STM32CubeMX配置HAL库串口通信:从基础到DMA空闲中断

简介:基于STM32F103C8T6与HAL库、STM32CubeMX的UART串口通信完整例程包,主要面向嵌入式初学者、单片机爱好者以及需要快速搭建串口收发功能的开发者。压缩包共146个文件,大小4.31MB,源码以c/h文件为主,同时包含ioc工程配置、uvprojx工程、hex烧录文件、axf/map/lst调试信息以及htm说明文档,覆盖从项目生成到编译下载的完整链路,目录结构清晰便于对照学习。已有6056人学习下载,热度较好。例程围绕CubeMX的时钟与串口引脚配置、HAL库UART初始化、阻塞式发送与中断接收回调处理展开,并额外提供NV_USART模块,方便将重要数据结合非易失性存储保存;描述中还提到传输错误检查、接收超时、DMA搬运等调试与优化思路,对实际项目开发有直接参考价值。下载后可在现有工程模板上直接修改波特率、引脚和中断方式,快速验证串口通信,适合入门STM32串口通信、设备间数据交互及底层驱动调试。

1. 为什么用CubeMX配HAL库做串口:从最省事的点接到中断收发

做嵌入式开发的人大多有过这种体验:手里一块STM32F103C8T6最小系统板,想通过串口跟电脑通信,结果不是乱码就是数据收不全。这个资源把CubeMX的图形配置和HAL库的UART收发代码打包在一起,照着做就能拿到一套能直接复用的串口Demo。它解决的不只是“点灯式”的发送,还讲了中断接收、回调函数、以及数据丢失的排查方法。适合嵌入式入门者、HAL库新手,也适合需要快速移植串口模块的工程师。整个工程从引脚配置到收发代码,逻辑是完整的,每一处参数都可以按自己的需求改。

2. CubeMX配置UART1:引脚、时钟和参数面板上的四个关键项

2.1 引脚分配与时钟树:别让APB2总线时钟拖慢波特率

打开CubeMX,选好芯片型号后第一件事是配置时钟。很多教程只教你点几下鼠标,却不解释为什么。以STM32F103C8T6为例,USART1挂在APB2总线上,USART2和USART3挂在APB1上。APB2的默认最高频率是72MHz,而APB1是36MHz。波特率计算公式是:波特率 = 时钟频率 / (16 * USARTDIV)。USART1的时钟源来自APB2,如果你在CubeMX里把APB2的分频系数设成了2,那么USART1的输入时钟就会变成36MHz,在相同USARTDIV下波特率就会减半。用115200时,接收端按72MHz的时钟去采样,就会因为位周期变长而产生误码。

所以在Clock Configuration页面,先把HCLK调到72MHz,然后检查APB1 Prescaler为2,APB2 Prescaler为1。这样系统时钟72MHz,APB1 36MHz,APB2 72MHz。对USART1来说,时钟就是72MHz,误差为零。

引脚分配方面,USART1默认的发送引脚是PA9,接收引脚是PA10。在Pinout页面中用鼠标点击PA9,选择USART1_TX,点击PA10选择USART1_RX。如果你的板子把这两个引脚复用成了其他功能,也可以用CubeMX的Alternate Function Mapping重新映射,比如映射到PB6/PB7,但F103C8T6的引脚不多,绝大多数情况默认PA9/PA10就够了。注意,如果同时使能了I2C或SPI的引脚,可能发生冲突,CubeMX会直接标记为红色,这时你需要调整。

时钟配置生成的代码会是这样的:

void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct = {0}; RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9; if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) { Error_Handler(); } RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1; RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2; RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1; if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2) != HAL_OK) { Error_Handler(); } }

这段代码的含义是:使用外部晶振HSE,PLL 9倍频得到72MHz系统时钟,AHB不分频,APB1二分频得到36MHz,APB2不分频保持72MHz。FLASH_LATENCY_2表示Flash等待周期为2,因为72MHz下必须设置为2,否则程序运行会出错。PLL_MUL9适合外部晶振为8MHz的情况。如果你的板子上晶振是外部无源,就用这个配置。如果用的是内部RC振荡器,HSE部分换成HSI,但内部RC精度不高,串口高速通信容易丢数据,所以板子上有8MHz晶振时优先用HSE。如果你买的板子没有焊晶振,那就只能用HSI,然后注意波特率偏差可能在2%以上,115200时会有可感知的误码。

2.2 参数面板:波特率、字长、停止位与校验位的取舍

