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 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 中断处理的抽象
中断是飞控里最头疼的部分。不同芯片的中断号、优先级分组都不一样。我的做法是:
- 在HAL层注册中断回调:上层通过函数指针注册回调
- 在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