尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
STM32测频选型:输入捕获与FFT的实现与避坑
测频这件事最容易做错的地方不是算法而是选型。我在 STM32 上折腾输入捕获和 FFT 测频有几年了见过太多板子一个干干净净的方法信号本来用定时器抓两个边沿就能把频率算到小数点后好几位偏偏要上 ADC 采样加 4096 点 FFT最后分辨率只有几赫兹还吃掉半颗芯片的算力反过来电机相电流、逆变器输出这种带一堆谐波和噪声的信号拿输入捕获去过零比较读数能在一分钟内跳几十次因为它只认边沿不认这个边沿到底是谁的。输入捕获和 FFT 是两条完全不同的技术路线一个在时域数时钟一个在频域数周期它们擅长的场景几乎没有重叠。这篇文章就围绕 STM32 上的这两种测频方式展开先把两种方法的物理边界和误差来源讲透再落到定时器配置、信号调理、ADC 采样链路、CMSIS-DSP 的频谱计算最后把我实测中踩过的坑按排查顺序整理出来。不管你是刚上手 STM32 想做一个频率计还是手上有现成的板子读数不稳想找原因或者是要给逆变器、电机、传感器做频率采集这里的内容都能直接拿去用。1. 输入捕获和 FFT 不是替代关系先把两种测频的物理边界划清楚1.1 输入捕获数的是时钟所以它的误差只跟时基有关输入捕获这件事的本质特别朴素定时器里的计数器一直在按固定频率累加当引脚上出现一个上升沿硬件立刻把当前计数值锁存到 CCR 寄存器里同时按配置决定是否把计数器清零。两个上升沿之间锁存值的差就是这个信号周期内数到了多少个定时器时钟。频率就是一个除法f_signal f_timer / count。这个机制的误差来源非常干净只有两个。第一个是量化误差也就是 ±1 个计数的不确定度相对误差就是1/count。举个例子定时器计数频率 1MHz测一个 1kHz 的信号一个周期数到 1000 个数量化误差 0.1%如果把计数频率提到 84MHz一个周期数到 84000 个数量化误差降到 0.0012%。第二个误差来源是时基本身的精度也就是晶振。这里有个容易被忽略的细节PLL 倍频不会放大相对误差8MHz 晶振 ±20ppm倍频到 168MHz 还是 ±20ppm只是绝对偏差按比例放大了。所以输入捕获在测一个固定频率这个任务上是极强的精度直接由你那颗晶振决定跟被测信号的形状关系不大只要它的边沿足够干净。但它有一个硬性前提每个周期只能有一个有效的触发沿而且这个沿必须是真正的周期沿不能是谐波、毛刺或者反射造成的假沿。1.2 FFT 数的是窗口长度分辨率由采样时长决定FFT 测频的误差模型和输入捕获完全不同。做完 N 点 FFT 之后频谱上相邻两根谱线的间隔是Δf fs / N而这个式子可以改写成Δf 1 / TT 就是你的采样窗口时长。这一步推导非常关键想分辨 1Hz 的间隔你就必须观察 1 秒钟的信号这跟算法、跟芯片主频、跟点数都没有关系。你把点数从 1024 加到 4096并不会提高分辨率你只是在同样的时间段上把同一个频率轴插值得更密。不做任何插值的情况下峰值只能落在谱线上最坏情况的读数误差是半个谱线间隔。举个实际的数字采样率 100kHz、点数 4096谱线间隔是 24.4Hz测一个 1000Hz 的信号最坏误差可以到 12Hz 左右这在很多场合是不能接受的。加上 Hann 窗再做抛物线插值通常能把误差压到 0.05 到 0.1 个谱线间隔也就是 1 到 2.5Hz。如果能让信号频率正好落在整数谱线上相干采样误差可以低到忽略不计但前提是你得先知道 f0这就回到了输入捕获的价值。FFT 真正的优势不在精度而在分辨是谁。信号里混着基波、三次五次谐波、开关噪声输入捕获只能给你一个被所有这些成分搅在一起的边沿时刻而 FFT 能把它们摊开放在频率轴上告诉你哪个是基波、每个谐波的幅值是多少、总谐波失真有多大。这个能力是时域方法给不了的。1.3 一张表把选型敲定对比维度输入捕获FFT单频干净信号极佳误差由晶振决定好需插值才能匹敌含谐波、含噪声的信号差容易误触发导致倍频读数好可分离基波与谐波同时存在多个频率成分无法处理可以这是它的主场幅值、THD 信息PWM 模式下只有占空比有完整幅值谱单次测量延迟1 到 2 个信号周期一个窗口时长 TCPU 占用极低几乎全是硬件干活中到高看点数与浮点支持频率上下限从毫赫兹到兆赫兹级上限受 fs/2 限制下限受 T 限制典型分辨率时钟决定可做到 ppm 级1/T通常在 Hz 级看完这张表结论其实很清楚输入捕获负责测得准FFT 负责认得清。工程上最好的做法是把两者串起来先用输入捕获拿到高精度的基波频率再用这个频率去指导 FFT 的采样参数或者直接做相干采样最后用 FFT 判定这个频率是不是真的基波、有没有谐波干扰。我在做一个逆变器输出监测的项目时就是这么干的捕获负责实时跟踪输出频率做保护FFT 每 100ms 出一帧频谱算 THD 做显示两套数据互相校验任何一个出问题都能立刻发现。2. 输入捕获的硬件链路和定时器配置引脚这一关比代码更容易翻车2.1 信号调理别指望 STM32 的施密特输入能救你很多人测频的代码写得没问题读数却乱跳问题几乎都在引脚前面那一小段模拟电路上。STM32 的 GPIO 确实自带施密特触发器但它是一个偏弱的施密特阈值大概在高电平 0.7×VDD、低电平 0.3×VDD 附近具体看数据手册。如果你的信号是幅度只有 1V 的正弦波或者上升时间有几十微秒的缓慢边沿它跨过阈值的过程会很长这段时间里任何一点噪声都能让输入翻转好几次结果就是一个周期被数成两个甚至三个。标准做法是加一级滞回比较器运放搭或者直接用 74HC14 这类施密特反相器都行。如果实在不想加芯片那就用 AC 耦合把信号抬到 VDD/2 附近再加上足够的幅度保证边沿陡峭。几个我踩过之后总结的细节RC 低通的截止频率取最高待测频率的 3 到 10 倍比较合适。设得太低会把边沿拉缓反而更容易误触发设得太高等于没滤波。探头的地线别拖长。用长鳄鱼夹地线去看波形的时候开关电源的尖峰会通过地环路串进来示波器上看到的毛刺很可能只是你的探测方式造成的不一定是板子的问题。如果测量点是电机的相线或者逆变器的输出一定要做隔离。共地之后功率级的开关噪声会直接灌进 MCU 的输入脚轻则读数跳重则打坏引脚。定时器的输入滤波器 ICF 位是可以用的它按 f_DTS 采样连续 N 次一致才认这个电平。抑制窄毛刺很有效但会引入几个到十几个时钟周期的延迟测高频信号的时候要慎用否则你会发现自己测出来的频率总是偏低一点。2.2 定时器时钟到底是多少APB 分频那笔账必须算这是新手最容易栽的一个坑也是最容易产生为什么我的 1kHz 显示成 2kHz这种迷惑现场的地方。STM32 的定时器时钟不是总线时钟规则是这样的如果某条 APB 总线的预分频系数等于 1那么这条总线上的定时器时钟就等于 PCLK如果 APB 预分频系数大于 1定时器时钟等于 2 倍 PCLK。这个倍频机制存在的原因是让定时器在主频较低的配置下也能拿到足够高的计数时钟。拿 F407 跑 168MHz 举例APB1 分频系数是 4得到 42MHz所以挂在 APB1 上的 TIM2、TIM3、TIM4、TIM5 时钟是 84MHzAPB2 分频系数是 2得到 84MHz所以挂在 APB2 上的 TIM1、TIM8、TIM9 到 TIM11 时钟是 168MHz。你要是按 42MHz 去算最后测出来的频率正好差一倍。稳妥的写法是不手写常量而是从 RCC 配置里反推/* 注意这里用地址范围做粗略判断实际项目建议直接按外设硬编码并加注释 */ static uint32_t TIM_GetClockFreq(void) { RCC_ClkInitTypeDef clk; uint32_t latency; HAL_RCC_GetClockConfig(clk, latency); /* APB1 上的定时器TIM2~TIM7、TIM12~TIM14 */ uint32_t pclk1 HAL_RCC_GetPCLK1Freq(); uint32_t tim_apb1 (clk.APB1CLKDivider RCC_HCLK_DIV1) ? pclk1 : (pclk1 * 2U); /* APB2 上的定时器TIM1、TIM8~TIM11 */ uint32_t pclk2 HAL_RCC_GetPCLK2Freq(); uint32_t tim_apb2 (clk.APB2CLKDivider RCC_HCLK_DIV1) ? pclk2 : (pclk2 * 2U); return tim_apb2; /* 按实际使用的定时器返回对应值 */ }再确认一件事你的 SYSCLK 到底是从 HSE 通过 PLL 来的还是从内部 HSI 来的。HSI 的出厂校准精度是 ±1% 量级温漂还能到几十 ppm 每摄氏度拿它做频率基准是不可接受的。测频要精度必须用外部晶振并且确认 RCC 配置里 SYSCLK 的来源确实是 PLL 而不是 HSI。2.3 PWM 输入模式一次拿下周期和占空比输入捕获有个很好用但很多人不知道的模式叫 PWM 输入模式。原理是这样的STM32 的定时器通道存在共享关系CH1 和 CH2 共用 TI1 这一路输入CH3 和 CH4 共用 TI2。于是你可以把 CH1 配成上升沿直接捕获、CH2 配成下降沿间接捕获硬件就会自动完成两件事——上升沿到来时把计数器清零并把值锁进 CCR1下降沿到来时把值锁进 CCR2。一个周期结束后CCR1 就是周期计数CCR2 就是高电平计数占空比直接就是 CCR2/CCR1。这个模式的好处是只占用一个定时器、一个引脚就同时拿到了频率和占空比而且两次捕获之间不需要任何软件参与中断开销也只是一次。用 HAL 配置的关键点在于极性和选择位TIM_HandleTypeDef htim2; TIM_IC_InitTypeDef sIC; /* 计数频率 1MHz84MHz / (831) */ htim2.Instance TIM2; htim2.Init.Prescaler 84 - 1; htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFFFFFF; /* F4 的 TIM2 是 32 位直接用满 */ htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_IC_Init(htim2); /* CH1上升沿直连负责锁存整个周期 */ sIC.ICPolarity TIM_INPUTCHANNELPOLARITY_RISING; sIC.ICSelection TIM_ICSELECTION_DIRECTTI; sIC.ICPrescaler TIM_ICPSC_DIV1; sIC.ICFilter 0; HAL_TIM_IC_ConfigChannel(htim2, sIC, TIM_CHANNEL_1); /* CH2下降沿间接跟着 CH1 的输入走负责锁存高电平时间 */ sIC.ICPolarity TIM_INPUTCHANNELPOLARITY_FALLING; sIC.ICSelection TIM_ICSELECTION_INDIRECTTI; HAL_TIM_IC_ConfigChannel(htim2, sIC, TIM_CHANNEL_2); HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1);有个坑要提前说因为 CH1 和 CH2 共用 TI1所以这个模式只能用 CH1/CH2 这一对或者 CH3/CH4 这一对不能跨对使用。而且如果你把 CH1 配成下降沿、CH2 配成上升沿得到的周期就变成了低电平时间频率读数会翻倍这种错误在示波器上很容易看出来但在代码里很难发现。2.4 低频测量和溢出手写一个自动量程16 位定时器在 1MHz 计数频率下最多数 65536 个数也就是 65.5ms对应最低能测到大约 15Hz。低于这个频率就会溢出捕获到的值会绕回来。解决办法有两个。第一个是在更新中断里累加溢出次数每次捕获时算出真正的总计数。这里有个容易写错的细节必须判断两次捕获之间到底发生了几次溢出如果溢出次数大于 1说明你在一个周期里错过了不止一次更新中断这个数据就是不可信的应该直接丢弃。第二个办法更省事也是我更推荐的自动量程。先用一个较大的预分频系数试测一次拿到周期计数后判断——如果计数小于某个下限比如 2000说明分辨率浪费了逐级减小预分频如果检测到溢出就逐级加大预分频。通常两三轮就能锁定合适的档位。这跟万用表频率档的换挡逻辑是一样的代码量不大但能让你一个固件覆盖从几赫兹到几百千赫兹的整个范围非常省心。如果用的是 F4 或者 F7直接用 TIM2 或 TIM5 这两个 32 位定时器84MHz 计数频率下能数 51 秒绝大多数场景都不用再管溢出这件事能省掉一大坨代码。3. 从寄存器到代码输入捕获测频的完整实现3.1 预分频怎么定先算清楚三个约束预分频系数不是随便填的它被三个约束夹在中间把这三个约束写清楚参数自然就出来了。第一个约束是分辨率下限。你希望一个周期至少数到多少个计数如果要求相对误差不超过 0.1%那计数至少要 1000 个。假设定时器时钟是 f_tim最高待测频率是 f_max那么PSC 1 ≤ f_tim / (1000 × f_max)。84MHz 时钟、最高测 100kHz算出来 PSC1 不超过 840取 84 得到 1MHz 的计数频率正合适。第二个约束是高频上限。一个周期至少要数到 2 个计数才能测出频率实际上要更多才稳定所以f_count / 2是理论上限。1MHz 计数频率下理论上能测到 500kHz但实际因为边沿抖动和捕获延迟稳妥的上限在 100 到 200kHz 左右想测更高就得把预分频降到 1让计数频率跑到 84MHz 甚至 168MHz。这时候要注意输入引脚的最高翻转频率和定时器本身的采样能力。第三个约束是低频不溢出。f_count / f_min ≤ 6553616 位定时器。1MHz 计数频率下 f_min 是 15.3Hz需要测更低的频率就得加大预分频但加大预分频又会牺牲高频端的分辨率这就是为什么要做自动量程。3.2 ISR 里只搬运不做除法捕获中断服务程序里有一条铁律只做数据搬运不做任何复杂运算。原因很简单中断频率跟被测信号频率是同一个量级被测信号到 100kHz 的时候中断也是 100kHz一个中断里如果塞了浮点除法、64 位乘加甚至打印CPU 就直接被吃光了。正确的做法是把捕获值存到几个 volatile 变量里置一个标志剩下的计算全部放到主循环#define CNT_FREQ_HZ 1000000U /* 定时器实际计数频率 */ #define SIGNAL_LOST_MS 1000U /* 超过 1 秒没有新捕获就认为信号丢失 */ volatile uint32_t g_cap_period 0; volatile uint32_t g_cap_high 0; volatile uint8_t g_cap_ready 0; static uint32_t s_last_tick 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance ! TIM2) return; g_cap_period __HAL_TIM_GET_COMPARE(htim, TIM_CHANNEL_1); g_cap_high __HAL_TIM_GET_COMPARE(htim, TIM_CHANNEL_2); g_cap_ready 1; s_last_tick HAL_GetTick(); } /* 主循环里 */ static void App_ProcessCapture(void) { if (!g_cap_ready) { if ((HAL_GetTick() - s_last_tick) SIGNAL_LOST_MS) { s_freq 0.0f; s_duty 0.0f; /* 判无信号 */ } return; } uint32_t p, h; __disable_irq(); p g_cap_period; h g_cap_high; g_cap_ready 0; __enable_irq(); if (p 0) return; /* 防除零极端情况下会出现 */ /* 周期计数 高电平计数是正常情形反过来说明极性配错了 */ if (h p) { s_config_error 1; return; } s_freq (float)CNT_FREQ_HZ / (float)p; s_duty 100.0f * (float)h / (float)p; }这段代码里有三个值得说的点。一是关中断拷贝因为捕获值是两个 32 位变量主循环读的过程中可能被中断打断导致读到一个新一个旧算出来的周期是错的这个错误不会经常出现但一旦出现就是离谱的数值很难查。二是h p的检查正常情况下高电平时间一定小于等于周期如果反过来了说明你把两个通道的极性配反了或者用了错误的输入通道直接在软件里报错比在示波器上找半天要省事。三是信号丢失判定输入捕获有个特点——信号没了它不会报错只是那几个捕获值一直保持不动你会看到一个永远不变的频率读数如果没有超时检测这比乱跳还危险。还有一个提升稳定性的小技巧加一个 5 点中值滤波。输入捕获最怕的是偶发误触发一次误触发会让周期计数变成一半频率直接翻倍这种尖刺用均值滤波是压不住的因为一个离群点会把均值拉偏但中值滤波能直接把它剔掉。五个数排个序取中间开销极小效果立竿见影。3.3 从模式加 DMA不看平均值看每个周期的抖动前面那一套测的是周期实际上是上一周期的值你能得到频率但得不到周期之间的波动信息。如果你要分析的是电机转速的稳定度、编码器的丢步、或者晶振的抖动那你要的是每一个周期的长度这时候就该上从模式加 DMA。配置思路是这样的把定时器设成从模式复位触发源选 TI1FP1。这样每个上升沿到来时硬件会自动做两件事——把计数器清零同时触发一次捕获。因为计数器被清零了所以每次捕获到的 CCR1 就是本个周期的完整计数不需要再做减法。然后打开捕获比较通道的 DMA 请求让 DMA 把 CCR1 一个一个搬到数组里CPU 完全不参与。/* 从模式TI1FP1 触发复位 */ TIM_SlaveConfigTypeDef slave {0}; slave.SlaveMode TIM_SLAVEMODE_RESET; slave.InputTrigger TIM_TS_TI1FP1; slave.TriggerPolarity TIM_TRIGGERPOLARITY_RISING; slave.TriggerFilter 0; HAL_TIM_SlaveConfigSynchro(htim2, slave); /* 每个捕获事件触发一次 DMA 搬运 */ HAL_TIM_IC_Start_DMA(htim2, TIM_CHANNEL_1, (uint32_t *)g_period_buf, BUF_LEN);DMA 用循环模式长度取 256 或者 512在传输完成和半传输回调里各处理一半的数据。这个方法的妙处在于你拿到的是一个周期序列可以直接算标准差评估稳定度也可以找最大最小值看有没有丢步。我拿它测过一个带减速箱的直流电机平均转速看起来纹丝不动但周期序列的标准差在负载变化时会明显变大这种信息用平均值是完全看不出来的。需要注意一点这个模式下 DMA 的搬运频率就等于信号频率如果信号是 100kHzDMA 每秒要搬 10 万个半字总线带宽占用不小。所以高频信号慎用这种方式或者把缓冲区改小、只在需要分析的时候临时开启。4. FFT 这条路采样链路的参数才是成败关键4.1 采样率和点数是一道算术题不能拍脑袋选采样率的第一条规则是奈奎斯特采样率必须大于信号最高频率的两倍。但工程上千万别贴着这条线走模拟前端不是理想滤波器电源噪声、开关尖峰、谐波都会超过你预想的频率上限一旦超过 fs/2 就会混叠折叠成频谱上一个根本不存在的低频峰你还以为发现了新东西。实际经验是取目标最高频率的 4 到 10 倍。音频 20kHz 用 48kHz 采样就是 2.4 倍靠的是前端有像样的抗混叠滤波器如果是自己搭的简单 RC建议老老实实取 8 倍以上。点数 N 的选取要同时看两个指标谱线间隔Δf fs/N和刷新时间T N/fs。这两个指标是互相牵制的加大 N 能提高分辨率但刷新变慢实时性下降。给你几个我实际用过的组合应用场景采样率 fs点数 N谱线间隔刷新时间音频谐波分析48 kHz409611.7 Hz85 ms工频及 50 次谐波12.8 kHz20486.25 Hz160 ms1kHz 单频精确测频100 kHz409624.4 Hz41 ms机械振动分析10 kHz81921.22 Hz819 ms从表里能直接看出那个反直觉的结论想要 1Hz 级别的分辨率就必须接受接近一秒的刷新时间没有任何技巧能绕过这个约束。4.2 TIM 触发 ADC 加 DMA 环形缓冲把采样节拍钉死FFT 的频率刻度完全依赖于采样率的准确性。如果采样是靠软件循环采的采样间隔会随代码执行时间抖动频谱就会糊掉本来尖锐的谱线会变成一坨。正确做法是用一个定时器的更新事件去触发 ADC节拍由硬件保证软件抖动完全消除。配置路径是把 TIM3 的 TRGO 设为更新事件TIM_TRGO_UPDATEADC 的外部触发源选成这个定时器的触发然后把 DMA 设成循环模式一次搬 N×2 个半字构成双缓冲#define FFT_N 4096 static uint16_t adc_dma_buf[FFT_N * 2]; HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_dma_buf, FFT_N * 2); /* 前半满处理前一半此时 DMA 正在写后一半 */ void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { App_SubmitBlock(adc_dma_buf[0]); } /* 后半满处理后一半此时 DMA 正在写前一半 */ void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { App_SubmitBlock(adc_dma_buf[FFT_N]); }这里有一个必须遵守的纪律回调里只做提交动作不要在里面直接算 FFT。用双缓冲的意义就在于你处理这一半数据的时候DMA 正在填另一半两边互不干扰。如果你在回调里就地算 FFT等算完的时候另一半早就填满并且被覆盖了数据就撕裂了。时间预算要心里有数。4096 点、100kHz 采样率一半缓冲对应的时间是 20.5ms而 F4 上用 FPU 做 4096 点单精度实数 FFT 大概需要几毫秒绰绰有余。换成 F103 这种没有 FPU 的芯片同样点数的浮点 FFT 要几十毫秒这个预算就不够了只能降点数、用定点或者改用后面会提到的 Goertzel 算法。4.3 采样时间和输入阻抗读数不对先查这里STM32 的 ADC 内部是一个采样保持电容F4 系列大约 4pF加上一串开关。采样阶段这个电容通过外部源阻抗充电如果源阻抗太大充电时间不够采到的电压就会偏低波形也会被拖尾变形。症状是幅值整体偏小、频谱上多出一堆奇次谐波——看起来像信号本身有问题实际上是 ADC 没采准。采样时间的选择要以数据手册里的 R_AIN 与 t_s 对应表为准。经验值是这样的源阻抗在 1.5kΩ 以下12 位精度可以用最短的 3 个采样周期源阻抗到 10kΩ 左右就得拉到 84 个采样周期以上。稳妥的做法是在 ADC 输入前面加一级运放跟随器输出串 100Ω 电阻再并一个电容到地既做缓冲又做抗混叠成本很低但能省掉大量调试时间。另外两个容易被忽略的点。一是参考电压用 VDDA 做参考的时候数字电路的开关噪声会通过电源串进来表现为频谱底部一片起伏对频率测量影响不大但会干扰幅值测量敏感的场合建议用外部基准芯片。二是信号偏置单极性 ADC 只能采 0 到 VREF 之间的电压交流信号必须抬到 VREF/2并且保证峰值不超上限、不破下限上下各留 10% 的余量。一旦削顶削出来的那部分会产生大量奇次谐波你会以为信号本身就带谐波其实是自己把信号弄坏的。5. 频谱计算与峰值定位CMSIS-DSP 的正确用法5.1 去直流和加窗两个打死都不能省的前处理原始采样数据进 FFT 之前必须做两步处理少任何一步都会让结果变得无法解释。第一步是去均值。ADC 采到的是带偏置的单极性数据均值大概在 2048 附近换算成电压是 1.65V。这么一个巨大的直流分量在频谱上是一个极强的 DC 峰而且因为窗函数的存在它的能量会泄漏到临近的低频谱线上把你想看的低频小信号直接埋掉。所以先算一遍 N 点均值然后在加窗之前减掉。第二步是加窗前提是你没有做到相干采样。相干采样指的是采样窗口里正好包含整数个信号周期这时候不加窗也没有泄漏而且主瓣最窄、分辨率最好。但相干采样需要先知道精确的信号频率一般情况做不到所以要加窗来压制截断带来的频谱泄漏。泄漏的成因说白了就是窗口的首尾接不上产生一个突变这个突变在频域里就表现为能量从主瓣往两边撒出去。几个常用窗的取舍窗类型主瓣宽度最高旁瓣幅度修正适用场景矩形窗2 bin-13 dB1.0已实现相干采样Hann 窗4 bin-31 dB0.5通用首选插值精度好Hamming 窗4 bin-43 dB0.54旁瓣要求更严的场合Blackman-Harris8 bin-92 dB0.36弱信号检测Flat-top 窗10 bin-90 dB专用系数幅值精确测量对测频来说 Hann 窗是最省事的因为它的主瓣形状可以用抛物线很好地拟合配合插值的精度比较高而且它的旁瓣衰减是 -18dB/oct 的规律远端衰减快不容易被强信号的旁瓣污染。代价是主瓣变宽两个靠得很近的频率成分可能分不开这时候就得退回汉明窗或者矩形窗。5.2 arm_rfft_fast_f32 的输出排布和归一化CMSIS-DSP 里做实数 FFT 的接口是arm_rfft_fast_f32它的输出排布比较特殊不看文档直接写很容易踩坑。对于偶数点 N输出是这样一个紧凑格式out[0]是直流分量的实部out[1]是奈奎斯特频率分量的实部然后从 k1 开始out[2k]和out[2k1]分别是第 k 根谱线的实部和虚部一直到 kN/2-1。也就是说整个数组只有前 N 个元素是有效数据第 k 根谱线对应的频率是k × fs / N。验证排布是否理解正确有个很简单的办法喂一个已知频率的正弦进去比如 1kHz采样率 100kHz 点数 4096峰值应该出现在第1000 × 4096 / 100000 ≈ 41根谱线上。位置对不上就说明你的排布或者频率映射理解错了。static arm_rfft_fast_instance_f32 s_rfft; static float32_t fft_in[FFT_N]; static float32_t fft_out[FFT_N]; static float32_t mag[FFT_N / 2 1]; static float32_t win[FFT_N]; static void Dsp_Init(void) { arm_rfft_fast_init_f32(s_rfft, FFT_N); /* Hann 窗先算好存起来别在每次处理时重算三角函数 */ for (int i 0; i FFT_N; i) { win[i] 0.5f * (1.0f - arm_cos_f32(2.0f * PI * (float)i / (float)(FFT_N - 1))); } } void App_Spectrum(const uint16_t *raw) { float32_t mean 0.0f; for (int i 0; i FFT_N; i) mean (float32_t)raw[i]; mean / (float32_t)FFT_N; for (int i 0; i FFT_N; i) { float32_t v ((float32_t)raw[i] - mean) * (VREF_VOLT / 4096.0f); fft_in[i] v * win[i]; } arm_rfft_fast_f32(s_rfft, fft_in, fft_out, 0); mag[0] fabsf(fft_out[0]); mag[FFT_N / 2] fabsf(fft_out[1]); for (int k 1; k FFT_N / 2; k) { float32_t re fft_out[2 * k]; float32_t im fft_out[2 * k 1]; arm_sqrt_f32(re * re im * im, mag[k]); } }关于归一化我不想给一个可能出错的公式而是推荐一个更可靠的做法实测标定。用信号发生器输入一个已知幅值的正弦比如 1V 峰峰值跑一遍程序读出峰值谱线的数值两者的比值就是你的标定系数。同一次标定里顺便验证频率映射一举两得。要注意直流和奈奎斯特这两根谱线的归一化系数跟其它谱线不一样它们的能量不像其它谱线那样被分成正负两半做幅值测量时得单独处理做频率测量则可以不管。还有一点Hann 窗的相干增益是 0.5也就是说加窗之后整个信号的幅值被压掉了一半如果不做补偿读出来的幅值会偏小一倍。频率测量不受这个影响因为峰值位置不会因为整体缩放而改变这也是为什么测频比测幅值宽容。5.3 抛物线插值把分辨率从一根谱线提到零点零几根不做插值时峰值只能落在谱线上误差最坏是半个谱线间隔。既然峰值的真实位置在两三根谱线之间那就可以用相邻三根谱线的幅值拟合出一条抛物线抛物线的顶点位置就是估计的真实频率偏移static float32_t App_PeakFreq(const float32_t *m, int n) { /* m 的长度是 n FFT_N/2 1跳过 DC避免直流泄漏干扰峰值搜索 */ uint32_t idx 0; float32_t peak 0.0f; arm_max_f32(m[1], (uint32_t)(n - 2), peak, idx); int k (int)idx 1; /* 峰值贴边时无法三点插值直接放弃返回谱线频率 */ if (k 1 || k n - 2) { return (float32_t)k * (float32_t)SAMPLE_RATE / (float32_t)(2 * (n - 1)); } float32_t a m[k - 1]; float32_t b m[k]; float32_t c m[k 1]; float32_t den a - 2.0f * b c; float32_t delta 0.0f; if (fabsf(den) 1e-12f) { delta 0.5f * (a - c) / den; /* 顶点相对 k 的偏移范围 -0.5 ~ 0.5 */ } return ((float32_t)k delta) * (float32_t)SAMPLE_RATE / (float32_t)(2 * (n - 1)); }这段代码有几个细节。范围限制-0.5 到 0.5是必须的如果算出来的 delta 超出这个范围说明峰值附近不是单一成分插值结果不可信这时候应该退回谱线值或者干脆标记为无效。另外插值只对信噪比足够高的信号有效如果底噪很高三根谱线的幅值本身就被噪声扰动插出来的位置反而比直接用谱线更差。经验阈值是峰值至少要比邻近的底噪高 20dB低于这个值就别插了。还有一点用线性幅度做插值配 Hann 窗是最省事也最准确的组合。有人喜欢在 dB 域做插值那个对 Hann 窗反而不准除非用专门的修正系数。这一点我在早期的项目里吃过亏对着插值结果百思不得其解后来换成线性幅度就正常了。5.4 相干采样的骚操作用捕获结果反算 ARR前面提过相干采样能完全消除泄漏代价是要先知道精确的信号频率。而精确的信号频率正好可以从前面的输入捕获拿到。这就把两条技术路线串成了一个闭环。具体做法是先用输入捕获测出信号频率 f0然后让 f0 正好落在第 k 根谱线上也就是满足f0 k × fs / N反解出fs f0 × N / k。采样率是由定时器的 ARR 决定的fs f_tim_clock / ((PSC1) × (ARR1))所以调 ARR 就能调采样率。取 k 128、N 4096f0 是 50Hz算出来 fs 1.6kHzARR 按这个反算出来取整实际采样率会有微小偏差这时候必须用实际 ARR 反算真实 fs再去映射频率否则会引入一个整体比例误差而且这个误差很隐蔽因为频谱看起来非常干净。这个技巧在固定频率的场合特别好用比如工频的谐波分析基波锁定在 50Hz谐波自然就落在整数谱线上了完全不用加窗分辨率还能做到最好。代价是采样率会随信号频率变化一旦信号频率漂移相干条件就被破坏泄漏又回来了所以它适合频率稳定的场景。如果用的是没有 FPU 的 M3 或者 M0还有一个更划算的选择Goertzel 算法。你如果只关心少数几个频点的幅值比如只想判断某个特定频率的信号有没有出现根本没必要算整个频谱。Goertzel 每个频点只需要 O(N) 次乘加不需要三角函数表也不需要复数运算定点实现非常容易。代价是它不能给你完整的频谱只能给你指定频点的能量。/* 单个频点的能量f 是关心的频率fs 是采样率 */ static float32_t Goertzel(const float32_t *x, int n, float32_t f, float32_t fs) { float32_t w 2.0f * PI * f / fs; float32_t c 2.0f * arm_cos_f32(w); float32_t s0 0.0f, s1 0.0f, s2 0.0f; for (int i 0; i n; i) { s0 x[i] c * s1 - s2; s2 s1; s1 s0; } return s1 * s1 s2 * s2 - c * s1 * s2; }这个函数大概二十行跑在 48MHz 的 M0 上处理 1024 点也就几毫秒比整段 FFT 省下十几倍的时间。我在一个用 M0 做按键音识别的小项目里用的就是它只需要检测四个固定频率效果比硬塞一个 FFT 好得多。6. 实测踩坑记录读数乱跳的时候按这个顺序查6.1 捕获读数正好是真实值的两倍或三倍这是输入捕获最常见的一类故障出现这种整数倍的偏差基本可以锁定是一个周期被数了多次。排查按这个顺序走。先看波形。拿示波器直接挂在 MCU 的输入引脚上不是信号源输出端把时基拉大到能看清一个周期。如果看到边沿附近有振荡或者毛刺那就是信号调理的问题上滞回比较器或者打开输入滤波。如果波形很干净那就要怀疑配置了检查 CH1 是不是真的配成了上升沿直连、CH2 是不是下降沿间接这两个配反了会导致周期变成半个周期读数正好翻倍。再检查一下是不是误开了两个通道的捕获中断导致同一个边沿被处理了两次。还有一个隐蔽的原因信号里含有很强的三次谐波。如果三次谐波的幅度足够大在过零点附近产生了额外的过零施密特触发器就会多翻一次。这种情况下输入捕获无论怎么调都测不准必须换成 FFT 或者先做低通滤波把谐波滤掉。6.2 读数总是偏高一点点而且随温度慢慢漂如果偏差是千分之几的量级还随温度漂移几乎可以肯定是时钟源的问题。先确认 SYSCLK 到底用了 HSI 还是 HSE很多 CubeMX 生成的工程在改配置的时候不小心把时钟树改回 HSI 了编译能过、程序能跑就是频率不准。确认用的是外部晶振之后还要看晶振本身的精度等级普通无源晶振的频差可以到 ±20ppm 到 ±50ppm如果要求更高就得换温补晶振。还有一种情况是负载电容选错了。8MHz 的无源晶振配 20pF 的负载电容和配 12pF 的起振频率会有几十 ppm 的差别而且这个偏差会随温度变化。如果对精度有要求用频率计校准一次把偏差作为一个修正系数写在代码里是很实用的做法。6.3 FFT 测出来的频率整体偏一个固定比例这种所有频率都按同一个比例偏的现象说明频率轴的刻度错了也就是采样率算错了。拿定时器触发 ADC 的方案来说实际采样率是f_tim / ((PSC1) × (ARR1))很多人是按 CubeMX 里填的目标值去算的但 CubeMX 会自动把 ARR 圆整到整数实际值跟目标值之间有偏差这个偏差会原封不动地出现在所有频率读数上。解决方法是别用以为的采样率而是从寄存器反算实际值读出 TIM 的 PSC 和 ARR算出真实采样率再拿它去映射频率。这一条改完偏差通常立刻消失。顺便说一下DMA 长度一定要跟实际用的缓冲区长度一致如果HAL_ADC_Start_DMA传的长度跟你后面处理时假定的点数不一样数据会错位频谱会变得完全无法解释。6.4 低频段一片混乱什么都看不清先做一件事把原始采样序列通过串口导出来在电脑上画成波形看。这一步能省掉至少一半的瞎猜时间。常见的三个原因一是直流没去掉二是前端有工频干扰被直流偏置调制三是地环路。第一种在代码里减均值就解决了第二种要看波形上有没有一个 50Hz 的缓慢起伏有的话说明偏置电路或者电源有问题第三种把示波器地和 MCU 地好好处理一下或者干脆用电池供电测一次如果换了供电就正常了那基本确认是地环路。6.5 频谱里出现一个信号里根本不存在的低频峰这是混叠的典型特征而且混叠是所有频谱分析里最阴的坑因为折叠出来的频率看起来完全合法你不会怀疑它。产生的原因是有高于 fs/2 的成分跑进来了最常见的是开关电源的开关频率几十到几百 kHz、MCU 自己的 SPI 或者时钟串扰这些信号幅度可能不大但通过共用电源线耦合进 ADC 输入就足够了。处理办法有两个方向加一级模拟抗混叠低通滤波器截止频率设在 fs/2 略微高一点的位置或者干脆提高采样率让 Nyquist 频率远离那些干扰源。我个人更倾向于两个都做因为抗混叠滤波器不可能是理想的留一点余量更稳妥。判断一个可疑的峰是不是混叠有个简单方法把采样率改变 10%如果这个峰的频率跟着变那它大概率是混叠如果不变那就是真实存在的成分。6.6 跑一段时间就丢帧或者卡住这种能跑但不稳定的症状九成出在中断和缓冲区的配合上。最典型的错误是在 HAL 的回调里做了耗时的计算导致下一半缓冲区被覆盖的时候还没处理完。解决办法就是前面说的双缓冲加标志位回调里只提交不计算。另一个常见原因是访问了正在被 DMA 写入的缓冲区。如果你的处理速度偶尔慢一拍正好赶上 DMA 在写同一块内存读出来的就是半新半旧的数据频谱上表现为偶发的异常谱线很难抓住。判断的标准很简单处理区的地址范围跟 DMA 当前写入的范围绝对不能重叠双缓冲天然满足这个条件前提是你在正确的回调里处理正确的半区。还有一个资源冲突的问题容易被忽略如果你同时用 DMA1 的两个 stream一个给 ADC 一个给输入捕获输入捕获在高频时的 DMA 请求会抢总线带宽影响 ADC 的采样节拍表现为频谱偶尔糊一下。把两者分到 DMA1 和 DMA2 上会稳很多这个细节在 F4 这类有双 DMA 控制器的芯片上很容易做到。6.7 两种方法一起用的时候互相打架把输入捕获和 FFT 放进同一个工程要注意几个共享资源。定时器尽量不要复用ADC 触发用的定时器和捕获用的定时器分开选避免 TRGO 和捕获事件互相干扰。中断优先级上捕获中断的频率通常比 ADC 转换完成中断高把捕获的抢占优先级设得更高一点避免高频边沿被 ADC 中断挡住导致丢捕获。采样通道也要分开。如果用同一个 ADC 的多通道扫描两个通道的采样时刻会错开对高频测量的影响不能忽略。测频用的通道尽量独占一个 ADC或者至少保证它是规则组的第一个转换采样时刻最确定。我个人的习惯是每次硬件改版之后先用输入捕获测一个 1kHz 的方波把时基校准一遍再做别的。这个动作只要两分钟但能立刻分辨出是硬件变了还是软件错了比后面花半天找比例误差划算得多。另外建议在固件里留一个调试口能把原始采样序列和周期序列通过串口或 USB 吐出来很多看起来玄学的问题把数据导出来画一张图就真相大白了。
RELATED

