news 2026/9/9 13:54:11

STM32差分ADC与2048点FFT频谱分析实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32差分ADC与2048点FFT频谱分析实践

简介:面向STM32与数字信号处理初学者及嵌入式开发者,这份2048点FFT频谱分析工程以纯C实现差分ADC信号采集与频域变换,可直观输出信号频谱图,适用于音频分析、设备振动监测、电力谐波检测等场景。工程共193个文件,压缩包大小6.38MB,源码与工程配置齐备,包括C程序、头文件、汇编启动文件、Keil工程文件以及hex、axf、map等编译产物,目录结构清晰,方便直接打开和二次移植。目前已有2798人学习下载。代码基于标准外设库,完整覆盖差分ADC采样参数配置、2048点FFT蝶形运算、幅度计算与对数标定等关键环节,并充分考虑了采样率选取和抗混叠处理;无论是用于毕业设计、课程项目,还是作为深入学习CMSIS-DSP及纯手写FFT算法的参考资料,都能从工程源码和模块划分中获得清晰思路。此外,工程中对内存管理与运算效率的取舍也有参考价值,便于理解实时频谱分析系统的资源占用与性能平衡。 今年调一个基于STM32的电机驱动器项目,卡在最不愿意卡的地方:采样电阻两端明明能看到干净的电流波形,但STM32做完FFT之后,频谱底噪高得离谱,主峰周围全是毛刺。一开始以为是FFT算法写得有问题,后来把采集链路一路查下去才发现——单端ADC把功率地上的共模噪声也一并采了进来。换差分ADC之后,同一个算法、同一块板子,频谱立刻变了样。

这篇就把整个项目的完整链路写下来:为什么选"差分ADC + 2048点FFT"这套组合,硬件上怎么处理差分输入和抗混叠,纯C怎么实现2048点FFT,以及连续采集时怎么用DMA双缓冲保证每帧数据干净。适合正在做电机电流分析、电源纹波测量、振动或音频频谱分析的朋友,尤其适合想把FFT真正落到MCU上、而不是只会调用库函数的开发者参考。

1. 为什么是"差分ADC + 2048点FFT"这套组合

1.1 单端采集被地噪声污染的那次教训

先说场景。当时要处理的是电机驱动器里相电流信号的频谱,想看电流波形里除了基频之外有没有异常的谐振峰。采样点是低端采样电阻,0.01Ω,电流10A时压降才100mV。最初用单端ADC,IN引脚接采样电阻一端,另一端接系统大地,理论上能采到信号。

问题出在实际板子上。电机驱动是桥式电路,开关管动作瞬间会在地平面上拉出瞬态电流,控制地并不是理想的0V。单端ADC测的是"引脚对地电压",地弹噪声直接叠加在信号上。时域波形看着还行,一进FFT,整个底噪比理论值高十几dB,还能看到开关频率倍频的杂散峰。最难受的是,这类干扰不是稳定白噪声,偶尔还漂,根本没法分析。

换成差分ADC之后,AINP和AINN分别接采样电阻两端,两个输入共模的地噪声被减掉了,频谱立刻干净。所以如果你也是功率回路采样场景,先别纠结算法,把采集方式换成差分,见效最快。

1.2 2048点:分辨率、内存和运算量的三方平衡

FFT点数不是越大越好。2048等于2的11次方,正好符合基2蝶形运算结构。选点数前先算两笔账。

第一笔账是频率分辨率:Δf = fs / N。采样率固定时,N越大,谱线间隔越小,频率分辨能力越强,但时域窗口也越长,观察瞬态信号的实时性变差。第二笔账是内存和运算量:2048点复数float缓冲区,一个元素8字节(实部和虚部各4字节),总共16KB RAM;4096点就得32KB,很多STM32型号RAM总共才64KB,再放运行栈就很紧张。运算量按N·log₂(N)估算,2048点是22528次复数运算量级,4096直接翻倍还多。

