一个 C++ 函数在源码里叫 open_device,并不代表其他程序一定能在 DLL 中按这个名字找到它。跨文件调用依赖链接,跨动态库调用还依赖导出表、加载器和 ABI。本篇把这些容易混在一起的概念拆开。
1. 翻译单元与链接符号
每个 .cpp 文件连同展开后的头文件通常形成一个翻译单元(translation unit,也常译作编译单元)。编译器分别处理翻译单元,链接器再把目标文件和库组合起来。
| |
| |
头文件声明使调用处能通过类型检查,math_api.cpp 中的定义则产生链接所需的符号。如果定义没有参与最终链接,会出现未解析外部符号;如果不符合规则地提供多个定义,也可能链接失败或形成违反单一定义规则的程序。
2. 静态库和动态库
| 产物 | Windows 常见形式 | Linux 常见形式 | 主要作用 |
|---|---|---|---|
| 目标文件 | .obj | .o | 单个翻译单元的编译结果 |
| 静态库 | .lib | .a | 链接时把所需目标代码纳入最终产物 |
| 动态库 | .dll | .so | 运行时由加载器映射,并解析导出符号 |
| 导入库 | .lib | 通常直接链接 .so(或指向 SONAME 的链接名符号链接) | Windows 原生链接器用来描述 DLL 入口 |
Windows 的 .lib 既可能是静态库,也可能是 DLL 的导入库,必须结合生成方式判断。P/Invoke 在运行时加载的是 DLL,不使用 C++ 链接器所需的 .lib;但原生 C++ 调用者通常会在构建时链接导入库。
动态库还可能依赖其他动态库。主 DLL 文件存在,不代表加载一定成功:依赖缺失、进程架构不一致、搜索路径错误或运行库缺失都会在调用入口之前造成失败。
3. ABI 是二进制层面的合同
API 描述源码如何调用;ABI(Application Binary Interface)描述编译后二进制怎样协作,通常包括:
- 参数如何放入寄存器或栈、返回值如何传递;
- 类型宽度、对齐、结构体和类布局;
- 函数调用约定;
- 符号命名和名称修饰;
- 异常传播、运行库和内存分配约定。
C++ 标准不规定一个跨所有编译器和平台的统一 ABI。即使两份代码都符合相同语言标准,由不同工具链、选项或标准库构建,也不能据此推断二进制接口必然兼容。
这也是“头文件能看懂”却仍然“DLL 调不通”的根本原因:P/Invoke 对接的是 ABI,而不是 C++ 源码语法。
4. 名称修饰与 extern "C"
C++ 支持重载,下面两个函数源码名相同:
| |
链接器需要区分它们,因此编译器会把函数名、参数类型、命名空间等信息编码到链接名称中,这称为名称修饰(name mangling)。不同工具链的修饰规则可能不同。
| |
extern "C" 为函数指定 C 语言链接,避免 C++ 重载式名称修饰,使导出名称更适合其他语言查找。但它不等于“这个接口已经跨平台稳定”:参数布局、调用约定、类型宽度和所有权仍需单独设计,而且同一作用域中不能依靠 C 链接名称导出重载组。
5. Windows 导出与跨平台可见性
可以用宏把平台差异集中在公共头文件中:
| |
构建 DLL 本身时定义 DEVICE_NATIVE_BUILD,Windows 编译器把函数写入 DLL 导出表;原生 C++ 消费者包含同一头文件时不定义它,声明会变成 dllimport。P/Invoke 消费者不编译这个头文件,但其声明仍必须匹配最终 ABI。这里的简化形式依赖 C++ 编译器;完整的 C/C++ 双解析公共头写法见下一篇。
在 64 位 Windows 的常规 x64 ABI 下,__cdecl、__stdcall 等传统约定大多不再像 32 位 x86 那样改变参数传递方式,但显式约定仍能表达合同,并避免 32 位构建时双方不一致。不要拿 x64 上“碰巧都能调用”的现象反推所有架构。
Linux 共享库默认可能暴露许多全局符号。项目可用 -fvisibility=hidden 隐藏默认符号,再用 visibility("default") 只开放公共入口,从而缩小 ABI 表面。
6. 用 CMake 描述库目标
CMake 是跨平台构建系统生成器,不是编译器。下面的项目可用 MSVC、GCC 或 Clang 后端构建共享库:
| |
常见构建命令:
| |
-S 指定源码目录,-B 指定独立构建目录。Visual Studio 等多配置生成器需要 --config Release 选择配置;单配置生成器通常在配置阶段通过 CMAKE_BUILD_TYPE 选择,不能假设两者行为完全相同。
CMake 的 SHARED 目标在 Windows 通常产生 DLL 和导入库,在 Linux 通常产生 .so。具体文件名、后缀和输出目录仍受平台、生成器和项目属性影响。
7. 检查导出符号
Windows 开发者命令行可使用:
| |
Linux 常用:
| |
你希望看到稳定的 device_version 之类的 C 入口,而不是带有大量问号、类型编码或命名空间信息的 C++ 修饰名。
符号检查只证明“入口被导出”,不能证明 P/Invoke 参数、结构布局和调用约定正确。后者需要对照公共头文件,并以实际调用测试验证。
8. Debug / Release 与运行库边界
Debug 和 Release 不只是“有没有日志”:优化、迭代器检查、运行库选择和二进制布局都可能不同。不要把 Debug 版原生库误当作可随意交付的生产依赖。
尤其要避免一边分配、另一边用不匹配的运行库释放。例如 DLL 返回由内部 new[] 分配的缓冲区,C# 再调用不对应的释放方式,可能破坏堆。可靠做法包括:
- 调用者提供缓冲区和容量;
- DLL 同时提供配对的
create/destroy或allocate/free; - 对象始终由创建它的模块销毁;
- 文档明确线程安全、生命周期和版本兼容策略。
9. DLL 能否 P/Invoke 的判断清单
拿到一个 C++ SDK 时依次确认:
- 当前进程与 DLL 的架构是否一致;
- DLL 是否导出可定位的非托管函数入口;
- 是否提供 C 兼容头文件,而不是只导出 C++ 类;
- 调用约定、整数宽度、结构布局和字符编码是什么;
- 每个指针是否可空、长度单位是什么、谁拥有内存;
- 错误怎样返回,异常是否被挡在 DLL 内;
- DLL 的依赖库和运行库是否随部署完整;
- 回调能从哪些线程触发,句柄能否并发使用。
如果供应商只提供 C++ 类接口,常见办法不是在 C# 中猜 ABI,而是增加一层由兼容工具链构建的原生包装 DLL,对外暴露 C ABI。现有文章 C# 原生互操作与设备 SDK(八):C++ ABI 与跨平台封装 从 C# 调用方讨论了这条路线。
10. 本篇小结
库文件只是载体,真正连接双方的是 ABI。extern "C" 解决的是语言链接和名称修饰的一部分,导出宏解决符号可见性的一部分;类型布局、调用约定、内存所有权和错误边界仍需共同设计。下一篇将把这些规则落到一套可以被 P/Invoke 调用的完整接口上。