第 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 查找表面积较大,布线延迟略长。
- 硬件生成:综合器必须生成一个 24 位的数值比较器。比较器内部需要将
- 写法 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!
- 底层机理:我们在第 03 讲学过,
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 秒的节奏交替欢快跳动!
- 在计数器满溢的那一瞬时,LED 触发器对自己的当前值取反:
第三章:微观时序推演与真值滚动表
为了让你彻底看清这 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; }
深度避坑:你的板子到底是“给 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 颗灯全部死死长亮(或全灭),死活不闪烁
- 排查诊断:
- 检查时钟引脚
sys_clk是否约束错位:若把 R7 错写成了别的普通 I/O 脚,芯片内部加法器根本收不到 50MHz 心跳,整个电路处于“脑死亡”状态; - 检查复位引脚
sys_rst_n的物理按键极性:有的简陋核心板按键按下是高电平(Active-High),若依然写if (!sys_rst_n),板子上电后电路将永久处于复位状态,计数器永远被死死按在 0,灯自然一动不动!
- 检查时钟引脚
故障 2:灯闪烁极其微弱,像快没电的萤火虫
- 排查诊断:
检查 ADC 约束文件里的DRIVESTRENGTH(驱动电流)。如果不小心设成了2(2mA),驱动电流过小,贴片 LED 处于欠饱和工作区,就会出现这种半死不活的昏暗状态。将其改为8或12即可瞬间光芒四射。
故障 3:修改了位宽 reg [15:0] counter; 后,灯一直微亮不灭
- 排查诊断:
就是本讲第一章讲过的高频闪烁视觉暂留事故!16 位计数器闪烁频率高达 763Hz,虽然它在疯狂跳变,但你的视网膜把它自动滤成了一道连续的微光。必须老老实实开到 24 位宽!
课后思考与自测进阶题
为了检验你是否彻底融会贯通了闪烁灯的本质,请尝试在草稿纸上回答以下 2 道进阶题:
- 进阶题 1:如果我想把灯闪烁的速度加快一倍(从 0.33 秒翻转一次变成约 0.16 秒翻转一次),在不改动晶振频率的前提下,有哪两种最优雅的代码修改方式?
- 进阶题 2:在
always块里,如果把counter <= counter + 1'b1;误写成了阻塞赋值counter = counter + 1'b1;,在当前的简单设计中综合工具依然能生成电路,但为什么老工程师会视其为“大逆不道”的坏习惯?