2. 硬件抽象层(HAL)设计:如何隔离MCU寄存器操作

做飞控底层移植,最头疼的是什么?

我个人觉得,就是换芯片时那堆寄存器操作。STM32换到GD32,或者换到国产的AT32,底层代码几乎要重写。你想想看,一个飞控项目几十个外设驱动,每个都跟寄存器强耦合,那移植工作量简直要命。

所以,硬件抽象层(HAL)就是来解决这个问题的。说白了,就是在你的应用代码和硬件寄存器之间,加一层「翻译官」。上层代码只跟HAL打交道,HAL再去操作具体寄存器。换芯片?只改HAL底层就行。

核心思想: 上层代码永远不直接读写寄存器。所有硬件操作都通过HAL接口完成。

2.1 为什么需要HAL?

我在一个无人机项目里吃过亏。当时用的STM32F4,飞控跑得好好的。后来客户要求换国产芯片降成本,我天真地以为改改引脚配置就行。结果呢?定时器寄存器结构不一样,DMA描述符格式不同,ADC触发方式也变了...整整折腾了两周。

从那以后,我学乖了。所有新项目,第一件事就是搭HAL层。

HAL的好处很明显:

  • 可移植性:换MCU时,只改HAL底层,上层代码不动
  • 可测试性:可以写模拟的HAL层,在PC上跑飞控算法
  • 可读性:代码里看到的是 HAL_GPIO_WritePin(),而不是 GPIOA->ODR |= 0x01
  • 团队协作:上层开发不用关心底层寄存器细节

2.2 HAL的分层设计

我习惯把HAL分成三层,每层干每层的活:

HAL三层架构 应用层 (Application) 飞控算法、姿态解算、控制逻辑 HAL API层 (硬件抽象接口) HAL_GPIO_Init()、HAL_TIM_PWM_Start()、HAL_SPI_Transmit() MCU适配层 (MCU Adapter) 寄存器操作、时钟配置、中断向量表 硬件层 (Hardware - MCU寄存器)

嗯,这里要注意:应用层绝对不能跳过HAL API直接调用MCU适配层。这是红线,跨了就失去HAL的意义了。

2.3 具体怎么设计?

拿GPIO来举例。这是最常用的外设,也是寄存器操作最直观的。

2.3.1 定义接口头文件

先定义一套通用的接口,跟具体芯片无关:

/* hal_gpio.h - 硬件抽象层GPIO接口 */
#ifndef __HAL_GPIO_H__
#define __HAL_GPIO_H__

#include <stdint.h>

/* GPIO引脚号枚举 */
typedef enum {
    HAL_GPIO_PIN_0  = 0,
    HAL_GPIO_PIN_1  = 1,
    /* ... 省略中间 ... */
    HAL_GPIO_PIN_15 = 15
} hal_gpio_pin_t;

/* GPIO模式枚举 */
typedef enum {
    HAL_GPIO_MODE_INPUT,      /* 输入模式 */
    HAL_GPIO_MODE_OUTPUT_PP,  /* 推挽输出 */
    HAL_GPIO_MODE_OUTPUT_OD,  /* 开漏输出 */
    HAL_GPIO_MODE_AF_PP,      /* 复用推挽 */
    HAL_GPIO_MODE_AF_OD       /* 复用开漏 */
} hal_gpio_mode_t;

/* GPIO配置结构体 */
typedef struct {
    hal_gpio_pin_t  pin;
    hal_gpio_mode_t mode;
    uint8_t         speed;    /* 0:低, 1:中, 2:高, 3:极高 */
    uint8_t         pull;     /* 0:无, 1:上拉, 2:下拉 */
} hal_gpio_config_t;

/* 初始化GPIO */
void hal_gpio_init(hal_gpio_config_t *config);

/* 写引脚电平 */
void hal_gpio_write_pin(hal_gpio_pin_t pin, uint8_t level);

/* 读引脚电平 */
uint8_t hal_gpio_read_pin(hal_gpio_pin_t pin);

/* 翻转引脚电平 */
void hal_gpio_toggle_pin(hal_gpio_pin_t pin);

#endif /* __HAL_GPIO_H__ */

小技巧: 接口参数尽量用枚举和结构体,别用裸的int。这样调用者一看就知道传什么值,不容易出错。

2.3.2 实现MCU适配层

这部分才是真正跟寄存器打交道的。以STM32为例:

/* hal_gpio_stm32.c - STM32平台的GPIO适配层 */
#include "hal_gpio.h"
#include "stm32f4xx.h"  /* 芯片厂商提供的寄存器定义 */

/* 引脚号到GPIO端口的映射表 */
static GPIO_TypeDef* gpio_port_map[] = {
    GPIOA, GPIOB, GPIOC, GPIOD, GPIOE, GPIOF, GPIOG
};

void hal_gpio_init(hal_gpio_config_t *config)
{
    GPIO_TypeDef *port;
    uint32_t pin_mask;
    uint32_t mode_reg_val;
    
    /* 计算端口和引脚掩码 */
    port = gpio_port_map[config->pin / 16];
    pin_mask = 1UL << (config->pin % 16);
    
    /* 开启时钟 - 这里简化了,实际要查具体端口 */
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    
    /* 配置模式寄存器 */
    mode_reg_val = port->MODER;
    mode_reg_val &= ~(0x3UL << (config->pin % 16) * 2);
    mode_reg_val |= (config->mode & 0x3) << (config->pin % 16) * 2;
    port->MODER = mode_reg_val;
    
    /* 配置上下拉 */
    /* ... 类似操作PUPDR寄存器 ... */
}

