5.3 表達式:if/case/switch、for/inside/outside 與 msb/repeat#
硬體語言裡最不「像程式語言」的部分#
到目前為止,我們處理的語言特性都跟「硬體」有關:時序、並行、接線規則、寬度。現在我們來看表達式——語言中最像軟體的部分。
但表達式在硬體語言裡有一個根本的不同:它描述的不是「計算過程」,而是資料流的選擇。一個 if 表達式在硬體上是一組多工器;一個 case 表達式是一組查表電路;一個 for 迴圈是硬體的複製。
正因為如此,Verilog / SystemVerilog 的表達式設計有幾個明顯的缺陷:
- 選擇只有三元運算子。
a ? b : c可以巢狀,但巢狀三元的可讀性會隨深度快速崩潰。 case的語意有漏洞。SV 的case在沒有匹配時不賦值——這在硬體上是 latch,在 SV 裡卻是合法語法。casez/casex的萬用字元語意令人困惑。casex把x當萬用元,導致4'bxxxx匹配任何值。- 位元操作的語法過載。
a[3+:1]、a[4-:2]、a[2 step 3]、$bits(a)、a[WIDTH-1:0]混雜在一起,每個都有微妙的語意。 - 沒有「集合」的抽象。判斷「某值是否在範圍集合內」需要手寫
>= && <=鏈。
本章的主題是: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 } ]
注意兩個要點:
-
條件不需要括號。
if i_a == 0而不是if (i_a == 0)。這是 Veryl 的一致性選擇(見 5.1 節:運算子優先序足以讓括號可選)。實際上,括號在需要時仍然允許。 -
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 鏈完全等價,但:
- 可讀性更好:多個平行條件寫在一起,不需要重複的條件運算子。
- 可以檢查互斥性:工具可以分析各 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(或工具的擴充行為),在硬體上則是一條接到未定義值的線。
這個靜態檢查把三個錯誤一起擋下:
- 無界的硬體展開(編譯期崩潰)。
- 行為迴圈中的索引越界。
- 「我以為它會迭代到終點」的誤解。
這是一個「語意錯誤被靜態檢查捕獲」的漂亮例子,而且它捕獲的是一個在 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] |
下標計算規則:
也就是說:
+:往 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[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 是「相對於型別寬度」的符號,它們的數值由型別決定:
這解決的是一個真實的高頻錯誤來源:「位寬參數化後手算 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 的價值:
- 消除重複計數。SV 的
{10{x}}要求工程師手寫 10,而x repeat 10裡的 10 常常就是某個參數——此時 SV 寫成{WIDTH{x}}(可以),但repeat更統一。 - 在陣列字面量中特別有用:
'{ 1, 2, 3 }
'{ 1 repeat 3 } // { 1, 1, 1 }
'{ 0, 2 repeat 4 } // { 0, 2, 2, 2, 2 }
'{ default: 3 } // 全部填 3
- 在非打包陣列的初始化中特別實用:
var r_mem : logic<32> [8] = '{ 0 repeat 8 };
陣列字面量#
'{ 1, 2, 3 }
這是 SV 的 assignment pattern。Veryl 的 '{...} 同樣出現在兩個位置:
- struct 建構子:
Point'{x: 1, y: 2}(見 5.1 節)。 - 陣列字面量:
'{ 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 的運算子優先序高於 +,解析結果是:
而不是我們想要的:
兩者的硬體結果完全不同:前者是「先截斷到 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
注意兩件事:
- 範圍
3..=5被展開成3, 4, 5——SV 的case沒有範圍語法。 - 生成的 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,
};
}
思考:
-
default是必要的:4-bit 輸入有 16 種可能,0..9 之外還有 10..15(六種無效的 BCD)。default把它們映射成「全熄」(7'b0000000)。這正是default強制存在所帶來的價值——它強迫設計者思考無效輸入。 -
硬體結構:這是一個 16-to-1 的查表電路。實際上可以實作成 LUT(FPGA)或解碼器 + AND-OR(ASIC)。
-
用
case表達式 vsalways_comb+case敘述:兩者硬體相同。表達式版本更簡潔,因為不需要中間變數。 -
驗證: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)\) 。這是「語言不替你最佳化」的具體例子**:語言忠實地展開你的敘述,效能的責任在你。
本章小結#
- 表達式在硬體裡描述的是資料流的選擇,不是計算過程。
if是多工器,case是查表,for是硬體複製。 if表達式取代巢狀三元運算子:條件不需括號、支援省略else(但有明確的預設值)、兩條分支必須同型。case表達式的default是強制的——表達式必須有值。這把「忘寫 default → 產生 latch」從合成後的警告提前到編譯期。- 範圍
../..=與 Rust 一致,硬體上是比較器的並聯。 x萬用字元語意明確且單一:x匹配任何值。這比 SV 的casez/casex更好記,代價是無法表達「嚴格匹配 x」——那要用==。switch表達式以布林條件分派,等價於if/else if鏈,但可讀性更好,且工具能檢查條件重疊。for迴圈的三種形式:項目層級的是硬體迴圈(編譯期展開),always_comb內的是行為迴圈(串聯硬體),rev與step控制迭代方向與步長。- 「永遠無法到達範圍終點的 step 是錯誤」這條靜態檢查同時擋下無界展開、索引越界與「我以為它會收斂」的誤解。
inside/outside給出集合語意,取代casez的部分用途與手寫的>= && <=鏈;outside的硬體結構與case相同。- 位元選擇工具:
+:/-:(方向相反的下標計算)、step選擇(等價於base*w +: w)、msb/lsb(相對於型別寬度,消除手算WIDTH-1)。 repeat取代 SV 的{N{x}},並可與陣列字面量、struct 建構子結合;..default(0)對const特別重要。as的優先序高於算術運算子,x as W + 1解析為(x as W) + 1——這是硬體結果會不同的陷阱,括號是語義的一部分。- 具名引數不能與位置引數混用,規則簡單而嚴格。
- 運算子優先序表:
as優先序高(2)、=最低(16)、<:/-:屬於索引語法的一部分。 - 生成的 SV 把所有抽象還原成 SV 能表示的形式:
if表達式 → 巢狀三元、case表達式 →always_comb+case敘述、inside→ 比較鏈、msb→ 具體索引、repeat→ 重複串接。
五章的核心語法至此結束。下一章(6.1 起)我們進入時鐘域與跨時鐘域安全——這是硬體設計中最難的一類 bug,也是第 4 章的 clock 型別與 'a 時鐘域標註真正發揮作用的地方。
下一節:第 6.1 節「時鐘域的概念與 Veryl 的時鐘域標註」