用 Python 写硬件:PyCircuit 6 从仿真到 FPGA 上板
2026/9/17 10:43:12 网站建设 项目流程

1. 那瓶"醋"是什么:用 Python 写硬件的执念从哪来

先交代一下这盘饺子的由来。PyCircuit 6是我折腾了六轮的一个硬件开发框架,核心思路只有一句话:用 Python 把数字电路从设计到仿真、再到综合上板这一整条链路串起来。也就是说,我真正想要的那瓶"醋",其实是 Python 这套写起来顺手、库又多的语言;为了让这瓶醋能蘸上硬件开发这盘饺子,我不得不把饺子皮、饺子馅、案板、擀面杖全重新备一遍。

标题听着像段子,但这确实是很多做硬件的人有过的心路。你手上有个算法模型,在 Python 里跑得挺好,一旦要落到 FPGA 上,就要把它翻译成 Verilog 或 VHDL,翻译的过程既费时又容易出错,改一个参数往往要动十几处代码。于是"能不能直接用 Python 描述硬件"这个问题,就在每一次深夜改 bit 宽的时候反复冒出来。

PyCircuit 6 不是第一个吃这只螃蟹的,MyHDL、Amaranth、cocotb 都在这条路上走过。但它们的侧重点各不相同,有的偏设计、有的偏验证。而我想做的是一个"能从头走到尾"的东西,哪怕它没那么优雅,至少能让我在一个语言环境里把活儿干完。

1.1 硬件的"手写体"为什么让人越来越不耐烦

传统 HDL 的痛点不在语法难,而在冗余和缺乏抽象。一个再普通不过的计数器,你要写端口声明、类型声明、复位逻辑、时钟敏感列表,几百行代码里真正表达"意图"的可能就两三行。当设计规模上去以后,重复代码像滚雪球一样增长,参数化能力又弱,改一个位宽要全局搜索替换,稍不留神就埋下位宽不匹配的隐患。

Python 的优势恰恰在这里:列表推导、生成器、类继承、装饰器,这些抽象手段能极大压缩"只想描述一块逻辑"的表达成本。你可以写一个参数化的 FIFO 生成器,用几个循环就把任意深度、任意位宽的实例铺开,这在 Verilog 里要靠generate加一堆繁琐的样板代码。

1.2 "有 Python 就够了"是个陷阱

一开始我也天真地以为,写个库把 Python 对象映射成信号就完事了。真做下去才发现,硬件和软件的语义差得远。软件里赋值是瞬时的,硬件里信号在一个时钟沿上是"同时更新"的;软件里变量可以随意读写,硬件里组合逻辑不能成环;软件里的整数是数学意义的,硬件里的整数有明确的位宽和溢出行为。这些语义如果不在框架层面处理干净,用户写出来的电路就会和预期南辕北辙。

更麻烦的是时序。软件不用管"什么时候算完",硬件必须知道每个信号在哪一级寄存器、延迟几个周期。这就逼着框架必须有一套完整的时序推导能力,不能只做语法糖。

1.3 PyCircuit 6 想解决的最小闭环

我给自己定的目标很朴素:一个 .py 文件进去,一串能综合的 Verilog 出来,中间在 Python 里就能跑仿真验证。不追求覆盖所有 HDL 特性,但要求这条最小链路走得通、走得稳,遇到问题能定位。第 6 版基本做到了这件事,下面就把这套东西摊开讲讲,包括每一步我为什么这么设计、踩过哪些坑。

2. PyCircuit 6 的骨架:一盘饺子里到底有几样馅

先把架构讲清楚,不然后面聊细节会飘。PyCircuit 6 大体分成三层:前端(用户 API)、中端(中间表示 IR)、后端(代码生成与工具链接)。前端负责让你用 Python 描述电路,中端把描述转成一张有向图并做时序推导,后端把图翻译成 Verilog 并调用厂商工具。这个"三段式"结构不是我拍脑袋定的,而是被前几个版本逼出来的。

2.1 模块层:Module 与 ClockDomain 的设计取舍

