Привет, Хабр!

Код проверяет, не переполнилось ли сложение, и при переполнении отказывается работать дальше. Проверка написана, прошла ревью, покрыта тестом на маленьких значениях. В отладочной сборке всё срабатывает.

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

Формально компилятор прав, а бинарник тем временем принимает значение, от которого его защищали.

Разберём, как оптимизатор приходит к таким выводам, какие конструкции провоцируют его чаще всего и что делать, кроме как надеяться, что пронесёт.

Компилятор рассуждает от противного

Стандарт языка делит поведение на несколько категорий.

  • Определённое — работает одинаково везде.

  • Определяемое реализацией — работает по‑разному, но компилятор обязан задокументировать как.

  • И неопределённое — стандарт не накладывает вообще никаких требований, программа может делать что угодно.

Оптимизатор трактует последнюю категорию буквально и с выгодой для себя: раз программа с неопределённым поведением ничего не обязана, значит корректная программа его не содержит.

А если не содержит, то любое условие, истинное только при неопределённом поведении, ложно всегда — и его можно выбросить.

Посмотрим:

#include <limits.h>
#include <stdio.h>

int safe_add(int a, int b) {
    int sum = a + b;
    if (sum < a) {              // проверка на переполнение
        return -1;
    }
    return sum;
}

int main(void) {
    printf("%d\n", safe_add(INT_MAX, 1));
    return 0;
}

Собираем без оптимизаций и с ними:

gcc -O0 ub.c -o ub0 && ./ub0
gcc -O2 ub.c -o ub2 && ./ub2
-1
-2147483648

Одна и та же программа, разные ответы. В оптимизированной сборке проверка не сработала, потому что её там больше нет:

gcc -O2 -S -masm=intel -o - ub.c | grep -A6 'safe_add:'
safe_add:
        lea     eax, [rdi+rsi]
        ret

Три инструкции: сложить, вернуть. Ни сравнения, ни перехода, ни ветки с минус единицей.

Рассуждение компилятора здесь такое:

  • Если b неотрицательное, то a + b не может оказаться меньше a без переполнения.

  • Если b отрицательное — тоже разбирается отдельно и тоже сводится к переполнению. Переполнения не бывает по определению, значит sum < a ложно, значит ветка мёртвая.

Обратите внимание, что для беззнаковых типов такой номер не проходит, их переполнение стандарт определяет как заворачивание по модулю, и компилятор обязан его сохранить. Тот же код на unsigned int собирается вместе с проверкой.

История, из‑за которой это обсуждают

В январе 2007 года Феликс фон Ляйтнер завёл в багтрекере GCC отчёт с заголовком: проверка assert(a + 100 > a) оптимизируется в ничто. Обнаружил он это в собственном патче к GnuPG.

Пример из отчёта:

#include <assert.h>
#include <stdio.h>

int foo(int a) {
    assert(a + 100 > a);
    printf("%d %d\n", a + 100, a);
    return a;
}

int main(void) {
    foo(100);
    foo(0x7fffffff);      // здесь проверка обязана сработать
}
200 100
-2147483549 2147483647

Второй вызов переполняет, проверка молчит. Причём assert тут ничем не отличается от обычного if, макрос разворачивается в него же, так что заменой на ручную проверку ничего не выиграть.

Отчёт закрыли со статусом «не будет исправлено», и обсуждение вышло за пределы багтрекера, выяснилось, что именно так написана значительная часть кода, отвечающего за безопасность.

Программисты проверяли переполнение самым естественным способом — сравнением результата с операндом, — потому что на всех знакомых машинах оно заворачивалось. Спор тянулся годами.

Разработчики компилятора остались при своём, потому что стандарт на их стороне. Мир пришёл к другому решению — просто перестал полагаться на неопределённое поведение там, где цена ошибки высока.

Ядро Linux, например, много лет собирается с флагом, который запрещает компилятору строить оптимизации на предположении об отсутствии переполнения:

gcc -fno-strict-overflow ...

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

Два указателя, которые компилятор считает разными

Второе неопределённое поведение из числа тех, что портят жизнь чаще всего, — нарушение правил доступа к памяти через указатели разных типов.

Правило гласит: обращаться к объекту через указатель несовместимого типа нельзя. Компилятор из этого делает вывод, что int* и float* никогда не указывают на одну память, и на этом основании переставляет чтения и записи местами.

#include <stdio.h>

float reinterpret(int value) {
    int *ip = &value;
    float *fp = (float *) ip;      // нарушение правила
    *fp = 1.0f;
    return *(float *) ip;
}

Компилятор здесь вправе считать, что запись через fp не влияет на то, что лежит по ip, и порядок операций может оказаться любым.

Проверить, страдает ли ваш код от этого, можно так:

gcc -O2 -Wstrict-aliasing=2 -fstrict-aliasing alias.c
alias.c:6:20: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]

