跳轉到

5.3 表達式:if/case/switch、for/inside/outside 與 msb/repeat#

硬體語言裡最不「像程式語言」的部分#

到目前為止,我們處理的語言特性都跟「硬體」有關:時序、並行、接線規則、寬度。現在我們來看表達式——語言中最像軟體的部分。

但表達式在硬體語言裡有一個根本的不同:它描述的不是「計算過程」,而是資料流的選擇。一個 if 表達式在硬體上是一組多工器;一個 case 表達式是一組查表電路;一個 for 迴圈是硬體的複製。

正因為如此,Verilog / SystemVerilog 的表達式設計有幾個明顯的缺陷:

  1. 選擇只有三元運算子。a ? b : c 可以巢狀,但巢狀三元的可讀性會隨深度快速崩潰。
  2. case 的語意有漏洞。SV 的 case 在沒有匹配時不賦值——這在硬體上是 latch,在 SV 裡卻是合法語法。
  3. casez / casex 的萬用字元語意令人困惑。casex 把 x 當萬用元,導致 4'bxxxx 匹配任何值。
  4. 位元操作的語法過載。a[3+:1]、a[4-:2]、a[2 step 3]、$bits(a)、a[WIDTH-1:0] 混雜在一起,每個都有微妙的語意。
  5. 沒有「集合」的抽象。判斷「某值是否在範圍集合內」需要手寫 >= && <= 鏈。

本章的主題是:Veryl 如何為硬體的資料流選擇與位元操作,設計一套可預測的表達式語法。


表達式分類#

在 Veryl 中,表達式出現在三個位置:

位置 例子 硬體意義
assign 的右側 assign o_y = i_a & i_b; 連續賦值
let / const 的右側 let r : u32 = 32'hAB; 綁定
函式的回傳 return a > b ? a : b; 回傳值

所有這些位置都接受同一套表達式語法。 這是語言一致性的重要體現:在 SV 中,assign 右側、function 內的 assign、以及 always_comb 內的敘述,語法是三套不同的東西。

Veryl 的表達式文法(簡化):

表達式    ::= 三元表達式
三元表達式 ::= 邏輯或表達式 [ '?' 三元表達式 ':' 三元表達式 ]
            | 邏輯或表達式 'if' 表達式 [ 'else' 表達式 ]
            | 邏輯或表達式 'case' 表達式 '{' 分支串列 '}'
            | 邏輯或表達式 'switch' '{' 分支串列 '}'
            | ...

if 表達式#

語法#

assign o_b = if i_a == 0 ? 1 : if i_a > 1 ? 2 : 3;

形式:

if 表達式 { then_expr } [ else { else_expr } ]

注意兩個要點:

  1. 條件不需要括號。 if i_a == 0 而不是 if (i_a == 0)。這是 Veryl 的一致性選擇(見 5.1 節:運算子優先序足以讓括號可選)。實際上,括號在需要時仍然允許。

  2. else 可以省略,但必須有預設結果。 這是一個非常重要的規則:

assign o_b = if i_a == 0 ? 1 : 0;    // 合法

省略 else 時,結果是「條件不成立時的預設值」。但預設值必須明確給出——在 assign 的右側,這意味著你必須寫 : 0 這樣的預設。

更精確地說,Veryl 的 if 表達式有兩種寫法:

形式 語意
if cond ? a : b 條件成立取 a,否則取 b
if cond ? a 條件成立取 a,否則取型別的零值/預設值

第二種形式的「預設值」是由型別決定的:整數型別是 0,布林是 false,結構體是全零。

這個設計值得討論。 硬體語言的表達式有一個 SV 沒有的性質:它必須有值(因為它是資料流,不是指令)。SV 的 if 是一個敘述(statement),可以沒有 else,結果是「不賦值」。Veryl 把 if 變成表達式,就必須回答「沒有 else 時的值是什麼」。

為什麼表達式形式的 if 比敘述形式好#

(一)可讀性:巢狀三元的災難。

SV 的多路選擇:

assign o_b = (i_a == 0) ? 1 :
             (i_a == 1) ? 2 :
             (i_a == 2) ? 3 :
             (i_a == 3) ? 4 : 0;

四層就已經需要仔細對齊括號。Veryl 的版本:

assign o_b = if i_a == 0 ? 1 :
             if i_a == 1 ? 2 :
             if i_a == 2 ? 3 :
             if i_a == 3 ? 4 : 0;

括號消失了,因為 if 本身帶有明確的結構邊界。

(二)型別一致性:if 表達式的兩條分支必須同型。

在 SV 中,三元運算子的兩個分支型別不同時,會發生隱式轉換:

assign o = sel ? 8'd1 : 16'd2;   // 8-bit 的 o 會把 16'd2 截斷!

在 Veryl 中,這會是型別錯誤。這正是 5.1 節「型別系統的第一道防線」的價值。

(三)if 表達式與 if 敘述的語意統一。

在 Veryl 中,if 敘述(在 always_comb 內)與 if 表達式(右側)是同一套語法:

always_comb {
    if i_en {
        o_y = i_a;
    } else {
        o_y = i_b;
    }
}
assign o_y = if i_en ? i_a : i_b;

