STM32 HAL库UART串口通信:波特率、中断与DMA+IDLE实战

发布时间:2026/11/4 10:38:28
STM32 HAL库UART串口通信:波特率、中断与DMA+IDLE实战

在嵌入式开发里,串口通信大概是最早被点亮、也最容易被轻视的外设。很多人拿到一块新板子,第一件事就是烧一个串口打印的例程,看到终端里跳出字符,就觉得这活儿已经通关了。可真到了项目里,问题就来了:上位机收到的数据偶尔少一个字节,中断接收只能进一次回调,DMA 收长包被截断,打印中文变成一堆问号,换个波特率就满屏乱码。这些问题八成都不是芯片坏了,而是 STM32 HAL 库 UART 这套接口背后有一堆默认行为没被讲透。这篇笔记把我这几年在 STM32 HAL 库上折腾串口通信(UART)的经验摊开讲,从时钟与波特率的底层计算,到阻塞、中断、DMA 三种收发模式的取舍,再到不定长数据的 IDLE 中断方案和一堆真实踩过的坑。刚上手 HAL 库的新手能顺着抄作业,已经写过几个项目的老手也能在排查思路那几节找到共鸣。

1. HAL 库把 UART 包装得很好,好到藏住了关键细节

HAL 库最大的价值是把寄存器操作抽象成了几个函数,HAL_UART_Transmit、HAL_UART_Receive、HAL_UART_Transmit_IT、HAL_UART_Receive_DMA,名字一看就懂。但这种"一看就懂"恰恰是问题的起点,因为函数返回值HAL_OK只代表这次调用的流程走完了,不代表数据真的上了线,更不代表对面的设备真的收到了。我见过太多人把HAL_UART_Transmit当成printf用,在中断里调用它,然后困惑为什么整个系统卡死。

1.1HAL_UART_Transmit返回HAL_OK之后,最后一个字节还在移位寄存器里

这是新手最容易忽略的一个时序细节。发送一个字节的过程分两步:先由 CPU 把数据写进发送数据寄存器 DR,再由硬件把 DR 里的内容搬到移位寄存器,然后一位一位地推出去。HAL_UART_Transmit在内部等待的是 TXE(发送数据寄存器空)标志,只要 DR 空了、能把下一个字节塞进去,它就认为这一轮写完了。等你把最后一个字节塞进 DR,函数返回HAL_OK,此时那个字节还在移位寄存器里慢慢往外吐。

平时这没什么影响,但有两种场景会直接出问题。第一种是 RS485 半双工,发送完需要立刻把方向引脚从发送切回接收,如果你在HAL_UART_Transmit返回后马上拉低 DE,最后一个字节会被硬生生截断,对面收到的就是残帧。第二种是低功耗场景,函数返回后立刻进 STOP 模式,时钟一停,最后一个字节也发不完。

正确的做法是等 TC(发送完成)标志,也就是移位寄存器真正空了。HAL 库提供了HAL_UART_Transmit的完整版本其实内部是等 TC 的,但如果你自己写轮询或者用_IT版本,就需要注意这一点。稳妥的写法是在发送结束后补一句:

/* 等待最后一个字节真正移出移位寄存器 */ while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) == RESET) { /* 可以加一个超时计数,防止死等 */ }

顺带提一句,HAL_UART_Transmit_IT的完成回调HAL_UART_TxCpltCallback是在 TC 中断里触发的,所以用中断版本反而不需要额外处理,这也是我后来倾向于用中断发送的原因之一。

1.2 阻塞、中断、DMA 三种模式不是性能档位,而是三种不同的责任划分

很多人把这三者理解成"慢、中、快"三档,实际上它们的区别在于谁在等。阻塞模式下,CPU 亲自在循环里盯着标志位,一等等到超时,期间什么也干不了;中断模式下,CPU 把"数据搬完了叫我"这件事交给 NVIC,自己去干别的;DMA 模式下,连搬运这件事都不经过 CPU,CPU 只在开头配置一次,结尾收个通知。

