资讯动态

定时器触发ADC+DMA:STM32高稳定采样率数据采集实战

发布时间:2026/10/26 17:05:30 来源:尧图企业网站定制
简介面向嵌入式初学者与STM32项目实践者的完整工程包围绕STM32F103展示由定时器TIM2中断周期性触发ADC通道2采样并在转换完成后经串口按约定格式上传至上位机的实现路径可用于理解定时器、模数转换与串口通信的协同配置。压缩包共189个文件、约6.62MB包含源文件、头文件、目标文件与依赖文件等编译中间产物以及可执行文件、固件库、硬件驱动、系统配置、用户代码、说明文档和一键清理脚本目录覆盖从外设驱动到应用层的完整开发结构。已有4891人学习说明该案例具备较高参考价值。读者可据此对照定时器预分频与重载值、ADC采样时间与通道选择、串口波特率及数据帧配置快速搭建可编译下载的工程并进一步改造成多通道采集或自定义上位机协议。1. 一块板子上的采样率对不上问题出在触发方式前阵子帮人调一块采集板信号源给的是 1 kHz 方波STM32 定时器触发 ADC 采集、数据发送至上位机之后上位机算出来的频率总在 900 多 Hz 徘徊偶尔还跳到 1.1 kHz。示波器直接量 ADC 引脚波形干净得很怀疑过参考电压、地线、串口丢包最后定位到一个很朴素的地方ADC 跑在连续转换模式下采样节奏被串口发送和主循环里的其他活儿拖慢了。改成定时器 TRGO 硬触发、DMA 搬运之后波形立刻就稳了。这套「定时器触发 ADC DMA 搬运 串口上传」的链路几乎是每个做数据采集的嵌入式开发者都要过一遍的坎采样率要准、搬运不能占 CPU、上位机还得能把波形还原出来。它适合正在做传感器采集、振动或电流波形、教学实验箱这类需求的人也适合已经能把 ADC 读出来、但采样率总是飘的开发者。下面按我拆工程的顺序从链路设计讲到代码再讲到怎么改。2. 链路拆解TIM_TRGO → ADC → DMA → UART 这条数据通路怎么走先把整条数据通路摆出来后面所有配置和代码都是围绕这条链路服务的。定时器负责「什么时候采」ADC 负责「采多少」DMA 负责「搬走」串口负责「送出去」。四者各管一段任何一段慢下来或者顺序错了最终波形就会变形而现象往往表现为「数据不对」让人误以为是 ADC 的问题。2.1 为什么连续转换模式撑不起稳定采样率ADC 的连续转换模式Continuous Conversion含义是一次转换结束立刻启动下一次中间不留空档。听起来很理想但它的采样间隔取决于转换本身耗时加上中断/DMA 响应开销你没法从外部精确指定「每 50 µs 采一个点」。一旦主循环里有串口发送、有 printf、有别的中断抢占转换节奏立刻变化采样率变成「大概每秒多少点」而不是「严格每秒多少点」。定时器触发就完全不同TIM 的更新事件Update Event通过 TRGO 输出直接连到 ADC 的外部触发源。ADC 收到一个触发沿才开始一次转换两次触发之间隔多久完全由定时器的预分频和自动重装值决定与 CPU 在干什么无关。这就是采样率可计算、可复现的根本原因。常见做法是TIM 工作在向上计数模式PSC 和 ARR 算出触发周期TRGO 选 UpdateADC 配置为外部触发、触发源选对应 TIM 的 TRGO转换模式设为单次或扫描DMA 设为循环模式一次性搬一批。这样定时器就是整个系统的节拍器其他任务再忙也改不了这个节拍。还有个细节值得说为什么不直接在定时器中断里手动启动 ADC因为中断响应有延迟和抖动几十个微秒的抖动在音频或振动采集里就是肉眼可见的频谱污染。用硬件触发把「定时」和「启动」焊死在一起CPU 完全不参与抖动只取决于定时器时钟通常在纳秒级。2.2 一次采样的时间预算采样率是算出来的不是配出来的采样率不是 CubeMX 里填个数字就完事它受 ADC 时钟和采样时间两段共同约束。总转换时间 采样时间 转换时间12 位分辨率通常为 12.5 个 ADC 时钟。以 STM32F103 系列为例ADC 时钟由 PCLK2 分频而来最高 14 MHz常用 12 MHz采样时间选 239.5 周期时参数取值说明ADC 时钟12 MHzPCLK2 72 MHz 经 6 分频采样时间239.5 周期信号源内阻大时要用长采样转换时间12.5 周期12 位分辨率固定开销单次总耗时252 周期 ≈ 21 µs理论上限约 47.6 kSPS实际选定时器触发率20 kHz留一半余量防止 DMA 与串口打架这张表要表达的意思很直白如果你的定时器触发频率高于 ADC 能完成一次转换的速度ADC 会漏触发波形上表现为周期性「丢点」或频率偏低。我一般先把 ADC 时钟和采样时间定死算出单次耗时再取它的一半到七成作为定时器触发率。留余量的原因是 DMA 请求、总线仲裁、串口发送都会占时间片卡在极限值上跑工况一变就翻车。采样时间的选择也有讲究。信号源内阻高比如某些传感器分压后直接进 ADC、或者需要采慢变信号时采样电容充电不充分读数会偏低且不稳。这时候把采样时间从 55.5 周期加到 239.5 周期往往比换芯片更有效。反过来如果追求高采样率就得降低采样时间并确认源阻抗足够低这是个典型的取舍。2.3 缓冲区模型单缓冲、双缓冲和环形队列的取舍数据从 ADC 出来后用什么缓冲区承接直接决定了这套代码能不能用于真实项目。单缓冲最简单DMA 配置成 Normal 模式搬完 N 个点进一次中断中断里把整块数据通过串口发出去。缺点是采集中间有断档而且串口发送期间不采集采样率越高断档越明显。只适合低速、非连续的场合。双缓冲是我最常用的方案DMA 配置成 Circular 模式缓冲区长度设为 2NDMA 半传输中断Half Transfer和传输完成中断Transfer Complete各代表一半数据就绪。半满中断里处理前半段全满中断里处理后半段处理期间 DMA 继续往另一半写。用「处理」和「采集」的时间重叠把断档压到最小。代价是要保证单次处理耗时小于半缓冲填满的时间否则还是会被覆盖。环形队列则更进一步适合采样块大小不固定、或者需要做变速率发送的场景。DMA 写入位置由硬件管理读指针由软件管理中间加临界区保护。但环形队列在 MCU 上容易写成「看起来对、跑起来偶尔错位」的玄学代码除非确有需求我一般先用双缓冲把项目跑通再考虑升级。选择哪一套判据其实很简单先看你的采样率和单块数据量再算一次串口发送要多久如果发送时间接近甚至超过采集一块的时间就必须用双缓冲并考虑降采样或压缩后再发。3. 工程落地CubeMX 参数、目录结构和启动顺序链路想清楚之后落到工程上就是三件事时钟和参数先算好、CubeMX 里逐项核对、目录结构别乱。这一章按我实际建工程的顺序走一遍配置项都给出取值理由方便你按自己的芯片改。3.1 时钟树先定 ADC 时钟再反推采样率配置顺序很关键。很多人习惯先把系统时钟拉到最高再回头配 ADC结果发现 ADC 时钟超限、分频后又和定时器对不上。我的习惯是反过来先在时钟树里确定 ADC 预分频让 ADC 时钟落在 12 MHz 左右再回头检查定时器时钟是否够用。以 72 MHz 主频为例PCLK2 也是 72 MHzADC 预分频选 6 分频得到 12 MHz。定时器挂在 APB1 上若 APB1 预分频不为 1定时器时钟会倍频实际是 72 MHz。定时器触发 20 kHz 的算法是72 MHz / (PSC1) / (ARR1) 20000取 PSC71 得到 1 MHz 计数频率ARR49 得到 20 kHz 更新频率。这两个值要写进代码注释不然半年后回来看就是天书。采样率预算最好在工程根目录放一个简短的说明文件把「ADC 时钟、采样时间、单次耗时、触发率、缓冲块大小、串口波特率」六个数写清楚。这不是形式主义血泪经验是改了一处分频忘了另一处依赖现象是波形频率整体偏移查起来要从头算一遍。3.2 CubeMX 里必须逐项核对的配置下面这张表是我每次新建工程都会对着核一遍的少一项就可能出现「能跑但数据不对」的情况。外设配置项取值注意点TIM3Clock SourceInternal Clock内部时钟即可TIM3Prescaler / Period71 / 49对应 20 kHz 更新TIM3Trigger Event SelectionUpdate EventTRGO 必须设成 UpdateADC1Clock PrescalerPCLK2/6结果 12 MHzADC1External Trig ConvTIM3_TRGO不同系列名字略有差异ADC1Continuous ConversionDisabled关键必须关掉ADC1DMA Continuous RequestsEnabled每次触发都发 DMA 请求ADC1Sampling Time239.5 Cycles高阻源必调大DMAModeCircular配合双缓冲USART1Baud Rate921600由采样率反推见 3.3USART1DMA TXNormal发送用 DMA 不阻塞主循环波特率不能拍脑袋。假设 20 kHz 采样、每点 2 字节、每 100 点打一帧帧长大约 210 字节每秒 200 帧约 42 KB/s加上帧头帧尾和 CRC 约 45 KB/s。8N1 下每字节 10 位需要至少 450 kbps取 921600 留出余量。如果串口跑 115200帧会越积越多最终表现为「上位机看到的是延迟好几秒的历史波形」。这是硬件流控缺失时的典型症状后面避坑章节还会提到。3.3 目录结构与构建方式工程目录我一般这么组织CubeMX 生成的代码和自己的业务代码严格分开重新生成时不会被覆盖adc_timer_daq/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── app_daq.h # 采集业务接口 │ │ └── proto_frame.h # 帧协议定义 │ └── Src/ │ ├── main.c # CubeMX 生成只放初始化和主循环 │ ├── app_daq.c # 缓冲管理、半满/全满回调 │ ├── proto_frame.c # 打包、CRC │ └── stm32f1xx_it.c # 中断入口只做转发 ├── Drivers/ # HAL 库不动 ├── Upper/ # 上位机脚本 │ └── plot_serial.py └── CMakeLists.txt # 或 MDK 工程文件这里的原则是中断服务函数里只做「清标志 置信号量或调一个短函数」真正的处理放在 app_daq.c。CubeMX 重新生成代码时只会动 main.c 和 it.c 的特定区块业务代码放在独立文件里就不会被冲掉。构建方式用 CMake 或 Keil 都行我用 CMake 主要是方便在命令行里一键编译和烧写团队里换人也不用重新配工程。4. 核心代码TRGO 触发、DMA 搬运和帧协议打包配置对了只是前提真正决定数据质量的还是代码里的启动顺序和缓冲切换逻辑。这一章按「定时器 → ADC/DMA 启动 → 中断处理 → 组帧发送」的顺序给代码每段后面说明参数和容易出错的地方。4.1 TIM3 的 TRGO 配置/* TIM3 初始化72MHz / 72 1MHz 计数50 个计数 20kHz 触发 */ static void TIM3_Trigger_Init(void) { TIM_HandleTypeDef htim3 {0}; TIM_MasterConfigTypeDef sMasterConfig {0}; htim3.Instance TIM3; htim3.Init.Prescaler 71; /* 分频后计数频率 1MHz */ htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 49; /* 1MHz / 50 20kHz */ htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(htim3); /* 关键把更新事件映射到 TRGOADC 才能收到硬件触发 */ sMasterConfig.MasterOutputTrigger TIM_TRGO_UPDATE; sMasterConfig.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(htim3, sMasterConfig); HAL_TIM_Base_Start(htim3); /* 只启动计数不开中断 */ }Prescaler和Period两个参数就是采样率的全部来源改采样率只改这两个数别去动 ADC 那边。TIM_TRGO_UPDATE是把更新事件作为触发输出如果误选成TIM_TRGO_RESET或比较通道ADC 要么不触发、要么节奏对不上。AutoReloadPreload建议开启改 ARR 时不会产生半个周期的毛刺。最后用HAL_TIM_Base_Start而不是带中断的版本因为这个定时器只负责产生触发不需要 CPU 介入。4.2 ADC DMA 的启动顺序#define BUF_LEN 200 /* 双缓冲总长前后各 100 点 */ static uint16_t adc_buf[BUF_LEN]; void DAQ_Start(void) { /* 1. 先校准F1/F4 系列校准必须在 ADC 使能前完成 */ HAL_ADCEx_Calibration_Start(hadc1); /* 2. 启动 DMA把 ADC 数据寄存器与内存绑定 */ HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, BUF_LEN); /* 3. 最后启动定时器触发顺序反了会先采到脏数据 */ TIM3_Trigger_Init(); } /* 半满回调前半段 [0, 99] 就绪 */ void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { Proto_SendBlock(adc_buf[0], BUF_LEN / 2); } /* 全满回调后半段 [100, 199] 就绪DMA 已绕回开头 */ void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { Proto_SendBlock(adc_buf[BUF_LEN / 2], BUF_LEN / 2); }顺序是这段代码的核心先校准、再开 DMA、最后开定时器。如果先开定时器ADC 还没准备好就被触发前几个点可能是 0 或者上一次的残留值。回调里只做组帧和投递不做耗时运算因为这两个回调的调用频率等于采样率除以块长20 kHz 采样、100 点一块就是每秒 200 次回调里拖太久会挤占主循环。BUF_LEN必须是偶数否则半满指针会落在元素中间数据就错位了。4.3 帧协议与串口发送裸发 ADC 原始值上位机很难判断哪里是一帧的开头一旦丢一个字节后面全部错位这就是典型的粘包半包问题。我用的帧格式是固定的帧头 2 字节、长度 2 字节、序号 2 字节、数据区、CRC16 两字节。/* 帧格式: AA 55 | len(2) | seq(2) | payload | crc16(2) 小端 */ void Proto_SendBlock(const uint16_t *data, uint16_t cnt) { static uint16_t seq 0; uint8_t frame[8 200 * 2]; uint16_t idx 0; uint16_t payload_len cnt * 2; frame[idx] 0xAA; /* 帧头用于重同步 */ frame[idx] 0x55; frame[idx] payload_len 0xFF; frame[idx] payload_len 8; frame[idx] seq 0xFF; frame[idx] seq 8; seq; memcpy(frame[idx], data, payload_len); /* ADC 值原样小端发出 */ idx payload_len; uint16_t crc CRC16_Modbus(frame, idx); /* 覆盖帧头到数据区 */ frame[idx] crc 0xFF; frame[idx] crc 8; /* 用 DMA 发送队列忙则丢弃本帧并计数绝不阻塞采集 */ if (HAL_UART_Transmit_DMA(huart1, frame, idx) ! HAL_OK) { drop_cnt; } }seq是给上位机用的如果收到的序号不连续就能立刻判断中间丢了帧而不是把两段不连续的波形强行拼在一起。CRC 覆盖范围从帧头到数据区末尾校验失败直接丢帧。发送用 DMA返回非HAL_OK说明上一帧还没发完此时宁可丢帧也不能死等因为阻塞一次就会拖垮下一轮半满中断。frame是局部数组DMA 发送期间必须保证内存有效所以要么声明成 static要么等发送完成再复用——这一点在优化编译器下尤其要注意。4.4 主循环保持极简主循环里我基本只做三件事喂看门狗、处理串口接收的命令、统计丢帧数。while (1) { if (cmd_flag) { /* 上位机下发的增益/采样率切换命令 */ Apply_Command(); cmd_flag 0; } if (drop_cnt 100) { /* 连续丢帧说明链路撑不住 */ LED_Error_On(); } HAL_IWDG_Refresh(hiwdg); }采集链路的实时性全部压在硬件和中断上主循环越空越安全。很多人习惯在主循环里做滤波、做 FFT结果采样率一高就全乱套。滤波放在上位机或者单独的低优先级任务里做主循环只保留必要逻辑。5. 排查清单这五处最容易翻车下面这些是我自己和身边人踩过的坑按「现象 → 原因 → 解决」整理出现类似症状时可以直接对照。5.1 触发与时钟相关的坑现象上位机波形频率整体偏低且随串口波特率变化。原因ADC 用的是连续转换模式或者软件触发采样节奏被主循环和串口发送拖慢波特率越高主循环越闲采样反而越快。解决确认Continuous Conversion已关闭、External Trig Conv已选 TIM_TRGO用示波器量一个 GPIO 翻转在半满回调里翻转来验证实际块频率是否等于采样率除以块长。现象ADC 读数全是 0 或全是满量程。原因校准在 ADC 使能之后才调用或者校准期间有触发进来。解决把HAL_ADCEx_Calibration_Start放在HAL_ADC_Start_DMA之前且校准期间不要启动定时器。另外检查 ADC 通道对应的 GPIO 是否配置成模拟输入配成浮空输入会出现读数漂移。现象低频信号测量正常高频信号幅度明显衰减。原因采样时间太短采样保持电容没充满等效于加了一个低通。解决把采样时间从 55.5 周期提到 239.5 周期或降低信号源内阻提高采样率与加长采样时间是一对矛盾需要在预算表里重新算一遍。5.2 数据错位与丢包相关的坑现象上位机偶尔出现一段波形跳变之后又恢复正常。原因DMA 半满/全满回调里处理时间超过了半块采集时间DMA 已经绕回来覆盖了正在发送的数据。解决减小块长、提高波特率或者在回调里只做 memcpy 到发送缓冲把组帧和 CRC 放到主循环做。判断方法是加一个计数器统计回调耗时超过半块周期的 50% 就要警惕。现象串口收到的数据整体偏移一两个字节CRC 全部失败。原因帧头 0xAA 0x55 在数据区里重复出现上位机按帧头同步时误判。解决上位机解析必须按「找帧头 → 读长度 → 校验 CRC → 失败则从下一个字节重新找」的状态机来写不能简单按帧头 split。协议里加长度字段的意义就在这里。现象连续运行几分钟后开始丢帧重启又正常。原因串口 DMA 发送未完成时被下一次调用覆盖或者发送缓冲没有做忙判断。解决发送前检查huart1.gState忙则丢帧并计数同时确认串口波特率对应的理论吞吐大于实际数据率20 kHz 双字节采样至少要 450 kbps 以上115200 撑不住。6. 二次开发把采样率推上去把协议做厚工程跑通之后多数人的下一步都是「能不能再快一点」和「能不能再稳一点」。这两件事的改造路径不太一样分开说。提速的第一瓶颈通常不是 ADC而是串口。20 kHz、2 字节、每秒 40 KB 原始数据115200 波特率根本发不完921600 也只是勉强。想把采样率推到 100 kSPS 以上有几条路一是换 USB CDC 或 USB 虚拟串口STM32F4/F7 系列自带全速 USB实测跑到 500 KB/s 以上不难二是加片外存储器先存后传适合不需要实时看波形的场景三是降数据量比如只传过零附近的数据或者把 12 位压成 8 位差分。我用得最多的是第一条上位机侧用 pyserial 打开 CDC 端口代码几乎不用改只把波特率参数忽略掉即可。协议做厚的重点是三件事CRC 换成查表实现、加时间戳或块序号、多通道时做交织。CRC16 逐位算在 100 kSPS 下会吃掉可观的 CPU用 256 项查表能把耗时压到原来的八分之一左右。多通道交织的写法是 DMA 缓冲区按[ch0, ch1, ch0, ch1, ...]排列上位机按通道号取模拆分这样能保证同一时刻的多路数据是严格对齐的比分别采集再拼时间戳靠谱得多。上位机这边一段能直接跑的解析代码如下重点是状态机式的重同步逻辑import serial, struct, numpy as np def read_frames(port, baud921600): ser serial.Serial(port, baud, timeout0.5) buf bytearray() while True: buf ser.read(4096) while True: i buf.find(b\xaa\x55) # 找帧头失败就丢弃前面的字节 if i 0 or len(buf) i 8: break length struct.unpack_from(H, buf, i 2)[0] total 6 length 2 if len(buf) i total: break # 半包等下一批数据 frame buf[i:i total] if crc16(frame[:-2]) ! struct.unpack_from(H, frame, -2)[0]: buf buf[i 1:] # 校验失败跳一个字节重新同步 continue seq struct.unpack_from(H, buf, i 4)[0] yield seq, np.frombuffer(frame[6:-2], dtypeu2) buf buf[i total:] def crc16(data): crc 0xFFFF for b in data: crc ^ b for _ in range(8): crc (crc 1) ^ 0xA001 if crc 1 else crc 1 return crc这段代码里buf buf[i 1:]那一行是重同步的关键校验失败只跳过帧头的一个字节而不是整帧丢掉因为很可能是数据区里恰好出现了 0xAA 0x55 导致的伪同步。seq返回给调用方后可以检测跳变来统计丢帧率。如果在 Python 里做实时 FFT注意np.frombuffer返回的是视图后续处理前要copy()否则下一帧数据进来会把它改掉这个坑我调了半个下午才反应过来。从那以后我每次新建采集工程都会先在纸上把「ADC 时钟 → 单次转换时间 → 定时器触发率 → 每帧点数 → 帧长 → 所需波特率」这条链算一遍再动手配置哪怕项目再小也不跳步。这套东西看起来只是几个数字但任何一个环节估错后面调波形的时间就是成倍增加。希望这些能帮你少走几段弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