PyCircuit 6 这个版本号跳进我视线的时候,我正对着一块跑了三年的采集板发愁。板子本身没问题,问题出在验证环境上:一个两百行的时序逻辑,我配了八百行的 testbench,改一个位宽要动三处地方,改到最后连我自己都分不清哪份代码才是可信的。硬件开发这个行当有个不成文的规矩,功能写得快不算本事,能证明它对才算。于是我动了念头:干脆用 Python 把描述层和验证层缝在一起,让同一份代码既能吐出 RTL,又能被 pytest 直接驱动跑仿真。PyCircuit 6 就是这么长出来的——它不是哪个厂商的官方工具,也没有大团队背书,就是一个人为了那瓶醋,重新包了一盘饺子。这篇文章写给两类人:一类是写过 Verilog 但被 testbench 折磨过的硬件工程师,另一类是懂 Python、想伸手摸摸数字电路的软件人。前者能看到怎么把验证成本砍下来,后者能看到软件思维在硬件里哪些地方会撞墙。
1. 动机拆解:那瓶"醋"到底是什么,饺子皮为什么要重擀
先把话说透。我做 PyCircuit 6,表面上是想"用 Python 写电路",但真实驱动力只有一个:我想要一个描述与验证不分离的开发流。Verilog 生态里,DUT 和 testbench 是两套语言、两套心智模型、两套编译流程,中间靠字符串名字和波形对齐。这种割裂在小项目里无所谓,一旦模块超过五个、参数开始组合爆炸,维护成本是指数上升的。Python 只是顺手抓来的工具,换成别的语言也行,关键是它天然支持元编程、反射和现成的测试框架。
1.1 真正让我动手的三件事
第一件事是参数化的地狱。我做的是一个可变位宽、可变深度的数据通路,同样的位宽参数在模块声明、内部寄存器、testbench 的随机激励、以及上层的例化端口里各写了一遍。有一次我把深度从 64 改成 128,漏改了 testbench 里的边界检查,仿真跑过、上板挂掉,查了两天才发现是指针位宽少了一位,回绕发生在错误的位置。这种错误不是能力问题,是工具链逼出来的。
第二件事是测试代码复用率太低。我发现同一个"先复位、再灌数据、再比对"的流程,我在七个模块里抄了七遍,每遍的时钟节拍写法还不一样。用 Python 的话,这应该是一个 fixture,加个参数化装饰器就能覆盖所有位宽组合。
第三件事是中间表示的缺失。纯 Verilog 开发时,我脑子里没有一个可以检查的对象。我想做的一件事是:在生成 RTL 之前,先遍历一遍电路结构,自动检查有没有组合环、有没有未驱动的信号、有没有跨时钟域却没打拍子的路径。这些检查在文本层面做不了,必须先有结构化的中间表示。
1.2 为什么没有直接选现成方案
市面上做"Python 描述硬件"的项目不少,Amaranth(原 nMigen)、MyHDL、Migen、PyRTL,还有 Chisel 这类基于其他语言的方案。我逐个试过,也认真评估过,最后决定自己写,理由有三条,都是很具体的工程原因,不是口味问题。
其一是仿真与综合的一致性。有些框架的仿真器是纯 Python 事件循环,综合走另一条路,两者在边界情况(比如复位释放时刻、多驱动仲裁)上的行为不完全一致。我吃过这个亏:仿真通过、综合出来的网表行为不同,定位花了一整天。我需要一个明确的原则——仿真和生成 RTL 必须来自同一个中间表示,只有后端不同。
其二是可读的产物。我看过一些工具生成的 Verilog,信号名是_tmp_3821这种,一旦需要人工介入调试,等于从头读一遍网表。我的硬要求是:生成的 RTL 信号名要和 Python 侧一一对应,寄存器名、模块名都可控,方便我在综合报告里直接对号入座。
其三是验证栈的整合度。我需要断言、覆盖率、随机约束这些东西能直接用 Python 生态的库,而不是再学一套 DSL。pytest 的 fixture、hypothesis 的属性测试、numpy 的向量化比对,这些拿来就能用,省下的时间比写框架本身还多。
注意:自己造工具最大的风险不是写不出来,而是写出来之后只有你自己会用、只有你自己敢改。所以从第一版起我就给自己立了规矩:任何特性必须能被一个独立的 pytest 用例证明,做不到就不进主干。
1.3 重包饺子的代价,我心里有数
自己写框架,等于主动接过三份账单。第一份是时钟语义的实现成本——软件里的赋值是即时的,硬件里的赋值有先后、有边沿、有延迟。要在 Python 里表达"非阻塞赋值",得靠重载__setattr__或者专门的语法糖,稍不注意就会写出仿真能过、综合报错的代码。第二份是类型系统的边界——Python 的整数是任意精度的,硬件的位宽是固定的,溢出、截断、符号扩展这些必须显式处理,否则验证的时候全是假阳性。第三份是生态孤岛——没有大厂的 IP 库,没有成熟的 UVM 对接,遇到复杂验证场景还得自己补轮子。
这三份账单我都认。因为我的项目规模卡在一个甜点区:模块数量在十个到三十个之间,时钟域不超过三个,团队就我一个人加一个兼职的验证同事。这个规模下,自建轻量框架的收益远大于成本。如果你的项目是几百个模块、五六个时钟域、还要跟第三方 IP 做 UVM 集成,那就别学我,老老实实用成熟流程。
2. PyCircuit 6 的整体设计与核心机制拆解
框架的设计目标定下来之后,结构其实是被目标倒推出来的:一份 Python 类描述,编译成中间表示,中间表示同时喂给三个后端——仿真内核、Verilog 生成器、静态检查器。三个后端共享同一个结构,行为差异被压到最小。这一层是整套东西的地基,值得展开讲清楚。
2.1 从 Python 类到可读 Verilog 的编译链路
链路分四步走,每一步都有明确的输入输出,方便单独测试。
第一步是构建期。用户继承一个Module基类,在__init__里声明端口、寄存器、时钟和复位,用装饰器标注组合逻辑和时序逻辑。构建期只做一件事:把 Python 的类属性变成一张平坦的信号表,每个信号记下名字、位宽、方向、所属时钟域。
第二步是求值期。调用一次elaborate(),框架真正执行被装饰的函数体。这一步用的是一个惰性表达式对象:a + b不会立刻算出数值,而是构造一棵加法节点树。这一步是整个设计里最关键的地方,因为它天然把"结构"和"求值"分开了——同一段函数体,喂给它真实的信号对象得到电路,喂给它仿真用的数值对象就得到结果。
第三步是中间表示(IR)。求值完的电路被转换成一张有向图:节点是运算,边是信号依赖,另外维护一张寄存器表记下每个寄存器的时钟域、复位值、初值。跨时钟域检查、组合环检查、未驱动信号检查全在这一层做,因为它们本质是图论问题。
第四步是后端生成。Verilog 后端做拓扑排序,把节点树按层次压平成assign和always块,寄存器名直接沿用 Python 属性名,中间变量按模块名_信号名_idx命名,保证可追溯。仿真后端复用同一张图,只是把节点替换成带时间戳的事件。
# 伪代码示意:同一份表达式树,两种求值方式 expr = (self.a + self.b) * self.c # 此刻什么都没算,只建了树 rtl_node = expr.to_ir() # 交给 Verilog 后端 sim_val = expr.eval({"a": 3, "b": 5, "c": 2}) # 交给仿真器,得到 162.2 位宽、时钟与复位:把隐式约定变成显式声明
硬件开发里最容易出事的地方,恰恰是软件里不存在的东西:位宽和时序。我的处理原则是——凡是隐式的,一律显式化,并且在编译期报错。
位宽上,所有信号必须给宽度,Signal(8)就是 8 位无符号。加法结果自动扩宽,但把宽结果赋给窄信号会触发显式的截断,必须写.truncate(8)或者.resize(8),否则报错。这个设计一开始很烦,写惯了 Verilog 的人会觉得啰嗦,但它拦住了一大类错误:本来我要的是 8 位回绕,结果综合出来是 9 位,多出来的那一位在综合报告里根本看不出来。
时钟和复位上,每个Reg必须绑定一个时钟域对象,寄存器在哪个域是类型系统里查得到的,而不是靠人肉记忆。复位支持同步和异步两种,且必须指定有效电平。异步复位在仿真里有个经典陷阱:复位释放时刻如果太靠近时钟边沿,真实芯片可能进入亚稳态,而理想仿真永远不会。我在仿真器里加了一个可选的复位释放检查,如果复位释放距离时钟边沿小于设定的保护窗口,就抛出警告,逼着我在 testbench 里把释放时刻挪到安全位置。
注意:跨时钟域的信号必须在 IR 层显式声明。框架不提供隐式的自动同步,凡是从 A 域寄存器直接读到 B 域逻辑的路径,静态检查一律报错。这是我给自己上的最紧的一道箍。
2.3 事件驱动仿真内核与波形导出
仿真内核是自己写的,因为要用同一张 IR 图。内核是事件驱动的:维护一个按时间排序的事件队列,每个事件是"某信号在某个时刻变成某个值"。时钟生成器往队列里推边沿事件,寄存器在时钟边沿事件触发时求值,组合逻辑用迭代求不动点的方式算,直到没有信号再变化为止。
这套东西的关键细节有三个。第一个是组合环检测:不动点迭代设一个上限,比如 100 轮,超过就判定为组合环,直接抛异常并打印环路路径,比综合工具报的错误清楚得多。第二个是零延迟与惯性延迟:框架默认组合逻辑零延迟,但允许给信号挂惯性延迟,用来模拟真实的路径延时,验证时序假设。第三个是波形导出:内核可以把所有信号的变化记录成 VCD,直接拖进 GTKWave 就能看。这一点极其重要——自动断言能覆盖九成情况,剩下那一成还是得靠眼睛看图。
仿真速度方面,实测一个三百个寄存器、三个时钟域的子系统,跑十万个时钟周期大概两秒到四秒,具体看组合逻辑深度。这个速度不足以跑完整的 SoC 级回归,但用来跑模块级自检和随机激励是够的。需要门级仿真或者超长回归的时候,我会切到联合仿真模式,把生成的 Verilog 交给专业仿真器,Python 这边只负责激励和比对。
3. 手把手搭环境:从零跑通第一个可综合模块
讲完原理,该动手了。这一节我按真实操作顺序来,包括目录怎么放、命令怎么敲、出错之后怎么看。所有命令都在 Linux 下验证过,Windows 用 WSL 也一致。
3.1 环境准备与目录约定
Python 版本要求 3.10 以上,因为用到了结构化模式匹配和一些新语法。依赖很少,核心只有三个:pytest负责测试驱动,numpy负责向量化比对,pyvcd负责波形写出。综合和布局布线走外部工具,框架本身不做这些事。
目录我建议这样放,两年下来我觉得这套约定最省心:
project/ ├── rtl/ # Python 侧的电路描述,一个文件一个模块 │ ├── counter.py │ └── async_fifo.py ├── tests/ # 每个模块配一个同名测试文件 │ ├── test_counter.py │ └── test_async_fifo.py ├── build/ # 生成的 Verilog,全部由工具产出,不进版本库 ├── constraints/ # SDC 时序约束,手写 └── pycircuit.toml # 全局配置:默认时钟、复位、生成器选项安装就一行:
python -m pip install -e .build/目录一定要写进.gitignore。我犯过一次错,把生成的 Verilog 提交进了版本库,后来有人手改了里面一个参数没同步回 Python,导致两边不一致,排查了半天。生成物就是生成物,永远不要手工改。
3.2 第一个可综合模块:带参数的同步计数器
计数器是硬件开发的"Hello World",但它能覆盖位宽、复位、使能、参数化这四个核心概念,非常适合当起点。
import pycircuit as pc class Counter(pc.Module): def __init__(self, width: int = 8, init: int = 0): super().__init__(name="counter") self.width = width self.clk = pc.Clock("clk") self.rst_n = pc.Reset("rst_n", active_low=True, sync=True) self.en = pc.Input("en", 1) self.q = pc.Output("q", width) self.cnt = pc.Reg("cnt", width, init=init) @pc.comb def nxt(self): # 显式声明回绕范围,框架会据此推断位宽 return (self.cnt + 1).truncate(self.width) @pc.seq(self.clk, self.rst_n) def tick(self): if self.en: self.cnt <<= self.nxt() @pc.comb def drive_q(self): self.q <<= self.cnt有几个地方值得说清楚。sync=True表示同步复位,综合出来是复位信号并入寄存器使能逻辑,不占用额外的异步复位资源。<<=这个运算符我特意重载成非阻塞赋值的语义,和 Verilog 的<=对齐,避免有人误用=写出组合时序混合逻辑。.truncate(width)是必须写的,因为cnt + 1的结果是宽一位的,不写就报错——这正是我最想要的那种报错。
生成 Verilog:
python -m pycircuit build rtl/counter.py --top counter --params width=8产物长这样,信号名和 Python 侧一一对应:
module counter #(parameter WIDTH = 8) ( input wire clk, input wire rst_n, input wire en, output wire [WIDTH-1:0] q ); reg [WIDTH-1:0] cnt; wire [WIDTH-1:0] nxt = cnt + 1'b1; always @(posedge clk) begin if (!rst_n) cnt <= 8'd0; else if (en) cnt <= nxt; end assign q = cnt; endmodule3.3 用 pytest 驱动的自检 testbench
这是整套流程里我最满意的部分。testbench 就是普通的 pytest 函数,断言失败会有正常的栈回溯。
import pytest import pycircuit as pc from rtl.counter import Counter @pytest.mark.parametrize("width", [4, 8, 16]) def test_counter_wraps_at_width(width): sim = pc.Sim(Counter(width=width)) sim.reset(cycles=2) # 拉低复位两拍再释放 assert sim.get("q") == 0 steps = (1 << width) + 5 # 绕一圈再多走五步 for _ in range(steps): sim.set(en=1) sim.tick() assert sim.get("q") == 5 # 回绕之后应当停在 5 def test_counter_holds_when_disabled(): sim = pc.Sim(Counter(width=8)) sim.reset(cycles=2) for _ in range(10): sim.set(en=1); sim.tick() for _ in range(20): sim.set(en=0); sim.tick() # 使能拉低,值必须保持 assert sim.get("q") == 10sim.reset()会按配置自动生成复位时序,释放时刻自动避开时钟边沿的保护窗口。sim.tick()推进一个时钟周期,所有寄存器在这个函数里完成更新,组合逻辑自动收敛。断言的写法非常直接——比对整数值,而不是比对波形。
跑起来就一句:
pytest tests/ -v参数化那一行帮我省了大量时间:三个位宽、绕圈回绕、使能保持,两分钟写完,换成 Verilog 加脚本驱动的 testbench,我至少写一个下午。
3.4 综合、时序核对与上板
生成的 Verilog 交给综合工具,我常用的是 Yosys 做快速面积评估,正式流程还是走厂商工具。SDC 约束手写,核心是时钟定义和跨域声明:
create_clock -name clk -period 10.000 [get_ports clk] set_input_delay -clock clk 2.0 [get_ports en] set_output_delay -clock clk 2.0 [get_ports q]综合完之后必看三样东西:寄存器数量、查找表占用、时序裕量。8 位计数器在常见 FPGA 上大约是 8 个寄存器和几个查找表,如果综合报出来的寄存器数量远超预期,通常是某个组合表达式被意外推断成了寄存器,八成是分支没写全。我遇到过一次,多出来的寄存器是因为if/else少写了else,框架的静态检查当时还没做全,后来补上了"时序块内赋值不完整"的检查,现在会在生成阶段直接报错。
注意:综合报告里的警告不要习惯性忽略。我给自己定的规矩是——综合日志里每一条警告都要能被解释清楚,解释不清的就去改代码,而不是加抑制标记。
4. 关键环节实现:一个跨时钟域异步 FIFO 的完整落地
计数器只是热身,真正检验框架的是跨时钟域设计。异步 FIFO 是每个硬件工程师都会遇到的东西,也是踩坑最多的地方。这一节我把需求拆解、代码实现、验证策略完整走一遍,包括深度计算的推导过程。
4.1 需求拆解与深度参数的计算过程
场景是这样:写侧时钟 50 MHz,读侧时钟 100 MHz。写侧以突发方式灌数据,最大突发长度 200 个写时钟周期,其中每 2 个周期写入一个 32 位数据,也就是突发期间共写 100 个数据。读侧持续读,每 4 个读时钟周期读出一个数据。
先算读写速率。写侧有效写速率是每 2 个写周期 1 个数据。读侧每 4 个读周期 1 个数据,换算到写时钟域:4 个读周期等于 2 个写周期(读时钟是写时钟的两倍),所以读侧每 2 个写周期读出 1 个数据。这样读写速率刚好相等,理论上不积累。
但工程上不能按理论值设计,必须留余量,因为读侧启动有延迟。指针从写侧同步到读侧需要经过两级同步器,加上格雷码转换的组合逻辑,实际延迟约 3 个读时钟周期,也就是 1.5 个写周期。在这段时间内写侧已经写了约 1 个数据,但读侧还没开始读。
再考虑对齐误差和突发内部的抖动,我取一个安全系数 2,得到深度需求约为 4。这个数字太小,没有工程意义,因为深度必须大于最大的同步延迟与突发的组合。实际我按下面的公式取:
最小深度 = 读写速率差 × 突发时长 + 同步延迟期间写入量 + 安全余量 = 0 + 1 + 余量真正决定深度的是最坏情况:如果读侧因为下游反压暂停 50 个读周期,写侧会在这段时间写入 25 个数据。所以深度必须覆盖"下游最长停顿期间的全部写入量"。我的下游最长停顿预估是 60 个读周期,对应写入 30 个数据,向上取 2 的幂,深度定为 64。
取 2 的幂有个硬性理由:格雷码指针在 2 的幂深度下,地址回绕只翻转一位,同步到另一侧时不会出现多位同时变化导致的错误采样。如果深度取 60 这种非 2 的幂,格雷码的循环结构就被破坏了,必须额外做地址映射,得不偿失。
4.2 代码实现要点与三段式结构
实现上我分三块:写侧逻辑、读侧逻辑、跨域同步。写侧维护二进制指针和格雷码指针,二进制指针用来寻址,格雷码指针送去同步。这个"双指针"的做法是异步 FIFO 的标准套路,原因很实在:二进制指针做地址加一最方便,格雷码指针跨域最安全,两者之间做一次异或转换,代价是每个时钟周期几个异或门。
class AsyncFifo(pc.Module): def __init__(self, width=32, depth=64): super().__init__(name="async_fifo") self.aw = (depth - 1).bit_length() # 地址位宽,64 深度为 6 self.wclk = pc.Clock("wclk") self.rclk = pc.Clock("rclk") self.wrst_n = pc.Reset("wrst_n", active_low=True, sync=True, domain="w") self.rrst_n = pc.Reset("rrst_n", active_low=True, sync=True, domain="r") self.wdata = pc.Input("wdata", width, domain="w") self.we = pc.Input("we", 1, domain="w") self.wfull = pc.Output("wfull", 1, domain="w") self.rdata = pc.Output("rdata", width, domain="r") self.re = pc.Input("re", 1, domain="r") self.rempty = pc.Output("rempty", 1, domain="r") # 存储用双端口 RAM,两个时钟域各一个端口 self.mem = pc.DualPortRam(width=width, depth=depth, wa=self.waddr, ra=self.raddr)满和空的判断是这套设计里最容易写错的地方,我把判断依据说清楚。空的判断:读侧同步过来的写指针格雷码,与读指针格雷码相等时为空。满的判断:写侧的格雷码指针与同步过来的读指针格雷码,最高两位相反、其余位相同,也就是格雷码意义上的"差一圈"。
跨域同步用两级触发器,这是标准做法:
@pc.sync_to(clock="r") def sync_wptr(self): # 两级同步,第一级是亚稳态风险窗口,第二级输出给逻辑用 self.wptr_gray_r1 <<= self.wptr_gray self.wptr_gray_r2 <<= self.wptr_gray_r1框架在这里做了一件我觉得很有价值的事:@pc.sync_to标注之后,静态检查会确认这条路径只经过同步寄存器,任何绕过同步器的跨域读都会报错。这比综合工具里那条"crossing clock domains"的通用警告具体得多,直接指出是哪根信号、哪个模块、哪一行。
注意:两级同步器只能降低亚稳态传播的概率,不能消除。涉及多位数据跨域时必须用格雷码或者握手,绝不能让多个位同时跨域。这个坑我踩过——早期版本我直接把二进制指针跨域,高位和低位变化速度不同,读侧采样到了一个从未存在过的地址,数据错乱。
4.3 验证策略:随机激励、断言与覆盖率三件套
异步 FIFO 的验证不能只跑固定序列,必须上随机。我用一个双进程的激励生成器:写侧线程按随机的间隔和随机的使能概率写数据,读侧线程同样随机。两边同时推进仿真时间,用一个队列在 Python 侧记录写入顺序,读出来的时候逐个比对。
def test_async_fifo_random(): sim = pc.Sim(AsyncFifo(width=32, depth=64)) expect = collections.deque() rng = random.Random(20240501) # 固定种子,失败可复现 for cycle in range(20000): if rng.random() < 0.35 and sim.get("wfull") == 0: val = rng.getrandbits(32) sim.set(we=1, wdata=val) expect.append(val) else: sim.set(we=0) if rng.random() < 0.40 and sim.get("rempty") == 0: sim.set(re=1) else: sim.set(re=0) sim.tick_both() # 两个时钟域各自推进一拍 if sim.get("rvalid"): assert sim.get("rdata") == expect.popleft() assert len(expect) < 64 # 结束时残留不应超过深度固定随机种子这一点非常重要。随机测试失败的时候,如果没有固定种子,你根本不知道是哪次激励触发的。我现在的习惯是:种子写在测试函数里,失败时日志会打印整个激励序列,可以原样重放。
断言方面,除了 Python 侧的数据比对,我还在 RTL 里插了内嵌断言,覆盖三类不变量:写指针永不超过读指针一圈、满状态下不写、空状态下不读。这些断言生成在 Verilog 里,联合仿真时由专业仿真器检查,比 Python 侧检查更接近真实硬件行为。
覆盖率我用的是简单直接的指标:读写指针的所有相对距离是否都被覆盖过。这个距离从 0 到 64,如果随机测试跑完还有某些距离从没出现过,说明激励分布有问题。我第一版就是这个问题——写侧概率设成 0.35,读侧 0.40,结果 FIFO 长期处于接近空的状态,从来没到过满附近,等于满判断逻辑根本没被验证。后来我把概率改成动态可调的:周期性进入"快写慢读"和"慢写快读"两种模式,才把整个距离空间跑满。
5. 工具选型对比:什么情况下该用它,什么情况下别碰
自己写的工具,最怕的就是"因为是我写的所以我觉得好"。所以这一节我尽量客观,把几个常见方案摆在一起对比,把适用边界说清楚。
5.1 几个方案的正面对比
| 维度 | PyCircuit 6 | Amaranth | MyHDL | 手写 Verilog + 脚本 |
|---|---|---|---|---|
| 描述语言 | Python 类与装饰器 | Python 生成器语法 | Python 生成器语法 | Verilog / SystemVerilog |
| 仿真与综合一致性 | 同一 IR,双后端 | 一致性较好 | 一般,历史包袱多 | 取决于仿真器 |
| 生成 RTL 可读性 | 高,信号名一一对应 | 中等 | 中等 | 最高 |
| 参数化能力 | 强,元编程直接可用 | 强 | 强 | 靠 parameter 与宏,较弱 |
| 验证生态 | pytest + numpy + 自研断言 | 第三方 cocotb 配合 | 自带仿真器 | UVM 等完整体系 |
| 跨域检查 | 内建,编译期报错 | 需自行约束 | 无 | 靠 lint 工具 |
| 学习成本 | 低,会 Python 就行 | 中,生成器语法需适应 | 中 | 高,需精通硬件与脚本 |
| 适合规模 | 10 到 30 个模块 | 中大型 | 中小型 | 任意 |
从表里能看出来,PyCircuit 6 的优势集中在中小规模、强参数化、验证驱动这三个交叉点上。一旦项目规模上去,优势会迅速被生态短板抵消。
5.2 我给出的选型建议
适合用的场景:算法验证板、教学项目、自定义协议解析、需要大量参数组合的 IP 核、以及任何"验证工作量大于设计工作量"的项目。这类项目里,参数化和测试复用带来的收益是压倒性的。
不建议用的场景:多团队协作的大型 SoC、需要采购或复用第三方加密 IP 的项目、以及有严格认证流程的领域。这些场景里,生态和工具链兼容性的权重远高于开发效率。特别是认证流程,它要求的是可追溯的工具资质,自研工具在这条路上很难走通。
还有一个现实建议:如果你只是偶尔写写小模块,别折腾框架,Verilog 加一个好的 lint 工具就够了。造框架的时间成本是实打实的,我前后投入的时间折算下来大概是一个多月的业余时间,如果项目总工作量不到两周,这笔账不划算。
6. 常见问题与排查技巧实录
这一节全是血泪。下面这些问题我都真实遇到过,排查思路和解决方法一并写出来。
6.1 问题速查表
| 现象 | 大概率原因 | 排查动作 |
|---|---|---|
| 仿真通过,综合后行为不同 | 组合逻辑存在毛刺被当时钟用,或分支不全推断出锁存 | 看综合日志的 latch 警告,检查所有时序块分支完整性 |
| 生成的 RTL 里信号名全变成 tmp | 中间表达式没有命名,被后端自动编号 | 给关键中间结果显式命名,或用pc.wire()声明 |
| 跨域路径检查报错但我觉得没问题 | 该路径确实绕过了同步器,只是恰好没出问题 | 打开 IR 视图打印路径,逐级确认是否经过两级同步 |
| 随机测试偶尔失败,重跑不复现 | 随机种子没固定,或仿真存在非确定性 | 固定种子,检查是否有多驱动或未初始化寄存器 |
| 位宽不匹配报错频繁 | 表达式默认扩宽,赋值时未显式截断 | 用.truncate()或.resize()明确意图,别关掉检查 |
| 仿真速度越来越慢 | 事件队列里累积了大量无变化事件 | 检查组合逻辑是否形成了缓慢收敛的循环 |
| 复位释放后第一拍寄存器值不对 | 复位释放离时钟边沿太近,或复位未同步 | 开启复位释放检查,把释放对齐到时钟边沿之后 |
6.2 几条用血换来的经验
第一条:把静态检查的开关全部打开,一个都别关。我在早期为了赶进度,把位宽检查和跨域检查都临时关掉了,理由是"先跑通再说"。结果就是两周后我在一堆报错里做考古,成本是当初的十倍。检查报错的那几分钟,永远比事后调试的几小时便宜。
第二条:所有寄存器必须给初值,哪怕综合工具不需要。仿真器里未初始化的寄存器如果默认是 0,会掩盖掉真实的 X 传播问题。我给框架设定的默认行为是:未指定初值的寄存器在仿真里是未知状态,读到未知值参与判断会直接抛异常。这个设定一开始让很多测试挂掉,但它帮我找到了三个真实的初始化缺失问题。
第三条:波形要常看,不要只信断言。断言只能检查你想到的事情。我有一个 bug 是写指针在满状态下仍然加了一拍,但因为满的时候数据被丢弃,Python 侧比对居然通过了。后来看波形才发现指针在满状态下多走了一格。现在我给自己定了个习惯:每个模块第一次跑通之后,必须人工看一遍关键信号的波形,确认时序关系符合预期,再开始写断言。
第四条:参数化要适度,不要为了通用性牺牲可读性。我曾经写过一个 FIFO,支持数据位宽、深度、是否带首字直通、是否支持非对齐写入,参数组合有十几种,代码里全是条件分支,生成出来的 RTL 里一半逻辑是死代码,综合工具报了几十条警告。后来我把它拆成两个模块,每个参数不超过三个,可读性和综合结果都好了很多。
第五条:把生成的 Verilog 当作阅读材料,而不是当作产物。我每次生成完都会打开看一眼,不是为了改,是为了确认生成的东西和我想的一样。这个习惯帮我发现了好几次"我以为我写了"和"实际我写了"之间的偏差,最典型的一次是我以为某个乘法被综合成 DSP 块,实际生成的是移位加法链,因为我把常数写成了变量。
最后分享一个我觉得最有用的做法:给每个模块在仓库里配一份简短的说明文件,写清楚它的时钟域、复位策略、接口时序假设、以及验证覆盖了哪些场景。这份文件不要求长,十行就够。我在两个月后回头看自己写的跨时钟域模块,代码看得懂,但当时的时序假设已经忘得差不多了,全靠这份文件把上下文捡回来。硬件开发这件事情,代码本身只是信息的一半,另一半是那些没写在代码里的约定和假设,把它们记下来,比多写几个断言更有价值。