现代 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
└── ...(其他动态链接元数据)
// 全局补充说明:
// - .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:动态库;
可执行文件无扩展名;
符号表
.symtab:完整符号表,用于调试、静态分析,包含 default / hidden 所有符号;.dynsym:动态符号表,仅包含 default 可见性符号,供动态链接使用。
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 等符号索引
【异常相关 —— 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_ 前缀符号
二进制类型
.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
Dynamic Library Loading (ELF)
├── dlopen (API 调用)
│ ├── Search Path Resolution (查找路径解析)
│ │ ├── LD_LIBRARY_PATH Environment Variable
│ │ ├── /etc/ld.so.conf.d/
│ │ ├── Default Paths (e.g., /lib, /usr/lib)
│ │ └── RPATH/RUNPATH in ELF Header
│ ├── Open File (打开动态库文件)
│ ├── Read ELF Headers (读取 ELF 头)
│ │ ├── Program Headers (程序头)
│ │ ├── Section Headers (段头)
│ ├── Map Segments to Memory (将段映射到内存)
│ │ ├── PT_LOAD Segments (加载段)
│ │ ├── PT_DYNAMIC Segment (动态链接信息)
│ ├── Process Dynamic Section (处理动态段)
│ │ ├── Resolve Dependencies (解析依赖库)
│ │ │ ├── Load Dependent Libraries (加载依赖库)
│ │ │ ├── Relocate Symbols (重定位符号)
│ │ │ └── Bind Symbols (绑定符号)
│ ├── Initialize Global Variables (初始化全局变量)
│ ├── Call _init Function (调用 _init 函数)
│ │ ├── Constructor Functions (构造函数)
│ │ └── Initialization Code (初始化代码)
└── Return Handle (返回句柄)
ELF 动态库加载过程中,通过 ld.so 提供基本的安全性保障。
Mach-O
dyld2
Dynamic Library Loading (Mach-O dyld2)
├── dlopen (API 调用)
│ ├── Search Path Resolution (查找路径解析)
│ │ ├── DYLD_LIBRARY_PATH Environment Variable
│ │ ├── @rpath in Mach-O Header
│ │ ├── /usr/lib, /System/Library/Frameworks
│ │ └── Framework Search Paths
│ ├── Open File (打开动态库文件)
│ ├── Read Mach-O Headers (读取 Mach-O 头)
│ │ ├── Load Commands (加载命令)
│ │ ├── LC_SEGMENT_64 Segments (段)
│ ├── Map Segments to Memory (将段映射到内存)
│ │ ├── __TEXT, __DATA, __LINKEDIT 段
│ ├── Process LC_LOAD_DYLIB Commands (处理 LC_LOAD_DYLIB 命令)
│ │ ├── Resolve Dependencies (解析依赖库)
│ │ │ ├── Load Dependent Libraries (加载依赖库)
│ │ │ ├── Bind Symbols (绑定符号)
│ │ │ └── Rebase Pointers (重定向指针)
│ ├── Apply Dyld Bindings (应用 dyld 绑定)
│ │ ├── Lazy Binding (延迟绑定)
│ │ ├── Weak Binding (弱绑定)
│ │ └── Flat Namespace Binding (扁平命名空间绑定)
│ ├── Call Initializers (调用初始化器)
│ │ ├── +load Methods (Objective-C +load 方法)
│ │ ├── C++ Constructors (C++ 构造函数)
│ │ └── Other Initialization Code (其他初始化代码)
└── Return Handle (返回句柄)
dyld3
dyld3 主要是安全性和加载性能方面的优化。
ynamic Library Loading (Mach-O dyld3)
├── dlopen (API 调用)
│ ├── Pre-Flight Checks (预检)
│ │ ├── Validate Code Signature (验证代码签名)
│ │ ├── Check Entitlements (检查权限)
│ │ └── Verify Library Integrity (验证库完整性)
│ ├── Search Path Resolution (查找路径解析)
│ │ ├── Same as dyld2 (与 dyld2 相同)
│ ├── Open File (打开动态库文件)
│ ├── Read Mach-O Headers (读取 Mach-O 头)
│ │ ├── Load Commands (加载命令)
│ │ ├── LC_SEGMENT_64 Segments (段)
│ ├── Map Segments to Memory (将段映射到内存)
│ │ ├── __TEXT, __DATA, __LINKEDIT 段
│ ├── Process LC_LOAD_DYLIB Commands (处理 LC_LOAD_DYLIB 命令)
│ │ ├── Resolve Dependencies (解析依赖库)
│ │ │ ├── Load Dependent Libraries (加载依赖库)
│ │ │ ├── Bind Symbols (绑定符号)
│ │ │ └── Rebase Pointers (重定向指针)
│ ├── Apply Dyld Bindings (应用 dyld 绑定)
│ │ ├── Optimized Bindings (优化绑定)
│ │ ├── Prebinding (预绑定)
│ │ └── Fast Path for Common Libraries (常见库的快速路径)
│ ├── Call Initializers (调用初始化器)
│ │ ├── +load Methods (Objective-C +load 方法)
│ │ ├── C++ Constructors (C++ 构造函数)
│ │ └── Other Initialization Code (其他初始化代码)
│ ├── Post-Initialization (后初始化)
│ │ ├── Runtime Checks (运行时检查)
│ │ ├── Security Enhancements (安全增强)
│ │ └── Performance Monitoring (性能监控)
└── Return Handle (返回句柄)
PE/COFF
PE/COFF 动态库加载过程中的安全性校验较少。
Dynamic Library Loading (Windows)
├── LoadLibrary (API 调用)
│ ├── Search Path Resolution (查找路径解析)
│ │ ├── Current Directory
│ │ ├── Application Directory
│ │ ├── System Directory (e.g., C:\Windows\System32)
│ │ ├── PATH Environment Variable
│ │ └── Known DLLs (预加载的系统 DLL)
│ ├── Open File (打开动态库文件)
│ ├── Map File to Memory (将文件映射到内存)
│ │ ├── Headers (PE Header)
│ │ ├── Sections (段:.text, .data 等)
│ ├── Process Import Table (处理导入表)
│ │ ├── Resolve Imports (解析导入符号)
│ │ │ ├── Load Dependent Libraries (加载依赖库)
│ │ │ ├── Bind Symbols (绑定符号)
│ │ │ └── Fixup Addresses (修正地址)
│ ├── Initialize Data Sections (初始化数据段)
│ ├── Call DllMain (调用 DllMain 函数)
│ │ ├── DLL_PROCESS_ATTACH (进程附加)
│ │ ├── DLL_THREAD_ATTACH (线程附加)
│ │ └── DLL_THREAD_DETACH (线程分离)
└── Return Handle (返回句柄)
可以看到,三种二进制动态库加载流程虽有细节差异,但都包含三个核心步骤:动态查询依赖、内存映射、处理符号表,涉及到密集的 CPU 和 IO 操作,严重影响冷启动速度。
静态库/动态库对比
动态库
优势
- linker 负责加载主库的间接依赖,外部不用感知;
劣势
影响冷启动速度:动态加载、符号解析、依赖分析、mmap、符号重定位等操作涉及密集的 CPU 和 IO 操作;(iOS 对动态库还有额外要求,如签名校验等)
影响包体积:动态库是已完成链接的独立编译单元,为保证二进制兼容,不便做跨模块死代码消除,最终产物可能包含冗余符号;
可能出现运行异常:加载顺序问题、符号缺失、符号二进制兼容问题等;
暴露依赖细节:所有依赖的动态库都会直接出现在最终编译产物中。
静态库
优势
本质是对 .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)vsfoo(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 经常出错或信息缺失。
符号分析的干扰因素
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!
二进制重组
内部分发静态库时,如果涉及到第三方库,通常涉及多个静态库,为了便于平台封装层接入,可考虑先将多个静态库还原为 .o 集合,然后归档为一个静态库。
当然前提是:
只有静态库(本身就只是 .o 的归档),动态库重组会影响链接;
必须确保多个静态库编译环境一致。
工具
各平台都有自己的相关工具(Linux 的 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