awk Part III · Module 10 · Timing and STA Report Analysis
Parsing Multi-Line EDA Reports
Every chapter in this module carried state across records, and the obvious objection is that awk already has a feature for multi-line records: set RS to the empty string and each blank-line-separated block becomes one record, fields and all. This chapter takes that objection seriously and measures it. On a compact report paragraph mode is the better tool and the shorter program. On the same report with its delay tables left in — which is what a timing tool actually emits — the identical program extracts nothing at all, because one blank line inside each record is enough to halve it.
Advanced19 min readawkPart IIISTAParagraph ModeRS
Part III · Module 10 · Chapter 10.7 · Parsing Multi-Line EDA Reports
1. Why This Matters
Chapters 10.2 through 10.6 all have the same skeleton: remember something from a header line, use it when the slack line arrives, clear it. That skeleton exists because a timing path spans many lines and awk processes one line at a time.
awk has a feature that appears to remove the need for it. Setting RS to the empty string puts awk in paragraph mode, where a record is a block of text delimited by blank lines rather than a single line. A whole timing path in one record, every field addressable, no carried state, no clearing, no mispairing.
It is the right answer for some report formats and it fails completely on others, and the line between the two is thinner than it looks. This chapter closes the module by measuring where that line is.
2. Paragraph Mode
alpha:beta
gamma:deltaawk 'BEGIN { RS = ""; FS = ":" } { printf "NF=%d:", NF; for (i=1;i<=NF;i++) printf " [%s]", $i; print "" }' two.txtNF=4: [alpha] [beta] [gamma] [delta]Two lines became one record with four fields. The colon split each line, and — this is the part that surprises people — the newline also acted as a field separator, in addition to FS. That behaviour is specific to paragraph mode and it is what makes the whole record addressable by field number.
The most useful variant sets FS to a newline, which makes each field a whole line:
awk 'BEGIN { RS = ""; FS = "\n" } { printf "NF=%d $1=[%s] $NF=[%s]\n", NF, $1, $NF }' two.txtNF=2 $1=[alpha:beta] $NF=[gamma:delta]NF is now the record's line count and $1, $2, $NF address lines. For a report whose records are fixed-shape blocks, that is a very direct way to work.
3. RS = "" Is Not RS = "\n\n"
Before using either, the difference is worth measuring, because the literal form looks equivalent and is not.
a 1
b 2
c 3
d 4printf 'RS="" : '; awk 'BEGIN { RS = "" } { printf "[rec %d: %d field(s)]", NR, NF } END { print "" }' gaps.txt
printf 'RS="\\n\\n" : '; awk 'BEGIN { RS = "\n\n" } { printf "[rec %d: %d field(s)]", NR, NF } END { print "" }' gaps.txtRS="" : [rec 1: 4 field(s)][rec 2: 4 field(s)]
RS="\n\n" : [rec 1: 4 field(s)][rec 2: 0 field(s)][rec 3: 4 field(s)]Paragraph mode collapses a run of blank lines into one separator. The literal two-newline form does not, and produces an empty record for the extra blank line.
Leading and trailing blank lines differ too:
The fixture has three blank lines before the first block, one between the two blocks, and two after — a shape a fence cannot carry reliably, so it is built with printf:
printf '\n\n\nfirst\n\nsecond\n\n\n' > pad.txt
printf 'RS="" : '; awk 'BEGIN { RS = "" } { printf "[%s]", $0 } END { printf " -> %d record(s)\n", NR }' pad.txt
printf 'RS="\\n\\n" : '; awk 'BEGIN { RS = "\n\n" } { printf "[%s]", $0 } END { printf " -> %d record(s)\n", NR }' pad.txtRS="" : [first][second] -> 2 record(s)
RS="\n\n" : [][
first][second][
] -> 4 record(s)Two clean records against four, two of which are junk. And the field splitting differs as well:
awk 'BEGIN { RS = "\n\n"; FS = ":" } { printf "RS literal NF=%d:", NF; for (i=1;i<=NF;i++) printf " [%s]", $i; print "" }' two.txtRS literal NF=3: [alpha] [beta
gamma] [delta
]NF=3, and the second field spans a line break — beta and gamma in one field — because the literal RS does not make a newline a field separator. Compare that against the NF=4 from section 2 on the same input.
4. The Measurement
Two reports. hidden.rpt from Chapter 10.6 is compact — seven lines per path, no blank line inside a path. timing.rpt from Chapter 10.1 is what a timing tool emits — the same headers plus the delay table, with a blank line between the header block and the table.
printf "full report (delay tables): "; awk 'BEGIN { RS = "" } { n++ } /Startpoint/ && /slack/ { b++ } END { printf "%d paragraph(s), %d containing BOTH a Startpoint and a slack\n", n, b+0 }' timing.rpt
printf "compact report (no tables): "; awk 'BEGIN { RS = "" } { n++ } /Startpoint/ && /slack/ { b++ } END { printf "%d paragraph(s), %d containing BOTH a Startpoint and a slack\n", n, b+0 }' hidden.rptfull report (delay tables): 7 paragraph(s), 0 containing BOTH a Startpoint and a slack
compact report (no tables): 3 paragraph(s), 3 containing BOTH a Startpoint and a slackZero against three. On the full report not one paragraph contains both a path's identity and its answer, because the blank line before the delay table splits every path into two paragraphs: a header block and a table block, and the slack line is at the end of the table.
Now one program against both inputs:
BEGIN { RS = "" }
{
s = ""; sl = ""
for (i = 1; i < NF; i++) {
if ($i == "Startpoint:") s = $(i + 1)
if ($i == "slack") sl = $NF
}
printf "para %d: %-22s %8s\n", NR, (s == "" ? "<none>" : s), (sl == "" ? "<none>" : sl)
}awk -f para.awk hidden.rptpara 1: u_regfile/dout_reg[3] -0.018
para 2: u_periph/req_reg[0] -0.205
para 3: u_io/pad_reg[0] -0.023Three paths, correct. The field scan is the paragraph-mode idiom: walk the fields looking for a marker and take the one after it — which works because the newline-as-field-separator rule put every word of the record into the field list.
The same program on the full report:
awk -f para.awk timing.rptpara 1: <none> <none>
para 2: u_regfile/dout_reg[3] <none>
para 3: <none> -0.018
para 4: u_ctrl/state_reg[1] <none>
para 5: <none> 0.067
para 6: u_io/pad_reg[0] <none>
para 7: <none> -0.023Seven rows and not one of them is a path. The names and the numbers are all present and correct, in alternating records, and nothing pairs them. Every value the program needs is in the file; the record boundary is in the wrong place.
And the state-carrying form from Chapter 10.2:
/^ *Startpoint:/ { s = $2 }
/^ *slack/ { printf "%-22s %8s\n", (s == "" ? "<none>" : s), $NF; s = "" }awk -f state.awk hidden.rpt
awk -f state.awk timing.rptu_regfile/dout_reg[3] -0.018
u_periph/req_reg[0] -0.205
u_io/pad_reg[0] -0.023
u_regfile/dout_reg[3] -0.018
u_ctrl/state_reg[1] 0.067
u_io/pad_reg[0] -0.023Three correct rows from each. Two lines of awk, and the blank lines are irrelevant to it because it never used them for anything.
5. Choosing
Step 3 is the cheap one-liner worth running before anything else:
for f in hidden.rpt timing.rpt; do printf "%-12s " "$f"; awk 'BEGIN { RS = "" } { n++ } /Startpoint/ && /slack/ { b++ } END { printf "%d record(s), %d with both ends -> %s\n", n, b+0, (b == n ? "paragraph mode is safe" : "PARAGRAPH MODE WILL NOT WORK") }' $f; donehidden.rpt 3 record(s), 3 with both ends -> paragraph mode is safe
timing.rpt 7 record(s), 0 with both ends -> PARAGRAPH MODE WILL NOT WORKThree records and three usable against seven records and none usable. The verdict needs no judgement: if the number of records carrying both ends is not the number of records, the boundary is in the wrong place and the mode cannot be used. Running that one line against a real report costs nothing and replaces an assumption about the format with a measurement of the file.
6. Where Paragraph Mode Wins
The case for it is not brevity in general — state.awk is two lines. It is a condition over the whole record:
awk 'BEGIN { RS = ""; FS = "\n" } /clk_periph/ && /clk_ddr/ { print "---- cross-domain block ----"; print $0 }' hidden.rpt---- cross-domain block ----
Startpoint: u_periph/req_reg[0]
(rising edge-triggered flip-flop clocked by clk_periph)
Endpoint: u_mem/wdata_reg[0]
(rising edge-triggered flip-flop clocked by clk_ddr)
Path Group: clk_ddr
Path Type: max
slack (VIOLATED) -0.205One pattern — two regexes joined by && — finds the block that mentions both clocks and prints it whole. Chapter 10.6 needed a stage variable, two carried clock names and a positional parse to reach the same conclusion.
Counting how many clocks each block mentions is a single rule:
awk 'BEGIN { RS = "" } { n = 0; if (/clk_core/) n++; if (/clk_ddr/) n++; if (/clk_io/) n++; if (/clk_periph/) n++; printf "block %d mentions %d clock(s)\n", NR, n }' hidden.rptblock 1 mentions 1 clock(s)
block 2 mentions 2 clock(s)
block 3 mentions 1 clock(s)The line-oriented equivalent has to accumulate the block by hand:
awk '/^ *Startpoint:/ { blk = $0; next } /^ *slack/ { blk = blk "\n" $0; nc = 0; if (blk ~ /clk_core/) nc++; if (blk ~ /clk_ddr/) nc++; if (blk ~ /clk_io/) nc++; if (blk ~ /clk_periph/) nc++; printf "block %d mentions %d clock(s)\n", ++n, nc; blk = ""; next } { blk = blk "\n" $0 }' hidden.rptblock 1 mentions 1 clock(s)
block 2 mentions 2 clock(s)
block 3 mentions 1 clock(s)Same answer, three rules instead of one, with a hand-built accumulator and two next statements to keep the header and slack lines from being appended twice.
And FS = "\n" makes a fixed-shape block addressable by line:
awk 'BEGIN { RS = ""; FS = "\n" } { printf "block %d: %d line(s); line 1 = [%s]; last = [%s]\n", NR, NF, $1, $NF }' hidden.rptblock 1: 7 line(s); line 1 = [ Startpoint: u_regfile/dout_reg[3]]; last = [ slack (VIOLATED) -0.018]
block 2: 7 line(s); line 1 = [ Startpoint: u_periph/req_reg[0]]; last = [ slack (VIOLATED) -0.205]
block 3: 7 line(s); line 1 = [ Startpoint: u_io/pad_reg[0]]; last = [ slack (VIOLATED) -0.023]$1 is the startpoint line and $NF is the slack line, addressed directly, with no state and no pattern matching — which is exactly what paragraph mode is for, on a file whose blocks are reliable.
7. Common Mistakes
Assuming a blank-line record boundary is part of the format. It is usually presentational — section 4.
Using RS = "\n\n" as an explicit paragraph mode. It yields empty records, keeps leading and trailing blanks, and does not split fields on newlines — section 3.
Not counting the records before trusting them. Paragraph mode fails silently with well-formed output — section 5.
Expecting FS alone to split a paragraph-mode record. A newline splits too, which is the behaviour that makes the field scan work.
Forgetting that FS = "\n" makes NF a line count. Useful, and easy to misread as a field count.
Mixing modes in one program. RS is global; a file with a line-oriented preamble needs two passes.
Reaching for paragraph mode to avoid carried state. State-carrying was two lines and worked on both shapes — section 4.
Dismissing paragraph mode for everything. A condition over the whole record is one pattern instead of three rules — section 6.
8. Exercises
Exercise 1 — Count the paragraphs
awk 'BEGIN { RS = "" } END { printf "%d paragraph(s)\n", NR }' hidden.rpt
awk 'BEGIN { RS = "" } END { printf "%d paragraph(s)\n", NR }' timing.rpt3 paragraph(s)
7 paragraph(s)Three paths read as three records in one file and as seven in the other.
Exercise 2 — Address lines by number
awk 'BEGIN { RS = ""; FS = "\n" } { printf "block %d: %d line(s), first [%s]\n", NR, NF, $1 }' hidden.rptblock 1: 7 line(s), first [ Startpoint: u_regfile/dout_reg[3]]
block 2: 7 line(s), first [ Startpoint: u_periph/req_reg[0]]
block 3: 7 line(s), first [ Startpoint: u_io/pad_reg[0]]Exercise 3 — Debug a parser that extracts nothing
A paragraph-mode extractor returns seven rows from a three-path report, each missing half its data.
awk -f para.awk timing.rpt | head -3
awk 'BEGIN { RS = "" } /Startpoint/ && /slack/ { b++ } END { printf "%d record(s) contain both ends\n", b+0 }' timing.rptpara 1: <none> <none>
para 2: u_regfile/dout_reg[3] <none>
para 3: <none> -0.018
0 record(s) contain both endsSeven records and none of them holds both a startpoint and a slack. Anchoring the patterns to the markers confirms it and shows where the halves went:
awk 'BEGIN { RS = "" } /(^|\n) *Startpoint:/ { st++ } /(^|\n) *slack \(/ { sl++ } /(^|\n) *Startpoint:/ && /(^|\n) *slack \(/ { both++ } END { printf "records with a startpoint %d, with a slack %d, with BOTH %d\n", st+0, sl+0, both+0 }' timing.rptrecords with a startpoint 3, with a slack 3, with BOTH 0Three records hold a startpoint, three hold a slack, and none holds both. The blank line before each delay table put the boundary between them.
awk -f state.awk timing.rptu_regfile/dout_reg[3] -0.018
u_ctrl/state_reg[1] 0.067
u_io/pad_reg[0] -0.023Exercise 4 — Debug RS = "\n\n"
A parser using the literal two-newline separator reports more records than the file has blocks.
awk 'BEGIN { RS = "\n\n" } { printf "[rec %d: %d field(s)]", NR, NF } END { print "" }' gaps.txt
awk 'BEGIN { RS = "" } { printf "[rec %d: %d field(s)]", NR, NF } END { print "" }' gaps.txt[rec 1: 4 field(s)][rec 2: 0 field(s)][rec 3: 4 field(s)]
[rec 1: 4 field(s)][rec 2: 4 field(s)]The extra blank lines produce a record with no fields. Paragraph mode collapses the run.
Exercise 5 — Engineering application
A reader that measures the file before choosing, and refuses rather than guessing.
# readmode.awk — report whether a file can be read in paragraph mode.
#
# Usage: awk -f readmode.awk hidden.rpt timing.rpt
# Exit: 0 every file is safe for paragraph mode
# 1 at least one file is not
BEGIN { RS = "" }
FNR == 1 && NR > 1 { report() }
FNR == 1 { file = FILENAME; n = 0; st = 0; sl = 0; both = 0 }
{
n++
hs = ($0 ~ /(^|\n) *Startpoint:/)
hl = ($0 ~ /(^|\n) *slack \(/)
st += hs
sl += hl
both += (hs && hl)
}
END { report() ; exit (bad ? 1 : 0) }
function report() {
printf "%-14s %2d record(s), %2d with a startpoint, %2d with a slack, %2d with BOTH -> %s\n",
file, n, st, sl, both,
(both == n && n > 0 ? "paragraph mode" : "carried state")
if (!(both == n && n > 0)) bad = 1
n = 0; st = 0; sl = 0; both = 0
file = FILENAME
}awk -f readmode.awk hidden.rpt timing.rpt
echo "status: $?"hidden.rpt 3 record(s), 3 with a startpoint, 3 with a slack, 3 with BOTH -> paragraph mode
timing.rpt 7 record(s), 3 with a startpoint, 3 with a slack, 0 with BOTH -> carried state
status: 1Two files, two verdicts, from a measurement rather than an assumption about the format. The FNR == 1 && NR > 1 rule is the per-file flush from Chapter 7.4 section 7 — in paragraph mode FNR counts records rather than lines, so it still identifies the first record of each file.
Note the exit status is 1 because one file needs carried state. That is the useful polarity for a wrapper script: a non-zero status means do not use the paragraph-mode path for this set of inputs, which is a decision, not an error.
Exercise 6 — Challenge: show that the paragraph-mode failure passes a reasonable sanity check
A reviewer proposes validating the paragraph-mode parser by checking that it emits one row per record and that every slack value it prints appears in the report. Show that both checks pass on the broken run.
awk -f para.awk timing.rpt | wc -l
awk 'BEGIN { RS = "" } END { print NR }' timing.rpt 7
7Seven rows for seven records — the first check passes exactly.
awk -f para.awk timing.rpt | awk '$NF != "<none>" { v++; while ((getline l < "timing.rpt") > 0) if (index(l, $NF)) { f++; break }; close("timing.rpt") } END { printf "%d slack value(s) printed, %d found in the report\n", v+0, f+0 }'3 slack value(s) printed, 3 found in the reportEvery value the parser printed is genuinely in the report. The second check passes too.
Both checks pass, and the parser produced not one correct path. What neither check looks at is whether a row's two halves came from the same path — and they cannot, because each half is individually valid.
awk 'BEGIN { RS = "" } /(^|\n) *Startpoint:/ && /(^|\n) *slack \(/ { both++ } END { printf "records carrying both halves: %d\n", both+0 }' timing.rptrecords carrying both halves: 0One number, measured on the input rather than the output, settles it. Zero records carry both halves, so no correct row was possible and the seven that appeared are all artefacts.
That is the module's closing lesson, and it has now been reached from five directions: Chapter 10.2's count of startpoints against slack lines, Chapter 10.3's per-row comparison of two methods, Chapter 10.4's Path Type count against the path count, Chapter 10.6's startpoint count against endpoint count, and this. A multi-line parser is validated against the structure of its input, never against the shape of its output — because the output's shape is determined by the program, and a wrong program produces a well-formed wrong answer.
9. Summary
RS = ""is paragraph mode: records are blank-line-delimited, runs of blank lines collapse, leading and trailing blanks are ignored, and a newline is a field separator in addition toFS.RS = "\n\n"is none of those things. It yields empty records and fields that span lines — section 3.FS = "\n"with paragraph mode makesNFa line count and$1,$NFaddress lines.- A blank line inside a record halves it. On the full timing report, zero of seven paragraphs carried both a path's identity and its slack — section 4.
- One program, two inputs differing by a cosmetic blank line: 3 correct rows and 0. State-carrying gave 3 from both, in two lines of awk.
- The failure is silent. Seven well-formed rows, exit 0, real registers, real slack values, no correct path.
- Measure before choosing. Count the records and count how many carry both ends — section 5.
- Paragraph mode wins on a condition over the whole record — one pattern against three rules with a hand-built accumulator — section 6.
- Validate a multi-line parser against the structure of its input. A row count and a value lookup both passed on a run with no correct rows — Exercise 6.
That closes Module 10 and with it the hardest parsing problem in this track: a record that spans lines has no boundary awk can see, so every answer depends on something the program decided rather than something the file said. Every chapter here ended at the same place — a precondition counted on the input, before the parse is trusted.
Where this fits
Part of the awk curriculum.
