Menu

Неопределённое поведение в C: что это значит и почему кусается

Неопределённое поведение — это код, на который стандарт C не накладывает никаких требований, так что произойти может что угодно, вплоть до удаления оптимизатором ваших проверок. Здесь: что его вызывает, почему «у меня работает» ничего не доказывает и какие флаги его ловят.

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

«Неопределённое поведение» звучит как жаргонное обозначение непредсказуемости. Оно сильнее. Когда стандарт 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 ловит похожий набор без пересборки.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