简介:这份资源面向学习STM32信号采集与数字信号处理的开发者,尤其是希望把FFT从理论落到实际工程中的嵌入式初学者与进阶者。它基于STM32F407内置ADC,通过定时器触发配合DMA连续搬运采样数据,可灵活设置512kHz、256kHz、128kHz等采样频率,并将FFT运算结果经串口输出,帮助读者直观感受频谱分析效果,采样频率与点数均可自行修改。压缩包共187个文件,约4.85MB,以58个h头文件、47个c源文件为核心,涵盖ADC、定时器、DMA等外设驱动,另含Keil工程文件、编译中间产物与hex、axf等可执行文件,以及少量说明文档,目录结构完整,便于直接打开工程编译验证。目前已有5795人学习下载,适合作为FFT实战入门与ADC+DMA采集方案的参考模板,读者可据此理解采样率配置、数据搬运与频谱打印的完整链路,并在此基础上扩展自己的信号处理应用。
1. 从零搭建 STM32F407 的 ADC+DMA+FFT 信号采集链路
如果你手头有一块 STM32F407 开发板,想做一个能实时看频谱的小工具——比如采集音频信号、分析振动波形、做个简易频谱仪——那这套 ADC+DMA+FFT 的代码框架基本就是绕不开的起点。它的核心思路很直接:ADC 负责把模拟信号转成数字量,DMA 在后台默默搬运数据不占用 CPU,FFT 把时域波形变换成频域信息,最后通过串口或者屏幕把频谱显示出来。整个过程 CPU 几乎不参与数据搬运,腾出来的算力全留给 FFT 运算和后续处理。这套代码适合刚接触 STM32 想找个完整项目练手的开发者,也适合需要快速验证信号处理算法的熟手——毕竟从寄存器配置到 CMSIS-DSP 库调用,整条链路都打通了。
2. 硬件与软件架构:信号从引脚到频谱的完整路径
2.1 硬件连接与信号调理
先看硬件层面。STM32F407 的 ADC1 通道 0 对应 PA0 引脚,这是最常用的模拟输入口。但模拟信号不能直接怼上去——STM32 的 ADC 输入范围是 0 到 3.3V,如果你要采集的是双极性信号(比如音频交流信号),得先加一个偏置电路把信号抬到 1.65V 附近,再送进 ADC。常见做法是用两个等值电阻分压得到 1.65V 基准,信号通过电容耦合进来。输入阻抗也要注意,ADC 采样时内部采样电容会瞬间拉低引脚电压,如果信号源内阻太大,采样值会偏低。我一般会在信号源和 ADC 引脚之间串一个 100Ω 电阻,再并一个 10nF 电容到地,构成简单的抗混叠滤波。
采样率怎么定?根据奈奎斯特采样定理,采样率至少是信号最高频率的两倍。假设你要分析 0 到 10kHz 的音频信号,采样率设到 20kHz 以上才靠谱。STM32F407 的 ADC1 挂在 APB2 总线上,时钟最高 84MHz,经过 ADC 预分频器后 ADC 时钟不能超过 36MHz。假设 ADC 时钟 36MHz,采样周期设为 84 个周期,转换时间就是 (84+12)/36MHz ≈ 2.67μs,对应最高采样率约 375kHz。实际用定时器触发 ADC 采样,采样率由定时器频率决定,这样更精确。
2.2 软件架构与数据流
软件层面分四层:底层驱动层配置 GPIO、ADC、DMA、定时器;中间层用 DMA 双缓冲机制管理数据流;算法层调用 CMSIS-DSP 库做 FFT;应用层负责频谱显示或串口输出。数据流是这样的:定时器 TIM2 以固定频率产生触发信号,ADC1 收到触发后开始转换,每次转换完成 DMA 自动把数据搬到缓冲区。当缓冲区半满或全满时产生中断,在中断里置标志位通知主循环处理。主循环检测到标志位后,对缓冲区数据加窗函数(常用汉宁窗),然后调用 arm_cfft_f32 做 FFT,再计算幅值谱,最后输出。
这里有个关键设计:DMA 双缓冲模式。它把 DMA 目标地址分成两块,DMA 填第一块时 CPU 可以处理第二块,填第二块时 CPU 处理第一块,交替进行。这样采集和处理互不干扰,不会丢数据。缓冲区大小一般取 1024 或 2048 点,对应 FFT 的分辨率是采样率除以点数。比如采样率 20kHz、1024 点 FFT,频率分辨率约 19.5Hz。
2.3 关键外设配置参数
ADC 配置有几个参数要留意。分辨率设为 12 位,对齐方式选右对齐,扫描模式关闭(单通道),连续转换模式关闭(用外部触发),外部触发源选 TIM2 的 TRGO 事件。DMA 配置里,外设地址是 ADC1 的数据寄存器,内存地址是缓冲区数组,数据宽度都是半字(16 位),模式选循环模式,优先级设高。定时器 TIM2 配置成向上计数模式,ARR 值决定触发频率,比如 84MHz 时钟、ARR=4199、预分频 0,触发频率就是 84MHz/4200=20kHz。
// ADC1 初始化关键片段 void ADC1_Init(void) { ADC_InitTypeDef ADC_InitStructure; ADC_CommonInitTypeDef ADC_CommonInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); ADC_CommonInitStructure.ADC_Mode = ADC_Mode_Independent; ADC_CommonInitStructure.ADC_Prescaler = ADC_Prescaler_Div2; // ADC时钟=84M/2=42M,超了,实际用Div4 ADC_CommonInitStructure.ADC_DMAAccessMode = ADC_DMAAccessMode_Disabled; ADC_CommonInitStructure.ADC_TwoSamplingDelay = ADC_TwoSamplingDelay_5Cycles; ADC_CommonInit(&ADC_CommonInitStructure); ADC_InitStructure.ADC_Resolution = ADC_Resolution_12b; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConvEdge = ADC_ExternalTrigConvEdge_Rising; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_TRGO; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfConversion = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_84Cycles); ADC_DMARequestAfterLastTransferCmd(ADC1, ENABLE); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_SoftwareStartConv(ADC1); // 先软件触发一次校准,之后靠定时器触发 }上面代码里 ADC_Prescaler 我写了 Div2 但注释说实际用 Div4,因为 84MHz 二分频后 42MHz 超过了 ADC 最高 36MHz 的限制。这是新手常踩的坑,后面避坑章节会细说。ADC_SampleTime_84Cycles 对应 84 个 ADC 时钟周期,加上 12 个周期的转换时间,总转换时间 96 个周期,在 36MHz ADC 时钟下约 2.67μs,足够 20kHz 采样率使用。
3. DMA 双缓冲与 FFT 运算:核心代码逐段拆解
3.1 DMA 双缓冲配置与中断处理
DMA 双缓冲是这套代码的灵魂。配置时用 DMA_DoubleBufferModeConfig 指定第二块缓冲区地址,DMA_DoubleBufferModeCmd 使能双缓冲模式,然后分别设置两个缓冲区的传输长度。中断方面,开启传输完成中断和半传输中断,这样每半块填满就能通知 CPU 处理。
#define FFT_SIZE 1024 float32_t adc_buf_a[FFT_SIZE]; float32_t adc_buf_b[FFT_SIZE]; volatile uint8_t dma_half_flag = 0; volatile uint8_t dma_full_flag = 0; void DMA2_Stream0_Init(void) { DMA_InitTypeDef DMA_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); DMA_InitStructure.DMA_Channel = DMA_Channel_0; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)adc_buf_a; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory; DMA_InitStructure.DMA_BufferSize = FFT_SIZE; 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_FIFOMode = DMA_FIFOMode_Disable; DMA_Init(DMA2_Stream0, &DMA_InitStructure); DMA_DoubleBufferModeConfig(DMA2_Stream0, (uint32_t)adc_buf_b, DMA_Memory_0); DMA_DoubleBufferModeCmd(DMA2_Stream0, ENABLE); DMA_ITConfig(DMA2_Stream0, DMA_IT_TC | DMA_IT_HT, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = DMA2_Stream0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); DMA_Cmd(DMA2_Stream0, ENABLE); } void DMA2_Stream0_IRQHandler(void) { if (DMA_GetITStatus(DMA2_Stream0, DMA_IT_HTIF0)) { DMA_ClearITPendingBit(DMA2_Stream0, DMA_IT_HTIF0); dma_half_flag = 1; // 前半块填满,adc_buf_a 可处理 } if (DMA_GetITStatus(DMA2_Stream0, DMA_IT_TCIF0)) { DMA_ClearITPendingBit(DMA2_Stream0, DMA_IT_TCIF0); dma_full_flag = 1; // 后半块填满,adc_buf_b 可处理 } }这段代码里 DMA_MemoryDataSize_HalfWord 对应 ADC 的 12 位数据,虽然 ADC 精度是 12 位,但数据寄存器是 16 位的,所以用半字传输。DMA_Mode_Circular 让 DMA 自动循环,不用每次重新配置。中断里只置标志位,不做实际处理,这是嵌入式开发的常规操作——中断要短,处理放主循环。
3.2 FFT 运算与加窗处理
FFT 用 CMSIS-DSP 库的 arm_cfft_f32 函数。这个函数要求输入是复数数组,格式是实部、虚部交替排列。ADC 采到的是实数,所以要先把实数转成复数格式,虚部填 0。加窗是为了减少频谱泄漏,常用汉宁窗。窗函数系数可以用 arm_hanning_f32 生成,也可以预先算好存数组里。
#include "arm_math.h" #define FFT_SIZE 1024 float32_t fft_input[FFT_SIZE * 2]; // 复数格式:实部、虚部交替 float32_t fft_output[FFT_SIZE]; float32_t window[FFT_SIZE]; arm_cfft_instance_f32 fft_inst; void FFT_Init(void) { arm_cfft_init_f32(&fft_inst, FFT_SIZE); arm_hanning_f32(window, FFT_SIZE); // 生成汉宁窗系数 } void FFT_Process(float32_t *adc_data) { uint32_t i; // 1. 加窗并转复数格式 for (i = 0; i < FFT_SIZE; i++) { fft_input[2 * i] = adc_data[i] * window[i]; // 实部 fft_input[2 * i + 1] = 0.0f; // 虚部 } // 2. 执行 FFT arm_cfft_f32(&fft_inst, fft_input, 0, 1); // 3. 计算幅值谱 arm_cmplx_mag_f32(fft_input, fft_output, FFT_SIZE); // 4. 只取前一半(对称) // fft_output[0] 到 fft_output[FFT_SIZE/2 - 1] 有效 }arm_cfft_f32 的第三个参数是 ifftFlag,0 表示正变换,1 表示逆变换。第四个参数是 bitReverseFlag,1 表示输出按位反转顺序排列,这样频率分量就是顺序的。arm_cmplx_mag_f32 计算复数模值,结果存在 fft_output 里。注意 FFT 输出是对称的,前一半对应 0 到采样率/2 的频率,后一半是镜像,不用管。
3.3 主循环与频谱输出
主循环里轮询标志位,哪个缓冲区满了就处理哪个。处理完把标志位清零,等下一次中断。输出可以用串口打印,也可以驱动 LCD 画柱状图。
int main(void) { SystemInit(); ADC1_Init(); DMA2_Stream0_Init(); TIM2_Init(); FFT_Init(); USART1_Init(115200); while (1) { if (dma_half_flag) { dma_half_flag = 0; FFT_Process(adc_buf_a); USART_SendSpectrum(fft_output, FFT_SIZE / 2); } if (dma_full_flag) { dma_full_flag = 0; FFT_Process(adc_buf_b); USART_SendSpectrum(fft_output, FFT_SIZE / 2); } } }这里有个细节:FFT_Process 里对 adc_buf_a 操作时,DMA 正在填 adc_buf_b,所以不会冲突。但要注意 adc_buf_a 是 float32_t 类型,而 DMA 搬过来的是 uint16_t 数据。实际工程里通常定义 uint16_t 的 DMA 缓冲区,然后在 FFT_Process 里转成 float 并归一化。上面代码为了简洁直接用了 float 数组,但 DMA 配置里数据宽度是半字,这里类型不匹配,实际会出问题。正确做法是定义 uint16_t dma_buf[FFT_SIZE],DMA 填完后在 FFT_Process 里做类型转换和归一化。
4. 避坑与常见问题排查
4.1 ADC 采样值跳动大或始终为 0
现象:串口打印的 ADC 原始值在 0 和 4095 之间乱跳,或者一直是 0。原因通常是 GPIO 没配置成模拟输入模式。STM32F407 的 GPIO 默认是浮空输入,直接配 ADC 通道不会自动切换模式。解决:在 ADC 初始化前调用 GPIO_Init 把 PA0 设为 GPIO_Mode_AN。另外检查 ADC 时钟是否使能,ADC_CommonInit 里的预分频是否超限。如果值一直是 0,用万用表量一下 PA0 电压,确认信号真的送到了引脚。
4.2 DMA 传输不触发或只传一次
现象:DMA 中断只进一次,之后再也不进。原因可能是 DMA 模式配成了普通模式而不是循环模式,传完一次就停了。解决:把 DMA_Mode 改成 DMA_Mode_Circular。另一个可能是 ADC 的 DMA 请求没使能,检查 ADC_DMACmd 是否调用。还有一点容易忽略:DMA2 的时钟在 AHB1 总线上,要确保 RCC_AHB1PeriphClockCmd 使能了 DMA2 时钟。
4.3 FFT 结果全是噪声或频率对不上
现象:频谱图上一片杂乱,找不到信号峰值。原因有几个:一是没加窗,频谱泄漏严重;二是采样率和 FFT 点数不匹配,导致频率轴算错;三是 ADC 数据没归一化,幅值太大溢出。解决:先加汉宁窗,再把 ADC 原始值除以 4096 归一化到 0 到 1 之间。频率轴计算公式是 f = i * fs / N,其中 i 是谱线索引,fs 是采样率,N 是 FFT 点数。如果峰值出现在错误位置,检查定时器触发频率是否等于预期采样率。
4.4 串口输出乱码或丢数据
现象:串口助手收到乱码,或者数据断断续续。原因通常是波特率不匹配,或者串口发送阻塞了主循环。解决:确认 USART1 的波特率和串口助手设置一致,STM32F407 的 USART1 挂在 APB2 上,时钟 84MHz,波特率 115200 时 BRR 寄存器值要算对。如果丢数据,改用 DMA 发送或者降低发送频率,别在 FFT 处理后立刻发大量数据。
4.5 程序跑一段时间后死机
现象:运行几分钟后程序卡死,看门狗复位。原因可能是中断优先级冲突,DMA 中断和定时器中断互相打断导致栈溢出。解决:检查 NVIC 优先级分组,确保 DMA 中断优先级高于定时器中断,但不要设成最高。另外检查栈大小,FFT 运算和浮点运算比较吃栈,启动文件里的 Stack_Size 建议改成 0x800 以上。
5. 二次开发与性能优化:从能跑到好用
这套代码跑通之后,下一步就是按自己的需求改。如果你要分析更高频率的信号,比如 100kHz 以上的振动,采样率得往上提。STM32F407 的 ADC 在 12 位精度下最高约 2.4MSPS,但那是单次转换速度,实际用定时器触发时受限于定时器频率和 DMA 带宽。想再快可以降低 ADC 分辨率到 10 位或 8 位,转换时间缩短,采样率能翻倍。另一个思路是用 ADC 的交替模式,ADC1 和 ADC2 交替采样,等效采样率翻倍,但代码复杂度也上去了。
FFT 点数也可以调。1024 点分辨率约 19.5Hz,如果信号频率间隔很小,比如两个峰只差 10Hz,那就得用 2048 或 4096 点。但点数翻倍,运算量也翻倍,STM32F407 带 FPU,跑 1024 点 FFT 大概几十微秒,2048 点一百多微秒,4096 点三百微秒左右。如果主循环里还有其他任务,得算好时间余量。我一般会先跑 1024 点,不够再往上加,别一上来就 4096,内存和算力都吃不消。
输出方式也值得改。串口打印频谱数据适合调试,但实际产品里通常要驱动 LCD 画图。用 STM32 的 FSMC 接口驱动 ILI9341 这类屏幕,把 fft_output 的幅值映射成柱状图高度,刷新率能到 30fps 以上。如果要保存数据,可以加一个 SD 卡,用 FatFS 文件系统把原始 ADC 数据或频谱数据写成 CSV,方便后续在电脑上分析。
还有一个容易忽略的点:ADC 的参考电压。STM32F407 的 VREF+ 默认接 VDDA,也就是 3.3V,但实际 LDO 输出可能有几十毫伏波动,导致测量值有偏差。如果对精度要求高,可以外接一个高精度基准源,比如 2.5V 的 REF5025,接到 VREF+ 引脚上。这样 ADC 的满量程就是 2.5V,计算实际电压时用 2.5 而不是 3.3。
最后说一个我踩过的坑:DMA 双缓冲模式下,如果处理速度跟不上采集速度,比如 FFT 运算时间超过了半缓冲区填满的时间,就会丢数据。这时候要么降低采样率,要么减少 FFT 点数,要么优化 FFT 代码——比如用汇编优化的 arm_cfft_f32 替代 C 版本,或者把浮点运算改成定点运算。从那以后我每次调采样率之前,都会先算一遍 FFT 耗时和缓冲区填充时间的比值,确保处理时间不超过采集时间的一半,留足余量。希望帮到你。
本文还有配套的精品资源,点击获取