☰
STM32 HAL库UART四种模式详解:阻塞、中断、DMA与回调
2026/10/16 8:51:17 网站建设 项目流程

1. 项目整体思路与UART基础回顾

聊到STM32开发,UART串口绝对是最常打交道的外设。不管你是点个灯、传个传感器数据、做OTA升级,还是跟Wi-Fi模块/蓝牙模块对数据,都绕不开串口这一关。而到了STM32的HAL库时代,很多从标准库切过来的朋友第一反应就是:干,UART的接口怎么变这么多了,HAL_UART_Transmit、HAL_UART_Receive,后面还能加_IT、_DMA,再往下看还有一堆HAL_UARTEx_xxx的扩展接口,搞得人一头雾水。

其实HAL库把串口传输分成几套模式,本意不是给你添堵,而是让你根据实际场景做取舍。我这次梳理项目的核心思路,就是把UART的用法按四条主线拆开:阻塞模式(轮询)、中断模式、DMA模式,外加一条贯穿始终的回调逻辑线。每条线配好使用姿势、代码示例和适用场景,理清楚之后你会发现串口收发的选择本质上是一个“CPU时间”和“代码复杂度”的权衡游戏。

先说清楚一个底层概念:UART收发本质上是线路空闲时把一个字节的8个bit一位一位地发出去,收的时候也类似,一个字节一个字节地进移位寄存器。这个“逐bit收发”的过程由硬件完成,但驱动它、通知你的方式各有不同。HAL库不同的传输模式,对应的就是硬件做完一个字节之后,用什么方式通知CPU、由谁来接着干下一步活。

这四种模式适合的人也不同。如果只是做简单调试打印,阻塞模式已经完全够用;如果项目里面有多个外设需要同时跑,比如一边采集传感器一边处理串口指令,那中断模式不能不会;如果传的数据量大、频率高,或者不希望CPU在收发上花太多时间,DMA模式才是归宿。而不管选哪一种,想在HAL库里对“传输完成”这个事件做响应,必须理解回调机制,这也是很多人把程序写“死”的根源。

下面我按四个部分逐一拆解,后面每个部分都会给出参考代码和调用位置,你可以直接抄走改一改就能用。先说最基础的阻塞模式。

2. 阻塞模式:简单可靠,但CPU被“焊死”了

2.1 阻塞模式原理与核心API

阻塞模式,也叫轮询模式,是HAL库UART里最直观的传输方式。它的工作逻辑相当朴素:CPU往发送数据寄存器里丢一个字节,然后死等硬件把这一字节发完,再丢下一个字节,直到规定的数据发完为止。接收也一样,CPU不断查询接收标志位,等到数据来了就取走,超时没数据就返回一个超时错误。

对应的API就是你天天见的那两个:

HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout); HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);

注意看,这两个函数是成对出现的,一个是发,一个是收。原型里最后一个参数是Timeout,单位是毫秒(ms),意思是如果在这个时间内硬件还没有完成要求的收发动作,函数就返回超时错误码,而不是无限死磕下去。这一点比标准库寄存器那套写法要友好,起码给了你一个容错窗口。

在对超时参数的理解上,有一个细节很多初学者会忽略:这个Timeout针对的是“单字节”还是“整包数据”?实际上,库函数内部是按字节轮询,每一字节的处理都会检测超时,但超时判断的逻辑是看“整个操作”的最长等待时间。你可以把它简单理解成:函数在内部每个字节处理前都会检查一次是否超出了总超时预算,超了就立刻退出,返回HAL_TIMEOUT。这个机制的意思是,如果你的串口波特率低、数据包又大,Timeout千万别按10ms这种余量很小的值去配,否则数据稍微有点延迟,整个发送就会被掐断,而且不会被重发。

2.2 阻塞模式实战示例

下面是一个最简单的阻塞发送和接收的例子,假设我们初始化好了huart1,即串口1:

uint8_t txBuf[] = "Hello STM32\r\n"; uint8_t rxBuf[16]; uint16_t len = 0; uint32_t timeout = 100; // 100ms超时 HAL_UART_Transmit(&huart1, txBuf, sizeof(txBuf) - 1, timeout); len = HAL_UART_Receive(&huart1, rxBuf, 9, timeout); // 尝试接收9字节 if (len != HAL_OK) { // 超时或出错处理 Error_Handler(); }

在阻塞模式下,程序就停在HAL_UART_Receive这一行,要么收满9字节走人,要么100ms超时走人,中间干不了别的事。这在很多场景下没有什么问题,比如上电后等待主机下发配置、调试时打印一条状态信息,这类操作本身就不在乎CPU空等那几几十微秒。把这句话翻译成人话就是:如果程序状态机允许“等一会儿”,那阻塞模式完全够用。

2.3 阻塞模式的使用场景与避坑要点

阻塞模式适合哪些地方呢?我结合经验说几个典型场景。

第一个是系统初始化阶段的调试日志。上电之后,时钟、GPIO、串口本身都配好了,主循环还没跑起来,这时候打一些版本号、启动信息,用阻塞发送最简单粗暴,因为此时CPU本来就是闲着。

第二个是简单指令应答。比如通过串口接收上位机下发的参数,交互频率低、数据量小,上位机发一帧,MCU处理完再回一帧,中间不需要并行处理其他逻辑,阻塞收发完全能够胜任。

第三个是低功耗唤醒后的状态上报。休眠唤醒后,用阻塞模式把几个字节状态快速发出去,然后再回到低功耗,整个过程CPU不会有任何并发压力。

但阻塞模式有几个问题必须重视。最致命的就是对CPU的占用。假设你以9600波特率连续发1000个字节,光传输这1000字节就需要大约1秒(9600波特率下理论约9.6ms每字节,实际因为有起始位和停止位接近1ms多一字节,所以1000字节要1秒以上),如果在主循环里用阻塞方式发,那CPU这一个多秒钟别的事情就全都不干了。可能有人会说,发送DMA中断不也一样吗?差别就在DMA模式下,CPU只在初始化和完成中断那一刻参与工作,中间的一个多秒钟CPU早去跑其他任务了。

另一个坑是Timeout与波特率的关系。我见过相当多的人在9600波特率下发一帧30字节的数据,然后Timeout给10ms,结果发送几乎是100%超时失败。为什么?9600波特率下1字节约为1.04ms,30字节理论传输就要31ms以上,哪怕不带任何延时,10ms的超时连一半数据都没发完。所以一旦出现阻塞发送返回HAL_TIMEOUT,第一个要检查的不是代码逻辑,而是TimeOut跟波特率、数据长度是否匹配。这也是经验里最常踩的坑之一。

最后提醒一点,很多人把阻塞接收直接扔进主循环里,却忘了这会造成主循环卡死的风险。如果从机要等的下一帧数据迟迟不来,超时时间内主循环就一直在等UART,LED闪烁也好、按键扫描也好,全都停了。超时时间设短一点的通常还能接受,设长了整个系统就“死了”。所以使用阻塞模式前,一定要在原理层面想清楚当前阶段的CPU能不能这样空等。不能的时候,就该切到中断模式了。

3. 中断模式:把“等待通知”这件事交给硬件

3.1 中断模式的核心机制

中断模式和阻塞模式最大的区别在于:CPU不再死等,而是“预约好人数然后先去干别的,等硬件叫我了再回来”。在HAL库里,无论是发送还是接收,都以_IT后缀作为中断版本的标识:

HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size); HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);

看到这两个函数的声明你会发现,它们根本没有Timeout参数。原因很简单:中断模式根本不需要超时,因为CPU没有在死等,它只是开启了“传输完成中断”的使能,然后就返回主循环干活去了。硬件在每发送完一个字节之后,就会触发一次中断,中断服务函数里把下一个字节继续搬进发送寄存器;接收也一样,每收到一字节就中断一次,中断服务函数把数据从寄存器搬进用户缓冲区。等到整批数据都处理完了,HAL库会调用一个回调函数,告知你整个传输过程已经完成。

