STM32 HAL库UART串口通信:参数配置、环形缓冲与DMA空闲中断
1. 我先说清楚为什么固定用这套 HAL 库 UART 写法
STM32 的串口通信大概是整个 HAL 库里门槛最低的一块,CubeMX 点几下、生成代码、调个HAL_UART_Transmit就能往外吐数据,很多人点亮 LED 之后紧接着做的就是这件事。但我这几年在几个量产项目里反复折腾下来,发现 UART 恰恰是最容易在后期翻车的外设:调试阶段一切正常,一上现场就开始丢包;单机测试稳如老狗,接上第二个节点就开始打架;换一颗同系列不同型号的芯片,原来能跑的代码直接哑火。这篇笔记不复述参考手册里的寄存器位定义,而是把我自己用 HAL 库做串口通信的完整思路摊开——参数怎么算、三种收发方式各自适合什么场景、环形缓冲为什么非加不可、出问题往哪里看。
不管你是刚接触 STM32 的新手,还是做了几年仍在主循环里轮询收发的老手,下面的内容应该都能捡到点能直接抄的东西。
1.1 从寄存器版到 HAL 版的这笔账怎么算
早些年我是纯寄存器派,USART1->BRR = ...、USART1->CR1 |= USART_CR1_UE | USART_CR1_TE | USART_CR1_RE,几行代码搞定,执行效率也确实漂亮。但项目一多,问题就出来了:不同系列的芯片寄存器名字和位定义有差异,F1、F4、H7 各自一套,代码没法直接搬;团队里新来的人接手,注释写不全就得回去翻手册;更麻烦的是中断和 DMA 的联动,手动配 NVIC 优先级、清标志位、算 DMA 计数器,一处漏了就埋一个偶发性的坑。
HAL 库的代价很明确:多一层函数调用、句柄结构体占内存、中断处理链路比裸写长。换来的是三件事——跨系列的可移植性、一套统一的中断回调框架、以及 DMA 和中断的组合被封装成了几行调用。对于绝大多数工业控制、模组通信、人机交互的场景,UART 的速率撑死也就几兆,多出来的那点函数调用开销完全不是瓶颈;真正的瓶颈永远在协议设计、缓冲管理和错误处理上。所以我的选择是:业务层用 HAL,个别对时序敏感的场合再局部下探到寄存器,比如手动清 IDLE 标志这种 HAL 没有直接覆盖的操作。
这里要提醒一句:一旦你决定用 HAL 的 UART 中断框架,就别在同一个外设上混着裸写中断服务函数和
HAL_UART_IRQHandler,两边抢着清标志位,最后一定出问题。
1.2 UART 在真实项目里到底扮演几个角色
搞清楚这一点,后面的选型才有依据。我统计过自己经手的项目,UART 主要出现在四种场合,要求完全不同。
第一种是调试口。开发阶段用来打日志、看变量,特点是单向、低速率、随时可能被打断。这种场景我只关心一件事:别把主循环堵住,也别因为日志把数据通道挤掉。第二种是模组指令通道,比如各类无线模组、定位模组的 AT 指令交互,特点是请求-应答模式、不定长、有超时要求。第三种是设备间协议通信,像 Modbus 这类主从结构,特点是帧格式严格、有校验、有帧间隔判定。第四种是接上位机或者屏,特点是大块数据、突发流量。
这四种场景对收发方式的要求差异极大。调试日志用中断发送就够,AT 指令用中断接收加环形缓冲,Modbus 必须靠帧间隔超时来切帧,大块数据才轮到 DMA。很多人一上来就无脑上 DMA,结果协议本身是请求-应答的小包结构,DMA 配置的复杂度反而成了负担。所以别问"哪个方式最好",先问自己这个口在干什么活。
2. 参数配置这关:波特率误差、引脚模式、CubeMX 必改项
参数配置是最容易被跳过的一步,CubeMX 里默认值一点,生成代码就跑。但默认值里藏着好几个坑,尤其是波特率误差这一项,很多人根本不知道 CubeMX 界面上那个不起眼的小数字意味着什么。
2.1 波特率误差到底怎么算,容忍边界在哪
UART 是异步通信,收发双方没有共享时钟,全靠各自的波特率对齐采样点。接收方在每一位的中间采样,如果累计误差超过半个位宽,采样就会落到相邻位上,表现为乱码或者丢帧。经验边界是这样的:误差控制在 2% 以内基本无感,2% 到 3% 属于勉强能通但长包容易出错,超过 3% 就别指望稳定了。工程上我一般要求 1.5% 以内。
HAL 库自己会算,但你要会验算。以过采样 16 为例:
USARTDIV = fCK / (16 × 目标波特率) BRR = (整数部分 << 4) | (小数部分 × 16 四舍五入) 实际波特率 = fCK / (16 × 实际 USARTDIV)举个具体例子。某系列芯片的串口挂在 APB2 上,时钟 72MHz,目标 115200:
72,000,000 / (16 × 115200) = 39.0625 整数部分 39,小数部分 0.0625 × 16 = 1 BRR = 0x271,实际波特率 = 72e6 / (16 × 39.0625) = 115200,误差 0再换一个更常见的情形:APB1 时钟 36MHz,同样目标 115200。
36,000,000 / (16 × 115200) = 19.53125 整数 19,小数 0.53125 × 16 = 8.5,取 8 或 9 取 8 时 USARTDIV = 19.5,实际 = 36e6 / (16 × 19.5) = 115384.6 误差 = (115384.6 - 115200) / 115200 ≈ 0.16%,非常安全真正危险的是低主频配高速率。假设系统时钟只有 8MHz,要跑 115200:
8,000,000 / (16 × 115200) = 4.3403 小数 0.3403 × 16 = 5.44,取 5,USARTDIV = 4.3125 实际 = 8e6 / (16 × 4.3125) = 115942,误差 0.64%这个还行。但如果主频再低、速率再高,比如 8MHz 跑 921600:8e6 / (16 × 921600) = 0.542,整数部分直接是 0,根本配不出来。这时候要么提高该总线的时钟,要么把过采样从 16 改成 8,等于把分频精度翻倍,但抗噪能力会下降。我的做法是优先保证时钟树,实在不行才动过采样,并且在文档里记一笔。
一个特别容易忽略的点:APB 预分频不为 1 时,串口时钟是分频后的频率,不是系统主频。配置前务必确认
HAL_RCC_GetPCLK2Freq()返回的真实值。
2.2 CubeMX 里必须手动确认的几项
CubeMX 的默认值大部分是对的,但下面这几项我每次都会过一遍,因为默认值在某些版本里会挖坑。
| 配置项 | 默认值 | 我的建议 | 原因 |
|---|---|---|---|
| Word Length | 8 Bits | 保持 8 | 绝大多数协议是字节流 |
| Parity | None | 保持 None | 校验交给协议层,硬件奇偶校验只保单位 |
| Stop Bits | 1 | 保持 1 | 多数从设备只支持 1 |
| Over Sampling | 16 | 保持 16 | 抗噪更好,误差大时才改 8 |
| Hardware Flow Control | Disable | 视对端而定 | 接流控芯片才开,否则会误判 |
| NVIC 中断使能 | 不勾 | 用中断就必须勾 | 忘了勾就是"代码没问题但收不到数据" |
| GPIO 模式 | 复用推挽 | TX 推挽,RX 上拉输入 | RX 悬空容易收到噪声帧 |
| DMA 请求 | 不配 | 用 DMA 就在 DMA 页签配 | 中断页和 DMA 页是分开的 |
引脚模式那一项我要单独说。CubeMX 生成的 RX 引脚默认是复用推挽或者复用开漏,实际上 RX 应该配成上拉输入或者复用带上拉的模式。原因很直白:对端没上电、线缆没插、设备掉线的时候,RX 引脚是悬空的,悬空的高阻态会随环境噪声乱跳,接收器会把噪声当成起始位,触发一串帧错误。我见过最邪门的现象是设备上电后没接任何从机,接收中断却在疯狂触发,查了半天才定位到引脚浮空。
另外 NVIC 优先级也要提前规划。串口的接收中断建议高于主循环里跑的普通任务,但不要高过系统节拍和关键定时器。如果同时用了 DMA,DMA 通道的中断优先级和串口本身的中断优先级是两码事,都得配。
3. 三种收发方式的实操:轮询、中断、DMA 加空闲中断
这三套东西我都长期用过,各有各的地盘。下面按复杂度从低到高讲,每套都给出能直接跑的最小骨架,以及它会在哪里撞墙。
3.1 轮询发送:最短路径,也最容易把主循环拖死
HAL_UART_Transmit是阻塞式的,函数不返回意味着 CPU 一直卡在循环里等标志位。
uint8_t tx_buf[32] = "hello\r\n"; HAL_UART_Transmit(&huart1, tx_buf, 7, 100);最后那个参数是超时,单位毫秒。这个值不是随便填的,得算。以 115200、8N1 为例,每个字节实际占 10 位(1 起始 + 8 数据 + 1 停止),单字节耗时 10 / 115200 ≈ 86.8 微秒。发 32 个字节大约 2.8 毫秒,发 128 个字节大约 11 毫秒。超时值至少给实际耗时的 2 到 3 倍,我一般按字节数 × 10 × 1000 / 波特率 × 3 + 5来估。
这个耗时的意义在低速率下会被放大。如果波特率是 9600,单字节 1.04 毫秒,发 512 字节就是 533 毫秒,主循环直接停摆半秒多。所以轮询发送只适合两种情况:一是初始化阶段的调试输出,反正那时候没别的事可干;二是发一两个字节的短指令,比如脉冲控制。超过 16 字节的发送,我基本不会用轮询。
新手最爱犯的错是把轮询发送塞进定时器回调或者中断里。在中断里调
HAL_UART_Transmit会长时间占住中断,其他中断全部延迟,严重的时候直接触发看门狗。
3.2 中断接收:单字节进,帧数据出
HAL 的中断接收有一个必须记住的行为:HAL_UART_Receive_IT只接收指定长度的数据,收满就进回调,然后自动停止。也就是说,如果你不在回调里重新挂一次,它只收一次就不收了。这是新手最常见的"只收到第一个字节"的元凶。
uint8_t rx_byte; void uart1_start_rx(void) { HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rb_put(&uart_rb, rx_byte); /* 先入环形缓冲 */ HAL_UART_Receive_IT(&huart1, &rx_byte, 1); /* 立刻重新挂 */ } }这里有两个关键设计。第一,回调里只做一件事——把字节丢进环形缓冲,绝不做协议解析。第二,重新挂接收放在缓冲写入之后立刻执行,中间不要插入任何耗时操作,否则这两句之间的窗口期来的字节会丢。
单字节中断接收的代价是每个字节都要进一次中断。波特率 115200 时,满载情况下每 86.8 微秒中断一次,对于主频几十兆的芯片来说负担不大,但如果系统里还有别的高频中断,就要算一下总中断占用率。速率上到 921600 甚至更高时,单字节中断就不太合适了,中断开销会明显起来,这时候该换 DMA。
中断发送的原理类似,HAL_UART_Transmit_IT发完后进HAL_UART_TxCpltCallback。它的价值在于不阻塞,但要注意发送缓冲区的生命周期——缓冲区不能在发送完成前被释放或者改写。我见过用局部数组调发送中断的代码,函数一返回栈就被覆盖,发出去的全是乱码。
static uint8_t tx_buf[64]; /* 必须是静态或者全局 */ void uart1_send(const uint8_t *data, uint16_t len) { if (len > sizeof(tx_buf)) return; memcpy(tx_buf, data, len); HAL_UART_Transmit_IT(&huart1, tx_buf, len); }3.3 DMA 加空闲中断:不定长帧的稳妥解法
前面两种方式处理"不定长数据"都很别扭。中断接收你总得预估一个长度,DMA 接收如果用HAL_UART_Receive_DMA配固定长度,数据不够长就一直不触发完成回调,够了长又会把下一帧的开头切掉。空闲中断就是解决这个问题的:串口线上连续一个字符时间没有新数据,硬件就置 IDLE 标志,告诉你"这一帧结束了"。
新版本的 HAL 直接提供了HAL_UARTEx_ReceiveToIdle_DMA,一行调用搞定。老版本或者想自己控制的,就得手动开 IDLE 中断,在中断服务函数里算长度。我通常用手动版本,因为可控性更好,而且能兼容不同版本的库。
#define RX_DMA_SIZE 256 uint8_t rx_dma_buf[RX_DMA_SIZE]; void uart1_start_dma_rx(void) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(&huart1, rx_dma_buf, RX_DMA_SIZE); } void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t remain = __HAL_DMA_GET_COUNTER(huart1.hdmarx); uint16_t recv_len = RX_DMA_SIZE - remain; if (recv_len > 0) { rb_put_block(&uart_rb, rx_dma_buf, recv_len); } HAL_UART_AbortReceive(&huart1); uart1_start_dma_rx(); /* 立即重新武装 */ return; /* IDLE 已处理,直接返回 */ } HAL_UART_IRQHandler(&huart1); }这段代码有几个细节值得掰开说。
第一,__HAL_DMA_GET_COUNTER返回的是"还剩多少个没填",所以实际收到的长度是缓冲区大小 - 剩余计数。这个思路在几乎所有支持 DMA 的芯片上都通用,记牢它。
第二,IDLE 判断必须放在HAL_UART_IRQHandler之前,处理完就return。因为 HAL 的中断处理函数内部也会碰状态机,两边都往下走会导致句柄状态错乱,表现为第二次接收再也进不来。
第三,DMA 缓冲区大于最大可能帧长很重要。如果一帧数据把 256 字节填满,DMA 会先触发传输完成中断,IDLE 反而还没来,这种情况下长度计算照样成立,但你得同时处理HAL_UART_RxCpltCallback才能不漏。
第四,重新武装的顺序是"先 Abort 再重新 Start"。直接调HAL_UART_Receive_DMA在部分版本里会因为句柄处于 BUSY_RX 状态而直接返回 HAL_BUSY,看着代码执行了实际没生效。
3.4 printf 重定向与调试口的复用
开发阶段省事的做法是把printf重定向到串口,这样打日志不用管格式转换。
int __io_putchar(int ch) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }现代工具链大多认这个弱函数,Keil 里需要勾上 MicroLIB,否则会链接到半主机模式的实现,代码跑起来就卡死在BKPT指令上,表现为"程序下载进去了但一点都不动"。这个坑我踩过不止一次,换成标准库的时候还得自己写fputc并且加#pragma import(__use_no_semihosting)。
重定向之后还有个隐患:HAL_MAX_DELAY意味着永不超时,一旦串口硬件出问题,printf会永久阻塞。生产代码里我一般把它换成几十毫秒的超时,并且只在调试宏打开时编译进去。
#ifdef DEBUG_LOG #define LOG(fmt, ...) printf(fmt, ##__VA_ARGS__) #else #define LOG(fmt, ...) ((void)0) #endif另外强烈建议把调试口和业务口分开。我习惯用 USART1 打日志,业务通信走 USART2 或 USART3。混在一起的话,日志和数据在同一个流里,解析端要额外做过滤,调试完还得记得关日志,否则日志把业务帧冲掉,排查时能让人抓狂。
4. 环形缓冲与协议解析:把数据接住,再慢慢处理
中断里收到字节只是第一步,真正的活儿在主循环里。这一节讲怎么把字节流转成可用的帧,以及为什么中间必须夹一个环形缓冲。
4.1 为什么坚决不在中断回调里解析协议
最朴素的做法是在接收回调里直接判断帧头、算长度、做校验,收到完整帧就置个标志让主循环处理。小数据量下这么写确实能跑,但它有三个硬伤。
第一是中断占用时间不可控。协议解析涉及分支判断、循环、甚至浮点运算,一旦帧长变大或者校验算法变复杂,中断里待的时间就从几微秒涨到几十上百微秒,高频中断场景下直接导致后续字节丢失——你在解析当前帧,下一个字节已经来了,硬件溢出标志置位,数据没了。
第二是可重入问题。主循环正在读某个全局帧缓冲,中断进来把它改了一半,读到的是半新半旧的数据。这类 bug 极难复现,往往跑几天才出一次,出了还抓不到现场。
第三是扩展性差。今天协议是定长帧,明天加了变长字段,后天要支持多帧粘包,写在中断里的解析逻辑每改一次都要重新评估中断时序。
所以我的固定套路是:中断里只做搬运,主循环里做解析。中间那层搬运工就是环形缓冲。
4.2 一份能直接用的环形缓冲实现
环形缓冲的核心就两个指针,写指针由生产者(中断)推进,读指针由消费者(主循环)推进。缓冲区大小取 2 的幂,用位与代替取模,速度更快。
#define RB_SIZE 512 /* 必须是 2 的幂 */ #define RB_MASK (RB_SIZE - 1) typedef struct { uint8_t buf[RB_SIZE]; volatile uint16_t head; /* 写位置,仅中断修改 */ volatile uint16_t tail; /* 读位置,仅主循环修改 */ } ring_t; static ring_t uart_rb; /* 中断上下文调用 */ void rb_put(ring_t *rb, uint8_t byte) { uint16_t next = (rb->head + 1) & RB_MASK; if (next == rb->tail) { return; /* 满了,丢弃新数据 */ } rb->buf[rb->head] = byte; rb->head = next; } /* 主循环上下文调用 */ int rb_get(ring_t *rb, uint8_t *byte) { if (rb->head == rb->tail) { return 0; /* 空 */ } *byte = rb->buf[rb->tail]; rb->tail = (rb->tail + 1) & RB_MASK; return 1; }这段代码的正确性依赖一个前提:head只被中断改,tail只被主循环改。单生产者单消费者模型下,两个 16 位变量的读写本身是原子的,不需要关中断。但如果你的场景里有两个中断都往里写(比如串口中断和 DMA 中断),就必须加临界区。
#define RB_ENTER() uint32_t __primask = __get_PRIMASK(); __disable_irq() #define RB_EXIT() __set_PRIMASK(__primask)批量写入的时候还要注意,一次搬 N 个字节不能简单地循环调rb_put然后不管——如果中途缓冲满了,后半段数据就静默丢了。我的做法是先算可用空间,装不下的部分直接丢弃并记一个溢出计数,事后能通过这个计数判断现场的数据压力。
uint16_t rb_free(const ring_t *rb) { return (rb->tail - rb->head - 1) & RB_MASK; }缓冲大小怎么定?我的经验值是"最大帧长 × 4",且不低于 256。太小了在突发流量下必丢,太大了浪费 RAM。另外记得给溢出单独计数,出问题时这个数字比任何日志都直观。
4.3 主循环里的取帧状态机
有了字节流,接下来要把它们切成帧。这里我用一个极简的状态机,适配带帧头、带长度、带校验的常见结构。假设协议是0xAA 0x55 | LEN | DATA[LEN] | CRC8。
typedef enum { S_HEAD1 = 0, S_HEAD2, S_LEN, S_DATA, S_CRC } parse_state_t; void uart_poll(void) { static parse_state_t st = S_HEAD1; static uint8_t len, idx, crc; static uint8_t frame[64]; uint8_t b; while (rb_get(&uart_rb, &b)) { switch (st) { case S_HEAD1: if (b == 0xAA) st = S_HEAD2; break; case S_HEAD2: st = (b == 0x55) ? S_LEN : S_HEAD1; break; case S_LEN: len = b; if (len == 0 || len > sizeof(frame)) { st = S_HEAD1; break; } idx = 0; st = S_DATA; break; case S_DATA: frame[idx++] = b; if (idx >= len) st = S_CRC; break; case S_CRC: crc = b; if (crc_ok(frame, len, crc)) { on_frame(frame, len); /* 业务处理 */ } st = S_HEAD1; break; } } }这个状态机有三处设计是我特意保留的。帧头只在S_HEAD1状态才判断,如果帧头出现两次(比如上一帧恰好以 0xAA 结尾),S_HEAD2那一步会做二次确认,不会误吞。长度字段做了上下限校验,防止野数据把索引撑爆。校验失败直接回初始态,不做局部重试,因为在字节流上重试本身就不成立。
主循环里调uart_poll的节奏也要考虑。如果主循环有长耗时的任务,比如写 Flash、刷屏,建议加大缓冲并且在长任务前后各调一次uart_poll。更稳妥的做法是把解析挂到一个 1 毫秒的软定时器里,配合 DMA 接收,基本不会丢。
5. 常见问题与排查实录:这些坑我基本都踩过
这一节按现象归类,都是实际调试中反复遇到的问题。遇到串口不工作,按下面的顺序从物理层往上查,比直接翻代码快得多。
5.1 收发异常速查表
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 完全收不到数据 | 中断没使能 / 引脚模式错 / 波特率不匹配 | 量 RX 波形,确认有电平跳变 |
| 只收到第一个字节 | 回调里没重新挂Receive_IT | 检查回调末尾 |
| 收到固定乱码(如全是 0xFF) | 引脚浮空或对端未上电 | RX 加上拉,量空闲电平 |
| 偶发个别字节错 | 波特率误差过大 / 地线共模 | 验算误差,检查共地 |
| 数据前几位对后面全错 | 发送缓冲区被提前释放 | 确认缓冲区为静态 |
| 运行一段时间后彻底死掉 | ORE 溢出未清 / 句柄卡 BUSY | 加错误回调,加超时复位 |
| DMA 只收一次 | 没重新武装 / IDLE 标志未清 | 检查重复调用 |
printf卡死 | 半主机模式 / 串口被占用 | 开 MicroLIB,检查外设冲突 |
表格里第一条我特别想强调。很多人一上来就怀疑代码,实际上超过一半的串口问题是硬件层面的:排线接触不良、TX/RX 接反、共地没接、电平不匹配。用示波器或者逻辑分析仪看一眼 RX 引脚有没有波形,这一步能省掉几小时的猜谜。
5.2 ORE 溢出与句柄死锁的处理
溢出错误是串口开发里最隐蔽的一类问题。硬件上字节到了,但上一帧还没被读走,接收寄存器里已有的数据被新数据覆盖,硬件置 ORE 标志。这个标志在 HAL 的默认流程里处理得不彻底,如果不清,后续接收会一直失败。表现出来就是"跑一会儿就不收数据了,重启又好了"。
稳妥做法是实现错误回调:
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint32_t err = huart->ErrorCode; if (err & HAL_UART_ERROR_ORE) { __HAL_UART_CLEAR_OREFLAG(huart); huart->gState = HAL_UART_STATE_READY; /* 强行复位状态 */ huart->RxState = HAL_UART_STATE_READY; uart1_start_dma_rx(); /* 重新武装 */ } else if (err & HAL_UART_ERROR_FE) { /* 帧错误,通常是波特率或噪声,不计入致命错误 */ } } }手工改句柄的gState和RxState这种做法挺野,但确 implementation 情况下能救命——HAL 内部一旦状态卡在 BUSY_RX,所有后续接收调用都会被拒绝,而唯一的出路就是复位状态。代价是可能丢掉正在传的一帧,不过相比彻底失联,这个代价可以接受。
批处理代码里还要注意,别在错误回调里调HAL_UART_Receive_DMA之前忘了HAL_UART_AbortReceive,否则经常返回 HAL_BUSY。这个顺序我写在注释里,隔三个月回来看还能想起来。
5.3 几条书上不写的实操心得
第一条,永远给通信口准备一个软件看门狗。我一般统计"连续多少秒没有收到任何有效帧",超过阈值就复位串口外设本身,重新走一遍 GPIO 和 UART 初始化,再重新武装接收。这一招救过好几次现场,因为有些故障是硬件层面的静电或者电源抖动导致的,软件只能靠重启外设绕过。
第二条,调试阶段把关键状态打印到串口之外的地方。我习惯另外留一个指示灯或者跑一个简单的计数变量,通过调试器观察。因为串口本身出问题的时候,日志也打不出来,容易误判成程序完全卡死。
第三条,接收和发送用不同的缓冲,别图省事共用一个静态数组。收发同时进行时,共用缓冲区会导致发出去的数据被正在接收的数据覆盖,且这类 bug 的触发条件很随机。
第四条,对于需要响应时间保证的从机协议,别在主循环里处理帧。主循环的调度抖动可能到几毫秒,超出协议规定的应答时限就会被主机判为超时。这种场景要么把处理下沉到中断(并且严格控制处理时间),要么用 RTOS 开一个高优先级任务专门处理通信。
第五条,固件升级或者参数配置这类大块数据传输,一定要有应用层的分片和重传。串口在电气层面就不可靠,指望一个长包一次成功不现实。分片长度我一般取 64 到 128 字节,配合序号和超时重传,实测在干扰环境下能稳到 99.9% 以上。
我个人在实际项目里最大的一个体会是:串口通信的复杂度从来不在配置,而在状态管理和异常恢复。花半小时把环形缓冲、错误回调、超时复位这三样搭好,后面的协议换多少次都不慌;反过来图快直接在主循环里轮询,前期省的那点时间,后期会以数倍的调试成本还回来。上面这些代码片段都是从实际项目里抽出来的骨架,参数和长度按你的场景调,但结构不用改,都是踩过坑之后留下来的。