3.1 四態值與 delta cycle#
對應原始碼:
veryl/sim/src/sim.rs(核心、排程器、VCD)、veryl/sim/src/value.rs(四態值)、veryl/sim/src/expr.rs(表示式) 下一節:3.2 測試平台與 initial 區塊的編譯
3.1.1 硬體是並行的,CPU 是序列的#
一個典型的設計裡有上百個 always_comb 與 always_ff 區塊,它們「同時」對訊號驅動。但模擬器一次只能執行一段程式碼。
事件驅動模擬器的解法是把時間切成兩種:
- 模擬時間(simulation time):
#10、時脈週期這種真實的前進,對應$time。 - delta 週期(delta cycle):同一個模擬時間點內部的微觀迭代。每輪讓「上一輪被改變的訊號」傳播到讀取它們的邏輯,直到整個組合邏輯網路穩定。
一個 posedge 到輸出翻轉可能經過 5 層組合邏輯,就是 5 個 delta cycle——但 $time 一動也不動。
Verilog LRM 稱之為 iteration;業界慣稱 delta cycle。
flowchart LR
A["時脈事件<br/>模擬時間 t"] --> B["執行所有<br/>always_ff / initial"]
B --> C["NBA 區域<br/>套用非阻塞指派"]
C --> D["組合邏輯<br/>delta 收斂"]
D --> E{"訊號<br>又變了?"}
E -- 是 --> D
E -- 否 --> F["VCD dump<br/>前進到下一個時間點"]3.1.2 兩種指派語意#
Verilog 的老問題:為什麼有 = 和 <= 兩種指派?
- 阻塞指派(blocking,
=):立刻寫入,下一行馬上看到新值。組合邏輯用它。 - 非阻塞指派(non-blocking,
<=):先「排程」,等所有程序都讀完舊值後再一次套用。序向邏輯用它。
如果 flip-flop 用阻塞指派,q <= d 會立刻把 q 改掉,同一時脈緣上後面讀 q 的另一個 flip-flop 就會看到「穿越一級」的錯誤結果(race condition)。NBA 區域的存在就是為了消除這個 race:先全部讀、再全部寫。
rueda 把這個語意直接放進 IR:
// veryl/sim/src/sim.rs
pub struct Sim {
...
pub nba: Vec<(usize, u32, u32, Val)>, // 排程中的 NBA:(sig, bit lo, width, 值)
}
pub fn assign(&mut self, lv: &LValue, e: &Expr, nb: bool) {
let Some((sig, lo, w)) = self.target(lv) else { return };
let v = self.eval(e, w).resize(w);
if nb { self.nba.push((sig, lo, w, v)); } // <= : 只排程
else { self.write_bits(sig, lo, w, v); } // = : 立即寫
}
pub fn apply_nba(&mut self) {
let q = std::mem::take(&mut self.nba);
for (s, lo, w, v) in q { self.write_bits(s, lo, w, v); }
}
Proc::Ff 的主體透過 self.sim.exec(body, true)(nb = true)執行,所以 flip-flop 內所有指派都是 NBA;Proc::Comb 則用 nb = false。這個分類讓 race condition 在架構上不可能發生——不需要靠使用者紀律。
3.1.3 主迴圈#
Design 是排程器,持有 Sim(訊號狀態)、procs(程序列表)與 inits(initial 區塊的編譯結果)。
// veryl/sim/src/sim.rs Design::settle
pub fn settle(&mut self) {
for _ in 0..1000 {
self.settle_comb(); // (1) 組合邏輯收斂
// (2) 邊緣偵測
// (3) 觸發正反器
// (4) 套用 NBA
}
}
3.1.3.1 組合邏輯收斂#
fn settle_comb(&mut self) {
for it in 0..500 {
// 比較整輪前後的狀態
let before: Vec<(Bits, Bits)> = self.sim.sigs.iter().map(|s| (s.val.v, s.val.x)).collect();
for p in &self.procs { if let Proc::Comb(b) = p { self.sim.exec(b, false); } }
let same = self.sim.sigs.iter().zip(&before).all(|(s, b)| (s.val.v, s.val.x) == *b);
if same { return; }
if it == 499 { /* 警告:組合迴路沒有收斂 */ }
}
}
這裡沒有「事件佇列」。 這是本章最重要的一點。
Verilog 的事件驅動模擬器維護一個精細的事件佇列,只重新求值「輸出改變了」的閘。rueda 的模擬器用的是反覆迭代直到穩定(levelized by iteration)。
為什麼這樣做?
| 事件佇列 | 反覆迭代 | |
|---|---|---|
| 速度 | 快(只碰改變的部分) | 慢(每輪掃全部 Proc::Comb) |
| 實作複雜度 | 高(事件優先序、去重、競態處理) | 低 |
| 迴路偵測 | 需要額外機制 | 天然(迭代不收斂) |
對一個教學與中等規模設計用的模擬器,簡單這個優勢勝出。而且迭代法的迴路偵測特別優雅:迭代 500 次還不穩定,就是有組合迴路。
一個重要的細節:狀態比較是針對整個訊號向量,而不是個別訊號。所以一段程式碼合法地寫兩次同一個訊號(x = 0; x += 1;)不會被誤判為迴路。
3.1.3.2 邊緣偵測#
// 對每個被 watch 的訊號(時脈與非同步重置)比較現在與上一次的值
let mut prev = vec![2u8; n]; // 2 = 「未知」
for &s in &self.watched {
let b = bitv(&self.sim.sigs[s].val);
let o = self.prev[s];
if b != o {
if b == 1 { pos[s] = true; any = true; }
if b == 0 { neg[s] = true; any = true; }
}
self.prev[s] = b;
}
if !any { return; } // 沒有任何邊緣 → 收斂完成
邊緣偵測是靠「比較前後」而不是「事件排程」。這意味著:
- 只有被
watched的訊號才有邊緣偵測(watched收集所有always_ff的時脈與非同步重置); - 下降沿與上升沿都會被偵測,因此
clock_negedge型別的時脈可以正常工作。
3.1.3.3 觸發與 NBA#
for p in &self.procs {
if let Proc::Ff { clk, pos: edge_pos, rst, body } = p {
let mut trig = if *edge_pos { pos[*clk] } else { neg[*clk] };
if let Some((r, low, asynch)) = rst {
if *asynch && ((*low && neg[*r]) || (!*low && pos[*r])) { trig = true; }
}
if trig { self.sim.exec(body, true); } // NB: 非阻塞
}
}
self.sim.apply_nba();
非同步重置會獨立觸發正反器,不需要等時脈——這正確反映了硬體行為(重置接腳是異步的)。
3.1.4 時間與時脈產生器#
時間是一個 u64 計數器。Design::run 用一個 BinaryHeap<Reverse<(u64, usize)>> 驅動時脈產生器:
pub fn run(&mut self, max_time: u64) {
let mut heap: BinaryHeap<Reverse<(u64, usize)>> = BinaryHeap::new();
for (i, p) in self.procs.iter().enumerate() {
if let Proc::ClockGen { half, .. } = p { heap.push(Reverse((*half, i))); }
}
self.sim.time = 0;
self.settle();
for (k) in 0..self.inits.len() { self.run_init(k); }
self.settle();
while !self.sim.finished {
let Some(Reverse((t, _))) = heap.peek().copied() else { break };
if t > max_time { break; }
self.sim.time = t;
while let Some(Reverse((t2, i))) = heap.peek().copied() {
if t2 != t { break; }
heap.pop();
if let Proc::ClockGen { sig, half } = &self.procs[i] {
let cur = self.sim.sigs[*sig].val.v.bit(0);
self.sim.write_bits(*sig, 0, 1, Val::bit(!cur)); // .toggle
heap.push(Reverse((t + *half, i)));
}
}
self.settle();
...
}
}
整個「事件佇列」就是這一個時脈切換的最小堆。 其他所有活動都是從時脈的觀測轉移中產生的。
時脈每 half 個時間單位切換一次(預設 half = 5,即週期 10)。時間單位在 VCD 裡是 1 ns:
$timescale 1ns $end
3.1.5 四態邏輯#
Verilog 的值不只是 0/1,還有 x(未知)與 z(高阻)。
// veryl/sim/src/value.rs
pub struct Val { pub w: u32, pub v: Bits, pub x: Bits, pub s: bool }
v 與 x 是兩個平行的位元平面;不變式是「x 為 1 的位置,v 必為 0」。
flowchart LR
subgraph V["Val { v, x }"]
B0["b0"] --- B1["b1"] --- B2["b2"] --- BN["..."]
end
V0["v: 實際值"] -.-> V
V1["x: 未知遮罩"] -.-> V雙平面表示的好處是運算子可以自然處理未知:
| 運算 | 結果 | 理由 |
|---|---|---|
x & 0 | 0 | 0 與任何值都是 0 |
x \| 1 | 1 | 1 與任何值都是 1 |
x & 1 | x | 未知 |
x ^ x | x | |
三元 c ? a : b,c 為 x | a 與 b 的差異遮罩 | 已知的部分照常,未知的部分保持 x |
不需要為每個運算子寫 16 列的真值表——只需要處理「兩邊都是 1」的位元。
3.1.5.1 x 從哪裡來#
主要有三個來源:
- 未初始化的正反器。
reg [7:0] r;未賦值時所有位元是x。 - 越界的陣列索引。
Expr::Elem(base, idx)超出陣列長度時回傳全x。 - 未驅動的訊號。 宣告但沒有任何程序寫它。
3.1.5.2 為什麼 x 很重要#
x 不是裝飾,它是硬體事實的模型:
- 上電時 SRAM 的內容未知 →
x; - 尚未收到有效資料的匯流排 →
x; - reset 尚未解除的正反器 →
x。
因此 x 讓模擬器能抓到初始化順序的 bug:如果一個正反器在 reset 解除前就被讀取,它的值會是 x,而測試平台可以斷言它不是 x。
$assert(q != 8'hxx, "q must be reset before use");
3.1.6 兩態對照#
模擬器是四態的,但閘級模擬器(eda/src/netsim.rs)是二態的:
/// 一個小小的二態閘級模擬器,在合成後的網表上執行,用來檢查綜合結果
/// (單一時脈域:step = 一個有效邊緣)
pub struct NetSim<'n> {
nl: &'n Netlist,
v: HashMap<u32, bool>,
...
}
為什麼閘級模擬是二態?
- 實際硬體就是二態的(除了老化造成的邏輯 0/1 失效)。閘級模擬的目的是驗證合成的功能等價性,不是重新模擬開機狀態。
- 合成已經把「初始狀態」處理掉了。
optimize假設正反器開機為 0(iCE40 就是如此),並把永遠卡在 0 的正反器折成常數。在這個前提下,四態沒有意義。 - 簡化實作:
boolvsVal(兩個Bits平面)。
這也解釋了為什麼形式等價性檢查要用
-set-init-zero(第 4 章)。
3.1.6.1 閘級模擬的評價引擎#
/// 評價組合邏輯(單元依拓樸順序);非同步重置可以改變正反器,所以重複直到穩定
pub fn settle(&mut self) {
for _ in 0..4 {
for c in &self.nl.cells {
if c.is_ff() || c.typ == "SB_RAM40_4K" || c.typ == "SRAM" || c.typ == "DP16KD" || c.typ == "DIODE" { continue; }
// 評價組合單元
}
// 檢查非同步重置是否改變了正反器;若是,再跑一輪
if !changed { break; }
}
}
for _ in 0..4 是「重複直到穩定」的機制,因為非同步重置可以在掃描過程中改變正反器,進而改變下游組合邏輯,需要再掃一輪。4 次是實務上夠用的 magic number。
單元評價表涵蓋:
| 家族 | 支援的單元 |
|---|---|
| 通用 | $_NOT_ $_AND_ $_OR_ $_XOR_ $_MUX_ |
| vlib / ASIC | INV BUF CLKBUF NAND2 NOR2 AND2 OR2 XOR2 XNOR2 AOI21 AO21 OA21 NAND3 NOR3 AND3 OR3 AOI22 OAI22 OAI21 MUX2 TIEL TIEH |
| iCE40 | SB_CARRY SB_LUT4 SB_RAM40_4K SB_DFF* |
| ECP5 | LUT4 CCU2C TRELLIS_FF DP16KD |
| ASIC | SRAM macro、DIODE |
3.1.6.2 LUT 的評價#
LUT 用真值表查表:
"SB_LUT4" => {
let idx = ["I0", "I1", "I2", "I3"].iter().enumerate()
.fold(0, |a, (i, p)| a | ((g(p, self) as usize) << i));
let init = &c.params.iter().find(|p| p.0 == "LUT_INIT").unwrap().1;
init.as_bytes()[15 - idx] == b'1'
}
注意 15 - idx:LUT_INIT 字串是 MSB 優先的 16 字元二進位串,索引 15 是索引 0。這是 yosys 的慣例,容易弄錯。
相對地,模擬器裡的 LUT 模型(veryl/sim/src/gates.rs)卻是用 mux 樹:
fn tree(init: u128, ins: &[Expr], k: usize, base: usize) -> Expr {
if k == 0 { return Expr::Num(Val::new(1, (init >> base) & 1)); }
let half = 1usize << (k - 1);
let part = |b: usize| (init >> b) & ((1u128 << half) - 1);
if part(base) == part(base + half) { return tree(init, ins, k - 1, base); }
let (lo, hi) = (tree(init, ins, k - 1, base), tree(init, ins, k - 1, base + half));
Expr::Tern(Box::new(ins[k - 1].clone()), Box::new(hi), Box::new(lo))
}
為什麼模擬器要用 mux 樹而不是查表? 因為查表無法處理 x。mux 樹是資料流表達式,求值時自然地把 x 傳播到輸出。這與前面「運算子自然處理未知」的原則一致。
這是同一個語意的第三種實作:RTL 表達式 → mux 樹(模擬器)→ 真值表字串(合成器)→ 查表(閘級模擬器)。三者必須一致,這就是
reda gatesim存在的理由。
3.1.6.3 時脈邊緣偵測#
閘級模擬器的 tick() 用同一個技巧——比較時脈接腳的前後值:
/// 輸入時脈 clk 的一個完整週期(上升,然後下降):只有時脈接腳看到有效邊緣的
/// 正反器 / RAM 才擷取(多個時脈域、負沿正反器、經過緩衝器的時脈)
pub fn tick(&mut self, clk: &str) {
let cells: Vec<Option<(Bit, bool)>> = self.nl.cells.iter().map(Self::clock_of).collect();
self.set(clk, 0);
for level in [1u128, 0] {
let before: Vec<bool> = cells.iter().map(|c| c.map(|(p, _)| self.get_bit(p)).unwrap_or(false)).collect();
self.set(clk, level);
let sel: Vec<bool> = cells.iter().zip(&before).map(|(c, b)| match c {
Some((p, rising)) => { let now = self.get_bit(*p); now != *b && now == *rising }
None => false,
}).collect();
if sel.iter().any(|x| *x) { self.capture(&sel); }
}
}
這個技巧讓 tick() 支援多種時脈拓樿,不需要任何時脈樹分析:時脈可以經過緩衝器、可以被 AND 閘門控、可以是負沿、也可以有好幾個域。
時脈接腳名稱的清單:
let pin = ["C", "CK", "RCLK", "CLK", "CLKB"].iter().find_map(|p| c.port(p))?;
涵蓋 yosys 的 C、vlib 的 CK、iCE40 的 C、ECP5 的 CLK/CLKB。
3.1.6.4 重置語意#
/// (重置接腳, 高有效, 非同步, 值)
fn reset_of(c: &Cell) -> Option<(&'static str, bool, bool, bool)> {
let t = c.typ.as_str();
if t == "TRELLIS_FF" {
// ECP5:LSR 經過 LSRMUX,值來自 REGSET,SRMODE 決定 ASYNC 或同步
c.port("LSR")?;
let p = |k: &str| c.params.iter().find(|x| x.0 == k).map(|x| x.1.as_str()).unwrap_or("");
return Some(("LSR", p("LSRMUX") != "INV", p("SRMODE") == "ASYNC", p("REGSET") == "SET"));
}
// vlib 正反器:低有效非同步重置 / 設定
match t {
"DFFR" | "DFFNR" => return Some(("RN", false, true, false)),
"DFFS" | "DFFNS" => return Some(("SN", false, true, true)),
"DFF" | "DFFN" => return None,
_ => {}
}
if let Some(k) = t.strip_prefix("SB_DFF") {
// iCE40 的重置 / 設定都是高有效
let k = k.trim_start_matches('N');
let (sync, k) = if k.len() == 2 { (true, &k[1..]) } else { (false, k) };
return match k { "R" => Some(("R", true, !sync, false)), "S" => Some(("S", true, !sync, true)), _ => None };
}
...
}
注意每個家族的慣例不同:
| 家族 | 重置接腳 | 極性 | 同步性 |
|---|---|---|---|
iCE40 SB_DFF* | R / S | 高有效 | 看名稱有沒有 S(同步) |
ECP5 TRELLIS_FF | LSR | 看參數 LSRMUX | 看參數 SRMODE |
vlib DFFR / DFFS | RN / SN | 低有效 | 非同步 |
這些差異正是「技術無關的 IR 必須編碼足夠資訊」的證明:光有「有一個重置」是不夠的,必須知道極性與同步性。
3.1.7 記憶體的模擬語意#
閘級模擬器的 capture() 有三個階段,且採用先讀舊值再寫入的慣例:
/// 被選中的正反器與 RAM 取下一個狀態
fn capture(&mut self, sel: &[bool]) {
- 正反器:計算
D(含重置覆蓋),收集到next。 - Block RAM:先讀舊內容到
next_ram,再執行寫入。這模擬 yosyscells_sim.v的「registered read of the old contents, then the write」。 - SRAM macro(ASIC):
RA/WA/WD匯流排從索引化的接腳名讀取(RA[3]、WD[0]),位址對DEPTH取模。
iCE40 SB_RAM40_4K 的位址切分:
word = addr & 0xff
lane = (addr >> 8) & ((1 << rm) - 1)
寫入模式 0 是簡單遮罩寫入;模式 > 0 是每個 lane 的位元位置擺放。
ECP5 DP16KD 是 2048 × 9,埠 A 寫、埠 B 讀(DOB)。
最後:
for (w, d) in next { self.v.insert(w, d); }
for (w, d) in next_ram { self.v.insert(w, d); }
self.settle();
正反器輸出先套用,RAM 輸出後套用,然後重新求值組合邏輯。
3.1.8 初始狀態#
/// 網表上電:正反器為 0,記憶體為其初始內容
/// (通用網表的 $mem:NAME:init 網線、block RAM 的 INIT 參數)
pub fn new(nl: &'n Netlist) -> Self {
...
}
block RAM 的初始內容從 INIT_0..INIT_F 參數解碼:
/// SB_RAM40_4K 的 256 個字,從它的 INIT_0..INIT_F(msb 優先,每個 16 個字)解出
fn ram40_words(c: &Cell) -> Vec<u16> {
let mut ram = vec![0u16; 256];
for (i, name) in crate::bram::INIT.iter().enumerate() {
// ...
for bit in 0..256 { if b[255 - bit] == b'1' { ram[i * 16 + bit / 16] |= 1 << (bit % 16); } }
}
ram
}
load_mem(name, words) 模仿測試平台的 $readmemh:寫進通用網表的 $mem:NAME:wd 正反器,或是 iCE40 block RAM 的 INIT_* 參數,或 ECP5 DP16KD 的初始值。
3.1.9 VCD 輸出#
// veryl/sim/src/sim.rs Vcd
- 檔頭是
$timescale 1ns $end,scopetop,每個訊號一個$var wire <w> <id> <name> $end。 - 識別符用 base-94(
!到~)產生:fn vcd_id(mut i: usize)。 - 只在值改變時輸出,並用一個
#<time>標頭表示時間點。
這是「delta cycle 對使用者的可見性」的來源:在同一個 #time 下可能有多次變化,VCD 會把它們全部記錄下來。
3.1.10 設計取捨總結#
flowchart TB
A["模擬器的三個設計軸"] --> B["時間軸 1:事件佇列 vs 迭代"]
A --> C["時間軸 2:四態 vs 二態"]
A --> D["值軸:平面遮罩 vs 真值表"]
B --> B1["rueda 選:迭代<br/>簡單、迴路偵測自然"]
C --> C1["RTL 模擬:四態<br/>閘級模擬:二態"]
D --> D1["RTL 模擬:mux 樹<br/>閘級模擬:查表"]每個選擇都有代價,也都有理由。 讀程式碼時看到一個「不尋常」的設計,先問「它在換取什麼」——這是讀 EDA 原始碼最有用的姿勢。