现代 C++ 跨平台开发 - 二进制篇:二进制格式 → 符号分析

从 12 年开始接触安卓 NDK 开发,到 17 年接触 iOS ObjC/C++ 混编,再到去年接触鸿蒙 NDK 开发和 WebAssembly 开发,也算是自诩有过较丰富的跨平台开发经验。

但直到最近适配 Windows 平台,才越来越意识到:搞定了 Windows,才有资格说自己做过真正的跨平台 – 因为它是唯一同时支持三大编译器(Clang/GCC/MSVC)的平台,编译环境可谓是最复杂的。

编译器的差异,再叠加各操作系统的差异,使得 C++ 二进制的跨平台分发变得复杂。

编译器与运行时

这里的“运行时”并不是 JVM/Dalvik/ART 那种虚拟机运行时,而是不同编译器的 C++ 标准库实现(ISO C++ 只制定标准,不严格限制具体实现)。

三大主流编译器

GCC

  • 标准库实现为 libstdc++;

  • 随编译器发布,静态/动态链接均可;

  • 更新编译器即可享受最新 C++ 标准 feature;

  • 应用需要带上标准库,可能存在兼容问题。

Clang

  • 标准库实现为 libc++;

  • Android/HarmonyOS:

    • 分发方式同 GCC;优势和劣势也类似;
  • Apple:

    • 随系统发布;

    • 所有应用共用,不用担心兼容问题;

    • 如果使用 C++ 新特性,不光代码要通过 feature 宏判断,编译时还要判断 target 系统版本;

MSVC

  • 运行时包含:系统自带的 UCRT(Universal C RunTime) + 各个版本对应的 vcruntime 和 msvcp 的 dll;

  • 要么静态链接,要么安装各版本 dll(Visual C++ Redistributable);

GCC for Windows

MinGW

MinGW 即 Minimalist GNU for Windows:

  • 将 GCC 等 GNU 工具移植到 Windows,直接调用 Win32 API,生成原生 Windows PE 二进制程序;

  • 无需额外运行时,效率高;

Cygwin

Cygwin 即 Cygnus + GNU + Windows:

  • 基于 Win32 API 在用户态模拟 Unix 行为,相当于增加了一个兼容层;

  • 运行时依赖 cygwin1.dll,效率低;

  • 支持完整的 POSIX API,适合需要移植 Linux/Unix 程序到 Windows 的场景;

Clang for Windows

Clang-CL

  • Clang-CL 本质是 Windows 平台的 LLVM 驱动 - 模拟 MSVC 编译器前端(cl.exe)的行为;

  • 默认链接 MSVC 的运行时库,支持 MSVC 编译参数;

  • 跨平台首选:既能享受 Clang 的优势,又能调用 Windows API。

二进制格式

编译产物格式

ELF

即 Executable and Linkable Format,是 Linux/Android/HarmonyOS/WebAssembly 的二进制格式;

ELF Header
├── e_ident (魔数 + 标识)
│   ├── EI_MAG0-EI_MAG3 (魔数 "ELF")
│   ├── EI_CLASS (类:32/64 位)
│   ├── EI_DATA (数据编码:小端/大端)
│   ├── EI_VERSION (版本)
│   ├── EI_OSABI (OS/ABI 标识)
│   └── EI_ABIVERSION (ABI 版本)
├── e_type (文件类型:ET_REL, ET_EXEC, ET_DYN)
├── e_machine (架构:EM_X86_64, EM_AARCH64)
├── e_version (版本)
├── e_entry (程序入口地址)
├── e_phoff (Program Header 表偏移)
├── e_shoff (Section Header 表偏移)
├── e_flags (处理器特定标志)
├── e_ehsize (ELF 头大小)
├── e_phentsize (每个 Program Header 大小)
├── e_phnum (Program Header 数量)
├── e_shentsize (每个 Section Header 大小)
├── e_shnum (Section Header 数量)
└── e_shstrndx (包含段名的字符串表索引)

Program Headers (仅可执行文件/共享库有)
├── p_type (段类型:PT_LOAD, PT_DYNAMIC 等)
├── p_offset (段在文件中的偏移)
├── p_vaddr (段在内存中的虚拟地址)
├── p_paddr (物理地址,通常为 0)
├── p_filesz (段在文件中的大小)
├── p_memsz (段在内存中的大小)
├── p_flags (权限标志:PF_R, PF_W, PF_X)
└── p_align (对齐要求)

Section Headers (描述每个段:.text, .data, .symtab 等)
├── sh_name (段名称偏移)
├── sh_type (段类型:SHT_PROGBITS, SHT_SYMTAB 等)
├── sh_flags (段标志:SHF_ALLOC, SHF_EXECINSTR 等)
├── sh_addr (段在内存中的地址)
├── sh_offset (段在文件中的偏移)
├── sh_size (段大小)
├── sh_link (关联节索引)
├── sh_info (额外信息)
├── sh_addralign (对齐要求)
└── sh_entsize (条目大小)

Sections (实际代码/数据)
├── .text (代码段)                                                  // 包含 landing pad(异常处理入口桩代码)
├── .data (已初始化数据段)
├── .bss (未初始化数据段)
├── .rodata (只读数据段)
├── .symtab (静态符号表)                                            // 静态符号表,可被 strip
├── .strtab (符号名称字符串表)
├── .shstrtab (段名称字符串表)
├── .eh_frame                                                       // DWARF 栈展开信息(SHT_PROGBITS, SHF_ALLOC);含 CIE/FDE;FDE 可含 LSDA 指针
└── .gcc_except_table                                               // C++ 异常语义表(LSDA);含 Type Table / Action Table / Call Site Table

【动态库专有】
├── .dynsym (动态符号表)                                            // 动态链接器使用的符号表(不可 strip)
├── .dynstr (动态符号名称字符串表)                                   // 与 .dynsym 配套
├── .hash 或 .gnu.hash (动态符号哈希表)                              // 加速符号查找
└── .dynamic (动态链接信息段)                                        // 包含 DT_SYMTAB、DT_STRTAB 等条目

【动态库专有】
Symbol Table (静态符号表,对应 .symtab)
├── st_name (符号名称偏移)
├── st_value (符号值/地址)
├── st_size (符号大小)
├── st_info (符号类型和绑定)
├── st_other (保留字段)
└── st_shndx (符号所在段索引)

【动态库专有】
Dynamic Symbol Table (对应 .dynsym)
├── st_name (在 .dynstr 中的偏移)
├── st_value (运行时地址或偏移)
├── st_size (符号大小)
├── st_info (绑定:STB_GLOBAL/WEAK;类型:STT_FUNC/OBJECT)
├── st_other (可见性等)
└── st_shndx (所在段索引,常为 SHN_UNDEF 表示运行时解析)

【动态库专有】
.dynamic 段关键条目(由 PT_DYNAMIC 指向)
├── DT_SYMTAB → 指向 .dynsym 的内存地址
├── DT_STRTAB → 指向 .dynstr 的内存地址
├── DT_HASH / DT_GNU_HASH → 哈希表地址
├── DT_SONAME → 共享库名称
├── DT_NEEDED → 依赖的其他共享库                                 // 可能包含 libgcc_s.so.1、libstdc++.so.6、libc++.so.1
└── ...(其他动态链接元数据)

【动态链接核心机制】
├── .plt (Procedure Linkage Table)
│   ├── 类型: SHT_PROGBITS, 标志: SHF_ALLOC | SHF_EXECINSTR
│   ├── 内容: 汇编指令 (跳板代码/Stubs)
│   ├── .plt[0]: 通用解析入口 (跳转至 _dl_runtime_resolve)
│   └── .plt[n]: 特定外部函数的桩代码 (首次调用触发解析)
│
├── .got (Global Offset Table)
│   ├── 类型: SHT_PROGBITS, 标志: SHF_ALLOC | SHF_WRITE
│   ├── 内容: 地址指针 (数据)
│   ├── .got (前部): 全局变量地址 (通常启动时解析)
│   └── .got.plt (后部): 外部函数地址 (支持延迟绑定/Lazy Binding)
│       ├── [0-2]: 动态链接器元数据 (.dynamic 指针等)
│       └── [3..n]: 外部函数真实地址 (初始为桩地址,解析后更新)
│
├── .rel.plt / .rela.plt (PLT 重定位表)
│   ├── 作用: 映射 ".got.plt 条目" <-> ".dynsym 符号"
│   ├── 结构: { r_offset (GOT 地址), r_info (符号索引 + 类型) }
│   └── 重要性: PLT Hook (如 ByteHook) 的核心依据
│
└── .rel.dyn / .rela.dyn (数据重定位表)
    └── 作用: 映射 ".got 条目" <-> 全局变量符号

// 全局补充说明:
// - .eh_frame 和 .gcc_except_table 必须加载到内存(SHF_ALLOC),否则异常行为异常
// - 异常运行时依赖符号如 __cxa_throw、__gxx_personality_v0 等,通常来自 libsupc++(GCC)或 libc++abi(Clang)
// - landing pad 是编译器生成的 catch 入口代码,位于 .text 中,由 .gcc_except_table 的 call site 表引用
二进制类型
  • .o:编译器生成的可重定位编译产物;

  • .a:静态库,本质为 .o 归档;

  • .so:动态库;

  • 可执行文件无扩展名;

符号表
组件 比喻 内容 是否可被 Strip 作用阶段
.symtab 完整词典 所有符号(函数/变量)的名字、类型、所在段索引。包含内部静态函数。 是 (发布时通常被删掉以减小体积) 调试、静态分析、链接静态库时
.dynsym 公共电话簿 仅包含 动态链接需要的符号(导出给别人的,或需要从别人那里导入的)。 否 (删了程序无法运行) 运行时 (动态链接器 ld.so / dyld 使用)
.got 速查通讯录 存储解析后的真实内存地址。初始可能是桩地址。 否 运行时 (CPU 直接读取这里的地址进行跳转)
.plt 接线员/前台 一段代码,负责检查 GOT 里有没有地址。如果没有,就叫动态链接器去查字典。 否 运行时 (作为调用入口)
.rel.plt 连接说明书 关键桥梁! 告诉链接器:.got.plt 的第 N 个格子,对应 .dynsym 的第 M 号符号。 否 (通常在解析完成后不再需要,但加载时必须存在) 加载/初始化阶段

Mach-O

即 Mach Object file format,是 Apple 生态系统专属的二进制格式;

