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

122. Целые типы и преобразования: продвижение, знак, переполнение

Половина странных ошибок в прошивке — не в логике, а в том, как Си считает целые: сравнение, которое «очевидно» верно, оказывается ложным, тайм-аут срабатывает раньше времени, код, отлаженный на компьютере, на плате отвечает иначе. Правила при этом строгие и немногочисленные. Раздел разбирает их на опыте: одна программа прогоняется на gcc и clang на компьютере и на 32-битном ARM — и показывает, где машины совпадают, а где Cortex-M отвечает по-своему.

Опыт собирается с ловушками неопределённого поведения: если бы хоть одна строка им грешила, программа бы упала. Разбор самих ловушек — раздел 123.

Как это называется в документации

Оригинал Перевод
integer promotion целочисленное продвижение: всё меньше int становится int
usual arithmetic conversions обычные арифметические преобразования
signed / unsigned со знаком / без знака
wrap-around (modulo 2^N) переход через ноль, счёт по модулю
overflow переполнение
implementation-defined поведение задаёт реализация
undefined behavior неопределённое поведение
exact-width types (stdint.h) типы точной ширины
data model (ILP32, LP64) модель данных: ширина int, long и указателя
narrowing conversion сужающее преобразование
arithmetic / logical shift арифметический / логический сдвиг

122.1 Размеры: у платы и у компьютера они разные

Размеры типов в байтах на x86-64 и на Cortex-M: long и указатель различаются

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

Тип x86-64 Cortex-M
int 4 4
long 8 4
указатель 8 4
char со знаком без знака

У Cortex-M модель данных ILP32: int, long и указатель — по 32 бита. У 64-битного компьютера — LP64: long и указатель по 64. Разница в знаке char незаметна в байтах, но меняет результат: (char)200 на ARM — 200, на компьютере — -56.

Отсюда первое правило прошивки: где важна ширина — типы из stdint.h: uint8_t, int16_t, uint32_t. char — только для текста, int — для счётчиков и индексов, где ширина не принципиальна.


122.2 Продвижение: всё, что меньше int, становится int

Прежде чем что-либо считать, Си превращает char, short, uint8_t, uint16_t в int. Поэтому два uint8_t 200 и 100 дают 300, а не переполненные 44: сложение идёт уже в int, и тип суммы — int, 4 байта. Обрезка до байта случится только при записи обратно в uint8_t.

Ловушка — оператор ~:

uint8_t x = 0x0F;
printf("not_u8=%d\n", ~x);                    /* ~ над int: 0xFFFFFFF0 = -16 */
printf("not_u8_eq=%d\n", ~x == 0xF0);         /* и поэтому не 0xF0 */

x сначала стал int со значением 15, и ~ перевернул все 32 бита: получилось -16, а не 0xF0. Сравнение ложно. Верно — вернуть результат в байт перед сравнением:

return (uint8_t)~x == 0xF0u;

gcc об этом предупреждает прямо: «сравнение продвинутого побитового дополнения беззнакового значения с константой».


122.3 Знаковое против беззнакового

Когда в операции встречаются int и unsigned int, знаковое превращается в беззнаковое. -1 становится 4294967295, и сравнение -1 < 1u ложно. Компилятор предупреждает ключом -Wsign-compare — и этот ключ стоит держать включённым.

Правило решает ширина, а не знак, и здесь машины расходятся. Сравнение (long)-1 < 1u на компьютере верно: long там шире unsigned int, и -1 остаётся -1. На Cortex-M long той же ширины — -1 снова превращается в беззнаковое, и сравнение ложно. Одна строка, два ответа: код, «проверенный на компьютере», на плате ведёт себя иначе.


122.4 Беззнаковые считают по модулю

Для беззнаковых переход через ноль определён: UINT32_MAX + 1 — это 0, (uint8_t)300 — 44 (остаётся младший байт), (uint8_t)-1 — 255. На этом держится половина прошивки: счётчики, кольцевые буферы, контрольные суммы.

Для знаковых всё иначе: переполнение int — неопределённое поведение, компилятор вправе считать, что его не бывает, и строить на этом оптимизации (раздел 123). Сужение в знаковый тип вне диапазона — «задаёт реализация»: (int8_t)200 у gcc, clang и компиляторов ARM даёт -56, но это договорённость компиляторов, а не языка.