在CubeMX中点击USART1,右侧参数面板出现。这里最重要的四个参数是Baud Rate、Word Length、Parity和Stop Bits。我自己的默认组合是115200、8位数据、无校验、1位停止位,也就是常说的8N1。这个组合兼容性最好,几乎所有的USB转串口模块和串口助手都认它。

波特率的选择要结合对端设备。GPS模块通常9600,蓝牙模块常见38400或115200,WiFi模块多用115200,少数工业仪表是4800。选波特率没有绝对标准,但有一个原则:能低则低,只有在速率不够时才提高。因为波特率越高,位周期越短,对线路寄生电容和时钟误差越敏感。调试时用115200很舒服,但如果线材较长或现场有干扰,降到9600能解决很多莫名丢包。

Word Length决定一帧数据的有效位数,可以是8或9。如果开启Parity,Word Length必须包含校验位。例如8位数据加偶校验,Word Length要选9。如果选8,那么数据位会少一位,通信直接对不上。我们平时调试关掉校验,选8位,因为很多廉价USB转串口线对校验位的处理并不严格,容易出奇怪问题。

Stop Bits选1位就够,2位停止位一般用于老式设备或低速同步场景。如果你发现对方设备参数里明确要求2停止位,才需要改。这里有个细节,CubeMX里修改停止位后,生成的代码会从STOPBITS_1变成STOPBITS_2,但波特率不变。

另外,这个面板里有Overrun Detection(溢出检测)选项。默认是使能的,作用是在接收数据时如果CPU没有及时读走数据,硬件会置溢出错误标志。HAL库在溢出错误后,需要你清除错误并重新启动接收,否则它一直不工作。很多人遇到“收了几帧就再也不收”的情况,往往就是这个原因。这个选项可以保持使能,但必须在错误回调中恢复接收。我们会在避坑章节具体说。

参数确认后,在Project Manager的Code Generator页面,我习惯勾选“Generate peripheral initialization as a pair of .c/.h files per peripheral”,这样每个外设单独生成uart.c和uart.h,而不是全部堆在main.c。这样代码结构清晰,找问题也快。生成的UART初始化函数如下:

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(); } }

这里的OverSampling是16倍过采样。USART1最高可以到72MHz/16=4.5M波特率,但实际不建议超过921600。HwFlowCtl是硬件流控,要用RTS/CTS引脚,一般调试不需要,保持NONE。这段代码里的每个字段都可以在前面参数面板中找到,读代码时对照界面,就不会晕。

另外,Mode选择里有一个Async模式和Sync模式。我们常用的就是Asynchronous异步模式。如果选成Synchronous,会多出SCLK时钟引脚,通讯方式变成同步,需要外部时钟线,大多数人用不到。还有半双工模式Single Wire,只占用一根数据线,用于RS485这类总线。这里用异步就好。然后在Configuration旁边的NVIC Settings中,必须勾选“USART1 global interrupt”使能,否则HAL_UART_Receive_IT不会触发中断回调。这个选项很多人会漏掉,配置完参数后一定要点开NVIC Settings看一眼。

2.3 硬件连接与电平匹配:TX/RX交叉是常识也是坑

软件配置完之后,剩下就是硬件接线。最小系统板的USART1_TX引脚是PA9,RX是PA10。接到USB转串口模块时,需要把PA9接到模块的RX,PA10接到模块的TX,交叉连接。这是我见过最多的翻车点,多数人会下意识把同名的TX和TX接在一起,结果数据永远出不来。其次要共地,模块的GND和板子的GND必须连在一起,否则电平没有参考点,通信随时可能错乱。

电平匹配是另一个坑。STM32F103的I/O是3.3V逻辑,很多USB转串口模块是5V逻辑。如果模块的输出是5V,直接接到PA10上,长期使用可能损伤引脚。正规的模块会做电平转换,比如带输入输出电平指示的,或者使用3.3V供电的CP2102N/CH340E等。如果你手里只有一个5V的模块,最安全的方案是在RX线上串一个1K电阻,把电流限制住,再配合内部钳位二极管,勉强能用,但不推荐长期用。

串口线本身也会出问题。常见的是老款USB转串口芯片在系统上驱动不稳定,设备管理器里显示黄色感叹号。这时换一根常见方案的线最省事。还有的是USB延长线太长导致供电不足,模块指示灯亮但通信失败。用串口助手时,如果发送了数据但开发板没反应,先用回环法测试串口线:把模块的TX和RX短接,自发自收,如果能在串口助手看到自己发的数据,说明模块和驱动没问题,问题在目标板接线或代码。这个方法能快速定位问题。

