Files
ubbook/runtime/odr_violation.md
2024-09-24 19:32:54 +01:00

21 KiB
Raw Permalink Blame History

Нарушение ODR (One definition rule)

Вызвать функцию, которая не должна вызываться. Испортить стек. Сломать проверенную временем стороннюю библиотеку. Довести до безумия программиста, пытающегося найти проблему под отладчиком — все может ODR violation!

Вполне естественное и понятное правило, действующее во многих языках программирования: у одной и той же сущности должно быть не больше одного определения. Наличие двух и более определений, например, для функции, различающихся поведением, тут же приводит к проблеме: а какое же использовать.

В некоторых языках неопределенности нет — например, в Python, каждое следующее определение перекрывает предыдущее:

# hello.py
def hello():
    print("hello world")
    
hello() # hello world 

def hello():
    print("Hello ODR!")
    
hello() # hello ODR!

В иных языках множественные определения просто приводят к ошибке компиляции

fun x y = x + y

gun x y = x - y

fun x y = x * y

main = print $ "Hello, world!" ++ (show $ fun 5 6)

--    Multiple declarations of fun
--    Declared at: 1124215805/source.hs:3:1
--                 1124215805/source.hs:7:1

С и С++ не исключения — в них переопределения функции, классов, шаблонов тоже диагностируются и выливаются в ошибку компиляции

int fun() {
    return 5;
}

int fun() { // CE: redefinition
    return 6;
}

И вроде бы все хорошо, ожидаемое, отличное решение. Но есть нюансы.

Для статического анализа, конечно, очень удобно, если весь ваш код живет в одном единственном файле. Но на практике обычно код разделяют на отдельные «модули», занимающиеся своей обособленной логикой. И вполне встречается ситуация, в которой два разных модуля содержат одноименные типы или функции. И это не должно вызывать проблем, должно работать из коробки... Но не в C/C++.

Знакомые с Python, наверное, знают, что в нем каждый отдельный файл — модуль — отдельное пространство имен. Имена классов и функции из разных файлов никак не интерферируют, до тех пор пока не будут импортированы.

В C никогда модулей не было и, скорее всего, не будет. Вместо них — раздельная компиляция, работающая на возможности оставлять сущности объявленными (например, в «подключаемых» заголовочных файлах), но не определенными (определение помещают в отдельную единицу трансляции, компилируемую независимо). Окончательная сборка и разрешение всех неопределенных имен откладывается до этапа линковки.

Никаких пространств имен также нет, и определение двух функций с одним и тем же именем в разных единицах трансляции — нарушает ODR и... почти наверняка не будет отловлено на этапе компиляции. Возможно, если вам повезет и вы не забыли настроить опции линковки, проблема будет выявлена на следующем этапе. А если же вам не повезет, вы попадете в цепкие лапы неопределенного поведения.

Наибольшую неприятность доставляет то, что проблема не ограничивается сборкой лишь вашего кода. Ведь вы можете случайно использовать какое-то имя, встречающееся в сторонней библиотеке! И тогда можно сломать эту библиотеку как в своем проекте, так и в чужом — если ваш код будет использоваться в качестве зависимости. Причем достаточно случайно угадать лишь имя функции: в С нет перегрузок функции и определение функции с тем же именем, но с другими аргументами — ODR violation.

Из-за всех этих проблем в стандартах C и С++ даже указаны ограничения на имена, которые вы можете использовать в своем коде, чтобы случайно не сломать стандартную библиотеку!

Что же делать?

В мире чистого C с этим борются комплексом методов:

  1. Ручной имплементацией механизма пространств имен — каждой функции и структуре в проекте дописывают префиксом имя проекта.
  2. Настраивают видимость символов:
    1. static делает функцию или глобальную переменную «не видимой» за пределами единицы трансляции.
    2. __attribute__((visibility("hidden"))) для приватных структур и функций
    3. флаги -fvisibility=hidden, -fvisibility-inlines-hidden и выставление атрибутов только для публичного интерфейса
  3. Пишут скрипты для линкера, если предыдущий пункт пропустил в итоговый бинарь что-то лишнее

Все это, возможно, спасет при интеграции с другими библиотеками. Но от переопределения ваших функций и структур внутри вашего же проекта — почти не помогает.


В C++ ситуация немного лучше.

Во-первых, есть перегрузки функций, типы аргументов участвуют в формировании имен, используемых при линковке, так что всего лишь угадать имя недостаточно, чтобы встрять в неприятности — нужно еще угадать аргументы (но не тип возвращаемого значения!)

