符号testbench与SVA互补:用Yosys+SymbiYosys实现覆盖收敛新思路
2026/9/19 4:59:04 网站建设 项目流程

1. 验证意图,SVA并不是唯一答案

做芯片验证这些年,我一直有个感受:SVA(SystemVerilog Assertions)几乎成了“表达验证意图”的代名词。不管是协议检查、时序约束,还是覆盖率收集,大家都习惯性地用断言去描述“设计必须满足什么”。SVA也确实好用,尤其在动态仿真里,一条断言挂在接口上,跑回归的时候就能实时盯着关键时序,发现问题直接报错,效率很高。

但SVA也有它力不从心的时候。举一个很常见的例子:你想验证一个计数器在任意起始值、任意使能序列下,最终能否在10个周期内输出一个溢出标志。用SVA写,你只能写“如果使能连续拉高10拍,则溢出信号在第10拍拉高”这种带限定前缀的属性;可一旦使能不是连续的、起始值不是0,这类基于确定场景的断言就覆盖不了。你真正想问的问题是:“有没有可能,在某些输入序列下,设计会进入某个特定状态?”换句话说,你想验证的不是“给定激励的结果”,而是“激励空间里是否存在一条路径能到达某个条件”。这个问题,SVA表达起来很别扭。

我第一次意识到这个问题,是在一次覆盖率收敛会上。团队拿着一个数据通路的coverage报告,功能覆盖率卡在83%怎么都上不去,随机回归跑了几十个小时就是碰不到那几个边界组合。有人随口说了一句:“要是能直接让工具自己枚举出所有能触发这个状态的情况就好了。”当时没当回事,后来我接触到符号仿真(symbolic simulation)和符号testbench,才明白那句话其实就是验证领域里一个被长期低估的方向:用符号化的方式,把“验证意图”直接交给工具去求解,而不是靠人去猜测激励。

所谓的符号testbench,简单说,就是不把testbench输入限定成一个具体的0/1序列,而是把输入当作一个符号变量,让形式化工具在符号空间里自动搜索满足条件的激励。你不用写几百条SVA来覆盖各种场景,你只要表达清楚“我要验证什么”,剩下的路径探索交给求解器。这种思路适合那些条件组合多、边界深、随机仿真很难自动踩中的验证场景,是SVA之外的另一种思路,而且它和你用SVA做的检查并不冲突,两者是互补关系。

这篇文章我想分享的是:符号testbench到底是什么、它的验证意图是怎么表达的、和SVA的差异在哪里,以及怎么在一个真实的RTL模块上把它跑起来。我会尽量讲得具体一些,包括工具链、代码示例、参数设置和踩过的坑,希望给正在为验证覆盖率发愁的工程师多一个可以落地的选项。

2. 符号testbench的思维转换:从“条件声明”到“存在性搜索”

2.1 SVA表达的是“在什么条件下必须怎么样”,符号testbench表达的是“什么条件下会发生什么”

先理清楚SVA的本质。SVA写的断言,本质上是一个线性时序逻辑公式。assert property (@(posedge clk) a |-> b);这句话的意思是:当a在当前拍为真时,下一拍b必须为真。这是对设计行为的一种“法规性约束”。验证工程师做的,就是不停地往testbench里注入激励,让仿真器去检查这些法规是否被违反。SVA的质量高度依赖于你写属性时对场景的理解程度,也就是说,你得先想清楚什么情况下设计应该有怎样的表现,然后把这个“什么情况”固化到穷举条件里。

但在很多复杂场景里,验证意图恰恰不是“某个条件下的行为”,而是“设计是否存在一个状态,使得某个条件可达”。比如:

  • FIFO在写读指针交错到一定程度时,是否存在一个序列可以让空/满标志同时出错?
  • 总线上是否存在一个合法的仲裁序列,使某条请求永远得不到grant?
  • 对于一个有限状态机,是否存在一条不经过IDLE状态的路径,能直接从状态A到状态C?

