Skip to content

Latest commit

 

History

History
308 lines (230 loc) · 20.9 KB

odr_violation.md

File metadata and controls

308 lines (230 loc) · 20.9 KB

Нарушение 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