简介:这是一套基于STM32F10x平台的ADC数据采集与FFT频谱分析完整工程,面向嵌入式爱好者、数字信号处理初学者及毕业设计开发者。工程围绕“模拟信号采样—数字转换—FFT变换—液晶屏实时显示”的闭环链路展开,可直接编译运行,也可作为理解和调试信号处理流程的参考模板。资源共261个文件,代码以C源文件与头文件为主,包含标准外设库、DAC/ADC驱动及大量编译中间文件(.o、.d、.crf),同时提供Keil工程配置、汇编启动文件和地图/十六进制输出等,压缩包整体10.12MB。目前已有386人学习下载。通过研读该工程,读者可掌握STM32的ADC多通道采集、FFT算法调用与频域分析、LCD显示驱动以及工程规范化组织方法,适合用于课程设计、电子竞赛备赛或嵌入式信号处理入门。
1. ADC 采集和 FFT 放在同一个工程里,先想清楚数据怎么走
拿到一个 Keil 工程,文件名是 FFT.rar,但里面躺着 DAC.axf 和 HzLib_65k.c 时,第一反应不是去看代码,而是去看数据流:ADC 把连续电压变成二进制序列,FFT 把这段序列拆成频率分量,最后搬到液晶屏上。很多人在 STM32F103 上做频谱分析,第一步就卡在“怎么让采样率和 FFT 点数对上”,第二步卡在“屏上那些毛刺是信号还是噪声”。这个工程的价值在于它把两条链路拼成了一条:外设库文件 stm32f10x_adc.c 负责采样,HzLib_65k.c 提供频域变换,剩下的工作全是在做数据搬运。适合做音频谱分析、振动监测和电源纹波检测的人参考,也适合想搞懂 ADC 动态参数和 FFT 分辨率关系的嵌入式开发者。
2. STM32 ADC 采样链路:先定采样率和转换时间
2.1 采样率决定 FFT 能看到多高的频率
频域分析的第一个约束不是 FFT 算法本身,而是 ADC 采样率。按 Nyquist 定理,采样率 fs 必须大于目标信号最高频率的两倍,否则高频分量会混叠到低频段,在频谱上形成假峰。STM32F103 的 ADC 时钟树上限是 14MHz,标准外设库一般配置为 PCLK2 的分频,工程里常用 Div6,也就是 72MHz / 6 = 12MHz。每次转换的总周期数是“采样周期 + 12.5”,等待时间和采样保持时间都在这一个参数里。
下面这张表按 ADC 时钟 12MHz 计算,列出不同采样周期配置对应的采样率和理论无混叠频率。实际设计时不要把信号频率推到 fs/2 边界,要留出抗混叠滤波器的过渡带。
| 采样周期配置 | 总周期数 | 单次转换时间 | 采样率 | 理论 fs/2 |
|---|---|---|---|---|
| 1.5 | 14 | 1.17μs | 857kHz | 428kHz |
| 7.5 | 20 | 1.67μs | 600kHz | 300kHz |
| 28.5 | 41 | 3.42μs | 292kHz | 146kHz |
| 71.5 | 84 | 7.00μs | 143kHz | 71kHz |
采样周期配置在标准外设库里的名称是ADC_SampleTime_28Cycles5,后面的数字就是采样保持时间。ADC 是逐次逼近型(SAR ADC),内部采样电容通过模拟开关连接外部引脚,开关闭合的时间就是采样周期。时间越短,对信号源阻抗要求越高。如果前面没有运放缓冲,换取的高采样率会栽在 ADC 建立时间上,产生非线性误差。
提示:fs/2 只是奈奎斯特极限,频谱分析工程上一般只用到 fs/4 以内的频率,也要确认前级是否有足够的抗混叠能力。
2.2 用标准外设库把 ADC 打开
这个工程的文件列表里已经有 stm32f10x_adc.c,所以初始化代码走的是标准外设库 API。下面是一段常用的单通道 ADC 初始化,PA1 进 ADC1,采样周期选 28.5 个时钟,适合中低阻抗信号源。
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); /* ADC 时钟 = 72MHz / 6 = 12MHz */ GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); ADC_InitTypeDef ADC_InitStructure; ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; /* 连续转换,配合 DMA */ ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_28Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE);RCC_ADCCLKConfig决定了 ADC 的转换时钟,这里 Div6 是 12MHz,采样周期 28.5 对应总周期数 41,单次转换约 3.42μs。ADC_ExternalTrigConv_None表示不使用外部触发,启动后由内部自动连续转换。ADC_DataAlign_Right很重要,12 位数据右对齐存放在寄存器低 16 位,后面 DMA 搬运和 FFT 预处理都依赖这个布局。
2.3 用 DMA 把连续采样搬进内存
连续转换模式如果每次转换都进中断,CPU 会一直被 ADC 打断,FFT 根本没时间算。常见做法是开 DMA 周期传输,把 ADC1 的数据寄存器直接搬进内存数组,搬满一帧后再触发一次 FFT。这个工程要做的正是“采集 + FFT + 显示”,所以 DMA 通道几乎绕不开。
#define FFT_N 1024 uint16_t adc_buf[FFT_N]; void ADC1_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = FFT_N; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &DMA_InitStructure); DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE); DMA_Cmd(DMA1_Channel1, ENABLE); }DMA_BufferSize要和 FFT 点数一致,这里设成 1024,搬满后硬件自动产生传输完成中断。外设地址固定为ADC1->DR,内存地址递增。关键在HalfWord数据宽度,ADC1 数据寄存器高 16 位没有有效数据,只有半字对齐才不会把数据错位。DMA_Mode_Circular让 DMA 在搬完一帧后自动回到起点继续搬,ADC 永远在写,FFT 处理的是上一帧。
2.4 数据是 12 位右对齐,进 FFT 前先做预处理
DMA 搬进来的adc_buf是 0~4095 的无符号整数。信号如果是双极性系统,比如音频或振动传感器,需要先消掉直流偏置。很多新手直接把原始数组丢给 FFT,结果 0 号 bin 出现一个巨大直流分量,旁边还拖着一串泄漏,低频信号被淹掉。
int16_t adc_signed[FFT_N]; uint32_t sum = 0; for (int i = 0; i < FFT_N; i++) { sum += adc_buf[i]; } int16_t dc = (int16_t)(sum / FFT_N); for (int i = 0; i < FFT_N; i++) { adc_signed[i] = (int16_t)adc_buf[i] - dc; }先对整帧求平均值,再把每个点减去这个平均值,得到以零为中心的int16_t序列。这个操作相当于在时域做了高通滤波,但只滤掉纯直流。手动去直流比在 FFT 结果里忽略第 0 个 bin 更稳妥,因为直流分量在变换时会产生频谱泄漏,影响相邻几个低频点。
3. FFT 算法实现:HzLib_65k.c 的底层逻辑和调用方式
3.1 从 DFT 到 FFT,这个库帮你省掉什么
离散傅里叶变换的定义是把 N 个时域样本分别和 N 个频率点做复数相关,计算量是 O(N^2)。一个 1024 点的 DFT 要做超过 100 万次复数乘法,Cortex-M3 跑起来非常吃力。FFT 利用旋转因子的周期性和对称性,把大 DFT 拆成多级蝶形运算,计算量降到 O(N log N)。1024 点基 2 FFT 只需要大约 5120 次复数乘法,差了接近两个数量级。
HzLib_65k.c这类库一般会在初始化阶段把旋转因子预先算好,存成一张查找表,运行时只做加减法和查表乘法。名字里的 65k 我倾向于理解成和频率刻度有关,具体对应多少 Hz 的基频,要去看文件头部注释和采样率配合。不要被文件名里的数字迷惑,核心还是点数、位宽和输出格式三个参数。
3.2 在工程里怎么调用:初始化、变换、取模
一般这类库对外暴露的函数很简洁:一个初始化,一个执行变换。主循环里先等 DMA 搬完一帧,然后把上一节的adc_signed填入实部数组,虚部全部清零,调用变换函数,最后对前一半输出取模。
static int16_t re[FFT_N]; static int16_t im[FFT_N]; static uint16_t mag[FFT_N / 2]; /* 以 HzLib_65k.c 头文件中的实际函数名为准 */ HzLib_Init(FFT_N); for (uint16_t i = 0; i < FFT_N; i++) { re[i] = adc_signed[i]; im[i] = 0; } HzLib_FFT(re, im); for (uint16_t i = 0; i < FFT_N / 2; i++) { mag[i] = isqrt32((uint32_t)re[i] * re[i] + (uint32_t)im[i] * im[i]); }im[i] = 0是实数输入,FFT 输出中后半部分只是前半部分的镜像,所以只保留前N/2个点。isqrt32可以是整数平方根函数,如果编译器自带sqrtf且不心疼浮点时间,也可以直接转 float 求模。输出数组是uint16_t,显示和比较都方便。注意定点乘法可能会溢出,中间结果要转成uint32_t再平方。
3.3 窗函数不是可选项
如果采样点没有覆盖输入信号的整数个周期,FFT 会把截断造成的跳变展开成额外频谱分量,这就是频谱泄漏。最简单有效的方法是加窗。工程里最常见的是 Hanning 窗,公式简单,旁瓣衰减也够用。
#define PI 3.14159265358979f float win; for (uint16_t i = 0; i < FFT_N; i++) { win = 0.5f - 0.5f * cosf(2.0f * PI * i / (FFT_N - 1)); re[i] = (int16_t)(adc_signed[i] * win); im[i] = 0; }加窗会把信号能量压缩,峰值幅度会偏小。如果只看频率位置,不影响判断;如果要做幅值读取,就需要在显示前乘回窗函数的幅度修正系数。Hanning 窗的峰值修正因子大约是 2,也就是加了窗以后,真正正弦分量在频域里的峰值大约是需要数值的 1/2,具体还要看库是否已经做过归一化。
3.4 频率分辨率:N 和 fs 怎么搭配
频谱上相邻两个 bin 之间的频率间隔是 Δf = fs / N。分辨率越高,越能把靠近的两个峰分开。下面以几个常见采样率为例:
| fs | N=256 | N=512 | N=1024 | N=2048 |
|---|---|---|---|---|
| 8kHz | 31.25Hz | 15.63Hz | 7.81Hz | 3.91Hz |
| 40kHz | 156.25Hz | 78.13Hz | 39.06Hz | 19.53Hz |
| 292kHz | 1.14kHz | 570Hz | 285Hz | 142Hz |
提高频率分辨率有两种办法:降低采样率,或者增加 N。但 N 翻倍,FFT 运算时间只增一倍多,而显示和 DMA 内存占用也会翻倍。STM32F103 内存不大,FFT_N取 1024 在资源和实时性之间比较平衡。HzLib_65k.c 里很可能写了固定点数优化,换点数之前要先确认库支持的序列长度是不是 2 的整数次幂。
4. 频谱上屏:LCD 显示与坐标映射
4.1 从 FFT 输出到屏幕的坐标换算
FFT 算出来的是离散频谱,但液晶屏只有几百像素宽,直接把FFT_N/2个点铺满不现实,必须做合并。我一般会按屏幕宽度把频谱分成若干区间,每个区间取峰值或平均值,窄带信号取峰值不会丢,宽带噪声取平均更平滑。
#define SCREEN_W 240 #define SCREEN_H 320 uint16_t bar_height[SCREEN_W]; for (uint16_t x = 0; x < SCREEN_W; x++) { int bin_start = x * (FFT_N / 2) / SCREEN_W; int bin_end = (x + 1) * (FFT_N / 2) / SCREEN_W - 1; uint16_t max_mag = 0; for (int b = bin_start; b <= bin_end; b++) { if (mag[b] > max_mag) { max_mag = mag[b]; } } bar_height[x] = lcd_log_scale(max_mag); }bin_start到bin_end把 512 个 FFT bin 映射到 240 列,每列大约合并两个 bin。实际工程里屏幕宽 240,柱状图底部留 20 像素刻度,顶部留一点余量。lcd_log_scale用对数压一下幅度,否则大信号会把小信号全压扁:
uint16_t lcd_log_scale(uint16_t raw_mag) { if (raw_mag == 0) return 0; float ratio = log10f((float)raw_mag) / log10f(4095.0f); return (uint16_t)(ratio * (SCREEN_H - 20)); }对数映射能同时看到 -40dB 量级的微弱信号和满量程的大峰,频谱分析仪习惯上也是这么显示。显示代码要注意raw_mag为 0 时的除零问题,所以先判零返回。
4.2 刷新策略:整帧擦除和增量擦除
一种最直接的画法是每帧先清屏,再画所有柱状条。缺点是闪烁明显,而且 LCD 写像素很慢。简单优化是维护上一帧每个柱子的高度,只擦掉从当前高度到旧高度之间的区域,然后画新的柱子。这样每次刷屏的写像素量减少很多。
如果项目用的是带显存的 LCD 驱动芯片,比如 ILI9341,可以先把整帧频谱画在一块内存缓冲区里,再用一条命令整块刷过去。STM32F103 内部 RAM 只有 20KB,240×320×2 字节的 RGB565 缓冲放不下,所以还是推荐用增量擦除法,或者把屏幕分辨率降到 240×160。
4.3 帧率预算:数据从采样到显示花在哪里
一帧完整的流程包括等待 DMA 采集满 FFT_N 个点、做加法去直流、乘窗函数、执行 FFT、取模、画柱状图。如果用 fs = 292kHz、N = 1024,采集一帧只要 3.5ms,但 FFT 浮点运算和 LCD 绘制会吃掉大部分时间。显示线段如果一条条写像 aff 普通 GPIO 模拟时序,光是画 240 根柱子就可能超过 10ms。
这时候优先优化 LCD 写入接口,用 SPI DMA 发送像素数据,而不是在循环里等while(SPI_I2S_GetFlagStatus)==RESET。FFT 运算本身如果超过 20ms,再考虑换定点库或者缩小点数。帧率能达到 10fps 以上,肉眼观察频谱已经比较流畅。
5. 实时性瓶颈与常见坑:从采样周期到 DMA 双缓冲
5.1 ADC 建立时间不够,频谱里平白多出谐波
SAR ADC 的内部采样电容在采样阶段从输入信号抽取电荷。如果信号源阻抗太高,采样时间又短,电容端电压还没稳定就被断开,同一幅度的信号在不同采样点会被偏置,产生非线性失真。具体表现是 FFT 里出现输入频率的 2 次、3 次谐波,而且幅度随采样周期变短而变大。
| 信号源阻抗 | 建议采样周期 |
|---|---|
| < 1kΩ | 1.5~7.5 |
| 1kΩ~10kΩ | 28.5 |
| 10kΩ~100kΩ | 71.5,或加运放跟随器 |
这组经验值适用于 STM32F103 这类 SAR ADC。如果板子上 ADC 引脚直接接电位器这种高压阻源,最稳妥的是加一个低输出阻抗的运放跟随器。不要只盯着采样率高,ADC 建立时间不够时,信噪比和 THD 都会明显变差,FFT 看到的数据已经失真了。
5.2 DMA 循环模式的数据撕裂问题和双缓冲
DMA_Mode_Circular很方便,但有个隐患:ADC 在持续写adc_buf,而 FFT 正在读同一块数组,可能读到半帧新数据、半帧旧数据。解决方法是双缓冲,或者叫乒乓缓冲。两个数组交替使用,DMA 写一块,FFT 读另一块。
uint16_t adc_buf[2][FFT_N]; volatile uint8_t cur_buf = 0; volatile uint8_t frame_ready = 0; void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); cur_buf ^= 1; frame_ready = 1; } }主循环里检测frame_ready,为 1 时处理adc_buf[cur_buf ^ 1]。需要保证 FFT 处理时间小余采集一帧的时间,否则frame_ready还没被清零,下一次 DMA 中断又置位,数据仍然会互相追赶。cur_buf要声明成volatile,防止编译器优化掉反复判断。
5.3 电源噪声和 PCB 布局对噪底的影响
FFT 分析对电源噪声特别敏感。ADC 的参考电压如果有 10mV 的开关噪声,整个频谱底噪会被抬高,弱小信号直接消失在底噪里。常见的三个 PCB 布局检查点:模拟电源用 π 型 LC 滤波单独给 VDDA;模拟地和数字地单点连接,不要大面积连通;ADC 采样引脚不要和高速数字信号线并行走长线。
时钟抖动也会影响采样精度。对正弦波采样时,抖动相当于在时间轴上叠加噪声,转化为幅度上的随机误差,最终看到的是噪底不平。STM32 内部 ADC 对时钟抖动不算苛刻,但切忌用 I/O 翻转模拟时钟去触发 ADC。
5.4 ADC 数据漂移下的软件姿态
如果检测到 ADC 数值整体缓慢漂移,比如温度变化或基准漂移,可以在时域做中值滤波或滑动平均。但对频谱采集来说,均值滤波会压低高频成分,3 点中值滤波是性价比最高的选择,既能去掉毛刺,又不会明显破坏信号边缘。
uint16_t adc_median3(uint16_t a, uint16_t b, uint16_t c) { if ((a - b) * (a - c) <= 0) return a; if ((b - a) * (b - c) <= 0) return b; return c; }这个函数用乘法的符号判断中间值,注意uint16_t减法可能下溢,但在无符号数运算里结果是正确模值,不影响比较。中值滤波适合滤掉单点尖峰,如果漂移是累积性的,还是要检查基准源和 ADC 参考电压,软件滤波解决不了系统性误差。
6. 进阶:用已知信号校准频率轴和幅值
6.1 用 DAC 输出正弦波做自闭环验证
这个工程的文件里出现了 DAC.axf,说明原有的 ARM 目标里很可能已经启用了 DAC 外设。把 DAC 配置成输出一个已知频率的正弦波,再用杜邦线或板载走线接到 ADC 输入,是最快的验证方式。假设目标频率是 1kHz,采样率 fs = 292kHz,FFT 点数 N = 1024,那么峰值应该出现在 k = f_target × N / fs ≈ 3.5 这个 bin 附近。
由于 k 不是整数,理论上峰值会泄漏到相邻几个 bin,加 Hanning 窗后峰值周围呈抛物线分布。这时可以先用峰值 bin 做粗定位,再用相邻两个 bin 的幅值做抛物线插值,频率估计精度可以远高于一个 bin 的宽度。这个步骤不复杂,但对理解 FFT 参数很有帮助。
6.2 用已知幅度校准显示增益
校准频率轴后,再校准幅度。给 DAC 一个满量程 10% 的正弦波,比如 0.5Vpp,记录加窗后峰值 bin 的幅值 A_measured。理想情况下,加窗后峰值幅度和真实幅度之间存在固定比例,这个比例受窗函数和库归一化影响。把实际输入幅度除以测量值,得到一个增益系数。
float gain = 0.5f / (float)peak_mag; /* 实测 0.5Vpp 信号的峰值幅值 */ for (uint16_t i = 0; i < FFT_N / 2; i++) { display_mag[i] = (uint16_t)(mag[i] * gain); }把gain做成一维数组,存到代码里作为校准表,每次显示前直接乘上,能做到比较准的幅值读数。校准表只在采样率、FFT 点数和窗函数不变时有效,换一个参数重跑一遍校准函数。这样处理之后,LCD 上的柱状图纵轴就变成了(大致)真实的电压幅度,而不是一个只可看相对强弱的无量纲数字。
本文还有配套的精品资源,点击获取