不同进程:消息通信
各自运行,把请求和结果序列化后传递。双方无需使用同一种语言,也不需要被链接成同一个程序。
你已经能写 add(20, 22)。现在换一个问题:如果 add 的实现来自另一种语言,C++ 还可以调用它吗?
一个项目可能既要调用成熟的库,又要做界面、统计和高效计算。不同部分选用合适的语言,是为了复用已有成果、减少开发工作;收益值得时,才增加语言之间的边界。
下面从这个动机出发,把编译、链接和二进制接口连成一条线,最后亲手做出一个输出 42 的混合语言程序。
各自运行,把请求和结果序列化后传递。双方无需使用同一种语言,也不需要被链接成同一个程序。
代码被编译为兼容的目标文件或库,在同一进程内调用。接口两侧需要对齐符号、类型和调用约定。
执行 gcc hello.c -o hello 时,通常由编译器驱动程序组织多个工具完成工作。为了看清分工,我们用常见的 C 工具链,把它拆成五个可以观察的位置。[1]
宏、头文件和函数调用仍然是源代码中的文本。处理器不能直接执行这些 C 语言语句。
编辑文件:hello.c#include <stdio.h>
#define ANSWER 42
int main(void) {
printf("%d\n", ANSWER);
return 0;
}| 选项 | 做到哪一步 | 典型输出 |
|---|---|---|
| gcc -E hello.c | 仅预处理 | 预处理后的文本 |
| gcc -S hello.c | 预处理 + 编译 | hello.s 汇编文本 |
| gcc -c hello.c | 预处理 + 编译 + 汇编 | hello.o 目标文件 |
| gcc hello.c -o hello | 继续到链接 | 可执行文件 hello |
这是常见 C/GCC 风格流程。编译器可以使用内部中间表示,也可能集成汇编器,不一定把每种中间文件写到磁盘;其他语言的流程也可能不同。macOS 上名为 gcc 的命令还可能实际调用 Apple Clang,可用 gcc --version 查看。
gcc -c add.c,你得到什么?编译 main.cpp 时,编译器可以先根据声明检查 add 的调用。它不必在同一份源码里看到函数体。到了链接阶段,工具才需要为外部引用找到对应定义。
main.o调用方add.o这是对符号解析的简化演示,假定符号名、目标格式、架构和 ABI 均兼容。真实错误文案依链接器而异。
链接器还会处理重定位:根据最终布局修正需要调整的位置。链接能解决“引用指向哪里”,但它不会自动修复“调用双方对参数的理解不一致”。后者留到 ABI 章节。
“库”是便于复用的一组代码。下面两种方式描述库与程序的组合关系;它们都可以参与多语言项目。
链接时,所需库代码被纳入各自的程序。通常更便于打包这一部分依赖,但多个程序可能重复包含同一份库代码。
| 常见平台 | 静态库 | 共享库 |
|---|---|---|
| Linux | .a | .so |
| macOS | .a | .dylib |
| Windows / MSVC | .lib | .dll,常配合导入库 |
这些是常见命名,扩展名不是语言。Windows 的 .lib 也可能是导入库,不能仅凭后缀断定库代码被静态打进了程序。共享库的更新还必须考虑 ABI 兼容性。
我们把入口和输出留在 C++,把加法放在 C。两边共享一份头文件,各自编译,再链接成一个程序。这个例子故意使用简单整数,先把接口和构建过程看清。
#ifndef ADD_H
#define ADD_H
#ifdef __cplusplus
extern "C" {
#endif
int add(int a, int b);
#ifdef __cplusplus
}
#endif
#endif#include "add.h"
int add(int a, int b) {
return a + b;
}#include <iostream>
#include "add.h"
int main() {
std::cout << add(20, 22) << '\n';
return 0;
}add.c 提供实现,main.cpp 使用这个接口。cc 编译 C,c++ 编译 C++,都用 -c 暂停在目标文件。add.o 加入链接,不会让整个项目变成 C 项目。cc -std=c11 -Wall -Wextra -c add.c -o add.o
c++ -std=c++17 -Wall -Wextra -c main.cpp -o main.o
c++ main.o add.o -o demo
./demoextern "C" 到底做了什么?在 C++ 一侧,它为相关函数指定 C 语言链接;在常见工具链中,这也避免使用 C++ 的函数名修饰方式。__cplusplus 条件让 C 编译器跳过这段 C++ 专用语法。它不会把任意 C++ 类型或代码“转换成 C”,也不能消除架构、数据布局等兼容要求。[2]C++ 支持函数重载,常见实现会把参数等信息编码进链接时的符号名;而 C 编译出的 add 使用另一套命名约定。若 C++ 一侧按 C++ 链接寻找它,就可能找不到 C 一侧提供的定义。
这不是“加个 extern 就什么都兼容”:接口类型、平台 ABI、对象格式和编译目标仍要匹配。std::string、异常和对象所有权等也需要单独设计,不能照搬这个整数例子。
原理仍然是导出兼容的接口,再用适合目标平台的方式构建、链接库。下面展示 Rust 2024 的接口片段,使用 C 的整数类型和调用约定;此片段不是本页下载示例的一部分。[3]
// 使用 Rust 2024 edition
use std::ffi::c_int;
// SAFETY: 整个链接单元中,add 符号只有这一个定义。
#[unsafe(no_mangle)]
pub extern "C" fn add(a: c_int, b: c_int) -> c_int {
a + b // 本教程只使用 20、22 这样不会溢出的输入。
}extern "C" 指定 ABI;no_mangle 保留可预测的导出符号名。Rust 2024 要求将这一属性标为 unsafe(...),因为符号名冲突可能带来安全问题。真实接口还要约定溢出、错误处理和所有权;不要让异常或 Rust panic 随意跨过 FFI 边界。具体链接参数依平台、库类型和工具链而异。
运行环境:Linux/macOS,已安装支持上述选项的 C/C++ 工具链。Windows/MSVC 的命令和产物命名不同。这是终端练习,网页中的实验不会代替真实编译器。
假设函数已经找到了:调用方把第一个参数放到 R0,函数却从 R1 读。这时,符号能对上,计算依然可能出错。要让二进制代码正确协作,双方还需要遵守兼容的 ABI。
| 看哪一层 | 关心的问题 | 在 add 例子中 |
|---|---|---|
| API · 源码接口 | 函数叫什么、参数和返回值的语义是什么? | int add(int a, int b) 用来求和 |
| ABI · 二进制接口 | 类型如何布局、参数如何传递、结果从哪里取、符号如何命名? | 调用双方按同一套底层规则传递两个整数 |
调用方把 20、22 放入 R0、R1;被调函数从同样的位置读取,求和后返回到 R0。调用方读回 42,与预期一致。
类型大小、对齐要求、结构体字段布局等。同样写成“一个整数”,二进制表示也需要双方理解一致。
参数在寄存器还是栈上、返回值放哪里、哪些寄存器需要保存、谁负责相应的栈操作。
函数名如何编码、哪些符号可见。链接器能找到函数,是跨语言调用的必要环节之一。
处理器、操作系统、对象格式、编译器选项等都会影响兼容性。“都是 C ABI”不能跨所有平台通用。
add(a, b),不能证明二进制兼容;同样,某次恰好得到正确数值,也不能证明约定一致。跨语言接口还要明确内存归谁释放、字符串如何编码、错误如何传递。真实 ABI 通常由具体平台规范定义。例如 Arm AArch64 过程调用标准会规定真实的寄存器与参数传递规则;本页的虚构实验只用于帮助理解“约定必须对齐”。
多语言本身不是目标。先明确需要复用的能力,以及接口要跨过哪条边界,再衡量构建、调试和维护的成本。
已有成熟 C 库,或操作系统提供了 C 接口。通过合适的接口接入,能减少重复实现。
先测量瓶颈,再考虑用原生代码处理关键计算。频繁跨边界、复制数据,也可能抵消收益。
界面、脚本和业务逻辑可以使用合适的生态;硬件、实时约束或特定平台能力另作设计。
保留已有模块,通过稳定接口替换局部。团队经验、平台支持也会影响选择,但会增加协调成本。
例如 HTTP、RPC。重点设计协议、序列化、超时和错误语义。
先确认支持的语言接口、目标平台 ABI、类型、内存与错误处理约定。
只有收益足以覆盖构建、调试和分发成本时,再增加语言边界。
-E、-S、-c。extern "C"。