第十九章:按键控制LED实验
本章对应原书第 398~415 页,属于「第五篇 实战篇」。
本章在讲什么?(先看这个)
从这一章开始进入实战篇:用按键控制 LED 的闪烁方式。之前流水灯实验里 LED 只会自己闪,这一章要让人参与进来——按不同的按键,LED 做不同的动作。这一章你会学到轻触按键的硬件原理(按下是低电平还是高电平?为什么?)、Verilog 里非常重要的 case 多分支语句,以及一个很实用的技能:把程序固化到 Flash 或 SD 卡里,让开发板一上电就自动运行你的程序,不用每次都打开 Vivado 重新烧录。
核心概念拆解
19.1 按键简介
按键本质上就是一个电子开关。开发板上常用的是两种:轻触式按键和自锁按键。
轻触式按键(轻触开关):往下一按,内部金属弹片受力变形,电路接通;一松手,弹片弹回,电路断开。特点是"按住才通,松开就断",我们平时用的鼠标、键盘按键基本都是这种。

自锁按键:第一次按下后开关保持接通(像被"锁住"了一样),再按一次才断开,同时按钮弹出来。老式台式机的电源开关就是这种。

19.2 硬件设计
开发板底板右下侧板载了 5 个轻触按键:最左边是 PL 端复位键,接下来两个(PL_KEY0、PL_KEY1)接 ZYNQ 的 PL 引脚,最右边两个接 PS 引脚。本书只使用 PL 端外设,所以本章用 PL_KEY0 和 PL_KEY1。

按键电路原理图如下。注意每个按键都串联了一个 10K 电阻,作用是限流——防止按键按下时电源直接对地短路。

按键的电平逻辑是本章的关键:按键未按下时引脚为高电平(1),按下时为低电平(0)。记住这个约定,后面代码里所有判断都基于它。
19.3 实验任务
任务描述:
- 没有按键按下时,两个 LED(PL_LED0、PL_LED1)保持常灭;
- 按下 PL_KEY0 时,两个 LED 交替闪烁(一个亮一个灭,轮流来);
- 按下 PL_KEY1 时,两个 LED 同时闪烁(一起亮、一起灭)。
闪烁间隔都是 0.5 秒。
19.4 程序设计
整体思路
设计只有一个模块 key_led,输入系统时钟(50MHz)、复位、两位按键 key,输出两位 LED led。内部需要三个部件:
- 一个计数器,计时 0.5 秒(50MHz 时钟下计 2500_0000 个周期);
- 一个 LED 控制信号
led_flag,每 0.5 秒翻转一次(周期 1 秒); - 一个 case 语句,根据按键状态选择 LED 的显示模式。
系统框图如下:

先把波形图想清楚。两个 LED 只有 4 种状态组合:
2'b11:两个都亮;2'b00:两个都灭;2'b01:LED0 亮 LED1 灭;2'b10:LED0 灭 LED1 亮。
交替闪烁就是在 2'b01 和 2'b10 之间每 0.5 秒切换一次;同时闪烁就是在 2'b11 和 2'b00 之间切换。谁来负责切换?就是每 0.5 秒翻转一次的 led_flag。

