2. MCU体系结构与外设基础

HAL库,说白了就是ST官方给我们搭好的一套软件框架。很多初学者觉得它就是个API集合,调就完了。但我在项目中踩过几次坑之后才真正明白——不理解HAL库的层次结构,你写出来的代码要么跑不起来,要么性能一塌糊涂。

这一章,我带你把HAL库的骨架拆开来看。从LL层到BSP层,从句柄机制到三种工作模式,咱们一个一个过。

2.1 HAL库的三层结构:LL / HAL / BSP

HAL库不是铁板一块。它分了三层,每层干的事不一样。我习惯用一张图来理解:

HAL库三层架构 BSP层(板级支持包) 板级初始化、外设引脚映射、板级驱动接口 HAL层(硬件抽象层) 外设通用API、句柄管理、中断/DMA框架 LL层(底层驱动层) 寄存器直接操作、极简函数、无状态、高性能 越往下越接近硬件,性能越高;越往上越抽象,移植性越好

LL层:寄存器级的裸操作

LL层是离硬件最近的一层。它直接操作寄存器,没有状态保存,没有超时判断。说白了就是给寄存器操作包了一层宏定义和inline函数。

LL层的典型特征:

  • 函数名以 LL_ 开头,比如 LL_GPIO_SetOutputPin
  • 执行速度快,几乎没有额外开销
  • 不保存句柄,不维护状态机
  • 适合对时序要求苛刻的场景
我的经验:在SPI通信的位操作、I2C的起始信号生成这类微秒级时序场景,我从来不用HAL层,直接用LL层。有一次做LED点阵屏驱动,HAL层的SPI发送函数一次要20μs,换成LL层直接压到3μs。

HAL层:通用性与可移植性的平衡点

HAL层是我们日常开发接触最多的。它封装了外设的完整操作流程,包括初始化、配置、数据收发、中断处理等。

HAL层的核心设计:

  • 每个外设对应一个句柄结构体
  • 支持轮询、中断、DMA三种模式
  • 内置超时机制和错误处理
  • 回调函数机制,方便用户扩展

嗯,这里要注意——HAL层的代码量比较大,一个简单的GPIO翻转,HAL层可能要执行几十条指令。如果你在中断服务函数里调HAL层的API,很容易把中断响应时间拖长。

BSP层:板级定制的最后一公里

BSP层是ST官方没有直接提供的。它需要你自己写。BSP层负责把芯片上的外设映射到具体的板级功能上。

举个例子:同样是USART1,在A板子上接的是RS232,在B板子上接的是RS485。HAL层的USART驱动是一样的,但BSP层要处理收发使能引脚、电平转换配置这些板级差异。

BSP层通常包含:

  • 板级初始化函数(时钟、引脚、外设)
  • 板级外设接口(比如 BSP_LED_On、BSP_UART_Send)
  • 板级配置宏定义(引脚号、时钟源、中断优先级)
避坑指南:我曾经在一个项目里直接把HAL层的初始化代码写在main函数里,结果换板子的时候改了几十个引脚配置。后来老老实实抽了一层BSP,换板子只需要改一个头文件。

2.2 句柄机制与回调函数:HAL库的"对象化"设计

HAL库虽然是C语言写的,但它借鉴了面向对象的思想。核心就是句柄机制。

句柄是什么?

句柄就是一个结构体,保存了外设的所有状态信息。每个外设实例对应一个句柄。

// UART句柄结构体(简化版)
typedef struct {
  USART_TypeDef *Instance;      // 外设寄存器基地址
  UART_InitTypeDef Init;        // 初始化参数
  uint8_t *pTxBuffPtr;          // 发送缓冲区指针
  uint16_t TxXferSize;          // 发送数据长度
  uint16_t TxXferCount;         // 剩余发送字节数
  uint16_t RxXferSize;          // 接收数据长度
  uint16_t RxXferCount;         // 剩余接收字节数
  void (*RxCpltCallback)(struct __UART_HandleTypeDef *huart);  // 接收完成回调
  void (*TxCpltCallback)(struct __UART_HandleTypeDef *huart);  // 发送完成回调
  // ... 其他成员
} UART_HandleTypeDef;

你看这个结构体,它把外设实例、配置参数、数据缓冲区、状态标志、回调函数全放在一起了。这就是HAL库的"对象"。

句柄机制的好处:

  • 支持多实例——你可以同时用两个USART,每个有自己的句柄
  • 状态自包含——中断服务函数里通过句柄就能知道当前在发什么数据
  • 便于移植——换芯片只需要改句柄里的Instance指针

回调函数:让HAL库"通知"你

回调函数是HAL库实现异步操作的关键。你不需要在中断里写业务逻辑,只需要注册一个回调函数,HAL库在合适的时候会调用它。

典型的回调流程:

  1. 你调用 HAL_UART_Receive_IT 启动中断接收
  2. 硬件收到一个字节,触发中断
  3. HAL库的中断服务函数处理数据,判断是否收完
  4. 收完后,HAL库调用你注册的 RxCpltCallback
  5. 你在回调函数里处理收到的数据
关键点:回调函数是在中断上下文中执行的。不要在回调函数里做耗时操作,比如打印、延时、复杂计算。我见过有人直接在回调里调printf,结果串口输出乱码——因为printf本身可能依赖同一个串口。

2.3 三种工作模式:轮询 / 中断 / DMA

HAL库为每个外设提供了三种工作模式。我刚开始学的时候觉得选哪个都行,后来发现选错了模式,整个系统的实时性就崩了。