这一章内容比较多,主要是把配置阶段的坑点铺开,为后面的代码和排错打底。记住一个现象:配置好了之后,生成的代码本身不会错,错的往往是时钟源、引脚映射和外部接线这三件事。

3. HAL_UART_Receive_IT与发送函数:中断收发的代码骨架

3.1 用回调函数HAL_UART_RxCpltCallback处理不定长数据

HAL库的中断接收机制不是“收到数据就自动存到缓冲区”,而是要先调用一次HAL_UART_Receive_IT函数,告诉HAL库你希望接收多少个字节。比如你调用HAL_UART_Receive_IT(&huart1, buffer, 1),那么每收到一个字节就会触发一次回调。这是最基础的做法,但效率不高,因为每收一个字节都要重新调用一次。在数据量小、波特率低的场合完全够用。

在实际项目中,我一般先约定一帧数据的长度,比如8个字节,再用HAL_UART_Receive_IT(&huart1, rx_buf, 8)去接收。当8个字节全部到达后,HAL_UART_RxCpltCallback被调用,在这里处理数据,然后再重新调用一次HAL_UART_Receive_IT继续接收。这种固定长度的方式比较适合通信协议固定、帧长不变的场景,比如传感器模块每帧固定输出8字节。

回调函数的标准写法:

uint8_t rx_buf[8] = {0}; uint8_t rx_flag = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rx_flag = 1; // 标记接收到完整一帧 } }

这个回调函数的触发条件是:之前调用的HAL_UART_Receive_IT收到了指定数量的字节。函数里通过huart->Instance判断是哪个串口,因为多个串口共用同一个回调入口。rx_flag是主循环里检查的标记,处理完数据后要手动清除。注意,回调函数是在中断上下文中执行的,不要在回调函数里做耗时操作,比如printf打印、延时、内存分配等。正确做法是置个标志位,把数据处理放到主循环里。

如果你要处理不定长数据,固定长度的方式就不够用了。这时要么用DMA加空闲中断,要么在接收完一个字节后把接收到的字节拼接起来。更简单的做法是:每次调用HAL_UART_Receive_IT(&huart1, &temp, 1)接收一个字节,然后在回调函数里把temp保存到自己的数组,并判断是否满足帧结束条件。代码框架:

uint8_t temp = 0; uint8_t cmd_buf[64] = {0}; uint8_t idx = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { cmd_buf[idx++] = temp; if (temp == '\n' || idx >= sizeof(cmd_buf) - 1) { rx_flag = 1; idx = 0; } HAL_UART_Receive_IT(&huart1, &temp, 1); } }

这段代码先定义一个临时变量temp,每次接收一个字节,在回调里存储到cmd_buf。如果收到换行符或者缓冲区快满,就置位rx_flag。注意,在回调里重新调用HAL_UART_Receive_IT是为了让串口持续接收。如果不重新调用,之后串口就不再接收数据了,这是HAL库最容易踩的坑之一。这种逐字节接收的方式适合数据长度不定的场景,但缺点是频繁进入中断,波特率高时CPU负担大,比如115200下,10字节/ms,每个字节触发一次中断,CPU占用很高。所以做产品级代码时,我更倾向于用DMA+空闲中断,后面会讲。

提示:HAL_UART_Receive_IT的长度参数决定了每次回调触发需要积累多少个字节。不要把这里的长度理解成缓冲区大小,而是“目标接收字节数”。

3.2 发送数据的三种方式:阻塞、中断和DMA的适用边界

HAL库发送数据有三种调用方式。第一种是HAL_UART_Transmit,阻塞发送。调用后函数会一直等待,直到所有字节都移出数据寄存器才返回。比如发送16字节时,在9600波特率下大约需要16/960*1.1约18ms,这个时间内CPU被占用。适合调试时打印日志,或者发送短小的查询指令。

第二种是HAL_UART_Transmit_IT,中断发送。调用后立即返回,数据在串口中断中逐字节发送,发送完成会回调HAL_UART_TxCpltCallback。适合发长数据,但需要保证发送缓冲区在发送完成前不能被修改。比如你发一个全局数组,紧接着又往数组里写新数据,新内容可能被当成串口数据发出去,产生莫名其妙的帧错乱。

第三种是HAL_UART_Transmit_DMA,DMA发送。调用后返回,硬件DMA负责把数据移位到串口发送寄存器,发送完成后触发回调。适合大批量数据,比如音频流或固件升级。CPU基本不用参与,但缓冲区不能是局部变量,因为DMA是硬件异步搬运,函数返回后栈内存就失效了。常见做法是把发送缓冲区定义为static或全局。

