Python Part II · Module 18 · VLSI Files, Paths and Project Structures
RTL, Verification and Run Directory Trees
A flow script spends most of its life answering questions about where things are: which files to compile, where last night's logs went, where this run's output should land. Hard-coding the answers works on exactly one project and is the single most common reason a script cannot be reused. The alternative is not cleverness — it is knowing the small number of shapes real projects actually use, understanding the one distinction that governs what a script is allowed to do to each directory, and anchoring everything to a single value so that moving to a new project changes one line rather than forty.
Intermediate14 min readPythonPart IIPathsProject StructureVerification Flow
Part II · Module 18 · Chapter 18.4 · RTL, Verification and Run Directory Trees
1. The Shape
Most RTL projects converge on something close to this, whatever the names:
project/
├── rtl/ design sources .sv .v .vhd
│ └── generated/ produced by a tool DO NOT compile by hand
├── tb/ verification environment .sv
│ └── tests/ the test library
├── bin/ flow scripts .py
├── runs/ one directory per run created, not committed
│ └── smoke_1234/
│ ├── logs/
│ └── waves/
└── reports/ summaries for humans created, not committedThe names vary — dv/ for tb/, scratch/ or work/ for runs/, src/ for rtl/ — and the shape does not. A design area, a verification area, a place scripts live, a place runs land, and a place summaries land.
2. The Distinction That Matters
Five directories, and they divide into two kinds:
Committed directories are inputs. rtl/, tb/ and bin/ are in version control. A script reads them, discovers files in them, and never writes to them — anything a script creates there will either be committed by accident or deleted by the next git clean.
Generated directories are outputs. runs/ and reports/ are produced by the flow and excluded from version control. A script creates them, writes into them, and — only here — may delete them.
That single distinction is the reason behind most of this module:
| Chapter | Operates on | Because |
|---|---|---|
| 18.5 discovery | the committed side | you find sources; you do not find outputs, you name them |
| 18.7 filelists | reads committed, writes generated | the filelist is an output describing inputs |
| 18.8 creation | the generated side | you never mkdir inside rtl/ |
| 18.9 cleanup | only the generated side | deleting a committed file is a different kind of accident |
Chapter 18.9's whole safety argument is this diagram with the deletion arrow allowed to point at one side only.
3. Why Hard-Coding Fails
The obvious approach, and what it costs:
LOGDIR = "/proj/chipA/runs"
RTL = "/proj/chipA/rtl"
print("works on : chipA, mounted at /proj")
print("fails on : chipB, a colleague's clone, a CI checkout")
print("fails when : the project moves to /proj2 or /nfs/proj")
print("number of lines to change:", 2, "per script, times every script")works on : chipA, mounted at /proj
fails on : chipB, a colleague's clone, a CI checkout
fails when : the project moves to /proj2 or /nfs/proj
number of lines to change: 2 per script, times every scriptThe failure is not that absolute paths are wrong — Chapter 18.1 established they are the reliable kind. It is that the project-specific prefix is repeated, so a script encodes a fact about one checkout in forty places.
Relative paths are not the fix either, for the reason Chapter 18.1 gave: rtl/alu.sv works only when the process happens to be standing in the project root.
4. Anchor Once, Derive Everything
The canonical form, and the idea behind the whole chapter:
ROOT = Path(__file__).resolve().parents[1]
RTL, TB, RUNS = ROOT/"rtl", ROOT/"tb", ROOT/"runs"__file__ is the path of the source file currently executing. resolve() makes it absolute (Chapter 18.1 Section 6), and parents[1] climbs two levels — past the filename, past bin/ — to the project root (Chapter 18.3 Section 4).
So a script in project/bin/check.py computes project/ without being told, and every other path derives from it.
Here it is working, with the script in bin/ exactly as it would be:
module alu; endmoduleclass smoke; endclassfrom pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RTL, TB, RUNS = ROOT / "rtl", ROOT / "tb", ROOT / "runs"
print("the script lives in :", Path(__file__).parent.name)
print("ROOT is its parent :", ROOT.name == Path(__file__).resolve().parents[1].name)
print()
print("rtl exists :", RTL.is_dir())
print("a source under it :", (RTL / "alu.sv").is_file())
print("tb tests exists :", (TB / "tests").is_dir())
print("runs not yet created:", RUNS.exists())
print()
print("every path is absolute:", RTL.is_absolute(), TB.is_absolute(), RUNS.is_absolute())
print("RTL is inside ROOT :", RTL.is_relative_to(ROOT))
print("RTL relative to ROOT :", RTL.relative_to(ROOT))the script lives in : bin
ROOT is its parent : True
rtl exists : True
a source under it : True
tb tests exists : True
runs not yet created: False
every path is absolute: True True True
RTL is inside ROOT : True
RTL relative to ROOT : rtlNote what is printed and what is not. The relationships are shown — that RTL is absolute, that it is inside ROOT, that it is rtl relative to it. The absolute strings are never printed, because they differ on every machine, which is the same property that makes the technique work.
relative_to() is the inverse of / and is genuinely useful for output: a report listing rtl/alu.sv is readable on any machine, where the absolute form is noise.
5. Deriving the Rest
With one anchor, the project's shape becomes a handful of declarations at the top of the file:
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RTL = ROOT / "rtl"
TB = ROOT / "tb"
TESTS = TB / "tests"
RUNS = ROOT / "runs"
REPORTS = ROOT / "reports"
def run_dir(test, seed):
"""Where one run's output belongs."""
return RUNS / f"{test}_{seed}"
def log_path(test, seed):
return run_dir(test, seed) / "logs" / "sim.log"from paths import ROOT, RTL, TESTS, RUNS, run_dir, log_path
for name, p in [("RTL", RTL), ("TESTS", TESTS), ("RUNS", RUNS)]:
print(f"{name:<7} {p.relative_to(ROOT)}")
print()
print("run dir :", run_dir("smoke", 1234).relative_to(ROOT))
print("log :", log_path("smoke", 1234).relative_to(ROOT))
print("waves :", (run_dir("cdc", 7) / "waves").relative_to(ROOT))RTL rtl
TESTS tb/tests
RUNS runs
run dir : runs/smoke_1234
log : runs/smoke_1234/logs/sim.log
waves : runs/cdc_7/wavesA paths module imported by every script in bin/ — Chapter 13.5's own-module import, doing the job it exists for. The project's shape is stated once, and a script that needs a log path asks for one rather than building it.
6. There Is No Standard Tree
The honest limit, and it matters because this chapter has just shown a specific shape.
Projects differ. Some put tests under tb/tests/, some under tests/, some under dv/uvm/tests/. Some have rtl/ and some have design/src/rtl/. A script that hard-codes this chapter's tree is no more portable than one that hard-codes /proj/chipA — it has just moved the assumption one level up.
The portable version takes the shape as configuration:
from pathlib import Path
LAYOUTS = {
"default": {"rtl": "rtl", "tb": "tb", "tests": "tb/tests", "runs": "runs"},
"legacy": {"rtl": "design/src", "tb": "dv", "tests": "dv/uvm/tests", "runs": "scratch"},
}
def project_paths(root, layout="default"):
shape = LAYOUTS[layout]
return {name: root / sub for name, sub in shape.items()}
root = Path("/proj/chipA")
for layout in ["default", "legacy"]:
paths = project_paths(root, layout)
rendered = {k: str(v.relative_to(root)) for k, v in paths.items()}
print(f"{layout:<8} {rendered}")default {'rtl': 'rtl', 'tb': 'tb', 'tests': 'tb/tests', 'runs': 'runs'}
legacy {'rtl': 'design/src', 'tb': 'dv', 'tests': 'dv/uvm/tests', 'runs': 'scratch'}One function, two projects, and the per-project knowledge is a dictionary rather than scattered through the code. Whether that indirection is worth it is the Chapter 1.4 question again: for a script serving one project, the five constants of Section 5 are right; for a tool a team uses across three, the layout table is.
7. Predict, Run, Observe
from pathlib import Path
ROOT = Path("/proj/chipA")
RTL = ROOT / "rtl"
RUNS = ROOT / "runs"
run = RUNS / "smoke_1234"
print("A:", run.relative_to(ROOT))
print("B:", run.relative_to(RUNS))
print("C:", run.is_relative_to(RTL))
print("D:", len(run.parts) - len(ROOT.parts))
print("E:", run.parents[1] == RUNS)
print("F:", (ROOT / "rtl" / ".." / "tb").parts[-3:])A: runs/smoke_1234
B: smoke_1234
C: False
D: 2
E: False
F: ('rtl', '..', 'tb')A and B — relative_to answers "relative to what you ask", so the same path has several correct short forms. Choosing the right one is what makes a report readable.
C — a run directory is not inside rtl/, which is the containment check Chapter 18.9 performs before deleting.
D — subtracting parts lengths gives the depth below the root, which is occasionally useful for limiting a traversal.
E is False, and it is the one to check carefully: run.parents[0] is runs, so parents[1] is chipA — one level further than expected. Indexing parents is off-by-one against intuition because it starts at the immediate parent, and that is exactly the kind of mistake that makes parents[1] in the anchoring idiom worth testing rather than assuming.
F keeps the .., confirming Chapter 18.1's point that a .. component survives in parts until something resolves it — so a containment check run against an unresolved path can be fooled by one.
8. Exercises
Exercise 1 — name your tree. Write down your project's five directories using Section 1's categories. Note which names differ.
Exercise 2 — classify them. For each, say committed or generated. Check one against .gitignore.
Exercise 3 — count the hard-codes. Grep a real script for the project prefix. Count the occurrences; that is how many lines change when the project moves.
Exercise 4 — anchor a script. Put a script in bin/ and compute the root with parents[1]. Verify by checking a known file exists relative to it.
Exercise 5 — off by one. For a path three levels deep, print parents[0], parents[1] and parents[2]. Say which one your anchoring idiom needs and why you should test it.
Exercise 6 — a paths module. Write paths.py exporting five constants and two functions. Import it from a second script.
Exercise 7 — relative_to for reports. Print ten discovered files as absolute paths and as paths relative to the root. Say which you would put in a report.
Exercise 8 — the layout table. Write the two-layout version from Section 6 for your project and one you have worked on before. Say whether the indirection is worth it for your case.
9. Summary
- Projects converge on a shape: design sources, a verification area, a place for scripts, a place runs land, a place summaries land.
- The names vary and the shape does not.
- Committed directories are inputs — read, discovered, never written to.
- Generated directories are outputs — created, written, and the only place deletion is allowed.
- That split is the reason behind most of this module, including Chapter 18.9's safety rule.
- Hard-coding a project prefix repeats one fact in forty places, which is the cost, not the absoluteness.
Path(__file__).resolve().parents[1]computes the root from the script's own location.- Derive everything else from the anchor, in a
pathsmodule imported by the rest. relative_to()is the inverse of/and is what makes a report readable on any machine.- There is no standard tree, so a tool serving several projects should take the shape as configuration — the anchor is the invariant, not the directory names.
The next chapter finds the files inside those directories without naming them one by one.
Where this fits
Part of the Python curriculum.
