Tcl · Module 25
Waveform Setup Scripts
A waveform you have to assemble by clicking is a waveform you assemble differently every time. Script it from the design's own signal list, and make every exclusion a reason you wrote down.
Intermediate20 min readtcledapart-iisimulationwaveformdebug
Part II · Module 25 · Chapter 25.3 · Waveform Setup Scripts
A test fails at three in the morning. Someone opens the waveform, and then spends ten minutes finding the signals that matter, in an order that makes sense, with radixes that are readable.
They do that every time, slightly differently, and the setup is gone when the window closes.
1. Why this is a scripting problem
The obvious objection is that a GUI remembers your layout. Sometimes it does — and the layout is still the wrong artefact, for three reasons.
It is per-person. Your grouping is not in the repository, so the next engineer starts from nothing.
It does not travel with the design. Add a port, rename a block, and a saved layout silently refers to signals that no longer exist.
It cannot be generated. The useful waveform for a FIFO is "every port, grouped by role, plus the pointers" — a rule, applied to whatever signals the design currently has. A rule is a script. A saved layout is the output of running one by hand, once.
2. Start from what the simulation actually has
This chapter does not invent a signal list. It reads the one a real run produced.
Icarus is installed on the machine these chapters were verified on (Chapter 25.1 §1), so the FIFO testbench was run with $dumpvars(0, fifo_tb) and the resulting VCD header carries the real hierarchy the simulator saw. The add wave side of the chapter uses the same mock shell as Chapter 25.2 — but the signals below are real.
A VCD header is a small nested format: $scope descends, $upscope ascends, $var declares a signal.
proc vcd_signals {path} {
set fh [open $path r]
set scope {} ; set sigs {}
while {[gets $fh line] >= 0} {
set line [string trim $line]
if {[regexp {^\$scope\s+\w+\s+(\S+)\s+\$end} $line -> name]} {
lappend scope $name ; continue
}
if {[string match {$upscope*} $line]} {
set scope [lrange $scope 0 end-1] ; continue
}
if {[regexp {^\$var\s+(\S+)\s+(\d+)\s+\S+\s+(\S+)(?:\s+\[[^\]]*\])?\s+\$end} \
$line -> kind width name]} {
lappend sigs [list "/[join $scope /]/$name" $kind $width]
continue
}
if {[string match {$enddefinitions*} $line]} { break }
}
close $fh
return $sigs
}That is Chapter 24.1 §4's stateful parser in a different costume: a line-at-a-time loop where $scope and $upscope maintain a stack rather than a flat set of accumulators. lrange $scope 0 end-1 is how you pop in Tcl — there is no lpop in 8.5.
Note the (?:\s+\[[^\]]*\])? group: a vector's declaration carries its range ([7:0]) after the name, and a scalar's does not. Non-capturing so it does not shift the positional captures, and a negated class rather than a quantifier preference, for Chapter 24.3 §5's reason.
3. What dumping everything gives you
=== signals the simulation actually dumped ===
31 signal(s) in the VCD header
PATH KIND WIDTH
/fifo_tb/full wire 1
/fifo_tb/empty wire 1
/fifo_tb/dout wire 8
/fifo_tb/count wire 3
/fifo_tb/clk reg 1
/fifo_tb/din reg 8
/fifo_tb/pop reg 1
/fifo_tb/push reg 1
/fifo_tb/rst_n reg 1
/fifo_tb/testname reg 256
/fifo_tb/checks integer 32
/fifo_tb/errors integer 32
/fifo_tb/i integer 32
/fifo_tb/pushed integer 32
/fifo_tb/seed integer 32
/fifo_tb/dut/clk wire 1
/fifo_tb/dut/din wire 8
/fifo_tb/dut/pop wire 1
/fifo_tb/dut/push wire 1
/fifo_tb/dut/rst_n wire 1
/fifo_tb/dut/full wire 1
/fifo_tb/dut/empty wire 1
/fifo_tb/dut/DEPTH parameter 32
/fifo_tb/dut/WIDTH parameter 32
/fifo_tb/dut/count reg 3
/fifo_tb/dut/dout reg 8
/fifo_tb/dut/rptr reg 2
/fifo_tb/dut/wptr reg 2
/fifo_tb/check/what reg 384
/fifo_tb/check/exp integer 32
/fifo_tb/check/got integer 3231 signals from a design with ten ports. Look at what is in there:
DEPTHandWIDTHare parameters. They are constant for the whole simulation, so as waveforms they are flat lines carrying no information.testnameis 256 bits andcheck/whatis 384 bits — Verilog string registers. A waveform viewer will render them as enormous hex values that change once.checks,errors,i,pushed,seedare the testbench's own bookkeeping. Useful in a log, meaningless as traces.check/what,check/exp,check/gotare locals of a task, which exist only while the task is executing and are stale the rest of the time.
So "dump everything and look at it" is not a strategy. It is 31 traces of which roughly ten are the design.
4. Select, with the reason written down
set RULES {
{parameter {a parameter never changes}}
{wide {too wide to read as a waveform}}
{bookkeeping {testbench bookkeeping, not design state}}
{tasklocal {a task local, not a signal}}
}
proc rule_hit {rule sig} {
set path [lindex $sig 0]
set kind [lindex $sig 1]
set width [lindex $sig 2]
switch -- $rule {
parameter { return [expr {$kind eq "parameter"}] }
wide { return [expr {$width > 64}] }
bookkeeping { return [expr {$kind eq "integer"}] }
tasklocal { return [regexp {/check/} $path] }
}
return 0
}The rules are data, each paired with the sentence that justifies it. That is deliberate: a filter written as a chain of anonymous ifs accumulates exclusions nobody can account for, and six months later no one knows whether a missing signal is filtered on purpose.
=== every rule, with what it actually caught ===
parameter 2 a parameter never changes
wide 2 too wide to read as a waveform
bookkeeping 7 testbench bookkeeping, not design state
tasklocal 0 a task local, not a signal
kept 20 of 31 dumped5. The rule that fired zero times
The general form, and it is worth carrying out of this chapter:
A classification rule that fires zero times is either unnecessary or shadowed, and a report that omits empty categories cannot tell you which. Print every rule's count, including the zeros, and when one reads zero, probe a case it was written for and see which rule claims it first.
This is the same hazard as a monitor that never triggers, and the same cure: a check you have never seen fire is a check you do not know you have.
6. Group by role, not by hierarchy
Twenty signals is still too many to read as a flat list, and the fix is grouping. The useful grouping is by role, which a path and a kind are usually enough to derive:
proc group_for {path} {
if {[regexp {/(clk|rst_n)$} $path]} { return clock-reset }
if {[regexp {/dut/} $path]} {
if {[regexp {/(wptr|rptr|count)$} $path]} { return dut-internal }
return dut-port
}
if {[regexp {/(push|pop|full|empty)$} $path]} { return handshake }
return testbench
}=== the wave script this generates ===
group 'clock-reset'
/fifo_tb/clk
/fifo_tb/rst_n
/fifo_tb/dut/clk
/fifo_tb/dut/rst_n
group 'handshake'
/fifo_tb/full
/fifo_tb/empty
/fifo_tb/pop
/fifo_tb/push
group 'testbench'
/fifo_tb/dout
/fifo_tb/count
/fifo_tb/din
group 'dut-port'
/fifo_tb/dut/din
/fifo_tb/dut/pop
/fifo_tb/dut/push
/fifo_tb/dut/full
/fifo_tb/dut/empty
/fifo_tb/dut/dout
group 'dut-internal'
/fifo_tb/dut/count
/fifo_tb/dut/rptr
/fifo_tb/dut/wptr
20 signal(s), 5 group(s)The group order is the reading order. Clock and reset first, because every timing question starts there; handshake next, because that is where a protocol bug shows; the DUT's internals last, because you only look at them once the interface tells you something is wrong. foreach g $ordered over an explicit list rather than dict keys — the order is a decision, so it is written down.
7. Where the signal list should come from
The VCD route above is used here because it is what can be verified on this machine, and it has a real limitation worth stating: a VCD only contains signals that were dumped, so a selector built from one cannot offer you a signal the run did not record. It is a list of what was captured, not of what exists.
In a live session the tool answers the question directly, and those commands are vendor-specific (find signals, show, and relatives). The structure of the script does not change — query, classify, group, add wave — only the first step does. Which means the classify-and-group logic is the portable part and worth keeping separate from the query, as Chapter 17.5 argues generally.
A third route is the RTL itself, parsed for port declarations the way Chapter 21.3 pulls module names out of source. That has the opposite trade: it knows every port whether or not it was dumped, and it knows nothing about what the elaborated hierarchy actually instantiated.
8. Exercises
Exercise 1. Give three reasons a saved GUI layout is the wrong artefact.
Exercise 2. $dumpvars(0, fifo_tb) produced 31 signals for a design with ten ports. Name three categories of the surplus.
Exercise 3. Why are DEPTH and WIDTH useless as waveforms?
Exercise 4. Why pair each filter rule with a sentence?
Exercise 5. tasklocal fired zero times. What are the two possible explanations, and how did the probe distinguish them?
Exercise 6. Why was the zero invisible in the first version of the report?
Exercise 7. Should the shadowed rule be removed? Why or why not?
Exercise 8. Why is the group order written as an explicit list rather than taken from dict keys?
Exercise 9. When do you want both sides of a port connection in the waveform, and when do you not?
Exercise 10. What can a VCD-derived signal list never offer you?
Where this fits
Part of the Tcl curriculum.
