10. 综合项目:可配置 LED 闪烁器与编码规范
这一篇把前面的知识组合成一个能仿真、能综合、能复用的小项目。目标不是“点亮 LED”本身,而是掌握从需求到验证的完整方法。

1. 先把需求写成可验证条目
跳转到“1. 先把需求写成可验证条目”- 输入时钟频率由参数
CLK_HZ表示; - LED 完整闪烁频率由参数
BLINK_HZ表示; - 低有效异步复位时,LED 熄灭、计数器清零;
- 每经过
CLK_HZ / (2 * BLINK_HZ)个时钟,LED 翻转一次; - 参数必须满足
BLINK_HZ > 0、CLK_HZ >= 2 * BLINK_HZ且能够整除,否则频率无效或存在截断误差; - 计数器位宽由模块根据半周期自动计算,避免手工位宽过小导致终值被静默截断。
为什么除以 2?一个完整闪烁周期包含“亮半周期 + 灭半周期”,每半周期翻转一次。
2. 先算一个具体例子
跳转到“2. 先算一个具体例子”若开发板时钟为 50 MHz,希望 LED 每秒完成一次亮灭:
HALF_PERIOD = 50_000_000 / (2 × 1) = 25_000_000 个时钟计数器从 0 数到 HALF_PERIOD - 1,恰好经历 25,000,000 个时钟。最大值小于 2²⁵,因此需要 25 位。
3. RTL 实现
跳转到“3. RTL 实现”module led_blinker #( parameter integer CLK_HZ = 50_000_000, parameter integer BLINK_HZ = 1) ( input wire clk, input wire rst_n, output reg led);
// SAFE_BLINK_HZ 只用于避免非法参数在展开阶段触发除零。 localparam integer SAFE_BLINK_HZ = (BLINK_HZ > 0) ? BLINK_HZ : 1; localparam integer HALF_PERIOD = CLK_HZ / (2 * SAFE_BLINK_HZ);
function integer width_for_count; input integer count; integer value; begin value = count - 1; width_for_count = 1; while (value > 1) begin value = value >> 1; width_for_count = width_for_count + 1; end end endfunction
localparam integer CNT_WIDTH = width_for_count(HALF_PERIOD); localparam [CNT_WIDTH-1:0] COUNT_MAX = HALF_PERIOD - 1;
reg [CNT_WIDTH-1:0] counter;
`ifndef SYNTHESIS initial begin if (BLINK_HZ <= 0) begin $display("ERROR: BLINK_HZ must be greater than zero"); $finish; end else if (CLK_HZ < (2 * BLINK_HZ)) begin $display("ERROR: CLK_HZ must be at least 2 * BLINK_HZ"); $finish; end else if ((CLK_HZ % (2 * SAFE_BLINK_HZ)) != 0) begin $display("ERROR: CLK_HZ must be divisible by 2 * BLINK_HZ"); $finish; end end`endif
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin counter <= {CNT_WIDTH{1'b0}}; led <= 1'b0; end else if (counter == COUNT_MAX) begin counter <= {CNT_WIDTH{1'b0}}; led <= ~led; end else begin counter <= counter + 1'b1; end end
endmodule逐段理解:
parameter暴露外部配置;localparam计算模块内部不可覆盖的派生常量;width_for_count在展开时计算最小计数器位宽,半周期为 1 时仍保留 1 位;- 仿真专用参数检查被
`ifndef SYNTHESIS包围,不进入正常综合逻辑; counter和led都是状态,所以在时钟过程内用<=;- 复位分支给所有状态确定值;
- 到达终值时同时清零并翻转;
- 普通周期只更新计数器,LED 自然保持。
4. 为仿真缩小参数
跳转到“4. 为仿真缩小参数”仿真没必要真的等待 25,000,000 个周期。实例化时把参数改小:
led_blinker #( .CLK_HZ (20), .BLINK_HZ (2)) dut ( .clk (clk), .rst_n (rst_n), .led (led));此时 HALF_PERIOD = 20 / (2 × 2) = 5,LED 每 5 个时钟翻转,函数自动得到 3 位计数器,足够表示 0..4。
5. 自检查 testbench
跳转到“5. 自检查 testbench”`timescale 1ns/1ps
module led_blinker_tb;
reg clk; reg rst_n; wire led;
integer cycles_since_toggle; integer toggles; integer errors; integer observed_cycles; reg previous_led;
led_blinker #( .CLK_HZ (20), .BLINK_HZ (2) ) dut ( .clk (clk), .rst_n (rst_n), .led (led) );
initial begin clk = 1'b0; forever #5 clk = ~clk; end
initial begin rst_n = 1'b0; cycles_since_toggle = 0; toggles = 0; errors = 0; observed_cycles = 0; previous_led = 1'b0;
repeat (3) @(posedge clk); @(negedge clk); rst_n = 1'b1;
// 最多观察 30 个周期,DUT 不翻转时也能结束并报告失败。 while ((toggles < 4) && (observed_cycles < 30)) begin @(negedge clk); cycles_since_toggle = cycles_since_toggle + 1; observed_cycles = observed_cycles + 1;
if (led !== previous_led) begin if (cycles_since_toggle != 5) begin $display("ERROR: toggle interval=%0d", cycles_since_toggle); errors = errors + 1; end previous_led = led; cycles_since_toggle = 0; toggles = toggles + 1; end end
if (toggles != 4) begin $display("ERROR: timeout, observed only %0d toggles", toggles); errors = errors + 1; end
if (errors == 0) $display("PASS: four LED toggles have correct intervals"); else $display("FAIL: %0d errors", errors);
$finish; end
endmodule为什么在 negedge clk 检查?DUT 在正边沿更新,等到负边沿时输出已经稳定,能避免 testbench 与非阻塞赋值更新发生竞态。为什么还要限制 observed_cycles?如果 DUT 永远不翻转,无上限的 while 会让回归永久挂起。
这个 testbench 会自动打印 PASS 或 FAIL。在纯 Verilog-2001 流程中,CI 脚本应把“出现唯一 PASS 且没有 ERROR/FAIL”作为成功条件,因为不同仿真器对 $finish 的进程退出码处理并不一致;使用 SystemVerilog 时可在失败分支改用 $fatal。
6. 在顶层实例化
跳转到“6. 在顶层实例化”module top ( input wire sys_clk, input wire sys_rst_n, output wire led0);
led_blinker #( .CLK_HZ (50_000_000), .BLINK_HZ (1) ) u_led_blinker ( .clk (sys_clk), .rst_n (sys_rst_n), .led (led0) );
endmodule顶层还需要与器件引脚约束配套:把 sys_clk 绑定到板载时钟引脚,声明正确时钟周期,把 led0 绑定到 LED 引脚并设置 I/O 电平标准。
7. 参数边界
跳转到“7. 参数边界”当前实现使用整数除法,并在仿真开始时检查非法参数。必须满足 BLINK_HZ > 0、CLK_HZ >= 2 * BLINK_HZ 和整除关系。若需要支持不能整除的频率,工程选择包括:
- 只允许整除,并在文档/仿真中检查;
- 使用相位累加器产生更准确的平均频率;
- 由上层直接传入半周期计数值;
- 使用脚本生成正确的计数参数。
初学项目先选第一种,重要的是把约束写清楚,而不是让错误静默发生。
8. 编码规范
跳转到“8. 编码规范”教材强调规范不是美观问题,而是协作、维护和复用的基础。建议形成下面的最小约定。
工程目录
跳转到“工程目录”project/├── doc/ 需求、接口、数据手册├── rtl/ 可综合 Verilog├── sim/ testbench 与仿真脚本├── constr/ 引脚与时序约束└── build/ 工具生成物,不手工维护文件与模块
跳转到“文件与模块”- 一个主要模块一个文件,文件名与模块名一致;
- 文件头写功能、接口假设、时钟/复位、参数限制和修改记录;
- 模块端口按时钟复位、输入、输出分组;
- 使用命名端口实例化;
- 顶层尽量只做连接和少量胶合逻辑。
信号命名
跳转到“信号命名”- 小写下划线:
data_valid; - 低有效后缀:
rst_n; - 参数大写:
COUNT_MAX; - 实例前缀:
u_led_blinker; - 同一信号跨层保持相近名称;
- 名称包含单位:
timeout_ms、clk_hz。
过程块
跳转到“过程块”- 组合块使用
always @(*)和阻塞赋值; - 时序块使用单一时钟和非阻塞赋值;
- 同一寄存器只由一个过程驱动;
if/case缩进一致,分支复杂时使用begin/end;- 关键常量不用裸数字,改用
parameter/localparam。
9. 从代码到开发板的检查顺序
跳转到“9. 从代码到开发板的检查顺序”- 代码检查:语法、位宽、驱动、锁存器;
- RTL 仿真:复位、边界、多个周期、自动 PASS;
- 综合:查看警告、资源、推导出的寄存器和锁存器;
- 实现:检查时序是否满足;
- 约束:确认时钟和引脚正确;
- 上板:从慢速可见现象开始验证;
- 修改后重新跑回归,不只看新波形。
10. 进阶练习
跳转到“10. 进阶练习”- 增加
enable:关闭时 LED 保持,计数器是否保持或清零由你定义并写入需求。 - 增加
tick输出:LED 每次翻转时产生一个时钟周期脉冲。 - 实例化两次闪烁器,让两个 LED 使用不同频率,观察“实例就是两份硬件”。
- 把频率参数改成不能整除的值,计算实际误差。
- 让 testbench 在仿真中途再次复位,并检查 LED 立即熄灭、计数重新开始。
完成这些练习后,你已经走完了 Verilog 基础的闭环:需求 → 电路模型 → RTL → testbench → 综合与时序 → 板级验证。