第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_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++文件往往混在一个项目里。编译时要注意两点:
- C文件用C编译器编译(gcc),C++文件用C++编译器编译(g++)。
- 链接时用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
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调用流程:
- Java中声明native方法
- 用javah生成C/C++头文件
- 在C++中实现这些方法
- 编译成动态库(.so / .dll)
- 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 及时释放 |
24.7 知识体系总览
下面这张图,把C与C++互操作的核心脉络梳理清楚了:
这张图把四个核心方向串起来了。你从上往下看,每个分支都有对应的技术要点。我个人建议,初学者先从 extern "C" 入手,搞懂名字修饰的原理,再逐步深入混合编译和封装。JNI属于特定场景,用到时再细学也不迟。
好了,这一章的内容就到这里。C与C++互操作,说难不难,说简单也不简单。关键是把原理搞透,把边界条件想清楚。下次遇到跨语言调用,你心里就有底了。
公众号:蓝海资料掘金营,微信 deep3321