回调函数就是你实现的中断里执行的“善后工作”。比如你要发一串数据,开中断模式发送,然后主循环继续跑,当硬件把最后一个字节发出去了,HAL库会跑到HAL_UART_TxCpltCallback里面执行你写的代码。接收也是一样,收到指定长度的数据后,自动进HAL_UART_RxCpltCallback。

中断模式解决了CPU空等的问题,但代价是代码从一个线性流程变成了事件驱动,回调函数的执行时机、中断与主循环的竞争关系,都需要额外考虑。

3.2 中断模式示例:发送与接收

先看一个发送的例子:

uint8_t data[] = "AT+OK\r\n"; // 中断方式发送,调用后立即返回,CPU可以去干别的 if (HAL_UART_Transmit_IT(&huart1, data, sizeof(data) - 1) != HAL_OK) { Error_Handler(); } // ... 主循环其他逻辑继续跑 ... // 发送完成回调函数,由HAL库在中断里调用 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 发送完成,可以在这里置标志位,或者开启下一个操作 s_tx_done = 1; } }

接收的例子稍微多一点,因为接收需要确保调用接收函数之前就已经准备好一个缓冲区,否则数据来了没地方放:

uint8_t rxBuffer[64]; uint8_t rx_len = 0; // 开启中断方式接收,指定接收64字节,接收完成后会触发回调 HAL_UART_Receive_IT(&huart1, rxBuffer, 64); void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rx_len = 64; // 这里表示收到了64字节 // 处理收到的数据... // 然后再开启一轮中断接收 HAL_UART_Receive_IT(&huart1, rxBuffer, 64); } }

这里有一个初学者经常掉进去的坑:HAL_UART_Receive_IT如果你只调用一次并指定接收64字节,那么它要收满64字节才会触发一次RxCpltCallback。如果只来了5个字节,回调永远不执行,数据就一直躺着缓冲区里。所以对于不定长接收,你要么在回调里重新开启接收(就上面的写法),要么根本不用这种指定长度的中断接收,而是走后面DMA的部分讲到的IDLE中断方案。这一点后面在讨论不定长接收的时候再展开。

3.3 中断模式的场景与实操心得

中断模式的适用场景很明确:要求CPU在等待串口期间还能处理其他任务。比如一个设备既要保持串口接收上位机指令,又要动态刷新显示屏、扫描按键、处理定时任务。如果你用阻塞接收,整个系统在等待指令时所有任务都暂停;而用中断接收之后,接收这个动作本身就是“通知制”,数据来与不来,CPU该干嘛干嘛。

在实际项目里,中断模式还有一个很重要的作用:配合外部事件做精准响应。比如一个门禁系统,刷卡模块通过串口上报卡号,如果MCU用阻塞轮询读串口,有可能刚好错过刷卡上报的瞬间;而用中断方式,卡号一到就触发中断进回调,在回调里置位事件标志,主循环检测到标志再去读卡号、做验证,整个响应链就不会丢数据。

关于中断优先级,我必须专门说一句。UART中断的优先级不要设得过高,原因在于中断服务函数处理时间不能太长,尤其是不要在回调函数里做耗时长的操作,比如HAL_Delay、printf、复杂的滤波算法。中断优先级设太高会抢占其他同样重要的中断,比如定时器中断、外部中断,造成系统时序混乱。有经验的人通常把UART中断优先级设为中等偏低,回调里只做两件事:拷贝数据、置标志位。一切耗时处理统统放到主循环里等标志位再去执行。

同时要注意,HAL_UART_Transmit_IT、HAL_UART_Receive_IT这两兄弟不能被主循环和中断回调里同时调用同一个UART实例。比如主循环里正在用串口1发送,这时串口1又接到了接收中断,接收中断里又调用了HAL_UART_Transmit_IT,就会引发资源竞争,轻则数据发乱,重则进入HardFault。很多人在回调里直接调用另一个发送函数,导致系统崩溃,排查半天也没头绪。正确的做法是:回调里置标志位、存数据,然后回主循环判断标志,再执行发送。

