跳轉到

05.1 comb 區塊:組合邏輯的明確化#

為什麼需要 always_comb#

在前一章我們看過 Veryl 的模組骨架,本章開始深入行為描述的核心。硬體描述語言中最容易寫錯、也最容易被合成工具「幫倒忙」的地方,就是組合邏輯。Verilog 提供了 always @*(或早期寫法 always @(a or b or c))來描述組合邏輯,但這個語法把「哪些訊號觸發這個程序塊」交給工程師自己維護,於是產生了兩個歷史上有名的問題。

第一個問題是敏感度列表(sensitivity list)。在 Verilog-1995 的時代,always 必須手寫敏感度列表,漏寫一個訊號,模擬行為就與合成結果不一致——合成工具會依照「邏輯上需要」的輸入建立組合電路,模擬器卻只在列出的訊號變化時才重新執行程序塊。這種 sim-synth mismatch 在 1990 年代是 RTL 除錯的噩夢:模擬結果正確,合成出的 netlist 卻行為怪異,反之亦然。Verilog-2001 引入 always @*,讓工具自動推斷敏感度列表,算是解決了一半。

第二個問題更陰險:latch inference(閂鎖器推論)。合成工具看到一個程序塊中某個輸出在某些條件路徑下沒有被賦值,就會「貼心地」幫你補上一個 latch 來保持舊值。問題是,大多數時候這不是設計者的意圖,而是漏寫了 else 或 case 的 default 分支。一個不小心,你的「組合邏輯」裡混進了電位觸發的記憶元件,timing 分析與模擬都會出現難以理解的結果。

1990 年代的教訓:coding style 準則#

Synopsys Design Compiler 在 1990 年代是 RTL 合成的代名詞,「RTL coding style」這門學問也在那個時代成型。當時 Synopsys 以及各大 ASIC vendor 的設計服務部門都發行了 coding guideline 文件,內容大同小異:組合程序塊中所有輸出必須在所有路徑上被賦值(最常見的寫法是程序塊開頭先給 default 值);case 必須寫 default;敏感度列表要完整;避免在組合塊中做非預期的阻斷式賦值順序依賴。這些準則本質上都是在對抗語言的寬容性——Verilog 允許你寫出「模擬上說得通、合成上卻是另一回事」的程式碼。

一整個世代的數位設計工程師都是靠背誦這些準則避開陷阱的。到了 SystemVerilog 時代,IEEE 1801 將 always_comb 納入標準,這個關鍵字不只自動推斷敏感度列表,還要求程序塊中不能有事件控制、不能讀寫會在別處賦值的變數等限制,讓「這是組合邏輯」成為可以由工具檢查的宣告。Veryl 的 always_comb 正是繼承這條路線,而且走得更遠。

Veryl 的 always_comb#

Veryl 中組合邏輯的寫法是:

always_comb {
    y = a & b;
}

重點不在語法本身,而在 Veryl 對程序塊內變數的設計。Veryl 中有三種宣告:

  • var:可變變數,可以在多個地方多次賦值。
  • let:單一賦值(single assignment),宣告後只能被賦值一次,再賦值就是編譯錯誤。
  • const:常數。

let 是 Veryl 對 latch-inference 問題的釜底抽薪之計。考慮一個 priority encoder:

module PriorityEncoder (
    req: input logic<4>,
    idx: output u32,
) {
    always_comb {
        let onehot = req & (0xxxxxF_u32 << req); // 概念示意
    }
}

實務上更常見的寫法是用 if/else 鏈。注意 Veryl 的 let 與 Rust 一致:let x = ...; 表示 x 這個識別字綁定一個值,之後不能再改。當你把組合邏輯的中間結果都用 let 宣告時,編譯器在編譯期就保證了「每個中間訊號恰好被定義一次」,任何遺漏賦值路徑的 bug 會在編譯期被攔截,而不是在合成報告裡看到「inferred 3 latches」才驚覺。

var 則保留了多次賦值的能力,適合需要先給 default 再依條件覆寫的場合,例如:

var sel: logic;
always_comb {
    sel = 0;
    if req[3] {
        sel = 1;
    }
}

條件語法:if、case、switch#

Veryl 支援三種條件結構。if / else if / else 與大多數語言相同,注意 Veryl 的條件不需要括號,但塊需要大括號。

case 對應傳統的 case statement:

case op {
    3'b000: y = a + b;
    3'b001: y = a - b;
    default: y = 0;
}

switch 是 Veryl 特有的語法,它不是對「一個選擇訊號」分派,而是對「多個布林條件」並列判斷,語意上接近 priority-if 的平行化寫法,適合表達互斥條件集合:

switch {
    req[0]: gnt = 4'b0001;
    req[1]: gnt = 4'b0010;
    req[2]: gnt = 4'b0100;
}

switch 的價值在於工具可以檢查條件之間是否互斥、是否涵蓋所有情況,這是 if 鏈做不到的。這個設計同樣來自「把 coding style 準則變成語言檢查」的思路:1990 年代靠人肉紀律維持的東西,2020 年代的語言直接由編譯器把關。

完整範例:簡單 ALU#

module Alu #(
    param W: u32 = 8,
) (
    a  : input  logic<W>,
    b  : input  logic<W>,
    op : input  logic<2>,
    y  : output logic<W>,
) {
    always_comb {
        case op {
            2'b00: y = a + b;
            2'b01: y = a - b;
            2'b10: y = a & b;
            2'b11: y = a | b;
        }
    }
}

這個例子中,case 涵蓋了 op 的全部四種組合,不需要 default(但寫上也無妨)。如果把 2'b11 那行刪掉,合成工具就必須決定「其餘情況 y 是多少」——在 Verilog 裡這正是 latch inference 發生的時刻;在 Veryl 裡,若 y 是用 var 且只在此處賦值,編譯器會提醒你路徑不完整。語言設計的差異,就是把除錯時程從「合成後數天」縮短到「編譯當下數秒」。

小結#

always_comb 承接了 SystemVerilog 對組合邏輯明確化的努力,再加上 let 的單一賦值語意與 switch 的互斥條件檢查,把 1990 年代 Design Compiler 時代累積下來的 coding style 準則,內化成語言本身的型別與流程檢查。下一章我們看時序邏輯:always_ff 如何把時脈與重置也變成語言層級的檢查對象。