Tcl · Module 30
A Synthesis Run Script
The flow is the easy part. What makes this a production driver is a phase wrapper that gets Tcl's return codes right, an error classification that survives being re-raised, and a gate that could not fail until one line was added.
Advanced26 min readtcledapart-iisynthesisflowcase-study
Part II · Module 30 · Chapter 30.1 · A Synthesis Run Script
This module is six complete scripts of the kind engineers actually ship, read end to end. Not fragments — whole programs, with the decisions explained and the failure handling present rather than assumed.
This one is a synthesis driver: nine phases, seven of them gated, three exit statuses. It is about 180 lines, and nearly all of its interest is in four places where plausible-looking Tcl does something other than what it appears to.
1. Nine phases, and why the shape matters
Two phases prepare and are not gated. Seven run through a wrapper that times each one, records it, and re-raises failures with their classification intact.
The clean run, captured:
read_rtl 2 file(s) 0 ms
libraries 1 directory(ies) 0 ms
elaborate 80 cells 0 ms
constrain 1 clock(s) 2 ms
compile 698.47 um2 0 ms
report 5 path(s), wns 0.0230 4 ms
archive 3 file(s) + MANIFEST 5 msExit status 0. Every line names what the phase produced, not that it finished — a distinction §7 turns into a rule.
(The millisecond column is the only part of that capture that changes between runs; §7 is about exactly that.)
2. The tool here is a teaching mock
3. Locating yourself, and a configuration with a contract
set ::ROOT [file dirname [file dirname [file normalize [info script]]]]
source [file join $::ROOT lib tool.tcl]info script gives the path of the file being evaluated; file normalize makes it absolute and resolves symlinks. Two file dirname calls climb from bin/synth.tcl to the project root. The script can now be invoked from any working directory — a property worth having before the first cd in somebody's cron entry breaks it.
The configuration is a single dict, and it is checked:
set ::CFG [dict create \
top core \
rtl_dir [file join $::ROOT rtl] \
lib_dirs [list [file join $::ROOT libs tech]] \
sdc [file join $::ROOT constraints core.sdc] \
effort high \
out_dir [file join $::ROOT work synth]]
proc require {cfg keys} {
set missing {}
foreach k $keys { if {![dict exists $cfg $k]} { lappend missing $k } }
if {[llength $missing]} {
error "config is missing: [join $missing {, }]" {} [list CFG MISSING $missing]
}
return [dict size $cfg]
}Two details carry weight. require collects every missing key before raising, so one run tells you everything that is wrong rather than one thing at a time. And the raise carries a structured errorCode — CFG MISSING ... — which §6 turns into an exit status.
Measured, with effort deleted from the dict:
synthesis failed: config is missing: effort
phases completed: 0Exit 1, and phases completed: 0 says the design was never touched.
4. The phase wrapper, and the code a return really produces
Every gated phase goes through this:
proc step {name body} {
set t0 [clock milliseconds]
set code [catch {uplevel 1 $body} result]
set ms [expr {[clock milliseconds] - $t0}]
set failed [expr {$code == 1}]
...
}That $code == 1 looks like a stylistic choice. It is not — it is the difference between a working driver and one that reports every phase as failed.
catch returns the completion code, and a return inside the body does not produce 0. Measured, all three endings:
a body that RETURNS a value:
body ended with: code=2 result='5 path(s)'
truthiness test if {$code} -> FAILED
correct test if {$code==1} -> ok
a body that RAISES:
body ended with: code=1 result='boom'
truthiness test if {$code} -> FAILED
correct test if {$code==1} -> FAILED
a body that just ends:
body ended with: code=0 result='1'
truthiness test if {$code} -> ok
correct test if {$code==1} -> okCode 2 is TCL_RETURN, and every phase body here ends with return "80 cells" or similar, because the wrapper prints that string. So if {$code} — the reflex — marks a successful phase as FAILED.
What makes this nasty is the third row. A body that just falls off the end gives 0, so a wrapper written with the truthiness test works on the phases that return nothing and fails on exactly the phases that produce a result worth printing. Tested with a trivial body, it passes.
Two Part I ideas meet here: catch returns a code rather than a boolean (14.3), and uplevel 1 runs the body in the caller's scope so $cfg resolves (11.5).
5. The main guard, and the driver somebody renamed
The script runs its flow only when it is the file being executed, not when it is sourced:
if {[file normalize $::argv0] eq [file normalize [info script]]} {
...
}The tempting form compares names — [file tail $::argv0] eq "synth.tcl". Measured against the path comparison in all three situations:
argv0 = /tmp/p_guard.tcl
normalized argv0 = /private/tmp/p_guard.tcl
name comparison [file tail $argv0] eq "p_guard.tcl" -> 1
path comparison normalized eq normalized -> 1
argv0 = /tmp/renamed_driver.tcl
normalized argv0 = /private/tmp/renamed_driver.tcl
name comparison [file tail $argv0] eq "p_guard.tcl" -> 0
path comparison normalized eq normalized -> 1
argv0 = /tmp/p_src.tcl
normalized argv0 = /private/tmp/p_src.tcl
name comparison [file tail $argv0] eq "p_guard.tcl" -> 0
path comparison normalized eq normalized -> 0The three blocks are: run directly, run as a copy under a different name, and sourced from another script.
The name test is wrong in exactly one case, and it is the case that happens: a driver copied or renamed — synth_fast.tcl, synth.tcl.bak, a per-block variant — silently does nothing at all. It exits 0, prints nothing, and the make target succeeds.
file normalize is doing real work here too. The same probe printed:
argv0 = /tmp/p_guard.tcl
normalized argv0 = /private/tmp/p_guard.tclOn this machine /tmp is a symlink to /private/tmp, so comparing the raw strings would fail for a script invoked by its /tmp path. Normalize both sides, always.
6. errorCode, and the exit status it decides
The wrapper re-raises a failure, and how it re-raises is the whole classification:
set saved [expr {$failed ? $::errorCode : "NONE"}]
...
if {$failed} {
puts stderr " $name: FAILED after ${ms}ms — $result"
return -code error -errorcode $saved $result
}Measured, the plain form against the preserving form:
the original raise:
errorCode = GATE glob
re-raised with `return -code error $r`:
errorCode = NONE <- the classification is GONE
re-raised with `-errorcode $saved`:
errorCode = GATE glob <- preservedreturn -code error $msg resets ::errorCode to NONE. Nothing warns you; the message is identical; only the structured code is lost. And the final line of the script depends on it:
exit [expr {[lindex $::errorCode 0] in {GATE CFG} ? 1 : 2}]So losing the code collapses "a gate stopped the run" and "the tool raised" into one status, and a caller that retries tool errors but not gate failures starts retrying typos.
There is a second trap in that one line. $saved is read only when something failed, because on a clean run the variable does not exist yet:
reading $::errorCode unconditionally: RAISED -> can't read "::errorCode": no such variableA wrapper that reads it eagerly works for every failing run and crashes on the first successful one — the inverse of the usual bug, and far more embarrassing.
Three statuses, then, each meaning something a caller can act on: 0 the design passed, 1 the flow could not run (gate or configuration), 2 the tool refused.
7. Gates that check an effect, and a runtime table that refuses to lie
The constrain phase is the clearest example of the module's recurring rule:
step constrain {
set n [read_sdc [dict get $cfg sdc]]
gate constrain $n "no clock was defined by [dict get $cfg sdc]"
return "$n clock(s)"
}read_sdc returns a clock count — the number of clocks it actually defined — and the gate tests that count, not the fact that the call returned. Chapter 21.6 measured why: sourcing an SDC returns 0 whether or not anything applied, so "the command succeeded" and "the constraints took effect" are different claims. The run prints 1 clock(s), which is a fact about the design rather than about the script.
The same principle produces the runtime table — and its floor:
set ::SHARE_FLOOR_MS 500Below that total, the table reports milliseconds and refuses to apportion them. Both sides were exercised. At the real total of 11 ms:
PHASE MS SHARE STATUS
read_rtl 0 - ok
libraries 0 - ok
elaborate 0 - ok
constrain 2 - ok
compile 0 - ok
report 4 - ok
archive 5 - ok
TOTAL 11 -
total under 500 ms: too small to apportion, so no share is shownWith the floor lowered to 1 ms and nothing else changed, the same work over four consecutive runs:
| run | constrain | compile | report | archive |
|---|---|---|---|---|
| 1 | 22.2% | 0.0% | 38.9% | 33.3% |
| 2 | 12.5% | 0.0% | 25.0% | 50.0% |
| 3 | 0.0% | 10.0% | 20.0% | 70.0% |
| 4 | 25.0% | 0.0% | 25.0% | 50.0% |
archive ranges from 33% to 70%, constrain from 0% to 25%, on identical inputs. A share column computed from a handful of milliseconds is noise presented as analysis, and it will confidently tell you to optimise whichever phase caught the clock tick.
The table is also printed on both paths — success and failure:
puts ""
runtime_report
if {$code} { ... }A phase breakdown that only appears on success is missing exactly when it would be useful, and phases completed: 4 is often the single most informative line in a failed run.
8. The archive, and the gate that could not fail
The hand-off is not the reports. It is the reports plus the inputs that produced them — summary, netlist and constraints, copied together with a manifest:
set copied 0 ; set missing {}
foreach f $want {
if {![file isfile $f]} { lappend missing [file tail $f] ; continue }
file copy -force $f [file join $dest [file tail $f]]
incr copied
}
...
gate archive [expr {$copied == [llength $want]}] \
"archived $copied of [llength $want] files; missing: [join $missing {, }]"Two things here were wrong in the first version, and both made the gate incapable of failing.
The loop raised on the first missing file. With a continue replaced by an immediate error, copied could never differ from the want count by the time the gate ran — the gate was unreachable in the control-flow sense. Collecting the misses and letting the loop finish is what gives the gate something to test, and it also means one run names every missing file.
And the output directory was not cleared. This is the finding worth the section. Two scripts, both with write_netlist removed from the report phase so the netlist is genuinely never produced, each run after a previous run that did leave a netlist behind — which is the ordinary state of a work directory. The three steps below are one annotated sequence, with each script's own exit status and archive line quoted from its run:
1. a previous run that DID write a netlist -> exit 0
netlist present: work/synth/core.v 107 bytes
2a. tonight, with the directory cleared first:
bin/synth_nonetlist.tcl -> exit 1
archive: FAILED after 4ms — gate after 'archive':
archived 2 of 3 files; missing: core.v
2b. tonight, without clearing:
bin/synth_nonetlist_old.tcl -> exit 0
archive 3 file(s) + MANIFEST 5 msSame missing netlist. Same gate. The precondition matters and is worth stating plainly: run synth_nonetlist_old.tcl on a clean tree and it fails correctly. It only passes when a previous run left a netlist there — which is exactly the situation every real work directory is in.
The only difference between those two scripts is one line in the report phase:
file delete -force $dir
file mkdir $dirWithout it, the archive finds last night's core.v still sitting in the output directory, copies it, counts three of three, and ships it as this run's hand-off. Exit 0 — and the manifest it writes says this:
top core
tcl 8.5.9
file core.sdc 58 bytes
file core.v 107 bytes
file summary.tsv 78 bytesThree files, all present, sizes recorded. The core.v in it was produced by a different run. Nothing in the manifest, the log, or the exit status says so.
That is worse than a missing file, because a missing file is noticed. Chapter 29.2 is the same problem one level up — an input older than what produced it — and Chapter 30.5 §7 meets it again in a report collector that cannot tell a stale step report from a fresh one. The rule each time is identity, not counting: a check that counts files cannot distinguish this run's output from any other run's.
9. Every failure path, made to fire
Six deliberately broken copies of the driver live beside it. All were run:
| What was broken | Where it stopped | Phases | Exit |
|---|---|---|---|
| nothing — the real design | ran to completion | 7 | 0 |
effort key deleted | require, before any work | 0 | 1 |
rtl_dir points at an empty directory | the glob gate | 1 | 1 |
sdc path does not exist | the tool, inside constrain | 4 | 2 |
effort extreme | the tool, inside compile | 5 | 2 |
write_netlist removed | the archive gate | 7 | 1 |
write_netlist removed and no file delete | nowhere — it passed | 7 | 0 |
The last two rows are the same defect, and only one of them is detectable. That is the measurement §8 is built on.
Notice also that the exit statuses separate cleanly: 1 for the three gate-and-config failures, 2 for the two tool failures — which is the classification §6's -errorcode is protecting. Collapse that, and rows 3 and 4 become indistinguishable to the caller.
10. Where Part I shows up
The registry asks for this explicitly, and the answer is that there is almost nothing else in the script.
| Construct | Where it appears | Chapter |
|---|---|---|
dict create, dict exists, dict get | the configuration and its contract | 7.2 |
catch returning a code, not a boolean | the phase wrapper (§4) | 14.3 |
error with a structured errorCode | gate and require | 14.2 |
return -code error -errorcode | re-raising without losing the class (§6) | 14.6 |
uplevel 1 | running a phase body in the caller's scope | 11.5 |
info script, file normalize, file dirname | locating the project root (§3) | 12.11 |
glob -nocomplain, lsort | the file list, deterministically ordered | 12.12 |
file delete -force, file mkdir, file copy | the output directory and archive (§8) | 12.9 |
open / puts / close | summary.tsv and MANIFEST | 12.4 |
format for aligned columns | the phase and runtime tables (§7) | 5.11 |
exit with a meaningful status | the entry point (§6) | 15.4 |
Not one construct above is beyond Part I. What makes this a production driver is §4, §6 and §8 — and all three are places where correct-looking Tcl does something else.
Exercises
- Write a
step-style wrapper usingif {$code}and give it a body ending inreturn. What status does it report, and what happens if you test it with an empty body instead? - Take a
procof yours that re-raises an error. Print$::errorCodebefore and after. Is the classification still there? - Read
$::errorCodeat the top of a fresh script that has not raised. What happens? - Replace a main guard that compares names with one that compares normalized paths. Copy the script to a new name and run both versions.
- Find a flow script with a timing or share breakdown. Run it three times on identical input and compare the percentages. Should it be apportioning at all?
- Find a gate in your own flow that has never fired. Work out whether it can.
- Delete the output-directory clear from a flow you own, remove one output, and run it. Does it pass?
- Your archive gate counts files. What question would it need to ask instead to notice a file from last night's run?
- Which of your flow's failures deserve different exit statuses, and what would the caller do differently with each?
Where this fits
Part of the Tcl curriculum.