這兩段產生完全相同的硬體。 語法統一帶來一個好處:工程師在兩種寫法之間切換時不需要重新學習語法,也不會因為寫錯形式而產生不同的硬體結構。


case 表達式#

語法#

assign o_b = case i_x {
    0: 1,
    1: 2,
    3..=5: 4,
    10'b00_0000_011x: 5,
    default: 6,
};

形式:

case 表達式 '{' 分支串列 '}'
分支 ::= 匹配值列表 ':' 結果 [ ',' ]

default 是強制的#

這是 case 表達式與 SV case 最重要的差異。

在 SystemVerilog 中:

always_comb begin
    case (i_sel) begin
        2'd0: o_d = i_d0;
        2'd1: o_d = i_d1;
        // 沒有 default
    end
end

如果 i_sel 是 2'd2 或 2'd3,o_d 不會被賦值。 在硬體上,這會產生一個 latch。SV 工具會發出 "inferred latch" 警告,但程式碼本身合法。

在 Veryl 中:

assign o_d = case i_sel {
    2'd0: i_d0,
    2'd1: i_d1,
    default: 0,
};

default 是強制的,沒有它就編譯不過。

理由:SV 的 case 是一個敘述(可以「什麼都不做」);Veryl 的 case 是一個表達式(必須有值)。而表達式沒有值是不合法的。

硬體意義:這條規則把「忘記寫 default → 產生 latch」這一整類錯誤從「合成後看到警告」提前到「編譯當下」。

但要注意一個重要的差異:case 敘述(在 always_comb 裡)與 case 表達式(在右側)在 Veryl 中的行為不完全一樣。

always_comb {
    case i_sel {
        2'd0: o_d = i_d0,
        2'd1: o_d = i_d1,
        default: o_d = 0,
    }
}

在這裡,default 仍然應該寫(為了涵蓋所有路徑、避免 latch),但語言的強制性主要作用在表達式形式上。我們建議:即使在敘述形式中,也一律寫 default。 這是 4.1 節的「好程式碼判準」:讓正確性不依賴於所在區塊。

範圍:..= 與 ..#

語法 意義 包含端點
a..b 半開區間 \([a, b)\) 不含 \(b\)
a..=b 閉區間 \([a, b]\) 含 \(b\)
assign o_b = case i_x {
    0..=3: 0,        // 0, 1, 2, 3
    4..7: 1,         // 4, 5, 6
    default: 2,
};

這個設計與 Rust 的範圍語法一致(Rust 用 .. 與 ..=),因此對熟悉 Rust 的工程師是自然的。

硬體意義:範圍匹配在硬體上就是比較器的並聯。3..=5 展開成「 \(x \ge 3\) 且 \(x \le 5\) 」,對應兩個比較器加一個 AND 閘。

x / z 萬用字元語意#

這是 case 表達式最需要小心設計的部分。

在 SystemVerilog 中:

關鍵字 z 當萬用元 x 當萬用元 ? 當萬用元
case 否 否 否
casez 是 否 是
casex 是 是 是

casex 是危險的。 它的問題在於:把 x 當萬用元,意味著 4'bxxxx 會匹配任何值:

casex (i_sel)
    4'bxxxx: o = 1;   // 永遠匹配!
endcase

更常見的陷阱是:

casex (i_state)
    4'b00x1: ...      // 這個 arm 會匹配 4'b0011, 4'b0001, 4'b10x1, 4'bxxxx ...
endcase

結果是 arm 的匹配順序變得不可預測,因為多個 arm 可能同時匹配,而 SV 的 casex 不警告重疊。

Veryl 的做法:給 x 一個明確且單一的語意。

在 case 表達式的 arm 匹配值中,x 匹配任何值(0、1、x、z 皆可)。

這是一個定義明確的萬用字元語意,不同於 casex 的模糊行為。

但這裡有一個必須誠實指出的代價:Veryl 的語意接近 casex(x 當萬用元),而 SV 的 casez 只把 z 當萬用元。

為什麼選擇把 x 當萬用元? 理由是硬體語意:

在硬體裡,值 x 代表「這個比特的值未知」。當工程師寫下 4'b00x1 作為匹配值,他的心裡想的是:「前三個比特有特定的要求,最後一個比特是 1」。其中那個 x 代表不關心(don't care)。

因此把 x 當萬用元是符合工程師意圖的——把 x 當作「這個位置不參與比對」。

而 SV 把這件事分成 casez / casex 兩種語義的做法,產生的是更多的組合(2 種語意 × 3 種萬用字元)與更多的記憶負擔。工程師往往搞不清楚該用哪一個。

Veryl 的取捨是:

面向 SV Veryl
萬用字元組合數 3 種關鍵字 × 3 種字元 1 種規則 × 1 種字元
記憶負擔 高 低
與硬體意圖的對應 需要轉換 直接對應
想要「x 嚴格匹配 x」 需要 case 無法表達

「無法表達嚴格匹配 x」是一個真實的限制。 如果工程師真的需要「只有當這個值精確是 4'b00x1(含 x)時才匹配」,Veryl 的 case 做不到。這種需求在實務上非常罕見(因為要把 x 當成一個確切的比對對象,通常代表你正在處理未初始化訊號,而不是在做匹配邏輯)。

