理解 extern “C”:解决 C/C++ 混合编译问题 – wiki基地


理解 extern "C":解决 C/C++ 混合编译的桥梁与艺术

在现代软件开发中,将 C 和 C++ 代码混合编译和链接是家常便饭。许多高性能的库、操作系统API甚至遗留系统都是用 C 编写的,而新的应用程序或模块可能选择用 C++ 来实现,以利用其面向对象、泛型编程等高级特性。然而,将这两种语言的代码无缝地结合起来并非总是直截了当。最常见的一个障碍就出现在连接阶段,常常表现为令人费解的“符号未定义”(Undefined Reference)错误。解决这个问题的关键,在于理解并正确使用 C++ 提供的一个特殊链接规范:extern "C"

本文将深入探讨 extern "C" 的作用、原理,以及如何在 C/C++ 混合项目中有效地使用它,从而搭建起两种语言之间沟通的桥梁。

1. 问题之源:C++ 的名字修饰(Name Mangling)

要理解为什么需要 extern "C",首先必须理解 C++ 编译器在处理函数和变量名时与 C 编译器有什么不同。这种差异的核心在于 C++ 的一个强大特性:名字修饰(Name Mangling),有时也称为 名字重整(Name Decoration)

1.1 为什么 C++ 需要名字修饰?

C++ 引入了许多 C 语言不具备的特性,这些特性使得简单的函数名不足以唯一标识一个特定的函数或变量。主要原因包括:

  1. 函数重载(Function Overloading): C++ 允许在同一作用域内定义多个同名函数,只要它们的参数列表(参数类型、数量、顺序)不同。例如,void print(int)void print(double)。在 C 中,这是不允许的;所有函数名必须唯一。
  2. 运算符重载(Operator Overloading): 允许为自定义类型重定义运算符的行为。例如,为 std::complex 定义 + 运算符。
  3. 成员函数(Member Functions): 类成员函数需要知道它们属于哪个类,并且可能具有特定的访问权限(public, private, protected)。
  4. 命名空间(Namespaces): C++ 使用命名空间来组织代码,避免全局命名冲突。函数或变量可能属于特定的命名空间,例如 std::cout
  5. 模板(Templates): 函数模板和类模板在实例化时会生成具体的代码,这些生成的代码也需要唯一的标识符。
  6. 异常规范(Exception Specifications – 现代 C++ 中已不推荐使用,但历史上有影响): 告知函数可能抛出哪些异常。
  7. 调用约定(Calling Conventions): 在某些平台上,不同的调用约定(如 __cdecl, __stdcall, __fastcall)会影响函数参数传递和栈清理方式,也可能被编码到名字中。

为了区分这些具有相同源代码名称但实际上不同的实体,C++ 编译器在编译时会根据函数或变量的签名(名称、参数类型、命名空间等)生成一个独特的、经过“修饰”的内部链接名称。这个修饰后的名称包含了足够的信息,使得连接器能够区分 print(int)print(double),或者 MyClass::calculate()

1.2 名字修饰的例子

名字修饰的具体规则取决于编译器和平台,并且并没有一个固定的标准(C++ 标准允许编译器自行决定修饰方式,只要能保证唯一性)。但这不妨碍我们理解其原理。

假设有一个 C++ 函数签名:
c++
namespace MyNamespace {
class MyClass {
public:
int process(double value, const std::string& name);
};
}

在编译后,MyNamespace::MyClass::process 函数的内部链接名称可能看起来像这样(这只是一个 示意,实际名称会更复杂且依赖于编译器,例如 GCC/Clang 的 Itanium ABI):
_ZN11MyNamespace7MyClass7processEdRKNSt7__cxx1112basic_stringIcSt11char_traitsIcSaIcEEE
解析这个修饰后的名称,其中可能编码了:
* _Z: 表示这是 C++ 的名字修饰符号。
* N11MyNamespace: 表示在名为 MyNamespace 的命名空间内,名称长度为 11。
* 7MyClass: 表示在名为 MyClass 的类内,名称长度为 7。
* 7process: 表示函数名称是 process,长度为 7。
* d: 参数类型为 double
* RKNSt7__cxx1112basic_stringIcSt11char_traitsIcSaIcEEE: 参数类型为 const std::string& 的复杂编码。

正如你所见,这个修饰后的名称与原始源代码中的 process 已经完全不同。

1.3 C 语言的命名方式

