STM32 USART 详解(六):HAL库初始化及阻塞式收发源码拆解 📅 发布时间:2026/9/14 23:05:12 👁 浏览次数: 目录前言一、 USART1 串口物理地址与底层映射机制1.1 物理地址的层层推导1.2 片上外设的寄存器结构体映射1.3 HAL 库的句柄封装机制二、 USART 串口初始化核心流程剖析2.1 硬件层初始化HAL_UART_MspInit 详解2.1.1 开启外设时钟2.1.2 GPIO 引脚配置2.2 参数配置与寄存器写入UART_SetConfig 详解2.2.1 配置停止位CR2 寄存器2.2.2 配置字长、校验与收发使能CR1 寄存器三、UART阻塞式接收Receive机制3.1 接收核心依赖寄存器与标志位3.2 接收接口调用与参数解析3.3 接收源码逐段解析五、UART阻塞式发送Transmit机制5.1 发送核心依赖寄存器与标志位5.2 发送接口调用与参数解析5.3 发送源码逐段解析六、 Timeout 参数的详细解析6.1 源码层面的剖析6.1.1 时间戳获取6.1.2 核心的阻塞式轮询与数据读取七、 Timeout 参数的深度剖析与取值行为7.1 HAL_MAX_DELAY0xFFFFFFFF永久阻塞7.2 0非阻塞读取7.3 具体数值如 1000ms限时阻塞等待八、 阻塞、非阻塞与轮询的生活化通俗比喻结语 云泽Q个人主页 专栏传送入口: 《C语言》《数据结构》《C》《Linux》《蓝桥杯系列》《笔试算法》《AI赋能》《STM32》《Python》⛺️遇见安然遇见你不负代码不负卿~前言大家好啊我是云泽Q欢迎阅读我的文章一名热爱计算机技术的在校大学生喜欢在课余时间做一些计算机技术的总结性文章希望我的文章能为你解答困惑~一、 USART1 串口物理地址与底层映射机制在深入分析 USART 串口的源码时我们首先要解决一个核心问题片上外设是如何在代码中被寻址的。所有片上外设包括串口、内存、Flash 等在 Cortex-M3 架构中都是统一编址的。这意味着每一个串口都拥有其专属的物理地址我们需要通过源码一层层将其挖掘出来。1.1 物理地址的层层推导在我们的基本代码中当我们使用USART1时它实际上是一个宏定义。我们通过“右键转到定义”的操作可以像剥洋葱一样顺着源码的引用链找到它的物理真身。整个推导过程如下在源码中找到USART1宏定义它被强转为了一个结构体指针#defineUSART1((USART_TypeDef*)USART1_BASE)继续追踪USART1_BASE它是基于 APB2 总线外设基地址叠加了一个偏移量#defineUSART1_BASE(APB2PERIPH_BASE0x00003800UL)接着我们去找APB2PERIPH_BASE它又是基于更基础的外设基地址PERIPH_BASE叠加偏移量#defineAPB2PERIPH_BASE(PERIPH_BASE0x00010000UL)最后我们找到最底层的基地址PERIPH_BASE#definePERIPH_BASE((uint32_t)0x40000000)将上述层层嵌套的基地址与偏移量相加我们就可以计算出USART1的物理地址0x40000000(PERIPH_BASE) 0x00010000(APB2 偏移) 0x00003800(USART1 偏移) 0x40013800。这就意味着USART1的所有寄存器都是从物理内存地址0x40013800开始往后排布的。1.2 片上外设的寄存器结构体映射找到了基地址0x40013800后代码通过(USART_TypeDef *)将其强转为了结构体指针。这种设计极其精妙它将连续的物理地址与 C 语言中的结构体成员一一对应了起来。查看USART_TypeDef结构体的定义你会发现它内部的寄存器排列顺序与官方参考手册中的寄存器偏移地址是严格对应的typedefstruct{__IOuint32_tSR;/*! USART Status register, Address offset: 0x00 */__IOuint32_tDR;/*! USART Data register, Address offset: 0x04 */__IOuint32_tBRR;/*! USART Baud rate register, Address offset: 0x08 */__IOuint32_tCR1;/*! USART Control register 1, Address offset: 0x0C */__IOuint32_tCR2;/*! USART Control register 2, Address offset: 0x10 */__IOuint32_tCR3;/*! USART Control register 3, Address offset: 0x14 */__IOuint32_tGTPR;/*! 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;。这个句柄结构体主要包含以下两个核心部分typedefstruct{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 还是 USART3HAL 库底层的 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 中任何外设工作前都必须开启其对应的时钟否则无法读写其寄存器。/* Peripheral clock enable */__HAL_RCC_USART1_CLK_ENABLE();展开这个宏其底层是通过SET_BIT宏操作 RCC复位和时钟控制寄存器#define__HAL_RCC_USART1_CLK_ENABLE()do{\__IOuint32_ttmpreg;\SET_BIT(RCC-APB2ENR,RCC_APB2ENR_USART1EN);\/* Delay after an RCC peripheral clock enabling */\tmpregREAD_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 页面PA9USART1_TX配置为复用功能推挽输出GPIO_MODE_AF_PP最高速度设为 High。PA10USART1_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 寄存器停止位的配置涉及控制寄存器 2USART_CR2其地址偏移为0x10。具体控制位在 bit[13:12]STOP[1:0]。源码中通过MODIFY_REG宏来实现“读-改-写”操作确保只修改目标位而不影响寄存器其他位/* 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用来将 bit[13:12] 清零。huart-Init.StopBits是设置值SET MASK。在 CubeMX 中我们配置了 1 个停止位对应宏UART_STOPBITS_1其值为0x00000000U即写入00。2.2.2 配置字长、校验与收发使能CR1 寄存器控制寄存器 1USART_CR2承担了更多的配置任务其地址偏移为0x0C。主要涉及以下控制位位 12M - Word length字长控制。0表示 8 位数据位1表示 9 位数据位。位 10PCE - Parity control enable校验控制使能。位 9PS - Parity selection校验选择。0为偶校验1为奇校验。位 3TE - Transmitter enable发送使能。位 2RE - Receiver enable接收使能。源码中的处理逻辑如下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 会被置 1bit10 会被置 1bit9 也会被置 1。同时由于模式配置为“Receive and Transmit”bit3TE和 bit2RE也同时被置 1从而彻底开启了串口的收发功能。三、UART阻塞式接收Receive机制3.1 接收核心依赖寄存器与标志位接收过程只需要关注两个寄存器和一个核心标志位即可比初始化逻辑简单很多核心寄存器为状态寄存器USART_SR地址偏移0x00复位值0x00C0和数据寄存器DR接收数据时由硬件模块自动完成移位寄存到RDR、再转移到DR的过程不需要软件干预。核心标志位是RXNERead data register not empty读数据寄存器非空位于USART_SR的第5位当RDR中的数据转移到USART_DR时该位被硬件自动置1此时表示缓冲区有有效数据可以读取。当RXNE为0时代表没有收到数据为1时代表可以读出数据。常规操作下对USART_DR执行读操作会自动清零RXNE位仅在多缓存通讯场景下才推荐通过写0的方式手动清除该位。如果USART_CR1寄存器中的RXNEIE位为1RXNE置1时还会触发接收中断。3.2 接收接口调用与参数解析阻塞式接收的入口函数是HAL_UART_Receive调用时传入4个参数即可典型的调用示例为HAL_OKHAL_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。校验通过后初始化接收状态和计数参数huart-ErrorCodeHAL_UART_ERROR_NONE;huart-RxStateHAL_UART_STATE_BUSY_RX;huart-ReceptionTypeHAL_UART_RECEPTION_STANDARD;tickstartHAL_GetTick();// 初始化超时时间戳huart-RxXferSizeSize;// 总期望接收字节数huart-RxXferCountSize;// 剩余待接收字节数这里的RxXferSize和RxXferCount是UART_HandleTypeDef结构体里的两个关键字段前者记录你期望接收的总数据量后者记录还没接收完的剩余数据量后续每接收一个字节RxXferCount就会减1。接下来进入核心接收循环while(huart-RxXferCount0U){// 阻塞等待RXNE标志位置1超时则返回HAL_TIMEOUTif(UART_WaitOnFlagUntilTimeout(huart,UART_FLAG_RXNE,RESET,tickstart,Timeout)!HAL_OK){huart-RxStateHAL_UART_STATE_READY;returnHAL_TIMEOUT;}// 读取DR寄存器数据根据数据位长选择不同的处理方式if(pdata8BitsNULL){*pdata16bits(uint16_t)(huart-Instance-DR0x01FF);pdata16bits;}else{*pdata8Bits(uint8_t)(huart-Instance-DR0x00FF);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状态寄存器的两个标志位TXETransmit data register empty发送数据寄存器空位于SR的第7位。当TDR发送数据寄存器中的数据被转移到移位寄存器时该位被硬件置1代表可以往TDR写入新的发送数据如果对USART_DR执行写操作该位会自动清零。当TXE为0时代表数据还没转移到移位寄存器不能写入新数据为1时代表可以写入。TCTransmission complete发送完成位于SR的第6位。当包含数据的一帧全部发送完成并且TXE1时该位被硬件置1代表整个发送过程彻底完成。常规操作下可以通过先读USART_SR、再写USART_DR的方式清零也可以直接写0清零仅在多缓存通讯场景推荐写0清零。如果USART_CR1中的TCIE位为1TC置1时会触发发送完成中断。5.2 发送接口调用与参数解析阻塞式发送的入口函数是HAL_UART_Transmit调用参数和接收接口完全对应典型示例为HAL_UART_Transmit(huart1,recv_byte,1,0);四个参数依次为UART句柄指针、发送数据缓冲区指针、期望发送的字节数、超时时间逻辑和接收接口一致这里不做重复说明。5.3 发送源码逐段解析HAL_UART_Transmit的底层实现和接收逻辑高度对称我们逐段拆解同样先做前置校验检查gState是否为HAL_UART_STATE_READY如果正在执行其他发送任务则返回HAL_BUSY校验缓冲区指针和发送字节数不合法则返回HAL_ERROR。校验通过后初始化发送相关参数huart-ErrorCodeHAL_UART_ERROR_NONE;huart-gStateHAL_UART_STATE_BUSY_TX;tickstartHAL_GetTick();huart-TxXferSizeSize;huart-TxXferCountSize;TxXferSize和TxXferCount的含义和接收的两个字段完全对应分别记录总发送字节数和剩余待发送字节数。接下来进入核心发送循环while(huart-TxXferCount0U){// 阻塞等待TXE标志位置1超时则返回HAL_TIMEOUTif(UART_WaitOnFlagUntilTimeout(huart,UART_FLAG_TXE,RESET,tickstart,Timeout)!HAL_OK){huart-gStateHAL_UART_STATE_READY;returnHAL_TIMEOUT;}// 将数据写入DR寄存器if(pdata8BitsNULL){huart-Instance-DR(uint16_t)(*pdata16bits0x01FF);pdata16bits;}else{huart-Instance-DR(uint8_t)(*pdata8Bits0xFF);pdata8Bits;}huart-TxXferCount--;}循环逻辑和接收完全对称每次循环先检测TXE标志位为0就一直阻塞等待为1就将缓冲区数据写入DR寄存器然后指针加加、TxXferCount减1直到所有数据都写入DR寄存器循环结束。重点注意数据全部写入DR之后发送流程并没有结束还需要额外等待TC标志位if(UART_WaitOnFlagUntilTimeout(huart,UART_FLAG_TC,RESET,tickstart,Timeout)!HAL_OK){huart-gStateHAL_UART_STATE_READY;returnHAL_TIMEOUT;}这是因为TXE置1仅代表数据已经从TDR转移到移位寄存器最后一个字节写入DR之后可能还在移位寄存器里没有发送到物理线路上如果此时直接结束发送流程可能导致最后一个字节发送不完整。所以必须额外等待TC标志位置1确认TDR和移位寄存器都为空、所有数据都发送完成才代表发送流程彻底结束。最后恢复串口状态为HAL_UART_STATE_READY返回HAL_OK代表发送成功。六、 Timeout 参数的详细解析我们刚刚已经搞清楚了HAL_UART_Receive和HAL_UART_Transmit这两个基本函数的框架。通过之前的分析我们看到了它们确实会进行阻塞等待也确实会通过写DR寄存器或读DR寄存器来完成数据的收发。现在我们结合源码来聊一聊这对函数的最后一个参数——Timeout。只要有了对源码的理解这个参数的理解就会变得非常容易。首先我们从函数的整体签名和参数定义开始看起。这两个函数的标准定义如下HAL_StatusTypeDefHAL_UART_Receive(UART_HandleTypeDef*huart,uint8_t*pData,uint16_tSize,uint32_tTimeout)HAL_StatusTypeDefHAL_UART_Transmit(UART_HandleTypeDef*huart,constuint8_t*pData,uint16_tSize,uint32_tTimeout)在这里我们需要明确最后一个参数的物理含义Timeout超时时间。它的取值直接决定了函数的阻塞与非阻塞行为通常有三种情况0xFFFFFFFF即HAL_MAX_DELAY阻塞读取。0非阻塞读取。1000ms或其他具体数值阻塞 非阻塞结合限时阻塞。6.1 源码层面的剖析以读操作HAL_UART_Receive为例当我们进入其源码内部6.1.1 时间戳获取超时时间戳初始化通过tickstart HAL_GetTick();获取当前系统滴答定时器的值作为超时的起始基准时间。6.1.2 核心的阻塞式轮询与数据读取初始化完成后便进入了主体的读取逻辑。这个逻辑整体上也被分为两块第一块是阻塞式的标志位判断。我们需要循环检测接收寄存器非空标志位UART_FLAG_RXNE也就是调用底层函数UART_WaitOnFlagUntilTimeout(huart,UART_FLAG_RXNE,RESET,tickstart,Timeout)这个函数会通过while循环不断查询标志位/* 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)||(Timeout0U)){returnHAL_TIMEOUT;}}}第二块是读取寄存器并处理数据。当标志位满足条件后程序会读取DR寄存器。根据你设置的数据长度如 9 位、8 位和校验位设置系统会自动将对应的校验位通过位掩码操作“注释掉”屏蔽掉只保留纯粹的数据位前面详细剖析了这里就一笔带过七、 Timeout 参数的深度剖析与取值行为在刚刚的源码中我们看到了Timeout是如何在UART_WaitOnFlagUntilTimeout函数中被使用的。这个参数的不同取值直接决定了我们是做非阻塞的一次性检查还是永久的死循环等待亦或是带时间窗口的等待。底层是通过HAL_GetTick()获取当前的时间戳并计算(HAL_GetTick() - tickstart)的差值来判断是否超时。我们具体来看三种取值7.1 HAL_MAX_DELAY0xFFFFFFFF永久阻塞当你传入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_StatusTypeDeftypedefenum{HAL_OK0x00U,HAL_ERROR0x01U,HAL_BUSY0x02U,HAL_TIMEOUT0x03U}HAL_StatusTypeDef;在非阻塞或限时阻塞中如果没有在规定时间内拿到数据函数就会返回HAL_TIMEOUT方便我们在业务层进行错误处理。HAL_UART_Transmit的原理与Receive一模一样只不过它等待的是发送寄存器空标志位TXE或TC超时管理机制完全一致。八、 阻塞、非阻塞与轮询的生活化通俗比喻为了更透彻地理解我们可以用日常生活中的例子来类比。假设你在宿舍楼下等你的朋友下来拿外卖非阻塞检查一次立即返回你走到楼下给对方打了个电话相当于调用了一次函数。朋友说“我还没下来在打游戏呢。” 你说“好那我知道了。” 然后你把电话挂了去做自己的事情了。技术映射函数调用检查了一次状态发现不符合条件立即返回不耽误主程序干其他事情。阻塞死等不挂电话你走到楼下给对方打电话。朋友说“我还没下来。” 你说“别挂电话啊你把电话一直保持畅通我就在电话这头等你。” 于是你一直举着电话在楼下等直到朋友说“我下来了”你才挂断电话。技术映射函数调用后线程完全挂起条件不满足就不返回一直死循环等待。这就是Timeout HAL_MAX_DELAY。非阻塞配合轮询不断打电话你不可能在楼下傻等一天。你可能每隔 10 分钟给对方打个电话问一句“你下来没” 对方说“没下”你挂断。过了 10 分钟你又打一个对方说“没下”你又挂断。直到对方说“下来了”你就不再打了。技术映射这就是用户在自己写的while循环中不断调用Timeout 0的非阻塞函数。每次调用只检查一次状态后立即返回由用户自己的循环决定什么时候再查下一次。我们通常把这种“非阻塞配合用户循环”的方式称为轮询。所以非阻塞本身指的是一次性的检查而轮询则是非阻塞调用的重复使用策略在实际开发中它们经常配合使用来实现高效的资源利用。结语