STM32 USART 详解(七):超时设置、非阻塞发送与 ReadLine 实现

目录

  • 前言
  • 一、串口全双工硬件与单线程代码的冲突
    • [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 标志位置位)。

  1. 程序通过 HAL_UART_Receive 阻塞读取到一个字节;
  2. 进入 HAL_UART_Transmit 后,CPU 开始等待发送完成;
  3. 在发送期间,底层硬件的接收模块并没有停下来,它依然在通过 RX 引脚接收上位机发来的新数据;
  4. 但是,由于你的代码此时卡在发送逻辑里,CPU 一直在等待发送完成,你根本没有机会去调用接收函数把新到的数据读走;
  5. 如果上位机发送数据的速度非常快,新数据会不断涌入接收移位寄存器并转移到 RDR;
  6. 如果 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 关键逻辑剖析:

  1. 阻塞接收 :这里读取单个字节依然使用了 HAL_MAX_DELAY。因为我们在等待用户输入一整行,用户在键盘上敲击字符是有延迟的,所以我们必须无限期等待下一个字节的到来。
  2. 回车符 \r 的特殊处理 :当检测到 \r 时,我们并没有直接结束。因为串口助手勾选了 <CRLF>,所以在 \r 后面必然还有一个 \n。如果此时直接 return,那么这个残留的 \n 就会留在 USART 的接收寄存器里。下一次调用 ReadLine 时,一上来就会读到这个 \n,导致函数误判直接返回一个空字符串。因此,在遇到 \r 时,程序会再执行一次 HAL_UART_Receive,专门把后面的 \n 读走并丢弃(消耗掉)。
  3. 防溢出机制 :通过判断 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 的延时。为什么这次不会发生数据丢失了呢?

这得益于我们设计的一问一答式通信协议

  1. 从单片机(MCU)的角度看:它是"收一个完整的包(一行),然后再发一个完整的包"。
  2. 从上位机的角度看:它是"发一个完整的包,然后等待单片机回复,收到回复后再发下一个包"。

在这个流程中,当单片机进入 HAL_UART_Transmit 进行阻塞发送时,上位机是处于等待接收状态的,它并不会在这个期间向单片机发送任何新数据。既然上位机没有发新数据,单片机的 USART 底层接收端自然就不会有新数据涌入,也就根本不存在接收缓冲区被覆盖和数据丢失的问题。

通过这种逻辑上的收发同步控制(应用层握手),我们成功化解了硬件全双工与代码串行执行的冲突,既保证了数据读取的完整性,又让代码逻辑变得清晰可控。

完整的数据流向:

  1. 上位机发送:你在 COMTool 的输入框里敲入"1234567",按下回车,数据通过 USB/串口线从 TX 引脚发出。
  2. MCU 硬件接收 :数据到达 MCU 的 RX 引脚,USART 外设的移位寄存器 逐位接收,收满一帧(比如 8 位)后,数据被转移到 RDR(接收数据寄存器)。这个过程完全由硬件自动完成。
  3. 软件函数读取 :当程序执行到 HAL_UART_Receive(&huart1, &recv_byte, 1, HAL_MAX_DELAY) 时,这个函数会从 RDR 寄存器中把数据读出来,存入你定义的变量 recv_byte(或者 inbuffer 数组)。
  4. 程序处理数据:你的代码可以对读到的数据做任何处理,比如逐字节回显、按行拼接、做逻辑判断(比如收到"1"就翻转 LED 灯)。
  5. MCU 硬件发送 :当调用 HAL_UART_Transmit(&huart1, &recv_byte, 1, 0) 时,数据被写入 TDR(发送数据寄存器),USART 硬件自动将它一位一位地从 TX 引脚移出去。
  6. 上位机接收显示:数据通过串口线回到 COMTool 的 RX 端,工具将接收到的字节按 ASCII 显示出来。

硬件寄存器(RDR/TDR)是第一道关卡,软件缓冲区(recv_byte、inbuffer)是代码层面的容器。 HAL_UART_Receive 的作用就是把硬件寄存器的数据搬运到软件缓冲区,HAL_UART_Transmit 则是反方向搬运。


结语

相关推荐
2401_862880822 小时前
UART 通信协议
单片机·51单片机
一条破秋裤3 小时前
24_MPU6050结构参数与寄存器基础
stm32·嵌入式硬件·学习
沐欣工作室_lvyiyi3 小时前
矿井安全监测系统设计(论文+源码)
单片机·无线通信·云平台·物联网毕业设计·矿井安全监测系统·机智云
ZLG_zhiyuan3 小时前
低空测试“升维”:ZUS系列示波器如何破解复杂信号的“测与析”难点?
单片机·嵌入式硬件
..Dauntless..4 小时前
【stm32】GPIO(下篇)——统一编址、源码理解和输入模式
stm32·单片机·嵌入式硬件
youyou-06064 小时前
AUTOSAR‑COM 常见问题 QA (1)
java·linux·服务器·单片机·汽车
云边有个稻草人6 小时前
Tera Term 鸿蒙 PC 适配全记录:用 ArkUI 与 Native C++ 重建多协议终端工作流
c++·stm32·harmonyos
wuyk5556 小时前
从零吃透 MQTT 通信|第 10 章 阿里云 / 腾讯云 MQTT 设备完整对接实战,三元组、签名、设备上云调试
c语言·开发语言·stm32·学习·阿里云·云计算·腾讯云
白搞电子6 小时前
ESP-Mosaico 桌宠为什么会误睡?AS312 PIR 原理、休眠逻辑与排错方法
单片机·嵌入式硬件·动画