那個你無法熱修的錯誤
軟體有一張令人安心的安全網:出了 bug,推一個修補檔。你的手機半夜下載新版本,錯誤就消失了。晶片沒有這種網。當一個設計終於流片——送到晶圓代工廠去製造——邏輯就被一層金屬、一層矽地逐層凍結成實體,事後沒有任何辦法可以編輯。電晶體在哪就在哪。導線往哪走就往哪走。如果第 4,000,000 號閘本該是個 AND,你卻把它接成 OR,那麼晶圓上百萬顆晶片,每一顆都以完全相同的方式出錯。
修正它意味著一次重做(respin):改設計、製作一整套全新的光罩、再讓晶圓跑一次代工廠。在最先進的製程節點,光是一套光罩就可能要花上數千萬美元,而在你拿到新矽晶圓之前,往返晶圓廠就要兩到三個月。現在再乘上現實——錯誤總是成群結隊,而一個在上市後才被發現的嚴重錯誤可能引發召回。1994 年 Intel Pentium 的 FDIV 錯誤,是除法查表中的一個瑕疵,會對極小部分的輸入算出錯誤答案,光是更換已出貨的零件就讓公司付出約 4.75 億美元。那就是一個逃過驗證的錯誤的代價。
兩種工作,兩種心態
在一支晶片團隊裡,工作分成兩個並肩而坐的角色。設計工程師撰寫 RTL——暫存器傳輸級的描述,通常用 Verilog 或 SystemVerilog——說明硬體*應該做什麼*:這個計數器會遞增、那個緩衝區會存八個字、這個狀態機在等待一個請求。驗證工程師的工作正好與「建造」相反:他們的工作是把它*弄壞*。他們寫的程式碼,整個目的就是要證明設計是錯的,而只有在付出巨大努力之後仍然弄不壞時,他們才會鬆一口氣。
這是真正不同的兩種心態。設計者想的是*「這是我為它而打造的情況。」*驗證者想的是*「這是你忘掉的情況。」*這種對抗式的分工是刻意的,也是為什麼這兩個角色通常由不同的人擔任:要在你才剛剛深情寫下的程式碼裡獵捕瑕疵,在心理上很難。在一顆複雜的 SoC 上,驗證也絕不是個小小的事後補充——它通常會吃掉總工程量的 60% 到 70%。一顆晶片上的驗證工程師人數,往往比設計者還多。
測試平台到底是什麼
你沒辦法拿邏輯探棒去戳一顆還不存在的晶片。取而代之,你在軟體裡搭起一張虛擬的實驗工作台,並在邏輯模擬器上運行它——這個程式假裝自己是硬體,計算每一條訊號在每一個時脈邊緣上的行為。那張虛擬工作台就是測試平台,它有三個部分,會在你日後打造的每一個驗證環境裡反覆出現。
- 激勵(驅動器):產生輸入並餵進設計的程式碼——時脈邊緣、重置、資料、控制訊號。這就是你伸手進去按下按鈕。
- 待測設計(DUT):你正在驗證的真實 RTL,像把晶片插進插座一樣放進工作台中央。測試平台包在它外面,但不會改動它。
- 檢查(監視器 + 計分板):監看輸出、並自動判定它們是否正確的程式碼。這是初學者會忘掉的部分——也是最重要的部分。
+----------------------------------------------+
| TESTBENCH |
| |
random/ | +-----------+ +-------------+ |
directed| | STIMULUS |----->| DUT |---+ |
vectors --->| (driver) | clk | (your RTL) | | |
| +-----------+ rst +-------------+ | |
| v |
| +-----------------+ +-----------+ |
| | REFERENCE MODEL |--expected| CHECKER / | |
| | (golden, in C) |--------->| SCOREBOARD|-+--> PASS / FAIL
| +-----------------+ actual +-----------+ |
| |
+----------------------------------------------+「編譯過了」不等於「會動」
當你的 RTL 乾淨地編譯成功時,工具只確認了一件事:你的程式碼在文法上是合法的 Verilog——每一條線都有宣告、每一個括號都成對。它對「設計是否做了正確的事」隻字未提。一個完美編譯的 FIFO,可能會丟資料、重複計數、或永遠卡死。編譯證明的是*語法*;驗證證明的是*行為*。我們用一個微型的範例把這件事講具體,這個範例我們在之後每一個階段都會重複使用:一個小型的同步 FIFO——一個先進先出的佇列,深度八格,在一個快的寫入者與一個較慢的讀取者之間緩衝資料。
// A FIFO that COMPILES PERFECTLY but is BROKEN.
// The 'full' flag forgets one corner case.
module fifo8 (
input clk, rst,
input wr, rd, // write / read requests
input [7:0] din,
output [7:0] dout,
output full, empty
);
reg [7:0] mem [0:7];
reg [3:0] count; // 0..8 items held
assign full = (count == 4'd8);
assign empty = (count == 4'd0);
always @(posedge clk)
if (rst) count <= 0;
else begin
if (wr) count <= count + 1; // BUG: writes even when full!
if (rd && !empty) count <= count - 1;
end
// ...mem read/write omitted...
endmodule那個錯誤對編譯來說是隱形的,用眼睛也很容易漏掉,因為它只在一種情況下才重要:往一個已經滿了的 FIFO 寫入。那是一個邊角情況(corner case)——一組罕見的條件組合,讓設計悄悄地做了錯誤的事。真實的晶片在邊角情況失敗的次數,遠多於在常見路徑上失敗,因為常見路徑正是設計者想得最透徹的地方。整門驗證的藝術,就是有系統地去獵捕那些設計者從未想像過的邊角。
驗證計畫:你的合約
如果驗證的意思是「檢查每一個功能與每一個邊角情況」,那麼顯而易見的問題是:*你怎麼知道自己做完了?*你不能只是一直測到自己有信心為止——對一個一億閘的設計來說,那種感覺一文不值。答案是驗證計畫(vplan):一份事先談妥、白紙黑字的文件,逐項列出設計必須具備的每一個功能、以及每一個必須被檢查的情境。它是定義「完成」的合約。在計畫說某件事已驗證之前,它就尚未被驗證;而一個從計畫裡漏掉的功能,就是一個沒有人會去測試的功能。
FIFO8 VERIFICATION PLAN (excerpt) ID Feature / scenario Method Status ---- -------------------------------------- -------- ------- F-01 Reset clears count -> empty asserted directed PASS F-02 Write then read returns same data (FIFO) directed PASS F-03 full asserts at exactly 8 entries directed PASS C-01 Write to a FULL fifo is ignored directed FAIL <-- our bug C-02 Read from an EMPTY fifo is ignored directed PASS C-03 Simultaneous wr & rd holds count steady random ---- C-04 Random wr/rd mix, 1M cycles, never corrupt random ---- X-01 All 8 fill levels exercised (coverage) coverage 72%
看看方法那一欄——它分成兩種哲學,而本軌道接下來的內容,本質上就是從第一種走到第二種的旅程。指向式測試(directed tests)是手寫的情境:身為工程師的你決定「寫入九筆,然後檢查第九筆被拒絕」,然後你把那件事精確地寫成程式碼。它們精準、容易除錯,但每一個只測試你想到的那一個情況。對我們這個小小的 FIFO 來說,那也許就夠了。但對一顆有著數十億種可能指令序列的 CPU 而言,你窮盡一生也手寫不出足夠的指向式測試——你的想像力,永遠會比你的錯誤先用完。
另一種選擇是約束隨機、覆蓋率驅動(constrained-random, coverage-driven)的驗證——本軌道接下來的全部內容都建立在這個方法上。你不再寫一個情境,而是描述輸入的*合法*空間(任意的寫入與讀取組合,但絕不同時送進兩筆垃圾),然後讓測試平台產生數以千計的隨機化序列,並自動拿去和一個黃金模型比對。隨機性會觸及那些你根本想不到要手寫的邊角。但光有隨機性是盲目的,所以你把它和功能覆蓋率配成一對:一張記錄「哪些有趣情況真的發生過」的計分板——FIFO 曾經剛好滿過嗎?有寫入和讀取落在同一個週期嗎?覆蓋率回答了那個真正的問題:「我們是否操練過計畫所要求的一切?」,並告訴你什麼時候可以停下來。