我最后选了2048点,配合采样率25.6kHz,频率分辨率12.5Hz,每帧时长80ms。这个参数对电机电流、电源纹波、振动信号都够用,实时性也合适。如果信号偏工频谐波分析,可以把采样率降到12.8kHz,分辨率到6.25Hz;如果只关心高频噪声,采样率拉到64kHz,一帧32ms,实时性更强。

1.3 为什么坚持纯C写FFT,不直接用官方DSP库

这个问题很多人问。CMSIS-DSP库里的FFT性能极好,调用也简单,为什么不用?

说实话,项目后期性能不够用时用它没问题;但前期验证和排错阶段,它是个黑盒。库函数内部做了什么缩放、输入格式是什么、旋转因子表怎么组织,一旦结果不对很难查。纯C实现FFT就没有这个问题,所有变量都在眼前。另外纯C代码可以直接移植,以后换芯片、换平台,几乎零成本。

还有一层原因:只有自己在代码里写过位反转、蝶形运算和窗函数,才能真正理解FFT输出的每一个量是什么含义,后面做幅值修正和频域分析心里才有数。别人问"第k根谱线代表什么频率、幅值怎么修正",你不能永远回答"库函数算出来就是这样"。

2. 差分信号链路设计:引脚、参考电压与抗混叠

2.1 在STM32上打开差分模式

先选型号。不是所有STM32都有真差分ADC。我用的是STM32F303,带多路ADC、12位分辨率,支持差分输入;STM32G4、STM32H7系列也支持。F1、F4系列大部分ADC是单端的,如果手头只有F4想实现差分采样,只能外接仪表放大器,或者用两路ADC同步采集再做软件差分,复杂度完全不同。

F303的差分输入是两个引脚成对出现,比如ADC1的INP引脚和INN引脚组成一路差分通道。HAL库下配置很简洁:

// 正输入用ADC通道1,负输入用ADC通道0 HAL_ADCEx_EnableDifferentialMode(&hadc1, ADC_CHANNEL_1, ADC_CHANNEL_0);

注意,不是任意两个通道都能配成差分对,必须查数据手册里每个ADC通道的差分映射表。配置成差分模式后,ADC量程变成 ±VREF/2,也就是当VREF=3.3V时,差模输入范围是-1.65V~+1.65V。输出码型也变了,变成偏移二进制:0x000对应负满量程,0x800(12位中间值)对应0V,0xFFF对应正满量程。这个特性直接影响后面第4章的数据预处理,千万不能忽略。

2.2 共模电压和输入阻抗:比你想的更重要

差分ADC消除的是两个输入端的共模干扰,前提是两个输入端的源阻抗尽量对称。如果INP一侧串联了10kΩ电阻,而INN一侧什么都没串,偏置电流在两端产生不对称压降,共模噪声就会有一部分转化成差模,差分效果就废了。所以传感器出来到ADC之间,能走运放缓冲就走运放缓冲,运放输出阻抗低,两侧一致性有保障。

另一个容易忽视的是共模输入范围。差分模式下,ADC要求输入电压保持在某个范围内才能保证线性。单电源3.3V应用里,通常把静态工作点偏置在1.65V附近。比如用交流电流互感器时,次级输出本身是交流,需要加一个1.65V的直流偏置,让正负半周都落在ADC能线性转换的区间内。我踩过这个坑:一开始直接把互感器输出接到ADC,正半周正常、负半周全部削底,频谱里出现大量谐波,还以为是互感器坏了,其实是共模工作点不对。

参考电压方面,直接用VDD做基准,动态负载下会飘。如果对幅度精度有要求,建议外接低噪声基准,或者用MCU内部的VREFBUF,保证ADC参考电压稳定。

2.3 抗混叠滤波器:这步省了,后面算法再好也白搭

