简介:面向嵌入式初学者与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 选 Update;ADC 配置为外部触发、触发源选对应 TIM 的 TRGO,转换模式设为单次或扫描,DMA 设为循环模式一次性搬一批。这样定时器就是整个系统的节拍器,其他任务再忙也改不了这个节拍。
还有个细节值得说:为什么不直接在定时器中断里手动启动 ADC?因为中断响应有延迟和抖动,几十个微秒的抖动在音频或振动采集里就是肉眼可见的频谱污染。用硬件触发把「定时」和「启动」焊死在一起,CPU 完全不参与,抖动只取决于定时器时钟,通常在纳秒级。
2.2 一次采样的时间预算:采样率是算出来的,不是配出来的
采样率不是 CubeMX 里填个数字就完事,它受 ADC 时钟和采样时间两段共同约束。总转换时间 = 采样时间 + 转换时间(12 位分辨率通常为 12.5 个 ADC 时钟)。以 STM32F103 系列为例,ADC 时钟由 PCLK2 分频而来,最高 14 MHz,常用 12 MHz,采样时间选 239.5 周期时:
| 参数 | 取值 | 说明 |
|---|---|---|
| ADC 时钟 | 12 MHz | PCLK2 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 模式,缓冲区长度设为 2N,DMA 半传输中断(Half Transfer)和传输完成中断(Transfer Complete)各代表一半数据就绪。半满中断里处理前半段,全满中断里处理后半段,处理期间 DMA 继续往另一半写。用「处理」和「采集」的时间重叠,把断档压到最小。代价是要保证单次处理耗时小于半缓冲填满的时间,否则还是会被覆盖。
环形队列则更进一步,适合采样块大小不固定、或者需要做变速率发送的场景。DMA 写入位置由硬件管理,读指针由软件管理,中间加临界区保护。但环形队列在 MCU 上容易写成「看起来对、跑起来偶尔错位」的玄学代码,除非确有需求,我一般先用双缓冲把项目跑通,再考虑升级。
选择哪一套,判据其实很简单:先看你的采样率和单块数据量,再算一次串口发送要多久,如果发送时间接近甚至超过采集一块的时间,就必须用双缓冲并考虑降采样或压缩后再发。
3. 工程落地:CubeMX 参数、目录结构和启动顺序
链路想清楚之后,落到工程上就是三件事:时钟和参数先算好、CubeMX 里逐项核对、目录结构别乱。这一章按我实际建工程的顺序走一遍,配置项都给出取值理由,方便你按自己的芯片改。
3.1 时钟树先定 ADC 时钟,再反推采样率
配置顺序很关键。很多人习惯先把系统时钟拉到最高,再回头配 ADC,结果发现 ADC 时钟超限、分频后又和定时器对不上。我的习惯是反过来:先在时钟树里确定 ADC 预分频,让 ADC 时钟落在 12 MHz 左右,再回头检查定时器时钟是否够用。
以 72 MHz 主频为例,PCLK2 也是 72 MHz,ADC 预分频选 6 分频得到 12 MHz。定时器挂在 APB1 上,若 APB1 预分频不为 1,定时器时钟会倍频,实际是 72 MHz。定时器触发 20 kHz 的算法是:72 MHz / (PSC+1) / (ARR+1) = 20000,取 PSC=71 得到 1 MHz 计数频率,ARR=49 得到 20 kHz 更新频率。这两个值要写进代码注释,不然半年后回来看就是天书。
采样率预算最好在工程根目录放一个简短的说明文件,把「ADC 时钟、采样时间、单次耗时、触发率、缓冲块大小、串口波特率」六个数写清楚。这不是形式主义,血泪经验是:改了一处分频,忘了另一处依赖,现象是波形频率整体偏移,查起来要从头算一遍。
3.2 CubeMX 里必须逐项核对的配置
下面这张表是我每次新建工程都会对着核一遍的,少一项就可能出现「能跑但数据不对」的情况。
| 外设 | 配置项 | 取值 | 注意点 |
|---|---|---|---|
| TIM3 | Clock Source | Internal Clock | 内部时钟即可 |
| TIM3 | Prescaler / Period | 71 / 49 | 对应 20 kHz 更新 |
| TIM3 | Trigger Event Selection | Update Event | TRGO 必须设成 Update |
| ADC1 | Clock Prescaler | PCLK2/6 | 结果 12 MHz |
| ADC1 | External Trig Conv | TIM3_TRGO | 不同系列名字略有差异 |
| ADC1 | Continuous Conversion | Disabled | 关键,必须关掉 |
| ADC1 | DMA Continuous Requests | Enabled | 每次触发都发 DMA 请求 |
| ADC1 | Sampling Time | 239.5 Cycles | 高阻源必调大 |
| DMA | Mode | Circular | 配合双缓冲 |
| USART1 | Baud Rate | 921600 | 由采样率反推,见 3.3 |
| USART1 | DMA TX | Normal | 发送用 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); /* 关键:把更新事件映射到 TRGO,ADC 才能收到硬件触发 */ 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, baud=921600): ser = serial.Serial(port, baud, timeout=0.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], dtype='<u2') 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 时钟 → 单次转换时间 → 定时器触发率 → 每帧点数 → 帧长 → 所需波特率」这条链算一遍再动手配置,哪怕项目再小也不跳步。这套东西看起来只是几个数字,但任何一个环节估错,后面调波形的时间就是成倍增加。希望这些能帮你少走几段弯路。
本文还有配套的精品资源,点击获取