跳转到内容
新建笔记

10. 综合项目:可配置 LED 闪烁器与编码规范

这一篇把前面的知识组合成一个能仿真、能综合、能复用的小项目。目标不是“点亮 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?一个完整闪烁周期包含“亮半周期 + 灭半周期”,每半周期翻转一次。

若开发板时钟为 50 MHz,希望 LED 每秒完成一次亮灭:

HALF_PERIOD = 50_000_000 / (2 × 1) = 25_000_000 个时钟

计数器从 0 数到 HALF_PERIOD - 1,恰好经历 25,000,000 个时钟。最大值小于 2²⁵,因此需要 25 位。

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 自然保持。

仿真没必要真的等待 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。

`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。

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 电平标准。

当前实现使用整数除法,并在仿真开始时检查非法参数。必须满足 BLINK_HZ > 0、CLK_HZ >= 2 * BLINK_HZ 和整除关系。若需要支持不能整除的频率,工程选择包括:

  • 只允许整除,并在文档/仿真中检查;
  • 使用相位累加器产生更准确的平均频率;
  • 由上层直接传入半周期计数值;
  • 使用脚本生成正确的计数参数。

初学项目先选第一种,重要的是把约束写清楚,而不是让错误静默发生。

教材强调规范不是美观问题,而是协作、维护和复用的基础。建议形成下面的最小约定。

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. 从代码到开发板的检查顺序”
  1. 代码检查:语法、位宽、驱动、锁存器;
  2. RTL 仿真:复位、边界、多个周期、自动 PASS;
  3. 综合:查看警告、资源、推导出的寄存器和锁存器;
  4. 实现:检查时序是否满足;
  5. 约束:确认时钟和引脚正确;
  6. 上板:从慢速可见现象开始验证;
  7. 修改后重新跑回归,不只看新波形。
  1. 增加 enable:关闭时 LED 保持,计数器是否保持或清零由你定义并写入需求。
  2. 增加 tick 输出:LED 每次翻转时产生一个时钟周期脉冲。
  3. 实例化两次闪烁器,让两个 LED 使用不同频率,观察“实例就是两份硬件”。
  4. 把频率参数改成不能整除的值,计算实际误差。
  5. 让 testbench 在仿真中途再次复位,并检查 LED 立即熄灭、计数重新开始。

完成这些练习后,你已经走完了 Verilog 基础的闭环:需求 → 电路模型 → RTL → testbench → 综合与时序 → 板级验证。

上一篇:状态机与模块化 · 返回系列目录