简介:这份资料是西安电子科技大学微电子学院李振荣老师FPGA可编程逻辑器件课程的上机大作业报告,面向选修FPGA相关课程、需要完成实验报告或备战同类大作业的本科生与自学者。内容围绕Xilinx Basys3开发板展开,完整记录多模式LED发光控制器、多模式运动计时器以及液晶屏显示字符三个实验题目,涵盖设计思路、Verilog源代码、ModelSim仿真截图、Vivado综合网表、布局布线结果与器件资源利用率分析,并附有实验目的、器材说明与心得体会。压缩包内为1个PDF文件,约1018KB,将题目要求、原理分析、代码与验证过程整合成一份可直接参考的完整报告。目前已有154人学习浏览,适合刚接触FPGA开发流程、需要熟悉Vivado与ModelSim工具链、理清跑马灯模式控制、倒计时配置和约束文件编写思路的读者借鉴,便于对照梳理从代码设计到比特流下载的完整环节。
1. 从一份西电微电子上机大作业看 Basys3 上的三个 Verilog 实战题
Basys3 这块板子很多人当入门练手板,但真正把 16 位 LED、四位七段数码管、滑动开关和按键全部串起来做完整逻辑设计,坑比想象中多。这份西安电子科技大学微电子学院李振荣老师布置的 FPGA 上机大作业,给了三道题:多模式 LED 发光控制器、多模式运动计时器、液晶屏显示字符(选作),工具链是 Vivado 2014.4 加 ModelSim SE-64 10.4,芯片是 XC7A35T-1CPG236C。三道题表面看是跑马灯和计时器这种"玩具题",实际上覆盖了时钟分频、异步复位、状态编码、多位数码管动态扫描、管脚约束这几块 FPGA 入门必须过的坎。如果你正在做课程设计、准备 fpga 项目,或者想搞明白 fpga 实现数码管动态显示到底怎么落地,这三道题的思路可以直接抄。下面按设计原理、代码实现、约束综合、验证排错的顺序,把每一题的完整链路拆开讲。
2. 多模式 LED 控制器的速度分级与模式译码设计
2.1 用 100MHz 时钟做可视速度分级的本质
Basys3 板载 100MHz 晶振,也就是说 clk 每 10ns 翻转一次。如果直接拿这个时钟去驱动 LED 移位,人眼只能看到一片模糊,根本看不出跑马灯。所以核心问题就变成了:怎么把 100MHz 降到一个肉眼看得到变化的频率,而且还要分出四档速度。
原设计用了一个很典型的级联计数器思路:count1 从 0 数到 24 位全 1,也就是 2^24 - 1,跨越一次就驱动 count2 加一;count2 数到 2 时驱动 count3 加一,依此类推直到 count5。这样就形成了一串分频链,每往下一级速度就慢一截。24 位全 1 大约对应 16777216 个时钟周期,除以 100MHz 就是约 0.167 秒,所以 count2 每约 0.17 秒加一,依次往上时间成倍拉长。
这种"用高位计数器慢速进位"的做法在 fpga 入门里很常见,好处是不用写 PLL 或分频器 IP,纯逻辑就能搞定,缺点是它本质上还是组合逻辑比较加寄存器递增,占的 LUT 和 FF 不少。实际的计数阈值可以按需求改,比如想快一点就把 24 位全 1 换成 20 位或更小。
2.2 模式选择与速度选择的双 case 译码
模式选择用两位滑动开关 m1、m2 拼成 2'b00 到 2'b11,分别对应左循环、右循环、交叉闪烁、全亮全灭。速度选择用 v1、v2 拼出四档。原代码用了两个 case 语句,一个选速度、一个选模式:
// 速度选择:把对应档位的计数器赋给统一变量 count case({v1,v2}) 2'b00: count <= count2; // 最快 2'b01: count <= count3; 2'b10: count <= count4; 2'b11: count <= count5; // 最慢 endcase // 模式选择:每到阈值就改变 LED 状态 case({m1,m2}) 2'b00: if(count == 24'd2) led <= (led << 1); // 左循环 2'b01: if(count == 24'd2) led <= (led >> 1); // 右循环 2'b10: if(count == 24'd2) begin // 交叉闪烁 led <= 16'b1010_1010_1010_1010; #5 led <= 16'b0101_0101_0101_0101; end 2'b11: if(count == 24'd2) begin // 全亮全灭 led <= 16'b1111_1111_1111_1111; #5 led <= 16'b0000_0000_0000_0000; end endcase这里有几个点值得说清楚。第一,led << 1是左移盲跑马灯,移出去的位会丢掉,如果是从 0 移 1,最终会全部变 0。原代码初始化是复位时全亮,也就是 led 为 16 个 1,那左移时最高位丢失、最低位补 0,随着时间推移低位全变成 0,看起来就不像连续跑马灯了。更稳的写法是用循环移位{led[14:0], led[15]},让高位补到低位,形成真正的环形走马灯。
第二,阈值用的是24'd2,也就是统一变量 count 每次累加到 2 就动作一次,这是为了让灯的变化和所选档位的慢速计数器同步。因为 count 是直接跟 count2/count3 等绑定的,所以阈值设成 2 其实是在等每一档内部再分频两级。
提示:case 里用阻塞赋值
=而不是非阻塞<=,在时序 always 块里容易引起仿真和综合不一致,实际项目里建议统一改成<=。
2.3 异步复位的双 always 块写法及隐患
原代码用了两个 always 块,一个posedge clk or posedge rst(高电平复位),一个posedge clk or negedge rst。两个块的复位极性不一致,这是很典型的笔误。Basys3 按键按下输出高电平,所以复位统一用posedge rst才对。如果哪一路写成negedge rst,按下复位时那一路不会复位,count1 到 count5 停不下来,速度链就乱了。
正确的复位结构应该是:
always @(posedge clk or posedge rst) begin if (rst) {count1, count2, count3, count4, count5} <= 0; else begin count1 <= count1 + 1'b1; if (count1 == 24'hFFFFFF) begin count2 <= count2 + 1'b1; end if (count2 == 24'd2) begin count3 <= count3 + 1'b1; end if (count3 == 24'd2) begin count4 <= count4 + 1'b1; end if (count4 == 24'd2) begin count5 <= count5 + 1'b1; end end end把复位条件统一成高电平有效,并且把所有状态寄存器放进同一个复位分支,才是干净的写法。
3. 多模式运动计时器的位拼接与数码管动态扫描
3.1 8 位 q 变量如何压缩成 mm:ss
计时器最长的值是 59 分 59 秒,也就是 5959。如果用二进制存秒数,需要 13 位(最大 3599 秒),但要求数码管显示成 4 个十进制数字。原设计用了一个 8 位的 q 变量来同时存分和秒,格式是 q[7:6] 存分钟十位、q[5:4] 存分钟个位、q[3:2] 存秒十位、q[1:0] 存秒个位,每两位一个 BCD 数字。这样 4 个 2 位字段刚好拼成一个 8 位寄存器,直接连到数码管译码逻辑上就省事。
秒计数的进位逻辑是这样的:
// 每 1 秒(0.167s × 6 大约 1s,具体看 count 阈值)秒加一 q[1:0] <= q[1:0] + 1'b1; if (q[1:0] == 2'b11) begin q[1:0] <= 2'b00; q[3:2] <= q[3:2] + 1'b1; if (q[3:0] == 4'b1111) begin q[3:0] <= 4'b0000; q[5:4] <= q[5:4] + 1'b1; if (q[5:4] == 2'b11) begin q[5:4] <= 2'b00; q[7:6] <= q[7:6] + 1'b1; end end end这里 q 的每个 2 位字段到 2'b11 就清零,等价于十进制 9 加 1 归零,天然就是个十进制计数器。原代码里有个小问题:q[5:4] 到 2'b11(也就是 3)就清零了,但分钟的个位应该是 0 到 9 十个数,所以 2 位根本不够表示 0-9。原设计这里其实是用了十进制数的二进制表示,个位最大到 9。如果 q[5:4] 只有 2 位,最多到 3,显然不对。合理的做法是把分钟个位和秒个位各用 4 位 BCD 来存,或者干脆用二进制存总秒数,再在显示时做 BCD 转换。这是这份作业里一个值得推敲的点。
3.2 秒脉冲生成与倒计时初值装载
倒计时模式用 m=1'b0,初值从 15 位输入 t 装载。原代码里q[7:0] <= t[7:0]把 t 的低 8 位直接作为倒计时初值,但要求说 15 位滑动开关配置倒计时值,15 位足够表示 0 到 32767 秒,显然比 8 位 q 能表示的范围大得多。这意味着要么把 t 先做进制转换再塞进 q,要么 q 只用 8 位就够了(比如只配到 59:59 以内)。
// 倒计时模式:装载初值,然后每秒减一 1'b1: begin if (load) q <= t[7:0]; // 装载初值 if (count == 24'hFFFFFF) begin // 每秒触发 q[1:0] <= q[1:0] - 1'b1; if (q[1:0] == 2'b00) begin q[1:0] <= 2'b11; q[3:2] <= q[3:2] - 1'b1; if (q[3:0] == 4'b0000) begin q[3:0] <= 4'b1111; q[5:4] <= q[5:4] - 1'b1; if (q[5:4] == 2'b00) begin q[5:4] <= 2'b11; q[7:6] <= q[7:6] - 1'b1; end end end end end倒计时的借位逻辑和正向进位是对称的,每个 2 位字段到 0 就借位变成 11(也就是十进制 9 或相应最大值),同时向高位掏一。下面是 15 位倒计时初值 t 在装载时的分段方式:
| t 位域 | 含义 | 取值范围 | 说明 |
|---|---|---|---|
| t[11:8] | 分钟高位 | 0-5 | 最大 59 分的十位 |
| t[7:4] | 分钟低位 | 0-9 | 分钟个位 |
| t[3:0] | 秒低位 | 0-9 | 秒个位 |
| 剩余位 | 秒高位 | - | 用于更高秒位或扩展 |
实际装载时需要把 t 做拆分后再送到 q 的对应字段,原代码直接q <= t[7:0]是把 t 当二进制数而不是 BCD 用,显示出来会是乱码。这是学生作业里最容易踩的坑,显示格式错乱十有八九是这里没做进制转换。
注意:数码管的十进制显示和二进制存储是两码事。如果一定要 mm:ss 显示正确,要么全程用 BCD 存储,要么在扫描前做二进制转 BCD(比如用双 dabble 算法)。
3.3 启动暂停与异步复位的优先级
启动/暂停功能原设计用 p 输入,但代码里并没有体现 p 对计数的使能作用,count 一直在加。正确的做法是把计数使能与暂停绑定:
always @(posedge clk or posedge rst) begin if (rst) count <= 24'd0; else if (p) // p=1 表示暂停 count <= count; // 保持 else count <= count + 1'b1; end复位优先级最高,其次才是暂停。这样按下暂停时计数器冻结,计时自然停住;再次切换 p 后从冻结值继续。整个计时器的时序路径是"100MHz 时钟 → 分频计数 → 秒脉冲 → BCD 加减 → 数码管扫描",每一级都必须复位到确定值,否则上电后数码管可能显示随机数。
4. Basys3 管脚约束文件的三处易错点
4.1 XDC 里 LED 与开关的引脚映射
题目一约束文件里把 clk 绑到 W5,rst 绑到 U18,m1/m2/v1/v2 分别绑到 V16、V17、W16、W17,LED0 到 LED15 绑到 U16、E19、U19、V19、W18、U15、U14、V14、V13、V3、W3、U3、P3、N3、P1、L1。这些都是 Basys3 的官方引脚定义,直接抄板子手册就能对上。关键是要注意[get_ports {led[0]}]这种写法,当端口是向量时,花括号不能省,否则 Vivado 会报找不到端口。
## Clock signal set_property PACKAGE_PIN W5 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] ## LEDs set_property PACKAGE_PIN U16 [get_ports {led[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {led[0]}] set_property PACKAGE_PIN E19 [get_ports {led[1]}] set_property IOSTANDARD LVCMOS33 [get_ports {led[1]}] # ... 其余 LED 同理 ## Buttons set_property PACKAGE_PIN U18 [get_ports rst] set_property IOSTANDARD LVCMOS33 [get_ports rst]每一行先设 PACKAGE_PIN 再设 IOSTANDARD,顺序不能颠倒。IOSTANDARD 必须是 LVCMOS33,因为 Basys3 的 I/O bank 是 3.3V。原文件里出现了get_portsm1这种少了空格、把get_ports和端口名粘在一起的笔误,Vivado 会直接报语法错误,这是综合前就必须修掉的。
4.2 约束文件常见的三类报错与定位
第一种是端口名和顶层模块对不上,比如顶层写的是led,约束里写成leds,会报 "no objects matched"。定位方法是打开综合后的 I/O Ports 报告,逐一核对端口名。第二种是位宽不匹配,比如约束里写led[16]但实际只有 0-15,直接报错。第三种是重复约束,同一引脚被绑了两次,Vivado 会告警用最后一条。
另外,时钟引脚没有约束会报 "no clock defined",时序分析无法进行。至少要给 clk 一个create_clock约束:
create_clock -period 10.000 -name sys_clk [get_ports clk]100MHz 对应周期 10ns,这是时序约束的起点。少了这条,后续的建立时间、保持时间报告都是空的,时序收敛无从谈起。
5. ModelSim 验证多模式计时器的实用技巧
5.1 用任务封装测试激励
直接写死 initial 块里的#5 clk=~clk只能测一个场景,四种模式加倒计时、暂停、复位全测下来会非常乱。更好的做法是把每种场景封装成 task:
task run_pattern(input [1:0] mode, input [1:0] speed); begin {m1, m2} = mode; {v1, v2} = speed; #2000; // 跑足够长观察灯变化 end endtask initial begin clk = 0; rst = 1; m1 = 0; m2 = 0; v1 = 0; v2 = 0; #20 rst = 0; // 释放复位 run_pattern(2'b00, 2'b00); // 左循环,最快 run_pattern(2'b01, 2'b11); // 右循环,最慢 run_pattern(2'b10, 2'b01); // 交叉闪烁 run_pattern(2'b11, 2'b10); // 全亮全灭 $stop; end always #5 clk = ~clk; // 100MHz 时钟这样每个 case 分支都被覆盖,波形窗口里能直接看出 led 是否按预期移位或闪烁。用 task 的好处是改起测试刺激来不用重写 initial。
5.2 波形观察与时序检查要点
在 ModelSim 里重点看三样东西:一是 rst 拉高时 led 是否立刻变成全 1(异步复位的直接体现);二是切换 m1/m2 后 led 的变化方向是否跟着变;三是切 v1/v2 后 led 变化的时间间隔是否明显不同。很多人测速分级时发现四个档位看不出差别,原因是仿真时间太短,还没跑到慢速档位的进位点,把仿真时长拉到几十万个时钟周期就能看出来。
倒计时部分的波形要盯住 q 在每个秒脉冲来时是否正确借位,尤其是 10:00 减到 09:59 这种跨越。原代码里 q 用的是 2 位字段冒充十进制位,仿真时很容易看到 33 跳到 22 这种非法值,这就是进制设计没做对导致的,波形会直接暴露出来。
提示:仿真时钟周期设 10ns 对应 100MHz,和实际板子一致,这样测出来的时间间隔才有参考价值。如果仿真设了 10MHz,实际跑起来速度会差 10 倍。
本文还有配套的精品资源,点击获取