【STM32】 HAL库深度入门:从设计思想到 USART 全模式实战

STM32 HAL库深度入门:从设计思想到 USART 全模式实战

1. HAL库到底是什么

1.1 三层抽象的定位

STM32 的软件栈从下到上分三层:

复制代码
┌─────────────────────────────────┐
│  应用层(你写的业务代码)        │  ← main.c, app.c
├─────────────────────────────────┤
│  HAL库(Hardware Abstraction Layer)│  ← stm32f1xx_hal_uart.c
├─────────────────────────────────┤
│  寄存器层(直接操作外设寄存器)   │  ← USART1->DR = 0x55
├─────────────────────────────────┤
│  硬件(硅片)                    │
└─────────────────────────────────┘

HAL 层的职责:把"直接写寄存器"这种又长又容易错的操作,封装成有名字、有参数检查、有错误处理的函数,同时让代码在不同 STM32 型号之间尽量可移植。


1.2 HAL 的核心设计原则

学 HAL 之前先记住三个设计原则,后面所有东西都是围绕这三个来的:

  1. 句柄(Handle)驱动一切:每个外设有一个结构体,把寄存器基地址、状态、配置参数全装进去,函数第一个参数都是这个结构体指针;
  2. 三种模式并行:每个外设都支持轮询、中断、DMA 三种工作模式,用同一套句柄管理状态;
  3. 回调与状态机:异步操作(中断、DMA)靠状态机推进,完成后自动调回调函数通知你。

2. 句柄(Handle):HAL 的灵魂

2.1 为什么需要句柄

如果没有句柄,你操作 USART1 和 USART2 得写两套函数:

复制代码
// 没有句柄的样子:每个外设一套函数
void usart1_send(uint8_t *data, uint16_t len);
void usart2_send(uint8_t *data, uint16_t len);

有了句柄之后,一个函数搞定所有:

复制代码
HAL_UART_Transmit(&huart1, data, len, 100);
HAL_UART_Transmit(&huart2, data, len, 100);

函数内部通过 huart->Instance 找到你操作的是哪个外设。


2.2 UART_HandleTypeDef 结构逐字段拆解

打开 stm32f1xx_hal_uart.h,句柄长这样(我把关键字段挑出来讲):

复制代码
typedef struct
{
    USART_TypeDef *Instance;        // ★ 寄存器基地址:这是哪个串口?USART1/2/3?
    
    UART_InitTypeDef Init;          // ★ 配置参数:波特率、数据位、停止位...
    
    uint8_t *pTxBuffPtr;            // 发送缓冲区指针:当前正在发哪块数据
    uint16_t TxXferSize;            // 发送总长度
    uint16_t TxXferCount;           // 还剩多少没发
    __IO uint32_t TxState;          // 发送状态:就绪/忙/错误
    
    uint8_t *pRxBuffPtr;            // 接收缓冲区指针
    uint16_t RxXferSize;            // 接收总长度
    uint16_t RxXferCount;           // 还剩多少没收
    __IO uint32_t RxState;           // 接收状态
    
    uint32_t ErrorCode;             // 错误码
} UART_HandleTypeDef;

逐个解释:

Instance------最关键字段 这是一个指针,指向外设的寄存器基地址。比如:

复制代码
huart1.Instance = USART1;  // USART1 是寄存器基地址宏,值是 0x40013800

所有 HAL 函数内部都是通过 huart->Instance->DR = ... 这种方式操作寄存器。改一下 Instance,同一个函数就操作另一个串口。这就是句柄的本质。

Init------配置参数 存的是你在 CubeMX 里配的那些东西:波特率、字长、停止位、校验、模式。初始化函数 HAL_UART_Init 读这些字段,写对应的 CR1/CR2/BRR 寄存器。

复制代码
typedef struct {
    uint32_t BaudRate;      // 115200
    uint32_t WordLength;    // 8位或9位
    uint32_t StopBits;      // 1位、2位
    uint32_t Parity;        // 无、奇、偶
    uint32_t Mode;          // 收、发、收发
} UART_InitTypeDef;

pTxBuffPtr / TxXferSize / TxXferCount------异步发送的"进度条" 这三个字段是中断/DMA 模式下用的。