最上面一层是Module,它相当于 Verilog 里的一个 module 实例。每个 Module 里挂着若干Signal,以及一个或多个ClockDomain。我在这里做了一个关键取舍:把时钟域显式建模,而不是隐式挂在信号上。

原因很现实。多时钟域设计里,跨时钟域是最容易出 bug 的地方,如果框架不把时钟域当一等公民,用户就没法清晰地表达"这个信号属于哪个时钟"。于是我把同步逻辑统一放进with clk.domain():这样的上下文里,凡是写在块内的赋值,默认都按该时钟域打一拍。这个设计灵感来自 Amaranth,但我在错误提示上花了更多功夫——一旦检测到跨域直接赋值,框架会主动报错而不是默默生成一段有隐患的电路。

from pycircuit import Module, Signal, ClockDomain class Counter(Module): def __init__(self, width=8): self.clk = ClockDomain() self.en = Signal(in_=True) self.q = Signal(width, out=True, reset=0) def build(self): with self.clk.domain(): if self.en: self.q.next = self.q + 1

这段代码描述的就是一个带使能的计数器,self.q.next表示"下一个时钟沿后的值"。读起来是不是比 Verilog 顺眼一些?关键在于if self.en:会被翻译成条件更新,而不是软件里的分支跳转。

2.2 信号与位宽:Signal 体系如何避免隐式陷阱

信号体系是踩坑最多的地方。硬件里位宽是硬约束,软件里你几乎不用操心。我最初的版本允许"自动推断位宽",结果一个a + b到底是 8 位还是 9 位全凭运气,生成出来的电路和用户预期经常差一位。

第 6 版的规则是:加法结果位宽 = max(操作数位宽) + 1,乘法结果 = 两者之和,比较运算固定返回 1 位。这套规则和 Verilog 的语义尽量对齐,同时在表达式树上做静态检查,一旦发现把超出位宽的常量塞进信号,就立刻报错。用户还可以用Signal.slice(high, low)显式取值,避免隐式截断带来的困惑。

我还加了一个"位宽追踪"的内省接口,能打印出任意表达式推导出的位宽。调试时把中间表达式的位宽打出来,很多莫名其妙的 bug 一眼就能看到。

2.3 前端、中端、后端三段式流水线

为什么非要拆成三段?因为把"描述"和"生成"耦合在一起,改一处后端规则就要动前端,维护成本太高。拆开之后,中端的 IR 是一张与语言无关的图,节点是运算符和寄存器,边是信号连接。这样后端换一个目标语言(比如从 Verilog 换成 VHDL)只需要重写生成器,前端和 IR 完全不用动。

数据在这三段的流动是单向的:前端构建 IR,中端优化和时序推导,后端消费 IR。单向意味着可预测,出问题能快速定位在哪一段。实测下来,90% 的用户报错都卡在前端构建 IR 阶段,因为那是用户代码直接接触的部分。

3. 从 .py 到比特流:PyCircuit 6 的编译链路到底经历了什么

上一节讲了骨架,这一节把"肉"补上——一个 .py 文件是怎么一步步变成能烧进芯片的比特流的。这条链路上有三道关:降维、时序推导、后端生成。每一关都有它必须解决的问题,跳过任何一关,出来的电路都可能不靠谱。

3.1 抽象语法树到中间表示(IR)的降维

用户写的 Python 代码会被解析成抽象语法树(AST),但 AST 里混着大量和电路无关的东西,比如 import、注释、装饰器。中端第一步就是把这棵树"降维"成纯粹的电路图。

降维过程中最难处理的是控制流。Python 里的if/for/while语义和硬件完全不同:if在软件里是分支,在硬件里是条件赋值或多路选择;for在软件里是循环执行,在硬件里往往要展开成并行硬件。我的做法是区分"元编程循环"和"硬件循环"——如果循环次数在编译期确定,就展开成并行结构;如果是运行期条件,就报错提示用户改用状态机。这个区分规则我写在了文档最显眼的位置,因为它违反了大多数人的直觉。

# 编译期循环:展开成 8 路并行 for i in range(8): self.out[i].next = self.ins[i] & self.en # 运行期循环:会被拒绝 while self.q != 0: self.q.next = self.q - 1 # 报错:请改用状态机

