Pull to refresh

UB или не UB – вот в чём вопрос: как gcc и clang обрабатывают статически известное неопределённое поведение

Reading time7 min
Views3.2K
Original author: Lukas Diekmann

Недавно у нас в команде зашла дискуссия о неопределённом поведении (UB) в C. Напомню для тех, кто не знает: если мы пишем такой код, эффект от выполнения которого (и события в процессе его выполнения) строго не определён в спецификации языка, то возникает неопределённое поведение. Таким образом, встретив такой код, компилятор может действовать по собственному усмотрению, и нет никаких гарантий, что выполнение этого кода пойдёт по предсказуемому пути. Следовательно, нужно избегать неопределённого поведения любой ценой, поскольку мало того, что оно может приводить к глюкам программы, но и часто становится источником уязвимостей и угрозой безопасности. Примеры кода, в котором проявляется неопределённое поведение: выход за границы массива при его индексировании, целочисленное переполнение, деление на ноль, разыменование указателя на null [1].

Компиляторы нередко пользуются неопределённой семантикой языка, чтобы делать те или иные допущения о программе. Например, если написать что-то вроде int x = y/z, компилятор может предположить, что z не может быть равно нулю, так как деление на ноль приводит к неопределённому поведению, а программист явно не собирался писать такой код. На основе этой информации он может попытаться далее оптимизировать программу так:

Программа

int main(int argc) {
  int div = 5 / argc;
  if (argc == 0) {
      printf("A\n");
  } else {
    printf("B\n");
  }
  return div;
}

gcc -O2

.LC0:
    .string "A"
.LC1:
    .string "B"
main:
    mov     eax, 5
    xor     edx, edx
    push    rbx
    idiv    edi
    mov     ebx, eax
    test    edi, edi
    jne     .L2
    mov     edi, OFFSET FLAT:.LC0
    call    puts
.L1:
    mov     eax, ebx
    pop     rbx
    ret
.L2:
    mov     edi, OFFSET FLAT:.LC1
    call    puts
    jmp     .L1

clang -O2

main:
    push    rbx
    mov     ebx, edi
    lea     rdi, [rip + .Lstr]
    call    puts@PLT
    mov     eax, 5
    xor     edx, edx
    idiv    ebx
    pop     rbx
    ret
.Lstr:
    .asciz  "B"

Как показано в данном примере, clang опирается на тот факт, что деление на ноль — это неопределённое поведение. Соответственно, argc ни в коем случае не может быть равен нулю. Соответственно, условие if (argc == 0) полностью исключается, поскольку известно, что такой случай никогда не произойдёт [2].

Статически известное неопределённое поведение

Да, я знал, что компиляторы могут умно оптимизировать программу, если исходят из того, что неопределённого поведения в ней не может существовать. Но я интересовался, что делает компилятор, если  статически обнаруживает в программе неопределённое поведение — иными словами, когда мы вынуждаем компилятор скомпилировать такой код, в котором точно содержится неопределённое поведение (и об этом знаем и мы, и компилятор). Я так хотел найти повод, чтобы попользоваться Compiler Explorer, я наскоро провёл несколько экспериментов. Для многих из вас их результаты будут неудивительны (а эти эксперименты, даже если заслуживают такого названия, в любом случае не являются исчерпывающими), но своё любопытство я удовлетворил. Поэтому я решил ими здесь поделиться — надеюсь, и мои читатели смогут извлечь из них что-нибудь полезное.

Нужен гол ноль

Простейшая программа, которую мне удалось придумать, провоцирует неопределённое поведение в C, принудительно деля константу на ноль. Ниже приведены программа и её вывод, выдаваемый gcc (v14.1) и clang (v18.1), компилируемый в x86_64:

Программа

int main(int argc) {
  int ub = argc / 0;
  return ub;
}

gcc -O2

main:
    ud2

clang -O2

main:
    ret

В ходе компиляции как gcc, так и clang выдают предупреждение:

:2:17: warning: division by zero [-Wdiv-by-zero]
    2 |     int ub = argc / 0;
      |                   ^

