⚡C++ 编译链接与构建

ABI 与动态链接

面试回答

常见问法

  • 什么是 ABI?和 API 有什么区别?
  • 动态链接的过程是怎样的?
  • 为什么不同 libc 编译的 .so 不能混用?
  • 什么情况下会出现 ABI 不兼容?

回答

ABI(Application Binary Interface)是编译后二进制文件之间协作的规则集合,包括函数调用约定、数据类型大小与对齐、符号命名规则、动态链接器行为等。API 只保证源码能编译通过,ABI 保证编译好的二进制能直接拼在一起运行。

动态链接是程序运行时才把 .so 加载进来、解析符号地址的过程。Linux 下由动态链接器(如 /lib64/ld-linux-x86-64.so.2)负责。

不同 libc 编译的 .so 不能混用,因为它们对基础数据结构(如 FILEpthread_t)的内存布局不同,符号版本机制不同,动态链接器本身也不同。


追问

ABI 包含哪些具体内容?

ABI 的组成部分:

1. 调用约定(Calling Convention)
   - 参数通过哪些寄存器传递
   - 栈帧怎么组织
   - 返回值放哪
   - 谁负责清理栈(caller 还是 callee)

2. 数据类型的大小与对齐
   - int 是 4 字节还是 8 字节
   - 指针是 4 字节还是 8 字节
   - 结构体成员怎么对齐、有没有 padding

3. 符号命名与修饰(Name Mangling)
   - C++ 的 void foo(int) 编译后叫什么名字
   - 不同编译器的 mangling 规则可能不同

4. 虚函数表布局
   - vtable 里函数指针的排列顺序
   - 多继承时 vtable 怎么组织

5. 异常处理机制
   - 异常怎么传播、栈怎么展开
   - 异常表的格式

6. 动态链接规则
   - .so 的加载地址怎么确定
   - 符号怎么查找和绑定
   - 版本控制机制

调用约定具体是什么?

以 ARM64(你做鸿蒙开发用的架构)为例:

ARM64 调用约定(AAPCS64):

参数传递:
  - 前 8 个整数/指针参数 → x0-x7 寄存器
  - 前 8 个浮点参数 → v0-v7 寄存器
  - 超出的参数 → 压栈

返回值:
  - 整数/指针 → x0(大结构体通过 x8 指向的内存返回)
  - 浮点 → v0

栈帧:
  - 栈向低地址增长
  - 16 字节对齐
  - callee 保存 x19-x28、x29(FP)、x30(LR)

举例:
  int add(int a, int b, int c)
  调用时:a 在 x0,b 在 x1,c 在 x2
  返回值在 x0

为什么这个重要:如果库和你的程序对”参数放哪个寄存器”的理解不一致,函数调用就会传错参数。


C++ Name Mangling 是怎么回事?

// 源码
void foo(int x);
void foo(double x);
class Bar { void baz(int); };

// GCC/Clang 编译后的符号名(Itanium ABI)
_Z3fooi        // foo(int)
_Z3food        // foo(double)
_ZN3Bar3bazEi  // Bar::baz(int)

// MSVC 编译后的符号名
?foo@@YAXH@Z   // foo(int)
?foo@@YAXN@Z   // foo(double)

规则

  • _Z = C++ 符号前缀
  • 3foo = 函数名长度 3 + 名字 “foo”
  • i = int 参数,d = double 参数
  • N...E = 嵌套命名空间/类

为什么会出问题:GCC 和 MSVC 的 mangling 规则不同,所以 Windows 编译的 .lib 不能直接给 Linux 的 .o 链接。但 GCC 和 Clang 用同一套规则(Itanium ABI),所以它们编译的东西可以互链。


动态链接的完整过程

程序启动时发生了什么:

1. 内核加载 ELF 可执行文件到内存
2. 内核发现 .interp 段,里面写着动态链接器路径
   - glibc: /lib64/ld-linux-x86-64.so.2
   - musl:  /lib/ld-musl-aarch64.so.1
3. 内核把控制权交给动态链接器
4. 动态链接器做以下事情:
   a. 读取可执行文件的 .dynamic 段,找到依赖的 .so 列表
   b. 按顺序加载每个 .so(递归处理依赖)
   c. 搜索路径:RPATH → LD_LIBRARY_PATH → RUNPATH → /etc/ld.so.cache → 默认路径
   d. 符号解析:把程序里的 "printf" 绑定到 libc.so 里 printf 的实际地址
   e. 重定位:修改代码/数据中的地址引用
5. 全部解析完毕,跳转到程序的 main()

延迟绑定(Lazy Binding)

  • 默认情况下,函数符号不是启动时全部解析的
  • 第一次调用某函数时才解析(通过 PLT/GOT 机制)
  • 好处:启动快(不用解析所有符号)
  • 可以用 LD_BIND_NOW=1 强制启动时全部解析

