
Если бы Кардинал Ришелье был программистом, он бы сказал: «Дайте мне шесть строк кода, написанных рукой самого профессионального C-программиста в мире, и я найду в них лазейку для вызова неопределённого поведения.
Никто не может написать безошибочный код на С или C++. И я говоря об этом как человек, который пишет на этих языках почти каждый день около 30 лет. Я слушаю подкасты по C++. Я смотрю выступления про C++ на конференциях. Мне нравится читать и писать на этом языке.
C++ послужил нам сполна, но на дворе 2026 год, и современная рабочая среда явно отличается от среды 1985 (C++) или 1972 (С).
И я далеко не первый, кто об этом заговорил. Помню ещё с десять лет назад читал статью какого-то известного человека, в которой он утверждал, что использование C++ вполне обоснованно можно подвести под нарушение закона Сарбейнза-Оксли (SOX). И хотя с остальной его критикой я не был согласен (как и с тем, что он путал «its» и «it’s»), конкретно с этим пунктом я никогда не спорил.
Мало того, со временем я всё больше убеждался в его истинности. На деле в С для возникновения неопределённого поведения (undefined behaviour, UB) есть гораздо больше возможных причин, чем вы могли предполагать.
Все знают, что двойное освобождение памяти, её использование после освобождения, выход за границы объекта (например, массива) и чтение неинициализированной памяти — это UB. Как ни крути, но в контексте работы с памятью C и C++ безопасными не назовёшь. Тем не менее даже эти ошибки продолжают совершаться повсеместно раз за разом.
А ведь есть и другие, более тонкие и нелогичные.
Дело не в оптимизациях
Похоже, некоторые считают, что достаточно компилировать код без включения оптимизаций, и тогда UB не страшно. Они верят в какую-то изначальную враждебность компилятора, который только и думает — «Ага! Неопределённое поведение! Я могу делать всё, что захочу!» — и рассчитывают, что отключение оптимизаций его остановит.
Это не так.
UB не означает, что компилятор может воспользоваться вашей оплошностью. Оно означает, что компилятор может принять ваш код за корректный. Это говорит о том, что заложенное в код намерение, которое может быть столь очевидным для человека, невозможно выразить между этапами компиляции или модулями.
UB означает, что компилятору при генерации кода даже не нужно реализовывать некоторые особые случаи, потому что они попросту «не могут произойти».
Компилятор, да и само аппаратное обеспечение, играют в глухой телефон, пытаясь распознать ваши неопределённые намерения. В итоге вы, конечно, можете получить желаемый результат, но без каких-либо гарантий сейчас или в будущем.
UB повсюду
Я не стану пытаться перечислить все варианты неопределённого поведения, а просто аргументированно покажу, что оно повсюду. И если никому не под силу сделать всё идеально, то как вообще можно винить в этом программистов? Мой главный посыл в том, что ВЕСЬ нетривиальный код на С и C++ содержит в себе UB.
Обращение к объекту с неправильным выравниванием
Возьмём для примера такой код:
int foo(const int* p) {
return *p;
}
Если эту функцию вызвать с неправильно выровненным указателем (вероятно, с адресом, кратным sizeof(int), но кто знает), то это UB (пункт 6.3.2.3 стандарта C23).
На Linux Alpha это иногда приводило к вызову аппаратного исключения, которое ядро обрабатывало программно, эмулируя нужное вам поведение. В других случаях ваша программа могла просто упасть по сигналу SIGBUS.
На архитектуре SPARC это гарантированно приводило к SIGBUS.
Естественно, на x86/amd64 (далее просто x86) такой код проблем не вызовет. Да что тут говорить, это даже наверняка будет операцией атомарного чтения. Архитектура x86 известна своей крайней снисходительностью к ошибкам синхронизации кэша.
Итак, здесь мы имеем три случая:
-
помощь со стороны ядра (для некоторых инструкций чтения на Alpha);
-
сбой (другие инструкции чтения на Alpha и SPARC);
-
всё в порядке (x86).
А что с ARM, RISC-V и другими архитектурами? Что насчёт будущих? В какой-нибудь будущей архитектуре могут даже появиться специальные регистры для указателей на int, которые вообще не заполняют младшие биты, потому что таких указателей существовать не может.
И даже если такой код будет работать, компилятор может однажды переключиться с использования одной инструкции на другую, и внезапно ядро больше не поможет.
И всё потому, что компилятор не обязан генерировать инструкции ассемблера, работающие с не выровненными указателями. Ведь это UB.
Или вот другой вариант:
void set_it(std::atomic<int>* p) {
p->store(123);
}
int get_it(std::atomic<int>* p) {
return p->load();
}
Будет ли эта операция атомарной, если объект невыровнен? Му*— вопрос поставлен неверно. Это UB. (Хотя, да, на практике это вполне может обернуться проблемой с атомарностью).
*Прим. пер.: Иероглиф 無 (му) часто используется в дзэн-буддизме и обозначает отсутствие, небытие или пустоту. В текущем контексте он используется автором как образный способ указать на бессмысленность вопроса.
Если вам этих подтверждений мало, попробуйте представить, что произойдёт, если объект, который вы рассчитывали прочитать атомарно, окажется на стыке двух страниц. Только не задумывайтесь слишком сильно, а то вам может показаться, что «это нормально». Но это не так. Это неопределённое поведение.
По правде говоря, оно возникло даже раньше.
Не нужно винить функцию foo() выше. Проблему вызвало не разыменовывание указателя — для её появления было достаточно простого создания указателя.
Вот пример:
bool parse_packet(const uint8_t* bytes) {
const int* magic_intp = (const int*)bytes; // UB!
int magic_raw = foo(magic_intp); // Возможен сбой на SPARC.
int magic = ntohl(magic_raw); // Здесь всё в порядке.
[…]
}
Проблема в приведении, а не в foo().
Компилятор вполне может придумать для младших битов int* конкретное назначение, например, использовать их для сборки мусора или хранения тегов безопасности.
Применение isxdigit() к char
bool bar(char ch) {
return isxdigit(ch);
}
isxdigit() — это простая функция, которая получает символ и возвращает 1, если это шестнадцатеричная цифра: 0–9 или a–f. Она также может принимать значение EOF. Так, хорошо. А какое у EOF значение? Из раздела 7.4p1 стандарта C23 мы знаем, что это int, и можем сделать вывод, что его нельзя представить в виде unsigned char.
Поэтому isxdigit() получает int, а не char. Но любое значение типа char помещается в int, так что здесь проблем быть не должно. Приведение из char в int корректно, и если верить разделу 6.3.1.3, то всё в порядке, так?
Нет. Если bar() вызвать со значением вне диапазона 0–127, а в вашей архитектуре char является знаковым (что согласно параграфу 20 раздела 6.2.5 стандарта C23 определяется реализацией), то целое число окажется отрицательным.
Ниже я привёл вполне рабочую реализацию isxdigit(), которая вызовет чтение чёрт знает какого участка памяти. Это может быть даже память, связанная с устройствами ввода-вывода, что уже чревато проблемами почище простого получения случайного значения или сбоя. Такое поведение может, например, привести к запуску двигателя. Конечно, в десктопном приложении это менее вероятно, чем во встраиваемых системах, но иногда сетевые драйверы выносят в пространство пользователя (ради производительности), так что даже оно не гарантирует защиту.
int isxdigit(int c) {
if (c == EOF) {
return false;
}
return some_array[c];
}
Приведение float к int
int milliseconds(float seconds) {
int tmp = (int)(seconds 1000.0); / НЕПРАВИЛЬНО */
return tmp + 1; /* тоже НЕПРАВИЛЬНО (знаковое переполнение — это UB) */
}
Когда конечное значение реального типа с плавающей запятой преобразуется в целочисленный тип […] Если целую часть значения нельзя представить целочисленным типом, поведение не определено.
— 6.3.1.4
Кроме того, ввиду отсутствия явных правил в стандарте, это UB также возникнет, если float окажется бесконечным или NaN.
Так как же сравнивать float с INT_MAX? Нужно ли приводить float к int? Нет, это вызовет то самое UB, которого мы хотим избежать. Тогда приводить INT_MAX к float? А откуда вы знаете, что его получится выразить точно? Что, если при приведении INT_MAX к float значение округлится так, что перестанет влезать в int, и тогда ваше сравнение потеряет всякий смысл?
Может, сработает следующий вариант? Так вы упустите некоторые реально большие значения, но вдруг это не страшно?
int milliseconds(float seconds) {
const float ftmp = seconds * 1000.0f;
if (!isfinite(ftmp)) {
// Или другой способ обработки ошибки.
return 0;
}
if ((float)(INT_MIN + 1000) > ftmp) {
// Или другой способ обработки ошибки.
return 0;
}
if ((float)(INT_MAX - 1000) < ftmp) {
// Или другой способ обработки ошибки.
return 0;
}
// Теперь преобразование безопасно.
const int tmp = (int)ftmp;
if (INT_MAX == tmp) {
// Или другой способ обработки ошибки.
return 0;
}
// Теперь можно безопасно прибавить единицу.
return tmp + 1;
}
А ведь я всего лишь хотел превратить float в int. :-(
Уверен, существует много программ, код которых принимает значения в секундах и преобразует их в целочисленные миллисекунды просто через умножение и приведение.
Хранение объектов по нулевому адресу
Большинство программистов с этим не столкнутся, но я сомневаюсь, что существует отвечающий стандартам С способ поместить объект по нулевому адресу. Тем не менее такое вполне может потребоваться при написании ядра ОС и встраиваемых программ.
Согласно разделу 6.3.2.3, целочисленная константа нуль (которая преобразуется в указатель) и nullptr являются «константой нулевого указателя» (я буду называть её просто NULL). При этом стандарт C не указывает, что конкретный указатель NULL должен ссылаться именно на физический нулевой адрес, поскольку стандарт описывает абстрактную машину C, а не реальное аппаратное обеспечение».
Гарантируется лишь то, что при сравнении NULL с нулём вы получите равенство. Но вы и знать не знаете, как это происходит внутренне — возможно, этот нуль преобразуется в нативный NULL для данной платформы, которым вполне может оказаться 0xffff.
В стандарте также явно говорится, что разыменовывание нулевого указателя, независимо от его значения, ведёт к неопределённому поведению. Такой пример приводится в разделе 3.4.3.
Это также значит, что вы не можете рассчитывать на создание нулевого указателя с помощью memset(&ptr, 0, sizeof(ptr));. Нельзя инициализировать структуры таким образом и думать, что указатели в их полях окажутся нулевыми. Причём это уже касается большинства программистов.
К слову, в некоторых старых машинах использовались ненулевые указатели NULL.
Но предположим, что у вас современная машина, где NULL — это указатель на нулевой адрес, и у вас там реально хранится объект.
Опять же, в разделе 6.3.2.3 говорится, что NULL не равен «любому объекту или функции». Значит, это UB:
void (*func_ptr)() = NULL;
func_ptr();
Стандарт С говорит «там нет функции». Откуда вам знать — возможно, у компилятора в этом случае даже нет внутреннего способа выразить ваше намерение. Вы можете возразить: «Но он же просто сгенерирует инструкцию вызова по битовому шаблону из всех нулей? Других адекватных вариантов нет».
А что вообще значит «из всех нулей»? На 16-битной x86 это будет 0000:0000? Или CS:0000?
Переменные аргументы и типы (например, printf с %ld вместо %lld)
Здесь получаем UB:
execl("/bin/sh", "sh", "-c", "date", NULL); /* НЕПРАВИЛЬНО */
execl("/bin/sh", "sh", "-c", "date", 0); /* НЕПРАВИЛЬНО */
А вот здесь уже нет:
execl("/bin/sh", "sh", "-c", "date", (char*)NULL);
Потому что аргумент должен быть указателем, а макрос NULL может быть ошибочно интерпретирован компилятором как целочисленный нуль.
И здесь тоже UB:
uint64_t blah = 123;
printf("%ldn", blah); /* НЕПРАВИЛЬНО */
Должно быть так:
uint64_t blah = 123;
printf("%"PRIu64"n", blah);
Так как же тогда выводить uid_t? Как вариант, вы можете привести его к uintmax_t и вывести с помощью PRIuMAX. Но уверены ли вы, что uid_t беззнаковый? Думаю, в худшем случае вы получите на выходе бессмысленное число вместо -1.
Деление на нуль — это UB
Уверен, вы и так это знали. Но учитывали ли вы сопутствующие аспекты безопасности? Нередко бывает, что знаменатель поступает из непроверенных источников.
И здесь ещё много других нюансов. Стандарт C23 содержит 283 вхождения слова «undefined», и это без учёта тех случаев, которые не задокументированы.
Бонус: реализация без UB
Никто не способен просчитывать правила целочисленного расширения при беглом просмотре кода. Никто!
Статья и так уже затянулась, поэтому ограничусь парой примеров:
unsigned char a = 0xff;
unsigned char b = 1;
unsigned char zero = 0;
bool overflowed = (a + b) == zero;
// overflowed примет значение 0, а не 1.
unsigned char a = 0x80;
uint64_t b = a << 24; // Бонусное UB(?)
// Теперь b равна 18446744071562067968 (ffffffff80000000), а не 2147483648 (0x80000000).
// Даже при том, что все наши переменные беззнаковые.
LLM здесь справляются лучше нас
Покажите LLM ЛЮБОЙ код на C, попросите найти в нём UB, и она справится. Причём на сегодня окажется права почти в каждом случае.
Честно говоря, мне стало немного не по себе, когда ИИ корректно нашёл неопределённое поведение в моём коде, и тогда я решил таким же образом прощупать зрелую и скрупулёзно написанную OpenBSD. Я выбрал первый инструмент, какой пришёл мне на ум — find — и он выдал целую кучу предупреждений.
Я отправил мейнтейнерам патч для исправления выхода за границы объекта при записи (а также логической ошибки, не связанной с UB). Я не стал слать им патчи для всех случаев UB, которые встречались на каждом шагу, отчасти, потому что проект OpenBSD в прошлом неохотно реагировал на баг-репорты. К тому же, у меня было ощущение, что «на практике всё наверняка не так плохо», и если уж разработчики OpenBSD решат искоренить из кодовой базы всё UB, то это должен быть масштабный системный проект, а не кто-то вроде меня, выступающий посредником между LLM и мейнтейнерами.
Так что же нам делать?
Мы не можем просто взять и выбросить кодовые базы на С и C++. Но и оставлять их в таком глубоко уязвимом состоянии тоже не вариант.
Нам нужен способ исправления существующего UB в масштабах всей индустрии. Причём такой, чтобы при этом не наплодить ИИ-слопа и не завалить ревьюеров работой.
Хотя эта идея тоже не нова и озарением не является.
Тем не менее в 2026 году написание кода на С или С++ без анализа на UB с помощью LLM уже следует расценивать как нарушение SOX, да и просто как банальную безответственность. Если уж разработчики OpenBSD более 30 лет не могут отыскать эти проблемы, то каковы шансы у всех остальных?
Такой подход может не масштабироваться на крупные кодовые базы, но конкретно в своих проектах я прошу LLM найти UB, а при необходимости объяснить и исправить. После этого я внимательно изучаю результат, пока не убеждаюсь, что баг реально присутствовал и был исправлен.
Проблема такой методики в том, что для подтверждения найденных ошибок необходим опытный специалист. Но такие люди обычно заняты другими делами. Подобная перепроверка — это рутинная работа «санитара», но в то же время слишком тонкая, чтобы доверить её джуниорам, которым обычно такую рутину поручают.
Автор: Bright_Translate