发送代码示例:

char msg[] = "Hello STM32\r\n"; HAL_UART_Transmit(&huart1, (uint8_t *)msg, strlen(msg), 100);

这里的100是超时时间,单位是ms。如果串口发送队列堵塞超过100ms,函数会返回超时错误。实际项目中我把超时时间设成1000。但要注意,阻塞发送里这个超时是“轮询等待”的实现,HAL库在空闲时进入等待,每毫秒检查一次状态,超时后返回HAL_TIMEOUT。如果你设成HAL_MAX_DELAY,那就会永远等下去。在某些低功耗场景,恶意阻塞可能导致系统进入休眠时串口没发完。

中断发送的写法:

uint8_t tx_data[32] = {0}; HAL_UART_Transmit_IT(&huart1, tx_data, sizeof(tx_data));

调用后不需要等待,你可以继续干别的事。但如果你紧接着修改tx_data的内容,可能发生数据错乱,因为发送还没完成。所以要么等发送完成回调,要么用双缓冲区。这个坑在慢波特率下尤其明显,比如9600波特率下发送32字节需要约30ms,这期间CPU已经跑了好几万条指令。

DMA发送的缓冲区,常见的错误是:

void send_data(size_t len) { uint8_t buf[64]; HAL_UART_Transmit_DMA(&huart1, buf, len); // 危险,函数返回后buf失效 }

正确做法是把buf改成静态数组。我一般还会在发送前加一份拷贝,防止数据在DMA期间被修改。

3.3 中断优先级与NVIC:一个被忽略的丢数据来源

CubeMX中使能USART1中断后,NVIC设置页面会出现USART1 global interrupt的优先级选项。默认的抢占优先级是0,子优先级是0。对于串口接收中断,建议抢占优先级不要设得太高,因为每个字节都会触发中断,如果优先级高于系统滴答定时器,可能会影响系统时钟。反过来,如果系统里其他中断频繁打断串口接收,而你没有及时读走数据,就可能发生溢出丢包。

某开发者曾把USART1中断的抢占优先级设为最高,然后又跑了一段软件定时器中断,串口数据频繁丢失。原因是软件定时器中断被串口抢占,但串口中断里又重新调用了HAL_UART_Receive_IT,这段代码虽然短,多次叠加后导致定时器任务超时。后来把串口中断优先级降到5,问题就消失了。所以优先级需要根据系统负载权衡,一般把串口优先级设为中等偏上即可。

HAL库生成的NVIC初始化代码:

HAL_NVIC_SetPriority(USART1_IRQn, 5, 0); HAL_NVIC_EnableIRQ(USART1_IRQn);

STM32F103内核的优先级位共4位,CubeMX中可以通过“Priority Group”设置分组模式。默认是Group 4,即4位全部用于抢占优先级,子优先级固定为0。所以你看到的优先级范围是0到15,数值越小优先级越高。如果配置成Group 2,那么高两位是抢占优先级,低两位是子优先级。这个分组如果和操作系统配置不一致,会导致中断无法正确嵌套。如果你的工程没跑系统,保持默认即可。

另外,中断服务函数不需要你手动编写逻辑。CubeMX生成的stm32f1xx_it.c中已经包含了USART1_IRQHandler,里面调用HAL_UART_IRQHandler,HAL库会自己判断是接收、发送还是错误中断,并调用相应的回调函数。网上一些老代码教你手动操作SR寄存器清除标志,在HAL库下属于多余操作,而且可能和HAL内部状态冲突。所以直接用生成的就好。

4. 从CubeMX生成代码到点灯验证:一份能跑的串口回环Demo

4.1 生成代码后必须补的那几行变量与初始化

在CubeMX中完成配置,点击生成代码后,工程结构是main.c、stm32f1xx_it.c等。你需要手动添加的主要是接收缓冲区和回调函数。以最小工程为例,我们在main.c的私有变量区定义一个接收缓冲区,然后在main函数里初始化串口后调用一次HAL_UART_Receive_IT,启动第一次接收。

代码:

/* Private variables */ UART_HandleTypeDef huart1; uint8_t rx_buf[16] = {0}; uint8_t rx_complete_flag = 0; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); HAL_UART_Receive_IT(&huart1, (uint8_t *)rx_buf, 16); while (1) { if (rx_complete_flag) { // 把收到的数据原样发回去,形成回环 HAL_UART_Transmit(&huart1, rx_buf, 16, 1000); rx_complete_flag = 0; HAL_UART_Receive_IT(&huart1, rx_buf, 16); } } }

注意,这里rx_buf大小是16,所以必须收满16字节才会触发一次回调。如果你在串口助手里发送“hello”只有5个字节,那永远不会触发回调,这是初学者最常见的问题。解决办法是按实际帧长度定义缓冲区,或者改用单字节接收+自定义结束符方式。回环测试时,发送长度为16字节的固定数据包即可验证通路。

回调函数定义在哪里?它可以放在main.c的末尾,也可以单独建一个文件。HAL库在stm32f1xx_hal_uart.c里有弱定义,你定义的强定义会覆盖它。注意回调函数里不要调用HAL_UART_Transmit这种阻塞函数,因为你现在就在中断上下文里,再发送会阻塞等待,而且嵌套调用容易出问题。正确做法是置标志位,在主循环中发送。

说到Error_Handler,默认生成的代码在出现错误时会进入死循环。调试时可以在Error_Handler里加一个闪烁LED,或者在串口里输出错误代码。实际工程中,可以使用HAL_UART_ErrorCallback来捕获串口错误,避免程序卡死。

4.2 主循环里怎么组织接收标记与发送触发

主循环的典型结构是轮询标志位、处理数据、重新启动接收。这里有个容易犯的错:重新调用HAL_UART_Receive_IT在什么位置?如果放在处理完数据之后,那在处理期间中断就无法接收新数据,可能造成数据覆盖。如果放在处理之前,缓冲区可能在处理中被新数据覆盖。细想一下,HAL_UART_Receive_IT只是启动接收,数据到达后是中断硬件往缓冲区里写的。如果你在处理旧数据的同时启动了新接收,那么新数据会写入同一个缓冲区,当下一次回调触发时,旧数据可能还没被处理完。所以,正常的顺序是:收到完整一帧后,不再调用Receive_IT,直到主循环处理完当前帧,再调用一次开启下一帧接收。这样可以避免缓冲区竞争。

一个更稳妥的框架是使用双缓冲区。定义rx_buf1和rx_buf2,每帧交替使用。在处理rx_buf1时,开启对rx_buf2的接收。这种乒乓缓冲在数据量大的时候很有用,但普通Demo不需要。作为工程师,我通常先固定帧长,用单缓冲区加标志位,确认调试成功后,再升级到DMA+空闲中断。

在验证回环时,注意串口助手的设置:波特率115200,8数据位,1停止位,无校验。发送时如果你用的是字符串模式,并且勾选了“自动追加回车换行”,那么实际发送的数据会多出\r\n。如果你固定接收16字节,就会因为多了两个字节导致对不上。所以调试时要么关闭追加,要么发送HEX格式固定长度。

4.3 用LED指示收发状态:肉眼看到的才算数

串口通信这种不可见的逻辑,最好加一个可视化指示。在GPIO配置里,我们把PC13设为输出,这个引脚上板载LED通常低电平点亮。收到数据时翻转LED,发送完成时再翻一次。这样你不需要看串口助手的日志就知道收发是否在跑。

示例代码:

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rx_complete_flag = 1; HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } }

主循环里发送后也翻转一次。如果LED只亮一次不再动,说明接收根本没触发;如果LED快速闪烁但串口没有数据,说明你收到的数据都是垃圾或者正在处理错误。这个经验在调试无线模块时尤其有用,因为你不知道干扰还是链路问题,先把串口链路用LED确认了再排查后面。

5. 串口通信避坑指南:乱码、丢数据、卡死的排查路径

5.1 现象一:收到乱码,波特率明明没错

现象:串口助手打印出来的内容是乱码,但频率不高,偶尔有一两个字符错误。

原因:最常见的是时钟树配置错误。如果USART1使用的时钟不是72MHz,而是36MHz甚至8MHz,那么计算出的波特率与真实波特率存在偏差。比如在115200波特率下,如果时钟差一倍,接收方会在每个位周期采样点偏离,产生误判。另一个常见原因是USB转串口模块质量差,或者接线过长。

解决:先用CubeMX重新确认时钟配置。把HCLK设为72MHz,APB2时钟不分频,APB1二分频。然后在代码里通过串口发送一个固定字符串,用逻辑分析仪测量实际波形,或者换一个已知正常的串口模块交叉验证。还要检查串口助手的波特率是否与代码一致。有时候串口助手有两个波特率设置,比如数据位和停止位旁边有个“流控”选项,也影响通信。关掉所有流控选项。

5.2 现象二:偶尔丢一包数据,回调没触发

现象:发送多帧数据,有时第一帧正常,第二帧就丢失,或者数据是错乱的。

