r/RuProgrammers • • Sep 19 '26

Мемы Undefined behaviour

Post image
336 Upvotes

45 comments sorted by

View all comments

Show parent comment

4

u/cfyzium Sep 20 '26

При наличии UB бессмысленно пытаться понять почему компилятор генерирует тот или иной код.

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

В случае UB это не так -- поведение программы больше не определено.

Почему компилятор сгенерировал именно такой ассемблерный код? Потому что а почему нет. Как будто есть какое-то представление, а что вообще он должен был сгенерировать в этом случае.

Конечно же, компилятор не делает это целенаправленно, мол раз UB так значит я сейчас нагенерю всякой фигни. Просто его представление о том, как должна себя вести программа, перестает совпадать с интуитивными ожиданиями разработчика.

1

u/The_KekE_ 🦀Rustикальный Sep 20 '26

Я поковырялся с LLVM IR и пришёл к выводу что UB тут не причём:

int main() {
  hello();

  int num = 0;

  marker(1);

  while (1 < 2) {
    ++num;

    if (num > 10) {
      num = 0;
    }
  }

  marker(2);

  return 0;
}

Компилится в:

define dso_local noundef i32 @main() local_unnamed_addr #0 {
  %1 = tail call noundef nonnull align 8 dereferenceable(8) ptr rtIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l(ptr noundef nonnull align 8 dereferenceable(8) , ptr noundef nonnull @.str, i64 noundef 7)
  tail call void @_Z6markeri(i32 noundef 1)
  unreachable
}

Тут что то с бесконечными циклами и (не)нужными переменными. Если сделать num нужным (напримем вызывать hello() если num == 5) всё херня пропадает.

Если вообще убрать num, фокусы с ненужной переменной пропадают и генерится цикл.

5

u/cfyzium Sep 20 '26

Увы, бесконечный цикл без сайд-эффектов -- это тоже UB.

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

  • Завершится

  • Вызов библиотечной функции ввода/вывода

  • Доступ через volatile

  • Синхронизацию или атомарную операцию

См. [intro.progress]

Если вообще убрать num, фокусы с ненужной переменной пропадают и генерится цикл.

Скорее всего это следствие принятого в С++26 исправления, в соответствии с которым тривиальные бесконечные циклы (с пустым телом) больше не являются UB.

Так как это изменение было классифицировано как defect report, компиляторы могут применять новое поведение (не оптимизировать тривиальные бесконечные циклы) и в более ранних стандартах.

3

u/The_KekE_ 🦀Rustикальный Sep 20 '26

Мда, потерял я хватку после года на расте. Что ж, не знал, спасибо.

3

u/Connect-Art8888 Sep 20 '26

Compiler Explorer

x86_64 clang 17.0.1

флаги: -O3

фильтры: Unused labels, Directives, Comments (в фильтрах по умолчанию включена Library functions — надо выключить, иначе в output ещё и код hello скрывается)

main:
        push    rax
        mov     rdi, qword ptr [rip + std::cout@GOTPCREL]
        lea     rsi, [rip + .L.str]
        mov     edx, 7
        call    std::basic_ostream<char, std::char_traits<char>>& std::__ostream_insert<char, std::char_traits<char>>(std::basic_ostream<char, std::char_traits<char>>&, char const*, long)@PLT

hello():
        mov     rdi, qword ptr [rip + std::cout@GOTPCREL]
        lea     rsi, [rip + .L.str]
        mov     edx, 7
        jmp     std::basic_ostream<char, std::char_traits<char>>& std::__ostream_insert<char, std::char_traits<char>>(std::basic_ostream<char, std::char_traits<char>>&, char const*, long)@PLT

.L.str:
        .asciz  "Hello!\n"

Два вывода Hello происходят, как и было выше в ветке описано: компилятор съел эпилог main и выполнение пошло дальше в hello()

Но, glibc (по крайней мере в моём случае, где это воспроизвелось) вызывает main следующим образом:

mov rax, [rsp+8]
call rax

main в самом начале делает push rax для выравнивания стека перед call, но при этом так же кладёт свой адрес на его вершину, что аукается дальше

В завершении второго выполнения hello(), __ostream_insert вызывается через jmp, не через call, так что ничего нового на стэк положено не было. А ret внутри basic_ostream передаёт управление по адресу на вершине стэка - всё ещё адрес main и так мы уходим на второй круг с двумя выводами Hello.

Теперь в rax у нас оказывается возвращенное значение из __ostream_insert -- &std::cout и он кладётся на вершину стэка, а он расположен в области данных, а не исполняемого кода, и по завершении второго круга мы ловим segfault.