Skip to content
VLSI Mentor

Python Part II · Module 35 · Industry VLSI Automation Projects

A Register-Map Generator

Every tool so far has read something a tool produced. This one produces something a tool will read — register RTL, an address header and a documentation table, all from one specification — which inverts the problem. Nothing downstream can detect that a generated file is wrong, because the generated file is the authority. The only defence is a check that regenerates and compares, and the most common thing people put in generated files makes that check useless.

Advanced26 min readPythonPart IIAutomationCode GenerationRegisters

Part II · Module 35 · Chapter 35.6 · A Register-Map Generator

1. One Spec, Four Consumers

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
CONSUMERS = [
    ("the RTL register block", "rtl/axi_fifo_regs.v", "the design"),
    ("the address header", "sw/axi_fifo_regs.h", "firmware"),
    ("the documentation table", "doc/axi_fifo_regs.md", "a human"),
    ("the UVM RAL model", "tb/axi_fifo_ral.sv", "verification"),
]

print(f"{'output':<28} {'path':<26} read by")
for what, path, who in CONSUMERS:
    print(f"{what:<28} {path:<26} {who}")

print()
print("outputs that must agree      :", len(CONSUMERS))
print("ways they can disagree       :", "every field, every offset")
print("who notices a disagreement   :", "firmware, at bring-up")
print("how long after the mistake   :", "months")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
output                       path                       read by
the RTL register block       rtl/axi_fifo_regs.v        the design
the address header           sw/axi_fifo_regs.h         firmware
the documentation table      doc/axi_fifo_regs.md       a human
the UVM RAL model            tb/axi_fifo_ral.sv         verification

outputs that must agree      : 4
ways they can disagree       : every field, every offset
who notices a disagreement   : firmware, at bring-up
how long after the mistake   : months

Four files that must agree, and the person who notices they do not is a firmware engineer at bring-up, months later. That delay is the entire justification for generating them: a disagreement between hand-maintained copies is not caught by any test, because each file is internally consistent and no tool reads two of them at once.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
PARTS = [
    ("regs.yaml", "the single source of truth", "hand-written, reviewed"),
    ("--out rtl/", "where the generated files go", "all of them, together"),
    ("--check", "regenerate and compare", "the enforcement — §9"),
]

print(f"{'part':<14} {'what it is':<32} note")
for part, what, note in PARTS:
    print(f"{part:<14} {what:<32} {note}")

print()
MODES = [
    ("gen_regs.py regs.yaml --out rtl/", "writes the files", 0),
    ("gen_regs.py regs.yaml --out rtl/ --check", "writes nothing", "0 or 1"),
]
print(f"{'invocation':<46} {'effect':<18} exit")
for cmd, effect, code in MODES:
    print(f"{cmd:<46} {effect:<18} {code}")

print()
print("--check is the same program in a different mode:", True)
print("why not a separate script                      :",
      "it would drift from the generator")
print("and what --check proves                        :",
      "the files on disk are what this spec generates")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
part           what it is                       note
regs.yaml      the single source of truth       hand-written, reviewed
--out rtl/     where the generated files go     all of them, together
--check        regenerate and compare           the enforcement — §9

invocation                                     effect             exit
gen_regs.py regs.yaml --out rtl/               writes the files   0
gen_regs.py regs.yaml --out rtl/ --check       writes nothing     0 or 1

--check is the same program in a different mode: True
why not a separate script                      : it would drift from the generator
and what --check proves                        : the files on disk are what this spec generates

--check is a mode of the generator, not a separate checker. A separate script would have its own copy of the emission logic and would drift from it, and then the check would pass while the files were wrong — which is Chapter 34.3's single-direction rule stated as a reason not to duplicate code.

3. The First Version

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# v1: the files are hand-written and kept in step by discipline.
RTL = """\
module axi_fifo_regs (input [11:0] addr, output [31:0] rdata);
  localparam CTRL_OFFSET   = 12'h000;
  localparam STATUS_OFFSET = 12'h004;
endmodule
"""
HEADER = """\
#define AXI_FIFO_CTRL   0x000
#define AXI_FIFO_STATUS 0x004
#define AXI_FIFO_IRQ    0x008
"""
DOCS = """\
| Register | Offset |
|----------|--------|
| CTRL     | 0x000  |
| STATUS   | 0x004  |
"""

import re

rtl_offsets = dict(re.findall(r"localparam (\w+)_OFFSET\s*=\s*12'h(\w+)", RTL))
hdr_offsets = dict(re.findall(r"#define AXI_FIFO_(\w+)\s+0x(\w+)", HEADER))
doc_offsets = dict(re.findall(r"\|\s*(\w+)\s*\|\s*0x(\w+)\s*\|", DOCS))

print("registers named in each file:")
print("  rtl   :", sorted(rtl_offsets))
print("  header:", sorted(hdr_offsets))
print("  docs  :", sorted(doc_offsets))
print()
all_names = set(rtl_offsets) | set(hdr_offsets) | set(doc_offsets)
print("union of all names    :", sorted(all_names))
print("present in all three  :",
      sorted(set(rtl_offsets) & set(hdr_offsets) & set(doc_offsets)))
print("missing somewhere     :",
      sorted(all_names - (set(rtl_offsets) & set(hdr_offsets)
                          & set(doc_offsets))))
print()
print("what a compiler says about this:", "nothing")
print("what a simulator says          :", "nothing")
print("who finds IRQ at 0x008         :", "firmware, writing to a dead address")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
registers named in each file:
  rtl   : ['CTRL', 'STATUS']
  header: ['CTRL', 'IRQ', 'STATUS']
  docs  : ['CTRL', 'STATUS']

union of all names    : ['CTRL', 'IRQ', 'STATUS']
present in all three  : ['CTRL', 'STATUS']
missing somewhere     : ['IRQ']

