5.1 資料型別系統:logic、bit、u32、p32 與複合型別#
硬體語言裡最不「硬體」的部分#
前面三章我們處理的是語言中最硬體的部分:時序、並行、接線規則。那些語言特性直接對應硬體結構,設計時的推理是直覺的。
型別系統則是另一個故事。在硬體描述語言裡,型別系統要同時滿足兩個互相拉扯的需求:
- 表達硬體:位寬、有號性、編碼方式、記憶體深度——這些都是硬體參數。
- 防止低階錯誤:位寬溢位、索引越界、符號延伸、寬度不匹配——這些是 bug 的高發來源。
SystemVerilog 在這兩者之間取了一個「什麼都允許」的位置,結果是內建型別混亂、隱式轉換規則複雜、寬度錯誤在編譯期被放行但在硬體上致命三個問題同時存在。
本章的主題是:Veryl 如何把「硬體設計中最常見的常數與寬度錯誤」搬進型別系統。其中的關鍵設計是 p32 這類正整數型別——它把一個純粹是「數學上的約束」變成「型別上的約束」。
SystemVerilog 型別系統的問題#
內建型別清單#
SystemVerilog 的內建型別有十幾種。我們把最常見的列出來:
| SV 型別 | 位寬 | 有號 | 二態 | 用途 |
|---|---|---|---|---|
reg | 1 | — | 4 態 | 舊式變數宣告 |
logic | 1 | — | 4 態 | 最通用的資料型別 |
bit | 1 | — | 2 態 | 純二態值 |
byte | 8 | 有號 | 4 態 | 8-bit 有號整數 |
shortint | 16 | 有號 | 4 態 | 16-bit 有號整數 |
int | 32 | 有號 | 4 態 | 32-bit 有號整數 |
longint | 64 | 有號 | 4 態 | 64-bit 有號整數 |
integer | 32 | 有號 | 4 态 | 32-bit 有號整數(與 int 同寬但語意不同) |
time | 64 | 無號 | 4 態 | 時間 |
real | 64 | — | 2 態 | 雙精度浮點 |
shortreal | 32 | — | 2 態 | 單精度浮點 |
問題一:名字不直觀#
看這幾個名字:
byte是 8-bit 有號。不是 8-bit 無號,不是 1 byte 的無符號。int是 32-bit 有號。integer也是 32-bit 有號,但它是原子型別(atomic),而int不是——int可以被 part-select。
byte 這個名字尤其誤導:C 語言的 unsigned char 是 8-bit 無號,但 SV 的 byte 是有號的。要寫一個 8-bit 無號值,SV 裡通常得寫 logic [7:0] 或 bit [7:0]。
問題二:int 與 integer 的並存#
這兩個型別位寬相同、有號性相同、四態性相同,差別只在 atomic 性質。而它們的預設值還不同(int 預設 0,integer 預設 x)。這是純粹的歷史包袱。
結果:工程師在寫 RTL 時,根本不用 int 與 integer(因為預設 x 會造成模擬與硬體的差異),他們用的是 logic [31:0]。於是這兩個型別的主要用途淪為測試平台與迴圈索引。這是語言設計的失敗信號:語言提供的型別,工程師不用。
問題三:位寬與有號性的隱式轉換#
SV 的表達式運算有一組非常複雜的規則:
- 位寬:運算結果的位寬取兩操作數中較寬者,再依運算子調整。
- 有號性:只要任一操作數是無號,結果就是無號。
- 上下文決定位寬:賦值時,左邊的位寬會「回推」影響右邊的計算位寬。
這套規則的實際效果是:
logic [7:0] a;
logic [15:0] b;
logic [31:0] c;
c = a + b; // 結果位寬是 16,然後零擴充到 32
看似無害,但在乘法與移位上會出問題:
logic [7:0] a;
logic [7:0] b;
logic [15:0] prod;
prod = a * b; // 8x8 的乘積需要 16 bits,但 SV 的推導是 8 bits!
是的,a * b 在 SystemVerilog 中是在 8-bit(或 max(8, 8) = 8 bit)的寬度上計算的,結果被截斷成 8 bit,再零擴充到 16 bit。乘法結果的高 8 位永遠是 0。 這是 SV 中最著名的陷阱之一,而它的根源是 SV 讓「運算結果的位寬」取決於操作數而非操作本身。
要寫正確的乘法,必須手動擴充:
prod = a * b; // 錯
prod = 16'(a) * 16'(b); // 對,但囉嗦
問題四:隱式截斷#
logic [3:0] small;
logic [7:0] big;
big = small; // 零擴充,OK
small = big; // 截斷!高位被丟掉,只是一個警告
SV 對這類截斷給警告但不是錯誤,而且在很多專案裡,警告被當作雜訊關掉了。
小結:SV 的型別系統基本上是「語法上什麼都允許,靠警告提示」。這在軟體語言裡是不可接受的,因為軟體裡的隱式截斷幾乎必然是 bug;但在硬體語言裡,它卻是常態——因為硬體的位寬計算本來就複雜。
Veryl 的內建型別#
Veryl 的內建型別分成六組。
第一組:位向量基底 logic 與 bit#
var r_bit : logic // 1 bit,四態
var r_byte : logic<8> // 8-bit,四態
var r_2d : logic<8, 4> // packed 二維:8 bits × 4 個元素
var r_2state: bit<8> // 8-bit,二態
| 型別 | 狀態數 | 用途 |
|---|---|---|
logic | 4(0/1/x/z) | 硬體資料路徑的預設選擇 |
bit | 2(0/1) | 純二態的資料、位元索引、控制訊號 |
為什麼 logic 是預設? 因為在硬體裡,「這個訊號在某些時刻尚未被賦值」是真實存在的情況(尤其在模擬初期)。四態讓模擬器能表達「未知」,這是找出 bug 的重要工具。位元索引、迴圈計數器這類永遠不會是 x 的值,才用 bit。
型別修飾符:signed 與 tri#
var r_s : signed logic<32>
var r_t : tri logic<8>
signed:把位向量解讀成二補數的有號整數。tri:三態(0/1/z),對應 SV 的tri。
注意 signed 是修飾符,不是獨立型別。這與 SV 一致,但 SV 裡 signed 可以寫在型別的任意位置(logic signed [31:0]、signed logic [31:0]),Veryl 把它固定成「修飾符在前」的位置。
第二組:固定寬度整數型別#
var r_u8 : u8 // 8-bit 無號
var r_u16 : u16
var r_u32 : u32
var r_u64 : u64
var r_i8 : i8 // 8-bit 有號
var r_i16 : i16
var r_i32 : i32
var r_i64 : i64
這是 Veryl 最重要也最直觀的改進之一:名字直接編碼寬度與符號性。
| Veryl | 位寬 | 符號 | 狀態數 |
|---|---|---|---|
u8 | 8 | 無號 | 2 |
u16 | 16 | 無號 | 2 |
u32 | 32 | 無號 | 2 |
u64 | 64 | 無號 | 2 |
i8 | 8 | 有號 | 2 |
i16 | 16 | 有號 | 2 |
i32 | 32 | 有號 | 2 |
i64 | 64 | 有號 | 2 |
這個設計的價值在於「一眼可讀」。 在 SV 裡,「我要一個 32-bit 無號值」要寫 logic [31:0],而且要小心不要寫成 int(有號)或 integer(預設 x)。在 Veryl 裡,u32 沒有任何歧義。
而且它們是二態的(u32 對應 SV 的 bit [31:0]),這消除了 SV 中 int / integer 預設值為 x 的問題。
對照 SV:
| SV | Veryl | 說明 |
|---|---|---|
bit [7:0] | u8 | 8-bit 二態無號 |
logic [7:0] | logic<8> | 8-bit 四態 |
byte | i8 | 8-bit 有號 |
int | i32 | 32-bit 有號(二態) |
longint | i64 | 64-bit 有號 |
integer | — | 沒有對應物(它的存在理由是 int 的祖先) |
第三組:正整數型別 p8 / p16 / p32 / p64#
這是 Veryl 在 2026 年新增的一組型別,也是本章最值得討論的設計。
var r_p32 : p32 // 32-bit,保證值為正(或 0)
| 型別 | 位寬 | 約束 |
|---|---|---|
p8 | 8 | \(0 \le v \le 255\) |
p16 | 16 | \(0 \le v \le 65535\) |
p32 | 32 | \(0 \le v \le 2^{32}-1\) |
p64 | 64 | \(0 \le v \le 2^{64}-1\) |
為什麼硬體設計需要「正整數」#
讓我們先想清楚:硬體設計裡,哪些值在語意上必然是正的?
答案是:幾乎所有用來描述「結構大小」與「索引」的量。
| 例子 | 語意 | 典型錯誤 |
|---|---|---|
資料寬度 WIDTH | 位元數,必然 ≥ 1 | WIDTH = 0 產生零寬邏輯 |
陣列深度 DEPTH | 元素數,必然 ≥ 1 | DEPTH = 0 |
| 指標寬度 | 位元數 | 計算結果為 0 |
| 位元索引 | 索引,必然 ≥ 0 | 負索引 |
| 除法的除數 | 必然 ≠ 0 | 除零 |
$clog2 的參數 | 必須 ≥ 1 | $clog2(0) 未定義 |
這些量在 SV 裡都用 integer 或 int 宣告,編譯器完全不管它們是否合法。
一個具體例子#
// SystemVerilog:沒有任何保護
module Memory #(
parameter integer DEPTH = 16,
parameter integer WIDTH = 32
) (
input logic clk,
input logic [$clog2(DEPTH)-1:0] addr,
input logic [WIDTH-1:0] wdata,
output logic [WIDTH-1:0] rdata
);
logic [WIDTH-1:0] mem [0:DEPTH-1];
...
endmodule
在 SV 裡,DEPTH 與 WIDTH 是 integer,可以設成任何值:
DEPTH = 0:$clog2(0)是未定義(多數工具回傳 0),然後mem [0:-1]是一個空陣列,寫入無效,讀出x。WIDTH = 0:logic [-1:0]是零寬向量。DEPTH設定成不二的冪(例如 15):$clog2(15) = 4,位元索引能表示 0..15,但記憶體只有 15 個元素——位元寬度是對的,範圍不對。
這些錯誤在編譯期完全不會被發現。
Veryl 的做法#
module Memory #(
parameter DEPTH : p32 = 16,
parameter WIDTH : p32 = 32,
) (
i_clk : clock,
i_addr : input logic<$clog2(DEPTH)>,
i_wdata : input logic<WIDTH>,
o_rdata : output logic<WIDTH>,
) {
var r_mem : logic<WIDTH> [DEPTH]
always_ff (i_clk) {
if i_we {
r_mem[i_addr] = i_wdata;
}
}
assign o_rdata = r_mem[i_addr];
}
現在 DEPTH 與 WIDTH 是 p32,它們在型別層面就保證了:
這帶來三個好處:
(一)負值在編譯期被拒絕。 parameter DEPTH: p32 = -1; → 編譯期錯誤。
(二)溢位在編譯期被拒絕。 parameter DEPTH: p32 = 4294967296; → 超過 \(2^{32}-1\) ,編譯期錯誤。
(三)運算結果的正性被推導。 這是最微妙也最有用的一點。考慮:
const HALF : p32 = WIDTH / 2;
如果 WIDTH 是 p32,那麼 WIDTH / 2 的型別推導結果仍然是 p32(正整數除以正整數仍是正整數),因此 HALF 的宣告合法。如果 WIDTH 可能是負數,這個除法的結果可能是負的,HALF 的宣告就會報錯。
這就是「把型別資訊串起來」的威力:一個約束會沿著資料流往下傳播,讓下游的錯誤在最早的源頭被攔截。
與 u32 的差別#
| 型別 | 保證 |
|---|---|
u32 | 值在 \([0, 2^{32}-1]\) (由位寬決定) |
p32 | 值在 \([0, 2^{32}-1]\) 且語意上為非負的結構量 |
在位元層級兩者完全相同。差別在於:
p32帶有「這個量語意上必須是非負的」這個宣告,因此可以參與型別推導的規則。p32可以用在需要正性保證的位置——例如$clog2()的參數、位元寬度、陣列長度。u32可以用在任何需要 32-bit 無號值的位置——例如位元向量、位元遮罩。
實務上的用法:模組的結構參數(寬度、深度)用 p32;資料路徑上的無號值用 u32。
更進一步:正整數的推導規則#
我們把「正整數」的推導規則形式化。設 \(\mathbb{Z}_{+}\) 為正整數的集合(此處含 0), \(\mathbb{Z}\) 為整數集合。
Veryl 的推導規則可以近似表述為:
注意第二個分支:正數除以正數仍是正數,所以結果型別仍是 p32。這個簡單的規則有強大的連鎖效果:
const A : p32 = 100;
const B : p32 = A / 4; // p32
const C : p32 = B + 1; // p32
const D : p32 = C * 2; // p32
const E : p32 = $clog2(D); // p32,且語意正確
如果其中任何一環的值變成負的,錯誤會在那一環就爆發,而不是在最後才以「某個常數是負的」這種難以追查的形式出現。
第四組:浮點與字串#
var r_f32 : f32 // IEEE 754 單精度
var r_f64 : f64 // IEEE 754 雙精度
var r_s : string // 主要用於模擬
浮點在硬體裡的特殊地位:浮點運算在 ASIC 中通常是一個相對昂貴的硬體區塊(或者用軟體模擬)。因此 Veryl 把 f32 / f64 做成明確的型別,而不是讓工程師用 real(在 SV 中 real 是 64-bit 雙精度),好讓工具能辨識「這段邏輯需要浮點硬體」。
第五組:type 型別#
type word_t = logic<16>;
type addr_t = p32;
type 宣告的是型別別名。它可以用在 parameter 與 const 上:
type word_t = logic<16>;
const DEFAULT_WORD : word_t = 16'h0000;
在 SV 裡對應的是 typedef:
typedef logic [15:0] word_t;
localparam word_t DEFAULT_WORD = 16'h0000;
Veryl 的 type 與 const 的關係:type 是「一個型別的名字」,const 是「一個有名字的值」。兩者都是編譯期實體。
第六組:布林型別的別名#
var r_b : bbool // bit<1> 的別名
var r_l : lbool // logic<1> 的別名
| 型別 | 等價 |
|---|---|
bbool | bit<1> |
lbool | logic<1> |
以及布林常值 true 與 false:
const ENABLED : bbool = true;
為什麼需要 bbool / lbool 這樣的別名?
因為在硬體裡,「布林」其實是一個位元,但它的語意與一般的資料位元不同:它通常不應該是 x。如果工程師寫 logic 型的布林變數,模擬初期會是 x,導致 if (flag) 的結果在模擬初期是 x 而不是 false——這在驗證時可能造成困惑。
bbool 明確表示「這是一個二態布林」,模擬初值是 0。
寬度推導規則#
logic 與 bit 的預設寬度#
var r_a : logic // 1 bit
var r_b : logic<8> // 8 bit
var r_c : logic<8, 4> // packed 二維
logic 不帶寬度時是 1 bit。
特殊寬度字面值#
SV 有四個特殊的寬度字面值,它們的意義是「在這個上下文中,被填滿」:
| 字面值 | 意義 |
|---|---|
'0 | 全部填 0 |
'1 | 全部填 1 |
'x | 全部填 x |
'z | 全部填 z |
在 Veryl 中它們用同樣的寫法:
var r_a : logic<8> = '0; // 8'b0000_0000
var r_b : logic<8> = '1; // 8'b1111_1111
關鍵性質:'0 的位寬是由上下文決定的。當它出現在一個 8-bit 的賦值右側時,它就是 8 bits。
這是 SV 的「上下文決定位寬」機制。Veryl 保留了這個機制(因為它在硬體裡非常實用:填滿一個寬度明確的欄位),但把它限制在明確寫出寬度的上下文中。
指定寬度的字面值#
const A : logic<8> = 8'hAB;
const B : logic<8> = 8'b1010_1011;
const C : logic<8> = 8'd171;
const D : logic<8> = 8'o253;
const E : logic<8> = 8'sd-85;
格式是 <寬度>'<基準><數字>:
| 基準 | 說明 |
|---|---|
b | 二進位 |
o | 八進位 |
d | 十進位 |
h | 十六進位 |
sd | 有號十進位 |
底線分隔#
const MASK : logic<32> = 32'hFFFF_FF00;
const VEC : logic<8> = 8'b1010_1011;
底線只是分隔符,不影響數值。它是硬體工程師的標準閱讀習慣(位元組分組、位域對齊),Veryl 支援它。
複合型別#
struct:結構體#
struct Point {
x : logic<16>,
y : logic<16>,
}
var r_p : Point
欄位以 . 存取:
assign r_p.x = 16'h1234;
在 SV 裡對應 struct packed,Veryl 的 struct 預設是 packed(所有欄位連續位元排列)。
struct 建構子:Struct'{...}#
var r_p : Point = Point'{x: 16'h1234, y: 16'h5678};
語法是 型別名'{ 欄位: 值, ... }。這與 SV 的 '{...} 賦值模式(assignment pattern)對應。
..default:未指定欄位的預設值#
var r_p : Point = Point'{x: 16'h1234, ..default(0)};
..default(0) 的意思是「所有未指定的欄位,都填這個值」。
這個特性對 const 特別重要,理由如下。
在 SystemVerilog 裡,const 物件的初始化必須是一個常數表達式,而賦值模式(assignment pattern)中的元素若是省略,會被填 x——但在 const 的脈絡下,x 不是合法的常數。因此 SV 裡要給 const 賦一個部分初始化的結構體是很麻煩的。
Veryl 的 ..default(值) 提供了一個明確的機制:
struct Config {
addr_width : p32,
data_width : p32,
enable_sync : bbool,
}
const DEFAULT_CFG : Config = Config'{
addr_width: 32,
..default(0),
};
這裡 enable_sync 會被明確設成 0(bbool 的二態 0),而不是 x。
..default 也讓「新增欄位」變成安全的修改。 如果日後 Config 新增了一個欄位,..default(0) 會讓新欄位自動得到 0,而不是變成 x 造成隱藏的 bug。
enum:列舉#
enum State {
Idle,
Run,
Done,
}
var r_s : State
預設基礎型別是 logic,寬度由變體數推斷。 三個變體需要 2 bits,所以 State 實際是 logic<2>。
也可以指定基礎型別:
enum State : logic<4> {
Idle,
Run,
Done,
}
enum Flag : bit<1> {
Off,
On,
}
注意:指定基礎型別只決定狀態數(四態或二態)與有號性,寬度仍然是推斷出來的(除非你像 logic<4> 這樣明確指定)。這與 SV 的 enum logic [3:0] { ... } 相同。
列舉編碼:#[enum_encoding(...)]#
#[enum_encoding(sequential)]
enum State {
Idle,
Run,
Done,
}
#[enum_encoding(onehot)]
enum State {
Idle,
Run,
Done,
}
#[enum_encoding(gray)]
enum State {
Idle,
Run,
Done,
}
| 編碼 | 說明 | 適用場景 |
|---|---|---|
sequential | 依序 0, 1, 2 | 預設;一般狀態機 |
onehot | 每個變體一位元中只有一位為 1 | FPGA;降低解碼邏輯的組合深度 |
gray | 格雷碼,相鄰變體只差一位元 | 降低狀態轉換時的 glitch 與功耗 |
one-hot 編碼在 FPGA 上特別常見:在 LUT 型架構的 FPGA 中,用 one-hot 編碼的狀態機可以用一組 LUT 直接解碼,避免深層的優先編碼器電路。語言層級提供這個屬性,讓編碼成為「宣告」而不是「手工設計」。
union:聯合型別#
union Value {
int_val : i32,
flt_val : f32,
}
Veryl 的 union 是 packed、untagged 的 sum type:
- packed:所有變體共用同一組位元。
- untagged:型別系統不記錄當前是哪個變體。
這對應 SV 的 packed union:
typedef union packed {
int int_val;
float flt_val;
} Value;
限制:所有變體的位寬必須相同。 這是 packed union 的自然限制,因為它們共用位元。
untagged 的後果:讀取 union 時,你必須自己知道目前存的是哪個變體。型別系統不會阻止你讀錯。語言在這裡的選擇是保守——只提供最簡單、最可預測的形式。
型別別名#
type word_t = logic<16>;
type idx_t = p32;
陣列#
<> 與 [] 的語意#
這是 Veryl 型別系統中最重要也最容易搞混的一條規則:
<>內的維度是「打包」維度,[]內的維度是「非打包」維度。
| Veryl | SV | 硬體意義 |
|---|---|---|
logic<8> | logic [7:0] | 8-bit 向量 |
logic<8, 4> | logic [7:0] [3:0] | packed 二維(8 bits × 4 元素,共 32 bits) |
logic [4] | logic [3:0] [7:0] | unpacked:4 個 8-bit 元素(記憶體) |
logic<8> [4] | logic [7:0] [3:0] | unpacked:4 個 8-bit 元素 |
logic [4, 2] | logic [3:0] [1:0] | unpacked 二維 |
判斷的直覺:
- 打包(packed) = 位元層級的組織,相鄰元素的位元是連續的。適合純粹的資料位元操作。
- 非打包(unpacked) = 元素層級的組織,每個元素是獨立的位元向量。適合記憶體、陣列暫存器。
硬體上的區別很重要:
var r_packed : logic<8, 4> // 32 bits 的單一向量
var r_unpacked : logic<8> [4] // 4 個獨立的 8-bit 向量
前者可以用 r_packed[2][0] 這種方式做跨元素的位元操作;後者不行,因為元素之間沒有連續的位元關係。
多維陣列與索引#
var r_mem : logic<10> [10, 20, 30]
索引寫法:
r_mem[0][0][0]
r_mem[3][7][12]
索引的順序是從左到右對應維度從外到內(也就是 SV 的 logic [9:0][19:0][29:0] mem 的索引順序)。
陣列長度由參數決定#
module Memory #(
parameter DEPTH : p32 = 16,
parameter WIDTH : p32 = 32,
) {
var r_mem : logic<WIDTH> [DEPTH]
}
這是硬體設計中最常見的參數化需求。
型別推斷#
let / var / const 的型別可省略#
let r_a = 32'h1234_5678; // 型別推斷為 logic<32>
var r_b = r_a; // 型別推斷為 logic<32>
const r_c = 100; // 型別推斷為 u32(或 p32)
但這裡有一個重要的細節:const 的型別推斷會盡可能地「收窄」到最小的整數型別。const r_c = 100; 會被推斷成 u8(或類似的最Fit型別),因為 100 放得下 8 bits。
這與 SV 的常數推導不同(SV 推導成 32-bit int),而 Veryl 的做法更緊湊,產生的 SV 也更乾淨:
localparam logic [7:0] r_c = 8'd100;
泛型參數可由呼叫端推斷#
function Width::<T: u32>(a: T, b: T) -> T {
return a > b ? a : b;
}
呼叫時可以省略型別參數:
let r = Width(8'd3, 8'd5);
型別參數 T 被推斷為 u8。
為什麼埠與函式簽章仍要求明確型別#
這是一個重要的設計取捨。
推論(inference)在「表達式」很好用,在「介面契約」很危險。
考慮:
module Top (
i_d : input logic<8>,
o_q : output logic<8>,
) {
}
如果允許 i_d : input(讓型別從別處推斷),會發生什麼?
- 讀者看不到介面契約。 一個模組的埠清單是它的公開介面。看到
i_d : input logic<8>,任何人都不需要看別處就知道這根線是 8 bits。省略型別後,這項資訊就消失了。 - 推論的依據可能是隱晦的。 型別從哪裡來?如果是從預設值推論(
i_d : input = 0→ 推斷成 1 bit?),那推論結果可能與工程師的直覺不符。 - 對外誤導。 埠的型別是模組與外界之間的契約。契約應該是明確寫出的。
函式簽章也是同樣的道理:
function Max(a: u32, b: u32) -> u32 { ... }
回傳型別尤其重要——回傳型別應該是契約的一部分,而不是推論出來的。如果回傳型別是推論的,那麼函式的實作細節一改,回傳型別就可能跟著變,這是危險的。
Veryl 的規則因此是:
| 位置 | 型別要求 |
|---|---|
| 模組埠 | 必須明確 |
| 函式參數與回傳型別 | 必須明確 |
let / var / const 宣告 | 可省略(由右側推斷) |
| 泛型參數 | 可省略(由呼叫端推斷) |
這是「契約要明確,實作可以簡潔」的原則。
型別檢查器的演進:2025-02 的「新版型別檢查器」#
Veryl 在 2025 年 2 月發布了一版重寫的型別檢查器。這次演進的意義不在於修了多少 bug,而在於它解決了哪一類新的錯誤。
推斷帶來的新錯誤類別#
在有推論的語言裡,錯誤不只來自「型別不相容」,還來自「推論不出唯一的答案」。
典型的推論歧義:
let r_a = 0; // 0 是整數字面值,型別不明確
0 可以是 i32、u32、p32、logic<1>、bit<1>……推論結果取決於上下文。如果沒有上下文(例如在一個 let 宣告中,宣告本身也沒有型別),就產生歧義。
另一個例子:
fn f(a: u32) -> u32 { return a; }
fn g(a: u32) -> u32 { return a; }
let r = f(1) + g(2); // 1 和 2 的型別是什麼?
新版檢查器引入的規則包括:
| 規則 | 說明 |
|---|---|
| 整數字面值預設型別 | 未標註的整數字面值預設為 i32(有號),避免無意的無號化 |
| 無上下文時報錯 | 若一個值的型別無法唯一決定,編譯器要求明確標註 |
| 推論結果的窄化檢查 | 從 u32 推論到 u8 時檢查是否會截斷 |
| 泛型推論的唯一性 | 泛型參數若有多個可行的推論結果,要求呼叫端明確指定 |
這次演進的教訓#
推論是一把雙刃劍:
- 好處:程式碼更簡潔,減少樣板資訊。
- 代價:引入了一類新的錯誤(推論歧義),而且這類錯誤的診斷訊息比「型別不相容」更難懂。
Veryl 的策略是「在表達式內大膽推論,在契約處嚴格要求明確」——這正是上一節那條原則的延伸。
生成的 SystemVerilog 對照#
對照一:基本型別#
Veryl:
module Types (
i_a : input logic<8>,
i_b : input u32,
i_c : input p32,
o_a : output logic<8>,
o_b : output u32,
) {
assign o_a = i_a;
assign o_b = i_b + i_c;
}
生成的 SystemVerilog:
module Types (
input logic [7:0] i_a,
input logic [31:0] i_b,
input logic [31:0] i_c,
output logic [7:0] o_a,
output logic [31:0] o_b
);
assign o_a = i_a;
assign o_b = i_b + i_c;
endmodule
注意 p32 與 u32 在 SV 層面都是 logic [31:0]——它們的差別只在 Veryl 的型別層面(正性約束)。這是「抽象必須在語言邊界被還原」的例子:p32 的正性約束在編譯期已經被檢查完畢,產生 SV 時不需要保留。
對照二:整數常值的窄化#
Veryl:
const WIDTH : p32 = 32;
const MASK : logic<8> = 8'hFF;
生成的 SystemVerilog:
localparam logic [31:0] WIDTH = 32'd32;
localparam logic [7:0] MASK = 8'hff;
32'd32 這個形式值得注意。 SV 中單獨寫 32 是 32-bit 的有號 int,寫成 32'd32 則明確標示了位寬與無號性。Veryl 產生時一律把常數寫成「寬度 + 基準 + 數字」的形式,消除隱式轉換的疑義。
對照三:struct 與 ..default#
Veryl:
struct Config {
addr_width : p32,
enable : bbool,
}
const DEFAULT_CFG : Config = Config'{
addr_width: 32,
..default(0),
};
生成的 SystemVerilog:
typedef struct packed {
logic [31:0] addr_width;
logic enable;
} Config;
localparam Config DEFAULT_CFG = '{addr_width: 32'd32, enable: 1'b0};
注意 ..default(0) 被展開成每個未指定欄位的明確賦值。 這是必要的,因為 SV 的賦值模式在省略欄位時會填 x,而 localparam 不能含 x。
對照四:enum 與編碼#
Veryl:
#[enum_encoding(onehot)]
enum State {
Idle,
Run,
Done,
}
生成的 SystemVerilog:
typedef enum logic [1:0] {
Idle = 2'b00,
Run = 2'b01,
Done = 2'b10
} State;
注意 one-hot 編碼需要 3 bits(3 個變體),但 SV 的 enum 基礎型別仍然是推斷出的寬度。這裡展示了 SV 與 Veryl 在 one-hot 上的一個表示能力差距——SV 的 enum 只能表達 sequential 編碼(以及少數手動指定值的情形),one-hot 需要 Veryl 這層的編碼屬性來承載。
設計討論:把「正整數」放進型別系統的價值#
我們在這裡停下來,認真評估 p32 這個設計。
硬體設計裡的 bug 分布#
在實際的硬體專案中,bug 的分布大致如下(不同領域略有差異,但順序相當穩定):
| 類別 | 佔比 | 典型例子 |
|---|---|---|
| 寬度/常數相關 | 約 30–40% | 位寬溢位、索引越界、$clog2 計算錯誤 |
| 握手/協定 | 約 20–30% | 握手中斷、狀態機死結 |
| 時序/時鐘域 | 約 15–20% | CDC、重置解除 |
| 功能邏輯 | 約 10–20% | 算術錯誤、條件寫反 |
| 其他 | 少量 | 工具問題、環境問題 |
寬度與常數相關的錯誤佔了三分之一以上。 這不是巧合:硬體設計的本質就是不斷地問「這裡需要幾個位元?」
三個典型例子#
(一)位寬溢位。
const SUM_W : p32 = A_W + B_W; // 正確
var r_sum : logic<SUM_W>
如果 A_W 與 B_W 是 u32 而非 p32,A_W + B_W 仍然是 u32,不會報錯——但如果 A_W + B_W 超過 \(2^{32}-1\) (不太可能),就會靜默溢位。用 p32 至少保證「這是個結構量,不會是負的」。
(二)索引越界。
var r_mem : logic<WIDTH> [DEPTH]
assign r_out = r_mem[i_addr];
如果 i_addr 的型別是 u8 而 DEPTH 是 300,那麼 i_addr 的 0..255 都在範圍內——沒問題。但如果 i_addr 是 u16 而 DEPTH 是 300,那麼 300..65535 就是越界。SV 對 unpacked 陣列的越界存取不做任何檢查(回傳 x)。
在 Veryl 中,若 DEPTH 是 p32 型的常數,型別檢查器可以把 i_addr 的寬度與 DEPTH 比較,並在無法保證安全時警告。 這是把「陣列索引必須在範圍內」這條規則部分地變成型別規則的機會。
(三)$clog2 計算錯誤。
const PTR_W : p32 = $clog2(DEPTH);
這裡有兩個潛在錯誤:
$clog2需要DEPTH ≥ 1(否則未定義)。用p32與額外檢查可以攔截DEPTH = 0。- 當
DEPTH不是 2 的冪時,$clog2(DEPTH)給出的位寬能表示比DEPTH大的值。例如DEPTH = 100,$clog2(100) = 7(能表示 0..127),但記憶體只有 100 格。這時指標會繞回,讀到錯誤的資料。
正確的實作需要額外處理:
const PTR_W : p32 = $clog2(DEPTH);
var r_addr : logic<PTR_W>
// 必須用 if 限制 addr < DEPTH
或者(更常見的做法)把記憶體深度取成 2 的冪:
const DEPTH_LOG2 : p32 = $clog2(REQ_DEPTH);
const DEPTH : p32 = 1 << DEPTH_LOG2;
「正整數」的抽象層級#
我們注意到 p32 這個設計的抽象層級是語意層面的(「這個量在語意上是非負的結構量」),而不是單純的位寬層面。這帶來一個好處:
正性可以在運算中傳播(正 ÷ 正 = 正),所以約束能沿著資料流一路向下,把錯誤攔截在源頭。
這與「把寬度寫在名字裡」是兩層不同的改進:
| 改進 | 解決的問題 |
|---|---|
u32 / i32 | 名稱不直觀(byte 是有號的 8-bit!) |
p32 | 常數的非負性無法被表達 |
誠實的評價#
p32 這類型別不是完美的:
- 它是語義標籤,不是硬體差異。 生成的 SV 裡
p32就是logic [31:0]。所有價值都發生在編譯期。 - 它增加了一個要記的型別。 學習曲線變長了。
- 它的檢查能力有限。 它能擋住負的與溢出的常數,但擋不住「 \(\text{DEPTH} = 100\) 但位寬是 7」這種語意不匹配——那需要更深的分析。
但它把一個「永遠都是約定俗成」的規則變成了「編譯器保證」,而這正是 Veryl 的整體設計哲學。第 4 章我們用 clock / reset 型別做了同樣的事;這裡只是同一個哲學在資料型別上的應用。
實作練習#
練習一:AXI 風格的型別包#
題目: 實作一個 AxiPkg 套件,包含匯流排寬度常數、狀態機列舉與一個結構化的描述子型別。
解答:
package AxiPkg {
pub const ADDR_WIDTH : p32 = 32;
pub const DATA_WIDTH : p32 = 64;
pub const ID_WIDTH : p32 = 4;
pub const LEN_WIDTH : p32 = 8;
pub const STRB_WIDTH : p32 = DATA_WIDTH / 8;
pub const MAX_BURST : p32 = 256;
pub const BURST_LEN_W : p32 = $clog2(MAX_BURST);
pub enum Resp {
Okay,
ExOkay,
SlvErr,
DecErr,
}
pub struct Addr {
burst : u2,
size : u3,
len : logic<LEN_WIDTH>,
addr : logic<ADDR_WIDTH>,
}
pub struct Descriptor {
addr : Addr,
id : logic<ID_WIDTH>,
len : logic<LEN_WIDTH>,
strobe : logic<STRB_WIDTH>,
}
}
這個套件展示了幾件事:
- 寬度常數之間有相依關係:
STRB_WIDTH由DATA_WIDTH推導,BURST_LEN_W由MAX_BURST推導。因為都是p32,這些推導是型別安全的。 - 狀態機列舉:四個變體 → 2 bits。
- 巢狀結構:
Descriptor內含Addr,Addr又含多個欄位。 pub:讓這些項目在套件外可見(見 3.3 節)。
注意 LEN_WIDTH 是 p32 型別但被用在 logic<LEN_WIDTH> 裡——這正是 p32 的用途場景:它是個結構尺寸常量。
練習二:一個帶寬度檢查的記憶體#
題目: 寫一個記憶體模組,並用 p32 型別表達它的寬度與深度,同時讓位址型別反映實際的位元寬度。
解答:
module Memory #(
parameter DEPTH : p32 = 256,
parameter WIDTH : p32 = 32,
) (
i_clk : clock,
i_addr : input logic<$clog2(DEPTH)>,
i_we : input logic,
i_wdata : input logic<WIDTH>,
o_rdata : output logic<WIDTH>,
) {
var r_mem : logic<WIDTH> [DEPTH]
always_ff (i_clk) {
if i_we {
r_mem[i_addr] = i_wdata;
}
}
assign o_rdata = r_mem[i_addr];
}
思考與延伸:
-
這裡
DEPTH預設 256 是 2 的冪,所以$clog2(DEPTH) = 8正好對應 256 個元素。若把DEPTH改成 100,位元寬度仍是 7(能表示 0..127),但記憶體只有 100 格——這就是「 \(\text{clog2}\) 與實際深度不一致」的問題。請思考如何用型別或 lint 把它變成可檢查的。 -
一個常見的改良是讓位址多一位,用來表達「無效」:
const PTR_W : p32 = $clog2(DEPTH);
i_addr : input logic<PTR_W + 1>,
(多出的最高位在某些協定中代表「無效」或「爆發結束」。)
練習三:找出型別錯誤#
題目: 下面這段程式碼有幾個型別問題。找出並修正它們。
module Bad #(
parameter WIDTH : i32 = 32,
parameter DEPTH : i32 = 16,
) (
i_clk : clock,
i_d : input logic<WIDTH>,
o_q : output logic<WIDTH>,
) {
var r_mem : logic<WIDTH> [DEPTH]
always_ff (i_clk) {
r_mem[0] = i_d;
}
assign o_q = r_mem[0];
}
問題清單:
| 問題 | 說明 | 修正 |
|---|---|---|
WIDTH : i32 | 結構尺寸不該是有號 | 改為 p32 |
DEPTH : i32 | 同上 | 改為 p32 |
logic<WIDTH> | 若 WIDTH 為負或 0 則無意義 | p32 + 額外檢查(≥ 1) |
r_mem[0] = i_d 在 always_ff 中 | r_mem 是記憶體,但只寫索引 0 | 這是邏輯問題(應該有位址輸入),不是型別問題——但正是「型別系統不管的事」 |
i_clk 未用於重置 | always_ff (i_clk) 沒有重置 | 對記憶體是合理的 |
這個練習的重點:型別系統只能擋下第一類問題。第二類(邏輯正確性)仍然是 lint 與 code review 的領域。型別系統是防禦的第一道防線,不是唯一防線。
本章小結#
- SV 的型別系統問題:名稱不直觀(
byte是有號的)、int與integer並存、隱式轉換規則複雜、隱式截斷只給警告。 - Veryl 的內建型別分六組:
logic/bit(位向量)、signed/tri(修飾符)、u8–u64/i8–i64(固定寬度整數)、p8–p64(正整數)、f32/f64/string、type別名與bbool/lbool。 u32這類命名把寬度與符號性編進名字,取代 SV 的byte/shortint/int/longint混亂。p32把「非負性」放進型別系統,而且正性能在運算中傳播(正 ÷ 正 = 正),讓錯誤在源頭被攔截。硬體設計中三到四成的 bug 與寬度/常數有關,這是型別系統能實質貢獻的地方。- 特殊寬度字面值
'0/'1/'x/'z由上下文填滿;指定寬度的字面值8'hAB明確無歧義;底線只作分隔。 - 複合型別:
struct(packed,欄位以.存取,Struct'{.., ..default(0)}建構子——..default對const特別重要)、enum(預設logic基底,寬度推斷,#[enum_encoding]控制編碼)、union(packed untagged,變體等寬)。 - 陣列:
<>是打包維度,[]是非打包維度——這是硬體結構的直接對應。 - 型別推論只在表達式裡進行;埠與函式簽章要求明確型別,因為那是契約。
- 2025-02 的新版型別檢查器處理了推論帶來的新錯誤類別(歧義、窄化、多重可行的泛型推論)。
- 生成的 SV 會把抽象還原:
p32變成logic [31:0]、..default展開成明確賦值、常數寫成32'd32形式。
下一節(5.2)我們處理參數化的第二個階段:不只改變常數值,也要改變型別。SV 的 parameter 做不到這件事,因此產生巨集地獄;Veryl 用泛型與 gen 把它變成語言層級的、可檢查的機制。
下一節:第 5.2 節「泛型與 gen:超越參數覆寫的程式碼生成」