Mach-O Header
├── magic (魔数)
│   ├── MH_MAGIC_64 (0xfeedfacf) 小端
│   └── MH_CIGAM_64 (0xcffaedfe) 大端
├── cputype (CPU 类型:CPU_TYPE_X86_64, CPU_TYPE_ARM64)
├── cpusubtype (CPU 子类型:如 ARM64E)
├── filetype (文件类型:MH_OBJECT, MH_EXECUTE, MH_DYLIB, MH_BUNDLE)
│   ├── MH_OBJECT → .o 文件
│   ├── MH_DYLIB  → 【动态库专有】.dylib
│   └── MH_ARCHIVE → 【静态库专有】实际是 archive 格式(见下)
├── ncmds (Load Commands 数量)
├── sizeofcmds (Load Commands 总大小)
├── flags (标志:如 MH_PIE, MH_NOUNDEFS)
└── reserved (仅 64 位有,保留)

【静态库专有】
├── Archive Header (若 filetype == MH_ARCHIVE)
│   ├── "!<arch>\n" 魔数
│   ├── 成员文件列表(每个成员是一个 Mach-O object 或 table of contents)
│   └── 可选:__.SYMDEF 或 __.SYMDEF SORTED(符号索引表,供链接器快速查找)

Load Commands (描述段和其他元数据)
├── cmd (命令类型:LC_SEGMENT_64, LC_SYMTAB 等)
├── cmdsize (命令大小)
└── 具体命令数据

【动态库专有】
├── LC_ID_DYLIB        → 声明本 dylib 的 install name(即 SONAME)
├── LC_LOAD_DYLIB      → 声明依赖的其他 dylib
├── LC_DYLD_INFO_ONLY  → 包含绑定、懒绑定、导出信息(现代 dyld 使用)
├── LC_SYMTAB          → 指向 Symbol Table 和 String Table
└── LC_DYSYMTAB        → 【关键】标记哪些符号是 **导出符号(external)**、**未定义符号(undefined)**

Segment Commands (描述每个段:__TEXT, __DATA 等)
├── segname (段名称)
├── vmaddr (虚拟地址)
├── vmsize (虚拟大小)
├── fileoff (文件偏移)
├── filesize (文件大小)
├── maxprot (最大保护级别)
├── initprot (初始保护级别)
├── nsects (节数量)
└── flags (段标志)

Sections (实际代码/数据)
├── sectname (节名称)
├── segname (所属段名称)
├── addr (节地址)
├── size (节大小)
├── offset (节在文件中的偏移)
├── align (对齐要求)
├── reloff (重定位表偏移)
├── nreloc (重定位条目数)
├── flags (节标志)
├── reserved1 (保留字段)
├── reserved2 (保留字段)
└── reserved3 (保留字段)
    // 【异常相关】典型节包括:
    //   __TEXT,__text                → 包含 landing pad(异常处理入口桩)
    //   __TEXT,__eh_frame            → DWARF 栈展开信息(等价于 ELF 的 .eh_frame)
    //   __TEXT,__gcc_except_tab      → C++ 异常语义表 LSDA(等价于 ELF 的 .gcc_except_table)

Symbol Table (符号表)
├── nlist (符号记录)
│   ├── n_strx (符号名称偏移)
│   ├── n_type (符号类型)
│   │   ├── N_UNDF (0x0) → 未定义(导入)
│   │   ├── N_ABS  (0x2) → 绝对符号
│   │   ├── N_SECT (0xe) → 定义在某节中
│   │   └── 标志位:N_EXT = 全局/外部符号(导出或导入)
│   ├── n_sect (符号所在节索引)
│   ├── n_desc (描述符)
│   │   ├── REFERENCE_FLAG → 引用类型
│   │   └── N_WEAK_DEF / N_WEAK_REF → 弱符号
│   └── n_value (符号值)
└── string table (符号名称字符串表)
    // 【异常相关】关键运行时符号(通常为 N_UNDF + N_EXT):
    //   ___cxa_throw
    //   ___cxa_begin_catch
    //   ___gxx_personality_v0
    //   ___clang_call_terminate

【动态库专有】
Export Trie (导出符号 Trie 树)
└── 存储在 __LINKEDIT 段中,由 LC_DYLD_INFO 的 export_off 指向
    → 替代传统符号表,用于快速符号查找(iOS/watchOS 必用,macOS 推荐)
    // 【异常相关】若 dylib 提供异常 runtime(如 libc++abi.dylib),
    //     则 Export Trie 中包含 ___cxa_*、___gxx_personality_v0 等符号

【静态库专有】
__.SYMDEF (Archive Symbol Table)
└── 位于静态库(.a)开头或紧随魔数后
    → 包含所有成员 .o 中的全局符号名 + 所在成员偏移
    → 链接器用它避免遍历整个 archive
    // 【异常相关】若静态库含异常代码(如 libsupc++.a),
    //     __.SYMDEF 会包含 ___cxa_throw 等符号索引

动态链接核心机制】
├── __TEXT,__stubs (Procedure Stubs - 等价于 .plt)
│   ├── 类型: S_SYMBOL_STUBS
│   ├── 内容: 汇编指令 (arm64 每函数 12 字节: adrp + ldr + br;x86_64 是 6 字节 jmp)
│   ├── 逻辑: 读取 __la_symbol_ptr 中的地址并跳转
│   └── 初始状态: 指向该符号的 stub helper(由 helper 再去跳 dyld_stub_binder)
│
├── __DATA,__la_symbol_ptr (Lazy Symbol Pointers - 等价于 .got.plt)
│   ├── 类型: S_LAZY_SYMBOL_POINTERS
│   ├── 内容: 地址指针数组
│   ├── 初始值: 指向该符号的 stub helper(x86_64 上这段 glue 叫 dyld_stub_binding_helper)
│   ├── 运行时: 首次调用后变为真实函数地址
│   └── Hook 目标: FishHook / Frida 这类工具常改此处(改指针即改调用目标,无需动代码段)
│
├── __DATA,__nl_symbol_ptr (Non-Lazy Symbol Pointers - 等价于 .got)
│   ├── 类型: S_NON_LAZY_SYMBOL_POINTERS
│   ├── 内容: 地址指针数组
│   └── 行为: 启动时由 dyld 立即解析填充 (用于全局变量等)
│
└── __LINKEDIT + LC_DYLD_INFO (绑定与重定位元数据 - 等价于 .rel.plt + .dynsym 部分功能)
    ├── 位置: 独立的 __LINKEDIT 段 (原始数据)
    ├── Binding Table: 描述如何将符号绑定到 __la/__nl_symbol_ptr
    ├── Lazy Binding Table: 专门描述延迟绑定的细节
    ├── Export Trie: 导出符号的压缩树结构 (替代部分 symtab)
    └── 特点: 无传统 .rel 节区,所有重定位信息压缩在此,由 dyld 解析

【异常相关 —— Mach-O 特有节命名规则】
├── __TEXT,__eh_frame              // 等价于 ELF 的 .eh_frame;S_REGULAR | S_ATTR_LIVE_SUPPORT
│   ├── CIE / FDE 结构与 ELF 相同
│   └── FDE augmentation 可含 LSDA 指针(指向 __gcc_except_tab)
│
└── __TEXT,__gcc_except_tab        // 等价于 ELF 的 .gcc_except_table;LSDA 内容结构完全一致
    ├── LPStart encoding + value
    ├── TType encoding + base
    ├── Call Site Table
    ├── Action Table(ttype_index + next_action_offset 链)
    └── Type Table(type_info* 数组)

【异常相关 —— 运行时依赖】
├── 动态依赖(通过 LC_LOAD_DYLIB):
│   ├── /usr/lib/libc++.1.dylib        → Clang + libc++
│   ├── /usr/lib/libstdc++.6.dylib     → GCC(macOS 已弃用)
│   └── /usr/lib/libSystem.B.dylib     → 间接依赖 libunwind / libcppabi
│
└── 关键符号前缀:
    ├── Mach-O C++ 符号使用 **三下划线前缀**(如 ___cxa_throw)
    └── 与 ELF 的双下划线(__cxa_throw)不同,因 Mach-O 不允许以单下划线开头的全局符号

// 全局补充说明:
// - Mach-O 的 __eh_frame 和 __gcc_except_tab 通常位于 __TEXT 段(可执行但只读)
// - dyld 加载时会解析 __eh_frame 并注册到 unwind runtime(libunwind 或 compact unwind)
// - Apple 平台还支持 **compact unwind info**(通过 __LD,__compact_unwind 节),作为 .eh_frame 的优化替代;
//   但若函数含 C++ 异常(landing pad),仍需生成完整的 __eh_frame + __gcc_except_tab
二进制类型
  • .o:编译器生成的可重定位编译产物;

  • .a:静态库,本质为 .o 归档;

  • .dylib:动态库;

  • 可执行文件无扩展名;

iOS 不允许通过 dlopen 动态加载,也不允许分发标准的 .dylib 动态库,但分发 .framework 可设置 Mach-O Type 为动态库;
此时内部的二进制同样是无扩展名,运行时由系统的 dyld 自动根据 Mach-O 的 load commands 加载。
通过 file 或 otool 命令可查看二进制类型。

file sqlite3.framework/sqlite3 
sqlite3.framework/sqlite3: Mach-O 64-bit dynamically linked shared library arm64

otool -hv sqlite3.framework/sqlite3 
sqlite3.framework/sqlite3:
Mach header
      magic  cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
MH_MAGIC_64    ARM64        ALL  0x00       DYLIB    17       1512   NOUNDEFS DYLDLINK TWOLEVEL NO_REEXPORTED_DYLIBS
符号表
  • Mach-O 没有静/动态符号表分离的概念,所有符号都在一个 nlist 表中;

  • 是否导出由 n_type & N_EXT 和 LC_DYSYMTAB / Export Trie 决定;

PE/COFF

即 Common Object File Format,原本用于 Unix System V;后 Windows 扩展为 PE(Portable Executable):

PE = COFF + Windows 特定头(DOS Header, NT Headers, Section Table 等)

COFF File Header
├── Machine (架构标识)
├── NumberOfSections (段数量)
├── TimeDateStamp (时间戳)
├── PointerToSymbolTable (符号表偏移)
├── NumberOfSymbols (符号数量)
├── SizeOfOptionalHeader (可选头大小,.obj 中为 0;.exe/.dll 中 > 0)
└── Characteristics (文件属性)
    ├── IMAGE_FILE_RELOCS_STRIPPED
    ├── IMAGE_FILE_EXECUTABLE_IMAGE → 可执行文件
    └── IMAGE_FILE_DLL             → 【动态库专有】标记为 DLL

【静态库专有】
├── Archive Format (若为 .lib 静态库)
│   ├── "!<arch>\n" 魔数
│   ├── /       → 符号索引表(First Linker Member)
│   ├── //      → 长文件名表(Second Linker Member)
│   └── *.obj   → 成员目标文件(COFF 格式)

Optional Header (仅 PE 可执行文件有,.obj 没有)
├── Magic (PE 标识)
├── ...(原有字段省略)
├── Data Directory (16 项,关键!)
│   ├── [0] Export Table        → 【动态库专有】指向导出函数表
│   ├── [1] Import Table        → 导入表
│   ├── [6] Base Relocation Table
│   └── [13] CLR Runtime Header (托管代码)
└── ...(其余字段)