与 C++ 截然不同,C 语言非常简单直白。它不支持函数重载、命名空间或类成员函数。因此,C 编译器生成的函数或变量的链接名称通常就是其源代码中的名称,可能前面会加上一个平台特定的前缀(如 _)。

例如,一个 C 函数:
c
void greet(const char* message);

在编译后,它的内部链接名称可能就是:
_greet
或者在某些系统上就是 greet

2. C/C++ 混合编译中的问题:链接器找不到符号

现在,想象以下场景:

  1. 你有一个用 C++ 编写的程序,其中调用了一个 C 函数 void greet(const char* message);
  2. C 函数在一个独立的 C 源文件 (greet.c) 中实现,并编译成一个 C 目标文件或库。
  3. C++ 程序在一个 C++ 源文件 (main.cpp) 中编译成一个 C++ 目标文件。
  4. 最后,连接器尝试将 C++ 目标文件和 C 目标文件链接起来。

在 C++ 源文件 (main.cpp) 中,当你像这样声明并调用 C 函数时:
“`c++
// main.cpp

include

// 假设 C 函数声明在这里
void greet(const char* message); // 问题所在!

int main() {
std::cout << “Calling C function…” << std::endl;
greet(“Hello from C!”); // 调用 C 函数
return 0;
}
``
C++ 编译器看到
void greet(const char message);声明时,它会按照 C++ 的规则对其进行名字修饰。假设修饰后的名称是_Z5greetPKc` (示意,表示函数名为 greet,参数为 const char)。

然而,当连接器试图解析 main.cpp 中对 greet 的调用时,它会在 C 目标文件或库中查找名称为 _Z5greetPKc 的符号。但是,C 编译器编译 greet.c 时,生成的 greet 函数的符号名称是 _greet (或者 greet)。

结果就是:连接器找不到 _Z5greetPKc 这个符号,因为它在 C 目标文件中是以 _greet 的名字存在的。这就是导致“Undefined Reference to _Z5greetPKc”这类链接错误的原因。

同样的问题也会出现在 C 代码调用 C++ 函数时,如果 C++ 函数没有采取措施避免名字修饰,C 编译器生成的调用符号会是一个简单的名称,而 C++ 库提供的符号是修饰后的名称,两者不匹配。

3. extern "C" 的解决方案

extern "C" 就是用来解决这个问题的。它是一个链接规范(Linkage Specification),用来指示 C++ 编译器:对于被 extern "C" 修饰的函数或变量,请按照 C 语言的命名规则来生成它们的链接符号,而不是使用 C++ 的名字修饰规则。

本质上,extern "C" 告诉 C++ 编译器:“嘿,这个函数/变量是用 C 语言(或与 C 语言兼容的命名方式)编译的,或者希望它能被 C 语言代码链接。所以,请不要对它的名字进行 C++ 式的修饰。”

3.1 extern "C" 的语法

extern "C" 可以用于单个声明或一个声明块。

单个声明:
c++
extern "C" return_type function_name(parameter_list);
extern "C" data_type variable_name;

例如:
c++
extern "C" void greet(const char* message);
extern "C" int global_counter;

声明块:
通常用于包含多个需要 C 链接的声明。
c++
extern "C" {
// 多个 C 链接的声明
return_type function1(parameter_list);
return_type function2(parameter_list);
data_type variable;
}

例如:
c++
extern "C" {
void greet(const char* message);
int add(int a, int b);
extern int global_status; // extern 在此处表示变量定义在别处
}

3.2 如何使用 extern "C" 解决链接问题

回到前面的 C++ 调用 C 函数的例子:

正确做法是在 C++ 代码中声明 C 函数时,使用 extern "C"
“`c++
// main.cpp

include

// 声明 C 函数,告知 C++ 编译器使用 C 链接
extern “C” void greet(const char* message);

int main() {
std::cout << “Calling C function…” << std::endl;
greet(“Hello from C!”);
return 0;
}
``
当 C++ 编译器看到
extern “C” void greet(const char* message);时,它知道greet这个函数应该按照 C 的规则生成链接符号。所以,它会查找或生成名为_greet(或greet`) 的符号。

现在,当连接器尝试解析 main.cpp 中对 greet 的调用时,它会查找符号 _greet。这个符号正是由 C 编译器编译 greet.c 时生成的。连接器找到了匹配的符号,链接成功!

总结: extern "C" 应用于声明。它影响的是编译器如何生成符号,以及如何查找被调用的符号。它影响函数的实现语言(函数本身还是用 C 或 C++ 写的),也影响函数的调用约定(尽管在某些平台上,C 链接可能隐含特定的调用约定,但 extern "C" 的核心功能是控制名字修饰)。