我們的建議:在需要嚴格比對的場景,用 == 而不是 case:

assign o = (i_x == 4'b00x1) ? 1 : 0;

== 使用 SV 的嚴格相等語意(四態下 x 只能與 x 相等)。這是 Veryl 的 case 與 == 之間的分工:case 做模式匹配,== 做嚴格相等。

switch 表達式#

assign o_b = switch {
    i_a == 0: 1,
    i_a == 1: 2,
    default: 5,
};

switch 的條件是布林式的,而不是「一個被比較的值」。 它對應 SV 裡的 unique case / priority case 的使用場景,但語法更直接。

switch 的語意是「依序檢查條件,第一個成立的分支勝出」。這與 if / else if 鏈完全等價,但:

  1. 可讀性更好:多個平行條件寫在一起,不需要重複的條件運算子。
  2. 可以檢查互斥性:工具可以分析各 arm 的條件是否互斥,若有重疊則警告(這在硬體裡很重要,因為重疊的 arm 意味著硬體有多餘的邏輯)。
always_comb {
    switch {
        i_req[0]: o_gnt = 4'b0001,
        i_req[1]: o_gnt = 4'b0010,
        i_req[2]: o_gnt = 4'b0100,
        default:  o_gnt = 4'b0000,
    }
}

注意這個寫法有一個硬體上的隱憂:如果 i_req[0] 與 i_req[1] 同時為 1,只有 i_req[0] 的 arm 生效——這是「優先權編碼器」的語意。工具的互斥性檢查會警告這個問題。


for 迴圈的三種形式#

Veryl 的 for 迴圈在不同語境下有三種不同的語意。這個區分是本章最重要的內容之一,因為它直接對應「硬體的複製」與「軟體的迭代」的根本差異。

形式一:硬體迴圈(generate)#

在項目主體層級(不在 always_comb / always_ff 內)的 for 迴圈,是硬體迴圈:它在編譯期被展開成 N 份硬體。

module CellArray::<N: u32> (
    i_clk : clock,
    i_d   : input  logic<N>,
    o_q   : output logic<N>,
) {
    for i: u32 in 0..N {
        var r_cell : logic

        always_ff (i_clk) {
            r_cell = i_d[i];
        }

        assign o_q[i] = r_cell;
    }
}

這個模組產生 N 顆獨立的 FF。 迴圈變數 i 在編譯期有 N 個不同的值。

在 SystemVerilog 裡,對應的是 generate for:

genvar i;
generate
    for (i = 0; i < N; i = i + 1) begin : g_cells
        logic r_cell;
        always_ff @(posedge i_clk) begin
            r_cell <= d[i];
        end
        assign q[i] = r_cell;
    end
endgenerate

Veryl 省掉了 genvar、begin : label、endgenerate 三層儀式。 這是 2.2 節「保留語意、刪除儀式」的典型例子。

形式二:區塊內的迴圈敘述#

在 always_comb 內的 for 迴圈,是行為迴圈:它在硬體上展開成串聯的邏輯。

always_comb {
    let sum : logic<16> = 0;
    for i: u32 in 0..8 {
        sum = sum + i_data[i];
    }
    o_result = sum;
}

這段程式碼產生一個 8 位的加法鏈(串聯的 8 個加法器),不是一個 8 次的軟體迴圈。

硬體代價:串聯加法鏈的延遲是 \(O(n)\) 。若要改善吞吐率,工程師要自己改寫成平衡樹:

always_comb {
    let l0 : logic<4> = i_data[0] + i_data[1];
    let l1 : logic<4> = i_data[2] + i_data[3];
    let l2 : logic<4> = i_data[4] + i_data[5];
    let l3 : logic<4> = i_data[6] + i_data[7];
    o_result = (l0 + l1) + (l2 + l3);
}

這個 \(O(n)\) vs \(O(\log n)\) 的差異是硬體設計的核心考量之一,而語言本身不替你最佳化——這與第 0 章說的「Veryl 刻意不提供自動流水線」是同一個設計立場:不偷偷改變你的硬體結構。

形式三:rev 與 step#

for 迴圈的迭代方向與步長可以控制:

for i: u32 in 0..8 {          // 前進,步長 1
}

for i: u32 rev in 0..8 {     // 反向:7, 6, 5, ..., 0
}

for i: u32 in 0..8 step += 2 {   // 步長 2:0, 2, 4, 6
}
形式 意義
for i: u32 in A..B 前進迭代
for i: u32 rev in A..B 反向迭代
step += N 每次遞增 \(N\)
step -= N 每次遞減 \(N\)
step *= N 每次乘以 \(N\) (僅用於 rev)

rev 的語意:for i: u32 rev in 0..8 依次取 \(7, 6, 5, 4, 3, 2, 1, 0\) 。這在硬體裡非常實用——位元反轉就是最典型的例子:

always_comb {
    let r_rev : logic<W>;
    for i: u32 rev in 0..W {
        r_rev[i] = i_data[W - 1 - i];
    }
    o_reversed = r_rev;
}

step 的語意:step += 2 表示迭代值每次加 2。

靜態檢查:永遠無法到達終點的 step 是錯誤#

這是 Veryl 的一條硬性規則,也是本章最值得注意的靜態檢查之一。

如果一個 for 迴圈的 step 無法讓迭代值到達範圍終點,這是編譯期錯誤。

舉例:

for i: u32 in 0..8 step += 0 { }     // 錯誤:step 為 0,永遠不前進
for i: u32 in 0..8 step += 3 { }     // 錯誤:0,3,6,9... 永遠不會停在 8
for i: u32 rev in 0..8 step *= 2 { } // 錯誤:步長不為 1 的 rev,無法窮舉

為什麼這個檢查很重要?

讓我們從硬體的角度看。硬體迴圈在編譯期被展開。如果迭代值無法到達終點,編譯器就不知道要展開多少次——這是無界展開,會導致編譯器無限迴圈、記憶體耗盡。

更微妙的是:如果這個錯誤沒有被靜態抓到,行為迴圈(在 always_comb 內)會產生錯誤的硬體。 考慮:

always_comb {
    let sum : logic<8> = 0;
    for i: u32 in 0..8 step += 3 {   // 0, 3, 6 → 下一個是 9,已經超過 8
        sum = sum + i_data[i];       // i_data[9] 越界!
    }
    o_result = sum;
}

i_data[9] 超出 8-bit 向量的範圍。 在 SV 裡這會回傳 x(或工具的擴充行為),在硬體上則是一條接到未定義值的線。

這個靜態檢查把三個錯誤一起擋下:

  1. 無界的硬體展開(編譯期崩潰)。
  2. 行為迴圈中的索引越界。
  3. 「我以為它會迭代到終點」的誤解。

這是一個「語意錯誤被靜態檢查捕獲」的漂亮例子,而且它捕獲的是一個在 SV 中幾乎不可能被發現的錯誤——SV 裡根本沒有 step 這個概念。

為什麼 rev 不允許 step *= 2#

反向迭代 rev 的語意是「從終點往回走」,這要求步長的絕對值恰好是 1(配合 + 或 -)。step *= 2 的反向迭代會產生 \(7, 5, 3, 1\) (少數)或 \(14, 12, \ldots\) (超出範圍),無法窮舉所有元素。

語言在這裡選擇嚴格:只有能確定窮舉的組合才允許。


inside / outside#

語法#

assign o_a = inside 1 + 2 / 3 { 0, 0..10, 1..=10 };

形式:

inside 表達式 { 集合元素串列 }
outside 表達式 { 集合元素串列 }

集合語意#

集合的元素可以是:

元素 意義
v 單值
a..b 半開區間 \([a, b)\)
a..=b 閉區間 \([a, b]\)

inside 的語意是「表達式的值落在集合的聯集內」。

// 值在 {0} ∪ [0,10) ∪ [1,10] 內
assign o = inside i_x { 0, 0..10, 1..=10 };

注意這個例子中 0..10 與 1..=10 有重疊——inside 不要求集合的各元素互斥。它就是聯集。

outside 是 inside 的否定:

assign o = outside i_x { 0, 1..=10 };   // 不在 {0} ∪ [1,10] 內

它取代了什麼#

(一)取代 casez / casex 的部分用途。

在實務中,SV 裡 casez 常被用來做「範圍判斷」:

casez (i_addr[7:4])
    4'b0000, 4'b0001, 4'b0010: o = 0;
    default: o = 1;
endcase

這是冗長且容易出錯的寫法。Veryl:

assign o = outside i_addr[7:4] { 0..=2 };

(二)取代手寫的 >= && <= 鏈。

// SV
assign o = (i_x >= 10) && (i_x <= 20);

// Veryl
assign o = inside i_x { 10..=20 };

(三)取代多值的 == 鏈。

// SV
assign o = (i_x == 3) || (i_x == 7) || (i_x == 9);

// Veryl
assign o = inside i_x { 3, 7, 9 };

硬體意義#

inside / outside 在硬體上展開成一組比較器:

  • inside x { a..=b } → 一個範圍比較器(可以用一個減法器 + 符號檢查實作)。
  • inside x { v1, v2, v3 } → 一組等值比較器 + OR 樹(與 case 的硬體結構相同)。

這是 Veryl 的一個漂亮的地方:inside 與 case 在硬體上產生相同的結構,但前者更適合表達「值落在某個集合內」的語意。

特別值得注意的一點:inside 與 outside 特別適合表達位元向量上「非連續區段」的判斷:

// 判斷 i_x 的高 4 bits 是否全為 0
assign o = inside i_x[7:4] { 0 };

位元選擇#

這是硬體語言特有的運算,Veryl 提供了一套相對整齊的工具。

基本選擇#

a[3]        // 第 3 位(單一位元)
a[4:0]      // 第 0 到第 4 位(含)

注意索引方向:a[4:0] 是從第 4 位到第 0 位,MSB 在左。這與 SV 一致。

+: 與 -::由起點與寬度選擇#

這是 SV 裡非常有價值的語法(用於位元串接時指定寬度),Veryl 保留並定義清楚。

語法 意義 等價展開
a[3+:1] 從第 3 位起,取 1 位 a[3:3]
a[4-:2] 從第 4 位起,往下取 2 位 a[4:3]
a[3+:4] 從第 3 位起,取 4 位 a[6:3]

下標計算規則:

\[ \text{base} +: w \quad \Rightarrow \quad [\text{base} + w - 1 : \text{base}] \]
\[ \text{base} -: w \quad \Rightarrow \quad [\text{base} : \text{base} - w + 1] \]

也就是說:

  • +: 往 MSB 方向延伸。
  • -: 往 LSB 方向延伸。

這個區別在硬體裡非常重要,因為位元串接時的順序會決定硬體的接線。

具體例子:

// 取寬度 WIDTH 的資料,並在前面補上 1 個位元
assign o = {1'b0, i_data[WIDTH-1:0]};

// 用 +: 更不容易出錯
assign o = {1'b0, i_data[WIDTH-1 -: WIDTH]};

+: 的真正價值在於消除「手算 WIDTH-1」。這是硬體設計裡最常見的 off-by-one 錯誤來源之一。

step 選擇#

a[2 step 3]

語意:從索引 2 開始,每次跳 3,取 3 個元素。

精確展開:

\[ a[\text{base}\ \text{step}\ w] \quad \Rightarrow \quad a[(\text{base} \cdot w) +: w] \]

所以 a[2 step 3] 等價於 a[6+:3],也就是 a[8:6](取第 6、7、8 位)。

這個語法的用途:抽取向量中「每隔 n 個取一個」的子序列。例如,從一個 32-bit 向量抽取 4 個 byte:

// 每 8 個位元取一個 byte
assign o_bytes = i_data[0 step 8];

它對應 SV 的 a[base +: width] 加上「間隔」的擴充,SV 裡沒有直接對應的語法(通常用迴圈或巨集)。

msb / lsb#

X[msb]           // 最高位
X[lsb]           // 最低位
a[msb - 3 : lsb] // 從次高 3 位到最低位

msb 與 lsb 是「相對於型別寬度」的符號,它們的數值由型別決定:

\[ \text{msb}(T) = \text{width}(T) - 1, \qquad \text{lsb}(T) = 0 \]

這解決的是一個真實的高頻錯誤來源:「位寬參數化後手算 WIDTH-1」。

比較一下:

// 手算版
assign o_top = i_data[WIDTH - 1];
assign o_low4 = i_data[WIDTH - 4 : 0];

// msb/lsb 版
assign o_top = i_data[msb];
assign o_low4 = i_data[msb - 3 : lsb];

後者不會因為改了 WIDTH 而忘記改 WIDTH - 4。 這是「把算術錯誤變成語法錯誤」的又一個例子。

一個特別優雅的用法:在泛型函式裡,型別寬度是 T,所以:

function TopBit::<T: u32>(a: logic<T>) -> logic {
    return a[msb];
}

注意這裡不需要寫 T-1。 因為 msb 是相對於 a 的型別(logic<T>)而言的。

另一個用法:lsb 在「從 LSB 數起」的場景中特別有用:

// 取最低 8 位,不受寬度影響
assign o_byte = i_data[7 : lsb];

串接與 repeat#

串接#

{ a, b }        // 把 b 放在低位,a 放在高位

這是 SV 的 {a, b},在硬體上就是串接(連線)。a 是 MSB,b 是 LSB。

repeat:複製#

{ a[9:0] repeat 10, b repeat 4 }

語意:a[9:0] 被複製 10 份,b 被複製 4 份,全部串接起來。

對照 SV:

{ {10{a[9:0]}}, {4{b}} }

repeat 的價值:

  1. 消除重複計數。SV 的 {10{x}} 要求工程師手寫 10,而 x repeat 10 裡的 10 常常就是某個參數——此時 SV 寫成 {WIDTH{x}}(可以),但 repeat 更統一。
  2. 在陣列字面量中特別有用:
'{ 1, 2, 3 }
'{ 1 repeat 3 }        // { 1, 1, 1 }
'{ 0, 2 repeat 4 }     // { 0, 2, 2, 2, 2 }
'{ default: 3 }         // 全部填 3
  1. 在非打包陣列的初始化中特別實用:
var r_mem : logic<32> [8] = '{ 0 repeat 8 };

陣列字面量#

'{ 1, 2, 3 }

這是 SV 的 assignment pattern。Veryl 的 '{...} 同樣出現在兩個位置:

  1. struct 建構子:Point'{x: 1, y: 2}(見 5.1 節)。
  2. 陣列字面量:'{ 1, 2, 3 }。

'{default: 3} 是把陣列所有元素填 3 的簡寫。


型別轉換 as#

語法#

x as EnumA        // 轉換成列舉型別
x as 2            // 轉換成 2-bit
x as u32          // 轉換成 u32

as 是明確的型別轉換,它在 SV 裡對應 type'(expr) 或 $cast。

兩種主要用途#

(一)位寬轉換。

let r_narrow : logic<2> = i_wide as 2;

這個轉換會截斷(或零擴充,取決於方向)。因為它是明確的,語言不會警告——工程師已經表態了。

(二)型別轉換(例如轉成列舉)。

let r_state : State = i_raw as State;

一個值得說明的解析陷阱#

let r : logic<(W + 1)> = i_x as (W + 1);

括號是必要的!

如果寫成:

let r : logic<W + 1> = i_x as W + 1;

那麼 as 的運算子優先序高於 +,解析結果是:

\[ (i_x \text{ as } W) + 1 \]

而不是我們想要的:

\[ i_x \text{ as } (W + 1) \]

兩者的硬體結果完全不同:前者是「先截斷到 W bits,再加 1」(可能溢出),後者是「先擴充到 W+1 bits」(不溢出)。

這是一個非常典型的解析陷阱,因為它涉及的是硬體結果的正確性,而不只是型別。類似的陷阱還有:

a as u32 + 1     // 解析為 (a as u32) + 1
a as (u32 + 1)   // 括號內不是合法的型別,會報錯

規則很簡單:as 的右側若要表達一個運算式,必須用括號明確包住。括號不是冗餘的,是語義的一部分。

轉換的語意#

as 轉換不改變位元,只改變型別的解讀方式(寬度可能改變,若目標型別較窄則截斷,較寬則零擴充或符號擴充)。

as 與 $cast 的差異:SV 的 $cast 在失敗時回傳 0(執行期檢查),而 as 是編譯期的靜態轉換,失敗是編譯期錯誤(當轉換不合理時)。這是「硬體沒有執行期」的又一例證:轉換要么編譯期就確定,要么根本不該寫。


函式呼叫#

具名引數#

let r = Func(arg1: 10, arg2: 20);

具名引數不能與位置引數混用。 這是一條硬性規則:

let r = Func(10, arg2: 20);   // 錯誤

理由:混用會讓「第三個引數是什麼」變得取決於規則的細節。SV 在這方面也很含糊(SV 允許部分具名部分位置,但規則複雜)。

全部具名或全部位置,這條規則簡單而嚴格。

位置引數的順序是宣告的順序,因此改變參數順序會破壞所有位置式呼叫。這是為什麼具名引數在長期維護的程式碼中特別重要。


運算子優先序表#

以下是 Veryl 的運算子優先序(由高到低)。這個表是本節的核心參考資料。

優先序 運算子 結合性 說明
1 () [] . — 群組、索引、欄位存取
2 as 右 型別轉換
3 ! ~ 右 邏輯否、位元否
4 ** 右 冪
5 * / % 左 乘、除、餘
6 + - 左 加、減
7 << >> <<< >>> 左 位移
8 < <= > >= 左 比較
9 == != === !== 左 等值、四態等值
10 & 左 與
11 ^ ^~ 左 互斥或
12 \| 左 或
13 && 左 邏輯與
14 \|\| 左 邏輯或
15 ?: 右 三元條件
16 = += -= *= /= &= \|= ^= 右 賦值(最低)

幾個必須注意的位置:

(一)<: 與 >: 屬於哪裡?

+: 與 -: 是位元選擇的一部分,屬於優先序 1(索引類)的一部分。它們不是獨立的運算子,而是 a[expr +: expr] 這個索引語法的一部分。

(二)as 的優先序很高(2),高於所有算術運算子。

這就是前面那個解析陷阱的根源:x as W + 1 解析為 (x as W) + 1。

(三)= 的優先序最低。

這意味著:

a = b == c;

解析為 a = (b == c),而不是 (a = b) == c。這與 C 系列語言一致,是安全的。

(四)if / case / switch 表達式的位置。

if / case / switch 表達式的優先序低於所有運算子,高於賦值。所以:

o_y = if cond ? a : b;      // 正確

(五)<: 與 >: 這些「非標準名字」在 SV 裡已經存在。

a[3+:1] 的 +: 與 a[4-:2] 的 -: 是 SystemVerilog 的語法,Veryl 保留了它們。這是「互操作」支柱的體現:不改變工程師已知的正確語法。


生成的 SystemVerilog 對照#

對照一:if 與 case 表達式#

Veryl:

assign o_b = if i_a == 0 ? 1 : if i_a > 1 ? 2 : 3;

生成的 SystemVerilog:

assign o_b = (i_a == 0) ? 1 : ((i_a > 1) ? 2 : 3);

Veryl 的 if 表達式被還原成巢狀三元運算子——因為 SV 沒有 if 表達式。括號被明確加上,因為 SV 的三元運算子是右結合的,沒有括號會改變結構。

case 表達式:

assign o_b = case i_x {
    0: 1,
    1: 2,
    3..=5: 4,
    default: 6,
};
always_comb begin
    case (i_x)
        0: begin o_b = 1; end
        1: begin o_b = 2; end
        3, 4, 5: begin o_b = 4; end
        default: begin o_b = 6; end
    end
endcase

注意兩件事:

  1. 範圍 3..=5 被展開成 3, 4, 5——SV 的 case 沒有範圍語法。
  2. 生成的 SV 是 always_comb + case 敘述,而不是 SV 的 case 表達式(SV 沒有 case 表達式)。

對照二:inside / outside#

Veryl:

assign o = inside i_x { 10..=20 };

生成的 SystemVerilog:

always_comb begin
    o = (i_x >= 10) && (i_x <= 20);
end

inside 被展開成明確的比較鏈——這是「語義等價」的還原。硬體結構完全一致(範圍比較器)。

對照三:msb / lsb#

Veryl:

assign o_top  = i_data[msb];
assign o_low4 = i_data[msb - 3 : lsb];

生成的 SystemVerilog:

assign o_top  = i_data[WIDTH-1];
assign o_low4 = i_data[WIDTH-4:0];

msb / lsb 被折疊成具體的索引常數。 這是「語言層級的相對索引 → 編譯期的絕對索引」的轉換。

對照四:repeat#

Veryl:

assign o = { i_data[9:0] repeat 10, i_b repeat 4 };

生成的 SystemVerilog:

assign o = { {10{i_data[9:0]}}, {4{i_b}} };

repeat 被還原成 SV 的重複串接語法。 硬體完全相同。

對照五:for 迴圈#

Veryl(硬體迴圈):

for i: u32 in 0..4 {
    var r : logic
    always_ff (i_clk) {
        r = i_d[i];
    }
    assign o_q[i] = r;
}

生成的 SystemVerilog:

logic r;
always_ff @(posedge i_clk) begin
    r <= i_d[0];
end
assign o_q[0] = r;

logic r_1;
always_ff @(posedge i_clk) begin
    r_1 <= i_d[1];
end
assign o_q[1] = r_1;
...

硬體迴圈被完全展開,每個迭代產生一份獨立的硬體,並且命名被系統化(r、r_1、r_2),避免名稱衝突。


實作練習#

練習一:四位元 BCD 轉換器#

題目: 用 case 表達式實作一個四位元 BCD(二進位碼十進位)轉換器:把 4-bit 的 BCD 數字轉換成 7-segment 顯示器的段碼(a 到 g 七段,各 1 bit)。

BDE 段碼對照(以常見的共陰極 7-segment 為例):

數字 a b c d e f g
0 1 1 1 1 1 1 0
1 0 1 1 0 0 0 0
2 1 1 0 1 1 0 1
3 1 1 1 1 0 0 1
4 0 1 1 0 0 1 1
5 1 0 1 1 0 1 1
6 1 0 1 1 1 1 1
7 1 1 1 0 0 0 0
8 1 1 1 1 1 1 1
9 1 1 1 1 0 1 1

解答:

module BcdToSevenSeg (
    i_bcd  : input  logic<4>,
    o_seg  : output logic<7>,
) {
    assign o_seg = case i_bcd {
        4'd0: 7'b1111110,
        4'd1: 7'b0110000,
        4'd2: 7'b1101101,
        4'd3: 7'b1111001,
        4'd4: 7'b0110011,
        4'd5: 7'b1011011,
        4'd6: 7'b1011111,
        4'd7: 7'b1110000,
        4'd8: 7'b1111111,
        4'd9: 7'b1111011,
        default: 7'b0000000,
    };
}

思考:

  1. default 是必要的:4-bit 輸入有 16 種可能,0..9 之外還有 10..15(六種無效的 BCD)。default 把它們映射成「全熄」(7'b0000000)。這正是 default 強制存在所帶來的價值——它強迫設計者思考無效輸入。

  2. 硬體結構:這是一個 16-to-1 的查表電路。實際上可以實作成 LUT(FPGA)或解碼器 + AND-OR(ASIC)。

  3. 用 case 表達式 vs always_comb + case 敘述:兩者硬體相同。表達式版本更簡潔,因為不需要中間變數。

  4. 驗證:10 個有效輸入 + 6 個無效輸入,共 16 個測試向量。第 8 章的測試框架可以自動窮舉。

練習二:位元翻轉與奇偶檢查產生器#

題目: 用 for 與 repeat 實作:(a) 一個 W-bit 的位元翻轉器;(b) 一個奇偶檢查產生器(輸出 XOR of all bits)。

解答一:位元翻轉

module BitReverse #(
    parameter WIDTH : p32 = 8,
) (
    i_din  : input  logic<WIDTH>,
    o_dout : output logic<WIDTH>,
) {
    always_comb {
        let r_rev : logic<WIDTH>;
        for i: u32 rev in 0..WIDTH {
            r_rev[i] = i_din[WIDTH - 1 - i];
        }
        o_dout = r_rev;
    }
}

注意 rev 的使用:因為 rev 迭代的是 WIDTH-1, WIDTH-2, ..., 0,所以 r_rev[WIDTH-1] = i_din[0]、r_rev[0] = i_din[WIDTH-1]——這正是反轉。

但這裡其實可以用 step 更簡潔地表達:

always_comb {
    let r_rev : logic<WIDTH>;
    for i: u32 in 0..WIDTH step += 1 {
        r_rev[i] = i_din[WIDTH - 1 - i];
    }
    o_dout = r_rev;
}

這兩個版本相同。rev 的價值在於當你不需要索引運算式時:

// 假設你想把 a 複製並且每次複製的內容要錯開

另一種更「硬體直覺」的寫法是直接用選擇:

always_comb {
    o_dout = i_din[msb : lsb];   // 這只是恆等,不是反轉
}

注意 msb : lsb 不能做反轉——位元向量的切片方向不改變位元的位置,只改變書寫順序。反轉必須逐位重新對應。這是一個常見的誤解,值得記住。

解答二:奇偶檢查產生器

module ParityGenerator #(
    parameter WIDTH : p32 = 8,
) (
    i_din  : input  logic<WIDTH>,
    o_par  : output logic,
) {
    always_comb {
        let r_par : logic = 1'b0;
        for i: u32 in 0..WIDTH {
            r_par = r_par ^ i_din[i];
        }
        o_par = r_par;
    }
}

硬體意義:這產生的是一條串聯的 XOR 鏈,延遲是 \(O(W)\) 。對 8 bits 沒問題,對 64 bits 就有意義了。

用 repeat 與 case 表達式實作的變體:

always_comb {
    // 用 repeat 產生常數 1,用 case 決定奇偶
    o_par = inside i_din { 0 } ? 1'b0 : 1'b1;
}

這個寫法是錯的! inside i_din { 0 } 只判斷整個向量是否等於 0,不是奇偶檢查。

正確的 case 表達式版本:

always_comb {
    o_par = case i_din {
        0: 1'b0,
        default: 1'b1,
    };
}

這也不對——case 是精確匹配,不是奇偶。

正確的做法就是 XOR 鏈(或平衡樹):

always_comb {
    let r_par : logic = 1'b0;
    for i: u32 in 0..WIDTH {
        r_par = r_par ^ i_din[i];
    }
    o_par = r_par;
}

這裡有一個值得強調的教訓:不是所有的功能都能用 case 表達式寫出來。case 是查表,硬體是固定的 n 對 1 映射;奇偶檢查是「對所有位元做歸約」,需要的是迴圈或專用結構。語言的每一種表達式都有它對應的硬體形態,工程師必須知道自己在描述什麼。

平衡樹版本(改善延遲):

always_comb {
    var r_level0 : logic<WIDTH>;
    var r_level1 : logic<WIDTH / 2>;
    var r_level2 : logic<WIDTH / 4>;

    for i: u32 in 0..WIDTH / 2 {
        r_level0[2*i]     = i_din[2*i]   ^ i_din[2*i+1];
        r_level0[2*i + 1] = 1'b0;
    }
    for i: u32 in 0..WIDTH / 4 {
        r_level1[i] = r_level0[2*i] ^ r_level0[2*i+1];
    }
    o_par = r_level1[0] ^ r_level1[1];
}

這個平衡樹的延遲是 \(O(\log W)\) ** 而非 \(O(W)\) 。這是「語言不替你最佳化」的具體例子**:語言忠實地展開你的敘述,效能的責任在你。


本章小結#

  1. 表達式在硬體裡描述的是資料流的選擇,不是計算過程。if 是多工器,case 是查表,for 是硬體複製。
  2. if 表達式取代巢狀三元運算子:條件不需括號、支援省略 else(但有明確的預設值)、兩條分支必須同型。
  3. case 表達式的 default 是強制的——表達式必須有值。這把「忘寫 default → 產生 latch」從合成後的警告提前到編譯期。
  4. 範圍 .. / ..= 與 Rust 一致,硬體上是比較器的並聯。
  5. x 萬用字元語意明確且單一:x 匹配任何值。這比 SV 的 casez / casex 更好記,代價是無法表達「嚴格匹配 x」——那要用 ==。
  6. switch 表達式以布林條件分派,等價於 if / else if 鏈,但可讀性更好,且工具能檢查條件重疊。
  7. for 迴圈的三種形式:項目層級的是硬體迴圈(編譯期展開),always_comb 內的是行為迴圈(串聯硬體),rev 與 step 控制迭代方向與步長。
  8. 「永遠無法到達範圍終點的 step 是錯誤」這條靜態檢查同時擋下無界展開、索引越界與「我以為它會收斂」的誤解。
  9. inside / outside 給出集合語意,取代 casez 的部分用途與手寫的 >= && <= 鏈;outside 的硬體結構與 case 相同。
  10. 位元選擇工具:+: / -:(方向相反的下標計算)、step 選擇(等價於 base*w +: w)、msb / lsb(相對於型別寬度,消除手算 WIDTH-1)。
  11. repeat 取代 SV 的 {N{x}},並可與陣列字面量、struct 建構子結合;..default(0) 對 const 特別重要。
  12. as 的優先序高於算術運算子,x as W + 1 解析為 (x as W) + 1——這是硬體結果會不同的陷阱,括號是語義的一部分。
  13. 具名引數不能與位置引數混用,規則簡單而嚴格。
  14. 運算子優先序表:as 優先序高(2)、= 最低(16)、<: / -: 屬於索引語法的一部分。
  15. 生成的 SV 把所有抽象還原成 SV 能表示的形式:if 表達式 → 巢狀三元、case 表達式 → always_comb + case 敘述、inside → 比較鏈、msb → 具體索引、repeat → 重複串接。

五章的核心語法至此結束。下一章(6.1 起)我們進入時鐘域與跨時鐘域安全——這是硬體設計中最難的一類 bug,也是第 4 章的 clock 型別與 'a 時鐘域標註真正發揮作用的地方。


下一節:第 6.1 節「時鐘域的概念與 Veryl 的時鐘域標註」