Во-вторых, есть пространства имен, и вручную прописывать префиксы к каждой объявляемой функции не нужно.

В-третьих, есть анонимные пространства имен, позволяющие делать не видимым за пределами единицы трансляции, все, что определено внутри него:

// A.cpp

namespace {
    struct S {
        S() {
            std::cout << "Hello A!\n";
        }
    };
}

void fun_A() {
    S{};
}

// B.cpp

namespace {
    struct S {
        S() {
            std::cout << "Hello B!\n";
        }
    };
}

void fun_B() {
    S{};
}

Структуры S находятся в разных анонимных пространствах имен, проблем с нарушением ODR не возникает

У меня в проекте долгое время существовали два определения вспомогательной приватной структуры префиксного дерева. Но эти определения не было помещены в анонимное пространство имен. Всё прекрасно работало до тех пор, пока однажды не поменяли порядок компиляции файлов. И сразу SEGFAULT — в объявлениях были разные типы полей, и при тестировании происходило настоящее безумие. Хорошо, что нашлось раньше, чем упало на боевом стенде.

Наконец, в C++, начиная с 20 стандарта, появились модули. Приватные, явно не экспортируемые, имена внутри одного модуля не интерферируют с именами из других модулей. Но для экспортируемых имен все проблемы сохраняются — объявлять пространство имен, следить за пересечениями надо самостоятельно.


Вместе с возможностями чуть реже нарушать ODR, в C++, конечно же, есть дополнительные возможности для неявного нарушения ODR — шаблоны.

Шаблоны инстанциируются в каждой единице трансляции. И при использовании одних и тех же параметров должны раскрываться в один и тот же код — чтобы не нарушить ODR.

В C++ мы можем определять функции, принадлежащие к какому угодно пространству имен, совершенно в любой единице трансляции. А шаблоны компилируются в два прохода, с привлечением ADL (argument dependent lookup). И горе вам, если один из проходов вытянет разные функции!

struct A {};
struct B{};
struct D : B {};

// demo_1.cpp
bool operator<(A, B) { std::cout << "demo_1\n"; return true; }
void demo_1() { 
    A a; D d; std::less<void> comparator; 
    comparator(a, d); // шаблонный оператор () ищет подходящее определение для <
}

// demo_2.cpp
bool operator<(A, D) { std::cout << "demo_2\n"; return true; }
void demo_2() { A a; D d; std::less<void> comparator; comparator(a, d); }

int main() {
    demo_1();
    demo_2();
    return 0;
}

В этом примере (спасибо, LDVSOFT) разный порядок компиляции дает разные результаты:

Занятно то, что из-за специфики и трудности реализации двухэтапной компиляции шаблонов, разные компиляторы будут давать разный результат, если поместить этот пример в одну единицу трансляции! И о проблеме никто не сообщит!

Для упрощения анализа, печать строк заменена на печать чисел 1 и 2

GCC:

demo_1():
    mov     esi, 1
    mov     edi, OFFSET FLAT:_ZSt4cout
    jmp     std::basic_ostream<char, std::char_traits<char> >::operator<<(int)

demo_2():
    mov     esi, 1
    mov     edi, OFFSET FLAT:_ZSt4cout
    jmp     std::basic_ostream<char, std::char_traits<char> >::operator<<(int)

MSVC:

void demo_1(void) PROC                           ; demo_1, COMDAT
    push    2
    mov     ecx, OFFSET std::basic_ostream<char,std::char_traits<char> > std::cout ; std::cout
    call    std::basic_ostream<char,std::char_traits<char> > & std::basic_ostream<char,std::char_traits<char> >::operator<<(int) ; std::basic_ostream<char,std::char_traits<char> >::operator<<
    ret     0
void demo_1(void) ENDP  

void demo_2(void) PROC                           ; demo_2, COMDAT
    push    2
    mov     ecx, OFFSET std::basic_ostream<char,std::char_traits<char> > std::cout ; std::cout
    call    std::basic_ostream<char,std::char_traits<char> > & std::basic_ostream<char,std::char_traits<char> >::operator<<(int) ; std::basic_ostream<char,std::char_traits<char> >::operator<<
    ret     0

Код, собранный GCC печатает 11. MSVC — 22.

Страшно? Не бойтесь! Если в этом примере operator < действительно предполагался приватным, то заворачивание его в анонимное пространство имен решило бы проблему: std::less<void>::operator() его бы не нашел, вы бы получили ошибку компиляции (она бы вам не понравилась), пришлось бы использовать сравнение явно, и тут уже все определено.

