Skip to content
VLSI Mentor

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 nine phases of the synthesis driver. The first two prepare and are not gated: the script locates itself from its own path so it can be run from anywhere, and builds a configuration dictionary whose required keys are checked before any work begins. The remaining seven run through a wrapper that times each phase, records it, and re-raises with its error classification preserved. Read RTL globs the source directory and gates on having found something. Libraries gates on a readable directory. Elaborate gates on a non-zero cell count. Constrain reads the SDC and gates on the number of clocks it actually defined, not on the call having returned. Compile gates on non-zero area. Report clears the output directory, writes the summary and the netlist, and gates on the timing report being non-empty. Archive copies the summary, the netlist and the constraints together with a manifest, and gates on having copied all three. The exit status is then one of three values: zero for success, one for a gate or configuration failure, two for a tool failure.Nine phases: two that prepare, seven that are gated01LOCATEfrom its own path, so it runs from anywhere02CONFIGURErequired keys checked before any work03READ RTLgate: did the glob find anything?04LIBRARIESgate: was a directory readable?05ELABORATEgate: did it elaborate to non-zero cells?06CONSTRAINgate: how many clocks did the SDC define?07COMPILEgate: is the area non-zero?08REPORTclear the directory first, then gate on path count09ARCHIVEgate: were all three files copied?10EXIT STATUS0 success · 1 gate or config · 2 tool
Figure 1 — the nine phases and the gate on each. The first two prepare: the script locates itself so it can be run from anywhere, and checks its configuration before touching the design. The seven that follow each end in a question whose answer can stop the run — and the questions are deliberately about effects rather than about calls returning. The exit status at the bottom distinguishes a design that failed from a flow that could not run.

The clean run, captured:

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

Exit 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

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
synthesis failed: config is missing: effort
phases completed: 0

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

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

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

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

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

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
argv0     = /tmp/p_guard.tcl
normalized argv0      = /private/tmp/p_guard.tcl

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

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

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

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
reading $::errorCode unconditionally: RAISED -> can't read "::errorCode": no such variable

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set ::SHARE_FLOOR_MS 500

Below that total, the table reports milliseconds and refuses to apportion them. Both sides were exercised. At the real total of 11 ms:

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

With the floor lowered to 1 ms and nothing else changed, the same work over four consecutive runs:

runconstraincompilereportarchive
122.2%0.0%38.9%33.3%
212.5%0.0%25.0%50.0%
30.0%10.0%20.0%70.0%
425.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:

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

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

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
file delete -force $dir
file mkdir $dir

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
top	core
tcl	8.5.9
file	core.sdc	58 bytes
file	core.v	107 bytes
file	summary.tsv	78 bytes

Three 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 brokenWhere it stoppedPhasesExit
nothing — the real designran to completion70
effort key deletedrequire, before any work01
rtl_dir points at an empty directorythe glob gate11
sdc path does not existthe tool, inside constrain42
effort extremethe tool, inside compile52
write_netlist removedthe archive gate71
write_netlist removed and no file deletenowhere — it passed70

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.

ConstructWhere it appearsChapter
dict create, dict exists, dict getthe configuration and its contract7.2
catch returning a code, not a booleanthe phase wrapper (§4)14.3
error with a structured errorCodegate and require14.2
return -code error -errorcodere-raising without losing the class (§6)14.6
uplevel 1running a phase body in the caller's scope11.5
info script, file normalize, file dirnamelocating the project root (§3)12.11
glob -nocomplain, lsortthe file list, deterministically ordered12.12
file delete -force, file mkdir, file copythe output directory and archive (§8)12.9
open / puts / closesummary.tsv and MANIFEST12.4
format for aligned columnsthe phase and runtime tables (§7)5.11
exit with a meaningful statusthe 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

  1. Write a step-style wrapper using if {$code} and give it a body ending in return. What status does it report, and what happens if you test it with an empty body instead?
  2. Take a proc of yours that re-raises an error. Print $::errorCode before and after. Is the classification still there?
  3. Read $::errorCode at the top of a fresh script that has not raised. What happens?
  4. Replace a main guard that compares names with one that compares normalized paths. Copy the script to a new name and run both versions.
  5. 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?
  6. Find a gate in your own flow that has never fired. Work out whether it can.
  7. Delete the output-directory clear from a flow you own, remove one output, and run it. Does it pass?
  8. Your archive gate counts files. What question would it need to ask instead to notice a file from last night's run?
  9. 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.