数字频谱分析有个硬前提,信号里不能有超过采样率一半的频率成分。比如fs=25.6kHz,超过12.8kHz的成分会折叠到低频,在频谱上形成假峰,不管FFT怎么写都滤不掉,因为混叠发生在采样环节。

工程上最简单的做法是ADC输入端加一阶RC低通,截止频率取0.4~0.5fs就够了。例如fs=25.6kHz,让fc≈10kHz,R=1kΩ、C=15nF:

fc = 1 / (2π × 1000 × 15×10⁻⁹) ≈ 10.6kHz

如果信号带宽本身很窄,比如只关注电机电流的基频和几十次谐波,可以把截止频率再压低,把带外噪声一起滤掉,提升谱线质量。二阶有源滤波当然更好,但我的建议是先用一阶RC把混叠风险排除,再根据实测频谱决定要不要加二阶,别一开始就把电路搞复杂。

3. 纯C实现2048点FFT:从位反转到蝶形运算

3.1 位反转排序:看起来简单,实际上最容易翻车

迭代FFT的第一步,是把时域序列按下标的位反转顺序重新排列。2048对应11位二进制,所以要把0到2047每个数的二进制高低位颠倒。这一步看着不起眼,翻车概率其实很高。

#define FFT_N 2048 #define FFT_LOG2_N 11 #define FFT_PI 3.14159265358979323846f typedef struct { float r; float i; } complex_t; static complex_t fft_buf[FFT_N]; static complex_t twiddle[FFT_N]; // src 是已经减去中值2048的有符号ADC数据 void bit_reverse_copy(const int16_t *src, complex_t *dst) { for (uint32_t i = 0; i < FFT_N; i++) { uint32_t rev = 0; uint32_t x = i; for (uint32_t j = 0; j < FFT_LOG2_N; j++) { rev = (rev << 1) | (x & 1); x >>= 1; } dst[rev].r = (float)src[i] / 2048.0f; dst[rev].i = 0.0f; } }

两个容易踩的坑:一是循环次数写错,2048对应11位,写成8或12,重排顺序就乱套,频谱图上会出现一堆奇怪的分量;二是下标类型,2048已经超过uint8_t甚至uint16_t的符号上限,建议直接用uint32_t,别在这个地方抠内存。

3.2 蝶形运算主循环

位反转之后进入11级蝶形运算。核心结构是三层循环:第一层控制蝶形组宽度,从2开始每次翻倍直到2048;第二层遍历整帧的每个分组;第三层在组内做蝶形计算。

void fft_run(void) { for (uint32_t len = 2; len <= FFT_N; len <<= 1) { uint32_t step = len >> 1; for (uint32_t i = 0; i < FFT_N; i += len) { for (uint32_t j = 0; j < step; j++) { uint32_t k = j * (FFT_N / len); complex_t w = twiddle[k]; complex_t tmp; tmp.r = fft_buf[i + j + step].r * w.r - fft_buf[i + j + step].i * w.i; tmp.i = fft_buf[i + j + step].r * w.i + fft_buf[i + j + step].i * w.r; fft_buf[i + j + step].r = fft_buf[i + j].r - tmp.r; fft_buf[i + j + step].i = fft_buf[i + j].i - tmp.i; fft_buf[i + j].r += tmp.r; fft_buf[i + j].i += tmp.i; } } } }

总蝶形数很好算:1024 × 11 = 11264次。每次蝶形包括一次复数乘法和两次复数加减法,对带FPU的Cortex-M4来说开销不大。我实测在STM32F303 @72MHz上,这段代码耗时约4.5ms。对80ms一帧的采集周期来说,处理时间绰绰有余。

这段代码还有优化空间,但别在功能没验证通过之前做:第一级旋转因子W=1,可以单独拆出来省乘法;循环里反复读twiddle表,可以把当前蝶形组需要的表拷到局部数组里利用缓存。这些都等频谱正常了再搞。

3.3 旋转因子表和窗函数:FFT前的两件套

