C++ 基础与原生互操作(四):库、符号与 ABI

一个 C++ 函数在源码里叫 open_device,并不代表其他程序一定能在 DLL 中按这个名字找到它。跨文件调用依赖链接,跨动态库调用还依赖导出表、加载器和 ABI。本篇把这些容易混在一起的概念拆开。

1. 翻译单元与链接符号

每个 .cpp 文件连同展开后的头文件通常形成一个翻译单元(translation unit,也常译作编译单元)。编译器分别处理翻译单元,链接器再把目标文件和库组合起来。

1
2
3
4
// math_api.h
#pragma once

double add(double left, double right);
1
2
3
4
5
6
7
// math_api.cpp
#include "math_api.h"

double add(double left, double right)
{
    return left + right;
}

头文件声明使调用处能通过类型检查,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++ 支持重载,下面两个函数源码名相同:

1
2
int convert(int value);
double convert(double value);

链接器需要区分它们,因此编译器会把函数名、参数类型、命名空间等信息编码到链接名称中,这称为名称修饰(name mangling)。不同工具链的修饰规则可能不同。

1
extern "C" int open_device(const char* endpoint);

extern "C" 为函数指定 C 语言链接,避免 C++ 重载式名称修饰,使导出名称更适合其他语言查找。但它不等于“这个接口已经跨平台稳定”:参数布局、调用约定、类型宽度和所有权仍需单独设计,而且同一作用域中不能依靠 C 链接名称导出重载组。

5. Windows 导出与跨平台可见性

可以用宏把平台差异集中在公共头文件中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#pragma once

#if defined(_WIN32)
    #if defined(DEVICE_NATIVE_BUILD)
        #define DEVICE_API __declspec(dllexport)
    #else
        #define DEVICE_API __declspec(dllimport)
    #endif
    #define DEVICE_CALL __cdecl
#else
    #define DEVICE_API __attribute__((visibility("default")))
    #define DEVICE_CALL
#endif

extern "C" DEVICE_API int DEVICE_CALL device_version();

构建 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 后端构建共享库:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
cmake_minimum_required(VERSION 3.20)
project(device_native LANGUAGES CXX)

add_library(device_native SHARED
    device_api.cpp
    device_api.h
)

target_compile_features(device_native PRIVATE cxx_std_20)
target_compile_definitions(device_native PRIVATE DEVICE_NATIVE_BUILD)

set_target_properties(device_native PROPERTIES
    CXX_VISIBILITY_PRESET hidden
    VISIBILITY_INLINES_HIDDEN YES
)

if(MSVC)
    target_compile_options(device_native PRIVATE /W4 /permissive-)
else()
    target_compile_options(device_native PRIVATE -Wall -Wextra -Wpedantic)
endif()

常见构建命令:

1
2
cmake -S . -B build
cmake --build build --config Release

-S 指定源码目录,-B 指定独立构建目录。Visual Studio 等多配置生成器需要 --config Release 选择配置;单配置生成器通常在配置阶段通过 CMAKE_BUILD_TYPE 选择,不能假设两者行为完全相同。

CMake 的 SHARED 目标在 Windows 通常产生 DLL 和导入库,在 Linux 通常产生 .so。具体文件名、后缀和输出目录仍受平台、生成器和项目属性影响。

7. 检查导出符号

Windows 开发者命令行可使用:

1
dumpbin /exports device_native.dll

Linux 常用:

1
2
nm -D --defined-only libdevice_native.so
readelf --dyn-syms libdevice_native.so

你希望看到稳定的 device_version 之类的 C 入口,而不是带有大量问号、类型编码或命名空间信息的 C++ 修饰名。

符号检查只证明“入口被导出”,不能证明 P/Invoke 参数、结构布局和调用约定正确。后者需要对照公共头文件,并以实际调用测试验证。

8. Debug / Release 与运行库边界

Debug 和 Release 不只是“有没有日志”:优化、迭代器检查、运行库选择和二进制布局都可能不同。不要把 Debug 版原生库误当作可随意交付的生产依赖。

尤其要避免一边分配、另一边用不匹配的运行库释放。例如 DLL 返回由内部 new[] 分配的缓冲区,C# 再调用不对应的释放方式,可能破坏堆。可靠做法包括:

  • 调用者提供缓冲区和容量;
  • DLL 同时提供配对的 create / destroyallocate / free
  • 对象始终由创建它的模块销毁;
  • 文档明确线程安全、生命周期和版本兼容策略。

9. DLL 能否 P/Invoke 的判断清单

拿到一个 C++ SDK 时依次确认:

  1. 当前进程与 DLL 的架构是否一致;
  2. DLL 是否导出可定位的非托管函数入口;
  3. 是否提供 C 兼容头文件,而不是只导出 C++ 类;
  4. 调用约定、整数宽度、结构布局和字符编码是什么;
  5. 每个指针是否可空、长度单位是什么、谁拥有内存;
  6. 错误怎样返回,异常是否被挡在 DLL 内;
  7. DLL 的依赖库和运行库是否随部署完整;
  8. 回调能从哪些线程触发,句柄能否并发使用。

如果供应商只提供 C++ 类接口,常见办法不是在 C# 中猜 ABI,而是增加一层由兼容工具链构建的原生包装 DLL,对外暴露 C ABI。现有文章 C# 原生互操作与设备 SDK(八):C++ ABI 与跨平台封装 从 C# 调用方讨论了这条路线。

10. 本篇小结

库文件只是载体,真正连接双方的是 ABI。extern "C" 解决的是语言链接和名称修饰的一部分,导出宏解决符号可见性的一部分;类型布局、调用约定、内存所有权和错误边界仍需共同设计。下一篇将把这些规则落到一套可以被 P/Invoke 调用的完整接口上。

参考资料

Licensed under CC BY-NC-SA 4.0