试用了一个专门写 Verilog 的 Codex Skill:比“让 AI 直接吐 RTL”靠谱不少

最近试用了一个面向 Verilog-2001 RTL 开发的 Codex Skill:Verilog Generator

这次我重点阅读了当前最新版 v1.3.1 的工作流,同时拿之前使用 v1.0.0 生成的一份实际 RTL 结果做了对照。

它给我的第一印象是:这并不是简单给模型加一句“你是 FPGA 专家”,而是在尝试把 AI 生成 Verilog 变成一套更接近工程习惯的流程——先把接口、时钟与复位、逐周期行为、状态转换和边界情况讲清楚,再进入代码生成与验证。

一句话结论:

它把“让 AI 直接吐一段 RTL”,推进到了“先确认设计行为,再生成、审查并说明验证边界”的阶段。

GitHub 项目地址:

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


一、本次试用说明

项目 内容
当前最新版 v1.3.1
实验结果来源 使用 v1.0.0 生成的 RTL
测试模块 64×64 Conway Game of Life 二维元胞自动机
重点观察 接口、状态机、双缓冲、边界处理、代码组织和中文注释
本次未包含 testbench、仿真波形、综合报告、时序报告和上板结果

在这里插入图片描述


二、我用它做了什么

这次选择的测试任务,是生成一个 64×64 Conway Game of Life(生命游戏)RTL 核心

需求主要包括:

  1. 在空闲状态下写入初始种子;
  2. 支持连续运行和单步演化;
  3. 输出忙状态、单代完成脉冲和代数计数;
  4. 支持按行读取当前一代的 64 bit 细胞状态;
  5. 按照 Conway 生命游戏的 B3/S23 规则生成下一代状态。

从 v1.0.0 生成的代码来看,它没有只给出一个接口空壳,而是把主要的数据通路和控制流程都展开了。

模块内部使用三个状态组织一代生命演化:

localparam [1:0] ST_IDLE   = 2'd0;
localparam [1:0] ST_SCAN   = 2'd1;
localparam [1:0] ST_COMMIT = 2'd2;

三个状态分别负责:

  • ST_IDLE:等待单步或连续运行请求,同时允许写入初始种子;
  • ST_SCAN:逐点扫描 64×64 网格,并计算下一代细胞状态;
  • ST_COMMIT:完成整代计算后切换双缓冲,并决定继续运行还是返回空闲态。

此外,生成结果还包含:

  • A/B 两份 4096 bit 帧数据,用于当前代与下一代之间的切换;
  • 八邻域的独立边界判断与活细胞计数;
  • B3/S23 规则判断;
  • 连续运行、单步运行和完成脉冲控制;
  • 种子逐点写入与当前代按行读回;
  • 对接口、状态、计数器、标志信号、索引和帧缓存的分区组织;
  • 比较完整的中文语义注释。

【生成结果中的模块端口】

// 64x64 二维生命游戏自演化核心模块
module cellular_automaton_2d
(
	//-----------------全局信号-----------------//
	input i_clk,                                // 模块主时钟,用于推进扫描状态机和代际切换
	input i_rstn,                               // 模块低有效复位,拉低时清空双缓冲和代数计数

	//-----------------控制接口-----------------//
	input i_run_en,                             // 连续运行使能,为高时每代提交后立刻开始下一代扫描
	input i_step,                               // 单步推进请求,仅在空闲态被采纳为一代启动脉冲
	output o_busy,                              // 正在执行扫描或提交流程时对外拉高的忙标志
	output o_generation_done,                   // 每代提交完成后维持一个时钟周期的完成脉冲
	output [31:0]o_generation_count,            // 已经提交完成的生命代数统计值

	//---------------种子写入接口---------------//
	//SEED接口
	input i_seed_we,                            // 空闲态逐点写入种子的写使能脉冲
	input [5:0]i_seed_row,                      // 种子写请求命中的目标行编号
	input [5:0]i_seed_col,                      // 种子写请求命中的目标列编号
	input i_seed_value,                         // 种子写请求希望写入的细胞状态值

	//---------------状态读取接口---------------//
	input [5:0]i_read_row,                      // 当前代按行读回所选择的行编号
	output [63:0]o_read_row_data                // 当前代指定行的完整 64bit 细胞状态向量
);

【状态机】

