Skip to content
VLSI Mentor

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

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

One 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
last emitted connection: .dout_valid(dout_valid),
returncode: 2
message   : wrap_tc.v:10: syntax error

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

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
full wrapper connections: 6
miss wrapper connections: 5
port omitted            : rst_n

compile returncode: 0
warnings about the missing port: none

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

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

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

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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']}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
all ports        parsed=6 connected=6 missing=[] complete=True
rst_n dropped    parsed=6 connected=5 missing=['rst_n'] complete=False

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

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

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

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

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

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

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 puts n - 1 separators between n items.
  • 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 5a instead of clearing.
  • An unconnected input floats to z, !z is x, and if (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 inout in 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 missing half of the completeness check earns its keep and the unexpected half 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.