Skip to content
VLSI Mentor

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

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

Four 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
for reg in spec:
    print(f"  localparam ADDR_{reg.name.upper()} = {reg.offset};", file=f)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  localparam ADDR_CTRL = 0;
  localparam ADDR_MODE = 4;
  localparam ADDR_STATUS = 8;

registers emitted: 3
artefacts produced: 1
consistency rules checked: 0

Three 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
distinct offsets     compile=0  wrote 0x04: ctrl=xxxxxxxx mode=aaaaaaaa
colliding offsets    compile=0  wrote 0x04: ctrl=aaaaaaaa mode=xxxxxxxx

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
compile returncode: 0
address values tried   : 256
values matching 256    : 0

Every 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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=True

The 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:

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

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

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

Seven 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

Three blocks left to right. The first is the register spec, a CSV or YAML file holding name, offset, width, access and reset for each register. An arrow labelled validate leads to the second, the generator, which applies the seven consistency rules and refuses a spec that breaks any of them. An arrow labelled emit leads to the third, three artefacts: the RTL register block, the C header and the documentation table.Register specone CSV or YAMLGenerator7 rules, then emit3 artefactsRTL, header, docsvalidateemit12
The middle box is where every check in Section 6 runs, and it is the reason the arrangement is worth building: three artefacts generated from an unvalidated spec are three consistent descriptions of hardware that does not work.
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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="")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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

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

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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, deliberately

Eleven 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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: {4: ['ctrl', 'mode']}
B: ['huge']
C: 2 0
D: 4 8'h04

A — 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 case takes the first match, so the second register is never written: verified as mode=xxxxxxxx in simulation.
  • The collision check is four lines of Counter and 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: ro kept status out of the write case and 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.