LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Tcl for EDA Engineers: The Language Every Flow Script Is Secretly Written In

tcledascriptingvlsisynopsyscadencehardwareprogramming

If you work in EDA, you write Tcl. You may not have wanted to — you may have gone into hardware to escape scripting — but Synopsys Design Compiler, PrimeTime, IC Compiler, Innovus, Genus, Tempus, Calibre, Quartus, Vivado, ModelSim, Questa, and just about every major tool exposes its command interface as a Tcl interpreter. When you type report_timing or set_max_delay 3.0 -from A -to B, you are typing Tcl commands. When you write a flow script that runs synthesis and then P&R, you are writing a Tcl program.

Most EDA engineers learn just enough Tcl to get by. They know that set foo 5 assigns 5 to foo, and that $foo references it, and that proc makes a procedure. And then they fight the language for the rest of their careers — subtle bugs with list vs string, mysterious “extra characters after close-brace” errors, confusing quoting rules, surprising behavior when variables contain spaces.

The thing is, Tcl is a small, consistent language once you understand its fundamental rule: everything is a string, and the interpreter runs exactly twelve substitution rules on every command, in a predictable order. Once that clicks, the language stops fighting you. This post is the “learn Tcl properly” version that targets the working EDA engineer — enough to write flow scripts that do not break under weird input, to read and extend other people’s scripts, and to stop confusing curly braces with square brackets.

The one rule that explains everything: Dodekalogue

Tcl’s syntax has exactly twelve rules (Ousterhout called them the “Dodekalogue”). The most important ones for day-to-day work:

  1. A command is a list of words separated by whitespace, terminated by a newline or a semicolon.
  2. Words can be grouped with double quotes ("...") — substitutions happen inside.
  3. Words can be grouped with curly braces ({...}) — substitutions do not happen inside. The braces are stripped and the raw text is the word.
  4. A $name is substituted with the value of the variable name. $name(index) for array elements.
  5. A [command ...] is replaced with the result of executing command.
  6. Backslashes escape: \n, \t, \\, \$.
  7. Each command returns a string.

That is it. The interpreter does substitution once (in the correct order: backslash, command, variable) then invokes the first word as a command with the remaining words as arguments. No implicit evaluation, no hidden quoting, no operator precedence that applies outside expr.

When you internalize “braces suppress substitution; quotes allow substitution; everything is a string until a command parses it differently,” the language stops being weird.

Assignment, variables, and the difference from every other language

1
2
3
4
5
6
7
8
set foo 5        ;# foo = 5
set bar hello    ;# bar = "hello"
set baz "hello world"  ;# baz = "hello world"
set qux {$foo}   ;# qux = literally $foo (not 5) — braces suppress substitution

puts $foo        ;# prints 5
puts "$bar $baz" ;# prints "hello hello world"
puts {$foo}      ;# prints literally $foo

Critical distinction: in most languages, variables have types. In Tcl, variables are strings, but a command can interpret the string as a list, integer, floating-point, dict, or whatever it needs. The set command does not care about type; it just stores the string you gave it. When you later call expr {$foo + 3}, the expr command parses $foo as an integer.

This is liberating (Tcl handles arbitrary precision integers, rational numbers, and bignums correctly) and occasionally painful (you have to be careful with numeric strings that have leading zeros, because 08 looks like octal).

Command substitution

Square brackets run a command and substitute its result:

1
2
3
set now [clock seconds]
set files [glob *.v]
set report [report_timing -nosplit -path full]

Inside an EDA tool, most of what you do is command substitution: take the output of one command (say, all_registers) and feed it to another (say, set_false_path -from). Square brackets are how you compose commands.