【动态库专有】
Export Table (由 Optional Header.DataDirectory[0] 指向)
├── Export Flags
├── Time/Version
├── Name RVA → 指向 DLL 名称(如 "mylib.dll")
├── Ordinal Base
├── Number of Functions
├── Number of Names
├── Address of Functions → 函数 RVA 数组
├── Address of Names     → 函数名 RVA 数组
└── Address of Name Ordinals → 序号数组(名字 ↔ 序号映射)

Section Headers (描述每个段:.text, .data, .rdata 等)
├── Name (段名称)
├── Misc (物理大小或虚拟大小)
├── VirtualAddress (虚拟地址)
├── SizeOfRawData (原始数据大小)
├── PointerToRawData (原始数据偏移)
├── PointerToRelocations (重定位表偏移)
├── PointerToLinenumbers (行号表偏移)
├── NumberOfRelocations (重定位条目数)
├── NumberOfLinenumbers (行号条目数)
└── Characteristics (段属性)

Sections (实际代码/数据)
├── .text (代码段)
├── .data (已初始化数据段)
├── .rdata (只读数据段)
├── .edata → 【动态库专有】旧版导出表(现代工具通常不用)
├── .idata → 导入表
└── 其他自定义段

Symbol Table (符号表)
├── Symbol Records (符号记录)
│   ├── Name / Offset to String Table
│   ├── Value (地址或 section offset)
│   ├── Section Number
│   ├── Type (函数/对象等)
│   └── Storage Class (extern, static, weak 等)
└── String Table (符号名称存储区)

【动态库专有】
Import Library (.lib for DLL)
└── 不包含代码,仅包含:
    ├── 导出符号的 stub 记录
    ├── 指向对应 .dll 的引用
    └── 链接时供 linker 解析 __imp_ 前缀符号

【动态链接核心机制】
├── .idata (Import Data Section)
│   ├── Import Directory Table (IMAGE_IMPORT_DESCRIPTOR 数组)
│   │   ├── 每个条目对应一个依赖 DLL (如 kernel32.dll)
│   │   ├── OriginalFirstThunk → 指向 ILT (只读,存函数名 RVA)
│   │   └── FirstThunk → 指向 IAT (可写,存真实地址)
│   │
│   ├── Import Lookup Table (ILT)
│   │   ├── 内容: IMAGE_IMPORT_BY_NAME (Hint + "FunctionName")
│   │   └── 作用: 加载器的“查找依据”,运行时不变
│   │
│   ├── Import Address Table (IAT) ★ Hook 核心目标
│   │   ├── 初始: 同 ILT (存函数名 RVA)
│   │   ├── 加载后: 被 OS 替换为真实函数内存地址
│   │   ├── 性质: 相当于 ELF 的 .got.plt / Mach-O 的 __la_symbol_ptr
│   │   └── 调用方式: call [IAT_Entry] (间接调用)
│   │
│   └── Import Thunks (桩代码)
│       ├── 位置: .idata 尾部或 .text 附近
│       ├── 代码: jmp [IAT_Entry]
│       ├── 作用: 编译器生成的 call 目标,负责间接跳转
│       └── 区别: 无自修复逻辑 (非延迟加载情况下)
│
└── .didat (Delay Import Data Section - 可选)
    ├── 由 DataDirectory[13] 指向
    ├── 机制: IAT 初始指向 __delayLoadHelper2
    ├── 行为: 首次调用触发解析并修补 IAT (类似 Lazy Binding)
    └── 作用: 优化启动速度,按需加载 DLL
二进制类型
  • .obj:编译器生成的可重定位编译产物;

  • .lib:静态库,本质为 .obj 归档;

  • .dll:动态库;

  • .exe:可执行文件;

符号表
  • 动态库(.dll) 的导出符号不依赖 COFF 符号表,而是通过 Export Table 提供;

  • 发布时只会剥离 COFF 的符号表,不影响动态库。

调试符号格式

DWARF

即 Debug With Arbitrary Record Formats,调试符号格式行业标准,被 GCC/GDB、Clang/LLDB 广泛支持:

DWARF
├── Compilation Unit Header
│   ├── Length of Compilation Unit Info (total length)
│   ├── DWARF Version (number)
│   ├── Offset into .debug_abbrev section
│   └── Address Size
├── Debugging Information Entries (DIEs)
│   ├── Tag (e.g., DW_TAG_subprogram, DW_TAG_variable)
│   ├── Attribute Name and Value Pairs
│   │   ├── Attribute name (defines what the attribute represents)
│   │   └── Attribute value (the actual value for that attribute)
│   └── Child DIEs (if any)
├── .debug_info
│   └── Contains the debugging information entries.
├── .debug_abbrev
│   └── Contains encoding descriptions for the debugging information entries.
├── .debug_str
│   └── Contains strings referenced from the debugging information entries.
├── .debug_line
│   └── Maps instruction addresses to source file lines.
├── .debug_loc
│   └── Location lists for describing objects that have varying locations.
├── .debug_ranges
│   └── Non-contiguous address ranges for variables or other entities.
├── .debug_frame
│   └── Call frame information for unwinding stack during debugging.
└── Other sections (.debug_macinfo, .debug_pubnames, etc.)
    └── Additional debugging support data.
  • 对于 ELF:

    • DWARF 符号都位于顶级 section,名字以 .debug_ 开头(.debug_info、.debug_line、.debug_abbrev);

    • 默认内嵌到编译产物,发布阶段需使用相关工具手动分离到 .debug 文件;

  • 对于 Mach-O:

    • DWARF 符号被划入单独的 __DWARF 段,section 命名类似,只是前缀不是 .debug_ 而是 __debug_;

    • XCode 发布阶段自动将调试符号分离到 .dSYM 文件;

    • Apple 对 DWARF 做了大量扩展,可参考 Swift 生成调试信息的实现代码。

CodeView

微软针对 PE/COFF 设计的调试符号格式,早于 DWARF。

CodeView Debug Information
├── Type Records (类型记录)
│   ├── Basic Types (基本类型, 如 int, char)
│   ├── Derived Types (衍生类型, 如 pointers, arrays)
│   └── User-Defined Types (用户自定义类型, 如 structs, classes)
├── Symbol Records (符号记录)
│   ├── Global Symbols (全局符号)
│   ├── Local Symbols (局部符号)
│   └── Function Symbols (函数符号)
└── Line Number Information (行号信息)
    ├── Source File Names (源文件名)
    └── Line Numbers Mapping (行号映射)
  • 编译器将调试信息以 CodeView 格式 写入 .obj 文件的特定段;

  • 链接器读取这些段,并聚合生成独立的 .pdb 文件;

  • 最终的 .exe/.dll 中只保留一个指向 .pdb 的路径和 GUID,不包含实际调试数据。

PDB

PDB(Program Database)是微软专有的二进制数据库格式,最初是为解决 CodeView 嵌入目标文件导致链接慢、符号重复等问题:

  • 存储:

    • 符号表(函数、变量名);

    • 类型信息(结构体、类布局);

    • 源码行号映射(类似 DWARF 的 .debug_line);

    • 局部变量位置(寄存器/栈偏移);

  • 支持高效随机访问(通过 GUID 快速加载符号);

  • 支持事务和回滚;

  • 支持增量链接。

PDB File
├── Stream Directory (流目录)
│   ├── Stream 1: Symbol Records (函数/变量符号)
│   ├── Stream 2: Type Records (结构体/类定义)
│   ├── Stream 3: Line Number Info
│   └── Stream 4: Module Info
├── Pages (固定大小块,类似数据库页)
└── Root Stream (描述其他流的位置)

CMake 在检测到 clang-cl 作为编译器且目标平台为 Windows 时,会自动启用 CodeView 调试信息(即隐式添加 -gcodeview),前提是启用了调试信息(如使用 Debug 构建类型或显式设置 CMAKE_BUILD_TYPE=Debug)。

符号可见性

何为符号可见性

编译单元可见性

普通 C 函数默认是全局可见的,而 static 函数仅对当前源文件可见;

C++ 新增的匿名 namespace 也能实现仅对当前编译单元可见。

动态符号表可见性

我们后面即将讨论的“符号可见性”,是针对动态链接的,即能否在运行时解析该符号。

对动态链接器不可见,即不存在于动态符号表,仅影响外部动态调用,并不影响内部调用。

那设置符号可见性的意义何在?

  • 接口最小化:只暴露必要 API,避免其他模块误用内部函数;

  • 避免符号冲突:防止不同库中的同名符号互相覆盖;

  • 安全加固:逆向者无法通过普通工具发现内部函数(发布版本通常会 strip);

  • 减小体积:.dynsym 比 .symtab 更小,启动更快、内存占用更低;

  • 兼容性保障:内部实现可随意修改,不影响 ABI;

如何设置符号可见性

GCC/Clang

// 导出
__attribute__((visibility("default"))) void api_func();

// 隐藏
__attribute__((visibility("hidden"))) void internal_func();
  • 常配合 -fvisibility=hidden 编译选项使用(即默认隐藏);

  • 对于 Mach-O,虽然没有 .dynsym 概念,但符号可见性会影响:

    • 是否出现在 导出符号表(Export Trie / LC_DYSYMTAB);

    • 是否被 nm -g 显示;

MSVC

MSVC 没有“全局默认隐藏”选项,必须显式标记 dllexport 才导出:

// dll 中导出
__declspec(dllexport) void api_func();

// exe 或其他 dll 中导入(可选)
__declspec(dllimport) void api_func(); // 提升性能(非必需)
  • 所有 __declspec(dllexport) 的函数/变量,进入 Export Table;

  • 其他符号,默认隐藏(即使全局);

  • MinGW 虽同时兼容两种设置符号可见性的方法,但导出符号仍位于 Export Table;

其他影响符号可见性的场景

JNI 动态注册

  • 静态注册:

    • 依赖 linker 在加载时自动解析 Java_... 符号;

    • 必须导出为全局符号,否则 UnsatisfiedLinkError;

  • 动态注册:

    • 由 JNI_OnLoad 主动注册函数指针,不依赖字符串查找;

    • 函数不需要全局,只需要位于同一编译单元即可,可以为 static;

    • 符号不会出现在 .dynsym,可安全 strip。

静态库 vs 动态库

我们知道 Android NDK 和鸿蒙 NAPI 对外发布的 C/C++ 库都是动态库(.so),但很多时候我们需要进行平台层二次封装,涉及到二进制中间分发,这种场景可能更适合静态库。

我们首先梳理下三种二进制格式对应的动态库加载流程,充分认识动态库对冷启动性能的影响,再综合对比下静态库/动态库的特性。

动态库加载流程

ELF

