4、模块化设计原则(下):依赖倒置原则在C中的应用、面向接口编程(函数指针表)、模块间通信机制(回调、消息队列)
好,咱们接着聊模块化设计。上一节我们把高内聚低耦合讲透了,这一节要啃的,是真正让C语言项目「活起来」的几个硬核技巧。
说实话,我早年带团队的时候,最头疼的就是模块之间的依赖关系。A模块改了结构体,B模块就得跟着改,C模块再连锁反应。整个项目像多米诺骨牌,一碰就倒。后来我意识到,问题的根源在于——我们让高层模块直接依赖了底层模块。
4.1 依赖倒置原则:让高层不再看底层脸色
依赖倒置原则(DIP)听起来玄乎,其实核心就一句话:高层模块不应该依赖底层模块,两者都应该依赖抽象。
你想想看,在C语言里,抽象是什么?就是接口。但C没有interface关键字,怎么办?用函数指针。
我举个例子。假设你写了一个数据采集系统,底层有串口、SPI、I2C三种采集方式。传统写法是这样的:
// 传统写法:高层直接依赖底层
void collect_from_uart(void) { /* UART采集逻辑 */ }
void collect_from_spi(void) { /* SPI采集逻辑 */ }
void collect_from_i2c(void) { /* I2C采集逻辑 */ }
// 高层模块里写死调用
void data_collector(void) {
if (mode == UART_MODE) {
collect_from_uart();
} else if (mode == SPI_MODE) {
collect_from_spi();
} else if (mode == I2C_MODE) {
collect_from_i2c();
}
}
这种代码,每加一种采集方式,就要改data_collector函数。我在一个物联网项目里见过类似的代码,最后那个if-else堆了二十多个分支,维护起来简直想哭。
用依赖倒置怎么改?定义一个抽象接口:
// 抽象接口:采集器
typedef struct {
int (*init)(void);
int (*read)(uint8_t *buf, uint32_t len);
void (*deinit)(void);
} collector_t;
// 具体实现:UART采集器
static int uart_init(void) { /* ... */ }
static int uart_read(uint8_t *buf, uint32_t len) { /* ... */ }
static void uart_deinit(void) { /* ... */ }
collector_t uart_collector = {
.init = uart_init,
.read = uart_read,
.deinit = uart_deinit
};
// 高层模块只依赖抽象
void data_collector(collector_t *collector) {
collector->init();
collector->read(buffer, size);
collector->deinit();
}
看到了吗?高层模块不再关心底层是UART还是SPI。它只认collector_t这个接口。这就是依赖倒置——让底层去适配高层定义的接口,而不是高层去迁就底层。
4.2 面向接口编程:函数指针表就是C的虚函数表
说到接口,就不得不提函数指针表。这东西在C语言里,其实就是面向对象里的虚函数表(vtable)。
我习惯把函数指针表叫做「操作表」。每个模块暴露的不是函数,而是一张表。调用方拿着这张表,想调哪个功能就调哪个。
来看一个实际例子。嵌入式系统里经常要管理多种存储设备:Flash、SD卡、EEPROM。我们可以定义一个统一的存储接口:
// 存储设备接口表
typedef struct {
int (*open)(void);
int (*close)(void);
int (*read)(uint32_t addr, uint8_t *data, uint32_t len);
int (*write)(uint32_t addr, const uint8_t *data, uint32_t len);
int (*ioctl)(uint32_t cmd, void *arg);
} storage_ops_t;
// Flash的实现
static int flash_open(void) { /* 初始化Flash控制器 */ }
static int flash_read(uint32_t addr, uint8_t *data, uint32_t len) { /* ... */ }
// ... 其他函数
storage_ops_t flash_ops = {
.open = flash_open,
.close = flash_close,
.read = flash_read,
.write = flash_write,
.ioctl = flash_ioctl
};
// SD卡实现类似
storage_ops_t sd_ops = { /* ... */ };
上层模块怎么用?很简单:
void file_system_init(storage_ops_t *storage) {
storage->open();
// 后续所有读写都通过storage->read/write
}
这种设计的好处是什么?换底层驱动就像换电池一样简单。我在一个工业平板项目里,客户要求从Flash换成SD卡,我只改了初始化时传入的ops表,上层文件系统一行代码没动。
4.3 模块间通信机制:回调与消息队列
模块之间总要通信的。怎么通信?我总结下来,C语言项目里就两种主流方式:回调和消息队列。
4.3.1 回调函数:轻量级的事件通知
回调的本质,就是把函数指针当作参数传递。A模块告诉B模块:「事情办完了,你调用我这个函数通知我」。
举个例子。一个定时器模块,超时后要通知应用层:
// 定时器模块定义回调类型
typedef void (*timer_callback_t)(void *context);
// 注册回调
int timer_register(uint32_t ms, timer_callback_t cb, void *ctx);
// 应用层使用
void my_timeout_handler(void *ctx) {
// 超时处理逻辑
printf("Timeout! param=%d\n", *(int *)ctx);
}
int main() {
int param = 42;
timer_register(1000, my_timeout_handler, ¶m);
// 1秒后自动调用my_timeout_handler
}
回调的好处是轻量、实时。但有个坑——回调里不能做耗时操作。我曾经在一个项目里,有人在回调里调了malloc,结果在中断上下文里直接崩了。嗯,这个教训挺深刻的。
4.3.2 消息队列:异步解耦的利器
当模块间需要传递复杂数据,或者通信双方不在同一个线程/中断上下文时,消息队列就是最佳选择。
消息队列的核心思想:发送方只管往队列里放消息,接收方从队列里取消息处理。两者完全解耦。
一个典型的消息结构体:
// 消息定义
typedef struct {
uint32_t msg_id; // 消息ID
uint32_t src_module; // 源模块
uint32_t dst_module; // 目标模块
uint8_t data[64]; // 消息数据
uint32_t len; // 数据长度
} message_t;
// 消息队列接口
int msg_queue_init(void);
int msg_queue_send(message_t *msg);
int msg_queue_recv(message_t *msg, uint32_t timeout_ms);
使用场景很广。比如按键模块检测到按键按下,不直接调用业务逻辑,而是发送一个按键消息:
// 按键模块
void key_scan_task(void) {
message_t msg;
msg.msg_id = MSG_KEY_PRESS;
msg.src_module = MODULE_KEY;
msg.dst_module = MODULE_UI;
msg.data[0] = key_value;
msg.len = 1;
msg_queue_send(&msg);
}
// UI模块
void ui_task(void) {
message_t msg;
while (1) {
msg_queue_recv(&msg, 1000);
if (msg.msg_id == MSG_KEY_PRESS) {
handle_key_press(msg.data[0]);
}
}
}
这样做的好处太明显了:按键模块和UI模块可以独立开发、独立测试。我甚至可以在没有硬件按键的情况下,用测试脚本往队列里塞消息来验证UI逻辑。
4.4 本章知识体系总览
说了这么多,咱们用一张图来总结本章的核心逻辑:
4.5 总结与建议
这一章的内容,说白了就是三件事:
- 依赖倒置:让高层定义接口,底层去实现。谁也别依赖谁的具体实现。
- 函数指针表:C语言实现多态的标准手法。一张表搞定所有变体。
- 回调与消息队列:轻量级通信用回调,复杂场景用消息队列。各司其职。
我个人建议,在项目设计阶段,先画出模块间的依赖关系图。如果发现A模块直接调用了B模块的内部函数,那就要警惕了——是不是该引入一个接口层?
记住一句话:好的架构,改起来不疼;烂的架构,加个功能都像在拆炸弹。
可可精品资料库