例如 ST_IDLE / ST_SCAN / ST_COMMIT 状态机,以及 A/B 双缓冲切换相关代码

	//---------------配置参数区域---------------//
	// 网格尺寸与零值常量组
	localparam [5:0]GRID_LAST_INDEX = 6'd63;    // 64x64 网格中最后一行或最后一列对应的索引值
	localparam [63:0]ROW_DATA_ZERO = 64'd0;     // 越界行或复位清零时复用的全零行向量
	localparam [4095:0]FRAME_DATA_ZERO = 4096'd0; // 双缓冲整帧复位时复用的全零 4096bit 初始图样

	//---------------状态参数区域---------------//
	// 顶层控制状态编码组
	localparam [1:0]ST_IDLE = 2'd0;             // 停机并允许外部装载种子的空闲状态
	localparam [1:0]ST_SCAN = 2'd1;             // 逐点扫描当前代并写出下一代结果的运行状态
	localparam [1:0]ST_COMMIT = 2'd2;           // 整代扫描结束后切换前后台缓冲的提交状态

	//-----------------计数信号-----------------//
	wire [3:0]cnt_neighbor_sum;                 // 当前被扫描细胞 8 邻域中活细胞数量的求和结果

	//----------------状态机信号----------------//
	reg [1:0]state_current = 0;                 // 当前正在执行的生命演化控制状态
	reg [1:0]state_next = 0;                    // 组合逻辑计算得到的下一拍控制状态
	reg [5:0]state_row_index = 0;               // 扫描流程当前命中的细胞行号
	reg [5:0]state_col_index = 0;               // 扫描流程当前命中的细胞列号

	//----------------寄存器信号----------------//
	reg [4095:0]reg_frame_a = 0;                // 扁平化帧缓冲 A,按 {行号,列号} 映射 4096 个生命位
	reg [4095:0]reg_frame_b = 0;                // 扁平化帧缓冲 B,作为与 A 轮换的整帧存储向量
	
    //----------------状态机区域----------------//
	// 根据当前状态与控制输入决定下一拍状态流向
	always@(*)begin
		state_next = state_current;             // 缺省保持当前状态,避免组合分支遗漏导致锁存推断
		case(state_current)

			// 状态分支说明: 空闲态负责吸收启动控制并保持种子可写
			// 空闲态等待单步或连续运行请求启动新一代扫描
			ST_IDLE:begin
				if(flag_start_scan == 1'b1)begin
					state_next = ST_SCAN;       // 收到连续运行或单步请求后切换到扫描状态
				end else begin
					state_next = ST_IDLE;       // 没有启动请求时继续停留在空闲状态
				end
			end

			// 状态分支说明: 扫描态遍历全网格并生成后继代位图
			// 扫描态逐点遍历 64x64 网格并在末单元后进入提交阶段
			ST_SCAN:begin
				if(flag_scan_last_cell == 1'b1)begin
					state_next = ST_COMMIT;     // 整个 64x64 网格最后一个细胞计算完成后进入提交状态
				end else begin
					state_next = ST_SCAN;       // 尚未扫完整个网格时继续保持扫描状态
				end
			end

			// 状态分支说明: 提交态负责换帧并判定后续是否继续运行
			// 提交态翻转双缓冲并根据运行使能决定是否继续下一代
			ST_COMMIT:begin
				if(i_run_en == 1'b1)begin
					state_next = ST_SCAN;       // 连续运行打开时提交完成后直接开始下一代扫描
				end else begin
					state_next = ST_IDLE;       // 连续运行关闭时提交完成后回到空闲装载状态
				end
			end

			// 状态分支说明: 默认分支吸收非法状态编码并回到安全起点
			// 兜底分支把异常状态编码拉回安全空闲态
			default:begin
				state_next = ST_IDLE;           // 检测到未定义状态编码时回退到安全空闲状态
			end
		endcase
	end

	// 把组合求得的下一状态注册为当前状态
	always@(posedge i_clk or negedge i_rstn)begin
		if(i_rstn == 1'b0)begin
			state_current <= ST_IDLE;           // 复位时强制状态机回到空闲态等待重新装载
		end else begin
			state_current <= state_next;        // 每个时钟把下一状态锁存成新的当前状态
		end
	end

【八邻域求和代码】

八邻域求和与 B3/S23 规则代码。

    //-------------状态任务处理区域-------------//
	// 当前帧选择位在提交态完成前后台缓冲互换
	always@(posedge i_clk or negedge i_rstn)begin
		if(i_rstn == 1'b0)begin
			flag_frame_select <= 1'b0;          // 复位后固定由 A 帧作为当前代读出来源
		end else if(state_current == ST_COMMIT)begin
			flag_frame_select <= ~flag_frame_select; // 每次提交新一代时翻转当前帧选择位完成双缓冲切换
		end
	end

	// 扫描行号在启动、行末和整代切换时进行更新
	always@(posedge i_clk or negedge i_rstn)begin
		if(i_rstn == 1'b0)begin
			state_row_index <= 6'd0;            // 复位时从网格首行重新开始扫描坐标
		end else if((state_current == ST_IDLE) && (state_next == ST_SCAN))begin
			state_row_index <= 6'd0;            // 从空闲态启动新一代时把扫描起点重新放回首行
		end else if(state_current == ST_SCAN)begin
			if(flag_scan_last_cell == 1'b1)begin
				state_row_index <= 6'd0;        // 扫描完最后一个细胞后把下一代行起点预置回首行
			end else if(flag_scan_last_col == 1'b1)begin
				state_row_index <= state_row_index + 6'd1; // 当前行末列写完后把扫描行号推进到下一行
			end
		end
	end

	// 扫描列号在启动、列推进与行切换时进行更新
	always@(posedge i_clk or negedge i_rstn)begin
		if(i_rstn == 1'b0)begin
			state_col_index <= 6'd0;            // 复位时从网格首列重新开始扫描坐标
		end else if((state_current == ST_IDLE) && (state_next == ST_SCAN))begin
			state_col_index <= 6'd0;            // 从空闲态启动新一代时把扫描起点重新对准首列
		end else if(state_current == ST_SCAN)begin
			if(flag_scan_last_col == 1'b1)begin
				state_col_index <= 6'd0;        // 当前行最后一列写完后把列坐标回卷到首列
			end else begin
				state_col_index <= state_col_index + 6'd1; // 同一行内部扫描时把列坐标推进到下一个细胞
			end
		end
	end

	// A 帧在复位、空闲装载和 B 当前帧扫描时承接下一代写入
	always@(posedge i_clk or negedge i_rstn)begin
		if(i_rstn == 1'b0)begin
			reg_frame_a <= FRAME_DATA_ZERO;     // 复位时把 A 扁平帧一次性清成 4096bit 全零图样
		end else if((state_current == ST_IDLE) && (i_seed_we == 1'b1))begin
			reg_frame_a[index_seed_bit] <= i_seed_value; // 空闲装载阶段把外部种子值写入 A 帧目标单元位
		end else if((state_current == ST_SCAN) && (flag_frame_select == 1'b1))begin
			reg_frame_a[index_scan_bit] <= flag_cell_next; // 当 B 帧作为当前代时把计算出的下一代位写回 A 帧
		end
	end

	// B 帧在复位、空闲镜像装载和 A 当前帧扫描时收集候选后继代
	always@(posedge i_clk or negedge i_rstn)begin
		if(i_rstn == 1'b0)begin
			reg_frame_b <= FRAME_DATA_ZERO;     // 复位时把 B 扁平帧初始化成备用后继代的全零图样
		end else if((state_current == ST_IDLE) && (i_seed_we == 1'b1))begin
			reg_frame_b[index_seed_bit] <= i_seed_value; // 空闲装载阶段把同一颗种子同步镜像写入 B 帧对应单元位
		end else if((state_current == ST_SCAN) && (flag_frame_select == 1'b0))begin
			reg_frame_b[index_scan_bit] <= flag_cell_next; // 当 A 帧作为源帧时把新计算的后继位落入 B 帧待提交结果
		end
	end

三、试下来印象比较深的三点

1. 可读性确实比“裸生成 Verilog”好

最明显的是,代码结构比较容易顺着读下来。

端口、参数、状态、计数器、寄存器、标志信号、索引和输出都有明确分区。信号名也尽量表达用途,而不是大量出现 tmp1data2 这类需要反复回头猜语义的名字。

例如,八邻域分别使用具有明确含义的信号表示:

flag_neighbor_up_left
flag_neighbor_up_center
flag_neighbor_up_right
flag_neighbor_mid_left
flag_neighbor_mid_right
flag_neighbor_down_left
flag_neighbor_down_center
flag_neighbor_down_right

这种写法可能不是最短的,但对人工审查、课堂讲解、工程交接和后续修改更友好。

注释也不是简单重复代码,而是在解释某个信号为什么存在、某个状态负责什么、当前帧与下一帧如何切换,以及边界条件如何处理。


2. 最新版不再把“生成代码”当成第一步

最新版更强调 先确认行为,再生成 RTL

新模块的流程不再是:

一句自然语言需求
        ↓
直接生成一大段 RTL

而更接近:

需求输入
   ↓
补齐接口、时钟、复位和时序信息
   ↓
形成 codegen plan 与同名模块 Spec
   ↓
通过 WaveDrom 预览逐周期行为
   ↓
确认状态转换、握手和边界情况
   ↓
生成 RTL 并执行所需检查

这比传统的“一句话需求 → 一大段 RTL”更合理。

因为 FPGA 设计里真正容易出错的,往往不是 Verilog 语法,而是双方对复位、延迟、握手、完成脉冲和异常场景的理解不一致。

3. 它对“验证证据”的边界比较克制

我比较认可的一点,是它会把静态检查、仿真、综合、实现和硬件结果分开。

检查层次 能说明什么 不能直接说明什么
静态审查 命名、位宽、结构和潜在风险 功能一定正确
编译或 Lint 工具可以解析,部分规则检查通过 仿真行为正确
仿真 指定测试场景下行为符合预期 综合后时序一定收敛
综合 设计能够映射到目标器件资源 实现时序一定通过
实现与时序 布局布线后满足指定约束 板级系统一定正常
上板验证 指定硬件环境中的真实运行结果 所有输入场景均已覆盖

没有实际运行过的检查,不应该被写成已经通过。

代码看起来合理
≠ 语法检查通过
≠ 仿真通过
≠ 综合通过
≠ 时序收敛
≠ 上板可用

这听起来像一句常识,但在 AI 生成硬件代码时非常重要。


四、也有几点需要理性看待

首先,这次用来对照的是 v1.0.0 生成的一份 RTL 源码,没有同时附带 testbench、仿真波形、综合报告和时序报告。因此,本次试用主要能说明它在 代码组织、接口完整性和可读性方面的表现,不能据此直接声称设计已经完成全部工具验证。

其次,生成结果的注释比较密。对于教学、交接和首次理解很友好,但放进已有大型工程后,可能还需要根据团队规范适当收敛。

另外,生命游戏示例直接使用两份 4096 bit 扁平帧数据,逻辑关系很直观。但真正部署到具体 FPGA 时,仍然需要结合器件资源继续考虑 BRAM/URAM 映射、存储端口、行缓存、吞吐率、流水线、并行化和目标频率。

可读的 RTL 初版很有价值,但它不等于已经得到最优硬件实现。


五、总体感受

整体试下来,我觉得它已经不太像一个单纯的“Verilog 代码生成 Prompt”,而更像一个围绕下面这些环节组织起来的 Codex Skill:

  1. 需求确认;
  2. 行为与时序预览;
  3. 模块 Spec;
  4. Verilog-2001 RTL 生成;
  5. 现有代码阅读与审查;
  6. 语义注释;
  7. 根据真实诊断进行修复;
  8. 区分静态检查、仿真、综合、实现和硬件证据。

它比较适合用在:

  • 快速搭建结构清楚的 RTL 初版;
  • 把自然语言需求整理成可审查的模块行为;
  • 阅读和解释已有 Verilog;
  • 给旧代码补充语义注释;
  • 根据真实 Lint、编译或仿真日志定位问题;
  • 在交付前说明实际完成了哪些检查,还有哪些内容尚未验证。

当然,正式工程里仍然离不开 testbench、仿真、综合、时序分析、上板验证和人工 review。

但从这次试用来看,它至少把 AI 写 RTL 从:

生成一段看起来像代码的文本

往下面这个方向推进了一步:

生成一份行为更明确、结构可阅读、能够继续审查和验证的工程结果

这也是我认为它目前最有价值的地方。


六、项目地址与调用方式

GitHub:

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

也可以直接把下面这句话交给 Codex:

请从 https://github.com/Eriemon/verilog-generator 安装 Verilog Generator,
并使用 $readable-verilog-generator 帮我完成这个 Verilog 任务。

#FPGA #Verilog #RTL #Codex #EDA #AgentSkill #AI4EDA

Logo

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

更多推荐