2.6 VCD 波型輸出#
原始碼:
veryl-sim/src/sim.rs(Vcd結構、Design::enable_vcd/dump/take_vcd) 上一節:2.5 時脈與測試平台 下一節:2.7 閘級模擬
2.6.1 VCD 格式入門#
VCD(Value Change Dump)是 Verilog 標準(IEEE 1364)附錄定義的文字波形格式,所有波形檢視器(GTKWave、Surfer、WaveTrace…)都支援。一個最小的 VCD 長這樣:
$date ... $end
$timescale 1ns $end
$scope module top $end
$var wire 1 ! clk $end
$var wire 8 " cnt $end
$upscope $end
$enddefinitions $end
#0
x!
bxxxxxxxx "
#5
1!
#10
0!
b00001010 "
三個組成部分:
- 標頭:
$timescale(時間單位)、$scope(層次容器)、$var(變數宣告:寬度、識別碼、名字)。 - 識別碼(identifier code):每個訊號一個短字串(
!、"、#…),後續值變化用它引用,避免重複寫訊號名。 - 值變化區:
#時間戳開一個時間點,其後每行一個變化——單位元用1!,多位元用b0010 "(二進位 + 識別碼)。
2.6.2 veryl-sim 的實作#
VCD 寫入器極小——三個欄位的 struct,輸出累積在記憶體的 Vec<u8>:
// veryl-sim/src/sim.rs
pub struct Vcd {
out: Vec<u8>, // 累積的 VCD 文字
ids: Vec<String>, // 訊號索引 -> 識別碼
last: Vec<Option<Val>>, // 每訊號上次 dump 的值(變化偵測)
}
pub struct Design {
...
pub watched: Vec<usize>,
prev: Vec<u8>, // edge 偵測用的前值(watched 訊號)
vcd: Option<Vcd>, // None = 未啟用
}
標頭由 enable_vcd() 一次寫完:
// veryl-sim/src/sim.rs
pub fn enable_vcd(&mut self) -> std::io::Result<()> {
let mut out = Vec::new();
writeln!(out, "$timescale 1ns $end\n$scope module top $end")?;
let mut ids = vec![];
for (i, s) in self.sim.sigs.iter().enumerate() {
let id = vcd_id(i);
writeln!(out, "$var wire {} {} {} $end", s.w, id, s.name.replace(' ', "_"))?;
ids.push(id);
}
writeln!(out, "$upscope $end\n$enddefinitions $end")?;
self.vcd = Some(Vcd { out, ids, last: vec![None; self.sim.sigs.len()] });
Ok(())
}
設計取捨:
- 每個 Design 一個
$scope module top:veryl-sim 攤平了層次(2.3 節),層次資訊留在訊號全名(u0.core.cnt)裡,直接做成扁平的 var 名。GTKWave 會把u0.core.cnt顯示為一個長名訊號——可讀性略遜於巢狀 scope,但實作只需一個 loop。 - 訊號名中的空格換底線:
s.name.replace(' ', "_")——陣列元素的命名(如mem[0])在精細化時可能帶空格,VCD 的 var 名不允許空白。 wire型別、實際寬度:s.w是邏輯寬度;GTKWave 依此決定顯示為單位元線還是匯流排。
識別碼產生#
// veryl-sim/src/sim.rs
fn vcd_id(mut i: usize) -> String {
let mut s = String::new();
loop {
s.push((b'!' + (i % 94) as u8) as char);
i /= 94;
if i == 0 { break; }
}
s
}
把訊號索引編碼為 94 進位(! 到 ~——VCD 規定識別碼是 ASCII 33–126 的可列印字元):!、"、#、…、!A、!B…。零填充、無衝突、順序穩定(索引 0 永遠是 !),波形檔案在模擬間可重現。
2.6.3 寫入時機:值變化偵測#
dump() 在每個「模擬時間點結束」時被呼叫(run() 的三個位置:t=0 初始化後、每個時脈時間點後、最後收尾):
// veryl-sim/src/sim.rs
fn dump(&mut self) {
let t = self.sim.time;
let Some(v) = self.vcd.as_mut() else { return };
let mut wrote_t = false;
for (i, s) in self.sim.sigs.iter().enumerate() {
if v.last[i] == Some(s.val) { continue; } // 值沒變:跳過
if !wrote_t { let _ = writeln!(v.out, "#{}", t); wrote_t = true; }
if s.w == 1 {
let c = if s.val.x & 1 == 1 { 'x' } else if s.val.v & 1 == 1 { '1' } else { '0' };
let _ = writeln!(v.out, "{}{}", c, v.ids[i]);
} else {
let _ = writeln!(v.out, "b{} {}", s.val.fmt_bin(), v.ids[i]);
}
v.last[i] = Some(s.val);
}
}
要點:
- 時間戳惰性寫入(
wrote_t旗標):只有真的有訊號變化才寫#t,避免大量空時間戳膨脹檔案。 - 變化偵測以整個
Val為單位:last[i] == Some(s.val)比較(v, x, w, s)——x 位元的出現/消失也是變化(VCD 支援x值),偵測粒度與模擬語意一致。 - 同一時間點只有一個快照:delta 週期內的中間值(例如兩層組合之間的過渡)不會進 VCD。這是刻意簡化——VCD 的時間粒度是模擬時間,delta 是時間內部的;多數波形除錯只關心時脈緣後的穩定值。若需要 delta 級波形,得在
settle()每輪加 dump(代價是指數膨脹)。 - x 的編碼:單位元直接
x!;多位元用fmt_bin(),其中 x 位元印x(VCD 允許bx表示全 x,b10x0表示部分 x——GTKWave 兩者都接受)。
sequenceDiagram
participant R as Design::run
participant S as settle()
participant D as dump()
R->>S: t=0 初始化 + initial
S-->>R: 穩定
R->>D: dump() → #0 + 初值
loop 每個時脈時間點
R->>S: 時脈翻轉 + delta 收斂
S-->>R: 穩定
R->>D: dump() → #t + 變化的訊號
end
R->>D: 收尾 dump() + 最終 #t2.6.4 收尾與取用#
// veryl-sim/src/sim.rs
pub fn run(&mut self, max_time: u64) {
...
if self.sim.finished { self.sim.say(&format!("$finish called at time {}", self.sim.time), true); }
self.dump();
if let Some(v) = self.vcd.as_mut() { let _ = writeln!(v.out, "#{}", self.sim.time); }
}
impl Design {
pub fn take_vcd(&mut self) -> Option<Vec<u8>> { self.vcd.take().map(|v| v.out) }
}
模擬結束時補一個最終時間戳(讓波形檢視器知道模擬持續到哪裡,即使最後沒有值變化),然後 take_vcd() 把位元組序列整體交出。輸出在記憶體而非檔案:CLI 把它寫到 --vcd 指定的路徑;wasm 版(web 前端)直接把同一份 Vec<u8> 給瀏覽器下載——一個生產點、兩個消費端,不需要 trait 抽象。
2.6.5 GTKWave 相容性#
GTKWave 對 VCD 的解析相當寬容,但幾個地雷 veryl-sim 都避開了:
| GTKWave 要求 | veryl-sim 處理 |
|---|---|
| 識別碼為可列印 ASCII 33–126 | vcd_id 用 b'!' + i%94,正好落在 33–126 |
| var 名不可含空白 | replace(' ', "_") |
| 值變化須在時間戳之後 | dump 先寫 #t 再寫變化行 |
多位元值 b 前綴 + 空格 + 識別碼 | "b{} {}" 格式 |
| 檔案須以時間戳或 EOF 結束 | 收尾補 #t |
不寫 $date/$version/$comment(皆可選);GTKWave 不需要。$timescale 1ns 是固定值——模擬時間單位本身是抽象的(半週期 5 的「5」沒有物理單位),1ns 只是讓 GTKWave 的時間軸標籤有個單位。
2.6.6 VCD vs FST#
FST(Fast Signal Trace)是 GTKWave 作者開發的二進位格式,專為大型波形設計:
| 面向 | VCD | FST |
|---|---|---|
| 編碼 | 文字 | 二進位 + 依值字典壓縮 |
| 檔案大小 | 基準 | 約 1/10 – 1/50 |
| 寫入速度 | 快(格式化文字) | 快(搭配壓縮) |
| 讀取/隨機存取 | 線性掃描 | 有索引,快 |
| 通用性 | 所有工具 | GTKWave 系(及部分現代工具) |
| 實作複雜度 | ~100 行 | 需要壓縮/索引層(通常鏈結 libfst) |
veryl-sim 選 VCD 的理由:
- 依賴最小:FST 需要外部函式庫或數千行編碼器;VCD 是 30 行的
writeln!。veryl-sim 的定位是輕量模擬器(AGENTS.md:a deliberately smaller simulator)。 - 測試平台的波形不大:RTL 測試動輒數十萬時間點、數千訊號的 VCD 約數十 MB,gzip 後數 MB——可接受。晶片級全波除錯(GB 級)不是它的使用場景。
- 通用性:VCD 可被任何工具(包括
grep與自製腳本)直接處理;測試自動化(例如 tutorial 的檢查腳本)受益。
若未來需要大型波形的互動檢視,合理的路徑是在 CLI 層把 VCD 轉檔為 FST(vcd2fst 工具隨 GTKWave 提供),模擬器本身不動。
2.6.7 實際產出的範例#
以一個半加器 testbench 為例,--vcd ha.vcd 產生的檔案(節錄、加註解):
$timescale 1ns $end
$scope module top $end
$var wire 1 ! a $end ← 訊號索引 0 → 識別碼 "!"
$var wire 1 ! " b $end ← (示意)索引 1 → "\""
$var wire 1 # sum $end
$var wire 1 $ carry $end
$var wire 1 % t_a $end ← 介面/陣列成員的攤平全名
$upscope $end
$enddefinitions $end
#0 ← t=0 快照:initial 跑完後的穩定值
0!
0"
0#
0$
#5 ← 第一個 posedge(半週期 5)
1! ← a 由 0 變 1
0"
1#
0$
#10
1" ← b 由 0 變 1
1#
1$ ← sum=1, carry=1
對照 dump() 的行為即可還原每行的來源:時間戳只在「有至少一個訊號變化」時寫入;每個變化訊號一行;x 值以 x!(單位元)或 bx/b1x0(多位元)出現。
用 VCD 除錯一個綜合 bug 的實例#
假設 RTL 模擬通過、閘級模擬(2.7 節)失敗。打開兩份 VCD 對照:
- 找第一個斷言失敗的時間點(
ASSERTION FAILED at time 35); - 在 GTKWave 中跳到
#35,回溯追訊號錐(cone of logic); - 常見模式:某 FF 的 Q 在 reset 釋放後是
x而非預期值——表示非同步 reset 沒有被網表正確連接(gates.rs的rst解析或綜合器映射出錯); - x 傳播會把整條下游染成 x,波形上一目了然——這正是 4 值閘級模擬(相對 netsim 全 0 上電)的除錯優勢。
為什麼這樣設計#
- 記憶體累積 + take:輸出目的地(檔案/wasm/記憶體)由呼叫端決定,模擬核心不知道 I/O 存在;也讓同一份程式碼同時服務 CLI 與瀏覽器。
- 快照式 dump 而非增量事件流:VCD 本質是「值變化」流,但 veryl-sim 的排程(輪詢式 settle)天然只提供時間點快照;在每個時間點後 diff 上次快照即可重構出合法的 VCD。省去了在
write_bits裡埋 hook 的侵入性。 - x 是一等公民:
last[i]比較整個Val,x 的出現/清除都產生波形事件——x 傳播的可見性(2.4 節)延伸到波形上,這正是 4 值模擬的除錯價值所在。 - delta 不入波形:時間點穩定值是 RTL 除錯需要的粒度;delta 級波形會讓檔案大一到兩個數量級,且 GTKWave 的時間軸也無法表達。
本章重點#
- VCD = 標頭(
$timescale/$scope/$var)+ 識別碼 +#t時間戳下的值變化行;veryl-sim 以Vec<u8>記憶體累積、take_vcd()交出。 - 識別碼是索引的 94 進位編碼(
!~~),零填充且穩定;訊號全名(攤平層次)直接做為 var 名。 dump()在每個模擬時間點後做快照 diff(last: Vec<Option<Val>>),x 進出都算變化;時間戳惰性寫入。- delta 中間值不入波形;時間點穩定值是除錯所需粒度。
- 選 VCD 而非 FST:零依賴、波形規模可控、通用性;大波形可用
vcd2fst後處理。