HAL_UART_Transmit_IT 启动一次中断发送后:

  • pTxBuffPtr 指向你要发的数据;
  • TxXferSize 是总长度;
  • TxXferCount 是还剩多少没发,每发一个字节硬件自动减一;
  • 减到 0 就发完了,调回调函数。

TxState / RxState------状态机 这是 HAL 防止重入的关键。状态值有:

  • HAL_UART_STATE_RESET:还没初始化
  • HAL_UART_STATE_READY:空闲,可以接收新请求
  • HAL_UART_STATE_BUSY_TX:正在发送,不能再发
  • HAL_UART_STATE_BUSY_RX:正在接收
  • HAL_UART_STATE_BUSY_TX_RX:同时收发

你调用 HAL_UART_Transmit 时,函数第一件事就是检查 gState == READY,不是就直接返回 HAL_BUSY。这就是为什么你不能在中断里又调一次发送函数------会被状态机拦住。


3. HAL 库的三层:宏、函数、回调

3.1 宏层:直接操作寄存器

HAL 里有大量宏,本质就是寄存器位操作,比函数调用还快:

复制代码
// 常用的宏,展开后就是寄存器操作
__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TXE)
// 展开为:(huart1.Instance->SR & (UART_FLAG_TXE))

__HAL_UART_ENABLE_IT(&huart1, UART_IT_TXE)
// 展开为:(huart1.Instance->CR1 |= (UART_IT_TXE))

什么时候用宏?

  • 时间要求极高的场景,不想有函数调用开销;
  • 直接读标志位、直接开关中断;
  • 在回调函数里做快速判断。

3.2 函数层:有检查、有状态机的完整流程

函数层就是你平时调的 HAL_UART_TransmitHAL_UART_Receive_IT 这些。它们做了宏不做的事:

  • 检查句柄状态,防止重入;
  • 做超时管理;
  • 更新内部进度字段;
  • 返回错误码。

3.3 回调层:异步完成的通知机制

异步操作(中断/DMA)不能让函数卡住等,所以 HAL 用了回调机制:

复制代码
你调用 HAL_UART_Transmit_IT()
    → 函数立刻返回 HAL_OK
    → 后台中断自动发数据
    → 全部发完后,HAL 内部自动调用 HAL_UART_TxCpltCallback()
    → 你在这个回调函数里写"发完了要干什么"

默认回调函数是空的,你要自己重写:

复制代码
// 弱定义(weak),你在 main.c 里写一个同名函数就覆盖了
__weak void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    /* 默认为空 */
}

// 你自己实现:
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        // USART1 发完了,做后续处理
        tx_done_flag = 1;
    }
}

关键:回调函数是 HAL 替你调的,你只需要实现函数体。函数名和参数必须和 HAL 定义的一模一样。


4. USART 三种模式:同一套句柄的三种跑法

4.1 轮询模式:CPU 死等

API:

复制代码
HAL_UART_Transmit(&huart1, data, len, 100);   // 发送,100ms超时
HAL_UART_Receive(&huart1, buf, len, 100);     // 接收

特点:

  • 函数不返回,直到发完/收完/超时;
  • 不需要回调;
  • 不需要开中断;
  • 代码里之前拆解过了,核心就是 while 循环查标志。

什么时候用: 开机日志、短数据、调试。


4.2 中断模式:后台自动收发

启动发送:

复制代码
HAL_UART_Transmit_IT(&huart1, data, len);

这行做了什么:

  1. 检查 TxState == READY
  2. data 地址存进 pTxBuffPtr
  3. len 存进 TxXferSizeTxXferCount 也设为 len;
  4. 开 TXE 中断;
  5. TxState 设为 BUSY_TX
  6. 立刻返回 HAL_OK

然后呢?CPU 可以干别的去了。硬件每发完一个字节,TXE 置 1,触发中断,进入 USART1_IRQHandler

复制代码
void USART1_IRQHandler(void)
{
    HAL_UART_IRQHandler(&huart1);  // HAL的中断总入口
}

HAL_UART_IRQHandler 内部做了什么:

  1. 查 SR,看是 TXE 中断还是 RXNE 中断还是错误中断;
  2. 如果是 TXE:
    • pTxBuffPtr 读一个字节;
    • 写进 DR;
    • pTxBuffPtr++TxXferCount--
    • 如果 TxXferCount 减到 0:关 TXE 中断,开 TC 中断;
  3. 如果是 TC:
    • 说明最后一个字节也发完了;
    • 关 TC 中断;
    • TxState 设回 READY;
    • HAL_UART_TxCpltCallback()

