STM32频率测量实战:输入捕获与FFT选型、代码与避坑 在嵌入式测量这块频率检测看起来是个小需求真做起来却能把人磨得没脾气。我这两年做过的项目里从逆变器输出频率监测、电机转速反馈到音频信号分析、传感器谐振点扫描几乎每一类都绕不开把一个周期性信号的频率测准这件事。STM32 上能走的路子其实就两条一条是拿定时器的输入捕获硬件去数边沿另一条是把信号采样进内存做 FFT 频谱分析。前者快、准、几乎不占 CPU但有前提后者信息量大、能看谐波可是分辨率和实时性又卡得死。这篇就把我在 STM32 上折腾输入捕获和 FFT 测频的全过程摊开讲包括寄存器级的关键配置、参数怎么算、代码怎么写、哪里最容易翻车。不管你是刚上手 CubeMX 的新人还是已经在用 F4、H7 做数字电源、电机控制的老手应该都能从这里捞到点能直接抄的东西。1. 测频需求拆解与两条技术路线的选型逻辑1.1 先搞清楚你要测的到底是什么信号选路线之前必须先回答一个问题信号长什么样。这句话听起来像废话但我见过太多人上来就问STM32 怎么测频率然后拿一个 10mV 峰峰值的正弦信号直接怼到 GPIO 上结果读数乱跳最后怪芯片不行。信号的三个属性决定了方案波形类型方波、矩形脉冲这类已经数字化的信号可以直接进定时器捕获通道正弦、三角、任意波形必须先整形。幅度范围STM32 的 IO 是 3.3V 域输入高电平门限大概在 0.7×VDD 附近也就是 2.3V 左右。信号峰值低于这个值捕获通道根本认不出来。超过 VDD0.3V 又会打坏 IO 的钳位二极管。频率范围与信噪比这是最关键的。低频段几十 Hz 到几 kHz和噪声大的场合FFT 反而更稳高频段几十 kHz 到几 MHz输入捕获优势明显。我一般的判断顺序是先看频率跨度。如果被测信号频率变化范围超过两个数量级比如既要测 5Hz 又要测 500kHz那就得做分段处理或者直接上等精度测量法。如果频率稳定在某个窄区间用输入捕获做起来最省事。1.2 输入捕获硬件直接给你答案输入捕获的本质是用定时器记录边沿到来的时刻。你配置一个定时器以固定时钟跑着每当捕获通道检测到你设定的边沿上升沿、下降沿或者双边沿硬件就把当前计数器 CNT 的值复制到捕获比较寄存器 CCR 里同时置位标志。两次捕获之间 CCR 的差值就是这两个边沿之间的时间间隔。这套机制的好处非常直接精度取决于定时器时钟比如 84MHz 主频的 F4定时器时钟 84MHz预分频后按 1MHz 计数时间分辨率就是 1µs对应的频率误差在 1kHz 信号上只有 0.1%。几乎不占 CPU。你可以让它完全跑在硬件里只用中断或者 DMA 在数据攒够之后取一次。响应快。一个周期就能出结果适合做闭环控制里的实时反馈。缺点同样明显它只能告诉你周期这一个信息。信号里有没有谐波、有没有噪声毛刺、是不是发生了畸变输入捕获一概不知。而且信号本身得是干净的方波边沿得陡。1.3 FFT把信号拆开看的代价FFT 的路子是另一套逻辑。先用 ADC 以固定采样率把信号采成一串离散点然后做快速傅里叶变换把时域数据变成频域谱线找幅度最大的那根谱线对应的频率。它的优势在于信息量你能同时看到基波、二次谐波、三次谐波能看到噪声底能看到杂散。做音频分析、振动分析、谐波含量评估非它不可。代价则是三个绕不开的约束第一采样率决定上限。奈奎斯特采样定理要求采样率至少是被测信号最高频率的两倍实际工程里我一般取 5 倍以上因为抗混叠滤波器的过渡带没那么理想。第二点数决定分辨率。频率分辨率 Δf fs / Nfs 是采样率N 是 FFT 点数。100kHz 采样、1024 点Δf 就是 97.66Hz。要测 1kHz 附近两个相差 50Hz 的分量这分辨率根本分不开。第三实时性差。攒够 1024 个点才能做一次变换采样率 100kHz 的话就是 10.24ms 一次。对于需要毫秒级闭环的场合这个延迟得提前算进控制周期里。1.4 两条路线怎么选怎么合给一张我实际项目里用的对照表直接照着对号入座维度输入捕获FFT适用频率1Hz ~ 数 MHz取决于 fs一般 fs/2频率精度高取决于定时器时钟受 Δf 限制需插值校正CPU 占用极低中等1024 点 F4 约 100~200µs内存占用几字节1024 点 float 约 8~16KB谐波/噪声信息无完整频谱信号要求干净方波任意需抗混叠典型场景转速、逆变频率、脉冲计数音频、振动、谐波分析实际项目里我最常用的做法是融合FFT 先粗测找出主频大概落在哪个位置再用输入捕获在那个频段做精测两者互相校验。比如做谐振点扫描时先用 FFT 扫出谐振峰在 20kHz 附近再切到输入捕获模式用 1µs 分辨率把谐振频率精确到个位数 Hz。这个组合拳在 STM32F4 上跑起来很舒服因为 F4 有 FPUFFT 做起来不肉。2. 输入捕获测频的核心原理与寄存器级细节2.1 从计数器差值到频率的换算先把最核心的公式摆出来这个搞明白了后面都是细节信号周期 T (CCR2 - CCR1) × (PSC 1) / f_tim 信号频率 f f_tim / ((PSC 1) × (CCR2 - CCR1))其中 f_tim 是定时器的输入时钟PSC 是预分频寄存器的值。举个具体例子。STM32F407 的 TIM3 挂在 APB1 上APB1 定时器时钟是 84MHz。我设 PSC 83那么计数频率就是 84MHz / 84 1MHz也就是每 1µs 计一个数。被测信号周期对应 1000 个计数频率就是 1MHz / 1000 1kHz。这里有个容易忽略的点预分频值要跟着被测频率范围调整。如果你固定用 1MHz 计数测 10Hz 信号时CCR 差值会达到 100000超过 16 位定时器的 65535 上限直接溢出。我踩过这个坑当时读数一直偏排查了半天才发现是溢出没处理。一个实用的分段策略被测频率范围定时器计数频率PSC 设置84MHz 时钟单周期最大计数1Hz ~ 100Hz100kHz839100000需溢出处理100Hz ~ 10kHz1MHz831000010kHz ~ 500kHz10MHz71000500kHz ~ 5MHz84MHz01682.2 单通道捕获、双通道捕获和 PWM 输入模式CubeMX 里配置输入捕获有三种典型玩法用错了会绕远路。单通道捕获是最基础的形式。选一个通道设定边沿极性中断里读 CCR。测周期需要连续捕获两次同极性边沿代码里自己算差值。优点是配置简单缺点是两个边沿之间如果发生溢出得自己维护溢出计数。双通道捕获用两个通道分别抓上升沿和下降沿。这适合测占空比因为 CH1 抓上升沿得到周期起点CH2 抓下降沿得到高电平结束点两个 CCR 一减就是高电平时间。PWM 输入模式是 STM32 定时器的一个硬件特性我平时最爱用。它的原理是把通道 1 配成上升沿捕获并映射到 TI1通道 2 配成下降沿捕获并映射到同一个 TI1通过 IC2 的间接映射同时把定时器设成从模式复位Slave Mode Reset触发源选 TI1FP1。这样每来一个上升沿计数器 CNT 自动清零同时 CCR1 记录周期值、CCR2 记录高电平时间。这套配置的好处是硬件自动清计数你完全不用管溢出问题只要周期不超过 65535 个计数读一次 CCR1 就是完整周期。注意PWM 输入模式下两个通道必须映射到同一个 TI 输入。CubeMX 里配置时CH2 的 Input Channel 要选 IndirectInput Trigger 选 TI1FP1 或 TI1FP2别选成独立的 TI2否则两个通道各抓各的数据对不上。2.3 PWM 输入模式的完整配置代码CubeMX 生成的代码框架下关键配置我一般手工再确认一遍void MX_TIM3_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig {0}; TIM_MasterConfigTypeDef sMasterConfig {0}; TIM_IC_InitTypeDef sConfigIC {0}; htim3.Instance TIM3; htim3.Init.Prescaler 83; // 84MHz - 1MHz htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 0xFFFF; htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_DISABLE; if (HAL_TIM_IC_Init(htim3) ! HAL_OK) { Error_Handler(); } sClockSourceConfig.ClockSource TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(htim3, sClockSourceConfig); sMasterConfig.MasterOutputTrigger TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(htim3, sMasterConfig); // 通道1上升沿直接映射 TI1 sConfigIC.ICPolarity TIM_INPUTCHANNELPOLARITY_RISING; sConfigIC.ICSelection TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler TIM_ICPSC_DIV1; sConfigIC.ICFilter 0x08; // 数字滤波抗毛刺关键 HAL_TIM_IC_ConfigChannel(htim3, sConfigIC, TIM_CHANNEL_1); // 通道2下降沿间接映射到 TI1 sConfigIC.ICPolarity TIM_INPUTCHANNELPOLARITY_FALLING; sConfigIC.ICSelection TIM_ICSELECTION_INDIRECTTI; HAL_TIM_IC_ConfigChannel(htim3, sConfigIC, TIM_CHANNEL_2); // 从模式复位触发源 TI1FP1 HAL_TIM_SlaveConfigSynchro(htim3, TIM_SLAVEMODE_RESET); __HAL_TIM_SET_TRIGGER_SOURCE(htim3, TIM_TS_TI1FP1); }ICFilter这个参数值得单独说。它的取值 0~15对应以 fDTS 为采样时钟的连续采样次数。设成 8 意味着信号要连续稳定 8 个采样周期才认一次边沿。信号源阻抗高、走线长、旁边有大功率开关管的时候这个值必须往上调否则噪声毛刺会疯狂触发捕获读数直接飞。我做过一个电机项目PWM 输出旁边的采集线上感应了尖峰滤波设 0 的时候频率读数在 800Hz 和 1600Hz 之间乱跳调到 12 就彻底稳了。2.4 高频信号的两级计数方案当被测频率超过 5MHz单纯靠捕获已经开始吃力因为每个边沿都要进一次中断。这时候我更推荐定时器级联方案用一个定时器做事件计数外部时钟模式另一个定时器做闸门。具体做法是把被测信号接到 TIMx_ETR 引脚把 TIMx 配置成外部时钟模式 2External Clock Mode 2让每个信号边沿直接驱动计数器加一。然后再用另一个定时器产生固定闸门时间比如 100ms。闸门结束时读一次计数器的值除以时间就是频率。// 外部计数定时器配置片段 htim2.Instance TIM2; htim2.Init.Prescaler 0; htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFFFFFF; // 32位定时器几乎不会溢出 TIM_ClockConfigTypeDef sClk {0}; sClk.ClockSource TIM_CLOCKSOURCE_ETRMODE2; // 外部时钟模式2 sClk.ClockPolarity TIM_CLOCKPOLARITY_RISING; sClk.ClockPrescaler TIM_CLOCKPRESCALER_DIV1; sClk.ClockFilter 0; HAL_TIM_ConfigClockSource(htim2, sClk);这个方案在 F4 上测到 20MHz 方波都很稳因为计数器本身能跑很快而 CPU 只在闸门结束时读一次数。3. FFT 测频的工程实现要点3.1 ADC 采样链路定时器触发是唯一正解FFT 测频最怕采样率不准。如果你用软件循环里调HAL_ADC_GetValue()逐个采采样间隔会受中断、分支预测、Cache 影响抖动可能到几个微秒。1024 个点累积下来等效采样率偏了 1%频率读数就跟着偏 1%。正确做法是用定时器硬件触发 ADC定时器 TIM6 或者 TIM8_TRGO 作为触发源输出频率就是目标采样率。ADC 配置成外部触发模式External Trigger Conversion Source Timer Trigger Out eventDMA 循环搬运。DMA 设成 Circular 模式半传输和传输完成各产生一次中断天然形成双缓冲。采样率 100kHz 的配置TIM6 时钟 84MHzPSC 0ARR 839触发频率就是 84MHz / (01) / (8391) 100kHz。// TIM6 触发源配置 htim6.Instance TIM6; htim6.Init.Prescaler 0; htim6.Init.Period 840 - 1; // 100kHz 触发 HAL_TIM_Base_Init(htim6); TIM_MasterConfigTypeDef sMasterConfig {0}; sMasterConfig.MasterOutputTrigger TIM_TRGO_UPDATE; sMasterConfig.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(htim6, sMasterConfig);ADC 这边的关键参数有两个。采样时间必须给够信号源阻抗越高给得越长。我的经验是源阻抗 10k 以下采样时间设 84 个 ADC 时钟周期约 1µs比较稳源阻抗到 100k得往 480 个周期以上走。采样时间不够的典型症状是高频段幅度塌陷低频正常这个现象很容易被误判成滤波器问题。分辨率按需求选12 位够用追求更高精度可以开硬件过采样把 12 位过采样到 16 位代价是吞吐率下降。3.2 采样率、点数与分辨率的三角权衡这三个参数互相拉扯我用一个具体案例把计算过程走一遍。目标测 1kHz 附近的信号希望分辨率优于 10Hz同时能实时输出刷新率不低于 20 次每秒。第一步定分辨率。Δf fs / N ≤ 10Hz。第二步定刷新率。一次变换时间 N / fs要求 ≤ 50ms即 N / fs ≤ 0.05。第三步定频率上限。如果关心到 5 次谐波即 5kHz那 fs ≥ 2×5000 10kHz工程上取 5 倍余量fs 50kHz。把 fs 50kHz 代进去N ≥ 50kHz / 10Hz 5000取 8192 点2 的幂。变换时间 8192 / 50000 163.8ms。这就不满足 50ms 的要求了说明光靠 FFT 的原始分辨率做不到必须上插值校正。这也是实际项目的常态原始分辨率不够时靠算法补。改成 fs 20kHzN 1024Δf 19.53Hz变换时间 51.2ms。原始分辨率 19.53Hz看着不够但加三点抛物线插值之后等效精度能做到 Δf 的 1/50 甚至更好也就是 0.4Hz 左右完全达标。1024 点、20kHz 采样、插值校正这套组合我用了很多次在 F4 上单次变换加找峰插值总共 150µs 左右刷新率完全不是问题。3.3 窗函数、泄漏和栅栏效应这两个概念是 FFT 测频精度不高的头号元凶必须讲清楚。频谱泄漏的根源是非整周期截断。你截取的 1024 个点如果信号在里面恰好走了完整的整数个周期那么 FFT 之后能量会集中在单根谱线上非常干净。但只要差一点点能量就会向两边扩散主瓣变宽旁瓣抬高。信号频率和采样率不是整数倍关系时泄漏必然发生。解决泄漏靠加窗。常用的几种窗和它们的取舍窗函数主瓣宽度旁瓣衰减幅度修正系数适用场景矩形窗最窄-13dB1.0整周期同步采样汉宁窗中-31dB2.0通用首选汉明窗中-43dB1.85旁瓣要求更高布莱克曼窗宽-58dB2.38强弱信号共存注意幅度修正这一列。加了汉宁窗之后谱线峰值会变成真实幅度的一半左右因为窗函数的相干增益是 0.5。做幅度测量时必须除以这个系数只测频率的话可以不管但找峰的相对关系会变还是建议一起修正。栅栏效应是另一个问题。FFT 的输出频率是离散的只能落在 k·Δf 这些格点上就像透过栅栏看东西。真实频率 1000HzΔf 19.53Hz那它落在第 51.2 根谱线上实际只会显示第 51 根最大读数 995.9Hz差了 4Hz。补零可以让谱线变密但它不能提高真实分辨率只是把 sinc 插值曲线画得更细峰值位置依然受限于原始数据的信息量。真正有效的手段是插值校正。3.4 三点抛物线插值把精度再提一个量级插值的原理很简单。找到最大谱线位置 k 和它的幅度 y0以及左右相邻两根谱线的幅度 y_{-1} 和 y_{1}。在峰值附近主瓣形状近似抛物线用这三点拟合顶点偏移量是float delta 0.5f * (y_prev - y_next) / (y_prev - 2.0f * y0 y_next); float freq (k delta) * fs / N;这个式子看着像个经验公式其实有理论依据矩形窗的主瓣是 sinc 函数在峰值附近泰勒展开后确实接近抛物线。加汉宁窗之后主瓣更宽更平滑抛物线拟合的误差更小。实测下来1024 点、20kHz 采样对 1kHz 正弦做插值读数能稳定在 999.8 ~ 1000.2Hz 之间误差 0.02%。这已经超过大多数应用的需求了。注意插值时 k 不能取 0 或者 N/2-1因为取不到左右邻点。另外找峰要从 k 1 开始扫跳过直流分量。很多人忘了这一步结果 0 号谱线的直流泄漏最大永远被当成峰值频率读数恒定接近 0。3.5 定点 FFT 还是浮点 FFT这个选择取决于芯片有没有 FPU。STM32F4、F7、H7 系列带硬件浮点单元直接用arm_rfft_fast_f32或者arm_cfft_f32代码写起来干净精度也好。1024 点实数 FFT 在 F407 上168MHz大约 200µs 以内。内存方面1024 点 float 输入缓冲 4KB输出 4KB再加一份模值数组 2KB一共 10KB 左右F407 的 192KB SRAM 轻松容纳。F0、F1、F3 这些没有 FPU 的芯片浮点运算是软件模拟慢得离谱必须用定点版本arm_rfft_q15或arm_cfft_q15。定点 FFT 有两个坑一是输入要缩放到 Q15 范围也就是 ±32767ADC 的 12 位数据左移 3 位刚好二是 CMSIS 的定点 FFT 内部会做逐级移位防止溢出输出结果比实际小需要自己按1 (log2(N) - 1)之类的系数还原。这块我一开始没注意测出来的幅度只有真实值的 1/16查了好久才知道是定标问题。4. 完整实操流程与关键代码实现4.1 信号前端调理电路别跳过这一步把信号送进 MCU 之前前端必须处理干净。我总结了三件必做的事。第一件电平搬移和钳位。3.3V 系统里输入信号要限制在 0 ~ 3.3V。做法是串一个 1kΩ 限流电阻再在 IO 到 VDD 和 IO 到 GND 各接一个肖特基二极管比如 BAT54S构成钳位。这样即使信号幅度冲到 5V钳位二极管导通后 IO 上的电压也就 3.3V 0.3V 左右不会损坏芯片。第二件交流耦合加直流偏置FFT 用。ADC 只能采正电压而正弦信号有负半周。做法是串一个 100nF 耦合电容再通过两个 10kΩ 电阻分压到 VREF/2 的偏置点把信号抬到 1.65V 直流上。这之后 ADC 采到的就是一个 0 ~ 3.3V 摆动的信号去掉直流分量后就是原始交流信号。第三件抗混叠滤波FFT 必做。采样率 20kHz信号里如果有 15kHz 的高频成分会混叠到 5kHz 上变成一根假谱线你怎么找峰都是错的。做法是在 ADC 输入前加一个二阶有源低通截止频率设在 fs/2 稍往下的位置比如 8kHz用 Sallen-Key 拓扑加一个运放就能搞定。输入捕获的前端则要多加一步整形。正弦波直接进定时器捕获通道是不行的边沿不够陡加上噪声捕获会乱触发。用一片 LM393 或者 TL3016 做比较器配上正反馈构成迟滞把正弦整形成方波。迟滞窗口的大小按信号幅度定一般取峰峰值的 10%~20%。这一步做过之后输入捕获的读数会从乱跳直接变成稳如老狗。4.2 输入捕获的读取代码与溢出处理PWM 输入模式下捕获回调里有几个坑要提前避开。volatile uint32_t g_period_ticks 0; volatile uint32_t g_high_ticks 0; volatile float g_freq_hz 0.0f; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { uint32_t ccr1 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); uint32_t ccr2 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2); if (ccr1 0) { g_period_ticks ccr1; // 从模式复位直接就是周期 g_high_ticks ccr2; // 计数频率 1MHz周期单位是 us g_freq_hz 1000000.0f / (float)ccr1; } } }这段代码看着简单但有个 HAL 库的历史遗留问题必须提醒在 PWM 输入模式 从模式复位下HAL 的捕获回调可能只触发一次。原因是触发时 CC1IF 和 CC2IF 会同时置位而 HAL 的回调机制在清除一个标志之后另一个标志的处理被跳过了。解决方案有两个。一是直接重写TIM3_IRQHandler自己判断标志位void TIM3_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim3, TIM_FLAG_CC1) __HAL_TIM_GET_IT_SOURCE(htim3, TIM_IT_CC1)) { __HAL_TIM_CLEAR_IT(htim3, TIM_IT_CC1); uint32_t ccr1 __HAL_TIM_GET_COMPARE(htim3, TIM_CHANNEL_1); uint32_t ccr2 __HAL_TIM_GET_COMPARE(htim3, TIM_CHANNEL_2); if (ccr1) { g_period_ticks ccr1; g_high_ticks ccr2; g_freq_hz 1000000.0f / (float)ccr1; } } if (__HAL_TIM_GET_FLAG(htim3, TIM_FLAG_CC2) __HAL_TIM_GET_IT_SOURCE(htim3, TIM_IT_CC2)) { __HAL_TIM_CLEAR_IT(htim3, TIM_IT_CC2); } }二是干脆不用中断用 DMA 突发搬运DMA Burst。把 TIM3 的 DMAR 寄存器配好让每次 CC1 捕获事件自动把 CCR1、CCR2 搬到内存数组里攒够一批再统一处理。这个方案 CPU 占用最低做高刷新率采集的时候特别香。如果被测频率低到周期超过 65535 个计数16 位定时器就不够了。两个办法换 TIM2 或 TIM5 这种 32 位定时器或者保持 16 位在更新中断里累加溢出次数volatile uint16_t g_overflow 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { g_overflow; } }周期计算就变成(g_overflow * 65536 ccr1)算完之后记得清零 g_overflow。4.3 ADC DMA FFT 的完整实现先给数组和 FFT 实例的定义#include arm_math.h #define FFT_SIZE 1024 #define SAMPLING_FREQ 20000.0f float32_t g_adc_buf[FFT_SIZE]; // DMA 目标缓冲 float32_t g_fft_in[FFT_SIZE * 2]; // rfft 输入需要 2N 空间 float32_t g_fft_out[FFT_SIZE]; // rfft 输出 N 空间 float32_t g_mag[FFT_SIZE / 2]; // 模值 static arm_rfft_fast_instance_f32 s_fft; static volatile uint8_t g_fft_ready 0; void FFT_Init(void) { arm_rfft_fast_init_f32(s_fft, FFT_SIZE); HAL_ADC_Start_DMA(hadc1, (uint32_t *)g_adc_buf, FFT_SIZE); HAL_TIM_Base_Start(htim6); // 启动触发定时器 }DMA 采用循环模式转换完成回调里置标志void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { g_fft_ready 1; } }主循环里处理void FFT_Process(void) { if (!g_fft_ready) return; g_fft_ready 0; // 第一步去直流 归一化 加汉宁窗 float32_t mean 0.0f; arm_mean_f32(g_adc_buf, FFT_SIZE, mean); for (uint32_t i 0; i FFT_SIZE; i) { float32_t v (g_adc_buf[i] - mean) / 2048.0f; // 归一到约 ±1 // 汉宁窗: 0.5 - 0.5*cos(2*pi*i/(N-1)) float32_t w 0.5f - 0.5f * arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1)); g_fft_in[i] v * w; } // 第二步实数 FFT正变换 arm_rfft_fast_f32(s_fft, g_fft_in, g_fft_out, 0); // 第三步求模。注意 rfft 输出格式 // out[0]DC实部, out[1]Nyquist实部之后 out[2k]实部, out[2k1]虚部 g_mag[0] fabsf(g_fft_out[0]); for (uint32_t k 1; k FFT_SIZE / 2; k) { float32_t re g_fft_out[2 * k]; float32_t im g_fft_out[2 * k 1]; g_mag[k] sqrtf(re * re im * im); } }注意 rfft 输出的打包格式这个跟arm_cfft_f32完全不同。我第一次用的时候按 Ooura 库的习惯去取数组结果整个谱是错的查了两小时才反应过来。CMSIS 里arm_rfft_fast_f32的flag 0是正变换把 N 个实数变成 N 个打包的复数前两个位置存 DC 和 Nyquist。找峰和插值float32_t FFT_FindPeakFreq(void) { uint32_t k_max 0; float32_t v_max 0.0f; // 从 bin 1 开始跳过直流 for (uint32_t k 1; k FFT_SIZE / 2 - 1; k) { if (g_mag[k] v_max) { v_max g_mag[k]; k_max k; } } if (k_max 0 || k_max FFT_SIZE / 2 - 1) return 0.0f; // 三点抛物线插值 float32_t y0 g_mag[k_max]; float32_t ym g_mag[k_max - 1]; float32_t yp g_mag[k_max 1]; float32_t denom ym - 2.0f * y0 yp; float32_t delta 0.0f; if (fabsf(denom) 1e-9f) { delta 0.5f * (ym - yp) / denom; } return ((float32_t)k_max delta) * SAMPLING_FREQ / (float32_t)FFT_SIZE; }4.4 两条路线结果融合的做法我一般会维护一个状态机上电先跑 FFT判断主频落在哪个区间同时看谱峰是否足够尖锐用峰值和相邻谱线的比值判断。峰够尖、信噪比够好就切到输入捕获模式精测如果频谱很脏、峰值不明显就继续用 FFT因为这时候输入捕获也会被噪声整得没法用。切换的时候有个细节定时器预分频要重设。根据 FFT 测出来的频率选 PSC让单周期计数落在 1000 ~ 30000 这个舒适区间。重设 PSC 之后建议把计数器复位一次避免新旧分频比混在一起导致第一帧数据错。融合结果用一个简单的加权最终频率 0.3 × FFT 结果 0.7 × 捕获结果。权重偏向捕获因为它的精度和响应速度都更好。这个系数是我在几个项目里试出来的经验值你可以根据自己信号的质量调整。5. 常见问题排查与避坑经验5.1 输入捕获读数异常的速查表现象可能原因排查方法解决读数恒定接近 0找峰从 bin 0 开始FFT打印谱线数组改为从 bin 1 开始读数乱跳、无规律无滤波或滤波太弱示波器看 IO 波形ICFilter 调大加硬件迟滞读数偏小约 2 倍捕获到双边沿看是否配成 BOTHEDGE改为单极性读数偏大约 2 倍PWM 模式下 ARR 干扰检查从模式触发源确认 TRGI TI1FP1高频段读数正常低频异常16 位溢出未处理加溢出计数打印累加 overflow 或换 32 位定时器回调只进一次HAL 标志清除顺序问题中断里打断点重写 IRQHandler 或改用 DMA完全无中断忘了 Start_IT检查初始化顺序两个通道都要 Start_IT切换频率后第一帧错PSC 切换余数打印 CCR 原始值切换后复位 CNT5.2 FFT 谱线不准的典型原因信号本身的频率其实很容易被测准难点在于系统误差。我按排查频率从高到低列一遍。采样率标称值和实际不符。这是最常见的。你以为 TIM6 触发频率是 100kHz结果因为时钟树里 APB1 分频算错实际是 84MHz/2 42MHz 进定时器触发出来变成 50kHz。频率读数就整体偏差一倍。验证方法在 DMA 完成回调里翻转一个 GPIO用示波器量连续两次回调之间的间隔反算实际帧周期再用帧周期除以 N 得到真实采样率。偏置电压没去干净。直流分量在 FFT 里会泄漏到低频段如果被测频率也低就会被淹没。我在一个振动检测项目里遇到过信号是 30Hz直流泄漏正好压在旁边峰位偏了 15%。加均值去除之后再测就对了。ADC 采样时间不足。现象是频率读数没问题但幅度随频率升高而明显衰减看起来像多了一个低通。排查方法是把采样时间翻倍如果幅度显著变化就是采样时间不够。信号源和采样时钟存在拍频。偶尔会遇到读数在某个值附近周期性缓慢摆动这不是算法问题是两个时钟源的频差造成的。如果信号源是外部设备提供的有条件的话让它和 MCU 共时钟问题立刻消失。窗函数和幅度修正不匹配。用了汉宁窗但没做 2 倍修正测量相对幅度会整体偏小。只测频率不受影响但如果你用多个峰值的比值做判断比如判断谐波含量就全错了。5.3 我在实际项目里踩过的几个大坑坑一CubeMX 里改了通道映射忘了改触发源。有一次我在 CubeMX 里把输入从 CH1 换到 CH2生成的代码里通道配置确实变了但TIM_TS_TI1FP1没跟着改还是指向 TI1。结果从模式复位永远不触发计数器一直往上跑到 ARR 再回绕周期数据全是随机的。这个坑浪费了我一个下午。教训是改配置之后把MX_TIMx_Init完整读一遍重点看从模式触发源那一行。坑二DMA 和 Cache 打架F7/H7 特有。在 H7 上做 ADC DMA FFT 的时候DMA 搬进 SRAM 的数据被 Cache 缓存了旧值FFT 算出来的是上一帧数据看起来就是结果滞后一帧。解决方法是把 DMA 目标缓冲放到 D2 域的 SRAM或者在读取前调用SCB_InvalidateDCache_by_Addr()清缓存。这个坑在 F4 上不存在F4 没有 D-Cache但从 F4 升到 H7 的人几乎都要栽一次。坑三中断优先级配错导致 FFT 数据被截断。FFT 处理时间 150µs 左右如果这期间被高频中断频繁打断处理时间会拉长导致下一帧 DMA 已经覆盖了正在处理的数据。症状是偶尔出现一根莫名其妙的谱线。解决办法很简单把 FFT 处理的优先级设成最高或者双缓冲交替使用处理 A 缓冲的时候 DMA 往 B 缓冲写。坑四忘了开 FPU。F4 的 FPU 默认是不开的要在编译选项里加-mfpufpv4-sp-d16 -mfloat-abihard同时保证__FPU_PRESENT和__FPU_USED宏定义正确。没开的时候代码也能跑但浮点运算是软件模拟1024 点 FFT 要几毫秒实时性完全丧失。判断是否开了 FPU看反汇编里有没有vldr、vmul这类指令如果全是bl __aeabi_fmul就是没开。5.4 精度提升的几个实操技巧用相位差法做二次校正。输入捕获的精度受定时器计数分辨率限制被测频率越高相对误差越大。如果同时用两个定时器一个在被测信号上升沿启动一个在下一个上升沿停止中间数标准时钟这就是等精度测量法。它的精度在整个频段内保持一致因为闸门时间是被测周期的整数倍。代价是要占用两个定时器和一点中断资源。测 1MHz 信号时普通捕获法相对误差 0.1%等精度法能压到 0.001%。对 FFT 结果做多帧平均。单帧结果抖动在 ±0.5Hz 左右连续取 8 帧做均值抖动能压到 ±0.1Hz。代价是响应变慢适合稳态测量不适合扫频。输入捕获加滑动平均。连续记录最近 8 个周期值取中位数而不是平均值。中位数对偶发的大偏差比如噪声误触发免疫平均值会被拉偏。这个改动很小效果立竿见影我在振动环境下测转速时必用。两个方案互为校验。在系统里同时跑输入捕获和 FFT如果两者差值超过 2%就判定信号异常输出一个警告标志。这个机制在电源监测项目里帮我抓到过好几次信号畸变因为它同时反映了周期和频谱两个视角比单个指标可靠得多。最后分享一个我常用的调试手段把 FFT 的模值数组通过串口按文本打出来用任意一个上位机工具画个曲线。看频谱图比看数字快得多谱线位置对不对、有没有杂散、噪声底多高一眼就能判断。做测频这件事图像永远比数字直观别舍不得这点串口带宽。