ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

符号testbench与SVA的本质区别及实战落地指南

符号testbench与SVA的本质区别及实战落地指南 做验证时间长了大家都有过这种经历DUT里藏着一个特别深的bug随机约束跑了几天几夜就是打不中SVA断言写了一堆约束都压到极限了该命中的场景还是不来。这时候大多数人的反应是继续加约束、加seed、或者干脆手动定向写用例去砸。但有个问题很少有人停下来想——我们写SVA断言的时候其实是在描述信号在某些时刻应该长什么样那么对于更复杂的时序关系、数据流转、甚至跨周期的事务级约束有没有比SVA更接近意图本身的表达方式符号testbenchSymbolic Testbench就是干这个的。它跟传统的directed testbench和基于断言的验证方法走的是完全不同的路子传统仿真是在具体值上跑符号testbench直接在符号上跑。所谓符号就是把一个变量当作未知的代数符号来处理它不是一个具体的0或1而是一个可以代表所有可能取值的抽象实体。这篇文章我就围绕符号testbench这套玩法展开讲讲它和SVA在验证意图表达上的本质区别、完整实现思路、以及实际落地中的坑和心得。适合两类人看一类是正在为复杂协议验证或数据通路验证发愁的验证工程师另一类是对形式化验证、符号执行感兴趣想拓宽验证方法学视野的开发者。1. 符号testbench到底是个什么东西1.1 从一次真实的验证困境说起我先还原一个场景。某次我在做cache一致性协议的验证其中有个模块是处理多个CPU核发出的事务请求排序。协议要求当两个请求同时到达且访问地址冲突时仲裁器必须按照某个优先级规则处理并且对应的response信号必须在n个周期内返回。这类功能如果用SVA写断言大概是这样的property p_req_priority; (posedge clk) req_a req_b (addr_a addr_b) |- ##[1:MAX_CYCLES] resp_a resp_b; endproperty看着没什么问题但实际跑起来就发现这条断言定义了行为边界但边界内部的对应关系是模糊的。比如resp_a对应的数据必须来自req_a这一点SVA表达起来非常别扭要么引入辅助信号、要么用复杂的局部变量。而且更致命的是SVA本质上是反应式的——它只告诉你错了完全不告诉你为什么错了是哪条路径触发的。这就是我说的验证意图表达范式的问题。SVA的语法设计初衷是描述信号的时序行为它适合回答信号对不对但不太擅长回答事务之间的因果关系对不对。1.2 符号testbench的核心思路那么符号testbench是怎么干的呢它换个角度直接声明输入是符号变量// 符号testbench示意 symbolic bit [31:0] addr_a; symbolic bit [31:0] addr_b; symbolic bit [7:0] data_a; symbolic bit [7:0] data_b;这几个变量在仿真过程中不绑定具体值。DUT正常跑符号变量顺着逻辑传播、经过状态寄存器、穿过组合逻辑和时序逻辑最后在DUT的输出端或内部观测点形成一个符号表达式。比如某条路径的输出可能是output_data (addr_a addr_b) ? data_a 8h1 : data_b - 8h2然后testbench要做的事情就清楚了。假设我们关心的条件是output_data应该在某个范围内或者resp信号必须在req到达之后的n拍内拉高我们直接把这个条件和符号表达式一起丢给约束求解器constraint solver问它是否存在一组输入值使得这条路径走到终点时条件不成立如果求解器说可满足SAT并给出一组反例恭喜bug抓到了。如果求解器说不可满足UNSAT那说明在符号覆盖到的所有路径里这个性质都成立。这个思路等于把验证意图从写一个反应式的断言去监控信号变成了**直接对行为和关系建模然后用求解器去证明或证伪**。这才是它被称为另一种方式的根源——它不是SVA的语法糖而是验证范式的变化。1.3 跟普通仿真验证在数学基础上的差异普通testbench的数学基础是采样。你铺随机种子跑千百万个时钟周期本质上是从一个极其庞大的输入空间里抽样。而符号testbench的数学基础是穷举推理。符号值经过逻辑门传播时逻辑门不会去计算一个确定的输出而是生成一个布尔表达式整个DUT在符号值驱动下运行的过程本质上是在构建一个巨大的布尔公式也就是CNF公式的解空间就对应所有可能的输入组合。这两者之间的差别打个比方普通仿真就像你去一个超大的图书馆里一本一本抽样看书页寻找错别字符号testbench则是把整本书的逻辑结构抽象成一个知识图谱直接问这个段落和那个章节矛盾吗。这个基础差异带来两个直接后果一个是覆盖率的意义变了。普通仿真的覆盖率代表我采样了多少空间符号testbench的覆盖率代表我已经证明了多少行为。另一个是bug发现时机的差异。随机验证通常是在回归测试阶段暴露bug符号testbench则理论上可以在早期甚至在RTL刚写好、还没有足够多定向用例支持的时候就能对关键属性做一轮数学上的完备验证。2. 符号testbench与SVA的正面交锋2.1 两者各自的擅长领域先说结论SVA和符号testbench不是替代关系而是互补关系。在真正选型之前必须搞清楚各自的边界。SVA擅长的是时序边界的描述。比如req拉高后2到4拍内必须看到ack这种时序窗口约束是SVA的看家本领。形式化验证中的属性约束。如果配合形式化工具比如JasperGold、VC FormalSVA属性本身就可以作为形式化证明的目标属性。回归仿真中的线上监控。SVA天然嵌入在simulation流程里作为实时断言是一种干净的在线检查手段。符号testbench擅长的是事务级的数据流转关系。比如A请求的数据必须原样到达B请求的响应直接定义一个关系表达式即可不需要把这些关系转译成一组波形约束。高维组合空间的穷举覆盖。随机约束一次只能随机出一组值符号testbench天然覆盖所有路径。算法类、计算类模块的等价性验证。比如一个浮点加法器、一个加密算法的硬件实现符号testbench可以直接把硬件电路的符号输出和参考模型的输出做形式等价比较。微架构中复杂交互的正确性。比如重命名、乱序提交、分支预测恢复这类各状态元素之间的因果关系非常复杂SVA写起来写到怀疑人生但用符号testbench直接对状态变换关系建立模型思路清晰得多。2.2 在验证意图表达上的本质区别SVA表达验证意图时有一个绕不开的翻译损耗验证工程师心里想的是事务的因果关系但SVA的语法要求你把这种关系表达为信号的时序行为。也就是说SVA的语言原语和验证意图之间天然隔了一层。举个例子验证意图是当master A和master B的写请求同时命中同一个cache line时最后写入结果必须是优先级高的那个master的数据。这个想法很直接。但要在SVA里表达就得先设计协议信号怎么体现优先级、仲裁结果在哪个周期生效、数据在哪个周期写入——这已经是在做把意图翻译成波形的工作了翻译过程中极容易引入和设计类似的思维定势自己写的断言自己看不出问题。符号testbench不需要这种翻译。你直接声明两个事务的地址和数据为符号然后让DUT跑起来最后检查输出的缓存线内容是否等于高优先级master的数据。这就是我前面说的表达方式更接近意图本身。2.3 一个典型的场景对比我用一个简单的仲裁器来对比两者写法。仲裁器的行为是两个输入通道一个高优先级一个低优先级。冲突时输出高优先级的请求。SVA写法property p_arb_priority; (posedge clk) req_high req_low |- ##[1:2] grant_high; endproperty这条SVA的问题在于它只验证了grant_high会拉高没有验证grant_low此时不会拉高以及输出去的那份data到底来自谁。符号testbench的写法思路// 符号testbench logic [31:0] data_high, data_low; initial begin symbolic_data(data_high); symbolic_data(data_low); // 驱动DUT req_high 1; req_low 1; // 等待稳定 assert_check(check_output_is_high_priority_data()); end这里的check函数不是简单的信号比较而是交给约束求解器去判断是否存在一种情况使输出不等于高优先级数据。如果存在求解器会给出具体的反例输入。这种级别的检查SVA很难简洁地表达。3. 实操从零搭一个符号testbench3.1 整体架构怎么设计符号testbench的架构大致分几块符号驱动层、DUT实例、属性检查层、求解器接口、结果收集层。其中最难的部分其实是和DUT的连接。典型的连接方式有两种方式一直接在RTL上做符号化替换用支持符号仿真的工具或者自研的符号执行引擎把DUT输入端口的驱动信号替换成符号值然后以正常仿真方式跑。这种方式的优点是贴近真实硬件行为缺点是符号传播过程计算量极大仅适用于中等规模设计。方式二将RTL抽象成模型再符号化先从RTL中抽取核心数据通路的模型可能是周期精确的C模型再对模型做符号化。计算量大幅下降但需要保证模型和RTL的一致性。我个人实际项目里用的是方式一和方式二混合。关键模块比如仲裁器、FIFO控制逻辑直接符号化周边模块用抽出来的周期精确模型替代。3.2 DPI-C接口设计与符号变量的生成SystemVerilog提供了DPI-C机制让我们可以在SV里调用C/C函数这是符号testbench最常见的连接点。我们可以把C写的约束求解器封装成DPI函数SV侧负责生成符号变量、调用求解器。举个具体例子。假设我们要验证一个简单的FIFO组件写入端有data_in、wr_en读出端有data_out、rd_en。FIFO内部有存储数组。验证意图是凡是成功写入的数据在读出来时必须保持一致。传统testbench需要生成大量随机数据写入再读出然后比对。而符号testbench只需要把写入的数据设成符号import DPI-C function void symbolic_init(); import DPI-C function int solve_check(string property_name, output bit [31:0] counter_example); module symbolic_fifo_tb; logic clk, rst_n; logic wr_en, rd_en; logic [31:0] data_in; logic [31:0] data_out; fifo dut ( .clk(clk), .rst_n(rst_n), .wr_en(wr_en), .rd_en(rd_en), .data_in(data_in), .data_out(data_out) ); initial begin symbolic_init(); // 驱动阶段让FIFO先写入一个符号值 rst_n 0; #20 rst_n 1; wr_en 1; data_in 32hDEAD_BEEF; // 这里实际会换成符号变量接口 #10; wr_en 0; rd_en 1; #10; // 检查输出是否匹配 if (solve_check(fifo_data_integrity, data_out)) begin $display(BUG FOUND: FIFO data corruption detected); $finish; end else begin $display(FIFO data integrity property verified); end end endmodule当然上面代码里的data_in 32hDEAD_BEEF是示意。真正的符号testbench中符号变量的生成是通过DPI函数完成的符号值的具体解释权在求解器侧。3.3 C端的核心逻辑路径收集与求解C端是符号testbench的灵魂。大致分几个模块符号管理模块每个符号变量有一个唯一的ID和类型信息。DUT运行过程中产生的数值操作不会真的去计算数值结果而是生成一个包含符号引用的表达式树。实现上可以用表达式模板expression templates技术基本的符号类设计如下class SymbolicValue { std::shared_ptrExprNode expr; public: explicit SymbolicValue(std::shared_ptrExprNode e) : expr(std::move(e)) {} SymbolicValue operator(const SymbolicValue rhs) const { return SymbolicValue(std::make_sharedAddNode(expr, rhs.expr)); } SymbolicValue operator(const SymbolicValue rhs) const { return SymbolicValue(std::make_sharedEqNode(expr, rhs.expr)); } };约束收集模块DUT跑的过程是在模拟电路的行为只不过所有变量都是表达式。每走一个分支比如if条件为真或为假就把这个分支条件收集成一个路径约束。例如DUT中有一段逻辑if (data_in 8h80) data_out data_in - 8h01; else data_out data_in 8h02;那么在符号testbench中当走到if条件时约束收集器会fork出两个路径一个路径带约束data_in 8h80另一个路径带约束!(data_in 8h80)。最终结果是一个路径约束集 输出表达式列表。求解器接口模块把表达式和约束转成求解器的输入格式。常见的求解器有Z3、Boolector、bitwuzla等。在验证意图层面通常不是直接把整个布尔公式丢进去而是先做一个提前量判断——比如我们检查输出一定等于输入那核心公式就是check: output_expr ! input_symbol然后问求解器这个公式有没有解。bool check_property(const std::vectorPathConstraint path_constraints, const SymbolicValue output_expr, const SymbolicValue expected_expr) { // 构建查询公式存在一组输入使输出不等于期望值 Formula query mk_not(mk_eq(output_expr.to_formula(), expected_expr.to_formula())); for (const auto pc : path_constraints) { query mk_and(query, pc.to_formula()); } Solver solver; auto result solver.check(query); if (result SolverResult::SAT) { std::cout Counterexample found: solver.get_model() std::endl; return false; // 性质不成立有反例 } return true; // 性质成立 }这里的关键是把验证意图表达为一个可判定的数学问题。本质上来说符号testbench在C端做的事情就是把数字电路变成一个可以问问题的数学模型。3.4 编译、运行与结果解读符号testbench的编译运行方式和普通仿真不太一样推荐的做法是分两步走第一步先用普通仿真的方式跑通testbench框架。这里作为验证工程师我通常的做法是先让所有符号变量退化为普通常量确保testbench环境和DUT连接正确。第二步再把常量替换为符号接口开启符号执行。如果是用商业工具比如Cadence的Xcelium支持一定程度的符号仿真Synopsys的VC Formal侧重形式化属性验证则按工具要求配置即可。如果是自研基本流程是# 编译C求解器后端 g -stdc17 -I./include \ -c solver_backend.cpp -o solver_backend.o # 编译DPI-C库 g -stdc17 -shared -fPIC \ solver_backend.o symbolic_runtime.cpp \ -lz3 -o libsymbolic_tb.so # 用仿真器编译并加载DPI库 xrun -sv symbolic_fifo_tb.sv fifo.sv \ -dpi libsymbolic_tb.so运行时会有一个非常有意思的现象仿真不会跑很多周期而是不慌不忙地遍历路径。对于小型模块可能输出直接就告诉你验证通过或者给出一组反例。反例的格式一般包含具体的输入值——这就是非常有价值的调试信息比SVA报错时能够提供的信息多得多。可能有人会问这么搞跟直接用形式化工具做formal verification有什么区别本质上确实有很多相似之处符号testbench可以看作是把formal的能力跟传统testbench的灵活性和事务级建模能力结合到一起。区别在于形式化验证工具通常需要你以属性或者sby文件的形式去描述验证目标而符号testbench允许你用更贴近程序的方式去组织验证逻辑——循环、函数调用、动态级联的检查逻辑都可以用起来灵活性更高。4. 常见问题与排查技巧实录4.1 符号爆炸路径指数增长这是符号testbench遇到的最典型问题。一个m位输入的模块理论上就有2^m条路径。如果DUT内部还有多个分支路径数会随分支点指数增长。应对思路主要有几个方向限制符号范围。不要一上来就全符号化选准关键变量做符号其余用具体值。比如验证FIFO时wr_en和rd_en可以作为具体值调度只有data_in做符号这样复杂度降低一个数量级。引入抽象。对输入的符号做区间抽象interval abstraction用范围代替具体值牺牲精度换效率。实际验证过程中未必需要精确到bit级往往是block级够用就行。分段验证。大模块拆小验证验证完再组合。符号testbench没必要追求一次验证整个SoC它的定位更偏向模块级或关键路径级。4.2 DPI-C与仿真器的兼容性坑DPI-C接口看似简单但实际使用时坑不少。最大的坑是仿真器的类型转换。SV侧传入的bit [31:0]在C侧通常映射为svBitVecVal如果你混用了logic和bit或者传的是多维数组很容易出现数据错位。我的经验是DPI-C接口函数的参数设计尽量简单能传整数就传整数避免传struct和数组。符号变量的ID可以用整数handle传递C侧维护一张全局哈希表映射到真正的符号对象。另外注意仿真结束后进程清理。DPI-C创建的线程和内存如果不释放干净仿真器退出时会挂起或者coredump多跑几轮回归会让人抓狂。务必在SV侧调用一个symbolic_cleanup()的DPI函数做收尾。4.3 求解器选择的心得我用过Z3和bitwuzla做底层求解。有几点实践感受Z3功能全、文档丰富、社区活跃适合做复杂的位向量和数组逻辑验证第一次接入优先选它。bitwuzla对位向量问题做了专门优化某些场景下速度快很多尤其适合纯电路性质的检查。遇到性能问题时先做位宽裁剪。比如只需要关注低8位的数据通路符号变量就定义为8位不要用32位符号去跑否则求解器会花大量时间在无意义的高位逻辑上。4.4 反例不直观报错位置难定位符号testbench抓到反例后经常遇到给出的输入是一个很长的位向量看起来完全随机的情况定位排查起来也很费劲。我的做法是加一层反例可读化处理拿到求解器给出的模型后用脚本把对应的符号变量翻译回测试意图层面。比如符号变量代表master id求解器给出了0x3那么脚本直接打印master_c_id而不是裸的数字。另外在收集路径约束的时候把每个分支对应的源码行号记录下来这样拿到反例时可以回溯是哪几行代码的分支路径组合起来导致了这个反例定位速度快很多。5. 哪些场景最值得用符号testbench5.1 高价值场景速览从我的实际经验来看符号testbench最适合以下场景协议控制器的健壮性验证。比如AMBA AXI/AHB桥、中断控制器这类模块的随机仿真覆盖率很难做满且状态机跳转复杂。符号化跑一遍状态转换关系能把某些转角场景直接覆盖到位。数据通道的一致性验证。加解密模块、CRC模块、位宽转换器、字节序调整逻辑。这类模块的特点是处理过程复杂但输入输出具有明确的数学关系用符号testbench直接验证输出等于某种数学函数作用于输入再合适不过。设计中容易被随机仿真漏掉的深角用例。比如跨时钟域握手逻辑不过CDX验证通常需要专门的CDV工具这里说的一般是功能层面的数据交互、复杂的乱序装配逻辑。5.2 不适合的场景不是说符号testbench万能。以下场景我用下来效果不太好大家避坑超大规模SoC级验证。层次太高、变量太多符号爆炸不可避免。这时候还是老老实实靠UVM做随机约束回归符号testbench顶多作为某个IP子模块的补充验证手段。模拟电路或混合信号模块。符号testbench本质面向数字逻辑模拟信号里连续的值域、噪声、瞬态特性都很难建模。验证目标软件的交互行为。如果验证的重点是CPU跑程序跑挂没有这种系统级问题符号testbench的粒度太细了效率不如直接刷用例如同仿真和定向用例来的直接。5.3 如何在现有流程中引入符号testbench即使决定尝试也不要一口气推翻传统UVM环境。我的建议是采取渐进策略先在现有仿真环境旁边搭建一个符号testbench的独立环境针对一个或两个关键模块做试点。跑出bug后和现有回归结果对比验证有效性。同时把符号testbench的检查逻辑嵌入到传统回归中定期跑一下作为随机仿真之外的一道保险。另外一个实用经验符号testbench的结果和覆盖率数据可以反过来指导随机约束的设计。符号testbench如果发现某个属性在N个周期内不可达可能说明随机约束里某些寄存器状态配置根本覆盖不到——顺着这个线索去调整约束权重是一个很有意思的用法。6. 总结之外的一点体会写了这么多最后想聊点实际的感受。符号testbench不是一个银弹它更像一把手术刀在特定场景下锋利无比但用错地方也会伤到自己。我用它的最大收获其实不完全是抓到了几个隐藏很深的bug而是它逼着我以完全不同的方式去思考验证问题——不再是我要让DUT跑到什么状态而是我关心DUT的什么性质然后把注意力放在性质本身的正确性上。这种思维上的转变对于验证工程师来说可能比工具本身更有价值。如果你正在被某个模块的覆盖率瓶颈卡住或者写SVA写到思路堵塞不妨试试这个思路把那个困扰你的信号行为换成性质关系来思考然后直接把它变成符号表达式丢给求解器。也许会有惊喜。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进