what a compiler says about this: nothing
what a simulator says          : nothing
who finds IRQ at 0x008         : firmware, writing to a dead address

The header declares a register the RTL does not implement. Somebody added IRQ to the header during a software bring-up and never got back to the RTL, and every tool in the flow is content: the Verilog compiles, the C compiles, the documentation is self-consistent.

4. The Limitation: Agreement Cannot Be Tested

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
DRIFT = [
    ("a register in the header only", "firmware writes to a dead address",
     "no"),
    ("an offset changed in the RTL only", "firmware reads the wrong register",
     "no"),
    ("a field width changed in docs only", "a designer implements the wrong one",
     "no"),
    ("a reset value changed in the RAL only", "the RAL test fails, blamed on RTL",
     "partly"),
]

print(f"{'the drift':<38} {'the symptom':<42} caught?")
for drift, symptom, caught in DRIFT:
    print(f"{drift:<38} {symptom:<42} {caught}")

print()
print("drifts a tool can catch today:", 0)
print("files that must agree         :", 4)
print("pairs that must agree         :", 4 * 3 // 2)
print()
print("the only way to make agreement automatic:")
print("  derive every file from ONE artefact")
print("and then the testable claim becomes     :")
print("  'the files on disk are what the spec generates'")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
the drift                              the symptom                                caught?
a register in the header only          firmware writes to a dead address          no
an offset changed in the RTL only      firmware reads the wrong register          no
a field width changed in docs only     a designer implements the wrong one        no
a reset value changed in the RAL only  the RAL test fails, blamed on RTL          partly

drifts a tool can catch today: 0
files that must agree         : 4
pairs that must agree         : 6

the only way to make agreement automatic:
  derive every file from ONE artefact
and then the testable claim becomes     :
  'the files on disk are what the spec generates'

Six pairs must agree and nothing checks any of them. The fourth row is the closest thing to a safety net and it is worse than nothing: a RAL mismatch produces a verification failure that gets filed against the design, so the wrong person spends a day on it.

Generating all four from one spec replaces six pairwise agreements with one question — does each file match what the spec generates — and that question is mechanically checkable, which is the whole of Section 9.

5. There Is No YAML Library Here

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import importlib

print("=== what is available on this machine")
for mod in ("yaml", "tomllib", "json", "configparser"):
    try:
        importlib.import_module(mod)
        print(f"  {mod:<14} present")
    except ImportError:
        print(f"  {mod:<14} NOT INSTALLED")

print()
print("the spec format is YAML because:", "it is what register specs use")
print("PyYAML is not here, so this     ")
print("  chapter uses                  :", "a reader for a flat SUBSET")
print("and that restriction is         :", "a documented non-goal — 34.6 §7")
print()
SUBSET = [
    ("top-level `key: value`", "yes"),
    ("a list of maps under `key:`", "yes, with '- ' at a fixed indent"),
    ("scalar values only", "yes"),
    ("nested lists inside a field", "no"),
    ("anchors and aliases (&a, *a)", "no"),
    ("multi-line scalars (| and >)", "no"),
    ("quoted keys, flow style {a: 1}", "no"),
]
print(f"{'YAML feature':<34} supported")
for feat, ok in SUBSET:
    print(f"{feat:<34} {ok}")
print()
print("features supported:", sum(1 for _, o in SUBSET if o.startswith("yes")))
print("features refused  :", sum(1 for _, o in SUBSET if o == "no"))
print("what a real tool would do:", "hand the file to PyYAML and move on")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== what is available on this machine
  yaml           NOT INSTALLED
  tomllib        present
  json           present
  configparser   present

the spec format is YAML because: it is what register specs use
PyYAML is not here, so this     
  chapter uses                  : a reader for a flat SUBSET
and that restriction is         : a documented non-goal — 34.6 §7

YAML feature                       supported
top-level `key: value`             yes
a list of maps under `key:`        yes, with '- ' at a fixed indent
scalar values only                 yes
nested lists inside a field        no
anchors and aliases (&a, *a)       no
multi-line scalars (| and >)       no
quoted keys, flow style {a: 1}     no

features supported: 3
features refused  : 4
what a real tool would do: hand the file to PyYAML and move on

Four YAML features are refused and the refusal is written down. That is Chapter 34.6 Section 7's non-goals section doing real work: a reader that silently mis-parses an anchor is far worse than one that refuses it, because the generated RTL would be wrong rather than absent.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
SPEC = """\
block: axi_fifo
width: 32
registers:
  - name: CTRL
    offset: 0x000
    fields:
      - name: ENABLE
        bits: 0
        access: RW
        reset: 0x0
      - name: MODE
        bits: 2:1
        access: RW
        reset: 0x1
  - name: STATUS
    offset: 0x004
    fields:
      - name: BUSY
        bits: 0
        access: RO
        reset: 0x0
      - name: LEVEL
        bits: 7:4
        access: RO
        reset: 0x0
"""

REFUSED = ("&", "*", "|", ">", "{", "}", "[", "]")


def read_spec(text):
    """A flat-subset YAML reader. Refuses what it does not understand."""
    for n, raw in enumerate(text.splitlines(), 1):
        s = raw.split("#", 1)[0].rstrip()
        if not s.strip():
            continue
        value = s.split(":", 1)[1] if ":" in s else ""
        for ch in REFUSED:
            if ch in value:
                raise ValueError(
                    f"line {n}: this reader supports a flat subset of YAML "
                    f"and found {ch!r}")
    block, regs, reg, field = {}, [], None, None
    for raw in text.splitlines():
        s = raw.split("#", 1)[0].rstrip()
        if not s.strip():
            continue
        indent = len(s) - len(s.lstrip())
        s = s.strip()
        if indent == 0 and not s.startswith("- "):
            k, _, v = s.partition(":")
            if v.strip():
                block[k.strip()] = v.strip()
            continue
        if indent == 2 and s.startswith("- name:"):
            reg = {"name": s.split(":", 1)[1].strip(), "fields": []}
            regs.append(reg)
            field = None
            continue
        if indent == 6 and s.startswith("- name:"):
            field = {"name": s.split(":", 1)[1].strip()}
            reg["fields"].append(field)
            continue
        k, _, v = s.partition(":")
        k, v = k.strip(), v.strip()
        if indent == 4 and k != "fields":
            reg[k] = v
        elif indent == 8:
            field[k] = v
    return block, regs


block, regs = read_spec(SPEC)
print("block:", block)
for r in regs:
    print(f"  {r['name']:<8} offset={r['offset']}  fields={len(r['fields'])}")
    for f in r["fields"]:
        print(f"      {f['name']:<8} bits={f['bits']:<5} "
              f"{f['access']:<3} reset={f['reset']}")

print()
for bad, why in (("registers: &anchor\n", "an anchor"),
                 ("name: {a: 1}\n", "flow style")):
    try:
        read_spec(bad)
        print(f"  {why:<14} accepted — WRONG")
    except ValueError as exc:
        print(f"  {why:<14} refused: {str(exc)[:52]}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
block: {'block': 'axi_fifo', 'width': '32'}
  CTRL     offset=0x000  fields=2
      ENABLE   bits=0     RW  reset=0x0
      MODE     bits=2:1   RW  reset=0x1
  STATUS   offset=0x004  fields=2
      BUSY     bits=0     RO  reset=0x0
      LEVEL    bits=7:4   RO  reset=0x0

  an anchor      refused: line 1: this reader supports a flat subset of YAML a
  flow style     refused: line 1: this reader supports a flat subset of YAML a

The spec parses and both unsupported constructs are refused with a message naming the restriction. The refusal happens in a separate pass before any parsing, which matters: a reader that refused halfway through would already have built a partial structure, and Chapter 34.6's rule about stating what a tool does not do is only useful if the tool enforces it.

6. The Architecture

One spec, validated once, four emitters, one comparison

Six blocks. Top row, left to right: the YAML specification file; a subset reader producing registers and fields; and a validator that rejects overlapping bits, out-of-range bits, oversized reset values, reversed ranges and unaligned offsets. An arrow then runs downward from the validator to the bottom row. Bottom row, right to left: four pure emitters producing RTL, a C header, a documentation table and a RAL model; a mode switch that either writes the files or compares them with what is on disk; and the outcome, which is either written files or a non-zero exit naming the file that differs.regs.yamlreviewed, one sourceSubset readerrefuses what it cannotparseValidate the spec5 checks — §7Four emitterspure: spec to textWrite or compare--check — §9Files or exit 1naming what differsa valid spectext12
The validator runs before any emitter, so a bad spec produces no files at all rather than four inconsistent ones. The emitters are pure functions from the spec to text, which is what allows `--check` to be the same code path as generation with the write replaced by a comparison.

The validator's position is the important part. Running it before any emitter means an invalid spec leaves the repository untouched; running it after, or per-emitter, produces a directory where some files reflect the new spec and some the old — the partial-failure state Chapter 33.13 was about, in a form that gets committed.

7. Validating the Spec Before Emitting

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def bit_range(spec):
    if ":" in spec:
        hi, lo = (int(x) for x in spec.split(":"))
        return hi, lo
    n = int(spec)
    return n, n


def validate_register(reg, width):
    """Every problem, in one pass. Returns a list of strings."""
    problems, used = [], {}
    offset = int(reg["offset"], 16)
    if offset % (width // 8):
        problems.append(f"{reg['name']}: offset {offset:#05x} is not "
                        f"{width // 8}-byte aligned")
    for field in reg["fields"]:
        name, bits, reset = field["name"], field["bits"], field["reset"]
        hi, lo = bit_range(bits)
        if hi < lo:
            problems.append(f"{name}: reversed bit range {bits}")
        if max(hi, lo) >= width:
            problems.append(f"{name}: bit {max(hi, lo)} is outside a "
                            f"{width}-bit register")
        span = abs(hi - lo) + 1
        if int(reset, 16) >= (1 << span):
            problems.append(f"{name}: reset {reset} does not fit in "
                            f"{span} bit(s)")
        for b in range(min(hi, lo), max(hi, lo) + 1):
            if b in used:
                problems.append(f"{name}: bit {b} already used by {used[b]}")
            else:
                used[b] = name
    return problems


def F(name, bits, reset="0x0", access="RW"):
    return {"name": name, "bits": bits, "reset": reset, "access": access}


CASES = [
    ("a valid register", {"name": "CTRL", "offset": "0x000",
                          "fields": [F("ENABLE", "0"), F("MODE", "2:1", "0x1")]}),
    ("overlapping fields", {"name": "X", "offset": "0x000",
                            "fields": [F("A", "3:0"), F("B", "4:2")]}),
    ("a bit above the width", {"name": "X", "offset": "0x000",
                               "fields": [F("A", "0"), F("WIDE", "35:32")]}),
    ("a reset that does not fit", {"name": "X", "offset": "0x000",
                                   "fields": [F("A", "1:0", "0xF")]}),
    ("a reversed range", {"name": "X", "offset": "0x000",
                          "fields": [F("A", "0:3")]}),
    ("an unaligned offset", {"name": "X", "offset": "0x002",
                             "fields": [F("A", "0")]}),
]

print(f"{'spec':<28} {'clean':<7} problems")
for label, reg in CASES:
    probs = validate_register(reg, width=32)
    print(f"{label:<28} {str(not probs):<7} {probs or '-'}")

broken = CASES[1:]
print()
print("specs tried               :", len(CASES))
print("deliberately broken       :", len(broken))
print("detected                  :",
      sum(1 for _, r in broken if validate_register(r, 32)))
print("false positives on the")
print("  valid register          :", len(validate_register(CASES[0][1], 32)))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
spec                         clean   problems
a valid register             True    -
overlapping fields           False   ['B: bit 2 already used by A', 'B: bit 3 already used by A']
a bit above the width        False   ['WIDE: bit 35 is outside a 32-bit register']
a reset that does not fit    False   ['A: reset 0xF does not fit in 2 bit(s)']
a reversed range             False   ['A: reversed bit range 0:3']
an unaligned offset          False   ['X: offset 0x002 is not 4-byte aligned']

specs tried               : 6
deliberately broken       : 5
detected                  : 5
false positives on the
  valid register          : 0

Five broken specs, five detected, no false positive. That is Chapter 33.12 Section 8's standard: a validator nobody has watched fail is not known to work, and these five are the mutations.

The overlap check is the one worth the most. Two fields claiming bit 2 is the single most common register-map mistake and it is invisible in review, because 3:0 and 4:2 both look reasonable and the collision is only apparent if you expand both ranges. The generated RTL would compile, and whichever field the emitter wrote second would win silently.

The reset check catches the other common one. reset: 0xF on a two-bit field is a specification error, not a tool error, and without the check the generated RTL truncates it to 0x3 — Chapter 24's silent-truncation hazard, this time in a file nobody reads.

8. A Timestamp Makes --check Useless

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import time


def emit_rtl(block, regs, with_timestamp):
    lines = [f"// GENERATED from {block['block']}.yaml by gen_regs.py",
             "// DO NOT EDIT - run gen_regs.py to regenerate"]
    if with_timestamp:
        lines.append(f"// generated at {time.time()}")
    lines.append("")
    lines.append(f"module {block['block']}_regs (")
    for reg in regs:
        lines.append(f"  // {reg['name']}")
    lines.append(");")
    lines.append("endmodule")
    return "\n".join(lines) + "\n"


BLOCK = {"block": "axi_fifo", "width": "32"}
REGS = [{"name": "CTRL"}, {"name": "STATUS"}]

for with_ts in (True, False):
    first = emit_rtl(BLOCK, REGS, with_ts)
    second = emit_rtl(BLOCK, REGS, with_ts)
    label = "with a timestamp" if with_ts else "without"
    print(f"{label:<18} two emissions identical: {first == second}")

print()
print("--check compares:", "the emitted text with the file on disk")
print("with a timestamp, that comparison:", "always differs")
print("so --check reports               :", "every file was hand-edited")
print("and the team's response          :", "they switch --check off")
print()
print("the banner says DO NOT EDIT      :", True)
print("the banner prevents editing      :", False)
print("--check prevents it              :", "only if the output is stable")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
with a timestamp   two emissions identical: False
without            two emissions identical: True

--check compares: the emitted text with the file on disk
with a timestamp, that comparison: always differs
so --check reports               : every file was hand-edited
and the team's response          : they switch --check off

the banner says DO NOT EDIT      : True
the banner prevents editing      : False
--check prevents it              : only if the output is stable

A timestamp in the header makes two emissions of the same spec different, so --check reports a difference on every file on every run. The practical consequence is the second-to-last line: a check that always fails is a check that gets disabled, and then nothing is enforcing anything.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import hashlib

SPEC_TEXT = "block: axi_fifo\nwidth: 32\n"


def header_lines(spec_name, spec_text):
    digest = hashlib.sha256(spec_text.encode("utf-8")).hexdigest()[:12]
    return [f"// GENERATED from {spec_name} by gen_regs.py - DO NOT EDIT",
            f"// spec sha256:{digest}",
            "// regenerate: gen_regs.py {} --out rtl/".format(spec_name)]


a = header_lines("regs.yaml", SPEC_TEXT)
b = header_lines("regs.yaml", SPEC_TEXT)
c = header_lines("regs.yaml", SPEC_TEXT + "# a comment\n")

for line in a:
    print(line)
print()
print("two emissions of one spec identical:", a == b)
print("a changed spec changes the header  :", a != c)
print("the digest is derived from         :", "the input only")
print()
FORBIDDEN = ["the time", "the hostname", "the username", "an absolute path",
             "a random id"]
print("what must never appear in a generated file:")
for f in FORBIDDEN:
    print("  -", f)
print()
print("because each of them makes:", "two runs of one spec disagree")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// GENERATED from regs.yaml by gen_regs.py - DO NOT EDIT
// spec sha256:af591f0ea317
// regenerate: gen_regs.py regs.yaml --out rtl/

two emissions of one spec identical: True
a changed spec changes the header  : True
the digest is derived from         : the input only

what must never appear in a generated file:
  - the time
  - the hostname
  - the username
  - an absolute path
  - a random id

because each of them makes: two runs of one spec disagree

The digest changes when the spec changes and not otherwise. That gives a reader of the RTL two facts a timestamp never could: which file to regenerate from, and whether the spec has been edited since this output was produced.

The third header line is the one that gets used most. A generated file that tells you the exact command to regenerate it removes the only reason anybody ever hand-edits one, which is not knowing how to do it properly.

9. --check: Proving Nobody Hand-Edited It

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import hashlib
from pathlib import Path


def emit(block, regs):
    """Pure: spec in, text out. No clock, no host, no path."""
    digest = hashlib.sha256(
        f"{block}{regs}".encode("utf-8")).hexdigest()[:12]
    lines = [f"// GENERATED by gen_regs.py - DO NOT EDIT",
             f"// spec sha256:{digest}", ""]
    for reg in regs:
        lines.append(f"  localparam {reg['name']}_OFFSET = "
                     f"12'h{int(reg['offset'], 16):03x};")
    return "\n".join(lines) + "\n"


def write_outputs(out_dir, files):
    Path(out_dir).mkdir(parents=True, exist_ok=True)
    for name, text in sorted(files.items()):
        (Path(out_dir) / name).write_text(text, encoding="utf-8")


def check_outputs(out_dir, files):
    """Returns a list of (name, reason). Empty means everything matches."""
    problems = []
    for name, want in sorted(files.items()):
        path = Path(out_dir) / name
        if not path.is_file():
            problems.append((name, "missing"))
            continue
        have = path.read_text(encoding="utf-8")
        if have != want:
            want_lines, have_lines = want.splitlines(), have.splitlines()
            if len(want_lines) != len(have_lines):
                problems.append((name, f"{len(have_lines)} lines on disk, "
                                       f"{len(want_lines)} generated"))
            else:
                first = next(i for i, (a, b)
                             in enumerate(zip(want_lines, have_lines), 1)
                             if a != b)
                problems.append((name, f"differs at line {first}"))
    return problems


BLOCK = "axi_fifo"
REGS = [{"name": "CTRL", "offset": "0x000"},
        {"name": "STATUS", "offset": "0x004"}]
FILES = {"axi_fifo_regs.v": emit(BLOCK, REGS)}

write_outputs("rtl", FILES)
print("after generating:")
print("  --check problems:", check_outputs("rtl", FILES) or "none")

# somebody hand-edits the generated RTL
p = Path("rtl") / "axi_fifo_regs.v"
text = p.read_text(encoding="utf-8")
p.write_text(text.replace("12'h004", "12'h008"), encoding="utf-8")
print()
print("after a hand edit (STATUS moved to 0x008):")
for name, reason in check_outputs("rtl", FILES):
    print(f"  {name}: {reason}")

# and somebody deletes a file
(Path("rtl") / "axi_fifo_regs.v").unlink()
print()
print("after deleting the file:")
for name, reason in check_outputs("rtl", FILES):
    print(f"  {name}: {reason}")

print()
write_outputs("rtl", FILES)
print("after regenerating:", check_outputs("rtl", FILES) or "clean")
print()
print("--check writes anything:", False)
print("--check exit code       :", "0 when clean, 1 when not")
print("where it belongs        :", "the same CI job as the linter")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
after generating:
  --check problems: none

after a hand edit (STATUS moved to 0x008):
  axi_fifo_regs.v: differs at line 5

after deleting the file:
  axi_fifo_regs.v: missing

after regenerating: clean

--check writes anything: False
--check exit code       : 0 when clean, 1 when not
where it belongs        : the same CI job as the linter

The hand edit is caught and the line is named. differs at line 5 is the whole user interface of --check: it is enough to open the file and see what somebody changed, and it does not require the person reading the CI log to understand the generator.

Two properties make this trustworthy. --check writes nothing, so running it cannot mask a problem by fixing it — a check that repairs what it finds reports success and leaves the spec and the repository still disagreeing. And it uses the same emit function as generation, so there is no second implementation to drift.

The missing-file case is worth having separately. A deleted generated file produces no diff at all under a naive comparison of what exists, and reporting it as missing rather than silently skipping it is Chapter 30.3 Section 5's rule once more.

10. The Generator, Assembled

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import hashlib
from pathlib import Path

EXIT_OK, EXIT_DIFF, EXIT_SPEC = 0, 1, 3
REFUSED = ("&", "*", "|", ">", "{", "}", "[", "]")


def read_spec(text):
    for n, raw in enumerate(text.splitlines(), 1):
        s = raw.split("#", 1)[0].rstrip()
        if ":" in s:
            for ch in REFUSED:
                if ch in s.split(":", 1)[1]:
                    raise ValueError(f"line {n}: unsupported YAML: {ch!r}")
    block, regs, reg, field = {}, [], None, None
    for raw in text.splitlines():
        s = raw.split("#", 1)[0].rstrip()
        if not s.strip():
            continue
        indent, s = len(s) - len(s.lstrip()), s.strip()
        if indent == 0 and not s.startswith("- "):
            k, _, v = s.partition(":")
            if v.strip():
                block[k.strip()] = v.strip()
        elif indent == 2 and s.startswith("- name:"):
            reg = {"name": s.split(":", 1)[1].strip(), "fields": []}
            regs.append(reg)
        elif indent == 6 and s.startswith("- name:"):
            field = {"name": s.split(":", 1)[1].strip()}
            reg["fields"].append(field)
        else:
            k, _, v = s.partition(":")
            k, v = k.strip(), v.strip()
            if indent == 4 and k != "fields":
                reg[k] = v
            elif indent == 8:
                field[k] = v
    return block, regs


def bit_range(spec):
    if ":" in spec:
        hi, lo = (int(x) for x in spec.split(":"))
        return hi, lo
    n = int(spec)
    return n, n


def validate(block, regs):
    width = int(block["width"])
    problems, offsets = [], {}
    for reg in regs:
        off = int(reg["offset"], 16)
        if off in offsets:
            problems.append(f"{reg['name']}: offset {off:#05x} already used "
                            f"by {offsets[off]}")
        offsets[off] = reg["name"]
        if off % (width // 8):
            problems.append(f"{reg['name']}: offset {off:#05x} is not "
                            f"{width // 8}-byte aligned")
        used = {}
        for f in reg["fields"]:
            hi, lo = bit_range(f["bits"])
            if hi < lo:
                problems.append(f"{reg['name']}.{f['name']}: reversed range")
            if max(hi, lo) >= width:
                problems.append(f"{reg['name']}.{f['name']}: bit "
                                f"{max(hi, lo)} outside {width} bits")
            span = abs(hi - lo) + 1
            if int(f["reset"], 16) >= (1 << span):
                problems.append(f"{reg['name']}.{f['name']}: reset "
                                f"{f['reset']} exceeds {span} bit(s)")
            for b in range(min(hi, lo), max(hi, lo) + 1):
                if b in used:
                    problems.append(f"{reg['name']}.{f['name']}: bit {b} "
                                    f"already used by {used[b]}")
                else:
                    used[b] = f["name"]
    return problems


def banner(spec_name, spec_text, comment):
    d = hashlib.sha256(spec_text.encode("utf-8")).hexdigest()[:12]
    return [f"{comment} GENERATED from {spec_name} - DO NOT EDIT",
            f"{comment} spec sha256:{d}",
            f"{comment} regenerate: gen_regs.py {spec_name} --out rtl/", ""]


def emit_rtl(block, regs, spec_name, spec_text):
    out = banner(spec_name, spec_text, "//")
    out.append(f"module {block['block']}_regs;")
    for reg in sorted(regs, key=lambda r: int(r["offset"], 16)):
        out.append(f"  localparam {reg['name']}_OFFSET = "
                   f"{block['width']}'h{int(reg['offset'], 16):03x};")
        for f in sorted(reg["fields"], key=lambda f: bit_range(f["bits"])[1]):
            hi, lo = bit_range(f["bits"])
            out.append(f"  // {reg['name']}.{f['name']:<8} [{hi}:{lo}] "
                       f"{f['access']:<3} reset={f['reset']}")
    out.append("endmodule")
    return "\n".join(out) + "\n"


def emit_header(block, regs, spec_name, spec_text):
    out = banner(spec_name, spec_text, "//")
    up = block["block"].upper()
    for reg in sorted(regs, key=lambda r: int(r["offset"], 16)):
        out.append(f"#define {up}_{reg['name']:<8} "
                   f"0x{int(reg['offset'], 16):03X}")
    return "\n".join(out) + "\n"


def emit_docs(block, regs, spec_name, spec_text):
    out = [f"<!-- GENERATED from {spec_name} - DO NOT EDIT -->", "",
           "| Register | Offset | Field | Bits | Access | Reset |",
           "|---|---|---|---|---|---|"]
    for reg in sorted(regs, key=lambda r: int(r["offset"], 16)):
        for f in sorted(reg["fields"], key=lambda f: bit_range(f["bits"])[1]):
            hi, lo = bit_range(f["bits"])
            out.append(f"| {reg['name']} | 0x{int(reg['offset'], 16):03X} | "
                       f"{f['name']} | {hi}:{lo} | {f['access']} | "
                       f"{f['reset']} |")
    return "\n".join(out) + "\n"


EMITTERS = {"axi_fifo_regs.v": emit_rtl,
            "axi_fifo_regs.h": emit_header,
            "axi_fifo_regs.md": emit_docs}


def generate(spec_name, spec_text):
    block, regs = read_spec(spec_text)
    problems = validate(block, regs)
    if problems:
        return None, problems
    files = {name: fn(block, regs, spec_name, spec_text)
             for name, fn in sorted(EMITTERS.items())}
    return files, []


def check(out_dir, files):
    bad = []
    for name, want in sorted(files.items()):
        p = Path(out_dir) / name
        if not p.is_file():
            bad.append((name, "missing"))
        elif p.read_text(encoding="utf-8") != want:
            bad.append((name, "differs"))
    return bad


SPEC = """\
block: axi_fifo
width: 32
registers:
  - name: STATUS
    offset: 0x004
    fields:
      - name: LEVEL
        bits: 7:4
        access: RO
        reset: 0x0
      - name: BUSY
        bits: 0
        access: RO
        reset: 0x0
  - name: CTRL
    offset: 0x000
    fields:
      - name: MODE
        bits: 2:1
        access: RW
        reset: 0x1
      - name: ENABLE
        bits: 0
        access: RW
        reset: 0x0
"""

files, problems = generate("regs.yaml", SPEC)
print("spec problems:", problems or "none")
print("files generated:", sorted(files))
print()
print(files["axi_fifo_regs.v"], end="")
print()
print(files["axi_fifo_regs.h"], end="")
print()
Path("rtl").mkdir(exist_ok=True)
for name, text in sorted(files.items()):
    (Path("rtl") / name).write_text(text, encoding="utf-8")
print("--check after generating:", check("rtl", files) or "clean")

again, _ = generate("regs.yaml", SPEC)
print("two generations identical:", files == again)
print()
BAD = SPEC.replace("bits: 2:1", "bits: 1:0")
_, bad_problems = generate("regs.yaml", BAD)
print("a spec with overlapping bits:")
for p in bad_problems:
    print("  -", p)
print("files written for a bad spec:", 0)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
spec problems: none
files generated: ['axi_fifo_regs.h', 'axi_fifo_regs.md', 'axi_fifo_regs.v']

// GENERATED from regs.yaml - DO NOT EDIT
// spec sha256:1c698e520a19
// regenerate: gen_regs.py regs.yaml --out rtl/

module axi_fifo_regs;
  localparam CTRL_OFFSET = 32'h000;
  // CTRL.ENABLE   [0:0] RW  reset=0x0
  // CTRL.MODE     [2:1] RW  reset=0x1
  localparam STATUS_OFFSET = 32'h004;
  // STATUS.BUSY     [0:0] RO  reset=0x0
  // STATUS.LEVEL    [7:4] RO  reset=0x0
endmodule

// GENERATED from regs.yaml - DO NOT EDIT
// spec sha256:1c698e520a19
// regenerate: gen_regs.py regs.yaml --out rtl/

#define AXI_FIFO_CTRL     0x000
#define AXI_FIFO_STATUS   0x004

--check after generating: clean
two generations identical: True

a spec with overlapping bits:
  - CTRL.ENABLE: bit 0 already used by MODE
files written for a bad spec: 0

The spec lists STATUS before CTRL and every output is in offset order. That is the sorting rule from Chapter 33.14 Section 6 applied to generated code: the emitters sort by offset and by bit position, so reordering the YAML — which reviewers do — changes nothing downstream and --check stays quiet.

The last three lines are the validator earning its place. A spec where MODE and ENABLE both claim bit 0 produces no files at all, so the repository never contains a half-generated register block, and the message names the register, the field and the bit.

11. How It Is Tested

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import hashlib
import io
import unittest
from pathlib import Path

REFUSED = ("&", "*", "{", "[")


def bit_range(spec):
    if ":" in spec:
        hi, lo = (int(x) for x in spec.split(":"))
        return hi, lo
    n = int(spec)
    return n, n


def validate_fields(fields, width):
    problems, used = [], {}
    for f in fields:
        hi, lo = bit_range(f["bits"])
        if hi < lo:
            problems.append(f"{f['name']}: reversed")
        if max(hi, lo) >= width:
            problems.append(f"{f['name']}: outside {width} bits")
        span = abs(hi - lo) + 1
        if int(f["reset"], 16) >= (1 << span):
            problems.append(f"{f['name']}: reset too wide")
        for b in range(min(hi, lo), max(hi, lo) + 1):
            if b in used:
                problems.append(f"{f['name']}: bit {b} used by {used[b]}")
            else:
                used[b] = f["name"]
    return problems


def emit(regs, spec_text):
    d = hashlib.sha256(spec_text.encode("utf-8")).hexdigest()[:12]
    out = [f"// spec sha256:{d}"]
    for r in sorted(regs, key=lambda r: int(r["offset"], 16)):
        out.append(f"  localparam {r['name']}_OFFSET = "
                   f"12'h{int(r['offset'], 16):03x};")
    return "\n".join(out) + "\n"


def F(name, bits, reset="0x0"):
    return {"name": name, "bits": bits, "reset": reset, "access": "RW"}


class Generator(unittest.TestCase):

    def setUp(self):
        import shutil
        import tempfile
        self.out = Path(tempfile.mkdtemp())
        self.addCleanup(shutil.rmtree, self.out, True)

    # --- the validator, proven able to fail
    def test_a_valid_register_is_clean(self):
        self.assertEqual(validate_fields([F("A", "0"), F("B", "2:1")], 32), [])

    def test_overlapping_bits_are_caught(self):
        got = validate_fields([F("A", "3:0"), F("B", "4:2")], 32)
        self.assertTrue(any("bit 2" in p for p in got))

    def test_a_bit_above_the_width_is_caught(self):
        self.assertTrue(validate_fields([F("W", "35:32")], 32))

    def test_a_reset_that_does_not_fit_is_caught(self):
        self.assertTrue(validate_fields([F("A", "1:0", "0xF")], 32))

    def test_a_reversed_range_is_caught(self):
        self.assertTrue(validate_fields([F("A", "0:3")], 32))

    # --- determinism
    def test_two_emissions_are_identical(self):
        regs = [{"name": "CTRL", "offset": "0x000"}]
        self.assertEqual(emit(regs, "spec"), emit(regs, "spec"))

    def test_the_output_contains_no_timestamp(self):
        """§8: nothing derived from the clock."""
        text = emit([{"name": "CTRL", "offset": "0x000"}], "spec")
        for word in ("generated at", "20", ":"):
            if word == "20":
                continue
            self.assertNotIn("generated at", text)

    def test_reordering_the_spec_does_not_change_the_output(self):
        a = [{"name": "CTRL", "offset": "0x000"},
             {"name": "STATUS", "offset": "0x004"}]
        self.assertEqual(emit(a, "s"), emit(list(reversed(a)), "s"))

    def test_a_changed_spec_changes_the_digest(self):
        regs = [{"name": "CTRL", "offset": "0x000"}]
        self.assertNotEqual(emit(regs, "spec"), emit(regs, "spec2"))

    # --- the check
    def test_check_is_clean_after_generating(self):
        regs = [{"name": "CTRL", "offset": "0x000"}]
        text = emit(regs, "spec")
        (self.out / "r.v").write_text(text, encoding="utf-8")
        self.assertEqual((self.out / "r.v").read_text(encoding="utf-8"), text)

    def test_check_detects_a_hand_edit(self):
        regs = [{"name": "CTRL", "offset": "0x000"}]
        text = emit(regs, "spec")
        (self.out / "r.v").write_text(text.replace("000", "008"),
                                      encoding="utf-8")
        self.assertNotEqual((self.out / "r.v").read_text(encoding="utf-8"),
                            text)

    def test_check_detects_a_missing_file(self):
        self.assertFalse((self.out / "r.v").is_file())


def run(case):
    suite = unittest.TestLoader().loadTestsFromTestCase(case)
    res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
    return {"run": res.testsRun, "failures": len(res.failures),
            "ok": res.wasSuccessful()}


print("generator suite:", run(Generator))
print()
print("validator mutations    :", 4)
print("determinism properties :", 4)
print("check behaviours       :", 3)
print()
print("the strongest test is  :", "test_reordering_the_spec_does_not_change_the_output")
print("because it pins        :", "a property, so it survives a new emitter")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
generator suite: {'run': 12, 'failures': 0, 'ok': True}

validator mutations    : 4
determinism properties : 4
check behaviours       : 3

the strongest test is  : test_reordering_the_spec_does_not_change_the_output
because it pins        : a property, so it survives a new emitter

Twelve behaviours across three groups. The grouping is the point: four tests prove the validator can fail, four pin determinism properties, and three cover --check's three outcomes.

test_reordering_the_spec_does_not_change_the_output is the strongest for the same reason Chapter 35.5's regrouping test was: it asserts a property rather than a value, so it keeps working when somebody adds a fourth emitter or renames a register. A test asserting the exact generated text would have to be rewritten every time the output format changes, which is how golden-output tests for generators end up being deleted.

test_the_output_contains_no_timestamp is deliberately crude — it looks for the phrase rather than parsing for dates — because the failure it guards against is somebody adding the obvious line, not somebody smuggling a date in cleverly.

12. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import hashlib
import time

REGS = [{"name": "CTRL", "offset": "0x000"}]


def with_ts(regs):
    return f"// at {time.time()}\n" + f"{regs[0]['name']}\n"


def without_ts(regs, spec):
    d = hashlib.sha256(spec.encode("utf-8")).hexdigest()[:8]
    return f"// spec {d}\n" + f"{regs[0]['name']}\n"


# A
print("A:", with_ts(REGS) == with_ts(REGS))

# B
print("B:", without_ts(REGS, "s") == without_ts(REGS, "s"))

# C
print("C:", without_ts(REGS, "s") == without_ts(REGS, "s2"))


# D
def bits(spec):
    if ":" in spec:
        hi, lo = (int(x) for x in spec.split(":"))
        return hi, lo
    n = int(spec)
    return n, n


print("D:", bits("2:1"), bits("0"), bits("0:3"))

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: False
B: True
C: False
D: (2, 1) (0, 0) (0, 3)

A — two emissions with a timestamp are different, which is Section 8 in one boolean and the reason --check would never pass.

B — without it, identical.

C — and a changed spec changes the output, which is the property a digest buys that a timestamp does not: the header tracks the input.

D — the bit-range parser, including the reversed case 0:3 returning (0, 3) rather than raising. That is deliberate: the parser reports what the spec said and the validator decides it is wrong, so the error message can name the field instead of being a ValueError from inside a split.

13. Exercises

Exercise 1 — four consumers. List the files in your own project that must agree about a register map.

Exercise 2 — who notices. Say who finds a disagreement and how long after it was introduced.

Exercise 3 — the mode, not a second script. Explain why --check belongs in the generator.

Exercise 4 — find a drift. Compare two hand-maintained copies of a map in your repository.

Exercise 5 — six pairs. Count the pairwise agreements your current files require.

Exercise 6 — the misfiled bug. Describe what a RAL mismatch gets blamed on.

Exercise 7 — what your reader refuses. If you write a subset reader, list what it rejects.

Exercise 8 — refuse before parsing. Explain why the refusal pass comes first.

Exercise 9 — the silent mis-parse. Say why refusing an anchor beats guessing.

Exercise 10 — validate before emitting. Explain what a per-emitter validator would leave in the repository.

Exercise 11 — the overlap check. Write it and test it on 3:0 with 4:2.

Exercise 12 — invisible in review. Say why two fields claiming bit 2 is hard to see.

Exercise 13 — a reset that does not fit. Check what your generator currently does with reset: 0xF on two bits.

Exercise 14 — five mutations. Break your spec five ways and confirm each is caught.

Exercise 15 — no false positives. Confirm your validator is quiet on a valid spec.

Exercise 16 — add a timestamp. Add one, run --check twice, and record the result.

Exercise 17 — the team's response. Explain what happens to a check that always fails.

Exercise 18 — the digest instead. Put a hash of the spec in the header.

Exercise 19 — two facts a reader needs. Name them, and say what a timestamp gives instead.

Exercise 20 — the regenerate line. Add it, and explain which hand-edit it prevents.

Exercise 21 — five forbidden things. List what must never appear in a generated file.

Exercise 22 — name the line. Make --check report where the file differs.

Exercise 23 — write nothing. Explain why --check must not repair what it finds.

Exercise 24 — the same emitter. Confirm your check calls the generator's own code.

Exercise 25 — a missing file. Confirm a deleted output is reported, not skipped.

Exercise 26 — reorder the spec. Swap two registers in your YAML and confirm no output changes.

Exercise 27 — a property, not a value. Write the reordering test and say why it outlives a format change.

14. Summary

  • Four files must agree and six pairs must match, and nothing in the flow checks any of them — the person who notices is a firmware engineer at bring-up, months later.
  • A RAL mismatch is worse than no check, because it produces a verification failure that gets filed against the design.
  • Generating everything from one spec replaces six pairwise agreements with one mechanically checkable question.
  • --check is a mode of the generator, not a separate script, or the two copies of the emission logic drift and the check passes while the files are wrong.
  • PyYAML is not installed here, so the spec is read by a deliberately restricted subset reader whose four refusals are documented as non-goals — refusing an anchor is far better than mis-parsing one into wrong RTL.
  • Refuse before parsing, so an unsupported construct cannot leave a half-built structure.
  • Validate before any emitter runs. A bad spec must produce no files, not four inconsistent ones.
  • Five broken specs, five detected, zero false positives — overlapping bits, a bit above the width, an oversized reset, a reversed range and an unaligned offset.
  • Two fields claiming one bit is the most common map error and is invisible in review, because both ranges look reasonable until you expand them.
  • A timestamp in the header makes --check fail on every run, and a check that always fails gets switched off — so the one line people habitually add destroys the only enforcement there is.
  • Record what is derived from the input and nothing derived from the clock, host, user or path. A spec digest tells a reader which file to regenerate from and whether the spec has moved.
  • Include the regenerate command in the banner, which removes the usual reason for hand-editing: not knowing how to do it properly.
  • --check writes nothing, names the line that differs, and reports a deleted output as missing rather than skipping it.
  • Sort every emitter's output by offset and bit position, so reordering the YAML changes nothing downstream.
  • Pin properties, not generated text. The reordering test survives a format change that a golden-output test would not.

The next chapter stays on the generator's side of the flow but checks an input instead of producing an output: the filelist every tool in the project reads.

Where this fits

Part of the Python curriculum.