STM32 HAL库UART实战:DMA空闲中断与协议解析

串口这东西,说简单也简单,两根线一接,波特率一对,数据就能跑起来;说坑多也是真的多,我见过太多项目在串口上栽跟头,明明是块好板子,偏偏卡在“第一行打印出不来”这种地方耗掉一整天。这篇笔记就是我围绕STM32 HAL 库串口通信(UART)整理的一份完整实战记录,从为什么选串口、波特率怎么算、CubeMX 怎么配,到中断、DMA、空闲中断三种收发模型怎么写,再到自定义协议帧和一大堆现场踩出来的坑。它不是一份官方手册的复述,而是我在实际项目里反复调通、反复推翻之后留下的东西。无论你是刚拿到开发板、还在为 printf 重定向发愁的新手,还是已经写过几个项目、但一到不定长数据和粘包就头疼的老手,下面这些内容应该都能直接抄去用。

1. 为什么我至今把串口当成第一调试口

1.1 一个真实的选型取舍:调试口为什么不是 USB 和 SWD

每次有新同事问我,现在调试手段这么多,SWD 能单步、USB 能高速传数据,为什么还要折腾串口。我的回答通常很朴素:因为串口是唯一一个“板子只要还能跑,就大概率还能说话”的通道。SWD 依赖调试器、依赖内核状态,一旦程序进了 HardFault 或者低功耗模式,调试器连接经常断得莫名其妙;USB 更麻烦,枚举失败、驱动不认、主机端还得装环境,随便一个问题都能让你怀疑人生。

串口的优势在于它的实现层级极低。你只要保证时钟配置对、引脚复用对、波特率对,哪怕程序逻辑一塌糊涂,HAL_UART_Transmit也能把几个字节硬塞出去。定位问题时这种“最低限度可用”的能力非常关键。我在排查一个上电就跑飞的板子时,最终就是靠主函数第一行无条件打印一个字符,才确认问题出在时钟初始化之后、外设初始化之前的那段代码,而不是硬件焊接。

另外从工程角度看,串口的资源占用极小。两三个引脚、一个外设、一小段中断处理,就能换来一条独立的日志通道,和主业务逻辑几乎不抢资源。对比之下,USB CDC 要占一大块 RAM 做端点缓冲,协议栈本身也有体积。所以我的习惯是:只要芯片上还有空闲串口,一定留一路出来做调试输出,哪怕产品最终不引出这个接口,板上留两个测试点也值。

1.2 HAL 库的串口抽象层到底帮我们做了什么

很多新手对 HAL 库的态度是两个极端,要么全信,要么全骂。我的看法是,得先搞清楚它到底封装了什么。HAL 的串口驱动本质上做了三件事:第一,把寄存器的初始化流程做成了HAL_UART_Init,你填一个UART_InitTypeDef结构体,它帮你算 BRR、配 CR1/CR2/CR3;第二,把收发过程抽象成阻塞、中断、DMA 三套接口,并且用状态机管理huart->gState和RxState;第三,提供了一组弱函数回调,比如HAL_UART_RxCpltCallback,你重写它就能接管数据到达之后的事情。

理解了这三点,很多“玄学问题”就有解释了。比如为什么连续调用两次HAL_UART_Transmit中间必须等它返回,因为它内部会检查gState,如果上一次还没结束就返回HAL_BUSY;再比如为什么中断接收只收一次就停了,因为HAL_UART_Receive_IT在收满指定长度后会把状态切回 Ready,并且关掉接收中断,需要你在回调里重新调用一次。

HAL 库真正省事的地方是跨系列移植。同一套代码从 F1 挪到 F4、再挪到 G0,只要外设名字和中断向量对得上,业务层几乎不用改。它的代价是执行路径比寄存器操作长,尤其在中断里调 HAL 函数会有额外的状态判断开销。所以我的原则是:初始化和常规收发用 HAL,追求极致效率的循环缓冲和协议解析自己写,两边配合着用,别走极端。

1.3 这套笔记适合谁看,需要什么前置准备

内容覆盖的范围,是从零开始把一路串口跑通,一直到能稳定处理不定长数据帧。前置条件其实很低:手上有一块带串口的开发板、一个 USB 转 TTL 模块、一根能正常供电的线,电脑上装好 CubeMX 和任意一个串口助手。芯片系列不限,F1、F4、G0、H7 的配置思路完全一致,只是个别宏和函数名有差异,我会在涉及的地方标出来。

