FPGA安路赛项第五讲:led闪烁

第 05 讲:上板实验:LED 闪烁


课前导读:硬件工程师的“Hello World”

在软件学习中,每一个程序员写下的第一行代码几乎都是打印 printf("Hello, World!\n");
而在数字集成电路与 FPGA 的硬件世界里,所有工程师迈出的第一步,都是点亮并让一颗 LED(发光二极管)有节奏地闪烁

很多有 C 语言背景的初学者此时会轻敌:

“闪烁灯不就是写一个死循环,把灯点亮,然后调一个延时函数 sleep(1),再把灯关掉吗?”

大错特错!在真正的物理芯片里,根本不存在什么 sleep() 函数!

  • 软件里的 sleep(1) 是让操作系统把当前进程挂起、让 CPU 核心去干别的事或者空转指令周期;
  • 而在 FPGA 硅片里,晶体管没有‘打瞌睡’的机制。芯片通电后,内部的有源晶振就像一台疯狂敲击的蒸汽机节拍器,一秒钟敲击 50,000,000 次(50MHz),永远不知疲倦地向前奔涌!

你无法让时钟停下来,你唯一能做的,是用逻辑门搭建一个计数器,把微观飞速的时间,层层折叠并放大为人眼宏观可感知的尺度。

本讲将带你深入显微镜下,解剖这个看似最简单、实则蕴含了数字电路核心设计范式的经典实验。


第一章:物理困境——人眼视觉极限与高频晶振的鸿沟

在动手写代码前,我们先算一笔严肃的物理账:

【微观世界:FPGA 晶振时钟】
 频率 f = 50 MHz = 50,000,000 Hz
 每一个脉冲周期 T = 1 / 50MHz = 0.00000002 秒 = 20 纳秒 (ns)
   ┌──┐  ┌──┐  ┌──┐  ┌──┐
 ──┘  └──┘  └──┘  └──┘  └── ... (1秒钟疯狂跳变 5000 万次!)
 
                           VS
 
【宏观世界:人类肉眼的生理极限】
 人类视网膜具备“视觉暂留(Persistence of Vision)”效应:
 * 当光闪烁频率高于 24 Hz (每次亮灭短于 40ms) 时,大脑无法分辨单次闪烁,画面平滑融合;
 * 当闪烁频率高达几百 Hz 时,人眼只会觉得灯是“常亮的”,根本看不到闪烁!
  • 矛盾核心:如果让 LED 随时钟跳变(每 20ns 变一次,频率 25MHz),人眼看过去这颗灯就像被施了定身法一样一直微弱长亮,闪烁现象彻底消失!
  • 破局之道:我们必须在芯片内部安排一个“数数机器(计数器)”。时钟每跳一下,它就默默记个数,等它数了几千万下、宏观时间过去了几百毫秒之后,才去动手翻转一次 LED 的电平!

第二章:led_top.v 完整源码与显微镜级逐行手术剖析

下面是我们在前面学习到的闪烁灯经典源码:

module led_top (
    input  wire       sys_clk,   // 外部 50MHz 系统主时钟输入
    input  wire       sys_rst_n, // 外部异步复位按键输入 (低电平 0 有效)
    output reg  [3:0] led        // 4 位寄存器输出,直驱板载 4 颗 LED
);
    reg [23:0] counter; // 24 位宽内部计数器

    always @(posedge sys_clk or negedge sys_rst_n) begin
        if (!sys_rst_n) begin
            counter <= 24'd0;
            led     <= 4'b0101;
        end else begin
            counter <= counter + 1'b1;
            if (&counter)
                led <= ~led;
        end
    end
endmodule

我们拿出语言手术刀,逐行透视每一句代码在硅片中生成的晶体管实体:


2.1 端口层:芯片的外部引脚(第 1 ~ 5 行)