接收中断模式:

复制代码
HAL_UART_Receive_IT(&huart1, buf, len);

逻辑类似,每收一个字节中断一次,RxXferCount 减一,减到 0 调HAL_UART_RxCpltCallback()

注意坑:

中断模式默认收满 len 个字节才回调一次。如果你不知道对方发多长,收满 len 就停了,剩下的不收了。这就是为什么实际项目里接收常用 DMA + IDLE 中断。


4.3 DMA 模式:零拷贝批量收发

启动 DMA 发送:

复制代码
HAL_UART_Transmit_DMA(&huart1, data, len);

和中断模式的区别:

DMA 控制器直接在 RDR/TDR 和内存之间搬数据,CPU 完全不参与每个字节的搬运。发完一整批才触发一次中断。

什么时候用 DMA:

  • 大批量数据(比如发一整帧屏幕数据);
  • 高波特率(1Mbps 以上),中断模式每字节进一次中断,CPU 扛不住;
  • 低功耗要求,CPU 要睡觉,让 DMA 自己跑。

5. 从 CubeMX 生成代码看 HAL 初始化流程

打开 CubeMX 生成的代码,USART1 初始化长这样:

复制代码
void MX_USART1_UART_Init(void)
{
    huart1.Instance = USART1;
    huart1.Init.BaudRate = 115200;
    huart1.Init.WordLength = UART_WORDLENGTH_8B;
    huart1.Init.StopBits = UART_STOPBITS_1;
    huart1.Init.Parity = UART_PARITY_NONE;
    huart1.Init.Mode = UART_MODE_TX_RX;
    huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    huart1.Init.OverSampling = UART_OVERSAMPLING_16;
    HAL_UART_Init(&huart1);
}

HAL_UART_Init 内部做了什么:

第一步:开时钟

复制代码
// 在 HAL_UART_MspInit 里做(弱定义,CubeMX帮你写了)
__HAL_RCC_USART1_CLK_ENABLE();

第二步:配置 GPIO TX 引脚配成复用推挽,RX 配成输入。

第三步:写 BRR 寄存器 根据 Init.BaudRate 和当前 PCLK 时钟,算出分频系数写进 BRR。这个公式之前讲过了。

第四步:写 CR1/CR2/CR3 根据 Init.WordLengthStopBitsParityMode 配置帧格式和收发使能。

第五步:最后置 UE 位 开启 USART 外设。

整个过程就是:你在结构体里填参数,HAL 函数帮你翻译成寄存器位。


6. 常用 API 速查表

发送

函数 模式 阻塞? 回调
HAL_UART_Transmit 轮询
HAL_UART_Transmit_IT 中断 TxCpltCallback
HAL_UART_Transmit_DMA DMA TxCpltCallback

接收

函数 模式 阻塞? 回调
HAL_UART_Receive 轮询
HAL_UART_Receive_IT 中断 RxCpltCallback
HAL_UART_Receive_DMA DMA RxCpltCallback

错误处理

函数 作用
HAL_UART_GetError 读错误码(ORE/FE/NE/PE)
HAL_UART_ErrorCallback 错误发生时的回调

中断相关

函数 作用
HAL_UART_IRQHandler 中断总入口,你要在中断服务函数里调它
__HAL_UART_ENABLE_IT 开某个中断(TXE/RXNE/TC/PE)
__HAL_UART_DISABLE_IT 关某个中断

7. 常见坑和调试技巧

坑1:中断优先级没配,回调不进来

CubeMX 里配了中断,NVIC 里 USART1 中断使能打勾了,但优先级没设。默认优先级可能是 0,和别的中断冲突,或者根本没打开。

检查: NVIC_SetPriority(USART1_IRQn, 1); NVIC_EnableIRQ(USART1_IRQn);


坑2:中断服务函数里没调 HAL_UART_IRQHandler

自己写了中断服务函数,但忘了调 HAL 的总入口,回调永远不触发。

复制代码
void USART1_IRQHandler(void)
{
    HAL_UART_IRQHandler(&huart1);  // 这行不能少
}

