摘要

这次不聊抽象的 AI 芯片设计。笔者给 Codex 接入 Erie Verilog Generator v0.4.0,让它生成一个带一级缓存的 Ready/Valid 流水寄存器和自检查 Testbench,然后在 macOS 上用 Icarus Verilog 做真实仿真,再用 Yosys 检查综合结构。本文把需求、Prompt、代码、命令、结果和使用边界完整放出来,照着就能复现。

先说一个很常见的场景

写一个 Ready/Valid slice,看上去不算难。

接口只有几根线,核心逻辑也就两个寄存器。熟悉握手协议的工程师,坐下来十几分钟大概就能写完。把同样的需求交给大模型,它可能十几秒就给出一份代码,而且注释还挺多。

麻烦通常从下一步开始。

下游拉低 ready 时,data 有没有保持?缓存已满的那个周期,如果下游正好接走旧数据,上游能不能同时送进新数据?Testbench 最后打印了 PASS,究竟是因为做过逐笔比较,还是因为时间到了就打印?再往后,所谓的"检查通过",指的是文本扫描、Verilog 编译、事件仿真,还是综合?

这些问题都不复杂,但很琐碎。工程里真正花时间的地方,经常就是这种琐碎。

笔者以前也试过直接写一个很长的 Prompt,把接口、复位、编码风格、测试场景全塞进去。结果通常有两种。要么模型顾着功能,忘了项目里的命名和交付格式;要么代码看起来很规整,Testbench 却只覆盖了最顺利的路径。继续追问当然可以修,可几轮对话之后,最初的要求和后来的补丁混在一起,很难说现在这份代码到底遵循了哪套规则。

所以这次笔者换了一个做法:不只给 Codex 一段 Prompt,而是给它装一个专门处理 Verilog 的 Skill。

Skill 到底多做了什么

Codex Skill 可以简单理解为一套放在本地的工作说明。里面不只有提示词,还可以包含规则文件、脚本、模板、检查清单和固定的执行顺序。

这和"换一个更懂 Verilog 的模型"不是一回事。模型还是同一个模型。变化在于,它接到任务以后,不再直接从自然语言跳到 RTL,而是先读规则,再按约定生成中间产物,最后调用真实工具检查。

本次使用的 Erie Verilog Generator v0.4.0,标准生成流程是:

requirements -> codegen_plan -> python -> rtl

requirements 负责把自然语言整理成结构化需求;codegen_plan 先决定模块接口、状态和时序;python 阶段生成一个语义模型,给后面的 RTL 和测试向量提供参照;最后才输出 Verilog。

RTL 生成以后,流程还没有结束。Skill 会继续运行独立静态 Lint、formatter AST 检查、注释与命名检查,以及最终的 Verilog deliverable gate。需要仿真或综合时,再接到 Icarus、Vivado、VCS 或 Yosys 这类外部工具。

普通 Prompt 和 Skill 的区别,可以放在一张表里看:

普通 Prompt

Skill 工作流

需求直接进入代码生成

先整理 requirements,再写 codegen plan

编码习惯主要靠 Prompt 约束

读取版本固定的规则和检查清单

Testbench 可能只有激励和波形

要求比较、错误计数、watchdog 和明确退出状态

模型说"看起来正确"

脚本和外部 EDA 工具给出返回码

修改后不容易追踪依据

保存规格、计划、生成产物和验证报告

一次回复就是主要交付物

RTL、TB、JSON 报告和终端证据共同组成交付

笔者比较在意最后一行。RTL 开发不缺一段"看起来能用"的代码,缺的是代码旁边那套能复查的依据。

安装 Erie Verilog Generator

项目地址是:

https://github.com/Eriemon/verilog-generator

本文固定使用 v0.4.0,对应 commit:

25bca69b33bb2d1252262a3b103646bef0116ae0

固定 tag 很有必要。Skill 里有规则和验证脚本,默认分支以后发生变化,同一条 Prompt 得到的流程也可能不同。写教程时如果只说"使用最新版",过几个月往往就不好复现了。

在 macOS 或 Linux 上,可以把仓库放进 Codex 的 Skill 搜索目录:

git clone --depth 1 --branch v0.4.0 \

  https://github.com/Eriemon/verilog-generator.git \

  ~/.codex/skills/erie-verilog-generator

