ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

一个可复用 Verilog 模块模板:状态机 + 寄存器配置 + testbench

一个可复用 Verilog 模块模板:状态机 + 寄存器配置 + testbench 前言很多 FPGA 新人写模块时代码通常会经历三个阶段第一阶段只能实现固定功能if (cnt 99) pulse_out ~pulse_out;第二阶段开始加入一些参数parameter HIGH_CYCLES 10; parameter LOW_CYCLES 20;第三阶段实际项目要求运行过程中可以修改参数于是模块里开始出现配置寄存器启动命令busy状态done标志状态机多个计数器testbench。如果这些逻辑没有分层代码很快就会变成一团寄存器写逻辑改状态机 状态机改配置寄存器 计数器控制输出 输出又反过来控制状态本文使用一个“可配置脉冲发生器”作为例子给出一个可以复用的 Verilog 模块模板。它包含配置寄存器 状态机 运行参数锁存 状态输出 自检 testbench理解这个模板后可以把它改造成DMA 控制模块SPI 发送控制器ADC 采集控制器电机脉冲控制器帧同步控制器延时触发模块数据搬运状态机。一、先明确模块要实现什么本例实现一个可配置脉冲发生器。软件或上层逻辑可以配置三个参数高电平持续周期数 低电平持续周期数 脉冲重复次数例如配置HIGH_CYCLES 3 LOW_CYCLES 2 REPEAT_COUNT 4启动后输出波形为3周期 2周期 3周期 2周期 ┌─────┐ ┌─────┐ ───────┘ └─────────┘───────┘ └─────总共输出四个脉冲。模块还需要提供busy 当前是否正在运行 done_pulse 任务完成单周期脉冲 done状态位 供软件轮询二、模块的整体结构不要直接从状态机开始写。先把模块拆成四部分┌──────────────────┐ cfg写接口 ──────│ 配置寄存器 │ │ high/low/repeat │ └────────┬─────────┘ │ 启动时锁存 ▼ ┌──────────────────┐ │ 运行参数寄存器 │ │ active_xxx │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ 状态机 计数器 │ │ IDLE/HIGH/LOW │ └────────┬─────────┘ │ ▼ pulse_out/busy/done这里最重要的是配置寄存器和运行参数寄存器不要混在一起。配置寄存器保存上层写入的值。运行参数寄存器在收到启动命令时对配置值进行一次锁存。这样即使模块运行过程中上层修改了配置寄存器也不会影响当前任务。新配置只会在下一次启动时生效。三、寄存器地址规划本例使用一个简单的 32 位寄存器接口。地址名称位定义说明0x00CTRLbit0START写 1 启动一次任务0x00CTRLbit1CLR_DONE写 1 清除完成标志0x04HIGH_CYCLES[31:0]高电平持续周期数0x08LOW_CYCLES[31:0]低电平持续周期数0x0CREPEAT_COUNT[31:0]脉冲重复次数0x10STATUSbit0BUSY当前正在运行0x10STATUSbit1DONE任务已经完成START和CLR_DONE都属于“写 1 产生命令”的控制位。它们不是普通存储位。也就是说向 CTRL.bit0 写 1表示产生一次启动命令而不是让某个寄存器永久保持为 1。这种控制位通常称为Write One Pulse简称 W1P。四、为什么既要 done_pulse又要 DONE 状态位硬件模块之间连接时经常需要一个单周期完成脉冲done_pulse但如果处理器通过寄存器轮询完成状态一个周期的脉冲很容易被错过。假设 FPGA 时钟是 100 MHz一个时钟周期 10 ns而软件可能几微秒甚至几毫秒才读一次状态寄存器。软件几乎不可能可靠地读到一个只有 10 ns 的脉冲。因此通常需要同时提供done_pulse 给硬件逻辑使用 done_sticky 给软件轮询使用done_sticky一旦置 1就一直保持直到软件主动清除或者下一次任务启动。五、状态机如何划分本例状态机包含四个状态IDLE HIGH LOW DONE状态转换关系如下start ┌────────────────────┐ │ ▼ IDLE HIGH ▲ │ │ │ 不是最后一个脉冲 │ ▼ DONE ─────────────── LOW ▲ │ │ │ 低电平计数完成 └──── 最后一个脉冲 ──┘更准确地说IDLE 等待启动命令。 HIGH pulse_out 输出高电平并统计高电平周期数。 LOW pulse_out 输出低电平并统计低电平周期数。 DONE 产生一次 done_pulse并返回 IDLE。六、完整 Verilog 模块下面代码使用 Verilog-2001 写法。module pulse_engine #( parameter integer DEFAULT_HIGH 4, parameter integer DEFAULT_LOW 4, parameter integer DEFAULT_REPEAT 3 )( input wire clk, input wire rst_n, // 简单配置寄存器接口 input wire cfg_we, input wire cfg_re, input wire [4:0] cfg_addr, input wire [31:0] cfg_wdata, output reg [31:0] cfg_rdata, output reg cfg_rvalid, // 功能输出 output wire pulse_out, output wire busy, output reg done_pulse ); // // 1. 寄存器地址 // localparam [4:0] ADDR_CTRL 5h00; localparam [4:0] ADDR_HIGH 5h04; localparam [4:0] ADDR_LOW 5h08; localparam [4:0] ADDR_REPEAT 5h0C; localparam [4:0] ADDR_STATUS 5h10; // // 2. 状态机编码 // localparam [1:0] ST_IDLE 2d0; localparam [1:0] ST_HIGH 2d1; localparam [1:0] ST_LOW 2d2; localparam [1:0] ST_DONE 2d3; reg [1:0] state; // // 3. 配置寄存器 // 上层可以随时修改用于下一次任务 // reg [31:0] cfg_high_cycles; reg [31:0] cfg_low_cycles; reg [31:0] cfg_repeat_count; // // 4. 当前任务使用的运行参数 // 启动时从配置寄存器锁存 // reg [31:0] active_high_cycles; reg [31:0] active_low_cycles; reg [31:0] active_repeat_count; // // 5. 状态机计数器 // reg [31:0] cycle_count; reg [31:0] pulse_count; // 软件可轮询的完成标志 reg done_sticky; wire start_cmd; wire clear_done_cmd; // 只有空闲状态才接受启动命令 assign start_cmd cfg_we (cfg_addr ADDR_CTRL) cfg_wdata[0] !busy; assign clear_done_cmd cfg_we (cfg_addr ADDR_CTRL) cfg_wdata[1]; // 状态直接决定输出 assign pulse_out (state ST_HIGH); assign busy (state ! ST_IDLE); // // 6. 配置寄存器写逻辑 // always (posedge clk or negedge rst_n) begin if (!rst_n) begin cfg_high_cycles DEFAULT_HIGH; cfg_low_cycles DEFAULT_LOW; cfg_repeat_count DEFAULT_REPEAT; end else if (cfg_we) begin case (cfg_addr) ADDR_HIGH: begin cfg_high_cycles cfg_wdata; end ADDR_LOW: begin cfg_low_cycles cfg_wdata; end ADDR_REPEAT: begin cfg_repeat_count cfg_wdata; end default: begin // CTRL 和 STATUS 不在这里存储 end endcase end end // // 7. 配置寄存器读逻辑 // cfg_re 有效后的当前时钟沿锁存读数据 // always (posedge clk or negedge rst_n) begin if (!rst_n) begin cfg_rdata 32d0; cfg_rvalid 1b0; end else begin cfg_rvalid cfg_re; if (cfg_re) begin case (cfg_addr) ADDR_CTRL: begin cfg_rdata 32d0; end ADDR_HIGH: begin cfg_rdata cfg_high_cycles; end ADDR_LOW: begin cfg_rdata cfg_low_cycles; end ADDR_REPEAT: begin cfg_rdata cfg_repeat_count; end ADDR_STATUS: begin cfg_rdata { 30d0, done_sticky, busy }; end default: begin cfg_rdata 32d0; end endcase end end end // // 8. 状态机和运行控制 // always (posedge clk or negedge rst_n) begin if (!rst_n) begin state ST_IDLE; active_high_cycles 32d1; active_low_cycles 32d1; active_repeat_count 32d1; cycle_count 32d0; pulse_count 32d0; done_pulse 1b0; done_sticky 1b0; end else begin // 默认值done_pulse 只保持一个周期 done_pulse 1b0; // 软件主动清除完成标志 if (clear_done_cmd) begin done_sticky 1b0; end case (state) // ------------------------------------------------- // 空闲状态 // ------------------------------------------------- ST_IDLE: begin cycle_count 32d0; pulse_count 32d0; if (start_cmd) begin // 启动新任务时清除旧的完成状态 done_sticky 1b0; // 防止配置为 0 后出现减 1 下溢 active_high_cycles (cfg_high_cycles 0) ? 32d1 : cfg_high_cycles; active_low_cycles (cfg_low_cycles 0) ? 32d1 : cfg_low_cycles; active_repeat_count (cfg_repeat_count 0) ? 32d1 : cfg_repeat_count; state ST_HIGH; end end // ------------------------------------------------- // 输出高电平 // ------------------------------------------------- ST_HIGH: begin if (cycle_count active_high_cycles - 32d1) begin cycle_count 32d0; // pulse_count 表示当前脉冲编号从 0 开始 if (pulse_count active_repeat_count - 32d1) begin state ST_DONE; end else begin pulse_count pulse_count 32d1; state ST_LOW; end end else begin cycle_count cycle_count 32d1; end end // ------------------------------------------------- // 输出低电平 // ------------------------------------------------- ST_LOW: begin if (cycle_count active_low_cycles - 32d1) begin cycle_count 32d0; state ST_HIGH; end else begin cycle_count cycle_count 32d1; end end // ------------------------------------------------- // 任务完成 // ------------------------------------------------- ST_DONE: begin done_pulse 1b1; done_sticky 1b1; state ST_IDLE; end // ------------------------------------------------- // 非法状态恢复 // ------------------------------------------------- default: begin state ST_IDLE; cycle_count 32d0; pulse_count 32d0; end endcase end end endmodule七、这段代码最值得复用的地方1. 配置参数和运行参数分开代码中同时存在cfg_high_cycles active_high_cycles它们的作用不同。cfg_high_cycles是配置寄存器上层可以修改。active_high_cycles是当前任务实际使用的参数只在启动时更新active_high_cycles cfg_high_cycles;假设当前正在输出脉冲上层突然修改HIGH_CYCLES10 → 3当前任务仍然按照原来的 10 个周期运行。新的 3 个周期配置在下一次启动时生效。如果状态机直接使用cfg_high_cycles那么运行中修改寄存器可能导致当前脉冲突然缩短计数器条件突然变化不同脉冲宽度不一致波形难以复现。2. 状态决定输出而不是到处修改输出本例没有在多个分支中反复写pulse_out 1b1; pulse_out 1b0;而是直接写成assign pulse_out (state ST_HIGH);这种写法的好处是状态和输出关系明确 避免 pulse_out 被多个条件修改 减少漏赋值和时序混乱只要状态机正确输出基本就不会错。3. 启动命令在 busy 时被忽略assign start_cmd cfg_we (cfg_addr ADDR_CTRL) cfg_wdata[0] !busy;这里增加了!busy意味着模块正在运行时不接受新的启动命令。这是一个明确的接口规则。否则必须继续考虑新任务是否覆盖旧任务新任务是否进入队列是否支持多任务缓存busy 时写 START 返回什么错误。对于简单控制模块直接规定busy 期间忽略新的 START。通常最容易实现也最不容易出错。4. 配置为 0 时进行保护状态机中会使用active_high_cycles - 1如果配置值是 0会发生无符号数下溢0 - 1 32hFFFF_FFFF结果可能让状态机运行几十亿个周期。因此启动时进行限制active_high_cycles (cfg_high_cycles 0) ? 32d1 : cfg_high_cycles;这种输入边界保护在真实项目中非常重要。八、状态机计数为什么要减 1假设要求高电平持续三个周期active_high_cycles 3cycle_count从 0 开始时钟周期cycle_count是否结束第 1 个高电平周期0否第 2 个高电平周期1否第 3 个高电平周期2是因此结束条件是cycle_count active_high_cycles - 1不是cycle_count active_high_cycles如果写成后者就会多运行一个周期。这是 FPGA 新人最容易遇到的“差一个周期”问题。九、testbench 不应该只产生激励很多新人的 testbench 只有这些内容initial begin rst_n 0; #100; rst_n 1; #1000; $finish; end这种 testbench 只能生成波形不能自动判断结果是否正确。更实用的 testbench 应该做到自动写配置寄存器 自动启动模块 自动统计高电平宽度 自动统计低电平宽度 自动统计脉冲个数 发现错误时打印 ERROR 全部正确时打印 PASS这就是自检 testbench。十、完整自检 testbenchtimescale 1ns/1ps module tb_pulse_engine; reg clk; reg rst_n; reg cfg_we; reg cfg_re; reg [4:0] cfg_addr; reg [31:0] cfg_wdata; wire [31:0] cfg_rdata; wire cfg_rvalid; wire pulse_out; wire busy; wire done_pulse; integer errors; // // DUT 实例化 // pulse_engine dut ( .clk (clk), .rst_n (rst_n), .cfg_we (cfg_we), .cfg_re (cfg_re), .cfg_addr (cfg_addr), .cfg_wdata (cfg_wdata), .cfg_rdata (cfg_rdata), .cfg_rvalid (cfg_rvalid), .pulse_out (pulse_out), .busy (busy), .done_pulse (done_pulse) ); // // 100 MHz 时钟 // initial begin clk 1b0; end always #5 clk ~clk; // // 寄存器写任务 // task cfg_write; input [4:0] addr; input [31:0] data; begin (negedge clk); cfg_we 1b1; cfg_addr addr; cfg_wdata data; (negedge clk); cfg_we 1b0; cfg_addr 5d0; cfg_wdata 32d0; end endtask // // 寄存器读检查任务 // task cfg_read_check; input [4:0] addr; input [31:0] expected; begin (negedge clk); cfg_re 1b1; cfg_addr addr; (posedge clk); #1; if (!cfg_rvalid || cfg_rdata ! expected) begin $display( [ERROR] read addr0x%0h, got0x%08h, expected0x%08h, addr, cfg_rdata, expected ); errors errors 1; end (negedge clk); cfg_re 1b0; cfg_addr 5d0; end endtask // // 单组测试 // task run_case; input integer high_cycles; input integer low_cycles; input integer repeat_count; integer high_run; integer low_run; integer pulse_seen; reg prev_pulse; reg finished; begin $display( [INFO] start case: high%0d low%0d repeat%0d, high_cycles, low_cycles, repeat_count ); // 写入配置 cfg_write(5h04, high_cycles); cfg_write(5h08, low_cycles); cfg_write(5h0C, repeat_count); // 回读配置寄存器 cfg_read_check(5h04, high_cycles); cfg_read_check(5h08, low_cycles); cfg_read_check(5h0C, repeat_count); // 写 CTRL.bit0启动任务 cfg_write(5h00, 32h0000_0001); wait (busy 1b1); /* * cfg_write 返回时第一个高电平周期已经开始 * 因此需要把当前周期计入 high_run。 */ high_run pulse_out ? 1 : 0; low_run 0; pulse_seen 0; prev_pulse pulse_out; finished 1b0; while (!finished) begin (posedge clk); #1; // --------------------------------------------- // 当前输出为高电平 // --------------------------------------------- if (pulse_out) begin // 检测低到高跳变 if (!prev_pulse) begin if ((pulse_seen 0) (low_run ! low_cycles)) begin $display( [ERROR] low width got%0d expected%0d, low_run, low_cycles ); errors errors 1; end low_run 0; high_run 1; end else begin high_run high_run 1; end end // --------------------------------------------- // 当前输出为低电平 // --------------------------------------------- else begin // 检测高到低跳变 if (prev_pulse) begin pulse_seen pulse_seen 1; if (high_run ! high_cycles) begin $display( [ERROR] high width got%0d expected%0d, high_run, high_cycles ); errors errors 1; end high_run 0; if (pulse_seen repeat_count) begin low_run 1; end end // 统计两个脉冲之间的低电平周期 else if ( busy (pulse_seen 0) (pulse_seen repeat_count) ) begin low_run low_run 1; end end prev_pulse pulse_out; // --------------------------------------------- // 检测任务完成 // --------------------------------------------- if (done_pulse) begin finished 1b1; if (pulse_seen ! repeat_count) begin $display( [ERROR] pulse count got%0d expected%0d, pulse_seen, repeat_count ); errors errors 1; end end end if (busy ! 1b0) begin $display( [ERROR] busy should be low after done ); errors errors 1; end // STATUS 应为 // bit1 DONE 1 // bit0 BUSY 0 cfg_read_check(5h10, 32h0000_0002); // 写 CTRL.bit1清除 DONE cfg_write(5h00, 32h0000_0002); cfg_read_check(5h10, 32h0000_0000); end endtask // // 主测试流程 // initial begin rst_n 1b0; cfg_we 1b0; cfg_re 1b0; cfg_addr 5d0; cfg_wdata 32d0; errors 0; // 保持复位若干周期 repeat (4) (posedge clk); // 在时钟下降沿释放复位避免仿真竞争 (negedge clk); rst_n 1b1; // 检查默认寄存器值 cfg_read_check(5h04, 32d4); cfg_read_check(5h08, 32d4); cfg_read_check(5h0C, 32d3); // 测试多组参数 run_case(3, 2, 4); run_case(1, 1, 2); run_case(5, 3, 1); if (errors 0) begin $display([PASS] all test cases passed); end else begin $display([FAIL] errors%0d, errors); end #20; $finish; end endmodule十一、预期仿真结果如果 RTL 正确仿真控制台应该输出类似内容[INFO] start case: high3 low2 repeat4 [INFO] start case: high1 low1 repeat2 [INFO] start case: high5 low3 repeat1 [PASS] all test cases passed第一组测试对应的主要波形应该是clk _|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_ start_cmd ____|‾|_____________________________ busy ____|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾|_________ pulse_out ____|‾‾‾|__|‾‾‾|__|‾‾‾|__|‾‾‾|____ done_pulse ____________________________|‾|_____其中每个高电平持续 3 个周期 相邻脉冲之间低电平持续 2 个周期 一共输出 4 个脉冲 done_pulse 只持续 1 个周期十二、为什么 testbench 要在时钟下降沿驱动输入本例中的寄存器写任务使用(negedge clk); cfg_we 1b1;然后 DUT 在下一个时钟上升沿采样always (posedge clk)这样可以避免 testbench 和 DUT 在同一个上升沿同时修改、采样信号。错误示例(posedge clk); cfg_we 1b1;如果 testbench 和 DUT 都在posedge clk执行就可能产生仿真竞争。不同仿真器、不同代码调度顺序下结果可能不一致。对简单 testbench 来说一个常用做法是下降沿驱动输入 上升沿由 DUT 采样十三、常见错误一状态机直接使用配置寄存器错误写法if (cycle_count cfg_high_cycles - 1) state ST_LOW;如果软件在当前任务运行时修改cfg_high_cycles状态机的结束条件会立即变化。正确做法if (start_cmd) active_high_cycles cfg_high_cycles;状态机使用active_high_cycles配置寄存器只负责保存下一次任务参数。十四、常见错误二done 只打一拍给软件读错误设计done 1b1;下一周期done 1b0;如果done被映射到软件状态寄存器处理器很可能永远读不到它。正确做法是同时提供done_pulse done_sticky其中done_pulse 用于硬件模块握手 done_sticky 用于软件轮询十五、常见错误三busy 的定义不明确有些代码只在部分状态中手动修改busyif (start) busy 1b1; if (done) busy 1b0;随着状态机越来越复杂很容易漏掉某个分支。本例直接根据状态生成assign busy (state ! ST_IDLE);这样busy和状态机不会出现不一致。十六、常见错误四计数器没有在状态切换时清零例如从HIGH进入LOW时如果忘记cycle_count 0;低电平状态会继续使用高电平状态留下的计数值。结果可能是低电平周期明显变短状态刚进入就立即退出不同配置下出现偶发错误。因此每次阶段结束时都要明确处理计数器cycle_count 32d0; state ST_LOW;十七、常见错误五一个周期内多次给同一寄存器赋值在时序逻辑中下面代码需要特别小心done_sticky 1b0; if (task_done) done_sticky 1b1;同一个always块中如果一个寄存器被多次非阻塞赋值后执行的赋值优先。本例中if (clear_done_cmd) done_sticky 1b0;而在ST_DONE中done_sticky 1b1;如果清除命令和任务完成刚好发生在同一个周期最终完成事件优先done_sticky保持为 1。设计时应明确这种优先级而不是依赖“碰巧正确”。十八、这个模板如何改成实际工程模块这个模板的核心结构是配置寄存器 ↓ 启动时锁存 ↓ 状态机执行 ↓ busy/done/status例如改成 ADC 采集控制器本例信号ADC 模块中的含义HIGH_CYCLES采样周期LOW_CYCLES采样间隔REPEAT_COUNT采样点数START开始采集BUSY正在采集DONE一帧采集完成改成 SPI 发送模块本例信号SPI 模块中的含义HIGH_CYCLESSCLK 高电平分频LOW_CYCLESSCLK 低电平分频REPEAT_COUNT发送位数START开始发送BUSYSPI 正在发送DONE一帧发送完成改成 DMA 控制器本例信号DMA 模块中的含义配置寄存器源地址、目的地址、长度运行参数当前任务锁存参数状态机IDLE、READ、WRITE、DONEBUSY正在搬运DONE搬运结束模块功能会变但结构基本不变。十九、工程中还可以继续完善什么本文模板为了突出结构采用了一次只处理一个任务的设计。实际项目中还可以继续增加错误状态寄存器 中断输出 启动请求队列 超时保护 软复位命令 非法配置检查 AXI-Lite 或 APB 接口 任务取消功能 性能计数器例如增加错误状态reg config_error;当配置值超出范围时if (cfg_repeat_count MAX_REPEAT) config_error 1b1;但不要一开始就把所有功能都塞进模块。先保证基本结构清楚再逐步增加功能。二十、最后总结一个可复用的控制类 Verilog 模块通常包含以下部分1. 配置寄存器 2. 控制命令 3. 运行参数锁存 4. 状态机 5. 计数器或数据通路 6. busy 状态 7. done_pulse 8. 软件可轮询的完成标志 9. 自检 testbench真正值得复用的不是某一个脉冲发生器而是这种结构上层写配置 ↓ 写 START ↓ 锁存当前任务参数 ↓ 状态机开始执行 ↓ BUSY 拉高 ↓ 任务完成 ↓ 产生 DONE写 RTL 时尽量让每一类寄存器只负责一件事配置寄存器负责保存配置 运行寄存器负责保存当前任务参数 状态寄存器负责控制流程 计数器负责统计周期 状态输出负责向外报告结果只要这几部分边界清晰即使模块后续增加功能代码也不会轻易失控。
返回列表