3.2 时序推导:为什么你不需要手写 always @(posedge clk)

Verilog 里到处都是always @(posedge clk)这种敏感列表,写多了容易漏条件,导致综合出锁存器。PyCircuit 6 里这个信息是从上下文推出来的:你写在with clk.domain():里的赋值默认就是时序逻辑,写在外面的组合逻辑则要求无环。

推导的难点在于判断一个赋值到底是组合还是时序。规则定得很死:用.next赋值的一律时序,用.eq或直接表达式的默认组合。这样用户一眼就能看出自己在写什么。中端完成推导后,每个信号都会带上"属于哪个时钟域、几级延迟"的元数据,后端据此生成正确的敏感列表。实测下来,这套规则虽然不如图灵完备,但覆盖了 95% 的常见写法,剩下的用显式 API 补充。

3.3 后端:Verilog 生成与厂商工具链接

IR 出来后,后端负责翻译成可综合的 Verilog。这一层我最看重的是生成代码的可读性。工具生成的代码经常被诟病"没人看得懂",我特意保留了信号原名、加了注释,让它既能被综合工具吃进去,也能被人看懂。

生成完 Verilog 后,后端会调用厂商的综合工具(具体命令随目标器件不同),拿回时序报告和资源占用。第 6 版把这一步做成了可插拔的:不同厂商、不同器件用不同的后端插件,框架本身不硬编码任何一家。这样即便换了平台,用户代码也不用改。

注意:后端调用外部工具时,务必把综合警告当回事。我踩过一个大坑——某个隐式位宽扩展被工具静默接受,仿真对、上板错,查了整整两天才定位到那行"无害"的警告。

4. 仿真与验证:硬件开发里最不该省的那顿饭钱

硬件开发的残酷之处在于:软件出 bug 改一行重跑,硬件出 bug 可能要重做板子。所以仿真和验证的重要性怎么强调都不过分。PyCircuit 6 把仿真器做成了框架内建能力,不需要额外接第三方工具就能跑测试。

4.1 事件驱动仿真器的时钟模型

我在中端图的基础上写了个简单的事件驱动仿真器。核心是维护一个"事件队列",每个时钟沿推进时,把所有时序元件的更新事件排队执行,执行顺序按信号依赖拓扑排序,保证仿真结果和真实电路一致。

这里有个细节容易被忽略:组合逻辑必须在同一时间步内迭代到稳定。如果 A 依赖 B、B 又依赖 A,就是组合环,仿真器会检测到并在若干轮迭代后报错。这个检测机制帮我提前抓出过好几个难以发现的组合环 bug,尤其是当设计里有多个模块互相连接的时候。

sim = Simulator(top) sim.add_clock(top.clk, period=10) for _ in range(20): sim.step(10) print(sim.get(top.q))

4.2 断言与覆盖率:把 bug 拦在流片之前

光看波形不够,还得有断言。PyCircuit 6 允许在模块里直接写assert,仿真时若条件不满足就立即停下并打印上下文。我特别推荐在跨时钟域的握手信号上加断言,比如"valid 拉高后必须在 N 个周期内收到 ready",这类协议 bug 靠肉眼看波形极难发现。

覆盖率方面,框架会统计每个信号被触发的分支,以及状态机走过哪些状态。覆盖率报告看着麻烦,但它能回答一个关键问题:"我这段测试到底测了什么?"我见过太多人写了上千行测试,实际只覆盖了一半路径。

4.3 与软件测试的思维差异

软件测试讲究"输入输出对得上就行",硬件测试还得测时序。同一个输出值,在第 3 个周期出现和在第 5 个周期出现,可能是完全不同的 bug。所以 PyCircuit 6 的测试 API 支持"在第 N 个周期断言某信号等于某值",把时序敏感度写进测试里。这个习惯养成了以后,很多潜伏的时序 bug 会在开发早期就暴露。

5. 横向对比:PyCircuit 6 与 MyHDL、Amaranth、cocotb 摆一桌

