7、插件通信机制:共享数据结构、消息队列、函数指针表与虚函数表模拟

插件之间怎么说话?这是做插件化架构时绕不开的问题。

我刚开始做插件系统时,以为只要把每个插件编译成 .so 文件,然后 dlopen 加载就完事了。结果第一个项目就给我上了一课——插件之间需要交换数据,需要互相通知事件,甚至需要调用对方的功能。没有通信机制,插件就是一座座孤岛。

今天咱们就把插件通信的四种主流方式掰开揉碎讲清楚。每种方式都有它的适用场景,也有它的坑。

7.1 共享数据结构:最简单,也最危险

说白了,就是让多个插件访问同一块内存区域。我在嵌入式项目里经常用这种方式,因为性能开销几乎为零。

核心思路:主程序定义一份全局数据结构,插件通过指针或 extern 声明来访问。

// 主程序定义
typedef struct {
    int sensor_value;
    float temperature;
    uint8_t flags;
    void (*on_data_ready)(void);
} SharedData;

SharedData g_shared_data = {0};

// 插件中访问
extern SharedData g_shared_data;

void plugin_process(void) {
    if (g_shared_data.flags & 0x01) {
        g_shared_data.temperature = read_sensor();
    }
}

注意:共享数据结构没有锁保护。多线程环境下,一个插件在读,另一个在写,数据就乱套了。

我曾经在一个工控项目里吃过这个亏——两个插件同时修改 flags 字段,导致状态丢失,设备直接停机。从那以后,我规定所有共享数据必须用原子操作或互斥锁保护。

共享数据结构适合什么场景?

  • 数据量小,更新频率高(比如传感器读数)
  • 插件之间耦合度低,只读不写
  • 对延迟极度敏感(微秒级)

7.2 消息队列:解耦的利器

如果插件之间需要异步通信,消息队列是首选。我个人的习惯是:只要插件之间没有强实时要求,一律用消息队列。

消息队列的本质是什么?生产者把消息丢进队列,消费者从队列里取。双方不需要知道对方的存在。

// 消息结构
typedef struct {
    uint32_t msg_id;
    uint32_t src_plugin;
    uint32_t dst_plugin;
    void* data;
    size_t data_len;
} Message;

// 消息队列(简单实现)
typedef struct {
    Message* buffer;
    int head;
    int tail;
    int capacity;
    pthread_mutex_t lock;
    pthread_cond_t not_empty;
} MsgQueue;

// 发送消息
int msg_queue_send(MsgQueue* q, Message* msg) {
    pthread_mutex_lock(&q->lock);
    // 检查队列是否满
    int next = (q->tail + 1) % q->capacity;
    if (next == q->head) {
        pthread_mutex_unlock(&q->lock);
        return -1; // 队列满
    }
    q->buffer[q->tail] = *msg;
    q->tail = next;
    pthread_cond_signal(&q->not_empty);
    pthread_mutex_unlock(&q->lock);
    return 0;
}

// 接收消息
int msg_queue_recv(MsgQueue* q, Message* msg, int timeout_ms) {
    pthread_mutex_lock(&q->lock);
    while (q->head == q->tail) {
        if (timeout_ms == 0) {
            pthread_mutex_unlock(&q->lock);
            return -1; // 无消息
        }
        // 等待条件变量
        struct timespec ts;
        clock_gettime(CLOCK_REALTIME, &ts);
        ts.tv_sec += timeout_ms / 1000;
        ts.tv_nsec += (timeout_ms % 1000) * 1000000;
        pthread_cond_timedwait(&q->not_empty, &q->lock, &ts);
    }
    *msg = q->buffer[q->head];
    q->head = (q->head + 1) % q->capacity;
    pthread_mutex_unlock(&q->lock);
    return 0;
}

我的经验:消息队列的容量一定要根据最坏情况来定。我曾经设了 64 条消息的队列,结果某个插件突发大量消息,队列满了直接丢包。后来改成动态扩容的环形缓冲区,才算解决。

消息队列的优点很明显:

  • 发送方和接收方完全解耦
  • 支持一对多、多对一通信
  • 可以缓冲突发消息

缺点也有:

  • 有内存拷贝开销
  • 需要处理队列满的情况
  • 不适合高频小数据(比如每 10 微秒发一次)

