Part 1 · The C Language32 min read
Types, Sizes, and How Numbers Are Stored
A firmware engineer has to know exactly how many bits every variable occupies and what those bits mean, because the hardware does. A peripheral register is exactly 32 bits wide. A sensor sends a 16-bit two’s complement value. A protocol header has a field that is exactly one byte. Get the type wrong and you read garbage, overflow silently, or burn thousands of cycles on floating-point code the chip cannot execute in hardware. This lesson covers C’s integer types and why their sizes vary, the fixed-width types you will actually use, how signed numbers are encoded, what happens on overflow, how IEEE 754 floats are laid out, and how to print all of it correctly.
The basic integer types
Section titled “The basic integer types”C has five standard signed integer types, each with an unsigned counterpart:
| Type | Standard minimum width | Typical on Cortex-M (ILP32) | x86-64 Linux/macOS (LP64) | 64-bit Windows (LLP64) | AVR (8-bit) |
|---|---|---|---|---|---|
char | 8 bits | 8 | 8 | 8 | 8 |
short | 16 bits | 16 | 16 | 16 | 16 |
int | 16 bits | 32 | 32 | 32 | 16 |
long | 32 bits | 32 | 64 | 32 | 32 |
long long | 64 bits | 64 | 64 | 64 | 64 |
| pointer | n/a | 32 | 64 | 64 | 16 |
The standard deliberately fixes only minimums and an ordering (char no wider than short, no wider than int, and so on). int is meant to be the CPU’s natural word size, which is why it is 16 bits on an 8-bit AVR and 32 bits on an Arm Cortex-M. The names on the columns (ILP32, LP64, LLP64) are data models: they say which of int, long and pointers are 32 or 64 bits.
The consequence is direct. A program that stores a 32-bit register value in an unsigned int works on the STM32 and silently truncates on an AVR. A program that puts a pointer into a long works on Linux and loses half the address on 64-bit Windows. Code written on a PC and moved to a microcontroller breaks in exactly these places.
sizeof and limits.h
Section titled “sizeof and limits.h”sizeof gives the size of a type or object in bytes, as a value of type size_t. limits.h gives the ranges.
#include <stdio.h>#include <stdbool.h>#include <stddef.h>#include <limits.h>
int main(void){ printf("char %zu\n", sizeof(char)); printf("short %zu\n", sizeof(short)); printf("int %zu\n", sizeof(int)); printf("long %zu\n", sizeof(long)); printf("long long %zu\n", sizeof(long long)); printf("void * %zu\n", sizeof(void *)); printf("size_t %zu\n", sizeof(size_t)); printf("float %zu\n", sizeof(float)); printf("double %zu\n", sizeof(double)); printf("bool %zu\n", sizeof(bool)); printf("CHAR_BIT=%d INT_MIN=%d INT_MAX=%d\n", CHAR_BIT, INT_MIN, INT_MAX); printf("UINT_MAX=%u LONG_MAX=%ld\n", UINT_MAX, LONG_MAX); return 0;}On x86-64 Linux:
char 1short 2int 4long 8long long 8void * 8size_t 8float 4double 8bool 1CHAR_BIT=8 INT_MIN=-2147483648 INT_MAX=2147483647UINT_MAX=4294967295 LONG_MAX=9223372036854775807On the STM32F407 with arm-none-eabi-gcc, long, void * and size_t are 4 bytes and LONG_MAX is 2147483647. Everything else matches. sizeof(char) is 1 by definition: a “byte” in C means “the size of a char”, and CHAR_BIT says how many bits that is. It is 8 on every machine you will meet in this course, though some DSPs (TI C2000, for example) have 16-bit char.
%zu is the printf conversion for size_t. The format table near the end of this lesson lists the rest.
Fixed-width types: stdint.h
Section titled “Fixed-width types: stdint.h”Because the basic types vary, C99 added stdint.h. In firmware these are the types you use for almost everything:
| Type | Width | Range |
|---|---|---|
int8_t / uint8_t | exactly 8 | -128 to 127 / 0 to 255 |
int16_t / uint16_t | exactly 16 | -32768 to 32767 / 0 to 65535 |
int32_t / uint32_t | exactly 32 | -2147483648 to 2147483647 / 0 to 4294967295 |
int64_t / uint64_t | exactly 64 | about plus or minus 9.2e18 / 0 to about 1.8e19 |
The limits are available as macros: INT8_MIN, UINT16_MAX, INT32_MAX, UINT64_MAX and so on. To write a constant of a specific type, use UINT32_C(100000) or INT64_C(1), which add the correct suffix for the platform.
The exact-width types are technically optional: they exist only if the machine has a type of exactly that width with no padding bits, and the signed ones must use two’s complement. On a DSP with 16-bit char, uint8_t simply does not exist. For that reason stdint.h also defines two families that always exist:
uint_least16_tand friends: the smallest type with at least that many bits. Use it when you need a guaranteed minimum range and care about memory, in code that must port to unusual machines.uint_fast16_tand friends: the type with at least that many bits that the implementation considers fastest. Onarm-none-eabi(newlib), everyfasttype up to 32 bits is a 32-bitint, because the Cortex-M does arithmetic in 32-bit registers and narrower types need extra instructions to truncate. On x86-64 glibc,uint_fast8_tis one byte butuint_fast16_tanduint_fast32_tare eight bytes. “Fast” is the implementation’s opinion, not a law.
The practical rule for firmware:
- Registers, wire formats, file and flash layouts, anything whose size is dictated from outside: exact-width types, always.
- Counters and loop indices:
uint32_torint32_ton a 32-bit MCU is natural and fast.intis fine when the value is small and the code does not care about the exact width, which is why it still appears in loops. - Sizes and indexes into memory:
size_t(below).
size_t, ptrdiff_t, uintptr_t
Section titled “size_t, ptrdiff_t, uintptr_t”Three more types appear constantly:
size_t(fromstddef.h, alsostdio.h,string.h,stdlib.h): the unsigned type thatsizeofproduces and thatstrlen,memcpy,mallocand friends use for sizes and counts. It is 32 bits on Cortex-M and 64 bits on x86-64. Use it for buffer lengths and array indexes. Print it with%zu.ptrdiff_t(fromstddef.h): the signed type you get when you subtract two pointers. Print it with%td.uintptr_t(fromstdint.h): an unsigned integer type wide enough to hold a pointer converted to an integer and back without loss. Firmware uses it for address arithmetic, for example checking alignment or building a pointer to a peripheral at0x40020C00. It is optional in the standard but present on every target you will use. Pointers I: addresses, arithmetic, andconstuses it in depth.
C99 added the type _Bool, and stdbool.h provides the friendlier names bool, true (1) and false (0). (In C23 bool, true and false become keywords and the header is unnecessary.) A bool can hold only 0 or 1, and the conversion rule is special: any nonzero value converts to 1. Other narrow types just truncate. That difference has caused real bugs:
#include <stdbool.h>#include <stdint.h>
#define STATUS_READY 0x100u /* bit 8 of a 32-bit status register */
void check(uint32_t status){ uint8_t ready8 = status & STATUS_READY; /* bug: bit 8 does not fit in 8 bits, always 0 */ bool ready = status & STATUS_READY; /* correct: nonzero becomes true */ (void)ready8; (void)ready;}ready8 is always 0 because the conversion to uint8_t keeps only the low 8 bits, and bit 8 is not among them. ready is true whenever the bit is set. Use bool for flags, and when you must store a flag in an integer, write the comparison explicitly: (status & STATUS_READY) != 0. The (void)ready8; lines only silence the unused-variable warning in this demonstration. -Wconversion (introduced in Operators, expressions, and implicit conversions) flags the uint8_t line; -Wall alone does not.
Integer literals
Section titled “Integer literals”How you write a number determines its type, and sometimes its value.
| Written as | Base | Value | Notes |
|---|---|---|---|
255 | decimal | 255 | |
0xFF | hexadecimal | 255 | Use for masks and register values |
0377 | octal | 255 | A leading 0 means octal |
0b11111111 | binary | 255 | Standard only in C23; a GCC/Clang extension before that, flagged by -Wpedantic |
The octal rule is a genuine trap. int codes[] = { 100, 042, 007 }; holds 100, 34 and 7: someone padded the numbers for alignment and changed their value. Never pad decimal constants with leading zeros.
Suffixes set the type: u or U for unsigned, l or L for long, ll or LL for long long, combinable as UL or ULL. Prefer uppercase L, since l looks like 1.
Without a suffix, the type is the first one in a list that can represent the value:
- Decimal:
int, thenlong, thenlong long. - Hex or octal:
int,unsigned int,long,unsigned long,long long,unsigned long long.
So on a Cortex-M (32-bit int and long), 0x80000000 is an unsigned int, but 2147483648 is a long long because a decimal literal never becomes unsigned. Same value, different types, and different behavior in expressions. This is also why limits.h defines INT_MIN as (-INT_MAX - 1): the text -2147483648 is the unary minus applied to 2147483648, which is already too big for int.
Character constants like 'A' have type int in C (value 65 in ASCII), not char. Escape sequences give special characters: '\n' (newline, 10), '\0' (the NUL byte, 0), '\x41' (hex, 65).
Floating literals are double by default: 0.1, 1e-3, 2.0. Add f for float: 0.1f. That one letter matters a great deal on a Cortex-M4F, as you will see below.
Two’s complement
Section titled “Two’s complement”Unsigned integers are plain binary: bit n has weight 2 to the power n. An 8-bit unsigned value ranges from 0 (00000000) to 255 (11111111).
For signed integers, every machine you will program uses two’s complement. The rule is simple: the top bit has a negative weight.
bit: 7 6 5 4 3 2 1 0weight: -128 64 32 16 8 4 2 1
5 = 0 0 0 0 0 1 0 1 = 4 + 1-5 = 1 1 1 1 1 0 1 1 = -128 + 64+32+16+8 + 2+1 = -128 + 123-1 = 1 1 1 1 1 1 1 1 = -128 + 127-128 = 1 0 0 0 0 0 0 0127 = 0 1 1 1 1 1 1 1Consequences you should be able to derive:
- Negation is “invert all bits, then add 1”. 5 is
00000101, inverted11111010, plus 1 is11111011, which is -5. - The top bit is the sign bit. All negative values have it set.
- The range is asymmetric: -128 to 127 for 8 bits, -2147483648 to 2147483647 for 32 bits. The most negative value has no positive counterpart, so
-INT32_MINandabs(INT32_MIN)overflow. - -1 is all ones in every width:
0xFF,0xFFFF,0xFFFFFFFF.
Here is a program that prints the patterns:
#include <stdio.h>#include <stdint.h>
static void print_bits8(int8_t value){ uint8_t bits = (uint8_t)value; /* same bits, viewed as unsigned */
printf("%4d = ", value); for (int i = 7; i >= 0; i--) { putchar(((bits >> i) & 1u) ? '1' : '0'); } printf(" (0x%02X)\n", (unsigned)bits);}
int main(void){ const int8_t samples[] = { 0, 1, 5, 127, -1, -5, -128 };
for (size_t i = 0; i < sizeof samples / sizeof samples[0]; i++) { print_bits8(samples[i]); } return 0;} 0 = 00000000 (0x00) 1 = 00000001 (0x01) 5 = 00000101 (0x05) 127 = 01111111 (0x7F) -1 = 11111111 (0xFF) -5 = 11111011 (0xFB)-128 = 10000000 (0x80)Converting a signed value to the unsigned type of the same width, as (uint8_t)value does, is always well defined: the result is the value modulo 256, which for two’s complement means “the same bits”. The loop shifts the byte right by i and masks off the lowest bit. sizeof samples / sizeof samples[0] is the element count of the array, an idiom covered in Arrays, strings, and tables.
Why hardware loves two’s complement
Section titled “Why hardware loves two’s complement”Adding two’s complement numbers uses exactly the same circuit as adding unsigned numbers. 0xFB + 0x07 gives 0x02 (with a carry out) whether you read it as 251 + 7 = 258 mod 256, or -5 + 7 = 2. The CPU does not know or care whether a register holds a signed or unsigned value. Only the interpretation differs, and on Arm that shows up in two places:
- Flags.
ADDSandCMPset both the C flag (carry, meaning unsigned overflow) and the V flag (signed overflow). Signed comparisons branch withBLT/BGT(which test N and V); unsigned ones useBLO/BHI(which test C). When you compare two variables, the C types decide which branch instruction the compiler emits. - Widening. Loading an
int8_tusesLDRSB(load register signed byte), which copies the sign bit into the upper 24 bits: -5 becomes0xFFFFFFFB. Loading auint8_tusesLDRB, which fills with zeros:0xFBbecomes0x000000FB. This is sign extension versus zero extension. In registers the equivalent instructions areSXTB/SXTHandUXTB/UXTH.
C11 technically still permits ones’ complement and sign-magnitude representations for the basic signed types; C23 removed them. No machine you will ever program uses them, and int8_t through int64_t are required to be two’s complement regardless.
Overflow: unsigned wraps, signed is undefined
Section titled “Overflow: unsigned wraps, signed is undefined”Unsigned arithmetic in C is defined to be modulo 2 to the power N. It never overflows in the error sense; it wraps.
uint8_t u8 = 255;u8++; /* now 0 */
uint32_t u32 = 0;u32--; /* now 4294967295 (0xFFFFFFFF) */Firmware relies on this constantly. A free-running 32-bit millisecond counter wraps every 49.7 days, and (uint32_t)(now - start) still gives the correct elapsed time across the wrap: if start is 0xFFFFFFF0 and now is 0x00000010, the subtraction yields 0x20, which is 32 ms. Clocks, reset, and timing builds a wrap-safe delay on this.
Signed overflow is undefined behavior. If int32_t x = INT32_MAX; and you compute x + 1, the C standard places no requirements at all on what happens. On the hardware, the ADD instruction would produce 0x80000000 (-2147483648), and many people assume that is what C gives you. It is not guaranteed, because the optimizer is allowed to assume signed overflow never happens:
#include <stdbool.h>#include <stdint.h>
bool will_overflow(int32_t x){ return x + 1 < x; /* intended overflow check */}At -O2, GCC compiles this to return false;. Its reasoning is valid under the rules: for every x where x + 1 is defined, x + 1 < x is false, and the case where it is not defined “cannot happen”. The correct check tests before the operation: return x == INT32_MAX;. The same reasoning lets the compiler simplify (x * 4) / 4 to x and assume a loop with a signed counter terminates. Undefined behavior, volatile, and what the optimizer assumes covers this thoroughly. For now, the rule: if a value can exceed its range, make it unsigned or check before the operation.
What about converting a value that does not fit? Converting to an unsigned type is always defined (modulo again): (uint8_t)300 is 44. Converting an out-of-range value to a signed type is implementation-defined; GCC and Clang wrap it, so (int8_t)200 is -56, but the standard does not promise that. The implicit conversions that sneak these into your expressions are the subject of Operators, expressions, and implicit conversions.
char is signed or unsigned, depending on the platform
Section titled “char is signed or unsigned, depending on the platform”C has three distinct character types: char, signed char and unsigned char. Plain char behaves like one of the other two, and which one is implementation-defined. On x86 it is signed. On Arm, the procedure call standard makes it unsigned. Code that assumes one or the other changes behavior when it moves between your PC and your board:
#include <stdio.h>
int main(void){ char c = (char)0xE9; /* a byte from a UART, or Latin-1 'e-acute' */ int n = c;
printf("%d %s\n", n, (c < 0) ? "negative" : "non-negative"); return 0;}On x86-64 this prints -23 negative. Built for Arm it prints 233 non-negative. If n is then used as an array index, the x86 build reads 23 bytes before the start of the array.
The most famous instance is the input loop:
char c;while ((c = getchar()) != EOF) { /* bug on Arm: loops forever */ /* ... */}getchar returns an int: a byte value 0 to 255, or EOF (-1). Squeezing it into an unsigned char turns -1 into 255, and 255 never equals -1, so on Arm the loop never ends. With -Wextra, GCC can warn that the comparison is always true due to the limited range of the type. The fix is int c;.
Rules that make this a non-issue:
- Use
charonly for text. - Use
uint8_tfor raw bytes (buffers, packets, register values) andint8_tfor small signed numbers. - Cast to
unsigned charbefore passing achartoctype.hfunctions likeisdigit, and before using it as an index.
GCC lets you override the default with -fsigned-char or -funsigned-char, which is occasionally useful when porting legacy code, but code that needs the flag is code that should be fixed.
Floating point: IEEE 754
Section titled “Floating point: IEEE 754”float and double on every target in this course are IEEE 754 binary formats. A floating-point number is stored as a sign, an exponent and a fraction, like scientific notation in base 2:
float (binary32): 31 30 23 22 0+---+-------------+-----------------------------------+| S | exponent | fraction |+---+-------------+-----------------------------------+ 1 8 bits 23 bits
value = (-1)^S x 1.fraction x 2^(exponent - 127)
double (binary64): 1 sign bit, 11 exponent bits (bias 1023), 52 fraction bitsFor normal numbers the leading 1. is implicit (not stored), which gives a float 24 bits of precision, about 7 significant decimal digits, and a double 53 bits, about 15 to 16 digits. The exponent is stored with a bias (127 for float) so that it can be compared as an unsigned field.
Decode 1.0f: sign 0, value 1.0 x 2^0, so the stored exponent is 0 + 127 = 127 (01111111) and the fraction is all zeros. The bits are 0 01111111 00000000000000000000000, which is 0x3F800000. A program can show this directly:
#include <stdio.h>#include <stdint.h>#include <string.h>#include <inttypes.h>
static uint32_t float_bits(float f){ uint32_t u; memcpy(&u, &f, sizeof u); /* copy the 4 bytes; see the note below */ return u;}
int main(void){ const float values[] = { 1.0f, -2.0f, 0.1f, 0.5f };
for (size_t i = 0; i < sizeof values / sizeof values[0]; i++) { uint32_t u = float_bits(values[i]); printf("%-5g 0x%08" PRIX32 " sign=%" PRIu32 " exp=%3" PRIu32 " frac=0x%06" PRIX32 "\n", (double)values[i], u, u >> 31, (u >> 23) & 0xFFu, u & 0x7FFFFFu); } return 0;}1 0x3F800000 sign=0 exp=127 frac=0x000000-2 0xC0000000 sign=1 exp=128 frac=0x0000000.1 0x3DCCCCCD sign=0 exp=123 frac=0x4CCCCD0.5 0x3F000000 sign=0 exp=126 frac=0x000000memcpy copies the raw bytes of the float into a uint32_t. Casting a float * to uint32_t * instead would break C’s aliasing rules, which Undefined behavior, volatile, and what the optimizer assumes explains; the compiler turns this memcpy into a single register move. The shifts and masks pull out the three fields.
Look at 0.1f. The fraction 0x4CCCCD is the binary pattern 1001100110011... cut off and rounded up at bit 23. One tenth, like one third in decimal, has an infinitely repeating expansion in binary, so it cannot be stored exactly. The float closest to 0.1 is 0.100000001490116119384765625.
Why 0.1 + 0.2 != 0.3
Section titled “Why 0.1 + 0.2 != 0.3”#include <stdio.h>
int main(void){ double a = 0.1; double b = 0.2;
printf("%.17g\n%.17g\n%.17g\n", a, a + b, 0.3); printf("equal? %s\n", (a + b == 0.3) ? "yes" : "no"); return 0;}0.100000000000000010.300000000000000040.29999999999999999equal? noEach of 0.1, 0.2 and 0.3 is rounded to the nearest representable double when the program is compiled. The sum of the first two rounded values is then rounded again, and it lands on the neighbor just above the double nearest to 0.3. Nothing is broken; this is what finite binary precision means. The practical rules:
- Never compare floats with
==unless you know both values are exactly representable (small integers, halves, quarters). Compare with a tolerance appropriate to the quantity:fabsf(a - b) < 0.001ffor a voltage in volts, for example. - Never use floats for things that must be exact: counts, money, addresses, time accumulation.
The precision cliff
Section titled “The precision cliff”A float has 24 bits of precision, so above 2 to the power 24 (16777216) it cannot even represent every integer:
float t = 16777216.0f;t += 1.0f; /* t is still 16777216.0f: the result rounds back */Here is how that becomes a field bug. Suppose firmware keeps uptime as float seconds and adds 0.001f every millisecond. The gap between adjacent floats grows with magnitude. Past 16384 seconds the gap is about 0.00195, so adding 0.001 rounds up to a whole gap and the clock runs almost twice as fast. Past 32768 seconds (about 9 hours) the gap is 0.0039, adding 0.001 rounds to nothing, and the clock stops. The device passes every bench test that lasts less than an afternoon. Count time in integer ticks (uint32_t milliseconds) and convert to float only for display.
Special values
Section titled “Special values”IEEE 754 reserves exponent patterns for special cases:
| Exponent field | Fraction | Meaning |
|---|---|---|
| all zeros | zero | plus or minus zero (yes, -0.0f exists and compares equal to 0.0f) |
| all zeros | nonzero | subnormal: tiny numbers with reduced precision |
| all ones | zero | plus or minus infinity, for example 1.0f / 0.0f |
| all ones | nonzero | NaN (not a number), for example 0.0f / 0.0f or sqrtf(-1.0f) |
A NaN compares unequal to everything, including itself, so x != x is true only for NaN. NaNs propagate silently through arithmetic: one bad sensor reading can turn a whole control loop’s state into NaN. Check inputs with isnan from math.h at the boundary. (These results assume IEEE semantics, which GCC follows unless you enable -ffast-math, a flag that tells the compiler to assume NaN and infinity never occur.)
Floats on a microcontroller
Section titled “Floats on a microcontroller”The Cortex-M4F in the STM32F407 has a hardware FPU, but it is single precision only (FPv4-SP). That single fact drives most floating-point decisions on this chip:
| Operation | Cortex-M4F float | Cortex-M4F double | Cortex-M0/M3 (no FPU) |
|---|---|---|---|
| add, multiply | 1 cycle, one instruction (vadd.f32, vmul.f32) | software library call, tens of cycles or more | software, tens of cycles |
| divide, square root | about 14 cycles | software, much slower | software, slower still |
| code size | a few bytes per operation | pulls in several KB of soft-float library | several KB |
The trap is that double creeps in without you writing it:
float apply_gain(float x){ return x * 0.1; /* 0.1 is a double, so x is converted and the multiply is double */}Compiled for the M4F, this calls the library routines __aeabi_f2d (float to double), __aeabi_dmul (double multiply) and __aeabi_d2f (back to float). Change the literal to 0.1f and the whole function becomes a load of the constant, one vmul.f32 and a return. GCC’s -Wdouble-promotion flags the first version with a message like implicit conversion from 'float' to 'double' to match other operand of binary expression. Turn it on for every firmware build. Likewise use the float versions of math functions: sinf, sqrtf, fabsf, not sin, sqrt, fabs.
Promotion in printf and other variadic functions
Section titled “Promotion in printf and other variadic functions”When an argument is passed through the ... of a variadic function, the default argument promotions apply: float becomes double, and char, short and bool become int. So there is no way to pass a float to printf: %f always receives a double, and on the M4F every float you print first goes through a software conversion. (Embedded C libraries often leave float formatting out of printf entirely unless you ask for it; The C standard library on a microcontroller covers the cost.) The asymmetry with scanf is worth knowing: scanf receives pointers, which are not promoted, so there %f means float * and %lf means double *.
When to avoid floats
Section titled “When to avoid floats”- The chip has no FPU (Cortex-M0, M0+, M3): every operation is a library call. Use integers or fixed point.
- Inside interrupt handlers: using the FPU in an ISR forces the hardware to preserve the floating-point registers, adding latency. The Cortex-M architecture explains lazy stacking.
- For exact quantities: counts, time, addresses, protocol values.
- When results must be bit-identical across platforms or compiler settings.
Floats are perfectly good on the M4F for control loops, filters and sensor math in single precision. Just keep them float.
Fixed point, briefly
Section titled “Fixed point, briefly”The common alternative is to keep values as scaled integers. Store a voltage as int32_t millivolts instead of float volts. Or use a Q format, where an integer represents a value with an implied binary point: in Q16.16, the 32-bit integer 98304 means 98304 / 65536 = 1.5. Addition is plain integer addition; multiplication needs a wider intermediate and a shift, (int32_t)(((int64_t)a * b) >> 16). Fixed point is exact, fast on any CPU, and deterministic, at the cost of managing range and precision yourself. ADC, DAC, and analog basics develops it fully, including rounding and overflow.
Printing types correctly
Section titled “Printing types correctly”printf cannot check types at run time; it trusts the format string. A mismatch is undefined behavior, which in practice prints garbage or reads the wrong argument. -Wall checks literal format strings at compile time, which is one more reason to always enable it.
| Conversion | Argument type | Example output |
|---|---|---|
%d, %i | int (also char, short after promotion) | -42 |
%u | unsigned int | 42 |
%x, %X | unsigned int, hex | 2a, 2A |
%o | unsigned int, octal | 52 |
%c | int, printed as a character | A |
%s | char * to a NUL-terminated string | hello |
%p | void * | 0x20000010 (format varies) |
%ld, %lu, %lx | long, unsigned long | |
%lld, %llu, %llx | long long, unsigned long long | |
%hhu, %hu | unsigned char, unsigned short values | |
%zu | size_t | |
%td | ptrdiff_t | |
%f, %e, %g | double (and float, after promotion) | 3.140000, 3.140000e+00, 3.14 |
%% | none, prints a literal % | % |
Between the % and the letter you can add flags, a width and a precision: %08X pads with zeros to 8 hex digits (ideal for 32-bit registers), %-10s left-justifies in 10 columns, %.3f prints three decimals, %5d right-justifies in 5 columns.
inttypes.h and the PRI macros
Section titled “inttypes.h and the PRI macros”Since uint32_t might be unsigned int on one platform and unsigned long on another, no single conversion letter is correct everywhere. inttypes.h (which includes stdint.h) provides string macros that expand to the right one:
#include <stdio.h>#include <stdint.h>#include <inttypes.h>
int main(void){ uint32_t reg = 0x40020C14u; int32_t delta = -1500; uint64_t total = UINT64_C(5000000000);
printf("reg = 0x%08" PRIX32 "\n", reg); printf("delta = %" PRId32 "\n", delta); printf("total = %" PRIu64 "\n", total); return 0;}reg = 0x40020C14delta = -1500total = 5000000000PRIX32 is a string literal such as "X" or "lX", depending on the platform. C concatenates adjacent string literals at compile time, so "0x%08" PRIX32 "\n" becomes one format string. The family follows a pattern: PRI + conversion (d, i, u, x, X, o) + width (8, 16, 32, 64, PTR, MAX). It looks clumsy for a week and then becomes invisible. For input, the matching SCN macros (SCNu32) exist for scanf.
Common mistakes
Section titled “Common mistakes”- Assuming
intorlonghas a particular width: useuint32_t,int16_tand friends whenever the size matters. - Printing
uint32_twith%uor%lu: one of them warns on each platform. UsePRIu32. - Using
charfor bytes: its signedness differs between x86 and Arm. Useuint8_t. - Storing
getchar()in achar: theEOFtest breaks. Store it in anint. - Leading zeros on decimal constants:
010is 8. Remove the padding. - Relying on signed overflow wrapping: it is undefined, and the optimizer exploits that. Use unsigned types or check before the operation.
- Writing
0.1infloatcode on the M4F: it drags in software double arithmetic. Write0.1fand enable-Wdouble-promotion. - Comparing floats with
==: compare against a tolerance suited to the units. - Accumulating time or counts in a
float: precision runs out and the value drifts, then freezes. Use integer ticks. - Storing a flag bit in a narrow integer (
uint8_t f = reg & (1u << 8);): it truncates to 0. Useboolor!= 0.
Exercises
Section titled “Exercises”- Run
sizes.con your host. Then, without a board, find the sizes for the Cortex-M4: writeunsigned size_long(void) { return sizeof(long); }and similar functions forvoid *andlong long, compile witharm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb -S, and read the constants in the assembly. You should see 4, 4 and 8. - By hand, write the 8-bit two’s complement pattern of -2, -127 and -100, and the 16-bit pattern of -256. Extend
twos.cwith aprint_bits16function and check your answers. (-100 is10011100; -256 is0xFF00.) - Write a loop that starts a
floatat 0 and adds1.0funtil adding one no longer changes it. Print the final value with%.1f. Then repeat withdoubleand estimate (do not run) how long the loop would take: why is the answer impractical? - Compile
float g(float x) { return x * 0.1; }with the full target flags (-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -O2 -S). List theblcalls in the output and look up what each__aeabi_routine does from its name. Change0.1to0.1f, recompile, and count the instructions. Finally add-Wdouble-promotionto the first version and read the warning. - Write a function
void print_reg(const char *name, uint32_t value)that printsname: 0x40020C14 (1073875988). Compile it with-Wallfor the host and forarm-none-eabifirst using%u, then%lu, thenPRIu32. Record which combinations warn on which compiler and explain each warning.
Check yourself
Section titled “Check yourself”Question 1:What is the value of x after uint8_t x = 250; x += 10;?
The sum 260 is computed and converted back to uint8_t, which reduces it modulo 256, giving 4. Unsigned results wrap by definition; only signed overflow is undefined.
Question 2:On a Cortex-M built with GCC, char c = (char)0xFF; then if (c < 0) led_on();. Does the LED turn on?
The Arm procedure call standard makes plain char unsigned, so c holds 255 and is never negative. The same code on x86 would turn the LED on.
Question 3:With 32-bit int and long, which literal has type unsigned int?
A hex literal takes the first type from int, unsigned int, long, ... that fits, so 0x80000000 becomes unsigned int. A decimal literal never becomes unsigned; 2147483648 becomes long long here.
Question 4:On the STM32F407 (Cortex-M4F), why is float y = x * 0.5; much slower than float y = x * 0.5f;?
Unsuffixed floating literals are double, and C requires the arithmetic in the wider type. The M4F FPU handles only float, so double operations become software library calls.
Question 5:What is the portable way to print a uint32_t named n?
uint32_t is unsigned int on some platforms and unsigned long on others (including arm-none-eabi). PRIu32 from inttypes.h expands to the correct conversion for the platform.
Question 6:uint32_t status = 0x200; What are a and b after uint8_t a = status & 0x200; and bool b = status & 0x200;?
Converting 0x200 to uint8_t keeps only the low 8 bits, giving 0. Converting any nonzero value to bool gives 1 (true).
Key takeaways
Section titled “Key takeaways”- C fixes only minimum sizes for
char,short,int,longandlong long. On Cortex-M,int,longand pointers are all 32 bits; on a 64-bit PC,longand pointers usually are not. Usestdint.htypes whenever size matters. - Signed integers are two’s complement: the top bit has negative weight, -1 is all ones, negation is invert-and-add-one, and the range is asymmetric.
- Unsigned arithmetic wraps modulo 2^N by definition. Signed overflow is undefined behavior, and the optimizer assumes it never happens.
- Plain
charis unsigned on Arm and signed on x86. Usecharfor text anduint8_tfor bytes. floathas about 7 significant digits and cannot represent most decimal fractions exactly. Never compare with==, never accumulate time in it.- The Cortex-M4F FPU is single precision only: write
0.1f, usesinf, and enable-Wdouble-promotion. Variadic functions likeprintfalways promotefloattodouble. - Print fixed-width types with the
PRImacros frominttypes.h,size_twith%zu.
Next: Variables, scope, lifetime, and storage classes, where you learn where each variable lives, how long it lives, and who can see it.