前段时间我把几个常用的LLM都拉去做Verilog代码生成测试一眼看过去结果都像模像样但扔进仿真器之后能一次通过的屈指可数。要么是位宽对不上要么是复位逻辑漏了要么是组合逻辑里出现了不可综合的写法。这种“看起来能跑”和“真能跑”之间的落差光靠人眼很难定量。后来我花了几晚上把VerilogEval基准测试跑通了用一套标准化的数据集和仿真流程去衡量不同模型的真实生成能力再针对失败样本做迭代优化。这篇文章就从基准测试的拆解开始讲到怎么用评估结果反向改进LLM生成策略算是给正在用LLM辅助硬件开发、想建立一套可量化验证流程的工程师的一份实战记录。1. 为什么我需要VerilogEval先量化“看起来会写Verilog”的LLM1.1 我踩过的LLM生成Verilog的常见坑先说几个真实遇到的例子。第一次让大模型写一个同步FIFO生成结果用了initial块给寄存器赋初值。仿真能过但FPGA综合直接报不支持。第二次让它写一个8位计数器代码本身没语法错误但计数使能信号和异步复位之间的优先级写反了导致仿真波形里复位释放的第一个周期就少计一次。第三次让它写一个I2C接口的状态机它把posedge和negedge混在同一个always块里仿真器虽然没报错但行为完全不符合I2C时序。这些问题的共性是模型写的代码确实在“结构”上很像Verilog但在可综合性、时序语义、边界条件这些细节上很容易翻车。单靠人眼审查往往会因为代码表面整洁而忽略隐患。一开始我也想让模型自己生成对应的testbench然后通过仿真来验证但模型生成的testbench质量同样不稳定经常出现自问自答式的错误比如激励条件覆盖不到真正的bug。更麻烦的是任务多了以后人根本记不住哪个模型在哪些类型的任务上容易出问题。有的模型组合逻辑写得很好一到有限状态机就崩有的模型对长的英文描述理解能力差但对结构化的接口定义响应很准。这些规律不做批量测试根本发现不了。1.2 基准测试的核心价值不是跑分而是找失败模式我后来意识到我需要一个标准的任务集和一套固定的仿真检查逻辑。VerilogEval正好就是这样一种基准测试。它提供了一批已整理好的Verilog题目每道题包含自然语言描述、模块接口和一个用于验证功能的testbench。评测的时候让LLM只生成module本身的实现然后把它和给定的testbench拼在一起跑仿真最终统计生成代码的正确率。这种做法的核心价值不在于跑出一个分数然后发朋友圈炫耀而在于把“错误类型”暴露出来。比如我跑完第一批测试后把失败样本按类别归了一下发现大概有一半是语法或可综合性问题另外一半是功能逻辑问题而功能逻辑问题里又有时序状态错误、位宽截断错误、信号命名不一致等子类。有了这些分类优化工作才有方向。如果还是靠人工一个个试估计几天下来也只是得到一句“这个模型不太行”的模糊结论根本谈不上系统化优化。2. VerilogEval到底测了什么任务构成与指标解读2.1 Machine和Human两个子集的设计逻辑VerilogEval公开的数据集里常见的是两个子集VerilogEval-Machine和VerilogEval-Human。我记得机器生成子集的题目描述来自结构化模板会明确给出信号名、波形要求、模块端口甚至把真值表或状态转移条件都列清楚。人写子集则更接近工程师平时在记事本里写的需求描述句子更短、信息更跳跃比如“做一个4位右移寄存器带同步低有效复位”这样。这两个子集分开评测是有道理的。Machine子集考察的是模型对严格规格的解析和执行能力适合用来排查“模型能不能把接口要求转成代码”Human子集考察的是模型对模糊自然语言的理解和补全能力更贴近真实工作场景。我实测下来同一个模型在Human子集上的分数通常会低一些因为缺少结构化信息后模型需要自己推断默认行为默认值一旦猜错功能就不对了。任务类型上VerilogEval覆盖了组合逻辑、时序逻辑、有限状态机、计数器、移位寄存器、算术运算等常见基础模块。这些模块虽然简单但恰恰是复杂芯片设计的最小单元。基础模块生成不靠谱直接让LLM写大模块只会错得更离谱。2.2 passk指标怎么算以及为什么pass1不能完全信在VerilogEval的评测报告里最常用的指标是passk。简单说就是为每个题目生成k个候选答案只要这k个候选里有一个能通过仿真验证就认为这个题目被“解决”了。最后对所有题目统计平均通过率。这里有一个需要注意的细节同样数量的候选如果样本采样的随机性不同passk的数值会相差很大所以真正严谨的做法是生成足够多的样本然后用无偏估计公式来计算而不是简单地数“每k个里有几个通过”。早期我看很多项目只报告pass1也就是只生成一次答案然后算通过率。这个指标确实直观但它会低估模型的真实潜力。LLM解码时本身带有随机性一个温度参数调高一点答案质量就抖动。有些模型pass1只有30%但pass10能到80%这说明它本身具备生成正确代码的能力只是需要多次采样再筛选。反之有些模型pass1很高但pass10提升有限说明它的多样性不足换成不同的描述可能就抓瞎了。在硬件场景里passk其实很有实际意义。工程师完全可以让LLM一次生成10个候选然后跑一遍自动仿真把第一个通过的挑出来。这个过程比人眼检查快得多也可靠得多。所以后来我做评估时不光看pass1还会对比pass5和pass10用来判断模型的“上限”。3. 从零跑通VerilogEval环境、代码与实测记录3.1 安装和准备数据集跑VerilogEval的环境不复杂。我使用的是Linux环境核心依赖是Python 3.9以上、Icarus Verilog仿真器、以及一个能调用LLM的Python包。Icarus Verilog在Ubuntu下可以直接用apt install iverilog装上安装完以后确认iverilog和vvp两个命令都在PATH里否则评测脚本会一直在编译阶段报错。数据集方面VerilogEval的官方仓库里已经包含了整理好的题目和testbench。直接把仓库clone下来然后按照README把数据集路径配置好就行。需要注意一点官方评测脚本默认可能只统计结果不会把每个失败样本的仿真日志保存下来这在做失败模式分析时很痛苦。我在仓库代码基础上加了一个选项把编译错误和仿真输出都按题目编号存成独立文件后面看失败原因就方便多了。3.2 评测脚本的关键逻辑整个评测流程的核心是调用LLM生成Verilog模块 - 把生成的代码和对应testbench拼接 - 用iverilog编译 - 用vvp跑仿真 - 比对输出。下面是我在实际使用中精简过的脚本框架逻辑和官方脚本一致但更便于定制import os import subprocess import tempfile from openai import OpenAI client OpenAI(api_keyos.environ.get(LLM_API_KEY), base_urlos.environ.get(LLM_BASE_URL)) def generate_verilog(prompt: str, model: str, temperature: float 0.2) - str: resp client.chat.completions.create( modelmodel, messages[ {role: system, content: You are a Verilog expert. Output only the module code.}, {role: user, content: prompt}, ], temperaturetemperature, max_tokens512, ) return resp.choices[0].message.content def evaluate_one(problem: dict, model: str, timeout: int 30) - bool: code generate_verilog(problem[prompt], model) with tempfile.TemporaryDirectory() as tmpdir: design_file os.path.join(tmpdir, design.v) tb_file os.path.join(tmpdir, tb.v) with open(design_file, w) as f: f.write(code) with open(tb_file, w) as f: f.write(problem[testbench]) # 编译 compile_cmd [iverilog, -o, os.path.join(tmpdir, sim.vvp), design_file, tb_file] compile_res subprocess.run(compile_cmd, capture_outputTrue, textTrue, timeouttimeout) if compile_res.returncode ! 0: print(compile error:, compile_res.stderr) return False # 仿真 run_cmd [vvp, os.path.join(tmpdir, sim.vvp)] run_res subprocess.run(run_cmd, capture_outputTrue, textTrue, timeouttimeout) if run_res.returncode ! 0: print(simulation error:, run_res.stderr) return False # 匹配输出 return problem[expected_output] in run_res.stdout真实评测脚本需要考虑更多细节比如模型生成代码里可能会包含markdown代码块需要先清理比如iverilog在遇到部分语法错误时只会警告不会失败需要在仿真输出里加额外的断言检查比如单次仿真超时有可能是代码里写了死循环也有可能是模型把initial和always写成了一个无法结束的状态。这些都要在脚本里做防御式处理。3.3 我在本地跑出来的基线数据我用上述流程测过几个模型包括一个7B的开源模型、一个通用商业模型以及一个专门在代码数据上训练过的模型。评测集选的是VerilogEval-Machine的前50道题和Human子集的前30道题温度设为0.2每个题目采样10个候选。我把结果整理成了下面这样的基线表模型Machine pass1Machine pass10Human pass1Human pass107B开源模型A0.220.500.160.38通用商业模型B0.380.680.300.60代码专用模型C0.450.760.350.62这个表格给我最大的启发不是排名而是“采样次数”带来的差距。7B开源模型A的pass1只有0.22但pass10到了0.50说明它生成正确代码的能力并没有想象中差只是单次采样不稳定。商业模型B和代码专用模型C在pass10上差距不大但pass1差距明显说明prompt稍微不稳商业模型B就容易跑偏。再看具体题型时几乎所有模型都在有限状态机这类任务上表现最差组合逻辑任务相对好很多。另外Human子集的分数普遍低于Machine子集和前面提到的一致模型对模糊描述的理解还存在明显短板。4. 用评测结果反向优化LLM生成Prompt、RAG与微调4.1 针对失败模式设计Prompt模板有了失败分类第一步就是改Prompt。我一开始用的prompt非常简单“Write Verilog code for a module that implements...”结果模型经常发挥不稳定。后来我把失败样本归纳成几个规律重新设计了一套结构化prompt模板。核心思路是明确模块名、明确端口方向、明确位宽、明确时钟和复位最后强制要求只输出代码。以生成一个同步复位计数器为例我现在的prompt会这样写请生成一个Verilog模块。 模块名: counter 端口定义: input wire clk, input wire rst_n, input wire en, output reg [7:0] count 功能要求: - 同步复位复位信号rst_n低有效复位时count输出为0 - en为高时每个clk上升沿count加1 - 当count达到255后下一个有效使能周期回绕到0 额外要求: - 使用always (posedge clk)块 - 不要使用initial语句 - 只输出Verilog代码不要解释对比测试下来这种结构化prompt对VerilogEval-Machine子集的提升非常明显因为Machine题目本身的描述已经接近结构化模型不需要太多推断。但对Human子集就没有那么明显因为题目本身就是模糊的自然语言强行套模板反而会让模型忽视原描述中的额外要求。后来我把prompt改成“先把用户需求转成约束列表再写代码”的方式让模型内部先做一次任务分析Human子集上的表现才有所改善。这里有个很容易忽略的细节prompt中强调“只输出Verilog代码不要解释”并不总是好事。如果模型在输出代码前先用一两句话描述一下它在想什么有时候反而能减少端口遗漏。我给评测脚本做了个后处理允许模型输出解释但用正则提取代码块部分。结果发现允许“思维痕迹”之后Human子集的pass1提升了5%左右但Machine子集几乎没变化。4.2 用RAG把“参考代码”喂给模型Prompt改造还能再进一步。对很多Verilog任务来说与其让模型从零开始硬写不如给它一个相似模块的参考代码。这其实就是RAG的做法。我先在自己积累的代码库里建立索引包括计数器、状态机、FIFO、串口收发、滑动窗口滤波等常见模块然后根据用户问题的关键词检索最相近的1到2个代码片段塞进prompt里作为few-shot示例。实际操作中我不建议直接使用embedding向量检索因为Verilog模块之间的相似性很难用文本向量准确表达。比如“滑动窗口滤波”和“移位寄存器”从文本上看起来不相关但滑动窗口本质是移位和累加的组合。我一开始用简单关键词匹配后面改成了按模块端口数量和功能特征做规则匹配效果反而更好。RAG的收益在“一个任务有一百种写法”的题目上最大。比如反正切近似模型没有见过CORDIC实现时很容易生成一个real类型的浮点函数这在硬件里很难综合。但如果我在prompt里放一个定点查找表的参考实现模型就会尝试模仿定点格式和查找表结构生成结果的可综合性立刻提高。我在Human子集上测试加了参考代码后pass10提升了将近10个百分点。需要注意参考代码不要直接在prompt里贴一大段否则模型可能直接把参考代码原封不动复制出来和题目要求的端口对不上。我通常只保留参考代码的核心骨架删掉厂商注释和无关细节然后明确告诉模型“这是风格参考不是答案请根据当前端口定义重新实现”。4.3 微调要小心但数据可以这样组织除了Prompt和RAG另一个方向是微调开源模型。我在一个7B模型上用VerilogEval的训练集做过一次轻量级LoRA微调。先说结论确实有效果但没有想象的那么大而且使用门槛很高。微调数据组织上最重要的是对齐生成格式。我刚开始用的数据只包含“任务描述”和“完整代码”模型学完之后生成的代码经常缺少模块声明后来我在训练数据里强制加入端口定义部分问题才缓解。训练样本应该长这样用户请生成一个4位右移寄存器模块带同步低有效复位串行输入sin串行输出sout。 助手 module shift_reg( input clk, input rst_n, input sin, output sout ); reg [3:0] shift_reg; always (posedge clk) begin if (!rst_n) shift_reg 4b0; else shift_reg {shift_reg[2:0], sin}; end assign sout shift_reg[3]; endmodule微调最大的坑是数据泄露。如果训练集和测试集有重叠报告出来的分数会虚高。VerilogEval虽然给了训练验证划分但我在做项目时还会额外把自建的任务从评测集中拿出去单独留一个“私有验证集”。这样模型如果只是记住了公开题目私有验证集分数不会涨。总的来说我的个人顺序是先做Prompt结构化再做RAG参考最后才是微调。微调的成本高而且一旦模型能力边界变化重新测评的周期很长不适合快速迭代。5. 实战复盘计数器、滑动窗口滤波与反正切近似5.1 从无脑描述到模块化生成的计数器计数器是Verilog里最基础的模块也是评测集里最容易被人忽视的一类题。你以为所有模型都会写但其实很多模型会在位宽和计数边界上出错。有一次我让模型生成一个模60的BCD码计数器它直接用了count (count 1) % 60这样的综合风格代码。这行代码单独看没错但综合后可能会产生一个除法器面积和时序都会超标。正确的做法应该是module bcd_counter_60 ( input wire clk, input wire rst_n, input wire en, output reg [7:0] count ); always (posedge clk) begin if (!rst_n) count 8b0; else if (en) begin if (count[3:0] 4d9) begin count[3:0] 4d0; if (count[7:4] 4d5) count[7:4] 4d0; else count[7:4] count[7:4] 1b1; end else count[3:0] count[3:0] 1b1; end end endmodule优化前模型喜欢用integer类型的中间变量去计算然后在assign语句里做填充。这虽然不是错误但会引入不必要的逻辑级数。优化后我在prompt里明确加上“不要使用integer变量不要使用非阻塞赋值以外的赋值方式”计数器的生成质量立刻稳定。对这类基础模块显式指定硬件风格比单纯把需求说清楚更有效。5.2 滑动窗口滤波状态是最大拦路虎滑动窗口滤波在信号处理项目里很常见热搜词里也经常出现。它的本质是维护一个长度为N的窗口每个时钟周期计算窗口内数据的和或平均值。用Verilog实现时最常见的做法是移位寄存器加累加器但这里藏着一个大坑窗口有效信号的对齐。模型在生成这类代码时经常会把“窗口满”的信号和“数据有效”的信号混在一起。比如它可能在第一个数据还没进入窗口时就输出了一次结果导致输出早了一个周期。我在评测时把这类错误归类为“valid时序错位”。后来我在prompt里加了一个关键要求“data_in在valid拉高时采样当窗口内有效采样数量达到N时result_valid输出一个周期的高电平。”这让模型有明确的握手意识。类似下面的代码结构是模型比较容易正确生成的module sliding_window_avg #( parameter N 8, parameter DW 16 )( input wire clk, input wire rst_n, input wire valid_in, input wire [DW-1:0] data_in, output reg valid_out, output reg [DW-1:0] data_out ); reg [DW-1:0] window [0:N-1]; reg [$clog2(N)-1:0] count; reg [DW$clog2(N)-1:0] sum; always (posedge clk) begin if (!rst_n) begin count 0; valid_out 1b0; end else begin valid_out 1b0; if (valid_in) begin window[count] data_in; if (count N-1) count 0; else count count 1b1; end end end endmodule生成优化前后的关键区别是模型是否单独维护了一个“有效计数”变量。初始版本经常把有效计数和窗口索引混成一个变量导致窗口覆盖时输出错误。加入握手要求后模型更容易生成一个独立计数器逻辑清晰很多。5.3 arctan近似把数学问题翻译成硬件行为搜索热词里出现“verilog arctan”说明很多人在做角度解算相关项目。LLM在生成这类数学运算模块时最常见的毛病是输出浮点实现。比如直接写atan $atan(real_value)这在仿真里能用但综合工具往往不支持或者说即使支持也会产生巨大的资源消耗。我的优化方式是把数学问题框架化。我会在prompt里告诉模型“输入是16位有符号定点数不要求高精度使用查找表或CORDIC算法输出8位角度值。”然后给一个简单的查找表示例。模型看到示例后会倾向于生成一个基于casez的查找表虽然精度不高但完全可综合。这类题给我的启发是LLM对“算法”的理解很强但对“硬件可综合”的约束几乎无感。所以在prompt里必须把实现目标和约束同时给出来否则它会把软件思维带入硬件代码。特别是“无除法”“无浮点”“无动态数组”这类限制建议在系统prompt里常驻。6. 我现在的使用方式与边界感6.1 自动评测回归比任何花哨技巧都重要经过这一轮实战我最大的体会是优化LLM生成能力是一个持续迭代的过程而迭代离不开自动回归测试。每改一次prompt、每调整一次RAG参考库甚至换一个新版模型我都会重新跑一遍固定的评测集。这个评测集不一定是完整的VerilogEval我会从里面抽30道覆盖组合逻辑、时序逻辑、状态机的题目再加10道自己项目里的私有题目跑一次大概只要十几分钟。有一次我为了提升某个状态机任务的成功率在prompt里加了一句“请把状态转移条件都写在一个case语句中”。结果状态机任务确实提升了7%但很多组合逻辑任务因为模型把多段逻辑也强行塞进一个always块里编译错误率飙升。如果没有回归测试这种“拆东墙补西墙”的问题根本发现不了。所以如果你也想把LLM真正用到Verilog开发里我建议先把基准测试跑起来把它当成单元测试套件一样维护。不要等到模型真的生成了垃圾代码才发现问题。6.2 LLM在Verilog领域能替我做多少事最后说一点边界认知。LLM确实能帮我写模块模板、修语法错误、生成testbench、快速验证某些算法思路但它在Verilog领域的能力上限还是很明显的。比如遇到复杂的状态机设计模型生成的代码往往缺少异常状态处理遇到跨时钟域逻辑模型常常意识不到需要两级同步或异步FIFO遇到功耗优化、时序收敛这类非功能性需求模型更是完全靠猜。我的实际做法是把LLM当成一个能快速给出初稿的同事所有生成代码都要过一遍仿真验证关键模块还要做代码评审。VerilogEval这类基准测试能帮我把“哪个同事更靠谱”量化但最终还是需要由人来拍板。另外在评估任何新模型时不要只盯着总分看。分组统计失败原因然后针对自己业务涉及最多的模块类型做专项测试这才是基准测试的正确打开方式。比如我平时最常写滤波器和接口时序那我就会专门把滑动窗口、I2C、SPI这几类任务抽出来做局部对比而不是追求一个好看的平均分。写Verilog这件事容错率很低一次仿真失败就可能烧掉一块板子。所以无论LLM生成的代码看起来多漂亮都要保持“先验证后使用”的习惯。把VerilogEval这种基准测试嵌入到日常开发流程里就是我现在最放心的护栏。