flowchart LR
  subgraph S1["定位与打开"]
    direction TB
    a1["dlopen"] --> a2["查找路径解析"]
    a2 --> a3["LD_LIBRARY_PATH"]
    a3 --> a4["/etc/ld.so.conf.d/"]
    a4 --> a5["默认路径 /lib、/usr/lib"]
    a5 --> a6["ELF 头中的 RPATH / RUNPATH"]
    a6 --> a7["打开动态库文件"]
    a7 --> a8["读取 ELF 头"]
  end
  subgraph S2["读头与映射"]
    direction TB
    b1["Program Header"] --> b2["Section Header"]
    b2 --> b3["校验 PT_LOAD 的 p_align"]
    b3 --> b4["映射段到内存"]
    b4 --> b5["PT_LOAD"]
    b5 --> b6["PT_DYNAMIC"]
    b6 --> b7["处理动态段"]
  end
  subgraph S3["依赖、绑定与初始化"]
    direction TB
    c1["解析依赖库"] --> c2["加载依赖库"]
    c2 --> c3["重定位符号"]
    c3 --> c4["绑定符号"]
    c4 --> c5["初始化全局变量"]
    c5 --> c6["调用 _init"]
    c6 --> c7["构造函数"]
    c7 --> c8["初始化代码"]
    c8 --> c9["返回句柄"]
  end
  S1 --> S2 --> S3

ELF 动态库加载过程中,通过 ld.so 提供基本的安全性保障。Android Bionic 在查重、namespace 搜索和 APK 内加载上的差异,见后文《动态库加载 API》的「加载器实现细节」。

Mach-O

dyld2
flowchart LR
  subgraph D1["定位与打开"]
    direction TB
    a1["dlopen"] --> a2["查找路径解析"]
    a2 --> a3["DYLD_LIBRARY_PATH"]
    a3 --> a4["Mach-O 头中的 @rpath"]
    a4 --> a5["/usr/lib、/System/Library/Frameworks"]
    a5 --> a6["Framework 搜索路径"]
    a6 --> a7["打开动态库文件"]
    a7 --> a8["读取 Mach-O 头"]
    a8 --> a9["Load Commands"]
  end
  subgraph D2["映射与依赖"]
    direction TB
    b1["LC_SEGMENT_64 段"] --> b2["映射段到内存"]
    b2 --> b3["__TEXT"]
    b3 --> b4["__DATA"]
    b4 --> b5["__LINKEDIT"]
    b5 --> b6["处理 LC_LOAD_DYLIB"]
    b6 --> b7["解析依赖库"]
    b7 --> b8["加载依赖库"]
    b8 --> b9["绑定符号"]
  end
  subgraph D3["绑定与初始化"]
    direction TB
    c1["Rebase 指针"] --> c2["应用 dyld 绑定"]
    c2 --> c3["延迟绑定"]
    c3 --> c4["弱绑定"]
    c4 --> c5["扁平命名空间绑定"]
    c5 --> c6["调用初始化器"]
    c6 --> c7["ObjC +load"]
    c7 --> c8["C++ 构造函数"]
    c8 --> c9["其他初始化代码"]
    c9 --> c10["返回句柄"]
  end
  D1 --> D2 --> D3
dyld3

dyld3 主要是安全性和加载性能方面的优化。

flowchart LR
  subgraph E1["预检与打开"]
    direction TB
    a1["dlopen"] --> a2["预检"]
    a2 --> a3["校验代码签名"]
    a3 --> a4["检查 Entitlements"]
    a4 --> a5["验证库完整性"]
    a5 --> a6["查找路径解析(同 dyld2)"]
    a6 --> a7["打开动态库文件"]
    a7 --> a8["读取 Mach-O 头"]
    a8 --> a9["Load Commands"]
    a9 --> a10["LC_SEGMENT_64 段"]
    a10 --> a11["映射段到内存"]
  end
  subgraph E2["映射与依赖"]
    direction TB
    b1["__TEXT"] --> b2["__DATA"]
    b2 --> b3["__LINKEDIT"]
    b3 --> b4["处理 LC_LOAD_DYLIB"]
    b4 --> b5["解析依赖库"]
    b5 --> b6["加载依赖库"]
    b6 --> b7["绑定符号"]
    b7 --> b8["Rebase 指针"]
    b8 --> b9["应用 dyld 绑定"]
    b9 --> b10["优化绑定"]
  end
  subgraph E3["绑定、初始化与后置"]
    direction TB
    c1["预绑定"] --> c2["常见库的快速路径"]
    c2 --> c3["调用初始化器"]
    c3 --> c4["ObjC +load"]
    c4 --> c5["C++ 构造函数"]
    c5 --> c6["其他初始化代码"]
    c6 --> c7["后初始化"]
    c7 --> c8["运行时检查"]
    c8 --> c9["安全增强"]
    c9 --> c10["性能监控"]
    c10 --> c11["返回句柄"]
  end
  E1 --> E2 --> E3

PE/COFF

PE/COFF 动态库加载过程中的安全性校验较少。

flowchart LR
  subgraph P1["定位与打开"]
    direction TB
    a1["LoadLibrary"] --> a2["查找路径解析"]
    a2 --> a3["当前目录"]
    a3 --> a4["应用目录"]
    a4 --> a5["System32 等系统目录"]
    a5 --> a6["PATH 环境变量"]
    a6 --> a7["Known DLLs"]
    a7 --> a8["打开动态库文件"]
  end
  subgraph P2["映射与导入"]
    direction TB
    b1["映射文件到内存"] --> b2["PE Header"]
    b2 --> b3[".text / .data 等节"]
    b3 --> b4["处理导入表"]
    b4 --> b5["解析导入符号"]
    b5 --> b6["加载依赖库"]
    b6 --> b7["绑定符号"]
    b7 --> b8["修正地址"]
  end
  subgraph P3["初始化与返回"]
    direction TB
    c1["初始化数据段"] --> c2["调用 DllMain"]
    c2 --> c3["DLL_PROCESS_ATTACH"]
    c3 --> c4["DLL_THREAD_ATTACH"]
    c4 --> c5["DLL_THREAD_DETACH"]
    c5 --> c6["返回句柄"]
  end
  P1 --> P2 --> P3

可以看到,三种二进制动态库加载流程虽有细节差异,但都包含三个核心步骤:动态查询依赖、内存映射、处理符号表,涉及到密集的 CPU 和 IO 操作,严重影响冷启动速度。

二进制页对齐

上一节流程里「映射段到内存」那一步不是随便切的:

  • 内核按页映射,段的位置得落在页边界上,这条约束写在文件头里:ELF 是 p_align,PE 是 SectionAlignment,Mach-O 是 segment 的 vmaddr / filesize 取整;

  • 加载器读头时会校验,段的对齐与库的文件偏移不达标就直接拒绝加载(报错点与源码见后文「加载器实现细节」)。

与 C/C++ 内存对齐的区别

两者名字一样、层级不同:一个是加载器约束,一个是编译器 / ABI 约束。

维度 C/C++ 内存对齐 二进制页对齐
作用对象 变量、结构体成员 段 / 节
决定者 编译器 / ABI 链接器 / 加载器
典型单位 1、2、4、8、16 字节 页:4 KB / 16 KB / 64 KB
目的 CPU 访问正确、避免非对齐访问 mmap 得进来、按页给权限

段对齐保证「段进得来」,对象对齐保证「进来之后访问得对」;段的加载地址没按页对齐,段内对象的对齐也就无从谈起。

C++ 侧的对齐规则与设置方式(自然对齐、alignas、pack)见 现代 C++ 跨平台开发 - 内存篇 的「结构体内存布局」。

Android 的 16 KB 页

Android 新设备把页大小从 4 KB 提到 16 KB,最直接的影响就是 .so 的段对齐:

环节 4 KB 时代 16 KB 要求
段对齐 p_align = 0x1000 p_align ≥ 0x4000;链接时加 -Wl,-z,max-page-size=16384,NDK r28+ 已默认
APK 内 so 4 KB ZIP 对齐 16 KB ZIP 对齐(zipalign -P 16),否则 APK 内 so 的 entry 偏移按页 mmap 会失败
代码里的页大小假设 硬编码 4096 改用 sysconf(_SC_PAGESIZE)(Android 上等价于 getpagesize())

各平台对照

平台 格式 对齐约束 什么时候会撞上
Android ELF PT_LOAD 的 p_align 16 KB 页设备加载 4 KB 时代的旧 so,直接失败
Linux ELF 同上;x86_64 有时按 2 MB 对齐以配合 THP 内核用大页,或 arm64 用 64 KB 页时,对齐与映射粒度都要重看
Apple Mach-O segment 天然按页对齐(x86_64 / 老 arm64 为 4 KB,Apple Silicon arm64 为 16 KB) 工具链默认处理好了;跨架构、跨 Xcode 版本分发 .a / .framework 时仍可能踩到更严的校验
Windows PE/COFF FileAlignment(通常 512 B)与 SectionAlignment(4 KB) SectionAlignment 必须匹配页大小,页变大(如 64 KB)就得跟着调
鸿蒙 ELF 目前仍是 0x1000 暂无矛盾,设备改用大页后同样会遇到

动态库加载 API

前面讲的是“加载器怎么加载”和它的对齐约束,这一节落到“代码怎么触发加载”:ELF/Mach-O 的 dlopen 家族,与 PE/COFF 的 LoadLibrary 家族。

加载与卸载

ELF / Mach-O
#include <dlfcn.h>

void *handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
void *sym = dlsym(handle, "symbol_name");
dlclose(handle);

handle 并不是不透明句柄,而是加载器内部的库描述结构:Bionic 为 soinfo*、glibc 为 link_map*。先看最常被引用的 Bionic soinfo(定义见 bionic/linker/linker_soinfo.h,下面只留关键字段):

struct soinfo {
    const char* name;        // 库名(SONAME 或路径)
    ElfW(Phdr)* phdr;        // Program Header 表
    size_t      phnum;
    ElfW(Addr)  base;        // 映射区起始地址
    size_t      size;        // 映射区大小
    ElfW(Addr)  load_bias;   // 实际基址 − 最小 p_vaddr
    ElfW(Dyn)*  dynamic;     // .dynamic 段
    ElfW(Sym)*  symtab;      // .dynsym
    const char* strtab;      // .dynstr
    dev_t       st_dev;      // 查重三元组
    ino_t       st_ino;
    off64_t     file_offset;
    uint32_t    refcount;    // 引用计数
    soinfo*     next;        // solist 链表
};
字段组 用处
phdr / phnum 重定位,以及 dladdr 判归属时的逐 PT_LOAD 段比对
base / size / load_bias 地址换算:符号地址 = load_bias + st_value
symtab / strtab dlsym 查符号表的依据
st_dev / st_ino / file_offset 查重:判断这个库是不是已经加载过
refcount / next 引用计数与 solist 链表,dlclose 据此卸载

