Embedded C
Data Types & Integer Overflow
The C standard doesn't guarantee an int is 32 bits — only
that it's at least 16. On a desktop that ambiguity almost
never bites anyone; on embedded, where the same codebase might target
an 8-bit AVR and a 32-bit Cortex-M, it's exactly the kind of thing
that silently changes behavior between platforms. This page covers
what the types actually guarantee, why unsigned shows up
constantly in embedded code, and what actually happens — bit for
bit — when a value overflows.
- What it is
- How C represents integers in memory, and the fixed-width types (
stdint.h) embedded code uses instead of the ambiguous built-in ones. - Mental model
- An unsigned integer is a car odometer — it rolls over to zero silently when it runs out of digits. A signed integer overflowing is undefined behavior — the "odometer" analogy doesn't even apply, the compiler is allowed to assume it never happens.
- Key numbers
- An 8-bit unsigned value: 0 to 255. An 8-bit signed value: -128 to 127 — same 256 total values, split differently.
- Reach for it when
- Choosing a type for anything that crosses a hardware register, a wire protocol, or a subtraction between two counters that could ever produce a negative logical result.
Why stdint.h exists
char, short, int,
long, and long long each only guarantee a
minimum width — the standard leaves the exact size up to the
compiler and target architecture. An int is 16 bits on
some small microcontroller toolchains and 32 bits on almost everything
else; writing code that assumes one or the other is silently
non-portable. <stdint.h> fixes this with
fixed-width types that mean exactly what they say on every
conforming compiler:
| Type | Width | Range |
|---|---|---|
uint8_t | 8 bits | 0 to 255 |
int8_t | 8 bits | -128 to 127 |
uint16_t | 16 bits | 0 to 65,535 |
int16_t | 16 bits | -32,768 to 32,767 |
uint32_t | 32 bits | 0 to 4,294,967,295 |
int32_t | 32 bits | -2,147,483,648 to 2,147,483,647 |
Any embedded codebase that talks to hardware registers or a wire
protocol should reach for these instead of int/
unsigned — a struct sent over CAN
or read from an I2C register genuinely has a fixed bit width, and the
type should say so explicitly rather than relying on whatever
int happens to mean on this particular toolchain.
Signed vs. unsigned representation
Both a signed and unsigned 8-bit integer are 256 possible bit
patterns — the difference is purely how those patterns are
interpreted. Unsigned reads the bits directly as a magnitude:
0000 0000 through 1111 1111 is 0 through
255. Signed integers use two's complement: the
top bit becomes a sign bit, so the same 256 patterns split into
-128 through 127 instead — half the positive range, in exchange for
representing negative numbers at all.
Why embedded code reaches for uint
- The value is inherently non-negative — a buffer length, a pin number, a byte count, or a raw register value can never logically be negative; declaring it
unsigneddocuments that intent and lets a reviewer (or the compiler, with warnings on) catch a bug that would let it go negative. - Bitwise operations behave predictably — shifting, masking, and combining raw bits with
&/|/^is well-defined on unsigned types. A right-shift of a negative signed value is implementation-defined in C — not guaranteed to be the arithmetic shift most people assume — which makes signed types a real hazard for register-level bit manipulation. - Full range for the same memory — when a value truly can't be negative, unsigned doubles the usable range in the same number of bits — meaningful when every byte of RAM is budgeted.
- Hardware registers are unsigned bit patterns — a peripheral register doesn't have a sign; representing it as
uint32_tis the accurate mapping to what the hardware actually is, not a style preference.
What rollover actually is
Unsigned arithmetic in C is defined to wrap modulo 2n — this
is not a bug or undefined behavior, it's exactly what the standard
guarantees will happen. Adding 1 to the maximum value of a
uint8_t doesn't error, doesn't saturate — it silently
becomes 0.
int8_t past 127 is undefined behavior, and an optimizer is legally allowed to assume it never happens.The bug this actually causes in the field almost never looks like the diagram above — it looks like an unsigned subtraction going the wrong direction:
uint32_t deadline = start_time + TIMEOUT_MS;
uint32_t remaining = deadline - now();
// if now() has already passed deadline, "remaining" doesn't go negative —
// there's no negative in unsigned arithmetic. It wraps to a number near
// UINT32_MAX, and code checking "if (remaining > 0) keep_waiting();"
// concludes there's still billions of milliseconds left to wait.
Integer promotion and the comparison trap
Small types are promoted to int before most operations —
a uint8_t in an expression is temporarily treated as an
int, which is usually invisible but occasionally matters.
Far more dangerous: comparing a signed value to an
unsigned one silently converts the signed value to
unsigned first.
int x = -1;
unsigned int y = 0;
if (x > y) {
// this branch RUNS — x is converted to UINT_MAX before comparing,
// and UINT_MAX really is greater than 0
}
Real-world applications
- Register-width matching — writing an 8-bit peripheral register with a
uint8_tinstead ofintprevents a value silently truncated by the hardware from ever looking correct in the C code that wrote it. - Protocol field widths — a I2C/SPI frame field with a fixed bit width maps directly onto a
stdint.htype — the type declares the wire format instead of leaving it implicit. - Buffer index sizing — sizing a loop counter or array index to match the actual buffer size (rather than defaulting to
int) avoids the classic off-by-one/overflow class of bug in a fixed-size ring buffer (see Stacks, Queues & Ring Buffers).
Gotchas
- Never silence a signed/unsigned comparison compiler warning — it's flagging exactly the conversion trap shown above, not being pedantic.
- Unsigned subtraction that can logically go negative (a "time remaining" or "bytes left" calculation) needs an explicit check for the wraparound case before subtracting, not after.
- Assuming
intis a specific width across different toolchains/architectures is non-portable — always use explicitstdint.hwidths for anything that crosses a struct boundary, a register, or a wire protocol.