Правильный способ переинтерпретировать биты — не приведение указателей, а копирование памяти. Компилятор такую конструкцию распознаёт и оптимизирует до одной инструкции, так что за корректность платить производительностью не приходится:

#include <string.h>

float bits_to_float(int value) {
    float result;
    memcpy(&result, &value, sizeof result);
    return result;
}

Еще есть лазейка — объединение. Чтение поля объединения, которое не записывали последним, стандарт разрешает как определяемое реализацией, и все основные компиляторы это поддерживают:

union { int i; float f; } u = { .i = value };
return u.f;

В C++ такой приём формально неопределён.

Разыменование указателя

void process(struct config *cfg) {
    int timeout = cfg->timeout;      // разыменовали

    if (cfg == NULL) {               // а теперь проверяем
        return;
    }
    apply(timeout);
}

Порядок здесь важнее, чем кажется. Разыменование нулевого указателя — неопределённое поведение, значит к моменту первой строки cfg заведомо не равен нулю, значит проверка ниже бессмысленна и её можно убрать. Компилятор так и делает.

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

Так в 2009 году появилась уязвимость в ядре Linux, известная как CVE-2009-1897. В функции tun_chr_poll из драйвера drivers/net/tun.c присваивание стояло перед проверкой:

struct sock *sk = tun->sk;      // разыменовали
unsigned int mask = 0;

if (!tun)                        // проверка, которую компилятор удалил
    return POLLERR;

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

Исправление свелось к перестановке двух строк, а в сборку ядра добавили флаг, запрещающий такую оптимизацию:

gcc -fno-delete-null-pointer-checks ...

Правило простое: проверка идёт перед первым обращением, а не после.

Чтение неинициализированной переменной

Кажется, что чтение мусора — это чтение мусора, то есть какое‑то конкретное значение. Стандарт считает иначе, и компилятор пользуется этим.

#include <stdio.h>

int main(void) {
    int x;
    if (x == 0) {
        printf("ноль\n");
    }
    if (x != 0) {
        printf("не ноль\n");
    }
    return 0;
}

Программа может напечатать оба сообщения, ни одного или одно — на усмотрение оптимизатора. Переменная не обязана иметь стабильное значение между двумя чтениями, потому что она вообще не обязана иметь значение.

Ловится это как компилятором, так и инструментами времени выполнения:

gcc -O2 -Wall -Wextra -Wuninitialized uninit.c
uninit.c:5:8: warning: 'x' is used uninitialized [-Wuninitialized]

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

Ещё три места

Три конструкции ниже встречаются в обычном коде реже перечисленных, зато каждая ломается ровно тогда, когда её меньше всего ждут.

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

uint32_t mask(int bits) {
    return (1U << bits) - 1;      // при bits == 32 неопределённое поведение
}

Процессоры x86 берут по модулю ширины и вернут единицу вместо ожидаемого нуля, ARM в той же ситуации может вернуть ноль. Один и тот же код на двух архитектурах даёт разные ответы, и правильным с точки зрения стандарта не будет ни один. Безопасная запись выносит граничный случай отдельно:

uint32_t mask(int bits) {
    return bits >= 32 ? UINT32_MAX : (1U << bits) - 1;
}

Сравнение указателей на разные объекты тоже неопределено, и это удивляет тех, кто пишет проверки принадлежности буферу:

if (ptr >= buffer && ptr < buffer + size) {   // законно: один объект
    ...
}

if (ptr_a < ptr_b) {                          // неопределено, если объекты разные
    ...
}

Компилятор здесь вправе предполагать, что оба указателя относятся к одному массиву, и оптимизировать соответственно. Для сравнения адресов из разных источников их приводят к целочисленному типу uintptr_t.

А еще любой цикл без побочных эффектов, из которого невозможно выйти, стандарт разрешает считать завершающимся:

while (condition) { }       // компилятор вправе убрать цикл целиком

Цикл ожидания на переменной без пометки об изменчивости может быть выброшен, а вместе с ним и вся логика синхронизации. Фиксится это либо volatile, либо, что правильнее, атомарными операциями из stdatomic.h.

Если на каких‑то примерах выше пришлось остановиться и свериться со стандартом, можно заодно проверить себя по C целиком. Короткий вступительный тест покажет, какие темы уже закрыты, а где знания стоит подтянуть.

Как найти это в коде

До сих пор мы смотрели на короткие примеры, где ошибка видна. В коде она видна не бывает, и для поиска существует отдельный инструмент — санитайзер неопределённого поведения. Он вставляет проверки в момент компиляции и сообщает о нарушении во время выполнения.

