简介:这份资源面向嵌入式开发初学者与数字信号处理方向的工程师,围绕STM32F1系列单片机(ARM Cortex-M3内核)讲解并实现快速傅里叶变换,帮助读者在资源受限的MCU上完成时域到频域的转换,适用于通信、音频与图像处理等信号分析场景。压缩包共225个文件,约4.12MB,以60个h头文件与56个c源文件为核心,配合30个o目标文件、30个d依赖文件及27个crf等编译中间产物,另含uvprojx、uvoptx工程文件、map映射文件、hex烧录文件与bat脚本,构成一套可直接编译调试的完整Keil工程。内容覆盖Cortex-M3内核特性、Cooley-Tukey蝶形运算与位反转、内存布局、定时器中断触发、CMSIS-DSP库调用及误差与实时性分析等关键知识点。目前已有10174人学习下载,适合希望从零搭建FFT工程、对照源码理解算法实现并优化运算效率的读者参考。
1. 为什么在单片机上做 FFT,难点从来不是算法本身
很多人第一次在 STM32 上跑 FFT,都会有一个错觉:算法不是现成的吗,把公式抄进去不就行了。真正上手才发现,翻车的往往不是蝶形运算写错了,而是采样数据一塌糊涂、内存被栈冲爆、算出来的频谱全是镜像和噪声。FFT 本身是确定性的数学过程,可它跑在只有几十 KB RAM、没有 FPU 或者 FPU 只支持单精度的单片机上,问题就从「算法对不对」变成了「数据怎么进来、中间结果放哪、精度怎么保」。
这也是为什么「STM32 单片机实现 FFT」这个题目值得拆开讲。它表面上是一个算法移植问题,底层其实是三件事的耦合:ADC 采样与定时器触发的时序、定点/浮点运算在有限位宽下的取舍、以及内存布局对缓存和 DMA 的友好程度。搞懂这三层,你才算真正理解为什么同一份 FFT 代码,有人跑出来干净利落,有人跑出来全是鬼影。下面从采样这一层开始,一层层往下剥。
2. 采样与触发:频谱干不干净,在你按下 FFT 之前就定了
FFT 只是把时域样本变换到频域,它不会凭空创造信息,也不会修复已经坏掉的采样。频谱里出现的每一个杂散,几乎都能在采样阶段找到根源。这一章先把「数据怎么进到 RAM」这件事讲透,因为它是后面所有精度的前提。
2.1 定时器触发 ADC:为什么不能用循环里软件采样
最常见的错误写法是在主循环里while(1){ adc_val = ADC_GetConversion(); buffer[i++] = adc_val; }。这样采出来的数据,采样间隔取决于循环里其他代码的执行时间,抖动可能达到几十微秒。FFT 默认你送进去的是等间隔采样,一旦间隔不均匀,频谱就会发生频谱泄漏和杂散抬升,原本干净的单一频率会糊成一片。
正确的做法是让定时器硬件产生固定频率的触发信号,ADC 在每次触发时自动启动一次转换,转换完成由 DMA 把结果搬到缓冲区,全程不经过 CPU。这样采样间隔由定时器时钟和预分频决定,抖动在纳秒级。常见的实现思路是:TIM 配置成 PWM 或更新事件输出,作为 ADC 的外部触发源;ADC 配置成外部触发、连续转换关闭、DMA 循环模式;DMA 配成半满和全满中断,半满时处理前半段,全满时处理后半段,实现「边采边算」。
// 定时器触发 + ADC + DMA 的典型配置骨架(以常见 HAL 风格为例) // TIM: 更新事件作为 TRGO 输出 htim3.Init.Prescaler = 0; htim3.Init.Period = (SystemCoreClock / 2 / SAMPLE_RATE) - 1; // 定时器时钟的一半 HAL_TIM_Base_Start(&htim3); // ADC: 外部触发,触发源选 TIM3_TRGO hadc1.Init.ContinuousConvMode = DISABLE; // 关键:不要连续转换 hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T3_TRGO; HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, FFT_LEN); // DMA: 循环模式,半满/全满中断 // 半满回调里对 adc_buf[0..N/2-1] 做 FFT,全满回调里对后半段做这里每个参数都有讲究。ContinuousConvMode必须关掉,否则 ADC 会自己连续转换,定时器触发就失效了。Period决定了采样率,它和 FFT 点数一起决定了频率分辨率:分辨率 = 采样率 / 点数。比如采样率 10 kHz、点数 1024,分辨率约 9.77 Hz,意味着两个频率相差小于这个值的信号无法被区分。DMA 用循环模式是为了让缓冲区首尾相接,避免手动重置指针带来的采样空档。
2.2 采样率、点数与频率分辨率的三方约束
很多人调 FFT 时只盯着点数,觉得点数越多越好,结果 RAM 不够、算得又慢。实际上这三个量是互相锁死的。采样率必须满足奈奎斯特,至少是你要分析的最高频率的两倍,工程上一般留 2.5 到 4 倍余量。点数决定了频率分辨率,也决定了运算量和内存占用。在 STM32 上,1024 点复数 FFT 的输入缓冲区如果是 32 位浮点,光输入就要 8 KB,加上输出和旋转因子,很容易吃掉十几 KB。
一个实用的取舍是:先用采样率和目标分辨率反推点数,再看 RAM 够不够。如果 RAM 紧张,就降低点数或者改用定点。比如要分析 0 到 1 kHz 的信号,采样率取 4 kHz,想要 4 Hz 分辨率,那点数就是 1000,取最近的 2 的幂 1024。如果 RAM 只够 512 点,那分辨率就降到约 7.8 Hz,这是硬约束,不是代码能绕过去的。
2.3 加窗:为什么矩形窗在非整数周期时必然泄漏
即使采样完全等间隔,如果信号频率不是频率分辨率的整数倍,也就是在一个采样窗口内不是整数个周期,FFT 会认为信号在窗口边界处发生了突变,频谱上就会出现泄漏,主瓣变宽、旁瓣抬高。这不是 bug,是 DFT 的数学性质。解决办法是加窗,用汉宁、汉明或布莱克曼窗在窗口两端把信号渐隐到零,减小边界突变。
代价是加窗会让主瓣变宽,频率分辨率下降,同时幅度需要做补偿。常见的实现思路是:如果只关心某个已知频率的幅度,用平顶窗,幅度精度最高;如果关心频率成分的相对分布,用汉宁窗,泄漏和分辨率折中。加窗是在时域逐点乘窗函数系数,可以在 DMA 半满中断里顺手做掉,几乎不增加额外开销。
提示:加窗系数表可以预先算好放在 Flash 里,运行时只做乘法,不要每次调用
sin()现算,那会拖慢整个采样节奏。
3. 蝶形运算与旋转因子:FFT 到底省在哪,又贵在哪
采样数据准备好之后,就进入 FFT 本体。这一章讲清楚 FFT 相比 DFT 到底省了什么,以及旋转因子这个「看起来只是几个 cos/sin」的东西,为什么是精度的主要来源。
3.1 从 DFT 到 FFT:分治把 N² 降到 N·logN
DFT 的定义是每个输出频点都要和所有 N 个输入样本做一次复数乘加,总共 N² 次复数乘法。N 等于 1024 时就是约一百万次,在单片机上直接算会慢到无法接受。FFT 的核心是分治:把 N 点序列按奇偶拆成两个 N/2 点序列,分别做 DFT,再用旋转因子把两者组合起来。这样递归下去,运算量降到约 (N/2)·log₂N 次复数乘法,1024 点大约是 5120 次,比 DFT 少了两个数量级。
这个拆分在代码里通常用位反转加迭代实现,而不是真的递归,因为递归在单片机上栈开销太大。位反转的意思是:输入序列按索引的二进制位倒序重排,比如 8 点里索引 1(001)和 4(100)互换。重排之后,每一级蝶形运算的输入都是相邻的,可以原地计算,不需要额外缓冲区。
// 基 2 时域抽取 FFT 的迭代骨架(定点/浮点通用结构) // 输入 x[] 已按位反转顺序重排,N 为 2 的幂 for (stage = 1; stage <= log2N; stage++) { int half = 1 << (stage - 1); // 本级蝶形的半跨度 int step = half << 1; // 本级蝶形的整跨度 for (start = 0; start < N; start += step) { for (j = 0; j < half; j++) { // 旋转因子 W = exp(-j*2*pi*j/step) float wr = twiddle_re[j * (N / step)]; float wi = twiddle_im[j * (N / step)]; int a = start + j; int b = a + half; // 蝶形:t = W * x[b]; x[b] = x[a] - t; x[a] = x[a] + t float tr = wr * xr[b] - wi * xi[b]; float ti = wr * xi[b] + wi * xr[b]; xr[b] = xr[a] - tr; xi[b] = xi[a] - ti; xr[a] = xr[a] + tr; xi[a] = xi[a] + ti; } } }这段代码里,stage控制当前是第几级蝶形,half是本级中两个参与运算的样本的距离,step是下一级的分组跨度。旋转因子按j * (N/step)索引,是因为每一级用到的旋转因子是整张表的一个子集,这样只需要存一张 N/2 长度的表,而不是每级各存一张。原地计算意味着xr、xi数组既是输入也是输出,省掉一份缓冲区,这在 RAM 紧张的单片机上很关键。
3.2 旋转因子的精度:定点 Q15 与浮点的真实差距
旋转因子是 cos 和 sin 的采样值,理论上幅度恒为 1。但在定点运算里,用 Q15 表示时,1 对应 32767,cos 值会被量化到最近的整数,误差在 ±1/32768 左右。单次误差很小,但 FFT 有 log₂N 级,每级都会累积,1024 点有 10 级,误差可能放大到千分之几。对于要求 60 dB 以上动态范围的应用,Q15 定点往往不够,需要 Q31 或者干脆用浮点。
STM32 的 F 系列和 H 系列里,带 FPU 的型号支持单精度浮点硬件指令,一次浮点乘加只要几个周期,比软件模拟定点快得多,精度也更好。但要注意,单精度浮点只有 24 位有效尾数,在 1024 点 FFT 里累积误差仍然存在,只是比 Q15 好很多。如果对动态范围要求极高,比如音频分析要看到 -90 dB 的谐波,那就需要双精度,但双精度在单片机上要么没有硬件支持,要么慢到不实用,这时候通常改用 Q31 定点加块浮点缩放。
3.3 溢出与缩放:定点 FFT 为什么必须每级右移
定点 FFT 有一个绕不开的问题:蝶形运算里x[a] + t和x[a] - t的幅度可能比原值大一倍。如果输入已经占满了 Q15 的满量程,第一级蝶形就会溢出。常见的做法是每一级蝶形之后统一右移一位,相当于每级除以 2,总共除以 N。这样输出幅度是输入幅度的 1/N,不会溢出,但小信号会被压得更小,信噪比下降。
另一种做法是块浮点:每级运算前检查当前块的最大值,根据它决定这一级整体右移几位,并记录总的移位量,最后统一补偿。这样能保留更多小信号细节,但实现复杂,需要额外的求最大值和移位逻辑。在 STM32 上,如果 RAM 和算力允许,直接用浮点可以完全绕开这个问题,代价是内存翻倍、速度慢一些。选哪种,取决于你的动态范围要求和芯片资源,没有标准答案。
4. 内存布局与 DMA:为什么你的 FFT 跑一半就 HardFault
算法写对了,采样也配好了,结果一跑就进 HardFault,或者算出来的频谱前半段正常后半段是垃圾。这类问题十有八九出在内存和 DMA 的配合上。这一章专门讲这些「不是算法错,但比算法错更难查」的坑。
4.1 栈、堆与静态缓冲区:FFT 数组该放哪
FFT 的输入输出数组通常比较大,1024 点浮点复数就是 8 KB。如果把它定义成局部变量,它会分配在栈上,而 STM32 默认栈大小往往只有 1 KB 到 4 KB,一进去就栈溢出,表现就是 HardFault 或者莫名其妙的变量被改写。正确的做法是定义成全局变量或static,让它落在.bss段,链接器会自动分配在 RAM 里。
如果用了 DMA,还要注意缓冲区所在的内存区域是否支持 DMA 访问。有些 STM32 型号的 CCM RAM 不支持 DMA,如果把 DMA 目标缓冲区放在那里,DMA 会静默失败或者触发总线错误。常见的实现思路是:查芯片参考手册的内存映射表,确认目标区域在 DMA 可访问的总线上,或者直接用链接脚本把 DMA 缓冲区定位到普通 SRAM。
// 正确:全局静态缓冲区,避免栈溢出,且便于 DMA 访问 #define FFT_LEN 1024 static float fft_in_re[FFT_LEN]; static float fft_in_im[FFT_LEN]; static uint16_t adc_buf[FFT_LEN]; // DMA 目标,注意对齐和内存区域 // 错误示范:局部数组,1024 点浮点复数直接压爆栈 // void bad(void) { float re[1024], im[1024]; ... }4.2 半满/全满双缓冲:边采边算的时序陷阱
用 DMA 半满和全满中断实现边采边算,逻辑上是:半满时处理前半段,全满时处理后半段。但这里有一个隐蔽的竞争条件:如果 FFT 计算时间超过了半段采样的时间,就会出现「前半段还没算完,后半段已经采完并触发中断」,导致数据被覆盖或者处理顺序错乱。表现是频谱偶尔正常、偶尔全是噪声,很难复现。
解决办法有两个方向。一是降低采样率或减少点数,让 FFT 计算时间小于半段采样时间,这是最稳妥的。二是用更大的缓冲区分成更多段,比如四段缓冲,给计算留出更多余量。判断是否来得及,可以在 FFT 处理函数入口翻转一个 GPIO,用示波器看这个 GPIO 的高电平时间是否小于半段采样周期。这个土办法比任何理论估算都直接。
4.3 位反转与原地运算的内存访问模式
位反转重排会打乱内存访问顺序,索引跳来跳去,对没有缓存的 STM32 来说,影响主要是 Flash 里旋转因子表的访问。如果旋转因子表放在 Flash,每次蝶形都要读 Flash,而 Flash 访问速度比 SRAM 慢,可能成为瓶颈。常见的优化是把旋转因子表复制到 SRAM,或者用芯片的 ART 加速器、指令预取来缓解。另一个细节是位反转本身可以预先算好一张索引表,运行时直接查表重排,比每次现算位反转快得多,代价是多占 N 个整数的 RAM。
注意:位反转索引表用
uint16_t就够存 1024 以内的索引,不要用int,省一半 RAM。
5. 避坑与排查:那些让频谱变成玄学的常见问题
这一章把实际调试中最容易踩的坑集中列出来,每条都按现象、原因、解决三步走。这些不是理论推演,是调试时反复遇到的真实模式。
5.1 频谱出现对称镜像,且无法分辨正负频率
现象:算出来的频谱左右完全对称,看不出信号频率是正还是负。原因:实数输入的 FFT 结果本身就是共轭对称的,这是数学性质,不是错误。如果你只采了实信号,频谱必然对称,正负频率各占一半能量。解决:如果只需要幅度谱,取前 N/2 点即可,后一半是冗余的。如果需要区分正负频率,必须用复数输入,也就是 I/Q 两路采样,这在单片机里通常需要两路 ADC 同步采样或者用希尔伯特变换构造正交信号。
5.2 主频点幅度随信号频率变化而波动
现象:同一个幅度的信号,频率不同时 FFT 算出来的峰值幅度不一样,有的偏高有的偏低。原因:没有加窗,或者信号频率不在频率分辨率的整数倍上,发生了扇贝损失,峰值落在了两个频点之间。解决:加汉宁窗或平顶窗,并对幅度做窗函数增益补偿。如果只关心特定频率的幅度,用平顶窗最准;如果看整体频谱形状,汉宁窗够用。
5.3 低频段出现大量杂散,高频段反而干净
现象:频谱低频部分一堆小尖峰,高频部分很干净。原因:通常是电源纹波、地线噪声或者 ADC 参考电压不稳,这些低频干扰被采进来了。也可能是采样窗口内信号不是整数周期,泄漏的能量集中在主瓣附近,看起来像低频杂散。解决:先检查模拟前端,加 RC 低通滤波,确保参考电压干净;再确认是否加窗。如果杂散频率正好是工频的整数倍,那基本就是电源干扰。
5.4 FFT 结果每次运行都不一样,重复性差
现象:同样的输入信号,每次跑出来的频谱都有细微差别,峰值位置偶尔跳变。原因:采样和计算没有同步好,DMA 缓冲区在计算时被新数据覆盖,或者中断优先级配置不当导致处理被延迟。解决:检查 DMA 半满/全满中断和 FFT 处理的时序,确保计算期间对应半段缓冲区不被写入。用 GPIO 翻转配合示波器看时序是最快的定位手段。另外确认 FFT 处理函数没有被更高优先级中断打断太久。
5.5 定点 FFT 输出幅度远小于预期
现象:输入信号幅度正常,但 FFT 输出的峰值幅度只有预期的几十分之一。原因:定点 FFT 每级右移一位,总共右移了 log₂N 位,相当于整体除以 N。1024 点就是除以 1024,幅度自然小。解决:这是定点 FFT 的正常行为,需要在最后统一左移补偿,或者用块浮点记录总移位量。补偿时注意不要溢出,如果补偿后超过 Q15 范围,说明输入信号本身太大,需要在前端加衰减。
6. 精度、速度与 RAM 的三角取舍:没有最优,只有匹配
把 FFT 跑起来只是第一步,真正难的是在精度、速度、RAM 这三者之间找到匹配你应用的那个点。这三者构成一个不可能三角:想要高精度就得用浮点或 Q31,内存翻倍、速度下降;想要快就用 Q15 定点加查表,但动态范围受限;想要省 RAM 就减点数,分辨率跟着降。任何声称「三者都最优」的方案,要么是应用场景特殊,要么是牺牲了某个你没注意到的指标。
我一般会这么理解这个取舍:先确定你的应用到底需要多高的动态范围。如果是检测一个强信号的存在与否,比如判断某个频率有没有能量,Q15 定点加 512 点足够,RAM 只要几 KB,速度也快。如果是音频谐波分析,要看 -80 dB 的细节,那必须浮点加 1024 点以上,RAM 十几 KB 跑不掉。如果是电力谐波监测,关心的是 2 到 50 次谐波的幅度精度,平顶窗加浮点是比较稳的组合。
验证你的选择是否合理,有一个简单办法:用一个已知频率和幅度的信号源,或者用 DAC 自己生成一个纯净正弦,送进 ADC,看 FFT 出来的峰值幅度和理论值差多少,底噪在什么水平。如果峰值误差在 1% 以内、底噪低于你关心的最小信号 20 dB,那这套参数就是匹配的。如果差得远,先别改算法,回头查采样和窗函数。
还有一个容易被忽略的点是旋转因子表的生成方式。用sin()和cos()在运行时现算,精度取决于库函数实现,而且慢;预先用高精度算好存表,精度和速度都更好,代价是占 Flash。在 STM32 上,一张 1024 点的浮点旋转因子表大约 8 KB Flash,对大多数型号来说可以接受。如果 Flash 也紧张,可以利用对称性只存 1/4 表,运行时用索引变换还原,这是常见的省空间手法。
从那以后我每次在单片机上做 FFT,都会先用 GPIO 加示波器确认采样时序,再跑一个纯正弦验证幅度和底噪,最后才去调实际信号。这个顺序看起来笨,但能省掉大量在算法里瞎找问题的时间。希望帮到你。
本文还有配套的精品资源,点击获取