news 2026/10/24 1:40:18

STM32F103 FFT频谱分析实战:定时器触发ADC与CMSIS-DSP实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103 FFT频谱分析实战:定时器触发ADC与CMSIS-DSP实现

简介:基于ARM官方库的STM32F103 FFT例程是一套面向嵌入式开发者的实战工程,覆盖快速傅里叶变换在单片机上的完整落地流程,从定时器采样、ADC/DMA数据搬运到FFT运算与结果幅频转换,适合信号处理、频谱分析和实时监测场景,兼顾快速入门与二次开发。压缩包共179个文件,其中45个头文件、18个C源码文件构成核心实现,另有32个o与31个crf等编译中间文件,附带hex/axf可执行镜像,可直接烧录与调试;工程还包含CubeMX配置、map内存映射及链接脚本,便于外设初始化迁移与内存占用分析。整套约7.34MB,已有2130人学习。除算法代码外,例程还演示了定点数运算、中断与DMA结合等性能优化技巧,可帮助中高级开发者理解STM32与ARM数学库协同工作的细节,并用于快速开发音频分析、故障诊断等嵌入式应用。

1. 为什么在 F103 上做 FFT:一个被低估的频谱分析场景

把频谱分析搬进单片机,很多人觉得是玄学:FFT 不是上位机的事吗,一颗 ARM 内核的单片机算得动?这块基于 Cortex-M3 内核的 STM32F103 核心板用实测告诉我,1024 点实数 FFT 只要几毫秒,采样由定时器触发、DMA 搬运,CPU 只在一次变换和找峰时忙一下,串口传的是最终结果,不是几千字节的原始波形。这个 stm32f103 fft 例程,就是一套从 ADC 采样到幅值谱输出的完整流程,适合振动监测、音频频谱显示这类嵌入式硬件场景,也适合手里只有一块开发板、没有上位机条件的单片机入门者。跟着例程把采样率、点数和内存账算清楚,你会发现频谱分析没那么高不可攀。

2. FFT 跑在 72MHz 的 MCU 上:从采样定理到频率分辨率

很多人拿到 FFT 例程的第一反应是把采样率拉满、点数加到最大,结果性能不够,还找不到原因。核心问题在于没有先算清楚三件事:采样率、采样点数、频率分辨率之间的数学关系,以及单片机的内存和 CPU 能不能承受。

2.1 采样率、采样点数和频率分辨率怎么定

FFT 输出的每个频点间隔由公式决定:频率分辨率 = 采样率 / 采样点数。注意这里没有单片机本身任何事,它只取决于你喂给 FFT 的数据有多长、采得有多密。

采样率首先受采样定理约束,目标信号的最高频率不能超过采样率的一半,否则高频分量会折叠到低频区,也就是混叠。比如要分析 4kHz 以内的振动信号,采样率至少要 8kHz,实际工程上我会留出 1.5 到 2 倍余量,取 10kHz 或更高。但采样率不是越高越好——在点数固定的前提下,采样率越高,频率分辨率越差,一帧数据覆盖的时间也越短。

下面是同一个 10kHz 采样率下,不同点数的实际表现,这张表在做任何参数选择前都值得先过一遍:

采样点数频率分辨率 (Hz)单帧时长 (ms)需 RAM 约 (KB)
25639.0625.62.5
51219.5351.24.5
10249.77102.48.5
20484.88204.816.5

从这个表能看出,如果你的目标是分辨 50Hz 和 100Hz 两个频率成分,39Hz 的分辨率也够用,256 点就行;但如果你想看 200.0Hz 和 202.5Hz 这种靠得很近的谱线,就得上 2048 点甚至更多。F103 的 RAM 非常有限,C 系列只有 20KB,Z 系列是 48KB,选 1024 点是一般都能接受的平衡点,下面的计算都按 1024 点展开。

2.2 单片机上 FFT 的两种实现:CMSIS-DSP 还是手写蝶形

网上有很多手写基 2 FFT 的教程,蝶形运算、位反转、旋转因子表,代码能写几百行。但我不建议在 F103 上这么做,除非你的目的是教学,否则重复造轮子的成本和风险都不划算。