Verilog 代码:认识 case 语句
这一章的新语法是 case 语句。它的角色和 C 语言的 switch-case 一样,专门处理"多分支选择"。两个按键有 4 种状态组合(2'b11、2'b10、2'b01、2'b00),如果用 if-else 一层层嵌套会写得很啰嗦,用 case 就很清爽:
case(key) // 根据key的值选择分支
2'b10 : // 按键0被按下,两个LED交替闪烁
if(led_flag == 1'b0)
led <= 2'b01;
else
led <= 2'b10;
2'b01 : // 按键1被按下,两个LED同时亮灭
if(led_flag == 1'b0)
led <= 2'b11;
else
led <= 2'b00;
2'b11 : // 都没按下,LED保持长灭
led <= 2'b00;
default: ; // 其余情况(如两键同时按下)不做处理
endcase
case 的执行规则:从上往下依次比较各分支表达式和控制表达式(这里是 key)是否相等,相等就执行对应语句然后跳出;全都不等就执行 default 分支。default 建议永远写上,这样组合逻辑不会产生锁存器。
完整模块代码:
module key_led(
input sys_clk , //系统时钟
input sys_rst_n, //系统复位,低电平有效
input [1:0] key , //按键
output reg [1:0] led //LED灯
);
parameter CNT_MAX = 25'd2500_0000; //LED灯闪烁频率(0.5s)
reg [24:0] cnt; //计数器
reg led_flag; //LED控制信号
//计数器计时0.5s
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
cnt <= 25'd0;
else if(cnt < (CNT_MAX - 25'd1))
cnt <= cnt + 25'd1;
else
cnt <= 25'd0;
end
//每隔500ms翻转一次led_flag
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
led_flag <= 1'b0;
else if(cnt == (CNT_MAX - 25'd1))
led_flag <= ~led_flag;
end
//根据按键状态与led_flag切换LED显示方式
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
led <= 2'b00;
else case(key)
2'b10 : if(!led_flag) led <= 2'b01; else led <= 2'b10;
2'b01 : if(!led_flag) led <= 2'b11; else led <= 2'b00;
2'b11 : led <= 2'b00;
default: ;
endcase
end
endmodule
三段 always 各司其职:第一段做 0.5 秒计时;第二段每 0.5 秒翻转 led_flag;第三段用 case 根据按键状态输出 LED。这就是典型的"计数器 + 状态切换 + 输出选择"三段式写法,后面很多实验都会沿用。
仿真验证
Testbench 里把 key 依次赋值为 2'b11(都松开)→ 2'b10(按下 KEY0)→ 2'b11 → 2'b01(按下 KEY1),模拟真实按键操作:
initial begin
sys_clk <= 1'b0;
sys_rst_n <= 1'b0;
key <= 2'b11; //未按下时为高电平
#200
sys_rst_n <= 1'b1;
#1000
key <= 2'b10; //按下KEY0
#2000
key <= 2'b11;
#1000
key <= 2'b01; //按下KEY1
end
有个小技巧值得注意:0.5 秒的延时对仿真来说太长了(仿真 0.5 秒可能要等很久),所以仿真时把参数 CNT_MAX 临时改成 25'd25(即 500ns),跑完仿真再改回 2500_0000 再上板,否则 500ns 的闪烁人眼根本看不见。

仿真波形显示:key=2'b11 时 led=2'b00(常灭);key=2'b10 时 led 在 2'b01/2'b10 间交替;key=2'b01 时 led 在 2'b11/2'b00 间同时亮灭,与设计一致。
19.5 下载验证
引脚约束:本实验共约束 6 个引脚(时钟和复位在核心板,按键和 LED 在底板):
| 信号名 | 方向 | 管脚 | 说明 |
|---|---|---|---|
| sys_clk | input | U18 | 系统时钟 50MHz |
| sys_rst_n | input | N16 | PL复位按键,低有效 |
| key[0] | input | L14 | PL按键0 |
| key[1] | input | K16 | PL按键1 |
| led[0] | output | H15 | PL LED0 |
| led[1] | output | L15 | PL LED1 |
对应的 XDC 约束:
create_clock -period 20.000 -name sys_clk [get_ports sys_clk]
set_property -dict {PACKAGE_PIN U18 IOSTANDARD LVCMOS33} [get_ports sys_clk]
set_property -dict {PACKAGE_PIN N16 IOSTANDARD LVCMOS33} [get_ports sys_rst_n]
set_property -dict {PACKAGE_PIN L14 IOSTANDARD LVCMOS33} [get_ports {key[0]}]
set_property -dict {PACKAGE_PIN K16 IOSTANDARD LVCMOS33} [get_ports {key[1]}]
set_property -dict {PACKAGE_PIN H15 IOSTANDARD LVCMOS33} [get_ports {led[0]}]
set_property -dict {PACKAGE_PIN L15 IOSTANDARD LVCMOS33} [get_ports {led[1]}]
下载 bit 文件后:两个 LED 常灭;按住 PL_KEY0,两灯交替闪烁;按住 PL_KEY1,两灯同时闪烁。

19.6 程序固化
bit 文件是烧到 FPGA 里的,掉电即丢失——每次重新上电都得用 Vivado 再烧一次。这一节教你怎么把程序固化到开发板的 Flash 或 SD 卡里。
有个特别的地方要理解:哪怕你的工程只用了 PL 端资源,固化时也需要引入 ARM 硬核(ZYNQ 的启动流程由 ARM 负责引导),所以官方流程需要在 Vitis 软件里完成。为了降低门槛,配套资料提供了一个"纯 PL 端固化脚本",包含三个文件:
bootbin.bif:引导镜像文件,指定用哪些文件生成 BOOT.bin;create_bootbin.bat:批处理文件,调用 Vitis 的 bootgen(生成镜像)和 program_flash(烧写)功能;zynq_fsbl.elf:固化的初始化文件(7010 和 7020 核心板因 DDR 型号不同,文件有差异)。
使用步骤:
- 用文本编辑器打开
create_bootbin.bat,把里面 Vitis 安装路径下 bin 文件夹的路径替换成自己电脑的实际路径; - 把工程生成的 bit 文件复制到固化脚本文件夹里;
- 打开
bootbin.bif,第 4 行指定比特流文件名(所有文件在同一文件夹,直接写文件名即可); - 开发板 BOOT_CFG 拨到 JTAG 模式(1、2 都拨 ON),上电后双击运行
create_bootbin.bat。

看到 Bootimage generated successfully 说明 BOOT.bin 生成成功;看到 Flash Operation Successful 说明固化完成。(界面卡顿超过一两分钟时按一下回车键可继续运行。)
QSPI 启动:固化完成后先断电,把 BOOT_CFG 拨到 QSPI 模式(1 拨 ON、2 拨 OFF),再上电,板子就会自动运行 Flash 里的程序。

SD 卡启动:也可以把 BOOT.bin 拷到 SD 卡(需先格式化为 FAT32),插入开发板 SD 卡槽,把 BOOT_CFG 拨到 SD CARD 模式(1、2 都拨 OFF),上电即可从 SD 卡启动。
初学者容易踩的坑
- 按键电平记反:本章按键未按下是高电平、按下是低电平。如果代码里判断写成"按下为高",现象会完全反掉。
- 仿真完忘了改回参数:仿真时把 CNT_MAX 改成了 25,如果直接拿去上板,LED 闪烁间隔只有 500ns,肉眼看不到任何闪烁,会以为程序跑错了。
- case 漏写 default:不写 default,当出现未列举的输入组合时(比如两个键同时按下),综合器可能生成锁存器,埋下时序隐患。
- 计数器位宽不够:0.5 秒需要数到 2500_0000,超过了 2^24(约 1677 万),所以计数器必须是 25 位。位宽不够会导致计数器永远到不了最大值。
- 固化后忘了拨启动模式:固化完成直接重启不会运行新程序,必须先断电、把 BOOT_CFG 拨到 QSPI(或 SD 卡)模式再上电。
- 固化脚本路径没改:create_bootbin.bat 里的 Vitis 路径是作者电脑的,必须替换成自己机器的实际安装路径,否则批处理找不到 bootgen 工具。
小结 & 下一步
本章学会了按键的硬件原理(按下为低电平)、case 多分支语句的用法,以及一个完整的"输入控制输出"设计流程,还掌握了把程序固化到 QSPI Flash / SD 卡的方法——从此板子可以脱机运行你的设计。下一步可以做拓展训练:把例程改成"按键长按 20ms 才算有效按下"——这正是按键消抖的雏形,下一章会正式讲解按键消抖和蜂鸣器驱动。