坑3:中断发送第二次不工作

第一次 HAL_UART_Transmit_IT 正常,第二次调就返回 HAL_BUSY

因为你没在回调里把状态清掉,或者回调没被调用。

排查: 在回调里打个断点,看有没有进来。如果没进来,查 TC 中断开没开。


坑4:接收中断丢数据,ORE 错误

中断服务函数里做了耗时操作(比如 printf、HAL_Delay),导致下一个字节来了没及时读 DR。

解决: 中断里只读 DR 存缓冲,复杂逻辑放主循环。


坑5:HAL_Delay 在中断里用,直接卡死

HAL_Delay 靠 systick 中断计时,如果你在 USART 中断里调 HAL_Delay,而 USART 中断优先级比 systick 高,systick 永远进不来,时间不走,直接死循环。

铁律: 中断里绝对不能调阻塞函数。


HAL 库深度拆解(进阶版)


1. HAL 库的完整文件结构

打开 STM32CubeF1 固件包,HAL 库文件分布在这几个目录里:

复制代码
Drivers/
├── STM32F1xx_HAL_Driver/
│   ├── Inc/
│   │   ├── stm32f1xx_hal.h              ← 总入口头文件
│   │   ├── stm32f1xx_hal_def.h          ← 公共定义
│   │   ├── stm32f1xx_hal_uart.h        ← UART 句柄定义、API 声明
│   │   ├── stm32f1xx_hal_uart.c        ← UART 所有函数实现
│   │   ├── stm32f1xx_hal_rcc.h/c       ← 时钟控制
│   │   ├── stm32f1xx_hal_gpio.h/c      ← GPIO
│   │   ├── stm32f1xx_hal_dma.h/c       ← DMA
│   │   ├── stm32f1xx_hal_cortex.h/c    ← NVIC、systick
│   │   └── ... (其他外设)
│   └── Src/
│       └── (同上的 .c 文件)
└── CMSIS/
    ├── Device/ST/STM32F1xx/
    │   ├── Include/
    │   │   ├── stm32f103xb.h           ← 寄存器定义(最底层)
    │   │   ├── system_stm32f1xx.c      ← 系统初始化
    │   └── Source/
    └── Include/
        └── core_cm3.h                   ← Cortex-M3 内核寄存器

两层划分:

  • CMSIS 层:ARM 和芯片厂提供的最底层,寄存器地址定义、内核寄存器、启动文件。你基本不用改。
  • HAL Driver 层:ST 提供的硬件抽象层,所有 HAL_xxx 函数都在这。

1.1 关键文件分工

文件 干什么
stm32f1xx.h 总头文件,根据芯片型号 include 对应的寄存器头文件
stm32f1xx_hal.h HAL 总头文件,include 所有外设头文件
stm32f1xx_hal.c HAL 初始化、systick、时间 base
stm32f1xx_hal_uart.c UART 所有函数实现
stm32f1xx_hal_uart.h UART 句柄、宏、回调声明
stm32f1xx_hal_def.h 公共类型定义、错误码

1.2 stm32f1xx_hal_conf.h------HAL 的总开关

这个文件在你的项目 User 目录里,CubeMX 生成的。它决定了哪些 HAL 模块被编译进来:

复制代码
// 你可以注释掉不用的外设,减小代码体积
#define HAL_MODULE_ENABLED
#define HAL_GPIO_MODULE_ENABLED
#define HAL_UART_MODULE_ENABLED
// #define HAL_SPI_MODULE_ENABLED   ← 不用 SPI 就注释掉
// #define HAL_I2C_MODULE_ENABLED

每打开一个模块,对应的 .c 文件就会参与编译。这就是为什么 CubeMX 配了什么外设,编译后的代码体积就对应多大。


2. HAL_UART_Transmit_IT 完整源码逐行拆解

之前讲了轮询发送的源码,这篇把中断发送的完整流程拆开------这是 HAL 里最经典的状态机实现,看懂了这一个,其他外设的 IT 模式都是同一个套路。

2.1 启动函数 HAL_UART_Transmit_IT

