第24章:C与C++互操作:extern "C"、混合编译、封装C库、JNI与C++交互

说实话,C和C++的互操作,是每个C++开发者迟早要面对的硬骨头。我早年接手过一个遗留系统,底层是纯C写的网络库,上层业务逻辑全用C++11重构。那会儿我才真正体会到——两种语言看似一家,真要混在一起用,坑还真不少。

这一章,我就把这些年踩过的坑、总结的经验,掰开揉碎了讲给你听。

24.1 为什么需要互操作?

先问个问题:你手头有没有C语言写的成熟库?比如libcurl、sqlite3、OpenSSL?

这些库经过十几年甚至几十年的打磨,稳定性和性能都无可挑剔。你不可能为了用C++就重写一遍。反过来,有些团队用C++开发新模块,但老系统是纯C的,两者必须共存。

说白了,互操作不是「要不要」的问题,而是「怎么做好」的问题。

24.2 extern "C":C++调用C函数的桥梁

C++支持函数重载,编译器会对函数名进行「名字修饰」(name mangling)。比如:

// C++ 代码
void foo(int);
void foo(double);

编译后,这两个函数在符号表中的名字会被改成类似 _Z3fooi_Z3food 的样子。但C语言没有重载,它的函数名就是 foo

如果你在C++里直接调用C函数,链接器会去找修饰后的名字,结果找不到——链接失败。

解决方案就是 extern "C"。它告诉编译器:这段代码按C的规则来,不要做名字修饰。

// C++ 文件
extern "C" {
    #include "c_library.h"
}

// 或者直接声明
extern "C" void c_function(int x);
我的习惯:在C头文件里加条件编译,让C++使用者直接include就能用。
// c_library.h
#ifdef __cplusplus
extern "C" {
#endif

void c_function(int x);
int  c_calculate(double a, double b);

#ifdef __cplusplus
}
#endif

这样写,C和C++都能直接include,不用额外包装。我在项目中一直这么用,省心。

24.3 混合编译:C和C++代码一起编译

实际项目中,C和C++文件往往混在一个项目里。编译时要注意两点:

  1. C文件用C编译器编译(gcc),C++文件用C++编译器编译(g++)。
  2. 链接时用C++链接器(g++),它会自动链接C++标准库。

举个例子:

# 编译C文件
gcc -c network.c -o network.o

# 编译C++文件
g++ -c main.cpp -o main.o

# 链接(用g++)
g++ main.o network.o -o app
注意:千万不要用gcc去链接C++代码。gcc不会自动链接C++标准库,你会遇到一堆「undefined reference」错误。我曾经在这个坑里浪费了半天。

24.4 封装C库:给C函数穿上C++的外衣

直接暴露C函数给C++调用,虽然能用,但不够「C++」。我个人习惯的做法是:写一个C++封装层,把C函数包装成类或命名空间。

比如,封装一个C风格的数据库接口:

// C 库接口(c_db.h)
typedef void* DBHandle;
DBHandle db_open(const char* path);
void     db_close(DBHandle handle);
int      db_query(DBHandle handle, const char* sql);

C++封装:

// db_wrapper.hpp
#include <string>
#include <stdexcept>

extern "C" {
    #include "c_db.h"
}

class Database {
public:
    explicit Database(const std::string& path)
        : handle_(db_open(path.c_str()))
    {
        if (!handle_) {
            throw std::runtime_error("Failed to open database");
        }
    }

    ~Database() {
        if (handle_) {
            db_close(handle_);
        }
    }

    // 禁止拷贝
    Database(const Database&) = delete;
    Database& operator=(const Database&) = delete;

    // 允许移动
    Database(Database&& other) noexcept
        : handle_(other.handle_)
    {
        other.handle_ = nullptr;
    }

    void query(const std::string& sql) {
        if (db_query(handle_, sql.c_str()) != 0) {
            throw std::runtime_error("Query failed");
        }
    }

private:
    DBHandle handle_;
};