既然大家都在这条路上走,那就干脆摆一桌对比清楚。没有最好的工具,只有最合适的场景。我把几个主流方案的核心差异列出来,方便你按需选型。

方案定位语言仿真综合典型场景
MyHDL设计 + 仿真Python内建生成 V/VHDL教学、中小设计
Amaranth设计为主Python内建生成 Verilog中大型 RTL
cocotb验证为主Python驱动外部不支持验证已有 RTL
PyCircuit 6设计 + 仿真 + 后端Python内建生成 Verilog端到端小闭环

5.1 各自的设计哲学

MyHDL 是最早把 Python 当 HDL 用的项目之一,思路是"Python 语法直接映射到硬件",所以你会看到很多alwaysposedge之类的关键字,和 Verilog 思维接得很近。Amaranth 走的是更现代的路线,用上下文管理器显式表达时钟域,抽象更干净。cocotb 则完全专注验证,它不生成硬件,只负责用 Python 驱动仿真器测试已有的 Verilog/VHDL。

PyCircuit 6 的哲学更偏"端到端打通":我既要设计能力,也要验证能力,还要一条能走到综合的路。它可能在单项上不如专门的工具,但胜在链路完整。

5.2 什么时候该选谁

如果你的目标只是验证已有的 RTL,cocotb 是明确的最优解,生态成熟、社区活跃。如果你想用 Python 写 RTL 且追求成熟度,Amaranth 值得优先考虑。如果你在做教学或者想要一个语法接近 Verilog 的过渡,MyHDL 更平滑。而如果你像我一样,想要一个自己能把控、模块边界清晰、能一路走到上板的框架,PyCircuit 6 这种自研路线才有意义——但你要接受它不够成熟、文档得自己写。

5.3 PyCircuit 6 的差异化在哪

我反思过这个问题,结论是"可定位性"。自研框架最大的好处是,任何一层出问题我都能直接翻源码,不用在黑盒里猜。第 6 版在错误信息上投入了很多,每条报错都带上出现位置、涉及的信号名和建议修法。这不是炫技,而是被前面五个版本的调试噩梦逼出来的。

6. 从 1 到 6:六个版本里踩过的坑与新加的糖

六个版本不是迭代出来的花架子,每一步都是被现实按在地上摩擦后的产物。挑几个最有代表性的讲讲,希望你能少走点弯路。

6.1 位宽推断那次翻车

第 2 版的位宽推断是"自动"的,我以为省心,结果生成出来的加法器位宽全错。举个例子,a是 8 位、b是 8 位,a + b我最初推断成 8 位,进位直接被截掉,仿真时因为测试用的是小数值没发现问题,上板立刻翻车。教训是:位宽必须显式、有规则、可检查。第 6 版的规则我在 2.2 节讲过,就是那次翻车换来的。

6.2 时钟域交叉(CDC)的补课

第 3 版加了多时钟域支持,但没做跨域检查,用户(也就是我自己)随手就把 A 时钟域的信号赋给了 B 时钟域的寄存器。这种电路在仿真里可能"看起来正常",实际上到硬件就是亚稳态。第 4 版专门加了 CDC 检测:只要检测到跨域直接赋值就报错,强制用户显式声明同步器。这个功能一开始很烦,但它救回来的排查时间远超过它带来的麻烦。

6.3 第 6 版主要变化

到第 6 版,主要动了三块:一是把后端插件化,支持多种器件;二是优化了错误提示,带源码定位;三是把仿真器的组合环检测做成了默认开启。此外还补了一批常用模块的原语库,比如 FIFO、同步器、分频器,用户不用每次从零写。这些原语都经过了我反复的仿真验证,比自己手搓靠谱。

7. 上手实操:用 PyCircuit 6 点亮一串流水灯

前面讲原理,这一节上干货。目标:在开发板上跑一串流水灯。别看简单,它能覆盖设计、仿真、综合、上板全链路,是最好的入门练手项目。

7.1 环境准备

你需要 Python 3.9 及以上,以及目标器件的综合工具链。PyCircuit 6 通过 pip 安装,后端插件根据你的器件单独装。装完以后用pycircuit doctor检查环境,它会告诉你缺哪些依赖、路径对不对。这一步别偷懒,环境问题占新手卡壳的一大半。

