FPGA两路正弦波相位差检测:FFT与CORDIC的实现与优化

发布时间:2026/9/13 8:21:04
FPGA两路正弦波相位差检测:FFT与CORDIC的实现与优化 简介面向基于FPGA的两路正弦波相位差检测需求围绕FFT与CORDIC算法的Verilog实现与优化展开适合具备FPGA基础并希望掌握频域分析与坐标旋转算法工程落地的开发者。包内共3个文件含PDF技术报告、HTML项目说明与txt辅助记录压缩包仅600KB便于对照阅读代码思路、设计文档及项目结构。内容以Altera芯片与Quartus II 13.1为实验环境详细分解FFT的位反转、蝴蝶运算等核心模块同时给出CORDIC迭代求取相位的硬件设计并覆盖定点与浮点转换、流水线划分、资源占用优化等关键策略。通过学习可以获得完整的相位差检测工程实现思路、可复用的Verilog模块划分方法以及官方工具链下的综合布线验证流程为无线通信、雷达、声纳等场景的实时信号处理开发提供可直接参考的范例。目前已有199人学习下载适合作为FPGA信号处理方向的中高级实践资料。1. 两路正弦波相位差检测FFT与CORDIC在FPGA上的分工在电机伺服、超声流速测量和激光测距这类对相位敏感的现场里两路同频正弦波的相位差往往直接决定了测量分辨率。用FPGA做这件事的动机和MCU完全不同ADC采样时钟可以从同一个源扇出FFT与CORDIC以流水线结构持续运转相位差结果能按固定周期刷新延迟是可计算的确定性数值。整个检测链路其实只有三步两路同步采样、FFT粗定位幅度谱峰值索引、CORDIC矢量模式在峰值频率上分别计算每路信号的atan2相位最后做一次减法与角度折叠。这篇文章沿着这条链路把定点数位宽预估、Vivado FFT/CORDIC IP核配置、优化权衡和仿真验证方法展开来讲读者可以在Xilinx或Altera器件上直接移植也可以反向借鉴到其他调制信号处理场景。2. FFT与CORDIC的数值基础定点数格式、位宽预算与角度量化误差2.1 为什么FFT只用来找频率相位交给CORDIC算直接对两路正弦波做相关运算也能得到相位差但FPGA上的相关实现要求本地振荡器频率与信号频率严格一致频率偏差会直接转化为相位漂移。FFT的思路是先用变换把信号从时域搬到频域找到幅度谱峰值对应的频率索引 k取出该频点上的复数 X(k)那么该频率分量的幅度是 |X(k)|相位是 atan2(Im(X(k)), Re(X(k)))。两路信号如果同频相位差就是这两个 atan2 结果之差。这里有一个容易被忽略的细节X(k) 的实部和虚部是二进制补码表示的定点数直接用除法求比值再去查反正切表会引入额外的查找表资源和除法延迟而且角度分辨率受表大小限制。CORDIC 的矢量模式Vectoring也叫线性化模式输入 x、y输出 r 和积累旋转角度恰好完成 atan2(y/x) 的迭代运算旋转角度以定点数输出。FPGA 上常见的做法是把 atan2 交给 CORDIC IP 核避开除法器也避开大容量查找表换取可预期的流水线延迟。2.2 定点数格式映射从ADC采样到FFT输出两路正弦波进入 FPGA 之前要确定数据位宽。假设 ADC 是 12 位带符号输出常用 Q1.11 格式表示即 1 位符号、11 位小数取值范围 [-1, 1)量化步长约为 0.000488折算成相位噪声时会影响最终结果的抖动。FFT IP 核输入输出位宽一般建议比 ADC 宽 2 到 4 位用于累加过程中的中间增益。我习惯先用一张表把位宽预算列清楚再落 RTL避免在综合后才发现数据溢出数据位格式取值范围相位量化误差影响ADC采样数据Q1.11 有符号定点[-1.0, 1.0)约 0.03° 抖动FFT输入缓存Q1.15 有符号定点[-1.0, 1.0)0.002° 级满足多数场景FFT输出实部/虚部Q8.15 或 Q1.31依赖缩放配置与点数相关溢出优先后级再截位CORDIC相位输出Q1.15 或 Q2.15 弧度[-π, π)单位 1 LSB ≈ 0.00003 rad表中的“FFT输出实部/虚部”位宽需要根据缩放策略Scaling Schedule决定。Vivado FFT IP 核提供两种路径一种是手动指定每一级蝶形运算的缩放因子另一种是选择 Block Floating Point 或 Unscaled。测相位差时我一般选 Block Floating Point因为它能按帧自动归一化避免频繁溢出但要注意输出时实部和虚部幅度是相对的不影响 atan2 的角度结果。2.3 CORDIC 旋转模式与角度收敛半径CORDIC 计算相位时输入 x、y 的范围直接影响收敛精度。大多数 IP 核要求输入满足 |x| |y| 不超过一定上限否则迭代中的向量增益会导致饱和。实际使用中FFT 输出频点峰值 X(k) 的动态范围很大信号较弱时 X(k) 很小这时直接送入 CORDIC 会因为定点量化噪声导致相位抖动偏大。常见做法是先对 X(k) 做一次右移归一化把较大的分量映射到接近满量程再入 CORDIC。另一个关键参数是 CORDIC 的迭代次数。并行流水线结构下每级迭代增加固定的寄存器开销迭代次数从 12 到 24 变化时角度精度大约从 0.05° 提升到 0.0005°但 LUT 和寄存器资源会成倍增加。对大多数相位差测量场景16 次迭代已经足够再往上增加迭代次数对精度提升很小反而在时序收敛上添麻烦。3. Verilog实现双通道同步采样、FFT IP核配置与CORDIC流水线级联3.1 双通道 ADC 数据同步与 FIFO 对齐相位差测量最忌讳两路信号采样时刻不一致。如果两片 ADC 共用同一个采样时钟理论上打拍延迟相同但由于 PCB 布线长度差异和芯片内部采保电路不一致实际采样点也会有皮秒级偏差这类固定偏差可以通过校准补偿。但如果是两片 ADC 分别来自不同时钟域必须先做跨时钟域处理否则相位差结果会随机抖动。我一般会在 ADC 接口后面做两级同步// 双通道同步采样模块 module adc_dual_sync #( parameter DATA_WIDTH 12 )( input wire clk_adc, input wire rst_n, input wire [DATA_WIDTH-1:0] adc_ch0, input wire [DATA_WIDTH-1:0] adc_ch1, input wire adc_valid, output reg [DATA_WIDTH-1:0] ch0_sync, output reg [DATA_WIDTH-1:0] ch1_sync, output reg valid_sync ); reg adc_valid_d; always (posedge clk_adc or negedge rst_n) begin if (!rst_n) begin ch0_sync {(DATA_WIDTH){1b0}}; ch1_sync {(DATA_WIDTH){1b0}}; valid_sync 1b0; adc_valid_d 1b0; end else begin // 对 valid 信号打一拍让数据信号和 valid 对齐 adc_valid_d adc_valid; ch0_sync ch0; ch1_sync ch1; valid_sync adc_valid_d; end end endmodule这里adc_valid_d是把 valid 打一拍后再作为模块输出的valid_sync目的是补偿内部 ADC 数据总线上数据晚于 valid 一个时钟的问题。没有对齐的话FFT 的输入帧会包含错误采样点表现为相位谱上出现随机毛刺。如果两路 ADC 的 valid 信号本身不同步则需要先各自进 FIFO再按水的深度差做对齐一般对齐到同一帧起始位置。3.2 Vivado FFT IP核的配置参数与Stream接口FFT IP 核的配置对最终相位精度影响很大。打开 Xilinx Vivado 的 FFT IP 配置界面时我建议重点看四个参数FFT 点数128、256、512、1024。点数翻倍频率分辨率减半但每帧时间也翻倍。测相位差时如果信号是单频、频率稳定256 点就够如果信号频率有漂移选 512 点更稳妥。目标时钟频率决定 IP 核选择流水线 Streaming 还是 Radix-4 Burst。Streaming 结构每时钟输入一个样本适合连续帧处理Burst 结构吞吐量低但资源少。缩放策略建议 Block Floating Point能自适应防溢出。输出顺序设为 Natural Order省去后续比特位逆序重排的麻烦。下面是一段常见 AXI-Stream 接口的实例化模板// Vivado FFT IP 核连接示例 (256点Streaming) wire [15:0] fft_xn_real {ch0_sync[11], ch0_sync, 3b000}; // 12bit ADC扩展到16bit wire [15:0] fft_xn_imag 16b0; // 实信号虚部填0 wire [31:0] s_axis_data_tdata {fft_xn_real, fft_xn_imag}; wire s_axis_data_tvalid valid_sync; wire s_axis_data_tready; wire s_axis_data_tlast data_last; // 每帧最后一位拉高 wire [31:0] m_axis_data_tdata; wire m_axis_data_tvalid; wire m_axis_data_tlast; xfft_0 u_fft ( .aclk (clk_adc), .aresetn (rst_n), .s_axis_config_tdata(32h0000_0000), // 配置正变换 .s_axis_config_tvalid(1b1), .s_axis_config_tready(), .s_axis_data_tdata (s_axis_data_tdata), .s_axis_data_tvalid (s_axis_data_tvalid), .s_axis_data_tready (s_axis_data_tready), .s_axis_data_tlast (s_axis_data_tlast), .m_axis_data_tdata (m_axis_data_tdata), .m_axis_data_tvalid (m_axis_data_tvalid), .m_axis_data_tlast (m_axis_data_tlast) );讲述一下数据拼接逻辑s_axis_data_tdata是高 16 位实部、低 16 位虚部的拼接对于实信号输入虚部直接给 0。tlast信号在每帧最后一个采样点拉高FFT 才能识别一帧的边界。实例化后不要忘记看tready如果 FFT 端反压前级 ADC 数据要保持有效状态并暂停丢弃。3.3 峰值索引提取与CORDIC IP核实例化FFT 输出流不是一个一个频点按自然顺序排列吗实际上 Natural Order 输出时每个时钟输出一个复数我们需要在时域上定位幅度谱最大值对应的索引。最简单的做法是对实部虚部求平方和再与当前最大值比较。注意平方后相乘会扩大位宽通常乘完后截取高 16 位比较即可不需要保留全精度。定位到峰值频率点后把该点的实部和虚部锁存到寄存器再送 CORDIC。CORDIC IP 核在 Vivado 里选择 Vector Translation 模式输入 X 通道为峰值实部Y 通道为峰值虚部输出相位为 atan2 结果。实例化片段如下// CORDIC IP核矢量模式输出相位 wire [15:0] cordic_phase_ch0; wire [31:0] cordic_result; // 假设 X、Y 输入位宽 16 位输出相位 16 位 cordic_0 u_cordic ( .aclk (clk_adc), .aresetn (rst_n), .s_axis_cartesian_tdata({peak_re_ch0, peak_im_ch0}), .s_axis_cartesian_tvalid(peak_valid), .s_axis_cartesian_tready(cordic_ready), .m_axis_dout_tdata (cordic_result), .m_axis_dout_tvalid (cordic_phase_valid) ); assign cordic_phase_ch0 cordic_result[15:0]; // 输出相位单位为弧度补码CORDIC 输出相位值一般按 2π 归一化为补码即 16 位全幅对应 [0, 2π)。计算两路相位差时不能直接相减因为减法结果可能跑到 [-2π, 2π) 之外需要一次条件补偿reg [16:0] phase_diff; always (posedge clk_adc) begin if (cordic_phase_valid_ch0 cordic_phase_valid_ch1) // 为减法预留1位符号位 phase_diff {1b0, phase_ch0} - {1b0, phase_ch1}; end // 角度折叠到 [-π, π) reg [15:0] phase_diff_wrapped; always * begin if (phase_diff[16]) // 负数 phase_diff_wrapped phase_diff 16h8000; else if (phase_diff 16h7FFF) phase_diff_wrapped phase_diff - 16h8000; else phase_diff_wrapped phase_diff[15:0]; end这里的折叠逻辑是当差值大于半幅值 π说明角度跨过了 2π 边界需要减去一个圆周当差值为负则加上 π 偏移折算到负半区。实际输出可以直接按等比例换算成角度比如补码 0x8000 代表 π0xC000 代表 -π/2。3.4 两路CORDIC复用与数据流控制两路信号各自做一次 FFT 后是否要各自例化一个 CORDIC如果器件资源充足分开例化最简单但更好的做法是两路 FFT 峰值数据按时间先后送入同一个 CORDIC实现分时复用。CORDIC 流水线模式下输入间隔仅需满足其吞吐间隔我一般用一个小型状态机控制tdata的通道选择依次处理 ch0 和 ch1 的峰值复数输出侧用tvalid区分结果归属localparam IDLE 2d0; localparam START_CH0 2d1; localparam START_CH1 2d2; localparam WAIT_RESULT 2d3; reg [1:0] cordic_state; always (posedge clk_adc or negedge rst_n) begin if (!rst_n) ... else case (cordic_state) START_CH0: begin // 送入ch0峰值然后进入等待 cordic_data {peak_re_ch0, peak_im_ch0}; cordic_state WAIT_RESULT; end START_CH1: begin cordic_data {peak_re_ch1, peak_im_ch1}; cordic_state WAIT_RESULT; end // ... endcase end注意 CORDIC 的tready信号在流水线未满时不会持续拉高状态机必须检查tready才能把数据推入否则会丢样本。这个小坑比功能错误更难排查仿真时数据流正常上板后偶发相位跳变多半是这里没有握手。4. 优化策略窗函数抑制频谱泄漏、多帧平均与CORDIC资源复用4.1 非整周期采样下的频谱泄漏与窗函数选择FFT 隐含假设是采样窗口内信号周期数为整数。当信号频率不是采样率除以 FFT 点数的整数倍时频谱峰值会洩漏到相邻频点导致峰值索引处的复数幅度偏低相位计算扰动增大。相位差检测中我见过不少方案完全不处理泄漏结果就是信号频率每变化 0.1%相位差输出抖动达到几度。解决手段是加窗。汉宁窗Hann是常用折中主瓣宽但旁瓣衰减快适合幅度和相位同时关注的场景。加窗在 Verilog 里实质上是把每个采样点乘以窗系数可以用一个 ROM 预存 256 个窗系数也可以在线用正弦表生成。用 ROM 的例子// 窗系数ROM存放汉宁窗的16位定点值 reg [11:0] addr_cnt; wire [15:0] window_coeff; window_rom u_win ( .clk (clk_adc), .addr (addr_cnt), .data (window_coeff) ); // 每帧更新窗系数地址 always (posedge clk_adc) begin if (frame_start) addr_cnt 0; else if (adc_valid) addr_cnt addr_cnt 1b1; end // 采样数据与窗系数相乘 wire [31:0] win_mul $signed({ch0_sync, 4b0}) * $signed(window_coeff); wire [15:0] ch0_win win_mul[27:12]; // 截取中间位宽窗系数的位宽选 16 位足够乘法后截位时要从高位往低位截保留带符号整数部分避免截掉符号位。加窗后信号能量变低FFT 输出幅度会成比例减小但这不影响 CORDIC 的 atan2 角度只要不触发 Block Floating Point 的归一化下限就行。4.2 多帧平均与相位差的滑动滤波单帧测量受 ADC 量化噪声和系统随机抖动影响直接输出可能存在 0.1° 到 0.5° 的波动。一种非常简单有效的优化是滑动平均保留最近 N 帧的相位差结果输出其平均值。N 取 8 或 16 时随机抖动可以降一个量级。相位差平均和普通数据平均不同因为相位值会跨 0 度边界直接累加平均会出现假跳变。我处理这类问题的方式是先把相位差拆分成角度的正弦和余弦分别累加再 atan2这样天然规避了 ±π 环绕问题// 滑动平均相位差用sin/cos累积避免跨0跳变 reg signed [31:0] sum_cos; reg signed [31:0] sum_sin; reg [3:0] cnt_avg; always (posedge clk_adc) begin if (phase_valid) begin // 相位查正余弦表后累加 sum_cos sum_cos cos_lut(phase_diff_wrapped); sum_sin sum_sin sin_lut(phase_diff_wrapped); cnt_avg cnt_avg 1b1; if (cnt_avg 4d15) begin avg_cos sum_cos 4; // 算术右移除以16 avg_sin sum_sin 4; sum_cos 0; sum_sin 0; end end end4.3 资源优化对比CORDIC复用、分时FFT与数据宽度压缩优化不是单纯压缩而是在延迟与资源之间选一个平衡点。下表是我在 Xilinx Artix-7 上评估两类实现的实际参考具体资源随器件波动但有参考意义方案FFT 配置CORDIC 数量每帧输出延迟LUT 占用DSP48 占用双路并行全流水两个 256 点 Streaming2短约 5μs 100MHz高高双路分时 FFTCORDIC 复用单核 512 点拼接两路1中约 10μs中中最低资源版256 点 BurstCORDIC 复用1长约 30μs低低分时 FFT 这种方式有一个隐蔽前提两路信号必须可以在同一帧内先后输入。把 ch0 和 ch1 拼接成 512 点序列等效于将两路信号在时域级联FFT 后按频率分量拆开处理这样只需要一个 FFT 核。但这种做法要求两路信号频率不能重叠在拼接频谱的折叠区所以实际项目中我更多采用“双路并行 FFT CORDIC 时分”的方案平衡性和可调试性最好。4.4 时序优化与流水线深度调整当采样率跑到 100MHz 以上FFT Streaming 模式和 CORDIC 之间的组合逻辑路径可能变成长路径导致时序违例。常见处理是插入寄存器切片把 CORDIC 输入端的数据寄存一拍问题大多能解决。另一个技巧是缩小 CORDIC 输入位宽峰值 X(k) 的实部虚部从 32 位截到 16 位相位精度损失有限但 LUT 面积会大幅下降时序也更易收敛。5. 仿真验证进阶MATLAB参考模型、FIFO边界条件与单点DFT替换法5.1 用MATLAB生成参考数据并与Verilog仿真结果比对没有参考模型就调 Verilog 相位检测逻辑等于闭着眼睛改代码。我通常在 MATLAB 里生成两路正弦波量化到 12 位再导出十六进制文本或$readmemh文件作为测试向量。下面这段可以生成 256 点测试数据fs 10e6; % 采样率 10MHz f0 100e3; % 信号频率 100kHz256点下不是整数周期 t (0:255)/fs; d 14; % 量化位数 ph0 0.3; % ch0初始相位 rad ph1 1.1; % ch1初始相位 rad ch0 round(sin(2*pi*f0*t ph0) * 2^(d-1)); ch1 round(sin(2*pi*f0*t ph1) * 2^(d-1)); fid fopen(ch0.txt,w); fprintf(fid,%04X\n,ch0 bitshift(bitand(ch0, hex2dec(8000)), 1));这里刻意让 256 点窗口内信号周期数为 2.56验证加窗算法是否正确。把 MATLAB 的 FFT 相位计算结果和 Modelsim 导出的相位差输出做曲线对比若偏差小于 0.01°说明实现基本正确。5.2 仿真中容易踩的边界tlast帧同步与valid握手仿真 FFT 时最先看到的异常通常是频谱错乱原因是tlast拉高的位置不对。Vivado FFT IP 核要求tlast在每个帧的最后一个有效数据处拉高如果设计里用一个计数器直接计数到 N-1 就拉高但前面有 FIFO 或握手停顿计数器和实际数据流错位帧边界就错了。我建议在仿真时用$display打印tvalid/tready的握手次数和实际帧长对比能快速定位这类问题。// 仿真脚本片段 reg [8:0] sample_cnt; always (posedge clk_adc) begin if (s_axis_data_tvalid s_axis_data_tready) begin sample_cnt sample_cnt 1b1; if (sample_cnt 255) $display(FFT frame boundary reached); end end仿真通过后上板另一个常见现象是静态相位偏移很大。这通常来自两路 ADC 本身的采样延迟差异或模拟前端滤波器群延迟差异需要额外做校准输入同相信号测量固定偏差存入寄存器并在后续相位差结果中减去即可。5.3 进阶用CORDIC做单点DFT替代整帧FFT如果待测信号频率已经确知且稳定整套 FFT 实际上有点浪费。更轻量级的方式是单点 DFTGoertzel 算法核心思路一致在特定频点计算复数投影再用 CORDIC 求相位差。这种思路把大量 FFT 蝶形运算替换为一次乘累加循环实现在 Verilog 中更简单且频率分辨率不受帧长限制。适用于超声波流量计这类发射频率固定、只有相位差变化的场景。沿用 CORDIC 做角度输出只是输入从 FFT 输出的复数变成了单点 DFT 的累加结果后续相位差折叠和平均策略不变。这时系统的瓶颈从资源变成了 ADC 采样时钟的稳定度实际效果往往比盲目上 FFT 更精准。本文还有配套的精品资源点击获取