从 extern "C" 到跨语言调用

在对 librdkafka 的 pthread 切 bthread 改造时,由于 librdkafka 是 C 代码实现,而 bthread 是 C++ 代码实现,所以直接联编两者会报错,需要新增一层 wrapper ,使用 extern "C" 包裹。cr 的时候同事就问我,这个 extern "C" 有什么用,当时我回答这样就可以让 C 调用 C++ 代码了,但是进一步深究其原理时,我就答不上了。抽空专门让克劳德老师辅导我了一下。

如何让 C 调用 C++ 代码

使用简单的样例来复现 C 调用 C++ 代码的场景,分析在编译过程中产出的相关文件:

首先编写 C++ 库的头文件:

1
2
3
4
5
6
7
8
9
10
11
// c++ 库头文件 cpp_func_bad.h
#ifndef CPP_FUNC_BAD_H
#define CPP_FUNC_BAD_H

/* 没有 extern "C" 包裹 —— C++ 编译器会对函数名做 name mangling,
生成形如 _Z15hello_from_cppPKc 的符号,C 链接器无法找到 */

void hello_from_cpp_bad(const char *name);
int add_in_cpp_bad(int a, int b);

#endif /* CPP_FUNC_BAD_H */

然后进行简单的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
// c++ 库的实现 cpp_func_bad.cpp
#include <iostream>
#include <string>
#include "cpp_func_bad.h"

void hello_from_cpp_bad(const char *name) {
std::string s(name);
std::cout << "[C++] Hello, " << s << "!" << std::endl;
}

int add_in_cpp_bad(int a, int b) {
return a + b;
}

最后编写 C 代码作为主函数入口,其中引用了 C++ 库的头文件,并调用了 C++ 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// c main.c
#include <stdio.h>
#include "cpp_func_bad.h" /* 头文件中没有 extern "C" */

int main(void) {
/* 编译阶段:gcc 按 C 规则记录符号名 hello_from_cpp_bad
链接阶段:cpp_func_bad.o 中只有 mangled 名 _Z19hello_from_cpp_badPKc
=> 链接器找不到符号,报 undefined reference */
hello_from_cpp_bad("C world");

int result = add_in_cpp_bad(3, 4);
printf("[C] 3 + 4 = %d\n", result);

return 0;
}

接下来分别编译 C++ 库和 C main 函数,编译均成功:

1
2
g++ -c cpp_func_bad.cpp -o cpp_func_bad.o
gcc -c main_fail.c -o main_fail.o