进入目录检查版本:

cd ~/.codex/skills/erie-verilog-generator

cat VERSION

git rev-parse HEAD

输出应分别对应 v0.4.0 和上面的 commit。重新打开一个 Codex 会话后,可以直接在任务里点名使用 Erie Verilog Generator。点名很重要,它能把"参考这个仓库的写法"和"实际执行这个 Skill"区分开。

这次还要做真实仿真和综合。macOS 用 Homebrew 安装 Icarus Verilog 与 Yosys:

brew install icarus-verilog yosys

检查工具:

iverilog -V

vvp -V

yosys -V

本次机器是 Apple Silicon,系统为 macOS 27.0,Python 3.14.4,Codex CLI 0.145.0。实际调用的模型是 gpt-5.6-sol,Erie Skill 使用 v0.4.0。最终安装的 Icarus Verilog 是 13.0,Yosys 是 0.67。

如果你的机器没有这两个工具,也可以先跑 Skill 自带的静态检查。但文章里要把话说准:静态 Lint 通过,不等于 Testbench 仿真通过;没有运行 Yosys,也不能写成综合通过。

怎么确认 Codex 真的调用了 Skill

这是第一次使用时最容易含糊的地方。

在 Prompt 里贴一个 GitHub 地址,让 Codex "按照这个项目的风格写",不等于调用 Skill。它可能只是读了 README,也可能只从仓库里挑了几条规则。真正调用 Erie Verilog Generator 时,Codex 应该先读取 SKILL.md,选择 regulardeep_review 或 agentic_repair 其中一条工作流,再执行对应脚本。

笔者的检查方法很直接。开始前要求它报告 Skill 名称、版本和实际根目录;生成时要求保存 requirementscodegen_plan 和 Python semantic model;结束时必须出现 deliverable gate 的 JSON。三项都能对上,才算进入了这套工作流。

还可以看输出目录。如果最后只有一个 .v 文件和一段聊天解释,多半没有走完整流程。正常的 regular 生成至少会留下规格、计划、模型、RTL、验证报告和 trace。文件多不代表代码一定对,但没有这些文件,就很难证明规则和门禁执行过。

笔者也会要求它把所有外部命令原样记录下来,包括工作目录和返回码。iverilog 的标准输出为空很正常,关键是编译命令确实执行过,而且返回 0。相比一句"仿真完成",这类记录不漂亮,却更有用。

这次要生成什么

例子选了一个单槽 Ready/Valid 流水寄存器,模块名为 ready_valid_slice

笔者选它不是因为代码短,而是因为它刚好能测出生成工具有没有认真处理握手边界。纯组合逻辑太容易,复杂总线又会把文章拖进协议细节。一级缓存处在中间位置,接口少,但对反压、数据保持和同周期收发都有明确要求。

完整 Prompt 如下。实际使用时可以原样交给 Codex:

请调用已经安装的 Erie Verilog Generator Skill v0.4.0,

使用 regular 工作流生成模块 ready_valid_slice。

 

设计要求:

1. 使用可综合 Verilog-2001。

2. 数据宽度由参数 DATA_WIDTH 配置,默认 32 位。

3. 使用单时钟 i_clk。

4. 使用低有效异步复位 i_rstn。

5. 上游接口为 i_data、i_valid、o_ready。

6. 下游接口为 o_data、o_valid、i_ready。

7. 只有 valid 和 ready 同时为 1 时才完成传输。

8. 模块内部使用一级缓存。

9. 当 o_valid=1 且 i_ready=0 时,o_data 和 o_valid 必须保持稳定。

10. 反压解除后,必须先正确输出缓存数据。

11. 支持连续传输,允许同一周期输出旧数据并接收新数据。

12. 不能丢失、重复或打乱数据。

 

验证要求:

1. 生成自检查 Verilog-2001 Testbench。

2. 覆盖复位、单次传输、连续无反压传输。

3. 覆盖下游持续反压和反压解除。

4. 覆盖同周期输入握手与输出握手。

5. 使用固定种子的随机 valid/ready。

6. 使用 FIFO scoreboard 检查数据不丢失、不重复、不乱序。

7. 检查反压期间 o_data 和 o_valid 稳定。

8. 加入 watchdog,失败时给出明确错误信息和非零错误计数。

 

