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 |
|---|---|---|
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.
122.6 Сдвиги
- Сдвиг на ширину типа и больше — неопределённое поведение:
x << 32для 32-битногоxможет дать что угодно. - Сдвиг единицы в знаковый бит —
1 << 31дляintнеопределён. Правильно —1u << 31, это 2147483648. - Сдвиг вправо отрицательного числа — задаёт реализация. gcc, clang
и ARM сдвигают арифметически, сохраняя знак:
-16 >> 2= -4. Для полей регистров и масок берут беззнаковые типы — там сдвиг всегда логический.
Отсюда привычка из разделов 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 Как писать, чтобы не думать об этом
- Ширина важна — типы из
stdint.h; регистры, маски, счётчики — беззнаковые. - Смешивать знаковые и беззнаковые в сравнении — только с явным приведением.
- Держать включёнными
-Wall -Wextra -Wsign-compare -Wconversion: многое из разобранного выше компилятор найдёт сам, но не всё. Сравнение соstart + timeoutдля него правильный код, а1 << 31он отмечает только с-Wshift-overflow=2. - Время — разностью беззнаковых, никогда не сравнением с
start + timeout. - Результат побитовых операций над байтами — обратно в байт до сравнения.
- Модель данных проверять
_Static_assertв начале модуля.
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 Итоги
- Cortex-M — ILP32:
int,longи указатель по 4 байта,charбез знака; компьютер —longи указатель по 8,charсо знаком. - Всё меньше
intстановитсяint: 200 + 100 в байтах — это 300, а~0x0F— это -16. - Знаковое с беззнаковым — в беззнаковое;
(long)-1 < 1uна компьютере 1, на Cortex-M 0. - Беззнаковые считают по модулю (
(uint8_t)300= 44,(uint8_t)-1= 255), знаковое переполнение — неопределённое поведение,(int8_t)200= -56 — договорённость компиляторов. - Тайм-аут — разностью: наивное сравнение срабатывает сразу, а не через 100 тиков.
- Маски —
1u << n;1u << 31= 2147483648.
Полные исходники
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 «Неопределённое поведение» — что происходит, когда правила нарушены, и как компилятор и санитайзеры это ловят.