模式CPU 占用适用场景典型问题
阻塞HAL_UART_Transmit全程占用上电自检、调试打印、极短报文在中断里调用导致卡死
中断_IT每字节一次中断中等速率、报文短、逻辑简单回调只进一次、高频中断吃 CPU
DMA几乎为零大批量、高波特率、不定长接收长度算错、IDLE 标志清不掉

我在一个采样率比较高的项目里做过一次粗略统计:波特率 921600,每 10ms 发一包 64 字节的数据,用中断发送时 CPU 大约有 6% 到 8% 的时间花在进出中断上,改成 DMA 之后这部分开销基本消失。但如果你的数据是每秒钟发几个字节的心跳包,用 DMA 就是杀鸡用牛刀,配置复杂度反而会引入新问题。选型的判断标准很简单:问自己"发送和接收的时候,CPU 有没有别的事情必须同时干",有就用 DMA 或中断,没有就老老实实阻塞。

1.3 CubeMX 生成的那几行初始化代码里,藏着两个默认值

用 CubeMX 配 UART,界面上只有波特率、字长、校验、停止位、流控这几个选项,看起来简单。但它默认给你开的东西,有些是需要回头确认的。第一个是过采样模式,默认 16 倍过采样,这个一般不用改,但在高波特率下改成 8 倍过采样可以让 BRR 的小数位精度更高,这一点我在下一节展开。第二个是中断使能,CubeMX 里勾了 "UART global interrupt" 只是打开了 NVIC 通道,具体的 RXNE 中断是由HAL_UART_Receive_IT在运行时动态打开的,这一点很多人搞混,以为勾了 NVIC 就自动能收数据。

还有MX_USART1_UART_Init()里那句huart1.Init.OverSampling = UART_OVERSAMPLING_16;,以及HAL_UART_Init内部会根据你传进来的BaudRate反算 BRR 寄存器。这个反算过程如果时钟配置有偏差,算出来的实际波特率就会偏,而 HAL 不会给你任何警告,它只在HAL_UART_Init里返回HAL_OK。所以真正决定串口能不能通的第一件事,是时钟树。

2. 波特率不是填个数字就行:从时钟树到 BRR 寄存器的完整推算

我见过最离谱的一次排查,是某位同事的板子串口一直乱码,换了三根线、换了两个串口助手、换了电脑,最后发现是 CubeMX 里外部晶振填的 8MHz,实际焊的是 12MHz。芯片照样跑起来了,因为 HSI 兜底,但所有基于 HSE 的分频结果全错了,波特率自然对不上。串口通信本质是双方约定好每个位的持续时间,任何一侧的时间基准偏了,累计到一个字节就会采样错位。

2.1 USARTDIV 的推导:一个除法加一个取小数

以最常见的 STM32F1 系列为例,USART 挂在 APB2 上,假设系统时钟 72MHz,PCLK2 也是 72MHz,目标波特率 115200,16 倍过采样。公式是:

USARTDIV = fPCLK / (16 × BaudRate) = 72,000,000 / (16 × 115200) = 39.0625

这个 39.0625 要拆成整数部分和小数部分写进 BRR 寄存器:整数部分 39 放进BRR[15:4],小数部分乘以 16 取整后放进BRR[3:0]。0.0625 × 16 = 1,所以 BRR = (39 << 4) | 1 = 0x271。这个例子刚好整除得漂亮,实际项目里很少这么幸运。

再看一个不那么漂亮的:PCLK2 = 8MHz,目标 115200。

USARTDIV = 8,000,000 / (16 × 115200) = 4.3403 整数部分 = 4 小数部分 = 0.3403 × 16 = 5.44 → 取 5 实际分频 = 4 + 5/16 = 4.3125 实际波特率 = 8,000,000 / (16 × 4.3125) = 115,942 误差 = (115942 - 115200) / 115200 ≈ 0.64%

0.64% 的误差完全能用,因为 UART 一帧 10 位(1 起始 + 8 数据 + 1 停止),接收端在每位中间采样,理论上累计误差不超过半个位宽也就是 5% 都能容忍,工程上一般要求控制在 2% 以内留余量。但如果你的时钟是 7.3728MHz 这种奇怪的值,或者用 HSI 直接跑,误差可能就奔着 3% 去了。

2.2 8 倍过采样什么时候值得开

