ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA上机大作业指南:从选题到上板调试的完整流程与避坑技巧

FPGA上机大作业指南:从选题到上板调试的完整流程与避坑技巧

简介:西安电子科技大学微电子学院李振荣老师FPGA可编程逻辑器件上机大作业报告,完整呈现了基于Xilinx Basys3开发板的数字逻辑设计全过程。报告围绕三个实验展开:多模式LED发光控制器(包含左循环、右循环、交叉闪烁、全亮全灭四种花样及四档速度)、多模式运动计时器(支持正计时/倒计时,倒计时时长可配置)以及液晶屏显示字符(选作),详细讲解异步复位、模式选择、速度控制、分钟秒显示等关键逻辑,并给出Verilog源码、ModelSim仿真、Vivado综合与布局布线结果。整份报告以单个PDF文件形式提供,大小约1018KB,适合正在学习FPGA基础或准备上机实验的学生对照参考;目前已有154人学习下载。报告结构清晰,从实验目的、设计思路到代码实现、仿真验证、硬件下载逐步展开,便于按步骤复现,可帮助读者系统掌握FPGA设计流程,包括代码编写、功能仿真、逻辑综合、引脚约束到比特流生成的完整链路,对提升数字系统设计能力很有帮助。

1. 一个FPGA可编程逻辑器件上机大作业到底要交什么:先把交付物拆明白

拿到某老师布置的FPGA可编程逻辑器件上机大作业文件时,我原以为又要写一篇课程论文。打开后才发现就一页要求:从清单里选一个数字系统设计,用硬件描述语言实现,仿真通过,再到开发板上演示,最后交一份报告。那一刻我意识到,这作业在考的不是背概念,而是你能否从零把一个模块级设计跑通。很多同学会卡在“不知道交什么”“先做哪步”,其实把交付物拆开就三样:可综合的工程源码、能覆盖边界的仿真测试、能演示的上板结果。这篇文章就按这个顺序,把每一步怎么做、参数怎么设、坑在哪讲清楚。适合刚开始接触FPGA的新手,也适合想找一份标准化流程的熟手。

2. 上机大作业从哪开始:选题、器件与开发环境三件套

2.1 选题怎么选才不踩坑:控制规模,留出余量

你可能会想,选个复杂的选题能加分。我的经验是,上机大作业的最大变量不是实现难度,而是调试时间。一个选题如果连仿真和上板都跑不完,写得再漂亮也是零分。常见的教学作业清单里有序列检测器、交通灯控制器、数字钟、简易频率计、四位数码管驱动、PWM呼吸灯,还有带使能信号的计数器。这些题目规模都不大,适合在几周内完成。我一般会推荐选“数字钟”或者“PWM呼吸灯”,原因是验证路径短:你能用肉眼判断LED亮度变化是否符合预期,不用接示波器就能判断正确性。

如果选了计数器,也别停在“0到9循环”这种基础版。我一般会建议在计数器基础上加“可调周期”或“可调占空比”,变成PWM呼吸灯。这样既没有引入协议解码、FIFO缓冲等复杂模块,又能在报告里写出“支持动态调节”的额外功能。选题的底层原则是“两个量”:仿真时间是否够短,板子资源是否够用。如果仿真跑一个完整流程需要几分钟,说明你的计数周期太长,要设置一个仿真专用的缩短参数。如果板子缺少某个外设,比如没有数码管,就不要选需要数码管的题目,除非你自己外接模块。

选题代码量大致规模演示难度可扩展方向
序列检测器1个状态机,约50行低,需要按键和LED可加多种序列、可配置序列
数字钟分频、计数、六进制/二十四进制、数码管刷新中,需要数码管显示可加闹钟、校时按键
PWM呼吸灯分频+比较器,约40行低,肉眼直接看LED渐变可加频率/占空比调节
交通灯控制器状态机+倒计时中,需要数码管和LED可加夜间模式、紧急模式