gcc -fsanitize=undefined -fno-omit-frame-pointer -g -O1 app.c -o app
./app
app.c:5:15: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
app.c:22:9: runtime error: load of misaligned address 0x55d4f2a01003 for type 'int', which requires 4 byte alignment
app.c:31:5: runtime error: index 12 out of bounds for type 'int [10]'

Набор проверок довольно широкий: переполнение знаковых операций, деление на ноль и особый случай деления минимального значения на минус единицу, сдвиги за пределы разрядности, разыменование нулевого указателя, невыровненный доступ, выход за границы массива с известным размером.

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

gcc -fsanitize=undefined,float-cast-overflow,float-divide-by-zero ...

Санитайзер работает во время выполнения, а значит находит только то, до чего дошло исполнение.

Что делать, кроме как надеяться

Диагностика при сборке ловит значительную часть на этапе компиляции, и включать её стоит целиком:

gcc -O2 -Wall -Wextra -Wpedantic \
    -Wstrict-overflow=5 -Wstrict-aliasing=2 \
    -Wnull-dereference -Wuninitialized \
    -Wshadow -Wconversion \
    app.c

Санитайзеры находят то, что диагностика пропустила, и держать под них отдельную конфигурацию в CI — самое дешёвое, что можно сделать:

gcc -fsanitize=undefined,address -g -O1 -o app_ubsan app.c

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

Флаги, меняющие семантику языка:

gcc -fwrapv -fno-strict-aliasing ...

Первый делает знаковое переполнение заворачивающимся, второй запрещает оптимизации на основе правил алиасинга. Оба немного стоят по производительности и оба убирают целый пласт сюрпризов. Ядро Linux живёт с ними много лет.

Разговоры про неопределённое поведение обычно скатываются в спор о том, прав ли компилятор, а спор этот бессмысленный: он прав, и с каждой версией становится всё изобретательнее в применении своей правоты.

Чтобы увереннее работать с такими пограничными случаями, полезно понимать не только сам язык, но и то, как код взаимодействует с памятью, процессором и компилятором. На курсе «Программист C» этому уделено отдельное внимание: от стандартов C и устройства памяти до ассемблера и системного программирования под UNIX и Windows.

А начать можно с бесплатного открытого урока:

  • 24 сентября в 20:00. «Указатели в Си — от адреса к управлению памятью». Записаться
    Разберём адреса переменных, операторы & и *, объявление и работу с указателями/

Полный список открытых уроков на конец августа и начало сентября собрали в дайджесте.

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


  1. unreal_undead2
    28.08.2026 12:06

    В C++ такой приём формально неопределён.

    Там есть start_lifetime_as


    1. Janycz
      28.08.2026 12:06

      На данный момент std::start_lifetime_as немного кривой: он в некоторых компиляторах генерирует излишний код: https://godbolt.org/z/EfbMva5Te. Как видно из приведенного кода, лучше использовать std::bit_cast. Кроме того, std::bit_cast требует C++20, а не C++23.


  1. jbenderov
    28.08.2026 12:06

    Отличный разбор, редко кто доводит цепочку рассуждений оптимизатора до конца так наглядно. Вдобавок к -fwrapv и санитайзерам хочу подкинуть ещё один удобный инструмент из этой же оперы — checked-арифметику, она хорошо ложится рядом.

    В GCC и Clang есть __builtin_add_overflow(a, b, &res) и его собратья __builtin_sub_overflow и __builtin_mul_overflow. Каждый выполняет операцию, кладёт результат по указателю и возвращает флаг переполнения, а компилируется в аппаратную проверку соответствующего флага процессора — быстро и без UB. Приятно тем, что классическое if (a + b < a) для знаковых, которое оптимизатор и выкидывает, тут просто не нужно: неопределённого поведения в builtin нет, проверять нечего.

    В C23 это же завезли в стандарт — заголовок <stdckdint.h> и ckd_add(&res, a, b), уже переносимо, без привязки к конкретному компилятору.

    Про -fwrapv стоит держать в голове его цену: он делает знаковое переполнение определённым для всей единицы трансляции, то есть заодно приглушает оптимизации везде, а не только там, где нужна проверка. Точечный builtin в этом смысле дешевле — ничего вокруг не трогает. В общем, есть из чего выбрать под задачу.


  1. Haseo596
    28.08.2026 12:06

    #include <limits.h>
    #include <stdio.h>
    
    int safe_add(int a, int b) {
        int sum = a + b;
        if (sum < a) {              // проверка на переполнение
            return -1;
        }
        return sum;
    }
    
    int main(void) {
        printf("%d\n", safe_add(INT_MAX, 1));
        return 0;
    }

    Вы уверены, что это корректный пример? Если второй аргумент отрицательный, условие истинно и нет никакого переполнения.


    1. usrsse2
      28.08.2026 12:06

      Функция инлайнится, и компилятор видит, что второй аргумент положителен.


      1. Haseo596
        28.08.2026 12:06

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