STM32 的 USART 支持 16 倍和 8 倍两种过采样。8 倍过采样的好处是小数部分有 3 位而不是 4 位,看似精度更低了,但由于分频公式变成fPCLK / (8 × Baud),在同样的时钟下算出来的 USARTDIV 更大,整数部分占比更高,某些特定组合下的量化误差反而更小。

举个实际例子,F1 系列 PCLK2 = 36MHz,目标 921600:

  • 16 倍过采样:USARTDIV = 36e6 / (16 × 921600) = 2.4414,小数部分 0.4414×16=7.06→7,实际 2.4375,实际波特率 923,077,误差 0.16%
  • 8 倍过采样:USARTDIV = 36e6 / (8 × 921600) = 4.8828,小数部分 0.8828×8=7.06→7,实际 4.875,实际波特率 923,077,误差一样

这个例子里两者没差别。真正拉开差距的是高波特率配低主频的极端情况,比如 PCLK2 = 8MHz 跑 921600。16 倍过采样下 USARTDIV = 0.542,整数部分是 0,HAL 库会直接返回错误,因为分频不能小于 1。8 倍过采样下 USARTDIV = 1.085,勉强能用,但误差高达 8% 以上,实际通信会丢包。所以结论是:8 倍过采样的价值在于让低主频能跑高波特率,但如果算出来误差超过 2%,正确的做法是提高时钟,而不是硬开 8 倍过采样。

2.3 HSE 和 HSI 的取舍:一个反直觉的结论

很多人图省事,CubeMX 里时钟源直接选 HSI,觉得内部 RC 不用外部晶振,省两个元件。但 HSI 的出厂精度是 ±1%(25℃),全温度范围能到 ±2% 甚至更多。这个误差会直接叠加到波特率误差上。如果你的分频本身就有 1% 误差,加上 HSI 的 2%,总误差 3%,短帧还能凑合,连续收长包就开始随机丢字节。

我的建议是:只要板子上有晶振,串口通信一律用 HSE。哪怕你不需要高精度定时,串口这条链路上省下来的调试时间远超一颗晶振的成本。如果实在没有晶振,那就把波特率降下来,115200 改成 9600 或 38400,位宽变长了,同样的绝对误差在采样点的相对占比就小了,容错空间更大。

2.4 用 GPIO 翻转法实测波特率,比示波器还方便

没有示波器的时候,怎么验证实际波特率?我的办法是借一个空闲的 GPIO,在发送一个已知字节前后翻转电平,用逻辑分析仪或者另一个串口设备去量。更简单的办法是让单片机连续发送 0x55(二进制 01010101),这个字节的波形是标准方波,周期就是两位的时间,用示波器一量周期,除以 2 就是位宽,倒数就是波特率。

如果连示波器都没有,还有个土办法:让设备连续发 1000 个 0x55,用另一块已知波特率的板子去收,看收到多少个字节、错位出现在第几个。错位越早出现,说明误差越大。这个方法精度不高,但用来判断"是不是波特率的问题"足够了。

排查动作判断依据结论
收 0x55 波形量周期位宽是否等于 1/波特率时钟或分频错
换波特率收到正确数据只在高波特率出错误差累积,降速或改时钟
换另一台电脑/串口助手仍然乱码排除上位机
短接 TX/RX 自收自发自发自收正常外设或线缆问题

3. 中断接收的完整链路:从 NVIC 到回调函数的那几道关

中断接收是 HAL 库 UART 里最容易"看起来能用但实际有坑"的部分。HAL_UART_Receive_IT(&huart1, buf, 1)这一句写下去,串口助手一发数据,回调函数确实进来了,很多人到这里就收工。但第二天发现只收到第一个字节,后面全没了,于是开始怀疑芯片。

3.1HAL_UART_Receive_IT只收一次,是设计如此而不是缺陷

这个行为的根源在HAL_UART_IRQHandler里。当 RXNE 标志置位、进入中断后,HAL 会把数据读到缓冲区,RxXferCount减一。当计数减到 0,它调用HAL_UART_RxCpltCallback,然后执行__HAL_UART_DISABLE_IT(huart, UART_IT_RXNE)把接收中断关掉。也就是说,每一次HAL_UART_Receive_IT调用只对应当前这一次接收任务,任务完成就自动注销。