有一点要提前说清楚:串口调试最容易被忽略的不是代码,而是硬件。杜邦线接触不良、USB 转 TTL 模块的电平是 3.3V 还是 5V、两边有没有共地,这些问题造成现象和软件 Bug 几乎一模一样。所以后面讲排查的时候,我会把硬件检查放在软件之前,这不是凑字数,是我用很多个下午换来的顺序。

2. 上电之前先想清楚的三件事:波特率、帧格式与电平

2.1 波特率不是随便填的:误差计算与常见取值

CubeMX 里波特率是个下拉框,很多人随手选 115200 就往下走,反正大多数时候也能通。但只要你遇到过“短线上正常、长线上偶尔丢字节”的情况,就该知道波特率背后有个误差问题。异步串口没有时钟线,收发双方全靠各自的分频时钟对齐,一旦累计误差超过半个位宽,采样点就会落到错误的位上。

以常见的 72MHz 主频、挂在 APB2 上的 USART1 为例,过采样设为 16,分频值计算方式是:

USARTDIV = fCK / (16 × BaudRate) = 72 000 000 / (16 × 115200) ≈ 39.0625

整数部分是 39,小数部分 0.0625 乘以 16 得到 1,所以 BRR 寄存器写成0x271。反过来验算实际波特率是 72MHz / (16 × 39.0625) = 115200,误差为 0,属于运气好。换成 9600 就未必这么整:72MHz / (16 × 9600) = 468.75,取整后实际波特率会偏离理论值零点几个百分点。

工程上的经验是,异步串口的总误差控制在 2% 以内比较稳妥,超过 3% 就开始出现偶发误码。HAL 库不会替你检查这件事,CubeMX 也只是在你把波特率填得离谱时给个提示。我一般会算一下,尤其是用内部 RC 振荡器做时钟源的时候。HSI 的精度本身就有限,再叠加分频误差,115200 这种比较高的速率就容易出问题,这时候要么降到 38400,要么换外部晶振。

还有一处容易忘的:HSE_VALUE这个宏必须和板上实际晶振一致。CubeMX 生成的代码里它通常在stm32fxxx_hal_conf.h或时钟配置文件里。我曾经接手过一块板,晶振是 8MHz,工程里宏还是默认的 25MHz,结果所有外设时钟全偏了三倍多,串口自然一片乱码。这种问题看代码看不出来,只能对硬件。

2.2 8N1 之外的世界:帧格式、校验与流控

大部分场景默认用 8N1,也就是 8 个数据位、无校验、1 个停止位,一帧 10 个位宽。这个组合在短距离、干扰小的场合足够用,但知道其他选项在什么时候有用,能省不少事。

数据位一般选 8 位,因为字节对齐最方便。如果协议里确实只需要传 7 位 ASCII,选 7 位能省一点带宽,但解析时要额外处理位对齐,不推荐为了省这点带宽去改。停止位通常 1 位,某些老设备或者长线缆场景会要求 2 位,给接收方多留一点帧间隔余量。

校验位是个有意思的选项。硬件奇偶校验只能发现奇数个位翻转,而且校验位本身不参与数据,很多协议干脆把它关掉,自己在应用层加 CRC。我的做法是:如果对端是固定设备、协议改不了,就按对端要求配;如果是自己两端都写,一律 8N1 加应用层校验,因为硬件校验能发现的问题太有限,反而让帧长变了 11 位,解析代码要多写分支。

流控分硬件和软件。硬件流控用 RTS/CTS 两根线,接收方缓冲区快满时拉高 RTS,发送方暂停。这个功能在高速率、大数据量时确实有用,但代价是多两根线,而且很多 USB 转 TTL 模块根本不引出这两个引脚。软件流控用 XON/XOFF 字符,在二进制数据里容易和有效数据冲突,我基本不用。数据量大的场合,我更倾向于把波特率提上去,然后用 DMA 加环形缓冲来解决,而不是依赖流控。

2.3 TTL、RS232、RS485:电平转换与共地问题