122.5 Тики и тайм-ауты через переполнение

Самое частое место, где это бьёт по прошивке. Счётчик SysTick — 32 бита и однажды переполнится; на частоте 1 кГц — примерно через 49,7 суток непрерывной работы. Прибор, проработавший полтора месяца, внезапно перестаёт ждать.

Опыт: старт за 16 тиков до переполнения, тайм-аут 100. Наивная проверка сравнивает с вычисленным моментом:

uint32_t deadline = start + timeout;
return g_ticks >= deadline;

start + timeout переполнился и стал равен 84, а счётчик до переполнения больше 84 — условие верно сразу, в первый же момент ожидания (опыт проверил его через 8 тиков). После переполнения оно на время снова станет ложным, пока счётчик не дойдёт до 84: наивная проверка ещё и скачет. Правильно — сравнивать прошедшее время, разность беззнаковых:

return (uint32_t)(g_ticks - start) >= timeout;

Разность по модулю 2^32 даёт настоящее число тиков и через переполнение: 8 тиков — рано, 112 — пора. Приведение к uint32_t на плате не меняет кода, но защищает от продвижения, если счётчик когда-нибудь станет uint16_t.

Тайм-аут через переполнение: наивное сравнение срабатывает на 8-м тике вместо 100-го


122.6 Сдвиги

Отсюда привычка из разделов 3а и 8: маски пишут 1u << n, а не 1 << n.


122.7 Константы тоже имеют тип

0xFFFFFFFF не помещается в int, поэтому это unsigned int, и -1 == 0xFFFFFFFF верно. 'a' в Си — int, а не char. 100000 на машине с 16-битным int было бы long. Суффиксы u, UL, ULL делают тип явным и снимают сюрпризы: 1u << 31, 100000UL.


122.8 Модель данных — проверять, а не предполагать

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

_Static_assert(sizeof(int) == 4 && sizeof(long) == 4 && sizeof(void *) == 4, "ILP32");
_Static_assert(CHAR_MIN == 0 && CHAR_MAX == 255, "char без знака, как у компиляторов для Cortex-M");
_Static_assert((long)-1 < 1u == 0, "long не шире unsigned: -1 превращается в беззнаковое");

armclang собрал этот файл и для F103, и для F407 — утверждения выполняются на обоих.


122.9 Как писать, чтобы не думать об этом


122.10 Грабли

Тайм-аут сработал раньше времени после полутора месяцев работы. Сравнение с start + timeout, который переполнился. Разность беззнаковых.

Проверка ~x == 0xF0 всегда ложна. Продвижение до int: ~0x0F — это -16. (uint8_t)~x == 0xF0.

-1 < 1u — ложь. Знаковое превратилось в беззнаковое. Приводить явно, держать -Wsign-compare.

На компьютере работает, на плате нет. Разная модель данных: long и указатель, знак char. Типы из stdint.h, _Static_assert.

1 << 31 в маске. Неопределённое поведение для int. 1u << 31.

Байт из char отрицательный. На x86 char со знаком. Для байтов — uint8_t.


122.11 Итоги

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

int122.c — опыт с целыми типами и преобразованиями на трёх машинах.

/* Раздел 122: целые типы и преобразования — опыт на трёх машинах.
 * Собирается gcc и clang на x86-64 и gcc для 32-битного ARM (запуск в qemu),
 * печатает ключ=значение; _verify/c122_check.py сравнивает машины между собой
 * и с тем, что написано в разделе. Неопределённого поведения нет: сборка идёт
 * с ловушками UB, любая ошибка обрывает программу. */
#include <limits.h>
#include <stdint.h>
#include <stdio.h>

/* Таймер тиков: старт перед переполнением счётчика, тайм-аут 100 тиков. */
static int expired_naive(uint32_t now, uint32_t start, uint32_t timeout)
{
    uint32_t deadline = start + timeout;   /* переполнился и стал маленьким */
    return now >= deadline;
}

static int expired(uint32_t now, uint32_t start, uint32_t timeout)
{
    return (uint32_t)(now - start) >= timeout;   /* разность верна и через переполнение */
}