原因:这是典型的接收缓冲区被覆盖。当你调用HAL_UART_Receive_IT接收固定长度数据时,如果对方发送的数据不连续,每帧之间间隔很短,主循环还没来得及处理上一帧,下一帧已经把缓冲区写满并触发回调。但实际上,HAL库的Overrun检测机制在检测到接收溢出时会停止接收,这时回调不会触发,而是进入HAL_UART_ErrorCallback。很多人没实现这个错误回调,所以看起来就是“数据丢了”。

解决:实现HAL_UART_ErrorCallback,并在里面清除错误标志后重新启动接收。代码片段:

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { __HAL_UART_CLEAR_OREFLAG(&huart1); HAL_UART_Receive_IT(&huart1, rx_buf, 16); } }

__HAL_UART_CLEAR_OREFLAG是清溢出标志的宏。注意HAL库版本差异,老版本可能叫__HAL_UART_CLEAR_FLAG。错误回调重新启动接收后,至少不会卡死。但要真正解决丢包,还是得优化上层协议,比如增加帧序号和重发机制,或者接收端改用DMA方式来降低CPU响应压力。

注意:清除溢出标志后,重新启动接收前,最好先确认中断接收没有在其他地方同时启动,避免重入。

5.3 现象三:程序死在HAL_UART_Receive_IT里

现象:调用HAL_UART_Receive_IT后,程序进入死循环或卡死在Error_Handler。

原因:一种情况是串口外设没有正确使能时钟或引脚冲突,导致初始化失败。另一种情况是你在中断回调里调用了可能阻塞的函数,比如HAL_UART_Transmit,而发送缓冲区恰好有数据,发送过程又被当前中断打断,造成死锁。还有一种是接收长度大于缓冲区实际大小,导致数组越界,破坏了内存。

解决:先检查初始化函数是否返回HAL_OK。在Error_Handler里加个串口打印或LED闪烁,别让Bug静默。然后审查回调函数,把数据处理都放到主循环,中断里只置标志。接收缓冲区大小与HAL_UART_Receive_IT的length参数严格一致,别用sizeof(指针)这种写法。另外,某些HAL库版本在UART中断中反复调用Receive_IT会导致重入问题,可以在错误回调里加一个静态标志,如果还忙就忽略这一次。

5.4 现象四:发送中文乱码,接收英文正常

现象:发送ASCII字符一切正常,发送中文字符串就变成乱码。

原因:这个和串口本身无关,是编码问题。在常见的IDE环境中,如果源文件编码是GB2312,串口助手以UTF-8方式接收,就会显示乱码。反过来,如果源文件是UTF-8,而串口助手按GBK解码,同样乱码。还有的IDE会在编译时根据编译器选项把字符串编码转换,导致代码文件中看着是中文,Flash里存的却是另一种编码。

解决:调试时统一使用十六进制或纯ASCII,别用中文。如果必须发中文,确认代码源文件编码和串口助手的解码编码一致。最省心的方法是输出英文或拼音,反正协议里中文也不是必须的。另外,有的串口助手有“字符集选择”下拉框,切换成GB2312或UTF-8看效果。

5.5 现象五:热插拔或上电顺序导致通信失败

现象:板子上电后第一次发送没反应,重新给串口模块断电再上电又正常了。

原因:这个多半是USB转串口模块和开发板的电源时序问题。如果开发板先上电,串口模块后上电,模块的TX引脚在初始化瞬间可能输出高低电平抖动,被单片机当成一帧数据的开始,导致接收状态错乱。反过来也可能。还有的是因为模块的DTR/RTS引脚在打开串口时产生电平跳变,接到开发板的复位电路上,导致单片机被意外复位。

解决:上电顺序固定为“先接串口模块,再给单片机供电”,或者反过来,找到一个稳定的顺序。如果使用的是带DTR/RTS控制的模块,在串口助手里把“DTR/RTS”相关的自动复位功能关掉。或者干脆在电路上把DTR/RTS悬空,只接GND、TX、RX三根线。这个现象在调试配置了串口下载的板子上特别常见,因为硬件设计上会把DTR连到复位脚。

6. 进阶:把收发改成带空闲中断的DMA环形缓冲

6.1 为什么需要IDLE中断:不定长帧的可靠接收

前面说到的固定长度接收有一个麻烦:如果通信双方不约定帧长度,就无法确定何时“接收完成”。很多自定义协议是用帧头+长度字段的方式,也有的干脆以换行符作为结尾。最稳妥的办法是利用串口的空闲中断(IDLE)。当串口在一帧数据后出现总线空闲,硬件会触发IDLE中断,此时你就能知道“这一帧已经收完了”。

