C Part II · Module 14 · C Through a Hardware Engineer's Eyes
Pointers as Addresses in a Real Address Map
Take an address out of a datasheet, convert it to a pointer, read it. The program compiles without a single warning and dies with signal 11, because the address map belongs to a target and this is not that target. That failure is the useful starting point: it separates the three things a pointer carries — a number, an alignment promise, and a claim to denote an object — which Part I never had reason to pull apart. This chapter connects Module 7's pointer model to a physical address map, establishes which of the three conversions involved are implementation-defined and which are undefined, and stops precisely where volatile becomes mandatory.
Advanced21 min readCPart IIHardwarePointers
Part II · Module 14 · Chapter 14.6 · Pointers as Addresses in a Real Address Map
1. Two Different Things Called a Pointer
Module 7 defined a pointer as a value that denotes an object. Every pointer in Part I came from somewhere that guaranteed this: the address of a declared variable, the result of malloc, an array decaying to a pointer to its first element. The pointer existed because the object did.
A hardware address map inverts that. The document says:
0x40021000 GPIO_BASE
+0x00 MODER (mode register)
+0x04 OTYPER (output type)
+0x08 IDR (input data, read-only)Nothing in your program created the object at 0x40021000. A hardware designer placed a register there, a bus decodes that address range, and your program's job is to name it. The address exists first and the pointer is constructed to reach it — the opposite of every pointer in Part I.
Both are "a pointer" and the type system does not distinguish them. The difference is entirely in what guarantees come attached, and the rest of this chapter is about keeping those guarantees separate.
2. What Happens If You Just Do It
The direct approach, with every recommended warning flag:
#include <stdio.h>
#include <stdint.h>
int main(void) {
/* An address from a datasheet. On the target it is a device register. */
volatile uint32_t *reg = (volatile uint32_t *)(uintptr_t)0x40021000u;
printf("about to read 0x40021000 ...\n"); fflush(stdout);
uint32_t v = *reg;
printf("read %u\n", v);
return 0;
}$ cc -std=c17 -Wall -Wextra -Wpedantic -O1 fab.c -o fab
$ ./fab
about to read 0x40021000 ...
$ echo $?
139No warnings. No diagnostics. Exit status 139 is 128 + 11 — the process was killed by SIGSEGV at the dereference, and the second printf never ran.
This is the right failure and it is worth being glad of it. The address map is a property of the target, not of C and not of your compiler. On the microcontroller the datasheet describes, that read returns a register. On a general-purpose machine running an operating system with virtual memory, nothing is mapped there and the hardware refuses, which is exactly what you want a laptop to do with a stray address.
The consequence for this chapter is practical: real memory-mapped I/O cannot honestly be demonstrated here. So the rest of this chapter models an address map with a real object, and says so each time.
3. Base, Offset, and the Arithmetic That Actually Happens
A register block is described as a base plus byte offsets, and the one reliable source of errors is that C pointer arithmetic is not in bytes. Module 7 established that p + 1 advances by sizeof(*p); the address map is written in bytes. Mixing the two is the classic defect.
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
/* A MODEL of a device register block. On a real target BASE would be a
number from the address map; here it is a real object so the program
is defined and can actually be run. */
static _Alignas(4) uint32_t block[4];
#define REG_ID 0x00u
#define REG_CTRL 0x04u
#define REG_STATUS 0x08u
int main(void) {
uintptr_t base = (uintptr_t)(void *)block;
printf("base = 0x%" PRIxPTR "\n", base);
printf("base %% 4 = %" PRIuPTR "\n\n", base % 4);
/* address = base + offset, computed in integers, converted once */
uint32_t *ctrl = (uint32_t *)(void *)(base + REG_CTRL);
*ctrl = 0xA5u;
printf("wrote 0xA5 via base+0x04\n");
printf("block[1] = 0x%08" PRIx32 " (same storage)\n\n", block[1]);
uint32_t *p = &block[2];
uintptr_t n = (uintptr_t)(void *)p;
uint32_t *q = (uint32_t *)(void *)n;
printf("round trip %s (p=%p q=%p)\n", p == q ? "preserved" : "BROKEN",
(void *)p, (void *)q);
printf("\n(uint32_t *)base + 1 = 0x%" PRIxPTR " (+4 bytes)\n",
(uintptr_t)(void *)((uint32_t *)(void *)base + 1));
printf("(uint8_t *)base + 1 = 0x%" PRIxPTR " (+1 byte)\n",
(uintptr_t)(void *)((uint8_t *)(void *)base + 1));
return 0;
}base = 0x104b44000
base % 4 = 0
wrote 0xA5 via base+0x04
block[1] = 0x000000a5 (same storage)
round trip preserved (p=0x104b44008 q=0x104b44008)
(uint32_t *)base + 1 = 0x104b44004 (+4 bytes)
(uint8_t *)base + 1 = 0x104b44001 (+1 byte)The run above is one run. The 0x104b44... base will be a different number every time you run it — the allocator and address-space layout randomisation decide it, and nothing in the C standard fixes it. What is reproducible is everything the chapter actually claims: the alignment, the offsets, the round trip, and the two pointer-arithmetic strides.
Four things measured, each worth naming.
base + REG_CTRL reached block[1]. The write of 0xA5 through an address computed as base plus four bytes landed in the second uint32_t of the block. That is the whole mechanism of register access, and it is just arithmetic.
The round trip is preserved. A pointer converted to uintptr_t and back compares equal to the original. uintptr_t exists precisely for this and is the only integer type that promises it.
The scaling is visible. + 1 on a uint32_t * advanced four bytes; + 1 on a uint8_t * advanced one. An offset of 0x04 taken from a datasheet and added to a uint32_t * would land at byte 16, not byte 4.
base % 4 is 0, because _Alignas(4) demanded it. On a real target the alignment comes from the hardware designer placing registers at aligned addresses; here it had to be requested, and checking it is not optional either way — Chapter 14.4 measured what a misaligned typed pointer licenses.
The rule that follows is short: do the offset arithmetic in integers, convert to a pointer once, at the end.
/* right: byte arithmetic in an integer type, one conversion */
uint32_t *ctrl = (uint32_t *)(void *)(base + REG_CTRL);
/* wrong: offset scaled by 4 because the pointer type says so */
uint32_t *ctrl = (uint32_t *)(void *)base + REG_CTRL;The second line is valid C, produces no diagnostic, and reads the wrong register. It is the single most common bug in hand-written register code, and the only defence is to never let a byte offset touch a typed pointer.
4. Three Operations, Three Different Statuses
Chapter 14.4 introduced two of these; the third is new here, and keeping all three apart is what lets you write register code that is defensible rather than merely working.
| Operation | Status | Why |
|---|---|---|
(uintptr_t)p and back to T * | implementation-defined | C17 §6.3.2.3 ¶5–6. Round trip preserved here. |
(T *)some_integer | implementation-defined | Same paragraphs. The implementation documents the mapping. |
dereferencing it when misaligned for T | undefined | §6.3.2.3 ¶7 — Chapter 14.4 measured it |
| dereferencing it when it denotes no object | undefined | §2's SIGSEGV is one possible manifestation |
The first two being implementation-defined rather than undefined is the licence for everything in Module 16. Chapter 13.3 argued that this distinction is the foundation of Part II, and this is the clearest case: converting the number 0x40021000 into a uint32_t * is not a hack that happens to work — it is a documented platform mechanism that the standard explicitly anticipates. Every embedded toolchain documents the mapping, and it is the identity on every flat-address-space target you will meet.
What is not licensed is the dereference, and it is undefined for two independent reasons. Getting the alignment right removes one. The other — whether anything is actually there — the language cannot help with at all. Only the address map can tell you, and the address map is a document.
5. Naming the Map in Code
An address map in a datasheet is a table. The useful way to carry it into C is to keep it looking like one, so that a reviewer can check the code against the document line by line.
#include <stdint.h>
/* --- the map, exactly as the datasheet lists it --------------------- */
#define GPIOA_BASE UINT32_C(0x40020000)
#define GPIOB_BASE UINT32_C(0x40020400)
#define GPIO_MODER 0x00u /* mode */
#define GPIO_OTYPER 0x04u /* output type */
#define GPIO_IDR 0x10u /* input data, read-only */
/* --- one place that turns base + offset into an address ------------- */
static inline uint32_t *gpio_reg(uintptr_t base, uint32_t offset) {
return (uint32_t *)(void *)(base + offset);
}Three properties make this worth the ceremony.
The map is transcribable. Every number appears once, next to the name the datasheet uses. Checking the code against the document is reading two columns.
The byte arithmetic is in one function. §3's scaling bug can occur in exactly one place, and that place is three lines long and gets reviewed once.
The offsets are not pointers. GPIO_IDR is 0x10u, an integer, so it cannot accidentally be added to a typed pointer somewhere else in the codebase.
If you want the compiler to check your transcription, Chapter 13.8's tool applies directly:
_Static_assert(GPIOB_BASE - GPIOA_BASE == 0x400, "GPIO ports are 1 KiB apart");That is one line and it catches a mistyped base address at build time. On a part with a dozen identical peripherals at a regular stride, it is worth more than it looks.
6. The Misconception: A Pointer Is Just an Integer
"On a flat-address-space machine a pointer is a 64-bit integer. The types are just decoration."
The arithmetic half is nearly right, and the conclusion is wrong, because a pointer carries three things and only one of them is the number.
The number, which converts to uintptr_t and back with the round trip preserved — measured in §3. This part is genuinely integer-like.
An alignment promise, which comes from the type and not from the value. Chapter 14.4 measured the consequence: a uint32_t * tells the compiler its target is 4-byte aligned, and the compiler may act on that belief anywhere in the program. An integer makes no such promise, which is exactly why integer arithmetic is the right place to compute an address and a typed pointer is the right place to finish.
A claim to denote an object, which neither the type nor the value can establish. §2 measured what happens when the claim is false: a correctly typed, correctly aligned, perfectly legal-looking volatile uint32_t * that compiled without a warning and killed the process.
An integer has the first property and neither of the other two. So the useful formulation is not "a pointer is an integer" but:
An address is a number. A pointer is a number plus two promises, and both promises are yours to keep.
Alignment you keep by construction — the hardware designer aligns the registers and you check it. Denotation you keep by getting the map right, and nothing in the language will help: there is no flag, no sanitizer and no analyser that can tell you 0x40021000 is the wrong base address for the part on this board. That one is settled by reading the document, which is why Chapter 13.11's habits matter more here than anywhere else in the curriculum.
7. What This Chapter Deliberately Does Not Do
The program in §2 contains the word volatile and this chapter has not explained it. That is deliberate, and the reason is worth stating rather than glossing.
Everything above treats a register address as though reading and writing it were ordinary memory operations. They are not. A register read can have a side effect; a register's value can change without your program writing it; and two writes to the same address can both matter even though the second overwrites the first. The optimiser's model of memory assumes none of this — it assumes that a value you just wrote is still there, that a value nobody wrote has not changed, and that a load whose result is unused can be deleted.
The consequence is that a polling loop written with everything this chapter teaches, and without volatile, can be hoisted out of the loop or removed entirely. That is Chapter 17.1's subject, volatile's actual meaning is 17.2's, and the three guarantees folklore attaches to volatile that it does not provide are 17.5's.
So this chapter stops at the address. Do not write register access code from this chapter alone — it would be correct about where the register is and wrong about whether the access survives compilation. Module 16 covers reading and writing registers properly, applying volatile as a rule, and Module 17 explains it in full.
8. What You Should Be Able to Say Now
- Part I's pointers denoted objects the program created. A hardware address exists first, placed by a designer, and the pointer is constructed to reach it. The type system cannot tell the two apart.
- An address map is a property of the target. Measured: a datasheet address dereferenced on this machine compiled with zero warnings and died with exit 139 (SIGSEGV).
- Compute offsets in integers and convert once.
base + 0x04reachedblock[1];(uint32_t *)base + 0x04would reach byte 16. Measured:+1on auint32_t *moves 4 bytes, on auint8_t *moves 1. uintptr_tround-trips a pointer — measured preserved. It is optional, and it is the unsigned one you want;intptr_tmakes high addresses negative.- Pointer↔integer conversion is implementation-defined (C17 §6.3.2.3 ¶5–6) and is the documented mechanism Module 16 is built on — not a hack.
- Two independent reasons the dereference can be undefined: misalignment (14.4), and denoting no object. Alignment you can check; denotation you cannot.
- Transcribe the map as a map — bases and byte offsets as integer macros, one function that joins them,
_Static_asserton the strides. - A pointer is a number plus two promises: an alignment promise from the type, and a claim to denote an object that nothing in the language can verify.
- This chapter stops before
volatile, and register code written without it can be optimised away. Modules 16 and 17 own that.
9. Exercises
- Run §3's program. Record
base % 4, and explain which line of the source guarantees it rather than leaving it to chance. - Change
REG_CTRLto be added to a typeduint32_t *instead of tobase. Predict which element ofblockthe write lands in before running, then confirm. - Write the
_Static_assertthat would catch a mistypedGPIOB_BASEon a part with eight GPIO ports at a 1 KiB stride. Then mistype one and read the error. - Reproduce §2's fabricated-pointer program on your machine and record the exit status. Then explain why the same source on a microcontroller without an MMU would be a worse outcome than a crash, not a better one.
- Using §5's style, transcribe a four-register block at base
0x50000000with offsets0x00,0x04,0x0Cand0x20. Note that one offset is not a multiple of the previous stride — say why that is normal and what it means for any attempt to describe the block as an array. (uintptr_t)pround-tripped here. Write the program that checks it, and say what you would conclude if it ever failed — which of the two optional types would be at fault.- Take §3's model and write a function
reg_read(uintptr_t base, uint32_t offset)that asserts the computed address is correctly aligned before dereferencing. Say which chapter's tool you used and why the assertion is not in the shipping build.
10. Summary
- Part I: the object exists and you take its address. Part II: the address exists and you construct the pointer. Same type, different guarantees.
- The map belongs to the target. Measured: a datasheet address on this machine compiled clean and exited 139 (SIGSEGV).
- Offsets are bytes; pointer arithmetic is elements. Measured
+4versus+1. Compute in integers, convert once, at the end. uintptr_tpreserves the round trip (measured), is optional, and is the unsigned one to use.- Pointer↔integer conversion is implementation-defined (§6.3.2.3 ¶5–6) — the documented basis for Module 16, not a hack.
- The dereference is undefined for two independent reasons: misalignment, and denoting nothing. Only the first is checkable.
- Transcribe the map as a map: integer bases and offsets, one joining function,
_Static_asserton strides. - A pointer is a number plus two promises — alignment, from the type; denotation, from the document.
volatileis Module 17's and register access is Module 16's. Code from this chapter alone knows where the register is and not whether the access survives.
Register blocks are addressed one word at a time, and the address was the whole problem. A payload is the opposite case: a run of bytes whose starting address is unremarkable and whose length is the thing everything depends on — and the last chapter of this module is about carrying that length everywhere the bytes go.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
