第4章:硬件抽象层(HAL)设计:如何为海思芯片编写统一的硬件抽象层,隔离芯片差异

做车载嵌入式开发的朋友,应该都遇到过这种场景:项目刚定型,芯片选型是海思的某款,代码写了大半年,突然被告知要换另一款海思芯片。或者更惨,同一套代码要同时适配高低配两个平台。这时候,如果没有一个靠谱的硬件抽象层,那真是欲哭无泪。

我自己的经验是,HAL设计得好不好,直接决定了项目后期是「喝茶看报」还是「加班改Bug」。说白了,HAL就是给上层软件一个统一的接口,把底层芯片的差异全部吃掉。今天我们就聊聊,怎么为海思芯片设计这样一个层。

4.1 为什么需要HAL?

海思芯片家族很庞大。从手机端的麒麟,到车载的昇腾、巴龙,再到监控领域的Hi35xx系列,每款芯片的外设寄存器地址、中断号、DMA通道、时钟配置都不一样。

举个例子。同样是I2C控制器,Hi3559A的寄存器基地址是0x120B0000,而Hi3516DV300的基地址是0x120C0000。如果你在应用层直接操作寄存器,换芯片就得改所有代码。这谁受得了?

所以,我们需要一个中间层。它负责:

  • 封装硬件差异:把不同芯片的寄存器操作、中断处理、DMA配置统一起来
  • 提供标准接口:上层应用只调用HAL的API,不关心底层实现
  • 便于移植:换芯片时,只需要修改HAL层,上层代码纹丝不动

核心思想:HAL不是简单的函数封装,而是一种「接口契约」。上层依赖的是接口,不是实现。

4.2 HAL的分层结构

我个人习惯把HAL分成三层。你想想看,这样设计的好处是每一层只关心自己的事,耦合度很低。

层级 名称 职责 示例
L1 芯片适配层 寄存器地址、中断号、时钟频率等芯片级参数 hi3559a_regs.h, hi3516dv300_regs.h
L2 外设驱动层 UART、I2C、SPI、GPIO等外设的初始化与操作 hal_uart.c, hal_i2c.c
L3 统一接口层 对外暴露的标准API,不暴露任何芯片细节 hal.h (包含 hal_uart_init, hal_i2c_read 等)

嗯,这里要注意:L1层是纯头文件或宏定义,L2层是具体实现,L3层是接口声明。上层代码只包含L3的头文件,链接时再选择L2的具体实现。

4.3 实战:为海思芯片编写HAL

我们以GPIO操作为例,看看具体怎么写。假设要支持Hi3559A和Hi3516DV300两款芯片。

4.3.1 定义芯片适配层(L1)

先定义每款芯片的寄存器地址。用宏定义,编译时通过条件编译选择。

// hal_chip_hi3559a.h
#ifndef __HAL_CHIP_HI3559A_H__
#define __HAL_CHIP_HI3559A_H__

#define GPIO_BASE_ADDR      0x12140000
#define GPIO_REG_DIR        (GPIO_BASE_ADDR + 0x00)
#define GPIO_REG_DATA       (GPIO_BASE_ADDR + 0x04)
#define GPIO_IRQ_NUM        32

#endif
// hal_chip_hi3516dv300.h
#ifndef __HAL_CHIP_HI3516DV300_H__
#define __HAL_CHIP_HI3516DV300_H__

#define GPIO_BASE_ADDR      0x120D0000
#define GPIO_REG_DIR        (GPIO_BASE_ADDR + 0x10)
#define GPIO_REG_DATA       (GPIO_BASE_ADDR + 0x14)
#define GPIO_IRQ_NUM        28

#endif

4.3.2 实现外设驱动层(L2)

这里用条件编译,根据芯片型号包含不同的头文件。我在项目中遇到过一个问题:不同芯片的GPIO方向寄存器定义不一样,有的写0是输出,有的写1是输出。所以这里必须做一层转换。

// hal_gpio.c
#include "hal.h"

#if defined(CHIP_HI3559A)
    #include "hal_chip_hi3559a.h"
#elif defined(CHIP_HI3516DV300)
    #include "hal_chip_hi3516dv300.h"
