ABI 与动态链接
面试回答
常见问法
- 什么是 ABI?和 API 有什么区别?
- 动态链接的过程是怎样的?
- 为什么不同 libc 编译的 .so 不能混用?
- 什么情况下会出现 ABI 不兼容?
回答
ABI(Application Binary Interface)是编译后二进制文件之间协作的规则集合,包括函数调用约定、数据类型大小与对齐、符号命名规则、动态链接器行为等。API 只保证源码能编译通过,ABI 保证编译好的二进制能直接拼在一起运行。
动态链接是程序运行时才把 .so 加载进来、解析符号地址的过程。Linux 下由动态链接器(如 /lib64/ld-linux-x86-64.so.2)负责。
不同 libc 编译的 .so 不能混用,因为它们对基础数据结构(如 FILE、pthread_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 找到宿主机的库
易错点
- 以为同一个编译器就一定 ABI 兼容 — 不是,还要看 libc、编译选项、标准库版本
- 以为 API 兼容就能直接换 .so — 不是,API 兼容只保证源码能编译,不保证二进制能互换
- 以为 RPATH 和 RUNPATH 一样 — 不一样,优先级不同
- 以为 ldd 显示的就是运行时实际加载的 — 不一定,LD_LIBRARY_PATH 可以覆盖
- 以为静态链接就没有 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。