Используйте модули или помещайте приватные кишки в анонимные пространства имен — и будет вам счастье. Наверное.


Если модули вам все еще не доступны, в мире C и C++ существует особый подход к организации процесса сборки проекта, который способен отлавливать ODR-violation — unity build. Но это скорее пробочный эффект. Сам же unity build прежде всего предназанчен для ускорения сборки за счет сокращения числа включений препроцессированных заголовков.

Например, если у вас есть два класса-компонента, разнесенные в независимые компилируемые файлы

// unit.cpp
#include <algorithm> 
struct Unit {};
// weapon.cpp
#include <algorithm>
struct Weapon {};

То при независимой компиляции этих файлов, каждый из них будет препроцессирован отдельно, а благодаря печально известному заголовку <algorithm> каждый препроцессированный файл выйдет в десятки тысяч строк величиной!

Например с GCC 13.2

g++ -E unit.cpp | wc -l
17710  # 17 тысяч строк!
g++ -E unit.cpp | wc -c
443562 # около 443 KB!

Подход unity build же состоит в том, чтоб предварительно включить все комрилируемые файлы в один общий

// unity_build.cpp
#include "unit.cpp"
#include "weapon.cpp"

И разумеется в таком случае, если мы нарушим ODR, использовав в разных файлах одно и то же имя для разных сущностей, мы получим ошибку компиляции — ведь теперь у нас один файл компилируется!

Однако стоит иметь в виду, что у unity build есть серьезные недостатки

  1. Этот подход может уменьшить время сборки с нуля, но значительно увеличить время пересборки при изменениях — нужно будет перекомпилировать всё!
  2. Вместо ODR violation можно внезапно получить выбор неправильных перегрузок и сопутствующие баги в логике!
// weapon.cpp
namespace {
double calculate_damage(double x) {
    return x * 10;
}
}
double Weapon::damage() {
    return calculate_damage(15.0);
}
// unit.cpp
namespace {
int calculate_damage(int x) {
    return x / 10;
}
}
void Unit::assign_damage(double x) {
    this->hp -= calculate_damage(x);
}

При включении файлов в порядке

#include "unit.cpp"
#include "weapon.cpp"

поведение метода Unit::assing_damage будет таким же как при независимой сборке.

Unit::assign_damage(double):
        ...
        mov     edi, eax
        call    (anonymous namespace)::calculate_damage(int)

Но при включении в другом порядке

#include "weapon.cpp"
#include "unit.cpp"

Перегрузка из анонимного пространства имен в файле weapon.cpp будет более подходящей и поведение поменяется!

Unit::assign_damage(double):
        ...
        movq    xmm0, rax
        call    (anonymous namespace)::calculate_damage(double)
     

ODR violation почти всегда ходит вместе с проблемами обновлений и слома ABI:

Вы обновили библиотеку, и теперь ваш код зависит от ее более новой версии — убедитесь, что другой код, зависящий от вашего, также использует новую версию этой библиотеки. Или хотя бы бинарно совместимую. Иначе — ODR violation, слом стека, нарушение конвенции вызова... ну, вы в курсе.

Слом ABI, потенциальное нарушение ODR — одни из самых острых причин, почему миграция на новые версии стандарта, компиляторов и библиотек в C++ мире занимают многие годы. Нужно все пересобрать. Все перетестить. Убедиться, что никто не привнес неправильных имен.


Как это ни парадоксально, но возможность нарушить ODR иногда оказывается полезной. Неопределенной поведение, с ним связанное, является в каком-то смысле определенным и контролируемым: какое из определений будет использоваться задается порядком, на который можно влиять. GCC, например, поддерживает __attribute__((weak)) для пометки функций, которые ожидаемо будут замещаться альтернативными определениями (с более эффективной реализацией, без отладочных инструкций, например). Или же техника symbol hooking, использующая LD_PRELOAD, чтобы заменить определенные функции из динамических библиотек: для отладки с инструментированным аллокатором или же для перехвата вызовов и сбора статистики.

Полезные ссылки

  1. https://en.cppreference.com/w/cpp/language/definition
  2. https://en.wikipedia.org/wiki/Weak_symbol
  3. https://liveoverflow.com/hooking-on-linux-with-ld_preload-pwn-adventure-3/
  4. https://gcc.gnu.org/wiki/Visibility