简介面向全国大学生电子设计竞赛23年H题的STM32F407完整工程代码内容覆盖从AD9959 DDS芯片激励生成、定时器触发ADC采样、DMA高速搬运到FFT频谱计算与GUI结果展示的完整信号处理链路适合正在备赛或想深入学习嵌入式实战开发的本科生和竞赛爱好者。工程基于Keil MDK构建所有代码均经过实测可直接运行能帮助读者快速理解多外设协同配置与中断/DMA时序配合。压缩包共155个文件其中C源码68个、H头文件75个并包含启动汇编文件、链接脚本、Keil工程文件等整体仅589KB目录紧凑、便于按需查阅。目前已有1051人学习使用既可作为电赛H题的完整参考方案也可改装为其他信号采集与实时处理项目的代码底包。 前阵子帮人调试一块基于STM32F407的综合实验板顺手把2023年电赛H题的那套采集、处理、显示的链路整个跑通了。这套代码里最核心的东西其实并不复杂但把ADC采样、TIM触发、DMA搬运、FFT计算、DAC输出的各个环节串起来之后整套系统就变成了一块非常好用的信号分析实验平台。点开这篇东西的朋友大概率是正在准备电赛、或者是刚接触STM32F407信号链开发的学生。这套系统能帮你解决什么问题简单说就是外部输入一个模拟信号经过ADC高速采样DMA自动把数据搬进内存然后跑FFT看频谱还能通过DAC-DMA输出一个已知波形最终所有结果都显示在LCD屏幕上。整个链路我调了两天踩了五六个坑这篇文章把这些过程和经验全部拆开来讲。1. 系统整体设计与方案选型1.1 这套系统到底解决什么问题电赛H题这种信号测量类的题目本质就是在干一件事把一个未知的模拟信号读进来想办法告诉用户这个信号长什么样子。比如频率多少、幅度多大、有没有失真、谐波分量是什么情况。传统做法是示波器加万用表手动测但竞赛场景要求的是自动测量、自动显示所以就必须在单颗MCU内部完成“采集-计算-显示”的闭环。我做的这套系统输入信号经过前端调理电路后接到STM32F407的ADC引脚由定时器产生精确定时触发信号控制ADC以固定采样率工作。ADC转换完的数据不经过CPU干预直接用DMA通道搬运到内存数组里。等采集完一整批数据就调用CMSIS-DSP库里的FFT函数算出频谱信息。与此同时DAC外设配合DMA循环发送可以输出一个测试用的标准波形比如正弦波或者扫频信号方便整个系统自测。最后所有波形、频谱和计算出的参数都渲染到TFT屏幕上。这套方案最有价值的地方在于它把嵌入式里最常见的三大数据流给打通了模拟输入流、数字计算流、模拟输出流。任何一个方向出了问题都会立刻在屏幕上暴露出来。所以我一直觉得这不只是给电赛用的哪怕只是想把STM32F407的ADC和DMA彻底搞明白这套演示也足够典型。1.2 为什么选STM32F407而不是其他芯片先说明一下这套方案如果用F103不是不行但F407在几个关键点上省去了大量麻烦。首先是主频F407跑到168MHz而F103最高只有72MHz。FFT这种大计算量的任务CPU频率直接决定了性能上限。其次是硬件FPUCortex-M4自带的单精度浮点运算单元配合CMSIS-DSP里的arm_cfft_f32函数处理1024点FFT只需要不到1ms。而F103是纯软件算浮点同样处理1024点FFT光计算就要吃掉十几毫秒甚至更久这在实时测量场景里是致命的。另外F407的ADC最高可以跑到2.4Msps的采样率配合DMA的循环模式可以做到不间断采样。再加上它有完整的DAC外设自带输出放大器不需要外扩DAC芯片。这些特性叠加在一起让它成为做信号处理类竞赛最合适的一块料。如果你打算用F103硬抗那光是浮点计算这个坎就会让你调试到怀疑人生。2. FFT核心原理与库函数落地2.1 从ADC的一串数据到频谱图中间发生了什么很多初学者学到FFT只记得“把时域变频域”这句话但真到他写代码的时候就蒙了我到底该调用哪个函数输入数据该放在哪里输出结果怎么跟横轴频率对上号我用大白话解释一遍。ADC采集回来的数据是时间序列横轴是时间纵轴是电压值就像示波器上看的样子。而FFT做的工作是把这串时间序列拆解成一组频率分量的叠加。它算出来的结果每一个点代表一个频率点的幅度和相位。比如输入信号是一个1kHz的正弦波加了一点噪声FFT之后你会在1kHz那个频率点附近看到一个高峰其他的地方幅度都比较低。在STM32上做这个运算不需要自己写蝶形运算的算法直接用CMSIS-DSP库就行。但前提是数据排列必须符合库函数的格式要求。arm_cfft_f32函数要求输入是一个实数数组交替排列为“实部、虚部、实部、虚部”的格式。也就是说如果你有N个采样点必须准备一个长度为2*N的float数组偶数下标放采样值奇数下标填0。这个细节一开始容易搞错一旦搞错频谱就是乱的。2.2 FFT参数选取与库函数用法我在这套工程里用的是1024点FFT采样率设置成20kHz。这两个参数得出一个很重要的指标频率分辨率它等于采样率除以FFT点数也就是20000除以1024大概是19.5Hz。意思是说这套系统能区分两个频率相差19.5Hz以上的信号。如果两个信号只差5Hz频谱上就会糊成一坨分不出来。如果你想更高的频率分辨率可以把FFT点数提高到2048或4096代价是采样时间变长计算量变大。也可以用更低的采样率但根据奈奎斯特采样定理采样率必须大于信号最高频率的两倍实际工程上一般取5倍以上才不会明显损失测量精度。这些参数之间是互相牵制的调试时需要根据信号的特性来折中。下面是关键的计算代码我直接贴出来每一行都有注释#define FFT_SIZE 1024 // FFT点数 #define SAMPLING_RATE 20000.0f // 采样率20kHz float32_t fft_input[FFT_SIZE * 2]; // FFT输入缓冲实虚交替存放 float32_t fft_output[FFT_SIZE]; // 频谱幅度结果 uint16_t adc_buffer[FFT_SIZE]; // ADC原始采样数据 // DMA搬运完成后调用这个函数执行FFT void FFT_Process(void) { uint16_t i; // 1. 把ADC原始数据填入FFT输入缓冲的实部虚部填0 for (i 0; i FFT_SIZE; i) { fft_input[i * 2] adc_buffer[i]; // 实部 fft_input[i * 2 1] 0.0f; // 虚部 } // 2. 调用CMSIS-DSP库做FFT最后一个参数1表示做位反转 arm_cfft_f32(arm_cfft_sR_f32_len1024, fft_input, 0, 1); // 3. 计算每个频点的幅度 arm_cmplx_mag_f32(fft_input, fft_output, FFT_SIZE); // 4. 直流分量除以N交流分量乘以2/N归一化才准确 fft_output[0] / FFT_SIZE; for (i 1; i FFT_SIZE; i) { fft_output[i] / (FFT_SIZE / 2); } }注意第4步的归一化非常关键。如果不做这步你会发现FFT算出来的幅度跟实际信号的伏特数对不上而且毫无规律。之前的调试中我发现同样的信号在10kHz的幅度显示总比实际小好多怀疑是FFT库的问题折腾了很久最后才发现是没做归一化。网上很多教程写到这里都是一笔带过但这里恰恰是数据显示是否准确的分水岭。3. 采样链路TIM触发ADCDMA无缝搬运3.1 定时器触发采样的关键作用ADC采集方式大致有几种直接软件启动转换、PWM信号触发、定时器事件触发。如果是做普通的电压读取比如采集一个温度传感器的值直接软件启动就够了反正不在乎时间点。但做信号频谱分析最关键的就是采样间隔必须严格相等。哪怕只有几微秒的抖动在频谱上就会表现为噪声底抬高甚至出现虚假频率峰。我在这套工程里选的是TIM8的更新事件作为ADC的触发源。配置思路是这样的把TIM8的定时周期设置成采样间隔的倒数它每次计数溢出就产生一个更新事件这个事件经过内部路径直接触发ADC开始转换。这样ADC的采样时刻完全由硬件定时器决定与CPU当前正在执行什么任务无关精度极高。这里还有一个很容易被忽视的坑定时器的时钟源和预分频器的关系。STM32的定时器挂在APB1或APB2总线上如果总线时钟是84MHz定时器时钟实际可能是168MHz。配置时一旦搞混分频系数实际采样率可能跟你的预期差一倍频谱上的频率坐标就会全部偏移。排查这种现象的快捷办法是输入一个已知频率的方波看看FFT峰值出现在哪个频点跟预期是否有整数倍偏差。3.2 DMA搬运与循环模式的妙用ADC转换完成之后数据是存放在ADC的数据寄存器里的。如果每次转换都用中断去读那CPU会被打断得非常频繁而且中断响应的延迟时间不确定会直接影响后续数据的连续性。所以正确做法是用DMA把ADC数据寄存器直接搬运到内存。DMA的意思是直接内存访问它搬运数据不需要CPU参与ADC转换完一个数据硬件就自动把这个数值拷到内存数组里整个过程不打断CPU执行主要任务。我用的DMA配置是循环模式这是我特别想强调的一个点。所谓循环模式就是DMA把数组填满之后自动从头开始继续写。如果你把数组长度设置成1024那么每采满1024个点DMA计数器就会归零重新开始同时触发一次传输完成中断。这样一来连续的ADC数据流就能被切成一段一段的FFT窗口非常稳定。我在采集部分用的关键配置如下DMA_InitTypeDef dma_init {0}; // ADC1的数据寄存器地址 dma_init.PeriphBaseAddr (uint32_t)ADC1-DR; // 内存目标地址ADC原始采样数组 dma_init.MemoryBaseAddr (uint32_t)adc_buffer; // 方向外设到内存 dma_init.Direction DMA_PERIPH_TO_MEMORY; // 数组长度 dma_init.BufferSize FFT_SIZE; // 循环模式采满自动从头开始 dma_init.Mode DMA_MODE_CIRCULAR; // 外设地址不自增内存地址自增 dma_init.PeriphInc DMA_PINC_DISABLE; dma_init.MemInc DMA_MINC_ENABLE; // 外设和内存数据宽度都是半字因为ADC是12位 dma_init.PeriphDataWidth DMA_PDATAALIGN_HALFWORD; dma_init.MemDataWidth DMA_MDATAALIGN_HALFWORD; // 优先级高一点保证不被其他DMA请求抢占 dma_init.Priority DMA_PRIORITY_HIGH;在实际调试时我建议先用逻辑分析仪或者示波器观察DMA的中断输出引脚先确认中断触发频率是否等于采样率除以FFT长度。如果不一致优先检查定时器的时钟配置和DMA的BufferSize设置。3.3 采样率与FFT点数的匹配逻辑关于采样率的选择我补一个具体的例子来说明这组参数为什么这么定。在H题那种信号测量场景里待测信号的频率范围一般在几百赫兹到几十千赫兹。如果信号最高频率是10kHz那么根据奈奎斯特定理采样率至少要在20kHz以上实际操作中我选了20kHz正好对应FFT图上0到10kHz的频谱范围。再看FFT点数它决定了采样时间窗口是1024除以20000约等于51.2毫秒。这51.2毫秒的数据长度对于测量一个1kHz左右的稳态正弦信号来说完全足够。如果是观察瞬态信号或者变频信号就需要根据信号的特性调整这两个参数不能生搬硬套。竞赛场景里一旦大概知道信号的频率范围整套参数很快就能定下来。4. DAC-DMA输出波形发生4.1 为什么需要DAC输出功能既然系统是用来分析信号的就一定要有信号源来自检。如果没有DAC输出功能每次调试都要外接信号发生器现场不方便。所以这块板子上我用DAC生成测试波形通过跳线直接连到ADC输入端这样整个系统的输入端到输出端就形成了一个闭环我可以在不依赖任何外部仪器的情况下验证ADC采样是否准确、FFT计算是否正确。STM32F407内部集成的DAC是12位的输出范围是0到3.3V。12位的分辨率意味着输出电压可以分成2的12次方也就是4096个等级。虽然比不上专业的信号发生器但对于测试音频范围的信号绰绰有余了。需要注意DAC输出的信号没有经过运放带载能力比较弱如果直接接负载会拉低输出电压我一般会在输出端加一个电压跟随器。4.2 用DMA循环发送波形数据生成固定频率的波形最直接的方法是查表法。我先在程序里定义一个正弦波查找表里面存放一个周期内各采样点的电压值然后用定时器触发DAC输出更新DMA自动把表里的数据逐个搬到DAC的数据寄存器里。这个过程同样是硬件自动完成的CPU不参与数据搬运。我在程序里生成的是一个256点的正弦波查找表DAC更新频率由TIM4触发设置为48kHz。按照这个参数输出的正弦波频率就是48000除以256等于187.5Hz。如果你想输出1kHz的正弦波可以把表的点数缩小或者提高定时器触发频率。计算方法就是波形频率乘上点数等于DAC的更新频率。波形数据生成代码如下#define DAC_TABLE_SIZE 256 uint16_t dac_sine_table[DAC_TABLE_SIZE]; // 生成正弦波查找表 void DAC_GenerateSineTable(void) { for (int i 0; i DAC_TABLE_SIZE; i) { // 12位DAC满量程4095 // 乘以0.9让信号留一点余量避免削顶 float val 0.9f * sinf(2.0f * 3.14159265f * i / DAC_TABLE_SIZE); dac_sine_table[i] (uint16_t)((val * 0.5f 0.5f) * 4095.0f); } }这里的零点偏移处理需要留意一下。DAC单端输出是0到3.3V正弦波是交流信号有正有负所以必须在软件层面把它整体上移让负半周也能表示出来。我加了一个0.5的直流偏置把范围从-1到1映射到0到1再乘以4095转成12位数字。如果忘了这一步输出波形会被削掉一半看起来像个奇怪的半波整流信号。4.3 DAC输出后的硬件补偿DAC内部虽然带有输出缓冲放大器但它的输出能力有限而且在零电平附近可能存在非线性。我的经验是DAC输出之后最好加一个简单的RC低通滤波器截止频率设置在目标波形的5到10倍频率上。这样做可以把DAC更新时产生的阶梯状毛刺平滑掉不少接上示波器看起来会干净很多。此外DAC输出和ADC输入之间的阻抗匹配也要考虑。DAC输出阻抗大约几欧姆ADC输入阻抗则在几兆欧以上直接连接问题不大不过我仍然会用一根短线连接避免长导线引入额外噪声。这些布线层面的细节在实验室里影响不大但在竞赛现场那种复杂电磁环境下提前做好总没有错。5. 人机交互GUI显示与菜单5.1 选型裸机刷屏还是LVGL关于GUI方案这块板子用的是SPI接口的TFTLCD屏幕主控ILI9341分辨率240x320。方案上有两条路可走一条是裸机方式直接在屏幕上画波形和文字另一条是上LVGL这样的图形库通过GUI Guider这类工具来设计界面。我最终选用的是裸机刷屏方案原因很现实LVGL虽然交互炫酷但会占用不少RAM和Flash资源而且事件驱动的编程模型与电赛这种“采集、计算、刷新”的死循环结构不太匹配。在竞赛场景下稳定和实时才是第一位的。裸机方案代码简单直观所有逻辑都在主循环里顺序执行排查问题也更容易。当然如果你要用LVGL我推荐用GUI Guider配合生成代码它能省掉大量手写控件布局的体力活。生成代码后只需要把LVGL的底层移植接口对接好屏幕驱动即可。不过做信号显示类的界面LVGL相对还是比较重的。在这里直接用画点、画线、填充矩形这几个基础绘图函数就足够实现一个用起来很顺手的界面了。5.2 波形绘制与数据可视化的实现思路我先说说波形显示的具体做法。TFT屏幕上有一个固定的显示区域比如左上角240x180的区域用来画时域波形。我需要把这180像素的高度映射到ADC值的动态范围上。假设ADC输入范围是0到4095那么每个样本对应的纵坐标就可以计算出来。把数组里的1024个点抽出一部分比如每4个点画一个像素对应约240个像素宽度这样一条波形线就出来了。绘制FFT频谱图也很类似。FFT输出的1024个点里我们只关心0到10kHz范围内的频谱也就是前512个点。我取对数幅度值然后把最大值映射到显示区域的高度这样能防止某个大峰把其他小峰压得看不见。这种做法在工程里很常用因为信号往往有一个特别大的基频成分直接用线性幅度画频谱谐波分量可能显示不出来取对数之后就顺眼多了。为了兼顾调试效率我还加了一个可选的十字光标。光标能通过按键左右移动实时显示所在位置的频率值和幅度值。这个功能在竞赛现场非常实用评委按下按键立刻能看到当前频点的具体数值远比对着波形图自己猜要直观。GUI刷新频率也需要控制一下。如果每个循环都刷新全屏屏幕会很闪而且占用大量CPU时间导致FFT计算来不及做完。我采用的是“分时刷新”策略每秒刷新4到5次GUIFFT计算则在每次采样数据就绪后立刻执行。这样人眼感觉画面流畅同时FFT的数据不会断。6. 常见问题与排查技巧实录6.1 频谱出现毛刺和泄漏先想到是不是加窗问题在我调试这套系统的过程中遇到过的最典型问题之一是明明输入的是一个纯净正弦波FFT出来除了主峰之外旁边却有好多小峰和拖尾的尾巴。理论上纯净正弦波的频谱应当是单根谱线出现一堆小峰就说明频谱泄漏了。频谱泄漏的根源在于FFT的周期性假设和实际信号的截断不匹配。解决办法就是加窗函数。我在ADC采样数据进入FFT之前先乘上一个汉宁窗能显著压低频谱泄漏带来的旁瓣。不过加窗之后幅度会有衰减幅度归一化系数需要针对汉宁窗做修正。我总结了一个经验表方便直接对照调整窗口类型主瓣宽度旁瓣抑制幅度修正系数适用场景矩形窗窄差1.0频率分辨率优先的场合汉宁窗中等较好2.0大多数信号测量场景汉明窗中等较好1.85靠近频点的幅度测量布莱克曼窗较宽很好2.8需要极大抑制旁瓣时在实际工程里最常用的还是汉宁窗。加了窗之后虽然峰值幅度会出现衰减但旁瓣压下去了谱线变得干净很多对谱峰位置的判断也更准确。6.2 DMA数据错位和ADC采样值抖动第二个高频问题是DMA搬运的数据发生错位。具体表现是FFT结果里的谱峰位置随机跳动无法稳定在正确的频率点上。这个问题的根源是DMA开始搬运的时刻和ADC转换的时刻没有对齐。当你启动DMA后它不一定是恰好从ADC的第一个转换结果开始搬第一批数据有可能搬的是第二批于是整个数组的数据整体偏移了。解决办法是在启动DMA之前先把ADC转换标志位清除然后做一次虚读丢掉当前寄存器里残留的无效数据。另外确认DMA工作在循环模式之后每次传输完成中断里只需要读取标志位然后清掉不需要重新初始化DMA否则反而会导致数据流中断。ADC采样值的抖动问题也很常见。如果发现ADC的数据在低位乱跳先别急着怀疑代码大概率是硬件层的处理问题。ADC的参考电压VREF如果直接接3.3V供电而电源本身有纹波采样结果就会跟着抖动。这种时候可以在VREF引脚上加一个100nF和10uF的电容做去耦。还有STM32F407的ADC对时钟频率有要求ADC时钟不要超过36MHz否则转换结果精度会明显下降。6.3 GUI刷新卡顿的优化手段GUI刷新卡顿这个问题几乎人人都遇到。我一开始是每次刷新时先把整块屏幕用背景色填充一遍再画所有内容结果导致画面闪烁加卡顿。后来我把刷新策略换成“局部重绘”只有数据发生变化的那块区域才重画背景填充和文字绘制尽量只在初始化时做一次。另外一个影响性能的地方是SPI屏幕的像素写入。ILI9341写一个像素需要先设置坐标再写RGB565格式的颜色值如果每次都调用单像素函数几千个点的波形画下来会非常慢。我后来启用了SPI DMA传输先把一整行波形的像素颜色预处理到内存缓冲区里然后通过DMA一次性发送给屏幕。这个优化让波形刷新速度提升了接近一倍肉眼几乎看不出延迟。如果你用的是高速RGB接口屏幕画屏的方式会有不同但“把像素缓冲处理好再一次性刷新”的思路是相通的。对于电赛这种需要用显示界面快速交互的场景花一点时间优化刷新机制绝对是值得的。写在最后的小经验这套系统调完之后我最大的感受是STM32F407做信号处理类的项目拼的不是你用了多少花哨的外设而是你能不能让恰好合适的硬件部件默契配合。定时器负责精确节拍ADC负责感知世界DMA负责无声搬运FPU负责高速计算DAC负责复现信号LCD负责与人沟通。每一部分单独拿出来都不复杂真正考验人的是它们之间的衔接细节包括时序对齐、数据格式转换、中断优先级调配、归一化系数修正这些才是经验的沉淀。如果你要拿这套方案做基础去扩展我觉得有几个方向可以走得更深把采样率提高到48kHz以上配合更大的FFT点数就能直接做音频频谱分析仪把DAC输出的波形表换成扫频信号就能做一个简易的频响测试仪在GUI上加入触摸校准就能扩展成带操作菜单的完整仪器界面。希望这篇文章能让你少踩几个我踩过的坑。本文还有配套的精品资源点击获取