STM32 F407 ADC+DMA连续采样与FFT频谱分析实战

发布时间:2026/10/20 11:41:01
STM32 F407 ADC+DMA连续采样与FFT频谱分析实战
简介针对STM32F407平台上ADC采样与FFT频谱分析需求的工程示例包面向嵌入式开发者、电子竞赛参赛者及数字信号处理入门者。基于内置ADC通过定时器触发与DMA搬运实现连续采样采样频率可在512kHz、256kHz、128kHz间切换FFT结果经串口打印也可调整采样点数便于直观对比不同参数下的频谱变化。压缩包共187个文件以.c源文件与.h头文件为主涵盖stm32f4xx_tim、stm32f4xx_adc、stm32f4xx_rcc等外设驱动源码以及LCD显示与DSP库相关组件同时附有.uvprojx工程文件、.hex烧录文件、.axf/.map编译输出整体仅4.85MB。目前已有5795人学习/下载。除完整工程源码和编译中间文件外还保留调试配置可打开工程直接复现实验配合定时器、DMA、ADC联动配置及FFT处理流程读者能快速掌握外设组合应用的工程结构并延伸至波形分析、谐波检测等场景。启动文件与链接脚本一并包含便于移植到其他F4系列芯片进行二次开发。1. 一块 F407 就能玩转 STM32 FFT从 ADC 连续采样到串口频谱的完整闭环很多人学 STM32 FFT第一想法是找个 DSP 库把 ADC 的一串数组丢进去跑完事。真跑起来才发现频谱里要么直流分量大到把信号盖住要么 DMA 搬着搬着数据串位出来的是噪声刷屏。这个STM32F407_ADC_DMA_FFT工程把“模拟信号采集、DMA 搬运、时域数据归整、256 点 FFT、幅值输出”做了最小闭环板子上的片内 ADC 加 DMA 循环模式就能连续往内存里写数据主循环取出一个固定长度窗口做浮点 FFT最后通过串口把每个频点的幅度传出来。适合想做音频频谱显示、振动监测、电源纹波分析又想先在一张 F407 开发板上把链路走通的人。这份工程不追求花哨显示重点是让你从“代码能编译”走到“频谱能测准”。2. 技术选型为什么是 ADC 连续扫描 DMA 循环 256 点 FFT2.1 项目定位与适合谁我把它定位成一个“FFT 最小可复现系统”。不是高保真音频分析仪不是高速采集卡而是把采集、搬运、变换、输出这一整条链路跑通并且留出足够的二次开发余地。整个工程没有接 LCD只靠串口打印结果这样做的好处是每一环节都能通过调试器或串口独立验证ADC 有没有输出、DMA 有没有循环搬完、FFT 的谱峰和信号频率对不对得上。适合两类人。一类是刚接触数字信号处理的初学者想知道 F407 上的 ADC 到底能不能配合 DMA 做实时 FFT另一类是公司里做状态监测预研的工程师先把一套最简单的信号链跑起来再接传感器和信号发生器的真实信号做验证。在这份工程上接上一个正弦波发生器就能在串口数据里看到对应频率的谱峰效果直观。2.2 F407 的 ADC、DMA 资源分配与映射F407 内部有 3 个 12 位 ADC每个最多 16 个通道支持扫描、连续、间断模式。工程中只使用 ADC1开了两个通道。其中一个通道接入信号另一个通道空闲保留后续可以做双通道校准。ADC1 的 DMA 请求固定映射到 DMA2 Stream0这是芯片手册写死的映射关系不是想改就能改的。我整理了一张映射表方便以后换 ADC 模块时快速对照无无无无其实这张表是多余的而且还有奇怪的“无”不要使用。我会替换为正常表格ADCDMA 流通道方向ADC1DMA2 Stream0Channel 0外设到内存ADC2DMA2 Stream2Channel 1外设到内存ADC3DMA2 Stream1Channel 2外设到内存注意DMA 的“通道”和 ADC 的“扫描通道”是两个不同概念这里写的是 DMA Stream 对应的请求通道号。之前有同事在这个地方选错代码编译没问题但 DMA 永远不触发。DMA 使用循环模式这是这份工程最核心的选择。循环模式让 DMA 向一个固定大小的数组连续写入写满后自动回卷不需要主程序反复调用启动函数数据流不会因为处理代码的执行时间而中断。缺点是你不知道 DMA 当前正写到数组的哪个位置如果主程序恰好读到 DMA 正在改写的元素就会得到撕裂的数据。解决办法是把数组长度设为 FFT 点数的两倍再做乒乓切换也就是利用 DMA 的半传输中断和传输完成中断来区分前半段和后半段。采样率方面我按官方推荐的时钟链路PCLK2 为 84MHzADC 分频设为 4得到 21MHz 的 ADC 时钟12 位转换需要 12 个 ADC 时钟周期加上采样时间单通道连续转换能力可以到 200kSPS 以上。这个数值对音频和多数振动监测都偏高了所以实际工程中我会在串口打印或者数据处理里主动等下一个 FFT 窗口完成不需要刻意降频。真正均匀的时间间隔要靠内部定时器触发初版先采用软件连续触发代码更简单后文二次开发章节会讲怎么改成外部触发。2.3 软件框架HAL 库为主寄存器操作为辅我在不少项目里习惯直接用标准外设库或者寄存器操作风格比较直接。但这份工程最终用 HAL 库封装主要理由是 STM32CubeMX 可以稳定生成 DMA 映射和中断回调代码出错率低而且后来者想加一个通道在图形工具里点两下就能重新生成不用去翻参考手册里的 DMA 请求表。用 HAL 库有个必须注意的初始化顺序DMA 初始化一定要在 ADC 初始化之前。如果顺序反过来HAL_ADC_Start_DMA 执行时 DMA 句柄还没有关联到 hadc1结果就是 ADC 在转、DMA 不搬数。工程里默认把MX_DMA_Init放在MX_ADC1_Init前面手动改代码时需要保持这个规则。还有一个容易被忽略的习惯HAL 库的HAL_ADC_ConvCpltCallback和HAL_ADC_ConvHalfCpltCallback是弱回调用户必须自己实现同名函数编译器不会报错但跑起来没有任何反应。有些人以为回调已经处理了数据实际什么也没做。工程里这两个回调都显式实现了并在里面置分段标志主循环再根据标志去处理数据避免在中断里直接跑 FFT。3. 目录结构与核心代码拆解一次采样到频谱输出的完整链路3.1 工程文件结构与职责拿到源码包第一件事先看文件划分不要急着编译。这套工程没有把所有逻辑压进 main.c而是拆成了比较清晰的四块文件职责Core/Src/main.c主循环、任务调度、状态切换Core/Src/adc.cADC 初始化、DMA 启动Core/Src/dma.cDMA 中断回调、乒乓缓冲区下标管理App/fft_engine.c时域窗口取出、FFT 计算、幅值求解Core/Src/usart.c串口发送、printf 重定向App/fft_engine.c 是后来单独加的文件工程在原始生成基础上把 FFT 相关逻辑从 main.c 里抽出来了。这样做的目的是让你想换成 1024 点 FFT或者想加窗函数只用改 fft_engine.c不必去中断里翻代码。3.2 ADC 扫描模式与 DMA 循环配置代码adc.c 负责搭好采集端的“水龙头”。以下是我在工程里实际使用的初始化参数// adc.c ADC_HandleTypeDef hadc1; DMA_HandleTypeDef hdma_adc1; void MX_ADC1_Init(void) { hadc1.Instance ADC1; hadc1.Init.ClockPrescaler ADC_CLOCK_SYNC_PCLK_DIV4; // 21MHz ADC时钟 hadc1.Init.Resolution ADC_RESOLUTION_12B; // 12位精度 hadc1.Init.ScanConvMode ADC_SCAN_ENABLE; // 扫描模式 hadc1.Init.ContinuousConvMode ENABLE; // 连续转换 hadc1.Init.DiscontinuousConvMode DISABLE; // 不用间断模式 hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; // 软件触发 hadc1.Init.DMAContinuousRequests ENABLE; // DMA循环请求 hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; // 右对齐 HAL_ADC_Init(hadc1); }这段代码决定了 ADC 以连续扫描方式工作两个通道会依次转换转换结果按通道顺序在 DMA 里排列。ClockPrescaler选PCLK_DIV4是因为我外部时钟树里 PCLK2 已经是 84MHz4 分频后得到 21MHz低于 F407 允许的 36MHz 上限留有余量。ScanConvMode必须开否则两个通道只转第一个。DMAContinuousRequests这一项很关键它让 ADC 每次转换完成后自动向 DMA 发请求形成连续流水。DMA 部分比较长核心是方向和数据宽度// dma.c static void MX_DMA_Init(void) { __HAL_RCC_DMA2_CLK_ENABLE(); hdma_adc1.Instance DMA2_Stream0; hdma_adc1.Init.Channel DMA_CHANNEL_0; // ADC1请求通道 hdma_adc1.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc DMA_PINC_DISABLE; hdma_adc1.Init.MemInc DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemoryDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode DMA_CIRCULAR; // 循环模式 hdma_adc1.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_adc1); __HAL_LINKDMA(hadc1, DMA_Handle, hdma_adc1); }这里PeriphDataAlignment和MemoryDataAlignment都选了半字因为 ADC 是 12 位转换结果保存在 16 位寄存器里。很多新人习惯性把它们配成字宽DMA 一次搬 32 位会把两个通道的值拼在一起数据直接错位。DMA_CIRCULAR是循环模式的核心DMA 写完数组末尾后自动回到开头不需要任何干预。启动代码放在 main 里一行就够// main.c #define ADC_BUF_LEN 512 // 双通道 2 组数据实际每通道 256 点 static uint16_t adc_buf[ADC_BUF_LEN]; HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, ADC_BUF_LEN);注意 HAL 库最后一个参数是传输次数不是字节数。这里传 512DMA 每次搬运一个半字一共搬运 512 次刚好填满 512 元素的数组。数组类型必须是uint16_t如果你定义成uint32_t然后强制指针转换DMA 就会按半字搬运浪费一半空间而且最后处理时会多出一倍长度。3.3 DMA 半传输完成中断与乒乓分段处理DMA 在循环模式下持续覆盖 adc_buf主循环拿到的每一个数据都有可能和 DMA 的写入发生竞争。解决思路是把数组从中间分成两块前半段和后半段交替使用。DMA 半传输产生一次中断表示前半段写完了传输完成产生一次中断表示后半段写完了。主循环处理完某段时DMA 正在写另一段这样就错开了访问冲突。// dma.c static volatile uint8_t dma_seg_ready 0; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { dma_seg_ready 1; // 前半段 0~255 可用 } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { dma_seg_ready 2; // 后半段 256~511 可用 } }主循环里的读取逻辑是这样的uint8_t last_seg 0; while (1) { if (dma_seg_ready ! last_seg) { uint16_t start (dma_seg_ready 1) ? 0 : 256; process_fft_block(start); last_seg dma_seg_ready; } }通过start参数告诉 FFT 计算函数该从 adc_buf 的哪个位置取数。这样 DMA 写数据时读的是另一段两者不会碰到同一个元素。很多资料里叫“双缓冲”严格讲 F407 的 DMA 本身没有硬件双缓冲这个方案是通过中断标志实现类似效果但足够稳定。3.4 FFT 计算代码与频谱坐标换算fft_engine.c 承担从原始值到幅值谱的全部运算。核心分三步去掉直流偏置、做实数 FFT、求每个频点幅度。下面是实际代码// fft_engine.c #include arm_math.h #define FFT_SIZE 256 static float32_t time_buf[FFT_SIZE]; static float32_t fft_out[FFT_SIZE * 2]; static float32_t mag_out[FFT_SIZE]; void process_fft_block(uint16_t start) { uint16_t i; for (i 0; i FFT_SIZE; i) { time_buf[i] ((float32_t)adc_buf[start i] - 2048.0f) / 2048.0f; } arm_rfft_fast_instance_f32 fft_inst; arm_rfft_fast_init_f32(fft_inst, FFT_SIZE); arm_rfft_fast_f32(fft_inst, time_buf, fft_out, 0); arm_cmplx_mag_f32(fft_out, mag_out, FFT_SIZE / 2); }首先12 位 ADC 原始值是 0 到 4095把每个值减去 2048相当于把信号中直流偏置拉到零。如果不做这一步FFT 结果的第 0 频点会大得离谱后续画图时整个纵轴都被它占满。除以 2048 是为了让幅值范围落在正负 1 附近方便和不同输入范围的信号对比。arm_rfft_fast_init_f32每次进入都重新初始化有点浪费因为 FFT 大小不变时只需要初始化一次。实际工程我把它放到了模块初始化函数里。arm_rfft_fast_f32最后一个参数传 0 表示正向 FFT传 1 是逆变换别写反了。输出 fft_out 是长度为 2 * N 的数组里面交替存放实部和虚部。计算幅度谱时只需要前 N/2 个复数点所以给arm_cmplx_mag_f32的长度是FFT_SIZE / 2结果对应 0 到奈奎斯特频率之间的 128 个频点。最后确定频率分辨率。假设实际采样率设为 40kHz那么频率分辨率就是Δf Fs / FFT_SIZE 40000 / 256 156.25 Hz意思是每根谱线代表 156.25Hz 的频带。你想分辨 1Hz 级别的信号必须提高 FFT 点数或降低采样率。这是 FFT 点数固定的结果不是程序 bug。程序里我把这个计算单独抽了一个函数让串口输出直接以 kHz 为单位的频率标签。4. 把工程跑起来从图形化初始化到串口画频谱4.1 硬件接线与输入保护硬件需求非常简单一块带 STM32F407 的板子一个串口转 USB 模块一个信号发生器或者可调电源。把信号源的输出接到 ADC 通道对应引脚我默认把通道 1 接到 PA1通道 2 接到 PA2。注意 F407 的 ADC 输入范围是 0 到 3.3V超过可能烧引脚。信号发生器最好串一个 1kΩ 电阻再进引脚这是最简单的保护手段。我刚开始做这个实验时直接用函数发生器输出 5V 正弦波结果看到频谱上一堆奇次谐波后来发现是信号超出输入范围导致削波。做 FFT 实验前先用万用表确认信号峰峰值在 3.3V 以内这是最容易踩的坑。4.2 图形配置工具里必须勾选的项工程不是从零手写的而是用图形配置工具先生成框架再往 main 里加逻辑。如果你要复现建议按下面顺序操作新建 F 系列工程芯片选 F407VET6 或同系列时钟树把主频配到 168MHzAPB2 分频器选 2保证 ADC 时钟可控。在外设配置面板里打开 ADC1使能通道 1 和通道 2都设成采样时间较长的一档比如 84 周期这样输入阻抗较大时也能测得准。DMA 选项卡勾选 ADC1 的请求流选 DMA2 Stream0方向外设到内存内存地址递增打开数据宽度半字。NVIC 里把 DMA 中断打开半传输中断和传输完成中断都要勾上否则前面的回调和分段逻辑不会执行也不会有提示。工程生成后需要手动把 CMSIS-DSP 库添加进去。在文件搜索路径里加入 arm_math.h 的位置然后添加对应的浮点库文件。我的经验是选择带有M4和lf标记的静态库版本lf表示 little-endian 浮点F407 正好匹配。这样编译出来的 FFT 计算速度比纯软件模拟要快得多因为会用到底层指令集。4.3 串口输出与波形观测编译烧录之后板子就会持续向串口发送格式化文本。常用频率超过 100 行数据避免一屏刷满导致看不到重点。输出格式我定义为printf(%d,%.3f\r\n, i * freq_resolution_hz, mag_out[i]);第一列是频率第二列是幅度。可以用串口助手看日志也可以接到上位机绘图软件里直接用 CSV 格式去描点。如果没有信号源拿一个电位器从 3.3V 和地之间分压输入到 PA1也能看到直流分量的幅值变化验证 ADC 和 DMA 链路是通的。这里还有一个验证 FFT 是否准的小技巧用另一块开发板输出一个 PWM 信号经过 RC 低通滤波后变成一个近似正弦波接到 ADC 输入。PWM 频率是你自己配的串口输出应该在对应频率处出现一个明显峰其余频点幅度很小。如果你看到的谱峰在正确位置但旁边有一股噪声多半是输入信号本身不够干净不是 FFT 代码问题。5. 常见问题与排查ADC 采不准、DMA 串位、FFT 全是直流5.1 现象一ADC 数据全是 0 或者满量程 4095很常见的现象是在串口里看到原始值要么全 0要么全 4095调整输入电压没有反应。原因多半是引脚初始化不对。ADC 对应的 GPIO 必须配置为模拟输入如果还是默认的推挽输出引脚内部强上拉会把 ADC 采样值拉高或者短路到地导致读数不变化。解决方法是检查 GPIO 初始化把对应引脚设为GPIO_MODE_ANALOG并且配置到 ADC 扫描序列。另一个原因是 ADC 使能太晚启动 DMA 后 GPIO 还没初始化完成。HAL 库生成的MX_GPIO_Init应该放在MX_ADC1_Init之前这样 ADC 采样到的是已经稳定电平。建议停电后先量引脚电压确认外部信号确实能进来。5.2 现象二DMA 缓存里的数据错位两个通道互相串现象是通道 1 和通道 2 数值完全一样或者通道 1 的数据每隔一个点出现一次跳动。这通常是因为 DMA 数据宽度配置成了 32 位而 ADC 结果是 12 位。DMA 一次搬 32 位时会把两个半字合成一个 32 位字于是缓冲区里第一个元素可能是通道 1 加通道 2 的组合按 uint16_t 解析时自然全乱。解决办法很直接把PeriphDataAlignment和MemoryDataAlignment都改成HALFWORD。另外还要检查adc_buf的数据类型是不是uint16_t,不要用uint32_t数组。这两个条件不满足无论怎么调中断优先级都没用因为数据在源头就已经拼错了。5.3 现象三FFT 结果第一个点奇大后面全是平缓噪声第一个点就是我们常说的直流分量。ADC 输入的信号如果是单极性 0 到 3.3V哪怕是一个 1V 峰值的正弦波整体叠加了约 1.65V 直流偏置FFT 第 0 点必然巨大。如果直流分量不去除后面的真实信号在纵轴上几乎看不出变化。处理办法很简单在process_fft_block里减去 2048。我之前在这上面吃过亏以为先对数组取平均再减就行后来发现减平均值虽然也能去直流但平均值本身受噪声影响不如直接减中值稳定。另外如果信号电压范围不是完整的 3.3V减 2048 只是一个近似更精确的做法是先用满量程校准把无信号时的读数作为零点。5.4 现象四一启动 DMA 就进入 HardFault只要调用HAL_ADC_Start_DMA程序立刻跳进 HardFault 的情况多是因为 DMA 流编号或通道号选错。ADC1 的 DMA 请求必须挂在 DMA2 Stream0 的 Channel0 上如果配置成 Stream1 或者 Channel1DMA 外设可能请求不到 ADC1 转换事件但某些库版本会触发错误中断。排查方法是先看调用栈在 HardFault_Handler 里打断点检查故障发生地址。大部分情况下会停在HAL_DMA_Init或第一次 DMA 写内存时。解决方法是回到图形配置工具删除 DMA 配置后重新选择 ADC1 的 DMA request不要手动改句柄编号。另外如果开启了 MPU 或串口调试注意数组地址是否超出 SRAM 范围512 字节的数组在 192KB SRAM 下几乎不可能超但 8 个并排的大数组可能会。5.5 现象五串口打印卡死接收端偶尔多出乱码现象是跑一段时间后串口助手不再刷新重新打开端口还能收到一部分乱码。原因不在 ADC 或 FFT而在 printf 重定向时没有处理好忙等待。HAL 库的串口发送是阻塞的如果上一字节没发完就写下一字节库函数可能一直等在状态标志上导致主循环暂停。我一般会在发送函数里加超时计数而不是无限等待while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TXE) RESET) { if (--timeout 0) return; }这样即使串口线断开主循环也不会死锁。输出乱码还要检查波特率我用 115200 8N1如果上位机配成 9600必然出现乱码。这也是调试中最常见的低级错误。6. 二次开发思路从单通道频谱变成多通道实时分析平台这套代码只做完“能测准”离“好用”还有一段距离。我给你的第一个改造方向是把 FFT 点数从 256 提高到 1024同时把 DMA 缓冲区指针长度改成 2048。注意必须同步修改三个地方ADC_BUF_LEN、process_fft_block里的分段起止位置、以及回调里的dma_seg_ready判断。这三个常量不一致FFT 计算就会把 DMA 写在末尾的数据误认为是有效数据频谱出现周期性抖动。第二个方向是加窗函数。我在做振动分析时遇到过一个典型问题信号频率不是采样频率的整数倍频谱上出现明显泄漏原本尖锐的谱峰变得拖尾。解决办法是在做 FFT 前让时域数据乘以汉宁窗。代码量很小在process_fft_block里加一行time_buf[i] * 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1)));代价是频率分辨率略变差但泄漏抑制效果立竿见影。如果你之后要测多个频率分量这一步几乎是必须的。第三个方向是把单通道扩展到真正的双通道同步分析。F407 有 3 个 ADC但 ADC1 和 ADC2 可以通过外部触发和组合模式同步采样。改造时要把 DMA 流改成 ADC1 和 ADC2 各自独立搬运或者使用 ADC 双模式。这个工程里我已经预留了两个 ADC 通道的扫描项你只需要把第二个通道的信号从电位器换成真正要测的信号FFT 计算部分加一个分支就能同时输出两路频谱。最后提一个我自己的调试习惯每次修改了采样率或 FFT 点数我都会强制检查 DMA 搬运次数、FFT 点数和数组元素个数这三个量是否匹配。有一回只改了采样率忘了改 DMA 数组长度结果频谱图变得像被刀切过浪费了一整晚才定位到问题。从那以后我把这三个常量写在同一处改任何一项时都过一遍整个数据链。希望这份工程和踩坑记录能帮你少走一段弯路顺利把 STM32 FFT 真正用起来。本文还有配套的精品资源点击获取