123. Неопределённое поведение: где прячется и как его ловить
Неопределённое поведение — не «случайный результат», а договор:
программист обещает, что определённых вещей в программе не будет,
а компилятор строит на этом обещании оптимизации. Нарушили обещание —
компилятор не обязан ни предупреждать, ни вести себя последовательно.
Отсюда самые загадочные ошибки прошивки: работает в отладочной сборке
и ломается в выпускной, ломается от лишнего printf, ведёт себя по-разному
на двух компиляторах.
Раздел показывает это на опыте: один и тот же текст собирается пятью способами и даёт разные ответы; затем разбирается, что из этого ловят ловушки компилятора, а что нет, и во что обходится ловушка на Cortex-M.
Как это называется в документации
Оригинал Перевод undefined behavior (UB) неопределённое поведение: язык не требует ничего unspecified behavior неуточнённое: один из допустимых вариантов implementation-defined задаёт реализация и документирует strict aliasing правило строгого псевдонима type punning чтение объекта как другого типа undefined behavior sanitizer (UBSan) проверка UB во время выполнения trap ловушка: останов на месте ошибки address sanitizer (ASan) проверка обращений к памяти static analyzer статический анализатор sequence point / unsequenced точка следования / без порядка
123.1 Три вида «не определено»
Стандарт различает три вещи, и путать их опасно.
- Задаёт реализация — компилятор выбирает и обязан описать: знак
char, сдвиг вправо отрицательного, размерlong. Результат постоянен для компилятора (раздел 122). - Неуточнено — выбирается один из разрешённых вариантов, но какой — не сказано: порядок вычисления аргументов функции.
- Не определено — требований нет вообще: переполнение знакового, выход за границы массива, разыменование нулевого указателя, сдвиг на ширину типа, чтение объекта через указатель несовместимого типа, изменение переменной дважды без точки следования.
Именно третье даёт свободу оптимизатору. Он не «ломает» программу — он доверяет обещанию, что таких мест в ней нет.
123.2 Опыт: один текст, разные ответы
Две пробы из опыта, собранные пятью способами:
| Сборка | x + 1 > x при INT_MAX |
запись int и float по одному адресу |
|---|---|---|
gcc -O0 |
1 | 0 |
clang -O0 |
0 | 0 |
gcc -O2 |
1 | 1 |
clang -O2 |
1 | 1 |
ARM, -O2 |
1 | 1 |
Ни одна из сборок не «ошиблась»: при неопределённом поведении любой
ответ разрешён. А определённый вариант той же задачи — биты float,
скопированные через memcpy, — во всех пяти сборках дал одно и то же:
0x3F800000.
123.3 Переполнение знакового
__attribute__((noinline)) int plus_one_bigger(int x)
{
return x + 1 > x;
}
Для INT_MAX сложение переполняется. Компилятор вправе считать, что этого
не бывает, и заменить выражение на 1 — так и сделали gcc и clang с
оптимизацией. clang без оптимизации честно сложил, получил отрицательное
и ответил 0. Отсюда классическая ошибка: проверка переполнения
после операции (if (a + b < a)) исчезает из выпускной сборки.
Проверять надо до операции, в пределах типа:
int can_increment(int x)
{
return x < INT_MAX;
}
или складывать со встроенной проверкой, где переполнения не случается вовсе, а есть признак:
return __builtin_add_overflow(a, b, out);
123.4 Строгий псевдоним
Стандарт разрешает обращаться к объекту только через совместимый тип
(плюс char). Запись float по адресу int компилятор считает
невозможной:
__attribute__((noinline)) int alias(int *i, float *f)
{
*i = 1;
*f = 0.0f;
return *i; /* -O2 вправе вернуть 1: float «не может» менять int */
}
С -O0 функция вернула 0 — перечитала память. С -O2 все три компилятора
вернули 1: запись через float «не могла» изменить int. В прошивке это
случается при разборе пакетов — *(uint32_t *)&buf[4] — и при чтении
битов float. Правильно — memcpy, компилятор превращает его в одну
пересылку:
uint32_t u;
memcpy(&u, &f, sizeof u);
return u;
Второй честный путь — union (в Си это разрешено). Третий — ключ
-fno-strict-aliasing: так поступают ядро Linux и многие прошивки, платя
частью оптимизаций.
123.5 Выход за границы и сдвиги
Чтение a[4] у массива из четырёх элементов может вернуть соседнюю
переменную, мусор или привести к отказу — ничего не обещано. На
Cortex-M без защиты памяти это обычно «работает», пока рядом не окажется
чувствительная переменная.
Сдвиг на ширину типа и больше — тоже UB: 1u << 32 на Cortex-M
аппаратно даёт 0, на x86 — 1, но компилятор не обязан ни то, ни другое.
Безопасный сдвиг проверяет величину:
return n < 32u ? v << n : 0u;
И индекс — до обращения:
return i < n ? a[i] : fallback;
123.6 Что ловят ловушки компилятора
Ключ -fsanitize=undefined вставляет проверки, а -fsanitize-trap превращает
найденное в немедленный останов. Опыт, с оптимизацией:
| Ошибка | gcc | clang |
|---|---|---|
| сдвиг на 32 | да | да |
| выход за границу локального массива | да | да |
переполнение x + 1 > x |
нет | да |
| выход за границу через указатель | нет | нет |
| нарушение строгого псевдонима | нет | нет |
Главный вывод — «ловушка молчит» не значит «ошибки нет». gcc заменил
x + 1 > x на 1 раньше, чем вставил проверку, — проверять стало нечего.
Размер массива за указателем неизвестен — проверку ставить не на что.
Для таких ошибок нужны другие средства: ASan на компьютере, статические
анализаторы и правила кодирования (раздел 129).
123.7 Ловушки на самой плате
armclang умеет то же для Cortex-M. Сложение a + b с ловушкой становится
четырьмя командами вместо двух (ADDS, BX lr):
ADDS r0, r0, r1
IT VC
BXVC lr ; нет переполнения — возврат
UDF #0xfe ; есть — неопределённая команда
Функция заняла 8 байт вместо 4. UDF на M3/M4 даёт UsageFault с флагом
UNDEFINSTR, если он разрешён в SHCSR, иначе HardFault; на M0 — сразу
HardFault. Отказ случается на месте ошибки: адрес в кадре исключения
укажет на строку с переполнением (раздел 35 — как его прочитать).
Библиотека времени выполнения не нужна. Разумная практика — собирать
с ловушками отладочную и стендовую прошивку, а выпускную — по решению:
проверки стоят места и тактов, но отказ «на месте» лучше тихой порчи данных.
123.8 Остальные места
- Неинициализированная переменная — читается мусор, а оптимизатор
вправе предположить любое значение. Ловится предупреждениями
-Wall -Wuninitialized. i = i++ + 1,f(i++, i++)— изменение без точки следования; gcc и clang выдают разное. Предупреждение-Wsequence-point.- Разыменование нулевого указателя — на Cortex-M по адресу 0 лежит
таблица векторов, и чтение «работает», а запись во флеш — отказ.
Компилятор вправе убрать проверку
if (!p), стоящую после*p. - Функция без
return, у которой значение используется, — UB. - Пустой цикл с неконстантным условием — если в нём нет ни
volatile, ни атомарных операций, ни ввода-вывода, C11 разрешает считать его завершающимся:while (!flag) { }с обычнымflagкомпилятор вправе выбросить. Вечныйfor (;;) { }с константным условием под это правило не попадает и остаётся. Циклы ожидания флага пишут сvolatile(раздел 124).
123.9 Как жить с этим в прошивке
- Держать включёнными
-Wall -Wextraи читать предупреждения: половина UB видна компилятору заранее. - Отладочную сборку — с ловушками UB; модульные тесты на компьютере — ещё и с ASan.
- Типы и биты — через
memcpy, никогда через приведение указателей. - Проверки — до операции:
x < INT_MAX,i < n,n < 32. - Знаковую арифметику, где возможен выход за пределы, — через
__builtin_add_overflowили в беззнаковом типе. - Если прошивка унаследована и полна приведений указателей —
-fno-strict-aliasingчестнее, чем надежда.
123.10 Грабли
Работает с -O0, ломается с -O2. Почти всегда UB: переполнение,
псевдоним, неинициализированная переменная. Собрать с ловушками и -Wall.
Проверка переполнения исчезла из выпускной сборки. Проверка после операции: компилятор считает, что переполнения не бывает. Проверять до.
Разбор пакета даёт мусор после смены оптимизации.
*(uint32_t *)&buf[i] — нарушение псевдонима; на Cortex-M0 — ещё
и невыровненный доступ, а на M3/M4 компилятор вправе склеить два таких
чтения в LDRD или LDM, которые невыровненный адрес не терпят. memcpy.
Ловушки ничего не нашли, а ошибка есть. Ловушки видят не всё: псевдоним и выход за границу через указатель им недоступны.
HardFault с UDF в отладочной прошивке. Это не поломка, а сработавшая
ловушка UB: адрес в кадре исключения указывает на виновную строку.
123.11 Итоги
- Неопределённое поведение — обещание компилятору; нарушили — ответ любой:
x + 1 > xдал 0 уclang -O0и 1 у остальных, запись черезfloat— 0 без оптимизации и 1 с ней. - Определённый путь одинаков везде: биты
floatчерезmemcpy— 0x3F800000 во всех пяти сборках. - Ловушки UB ловят сдвиги и выход за границу массива, clang — ещё переполнение; псевдоним и указатели не видит никто.
- На Cortex-M ловушка —
UDFи HardFault на месте ошибки: 8 байт вместо 4 на сложение. - Проверять до операции, биты — через
memcpy, предупреждения — включены.
Полные исходники
ub123.c — пробы неопределённого поведения для gcc и clang.
/* Раздел 123: неопределённое поведение — нарочно, чтобы увидеть, как оно выглядит.
* Каждая проба выбирается аргументом командной строки; входные значения идут
* через volatile, чтобы компилятор не посчитал всё заранее. Сборки: gcc и clang
* с -O0 и -O2, ARM с -O2 и те же пробы с ловушками UB (_verify/c123_check.py).
* Разные ответы одного и того же текста — и есть признак UB. */
#include <limits.h>
#include <stdio.h>
#include <string.h>
volatile int g_in = INT_MAX;
/* 1. Переполнение знакового: компилятор вправе считать, что x + 1 > x всегда. */
__attribute__((noinline)) int plus_one_bigger(int x)
{
return x + 1 > x;
}
/* 2. Нарушение правила строгого псевдонима: int и float по одному адресу. */
__attribute__((noinline)) int alias(int *i, float *f)
{
*i = 1;
*f = 0.0f;
return *i; /* -O2 вправе вернуть 1: float «не может» менять int */
}
/* То же без UB: байты копируются, компилятор знает, что они общие. */
__attribute__((noinline)) unsigned float_bits(float f)
{
unsigned u;
memcpy(&u, &f, sizeof u);
return u;
}
/* 3. Выход за границу массива — чтение соседней памяти или ловушка. */
__attribute__((noinline)) int read_at(const int *a, int i)
{
return a[i];
}
/* 4. Сдвиг на ширину типа. */
__attribute__((noinline)) unsigned shift_by(unsigned v, int n)
{
return v << n;
}
int main(int argc, char **argv)
{
const char *probe = argc > 1 ? argv[1] : "all";
if (!strcmp(probe, "overflow") || !strcmp(probe, "all"))
printf("overflow=%d\n", plus_one_bigger(g_in));
if (!strcmp(probe, "alias") || !strcmp(probe, "all")) {
int storage = 0;
printf("alias=%d\n", alias(&storage, (float *)&storage));
}
if (!strcmp(probe, "bits") || !strcmp(probe, "all"))
printf("bits=0x%08X\n", float_bits(1.0f));
if (!strcmp(probe, "bounds")) {
int a[4] = { 1, 2, 3, 4 };
volatile int i = 4;
printf("bounds=%d\n", read_at(a, i));
}
if (!strcmp(probe, "bounds_direct")) {
int a[4] = { 1, 2, 3, 4 };
volatile int i = 4;
printf("bounds_direct=%d\n", a[i]); /* размер массива известен здесь же */
}
if (!strcmp(probe, "shift")) {
volatile int n = 32;
printf("shift=%u\n", shift_by(1u, n));
}
return 0;
}
ub123_arm.c — то же без неопределённого поведения — для платы, с ловушками UB и без.
/* Раздел 123: как писать без неопределённого поведения — то же на плате.
* Собирается armclang для F103 и F407 дважды: обычно и с ловушками UB
* (-fsanitize=undefined -fsanitize-trap=undefined): тогда нарушение превращается
* в неопределённую команду, и ядро уходит в HardFault прямо на месте ошибки. */
#include <limits.h>
#include <stdint.h>
#include <string.h>
/* Биты float — через memcpy, а не через приведение указателя. */
uint32_t float_bits(float f)
{
uint32_t u;
memcpy(&u, &f, sizeof u);
return u;
}
/* Сложение со знаком с проверкой: переполнения не случается, есть признак. */
int add_checked(int a, int b, int *out)
{
return __builtin_add_overflow(a, b, out);
}
/* Проверка до операции, в пределах типа. */
int can_increment(int x)
{
return x < INT_MAX;
}
/* Сдвиг с проверкой величины: сдвиг на 32 и больше — неопределённое поведение. */
uint32_t shl(uint32_t v, unsigned n)
{
return n < 32u ? v << n : 0u;
}
/* Индекс проверяется до обращения. */
int read_checked(const int *a, unsigned n, unsigned i, int fallback)
{
return i < n ? a[i] : fallback;
}
/* Эта функция с ловушками UB превращает переполнение в останов. */
int sum_unchecked(int a, int b)
{
return a + b;
}
Дальше: раздел 124 «volatile, const и атомарность» — что обещает
volatile, чего не обещает, и как делить данные с прерыванием.