第2章:HAL分层架构:分层设计原则、接口隔离、模块化设计、依赖倒置

好,咱们直接进入正题。

上一章我们聊了HAL是什么,为什么要做。这一章,我们来拆解它的骨架——分层架构。

说白了,HAL就是一个中间层。它夹在硬件和上层应用之间。但怎么夹,夹多厚,接口怎么切,这里头门道不少。

我个人习惯,在设计HAL之前,先想清楚四个原则:分层设计、接口隔离、模块化、依赖倒置。这四个词,你背下来没用,得理解它们在实际代码里长什么样。

2.1 分层设计原则:别让上层碰硬件寄存器

先问个问题:你的应用层代码里,有没有直接写过 *(volatile uint32_t *)0x40020014 = 0x01 这种语句?

如果有,那你的架构就出问题了。

分层设计的核心就一句话:每一层只依赖它的下一层,而且只能通过接口依赖

在ECU里,我一般分三层:

  • 硬件驱动层(MCAL):直接操作寄存器。这部分跟芯片绑定死了。
  • 硬件抽象层(HAL):封装MCAL,提供统一的API。比如 Adc_ReadChannel(),不管底层是STM32还是Infineon。
  • 应用层(ASW):只调用HAL的API,不关心硬件细节。

我在项目中遇到过一件事:有个同事把GPIO的翻转操作直接写在了控制算法里。后来换芯片,所有带GPIO的代码都得重写。嗯,那场面,挺壮观的。

关键原则: 应用层永远不要包含 #include "stm32f4xx.h" 这类头文件。一旦出现,说明分层破了。

2.2 接口隔离:别让一个接口干所有事

接口隔离原则,英文叫Interface Segregation Principle,简称ISP。

它的意思很简单:不要强迫上层依赖它用不到的接口

举个例子。你写了一个 Can_Manager 接口,里面塞了发送、接收、配置、错误处理、唤醒、休眠……十几个函数。上层模块如果只想发个报文,却不得不依赖整个接口,这就不合理。

我建议的做法是:

  • 把接口拆小。比如 Can_SenderCan_ReceiverCan_Configurator
  • 每个模块只依赖它真正需要的接口。
// 不好的做法:一个接口包罗万象
typedef struct {
    void (*Send)(uint32_t id, uint8_t *data);
    void (*Receive)(uint32_t *id, uint8_t *data);
    void (*Configure)(Can_Config_t *cfg);
    void (*ErrorHandler)(Can_Error_t err);
} Can_Manager_t;

// 好的做法:接口拆分
typedef struct {
    void (*Send)(uint32_t id, uint8_t *data);
} Can_Sender_t;

typedef struct {
    void (*Receive)(uint32_t *id, uint8_t *data);
} Can_Receiver_t;
小技巧: 接口文件尽量小。一个头文件只声明一个接口结构体。这样上层包含时,不会引入一堆无关的东西。

2.3 模块化设计:每个模块只做一件事

模块化,说白了就是高内聚、低耦合。

每个HAL模块,应该只负责一类硬件资源。比如:

  • Adc_Hal.c 只处理ADC
  • Dio_Hal.c 只处理GPIO
  • Pwm_Hal.c 只处理PWM

千万别把ADC和PWM的代码写在一个文件里。我见过有人这么干,理由是“它们都用定时器”。嗯,听起来有点道理,但维护起来就是噩梦。

模块化还有一个好处:可以单独测试。你写个 Adc_Hal_Test.c,只测ADC模块,不用管别的。

注意: 模块之间不要互相调用。比如 Adc_Hal.c 里不要直接调用 Pwm_Hal.c 的函数。如果需要协作,通过上层或者回调机制。

2.4 依赖倒置:让高层定义接口,低层去实现

依赖倒置原则,Dependency Inversion Principle,简称DIP。

这句话有点绕:高层模块不应该依赖低层模块,两者都应该依赖抽象接口

在HAL里怎么用?

举个例子。你的应用层需要读取一个温度传感器。传统做法是:

  • 应用层直接调用 Temperature_Sensor_Read()
  • 这个函数又直接操作I2C寄存器

这样应用层就依赖了具体的传感器驱动。换传感器?应用层代码也得改。

用依赖倒置的做法是:

  • 应用层定义接口:Temperature_Read_t
  • 底层驱动去实现这个接口
  • 应用层只通过接口指针调用
// 应用层定义的抽象接口
typedef struct {
    float (*ReadTemperature)(void);
} Temperature_Read_t;

// 底层实现(比如用DS18B20)
float DS18B20_ReadTemperature(void) {
    // 操作硬件...
    return 25.5f;
}

// 底层实现(比如用NTC热敏电阻)
float NTC_ReadTemperature(void) {
    // 操作硬件...
    return 26.3f;
}

// 应用层只依赖接口,不依赖具体实现
void ProcessTemperature(Temperature_Read_t *sensor) {
    float temp = sensor->ReadTemperature();
    // 处理温度...
}

这样做的好处很明显:换传感器,只需要换底层实现,应用层代码纹丝不动

我曾经在一个项目里,因为供应商换了温度传感器,只改了3行代码——就是注册接口的那几行。其他代码完全没动。嗯,这种感觉,挺爽的。

总结一下: 依赖倒置的核心,就是让“谁依赖谁”反过来。不是上层依赖下层,而是下层去实现上层定义的接口。

2.5 实际项目中的分层示例

最后,我放一个实际项目中HAL分层的目录结构。你参考一下:

Project/
├── App/
│   ├── main.c
│   ├── ControlLogic.c
│   └── Diagnostic.c
├── Hal/
│   ├── Adc_Hal.c
│   ├── Adc_Hal.h
│   ├── Dio_Hal.c
│   ├── Dio_Hal.h
│   ├── Can_Hal.c
│   └── Can_Hal.h
├── Mcal/
│   ├── stm32f4_adc.c
│   ├── stm32f4_gpio.c
│   └── stm32f4_can.c
└── Config/
    └── Hal_Cfg.h

看到没?App层只包含 Hal/ 目录下的头文件。Mcal层的寄存器操作,对App层完全不可见。

这就是分层架构的威力。

我的建议: 刚开始做HAL时,别追求完美。先按功能拆模块,再慢慢优化接口。一口吃不成胖子,但一口一口吃,总能吃完。

好,这一章就到这里。下一章我们聊HAL的接口设计规范——怎么定义API,才能让上层用得顺手,底层改得轻松。