最近在追体系结构方向的论文榜单,翻到一篇很有意思的工作,题目是:VerilogCoder: Autonomous Verilog Coding Agents with Graph-based Planning and Abstract Syntax(原文标题太长,后几个词被截断,应该是抽象语法树那一类表示)。这个名字我盯了很久,因为硬件描述语言自动生成这件事,过去总被认为“LLM写不好Verilog”,但这篇论文把“自动编码”和“图规划”放在一起,摆明了要在编译器层面的精准性上做文章。我把全文啃完,又顺着它的思路做了几天手工实验,这里把我的拆解和实操心得整理出来,供同样在搞芯片前端设计、或者正在研究大模型辅助硬件开发的读者参考。
如果你平时写C/C++/Python比较多,可能觉得“用AI写代码”早就不是新闻。但换成Verilog,事情会立刻变味:HDL代码不仅要满足功能,还要能被综合工具接受,时序能不能收敛、状态机有没有死锁、有没有产生锁存器,这些都是普通代码生成不关心的硬约束。VerilogCoder想做的,就是让一个智能体像工程师一样,先把设计目标拆成一张依赖图,再按照图上的节点一步步生成和验证代码。这篇论文的核心价值不在于“用了一个大模型”,而在于它把“规划”和“语法结构”放在了生成过程的正中间,这件事值得展开聊。
1. 从“写代码”到“设计硬件”:Verilog自动生成到底难在哪
1.1 硬件设计自动化走到哪一步了
芯片前端设计里,RTL级Verilog仍然是主干语言。这几年EDA厂商陆续把自己工具链往云端推,业界也在尝试把大型语言模型接进IC设计流程,但大部分落地方式还是“用ChatGPT当辅助问答”——你问一句“怎么写一个同步FIFO”,它给你一段代码,你复制到仿真环境里跑,跑不通再贴报错给它修。
这种方式应对简单模块还可以,一旦设计规模上去,问题就暴露了:LLM基于概率生成代码,它不会自动意识到“先写接口定义,再写内部状态,最后写组合逻辑输出”这样的依赖顺序。你让它一次性生成一个多级流水线的Verilog,它很可能把各个子模块的接口对不上,或者把时序逻辑和组合逻辑混在一起,导致仿真波形奇奇怪怪。
这篇论文的出发点就是把“生成代码”从单步完成改成一个有规划的计算过程:先把大任务拆成小任务,再按依赖关系逐个求解。这个思路在软件工程里已经有很多先例,但放到Verilog上,难点在于怎么表示硬件设计的依赖关系,以及怎么把语法结构上的约束嵌入到每一步生成之中。
1.2 Verilog代码生成的特殊性
Verilog本身兼顾了行为级和结构级描述。同一个模块,你可以用行为级写法(always块、if/else、case),也可以用结构级写法(模块实例化、连线)。LLM常见问题是倾向生成行为级代码,因为训练语料里这类代码更多,但在真实项目里,结构级代码反而占据了很大比例:例化IP、连接总线、做层次化组合。
另外,Verilog有非常强的“风格约束”。同一个功能,用阻塞赋值、非阻塞赋值、组合always块和时序always块,结果可能天差地别。通常可综合代码有一条铁律:时序逻辑里用非阻塞赋值,组合逻辑里用阻塞赋值。LLM如果没被显式约束,很容易在这条铁律上翻车。论文里提到使用“抽象语法”约束,我理解为它会把这类规则编码到语法表示里,让智能体生成的每一步都走在合法子树上。
提示:如果你在调LLM写Verilog时遇到莫名其妙的多驱动、锁存器警告,大概率不是功能错,而是语法风格没被约束住。抽象语法树的作用就是把这种约束从“prompt里的口头嘱咐”变成“生成过程的结构限制”。
2. 方法核心:Graph-based Planning + Abstract Syntax
2.1 为什么是图,而不是一段自然语言描述
标题里最显眼的两个词,一个是“Graph-based Planning”,一个是“Abstract Syntax”。很多论文在做代码生成时,也提到“planning”,但通常只是让LLM先写一段“计划”,再写代码。这种做法本质上还是一种线性文本:计划写在前面,代码写在后面,模型还是从第一个token一直预测到最后一个token,前后依赖很难长期保持。
VerilogCoder用图来做规划,本质上是把整个设计结构当成一张有向图:每个节点代表一个待生成的代码片段、一个模块或者一个函数块,连边代表依赖关系。比如:
- “解析输入信号”是“生成状态更新逻辑”的前置节点;
- “状态更新逻辑”又是“输出逻辑”的前置节点;
- 某些组合逻辑可能并行,它们之间没有连边,可以并行处理。
图的好处是显式地表达了这种先后和并行关系。智能体不再靠“记住前面说过什么”来维持一致,而是直接看图上还有哪些前置节点没完成,哪些节点可以同时开工。这个思路很像微内核架构里任务依赖图的调度,只不过这里调度的是“代码生成步骤”。
2.2 抽象语法树在这里不是装饰品
“Abstract Syntax”这个词组大概率对应Abstract Syntax Tree,也就是AST。一般的代码生成模型也会用AST辅助,但VerilogCoder更进了一步:它把AST节点直接作为决策单元。给定一个自然语言设计意图,系统先生成一个高层级的设计骨架,这个骨架由AST节点构成,每个节点标注了功能职责和接口信息,然后再由智能体逐节点填充实现。
这样做的好处,对应到工程实践上非常直观:如果你直接让LLM输出整个文件,它很难意识到“这个模块的实例名会在三个地方被引用”。但如果你把AST拆开,一个节点对应一个模块定义,另一个节点对应一个实例化,那么“填充模块定义”和“填充实例化”就变成了两个有清晰边界的子任务,模型可以在各自范围内集中精力。
而且Verilog的AST本身层级分明:Design → Module → Port/Net/Always/Instance。分析AST后,很多依赖关系可以直接从树结构推断出来,而不需要额外训练一个关系抽取模型。这比用通用文本规划器要稳得多。
2.3 图规划与语法树的结合方式
论文的方法简单来说是这样:先从设计需求中抽出一个“设计骨架图”,骨架图里的节点关联到AST子树;然后规划模块按图节点顺序执行,每次执行调一个后端编码器生成某个节点的Verilog片段;生成完立即包装成AST节点,并更新图状态;等所有节点都被标记为完成且验证通过,就拼接成完整设计。
我用一张简单的伪代码流程来还原这个思路:
输入: 自然语言设计规格 S 输出: 完整 Verilog 模块代码 V 1. T = parse_spec_to_tree(S) # 从规格生成高层 AST 骨架 2. G = build_dependency_graph(T) # 从 AST 结构构建决策依赖图 3. while 存在未完成节点: 4. node = select_next_node(G) # 选择依赖已全部满足的节点 5. code = call_coder(node.context) # 调用 LLM 生成该节点代码 6. ast_node = parse_and_check(code) # 解析成合法 AST,校验语法 7. update_graph(G, node, ast_node) # 更新图状态,解除后继依赖 8. run_smoke_test(ast_node) # 可选:单节点冒烟仿真 9. V = concatenate_nodes(G) # 按图顺序拼接最终代码这段伪代码里的“select_next_node”是关键。在实际实现中,可以用一个拓扑排序队列:每次选择入度为零的节点,生成完之后更新依赖关系。这种调度方式天然避免了重复生成和漏生成,比让模型自己去组织代码结构可靠得多。
3. 智能体的执行流程与关键设计
3.1 从自然语言需求到代码骨架
模型的输入端是自然语言规格,比如“实现一个支持读写同步复位和skid buffer的AXI Stream FIFO,数据位宽32位,深度16”。常规LLM会直接开始写模块,但VerilogCoder会先做一个“骨架提取”:把规格里的关键信息映射成若干个AST节点。
我猜这个骨架提取是通过prompt方式完成的,但在工程上也可以做成一个专门的NLP解析器。骨架节点至少包含:
- 顶层模块名;
- 端口列表;
- 内部主要寄存器/状态量;
- 主要子模块实例。
这一步相当于硬件工程里的“模块划分”。模块划分的质量直接影响后续生成的稳定性,我在实际测试中也感受过:如果让模型跳过骨架直接写完整代码,一旦功能复杂,它就会在端口名上自相矛盾。而有骨架之后,每个节点的上下文是收敛的,生成内容更可控。
3.2 图规划如何拆解子任务
一旦有了骨架,就可以建依赖图。举个例子,一个同步FIFO的设计可以拆成这些节点:
| 节点编号 | 功能描述 | 依赖节点 |
|---|---|---|
| n1 | 写指针管理模块 | 无 |
| n2 | 读指针管理模块 | 无 |
| n3 | 存储器阵列 | 无 |
| n4 | 空满状态逻辑 | n1, n2 |
| n5 | 读写数据通路 | n3, n4 |
| n6 | 顶层FIFO模块 | n4, n5 |
n1和n2互不依赖,可以并行生成;n4需要依赖n1和n2的接口定义;n6则等到所有子节点完成后再组装。图规划器保证每次只把“前置节点全部完成”的节点交给语言模型,这样模型永远面对的是一块已有明确接口局部输入,不需要自己脑补“前面的模块里面用了什么信号”。
这个设计非常实用。因为我之前用朴素方式让LLM生成多模块Verilog时,经常看到同一个信号在子模块定义为“wire”,在顶层却被当作“output”用,图规划这种“接口先行”的方式能根治这个问题。
3.3 迭代修复与验证回路
论文里还重点提到了验证回路。每个节点生成后并不是“写完了就完事”,而是先做语法检查,再尽可能做单点仿真。如果检查失败,就把错误信息连同当前AST节点状态一起反馈给模型,让它修复。
工程上这一步骤可以做得更细:
- 语法检查:用开源解析器(比如pyverilog / surelog)把生成节点解析成AST,检查端口匹配、赋值方向、位宽等。
- 静态检查:检查是否存在组合逻辑环路、是否在时序逻辑里用了阻塞赋值、有没有未声明的信号。
- 局部仿真:给节点配一个小型testbench,跑几个基本波形,验证状态转换是否符合预期。
验证回路的存在,让整个过程不再是“一次性生成-失败-重头再来”,而是“小步快速纠错”。实际用起来,成功率比直接端到端生成要高很多,尤其是带时序逻辑的代码,修复“漏掉一个复位条件”这种事情,局部的报错信息要比整段代码综合报错精确得多。
4. 从论文到实践:怎么搭一个最小可用原型
4.1 你可以怎么复现一个简化版本
论文的原版实现我不展开,但你可以用现成的大模型和开源工具复现一个简化版,不需要Titan级别的资源。我的搭建思路如下:
第一步,准备一个可调用的LLM。如果本地有显卡,可以用代码模型,比如DeepSeek-Coder或者别的开源模型,量化后跑起来。没有显卡就直接用API,注意上下文长度尽量选大一点的,当前代码生成模型建议至少8K上下文。
第二步,用pyverilog把种子模块解析成AST,生成初始骨架。pyverilog能读Verilog文件,也能生成AST,我们可以先用它把一块手工编写的模块模板解析出来,作为骨架的起点。如果连模板都不准备,也可以让LLM先生成一个粗略的模块骨架,再解析成AST。
第三步,写一个简单的依赖图封装类。可以用networkx管理节点和边。每个节点存三个字段:context(该节点对应的代码片段)、status(pending/done/failed)、deps(依赖节点列表)。
第四步,实现一个代码生成循环。每次从图里取一个入度为0的节点,把它的AST上下文(端口定义、依赖信号)序列化成prompt,让LLM补全内部实现。生成后用pyverilog解析,如果解析失败则回退重试。成功后把该节点的代码片段存回图,并解除后继节点的依赖。
第五步,节点全部完成后做一次全模块的编译和仿真验证。
这个简化版和论文原版差距肯定不小,但我试下来,对中小模块(FIFO、ALU、状态机)已经能达到“能跑”的水平,比直接让LLM写整段代码成功率高一截。
4.2 工程细节:上下文怎么写才不至于让模型瞎编
运行中最关键的其实是prompt构造。我走了不少弯路,总结下来,一个节点级生成prompt应该包含四部分:
- 全局指令:你是Verilog RTL编码助手,只能输出可综合代码。
- 模块接口:当前节点所属模块的端口列表以及位宽。
- 依赖信号说明:按依赖图列出该节点必须使用的输入信号及其来源节点。
- 节点目标:一句话描述这段代码要实现的逻辑功能。
其中“依赖信号说明”最重要。如果不告诉模型“n4需要用到n1输出的wptr和n2输出的rptr,且均为wire [4:0]”,它很可能自己创建一个新信号或者改变位宽。图规划器存的所有节点属性,此时就成了约束上下文。
还需要注意一点,生成的代码片段拼接时,端口名称和信号宽度必须全图一致。我的做法是,模块接口由骨架定义,任何节点生成内容里如果出现了骨架不存在的信号,就在后处理时做一轮“合法性过滤”:把未声明的信号自动补充成wire并在顶层声明,或者报错要求模型重写。后者更稳妥,因为自动补wire有可能掩盖真实的逻辑错误。
5. 几个容易踩的坑
5.1 模型生成的“准Verilog”比想象中多
LLM生成的代码,最常见的问题不是逻辑错,而是没法综合。我遇到最高频的问题包括:
- 在always @(posedge clk)里使用阻塞赋值。
- 在组合always块里习惯性写
@*但漏了always块内的default赋值,导致综合出锁存器。 - 位宽不匹配:比如把
[7:0]的信号直接赋给[15:0]端口。 - 模块实例化时,写端口名却忘了写对应连接信号。
这些问题靠语法解析器只能拦掉一部分,真正有效的是在节点prompt里给一条“风格约束”清单,明确本节点必须使用非阻塞赋值、所有if分支必须完整、端口位宽不得自行修改。实测加了这个步骤后,综合告警数减少了一半以上。
5.2 图规划不是万能药,拆得太细反而更差
我第一次复现时,把图拆得很细:每个always块算一个节点,每个assign也单独建节点。结果节点变多之后,大模型反而失去了模块级别的上下文,生成出来的子块接口语义对不上,修bug修到崩溃。
后来调整成“一个节点对应一个功能块”,比如整个写指针模块是一个节点,整个空满判断是一个节点,粒度对齐到模块级。这个粒度既能给模型足够的上下文,又不会让依赖图过于复杂。论文里的抽象语法表示应该也包含类似的分层粒度,不是简单的“越细越好”。
5.3 验证回路必须“轻量但不变态”
论文特别强调验证回路,但我建议大家从最小化的冒烟仿真开始。如果每个节点都跑完整UVM或全量仿真,时间成本太高,图规划带来的并发优势会被抵消。我采用的策略是,每个节点生成后只做语法解析+一条极简testbench(驱动时钟和复位,检查几个关键信号是否出现预期跳变),等到最后全模块拼接完成,再跑完整回归。
这样既能及早发现问题,又不至于让每一步都陷入漫长的仿真。真正能拿到收益的时序检查、覆盖率分析,留在全模块阶段做。
6. 一点延伸思考与经验
我按照这个思路落地了一个简化版工具,专门用来生成一些小模块。测试集只有十几个模块:异步复位同步释放的寄存器组、固定深度FIFO、多种状态编码的状态机,以及一些流水段寄存器。整体生成的首次通过率在40%左右,配合图规划和节点级修复之后,通过率能到70%以上。这个数字虽然比不上成熟工具,但考虑到我只是用了一套很粗糙的prompt和开源解析器,已经能说明“图规划+AST约束”确实比盲目端到端生成有效。
让人印象最深的不是“它能写出代码”,而是它写出代码后出错的位置更集中了。以前直接让LLM写整块代码,错了根本不知道从哪里改,现在错的位置基本上都在某个节点的内部逻辑,替换掉那一个节点就行。这个体验上的提升,比任何指标都更适合工程项目。
如果你也在做类似的尝试,我的建议是:把精力花在依赖图节点的划分和验证信息的反馈上,模型本身够用就行。毕竟Verilog的代码量远小于软件工程项目,模型能力的差距完全可以靠结构化流程来弥补。
再分享一个小技巧:生成节点代码时,可以把当前节点的AST子树序列化成类似“端口图”的文本格式,而不是贴一大段模块代码。让模型看到明确的端口连接关系,比如“din = wire[31:0], from_mem; wen = wire, from_ctrl;”, 这样它会更愿意遵守既有接口,而不是自作主张改名。这个细节我试了几次,对接口一致性帮助很明显。
这篇论文最值得学习的地方,就是把“工程化约束”嵌入到LLM的生成流程中,而不是期待模型自己学会硬件设计的纪律。这个思路不止适用于Verilog,放在任何一种需要高一致性的代码生成场景里,都有值得借鉴的地方。