这些问题本质上都是状态空间的可达性问题,而不是断言式约束。用SVA写可达性检查,你得先把路径枚举出来,这往往是不可行的,因为状态空间太大,人的脑力不够用。符号testbench的思路则完全不同:它把输入信号当成符号,把设计的状态转移逻辑当作约束,用求解器去问“这样一组输入是否存在”。验证意图在这里发生了变化:不是“检查行为是否违反规则”,而是“搜索解空间里是否存在满足规则的激励”。

我画个不精确但很好懂的类比。SVA像交警在路口盯着监控,看有没有车闯红灯。符号testbench则像交通规划部门问:有没有可能存在一种车流组合,让某个路口在早高峰彻底锁死?前者是规则检查,后者是可达性分析,两个问题可以同时回答。

2.2 符号变量、约束求解器与反例的根本区别

要理解符号testbench,得先接受三个基本概念。

第一,符号变量。传统testbench里,输入信号的每一个bit在仿真的某一时刻都有确定的0或1;符号testbench里,输入信号的bit可以是一个“符号”,它没有被赋值,只是一个抽象的布尔变量。仿真器在做符号执行时,会把这些符号变量带进逻辑计算里,跑出来的结果也是一个由这些符号变量构成的布尔表达式。比如说,你给一个2输入与门,输入a是符号变量x,输入b是符号变量y,那么输出就是x&y,而不是一个具体的值。

第二,约束求解器。符号仿真跑完一轮之后,你会得到一个布尔表达式,它表示“在什么条件下,目标信号会变成某个值”。接下来让约束求解器(比如Z3、Boolector)去判断这个表达式是否可满足(SAT/UIO/SMT)。如果可满足,求解器会给你一组具体的符号赋值,这就是一条能触发目标条件的实际激励;如果不可满足,就说明在给定的约束空间里,不存在这样的激励。

第三,反例(Counterexample)。符号testbench返回的“能触发条件的激励”,在验证的意义上其实就是一个反例:如果这个条件本身是你期望永远不会发生的(比如assertion的违反),那这个反例就帮你证明设计存在bug;如果这个条件是你希望发生的(比如覆盖率目标),那这个反例就变成了一段可用于动态仿真的测试向量。

整个过程听起来挺玄,但其实你不需要自己实现符号引擎。目前主流的方法是用开源工具Yosys把RTL综合成门级网表,再用SymbiYosys(sby)配合SAT/SMT求解器来做符号模型检验(model checking)。符号testbench在sby里可以直接用formal指令来跑,也可以用cover指令来搜索可达状态,两种模式下testbench的写法和普通Verilog颇为不同。

2.3 验证意图在三种层面的表达形式

我自己的体会是,符号testbench中的“验证意图”不一定非要写成一条条断言,它可以落到三个层面。

第一个层面是断言的可达性。你依然可以写SVA属性,但不同的是,符号testbench不是随机跑仿真去验证属性,而是用形式化引擎去证明在给定深度(比如N个周期)内,这个属性是否在所有可能的输入下都成立。如果不成立,引擎直接返回反例。这就是大家常说的“用形式化验证(formal verification)做断言检查”。符号testbench在这里的价值,是把原来需要跑几百万次随机回归的验证意图,压缩成一个可满足性问题。

第二个层面是覆盖的引导性。Functional coverage声明了你想看的功能点,比如“写指针和读指针相差恰好等于FIFO深度减1”。在随机仿真里,这个条件可能很难自动碰上;但在符号testbench里,你只要把它写成一条cover属性,引擎就会搜索是否存在一个激励序列能到达这个状态。如果能找到,反例本身就是一条高效的定向激励,直接拿到动态仿真里用,覆盖率的收敛速度能提升非常明显。

第三个层面是模型条件的目的性假设。这是最灵活的一种用法。你可以在testbench里先给设计加一些假设约束(assume),比如“复位释放后至少经过5拍才允许进行第一次写操作”,然后在这种约束下问引擎:是否存在一个合法激励,让某个内部状态触发一个危险情况?这种做法本质上就是把“验证意图”从顶层接口下放到模块内部状态,你在符号testbench里关心的问题,比写SVA时更接近设计本身的边界条件。

3. 核心实操:用SymbiYosys搭建第一个符号testbench

3.1 工具链选型:为什么我推荐Yosys + sby

