简介:面向STM32F407嵌入式开发者的ADC采样与FFT频谱分析完整工程,围绕内置ADC、定时器与DMA三者的协同工作,实现了512kHz、256kHz、128kHz三档采样频率切换,并能将输入电压经FFT变换后的频谱结果通过串口实时打印。工程共包含187个文件,压缩包约4.85MB,以58个h头文件、47个c源文件为主,同时附有uvprojx工程配置、o与crf等编译中间文件、axf与hex可烧录文件,以及链接脚本、调试配置和清理脚本,可直接用MDK打开工程进行编译、烧录与二次开发,也能通过hex快速验证功能。项目源码覆盖定时器触发ADC、DMA数据搬运、DSP库FFT计算、串口输出等关键流程,支持自由修改采样点数与分频系数,便于对比不同参数下的频谱泄漏与分辨率变化,是单片机进阶和数字信号处理入门的有价值参考资料。目前已有5782人学习下载。 做单片机开发久了,你会发现很多项目说到底就是三件事:把信号采进来、把数据搬走、把结果算出来。去年我做一个电机振动监测的小东西,在STM32F407上恰好把“ADC采样 + DMA搬运 + FFT频谱分析”这条链路完整跑通了。这套组合在电赛、仪器仪表、音频分析甚至电源纹波检测里都太常用了,但网上资料多半只讲其中一块,很少有人把三者串起来讲明白。这篇文章我就从方案选型、参数计算到代码实现、踩坑实录,把完整过程复现一遍,适合刚入门F4平台、或者已经会用ADC但还没搞通DMA和FFT的朋友。
1. 方案选型:为什么是“F407+ADC+DMA+FFT”这套组合
1.1 从需求反推硬件选择
我当时的需求很明确:采集一路模拟信号,频率范围大概在100Hz到10kHz,要实时算出频谱并识别主频。这属于典型的“中等速率信号采集”场景——频率不算特别高,但也不能用老式的“ADC中断+读寄存器”方式硬干,否则CPU资源会被占掉一大半。
STM32F407在这个场景下几乎是教科书级别的选择。它主频168MHz,带硬件FPU和DSP指令,跑FFT不用像M3那样纯靠软件算得吭哧吭哧;内置3个12位ADC,最快转换速率能做到2.4Msps(理论值),实际工程中跑到几百kSPS轻轻松松;再加上DMA2的8个Stream,足够把ADC转换结果无缝搬运到内存。相比用外部ADC芯片,F407的内置ADC配合DMA,省掉了SPI通信时序的麻烦,开发效率高不少。
1.2 DMA的价值不只在于“不用CPU管”
很多新手对DMA的理解就是“不用CPU干预自动搬数据”,这个理解没错,但对DMA的认知如果停在这层,后面配置容易翻车。DMA真正厉害的地方是它能把“外设事件触发”和“内存写入”解耦。ADC每转换完一次就会产生一个数据寄存器满的事件,如果让CPU去响应这个事件,频率一高,中断嵌套就会把系统拖垮。
用DMA之后,ADC模块会直接把转换结果写进连续的内存缓冲区,缓冲区满了才触发一次完成中断。我习惯把缓冲区设为1024个采样点,这样CPU大约每50毫秒才需要处理一次,而在这50毫秒里,CPU可以跑UI、存数据、做其他任务。这种“批量搬运”的思路,比“一边采集一边处理”要舒服得多。
1.3 CubeMX快速搭骨架
现在的开发方式和我十年前刚入行时完全不同,直接用STM32CubeMX生成工程骨架,比自己手写寄存器代码省太多时间。我选择的是正点原子探索者F407开发板,主控是STM32F407ZGT6。在CubeMX里选好芯片后,按下面几步配置即可:
- 时钟树配到168MHz(HSE 8MHz,PLL倍频等标准配置)。
- ADC1使能,选择IN0通道(引脚为PA0),分辨率12位,软件触发,连续转换。
- DMA Settings里为ADC1添加一个DMA请求,会自动对应到DMA2 Stream0 Channel0。
- 关键一步:DMA传输模式必须选Circular(循环模式),否则缓冲区填满一次就罢工了。
- 生成代码,选HAL库。
这里插一句:不要觉得用CubeMX就是“不硬核”,工具的目的就是把重复劳动交给机器,让你把精力放在算法和调试上。我后面所有关键细节还是会解释到底层原理,不会让你只会点鼠标而不知道发生了什么。
2. 关键参数计算与DMA配置拆解
2.1 ADC采样时钟与转换时间的计算逻辑
ADC这块是个容易出问题的重灾区,很多人配置完发现采样速率不对,就是因为没搞清楚ADC的时钟树。F407上ADC挂在APB2总线上,APB2最高频率是84MHz,但ADC本身最快只能跑36MHz。所以ADC模块前要加一级分频器,CubeMX里对应ClockPrescaler参数。
我选择的是4分频,ADCCLK = 84MHz / 4 = 21MHz。这个频率没有超过36MHz的限制,又是整数分频,计算采样时间方便。单次12位转换的时间公式是:
Tconv = (采样时间 + 12个周期) / ADCCLK其中12个周期是12位逐次逼近寄存器(SAR)本身需要的转换周期数。采样时间可以在CubeMX里配置,从3周期到480周期可选。我实际用的采样时间试过两种:
- 3周期:Tconv = (3 + 12) / 21MHz ≈ 714ns,理论采样率约1.4Msps。
- 84周期:Tconv = (84 + 12) / 21MHz ≈ 4.57µs,理论采样率约218kSPS。
对于10kHz以内的信号,218kSPS绰绰有余。但这里有个很容易踩的坑:采样时间不是越小越好。采样时间本质上是给ADC内部采样电容充电的时间,如果信号源内阻大或者电路走线长,采样时间太短会导致电容没充满,测出来的电压值偏低。我在测一个经过100kΩ分压电阻的信号时,用3周期采样时间,数值比实际值低了将近5%。所以工频环境下建议采样时间至少选15周期以上,有高阻信号源时选84周期更稳。
2.2 DMA配置的四个关键点
DMA配置界面上看起来选项很多,但真正影响这颗芯片命运的只有四个点。
第一个是数据宽度,外设端选Half Word(16位),内存端也选Half Word。原因很简单:F407的ADC是12位,存放在寄存器低16位中,用16位搬运刚好匹配。选错宽度会出现数据错位或者只能采到高8位的问题。
第二个是内存地址增量,必须使能。ADC每次转换完产生的是单个数据,但DMA要把这一个个数据依次存到数组里,如果内存地址不加增量,所有的数据都会覆盖到同一个地址上,缓冲区永远只有一个有效值。
第三个是Circular模式。这个太重要了,我单独说。Circular模式下,DMA传输完毕后会自动把内存地址指针重新指向缓冲区首地址,然后继续搬运,两块缓冲区轮流使用。对于持续采集场景,Circular模式是刚需。如果你的项目只需要单次采集若干点,才用Normal模式。
第四个是FIFO直接模式。F407的DMA自带FIFO,默认情况下CubeMX会把它设置在Direct模式(直通)。对于ADC搬运这种单次存取数据量固定的场景,直接模式够用且延迟最小。不需要纠结FIFO阈值配置,保持默认即可。
启动ADCDMA采集的核心代码非常简单,但顺序不能错。正确流程是:先使能DMA中断,再调用HAL_ADC_Start_DMA()。如果反过来,ADC可能已经启动生成第一次转换结果,但DMA还没准备好接受数据,就会出现丢点。
// 开启DMA流中断 HAL_NVIC_EnableIRQ(DMA2_Stream0_IRQn); // 启动ADC DMA采集,结果存入adc_buf,共采集4096个点 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 4096);2.3 触发方式选择:定时器触发还是软件连续?
CubeMX里ADC触发方式有两种主流选择:软件触发和外部定时器触发。我第一版用的是软件连续转换,也就是ADC启动后自己一直转,DMA跟着一直搬。好处是配置简单,坏处是采样频率完全由ADC自身时钟决定,精度取决于时钟源的准确性。如果MCU系统时钟锁相环频率有微小偏差,采样率也会跟着偏。
如果对频率准确性有要求,比如做音频分析或者电力谐波检测,更专业的做法是用一个定时器产生触发信号,控制ADC每次采样的时间间隔。这样采样率由定时器的预分频和重装载值决定,能做到非常精准。我在项目中后期把这套逻辑优化过一遍:用TIM2产生1kHz的PWM输出作为ADC外部触发源,每个PWM上升沿触发一次转换,采样率就严格等于1kHz。
两种方式都可行,看你的应用场景。如果只是快速看看波形、做实验,软件连续转换完全够用;要是做定量分析、需要严格等时间间隔采样的场合,上定时器触发。
3. FFT落地:从CMSIS-DSP到真实频谱
3.1 为什么不自己写FFT而是用CMSIS-DSP
我见过不少人一提到FFT,就跃跃欲试想自己手写蝶形运算。快刀斩乱麻说一句:能用库就别自己造轮子。ARM官方提供的CMSIS-DSP库已经针对Cortex-M4的FPU和DSP指令做了深度优化,Michael基本上把循环展开、饱和运算这些细节都优化到位了,自己写出来的代码很难在速度和资源占用上超越它。
CMSIS-DSP里,FFT相关函数分成两类:arm_cfft_f32用于复数FFT,arm_rfft_f32用于实数FFT。因为我的输入是ADC采样得到的实数序列,理论上用rfft更省内存,但我实际工程里用的是cfft。原因也很实在:把实数序列填充到复数数组的实部、虚部置0,这个操作逻辑最直观,而且cfft函数的蝶形运算在F407上执行时间依然很快,1024点FFT实测只要几百微秒,完全够用。
3.2 从ADC原始数据到频谱幅值
整套流程分三步:第一步把ADC采到的原始整数数组转换成浮点电压值;第二步按CMSIS-DSP要求的排列方式填充到复数输入数组;第三步调FFT计算,再取模得到幅值谱。
先说第一步。ADC值是0到4095的整数,对应0V到3.3V。代码里我直接减去2048(偏移量),这样直流分量归零,频谱看起来更干净,然后除以4096再乘以3.3,得到以伏特为单位的浮点采样值。如果你不关心绝对电压值,相对幅度也够用,减偏移量这步依然推荐做,否则FFT结果里第一个点(直流分量)会异常大,小信号频率分量全都被压得看不见。
第二步和第三步的代码:
#define FFT_SIZE 1024 static uint16_t adc_buf[FFT_SIZE]; // ADC DMA直接往这里填数据 static float32_t fft_input[FFT_SIZE * 2]; // 实部虚部交替存放 static float32_t fft_mag[FFT_SIZE / 2]; // 幅值谱结果 void Process_FFT(void) { // 1. 把ADC原始值转成浮点电压值,并去掉直流分量 for (int i = 0; i < FFT_SIZE; i++) { fft_input[2 * i] = (float32_t)(adc_buf[i] - 2048) * 3.3f / 4096.0f; fft_input[2 * i + 1] = 0.0f; // 虚部填0 } // 2. 1024点复数FFT,正变换,位反转启用 arm_cfft_f32(&arm_cfft_sR_f32_len1024, fft_input, 0, 1); // 3. 计算幅值:sqrt(Re^2 + Im^2) arm_cmplx_mag_f32(fft_input, fft_mag, FFT_SIZE / 2); }arm_cfft_f32输出的数组是按特定的蝶形运算顺序排列的,前面 FFT_SIZE/2 个点就是从0Hz到Nyquist频率(采样率一半)的频谱分量。arm_cmplx_mag_f32会从输入的复数数组中取出实部虚部求模,正好生成幅值谱。补充一句,CMSIS-DSP库需要在Keil的Pack Manager或者STM32CubeMX的Software Packs里单独勾选安装,也可以把源码直接拉进工程,别忘添加arm_math.h头文件,并在代码文件里定义__FPU_PRESENT和ARM_MATH_CM4宏,否则FPU相关指令不会启用。
3.3 频率分辨率与幅度校正
一个频谱图上横坐标每个点代表多少赫兹,这个要心里有数。频率分辨率公式是:
每个频率点代表的宽度 = 采样率 / FFT点数我项目里系统采样率设为20kSPS(这是定时器触发方案下的值),做1024点FFT,那每个点对应约19.53Hz。也就是说,如果你输入的信号是1kHz正弦波,它会在频谱上大约第51个点附近出现峰值,因为1000 / 19.53 ≈ 51.2。如果信号频率不是频率分辨率的整数倍,能量会泄漏到旁边好几个点上,这就是频谱泄漏。
泄漏问题有两个缓解方向:一个是提高频率分辨率,也就是增加FFT点数或降低采样率,但这会加大内存开销或缩小可分析频率上限;另一个是使用窗函数,工程上最常用汉宁窗。我实际测试时发现,不加窗时1kHz的尖峰旁边有两个明显的旁瓣,加上汉宁窗后旁瓣被压下去了,但主峰也会稍微变宽。利大于弊。
幅度方面,FFT结果的绝对值不是信号的物理幅值,需要换算。对于单频正弦波,正确幅值约等于fft_mag[峰] / (FFT_SIZE / 2)。我通常会在系统里放一个已知幅值电压的校准源测一遍,反推出一个比例系数,后面所有测量结果都乘这个系数。比纯理论计算简单,也更符合工程测试的习惯。
4. 调试实录:我把这些坑踩了一遍
4.1 DMA缓冲区数据全是一样的值
这个现象最诡异:adc_buf 里所有值都相同,或者只有第一个值是有效数据,后面全是同一个固定值。排查后发现是DMA的内存地址增量没开启。ADC每次产生新数据,DMA确确实实搬运了,但因为内存地址不递增,数据每次都覆盖到数组首地址,导致看起来“缓冲区所有元素都一样”。
解法很简单,把 DMA_InitStructure 里的DMA_MemoryInc设为DMA_MemoryInc_Enable就行。这个配置在CubeMX里是对应“Memory Increment Address”的勾选项,如果下次遇到类似问题,先检查这两个增量配置。
4.2 采到的电压和万用表测的差了一截
这是我前面提到的高阻抗信号源问题。当时我用一块三极管放大电路输出信号接ADC,电路输出阻抗比较大,结果ADC读数比示波器/万用表测量值低了不少。起先以为是放大电路问题,排查了一圈,最后发现是ADC的采样时间太短——内部采样电容还没稳定,外部信号源根本没能力快速充电。
解决思路有两个。优先在硬件上处理,加一个电压跟随器(运放接成跟随器)作为缓冲,降低信号源阻抗。软件上把采样时间从3周期改成84周期,数值立刻准了不少。关键是定位思路:如果ADC读数和实际电压成比例偏小,而不是随机乱跳,大概率就是采样时间不足。
4.3 FFT结果全是NaN或者乱跳
这个问题刚把FFT代码接进去时遇到过一次,第一反应是算法写错了。后来仔细检查,发现是arm_cfft_f32根本没有被正确执行。原因在于Keil编译器没有启用FPU的硬件浮点指令,编译器默认用软件浮点库,而CMSIS-DSP的f32系列函数要求必须开启FPU硬件指令。
解决步骤:在Keil的Options for Target → Target选项卡里,Floating Point Hardware选“Single Precision”或“FPU”。注意F407是单精度FPU,不要选Double。同时,arm_math.h里建议定义__FPU_PRESENT=1U和ARM_MATH_CM4,这两个宏缺一不可。之后再编译运行,FFT结果就正常了。
4.4 频谱图上主频旁边出现“鬼影”频率
有一次我在测一个标称1kHz的信号,频谱图上除了1kHz尖峰,还在一千多赫兹的位置出现了不小的峰。排查到最后发现是采样率设置的问题:定时器触发频率设成了19.2kHz而不是整数,实际采样率与预期差了一点,导致真实信号的频率落在两个频率分辨率点之间,频谱泄漏加剧,旁瓣“糊”成一片,看起来就像多个频率成分。
解决方法是把定时器触发频率调准,同时加汉宁窗处理。加窗不是万能的,但配合准确的采样率,能大幅压低旁瓣。另外,如果信号本身是好的,但电源有高频纹波,也可能被ADC采进来,在频谱上表现为高频段的毛刺。遇到这种问题,除了软件滤波,还需检查模拟地的布线。
5. 实测效果与一些个人建议
整个链路调通之后,我用信号发生器输出了1kHz、幅值1V的正弦波,通过PA0接入。我用20kSPS的采样率采样,收集1024个点,跑一次FFT,在主频1kHz附近看到了非常明显的尖峰,幅值换算后和示波器的读数误差在2%以内。这个精度对于大多数监测场景已经足够了。
如果你想把这套系统移植到自己的板子上,我有几点实际建议。第一,ADC引脚附近不要走高频数字信号线,F407的ADC引脚对噪声比较敏感,布线不好会直接影响频谱底噪。第二,DMA中断里不要做耗时操作,建议设置一个标志位,把FFT计算放到主循环中处理,否则中断里跑FFT不仅耗时,还容易造成其他中断阻塞。第三,如果你以后要扩展多通道采集,记得把ScanConvMode设为ENABLE,并在配置里指定通道序列和每个通道的采样时间,DMA缓冲区大小也要相应乘以通道数。
做这套项目的过程中,我最大的体会是:嵌入式工程大部分时间不是在写代码,而是在理解硬件行为。ADC的采样时间、DMA的循环模式、FFT的排列规则,每一个环节看起来都是几个寄存器配置的事,但它们之间是强耦合的,一步配错,前面全白干。另外我也建议你把数据拆出来放上位机看——我现在习惯把采到的ADC数组通过串口发到Python里画一下,再做FFT对比,调试效率比只看串口打印高太多。最后再分享一个实用细节:尽量把ADC原始数据和FFT结果分开存储,调试时你有原始数据在手,随时可以离线复算,定位问题会快很多。
本文还有配套的精品资源,点击获取