Embedded HandbookПриложения
Все разделы › XV. Язык C глубже
STM32F103, STM32F407 · уровень продвинутый · 11 мин чтения
Открыть в интерактивном справочнике

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 Три вида «не определено»

Стандарт различает три вещи, и путать их опасно.

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


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);

x + 1 > x при INT_MAX в пяти сборках: ответы 1 и 0, и как проверять переполнение правильно


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 Что ловят ловушки компилятора

Что видят ловушки неопределённого поведения у gcc и clang: сдвиг и выход за границу массива ловят, псевдоним и указатели — нет

Ключ -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 Остальные места


123.9 Как жить с этим в прошивке


123.10 Грабли

Работает с -O0, ломается с -O2. Почти всегда UB: переполнение, псевдоним, неинициализированная переменная. Собрать с ловушками и -Wall.

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

Разбор пакета даёт мусор после смены оптимизации. *(uint32_t *)&buf[i] — нарушение псевдонима; на Cortex-M0 — ещё и невыровненный доступ, а на M3/M4 компилятор вправе склеить два таких чтения в LDRD или LDM, которые невыровненный адрес не терпят. memcpy.

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

HardFault с UDF в отладочной прошивке. Это не поломка, а сработавшая ловушка UB: адрес в кадре исключения указывает на виновную строку.


123.11 Итоги

Полные исходники

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, чего не обещает, и как делить данные с прерыванием.

Открыть в интерактивном справочнике