pip install pycircuit pycircuit doctor --backend generic

7.2 写模块

流水灯的核心是一个移位寄存器,每个时钟沿把点亮位左移一位,到末尾再绕回来。用 PyCircuit 6 写出来不到二十行:

from pycircuit import Module, Signal, ClockDomain class Blinker(Module): def __init__(self, width=4, div=24): self.clk = ClockDomain() self.div = div self.leds = Signal(width, out=True, reset=1) def build(self): cnt = Signal(self.div, reset=0) with self.clk.domain(): cnt.next = 0 if cnt == self.div - 1 else cnt + 1 if cnt == self.div - 1: self.leds.next = self.leds.rotate_left(1)

div是分频系数,用来把板子的高频时钟降到肉眼能看见的闪烁速度。rotate_left是原语库里提供的循环移位,比手写拼接干净得多。

7.3 仿真验证

上板之前先在仿真里确认逻辑对。写个测试,跑几十个周期,断言灯的每一位依次点亮:

from pycircuit import Simulator sim = Simulator(Blinker(width=4, div=2)) sim.add_clock(sim.top.clk, period=10) seen = [] for _ in range(16): sim.step(10) seen.append(sim.get(sim.top.leds)) assert len(set(seen)) == 4 # 四种状态都出现过

断言通过说明移位逻辑正常。如果没过,先看波形,再检查分频和复位。这一步花的时间最值——它能拦下绝大多数低级错误。

7.4 综合与上板

仿真过了就调后端生成 Verilog 并综合:

pycircuit build blinker.py --backend generic --out build/

生成的build/blinker.v可以直接塞进厂商工具综合。约束文件里记得指定时钟频率和引脚映射,这两样框架不替你决定,因为每块板子都不一样。综合通过后,把比特流烧进去,你应该就能看到灯跑起来了。

提示:首次上板强烈建议先用最简单的"单灯常亮"验证烧录链路,别一上来就跑复杂逻辑。烧录链路的坑和设计逻辑的坑混在一起,排查起来会很痛苦。

8. 边界与取舍:什么时候该上 PyCircuit,什么时候老实写 Verilog

讲了这么多,最后必须泼盆冷水:PyCircuit 6 不是万能药。它适合某些场景,不适合另一些。想清楚边界,比盲目上手重要。

8.1 适合的场景

它最舒服的场景是中小规模、参数化程度高、需要频繁仿真验证的设计。比如算法原型验证、教学实验、中小规模的控制逻辑。这类设计里 Python 的抽象优势能充分发挥,参数一改、重新仿真验证一遍,迭代极快。我自己做算法到硬件的映射时,基本都用它先跑通逻辑,再考虑细节优化。

8.2 别碰的场景

如果你的设计对时序收敛极度敏感、要用手工布局布线、或者依赖大量厂商特定 IP,那还是老老实实写 Verilog,配合成熟工具链。自研框架在极端优化场景下的可控性和稳定性都不够。另外,团队协作场景下用自研框架要慎重,别人接手成本高,文档和培训都得跟上,否则就是给自己挖坑。

8.3 混合工作流

实践中最舒服的是混合:用 PyCircuit 6 写核心逻辑并仿真验证,生成 Verilog 后,在厂商工具里做时序约束和 IP 集成。两边各干各擅长的活儿,框架负责把逻辑写清楚、验充分,工具负责把电路做到位。这套流程我用了大半年,自认为效率提升明显,尤其是仿真验证环节,Python 的便利性是真香。

话说回来,为了这瓶"醋"重新包一盘饺子,值不值?我的答案是:如果这台饺子的配方你在自己手里,值。PyCircuit 6 未必适合所有人,但它让我第一次觉得,硬件开发和软件开发的思维鸿沟没那么难跨越了。如果你也在被 HDL 的样板代码折磨,不妨用 Python 先搭个小模块试试,从点亮一盏灯开始,你会理解我为什么愿意为它折腾六个版本。

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

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

立即咨询