Part 1 · The C Language37 min read
Pointers I — Addresses, Arithmetic, and const
A pointer is a variable that holds an address. That one sentence is the whole idea, and firmware is built on it: every peripheral register you touch is reached through a pointer, every buffer handed to a UART or DMA driver is a pointer plus a length, and every driver API that returns more than one value does it through pointers. This lesson builds the mental model from the bytes up. By the end you will be able to draw the memory behind any pointer expression, predict what p + 1 does for any pointer type, read every const placement correctly, and know exactly which pointer casts are safe on a Cortex-M4 and which ones end in a HardFault.
Memory is a numbered row of bytes
Section titled “Memory is a numbered row of bytes”From the CPU’s point of view, memory is one long row of bytes, each with a number. That number is its address. On the STM32F407 the address is 32 bits wide, so there are 2^32 possible byte addresses, and different ranges of that space are wired to different things: flash at 0x08000000, SRAM at 0x20000000, peripherals from 0x40000000. A load instruction puts an address on the bus and gets bytes back; it does not care whether those bytes came from SRAM or from a GPIO register.
A variable is a named region of that row. A uint32_t occupies four consecutive bytes; the address of the variable is the address of its first (lowest) byte.
SRAM (addresses increase downward) +------+0x20000100 | 0x2A | <- count (uint32_t, 4 bytes), value 420x20000101 | 0x00 |0x20000102 | 0x00 |0x20000103 | 0x00 | +------+0x20000104 | ... | <- whatever the linker placed nextNotice the byte order: the least significant byte (0x2A) sits at the lowest address. Cortex-M is little-endian, and so is every x86 host. Endianness gets a full treatment in Bit manipulation; for now just remember that a multi-byte value is stored low byte first on everything you will use in this course.
The & and * operators
Section titled “The & and * operators”Two operators do all the work:
&xgives the address ofx. Its type is “pointer to the type ofx”.*pfollows the address inpand names the object stored there. You can read it or assign to it.
#include <inttypes.h>#include <stdio.h>
int main(void){ uint32_t count = 42u; uint32_t *p = &count; /* p holds the address of count */
printf("count = %" PRIu32 "\n", count); printf("&count = %p\n", (void *)&count); printf("p = %p\n", (void *)p); printf("*p = %" PRIu32 "\n", *p);
*p = 7u; /* writes through the pointer into count */ printf("count = %" PRIu32 "\n", count); return 0;}count = 42&count = 0x7ffd3c5e1a4cp = 0x7ffd3c5e1a4c*p = 42count = 7Your addresses will differ; on a 64-bit host they are large stack addresses, and they often change between runs because of address-space randomisation. On the STM32 they would be small numbers in the 0x2000xxxx range.
The %p specifier requires a void * argument, so the casts are not decoration. Passing a uint32_t * straight to %p is technically undefined, and -Wpedantic with format checking will tell you so.
Here is the memory after uint32_t *p = &count;, drawn with target-sized (4-byte) pointers:
+------------+0x20000100 | 0x0000002A | count (uint32_t) +------------+0x20000104 | 0x20000100 | p (uint32_t *) ---+ +------------+ | ^ | +------------------------------------+ p "points to" countThere is nothing magic about p. It is a 4-byte variable whose value happens to be another variable’s address. *p = 7u means: load the value of p (0x20000100), then store 7 at that address. Here is what the compiler emits for a function that does exactly that, built with -O2 -mcpu=cortex-m4 -mthumb:
#include <stdint.h>
void set_seven(uint32_t *p){ *p = 7u;}set_seven: movs r3, #7 @ r3 = 7 str r3, [r0] @ store word at address held in r0 (p) bx lrThe pointer arrives in r0 (the first argument register, as you saw in Functions and the calling convention). str r3, [r0] is the dereference. At the machine level a dereference is just “use this register as an address”.
Declaration syntax
Section titled “Declaration syntax”In uint32_t *p, the * belongs to the declarator, not to the type. This matters when you declare more than one name:
uint32_t *a, b; /* a is uint32_t *, b is plain uint32_t */Declare one pointer per line and the problem disappears. Also note that * means two different things: in a declaration it says “this is a pointer”; in an expression it says “follow this pointer”. The declaration was designed to mirror use: uint32_t *p reads as “*p is a uint32_t”.
Why the pointed-to type matters
Section titled “Why the pointed-to type matters”Every pointer is just an address, so why does C distinguish uint32_t * from uint8_t *? Because the type tells the compiler two things it cannot know from the address alone:
- How many bytes to read or write on a dereference, and how to interpret them.
- How far to move when you do arithmetic on the pointer.
The first point is easy to see by looking at the same address through two pointer types:
#include <stdint.h>#include <stdio.h>
int main(void){ uint32_t word = 0x11223344u; const uint8_t *b = (const uint8_t *)&word;
for (int i = 0; i < 4; i++) { printf("offset %d: 0x%02X\n", i, (unsigned)b[i]); } return 0;}offset 0: 0x44offset 1: 0x33offset 2: 0x22offset 3: 0x11 +------+0x20000100 | 0x44 | <- word starts here; b points here too0x20000101 | 0x33 |0x20000102 | 0x22 |0x20000103 | 0x11 | +------+
*(uint32_t *)0x20000100 reads 4 bytes -> 0x11223344 *(uint8_t *)0x20000100 reads 1 byte -> 0x44Both pointers hold 0x20000100. Dereferencing &word reads four bytes and assembles 0x11223344; dereferencing b reads one byte. The compiler picks a different instruction for each:
#include <stdint.h>
uint32_t load_word(const uint32_t *p) { return *p; }uint32_t load_byte(const uint8_t *p) { return *p; }uint32_t load_half(const int16_t *p) { return (uint32_t)*p; }load_word: ldr r0, [r0] @ 32-bit load bx lrload_byte: ldrb r0, [r0] @ 8-bit load, zero-extended bx lrload_half: ldrsh r0, [r0] @ 16-bit load, sign-extended bx lrldr, ldrb and ldrsh are different instructions. The pointer type is how you choose between them. When you write to a peripheral register that must be accessed as a full 32-bit word, using a uint8_t * would emit strb and the peripheral might ignore the write or behave differently. Register width is part of the contract.
Reading any object’s bytes through unsigned char * (or uint8_t *, which is unsigned char on every compiler you will meet) is always allowed. Reading a float through a uint32_t * is not: that breaks the strict aliasing rule, which Undefined behavior, volatile, and what the optimizer assumes covers in depth.
Pointer size: target versus host
Section titled “Pointer size: target versus host”A pointer must be big enough to hold any address. On a 32-bit MCU that is 4 bytes; on a 64-bit PC it is 8.
| Type | Cortex-M4 (arm-none-eabi-gcc) | x86-64 Linux/macOS | x86-64 Windows |
|---|---|---|---|
uint32_t *, char *, void * | 4 | 8 | 8 |
| Function pointer | 4 | 8 | 8 |
int | 4 | 4 | 4 |
long | 4 | 8 | 4 |
uintptr_t, size_t | 4 | 8 | 8 |
Two consequences matter in practice. First, any struct that contains pointers has a different size and layout on the host than on the target, so never send such a struct over a wire or store it in flash and expect the other side to read it. Second, code that stores a pointer in a uint32_t “works” on the MCU and silently truncates addresses on the host, which breaks your host unit tests in confusing ways. Use uintptr_t when you need an integer that holds a pointer (covered below).
NULL and null checks
Section titled “NULL and null checks”A null pointer is a pointer value guaranteed not to point at any object. You get one by assigning the macro NULL (from stddef.h, also provided by stdio.h, stdlib.h and string.h) or the literal 0 to a pointer. Null is the conventional “no object” value: a lookup that finds nothing returns NULL, an optional callback that is not set is NULL.
#include <stdbool.h>#include <stddef.h>#include <stdint.h>
bool adc_to_millivolts(uint16_t raw, uint32_t *out_mv){ if (out_mv == NULL || raw > 4095u) { return false; } *out_mv = ((uint32_t)raw * 3300u) / 4095u; return true;}Check pointers at API boundaries, where a caller you do not control hands you one. Inside a module, where you know every call site, an assert is usually the better tool than silently returning an error code.
On a PC, dereferencing NULL crashes with a segmentation fault because the OS leaves page zero unmapped. On the STM32F407 there is no OS and no MMU, and address 0x00000000 is real memory: the boot configuration aliases flash there. So this:
uint32_t *p = NULL;uint32_t v = *p; /* undefined behavior; on STM32F407 it reads flash */does not fault. It quietly returns the first word of the vector table, which is the initial stack pointer, typically 0x20020000 (the top of the 128 KB SRAM). Your code continues with a plausible-looking garbage value. Writes through a null pointer go to the flash alias and are either ignored or raise a BusFault, depending on the device and how flash is configured; either way you do not get a clean crash at the bad line.
Dereferencing a null pointer is undefined behavior in C, and the optimizer is allowed to assume it never happens. If you dereference p and then check if (p == NULL), the compiler may delete the check. Undefined behavior, volatile, and what the optimizer assumes shows that transformation; for now, always check before you dereference, never after.
Pointer arithmetic scales by sizeof
Section titled “Pointer arithmetic scales by sizeof”Adding an integer n to a pointer moves it by n elements, not n bytes. The compiler multiplies by sizeof(*p) for you.
#include <stdint.h>#include <stdio.h>
int main(void){ uint32_t samples[4] = {10u, 20u, 30u, 40u}; uint32_t *p32 = samples; uint8_t *p8 = (uint8_t *)samples;
printf("p32 = %p\n", (void *)p32); printf("p32 + 1 = %p\n", (void *)(p32 + 1)); printf("p8 = %p\n", (void *)p8); printf("p8 + 1 = %p\n", (void *)(p8 + 1)); return 0;}p32 = 0x7ffe9b1c4a30p32 + 1 = 0x7ffe9b1c4a34p8 = 0x7ffe9b1c4a30p8 + 1 = 0x7ffe9b1c4a31 +----+----+----+----+0x20000200 | 0A | 00 | 00 | 00 | samples[0] = 10 <- p32, p8 +----+----+----+----+ p8 + 1 = 0x200002010x20000204 | 14 | 00 | 00 | 00 | samples[1] = 20 <- p32 + 1 +----+----+----+----+0x20000208 | 1E | 00 | 00 | 00 | samples[2] = 30 <- p32 + 2 +----+----+----+----+0x2000020C | 28 | 00 | 00 | 00 | samples[3] = 40 <- p32 + 3 +----+----+----+----+0x20000210 one past the end <- p32 + 4 (may be formed, never dereferenced)p32 + 1 is 4 bytes further on; p8 + 1 is 1 byte further on. Same starting address, different step, purely because of the type. On Cortex-M the scaling is often free because the load instruction can shift the index:
#include <stdint.h>
uint32_t get(const uint32_t *p, uint32_t i){ return p[i];}get: ldr r0, [r0, r1, lsl #2] @ load from p + (i << 2) bx lrlsl #2 multiplies the index by 4, the size of uint32_t. For a uint16_t array the compiler would use ldrh with lsl #1.
The rules of pointer arithmetic
Section titled “The rules of pointer arithmetic”C only defines pointer arithmetic inside a single array object. Specifically:
- You may form pointers to any element and to one past the last element.
- You may dereference only pointers to actual elements, never the one-past-the-end pointer.
- Forming a pointer before the first element, or more than one past the end, is undefined behavior even if you never dereference it.
- A single non-array object behaves like an array of one element for these rules.
The one-past-the-end allowance exists so that loops like the one below are legal. The “before the start” rule bites people who write loops that count down with a pointer and end at buf - 1.
#include <stddef.h>#include <stdint.h>
uint32_t sum(const uint32_t *buf, size_t len){ uint32_t total = 0u; const uint32_t *end = buf + len; /* one past the end: legal to form */
for (const uint32_t *p = buf; p != end; p++) { total += *p; } return total;}p++ advances by one element. *p++, a common idiom, means *(p++): dereference the current pointer, then advance it (precedence is in Operators, expressions, and implicit conversions).
You cannot add two pointers (the result would be meaningless), and you cannot do arithmetic on a void * in standard C because void has no size. GCC allows void * arithmetic as an extension, treating the size as 1; -Wpedantic (or -Wpointer-arith) warns about it. Convert to uint8_t * when you mean bytes.
Pointers and arrays
Section titled “Pointers and arrays”Arrays and pointers are closely related but not the same thing. The relationship comes from two rules.
Rule 1: indexing is pointer arithmetic. The expression a[i] is defined as *(a + i). That is the whole definition. It is also why i[a] compiles and means the same thing (addition commutes), which is a party trick and never something to write.
Rule 2: arrays decay. In almost every expression, the name of an array is converted to a pointer to its first element. The exceptions are sizeof arr, &arr, and a string literal used to initialise a char array.
#include <stdint.h>#include <stdio.h>
int main(void){ uint16_t table[4] = {100u, 200u, 300u, 400u}; uint16_t *p = table; /* decay: same as &table[0] */
printf("%u %u\n", (unsigned)table[2], (unsigned)*(p + 2)); printf("sizeof table = %zu\n", sizeof table); printf("sizeof p = %zu\n", sizeof p); return 0;}300 300sizeof table = 8sizeof p = 8On a 64-bit host both sizes are 8, which makes this a poor demonstration. On the Cortex-M4 you would see sizeof table = 8 (four 2-byte elements) and sizeof p = 4 (one pointer). The array is the storage; the pointer is a separate variable holding the storage’s address:
+-----+-----+-----+-----+0x20000300 | 100 | 200 | 300 | 400 | table (uint16_t[4], 8 bytes, no pointer stored anywhere) +-----+-----+-----+-----+ ^ | +------------+0x20000308 | 0x20000300 | p (uint16_t *, 4 bytes) +------------+There is no hidden pointer inside table. When you write table[2], the compiler knows the array’s address directly (it is a link-time constant for a global, or an offset from sp for a local) and generates the access. When you write p[2], it must first load p and then index from it. The difference is invisible in source and visible in the disassembly.
&table is not the same as table either. Both have the same numeric value, but &table has type uint16_t (*)[4], “pointer to array of 4 uint16_t”, so &table + 1 jumps 8 bytes, past the whole array. Pointers to arrays get their full treatment in Pointers II: function pointers, declarations, and bugs.
When you pass an array to a function, it decays, so the function receives only a pointer and has no idea how long the array is. That is why C APIs always take a length alongside a buffer, and why sizeof on an array parameter gives you the pointer size. Arrays, strings, and tables covers that trap in detail.
Comparing and subtracting pointers
Section titled “Comparing and subtracting pointers”Equality (==, !=) works between any two pointers of compatible type, and against NULL. Two pointers compare equal if they point to the same object (or are both null).
Relational comparison (<, >, <=, >=) and subtraction are only defined between pointers into the same array (including one past its end). Subtracting gives the distance in elements, as a signed ptrdiff_t from stddef.h:
#include <stddef.h>#include <stdint.h>#include <stdio.h>
int main(void){ uint8_t frame[8] = {0x7Eu, 0x01u, 0x55u, 0x03u, 0x7Eu, 0x00u, 0x00u, 0x00u}; const uint8_t *start = frame; const uint8_t *p = frame + 1;
while (*p != 0x7Eu) { /* find the closing flag byte */ p++; } ptrdiff_t payload_len = p - (start + 1); printf("payload length = %td\n", payload_len); return 0;}payload length = 3 +----+----+----+----+----+----+----+----+0x20000400 | 7E | 01 | 55 | 03 | 7E | 00 | 00 | 00 | frame[8] +----+----+----+----+----+----+----+----+ ^ ^ ^ start start+1 p (after the loop)
p - (start + 1) = 0x20000404 - 0x20000401 = 3 elementsFor uint32_t pointers the subtraction would divide the byte difference by 4; the result is always in elements of the pointed-to type. %td is the printf specifier for ptrdiff_t.
Comparing pointers into different objects with < is undefined in C. On a flat 32-bit address space like Cortex-M it “works” in practice, and memory allocators and memmove implementations rely on that, but in your own code it is a sign that something is designed wrong. If you need to ask “is this pointer inside this buffer?”, convert both to uintptr_t and compare integers, which is implementation-defined rather than undefined, and well defined on every flat-memory MCU.
Pointers to pointers and out-parameters
Section titled “Pointers to pointers and out-parameters”C passes every argument by value. A function receives a copy, and changing the copy does nothing to the caller’s variable. To let a function modify a caller’s variable, you pass the variable’s address. This is an out-parameter, and you have already seen one: adc_to_millivolts(raw, &mv) writes its result into mv.
The same rule applies when the caller’s variable is itself a pointer. Suppose you want a function that finds the first occurrence of a byte in a buffer and hands back a pointer to it. Here is the version everyone writes first:
#include <stdbool.h>#include <stddef.h>#include <stdint.h>
/* BROKEN: assigns to a local copy of the pointer */bool find_byte_broken(const uint8_t *buf, size_t len, uint8_t value, const uint8_t *found){ for (size_t i = 0; i < len; i++) { if (buf[i] == value) { found = &buf[i]; /* changes the parameter, not the caller's variable */ return true; } } return false;}-Wextra warns that found is set but not used (-Wunused-but-set-parameter), which is the compiler telling you exactly what is wrong. The caller’s pointer never changes. To change a const uint8_t * in the caller, you need its address, a const uint8_t **:
#include <stdbool.h>#include <stddef.h>#include <stdint.h>#include <stdio.h>
static bool find_byte(const uint8_t *buf, size_t len, uint8_t value, const uint8_t **found){ for (size_t i = 0; i < len; i++) { if (buf[i] == value) { *found = &buf[i]; /* writes into the caller's pointer variable */ return true; } } *found = NULL; return false;}
int main(void){ const uint8_t frame[4] = {0x7Eu, 0x01u, 0x55u, 0x7Eu}; const uint8_t *hit;
if (find_byte(frame, sizeof frame, 0x55u, &hit)) { printf("found at index %td\n", hit - frame); } return 0;}found at index 2Inside find_byte, while it executes *found = &buf[i]:
main's stack frame find_byte's stack frame +-----------------------------------+ +---------------------------------+ | frame @0x20001FF8: 7E 01 55 7E | | buf = 0x20001FF8 | | ^ | | found = 0x20001FF4 ----------+ | | hit @0x20001FF4 | +-------------------------------|-+ | = 0x20001FFA -----+ |<------------------------------------+ +--------------------------|--------+ | +--> frame[2] (0x55)found holds the address of hit. *found is hit itself, so assigning to *found changes hit. **found would be the byte 0x55. Each * follows one arrow in the diagram.
This pattern (a function that hands back a pointer through a T **) is common in firmware: a memory pool that gives you a block, a parser that advances a cursor through a buffer, a driver that returns a pointer to its next free DMA descriptor. The usual signature is “return a status code, deliver the result through an out-parameter”:
struct pool; /* defined inside the pool module */typedef enum { POOL_OK, POOL_EMPTY } pool_status_t;
pool_status_t pool_get(struct pool *pool, void **out_block);The status tells the caller whether *out_block is valid; the caller never has to guess whether NULL meant “empty” or “error”.
const and pointers
Section titled “const and pointers”A pointer declaration involves two objects: the pointer itself and the thing it points to. const can apply to either, both or neither, giving four combinations.
| Declaration | Read right to left | Write *p = x? | Write p = q? |
|---|---|---|---|
uint8_t *p | p is a pointer to uint8_t | yes | yes |
const uint8_t *p | p is a pointer to uint8_t that is const | no | yes |
uint8_t const *p | identical to the line above | no | yes |
uint8_t *const p | p is a const pointer to uint8_t | yes | no |
const uint8_t *const p | p is a const pointer to const uint8_t | no | no |
The rule: const applies to whatever is immediately to its left; if nothing is to its left, it applies to what is immediately to its right. So in const uint8_t *p the const binds to uint8_t (the pointee), and in uint8_t *const p it binds to the * (the pointer). Read the declaration from the name outward, right to left: uint8_t *const p is “p is a const pointer to uint8_t”.
const uint8_t *p uint8_t *const p +------------+ +--------+ +------------+ +--------+ | p (can |-->| data | | p (fixed, |-->| data | | change) | | (read | | const) | | (can | +------------+ | only | +------------+ | change)| | via p) | +--------+ +--------+Each combination has a job in firmware:
const uint8_t *data: the most important one. It is how a function promises not to modify a buffer you pass in.uart_write(const uint8_t *data, size_t len)tells every reader, and the compiler, that the driver only readsdata. It also lets callers pass pointers to genuinely read-only data, such as a table in flash.uint8_t *const p: a pointer that always refers to the same place. The classic firmware use is a fixed hardware address:volatile uint32_t *const gpiod_odr = (volatile uint32_t *)0x40020C14u;(GPIOD base0x40020C00plusODRoffset0x14). The register contents change; which register you mean does not.const uint8_t *const p: a fixed pointer to read-only data, for example a pointer to a calibration block in flash.
Pointer-to-const is a promise about this access path, not about the object. The object itself may well be modifiable through some other pointer:
#include <stdint.h>
void example(void){ uint8_t buf[2] = {1u, 2u}; const uint8_t *view = buf; /* fine: adding const is always implicit */ uint8_t *edit = buf;
edit[0] = 9u; /* legal: buf is not const */ /* view[0] = 9u; */ /* error: assignment of read-only location */ (void)view;}Converting uint8_t * to const uint8_t * is implicit and always safe; it only removes permissions. Going the other way requires a cast, and writing through the result is undefined behavior if the original object was defined const. On an MCU that is not academic: a static const table is placed in flash by the linker (see Arrays, strings, and tables), so a write through a cast-away-const pointer becomes a store to flash. It does nothing or it faults; it never does what you meant.
One surprising rule to know exists: a char ** does not convert implicitly to const char **. It looks like it should (it adds const), but allowing it would let you store the address of a const char into a char * without a cast. The compiler warns (“initialization from incompatible pointer type”), and the right fix is usually to make the outer level const too (const char *const *) or to declare the variable with the right type from the start.
void pointers and generic interfaces
Section titled “void pointers and generic interfaces”A void * is a pointer to “something, type unknown”. It holds any object address, and C converts between void * and any other object pointer type implicitly, in both directions, without a cast. (C++ requires a cast from void *; C does not, and adding one in C can hide a missing #include.)
You cannot dereference a void * or do arithmetic on it, because the compiler does not know the size of what it points to. To use the memory you convert it to a concrete type. The standard library’s memcpy, memset and memcmp all take void * for this reason: they work on any object, treating it as raw bytes.
#include <stddef.h>#include <stdint.h>#include <stdio.h>
static void swap_bytes(void *a, void *b, size_t size){ uint8_t *pa = a; /* implicit conversion from void * */ uint8_t *pb = b;
for (size_t i = 0; i < size; i++) { uint8_t tmp = pa[i]; pa[i] = pb[i]; pb[i] = tmp; }}
int main(void){ uint32_t x = 1u; uint32_t y = 2u; double d1 = 1.5; double d2 = -3.25;
swap_bytes(&x, &y, sizeof x); swap_bytes(&d1, &d2, sizeof d1); printf("x=%u y=%u d1=%.2f d2=%.2f\n", (unsigned)x, (unsigned)y, d1, d2); return 0;}x=2 y=1 d1=-3.25 d2=1.50 main's frame (on entry to swap_bytes) swap_bytes(&x, &y, 4) +-----------------------------+ +----------------------+ | x @0x20001FF0: 01 00 00 00 |<------------| a = 0x20001FF0 | | y @0x20001FF4: 02 00 00 00 |<------------| b = 0x20001FF4 | +-----------------------------+ | size = 4 | | pa, pb: same values, | | typed uint8_t * | +----------------------+The price of void * is that the compiler can no longer check types: swap_bytes(&x, &d1, sizeof d1) compiles and overwrites 4 bytes past x. Generic interfaces therefore always pair a void * with a size, and the caller must get both right.
The other big use of void * in firmware is the context pointer passed to callbacks, so one callback function can serve several driver instances. That pattern belongs to Pointers II: function pointers, declarations, and bugs.
Casting between pointer types and alignment
Section titled “Casting between pointer types and alignment”You can cast any object pointer to any other object pointer type. The cast itself just reinterprets the address. What can go wrong is the access you then make through it, for two independent reasons: alignment and aliasing.
Alignment
Section titled “Alignment”Most types have an alignment requirement: the address of an object must be a multiple of some power of two. On Cortex-M, uint16_t needs 2-byte alignment, uint32_t, float and pointers need 4, and uint64_t and double need 8 under the AAPCS. The compiler always places your variables correctly. The trouble starts when you manufacture a pointer from an arbitrary byte address, which is exactly what happens when you parse a packet:
Received packet in a byte buffer (rx starts at a 4-byte boundary)
+------+------+------+------+------+------+------+------+0x20000500 | type | ts0 | ts1 | ts2 | ts3 | len | ... | ... | +------+------+------+------+------+------+------+------+ ^ rx + 1 = 0x20000501: a uint32_t timestamp starts here, which is NOT a multiple of 4#include <stdint.h>#include <string.h>
/* Packet: [0] type, [1..4] timestamp (CPU byte order), [5] length */
uint32_t get_timestamp_bad(const uint8_t *rx){ return *(const uint32_t *)(rx + 1); /* misaligned access, aliasing violation */}
uint32_t get_timestamp_good(const uint8_t *rx){ uint32_t ts; memcpy(&ts, rx + 1, sizeof ts); /* defined for any alignment */ return ts;}What happens with the bad version depends on the core:
- Cortex-M0/M0+ (ARMv6-M): every unaligned access faults. HardFault, immediately.
- Cortex-M3/M4/M7 (ARMv7-M): single
LDR,LDRH,STRandSTRHinstructions support unaligned addresses in normal memory (SRAM and flash), at the cost of extra bus cycles, as long asSCB->CCRbit 3 (UNALIGN_TRP) is clear, which is the reset default. ButLDRD,STRD,LDM,STM,LDREXandSTREXalways fault on misaligned addresses, and unaligned access to peripheral registers is never supported.
So on an M4 the bad version usually works, which is the dangerous part. It works until the optimizer combines two adjacent word loads into one LDRD, which it is entitled to do because you told it the pointer was a properly aligned uint32_t *. For example, GCC at -O2 for Cortex-M4 can compile this function to a single LDRD:
#include <stdint.h>
uint64_t read_pair(const uint8_t *buf){ const uint32_t *w = (const uint32_t *)buf; return ((uint64_t)w[1] << 32) | w[0];}read_pair: ldrd r0, r1, [r0] @ one double-word load: faults if buf is not 4-aligned bx lrA one-line change somewhere else, or a new compiler version, and code that ran for months takes an alignment UsageFault (escalated to HardFault unless you have enabled the separate UsageFault handler, see Watchdogs, faults, and recovering from them). Changing the struct layout of the packet by one byte moves the field and triggers it too.
The memcpy version is the fix. It is defined for any alignment, and the compiler does not actually call a library function for a small fixed size: on Cortex-M4 at -O2 it typically emits a single ldr r0, [r0, #1], the same instruction the bad version produced in the lucky case, but chosen by the compiler with full knowledge that the address may be unaligned. You get the efficient code without the trap.
The GCC flags -munaligned-access and -mno-unaligned-access control whether the compiler itself may emit unaligned LDR/STR instructions, for example when inlining that memcpy or accessing a packed struct. On ARMv7-M the default is to allow them; with -mno-unaligned-access the same memcpy becomes four byte loads and shifts. Neither flag makes your misaligned casts safe; they only govern accesses the compiler knows are unaligned.
The memcpy version reads the bytes in memory order, so it assumes the packet’s byte order matches the CPU’s. Bit manipulation shows the fully portable form that assembles the value with shifts, which works regardless of alignment and of either side’s endianness.
Aliasing
Section titled “Aliasing”Even when the address is aligned, accessing an object through a pointer to an unrelated type (reading a float through a uint32_t *, say) violates the strict aliasing rule, and the optimizer may reorder or drop accesses on the assumption that the two pointers cannot refer to the same memory. Character-type pointers are exempt: you can always look at the bytes of any object through unsigned char *. Strict aliasing has its own section in Undefined behavior, volatile, and what the optimizer assumes. The practical rule is the same as for alignment: use memcpy to reinterpret bytes.
uintptr_t: pointers as integers
Section titled “uintptr_t: pointers as integers”Sometimes you need an address as a number: to check alignment, to compute an offset into a memory region, to print it, or to store it in a register. uintptr_t, from stdint.h, is an unsigned integer type wide enough to hold any object pointer and convert back unchanged. It is 32 bits on Cortex-M and 64 bits on a 64-bit host, which is exactly why you use it instead of uint32_t.
#include <stdbool.h>#include <stdint.h>
/* align must be a power of two */static inline bool is_aligned(const void *p, uintptr_t align){ return ((uintptr_t)p & (align - 1u)) == 0u;} p = 0x20000501, align = 4
0x20000501 = ... 0101 0000 0001 & 0x00000003 = ... 0000 0000 0011 ----------------------------- 0x00000001 != 0 -> not 4-byte alignedThe low bits of an address tell you its alignment: a 4-byte aligned address has its bottom two bits clear. (Rounding addresses up and down with masks is covered in Bit manipulation.)
The conversion works in the other direction too, and this is how firmware reaches hardware: an integer constant cast to a pointer type.
#include <stdint.h>
#define GPIOD_ODR (*(volatile uint32_t *)0x40020C14u)That macro turns the number 0x40020C14 into a pointer to a volatile uint32_t and dereferences it, so GPIOD_ODR = 0x1000u; becomes a single store to the GPIOD output register. Why volatile is needed and how to build complete register headers is the subject of Memory-mapped registers in C.
Integer-to-pointer conversion is implementation-defined in C. On every flat-address-space MCU it does the obvious thing, which is why vendor headers rely on it.
Common mistakes
Section titled “Common mistakes”- Declaring
uint32_t *a, b;and expecting two pointers.bis a plain integer. Declare one pointer per line. - Assigning to a pointer parameter and expecting the caller to see it. Parameters are copies. Pass a
T **and assign through it. - Passing a pointer to
%pwithout a(void *)cast. Undefined in principle, warned by format checking. Always cast. - Checking for
NULLafter dereferencing. The optimizer may delete the check. Check first. - Assuming a null dereference will crash on the MCU. Address 0 is mapped flash on the STM32F407; you read the initial stack pointer. Validate pointers at API boundaries.
- Casting a byte buffer to
uint32_t *or a struct pointer. Misaligned and an aliasing violation; works until anLDRDappears. Usememcpyor shifts. - Looping a pointer below the start of an array (
for (p = end - 1; p >= buf; p--)). Formingbuf - 1is undefined. Loop on an index, or test before decrementing. - Storing a pointer in
uint32_t. Truncates on 64-bit hosts and breaks host tests. Useuintptr_t. - Casting away
constto write a table. The table lives in flash. Make the data non-const if it must change.
Exercises
Section titled “Exercises”- Draw then run. Write a host program with
uint16_t vals[3] = {0x1234, 0xABCD, 0x0F0F};, auint16_t *p = vals;and auint8_t *b = (uint8_t *)vals;. Before running, draw the 6 bytes of memory and predict*(p + 1),b[2],b[3]and(uint8_t *)(p + 2) - b. Then print them. Correct result:0xABCD,0xCD,0xAB,4. - Out-parameters. Write
bool split_u32(uint32_t v, uint16_t *hi, uint16_t *lo)that returnsfalseif either pointer isNULL. Call it with0xDEADBEEFand print both halves (0xDEAD,0xBEEF). Then call it withNULLforhiand confirm it returnsfalsewithout crashing. - const compile errors on purpose. Declare each of the four
constcombinations from the table, pointing at a localuint8_tarray. For each one, write both*p = 1;andp++;and compile with-std=c11 -Wall -Wextra -Wpedantic. Record which lines the compiler rejects and check that it matches the table exactly. - Read the instructions. Put
load_word,load_byteandload_halffrom this lesson in a file and compile witharm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb -S. Findldr,ldrbandldrshin the.soutput. Then add a functionint32_t load_signed_byte(const int8_t *p)and predict the instruction before checking (it should beldrsb). - Provoke the LDRD. Compile
read_pairfrom this lesson for Cortex-M4 at-O2and look for theldrdin the disassembly (arm-none-eabi-objdump -d); if your GCC version emits twoldrinstructions instead, note that nothing in the C source prevents the merge. Rewrite it using twomemcpycalls intouint32_tlocals and confirm theldrddisappears (you should see twoldrinstructions with offsets, or byte loads with-mno-unaligned-access). If you have Renode or a board, call the original withbuf + 1and observe the HardFault in the debugger.
Check yourself
Section titled “Check yourself”Question 1:Given uint32_t a[4]; placed at address 0x20000100, what is the value of (uint8_t *)(a + 2)?
a + 2 moves two uint32_t elements, 8 bytes, to 0x20000108. The cast to uint8_t * happens after the arithmetic, so it only changes the type, not the address.
Question 2:Which declaration lets you advance the pointer with p++ but forbids *p = 0?
const binds to what is on its left, here uint8_t, so the pointee is read-only while p itself can change. uint8_t const *p is identical to const uint8_t *p.
Question 3:A function void reset(uint8_t *p) { p = NULL; } is called as reset(buf_ptr);. What is buf_ptr afterwards?
p is a copy of buf_ptr; assigning to the copy does not touch the caller's variable. To change it the function needs a uint8_t ** and must assign to *p.
Question 4:On an STM32F407 with default settings, what most likely happens when code reads *(uint32_t *)NULL?
Address 0x00000000 aliases flash at boot, so the read succeeds and returns the first vector table word, the initial MSP. The bug surfaces later, far from the cause.
Question 5:On a Cortex-M4 with UNALIGN_TRP clear, which access through a pointer to address 0x20000501 is guaranteed to fault?
ARMv7-M supports unaligned single LDR/LDRH/STR/STRH to normal memory, but LDRD, STRD, LDM and STM always fault on misaligned addresses. The optimizer can generate LDRD from two ordinary word accesses.
Question 6:Why is uintptr_t preferred over uint32_t for holding a pointer value as an integer?
On Cortex-M both are 32 bits, but on a 64-bit host a pointer stored in uint32_t is silently truncated. uintptr_t always matches the platform's pointer width.
Key takeaways
Section titled “Key takeaways”- A pointer is a variable holding an address.
&takes an address,*follows one, and at the machine level a dereference is a load or store using that address as its operand. - The pointed-to type decides how many bytes a dereference touches (which instruction:
ldrb,ldrh,ldr) and how far pointer arithmetic moves (p + nadvancesn * sizeof *pbytes). a[i]is defined as*(a + i). Arrays decay to pointers in expressions, but an array is storage, not a pointer:sizeofand&see the difference.- Pointer arithmetic, subtraction and relational comparison are only defined within one array, plus one past its end.
- Read declarations right to left;
constbinds to its left.const T *pprotects the data,T *const pfixes the pointer. Make every read-only pointer parameterconst. - On an MCU a null or wild pointer usually does not crash where it is used. Address 0 is real flash on the STM32F407.
- Never dereference a byte buffer through a cast to a wider type. Use
memcpyor shifts; misalignedLDRD/STMfault even on Cortex-M4.
Next: the lesson Pointers II (function pointers, declarations, and bugs) takes pointers further into callback tables, reading complex declarations, and the pointer bugs that silently corrupt memory on a microcontroller.