1. 从一瓶醋说起为什么我要重造硬件开发这盘饺子“为了那瓶醋我重新包了一盘硬件开发的饺子”——这句话是我在项目做到第三周的时候脑子里突然蹦出来的。起因特别简单我想在 FPGA 上做一个滑动窗口滤波器用来处理一路 ADC 采样数据。按常规路子写个 Verilog 模块例化几个寄存器状态机推一推两个下午就能收工。但当我真正开始动手问题来了窗口长度想从 8 改成 16我得手动改一堆参数、位宽、循环边界想换个滤波算法整个模块几乎要重写想仿真验证一下中间结果还得单独搭 testbench信号名对不上就查半天。这瓶“醋”就是滑动窗口滤波这个具体需求而那盘“饺子”就是整个硬件开发流程。我当时的想法是既然每次写 Verilog 都这么费劲能不能搞一套工具让我用更高级的方式描述硬件自动生成 Verilog于是就有了PyCircuit 6这个项目——一个用 Python 做前端、MLIR 做中间表示、最终产出 Verilog 的硬件开发框架。说白了PyCircuit 6 想解决的问题是让硬件描述像写软件一样自然同时不牺牲底层可控性。它适合谁适合那些写过 Verilog、被位宽和时序折磨过、但又不想完全放弃底层控制的开发者也适合想从软件转硬件、被 Verilog 语法劝退的人。你不需要精通 Chisel 或 SystemVerilog只要会 Python 的基本语法就能描述出可综合的硬件电路。我先把结论放这儿PyCircuit 6 不是要替代 Verilog而是要在 Verilog 之上加一层“可编程的抽象”。你可以把它理解成硬件界的“编译器前端”——你写 Python它帮你生成干净的 Verilog中间经过 MLIR 做优化和验证。下面我从设计思路、核心细节、实操过程到踩坑记录完整拆一遍。2. 整体设计与思路拆解为什么是 Python MLIR Verilog2.1 为什么不用 Chisel 而要自己造轮子Chisel 确实是硬件开发领域里 Scala 系的高级抽象代表我也用过一段时间。它的优点很明显参数化能力强、支持面向对象、能生成 Verilog。但问题在于Chisel 的学习曲线对软件背景的人不算友好——Scala 的隐式转换、类型系统、函数式编程范式光是搞懂when、otherwise、Wire、Reg这些 DSL 构造就要花不少时间。而且 Chisel 的编译流程比较重一个小改动就要重新跑整个 FIRRTL 编译链迭代速度在调试阶段会让人抓狂。PyCircuit 6 的选择是用 Python 做前端 DSL用 MLIR 做中间层用 Verilog 做后端。这个组合的逻辑是这样的Python 的语法足够简单生态足够丰富做参数化生成、脚本化测试、自动化流程都很顺手。MLIR 提供了多级中间表示可以在不同抽象层次做优化和变换比如把高层算子降到 RTL 级、做位宽推断、做死代码消除。Verilog 是最终交付物保证能对接现有的综合工具和仿真器比如 Icaurus Verilog、Vivado、Quartus。注意PyCircuit 6 不是第一个做这件事的项目但它是我个人在“滑动窗口滤波”这个具体场景下觉得最顺手的一套组合。如果你已经在用 Chisel 且团队协作顺畅没必要强行迁移。2.2 MLIR 在硬件开发里到底扮演什么角色MLIR 全称 Multi-Level Intermediate Representation最早是编译器领域的东西后来被广泛用于机器学习框架和硬件加速器设计。它的核心思想是用一套统一的 IR 框架支持多种方言Dialect每种方言描述不同抽象层次的计算。在 PyCircuit 6 里我定义了三个层次的方言层次方言名称描述内容典型操作高层PyCircuit-High算法级描述类似 Python 函数滑动窗口、卷积、累加中层PyCircuit-RTL寄存器传输级带时序寄存器读写、状态机、多路选择低层PyCircuit-Gate门级网表接近 Verilog与门、或门、触发器这样做的好处是你可以在高层写算法在中层做时序优化在低层做面积和功耗权衡。比如滑动窗口滤波高层只需要描述“对最近 N 个采样做加权平均”中层会自动插入移位寄存器和加法树低层再根据目标工艺做资源映射。2.3 为什么最终输出 Verilog 而不是直接上 FPGA有人可能会问既然都做到 MLIR 了为什么不直接生成比特流原因很简单Verilog 是硬件开发的通用语言综合工具链成熟调试手段丰富。你生成的 Verilog 可以拿去跑仿真、可以插 ILA、可以手动改几行做实验。如果直接生成比特流出了问题你连信号都抓不到。而且 Verilog 的可读性对于团队协作很重要。PyCircuit 6 生成的 Verilog 是带注释的、信号命名清晰的、模块层次分明的不是那种“机器生成的天书”。我实测下来一个中等复杂度的滑动窗口滤波器生成的 Verilog 大概 200 行左右人工 review 完全没问题。3. 核心细节解析与实操要点从 Python 到 Verilog 的关键环节3.1 前端 DSL 的设计让 Python 写起来像硬件PyCircuit 6 的前端 DSL 核心是一组 Python 装饰器和类。你不需要写always (posedge clk)而是用circuit装饰器标记一个函数用Signal类表示信号用Reg类表示寄存器。举个例子一个 8 位计数器from pycircuit import circuit, Signal, Reg circuit def counter(clk, rst, en): cnt Reg(8, init0) with clk.posedge(): with rst.if_(): cnt.next 0 with en.if_(): cnt.next cnt 1 return cnt这段代码会生成对应的 Verilogmodule counter( input clk, input rst, input en, output reg [7:0] cnt ); always (posedge clk) begin if (rst) begin cnt 8d0; end else if (en) begin cnt cnt 1b1; end end endmodule关键点在于Python 的with语句被重载成了时序和条件分支的语义。clk.posedge()表示上升沿触发rst.if_()表示同步复位条件。这种写法比 Verilog 的begin...end嵌套更直观而且不容易漏掉else分支。实操心得一开始我担心 Python 的动态类型会导致位宽推断错误后来发现只要在Reg和Signal初始化时显式指定位宽MLIR 的类型系统会帮你检查所有运算的位宽一致性。如果两个信号位宽不匹配编译阶段就会报错不会拖到仿真才发现。3.2 滑动窗口滤波的硬件实现细节回到那瓶“醋”——滑动窗口滤波。在 PyCircuit 6 里我用高层方言描述算法circuit def moving_average(clk, rst, din, valid): window [Reg(16) for _ in range(8)] sum_reg Reg(20) with clk.posedge(): with rst.if_(): for i in range(8): window[i].next 0 sum_reg.next 0 with valid.if_(): window[0].next din for i in range(1, 8): window[i].next window[i-1] sum_reg.next sum(window) din dout sum_reg 3 return dout这段代码在 MLIR 中层会被转换成移位寄存器链和加法树。具体来说8 个 16 位寄存器构成移位窗口每个时钟周期数据向右移动一位。加法树把 8 个寄存器的值加上当前输入共 9 个数求和。最后右移 3 位实现除以 8得到平均值。这里有个细节求和结果的位宽必须足够大否则会溢出。8 个 16 位数相加最大需要 19 位16 log2(8) 19再加上当前输入需要 20 位。我在代码里显式声明了Reg(20)MLIR 的位宽推断会验证这个选择是否正确。参数值说明输入位宽16ADC 采样精度窗口长度8可参数化调整求和位宽20防止溢出输出位宽16平均值精度资源消耗8 个 16 位寄存器 1 个 20 位寄存器 加法树在 Artix-7 上约 120 LUT3.3 MLIR 优化通道的配置与调优PyCircuit 6 的 MLIR 优化通道是可配置的。默认情况下它会跑以下 pass位宽推断自动推导每个信号的位宽检查一致性。死代码消除移除未使用的寄存器和逻辑。公共子表达式消除合并重复的运算。寄存器重定时在组合逻辑路径上插入寄存器改善时序。资源映射把加法树映射到 DSP48 或 LUT。你可以通过pycircuit.optimize(level2)来控制优化级别。级别 0 不做任何优化级别 1 只做位宽推断和死代码消除级别 2 加上重定时和资源映射级别 3 会尝试更激进的面积优化。注意寄存器重定时会改变信号的时序关系如果你在 testbench 里依赖特定的周期数建议先用级别 1 验证功能再逐步提高优化级别。4. 实操过程与核心环节实现从零跑通一个滑动窗口滤波器4.1 环境搭建与依赖安装PyCircuit 6 的依赖不算多但 MLIR 的安装比较讲究。我推荐用 conda 建一个独立环境conda create -n pycircuit python3.10 conda activate pycircuit pip install pycircuit6 mlir-python-bindings如果你要用 Icaurus Verilog 做仿真还需要装conda install -c conda-forge iverilogVivado 或 Quartus 不是必须的但如果你想看综合后的资源报告建议装一个。我平时用 Vivado 做综合Icaurus Verilog 做功能仿真两者配合基本覆盖所有需求。4.2 编写第一个 PyCircuit 模块新建一个moving_average.py内容如下from pycircuit import circuit, Reg, Signal from pycircuit.sim import Simulator circuit def moving_average(clk, rst, din, valid, window_size8): window [Reg(16) for _ in range(window_size)] sum_reg Reg(20) with clk.posedge(): with rst.if_(): for i in range(window_size): window[i].next 0 sum_reg.next 0 with valid.if_(): window[0].next din for i in range(1, window_size): window[i].next window[i-1] total din for i in range(window_size): total total window[i] sum_reg.next total dout sum_reg 3 return dout if __name__ __main__: ma moving_average() ma.emit_verilog(moving_average.v) print(Verilog generated: moving_average.v)运行python moving_average.py你会得到moving_average.v。打开看看应该是一个带参数化窗口长度的模块。4.3 仿真验证用 Python 写 testbenchPyCircuit 6 自带一个轻量级仿真器可以直接在 Python 里跑时序仿真from pycircuit.sim import Simulator sim Simulator(moving_average) sim.set_clock(clk, period10) sim.reset(rst, cycles2) test_data [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000] for data in test_data: sim.set_input(din, data) sim.set_input(valid, 1) sim.tick() dout sim.get_output(dout) print(fdin{data}, dout{dout})跑出来的结果应该显示前 8 个周期输出逐渐增大第 9 个周期开始输出稳定在平均值附近。如果你发现输出一直是 0检查valid信号是否拉高以及复位是否释放。实操心得仿真的时候我习惯先用小窗口比如 4跑一遍确认逻辑正确后再改成 8 或 16。窗口越小波形越容易看调试速度越快。4.4 生成 Verilog 并做综合生成的 Verilog 可以直接喂给 Vivado 或 Quartus。我一般会写一个简单的约束文件create_clock -period 10.000 -name clk [get_ports clk] set_input_delay -clock clk 2.000 [get_ports din] set_output_delay -clock clk 2.000 [get_ports dout]综合后看资源报告一个 8 窗口的滑动平均滤波器大概消耗资源用量说明LUT120加法树和移位逻辑FF1368×16 位窗口 20 位累加器DSP0纯 LUT 实现最大频率250 MHz在 Artix-7 上如果你想要更高的频率可以在 MLIR 优化通道里开启寄存器重定时把加法树拆成两级流水线。代价是延迟增加一个周期但频率能上到 350 MHz 左右。5. 常见问题与排查技巧实录那些我踩过的坑5.1 位宽不匹配导致的静默错误这是最常见的问题。比如你把一个 16 位信号直接赋给 8 位寄存器Verilog 会静默截断高位仿真结果看起来“差不多”但实际已经错了。PyCircuit 6 的 MLIR 位宽推断会在编译阶段报错Error: Width mismatch: assigning 16-bit signal to 8-bit register解决办法很简单显式做位宽转换。比如din[7:0]取低 8 位或者用din 8取高位。我一般会在代码里加注释说明为什么这么截断。5.2 复位逻辑的同步与异步之争PyCircuit 6 默认生成同步复位。如果你需要异步复位可以在Reg初始化时指定async_resetTrue。但要注意异步复位在跨时钟域时容易出问题如果你有多个时钟域建议统一用同步复位或者加复位同步器。复位类型优点缺点适用场景同步复位时序好跨时钟域安全需要时钟才能复位大多数数字电路异步复位无需时钟即可复位释放时可能亚稳态上电初始化5.3 仿真与综合结果不一致有时候仿真跑得好好的综合后行为不对。常见原因有三个未初始化寄存器Verilog 仿真时寄存器默认是 X综合后可能是 0 或 1。PyCircuit 6 强制要求所有寄存器显式初始化避免这个问题。组合逻辑环路MLIR 会检测组合逻辑环路并报错但如果你手动改了生成的 Verilog可能引入环路。时序约束不完整综合工具不知道你的时钟频率可能优化掉一些逻辑。记得写约束文件。5.4 常见问题速查表现象可能原因排查方法解决方案输出一直为 0valid 未拉高 / 复位未释放查波形检查 testbench 时序输出值偏大位宽溢出看 MLIR 位宽报告增加累加器位宽综合报错信号未声明看 Verilog 生成日志检查 Python 代码变量名频率上不去组合逻辑太长看时序报告开启寄存器重定时仿真卡死组合逻辑环路看 MLIR 环路检测检查反馈路径独家避坑技巧每次改完 Python 代码先跑pycircuit lint做静态检查再跑仿真最后才综合。这个顺序能帮你省下大量时间因为综合一次动辄几分钟而 lint 和仿真都是秒级。6. 工具链选型与对比PyCircuit 6 在硬件开发生态中的位置6.1 与 Chisel、SystemVerilog、传统 Verilog 的对比特性PyCircuit 6ChiselSystemVerilogVerilog前端语言PythonScalaSystemVerilogVerilog学习曲线低中高中低参数化能力强很强中弱中间表示MLIRFIRRTL无无生成 Verilog是是否否仿真支持Python IcaurusTreadle Verilator原生原生生态成熟度早期较成熟成熟成熟PyCircuit 6 的优势在于Python 生态的易用性和MLIR 的优化能力。如果你团队里有人会 Python 但不会 ScalaPyCircuit 6 的上手速度会快很多。但如果你需要非常成熟的验证 IP 和工具链支持SystemVerilog 仍然是首选。6.2 什么时候该用 PyCircuit 6什么时候不该用适合的场景算法复杂度高、参数化需求强的模块比如滤波器、矩阵运算、编解码器。快速原型验证需要频繁改参数、跑仿真。软件背景的开发者想尝试硬件设计。不适合的场景需要对接特定厂商 IP 核比如 DDR3 控制器、PCIe 硬核。项目周期紧、团队已经熟悉 Verilog 且没有学习新工具的预算。对面积和功耗有极端要求需要手工优化门级网表。我个人经验是PyCircuit 6 适合做“算法到 RTL”的快速迭代但最终的顶层集成和时序收敛还是得靠传统 Verilog 和综合工具。两者不是替代关系而是互补关系。7. 后续扩展与个人体会这个项目做到现在滑动窗口滤波只是第一个用例。我接下来打算把 I2C 读写 EEPROM、QSPI 读写 Flash、串口数据传输这些常见外设控制逻辑也用 PyCircuit 6 重写一遍看看能不能把常用外设的驱动开发时间从几天压缩到几小时。另外MLIR 的优化通道还可以继续扩展比如加入自动流水线插入、时钟域交叉检查、功耗估算等功能。说实话为了那瓶醋重新包饺子听起来有点折腾。但实际做下来我发现这盘饺子确实比原来那盘好吃——参数化改起来方便仿真跑得快生成的 Verilog 也干净。如果你也在做硬件开发被 Verilog 的重复劳动折磨过不妨试试 PyCircuit 6 的思路用 Python 描述硬件用 MLIR 做优化用 Verilog 做交付。不一定非要用我的实现但“高层抽象 中间优化 底层交付”这个模式我觉得是硬件开发未来的一个方向。