«Неопределённое поведение» звучит как жаргонное обозначение непредсказуемости. Оно сильнее. Когда стандарт C говорит, что конструкция имеет неопределённое поведение, это значит, что стандарт не накладывает вообще никаких требований на то, что делает программа: ни на значение, ни на инструкцию, ни на программу целиком.
Вот что удивляет людей: неопределённое поведение не ограничено проблемной строкой. Компилятору позволено считать, что оно никогда не происходит, и переписывать окружающий код исходя из этого допущения. В результате в двоичном файле может не оказаться проверки, которую вы явно написали.
Модель контракта
Считайте стандарт контрактом между вами и компилятором. Вы обещаете не делать определённых вещей; взамен компилятор обещает, что ваша программа означает то, что в ней написано.
Не индексируйте за пределами массива. Не переполняйте знаковое целое. Не читайте неинициализированное значение. Не используйте указатель после освобождения памяти. Не изменяйте один и тот же объект дважды в одном выражении без точки следования между изменениями.
Нарушьте пункт — и сделка расторгнута для всей программы, а не только для той строки. Никакого «разумного запасного поведения» нет, и падать программа не обязана.
Три родственных термина стоит различать:
- Неопределённое поведение — произойти может что угодно. Выход за границы, знаковое переполнение, использование после освобождения.
- Неуточнённое поведение — один из нескольких допустимых исходов, и компилятор не обязан сообщать, какой именно. Например, порядок вычисления аргументов функции.
- Поведение, определяемое реализацией, — реализация выбирает и обязана задокументировать свой выбор. Знаковый ли
char, каков размерint.
Опасно в смысле «оптимизатор удалил мой код» только первое.
Основные источники
Переполнение знакового целого
Беззнаковая арифметика заворачивается, и стандарт прямо об этом говорит. Знаковая — нет: выход за пределы диапазона не определён.
Проверка if (a > INT_MAX - b) целиком выполняется внутри допустимого диапазона — именно это делает её корректным тестом на переполнение. Запись if (a + b < 0) сначала выполняет переполнение, а потом спрашивает о результате, и компилятор, вправе считая, что переполнения не было, может удалить проверку.
Выход за границы
Чтение или запись за пределами массива не определены, независимо от того, падает программа или нет:
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5]; /* НП: индекса 5 не существует */
arr[-1] = 0; /* НП */
int *p = arr + 10; /* НП даже просто вычислить такой указатель */
Обратите внимание на последнюю строку: формирование указателя, уходящего дальше чем на один элемент за конец, не определено, даже если вы его никогда не разыменуете. Стандарт разрешает arr + 5 (на один за конец, для завершения цикла), но не arr + 6.
Опаснее всего небольшие выходы за границы. Обычно они не вызывают segfault, а тихо перезаписывают соседнюю переменную, и неверный ответ вылезает где-то совсем в другом месте.
Чтение неинициализированного
int x;
printf("%d\n", x); /* НП: чтение неопределённого значения */
int *p;
*p = 42; /* НП: разыменование неопределённого указателя */
Соблазнительно думать «там просто мусор», но стандарт говорит не это, и компиляторы пользуются разницей. Известны случаи, когда GCC заключал, что прочитанная до присваивания переменная может содержать любое удобное ему значение, включая то, при котором ветка сворачивается.
Висячие указатели
int *p = malloc(sizeof *p);
free(p);
*p = 42; /* НП: использование после освобождения */
free(p); /* НП: двойное освобождение */
int *q;
{
int local = 10;
q = &local;
}
printf("%d\n", *q); /* НП: время жизни объекта закончилось */
Последствия во время выполнения разобраны в статье ошибка сегментации; здесь важно то, что падение — это ещё везение.
Неверный спецификатор printf
printf("%d\n", 3.14); /* НП: %d с double */
printf("%s\n", 42); /* НП: %s с int - обычно падение */
printf("%d %d\n", 1); /* НП: аргументов меньше, чем спецификаторов */
long n = 5;
printf("%d\n", n); /* НП на системах, где long шире int */
printf принимает переменное число аргументов: он читает их согласно строке формата и не может их проверить. Несоответствие заставляет его прочитать неверное число байтов из неверного места. Собирайте с -Wall, и компилятор проверит строку формата за вас — это одно из самых полезных предупреждений в наборе.
Изменение объекта дважды в одном выражении
int i = 0;
i = i++ + ++i; /* НП */
arr[i] = i++; /* НП */
printf("%d %d\n", i++, i); /* НП */
Это именно не определено, а не «зависит от компилятора». У учебниковых головоломок про то, чему равно i = i++ + ++i, нет правильного ответа.
Строгий алиасинг
Обращение к объекту через указатель несовместимого типа не определено, и это удивляет даже опытных программистов:
float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p); /* НП: чтение float через int * */
Компилятор исходит из того, что int * и float * никогда не указывают на одну и ту же память, и переупорядочивает код соответственно. Определённый способ переинтерпретировать байты — это memcpy (который оптимизируется в те же самые инструкции) или объединение:
Исключение — char *: байты любого объекта всегда можно рассматривать через unsigned char *.
Почему «у меня на машине работает» ничего не доказывает
Неопределённое поведение часто выглядит работающим, и именно это делает его опасным. Программа корректно работает всю разработку и тестирование, а затем ломается, когда меняется что-то совершенно постороннее:
- Новая версия компилятора с более умным оптимизатором.
- Переход с
-O0на-O2для релизной сборки. - Добавление никак не связанной функции, сдвинувшее раскладку стека так, что выход за границы теперь попадает во что-то значимое.
- Другая машина, другая libc, другая операционная система.
Видимость работоспособности не является свидетельством корректности, потому что стандарт ничего и не обещал. Это спящий баг, и спусковым крючком обычно оказывается релизная сборка.
Как этим пользуется оптимизатор
Вот пример, который обычно завершает спор. Программист пишет проверку на null:
void process(int *p) {
int value = *p; /* разыменование */
if (p == NULL) { /* а затем проверка на null */
return;
}
printf("%d\n", value * 2);
}
Порядок неверный — проверка идёт после разыменования, — но ведь проверка-то всё равно выполнится?
Не обязана. Компилятор рассуждает так: *p был разыменован, значит, p не может быть NULL (разыменование NULL не определено, поэтому в любой программе с определённым поведением он не NULL), значит, p == NULL всегда ложно, значит, всё тело if — мёртвый код и может быть удалено.
В скомпилированной функции проверки на null нет вообще. Реальный случай такого шаблона в ядре Linux стал CVE-2009-1897, где GCC удалил ровно такую проверку и превратил безобидную на вид ошибку в порядке действий в эксплуатируемую уязвимость.
Второй, более скромный пример:
/* Проверка переполнения, которая не работает */
int safe_add(int a, int b) {
int sum = a + b;
if (sum < a) { /* "а не завернулось ли?" */
return -1;
}
return sum;
}
Для знаковых типов компилятор может считать, что a + b не переполнилось, и тогда sum < a возможно только при b < 0. С таким допущением проверка проверяет не то, что имел в виду автор, а при b >= 0 может быть полностью удалена. Рабочая версия проверяет заранее:
Обе проверки остаются внутри представимого диапазона, поэтому переполнения не происходит и оптимизатору нечего «предполагать прочь». (В GCC и clang есть ещё __builtin_add_overflow, делающий это одной инструкцией.)
Как это обнаруживать
Сначала статические предупреждения — они бесплатны:
gcc -Wall -Wextra -Wpedantic program.c -o program
Так ловятся несоответствия в строках формата, часть чтений неинициализированных значений, подозрительные сравнения и недостижимый код.
Затем санитайзеры, которые инструментируют программу и сообщают о нарушении в момент его возникновения:
gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'
Соедините с AddressSanitizer ради памяти:
gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program
Вместе они ловят выходы за границы, использование после освобождения, двойные освобождения, знаковое переполнение, некорректные сдвиги, невыровненные указатели и разыменование null — каждое с файлом, строкой и стеком вызовов. Примерно вдвое медленнее, что во время разработки ничто.
valgrind ./program не требует пересборки и ловит чтение неинициализированного и ошибки памяти, хотя арифметическое неопределённое поведение — нет. clang --analyze и gcc -fanalyzer находят часть всего этого, вообще не запуская программу.
Практическое правило: прогоняйте тесты под санитайзерами в CI. Неопределённое поведение, которое локально выглядит рабочим, — это ровно то, ради чего они существуют.
Как с этим жить
Избежать неопределённого поведения одной аккуратностью невозможно: рано или поздно его пишут все. Работает другое — сделать его громким:
- Собирайте с
-Wall -Wextraс первого дня и исправляйте каждое предупреждение. - Прогоняйте тесты под
-fsanitize=address,undefined. - Инициализируйте каждую переменную при объявлении, а каждый указатель — значением
NULL. - Проверяйте индексы массива по длине и пишите цикл с
i < n. - Проверяйте переполнение до арифметики, пользуясь
<limits.h>. - Присваивайте указателю
NULLпосле освобождения. - Используйте
unsigned-типы там, где заворачивание — задуманное поведение: там оно определено. - Предпочитайте
memcpyприведениям указателей, когда переинтерпретируете байты.
Скорость C берётся из того, что компилятору разрешено считать, что вы соблюли контракт. Это настоящий размен, а не изъян проектирования, — и перечисленные инструменты возвращают вам почти всю безопасность обратно ценой, равной нулю на этапе разработки.
О том, как эти правила выглядят во время выполнения, см. ошибка сегментации; о том, какие ошибки встречаются раньше, на этапе компиляции, — типичные ошибки.
Часто задаваемые вопросы
Что такое неопределённое поведение в C?
Это код, на который стандарт C не накладывает вообще никаких требований. Компилятор волен породить что угодно: падение, неверный ответ, код, который вроде бы работает, или код, из которого проблемная ветка удалена целиком. Это не «зависит от реализации» и не «случайно» — это нарушенный вами контракт, после которого ничего не обещано.
Почему в C есть неопределённое поведение, а не определение для всего?
Скорость и переносимость. Требовать проверку границ при каждом обращении к массиву значило бы платить производительностью, платить которую C не проектировался; определить переполнение знакового целого как заворачивание значило бы навязать лишние инструкции железу, которое вместо этого вызывает прерывание. Оставляя такие случаи неопределёнными, стандарт позволяет компилятору считать, что они не происходят, и оптимизировать соответственно.
Является ли переполнение знакового целого неопределённым поведением в C?
Да. INT_MAX + 1 не определено, и завернуться в INT_MIN оно не обязано. С беззнаковым переполнением всё иначе: оно полностью определено и заворачивается по модулю 2^N. Именно поэтому компилятор может считать, что x + 1 > x для знакового x всегда истинно, и удалить написанную так проверку переполнения.
Как обнаружить неопределённое поведение в своей программе на C?
Соберите с -Wall -Wextra, чтобы поймать то, что компилятор видит статически, а затем прогоните тесты с -fsanitize=address,undefined: он сообщает о выходах за границы, использовании освобождённой памяти, знаковом переполнении и прочем в момент, когда это происходит, с именем файла и номером строки. valgrind ловит похожий набор без пересборки.