Python Part II · Module 31 · Generating RTL and Verification Artifacts
Register Maps From a Single Source of Truth
A register map is the canonical generator target and the one most teams build first, because the same twenty facts have to appear in the RTL, in a C header, in a verification model and in a document, and keeping four copies aligned by hand fails within a month. The generator itself is a loop. What this chapter is about is everything the loop does not do: a register map has internal consistency rules — offsets must not collide, must fit the bus, must be aligned — and a generator that emits without checking them produces files that compile perfectly and describe hardware nobody can reach.
Advanced22 min readPythonPart IIAutomationCode GenerationRegisters
Part II · Module 31 · Chapter 31.3 · Register Maps From a Single Source of Truth
1. Four Copies of Twenty Facts
ARTEFACTS = [
("RTL register block", "address decode, write path, read mux", "the hardware"),
("C header", "#define for each offset", "firmware"),
("verification model", "a mirror of the same offsets", "the testbench"),
("documentation table", "name, offset, width, access, reset", "humans"),
]
print(f"{'artefact':<22} {'contains':<38} read by")
for name, contains, who in ARTEFACTS:
print(f"{name:<22} {contains:<38} {who}")
print()
print("artefacts:", len(ARTEFACTS))
print("facts per register: name, offset, width, access, reset =", 5)
print("hand-maintained copies of each fact:", len(ARTEFACTS))artefact contains read by
RTL register block address decode, write path, read mux the hardware
C header #define for each offset firmware
verification model a mirror of the same offsets the testbench
documentation table name, offset, width, access, reset humans
artefacts: 4
facts per register: name, offset, width, access, reset = 5
hand-maintained copies of each fact: 4Four copies, and unlike Chapter 31.2's filelists the divergence here is not merely inconvenient: a C header whose offset disagrees with the RTL is firmware writing to the wrong register, found during bring-up on real silicon.
By Chapter 31.1's metric this is a good target — an address decode is one shape, a write path is one shape, a read mux is one shape, so three shapes buy a file of any length.
2. The Annotation
for reg in spec:
print(f" localparam ADDR_{reg.name.upper()} = {reg.offset};", file=f)from dataclasses import dataclass
@dataclass(frozen=True)
class Reg:
name: str
offset: int
width: int
access: str
reset: int
SPEC = [
Reg("ctrl", 0x00, 32, "rw", 0),
Reg("mode", 0x04, 32, "rw", 1),
Reg("status", 0x08, 32, "ro", 0),
]
for reg in SPEC:
print(f" localparam ADDR_{reg.name.upper()} = {reg.offset};")
print()
print("registers emitted:", len(SPEC))
print("artefacts produced:", 1)
print("consistency rules checked:", 0) localparam ADDR_CTRL = 0;
localparam ADDR_MODE = 4;
localparam ADDR_STATUS = 8;
registers emitted: 3
artefacts produced: 1
consistency rules checked: 0Three lines of valid Verilog from a three-line loop. The offsets print as decimals, which is legal and unreadable in a register map — but that is cosmetic, and the next four sections are not.
3. Colliding Offsets Compile, and One Register Dies
import subprocess
from pathlib import Path
def regblock(regs):
L = ["module regs(input clk, input we, input [7:0] addr, input [31:0] wdata,",
" output [31:0] rd_ctrl, output [31:0] rd_mode);",
" reg [31:0] r_ctrl, r_mode;",
" always @(posedge clk) if (we) begin",
" case (addr)"]
for name, off in regs:
L.append(f" 8'h{off:02X}: r_{name} <= wdata;")
L += [" endcase", " end",
" assign rd_ctrl = r_ctrl;", " assign rd_mode = r_mode;",
"endmodule"]
return "\n".join(L) + "\n"
TB = """module tb;
reg clk=0, we=0; reg [7:0] addr; reg [31:0] wdata;
wire [31:0] c, m;
regs u(.clk(clk), .we(we), .addr(addr), .wdata(wdata),
.rd_ctrl(c), .rd_mode(m));
always #1 clk = ~clk;
initial begin
@(negedge clk); addr=8'h04; wdata=32'hAAAA_AAAA; we=1;
@(posedge clk); #0 we=0; @(negedge clk);
$display("wrote 0x04: ctrl=%08h mode=%08h", c, m);
$finish;
end
endmodule
"""
Path("tb.v").write_text(TB, encoding="utf-8")
for label, regs in (("distinct offsets", [("ctrl", 0x00), ("mode", 0x04)]),
("colliding offsets", [("ctrl", 0x04), ("mode", 0x04)])):
Path("regs.v").write_text(regblock(regs), encoding="utf-8")
c = subprocess.run(["iverilog", "-g2005", "-o", "r.vvp", "regs.v", "tb.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
r = subprocess.run(["vvp", "r.vvp"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
line = [l for l in r.stdout.splitlines() if l.startswith("wrote")][0]
print(f"{label:<20} compile={c.returncode} {line}")distinct offsets compile=0 wrote 0x04: ctrl=xxxxxxxx mode=aaaaaaaa
colliding offsets compile=0 wrote 0x04: ctrl=aaaaaaaa mode=xxxxxxxxRead the two right-hand columns together. With distinct offsets, writing 0x04 sets mode and leaves ctrl untouched — correct. With both registers at 0x04, the write goes to ctrl, and mode is xxxxxxxx: it was not written, and it never will be.
A Verilog case takes the first matching item, so the second register at a duplicated offset is not aliased or shared — it is dead. Firmware writing to mode succeeds, reads back whatever ctrl happens to hold, and the bug surfaces as a feature that does not work.
Both versions compiled with returncode zero. No tool in the flow objects, because nothing in the flow knows that two case items at the same address was not the intent.
from dataclasses import dataclass
from collections import Counter
@dataclass(frozen=True)
class Reg:
name: str
offset: int
width: int
def collisions(regs):
seen = Counter(r.offset for r in regs)
return {off: sorted(r.name for r in regs if r.offset == off)
for off, n in seen.items() if n > 1}
GOOD = [Reg("ctrl", 0x00, 32), Reg("mode", 0x04, 32)]
BAD = [Reg("ctrl", 0x04, 32), Reg("mode", 0x04, 32), Reg("stat", 0x08, 32)]
for label, regs in (("good spec", GOOD), ("bad spec", BAD)):
c = collisions(regs)
print(f"{label:<12} collisions={c or 'none'}")
print()
print("the check is four lines and runs before anything is emitted.")good spec collisions=none
bad spec collisions={4: ['ctrl', 'mode']}
the check is four lines and runs before anything is emitted.Four lines, run before emitting, and the generator refuses rather than producing a file that compiles. This is Chapter 21.7's validate-before-you-run applied to a spec, and Chapter 29.2's refuse-the-ungoverned-metric applied to a register map.
4. An Offset Too Wide for the Bus Is Unreachable
The second defect needs no duplicate at all:
import subprocess
from pathlib import Path
Path("ov.v").write_text("""module ov;
reg [7:0] addr;
localparam ADDR_BIG = 256;
integer hits, tried;
initial begin
hits = 0; tried = 0;
for (addr = 0; addr < 255; addr = addr + 1) begin
tried = tried + 1;
if (addr == ADDR_BIG) hits = hits + 1;
end
tried = tried + 1;
if (8'hFF == ADDR_BIG) hits = hits + 1;
$display("address values tried : %0d", tried);
$display("values matching 256 : %0d", hits);
$finish;
end
endmodule
""", encoding="utf-8")
c = subprocess.run(["iverilog", "-g2005", "-o", "ov.vvp", "ov.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
r = subprocess.run(["vvp", "ov.vvp"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
print("compile returncode:", c.returncode)
for l in r.stdout.splitlines():
if not l.startswith("ov.v"):
print(l)compile returncode: 0
address values tried : 256
values matching 256 : 0Every one of the 256 possible addresses was tried and none matched. A register at offset 0x100 behind an 8-bit address bus is unreachable by construction, and the file compiles.
This is the real width hazard, and it is worth being precise about why, because the obvious story is wrong:
BITS = {"unsized localparam": 32, "sized [7:0] localparam": 8, "address bus": 8}
for name, b in BITS.items():
print(f" {name:<24} {b} bits")
print()
print("does the width mismatch break equality?", "no — the 8-bit value is zero-extended")
print("what breaks :", "an offset whose VALUE exceeds the bus range")
print()
for off, bus_bits in ((0x08, 8), (0xFF, 8), (0x100, 8), (0x100, 16)):
fits = off < (1 << bus_bits)
print(f" offset 0x{off:03X} on a {bus_bits:>2}-bit bus reachable={fits}") unsized localparam 32 bits
sized [7:0] localparam 8 bits
address bus 8 bits
does the width mismatch break equality? no — the 8-bit value is zero-extended
what breaks : an offset whose VALUE exceeds the bus range
offset 0x008 on a 8-bit bus reachable=True
offset 0x0FF on a 8-bit bus reachable=True
offset 0x100 on a 8-bit bus reachable=False
offset 0x100 on a 16-bit bus reachable=TrueThe unsized localparam really is 32 bits wide against an 8-bit bus, and that does not by itself break the comparison — Verilog zero-extends the narrower operand, so addr == ADDR_CTRL is true whenever the values agree. The folklore version of this warning ("an unsized parameter silently truncates") is not what happens here.
What does happen is simpler and worse: if the offset's value does not fit the bus, the comparison is false for every possible address.
5. The One Defect That Cannot Survive
A spec read from YAML or CSV hands back strings, and one of them produces the chapter's only loud failure:
import subprocess
from pathlib import Path
for label, value in (("int 16 from the spec", 16),
("string '0x10' from YAML", "0x10"),
("int(…, 16) applied", int("0x10", 16))):
line = f" localparam ADDR_CTRL = {value};"
Path("s.v").write_text(f"module s;\n{line}\nendmodule\n", encoding="utf-8")
c = subprocess.run(["iverilog", "-g2005", "-o", "s.vvp", "s.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
msg = c.stdout.strip().splitlines()[0][:34] if c.stdout.strip() else "(none)"
print(f"{label:<26} emits {line.strip():<34} rc={c.returncode} {msg}")int 16 from the spec emits localparam ADDR_CTRL = 16; rc=0 (none)
string '0x10' from YAML emits localparam ADDR_CTRL = 0x10; rc=2 s.v:2: syntax error
int(…, 16) applied emits localparam ADDR_CTRL = 16; rc=0 (none)0x10 is Python and C syntax and is not Verilog, so the generated file does not compile. This is the best-behaved defect in the chapter, and it is here to make that point: a generator bug that stops the build costs an hour, and a generator bug that compiles costs a bring-up cycle. Three of the four defects in this chapter are the second kind.
The fix is to convert at the boundary, exactly as Chapter 30.4 Section 4 did for CSV:
def to_int(v):
"""Spec values arrive as strings; accept decimal and 0x forms."""
if isinstance(v, int):
return v
s = str(v).strip().lower()
return int(s, 16) if s.startswith("0x") else int(s, 10)
RAW = [("ctrl", "0x00"), ("mode", "0x04"), ("status", "8"), ("extra", 12)]
for name, raw in RAW:
off = to_int(raw)
print(f" {name:<8} spec={raw!r:<7} -> {off:>3} emitted as 8'h{off:02X}")
print()
print("every offset is now an int:", all(isinstance(to_int(r[1]), int) for r in RAW))
print("emitted in hex, which is how a register map is read by humans.") ctrl spec='0x00' -> 0 emitted as 8'h00
mode spec='0x04' -> 4 emitted as 8'h04
status spec='8' -> 8 emitted as 8'h08
extra spec=12 -> 12 emitted as 8'h0C
every offset is now an int: True
emitted in hex, which is how a register map is read by humans.Emitting 8'h04 rather than 4 costs nothing and gives the generated RTL the same notation the specification uses, which is what makes a generated register block reviewable against the document.
6. The Rules a Register Map Has
Collisions and range are two of a short list, and all of them are arithmetic over the spec:
from dataclasses import dataclass
from collections import Counter
from typing import List
@dataclass(frozen=True)
class Reg:
name: str
offset: int
width: int
access: str
reset: int
def validate(regs: List[Reg], addr_bits: int, data_bits: int):
problems = []
dupes = {off: sorted(r.name for r in regs if r.offset == off)
for off, n in Counter(r.offset for r in regs).items() if n > 1}
if dupes:
problems.append(f"offset collision: {dupes}")
names = {n: c for n, c in Counter(r.name for r in regs).items() if c > 1}
if names:
problems.append(f"duplicate names: {sorted(names)}")
too_big = [r.name for r in regs if r.offset >= (1 << addr_bits)]
if too_big:
problems.append(f"offset exceeds a {addr_bits}-bit bus: {too_big}")
unaligned = [r.name for r in regs if r.offset % (r.width // 8)]
if unaligned:
problems.append(f"misaligned for their width: {unaligned}")
too_wide = [r.name for r in regs if r.width > data_bits]
if too_wide:
problems.append(f"wider than the {data_bits}-bit data bus: {too_wide}")
bad_access = [r.name for r in regs if r.access not in ("rw", "ro", "wo")]
if bad_access:
problems.append(f"unknown access type: {bad_access}")
ro_with_reset = [r.name for r in regs if r.access == "ro" and r.reset != 0]
if ro_with_reset:
problems.append(f"read-only with a non-zero reset: {ro_with_reset}")
return problems
GOOD = [Reg("ctrl", 0x00, 32, "rw", 0), Reg("mode", 0x04, 32, "rw", 1),
Reg("status", 0x08, 32, "ro", 0)]
print("good spec:", validate(GOOD, 8, 32) or "no problems")
print()
BAD = [Reg("ctrl", 0x04, 32, "rw", 0),
Reg("mode", 0x04, 32, "rw", 0),
Reg("ctrl", 0x06, 32, "rw", 0),
Reg("huge", 0x100, 32, "rw", 0),
Reg("wide", 0x10, 64, "rw", 0),
Reg("odd", 0x20, 32, "rwx", 0),
Reg("ro_rst", 0x24, 32, "ro", 5)]
for p in validate(BAD, 8, 32):
print(" -", p)
print()
print("rules checked:", 7)
print("rules the annotation checks:", 0)good spec: no problems
- offset collision: {4: ['ctrl', 'mode']}
- duplicate names: ['ctrl']
- offset exceeds a 8-bit bus: ['huge']
- misaligned for their width: ['ctrl']
- wider than the 32-bit data bus: ['wide']
- unknown access type: ['odd']
- read-only with a non-zero reset: ['ro_rst']
rules checked: 7
rules the annotation checks: 0Seven rules, none of which needs a tool, and every one of which describes a spec that would otherwise produce a compiling file.
Two of the rows are worth reading closely. misaligned caught ctrl at 0x06, which is the kind of offset a human introduces while inserting a register into a list by hand. And read-only with a non-zero reset is not a hardware error at all — it is a specification error, because nothing can ever write the value the spec promises after reset, so either the access type or the reset value is wrong.
7. One Spec, Three Artefacts
One register spec, three generated artefacts
from dataclasses import dataclass
@dataclass(frozen=True)
class Reg:
name: str
offset: int
width: int
access: str
reset: int
SPEC = [Reg("ctrl", 0x00, 32, "rw", 0), Reg("mode", 0x04, 32, "rw", 1),
Reg("status", 0x08, 32, "ro", 0)]
HDR = "// GENERATED by gen_regs.py from regs.csv — do not edit"
def emit_rtl(regs, addr_bits=8, data_bits=32):
L = [HDR,
f"module regs(input clk, input rst_n, input we,",
f" input [{addr_bits-1}:0] addr, input [{data_bits-1}:0] wdata,",
f" output [{data_bits-1}:0] rdata);"]
for r in regs:
L.append(f" localparam [{addr_bits-1}:0] ADDR_{r.name.upper()} "
f"= {addr_bits}'h{r.offset:02X};")
for r in regs:
L.append(f" reg [{r.width-1}:0] r_{r.name};")
L.append(" always @(posedge clk) begin")
L.append(" if (!rst_n) begin")
for r in regs:
L.append(f" r_{r.name} <= {r.width}'d{r.reset};")
L.append(" end else if (we) begin")
L.append(" case (addr)")
for r in regs:
if r.access != "ro":
L.append(f" ADDR_{r.name.upper()}: r_{r.name} <= wdata;")
L.append(" default: ;")
L.append(" endcase")
L.append(" end")
L.append(" end")
L.append(" assign rdata =")
for r in regs:
L.append(f" (addr == ADDR_{r.name.upper()}) ? r_{r.name} :")
L.append(f" {data_bits}'d0;")
L.append("endmodule")
return "\n".join(L) + "\n"
def emit_header(regs):
L = ["/* GENERATED by gen_regs.py from regs.csv - do not edit */"]
for r in regs:
L.append(f"#define ADDR_{r.name.upper()} 0x{r.offset:02X}u")
return "\n".join(L) + "\n"
def emit_docs(regs):
L = ["| name | offset | width | access | reset |",
"|---|---|---:|---|---:|"]
for r in regs:
L.append(f"| {r.name} | 0x{r.offset:02X} | {r.width} | "
f"{r.access} | 0x{r.reset:X} |")
return "\n".join(L) + "\n"
rtl = emit_rtl(SPEC)
hdr = emit_header(SPEC)
doc = emit_docs(SPEC)
print(rtl, end="")
print()
print(hdr, end="")
print()
print(doc, end="")// GENERATED by gen_regs.py from regs.csv — do not edit
module regs(input clk, input rst_n, input we,
input [7:0] addr, input [31:0] wdata,
output [31:0] rdata);
localparam [7:0] ADDR_CTRL = 8'h00;
localparam [7:0] ADDR_MODE = 8'h04;
localparam [7:0] ADDR_STATUS = 8'h08;
reg [31:0] r_ctrl;
reg [31:0] r_mode;
reg [31:0] r_status;
always @(posedge clk) begin
if (!rst_n) begin
r_ctrl <= 32'd0;
r_mode <= 32'd1;
r_status <= 32'd0;
end else if (we) begin
case (addr)
ADDR_CTRL: r_ctrl <= wdata;
ADDR_MODE: r_mode <= wdata;
default: ;
endcase
end
end
assign rdata =
(addr == ADDR_CTRL) ? r_ctrl :
(addr == ADDR_MODE) ? r_mode :
(addr == ADDR_STATUS) ? r_status :
32'd0;
endmodule
/* GENERATED by gen_regs.py from regs.csv - do not edit */
#define ADDR_CTRL 0x00u
#define ADDR_MODE 0x04u
#define ADDR_STATUS 0x08u
| name | offset | width | access | reset |
|---|---|---:|---|---:|
| ctrl | 0x00 | 32 | rw | 0x0 |
| mode | 0x04 | 32 | rw | 0x1 |
| status | 0x08 | 32 | ro | 0x0 |Note what the access field did to the RTL: status is ro, so it appears in the read mux and not in the write case. One field of the spec drove a structural difference in the generated hardware, which is the kind of leverage that makes register generators worth building — and the kind of thing a reviewer must be able to check, which is Section 8.
8. Compile It, Simulate It, and Cross-Check the Three
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Reg:
name: str
offset: int
width: int
access: str
reset: int
SPEC = [Reg("ctrl", 0x00, 32, "rw", 0), Reg("mode", 0x04, 32, "rw", 1),
Reg("status", 0x08, 32, "ro", 0)]
def emit_rtl(regs, addr_bits=8, data_bits=32):
L = ["module regs(input clk, input rst_n, input we,",
f" input [{addr_bits-1}:0] addr, input [{data_bits-1}:0] wdata,",
f" output [{data_bits-1}:0] rdata);"]
for r in regs:
L.append(f" localparam [{addr_bits-1}:0] ADDR_{r.name.upper()} "
f"= {addr_bits}'h{r.offset:02X};")
for r in regs:
L.append(f" reg [{r.width-1}:0] r_{r.name};")
L += [" always @(posedge clk) begin", " if (!rst_n) begin"]
for r in regs:
L.append(f" r_{r.name} <= {r.width}'d{r.reset};")
L += [" end else if (we) begin", " case (addr)"]
for r in regs:
if r.access != "ro":
L.append(f" ADDR_{r.name.upper()}: r_{r.name} <= wdata;")
L += [" default: ;", " endcase", " end", " end",
" assign rdata ="]
for r in regs:
L.append(f" (addr == ADDR_{r.name.upper()}) ? r_{r.name} :")
L += [f" {data_bits}'d0;", "endmodule"]
return "\n".join(L) + "\n"
def emit_header(regs):
return "\n".join(f"#define ADDR_{r.name.upper()} 0x{r.offset:02X}u"
for r in regs) + "\n"
def emit_docs(regs):
L = ["| name | offset | width | access | reset |", "|---|---|---:|---|---:|"]
for r in regs:
L.append(f"| {r.name} | 0x{r.offset:02X} | {r.width} | {r.access} | 0x{r.reset:X} |")
return "\n".join(L) + "\n"
Path("regs.v").write_text(emit_rtl(SPEC), encoding="utf-8")
hdr = emit_header(SPEC)
doc = emit_docs(SPEC)
TB = """module tb;
reg clk=0, rst_n=0, we=0; reg [7:0] addr; reg [31:0] wdata;
wire [31:0] rdata;
regs u(.clk(clk), .rst_n(rst_n), .we(we), .addr(addr),
.wdata(wdata), .rdata(rdata));
always #1 clk = ~clk;
task wr(input [7:0] a, input [31:0] d);
begin @(negedge clk); addr=a; wdata=d; we=1; @(posedge clk); #0 we=0; end
endtask
task rd(input [7:0] a);
begin @(negedge clk); addr=a; #0 $display("read 0x%02h -> %08h", a, rdata); end
endtask
initial begin
@(negedge clk); rst_n=0; @(posedge clk); @(negedge clk); rst_n=1;
rd(8'h00); rd(8'h04); rd(8'h08);
wr(8'h00, 32'h1234_5678);
wr(8'h08, 32'hDEAD_BEEF);
rd(8'h00); rd(8'h08);
$finish;
end
endmodule
"""
Path("tb.v").write_text(TB, encoding="utf-8")
c = subprocess.run(["iverilog", "-g2005", "-o", "r.vvp", "regs.v", "tb.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
print("compile returncode:", c.returncode)
r = subprocess.run(["vvp", "r.vvp"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
for l in r.stdout.splitlines():
if l.startswith("read"):
print(" ", l)
rtl_offsets = {m.group(1).lower(): int(m.group(2), 16) for m in
re.finditer(r"ADDR_(\w+)\s*=\s*8'h([0-9A-Fa-f]+)",
Path("regs.v").read_text(encoding="utf-8"))}
hdr_offsets = {m.group(1).lower(): int(m.group(2), 16) for m in
re.finditer(r"#define ADDR_(\w+)\s+0x([0-9A-Fa-f]+)u", hdr)}
doc_offsets = {m.group(1): int(m.group(2), 16) for m in
re.finditer(r"^\| (\w+) \| 0x([0-9A-Fa-f]+) \|", doc, re.M)}
print()
print("offsets parsed back from the RTL :", rtl_offsets)
print("offsets parsed back from the header:", hdr_offsets)
print("offsets parsed back from the docs :", doc_offsets)
print()
print("all three agree:", rtl_offsets == hdr_offsets == doc_offsets)
print("and agree with the spec:",
rtl_offsets == {r.name: r.offset for r in SPEC})compile returncode: 0
read 0x00 -> 00000000
read 0x04 -> 00000001
read 0x08 -> 00000000
read 0x00 -> 12345678
read 0x08 -> 00000000
offsets parsed back from the RTL : {'ctrl': 0, 'mode': 4, 'status': 8}
offsets parsed back from the header: {'ctrl': 0, 'mode': 4, 'status': 8}
offsets parsed back from the docs : {'ctrl': 0, 'mode': 4, 'status': 8}
all three agree: True
and agree with the spec: TrueFour claims established by a compiler and a simulator rather than by assertion:
The reset values from the spec reached the hardware. mode reads 00000001 after reset because the spec said reset=1, and the other two read zero.
A writable register took the write. ctrl reads back 12345678.
The read-only register ignored it. Writing DEADBEEF to 0x08 left status at zero, because the access field kept it out of the write case — the structural difference from Section 7, confirmed behaviourally.
The three artefacts agree, and the offsets were re-parsed out of the emitted text with three different regexes rather than compared against the spec in memory. That is Chapter 31.2 Section 7's discipline: a check that reads the spec cannot see an emitter that dropped a register.
9. Fields Inside a Register Have Their Own Rules
Section 6 validated the register list. A real spec goes one level deeper — each register has named bit fields — and the field layout has its own consistency rules, which are the same kind of arithmetic.
from dataclasses import dataclass
from typing import List
@dataclass(frozen=True)
class Field:
name: str
lsb: int
width: int
@property
def msb(self):
return self.lsb + self.width - 1
def validate_fields(reg_name, reg_width, fields: List[Field]):
problems = []
over = [f.name for f in fields if f.msb >= reg_width]
if over:
problems.append(f"extends past bit {reg_width - 1}: {over}")
bits = {}
for f in fields:
for b in range(f.lsb, f.msb + 1):
bits.setdefault(b, []).append(f.name)
overlap = {b: ns for b, ns in bits.items() if len(ns) > 1}
if overlap:
pairs = sorted({tuple(sorted(ns)) for ns in overlap.values()})
problems.append(f"overlapping fields: {pairs}")
zero = [f.name for f in fields if f.width <= 0]
if zero:
problems.append(f"zero or negative width: {zero}")
return problems
GOOD = [Field("enable", 0, 1), Field("mode", 1, 2), Field("thresh", 8, 8)]
BAD = [Field("enable", 0, 1), Field("mode", 0, 2), Field("huge", 24, 16),
Field("empty", 4, 0)]
print("good field list:", validate_fields("ctrl", 32, GOOD) or "no problems")
print()
print("bad field list:")
for p in validate_fields("ctrl", 32, BAD):
print(" -", p)good field list: no problems
bad field list:
- extends past bit 31: ['huge']
- overlapping fields: [('enable', 'mode')]
- zero or negative width: ['empty']Three rules, and overlapping fields is the one that matters most: two fields sharing bit 0 means firmware writing mode also writes enable, which is a hardware behaviour nobody specified and which the generated RTL will faithfully implement.
The unoccupied bits are worth reporting too, and not as an error:
from dataclasses import dataclass
@dataclass(frozen=True)
class Field:
name: str
lsb: int
width: int
@property
def msb(self):
return self.lsb + self.width - 1
FIELDS = [Field("enable", 0, 1), Field("mode", 1, 2), Field("thresh", 8, 8)]
REG_WIDTH = 32
used = set()
for f in FIELDS:
used |= set(range(f.lsb, f.msb + 1))
unused = sorted(set(range(REG_WIDTH)) - used)
def runs(bits):
out = []
for b in bits:
if out and b == out[-1][1] + 1:
out[-1][1] = b
else:
out.append([b, b])
return [(a, z) for a, z in out]
print(f"{'field':<10} {'bits':<10} width")
for f in FIELDS:
print(f"{f.name:<10} [{f.msb}:{f.lsb}]".ljust(22) + f"{f.width}")
print()
print("bits defined :", len(used), "of", REG_WIDTH)
print("bits reserved :", len(unused))
print("reserved runs :", [f"[{z}:{a}]" if z != a else f"[{a}]"
for a, z in runs(unused)])
print()
print("reserved bits must read as:", "zero, deliberately")field bits width
enable [0:0] 1
mode [2:1] 2
thresh [15:8] 8
bits defined : 11 of 32
bits reserved : 21
reserved runs : ['[7:3]', '[31:16]']
reserved bits must read as: zero, deliberatelyEleven bits defined and twenty-one reserved, in two runs. Reporting the runs rather than the count is what makes the output usable: [7:3] and [31:16] are the gaps a designer looks at and asks whether they were intended.
That last line is a specification decision the generator has to encode rather than leave to chance. A read of a register with undefined bits returns whatever the read mux happens to produce, and firmware that does read-modify-write on that register will write back whatever it read — so reserved bits that are not deliberately tied to zero turn a read-modify-write into an undefined operation. The generated read mux is where that gets decided, which is one more place where a field in a CSV drives the shape of the hardware.
10. Predict, Run, Observe
from collections import Counter
REGS = [("ctrl", 0x04), ("mode", 0x04), ("huge", 0x100)]
# A
print("A:", {o: [n for n, x in REGS if x == o]
for o, c in Counter(o for _, o in REGS).items() if c > 1})
# B
print("B:", [n for n, o in REGS if o >= (1 << 8)])
# C
print("C:", 0x06 % 4, 0x08 % 4)
# D
print("D:", f"{4}", f"8'h{4:02X}")The verified output:
A: {4: ['ctrl', 'mode']}
B: ['huge']
C: 2 0
D: 4 8'h04A — the collision check, which is a Counter and a comprehension.
B — the register no address can reach.
C — misalignment as a remainder: 0x06 is not a multiple of four, 0x08 is.
D — the same offset as a bare decimal and as sized hex. Both compile; only one reads like a register map.
11. Exercises
Exercise 1 — count your copies. List every artefact in your project that contains a register offset, and say which is authoritative.
Exercise 2 — the bring-up bug. Describe what a firmware/RTL offset disagreement looks like when it is found on silicon.
Exercise 3 — three shapes. Identify the address decode, write path and read mux in your generated register block.
Exercise 4 — collide two offsets. Put two registers at the same address, compile, and simulate a write. Report both read-backs.
Exercise 5 — first item wins. Explain why the second register is dead rather than aliased.
Exercise 6 — the collision check. Implement it and run it against your real spec.
Exercise 7 — an unreachable offset. Put a register beyond your address width and report how many addresses match it.
Exercise 8 — range, not truncation. Explain why offset < 2**addr_width is the right check and a declared width is not.
Exercise 9 — the folklore claim. Test whether an unsized localparam breaks equality in your simulator, and report what you measured.
Exercise 10 — the loud defect. Emit a 0x10 string into Verilog and record the returncode.
Exercise 11 — loud versus silent. Rank this chapter's four defects by how long each would survive, and justify the order.
Exercise 12 — convert at the boundary. Write the to_int that accepts decimal and hex spec values, and run it over your spec.
Exercise 13 — hex in the output. Change your generator to emit sized hex and say what it buys a reviewer.
Exercise 14 — the seven rules. Run all seven against your real spec and report which fire.
Exercise 15 — a misaligned offset. Find or construct one, and say how it got there.
Exercise 16 — a specification error. Explain why read-only with a non-zero reset is a spec bug rather than a hardware bug.
Exercise 17 — one field, two artefacts. Trace your access field into both the write path and the read mux.
Exercise 18 — compile the generated block. Run your generator's RTL through a compiler and report the returncode.
Exercise 19 — simulate the reset values. Read every register after reset and compare against the spec column.
Exercise 20 — prove the read-only. Write to a read-only register in simulation and confirm it does not change.
Exercise 21 — re-parse the artefacts. Extract the offsets from your RTL, header and docs with three separate regexes.
Exercise 22 — a dropped register. Break one emitter so it omits a register, and confirm your cross-check fires.
Exercise 23 — why not compare against the spec. Say what class of bug a spec-based comparison cannot see.
Exercise 24 — refuse, do not warn. Decide what your generator does with an invalid spec, and justify it against Chapter 30.7 Section 10.
12. Summary
- Four artefacts hold the same five facts per register, and a header/RTL disagreement is firmware writing to the wrong address, found on silicon.
- By Chapter 31.1's metric this is an ideal target: three shapes — decode, write path, read mux — buy a file of any length.
- Two registers at one offset compile cleanly and one of them is dead. A Verilog
casetakes the first match, so the second register is never written: verified asmode=xxxxxxxxin simulation. - The collision check is four lines of
Counterand runs before anything is emitted. - An offset beyond the address width matches none of the possible addresses — 0 of 256 on an 8-bit bus, and it compiles.
- The width story is a range problem, not a truncation problem. An unsized localparam is 32 bits against an 8-bit bus and equality still works by zero-extension; the check is
offset < 2**addr_width. - A YAML string offset is the one loud defect:
localparam ADDR_CTRL = 0x10;is a syntax error. The other three compile, which is what makes them expensive. - Convert spec values at the boundary and emit sized hex, so the RTL reads the way the specification does.
- A register map has seven checkable rules — collisions, duplicate names, range, alignment, data width, access type, and read-only-with-reset — none of which needs a tool.
- Read-only with a non-zero reset is a specification error, because nothing can write the value the spec promises.
- One spec field drove a structural difference:
access: rokeptstatusout of the writecaseand in the read mux, confirmed behaviourally. - Re-parse the offsets out of the emitted artefacts with separate regexes; a check that reads the spec cannot see an emitter that dropped a register.
- Bit fields have their own three rules: they must fit the register width, must not overlap, and must have a positive width.
- Two fields sharing a bit means firmware writing one writes the other, and the generated RTL implements that faithfully.
- Report the reserved bits as runs, not a count —
[7:3]and[31:16]are what a designer inspects for intent. - Reserved bits must be deliberately tied to zero, or a firmware read-modify-write on that register is an undefined operation.
The next chapter takes the simplest shape in the module — one line repeated — and finds the two ways it still goes wrong.
Where this fits
Part of the Python curriculum.
