Python Part II · Module 31 · Generating RTL and Verification Artifacts
Interface and IP Wrapper Generation
Wrapping an IP is the task this module exists for: three hundred ports, each connected to a port of the same name, written by hand the evening before a handoff. It is one shape repeated, so Chapter 31.1 approves, and the generator is four lines. The chapter is worth reading for the asymmetry between its two failure modes — one of them is a syntax error you fix in a minute, and the other compiles, simulates, passes the tests you have, and quietly disconnects a feature.
Advanced20 min readPythonPart IIAutomationCode GenerationIntegration
Part II · Module 31 · Chapter 31.5 · Interface and IP Wrapper Generation
1. Three Hundred Ports, One Shape
import re
PORTS = ["clk", "rst_n", "din", "din_valid", "dout", "dout_valid"]
lines = [f" .{p}({p})," for p in PORTS]
for l in lines:
print(l)
shape = lambda s: re.sub(r"\w+", "x", s.strip())
print()
print("lines :", len(lines))
print("shapes:", len({shape(l) for l in lines}))
print("at 300 ports the review discount would be:", f"{1/300:.4f}") .clk(clk),
.rst_n(rst_n),
.din(din),
.din_valid(din_valid),
.dout(dout),
.dout_valid(dout_valid),
lines : 6
shapes: 1
at 300 ports the review discount would be: 0.0033One shape, and the best discount in the module. Note the normaliser here replaces identifiers rather than digits, because a port list's repetition is in its names — a reminder that Chapter 31.1's metric needs a normaliser matched to what varies.
There is also a defect in that output, on the last line, and it is the subject of Section 2.
2. The Trailing Comma, and the Missing Port
import subprocess
from pathlib import Path
Path("ip.v").write_text("""module ip_core(
input clk,
input rst_n,
input [7:0] din,
input din_valid,
output [7:0] dout,
output dout_valid
);
reg [7:0] r; reg v;
always @(posedge clk) begin
if (!rst_n) begin r <= 8'h00; v <= 1'b0; end
else if (din_valid) begin r <= din; v <= 1'b1; end
else v <= 1'b0;
end
assign dout = r;
assign dout_valid = v;
endmodule
""", encoding="utf-8")
PORTS = ["clk", "rst_n", "din", "din_valid", "dout", "dout_valid"]
naive = "\n".join(f" .{p}({p})," for p in PORTS)
Path("wrap_tc.v").write_text(
"module wrap_tc(input clk, input rst_n, input [7:0] din,\n"
" input din_valid, output [7:0] dout, output dout_valid);\n"
" ip_core u (\n" + naive + "\n );\nendmodule\n", encoding="utf-8")
print("last emitted connection:", naive.splitlines()[-1].strip())
c = subprocess.run(["iverilog", "-g2005", "-o", "t.vvp", "ip.v", "wrap_tc.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
print("returncode:", c.returncode)
print("message :", c.stdout.strip().splitlines()[0])last emitted connection: .dout_valid(dout_valid),
returncode: 2
message : wrap_tc.v:10: syntax errorReturncode 2 on the first attempt. The annotation appends a comma to every connection including the last, and Verilog does not allow a trailing comma in an instance port list.
This is the defect everybody meets, and it is the good one: it stops the build, the message points at the right line, and the fix is one call.
PORTS = ["clk", "rst_n", "din", "din_valid", "dout", "dout_valid"]
wrong = "\n".join(f" .{p}({p})," for p in PORTS)
right = ",\n".join(f" .{p}({p})" for p in PORTS)
print("the fix:", "',\\n'.join(...) instead of appending a comma")
print()
print("last line, wrong:", wrong.splitlines()[-1].strip())
print("last line, right:", right.splitlines()[-1].strip())
print()
print("commas, wrong:", wrong.count(","), "for", len(PORTS), "ports")
print("commas, right:", right.count(","), "for", len(PORTS), "ports")the fix: ',\n'.join(...) instead of appending a comma
last line, wrong: .dout_valid(dout_valid),
last line, right: .dout_valid(dout_valid)
commas, wrong: 6 for 6 ports
commas, right: 5 for 6 portsSix commas for six ports is one too many, and n - 1 separators is what join is for. Now the other failure mode, which does not announce itself at all:
import subprocess
from pathlib import Path
Path("ip.v").write_text("""module ip_core(
input clk,
input rst_n,
input [7:0] din,
input din_valid,
output [7:0] dout,
output dout_valid
);
reg [7:0] r; reg v;
always @(posedge clk) begin
if (!rst_n) begin r <= 8'h00; v <= 1'b0; end
else if (din_valid) begin r <= din; v <= 1'b1; end
else v <= 1'b0;
end
assign dout = r;
assign dout_valid = v;
endmodule
""", encoding="utf-8")
ALL_PORTS = ["clk", "rst_n", "din", "din_valid", "dout", "dout_valid"]
def wrapper(name, ports):
conns = ",\n".join(f" .{p}({p})" for p in ports)
return (f"module {name}(input clk, input rst_n, input [7:0] din,\n"
" input din_valid, output [7:0] dout, output dout_valid);\n"
f" ip_core u (\n{conns}\n );\nendmodule\n")
Path("w_full.v").write_text(wrapper("wrap_full", ALL_PORTS), encoding="utf-8")
Path("w_miss.v").write_text(
wrapper("wrap_miss", [p for p in ALL_PORTS if p != "rst_n"]), encoding="utf-8")
print("full wrapper connections:", len(ALL_PORTS))
print("miss wrapper connections:", len(ALL_PORTS) - 1)
print("port omitted :", "rst_n")
print()
c = subprocess.run(["iverilog", "-g2005", "-o", "w.vvp",
"ip.v", "w_full.v", "w_miss.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
print("compile returncode:", c.returncode)
print("warnings about the missing port:", len(c.stdout.strip()) and c.stdout.strip() or "none")full wrapper connections: 6
miss wrapper connections: 5
port omitted : rst_n
compile returncode: 0
warnings about the missing port: noneReturncode zero and no diagnostic of any kind. An unconnected port is legal Verilog — it is how you deliberately leave an output unused — so the compiler cannot distinguish "intentionally unconnected" from "the generator dropped it".
3. The Omitted Port Passes a Passing Test
Here is the part that makes this the dangerous defect:
import subprocess
from pathlib import Path
Path("ip.v").write_text("""module ip_core(
input clk,
input rst_n,
input [7:0] din,
input din_valid,
output [7:0] dout,
output dout_valid
);
reg [7:0] r; reg v;
always @(posedge clk) begin
if (!rst_n) begin r <= 8'h00; v <= 1'b0; end
else if (din_valid) begin r <= din; v <= 1'b1; end
else v <= 1'b0;
end
assign dout = r;
assign dout_valid = v;
endmodule
""", encoding="utf-8")
def wrapper(name, ports):
conns = ",\n".join(f" .{p}({p})" for p in ports)
return (f"module {name}(input clk, input rst_n, input [7:0] din,\n"
" input din_valid, output [7:0] dout, output dout_valid);\n"
f" ip_core u (\n{conns}\n );\nendmodule\n")
ALL_PORTS = ["clk", "rst_n", "din", "din_valid", "dout", "dout_valid"]
Path("w_full.v").write_text(wrapper("wrap_full", ALL_PORTS), encoding="utf-8")
Path("w_miss.v").write_text(
wrapper("wrap_miss", [x for x in ALL_PORTS if x != "rst_n"]),
encoding="utf-8")
Path("tb.v").write_text("""module tb;
reg clk=0, rst_n=0, dv=0; reg [7:0] din;
wire [7:0] df, dm; wire vf, vm;
wrap_full f(.clk(clk), .rst_n(rst_n), .din(din), .din_valid(dv),
.dout(df), .dout_valid(vf));
wrap_miss m(.clk(clk), .rst_n(rst_n), .din(din), .din_valid(dv),
.dout(dm), .dout_valid(vm));
always #1 clk = ~clk;
initial begin
rst_n = 1; din = 8'h5A; dv = 1;
@(posedge clk); #0 dv = 0; @(negedge clk);
$display("after a write full=%h miss=%h agree=%b", df, dm, (df === dm));
rst_n = 0;
@(posedge clk); @(negedge clk);
$display("after RESET full=%h miss=%h agree=%b", df, dm, (df === dm));
$display("reset cleared the register: full=%b miss=%b",
(df === 8'h00), (dm === 8'h00));
$finish;
end
endmodule
""", encoding="utf-8")
c = subprocess.run(["iverilog", "-g2005", "-o", "s.vvp",
"ip.v", "w_full.v", "w_miss.v", "tb.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
print("compile returncode:", c.returncode)
r = subprocess.run(["vvp", "s.vvp"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
for l in r.stdout.splitlines():
if not l.startswith("tb.v"):
print(" ", l)compile returncode: 0
after a write full=5a miss=5a agree=1
after RESET full=00 miss=5a agree=0
reset cleared the register: full=1 miss=0Read the three lines in order, because they are the whole argument.
After a write the two wrappers agree exactly. A data-path test — write a byte, read it back — passes on both, and nothing in the result hints that one of them has a disconnected reset.
After reset they diverge. The correct wrapper clears to 00; the one missing rst_n still holds 5a, because an unconnected input floats to z, !z evaluates to x, and if (x) takes the else branch — so the reset condition is never true and the register is never cleared.
So the defect is invisible to every test that does not exercise the dropped port, which is exactly the set of tests somebody writes while integrating an IP for the first time. The port that gets dropped is typically the one that looks least interesting, and reset, scan enable and a clock-gate override are all in that category.
4. Parse the Port List, Then Assert the Count
For a three-hundred-port IP the only honest source for the list is the IP's own source file:
import re
from dataclasses import dataclass
from pathlib import Path
from typing import List
PORT = re.compile(
r"\b(input|output|inout)\s*(?:wire|reg|logic)?\s*(\[[^\]]+\])?\s*(\w+)")
@dataclass(frozen=True)
class Port:
direction: str
width: str
name: str
def parse_ports(path, module) -> List[Port]:
src = Path(path).read_text(encoding="utf-8")
start = src.index(f"module {module}(")
head = src[start:src.index(");", start)]
return [Port(m.group(1), m.group(2) or "", m.group(3))
for m in PORT.finditer(head)]
Path("ip.v").write_text("""module ip_core(
input clk,
input rst_n,
input [7:0] din,
input din_valid,
output [7:0] dout,
output dout_valid
);
reg [7:0] r; reg v;
always @(posedge clk) begin
if (!rst_n) begin r <= 8'h00; v <= 1'b0; end
else if (din_valid) begin r <= din; v <= 1'b1; end
else v <= 1'b0;
end
assign dout = r;
assign dout_valid = v;
endmodule
""", encoding="utf-8")
ports = parse_ports("ip.v", "ip_core")
print(f"{'dir':<7} {'width':<8} name")
for p in ports:
print(f"{p.direction:<7} {p.width:<8} {p.name}")
print()
print("ports parsed:", len(ports))
print("inputs :", sum(1 for p in ports if p.direction == "input"))
print("outputs :", sum(1 for p in ports if p.direction == "output"))
print("inouts :", sum(1 for p in ports if p.direction == "inout"))dir width name
input clk
input rst_n
input [7:0] din
input din_valid
output [7:0] dout
output dout_valid
ports parsed: 6
inputs : 4
outputs : 2
inouts : 0Six ports with their directions and widths. Three things about that regex are deliberate.
inout is in the alternation. Omitting it is how a bidirectional pin silently becomes an input in a generated wrapper, and the resulting wrapper compiles.
wire|reg|logic is optional, because all four spellings appear in real port declarations and a pattern that requires one of them drops the plain input clk form — which would be a port missing from the parse, producing Section 3's defect from the parser rather than from the generator.
The width group is captured rather than skipped, because Section 5 needs it.
import re
from dataclasses import dataclass
from pathlib import Path
PORT = re.compile(
r"\b(input|output|inout)\s*(?:wire|reg|logic)?\s*(\[[^\]]+\])?\s*(\w+)")
@dataclass(frozen=True)
class Port:
direction: str
width: str
name: str
Path("ip.v").write_text("""module ip_core(
input clk,
input rst_n,
input [7:0] din,
input din_valid,
output [7:0] dout,
output dout_valid
);
reg [7:0] r; reg v;
always @(posedge clk) begin
if (!rst_n) begin r <= 8'h00; v <= 1'b0; end
else if (din_valid) begin r <= din; v <= 1'b1; end
else v <= 1'b0;
end
assign dout = r;
assign dout_valid = v;
endmodule
""", encoding="utf-8")
src = Path("ip.v").read_text(encoding="utf-8")
head = src[src.index("module ip_core("):src.index(");")]
ports = [Port(m.group(1), m.group(2) or "", m.group(3))
for m in PORT.finditer(head)]
def connections(ports):
return ",\n".join(f" .{p.name}({p.name})" for p in ports)
def check_complete(ports, text):
connected = re.findall(r"\.(\w+)\s*\(", text)
missing = [p.name for p in ports if p.name not in connected]
extra = [c for c in connected if c not in {p.name for p in ports}]
return {"parsed": len(ports), "connected": len(connected),
"missing": missing, "unexpected": extra,
"complete": not missing and not extra}
good = connections(ports)
short = connections([p for p in ports if p.name != "rst_n"])
for label, text in (("all ports", good), ("rst_n dropped", short)):
r = check_complete(ports, text)
print(f"{label:<16} parsed={r['parsed']} connected={r['connected']} "
f"missing={r['missing']} complete={r['complete']}")all ports parsed=6 connected=6 missing=[] complete=True
rst_n dropped parsed=6 connected=5 missing=['rst_n'] complete=FalseSix parsed against five connected, and the missing name reported. This is Chapter 30.1 Section 3's accounting step applied to a port list: iterate the expected set, and report what is absent by name rather than trusting that the loop ran.
5. A Wrapper Is Two Generated Lists
Even with the connections correct, the annotation produces half a wrapper. The wrapper's own ports have to be declared, and that is where direction and width matter:
import re
from dataclasses import dataclass
from pathlib import Path
from typing import List
PORT = re.compile(
r"\b(input|output|inout)\s*(?:wire|reg|logic)?\s*(\[[^\]]+\])?\s*(\w+)")
@dataclass(frozen=True)
class Port:
direction: str
width: str
name: str
Path("ip.v").write_text("""module ip_core(
input clk,
input rst_n,
input [7:0] din,
input din_valid,
output [7:0] dout,
output dout_valid
);
reg [7:0] r; reg v;
always @(posedge clk) begin
if (!rst_n) begin r <= 8'h00; v <= 1'b0; end
else if (din_valid) begin r <= din; v <= 1'b1; end
else v <= 1'b0;
end
assign dout = r;
assign dout_valid = v;
endmodule
""", encoding="utf-8")
src = Path("ip.v").read_text(encoding="utf-8")
head = src[src.index("module ip_core("):src.index(");")]
ports = [Port(m.group(1), m.group(2) or "", m.group(3))
for m in PORT.finditer(head)]
def declarations(ports):
return ",\n".join(
f" {p.direction:<6} {p.width:<7} {p.name}".rstrip() for p in ports)
def connections(ports):
return ",\n".join(f" .{p.name}({p.name})" for p in ports)
print("list 1 — the wrapper's own port declarations:")
print(declarations(ports))
print()
print("list 2 — the instance connections:")
print(connections(ports))
print()
print("lists generated from one parse:", 2)
print("widths carried through :",
[p.width for p in ports if p.width])list 1 — the wrapper's own port declarations:
input clk,
input rst_n,
input [7:0] din,
input din_valid,
output [7:0] dout,
output dout_valid
list 2 — the instance connections:
.clk(clk),
.rst_n(rst_n),
.din(din),
.din_valid(din_valid),
.dout(dout),
.dout_valid(dout_valid)
lists generated from one parse: 2
widths carried through : ['[7:0]', '[7:0]']Two lists from one parse, which is the arrangement that makes them impossible to disagree. A wrapper whose declarations and connections came from separate hand-maintained lists is how a [7:0] becomes a [6:0] and a bus loses its top bit — and that defect compiles too.
Note the directions are preserved verbatim rather than re-derived. A wrapper's ports face outward and the instance's ports face inward, but because the wrapper is a pass-through, the direction of each wrapper port is the same as the corresponding IP port — which is true for a wrapper and would be false for a bridge. It is worth saying that out loud in the generator's comment, because it is the kind of assumption that is invisible until somebody reuses the generator for something that is not a pass-through.
6. The Generator, Assembled
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import List
PORT = re.compile(
r"\b(input|output|inout)\s*(?:wire|reg|logic)?\s*(\[[^\]]+\])?\s*(\w+)")
HDR = "// GENERATED by gen_wrapper.py from ip.v — do not edit"
@dataclass(frozen=True)
class Port:
direction: str
width: str
name: str
def parse_ports(path, module) -> List[Port]:
src = Path(path).read_text(encoding="utf-8")
start = src.index(f"module {module}(")
head = src[start:src.index(");", start)]
out = [Port(m.group(1), m.group(2) or "", m.group(3))
for m in PORT.finditer(head)]
if not out:
raise ValueError(f"no ports parsed from {module} in {path}")
return out
def emit_wrapper(ports, ip_module, wrapper_module):
"""A pass-through wrapper: each wrapper port has the IP port's direction."""
decls = ",\n".join(
f" {p.direction:<6} {p.width:<7} {p.name}".rstrip() for p in ports)
conns = ",\n".join(f" .{p.name}({p.name})" for p in ports)
return (f"{HDR}\nmodule {wrapper_module} (\n{decls}\n);\n"
f" {ip_module} u_{ip_module} (\n{conns}\n );\nendmodule\n")
def check_complete(ports, text):
connected = re.findall(r"\.(\w+)\s*\(", text)
names = {p.name for p in ports}
return {"parsed": len(ports),
"connected": len(connected),
"missing": sorted(names - set(connected)),
"unexpected": sorted(set(connected) - names)}
Path("ip.v").write_text("""module ip_core(
input clk,
input rst_n,
input [7:0] din,
input din_valid,
output [7:0] dout,
output dout_valid
);
reg [7:0] r; reg v;
always @(posedge clk) begin
if (!rst_n) begin r <= 8'h00; v <= 1'b0; end
else if (din_valid) begin r <= din; v <= 1'b1; end
else v <= 1'b0;
end
assign dout = r;
assign dout_valid = v;
endmodule
""", encoding="utf-8")
ports = parse_ports("ip.v", "ip_core")
rtl = emit_wrapper(ports, "ip_core", "ip_wrap")
Path("ip_wrap.v").write_text(rtl, encoding="utf-8")
chk = check_complete(ports, rtl)
print("parsed :", chk["parsed"])
print("connected :", chk["connected"])
print("missing :", chk["missing"] or "none")
print("unexpected:", chk["unexpected"] or "none")
print("complete :", not chk["missing"] and not chk["unexpected"])
print()
print("trailing comma in the connection list:",
",\n );" in rtl)
print("header present:", rtl.splitlines()[0].startswith("// GENERATED"))
print()
c = subprocess.run(["iverilog", "-g2005", "-o", "wrap.vvp", "ip.v", "ip_wrap.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
print("compile returncode:", c.returncode)
Path("tbw.v").write_text("""module tb;
reg clk=0, rst_n=1, dv=0; reg [7:0] din;
wire [7:0] dout; wire dv_out;
ip_wrap u(.clk(clk), .rst_n(rst_n), .din(din), .din_valid(dv),
.dout(dout), .dout_valid(dv_out));
always #1 clk = ~clk;
initial begin
din = 8'hA5; dv = 1; @(posedge clk); #0 dv = 0; @(negedge clk);
$display("wrote A5 -> dout=%h valid_seen=%b", dout, 1'b1);
rst_n = 0; @(posedge clk); @(negedge clk);
$display("after reset -> dout=%h cleared=%b", dout, (dout === 8'h00));
$finish;
end
endmodule
""", encoding="utf-8")
c2 = subprocess.run(["iverilog", "-g2005", "-o", "w2.vvp",
"ip.v", "ip_wrap.v", "tbw.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
r = subprocess.run(["vvp", "w2.vvp"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
for l in r.stdout.splitlines():
if not l.startswith("tbw.v"):
print(" ", l)parsed : 6
connected : 6
missing : none
unexpected: none
complete : True
trailing comma in the connection list: False
header present: True
compile returncode: 0
wrote A5 -> dout=a5 valid_seen=1
after reset -> dout=00 cleared=1Six ports parsed, six connected, no trailing comma, a do-not-edit header, a clean compile, and a simulation proving that both the data path and the reset work through the wrapper.
That last line is the check Section 3 showed is necessary: a wrapper that passes a write test can still have a dead reset, so the smoke test for a generated wrapper has to exercise every port it claims to connect — which, for three hundred ports, is itself a generated testbench. Chapter 31.6 generates tests from a spec and the same technique applies here: the port list that produced the wrapper can produce the stimulus that checks it.
import re
CONNECTED = ["clk", "rst_n", "din", "din_valid", "dout", "dout_valid"]
wrap_text = " ip_core u (\n" + ",\n".join(
f" .{p}({p})" for p in CONNECTED) + "\n );"
ports = re.findall(r"\.(\w+)\s*\(", wrap_text)
INPUTS = ["clk", "rst_n", "din", "din_valid"]
exercised = ["din", "din_valid", "clk", "rst_n"]
print(f"{'port':<12} connected exercised by the smoke test")
for p in ports:
print(f"{p:<12} {'yes':<10} {'yes' if p in exercised else 'no'}")
print()
print("connected:", len(ports))
print("exercised:", sum(1 for p in ports if p in exercised))
print("outputs are observed rather than driven:",
[p for p in ports if p not in INPUTS])port connected exercised by the smoke test
clk yes yes
rst_n yes yes
din yes yes
din_valid yes yes
dout yes no
dout_valid yes no
connected: 6
exercised: 4
outputs are observed rather than driven: ['dout', 'dout_valid']Four inputs driven and two outputs observed, which accounts for all six. The distinction matters because "exercise every port" means drive the inputs and read the outputs, and a coverage-style check over a generated wrapper has to count the two differently — the same input/output asymmetry Chapter 29.5 met when deciding which bins could ever be hit.
7. A Connection to a Port That Does Not Exist
Section 4's completeness check reported two things and only one has been demonstrated. The other direction — a connection naming a port the IP does not have — is the failure a stale generator produces:
import subprocess
from pathlib import Path
Path("ip.v").write_text("""module ip_core(
input clk,
input [7:0] din,
output [7:0] dout
);
assign dout = din;
endmodule
""", encoding="utf-8")
# the generator ran against last month's ip.v, which still had din_valid
STALE = ["clk", "din", "din_valid", "dout"]
conns = ",\n".join(f" .{p}({p})" for p in STALE)
Path("wrap.v").write_text(
"module wrap(input clk, input [7:0] din, input din_valid,\n"
" output [7:0] dout);\n"
f" ip_core u (\n{conns}\n );\nendmodule\n", encoding="utf-8")
c = subprocess.run(["iverilog", "-g2005", "-o", "w.vvp", "ip.v", "wrap.v"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
print("ports the IP has :", ["clk", "din", "dout"])
print("ports the wrapper connects:", STALE)
print("returncode:", c.returncode)
for l in c.stdout.strip().splitlines()[:2]:
print(" ", l[:66])ports the IP has : ['clk', 'din', 'dout']
ports the wrapper connects: ['clk', 'din', 'din_valid', 'dout']
returncode: 1
wrap.v:3: error: port ``din_valid'' is not a port of u.
1 error(s) during elaboration.This direction is caught: returncode 1, and an error that names din_valid specifically — along with an error count, which is the error-counting behaviour Chapter 31.4 Section 6 measured.
Note what is not in that output. There is no warning about the port the IP has and the wrapper no longer drives, and no warning about anything floating. Taken with Section 2's clean compile, that settles the compiler's behaviour across both directions: Icarus diagnoses a connection to a port that does not exist and says nothing whatsoever about a port left unconnected. The asymmetry is not an oversight in the tool — an unconnected port is legal and sometimes intended, while a connection to a nonexistent port cannot be anything but a mistake.
So the two directions of the completeness check have completely different urgencies:
DIRECTIONS = [
("a port the IP has, missing from the wrapper",
"rc=0, no diagnostic", "ships — §2, §3"),
("a port the wrapper names, absent from the IP",
"rc=2, named error", "stops the build"),
]
print(f"{'direction':<46} {'compiler':<22} outcome")
for d, comp, out in DIRECTIONS:
print(f"{d:<46} {comp:<22} {out}")
print()
print("directions the generator must check itself:", 1)
print("directions the compiler already covers :", 1)
print("so `missing` is the half that earns its keep.")direction compiler outcome
a port the IP has, missing from the wrapper rc=0, no diagnostic ships — §2, §3
a port the wrapper names, absent from the IP rc=2, named error stops the build
directions the generator must check itself: 1
directions the compiler already covers : 1
so `missing` is the half that earns its keep.The missing half of the check earns its keep and the unexpected half is belt-and-braces, and keeping both is still right: the generator's message names the spec and the parse, the compiler's names only the line, and a generator that reports unexpected=['din_valid'] tells somebody immediately that the IP changed under them.
That is the real scenario behind this section. Nobody writes a connection to an imaginary port; a port disappears from an IP between drops, and the wrapper is regenerated — or worse, is not, because it is a committed file that nobody re-ran. Chapter 31.7's regenerate-and-diff gate is what catches that second case, and it is why a generated wrapper belongs under the gate rather than in somebody's memory of having run the script.
8. Predict, Run, Observe
PORTS = ["a", "b", "c"]
# A
print("A:", "|".join(f".{p}({p})," for p in PORTS))
# B
print("B:", "|".join(",".join(f".{p}({p})" for p in PORTS).split(",")))
# C
print("C:", "".join(f".{p}({p})," for p in PORTS).count(","),
",".join(f".{p}({p})" for p in PORTS).count(","))
# D
import re
print("D:", re.findall(r"\.(\w+)\s*\(", ".a(a), .b(b)"))The verified output:
A: .a(a),|.b(b),|.c(c),
B: .a(a)|.b(b)|.c(c)
C: 3 2
D: ['a', 'b']A — every connection gets a comma, including the last. A syntax error.
B — join puts a separator between items, which is n - 1 of them.
C — three commas against two, for three ports.
D — the completeness check's extractor, which reads connection names back out of the emitted text rather than trusting the loop.
9. Exercises
Exercise 1 — your largest IP. Count the ports on the biggest IP in your project and compute the review discount.
Exercise 2 — the right normaliser. Explain why the shape metric here replaces identifiers rather than digits.
Exercise 3 — emit the trailing comma. Generate a wrapper with a comma after every connection and compile it. Report the returncode.
Exercise 4 — count the separators. For your port count, state how many commas the correct list has.
Exercise 5 — drop a port. Omit one connection, compile, and report whether anything was said.
Exercise 6 — why it is legal. Explain why a compiler cannot flag an unconnected port.
Exercise 7 — the test that passes. Write a data-path test that passes on both the correct and the short wrapper.
Exercise 8 — the test that fails. Extend it until the short wrapper is caught, and say which port you had to exercise.
Exercise 9 — float to x. Trace !z through to if (x) and say why reset never fires.
Exercise 10 — the least interesting port. For your IP, name the port most likely to be dropped and say what feature it would disable.
Exercise 11 — rank the two failures. Say how long each of this chapter's two defects survives in your flow.
Exercise 12 — parse your IP. Run the port regex over a real IP source and report the counts by direction.
Exercise 13 — the inout case. Remove inout from the alternation and say what happens to a bidirectional pin.
Exercise 14 — the optional type word. Remove wire|reg|logic from the pattern and find a declaration form that stops parsing.
Exercise 15 — a port missing from the parse. Explain why a parser bug produces the same defect as a generator bug.
Exercise 16 — assert the count. Add the parsed-versus-connected check to your generator and run it.
Exercise 17 — report by name. Say why reporting missing=['rst_n'] beats reporting connected=5.
Exercise 18 — two lists, one parse. Generate both the declarations and the connections, and say what that makes impossible.
Exercise 19 — a lost bit. Describe how a hand-maintained declaration list turns [7:0] into [6:0], and whether it compiles.
Exercise 20 — pass-through, not bridge. State the direction assumption your generator makes and where it would be wrong.
Exercise 21 — generate the smoke test. Use the parsed port list to emit a testbench that drives every input.
Exercise 22 — drive versus observe. Split your port list into driven and observed, and confirm the two counts add up.
Exercise 23 — three hundred ports. Explain why care does not scale and a length comparison does.
Exercise 24 — the handoff. Write the two sentences you would put in a handoff note about how the wrapper was produced and checked.
10. Summary
- A port list is one shape repeated and gives the best review discount in the module — 0.0033 at three hundred ports.
- The shape metric needs a normaliser matched to what varies: identifiers here, digits in Chapter 31.4.
- The annotation emits a trailing comma and the build stops with returncode 2 — the good failure, fixed by
",\n".join(...), which putsn - 1separators betweennitems. - An omitted port compiles with returncode zero and no diagnostic, because an unconnected port is legal Verilog and the compiler cannot read intent.
- The short wrapper agreed exactly with the correct one after a write and diverged only after reset, where it held
5ainstead of clearing. - An unconnected input floats to
z,!zisx, andif (x)takes the else branch — so the reset condition is never true and the register is never cleared. - The defect is invisible to every test that does not exercise the dropped port, which is the set of tests written while first integrating an IP.
- Care does not scale to three hundred ports; a length comparison does. Generate the connections from the parsed list and assert parsed equals connected.
- Parse the port list from the IP's own source, with
inoutin the alternation and the type word optional — a parser that drops a port produces the same defect as a generator that does. - Report what is missing by name, which is Chapter 30.1 Section 3's accounting applied to ports.
- A wrapper is two generated lists — its own declarations and the instance connections — and generating both from one parse makes them unable to disagree.
- Direction is preserved verbatim because a wrapper is a pass-through, which is an assumption worth stating in the generator rather than leaving to be discovered.
- The smoke test must exercise every port the wrapper claims to connect, driving the inputs and observing the outputs, and for a large IP that testbench is generated from the same port list.
- The compiler diagnoses a connection to a port that does not exist — returncode 1, naming the port — and says nothing whatsoever about a port left unconnected.
- That asymmetry is deliberate in the tool: an unconnected port is legal and sometimes intended, while a connection to a nonexistent port can only be a mistake.
- So the
missinghalf of the completeness check earns its keep and theunexpectedhalf is belt-and-braces worth keeping, because it names the spec rather than just the line. - The real scenario is an IP whose port disappeared between drops, which is why a generated wrapper belongs under Chapter 31.7's gate rather than in somebody's memory of having run the script.
The next chapter generates the artefact engineers are most tempted to edit afterwards, which is why it is followed by the chapter on keeping generated files honest.
Where this fits
Part of the Python curriculum.
