Рэймонд Чен в октябрьском посте объяснил, зачем компилятор ставит в код ud2: так помечается место, куда программа не должна была дойти, например конец функции, которая должна вернуть значение, но где забыли return. Программа упала на ud2 — ищи логическую ошибку. Чен пишет про MSVC. Мне стало интересно обратное: если падения на ud2 нет, значит ли это, что забытого return тоже нет? Я прогнал один и тот же C+±код через 114 версий GCC и Clang для x86-64 на Compiler Explorer и запустил демонстрационную программу. Без оптимизации ud2 есть у Clang и у свежих GCC. На -O2 программа с пропущенным return у всех четырёх компиляторов в демо исполнила ветку, написанную для других входных данных, и завершилась с кодом 0, а функцию без единого return Clang 23.1 оставил пустой, и в демо выполнилась соседняя wipe_disk(). Успешное завершение такую ошибку не обнаруживает.
Что проверял
Две функции на C++, обе с ошибкой:
extern "C" int f_noret(int x) { if (x > 0) return side(x); } // нет return для x <= 0extern "C" int f_empty(int x) { (void)x; } // нет return вообще
По стандарту C++ ([stmt.return]) выход за конец такой функции — неопределённое поведение всегда, даже если результат никто не читает. В C иначе: поведение не определено, только если вызывающий использует значение. Поэтому Clang ставит ловушку только в C++, это видно прямо в его исходнике, в CodeGenFunction.cpp. В C в пробе на 16 версиях обоих компиляторов я ловушек не нашёл ни на -O0, ни на -O2, так что дальше речь только о C++.
В 2020 году на Хабре уже разбирали забытый return в статье «Исследование одного неопределённого поведения»: там Clang 10 на -O0 падал на ud2, а на -O1 и выше и он, и GCC 9.3 выбрасывали вызов целиком. Там была одна версия каждого компилятора и не проверялись флаги, которые возвращают падение. Мне нужна была картина по всем версиям.
Исходники ушли в API Compiler Explorer на все версии x86-64: GCC 4.5.3–16.2 (69 штук) и Clang 3.0–23.1 (45). Ещё четыре GCC, от 3.4.6 до 4.4.7, тестовый файл не собрали: в нём есть __builtin_unreachable, которого они не знают. В подсчёт они не вошли. Для каждой версии я разбирал ассемблер: есть ли ud2, осталась ли проверка x > 0, есть ли в теле хоть одна инструкция.
Таблица по версиям
Что в коде
-O0
-O2
f_noret, GCC 4.5–7.5
ret, в eax мусор
ret, в eax мусор
f_noret, GCC 8.1–12.5
ret, в eax мусор
проверки нет, один jmp side
f_noret, GCC 13.1–16.2
ud2
проверки нет, один jmp side
f_noret, Clang 3.2–23.1
ud2
проверки нет, один jmp side
f_empty, GCC 4.5–7.5
—
ret, в eax мусор
f_empty, GCC 8.1–15.3
—
пустое тело, ноль инструкций
f_empty, GCC 16.1–16.2
—
ud2
f_empty, Clang 3.2–23.1
—
пустое тело, ноль инструкций
Clang 3.0 и 3.1 ведут себя как старый GCC: ret и мусор на обоих уровнях.
jmp side означает, что при x <= 0 функция всё равно вызывает side(x), то есть исполняет ветку, написанную для положительных чисел. С точки зрения компилятора это законно: путь без return не может случиться, значит, условие всегда истинно и проверять его незачем.
Пустое тело опаснее. Метка функции есть, инструкций за ней нет, и процессор, попав на неё, продолжит с того, что линкер положил следом.
Что исполняется на самом деле
Ассемблер — это ещё не поведение, поэтому я собрал маленькую программу. В исходнике сразу за f_empty лежит функция с заметным побочным эффектом:
extern "C" __attribute__((noinline)) int side(int x) { std::printf("side(%d) ", x); return x; }extern "C" __attribute__((noinline)) int f_noret(int x) { if (x > 0) return side(x); }extern "C" __attribute__((noinline)) int f_empty(int x) { (void)x; }extern "C" __attribute__((noinline, used)) int wipe_disk(int x) { std::printf("WIPE_DISK(%d)! ", x); return 7; }int main(int argc, char** argv) { int (*volatile fp)(int) = argv[1][0] == 'n' ? f_noret : f_empty; int r = fp(std::atoi(argv[2])); std::printf("returned %dn", r); return 0;}
Указатель volatile, чтобы компилятор не мог ничего вывести из места вызова. GCC 15.3 я запускал локально в образе gcc:15, GCC 16.2, Clang 23.1 и Clang 19.1 через исполнение на Compiler Explorer.
./demo n -1 ./demo e -1GCC 15.3 -O0 SIGILL SIGILLGCC 16.2 -O0 SIGILL SIGILLClang 23.1 -O0 SIGILL SIGILLGCC 15.3 -O2 side(-1) returned -1, rc=0 SIGSEGVGCC 16.2 -O2 side(-1) returned -1, rc=0 SIGILLClang 23.1 -O2 side(-1) returned -1, rc=0 WIPE_DISK(-1)! returned 7, rc=0Clang 19.1 -O2 side(-1) returned -1, rc=0 WIPE_DISK(-1)! returned 7, rc=0
Без оптимизации все трое падают сразу, как в объяснении Чена. С -O2 случай n -1 у всех заканчивается штатно: программа выполнила ветку для положительных x и вернула код 0. Тест, который проверяет только код завершения, эту ошибку пропустит.
Clang на e -1 исполнил wipe_disk(), которую никто не вызывал, и программа тоже штатно завершилась. В этом демо выполнилась соседняя функция; что выполнится в другой программе, зависит от того, как линкер разместил код.
Откуда взялся SIGSEGV
Сначала это выглядело как защита. Оказалось, нет. GCC 15.3 на -O2 кладёт пустую f_empty в секцию .text.unlikely с нулевым размером, а main в .text.startup. Линкер ставит эти секции подряд, и в собранном бинарнике у f_empty и main один и тот же адрес:
0000000000401050 T f_empty0000000000401050 T main
Вызов f_empty(-1) на деле запускает main заново, с аргументом -1 вместо argc и случайным содержимым регистра rsi вместо argv. По дизассемблеру первое, что делает main, — читает argv[1] по адресу из rsi. Точную сбойную инструкцию я не снимал, но других обращений к памяти до этого места нет. Чужой код исполнился и здесь, а упало потому, что соседом оказалась main, которая сразу лезет в аргументы. Окажись соседом функция, которой аргументы не нужны, она бы, скорее всего, отработала, как wipe_disk() у Clang, но этот вариант я не проверял.
Флаги, которые ловят пропущенный return на -O2
Ловушку на -O2 можно вернуть, но не любым флагом, который на это похож.
Флаг
n -1
e -1
GCC 13+: -funreachable-traps
SIGILL
SIGILL
GCC: -fsanitize=return -fsanitize-undefined-trap-on-error
SIGILL
SIGILL
Clang: -fsanitize=return -fsanitize-trap=return
SIGILL
SIGILL
Clang: -mllvm -trap-unreachable
side(-1), rc=0
SIGILL
С -mllvm -trap-unreachable Clang ставит ud2 в пустое тело, но проверку x > 0 оптимизатор выбрасывает раньше, и ловить уже нечего.
Ловушка GCC на -O0 — это и есть -funreachable-traps, включённый по умолчанию: с -fno-unreachable-traps GCC 13–16 возвращается к ret с мусором. В GCC 12.5 такой опции нет, компилятор её не узнаёт. Отсюда граница между 12 и 13 в таблице. У Clang ловушку на -O0 отключает -fno-strict-return, тогда и там остаётся ret с мусором.
Что из этого следует
Совет Чена работает в одну сторону: упала программа на ud2 — ошибка есть. Обратное неверно. На -O2 в функции с условием ловушки нет ни у одной из 114 версий: старые версии возвращают мусор, а GCC с 8.1 и Clang с 3.2 удаляют проверку, и в демо все четыре компилятора исполнили не ту ветку и завершились с кодом 0. Функция без единого return у большинства версий остаётся без инструкций, и исполняется соседний код. Ловушку на -O2 по умолчанию ставит только GCC 16, и только в функцию без единого return.
Дешевле всего не дать такой функции скомпилироваться: -Werror=return-type. Если предупреждение отключено или код чужой, в сборку с -O2 стоит добавить -funreachable-traps для GCC 13+ или -fsanitize=return -fsanitize-trap=return для Clang. Тогда программа, дойдя до конца такой функции, завершится с SIGILL.
Окружение: GCC 4.5.3–16.2 и Clang 3.0–23.1 на Compiler Explorer (API компиляции и исполнения, ноябрь 2026), локально gcc:15 (GCC 15.3.0), x86-64, Linux. MSVC не проверял: к нему тезис Чена, возможно, применим целиком.
Источник: habr.com