聊工具之前先交代背景。工业界的商业形式化验证工具性能确实很强,但授权门槛高,想快速验证一个思路不太现实。开源这边,Yosys作为RTL综合工具已经很成熟,它不只是做逻辑综合,还支持将Verilog转成形式化验证用的门级网表和属性逻辑。SymbiYosys(简称sby)是Yosys周边的一个形式化验证前端,它把Yosys综合、求解器调用、断言检查、覆盖搜索这些环节串起来,使用起来比直接写底层接口方便很多。

我用的是Yosys 0.33 + sby 0.32版本,求解器默认配了Z3和Boolector。安装方式在Ubuntu上很直接:

sudo apt install yosys sudo apt install sby sudo apt install z3

如果是其他平台,也可以直接从GitHub拉源码编译。这里多提一句:sby对Boolector和Z3的适配度都很高,但实际使用下来,Z3在涉及算术运算的场景(比如计数器、地址加减)中表现更好,Boolector在纯逻辑布尔运算中更快。如果你要验证的模块里满是乘加器,强烈建议把求解器换成Z3,否则求解时间可能让人崩溃。

写符号testbench的时候,工程目录结构我习惯这样组织:

symbolic_verify/ ├── rtl/ │ └── counter.v ├── tb/ │ └── formal_tb.sv └── sby/ └── counter_cover.sby

rtl放设计源码,tb放符号testbench文件,sby放验证配置。这是一个很基础的结构,却是后续所有实验的骨架。

3.2 写一个可复现的例子:可变起始值计数器的覆盖搜索

为了把前面的概念落到具体代码里,我用一个非常经典的模块来做演示:一个可配置起始值的计数器。功能很简单:

  • 输入clk、rst_n、load_en、load_value[7:0]、count_en;
  • 复位后计数值为0;
  • 当load_en为高时,计数值装载为load_value;
  • 当count_en为高时,计数值在每个时钟周期加1;
  • 输出overflow_flag,在计数值达到255后再加1时拉高一个周期。

代码写出来大概是这样:

module counter ( input clk, input rst_n, input load_en, input [7:0] load_value, input count_en, output reg overflow_flag ); reg [7:0] count; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin count <= 8'h00; overflow_flag <= 1'b0; end else if (load_en) begin count <= load_value; overflow_flag <= 1'b0; end else if (count_en) begin {overflow_flag, count} <= {1'b0, count} + 9'h1; end else begin overflow_flag <= 1'b0; end end endmodule

传统testbench会怎么做?给load_value赋值几组边界值(0、127、255),再让count_en拉高几百个周期,看overflow_flag是否在正确的周期数后出现。但这样验证有个明显盲区:你无法穷举所有load_value和所有count_en序列的组合。如果load_value是某个中间值,且count_en时断时续,到底能不能在某个周期触发溢出?随机仿真可能永远跑不到这个组合。

在符号testbench里,我不指定load_value的具体值,而是把它当作符号变量。为了给求解器合理的搜索范围,我可以加约束:load_value只取奇数。然后我要搜索的问题是:是否存在一个合法的激励序列,使得overflow_flag在复位后第N个周期拉高?这个问题用SVA写非常繁琐,但在sby里,只需要在testbench中写一条cover属性。

注意,这里有个关键点:符号testbench文件并不是一个可综合的testbench,它是专门为形式化验证写的“formal testbench”,里面的接口、约束、属性都带上了形式化验证的标签。我的formal_tb.sv内容如下:

module formal_tb ( input clk, input rst_n ); reg [7:0] load_value; reg load_en; reg count_en; reg overflow_flag; // 将DUT实例化 counter dut ( .clk (clk), .rst_n (rst_n), .load_en (load_en), .load_value (load_value), .count_en (count_en), .overflow_flag (overflow_flag) ); // 符号testbench的关键:把分析目标交给verify指令 // 这里我们使用sby的formal模式:默认所有输入都是符号变量 // 也可以显式声明符号输入范围 // 给load_value添加约束:只允许奇数 assume property (load_value[0] == 1'b1); // 复位释放后,至少等待2拍再开始装载 reg [2:0] post_rst_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) post_rst_cnt <= 3'b0; else if (post_rst_cnt < 3'd4) post_rst_cnt <= post_rst_cnt + 1'b1; end // 只允许复位释放4拍后拉高load_en assume property (post_rst_cnt >= 3'd4 || !load_en); // 目标:在第5拍(复位释放后)到第8拍之间,overflow_flag至少为高一次 cover property ( post_rst_cnt > 3'd4 && post_rst_cnt <= 3'd8 && overflow_flag == 1'b1 ) ; endmodule