一次FFT运行期间,旋转因子要被用到一万多次,所以提前算好一张表。全表2048个复数,float存储占16KB Flash,STM32的Flash一般256KB起步,开销可以接受。如果想省Flash,可以只存1/4周期再靠象限对称推算,但代码复杂度上去了,省的空间不多,我一般不折腾。

表在FFT之前生成一次:

void twiddle_init(void) { for (uint32_t k = 0; k < FFT_N; k++) { float angle = -2.0f * FFT_PI * (float)k / (float)FFT_N; twiddle[k].r = cosf(angle); twiddle[k].i = sinf(angle); } }

窗函数也是FFT前必须处理的一步。FFT本质上是对N点数据做矩形窗截断,矩形窗的旁瓣高达-13dB,信号动态范围一大,强信号会把旁边弱信号全盖住。加汉宁窗是稳妥的默认选择,代码没几行:

void apply_hanning(void) { for (uint32_t i = 0; i < FFT_N; i++) { float w = 0.5f * (1.0f - cosf(2.0f * FFT_PI * (float)i / (float)(FFT_N - 1))); fft_buf[i].r *= w; fft_buf[i].i = 0.0f; } }

注意加窗之后信号总能量变小。汉宁窗的相干增益是0.5,幅度被折半,后面做幅值修正时要把这个0.5补回来,具体公式在第5章。

3.4 浮点还是Q15定点:要看芯片有没有FPU

STM32F3/F4/F7/H7这些带FPU的Cortex-M系列,直接用float做FFT完全没问题,编译器把浮点运算直接映射到FPU硬件,速度和定点差不多,代码还简单。上面所有代码都是float版本。

但如果目标MCU是M0或者不带FPU的M3,情况就完全不同。浮点运算全部靠软件模拟,2048点FFT一次性耗时可能超过200ms,实时频谱基本没戏。这时候要么用Q15定点,要么干脆把FFT挪到外置DSP里做。Q15定点核心思路是:把ADC数据、旋转因子、中间结果都落到[-1, 1)范围的16位整数,乘法用带饱和的定点乘法处理。

从12位差分ADC出来的数据,本身只需要12位精度,左移4位放进Q15格式后还有三四位余量,加窗会进一步压低幅度,所以一般不会溢出。但如果你后面还要做频域加权或累积平均,就得注意中间结果的位宽,必要时升级到Q31。

4. 连续采集的同步机制:定时器、DMA双缓冲

4.1 采样率怎么定:先搞清信号里到底有什么

FFT是离散采样,采样率决定了可分析的最高频率。奈奎斯特定理要求fs至少大于两倍信号最高频率,工程上一般取3到5倍起步。电机电流基频50Hz,想看50次谐波也就是2.5kHz,fs=12.8kHz就够;要做音频频谱到20kHz,fs至少40kHz以上,我一般取64kHz,对应2048点下一帧32ms。

具体采样率我习惯选能整除2048、又方便计算的数,比如25600、51200、64000,后面频率轴计算和谱线索引能直接心算。采样率定下来后,定时器周期也就跟着定了。

ADC必须由定时器硬件触发启动,而不是在代码里延时然后手动拉起。原因很简单:软件启动ADC的时机受中断、总线仲裁影响,采样时刻抖动,相当于给信号叠加了随机相位调制,底噪会被抬高。定时器硬件触发则保证每次采样间隔严格相等,这是频谱质量的隐性保障。

4.2 DMA双缓冲:别让"正在算"挡住"正在采"

连续采集时,2048点采完了,处理上一帧的同时下一帧不能停。最常见的方案是乒乓缓冲:两块2048点的内存区,A区积累时B区送去FFT,下一帧反过来。DMA可以配置为循环模式加双缓冲,或者用半传输中断/全传输中断来切换。

我用的思路是这样的:DMA工作在循环模式,缓冲区大小设为2倍单帧,前半段存一帧、后半段存下一帧。半传输中断到来说明前半段数据就绪,传输完成中断说明后半段就绪。处理逻辑如下:

static uint16_t adc_dma_buf[FFT_N * 2]; static volatile uint32_t frame_ready = 0; static volatile uint32_t cur_half = 0; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { cur_half = 0; // 前半段就绪 frame_ready = 1; } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { cur_half = 1; // 后半段就绪 frame_ready = 1; }

主循环里检测到frame_ready,就锁定对应半区数据去跑FFT:

if (frame_ready) { frame_ready = 0; uint16_t *buf = cur_half ? adc_dma_buf + FFT_N : adc_dma_buf; process_frame(buf); // 先拷贝或者直接处理该半区 }

有一点必须强调:不要在DMA中断函数里直接跑FFT。虽然80ms帧周期对4.5ms的FFT处理时间来说够用,但中断里执行时间过长会影响PWM中断、通信等实时任务。我的习惯是中断只置标志,FFT放主循环或专用低优先级任务。

4.3 差分码值到FFT输入的转换细节

差分ADC的原始数据是偏移二进制,12位数值0x000到0xFFF,中点0x800表示差模电压0V。如果直接把raw数据丢给FFT,第0根谱线上会有一个巨大的直流分量,不仅占据一大块能量,还会通过窗函数旁瓣泄漏污染附近频带。

正确做法是在复制进FFT缓冲前减去2048,再转成有符号数。如果MCU内部已经左对齐到16位格式,中间值可能是32768而不是2048,这取决于用的HAL还是标准库,务必先查明白。

int16_t sample = (int16_t)(raw_data[i] - 2048); fft_input[i].r = (float)sample / 2048.0f; fft_input[i].i = 0.0f;

这样处理完,直流分量为0,FFT第0根谱线只代表真正的直流成分。如果你确实想观察直流,也是先转成有符号再分析,而不是把偏移值当成直流。

5. 从裸频谱到看得懂的图:校准与踩坑复盘

5.1 频率轴和幅值修正

FFT输出的X[k]是复数,第k根谱线对应的实际频率:

f = k × fs / N

2048点FFT输出2048根谱线,只有前1024根(0到1023)有意义,对应0到fs/2。比如fs=25.6kHz时,第80根谱线对应频率是80 × 25600 / 2048 = 1000Hz,正好1kHz。

幅值修正是最容易算错的地方。对正弦信号做N点FFT后,峰值谱线的模值近似等于 A × N / 2(矩形窗),所以反推 A = 2 × |X[k]| / N。但加了汉宁窗之后,能量折半,等效增益变成0.5,需要再乘以2,也就是:

A = 4 × |X[k]| / N

换算dB值时按20 × log₁₀(A / ref)处理,横轴放频率,纵轴放dB。加上窗后主瓣变宽,幅值也会被分散到相邻谱线上,单频信号通常会同时出现在相邻2到3根谱线上,这属于正常现象。

5.2 用1kHz正弦波验证:固定流程省下大量排查时间

建议把单频验证固化成调试第一步。信号发生器输出1kHz正弦波,幅度1V,直流偏置1.65V,接入差分引脚的INP和INN,fs设为25.6kHz,跑一遍FFT。期望在第80根谱线附近看到明显峰值,按修正公式换算后幅值接近1V。实际读数落在0.9到1.1V之间,链路就通了。

如果1kHz的峰不在第80根,而是落在79或81,先别着急,这不一定是错。信号发生器本身的频率精度、采样率时钟误差都会导致一两根谱线的偏差。但如果偏离很多,优先检查定时器分频和PLL配置,确认实际采样率到底是多少。

5.3 复盘三个真实踩坑

第一个坑是差分引脚接反。出现这种情况时,时域波形看着正常、FFT幅值也正常,但正负极对调了。单频分析看不出问题,一旦做跨通道相位比较,结果全错。排查方法是输入已知相位关系的双路信号,看FFT结果的相位角是否一致。这个坑我花了半天才发现。

第二个坑是输入信号超出差分ADC量程。前面提过F303差分模式有效量程是±VREF/2,信号摆幅过大ADC削波,频谱上会出现成串的奇次谐波,看起来极像"电路产生了失真",实际是采样饱和。遇到频频出现3次、5次谐波,先算量程,别直接怀疑传感器。

第三个坑是DMA缓冲切换。第一次只配了一个DMA buffer,处理完一帧后再次启动DMA还是同样的旧数据,频谱完全静止,和实时信号对不上。改用半传输/全传输中断切换之后才解决。

现象可能原因处理手段
频谱底噪高、杂散多单端ADC共模噪声换差分模式
频谱出现成串奇次谐波输入超量程削波检查量程/增益
相位错乱差分引脚接反调换INP/INN
主峰周围泄漏明显没加窗或幅值修正不对加汉宁并修正
频谱静止不更新DMA没有双缓冲切换改用乒乓缓冲

5.4 上位机画图最快路径

FFT算完只是一堆浮点数,要看得清频谱还得有可视化手段。调试阶段我建议直接走串口,把1024根有效谱线用文本格式发出去,格式简单点:

freq,db 0.00,-35.2 12.50,-28.1 ...

PC端用Python脚本读取串口,matplotlib画曲线,整个过程十分钟能通。我的习惯是调试阶段不要急着接屏幕,先把ADC到FFT到串口的裸数据链路全部调通,再考虑LCD或OLED显示。屏幕刷新、UI布局的问题排查难度远高于裸数据,放后面。

最后补一个调试习惯:每次改完参数,先跑一遍1kHz单频验证再上真实信号。我因为跳过这个步骤,把真实负载里的一个"异常峰"当成故障查了两天,最后发现是采样率改错了,谱线索引全偏了。固定单频输入,能让所有可疑点快速收敛到是采集链路的问题还是信号源本来就这样,这个顺序别搞反。

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

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

软件测试Bug全生命周期管理:从发现到关闭的实战指南

1. 软件测试里的Bug&#xff0c;不止是“找茬”那么简单 1.1 第一次提交Bug被驳回&#xff1a;缺陷与Bug的区别 我入行第一周就闹了个笑话。当时测一个后台管理系统&#xff0c;发现某个输入框输入超过50个字符后&#xff0c;页面会弹出一个英文报错。我觉得这是Bug&#xff0…

作者头像 李华
网站建设 2026/9/9 13:51:31

从碎片笔记到技术博文:内容创作与SEO优化完整指南

抱歉&#xff0c;我目前无法基于空内容生成文章。您提供的项目标题为“【无标题】”&#xff0c;且项目正文、关键词、摘要描述、相关热搜词、最新网络热词均为空白。这意味着没有任何实质信息可供提取和延展。为了帮您生成一篇有干货、有结构、能直接发布的博文&#xff0c;麻…

作者头像 李华
网站建设 2026/9/9 13:50:26

银行外拓服务全流程拆解:从社保卡激活到“送服务上门”的实战经验

1. 服务项目整体拆解&#xff1a;从“等客上门”到“送服务上门”的转型逻辑 这类“步履不停送服务、金融为民践初心”主题行动&#xff0c;在银行系统内早已不是新鲜提法&#xff0c;但真正落到实处的分支机构并不算多。我参与过几次类似的外拓服务专项活动&#xff0c;包括社…

作者头像 李华
网站建设 2026/9/9 13:48:58

推理阶段不同batch size对大模型推理结果的影响

SGLang最新版本提供了确定性推理的方法:SGLang的确定性推理 !!!Thinking Machines Lab对这个问题基本上画上了句号&#xff0c;在其官方blog 大模型推理阶段&#xff0c;进行batch inference批处理推理解码&#xff0c;会像预期的那样速度很快推完吗&#xff1f;会不会有什么问…

作者头像 李华