复制代码
HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, 
                                       uint8_t *pData, 
                                       uint16_t Size)
{
    // 第1步:检查发送状态是不是 READY
    if (huart->gState == HAL_UART_STATE_READY) {
        
        // 检查参数合法性
        if ((pData == NULL) || (Size == 0U)) {
            return HAL_ERROR;
        }
        
        // 第2步:锁住状态,防止重入
        // 把 TxState 设为 BUSY_TX,这样第二次调用就会返回 BUSY
        __HAL_LOCK(huart);
        huart->TxState = HAL_UART_STATE_BUSY_TX;
        
        // 第3步:把参数存进句柄,给中断里用
        huart->pTxBuffPtr = pData;        // 发送数据指针
        huart->TxXferSize = Size;         // 总长度
        huart->TxXferCount = Size;       // 剩余计数,初始=总长度
        
        // 第4个:关 TC 中断,开 TXE 中断
        // 为什么先关 TC?因为上次发送完可能 TC 标志还挂着,避免误触发
        __HAL_UART_DISABLE_IT(huart, UART_IT_TC);
        __HAL_UART_ENABLE_IT(huart, UART_IT_TXE);
        
        // 第5步:解锁
        __HAL_UNLOCK(huart);
        
        return HAL_OK;
    }
    
    // 状态不是 READY,说明上次还没发完
    return HAL_BUSY;
}

这函数干了什么?

  1. 把你要发的数据地址和长度记下来(存在句柄里);
  2. 开 TXE 中断;
  3. 立刻返回,CPU 可以去干别的。

真正的发送工作,全在中断里。


2.2 中断总入口 HAL_UART_IRQHandler

所有 USART 中断都从这里进。你的中断服务函数就一行:

复制代码
void USART1_IRQHandler(void)
{
    HAL_UART_IRQHandler(&huart1);
}

然后 HAL 内部帮你做分支判断。源码核心逻辑:

复制代码
void HAL_UART_IRQHandler(UART_HandleTypeDef *huart)
{
    uint32_t isrflags = READ_REG(huart->Instance->SR);  // 读一次 SR
    uint32_t cr1its   = READ_REG(huart->Instance->CR1); // 读中断使能
    
    // ====== 分支1:发送相关中断 ======
    
    // 分支1.1:TXE 中断(发送缓冲空)
    if (((isrflags & USART_SR_TXE) != 0U) && 
        ((cr1its & USART_CR1_TXEIE) != 0U)) {
        
        // 检查还有没有数据要发
        if (huart->TxXferCount > 0U) {
            huart->TxXferCount--;
            
            // 写 DR:把 pTxBuffPtr 指向的数据写进去
            huart->Instance->DR = (uint8_t)(*huart->pTxBuffPtr & 0xFF);
            
            // 指针往后挪
            huart->pTxBuffPtr++;
            
            // 发完最后一个字节了?
            if (huart->TxXferCount == 0U) {
                // 关 TXE 中断,开 TC 中断
                // 因为最后一个字节刚写进 TDR,还要等移位寄存器发完
                __HAL_UART_DISABLE_IT(huart, UART_IT_TXE);
                __HAL_UART_ENABLE_IT(huart, UART_IT_TC);
            }
        }
    }
    
    // 分支1.2:TC 中断(发送完成)
    if (((isrflags & USART_SR_TC) != 0U) && 
        ((cr1its & USART_CR1_TCIE) != 0U)) {
        
        // 关 TC 中断
        __HAL_UART_DISABLE_IT(huart, UART_IT_TC);
        
        // 状态设回 READY
        huart->TxState = HAL_UART_STATE_READY;
        
        // 调发送完成回调
        HAL_UART_TxCpltCallback(huart);
    }
    
    // ====== 分支2:接收相关中断 ======
    
    // 分支2.1:RXNE 中断(接收数据非空)
    if (((isrflags & USART_SR_RXNE) != 0U) && 
        ((cr1its & USART_CR1_RXNEIE) != 0U)) {
        
        // 读 DR:数据存进接收缓冲
        *huart->pRxBuffPtr = (uint8_t)(huart->Instance->DR & 0xFF);
        huart->pRxBuffPtr++;
        huart->RxXferCount--;
        
        if (huart->RxXferCount == 0U) {
            // 收够了,关接收中断
            __HAL_UART_DISABLE_IT(huart, UART_IT_RXNE);
            huart->RxState = HAL_UART_STATE_READY;
            
            // 调接收完成回调
            HAL_UART_RxCpltCallback(huart);
        }
    }
    
    // ====== 分支3:错误处理 ======
    
    // ORE/FE/NE/PE 错误
    if ((isrflags & (USART_SR_ORE | USART_SR_FE | USART_SR_NE)) != 0U) {
        // 读 DR 清错误标志
        (void)huart->Instance->DR;
        
        // 记录错误码
        huart->ErrorCode |= ...;
        
        // 调错误回调
        HAL_UART_ErrorCallback(huart);
    }
}

