简介:压缩包提供了一套基于STM32与差分ADC的2048点FFT频谱采集完整工程,面向需要将时域信号转成频域谱图的嵌入式开发者。工程以标准库外设驱动为基础,覆盖ADC采样配置、FFT蝶形运算及频谱后处理,并附有可直接烧录的hex与axf文件。包内共193个文件,以c源文件、h头文件、o编译目标及crf中间文件为主,辅以uvprojx工程文件、sct链接脚本和Keil备份配置,便于在MDK中直接打开重建。压缩包仅6.38MB,结构紧凑,适合学习移植和二次开发;已有2803人浏览学习。实现中涉及差分ADC消除共模噪声、16位精度采样、奈奎斯特频率规避混叠等关键点,并给出幅度取模、dB变换与频点划分的处理思路,可快速迁移到音频分析、振动监测、电力谐波检测等场景。
1. 用STM32和差分ADC做2048点FFT:这套信号频谱图方案到底值不值得上
想用STM32在本地做实时频谱分析,又不想上Linux板和商用FFT库,最直接的路子就是把差分ADC采到的信号,用纯C自己写一个2048点FFT,然后把频谱图直接画出来。这套方案的核心有两个:一是采样链路要用差分结构把底噪压住,二是FFT计算要在单片机上靠自己写的蝶形运算跑完,还不能拖垮采样。我调试过好几个振动和音频采集项目,最终都落在了这个组合上——2048点FFT在168MHz主频的Cortex-M4上,纯C浮点算子大约几毫秒跑完,配合双缓冲DMA完全够用。这篇文章就把从ADC选型、DMA采样、FFT代码到频谱图输出的完整路径讲清楚,新手能照着搭,老手也能看看边界和坑。
适用的人很明确:做音频频谱显示、振动监测、电力谐波分析,或者只是想在现有STM32项目里加一个频谱功能,又不想引入庞大的DSP库。常见的做法是用内置ADC加上DMA,但采样精度和噪声往往撑不起后续FFT的幅值精度,所以我会重点讲差分ADC怎么接、怎么配,以及2048点纯C FFT怎么写才又快又稳。
2. 采样链路先行:差分ADC的选型理由与DMA配置
2.1 为什么FFT之前要把采样做成差分:共模噪声与地弹的代价
FFT对采样的要求不只是“分辨率够”,更关键的是采样值的重复性和稳定性。单端ADC在信号源和ADC共用参考地时,地线上的纹波会直接叠加进采样结果,表现为频谱图上的底噪抬高和50Hz附近工频干扰谱线。我做第一版音频频谱时用的是内置单端采样,FFT跑完在100Hz以下的低频段几乎没法看——噪声底平白无故高了十几个dB。差分输入的核心价值在于:有用信号靠两根线之间的电压差传递,两根线上的共模噪声(地弹、电源纹波、辐射耦合)理论上被差分放大器抵消掉。
具体到信号链,常见的做法是前级用差分放大器(如INA型仪表放大器)把单端信号转成差分对,再送入差分ADC;或者直接用带差分输入的ADC芯片,让传感器(比如电桥、压电片)直接接差分端。选型时重点看三个指标:有效位数(ENOB)、最大采样率、输入共模范围。有效位数决定FFT的动态范围底,比如一款24位Δ-Σ ADC在1kSPS下能达到20位以上的ENOB,但它的最大采样率往往只有几十kSPS,只适合低频振动和音频段。
2.2 采样率与频率分辨率的匹配:2048点到底够看什么
FFT的频率分辨率等于Fs/N。2048点工频采样时,分辨率 = Fs / 2048。如果要看工频附近的谐波(50Hz、100Hz、150Hz),建议Fs取10kHz或20kHz,分辨率分别为4.88Hz和9.77Hz,能清楚分开相邻谐波。再高并不是不行,比如48kHz采样、2048点时分辨率23.4Hz,低频段两个靠得太近的谱峰就可能混在一起。所以先定Fs,再反推N,而不是先定N再选Fs。
抗混叠也必须在这步解决。差分ADC的模拟输入端之前要加一阶或二阶低通滤波器,截止频率设在Fs/2以内。常见做法是RC低通,截止频率设在0.4×Fs左右,给采样留出频谱“滚降”余量。这个滤波器如果漏掉,FFT会把高于Nyquist频率的杂散折叠到低频段,产生幽灵谱线,到时候在软件里排查非常痛苦。
2.3 让差分ADC把数据送进STM32:SPI+DMA的配置与参数说明
我一般用SPI接口外接差分ADC,并让ADC用自由运行模式持续转换,STM32作为SPI主机用DMA循环接收转换结果。下面这段代码是初始化SPI和DMA接收的典型写法,基于HAL库描述,换芯片型号时只需改外设时钟和引脚定义。
/* SPI3 作为主机接收差分ADC的数据, 16bit-数据宽度, MSB-first */ SPI_HandleTypeDef hspi3; DMA_HandleTypeDef hdma_spi3_rx; void MX_SPI3_Init(void) { hspi3.Instance = SPI3; hspi3.Init.Mode = SPI_MODE_MASTER; hspi3.Init.Direction = SPI_DIRECTION_2LINES; hspi3.Init.DataSize = SPI_DATASIZE_16BIT; hspi3.Init.CLKPolarity = SPI_POLARITY_LOW; hspi3.Init.CLKPhase = SPI_PHASE_1EDGE; hspi3.Init.NSS = SPI_NSS_SOFT; hspi3.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8; hspi3.Init.FirstBit = SPI_FIRSTBIT_MSB; HAL_SPI_Init(&hspi3); } /* DMA接收: 循环模式, 数据长度等于一次FFT需要的样本数 */ void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_spi3_rx.Instance = DMA1_Stream0; hdma_spi3_rx.Init.Channel = DMA_CHANNEL_0; hdma_spi3_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_spi3_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_spi3_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_spi3_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_spi3_rx.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_spi3_rx.Init.Mode = DMA_CIRCULAR; hdma_spi3_rx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_spi3_rx); __HAL_LINKDMA(&hspi3, hdmarx, hdma_spi3_rx); } /* 启动SPI接收, 数据直接灌进2048个uint16的缓冲区 */ #define FFT_N 2048 uint16_t adc_buf[FFT_N]; void Start_Acquisition(void) { HAL_SPI_Receive_DMA(&hspi3, (uint8_t *)adc_buf, FFT_N); }SPI的主频不能超过差分ADC数据手册允许的最大SCLK,一般16MHz以内都安全。数据宽度16位对应大多数24位ADC的高16位或SAR型ADC的整16位输出,拿到后要根据ADC的输出编码格式转成有符号数。BaudRatePrescaler_8在APB2为84MHz的就是10.5MHz SCLK,如果发现采出来的值跳变频繁,先把预分频降一档再测。DMA必须设成循环模式,这样它永远在填这块缓冲区,主循环只用去计算“当前DMA写到了哪个位置”,不需要手动启动下一次传输。
3. 2048点纯C FFT的实现:位反转、蝶形运算与性能边界
3.1 基2 FFT的代码骨架:旋转因子表预计算与就地蝶形
2048点正好是2的11次方,可以完美用基2时间抽取(DIT)实现。整个算法由三部分组成:把输入序列按位反转重新排列、逐级蝶形计算、每级乘以旋转因子W。旋转因子如果实时调用sinf/cosf计算,2048点总共要做(N/2)×log2(N) = 11264次蝶形,每次都要算三角函数会严重拖慢速度。常见做法是启动时预计算一张旋转因子表,用float数组存下来,之后每次FFT直接查表。
蝶形运算是就地(in-place)的,输入数组同时作为输出数组,省内存。下面是完整的纯C实现,不依赖任何DSP库,IAR/Keil/GCC都能直接编过。
#include <math.h> #include <stdint.h> #define FFT_N 2048 #define FFT_LOG_N 11 /* 旋转因子表, 预先在初始化时填好 */ static float cos_tab[FFT_N / 2]; static float sin_tab[FFT_N / 2]; /* 复数数组交替存实部和虚部: buf[2*i]为实部, buf[2*i+1]为虚部 * 这样避免定义结构体, 内存连续且cache友好 */ static float fft_in[FFT_N * 2]; void FFT_Init(void) { for (int i = 0; i < FFT_N / 2; i++) { float angle = -2.0f * 3.14159265358979f * i / FFT_N; cos_tab[i] = cosf(angle); sin_tab[i] = sinf(angle); } } /* 位反转重排: 把索引按二进制位倒序重排 */ static void BitReverse(float *buf) { for (int i = 1, j = 0; i < FFT_N; i++) { int bit = FFT_N >> 1; for (; j & bit; bit >>= 1) j ^= bit; j |= bit; if (i < j) { float tr = buf[2 * i]; float ti = buf[2 * i + 1]; buf[2 * i] = buf[2 * j]; buf[2 * i + 1] = buf[2 * j + 1]; buf[2 * j] = tr; buf[2 * j + 1] = ti; } } } /* 就地FFT, len固定为FFT_N */ void FFT_Compute(float *buf) { BitReverse(buf); for (int len = 2; len <= FFT_N; len <<= 1) { int half = len >> 1; int step = FFT_N / len; for (int i = 0; i < FFT_N; i += len) { int k = 0; for (int j = i; j < i + half; j++) { float wr = cos_tab[k]; float wi = sin_tab[k]; float xr = buf[2 * j + half] * wr - buf[2 * j + half + 1] * wi; float xi = buf[2 * j + half] * wi + buf[2 * j + half + 1] * wr; float ar = buf[2 * j]; float ai = buf[2 * j + 1]; buf[2 * j] = ar + xr; buf[2 * j + 1] = ai + xi; buf[2 * j + half] = ar - xr; buf[2 * j + half + 1] = ai - xi; k += step; } } } }3.2 浮点还是定点:F4以上用float,F1老老实实查表
F4系列自带FPU(单精度浮点),上面的float版本在168MHz下跑2048点大约1~3ms,完全可以接受。如果你用的是F1或者F0这类没有FPU的芯片,float运算会全部走软件模拟,慢一个数量级,2048点可能要上百毫秒,那就得改成12位或16位定点FFT,蝶形里的乘加用int32累加,旋转因子用Q15格式存。实话讲,定点版本写起来容易在溢出和舍入上翻车,我自己的建议是:预算允许就用带FPU的芯片,别在定点上耗时间。
浮点版本另外一个好处是动态范围宽,输入ADC值是16位无符号,减去中点后范围是±32768,直接转float放进实部,虚部清零,不需要额外归一化。虽然蝶形运算中间值会超过这个范围,但float的指数位足够容纳中间结果的动态范围,不会像定点那样容易溢出。如果发现某条谱线幅度异常大,先检查是不是输入信号已经超过ADC量程,削波(clipping)会让FFT在整个频段铺满伪谱线。
3.3 输入数据的组织方式:为什么虚部清零和去直流都放在FFT之前
ADC采集到的是有符号数序列,但很多差分ADC输出的原始编码是无符号的(比如0对应负满量程,中间码对应0V)。直接把这个序列喂进FFT,结果会在0Hz处出现一个巨大的直流分量,甚至盖过旁边几十Hz的真实信号。所以FFT之前必须先做两件事:把无符号编码减掉中点偏置(比如16位就是减去32768),得到零均值的信号序列;如果信号本身还有残余直流偏置,再用软件方式估计并扣除,或者靠后面的窗函数来压低频谱泄漏。
/* 把DMA收到的原始ADC编码转换成零均值浮点序列 */ void PrepareInput(uint16_t *adc_raw, float *fft_buf) { const float midpoint = 32768.0f; for (int i = 0; i < FFT_N; i++) { fft_buf[2 * i] = (float)adc_raw[i] - midpoint; fft_buf[2 * i + 1] = 0.0f; } }这段代码本身很简单,但它决定了频域结果的可用性。减去中点后,信号就是双极性交流信号,直流分量取决于输入信号本身是否含有直流成分。如果后续要加汉宁窗,窗函数也要在这个阶段乘上,乘完再丢给FFT_Compute。注意窗函数会让非整周期截断的信号幅值衰减一半,所以幅度谱计算时要乘一个恢复系数(后面第4章会算)。
4. 从DMA缓冲到频谱图:把采样、窗函数和幅值计算串成一条流水线
4.1 双缓冲设计:别让FFT计算和DMA采集抢同一块内存
如果只有一个2048点的缓冲区,DMA一边填、FFT一边读,会出现数据被改写到一半的“撕裂”问题。常见做法是开两个缓冲区,DMA轮流往A、B两块里写,当它填完A时置一个标志位,主循环看到标志后去处理A,同时DMA已经开始写B,互不干扰。实现上最简单的方式是用DMA传输完成中断:每次传输完成,在中断里把“当前满的缓冲区索引”翻转一下。
#define BLOCK_NUM 2 static uint16_t adc_buf[BLOCK_NUM][FFT_N]; static volatile uint8_t full_idx = 0; static volatile uint8_t new_data_ready = 0; /* 在SPI DMA接收完成中断里调用 */ void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI3) { /* 翻转索引, 指向刚填满的缓冲区 */ full_idx = 1 - full_idx; new_data_ready = 1; } } /* 主循环里检查标志后再处理 */ void ProcessLoop(void) { if (new_data_ready) { new_data_ready = 0; /* 处理full_idx指向的缓冲区, 另一块正在被DMA填 */ PrepareInput(adc_buf[full_idx], fft_in); ApplyWindow(fft_in); FFT_Compute(fft_in); ComputeSpectrum(); } }这里有个细节:DMA循环模式配两个缓冲区时,要把DMA的Memory 0和Memory 1地址分别指向adc_buf[0]和adc_buf[1],并且开启DMA的双缓冲模式(在寄存器层面设置M0AR、M1AR和DBM位。用HAL库时是调用HAL_DMAEx_MultiBufferStart)。如果只配置单块循环缓冲区,传输完成中断永远不会在整块填完时触发,你只能靠轮询DMA的当前数据指针来判断写到了哪,既浪费CPU又容易算错边界。
4.2 加窗:2048点FFT与整周期采样的现实差距
理论上如果采样频率和信号频率是整数倍关系,截取整周期数据做FFT不会产生频谱泄漏。但现实中信号频率不可能正好是Fs/N的整数倍(比如工频50Hz,用10kHz采样时2048点恰好是10个周期,这是凑巧;振动信号几百Hz到几kHz根本不是整齐的),所以必须加窗。最常用的是汉宁窗,主瓣较宽但旁瓣衰减快,适合大多数频谱分析场景。
static float window_tab[FFT_N]; void Window_Init(void) { for (int i = 0; i < FFT_N; i++) { window_tab[i] = 0.5f * (1.0f - cosf(2.0f * 3.14159265f * i / (FFT_N - 1))); } } void ApplyWindow(float *buf) { for (int i = 0; i < FFT_N; i++) { buf[2 * i] *= window_tab[i]; } }加窗之后幅值会发生变化。汉宁窗的相干增益约为0.5,也就是说正弦信号的幅度谱峰值约为真实幅值的一半。要恢复真实幅值,幅值谱计算时要乘以2.0 / N再乘以1 / 0.5,也就是4.0 / N。这个系数写错的话,频谱图上所有幅值都会偏小6dB,看着就像信号变弱了一样,排查起来还挺隐蔽。
4.3 幅值谱与dB刻度:从复数FFT结果到能看的频谱图
FFT计算完成后,每个频点的复数结果存放在fft_in[2i](实部)和fft_in[2i+1](虚部)里。幅值等于sqrt(re²+im²),相位等于atan2(im, re)。做频谱图只关心幅值,但要显示成类似对数坐标的形态,需要把幅值转成dB。参考电平可以定为满量程(满量程的正弦波幅度对应0dBFS),这样所有读数都是负值。
#define SCALE (4.0f / FFT_N) /* 加窗恢复系数: 2/N * 1/0.5 */ /* 帧长2048, 假设采样率Fs=10000 */ #define Fs 10000.0f typedef struct { float freq; float amp_dbfs; } spectrum_line_t; /* 只需要前N/2个频点, 后半段是镜像 */ void ComputeSpectrum(spectrum_line_t *out) { for (int i = 0; i < FFT_N / 2; i++) { float re = fft_in[2 * i]; float im = fft_in[2 * i + 1]; float mag = sqrtf(re * re + im * im); float amp = mag * SCALE; float dbfs = 20.0f * log10f(amp + 1e-12f); out[i].freq = (float)i * Fs / FFT_N; out[i].amp_dbfs = dbfs; } }实际显示时,建议只显示0到Fs/2之间的1024个频点,后半部分是共轭对称的,没有额外信息。10kHz采样、2048点FFT的频率分辨率是4.88Hz,每条谱线间隔4.88Hz,如果屏幕横向只有128像素,要在一格里塞8条谱线,得做峰值保持或取最大值。这个映射逻辑很简单:横坐标是index × Fs / N,纵坐标是dB值。
5. 这套方案最常见的5个坑与排查路径
5.1 频谱图里出现对称镜像频率
现象:信号是1kHz,频谱图上在1kHz处有条谱线,但同时在9kHz处也出现一条几乎等高的谱线(Fs=10kHz时)。原因:差分ADC输入端有混叠信号,或者SPI采样时序采样到了错误的点。解决:先检查抗混叠RC滤波器是否接对位置,再用示波器看差分输入端的波形有没有明显噪声。如果模拟滤波器没问题,调低SPI时钟频率,排除DMA采样边沿采错。
5.2 加窗后幅值整体偏低6dB
现象:标准正弦波输入,幅度谱峰值比理论值小一半。原因:没有乘窗函数相干增益的恢复系数。解决:确认幅值计算里的SCALE是否等于4.0/N。汉宁窗的相干增益是0.5,恢复系数是2.0/N再除以0.5。如果用的是平顶窗或布莱克曼窗,恢复系数不同,需要重新查表。这个坑我在第一版里踩过,那时候对比实测幅值和理论值,怎么都对不上。
5.3 FFT过程中DMA丢数据,频谱图毛刺多
现象:SSR(正弦信号)输入时,频谱图上除了主谱线还有一堆杂乱的小尖峰。原因:主循环处理FFT期间,DMA还在持续往缓冲区写数据,如果缓冲区不够或者中断优先级配置不当,DMA传输会被其他高优先级中断打断,造成采样间隙。解决:改用双缓冲方案,处理A缓冲区时DMA写B缓冲区;同时把SPI DMA中断优先级调到比定时器中断低但比串口高的档位。关键检查点:两块缓冲区大小是否都是2048,有没有出现缓冲区索引覆盖。
5.4 频率分辨率对不上理论值
现象:输入1kHz正弦波,频谱图峰值落在990Hz或1010Hz处。原因:实际采样率偏离预设值,常见于外部晶振精度不足,SPI时钟分频配置错误,或ADC自由运行模式的实际转换速率与SPI读取速率不匹配。解决:先用示波器测SPI的SCLK,确认实际频率;再用一个已知频率的信号标定。如果偏差是固定倍数,直接修改程序里的Fs常量。要彻底解决就改用外部有源晶振喂给ADC或STM32的PLL输入,别依赖内部RC振荡器。
5.5 空载时底噪异常高,频谱图底部不是平的
现象:输入端接地,频谱图上底噪仍然在-40dBFS以上,且分布不规则。原因:数字地模拟地没分开,差分ADC的参考电压(VREF)带纹波,或SPI数据线串扰。解决:先把差分输入的负端和正端短接,看底噪是否下降;再用万用表量参考电压纹波。常见做法是给VREF加RC滤波或者用专门的低噪声基准芯片。底线是:空载频谱底噪至少要到-70dBFS以下才说明采样链路合格。
6. 锦上添花:用谱线插值把频率读数精确到0.1Hz级别
2068点FFT的频率分辨率只有4.88Hz,直接找峰值谱线的位置,频率读数误差最大能到±2.4Hz。对音频频谱显示来说体感不明显,但测振动特征频率或电力谐波时这误差就太大了。这里有个便宜又好用的技巧:利用窗函数主瓣的形状,用峰值谱线和它左右相邻谱线的幅值比例,估算真实频率在峰值谱线附近的偏移量。原理是加窗后信号的频谱主瓣形状是已知的,三个采样点就能拟合出峰值位置。具体公式如下。
/* 用最大值及左右邻点做抛物线插值, 返回精确谱线位置(浮点) */ float GetPeakIndex(float *mag, int peak_idx) { float y1 = mag[peak_idx - 1]; float y2 = mag[peak_idx]; float y3 = mag[peak_idx + 1]; float denom = (y1 - 2.0f * y2 + y3); if (denom == 0.0f) return (float)peak_idx; /* 抛物线顶点偏移量, 结果在[-0.5, 0.5]之间 */ float delta = 0.5f * (y1 - y3) / denom; return (float)peak_idx + delta; } /* 真实频率 = 峰值位置 × 频率分辨率 */ float freq = GetPeakIndex(amp_dbfs, max_idx) * (Fs / FFT_N);实际使用时要注意:这个抛物线插值在汉宁窗下精度最高,对幅值谱(线性幅值)插值比dB值插值更准,因为dB压缩后不再是抛物线形状。我用这个方法处理过一组转速信号,FFT粗读在3997Hz附近,插值后稳定到3999.8Hz,外部用频率计校过,误差在0.2Hz以内。后来我养成了习惯:凡是测振动频率,都不直接取峰值谱线,而是先做两次插值再报数。如果这一步已经掌握,整个方案就等于多了一块“精频测量仪”的能力。希望这篇整理能帮你在采样链路和FFT实现上少走点弯路。
本文还有配套的精品资源,点击获取