
前言C 与 C 的互调用是系统编程里的常见需求老项目用 C 写驱动或基础库新模块用 C 写业务逻辑或者 C 程序需要调用某个只提供 C API 的第三方库。语言层面两者确实可以互相调用但前提是跨过一道名为「链接」的关口而这道关口上横着名字修饰name mangling、调用约定、异常传播和对象布局四个问题。最常见的误解是「C 是 C 的超集所以把.c文件直接交给 C 编译器就行」。语法上大部分 C 代码确实能被 C 编译器接受但链接行为并不相同C 编译器会对函数名做修饰C 编译器不会。如果一方按修饰名生成符号、另一方按原始名查找链接器就会报undefined reference。另一个误解是以为在函数前加extern C就改变了函数的实现方式——它只改变链接名函数体仍然是完整的 C。本文先讲清名字修饰与链接属性的关系再分别给出「C 调 C」和「C 调 C」两条路径的完整写法最后讨论跨语言传递数据时哪些类型是安全的、哪些会踩到 UB。示例以 C11 C17 为基准由于本机没有编译环境所有结论均按语言标准与常见 ABI 推演未做实际编译验证。一、名字修饰与链接属性为什么必须 extern CC 支持函数重载int add(int, int)与double add(double, double)可以同名共存。为了在目标文件里区分它们编译器把参数类型信息编码进符号名这个过程叫名字修饰。以 Itanium C ABIGCC/Clang 在 Linux 上使用为例int add(int, int)的符号大致是_Z3addii其中_Z是前缀、3add是长度加函数名、ii表示两个int参数。C 语言没有重载符号名就是函数名本身。于是 C 代码里的add与 C 代码里的_Z3addii对不上链接器自然找不到。extern C的作用就是告诉 C 编译器这个声明使用 C 语言链接C language linkage不要修饰名字按 C 的规则生成和查找符号。// 适用于 C17extern C int c_add(int a, int b); // 符号名就是 c_add需要强调三点。第一extern C只能出现在 C 中C 编译器不认识这个语法所以共享头文件必须用#ifdef __cplusplus包起来。第二extern C可以作用于单个函数、一组函数用花括号或整个头文件但不能作用于类成员函数——成员函数有this参数和类作用域本身就不适合直接暴露给 C。第三链接属性是声明的一部分如果一处声明带extern C、另一处不带就是违反单一定义规则ODR的行为未定义实践中往往表现为链接失败。可以用工具确认符号名。nm -C能还原 C 符号nm不带-C看到的是修饰后的名字objdump -t可以查看目标文件的符号表。判断一个库能否被 C 调用看它的动态符号表里有没有未修饰的c_add即可。声明方式目标文件符号Itanium ABI 示例C 能否直接调用int add(int,int)_Z3addii否extern C int add(int,int)add是namespace n { int add(int,int); }_ZN1n3addEii否extern C { int add(int,int); }add是二、C 调用 C 函数这是两条路径中较简单的一条C 库本来就是按 C 规则编译的只要 C 侧的声明带上extern C链接名就一致了。标准 C 库头文件如cstdio内部已经处理过这件事所以直接包含即可。对于自己写的 C 库推荐在头文件里用宏做双向兼容/* 适用于 C11 *//* c_math.h */#ifndef C_MATH_H#define C_MATH_H#ifdef __cplusplusextern C {#endifint c_add(int a, int b);typedef int (*c_callback)(int value, void* user);int c_apply(int value, c_callback cb, void* user);#ifdef __cplusplus}#endif#endif /* C_MATH_H */对应的实现文件用 C 编译器编译/* 适用于 C11 *//* c_math.c */#include c_math.h#include stddef.hint c_add(int a, int b) {return a b;}int c_apply(int value, c_callback cb, void* user) {if (cb NULL) {return value;}return cb(value, user);}C 侧直接包含头文件并调用// 适用于 C17#include c_math.h#include iostreamint main() {std::cout c_add(2, 3) \n;auto twice [](int v, void* user) - int {int factor *static_castint*(user);return v * factor;};int factor 2;std::cout c_apply(21, twice, factor) \n;return 0;}这里把无捕获的 lambda 传给了 C 函数指针。无捕获的 lambda 可以隐式转换为函数指针这是标准保证的但它的语言链接属性在标准层面并没有明确保证为 C主流实现都能正常工作。更保守的写法是定义一个extern C的普通函数或static函数再传进去。编译时要注意分别处理C 文件用gcc -stdc11 -c c_math.cC 文件用g -stdc17最后用 C 链接器链接因为要带上 C 运行时。如果误用gcc链接包含 C 目标文件的程序可能出现undefined reference to operator new之类的错误。三、C 调用 C 函数反方向要复杂一些因为 C 无法理解类、模板、引用和异常。可行做法是在 C 侧写一层「C 接口包装」内部用完整的 C 实现对外只暴露extern C的函数和指向不完整类型的指针。先定义对外头文件注意它必须能被 C 编译器解析/* 适用于 C11 *//* cpp_engine.h */#ifndef CPP_ENGINE_H#define CPP_ENGINE_H#ifdef __cplusplusextern C {#endiftypedef struct cpp_engine cpp_engine; /* 不完整类型C 侧看不到布局 */cpp_engine* cpp_engine_new(void);void cpp_engine_delete(cpp_engine* e);int cpp_engine_sum(cpp_engine* e, const int* data, int count);const char* cpp_engine_last_error(const cpp_engine* e);#ifdef __cplusplus}#endif#endif /* CPP_ENGINE_H */实现文件里定义struct cpp_engine把真正的 C 对象放进去// 适用于 C17/* cpp_engine.cpp */#include cpp_engine.h#include exception#include numeric#include string#include vectorclass Engine {public:int sum(const std::vectorint values) {return std::accumulate(values.begin(), values.end(), 0);}void set_error(std::string msg) { error_ std::move(msg); }const std::string error() const { return error_; }private:std::string error_;};struct cpp_engine {Engine impl;};extern C cpp_engine* cpp_engine_new(void) {try {return new cpp_engine{};} catch (...) {return nullptr;}}extern C void cpp_engine_delete(cpp_engine* e) {delete e;}extern C int cpp_engine_sum(cpp_engine* e, const int* data, int count) {if (e nullptr || data nullptr || count 0) {return 0;}try {std::vectorint values(data, data count);return e-impl.sum(values);} catch (const std::exception ex) {e-impl.set_error(ex.what());return 0;} catch (...) {e-impl.set_error(unknown error);return 0;}}extern C const char* cpp_engine_last_error(const cpp_engine* e) {if (e nullptr) {return ;}return e-impl.error().c_str();}几个设计要点cpp_engine在头文件里只有前置声明C 侧只能持有指针无法在栈上创建也无法访问成员。这从源头上避免了布局不一致。内存的分配与释放都在 C 侧完成C 代码只负责传递指针。new与delete必须配对而且要在同一个运行时里配对。所有extern C函数都加了try/catch(...)。异常不能穿过 C 栈帧因为 C 代码没有栈展开所需的元数据让异常逃出extern C边界标准层面是未定义行为实践中通常直接终止进程。字符串以const char*返回指向对象内部的std::string。调用方只能在对象存活期间、且字符串未被修改时使用这个指针不能free它。C 侧调用就很自然/* 适用于 C11 */#include cpp_engine.h#include stdio.hint main(void) {cpp_engine* e cpp_engine_new();if (e NULL) {return 1;}int data[4] {1, 2, 3, 4};printf(%d\n, cpp_engine_sum(e, data, 4));printf(%s\n, cpp_engine_last_error(e));cpp_engine_delete(e);return 0;}如果 C 代码需要把回调注册给 C同样用函数指针加void* user的经典模式。C 侧保存回调时要注意生命周期C 侧的对象可能先于 C 对象销毁回调表必须在delete时清空。回调在哪个线程执行、执行时是否持有 C 侧的锁也要在接口文档里写清楚否则很容易在回调里重入死锁。四、数据类型边界哪些能传哪些不能跨语言传递数据的基本原则是「只传两边布局都确定的类型」。下面这些是安全的基本整数类型int、unsigned、long long、std::int32_t这类固定宽度类型。浮点类型float、double。指针包括void*、指向不完整类型的指针、函数指针。标准布局standard-layout的struct且所有成员都是上述类型、没有虚函数、没有基类。枚举最好指定底层类型。不安全的类型包括std::string、std::vector、std::map等标准库容器带虚函数的类带用户定义构造/析构的类引用类型bool与 C 的_Bool混用虽然多数实现兼容但标准不保证long和size_t的宽度跨平台不一致头文件里应改用固定宽度类型。另一个隐蔽的问题是调用约定。在 x86 Windows 上__cdecl、__stdcall、__fastcall会影响参数压栈方式和名字修饰Linux x86-64 通常只有一种 System V 调用约定。如果 C 头文件里用了__stdcall之类的修饰C 侧的extern C声明必须完全一致否则栈会失衡。可移植的代码应当避免显式调用约定或者用宏在两端统一。还有一点C 的struct即使成员与 C 完全相同只要它带有访问说明符、虚函数或非标准布局就不能假定与 C 结构体二进制兼容。反过来C 的结构体在 C 里通常可以直接使用因为 C 对标准布局类型有布局保证。完整示例把两条路径合起来看一个同时包含 C 库和 C 包装层的工程通常有三类源文件c_math.h/c_math.c纯 C 库、cpp_engine.h/cpp_engine.cppC 实现 C 接口包装再加一个 C 调用方engine_client.c。构建时按语言分别编译最后统一用 C 链接器链接# C 源文件交给 C 编译器gcc -stdc11 -c c_math.c -o c_math.ogcc -stdc11 -c engine_client.c -o engine_client.o# C 源文件交给 C 编译器g -stdc17 -c cpp_engine.cpp -o cpp_engine.o# 用 C 链接器链接确保带上 C 运行时g engine_client.o c_math.o cpp_engine.o -o app调用链是C 代码通过cpp_engine_new拿到不透明句柄把句柄和普通 C 数组传给cpp_engine_sumC 包装层在内部构造std::vector、调用Engine::sum再以int返回。反向的 C 调 C 则直接包含c_math.h由extern C保证链接名一致。两条路径的边界上都只出现 C 兼容类型异常与 STL 对象都被挡在包装层内部。常见坑点❌ 只在 C 实现文件里写extern C头文件里不写C 侧包含后按 C 规则声明。✅ 在共享头文件里用#ifdef __cplusplus包住extern C保证两侧声明一致。❌ 把extern C加在类成员函数上。✅ 成员函数不能有 C 链接应提供自由函数作为包装把对象指针作为第一个参数。❌ 让 C 异常穿过 C 代码的栈帧。✅ 在每个导出函数里try/catch(...)用返回值或错误码传递失败信息。❌ 在 C 接口里返回std::string或std::vector或让 C 侧freeCnew出来的内存。✅ 只返回 C 兼容类型内存由分配它的一方释放。❌ 头文件里用long表示「32 位整数」在 LLP64 平台上出错。✅ 用cstdint里的std::int32_t/std::uint64_t等固定宽度类型。❌ C 文件用g编译导致.c里的符号被按 C 规则处理。✅ 用gcc编译 C 源文件用g编译 C 源文件链接时用g。❌ 把带虚函数的对象指针强转成void*传给 C再转回来。✅ 传递不透明句柄所有解引用都在 C 侧完成void*只作为搬运载体。❌ 在 C 侧保存 C 返回的const char*并在对象销毁后继续使用。✅ 明确指针有效期或把字符串复制到调用方提供的缓冲区。总结方向关键手段主要风险C 调 C头文件加extern C声明漏声明导致链接名不匹配C 调 C自由函数包装 不透明句柄异常穿越边界、内存归属混乱头文件#ifdef __cplusplus双向兼容C 编译器无法解析extern C数据基本类型、POD、指针STL 容器与类布局不兼容错误处理返回码 内部 catch异常导致进程终止C 与 C 互调的本质是「在链接层面对齐契约」名字要一致调用约定要一致数据的二进制布局要一致错误的传播方式也要一致。把这四条守住extern C就只是一个开关守不住它会变成一连串难以定位的链接错误和运行时崩溃。