所以正确的写法是在回调里再次启动接收:

uint8_t rx_byte; uint8_t rx_buf[64]; volatile uint16_t rx_len = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { if (rx_len < sizeof(rx_buf)) { rx_buf[rx_len++] = rx_byte; } /* 关键:重新挂上接收,否则只能收一次 */ HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }

这段代码里rx_byte必须是全局或者静态变量,不能是回调里的局部变量,因为HAL_UART_Receive_IT保存的是这个指针,中断来了往这个地址写。我见过有人把接收缓冲定义在回调函数内部的栈上,函数返回后那块栈被别的代码覆盖,收到的数据自然是乱的。

3.2 回调里的隐形陷阱:在中断上下文里干重活

另一个高频问题是回调里做的事太多。HAL_UART_RxCpltCallback是在中断上下文里执行的,优先级由 NVIC 里 UART 的抢占优先级决定。如果你在这个回调里调用HAL_Delay、做浮点运算、甚至调用printf,后果就是中断服务时间被拉长,高波特率下下一个字节已经到了而 CPU 还没退出中断,RXNE 标志来不及处理,直接触发溢出错误 ORE。

我的处理原则是:回调里只做搬运和置标志,解析和业务逻辑放到主循环。比如上面那段代码只是把字节塞进缓冲区,主循环检测到帧结束标志再去解析。如果你一定要在回调里做判断,那判断条件必须足够简单,比如只比较帧头帧尾,不涉及字符串处理和内存分配。

在跑 RTOS 的工程里还得再加一层。回调里不能直接调用带阻塞的 FreeRTOS API,正确做法是用xQueueSendFromISR,并且在退出前调用portYIELD_FROM_ISR触发一次调度:

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { BaseType_t woken = pdFALSE; if (huart->Instance == USART1) { xQueueSendFromISR(uart_queue, &rx_byte, &woken); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } portYIELD_FROM_ISR(woken); }

3.3 被忽略的第四种回调:错误回调与 ORE 溢出

HAL 库给 UART 准备了四个回调:RxCpltCallback、TxCpltCallback、ErrorCallback、AbortCallback。前两个大家都熟,后两个基本没人写。但恰恰是HAL_UART_ErrorCallback能救你一命。

当接收溢出发生时,ORE 标志置位,HAL 的UART_IRQHandler会检测到并调用错误回调,同时把huart->ErrorCode置成HAL_UART_ERROR_ORE。如果你没有实现错误回调,HAL 内部的默认处理是清除错误标志然后尝试恢复,但恢复过程有时会中断当前的接收任务,导致后续数据全部丢失,而且没有任何提示。

我在一个 115200 波特率、每 20ms 一包数据的项目里遇到过一次,现象是运行几分钟后接收停摆,加日志才发现是 ORE。根因是主循环里有段时间在擦 Flash,关中断时间过长。处理办法是在错误回调里重新挂接收,并统计溢出次数作为健康指标:

volatile uint32_t uart_err_cnt = 0; void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uart_err_cnt++; /* 清标志并重新挂接收 */ __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }

顺便说一句,__HAL_UART_CLEAR_OREFLAG这个宏的实现是先读 SR 再读 DR,这是硬件要求,ORE 标志必须通过"读 SR 后读 DR"才能清除,只写 0 是清不掉的。

3.4 中断优先级和临界区:别让串口把整个系统拖住

UART 中断的抢占优先级不要设得太高。有些人生怕丢数据,把 UART 设成最高优先级 0,结果它一进中断,所有其他外设包括定时器和 DMA 都被挡住。正确做法是让 UART 优先级居中偏下,同时保证它的响应时间小于一个字节的传输时间。

一个字节 10 位,115200 波特率下单字节耗时约 87µs,921600 下只有 10.8µs。这意味在最高波特率下,你的中断响应加上其他高优先级中断的嵌套总和不能超过 10µs,否则必然丢字节。这个数字给了一个很清晰的判断依据:如果你需要在高波特率下做复杂的系统调度,单字节中断接收方案本身就不合适,应该换 DMA。

4. DMA 加 IDLE 中断:不定长接收的最优解

