4.1 網表 IR 與位元層級 Builder#
對應原始碼:
eda/src/netlist.rs、eda/src/bram.rs、eda/src/output.rs下一節:4.2 最佳化:常數證明與固定點
4.1.1 為什麼 IR 選在這個位置#
一個 EDA 流程有許多個可能的切入點:AST、RTL IR、邏輯網表、閘級網表、版圖。IR 選在哪裡,決定了哪些最佳化可以被做、哪些不能。
rueda 選的位置是閘級網表(yosys 內部單元層級),理由:
- 前端與後端都已經是「硬體」的形狀。 前端不需要知道目標技術;後端不需要知道語言。
- 它是一個公開的、大家都懂的格式。 yosys JSON、Verilog——任何工具都能接上。
- 所有最佳化機會都還在。 LUT 覆蓋(第 5 章)、樹覆蓋(第 6 章)、時序驅動最佳化都在這之上。
被放棄的選項:
| 選項 | 放棄理由 |
|---|---|
| AST | 太早,無法做硬體層面的最佳化 |
RTL IR(保留 always 結構) | 技術映射時必須拆開,浪費一次轉換 |
| 標準單元網表 | 太晚,FPGA 流程需要保持 LUT 抽象 |
4.1.2 資料結構詳解#
/// 一個位元:編譯期常數,或一條網線(net id >= 2,如 yosys JSON)
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)]
pub enum Bit {
C0,
C1,
W(u32),
}
Bit 是 Copy + Hash + Ord,這三個 derive 各有用途:
Copy:值到處傳遞,不用到處 clone。Hash:可以當HashMap的鍵(結構雜湊)。Ord:交換律正規化(a.min(b), a.max(b))與排序輸出都需要。
Bit::W(u32::MAX) 是 NO_OUT 哨兵,表示「這個單元不驅動任何東西」(天線二極體)。
#[derive(Clone, Debug)]
pub struct Cell {
pub typ: String,
/// (接腳名稱, 位元);輸出接腳是 "Y"(閘)、"O"(LUT)或 "Q"(正反器)
pub conns: Vec<(&'static str, Bit)>,
/// 單元參數(如同 LUT_INIT,msb 優先)
pub params: Vec<(&'static str, String)>,
}
接腳名稱是 &'static str,這代表:
- 零配置;
- 合法接腳名的集合在編譯期就固定。
但接腳數量是動態的(不同技術有不同數量的接腳),所以 conns 是一個 Vec 而不是一個固定長度的陣列。
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Dir { In, Out }
#[derive(Clone)]
pub struct Port { pub name: String, pub dir: Dir, pub bits: Vec<Bit> }
#[derive(Clone)]
pub struct Netlist {
pub name: String,
pub ports: Vec<Port>,
/// 具名網線(階層式名稱)
pub nets: Vec<(String, Vec<Bit>)>,
/// 組合單元依拓樸順序,接著是正反器
pub cells: Vec<Cell>,
}
沒有顯式的寬度欄位。 寬度是隱含的:
- 埠的寬度 =
port.bits.len(); - 匯流排接腳
"RADDR[0..10]"= 11 個分開的conns項目。
cells 的順序不變式(「組合在前、正反器在後」)是整個工具鏈的隱藏基石,第 4.1.7 節會說明它撐起了多少東西。
4.1.3 Builder:一個閘一個閘地建構#
pub struct Builder {
pub cells: Vec<Cell>,
pub next_wire: u32,
cache: HashMap<(&'static str, [Bit; 3]), Bit>,
/// x <-> !x,因此雙重否定與 x & !x 會被折疊掉
inv: HashMap<Bit, Bit>,
/// 把多工器折疊進它選擇的兩個相同值之間
/// (`s ? (t ? v : a) : a` = `(s & t) ? v : a`)
pub fold_mux: bool,
muxes: HashMap<Bit, [Bit; 3]>,
}
4.1.3.1 結構雜湊#
fn gate(&mut self, typ: &'static str, ins: &[(&'static str, Bit)]) -> Bit {
let mut key = [Bit::C0; 3];
for (k, (_, b)) in ins.iter().enumerate() { key[k] = *b; }
if let Some(&y) = self.cache.get(&(typ, key)) { return y; }
let y = self.wire();
// ...
self.cache.insert((typ, key), y);
y
}
三輸入簽章統一了不同 arity 的閘:
| 單元 | 使用的鍵 |
|---|---|
$_NOT_ | (typ, [A, C0, C0]) |
$_AND_ | (typ, [A, B, C0]) |
$_MUX_ | (typ, [A, B, S]) |
這是個小技巧,但避免了泛型化 HashMap 鍵的複雜度。
4.1.3.2 常數折疊#
pub fn and(&mut self, a: Bit, b: Bit) -> Bit {
match (a, b) {
(Bit::C0, _) | (_, Bit::C0) => Bit::C0,
(Bit::C1, x) | (x, Bit::C1) => x,
_ if a == b => a,
_ if self.is_inv(a, b) => Bit::C0,
_ => {
let (x, y) = (a.min(b), a.max(b));
self.gate("$_AND_", &[("A", x), ("B", y)])
}
}
}
交換律正規化(a.min(b), a.max(b))是結構雜湊能生效的前提。少了它,a & b 與 b & a 會是兩個 cell。
mux 的真值表折疊更細緻:
match (s, a, b) {
(Bit::C0, _, _) => a,
(Bit::C1, _, _) => b,
(_, Bit::C0, Bit::C1) => s, // 0 ? 1 : 0 == S
(_, Bit::C1, Bit::C0) => self.not(s),
(_, Bit::C0, _) => self.and(s, b),
(_, _, Bit::C1) => self.or(s, a),
// ...
}
4.1.3.3 MUX 折疊#
fold_mux 預設關閉,由 Fluxa 前端開啟。規則是:
為什麼重要? 考慮「在巢狀條件下寫入陣列的一個元素」:
always_ff (i_clk) {
if cond1 { mem[i] = x; }
if cond2 { mem[i] = y; }
}
每一個條件都需要一個 mux: \(n\) 個條件就是 \(n\) 個 mux 串聯(深度 \(n\) )。用折疊規則:
每個位元只需要一個 mux。這是把 mux 鏈變成平衡樹的關鍵最佳化。
4.1.3.4 互補追蹤#
/// x <-> !x,讓雙重否定與 x & !x 能折疊
inv: HashMap<Bit, Bit>,
這個 map 讓 not() 是對稱的:
pub fn not(&mut self, a: Bit) -> Bit {
// 若 !a 已知,直接回傳
// 否則建立新的 not 閘並記錄雙向關係
}
於是:
!!x → xx & !x → 0x | !x → 1x ^ !x → 1
全部在建構時完成,不需要事後的邏輯化簡 pass。
4.1.4 算術元件庫的實作#
4.1.4.1 平衡歸約#
n => {
let (l, r) = a.split_at(n / 2);
let (x, y) = (self.reduce(l, unit, f), self.reduce(r, unit, f));
f(self, x, y)
}
平衡樹 vs 線性鏈:32 位元的 AND,線性鏈深度 32,平衡樹深度 5。這個差異在 ASIC 流程裡直接變成 Fmax。
4.1.4.2 漣波進位加法器#
// p = x^y, g = x&y, pc = p&c, c' = g|pc
這是標準的 ripple-carry。在 FPGA 上它會被進位鏈取代(第 5.1 節),在 ASIC 上它會被技術映射重寫(第 6.1 節)。
4.1.4.3 回復式除法#
pub fn udivmod(&mut self, a: &[Bit], b: &[Bit]) -> (Vec<Bit>, Vec<Bit>)
每一位元一次「減法 + 比較 + 條件選擇」。除數為零時,商是全 1、餘數是被除數。
這個邊界行為必須與硬體一致,所以它不是「錯誤」,而是規格的一部分。
4.1.4.4 對數桶形移位器#
pub fn shift(&mut self, a: &[Bit], amt: &[Bit], left: bool) -> Vec<Bit>
每一個移位量位元對應一個 mux 階段,深度 \(O(\log w)\) 。當某階段的移位量 \(n \ge w\) 時,後續階段飽和,剩餘的量位元被 OR 起來餵給一個「全部移出去」的 mux。
4.1.4.5 一熱解碼#
pub fn decode(&mut self, idx: &[Bit], k: usize) -> Vec<Bit>
idx == k,用與常數比較實作。
4.1.5 記憶體推論的介面#
第 2.3.9 節提過 $mem: 命名約定。這裡看 bram.rs 怎麼用。
pub struct Mem {
pub name: String,
pub depth: usize,
pub width: usize,
pub clk: Bit,
pub we: Bit,
pub waddr: Vec<Bit>,
pub wdata: Vec<Bit>,
pub reads: Vec<ReadPort>,
/// 初始內容(init_words),若有
pub init: Option<Vec<u128>>,
}
pub struct ReadPort {
pub addr: Vec<Bit>,
/// 讀取使能(C1 = 永遠啟用)
pub en: Bit,
/// 每個資料位元的讀取暫存器輸出(None = 該位元未使用)
pub data: Vec<Option<Bit>>,
}
4.1.5.1 幾何條件#
if width == 0 || store.len() % width != 0 { continue 'mem; }
let depth = store.len() / width;
let abits = bits_for(depth);
if depth != 1 << abits || wa.len() != abits { continue 'mem; }
深度必須是 2 的冪,且寫入位址必須恰好是 \(\lceil \log_2 depth \rceil\) 位。
4.1.5.2 兩種讀取形狀#
let (ff, e) = if p == "D" && plain_ff(i) {
(i, Bit::C1) // 直接暫存器讀取
} else if p == "B" && c.typ == "$_MUX_" && !port_bits.contains(&c.out()) {
// 使能:D = en ? rd : Q
let uu = users.get(&c.out()).cloned().unwrap_or_default();
if uu.len() != 1 || uu[0].1 != "D" || !plain_ff(uu[0].0)
|| c.port("A") != Some(nl.cells[uu[0].0].out()) { continue 'mem; }
gone.push(i);
(uu[0].0, c.port("S").unwrap())
} else { continue 'mem; };
- 直接形狀:
rd[k]直接接到同時脈的$_DFF_P_的D。 - 帶使能的形狀:
rd[k]接到一個$_MUX_的B,而該 mux 的A是同一個正反器的Q——也就是 \(D = en \wedge rd \vee \overline{en} \wedge Q\) ,這是「用回授 mux 實作的時脈使能」。
為什麼只有這兩種? 因為這正是硬體 block RAM 的讀取埠形狀。其他形狀(例如讀取資料經過邏輯運算)代表這不是一個簡單的記憶體,應該留在邏輯裡。
4.1.5.3 位址檢查#
// 記憶體大小之上的位址位元必須是 0(不容許環繞)
if ra.iter().skip(abits).any(|b| *b != Bit::C0) { continue 'mem; }
為什麼? 因為 block RAM 的位址是固定寬度的,多出來的位元會被硬體忽略(相當於環繞)。語言層的環繞與硬體層的環繞語意不同(語言上是索引錯誤,硬體上是別的位址),所以必須拒絕。
4.1.5.4 讀取必須是同步的#
Asynchronous reads stay logic, because the block RAM only has a registered read port.
這是 iCE40 SB_RAM40_4K 的限制。非同步讀取的記憶體必須留在 flip-flop 實作。
這解釋了一個常見的現象:RV32I 的暫存器檔(Ram)因為是非同步讀取,留在正反器;而 SyncRam 變成 block RAM。第 2.1.9 節的 Fifo 例子正是把記憶體寫入與讀取分開成兩塊的原因。
4.1.6 輸出格式#
eda/src/output.rs 有兩個寫出器。
4.1.6.1 網線重新編號#
/// 緊湊的網線編號(2, 3, ...),依首次出現的順序
fn numbering(nl: &Netlist) -> HashMap<u32, u32> {
let mut m = HashMap::new();
let mut add = |b: Bit| if let Bit::W(w) = b { let n = m.len() as u32 + 2; m.entry(w).or_insert(n); };
for p in &nl.ports { p.bits.iter().for_each(|b| add(*b)); }
for c in &nl.cells { c.conns.iter().for_each(|(_, b)| add(*b)); }
for (_, bits) in &nl.nets { bits.iter().for_each(|b| add(*b)); }
m
}
從 2 開始(保留 yosys 的 0/1 給常數),依首次出現順序(埠 → 單元 → 網線)。內部網表經過 rebuild 後可能有跳號,yosys 要的是密集編號。
4.1.6.2 yosys JSON#
{
"creator": "rueda <version>",
"modules": {
"<name>": {
"attributes": { "top": "00000000000000000000000000000001" },
"ports": { "<port>": { "direction": "input|output", "bits": [ ... ] } },
"cells": { "$veryl$0": { "type": "$_AND_", "connections": {...}, "parameters": {...} } },
"netnames": { ... }
}
}
}
位元編碼:Bit::C0 → 字串 "0",Bit::C1 → "1",Bit::W(w) → 數字。
單元名稱用 "$veryl${i}"——$ 前綴讓 yosys 視為自動生成的名稱,hide_name: 1` 讓它不出現在報告裡。
4.1.6.3 一個容易踩到的坑:字串參數#
// 只含 0 / 1 的字串參數加上一個尾端空格(yosys 的 JSON 慣例),
// 否則它會被讀成位元向量
let sp = if crate::fpga::ecp5::is_string_param(&c.typ, k) && v.chars().all(|ch| ch == '0' || ch == '1') {
" "
} else { "" };
format!("\"{}\": \"{}{}\"", k, v, sp)
yosys 靠字元內容判斷一個參數是「字串」還是「位元向量」。 "0101" 會被讀成二進位位元,而不是字串 "0101"。加上一個尾端空格可以強制它被當成字串。
這對 LUT_INIT(16 個字元的二進位字串,必須保持字串)與 ECP5 的 INIT0/INIT1 是必要的。
4.1.6.4 MEM_SLOT 變成屬性#
let attrs: Vec<String> = c.params.iter().filter(|(k, _)| *k == crate::bram::SLOT)
.map(|(_, v)| format!("\"veryl_mem\": \"{}\"", v)).collect();
MEM_SLOT 是 rueda 特有的擴充(記錄「這個 block RAM 裝的是哪個記憶體的第幾塊」),不是真正的 iCE40 參數。所以它被寫成 yosys 的 attribute 而不是 parameter,這樣它可以安全地往返而不會變成一個假的硬體參數。
它是「把程式燒進記憶體」這件事的基礎:pnr::patch_ram 靠它知道要改哪一塊。
4.1.6.5 結構化 Verilog#
to_verilog 產生的是閘級 Verilog(給 iverilog 之類的工具用)。
幾個細節:
正反器變成 reg,其他變成 wire:
let regs: HashSet<Bit> = nl.cells.iter()
.filter(|c| c.is_ff() && c.typ.starts_with("$_"))
.map(|c| c.out()).collect();
正反器的類型字串被解碼回 Verilog:
// $_DFF_P_ / $_DFF_PN0_ / $_SDFF_PP1_ ...
let f: Vec<char> = t.trim_start_matches("$_").trim_start_matches("S").trim_start_matches("DFF_").chars().collect();
let edge = if f[0] == 'P' { "posedge" } else { "negedge" };
同步重置($_SDFF*)的敏感度列表只有時脈;非同步重則是posedge C or negedge R`。
匯流排接腳的組合:
// 索引接腳 "NAME[i]" 組成一個匯流排埠(缺少的輸入位元讀 0)
let bits: Vec<String> = (0..=top).rev().map(|k| match v.iter().find(|x| x.0 == k) {
Some(x) => name(&x.1),
None if is_out(p) => { dangling += 1; format!("nc{}_{}", i, dangling) }
None => "1'b0".to_string(),
}).collect();
format!(".{}({{{}}})", p, bits.join(", "))
MSB 優先(.rev()),符合 Verilog 的 {a[3],a[2],a[1],a[0]} 慣例。
輸出的懸空位元變成具名導線 nc<cell>_<k>,因為 Verilog 不允許輸出接腳的某些位元不連。
4.1.7 「組合在前、正反器在後」撐起了什麼#
這個不變式看起來只是個實作細節,但它被多處依賴:
flowchart TB
I["不變式:組合單元在前,正反器在後"]
I --> A["netsim::settle<br/>走一遍就能正確求值"]
I --> B["asic::sta<br/>一次前向傳播 + 一次後向傳播"]
I --> C["rebuild 的 DFS<br/>自然得到拓樸順序"]
I --> D["死邏輯掃描<br/>從根反向可達"]
I --> E["輸出格式<br/>不需要重新排序"]具體來說:
閘級模擬(netsim::settle)走一遍 cells 就得到正確的組合邏輯值。若順序不保證,就得反覆迭代直到收斂——那就是事件驅動模擬器的複雜度。
時序分析(asic::sta)不需要拓樸排序,因為順序天然滿足資料流方向。
rebuild 的 DFS 在拓樸順序上重新發出 cell,因此所有折疊與雜湊都能重新套用。
這是一個「用資料結構的物理順序承載語意」的漂亮例子。 在很多系統裡,這種隱含不變式會成為維護的負擔;但在這裡,它讓三個演算法都變簡單了。
4.1.8 完整的一次建構流程#
sequenceDiagram
participant FE as 語言前端
participant B as Builder
participant NL as Netlist
FE->>B: declare(name, type) → 佔位線
FE->>B: drive(placeholder, driver) → 別名
FE->>B: and/or/xor/mux/add/... → 實際閘
Note over B: 每次建閘都做結構雜湊 + 常數折疊
FE->>B: ff(type, conns) → 正反器
FE->>NL: optimize() → 解析別名、重建、去除死邏輯
NL->>NL: 輸出 yosys JSON / Verilog4.1.9 一個完整的算術例子#
從 Veryl 的:
assign o_sum = a + b;
到網表:
a與b的佔位線被drive到實際的運算結果;- 合成器的
eval認出Bin("+", a, b),呼叫Builder::add_c; add_c產生 32 顆全加器(p = x^y、g = x&y、c' = g | (p & c)),並串接進位;- 每顆全加器的閘經過結構雜湊——如果設計裡有多個加法器,共用的部分會自動共用;
- 常數折疊會把
a + 0變成a; optimize解析別名,把佔位線換成真正的驅動線;- 死邏輯掃描刪掉沒有連到任何輸出的閘;
- 輸出成 yosys JSON。
第 3 章的 case 一熱編碼、第 2 章的 if_reset 兩路徑、第 1 章的 is_out 判準,全部都發生在這條管線裡。
下一節:4.2 最佳化:常數證明與固定點