目录
- 前言
- 一、串口全双工硬件与单线程代码的冲突
-
- [1.1 硬件全双工与软件串行的矛盾](#1.1 硬件全双工与软件串行的矛盾)
- [1.2 为什么发送超时时间要设为 0](#1.2 为什么发送超时时间要设为 0)
-
- [1.2.1 实验一:非阻塞发送(超时设为 0)](#1.2.1 实验一:非阻塞发送(超时设为 0))
- [1.2.2 实验二:加入人为延时与数据丢失复现](#1.2.2 实验二:加入人为延时与数据丢失复现)
- [二、自定义按行读取函数 ReadLine 的设计与实现](#二、自定义按行读取函数 ReadLine 的设计与实现)
-
- [2.1 为什么需要"按行"处理](#2.1 为什么需要"按行"处理)
-
- [2.1.1 行分隔符的概念](#2.1.1 行分隔符的概念)
- [2.2 串口调试工具的回车换行符差异](#2.2 串口调试工具的回车换行符差异)
- [2.3 ReadLine 核心代码逻辑解析](#2.3 ReadLine 核心代码逻辑解析)
-
- [2.3.1 关键逻辑剖析:](#2.3.1 关键逻辑剖析:)
- [2.4 为什么整体发送时可以安全使用 MAX_DELAY](#2.4 为什么整体发送时可以安全使用 MAX_DELAY)
- 结语


🎬 云泽Q :个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列》《笔试算法》《AI赋能》《STM32》《Python》
⛺️遇见安然遇见你,不负代码不负卿~
前言
大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~
一、串口全双工硬件与单线程代码的冲突
1.1 硬件全双工与软件串行的矛盾
在实际看大佬编写串口通信代码时,我们经常会遇到一个设计上的细节问题:为什么在一次读取一个字节的场景下,读取函数 HAL_UART_Receive 的超时时间设置的是 HAL_MAX_DELAY,而发送函数 HAL_UART_Transmit 的超时时间却要设置成 0?
要理解这个问题,我们需要先理清硬件和软件执行方式上的一个核心冲突。
首先,USART 硬件本身是支持全双工通信的。也就是说,在物理层面上,MCU 的 USART 外设和上位机(串口设备)之间是可以同时进行发送(TX)和接收(RX)的。硬件的移位寄存器和数据寄存器是相互独立的,既能收,也能发。

但是,你心里必须要很清楚一点:硬件虽然能全双工,但你的代码在执行时是单线程串行执行的。
什么叫代码串行执行?就是你的 MCU 程序必须得先读到数据,然后你才能再去发数据。你必须遵循"读一个,发一个"或者"发一个,读一个"的顺序。由于代码是串行执行的,这就带来了一个潜在的隐患:如果你今天读到了一批数据,然后程序开始进入发送阶段,如果在发送期间耗时稍微久一点,就会出现问题。
我们知道,在阻塞式发送时,程序是要等待 TC(发送完成)标志位的。发送是通过发送数据寄存器一位一位把数据往出移的。比如发送一帧数据,可能需要 9 个、10 个甚至 11 个时钟周期来传输高低电平。
在这个发送过程中,底层硬件的接收模块其实并没有停下来,它依然在默默地接收上位机发来的新数据。但是,由于你的代码此时卡在发送逻辑里,你根本没有机会去调用接收函数把数据读走。
如果对方(上位机)发送数据的速度非常快,而我们底层来不及调用接收函数,数据就会堆积。USART 的接收端只有两个关键的寄存器:一个是移位寄存器,另一个是 RDR(接收数据寄存器)。如果发送的时间太长,上位机又连续发数据,新来的数据就会覆盖掉 RDR 里还没来得及被读走的旧数据,最终导致数据丢失。
我们用代码来具体说明这个问题。当使用阻塞方式读取一个字节时:
c
if (HAL_OK == HAL_UART_Receive(&huart1, &recv_byte, 1, HAL_MAX_DELAY)) // 阻塞方式
{
// HAL_Delay(100); // 测试
HAL_UART_Transmit(&huart1, &recv_byte, 1, HAL_MAX_DELAY); // CPU 等待
}
这里第三个参数 1 表示发送次数,也就是要发送的数据量 ,即只发送 1 个字节。当超时时间设为 HAL_MAX_DELAY 时,CPU 会一直等待,直到这 1 个字节完全发送完毕(TC 标志位置位)。
- 程序通过
HAL_UART_Receive阻塞读取到一个字节; - 进入
HAL_UART_Transmit后,CPU 开始等待发送完成; - 在发送期间,底层硬件的接收模块并没有停下来,它依然在通过 RX 引脚接收上位机发来的新数据;
- 但是,由于你的代码此时卡在发送逻辑里,CPU 一直在等待发送完成,你根本没有机会去调用接收函数把新到的数据读走;
- 如果上位机发送数据的速度非常快,新数据会不断涌入接收移位寄存器并转移到 RDR;
- 如果 RDR 中的数据还没被 CPU 读走,新的数据又到了,就会发生 数据覆盖( overrun ),导致数据丢失。
1.2 为什么发送超时时间要设为 0
为了验证上述的硬件与软件冲突问题,我们可以通过两组代码对比实验来直观地观察现象。
1.2.1 实验一:非阻塞发送(超时设为 0)
在第一种代码设计中,我们在主循环中采用阻塞方式读取一个字节(超时设为 HAL_MAX_DELAY),读到后立即通过非阻塞方式发送(超时设为 0):
c
/* USER CODE BEGIN PV */
uint8_t recv_byte = 0; // 缓冲区
uint32_t cnt = 0; // 灯的反转计数
/* USER CODE END PV */
/* USER CODE BEGIN WHILE */
while (1)
{
if(HAL_OK == HAL_UART_Receive(&huart1, &recv_byte, 1, HAL_MAX_DELAY)) // 阻塞方式读取一个字节
{
// 读到字节后立即发送,超时时间设为 0(非阻塞/立即返回)
HAL_UART_Transmit(&huart1, &recv_byte, 1, 0);
}
cnt++;
if(cnt >= 10)
{
cnt = 0;
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);
}
}
/* USER CODE END WHILE */
在这种设计下,由于 HAL_UART_Transmit 的超时时间被设为了 0,函数会立即将数据写入发送寄存器并返回,不会等待 TC 标志位。这就最大程度地减少了 CPU 在发送阶段的阻塞时间,让程序能够以极快的速度回到 HAL_UART_Receive 继续等待下一帧数据的到来。
在这种高速回显的场景下,代码运行非常稳定,发送和接收都能保持同步,没有出现数据丢失。
1.2.2 实验二:加入人为延时与数据丢失复现
为了模拟发送耗时过长的情况,我们在读取到字节后、发送之前,人为加入一个 100 毫秒的延时 HAL_Delay(100):
c
/* USER CODE BEGIN WHILE */
while (1)
{
if(HAL_OK == HAL_UART_Receive(&huart1, &recv_byte, 1, HAL_MAX_DELAY)) // 阻塞方式读取一个字节
{
HAL_Delay(100); // 模拟发送耗时,人为阻塞 100ms
// 超时时间依然设为 0
HAL_UART_Transmit(&huart1, &recv_byte, 1, 0);
}
}
/* USER CODE END WHILE */


这时候我们打开串口调试工具(如 COMTool),向单片机连续发送数据,比如输入 1234567。
在串口助手中,我们会观察到以下现象:
- 上位机发送(
=>):1234567 - 单片机回显(
<=):12 - 上位机再次发送(
=>):1234567 - 单片机回显(
<=):12
底部的状态栏统计数据显示,已发送字节数为 28,而已接收字节数只有 8。很明显,由于每读取一个字节后程序都被强行阻塞了 100ms,在这个期间上位机发送的后续字节(如 34567)在 USART 底层硬件中因为无人接收而被溢出丢弃,最终只成功回显了最前面的 12。
这就从实验层面证实了:如果在代码串行执行的过程中,发送或处理逻辑耗时过长,必然会导致底层接收数据丢失。这就是为什么在单字节实时回显的场景下,发送函数的超时时间不能设置为阻塞(HAL_MAX_DELAY),甚至要尽量避免在收发之间插入长延时。
二、自定义按行读取函数 ReadLine 的设计与实现
虽然单字节实时回显需要避免阻塞,但在实际的工程应用中,我们往往需要一次性读取完整的一行数据(例如用户输入了一条指令并按下了回车),然后再进行统一的处理和回复。
为了解决这个问题,我们可以自定义一个 ReadLine 函数来实现"按行读取"。
2.1 为什么需要"按行"处理
在实际的串口通信中,我们往往不知道该发送的一行数据究竟有多少个字节。比如上位机可能发送 123456789,也可能只发送 12,长度是不确定的。如果我们仍然采用逐字节处理的方式,程序逻辑会变得非常复杂,而且容易出现前面分析的数据丢失问题。
因此,我们需要对 HAL_UART_Receive 进行封装,实现一个按行读取的功能。
2.1.1 行分隔符的概念
要实现按行读取,首先要明确一个核心问题:程序怎么知道一行数据已经结束了?
答案是:一行数据一定以行分隔符结束 。
常见的行分隔符有两种形式:
\n(Line Feed,换行符,LF)\r\n(Carriage Return + Line Feed,回车换行,CRLF)
举个例子,如果上位机连续发送三行数据:
c
123换行符
456换行符
789换行符
那么实际在串口线上传输的字节序列中,每一行的末尾都会携带一个行分隔符。我们的程序只需要逐字节读取,当检测到行分隔符时,就知道"这一行已经完整接收完毕了",然后就可以进行整行数据的处理。
2.2 串口调试工具的回车换行符差异
接下来必须搞清楚一个底层细节:当我们用串口助手发送数据并按下回车时,底层到底发送了什么?
在常见的串口调试工具(如 COMTool)的发送设置中,有一个 <CRLF> 的选项。

- 如果勾选了
<CRLF>,意味着当你按下回车键时,工具会自动在数据末尾附加两个控制字符:回车符\r(Carriage Return,十六进制为 0x0D)和换行符\n(Line Feed,十六进制为 0x0A)。 - 如果没有勾选,只会发送一个
\n。
在我们的设计中,通常以 Windows 串口工具默认的 \r\n 组合或者单纯的 \n 作为"一行数据结束"的标志。
2.3 ReadLine 核心代码逻辑解析
为了实现按行读取,我们在程序的全局变量区定义了一个缓冲区 inbuffer,并在自定义函数 ReadLine 中循环调用 HAL_UART_Receive 来逐个读取字节,直到检测到行结束符:
c
/* Private variables ---------------------------------------------------------*/
UART_HandleTypeDef huart1;
/* USER CODE BEGIN PV */
uint8_t recv_byte = 0; // 缓冲区
uint32_t cnt = 0; // 灯的反转
uint8_t inbuffer[INBUFFER_NUM]; // 自定义行读取缓冲区
/* USER CODE END PV */
/* Private function prototypes -----------------------------------------------*/
// ... 其他初始化函数 ...
uint8_t ReadLine(UART_HandleTypeDef *huart); // ReadLine 函数声明
/* USER CODE END PFP */
下面是 ReadLine 函数的具体实现逻辑:
c
/**
* @brief 阻塞式读取一行数据,以 '\n' 或 '\r' 结尾
* @param huart: 串口句柄指针
* @retval 成功读取的字节数(不含回车换行符),若缓冲区溢出则返回 0
*/
uint8_t ReadLine(UART_HandleTypeDef *huart)
{
uint8_t ch;
uint8_t total = 0; // 记录当前读取的有效字节数
while(1)
{
// 1. 阻塞读取一个字节
// 12345\r\n
HAL_UART_Receive(huart, &ch, 1, HAL_MAX_DELAY);
// 2. 判断是否为换行符 '\n'
if(ch == '\n')
{
// 如果是 '\n',说明一行结束
inbuffer[total] = '\0'; // 字符串末尾添加结束符
return total; // 返回有效数据长度
}
// 3. 判断是否为回车符 '\r'
if(ch == '\r')
{
// 很多工具发送的是 "\r\n",如果读到了 '\r',通常后面紧跟着一个 '\n'
// 我们需要再调用一次接收,把 '\n' 消耗掉,防止它干扰下一次读取
HAL_UART_Receive(huart, &ch, 1, HAL_MAX_DELAY); // 消耗掉 \n
inbuffer[total] = '\0'; // 字符串末尾添加结束符
return total; // 返回有效数据长度
}
// 4. 缓冲区溢出保护
if(total >= INBUFFER_NUM)
{
// 如果读取的字节数达到了缓冲区上限,说明数据异常或过长
// 为了安全,直接返回 0,并在主逻辑中丢弃该帧数据
return 0;
}
// 5. 将读取到的有效字符存入缓冲区,索引自增
inbuffer[total++] = ch;
}
}


2.3.1 关键逻辑剖析:
- 阻塞接收 :这里读取单个字节依然使用了
HAL_MAX_DELAY。因为我们在等待用户输入一整行,用户在键盘上敲击字符是有延迟的,所以我们必须无限期等待下一个字节的到来。 - 回车符
\r的特殊处理 :当检测到\r时,我们并没有直接结束。因为串口助手勾选了<CRLF>,所以在\r后面必然还有一个\n。如果此时直接return,那么这个残留的\n就会留在 USART 的接收寄存器里。下一次调用ReadLine时,一上来就会读到这个\n,导致函数误判直接返回一个空字符串。因此,在遇到\r时,程序会再执行一次HAL_UART_Receive,专门把后面的\n读走并丢弃(消耗掉)。 - 防溢出机制 :通过判断
total >= INBUFFER_NUM,避免了由于上位机长时间不发送回车符而导致的缓冲区溢出(数组越界)问题。
2.4 为什么整体发送时可以安全使用 MAX_DELAY
有了 ReadLine 函数后,我们在主循环中的逻辑就变成了"读取一整行,然后回复一整行":
c
/* USER CODE BEGIN WHILE */
while (1)
{
// version2: 按行读取
uint8_t num = ReadLine(&huart1); // 阻塞读取一行数据
if(num > 0)
{
// 将读取到的一整行数据原样发送回去
// 此时发送超时时间可以安全地设置为 HAL_MAX_DELAY
HAL_UART_Transmit(&huart1, inbuffer, num, HAL_MAX_DELAY);
// 顺便翻转 LED 灯作为执行成功的指示
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);
}
}
/* USER CODE END WHILE */
在这个版本中,HAL_UART_Transmit 的超时时间被重新设置为了 HAL_MAX_DELAY,而且我们也没有再加入任何 100ms 的延时。为什么这次不会发生数据丢失了呢?
这得益于我们设计的一问一答式通信协议:
- 从单片机(MCU)的角度看:它是"收一个完整的包(一行),然后再发一个完整的包"。
- 从上位机的角度看:它是"发一个完整的包,然后等待单片机回复,收到回复后再发下一个包"。
在这个流程中,当单片机进入 HAL_UART_Transmit 进行阻塞发送时,上位机是处于等待接收状态的,它并不会在这个期间向单片机发送任何新数据。既然上位机没有发新数据,单片机的 USART 底层接收端自然就不会有新数据涌入,也就根本不存在接收缓冲区被覆盖和数据丢失的问题。
通过这种逻辑上的收发同步控制(应用层握手),我们成功化解了硬件全双工与代码串行执行的冲突,既保证了数据读取的完整性,又让代码逻辑变得清晰可控。
完整的数据流向:
- 上位机发送:你在 COMTool 的输入框里敲入"1234567",按下回车,数据通过 USB/串口线从 TX 引脚发出。
- MCU 硬件接收 :数据到达 MCU 的 RX 引脚,USART 外设的移位寄存器 逐位接收,收满一帧(比如 8 位)后,数据被转移到 RDR(接收数据寄存器)。这个过程完全由硬件自动完成。
- 软件函数读取 :当程序执行到
HAL_UART_Receive(&huart1, &recv_byte, 1, HAL_MAX_DELAY)时,这个函数会从 RDR 寄存器中把数据读出来,存入你定义的变量recv_byte(或者inbuffer数组)。 - 程序处理数据:你的代码可以对读到的数据做任何处理,比如逐字节回显、按行拼接、做逻辑判断(比如收到"1"就翻转 LED 灯)。
- MCU 硬件发送 :当调用
HAL_UART_Transmit(&huart1, &recv_byte, 1, 0)时,数据被写入 TDR(发送数据寄存器),USART 硬件自动将它一位一位地从 TX 引脚移出去。 - 上位机接收显示:数据通过串口线回到 COMTool 的 RX 端,工具将接收到的字节按 ASCII 显示出来。
硬件寄存器(RDR/TDR)是第一道关卡,软件缓冲区(recv_byte、inbuffer)是代码层面的容器。 HAL_UART_Receive 的作用就是把硬件寄存器的数据搬运到软件缓冲区,HAL_UART_Transmit 则是反方向搬运。
结语
