第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_Sender、Can_Receiver、Can_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只处理ADCDio_Hal.c只处理GPIOPwm_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的接口设计规范——怎么定义API,才能让上层用得顺手,底层改得轻松。