Skip to content
VLSI Mentor

C Part II · Module 16 · Registers and Memory-Mapped I/O

Register Maps as C Headers and Structures

A register block is a run of words at fixed offsets, and C has a construct that describes exactly that — so laying a structure over the base address and writing dev->ctrl is the obvious move. It is also what several major vendors ship, and it is a defensible choice rather than the obvious mistake it is sometimes presented as. This chapter measures what the overlay actually guarantees, where explicit offsets win, and why bit-fields — which look like the natural way to express register fields — are ruled out by five separate properties, two of which are specific to hardware and are the ones that matter.

Advanced22 min readCPart IIHardwareRegisters

Part II · Module 16 · Chapter 16.7 · Register Maps as C Headers and Structures

1. Two Ways to Write the Same Block

Everything so far in this module has used explicit offsets:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#define UART_BASE    UINT32_C(0x40021000)
#define UART_CTRL    0x04u
#define UART_STATUS  0x08u

uint32_t s = reg_read32(UART_BASE, UART_STATUS);

The alternative lays a structure over the base address:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
typedef volatile uint32_t vu32;

struct uart_regs {
    vu32 id;      /* 0x00  RO  */
    vu32 ctrl;    /* 0x04  RW  */
    vu32 status;  /* 0x08  RO  */
    vu32 irq;     /* 0x0C  W1C */
    vu32 data;    /* 0x10  RC  */
    vu32 txd;     /* 0x14  WO  */
};

#define UART0  ((struct uart_regs *)(uintptr_t)UART_BASE)

uint32_t s = UART0->status;

UART0->status is better to read than reg_read32(UART_BASE, UART_STATUS), and that is not a small thing in code that touches forty registers. Several major vendors ship headers in exactly this style.

Chapter 14.5 asked the structurally identical question for a wire format and answered it with a decision procedure rather than a rule. The same applies here, with one extra consideration that did not exist there: the thing under the structure answers back.

2. What the Overlay Actually Gives You

Measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
struct dev_regs { vu32 id, ctrl, status, irq, data, txd; };

_Static_assert(sizeof(struct dev_regs) == 0x18,            "block must be 0x18 bytes");
_Static_assert(offsetof(struct dev_regs, ctrl) == 0x04,    "ctrl offset");
_Static_assert(offsetof(struct dev_regs, irq)  == 0x0C,    "irq offset");
_Static_assert(offsetof(struct dev_regs, txd)  == 0x14,    "txd offset");
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sizeof(dev_regs) = 0x18  alignof = 4
offsets: id 0 ctrl 4 status 8 irq 12 data 16 txd 20

Every offset matches the map and all four assertions pass. For a block of same-sized, contiguous registers this is reliable: Chapter 10.5's padding rules insert nothing when every member is a uint32_t at a naturally aligned offset.

Three things the overlay genuinely buys:

Readability at the call site. UART0->ctrl against reg_read32(UART_BASE, UART_CTRL), forty times over.

The offsets are structural. You cannot mistype 0x0C as 0x0D, because you never write it. Order the members correctly and the offsets follow.

Multiple instances are free. ((struct uart_regs *)UART1_BASE)->ctrl, or a function taking struct uart_regs *, which is Chapter 16.2 §5's base-as-parameter with types attached.

And the thing it does not buy, which Chapter 14.5 §2 measured in the wire-format case: the layout is not guaranteed by the language, only by the padding rules applied to these particular members. Add a uint16_t register to the block and the offsets of everything after it change. The _Static_assert lines are therefore not optional decoration — they are what converts a layout assumption into a build failure.

3. Where Explicit Offsets Win

Real register maps are rarely a clean run of words, and three situations make the overlay awkward enough that offsets are the better choice.

Gaps. A block may have registers at 0x00, 0x04 and then nothing until 0x18. The structure needs explicit padding, which you have to compute and keep correct:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
struct gappy { vu32 a; vu32 b; uint8_t _pad[0x18 - 0x08]; vu32 c; };
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sizeof(gappy) = 0x1c, c at 0x18

It works — measured — and it is a hand-computed constant of exactly the kind the overlay was supposed to eliminate. With four gaps in a block, the readability advantage has evaporated.

Mixed widths. A block with 32-bit and 16-bit registers acquires padding decided by Chapter 10.5's alignment rules rather than by the datasheet, and a 16-bit register at an odd offset cannot be a naturally aligned member at all.

Irregular or sparse maps. When the block is really a sparse set of addresses rather than a contiguous array of registers, the structure is mostly padding and the offsets are mostly invisible.

In all three the explicit-offset form stays exactly as simple as it was:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#define UART_C   0x18u
uint32_t v = reg_read32(base, UART_C);

One number, from the document, in one place.