固定长度接收用 DMA 很舒服,HAL_UART_Receive_DMA(&huart1, buf, 128)一句话,收满 128 字节回调一次。但真实协议里的数据包长度往往是变的,可能是 8 字节的指令,也可能是 200 字节的数据块。用固定长度 DMA 只能靠"猜一个最大值然后等超时",效率极低。IDLE 中断就是为解决这个问题而生的。

4.1 IDLE 中断的原理:总线空闲一个字节时间就报警

串口在接收数据时,RXNE 每次置位代表来了一个字节。当一帧数据发完,发送方停止发送,接收线上保持高电平。硬件检测到"接收线在接收完一个字节后又保持了整整一个字节时间的高电平",就把 IDLE 标志置位,这就是总线空闲检测。

配合 DMA 使用的逻辑是:DMA 在后台不停地往缓冲区里搬数据,同时NDTR寄存器自动递减,记录还剩多少没搬。IDLE 中断到来时,说明这一包数据发完了,此时用"总长度减去 NDTR 剩余值"就能算出这一包实际收到了多少字节。整个过程 CPU 完全不参与搬运。

这个方案的关键优势是不需要知道包长,也不需要等超时,报文之间的间隔由硬件自动识别,响应延迟就是一个字节的时间。

4.2 完整实现:从配置到解析

第一步,CubeMX 里把 USART1 的 DMA 接收通道打开,模式选 Normal(不是 Circular),然后在 NVIC 里勾上USART1 global interrupt。生成的代码里还需要手动添加一句使能 IDLE 中断:

/* 在 MX_USART1_UART_Init 之后,或初始化完成后调用 */ __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(&huart1, dma_rx_buf, DMA_RX_BUF_SIZE);

第二步,重写中断服务函数。注意这里不能直接调用HAL_UART_IRQHandler里的默认逻辑,因为 HAL 本身不处理 IDLE,需要我们自己判断:

#define DMA_RX_BUF_SIZE 512 uint8_t dma_rx_buf[DMA_RX_BUF_SIZE]; uint8_t frame_buf[DMA_RX_BUF_SIZE]; volatile uint16_t frame_len = 0; volatile uint8_t frame_ready = 0; void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { /* 1. 先停 DMA,防止计算长度时数据还在变 */ __HAL_DMA_DISABLE(&hdma_usart1_rx); /* 2. 清 IDLE 标志(读 SR 再读 DR) */ __HAL_UART_CLEAR_IDLEFLAG(&huart1); /* 3. 用 NDTR 反算本包长度 */ uint16_t recv_len = DMA_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (recv_len > 0 && recv_len <= DMA_RX_BUF_SIZE) { memcpy((void *)frame_buf, dma_rx_buf, recv_len); frame_len = recv_len; frame_ready = 1; } /* 4. 重新启动 DMA 接收下一包 */ __HAL_DMA_SET_COUNTER(&hdma_usart1_rx, DMA_RX_BUF_SIZE); __HAL_DMA_ENABLE(&hdma_usart1_rx); } else { HAL_UART_IRQHandler(&huart1); } }

第三步,主循环里检测frame_ready标志后处理。处理完记得把标志清零,并且注意frame_buf在下一次 IDLE 到来时会被覆盖,如果处理逻辑耗时较长,应该再多加一级软件缓冲,比如用双缓冲或者环形队列。

4.3 清 IDLE 标志的顺序,决定你会不会丢第一个字节

上面代码里那三步的顺序是有讲究的,很多教程直接写__HAL_UART_CLEAR_IDLEFLAG(&huart1)就算完事,实际跑起来偶尔会丢数据。原因在于__HAL_UART_CLEAR_IDLEFLAG这个宏的定义是"读 SR 再读 DR",读 DR 这个动作本身会把接收数据寄存器里的内容取走。如果此时 DMA 还没来得及搬运最后一个字节,你这一读,这个字节就从 DMA 通道里消失了。

所以稳妥的顺序是:先关 DMA,再清 IDLE,再算长度,最后重启 DMA。先关 DMA 保证 NDTR 不再变化,算出来的长度是准的;此时最后一个字节已经被 DMA 搬进缓冲区,读 DR 不会造成数据丢失;重启 DMA 时用__HAL_DMA_SET_COUNTER把计数器重置回最大长度。