IDLE中断配合DMA,可以自动接收数据到缓冲区,空闲时回调,CPU只在帧结束时处理数据,效率很高。STM32F103的USART1接收DMA通道是DMA1 Channel5,使用外设到内存模式,字节数设为最大可能长度。配置步骤:CubeMX中在串口配置里使能USART1中断,然后在DMA Settings中添加USART1_RX的DMA通道,模式Normal。注意,DMA模式不要选Circular循环模式,否则计数器回绕后难以准确计算接收长度。Normal模式配合IDLE中断是最常用的组合。

6.2 空闲中断+DMA的完整实现与重启流程

这里给出一个可用的代码骨架。在CubeMX里配置DMA后,把生成的代码补充为:

#define RX_BUF_SIZE 128 uint8_t dma_rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_len = 0; volatile uint8_t idle_flag = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // 如果用DMA正常接收完成(缓冲区满),这里可以作为备用 } void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 停止DMA,计算接收长度 HAL_UART_DMAStop(&huart1); rx_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); idle_flag = 1; // 在这里可以处理数据,也可以设置标志交给主循环 UART1_Restart(); } }

这段代码在USART1的中断服务函数中检查空闲标志。当总线空闲,DMA已经接收完一帧数据,此时通过DMA剩余计数器rx_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(...)算出实际接收字节数。然后置idle_flag通知主循环,并调用UART1_Restart重新准备下一次接收。

UART1_Restart函数实现:

void UART1_Restart(void) { __HAL_UART_CLEAR_OREFLAG(&huart1); __HAL_UART_CLEAR_IDLEFLAG(&huart1); HAL_UART_Receive_DMA(&huart1, dma_rx_buf, RX_BUF_SIZE); }

需要注意的是,HAL_UART_DMAStop会关闭DMA和串口接收,所以重启时必须重新调用HAL_UART_Receive_DMA来恢复。清除溢出标志和IDLE标志的顺序要放在启动DMA之前,否则残留标志可能导致立即触发错误中断。另外,HAL_UART_IRQHandler内部对错误和正常中断的处理不会和你手动检查IDLE冲突,因为IDLE标志不是接收完成标志,HAL库默认不会清除它。我们手动清除是安全的。

使用DMA接收时,缓冲区的大小要留有余量。如果你预计最大帧长度是64字节,建议缓冲区设为128甚至256,防止一帧数据超过缓冲区大小,导致DMA溢出或覆盖其他内存。另外,DMA接收缓冲区必须对齐到4字节边界,否则在某些编译器或高优化等级下,DMA访问可能出错。可以在定义数组时用__attribute__((aligned(4)))。

这个方案的验证方法:连接串口助手,在发送区填入一帧数据,但不限制长度,点击发送。观察LED或打印信息,确认接收长度等于实际发送长度。然后再连续发送多帧,中间间隔几十毫秒,确认每帧都能正确捕获。如果帧与帧间隔太短,比如小于一个字符的传输时间,IDLE中断不会触发,这时需要协议上增加帧间隔的约束。

从那以后,我每次写串口接收代码都强制走一遍这个流程:先固定帧长跑通,再换DMA加IDLE,最后验证多帧间隔下的稳定性。这个习惯帮我省下了很多现场调试的时间。希望帮到你。

本文还有配套的精品资源,点击获取

相关新闻

Java+YOLOv5食品包装缺陷检测实战:从模型导出到Spring Boot API对接

Java+YOLOv5食品包装缺陷检测实战:从模型导出到Spring Boot API对接

JavaYOLOv5食品包装缺陷检测实战:从模型导出到Spring Boot API对接前几个月在做一个食品产线的质检项目,需求很朴素:检测包装袋的封口是否平整、有无漏气皱褶、印刷字符是否错位、包装表面有没有污渍。算法那边用YOLOv5训练了一版模型&#x…

2026/10/19 17:43:52 阅读更多 →
COMSOL两相流THM热流固耦合模型:从物理原理到收敛性优化

COMSOL两相流THM热流固耦合模型:从物理原理到收敛性优化

简介:面向COMSOL仿真用户的THM热流固耦合两相流模型资源,适用于石油工程、地热开发等领域中饱和水与油流动的数值模拟,也可扩展至水与超临界二氧化碳两相流场景。模型完整涵盖热-流-固三场耦合,用户可根据实际需求自由修改几何、材…

2026/10/19 17:42:49 阅读更多 →
手写识别数据集.pot文件解析:从笔迹坐标到深度学习训练样本

