
搞单片机测频率这件事很多人在输入捕获和FFT之间纠结。说实话我刚接触STM32那会儿也绕了不少弯路一开始只会用定时器输入捕获测个方波频率后来项目里要分析音频信号频谱才发现库函数自带的测频逻辑根本顶不住这才回头沉下心把FFT补上。这篇就把我自己做STM32测频的完整思路、代码细节和踩坑记录都摊开讲从输入捕获到FFT频谱分析一次讲透适合手里有开发板、想真正搞明白频率测量底层逻辑的人。1. 两种主流测频方案的设计考量做频率测量前得先想清楚一个根本问题你要测的信号长什么样是干净的方波、有噪声的脉冲串还是连续变化的正弦波不同信号形态直接决定方案选型这也是我在项目初期踩坑最多的地方。1.1 输入捕获和FFT到底分别解决什么问题输入捕获走的是定时器硬件通道核心原理是记录信号边沿到来时的计数器值。比如定时器在72MHz下运行信号上升沿到来时计数器是1000下一个上升沿到来时计数器是3000中间差了2000个计数周期那信号周期就是2000/72MHz频率就是36kHz。这种方案最大的优势在于精度极高、实时性极强而且CPU占用极低——因为整个计数过程由硬件完成中断里只需要做加减法。它天然适合电压比较器输出的方波、霍尔传感器输出的转速脉冲这类干净的数字信号。FFT测频则是另一条路它把ADC连续采样的时域信号变换到频域在频谱上寻找峰值对应的频率点。这条路的优势在于能同时分析多个频率分量而且不要求信号是规整的方波哪怕你输入的是带噪声的音频信号、振动信号、甚至是混叠了多个频率的复杂波形FFT都能把各个频率分量拆出来。代价是计算量大、实时性相对较差而且频率分辨率受到采样点数和采样率的限制。实际项目里怎么选我总结出一条经验信号越干净、只需要单频点就用输入捕获信号越复杂、需要频谱全貌就用FFT。还有一种做法是两者结合用输入捕获做基频锁定用FFT做谐波分析这在电机故障诊断、音频设备测试里非常常见。1.2 为什么选择定时器输入通道而不是外部中断测频很多人初学者喜欢用外部中断配合HAL_GPIO_EXTI_Callback来测频率思路是在每次上升沿中断里累加计数、配合定时器做时间基准。这种方式在小项目里勉强能用但问题很明显CPU被中断频繁打断如果信号频率太高中断响应不过来测出来的频率直接飘掉。而且外部中断的响应延迟受主循环负载影响很大稳定性很难保证。定时器输入捕获则是硬件级操作。信号边沿到来时定时器硬件会瞬间把当前计数器值锁存到捕获寄存器里同时置位捕获标志位整个过程零延迟、不需要CPU介入。CPU只需要在中断里读走捕获值和计算差值就拿到了精确的时间间隔。同样的频率用外部中断可能误差到几百Hz用输入捕获误差能控制在几Hz以内。另外STM32的定时器输入捕获还支持边沿检测极性配置上升沿、下降沿、双边沿都能捕获这意味着你不但能测频率还能顺便测占空比。一个TIM channel同时输出两个捕获事件具体实现后面细说。1.3 系统整体架构和各模块的配合关系这套测频系统我在开发板上完整跑通过整体架构分六层信号调理前端、STM32定时器输入捕获模块、ADC连续采样模块、FFT运算核、结果处理和显示输出。信号进来先经过一个运放组成的电压比较器或直接用电平转换电路把模拟信号转为3.3V方波给TIM输入捕获用同时信号也分一路到ADC做连续采样采样数据用DMA搬运到内存避免CPU干预。DMA搬运满一个缓冲块后触发DMA传输完成中断在中断服务函数里启动FFT计算。FFT结果存放在一个结构体里包含主频点索引和幅值。最后由OLED或者串口把结果输出。为什么要把信号同时送给捕获和ADC因为两种测量模式各有不可替代的使用场景。捕获通道测的就是此刻的精确频率FFT反映的是这一小段时间窗内的频谱分布。两者可以交叉验证比如捕获测出来基频是1kHzFFT频谱正好在1kHz处有尖峰那测量结果基本可以确认是对的。这种冗余设计在工程上非常有必要。2. 输入捕获的底层原理与代码实现说句实在话定时器输入捕获的官方文档和HAL库注释写得很抽象一堆寄存器名字绕得人头疼。但如果你理解了它的核心机制其实就三件事配置捕获通道、设置边沿极性、在中断里读计数器差值。2.1 定时器时基配置和捕获通道初始化我用的是STM32F407定时器TIM3挂载在APB1总线上时钟84MHz。初始化时基部分代码如下TIM_HandleTypeDef htim3; htim3.Instance TIM3; htim3.Init.Prescaler 84 - 1; // 84MHz / 84 1MHz, 计数周期1us htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 0xFFFF; // 16位定时器最大值 htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(htim3);预分频器设为84-1这步很关键。84MHz的时钟源经过84分频后定时器计数频率正好是1MHz每个计数脉冲代表1微秒。这样捕获寄存器读到的值直接就是微秒级的时间戳不需要再做换算调试的时候用逻辑分析仪对比也方便。然后是捕获通道配置TIM_IC_InitTypeDef sConfigIC; sConfigIC.ICPolarity TIM_INPUTCHANNELPOLARITY_RISING; sConfigIC.ICSelection TIM_IC_SELECTION_DIRECTTI; sConfigIC.ICPrescaler TIM_ICPSC_DIV1; sConfigIC.ICFilter 0x00; HAL_TIM_IC_ConfigChannel(htim3, sConfigIC, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(htim3, TIM_CHANNEL_1);ICPolarity设置上升沿触发捕获测量频率一般用上升沿。ICSelection选DIRECTTI意思是直接把引脚信号接到捕获通道也可以配置为间接模式来做异或输入。ICFilter是数字滤波器的参数设为0表示不过滤如果信号有抖动可以调到4以上但代价是引入几百纳秒的延迟对高精度测量有影响。2.2 中断处理中如何计算频率并处理溢出中断回调函数是频率测量的核心我直接把完整逻辑贴出来uint32_t cap_value 0; uint32_t cap_last 0; uint32_t cap_diff 0; uint32_t overflow_cnt 0; float frequency 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3 htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { cap_value HAL_TIM_ReadCapturedValue(htim3, TIM_CHANNEL_1); if (cap_value cap_last) { cap_diff cap_value - cap_last; } else { cap_diff (0xFFFF - cap_last) cap_value 1; } cap_last cap_value; frequency (float)1000000.0f / (float)cap_diff; } }这个代码处理了定时器溢出的情况。16位定时器最大计数值65535计数到65535后如果还没等到信号边沿计数器会回绕到0继续计数。如果不处理回绕算出来的时间差就是个巨大的错误值频率自然完全不对。我的办法是判断当前捕获值是否小于上一次如果是就说明发生了回绕用(0xFFFF - cap_last) cap_value 1把两段计数补起来。还有更稳妥的做法是开启定时器更新中断在更新中断里维护一个overflow_cnt变量记录溢出次数捕获中断里用(overflow_cnt 16) | cap_value得到完整的32位时间戳。这种方案适合低频信号因为低频意味着两个脉冲之间间隔很长定时器可能溢出多次。2.3 多通道同时测占空比的扩展方法测频率只是开始很多场景还要测占空比比如PWM波形的调试、电机调速信号的解析。实现方法是用定时器的两个捕获通道通道1捕获上升沿通道2捕获下降沿。上升沿时刻记下t1下降沿时刻记下t2高电平时间就是t2 - t1接着下一个上升沿时刻记下t3周期就是t3 - t1。占空比就是高电平时间除以周期。代码上需要开启两个通道的捕获中断HAL_TIM_IC_Start_IT(htim3, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(htim3, TIM_CHANNEL_2);然后在回调里用htim-Channel来区分是哪个通道事件void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { static uint32_t t_rising 0; static uint32_t t_falling 0; if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { t_rising HAL_TIM_ReadCapturedValue(htim3, TIM_CHANNEL_1); } else if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_2) { t_falling HAL_TIM_ReadCapturedValue(htim3, TIM_CHANNEL_2); } }这里有一个细节通道2配置时ICSelection必须选TIM_IC_SELECTION_INDIRECTTI否则两个通道读的是同一个引脚。间接模式让通道2映射到和通道1相同的引脚上但边沿极性不同这样上升沿和下降沿都能被捕获到。实际操作中还要注意如果信号占空比极小或极大比如1%或者99%捕获到的t_falling可能非常接近t_rising计数器差值很小计算时浮点误差会被放大。这时最好多测几个周期取平均或者用DMA批量捕获多个边沿再做统计。3. FFT测频的完整实现路线如果输入捕获是测频的精确制导武器那FFT就是全景侦察雷达。它能告诉你信号里有哪些频率成分、各自幅值多大但精度受限于频率分辨率。做嵌入式FFT之前我建议先弄清采样定理、分辨率、窗函数这几个概念否则出来的频谱图你会怀疑人生。3.1 FFT原理通俗解读和采样率的选定标准FFT快速傅里叶变换对很多人来说像黑魔法其实核心就一句话把一段时域信号分解成若干个不同频率正弦波的叠加。N个采样点经过FFT后得到N个复数结果每个复数对应一个频率分量的幅值和相位。嵌入式里最常用的FFT大小是1024点或者4096点为什么是2的幂次因为FFT的核心就是不断把序列分成奇偶两组进行蝶形运算2的幂次才能保证这一过程完整进行实际代码里用基数-2 FFT实现最简单。采样率的选择直接决定你能测到的最高频率。奈奎斯特采样定理说采样率必须大于信号最高频率的2倍实际工程中一般取3到5倍。比如你要分析10kHz以内的音频信号采样率至少设30kHz。STM32F407的ADC最高可以跑到2.4MHz采样率但这么高的采样率下FFT的点数如果不够频率分辨率就会很糟糕。频率分辨率的公式是Fs / NFs是采样率N是FFT点数。假设采样率30kHz、FFT点数1024分辨率就是30kHz/1024 ≈ 29.3Hz。这意味着两个频率相差不到29.3Hz的信号在频谱上是分不开的它们会糊成同一个峰。要提高分辨率要么降低采样率要么增大FFT点数。但增大FFT点数意味着更多内存和更长计算时间F407用硬件FPU做1024点复数FFT大概需要几百微秒到几毫秒做4096点就明显吃力了。3.2 ADC连续采样配合DMA的数据采集实现FFT的数据来源是ADC采样序列采样必须连续且间隔均匀否则频谱会出现严重的杂散。我用的是ADC1 DMA 定时器触发的多通道采集模式。定时器TRGO事件触发ADC启动ADC转换完成后由DMA把结果搬到内存缓冲区整个过程不需要CPU参与。配置代码大致如下ADC_HandleTypeDef hadc1; DMA_HandleTypeDef hdma_adc1; hadc1.Instance ADC1; hadc1.Init.ClockPrescaler ADC_CLOCK_SYNC_PCLK_DIV4; hadc1.Init.Resolution ADC_RESOLUTION_12B; hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.ScanConvMode DISABLE; hadc1.Init.EOCSelection ADC_EOC_SINGLE_CONV; HAL_ADC_Init(hadc1);DMA配置hdma_adc1.Instance DMA2_Stream0; hdma_adc1.Init.Channel DMA_CHANNEL_0; hdma_adc1.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc DMA_PINC_DISABLE; hdma_adc1.Init.MemInc DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode DMA_CIRCULAR; hdma_adc1.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_adc1);这里有几个点值得注意DMA用了循环模式这样ADC会不停地采集并覆盖旧数据模拟一个不断更新的环形缓冲区。你要做的就是隔一段时间去读取缓冲区里的最新数据做FFT。如果用普通模式缓冲区满了DMA就停了还得手动重新启动很麻烦。DMA的传输方向是从外设到内存数据和内存宽度都用半字16位因为12位ADC的结果正好够装进16位变量。MemInc必须使能否则每次传输都写同一个地址。最后别忘了在HAL_ADC_Start_DMA之前调用HAL_ADC_Start的变体启动连续转换模式。3.3 FFT库的选择ARM DSP库的移植和核心代码自己做FFT从数学原理开始写没必要ARM官方提供了CMSIS-DSP库里面包含针对Cortex-M4/M7优化过的FFT函数利用硬件FPU和SIMD指令比手写的代码快好几倍。STM32CubeMX里只需要在Middleware或者Software Packs里勾选CMSIS-DSP然后加头文件arm_math.h和库文件libarm_cortexM4lf_math.a即可。FFT计算的典型流程是先初始化实部/虚部交错排列的数组然后调用arm_cfft_f32进行变换最后用arm_cmplx_mag_f32计算幅值。#define FFT_SIZE 1024 float32_t input[FFT_SIZE * 2]; float32_t output[FFT_SIZE]; arm_cfft_instance_f32 fft_instance; arm_cfft_init_f32(fft_instance, FFT_SIZE); arm_cfft_f32(fft_instance, input, 0, 1); arm_cmplx_mag_f32(input, output, FFT_SIZE);arm_cfft_f32的第三个参数为0表示正变换第四个参数为1表示使用位反转。CMSIS库的实现把位反转也放在了这个函数内部完成你只需要按照复数格式填充数据就行。ADC采样得到的是实数数据怎么填进复数数组一个常见技巧是让输入序列的偶数下标存实数奇数下标存0然后调用实数FFT的接口arm_rfft_f32。它的执行效率比直接填零的复数FFT更高。实际代码如下arm_rfft_fast_instance_f32 rfft_instance; arm_rfft_fast_init_f32(rfft_instance, FFT_SIZE); arm_rfft_fast_f32(rfft_instance, adc_buffer, fft_output, 0); arm_cmplx_mag_f32(fft_output, magnitude_output, FFT_SIZE);这套流程跑下来频谱数据就存在magnitude_output里了。第0个元素是直流分量第1个元素对应频率Fs/N第2个元素对应2*Fs/N依此类推。找频谱峰值就是在数组里搜索最大值。3.4 频谱峰值搜索和插值修正频率值直接搜最大值得到的频率索引对应的是FFT的离散频率点这个值和真实频率之间存在量化误差。举例说明采样率30kHzFFT点数1024频率分辨率29.3Hz。如果真实信号频率是1000Hz它落在索引34.13的位置1000/29.3 34.13但FFT输出只有整数索引34对应的频率是996.1Hz35对应1025.4Hz你搜索最大值可能落在34上读出来的频率就差了约4Hz。这个误差在要求不高的场景可以接受但要进一步提高精度可以用抛物线插值。以峰值索引k为中心取k-1、k、k1三个点的幅值做抛物线拟合顶点位置就是更精确的峰值位置。float parabolic_interp(float *mag, int peak_index) { float y1 mag[peak_index - 1]; float y2 mag[peak_index]; float y3 mag[peak_index 1]; float delta 0.5f * (y1 - y3) / (y1 - 2.0f * y2 y3); return (float)peak_index delta; }计算出来的delta加上索引值再乘以频率分辨率就是修正后的频率。这种方法在信号比较纯净、没有强干扰的情况下能把误差压到0.1Hz以内。关于加窗还有一个常被忽略的坑。直接对ADC数据做FFT相当于加了矩形窗频谱会有严重的频谱泄漏——主峰旁边出现很多旁瓣淹没了较小的频率分量。解决方法是先对数据乘一个窗函数Hann窗是最常用的。ARM库也集成了相关函数用arm_mult_f32配合预先生成的窗系数数组就行。4. 显示层设计从裸机到直观调试测频系统没有数据显示就等于白做。这里说两种输出方式OLED屏直接显示和串口输出到上位机。如果做到项目演示或产品原型后者几乎是标配。4.1 OLED屏幕显示测频结果的驱动方法我习惯用0.96寸I2C接口的OLEDSSD1306驱动芯片四根线VCC、GND、SCL、SDA接上就能跑。STM32的硬件I2C有时会因为电气特性不稳定导致通信失败我干脆直接用GPIO模拟I2C稳得一批。显示逻辑比较简单但在实际项目里有个细节要注意OLED是刷新率很低的设备如果每算完一次FFT就全屏刷新一次画面会闪烁得厉害。我的做法是开一个显存缓冲所有绘制操作先在缓冲区里完成然后用DrawScreen函数一次性把整个缓冲区送到OLED。频率值的字符串格式化也有讲究。C标准库的sprintf在嵌入式上占ROM空间不小如果确实要用就启用MicroLIB。更推荐的做法是自己写个简单的整数转字符串函数频率值如果只显示整数部分完全够用。4.2 串口协议上报和PC端波形上位机对接串口输出我做了一套简单的协议帧方便上位机解析。帧格式采用帧头0xAA 0x55 | 数据类型1字节 | 数据长度1字节 | 数据N字节 | 校验和1字节数据类型区分是频率值、频谱数组还是系统状态。用DMA空闲中断接收上位机下发的命令比如切换测频模式、调整采样率等。PC端我用Python写了个简单的上位机从串口读数据后用matplotlib动态绘制频谱图效果非常直观。这里给一段串口读取和绘图的简化示例import serial import numpy as np import matplotlib.pyplot as plt ser serial.Serial(COM10, 115200, timeout1) plt.ion() fig, ax plt.subplots(figsize(10, 6)) while True: header ser.read(2) if header b\xaa\x55: data_type ser.read(1)[0] data_len ser.read(1)[0] payload ser.read(data_len) checksum ser.read(1)[0] if data_type 0x01: freq struct.unpack(f, payload[:4])[0] ax.clear() ax.text(0.5, 0.5, f{freq:.2f} Hz, hacenter, vacenter, fontsize30) plt.pause(0.01)这段代码逻辑不复杂但要注意串口和matplotlib的刷新频率不能太高否则UI线程会卡死。一般控制在10Hz以内就够了。5. 实测校准、误差分析与问题排查测频系统做出来只是第一步真正让人头大的是后续的校准和排错。我把实测过程中遇到的问题整理成一套排查方法论照着走能省下大量时间。5.1 信号发生器实测数据比对的校准方法我用信号发生器输出标准方波和正弦波从1Hz到100kHz分段测试记录下捕获模式测频和FFT测频的实际读数与标准值的偏差。部分实测数据如下标准频率输入捕获测频FFT测频捕获误差FFT误差100Hz100.02Hz100Hz0.02%0%1kHz1000.05Hz999.8Hz0.005%0.02%10kHz9999.89Hz10003Hz0.001%0.03%100kHz99998.20Hz-0.002%采样率不足从数据能看出低频段FFT误差反而大因为频率分辨率限制了精度高频段捕获模式依然稳定FFT则受限于采样率。这也印证了方案选型的原则——没有哪种方案是万能的知道它们的边界比死磕哪一套更重要。校准的时候要特别注意信号发生器的输出阻抗。很多信号发生器默认50Ω输出如果你直接接STM32引脚信号会被分压幅度可能到不了触发阈值。我后来用了高阻输入模式或者加了一级运放缓冲问题才解决。5.2 捕获丢波和FFT杂散频谱的排查过程排查采样问题时要先分清楚是硬件问题还是软件问题。我归类出最常见的三类症状和对应解法。第一类是捕获模式下频率读数偶尔跳变跳变值通常是正常值的几倍。排查思路先用示波器看信号波形是否干净。如果信号沿比较缓触发点抖动就会导致捕获时间偏差。解决办法是调大ICFilter参数或者用施密特触发器整形。软件上可以加入超时丢弃机制——如果两次捕获间隔超过预期范围就丢弃本次数据。第二类是FFT频谱在多个频率点出现等间距的杂散峰通常不是真实信号而是谐波或者混叠。先检查ADC采样率配置是否准确特别是定时器的时钟树配置是否算对了。曾经我把APB1的时钟配置搞错导致定时器触发的ADC采样率差了一倍频谱上所有频率都偏移了。第三类是FFT结果全是噪底没有明显峰值。这通常是信号幅度太小ADC分辨率不足以分辨。检查信号是否经过了适当的放大和偏置电路确保信号的交流分量落在ADC输入范围的中间位置。STM32的ADC输入范围是0到3.3V如果信号只在0.5V到1.5V之间摆动动态范围就浪费了。5.3 常见问题速查表和避坑提示格式化的速查表能在调试时快速定位问题这里整理一份我在开发过程中沉淀的笔记症状可能原因检查和解决办法捕获频率为0或巨大定时器溢出未处理检查溢出计数逻辑或用32位扩展法捕获频率波动大信号边沿抖动或噪声增大ICFilter或加施密特触发器整形FFT峰值频率偏移采样时钟配置错误用定时器输出频率和理论值对比校准FFT频谱底噪很高信号幅度太小或偏置不对调整前端放大电路检查ADC输入范围DMA不传输数据DMA配置方向或宽度错误检查DMA配置使用调试器观察寄存器系统卡死无响应中断优先级配置错误检查NVIC配置捕获中断优先级不能太低OLED不显示I2C地址错误或初始化时序不对用I2C扫描程序确认设备地址切换方案时还要注意DMA缓冲区和FFT计算的内存占用。F407的RAM虽然不算小但4096点FFT的复数数组就要占32KB内存加上DMA缓冲区、显示缓冲很容易爆RAM。我的经验是根据实际信号带宽需求尽量选1024点FFT内存占用和精度之间最平衡。6. 不同应用场景下的方案选型建议项目测试过了不代表问题结束了实际工程里最常被问到的就是我这个场景该用捕获还是FFT。如果做电机转速测量、转速闭环控制输入捕获是首选。霍尔传感器或光电编码器输出的脉冲信号特征明显捕获模式精度高、延迟低能满足实时控制的需求。即使信号频率很低只要用定时器溢出扩展法处理也能测准到0.1Hz以内。如果做音频频谱分析、振动监测、声音特征识别FFT是必须的。这类信号本身是多频率叠加的复杂波形输入捕获只能测出一个大概的基频对谐波成分完全无能为力。FFT配合窗函数能展示完整的频谱图上位机绘制频谱瀑布图效果非常直观。如果做电力系统谐波分析、逆变器输出质量检测建议两者结合。基波频率用输入捕获锁定谐波含量用FFT分析能兼顾精度和深度。需要注意的是电力信号电压较高前端一定要用电压互感器或隔离运放做电气隔离直接采220V会把开发板烧掉。如果做便携式低成本仪器比如简易频率计输入捕获配合高精度外部时钟源是性价比最高的方案。STM32内部HSI时钟精度在±1%左右测高频信号误差明显换成外部8MHz晶振配合PLL锁相后精度能提升两个数量级。我做过一个用GPS模块的1PPS信号做校准的简易频率计长期稳定度能达到ppm级别这个在实验室里用来标定其他仪器特别好用。从成本角度考量STM32F1系列不带硬件FPU做FFT运算偏慢但1024点实数FFT跑几十毫秒也能接受F4系列带FPUFFT计算时间直接降到几毫秒适合需要实时频谱显示的场景。如果预算允许上F7或者H7系列性能更强但开发难度和PCB设计要求也相应提高新手不建议直接从高端芯片开始。这套测频思路还可以往更多方向扩展。比如把采集到的实时频谱通过无线模块传到手机App就能做成一个便携式环境噪声分析仪如果把捕获测频逻辑移植到FreeRTOS任务里配合信号量做任务间的数据交互系统的实时性和可维护性会进一步提升。我在实际做产品的时候给这块测频逻辑加了一层很薄的抽象接口把捕获、FFT、显示三个模块解耦后面换屏幕、换芯片、加通信协议都方便多了这个设计习惯也建议你从一开始就养成。