再来一个我从实际调试中总结的小技巧:中断接收的缓冲数组最好定义成全局变量或静态变量,别在函数内部定义一个临时数组然后传给HAL_UART_Receive_IT。因为中断是异步的,如果函数退出后栈上数组被释放,而硬件中断还没完成接收,后续中断搬移数据时就会操作一个已经无效的地址,查起来极其困难。这个问题在内存较小的芯片上更隐蔽,因为在函数返回后栈内存可能很快就会被其他函数覆盖,中断再把数据写进去,缓冲区数据就莫名其妙被破坏。

4. DMA模式:让数据自己“搬运”,CPU彻底解放

4.1 DMA为什么能“扛大包”

DMA(Direct Memory Access,直接存储器访问)翻译过来是“直接内存访问”,它的核心价值在于:数据在外设寄存器和内存之间搬运的时候,完全可以绕开CPU。也就是说,你要发2000个字节到串口,不需要CPU一个字节一个字节地往寄存器塞,也不需要每发一个字节进一次中断,只要给DMA控制器说清楚“源地址在哪、目的地址在哪、搬多少、搬完通知我”,DMA控制器就能在后台把数据一趟一趟搬完,搬完后再触发一个完成中断告诉CPU,完事。

HAL库里DMA模式对应的API:

HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size); HAL_StatusTypeDef HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);

DMA模式适合哪些场景呢?最典型的是大数据量、高频率的串口通信。比如一个产品要不停地上报传感器数据,每包百来个字节,每秒钟好几包,如果走中断模式,CPU会被频繁的中断打断好几次,处理别的逻辑的时间被严重压缩。换成DMA后,CPU只需要发起一次DMA传输,之后数据就在后台悄悄搬家,CPU可以全身心去跑显示刷新、控制算法这些需要算力的任务。DMA模式还有一个额外优势:哪怕CPU在低功耗睡眠模式,DMA也能照常搬运数据,这在低功耗设计中非常有价值。

4.2 DMA发送与接收的代码实现

DMA发送的代码非常简洁:

uint8_t tbuf[256] = { ... 数据 ... }; // 开启DMA发送,传完数据后触发回调 HAL_UART_Transmit_DMA(&huart1, tbuf, 256); // DMA发送完成回调 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { s_tx_dma_done = 1; } }

DMA接收类似,打个比方就是告诉硬件“你把后续收到的东西都放进这个缓冲区,装满我再处理”:

uint8_t rbuf[128]; // 开启DMA接收,收满128字节后回调 HAL_UART_Receive_DMA(&huart1, rbuf, 128); void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 缓冲区已满,处理数据 process_data(rbuf, 128); // 处理完后继续开启下一次DMA接收 HAL_UART_Receive_DMA(&huart1, rbuf, 128); } }

这里稍微看仔细一点,DMA接收的回调和中断接收用的回调其实是同一个HAL_UART_RxCpltCallback,说明这两种模式下“接收完成”这个事件的回调接口是一致的。但这恰恰也让不少人犯了迷糊:DMA接收确实是把数据搬进缓冲区了,但如果你指定的接收长度是128字节,而实际只来了几十个字节,那可怎么办?要么一直等,等满128字节才触发回调,要么引入字节数不固定的检测机制,最常见的手段就是利用UART的空闲中断(IDLE)。关于IDLE模式我在后面还会单独说。

4.3 DMA使用中的几个关键坑

DMA模式虽然省心,但坑也比前两种模式多,我这里列几个自己调试时印象深刻的问题。

第一个是缓存对齐问题。部分芯片的DMA要求外设寄存器或者内存地址满足某种对齐条件,尤其是当DMA传输的数据类型是半字(16位)或字(32位)时,地址对齐尤其重要。UART的DMA一般是字节传输,因此对齐问题不明显,但如果你拿DMA去搬运CAN、ADC、SPI等外设数据,对齐条件可能就开始挑事了。一个稳妥的做法是,缓冲数组定义时交给编译器对齐,或者使用内存对齐属性。

第二个是DMA缓冲区访问冲突。CPU和DMA同时访问同一块内存时,理论上有仲裁机制不会出错,但在实际调试中,如果你在DMA还在传输时就更新缓冲区内容,可能出现发送数据和期望数据不一致的情况。解决办法是在启动DMA发送前等待上一次发送结束,或者在没有发送的时候才更新缓冲。标准做法是搞一个互斥标志:发送完成回调置0,没置0之前不要往缓冲区里塞新数据。

