跳轉到

5.1 資料型別系統:logic、bit、u32、p32 與複合型別#

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

前面三章我們處理的是語言中最硬體的部分:時序、並行、接線規則。那些語言特性直接對應硬體結構,設計時的推理是直覺的。

型別系統則是另一個故事。在硬體描述語言裡,型別系統要同時滿足兩個互相拉扯的需求:

  1. 表達硬體:位寬、有號性、編碼方式、記憶體深度——這些都是硬體參數。
  2. 防止低階錯誤:位寬溢位、索引越界、符號延伸、寬度不匹配——這些是 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,它們在型別層面就保證了:

\[ 0 \le v < 2^{32} \]

這帶來三個好處:

(一)負值在編譯期被拒絕。 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]\) 且語意上為非負的結構量

在位元層級兩者完全相同。差別在於:

  1. p32 帶有「這個量語意上必須是非負的」這個宣告,因此可以參與型別推導的規則。
  2. p32 可以用在需要正性保證的位置——例如 $clog2() 的參數、位元寬度、陣列長度。
  3. u32 可以用在任何需要 32-bit 無號值的位置——例如位元向量、位元遮罩。

實務上的用法:模組的結構參數(寬度、深度)用 p32;資料路徑上的無號值用 u32。

更進一步:正整數的推導規則#

我們把「正整數」的推導規則形式化。設 \(\mathbb{Z}_{+}\) 為正整數的集合(此處含 0), \(\mathbb{Z}\) 為整數集合。

Veryl 的推導規則可以近似表述為:

\[ \text{type}(a \mathbin{/'} b) = \begin{cases} p_{32} & a, b \in \mathbb{Z}_{+},\ b \neq 0 \\ u_{32} & b \in \mathbb{Z}_{+},\ a \in \mathbb{Z} \\ i_{32} & \text{otherwise} \end{cases} \]

注意第二個分支:正數除以正數仍是正數,所以結果型別仍是 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(讓型別從別處推斷),會發生什麼?

  1. 讀者看不到介面契約。 一個模組的埠清單是它的公開介面。看到 i_d : input logic<8>,任何人都不需要看別處就知道這根線是 8 bits。省略型別後,這項資訊就消失了。
  2. 推論的依據可能是隱晦的。 型別從哪裡來?如果是從預設值推論(i_d : input = 0 → 推斷成 1 bit?),那推論結果可能與工程師的直覺不符。
  3. 對外誤導。 埠的型別是模組與外界之間的契約。契約應該是明確寫出的。

函式簽章也是同樣的道理:

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);

這裡有兩個潛在錯誤:

  1. $clog2 需要 DEPTH ≥ 1(否則未定義)。用 p32 與額外檢查可以攔截 DEPTH = 0。
  2. 當 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 這類型別不是完美的:

  1. 它是語義標籤,不是硬體差異。 生成的 SV 裡 p32 就是 logic [31:0]。所有價值都發生在編譯期。
  2. 它增加了一個要記的型別。 學習曲線變長了。
  3. 它的檢查能力有限。 它能擋住負的與溢出的常數,但擋不住「 \(\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>,
    }
}

這個套件展示了幾件事:

  1. 寬度常數之間有相依關係:STRB_WIDTH 由 DATA_WIDTH 推導,BURST_LEN_W 由 MAX_BURST 推導。因為都是 p32,這些推導是型別安全的。
  2. 狀態機列舉:四個變體 → 2 bits。
  3. 巢狀結構:Descriptor 內含 Addr,Addr 又含多個欄位。
  4. 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 的領域。型別系統是防禦的第一道防線,不是唯一防線。


本章小結#

  1. SV 的型別系統問題:名稱不直觀(byte 是有號的)、int 與 integer 並存、隱式轉換規則複雜、隱式截斷只給警告。
  2. Veryl 的內建型別分六組:logic / bit(位向量)、signed / tri(修飾符)、u8–u64 / i8–i64(固定寬度整數)、p8–p64(正整數)、f32 / f64 / string、type 別名與 bbool / lbool。
  3. u32 這類命名把寬度與符號性編進名字,取代 SV 的 byte / shortint / int / longint 混亂。
  4. p32 把「非負性」放進型別系統,而且正性能在運算中傳播(正 ÷ 正 = 正),讓錯誤在源頭被攔截。硬體設計中三到四成的 bug 與寬度/常數有關,這是型別系統能實質貢獻的地方。
  5. 特殊寬度字面值 '0 / '1 / 'x / 'z 由上下文填滿;指定寬度的字面值 8'hAB 明確無歧義;底線只作分隔。
  6. 複合型別:struct(packed,欄位以 . 存取,Struct'{.., ..default(0)} 建構子——..default 對 const 特別重要)、enum(預設 logic 基底,寬度推斷,#[enum_encoding] 控制編碼)、union(packed untagged,變體等寬)。
  7. 陣列:<> 是打包維度,[] 是非打包維度——這是硬體結構的直接對應。
  8. 型別推論只在表達式裡進行;埠與函式簽章要求明確型別,因為那是契約。
  9. 2025-02 的新版型別檢查器處理了推論帶來的新錯誤類別(歧義、窄化、多重可行的泛型推論)。
  10. 生成的 SV 會把抽象還原:p32 變成 logic [31:0]、..default 展開成明確賦值、常數寫成 32'd32 形式。

下一節(5.2)我們處理參數化的第二個階段:不只改變常數值,也要改變型別。SV 的 parameter 做不到這件事,因此產生巨集地獄;Veryl 用泛型與 gen 把它變成語言層級的、可檢查的機制。


下一節:第 5.2 節「泛型與 gen:超越參數覆寫的程式碼生成」