#else
    #error "Unsupported chip!"
#endif

int hal_gpio_set_dir(uint8_t pin, uint8_t dir) {
    uint32_t reg_val;
    uint32_t *reg_dir = (uint32_t *)GPIO_REG_DIR;

    reg_val = *reg_dir;
    if (dir == HAL_GPIO_DIR_OUT) {
        reg_val |= (1 << pin);   // 假设写1为输出
    } else {
        reg_val &= ~(1 << pin);  // 写0为输入
    }
    *reg_dir = reg_val;

    return 0;
}

int hal_gpio_write(uint8_t pin, uint8_t value) {
    uint32_t *reg_data = (uint32_t *)GPIO_REG_DATA;
    uint32_t reg_val = *reg_data;

    if (value) {
        reg_val |= (1 << pin);
    } else {
        reg_val &= ~(1 << pin);
    }
    *reg_data = reg_val;

    return 0;
}

4.3.3 定义统一接口层(L3)

接口层只声明函数,不暴露任何寄存器细节。上层应用看到的就是这些干净的API。

// hal.h
#ifndef __HAL_H__
#define __HAL_H__

#include <stdint.h>

#define HAL_GPIO_DIR_IN     0
#define HAL_GPIO_DIR_OUT    1

int hal_gpio_init(void);
int hal_gpio_set_dir(uint8_t pin, uint8_t dir);
int hal_gpio_write(uint8_t pin, uint8_t value);
int hal_gpio_read(uint8_t pin, uint8_t *value);

#endif

小技巧:接口参数尽量用枚举或宏定义,不要用魔数。比如方向用 HAL_GPIO_DIR_IN / HAL_GPIO_DIR_OUT,而不是直接传0或1。这样代码可读性高,也不容易出错。

4.4 避坑指南

我曾经踩过一个坑,说出来给大家提个醒。当时做项目,HAL层封装了I2C读写接口,上层调用得很开心。结果换芯片后,新芯片的I2C控制器支持超时重传,旧的不支持。上层代码没做超时处理,导致在某些异常情况下,I2C总线直接卡死。

所以,设计HAL接口时,要注意以下几点:

  • 接口要足够抽象:不要暴露芯片特有的功能。比如「超时重传」这种特性,如果只有部分芯片支持,就不要在统一接口里暴露。可以在接口参数里加一个「超时时间」,不支持重传的芯片直接忽略这个参数。
  • 返回值要统一:所有接口返回0表示成功,负数表示错误码。错误码定义要全局统一,比如 -1 表示超时,-2 表示参数错误。
  • 初始化要可重入:HAL的初始化函数最好支持多次调用,第二次调用直接返回成功。这样上层代码不用关心初始化顺序。

特别注意:海思芯片的某些外设(比如看门狗、RTC)在休眠模式下行为不同。设计HAL时,一定要考虑低功耗场景。我建议在接口中增加一个「模式」参数,比如 HAL_MODE_NORMAL 和 HAL_MODE_SLEEP,让底层根据芯片特性做适配。

4.5 编译时的芯片选择

实际项目中,我们通常通过Makefile或CMake来指定芯片型号。比如:

# Makefile 示例
CHIP ?= hi3559a

ifeq ($(CHIP), hi3559a)
    CFLAGS += -DCHIP_HI3559A
else ifeq ($(CHIP), hi3516dv300)
    CFLAGS += -DCHIP_HI3516DV300
endif

OBJS = hal_gpio.o hal_uart.o hal_i2c.o

编译时只需要 make CHIP=hi3516dv300,就能生成对应芯片的固件。上层代码完全不用改。

4.6 总结

说白了,HAL设计就是「面向接口编程」在嵌入式领域的具体实践。它的核心价值在于:把变化封装在底层,把稳定留给上层

我做了这么多年车载项目,最大的体会就是:前期多花点时间设计好HAL,后期能省下无数个加班的夜晚。尤其是海思这种芯片迭代快的厂商,HAL做得好,换芯片就像换衣服一样简单。

下一章,我们会聊聊具体怎么用HAL来管理海思芯片的DMA和中断。到时候再给大家分享一个我当年调试DMA时遇到的「灵异事件」。