Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
0x0000_0000 – 0x0007_FFFF   Flash
0x2000_0000 – 0x2001_FFFF   SRAM
0x4002_1000 – 0x4002_1017   UART0        <- a peripheral's register block
0x4002_1018 – 0x4002_102F   UART1

Each peripheral owns a contiguous range, and within that range its registers sit at documented byte offsets:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 data

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
fifo level before   = 3
read 0x41 then wrote it back; fifo level = 2  <- a byte was consumed

So 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 DATA does not accept writes
  • CTRL is 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
UART0  0x40021000
UART1  0x40021018
UART2  0x40021030

This is the case where a function taking the base as a parameter stops being ceremony and becomes the only sensible design:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#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

  1. Two numbers make an address: a base from the system map and a byte offset from the peripheral's register table. 0x40021000 + 0x04 = 0x40021004.
  2. Offsets are bytes; pointer arithmetic is elements. Add in an integer type, convert once at the end (14.6 §3).
  3. The scaling error reaches a real register. Measured: (uint32_t *)base + 0x04 lands on DATA, which is read-to-clear — so it destroys a received byte, ignores the write, and never touches CTRL.
  4. Transcribe the map as a map: integer bases and offsets, each number once, the access type in the comment, one function that joins them.
  5. Assert the relationships the map promises — instance stride, block span, offset alignment. Three lines, no runtime cost, three different classes of error.
  6. Take the base as a parameter so one driver serves every instance.
  7. A computed stride is a claim; assert it, because real maps have irregular gaps.
  8. 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.
  9. Print the computed addresses and check them against the datasheet before touching hardware.

8. Exercises

  1. Write the header from §4 for the model's register block. Add the three _Static_assert lines from the callout and confirm the build passes.
  2. Deliberately change UART1_BASE to 0x40021016 and record the assertion failure. Say which of §4's four properties caught it.
  3. 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?
  4. 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.
  5. A map lists four timer instances at 0x40000000, 0x40000040, 0x40000080 and 0x40000100. Write TIMER_BASE(n) and the assertion. What does the assertion tell you, and what should you do about it?
  6. 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.
  7. A register sits at offset 0x06 and holds 16 bits. Write the correct accessor, and say what Chapter 14.4 would report about accessing it through a uint32_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 reaches CTRL.
  • Transcribe as a map: each number once, access type in the comment, one joining function, offsets as integers.
  • _Static_assert the 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.