STM32定时器触发ADC采集:TRGO到DMA的完整数据链路
简介在嵌入式系统中定时器触发、模数转换ADC与直接存储器访问DMA是构建高性能数据采集链路的核心技术。定时器通过TRGO事件产生精确的触发脉冲控制ADC以固定频率完成单次转换转换结果由DMA自动搬运至内存无需CPU逐样本干预。这一机制能显著降低采样抖动和CPU占用适用于电机电流波形分析、电源纹波监测等对时序要求严苛的工业与物联网场景。串口与上位机配合实现数据解析构成从模拟信号到数字波形的完整闭环。本文以STM32 F4系列为例深入讲解TRGO配置、ADC外部触发模式、DMA循环搬运及UART帧协议的设计要点并给出常见踩坑排查与多通道扩展思路帮助开发者快速搭建稳定、可复用的高精度采集系统。1. 定时器触发ADC采集一个从裸机到上位机的完整链路如果你以为定时器触发ADC采集就是开个定时器中断、在中断里读一下转换结果那这个项目会告诉你真正跑的通的采集链路远没这么直白。这个工程用STM32定时器的TRGO事件作为ADC外部触发源让模数转换以固定的频率自动进行转换结果由DMA直接搬到内存再通过串口组帧发给上位机全程不占用CPU逐样本处理。它适合电机电流波形分析、电源纹波监测这类对采样时序有硬要求的场景也适合想把ADC、DMA、UART几个外设串成一条完整数据链的嵌入式开发者。看完这篇你能把这个工程跑起来也能顺手改成多通道或可变采样率的版本。2. 项目架构与数据流从模拟引脚到串口帧的完整路径2.1 系统构成三个外设如何串成一条链这个项目不复杂核心外设就三个定时器、ADC、串口中间再搭两条DMA通道。但工程上的难点不在单个外设怎么配而在它们之间怎么配合。整个数据链路是这样的定时器产生周期性的更新事件更新事件通过TRGO内部信号线直接触发ADC开始一次转换ADC转换完成后的数据由DMA自动搬运到内存缓冲区当缓冲区攒够一批样本DMA传输完成中断被触发处理函数把缓冲区数据按帧格式打包再用串口DMA发送给上位机。上位机收到数据后按相同的帧协议解析还原成时序波形。这个设计里CPU只在DMA半满、全满中断时介入一次把数据从采集缓冲区拷贝到发送缓冲区其余时间都在跑业务逻辑或者干脆睡在WFI里。采样率再高也不会出现中断里读ADC导致的时间抖动。2.2 外设资源分配与工程目录我手上这块板子以F4系列为例四个外设的分配关系如下外设作用关键配置点TIM2产生触发脉冲输出比较/更新事件映射到TRGOADC1 规则组采样模拟信号外部触发模式关闭连续转换DMA1 Stream0ADC数据搬运循环模式内存地址自增DMA1 Stream4串口发送内存到外设普通模式USART1数据上行波特率921600DMA发送选型上有个原则ADC触发源优先用定时器的TRGO而不是某个通道的比较输出。TRGO是定时器内部事件的总出口配置简洁而且更新事件和触发脉冲之间只差一个硬件延时周期非常稳定。工程目录沿用标准HAL库风格核心文件的位置和职责大概是文件职责Core/Src/tim.cTIM2的PSC、ARR、TRGO配置Core/Src/adc.cADC1规则组、外部触发、DMA请求配置Core/Src/dma.c两条DMA流的参数配置Core/Src/usart.c串口引脚复用、定长DMA发送Core/Src/main.c外设启动顺序、中断回调注册、主循环第一次读这种工程建议按数据流方向读代码先看定时器怎么产生触发再看ADC怎么被触发接着看DMA把数据搬去哪最后看串口怎么发出去。顺着这个顺序理清后剩下的都是细节。3. 核心采集代码定时器TRGO与ADC/DMA的联动3.1 定时器初始化TRGO才是真正的触发源先看定时器。这里的定时器不是用来产生中断的而是作为一个信号发生器周期性地把更新事件输出到TRGO引脚。用HAL库写就是static void MX_TIM2_Init(void) { htim2.Instance TIM2; htim2.Init.Prescaler 84 - 1; // 84MHz / 84 1MHz htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 100 - 1; // 1MHz / 100 10kHz htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(htim2); TIM_MasterConfigTypeDef sMasterConfig {0}; sMasterConfig.MasterOutputTrigger TIM_TRGO_UPDATE; sMasterConfig.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(htim2, sMasterConfig); }Prescaler和Period的组合决定了触发频率这里算出来是10kHz也就是每100微秒触发一次ADC转换。TIM_TRGO_UPDATE是这段的灵魂它告诉定时器每次计数器溢出更新时往TRGO线上送一个脉冲这个脉冲会直接喂给ADC的触发输入端。有个常见的误解要澄清这里不需要使能定时器中断也不需要实现HAL_TIM_PeriodElapsedCallback。一旦触发源变成硬件链路CPU完全不关心定时器什么时候溢出只要保证初始化时调用了HAL_TIM_Base_Start(htim2)把定时器转起来。3.2 ADC外部触发配置单次转换而非连续转换ADC侧的配置核心是关闭连续转换改成外部触发单次转换static void MX_ADC1_Init(void) { hadc1.Instance ADC1; hadc1.Init.ClockPrescaler ADC_CLOCK_SYNC_PCLK_DIV4; hadc1.Init.Resolution ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode ADC_SCAN_DISABLE; hadc1.Init.ContinuousConvMode DISABLE; hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_EXTERNALTRIGCONV_T2_TRGO; hadc1.Init.ExternalTrigConvEdge ADC_EXTERNALTRIGCONVEDGE_RISING; hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion 1; hadc1.Init.DMAContinuousRequests ENABLE; HAL_ADC_Init(hadc1); }ExternalTrigConv指定了触发源来自TIM2的TRGOExternalTrigConvEdge表示上升沿触发一次。ContinuousConvMode设为DISABLE非常关键如果这里保持使能ADC会自己连续转个不停外部触发反而失去意义。DMAContinuousRequests要设为ENABLE这表示在规则通道转换完成后自动向DMA发一次请求从而让DMA进入循环搬运模式。这个参数如果不打开DMA很可能只搬一次就停了采样数据在缓冲区里不会再更新。3.3 DMA循环搬运把数据直接搬进内存DMA配置决定了数据落到哪块内存以及怎么通知CPU来取static void MX_DMA_Init(void) { hdma_adc1.Instance DMA1_Stream0; hdma_adc1.Init.Channel DMA_CHANNEL_0; 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.MemDataAlignment 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); }这里有两个参数值得细看。Mode DMA_CIRCULAR让DMA在搬完整个缓冲区后自动回卷到起点继续搬这样ADC往内存里写数据永远不用CPU操心缓冲区始终是最近一段时间的样本。PeriphDataAlignment和MemDataAlignment都是HALFWORD因为ADC数据寄存器是16位宽的内存缓冲区也必须用uint16_t数组声明。启动顺序也有讲究先启动定时器再启动ADC的DMA采集之后定时器输出的触发脉冲才会被有效转换。反过来如果先让定时器跑起来再做ADC初始化前几个脉冲会落在ADC还没有准备好的阶段丢样本不说还容易让调试者的第一反应是配置写错了。启动代码的核心就三行HAL_TIM_Base_Start(htim2); HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, BUFFER_SIZE);3.4 三个参数怎么配合整个采集链路其实就是一个参数三角定时器频率决定采样率DMA缓冲区大小决定中断频率ADC采样周期决定采集精度。三者互相牵制我一般先定采样率再根据串口波特率反推缓冲区大小最后看芯片主频够不够完成打包发送。以上面10kHz采样率、512点缓冲区为例DMA每半满一次是256个样本也就是25.6毫秒产生一次中断。CPU在这个窗口内要做的事情是组装约520字节的帧并通过串口DMA发出去这个工作在921600波特率下大约需要5毫秒左右完全来得及。如果采样率提到100kHz同样的缓冲区每2.56毫秒来一次中断CPU的余量就变得很紧此时要么扩大缓冲区要么换成USB CDC要么精简帧格式。4. 数据上行串口协议与上位机解析4.1 帧格式让上位机能安全地切回数据流模拟信号转换成数字量之后剩下的问题是怎么让上位机收到后不出错。裸发二进制流看着简单一旦丢一个字节之后所有数据就全错位了。我在这里用的是经典帧格式帧头加长度加载荷加校验。字段占用字节说明帧头11固定0xAA帧头21固定0x55长度1本次样本个数uint16_t点数数据区2*NN个16位ADC样本CRC81对长度数据区做CRC校验上位机解析时只要盯住帧头遇到0xAA 0x55就进入组帧状态不满足帧头直接丢字节重新搜索。这个机制保证了即使串口偶发丢数据下一帧也能自动恢复同步不会让整个采集过程崩溃。4.2 DMA双缓冲发送不阻塞采集的关键数据打包发送的代码看起来不难难点在于不要让发送过程阻塞DMA的持续搬运。如果直接在中断里调用阻塞式串口发送发送时间会被算进中断占用的时间里采样数据会因为在中断里消耗过久而被损坏。我的做法是乒乓缓冲ADC DMA在采集缓冲区的半满和全满时各触发一次回调CPU在回调里把对应半段数据封装成帧发出。发送本身也用DMACPU只负责组装帧真正的字节输出由串口硬件完成。uint16_t adc_buf[BUFFER_SIZE]; uint8_t send_buf[FRAME_SIZE]; void pack_and_send(uint16_t offset, uint16_t count) { uint8_t len count / 2; // 实际点数 send_buf[0] 0xAA; send_buf[1] 0x55; send_buf[2] len; memcpy(send_buf[3], (uint8_t *)adc_buf[offset], count * 2); send_buf[3 count * 2] crc8(send_buf[2], count 1); // 等待上一次发送完成再启动新的DMA发送 while (uart_busy) __NOP(); uart_busy 1; HAL_UART_Transmit_DMA(huart1, send_buf, 4 count * 2); } void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { pack_and_send(0, BUFFER_SIZE / 2); } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { pack_and_send(BUFFER_SIZE / 2, BUFFER_SIZE / 2); } }半满和全满由DMA自动交替触发CPU每次处理的都是DMA当前没有在写的那半段数据读写操作天然错开。memcpy一定要用双字节搬运不能在拷贝过程中被新的DMA写入打断这是这个模块最基本的时序保证。4.3 上位机解析脚本Python串口端的同步思路上位机我用Python加pyserial写了个解析脚本逻辑和单片机上的打包是镜面对称的import serial, struct def crc8(payload): crc 0 for b in payload: crc ^ b for _ in range(8): crc ((crc 1) ^ 0x07) 0xFF if crc 0x80 else (crc 1) 0xFF return crc ser serial.Serial(/dev/ttyUSB0, 921600, timeout0.2) buf bytearray() while True: data ser.read(4096) if not data: continue buf.extend(data) while len(buf) 4: if buf[0] 0xAA and buf[1] 0x55: n buf[2] need 3 n * 2 1 if len(buf) need: break if crc8(buf[2:3 n * 2]) buf[3 n * 2]: samples struct.unpack(%dH % n, buf[3:3 n * 2]) # 这里拿到解析后的样本序列可以画波形或存文件 del buf[:need] else: del buf[0] else: del buf[0]这个脚本最巧妙的地方是帧头不匹配时只丢一个字节继续找而不是清空整个缓冲区。在串口这种流式通道上这是最鲁棒的同步逻辑。crc8是表驱动算法的简单实现代价不大但对数据完整性帮助很大。4.4 波特率与数据量的匹配串口波特率不是拍脑袋定的。以10kHz采样率、16位精度计算原始数据率是20KB/s也就是160kbps。UART每字节实际发送10位起始位加停止位加上帧头、校验的开销真实波特率需求接近200kbps。选115200的话数据量已经顶到极限稍微有点干扰就会出现断流所以我选了921600留出了接近五倍的余量。如果采样率提到100kHz以上921600波特率也会吃紧。这种情况下两个方向可选一是用USB CDC虚拟串口从波特率限制里解放出来二是直接在板上做降采样或者只上传统计值。做这个项目时先把这条账算清楚能省掉后面大量调试时间。5. 踩坑记录与排查手段五个高频翻车现场5.1 现象ADC采样值全为0或者固定在一个值不动原因定时器TRGO没有输出或者ADC的外部触发边沿配置反了。F4系列的外部触发有上升沿和下降沿两种模式如果配了下降沿而TRGO输出的是上升沿ADC就完全接收不到触发脉冲。还有一种情况定时器根本没启动ADC还在等外部触发结果就是缓冲区里永远是复位后的0。解决先用调试器看ADC1-SR寄存器里的EOC标志位如果这个位从不置位说明触发链路有问题再用逻辑分析仪或者示波器量定时器的TRGO引脚确认触发脉冲是否存在。最省事的调试手段是在HAL_TIMEx_MasterConfigSynchronization之前先加一句HAL_TIM_Base_Start_IT让更新事件走中断回调在回调里点一个变量翻转确认定时器本身在跑。5.2 现象波形出现周期性毛刺个别采样点飞出去原因ADC采样时间太短信号源输出阻抗偏高。常见做法是把采样时间设成最小值来追求高采样率但忽略了ADC内部采样电容还没有完全充到输入电压就开始转换了。尤其信号来自分压电阻网络或者传感器输出端时源阻抗高达几十千欧采样周期不够就会带进明显误差。这类毛刺很有规律每隔固定样本数出现一次很好识别。解决把SamplingTime从ADC_SAMPLETIME_3CYCLES加到ADC_SAMPLETIME_84CYCLES。代价是单次转换时间变长但10kHz采样率下完全够用。如果采样率必须拉满就在模拟前端加一个运放电压跟随器把源阻抗降下来再进ADC引脚。5.3 现象上位机偶尔整帧丢失甚至连续几帧错位原因pack_and_send函数里用了while等待上一次发送完成这个忙等一旦超过DMA中断周期就会导致回调函数还没执行完下一次DMA又覆盖了同一块缓冲区。数据被半覆盖组装出来的帧自然过不了CRC校验上位机只能丢帧。解决把忙等改成状态机如果上一次发送还没完成就丢弃当前帧。采集时序的稳定优先级永远高于数据完整性丢一帧可以接受拖住DMA不可接受。更进一步可以用环形发送队列把待发送帧先存起来串口DMA发送完成中断里再从队列取下一帧这样一帧都不会丢。5.4 现象定时器触发频率和计算值差一倍原因定时器的时钟源不是直接等于APB1外设时钟。F4系列里当APB1预分频系数大于1时定时器时钟自动变成PCLK1的两倍。如果直接把HAL_RCC_GetPCLK1Freq()的返回值拿去做预分频计算得到的触发频率就比预期少一半ADC采样点会整体稀疏波形看起来像被抽过样。解决计算定时器时钟时先判断APB1分频系数。标准做法是在配置时手动乘2uint32_t tim_clk HAL_RCC_GetPCLK1Freq(); if (RCC-CFGR RCC_CFGR_PPRE1_DIV2) { tim_clk * 2; }在注释里写明时钟来源比事后用示波器量频率再猜要快得多。5.5 现象上电后前几百个样本明显异常后面恢复正常原因ADC上电后内部模拟电路需要几十微秒稳定但定时器可能在上电瞬间就已经开始输出触发脉冲。如果在这段时间内完成了若干次转换DMA会把这些无效数据搬进缓冲区直到ADC完全稳定。这段数据在波形图上通常表现为初始阶段的跳变或者平台。解决这是很多项目忽略的细节。启动流程里先开启ADC校准等待校准完成后再启动DMA采集。如果校准之后还有极少量的异常样本可以在回调和上位机里加一个启动丢弃逻辑把固定数量的前导样本直接丢弃数据从稳定之后才开始上传。6. 二次开发改造从固定速率到多通道可配置采集拿到这个工程大多数人不会只满足于单通道固定采样率。我列三条最常做的改造路径从易到难。第一个改动是扩展多通道采集。把ScanConvMode设为ENABLENbrOfConversion改成2或更多在adc.c里用HAL_ADC_ConfigChannel配置多个通道并设置好规则序列的顺序。DMA缓冲区里数据的排列顺序和通道配置顺序一致上位机按同样顺序拆解即可。注意多通道时总采样率会被均分比如10kHz规则序列有两个通道每个通道的有效采样率只有5kHz所以定时器频率要相应提高。第二个改动是给数据加软件滤波。ADC采样系统里最常见的需求是去毛刺我一般会在pack_and_send之前做一次滑动平均for (int i 4; i BUFFER_SIZE; i) { uint32_t sum adc_buf[i-3] adc_buf[i-2] adc_buf[i-1] adc_buf[i] adc_buf[i1]; adc_buf[i] sum / 5; }滑动平均窗口选5点成本最低对高频毛刺的抑制效果也比较明显。如果要做更精细的滤波可以换成中值滤波或者IIR但要注意计算量对DMA中断时序的影响跑完新算法后看一遍HAL_ADC_ConvCpltCallback的执行时间。第三个改动是发送端升级把UART换成USB CDC。硬件上接一个小封装芯片或者直接用带USB的型号软件上把pack_and_send里的串口DMA替换成USB发送接口帧格式和上位机解析逻辑都不用动。USB CDC没有波特率限制吞吐量比UART高一个量级还能同时实现指令下发让上位机随时改采样率、开关通道。验证改造是否成功最有效的方法是给模拟输入端送一个已知频率的标准正弦波比如用信号发生器给1kHz然后在上位机里做FFT看频谱。改完代码后先跑半小时确认无错帧、无毛刺、无内存持续上涨再去做功能迭代。前几年我做一个电源纹波采集项目时冒进地跳过了稳定性测试结果凌晨压测时发现DMA缓冲区在极端情况下会被写穿整个上位机画面直接卡死。从那以后我每次拿到一个采集工程都会先画一张数据流时序图把TRGO、DMA半满全满、串口DMA这三个节点的先后关系标清楚再动代码。希望帮到你。本文还有配套的精品资源点击获取