dlopen 返回它、dlsym 拿它查表、dlclose 拿它减引用计数。

  • dlerror() 只在返回 NULL 后调用一次,调用本身会清空错误状态,不要在日志里反复取;

  • 与 dlsym 方向相反的反查接口是 dladdr,见后文。

第二个参数 mode 决定符号解析时机与可见性,语义如下:

标志 值 作用
RTLD_LOCAL 0 符号不导出给后续加载的库(默认值)
RTLD_GLOBAL 0x00100 符号进入全局表,后续加载的库可解析
RTLD_LAZY 0x00001 函数符号推迟到首次调用才解析;变量引用总是立即解析
RTLD_NOW 0x00002 返回前解析全部未定义符号,失败直接返回 NULL
RTLD_NOLOAD 0x00004 不实际加载,仅探测是否已加载、或把 LOCAL 提权为 GLOBAL
RTLD_NODELETE 0x01000 dlclose 不真正卸载,静态变量不再重新初始化

RTLD_NOW 失败快、首次调用无解析开销,代价是加载慢,且连用不到的符号也要解析;RTLD_LAZY 反之,错误只能推迟到运行时暴露。

  • 以 RTLD_LOCAL 加载的库,若被 RTLD_GLOBAL 加载的库依赖,会被自动提升为 GLOBAL,隔离失效;

  • 高版本 glibc 还提供 RTLD_DEEPBIND,优先在本库内查找符号,用于规避同名符号冲突。

Android

Android 只实现了 RTLD_LOCAL / RTLD_GLOBAL / RTLD_NOW;出于 RELRO 等加固需要,linker 不做延迟绑定,头文件里也直言 “Android always uses RTLD_NOW”。

  • System.loadLibrary() 内部即 dlopen(path, RTLD_NOW),且不带 RTLD_GLOBAL,所以 Java 层加载的 so 不会污染全局符号表;

  • RTLD_LAZY / RTLD_NOLOAD / RTLD_NODELETE 均未实现,传入要么非法要么退化为 RTLD_NOW,不要依赖;

  • 探测库是否已加载,改用 /proc/<pid>/maps;

  • 需要显式指定 namespace 或加载地址时,用扩展接口 android_dlopen_ext()。

鸿蒙

头文件中保留了 RTLD_LAZY 等定义,行为与 Android 不一致,跨平台 SDK 需实测确认;namespace 隔离规则见后文。

PE/COFF
HMODULE h = LoadLibraryExW(path, nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
FARPROC p = GetProcAddress(h, "symbol_name");
FreeLibrary(h);
  • 默认搜索顺序包含当前目录,是 DLL 劫持的经典入口,建议显式指定 LOAD_LIBRARY_SEARCH_* 或直接传绝对路径;

  • 错误码通过 GetLastError() 获取,与 dlerror() 不同,它不会自动清空;

  • 进程生命周期内通常不卸载:FreeLibrary 只在引用计数归零时才真正卸载,效果不确定。

符号查找与反查

ELF / Mach-O

dlsym 与 dladdr 是对同一张动态符号表(.dynsym + .dynstr)的正反向查询,都只读已映射到内存的动态段,不碰 .symtab,因此 static 与 visibility("hidden") 的符号天然查不到:

dlsym dladdr
方向 符号名 → 地址 地址 → 符号信息
依据 soinfo 缓存的符号表,DT_GNU_HASH 做 O(1) 查找 先 untag_address() 剥掉指针 tag(ARM64E 带指针认证),再在已加载库里逐 PT_LOAD 段判断命中
规则 名字精确匹配 地址 ≤ 目标且最接近的符号,非精确匹配
入参 库句柄(或 RTLD_DEFAULT/RTLD_NEXT) 任意地址,无需句柄

dladdr 最经典的用法是反查自身所在模块的路径:传入本 so 内任意函数的地址,dli_fname 即为该 so 的加载路径,取目录后就能拼出同目录其他 so 的绝对路径,从而不依赖 Java/JNI 完成自举。

Dl_info info;
dladdr((void*)get_self_dir, &info);         // 必须传本 so 内的地址;传 printf 只会得到 libc 的路径
std::string dir = dirname(info.dli_fname);  // 形如 /data/app/<pkg>/lib/arm64
dlopen((dir + "/libbar.so").c_str(), RTLD_NOW | RTLD_LOCAL);
  • 符号被隐藏时 dli_sname/dli_saddr 可能为 NULL,但 dli_fname/dli_fbase 仍有效;

  • dli_fbase 在部分 Android 版本上不准,需精确基址时用 GNU 扩展 dladdr1(..., RTLD_DL_LINKMAP) 取 link_map->l_addr;

  • 兜底可解析 /proc/self/maps,按地址区间匹配自身所在行;

  • 判归属走的是 find_containing_library():逐段比对而不是只比 base / size 范围,所以落在段间隙的地址不会误判。

PE/COFF

Windows 没有 dladdr,反查模块路径要用 GetModuleHandleEx() 加 GetModuleFileName():

HMODULE hmod = nullptr;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
                   (LPCWSTR)&self_func, &hmod);
WCHAR path[MAX_PATH];
GetModuleFileNameW(hmod, path, MAX_PATH);   // 本 dll 的完整路径

加载器实现细节

ELF(Android Bionic)

soinfo 的结构与字段见前文「加载与卸载」。相对前文《动态库加载流程》的通用 ELF 分支,Bionic 的差异集中在「怎么找库、怎么打开」:多了查重,搜索走的是 namespace,还多一条从 APK 里读的分支(源码见 bionic/linker/linker.cpp)。

查重与路径解析:

flowchart TD
  A["dlopen(path, mode)"] --> B["查重:st_dev + st_ino + file_offset"]
  B --> B1{"已加载"}
  B1 -->|"是"| B2["refcount++,直接返回 handle"]
  B1 -->|"否"| C{"路径含 /"}
  C -->|"是"| C7["得到候选路径"]
  C -->|"否"| C2["按 namespace 搜索"]
  C2 --> C7
  C2 --> C3["LD_LIBRARY_PATH"]
  C2 --> C4["DT_RPATH / DT_RUNPATH"]
  C2 --> C5["ld.so.cache"]
  C2 --> C6["默认目录"]

拿到候选路径后,按是否含 !/ 分成两条打开路径:

flowchart TD
  A["候选路径"] --> B{"路径含 !/"}
  B -->|"否"| C["open() 普通文件"]
  B -->|"是"| D["open_library_in_zipfile():拆出 zip 与 entry"]
  D --> E{"entry 未压缩且偏移页对齐"}
  E -->|"否"| F["加载失败"]
  E -->|"是"| G["按 entry 偏移 mmap"]

打开后、映射前,还要过两道对齐校验:

flowchart TD
  A["已打开,拿到 file_offset"] --> B{"file_offset 是否按页对齐"}
  B -->|"否"| X["加载失败"]
  B -->|"是"| C["读 Program Header,统计 LOAD 段的 p_align"]
  C --> D{"16 KiB 页上 min_align 够不够"}
  D -->|"不够且未开兼容模式"| X
  D -->|"够"| E["映射段到内存"]
  • 文件偏移那道在 linker.cpp 的 load_library() 里,!/ 的 zip entry 必须页对齐就是这个原因;

  • p_align 那道在 linker_phdr.cpp 的 ElfReader::CheckProgramHeaderAlignment() / LoadSegments() 里:不是 2 的幂只告警,旧 4 KiB so 还可走 bionic.linker.16kb.app_compat.enabled / get_16kb_appcompat_mode()(见 linker_phdr.cpp);

  • TLS 段的对齐同样要过校验(必须是 2 的幂)。

之后的按段映射、展开依赖、重定位、初始化,骨架与前文 ELF 分支同构(故不重画);Bionic 特有的处理:

  • 映射先用可写映射(便于重定位),重定位完成后再 mprotect 把 .text 收成 RX、.data 收成 RW;

  • 依赖与重定位分两阶段:先递归展开 DT_NEEDED、只建 LoadTask 依赖树,再统一 prelink → 重定位(回填 GOT / PLT)→ 标记 linked;

  • 顶层库的加载顺序会被打乱(源码注释只说 normal case 下 shuffle,未说明动机),依赖树内部按 BFS;

  • 进出 dlopen / dlclose(以及 namespace 操作)时,ProtectedDataGuard 会把 linker 内部 allocator 的内存切到 RW、退出再切回 RO,引用计数支持嵌套(嵌套过深直接 fatal);

  • 查重看的是 inode 三元组,不是路径字符串:同一文件经软链或不同路径打开会命中同一个 soinfo(zip 内库则用 APK 的 st_dev/st_ino 加 entry 偏移),而 dladdr 返回的路径字符串取决于首次是用哪条路径加载的;

  • handle 不一定是裸 soinfo*:LSB=1 时是 g_soinfo_handles_map 里的映射句柄,只能当作不透明值回传给 dlsym / dlclose,不能当指针解引用;

  • libdl.so 里的 dlopen/dlsym/dladdr 都只是桩函数,真正实现在 /system/bin/linker(64),其内部结构随版本变化,不可直接依赖;

  • 重定位阶段还认两种 ELF 扩展:DT_RELR 用位图压缩连续的 RELATIVE 重定位(省 so 体积),IFUNC 则在此时调 resolver、按 CPU 特性回填最优实现到 GOT;

  • constructor 中再次 dlopen 是被支持的嵌套加载(靠引用计数),只有嵌套层数过深才会 fatal——真正要避免的是让加载顺序依赖 constructor 之间的先后。

压缩与重定位

.rela.dyn / .rela.plt 的条目绝大多数是 relative relocation(本质是 base + addend),逐条用 ElfW(Rela) 记录很占体积。各平台都在这张表上做文章,思路一致、编码与标记各异。

ELF(Android / Linux)

ELF 侧有三种压缩形态,都由 linker 在重定位阶段解码展开:

形态 编码 段 / tag linker 支持
APS1 简单 offset 序列 LOOS 区段的 section 标记 早期需自定义 linker 或额外工具解压
APS2(Android packed relocations) SLEB128 变长整数 + 游程编码 .android.rel.dyn(DT_ANDROID_REL / DT_ANDROID_RELA) API 23 起原生支持,Chromium 时代大规模应用
RELR 地址项 + 位图(bitmap)项 .relr.dyn + DT_RELR / DT_RELRSZ / DT_RELRENT;Android 私有:SHT_ANDROID_RELR + DT_ANDROID_RELR* 私有 tag API 28,标准 SHT_RELR API 30