4. The Rule the Overlay Must Not Break

One hazard is specific to registers and has no equivalent in Chapter 14.5's wire-format discussion.

Accessing a member must still be exactly one access of the right width. The overlay makes this easy to get wrong, because UART0->ctrl looks like a variable and invites being treated as one:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
UART0->ctrl |= 1u;          /* a read AND a write — two transactions */

That is a read-modify-write, written as though it were a single operation. On ctrl it is correct and Chapter 16.4 endorsed it. On UART0->irq, which is W1C, it is Chapter 16.5 §2's measured bug — 0x5 becoming 0x0 — and the source line gives no hint that two transactions are involved.

The explicit form is more honest about this, because reg_read32 and reg_write32 are visibly two calls. That is a real argument for offsets in code that touches registers of several access types, and it is worth weighing against the readability argument rather than dismissing either.

The defence, if you use the overlay, is Chapter 16.6's: put the access type in the comment on the member, so that the information is where the code is.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
struct uart_regs {
    vu32 id;      /* 0x00  RO                                        */
    vu32 ctrl;    /* 0x04  RW   — RMW is safe                        */
    vu32 status;  /* 0x08  RO                                        */
    vu32 irq;     /* 0x0C  W1C  — write only the bit; NEVER |=       */
    vu32 data;    /* 0x10  RC   — reading pops the FIFO; read once   */
    vu32 txd;     /* 0x14  WO   — reads return 0                     */
};

5. Why Bit-Fields Are the Wrong Tool

A register's fields look exactly like what bit-fields were invented for:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
struct ctrl_bf {
    uint32_t enable  : 1;
    uint32_t mode    : 2;
    uint32_t rsvd    : 1;
    uint32_t divisor : 12;
    uint32_t thresh  : 8;
    uint32_t rest    : 8;
};

ctrl->divisor = 1000 would be the most readable register code anyone has ever written. Measured against Chapter 15.1's register:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sizeof = 4
enable=1 mode=2 rsvd=1 divisor=419 thresh=8  ->  word = 0x00081a3d
match: YES

It produces exactly the right word. That is the problem, not the solution.

It works perfectly on this compiler, which is why people ship it, and there are five separate reasons not to. Only the first is well known.

One: the allocation order within a unit is implementation-defined. Chapter 10.10's table established this in Part I. Clang placed enable at bit 0; another implementation may legitimately place it at bit 31 and work down. The same header, the same source, a different toolchain, no diagnostic, and every field in the wrong place.

Two: whether a field may straddle a storage unit is implementation-defined. Also from 10.10's table. A 12-bit field that does not fit in what remains of a unit may cross into the next or may move to it, and which one happens is not yours to choose.

Three: you do not control the access width. This is register-specific and is the serious one. Chapter 16.3 §2 measured that the pointer type selects the instruction — strb, strh, str — and therefore the bus transaction. With a bit-field you are not writing a pointer type: you are assigning to a member, and the compiler chooses how to access the storage unit. It may read and write the whole 32-bit word, or just the byte containing your field. On a register that requires 32-bit access, the byte access is a protocol violation, and nothing in your source mentions a width.

Four: a read-modify-write is implied and invisible. ctrl->divisor = 1000 must read the word, insert the field, and write it back. That is Chapter 16.5's sequence, with all of its hazards, generated from a line that looks like a plain assignment. On a W1C or RC register it is the measured bug from 16.5 §2 and §3, and there is no syntax at the call site that would warn you.

Five: volatile does not do what you need. Qualifying the structure makes accesses to the storage unit volatile, which preserves them — but does not tell the compiler how wide to make them or stop it from generating the implied read. Reasons three and four survive the qualifier.

So the position in Chapter 15.4 and the one here are the same: extract and insert fields with explicit masks and shifts. The resulting code is three lines longer per accessor and has exactly one behaviour on every compiler, which for a header — the artefact most likely to be compiled by a toolchain you have not met — is the property that matters.

6. Choosing

The honest answer is a decision with three inputs, not a rule.

Use the struct overlay when the block is a contiguous run of same-width registers, your toolchain is fixed and known, and you add _Static_assert lines pinning the size and the offsets. This is a good choice and it is why vendor headers use it.

Use explicit offsets when the map is sparse or irregular, widths are mixed, the header must build on several toolchains, or the block contains registers of several access types where making each transaction visible is worth more than the syntax.

Use neither for fields — masks and shifts, from Module 15.

And whichever you pick, two things are not optional:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
/* the layout is an assumption until you assert it */
_Static_assert(sizeof(struct uart_regs) == 0x18, "block size");
_Static_assert(offsetof(struct uart_regs, irq) == 0x0C, "irq offset");

and the access type recorded against every register, as in §4.

