
1. 从点灯到串口为什么UART是绕不开的第一道坎刚把GPIO和中断摸熟很多人下一步就会盯上串口。原因很简单UART是嵌入式开发里最便宜、最通用的调试通道。没有屏幕、没有仿真器的时候一根串口线就能把芯片内部的运行状态吐出来这个能力在排查问题时几乎是救命的。而STM32CUBEMX加上HAL库这套组合把UART的初始化从“翻手册算波特率、配寄存器”变成了“点几下鼠标、填几个参数”门槛确实降了不少。但门槛降低不等于没有坑。我见过太多人用CUBEMX生成了代码编译通过烧进去串口助手却一片空白然后开始怀疑人生。问题往往不在HAL库本身而在于对UART的收发机制、中断与DMA的配合方式、以及HAL库那套回调体系理解得不够透。这篇记录就围绕UART设备展开从CUBEMX的配置界面一路讲到实际收发代码的落地把每个参数背后的含义、每种收发方式的取舍、以及调试时最常踩的坑都摊开来说。内容适合已经能跑通GPIO、对STM32有基本认知、准备把串口用起来的开发者。不管你是用F1、F4还是H7系列UART的底层逻辑是相通的差别只在时钟树和部分寄存器的细节上。我会尽量把通用部分讲透系列差异单独标注。2. CUBEMX里UART配置的每个选项到底在干什么2.1 模式选择Asynchronous才是你99%场景要用的打开CUBEMX的Connectivity分类点进USART或UART外设第一个要面对的就是Mode选择。这里通常有Asynchronous、Synchronous、Single Wire、Multiprocessor、IrDA、LIN、SmartCard等一堆选项。新手容易懵其实绝大多数情况下你只需要Asynchronous。Asynchronous就是标准的异步串口TX和RX两根线没有时钟线靠双方约定的波特率来同步。Synchronous会多出一根CK线提供时钟一般用在需要高速或长距离的场景日常调试用不上。Single Wire是单线半双工一根线既发又收适合引脚紧张的情况但收发不能同时进行。Multiprocessor、IrDA、LIN、SmartCard这些是特定协议场景除非你明确知道自己在做那个方向否则不用碰。选Asynchronous之后下面会出现硬件流控的选项Hardware Flow Control。这个对应的是RTS和CTS两根线作用是防止接收方缓冲区满了还在被硬塞数据。如果你只是接个串口助手调试不需要开流控开了反而要多占两个引脚接线也麻烦。只有在和某些模块通信、对方数据量大且要求严格流控时才需要启用。2.2 波特率不是随便填的误差计算与时钟源的关系波特率Baud Rate这一栏默认是115200。这个值不是随便定的它是嵌入式领域事实上的“标准调试速率”。为什么是115200而不是100000或者200000因为它是标准波特率序列里比较高的一个同时大多数USB转串口芯片都能稳定支持再往上到921600甚至更高对线材和芯片质量就有要求了。但真正要理解的是波特率能不能配准取决于你的外设时钟和分频系数。STM32的UART波特率计算公式大致是Baud fCK / (8 * (2 - OVER8) * USARTDIV)其中fCK是挂载在UART上的时钟频率OVER8是过采样模式0表示16倍过采样1表示8倍过采样USARTDIV是分频系数包含整数部分和小数部分。CUBEMX会自动帮你算这个值但它算出来的实际波特率和你要的目标波特率之间可能存在误差。误差超过3%左右通信就会开始不稳定表现为偶尔收到乱码或者干脆收不到。在CUBEMX的时钟树配置里你能看到USART挂载在哪条总线上。比如F4系列USART1挂在APB2上USART2挂在APB1上两条总线的时钟频率可能不同。你要确保目标波特率在对应时钟下能算出误差足够小的分频值。CUBEMX通常会在配置界面下方显示实际计算出的波特率和误差百分比养成看一眼这个误差值的习惯如果误差偏大可以尝试调整时钟树里对应总线的分频系数让fCK变成更容易整除的数。2.3 数据位、停止位、校验位默认值背后的逻辑Word Length默认是8 Bits这是最通用的配置一个字节正好8位。Stop Bits默认是1Parity默认是None。这三个默认值组合起来就是常说的“8N1”即8位数据位、无校验、1位停止位。串口助手上一般也是这么设的两边一致才能通。什么时候需要改如果你在和某些老设备或特定模块通信对方手册明确要求7位数据位或者偶校验那就得跟着改。校验位的作用是检测单比特错误但它不能纠错而且会增加开销。在短距离、干扰小的板级通信里不开校验完全没问题。如果通信环境干扰大可以考虑开奇偶校验但更可靠的做法是在应用层加CRC校验而不是依赖UART自带的奇偶校验。停止位改成2的情况比较少一般出现在对方设备时钟精度较差、需要更多时间完成帧同步的场景。改任何一项之前先确认对方设备的配置两边不一致是串口不通最常见的原因之一而且这种问题往往很隐蔽因为代码看起来完全正确。2.4 中断与DMA什么时候该勾什么时候不该勾CUBEMX的NVIC Settings里可以勾选USART的全局中断DMA Settings里可以添加TX和RX的DMA通道。这三个选项决定了你后续用哪种方式收发数据。中断方式适合数据量不大、实时性要求一般的场景。每收到一个字节触发一次中断在中断里把数据存起来。优点是实现简单不需要额外配置DMA缺点是波特率高的时候中断频繁CPU开销大。115200波特率下每个字节大约87微秒如果连续接收大量数据中断会占掉不少CPU时间。DMA方式适合大数据量、高波特率的场景。DMA控制器自动把数据从外设搬到内存或者从内存搬到外设CPU只在传输完成时处理一次。优点是CPU占用极低缺点是需要配置DMA通道代码逻辑稍复杂而且DMA和中断的配合需要理解清楚。我个人的习惯是调试输出用中断发送就够了接收如果数据量大就上DMA。发送用DMA的收益不如接收明显因为发送是你主动发起的你知道什么时候发完接收是被动的对方什么时候发你控制不了用DMA可以避免丢数据。3. HAL库UART收发的三种方式与代码落地3.1 阻塞式收发最简单但最容易卡死HAL库提供了三个阻塞式函数HAL_UART_Transmit()、HAL_UART_Receive()、HAL_UART_TransmitReceive()。它们的共同特点是带超时参数函数会一直等到数据发完或收完才返回。uint8_t txBuf[] Hello UART\r\n; HAL_UART_Transmit(huart1, txBuf, sizeof(txBuf)-1, 1000);上面这行代码把字符串通过串口1发出去超时1000毫秒。如果1000毫秒内没发完函数返回HAL_TIMEOUT。发送场景下只要波特率不是低得离谱一般都能发完。接收就麻烦一些uint8_t rxBuf[10]; HAL_UART_Receive(huart1, rxBuf, 10, 1000);这行代码会阻塞等待直到收满10个字节或者超时。问题在于你往往不知道对方会发多少字节、什么时候发。如果对方只发了5个字节就不发了这个函数会一直等到超时期间CPU什么都干不了。所以在实际项目里阻塞式接收很少单独使用通常只用在确定数据长度和到达时间的场景比如和某些传感器的一问一答式通信。注意阻塞式发送在中断上下文里绝对不能用因为HAL_Delay和阻塞等待都依赖系统滴答在中断里调用会导致死锁。3.2 中断式收发回调函数才是核心中断方式的关键在于理解HAL库的回调机制。你调用HAL_UART_Receive_IT()启动接收然后每收到一个字节HAL库的中断服务程序会自动调用HAL_UART_RxCpltCallback()。你需要重写这个回调函数来处理数据。uint8_t rxByte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 处理收到的这个字节 rxByte // ... // 重新启动接收等待下一个字节 HAL_UART_Receive_IT(huart1, rxByte, 1); } }注意最后那行重新启动接收的代码。HAL库的中断接收是一次性的收完指定数量的字节后就不会再自动接收了必须在回调里重新调用HAL_UART_Receive_IT()否则只能收到第一批数据。这是新手最容易忽略的点之一表现为“第一包数据能收到后面就没了”。发送也可以用中断HAL_UART_Transmit_IT()。发送完成时会调用HAL_UART_TxCpltCallback()。但发送中断的实用性不如接收中断因为发送是你主动发起的用阻塞发送等一小会儿通常可以接受。3.3 DMA式收发大数据量场景的正解DMA方式下你调用HAL_UART_Receive_DMA()DMA控制器会自动把串口收到的数据搬到指定缓冲区收满指定数量后触发HAL_UART_RxCpltCallback()。整个过程CPU几乎不参与。uint8_t rxDmaBuf[64]; HAL_UART_Receive_DMA(huart1, rxDmaBuf, 64); void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // rxDmaBuf里是收到的64字节数据 // 处理完后重新启动DMA接收 HAL_UART_Receive_DMA(huart1, rxDmaBuf, 64); } }DMA接收有个现实问题如果对方发来的数据长度不固定你怎么知道一帧数据什么时候结束收满64字节才触发回调如果对方只发了10字节回调永远不会来。解决办法有几种一是用空闲中断IDLE Interrupt串口总线空闲时触发中断在中断里读取DMA当前剩余计数算出实际收到了多少字节二是用定时器做超时判断一段时间没有新数据就认为一帧结束。空闲中断是更优雅的方案后面单独讲。发送用DMAHAL_UART_Transmit_DMA()。适合发送大量数据的场景比如把一段缓冲区的内容一次性吐出去。注意DMA发送期间不能修改发送缓冲区的内容否则发出去的数据会乱。如果需要连续发送多包要等上一包发完HAL_UART_TxCpltCallback触发再启动下一包。4. 空闲中断加DMA不定长数据的标准解法4.1 为什么需要空闲中断前面提到DMA接收需要指定一个固定长度收满才触发回调。但实际通信中对方发来的数据长度往往是变化的。比如GPS模块每次输出的语句长度不同或者上位机发来的命令有长有短。这时候固定长度的DMA接收就不够用了。空闲中断IDLE Interrupt解决的就是这个问题。当串口接收线上连续一个字节的时间没有新数据时硬件会置位IDLE标志并触发中断。这个“一个字节的时间”取决于波特率115200下大约是87微秒。也就是说如果两帧数据之间的间隔超过87微秒就会触发空闲中断。在空闲中断里你可以读取DMA的剩余传输计数寄存器NDTR用总长度减去剩余计数就得到了这一帧实际接收到的字节数。然后处理数据重置DMA等待下一帧。4.2 完整实现步骤第一步在CUBEMX里使能USART的全局中断但不要在DMA Settings里勾选接收DMA的“Circular”模式用默认的Normal模式即可。生成代码后在main函数里启动DMA接收#define RX_BUF_SIZE 128 uint8_t rxBuf[RX_BUF_SIZE]; HAL_UART_Receive_DMA(huart1, rxBuf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);第二步重写空闲中断的处理。HAL库没有直接提供空闲中断的回调需要自己在stm32fxxx_it.c的USART1_IRQHandler里判断void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 停止DMA计算接收长度 HAL_UART_DMAStop(huart1); uint16_t rxLen RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 处理rxBuf中的rxLen个字节 // ... // 重新启动DMA接收 HAL_UART_Receive_DMA(huart1, rxBuf, RX_BUF_SIZE); } }第三步处理数据。注意在空闲中断里不要做太耗时的操作把数据拷贝出来或者置个标志位在主循环里处理。提示__HAL_DMA_GET_COUNTER返回的是DMA还剩多少字节没搬所以实际接收长度等于总长度减去剩余计数。这个计算别搞反了。4.3 双缓冲方案进一步降低丢数据风险单缓冲方案在数据处理期间如果新数据到来DMA已经停止了就会丢数据。双缓冲Double Buffer可以缓解这个问题准备两个缓冲区DMA收满一个自动切换到另一个同时触发回调处理已满的那个。HAL库对DMA双缓冲的支持因系列而异有些系列有HAL_UARTEx_ReceiveToIdle_DMA()配合HAL_UARTEx_RxEventCallback()内部已经处理了空闲事件和双缓冲逻辑用起来更方便。如果你的HAL库版本支持优先用这套API能省不少事。5. 调试串口时最常踩的六个坑5.1 第一包能收后面收不到这是中断接收的经典问题。原因就是前面说的HAL库的中断接收是一次性的收完指定数量后不会自动重启。解决办法是在HAL_UART_RxCpltCallback里重新调用HAL_UART_Receive_IT()。如果你用的是DMA接收同理需要在回调里重启DMA。还有一种可能是中断优先级配置问题。如果UART中断优先级低于某个频繁触发的中断可能导致UART中断被延迟处理表现为数据偶尔丢失。检查NVIC里的优先级设置UART中断不要设得太低。5.2 串口助手收到乱码乱码的原因按概率排序波特率不匹配 时钟配置错误 数据位/停止位/校验位不匹配 硬件接线问题。先确认串口助手和代码里的波特率一致。然后检查CUBEMX时钟树里USART挂载的总线时钟是否正确特别是用了外部晶振但时钟树没配好的情况。再确认数据位、停止位、校验位两边一致。最后检查TX和RX有没有接反GND有没有共地。TX接对方的RXRX接对方的TXGND对GND这三根线缺一不可。5.3 发送正常但接收不到发送正常说明TX线路和波特率没问题。接收不到先检查RX引脚配置在CUBEMX里确认RX引脚被正确分配为UART功能而不是被其他外设占用。然后检查是否启动了接收中断方式要调用HAL_UART_Receive_IT()DMA方式要调用HAL_UART_Receive_DMA()光配置不启动是收不到的。还有一个隐蔽的问题如果之前用过阻塞式接收且超时了UART可能处于错误状态需要调用HAL_UART_AbortReceive()清理状态再重新启动。5.4 DMA发送时数据被覆盖DMA发送是异步的HAL_UART_Transmit_DMA()调用后立即返回数据在后台慢慢发。如果这时候你修改了发送缓冲区的内容DMA搬出去的可能是修改后的数据。解决办法是等发送完成回调触发后再修改缓冲区或者用双缓冲交替发送。5.5 中断里调用HAL_Delay导致死机HAL_Delay()依赖系统滴答中断来计数。如果在UART中断里调用它而UART中断优先级又高于滴答中断滴答中断无法触发HAL_Delay()就会永远等下去表现为死机。中断里绝对不要用任何阻塞式函数包括HAL_Delay()、阻塞式UART收发、阻塞式I2C/SPI传输等。5.6 波特率误差导致的偶发错误有时候通信大部分时间正常偶尔出现一两个错误字节。这种偶发问题很难查但波特率误差偏大是常见原因。回到CUBEMX看实际波特率误差如果超过2%尝试调整时钟树让误差降到1%以内。另外如果用的是内部RC振荡器作为时钟源精度本身就不如外部晶振高速波特率下更容易出问题建议用外部晶振。现象最可能原因排查方向完全无数据接线错误/未启动接收检查TX-RX交叉、GND共地、接收函数是否调用全是乱码波特率不匹配核对两边波特率、检查时钟树第一包正常后续无未重启接收回调里重新调用接收函数偶发错误字节波特率误差大查看CUBEMX误差值、换外部晶振发送时死机中断里用了阻塞函数检查中断上下文是否有HAL_Delay等DMA数据错乱缓冲区被提前修改等发送完成回调再改缓冲区6. 几个提升效率的实操习惯6.1 用printf重定向做调试输出把printf重定向到UART调试时直接打印变量值比用仿真器单步看变量方便得多。重定向的方法是重写fputc函数#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100); return ch; }然后在工程设置里勾选“Use MicroLIB”Keil环境下。这样就能用printf(value %d\r\n, val)往串口打印了。注意这种重定向用的是阻塞发送在中断里调用printf同样会死机调试信息只在主循环里打。6.2 给接收数据加环形缓冲区中断接收到的字节如果直接在主循环里处理处理速度跟不上接收速度时会丢数据。环形缓冲区Ring Buffer是标准解法中断里只负责把字节塞进缓冲区主循环从缓冲区里取数据处理。这样中断处理极快不会因为处理逻辑耗时导致丢数据。环形缓冲区的实现不复杂两个指针读指针和写指针加一个数组就行。写指针在中断里移动读指针在主循环里移动通过判断指针是否相等来判断空和满。网上有很多现成的轻量级实现直接拿来用即可。6.3 用宏定义管理串口句柄项目里如果有多个串口代码里到处写huart1、huart2容易乱。用宏定义统一管理#define DEBUG_UART huart1 #define MODULE_UART huart2 HAL_UART_Transmit(DEBUG_UART, ...);这样以后换串口只改宏定义就行不用满工程搜索替换。6.4 调试时先降波特率如果通信不稳定先把波特率降到9600试试。9600对时钟误差的容忍度高得多如果9600能通而115200不通基本可以确定是波特率误差或信号完整性问题。排除逻辑问题后再逐步提高波特率找到稳定工作的上限。串口这东西说简单也简单两根线就能通说复杂也复杂中断、DMA、空闲中断、双缓冲每一层都有细节。我自己的经验是先把阻塞式收发跑通确认硬件和配置没问题再逐步升级到中断和DMA。一上来就搞DMA加空闲中断出了问题很难定位是哪一层的问题。另外CUBEMX生成的代码只是骨架回调函数里的逻辑才是真正决定能不能用的关键别指望点几下鼠标就万事大吉。