RELR 的编码(每项宽度 = 机器字长:64 位 8 字节 / 32 位 4 字节,靠最低位区分两种项):

  • 地址项(最低位 0):一个需要重定位的地址,它自身要重定位,之后基址落在它后面一个字(base = offset + word);

  • 位图项(最低位 1):把这一项当开关用(不是图像,是位掩码)——bitmap = entry >> 1,第 N 位为 1 表示 base + N × word 处要做相对重定位。64 位一次最多覆盖 63 个机器字,32 位是 31 个;

  • 例(64 位):段内容 [0x100, 0x0F]——0x100 是地址项(自身重定位,base 落到 0x108);0x0F >> 1 = 0b0111,bit0/1/2 为 1,于是 0x108、0x110、0x118 三处重定位。四条相对重定位一共只占 2 × 8 = 16 字节,同样四条 RELA 要 4 × 24 = 96 字节;

  • 一段连续的重定位被压成「1 个地址项 + 1 个位图项」——对密集的 R_*_RELATIVE,比 APS2 逐条记 offset 省得多。

术语别混:Rel 无显式加数,Rela(Relocation with Addend)多一个 r_addend 字段,RELR(Relative Relocation)是专压 R_*_RELATIVE 的独立编码,不是 Rela 的变体。

范围要记牢:

  • RELR 只吃 R_*_RELATIVE(基址 + 偏移);
  • 需要查符号的 GLOB_DAT、JUMP_SLOT 一概不管——这决定了它能压掉多少。

压缩收益(Chromium《Native Relocations》):

  • 二进制更小:Linux 侧 .rela.dyn 超过 14 MiB,Android 用 APS2 压到约 300 KB,RELR 只需约 60 KB;

  • 加载更快:低端机 APS2 约 100 ms,Pixel 4a 约 50 ms,换 RELR 后约 15 ms;

  • 注:APS2 ≈300 KB 与低端机 ≈100 ms / Pixel 4a ≈50 ms 出自该文档的早期修订,现行版本只保留 14 MiB、60 KB 与 Pixel 4a 15 ms。

但收益大小取决于可压缩重定位的绝对数量,与代码行数无关:

  • 最容易产出 R_*_RELATIVE 的:C++ 虚表、RTTI / typeinfo 指针、全局与静态指针初始化、函数指针表 / 回调表、数据段里的字符串指针、PIC 下的 GOT 与 .data.rel.ro 引用(IFUNC 多为 IRELATIVE);

  • 基本不产出的:调用外部函数(JUMP_SLOT / GLOB_DAT)、引用外部全局变量、纯计算与局部变量、不含指针的只读常量;

  • 量级参考:Chromium 那组数字是 .rela.dyn 14 MiB 级;只有虚表、函数指针表、RTTI 极密集的大型产物(UE / Unity 这类堆了大量第三方静态库的)才会到 MB 级收益;

  • 所以「开了 APS2 没效果」多半不是 APS2 没用,而是这个 .so 里 R_*_RELATIVE 本来就少(几百到几千条),压缩空间还被符号表与调试信息掩盖;只有到几十万、上百万条,RELR 才明显优于 APS2(判断命令见后文《压缩与重定位:校验》);

  • 结论:收益小也值得开——对体积基本无负作用,加载期内存映射还略有好处,前提是先确认 tag 真的生成了。

linker 处理路径(「处理 PT_DYNAMIC → 依赖 / 重定位」的通用骨架见前文《动态库加载流程》的 ELF 分支,这里只画压缩相关分支):

flowchart TD
  A["处理 PT_DYNAMIC"] --> B{"存在 Android packed relocation section"}
  B -->|"是"| C["识别 APS1 / APS2"]
  C --> D["读压缩数据"]
  D --> E["在内存中展开为等效 relative relocation 列表"]
  E --> Z["按相对重定位执行 *addr = base + addend"]
  B -->|"否"| F{"存在 DT_RELR / DT_ANDROID_RELR"}
  F -->|"是"| G["按 base + bitmap 展开并回填"]
  F -->|"否"| Z
  G --> Z
  • 解码只发生在内存:磁盘上的段更紧凑,加载时多一步展开,最终语义与普通 .rela.dyn 完全一致;

  • 识别依据是段名与 dynamic tag——.android.rel.dyn 对应 APS2,.relr.dyn + DT_RELR* / DT_ANDROID_RELR* 对应 RELR;

  • 这些分支与 DT_RELR、IFUNC 同在重定位阶段处理(见前文「加载器实现细节」)。源码侧:打开与加载在 linker.cpp,段映射与对齐校验在 linker_phdr.cpp,重定位表与压缩格式在 linker_relocate.cpp。

构建侧开关(LLD 参数):

  • --pack-dyn-relocs=android:只生成 APS2;

  • --pack-dyn-relocs=android+relr:在 APS2 基础上再叠加 RELR;

  • --use-android-relr-tags:让 RELR 走 Android 私有 tag(兼容到 API 28),不加则是标准 DT_RELR(要求 API 30+);

  • 编译侧仍需 -fPIC。

校验命令(readelf 查 tag 与段)见后文《压缩与重定位:校验》。

按 minSdk 选档:

  • minSdk >= 30:可用标准 RELR;

  • minSdk >= 28:android+relr + --use-android-relr-tags;

  • minSdk >= 23:只开 APS2,不要开 RELR;

  • minSdk < 23:不开任何压缩重定位更稳。

跨平台归属:APS1 / APS2 是 Android 专有,RELR 已是通用 ELF 扩展:

目标 能不能用 链接选项
Android(minSdk ≥ 28) APS2 + Android 私有 RELR --pack-dyn-relocs=android+relr --use-android-relr-tags
HarmonyOS / OpenHarmony 实践可用(复用 RELR 机制,没有「鸿蒙专有格式」) 同上,需真机验证 dlopen
Linux glibc 只认标准 RELR,不认 APS2 与 ANDROID_RELR --pack-dyn-relocs=relr,不要加 --use-android-relr-tags
Linux musl 较新版本支持 RELR,不支持 APS2 同上,注意版本
同一份 .so 同时覆盖 Android + Linux 不建议 两套 linker 对 tag 的兼容性不同,按平台分档打包
  • CREL(Compact Relocations)是比 RELR 更进一步的通用方案:用 ULEB128 / SLEB128 加差分编码压缩所有重定位(静态 .o、动态 .so、调试信息都算),设计目标之一是把 32 位 ELF 里只有 8 bit 的类型字段扩到 32 bit;LLVM 19 起实验性支持,发布说明明确 0x40000020 只是临时 section 类型、后续会改并破坏兼容,目前只适合内部评测,不进生产。

易错点:决定能否加载的是目标设备 linker 是否认识这些 tag,不是 NDK 版本——较新的 NDK / LLD 只保证能生成。跨平台 SDK、插件这类产物要按 ABI 与 API level 分档打包,并在最低目标版本的真机上跑一次 System.loadLibrary。

Mach-O

动态修复分两类:rebase(内部指针 + ASLR slide)与 bind(外部符号)。

  • 早期 LC_DYLD_INFO 用一套压缩 opcode 编码这两类信息;
  • macOS 11 / iOS 14 起改用 Chained Fixups(LC_DYLD_CHAINED_FIXUPS):把「下一个待修指针的偏移 + bind ordinal + 可选 addend」直接编进 __DATA 段的指针里,dyld 顺链走一遍即可,不再需要 opcode 解释器;iOS 15 起 dyld 默认启用,传统 lazy bind 一并取消;
  • 它比 RELR 更激进——RELR 只压 relative relocation,Chained Fixups 把 rebase 与 bind 都塞进指针链。
PE
  • 基址重定位表 .reloc 按 4 KB 页分块,每项 2 字节(高 4 位类型、低 12 位页内偏移);
  • 常见类型:IMAGE_REL_BASED_HIGHLOW = 3、DIR64 = 10、ABSOLUTE = 0(对齐填充);
  • 仍是 offset 列表,既没有 RELR 的位图连续编码,也没有 Chained Fixups 的链式结构,属于「轻量省一点」。

三个平台的取舍可以这样记:Android / Linux 走 ELF 的 RELR(Android 另有 APS1 / APS2),iOS / macOS 靠 Chained Fixups 且 Xcode 默认开启,Windows 只有 PE 自带的 .reloc,没有等价于 --pack-dyn-relocs 的链接器选项。

加载隔离

同名库同时存在多份(系统内置 + APK 自带 + 三方 SDK 内置)是符号冲突与 ABI 断裂的根源,各平台的隔离强度差别很大:

平台 隔离机制 默认结果
Android 7.0+ Linker Namespace:系统库与应用库分离,每个 ClassLoader 有专属 namespace 优先加载 APK 私有目录 nativeLibraryDir,再回退系统目录
鸿蒙 三层 namespace:default ns / ndk ns / app ns app ns 可用 ndk ns,不可访问 default ns 的私有库
Linux 无隔离,LD_LIBRARY_PATH 全局生效 后加载者可劫持先加载者的符号
Windows 无隔离,仅 KnownDLLs 与安全搜索模式 全局符号表是扁平的,同名 DLL 由搜索顺序决定

不显式给路径,就等于把选择权交给加载器:

  • LD_LIBRARY_PATH 这类环境变量是进程全局的,塞业务目录进去就会污染系统二进制对同名依赖的解析;

  • dlopen() 只传库名(不含 /):走的是 namespace 搜索路径,同样可能命中系统版本。

所以加载时只剩这几条规则:

  • 一律用绝对路径 dlopen() / System.load(),不让加载器自己去搜;

  • 绝对路径也能由 so 自己反查得到(ELF / Mach-O 的 dladdr()、Windows 的 GetModuleHandleEx() + GetModuleFileName(),见前文「符号查找与反查」),不依赖上层传参;

  • curl/openssl 这类高危库,重命名规避、统一依赖版本,或直接静态链接进主 so。

Android
  • 加载路径的搜索行为,Java 层与 C++ 层不同:

    • System.loadLibrary() 是“智能加载”:ClassLoader 负责路径搜索、自动绑定 namespace、按 DT_NEEDED 递归加载依赖;

    • C++ 层的 dlopen 是“手动挡”:只传库名时只会走系统默认路径,不会搜索 APK 私有目录。

  • APK 内置 so 的落地路径由 extractNativeLibs 决定:

    • true:安装期解压到 /data/app/<pkg>/lib/<abi>/,即 ApplicationInfo.nativeLibraryDir,Java 侧读出来经 JNI 传给 native 即可;

    • false(现行 AGP 默认):so 留在 APK zip 里不落地,路径形如 /data/app/<pkg>/base.apk!/lib/<abi>/libfoo.so,!/ 是 zip 入口分隔符,只有 Framework 配好的 linker namespace 能加载。

  • !/ 这种路径由 linker 自己解析:do_dlopen 见到 !/ 就转给 open_library_in_zipfile()——拆出 zip 与 entry 路径,打开 APK、定位 entry,校验「STORED 未压缩 + 偏移页对齐」后按偏移 mmap。

    • 所以在 app namespace 下,dladdr 反查出的 !/ 路径能直接喂给 dlopen,dladdr + dirname + dlopen 两种模式都成立,不必依赖 JNI 传 nativeLibraryDir;

    • 但只对 dlopen 有效:open() / fopen() / 目录扫描都不认 !/,会报 ENOENT;

    • so 必须未被压缩,重新压过的 APK linker 会直接拒载。

  • 预置到 /system/app 的系统应用例外:搜索顺序会优先系统目录,需用 System.load(绝对路径) 强制指定;

  • 出现在 /system/etc/public.libraries.txt 白名单中的同名库,仍可能被系统版本抢先;

  • 路径还会被平台改写:

    • APEX:Android 10 起的系统组件容器,libc / libm / ART 等被打包成 .apex 挂到 /apex/<包名>/,可随 Mainline 单独升级;系统库迁过去后,dlopen("/system/lib/libx.so") 会被翻译成 /apex/...;

    • ASAN / HWASAN 构建:路径里会插入 /asan/ / /hwasan/ 前缀;

    • targetSdk < 24:另有一份绕过 namespace 可见性检查的豁免白名单。

