ZYNQ双项目实战:FFT频谱分析与打地鼠游戏开发全流程复盘
简介:ZYNQ平台双项目实战包是一份面向高校数字系统设计、嵌入式FPGA综合实践及课程设计场景的完整参考案例。包内集合两套可运行的工程:基于FFT的频谱分析系统,以及趣味性强的打地鼠交互游戏。频谱分析项目采用Vivado IP核或HLS方式构建,提供设计文档、仿真波形截图和实际运行截图,覆盖从信号采集、快速傅里叶变换到频域显示的完整实现路径;打地鼠项目以PL端逻辑完成游戏核心控制,PS端搭配Linux或裸机程序实现人机交互,工程目录结构清晰,实验报告与硬件运行实拍图一应俱全。两个独立项目均配有可复现的仿真环境与工程配置,内容按模块组织,便于边读代码边对照截图验证。整个资源压缩包共83个文件,以VHDL源码、ModelSim仿真脚本、Vivado工程配置和运行截图为主,辅以文本、Shell脚本等辅助资料,体积约11.28MB。当前已有16人学习,内容经真实开发环境验证,按归档目录即可直接参考,能够显著节省工程搭建时间,同时帮助理解软硬件协同设计、外设交互和实时信号处理等关键知识。 最近把一套吃灰半年的ZYNQ开发板翻出来,顺手把两份工程从头到尾重新跑了一遍:一份是用FFT做实时频谱分析,一份是打地鼠交互小游戏。两套工程都是完整的Vivado 工程,板级实测截图也都在。重新走完整个流程,我最大的感受是:这种“一硬一软”的双项目组合,对ZYNQ学习者的价值比单独啃某个教程大得多。频谱分析把数据采集、IP核配置、DMA搬运、PS端显示整条链路都打通了;打地鼠游戏又把中断、定时器、GPIO控制、状态机这些裸机开发基本功串了一遍。两张工程图放在一起,恰好覆盖了ZYNQ平台最核心的“PL做并行加速、PS做流程控制”协作模式。这篇就当一次完整复盘,从环境准备到烧写调试,把关键设计思路和踩过的坑一次性讲清楚。
1. 项目梳理:为什么把FFT和打地鼠放进同一个实战包
1.1 先搞清楚ZYNQ到底在练什么
ZYNQ严格来说不是一块纯FPGA,而是ARM处理器(PS端)+ 可编程逻辑(PL端)的异构芯片。很多初学者第一个疑问是:我到底该把它当单片机用,还是当FPGA用?我的看法是,两个都要会。PS端跑软件逻辑,负责流程控制、界面交互、协议解析;PL端跑并行数据处理,负责高速采集、流水线运算、时序生成。这两部分的协作方式,才是ZYNQ区别于普通MCU和纯FPGA的核心价值。
拿FFT频谱分析举例:ADC采样进来的数据速率很高,如果用PS端C语言算FFT,点数一大就扛不住;但放到PL端用FFT IP核,流水线一转,几千个点几十微秒就能算完。这就是PL的并行优势。而打地鼠游戏恰恰相反,游戏规则判断、计分、随机生成地鼠这类逻辑分支复杂但运算量很小的任务,用PS端C语言写非常简单,放到PL端反而会把状态机写到怀疑人生。所以两个项目正好从两个方向把ZYNQ的特长都练到了。
1.2 两套工程的难度梯度和学习顺序
我建议的学习顺序是先跑打地鼠,再跑FFT。打地鼠难度集中在软件逻辑上,你只需要在Vitis里写C代码,通过GPIO、GIC中断和定时器操作外设,折腾三天能跑出完整效果。FFT则反过来,难点在PL端:你需要配FFT IP核、设计数据缓存、处理不同帧之间的衔接,还要在PS端做数据读取和绘图。先做了打地鼠,你会对PS端操作外设的方式有手感,再去做FFT的PS端显示部分就不慌;先做了FFT,你会对IP核配置和数据流有概念,再回去看打地鼠的PL端按键消抖、LED控制也会更顺手。
如果手里板子紧张,一个工程包拆开跑完全没问题。但把它们放一起,最大的收益是你能看到同一个ZYNQ芯片,在两种完全不同的项目里承担的角色差异。
2. 环境准备:Vivado安装与双工程组织
2.1 版本选择与安装注意事项
这套工程我实际用的是Vivado 2020.2版本。不是越新越好,因为老工程在高版本Vivado里打开时,IP核版本、编译选项经常需要全部升级,升级过程偶尔会报兼容性错误,反而耽误时间。2020.2这个版本比较稳,网上教程多,从下载到破解、从新建工程到生成比特流,遇到问题基本都能搜到现成的答案。
安装时有几个细节容易踩坑。第一,安装路径不要带中文和空格,我见过有人装在“D:\软件\Vivado”下面,结果SDK死活起不来,改路径重装才解决。第二,下载安装包时如果提示需要WinPcap,直接装就行,这个组件在Vivado里主要用于仿真和硬件管理器的以太网相关功能,不装的话后续可能影响部分功能。第三,License文件要放到纯英文目录,否则Vivado启动时容易读取失败。网上问“Vivado License配置好了但启动一直报错”的,八成是路径问题。
2.2 工程目录划分建议
拿到套件后,建议把两份工程放在同一个Workspace下管理,结构大概是:
ZYNQ_Proj/ ├── FFT_Spectrum/ │ ├── vivado_prj/ # Vivado工程文件 │ ├── vitis_sdk/ # Vitis/SDK裸机工程 │ ├── docs/ # 原理图、引脚约束说明 │ └── images/ # 实测截图 └── WhackAMole/ ├── vivado_prj/ ├── vitis_sdk/ ├── docs/ └── images/我在工程里通常会再单独放一个README.txt,记录板卡型号、Vivado版本、生成bitstream的步骤、PS端运行入口函数位置。这套双项目包时间跨度大,当初很多工程工程重新打开时连板卡型号都要猜,后来养成了写README的习惯才好转。建议你也直接从第一天开始养成这个习惯,别高估自己的记忆力。
3. FFT频谱分析:从IP配置到板上实测
3.1 先说设计框图:数据从哪来,往哪去
FFT频谱分析项目的整体数据流如下:
DDS信号发生器 -> FIFO缓存 -> FFT IP核 -> 幅值计算模块 -> DMA -> DDR -> PS端读取 -> 串口/OLED显示我测试用的信号源是PL端内部的DDS IP核,好处是频率可控、不用接外部信号发生器。实际想扩展成音频频谱,只需把DDS换成ADC芯片,前面加一级I2C或SPI接口,数据流完全不用改。
DDS输出的是IQ两路正弦数据,每周期采样点数固定。FFT IP核每次处理一个帧,比如1024点。关键点是:DDS的采样时钟和FFT IP核的工作时钟必须同源或跨时钟域处理好,否则采集数据会有毛刺。我在工程里统一让所有模块挂在100MHz时钟下,DDS输出数据率也设成100MHz采样,省去异步FIFO的麻烦。如果想采样率低一些,可以在DDS后面加一个数据有效信号,用“有效时写FIFO”的方式降采样,而不用改时钟。
3.2 FFT IP核到底应该怎么配
Xilinx FFT IP核配置界面里,参数项很多,但核心是那么几个。我这份工程用的配置如下:
| 配置项 | 我用的值 | 说明 |
|---|---|---|
| Number of Channels | 1 | 单通道够用,多通道留给MIMO场景 |
| Transform Length | 1024 | 点数,分辨率=fs/N=100MHz/1024≈97.6kHz |
| Target Clock Frequency | 100 MHz | 实际IP会自动算最大运行频率 |
| Architecture | Radix-4 Burst I/O | 资源适中,时序好收敛 |
| Data/Phase Factor Width | 16 | 数据位宽,踩过精度坑的都懂 |
| Scaling Options | Block Floating Point | 自动缩放,输出做幅值计算时好处理 |
| Output Ordering | Natural Order | 省去排序逻辑,显示时直接按顺序取 |
“Block Floating Point”这个选项很关键。如果选Scaled,峰值幅值会随输入信号幅度浮动,幅度计算时容易被割裂;选Block Floating Point,IP核会自动对整帧数据做统一缩放,输出结果一致性更好,实测下来幅度误差能控制在1%左右。
数据接口方面,FFT IP核有几个关键信号:s_axis_data_tvalid、s_axis_data_tdata、m_axis_data_tvalid、m_axis_data_tdata。其中tdata是拼接的复数,低16位是实部,高16位是虚部。这个细节特别容易搞错,我一朋友曾经把虚部当实部读,折腾了一晚上才发现是字节序问题。对了,m_axis_data_tdata里还带有一个指数因子信号,Block Floating Point模式下幅度计算要乘上2^shift,这部分代码在C语言里记得用算术右移实现。
3.3 PS端怎么把频谱画出来
FFT IP算出来的频域数据通过DMA搬到DDR里,PS端用Vitis写应用程序读取。我用的显示方案是串口输出频谱值加OLED小屏绘图,OLED屏一个像素对应一个频点,取前256个频点值做缩放。核心代码如下:
// Vitis裸机应用:读取DMA搬运后的频域数据并显示 // 数据在DDR地址 0x10000000,每个点16bit实部+16bit虚部 #define FFT_DATA_ADDR 0x10000000 #define FFT_POINTS 1024 float magnitude[FFT_POINTS/2]; for (int i = 0; i < FFT_POINTS/2; i++) { int16_t real = *(volatile int16_t *)(FFT_DATA_ADDR + i*4); int16_t imag = *(volatile int16_t *)(FFT_DATA_ADDR + i*4 + 2); // 加上 Block Floating Point 的指数缩放 float amp = sqrtf((float)real*real + (float)imag*imag) / 512.0f; magnitude[i] = amp; } OLED_ShowSpectrum(magnitude, FFT_POINTS/2);注意两个坑。第一,DMA搬运完要发中断或置标志位,PS端轮询标志位再去读数据,不要直接读,否则可能读到半帧数据。第二,OLED屏刷新速度远低于FFT更新速度,我压到每100ms刷新一次,人眼看起来是连续的,但CPU占用率只有不到10%,后续扩展成音频实时频谱时这个经验很有用。
3.4 实测结果怎么解读
把DDS频率设置为10MHz,采样率100MHz,1024点FFT,理论分辨率约97.6kHz。实测OLED在第103个点附近出现明显峰值,幅度最大值处对应的频率约为103×97.6kHz≈10.05MHz,误差在0.5%左右,主要来自FFT栅栏效应和DDS频率字量化。测试50MHz时,峰值点在第512个点附近,正好对应奈奎斯特频率的一半,说明链路没有问题。
实测截图里能看到一条明显的谱线,旁边有少量杂散,那是DDS本身的谐波。如果是真实音频FFT,会对各频率分量做平滑处理,这一版的工程只做了幅度显示,没加窗函数。想做实时音频频谱分析的话,FFT前加一个汉宁窗,频谱泄漏会明显改善,代码量也不大。
4. 打地鼠交互游戏:状态机与中断的实战
4.1 游戏架构:硬件外设和软件逻辑怎么分
打地鼠游戏听起来是纯软件逻辑的事,但放在ZYNQ上,还是需要PL端做一些硬件准备。我用的方案是:PL端用按键模块接收锤子敲击信号,用LED灯模拟地鼠洞,用数码管或OLED显示分数;PS端跑游戏主循环、随机数生成、倒计时和按键消抖逻辑。
硬件连接上,8个LED对应8个洞,每个洞配一个按键。按键按下时,PL端通过GPIO把按下状态传给PS,PS读取GPIO寄存器判断哪一个洞被击中。为了简化接线,我用按键时就顺便做了硬件消抖:在PL端用2个触发器级联打拍,再加一个边沿检测,把毛刺滤掉。这个做法在实战里非常实用,省去PS端软件延时消抖的麻烦。
游戏规则很简单:地鼠随机出现在某个洞,玩家按下对应按键得分,倒计时结束后显示总分。这里的地鼠随机位置不是真正的随机,我用了一个线性反馈移位寄存器(LFSR)在PL端产生伪随机数,然后把随机值从AXI GPIO传给PS端。为什么不直接用C语言的rand()?因为PS端裸机环境没有时钟种子,rand()每次上电结果都一样;用LFSR硬件生成随机数,每次上电初始值不同,游戏才真正有随机性。
4.2 核心状态机与中断逻辑
游戏按状态机设计:IDLE->PLAY->COUNTDOWN->END。IDLE状态等待按键启动,PLAY状态地鼠随机出现,COUNTDOWN状态倒计时结束,END状态显示最终分数并等待重新开始。
PS端主代码如下:
typedef enum {GAME_IDLE, GAME_PLAY, GAME_COUNTDOWN, GAME_END} GameState; static GameState state = GAME_IDLE; static int score = 0; static int mole_position = 0; // GPIOTimer中断服务函数里处理倒计时和地鼠位置更新 void TimerISR(void *arg) { static int countdown = 30; if (state == GAME_PLAY) { // 每500ms换一次地鼠位置 mole_position = XGpio_DiscreteRead(&gpio_inst, RANDOM_CHANNEL) & 0x0F; XGpio_DiscreteWrite(&gpio_inst, LED_CHANNEL, 1 << mole_position); countdown--; if (countdown <= 0) { state = GAME_END; countdown = 30; } } } int main() { while (1) { switch (state) { case GAME_IDLE: // 等待START键按下,初始化分数和倒计时 break; case GAME_PLAY: // 读取按键,判断是否命中 int keys = XGpio_DiscreteRead(&gpio_inst, KEY_CHANNEL); if (keys & (1 << mole_position)) { score++; XGpio_DiscreteWrite(&gpio_inst, LED_CHANNEL, 0); // 立即换地鼠位置,提高节奏感 } break; case GAME_END: OLED_ShowScore(score); break; } } }这里要注意,GIC初始化步骤不能少。BSP生成之后,需要先在Vitis里把GIC配置好,注册Timer中断和按键中断,再写游戏逻辑。我最初直接在主循环里轮询按键,不仅响应慢,还占用CPU,后来改成按键边沿触发中断才解决。中断服务函数里不要做耗时操作,像OLED显示、分数刷这种I/O密集型操作全部放到主循环里,中断里只置标志位。这是裸机开发的老规矩,在ZYNQ上同样适用。
4.3 调试与调参心得
打地鼠项目调试起来比FFT直观得多,因为你能直观看到LED变化和分数。
我实测时发现几个体验细节:
- 地鼠切换间隔从1秒改成0.5秒后,游戏节奏明显变快,可玩性大幅提升;但如果改成0.2秒,按键响应跟不上,玩家会产生挫败感。这个参数建议做成可调常量,方便后续改难度。
- 按键消抖时长不能太长。我最初把消抖周期设为20ms,实测按快时偶尔会漏键;后来改成中断方式的边沿检测,漏键问题就消失了。
- 分数显示用了独立线程去刷新,主线程只处理逻辑。在裸机环境下,其实用定时器中断配合标志位就能实现“伪多线程”,这个技巧在后续项目里会反复用到。
5. 烧写、调试与常见问题排查
5.1 Vivado生成比特流报错怎么办
生成比特流时最容易碰到的是DRC错误,例如:
[Vivado 12-1345] error(s) found during DRC. Bitgen not run.这类错误通常是约束文件缺失或引脚分配冲突。排查思路是打开Messages窗口看具体是哪个DRC规则被违反;常见是引脚约束里某个引脚被占用、或者时钟约束没写。在工程里我有完整的XDC约束文件,直接用就行;如果自己重新分配引脚,务必核对FPGA芯片的bank电压和IO standard,ZYNQ开发板上不同bank的LVCMOS33电压不能混用。
还有“Vivado删除加入工程的文件”这类操作问题,如果你在工程里误删了某个源文件,不要慌,在Sources窗口右键Add Sources把文件加回来就行,不影响其他配置。如果编译时提示找不到端口,多半是模块例化名和顶层端口没对上,检查一下例化时“.”前面的端口名是否和模块定义一致。
5.2 从Vivado到Vitis的烧写流程
现在的Vivado版本都推荐用Vitis替代传统SDK,但很多老教程还在写SDK。实际上Vivado 2020.2自带的还是SDK,Vivado 2021.1以后才把Vitis集成进去。工程包里的“sdk”文件夹就是Vitis工程目录,打开方式没有太大区别。
烧写流程分两步。第一步在Vivado里生成比特流,然后Export Hardware(勾选Include bitstream),Launch Vitis。第二步在Vitis里新建Platform工程指向导出的硬件文件,新建Application工程,编译后用JTAG烧写或生成BOOT.bin到SD卡启动。想烧写到QSPI Flash的话,需要先生成Flash镜像,然后用Hardware Manager的Program Configuration Memory Device烧写;这个步骤注意地址要填0x0,否则板子上电找不到启动镜像。热词里有人问“zynq烧写”,其实烧写分几种场景:JTAG调试、QSPI Flash固化、SD卡启动,选哪一种取决于你的使用场景。开发阶段建议JTAG,发布阶段建议SD卡或QSPI。
5.3 常见报错速查表
我整理了这套双项目实战包重跑过程中遇到的典型问题,以及网上高频出现的问题,单独做成了一张表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| Vivado安装提示WinPcap失败 | 安装包运行时网络组件安装不完整 | 忽略后单独装WinPcap,或在安装前手动安装 |
| Project打开后IP核版本不匹配 | Vivado版本升级导致IP需重新编译 | Targets -> Upgrade IP,然后重新生成比特流 |
| “Vivado看Elaborated Design时闪退” | 系统内存不足或驱动兼容问题 | 关闭杀毒软件,更新显卡驱动,尝试换一个Display Mode |
| ILA触发后波形不更新 | 采样深度不够或触发条件没写到 | 增大采样深度;检查触发信号极性 |
| 实现后LUT超了 | 综合策略不够积极或用资源过多 | 调整综合策略到Flow_PerfOptimized_high,或精简冗余逻辑 |
| 固化Flash后板子启动不了 | 启动模式拨码开关错误或BOOT.bin格式不对 | 确认拨码选择QSPI模式,重新生成BOOT.bin |
| PS端GPIO读不到按键 | 引脚约束错误或GPIO通道号没对上 | 在Vitis里读回GPIO寄存器,对照地址表确认通道 |
| FFT输出幅度比预期小很多 | 缩放策略选择错误或指数因子没处理 | 改用Block Floating Point,并在软件里乘上2^shift |
这张表里的内容,九成都是初学者反复问的问题。处理原则很简单:先查约束、再查时序、最后查代码。不要一看到报错就怀疑工具坏了。
6. 个人心得与后续可以扩展的方向
这套双项目包重跑下来,我自己收获最大的不是FFT或者打地鼠本身,而是对“PL端做并行、PS端做控制”这句话有了更实在的理解。比如FFT里,300多行的Verilog控制逻辑远没有FFT IP核本身重要,设计上的重心是数据流怎么衔接;而打地鼠游戏里,真正的难点是状态转移的边界条件,而不是GPIO读写本身。这种“知道什么该用硬件解决,什么该用软件解决”的判断力,才是ZYNQ开发者最稀缺的素质。
跑完这两套工程,还可以往这几个方向做扩展:FFT项目改成真实音频频谱分析,外接一个I2S麦克风,加上加窗和频段能量统计;打地鼠项目把单机模式改成双人对抗,或者加一个计分排行榜,配合上位机串口通信把结果保存下来。要是对PL端还想继续深入,试试把FFT的数据通过AXI-Stream直接送到PL端的HDMI显示控制器,做成无CPU参与的纯硬件频谱显示,那又是一层新的挑战。如果各位在跟着做这套工程包的过程中遇到某个具体报错,也可以在评论区把报错日志发出来,我看到后会单独开一篇排查记录。
最后再分享一个我踩过几次的老坑:无论是在Vitis里编译应用程序,还是在Vivado里综合工程,尽量先把杀毒软件退出。Vivado和Vitis这类大型EDA工具在操作临时文件时,经常被安全软件后台拦截,轻则编译变慢,重则生成文件被删导致莫名其妙的错误。我因为这个原因浪费过一个晚上排查为什么比特流偶尔生成失败,当时差点把显卡驱动重装了。工具本身没那么脆弱,很多所谓的“玄学报错”其实都是外部干扰和资源冲突引起的,先恢复干净环境,问题往往自动消失。
本文还有配套的精品资源,点击获取