4. extern "C" 的常见应用场景

extern "C" 主要用于实现 C 和 C++ 代码之间的互操作性。以下是一些典型的应用场景:

4.1 C++ 调用 C 函数或使用 C 变量

这是最常见的场景。当你需要在 C++ 代码中使用一个用 C 编写的库或模块时,你需要确保 C++ 编译器知道这些 C 函数和变量的链接名称是按照 C 的规则生成的。

方法:
在 C++ 代码中,包含 C 语言的头文件,并在包含或声明时使用 extern "C"

最佳实践:设计可被 C 和 C++ 共同使用的头文件

为了让一个头文件 (.h 文件) 既可以被 C 编译器包含(在 C 项目中),也可以被 C++ 编译器包含(在 C++ 项目中),我们需要使用条件编译宏 __cplusplus。这个宏只有在 C++ 编译器编译时才会被定义。

标准的模式如下:
“`c
// example_c_library.h

ifndef EXAMPLE_C_LIBRARY_H

define EXAMPLE_C_LIBRARY_H

// 只有在 C++ 编译器编译时,才启用 extern “C”

ifdef __cplusplus

extern “C” {

endif

// 在这里放置 C 语言的函数和变量声明
// 这些声明可以被 C 和 C++ 代码看到
void c_function1(int param);
int c_function2(double param);
extern int c_global_variable;

// 在 C++ 编译器编译时,关闭 extern “C” 块

ifdef __cplusplus

}

endif

endif // EXAMPLE_C_LIBRARY_H

“`