还有一个细节,__HAL_DMA_DISABLE之后需要确认 DMA 真的停了,STM32 的 DMA 在禁用后可能有短暂的延迟。严格来说应该等 EN 位真正清零,但实际使用中在中断里直接操作基本没问题,如果遇到极端情况,可以在禁用后加一次__DSB()保证内存访问顺序。

4.4 断帧、粘包和超时补齐

IDLE 方案不是万能的,它解决的是"报文之间有间隔"这个场景。如果发送方是连续不断地发数据,字节和字节之间没有空闲,IDLE 就永远不会触发,DMA 会一直搬到缓冲区满。这时候NDTR归零会触发 DMA 传输完成中断,可以作为兜底:

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { /* DMA 满了,说明是连续流或者超长包 */ uint16_t recv_len = DMA_RX_BUF_SIZE; memcpy((void *)frame_buf, dma_rx_buf, recv_len); frame_len = recv_len; frame_ready = 1; /* 重启接收 */ HAL_UART_Receive_DMA(&huart1, dma_rx_buf, DMA_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); } }

另一种常见坏情况是"粘包":发送方两包数据之间间隔太短,硬件还没判定为空闲,第二包就来了,结果两包被合并成一包。这属于协议层的问题,解决办法是在应用层做帧头帧尾匹配,或者加上长度字段,收到数据后从头扫描找到完整的帧再切分。我个人的习惯是协议里永远带帧头、长度和 CRC 尾,这样无论底层怎么切分,上层都能重组。

现象底层原因处理方式
每包都少最后一字节清 IDLE 时读了 DR先关 DMA 再清标志
两包数据粘成一包间隔小于一字节时间应用层按帧头切分
长时间运行后停止接收DMA 未重启或 ORE 溢出错误回调里恢复
只在低速时正常中断响应超时提高优先级或改 DMA

5. 那些年踩过的串口坑:从乱码到复位异常

前面讲的都是架构层面的问题,这一节讲具体现象和定位过程。串口调试的特点是现象千奇百怪,但根因高度集中,翻来覆去就那么七八个。

5.1 第一个字节丢失和 TC 标志的直接关系

有个现象我遇到过至少三次:上位机收到的数据流里,每一帧的第一个字节总是缺失,后面的全对。第一次遇到时怀疑是协议解析错了,查了半天才发现是发送侧的问题。

根因往往是这样:代码里用HAL_UART_Transmit发数据,发送前先拉了一个 GPIO 作为"发送指示",或者做了某种切换动作,而这个动作恰好抢在了数据真正上线之前。另一种情况是用了 RS485 收发器,方向切换的时机不对,第一个字节在 DE 拉高的瞬间被吞掉了。

还有一种更隐蔽的:从机应答主机时,主机刚发完最后一位就切到接收模式,从机的第一个应答字节和主机的最后一位在总线上撞车。这需要从机在应答前留一点间隔,通常在协议里规定 1 到 2 个字节时间的空闲。

排查这类问题的方法很直接:在发送函数前后各翻转一个空闲 GPIO,用逻辑分析仪同时抓 TX 线和这个 GPIO,一眼就能看出是软件时序问题还是硬件问题。

5.2 printf 重定向的三个细节

重定向printf到串口几乎是每个项目的标配,但这段代码有几个坑。

第一个坑是半主机模式。在 Keil MDK 下用 ARMCC 编译,如果不做处理,printf会尝试通过调试器输出,没有调试器时程序直接卡死在BKPT指令里。解决办法是加#pragma import(__use_no_semihosting)并实现_sys_exit和_ttywrch,或者干脆在工程设置里勾选 MicroLIB。用 ARM Compiler 6 的话,要重定向的是__io_putchar或者fputc。

第二个坑是重定向函数的实现方式。用fputc加重定向时,每次调用都是阻塞发送一个字节,如果波特率是 115200,打印一行 50 个字符大约需要 4.3ms。在中断里调用printf就会阻塞 4.3ms,这个时间足以让串口接收溢出。所以我的做法是给调试打印单独开一个串口,或者干脆用 DMA 发送,把printf变成非阻塞的。

