第18章:流水灯实验
本章对应原书第 376~397 页,属于「第五篇 实战篇」。
本章在讲什么?(先看这个)
流水灯的地位堪比编程界的"Hello, World!"——对很多电子工程师来说,流水灯就是他们在硬件上看到的第一个实验现象。这个实验把前面学的知识全部串起来:计数器(第15章)做 0.5 秒延时,位拼接(第12章)实现 LED 移位,再补上一个新技能——时序约束。从本章开始进入实战篇,实验流程也更完整:硬件设计 → 程序设计 → 仿真 → 引脚约束与 RTL 视图 → 时序约束 → 上板验证。
核心概念拆解
LED 灯简介
LED,又名发光二极管。LED 灯工作电流很小(有的仅零点几毫安即可发光),抗冲击和抗震性能好,可靠性高、使用寿命长,因此被广泛用于仪器仪表指示灯、液晶屏背光源等领域。
不同材料的发光二极管可以发出红、橙、黄、绿、青、蓝、紫、白八种颜色的光。


直插型二极管长的一端是阳极,短的一端是阴极;贴片二极管正面有颜色标记,有标记的那端是阴极。
LED 与普通二极管一样具有单向导电性,不同颜色的正向导通电压不一样:红色和黄色约 2V,蓝色、绿色和白色约 3V(具体由生产厂家决定)。阳极加上正向电压后,通过 5mA 左右的电流就可以发光;电流越大亮度越强,但一般将电流限定在 3~20mA 之间,否则电流过大会烧坏二极管。
硬件设计
开发板底板板载 4 颗 LED,位于开发板右下侧:左边两个 LED 连接至 ZYNQ 的 PL 引脚,右边两个连接至 PS 引脚。


图中 PL_LED0 和 PL_LED1 连接到 ZYNQ 的 PL 端,PS_LED0 和 PS_LED1 连接到 PS 端(本书只使用 PL 端外设)。每个 LED 都串联了电阻,起限流作用,防止电流过大烧坏 LED。
关键结论:PL_LED0 和 PL_LED1 的阴极通过 470 欧姆电阻连到地(GND),阳极由 ZYNQ PL 的 IO 管脚控制——所以:
- IO 输出高电平 1 → 点亮 LED;
- IO 输出低电平 0 → 熄灭 LED。
实验任务
使用底板上的两个 PL LED 灯顺序点亮并熄灭,循环往复产生流水灯效果,流水间隔时间为 0.5s。
程序设计——模块设计
模块命名为 flow_led。0.5s 间隔需要计数器计时,所以需要系统时钟;复位也是必不可少的(程序跑飞时可以恢复默认状态)。

| 信号名 | 方向 | 端口说明 |
|---|---|---|
| sys_clk | 输入 | 系统时钟,50MHz |
| sys_rst_n | 输入 | 系统复位按键,低电平有效 |
| led | 输出 | PL_LED(底板) |
绘制波形图
流水灯是指同一时刻只有一个灯点亮,其余熄灭,每隔一段时间 LED 依次点亮(向右或向左流水)。底板只有两个 PL 端 LED,所以效果是两颗 LED 循环交替闪烁:

因为 IO 输出 1 点亮 LED,所以控制方法为:第一次给 led 赋值 2'b01(LED0 亮);等 0.5s 后赋值 2'b10(LED1 亮);再等 0.5s 后又回到 2'b01……循环往复。
延时怎么实现? 用计数器。系统时钟周期为 20ns(50MHz 晶振),计 0.5s 需要:
0.5s / 20ns = 500000000ns / 20ns = 25000000 个时钟周期
计数器从 0 开始计数,所以最大计到 25000000-1,刚好 0.5s。模块内部框图:

flow_led 模块的波形图:

cnt 循环从 0 计到 24999999 表示计时 0.5s;每当计到最大值时,切换一次 LED 状态。
编写代码
module flow_led(
input sys_clk , //系统时钟
input sys_rst_n, //系统复位,低电平有效
output reg [1:0] led //LED灯
);
//reg define
reg [24:0] cnt ; //计数器
//*****************************************************
//** main code
//*****************************************************
//计数器计时0.5s
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
cnt <= 25'd0;
else if(cnt < (25'd2500_0000 - 25'd1))
cnt <= cnt + 25'd1;
else
cnt <= 25'd0;
end
//对LED灯进行移位控制,以输出2位LED的状态
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
led <= 2'b01;
else if(cnt == (25'd2500_0000 - 25'd1))
led <= {led[0],led[1]};
else
led <= led;
end
endmodule
逐段解读:
- 计数器位宽的选择:cnt 最大计到 24999999,对应二进制
1 0111 1101 0111 1000 0011 1111,共需 25 位。信号需要多大位宽,由它能计数到的最大值决定:

- led 初始值:移位前必须给 led 赋初值 2'b01(或 2'b10)。如果赋 2'b00,无论怎么移位,led 始终全为 0;
- 移位的技巧:Verilog 移位运算符
<<(左移)和>>(右移)会在低位(左移)或高位(右移)补零,单纯移位最终 led 会全变 0。所以这里用位拼接{}实现循环移位的效果:
led <= {led[0],led[1]};
即 led[0] 移到最高位,但最低位不补零,而是接上原最高位 led[1] 的值——2'b01 变 2'b10,再变回 2'b01,循环不止。
仿真验证——编写 TB 文件
TestBench 验证三步走:加激励 → 例化 → 判断输出。sys_clk 对应 50MHz 晶振(周期 20ns),让信号每隔 10ns 翻转一次;sys_rst_n 初始为 0(复位),延时一段时间后拉高结束复位——正常运行前先复位一段时间,是为了给模块内的相关信号赋初值。
`timescale 1ns / 1ns //仿真单位/仿真精度
module tb_flow_led();
//parameter define
parameter CLK_PERIOD = 20; //时钟周期 20ns
//reg define
reg sys_clk;
reg sys_rst_n;
//wire define
wire [1:0] led;
//信号初始化
initial begin
sys_clk <= 1'b0;
sys_rst_n <= 1'b0;
#200
sys_rst_n <= 1'b1;
end
//产生时钟
always #(CLK_PERIOD/2) sys_clk = ~sys_clk;
//例化待测设计
flow_led u_flow_led(
.sys_clk (sys_clk),
.sys_rst_n (sys_rst_n),
.led (led)
);
endmodule
代码要点:第 6 行把时钟周期定义成参数形式,方便后续修改;第 24 行 sys_clk 每隔 10ns(CLK_PERIOD/2)翻转一次,一个完整周期含一个高电平和一个低电平,共 20ns,对应 50MHz。
仿真提速技巧:流水间隔是 0.5s,直接仿真要等很久(复杂程序仿真几十毫秒可能就要几个小时)。为了提升效率,仿真时可以临时修改延时较长的代码——把流水间隔改为 500ns(25×20ns):
// else if(cnt < (25'd2500_0000 - 25'd1))
else if(cnt < (25'd25 - 25'd1)) //改为计时500ns
//else if(cnt == (25'd2500_0000 - 25'd1))
else if(cnt == (25'd25 - 25'd1))
运行仿真 10us 后:

led=2'b01 持续 500ns,led=2'b10 同样持续 500ns,流水间隔与预期相符。
注意:仿真结束后要把流水间隔改回 0.5s——500ns 太短,人的肉眼看不到流水效果。
引脚约束与 RTL 视图
引脚约束:系统时钟、复位按键和两个 LED 的管脚分配如下(复位按键和两个 LED 在底板上,时钟在核心板上):
| 信号名 | 方向 | 管脚 | 端口说明 | 电平标准 |
|---|---|---|---|---|
| sys_clk | input | U18 | 系统时钟,50MHz | LVCMOS33 |
| sys_rst_n | input | N16 | 系统复位按键,低电平有效 | LVCMOS33 |
| led[0] | output | H15 | PL_LED0(底板) | LVCMOS33 |
| led[1] | output | L15 | PL_LED1(底板) | LVCMOS33 |
create_clock -period 20.000 -name sys_clk -waveform {0.000 10.000} [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 H15 IOSTANDARD LVCMOS33} [get_ports {led[0]}]
set_property -dict {PACKAGE_PIN L15 IOSTANDARD LVCMOS33} [get_ports {led[1]}]

RTL 视图:在 Flow Navigator → RTL ANALYSIS → Open Elaborated Design → Schematic 打开。

代码与电路的对应关系:RTL_ADD 是加法器,实现计数器累加;RTL_LT(Less Than)是比较器,判断 cnt 是否小于 25'h17D783F(即十进制 24999999);RTL_MUX 是选择器,根据比较结果选择输出 0 或 cnt 累加结果;RTL_REG_ASYNC 是异步复位寄存器;RTL_ROM 将 cnt 与 24999999 对比并输出结果。RTL 分析原理图只反映 RTL 层面,综合后的原理图(SYNTHESIS → Open Synthesized Design → Schematic)才能看到与 FPGA 内部资源的对应关系。
时序约束与上板验证
什么是时序约束:时序约束(Timing Constraints)是在数字电路设计中,为保证电路的正确性和稳定性,对电路中各时序参数(时钟频率、时钟占空比、时钟延迟与信号传输延迟等)进行限制和约束的方法。
举个直观的例子:没有加时序约束时,图片显示模糊和错乱:

添加时序约束后,画面显示正常:

对时钟约束最简单的理解:告诉 EDA 工具设计中使用的时钟频率是多少,工具才能按要求优化布局布线,使设计在该频率下正常工作。本次实验 sys_clk 为 50MHz(周期 20ns),约束时可以等于或略低于该值,不建议周期设置太小,否则布局布线很难满足要求。
对于简单设计,不加时序约束可能也不影响功能;但设计变复杂或时钟频率较高时,不加约束就可能出现意外情况。推荐无论简单还是复杂的实验都做时序约束。本文档只对输入时钟做周期约束,在 XDC 文件中添加:
create_clock -period 20.000 -name sys_clk [get_ports sys_clk]
create_clock 是命令名,用于创建一个时钟;-name sys_clk 指定时钟名称、-period 20 指定周期为 20ns、[get_ports sys_clk] 指定时钟源端口,一般只对输入的时钟做周期约束。
用 Vivado 图形界面添加时钟约束(避免手写命令出错):
- 先综合:点击 Flow Navigator 下的 Run Synthesis(约束前必须先综合):

- 综合完成后,点击 Flow Navigator 下的 Edit Timing Constraints:

- 在 Timing Constraints 界面点击"+"号添加时钟约束,弹出 Create Clock 界面:

各参数含义: - Clock name:时钟约束的名称,通常与被约束的时钟信号同名,这里命名为 sys_clk; - Source objects:指定被约束的时钟对象; - Waveform:设置时钟周期(Period)、上升沿(Rise at)、下降沿(Fall at)。50MHz 时钟(周期 20ns、占空比 50%)对应 Period=20、Rise at=0、Fall at=10。"Add this clock to the existing clock"只在同一约束源存在多个时钟频率且都需约束时才勾选,本例无需勾选; - Command:根据上述配置自动生成的约束命令,可直接复制到 XDC 文件使用。
- 指定时钟源对象:点击 Source objects 框后的"…",在弹出的 Specify Clock Source Objects 界面中,"Find names of type"选择 I/O Port(系统时钟来自 IO 口),点击 Find:

- 选中 sys_clk,点击"→"添加到右侧窗口,点击 Set,再点击 OK 完成添加:

- 保存约束:此时约束内容还没保存到文件中,点击左上角保存图标(或 Ctrl+S)。会弹出提示"保存当前约束文件会导致之前的综合分析过期",直接点击 OK;在 Save Constraints 界面默认保存到现有 XDC 文件,点击 OK 即可。
上板验证
编译工程生成比特流 .bit 文件后,点击 Flow Navigator 最下面的 Open Hardware Manager:

将 Xilinx 下载器一端连接电脑,另一端连接开发板上的 JTAG 下载口,再连接电源线:

注意!一定要先把下载器连接到电脑和 JTAG 接口之后,再给开发板上电!带电热插拔 JTAG 接口有一定概率会损坏接口。
打开电源开关,点击 Hardware 窗口中的 Auto Connect,出现连接成功界面后点击 Program Device,Bitstream File 会自动识别比特流文件,点击 Program 下载:

下载完成后,核心板上的 PL 配置完成灯(DONE 灯,绿色)点亮,底板上的两个 LED 呈现流水效果——不断交替闪烁:

本章总结
本章需要掌握三个要点:
1. LED 点亮原理:ZYNQ IO 输出高电平 1 点亮 LED,低电平 0 熄灭 LED;
2. 用计数器延时:0.5s = 25000000 个 20ns 时钟周期,计数器从 0 计到 24999999;
3. 用位拼接 {} 实现移位:led <= {led[0],led[1]} 实现循环移位,避免普通移位补零导致全灭。
另外记住一个实用技巧:程序中延时较长时(尤其程序复杂),仿真会非常久,可以在不影响观察有效波形的前提下临时降低延时时间,提高仿真效率——仿真完记得改回来。
图片对照
本章原文图片较多(30+ 张),已挑选最有教学价值的部分分散插入各小节:LED 硬件图、模块框图与波形图、位宽查看、仿真波形、引脚约束、RTL 原理图、时序约束操作步骤和上板效果图。
初学者容易踩的坑
- led 初值赋成 2'b00:全零怎么移位都是零,LED 永远不亮,必须赋 2'b01 或 2'b10 这样的非零初值。
- 直接用
<<移位:左移会在低位补零,2'b01 左移一次变 2'b10,再移一次就变 2'b00 全灭——循环移位要用位拼接。 - 计数器位宽不够:25000000 需要 25 位,位宽不够会导致计数溢出错误,延时不准。
- 仿真完忘记改回延时:把 500ns 的仿真参数下载到板子上,流水快到肉眼无法分辨,看起来像两灯常亮。
- 不做时序约束:简单实验可能侥幸没问题,但复杂设计或高频时钟下会出现显示错乱等问题,从现在开始养成每个工程都约束时钟的习惯。
- JTAG 带电插拔:先接好下载器再上电,避免损坏 JTAG 接口。
小结 & 下一步
本章完成了 FPGA 世界的"Hello World":掌握了 LED 点亮原理、计数器延时、位拼接移位三个核心技能,还学会了时序约束的两种添加方式(XDC 语句和 Vivado 图形界面)。建议动手拓展:把现有例程稍作修改,改成两个 LED 灯同闪同灭的效果,间隔时间 0.25s——想想同闪同灭时 led 应该赋什么值(2'b11 和 2'b00 交替),计数器又要计到多少。