解释:
* #ifndef EXAMPLE_C_LIBRARY_H#define EXAMPLE_C_LIBRARY_H 是标准的头文件防重复包含卫士(include guards)。
* #ifdef __cplusplus: 这检查当前编译器是不是 C++ 编译器。
* extern "C" {: 如果是 C++ 编译器,则开始一个 extern "C" 块。此块内的所有声明都将按照 C 链接规则处理。
* 函数和变量声明:这些是 C 库提供的公共接口。
* #ifdef __cplusplus }: 如果之前开启了 extern "C" { 块(即是 C++ 编译器),则在这里关闭它。
* 如果当前编译器是 C 编译器 (__cplusplus 未定义),那么 #ifdef __cplusplus 会失败,extern "C" { ... } 块会被完全忽略。C 编译器会直接处理其中的函数和变量声明,这正是我们希望的,因为 C 编译器不需要 extern "C",它本身就使用 C 链接。

这样设计的头文件,在 C++ 代码中 #include "example_c_library.h" 时,其中的声明会被 extern "C" 修饰,使得 C++ 编译器生成正确的 C 风格链接符号。在 C 代码中 #include "example_c_library.h" 时,extern "C" 部分被忽略,代码与普通 C 头文件无异。

4.2 C 调用 C++ 函数

这种情况相对复杂一些,因为 C 语言无法直接理解 C++ 的类、对象、成员函数、函数重载等概念。然而,C 代码可以调用具有 C 链接的函数。

要让 C 代码调用 C++ 中的功能,你需要:

  1. 在 C++ 中编写一个提供所需功能的函数或类。
  2. 在 C++ 中编写一个或多个 C 风格的包装函数(Wrapper Functions)。这些包装函数是普通的非成员函数,使用 extern "C" 声明,因此具有 C 链接。
  3. 这些包装函数的实现内部,调用 C++ 类的方法、全局函数或其他 C++ 代码。
  4. 提供一个使用 extern "C" 包裹的头文件,其中包含这些 C 风格包装函数的声明。C 代码包含这个头文件并调用这些包装函数。

示例:

假设你在 C++ 中有一个类 MyCppClass,你希望 C 代码能够创建它的实例并调用一个方法。

“`c++
// my_cpp_class.h (C++ header)

include

class MyCppClass {
public:
MyCppClass() { std::cout << “MyCppClass created” << std::endl; }
~MyCppClass() { std::cout << “MyCppClass destroyed” << std::endl; }
void doSomething(int value) {
std::cout << “MyCppClass doing something with: ” << value << std::endl;
}
};

// C++ implementation file (my_cpp_class.cpp)

include “my_cpp_class.h”

include “c_api_wrapper.h” // 包含 C 接口头文件

// C 风格的包装函数实现
// 使用 extern “C” 在 c_api_wrapper.h 中声明
void create_my_cpp_class() {
// 在堆上创建 C++ 对象并返回其指针 (void
是 C 的泛型指针)
return new MyCppClass();
}

void destroy_my_cpp_class(void obj_ptr) {
// 将 void
转换回 C++ 对象指针并删除
MyCppClass* obj = static_cast(obj_ptr);
delete obj;
}

void call_my_cpp_class_do_something(void obj_ptr, int value) {
// 将 void
转换回 C++ 对象指针并调用方法
MyCppClass* obj = static_cast(obj_ptr);
if (obj) {
obj->doSomething(value);
}
}

“`

“`c
// c_api_wrapper.h (Header for C code)

ifndef C_API_WRAPPER_H

define C_API_WRAPPER_H

// 标准的 extern “C” 块,使得 C++ 编译器使用 C 链接生成符号

ifdef __cplusplus

extern “C” {

endif

// 声明 C 风格的包装函数
// void 用于表示不透明的句柄,隐藏底层 C++ 细节
void
create_my_cpp_class();
void destroy_my_cpp_class(void obj_ptr);
void call_my_cpp_class_do_something(void
obj_ptr, int value);

ifdef __cplusplus

}

endif

endif // C_API_WRAPPER_H

“`

“`c
// main_c.c (C source file)

include

include “c_api_wrapper.h” // 包含 C 接口头文件

int main() {
printf(“Creating C++ object from C…\n”);

// 使用 C 风格的函数创建和操作 C++ 对象
void* my_obj = create_my_cpp_class();

if (my_obj) {
    call_my_cpp_class_do_something(my_obj, 42);

    printf("Destroying C++ object from C...\n");
    destroy_my_cpp_class(my_obj);
} else {
    fprintf(stderr, "Failed to create C++ object!\n");
}

return 0;

}
“`

编译和链接:
你需要分别编译 my_cpp_class.cppmain_c.c,然后将生成的目标文件链接在一起。
例如 (使用 g++ 和 gcc):
bash
g++ -c my_cpp_class.cpp -o my_cpp_class.o
gcc -c main_c.c -o main_c.o
g++ my_cpp_class.o main_c.o -o mixed_app

注意,链接阶段通常使用 C++ 编译器 (如 g++),因为它需要链接 C++ 运行时库,并且能够正确处理 C 和 C++ 风格的符号。

这个例子展示了如何通过 C 风格的包装函数和不透明指针 (void*),将 C++ 的功能暴露给 C 代码。C 代码不需要知道任何 C++ 的细节,只需要知道如何调用这些 extern "C" 函数来操作一个“句柄”。

4.3 使用 C 标准库头文件

在 C++ 中,你可能会包含 C 标准库头文件,如 <stdio.h>, <stdlib.h>, <string.h> 等。C++ 标准为了兼容性,对这些头文件做了特殊处理。

当你包含 C++ 风格的 C 标准库头文件时(例如 <cstdio> 而不是 <stdio.h>),C++ 标准规定这些头文件中的声明(如 printf, malloc, strcpy)是默认具有 C 链接的,就好像它们被放在 extern "C" {} 块中一样。

然而,当你包含 C 风格的 .h 头文件时(例如 <stdio.h>),C++ 标准 保证其内容被 extern "C" 包裹。许多现代 C 库的头文件已经内置了前面提到的 #ifdef __cplusplus 保护,所以你可以直接包含它们。但对于一些老旧的或非标准的 C 头文件,你可能需要在 C++ 代码中手动添加 extern "C" 块来包含它们:

“`c++
// main.cpp

include

// 手动将 C 头文件的包含放在 extern “C” 块中
extern “C” {
#include
}

int main() {
// … 使用 some_old_c_library.h 中的函数 …
return 0;
}
``
或者,更常见的是,
some_old_c_library.h自身内部包含了#ifdef __cplusplus extern “C” { … }`。

4.4 函数指针

当在 C 和 C++ 之间传递函数指针时,也需要考虑链接问题。

  • C++ 函数指针指向 C 函数: 如果你有一个 C 函数(声明为 extern "C"),并在 C++ 中获取它的地址赋给一个函数指针,那么这个函数指针的类型声明应该与 C 函数的声明匹配。extern "C" 主要影响函数本身的符号名,而非函数指针的类型定义(尽管在某些复杂的跨平台/ABI 场景下,调用约定可能影响指针类型)。通常,你声明一个普通的 C 风格函数指针类型即可。
    c++
    extern "C" void c_callback(int); // C function
    void (*ptr_to_c_callback)(int) = c_callback; // C++ function pointer to C function

  • C 函数指针指向 C++ 函数: 这需要 C++ 函数本身具有 C 链接。因此,如果你想将一个 C++ 非成员函数的地址赋给一个 C 函数指针,这个 C++ 函数本身必须被声明为 extern "C"。这意味着这个 C++ 函数不能是重载函数,不能是类成员函数等(因为这些特性与 C 链接不兼容)。
    “`c++
    extern “C” void cpp_c_linked_function(int); // C++ function with C linkage

    // In C code:
    void (*ptr_to_cpp_function)(int);
    // Assume you get the address from C++ side
    ptr_to_cpp_function = get_cpp_c_linked_function_address(); // A C++ wrapper function returning the address

    // Or directly in C++ if needed:
    void (ptr_in_cpp)(int) = cpp_c_linked_function;
    ``
    如果需要传递 C++ 类成员函数作为 C 回调,你通常需要一个全局的
    extern “C”包装函数,该函数接收一个用户数据指针(通常是void
    `,指向 C++ 对象实例),然后通过这个指针调用成员函数。