第三个是重复触发传输导致的状态机错乱。调用HAL_UART_Transmit_DMA前必须确认上一次DMA没有在接收或发送。一个典型场景是,上位机下发指令后立即再发一条指令,MCU还在处理第一条,如果直接在中断里又调用一次DMA接收,就会报错、状态机错乱。HAL库内部其实已经有忙碌状态检查,但它的检查结果只是返回HAL_BUSY,并不会替你排队。所以应用层必须自己做好“当前是否有传输在跑”的判断,两个DMA调用之间的间隔一定要大于实际传输完成的时间,否则数据就悄悄丢了。

我也要提醒一下:DMA通道和中断优先级的配合。在HAL库里,DMA模式的完成中断由DMA通道自己产生,UART只是把DMA请求发出去。如果DMA中断优先级设置得比UART中断还低,且外部大量数据同时拥入,有可能导致DMA完成事件被长时间延迟,进而影响下一个DMA传输的启动。经验是在HAL初始化代码里,把DMA中断优先级调到一个略高于普通外设中断、但低于系统关键中断(比如SysTick),这样既能及时处理传输完成,又不会过头地抢占系统资源。

考虑一种更进阶的需求:要不固定长度地接收数据。这时候,单独使用DMA模式是不够的——你虽说可以指定一个很大的接收长度比如1024字节,但1024字节永远收不满,所以回调永远不触发。要解决不定长接收,就必须结合UART的空闲中断(IDLE)来做一个“收到半包就通知”的方案,这一部分我放到后文DMA+IDLE不定长接收章节专门讲。这里先让大家心里有数:DMA并不是穿了就能飞,你还要学会怎么“下车”。

5. 回调逻辑:理解HAL库的“钩子”机制

5.1 HAL库回调体系与触发时机

前面几部分不断提到回调函数。我单独拿一小节出来讲清楚回调机制,是因为这个机制使用频率实在太高,而且很多人对它在什么时机触发完全靠猜。HAL库的UART回调本质上是一组由HAL库定义、默认是空实现的弱函数,你可以在自己的应用代码里重新实现同名函数,这样当传输发生到某个节点时,HAL库中断服务程序会调用你的实现。这就是所谓的“钩子”函数。

常用的UART回调有这么几个:

回调函数触发时机
HAL_UART_TxCpltCallback发送完成(中断或DMA模式)之后触发
HAL_UART_TxCpltCallback接收完成(中断或DMA模式)之后触发
HAL_UART_ErrorCallbackUART发生错误(溢出、帧错误、校验错误等)时触发
HAL_UARTEx_RxEventCallback使用扩展功能(如UART空闲事件接收)时触发

这些回调函数在HAL库源文件(stm32xx_hal_uart.c)中有默认弱定义,你只要在自己的C文件里写一个同名函数,链接器就会把弱符号替换成你写的版本。要注意的是,回调函数的参数是一个UART_HandleTypeDef *huart指针,如果你同时用了多个串口,就必须靠huart->Instance去判断具体是哪个串口触发的回调,再分别做响应。每个串口的回调都会进同一个函数。

5.2 回调函数里应该干什么、不该干什么

回调函数运行在中断上下文里,这意味着你在回调函数里写的所有代码,本质上都算中断服务程序的一部分。中断服务程序的铁律就是:执行时间尽量短、不要主动阻塞等待、不要调用会长时间运行的函数。所以回调函数里比较合适做的事情是置一个标志位、把关键数据拷贝到自己的缓冲区,或者把从缓冲区读取的数据放进队列,然后立即返回。所有耗时的逻辑(比如字符串解析、Flash写入、LCD刷屏、PID计算)都应该放到主循环里由标志位触发去执行。