芯片引脚出来的是 TTL 电平,3.3V 系统里高电平约 3.3V,低电平 0V。USB 转 TTL 模块输出的也是这个电平,所以两边可以直接接,但务必确认模块的电平选择跳线在 3.3V 档,5V 输出的模块直连 3.3V 芯片是有风险的。

RS232 是另一套电平标准,用负逻辑,正负十几伏表示 0 和 1,必须经过电平转换芯片才能接到单片机。RS485 则是差分传输,抗干扰能力强、传输距离远,适合工业现场,但它是半双工,收发方向要控制。用 485 时有个细节特别容易错:方向切换不能靠“发完函数返回”来判断,因为HAL_UART_Transmit返回时数据只是进了移位寄存器,最后一个字节可能还没真正发完。正确做法是等发送完成中断,或者轮询 TC 标志,确认最后一个位的停止位已经出去,再切回接收。我见过切早了半个位导致最后一个字节被截断的情况,排查了很久。

共地这件事必须单独强调。两块板子只用 TX、RX 两根线连起来,如果各自用独立的电源,参考地不同,电平判断就会漂移,现象是“有时候能通、有时候全是乱码”。USB 转 TTL 模块和开发板通过 USB 供电时通常已经共地了,但如果是两块独立供电的板子互连,一定要把 GND 连上。我自己现在的习惯是,凡是串口不通,第一件事是拿万用表量两边 GND 之间的电压,超过零点几伏就别急着查代码了。

3. CubeMX 配置与第一行收发代码

3.1 时钟树与串口外设的绑定关系

串口挂在哪条总线上,直接决定了它的时钟频率,而时钟频率又决定了波特率能不能算准。以 F1 系列为例,USART1 在 APB2 上,USART2、USART3 在 APB1 上,APB1 通常还有分频,所以同一块板子上不同串口的输入时钟可能差好几倍。CubeMX 的时钟树页面里,点开对应外设就能看到它的时钟源,这个数字要在算波特率之前确认。

另外要留意的是 APB 预分频对定时器的影响,虽然和串口没直接关系,但很多人调完串口发现定时器周期不对,原因就是 APB1 分频不是 1 时,定时器时钟会翻倍。串口这边倒是没有这种隐藏规则,输入时钟就是总线时钟本身。

我一般会在 CubeMX 里先把时钟树配到目标频率,再回头配串口,这样波特率下拉框里的误差提示是准的。顺序反了的话,改时钟会导致已配好的串口参数需要重新确认。

3.2 CubeMX 里那几个必须逐项确认的参数

打开串口外设的配置页,模式选 Asynchronous,这是最常用的异步模式。下面几项我每次都会看一遍:

配置项常用值说明
Baud Rate115200和上位机保持一致,别只改一边
Word Length8 Bits字节对齐,最通用
ParityNone校验交给应用层处理
Stop Bits1长线缆可改 2,需双方一致
Data DirectionReceive and Transmit只做打印可改 Receive/Transmit
Over Sampling16默认值,高速率时可考虑 8
Hardware Flow ControlDisable没接 RTS/CTS 时必须关

中断使能页里,如果打算用中断或 DMA 接收,要勾上 USART 全局中断;用 DMA 的话还要在 DMA 配置页加一条接收通道和一条发送通道,模式一般接收用 Circular 或 Normal 都有讲究,后面细说。

生成代码之前,我强烈建议在 Project 设置里把“Generate peripheral initialization as a pair of .c/.h files”打开,这样串口相关的初始化会单独放在usart.c里,而不是全堆在main.c,后期维护清爽很多。

3.3 轮询方式收发:最笨但最可靠的第一步

新板子第一次点灯之后,我做的第二件事就是轮询方式发一个字符串。原因很简单,它不依赖中断、不依赖 DMA,路径最短,能把“时钟、引脚、波特率、接线”这四个变量一次性验证掉。

uint8_t msg[] = "uart ok\r\n"; HAL_UART_Transmit(&huart1, msg, sizeof(msg) - 1, 100);

第三个参数是长度,这里减一是为了不把字符串结尾的\0也发出去,有些上位机看到0x00会显示异常。第四个参数是超时,单位毫秒,填 100 表示最多等 100ms。这个超时不是“发完就返回”的等待时间,而是内部轮询标志的上限,正常情况会提前返回。

接收端同理:

uint8_t buf[16]; if (HAL_UART_Receive(&huart1, buf, 5, 1000) == HAL_OK) { // 收到 5 个字节 }

注意这里是“收满 5 个字节或者超时”才返回,不是收到一个就返回。很多新手第一次用会困惑为什么发一个字节程序没反应,因为它还在等剩下四个。轮询接收在实际项目里用得不多,主要因为阻塞主循环,但在初始化阶段做一问一答的握手非常合适。

提示:轮询发送和接收不要在中断里调用。HAL 内部靠轮询标志实现,放进中断会导致中断服务函数长时间占用,严重时看门狗直接复位。

3.4 printf 重定向与半主机那个老问题

调试打印离不开 printf。Keil 环境下默认走半主机模式,需要调试器参与,脱机运行就会卡死在BKPT指令上。解决办法有两个:一是在魔术棒里勾选 Use MicroLIB,然后重写fputc;二是干脆不用 printf,自己写一个格式化输出函数。我选前者,因为改动最小。

#include <stdio.h> int fputc(int ch, FILE *f) { uint8_t c = (uint8_t)ch; HAL_UART_Transmit(&huart1, &c, 1, 10); return ch; }

注意这里每次只发一个字节,效率不高,打印长字符串时会明显拖慢主循环。追求效率的话可以改成先格式化到缓冲区,再一次性发送:

char log_buf[128]; int len = snprintf(log_buf, sizeof(log_buf), "tick=%lu\r\n", HAL_GetTick()); HAL_UART_Transmit(&huart1, (uint8_t *)log_buf, len, 100);

GCC 环境下重定向的函数名不一样,通常是_write,有些人会同时实现_read、_close等一套,其实只实现_write就够了。还有个细节:如果用的是printf加浮点数,链接时可能要加-u _printf_float,否则%f打出来是空的,这个坑坑过不少人。

4. 中断、DMA 与空闲中断:三种收发模型怎么选

4.1 中断接收的第一个坑:只收一次和溢出

HAL_UART_Receive_IT的行为是:你告诉它要收几个字节,它就开中断,收满之后调用一次回调,然后把接收状态置回 Ready 并关闭接收中断。这意味着如果你不在回调里重新调用一次,后面来的数据全部丢失。

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { process_byte(rx_byte); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // 必须重新开 } }

每次只收一个字节的写法简单直观,适合低速命令行交互,但中断频率很高。115200 波特率下每秒最多 11520 次中断,如果回调里还有耗时操作,主循环基本别想干别的。改进办法是一次多收几个字节,代价是帧边界变得模糊,需要额外的空闲判断或超时判断。

还有个必须处理的情况是溢出错误 ORE。接收移位寄存器满了、数据寄存器还没被读走,新数据来了就会置位 ORE,HAL 会调用HAL_UART_ErrorCallback。如果不处理,很多系列上接收会直接卡死。处理方式是在错误回调里把错误码打出来,然后重新开启接收:

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->ErrorCode & HAL_UART_ERROR_ORE) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }

4.2 DMA 加空闲中断:不定长帧目前最稳的解法

固定长度接收在实际项目中很少见,大多数时候对端发过来的帧长度是变化的,比如一行 AT 指令、一条 JSON、一包传感器数据。这时候最常用的组合是 DMA 接收加串口空闲中断。

思路是这样:DMA 配置为 Circular 模式,缓冲区给一个足够大的数组,比如 256 字节,DMA 一直开着,数据来了就往缓冲里塞,CPU 完全不参与。同时使能串口的 IDLE 中断,当总线上连续一个字节时间内没有新数据,硬件会置位 IDLE 标志,说明“这一帧发完了”。在 IDLE 中断里算出 DMA 当前写到了哪个位置,用写位置减去上次位置,就得到这一帧的长度。

#define RX_SIZE 256 uint8_t rx_dma_buf[RX_SIZE]; volatile uint16_t last_pos = 0; // 启动 HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_dma_buf, RX_SIZE); __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); // 关半传输中断 void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { // Size 就是本次接收到的长度 parse_frame(rx_dma_buf, Size); // Circular 模式下不用重启,继续收即可 } }

新版本 HAL 提供了HAL_UARTEx_ReceiveToIdle_DMA这个封装,直接把 DMA 和 IDLE 都配好了,回调是HAL_UARTEx_RxEventCallback,参数里带长度,比自己手写 IDLE 中断省事。老版本没有这个函数,就得自己开 IDLE 中断再配合__HAL_DMA_GET_COUNTER计算长度。

