ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32测频双方案:输入捕获与FFT互补实现高精度频率计

STM32测频双方案:输入捕获与FFT互补实现高精度频率计 做嵌入式测频方案翻来覆去无非就那几种定时器外部计数、输入捕获测脉宽、比较器加计数器再用ADC采样做FFT频谱分析。这次的项目把工程里最常用的两种测频手段落到同一块STM32上——输入捕获和FFT。输入捕获靠定时器硬件边沿检测来量周期适合单频方波信号FFT则先采样再算频谱适合正弦波、混叠信号和需要看频域分布的场合。两者不是替代关系而是互补关系这篇就把我从原理、选型、代码到调试踩坑的完整过程写出来给正在做频率计、电机转速测量、信号分析类项目的朋友一份可直接抄的作业。1. 测频方案的整体思考为什么把输入捕获和FFT放在一起1.1 输入捕获到底能干什么输入捕获是STM32定时器的一个硬件功能核心原理是当引脚上检测到指定的电平跳变上升沿或下降沿时定时器会把当前计数器的值瞬间锁存到捕获寄存器里同时触发中断整个过程不需要CPU轮询精度只取决于定时器时钟。用输入捕获测频率本质上是在测周期。你捕获到两次上升沿的时刻相减就是信号的一个周期频率自然就出来了。这个方案的好处是实时性好、精度高尤其适合低频信号。比如一个50Hz的工频信号周期20ms你只要测量两个边沿之间的时间差就能算出频率误差可以做到非常小。但它也有明显的短板如果信号频率太高两次捕获之间的计数值很小分辨率会迅速下降。举个例子定时器时钟是84MHz输入一个1MHz的信号两次上升沿之间只有84个计数再加上中断响应延迟的抖动测出来的频率可能差出几千Hz。这时候就需要换思路了。1.2 FFT在测频里是怎么定位的FFT快速傅里叶变换是另一种测频思路。它不直接去数边沿而是把信号采样下来变换到频域然后找到幅值最大的频率点。这样做的好处有几个第一它能同时识别多个频率成分。输入捕获只能测出信号的主频率也就是边沿速率但如果信号里有谐波、杂散或者多个独立的频率分量FFT一眼就能全看出来这也是频谱分析类应用选它的根本原因。第二它不挑波形。输入捕获严格要求信号有清晰的边沿像方波、矩形波、带毛刺的TTL信号都还行但如果输入是纯正弦波或者幅值很小的信号触发电平设置不好很容易误触发。FFT没有这个问题ADC采样是什么样就分析什么样。第三它的精度可以通过增加采样点来提升。FFT的频率分辨率是采样率除以采样点数你用4096个点、采样率10kHz分辨率大概2.44Hz对大多数应用来说足够了。但FFT同样有代价计算量大。一个1024点的基2 FFT大约需要上万次乘加运算在没有FPU和DSP指令的普通单片机上会很吃力。所以STM32F4系列带硬件FPU和CMSIS-DSP库做FFT才比较顺畅。1.3 两条技术路线的互补逻辑实际项目里怎么选我的经验是频率低于100kHz、信号是方波或矩形波、要求实时性极高的优先用输入捕获。频率高于100kHz、或者信号波形不规整、需要做频谱分析的用FFT。当然还有更“高级”的玩法——两个都做低频自动切输入捕获高频自动切FFT形成一个自动量程切换的频率计。这次项目我用的主控是STM32F407VET6168MHz主频带FPU跑FFT毫无压力。输入捕获用TIM3的通道1FFT用ADC1采集数据。整个系统的框图很简单信号经过整形和限幅后一路进PA6TIM3_CH1做输入捕获另一路进PA0ADC1_IN0做采样。2. 输入捕获测频原理拆解与STM32定时器实战2.1 定时器输入捕获的工作机制先聊透原理。STM32的通用定时器有四个独立通道每个通道都能配置为输入捕获模式。信号从通道引脚进来后经过输入滤波、边沿检测然后触发捕获事件把CNT计数器的当前值锁存到CCR寄存器同时置位捕获中断标志。这里有一个细节很多人会忽略输入捕获测周期你至少要捕获两次边沿才能算出一个周期。第一次捕获得到的是捕获时刻的计数值第二次捕获又得到一个计数值两者相减才是一个完整周期。所以代码里需要一个“上次捕获值”的变量来保存前一次的结果。如果信号频率很低比如1Hz那两次捕获之间计数器可能会溢出很多次。这种情况下你除了保存捕获值还要在捕获中断里检查CNT是否发生了溢出把溢出次数也记下来否则算出来的周期会差出一整个溢出周期结果完全错误。我最初做这个项目时就踩过这个坑后面会细说。2.2 测频法和测周法的选取逻辑输入捕获方案下还有细分测频法和测周法。测频法是在固定时间窗口比如1秒内统计输入脉冲的个数频率直接等于脉冲数除以时间。测周法是测量单个周期的时间长度频率等于周期的倒数。两种方法的精度特性和频率范围是互补的。高频信号适合测频法因为单位时间内的脉冲数多量化误差小。低频信号适合测周法因为周期长计数值大相对精度高。如果你在低频段用测频法1Hz的信号要等整整1秒才能更新一次实时性很差如果高频段用测周法测量时间极短但计数值太小一个计数误差就是几kHz的偏差。所以在工程实践中比较好的做法是设定一个切换阈值信号频率低于这个阈值用测周法高于用测频法。不过这次项目为了简化逻辑输入捕获这边固定用测周法然后用预分频器动态调整来拓展量程。具体来说我先用不分频的配置测一次计算出的周期如果太短说明频率高就把预分频加大再测等效于用更粗的时钟刻度去量一个很窄的脉冲这个技巧在工业测频里很常见。2.3 基于HAL库的输入捕获配置步骤我用STM32CubeMX做初始化省去手写寄存器配置的麻烦。关键配置点如下TIM3选择“Input Capture direct mode”直接模式通道1的极性选Rising Edge也就是上升沿触发捕获。分频系数PSC先设0ARR设最大值0xFFFF让计数器自由运行。输入滤波设为0因为测试信号比较干净如果现场干扰大可以把ICFilter调到4~8滤掉毛刺。CubeMX生成代码后需要手动开启捕获中断并重写中断回调函数。注意HAL库的机制是捕获中断发生后先进入中断服务函数再调用回调函数HAL_TIM_IC_CaptureCallback业务逻辑必须写在这个回调里。配置代码如下// TIM3通道1输入捕获初始化CubeMX已生成基础配置这里是关键手动补充 HAL_TIM_IC_Start_IT(htim3, TIM_CHANNEL_1);// 捕获回调计算周期和频率 uint32_t g_capture_val 0; uint32_t g_capture_period 0; uint16_t g_capture_overflow 0; float g_input_freq 0.0f; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { static uint32_t last_capture 0; static uint16_t last_overflow 0; uint32_t now_capture; uint32_t now_overflow; uint32_t total_cnt; uint32_t total_overflow; if (htim-Instance TIM3) { now_capture HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); now_overflow __HAL_TIM_GET_COUNTER(htim3); // 读取溢出计数值需要配合TIM中断处理 // 此处因HAL库封装实际溢出计数需在TIM全局中断里累加下面再给完整写法 total_cnt now_capture - last_capture; total_overflow now_overflow - last_overflow; if (total_cnt 0) total_cnt 1; uint32_t total_ticks total_overflow * 65536 total_cnt; float timer_clk 84000000.0f; // 定时器时钟84MHz float period total_ticks / timer_clk; g_input_freq 1.0f / period; last_capture now_capture; last_overflow now_overflow; } }上面这段代码逻辑是完整的但有一个工程上的关键细节__HAL_TIM_GET_COUNTER只能在中断里配合TIM全局中断一起工作因为溢出标志需要单独处理。为了让代码直接可用我建议把溢出处理放到HAL_TIM_PeriodElapsedCallback里捕获中断和更新中断分别处理这一段后面在完整工程中会给出来。2.4 实测过程中遇到的精度与误差问题输入捕获的误差来源我最深的体会有三个。第一个是量化误差也就是计数时钟的粒度过大。比如测一个100kHz的信号周期10微秒在84MHz时钟下计数840次误差1个计数就是0.12%。如果信号变成500kHz计数只有168次误差变成0.6%。这就是为什么高频信号输入捕获精度骤降。解决办法要么是提高定时器时钟要么换用FFT方案。第二个是中断响应延迟。从捕获事件发生到进入中断函数中间有硬件压栈、中断仲裁、软件判断的时间一般是几百纳秒到几微秒。如果信号频率高这个延迟相对周期不可忽略而且每次中断的延迟时间是不同的会造成周期测量抖动。第三个是信号本身的边沿抖动。如果输入信号叠加了噪声上升沿触发点会提前或延后直接导致捕获值波动。这时候ICFilter输入滤波就派上用场了但要注意滤波也会引入额外延迟。实测中50Hz到10kHz范围内输入捕获方案的精度可以做到0.01%以内非常稳超过100kHz我基本上就直接放弃输入捕获了。3. FFT测频从采样到频谱分析的完整链路3.1 FFT测频的原理与频率分辨率FFT测频的原理说简单也简单把一段时间内采样到的时域信号变换到频域得到幅值随频率变化的频谱然后找出最大幅值对应的频率。这个频率就是信号的主频。但工程上远没那么简单。首先要面对的就是频率分辨率。FFT输出的频谱是离散的相邻两个频点之间的间隔等于fs / N其中fs是采样率N是采样点数。举个例子采样率10kHz采样1024点分辨率就是10000 / 1024 ≈ 9.77Hz。这意味着就算信号真实频率是1000HzFFT也只能分辨到±5Hz左右。提高分辨率的办法有两种增加采样点数N或者降低采样率fs。但增加N意味着更长的采集时间和更大的计算量降低fs则受奈奎斯特采样定理限制采样率必须大于信号最高频率的2倍否则会产生频谱混叠信号频率会被“折叠”映射到错误的频点。我这次设计取的是折中方案采样率20kHz采样点数2048分辨率约9.77Hz。在20kHz以下频段这个精度对大多数测频场景足够。如果需要对某个具体频点精细测量就在FFT粗略定位后再用输入捕获或者更高采样率的二次FFT进行细化。3.2 采样策略与窗函数选择FFT有个天然的假设被分析的信号是无限周期的FFT只是截取了其中一个片段。真实采样不可能完全截取整数个周期于是在时域上相当于把信号乘了一个矩形窗频域上就会出现频谱泄漏——原本集中在某个频点的能量扩散到旁边的频点。解决频谱泄漏的标准做法是加窗。常用的窗函数有汉宁窗、海明窗、布莱克曼窗等。汉宁窗主瓣较宽但旁瓣衰减快适合测量单一频率成分布莱克曼窗旁瓣衰减更大但主瓣更宽频率分辨能力更差。测频应用里汉宁窗是平衡性最好的选择。在STM32上实现加窗很简单在ADC采样填充完数组后对每个采样点乘上对应的窗函数系数。窗函数系数可以预先计算好存到数组中避免运行时重复计算。核心代码如下// 汉宁窗系数预计算与加窗 #define FFT_SIZE 2048 float32_t hanning_win[FFT_SIZE]; float32_t fft_input[FFT_SIZE * 2]; // 实部虚部交织排列 float32_t fft_output[FFT_SIZE]; void window_init(void) { for (uint16_t i 0; i FFT_SIZE; i) { hanning_win[i] 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1))); } } void apply_window(uint16_t *adc_buf) { for (uint16_t i 0; i FFT_SIZE; i) { fft_input[2 * i] (float32_t)adc_buf[i] * hanning_win[i]; fft_input[2 * i 1] 0.0f; // 虚部置零 } }3.3 STM32F4上基于CMSIS-DSP的FFT实现STM32F4跑FFT我强烈建议直接用CMSIS-DSP库不要自己手写傅里叶变换。CMSIS-DSP是ARM官方针对Cortex-M内核优化的数学库里面的FFT函数利用FPU和指令级优化1024点浮点FFT在168MHz的F407上只需不到1ms完全满足实时测频需求。CMSIS-DSP的使用有几个关键步骤第一在Keil或STM32CubeIDE中勾选DSP库第二包含arm_math.h头文件第三调用arm_cfft_f32执行FFT再用arm_cmplx_mag_f32计算幅值。这里有个容易出错的地方CMSIS-DSP的FFT函数输入是复数数组数据排列是实部、虚部交替存放。如果你的处理对象是实数信号虚部要填0。另外FFT的输出有对称性前N/2个频点就已经包含了全部有效的频率信息后面的部分是镜像直接忽略。核心计算代码#include arm_math.h #include arm_const_structs.h void fft_process(void) { // 执行2048点FFT参数0表示正变换1表示位反转 arm_cfft_f32(arm_cfft_sR_f32_len2048, fft_input, 0, 1); // 计算幅值 arm_cmplx_mag_f32(fft_input, fft_output, FFT_SIZE); // 搜索最大幅值频点只遍历前一半有效频点 uint16_t peak_index 0; float32_t max_mag 0.0f; for (uint16_t i 1; i FFT_SIZE / 2; i) // 跳过直流分量 { if (fft_output[i] max_mag) { max_mag fft_output[i]; peak_index i; } } // 换算频率fs / N * i float32_t fs 20000.0f; float32_t freq fs * peak_index / FFT_SIZE; // freq 就是信号主频 }3.4 频率校正和幅值阈值的细节直接找最大幅值频点得到的频率是FFT分辨率的高散值和真实频率之间可能差半个分辨率。要想提高精度可以用抛物线插值对峰值频点进行细化。方法很简单取峰值频点左右各两个点的幅值用二次多项式拟合找到拟合曲线的顶点坐标这个顶点对应的频率就是校正后的频率。抛物线插值的公式推导略过直接给工程结论如果峰值处三个连续频点的幅值为A、B、C中间频点索引为k则峰值的偏移量delta可以通过0.5 * (A - C) / (A - 2*B C)近似计算校正频率就是(k delta) * fs / N。这个方法能把FFT的频率精度从±半个分辨率提升到约0.1个分辨率以内性价比很高。另外最大幅值并不总是有效信号。如果ADC输入悬空或者信号太弱噪声可能成为最大幅值导致测频结果乱跳。我的做法是设定一个幅值下限只有当最大幅值超过某个阈值时才更新频率否则认为无信号保持上次测量结果。阈值的确定要看具体ADC的位数和信号调理电路的增益我用12位ADC信号幅值大概在3000左右阈值设在200实测效果稳定。4. 完整实测流程与调试经验分享4.1 实验环境搭建与硬件注意事项我搭建了一个简单的测试环境信号发生器输出不同频率的方波和正弦波经SMA转杜邦线连接至STM32最小系统板。输入捕获通道接PA6ADC采样通道接PA0两个通道各自串联一个1kΩ电阻和一个5V稳压二极管做简单限幅保护。这里必须提醒一下STM32的IO口耐压是有限的信号发生器输出如果超过3.3V直接连接可能烧引脚。我刚开始就是直接接结果有一次信号发生器误调到5VPA6引脚的输入保护二极管被正向导通芯片虽然没烧但那个通道就开始偶尔误触发了。后来学乖了输入信号都经过一个由电阻分压二极管钳位的限幅电路再进单片机。接线完成后的验证方法也很简单先用信号发生器输出一个1kHz、3.3V的方波分别用示波器看PA6和PA0的波形确认信号干净无畸变后再烧录固件测试。不要跳过这一步很多“程序问题”最后发现是硬件信号根本不对。4.2 实测数据对比与精度分析我在同一块板子上分别用输入捕获和FFT测了一组标准频率信号数据如下信号发生器输出输入捕获实测FFT实测备注50 Hz (方波)50.003 Hz49.80 Hz输入捕获明显更准1 kHz (方波)1.0002 kHz997.07 HzFFT接近分辨率极限10 kHz (方波)9.998 kHz9.986 kHz两者都在可接受范围10 kHz (正弦)异常9.996 kHz方波测周期触发跳变50 kHz (方波)49.72 kHz50.10 kHz输入捕获开始出现明显误差100 kHz (方波)98.6 kHz99.64 kHzFFT优势逐渐显现从数据可以明显看出低频段输入捕获精度碾压FFT高频段输入捕获开始力不从心FFT反而稳定。这印证了之前“互补关系”的判断。正弦波那行数据值得说明输入捕获测方波没问题但测纯正弦波时由于信号爬升缓慢噪声可能导致触发点抖动严重实测频率经常跳得离谱这种情况下只能靠FFT。4.3 踩过的坑定时器溢出、DMA丢失与FFT耗时不均匀定时器溢出计数不准这是输入捕获最容易踩的坑。用测周法时如果信号周期长于定时器的溢出周期计数器会溢出多次捕获到的CNT值看起来没问题但实际上已经丢失了溢出的周期数算出的周期远小于真实值。我最终的处理方案开启TIM3的更新中断在HAL_TIM_PeriodElapsedCallback里累加溢出计数同时在捕获回调里读取当前的溢出计数和捕获值两者合并计算总周期。代码结构如下volatile uint32_t g_tim3_overflow_cnt 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { g_tim3_overflow_cnt; } } void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { static uint32_t last_capture 0; static uint32_t last_overflow 0; uint32_t now_capture HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); uint32_t now_overflow g_tim3_overflow_cnt; uint32_t total_ticks (now_overflow - last_overflow) * 65536 (now_capture - last_capture); // 防止周期为0的异常情况 if (total_ticks 0) { g_input_freq 84000000.0f / (float)total_ticks; } last_capture now_capture; last_overflow now_overflow; }注意溢出计数变量用了volatile修饰因为它在中断里被修改在主循环里被读取如果没有这个关键字编译器可能把变量优化到寄存器里读出来的永远是旧值。DMA采样丢数据FFT的采样我最初用ADCDMA连续采集2048个点DMA传输完成中断里启动FFT。但调试时发现一个规律频率显示偶尔会卡住不更新一查发现是DMA配置成普通模式时每次传输完需要手动清零标志并重新启动如果漏了这一步第二次DMA不工作数据永远停留在上一次的内容。解决办法是把DMA模式改为循环模式Circular Mode并直接读取内存数组里的最新数据。循环模式下DMA会持续把ADC转换结果写入内存主循环需要FFT时直接拷贝一份最新数据既避免了启动DMA的迟滞也防止了数据撕裂。代价是需要一块两倍大小的缓冲区做乒乓切换但对现代STM32来说这点内存根本不是问题。FFT耗时导致采样不连续我最初的代码是在DMA半传输中断里做FFT目的是让采样和计算并行。但实测发现一个问题当FFT计算耗时较长时中断阻塞了后续的采样中断导致采样间隔不均匀FFT的频谱上出现了额外的杂散分量。排查后确认是中断优先级设置的问题。解决办法是把DMA中断优先级设为最高优先级抢占优先级0FFT计算移到主循环中执行不在中断里做。DMA采样到2048个点后置一个标志位主循环检测到标志后拷贝数据、执行加窗和FFT。这样采样中断永远不会被FFT计算阻塞频谱干净多了。5. 进阶优化自动量程切换与系统集成5.1 自动量程切换让两个方案协同工作两块方案都做完后我顺手做了一个自动量程切换策略系统上电先跑一次FFT做粗测得到信号的大致频率范围然后根据频率值决定后续是走输入捕获还是FFT。具体规则如下频率低于20kHz切换到输入捕获测频使用测周法精度高、实时性好。 频率在20kHz到100kHz之间继续用FFT因为这个频段输入捕获误差已经变大而FFT还在精度范围内。 频率超过100kHz提高ADC采样率用更高采样率的FFT或者用定时器外部时钟模式直接数边沿。这套切换逻辑写起来不算复杂核心就是一个状态机但效果非常明显。以前单用输入捕获碰到高频信号就抓瞎单用FFT低频分辨率又太粗。当两个方案能自动切换时整个系统的可测频率范围可以覆盖几Hz到几百kHz而且每段都能拿到当下最优的精度和响应速度。5.2 与电机测控系统集成的思路做这个项目还有个实际背景给一个变频器输出检测系统做频率测量模块。变频器输出的PWM频率通常在几百Hz到几十kHz之间而且波形带有较强的谐波和噪声单纯用输入捕获测出来的频率波动很大。后来我把FFT测频模块加进去后用FFT先锁定基频再用输入捕获在基频附近做细测综合下来频率测量稳定性提升非常明显。这套“FFT粗测输入捕获精测”的思路也可以移植到电机转速检测上。光电编码器输出脉冲信号先用输入捕获做低速测周高速时切换为测频法转速适用范围大大扩展。如果你正在做电机控制、变频器通讯或者闭环调速这套双模测频方案可以直接参考。5.3 代码架构与资源占用建议最后聊聊代码组织。整个项目我是按模块划分的freq_meter.c负责输入捕获测频fft_analyzer.c负责FFT采样和分析main.c只做初始化、主循环和结果显示。每个模块的接口尽量简单输入捕获模块对外暴露一个GetFreqByCapture()函数FFT模块对外暴露GetFreqByFFT()函数顶层只需要调用这两个接口。资源占用方面FFT是吃内存大户。2048点FFT需要两个数组实部虚部交织的输入数组2048*2个float16KB和幅值输出数组2048个float8KB加上汉宁窗系数8KB总共约32KB RAM。STM32F407的192KB RAM完全无压力但如果你用的是STM32F103这种小内存芯片就需要把FFT降到1024点或者用16位定点FFT来节省内存。调试建议输出用串口打印频率值通过串口助手实时查看。刚开始可以先人为改变信号频率观察串口输出的频率变化趋势是否正确再逐步优化精度。等整体逻辑稳定后再把数据接到OLED或LCD显示效果会直观很多。我做这个项目的整体感受是输入捕获和FFT完全不是竞争关系而是不同频段、不同波形场景下的互补工具。低频方波用输入捕获精度高到你怀疑人生高频或波形复杂时果断切FFT虽然做不到极端精确但胜在稳定和不挑信号。两者结合才能真正覆盖一个实用的测频范围。这种“两种方案取长补短”的思路比纠结于某一个算法本身更能解决实际工程问题。
返回列表