PLT 和 GOT 是什么?

PLT (Procedure Linkage Table) — 代码段里的跳板
GOT (Global Offset Table) — 数据段里的地址表

调用 printf 时的流程:

第一次调用:
  你的代码 → PLT[printf] → GOT[printf](初始指向解析函数)
           → 动态链接器解析 printf 真实地址
           → 把真实地址写入 GOT[printf]
           → 跳转到 printf 执行

第二次调用:
  你的代码 → PLT[printf] → GOT[printf](已经是真实地址)
           → 直接跳转到 printf 执行

示意图:
┌──────────┐     ┌──────────┐     ┌──────────────┐
│ 你的代码  │────→│ PLT 跳板  │────→│ GOT 地址表    │
│ call printf│    │ jmp *GOT │     │ → printf 地址 │
└──────────┘     └──────────┘     └──────────────┘

为什么 glibc 和 musl 的 .so 不能混用?

具体原因有三层:

1. 动态链接器不同
   - glibc 程序的 .interp 写的是 /lib64/ld-linux-x86-64.so.2
   - musl 程序的 .interp 写的是 /lib/ld-musl-aarch64.so.1
   - 操作系统只会启动一个链接器,另一个 libc 的 .so 没法被正确加载

2. 内部数据结构不同
   - FILE 结构体:glibc 的 FILE 有 100+ 字段,musl 的精简很多
   - pthread_t:glibc 是指针,musl 是整数
   - 如果你的程序用 glibc 的 FILE 布局,传给 musl 的 fprintf → 内存越界

3. 符号版本不同
   - glibc 有符号版本机制:printf@GLIBC_2.2.5
   - musl 没有符号版本
   - glibc 编译的 .so 里记录了需要 GLIBC_2.x 版本的符号
   - musl 的链接器看不懂这些版本标记 → 链接失败

RPATH、RUNPATH、LD_LIBRARY_PATH 的区别和优先级

动态链接器搜索 .so 的顺序:

1. RPATH(编译时写死在 ELF 里,-Wl,-rpath)
   ↓ 如果没找到
2. LD_LIBRARY_PATH(环境变量,运行时设置)
   ↓ 如果没找到
3. RUNPATH(编译时写死,-Wl,--enable-new-dtags,-rpath)
   ↓ 如果没找到
4. /etc/ld.so.cache(ldconfig 生成的缓存)
   ↓ 如果没找到
5. 默认路径(/lib、/usr/lib)

注意:
- RPATH 和 RUNPATH 的区别:RPATH 优先级高于 LD_LIBRARY_PATH,
  RUNPATH 优先级低于 LD_LIBRARY_PATH
- 现代推荐用 RUNPATH(让用户能通过环境变量覆盖)
- $ORIGIN 是特殊变量,表示"当前 .so 所在目录"
  例:-rpath '$ORIGIN/../lib' 表示相对于可执行文件的路径

你在 PyTorch 移植时用到的:设置 RPATH 为 $ORIGIN/../lib,让 libtorch_cpu.so 能找到同目录下的其他依赖库。


什么情况下 ABI 会不兼容?

常见的 ABI 破坏场景:

1. 换了 libc(glibc → musl)
   → 你做 PyTorch 移植时遇到的

2. C++ 标准库 ABI 版本变了
   → GCC 5 引入新 std::string 实现
   → _GLIBCXX_USE_CXX11_ABI=0 vs =1

3. 编译器大版本升级
   → 虚函数表布局可能变
   → 异常处理表格式可能变

4. 结构体加了成员
   → sizeof 变了,所有用到这个结构体的代码都要重新编译

5. 编译选项不一致
   → -fPIC vs 非 PIC
   → -m32 vs -m64
   → 对齐选项不同

6. Python C 扩展的 ABI
   → CPython 3.11 和 3.12 的对象头布局不同
   → 所以 .so 扩展要标注 cp311 / cp312

原理展开:ELF 文件格式

ELF (Executable and Linkable Format) 是 Linux 下可执行文件和 .so 的格式。

一个 ELF 文件的结构:

┌─────────────────┐
│   ELF Header    │  ← 魔数、架构、入口地址
├─────────────────┤
│ Program Headers │  ← 告诉内核怎么加载到内存(段视图)
├─────────────────┤
│    .text        │  ← 代码段(机器指令)
│    .rodata      │  ← 只读数据(字符串常量等)
│    .data        │  ← 已初始化全局变量
│    .bss         │  ← 未初始化全局变量(不占文件空间)
│    .plt         │  ← PLT 跳板
│    .got         │  ← GOT 地址表
│    .dynamic     │  ← 动态链接信息(依赖哪些 .so)
│    .symtab      │  ← 符号表
│    .strtab      │  ← 字符串表
│    .interp      │  ← 动态链接器路径
│    ...          │
├─────────────────┤
│ Section Headers │  ← 告诉链接器各段在哪(节视图)
└─────────────────┘