7. The Misconception: The Overlay Is Modern and Offsets Are Legacy

"The struct overlay is the clean modern way; #define offsets are what people did before anyone knew better."

Both are current, both ship in vendor headers maintained by competent people today, and the choice is driven by the shape of the map rather than by fashion.

The belief usually comes from the readability argument, which is real — UART0->ctrl is better than a macro plus two arguments — and from an assumption that the overlay is also safer because the offsets are computed rather than written. §2 showed that is only true while the members are uniform, and §3 showed that a map with gaps turns computed offsets back into hand-computed padding constants.

What is genuinely true is narrower and worth keeping:

The overlay removes the chance of mistyping an offset, at the cost of adding an assumption about padding. Both must be checked; only the second is checked by _Static_assert.

And the one place where the overlay is actively worse — §4 — is not about layout at all: UART0->irq |= 1u is a read-modify-write that looks like a single operation, on a register where Chapter 16.5 measured that sequence destroying an unrelated interrupt.

8. What You Should Be Able to Say Now

  1. Two forms, both current. Explicit offsets, or a structure laid over the base. Vendor headers use both.
  2. The overlay works for a uniform block. Measured: sizeof 0x18, offsets 0/4/8/12/16/20, four _Static_asserts passing.
  3. It buys readability, structural offsets, and free instances — and costs an unchecked assumption about padding, which the assertions convert into a build failure.
  4. typedef volatile uint32_t vu32 is a qualifier typedef, not a pointer typedef, and makes a missing volatile on one member visually obvious.
  5. Qualify the members, not the pointer. volatile struct uart_regs *p is right; struct uart_regs *volatile p is 16.3 §4's measured mistake.
  6. Offsets win on gaps, mixed widths and sparse maps. Measured: a gapped block needs explicit padding — sizeof 0x1c, c at 0x18 — which is the hand-computed constant the overlay was meant to remove.
  7. UART0->irq |= 1u is two transactions that look like one — and on W1C that is 16.5's measured bug. Record the access type on every member.
  8. Bit-fields are ruled out by five properties: implementation-defined allocation order (10.10), implementation-defined straddling, no control over access width (and width is the bus transaction), an implied invisible read-modify-write, and volatile not fixing either of the last two.
  9. Measured: the bit-field version produced exactly the right word on this compiler — which is why it ships, and precisely what makes it dangerous in a header.
  10. Performance is not the discriminator. The two forms generate the same instruction.

9. Exercises

  1. Write the overlay for the model's six registers with all four _Static_assert lines. Then insert a uint16_t register after id and record which assertions fail and why.
  2. Write the gapped block from §3 for registers at 0x00, 0x04 and 0x18. Verify sizeof and the offset of the third. Then write the explicit-offset version and compare the two for readability.
  3. Compile UART0->status and reg_read32(UART_BASE, UART_STATUS) at -O2 -S and compare the generated code. Report what you found.
  4. Write UART0->irq |= 1u and the correct W1C acknowledgement. Count the transactions in each and state the final pending value for a starting value of 0x5.
  5. Build §5's bit-field structure and confirm it produces 0x00081a3d. Then state, without compiling anything, what you would expect on a compiler that allocates from the most significant bit down.
  6. For reason three in §5, explain why volatile on the structure does not let you control the access width, referring to what Chapter 16.3 §2 measured.
  7. Take a vendor header you have access to. Which form does it use? Does it have _Static_assert on the layout? Does it record access types? Write down what you would add.

10. Summary

  • Two current forms: explicit offsets, or a structure over the base. Both ship in real vendor headers.
  • The overlay is reliable for a uniform block — measured sizeof 0x18, offsets 0/4/8/12/16/20 — and requires _Static_assert on size and offsets, because the layout is an assumption.
  • vu32 is a qualifier typedef (not the pointer typedef 13.11 forbids) and makes a missing volatile visible.
  • Qualify members, not the pointer (16.3 §4).
  • Offsets win on gaps, mixed widths, sparse maps — measured: a gapped block needs a hand-computed pad, sizeof 0x1c.
  • -> hides the transaction count: irq |= 1u is a read-modify-write, and on W1C that is 16.5's measured destruction of an unrelated interrupt.
  • Bit-fields: five reasons out. Allocation order and straddling are implementation-defined (10.10); you cannot control the access width; a read-modify-write is implied and invisible; volatile fixes neither.
  • The bit-field version produced the exactly right word here — the reason it ships and the reason it is a trap in a header.
  • Both forms generate the same instruction, so performance decides nothing.

Every decision in this chapter rested on a sentence in a document — an offset, an access type, a reserved-bit instruction, a required access width. What a specification promises and what it merely happens to do on the part in front of you are different things, and the gap between them is where the longest-lived bugs in this field live.

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.