CMSIS-DSP 库提供了 arm_rfft_fast_f32 这类专门为实数输入优化的 FFT 接口,直接在 Cortex-M3 内核上用硬件乘法器加速,支持 f32 浮点和 q15 定点两种类型。它内部对实数输入的对称性做了优化,1024 点实数 FFT 实际计算量只有复数 FFT 的一半左右,而且不用自己维护旋转因子表。

选型逻辑很简单:有浮点需求的场景优先用 f32 版本,代码直观、动态范围大、不易踩坑;只有 RAM 实在紧张、或对计算时间极其敏感时才考虑 q15 定点版本。q15 的输入要压缩到 -1.0 到 1.0 之间,输出结果还要按比例还原,小信号容易被截断,新手很容易在这上面翻车。常见做法是先用 f32 跑通流程,确认功能和参数都对了,再根据需求决定要不要优化到定点。

2.3 内存与时间的账:N=1024 要花多少

这里给 F103C8T6 这种 20KB RAM 的芯片算笔细账。1024 点 float 数组占 4KB,arm_rfft_fast_f32 要求输出数组也是 float 形式,原地操作时输入输出可以共用一块内存,但大多数情况需要单独开输出缓存,又是 4KB。加上 DMA 缓存用 uint16_t 存 ADC 原始值,双缓冲 2x1024 点要 4KB。光这三个结构就去了 12KB,剩下 8KB 要放栈、全局变量和库的实例结构体,勉强够,但要非常克制,不能再开大数组。

时间方面的账相对乐观。1024 点实数 FFT 在 72MHz 主频的 Cortex-M3 上实测是毫秒级,10kHz 采样率下采满一帧要 102.4ms,计算只占其中约十分之一不到。这意味着可以把 FFT 放到主循环里跑,帧与帧之间即使有轻微延迟,也不会丢数据。如果是 256 点,单帧时间 25.6ms,计算耗时更短,实时性会更好,适合频点比较稀疏的场景。

3. 把 ADC 输出变成 FFT 输入:定时器触发采样与 DMA 搬运

FFT 的质量七成取决于采样环节。很多例程用 ADC 连续采样模式,采完一批算一次,这种做法在低频段勉强能看,但谱线旁边会拖出一圈噪声。真正工程上可靠的做法是:定时器产生精确采样时钟,ADC 每次定时溢出时转换一次,DMA 把结果搬进内存,全程不打扰 CPU。

3.1 定时器触发 ADC 的配置流程

先用定时器产生均匀的更新事件,ADC 配置成外部触发模式,由定时器更新事件启动转换。下面这段是我常用的定时器配置,假设定时器时钟是 72MHz,目标是 10kHz 采样率:

static void TIM2_Config(void) { TIM_TimeBaseInitTypeDef TIM_InitStruct; TIM_InitStruct.TIM_Prescaler = 72 - 1; // 72MHz / 72 = 1MHz TIM_InitStruct.TIM_Period = 100 - 1; // 1MHz / 100 = 10kHz TIM_InitStruct.TIM_ClockDivision = TIM_CKD_DIV1; TIM_InitStruct.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_InitStruct); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); }

实际采样率等于定时器时钟除以(预分频值+1)再除以(自动重载值+1),也就是 72MHz / 72 / 100 = 10kHz。注意这里的预分频和重载值都有减一,是寄存器从零开始计数的特征,新手最容易在这里把采样率算错一倍。

ADC 侧需要把触发模式改成外部定时器触发。对应代码大致是开启 ADC 时钟、配置 ADC1 的采样时间为 55.5 周期、选择定时器触发源,然后使能 DMA 请求。采样时间可以适当拉长,ADC 输入阻抗高的话,采样时间不够会导致结果偏小,这是硬件电路上常被忽略的点。

3.2 DMA 搬运与双缓冲思路

DMA 在这里扮演两个角色:一是把 ADC 数据寄存器里的转换结果及时搬走,避免数据覆盖;二是用双缓冲让采集和计算并行。

#define FFT_N 1024 volatile uint16_t adc_buf[2][FFT_N]; volatile uint8_t buf_index = 0; static void ADC1_DMA_Config(void) { DMA_InitTypeDef dma; dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)adc_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = FFT_N * 2; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; dma.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel1, &dma); }

数据宽度配成 HalfWord,因为 F103 的 ADC 是 12 位,用 16 位搬正好。DMA 工作在循环模式,BufferSize 直接配成 2 倍 N,这样缓冲区的前半段和后半段分别对应一次完整采集,通过 DMA 的半传输中断和传输完成中断来区分当前数据落在哪个半区。

void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT1)) { DMA_ClearITPendingBit(DMA1_IT_HT1); buf_index = 0; // 前半段可处理 } if (DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); buf_index = 1; // 后半段可处理 } }

中断里只翻转标志位,把 FFT 计算放主循环,这是关键。FFT 要几毫秒,如果在中断服务函数里直接跑,下次 DMA 中断会丢失,采集就会断断续续。我一般在主循环里检测 buf_index 是否改变了,改变就说明对应半区的数据已经采满,可以拿去计算。

3.3 为什么不能用 ADC 连续采样模式直接跑 FFT

ADC 连续采样模式确实省事:配置好启动,DMA 就一直接收数据,看起来和定时器触发没什么区别。但连续模式的采样间隔由 ADC 时钟和转换时间决定,不受精确控制,每个样本之间的间隔有微小抖动。这种抖动在时域上看着不严重,放大到频域会把单根谱线的能量打散到整个噪声底,幅度小的信号直接被淹没。

另一个问题是连续模式的启动时机不可控,采出来的前几个点是转换稳定期还是有效数据没法保证。而定时器触发模式下,每次启动转换的间隔都是确定不变的,数据边界清晰。频率分辨率、幅值一致性这些指标才有意义。

4. 调用 CMSIS-DSP 的 FFT 函数:从复数映射到幅值谱

ADC 和 DMA 拿到的是 16 位整型数组,不能直接丢给 FFT 库。CMSIS-DSP 的实数 FFT 接口期望输入是 float 数组,还要做直流去除和类型转换,这章把从原始数据到幅值谱的完整链路拆开讲。

4.1 实例结构与内存预算

使用 CMSIS-DSP 前要先初始化一个实例结构体 arm_rfft_fast_instance_f32,它内部保存了旋转因子和位反转表。初始化时告诉库一次处理多少点,后面每次计算都复用这个实例,不用重复生成表。

#include "arm_math.h" #define FFT_N 1024 static float32_t fft_input[FFT_N]; static float32_t fft_output[FFT_N]; static arm_rfft_fast_instance_f32 fft_inst; void fft_init(void) { arm_rfft_fast_init_f32(&fft_inst, FFT_N); }

注意一个细节:arm_rfft_fast_f32 的输入输出虽然都是 float32_t 数组,但输出排列是按复数形式交替存放的,即每个频点的实部和虚部相邻。因此从功能上看输出数组至少需要 N 个 float 的空间,f32 的 N 点需要 4KB,规划 RAM 时按双份算比较稳妥。

4.2 把 ADC 数据映射成浮点输入并完成变换

下面这段是完整的处理函数,涵盖去直流、实数 FFT、幅值计算三部分:

void fft_run(uint16_t *adc_raw) { uint32_t i; for (i = 0; i < FFT_N; i++) { fft_input[i] = (float32_t)adc_raw[i] - 2048.0f; } arm_rfft_fast_f32(&fft_inst, fft_input, fft_output, 0); for (i = 0; i < FFT_N / 2; i++) { float32_t re = fft_output[2 * i]; float32_t im = fft_output[2 * i + 1]; fft_output[i] = sqrtf(re * re + im * im) / (float32_t)FFT_N; } }

第一步减去 2048 是有原因的:F103 的 12 位 ADC 输出范围是 0 到 4095,中间值约 2048,信号叠加在约 1.65V 的直流偏置上。如果不减去这个直流偏置,FFT 结果第 0 个频点(直流分量)会出现一个巨大的尖峰,其他频率成分相对值小,小信号完全压扁。减掉偏置后,输入变成以零为中心的近似双极性浮点信号。

arm_rfft_fast_f32 的第四个参数是 0 代表正变换,输出不缩放。所以计算幅值时要除以 N,把 FFT 累加结果的能量还原到原始幅值量级。输出数组的排列不是顺序频点,而是每两个相邻 float 表示一个频点的实部和虚部,代码里用 2i 和 2i+1 去取就是对的位置。

4.3 做完 FFT 之后:幅值、频率坐标与常见误用

幅值算完后,第 i 个桶对应的实际频率是 i 乘以采样率除以 N。比如 10kHz 采样率 1024 点,第 10 个桶就是 10 * 10000 / 1024 = 97.66Hz。工程上最常见的误用有两个。

第一个是把 FFT 输出直接当幅值,不去求模。复数输出里实部和虚部不能单独看,必须做 sqrt(rere + imim),否则看到的值是乱的。第二个是忘了直流分量和对称分量的处理。第 0 个桶是直流,它只对应一个分量,除数 N 后幅值等于平均值;而 i 大于 0 的桶同时对应正负频率,单边谱里真正的信号幅值要乘 2。这就是为什么用袖珍示波器看同一个正弦波,例程打印出来的基波幅值会比实际输入信号小一半,补上乘 2 的系数才对得上。

加窗也会改变幅值估算。用汉宁窗这类非矩形窗时,信号能量被窗函数削减了一半,幅值需要往上修正。常见做法是加窗后对非直流桶乘一个固定系数补偿,具体系数和窗函数类型有关。测试时先不加窗,矩形窗下幅值最容易核对,确认整条链路没问题后再考虑加窗抑制频谱泄漏。

5. 常见问题排查:FFT 结果翻车的三处根源

我把主要调试 FFT 例程时遇过的坑整理成检查清单。顺着现象找原因,比重新看一遍代码效率高得多。

问题一:频谱像噪声一样铺开,完全看不到尖峰

现象:输入是干净的 1kHz 正弦波,FFT 结果却是一条接近水平的噪声底,偶尔有几个小鼓包。

原因:采样间隔抖动是最大嫌疑。最常见的情况是用了 ADC 连续采样模式,或者定时器没有真正触发 ADC,而是靠软件延时循环去读数据。软件延时的抖动容易到几十微秒,对 10kHz 采样来说误差能到 50%,FFT 自然全糊。

解决:回到第 3 章的方案,强制用定时器更新事件触发 ADC,DMA 搬数据。先确认定时器输出触发信号确实存在,可以用示波器挂 TIM2 的 TRGO 引脚看脉冲周期,再检查 ADC 外部触发使能位有没有打开。

问题二:峰值出现,但频率整体偏移,100Hz 显示成 95Hz 左右

现象:信号发生器给的是准确的 100Hz 正弦,程序打印出 94.73Hz,而且偏差比例固定。

原因:采样率算错了。定时器时钟不是想象的 72MHz,F103 的 APB1 预分频如果设成了 2 分频,定时器时钟实际是 36MHz,你按 72 分频去算 10kHz,实际只有 5kHz。采样率变一半,每个桶对应的频率也跟着变一半。

解决:RCC 配置里把 APB1 开成 2 分频后,定时器时钟会自动倍频回 72MHz,但前提是外设时钟使能顺序正确。我一般会写一个自检函数,用定时器输出一个已知频率的方波,再用另一块板子测实际频率,对不上就直接重查 RCC 配置。

问题三:波形幅值正常,FFT 谱线旁边拖出一串旁瓣,还衰减得很慢

现象:主峰旁边每隔几个桶就有一串次峰,幅值还不小,看起来像是主频被“复制”了。

原因:这是典型的频谱泄漏,不是故障。1024 点 FFT 采 102.4ms,如果信号频率不是频率分辨率的整数倍——比如输入 100Hz 到 9.77Hz 分辨率的系统时,100 / 9.77 = 10.24 不是整数——能量就会从主峰漏到相邻频点。

解决:加窗函数是最直接的缓解手段。在 fft_input 乘上汉宁窗或布莱克曼窗,主峰旁边旁瓣就会快速衰减。代价是主峰会变宽,幅值还需要修正。如果系统里频率已知且固定,另一个办法是调整采样率,让目标频率正好落在某个频点正中,但这在实际场景里很难实现,所以工程上还是加窗更通用。

问题四:DMA 中断里跑 FFT 后,数据帧错乱

现象:程序跑一会儿,幅值开始抖动,甚至串口打印出明显的断帧,整个频谱像被人切了一刀。

原因:把 FFT 计算直接写在中断服务函数里了。FFT 需要几毫秒,DMA 的中断一旦错过,采集链路就不连续。中断嵌套和缓冲区切换互相干扰,数据前后错位。

解决:中断里只置标志,主循环里检测到标志后再做 FFT。缓冲区切到哪个半区,也由标志位记录,不靠中断里做计算。这是嵌入式里中断快进快出的基本纪律,FFT 这种耗时操作必须挪到主循环。

问题五:用 q15 定点版本,小信号直接消失

现象:输入信号调到 100mV 左右,FFT 结果里找不到对应峰值,或者峰值明显失真。

原因:q15 格式的动态范围有限,输入在 -1.0 到 1.0 之外会被饱和,而小信号在定点下只有很小的量化步数,转换和计算舍入误差就把信号淹没在噪声里。

解决:先用 f32 浮点版本跑通整个链路,确认原理没问题再考虑定点优化。如果一定要用 q15,输入前先整体做缩放,让信号幅度尽量贴近满量程,输出侧再反向缩放回真实幅值。这个缩放系数必须和采集增益一起调,不能拍脑袋定。

6. 用测正弦波两步验证 FFT 例程:输出频率与幅值同时对上才算跑通

拿到例程第一件事不是改代码,而是验证数据链路。我最常用的方法是信号发生器输出 1kHz 正弦波,1V 峰峰值,叠加 1.65V 直流偏置,保证信号始终落在 ADC 输入范围内。接好线后先只打印原始 ADC 最大值和最小值,确认波形没有削顶,再进行下一步。

第一步验证频率。跑一次 FFT,找到幅值最大的索引,用频率公式换算:

uint32_t peak_idx = 1; float32_t peak_mag = 0.0f; for (i = 1; i < FFT_N / 2; i++) { if (fft_output[i] > peak_mag) { peak_mag = fft_output[i]; peak_idx = i; } } float32_t bin_hz = SAMPLE_RATE / FFT_N; float32_t freq = (float32_t)peak_idx * bin_hz;

理想情况下打印结果应该在 999Hz 到 1001Hz 之间。偏差超过 1% 就先查采样率配置,不要急着怀疑 FFT 函数。第二步验证幅值,把 fft_output[peak_idx] 乘 2 后和信号发生器设的峰峰值比较。这里最容易出偏差的地方是我在 4.3 里说的矩形窗不加窗的倍数问题,核对时先确认代码里有没有乘 2 的补偿。幅值误差在 10% 以内就可以认为整条链路正确。

验证通过后,我会再做一次加大采样率到 40kHz 的对比测试,输入同一个 1kHz 信号,确认峰值频率不随采样率改变而改变。这一步对排查混叠特别有效:如果高频段突然多出一些莫名其妙的峰值,多半是前置滤波没做好或者输入信号本身不干净。

从那以后,我每次拿到一块新板子或者改一次采样率,都会强制先跑一遍“已知频率正弦波验证流程”,频率和幅值两个数都对上了才继续调代码。这个习惯帮我避开了无数次“FFT 结果不对但不知道是采集还是计算问题”的尴尬。希望帮到你。

本文还有配套的精品资源,点击获取

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

微信小程序宿舍管理系统:云开发+原生框架实战

简介&#xff1a;本资源是一套完整的基于微信小程序的宿舍管理系统实战项目&#xff0c;面向前端开发初学者、校园信息化建设者及小程序学习者&#xff0c;解决高校宿舍管理中签到、卫生评分、晚归申请与双向沟通等实际痛点。项目采用微信开发者工具构建&#xff0c;支持宿管与…

作者头像 李华
网站建设 2026/10/24 1:39:38

Gateway - 入门教程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:39:38

副队长JavaScript教程(23)-选择结构与代码块

点此查看 所有教程、项目、源码导航 文章目录1. 背景2. 基础 if else 条件判断3. 缩进规范4. if else 的作用范围陷阱5. 代码块 {}6. 选择结构搭配代码块7. 编码规范建议8. 小结1. 背景 上一篇我们讲解了 JS 语言最基本的结构 —— 顺序结构&#xff0c;JS 代码在一般情况下&a…

作者头像 李华
网站建设 2026/10/24 1:38:27

51单片机引脚详解:AT89S51与AT89C2051接线避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:38:13

FastCopy v4.02+ 文件加速复制:原理、调优与场景实测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:38:08

知不足而奋进:个人成长操作系统实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华