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表,上层文件系统一行代码没动。

核心要点:函数指针表让C语言具备了运行时多态的能力。这是大型项目解耦的基石。

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, &param);
    // 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模块的内部函数,那就要警惕了——是不是该引入一个接口层?

记住一句话:好的架构,改起来不疼;烂的架构,加个功能都像在拆炸弹。

实践建议:下次写新模块时,先定义接口头文件(.h),再写实现(.c)。让调用方只看到接口,看不到实现细节。这就是「面向接口编程」的第一步。

可可精品资料库