122. Integer types and conversions: promotion, sign, overflow
Half of the strange bugs in firmware are not in the logic but in the way C computes with integers: a comparison that is "obviously" true turns out to be false, a timeout fires too early, code debugged on a computer answers differently on the board. The rules themselves are strict and few. This section works through them by experiment: one program is run on gcc and clang on a computer and on 32-bit ARM, and it shows where the machines agree and where Cortex-M gives its own answer.
The experiment is built with undefined-behavior traps: if even one line had it, the program would abort. The traps themselves are covered in section 123.
Terms used in the documentation
Term Meaning integer promotion everything smaller than int becomes int usual arithmetic conversions the rules that bring both operands to a common type signed / unsigned with a sign / without a sign wrap-around (modulo 2^N) passing through zero, counting by a modulus overflow the result does not fit in the type implementation-defined the compiler chooses the behavior and documents it undefined behavior the language requires nothing at all exact-width types ( stdint.h)types with a guaranteed width data model (ILP32, LP64) the widths of int, long and a pointer narrowing conversion conversion to a narrower type arithmetic / logical shift a shift that keeps the sign / fills with zeros
122.1 Sizes: the board and the computer differ
The standard sets only lower bounds; the exact sizes are chosen by the compiler for the machine. One and the same program gives:
| Type | x86-64 | Cortex-M |
|---|---|---|
int |
4 | 4 |
long |
8 | 4 |
| pointer | 8 | 4 |
char |
signed | unsigned |
Cortex-M uses the ILP32 data model: int, long and a pointer are 32 bits each.
A 64-bit computer uses LP64: long and a pointer are 64 bits. The difference in the sign
of char does not show in bytes, but it changes results: (char)200 is 200 on ARM
and -56 on the computer.
Hence the first firmware rule: where width matters, use types from stdint.h:
uint8_t, int16_t, uint32_t. char is only for text, int is for
counters and indexes where the width is not essential.
122.2 Promotion: everything smaller than int becomes int
Before computing anything, C turns char, short, uint8_t,
uint16_t into int. So two uint8_t values 200 and 100 give 300, not
an overflowed 44: the addition is already done in int, and the type of the sum is int,
4 bytes. Truncation to a byte happens only when the result is written back to a uint8_t.
The trap is the ~ operator:
uint8_t x = 0x0F;
printf("not_u8=%d\n", ~x); /* ~ над int: 0xFFFFFFF0 = -16 */
printf("not_u8_eq=%d\n", ~x == 0xF0); /* и поэтому не 0xF0 */
x first became an int with the value 15, and ~ inverted all 32 bits:
the result is -16, not 0xF0. The comparison is false. The correct way is to bring the result
back to a byte before comparing:
return (uint8_t)~x == 0xF0u;
gcc warns about this directly: "comparison of promoted bitwise complement of an unsigned value with constant".
122.3 Signed versus unsigned
When an int and an unsigned int meet in one operation, the signed one
is converted to unsigned. -1 becomes 4294967295, and the comparison
-1 < 1u is false. The compiler warns about it with -Wsign-compare,
and this option is worth keeping on.
The rule is decided by width, not by sign, and here the machines diverge.
The comparison (long)-1 < 1u is true on the computer: long is wider there than
unsigned int, and -1 stays -1. On Cortex-M long has the same width,
-1 is converted to unsigned again, and the comparison is false. One line,
two answers: code "tested on the computer" behaves differently on the board.
122.4 Unsigned types count modulo
For unsigned types, wrapping through zero is defined: UINT32_MAX + 1 is 0,
(uint8_t)300 is 44 (the low byte remains), (uint8_t)-1 is 255.
Half of all firmware relies on this: counters, ring buffers, checksums.
For signed types it is different: int overflow is undefined behavior,
the compiler may assume it never happens and build
optimizations on that (section 123). Narrowing to a signed type out of range
is "implementation-defined": (int8_t)200 gives -56 with gcc, clang and the ARM compilers,
but that is an agreement among compilers, not a rule of the language.
122.5 Ticks and timeouts across overflow
This is where it hits firmware most often. The SysTick counter is 32 bits
and will overflow one day; at 1 kHz that is after about 49.7 days
of continuous operation. A device that has run for a month and a half suddenly
stops waiting.
Experiment: start 16 ticks before the overflow, timeout 100. The naive check compares against a computed moment:
uint32_t deadline = start + timeout;
return g_ticks >= deadline;
start + timeout overflowed and became 84, while the counter before the overflow
is larger than 84, so the condition is true at once, at the very first moment of waiting (the experiment checked
it after 8 ticks). After the overflow it becomes false again for a while,
until the counter reaches 84: the naive check also jumps back and forth.
The correct way is to compare elapsed time, a difference of unsigned values:
return (uint32_t)(g_ticks - start) >= timeout;
The difference modulo 2^32 gives the real number of ticks even across the overflow:
8 ticks is too early, 112 means it is time. The cast to uint32_t does not change the
code on the board, but it protects against promotion if the counter ever becomes a uint16_t.
122.6 Shifts
- A shift by the width of the type or more is undefined behavior:
x << 32for a 32-bitxmay give anything. - Shifting a one into the sign bit:
1 << 31forintis undefined. The correct form is1u << 31, which is 2147483648. - Right shift of a negative number is implementation-defined. gcc, clang
and ARM shift arithmetically, keeping the sign:
-16 >> 2= -4. For register fields and masks, use unsigned types: there the shift is always logical.
Hence the habit from sections 3а and 8: masks are written 1u << n, not 1 << n.
122.7 Constants have a type too
0xFFFFFFFF does not fit in an int, so it is an unsigned int, and -1 ==
0xFFFFFFFF is true. 'a' in C is an int, not a char. 100000 on a machine
with a 16-bit int would be a long. The suffixes u, UL, ULL make the type
explicit and remove surprises: 1u << 31, 100000UL.
122.8 The data model: check it, do not assume it
A file for the board begins with static assertions: if the code is ever built for another machine, the compiler refuses instead of quietly producing a wrong result.
_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 built this file for both F103 and F407, and the assertions hold on both.
122.9 How to write so you do not have to think about it
- Width matters: types from
stdint.h; registers, masks, counters are unsigned. - Mix signed and unsigned in a comparison only with an explicit cast.
- Keep
-Wall -Wextra -Wsign-compare -Wconversionon: the compiler finds much of the above by itself, but not everything. A comparison withstart + timeoutis correct code to it, and it flags1 << 31only with-Wshift-overflow=2. - Time is measured by a difference of unsigned values, never by comparing with
start + timeout. - The result of bitwise operations on bytes goes back into a byte before comparing.
- Check the data model with
_Static_assertat the top of the module.
122.10 Pitfalls
The timeout fired too early after a month and a half of operation.
A comparison with start + timeout, which overflowed. Use a difference of unsigned values.
The check ~x == 0xF0 is always false. Promotion to int: ~0x0F is
-16. Use (uint8_t)~x == 0xF0.
-1 < 1u is false. The signed value was converted to unsigned. Cast explicitly,
keep -Wsign-compare.
Works on the computer, not on the board. A different data model:
long and the pointer, the sign of char. Types from stdint.h, _Static_assert.
1 << 31 in a mask. Undefined behavior for int. Use 1u << 31.
A byte from a char is negative. On x86 char is signed. For bytes, use
uint8_t.
122.11 Summary
- Cortex-M is ILP32:
int,longand a pointer are 4 bytes each,charis unsigned; on the computerlongand a pointer are 8 bytes each,charis signed. - Everything smaller than
intbecomesint: 200 + 100 in bytes is 300, and~0x0Fis -16. - Signed with unsigned becomes unsigned;
(long)-1 < 1uis 1 on the computer and 0 on Cortex-M. - Unsigned types count modulo (
(uint8_t)300= 44,(uint8_t)-1= 255), signed overflow is undefined behavior,(int8_t)200= -56 is an agreement among compilers. - A timeout is a difference: the naive comparison fires at once, not after 100 ticks.
- Masks are
1u << n;1u << 31= 2147483648.
Full source code
int122.c is the experiment with integer types and conversions on three machines.
/* Раздел 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 shows the same rules on Cortex-M: the data model and a timeout across overflow.
/* Раздел 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;
}
Next: section 123 "Undefined behavior" — what happens when the rules are broken, and how the compiler and sanitizers catch it.