鸿蒙

需在 module.json5 中显式声明 native 库,否则 so 放对位置也不会被识别;兼容层下 System.loadLibrary() 的搜索路径与原生 Android 不一致,建议同样走绝对路径。

文件权限(Android)

被 dlopen() / System.load() 映射的原生代码文件不能保持可写:较新的 Android(高 targetSdk、平台侧的 Safer Dynamic Code Loading 一类策略)会拒绝加载「可写的原生代码文件」,典型表现是加载失败或直接抛错,而不是静默降级。

原因很直接:可写 + 可执行 ≈ 运行时被篡改、注入的攻击面,系统要求 native 库文件在加载前满足更严的 W^X 文件侧约束——目录可写以便更新,文件本身只读。

步骤 做法
落盘刚完成 对每个 .so 去掉写权限(chmod() / File.setReadOnly())后再加载
覆盖升级重写 先加回写权限才能 truncate / 覆盖,写完立刻改回只读
未重写的情况 仍要扫一遍:旧版本留下的可写 so 也要补成只读
原子发布 先写 *.tmp,rename() 成正式名后再置只读

自动加载

ELF / Mach-O

__attribute__((constructor)) 在 so 加载时自动执行,配合 dladdr 反查路径即可预加载同目录依赖,避免 DT_NEEDED 找不到库:

__attribute__((constructor))
static void preload_deps() {
    Dl_info info;
    if (dladdr((void*)preload_deps, &info) && info.dli_fname) {
        std::string dir = dirname(info.dli_fname);
        dlopen((dir + "/libplugin.so").c_str(), RTLD_NOW | RTLD_LOCAL);
    }
}
  • 固定依赖场景:预加载后不需要 dlsym,linker 会自动完成符号解析,写起来和静态链接一样自然;

  • 插件、条件加载场景:仍需要 dlsym 取函数指针,但自动加载保证了路径正确、handle 有效。

Apple

ObjC 的 +load 与 constructor 都在 main() 之前执行,但分属 Runtime 层与链接器层:

对比项 +load(ObjC) __attribute__((constructor))
层面 ObjC Runtime:dyld 回调 runtime,runtime 遍历类表 链接器/镜像初始化:dyld 直接读 .init_array
时机 更早,先于 constructor 稍晚,仍在 main() 之前
范围 实现了 +load 的类与 Category 整个镜像中的 constructor 函数
顺序 父类 → 子类 → Category 按优先级数字排序(1~100 系统保留)
挂载 必须挂在某个 Class 上 独立函数即可
  • constructor 执行时所有类都已注册完成,是做全局 Hook、服务注册的合适时机。

静态库/动态库对比

动态库

优势
  • linker 负责加载主库的间接依赖,外部不用感知;
劣势
  • 影响冷启动速度:动态加载、符号解析、依赖分析、mmap、符号重定位等操作涉及密集的 CPU 和 IO 操作;(iOS 对动态库还有额外要求,如签名校验等)

    • 开销来自磁盘 I/O、符号重定位、初始化代码三部分,优化手段是延迟绑定、只预加载核心依赖、把重模块下沉为按需加载的插件;

    • Android 不实现延迟绑定,RTLD_LAZY 没有优化空间,只能靠“少加载”;

    • 库中含 thread_local 会引入 DTV 更新开销,热路径需谨慎;

  • 影响包体积:动态库是已完成链接的独立编译单元,为保证二进制兼容,不便做跨模块死代码消除,最终产物可能包含冗余符号;

  • 可能出现运行异常:加载顺序问题、符号缺失、符号二进制兼容问题等;

  • 暴露依赖细节:所有依赖的动态库都会直接出现在最终编译产物中。

静态库

优势
  • 本质是对 .o 文件的归档,包含所有编译符号,便于跨模块死代码消除,最终产物体积较小;

  • 对于 Android/鸿蒙等二次封装最终输出 so 的,静态库会被直接打进 jni.so,不会直接暴露;

  • 包含所有原始符号,有符号冲突会提前暴露,不会运行时异常;

劣势
  • 本身包容较大,不便于开发过程中的二进制分发;

  • 需要手动传递间接依赖。

动态库以运行时灵活性换取启动性能与部署复杂度,静态库以构建时复杂度换取运行时效率与封装性。
所以,内部分发二进制尽量采用静态库,既方便最终产物死代码消除从而减小包容,更重要的是避免最终交付产物包含过多动态库拖慢冷启动速度。

二进制分析与处理

前面讲 DWARF 时提到,有时需要手动提取或剔除调式符号;

优化包容时,可能需要查看二进制内是否包含了未使用的符号;

解决符号链接问题导致的编译或运行时异常时,需要查看指定二进制是否包含了目标符号;

这些都需要用到一系列二进制相关工具链。

字符串提取

strings

用于从二进制提取可打字符串,LLVM 和 GNU 工具链都支持。

符号分析时,通过字符串常量可辅助分析 dlsym 这类动态调用的场景。

查看调试符号

dwarfdump

LLVM 提供的 dwarfdump 工具可用于分析 DWARF 调试信息:

llvm-dwarfdump --debug-info test.o      # 只显示类型信息(默认)
llvm-dwarfdump --debug-line test.o      # 显示源码行号映射
llvm-dwarfdump --debug-loc test.o       # 显示变量位置(寄存器/栈偏移)
llvm-dwarfdump --name=Derived test.o    # 只查特定类型

编辑调试符号

dwarfutil

LLVM 提供的 dwarfutil 可用于编辑 DWARF 调试信息:

llvm-dwarfutil split --output=a.debug a.out                        # 从编译产物提取 DWARF
llvm-dwarfutil link --output=app.dwarf app.o lib1.o lib2.o         # 合并多个编译产物的 DWARF
llvm-dwarfutil optimize --output=app.optimized.dwarf app.debug     # 减小调试信息体积,提升调试器加载速度

C++ 符号还原

C++ 支持:

  • 函数重载(foo(int) vs foo(double));

  • 命名空间(ns::A::func());

  • 类成员函数(MyClass::method());

  • 模板(std::vector<int>::push_back());

但链接器只支持“唯一字符串标识符”,不能直接处理这些复杂结构。于是编译器采用名称修饰(name mangling) 规则:

_ZN2ns1A4funcEiRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE

但这会严重影响符号分析的可读性,需要通过工具还原。

cxxfilt

LLVM 提供了跨平台的工具 cxxfilt:

  • 对于 C 函数符号,他会原样输出;

  • 通常以管道命令的方式配合其他工具使用。

符号表剥离

strip

用于从二进制剥离某类符号,Apple 和 GNU 都支持,但参数不同:

  • 剥离所有符号:GNU 是 --strip-all,Apple 是 -s;

  • 剥离 debug 符号:GNU 是 --strip-debug,Apple 是 -d;

  • 剥离非全局符号:GNU 是 --strip-unneeded,Apple 是 -x;

这个“剥离”只是从符号表(如 .symtab)除名,并不是物理上擦除代码段机器码本身。
所以,对于不可见符号,发布阶段就算 strip-all 也不影响内部调用:
链接器生成动态库时,已将内部调用解析为固定偏移,运行时直接跳转,无需符号查找。
可类比为:孙猴子生死簿除名,只是对外查无此人,并不影响他逍遥法外。

建议:

  • 静态库需格外谨慎使用 strip,否则影响链接;

  • 调试阶段应保留 debug 符号;

  • 对外发布需保留全局符号。

objcopy

属于 LLVM 工具链,不仅支持符号剔除,还能指定保留某些符号、绑定 debug 符号:

# 导出 debug 符号
objcopy --only-keep-debug program  x.debug

# strip 主程序
strip --strip-all x

# 让 strip 后的二进制指向调试文件(用于 gdb)
objcopy --add-gnu-debuglink=x.debug x

快速导出符号名

nm

用于快速输出符号列表,LLVM 和 GNU 工具链都支持:

llvm-nm -C x.a
llvm-nm -C -D x.so
  • -C 参数表示还原被 mangle 的 C++ 符号:

  • -D 参数主要针对 ELF 格式:

    • 动态库必须带上参数,才能查看 .dynsym 符号表;

    • 不带参数,表示导出默认的 .symtab 符号表(前提是未 strip);

输出的符号前的字母表示符号类型,大写表示全局符号,小写表示静态符号:

  • A:

    • 绝对符号,之后经过任何链接都不会改变;
  • B/b:

    • 零初始化或未初始化的符号;

    • 位于 BSS 数据段;

    • 强符号,冲突时报错;

  • C/c:

    • 来源于 C 语言未初始化的符号;

    • 编译时不分配地址(暂存于 COMMON),同名会合并;

  • D/d:

    • 已初始化符号;
  • G/g:

    • 已初始化的小对象;

    • 强符号;

  • I:

    • 表示该符号是另一个符号的间接引用;
  • i:

    • 对于 PE,表示该函数实现位于 DLL;

    • 对于 ELF,表示运行时重定向的函数;

  • N:

    • 调试符号;
  • n:

    • 该符号位于非数据、非代码、非调试的只读段;
  • R/r:

    • 该符号位于只读数据段;
  • S/s:

    • 零初始化或未初始化的小对象;
  • T/t:

    • 普通函数符号;

    • 位于文本(代码)段;

  • U:

    • 未定义符号(在当前文件中被引用,但定义在其他地方,需链接时解析);
  • u:

    • ELF 特有,表示全局唯一符号;
  • V/v:

    • 弱对象符号,通常用 __attribute__((weak)) 定义;

    • 链接时遇到同名强符号(如 T/D),会被忽略;

  • W/w:

    • 弱函数符号;

    • 链接时遇到同名强符号,会被忽略;

深度符号分析

ELF