这里有个配置细节要强调:DMA 的接收通道如果设成 Normal 模式,一次传输结束就停了,回调之后必须重新调用接收函数;设成 Circular 模式则会一直循环覆盖,好处是不用反复启动,坏处是如果主循环处理慢了,新数据会覆盖还没处理完的旧数据。我用 Circular 加环形缓冲的组合,在回调里只做“记录写指针”,真正的解析放到主循环,这样中断里停留的时间极短。半传输中断建议关掉,否则数据量大的时候中断频率翻倍,收益却不大。

4.3 环形缓冲:把中断和主循环彻底解耦

直接在回调里解析协议是有风险的,尤其是协议里带 CRC 校验或者字符串查找,处理时间不可控。规范的做法是中断里只搬数据,解析放到主循环。环形缓冲就是干这个的。

单生产者单消费者的环形缓冲可以做到无锁:中断只写head,主循环只写tail,两边各自读对方的指针。不过head是 16 位变量的话,读写不是原子操作,中断打断主循环的读操作可能导致取到半个值,所以访问时要加临界区,用__disable_irq()包一下,或者用volatile配合单字节指针。G0、F0 这类 Cortex-M0+ 内核没有字节访问原子性保证,更要注意。

typedef struct { uint8_t buf[512]; volatile uint16_t head; volatile uint16_t tail; } ring_t; static inline uint16_t ring_used(ring_t *r) { return (uint16_t)((r->head - r->tail) & 511); } void ring_push(ring_t *r, uint8_t data) { uint16_t next = (r->head + 1) & 511; if (next != r->tail) // 留一个空位区分满和空 { r->buf[r->head] = data; r->head = next; } }

容量选 2 的幂,取模用按位与,速度快很多。满和空的区分靠“浪费一个字节”,这是最经典的做法,也可以额外加一个计数变量,但计数变量在多处读写时更容易出错。

4.4 发送端:什么时候该用 DMA,什么时候老老实实阻塞

发送比接收简单,因为主动权在自己手里。数据量小、调用不频繁的场合,HAL_UART_Transmit阻塞发送完全够用,一个几十字节的日志加上 10ms 超时,对主循环的影响可以忽略。

数据量大或者发送频繁时,阻塞发送就成了瓶颈。比如 115200 波特率下,100 字节要花将近 9ms,这期间 CPU 干等着。改用HAL_UART_Transmit_DMA可以立刻返回,但要注意缓冲区生命周期:DMA 还在搬运的时候,如果你把栈上的局部数组复用了,发出去的数据就是错的。稳妥做法是用静态缓冲区,或者在发送完成回调里再做下一次发送。

多个任务同时打印日志时,还要防重入。我的做法是加一个简单的忙标志,发送前检查,忙就丢弃这条日志或者排队等到下一轮。日志丢几条没关系,但如果两个任务同时调用导致 DMA 配置被改乱,问题就大了。用 FreeRTOS 的话,套一个互斥量更直观,注意在中断里打印不能用带阻塞的互斥量,得用FromISR版本。

5. 自定义协议帧的落地:从裸数据到可靠通信

5.1 帧结构设计:帧头、长度、命令、校验

裸发数据在调试阶段没问题,一旦两边要做真正的业务交互,就必须定协议。一个够用又不复杂的帧结构通常长这样:

字段长度说明
帧头2 字节固定值,如 0xAA 0x55,用于定位起始
长度1 字节负载长度,不含帧头帧尾
命令1 字节区分业务类型
负载N 字节实际数据
校验2 字节CRC16,覆盖长度到负载

帧头用两个字节而不是一个,是为了降低误判概率。数据流里出现单个 0xAA 很常见,连续两个 0xAA 加 0x55 的概率就低得多。长度字段放在命令之前,方便接收方先知道要收多少,再做后续判断。校验放在最后,覆盖范围从长度字段开始,帧头不参与,因为帧头是固定的,参与与否不影响检错能力,反而省一点计算。

我还见过把长度放在帧头前面的设计,好处是接收方读到第一个字节就知道整帧多长,坏处是一旦丢了一个字节,后面所有帧的边界全乱。带双字节帧头的方案虽然要多读一个字节,但重同步能力更强,我更倾向这种。

5.2 校验方式的选择:累加和、CRC8 还是 CRC16

校验方式的选择本质上是“检错能力”和“计算开销”的权衡。累加和最简单,一个循环加起来取低八位,但它检测不出字节顺序颠倒,也检测不出两个字节同时出错互相抵消的情况。CRC8 稍好,计算量也不大。CRC16 检错能力最强,代价是需要一张表或者按位计算,在低主频芯片上会占一点时间。

我的经验是:短帧、低速、干扰小的场合,累加和够用;工业现场、长线缆、有电机干扰的场合,老老实实上 CRC16。CRC16 的实现有两种,按位计算省 Flash 但费时间,查表法反过来。512 字节的表对大多数芯片不算负担,我一般直接贴表。

static const uint16_t crc16_table[256] = { /* 省略,按多项式 0xA001 生成 */ }; uint16_t crc16_modbus(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc = (crc >> 8) ^ crc16_table[(crc ^ *data++) & 0xFF]; } return crc; }

