Skip to content

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.

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 42
0x20000101 | 0x00 |
0x20000102 | 0x00 |
0x20000103 | 0x00 |
+------+
0x20000104 | ... | <- whatever the linker placed next

Notice 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.

Two operators do all the work:

  • &x gives the address of x. Its type is “pointer to the type of x”.
  • *p follows the address in p and 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 = 0x7ffd3c5e1a4c
p = 0x7ffd3c5e1a4c
*p = 42
count = 7

Your 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" count

There 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 lr

The 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”.

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”.

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:

  1. How many bytes to read or write on a dereference, and how to interpret them.
  2. 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: 0x44
offset 1: 0x33
offset 2: 0x22
offset 3: 0x11
+------+
0x20000100 | 0x44 | <- word starts here; b points here too
0x20000101 | 0x33 |
0x20000102 | 0x22 |
0x20000103 | 0x11 |
+------+
*(uint32_t *)0x20000100 reads 4 bytes -> 0x11223344
*(uint8_t *)0x20000100 reads 1 byte -> 0x44

Both 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 lr
load_byte:
ldrb r0, [r0] @ 8-bit load, zero-extended
bx lr
load_half:
ldrsh r0, [r0] @ 16-bit load, sign-extended
bx lr

ldr, 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.

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.

TypeCortex-M4 (arm-none-eabi-gcc)x86-64 Linux/macOSx86-64 Windows
uint32_t *, char *, void *488
Function pointer488
int444
long484
uintptr_t, size_t488

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).

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.

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 = 0x7ffe9b1c4a30
p32 + 1 = 0x7ffe9b1c4a34
p8 = 0x7ffe9b1c4a30
p8 + 1 = 0x7ffe9b1c4a31
+----+----+----+----+
0x20000200 | 0A | 00 | 00 | 00 | samples[0] = 10 <- p32, p8
+----+----+----+----+ p8 + 1 = 0x20000201
0x20000204 | 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 lr

lsl #2 multiplies the index by 4, the size of uint32_t. For a uint16_t array the compiler would use ldrh with lsl #1.

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.

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 300
sizeof table = 8
sizeof p = 8

On 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.

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 elements

For 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.

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 2

Inside 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”.

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.

DeclarationRead right to leftWrite *p = x?Write p = q?
uint8_t *pp is a pointer to uint8_tyesyes
const uint8_t *pp is a pointer to uint8_t that is constnoyes
uint8_t const *pidentical to the line abovenoyes
uint8_t *const pp is a const pointer to uint8_tyesno
const uint8_t *const pp is a const pointer to const uint8_tnono

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 reads data. 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 base 0x40020C00 plus ODR offset 0x14). 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.

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.

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, STR and STRH instructions support unaligned addresses in normal memory (SRAM and flash), at the cost of extra bus cycles, as long as SCB->CCR bit 3 (UNALIGN_TRP) is clear, which is the reset default. But LDRD, STRD, LDM, STM, LDREX and STREX always 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 lr

A 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.

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.

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 aligned

The 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.

  • Declaring uint32_t *a, b; and expecting two pointers. b is 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 %p without a (void *) cast. Undefined in principle, warned by format checking. Always cast.
  • Checking for NULL after 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 an LDRD appears. Use memcpy or shifts.
  • Looping a pointer below the start of an array (for (p = end - 1; p >= buf; p--)). Forming buf - 1 is undefined. Loop on an index, or test before decrementing.
  • Storing a pointer in uint32_t. Truncates on 64-bit hosts and breaks host tests. Use uintptr_t.
  • Casting away const to write a table. The table lives in flash. Make the data non-const if it must change.
  1. Draw then run. Write a host program with uint16_t vals[3] = {0x1234, 0xABCD, 0x0F0F};, a uint16_t *p = vals; and a uint8_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.
  2. Out-parameters. Write bool split_u32(uint32_t v, uint16_t *hi, uint16_t *lo) that returns false if either pointer is NULL. Call it with 0xDEADBEEF and print both halves (0xDEAD, 0xBEEF). Then call it with NULL for hi and confirm it returns false without crashing.
  3. const compile errors on purpose. Declare each of the four const combinations from the table, pointing at a local uint8_t array. For each one, write both *p = 1; and p++; and compile with -std=c11 -Wall -Wextra -Wpedantic. Record which lines the compiler rejects and check that it matches the table exactly.
  4. Read the instructions. Put load_word, load_byte and load_half from this lesson in a file and compile with arm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb -S. Find ldr, ldrb and ldrsh in the .s output. Then add a function int32_t load_signed_byte(const int8_t *p) and predict the instruction before checking (it should be ldrsb).
  5. Provoke the LDRD. Compile read_pair from this lesson for Cortex-M4 at -O2 and look for the ldrd in the disassembly (arm-none-eabi-objdump -d); if your GCC version emits two ldr instructions instead, note that nothing in the C source prevents the merge. Rewrite it using two memcpy calls into uint32_t locals and confirm the ldrd disappears (you should see two ldr instructions with offsets, or byte loads with -mno-unaligned-access). If you have Renode or a board, call the original with buf + 1 and observe the HardFault in the debugger.

Question 1:Given uint32_t a[4]; placed at address 0x20000100, what is the value of (uint8_t *)(a + 2)?

Question 2:Which declaration lets you advance the pointer with p++ but forbids *p = 0?

Question 3:A function void reset(uint8_t *p) { p = NULL; } is called as reset(buf_ptr);. What is buf_ptr afterwards?

Question 4:On an STM32F407 with default settings, what most likely happens when code reads *(uint32_t *)NULL?

Question 5:On a Cortex-M4 with UNALIGN_TRP clear, which access through a pointer to address 0x20000501 is guaranteed to fault?

Question 6:Why is uintptr_t preferred over uint32_t for holding a pointer value as an integer?

  • 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 + n advances n * sizeof *p bytes).
  • a[i] is defined as *(a + i). Arrays decay to pointers in expressions, but an array is storage, not a pointer: sizeof and & 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; const binds to its left. const T *p protects the data, T *const p fixes the pointer. Make every read-only pointer parameter const.
  • 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 memcpy or shifts; misaligned LDRD/STM fault 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.