多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

STM32串口USART收发全攻略:从HAL库初始化到DMA+空闲中断与环形缓冲区

STM32串口USART收发全攻略:从HAL库初始化到DMA+空闲中断与环形缓冲区 串口在STM32开发里的地位怎么强调都不过分。不管你是调传感器、连Wi-Fi模块、跟变频器通信还是单纯想把调试信息从板子里弄出来看一眼USART几乎是最快最顺手的通道。我见过不少人点灯已经玩得很溜了一到串口收发就卡壳要么TX发出去上位机收不到要么收进来全是乱码要么数据多了就丢帧。这篇就把我用USART发送和接收数据的完整思路、底层原理、踩过的坑一起捋一遍代码基于HAL库思路同样适用于标准库和LL库。1. USART不是UART时钟、数据帧与电平背后的底层差异很多教程把USART和UART混着叫真到了调板子的时候这俩的差别会影响你的初始化配置。UART是通用异步收发器只管收发没有时钟线USART是通用同步异步收发器多了同步模式可以外接时钟线做同步通信平时用异步模式就跟UART一样。STM32上绝大多数叫USART的外设同时支持异步和同步但因为异步模式不需要时钟线只靠两根数据线就能跑所以成了最常用的通信形态。1.1 时钟、引脚、波特率三者决定了通信能不能正常起来串口通信的底层逻辑其实不复杂就是两个设备约好在同一个波特率下按约定的数据帧格式一根线发数据一根线收数据。但这句话拆开来看有三个坑第一是时钟。STM32的USART外设不是上电就能用的它挂在APB总线上USART1和USART6挂在APB2上USART2/3/4/5挂在APB1上必须先使能对应总线的时钟否则写寄存器根本没用。HAL库的__HAL_RCC_USART1_CLK_ENABLE()就是干这个的。第二是引脚。USART1的TX/RX默认可以映射到PA9/PA10也可以重映射到PB6/PB7具体看芯片型号和CubeMX的配置。这里有个新手最容易忽略的点引脚模式必须配置成复用功能Alternate Function不是你把它设成GPIO输出就能当串口用的。而且TX和RX两根线必须交叉连接——你的TX接对方的RX你的RX接对方的TX同名的线接在一起是收不到数据的。第三是波特率。波特率是每秒传输的比特数收发双方必须一致。如果你用CubeMX默认配置115200而上位机串口助手设的是9600那收到的必然是一堆乱码。这个看起来简单但实际调试中因为选错波特率而怀疑硬件有问题的案例我见过不下十次。1.2 数据帧结构8N1是怎么来的为什么要有起始位和停止位串口异步通信没有时钟线接收方必须自己从数据线上猜出每一位的时机。这就是起始位的作用空闲时数据线保持高电平发送方先拉低一位起始位告诉接收方我要开始发数据了你准备好。接收方从起始位的下降沿开始按波特率定时采样后续的每一位。默认最常用的帧格式叫8N18个数据位无校验位1个停止位。一帧数据总共10个bit1个起始位 8个数据位 1个停止位。注意数据位是低位先发LSB first比如你要发送0x55二进制01010101线上先看到的是最低位1。这里就有个实际影响了有人拿示波器看串口波形发现发出的数据和自己预期的二进制顺序反了以为代码有问题。其实只要记住LSB first就通了。还有算波特率的时候115200波特率意味着每秒传115200个bit那每秒能传的数据字节数就是115200 / 10 11520字节不是115200字节。这个10就是8N1里一帧的总比特数。如果你用9位数据位那就是1 9 1 11个bit同样的波特率下每秒能传的字节数就变小了。1.3 HAL库和标准库对USART的抽象差异如果你以前用标准库写串口大概是这样的逻辑先开时钟配置GPIO复用再配置USART结构体最后调用USART_Cmd()使能外设。HAL库把这套流程封装成了HAL_UART_Init()但内部做的还是一样的操作。区别在于HAL库引入了一个更重要的概念句柄UART_HandleTypeDef。UART_HandleTypeDef huart1;这个句柄里存了串口的寄存器基地址、初始化参数、收发状态、错误标志、DMA配置等所有信息。之后所有操作都是围绕这个句柄展开的。好处是代码的可移植性好坏处是一旦初始化失败你要查的东西变多了。比如HAL_UART_Init()返回HAL_ERROR你要同时怀疑GPIO配置、时钟配置、波特率参数三个环节不像标准库那样一行行看寄存器就清楚了。我给个建议初学调试串口先把HAL库的硬件初始化和数据收发分开理解。硬件初始化只做一次收发数据是反复调用的。你把精力集中在收发函数上遇到问题再回头查初始化。2. 初始化环节最容易翻车的地方时钟树、引脚复用与波特率对齐很多人串口调不通根本原因不是收发函数用错了而是初始化没做对。初始化这块我按HAL库的习惯拆成四步每一步都标注了容易出错的地方。2.1 时钟源选择USART的时钟究竟是来自APB1还是APB2STM32F1系列USART1挂在APB2上最高时钟72MHzUSART2/3挂在APB1上最高时钟36MHzF1的APB1限36MHzF4以上是42MHz或45MHz看具体型号。如果你用CubeMX自动配置它会根据你给的外设时钟需求自动算出分频系数不需要你手动算波特率寄存器值。但如果你不看时钟树就乱配就会出现一种隐蔽的问题在CubeMX里改了一个外设的时钟频率导致USART挂载的总线时钟变了而波特率没有重新计算结果实际波特率偏移。我实际遇到过默认配置下USART1的波特率是115200通信一切正常。后来为了给定时器提供更精准的时钟改了PLL倍频系数APB2总线时钟从72MHz变成了64MHz串口的实际波特率也跟着漂了上位机能收到数据但全是乱码。排查了半小时才发现是时钟树变动引发的连锁反应。所以记住一个原则改任何外设时钟之前先看看USART挂在哪条总线上确认它的时钟源没被动过。2.2 引脚复用配置不是所有带TX/RX的引脚都能直接用CubeMX里给USART1选引脚时通常会自动关联到PA9TX和PA10RX并且自动把引脚模式设置成USART1_TX和USART1_RX。但如果你用的是标准库或者纯寄存器开发必须手动把引脚模式设置成复用推挽输出AF_PP和浮空输入IN_FLOATING。F4系列及以上和F1不一样F4使用GPIO复用功能需要配置GPIO_AF寄存器。比如PA9要复用为USART1_TX得设置GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1)。漏了这一步TX引脚不会输出任何数据。还有一个容易被坑的点有些板子把USART引脚和别的外设复用了。比如某些开发板上PB6/PB7默认接了I2C1的上拉电阻你要是把USART1重映射到这两个脚相当于TX/RX线上挂着上拉电阻虽然大部分情况下还能通信但在高速率下会出问题。复用功能别撞车这是画板和配板时都要注意的。2.3 串口助手和板子的参数必须完全对齐不只是波特率串口助手里有几个参数波特率、数据位、校验位、停止位。你板上初始化配的是115200-8-N-1助手里也必须是115200-8-N-1一个都不能差。有人只改了波特率没注意校验位默认是无结果板上配了偶校验收发就全乱了。更隐蔽的是流控。有些串口助手默认勾选了RTS/CTS流控而你的板子没有接流控引脚这时板子发数据给上位机可能正常但上位机发数据给板子就收不到因为RTS线没拉高上位机认为板子没准备好。遇到这种单方向通信正常反向不通的情况先检查串口助手的流控选项。2.4 一套完整的HAL库初始化流程示例我用STM32F103C8T6最小系统板为例CubeMX配置过程选择芯片型号STM32F103C8Tx。RCC中开启HSE外部晶振。时钟树配置HCLK设为72MHzF103的最高主频APB1预分频为/236MHzAPB2预分频为/172MHz。USART1挂在APB2上时钟就是72MHz。USART1开启异步模式Asynchronous波特率115200数据位8无校验停止位1无流控。引脚会自动分配PA9USART1_TXPA10USART1_RX模式自动设为复用功能。NVIC设置里使能USART1全局中断如果要用中断收发。CubeMX生成代码后MX_USART1_UART_Init()长这样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(); } }这段代码对应的底层动作是使能USART1时钟、配置GPIO复用、设置USART_CR1寄存器使能TX/RX/对应中断、写入波特率寄存器BRR。看到HAL_UART_Init()返回OK基本可以确定芯片内部寄存器层面的初始化已经完成。从初始化开始我的一个重要排查经验是先用板载LED做心跳指示。在while(1)里翻转LED确认主循环在跑再用串口主动发一帧(USART OK\r\n)用USB转TTL接上位机看是否收到。能收到说明硬件链路、初始化、发送路径全通之后再做接收就安心了。3. 发送数据的三种打开方式轮询、中断与DMA各有各的脾气发送看似简单实际选错方式会在特定场景下引起严重问题。我把三种方式放在一起说方便你根据项目需求选择。3.1 轮询发送简单可靠但会阻塞CPUHAL库的轮询发送接口是HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);这个函数的行为是把pData指向的Size个字节依次写入发送数据寄存器TDR每写一个字节等发送移位寄存器空出来直到所有字节发完或者超时。我实测F10372MHz115200波特率下发送一个字节大约耗时86.8微秒10bit / 115200。假设你要发100个字节那就是8.68毫秒。如果你在主循环里大量调用这个函数CPU在这8.68毫秒里什么都干不了。这就是轮询发送最大的缺点阻塞。但它也有不可替代的优点时序确定、简单直接、不会因为中断优先级问题导致发送被切断。调试初期、发送低频日志、一次性初始化信息我都是用轮询发送。代码写起来干净排查问题也方便。有一个细节Timeout参数的单位是毫秒表示如果发送在超时时间内未能完成则返回HAL_TIMEOUT。我调试的时候会把Timeout设成HAL_MAX_DELAY0xFFFFFFFF相当于无限等待先保证功能再优化时效性。正式产品里则要评估一下如果串口对端没接好发送会一直卡死在这里影响主流程这时候把Timeout设短一点并做好返回值判断就很有必要了。3.2 中断发送不阻塞CPU但大字符串要小心中断发送接口HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);调用后函数立刻返回数据在中断里逐字节发送。对CPU来说只有在每个字节发送完成TXE中断和整包发送完成TC中断这两个瞬间会被打断其他时间可以干别的活。但这里有个经典陷阱pData指向的缓冲区在数据没发完之前不能释放。有人喜欢这么写HAL_UART_Transmit_IT(huart1, (uint8_t *)Hello World\r\n, 13);把字符串投进去就不管了这在静态字符串场景下没问题因为字符串常量在生命期内不会被改动。但如果你传入的是一个局部数组函数返回后数组可能已经被其他代码覆盖那么中断里真正发出去的就是错误的数据了。void send_test(void) { uint8_t buf[16]; sprintf((char *)buf, value%d\r\n, adc_val); HAL_UART_Transmit_IT(huart1, buf, strlen((char *)buf)); // 危险 // 函数返回buf失效数据不确定 }解决方式有两种一是用静态或全局数组二是加一个发送完成标志只有上一次中断发送完全结束后才允许改写缓冲区。另外要提到的就是中断优先级。中断发送依赖于串口中断服务函数如果这个中断优先级设置得太低而系统里有更高频率的中断比如定时器中断持续占用CPU串口发送的效率会明显下降甚至出现超时。开发初期统一把USART中断优先级设高一点比如抢占优先级1比较省心。3.3 DMA发送大流量数据传输的归宿DMA发送接口HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);DMA的意义在于数据从内存搬到串口数据寄存器这个动作不再经过CPU干预由DMA控制器直接完成。CPU只需要在开始的时候发起一次DMA传输然后就可以去执行别的任务传输完成会触发DMA传输完成中断。DMA特别适合什么场景比如你要通过串口刷一屏LCD的帧缓冲数据几万字节用轮询发送CPU会卡到崩溃用中断发送虽然能干活但中断会频繁打断主程序DMA就非常合适。还有持续的高速传感器数据上报配合环形缓冲区DMA几乎是最优解。使用DMA发送的注意点是DMA通道要单独配置CubeMX里要把USART1_TX的DMA请求加上并设置DMA为Normal模式只发一次还是Circular模式循环发送。大部分场景用Normal就够了只有像持续发送同一段波形数据时才考虑Circular。3.4 多字节发送时如何避免拆包和串话实际项目里经常遇到这样一个问题主循环里不同模块都要发数据模块A发了一半模块B把他的数据插进来上位机就解析错了。这个叫串话。解决思路是互斥。比如我用一个简单的环形队列或状态锁volatile uint8_t uart_tx_busy 0; void uart_send_string(uint8_t *buf, uint16_t len) { while (uart_tx_busy) // 等待上一次发送结束 ; uart_tx_busy 1; HAL_UART_Transmit_IT(huart1, buf, len); } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart_tx_busy 0; } }这个方案的缺陷是while等待发送期间主循环仍然会被阻塞。如果你想真正做到并发安全需要把所有模块的待发送数据统一丢进一个发送缓冲区队列再让一个独立的发送任务或中断按顺序取数据发送这就是一个迷你版的串口发送引擎了。从实践角度来看多数中小项目不需要把发送队列做得太复杂只要约定每次发送必须完整、不能让其他模块插入自己的数据加上一个忙标志就够了。我在项目里最常用的是主任务和中断里都调用发送函数时中断里的数据优先发送主任务的数据排队靠一个简单的双缓冲就能解决。4. 接收才是串口实战的真难点从单字节中断到空闲中断再到环形缓冲区发送做对了只是串口入门接收才是真正有技术含量的部分。接收的难点在于对方什么时候发数据是不确定的数据有多长也是不确定的你必须用一种方式把不完整的数据拼成一帧完整的数据。我见过很多人在接收上栽跟头核心原因就是只用了一种简单的接收策略而没有根据协议去设计接收缓冲。4.1 查询接收的局限只适合极简场景轮询接收接口HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);它会一直等到接收完Size个字节才返回。这个函数在主循环里配合while调用适合我就等一个固定长度的指令这种场景比如等上位机发一个单字节命令来控制LED开或关。但它的缺点是致命的函数阻塞期间如果对方只发了2个字节而你等5个字节程序会一直卡住。实际项目中通信协议大多是不定长的轮询接收就没法直接用了。4.2 接收中断状态机最常用的不定长数据接收方案HAL库接收中断的启动函数HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);调用后只接收Size个字节接收完成触发HAL_UART_RxCpltCallback。这意味着如果你要连续接收不同长度的数据处理完一帧后必须重新调用一次这个函数。在中断回调里做状态机解析是嵌入式通信的主流做法。举个例子我要解析一个简单的协议帧头0xAA 0x55数据长度1字节数据N字节校验和1字节帧尾0x0D 0x0A。那么中断回调可以这么写uint8_t rx_byte; uint8_t rx_buffer[256]; uint16_t rx_index 0; uint8_t expect_len 0; uint8_t parse_state 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rx_buffer[rx_index] rx_byte; switch (parse_state) { case 0: if (rx_byte 0xAA) parse_state 1; else rx_index 0; break; case 1: if (rx_byte 0x55) parse_state 2; else { parse_state 0; rx_index 0; } break; case 2: expect_len rx_byte; parse_state 3; break; case 3: if (rx_index expect_len 2) // 数据 校验和 { // 校验和验证 parse_state 4; } break; case 4: if (rx_byte 0x0D) parse_state 5; else { parse_state 0; rx_index 0; } break; case 5: if (rx_byte 0x0A) { // 完整帧解析成功 protocol_frame_process(); } parse_state 0; rx_index 0; break; default: parse_state 0; rx_index 0; break; } HAL_UART_Receive_IT(huart1, rx_byte, 1); } }判断逻辑没写完整但思路是清楚的每收到一个字节就喂给状态机状态机根据当前状态决定下一个字节怎么处理。这种方案的优点是内存占用小、实时响应快适合通信比较规整的场景缺点是状态多了以后可读性差添加新协议要改状态机。4.3 空闲中断让一帧数据结束了变得明显STM32的USART外设里有一个特别实用的标志位IDLE空闲标志。当数据线上出现一个字节时间的连续高电平时IDLE标志被置1。用DMA空闲中断配合是目前STM32接收不定长数据的主流姿势DMA负责把数据连续搬进缓冲区空闲中断负责告诉你这一批数据收完了。CubeMX配置方式使能USART1的DMA接收请求RXNormal模式。在NVIC里使能USART1全局中断和DMA的传输完成中断Normal模式下用不到DMA完成但全局中断要开。启动接收的代码#define RX_BUF_SIZE 256 uint8_t rx_dma_buf[RX_BUF_SIZE]; volatile uint16_t rx_len 0; volatile uint8_t rx_frame_ready 0; HAL_UART_Receive_DMA(huart1, rx_dma_buf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); // 使能空闲中断在USART中断服务函数里判断空闲标志void USART1_IRQHandler(void) { 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(hdma_usart1_rx); // 实际收到的长度 rx_frame_ready 1; HAL_UART_Receive_DMA(huart1, rx_dma_buf, RX_BUF_SIZE); // 重新准备接收 } HAL_UART_IRQHandler(huart1); }这段代码的核心是DMA控制器在持续接收数据当DMA没有数据可收时缓冲区指针停住不动。空闲中断来了之后通过查询DMA剩余计数寄存器算出实际收了多少字节。这个方案的缺点是需要两个中断配合处理调起来有一定门槛。但它在大数据量、不定长、高频通信场景下非常好用。4.4 环形缓冲区把接收与处理解耦的工程解法实战项目一旦复杂起来中断里直接做业务解析往往不是好选择因为业务解析可能需要时间中断里待太久会影响其他中断。更工程化的做法是中断只把收到的一个字节塞进环形缓冲区Ring Buffer主循环里不断检查缓冲区有没有新数据有就取出并处理。环形缓冲区的好处是生产者和消费者解耦中断是生产者主循环是消费者。只要缓冲区没满生产者永远不会丢数据只要消费者处理速度大于生产者的平均速度系统就能稳定运行。我常用的环形缓冲区实现typedef struct { uint8_t buffer[256]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; void rb_init(ring_buffer_t *rb) { rb-head 0; rb-tail 0; } uint8_t rb_is_empty(ring_buffer_t *rb) { return rb-head rb-tail; } uint8_t rb_is_full(ring_buffer_t *rb) { return (uint16_t)(rb-head 1) % 256 rb-tail; } void rb_write(ring_buffer_t *rb, uint8_t data) { if (rb_is_full(rb)) return; rb-buffer[rb-head] data; rb-head (rb-head 1) % 256; } uint8_t rb_read(ring_buffer_t *rb, uint8_t *data) { if (rb_is_empty(rb)) return 0; *data rb-buffer[rb-tail]; rb-tail (rb-tail 1) % 256; return 1; }然后中断回调里只需要void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rb_write(rx_ring, rx_byte); HAL_UART_Receive_IT(huart1, rx_byte, 1); } }主循环里uint8_t ch; while (rb_read(rx_ring, ch)) { // 对ch做协议解析 }这个模式从各种意义上比直接在中断里做状态机要优雅尤其适合逻辑复杂的项目比如需要同时处理Wi-Fi模块指令、传感器数据、上位机控制命令的项目。4.5 丢帧问题RXNE、ORE和缓冲区溢出是三个不同的坑接收调试中最常见的问题就是收到的数据少了一截或者偶尔丢一两个字节。你要区分三个概念RXNEReceive data register not empty是接收数据寄存器非空标志表示收到一个字节但还没被读走。HAL库的HAL_UART_Receive_IT内部会在中断里读走数据寄存器同时自动清除RXNE。OREOverrun error是溢出错误。当上一个字节还没被读走新的数据又到达时硬件会产生ORE错误。默认情况下ORE会在ISR中触发HAL_UART_ErrorCallback。如果你使能了接收中断却没有在错误回调里重新调用接收函数那一帧数据就会断掉。void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { HAL_UART_Receive_IT(huart1, rx_byte, 1); // 重新启动接收 } }第三种丢帧原因是缓冲区溢出。比如我在中断里接收字节并写入环形缓冲区当缓冲区满了rb_write直接丢弃新数据。这种情况说明消费速度跟不上生产速度解决方式是扩大缓冲区或者降低业务逻辑的处理时间。5. 串口调试翻车现场乱码、不进中断、丢帧的排查链最后这部分我把这些年调串口遇见过的高频问题整理成一个排查清单。你在板子上遇到问题时按这个顺序排查基本都能解决。5.1 常见问题对照表先看症状再对症下药症状排查方向解决办法完全收不到数据引脚配置错误检查TX/RX是否交叉连接引脚复用是否开启完全收不到数据串口助手的COM口号选错设备管理器里确认USB转TTL对应的COM口收不到数据但TX/RX灯在闪波特率不一致板子和上位机都必须设为同一波特率收到乱码波特率漂移检查时钟树改动确认USART总线时钟收到乱码数据位/校验位不匹配双方统一为8-N-1单方向能通反向不通流控未关串口助手关闭RTS/CTS偶尔丢字节中断优先级被打断调高USART中断优先级大量丢帧接收缓冲区溢出扩大缓冲或改用DMA空闲中断方式发送大字符串卡死中断发送缓冲区被复用使用静态缓冲区或加发送完成标志5.2 逻辑分析仪才是串口排查的终极武器很多人习惯用串口助手看数据但串口助手只能看到最终结果看不到中间过程。举个例子板子发数据上位机收到乱码你是无法从串口助手判断到底是电平问题还是波特率问题。这时候如果手头有个便宜的USB逻辑分析仪直接夹在TX引脚和GND之间抓波形一看就知道了。逻辑分析仪上怎么判断波特率是否正确呢一帧完整的8N1数据低电平的起始位 8个数据位 高电平停止位一共10个位的时间宽度。测量一个字节的总体宽度除以10就得到了每位的时间。如果每位时间约等于8.68微秒说明波特率是115200如果明显偏长或偏短说明波特率没配对。这样一量问题定位就非常快了。5.3 用串口构建一个最低成本的调试日志系统串口调试的价值不只是连接上位机工具它还能变成你的调试日志系统。我在开发STM32项目时都会在代码里加一组宏#define LOG_DEBUG_ENABLE 1 #if LOG_DEBUG_ENABLE #define LOG_DEBUG(fmt, ...) printf([%s:%d] fmt \r\n, __FILE__, __LINE__, ##__VA_ARGS__) #else #define LOG_DEBUG(fmt, ...) #endif配合重定向fputc让printf输出到USART1int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100); return ch; }这样你就能像写PC程序一样打日志了。但这个方案有个隐患如果HAL_UART_Transmit阻塞了日志打印会影响主循环实时性。我通常会做一个开关正式版把LOG_DEBUG_ENABLE置为0所有日志代码自动变成空语句不用一处一处删。5.4 串口调试最具实效的几条经验我在项目里沉淀下来几条关于串口调试的经验应该能帮你少走弯路第一调串口前先固定一根示波器或逻辑分析仪的接地线。很多时候乱码不是代码问题而是地线没接好PC和板子之间参考地不一致会导致信号电平漂移。第二接收不定长数据时优先考虑DMA空闲中断方案。虽然它比单字节中断复杂但一旦调通之后的协议升级、大量数据吞吐都不用改架构。我在多个量产项目里用的都是这套方案稳定性和效率都很理想。第三不要在中断回调里做耗时的协议处理。串口打印或者大循环分析这类操作放到主循环去中断里只做数据的搬运和标志位的置位。第四如果你在用CubeMX跑多个外设每个外设的中断回调函数会冲突。比如USART的HAL_UART_RxCpltCallback和TIM的HAL_TIM_PeriodElapsedCallback都在同一个文件里要记得在回调里再判断一次句柄属于哪个外设防止串了。第五串口数据用Hex格式还是ASCII格式要看应用场景。调传感器时看Hex最直观调人机交互指令时ASCII更可读。串口助手一般支持这两种显示方式调试时按需切换不用改代码。最后补一句USART说是STM32开发的地基技能一点也不夸张很多人学了I2C、SPI、CAN最后回头发现串口才是那只最顺手、最朴素、最能救命的推手。这篇把发送、接收、初始化、排查的思路都过了一遍代码可以直接抄到工程里用。如果你在项目里真正把DMA空闲中断和环形缓冲区这套架构跑通一次再去写别的通信协议比如Modbus、自定义帧协议会发现套路都是通的——底层就那点东西变来变去只是协议层的事。
返回列表