常用命令:
  readelf -h a.out        # 查看 ELF 头
  readelf -d a.out        # 查看 .dynamic 段(依赖的 .so)
  readelf -l a.out        # 查看 Program Headers
  nm -D libfoo.so         # 查看动态符号
  ldd a.out               # 查看运行时依赖的 .so
  objdump -d a.out        # 反汇编

原理展开:交叉编译与 Toolchain

交叉编译 = 在 A 平台上编译出能在 B 平台运行的程序

三元组(Target Triple):
  架构-厂商-操作系统[-libc]
  
  x86_64-linux-gnu        → x86 Linux + glibc
  aarch64-linux-gnu       → ARM64 Linux + glibc
  aarch64-linux-musl      → ARM64 Linux + musl
  aarch64-linux-ohos      → ARM64 HarmonyOS(musl 变体)
  arm-none-eabi           → ARM 裸机(无 OS)

CMake Toolchain File 的核心内容:
# ohos-aarch64-toolchain.cmake
set(CMAKE_SYSTEM_NAME OHOS)
set(CMAKE_SYSTEM_PROCESSOR aarch64)

# 编译器
set(CMAKE_C_COMPILER   clang)
set(CMAKE_CXX_COMPILER clang++)

# 告诉 clang 目标平台
set(CMAKE_C_COMPILER_TARGET   aarch64-linux-ohos)
set(CMAKE_CXX_COMPILER_TARGET aarch64-linux-ohos)

# sysroot:目标平台的头文件和库在哪
set(CMAKE_SYSROOT /path/to/ohos-sdk/native/sysroot)

# 不要在宿主机上找库
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
使用方式:
  cmake -DCMAKE_TOOLCHAIN_FILE=ohos-aarch64-toolchain.cmake ..

原理:
  - CMAKE_SYSTEM_NAME 告诉 CMake "我不是在编译本机程序"
  - CMAKE_SYSROOT 告诉编译器 "头文件和库去这里找,别找宿主机的"
  - FIND_ROOT_PATH_MODE 防止 CMake 的 find_package 找到宿主机的库

易错点

  1. 以为同一个编译器就一定 ABI 兼容 — 不是,还要看 libc、编译选项、标准库版本
  2. 以为 API 兼容就能直接换 .so — 不是,API 兼容只保证源码能编译,不保证二进制能互换
  3. 以为 RPATH 和 RUNPATH 一样 — 不一样,优先级不同
  4. 以为 ldd 显示的就是运行时实际加载的 — 不一定,LD_LIBRARY_PATH 可以覆盖
  5. 以为静态链接就没有 ABI 问题 — 静态链接时 ABI 问题在编译期暴露(链接报错),不会到运行时才崩

记忆技巧

API vs ABI:
  API = 菜谱(源码约定)→ 能不能做出这道菜(能不能编译)
  ABI = 盘子规格(二进制约定)→ 做出来能不能装进盘子(能不能运行)

动态链接搜索顺序:
  "RPATH → 环境 → RUNPATH → 缓存 → 默认"
  记:R-E-R-C-D(RERCD)

ELF 关键段:
  .text = 代码
  .data = 数据
  .dynamic = 依赖信息
  .interp = 链接器路径
  .plt/.got = 延迟绑定跳板

面试速答版

Q: ABI 是什么?

编译后二进制之间协作的规则——调用约定、数据布局、符号命名、链接方式。API 管源码能不能编译,ABI 管编译好的东西能不能直接拼在一起跑。

Q: 动态链接过程?

内核加载 ELF → 找到 .interp 里的动态链接器 → 链接器读 .dynamic 找依赖 .so → 按 RPATH/LD_LIBRARY_PATH/RUNPATH/cache/默认路径搜索 → 加载并解析符号 → 通过 PLT/GOT 实现延迟绑定 → 跳转 main()。

Q: 为什么 glibc 和 musl 不兼容?

三个原因:动态链接器不同(路径和行为)、内部数据结构布局不同(FILE、pthread_t 等)、符号版本机制不同(glibc 有 versioned symbols,musl 没有)。

Q: 你在项目里怎么处理 ABI 问题的?

PyTorch 官方 wheel 链接的是 glibc,我需要在 musl 平台跑,所以用 musl 工具链从源码重新编译。写了 CMake Toolchain File 指定 target triple 为 aarch64-linux-ohos、sysroot 指向 musl 头文件和库,关掉不兼容的组件,最终产出能在 musl 环境运行的 .so。

Related · 编译链接与构建