C Part II · Module 16 · Registers and Memory-Mapped I/O
Address Maps, Base Addresses and Offsets
An address map is a table, computing a register address from it is one addition, and this is still the most common way register code fails — because every way of getting the arithmetic wrong produces a perfectly valid address that reaches a different register. This chapter measures the classic scaling error against a live peripheral model, where it does not read the wrong value but silently consumes a byte of received data, and then gives the transcription discipline that makes the whole class of error a build failure instead of a field failure.
Intermediate20 min readCPart IIHardwareRegisters
Part II · Module 16 · Chapter 16.2 · Address Maps, Base Addresses and Offsets
1. What an Address Map Is
A system's address map assigns ranges of the address space to things. Part of it looks like this:
0x0000_0000 – 0x0007_FFFF Flash
0x2000_0000 – 0x2001_FFFF SRAM
0x4002_1000 – 0x4002_1017 UART0 <- a peripheral's register block
0x4002_1018 – 0x4002_102F UART1Each peripheral owns a contiguous range, and within that range its registers sit at documented byte offsets:
UART0 base 0x40021000
+0x00 ID RO device identity
+0x04 CTRL RW control
+0x08 STATUS RO live status
+0x0C IRQ W1C pending interrupts
+0x10 DATA RC receive data — reading pops the FIFO
+0x14 TXD WO transmit dataTwo numbers produce an address: a base from the system map and an offset from the peripheral's register table. CTRL of UART0 is at 0x40021000 + 0x04 = 0x40021004, which is the address Chapter 16.1 measured in the generated store.
Chapter 16.1 §3 established what happens to that number: the interconnect compares it against each peripheral's range and routes the transaction to exactly one of them. The decoder has no opinion about whether you meant that one.
2. The Arithmetic, and the One Rule
Chapter 14.6 §3 measured the rule this chapter depends on, so it is worth restating in one line rather than re-deriving:
Offsets are in bytes. Pointer arithmetic is in elements. Do the addition in an integer type and convert to a pointer once, at the end.
#define UART0_BASE UINT32_C(0x40021000)
#define UART_CTRL 0x04u
/* right */
volatile uint32_t *ctrl = (volatile uint32_t *)(uintptr_t)(UART0_BASE + UART_CTRL);
/* wrong, and it compiles */
volatile uint32_t *ctrl = (volatile uint32_t *)(uintptr_t)UART0_BASE + UART_CTRL;The second line adds 4 elements to a uint32_t *, which is 16 bytes, reaching 0x40021010. Both lines are valid C, neither produces a diagnostic, and the difference is a pair of parentheses.
3. What the Wrong Address Actually Does
In Chapter 14.6 that error landed in a different element of a local array — a wrong number, easily spotted. Against a real peripheral it is considerably worse, and the model from Chapter 16.1 can show exactly how.
0x40021010 is not an invalid address. It is DATA, the receive register, and DATA is read-to-clear: reading it pops a byte off the receive FIFO.
fifo level before = 3
read 0x41 then wrote it back; fifo level = 2 <- a byte was consumedSo the consequences of a four-byte scaling error are, in order:
- the value read is not
CTRL— it is whatever byte was next in the receive FIFO - a byte of received data is destroyed, permanently, because the read consumed it
- the subsequent write to that address is silently ignored, because
DATAdoes not accept writes CTRLis never written, so the setting you were making does not take effect- nothing crashes, nothing warns, and the symptom appears later as "we drop a character occasionally"
That is the shape of nearly every address bug: not a fault, but a quiet interaction with a register you did not mean to touch. Chapter 16.6 is about why DATA behaves that way, and Chapter 16.9 catalogues the symptoms.
4. Transcribing a Map
Chapter 14.6 §5 gave the discipline for an address map and Chapter 15.1 §7 gave it for a register's fields. Together they produce a header that can be checked against the datasheet by reading two columns:
#include <stdint.h>
/* --- system map, from the memory map table ------------------------- */
#define UART0_BASE UINT32_C(0x40021000)
#define UART1_BASE UINT32_C(0x40021018)
#define UART_SPAN 0x18u
/* --- register offsets, from the peripheral's register table --------- */
#define UART_ID 0x00u /* RO */
#define UART_CTRL 0x04u /* RW */
#define UART_STATUS 0x08u /* RO */
#define UART_IRQ 0x0Cu /* W1C */
#define UART_DATA 0x10u /* RC — reading pops the FIFO */
#define UART_TXD 0x14u /* WO */
/* --- the one place that joins them --------------------------------- */
static inline volatile uint32_t *uart_reg(uintptr_t base, uint32_t offset) {
return (volatile uint32_t *)(base + offset);
}Four properties earn the ceremony.
Every number from the document appears exactly once, next to the name the document uses. Checking the header against the datasheet is reading two columns rather than evaluating expressions.
The byte arithmetic happens in one function, so §2's scaling error can occur in exactly one place, and that place is two lines long.
Offsets are integers, not pointers. UART_CTRL is 0x04u, so it cannot accidentally be added to a typed pointer somewhere else in the codebase.
The access type is in the comment, which is the cheapest possible place to put the information that Chapter 16.6 shows is decisive. A reader who reaches for UART_DATA sees reading pops the FIFO without opening the datasheet.
5. Several Instances of the Same Peripheral
Most systems have more than one of a thing — four UARTs, eight timers, sixteen GPIO ports — and the register layout is identical for all of them. The map gives a base per instance, usually at a regular stride:
UART0 0x40021000
UART1 0x40021018
UART2 0x40021030This is the case where a function taking the base as a parameter stops being ceremony and becomes the only sensible design:
static inline volatile uint32_t *uart_reg(uintptr_t base, uint32_t offset);
void uart_enable(uintptr_t base) {
*uart_reg(base, UART_CTRL) |= 0x1u;
}One driver, any instance. The alternative — a copy of the driver per instance, with the base baked in — is how a fix gets applied to three of four UARTs.
Where there is a regular stride it can be computed, and where it is computed it should be asserted:
#define UART_BASE(n) (UART0_BASE + (uintptr_t)(n) * UART_SPAN)
_Static_assert(UART_BASE(1) == UART1_BASE, "stride disagrees with the map");The assertion is the point. A computed base is a claim that the instances are evenly spaced, and real maps break that claim surprisingly often — an extra reserved block appears between instances 3 and 4 because the designer needed room. When the stride is irregular, write the bases out individually; when it is regular, assert that it is.
6. The Misconception: A Wrong Address Will Crash
"If I get an address wrong I will find out quickly — it will fault."
It will not, and this is the single most important thing in the chapter.
Chapter 14.6 §2 measured the one case where a bad address does fault: an address that nothing is mapped to, on a machine with a memory management unit, gave exit 139. That is the laptop case. A small embedded target typically has no MMU, and more importantly, the addresses you get wrong by arithmetic are almost never unmapped — they are other registers of the same peripheral, because the errors are small.
Add 16 instead of 4 and you reach another register in the same block. Use the wrong instance base and you configure a different UART, which works perfectly and talks to the wrong pins. Transpose two digits in an offset and you land on a reserved location that reads zero and ignores writes.
Every one of those is a legal transaction that the interconnect routes correctly to a peripheral that answers it. Chapter 16.1 §3's decoder does exactly what it was designed to do.
So the error is found by inspection and assertion, not by testing — which is why §4 and §5 are about making the transcription checkable rather than about being careful. Being careful is not a method.
7. What You Should Be Able to Say Now
- Two numbers make an address: a base from the system map and a byte offset from the peripheral's register table.
0x40021000 + 0x04=0x40021004. - Offsets are bytes; pointer arithmetic is elements. Add in an integer type, convert once at the end (14.6 §3).
- The scaling error reaches a real register. Measured:
(uint32_t *)base + 0x04lands onDATA, which is read-to-clear — so it destroys a received byte, ignores the write, and never touchesCTRL. - Transcribe the map as a map: integer bases and offsets, each number once, the access type in the comment, one function that joins them.
- Assert the relationships the map promises — instance stride, block span, offset alignment. Three lines, no runtime cost, three different classes of error.
- Take the base as a parameter so one driver serves every instance.
- A computed stride is a claim; assert it, because real maps have irregular gaps.
- A wrong address does not crash. It is a valid address that reaches another register, and the interconnect routes it correctly. Found by inspection and assertion, not by testing.
- Print the computed addresses and check them against the datasheet before touching hardware.
8. Exercises
- Write the header from §4 for the model's register block. Add the three
_Static_assertlines from the callout and confirm the build passes. - Deliberately change
UART1_BASEto0x40021016and record the assertion failure. Say which of §4's four properties caught it. - Compute, by hand, the address reached by
(volatile uint32_t *)(uintptr_t)UART0_BASE + UART_DATA. Which register is that, and what does reading it do? - Write
uart_enable(uintptr_t base)and call it for two instances. Then write the version with the base baked in, and say what maintenance problem the second creates. - A map lists four timer instances at
0x40000000,0x40000040,0x40000080and0x40000100. WriteTIMER_BASE(n)and the assertion. What does the assertion tell you, and what should you do about it? - Implement §6's address-printing check for a six-register block and run it. Then introduce one transposed digit and confirm the printout reveals it.
- A register sits at offset
0x06and holds 16 bits. Write the correct accessor, and say what Chapter 14.4 would report about accessing it through auint32_t *.
9. Summary
- Base + byte offset = address. The base comes from the system map, the offset from the register table.
- Offsets are bytes, pointer arithmetic is elements — add in an integer, convert once.
- Measured: the scaling error lands on
DATA, a read-to-clear register, so it consumes a received byte, drops the write, and never reachesCTRL. - Transcribe as a map: each number once, access type in the comment, one joining function, offsets as integers.
_Static_assertthe stride, the span and the alignment — three classes of transcription error, no runtime cost.- Base as a parameter, one driver per peripheral type rather than per instance.
- A computed stride is a claim. Assert it; real maps have gaps.
- Wrong addresses do not fault — they reach another register and are routed correctly.
- Print the computed addresses and check them by eye before any hardware is involved.
The address is now a number you can defend. Turning it into an access is the line Chapter 16.1 opened with, and it has two remaining decisions in it that have not been justified: the width the pointer type selects, and the volatile qualifier — which turns out to have two plausible positions in the declaration, only one of which does anything useful.
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.
