Perl Part II · Module 29 · Practical VLSI Automation Projects
Project: Netlist Analyzer
Module 20 found that reading a netlist with patterns works well on a flat synthesised netlist and fails silently on a handful of legal constructs — escaped names, macros, generate blocks, two instances on one line. This project builds the analyzer that a team can actually rely on: it writes a hierarchy file and a cell-count file for the netlists it can read, refuses every other netlist with the line that stopped it, and prints its own boundary in its usage text from the same table that enforces it. Two independent witnesses from the simulator check every row it writes.
Advanced24 min readPerlPart IIProjectsNetlists
Part II · Module 29 · Chapter 29.2 · Project: Netlist Analyzer
1. The Brief
| Command | analyze_netlist.pl NETLIST --hier FILE --cells FILE [--top NAME] [--lib FILE] |
| Reads | one gate-level netlist; optionally the cell library it was built from |
| Writes | --hier: one row per elaborated instance, path,type,kind,depth. --cells: one row per cell type, cell,in_text,elaborated, plus a TOTAL row |
| Exit 0 | both files written |
| Exit 1 | refused: a construct outside the supported subset, an unknown cell (with --lib), no single top, or a module that contains itself — every reason printed on STDERR with its line |
| Exit 2 | usage or I/O error |
| Promise | both files or neither; and --help lists exactly the constructs the tool refuses |
The registry asks for "its regex boundary documented in the tool's own usage text", and the last row of the brief is how this project meets that. The boundary is not a paragraph somebody keeps up to date. It is a table in the code, and the usage text is generated from that table, so the documentation and the enforcement are the same list. Section 8 has a test that fails if they ever differ.
The project rests on Module 20, which it applies rather than repeats:
- Reading a gate-level netlist — lines for directives, statements for instances (20.1).
- What a regex can and cannot see in a netlist — the constructs that defeat a scan, and that they fail silently (20.2).
- Building the hierarchy map and counting and classifying cells — text count versus elaborated count (20.4, 20.5).
2. The Input
The analyzer runs on the Module 20 fixture, unchanged: a four-bit counter netlist, the small representative cell library it uses, and a testbench that dumps every signal in the design. No synthesis tool exists on this machine, so the netlist was written by hand — but it is a working design that compiles and simulates, and Chapter 20.4 established its contents three independent ways: 18 instantiations in the text, 27 instances once elaborated, 23 of them cells.
// A small REPRESENTATIVE standard-cell library. The names follow the usual
// industry shape (function + drive strength) but belong to no real vendor.
`timescale 1ns/1ps
module INVD1 (input A, output ZN); assign ZN = ~A; endmodule
module BUFFD1 (input A, output Z); assign Z = A; endmodule
module ND2D1 (input A1, input A2, output ZN); assign ZN = ~(A1 & A2); endmodule
module NR2D1 (input A1, input A2, output ZN); assign ZN = ~(A1 | A2); endmodule
module AN2D1 (input A1, input A2, output Z); assign Z = A1 & A2; endmodule
module XOR2D1 (input A1, input A2, output Z); assign Z = A1 ^ A2; endmodule
// D flip-flop with asynchronous active-low reset
module DFCNQD1 (input D, input CP, input CDN, output reg Q);
always @(posedge CP or negedge CDN)
if (!CDN) Q <= 1'b0;
else Q <= D;
endmodule
// integrated clock gate (latch-based enable, modelled simply)
module CKLNQD1 (input CP, input E, input TE, output ECK);
reg en;
always @(negedge CP) en <= E | TE;
assign ECK = CP & en;
endmodule
module CKBD1 (input I, output Z); assign Z = I; endmodule
// physical-only cells: no function, must not appear in a functional netlist
module FILL1 (); endmodule
module TIEHI (output Z); assign Z = 1'b1; endmodule
module TIELO (output ZN); assign ZN = 1'b0; endmodule
module ANTENNA (input A); endmodule// ============================================================
// Netlist for design 'counter_top'
// Generated by a synthesis tool -- representative sample
// Library: repr_std_cell Date: (omitted, not reproducible)
// ============================================================
`timescale 1ns/1ps
module counter_bit (
D,
CP,
CDN,
Q );
input D;
input CP;
input CDN;
output Q;
wire n1;
wire d_buf;
INVD1 U1 ( .A(D), .ZN(n1) );
INVD1 U2 ( .A(n1), .ZN(d_buf) );
DFCNQD1 state_reg ( .D(d_buf), .CP(CP), .CDN(CDN), .Q(Q) );
endmodule
module counter_top (
clk,
rst_n,
enable,
count );
input clk;
input rst_n;
input enable;
output [3:0] count;
wire gclk, n10, n11, n12, n13, n14, n15, n16;
/* clock gating for the whole counter */
CKLNQD1 clk_gate ( .CP(clk), .E(enable), .TE(1'b0), .ECK(gclk) );
CKBD1 clk_buf ( .I(gclk), .Z(n10) );
// bit 0 toggles every enabled cycle
INVD1 U20 ( .A(count[0]), .ZN(n11) );
counter_bit bit0 ( .D(n11), .CP(n10), .CDN(rst_n), .Q(count[0]) );
// bit 1
XOR2D1 U21 ( .A1(count[1]), .A2(count[0]), .Z(n12) );
counter_bit bit1 ( .D(n12), .CP(n10), .CDN(rst_n), .Q(count[1]) );
// bit 2 -- written in the MULTI-LINE instance form a tool often emits
AN2D1 U22 (
.A1(count[0]),
.A2(count[1]),
.Z(n13) );
XOR2D1 U23 ( .A1(count[2]), .A2(n13), .Z(n14) );
counter_bit bit2 ( .D(n14), .CP(n10), .CDN(rst_n), .Q(count[2]) );
// bit 3
AN2D1 U24 ( .A1(n13), .A2(count[2]), .Z(n15) );
XOR2D1 U25 ( .A1(count[3]), .A2(n15), .Z(n16) );
counter_bit bit3 ( .D(n16), .CP(n10), .CDN(rst_n), .Q(count[3]) );
BUFFD1 U26 ( .A(rst_n), .Z() ); // output pin left UNCONNECTED
TIELO U27 ( .ZN() ); // physical-only cell, also unconnected
FILL1 FILLER_1 ( );
endmodule`timescale 1ns/1ps
module tb_dump;
reg clk = 0, rst_n = 0, enable = 1;
wire [3:0] count;
counter_top dut (.clk(clk), .rst_n(rst_n), .enable(enable), .count(count));
always #5 clk = ~clk;
initial begin
$dumpfile("h.vcd");
$dumpvars(0, dut); // the WHOLE hierarchy under dut
repeat (2) @(posedge clk); rst_n = 1;
repeat (4) @(posedge clk);
$finish;
end
endmoduleThree things in this netlist matter to the analyzer. counter_bit is a module, not a cell, and it is used four times, so everything inside it exists four times in the design. U22 is written across four lines, the form a tool emits for long connection lists. And there are comments of both kinds, including a block comment, that must not be read as code.
3. The Design
Figure 29.2.1 — Count only what can be read
The central decision is a whitelist. Chapter 20.2 measured that the constructs which defeat a pattern scan do not make it fail — they make it return a smaller number, with no error. A smaller number is the worst output a census can give, because it looks like an answer. So the analyzer does not try to list everything that can go wrong. It lists the three statement shapes it can read — a declaration, an assign, an instance with .port(net) connections — and refuses any other statement as UNRECOGNISED.
The boundary table then gives the common cases a precise name and line number: an escaped identifier, a macro, a generate block, a parameterised instance, two statements on one line. Two more checks happen per statement because they need the whole statement: POSITIONAL connections, and UNRECOGNISED.
Elaboration is a copy per use, the recursive expansion from Chapter 20.4: walking from the top, each instance of a module is expanded into its contents at a new path. That is what turns 18 lines of text into 27 instances.
4. The Tool
#!/usr/bin/perl
# analyze_netlist.pl -- hierarchy and cell-count CSV from a flat gate-level
# netlist, for the subset of Verilog it can read correctly, and a refusal
# with line numbers for everything else.
use strict;
use warnings;
use Getopt::Long qw(GetOptions);
use constant { OK => 0, REFUSED => 1, USAGE => 2 };
# The boundary. Each row is one construct the line-and-statement reader
# cannot count correctly. The SAME table drives detection and --help, so the
# documentation cannot drift from the code.
my @BOUNDARY = (
[ ESCAPED_ID => qr/\\\S/,
"escaped identifier (\\name followed by a space)" ],
[ MACRO => qr/`(?!timescale\b)\w/,
"a macro or compiler directive other than `timescale" ],
[ GENERATE => qr/\b(?:generate|genvar)\b/,
"a generate block or genvar: instance names are built at elaboration" ],
[ PARAMETERISED => qr/^\s*\w+\s*#\s*\(/,
"a parameterised instance (TYPE #(...) NAME)" ],
[ TWO_STATEMENTS => qr/;[^;]*\S[^;]*;/,
"two statements on one line" ],
);
# Two more refusals are decided per statement rather than per line:
# POSITIONAL an instance whose connections are not .port(net) pairs
# UNRECOGNISED any statement that is not a declaration, assign or instance
my $USAGE = <<"END";
usage: analyze_netlist.pl NETLIST --hier FILE --cells FILE [--top NAME] [--lib FILE]
--hier FILE one row per elaborated instance: path,type,kind,depth
--cells FILE one row per cell type: cell,in_text,elaborated (and TOTAL)
--top NAME the top module (default: the one module nobody instantiates)
--lib FILE a cell library; any cell type not declared in it is refused
What this tool reads: a flat synthesised netlist written as modules of
declarations, assign statements and instances with named .port(net)
connections. It refuses, naming the line, anything it cannot count correctly:
@{[ join "", map { sprintf " %-15s %s\n", $_->[0], $_->[2] } @BOUNDARY ]} POSITIONAL an instance with positional (not .port) connections
UNRECOGNISED any other statement inside a module
exit status: 0 both files written; 1 refused (reasons on STDERR); 2 usage or I/O
END
my %opt;
GetOptions(\%opt, "hier=s", "cells=s", "top=s", "lib=s", "help")
or fail(USAGE, "bad option (see --help)");
if ($opt{help}) { print $USAGE; exit OK }
fail(USAGE, "need exactly one NETLIST, plus --hier and --cells (see --help)")
unless @ARGV == 1 && $opt{hier} && $opt{cells};
my $netlist = $ARGV[0];
my @problems;
my ($modules, $order, $stmts) = read_netlist($netlist);
# ---- every instance, in declaration order, per module
my (%insts, %in_text);
for my $s (@$stmts) {
my ($mod, $line, $text) = @$s;
next if $text =~ /^(?:input|output|inout|wire|reg|tri|supply[01]|assign)\b/;
if ($text =~ /^(\w+)\s+(\w+)\s*\((.*)\)\z/s) {
my ($type, $name, $conns) = ($1, $2, $3);
if ($conns =~ /\S/ && $conns !~ /^\s*\.\w+\s*\(/) {
push @problems, "$netlist:$line: POSITIONAL: $type $name";
next;
}
push @{ $insts{$mod} }, { type => $type, name => $name, line => $line };
$in_text{$type}++;
next;
}
(my $short = $text) =~ s/\s+/ /g;
push @problems, "$netlist:$line: UNRECOGNISED: " . substr($short, 0, 50);
}
# ---- cells must be known, if a library was given
if ($opt{lib}) {
my ($libmods) = read_netlist($opt{lib}, 1);
for my $type (sort keys %in_text) {
next if $modules->{$type} || $libmods->{$type};
push @problems, "$netlist: UNKNOWN_CELL: $type is not a module here or in $opt{lib}";
}
}
# ---- the top: given, or the only module nobody instantiates
my %used = map { $_->{type} => 1 } map { @$_ } values %insts;
my $top = $opt{top};
if (defined $top) {
push @problems, "--top $top is not a module in $netlist" unless $modules->{$top};
} else {
my @tops = grep { !$used{$_} } @$order;
if (@tops == 1) { $top = $tops[0] }
else { push @problems, "cannot choose a top: " . (@tops ? join(", ", @tops) : "every module is instantiated") . " (use --top)" }
}
if (@problems) {
print STDERR "analyze_netlist.pl: $_\n" for @problems;
fail(REFUSED, scalar(@problems) . " problem(s); no output written");
}
# ---- elaborate: walk the hierarchy from the top, copying each module per use
my (@rows, %elab, %on_stack);
my $expand;
$expand = sub {
my ($mod, $prefix, $depth) = @_;
fail(REFUSED, "a module instantiates itself: $mod") if $on_stack{$mod}++;
for my $i (@{ $insts{$mod} || [] }) {
my $path = $prefix eq "" ? $i->{name} : "$prefix/$i->{name}";
my $kind = $modules->{ $i->{type} } ? "module" : "cell";
push @rows, [ $path, $i->{type}, $kind, $depth ];
if ($kind eq "module") { $expand->($i->{type}, $path, $depth + 1) }
else { $elab{ $i->{type} }++ }
}
delete $on_stack{$mod};
};
$expand->($top, "", 1);
# ---- write both files, or neither
my @cells = sort keys %elab;
my ($t_text, $t_elab) = (0, 0);
$t_text += $in_text{$_} for @cells;
$t_elab += $elab{$_} for @cells;
write_csv($opt{hier}, [qw(path type kind depth)], \@rows);
write_csv($opt{cells}, [qw(cell in_text elaborated)],
[ (map { [ $_, $in_text{$_}, $elab{$_} ] } @cells), [ "TOTAL", $t_text, $t_elab ] ]);
my $n_mod = grep { $_->[2] eq "module" } @rows;
printf "%s: %d module(s), top %s\n", $netlist, scalar @$order, $top;
printf " in the text: %d instantiation(s)\n", scalar map { @$_ } values %insts;
printf " elaborated: %d instance(s) = %d cell(s) + %d module copies\n",
scalar @rows, $t_elab, $n_mod;
printf " wrote %s (%d rows) and %s (%d cell types)\n",
$opt{hier}, scalar @rows, $opt{cells}, scalar @cells;
printf " cell names %s\n", $opt{lib} ? "checked against $opt{lib}" : "NOT checked (no --lib)";
exit OK;
# ---------------------------------------------------------------- helpers
sub fail {
my ($code, $msg) = @_;
unlink grep { defined } @opt{qw(hier cells)};
print STDERR "analyze_netlist.pl: $msg\n";
exit $code;
}
# Returns (\%module_names, \@modules_in_order, \@statements), where each
# statement is [module, line, text]. Comments are blanked first, keeping
# every newline so line numbers stay true. Directives are checked by LINE;
# everything else is read as statements ending in ';', plus 'endmodule'.
sub read_netlist {
my ($path, $names_only) = @_;
open my $fh, "<", $path or fail(USAGE, "cannot read $path: $!");
my $text = do { local $/; <$fh> };
close $fh;
$text =~ s{/\*(.*?)\*/}{ " " . ("\n" x ($1 =~ tr/\n//)) }gse;
$text =~ s{//[^\n]*}{}g;
my (%mods, @order, @stmts);
if ($names_only) {
$mods{$_} = 1 for $text =~ /^\s*module\s+(\w+)/mg;
return (\%mods);
}
my @lines = split /\n/, $text, -1;
for my $n (0 .. $#lines) {
for my $rule (@BOUNDARY) {
push @problems, sprintf "%s:%d: %s: %s", $path, $n + 1, $rule->[0], trim($lines[$n])
if $lines[$n] =~ $rule->[1];
}
$lines[$n] = "" if $lines[$n] =~ /^\s*`timescale\b/;
}
$text = join "\n", @lines;
my ($mod, $line) = (undef, 1);
while ($text =~ /\G(\s*)(endmodule\b|[^;]*?;)/gc) {
$line += ($1 =~ tr/\n//);
my $start = $line;
(my $stmt = $2) =~ s/;\z//;
$line += ($stmt =~ tr/\n//);
$stmt = trim($stmt);
if ($stmt eq "endmodule") { $mod = undef; next }
if ($stmt =~ /^module\s+(\w+)/) { $mod = $1; $mods{$mod} = 1; push @order, $mod; next }
if (!defined $mod) {
push @problems, "$path:$start: UNRECOGNISED: outside any module: " . substr($stmt, 0, 40);
next;
}
push @stmts, [ $mod, $start, $stmt ];
}
my $rest = substr($text, pos($text) // 0);
push @problems, "$path: UNRECOGNISED: text after the last statement: " . substr(trim($rest), 0, 40)
if $rest =~ /\S/;
return (\%mods, \@order, \@stmts);
}
sub trim { my $s = shift; $s =~ s/^\s+|\s+\z//g; return $s }
# Every field is a \w+ name, a path of \w+ names, a kind or a number -- the
# boundary refuses escaped identifiers, the only way a netlist name could
# hold a comma -- so the fields need no CSV quoting.
sub write_csv {
my ($path, $header, $rows) = @_;
my $tmp = "$path.tmp.$$";
open my $fh, ">", $tmp or fail(USAGE, "cannot write $tmp: $!");
print {$fh} join(",", @$_), "\n" for $header, @$rows;
close $fh or fail(USAGE, "cannot write $tmp: $!");
rename $tmp, $path or fail(USAGE, "cannot rename $tmp: $!");
}One table, two uses
my @BOUNDARY = (
[ ESCAPED_ID => qr/\\\S/,
"escaped identifier (\\name followed by a space)" ],Each row is an ID, a compiled pattern (Chapter 11.18) and an explanation. read_netlist loops over the rows to check each line; the usage heredoc loops over the same rows to print them. Adding a row adds both the check and its documentation, and there is no way to add one without the other.
Comments are blanked, not deleted
$text =~ s{/\*(.*?)\*/}{ " " . ("\n" x ($1 =~ tr/\n//)) }gse;A block comment is replaced by a space plus every newline it contained. Deleting it would move every later line up, and every refusal would then name the wrong line. The /e flag makes the replacement Perl code, and tr/\n// counts the newlines.
Directives by line, everything else by statement
Chapter 20.1 measured why one granularity is not enough: joining text up to each ; finds every instance, but glues `timescale to the module that follows it. So directives are handled per line — `timescale is blanked and any other directive is a MACRO refusal — and then the remaining text is read as statements:
while ($text =~ /\G(\s*)(endmodule\b|[^;]*?;)/gc) {\G anchors each match where the previous one ended (Chapter 11.20), so the loop walks the text statement by statement. A statement ends at ;, except endmodule, which has no semicolon. The leading whitespace is captured separately so its newlines can be counted, and that is how each statement keeps its true starting line.
Three statement shapes, and everything else is refused
Inside a module, a statement starting with a declaration keyword is skipped. A statement shaped TYPE NAME ( ... ) is an instance, but only if its connections are empty or start with .port( — otherwise it is POSITIONAL, because Chapter 20.3 measured that positional connections look exactly like an instance with nothing connected. Anything else is UNRECOGNISED, quoted in the message so the reader can see what was not understood.
The top, and the cycle guard
The top is the one module that no other module instantiates. With two candidates the tool refuses and names both, rather than picking one; --top resolves it. During elaboration, %on_stack holds the modules on the current path, and a module that reaches itself is a refusal — without it, the recursion would never end.
No quoting, on purpose
The CSV files are written with a plain join(","). That is safe here and only here: every field is a \w name, a /-separated path of them, a kind or a number, because the boundary refuses escaped identifiers — the one way a Verilog name can contain a comma. An earlier version carried a quoting helper anyway. It was removed, because the boundary means no input can ever reach it, and code that no input can reach cannot be tested. Chapter 29.6 writes CSV whose fields can contain commas, and quotes them properly.
5. Running It
#!/bin/bash
# demo.sh -- analyse the Module 20 netlist, checking cell names against
# the library, then look at both output files.
perl analyze_netlist.pl netlist/counter_syn.v --lib lib/cells.v \
--hier hier.csv --cells cells.csv
echo "exit $?"
echo
echo "== cells.csv"
cat cells.csv
echo
echo "== hier.csv (header, the top-level cells, and one counter_bit copy)"
grep -E '^(path|clk_|U20,|bit0)' hier.csvnetlist/counter_syn.v: 2 module(s), top counter_top
in the text: 18 instantiation(s)
elaborated: 27 instance(s) = 23 cell(s) + 4 module copies
wrote hier.csv (27 rows) and cells.csv (9 cell types)
cell names checked against lib/cells.v
exit 0
== cells.csv
cell,in_text,elaborated
AN2D1,2,2
BUFFD1,1,1
CKBD1,1,1
CKLNQD1,1,1
DFCNQD1,1,4
FILL1,1,1
INVD1,3,9
TIELO,1,1
XOR2D1,3,3
TOTAL,14,23
== hier.csv (header, the top-level cells, and one counter_bit copy)
path,type,kind,depth
clk_gate,CKLNQD1,cell,1
clk_buf,CKBD1,cell,1
U20,INVD1,cell,1
bit0,counter_bit,module,1
bit0/U1,INVD1,cell,2
bit0/U2,INVD1,cell,2
bit0/state_reg,DFCNQD1,cell,218 instantiations in the text, 27 instances in the design, 23 of them cells — the numbers Chapter 20.4 established. cells.csv is identical, row for row, to Chapter 20.5's census: the one flip-flop line in the text is four flops in the design, and three inverters are nine. The last line of the summary says the cell names were checked; without --lib it says plainly that they were not.
6. Two Witnesses From The Simulator
Matching an earlier chapter is reassuring, but both could share a mistake. The stronger check asks the simulator, which elaborates the design itself and knows nothing about this Perl.
cross_check.pl uses two witnesses. The first is the VCD file the testbench writes: every instance in the design appears as a nested $scope, so the set of scope paths under dut is the design's real hierarchy. The second is a census: a copy of the cell library in which every cell module gets one extra line, initial $display("CENSUS %m TYPE"), so that at time zero every cell in the design prints its own hierarchical name and type.
#!/usr/bin/perl
# cross_check.pl -- compare analyze_netlist.pl's two CSV files with what the
# SIMULATOR says the design contains. Two independent witnesses:
# 1. the VCD file: every instance is a $scope, nested as the hierarchy is
# 2. a census: a copy of the cell library in which every cell prints
# its own hierarchical name and type at time zero
use strict;
use warnings;
sub run { my $cmd = shift; my $out = `$cmd 2>&1`; die "failed: $cmd\n$out" if $?; return $out }
sub read_csv {
open my $fh, "<", shift or die $!;
<$fh>; # header
return map { chomp; [ split /,/ ] } <$fh>;
}
run("perl analyze_netlist.pl netlist/counter_syn.v --hier hier.csv --cells cells.csv --lib lib/cells.v");
my %tool_path = map { $_->[0] => $_->[1] } read_csv("hier.csv");
my %tool_cell = map { $_->[0] => $_->[2] } grep { $_->[0] ne "TOTAL" } read_csv("cells.csv");
# ---- witness 1: the scopes in a VCD of the whole design
run("iverilog -o dump.vvp lib/cells.v netlist/counter_syn.v tb/tb_dump.v && vvp -n dump.vvp");
open my $vcd, "<", "h.vcd" or die $!;
my (@stack, %scope);
while (<$vcd>) {
if (/^\s*\$scope\s+module\s+(\S+)/) { push @stack, $1; $scope{ join "/", @stack[2 .. $#stack] } = 1 if @stack > 2 }
elsif (/^\s*\$upscope/) { pop @stack }
}
my @only_tool = grep { !$scope{$_} } sort keys %tool_path;
my @only_vcd = grep { !$tool_path{$_} } sort keys %scope;
printf "VCD scopes under dut: %d tool rows: %d only in tool: %d only in VCD: %d\n",
scalar(keys %scope), scalar(keys %tool_path), scalar @only_tool, scalar @only_vcd;
# ---- witness 2: every cell announces itself
open my $lib, "<", "lib/cells.v" or die $!;
open my $cen, ">", "census_cells.v" or die $!;
while (my $l = <$lib>) {
$l =~ s/^(module\s+(\w+)\s*\([^)]*\)\s*;)/$1 initial \$display("CENSUS %m $2");/;
print {$cen} $l;
}
close $cen;
my $log = run("iverilog -o census.vvp census_cells.v netlist/counter_syn.v tb/tb_dump.v && vvp -n census.vvp");
my (%sim_cell, @type_mismatch, @outside);
for (grep { /^CENSUS / } split /\n/, $log) {
my (undef, $where, $type) = split ' ';
# Icarus elaborates every module nobody instantiates as an extra top, so
# unused library cells announce themselves too. Count only the design.
if ($where !~ /^tb_dump\.dut\./) { push @outside, $where; next }
$sim_cell{$type}++;
(my $path = $where) =~ s/^tb_dump\.dut\.//;
$path =~ tr{.}{/};
push @type_mismatch, "$path: tool says " . ($tool_path{$path} // "nothing") . ", simulator says $type"
if ($tool_path{$path} // "") ne $type;
}
my $inside = 0;
$inside += $_ for values %sim_cell;
printf "cells announced inside the design: %d outside it: %d (%s)\n",
$inside, scalar @outside, join(" ", sort @outside);
printf "path/type disagreements: %d\n", scalar @type_mismatch;
print " $_\n" for @type_mismatch;
printf "%-8s %5s %10s\n", "CELL", "TOOL", "SIMULATOR";
my $agree = 0;
for my $t (sort keys %{ { %tool_cell, %sim_cell } }) {
my ($by_tool, $by_sim) = ($tool_cell{$t} // 0, $sim_cell{$t} // 0);
$agree++ if $by_tool == $by_sim;
printf "%-8s %5d %10d%s\n", $t, $by_tool, $by_sim, $by_tool == $by_sim ? "" : " <-- DIFFERENT";
}
printf "%d of %d cell types agree\n", $agree, scalar keys %{ { %tool_cell, %sim_cell } };VCD scopes under dut: 27 tool rows: 27 only in tool: 0 only in VCD: 0
cells announced inside the design: 23 outside it: 4 (ANTENNA ND2D1 NR2D1 TIEHI)
path/type disagreements: 0
CELL TOOL SIMULATOR
AN2D1 2 2
BUFFD1 1 1
CKBD1 1 1
CKLNQD1 1 1
DFCNQD1 4 4
FILL1 1 1
INVD1 9 9
TIELO 1 1
XOR2D1 3 3
9 of 9 cell types agreeThe comparison is set equality, not just matching counts. Every one of the tool's 27 paths is a VCD scope and every scope is one of the tool's paths. Every one of the 23 cells the simulator announced inside the design is at a path where the tool says the same type, and all nine per-type counts agree.
The census needed one correction before it was right, and it is worth knowing. Its first run reported four disagreements: ANTENNA, ND2D1, NR2D1 and TIEHI, each announced once by the simulator and never by the tool. None of the four is in the design. Icarus elaborates every module that nothing instantiates as an extra top-level root, so the four unused library cells ran their initial blocks too, outside the design. The fix is to count only names under tb_dump.dut., which the VCD check already did by skipping the two outer scopes. A witness has a scope just like the tool it checks, and an unscoped witness reports a difference that is not there.
7. The Boundary, Measured
probes.pl writes one small netlist per construct — the three the tool should read and the eight it should refuse — and asks two questions of each. Is it legal? Icarus compiles it against the library. What does the tool do?
#!/usr/bin/perl
# probes.pl -- one small netlist per construct, each judged twice: is it
# LEGAL (does Icarus compile it with the library?) and what does the
# analyzer do with it? Every probe wraps exactly one construct, so a result
# can only have one cause.
use strict;
use warnings;
my $wrap = sub { # a one-module netlist around BODY
my ($body) = @_;
return "module probe (a, b, z, y);\n input a, b;\n output z;\n"
. " output [1:0] y;\n wire n1, n2;\n$body\nendmodule\n";
};
my @PROBES = (
[ "plain instances (control)", $wrap->(" INVD1 U1 ( .A(a), .ZN(z) );") ],
[ "comment between type and name", $wrap->(" INVD1 /* ECO 12 */ U1 ( .A(a), .ZN(z) );") ],
[ "multi-line instance", $wrap->(" AN2D1 U1 (\n .A1(a),\n .A2(b),\n .Z(z) );") ],
[ "escaped identifier", $wrap->(" INVD1 \\U1/fix ( .A(a), .ZN(z) );") ],
[ "cell type from a macro", "`define DRIVE D1\n" . $wrap->(" INV`DRIVE U1 ( .A(a), .ZN(z) );") ],
[ "generate loop", $wrap->(" genvar i;\n generate for (i = 0; i < 2; i = i + 1) begin : g\n INVD1 U1 ( .A(a), .ZN(y[i]) );\n end endgenerate") ],
[ "parameterised instance", "module sub #(parameter W = 1) (A, ZN);\n input A;\n output ZN;\n INVD1 U1 ( .A(A), .ZN(ZN) );\nendmodule\n"
. $wrap->(" sub #(.W(2)) U1 ( .A(a), .ZN(z) );") ],
[ "two instances on one line", $wrap->(" INVD1 U1 ( .A(a), .ZN(n1) ); INVD1 U2 ( .A(n1), .ZN(z) );") ],
[ "positional connections", $wrap->(" INVD1 U1 ( a, z );") ],
[ "instance array", $wrap->(" INVD1 U1 [1:0] ( .A({a, b}), .ZN(y) );") ],
[ "a cell the library lacks", $wrap->(" INVD4 U1 ( .A(a), .ZN(z) );") ],
);
printf "%-32s %-9s %-6s %s\n", "CONSTRUCT", "ICARUS", "TOOL", "WHAT THE TOOL SAID";
for my $p (@PROBES) {
my ($label, $netlist) = @$p;
open my $fh, ">", "probe.v" or die $!;
print {$fh} "`timescale 1ns/1ps\n", $netlist;
close $fh;
my $legal = system("iverilog -o /dev/null lib/cells.v probe.v > /dev/null 2>&1") == 0
? "legal" : "ILLEGAL";
my $err = `perl analyze_netlist.pl probe.v --hier h.csv --cells c.csv --lib lib/cells.v 2>&1 >/dev/null`;
my $rc = $? >> 8;
my ($why) = $err =~ /^analyze_netlist\.pl: (?:probe\.v:(?:\d+:)?\s*)?(\S.*)$/m;
if ($rc == 0) { # accepted: how many instances?
open my $h, "<", "h.csv" or die $!;
my $rows = () = <$h>;
$why = sprintf "read %d instance(s)", $rows - 1;
}
printf "%-32s %-9s %-6s %s\n", $label, $legal, $rc == 0 ? "ok" : "exit $rc",
substr($why // "-", 0, 44);
}
unlink "probe.v", "h.csv", "c.csv";CONSTRUCT ICARUS TOOL WHAT THE TOOL SAID
plain instances (control) legal ok read 1 instance(s)
comment between type and name legal ok read 1 instance(s)
multi-line instance legal ok read 1 instance(s)
escaped identifier legal exit 1 ESCAPED_ID: INVD1 \U1/fix ( .A(a), .ZN(z) )
cell type from a macro legal exit 1 MACRO: `define DRIVE D1
generate loop legal exit 1 GENERATE: genvar i;
parameterised instance legal exit 1 PARAMETERISED: sub #(.W(2)) U1 ( .A(a), .ZN(
two instances on one line legal exit 1 TWO_STATEMENTS: INVD1 U1 ( .A(a), .ZN(n1) );
positional connections legal exit 1 POSITIONAL: INVD1 U1
instance array legal exit 1 UNRECOGNISED: INVD1 U1 [1:0] ( .A({a, b}), .
a cell the library lacks ILLEGAL exit 1 UNKNOWN_CELL: INVD4 is not a module here orTen of the eleven netlists are legal Verilog — the compiler accepts them — and the tool refuses seven of those ten. That is the point of the boundary: these are not malformed inputs, they are correct netlists that this tool cannot count, and it says so instead of returning a smaller number. The one illegal probe, a cell the library does not have, is refused only because --lib was given. Without --lib the tool would count it, and its summary would say the names were not checked.
The three accepted probes matter as much. A comment between the cell type and the instance name, and an instance written across four lines, are both read as one instance. Chapter 20.2 had found that the comment case does not actually defeat a scan that strips comments first, and refusing it would have made the tool refuse correct netlists for no reason. A boundary that is too wide is a defect too: every unnecessary refusal teaches a team to ignore the tool.
8. A Test Suite For The Tool
The suite follows the plan from Chapter 29.1: black-box, fresh temporary files, one claim per test, and the five Test::More functions introduced there.
#!/usr/bin/perl
# t/analyze_netlist.t -- black-box tests for analyze_netlist.pl.
use strict;
use warnings;
use Test::More;
use File::Temp qw(tempdir);
use FindBin qw($Bin);
my $TOOL = "$Bin/../analyze_netlist.pl";
my $LIB = "$Bin/../lib/cells.v";
my $GOOD = "$Bin/../netlist/counter_syn.v";
my $dir = tempdir(CLEANUP => 1);
sub slurp { open my $fh, "<", $_[0] or return undef; local $/; return scalar <$fh> }
sub write_file { open my $fh, ">", $_[0] or die "$_[0]: $!"; print {$fh} $_[1]; close $fh }
# Run the tool on NETLIST with extra args; return (status, stdout, stderr).
sub run_tool {
my ($netlist, @extra) = @_;
my $pid = fork // die "fork: $!";
if ($pid == 0) {
open STDOUT, ">", "$dir/out.txt" or die;
open STDERR, ">", "$dir/err.txt" or die;
exec $^X, $TOOL, (defined $netlist ? $netlist : ()), "--hier", "$dir/h.csv", "--cells", "$dir/c.csv", @extra;
die "exec: $!";
}
waitpid $pid, 0;
return ($? >> 8, slurp("$dir/out.txt") // "", slurp("$dir/err.txt") // "");
}
sub rows { return map { [ split /,/ ] } grep { length } split /\n/, (slurp($_[0]) // "") }
# a one-module netlist around BODY (the same shape probes.pl uses)
sub probe {
my ($body, $before) = @_;
my $f = "$dir/probe.v";
write_file($f, ($before // "") . "module probe (a, b, z, y);\n input a, b;\n"
. " output z;\n output [1:0] y;\n wire n1;\n$body\nendmodule\n");
return $f;
}
# ---- the Module 20 netlist: the numbers three earlier methods agreed on
my ($rc, $out, $err) = run_tool($GOOD, "--lib", $LIB);
is($rc, 0, "counter_syn.v: exit 0");
my @hier = rows("$dir/h.csv");
my %cells = map { $_->[0] => $_ } rows("$dir/c.csv");
is(scalar @hier - 1, 27, "27 elaborated instances (the VCD count)");
is_deeply($cells{TOTAL}, [ "TOTAL", 14, 23 ], "14 cell instantiations in the text, 23 cells elaborated");
is_deeply($cells{DFCNQD1}, [ "DFCNQD1", 1, 4 ], "the flop: 1 in the text, 4 in the design");
is((grep { $_->[2] eq "module" } @hier), 4, "4 module copies (bit0 .. bit3)");
ok(!(grep { $_->[0] ne "path" && $_->[3] != 1 + ($_->[0] =~ tr{/}{}) } @hier),
"every depth equals the number of path levels");
my $first = slurp("$dir/h.csv");
run_tool($GOOD, "--lib", $LIB);
is(slurp("$dir/h.csv"), $first, "a second run writes the identical hierarchy");
# ---- the documented boundary and the enforced boundary are the same list
my (undef, $help) = run_tool("--help");
my @documented = sort $help =~ /^ ([A-Z_]+)\s{2,}/mg;
my %PROBE = (
ESCAPED_ID => [ " INVD1 \\U1/fix ( .A(a), .ZN(z) );" ],
MACRO => [ " INV`DRIVE U1 ( .A(a), .ZN(z) );", "`define DRIVE D1\n" ],
GENERATE => [ " genvar i;" ],
PARAMETERISED => [ " INVD1 #(.W(2)) U1 ( .A(a), .ZN(z) );" ],
TWO_STATEMENTS => [ " INVD1 U1 ( .A(a), .ZN(n1) ); INVD1 U2 ( .A(n1), .ZN(z) );" ],
POSITIONAL => [ " INVD1 U1 ( a, z );" ],
UNRECOGNISED => [ " INVD1 U1 [1:0] ( .A({a, b}), .ZN(y) );" ],
);
is_deeply(\@documented, [ sort keys %PROBE ], "--help documents exactly the refusals this test probes");
for my $id (sort keys %PROBE) {
write_file("$dir/h.csv", "stale\n");
my ($prc, undef, $perr) = run_tool(probe(@{ $PROBE{$id} }), "--lib", $LIB);
ok($prc == 1 && $perr =~ /\b$id\b/ && !-e "$dir/h.csv",
"$id: refused, named, and no CSV left behind");
}
# ---- what it must still accept
($rc) = run_tool(probe(" INVD1 /* ECO 12 */ U1 ( .A(a), .ZN(z) );"), "--lib", $LIB);
is($rc, 0, "a comment between type and name is read, not refused");
($rc) = run_tool(probe(" AN2D1 U1 (\n .A1(a),\n .A2(b),\n .Z(z) );"), "--lib", $LIB);
is(scalar(rows("$dir/h.csv")) - 1, 1, "a multi-line instance is read as one instance");
# ---- cell names: checked with --lib, and honestly NOT checked without it
my $unknown = probe(" INVD4 U1 ( .A(a), .ZN(z) );");
($rc, undef, $err) = run_tool($unknown, "--lib", $LIB);
ok($rc == 1 && $err =~ /UNKNOWN_CELL: INVD4/, "with --lib, an unknown cell is refused");
($rc, $out) = run_tool($unknown);
ok($rc == 0 && $out =~ /NOT checked/, "without --lib, it is accepted and the summary says so");
# ---- the top
my $two = "$dir/two.v";
write_file($two, "module a1 (x);\n input x;\nendmodule\nmodule a2 (x);\n input x;\nendmodule\n");
($rc, undef, $err) = run_tool($two);
ok($rc == 1 && $err =~ /cannot choose a top: a1, a2/, "two candidate tops: refused, both named");
($rc) = run_tool($two, "--top", "a2");
is($rc, 0, "two candidate tops with --top: accepted");
my $loop = "$dir/loop.v";
write_file($loop, "module m (x);\n input x;\n m inner ( .x(x) );\nendmodule\n");
($rc, undef, $err) = run_tool($loop, "--top", "m");
ok($rc == 1 && $err =~ /instantiates itself/, "a module that contains itself: refused");
# ---- usage
($rc) = run_tool();
is($rc, 2, "no netlist: exit 2");
done_testing();Test 8 is the one this project exists for. It reads the refusal IDs out of --help and compares them with the IDs the suite has a probe for. Tests 9 to 15 then check that each probe is refused with its own ID named and that no stale CSV file survives. Add a row to the table without a probe and test 8 fails; remove a check that --help still documents and its probe test fails. The documentation, the enforcement and the tests are tied together.
Tests 2 to 5 pin the numbers that three independent methods agreed on, so a change that breaks the elaboration cannot pass quietly. Tests 18 and 19 check both sides of --lib: with it, an unknown cell is refused; without it, the cell is accepted and the summary says the names were not checked — honesty about what was not verified is part of the output.
#!/bin/bash
# tests.sh -- run the suite through prove, keeping prove's own exit status.
prove -v t/ > prove.log 2>&1
status=$?
grep -v 'wallclock' prove.log
echo "prove exit $status"t/analyze_netlist.t ..
ok 1 - counter_syn.v: exit 0
ok 2 - 27 elaborated instances (the VCD count)
ok 3 - 14 cell instantiations in the text, 23 cells elaborated
ok 4 - the flop: 1 in the text, 4 in the design
ok 5 - 4 module copies (bit0 .. bit3)
ok 6 - every depth equals the number of path levels
ok 7 - a second run writes the identical hierarchy
ok 8 - --help documents exactly the refusals this test probes
ok 9 - ESCAPED_ID: refused, named, and no CSV left behind
ok 10 - GENERATE: refused, named, and no CSV left behind
ok 11 - MACRO: refused, named, and no CSV left behind
ok 12 - PARAMETERISED: refused, named, and no CSV left behind
ok 13 - POSITIONAL: refused, named, and no CSV left behind
ok 14 - TWO_STATEMENTS: refused, named, and no CSV left behind
ok 15 - UNRECOGNISED: refused, named, and no CSV left behind
ok 16 - a comment between type and name is read, not refused
ok 17 - a multi-line instance is read as one instance
ok 18 - with --lib, an unknown cell is refused
ok 19 - without --lib, it is accepted and the summary says so
ok 20 - two candidate tops: refused, both named
ok 21 - two candidate tops with --top: accepted
ok 22 - a module that contains itself: refused
ok 23 - no netlist: exit 2
1..23
ok
All tests successful.
Result: PASS
prove exit 09. Proving The Suite Can Fail
#!/bin/bash
# mutate.sh -- plant one defect at a time in a COPY of the tool; does the
# suite notice?
mutant() { # LABEL PERL-SUBSTITUTION (applied to the whole file)
local label=$1 edit=$2 d
d=$(mktemp -d)
mkdir -p "$d/t" "$d/lib" "$d/netlist"
cp t/analyze_netlist.t "$d/t/"
cp lib/cells.v "$d/lib/"
cp netlist/counter_syn.v "$d/netlist/"
perl -0pe "$edit" analyze_netlist.pl > "$d/analyze_netlist.pl"
if cmp -s analyze_netlist.pl "$d/analyze_netlist.pl"; then
printf '%-44s NOT PLANTED (edit matched nothing)\n' "$label"
elif ! perl -c "$d/analyze_netlist.pl" > /dev/null 2>&1; then
printf '%-44s INVALID (the mutant does not compile)\n' "$label"
else
local failed
failed=$(perl "$d/t/analyze_netlist.t" 2>/dev/null | grep -c '^not ok')
printf '%-44s %s (%s failing test(s))\n' "$label" \
"$([ "$failed" -gt 0 ] && echo CAUGHT || echo SURVIVED)" "$failed"
fi
rm -rf "$d"
}
mutant "1 drop the GENERATE row from the boundary" 's/\n \[ GENERATE =>.*?\],//s'
mutant "2 accept positional connections" 's/push \@problems, "\$netlist:\$line: POSITIONAL.*?next;/1;/s'
mutant "3 ignore unrecognised statements" 's/push \@problems, "\$netlist:\$line: UNRECOGNISED[^;]*;//'
mutant "4 count the text, not the design" 's/else\s+\{ \$elab\{ \$i->\{type\} \}\+\+ \}/else { \$elab{ \$i->{type} } = \$in_text{ \$i->{type} } }/'
mutant "5 leave the CSV files on failure" 's/unlink grep \{ defined \} \@opt\{qw\(hier cells\)\};//'
mutant "6 \$1 instead of \$_ reading the library" 's/\$mods\{\$_\} = 1 for/\$mods{\$1} = 1 for/'
mutant "7 keep block comments" 's/\$text =~ s\{\/\\\*.*?\}gse;//'
mutant "8 skip a self-instance silently" 's/fail\(REFUSED, "a module instantiates itself: \$mod"\) if \$on_stack\{\$mod\}\+\+;/return if \$on_stack{\$mod}++;/'1 drop the GENERATE row from the boundary CAUGHT (2 failing test(s))
2 accept positional connections CAUGHT (1 failing test(s))
3 ignore unrecognised statements CAUGHT (1 failing test(s))
4 count the text, not the design CAUGHT (2 failing test(s))
5 leave the CSV files on failure CAUGHT (7 failing test(s))
6 $1 instead of $_ reading the library CAUGHT (7 failing test(s))
7 keep block comments CAUGHT (6 failing test(s))
8 skip a self-instance silently CAUGHT (1 failing test(s))All eight mutants were caught on the first run — unlike Chapter 29.1, where three of the first six survived. The difference is test 8. Because the suite derives its list of refusals from --help, deleting a boundary row (mutant 1) breaks two tests at once: the documentation no longer matches the probes, and the probe no longer carries its ID.
Mutant 6 is the $1 defect from Chapter 29.1, deliberately put back into the library reader. It fails seven tests, because with only the library's last module recorded, every real cell in the counter becomes unknown. That is how this project found the bug in the previous one.
10. What The Tool Does Not Do
- It does not read port connections. Which net connects where, within one module, is Chapter 20.6's job, and that chapter measured that following a net across a hierarchy boundary means writing an elaborator.
- It does not classify cells into families. Flop, buffer and clock-gate families are library-specific — Chapter 20.5 found a realistic scan flop that a
^DFrule missed — so a family column belongs to a tool that is given the library's naming rules. - It reads the whole netlist into memory. That is fine for a block and wrong for a multi-gigabyte chip netlist, which needs the streaming approach of Chapter 18.10.
- Its refusals are coarse.
input a; input b;on one line is harmless and is still refused asTWO_STATEMENTS. That is a deliberate trade: a false refusal costs a reformat; a false count costs a wrong number in a review. - It does not count
assignstatements as logic. A netlist that implements some logic asassignwill have fewer cells than functions, correctly.
When a netlist hits the boundary for a construct that matters to a team, the answer is usually a real Verilog parser or the synthesis tool's own reports (Module 22), not a wider pattern.
11. Exercises
1. A new refusal. Add a CELLDEFINE row for `celldefine blocks. Write its probe first, watch test 8 fail, then add the row and watch it pass. What did the table design save you?
2. Make the coarse rule precise. Change TWO_STATEMENTS so that two declarations on one line are accepted but two instances are still refused. Which probe and which test do you add first?
3. A third witness. Use the VCD's leaf scopes (scopes with no child scopes) to count cells per depth, and compare with the depth column of hier.csv.
4. A family column. Add --families FILE, a two-column file of patterns and family names. Make an unmatched cell type a refusal, not a blank. Which mutation proves your test can see an unmatched type?
5. Two netlists, one question. Run the tool on counter_syn.v and on a copy with one counter_bit removed. Write a ten-line script that compares the two cells.csv files and reports only the types whose elaborated counts changed.
12. What To Remember
- A smaller number is the worst failure a census can have, because it looks like an answer. Refuse instead.
- Whitelist the shapes you can read and refuse everything else as unrecognised; name the common refusals precisely.
- Generate the usage text from the table that enforces the boundary, and test that they agree.
- Blank comments, keep newlines — every refusal must name the right line.
- Read directives by line and instances by statement; each question has its own granularity.
- Judge the output with the simulator: set equality of paths against VCD scopes, and a census from an instrumented library. Measured: 27 of 27 paths, 23 of 23 cells, 9 of 9 types.
- A witness needs a scope too. Icarus elaborated four unused library cells as extra tops.
- A boundary that is too wide is also a defect. The comment and multi-line forms are read, not refused.
- Code no input can reach cannot be tested; remove it.
Where this fits
Part of the Perl curriculum.
