ZYNQ软硬协同实战:FFT频谱分析与打地鼠游戏设计调试
简介一套基于Xilinx Zynq-7000系列SoC的双项目实战资料专为FPGA嵌入式课程设计、数字系统综合实践及竞赛备赛人员准备集成FFT频谱分析与打地鼠交互游戏两个完整可运行的工程方案。压缩包共83个文件以VHDL工程源码vhd、仿真与命令脚本do/sh、文本说明txt、运行截图png等为主资源整体仅11.28MB目录按项目模块划分便于对照Vivado工程快速定位代码与文档。FFT项目基于Vivado HLS或IP核方式构建提供详细设计文档、多张仿真截图及Vivado与板卡实际运行截图可支撑信号采集和实时频域显示打地鼠项目由PL端逻辑实现游戏核心控制PS端运行Linux或裸机程序完成人机交互附带实验报告和两组硬件运行实拍图。整套材料均经真实开发环境验证开箱即可参考运行已有20人学习下载适合作为课程设计报告撰写、工程实现或进阶学习的配套参考。1. 拿到 ZYNQ 开发板后的第一道坎为什么是 FFT 和打地鼠这两个项目很多人拿到 ZYNQ 开发板后第一反应是跑一个流水灯或者点个 LED。流水灯只能说明 PS 能读写寄存器说明不了 ZYNQ 真正值钱的地方——PL 和 PS 协同。ZYNQ 平台“双项目实战包FFT 频谱分析打地鼠交互游戏”这个方向恰好把两个最典型的应用场景放在一起FFT 频谱分析让 PL 端承担高吞吐并行计算打地鼠游戏让 PS 端负责细腻的交互逻辑两个项目共用一套 AXI 总线、一个 Vivado 工程最后还能在一台显示器上同时演示。对于想学会看时序、调 DMA、写中断服务程序的从业者这套方案几乎等于一本会动的软硬协同教材。本文就按“选型逻辑 → 两个项目分别怎么做 → 调试避坑 → 验证技巧”的顺序把这条落地方案讲透。2. 为什么这两个项目适合在 ZYNQ 上做软硬协同的选型逻辑2.1 FFT 频谱分析PL 端高吞吐计算的黄金案例FFT 的算法不难难的是在大采样率下不丢数据。一个 1024 点 FFT 如果只是偶尔算一次用 ARM 处理器跑也能从容应对但频谱分析通常要求输入信号持续不断比如 1MSPS 的 ADC 采样率每一微秒都要进一个新采样点。如果纯用 PS 做 FFT处理器在计算的同时还要响应外设中断很容易在中断嵌套中丢掉数据。而 PL 端的 FFT IP 核采用流水线结构可以做到每个时钟周期都接收一个采样点计算延迟固定吞吐量由硬件保证。在 ZYNQ 的软硬协同常态里把 FFT 放进 PL 不是因为它算得快而是因为它需要用“确定性”换“覆盖率”。PS 端的 ARM 有缓存、有分支预测算同一段代码的延迟可能波动PL 端没有这些问题时序一综合出来就是固定的。许多实战包把 FFT 工程做到 256 点或 1024 点原因是这两个长度在 FPGA 里占用资源合理输入 16 位定点、输出 24 位定点计算引擎用流水线流模式即便 DSP Slice 不够也能退化成 Radix-4 结构。你可以根据手头板子的资源调整但基本思路是一样的ADC 采样数据先送到 PL经 FFT IP 处理后通过 AXI-Stream 接口输出给 DMA最后存入 DDR 供 PS 读取。FFT IP 的接口并不复杂但有两个信号容易踩坑s_axis_data_tvalid表示输入数据有效s_axis_data_tlast表示一个 FFT 帧的最后一个数据。IP 核只有识别到tlast后才认为一帧完整才会在输出端送出结果。很多工程调不出来就是因为送数据的模块在每个 1024 点循环里没有正确拉高tlast导致 IP 一直在等待帧结束。表FFT 常见参数与适用场景变换长度输入宽度输出宽度频率分辨率fs1MHz典型用途25616 bit20 bit3.9 KHz语音识别频段粗看102416 bit24 bit976.56 Hz通用频谱分析409616 bit24 bit244.14 Hz高分辨率信号监测2.2 打地鼠游戏PS 端调度与 PL 端显示的互补游戏逻辑和信号处理完全是两种思维。打地鼠需要随机决定地鼠在哪一个洞出现、保持多长时间、按到之后加多少分、难度如何逐级提高。这种事务型逻辑用状态机在 PL 里写也能实现但每改一次难度就得改一遍 Verilog、重新综合效率太低。PS 端用 C 语言维护一个 3x3 棋盘数组配合定时器中断每 100ms 刷新一次状态逻辑清楚改起来也快。但打地鼠又离不开 PL 端因为视频时序是 FPGA 的强项。VGA 或 HDMI 的行同步、场同步、像素时钟、消隐区间全部是精确到纳秒的时序用 ARM 处理器通过 GPIO 模拟几乎一定会闪烁或撕裂。合理的分工是PL 端做显示控制器按固定时序把当前画面输出到显示器PS 端维护“哪个洞有地鼠”“分数是多少”这些业务状态再通过 AXI GPIO 或自定义寄存器把状态同步到 PL。这样两个复杂度被拆到各自擅长的处理单元上正是 ZYNQ 相比单芯片 FPGA 或单片机的核心价值。把这两个项目放在一个实战包里还有一个额外好处你可以直观地对比资源占用。FFT 会吃掉大量 DSP Slice 和 BRAM而打地鼠的像素缓存和字符库也占用 BRAM。两者同时存在时你会发现资源规划不再是一道单选题而是一个需要考虑“DSP 有余但 LUT 紧张”的分配问题。这种真实的密度测试比单独跑任何一个 Demo 都能更快建立资源预算感。3. 用 Vivado 搭 FFT 频谱分析工程IP 配置、DMA 与 PS 读取3.1 FFT IP 核心参数设置与 AXI-Stream 输入模块在 Vivado 的 IP Catalog 里FPGA 厂商提供了现成的 FFT 核你不需要从头写蝶形运算器。创建工程时建议先把 Block Design 拉空然后按下面的参数创建一个 FFT IP变换长度选 1024输入数据宽度 16 位定点输出数据宽度 24 位架构选 Pipelined Streaming数据格式选 FixedPoint缩放策略选 Block Floating Point。这里的关键是 Block Floating Point它会自动控制中间计算增益避免大信号削顶、小信号丢失是调试频谱时最省事的选项之一。FFT IP 的输入侧是 AXI-Stream 从接口你需要自己写一个小模块把连续采样流转换成帧。下面这个 Verilog 模块假设采样数据一直有效用计数器产生tlast// 1024点FFT输入帧控制器 module fft_input ( input wire clk, input wire rst_n, input wire [15:0] sample_data, // 采样数据持续有效 output reg [15:0] axis_tdata, // FFT IP输入数据 output wire axis_tvalid, // 输入有效 output reg axis_tlast // 帧结束标志 ); reg [9:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; axis_tdata 16d0; axis_tlast 1b0; end else begin axis_tdata sample_data; cnt cnt 1b1; axis_tlast (cnt 10d1023); end end assign axis_tvalid 1b1; endmodule这个模块的思路很简单每个时钟周期把采样数据送到 FFT IP计数到 1023 时拉高tlast下一拍计数器回绕进入下一帧。tvalid一直拉高表示输入是连续流。需要注意的是axis_tlast只能在高电平保持一拍如果持续拉高IP 会误判为连续多帧所以用组合逻辑或寄存器一拍输出都可以但一定不能在cnt1023之外的时间拉高它。改成 256 点或 4096 点时计数器位数分别改成 8 位或 12 位tlast比较值对应改。3.2 用 AXI DMA 把 FFT 结果搬到 DDRPS 再读取FFT 输出的数据是 AXI-Stream 格式PS 的 ARM 核不能直接读这种流式接口所以中间需要 AXI DMA 把数据搬进 DDR。工程里常见的连接关系是FFT IP 的 M_AXIS_DATA 接到一个 axi_data_fifo 再接到 DMA 的 S2MM 通道DMA 的 MM2S 通道可以暂时不用。这样 FFT 每算完一帧DMA 就把 1024 个复数实部虚部写入内存。在 PS 端你需要先配置 DMA 描述符再启动传输。下面是一段 C 代码的简化示例实际环境下用 SDK 生成的 BSP 驱动会封装层数更多但核心逻辑是一样的// 读取FFT结果到用户缓冲区 #define DDR_BASE_ADDR 0x1F000000 // 实际由地址映射决定 #define FFT_LEN 1024 void read_fft_result(dma_handle *dma, uint32_t *mag) { // 第一步指定DMA写回内存地址 dma_set_buffer(dma, 0x1F000000, FFT_LEN * 4); // 第二步启动S2MM通道 dma_start_transfer(dma, DMA_S2MM); // 第三步等待传输完成中断/轮询状态寄存器 while (dma_check_status(dma, DMA_S2MM) ! DMA_DONE); // 第四步从DDR中读取实部虚部计算幅度 short *raw (short *)0x1F000000; for (int i 0; i FFT_LEN; i) { int real raw[i * 2]; int imag raw[i * 2 1]; mag[i] real * real imag * imag; } }dma_set_buffer里的地址必须是物理地址不是虚拟地址。ZYNQ 的 Linux 或独立 SDK 都有物理地址到虚拟地址的映射函数不要直接拿指针赋值否则 Page Table 会给你一个无效地址。FFT_LEN * 4是因为每个 bin 的实部虚部两个 short 共 4 字节注意不同 IP 的输出字节顺序可能相反如果频谱出现“连续 0” 的坑先检查大小端。启动传输后不要马上读内存一定要等 DMA 状态寄存器出现 done 标志否则你会看到一半旧数据一半新数据而且很难复现。3.3 从频谱数组到频率横坐标采样率换算与泄漏拿到幅度数组后横坐标不是直觉中的“第几个点”而是需要换算频率。假设系统采样率为 fsFFT 长度为 N那么第 k 个点的实际频率是f k * fs / N。这个公式看似简单但两个坑常被忽视一个是 DC 分量在第 0 点另一个是奈奎斯特频率在第 N/2 点。如果你显示整个数组会把镜像频率也画出来所以一般只取前 N/2 点。下面的代码帮你找峰值频率double fs 1000000.0; // 1 MSPS double resolution fs / FFT_LEN; // 频率分辨率 int max_bin 0; uint32_t max_val 0; for (int i 1; i FFT_LEN/2; i) { if (mag[i] max_val) { max_val mag[i]; max_bin i; } } printf(peak bin%d, freq%.2f Hz\n, max_bin, max_bin * resolution);这里从 1 开始而不是从 0 开始是为了跳过直流。但还有一个“玄学”问题如果输入正弦波频率不是采样率/N 的整数倍峰值会泄漏到相邻几个 bin 上看起来像一堆小山峰。解决方式是给采样数据加窗函数工程里最常见的是 Hann 窗。加窗的代价是主瓣变宽频率分辨率略微变差但对初学者来说能看到一个稳定突出的单峰比精确测量频率重要得多。如果你在实测截图中看到每个正弦波都拖着几个旁瓣说明 IP 核用了矩形窗甚至没加窗这不是 bug但会影响峰值定位精度。4. 打地鼠游戏PL 侧的显示与 PS 侧的交互逻辑4.1 用 Verilog 写一个标准的 VGA 时序控制器打地鼠游戏需要一个稳定的视频画面。下列 Verilog 模块是一个简化的 640x48060Hz VGA 时序发生器它的职责是产生 hsync、vsync 以及当前像素坐标。实际工程中像素时钟由板载 PLL 生成比如 25.175MHz但这个模块本身不关心像素时钟来源只关心计数逻辑// 640x48060Hz 时序发生器 module vga_controller ( input wire clk_25m, // 25.175MHz 像素时钟 input wire rst_n, output wire hsync, output wire vsync, output wire [9:0] h_count, output wire [9:0] v_count, output wire video_valid ); reg [9:0] h_cnt 0; reg [9:0] v_cnt 0; always (posedge clk_25m or negedge rst_n) begin if (!rst_n) begin h_cnt 0; v_cnt 0; end else begin if (h_cnt 799) begin h_cnt 0; v_cnt (v_cnt 524) ? 0 : v_cnt 1; end else begin h_cnt h_cnt 1; end end end assign hsync (h_cnt 640); // 简化极性实际需要负脉冲 assign vsync (v_cnt 480); // 同上 assign video_valid (h_cnt 640) (v_cnt 480); assign h_count h_cnt; assign v_count v_cnt; endmodule这个模块把像素坐标暴露给下游。真正绘图时会判断video_valid为高的时候用h_count和v_count查一张画面 RAM 或直接组合判断。你会发现游戏里画地鼠其实就是“某个坐标范围是否等于地鼠位置”的逻辑判断当h_count位于第 x 个洞的圆形区域且当前洞状态为 1就输出棕色像素否则输出灰色背景。这种画法不需要调色板适合初学者理解视频叠加原理。4.2 PS 端用 GPIO 和定时器维护游戏状态游戏的核心状态是一个 3x3 的数组0 表示没有地鼠1 表示正在显示。地鼠的随机出现、超时消失、按键命中全部由 PS 端每 100ms 一次的定时器中断驱动。下面这段 C 代码反映了主循环里最典型的处理逻辑#define BOARD_SIZE 9 int g_mole_board[BOARD_SIZE]; // 0空 1地鼠 int g_score 0; void game_timer_isr(void) { // 每100ms触发一次清掉消失的地鼠生成新的地鼠 for (int i 0; i BOARD_SIZE; i) { if (g_mole_board[i] g_mole_timeout[i] 0) g_mole_board[i] 0; } int new_mole rand() % BOARD_SIZE; g_mole_board[new_mole] 1; g_mole_timeout[new_mole] 5; // 5个tick后自动消失 // 检查按键按下边沿触发 int key read_gpio_data(); if (key 0 key BOARD_SIZE g_mole_board[key]) { g_score; g_mole_board[key] 0; } write_display_reg(g_mole_board); // 把棋盘状态发给PL }按键检测一定要做边沿检测而不是电平检测。因为按下保持的一两百毫秒会被多个 100ms 中断采样到不消抖的话一次成功的敲击可能被算成 2~3 分。实际操作中我一般会在read_gpio_data()里保存上一次按键值只有当上次为 0、本次为 1 时才认为是一次有效按击。至于随机数建议在系统启动时给种子加一个微秒级时间戳否则每次上电的游戏局面都一样。4.3 双项目合到同一个 Vivado 工程的总线规划FFT 和打地鼠放到一个 Block Design 后PS 端需要同时管理多套 AXI 外设。常见的规划是AXI DMA 分配地址 0x40400000AXI GPIO按键输入分配 0x40410000自定义显示控制寄存器分配 0x40420000。三组外设都挂在同一个 AXI Interconnect 下面PS 通过 M_AXI_GP0 访问。需要特别注意的是地址对齐。DMA 搬运的内存首地址必须满足 4 字节对齐如果 FFT 输出 buffer 分配在非对齐地址DMA 描述符会报错传输卡死。另外打地鼠的画面 RAM 如果放在 PL 内部 BRAMPS 端每次更新棋盘状态时跨过 AXI 总线写寄存器如果画面 RAM 放在 DDR则会增加内存带宽占用这时 AXI Interconnect 的 Arbitration 优先级建议把实时显示器接的视频读口优先级调高防止 FFT DMA 风暴抢走带宽导致画面撕裂。5. 双项目调试避坑DMA 死锁、跨时钟域与复位毛刺5.1 DMA 传输没反应频谱数组全零现象PS 端轮询 DMA 状态寄存器始终没有 Done读取内存看到的都是 0。原因最常见的是 DMA 描述符没有正确写入或者 S2MM 通道从未被启动。描述符里保存着下一次传输的内存地址、控制字和下一次描述符指针只要其中一个字段是空指针DMA 就会停在错误状态。解决先确认 DMA 寄存器组中的 S2MM_DA 和 S2MM_SA 是否指向实际存在的 DDR 物理地址再确认描述符控制字的缓冲长度和中断使能位是否设置成 1。一个习惯做法是先用最简单的单描述符、固定长度、不循环的模式跑通再改大规模循环传输不要一上来就配置多描述符环形队列。5.2 FFT 输出频谱出现对称镜像或者峰值落在奇怪位置现象输入一个 100kHz 正弦波FFT 结果却在多处出现幅度相似的高峰而且看起来像左右对称。原因一个是未使用 Natural Order输出数据是 Bit Reversed另一个是采样的tlast帧边界不对导致输入序列实际上发生了循环移位。解决先在 FFT IP 配置界面把输出顺序设为 Natural Order然后检查输入帧模块确保tlast只在最后一个有效采样点那一拍出现且帧间隔至少有 1 个时钟周期。Bit Reversed 的表现在频谱图上非常像信号混叠会把你引到“采样率配置错误”的错误方向所以建议调 FFT 时第一步就用单频正弦波源频谱图上应只有一根主峰。5.3 打地鼠游戏画面横向撕裂或者随机绿线现象屏幕上半部分图像正常下半部分偶尔错位。原因这是典型的跨时钟域问题。VGA 控制器工作在 25.175MHz而 DDR 读回的画面数据来自 AXI 总线总线时钟可能是 100MHz 或 150MHz两者相位不同步。如果直接拿异步时钟下的数据去画就会出现采样点滞后又提前的情况。解决在显示控制器和总线读接口之间加一个异步 FIFOFIFO 的写时钟用 AXI 时钟读时钟用像素时钟。同时注意 FIFO 的读复位要在像素时钟域同步释放不然每次刚上电时会出现一行花屏。5.4 按键要么不触发要么一次算两分现象用力敲击地鼠位置分数随机加 1 或加 2用示波器看按键引脚发现按下过程有多个毛刺。原因按钮的机械抖动产生电平跳变而 PS 的 GPIO 采样速率远高于抖动时间。解决在 PL 端用一个 10ms 采样计数器做硬件消抖也可以直接在 PS 端读两次值确认稳定。若使用中断方式一定要在中断服务里先 disable 该 GPIO 中断用 50ms 定时器重新 enable否则按下边沿会触发多次中断。这种问题在独立按键场景下不明显但在打地鼠这种强调快速连续敲击的交互中重复计分会直接毁掉体验。5.5 PL 复位和 PS 启动顺序不对游戏画面卡死现象按下复位按键后PS 的 Linux 或裸机程序能正常启动但画面始终停在白色或黑色PS 访问 AXI 外设寄存器正常但显示 IP 不接收更新。原因PL 侧外设的 reset 由 Processor System Reset IP 输出但该 reset 在 PS 启动完成后才释放而此时已经赋值的显示控制寄存器被 PL 端的复位清掉了。解决使用 AXI 总线自带复位和电平转换让 PL 外设复位由 PS 的外设复位信号控制如果你用了 PLL则要确保 PLL locked 信号接入 PSRst IP 的 slowest_sync_clk 域。养成习惯每次上电后先跑完 PS 初始化再检查 PLL locked再操作自定义 IP不要一上电就写寄存器。6. 让实测截图帮你验证 FFT 正确性三个可复现技巧实战包里附带的实测截图不是给你看热闹的它完全可以当作验证工具。第一招从截图反推 FFT 长度和采样率。找到截图里主峰的横坐标比如显示的是第 34 条谱线假设采样率 1MHz如果你知道输入信号频率就能验证横坐标映射是否正确。反过来如果你看到截图里两个正弦波相隔 25 条谱线也能反推出频率分辨率从而确认 IP 的变换长度是不是你想象中的 1024。第二招用截图里的旁瓣形状判断窗函数类型。如果主峰两侧出现稳定的旁瓣衰减说明加窗了如果旁瓣几乎不衰减说明用的矩形窗。把截图放大看主峰旁边第一个谷的值Hann 窗的旁瓣衰减约 31dB而矩形窗只有约 13dB。这个差值在频谱图上肉眼可见能有效区分“窗口函数写错”和“信号本身有问题”。第三招用截图的刷新连贯性判断 DMA 是否丢帧。频谱分析如果连续显示每一帧之间主峰位置应该平滑变化。如果看到谱线偶尔跳变、幅度突然变小又恢复多半是 DMA 循环缓冲区的帧指针和读取指针没有同步。算法上可以对比相邻两帧的幅度差超过阈值就用前一次数据顶替。我个人的习惯是把实测截图存档以相同的输入信号、相同的参数重跑一次比对谱线位置和主峰幅度这样能最快发现是硬件通路漂移还是软件配置回退。ZYNQ 的双项目调试是一个反复“改配置-看波形-看画面”的过程截图就是最好的黑匣子记录仪。希望这篇笔记能帮你少走我走过的弯路也希望你把这套软硬协同的组合方案跑得比我的更顺。本文还有配套的精品资源点击获取