void hal_gpio_write_pin(hal_gpio_pin_t pin, uint8_t level)
{
    GPIO_TypeDef *port = gpio_port_map[pin / 16];
    uint32_t pin_mask = 1UL << (pin % 16);
    
    if (level) {
        port->BSRR = pin_mask;      /* 置位 */
    } else {
        port->BSRR = pin_mask << 16; /* 复位 */
    }
}

我曾经踩过的坑: 寄存器操作一定要用读-改-写模式,千万别直接赋值。比如配置MODER时,如果直接写 port->MODER = 0x01,会把其他引脚的模式给覆盖掉。我有个同事就这样把调试口给关了,芯片直接变砖。

2.4 更高级的抽象:外设句柄

对于定时器、SPI、I2C这类复杂外设,我建议用「句柄」的方式管理。每个外设实例对应一个句柄,句柄里存配置信息和状态:

/* hal_timer.h */
typedef struct {
    uint32_t            instance;    /* 定时器编号: 1,2,3... */
    uint32_t            prescaler;   /* 预分频值 */
    uint32_t            period;      /* 自动重装载值 */
    void               (*callback)(void);  /* 中断回调 */
    /* 内部状态 - 用户不用管 */
    uint8_t             is_initialized;
    uint32_t            reserved;
} hal_timer_handle_t;

/* 初始化定时器 */
int hal_timer_init(hal_timer_handle_t *handle);

/* 启动PWM输出 */
int hal_timer_pwm_start(hal_timer_handle_t *handle, uint32_t channel);

/* 设置占空比 */
int hal_timer_pwm_set_duty(hal_timer_handle_t *handle, 
                           uint32_t channel, uint32_t duty);

这样设计的好处是,上层代码只需要维护一个句柄数组,换芯片时句柄结构体可能变,但接口不变。

2.5 中断处理的抽象

中断是飞控里最头疼的部分。不同芯片的中断号、优先级分组都不一样。我的做法是:

  1. 在HAL层注册中断回调:上层通过函数指针注册回调
  2. 在MCU适配层处理中断向量:芯片相关的中断服务函数里,调用HAL层的回调
/* 在MCU适配层 */
void TIM2_IRQHandler(void)
{
    /* 清除中断标志 */
    TIM2->SR = ~TIM_SR_UIF;
    
    /* 调用HAL层的回调 */
    hal_timer_irq_handler(&timer2_handle);
}

/* 在HAL层 */
void hal_timer_irq_handler(hal_timer_handle_t *handle)
{
    if (handle->callback) {
        handle->callback();  /* 执行用户注册的回调 */
    }
}

关键点: 中断服务函数里只做最必要的事——清标志、调回调。千万别在中断里做复杂运算,那是飞控算法层的事。

2.6 实际项目中的HAL组织方式

我一般这样组织文件结构:

project/
├── hal/                    /* HAL层 - 平台无关 */
│   ├── inc/
│   │   ├── hal_gpio.h
│   │   ├── hal_timer.h
│   │   ├── hal_spi.h
│   │   └── hal_uart.h
│   └── src/
│       └── hal_common.c    /* HAL层公共函数 */
│
├── mcu/                    /* MCU适配层 - 平台相关 */
│   ├── stm32f4/
│   │   ├── hal_gpio_stm32.c
│   │   ├── hal_timer_stm32.c
│   │   └── startup_stm32f4.s
│   ├── gd32f3/
│   │   ├── hal_gpio_gd32.c
│   │   ├── hal_timer_gd32.c
│   │   └── startup_gd32f3.s
│   └── at32f4/
│       ├── hal_gpio_at32.c
│       ├── hal_timer_at32.c
│       └── startup_at32f4.s
│
├── app/                    /* 应用层 - 飞控算法 */
│   ├── attitude.c
│   ├── control.c
│   └── sensor.c
│
└── build/
    └── Makefile

换芯片时,只需要换 mcu/ 目录下的文件,hal/app/ 基本不动。我在一个项目里,从STM32F4换到GD32F3,只花了半天改底层,上层飞控代码一行没动。

2.7 避坑指南

我曾经犯过的错:

  • 别在HAL层做延时:HAL层只负责硬件操作,延时是应用层的事。否则换芯片后延时不准,整个飞控时序都乱掉。
  • 别把芯片特有的功能暴露出去:比如STM32的位带操作、DMA双缓冲,这些特性很强,但其他芯片不一定有。强行暴露会导致移植困难。
  • 注意字节序:不同MCU的字节序可能不同。SPI、I2C通信时,HAL层要做好字节序转换。
  • 中断优先级要统一:不同芯片的中断优先级位数不同。我建议HAL层只暴露「高、中、低」三个等级,具体映射到芯片的优先级寄存器由适配层处理。

2.8 小结

硬件抽象层设计,说白了就是「多一层间接,少一份痛苦」。做飞控这种对实时性和可靠性要求高的系统,HAL层更是必不可少。

我个人习惯是:先定义接口,再实现适配。接口要通用、简洁,适配层要高效、完整。别想着一步到位,HAL层也是迭代出来的。刚开始可能只抽象了GPIO和定时器,慢慢把SPI、I2C、DMA都加进来。

记住一句话:好的HAL层,让你换芯片像换衣服一样简单。不好的HAL层,换芯片就像换皮一样痛苦。


公众号:蓝海资料掘金营,微信deep3321