一个非常典型的反面例子是:在回调里调用HAL_UART_Transmit_IT或HAL_UART_Transmit_DMA发送回应数据,然后又在回调里等待发送完成标志。由于发送也是同样的中断上下文,没有及时返回,UART收发中断就一直被当前的优先级打断,严重时可能触发溢出错误(ORE)或者硬件错误。我调试时有段时间每次都卡死在回调里,排查了很久发现是回调里调用了一个内部也依赖中断的日志打印库,日志打印又恰好在用同一个串口。后来改成只在回调里写入环形队列,主循环再统一从队列取数据发送,问题立刻消失。

另外还要提醒一点:HAL库的回调函数默认是弱符号,但你如果在一个中断优先级很高的回调函数里使用HAL_Delay,而HAL_Delay依赖的SysTick中断优先级又比当前中断低,系统就永远等不到滴答中断,表现为程序卡死。所以不要在回调函数里出现任何形式的死等,不管等待的对象是什么。

5.3 回调与主循环的协作模型

讲了这么多限制,很多人会问,那回调怎么跟主循环配合?这里我给出一种最通用、最稳妥的协作模型,也是我自己在多数项目里使用的方案。

整体思路是:回调函数只负责置标志位和搬运数据,主循环负责处理标志位和数据。下面是一个示例伪代码:

volatile uint8_t uart2_rx_flag = 0; uint8_t uart2_rx_buf[32]; uint8_t uart2_rx_len = 0; // 回调函数 void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART2) { uart2_rx_flag = 1; uart2_rx_len = Size; } } // 主循环 int main(void) { // 初始化... HAL_UARTEx_ReceiveToIdle_DMA(&huart2, uart2_rx_buf, sizeof(uart2_rx_buf)); while (1) { if (uart2_rx_flag) { uart2_rx_flag = 0; process_packet(uart2_rx_buf, uart2_rx_len); // 根据协议决定是否再次开启接收 HAL_UARTEx_ReceiveToIdle_DMA(&huart2, uart2_rx_buf, sizeof(uart2_rx_buf)); } // 其他任务... } }

这个协作模型的好处是:回调里只有赋值和记录长度两行代码,耗时极短,不会影响中断实时性;而真正的数据处理在主循环里按部就班地执行,即使处理耗时较长也不会拖累中断。但这里有个细节很多人会踩坑:主循环里在处理完上一包数据后,一定要记得重新开启接收,否则后续数据就再也进不来了。这一点我在DMA不定长接收那一节还会反复强调。

6. 常见问题与排查技巧实录

6.1 六大高频问题的排查思路

这块我直接按问题清单的方式整理,都是我实际调试中最常遇到、以及在一些开发者交流群里被反复提问过的串口疑难杂症,每个问题后面都附上排查思路和解决办法。

问题1:串口助手收不到单片机发送的数据

看到只有一个波特率、引脚配置正确这种前提没有确认,我们就按排查顺序来。先用示波器或逻辑分析仪看TX引脚上有没有波形。如果没有波形,检查串口是否真的初始化成功、发送函数返回值是否为HAL_OK。如果返回HAL_TIMEOUT,那就是阻塞模式下超时参数与波特率数据长度不匹配;如果返回HAL_BUSY,说明上一次发送还没有结束,你可能在上一次传输还没完的时候又调用了发送函数。如果确认代码没问题,再查一下GPIO复用功能是否配好。

问题2:发送时偶尔丢字节

最常见的原因是发送缓冲区在数据没完全发出时就被修改了。比如你在主循环里给某个全局数组填数据,然后调用DMA发送,但调用前没有检查上一次DMA是否完成,导致DMA搬的是“还没写好”的数据,或者上一次DMA还在搬,你已经开始改数组内容。解决办法就是发送前严格判断发送完成标志,甚至用一个互斥锁标志位来保证缓冲区在被DMA读取期间不允许主循环写入。

另外一个隐藏原因是HAL_UART_Transmit_DMA调用后立即往缓冲区写数据,这时DMA通道已经被配置为从缓冲区读取数据,但还没搬完。加一个“清理”或“等待”操作,或者在发送前阻塞等待上一次发送完成,都可以解决。

问题3:接收不定长帧时,数据收不全或多收

