去年做一个传感器数据采集模块,主控用的 STM32F103C8T6,需要从串口接收一台设备每隔几十毫秒发来的非定长数据帧。一开始图省事,直接开逐字节接收中断,把帧解析逻辑全塞在中断函数里。跑到 115200 波特率之后,系统的定时任务开始频繁抖动,一帧 64 字节的数据偶尔还会丢一两个字节。后来我彻底重写了串口收发方案:DMA 搬运数据,空闲中断划定帧边界,CPU 只在完整一帧到达后进中断取一次结果。这篇文章就把这套方案的配置流程、核心代码、原理分析和踩坑过程完整过一遍,适合正在用 STM32F103 做串口协议交互、觉得传统中断接收不够用的人参考。
1. 为什么放弃逐字节中断:传统接收方案的三个死穴
1.1 中断风暴与 CPU 占用的失控
很多人刚开始写串口接收,最顺手的就是在USART1_IRQHandler里判断RXNE标志,然后把DR寄存器里的字节读到一个数组里。这个逻辑本身没错,但它有一个隐形成本:每收到一个字节,CPU 都要进一次中断。115200 波特率的 8N1 格式下,一个字节在总线上占 10 位(起始位 1 + 数据位 8 + 停止位 1),每秒会触发 11520 次接收中断,平均每 86 微秒就要打断一次 CPU。
如果你只是接收几条调试指令,这点开销可以忽略。但如果串口持续有数据流,而且你在中断里还做了帧头判断、长度解析甚至查表操作,那 CPU 的有效算力就被严重蚕食。我当时在主循环里跑着一个 1ms 周期的软件定时器,串口数据一密集,定时器就开始漂,漂到后面干脆出现几毫秒的跳变。用 GPIO 翻转法实测,逐字节中断占用的 CPU 时间在 115200 波特率下超过 30%,这在很多场景下是不可接受的。
1.2 帧边界的判断:中断里做超时判断很难受
不定长接收的核心问题不是"怎么收字节",而是"怎么知道一帧收完了"。常见做法是设计协议时带帧头、长度域、帧尾,接收端靠状态机判断。但现实里很多传感器、屏幕模组、老设备,协议就是简单的"发一串数据就算一帧",帧头帧尾都不给。这时候最朴素的办法是"超时判定"——最后一个字节后超过某个时间没有新数据,就认为一帧结束。
这个思路在裸机主循环里做还好办,在主循环里轮询"距上次收到字节的时间差"。可如果你把它塞进逐字节中断里做,就非常别扭——中断服务函数没法"等一等",你只能每进一次中断就更新一个时间戳,再靠另一个定时器去判断超时。代码绕一圈下来,复杂度上去了,还容易出现临界区问题。
1.3 DMA 加空闲中断的组合逻辑
DMA 加空闲中断的组合,恰好把上面两个问题一起解决。DMA 做的事情是:收到字节后由 DMA 控制器自动搬到内存缓冲区,整个过程 CPU 零参与,也不会产生每字节一次的中断。而 USART 外设自带的 IDLE(空闲)检测功能,会在总线上出现超过一个字节时间的空闲状态时拉高IDLE标志,这个标志正好可以当作"一帧结束"的信号。
我把这套机制类比成一条流水线:DMA 是自动装盒的机械臂,字节一个接一个丢进盒子,CPU 该干嘛干嘛;当传送带上没有新零件进来时,旁边的传感器会"叮"一声通知你——盒子可以封了,来取货。这里的"叮"就是空闲中断。这样一来,不定长接收就从"逐字节忙活"变成"一帧一次收工",CPU 占用几乎降到零。
2. 动手配置前的关键决策:CubeMX 参数与硬件基本盘
2.1 STM32F103 的供电与引脚底线
先说一个容易被忽略的硬件前提。STM32F103 的绝对最大 VDD 是 4.0V,长期运行建议 3.3V。如果你手上的板子是通过 USB 的 5V 直接供电,必须先做降压。低成本方案是 AMS1117-3.3 这类 LDO,电流要求高一点就上 MP1584 这类 DC-DC。网上经常有人问"STM32F103 能不能 5V 供电",答案是绝对不能直连,除非你的板子自带稳压电路。
引脚方面,USART1 默认映射在 PA9(TX)和 PA10(RX),这两个引脚是 FT(5V 容忍)引脚。如果你的外部设备输出电平是 5V,接 PA9/PA10 一般问题不大。但其他串口映射的引脚不全是 FT,比如 PB6/PB7 就不是。把 5V 信号直接灌进非 FT 引脚,运气好是读数异常,运气不好直接烧引脚。所以做转接板之前,先翻一下数据手册的引脚表格,确认是不是 FT。
2.2 CubeMX 里的 USART 与 DMA 配置顺序
用 CubeMX 生成工程时,配置顺序有讲究。我先配 USART1 的模式为Asynchronous,波特率 115200,字长 8 Bits,无校验,1 停止位。然后立刻切到DMA Settings选项卡,添加 TX 和 RX 两个 DMA 请求。
这里有两个关键点。
一是 DMA 通道选择。STM32F103 的 DMA1 通道和外设请求是固定映射的,USART1_TX 只能用 DMA1_Channel4,USART1_RX 只能用 DMA1_Channel5。如果你用的是 USART2,那就是 TX 对应 DMA1_Channel7、RX 对应 DMA1_Channel6。我在早期项目里曾经在 CubeMX 里手滑选了 Channel2,结果编译没问题,运行起来就是收不到数据,查了半天才发现是通道映射错了。CubeMX 自动生成的代码不会帮你纠正通道,它只会按你选的通道生成配置,所以这里务必对照参考手册确认。
二是 DMA 模式。接收用Circular(循环)模式,发送用Normal(正常)模式。原因后面详细讲,但你先记住这个组合:循环接收是为了让 DMA 不停歇地往缓冲区里写,配合空闲中断在任意时刻都能取到"最新一帧";发送用 Normal 是因为发送本来就是一次性的操作。
系统里中断优先级这一栏,USART1 global interrupt必须勾上。这里有个常见误区:以为用了 DMA 接收就不需要串口中断了。其实 IDLE 标志是挂在 USART 外设上的,它要靠串口中断把事件通知到 CPU。DMA 通道本身的中断要不要开?如果我的接收方案只用空闲中断判定帧结束,那么 RX DMA 通道的中断完全可以不开。TX DMA 中断则按需选择——如果你想知道发送是否彻底完成,可以打开并在回调里做处理。
2.3 一个容易翻车的点:数据位与校验位
CubeMX 里8 Bits字长,在不开启校验的情况下,DMA 搬运的字节就是 8 位有效数据,缓冲区定义成uint8_t数组正好。但如果你选了8 Bits + Parity,比如常见的 8E1 或 8O1,那么总线上传输的每帧其实包含 7 位数据加 1 位校验位,硬件在接收时会把校验位剥离,DMA 写到内存的仍然是 8 位。这里容易晕的地方在于:你定义的协议帧里,一个字节只能表达 7 位有效数据,0x80~0xFF 这些值根本发不出去。所以做工程前先想清楚协议对数据范围的要求,不要盲目为了"多一位校验"牺牲数据位宽。
3. DMA 发送的落地实现与 printf 重定向
3.1 发送的基本套路
DMA 发送的调用很简单,一句话:
HAL_UART_Transmit_DMA(&huart1, tx_buf, len);但简单不代表可以乱用。HAL 库的 DMA 发送是异步的,函数返回时数据可能还在搬运过程中。如果你连续调用两次发送,第二次会在 DMA 还没发完上一次数据时撞车。一个可靠的做法是在调用前检查串口状态:
#define TX_TIMEOUT 100 void uart_send_dma(uint8_t *data, uint16_t len) { uint32_t tick = 0; while (HAL_UART_GetState(&huart1) == HAL_UART_STATE_BUSY_TX) { // 等待之前发送完成 if (++tick > TX_TIMEOUT) return; // 超时保护,避免死等 // 这里可以加延时,也可以直接让出 CPU } HAL_UART_Transmit_DMA(&huart1, data, len); }HAL_UART_STATE_BUSY_TX表示发送正在进行中。这个状态在 DMA 发送全部结束后,由发送完成中断回调把串口状态改回READY。所以如果你不开 TX DMA 中断,HAL 库就永远不知道发送已经结束,下一次调用就总是超时。这也是很多人"DMA 发送只能发一次"的原因之一。
3.2 为什么发送完成后不要立刻改缓冲区
这是我在项目里真实踩过的一个坑。一开始我在HAL_UART_Transmit_DMA返回后,立刻往发送缓冲区里填下一帧数据,结果串口助手收到的数据里,尾部几个字节频繁不对。原因很简单:DMA 是异步搬运,它读的是内存地址,函数返回后 DMA 可能还在读这块内存。你往缓冲区里写新数据,等于把 DMA 还没搬走的旧数据覆盖了。
正确做法是在发送完成回调里置一个标志位:
volatile uint8_t tx_complete_flag = 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { tx_complete_flag = 1; } }主循环里发送完数据后,等tx_complete_flag置位再复用缓冲区。或者干脆像上面那样,每次发送前轮询HAL_UART_GetState。两条路殊途同归,核心是要尊重 DMA 的异步特性。
3.3 printf 重定向与 DMA 发送的配合
我记得相关热词里有"printf to usart stm32",这个需求几乎每个人都会遇到。最常见的方式是重定向fputc,让printf走串口。如果你用的是标准库,代码一般长这样:
#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100); return ch; }这种方式是阻塞发送,在调试打印时没问题,但有两个隐患:一是每发一个字符都要等整个字节发送完成,打印一片日志可能浪费几毫秒;二是一旦你在中断服务函数里不小心使了printf,整个系统可能直接卡死。
后来我试过把fputc改成 DMA 发送加等待标志:
int fputc(int ch, FILE *f) { uint8_t byte = ch; tx_complete_flag = 0; if (HAL_UART_Transmit_DMA(&huart1, &byte, 1) == HAL_OK) { while (!tx_complete_flag); } return ch; }实测下来,单字符 DMA 发送的收益其实很低,因为每发一个字符都要等待完成,本质上还是阻塞,而且 DMA 搬运单字节的开销比直接寄存器发送还大。所以我个人最后采用的方案是:工程里保留一个专门的调试串口,printf用阻塞发送,但只用于低速调试输出;实际业务数据走独立的 DMA 发送通道,互不干扰。如果你只有一个串口,那建议printf也走 DMA,但不要在低波特率(9600 以下)下频繁打印,否则 CPU 照样会被拖住。
4. 空闲中断 + DMA 不定长接收的完整实现
4.1 IDLE 标志的触发时机与清除方法
首先明确一下"空闲"的判定标准:USART 从收到第一个字节开始计时,如果总线上持续一个字节时间(包含起始位、数据位、校验位、停止位)没有检测到新的起始位,IDLE标志就会置位。这个时间是硬件固定的,对 8N1 格式、115200 波特率来说大约是 86.8 微秒。
IDLE标志清除有讲究。在寄存器层面,读取USART_SR寄存器再读取USART_DR寄存器,硬件会自动清除标志。但在 HAL 库里,标准做法是用__HAL_UART_CLEAR_IDLEFLAG(&huart1),这个宏实际做的事情是直接对SR寄存器里的IDLE位写 0 清除。我在调试时发现一个细节:如果你用了HAL_UART_IRQHandler再手动判断 IDLE 标志,一定要在退出中断前清掉标志,否则会反复进入中断。
4.2 循环缓冲区里计算接收长度:从 NDTR 倒推
接收缓冲区我定义成 1024 字节,DMA 工作在循环模式。循环模式的意思是 DMA 从缓冲区首地址开始写,写到末尾后自动回卷到开头继续写,像一圈环形传送带。
DMA 控制器里有个NDTR寄存器,记录"还剩多少次传输没有完成"。初始化时NDTR等于缓冲区大小 1024,每收到一个字节减 1。所以当前已接收字节数就是:
rx_len = RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(&hdma_usart1_rx);这里要注意一个边界情况:如果 DMA 已经完整绕了一圈回到初始位置,NDTR又变回 1024,你算出来的rx_len就是 0。如果接收的数据量超过缓冲区大小,前面的数据就被覆盖了。所以缓冲区尺寸最好大于最大协议帧长度,或者直接用 DMA 传输完成中断来处理"缓冲区满了"的场景。文章后面我会专门讲这个坑。
4.3 核心代码:初始化、中断服务、主循环处理
直接看代码,这是我自己项目里精简出来的版本,注释已经写得很细:
// uart_dma.c // 接收缓冲区,大小大于最大帧即可 #define RX_BUF_SIZE 1024 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_len = 0; volatile uint8_t rx_complete_flag = 0; // 开启 DMA 接收 + 空闲中断 void uart_start_rx_dma(void) { // 先清空闲标志 __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 使能空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 启动 DMA 循环接收 HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); }// stm32f1xx_it.c 里的串口中断服务函数 void USART1_IRQHandler(void) { // 让 HAL 库先处理它自己关心的收发中断 HAL_UART_IRQHandler(&huart1); // 判断空闲标志 if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { // 清除空闲标志 __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 停止 DMA 接收,此时 NDTR 保留当前值 HAL_UART_DMAStop(&huart1); // 计算本次收到的字节数 rx_len = RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 置位帧完成标志,主循环处理 rx_complete_flag = 1; // 立即重启 DMA 接收,准备下一帧 uart_start_rx_dma(); } }// main.c 主循环 while (1) { if (rx_complete_flag) { rx_complete_flag = 0; // 此时 rx_buf 里前 rx_len 个字节就是完整一帧 // 把数据拷贝出去,或直接原地解析 process_frame(rx_buf, rx_len); // process_frame 里注意:如果处理速度跟不上接收速度, // 下一帧数据可能已经覆盖 rx_buf,需要自行管理双缓冲 } }这段代码有几点值得解释。
第一,我在空闲中断里做了"停止 DMA"的动作。很多人会问:循环模式的 DMA 本来就能一直接收,为什么还要停止再重启?因为我想计算长度。停止 DMA 才能让NDTR停下来,不然它继续往下走,等你读计数器的时候已经不准了。停止后马上重启 DMA,空闲中断和循环接收又回到初始状态,整个流程可以无限循环。
第二,HAL_UART_DMAStop会不会把发送 DMA 也停了?不会。它内部只是停止接收对应的 DMA 通道,具体操作的是huart->hdmarx指向的hdma_usart1_rx。发送通道是独立句柄,互不影响。
第三,也是最重要的一点:中断服务函数里不要做耗时业务处理。我的代码里只做三件事——清标志、算长度、置标志,真正的协议解析放在主循环。原则是"中断快进快出",你在中断里多停留 1 微秒,整个系统的实时性都会受影响。
4.4 长帧跨越环形缓冲区边界怎么破
前面提到,循环 DMA 模式最大的坑是数据量超过缓冲区大小时,新数据会覆盖旧数据,而且NDTR会出现回绕,长度计算直接失效。
比如你定义了 128 字节的缓冲区,外面设备一口气发了 200 字节的帧。DMA 从 0 写到 127,然后自动回到 0 继续写 128~199。此时如果空闲中断触发,NDTR的值可能是 56(因为 200 字节已经被搬走,128 - 200 = -72,模 128 之后等效于 56),你算出rx_len = 128 - 56 = 72,但这 72 其实是"回绕后的一部分",而不是完整的一帧,里面可能还混了两帧的数据。
处理思路有三个层次。
第一个层次,也是最省事的:把缓冲区开大。如果你知道最大帧长不超过 512 字节,那就定义 1024 或更大的缓冲区,保证单帧数据永远填不满缓冲区。这个方案在绝大多数场景下就够用了,毕竟串口数据量通常不会大到一次性塞满几 KB。
第二个层次:利用 DMA 的"传输完成中断"(也叫半传输/全传输中断)。在循环模式下,DMA 每次绕缓冲区一圈会触发一次传输完成中断(CubeMX 里勾上 DMA 通道中断即可)。在中断里我可以判断当前 DMA 写到哪个位置,和上一次保存的位置做差,就能知道这段时间内收到了多少字节。这种方案适合缓冲区刻意开得不大、但又不想丢数据的场景。
第三个层次:干脆不用手动管理回绕,改用 HAL 库新一点的接口——HAL_UARTEx_ReceiveToIdle_DMA。这个接口把"空闲中断 + DMA 接收 + 半满/全满事件"全部封装好,回调函数会直接告诉你本次收到多少字节:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { // Size 就是本次空闲事件触发时的接收长度 }但这个方案有个使用前提:你的 HAL 库版本比较新(1.7.0 之后基本都有),并且你需要充分理解它内部对NDTR和空闲标志的处理方式。我自己做调试时更喜欢手动控制,因为能明确看到每一步在干什么,出了问题好定位。
5. 调试实录:四个高频问题的完整排查链路
5.1 问题一:只能收到第一帧,后面全部静默
这个问题出现的频率极高,几乎每个人第一次写这套方案都会碰到。现象是上电后串口助手发一帧数据,程序能正确解析,但发第二帧、第三帧就完全没反应了。
排查链路我按顺序列一遍,你可以对着检查。
第一步,在USART1_IRQHandler里打断点,看第二帧数据到达时有没有进中断。如果根本没进中断,说明空闲中断没有重新使能,或者 DMA 接收没有重新启动。你可以检查uart_start_rx_dma()有没有在第一次接收完成后被调用。我的代码把重启动作放在中断服务函数末尾,就是为了避免主循环处理完业务后忘了重启。
第二步,如果中断能进去,但rx_len计算出来是 0,说明NDTR的值等于RX_BUF_SIZE,也就是 DMA 计数器根本没被减。这种情况大概率是 DMA 通道配置错误,或者HAL_UART_Receive_DMA没有真正生效。
第三步,如果rx_len正常但主循环不处理,那检查rx_complete_flag有没有被清掉。这里有个经典失误:主循环用完之后把标志清了,结果中断置位时主循环又刚好把标志清了,造成丢帧。我习惯的做法是"先清标志再处理数据",因为处理过程耗时,如果中断在期间置位,等处理完再清标志就会把新帧的标志误删。
5.2 问题二:空闲中断频繁误触发,凭空收到数据
另一个高频问题是:明明没有发数据,程序却一直报"收到一帧",而且长度不稳定。
这种通常不是 DMA 或空闲中断本身的问题,而是干扰或误判。我记得有次调试时,串口线没接牢固,地线接触不良,结果接收引脚上全是感应噪声,USART 都识别成有效起始位了,自然一直接到垃圾数据。先查硬件:串口助手端有没有共地?杜邦线是不是太长?大致排查流程如下。
先看是不是字节间隔过大。用串口助手手动发送 HEX 数据时,如果两个字节之间间隔超过了一字节空闲时间,硬件就会把同一帧逻辑上的数据切成好几段,每段都触发一次空闲中断。你在主循环里会看到好多"短帧"。这个不是程序 bug,而是物理层发送方式导致的。解决办法是在协议层做帧头校验和长度校验,把不完整的残帧丢弃。
再看中断处理里有没有调用 HAL 库的回调函数。比如在空闲中断里不小心调用了HAL_UART_RxCpltCallback相关操作,或者清标志顺序不对,都可能导致中断反复进入。我还见过一种情况:清空闲标志用了读SR再读DR的方式,但HAL_UART_IRQHandler已经把DR读走了,导致标志没清掉,中断死循环。
5.3 问题三:第一个字节丢失或数据错位
这个问题比前一个更隐蔽。现象是接收到的数据整体错位了 1 个字节,比如发01 02 03 04,收到的是02 03 04 00,或者开头第一个字节总是丢。
排查时我最先想到的是配置冲突。如果你在 CubeMX 里同时开了 USART1 全局中断和 DMA 接收,又自己在中断里处理了RXNE中断——也就是"逐字节中断方式"和"DMA 方式"混用——那么两个机制会抢同一个DR寄存器,造成数据错乱。正确做法是:既然用了 DMA,就不要在中断里再处理RXNE。我在项目里只用空闲中断判断帧尾,RXNE完全不碰。
第二个可能原因是引脚复用功能没配好。PA9/PA10 默认是普通 GPIO,必须在 CubeMX 里把 USART1 勾上,并在初始化时调用MX_GPIO_Init之后调用MX_USART1_UART_Init,引脚复用功能才会正确初始化。我自己曾经把初始化函数顺序搞反,结果串口引脚一直处于 GPIO 模式,通信杂乱无章。
第三个原因是电平匹配问题。前面提过,非 FT 引脚接 5V 信号会异常。如果你用的是 USART2 且映射到 PB3/PB4,这两个也不是 FT 引脚,外设 5V 收发器的输出直接接上去,第一字节被钳位丢失就很正常。
5.4 问题四:串口助手收到的全是乱码
DMA 发送出现乱码,排查链路相对固定。
先确认波特率和时钟源。STM32F103 的 USART 波特率由 APB 时钟决定,如果系统时钟不是 72MHz(比如外部晶振没振起来,降到了 8MHz 的内部 HSI),那 115200 的实际波特率会偏差很大。建议在初始化后打印一下SystemCoreClock的值,确认时钟树配置正确。
然后检查发送缓冲区格式。DMA 发送的是原始内存数据,如果你定义的是char数组,里面有中文字符串,printf或格式化之后占用字节数要算对。我在HAL_UART_Transmit_DMA里传的长度参数,必须是实际要发送的字节数,而不是缓冲区数组的 sizeof,否则会把未初始化的内存也发出去。
最后检查 USB-TTL 模块。很多便宜的模块在高波特率下稳定性一般,换个模块或者把波特率降到 57600 试试,能快速区分是程序问题还是硬件问题。
6. 实测数据与一套可以继续扩展的姿势
6.1 三种帧长的接收压力测试
我手上的测试环境是:STM32F103C8T6 最小系统板,板载 USB-TTL 转串口模块,串口助手从电脑发数据,波特率 115200,8N1。分别用 1 字节、64 字节、512 字节三种帧长,每帧之间间隔 10 毫秒,每种发 1000 帧,统计结果如下:
| 帧长 | 理论总耗时 | 接收成功帧数 | 丢帧/误帧数 | 接收成功率 |
|---|---|---|---|---|
| 1 字节 | 约 87ms | 1000 | 0 | 100% |
| 64 字节 | 约 5.6s | 1000 | 0 | 100% |
| 512 字节 | 约 44.5s | 999 | 1(长度误判) | 99.9% |
512 字节那一次误判,问题出在缓冲区处理速度上。我的接收缓冲区是 1024 字节,处理器在一帧到达后需要把数据拷贝到应用缓冲区,如果下次接收启动时数据还没拷贝完,后续数据会覆盖。后来我把数据拷贝改成双缓冲结构,再跑 1000 帧就全过了。这个案例也说明一个道理:方案本身没问题,但工程实现里的临界区、缓冲区管理才是真正决定稳定性的地方。
6.2 如何测量 CPU 占用
我测量 CPU 占用的方法很土但很有效:在主循环里放一个 GPIO 翻转,用示波器或逻辑分析仪看低电平持续时间。先用阻塞式HAL_UART_Transmit发大量数据,测出来的低电平时间明显很长;换成 DMA 发送后,低电平时间几乎消失。逐字节中断接收时,GPIO 翻转频率也会变乱;换成 DMA 接收后,主循环执行时间变得非常稳定。
如果想要量化数据,可以在空闲中断入口翻转一次 GPIO,统计每 1000 帧中断消耗的总时间,除以 1000 就得到平均中断处理耗时。我实测这套空闲中断处理代码,一次中断大约 1~2 微秒,而逐字节接收一帧 64 字节需要进 64 次中断,光中断入口开销就高了两个数量级。
6.3 与 Modbus、AI 设备等协议对接的适配思路
这套方案要对接 Modbus-RTU 时,会有一个概念上的坑先讲清楚。Modbus-RTU 规定帧间间隔是 3.5 个字符时间(9600 波特率下约 4ms),帧内字节间隔不能超过 1.5 个字符时间。而 STM32 的 IDLE 中断触发条件是一个字节时间的空闲,也就是 1T 时间,小于 Modbus 的 3.5T。所以如果你直接把空闲中断当成 Modbus 帧结束标志,一台设备在发出完整报文后,如果它字节间有轻微的间隔抖动,就可能被拆成多帧。
解决办法是:空闲中断只负责"收到了一串数据,可以看看了",具体是不是完整 Modbus 帧,还需要在主循环里根据协议规则做二次判断——比如根据CRC16校验,不满足就继续缓存攒数据。我自己在对接一个数据采集器时就是这么处理的:一个 256 字节的拼接缓冲,空闲中断把数据追加进去,然后循环尝试解析 Modbus 帧,成功了就把帧头消费掉。这相当于在 DMA 物理层之上又做了一层软件协议缓冲。
对 AI 传感器来说,很多设备用的是主动持续上报模式,比如每 100 毫秒发一组 JSON 字符串。这套方案同样适用,但要注意字符串帧里\r\n是帧尾,空闲中断可能把多个帧粘在一起,需要在软件里按行切分。
6.4 低功耗与多串口扩展
低功耗场景下,DMA 加空闲中断反而是更好的选择。思路是:进入 STOP 模式前,先让 DMA 处于循环接收状态,并使能空闲中断。外设收到数据时,DMA 搬运不需要 CPU 参与,当一帧结束触发空闲中断时,系统可以从 STOP 模式唤醒。唤醒后先处理帧数据,然后重新进入 STOP 模式等待下一帧。我在一个低功耗传感节点上测过,纯待机电流能做到才几十微安级别(具体数值取决于外围电路),串口一来数据就能秒级响应。
多串口扩展就更直接了。F103 有 USART1/2/3,每一路串口都按照上面的流程配一套独立的缓冲区、长度变量和完成标志,中断服务函数里分别加判断分支。要注意的是中断优先级要分配好,比如 USART1 中断优先级设为高于 USART2,避免高波特率串口被低优先级串口阻塞。DMA 通道资源也要规划清楚,三路串口的收发 DMA 加起来已经用了 DMA1 的多个通道,别和别的外设(比如 ADC、SPI、定时器)抢通道。
最后再分享一个我的实际体会:这套 DMA 加空闲中断的不定长接收方案,我后来在好几个项目里复用,核心代码几乎没有改动,真正要动的只是缓冲区大小和协议解析部分。刚上手时,不要急着把代码写得天花乱坠,先把"启动一次接收、空闲标志处理、长度计算、重启接收"这个闭环跑通,再把协议解析加进来。闭环跑通了,后面的所有扩展都是在这个骨架上添砖加瓦。