选题时还有一个容易被忽略的点:板卡上的FPGA型号直接决定了开发工具版本和接口例程。同一个跨平台系统,如果板卡太老,版本不兼容会带来一堆额外报错。所以建议先确认开发板是否被当前IDE版本支持,再定选题。很多同学第一步就在建工程时恐慌,因为型号列表里找不到自己的FPGA编号。解决办法是去板卡官方页面下载器件支持文件,或者干脆只选板卡配套的工程模板,不从空白工程开始。

2.2 用某款主流IDE搭一个最小工程:新建工程到点亮LED

选题一旦定下来,别急着写代码。先把开发环境跑通,给工程放一个“最小系统”,也就是点亮LED。这一步能验证你的下载线、板子供电和IDE配置都是好的。常见做法是新建一个工程,选择开发板对应的器件型号,如果没有板卡文件,就在“型号”和“封装”里手动选择。然后写一个顶层文件,给LED引脚分配约束,综合、实现、生成比特流,下载到板子。

我先给出点亮LED的顶层模块:

module top_led( input wire clk, input wire rst_n, output wire led ); // 计数器:每隔约0.5s翻转一次LED parameter CNT_MAX = 24_999_999; // 50MHz时钟下对应0.5s reg [24:0] cnt; reg led_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; led_r <= 1'b1; end else begin if (cnt == CNT_MAX) begin cnt <= 0; led_r <= ~led_r; end else begin cnt <= cnt + 1'b1; end end end assign led = led_r; endmodule

代码逻辑很简单:每次时钟上升沿判断计数值是否达到CNT_MAX,达到就归零并把led_r取反。参数CNT_MAX是核心调整项,它由时钟频率和想要的LED翻转周期决定。以50MHz时钟为例,每个时钟周期20ns,LED亮0.5秒、灭0.5秒,就需要计数到2500万次,因此位宽选择25位(2的25次方约3300万)。如果时钟是100MHz,这个值就要翻倍到49999999。开发工具里常见的小技巧是,先用一个短周期(比如500次)在仿真里验证逻辑,再改回真实值上板。

接下来是引脚约束。不同工具使用的约束语法不同,主流工具都支持把引脚映射写到一个约束文件里。下面的写法是某个主流IDE的风格,同样功能可参考你手头工具的用户手册:

# 引脚约束:时钟、复位、LED set_property PACKAGE_PIN E3 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN C4 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] set_property PACKAGE_PIN A2 [get_ports led] set_property IOSTANDARD LVCMOS33 [get_ports led]

注意,E3、C4、A2是我随意写的示例,你必须查自己板卡的原理图,把真实引脚号填进去。搜索方法很直接:打开板卡原理图PDF,查找“LED_0”“CLK_50M”“KEY_RST”等关键词,在页面里会标出对应的FPGA引脚号。如果查不到,也可以在看板上PCB丝印附近找网络名。把引脚填错是上板阶段最常见的翻车原因,轻则报错,重则可能损伤IO口,所以这一步宁慢勿快。

3. 把核心逻辑写出来:可综合代码与仿真testbench的双保险

3.1 写一个可综合的移位寄存器/计数器:代码模板与参数设定