Правда, тогда как gcc скомпилировал эту программу до состояния единственной (недопустимой) инструкции ud2, clang редуцировал её до ret. При неопределённом поведении оба подхода допустимы, однако они очень разные: один подход обрушивает программу, а другой игнорирует проблематичный код [3].

Что если немного изменить программу, заменив в операции деления константу на переменную?

Программа

int main(int argc) {
    int i = 0;
    int ub = argc / i;
    return ub;
}

gcc -O2 -Wall

main:
    ud2

clang -O2 -Wall

main:
    ret

Притом, что в скомпилированном виде обе программы не изменились, мы более не получаем предпреждения (даже с -Wall), пусть даже оба компилятора легко могут статистически (например, путём свёртки констант) выяснить, что в программе происходит деление на ноль [4].

Никаких гарантий

Давайте добавим перед делением на ноль ещё несколько строк и посмотрим, как это повлияет на вывод:

Программа

int main(int argc) {
    int i = 0;
    printf("before");
    int ub = argc / i;
    printf("%d", ub);
    return ub;
}

gcc -O2

main:
    sub     rsp, 8
    mov     edi, OFFSET FLAT:.LC0
    xor     eax, eax
    call    printf
    ud2

clang -O2

main:
    push    rax
    lea     rdi, [rip + .L.str]
    xor     eax, eax
    call    printf@PLT
    lea     rdi, [rip + .L.str.1]
    xor     eax, eax
    pop     rcx
    jmp     printf@PLT

Довольно неудивительно, что gcc упорствует с подходом, при котором обрушивается программа. Правда, отметим, что аварийное завершение он вставляет только после того, как скомпилирует деление на ноль, не ранее — например, не в начале функции. В свою очередь, clang компилирует оба вывода, как до, так и после деления, просто удаляя саму операцию деления. Как и в случае с кодом, содержащим деление на ноль, не даётся никаких гарантий и относительно кода, приводящего к этой операции. Просто по факту наличия неопределённого поведения в программе никакие правила не действуют, и компилятор на своё усмотрение может обрушить функцию сразу же после того, как войдёт в неё. [5].

Если в программе существует неопределённое поведение, но никто его не использует, то заметны ли его отголоски?

Учитывают ли компиляторы такой код, в котором заложено неопределённое поведение, но который ни разу не используется в программе? Здесь вспоминается философский вопрос — Слышен ли звук падающего дерева в лесу, если рядом никого нет? Давайте попробуем разобраться:

Программа

int main(int argc) {
    int i = 0;
    int ub = argc / i;
    return 1;
}

gcc -O2

main:
     mov     eax, 1
     ret

clang -O2

main:
     mov     eax, 1
     ret

Как видим, ответ на этот вопрос утвердительный, и теперь оба компилятора в результате оптимизации удалили операцию деления. Скорее всего, если бы в программе применялось отсечение мёртвого кода, то операции деления были бы удалены ещё до того, как компилятор успеет определить, что это неопределённое поведение. Опять же, важно понимать, что компиляторы сами выбирают такую стратегию (и только если мы включим оптимизации, в противном случае деление компилируется как есть). Даже если неопределённое поведение «не используется», это не означает, что программа не содержит неопределённого поведения. Нам просто «повезло», что компилятор удалил мёртвый код ещё до того, как выяснил, что это неопределённое поведение. Не гарантируется, что с другими компиляторами будет так же, равно как не гарантируется, что это поведение будет непротиворечиво соблюдаться между версиями компиляторов. Оно с тем же успехом могло бы обрушить программу или открыть CD-дисковод.

Это значение — отрава

Итак, осталось ответить на два вопроса: 1) почему мы зачастую не получаем в программе предупреждений о неопределённом поведении, даже если компилятор смог до него докопаться и 2) почему clang (и иногда gcc) мягко относятся к обработке неопределённого поведения. Почему они компилируют (и выполняют) код, а не приводят к его аварийному завершению (например, не вставляют в него недопустимую инструкцию)?