2.3 完整状态流转图

把上面的逻辑画成状态机:

复制代码
启动发送: HAL_UART_Transmit_IT()
    │
    ▼
状态: BUSY_TX
    │
    ├─ TXE中断: 写DR,TxXferCount--
    │       │
    │       ├─ 还有数据 → 继续等下一次TXE
    │       │
    │       └─ 最后一字节写完 → 关TXE,开TC
    │
    ├─ TC中断: 最后一字节也发完了
    │       │
    │       ▼
    │    状态: READY
    │       │
    │       ▼
    │    调 TxCpltCallback()
    │
    └─ 错误中断: ORE/FE/NE...
            │
            ▼
         调 ErrorCallback()

关键设计:为什么要分 TXE 和 TC 两个阶段?

  • TXE 阶段:负责连续把数据塞进 TDR,保持流水线;
  • TC 阶段:负责确认最后一个字节真正发完。

如果只用 TXE 中断,最后一个字节写完后 TXE 就置 1 了,但这时候数据还在移位寄存器里,你不知道什么时候真发完。所以必须切换到 TC 中断来等"真正结束"。


3. 回调函数完整清单

HAL UART 一共有 6 个回调函数,全部是 __weak 弱定义,你自己写同名函数就覆盖:

回调函数 触发时机
HAL_UART_TxCpltCallback 发送全部完成(TC 置位后)
HAL_UART_RxCpltCallback 接收满 len 个字节后
HAL_UART_ErrorCallback ORE/FE/NE/PE 错误发生时
HAL_UART_TxHalfCpltCallback DMA 发送发了一半(半传输)
HAL_UART_RxHalfCpltCallback DMA 接收收了一半
HAL_UART_AbortCpltCallback 主动中止发送/接收完成

3.1 回调函数的正确写法

复制代码
// 发送完成回调
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        // USART1 发完了
        tx_done = 1;
    }
    else if (huart->Instance == USART2) {
        // USART2 发完了
        // 多串口共用一个回调,靠 Instance 区分
    }
}

// 接收完成回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        // 把收到的数据从 rx_buf 挪到处理队列
        // 注意:这里不能做耗时操作
    }
}

// 错误回调
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
    uint32_t err = HAL_UART_GetError(huart);
    
    if (err & HAL_UART_ERROR_ORE) {
        ore_count++;  // 溢出计数
    }
    if (err & HAL_UART_ERROR_FE) {
        fe_count++;   // 帧错误计数
    }
    
    // 错误发生后,通常需要重新启动接收
    // 因为 HAL 出错后会自动停掉接收
    HAL_UART_Receive_IT(huart, rx_buf, RX_BUF_SIZE);
}

3.2 回调里的铁律

  1. 回调函数在中断上下文执行,不能调阻塞函数(HAL_DelayprintfHAL_UART_Transmit);
  2. 不能在回调里加 while(1),否则整个系统卡住;
  3. 变量要加 volatile,因为主循环里要读回调里改的标志;
  4. 多串口共用回调时,靠 Instance 字段区分。

4. 错误码机制

ErrorCode 是句柄里的一个字段,记录最近发生的错误:

复制代码
typedef enum {
    HAL_UART_ERROR_NONE  = 0x00,   // 无错误
    HAL_UART_ERROR_PE    = 0x01,   // 校验错误
    HAL_UART_ERROR_NE    = 0x02,   // 噪声错误
    HAL_UART_ERROR_FE    = 0x04,   // 帧错误
    HAL_UART_ERROR_ORE   = 0x08,   // 溢出错误
    HAL_UART_ERROR_DMA   = 0x10,   // DMA 错误
} HAL_UART_ErrorTypeDef;

读错误码:

复制代码
uint32_t err = HAL_UART_GetError(&huart1);
if (err & HAL_UART_ERROR_ORE) {
    // 处理溢出
}

