
很多用 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 两根线电平是 03.3VUSB 转 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 生成的代码我加了注释来说明每个部分为什么这么配。假设主频 72MHzUSART1 跑 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 收到后回一帧 ACKA 超时未收到则重发。这个机制简单但能让通信可靠性大幅提升。再比如接了蓝牙模块、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 接外部模块的 RXSTM32 的 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 里使能了不少人配置了串口却忘了开中断结果数据收不到。这种细节文档里不会专门拎出来警告你但排查起来最费时间。我现在的习惯是每个串口驱动都做一个简单的自测程序上电后自发自收用回环测试验证收发链路。如果串口连接的是外部设备我会先打印收到的原始字节确认硬件链路没问题再开始写协议解析。这套流程虽然看起来多花了几分钟但能省下后面大量联调时间。调试串口的核心思路就一句话一步一步确认最底层的电平没问题再往上看协议最后才看业务逻辑。只要每一层都可靠了整个项目也就稳了。