这里assume property用来给符号输入加约束,cover property用来声明验证意图“我想找到一个激励,让overflow_flag在指定区间出现”。在symbolic模式下,sby会把load_value、load_en、count_en都当作符号变量去枚举。

有一点必须强调:symbolic testbench中的cover不是动态仿真里的coverage收集,它是给求解器一个“目标命题”,让引擎去寻找使它成立的输入序列。如果引擎回答“可满足”,它会返回一个反例波形,里面就包含了具体的load_value和count_en序列;如果回答“不可满足”,说明在当前约束下,这个意图无法实现。

3.3 sby配置文件的参数解读与求解器选择

有了testbench,下一步是写sby配置文件。我的counter_cover.sby长这样:

[options] mode cover depth 12 skip 4 [engines] smtbmc z3 [script] read_verilog -formal rtl/counter.v read_verilog -formal tb/formal_tb.sv prep -top formal_tb [files] rtl/counter.v tb/formal_tb.sv

逐个参数解释一下。

mode cover:告诉sby这次跑的是覆盖搜索任务,而不是断言证明任务(mode prove)。两者算法上略有区别,cover更偏向搜索,prove更偏向证明不变量。

depth 12:这是符号展开的时间深度,单位是时钟周期。它决定了引擎最多往后搜索多少个周期。这个值要根据设计复杂度来定,如果设小了,目标状态可能还没达到就停下了;设大了,求解时间会指数级增长。对于这个简单计数器,12拍足够覆盖装载后的计数行为。

skip 4:表示跳过前4个周期不做覆盖检查,主要用来跳过复位和复位释放初期的非稳态区域。这个值要和testbench里假设约束的时序对齐。

engines一项我用了smtbmc z3。smtbmc是sby内置的符号模型检查引擎,配合Z3求解器适合处理含算术逻辑的电路。如果模块纯逻辑、没有复杂运算,也可以换成btormc boolector,通常更快。这里给出一个经验性的选型表:

设计特点推荐引擎/求解器备注
纯组合逻辑为主btm cor求解速度快,资源消耗低
计数器、地址加减等算术较多smtbmc z3算术求解能力强
大规模状态机smtbmc yicesyices对位向量支持好
混合算术与状态smtbmc z3通用性最强

我在实际使用中倾向于无脑上Z3,因为不同case之间切换求解器的成本,远高于单个case多跑几分钟的成本。

3.4 跑通之后,怎么读懂反例波形

配置写好后,在sby目录下执行:

sby -f counter_cover.sby

如果一切正常,输出里会看到类似Status: PASS或者Status: FAIL。在cover模式下,PASS意味着引擎找到了至少一个满足cover属性的激励;如果不可满足才会显示FAIL。找到反例后,sby会在counter_cover/engine_0/trace.vcd生成波形文件。

第一次跑通这个流程的时候,我其实挺惊讶的。Z3返回的反例里,load_value是一个7位的具体值,比如8'b01010101,而load_en和count_en的序列在几个周期内也是确定的。也就是说,符号引擎花了几秒钟,帮我找到了一段之前随机回归跑了很久都未必能碰到的激励组合。把这段VCD对应的输入序列提取出来,回到动态仿真里重新播放,覆盖率点上那几条边界路径很快就闭合了。

把反例波形变成可回归的测试向量,这一步本身也很有价值。sby生成的VCD可以直接用vpi或者read_vcd的流程读入测试平台,但更简单粗暴的办法是直接把trace里显示出的输入值手写进一个task里。对于自动化程度要求高的团队,可以考虑把它们转化成UVM序列。不过这个转化逻辑并不复杂,最核心的难点其实在于怎么把反例波形里的输入时序准确提取出来,我一般会用GTKWave打开VCD,定位到目标信号拉高的那一段,往前回溯三四个周期把输入序列记下来。别小看这个手工作业,对于一次性验证意图来说足够用了。