这样做的好处很明显:

  • RAII管理资源,不用手动close
  • 异常安全,错误处理更自然
  • 禁止拷贝,避免悬空指针

嗯,这里要注意:C库返回的指针,在C++里要小心生命周期。我见过有人把C库返回的 char* 直接赋给 std::string,结果C库内部free了内存,string成了野指针。

24.5 JNI与C++交互:Java调用C++

JNI(Java Native Interface)是Java和C/C++之间的桥梁。说白了,就是Java代码可以调用本地(native)方法,这些方法由C或C++实现。

一个典型的JNI调用流程:

  1. Java中声明native方法
  2. 用javah生成C/C++头文件
  3. 在C++中实现这些方法
  4. 编译成动态库(.so / .dll)
  5. Java加载动态库并调用

Java端代码:

public class NativeBridge {
    static {
        System.loadLibrary("native_core");
    }

    public native int add(int a, int b);
    public native String greet(String name);
}

C++端实现:

#include <jni.h>
#include <string>

extern "C" {

JNIEXPORT jint JNICALL
Java_NativeBridge_add(JNIEnv* env, jobject thiz, jint a, jint b) {
    return a + b;
}

JNIEXPORT jstring JNICALL
Java_NativeBridge_greet(JNIEnv* env, jobject thiz, jstring name) {
    const char* utf_str = env->GetStringUTFChars(name, nullptr);
    if (!utf_str) return nullptr;

    std::string result = "Hello, " + std::string(utf_str) + "!";
    env->ReleaseStringUTFChars(name, utf_str);

    return env->NewStringUTF(result.c_str());
}

} // extern "C"
关键点:
  • 函数名必须严格遵循 Java_包名_类名_方法名 的命名规则
  • 所有JNI函数都要放在 extern "C" 块中
  • JNIEnv指针是线程相关的,不要跨线程缓存
  • GetStringUTFChars获取的字符串用完必须Release,否则内存泄漏

我曾经在Android项目里用JNI做图像处理,一开始没注意JNIEnv的线程问题,结果在子线程里调用JNI函数直接崩溃。查了两天才发现——JNIEnv不能跨线程使用,得用JavaVM的AttachCurrentThread来获取当前线程的JNIEnv。

24.6 互操作中的常见陷阱

陷阱 说明 解决方案
名字修饰不匹配 C++编译器对函数名做修饰,C不做 使用 extern "C"
内存管理不一致 C用malloc/free,C++用new/delete 谁分配谁释放,或统一使用C的分配器
异常跨语言边界 C++异常不能穿过C函数栈帧 在C++封装层捕获异常,返回错误码
结构体内存对齐 C和C++可能使用不同的对齐方式 使用 #pragma pack__attribute__((packed))
JNI全局引用泄漏 JNI全局引用不释放会导致内存泄漏 使用 DeleteGlobalRef 及时释放
避坑指南:我曾经在C++封装层里抛异常,结果C函数栈帧没处理,程序直接abort。从那以后,我所有跨语言边界的函数都统一返回错误码,绝不抛异常。

24.7 知识体系总览

下面这张图,把C与C++互操作的核心脉络梳理清楚了:

C与C++互操作知识体系 C与C++互操作 extern "C" 混合编译 封装C库 JNI交互 禁止名字修饰 条件编译包装 gcc编译C文件 g++编译/链接 RAII资源管理 异常安全封装 移动语义支持 Java声明native C++实现JNI函数 编译为动态库

这张图把四个核心方向串起来了。你从上往下看,每个分支都有对应的技术要点。我个人建议,初学者先从 extern "C" 入手,搞懂名字修饰的原理,再逐步深入混合编译和封装。JNI属于特定场景,用到时再细学也不迟。

好了,这一章的内容就到这里。C与C++互操作,说难不难,说简单也不简单。关键是把原理搞透,把边界条件想清楚。下次遇到跨语言调用,你心里就有底了。


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