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 四种方式怎么选?
我整理了一张对比表,方便你根据场景选择:
| 通信方式 | 耦合度 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 共享数据结构 | 高 | 极高 | 低 | 高频小数据、实时控制 |
| 消息队列 | 低 | 中 | 中 | 异步通信、事件通知 |
| 函数指针表 | 中 | 高 | 低 | 插件接口定义 |
| 虚函数表模拟 | 中 | 高 | 高 | 需要多态、继承的场景 |
我个人建议:
- 如果插件之间只是简单数据交换,用共享数据结构就够了
- 如果需要解耦和异步处理,上消息队列
- 如果定义插件接口,函数指针表是最稳妥的选择
- 如果要做复杂的设备驱动框架,可以考虑虚函数表模拟
实际项目中,这四种方式经常混合使用。比如主程序和插件之间用函数指针表定义接口,插件之间用消息队列通信,关键状态数据用共享数据结构。
最后说一句:没有银弹。每种通信机制都有它的 trade-off。关键是根据你的项目需求,选择最合适的方式,或者组合使用。我在实际项目中,经常是消息队列 + 函数指针表打天下,偶尔用共享数据结构做性能优化。