在对 librdkafka 的 pthread 切 bthread 改造时,由于 librdkafka 是 C 代码实现,而 bthread 是 C++ 代码实现,所以直接联编两者会报错,需要新增一层 wrapper ,使用 extern "C" 包裹。cr 的时候同事就问我,这个 extern "C" 有什么用,当时我回答这样就可以让 C 调用 C++ 代码了,但是进一步深究其原理时,我就答不上了。抽空专门让克劳德老师辅导我了一下。
如何让 C 调用 C++ 代码
使用简单的样例来复现 C 调用 C++ 代码的场景,分析在编译过程中产出的相关文件:
首先编写 C++ 库的头文件:
1 | // c++ 库头文件 cpp_func_bad.h |
然后进行简单的实现:
1 | // c++ 库的实现 cpp_func_bad.cpp |
最后编写 C 代码作为主函数入口,其中引用了 C++ 库的头文件,并调用了 C++ 函数
1 | // c main.c |
接下来分别编译 C++ 库和 C main 函数,编译均成功:
1 | g++ -c cpp_func_bad.cpp -o cpp_func_bad.o |
最后将 object 链接在一起生成可执行的 bin 。(注:这里由于包含了 C++ 代码,所以最终使用 g++ 而非 gcc 进行链接,g++ 会自动链接 C++ 的标准库 -lstdc++)
1 | g++ main_fail.o cpp_func_bad.o -o main_fail |
执行失败,相关报错如下:
1 | /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': |
查看两个 object 文件的符号表信息:
1 | # nm main_fail.o |
1 | # nm cpp_func_bad.o |
可以看到,两者的函数符号对不上,其中 g++ 在编译 C++ 代码时,对符号进行了改写。g++ 之所以会对符号进行改写,是因为 C++ 支持函数重载,同名函数可以有不同参数。为了让链接器区分它们,C++ 编译器会把函数名编码改写,称为 Name Mangling(名字改编)。而 C 编译器没有重载,也没有 name mangling,函数名编译后就是原始字符串。那么这个问题应该如何解决呢?此时就需要使用 extern "C" 了。
对 cpp_func_bad.h 进行简单的修改,如下:
1 |
|
重新执行对这个 C++ 代码的编译和最终的链接,都是成功的:
1 | g++ -c cpp_func_bad.cpp -o cpp_func_bad.o |
最终产出的 bin 执行也是成功的:
1 | ./main_fail |
重新执行 nm 查看符号表,此时两个函数均变成了 C 风格的符号了,这样在最终链接的时候就可以找到符号了:
1 | # cpp_func_bad.o |
底层知识补充
当了解到符号名称打平这一概念时,我想到,不论其是 C 实现的,还是 C++ 实现,甚至是 Golang / Rust / Python 等其它语言实现,只要它们的编译器编译产出的二进制中的符号表的格式一致,那么预期就可以互相调用。下面是一些底层概念知识点的学习。
ABI
ABI (Application Binary Interface) 是两个已编译的二进制模块之间交互的全部约定。
首先是内存布局,CPU 访问内存有对齐要求:int(4字节)要放在 4 的倍数地址,编译器通过插入 padding 字节满足这个要求。调用方和被调用方必须对同一个 struct 完全使用相同的定义,否则字段偏移不一致,读写的就是错误的内存位置。这就是为什么改了一个共享头文件里的 struct 字段顺序,所有依赖它的 .so 都要重新编译。
1 | BadLayout(12字节) GoodLayout(8字节) |
接着是调用约定(Calling Convention)。x86-64 System V ABI(Linux/macOS 标准)规定:
整型参数传递:rdi → rsi → rdx → rcx → r8 → r9 → 栈(第7个起)
返回值: rax(整型)/ xmm0(浮点)
1 | ; callee(1,2,3,4,5,6,7) 的汇编(简化) |
不透明指针(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 | 调用方内存(栈) 堆内存 |
调用方 buf 的变量只是一个 8 字节的地址,它执行的 struct Buffer 完全由实现方管理。它与 extern "C" 的结合为:不透明指针 + extern “C” = 稳定的跨语言 C ABI
1 | C++ 内部实现(class、模板、异常...) |
so 本质
.so 文件本质是一张 符号名 → 机器码地址 的表。只要符号名是 C 风格(不改编),且入口处的机器码遵守 C 调用约定,调用方是 C、C++、Go、Rust、Python 还是 Java(JNI)都无所谓——CPU 只认寄存器和栈,不认编程语言。