7.3 函数指针表:C 语言里的“接口”

C 语言没有虚函数,但我们可以用函数指针表来模拟。说白了,就是定义一个结构体,里面放一堆函数指针。插件注册自己的实现,主程序通过这个表来调用。

我在做音频处理框架时,就用这种方式让插件实现不同的编解码器。

// 定义插件接口
typedef struct {
    int (*init)(void* config);
    int (*process)(void* input, void* output);
    int (*control)(uint32_t cmd, void* param);
    void (*cleanup)(void);
} PluginInterface;

// 插件实现
static int my_init(void* config) {
    // 初始化硬件
    return 0;
}

static int my_process(void* input, void* output) {
    // 处理数据
    return 0;
}

PluginInterface my_plugin = {
    .init = my_init,
    .process = my_process,
    .control = NULL,  // 可选接口
    .cleanup = NULL
};

// 主程序调用
PluginInterface* plugin = load_plugin("my_plugin.so");
if (plugin->init) {
    plugin->init(&config);
}

关键点:函数指针表相当于定义了插件和主程序之间的“契约”。接口一旦发布,就不要轻易修改。否则所有插件都得跟着改。

函数指针表的好处:

  • 接口清晰,每个插件必须实现哪些函数一目了然
  • 可以设置 NULL 表示可选接口
  • 调用开销小(一次间接跳转)

7.4 虚函数表模拟:C 语言也能面向对象

如果你想让插件支持继承和多态,可以用虚函数表(vtable)模拟。这其实是函数指针表的升级版——每个插件实例可以有自己的 vtable 指针。

嗯,这里要注意:C 语言没有 this 指针,所以我们需要显式传递上下文。

// 虚函数表
typedef struct {
    void (*open)(void* self);
    void (*close)(void* self);
    int (*read)(void* self, uint8_t* buf, size_t len);
    int (*write)(void* self, uint8_t* buf, size_t len);
} VTable;

// 基类结构
typedef struct {
    VTable* vtable;
    int fd;
} Device;

// 派生类:串口设备
typedef struct {
    Device base;
    int baudrate;
    int parity;
} SerialDevice;

// 串口实现
static void serial_open(void* self) {
    SerialDevice* dev = (SerialDevice*)self;
    // 打开串口
}

static int serial_read(void* self, uint8_t* buf, size_t len) {
    SerialDevice* dev = (SerialDevice*)self;
    return read(dev->base.fd, buf, len);
}

// 虚函数表实例
VTable serial_vtable = {
    .open = serial_open,
    .close = serial_close,
    .read = serial_read,
    .write = serial_write
};

// 使用
void use_device(Device* dev) {
    dev->vtable->open(dev);  // 多态调用
    dev->vtable->read(dev, buf, 100);
    dev->vtable->close(dev);
}

避坑指南:虚函数表模拟最怕的是类型不匹配。我曾经把一个 SPI 设备的实例当成 I2C 设备传进去,结果 vtable 指针指向了错误的内存,程序直接崩溃。所以一定要在插件注册时做类型检查。

7.5 四种方式怎么选?

我整理了一张对比表,方便你根据场景选择:

通信方式 耦合度 性能 复杂度 适用场景
共享数据结构 极高 高频小数据、实时控制
消息队列 异步通信、事件通知
函数指针表 插件接口定义
虚函数表模拟 需要多态、继承的场景

我个人建议:

  • 如果插件之间只是简单数据交换,用共享数据结构就够了
  • 如果需要解耦和异步处理,上消息队列
  • 如果定义插件接口,函数指针表是最稳妥的选择
  • 如果要做复杂的设备驱动框架,可以考虑虚函数表模拟

实际项目中,这四种方式经常混合使用。比如主程序和插件之间用函数指针表定义接口,插件之间用消息队列通信,关键状态数据用共享数据结构。

插件通信机制总览 主程序 插件 A 插件 B 插件 C 共享数据结构 消息队列 函数指针表 虚函数表模拟 加载/注册 数据访问 消息传递 接口调用

最后说一句:没有银弹。每种通信机制都有它的 trade-off。关键是根据你的项目需求,选择最合适的方式,或者组合使用。我在实际项目中,经常是消息队列 + 函数指针表打天下,偶尔用共享数据结构做性能优化。

公众号:蓝海资料掘金营,微信deep3321