module led_top (
    input  wire       sys_clk,
    input  wire       sys_rst_n,
    output reg  [3:0] led
);
  • input wire sys_clk
    • 对应 PCB 电路板上的晶振连接引脚。外部有源晶振通电后,持续往这根铜线上灌入 $3.3\text{V}$ 的 50MHz 方波脉冲。
  • input wire sys_rst_n
    • 外部物理轻触按键。平时没按时被内部弱上拉拉高为 1;按键按下接地被强行拉低为 0
  • output reg [3:0] led
    • 板载 4 颗贴片 LED 灯的控制总线。
    • 【敲黑板】:为什么端口处写的是 reg
      在第 03 讲我们讲过铁律:凡是在 always 块内部被赋值的信号,左侧(LHS)必须无条件声明为 reg 综合器看到这行代码,就会在芯片输出引脚的 IOB 驱动器前,准备好 4 个带使能端的 D 触发器。

2.2 内部抽屉:为什么计数器必须开到 24 位宽?(第 6 行)

    reg [23:0] counter;

很多同学问:“老师,我写 reg [15:0] counter; 行不行?写 reg [31:0] 行不行?”

我们用纯数学严谨推导:

  • 如果开 16 位宽([15:0]
    • 最大计数值:$2^{16} = 65,536$;
    • 跑满一次的时间:$65,536 \times 20\text{ns} \approx 1,310,720\text{ns} \approx 1.31\text{ms}$;
    • 对应闪烁频率:$f = \frac{1}{1.31\text{ms}} \approx \mathbf{763\text{ Hz}}$!
      结论:人眼极限才几十 Hz,763Hz 的超高频闪烁人眼完全分辨不出,实验彻底失败!
  • 如果开 24 位宽([23:0]
    • 最大计数值:$2^{24} = 16,777,216$;
    • 跑满一次的时间:$16,777,216 \times 20\text{ns} \approx 335,544,320\text{ns} \approx \mathbf{0.3355\text{ 秒}}$!
    • 对应翻转周期:每隔约 0.33 秒(约 3 分之 1 秒)电平反转一次;完整的“灭-亮-灭”周期为 $0.3355 \times 2 \approx \mathbf{0.67\text{ 秒}}$!
      结论:节奏清晰明快,人眼观察极其舒适,属于完美的工业演示频率!
  • 如果开 32 位宽([31:0]
    • 跑满一次需要 $2^{32} \times 20\text{ns} \approx 85.9\text{ 秒}$(将近一分半钟才动一下),不仅浪费硅片寄存器资源,上板测试时你会以为芯片死机了。

2.3 敏感列表:异步复位硬件电路拓扑(第 8 行)

    always @(posedge sys_clk or negedge sys_rst_n) begin

这短短一行,在硅片底层搭建出了极其精密的异步复位 D 触发器(D-FF with Asynchronous Reset)

                     D 触发器 (Flip-Flop)
                   +---------------------+
  计数器加法器 ───► | D                 Q | ───► 输出计数值
                   |                     |
  50MHz 时钟 ─────► | CLK (上升沿触发)     |
                   |                     |
  按键信号 ───────► | CLRN (异步低电平清零)|
                   +---------------------+
  • posedge sys_clk(时钟上升沿):每当时钟脉冲由 0 变 1 的瞬间(每隔 20ns 发生一次),触发器对输入端采样并更新输出。
  • negedge sys_rst_n(复位下降沿):只要有人按下了复位键(电平由 1 跌落成 0),它享有凌驾于时钟之上的绝对霸权!不等时钟沿到来,甚至时钟线断线了,该信号也会瞬间通过物理清零端(CLRN)直接把所有内部触发器强行归零!

2.4 复位动作:确立开机第一态(第 9 ~ 12 行)

        if (!sys_rst_n) begin
            counter <= 24'd0;
            led     <= 4'b0101;
        end
  • if (!sys_rst_n)
    • 因为 sys_rst_n 是低电平有效,当按键按下时其值为 0。逻辑非 !0 得到 1(真),条件成立!
  • 初始化数值设置
    • counter <= 24'd0;:清空秒表,所有 24 根计数器导线电平瞬间全拉到地($0\text{V}$)。
    • led <= 4'b0101;:4 个 LED 状态设为 0101(交替排列:灭、亮、灭、亮)。

2.5 核心计数累加:加法器的生成(第 13 ~ 14 行)

        end else begin
            counter <= counter + 1'b1;
  • 硬件电路真相
    这行代码告诉综合器:“请给我用硅片内部的微型进位链(Carry Chain)和查找表,级联出一个 24 位二进制纹波加法器!”
  • 自然溢出机制(Rollover)
    当时钟一拍一拍打过来,计数器从 0 累加到最大值 24'hFF_FFFF(全是 1)后,在下一个上升沿,如果再加 1,最高进位会被硬件自动丢弃(截断),计数器无须任何额外的代码,就会非常自然地回滚归零:24'd0,周而复始!

2.6 全代码最神的一行:if (&counter)(第 15 ~ 16 行)

            if (&counter)
                led <= ~led;

很多同学问:“老师,这里为什么写一个单独的 &?不是应该写 counter == 24'd16777215 吗?”

这是区分硬件高手软件外行的经典标杆写法!

方案对比:&counter vs counter == 24'd16777215

  • 写法 A(常规比较器)if (counter == 24'd16777215)
    • 硬件生成:综合器必须生成一个 24 位的数值比较器。比较器内部需要将 counter 的每一位与硬编码常量 1111...1111 逐位做同或(XNOR)运算,再把 24 个同或门的结果汇聚进与门树。占用的 LUT 查找表面积较大,布线延迟略长。
  • 写法 B(高手写法:一元缩减与)if (&counter)
    • 底层机理:我们在第 03 讲学过,& 作用在单一向量前面时,代表把内部所有位依次串联做与运算
      $$\text{counter}[23] \ &\ \text{counter}[22] \ &\ \text{counter}[21] \ &\ \dots \ &\ \text{counter}[0]$$
    • 硬件生成:综合器直接调用底层现成的超快速进位逻辑,把这 24 根线引申进一个纯粹的多输入超大与门
    • 逻辑效果:只要 24 位里有哪怕 1 位是 0,与门输出就是 0;唯有全部 24 位全部爬升到 1(即 24'hFF_FFFF)的那唯一一个时钟周期(持续 20ns),与门瞬间吐出高电平 1
counter[0]  ──┐
counter[1]  ──┤
counter[2]  ──┤
 ...          ├──►【超大 24 输入与门】──► 输出翻转使能信号 (持续仅 20ns)
counter[22] ──┤                                    │
counter[23] ──┘                                    ▼
                                            [ 4 位 LED 寄存器 ]
                                               led <= ~led;
  • led <= ~led; 的翻转轨迹
    • 在计数器满溢的那一瞬时,LED 触发器对自己的当前值取反:
      • 第一次满溢:4'b0101 取反 $\to$ 4'b1010(原来亮的灭掉,原来灭的亮起!);
      • 第二次满溢:4'b1010 取反 $\to$ 4'b0101(再次翻转回原样!)。
    • 宏观物理现象:板载 4 颗灯分为两组,两亮两灭、两灭两亮,以 0.33 秒的节奏交替欢快跳动!

第三章:微观时序推演与真值滚动表

为了让你彻底看清这 20ns 之间发生的微观物理事件,我们把时间放大到极致,截取计数器即将跑满的最后几个时钟周期:

宏观时间流速 内部计数器 counter 十六进制 counter 的微观二进制形态 缩减与 &counter 输出 LED 当前寄存器输出 led[3:0] 物理现象描述
第 N 拍 24'hFF_FFFD 1111 ... 1111 1101 0 4'b0101 还有 2 拍满溢,灯保持不变
第 N+1拍 24'hFF_FFFE 1111 ... 1111 1110 0 4'b0101 还有 1 拍满溢,灯保持不变
第 N+2 拍
(关键奇迹瞬间!)
24'hFF_FFFF 1111 ... 1111 1111
(所有位全部为 1!)
1
(与门瞬间导通!)
4'b0101 满溢脉冲生成!
LED 触发器采样到翻转指令准备动身
第 N+3拍 24'h00_0000
(自然溢出清零!)
0000 ... 0000 0000 0
(与门瞬间切断关闭)
4'b1010
(完成物理翻转!)
计数器重新起跑,
LED 状态正式改写!灯珠亮灭互换!
第 N+4 拍 24'h00_0001 0000 ... 0000 0001 0 4'b1010 新的 1677 万次马拉松起跑,灯维持新态

第四章:物理约束与板载电气极性之谜(共阴 vs 共阳)

把上述代码放入工程后,必须在 ADC 约束文件中将逻辑端口与物理引脚绑定:

set_pin_assignment { sys_clk }   { LOCATION = R7;  IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { sys_rst_n } { LOCATION = A2;  IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { led[0] }    { LOCATION = A4;  IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { led[1] }    { LOCATION = A3;  IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { led[2] }    { LOCATION = C10; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { led[3] }    { LOCATION = B12; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }

:light_bulb: 深度避坑:你的板子到底是“给 1 亮”还是“给 0 亮”?

很多同学烧录后发现:“老师,你的代码初始是 0101,为什么我的板子上亮的灯正好和你反过来了?”

这取决于你手中开发板的硬件电路原理图设计

【接法 A:共阴极电路 (高电平点亮,Active-High)】
 FPGA 引脚 ─────►| (LED二极管正极向内) ─────[ 限流电阻 ]─────► GND (地 0V)
 * 当引脚输出 1 (3.3V) 时:二极管正偏导通,电流灌出,灯亮!
 * 当引脚输出 0 (0V) 时:无电势差,灯灭!
 
【接法 B:共阳极电路 (低电平点亮,Active-Low —— 工业开发板最常用!)】
 VCC (3.3V电源) ─────[ 限流电阻 ]─────►| (LED二极管负极向内) ─────► FPGA 引脚
 * 当引脚输出 0 (0V) 时:形成 3.3V 压降,电流被吸入芯片(灌电流模式),灯亮!
 * 当引脚输出 1 (3.3V) 时:两端都是 3.3V,无压降,灯灭!
  • 结论:若你的开发板是共阳极(低电平点亮),代码输出 0101 时,物理上亮起的反而是第 1、3 号灯(因为 0 亮 1 灭)。但这完全不影响翻转闪烁的逻辑本质!

第五章:实验室经典“踩坑翻车”实录与排错指南

在真实的大学实验室或公司研发中,这个最简单的闪烁实验每年都会产生大量“离奇故障”,请对照自查:

故障 1:烧录后 4 颗灯全部死死长亮(或全灭),死活不闪烁

  • 排查诊断
    1. 检查时钟引脚 sys_clk 是否约束错位:若把 R7 错写成了别的普通 I/O 脚,芯片内部加法器根本收不到 50MHz 心跳,整个电路处于“脑死亡”状态;
    2. 检查复位引脚 sys_rst_n 的物理按键极性:有的简陋核心板按键按下是高电平(Active-High),若依然写 if (!sys_rst_n),板子上电后电路将永久处于复位状态,计数器永远被死死按在 0,灯自然一动不动!

故障 2:灯闪烁极其微弱,像快没电的萤火虫

  • 排查诊断
    检查 ADC 约束文件里的 DRIVESTRENGTH(驱动电流)。如果不小心设成了 2(2mA),驱动电流过小,贴片 LED 处于欠饱和工作区,就会出现这种半死不活的昏暗状态。将其改为 812 即可瞬间光芒四射。

故障 3:修改了位宽 reg [15:0] counter; 后,灯一直微亮不灭

  • 排查诊断
    就是本讲第一章讲过的高频闪烁视觉暂留事故!16 位计数器闪烁频率高达 763Hz,虽然它在疯狂跳变,但你的视网膜把它自动滤成了一道连续的微光。必须老老实实开到 24 位宽!

课后思考与自测进阶题

为了检验你是否彻底融会贯通了闪烁灯的本质,请尝试在草稿纸上回答以下 2 道进阶题:

  1. 进阶题 1:如果我想把灯闪烁的速度加快一倍(从 0.33 秒翻转一次变成约 0.16 秒翻转一次),在不改动晶振频率的前提下,有哪两种最优雅的代码修改方式?
  2. 进阶题 2:在 always 块里,如果把 counter <= counter + 1'b1; 误写成了阻塞赋值 counter = counter + 1'b1;,在当前的简单设计中综合工具依然能生成电路,但为什么老工程师会视其为“大逆不道”的坏习惯?