相关推荐

PCA9546A四路I2C多路复用器深度解析与工程实践

PCA9546A四路I2C多路复用器深度解析与工程实践

1. 为什么四路I2C多路复用器不是“加个开关”那么简单我第一次在客户板子上看到PCA9546A时,以为就是个带地址的电子开关——不就是把SCL/SDA线从主设备“掰”到四个从设备之一嘛?结果调试三天没通,示波器抓出来的波形像心电图:时钟…

📅 2026/9/17 23:04:00
拖拉机转向轮角检测:视觉+IMU融合解算真实接地角

拖拉机转向轮角检测:视觉+IMU融合解算真实接地角

简介:本资源是一篇聚焦农业机械智能化的学术论文,面向农业工程、智能装备、自动驾驶等领域的研究人员与高校师生,重点解决拖拉机自动导航中转向轮角高精度检测这一关键技术难题。论文以雷沃M904D型轮式拖拉机为实验平台,系统阐述W…

📅 2026/9/17 23:04:00
Gutenberg BorderBoxControl 组件完全解析:Linked/Split 双视图边框控制器的原理与实践

Gutenberg BorderBoxControl 组件完全解析:Linked/Split 双视图边框控制器的原理与实践

Gutenberg BorderBoxControl 组件完全解析:Linked/Split 双视图边框控制器的原理与实践 【免费下载链接】gutenberg The Block Editor project for WordPress and beyond. Plugin is available from the official repository. 项目地址: https://gitcode.com/GitH…