最后将 object 链接在一起生成可执行的 bin 。(注:这里由于包含了 C++ 代码,所以最终使用 g++ 而非 gcc 进行链接,g++ 会自动链接 C++ 的标准库 -lstdc++

1
g++ main_fail.o cpp_func_bad.o -o main_fail

执行失败,相关报错如下:

1
2
3
4
/home/opt/compiler/gcc-12/bin/../lib/gcc/x86_64-pc-linux-gnu/12.1.0/../../../../x86_64-pc-linux-gnu/bin/ld: main_fail.o: in function `main':
main_fail.c:(.text+0xe): undefined reference to `hello_from_cpp_bad'
/home/opt/compiler/gcc-12/bin/../lib/gcc/x86_64-pc-linux-gnu/12.1.0/../../../../x86_64-pc-linux-gnu/bin/ld: main_fail.c:(.text+0x1d): undefined reference to `add_in_cpp_bad'
collect2: error: ld returned 1 exit status

查看两个 object 文件的符号表信息:

1
2
3
4
5
# nm main_fail.o
U add_in_cpp_bad
U hello_from_cpp_bad
0000000000000000 T main
U printf
1
2
3
4
5
# nm cpp_func_bad.o
...
00000000000000bf T _Z14add_in_cpp_badii
0000000000000000 T _Z18hello_from_cpp_badPKc
...

可以看到,两者的函数符号对不上,其中 g++ 在编译 C++ 代码时,对符号进行了改写。g++ 之所以会对符号进行改写,是因为 C++ 支持函数重载,同名函数可以有不同参数。为了让链接器区分它们,C++ 编译器会把函数名编码改写,称为 Name Mangling(名字改编)。而 C 编译器没有重载,也没有 name mangling,函数名编译后就是原始字符串。那么这个问题应该如何解决呢?此时就需要使用 extern "C" 了。

cpp_func_bad.h 进行简单的修改,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#ifndef CPP_FUNC_BAD_H
#define CPP_FUNC_BAD_H

/* 用 extern "C" 包裹,禁止 C++ 编译器对函数名做 name mangling,
使 C 代码可以按原始符号名找到这些函数 */
#ifdef __cplusplus
extern "C" {
#endif

void hello_from_cpp_bad(const char *name);
int add_in_cpp_bad(int a, int b);
#ifdef __cplusplus
}
#endif

#endif /* CPP_FUNC_BAD_H */

重新执行对这个 C++ 代码的编译和最终的链接,都是成功的:

1
2
g++ -c cpp_func_bad.cpp -o cpp_func_bad.o
g++ main_fail.o cpp_func_bad.o -o main_fail

最终产出的 bin 执行也是成功的:

1
2
3
./main_fail 
[C++] Hello, C world!
[C] 3 + 4 = 7

重新执行 nm 查看符号表,此时两个函数均变成了 C 风格的符号了,这样在最终链接的时候就可以找到符号了:

1
2
3
4
5
# cpp_func_bad.o
...
00000000000000bf T add_in_cpp_bad
0000000000000000 T hello_from_cpp_bad
...

底层知识补充

当了解到符号名称打平这一概念时,我想到,不论其是 C 实现的,还是 C++ 实现,甚至是 Golang / Rust / Python 等其它语言实现,只要它们的编译器编译产出的二进制中的符号表的格式一致,那么预期就可以互相调用。下面是一些底层概念知识点的学习。

ABI

ABI (Application Binary Interface) 是两个已编译的二进制模块之间交互的全部约定。

首先是内存布局,CPU 访问内存有对齐要求:int(4字节)要放在 4 的倍数地址,编译器通过插入 padding 字节满足这个要求。调用方和被调用方必须对同一个 struct 完全使用相同的定义,否则字段偏移不一致,读写的就是错误的内存位置。这就是为什么改了一个共享头文件里的 struct 字段顺序,所有依赖它的 .so 都要重新编译。

1
2
3
4
5
6
BadLayout(12字节)         GoodLayout(8字节)
┌────┬───────────┬────┐ ┌──────────┬────┬────┐
│ a │ padding │ b │ │ b │ a │ c │
│ 1B │ 3B pad │ 4B │ │ 4B │ 1B │ 1B │
└────┴───────────┴────┘ └──────────┴────┴────┘
offset(b) = 4 offset(b) = 0

接着是调用约定(Calling Convention)。x86-64 System V ABI(Linux/macOS 标准)规定:

  • 整型参数传递:rdi → rsi → rdx → rcx → r8 → r9 → 栈(第7个起)

  • 返回值: rax(整型)/ xmm0(浮点)

1
2
3
4
5
6
7
8
9
; callee(1,2,3,4,5,6,7) 的汇编(简化)
mov edi, 1 ; 第1参数 → rdi
mov esi, 2 ; 第2参数 → rsi
mov edx, 3 ; 第3参数 → rdx
mov ecx, 4 ; 第4参数 → rcx
mov r8d, 5 ; 第5参数 → r8
mov r9d, 6 ; 第6参数 → r9
push 7 ; 第7参数 → 压栈 ← 超出寄存器数量
call callee

不透明指针(Opaque Handle)

它的核心机制为不完整类型(Incomplete Type)。例如在 buffer.h 中只写 typedef struct Buffer Buffer 。这里为 “前向声明”,只告诉编译器”这个类型存在” ,而没有 struct Buffer { ... } 的完整定义。编译器对不完整类型的约束如下:

  • Buffer* p; 允许,指针大小固定 8 字节,不需要知道内部

  • sizeof(Buffer) 编译报错,不知道大小

  • p->size 编译报错,不知道字段偏移

  • malloc(sizeof(Buffer)) 编译报错,原因同上

  • buffer_create() 返回 Buffer* 允许,为指针传递

执行流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
调用方内存(栈)                堆内存
┌───────────────┐ ┌────────────────────────────────┐
│ Buffer* buf │──────────► │ struct Buffer(32 bytes) │
│ (8 bytes) │ 0x5a12b0 │ ┌──────┬──────┬─────┬───────┐│
└───────────────┘ │ │ data │ size │ cap │r_pos ││
│ │ * │ 13 │ 16 │ 13 ││
│ └──┬───┴──────┴─────┴───────┘│
└─────┼──────────────────────────┘

▼ buf->data 指向的动态数组
┌───────────────────────────────┐
│ "Hello, World!" (16 bytes) │
└───────────────────────────────┘

调用方 buf 的变量只是一个 8 字节的地址,它执行的 struct Buffer 完全由实现方管理。它与 extern "C" 的结合为:不透明指针 + extern “C” = 稳定的跨语言 C ABI

1
2
3
4
5
C++ 内部实现(class、模板、异常...)

│ extern "C" + 不透明指针 = C ABI 边界

void* / struct Foo* ─────► C 代码 / Python / Java JNI / Rust FFI

so 本质

.so 文件本质是一张 符号名 → 机器码地址 的表。只要符号名是 C 风格(不改编),且入口处的机器码遵守 C 调用约定,调用方是 C、C++、Go、Rust、Python 还是 Java(JNI)都无所谓——CPU 只认寄存器和栈,不认编程语言。