4. 符号testbench和SVA的协作:谁在什么场景下更管用

4.1 符号testbench适合解决的问题类型

踩过不少坑之后,我总结出符号testbench最适合的几类场景,供大家参考。

第一类是复杂配置寄存器空间的穿透性验证。SOC验证里经常有几十个配置寄存器,不同的寄存器组合会影响模块的工作模式。传统方法靠UVM寄存器模型和随机序列去覆盖组合空间,但组合爆炸导致覆盖率常年不达标。符号testbench可以直接把配置寄存器当作符号变量,一次性搜索出“哪些配置组合能触发某条内部路径”,效率比随机配置高一个量级。

第二类是深边界状态的可达性分析。比如一个状态机有几十个状态,从复位开始到某个特定状态可能需要经历一串非常特殊的事件序列。这种序列靠人肉写SVA很难cover全面,但符号testbench天然适合:把“到达状态X”写成cover属性,让引擎去搜路径。

第三类是协议时序的破缺检查。总线协议中某些非法场景的发生条件(比如读请求和写请求在同一周期到达)可能因为仲裁器的调度而难以触发。符号testbench里,你直接把非法条件定义为cover属性,如果引擎找到了反例,这就意味着设计中存在一条能够产生非法状态的合法输入路径——不管它最后会不会被更高层的防护机制拦住,这个反例本身对验证团队来说就是极有价值的信息。

4.2 SVA仍是不可替代的,两者协作才完整

写到这里,必须给SVA正名。符号testbench虽然有搜索能力,但它不适合做连续、大规模的协议行为监控。比如一个AXI总线接口的握手时序,在动态仿真里每个时钟周期都有可能出现新的传输,你不可能为每笔传输都启动一次符号搜索。SVA的优势在于它和动态仿真深度绑定,可以在跑大规模随机回归时实时报告违规位置。

所以我的真实项目里的做法是:用SVA搭日常回归的守夜人,用符号testbench打覆盖收敛的攻坚仗。SVA负责把已知的、可枚举的协议规则变成库,反复执行;符号testbench负责把未知的、需要搜索的边界条件变成问题,按需求解。两者并不冲突,甚至会有一种奇妙的互补:符号testbench中发现的反例,往往会让你意识到一个新的SVA属性漏写了;而SVA中反复出现的违规点,也经常会成为下一个符号testbench搜索的目标。

记得有一次,我在一个AHB转APB桥的设计里,先用SVA写好了完整的握手属性,动态回归也很干净。但用了符号testbench之后,引擎很快找到了一条路径:在master连续back-to-back传输的情况下,桥内部的一个中间状态可以在一个周期内被“跳过”,导致总线响应优先级错误。这条路径在随机回归里从来没出现过,但一旦把反例导入动态仿真,挂上那条手写SVA,错误立刻暴露。这个案例给我留下的印象很深:SVA说的是“如果发生这个,就必须那样”,符号testbench回答的是“其实这个也可能会发生”。两者缺一不可。

4.3 覆盖率收敛流程中的实操建议

如果你打算在团队里落地符号testbench,我的建议是不要一上来就追求全模块形式化验证,那样工程量大、风险高。更稳妥的路径是:

  1. 从覆盖率最差的功能点入手,把那些长期不收敛的点列出来;
  2. 针对每一个功能点,写一个独立的符号testbench和对应的cover属性;
  3. 用sby跑搜索,拿到反例;
  4. 把反例转成定向测试向量,加入回归集;
  5. 跑回归确认覆盖率确实提升,再考虑扩大搜索范围。

这套流程最看重的是“小而精”。一个符号testbench只需覆盖一个验证意图,跑起来快,调试也容易,不会因为设计复杂度过大而让求解器超时。等团队积累了一定的经验,再逐步扩展搜索的深度和规模,会稳得多。

5. 常见问题与排查技巧实录

5.1 求解器超时:深度是最大的敌人