注意收发双方的初始值、多项式、结果是否取反必须完全一致,这几项对不上,校验永远不过,而且现象是“看起来数据都对,就是不认”。我在联调时养成了一个习惯:把收到的原始字节和算出的 CRC 都打印出来,人工核对一次,确认算法对齐之后再往下查。

5.3 粘包与半包:串口协议的经典难题

串口是字节流,没有消息边界的概念。对端连续发两帧,你可能一次收到两帧拼在一起,这叫粘包;一帧数据被拆成两次到达,这叫半包。处理方法说到底就是一条:接收端自己维护一个缓冲区,不断尝试从缓冲区里解析出完整帧,解析不出来就等下一批数据。

具体流程是,把新数据追加到接收缓冲区,然后循环查找帧头,找到帧头后检查后面是否够长度字段,够的话算出整帧长度,再看缓冲区里剩余数据是否够这一帧,够就取出来校验和分发,不够就退出等待。校验不过的话,从帧头的下一个字节继续找,防止帧头本身是数据里的巧合。

void feed_data(uint8_t *data, uint16_t len) { append_to_buf(data, len); while (1) { int start = find_header(rx_buf, rx_len); if (start < 0) { rx_len = 0; return; } // 没找到,清空 if (rx_len - start < 4) { shift_buf(start); return; } uint8_t plen = rx_buf[start + 2]; uint16_t total = 2 + 1 + 1 + plen + 2; if (rx_len - start < total) { shift_buf(start); return; } uint16_t crc = crc16_modbus(&rx_buf[start + 2], plen + 2); if (crc == (rx_buf[start + total - 2] << 8 | rx_buf[start + total - 1])) { dispatch(&rx_buf[start], total); shift_buf(start + total); } else { shift_buf(start + 1); // 校验失败,跳过这个帧头继续找 } } }

这段逻辑看着啰嗦,但它是所有可靠串口协议的基础。写的时候要注意缓冲区越界,长度字段是外部输入,必须做上限检查,不然一个错误的长度值就能让程序读飞。

5.4 超时与重发:让通信真正可靠

加了校验只能保证“收到错误数据能发现”,不能保证“数据一定送到”。要可靠就得有应答和重发。最简单的做法是发送方发完一帧启动一个定时器,规定时间内收到应答就清定时器,超时就重发,重发三次还不成功就上报错误。

定时器不要用HAL_Delay死等,那会把整个流程卡住。用HAL_GetTick()记录时间戳,在主循环或者定时器中断里检查是否超时。命令和应答最好带一个序号字段,接收方回显序号,发送方靠序号判断这个应答对应哪次请求,避免重发之后收到的是上一次的迟到应答。

这套机制写起来比想象中复杂,状态机、超时、序号三者纠缠在一起。我的建议是先用最简单的“一问一答、超时重发三次”跑通,等业务稳定了再考虑滑动窗口之类的东西。很多项目其实根本用不上复杂协议,把 CRC 和超时做扎实,问题就解决了九成。

6. 常见问题与排查技巧实录

6.1 乱码、丢包、卡死:按这个顺序查

现象千奇百怪,但排查顺序可以固定下来:先硬件,再配置,再代码逻辑。硬件层面看三样,接线是否 TX 对 RX 交叉、两边是否共地、电平是否匹配。这三样用万用表和眼睛就能确认,花不了五分钟,却能排掉一半的问题。

配置层面看时钟和波特率。HSE_VALUE是否和晶振一致、串口挂在哪条总线上、算出来的波特率误差多大。我遇到过一次波特率明明填的 115200,实际通的是 9600 附近,最后发现是工程从别的板子拷过来,时钟配置没改。

代码逻辑层面的问题最多。中断接收只收一次不重启、DMA 用 Normal 模式没重新启动、回调里做了耗时操作导致溢出、发送缓冲区在 DMA 还在搬运时被复用。这些问题有个共同特点:低速时能跑,一加压就出问题,所以测试的时候别只用单个字节慢慢发,要连续高速发一段随机数据做压力测试。

6.2 一张表把高频问题对上号

现象常见原因验证方法处理方式
上电全是乱码波特率或时钟配置错换 9600 试,看是否变成可读字符核对时钟树和HSE_VALUE
一个字节都收不到TX/RX 没交叉、没共地示波器看 TX 是否有波形交换接线,补 GND
发 AT 指令无回复缺回车换行、对端要求特定格式手动加\r\n再发按对端协议补结束符
收几个字节后卡死ORE 溢出未清、中断未重启在错误回调打点清标志并重新开启接收
DMA 只收到第一批模式为 Normal 未重启观察回调是否只进一次改 Circular 或回调里重启
printf 无输出或卡住半主机模式、未勾 MicroLIB单步看是否停在 BKPT勾选 MicroLIB 并重写fputc
长时间运行丢数据环形缓冲满、解析太慢统计缓冲区使用峰值加大缓冲、把解析移出中断
高速率偶发误码波特率误差大、线太长降低波特率对比换晶振或降低速率、缩短线缆

这张表是我自己电脑里那份排查清单的浓缩版,每次遇到新问题就往里加一行,久了之后大部分故障都能在三分钟内定位到方向。

6.3 几个不花钱但很好用的土办法

第一个办法是主函数第一行无条件打印。不初始化任何业务,只配串口,然后死循环每秒打印一个递增数字。能打出来说明底层通了,打不出来就往底层查,这样能把问题范围一刀切开。

第二个办法是回环测试。把 TX 和 RX 短接,发什么收什么,能验证串口外设本身和中断配置是否正确,排除对端设备的影响。这个测试特别适合怀疑“是不是我代码有问题”的时候,做一次心里就有底了。

第三个办法是用逻辑分析仪或者示波器看波形,没有的话,很多 USB 转 TTL 模块自带的指示灯也能提供一点信息,TX 灯闪烁说明数据确实发出去了,问题在接收端。别小看这些低成本手段,比盯着代码瞎猜有效得多。

注意:调试期间不要在中断回调里调用printf,尤其是重定向到同一个串口的情况。中断里打印会阻塞自己,形成死锁,现象是程序跑着跑着就停住,复位后又正常,非常难查。

7. 一些踩坑之后的个人体会

我用串口这些年,最大的体会是:它简单,但不代表可以随便。很多人觉得串口就是两根线的事,配置随手一点,代码复制粘贴,能通就行。可一旦产品要跑在现场,干扰、长线、连续大数据量,任何一个环节没考虑周全都会暴露出来。真正稳的串口代码,功夫都在细节里,DMA 缓冲留多大、环形缓冲什么时候加锁、485 方向切换等哪个标志、超时重发几次,这些参数没有标准答案,只能根据实际场景一点点调。

另一个体会是,调试工具要提前准备好。串口助手、逻辑分析仪、一份自己的排查清单,这些东西平时花十分钟准备,出问题的时候能省几个小时。我现在的新项目模板里,串口日志、环形缓冲、协议解析这三块是固定预置的,新板子到手先跑通日志,再往上叠业务,心里踏实很多。

最后一个建议,协议一定要写文档,哪怕只有一页纸。帧头是什么、长度怎么算、CRC 覆盖哪些字节、超时多少毫秒,这些写下来,三个月后自己回头看才不会一脸茫然,换个同事接手也不用从头猜。串口通信本身不难,难的是把一件简单的事做得每次都一样可靠,这才是它值得反复琢磨的地方。