5. 关于 extern "C" 的更多细节和注意事项

  • extern "C" 只能应用于非成员函数和变量的声明。 你不能将它应用于类、类成员函数、模板、类型别名(typedef)等,因为这些是 C 语言没有的概念,无法拥有 C 链接。
  • extern "C" 影响的是链接时的命名,而不是编译时的代码生成。 一个被声明为 extern "C" 的 C++ 函数仍然是 C++ 函数,可以使用 C++ 特性(除了那些与 C 链接冲突的,如重载)。但是,为了能被 C 代码调用,它必须遵守 C 的调用约定和参数传递规则(尽管 extern "C" 通常会指示编译器这样做),并且不能抛出 C 代码无法捕获的 C++ 异常(如果可能抛出异常,通常需要在 extern "C" 包装函数内部捕获并转换为 C 风格的错误码)。
  • extern "C" 不会改变名称的作用域。extern "C" 修饰的实体仍然遵循 C++ 的作用域规则(命名空间、文件作用域等)。然而,extern "C" {} 块内的声明默认是在全局命名空间中。
  • 多个 extern "C" 链接规范: C++ 标准允许使用不同的字符串字面量作为链接规范,尽管 "C""C++" 是唯一由标准定义的。例如,理论上你可以有 extern "Pascal", extern "Fortran" 等,但它们的行为是未定义的,依赖于具体实现是否支持。实际上,除了 "C",你几乎不会看到其他自定义的链接规范。extern "C++" 是默认的链接规范,可以显式使用,但通常没有必要。
  • 同一个函数可以有多个 extern "C" 声明。 例如,在不同的头文件中都包含 extern "C" void my_c_function(int); 是合法的,只要它们指向同一个函数定义。
  • extern "C" 函数不能被重载。 因为 C 链接没有名字修饰来区分重载函数,所以一个具有 C 链接的函数名必须是唯一的。

6. 总结

extern "C" 是 C++ 中一个至关重要的特性,它是连接 C 和 C++ 世界的桥梁。通过指示 C++ 编译器使用 C 语言的命名和链接约定,它解决了由于 C++ 名字修饰导致的两语言代码在链接阶段无法找到对应符号的问题。

理解 extern "C" 的核心在于理解 C++ 的名字修饰以及 C 语言简单的命名规则。通过在 C++ 代码中正确使用 extern "C" 来声明需要与 C 代码交互的函数和变量,我们可以确保编译器生成或查找正确的链接符号。

在实践中,最常见的用法是利用 #ifdef __cplusplus 宏在 C 风格的头文件中创建一个 extern "C" 块,使得该头文件可以安全地被 C 和 C++ 代码包含。对于需要从 C 调用 C++ 功能的场景,通常需要创建 extern "C" 包装函数来隐藏底层的 C++ 细节。

掌握 extern "C" 是进行 C/C++ 混合编程的基本功,它让你能够充分利用两种语言各自的优势,复用现有的 C 库,并在 C++ 项目中集成高性能的 C 组件,或将 C++ 功能以 C 兼容的方式暴露给其他系统。

通过本文的详细阐述,希望你对 extern "C" 的作用、原理和使用方法有了清晰全面的理解,能够 confidently 在你的 C/C++ 项目中应用这一强大工具。


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部