📅 2026/9/17 22:59:00
MORE NEWS

更多资讯

📰

让魔百盒变身Linux小服务器:三个阶段刷好Armbian

让魔百盒变身Linux小服务器:三个阶段刷好Armbian 【免费下载链接】amlogic-s9xxx-armbian Supports running Armbian on Amlogic, Allwinner, and Rockchip devices. Support a311d, s922x, s905x3, s905x2, s912, s905d, s905x, s905w, s905, s905l, rk3588, rk356…

📰

Sunshine 游戏串流:从安装到推画面的快速搭建手册

Sunshine 游戏串流:从安装到推画面的快速搭建手册 【免费下载链接】Sunshine Self-hosted game stream host for Moonlight. 项目地址: https://gitcode.com/GitHub_Trending/su/Sunshine 游戏装在主卧 PC 上,人却窝在客厅沙发里。Sunshine 游戏串…

📰

用 Markdown+Git+CI 将项目管理手册变成可校验工程资产

简介:这份《项目管理手册》是企业级项目管理制度文档,面向项目经理、PMO 及质量、财务等职能部门负责人,用于统一从前期立项到结项后评估的全流程操作规范。包内为 1 个 doc 文件,体积约 1.01MB,可直接编辑套用&#x…

📰

EMC测试条件控制实战指南:环境、供电、布置与特殊要求

做产品开发这些年,我几乎每个项目都要和 EMC 测试打交道。很多人以为 EMC 测试就是把样品送到实验室、插上电、跑一遍就完事,等拿到报告才发现问题一大堆:不是样品在实验室里工作状态不对,就是供电条件不符合标准要求,…

📰

Dagger Container.withoutDirectory 详解:类型别名 ContainerWithoutDirectoryOpts 与目录移除的完整指南

Dagger Container.withoutDirectory 详解:类型别名 ContainerWithoutDirectoryOpts 与目录移除的完整指南 【免费下载链接】dagger Automation engine to build, test and ship any codebase. Runs locally, in CI, or directly in the cloud 项目地址: https://g…

📰

LeRobot 实操指南:3 条命令跑通机器人策略训练闭环

LeRobot 实操指南:3 条命令跑通机器人策略训练闭环 【免费下载链接】lerobot 🤗 LeRobot: Making AI for Robotics more accessible with end-to-end learning 项目地址: https://gitcode.com/GitHub_Trending/le/lerobot 手里有只桌面机械臂&…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