STM32 HAL库UART实战:从CubeMX配置到DMA收发与环形缓冲区

发布时间:2026/10/13 3:47:34
STM32 HAL库UART实战:从CubeMX配置到DMA收发与环形缓冲区 1. 为什么我建议你从UART开始啃STM32的HAL库如果你刚开始接触STM32或者从标准库转过来面对CubeMX里那一堆外设配置和HAL库函数大概率会有点懵。我当初也一样点开CubeMX看到UART那一页密密麻麻的参数什么Baud Rate、Word Length、Parity、Stop Bits、Over Sampling第一反应就是“这都啥跟啥”。但后来我发现UART其实是整个HAL库学习路径里最理想的切入点——它足够简单又足够典型能把GPIO、时钟树、中断、DMA、回调函数这些核心概念全部串起来。这篇文章就是我在实际项目中反复用UART之后把踩过的坑、想明白的原理、以及那些教程里不太会讲的细节完整地梳理一遍。内容会覆盖从CubeMX配置到HAL库收发函数的每一个关键环节包括轮询、中断、DMA三种模式的取舍以及实际调试中遇到的各种奇葩问题。不管你是刚点亮第一颗LED的新手还是已经用过标准库想转HAL的老手应该都能从里面找到对自己有用的东西。先说清楚一件事UART本身不难难的是HAL库那套“回调句柄”的抽象逻辑以及CubeMX自动生成的代码里那些你不太敢动的部分。我见过太多人CubeMX点完生成代码然后对着MX_USART1_UART_Init()发愣不知道下一步该往哪写。所以这篇文章的重点不是复述手册而是告诉你每个配置项背后的实际含义以及代码该写在哪个位置。2. UART在嵌入式开发里到底扮演什么角色2.1 串口不是“过时技术”而是调试和通信的基石很多人觉得UART老土速度慢不如SPI、I2C、USB这些“高级”接口。但实际做项目你会发现串口是唯一一个你几乎不需要额外硬件就能验证的通信方式。你只需要一根USB转TTL线接上TX、RX、GND就能在电脑上看到单片机输出的信息。这种“所见即所得”的调试体验是其他接口给不了的。我在做电机控制的时候SPI接编码器、I2C接EEPROM、PWM驱动MOS管但调试信息全部走UART。因为当你发现电机转不起来的时候第一反应是“程序卡在哪了”而不是“SPI时序对不对”。UART就是你跟芯片对话的窗口这个窗口不能关。从技术角度看UART是异步全双工通信没有时钟线靠双方约定的波特率来同步。这意味着它只需要两根信号线TX和RX硬件成本极低。但代价是双方必须提前约定好波特率、数据位、校验位、停止位这些参数任何一项对不上收到的就是乱码。这也是为什么CubeMX里那些参数看起来多其实每一个都有明确用途。2.2 HAL库对UART的抽象层次标准库时代你操作UART是直接写寄存器USART1-DR data;简单粗暴。HAL库则搞了一套“句柄”机制UART_HandleTypeDef这个结构体里包含了寄存器基地址、初始化参数、收发缓冲区指针、状态变量等等。你调用HAL_UART_Transmit()它内部会去检查句柄状态、配置寄存器、等待标志位最后才把数据写进去。这种抽象的好处是可移植性——同一套代码从F1换到F4基本只需要改CubeMX配置业务代码不用动。坏处是效率损失和理解成本。比如HAL_UART_Transmit()是阻塞式的发一个字节要等TXE标志发完一帧要等TC标志期间CPU啥也干不了。如果你要发100字节按115200波特率算大概要8.7毫秒这8.7毫秒CPU就在那空转。所以HAL库同时提供了中断和DMA模式让你在等待的时候去干别的事。但中断和DMA又引入了回调函数、状态机、缓冲区管理这些新问题。我的建议是先把轮询模式跑通理解基本流程再逐步过渡到中断和DMA。不要一上来就搞DMA出了问题你连从哪查都不知道。3. CubeMX里UART配置的每一个参数都值得较真3.1 波特率不是随便填个数就行CubeMX里波特率那一栏你填115200它自动算出BRR寄存器的值。但你知道这个值是怎么来的吗以STM32F103为例APB2时钟72MHz过采样16倍那么USARTDIV 72M / (16 * 115200) 39.0625。整数部分39小数部分0.0625对应BRR寄存器的低4位是0.0625 * 16 1。所以BRR 0x271。如果你填的波特率除不尽比如92160072M / (16 * 921600) 4.8828小数部分0.8828 * 16 14.125取整14实际波特率就是72M / (16 * (4 14/16)) 923076误差0.16%。这个误差在容忍范围内但如果你填了个更奇葩的值误差超过2%通信就会不稳定。注意CubeMX会自动计算并显示实际波特率和误差配置时留意一下那个“Baud Rate Error”数值超过2%就换个值。我一般常用115200因为大部分USB转TTL芯片都支持而且速度够用。如果数据量大可以上921600甚至2M但前提是你的USB转TTL芯片支持这么高的波特率CH340系列最高也就2MCP2102能到1M再高就不行了。3.2 数据位、校验位、停止位默认配置的坑默认是8位数据、无校验、1位停止位也就是常说的“8N1”。这个配置99%的场景都够用。但如果你接的是某些老设备比如工业仪表、GPS模块可能会遇到7位数据、偶校验、2位停止位这种组合。这里有个容易忽略的点校验位会占用数据位的位置。如果你选8位数据奇偶校验实际有效数据只有7位。因为UART帧格式是“起始位数据位校验位停止位”校验位是插在数据位和停止位之间的。所以如果你要传8位数据又想要校验得选9位数据位校验这样有效数据才是8位。停止位一般选1位但如果你通信距离长、干扰大可以选2位给接收方更多时间处理。不过实际项目中我很少见人用2位停止位除非是特定协议要求。3.3 过采样16倍还是8倍过采样这个参数很多人直接忽略默认16倍。它的作用是接收时对每个位采样16次取中间几次的值来判断是0还是1提高抗噪能力。8倍过采样则是采样8次速度快一倍但抗噪能力下降。什么时候用8倍当你需要高波特率但时钟又不够的时候。比如APB时钟只有36MHz你想跑2M波特率16倍过采样算出来36M / (16 * 2M) 1.125误差太大。改成8倍36M / (8 * 2M) 2.25误差就小多了。但代价是接收容错率降低线长了或者干扰大了容易出错。我的建议是能用16倍就用16倍除非波特率实在上不去。8倍过采样属于“没办法的办法”。3.4 中断优先级别让串口中断堵了系统如果你用中断模式接收NVIC里要配置USARTx_IRQn的优先级。这里有个坑HAL库的中断处理函数里会调用回调函数如果你在回调里做了耗时操作会阻塞其他中断。比如你设了优先级0最高然后在HAL_UART_RxCpltCallback()里做浮点运算或者延时那其他低优先级中断全得等着。我一般把串口中断优先级设成中等比如抢占优先级2子优先级0。这样既不会丢数据也不会影响系统滴答定时器。还有一点HAL库的UART中断处理函数会检查错误标志如果发生ORE过载错误、NE噪声错误、FE帧错误它会调用HAL_UART_ErrorCallback()。如果你没实现这个回调默认是个弱函数啥也不做但错误标志会被清除。如果你实现了记得在里面处理错误否则可能一直进中断。4. 轮询、中断、DMA三种收发模式怎么选4.1 轮询模式简单但低效适合调试轮询模式就是HAL_UART_Transmit()和HAL_UART_Receive()阻塞式发完才返回。代码写起来最简单uint8_t tx_buf[] Hello\r\n; HAL_UART_Transmit(huart1, tx_buf, sizeof(tx_buf)-1, 1000);最后一个参数是超时时间单位毫秒。如果1000毫秒还没发完函数返回HAL_TIMEOUT。实际用的时候如果波特率115200发6个字节大概0.5毫秒超时设1000绰绰有余。接收也一样uint8_t rx_buf[10]; HAL_UART_Receive(huart1, rx_buf, 10, 1000);但这里有个问题你不知道对方什么时候发数据。如果对方没发HAL_UART_Receive()会一直等到超时这1秒CPU就浪费了。所以轮询接收只适合“我知道对方马上会发”的场景比如你发了个查询命令然后等回应。我一般只在初始化阶段或者调试时用轮询正式代码里接收全部用中断或DMA。4.2 中断模式实时性够用但别在回调里磨蹭中断接收的流程是这样的你调用HAL_UART_Receive_IT(huart1, rx_buf, 1)启动接收。每收到一个字节触发中断HAL库把数据存到rx_buf然后调用HAL_UART_RxCpltCallback()。你在这个回调里处理数据然后再次调用HAL_UART_Receive_IT()启动下一次接收。注意HAL库的中断接收是一次性的收完指定数量就停了。所以如果你要连续接收必须在回调里重新启动。这是新手最容易忘的一步忘了就只收到第一帧后面全丢。void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 处理rx_buf[0] HAL_UART_Receive_IT(huart1, rx_buf, 1); // 重新启动 } }中断模式适合数据量不大、实时性要求中等的场景。比如接收GPS模块的NMEA语句每秒一次每次几百字节用中断完全够。但如果你要接收大量数据比如图像传输中断频率太高CPU光进中断就忙不过来。4.3 DMA模式大数据量的正确打开方式DMA模式下UART收到数据后直接由DMA搬运到内存不经过CPU。你只需要配置好DMA通道调用HAL_UART_Receive_DMA()然后等DMA传输完成中断。DMA接收有个经典问题你不知道对方发了多少数据。比如对方发了10个字节但你的DMA缓冲区设了100字节DMA会一直等直到收满100字节才触发完成中断。这显然不行。解决办法有两种一是用空闲中断IDLE当总线空闲时触发中断你在中断里读取DMA当前剩余计数算出实际接收了多少字节。二是用定长协议双方约定好每帧长度收满就处理。空闲中断的配置稍微麻烦点CubeMX里默认不开启需要手动在代码里加__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);然后在中断处理函数里判断IDLE标志void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 计算接收长度 uint16_t len RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 处理数据 // 重新启动DMA接收 HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUFFER_SIZE); } HAL_UART_IRQHandler(huart1); }DMA发送就简单多了调用HAL_UART_Transmit_DMA()数据自动发出去发完触发HAL_UART_TxCpltCallback()。但注意DMA发送期间不能修改发送缓冲区否则发出去的数据就乱了。我一般用双缓冲或者等发送完成再改。5. 从零搭建一个UART收发工程5.1 CubeMX配置步骤打开CubeMX选好芯片型号比如STM32F103C8T6。第一步配时钟RCC里HSE选Crystal/Ceramic Resonator然后Clock Configuration里把HCLK拉到72MHz。这一步很重要因为UART波特率依赖APB时钟时钟不对波特率就不对。第二步配UARTConnectivity里选USART1Mode选Asynchronous。参数保持默认115200、8位、无校验、1位停止位、16倍过采样。NVIC里勾上USART1全局中断优先级设2。第三步配DMA如果要用DMA Settings里Add选USART1_RX模式Normal优先级Medium。USART1_TX也加上同样Normal。第四步配GPIOPA9是TXPA10是RX默认就是复用推挽输出和浮空输入不用改。第五步生成代码Project Manager里选好IDE勾上“Generate peripheral initialization as a pair of .c/.h files”这样每个外设的初始化代码会单独放在usart.c里方便管理。5.2 代码结构哪些是自动生成的哪些要自己写CubeMX生成的代码里main.c的SystemClock_Config()和MX_USART1_UART_Init()是自动的不要动。你要写代码的地方是main()里的while(1)循环以及自己实现的回调函数。我一般会在main.c里加一个全局变量做接收标志然后在回调里置位volatile uint8_t rx_flag 0; uint8_t rx_data; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rx_flag 1; HAL_UART_Receive_IT(huart1, rx_data, 1); } } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); HAL_UART_Receive_IT(huart1, rx_data, 1); while (1) { if (rx_flag) { rx_flag 0; HAL_UART_Transmit(huart1, rx_data, 1, 100); } } }这个例子是回显收到什么就发什么。虽然简单但包含了中断接收、回调处理、轮询发送的完整流程。5.3 重定向printf到串口调试的时候用printf比手动调HAL_UART_Transmit方便多了。重定向的方法是重写fputc函数#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100); return ch; }然后在Keil里勾上“Use MicroLIB”否则printf会卡住。IAR和STM32CubeIDE里不需要MicroLIB但可能需要加-u _printf_float之类的链接选项。注意重定向之后printf是阻塞的如果你在中断里调用printf可能会因为等待发送完成而阻塞其他中断。所以中断里尽量别用printf或者用DMA发送。6. 那些年我踩过的UART坑6.1 第一个字节丢失原因和解决现象上电后第一次接收第一个字节总是丢。后面就正常了。原因UART初始化完成后接收引脚上如果有起始位比如对方已经发数据了HAL库还没启动接收这个字节就丢了。或者你启动接收太晚对方已经发完了。解决办法在初始化完成后立刻启动接收不要等到while(1)里再启动。我一般把HAL_UART_Receive_IT()放在MX_USART1_UART_Init()之后紧跟着就调用。还有一种情况如果你用DMA接收DMA通道使能之前UART可能已经收到数据了。所以要先初始化DMA再初始化UART最后启动DMA接收。6.2 乱码波特率、时钟、硬件三连查收到乱码按这个顺序查波特率对不对双方必须一致。如果你用外部晶振检查HSE_VALUE宏定义是不是和实际晶振频率一致。比如你焊的是8M晶振但代码里写的是25M那系统时钟就是错的波特率自然不对。时钟配置对不对CubeMX里Clock Configuration看一眼APB1和APB2的频率。USART1挂APB2USART2/3挂APB1。如果APB频率和你算的不一样波特率就偏了。硬件连接对不对TX接RXRX接TXGND接GND。很多人TX接TX然后纳闷为什么收不到。还有电平问题STM32是3.3V如果你接5V的设备可能需要电平转换。地线有没有接只接TX和RX不接地有时候能通信但很不稳定。地线是参考电平必须接。6.3 中断接收卡死ORE标志的坑现象程序跑着跑着就卡在中断里出不来了或者HAL_UART_Receive_IT()返回HAL_BUSY。原因过载错误ORE。当接收移位寄存器里的数据还没被读走新数据又来了就会触发ORE。HAL库的中断处理函数里会检查ORE如果发现ORE且没有使能错误中断它会调用HAL_UART_ErrorCallback()但不会自动清除ORE标志。结果就是一直进中断。解决办法在HAL_UART_ErrorCallback()里清除ORE标志并重新启动接收void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_IT(huart1, rx_data, 1); } }或者更简单在中断处理函数里直接读DR寄存器清除ORE。但HAL库封装了中断处理你不太好插手。所以实现错误回调是最干净的办法。6.4 DMA发送只发一次状态没复位现象第一次HAL_UART_Transmit_DMA()正常第二次调用返回HAL_BUSY发不出去。原因DMA发送完成后UART的gState状态没有复位到READY。HAL库在DMA发送完成中断里会调用UART_DMATransmitCplt()里面会复位状态。但如果你在发送完成回调里又启动了新的发送可能会因为状态还没复位而失败。解决办法不要在HAL_UART_TxCpltCallback()里直接调用HAL_UART_Transmit_DMA()而是置个标志在主循环里发。或者等一小会儿再发。我一般用标志位主循环的方式最稳妥。6.5 常见问题速查表现象可能原因排查方法收不到任何数据TX/RX接反、地线没接、波特率不对交换TX/RX、检查GND、示波器看波形收到乱码波特率不匹配、时钟配置错误核对双方波特率、检查HSE_VALUE第一个字节丢失启动接收太晚初始化后立即启动接收中断卡死ORE错误未清除实现ErrorCallback清除OREDMA只能发一次状态未复位用标志位在主循环发送数据偶尔出错过采样16倍但干扰大降低波特率、加屏蔽线、改8倍过采样7. 进阶用环形缓冲区管理串口数据7.1 为什么需要环形缓冲区中断接收模式下每收一个字节进一次中断如果你在回调里直接处理数据处理时间长了会丢后续字节。比如你收到一个字节就printfprintf是阻塞的这期间来的数据全丢。环形缓冲区的作用是中断里只负责存数据主循环里慢慢处理。中断服务程序极短不会丢数据。7.2 环形缓冲区的实现#define RING_BUF_SIZE 256 typedef struct { uint8_t buffer[RING_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } RingBuffer; RingBuffer rx_ring; void RingBuffer_Init(RingBuffer *rb) { rb-head 0; rb-tail 0; } uint8_t RingBuffer_Put(RingBuffer *rb, uint8_t data) { uint16_t next (rb-head 1) % RING_BUF_SIZE; if (next rb-tail) { return 0; // 满了 } rb-buffer[rb-head] data; rb-head next; return 1; } uint8_t RingBuffer_Get(RingBuffer *rb, uint8_t *data) { if (rb-head rb-tail) { return 0; // 空了 } *data rb-buffer[rb-tail]; rb-tail (rb-tail 1) % RING_BUF_SIZE; return 1; }中断回调里只调RingBuffer_Put()void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { RingBuffer_Put(rx_ring, rx_data); HAL_UART_Receive_IT(huart1, rx_data, 1); } }主循环里处理uint8_t data; while (RingBuffer_Get(rx_ring, data)) { // 处理data }这样中断里只做一次赋值和一次取模耗时极短基本不会丢数据。7.3 环形缓冲区的注意事项head和tail要用volatile修饰因为它们在中断和主循环里都被访问。缓冲区大小要合适太小容易满太大浪费RAM。一般256或512够用。多字节访问要关中断如果你在主循环里读head和tail可能读到一半被中断打断。不过对于8位单片机16位变量的读写是原子的问题不大。但为了保险可以在读之前关中断读完开中断。8. 调试工具和技巧8.1 串口助手的选择我常用的是SSCOM和XCOM功能都差不多。SSCOM支持时间戳和自动换行XCOM界面简洁。如果你在Linux下可以用minicom或picocom。Python的话pyserial写个脚本也很方便。选串口助手注意几点支持自定义波特率有些奇葩波特率需要手动输入、支持HEX显示调试二进制协议时用、支持定时发送测试接收稳定性。8.2 逻辑分析仪看波形如果通信不正常用逻辑分析仪抓TX和RX的波形一眼就能看出问题。比如波特率不对波形宽度就不对数据位不对帧结构就不对。我用的是一款几十块钱的8通道逻辑分析仪配合开源软件抓UART绰绰有余。看波形的时候重点看起始位是不是低电平、数据位是不是8个、停止位是不是高电平、位宽是不是和波特率匹配。比如115200波特率位宽应该是8.68微秒。如果测出来是10微秒那实际波特率就是100k左右肯定不对。8.3 用DMA空闲中断做不定长接收前面说了DMA接收不知道长度的问题空闲中断是标准解法。配置步骤CubeMX里使能USART1全局中断。代码里初始化DMA接收HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE);使能空闲中断__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);在USART1_IRQHandler里判断IDLE标志计算长度处理数据重启DMA。void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 处理rx_buf里的len个字节 HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); } HAL_UART_IRQHandler(huart1); }这个方案适合Modbus、GPS、JSON等不定长协议非常实用。9. 我个人在实际项目中的几点体会UART这东西说简单也简单说坑也多。我做了这么多年最大的体会是不要迷信HAL库的默认配置每一个参数都要搞清楚为什么。比如那个过采样默认16倍但如果你时钟配置错了16倍算出来的波特率误差可能很大这时候改成8倍反而能救回来。还有一点中断和DMA不是银弹。中断用不好会丢数据DMA用不好会卡死。我见过一个项目用DMA接收但没开空闲中断结果数据一直收不全查了两天才发现是DMA没触发完成中断。所以不管用什么模式先把数据流跑通再优化效率。最后分享一个小技巧如果你用Keil调试可以在Watch窗口里直接看huart1.Instance-DR、huart1.Instance-SR这些寄存器的值比单步调试快多了。特别是SR寄存器TXE、TC、RXNE、ORE这些标志位一目了然能帮你快速定位问题。UART是STM32外设里最基础也最常用的一个把UART吃透了SPI、I2C、CAN这些外设的学习曲线会平缓很多。因为HAL库的那套逻辑是通用的回调机制、状态机、DMA配置换到哪个外设都是类似的套路。所以别急着跳过UART去搞高级外设基础打牢了后面才走得稳。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询