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 之前先记住三个设计原则,后面所有东西都是围绕这三个来的:
- 句柄(Handle)驱动一切:每个外设有一个结构体,把寄存器基地址、状态、配置参数全装进去,函数第一个参数都是这个结构体指针;
- 三种模式并行:每个外设都支持轮询、中断、DMA 三种工作模式,用同一套句柄管理状态;
- 回调与状态机:异步操作(中断、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_Transmit、HAL_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);这行做了什么:
- 检查
TxState == READY;- 把
data地址存进pTxBuffPtr;- 把
len存进TxXferSize,TxXferCount也设为 len;- 开 TXE 中断;
- 把
TxState设为BUSY_TX;- 立刻返回
HAL_OK。然后呢?CPU 可以干别的去了。硬件每发完一个字节,TXE 置 1,触发中断,进入
USART1_IRQHandler:
void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); // HAL的中断总入口 }
HAL_UART_IRQHandler内部做了什么:
- 查 SR,看是 TXE 中断还是 RXNE 中断还是错误中断;
- 如果是 TXE:
- 从
pTxBuffPtr读一个字节;- 写进 DR;
pTxBuffPtr++,TxXferCount--;- 如果
TxXferCount减到 0:关 TXE 中断,开 TC 中断;- 如果是 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.WordLength、StopBits、Parity、Mode配置帧格式和收发使能。第五步:最后置 UE 位 开启 USART 外设。
整个过程就是:你在结构体里填参数,HAL 函数帮你翻译成寄存器位。
6. 常用 API 速查表
发送
函数 模式 阻塞? 回调 HAL_UART_Transmit轮询 是 无 HAL_UART_Transmit_IT中断 否 TxCpltCallbackHAL_UART_Transmit_DMADMA 否 TxCpltCallback接收
函数 模式 阻塞? 回调 HAL_UART_Receive轮询 是 无 HAL_UART_Receive_IT中断 否 RxCpltCallbackHAL_UART_Receive_DMADMA 否 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.hHAL 总头文件,include 所有外设头文件 stm32f1xx_hal.cHAL 初始化、systick、时间 base stm32f1xx_hal_uart.cUART 所有函数实现 stm32f1xx_hal_uart.hUART 句柄、宏、回调声明 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; }这函数干了什么?
- 把你要发的数据地址和长度记下来(存在句柄里);
- 开 TXE 中断;
- 立刻返回,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_ErrorCallbackORE/FE/NE/PE 错误发生时 HAL_UART_TxHalfCpltCallbackDMA 发送发了一半(半传输) HAL_UART_RxHalfCpltCallbackDMA 接收收了一半 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 回调里的铁律
- 回调函数在中断上下文执行,不能调阻塞函数(
HAL_Delay、printf、HAL_UART_Transmit);- 不能在回调里加
while(1),否则整个系统卡住;- 变量要加
volatile,因为主循环里要读回调里改的标志;- 多串口共用回调时,靠
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_MspInit、HAL_I2C_MspInit、HAL_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 这个方案为什么稳
- 中断里只做一件事:收一个字节存缓冲,重启接收------整个中断服务函数不超过 10 个指令周期,永远不会因为处理慢导致 ORE;
- 处理逻辑全在主循环------你在主循环里怎么 printf、怎么延时都不影响接收;
- 环形缓冲自动管理------收满了自动从头覆盖,不会溢出内存;
- 错误回调自动重启------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 的机制排查?
答:
- 在
HAL_UART_ErrorCallback里打个断点;- 读
huart->ErrorCode,看 ORE 位是不是置 1;- 如果 ORE 置 1,说明中断服务函数执行太慢,CPU 没及时读 DR;
- 解决:在回调里只做存缓冲的操作,把处理逻辑移到主循环。
Q6:HAL 库怎么减少代码体积?
答:在
stm32f1xx_hal_conf.h里注释掉不用的外设模块宏,对应的.c文件就不会被编译进来。另外可以开-Os编译选项,优化代码体积。

