第3章:分层架构设计原则:接口与实现分离、依赖倒置、适配器模式在协议栈中的应用
做嵌入式这些年,我见过太多“一锅粥”式的协议栈代码。一个文件上千行,中断处理、协议解析、硬件操作全揉在一起。改一个底层驱动,上层逻辑跟着崩。说白了,这种代码没法维护,更没法移植。
这一章,我们来聊聊怎么用分层架构把协议栈拆清楚。核心就三个原则:接口与实现分离、依赖倒置、适配器模式。这三个东西不是理论空谈,是真正能帮你写出可移植、可测试代码的实战利器。
3.1 接口与实现分离:别让上层知道底层细节
先问一个问题:你的应用层代码,需不需要知道底层用的是SPI还是I2C?当然不需要。它只关心“发一个字节”这个动作。至于这个字节怎么从引脚上送出去,那是底层的事。
接口与实现分离,就是把“做什么”和“怎么做”拆开。在C语言里,我们通常用一组函数指针结构体来定义接口。
核心思路:上层只依赖接口声明,不依赖具体实现。底层只要实现接口,上层无需改动。
来看一个实际例子。我在一个物联网项目中,需要支持多种无线模组——WiFi、NB-IoT、LoRa。如果每个模组的驱动都直接暴露给应用层,那应用层代码会变成一团乱麻。
我的做法是定义一个统一的通信接口:
/* comm_interface.h - 通信接口声明 */
typedef struct {
int32_t (*init)(void);
int32_t (*send)(const uint8_t *data, uint16_t len);
int32_t (*recv)(uint8_t *buf, uint16_t *len);
int32_t (*ioctl)(uint32_t cmd, void *param);
} comm_if_t;
/* 应用层只通过这个接口操作 */
extern const comm_if_t *comm_get_interface(void);
然后每个模组各自实现这个接口:
/* wifi_driver.c */
static int32_t wifi_init(void) { /* WiFi初始化代码 */ }
static int32_t wifi_send(const uint8_t *data, uint16_t len) { /* WiFi发送代码 */ }
static int32_t wifi_recv(uint8_t *buf, uint16_t *len) { /* WiFi接收代码 */ }
const comm_if_t wifi_interface = {
.init = wifi_init,
.send = wifi_send,
.recv = wifi_recv,
.ioctl = NULL
};
应用层调用时,根本不知道底层是哪个模组。它只调用 comm_get_interface()->send(data, len)。换模组?只需要改一行初始化代码,把接口指针指向另一个实现。
我的习惯:接口头文件里只放函数指针类型和接口获取函数声明。所有具体实现都放在.c文件里,头文件不暴露任何内部结构体或宏定义。这样上层根本看不到底层细节,想耦合都难。
3.2 依赖倒置:让高层模块定义规则
依赖倒置原则说:高层模块不应该依赖低层模块,两者都应该依赖抽象。抽象不应该依赖细节,细节应该依赖抽象。
听起来绕口?我换个说法:让调用者定义接口,而不是被调用者。
举个例子。你的协议栈需要把数据写入非易失存储。传统做法是协议栈直接调用Flash驱动函数。但问题是,不同平台Flash驱动千差万别。今天用W25Q64,明天换AT45DB,协议栈代码就得改。
依赖倒置的做法是:协议栈定义存储接口,底层驱动去实现这个接口。
/* storage_if.h - 由协议栈定义 */
typedef struct {
int32_t (*write)(uint32_t addr, const uint8_t *data, uint32_t len);
int32_t (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
int32_t (*erase)(uint32_t addr, uint32_t len);
} storage_if_t;
/* 协议栈内部使用这个接口 */
int32_t protocol_save_record(const storage_if_t *storage,
const record_t *rec) {
/* 协议栈只调用storage->write,不关心底层实现 */
return storage->write(rec->addr, rec->data, rec->len);
}
底层驱动反过来依赖这个接口:
/* flash_driver.c - 实现协议栈定义的接口 */
static int32_t flash_write(uint32_t addr, const uint8_t *data, uint32_t len) {
/* 具体Flash操作 */
}
const storage_if_t flash_storage = {
.write = flash_write,
.read = flash_read,
.erase = flash_erase
};
我曾经踩过的坑:在协议栈内部直接调用硬件抽象层(HAL)的函数。结果换MCU平台时,HAL接口变了,协议栈里几十处调用全部要改。后来我强制规定:协议栈代码里不允许出现任何HAL头文件,所有硬件操作都通过接口指针传入。从此移植工作量从一周降到了半天。
3.3 适配器模式:让不兼容的接口协同工作
适配器模式解决的是“接口不匹配”的问题。你有一个现成的模块,它的接口和你的协议栈要求的不一样。不改模块代码,加一层适配器。
我在一个项目中,需要把旧版传感器驱动接入新版协议栈。旧驱动的初始化函数是 void sensor_init(uint8_t addr),而新协议栈要求的是 int32_t (*init)(void)。直接改旧驱动?风险太大,影响其他模块。
适配器模式登场:
/* adapter.c - 适配旧驱动到新接口 */
static uint8_t g_sensor_addr = 0x50; /* 默认地址 */
static int32_t adapter_init(void) {
sensor_init(g_sensor_addr); /* 调用旧驱动 */
return 0; /* 旧驱动无返回值,适配器补上 */
}
/* 适配器结构体 */
const sensor_if_t sensor_adapter = {
.init = adapter_init,
.read = adapter_read,
.write = adapter_write
};
适配器模式的核心价值:不改旧代码,不新写驱动,只加一层转换。这在维护老项目时特别有用。
3.4 三种模式的协同:一个完整的协议栈分层案例
光讲理论不够,我们来看一个完整的例子。这是一个温湿度传感器协议栈,支持SHT30和DHT22两种传感器。
先看整体架构图:
代码结构如下:
| 层级 | 文件 | 职责 | 设计原则 |
|---|---|---|---|
| 应用层 | app_temp.c | 定时采集温度,上报数据 | 只调用接口,不关心硬件 |
| 抽象接口层 | sensor_if.h | 定义传感器操作接口 | 依赖倒置 |
| 适配器层 | sht30_adapter.c / dht22_adapter.c | 将具体驱动适配到抽象接口 | 适配器模式 |
| 硬件驱动层 | i2c_drv.c / onewire_drv.c | 操作MCU外设寄存器 | 接口与实现分离 |
应用层代码极其简洁:
/* app_temp.c */
#include "sensor_if.h"
void temp_task(void) {
const sensor_if_t *sensor = sensor_get_active();
int32_t temp, humi;
if (sensor->read_temp_humi(&temp, &humi) == 0) {
/* 上报温度、湿度 */
report_data(temp, humi);
}
}
换传感器时,只需要在初始化时切换适配器:
/* main.c */
#include "sht30_adapter.h"
#include "dht22_adapter.h"
int main(void) {
/* 使用SHT30 */
sensor_register(&sht30_adapter);
/* 或者使用DHT22 */
// sensor_register(&dht22_adapter);
while(1) {
temp_task();
delay_ms(1000);
}
}
总结一下这三个原则的关系:
- 接口与实现分离是基础,让代码模块化
- 依赖倒置是方向,让高层定义规则
- 适配器模式是工具,让不兼容的模块协同工作
三者配合,才能写出真正可移植、可测试、可维护的协议栈代码。
嗯,这一章的内容就到这里。记住:架构设计不是为了炫技,是为了让你在项目迭代中少熬夜。下次改需求时,你会感谢今天做的这些设计决策。