readelf 可输出详细的 ELF 二进制符号信息:

  • 符号索引;

  • 值(地址);

  • 大小;

  • 类型(FUNC/OBJECT);

  • 绑定(GLOBAL/LOCAL);

  • 可见性(DEFAULT/HIDDEN);

  • 所在段;

它默认输出的是 mangled 的 C++ 符号,所以通常配合 cxxfilt 一起使用:

llvm-readelf -Ws libmy.so | llvm-cxxfilt #输出所有符号信息
llvm-readelf -Wd libmy.so | llvm-cxxfilt #输出动态段,可查看依赖的 so

对于不可见符号,发布阶段通常会 strip 掉,readelf 也是看不到的。

Mach-O

对于 Mach-o,可通过 otool 做深入符号分析:

otool -s __TEXT __symbolstub xxx #查看指定区段
otool -h xxx #查看 Mach header
otool -d xxx #打印数据段
otool -l xxx #查看 Load Commands
otool -L xxx #查看依赖的动态库

PE/COFF

对于 PE/COFF,可以通过 MSVC 的 dumpbin 或者 LLVM 的 readobj:

# MSVC 工具(最权威)
dumpbin /headers foo.dll      # 查看头
dumpbin /exports foo.dll      # 查看导出函数
dumpbin /symbols foo.obj      # 查看符号(含 static)

# LLVM 工具
llvm-readobj --coff-exports foo.dll

通用工具

objdump

LLVM 提供的 objdump 同时支持三种二进制的分析:

  • 反汇编能力(-d/-D),可结合符号表进行带符号反汇编;

  • 支持节区内容 dump(-s);

  • 能显示重定位信息(-r/-R);

  • 查看符号(-t/-T);

  • C++ 符号 demangle(-C)。

不过它也存在问题,可作为上述工具的补充:

  • 对 ELF 的解析不如 readelf 精细;

  • 在 macOS 上用 objdump 看 Mach-O 经常出错或信息缺失。

段对齐检查

对齐没有专门的工具,直接读文件头里的对齐字段:

# ELF:读 Program Header 的 Align,0x1000 是 4 KB,0x4000 是 16 KB(GNU 用 readelf -l)
llvm-readelf -l libfoo.so | grep LOAD

# Mach-O:读 LC_SEGMENT_64 的 vmaddr / vmsize / fileoff / filesize
otool -l libfoo.dylib | grep -A6 LC_SEGMENT_64

# Fat 二进制:看各架构的 align,2^14 即 16 KB
otool -f -v MyApp.app/MyApp

# PE/COFF:看 SectionAlignment 与 FileAlignment
dumpbin /headers foo.dll | findstr /i alignment

为什么会有这条约束、各平台差在哪,见前文「二进制页对齐」。

压缩与重定位:校验

校验(llvm-readelf 与 GNU readelf 短选项一致):

# dynamic tag:是否出现 RELR / Android packed relocations
readelf -d libxxx.so | grep -iE "relr|android.rel"

# section:APS2 段 .android.rel.dyn、RELR 段 .relr.dyn
readelf -S libxxx.so | grep -iE "android|relr"
# APS2 段在 section header 里属于 LOOS+ 范围,GNU readelf 可能只显示类型不显示可读段名
readelf -S libxxx.so | grep -i "LOOS"

# 区分标准 RELR 与 Android 私有 tag
readelf -d libxxx.so | grep -E "DT_RELR|DT_ANDROID_RELR"

# 可压缩基数:R_*_RELATIVE 有多少条(RELR 只吃这一类)
readelf -r libxxx.so | grep -c RELATIVE

# 重定位总数与各段大小
readelf -r libxxx.so | wc -l
readelf -S libxxx.so | grep -E "rela|relr"
  • 判据:出现 .relr.dyn / RELR / ANDROID_RELR,或 APS2 的 .android.rel.dyn,即压缩已生效;

  • 收益判据:R_*_RELATIVE 只有几百到几千条时,压缩后体积几乎不变;到几十万、上百万条(虚表、函数指针表、RTTI 密集)才会明显;

  • 开了却没效果时按这几条排查:

可能原因 表现 怎么确认
R_*_RELATIVE 本来就少 开启前后 .so 大小几乎不变 readelf -r | grep -c RELATIVE
重定位集中在 .rela.plt APS2 / RELR 对 PLT 项收益有限 readelf -S | grep rela
.so 本身很小 收益被符号表、调试信息掩盖 strip 后再比一次
链接器没真正启用 参数传错或被覆盖 readelf -d | grep -iE "relr|android.rel"
已开 --gc-sections / LTO 死代码被删,重定位自然变少 看构建日志与最终 link 参数
  • 静态工具支持:
    • 直接可读:LLVM 工具链(llvm-readelf / llvm-readobj,NDK 自带)、Ghidra(有一个偏移显示的已知 bug);
    • 工具不认识该格式时:换 LLVM 工具链即可,不必为分析去装 Chrome 的构建工具;
    • relocation_packer 已退役——Chromium 改用 lld 原生生成,格式的参考实现现在在 AOSP bionic 的 tools/relocation_packer/;

格式细节、跨平台档位与收益来源见前文「压缩与重定位」。

符号分析的干扰因素

dlsym

某些场景下,需要通过 dlsym() 动态查找字符串符号对应的地址再调用,甚至会使用字符拼接/加密等手段,这种情况会严重干扰符号分析,也是常见的安全加固手段。

void* handle = dlopen("libsecret.so", RTLD_LAZY);

//字符拼接
char symbol0[128];
strcpy(symbol0, "my_");
strcat(symbol0, "secret");
strcat(symbol0, "_func");
void* f0 = dlsym(handle, symbol0);

//字符加密
const char encrypted[] = {0x1a, 0x2b, 0x3c, ...};
char symbol1[128];
decrypt(encrypted, symbol1);
void* f1 = dlsym(handle, symbol1);

Linux 的 dlsym() 默认只查 .dynsym,但 Android 在失败时会回滚到 .symtab!

堆栈符号还原

当你拿到崩溃堆栈里的地址(PC / RIP / LR / 返回地址)或偏移(+0x1234)时,“符号还原(symbolize)”的目标是把它们映射回:

  • 模块(哪个 so/dylib/exe)
  • 函数名(含 demangle)
  • 源码文件与行号(需要调试信息:DWARF / dSYM / PDB 等)

下面以 LLVM 的跨平台工具链为主线,再按平台补充各自的原生工具与常用命令。

LLVM 跨平台工具链

llvm-addr2line

偏“查单点地址”。输入目标文件(-e)+ 地址(常用模块内偏移),输出函数名与文件行号(依赖 DWARF 等调试信息)。
适合你已经明确“地址属于哪个 so”,并希望快速定位到源码位置的场景。
支持 -C(C++ demangle)、-f(函数名)、-i(内联栈展开)。

# 单个地址
llvm-addr2line -e libfoo.so -f -C -i 0x000000000001a2b3
# 批量还原:每行一个地址
cat addrs.txt | llvm-addr2line -e libfoo.so -f -C -i
llvm-symbolize

偏“符号化引擎”。能力覆盖 addr2line,并更擅长做成“批量/堆栈导向”的输出(尤其是内联展开);

# 直接对地址做符号化
echo "0x000000000001a2b3" | llvm-symbolize -obj=libfoo.so -demangle -inlining
# 常见堆栈格式:模块 + 地址
printf "libfoo.so 0x1a2b3\n" | llvm-symbolize -obj=libfoo.so -demangle -inlining

Linux 相关工具链

# binutils
addr2line -e a.out -f -C -i 0x401234
# elfutils(在某些发行版上对 DWARF 解析更稳)
eu-addr2line -e a.out -f -C -i 0x401234
# gdb 直接查“某地址属于哪一行”
gdb -q ./a.out -ex "info line *0x401234" -ex quit

Android NDK 相关工具链

ndk-stack

Android 的 tombstone / logcat 往往给的是“进程虚拟地址”,需要先确认该地址属于哪个 so,并拿到该 so 的 load base(来自 /proc/<pid>/maps 或 tombstone 的 memory map),再换算成“模块内偏移”去符号化。

ndk-stack 属于一键还原工具:自动匹配到对应 so、必要时做地址/偏移换算,然后调用 llvm 工具链输出可读堆栈。

ndk-stack -sym <symbols-dir> -dump tombstone.txt

Apple 相关工具链

Apple 平台常见的是“地址 + slide(加载基址)”,其中 atos 需要你提供 -l 指定 load address(通常是 Mach-O 的实际加载地址)。

# 用 dSYM 进行符号化
atos -o xxx.app.dSYM/Contents/Resources/DWARF/xxx -arch arm64 -l 0x100000000 0x10001234
# iOS 崩溃日志符号化(输出完整可读的 crash)
xcrun symbolicatecrash crash.crash xxx.app.dSYM > crash.symbolicated.txt

Windows 相关工具链

Windows 主要依赖 PDB 符号文件;调试器(WinDbg/CDB)支持从本地缓存或微软符号服务器按需下载符号。

# 设置符号缓存 + 微软符号服务器(CDB / WinDbg 都通用)
# 进入调试器后:
.symfix
.sympath+ SRV*c:\symbols*https://msdl.microsoft.com/download/symbols
.reload
# 查看某个地址对应的符号
ln 00007ff6`12345678

二进制重组

内部分发静态库时,如果涉及到第三方库,通常涉及多个静态库,为了便于平台封装层接入,可考虑先将多个静态库还原为 .o 集合,然后归档为一个静态库。

当然前提是:

  • 只有静态库(本身就只是 .o 的归档),动态库重组会影响链接;

  • 必须确保多个静态库编译环境一致。

工具

各平台都有自己的相关工具(Linux/macOS 的 ar,macOS 特有的 libtool、Windows 特有的 lib 等),这里仅介绍跨平台的 LLVM 工具链。

llvm-ar: 模拟 ar,支持 ELF 和 Mach-O,不支持 COFF;

llvm-lib:模拟 Windows 的 lib.exe,仅支持 COFF。

静态库解档

# 列出归档成员(仅列出,不解压)
llvm-ar t libfoo.a
# 解压全部;若归档内有同名成员,解到同一文件名会互相覆盖
llvm-ar x libfoo.a
# 解压「第 count 个」名为 foo.o 的成员
llvm-ar xN <count> libfoo.a foo.o

# Windows 只能逐个提取
llvm-lib /LIST mylib.lib > members.txt
foreach ($obj in Get-Content members.txt) {
    llvm-lib /EXTRACT:$obj mylib.lib
}

归档为静态库

llvm-ar rcs sdk-linux.a linux/*.o
llvm-ar rcs sdk-mac.a mac/*.o

llvm-lib /OUT:sdk-win.lib win/*.obj

直接合并静态库

先解档再归档不仅麻烦,而且可能出现冲突,可直接合并,更安全。

llvm-ar rcs combined.a lib1.a lib2.a

llvm-lib /OUT:combined.lib lib1.lib lib2.lib