手写识别数据集.pot文件解析:从笔迹坐标到深度学习训练样本

做中文手写识别,不管你是研究路线,还是已经被业务需求按着头写代码,大概率都会碰到 CASIA OLHWDB 这个名字。数据集虽好,.pot 这套文件格式对新人极其不友好——它不是图片,不是 JSON,不是官方给好读接口的…

2026/10/19 17:42:49 阅读更多 →

最新新闻

三维立体画生成器:从灰度深度图到视差编码的完整实现

三维立体画生成器:从灰度深度图到视差编码的完整实现

简介:这是一套面向图形处理爱好者与三维立体画创作者的三维立体画生成工具,适合希望快速上手立体图制作、又不想依赖昂贵商业软件的用户。软件在真实三维场景中以“照相”方式生成作品,内部采用双深度缓存技术,3D信息全面且左右对…

2026/10/19 18:26:20 阅读更多 →
10元ESP32-S3裸机跑YOLOv5s:边缘AI轻量化实战

10元ESP32-S3裸机跑YOLOv5s:边缘AI轻量化实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 18:23:17 阅读更多 →
Git常用命令实战指南:从数据模型到分支合并与撤销恢复

Git常用命令实战指南:从数据模型到分支合并与撤销恢复

Git 这个东西,说难不难,说简单也真不简单。我见过不少人把add和commit背得滚瓜烂熟,可是一遇到分支合并、代码冲突、提交撤销就手足无措,最后只能靠复制粘贴网上的命令来救急,运气好没事,运气差直接把同事的…

2026/10/19 18:23:17 阅读更多 →
毕业论文智能导航:从选题到查重全流程的AI辅助指南

毕业论文智能导航:从选题到查重全流程的AI辅助指南

毕业论文这个东西,只有真正常年带学生、自己也经历过完整写作过程的人才会明白:绝大多数人最后卡住的地方,根本不是“文笔差”或者“肚子里没货”,而是整个写作过程缺乏一条清晰的路径。选题反复推翻、文献越搜越乱、框架改了又改…

2026/10/19 18:23:17 阅读更多 →
AI辅助毕业论文写作全指南:功能、实操与避坑技巧

AI辅助毕业论文写作全指南:功能、实操与避坑技巧

1. 毕业论文的痛,AI真的能解吗每年到了毕业季,我身边就会冒出一大批“深夜改稿人”。有的在图书馆对着空白文档发呆,有的在跟参考文献格式搏斗到凌晨两点,还有的因为查重报告里的红色标记焦虑到脱发。毕业论文这件事,说…

2026/10/19 18:23:17 阅读更多 →
Kafka Java客户端实战:原理、参数与排障全解析

Kafka Java客户端实战:原理、参数与排障全解析

Kafka这名字,在Java后端圈子里,基本属于“躲不开”的那一类技术。不管是搞微服务、大数据管道,还是简单的异步解耦,消息队列总得选一款,而Kafka往往是选项里最“硬核”的那个。很多人第一反应是“分布式、高吞吐、源码…

2026/10/19 18:22:16 阅读更多 →

日新闻

频率跟踪型PWM电源设计:臭氧发生器谐振点漂移与软开关实现

频率跟踪型PWM电源设计:臭氧发生器谐振点漂移与软开关实现

简介:本资源为电源技术方向的学术研究文档,聚焦介质阻挡放电型臭氧发生器电源的频率跟踪型PWM控制策略,面向电力电子、高压电源及臭氧应用领域的科研人员与工程技术人员。针对负载呈容性、等效电容随外加电压升高而增大、谐振频率随之漂移的难…

2026/10/19 0:00:47 阅读更多 →
Vibe Coding 遇上 TaoToken:把开发者思维圣殿的灵感流接入统一 Key 通道

Vibe Coding 遇上 TaoToken:把开发者思维圣殿的灵感流接入统一 Key 通道

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 0:00:47 阅读更多 →
HundredCV-Chat话题建模实战教程:利用4,871个内置话题标签构建主题分析系统

HundredCV-Chat话题建模实战教程:利用4,871个内置话题标签构建主题分析系统

【免费下载链接】HundredCV-Chat 项目地址: https://ai.gitcode.com/hf_mirrors/Jax-dan/HundredCV-Chat 点击查看 免费下载 HundredCV-Chat 是一个由 100 位青年简历数据开发而来的中文多轮对话数据集,共包含 24,750 组日常闲聊对话和 4,871 个话题标签…

2026/10/19 0:00:47 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/19 1:30:52 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 12:16:16 阅读更多 →