注意: ErrorCode 是累积的,发生一次错就置位,不会自动清。你读完要手动清:

复制代码
huart1.ErrorCode = HAL_UART_ERROR_NONE;

5. MspInit:硬件初始化的回调层

5.1 为什么需要 MspInit

你有没有发现一个问题:HAL_UART_Init 函数里,只配置了 USART 的寄存器(波特率、帧格式),但没配 GPIO、没开时钟。

那 GPIO 和时钟是谁配的?

答案是:HAL_UART_Init 内部会调用 HAL_UART_MspInit,这个函数是弱定义的,让你在自己的代码里实现,配 GPIO 和时钟。

复制代码
// HAL 库里的弱定义:
__weak void HAL_UART_MspInit(UART_HandleTypeDef *huart)
{
    /* 空的,什么都不做 */
}

// 你在 stm32f1xx_hal_msp.c 里实现:
void HAL_UART_MspInit(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        // 1. 开时钟
        __HAL_RCC_USART1_CLK_ENABLE();
        __HAL_RCC_GPIOA_CLK_ENABLE();
        
        // 2. 配 GPIO
        GPIO_InitTypeDef gpio = {0};
        
        // PA9: TX,复用推挽
        gpio.Pin = GPIO_PIN_9;
        gpio.Mode = GPIO_MODE_AF_PP;
        gpio.Speed = GPIO_SPEED_FREQ_HIGH;
        HAL_GPIO_Init(GPIOA, &gpio);
        
        // PA10: RX,浮空输入
        gpio.Pin = GPIO_PIN_10;
        gpio.Mode = GPIO_MODE_INPUT;
        gpio.Pull = GPIO_NOPULL;
        HAL_GPIO_Init(GPIOA, &gpio);
        
        // 3. 配中断优先级
        HAL_NVIC_SetPriority(USART1_IRQn, 1, 0);
        HAL_NVIC_EnableIRQ(USART1_IRQn);
    }
}

5.2 MspInit 的设计意义

为什么不把 GPIO 和时钟配置直接写在 HAL_UART_Init 里?

因为:同一个 USART,可以接不同的 GPIO 引脚。比如 USART1 可以接 PA9/PA10,也可以重映射到 PB6/PB7。HAL 不知道你用哪组引脚,所以留个回调让你自己配。

所有 HAL 外设都有 MspInit:HAL_SPI_MspInitHAL_I2C_MspInitHAL_TIM_MspInit......都是同一个套路。


6. 实战:环形缓冲接收完整工程模板

这是实际项目里最常用的接收方案:中断里快速收字节到环形缓冲,主循环慢慢处理。不丢数据、不定长、不阻塞。

6.1 环形缓冲结构

复制代码
#define RX_BUF_SIZE  256

typedef struct {
    uint8_t  buf[RX_BUF_SIZE];
    uint16_t head;    // 写位置:中断里写
    uint16_t tail;    // 读位置:主循环里读
} RingBuffer;

RingBuffer rx_ring = {0};

6.2 启动接收

复制代码
// main.c 初始化里
uint8_t rx_byte;  // 每次中断收一个字节

// 启动单次接收,收一个字节就进回调
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);

6.3 接收回调:每次一个字节,塞进环形缓冲

复制代码
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        // 把刚收到的字节写进环形缓冲
        uint16_t next = (rx_ring.head + 1) % RX_BUF_SIZE;
        
        // 缓冲没满才写
        if (next != rx_ring.tail) {
            rx_ring.buf[rx_ring.head] = rx_byte;
            rx_ring.head = next;
        }
        
        // 重新启动下一次接收!
        // 因为 HAL_UART_Receive_IT 收完 len 个就停了,必须重启
        HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
    }
}

6.4 错误回调:出错了重启接收

复制代码
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        // 读一下 DR 清 ORE
        volatile uint32_t tmp = huart->Instance->DR;
        (void)tmp;
        
        // 清错误码
        huart->ErrorCode = HAL_UART_ERROR_NONE;
        
        // 重启接收
        HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
    }
}

6.5 主循环:从环形缓冲读数据处理

复制代码
uint8_t uart_read_byte(void)
{
    uint8_t data = 0;
    
    if (rx_ring.tail != rx_ring.head) {
        data = rx_ring.buf[rx_ring.tail];
        rx_ring.tail = (rx_ring.tail + 1) % RX_BUF_SIZE;
    }
    
    return data;
}

