news 2026/9/17 23:03:45

STM32测频选型:输入捕获与FFT的实现与避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 / T,T 就是你的采样窗口时长。这一步推导非常关键:想分辨 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 个信号周期一个窗口时长 T
CPU 占用极低,几乎全是硬件干活中到高,看点数与浮点支持
频率上下限从毫赫兹到兆赫兹级上限受 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 时钟是 84MHz;APB2 分频系数是 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; /* 计数频率 1MHz:84MHz / (83+1) */ 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,算出来 PSC+1 不超过 840,取 84 得到 1MHz 的计数频率正合适。

第二个约束是高频上限。一个周期至少要数到 2 个计数才能测出频率(实际上要更多才稳定),所以f_count / 2是理论上限。1MHz 计数频率下,理论上能测到 500kHz,但实际因为边沿抖动和捕获延迟,稳妥的上限在 100 到 200kHz 左右,想测更高就得把预分频降到 1,让计数频率跑到 84MHz 甚至 168MHz。这时候要注意输入引脚的最高翻转频率和定时器本身的采样能力。

第三个约束是低频不溢出。f_count / f_min ≤ 65536(16 位定时器)。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 的搬运频率就等于信号频率,如果信号是 100kHz,DMA 每秒要搬 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 ms
1kHz 单频精确测频100 kHz409624.4 Hz41 ms
机械振动分析10 kHz81921.22 Hz819 ms

从表里能直接看出那个反直觉的结论:想要 1Hz 级别的分辨率,就必须接受接近一秒的刷新时间,没有任何技巧能绕过这个约束。

4.2 TIM 触发 ADC 加 DMA 环形缓冲:把采样节拍钉死

FFT 的频率刻度完全依赖于采样率的准确性。如果采样是靠软件循环采的,采样间隔会随代码执行时间抖动,频谱就会糊掉,本来尖锐的谱线会变成一坨。正确做法是用一个定时器的更新事件去触发 ADC,节拍由硬件保证,软件抖动完全消除。

配置路径是:把 TIM3 的 TRGO 设为更新事件(TIM_TRGO_UPDATE),ADC 的外部触发源选成这个定时器的触发,然后把 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]是奈奎斯特频率分量的实部,然后从 k=1 开始,out[2k]out[2k+1]分别是第 k 根谱线的实部和虚部,一直到 k=N/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 / ((PSC+1) × (ARR+1)),所以调 ARR 就能调采样率。取 k = 128、N = 4096,f0 是 50Hz,算出来 fs = 1.6kHz,ARR 按这个反算出来取整,实际采样率会有微小偏差,这时候必须用实际 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 / ((PSC+1) × (ARR+1)),很多人是按 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 吐出来,很多看起来玄学的问题,把数据导出来画一张图就真相大白了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 22:58:03

银行数据治理实战:从对不上账到元数据、标准与质量闭环

简介&#xff1a;这份文档记录百信银行数据治理的一线落地经验&#xff0c;面向银行及金融机构的数据治理、数据管理与合规风控从业者&#xff0c;也适合正在搭建数据治理体系的技术管理者参考。内容从国家与监管动态切入&#xff0c;梳理《银行业金融机构数据指引》在治理架构…

作者头像 李华
网站建设 2026/9/17 22:56:41

5G小区高负荷判定:从PRB利用率到RRC用户数的联合门限解析

简介&#xff1a;5G高负荷场景流量与用户数联合判定标准文档&#xff0c;面向通信网络工程师、5G无线优化人员及运营商网络规划运维者&#xff0c;用于解决高负荷小区识别、容量评估与扩容决策等问题。内容给出大、中、小数据包划分依据&#xff0c;并覆盖2.6G/4.9G/700M等频段…

作者头像 李华
网站建设 2026/9/17 22:56:15

C++指针冒泡排序:从底层原理到代码调试的完全指南

如果你正卡在“C入门练习题里的指针冒泡排序”上&#xff0c;这篇笔记应该能帮到你。作为C入门阶段最常被拿来练手的组合题&#xff0c;指针和冒泡排序绑在一起&#xff0c;难度其实没有想象中那么高&#xff0c;但它确实是检验你三样基本功够不够扎实的好题目&#xff1a;指针…

作者头像 李华