基于STM32F103的高精度频率计设计:ADC采样、DMA传输与FFT处理的协同实现
在音频分析、电机控制或传感器信号监测中,我们常常需要知道一个周期性信号的频率。传统做法是用定时器捕获上升沿,通过测量周期来换算频率——这种方法简单直接,但对于非方波、含噪声的正弦信号却显得力不从心:边沿抖动、占空比变化都会导致测量误差,甚至完全失效。
有没有一种方法能“看穿”复杂波形,直接找出其中隐藏的主频?答案是肯定的:借助 ADC连续采样 + DMA自动搬运 + FFT频谱分析 ,我们可以在一片STM32F103上构建出媲美示波器功能的软件频率计。它不仅能识别任意周期信号(正弦、三角、锯齿),还能在混有噪声的情况下锁定目标频率,分辨率可达亚Hz级别。
这听起来像是高端设备才有的能力,但实际上,只要合理利用MCU内部资源,就能以极低成本实现。本文将带你一步步拆解这个系统的底层逻辑,从硬件配置到算法优化,揭示如何让一颗“普通”的Cortex-M3芯片胜任频谱分析任务。
为什么选择STM32F103?
尽管现在有性能更强的H7或F4系列,但F103依然是许多工程师心中的经典。原因很简单:成本低、资料全、生态成熟。更重要的是,它的外设组合恰好满足实时信号处理的基本需求:
- 12位ADC :足以应对大多数模拟输入场景;
- DMA控制器 :支持多通道数据自动搬运;
- 72MHz主频 :可运行轻量级FFT算法;
- 丰富的定时器资源 :可用于精确触发采样。
这套组合拳让我们无需额外添加FPGA或DSP芯片,仅靠片上模块即可完成从模拟输入到数字输出的闭环。
精确采样的关键:ADC与定时器联动
要进行频域分析,第一步就是高质量的时域采样。这里的“高质量”不是指分辨率越高越好,而是强调 等时间间隔采样 。如果采样间隔不均匀,哪怕只差几个微秒,也会造成严重的频谱泄漏,使得FFT结果失真。
STM32F103的ADC本身不具备独立时钟驱动能力,必须依赖外部触发源。常见做法是使用软件启动(
HAL_ADC_Start()
),但这会导致中断延迟引入抖动。更优的选择是
由定时器触发
。
以TIM2为例,将其配置为PWM模式,但并不输出任何波形,而是启用“更新事件触发输出”(TRGO)。当计数器溢出时,产生一个脉冲信号连接到ADC的外部触发输入端。这样,每过固定时间(比如100μs),ADC就自动开始一次转换。
// 配置TIM2作为ADC触发源
htim2.Instance = TIM2;
htim2.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 100 - 1; // 1MHz / 100 = 10kHz 采样率
htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
// 启用主模式:更新事件触发
TIM_MasterConfigTypeDef sMasterConfig = {0};
sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig);
HAL_TIM_Base_Start(&htim2);
此时ADC配置为:
- 单通道、连续转换模式;
- 外部触发源设为
ADC_EXTERNALTRIGCONV_T2_TRGO
;
- 开启DMA请求。
这样一来,整个采样过程完全脱离CPU干预,实现了真正意义上的同步和稳定。
数据搬运的“隐形助手”:DMA的作用远不止省CPU
很多人知道DMA可以减轻CPU负担,但在实际工程中,它的价值远不止于此。尤其是在高速采集场景下, DMA决定了系统能否可靠运行 。
设想一下:若采用中断方式读取ADC值,每次EOC(转换结束)都进入中断服务程序,保存数据。即使中断响应很快,当中断频繁发生(如10kHz采样率),频繁进出中断会极大增加上下文切换开销,还可能因优先级冲突导致漏采。
而DMA的工作机制完全不同。每当ADC完成一次转换,硬件自动将
ADC_DR
寄存器的内容转移到内存缓冲区,整个过程无需软件参与。你只需要提前告诉DMA三件事:
- 源地址:
&ADC1->DR
- 目标地址:
adc_buffer[N]
- 传输数量:N
然后启动DMA,剩下的交给硬件。
#define SAMPLES_NUM 1024
uint16_t adc_buffer[SAMPLES_NUM];
// 启动ADC-DMA链式操作
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, SAMPLES_NUM);
这里有个重要细节: 建议使用普通模式而非循环模式 。虽然循环模式可以让DMA不断覆盖缓冲区,看似适合持续采集,但它无法明确通知“一帧数据已满”。相比之下,普通模式在传完1024个点后会产生“传输完成中断”(TC),这是我们启动FFT处理的最佳时机。
如果你担心采样与处理之间出现间隙,也可以考虑双缓冲机制(通过DMA的双缓冲模式或两个独立通道交替工作),确保当前正在处理的数据与新采集的数据物理隔离,避免竞争。
从时域到频域:FFT不是魔法,但很接近
有了干净的采样数据,下一步就是解析频率成分。这时候轮到FFT登场了。
很多人以为FFT是个黑箱函数,调用一下就能出结果。实际上,要想得到准确的频率估计,必须理解其背后的限制和优化手段。
实数FFT vs 复数FFT
由于ADC采集的是实数序列(只有幅度,没有虚部),我们可以使用专门针对实信号优化的RFFT(Real FFT),计算量约为标准FFT的一半。ARM CMSIS-DSP库提供了
arm_rfft_fast_f32()
函数,非常适合嵌入式环境。
#include "arm_math.h"
float32_t fft_input[1024]; // 归一化后的ADC数据
float32_t fft_output[1024]; // 输出为交错复数格式:[re0, im0, re1, im1, ...]
arm_rfft_fast_instance_f32 fft_inst;
// 初始化FFT实例(只需一次)
arm_rfft_fast_init_f32(&fft_inst, 1024);
// 执行变换
arm_rfft_fast_f32(&fft_inst, fft_input, fft_output, 0);
注意:
fft_output
虽然是
float32_t
数组,但存储的是复数对。第k个频率点的实部和虚部分别为
fft_output[2*k]
和
fft_output[2*k+1]
。
幅度计算与峰值检测
FFT的结果是一个复数数组,我们需要从中提取幅值信息:
$$
|X[k]| = \sqrt{Re^2 + Im^2}
$$
遍历前半段(0 ~ N/2),因为实数信号的频谱是对称的,后半部分无新信息。
float max_mag = 0;
uint32_t peak_k = 0;
for (int k = 1; k < 512; k++) { // 跳过DC分量(k=0)
float re = fft_output[2*k];
float im = fft_output[2*k+1];
float mag = sqrtf(re*re + im*im);
if (mag > max_mag) {
max_mag = mag;
peak_k = k;
}
}
最终频率为:
$$
f = k_{\text{peak}} \times \frac{f_s}{N}
$$
例如,采样率10kHz、1024点FFT,则分辨率约为9.77Hz。这意味着你能分辨出100Hz和110Hz的信号,但难以区分100Hz和105Hz。
⚠️ 提示:不要迷信“找到最大值就是真实频率”。由于栅栏效应(frequency binning),真实频率可能落在两个bin之间,造成±Δf/2的误差。可通过插值法(如三点辛克插值)进一步提升精度。
如何让FFT结果更靠谱?几个实用技巧
1. 去除直流偏置
绝大多数传感器信号都有一定直流分量(比如麦克风静音时输出1.65V)。如果不处理,会在频谱中形成巨大的0Hz尖峰,掩盖其他低频信号。
解决办法很简单:在FFT前减去均值。
float sum = 0;
for (int i = 0; i < 1024; i++) {
sum += adc_buffer[i];
}
float mean = sum / 1024;
for (int i = 0; i < 1024; i++) {
fft_input[i] = (float)(adc_buffer[i]) - mean;
}
当然,也可以直接减去理论中值(如2048对应3.3V参考电压下的中间值),前提是系统偏置稳定。
2. 加窗抑制频谱泄漏
理想情况下,信号在采样窗口内刚好包含整数个周期。但现实中几乎不可能做到,导致信号突变,产生虚假高频成分——这就是所谓的“频谱泄漏”。
解决方案是加窗函数,平滑地衰减窗口边缘的样本。常用的有汉宁窗(Hanning)、汉明窗(Hamming)等。
for (int i = 0; i < 1024; i++) {
float window = 0.5f * (1.0f - cosf(2*M_PI*i/1023));
fft_input[i] = ((float)adc_buffer[i] - 2048.0f) * window;
}
加窗虽好,但会降低频率分辨率(主瓣展宽),需根据应用场景权衡。
3. 抗混叠滤波不可少
根据奈奎斯特采样定理,最高可测频率不能超过采样率的一半。如果输入信号中含有更高频率的成分(如开关电源噪声),它们会被“折叠”回低频区,造成误判。
因此,在ADC前端应加入RC低通滤波器,截止频率设置为略低于
fs/2
。例如采样率为10kHz,则滤波器截止频率建议设为4~4.5kHz。
系统整合与性能考量
完整的流程如下:
- 初始化GPIO、ADC、TIM2、DMA、UART/LCD;
- 启动TIM2和ADC-DMA;
- 等待DMA传输完成中断(或轮询标志位);
- 触发FFT运算;
- 解析主频并输出;
- 重置状态,准备下一帧。
在这个过程中有几个关键点需要注意:
- 内存占用 :1024点float数组约需8KB RAM,对于F103这类小容量型号(如CBT6仅有20KB SRAM)来说压力不小。必要时可改用Q15定点格式,节省一半空间。
- FFT耗时 :在72MHz主频下,1024点RFFT大约耗时3~5ms。若要求每秒刷新10次频谱,是完全可以接受的。
- 动态范围管理 :确保输入信号幅度适中。太小则信噪比差;太大则ADC饱和失真。可在前端加入可调增益放大器(PGA)或使用带PGA的ADC(如STM32G系列)。
写在最后:不只是频率计
这套“ADC+DMA+FFT”架构的意义,远不止做一个频率显示器。它是嵌入式信号处理的通用范式,可轻松扩展至更多高级应用:
- 谐波分析 :检测电网中的3次、5次谐波含量;
- 振动故障诊断 :识别电机轴承缺陷对应的特征频率;
- 音频可视化 :实现迷你版音乐频谱灯;
- LC振荡频率测试仪 :用于调试无线充电线圈匹配。
更重要的是,它教会我们如何在资源受限的环境中,通过软硬件协同设计,挖掘MCU的最大潜力。STM32F103或许已经不算“先进”,但只要掌握正确的工程思维,老平台也能焕发新生。
下次当你面对一个看似复杂的信号处理需求时,不妨想想:是否真的需要更贵的芯片?也许,只是缺了一个好的架构设计。
9543

被折叠的 条评论
为什么被折叠?



