很多用 STM32 做开发的朋友,第一步点亮 LED,第二步基本就是调 USART。这个外设看似简单,翻车率却一直不低:波特率配错了、数据丢字节、中断进不去、DMA 和空闲中断打架……我见过不少项目在串口这关卡上一两天,最后发现是某个配置位没置对。这篇东西我打算把 USART 从硬件原理到工程写法完整梳理一遍,重点讲基础配置之外那些真正干活时用得上的方案,比如环形缓冲区、printf 重定向、DMA + IDLE 不定长接收、多机协议帧解析,以及排查乱码丢数据的实战思路。不管你是刚接触 STM32 的新手,还是被串口调试折磨过的老手,应该都能找到能直接抄走的东西。
1. 先把 USART 这件事说透:它到底在做什么
1.1 USART、UART 和串口,别被名字绕晕
USART 的全称是 Universal Synchronous/Asynchronous Receiver/Transmitter,也就是通用同步/异步收发器。STM32 里的 USART 和 UART 差别其实只有一个:支不支持同步模式。同步模式需要提供时钟线,通常用于和需要时钟信号的设备通信;而我们日常说的“串口”,绝大多数跑的都是异步模式,UART 就是纯异步版本。很多型号上 USART1、USART2 等外设标注为 USART,实际当 UART 用也没问题。
有意思的是,我们常说的“串口”在电脑上往往指 RS-232、RS-485 或者 USB 转 TTL 这些物理层的概念,而 STM32 芯片引脚上输出的其实是 TTL 电平的 UART 信号。芯片出来的 TX、RX 两根线,电平是 0~3.3V,USB 转 TTL 工具通常也是这个电平,可以直接连。但如果是接到电脑原生串口或者老式设备上的 RS-232,就得通过 MAX3232 之类的芯片做电平转换,否则 3.3V 的 TTL 信号和 ±12V 的 RS-232 信号互相看不懂,通信自然失败。搞清楚这一层,很多“为什么我连上没反应”的问题就已经解决一半了。
1.2 从硬件管脚到数据帧:一条数据怎么“跑”出去
USART 异步通信的核心是双方约定好波特率、数据位、停止位和校验位,然后在一条线上按位发送。空闲时 TX 线保持高电平,要发送数据时先拉低一个位时间作为起始位,接着从低位到高位依次送出数据位,最后是停止位。接收端用自己的波特率时钟去采样这条线,就能还原出每一个字节。这里必须强调,双方波特率误差不能太大,通常在 ±2% 以内问题不大,但误差积累到一定程度就会开始出现乱码。
举个例子,我们配置 8 数据位、无校验、1 停止位,也就是常说的 8N1,发送一个字节 0x55(二进制 01010101),线上实际会出现 10 个位:1 个起始位(低电平)+ 8 个数据位 + 1 个停止位(高电平)。接收端必须在起始位的下降沿之后,在每一个数据位的中间时刻采样,才能得到最稳定的电平值。如果波特率偏差太大,采样点会逐渐偏移,最后采到错误的位。这一点在做长时间大数据传输时会特别明显,后面我会专门算一下波特率误差对通信的影响。
2. 基础配置:从寄存器到 HAL 库的落地写法
2.1 工程师常用的三种初始化路径
STM32 的串口初始化,不同人习惯差别很大。寄存器党喜欢直接操作 CR1、CR2、CR3 和 BRR,好处是执行效率高、代码量少,坏处是可读性差,换型号容易踩坑。标准外设库(SPL)现在用得越来越少,新项目基本不再推荐。HAL 库是目前主流,CubeMX 生成代码、初始化结构体一眼能看懂,但不少初学者容易只盯着 MX_USARTx_UART_Init 里那几个参数,反而忽略了 GPIO、时钟、中断优先级这些同样关键的配置。LL 库介于两者之间,代码更接近寄存器,性能好,但封装层次低,写起来没那么“傻瓜”。
我的建议很简单:新项目如果没有特殊理由,优先用 HAL 库。它的抽象层让你在换型号时少改很多东西,调试工具和网上资料也最丰富。但如果你在做一个对中断响应时间要求极其苛刻的裸机项目,或者代码空间极紧张,那可以自己封装一层寄存器操作,或者混用 LL 库。别盲目追求“底层”,稳定和可维护才是第一位。
2.2 轮询、中断、DMA,三种收发模式的取舍
轮询模式最简单,也在很多教学例程里最常见。发送用一个 while 循环等 TXE 标志位,接收也是死等 RXNE 标志位。这种模式在逻辑上完全阻塞 CPU,一个字节一个字节地磨,除非只是调试打印,否则不建议在真实产品里用来接收数据。想想看,如果对方给你发一堆数据,你主程序正在跑其他任务,轮询接收很容易丢数据。
中断模式是大多数项目的首选。每收到一个字节就触发一次接收中断,在中断里把数据存到缓冲区;发送也一样,可以用发送完成中断逐字节发。这种方式响应快,不阻塞主循环,但缺点是如果数据流量特别大,中断频率会很高,挤占 CPU 时间。DMA 模式让外设直接和内存搬运数据,搬运完成才打断 CPU,适合大块数据收发。不过 DMA 也不是万能药,后面会讲它和空闲中断配合时的坑。
2.3 一个可以直接抄的 HAL 库初始化示例
下面这个示例基于 CubeMX 生成的代码,我加了注释来说明每个部分为什么这么配。假设主频 72MHz,USART1 跑 115200-8-N-1,用 PA9 做 TX、PA10 做 RX。
// 这是 CubeMX 生成后,我手动调整过的初始化函数 static void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; // 收发都开 huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; // 不用硬件流控 huart1.Init.OverSampling = UART_OVERSAMPLING_16; if (HAL_UART_Init(&huart1) != HAL_OK) { Error_Handler(); } }GPIO 的配置同样关键。TX 脚要配成复用推挽输出,RX 脚配成复用浮空输入或者带上拉,具体看外部电路。很多人只改了 UART 的波特率,忘记检查 GPIO 的复用功能映射,结果串口怎么都不通。
static void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_USART1_CLK_ENABLE(); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_9, GPIO_PIN_RESET); GPIO_InitStruct.Pin = GPIO_PIN_9; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; // TX 复用推挽 GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_10; GPIO_InitStruct.Mode = GPIO_MODE_AF_INPUT; // RX 复用输入 GPIO_InitStruct.Pull = GPIO_NOPULL; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); }有两点我要特别提醒。第一,HAL_UART_Init内部会调用HAL_UART_MspInit,如果 MspInit 里没把时钟和 GPIO 初始化好,外设根本起不来。CubeMX 生成的代码会自动处理,但手写工程时经常漏。第二,发送用HAL_UART_Transmit,里面默认有超时时间,默认值很大,如果你用系统 tick 很忙的场景,记得把这个超时参数改小,否则发送会莫名卡死。
3. 高级应用:真正干活时需要的实用方案
3.1 重定向 printf 到 USART,调试效率翻倍
嵌入式里最常见的需求就是 printf 打印调试信息。在 GCC 环境下,重定向很简单,只需要实现_write函数;在 Keil MDK 下,则要实现fputc函数,同时勾选 MicroLIB 选项。网上有很多现成代码,但实际工程里坑不少。
// Keil MDK + MicroLIB 环境下重定向 printf #include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }用这个方法时要小心:默认HAL_UART_Transmit是阻塞发送,每打一个字符都可能卡在等待 TXE 标志上。如果你在中断回调里调用 printf,很容易造成死锁或者非常长的中断响应时间。我一般会自己封装一个带超时的、或者基于 DMA 发送的uart_printf,只在主循环或者任务里调用。
还有一个很多人忽略的问题:STM32 的硬件浮点 printf 默认不开启。如果你在 MDK 里不加--use_fp_opt之类的选项,%f打印出来的永远是 0 或者乱码。我建议少用浮点打印,真要打印浮点数,直接转成整数和小数部分分开打,既省空间又省时间。
3.2 环形缓冲区:中断收数不丢数据的基石
串口中断收数据,最朴素的做法是定义一个数组,每中断一次就往数组里存一个字节,存到末尾就从头再存。但这样存在两个问题:第一,主循环读取数据的速度跟不上中断写入速度,数据会被覆盖;第二,无法判断缓冲区里有多少有效数据。环形缓冲区就是为解决这个问题而生的。
环形缓冲区的核心是维护两个索引:写索引(head)和读索引(tail)。写入方(通常是中断)只移动 head,读取方(通常是主循环)只移动 tail。当 head == tail 时缓冲区为空,当 (head + 1) % size == tail 时缓冲区已满。注意我故意浪费一个存储单元,用来区分“空”和“满”,这样最简单也最不容易出错。
typedef struct { uint8_t buffer[512]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; int rb_write(ring_buffer_t *rb, uint8_t data) { uint16_t next = (rb->head + 1) % sizeof(rb->buffer); if (next == rb->tail) { return -1; // 缓冲区满 } rb->buffer[rb->head] = data; rb->head = next; return 0; } int rb_read(ring_buffer_t *rb, uint8_t *data) { if (rb->head == rb->tail) { return -1; // 缓冲区空 } *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % sizeof(rb->buffer); return 0; }在HAL_UART_RxCpltCallback里,把收到的字节写进环形缓冲区,然后立刻调用HAL_UART_Receive_IT重新开启下一次接收,这样就能实现不间断收数。这个方案我在多个实际项目里用过,特别适合协议不太复杂、数据流量中等的情况。要注意 head 和 tail 必须声明成 volatile,否则编译器优化后主循环可能一直读到旧值。
3.3 DMA + IDLE 中断实现不定长接收
环形缓冲区能解决单字节中断频繁打断 CPU 的问题,但如果数据帧比较长、频率又高,单字节中断还是太浪费 CPU。更好的方案是 DMA + 空闲中断(IDLE)。基本原理是:DMA 自动把串口收到的数据搬运到内存缓冲区,当一段数据发完、总线上出现空闲(即超过一个字节时间没有新数据)时,USART 会产生 IDLE 中断,这时候我们根据 DMA 当前计数寄存器算出这一帧有多少字节。
// 伪代码思路 #define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_len = 0; void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_DMAStop(huart); HAL_UART_Receive_DMA(huart, rx_buf, RX_BUF_SIZE); } } void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); HAL_UART_DMAStop(&huart1); rx_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmatx); // 注意这里是 hdmarx! // 处理 rx_buf 里的 rx_len 字节 HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); } }这里最容易搞错的就是 DMA 计数器寄存器。huart1.hdmarx才是接收 DMA 通道,不要写成hdmatx。另外,接收缓冲区的长度必须是 DMA 设定的最大长度,IDLE 到来时 DMA 计数器的值代表还有多少个位置没填,所以实际接收长度等于缓冲区总长度减去当前计数。
用 DMA + IDLE 还要注意一个细节:如果刚好数据长度等于缓冲区大小,DMA 会触发传输完成中断,不会产生 IDLE 中断。所以要么把缓冲区设置成比最大帧长大一点,要么在 DMA 完成中断里也做一次数据处理。我在项目里习惯把缓冲区大小设为最大帧长的 2 倍,这样基本能避开边界问题。
3.4 多机通信与协议帧解析实战
多机通信是串口应用里比较麻烦的场景。最简单的一主多从方式,是主机分别和每个从机通信,从机地址不同,帧带上地址字段。复杂一点的还可以做 RS-485 总线,主机发送带地址的帧,从机判断地址是否匹配,匹配才回复。RS-485 是半双工,需要在发送和接收之间切换方向控制引脚,这个切换时机如果掌握不好,容易造成总线冲突。一般做法是:发送完成后等待发送完成标志(TC),再延时一小段时间,最后把方向引脚切回接收模式。延时的长度至少要覆盖从机开始回复的响应时间,具体数值要根据实际设备测试。
协议帧的设计也是门学问。我常用的一个简洁帧格式是:帧头(0xAA 0x55)+ 长度(1字节)+ 命令字(1字节)+ 数据(N字节)+ CRC 校验(2字节)。帧头有两个字节是为了降低误同步概率;长度字段告诉解析器这一帧到底有多少数据;CRC 只对长度、命令字和数据做校验,帧头不算。
// 简单的状态机解析:这里只展示帧头同步部分 typedef enum { FRAME_WAIT_HEAD1, FRAME_WAIT_HEAD2, FRAME_WAIT_LEN, FRAME_WAIT_DATA, FRAME_WAIT_CRC } frame_state_t; frame_state_t state = FRAME_WAIT_HEAD1; uint8_t frame_buf[64]; uint16_t frame_index = 0; uint8_t frame_len = 0; void uart_frame_parse(uint8_t byte) { switch (state) { case FRAME_WAIT_HEAD1: if (byte == 0xAA) state = FRAME_WAIT_HEAD2; break; case FRAME_WAIT_HEAD2: if (byte == 0x55) state = FRAME_WAIT_LEN; else state = FRAME_WAIT_HEAD1; break; case FRAME_WAIT_LEN: frame_len = byte; frame_index = 0; state = (frame_len > 0 && frame_len <= sizeof(frame_buf)) ? FRAME_WAIT_DATA : FRAME_WAIT_HEAD1; break; case FRAME_WAIT_DATA: frame_buf[frame_index++] = byte; if (frame_index >= frame_len) state = FRAME_WAIT_CRC; break; case FRAME_WAIT_CRC: // 收了两个校验字节后做 CRC 判断 break; } }状态机解析的好处是代码直观、不会因为数据黏连导致错位,而且天然适用于从缓冲区里逐字节喂数据的场景。我在多个项目里都采用类似的解析结构,只要帧头和长度定义合理,基本不会出现因为半包、粘包导致的协议错乱。
3.5 与传感器/模块通信的特殊处理:编码、波特率与模块间通信机制
很多人以为串口通信就是把两边波特率设成一样就能通,真做产品时还会遇到编码问题。比如接一个 GPS 模块或者 4G 模组,模块可能输出 GBK 编码的汉字,而你的显示终端需要 UTF-8。STM32 本身没有现成的编码转换硬件,只能软件处理。如果只是少量汉字,可以建一张 GBK 到 Unicode 的映射表;如果数据量大,就得移植一个轻量级编码库。我通常建议能避免就避免,让模块直接输出 ASCII 或者 UTF-8,省掉一堆麻烦。网上有些“STM32 串口接收 GBK 转 UTF-8”的例程,核心思路也就是查表加重组 UTF-8 字节序列,并不复杂,但你要注意内存开销,尤其是汉字多的时候。
模块间通信机制也是新手容易忽略的。比如两个 STM32 板子通过串口互连,最好约定一个明确的应答机制:A 发给 B 一帧,B 收到后回一帧 ACK,A 超时未收到则重发。这个机制简单,但能让通信可靠性大幅提升。再比如接了蓝牙模块、LoRa 模块,不能只想着把 TX、RX 交叉相连就完事,还要看模块的流控配置。很多模块默认开启硬件流控 RTS/CTS,如果你的 STM32 没接对应引脚,数据就会悄悄丢掉。我踩过好几次这种坑,最后都在模块 AT 配置里把流控关掉才解决。
4. 常见问题与排查实录:从波形到代码的排障思路
4.1 数据乱码、丢字节、卡死的典型故障表
| 故障现象 | 可能原因 | 解决思路 |
|---|---|---|
| 所有数据都是乱码 | 波特率不匹配、时钟配置错 | 核对主频和 BRR 分频,用示波器测实际位宽 |
| 偶尔丢字节 | 接收中断里处理耗时太长 | 中断里只收数据,解析放到主循环 |
| 发送几个字节后卡死 | HAL_UART_Transmit 超时时间太短 | 增大超时参数,或改用中断/DMA发送 |
| 能收能发但协议对不上 | 帧解析状态机有误 | 打日志看每一帧的原始字节,逐步查状态跳转 |
| RS-485 通信偶尔冲突 | 方向切换时序不对 | 发送完成后等 TC 标志再延时切回接收 |
| 高波特率下长时间传输出错 | 波特率误差积累、干扰 | 降波特率,检查线路长度和地线,在 RX 脚加滤波 |
这张表基本上覆盖了我遇到过的绝大多数问题。排查的时候不要上来就怀疑代码,先把硬件链路确认了,很多时候是杜邦线接触不良、共地没接好、或者 TTL 电平不匹配。
4.2 用示波器和逻辑分析仪快速定位硬件问题
串口波形很简单,空闲是高电平,发送时先拉低一个位时间,然后按位翻转。用示波器看 TX 脚的波形是最直接的判断方法。把时基调到每格几十微秒,触发方式设为下降沿触发,就能看到一帧完整的波形。如果波形根本没变化,说明代码没跑、GPIO 配置错误,或者芯片没工作。如果波形存在但是翻转速度不对,那基本就是波特率配错了。
逻辑分析仪比示波器更适合看串口协议,因为可以直接解码出 0x55 这样的字节值。我现在调试串口都会挂一个便宜的 USB 逻辑分析仪,采样率设到 1M 以上,一边看解码结果一边对比程序预期。发现不对时,先用分析仪确认硬件上到底发的什么,再看软件处理有没有问题,这样能避免两个人互相甩锅。
有一点要提醒:杜邦线连接时 TX 和 RX 要交叉。也就是说 STM32 的 TX 接外部模块的 RX,STM32 的 RX 接外部模块的 TX。这是新手最常见的接法错误,接到同名单脚上只会出现“我发你也发,谁也收不到”的情况。
4.3 关于波特率误差的计算与实测
波特率误差是串口通信里一个隐蔽炸弹。STM32 的 USART 波特率由外设时钟和 BRR 分频决定,如果外设时钟不是波特率的整数倍关系,分频后就会有误差。公式是:波特率 = 外设时钟 / (16 × USARTDIV),其中 USARTDIV 可能带小数。HAL 库会自动计算最接近的分频值,但有些参数组合下误差会超过 2%。
举个例子,某 STM32F103 使用 72MHz 作为 USART1 时钟,想得到 115200 波特率:USARTDIV = 72000000 / (16 × 115200) = 39.0625,整数部分 39,小数部分 0.0625,正好可以用 4 位小数精确表示,理论误差为 0。但如果外设时钟变成 50MHz,同样的目标波特率:USARTDIV = 50000000 / (16 × 115200) ≈ 27.1267,小数部分无法精确表示,实际波特率会有误差,大概百分之零点几,短时间通信没问题,长时间大数据传输就可能累积出错。
实测方法很简单:用逻辑分析仪测量一个字节的起始位下降沿到停止位结束之间的实际时间,和理论位时间做个除法,就能算出真实波特率。我一般会在连接外部模块时,直接用模块回发的数据统计错误帧率,如果长时间稳定跑下来没有帧错误,就说明波特率这块没问题。
5. 从裸机到 RTOS:串口通信在系统中的定位
5.1 裸机主循环里的串口任务怎么拆
串口通信很少是独立存在的,它通常要和其他任务配合。裸机环境下,我习惯把串口接收分成两层:底层中断只负责把数据搬进缓冲区,上层主循环里用一个非阻塞的状态机去解析。这样既保证中断处理很短,又避免解析耗时影响其他模块。
主循环的结构大致是这样:周期检查环形缓冲区有没有新数据,有就取出来喂给状态机;状态机解析完整帧后,设置一个frame_ready标志;主循环检测到这个标志后,调用对应的业务处理函数。这里的关键是业务处理函数不能太耗时,否则下一个数据帧来了还没处理完,缓冲区又堆积起来。如果确实要处理大量数据,就得引入任务调度或者直接上 RTOS。
5.2 RTOS 下串口收发的注意事项
FreeRTOS 下串口通常有两种接法:一种是把串口中断通过信号量或消息队列通知任务,任务里再读取数据;另一种是更简单的做法,直接开一个串口接收任务,任务阻塞在队列上,有数据就处理。相比裸机,RTOS 下最大的优势是不会因为一个模块处理太慢而堵死其他模块。
但 RTOS 下的串口坑也不少。比如在中断里调用osMessageQueuePut这类 API 时,必须确认中断优先级不高于 FreeRTOS 允许的最高中断优先级,否则FromISR版本的 API 不能正确使用。再比如多个任务同时往同一个串口发数据,必须加互斥锁,不然两个任务的打印内容会交织在一起,调试信息完全没法看。我这里建议为串口发送封装一个带互斥量的函数,所有任务都调它,从根源上避免竞争。
5.3 和 485、蓝牙、LoRa 等模块通信时的联调心得
接伺服电机、PLC、变频器这类工业设备时,串口往往是 RS-485 半双工总线。这时候除了方向切换,还要注意总线上终端电阻的匹配。如果总线末端没有加 120Ω 电阻,长距离通信时信号反射会造成乱码,特别是波特率越高越明显。我见过一个项目,现场总线上挂了七八个设备,老是偶发通信失败,最后发现是某段线路过长且没有终端电阻,加上接地不良。解决后问题才消失。
接蓝牙模块时,第一次配对往往要留意模块的默认波特率。很多蓝牙模块出厂是 9600,而你的板子已经配成 115200,这时候先要发 AT 指令改波特率。LoRa 模块则要特别注意空中速率和串口缓冲区的匹配,如果串口速率大于空中速率,数据堆在模块缓冲区会丢失。这些外部模块看起来都是串口,但各自的流控机制不同,联调之前最好把模块手册里的时序图看明白。
6. 一些踩过坑之后的经验总结
最后聊几句我的个人体会。串口这个外设文档看着简单,实际工程里最容易出问题的往往是配置项之间的隐性依赖。比如 DMA 接收和空闲中断配合时,你必须确保 DMA 的循环模式没开,否则数据会反复覆盖缓冲区;再比如使用中断接收时,必须确认 USART 全局中断真的在 NVIC 里使能了,不少人配置了串口却忘了开中断,结果数据收不到。这种细节,文档里不会专门拎出来警告你,但排查起来最费时间。
我现在的习惯是,每个串口驱动都做一个简单的自测程序:上电后自发自收,用回环测试验证收发链路。如果串口连接的是外部设备,我会先打印收到的原始字节,确认硬件链路没问题再开始写协议解析。这套流程虽然看起来多花了几分钟,但能省下后面大量联调时间。调试串口的核心思路就一句话:一步一步确认,最底层的电平没问题,再往上看协议,最后才看业务逻辑。只要每一层都可靠了,整个项目也就稳了。