形式化验证领域有一句话叫“state space explosion”,状态空间爆炸。符号testbench最常见的失败形式,就是求解器跑了几十分钟甚至几个小时都没有返回结果。大部分情况下,罪魁祸首是depth设置过大。求解器需要展开N个周期的电路逻辑,展开的逻辑深度越大,求解的复杂度呈指数增长。

我的习惯是:先用一个很小的depth(比如4)快速验证testbench本身没有语法错误、约束没有冲突,然后逐步增加depth,每加一档都记录求解时间。如果depth从10加到11时求解时间暴涨,说明这个设计的状态空间可能很复杂,与其盲目加大depth,不如想办法通过增加约束来“切小”搜索空间。比如计数器例子里的“只允许奇数load_value”就是把搜索空间剪枝了一半,求解时间立刻下降。约束是符号testbench的利器,善用约束可以解决大部分超时问题。

5.2 assume写过头:意图被约束“绑死”

另一类很常见的问题是我称之为“假不可满足”。你以为cover属性怎么都搜不到反例,实际上是因为assume property添加的约束过紧,把可能的路径全部剪掉了。比如你在测试总线时,assume里写道“master_ready信号永远为高”,那如果设计里恰好有一段功能依赖ready拉低后的处理路径,这条路径就永远不可能被搜索到了。

排查这类问题有一个很实用的方法:在sby输出里查看求解器内部的可满足性统计。如果Status: FAIL出现得很干脆,求解时间很快,那多半不是状态爆炸,而是约束把空间给堵死了。这时候把assume先放开一轮,或者直接在测试里注释掉部分约束,再跑一次看看cover属性是否变成可满足。如果是,那就说明你的约束定义和真实场景不符,需要重新调整。

5.3 VCD波形凌乱:善用skip参数和控制信号

反例波形刚导出来的时候,往往包含前几个周期的非稳定状态。如果不加处理直接看,会让人一头雾水。skip参数就是为了跳过这些垃圾窗口的。但有时候skip设得太大会错过关键事件发生的起点,所以建议在设置skip时,参照你testbench里的复位释放逻辑,让跳过周期数略大于复位释放延迟,保留一小段“启动余量”即可。

调试时还有一个心法:在符号testbench里尽量多保留一些“可观测状态”。比如把DUT内部的关键计数器、状态寄存器连接到testbench的wire上,这样VCD里就会记录这些内部信号的演化过程。符号testbench虽然核心是求解,但最终人要看懂反例,还是离不开内部状态的变化轨迹。没有内部信号的VCD就像看一场没有表情的默剧,很难理解引擎给出的结论为什么成立。

5.4 与动态仿真回归的衔接问题

最后再讲一个工程化的坑。符号testbench返回的反例是一段带具体时序的输入序列,但它和UVM环境里的sequence结构差距往往很大。直接手写映射很容易出错,而且反例多了以后维护成本很高。我的做法是写一个小的解析脚本,读入sby生成的VCD,输出成一份CSV表格,每一列是一个输入信号,每一行是一个时钟周期的取值;然后在UVM环境里用一个通用的task drive_from_csv来驱动DUT。这样符号testbench产生的反例,可以直接变成动态回归的用例,中间不需要手动干预。

这个流程自动化之后,团队里其他工程师使用符号testbench的门槛也大大降低了。他们不需要完全理解形式化验证的原理,只需要提交一个验证意图,拿回一条可回归的用例,然后看覆盖率是否变化。对一个验证团队来说,这才是新方法真正落地的前提。

我在实际项目里体会到,符号testbench最大的价值不在于替代SVA,而在于它提供了一种新的提问方式。SVA让我们能表达“设计必须满足什么”,符号testbench让我们能表达“设计是否有可能出现什么”。第一种问题适合日常回归,第二种问题适合深挖边界。两种验证意图结合起来,验证的完整性才会更高。这个方向的开源工具链虽然还没有商业工具那么顺手,但已经足够支撑日常的验证攻坚了。如果你也遇到覆盖率收敛慢、回归找不到深层问题的情况,不妨抽一个周末,用Yosys和sby搭一个小例子试试。很可能你第一次跑通反例的时候,就会理解我说的“另一种验证意图表达方式”到底是什么意思了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询