目录
- 前言
- [一、 USART1 串口物理地址与底层映射机制](#一、 USART1 串口物理地址与底层映射机制)
-
- [1.1 物理地址的层层推导](#1.1 物理地址的层层推导)
- [1.2 片上外设的寄存器结构体映射](#1.2 片上外设的寄存器结构体映射)
- [1.3 HAL 库的句柄封装机制](#1.3 HAL 库的句柄封装机制)
- [二、 USART 串口初始化核心流程剖析](#二、 USART 串口初始化核心流程剖析)
-
- [2.1 硬件层初始化:HAL_UART_MspInit 详解](#2.1 硬件层初始化:HAL_UART_MspInit 详解)
-
- [2.1.1 开启外设时钟](#2.1.1 开启外设时钟)
- [2.1.2 GPIO 引脚配置](#2.1.2 GPIO 引脚配置)
- [2.2 参数配置与寄存器写入:UART_SetConfig 详解](#2.2 参数配置与寄存器写入:UART_SetConfig 详解)
-
- [2.2.1 配置停止位(CR2 寄存器)](#2.2.1 配置停止位(CR2 寄存器))
- [2.2.2 配置字长、校验与收发使能(CR1 寄存器)](#2.2.2 配置字长、校验与收发使能(CR1 寄存器))
- 三、UART阻塞式接收(Receive)机制
-
- [3.1 接收核心依赖:寄存器与标志位](#3.1 接收核心依赖:寄存器与标志位)
- [3.2 接收接口调用与参数解析](#3.2 接收接口调用与参数解析)
- [3.3 接收源码逐段解析](#3.3 接收源码逐段解析)
- 五、UART阻塞式发送(Transmit)机制
-
- [5.1 发送核心依赖:寄存器与标志位](#5.1 发送核心依赖:寄存器与标志位)
- [5.2 发送接口调用与参数解析](#5.2 发送接口调用与参数解析)
- [5.3 发送源码逐段解析](#5.3 发送源码逐段解析)
- [六、 Timeout 参数的详细解析](#六、 Timeout 参数的详细解析)
-
- [6.1 源码层面的剖析](#6.1 源码层面的剖析)
-
- [6.1.1 时间戳获取](#6.1.1 时间戳获取)
- [6.1.2 核心的阻塞式轮询与数据读取](#6.1.2 核心的阻塞式轮询与数据读取)
- [七、 Timeout 参数的深度剖析与取值行为](#七、 Timeout 参数的深度剖析与取值行为)
-
-
- [7.1 HAL_MAX_DELAY(0xFFFFFFFF):永久阻塞](#7.1 HAL_MAX_DELAY(0xFFFFFFFF):永久阻塞)
- [7.2 0:非阻塞读取](#7.2 0:非阻塞读取)
- [7.3 具体数值(如 1000ms):限时阻塞等待](#7.3 具体数值(如 1000ms):限时阻塞等待)
-
- [八、 阻塞、非阻塞与轮询的生活化通俗比喻](#八、 阻塞、非阻塞与轮询的生活化通俗比喻)
- 结语


🎬 云泽Q :个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列》《笔试算法》《AI赋能》《STM32》《Python》
⛺️遇见安然遇见你,不负代码不负卿~
前言
大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~
一、 USART1 串口物理地址与底层映射机制
在深入分析 USART 串口的源码时,我们首先要解决一个核心问题:片上外设是如何在代码中被寻址的。所有片上外设(包括串口、内存、Flash 等)在 Cortex-M3 架构中都是统一编址的。这意味着每一个串口都拥有其专属的物理地址,我们需要通过源码一层层将其挖掘出来。
1.1 物理地址的层层推导
在我们的基本代码中,当我们使用 USART1 时,它实际上是一个宏定义。我们通过"右键转到定义"的操作,可以像剥洋葱一样,顺着源码的引用链找到它的物理真身。
整个推导过程如下:
-
在源码中找到
USART1宏定义,它被强转为了一个结构体指针:c#define USART1 ((USART_TypeDef *) USART1_BASE) -
继续追踪
USART1_BASE,它是基于 APB2 总线外设基地址叠加了一个偏移量:c#define USART1_BASE (APB2PERIPH_BASE + 0x00003800UL) -
接着,我们去找
APB2PERIPH_BASE,它又是基于更基础的外设基地址PERIPH_BASE叠加偏移量:c#define APB2PERIPH_BASE (PERIPH_BASE + 0x00010000UL) -
最后,我们找到最底层的基地址
PERIPH_BASE:c#define PERIPH_BASE ((uint32_t)0x40000000)
将上述层层嵌套的基地址与偏移量相加,我们就可以计算出 USART1 的物理地址:
0x40000000 (PERIPH_BASE) + 0x00010000 (APB2 偏移) + 0x00003800 (USART1 偏移) = 0x40013800。
这就意味着,USART1 的所有寄存器,都是从物理内存地址 0x40013800 开始往后排布的。

1.2 片上外设的寄存器结构体映射
找到了基地址 0x40013800 后,代码通过 (USART_TypeDef *) 将其强转为了结构体指针。这种设计极其精妙,它将连续的物理地址与 C 语言中的结构体成员一一对应了起来。
查看 USART_TypeDef 结构体的定义,你会发现它内部的寄存器排列顺序与官方参考手册中的寄存器偏移地址是严格对应的:
c
typedef struct
{
__IO uint32_t SR; /*!< USART Status register, Address offset: 0x00 */
__IO uint32_t DR; /*!< USART Data register, Address offset: 0x04 */
__IO uint32_t BRR; /*!< USART Baud rate register, Address offset: 0x08 */
__IO uint32_t CR1; /*!< USART Control register 1, Address offset: 0x0C */
__IO uint32_t CR2; /*!< USART Control register 2, Address offset: 0x10 */
__IO uint32_t CR3; /*!< USART Control register 3, Address offset: 0x14 */
__IO uint32_t GTPR; /*!< USART Guard time and prescaler register, Address offset: 0x18 */
} USART_TypeDef;
通过这种方式,我们在 C 语言中只需访问 USART1->CR1,编译器就会自动帮我们定位到 0x40013800 + 0x0C 即 0x4001380C 这个物理地址去读写数据。
1.3 HAL 库的句柄封装机制
在 HAL 库中,ST 官方并没有让我们直接裸写寄存器,而是对片上外设做了一层更高级的类型封装。引入了一个名为 UART_HandleTypeDef 的句柄结构体,STM32CubeMX生成的代码就在 main.c 中会定义一个全局句柄变量 UART_HandleTypeDef huart1;。
这个句柄结构体主要包含以下两个核心部分:
c
typedef struct
{
USART_TypeDef *Instance; /*!< UART registers base address */
UART_InitTypeDef Init; /*!< UART communication parameters */
// ... 其他状态和收发缓冲区指针 ...
} UART_HandleTypeDef;
Instance:这是一个指针,指向具体的USART_TypeDef结构体。在初始化时,我们会执行huart1.Instance = USART1;,从而将这个句柄与物理地址0x40013800绑定。Init:这是一个UART_InitTypeDef类型的嵌套结构体,里面存放了用户需要配置的各项参数,例如波特率(BaudRate)、数据字长(WordLength)、停止位(StopBits)、校验位(Parity)、硬件流控(HwFlowCtl)以及过采样率(OverSampling)。
这种封装方式的好处是,无论操作的是 USART1、USART2 还是 USART3,HAL 库底层的 API 只需要接收这个句柄的指针,就能知道当前要操作的是哪个物理外设,以及该用什么样的参数去配置。
二、 USART 串口初始化核心流程剖析
在 main 函数中,串口初始化的完整链路是从 MX_USART1_UART_Init() 函数开始的。梳理其内部执行逻辑,主要包含以下几个核心步骤:
- 实例绑定与参数填充 :将
USART1物理基地址赋给句柄实例,并根据用户在 STM32CubeMX 中的设置(如 115200 波特率、9位数据位、奇校验、1位停止位等),填充huart1.Init结构体。 - 底层硬件初始化 :调用
HAL_UART_Init(),该函数内部会调用弱定义的HAL_UART_MspInit(),用来开启时钟和配置 GPIO 引脚。 - 寄存器参数写入 :时钟和引脚就绪后,调用
UART_SetConfig(),将Init结构体中的软件配置转化为具体的二进制值,写入到 CR1、CR2 等控制寄存器中。 - 外设使能:最后使能 USART1 的开关,正式开始工作。

2.1 硬件层初始化:HAL_UART_MspInit 详解
在 HAL_UART_Init() 内部,首先会执行 HAL_UART_MspInit(huart)。这个函数是"MCU 支持包(BSP/MSP)"级别的初始化,主要负责解决串口工作的物理前提------时钟和引脚。它的具体实现在 stm32f1xx_hal_msp.c 文件中。

2.1.1 开启外设时钟
在 STM32 中,任何外设工作前都必须开启其对应的时钟,否则无法读写其寄存器。
c
/* Peripheral clock enable */
__HAL_RCC_USART1_CLK_ENABLE();
展开这个宏,其底层是通过 SET_BIT 宏操作 RCC(复位和时钟控制)寄存器:
c
#define __HAL_RCC_USART1_CLK_ENABLE() do { \
__IO uint32_t tmpreg; \
SET_BIT(RCC->APB2ENR, RCC_APB2ENR_USART1EN); \
/* Delay after an RCC peripheral clock enabling */ \
tmpreg = READ_BIT(RCC->APB2ENR, RCC_APB2ENR_USART1EN); \
UNUSED(tmpreg); \
} while(0U)
结合参考手册,RCC_APB2ENR 是 APB2 外设时钟使能寄存器,其第 14 位(USART1EN)控制着 USART1 的时钟开关。写入 1 即可开启时钟。同时,因为串口需要用到 GPIOA 的引脚,GPIOA 的时钟也会在此处同步开启。
2.1.2 GPIO 引脚配置
时钟开启后,紧接着配置与 USART1 绑定的物理引脚(对应 STM32CubeMX 中的 GPIO Settings 页面):
- PA9(USART1_TX) :配置为复用功能推挽输出(
GPIO_MODE_AF_PP),最高速度设为 High。 - PA10(USART1_RX) :配置为输入模式(
GPIO_MODE_INPUT),无上拉无下拉(GPIO_NOPULL)。
通过 HAL_GPIO_Init(GPIOA, &GPIO_InitStruct) 函数,这些配置信息会被写入到 GPIOA 的相应寄存器(如 CRL/CRH)中,完成引脚的复用映射。
2.2 参数配置与寄存器写入:UART_SetConfig 详解
硬件时钟与引脚配置完毕后,回到 HAL_UART_Init(),紧接着会调用 UART_SetConfig(huart)。这个函数负责将我们在结构体中设置好的逻辑参数,写入到 USART 的控制寄存器中。
2.2.1 配置停止位(CR2 寄存器)
停止位的配置涉及控制寄存器 2(USART_CR2),其地址偏移为 0x10。具体控制位在 bit13:12(STOP[1:0])。
源码中通过 MODIFY_REG 宏来实现"读-改-写"操作,确保只修改目标位而不影响寄存器其他位:
c
/* Configure the UART Stop Bits: Set STOP[13:12] bits
according to huart->Init.StopBits value */
MODIFY_REG(huart->Instance->CR2, USART_CR2_STOP, (huart->Init.StopBits));
USART_CR2_STOP是清除掩码(CLEAR MASK),用来将 bit13:12 清零。huart->Init.StopBits是设置值(SET MASK)。在 CubeMX 中我们配置了 1 个停止位,对应宏UART_STOPBITS_1,其值为0x00000000U,即写入00。
2.2.2 配置字长、校验与收发使能(CR1 寄存器)
控制寄存器 1(USART_CR2)承担了更多的配置任务,其地址偏移为 0x0C。主要涉及以下控制位:

- 位 12(M - Word length) :字长控制。
0表示 8 位数据位,1表示 9 位数据位。 - 位 10(PCE - Parity control enable):校验控制使能。
- 位 9(PS - Parity selection) :校验选择。
0为偶校验,1为奇校验。 - 位 3(TE - Transmitter enable):发送使能。
- 位 2(RE - Receiver enable):接收使能。
源码中的处理逻辑如下:
c
tmpreg = (uint32_t)huart->Init.WordLength | huart->Init.Parity | huart->Init.Mode;
MODIFY_REG(huart->Instance->CR1,
(uint32_t)(USART_CR1_M | USART_CR1_PCE | USART_CR1_PS | USART_CR1_TE | USART_CR1_RE),
tmpreg);
首先,将用户在结构体中配置的字长、校验模式和收发模式(Mode)进行按位或(|)运算,拼装成一个完整的配置值 tmpreg。然后,再次使用 MODIFY_REG 宏,一次性将 CR1 寄存器中的 M、PCE、PS、TE、RE 这些关键位清空,并写入我们拼装好的 tmpreg。
例如,我们在 CubeMX 中配置了"9位数据位(含校验)"和"奇校验",那么对应的 bit12 会被置 1,bit10 会被置 1,bit9 也会被置 1。同时,由于模式配置为"Receive and Transmit",bit3(TE)和 bit2(RE)也同时被置 1,从而彻底开启了串口的收发功能。
三、UART阻塞式接收(Receive)机制
3.1 接收核心依赖:寄存器与标志位
接收过程只需要关注两个寄存器和一个核心标志位即可,比初始化逻辑简单很多:

- 核心寄存器为状态寄存器
USART_SR(地址偏移0x00,复位值0x00C0)和数据寄存器DR,接收数据时由硬件模块自动完成移位寄存到RDR、再转移到DR的过程,不需要软件干预。 - 核心标志位是
RXNE(Read data register not empty,读数据寄存器非空),位于USART_SR的第5位:- 当
RDR中的数据转移到USART_DR时,该位被硬件自动置1,此时表示缓冲区有有效数据可以读取。 - 当
RXNE为0时,代表没有收到数据,为1时代表可以读出数据。 - 常规操作下,对
USART_DR执行读操作会自动清零RXNE位,仅在多缓存通讯场景下才推荐通过写0的方式手动清除该位。如果USART_CR1寄存器中的RXNEIE位为1,RXNE置1时还会触发接收中断。
- 当
3.2 接收接口调用与参数解析
阻塞式接收的入口函数是HAL_UART_Receive,调用时传入4个参数即可,典型的调用示例为:
c
HAL_OK == HAL_UART_Receive(&huart1, &recv_byte, 1, HAL_MAX_DELAY);
四个参数的含义分别为:
- 第一个参数:
UART_HandleTypeDef类型的UART句柄指针,指向你要使用的串口外设实例(比如huart1对应串口1)。 - 第二个参数:接收缓冲区指针,用于存放读取到的数据,比如传
&recv_byte就可以把收到的1个字节存到recv_byte变量中,如果要接收数组,传数组首地址即可。 - 第三个参数:期望接收的字节数,示例中传
1表示只接收1个字节。 - 第四个参数:超时时间,传
HAL_MAX_DELAY表示阻塞等待直到数据接收完成,不设置超时上限。
3.3 接收源码逐段解析
我们把HAL_UART_Receive的底层实现拆开来看,不要被长代码吓到,逻辑非常清晰:

首先做前置校验:检查当前串口的RxState是否为HAL_UART_STATE_READY(空闲状态),如果串口已经在执行其他接收任务,直接返回HAL_BUSY(忙状态);同时校验传入的缓冲区指针不能为空、期望接收字节数不能为0,否则返回HAL_ERROR。
校验通过后初始化接收状态和计数参数:
c
huart->ErrorCode = HAL_UART_ERROR_NONE;
huart->RxState = HAL_UART_STATE_BUSY_RX;
huart->ReceptionType = HAL_UART_RECEPTION_STANDARD;
tickstart = HAL_GetTick(); // 初始化超时时间戳
huart->RxXferSize = Size; // 总期望接收字节数
huart->RxXferCount = Size; // 剩余待接收字节数
这里的RxXferSize和RxXferCount是UART_HandleTypeDef结构体里的两个关键字段:前者记录你期望接收的总数据量,后者记录还没接收完的剩余数据量,后续每接收一个字节,RxXferCount就会减1。
接下来进入核心接收循环:
c
while (huart->RxXferCount > 0U)
{
// 阻塞等待RXNE标志位置1,超时则返回HAL_TIMEOUT
if (UART_WaitOnFlagUntilTimeout(huart, UART_FLAG_RXNE, RESET, tickstart, Timeout) != HAL_OK)
{
huart->RxState = HAL_UART_STATE_READY;
return HAL_TIMEOUT;
}
// 读取DR寄存器数据,根据数据位长选择不同的处理方式
if (pdata8Bits == NULL)
{
*pdata16bits = (uint16_t)(huart->Instance->DR & 0x01FF);
pdata16bits++;
}
else
{
*pdata8Bits = (uint8_t)(huart->Instance->DR & 0x00FF);
pdata8Bits++;
}
huart->RxXferCount--;
}
循环的核心逻辑是:
- 每次循环先调用
UART_WaitOnFlagUntilTimeout函数检测RXNE标志位:这个函数会调用底层宏__HAL_UART_GET_FLAG,其实现是(huart->Instance->SR & UART_FLAG_RXNE) == UART_FLAG_RXNE,本质是按位与操作检测SR寄存器的第5位是否为1。如果RXNE为0,函数会一直阻塞等待直到超时;如果RXNE为1,就跳出等待进入下一步。 - 根据数据位长读取数据:如果配置的是9位数据长且无校验位,就用
uint16_t指针接收,读取DR寄存器低9位的数据;如果是8位数据长,就用uint8_t指针接收,读取DR寄存器低8位的数据。读取完成后指针加加,指向下一个缓冲区位置,后续接收的数据会依次存到连续地址中。 RxXferCount减1,代表完成一个字节的接收,直到剩余字节数减为0,循环结束。
所有数据接收完成后,恢复串口状态为HAL_UART_STATE_READY,返回HAL_OK代表接收成功。
五、UART阻塞式发送(Transmit)机制
5.1 发送核心依赖:寄存器与标志位
发送过程同样只需要关注USART_SR状态寄存器的两个标志位:

TXE(Transmit data register empty,发送数据寄存器空):位于SR的第7位。当TDR(发送数据寄存器)中的数据被转移到移位寄存器时,该位被硬件置1,代表可以往TDR写入新的发送数据;如果对USART_DR执行写操作,该位会自动清零。当TXE为0时代表数据还没转移到移位寄存器,不能写入新数据;为1时代表可以写入。TC(Transmission complete,发送完成):位于SR的第6位。当包含数据的一帧全部发送完成,并且TXE=1时,该位被硬件置1,代表整个发送过程彻底完成。常规操作下可以通过先读USART_SR、再写USART_DR的方式清零,也可以直接写0清零,仅在多缓存通讯场景推荐写0清零。如果USART_CR1中的TCIE位为1,TC置1时会触发发送完成中断。
5.2 发送接口调用与参数解析
阻塞式发送的入口函数是HAL_UART_Transmit,调用参数和接收接口完全对应,典型示例为:
c
HAL_UART_Transmit(&huart1, &recv_byte, 1, 0);
四个参数依次为:UART句柄指针、发送数据缓冲区指针、期望发送的字节数、超时时间,逻辑和接收接口一致,这里不做重复说明。
5.3 发送源码逐段解析
HAL_UART_Transmit的底层实现和接收逻辑高度对称,我们逐段拆解:

同样先做前置校验:检查gState是否为HAL_UART_STATE_READY,如果正在执行其他发送任务则返回HAL_BUSY;校验缓冲区指针和发送字节数,不合法则返回HAL_ERROR。
校验通过后初始化发送相关参数:
c
huart->ErrorCode = HAL_UART_ERROR_NONE;
huart->gState = HAL_UART_STATE_BUSY_TX;
tickstart = HAL_GetTick();
huart->TxXferSize = Size;
huart->TxXferCount = Size;
TxXferSize和TxXferCount的含义和接收的两个字段完全对应:分别记录总发送字节数和剩余待发送字节数。
接下来进入核心发送循环:
c
while (huart->TxXferCount > 0U)
{
// 阻塞等待TXE标志位置1,超时则返回HAL_TIMEOUT
if (UART_WaitOnFlagUntilTimeout(huart, UART_FLAG_TXE, RESET, tickstart, Timeout) != HAL_OK)
{
huart->gState = HAL_UART_STATE_READY;
return HAL_TIMEOUT;
}
// 将数据写入DR寄存器
if (pdata8Bits == NULL)
{
huart->Instance->DR = (uint16_t)(*pdata16bits & 0x01FF);
pdata16bits++;
}
else
{
huart->Instance->DR = (uint8_t)(*pdata8Bits & 0xFF);
pdata8Bits++;
}
huart->TxXferCount--;
}
循环逻辑和接收完全对称:每次循环先检测TXE标志位,为0就一直阻塞等待,为1就将缓冲区数据写入DR寄存器,然后指针加加、TxXferCount减1,直到所有数据都写入DR寄存器,循环结束。
重点注意:数据全部写入DR之后,发送流程并没有结束,还需要额外等待TC标志位:
c
if (UART_WaitOnFlagUntilTimeout(huart, UART_FLAG_TC, RESET, tickstart, Timeout) != HAL_OK)
{
huart->gState = HAL_UART_STATE_READY;
return HAL_TIMEOUT;
}
这是因为TXE置1仅代表数据已经从TDR转移到移位寄存器,最后一个字节写入DR之后,可能还在移位寄存器里没有发送到物理线路上,如果此时直接结束发送流程,可能导致最后一个字节发送不完整。所以必须额外等待TC标志位置1,确认TDR和移位寄存器都为空、所有数据都发送完成,才代表发送流程彻底结束。
最后恢复串口状态为HAL_UART_STATE_READY,返回HAL_OK代表发送成功。
六、 Timeout 参数的详细解析
我们刚刚已经搞清楚了 HAL_UART_Receive 和 HAL_UART_Transmit 这两个基本函数的框架。通过之前的分析,我们看到了它们确实会进行阻塞等待,也确实会通过写 DR 寄存器或读 DR 寄存器来完成数据的收发。
现在我们结合源码,来聊一聊这对函数的最后一个参数------Timeout。只要有了对源码的理解,这个参数的理解就会变得非常容易。
首先,我们从函数的整体签名和参数定义开始看起。这两个函数的标准定义如下:
c
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout)
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout)
在这里,我们需要明确最后一个参数的物理含义:
Timeout:超时时间。它的取值直接决定了函数的阻塞与非阻塞行为,通常有三种情况:0xFFFFFFFF(即HAL_MAX_DELAY):阻塞读取。0:非阻塞读取。1000ms(或其他具体数值):阻塞 + 非阻塞结合(限时阻塞)。
6.1 源码层面的剖析
以读操作 HAL_UART_Receive 为例,当我们进入其源码内部
6.1.1 时间戳获取
超时时间戳初始化 :通过 tickstart = HAL_GetTick(); 获取当前系统滴答定时器的值,作为超时的起始基准时间。
6.1.2 核心的阻塞式轮询与数据读取
初始化完成后,便进入了主体的读取逻辑。这个逻辑整体上也被分为两块:
第一块是阻塞式的标志位判断 。我们需要循环检测接收寄存器非空标志位(UART_FLAG_RXNE),也就是调用底层函数:
c
UART_WaitOnFlagUntilTimeout(huart, UART_FLAG_RXNE, RESET, tickstart, Timeout)
这个函数会通过 while 循环不断查询标志位:
c
/* Wait until flag is set */
while ((__HAL_UART_GET_FLAG(huart, Flag) ? SET : RESET) == Status)
{
/* Check for the Timeout */
if (Timeout != HAL_MAX_DELAY)
{
if (((HAL_GetTick() - Tickstart) > Timeout) || (Timeout == 0U))
{
return HAL_TIMEOUT;
}
}
}
第二块是读取寄存器并处理数据 。当标志位满足条件后,程序会读取 DR 寄存器。根据你设置的数据长度(如 9 位、8 位)和校验位设置,系统会自动将对应的校验位通过位掩码操作"注释掉"(屏蔽掉),只保留纯粹的数据位,前面详细剖析了这里就一笔带过
七、 Timeout 参数的深度剖析与取值行为

在刚刚的源码中,我们看到了 Timeout 是如何在 UART_WaitOnFlagUntilTimeout 函数中被使用的。这个参数的不同取值,直接决定了我们是做非阻塞的一次性检查,还是永久的死循环等待,亦或是带时间窗口的等待。
底层是通过 HAL_GetTick() 获取当前的时间戳,并计算 (HAL_GetTick() - tickstart) 的差值来判断是否超时。
我们具体来看三种取值:
7.1 HAL_MAX_DELAY(0xFFFFFFFF):永久阻塞
当你传入 Timeout = HAL_MAX_DELAY 时:
- 源码中的条件
Timeout != HAL_MAX_DELAY不成立。 - 因此,超时的判断分支完全被跳过,不会执行任何时间差的计算。
while循环会一直死等,直到标志位满足(即数据真正到来)才会跳出循环。- 这就是我们常说的绝对阻塞。
7.2 0:非阻塞读取
当你传入 Timeout = 0 时:
- 源码中的条件
(Timeout == 0U)成立。 - 程序仅仅检查一次
RDR寄存器(或对应的标志位)。如果此时有数据,那就正常读取并返回HAL_OK;如果没有数据,它会立即跳出循环,并返回HAL_TIMEOUT。 - 它不会做任何等待,检查一次就立即返回。
7.3 具体数值(如 1000ms):限时阻塞等待
当你传入一个非零且非 HAL_MAX_DELAY 的具体时间时:
- 程序会进入超时检测分支。
- 只要时间差没有超过你设定的
Timeout,且标志位不满足,它就会一直在while循环里死等。 - 一旦时间差超过了设定的超时时间,条件仍然不满足,循环就会终止,并返回
HAL_TIMEOUT。
这里要提一下 HAL 库定义的返回状态枚举值 HAL_StatusTypeDef:
c
typedef enum
{
HAL_OK = 0x00U,
HAL_ERROR = 0x01U,
HAL_BUSY = 0x02U,
HAL_TIMEOUT = 0x03U
} HAL_StatusTypeDef;
在非阻塞或限时阻塞中,如果没有在规定时间内拿到数据,函数就会返回 HAL_TIMEOUT,方便我们在业务层进行错误处理。
HAL_UART_Transmit 的原理与 Receive 一模一样,只不过它等待的是发送寄存器空标志位(TXE 或 TC),超时管理机制完全一致。
八、 阻塞、非阻塞与轮询的生活化通俗比喻
为了更透彻地理解,我们可以用日常生活中的例子来类比。
假设你在宿舍楼下等你的朋友下来拿外卖:
-
非阻塞(检查一次立即返回) :
你走到楼下,给对方打了个电话(相当于调用了一次函数)。朋友说:"我还没下来,在打游戏呢。" 你说:"好,那我知道了。" 然后你把电话挂了,去做自己的事情了。
- 技术映射:函数调用检查了一次状态,发现不符合条件,立即返回,不耽误主程序干其他事情。
-
阻塞(死等不挂电话) :
你走到楼下,给对方打电话。朋友说:"我还没下来。" 你说:"别挂电话啊,你把电话一直保持畅通,我就在电话这头等你。" 于是你一直举着电话在楼下等,直到朋友说"我下来了",你才挂断电话。
- 技术映射 :函数调用后,线程完全挂起,条件不满足就不返回,一直死循环等待。这就是
Timeout = HAL_MAX_DELAY。
- 技术映射 :函数调用后,线程完全挂起,条件不满足就不返回,一直死循环等待。这就是
-
非阻塞配合轮询(不断打电话) :
你不可能在楼下傻等一天。你可能每隔 10 分钟给对方打个电话问一句:"你下来没?" 对方说"没下",你挂断。过了 10 分钟,你又打一个,对方说"没下",你又挂断。直到对方说"下来了",你就不再打了。
- 技术映射 :这就是用户在自己写的
while循环中,不断调用Timeout = 0的非阻塞函数。每次调用只检查一次状态后立即返回,由用户自己的循环决定什么时候再查下一次。我们通常把这种"非阻塞配合用户循环"的方式,称为轮询。
- 技术映射 :这就是用户在自己写的
所以,非阻塞本身指的是一次性的检查,而轮询则是非阻塞调用的重复使用策略,在实际开发中它们经常配合使用来实现高效的资源利用。
结语