int main(void)
{
    /* 1. Размеры: у Cortex-M и у компьютера они разные */
    printf("sizes=%zu,%zu,%zu,%zu,%zu,%zu\n", sizeof(char), sizeof(short), sizeof(int), sizeof(long),
           sizeof(long long), sizeof(void *));
    printf("char_signed=%d\n", (char)-1 < 0);

    /* 2. Продвижение: всё, что уже int, до сложения становится int */
    uint8_t a = 200, b = 100;
    printf("promo_sum=%d\n", a + b);
    printf("promo_size=%zu\n", sizeof(a + b));
    uint8_t x = 0x0F;
    printf("not_u8=%d\n", ~x);                    /* ~ над int: 0xFFFFFFF0 = -16 */
    printf("not_u8_eq=%d\n", ~x == 0xF0);         /* и поэтому не 0xF0 */
    printf("not_u8_cast_eq=%d\n", (uint8_t)~x == 0xF0);

    /* 3. Обычные арифметические преобразования */
    printf("cmp_int_uint=%d\n", -1 < 1u);         /* -1 превращается в 4294967295 */
    printf("cmp_long_uint=%d\n", (long)-1 < 1u);  /* зависит от ширины long! */

    /* 4. Беззнаковые — по модулю, это определено */
    uint32_t m = UINT32_MAX;
    m = m + 1u;
    printf("u32_wrap=%lu\n", (unsigned long)m);
    printf("u8_from_300=%d\n", (uint8_t)300);
    printf("u8_from_minus1=%d\n", (uint8_t)-1);
    printf("i8_from_200=%d\n", (int8_t)200);      /* поведение задаёт реализация: у gcc — -56 */

    /* 5. Сдвиги */
    printf("shr_negative=%d\n", -16 >> 2);        /* задаёт реализация: у gcc и на ARM арифметический */
    printf("one_u_shl_31=%lu\n", (unsigned long)(1u << 31));
    printf("int_max=%d\n", INT_MAX);

    /* 6. Тики через переполнение: 8 тиков после старта, тайм-аут 100 */
    uint32_t start = 0xFFFFFFF0u, now = 0xFFFFFFF8u;
    printf("tick_naive=%d\n", expired_naive(now, start, 100u));
    printf("tick_right=%d\n", expired(now, start, 100u));
    now = 0x00000060u;                             /* 112 тиков — и правда пора */
    printf("tick_right_late=%d\n", expired(now, start, 100u));
    printf("done=1\n");
    return 0;
}

int122_arm.c — те же правила на Cortex-M: модель данных и тайм-аут через переполнение.

/* Раздел 122: те же правила на самой плате — armclang для F103 и F407.
 * Статические утверждения проверяют модель данных Cortex-M, функции — то,
 * что идёт в прошивку: тайм-аут по тикам SysTick через переполнение. */
#include <limits.h>
#include <stdint.h>

/* Модель данных Cortex-M: int, long и указатель по 32 бита, char без знака. */
_Static_assert(sizeof(int) == 4 && sizeof(long) == 4 && sizeof(void *) == 4, "ILP32");
_Static_assert(sizeof(long long) == 8, "long long — 64 бита");
_Static_assert(CHAR_MIN == 0 && CHAR_MAX == 255, "char без знака, как у компиляторов для Cortex-M");
_Static_assert((long)-1 < 1u == 0, "long не шире unsigned: -1 превращается в беззнаковое");
_Static_assert((uint8_t)300 == 44 && (uint8_t)-1 == 255, "в uint8_t остаётся младший байт");
_Static_assert(sizeof((uint8_t)1 + (uint8_t)1) == sizeof(int), "uint8_t + uint8_t — это int");
_Static_assert(UINT32_MAX + 1u == 0u, "беззнаковые — по модулю 2^32");

volatile uint32_t g_ticks;                         /* растёт в SysTick_Handler */

/* Тайм-аут: разность беззнаковых верна и через переполнение счётчика. */
int timeout_expired(uint32_t start, uint32_t timeout)
{
    return (uint32_t)(g_ticks - start) >= timeout;
}

/* Неверно: start + timeout переполняется, и сравнение срабатывает сразу. */
int timeout_expired_naive(uint32_t start, uint32_t timeout)
{
    uint32_t deadline = start + timeout;
    return g_ticks >= deadline;
}

/* Проверка бита в байте без ловушки продвижения: сначала обратно в uint8_t. */
int low_nibble_clear(uint8_t x)
{
    return (uint8_t)~x == 0xF0u;
}

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

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