模式 CPU占用 实时性 数据量 典型场景
轮询(Polling) 高(一直等) 差(阻塞) 小 按键扫描、简单传感器读取
中断(Interrupt) 低(触发才处理) 好(响应快) 中 串口通信、外部事件检测
DMA(Direct Memory Access) 极低(硬件搬运) 最好(不占CPU) 大 ADC连续采样、SPI刷屏、音频流

轮询模式:简单但低效

轮询模式就是CPU一直在循环里检查标志位。代码写起来最简单,但CPU被占死了。

// 轮询模式接收
HAL_UART_Receive(&huart1, buffer, 10, 1000);
// 这行代码会阻塞在这里,直到收到10个字节或超时

你想想看,如果主循环里还有其他任务要跑,轮询模式就把整个系统拖慢了。我一般只在初始化阶段或者调试的时候用轮询。

中断模式:实时响应的基础

中断模式是嵌入式系统最常用的方式。外设完成操作后触发中断,CPU暂停当前任务去处理,处理完再回来。

// 中断模式启动接收
HAL_UART_Receive_IT(&huart1, buffer, 10);
// 这行立即返回,CPU可以干别的事
// 收到10个字节后,自动调用 HAL_UART_RxCpltCallback

中断模式的注意事项:

  • 中断服务函数要短小精悍,只做必要的数据搬运
  • 多个中断要有优先级区分,避免嵌套过深
  • 中断频率太高会拖慢系统——比如1MHz的SPI中断,CPU基本干不了别的
我的习惯:中断里只做三件事——读数据、清标志、置位一个事件标志。真正的数据处理放在主循环里。这样既保证了实时性,又不会让中断占用太多CPU时间。

DMA模式:解放CPU的终极方案

DMA模式是硬件级别的数据搬运工。CPU只需要告诉DMA控制器:从哪搬、搬到哪、搬多少。然后DMA自己干活,搬完了通知CPU。

// DMA模式启动接收
HAL_UART_Receive_DMA(&huart1, buffer, 1024);
// CPU立即返回,DMA在后台搬运数据
// 搬完1024个字节后,调用 HAL_UART_RxCpltCallback

DMA模式的优势:

  • CPU零负担——数据搬运完全由硬件完成
  • 适合大数据量——比如音频采样、图像数据、高速ADC
  • 可以配合双缓冲——一个缓冲区在搬运,另一个在处理
避坑指南:我曾经在一个项目里用DMA做SPI刷屏,结果画面有撕裂。查了两天才发现是DMA传输完成中断和缓冲区切换的时序没配合好。后来加了双缓冲和同步信号,问题才解决。DMA不是万能的,缓存一致性和同步问题一定要处理好。

2.4 时间基准与延时实现

HAL库需要一个时间基准,用来实现超时判断和延时函数。这个时间基准通常由SysTick定时器提供。

HAL_Delay的实现原理

HAL_Delay 依赖一个全局变量 uwTick,这个变量在SysTick中断里每1ms加1。

// SysTick中断服务函数(每1ms执行一次)
void SysTick_Handler(void) {
  HAL_IncTick();  // uwTick += 1
}

// HAL_Delay的实现
void HAL_Delay(uint32_t Delay) {
  uint32_t tickstart = HAL_GetTick();
  while ((HAL_GetTick() - tickstart) < Delay) {
    // 空循环等待
  }
}

你看,HAL_Delay 本质上是一个忙等待。在等待期间,CPU一直在循环里转。这在裸机系统里没问题,但在RTOS里就出问题了——它会阻塞整个系统。

时间基准的替代方案

如果你在RTOS里用HAL库,一定要修改时间基准。我常用的做法:

  • 用RTOS的软件定时器替代SysTick
  • 或者用另一个硬件定时器作为HAL的时间基准
  • 在RTOS任务里用 osDelay 替代 HAL_Delay
重要提醒:在FreeRTOS里直接调用 HAL_Delay 会导致系统卡死。因为 HAL_Delay 是忙等待,而FreeRTOS的调度依赖SysTick中断。如果SysTick被HAL占用了,FreeRTOS就没办法切换任务了。这个坑我帮好几个同事排查过。

微秒级延时的实现

HAL_Delay 只能做到毫秒级。如果你需要微秒级延时,比如驱动DS18B20这种单总线器件,就得自己实现。

// 微秒级延时(基于定时器)
void delay_us(uint32_t us) {
  // 假设TIM2的时钟频率为1MHz(1us计数一次)
  __HAL_TIM_SET_COUNTER(&htim2, 0);
  while (__HAL_TIM_GET_COUNTER(&htim2) < us);
}

// 或者用指令周期估算(不精确,但够用)
void delay_us_approx(uint32_t us) {
  for (uint32_t i = 0; i < us * 8; i++) {
    __NOP();  // 空指令,约125ns
  }
}

嗯,这里要注意——用指令周期估算的延时受编译器优化等级影响很大。调试模式下延时100μs,Release模式下可能只有20μs。我建议用定时器实现微秒延时,虽然多占一个定时器,但精度有保障。

我的经验:在项目里我一般预留一个16位定时器专门做微秒延时。TIM2或者TIM3都行,配置成1MHz的计数频率,用起来特别方便。而且16位定时器最大能计65ms,覆盖大部分微秒级场景了。

好了,HAL库的架构核心就这些。从三层结构到句柄回调,从三种模式到时间基准,这些东西搞明白了,后面写驱动、做移植、调性能,你心里就有底了。