大作业的核心逻辑无外乎三类:计数器、状态机、数据通道。我刚开始写代码时总喜欢用高级写法,比如循环、二维数组、任务,结果综合出来一堆“不可综合”的警告。这里有一个思维转变:FPGA里没有“for循环”的串行概念,所有硬件代码都要思考“每个时钟沿做什么”。可综合代码有几个硬约束:不能用initial对寄存器赋值,不能把延迟语句(#)写进可综合模块,不能在多个always块里对同一个寄存器赋值。

以计数器为例,下面这个模板同时包含“使能”和“模值”两个常用参数,可拓展性比固定模数的计数器好得多:

module counter( input wire clk, input wire rst_n, input wire en, // 计数使能 output wire [3:0] q // 4位计数值 ); reg [3:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= 4'd0; else if (en) begin if (cnt == 4'd9) // 模10计数器 cnt <= 4'd0; else cnt <= cnt + 1'b1; end end assign q = cnt; endmodule

这里cnt的位宽是4位,最大能表示15,模10计数到9后归零。如果想做模6计数,就改比较值为6,同时位宽仍然4位足够;如果想做模24,必须检查24是否超过4位数最大值15,超过就要把位宽扩到5位。看起来简单,但很多人在这里翻车:位宽选小了,到最大值后cnt自动溢出成0,而不是你想要的“归零后继续”,行为完全出错。所以养成习惯:比较值小于位宽最大值,否则扩位。

这个模板还有两个设计细节值得学习。第一,使用了非阻塞赋值“<=”,这是时序逻辑的标准写法,能避免同一个时钟沿上多个always块产生竞争。第二,清零方式是在计数到目标值时同步清零,而不是用外部异步复位,这样q输出没有毛刺。如果用“cnt == 4'd9”作为进位信号去驱动下一级使能,注意这个信号只持续一个周期,正好可以作为高位原语的en输入。这就是级联计数器的基础:低位产生进位,高位使能加1,从而搭出任意模值的多位计数器。

3.2 仿真testbench覆盖边界:时序激励与自动比对

有了可综合代码,还必须写testbench。很多同学只跑一次“看到波形动了”就交差,这不是仿真通过,是自欺欺人。仿真要覆盖三种情况:复位释放后初始值、使能无效时保持不变、到达最大值后回绕。最好还能做自动比对,让仿真直接输出“PASS/FAIL”。手工看波形在多模块设计里非常不可靠,尤其当信号很多时,眼睛会漏掉偶发的数据错误。

下面是一个针对上面计数器模块的testbench:

`timescale 1ns/1ps module tb_counter; reg clk = 0; reg rst_n = 0; reg en = 0; wire [3:0] q; counter uut( .clk (clk), .rst_n (rst_n), .en (en), .q (q) ); // 50MHz时钟生成 always #10 clk = ~clk; integer error_cnt = 0; initial begin // 复位 #30; rst_n = 1; en = 1; // 连续20个时钟,期望q在0~9循环 repeat(20) begin @(posedge clk); #1; $display("q=%0d", q); if (q > 4'd9) begin $error("q超过9"); error_cnt = error_cnt + 1; end end // 检查使能禁止时保持 en = 0; @(posedge clk); #1; if (q !== uut.cnt) begin $error("使能关闭后计数值变化"); error_cnt = error_cnt + 1; end #100; if (error_cnt == 0) $display("TEST PASSED"); else $display("TEST FAILED: %0d errors", error_cnt); $finish; end endmodule

这个testbench里有两个关键点。第一,时钟用“always #10 clk = ~clk”生成,因为timescale是1ns/1ps,#10就是10ns,半个周期10ns,完整周期20ns,正好50MHz。第二,在每个上升沿后加了一个#1的延迟,这是为了等待非阻塞赋值更新完成。如果没有这#1,采样到的可能还是旧值,这是初学者最容易疑惑的地方。

这里的自动比对其实还很简单,真正的完善思路是把期望值存进一个数组,每个时钟周期将模块输出和期望值对比,一旦不一致就打印出错位置。比如你可以用循环生成期望序列,而不是写死20个repeat。对于数字钟这种多级级联设计,testbench里还应该检查进位:在秒个位是9时,下一个时钟沿触发秒十位加1。这样功能覆盖才算完整。

注意,testbench本身是仿真专用的,不需要可综合。所以里面可以用initial、用#延迟、用$display,这些都不会影响上板。但如果你把可综合模块和testbench写在同一个文件,又不小心让initial出现在可综合模块中,那综合工具会报错。我习惯把可综合模块和testbench分成两个文件存放,工程里也分开加入,这样逻辑清晰,避免误用。

4. 上板不是点一下“烧录”就完事:引脚约束与在线调试

4.1 引脚约束与时钟域处理:从原理图到约束文件的迁移

写代码可以天马行空,上板必须面对真实的引脚和时钟。开发板上的物理引脚很多,逻辑端口必须和它们一一对应,这一步就是引脚约束。新手最容易犯的错误是抄网上某段约束,结果板子上的LED引脚和网上工程完全不一样。正确做法是打开板卡原理图文件,搜“LED_0”或“GPIO”这类关键词,找到FPGA端的引脚号,再写进约束。我在第二章给过约束示例,这里补充两个关键点。

第一,时钟引脚需要额外设置“时钟约束”,告诉工具这个时钟的实际频率,否则布局布线会按照保守默认值处理,可能导致时序失败。例如对50MHz时钟,要在约束文件里加一条:

create_clock -period 20.000 -name sys_clk [get_ports clk]

period单位是ns,20ns对应50MHz。有的同学不写这条,工具也能跑出bit流,但可能在某些温度下出现随机失败。如果你的工程里使用了PLL/DLL生成派生时钟,还要在派生时钟约束里写明“源自哪个主时钟”,否则跨时钟域分析会出现错误路径报告。

第二,和时钟异步的复位信号、按键信号需要同步处理。常见的做法是加一个两级D触发器同步器,把按键输入打两拍,消除亚稳态。代码可以写:

reg [1:0] key_sync; always @(posedge clk or negedge rst_n) begin if (!rst_n) key_sync <= 2'b0; else key_sync <= {key_sync[0], key_raw}; end

然后使用key_sync[1]作为稳定信号。很多人问两级同步器为什么能消亚稳态,简单说就是给亚稳态一个额外周期去稳定,避免直接进入组合逻辑。如果你的设计里有多个按键,每个按键都需要这样打两拍。注意,我在这里用了异步复位(negedge rst_n)和同步复位混合,综合工具有时会把它识别为异步复位逻辑。为了统一,建议要么全部用异步复位,要么全部同步复位,并在代码注释里说明复位极性。

还有一个常见混淆是把“引脚约束”和“时钟约束”当成一回事。实际上,引脚约束定义的是哪个物理引脚、什么电平标准;时钟约束定义的是时钟周期。两者缺一不可。尤其当板卡上的复位按键是高有效还是低有效,要仔细看原理图。如果原理图上是高有效复位,而你的代码按低有效写,上板后按复位键就会倒置,表现成“不按键时复位,按键时运行”。

4.2 用IDE的在线逻辑分析仪看内部信号:抓取一次真正的问题

上板后的一个常见困境:波形仿真都正常,但板子就是不工作。这时你需要看FPGA内部的信号,而不是用万用表量引脚。我一般会在工程里添加一个“调试核”,把要观察的寄存器拉出来,设置触发条件为某个信号上升沿,抓一段数据。这个调试核在使用上很像一个迷你逻辑分析仪,但它占用的资源是FPGA内部的块RAM和逻辑单元。

具体步骤是这样的:在工程源码里把需要监控的wire/reg加到一个调试核的输入信号列表里,然后例化调试核。综合实现后重新烧录,在IDE的硬件管理器里打开调试窗口,设置触发信号——比如选择“计数器的最高位”作为触发源,触发条件设为“上升沿”。点击单次触发,然后手动复位或触发一次功能,调试窗口就会停到触发时刻并显示之后的波形。如果没有抓到,可以把触发条件改成“下降沿”或“等于特定值”。

我举一个真实发生的例子,虽然细节我改了名,但流程很有代表性。某次做数字钟,秒个位总是每10秒跳一次而不是每1秒,仿真完全正常但上板就是不对。我当时用调试核抓分频计数器的内部计数值,发现时钟分频电路里偶数和奇数分频的相位逻辑没有对齐,导致进位使能信号出现了一个毛刺,每隔10秒才触发一次。如果不是把内部信号拉出来,这种问题光靠看外部LED,可能一个下午都找不出来。所以我一直建议,大作业里不管多简单的设计,都至少把关键信号引到调试核上,演示的时候告诉评委“我用了在线调试手段定位问题”,这本身就是加分项。

这里也说明一下,在线逻辑分析仪抓到的信号数量有限,抓取深度也受板卡BRAM容量限制。一般建议抓4到8个关键信号,深度设置成1024到4096即可。深度太大不仅浪费资源,还会让触发后的上传速度变慢,调试起来反而累赘。如果信号太多,可以分两次抓取,或者把内部信号先通过多路选择器,用LED/数码管显示出来,也是一种“穷人的逻辑分析仪”。

5. FPGA上机大作业避坑指南:这5个坑让我多熬了两个通宵

5.1 现象:仿真通过但上板不工作

这是学生们最常遇到的“一周通宵点”。仿真正常说明testbench下的逻辑行为正确,但上板不工作通常是三件事没处理好:复位电路、时钟频率、引脚约束。比如testbench里用了initial给寄存器赋初值,但可综合代码里禁用了initial,上电后寄存器是随机值。另一个典型是仿真里假时钟和真实时钟频率不一致,计数器参数在仿真里设成小值,上板忘了改回大值。

解决的办法有两条。第一,代码里全部使用同步复位或明确的异步复位,复位信号由板上复位按键输入并做消抖。第二,把仿真用的参数和上板参数通过一个宏区分,比如在testbench里定义`define SIMULATION,在可综合模块里用条件赋值,让仿真走短计数,上板走真实计数。这样你就不用每次手动改参数,从根源上避免“改成小值后忘记改回来”。另外,上板前再花三分钟检查一遍引脚约束,确认LED用的引脚和顶层端口一一对应。

5.2 现象:引脚约束到处报错

现象:综合完成后,实现阶段报“port not in design”或“constraint conflict”。原因一般是:引脚名拼写错误;顶层端口大小写不匹配;多人协作时约束文件重复定义同一个引脚;IOSTANDARD写错,比如少一个字母。这些错误看着低级,但调试过程会非常耗时,因为错误信息里常常只显示一个端口名,你得去工程里挨个查。

解决:多用IDE的引脚规划器图形界面。把每个顶层端口拖到目标引脚上,让工具自动生成约束,而不是手写。这能避免很多拼写问题。如果已经在约束文件里写了一大堆,就把顶层模块里不存在的端口全部删掉。还要注意一个工程里只能有一个约束文件被设为目标约束文件,其他约束文件即使参与了综合也可能被忽略,导致你改了文件却看不到任何变化。

5.3 现象:烧录成功但LED全灭

现象:下载提示成功,但LED一个都不亮,其他功能也没有。原因:bitstream没有真正配置到FPGA,或者配置后被错误复位。首先检查板卡上的DONE灯是否点亮,如果DONE为低,说明配置失败。这可能是你选择了错误的启动模式,比如从SPI Flash启动但Flash里没有内容;也可能是下载线接触不良。

解决:烧录时明确选择“烧到RAM”而不是“烧到Flash”,这样每次上电后从RAM加载配置,适合调试阶段。如果板卡有配置模式跳线,确认它指向JTAG模式。另外,下载成功后如果板子还是没反应,检查全局复位信号是否常拉低。很多板卡复位按键是常态低电平,代码里却用了高有效复位,结果上电即复位,所有功能都被冻结。这种问题在原理图里一眼就能看出来,但往往被忽略。

5.4 现象:按键复位和功能切换总是不灵敏

现象:按一次按键,功能状态跳了两三次,或者偶尔不响应。原因:按键没有消抖,产生多个时钟沿,状态机被触发多次。机械按键在按下和松开时会有约5-20ms的抖动,在50MHz时钟下就是几万次触发,状态机会在同一瞬间连续跳变多次。

解决:给按键输入加消抖模块。最简单的方式是加两级同步器后再加一个20ms计数器。采样到按键电平变化后开始计数,20ms内电平稳定才认为这次有效。注意消抖计数器需要用到当前时钟,如果你的按键信号同时在另一个异步时钟域里使用,还要在跨时钟域处加FIFO或握手逻辑,否则消抖后仍有亚稳态。对于大作业来说,按键消抖是最值得提前封装的模块,因为数字钟、交通灯这类设计十个里有八个要用按键。

5.5 现象:报告里仿真波形全是红的或“X”

现象:仿真波形显示红色、蓝色或一整个未知态“X/ZZZ”,看起来像一团乱码。原因:testbench没有给信号初值,或者模块的复位没拉高,或者某个输入端口没有驱动。如果信号线是蓝色的,表示高阻态Z;红色或橙色表示未知态X。评测老师看到这种波形图,第一印象就是你没有把仿真跑通过。

解决:在testbench的initial块里,把所有reg信号先赋初值0。然后先拉低复位,保持几十纳秒后再拉高,让模块进入正常状态。另外检查是否所有输入都有驱动,包括使能端、数据输入端。如果是顶层模块例化了子模块,子模块的端口要在testbench中逐连接。如果你用了在线逻辑分析仪,报告中放的不应当是这个调试核的波形,而是简明清晰的仿真波形。保持波形文件干净,截取关键区域,注明“复位释放后计数器从0开始递增”。

6. 让大作业从“能跑”到“加分”:加一个串口打印的调试技巧

6.1 用UART模块输出内部状态

如果你的作业已经能跑,想再拿个高分,我建议加一个UART发送模块,把内部计数器的值实时打印到电脑的串口助手里。这需要你实现一个简化的UART发送器,或者调用厂商现成的IP核。相比之下,自己写一个无奇偶校验的UART发送器只有二十多行代码,不算复杂,但能让你在报告里写“我实现了一个在线调试器,便于实时观测内部信号”,这比只贴逻辑波形更有说服力。

下面是一个最简UART TX模块,波特率可调:

module uart_tx( input wire clk, input wire rst_n, input wire tx_start, input wire [7:0] tx_data, output reg txd ); parameter BAUD_DIV = 434; // 50MHz/115200 = 434 reg [8:0] shift; reg [9:0] baud_cnt; reg [3:0] bit_cnt; reg busy; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin txd <= 1'b1; shift <= 9'b0; bit_cnt <= 0; baud_cnt <= 0; busy <= 0; end else if (!busy && tx_start) begin // 发送起始位0 + 8bit数据 + 停止位1 shift <= {1'b1, tx_data, 1'b0}; bit_cnt <= 0; busy <= 1; txd <= 0; end else if (busy) begin if (baud_cnt == BAUD_DIV) begin baud_cnt <= 0; txd <= shift[0]; shift <= {1'b1, shift[8:1]}; if (bit_cnt == 4'd9) begin busy <= 0; bit_cnt <= 0; end else bit_cnt <= bit_cnt + 1'b1; end else baud_cnt <= baud_cnt + 1'b1; end end endmodule

代码逻辑:空闲时txd保持高电平。收到tx_start后,把数据位和起始位、停止位拼在一个9位移位寄存器里(实际是1个起始位+8个数据位+1个停止位,共10位,但这里用9位有点简化,实际需要在bit_cnt到9时结束)。每个波特率周期BAUD_DIV发送一个bit。BAUD_DIV由时钟频率和波特率相除得到,50MHz除以115200等于434。注意这个模块没有输入保护,tx_start必须在busy为低时拉高并保持一个周期。如果你想直接在主状态机里调用,需要在状态机里给一个单周期脉冲。

使用这个方法时,把内部计数器的低8位接到tx_data,然后用一个手动按键触发tx_start。你会在一秒内看到一串数字在串口助手里滚动,比自己数LED快太多了。如果不想自己写UART,也可以用调试核里的UART模块,但那样代码量更大,而且可能引入厂商依赖,不如这个二十行通用模块轻量。

6.2 报告里放三张图:框图、仿真波形、上板照片

报告是作业的“门面”。我习惯最后放三张图:一张顶层模块框图,用简单的方框画法标明时钟、复位、各功能模块连接关系;一张仿真波形截图,截取能说明回绕和进位的部分,旁边用红框标注“当cnt=9时产生进位”;一张上板演示的照片,LED亮灭状态或数码管显示都行。不要截图黑底白字就贴上去,要加标注解释时序关系。文字部分控制在“每个模块怎么划分、为什么这么写、遇到什么问题、怎么解决”四段,不要抄手册里的通用定义。

我第一次做大作业时,就是嫌麻烦没做串口调试,结果数字钟的进位问题靠肉眼数LED数了半小时。后来加了UART,一秒钟就能看到内部计数值,那感觉就像多了一只眼。希望你也能少走这个弯路,在动手前先把最小工程跑通,在仿真里多补几个边界用例,在上板前仔细核对原理图引脚。这份FPGA可编程逻辑器件上机大作业并不算难,难的是沉下心把一个模块从代码到演示完整打磨一遍。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进