JOVANA
Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

没验证过,就是坏的

芯片项目的大部分工作并不是设计芯片,而是证明芯片是对的。一旦设计走到了流片,一个错误就要赔上几个月和几百万,而且事后没有补丁可推。所以团队会把大部分精力花在验证上:搭一个能驱动设计运行的测试平台,从手写测试走向约束随机激励,用覆盖率来判断验证是否做完,用断言在错误发生的瞬间抓住它,到了高阶再上形式验证和 UVM。本指南会用一些小而可运行的例子带你走完这条路线。

为什么验证占了大部分工作量

有个数字会让刚入行的人吃惊:在一款正经设计上,验证通常占总工作量的 60–70%——投入的工程师、算力和日历时间都比设计本身还多。为什么这么悬殊?因为硬件不留情面。一个网页应用出了 bug,你当天下午就能推个修复上去。一颗芯片出了 bug,修复的代价是一套新掩模、一次新流片,外加你人生里的一个季度。

所以这份活儿不是「让它跑通一次」,而是要用证据说服你自己:对硅片这辈子会遇到的每一种输入,它都是对的。你不可能把输入全试一遍——哪怕只是一个 32 位加法器,也有两个 32 位操作数,也就是 2^64 种输入组合——所以验证其实是一门不靠穷举却令人信服地周全的艺术:选对激励,盯对该看的东西,再衡量你究竟驱动了设计的多少部分。

测试平台

测试平台是硬件的测试夹具——但它本身不是芯片的一部分。把它想成电子实验室里的一张试验台:一套夹住你电路、给它喂信号、再盯着回来什么的装置。在 Verilog 里,它是一个没有端口的模块——它坐在设计外面,把被测对象(DUT)实例化进来,驱动它的输入,再检查它的输出。

比起「光看波形」,关键的升级在于让测试平台自己会查:它事先就知道正确答案,一旦现实跟它不符就当场喊出来。那一句 `$error` 就是全部的关键——它把「我用肉眼扫了一遍,看着还行」变成了「机器把每一个结果都和黄金期望值比对过,没发现一个错的」。

module tb;
  logic [3:0] a, b;
  logic [4:0] sum;
  adder dut (.a(a), .b(b), .sum(sum));   // DUT: the design under test

  initial begin
    a = 4'd7; b = 4'd9;                   // drive inputs
    #1;                                   // let combinational logic settle
    if (sum !== a + b)                    // compare against expected
      $error("bad sum: %0d + %0d gave %0d", a, b, sum);
    $finish;
  end
endmodule
一个最小的自检测试平台:实例化 DUT,驱动输入,如果输出跟期望的和不一致就报 $error。

定向测试 vs 约束随机

上面那个测试平台是一个定向测试:是你这个工程师亲手挑了 7 + 9,因为你直觉它要紧。定向测试精确、可读,对那些你叫得出名字的情形再合适不过——复位行为、溢出边界、那个你心里有数会咬人的角落。它的弱点也正是它的本性:你只能找到那些你早就想到要去找的 bug。

约束随机把这件事反了过来。你不再一个个去点名情形,而是描述输入的合法空间,让工具朝设计扔出成千上万条「随机但合法」的激励。约束让随机性保持理智——一个合法的操作码、一个在范围内的地址——而随机性则能触及你这辈子也不会手挑出来的角落。让它跑一整夜,它会探索出没有哪个人会去敲出来的组合。

class packet;
  rand bit [7:0] addr;
  rand bit [3:0] len;
  constraint legal { addr < 8'd200; len inside {[1:8]}; }  // stay in the legal space
endclass
// call randomize() in a loop -> thousands of valid, varied stimuli
一段约束随机激励:addr 和 len 都是随机生成的,但约束保证每一个生成出来的包都合法。

覆盖率:我们到底测了多少?

约束随机带出一个让人不安的问题:既然工具是在乱扔随机输入,你怎么知道它真的试到了那些要紧的情形? 没准十万个随机包里,长度恰好为 8 的一次都没碰上。覆盖率就是答案——它是一块记分牌,告诉你设计被你实际驱动了多少,把「我们跑了好多测试」变成「我们命中了自己声明在意的场景里的 94%」。

它有两种口味。代码覆盖率是自动的,问的是「每一行、每个分支、每次翻转都被驱动到了吗?」——必要,但浅。[[functional-coverage|功能覆盖率]]才是真正撑得起价值的那个:是*你*来写下覆盖点,给那些要紧的情形点名——每个操作码都见过了、FIFO 既碰到过空也碰到过满、每个合法的长度值——再由工具统计究竟哪些真的发生了。它回答的是那个真问题:我们做完了吗?

covergroup cg @(posedge clk);
  coverpoint len    { bins all[] = {[1:8]}; }   // did we see every length 1..8?
  coverpoint state;                              // did we visit every FSM state?
endgroup
一个功能覆盖组:覆盖点记录这一轮运行里实际到达了哪些长度、哪些状态。

断言:当场抓住 bug

自检测试平台是在输出端抓 bug——而且是等坏值传播出来之后才抓到。断言则是在案发现场抓它:它是一条直接嵌进设计里的规则,说「这件事必须永远为真」,一旦被违反就在那一瞬间触发,径直指向出问题的信号和周期。

它们在跨越时间的规则上最出彩。普通的 `if` 只检查某一个瞬间;一条 SVA(SystemVerilog 断言)检查的是跨越多个周期的关系——「一个请求之后,三个时钟之内必须有应答」「这个独热信号永远不会有两位同时为 1」。那个 `|->` 读作蕴含:左边一旦发生,右边就必须跟着发生。

// after every request, an ack must arrive within 1 to 3 cycles
property req_gets_ack;
  @(posedge clk) req |-> ##[1:3] ack;
endproperty
assert property (req_gets_ack);
一条 SVA 断言:只要 req 为高,ack 就必须在 1 到 3 个时钟周期内跟上,否则断言失败,并指出出错的确切周期。

瞥一眼形式验证与 UVM

仿真哪怕跑上几十亿个随机周期,测到的也只是你恰好生成出来的那些输入——它是在对一片汪洋做采样形式验证走的是另一条路:它把你的断言交给一个数学引擎,让引擎证明它们对*所有*可能的输入都成立,或者递给你一个具体的反例。没有激励,没有覆盖率盲区——而是一份证明。它撑不到整颗芯片的规模,但在一个棘手的模块上(一个仲裁器、一个 FIFO、一个协议的角落),它带来的差别就是「我们没看到它出错」和「它不可能出错」之间的差别。

随着设计变大,临时拼凑的测试平台会撑不住。UVM(通用验证方法学)是业界用来搭建大型测试平台的标准框架——可复用的组件(driver、monitor、scoreboard)、一个用来生成约束随机流量的 sequencer,以及一个覆盖率模型,全都按照每个验证团队都认得的方式接在一起。把它想成那套脚手架,让一个测试平台从一个工程师的脚本,长成一整个团队可以维护多年的东西。