
从 115200 波特率到 4096 点 FFT手把手拆一个适合入门的 FPGA 频谱分析工程很多学 FPGA 的朋友会卡在一个很尴尬的位置点灯、按键、串口这些入门三件套都玩熟了但一抬头看到正经项目就是Zynq DDR MicroBlaze直接被劝退——中间缺了一个台阶。今天这篇带你看一个单时钟域、纯 Verilog、不用软核的数字信号处理工程。它在一块 Zynq UltraScale 上跑通了这样一条链路串口收命令 → 协议解析 → 读 I/Q 样本 → FIR 低通滤波 → 4096 点 FFT → 找频谱峰值工程不大10 来个 RTL 模块 6 个 IP但把 FPGA 信号处理里最该会的几件事全串起来了UART、状态机、CRC 校验、FIR 滤波、定点运算、AXI-Stream 握手、FFT。非常适合作为串口毕业之后的第一个正经项目。一、先看工程全貌整个工程分四层┌────────────────────────────────────────────────┐ │ 传输层 UART 收发 协议解析(0x55 CRC16) │ ├────────────────────────────────────────────────┤ │ 信号处理 rom_reader → FIR(I/Q) → FIFO → FFT │ │ → peak_detector │ ├────────────────────────────────────────────────┤ │ IP 层 clk_wiz · mix_I/Q ROM · FIFO · FFT │ │ · ila │ ├────────────────────────────────────────────────┤ │ 物理层 sys_clk / rs232_rx / rs232_tx ... │ └────────────────────────────────────────────────┘数据流信号级长这样rs232_rx ─► UART ─► 协议解析 ─start─► rom_reader │ 读 4096 个样本 ┌─────────────┴─────────────┐ ▼ ▼ mix_I(ROM) mix_Q(ROM) ▼ ▼ FIR 滤波(I) FIR 滤波(Q) └─────────────┬─────────────┘ {Q,I} 拼 32bit ▼ AXIS FIFO ──► FFT(4096点) ▼ 峰值检测 → peak_bin / peak_mag²先有个印象下面一层层拆代码。二、基础部分UART这就是入门级 Verilog串口是这套工程的入口也是最基础的模块。别小看它——把串口写扎实是后面所有总线协议SPI、I2C、AXI的底子。2.1 波特率分频speed_select115200 波特率的本质就是在一个高时钟里数出每一位的采样点。50 MHz 时钟下50_000_000 / 115_200 ≈ 433于是分频器就三行核心逻辑define BPS_PARA 433 // 一位的周期 define BPS_PARA_2 216 // 位中点采样 always (posedge clk or negedge rst_n) if(!rst_n) cnt 13d0; else if((cnt BPS_PARA) || !bps_start) cnt 13d0; else cnt cnt 1b1; always (posedge clk or negedge rst_n) if(!rst_n) clk_bps_r 1b0; else if(cnt BPS_PARA_2) clk_bps_r 1b1; // 位中间给一拍脉冲 else clk_bps_r 1b0;bps_start是接收端检测到起始位后拉起的使能让计数器只在传输期间跑空闲时不空转。2.2 起始位检测my_uart_rxUART 空闲是高电平起始位是下降沿。异步信号进来先打 4 拍去亚稳态再取边沿always (posedge clk or negedge rst_n) begin if(!rst_n) begin rs232_rx0 1b0; rs232_rx1 1b0; rs232_rx2 1b0; rs232_rx3 1b0; end else begin rs232_rx0 rs232_rx; rs232_rx1 rs232_rx0; rs232_rx2 rs232_rx1; rs232_rx3 rs232_rx2; end end assign neg_rs232_rx rs232_rx3 rs232_rx2 ~rs232_rx1 ~rs232_rx0;检测到下降沿 → 拉bps_start→ 等clk_bps在每一位中点采样 8 个数据位。这就是一个教科书级的 UART 接收。这一段就是很多教程里的串口回环水平。但往下工程开始和普通教程拉开差距了。三、进阶协议解析状态机 CRC16光能收字节没用得上位机能下命令。于是加了一个UART_Protocol_Parser帧格式是| 帧头 0x55 | 长度 | 命令类型 | 数据体... | CRC16 低 | CRC16 高 |核心是一个 9 个状态的状态机IDLE_HEADER → GET_LEN → GET_CMD_TYPE → LEN_JUGG → RECEIVE_DATA → ... → CRC_CHECK配合一个CRC16-Modbus校验函数function [15:0] crc16_calc; input [7:0] data; input [15:0] crc; integer i; reg [15:0] next_crc; begin next_crc crc; for (i 0; i 7; i i 1) begin if (next_crc[0] ^ data[i]) next_crc (next_crc 1) ^ 16hA001; else next_crc next_crc 1; end crc16_calc next_crc; end endfunction命令类型从0x01到0x0C每种有各自的长度校验LEN_JUGG状态里逐个case。收到合法命令后拉一拍start脉冲作为后面信号处理链的触发。到这里你已经把纯软件思维切换到硬件状态机 流水的思维了。四、核心DSP 链路工程真正的含金量4.1 65 抽头 FIR 低通滤波FIR 是全工程最实打实的 DSP 模块直接型结构、线性相位对称系数、6 级流水、全速 1 sample/clk。系数是对称的B[0]B[64]83、B[32]4455是中心省一半乘法流水线是延迟线 → 65 个并行乘法(DSP48) → 部分和 → 中级和 → 总累加 → 右移17饱和定点细节很关键——输入 16bit、系数 Q1.17乘积 34bit累加扩到 42bit最后右移 17 位再饱和回 16bitwire signed [ACC_W-COEFF_FRAC-1:0] acc_shifted sum_final COEFF_FRAC; if (acc_shifted $signed({{...}, SAT_POS})) dout SAT_POS; // 上饱和 else if (acc_shifted $signed({{...}, SAT_NEG})) dout SAT_NEG; // 下饱和 else dout acc_shifted[DATA_WIDTH-1:0];这是教材里几乎不写、但实际做 DSP 一定会撞上的东西定点、截断、饱和。4.2 AXI-Stream 握手 帧对齐我认为全工程最值钱的一段FIR 出来的数据要喂给 Xilinx 的 FFT IP而 FFT IP 是AXI4-Stream接口一帧 4096 个点靠tlast标记帧结束。这里有个新手特别容易踩的坑tlast不能用tvalid计数因为下游tready拉低时数据并没有真正被收走。正确做法是用**握手成功tvalid tready**计数reg [11:0] sample_cnt; wire fft_in_handshake s_axis_data_tvalid s_axis_data_tready; always (posedge clk or negedge rst_n) begin if (!rst_n) sample_cnt 12d0; else if (fft_in_handshake) sample_cnt sample_cnt 12d1; end assign s_axis_data_tlast s_axis_data_tvalid (sample_cnt 12hFFF);再加上一个axis_data_fifo_0去吸收 FFT IP启动期的tready凹陷不然会丢拍以及把m_axis_data_tready恒接1b1防止反压一路回传到输入端。这段代码看着就五六行但它背后是流式接口怎么保证不丢数据、帧边界怎么对齐这个核心问题。能把这套握手逻辑讲清楚面试信号处理岗基本就够用了。4.3 FFT 输出 峰值检测FFT IP 配置成Pipelined Streaming、4096 点、Unscaled、自然顺序。输出是 64bit 复数{Im[31:0], Re[31:0]}Unscaled 模式下有效位是[27:0]。峰值检测就是在一帧 4096 个频点里找|X|²最大的那个wire signed [27:0] re $signed(fft_m_tdata[27:0]); wire signed [27:0] im $signed(fft_m_tdata[59:32]); always (posedge clk or negedge rst_n) begin ... mag2_d1 re*re im*im; // |X|^22 拍流水 bin_d1 fft_m_tuser[11:0]; // XK_INDEX就是频点号 tlast_d1 fft_m_tvalid fft_m_tlast; end每帧结束tlast后一拍给出peak_bin峰值频点和peak_mag2峰值能量平方。这就是一个频谱分析仪最核心的一步。五、真实踩坑记录这些比代码值钱这个工程不是我凭空写的是实打实调通过、也踩过坑的。几个印象最深的FFT 启动期丢拍Real Time 模式下tready会凹陷前几个样本被丢掉频谱全错。改成 Non Real-Time AXIS FIFO 缓冲才解决。tlast错位一开始用tvalid计数帧边界飘了。改成握手计数后才稳。反压回传m_axis_data_tready不接死反压会一路传到输入端把链路堵死。流水线对齐FIR 6 拍、FFT 若干拍、峰值检测 2 拍valid/bin/tlast这些伴随信号必须和数据一路同步打拍错一拍就是全盘错。这些坑单独看每一条都不难但没踩过的人第一次做 FFT 工程大概率要花好几天去定位。六、结语回头看这个工程的定位很清晰它不是完整数字下变频缺 NCO 混频和抽取而是一个基带 I/Q FIR FFT的频谱分析链路——但恰恰是这个精简版让它成了串口毕业之后、上软核之前那个刚好够得着的台阶。想巩固基础的看 UART 那部分就够了想啃协议和状态机的看UART_Protocol_Parser想真正入门 FPGA 信号处理的FIR AXI-Stream FFT这三段是重点。这套工程我完整整理好了全部 RTL 源码、6 个 IP 的配置、引脚约束、仿真脚本、以及上面提到的每个坑的定位过程都留了注释。如果你不想从零踩坑、想直接跑通一版能上 ILA 看频谱的工程或者把它当作毕设/课程设计的底子来改可以私信我聊聊细节。需要的话我可以把这篇存成 Markdown 文件或者再补一版更短的朋友圈/公众号摘要版。文末那句软 CTA 我留了空你可以把自己的联系方式/价格/链接填进去。