这一篇在干嘛?
每个编程语言都有 Hello World,FPGA 世界的 Hello World 就是点灯,而流水灯是点灯的进阶版。本实验用开发板外接的流水灯扩展板,实现一个双向流动的流水灯:从右往左流到头,再从左往右流回来,如此往复。代码只有二十几行,却浓缩了分频、移位、方向翻转三个最常用的套路。
实验目的
使用 H4S20 开发板上接流水灯和交通灯扩展板,实现一个双向流水灯:从右往左流动,到最左边时再从左边往右流动,然后又从右边开始流动,如此不断反复。
实验原理:点亮一颗 LED 需要什么电平
根据开发板的原理图,可得到以下资料:

根据硬件原理图所示,12 颗发光二极管的阳极全部接通 3.3V 正电压(高电平),所以要让发光二极管导通,需要在阴极接低电平——也就是说 FPGA 引脚输出 0 才能点亮 LED,输出 1 是熄灭。这种接法叫低电平有效(低点亮)。
led 信号里 0 才是亮
看代码时千万不要把「某一位为 1」理解成「对应的灯亮」。在本实验的硬件接法下,
led[x] = 0才点亮第 x 颗灯。复位初值12'b111111111110里唯一的 0 在最低位,对应最先亮起的 D1。
有了「点亮」这个基本动作,流水灯的流动就变成两件事:
- 走一步:让唯一的 0 在 12 位中向左或向右移动一位——用循环移位实现;
- 走多快、往哪走:用分频计数器控制「多久走一步」,用一个方向标志 flag 控制「往哪走」。
两个关键寄存器
cnt[25:0]:对 50MHz 系统时钟计数,从 0 数到 max(9_999_999)后归零,正好约 0.2 秒,作为流水灯的「步进节拍」。这就是最简单的分频思想:计数器数满一圈产生一个节拍。flag:方向标志。当 0 移到最高位(12'b011111111111,即第 11 位亮)时置 1,改向右移;当 0 移到最低位(12'b111111111110,即第 0 位亮)时清 0,改向左移。
循环移位是本实验的点睛之笔:{led[10:0], led[11]} 把最高位绕回最低位实现整体左移一位;{led[0], led[11:1]} 把最低位绕回最高位实现整体右移一位。因为 12 位里只有一个 0,移位后看起来就是「灯在流动」。
实验程序
module liushui_flag(
input sclk
input rst_n
output reg [11:0] led
);
reg [25:0] cntparameter max = 9_999_999 ; always@(posedge sclk or negedge rst_n) if(!rst_n)
cnt <= 0 ;
else if(cnt==max)
cnt <= 0 ;
else
cnt <= cnt+1'b1 ;
reg flag ;
always@(posedge sclk or negedge rst_n) if(!rst_n)
flag <= 0 ;
else if(led==12'b011111111111)
flag <= 1 ;
else if(led==12'b111111111110)
flag <= 0 ;
always@(posedge sclk or negedge rst_n) if(!rst_n)
led <= 12'b111111111110 ;
else if(cnt==max&&flag==0)
led <= {led[10:0],led[11]} ;
else if(cnt==max&&flag==1)
led <= {led[0],led[11:1]} ;
endmodule拼接运算符:循环移位的关键
循环移位是本实验最值得吃透的语法点。Verilog 的花括号 {} 是拼接运算符,它把若干段位拼成一个新的位向量:
led <= {led[10:0], led[11]}:取 led 的低 11 位(bit10~bit0),再把原来的最高位 bit11 拼到最低——整体效果是「所有位向左挪一格,最左边的位绕回最右边」,即循环左移一位;led <= {led[0], led[11:1]}:取原来的最低位 bit0 拼到最高,其余位右移一格,即循环右移一位。
为什么不用左移运算符 << 或 <<=?因为普通移位会把移出去的位丢弃、空出的位补 0,移 12 次后 led 就变成全 0、灯全灭了;循环移位则让那个唯一的 0 永远在 12 位里「绕圈」,这正是流水灯「走到头又回来」的物理实现。方向切换时两种移位无缝衔接,因为那个 0 总是先流到边界、flag 才翻转。
节拍是怎么算出来的
系统时钟 sclk 为 50MHz,周期 20ns。cnt 从 0 数到 9_999_999 共 10_000_000 拍,一次计数循环耗时 10_000_000 × 20ns = 0.2s。也就是说流水灯每 0.2 秒走一步,从 D1 流到 D12 需要 12 × 0.2 = 2.4 秒,一个完整的「去 + 回」周期约 4.8 秒——这正是人眼看起来「流动感」合适的速度。如果想调节速度,只需要改 max 参数:max 越大走得越慢,max 越小走得越快。
三个 always 块各管一件事
第一块管「节拍」:cnt 数满一圈清零;第二块管「方向」:灯走到两端时翻转 flag;第三块管「走步」:节拍到来时按 flag 决定左移还是右移。这种「每个 always 只做一件事」的写法是时序逻辑设计的良好习惯,比把所有逻辑塞进一个大 always 里清晰得多。复位采用低有效的异步复位
negedge rst_n,复位时从最低位灯开始流。
实验仿真
根据描述语言进行仿真测试,分析波形。仿真时可以把 max 参数改小(比如改成 9),缩短 cnt 计数周期,这样不用等 0.2 秒就能在波形里看到 led 循环移位与 flag 翻转的完整过程。
观察波形时建议盯住三个信号:
- cnt:锯齿波,从 0 爬升到 max 后瞬间归零,每个「锯齿」就是流水灯的一步;
- led:每次 cnt 归零的同一拍变化一次,看那个 0 是在左移还是右移;
- flag:只在 led 等于
12'b011111111111或12'b111111111110的瞬间翻转,其余时间保持。
三个信号的时间关系对上了,说明节拍、走步、换向三个环节都正确,可以放心上板。
上板流程
- 确认流水灯扩展板已接插在开发板 J1 口上;
- 在 TD 工程中完成综合、布局布线,生成下载文件;
- 通过 USB 下载线把码流写入 FPGA;
- 下载完成后无需额外操作,流水灯即刻开始流动。
本实验没有单独的引脚约束段
原文未给出 set_pin_assignment 引脚约束清单,因为 12 颗流水灯全部位于 J1 扩展口上,引脚分配随扩展板硬件固定。实际工程中请按开发板《用户手册》中 J1 扩展口的 LED 对应引脚添加约束,或直接复用工程模板中已有的约束文件。
代码里省略了分号与逗号
原文给出的模块端口声明(
input sclk、input rst_n之间)缺少逗号,reg [25:0] cnt行尾缺少分号,这是教材排版省略所致。实际输入工程时必须补全:端口间用逗号分隔、每条声明以分号结尾,否则综合无法通过。
思路延伸:改出你自己的流水灯
理解模板之后,这个实验是最好的练手场,几个由易到难的改造方向:
- 改变速度:修改 max 参数。想做成「可调速度」,可以再加一个按键控制 max 在几档之间切换;
- 改变花样:把「单 0 绕圈」改成两个 0 相向而行(复位初值给两位 0),只需要改初值并小心处理两个 0 相遇时的移位逻辑;
- 来回不同速:给 flag 的两个状态分别用不同的计数终值 max_l / max_r,去程慢、回程快;
- 呼吸流水:结合呼吸灯实验的 PWM 思路,让「当前亮的那颗」亮度渐变——这需要引入脉宽调制,是不错的进阶挑战。
每一个改造都只动这一二十行代码里的某一块,不会牵一发动全身——这正是「节拍 + 数据 + 标志」三段式结构的可维护性优势。
下载测试
将流水灯模块接插在开发板 J1 口上。下载程序之后,可以看到流水灯从 D1 滚动到 D12,然后从 D12 滚动到 D1,循环上述步骤。

现象与总结
双向流水灯看似简单,却把三个最基础的 FPGA 套路串在了一起:计数分频(把 50MHz 时钟降到人眼可见的节拍)、循环移位(用拼接运算符让一个 0 在 12 位中绕圈)、边界检测翻转方向(到达两端时改变 flag)。整个设计没有用到任何高级语法,三个 always 块各司其职,是「小模块解决大问题」的典范。
后面的交通灯实验会把这个「计数 + 状态切换」的结构进一步升级成真正的状态机。掌握了本实验,你就拥有了写绝大多数「节拍驱动型」小逻辑的模板——呼吸灯是把节拍加到 PWM 占空比上,数码管动态扫描是把节拍加到位选切换上,蜂鸣器演奏是把节拍加到音符切换上,套路完全同源。
自测
自测
- 本实验硬件上 LED 的阳极和阴极分别接什么电平?FPGA 引脚输出 0 还是 1 才能点亮 LED?
- 参数 max = 9_999_999 配合 50MHz 时钟,流水灯每走一步大约多少秒?从 D1 流到 D12 再流回 D1 一个来回需要多久?
{led[10:0], led[11]}和{led[0], led[11:1]}分别实现什么操作?为什么不能用<<或>>普通移位代替?- 方向标志 flag 在什么条件下翻转?为什么翻转恰好发生在灯流到两端的时候?
- 复位后 led 的初值是什么?这个初值对应哪颗灯先亮?
- 如果想让流水速度加快一倍,应该修改代码中的哪个参数、改成多少?想改成 16 颗灯需要联动修改哪些地方?