// 主循环
while (1) {
    // 有没有数据?
    while (rx_ring.tail != rx_ring.head) {
        uint8_t c = uart_read_byte();
        process_char(c);  // 你自己的处理逻辑,怎么慢都没关系
    }
    
    // 干别的事
    do_other_things();
}

6.6 这个方案为什么稳

  1. 中断里只做一件事:收一个字节存缓冲,重启接收------整个中断服务函数不超过 10 个指令周期,永远不会因为处理慢导致 ORE;
  2. 处理逻辑全在主循环------你在主循环里怎么 printf、怎么延时都不影响接收;
  3. 环形缓冲自动管理------收满了自动从头覆盖,不会溢出内存;
  4. 错误回调自动重启------ORE 了也能自己恢复,不用人工干预。

7. HAL 库常见面试题

Q1:HAL_UART_Transmit 和 HAL_UART_Transmit_IT 的本质区别?

答:前者是轮询模式,函数内部死等 TXE/TC,不返回直到发完;后者是中断模式,函数只把数据地址和长度记进句柄、开 TXE 中断,立刻返回,真正的发送工作在中断里完成。


Q2:HAL_UART_Receive_IT 为什么收满 len 个字节就停了?

答:因为句柄里的 RxXferCount 每收一个减一,减到 0 就关 RXNE 中断、调回调。如果想连续接收,必须在回调里重新调一次 HAL_UART_Receive_IT 重启接收。


Q3:__weak 关键字是干什么的?

答:弱定义。如果用户代码里写了同名函数,链接器优先用用户的;没写就用 HAL 默认的空函数。HAL 的所有回调都是弱定义,就是为了让用户方便覆盖。


Q4:HAL_UART_MspInit 什么时候被调用?

答:HAL_UART_Init 内部调用。负责配 GPIO、开时钟、配 NVIC。HAL 把硬件相关的初始化从外设初始化里抽出来,做成回调,是为了适配同一外设不同引脚复用的情况。


Q5:中断接收丢数据,怎么用 HAL 的机制排查?

答:

  1. HAL_UART_ErrorCallback 里打个断点;
  2. huart->ErrorCode,看 ORE 位是不是置 1;
  3. 如果 ORE 置 1,说明中断服务函数执行太慢,CPU 没及时读 DR;
  4. 解决:在回调里只做存缓冲的操作,把处理逻辑移到主循环。

Q6:HAL 库怎么减少代码体积?

答:在 stm32f1xx_hal_conf.h 里注释掉不用的外设模块宏,对应的 .c 文件就不会被编译进来。另外可以开 -Os 编译选项,优化代码体积。


相关推荐
恒锐丰科技林技术员1 小时前
EG1192S:高耐压零功耗使能降压 DC‑DC 芯片,拓宽工业电源设计边界
经验分享·嵌入式硬件·硬件工程
深圳市恒锐丰科技杨生2 小时前
EG1192L 零功耗使能集成上管非同步降压 DC‑DC|屹晶 EGmicro
嵌入式硬件·硬件工程
JNX_SEMI2 小时前
HTD8239 5.5V~36V宽压H桥驱动:适配12V/24V主流电机供电系统
单片机·嵌入式硬件
2401_862880822 小时前
51单片机 --- GPIO、中断、定时器、PWM
单片机·嵌入式硬件·51单片机
零依赖极客2 小时前
《30 天手搓 ARM 架构零依赖纯 C 推理引擎》课程介绍与大纲
c语言·开发语言·arm开发·人工智能·嵌入式硬件·ai编程
国科安芯2 小时前
基于ECC加固的抗辐射MCU在卫星载荷数据管理中的应用分析
单片机·嵌入式硬件·mcu·抗辐射·ecc加固·卫星数据管理
深圳市恒锐丰科技杨生2 小时前
EG3033 三相 P‑N MOS 栅极驱动芯片|屹晶 EGmicro
嵌入式硬件·硬件工程
linx2953 小时前
单元三 · 那 C 的底层知识怎么办
c语言·开发语言·数据结构·c++·嵌入式硬件
abc light3 小时前
单片机(1)
单片机·嵌入式硬件