25、高级主题:C++与C混合插件、名字修饰与extern C

说实话,做插件化架构这么多年,我遇到最多的坑,就是C和C++混在一起编译链接的问题。

你想想看,我们辛辛苦苦写了一套C语言的插件框架,动态加载、函数指针、接口约定,一切都跑得好好的。结果团队里来了个C++高手,非要写个插件用STL、用类继承、用虚函数。然后一编译,链接报错,符号找不到。嗯,这就是我们今天要聊的核心问题——名字修饰extern "C"

为什么C++和C不能直接互相调用?

C语言编译出来的函数名,就是它本身。比如你写了个 int add(int a, int b),编译后在目标文件里的符号就是 _add(不同平台略有差异,但基本就是加个下划线前缀)。

C++就不一样了。它支持函数重载、命名空间、类成员函数,所以编译器必须对函数名进行修饰(mangling),把参数类型、作用域等信息编码进符号名里。比如:

// C++ 代码
int add(int a, int b);          // 可能被修饰为 _Z3addii
double add(double a, double b); // 被修饰为 _Z3adddd

你看,同样的函数名,参数不同,符号名完全不同。C语言链接器根本不认识这些乱七八糟的符号。所以当你尝试在C代码里调用C++函数时,链接器会报“未定义引用”。

核心结论:C和C++的符号命名规则不同,直接链接必然失败。必须通过 extern "C" 来告诉C++编译器:“这个函数请按C的方式导出符号”。

extern "C" 的正确用法

我个人习惯,在写C++插件时,头文件里一定会这样写:

// plugin_interface.h
#ifdef __cplusplus
extern "C" {
#endif

// 插件初始化函数
int plugin_init(const char* config);

// 插件执行函数
int plugin_run(int input);

// 插件清理函数
void plugin_cleanup();

#ifdef __cplusplus
}
#endif

这样写的好处是:

  • 当这个头文件被C++编译器包含时,extern "C" 生效,函数按C方式导出
  • 当被C编译器包含时,__cplusplus 宏未定义,extern "C" 被跳过,C编译器直接看到普通函数声明

我曾经在一个项目里吃过亏——头文件里忘了加这个宏保护,结果C插件调用C++动态库时,dlsym 死活拿不到函数地址。查了半天,发现符号名被修饰成了 _Z11plugin_initPKc,而C代码里找的是 plugin_init。嗯,从那以后我再也不敢忘写 extern "C" 了。

动态加载时的符号查找

在插件化架构里,我们通常用 dlopen + dlsym(Linux)或 LoadLibrary + GetProcAddress(Windows)来加载插件。这时候,符号名必须精确匹配。

举个例子,假设你的C++插件类是这样的:

// my_plugin.cpp
#include "plugin_interface.h"

class MyPlugin {
public:
    int init(const char* config) { return 0; }
    int run(int input) { return input * 2; }
    void cleanup() {}
};

// 全局实例
static MyPlugin g_plugin;

// 导出C接口
extern "C" int plugin_init(const char* config) {
    return g_plugin.init(config);
}

extern "C" int plugin_run(int input) {
    return g_plugin.run(input);
}

extern "C" void plugin_cleanup() {
    g_plugin.cleanup();
}

注意看,这里的关键是:C++类的方法不能直接导出给C用。你必须写一层C风格的包装函数,把类的方法调用包起来。然后通过 extern "C" 导出这些包装函数。

小技巧:我一般会在插件接口里加一个 void* handle 参数,用来传递C++对象的指针。这样C代码不需要知道类的存在,只操作不透明指针。说白了,就是C语言版的“面向对象”。

名字修饰的底层原理

为了让你更直观地理解名字修饰,我画了一张图:

C语言编译 源代码:int add(int a, int b) 编译后符号:_add 特点:符号名 = 函数名 + 前缀 C++编译 源代码:int add(int a, int b) 编译后符号:_Z3addii 特点:符号名包含参数类型信息 直接链接时:C代码找 _add,C++导出 _Z3addii → 链接失败! 这就是为什么需要 extern "C" 来统一符号命名规则 extern "C" 的作用 告诉C++编译器:这个函数请按C的方式导出符号 → 符号名变为 _add

实际项目中的避坑指南

我在多个大型项目中实践过C/C++混合插件架构,总结出几个常见问题:

  1. 全局对象构造/析构顺序:C++插件的全局/静态对象,在 dlopen 加载时构造,在 dlclose 卸载时析构。如果析构函数里释放了C代码还在用的资源,程序直接崩溃。我曾经遇到过插件卸载后,主程序调用了一个悬空指针——嗯,排查了整整两天。
  2. 异常不能跨越C边界:C++插件里如果抛出异常,而这个异常穿过 extern "C" 函数到达C代码,行为是未定义的。我的建议是:在C++包装函数里用 try-catch 捕获所有异常,返回错误码
  3. 内存分配器不一致:C插件用 malloc/free,C++插件用 new/delete。如果在C里 malloc 了一块内存,传给C++插件用 delete 释放——崩溃是必然的。统一使用 malloc/free 或者约定好谁分配谁释放。

警告:千万不要在 extern "C" 函数里直接返回C++的STL容器(如 std::stringstd::vector)。C代码根本不知道这些对象的内存布局,更不知道怎么释放。老老实实返回C风格的数据结构,比如 char*int*、结构体指针。

一个完整的混合插件示例

最后,我给你看一个我实际项目中用过的模式。这个模式既支持C插件,也支持C++插件,而且接口完全统一:

// plugin_api.h — 统一的插件接口
#ifdef __cplusplus
extern "C" {
#endif

typedef struct {
    int (*init)(void* ctx, const char* config);
    int (*process)(void* ctx, const void* input, void* output);
    void (*destroy)(void* ctx);
} plugin_ops_t;

typedef void* (*plugin_create_t)(void);
typedef plugin_ops_t* (*plugin_get_ops_t)(void);

#ifdef __cplusplus
}
#endif

C++插件实现时:

// cpp_plugin.cpp
#include "plugin_api.h"
#include <vector>

class CppPlugin {
    std::vector<int> data;
public:
    int init(const char* config) { /* ... */ return 0; }
    int process(const void* in, void* out) { /* ... */ return 0; }
    void destroy() { delete this; }
};

extern "C" void* create_plugin() {
    return new CppPlugin();
}

extern "C" plugin_ops_t* get_plugin_ops() {
    static plugin_ops_t ops = {
        [](void* ctx, const char* cfg) {
            return static_cast<CppPlugin*>(ctx)->init(cfg);
        },
        [](void* ctx, const void* in, void* out) {
            return static_cast<CppPlugin*>(ctx)->process(in, out);
        },
        [](void* ctx) {
            static_cast<CppPlugin*>(ctx)->destroy();
        }
    };
    return &ops;
}

你看,C++插件内部随便用STL、用类、用异常,但对外暴露的接口全是C风格的函数指针。主程序(不管是C还是C++写的)只需要 dlopen 加载插件,然后通过 dlsym 拿到 create_pluginget_plugin_ops 这两个函数指针,就能完全控制插件了。

个人建议:如果你要做一个长期维护的插件系统,我强烈推荐用纯C定义接口层。C++的特性(异常、RTTI、STL)在插件边界上都是隐患。让C++在插件内部尽情发挥,但边界一定要用C来守。这就像国境线——里面随便你怎么折腾,但出入境必须走海关检查。

好了,关于C++与C混合插件、名字修饰和extern "C",核心内容就这些。说白了,就是记住一句话:跨语言边界时,用C的符号规则,用C的ABI,用C的数据结构。做到这三点,你的插件架构就能同时拥抱C和C++的世界。