执行要求:

1. 保存结构化 requirements、codegen plan 和 Python semantic model。

2. 生成 ready_valid_slice.v 和 ready_valid_slice_tb.v。

3. 运行 Skill 的独立静态 Lint。

4. 运行严格 Verilog deliverable gate。

5. 使用 Icarus Verilog 实际编译并运行 Testbench。

6. 使用 Yosys 执行基础综合检查。

7. 保存所有实际命令、返回码和关键输出。

8. 不得使用模型自笔者评价代替工具结果。

这里有个小习惯值得保留:把"生成什么"和"怎么验证"分开写。大模型很容易把测试要求当成背景说明。明确写出要生成 Testbench、要运行什么命令、要保存什么证据,执行路径会清楚很多。

写硬件 Prompt 时,笔者还会把接口合同放在最前面。模块名、参数名、端口方向、时钟和复位一旦进入别人的例化脚本,后面再改会牵动不少文件。与其让模型先自由发挥,再要求它把 clk 改成 i_clk,不如一开始逐个列清楚。

行为要求尽量写成时钟边界上的可观察条件。比如"支持反压"有点宽,"当 o_valid=1 且 i_ready=0 时,o_data 和 o_valid 保持稳定"就能直接变成检查逻辑。"高吞吐"也不好测,换成"允许同一周期输出旧数据并接收新数据,连续流量不能插入气泡",实现和 Testbench 都知道该看什么。

最后再写禁止项。这里明确要求 Verilog-2001、可综合 RTL,并禁止用模型自评代替工具结果。若工程里不接受 initial、延时或某些综合器扩展,也应该在这一段说。限制写得越具体,后面靠人工猜测的地方越少。

Skill 产出了哪些文件

这次最终交付目录很朴素:

outputs/

├── wechat_verilog_skill_tutorial.md

├── experiment_summary.md

├── example/

│   ├── ready_valid_slice.v

│   ├── ready_valid_slice_tb.v

│   └── README.md

└── evidence/

    ├── environment.md

    ├── commands.md

    ├── validation_summary.md

    ├── deliverable_gate.json

    └── simulation_output.txt

工作流目录里还保存了结构化需求、生成计划、Python 模型、接口审计和 validation report。平时做一个小模块,未必需要把所有 JSON 都交给下游工程师,但在评估生成工具时,这些文件很有用。你能看到需求是怎样被解释的,也能定位某条检查来自哪条规则。

笔者建议至少保留五样东西:最终 RTL、最终 Testbench、可复制的运行命令、完整仿真输出、机器可读的门禁结果。只有 RTL 和一张波形截图,过一段时间通常很难复现。

这些文件不必从头挨个读。笔者通常先开 ready_valid_slice.v 看接口和时序逻辑,再看 Testbench 是否真的做了比较。接下来直接运行 commands.md 里的编译与仿真命令。代码和结果都对得上以后,再去翻 validation_summary.md 和 deliverable_gate.json

JSON 报告更适合追问题,不适合拿来理解设计。比如门禁报 VG012,可以在 JSON 中看到规则编号、严重级别和目标文件;但要判断该不该改参数名,仍然得回到原始接口要求。脚本能指出冲突,不能替项目做取舍。

environment.md 也别省。Python、Icarus、Yosys 和 Skill 版本不同,解析规则和 warning 都可能变化。教程要能复现,环境信息至少得写到版本号,而不是一句"在 macOS 上测试"。

RTL 里最值得看的几行

最终模块有一个数据寄存器 reg_pipe_data,再用 flag_pipe_valid 表示这个槽位是否装着一笔尚未被下游接收的数据。

组合握手逻辑如下:

assign flag_pipe_load = (~flag_pipe_valid) | i_ready;

assign flag_input_accept = i_valid & flag_pipe_load;

assign o_ready = flag_pipe_load;

assign o_data = reg_pipe_data;

assign o_valid = flag_pipe_valid;

flag_pipe_load 是这里的核心。缓存为空时,flag_pipe_valid 为 0,模块当然可以接收新数据。缓存不为空时,只有下游 i_ready 为 1,旧数据会在当前时钟沿被接走,槽位才允许更新。

这几种情况可以逐项看:

当前缓存状态

i_ready

o_ready

时钟沿上的动作

0 或 1

1