1
set_false_path -from [all_inputs] -to [get_pins u_sync/*/D]

Read that as: run all_inputs (returns a list of all input ports), run get_pins ... (returns a list of matching pins), then invoke set_false_path with -from the first list and -to the second.

Lists are strings (usually)

A Tcl list is a string with elements separated by whitespace. {a b c} is a three-element list. So is "a b c". So is a\ b\ c. The list commands — lindex, lrange, lappend, llength, lsort, lsearch — treat the string as a list when they need to:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
set things {apple banana "cherry pie"}
puts [llength $things]        ;# 3 (spaces inside quotes group)
puts [lindex $things 2]       ;# cherry pie
puts [lrange $things 0 1]     ;# apple banana

lappend things date
puts $things                  ;# apple banana {cherry pie} date

foreach x $things {
    puts "item: $x"
}

lappend is special: it is the idiomatic way to grow a list because Tcl can do it in place without reparsing. set things "$things date" works but reformats the list; lappend things date does not.

When EDA tools print a list of things, you are almost always safe to feed it back to another command as a list. The language is designed for this.

Dicts and arrays

Two data structures for key-value work:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Array — elements are addressed with parentheses
set cfg(period) 2.5
set cfg(clock)  CLK
set cfg(group)  CORE

puts $cfg(period)              ;# 2.5
foreach {key value} [array get cfg] {
    puts "$key = $value"
}

# Dict — first-class dictionary, introduced in Tcl 8.5
set d [dict create period 2.5 clock CLK group CORE]
puts [dict get $d period]      ;# 2.5
dict set d period 2.4
dict for {k v} $d {
    puts "$k = $v"
}

Arrays in Tcl have one quirk: they are always global/scoped — you cannot pass an array as a value into a procedure. Dicts can be passed around like any other value, which makes them generally preferable in modern code. Older EDA scripts use arrays everywhere because dicts arrived later.

Procedures

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
proc min {a b} {
    if {$a < $b} {return $a} else {return $b}
}

proc derive_clock_name {block} {
    return "CLK_${block}_0"
}

puts [min 3 7]                 ;# 3
puts [derive_clock_name CPU]   ;# CLK_CPU_0

Arguments are always passed by value. Default arguments and args (variable-length):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
proc report_paths {path_group {limit 10} args} {
    puts "Group: $path_group, limit: $limit"
    foreach flag $args {
        puts "extra flag: $flag"
    }
}

report_paths CORE                    ;# uses default limit
report_paths CORE 5                  ;# overrides limit
report_paths CORE 5 -nosplit -slack  ;# 5 as limit, rest as args

The args parameter receives a list of any remaining arguments — how most EDA tool commands with many optional flags are implemented.

Pass-by-reference with upvar

Because arguments are copied, you cannot modify a caller’s variable directly. upvar gives you access to a variable in the caller’s scope:

1
2
3
4
5
6
7
8
proc increment {varname} {
    upvar 1 $varname v
    incr v
}

set x 5
increment x
puts $x    ;# 6

upvar 1 means “one level up the call stack” (the immediate caller). In a deeper-nested procedure you can go further. This is the idiomatic Tcl way to pass a variable “by reference” — and the cleanest way to work with arrays inside procedures:

1
2
3
4
5
6
7
8
proc populate_config {array_name} {
    upvar 1 $array_name cfg
    set cfg(period) 2.5
    set cfg(clock)  CLK
}

populate_config myconf
puts $myconf(period)    ;# 2.5

uplevel runs a block of code in a specified caller’s scope, which is how commands like set_multicycle_path can modify the tool’s global state from inside a procedure.

Namespaces

As scripts grow, global variables collide. Tcl namespaces give you scoping:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
namespace eval ::myflow {
    variable defaults
    array set defaults {
        period 2.5
        corner TT
    }

    proc setup {block} {
        variable defaults
        puts "Setting up $block with period $defaults(period)"
    }
}

::myflow::setup CPU

Namespaces nest. ::myflow::utils::hash_name is a procedure in the utils namespace inside myflow at the global level. You can namespace import to bring specific commands into the current namespace without the prefix.

In practice, EDA flow scripts tend to live in one global namespace because that is what tools expect and what newcomers can follow. Libraries that want to be reused should use namespaces.

The expr trap

Tcl does not have operator precedence outside the expr command. To evaluate arithmetic or boolean expressions, you must wrap them in expr:

1
2
3
4
5
6
set x 5
set y 3

puts [expr {$x + $y}]          ;# 8
puts [expr {$x * 2 + 1}]       ;# 11
puts [expr {$x > $y ? "big" : "small"}]  ;# big

Always brace the expression. expr $x + $y works (substitutes first, then evaluates), but expr {$x + $y} is both faster (expr compiles braced expressions) and safer (it avoids double-substitution issues when a variable contains something like 1; exec rm -rf /). The EDA code you read in the wild will often have un-braced expressions; the code you write should not.

if and while conditions are evaluated with expr internally, but you still brace them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
if {$x > 0 && $y > 0} {
    puts "both positive"
} elseif {$x == 0} {
    puts "x is zero"
} else {
    puts "mixed"
}

while {$count < 10} {
    ...
    incr count
}

The outer braces are a word group (raw text), which Tcl passes to if/while which then evaluates with expr. This is why if {cond} {body} looks like a language construct; it is actually just a command invocation. There are no reserved words.

Control flow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# for loop
for {set i 0} {$i < 10} {incr i} {
    puts $i
}

# foreach with pair
foreach {name value} [list a 1 b 2 c 3] {
    puts "$name = $value"
}

# break and continue
foreach f $files {
    if {[string match "*.bak" $f]} continue
    if {$f eq "STOP"} break
    process $f
}

# switch
switch -exact -- $mode {
    synth   {do_synth}
    place   {do_place}
    route   {do_route}
    default {puts "unknown mode: $mode"}
}

switch -exact -- is the defensive form. -- ends option parsing (useful if $mode starts with -); -exact means “no pattern matching.” Without it, switch may treat the value as a pattern. Be explicit.

String comparison: eq vs ==

Do not compare strings with ==. Use eq:

1
2
if {$mode eq "synth"} {...}        ;# good
if {$mode == "synth"} {...}        ;# works, but coerces to numbers if possible

For integers, == is the only sensible choice. For strings, eq and ne are the string equality operators introduced in Tcl 8.4 and now universally supported. Mixing them up leads to rare bugs — "0" and "0.0" are numerically equal but not string-equal.

regexp and regsub

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Extract
if {[regexp {^CLK_(\w+)_(\d+)$} $clock_name -> block index]} {
    puts "Block=$block Index=$index"
}

# Replace
set normalized [regsub -all {\s+} $input " "]

# All matches
set matches [regexp -all -inline {\bU\d+\b} $report]

Tcl regexps are POSIX extended by default. regexp returns 1/0 indicating match; any additional arguments capture groups. regsub -all replaces all occurrences.

One thing that trips people up: Tcl’s regex uses \w, \d, etc., but you must brace the pattern so the backslashes do not get eaten by the interpreter’s own substitution:

1
2
regexp {^\d+$} $s         ;# correct
regexp "^\d+$" $s         ;# the \d is interpreted by Tcl first; often breaks

Always use {...} for regex patterns.

File I/O

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Read whole file
set fh [open "inputs.txt" r]
set contents [read $fh]
close $fh

# Line by line
set fh [open "big.txt" r]
while {[gets $fh line] >= 0} {
    if {[regexp {^\s*#} $line]} continue
    process_line $line
}
close $fh

# Write
set fh [open "output.rpt" w]
puts $fh "Report header"
puts $fh "value = $x"
close $fh

gets returns −1 at EOF. read -nonewline strips the trailing newline, which you usually want.

exec runs an external process:

1
2
set top_count [exec wc -l < design.v]
set files [glob -nocomplain *.v]

exec throws on non-zero exit, redirects and pipes look shell-like, and backticks do not exist — use [exec ...].

The module boundary: source, package require

Split large scripts by sourcing other files:

1
2
source utils.tcl
source constraints.tcl

For reusable libraries, use packages:

1
2
3
4
5
6
7
8
# mylib.tcl
package provide mylib 1.2

namespace eval ::mylib {
    proc compute_checksum {data} {
        ...
    }
}
1
2
3
4
5
# user.tcl
lappend ::auto_path /path/to/lib
package require mylib

::mylib::compute_checksum $input

package require searches auto_path for a file matching the package, loads it, and verifies the version. More disciplined than source; useful when multiple scripts or teams share libraries.

Error handling

1
2
3
4
if {[catch {dangerous_operation} result options]} {
    puts "Operation failed: $result"
    puts "Details: [dict get $options -errorinfo]"
}

catch returns 0 on success, 1 on error. result holds the return value or error message. options is a dict with error tracebacks, error codes, etc.

For cleanup, use try...finally (Tcl 8.6+):

1
2
3
4
5
6
7
8
9
try {
    set fh [open "file.txt" r]
    set data [read $fh]
    process $data
} on error {err opts} {
    puts "Error: $err"
} finally {
    catch {close $fh}
}

Tools frequently return error strings or throw Tcl errors on bad input. Wrap calls that might fail and produce a meaningful message for your flow’s log.

Tcl conventions in EDA tools

Every EDA tool extends Tcl with commands, but they follow conventions. Worth recognizing:

  • get_* commands — return collections of objects (pins, cells, nets, clocks). Chained with each other.
  • all_* commands — shorthand for common collections: all_inputs, all_outputs, all_registers, all_clocks.
  • set_* commands — set constraints or properties.
  • report_* commands — print tables.
  • -filter option — common filter syntax across many tools (like get_cells -filter "ref_name =~ NAND*").
  • Collections vs lists — Synopsys tools (PrimeTime, Design Compiler) use a collection object that is not always a real Tcl list. Many commands take either; some distinguish. query_objects, foreach_in_collection exist because of this.
  • Interpolation vs braced expansion — EDA tool commands often have two ways to write a constraint. set_input_delay 2 -clock CLK [get_ports data] vs set_input_delay 2 -clock CLK $data_ports. Both valid.

Writing maintainable flow scripts

A few patterns that survive years of reuse:

Small, composable procs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
proc read_design {block stage} {
    read_verilog [glob rtl/${block}/*.sv]
    current_design $block
    link_design
    puts "Loaded $block at stage $stage"
}

proc apply_constraints {block} {
    source "constraints/${block}.sdc"
    check_timing
}

proc run_synthesis {block} {
    read_design $block pre_synth
    apply_constraints $block
    compile_ultra
    write -format verilog -hierarchy -output "output/${block}_synth.v"
    write_sdc "output/${block}_synth.sdc"
}

Procedures with obvious names. Block-and-stage parameters threaded through. No hidden global state.

Configuration as data, not code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
set ::flow_config [dict create \
    blocks {cpu mem_ctrl uart io_bridge} \
    period_ns 2.5 \
    corners {TT SS FF} \
    effort high \
    report_dir "reports/[clock format [clock seconds] -format %Y%m%d_%H%M]"
]

proc load_config {key} {
    return [dict get $::flow_config $key]
}

foreach block [load_config blocks] {
    run_synthesis $block
}

Build a top-level config dict; consult it from procedures. When the config needs to change for a different run, you change data, not code.

Logging and run metadata

1
2
3
4
5
6
7
8
9
proc log {msg} {
    set t [clock format [clock seconds] -format "%H:%M:%S"]
    puts "\[${t}\] $msg"
    set fh [open "flow.log" a]
    puts $fh "\[${t}\] $msg"
    close $fh
}

log "Starting synthesis of $block"

Write a log that includes timestamps, intermediate metrics, file paths touched. Your future self, debugging a run from two weeks ago, will thank you.

Defensive constraint application

1
2
3
4
5
6
proc safe_set_false_path {args} {
    if {[catch {eval set_false_path $args} err]} {
        puts "WARNING: set_false_path failed with args: $args"
        puts "  $err"
    }
}

When constraints reference signals that may or may not exist in the current design (because of variant builds, conditional IP, etc.), wrap them in a defensive procedure that logs the failure instead of aborting the entire flow.

Script boundaries: what is a “good” Tcl file

Shape your scripts so each file has one purpose:

  • env.tcl — set up environment variables, library paths, tech settings.
  • sources.tcl — list of RTL and SDC inputs.
  • constraints_<block>.sdc — design constraints for one block.
  • flow_<stage>.tcl — the script that drives one stage (synth, place, cts, route, signoff).
  • utils.tcl — shared procedures.
  • report_<stage>.tcl — post-stage reports.

Avoid one-giant-script-per-project. A 2000-line Tcl file is a future nightmare; five 400-line files with clear boundaries is a team asset.

Common Tcl traps that bite EDA engineers

Unintended string-list conversion

1
2
3
set name "cpu core"       ;# 2 words
set cell_list $name        ;# list of 2 elements: "cpu" and "core"
get_cells $cell_list       ;# fails: looking for cell "cpu" and cell "core"

Fix: quote when you mean a single string, brace or list-ify when you mean a list.

Forgotten expr braces

1
2
3
4
5
6
7
set period 2.5
set half [expr $period / 2]       ;# works
set half [expr "$period / 2"]     ;# works
set expr_str "$period / 2"
set half [expr $expr_str]         ;# works
# but:
set half [expr {$expr_str}]       ;# returns the literal string!

Braces suppress substitution, so expr {$expr_str} evaluates the string $expr_str as an expression, which means “the variable expr_str.” Not a math expression. Confusing. Rule: brace only literal expressions you write at the call site.

if condition in strings

1
2
3
set cond "$x > 0"
if $cond {...}                    ;# works — substitution gives {if $x > 0 {...}}
if {$cond} {...}                  ;# FAILS — cond is a string, not expr-parseable

Use expr $cond inside if if you must do this — or restructure to not store expressions in variables.

File paths with spaces or metacharacters

1
2
3
set path "/some/path with spaces/file.v"
read_verilog $path                ;# fails — interpreted as 2+ files
read_verilog [list $path]         ;# works — single-element list

Always list-ify when passing paths to commands that accept lists.

Global vs local variables

1
2
3
4
5
set foo 1
proc use_foo {} {
    puts $foo                     ;# ERROR — foo is not visible
}
use_foo

Tcl procedures do not see global variables by default. Use global foo or variable foo (for namespace members):

1
2
3
4
proc use_foo {} {
    global foo
    puts $foo
}

In modern code, prefer passing values as arguments to reduce global state.

source vs package require search paths

1
2
source lib/mylib.tcl              ;# relative to cwd
source [file join [file dirname [info script]] lib/mylib.tcl]  ;# relative to script

Always use the [info script]-based pattern for source statements inside a script. Relative paths from cwd break when someone runs your script from a different directory.

Debugging Tcl

Tcl has no built-in debugger in most EDA tools, but the tools do:

  • puts everything. Tcl execution is cheap; printing intermediate values costs nothing.
  • parray arrname prints an array in full.
  • dict for loops print dicts readably.
  • info vars, info procs, info commands, info body procname introspect.
  • trace add variable runs a callback on every write:
1
trace add variable x write [list puts "x is being changed"]
  • return -code error explicitly raises an error with a message.
  • unknown is a built-in Tcl command called when an unknown command is invoked. Can be overridden to log missing commands.

For heavy-duty debugging, TclPro and tclsh -d exist, but in-tool printf is 95% of the workflow.

When to leave Tcl

Tcl’s strengths are composability, simplicity, and being the EDA tool lingua franca. Its weaknesses are everything data-processing-heavy: large data structures, complex string parsing, concurrency, package management.

If you are writing a flow script to drive tools, Tcl is correct. If you are writing a 1000-line report parser, a statistical analyzer, or a complex DRC report summarizer, write it in Python and call it from Tcl via exec:

1
2
set summary [exec python scripts/summarize_drc.py $drc_file]
puts $summary

This is how most modern EDA teams split work: Tcl for orchestration and tool calls, Python for data munging and web UIs, both coexisting by exec.

Tcl 8.6 features worth using

If your tool runs Tcl 8.6+ (most modern tools do):

  • try/on/finally — proper exception handling.
  • dict — first-class dictionaries.
  • lmap — functional-style list map.
  • coroutine — cooperative multitasking (useful for concurrent tool commands).
  • apply — anonymous procs / lambdas:
1
2
set square [list x {expr {$x * $x}}]
puts [apply $square 7]           ;# 49

Older flows written for 8.4 work but are more verbose. Newer tool releases have happily moved to 8.6; use the features.

A short reference you can keep

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
set var value                    ;# assign
set var                          ;# read (without substitution)
$var                             ;# substitute (inside command)
[cmd args]                       ;# command substitution
{literal $foo [cmd]}             ;# raw — no substitution
"interpolated $foo [cmd]"        ;# quoted — substitution happens

expr {$a + $b}                   ;# arithmetic (braced!)
if {cond} {body} elseif {c} {b} else {b}
while {cond} {body}
for {init} {cond} {step} {body}
foreach x $list {body}
foreach {k v} $pairs {body}
switch -exact -- $val {a {...} b {...} default {...}}

proc name {args} {body}
return val
return -code error "message"

list a b c                       ;# construct list
lindex $l i                      ;# element at index
llength $l                       ;# length
lappend l x                      ;# append
lrange $l 0 2                    ;# sublist
lsort $l
foreach x $l {...}

dict create k1 v1 k2 v2
dict get $d k
dict set d k v
dict for {k v} $d {...}

array set arr {k1 v1 k2 v2}
$arr(k1)
array names arr
foreach {k v} [array get arr] {...}

regexp {pattern} $s
regexp {pat (cap)} $s -> capvar
regsub {pat} $s "replace"

open f r, w, a                  ;# read/write/append
gets fh line
read fh
puts fh "line"
close fh
exec prog arg1 arg2

upvar 1 $name local              ;# pass-by-reference
uplevel 1 {body}                 ;# run in caller's scope
namespace eval ::ns {...}
package require name
source path

Wrapping up

Tcl is an unassuming language that looks odd the first time you see it and becomes one of your most-used tools once you understand it. Every EDA tool you touch is listening for Tcl. Every flow script your team maintains is Tcl. Every constraint file in your project is — at some level — Tcl.

The two investments that pay off are:

  1. Really understand the substitution rules. The twelve Dodekalogue rules explain why things work the way they do, and once you know them, Tcl stops surprising you.
  2. Use the modern idioms. eq for strings, braced expr, dicts for key-value data, namespaces for libraries, try/finally for cleanup. The language has grown; your code should too.

Do that, and Tcl stops being the thing you fight and becomes the fabric your flows are woven from. And your scripts will still be running — and still being read — five years from now.

Comments