第三个坑是浮点数支持。默认情况下很多工具链的printf不带浮点格式化,printf("%f", 3.14)输出的是空白或者乱码。需要在链接选项里加上浮点支持,或者在 MicroLIB 下自己实现一个精简版的浮点转字符串。

/* GCC 工具链下的重定向示例 */ int _write(int fd, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }

5.3 电平、共地和上拉:硬件层的锅也得会背

软件查不出问题的时候,八成是硬件。最常见的三个:

电平不匹配。单片机出来的是 TTL 电平(3.3V 或 5V),如果对面是 RS232 设备(±12V),中间必须有电平转换芯片,直接接上去轻则不通信,重则烧引脚。USB 转串口模块也分 TTL 和 RS232 两种,买错了就用不了。

没共地。两块板子只连了 TX 和 RX,没连 GND,信号没有共同的参考点,接收端采样电压漂移,表现为时好时坏、换个姿势就通了。这是最容易被忽略的一条。

上拉电阻。UART 空闲时线上是高电平,如果两端都没有上拉,线路悬空时可能被干扰拉低,接收端会误判为一个起始位,然后收到一个 0x00。板子之间距离稍远或者线缆较长时,加一个 4.7k 到 10k 的上拉能明显改善。

5.4 复位和下载时 TX 引脚的状态

还有一个容易被忽略的场景:程序烧录或者芯片复位期间,TX 引脚处于浮空输入状态,电平不确定。如果这个 TX 接到对面设备的一个敏感输入端,可能会被误触发。比如接到了另一个单片机的 RX,对面可能认为收到了一串乱码指令。

解决办法是在 TX 引脚上加一个上拉电阻,保证复位期间保持高电平(空闲态)。如果对面是对指令敏感的从机,还要在协议层加校验,把复位期间的乱码过滤掉。

6. 一套可以直接抄走的 UART 工程骨架

讲了这么多问题,最后落到实际能用的代码结构上。我的习惯是把 UART 相关的东西全部收在一个bsp_uart.c/h里,对外只暴露三个接口:初始化、发送、注册接收回调。

6.1 分层设计:驱动层只负责搬运,协议层只负责解析

驱动层的职责边界要划清楚:它只管"把字节收进来、把字节发出去",不判断帧头,不校验 CRC,不理解业务语义。这样做的价值在于,同一个驱动可以被不同的协议复用,改协议的时候不用动驱动。

驱动层对外提供的接口大概是这样:

/* bsp_uart.h */ typedef void (*uart_rx_handler_t)(uint8_t *data, uint16_t len); void bsp_uart_init(uint32_t baudrate); void bsp_uart_send(uint8_t *data, uint16_t len); void bsp_uart_register_handler(uart_rx_handler_t handler); uint32_t bsp_uart_get_error_count(void);

发送接口内部用 DMA 加忙等待或者队列,接收接口内部用 DMA 加 IDLE,收到一包就回调注册的处理函数。业务层的代码长这样:

static void on_frame_received(uint8_t *data, uint16_t len) { if (len < 4) return; /* 最短帧长 */ if (data[0] != 0xAA || data[1] != 0x55) return; /* 帧头校验 */ uint8_t payload_len = data[2]; uint16_t crc_calc = crc16(data, 3 + payload_len); uint16_t crc_recv = data[3 + payload_len] | (data[4 + payload_len] << 8); if (crc_calc != crc_recv) { bsp_uart_get_error_count(); /* 记一次校验失败 */ return; } /* 这里才进入真正的业务处理 */ handle_command(&data[3], payload_len); }

这样的分层在项目后期改动协议时特别省心,只需要换掉on_frame_received,底层一行不动。

6.2 一个够用的环形缓冲区实现

在高数据量场景下,IDLE 中断里直接memcpy到帧缓冲有风险,因为如果主循环处理不及时,下一包数据会覆盖。更可靠的做法是中断里只往环形缓冲区写,主循环从缓冲区读。

