1. 项目缘起与整体方案拆解1.1 为什么偏偏选定时器3来触发ADC1做嵌入式信号采集的朋友大概率都遇到过这样的场景想用STM32F407采一路正弦波第一反应是丢进主循环里轮询或者开个ADC中断采一个存一个。结果跑起来一看波形要么歪歪扭扭要么频率一高就丢点LCD上画出来的正弦波跟锯齿波似的。这个项目的出发点就是解决“采样节拍不稳”这个老大难问题。核心思路一句话概括用定时器3的更新事件作为ADC1的外部触发源让ADC在硬件层面按固定频率自动启动转换再配合DMA把结果搬进内存CPU全程不插手最后把缓冲区里的数据画到LCD上。这条链路里定时器负责“打拍子”ADC负责“采样”DMA负责“搬运”LCD负责“呈现”四个角色各司其职。那为什么是定时器3而不是定时器2、定时器4或者别的这里有几个实打实的考量。第一STM32F407的ADC1外部触发源里TIM3_TRGO是明确支持的对应ADC_ExternalTrigConv_T3_TRGO这是硬件层面的绑定不需要额外绕路。第二定时器3是通用定时器挂在APB1总线上配置灵活PSC和ARR随便调用来产生任意频率的触发脉冲都方便。第三定时器2往往被系统滴答或者别的时基占用定时器4有时候又要留给PWM输出定时器3相对“清闲”拿来做ADC触发不容易和别的外设打架。第四也是很多人忽略的一点定时器3的TRGO输出可以选择更新事件而更新事件的频率计算非常直观就是72MHz / ((PSC1)*(ARR1))调起来心里有数。提示STM32F407的ADC挂在APB2上最高时钟是84MHz但ADC自己的时钟不能超过36MHz所以ADC预分频器通常设成4分频或6分频这一点在CubeMX里会自动帮你算但心里要清楚。1.2 整条数据链路的架构设计把整个系统拆开看数据从模拟引脚进来到最后在LCD上显示中间经过了这么几道手信号输入正弦波从ADC1的某个通道比如PA0对应通道0进来注意输入电压范围是0到3.3V超了会烧引脚低于0V也不行所以前端最好加个偏置电路把信号抬到1.65V附近。触发源定时器3配置成向上计数模式ARR决定触发周期每次计数溢出产生更新事件这个事件通过TRGO输出给ADC。ADC转换ADC1配置成外部触发模式触发源选TIM3_TRGO转换完成后产生DMA请求。DMA搬运DMA2的Stream0ADC1对应的是DMA2_Stream0通道0把ADC数据寄存器里的值搬到内存数组里配置成循环模式这样缓冲区满了自动从头覆盖不会停。数据处理与显示主循环里检测缓冲区是否有新数据做去直流、归一化然后映射到LCD的Y坐标画点。这套架构最大的好处是确定性。采样间隔由定时器硬件保证不受主循环里其他代码执行时间的影响。你主循环里就算在跑LCD刷屏、跑串口打印采样节拍依然稳如老狗。这就是硬件触发的价值所在。1.3 方案选型背后的取舍逻辑有人会问用ADC的连续转换模式加DMA不行吗也行但连续转换模式的采样率是由ADC自己的转换时间决定的你没法精确控制“每隔多久采一次”。比如你想采一个1kHz的正弦波每个周期采100个点那就是100kHz采样率连续模式下你只能通过改ADC时钟来凑凑出来的频率往往不是整数而且改起来麻烦。用定时器触发采样率就是定时器频率想要多少给多少精确到小数点后几位都行。还有人问用软件触发加中断行不行行但中断有延迟而且每次进中断都有开销采样率高的时候CPU全耗在进出中断上了。DMA加硬件触发CPU占用率几乎为零这是质的区别。至于LCD的选择这个项目里用的是常见的TFT LCD通过FSMC或者SPI驱动都行。如果只是看波形用SPI的1.8寸小屏就够了分辨率128x160画个正弦波绰绰有余。如果要显示更多信息那就上FSMC驱动的3.5寸屏刷屏速度快不卡顿。2. 核心细节解析与CubeMX配置要点2.1 时钟树配置一切频率的源头打开CubeMX第一件事就是配时钟树这一步错了后面全乱。STM32F407的外部晶振通常是8MHz经过PLL倍频到168MHz作为系统时钟。具体路径是8MHz先经过M分频通常设8得到1MHz再经过N倍频设336得到336MHz最后经过P分频设2得到168MHz。这样SYSCLK就是168MHz。接下来是外设时钟。APB1总线最高42MHz定时器3挂在APB1上但定时器有个特殊规则如果APB1预分频系数不为1定时器时钟就是APB1时钟的2倍。所以APB1设成42MHz时定时器3的时钟是84MHz。等等这里要仔细算一下168MHz的SYSCLKAHB预分频设1得到168MHz的HCLKAPB1预分频设4得到42MHz的PCLK1因为APB1预分频不为1所以定时器3的时钟是42MHz乘以2等于84MHz。注意很多人在这里栽跟头以为定时器时钟就是APB1时钟结果算出来的触发频率差了一倍。记住那个“乘以2”的规则。ADC的时钟来自APB2APB2预分频设2得到84MHz的PCLK2。ADC预分频器再分一下设成4分频ADC时钟就是21MHz没超过36MHz的上限安全。2.2 定时器3的参数计算把频率算明白假设我们要采一个1kHz的正弦波每个周期采200个点那采样率就是200kHz。定时器3的时钟是84MHz要产生200kHz的更新事件分频系数和重装载值怎么算公式是触发频率 定时器时钟 / ((PSC1) * (ARR1))把已知量代进去200000 84000000 / ((PSC1) * (ARR1))所以(PSC1) * (ARR1) 420选PSC0那ARR1420ARR419。这样定时器3每420个时钟周期产生一次更新事件频率正好200kHz。PSC设0的好处是分辨率最高ARR可以精细调节。如果采样率要求不高比如10kHz那(PSC1)*(ARR1)8400可以选PSC83ARR99这样PSC184ARR1100乘积8400也对。PSC大一点的好处是ARR小一点但分辨率会降低一般建议PSC尽量小。在CubeMX里配置定时器3的时候Clock Source选Internal ClockPrescaler填0Counter Period填419Counter Mode选Upauto-reload preload选Enable。最关键的一步在Trigger Output (TRGO) Parameters里Trigger Event Selection选Update Event。这样每次计数器溢出TRGO就会输出一个脉冲给ADC。2.3 ADC1的触发与DMA配置ADC1的配置有几个关键点。首先在Parameter Settings里Mode选IndependentClock Prescaler选PCLK2 divided by 4Resolution选12位Data Alignment选Right alignment。Scan Conversion Mode如果只采一个通道就DisableEnable Regular Conversions要打开Number of Conversion设1External Trigger Conversion Source选Timer 3 Trigger Out eventExternal Trigger Conversion Edge选Rising edge。这里有个细节External Trigger Conversion Edge选Rising edge还是Falling edge还是Both一般选Rising edge就行因为TRGO输出的是一个脉冲上升沿触发一次转换干净利落。DMA的配置在DMA Settings标签页里点Add选ADC1然后配置DMA Request。Stream选DMA2 Stream0Channel选Channel0Direction选Peripheral To MemoryPriority选HighMode选CircularData Width外设和内存都选Half Word因为ADC是12位存在16位变量里。Circular模式很重要这样DMA搬完一轮自动从头开始不需要软件干预。提示STM32F407的ADC1对应DMA2的Stream0 Channel0ADC2对应Stream2 Channel1ADC3对应Stream0 Channel2别选错了。选错了DMA不工作而且不会报错查起来很头疼。2.4 LCD接口的选型与初始化LCD这块如果用的是SPI接口的1.8寸TFT配置就简单SPI选全双工主模式波特率预分频设2或4数据大小8位CPOL和CPHA根据屏幕手册来大部分是Mode 0。另外还需要几个GPIO做DC、RST、CS控制。如果用的是FSMC接口的大屏配置就复杂一些。FSMC的NOR/SRAM Bank1要打开数据宽度选16位地址建立时间、数据保持时间这些参数要根据屏幕手册调。一般地址建立时间设2到3个HCLK数据保持时间设2到4个HCLK具体看屏幕的时序要求。不管哪种接口初始化流程都是复位屏幕、发初始化命令序列、设置显示方向、开显示。初始化命令序列每个屏幕型号都不一样买屏幕的时候卖家一般会给例程直接抄过来就行。3. 实操过程与核心代码实现3.1 工程生成与基础代码框架CubeMX配置完之后生成MDK或者STM32CubeIDE工程。生成的时候注意Toolchain/IDE选对如果用的是Keil就选MDK-ARM用CubeIDE就选STM32CubeIDE。Code Generator里勾上“Generate peripheral initialization as a pair of .c/.h files”这样每个外设的初始化代码分开看着清爽。生成的代码里main.c会自动调用MX_GPIO_Init()、MX_DMA_Init()、MX_ADC1_Init()、MX_TIM3_Init()这些函数。注意顺序DMA的初始化要在ADC之前因为ADC初始化里会配置DMA链接。CubeMX生成的代码一般会处理好这个顺序但自己写的时候要注意。在main()函数的while(1)之前要手动启动定时器和ADC的DMAHAL_TIM_Base_Start(htim3); HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE);这两句一执行定时器开始跑ADC开始等触发DMA开始等数据。整个链路就活了。3.2 ADC采样缓冲区的定义与处理缓冲区大小怎么定这取决于你要显示多少个点。如果LCD宽度是128像素那缓冲区至少128个点最好多留一些比如256或512这样显示的时候可以抽点或者做平均。定义成全局数组#define ADC_BUFFER_SIZE 512 uint16_t adc_buffer[ADC_BUFFER_SIZE];DMA配置成Circular模式后它会不停地往这个数组里填数据填到最后一个自动回到开头。主循环里怎么知道数据填到哪了有两种办法一是用HAL_ADC_ConvHalfCpltCallback和HAL_ADC_ConvCpltCallback两个回调分别在填满一半和全部填满时触发二是直接读DMA的NDTR寄存器看还剩多少个没搬。第一种办法更规范推荐用。回调函数里可以置个标志位主循环检测到标志位就处理数据。注意回调函数里不要做耗时操作置个标志就出来。3.3 数据处理去直流与归一化ADC采到的原始数据是0到4095之间的整数对应0到3.3V。如果输入的正弦波是带偏置的比如中心在1.65V那原始数据的中心值大概在2048附近。要画波形先要去直流再归一化。去直流就是减去平均值。平均值怎么算把缓冲区里所有数据加起来除以个数。但这样有个问题如果缓冲区里正好不是整数个周期平均值会偏。更稳妥的办法是找最大值和最小值取中间值作为直流分量uint16_t vmax 0, vmin 4095; for (int i 0; i ADC_BUFFER_SIZE; i) { if (adc_buffer[i] vmax) vmax adc_buffer[i]; if (adc_buffer[i] vmin) vmin adc_buffer[i]; } uint16_t vmid (vmax vmin) / 2; uint16_t vpp vmax - vmin;归一化就是把去直流后的值映射到LCD的Y坐标范围。假设LCD高度是160Y坐标从0到159那映射公式是int y 80 - (int)((adc_buffer[i] - vmid) * 60 / (vpp / 2));这里80是屏幕中心60是波形幅度留点边距。除以vpp/2是因为正弦波的峰值是峰峰值的一半。这样算出来的y就在20到140之间不会超出屏幕。注意如果vpp很小比如输入信号很弱除法会出问题分母接近0。所以要先判断vpp是否大于某个阈值比如50小于50就认为没有有效信号直接画一条直线。3.4 LCD画波形的实现细节画波形有两种方式一种是画点一种是画线。画点简单但波形看起来不连续画线效果好但计算量大一点。推荐画线用Bresenham算法或者简单的线性插值。具体做法是先清屏然后从缓冲区第一个点开始算出第一个点的(x0, y0)再算第二个点的(x1, y1)在两点之间画一条线。循环下去整个波形就出来了。for (int i 0; i LCD_WIDTH - 1; i) { int idx0 i * ADC_BUFFER_SIZE / LCD_WIDTH; int idx1 (i 1) * ADC_BUFFER_SIZE / LCD_WIDTH; int y0 map_to_screen(adc_buffer[idx0], vmid, vpp); int y1 map_to_screen(adc_buffer[idx1], vmid, vpp); LCD_DrawLine(i, y0, i 1, y1, RED); }这里用i * ADC_BUFFER_SIZE / LCD_WIDTH做抽点把512个点映射到128个像素上。如果缓冲区大小和屏幕宽度一样那就不用抽点直接一对一。刷屏速度是个问题。如果每帧都全屏清再全画SPI屏会闪。解决办法是用双缓冲或者局部刷新。简单点的办法是只清波形区域不全区清屏。或者用FSMC屏刷屏速度快闪不明显。3.5 完整主循环的逻辑组织主循环里要做的事检测DMA完成标志、处理数据、刷屏。为了不阻塞可以用状态机的方式while (1) { if (adc_data_ready) { adc_data_ready 0; process_adc_data(); draw_waveform(); } // 其他任务 }adc_data_ready在DMA完成回调里置1。这样主循环大部分时间在空转CPU占用率很低。如果还有别的任务比如按键扫描、串口通信都可以塞进这个循环里互不干扰。4. 常见问题与排查技巧实录4.1 波形抖动或频率不对的排查思路波形抖动最常见的原因是触发频率算错了。先拿示波器量一下定时器3的TRGO引脚如果有引出的话或者量ADC的采样率。如果没有示波器可以在DMA完成回调里翻转一个GPIO用逻辑分析仪看翻转频率应该等于采样率除以缓冲区大小。另一个原因是ADC的采样时间设得太短。STM32F407的ADC采样时间有3、15、28、56、84、112、144、480个ADC时钟周期可选。如果信号源内阻比较大采样时间太短会导致采样电容充不满采到的值偏低。一般建议采样时间至少设56个周期信号源内阻大的话设144或480。还有可能是DMA和ADC的位宽不匹配。ADC是12位DMA外设和内存都设成Half Word16位这样正好。如果设成Byte数据会错位。4.2 DMA不搬运数据的几种可能DMA配置好了但数据不动先查这几个地方第一DMA Stream有没有使能HAL_ADC_Start_DMA里会使能但如果之前手动关过要确认再打开。第二ADC的DMA请求有没有使能在ADC的CR2寄存器里有个DMA位CubeMX配置里如果没勾“DMA Continuous Requests”这个位可能没置上。第三DMA的中断有没有开如果用了回调函数NVIC里要开对应的DMA中断。还有一个隐蔽的坑STM32F407的DMA2 Stream0和ADC1是固定映射但如果同时用了别的外设也占这个Stream就会冲突。比如SPI1的RX也用DMA2 Stream0那就打架了。查一下Reference Manual里的DMA请求映射表确认没有冲突。4.3 LCD显示异常的调试经验LCD不亮先查背光。背光一般是单独供电的有的屏幕背光需要升压电路3.3V直接驱动可能不够亮。再查复位时序RST引脚要先拉低再拉高延时不能太短一般至少10ms。显示花屏多半是初始化命令序列不对。不同厂家的屏幕初始化序列不一样甚至同一厂家不同批次都可能不同。买屏幕的时候一定要找卖家要对应的例程直接用例程里的初始化代码。颜色不对检查数据格式。RGB565和RGB888的转换方式不一样如果颜色偏色大概率是格式搞错了。还有字节序的问题16位数据的高低位可能要对调。4.4 常见问题速查表现象可能原因排查方法解决办法波形频率不对定时器频率算错量TRGO或翻转GPIO重新计算PSC和ARR波形抖动采样时间太短加大采样时间设成56或144周期DMA不搬运DMA请求未使能查CR2寄存器DMA位勾选DMA Continuous RequestsLCD不亮背光或复位问题量背光电压和RST波形检查背光电路和复位延时显示花屏初始化序列不对对比例程用卖家提供的初始化代码颜色偏色数据格式错误查RGB565转换修正颜色转换函数采样值跳变参考电压不稳量VREF加滤波电容波形削顶输入超范围量输入电压加偏置电路或衰减4.5 几个容易忽略的实操心得第一个心得ADC的参考电压。STM32F407的VREF默认接VDDA3.3V但如果VDDA不稳采样值就会跳。可以在VREF引脚加一个0.1uF和10uF的电容滤波效果立竿见影。第二个心得模拟地和数字地的处理。ADC对地噪声很敏感如果模拟部分和数字部分共地数字电路的开关噪声会串到模拟地上。最好用磁珠或者0欧电阻把模拟地和数字地分开在ADC下面单点接地。第三个心得DMA缓冲区的对齐。虽然16位数组一般不会有什么对齐问题但如果用了32位传输或者结构体要注意对齐。STM32F407的DMA对非对齐访问支持不好可能会出错。第四个心得定时器的自动重装载预装载。CubeMX里有个auto-reload preload选项建议打开。这样改ARR的时候不会立即生效而是等当前周期结束再生效避免产生一个异常短的周期。第五个心得调试的时候先用低频信号。比如先用100Hz的正弦波采样率设20kHz这样每个周期200个点波形很漂亮。等调通了再往上加频率一步步来不要一上来就搞高频出了问题不好定位。5. 性能优化与扩展思路5.1 提高采样率的几个手段如果200kHz的采样率不够用想再往上提有几个方向可以试。第一提高ADC时钟。ADC时钟最高36MHz如果现在用的是21MHz可以提到36MHz采样率能提升70%左右。但要注意ADC时钟越高功耗越大精度可能略降。第二缩短采样时间。如果信号源内阻很小采样时间可以从56周期降到15周期甚至3周期转换时间大幅缩短。但信号源内阻大的话不能这么干会采不准。第三用双重ADC模式。STM32F407有两个ADC可以配置成交替采样模式ADC1采一个点ADC2采下一个点等效采样率翻倍。这个配置在CubeMX里选Dual Mode就行但DMA配置要改成32位因为两个ADC的数据拼在一起。5.2 用DMA双缓冲实现无缝采集现在的方案是DMA循环模式主循环处理数据的时候DMA还在往同一个缓冲区里写如果处理时间太长数据可能被覆盖。用DMA双缓冲可以解决这个问题DMA填缓冲区A的时候主循环处理缓冲区BA填满了切换主循环处理ADMA填B。这样永远不会冲突。STM32F407的DMA支持双缓冲模式在CubeMX里把Mode从Circular改成Double Buffer就行然后指定两个缓冲区地址。回调函数里会告诉你当前填的是哪个缓冲区处理另一个就行。5.3 波形显示的进阶玩法现在只是画了个基本的正弦波其实还能做很多事。比如加个频率显示通过过零检测算周期再换算成频率显示在屏幕上。加个峰峰值显示把vpp换算成电压显示出来。加个FFT用CMSIS-DSP库做频谱分析把频谱画出来。这些都是在现有框架上扩展数据链路不用动只是处理算法变复杂。如果LCD分辨率够高还可以做滚动显示像示波器那样波形从右往左滚。实现方法是维护一个显示缓冲区每次新数据来了就整体左移新数据放最右边。这样看起来更直观。5.4 从正弦波到任意波形的通用化这套框架其实不限于采正弦波采方波、三角波、甚至音频信号都行。只要改一下采样率和显示映射就行。如果要采音频采样率设成44.1kHz或48kHz缓冲区设大一点比如1024或2048显示的时候做一下抽点。如果要采非周期信号比如按键抖动或者串口数据那触发方式可能要改。定时器触发适合周期信号非周期信号可能要用软件触发或者外部中断触发。但DMA加缓冲区的思路是一样的只是触发源换了。我在实际调试这套东西的时候最大的体会是硬件触发的稳定性是软件触发比不了的。以前用软件触发加中断采样率一高就丢点波形上全是毛刺。换成定时器触发加DMA之后波形干净得像示波器画出来的。这个方案值得每一个做信号采集的嵌入式工程师掌握调通一次以后遇到类似需求直接套用就行。