i_valid=1 时接收新数据

0

0

保持原数据和 o_valid

1

1

输出旧数据,可同时接收新数据

第三行最容易写错。若把 o_ready 简单写成 ~flag_pipe_valid,缓存满时就永远不能在同一个周期接收下一笔数据,中间会多一个气泡。现在的写法把"旧数据正被消费"也算作可装载条件,所以连续流量下可以做到每周期一个事务。

可以拿两笔数据过一遍。

假设缓存里已经有数据 A,o_valid=1,下游暂时把 i_ready 拉低。此时 o_ready=0,上游即便给出数据 B,本模块也不会接收。数据寄存器继续保存 A,输出端保持 A 和有效信号。

下一周期,下游把 i_ready 拉高,上游仍然保持 B 和 i_valid=1在上升沿到来之前,输出握手交付的是 A;输入握手也在同一周期成立。到了上升沿,寄存器写入 B,有效位继续为 1。再下一个周期,下游看到的就是 B。

这里要分清"沿前可观察的数据"和"沿后寄存器的新值"。如果只盯着赋值语句,很容易误以为 A 会被 B 覆盖。Ready/Valid 的传输条件是在采样沿同时满足 valid 和 ready,A 已经在那个沿完成交付,B 则成为下一笔缓存数据。Testbench 的采样方式也围绕这个顺序来写。

有效位和数据分别放在两个时序块中,每个 always 只维护一个主要目标。这符合 Skill 的 strict 风格,也方便看波形:

always@(posedge i_clk or negedge i_rstn)begin

    if(i_rstn == 1'b0)begin

        flag_pipe_valid <= 1'b0;

    end else if(flag_pipe_load == 1'b1)begin

        flag_pipe_valid <= i_valid;

    end else begin

        flag_pipe_valid <= flag_pipe_valid;

    end

end

 

always@(posedge i_clk or negedge i_rstn)begin

    if(i_rstn == 1'b0)begin

        reg_pipe_data <= {DATA_WIDTH{1'b0}};

    end else if(flag_input_accept == 1'b1)begin

        reg_pipe_data <= i_data;

    end else begin

        reg_pipe_data <= reg_pipe_data;

    end

end

当下游反压时,flag_pipe_load 为 0。有效位不变,数据寄存器也不更新,因而 o_valid 和 o_data 都保持。反压解除后,i_ready 拉高,原来缓存的数据先参与输出握手;如果这时上游也有效,新数据会在同一个上升沿写进缓存,下一周期继续输出。

这段逻辑没有什么神秘的。好处在于需求、实现和 Testbench 用的是同一套握手定义,不需要靠读者猜模型当时怎么理解"同周期输入和输出"。

Testbench 不是最后打印一句 PASS

生成 Testbench 时,笔者最关心的是 scoreboard。

每当输入侧发生 i_valid && o_ready,Testbench 就把这一笔 i_data 写入 FIFO 数组。输出侧发生 o_valid && i_ready 时,它取出最早的一笔期望数据比较:

if(sampled_output_accept == 1'b1)begin

    if(scoreboard_read >= scoreboard_write)begin

        $display("FAIL: duplicate output data=%h cycle=%0d",

                 sampled_output_data, cycle_index);

        error_count = error_count + 1;

    end else if(sampled_output_data !== scoreboard[scoreboard_read])begin

        $display("FAIL: out-of-order output expected=%h actual=%h cycle=%0d",

                 scoreboard[scoreboard_read],

                 sampled_output_data,

                 cycle_index);

        error_count = error_count + 1;

    end

    scoreboard_read = scoreboard_read + 1;

end

这个结构同时检查三件事。输出多了一笔,会遇到 scoreboard 下溢;数据内容或顺序不对,会在逐笔比较时失败;输出少了一笔,测试结束排空以后,读写指针和事务计数对不上。

反压稳定性单独检查:

if(sampled_stall == 1'b1)begin

    stall_count = stall_count + 1;

    if((o_valid !== sampled_output_valid) ||

       (o_data !== sampled_output_data))begin

        $display("FAIL: output changed under backpressure cycle=%0d",

                 cycle_index);

        error_count = error_count + 1;

    end

end

测试开始时先拉低 i_rstn,在时钟边沿之外检查异步复位是否已经把输出状态清掉。随后依次发送单笔数据、连续数据和持续反压场景。第 40 个主循环周期以后,固定 LFSR 种子驱动 i_valid 与 i_ready,总主循环为 200 个周期。最后停止输入,把 i_ready 持续拉高,再给 20 个周期排空缓存。

Testbench 还有一个 watchdog。仿真如果因为等待条件或时钟问题卡住,不会一直挂着,它会打印超时并退出。

固定种子的随机测试不等于覆盖所有情况。不过它有一个实际好处:结果可重复。某次修改出错时,输入序列不会跟着变化,波形和日志更容易对照。

Testbench 的采样时刻也值得看一眼。激励在时钟下降沿更新,随后等待 1 ns,记录当前周期是否发生输入或输出握手。到下一个上升沿,DUT 执行非阻塞赋值;再等 1 ns,Testbench 检查寄存器更新后的稳定性。

这种写法故意绕开上升沿上的竞争。若 Testbench 和 DUT 都在 posedge 同时用阻塞赋值更新信号,仿真结果可能依赖进程调度顺序,波形看着一会儿对、一会儿错。把驱动放到下降沿,留半个周期给组合逻辑稳定,读起来也更接近实际接口时序。

scoreboard 在输出比较之后再写入本周期新接收的数据。这样同周期输出 A、输入 B 时,比较对象仍然是旧队首 A,不会提前拿 B 去比。对于只有一级缓存的设计,这个细节很小;换成多级流水或小 FIFO,采样和记分顺序如果不清楚,Testbench 本身就会制造假错误。

在 macOS 上编译和运行

进入交付目录后,先用 Icarus 按 Verilog-2001 编译:

iverilog -g2001 -Wall \

  -s ready_valid_slice_tb \

  -o ready_valid_slice_tb.out \

  ready_valid_slice.v \

  ready_valid_slice_tb.v

编译返回码为 0,没有 warning。接着运行:

vvp ready_valid_slice_tb.out

本次真实输出是:

PASS: ready_valid_slice self-check complete input=121 output=121 stalls=25 simultaneous=89

outputs/example/ready_valid_slice_tb.v:197: $finish called at 2235000 (1ps)

这几个数字不是为了凑一行漂亮的日志。input=121 和 output=121 说明输入与输出事务数相同;stalls=25 说明测试确实观察到了有效数据被反压的周期;simultaneous=89 说明同周期输入和输出不是纸面上的测试意图,仿真里真的发生过。

再用 Yosys 做一次基础综合检查:

yosys -p 'read_verilog ready_valid_slice.v; \

  hierarchy -check -top ready_valid_slice; \

  proc; opt; check; stat'

关键输出如下:

Top module: \ready_valid_slice

Found async reset \i_rstn

Found and reported 0 problems.

 

8 ports

5 cells

2 $adffe

Yosys 成功识别顶层模块、异步复位和两个带使能的触发器组,check 没有发现结构问题。这里能写"基础综合检查通过",但不能顺手写成"时序通过"。这个命令没有时钟约束、工艺库,也没有布局布线。

Skill 的门禁结果怎么看

外部仿真和综合跑完以后,笔者又保留了 Erie Skill 自己的检查结果:

检查

实际状态

说明

RTL 独立静态 Lint

通过

0 error,0 warning

Testbench 独立静态 Lint

通过

0 error,0 warning

formatter AST

通过

识别 1 个 RTL module,无解析错误

Icarus Verilog 编译

通过

Verilog-2001,返回码 0

自检查 Testbench

通过

返回码 0,121 笔输入与输出一致

Yosys 基础综合

通过

返回码 0,check 为 0 problems

strict deliverable gate

未通过

剩余 1 个 VG012

时序与实现

未运行

本实验没有工艺库、约束和实现流程

CDC/RDC 与形式验证

未运行

不在这个小实验的验证范围内

唯一剩下的 VG012 是参数命名冲突。需求明确要求公共参数名为 DATA_WIDTH,而 Erie 的 erie_strict profile 要求模块参数使用 C_ 前缀,也就是 C_DATA_WIDTH

笔者保留了 DATA_WIDTH,因为它是实验接口的一部分。于是仿真通过、Yosys 通过,但 strict deliverable gate 仍然是 delivery_ready=false。这并不矛盾。前两个工具检查语法、行为和可综合结构,Skill 门禁还检查自己的命名规则。

如果这是一个允许调整接口的新项目,可以把参数改成 C_DATA_WIDTH,让代码完全服从该 profile。若项目已有模块接口和脚本依赖 DATA_WIDTH,更合理的做法是记录这项偏差,而不是为了门禁全绿悄悄改接口。

笔者反而喜欢这个不太整齐的结果。它提醒使用者,质量门禁不是总分。每一项检查都有自己的对象,读报告时要知道它究竟检查了什么。

实际使用时,笔者会怎么安排

如果把这套 Skill 放进日常 RTL 开发,笔者不会每次都让它从零生成完整模块。

接口明确、逻辑规模不大的模块,比如协议适配壳、寄存器切片、计数器、简单仲裁和控制状态机,适合走完整生成流程。先让 Skill 整理需求,再看 codegen plan。接口或时序理解不对,最好在代码出来以前纠正。

已有 RTL 出问题时,更适合走 existing RTL 的分析或 verify-repair 分支。先让工具读取源代码和日志,输出诊断与修复计划,再决定是否应用修改。不要看到一次仿真报错,就让模型重写整个模块。那种改法很快,review 时也最让人头疼。

Testbench 生成可以单独使用。即便 RTL 是工程师手写的,让 Skill 根据端口和行为要求搭一个带 scoreboard、watchdog 和随机握手的骨架,也能省掉一部分重复劳动。生成完仍然要读一遍,尤其检查采样时刻、复位释放和非阻塞赋值带来的一个周期差异。

还有一条很朴素:命令和返回码要跟代码放在一起。聊天窗口里的"已验证"很容易丢,commands.md 和 simulation_output.txt 不会。团队里另一个人拿到目录,至少知道该从哪条命令开始复跑。

它适合做什么,不适合做什么

Erie Verilog Generator v0.4.0 适合 Verilog-2001 模块生成、现有 RTL 分析、静态检查、自检查 Testbench 骨架和早期综合准备。它对命名、注释、区域划分和单目标 always 块有比较强的意见。如果团队本来就有一套稳定风格,需要先比较两边规则,不要直接混用。

它不是 UVM 环境,也不会替你完成 CDC、RDC、形式验证、STA 和签核。多时钟设计、异步 FIFO、安全相关控制、复杂 AXI 互连,不能因为一个随机 Testbench 跑过就放松验证。Skill 能帮忙整理工作和生成第一版,但验证计划仍然要由工程师负责。

另外,Python semantic model 也只是工作流中的参照模型。它可以帮助 RTL 和测试向量保持一致,却不是数学意义上的规格证明。若 Python 和 RTL 从一开始就共享了同一个错误理解,两边完全一致也可能一起错。

说到底,这个 Skill 最有用的地方不是"会写 Verilog"。直接对话也会写。它把需求、计划、代码和工具结果串成了一条可复查的链路,让工程师能在每个节点插手。

写在最后

这次例子很小,最终 RTL 也没有炫技。可笔者愿意把它当成一个合格的工程样本,因为读者能拿到 Prompt、源代码、Testbench、仿真命令和综合输出,并且能看到那条没有被藏起来的 VG012

笔者现在会放心让 Codex 加上 Erie Skill 去写这类模块的第一版,也愿意让它先搭 Testbench。但代码合入以前,笔者还是会自己看握手边界、跑仿真、读综合日志。对 RTL 来说,这点麻烦省不了,也不该省。

项目与复现信息:

  1. 项目:https://github.com/Eriemon/verilog-generator
  2. 固定版本:v0.4.0
  3. commit:25bca69b33bb2d1252262a3b103646bef0116ae0
  4. 示例 RTL:outputs/example/ready_valid_slice.v
  5. 示例 Testbench:outputs/example/ready_valid_slice_tb.v
  6. 仿真证据:outputs/evidence/simulation_output.txt
  7. 门禁结果:outputs/evidence/deliverable_gate.json

下次再看到一句"这段 RTL 已经验证过",笔者大概还是会追问:跑的是哪条命令,返回码是多少,输出存在哪里。Skill 没法替人判断设计能不能签核,但它能把这几个答案留在目录里。对一个小型 RTL 生成工具来说,笔者觉得这已经很实在了。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