#define RING_BUF_SIZE 1024 typedef struct { uint8_t buf[RING_BUF_SIZE]; volatile uint16_t head; /* 写指针,只在中断里改 */ volatile uint16_t tail; /* 读指针,只在主循环里改 */ } ring_buf_t; static ring_buf_t g_ring; static inline uint16_t ring_used(void) { return (g_ring.head - g_ring.tail) & (RING_BUF_SIZE - 1); } /* 在 IDLE 中断里调用,写入刚收到的一包 */ static void ring_write(uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { uint16_t next = (g_ring.head + 1) & (RING_BUF_SIZE - 1); if (next == g_ring.tail) { /* 缓冲区满,丢弃最老的一个字节 */ g_ring.tail = (g_ring.tail + 1) & (RING_BUF_SIZE - 1); } g_ring.buf[g_ring.head] = data[i]; g_ring.head = next; } } /* 在主循环里调用 */ uint16_t ring_read(uint8_t *out, uint16_t max_len) { uint16_t n = 0; while (n < max_len && g_ring.tail != g_ring.head) { out[n++] = g_ring.buf[g_ring.tail]; g_ring.tail = (g_ring.tail + 1) & (RING_BUF_SIZE - 1); } return n; }

这里有两个细节需要注意。一是head和tail必须加volatile,否则编译器优化可能把它们缓存在寄存器里,造成死循环。二是缓冲区大小取 2 的幂,这样取模可以用位与代替除法,中断里的开销更小。三是这个实现是单生产者单消费者模型,也就是只有一个中断写、一个主循环读,如果是多中断或者 RTOS 多任务,就需要加临界区保护。

6.3 心跳日志与健康度统计

串口通信的可靠性不能靠"看起来正常",得有量化指标。我在每个项目里都会加一个简单的统计结构,记录收到的总包数、CRC 错误数、溢出次数和缓冲区丢弃字节数。这四个数一摆出来,通信质量一目了然。

统计项正常范围异常时的排查方向
溢出次数 ORE0中断响应超时、关中断太久
CRC 错误数偶发个位数线缆干扰、波特率误差
缓冲区丢弃0主循环处理太慢或缓冲区太小
帧头丢失数0断帧、清 IDLE 时机问题

这些统计不需要实时打印,可以在空闲时通过一个诊断命令输出,或者存在结构体里供调试器查看。有了这些数字,现场排查的时候就不用猜了,直接看哪个指标异常,方向立刻就清楚了。

6.4 中断里的时间预算:算一遍心里才有数

最后说一个我每次做串口方案都会算的数:中断的时间预算。假设波特率 115200,一个字节 10 位,传输时间 86.8µs。如果你的方案是单字节中断,那么每 86.8µs 就要进一次中断,中断服务程序的时间必须远小于这个值,我一般要求控制在 20µs 以内,也就是留四分之三的余量。

DMA 加 IDLE 方案就宽松得多,一包数据才进一次中断。假设一包 64 字节,中断间隔变成 5.5ms,哪怕中断服务里做 200µs 的处理也毫无压力。这就是为什么数据量大的场合一定要上 DMA,它不是"更快",而是"把中断频率从每字节降到每帧",从根本上改变了系统的时序余量。

还有一点,如果系统里有 Flash 擦写操作,要特别注意。擦除一个扇区的时间可能是几十毫秒,这期间如果关中断,串口必然溢出。解决办法是把串口接收继续用 DMA 跑着(DMA 不依赖 CPU 中断),只在 IDLE 中断里做短暂处理,或者把 Flash 操作放到 DMA 传输的间隙进行。这一点在 OTA 升级场景里尤其重要,我就因为这个问题在一个项目里丢了半包固件数据,排查了大半天。

我个人在串口这块最深的体会是:HAL 库给你的是一套能跑起来的默认行为,但每一个默认行为背后都有一个需要你确认的假设。HAL_UART_Transmit假设你不需要等最后一个字节,HAL_UART_Receive_IT假设你只收一次,HAL_UART_Init假设你传进来的波特率和时钟是匹配的。把这些假设一条条验证过来,串口就从"能通"变成了"可靠"。后来我养成一个习惯,新建工程点亮串口之后,先用 0x55 测波形确认波特率,再用连续发包跑半小时看错误计数,这两步做完,后面写业务逻辑心里就踏实了。