PunchI funded PDP, Obasanjo’s travels — Orji KaluInquirerWATCH: Sara Duterte impeachment trial | Nov. 10, 2026The Jerusalem PostThe Hakeem Jeffries moment is great for the Jews - opinionZDF heuteEntdecken Sie das ZDF-NachrichtenstudioCNN TürkBeylikdüzü’nde D-100 Karayolu'nda 09.05Khaosod EnglishThai student earns NASA recognition after uncovering security flawThe Sydney Morning HeraldMark Bombara’s brain injuries unlikely to have led to Floreat double-murder, inquest toldIl Fatto QuotidianoAbu Dhabi, al via i colloqui tra Russia, Usa e Ucraina. Trump: “Alta propensione di Mosca e Kiev alla fine della guerra”RTL BoulevardFamilie Blom na zeven seizoenen weg uit Een huis vol: kijkers gaan vader Maarten missenObservador DesportoBombeiros combatem incêndio de grande proporção no Barreiro20 MinutenMordfall Fabian (8): Um 12.30 Uhr fällt das Urteil über Gina H.NotJustOkWhat is the update on Michael Carrick's future at Manchester United?
The Daily Newsstand · Free, Always
Tuesday, November 10, 2026

Что исполняет C++-программа при пропущенном return после сборки с -O2

Translate

Рэймонд Чен в октябрьском посте объяснил, зачем компилятор ставит в код 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 <= 0
extern "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 %d\n", 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 -1
GCC 15.3  -O0       SIGILL                      SIGILL
GCC 16.2  -O0       SIGILL                      SIGILL
Clang 23.1 -O0      SIGILL                      SIGILL
GCC 15.3  -O2       side(-1) returned -1, rc=0  SIGSEGV
GCC 16.2  -O2       side(-1) returned -1, rc=0  SIGILL
Clang 23.1 -O2      side(-1) returned -1, rc=0  WIPE_DISK(-1)! returned 7, rc=0
Clang 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_empty
0000000000401050 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 не проверял: к нему тезис Чена, возможно, применим целиком.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.