Ответы на оба вопроса даются в посте Криса Латтнера. По поводу предупреждений Латтнер объясняет, что зачастую компилятор мог бы выдавать предупреждения в таком количестве, что от него пропала бы всякая польза (при этом он выдавал бы множество ложноположительных результатов). Кроме того, сложно определить, кто хотел бы и кто не хотел бы получать столько предупреждений (например, никого не волнует неопределённое поведение в мёртвом коде). Что касается мягкости проверок, в особенности в вышеприведённых программах, Латтнер хорошо охарактеризовал их в следующем тезисе из своего поста:   

«Считается, что арифметические операции над неопределёнными значениями результируют в неопределённые значения, а не в неопределённые поведения. Разница в том, что неопределённое значение не отформатирует вам жёсткий диск и не даст других нежелательных эффектов».

В наше время LLVM использует преимущественно «отравленные» значения, которые открывают путь к более разнообразным оптимизациям, нежели просто ‘undef’, но идея всё та же: сам факт, что значение получено в результате неопределённого поведения, ещё не означает, что следует немедленно инвалидировать любой использующий его код. Например, если взять отравленное значение и проделать с ним and 0, можно предположить, что результат всегда будет 0, независимо от того, каково именно данное отравленное значение.

Это целесообразно, например, в случае, когда результат неопределённой операции не влияет на исполнение оставшейся части программы, как показано в следующем примере:

Программа

int main(int argc) {
  int i = 0;
  // Разыменование нулевого указателя
  int ub = *(int*)i;
  int p = ub | 1;
  printf("print");
  if (p) {
      printf("%d", ub);
  }
  return 1;
}

gcc -O2

main:
    mov     eax, DWORD PTR ds:0
    ud2

clang -O2

main:
    push    rax
    lea     rdi, [rip + .L.str]
    xor     eax, eax
    call    printf@PLT
    lea     rdi, [rip + .L.str.1]
    xor     eax, eax
    call    printf@PLT
    mov     eax, 1
    pop     rcx
    ret
.L.str:
    .asciz  "print"

.L.str.1:
    .asciz  "%d"

Заключение

Поскольку побитовое or с ненулевым значением всегда результирует в true, условие if всегда будет выполняться успешно, независимо от конкретного значения ub. В LLVM арифметическая операция над отравленными значениями не обязательно даёт отравленное значение в результате. Здесь компилятор свободно может избавиться от рассмотренного условия. С другой стороны, Gcc отделался ud2 сразу же, как только заметил разыменование нулевого указателя.

Благодарности: Спасибо Эдду Барретту и Лоуренсу Тратту за комментарии.

Примечания

[1] Притом, что приведённые примеры кажутся совершенно очевидными, есть и более сложные и трудноразрешимые случаи неопределённого поведения.

[2] Готовя этот пример, я был совершенно уверен, что gcc сделает то же самое, и удивился, когда этого не случилось. Ситуация сама по себе интересная, но выходит за рамки этой статьи.

[3] Последний случай может повлечь, а может и не повлечь неприятные последствия, в зависимости от того, как именно это значение будет использоваться после возврата (а в описываемый момент оно, как бы то ни было, окажется в RAX-регистре).

[4] Можно заставить как gcc, так и clang выдавать ошибки во время выполнения, поставив опцию -fsanitize=integer-divide-by-zero. Но это негативно скажется на производительности, а в остальном никак не изменит программу: gcc всё равно аварийно завершается с ud2, а clang игнорирует операцию деления.

[5] В этой ситуации я заинтересовался, а может ли инструмент по собственному усмотрению прервать компиляцию, если сможет доказать, что в коде есть неопределённое поведение, и что оно будет исполнено. По этому поводу ведутся некоторые дискуссии, но однозначного ответа на мой вопрос я не нашёл. Конечно, могу представить, что они этого не делают просто по той причине, что большинство программ в иной ситуации вообще не скомпилируется.

Tags:
Hubs:
+16
Comments13

Articles