Недавно у нас в команде зашла дискуссия о неопределённом поведении (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] В этой ситуации я заинтересовался, а может ли инструмент по собственному усмотрению прервать компиляцию, если сможет доказать, что в коде есть неопределённое поведение, и что оно будет исполнено. По этому поводу ведутся некоторые дискуссии, но однозначного ответа на мой вопрос я не нашёл. Конечно, могу представить, что они этого не делают просто по той причине, что большинство программ в иной ситуации вообще не скомпилируется.

Комментарии (10)


  1. Panzerschrek
    09.07.2024 19:07

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


  1. VladD-exrabbit
    09.07.2024 19:07
    +1

    Например, если взять отравленное значение и проделать с ним and 0, можно предположить, что результат всегда будет 0

    Нет, нельзя. UB — это UB всегда. «Значение», полученное в результате UB, имеет право быть сильно отравленным, например, как описано тут, и убить ваш процесс.


    1. me21
      09.07.2024 19:07

      Почему нельзя? UB на то и UB, что компилятор может делать всё, что угодно, в том числе считать результат всегда 0. То есть, можно предположить, что результат всегда 0, а можно не предполагать :) как компилятор захочет.


      1. VladD-exrabbit
        09.07.2024 19:07
        +2

        Но нельзя исходить из того, что это сойдёт с рук.

        Вы придираетесь к словам. «Можно предположить...» в данном контексте означает «Мы имеем право считать что...». А Стандарт такого права не даёт.


  1. MinimumLaw
    09.07.2024 19:07
    +1

    Довольно неудивительно, что gcc упорствует с подходом, при котором обрушивается программа ... clang компилирует оба вывода, как до, так и после деления, просто удаляя саму операцию деления

    Отчасти по этой причине именно gcc до сих пор является основным компилятором для ядра и остальных низкоуровневых интерфейсов. GCC верит программисту, даже если он откровенно врет. CLANG же пытается доказать себе и окружающим, что он умнее.

    Вообще все указанные примеры UB - это по сути и UB как таковое. Это просто откровенные ошибки и недостаточные проверки входных данных. Да, "самые умные" типа Rust'а их выловят еще на этапе компиляции. И в этом есть своя правда. Но править код, написанный программистом - это за гранью. А если падение программы это штатное поведение? Если я делю ноль целенаправленно? Допустим чтоб по завершении послать соответствующий сигнал родителю? Ну да - немного извращенный вариант, но тем не менее.

    Настоящее неопределенное поведение - это что-то в стиле того, что должен возвращать malloc(0) - NULL или уникальный валидный указатель.


    1. DesertDragon
      09.07.2024 19:07
      +3

      А если это код теста, который проверяет перехват ошибок/исключений. А если это платформа на которой операция деления на ноль не ошибка и возвращает бесконечность. А если этот код будет потом изменяться на лету. Да миллион причин и полезных использований бывает у любого машинного кода.
      С каких пор компилятор по умолчанию ограничивает возможности по генерированию нужного мне результата/машинного кода. Контрибьюторы в gcc/clang любящие интерпретировать UB в стандарте во вред программисту, похоже заигрались. Если кто-то когда то пошутил, что UB означает, что программа может отформатировать жесткий диск, то не значит что это надо воплощать на практике специально.


      1. kekoz
        09.07.2024 19:07

        кто-то когда то пошутил, что UB означает, что программа может отформатировать жесткий диск

        Шутка неудачна по причине повального заблуждения, что “неопределённое поведение” — это о “моей” программе. А оно, вообще-то, о компиляторе. Поведение “моей” программы всегда абсолютно однозначно определено архитектурой целевой системы и средой исполнения.


    1. kekoz
      09.07.2024 19:07
      +1

      То, что стандарт позволяет разработчикам компиляторов делать их настолько “заумными”, не освобождает программиста от необходимости изучать опции управления оптимизатором :)

      Что до malloc(0), то это не “неопределённое поведение” (undefined behavior), а “поведение, определяемое реализацией” (implementation-defined behavior). Таким образом, обязано (по стандарту) быть документировано реализацией и прочитано программистом.


      1. MinimumLaw
        09.07.2024 19:07

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

        Токарь, помимо непосредственно токарки, должен уметь как затачивать резцы и много чего еще. Только вот всегда обидно оказаться в ситуации когда надо точить детали, а приходится точить те самые резцы. Крупные опции компилятора, типо оптимизаций под размер или под скорость уже стали почти бесполезными - их в обязательном порядке приходится уточнять. Либо pragma'ми либо ключами компиляции. Да, это прогресс и отменять его несколько глупо, но создается ощущение что именно сейчас маятник где-то в районе другого максимума и ситуация близка к абсурду. И ладно "монстры индустрии" типа GCC или CLANG, но ребята попроще, типа того же IAR C Compiler не всегда позволяют адекватно все отстроить (особенно из своего IDE).

        В целом очень хочется начать применять ключевое слово volatile к функциям. Но по большому счету ситуация совсем не так страшна, как ее принято описывать на Хабре. Как всегда - аккуратно, нож острый - можно порезаться. И да, пока этим инструментом овладеешь - руки порезаны будут обязательно. За то (если не бросишь) получишь чувство материала и хорошо развитую мелкую моторику.

        P.S.

        Да, кто о чем, а я все о своем - о Embedded. С прикладным софтом все может быть по другому. Я не в курсе - вне сферы моих интересов.


  1. nv13
    09.07.2024 19:07
    +1

    Известен случай, когда утонул какой то аппарат ВМС США по причине того, что оператор оставил пустым одно поле ввода на панели управления, по дефолту там оказался ноль и программа аппарата на него поделила и упала.

    Понятно, что разработчикам хотелось бы подобные ситуации списыывать на UB языка, компиляторы, операторов и пятницу 13-е, однако это ошибка алгоритма, как ни крути. Выход за пределы массива - туда же. Ну а уж переполнение при целочисленных вычислениях это то, что разработчик обязан учитывать изначально - либо усекать промежуточные результаты , либо расширять разрядность, либо ещё что.

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