凡是涉及到不定长接收,只靠HAL_UART_Receive_IT和HAL_UART_Receive_DMA都是不够的,因为这两个接口都是按你指定的固定长度来触发回调的。如果你指定接收128字节,那么必须收满128字节才回调。不定长接收就两个主流方案:一是每收到一字节进一次中断,自己判断帧头和帧尾;二是用DMA+IDLE空闲中断,在线路空闲时说明一帧数据收完了,再去缓冲里解析。后者效率高,是当前推荐的做法,但要注意接收完一帧后重新开启接收,并处理好缓冲区的环形逻辑。

问题4:中断接收后系统死机或卡死

先检查回调函数里是否有HAL_Delay、printf、HAL_UART_Transmit这些危险操作。它们在中断上下文里都可能因为优先级问题导致系统卡住。其次检查中断回调中是否对同一个串口再次调用HAL_UART_Receive_IT,如果调用后紧接着又来了新数据,而回调还没有返回,可能会造成嵌套中断和资源竞争,导致状态错乱。最保险的方式是回调里只置标志,重新开启接收的工作放到主循环里做。

问题5:DMA接收一次之后不再接收

DMA不是“自动重新装载”的,每次接收完成之后,DMA通道就处于禁用状态,必须由你再次调用HAL_UART_Receive_DMA来重启通道。很多人只在初始化时调用了一次接收函数,然后发现只收了一轮就再也不触发回调,就是这个原因。除了在主循环里重新开启接收外,还可以使用DMA的正常模式配合IDLE中断重新装载,或者使用循环模式(Circular)避免每次手动重新配置,但循环模式又牵扯到数据长度怎么判断,需要额外处理。

问题6:误码率高、大量乱码

首先确认波特率匹配,发送端与接收端的波特率误差不超过2%一般没问题,但如果你把外部晶振的起振失败、内部RC的误差、或者分频配置搞错了,误码率就上来了。其次检查地线连接,串口通信对地线要求很高,两个设备之间地电位差过大时容易出现误码。再看你的电平转换电路,如果是TTL和RS232之间直接接,电平不完全匹配,也会导致数据乱掉。

6.2 不定长接收的DMA+IDLE实操方案

既然问题3高频出现,我就把这个方案单独拿出来演示。不定长接收的标准解法是:使用UART的IDLE(空闲)中断来感知“线路已经空闲一段时间”,结合DMA的接收缓冲与用户缓冲数组,实现“收到一段空闲就触发一次事件”。

HAL库的扩展接口把这个方案封装成了一个函数:

HAL_StatusTypeDef HAL_UARTEx_ReceiveToIdle_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);

这个接口做的事情是:开启DMA接收,同时使能UART空闲中断。当DMA接收缓冲区满,或者UART线路空闲(即一帧数据结束)时,HAL库都会触发HAL_UARTEx_RxEventCallback。注意,这个回调跟普通的RxCpltCallback不一样,它多了一个Size参数,告诉你当前实际接收了多少字节。利用这个Size,你就能知道一帧不定长数据的真实长度。

一个最小实现如下:

uint8_t rx_buf[256]; volatile uint8_t rx_flag = 0; uint16_t rx_len = 0; int main(void) { // ... 初始化所有外设 ... HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf)); while (1) { if (rx_flag) { rx_flag = 0; handle_frame(rx_buf, rx_len); // 解析一帧 memset(rx_buf, 0, rx_len); HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf)); // 必须重启 } } } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { rx_flag = 1; rx_len = Size; } }

这个方案有几个经验点要写出来。

第一个经验点是:中断里不要马上清零缓冲区,因为主循环可能还在使用。上面的代码是在主循环里memset清零,而不是在回调里,这样更安全。当然,如果你的协议已经涵盖了长度字段,甚至不需要清缓冲区,直接用长度覆盖写即可。

第二个经验点是:接收缓冲数组的大小要与通信协议的最大帧长度匹配。如果你把sizeof(rx_buf)设成256,但协议里最长一帧只有50字节,那HAL_UARTEx_ReceiveToIdle_DMA会一直开着DMA,数据到了50字节也不会触发RxEventCallback;只有当线路空闲了才会触发回调,所以不会丢数据。但如果数据一直不停流,中间没有空闲中断,就会一直积累到256字节然后自动触发回调。触发时Size就是256。此时如果协议帧大于256,就存在数据截断的风险。正确做法是缓冲区大小取“协议最大帧长+适当冗余”。

第三个经验点是:开启HAL_UARTEx_ReceiveToIdle_DMA之后,空闲中断和DMA完成中断都会触发同一个RxEventCallback,所以这个回调的处理逻辑一定要考虑到两种来源。判断业务上通常不用区分,因为你只要拿到Size和缓冲里的数据就能判断是不是完整的一帧。

最后是关于中断函数的“复位接收”顺序。如果你在回调里直接再次调用HAL_UARTEx_ReceiveToIdle_DMA,由于此时还在中断上下文,重新配置DMA也是安全的,但我仍然建议把重新开启的工作放到主循环。因为放在回调里容易出现一种场景:上一帧的数据还没处理完,新一帧数据又触发了回调,rx_buf被新数据覆盖,上一帧内容就丢了。主循环里先置标志、再处理、再重启DMA接收,这才能保证一帧处理完之前不会因为新数据覆盖而丢失。

6.3 模式选择的实用决策依据

讲到这里,把四种模式的选择标准总结一下。我不爱用那种一看就是“大而全”的废话总结,直接按照你项目里最关键的约束条件来给建议。

如果你的系统只有一个简单的数据上报任务,CPU当前都在空转,那阻塞模式已经完全够用,写起来还简单,何必为了“看起来高级”去上DMA。

如果系统里有多任务、多外设并行,串口数据不能丢失,那中断模式是最接地气的选择。它比DMA容易调试,但中断次数多,如果数据量很大,CPU会被频繁打断,这时候再切DMA。

如果数据量大、频率高,或者你需要在CPU睡眠时也串口能做后台收发,DMA模式是必然选择。DMA配合IDLE中断做不定长接收,基本覆盖了90%以上的物联网设备串口交互需求。

但如果项目里串口使用的是板载外设与内部存储器之间的高吞吐传输,很多特殊芯片有“专用的UART DMA”请求号,配置时务必检查DMA请求映射,别不小心复用了别的外设的通道。

我个人的经验是:阻塞模式能跑通的最简单系统,先用阻塞;阻塞满足不了实时性,换中断;中断导致CPU太忙,再上DMA。这样一步步升级,每一步都基于明确的性能指标来做判断,而不是一开始就上一套复杂得连自己都调不稳的DMA方案。

7. 写在最后的几点体会

每次写串口教程,我心里都有一种复杂的熟悉感。这东西看着极其基础,可是工作中几乎每个工程师都因为串口卡过壳。我在最初接触HAL库UART时,也曾在阻塞、中断、DMA之间反复切换,写出来的代码比寄存器版还乱。后来慢慢才意识到,HAL库并不是让你无脑调三套API,而是逼着你在动手前先想清楚一个最本质的问题:数据来了,我要让CPU以什么方式去接?

把这个问题想明白,选模式就变成了水到渠成的事。发送数据量小,直接阻塞,反正就那么几个字节;发送任务不阻塞系统流程,用中断;数据吞吐需求高、或要一边收发一边跑别的任务,上DMA。而不管是哪套模式,回调函数都只当“值班员”——登记一下事件就走,真正的干活活儿交给主循环。这个主从关系理顺了,串口代码基本不会再出乱七八糟的幺蛾子。

还有一个小技巧想分享给正在调试的朋友:不管用什么模式,第一版跑通前先用固定字节数和简单波形验证硬件链路。拔掉串口助手,用逻辑分析仪看波形是最省心的;如果波形正常但数据乱码,那就是波特率或者电平匹配问题;如果波形根本不动,再回头查GPIO复用和DMA配置。很多看起来异常玄学的串口问题,排查到最后都落脚在基本配置上。

这篇文章把HAL库UART的四种传输方式都覆盖到了,代码部分可以参考CubeMX生成的工程自行适配。如果你现在正在做一个小项目,而串口收发是中间最让你头疼的一环,希望这篇文章能让你少走几步冤枉路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询