前言
符号化(Symbolication) 就是把一个内存地址,翻译成人能看懂的函数名、源文件和代码行号。
比如,未符号化之前,调用栈中的地址:
swift
0 APMDEMO 0x0000000104000958
1 APMDEMO 0x00000001040008a0
符号化后:
swift
0 APMDEMO add(_:_:) main.swift:11
1 APMDEMO main main.swift:15
这个过程需要解决三个问题:
- 这个地址属于哪个 Mach-O?
- 它对应这个 Mach-O 中的什么位置?
- 哪一份符号信息能够解释这个位置?
解决这个问题的过程,就是符号化。
另外需要注意的是,它并不负责寻找上一层调用者,那是栈展开干的活,它只负责解释栈展开已经得到的地址。
关于栈展开,可以看看 APM_函数调用栈展开。
运行时的地址 -> Mach-O
在开始之前,先介绍一个概念:Binary Image。
Binary Image
- Binary Image:一个已加载的可执行镜像
- Binary Images:崩溃报告中保存这些镜像信息的数组
在 iOS 的崩溃语境里,每个 binary image 通常对应一个被加载的 Mach-O,区别在于:
- Mach-O:一种二进制文件格式
- Binary Image:一个可加载的 Mach-O 模块,或它在进程中的加载实例
swift
源码
↓ 编译
.o 文件(Mach-O Object)
↓ 链接
APMDemo 可执行文件(Mach-O Image)
↓ dyld 加载
进程内存中的 Binary Image
它的存在形式:
| 阶段 | 存在形式 | 例子 |
|---|---|---|
| 磁盘上 | Mach-O 文件 | App 可执行文件、Framework、.dylib |
| 进程运行时 | 映射到虚拟内存中的 Mach-O segments | __TEXT、__DATA、__LINKEDIT |
1. 磁盘上
在磁盘上的表示形式就是 Mach-O 文件。
比如:
swift
APMDemo.app/APMDemo
/System/Library/Frameworks/UIKit.framework/UIKit
/usr/lib/swift/libswiftCore.dylib
这些是磁盘上的二进制文件。
2. 内存中
在 App 启动后,dyld 会把这些 Mach-O 中不同的 segment 映射进进程虚拟地址空间:
swift
APMDemo
├── __TEXT 机器代码、只读数据、展开信息
├── __DATA 全局变量等可写数据
└── __LINKEDIT 符号表、字符串表等链接信息
内存里的一个 image 通常可以通过它的 Mach-O header 定位:
swift
const struct mach_header *imageLoadAddress;
const char *imageFilePath;
dyld 使用 dyld_all_image_infos.infoArray 保存已加载 image 的信息数组,其中 dyld_image_info 包含加载地址和路径。
崩溃报告中的 Binary Images 就是基于这个整理出来的。
继续
假设 backtrace 中有一个地址:
swift
0x104000958
只拿着这个数字是没法直接查询函数的,因为进程中同时加载了很多 Mach-O:
swift
APMDEMO
Foundation
UIKitCore
libswiftCore.dylib
其他动态库
这些 Mach-O 都有自己的地址空间和符号信息。所以,要先找到这个地址所在的 Mach-O 是哪个。
假设崩溃报告中记录:
swift
APMDEMO:
运行时起始地址:0x104000000
运行时结束地址:0x104010000
UUID:8F89359F-D469-42B1-8853-01A635B723F9
架构:arm64
由于:
swift
0x104000000 <= 0x104000958 < 0x104010000
所以可以确定 0x104000958 属于 APMDEMO。
但是还不能直接拿这个地址来查询磁盘上的 Mach-O,因为 Mach-O 的运行时地址可能受到 ASLR 的影响。
ASLR 就是 Address Space Layout Randomization,即地址空间布局随机化。链接器在生成 Mach-O 的时候,会给 __TEXT 等 Segment 分配一个链接时虚拟地址,系统在加载 Mach-O 的时候,会给这些地址统一加上一个随机偏移,这个偏移就是 ASLR slide。
假设这个 Demo 的 __TEXT 在 Mach-O 中从下面的地址开始:
swift
链接时地址 A = 0x100000000
本次运行时,它被加载到了:
swift
运行时加载地址 L = 0x104000000
那么 ASLR slide 为:
swift
slide = L - A
= 0x104000000 - 0x100000000
= 0x04000000
backtrace 中的运行时地址是:
swift
R = 0x104000958
从中减去 slide,就能恢复到 Mach-O 的文件地址空间:
swift
文件地址 = R - slide
= 0x104000958 - 0x04000000
= 0x100000958
也可以先计算地址在 image 中的相对位置:
swift
0x104000958 - 0x104000000 = 0x958
ASLR 会改变整个 Mach-O 的加载位置,却不会改变一条指令在同一份 Mach-O 中的相对位置。下次启动时,假设镜像变成:
swift
镜像加载地址:0x108000000
同一条指令: 0x108000958
两者相减仍然是:
swift
0x108000958 - 0x108000000 = 0x958
所以,崩溃报告不仅需要采集 backtrace 地址,还必须采集 Binary Images。地址只有和它所属的 Mach-O、运行时加载位置结合,才有意义。
地址 -> 函数和源码行
得到文件地址 0x100000958 后,下一步是在构建产物中查询这个地址。能够解释地址的信息并不只有一种,它们提供的精度也不同。
LC_FUNCTION_STARTS
Mach-O 中最基础的是 LC_FUNCTION_STARTS,它记录了函数从哪些地址开始,但不记录函数名。假设其中包括:
swift
0x100000880
0x100000940
0x100000980
查询地址 0x100000958 位于:
swift
0x100000940 <= 0x100000958 < 0x100000980
由此可以判断它位于从 0x100000940 开始的某个函数中,并计算:
swift
0x100000958 - 0x100000940 = 0x18
也就是:"某个函数 +24"。
这里的 24 表示距离函数第一条机器指令 24 字节,不是源码行号。
Mach-O 符号表
比 Function Starts 更详细的是 Mach-O 符号表,LC_SYMTAB 指向一组 nlist 记录,每条记录可以把一个地址和符号名关联起来,例如:
swift
0x100000940 _$s4main3addyS2i_SitF
现在就能把结果补充为:
swift
_$s4main3addyS2i_SitF + 24
这个 _$s4main3addyS2i_SitF 是 Swift mangled name。Swift 需要在符号名中编码模块、函数、参数和返回类型,才能唯一标识一个函数。经过 demangle 后,它可以变成:
swift
main.add(Swift.Int, Swift.Int) -> Swift.Int
最终展示时通常简化为:
swift
add(_:_:) + 24
Mach-O 符号表可以提供函数名,却不一定包含完整的本地函数,也通常不能提供精确的源码文件和行号。发布构建还可能根据 Strip 设置移除部分符号。
dSYM 中的 DWARF 调试信息
要得到:
swift
add(_:_:) main.swift:11
通常需要使用 dSYM 中的 DWARF 调试信息。
dSYM 是构建 App 时生成的调试符号 bundle,它不能运行,而是保存了机器代码地址和源代码之间的对应关系。其中:
debug_info:描述编译单元、函数、类型和它们之间的关系debug_line:保存机器指令地址和源码文件、行号的映射- 内联函数信息描述优化器消除真实函数调用后留在的逻辑调用关系
例如,编译器可能生成类似下面这样的地址映射:
swift
0x100000940 ~ 0x100000957 add(_:_:) main.swift:10
0x100000958 ~ 0x100000967 add(_:_:) main.swift:11
0x100000968 ~ 0x100000978 add(_:_:) main.swift:12
查询 0x100000958,就能得到:
swift
add(_:_:) main.swift:11
源码和机器指令不一定一一对应。优化器可能合并、移动或删除代码,也可能把一个函数直接内联到另一个函数中。被内联的函数没有独立的物理栈帧,但 DWARF 可以保存它的逻辑调用关系,因此一个 backtrace 地址经过完整符号化后,可能展开多层内联函数。Apple 将可用信息归纳为 Function Starts、
nlist符号表和 DWARF 三个层次,其中 DWARF 能提供文件、行号和优化信息。
这里要注意的是,不要和栈展开中的 DWARF 混淆,它们的用途不同:
swift
Mach-O 中的 __eh_frame / DWARF CFI
作用:恢复调用者的寄存器,完成栈展开
dSYM 中的 debug_info、debug_line
作用:把指令地址转换成函数、文件和源码行
一个是回答 "上一层函数在哪里",另一个回答 "这个地址对应哪段源码"。
dSYM 与 Mach-O 需要完全匹配
同一份源码重新构建一次,函数地址仍然可能因为编译器版本、优化结果或链接顺序而改变。
第一次构建可能是:
swift
add 开始地址:0x100000940
第二次构建可能变成:
swift
add 开始地址:0x100000a20
如果使用第二次构建的 dSYM 解释第一次构建的崩溃地址,就可能得到错误的函数和行号。
因此,Mach-O 和 dSYM 通过 UUID 建立联系:
Crash Report 中的 Binary Image UUID = dSYM 中对应架构的 UUID
只有 UUID 和 CPU 架构都匹配,才能确认这份 dSYM 描述的是崩溃时实际运行的机器代码。
可以用下面的命令检查:
swift
dwarfdump --uuid APMDEMO.app/APMDEMO
dwarfdump --uuid APMDEMO.app.dSYM
找到匹配的 dSYM 后,可以使用 atos 对单个地址进行符号化:
swift
xcrun atos \
-arch arm64 \
-o APMDEMO.app.dSYM/Contents/Resources/DWARF/APMDEMO \
-l 0x104000000 \
0x104000958
-o:指定用于查询的 DWARF 文件-l:指定 Mach-O 本次运行时的加载地址- 最后一个参数:需要符号化的运行时地址
比如下面这段代码:
swift
func add(_ a: Int, _ b: Int) -> Int {
let result = a + b
return result
}
let total = add(10, 20)
进行实际验证时,add 的函数起始是:
swift
0x100000940
执行 a+b 的指令位于:
swift
0x100000958
没有 dSYM 行号信息时,即使有 Mach-O 符号表,也只能得到:
swift
add(_:_:) + 24
使用 UUID 匹配的 dSYM 后,可以得到:
swift
add(_:_:) (main.swift:11)
整个转换过程就是:
swift
0x104000958
↓ 根据 Binary Images 找到 APMDEMO
APMDEMO + 0x958
↓ 去除 ASLR
文件地址 0x100000958
↓ 使用 UUID 找到匹配的 dSYM
查询 DWARF 地址映射
↓
add(_:_:) main.swift:11
总结
符号化,就是把程序运行时使用的指令地址,重新映射为开发者能够理解的函数名、函数内偏移、源文件和代码行号。
一个运行时地址不能单独完成符号化,首先要根据 Binary Images 确定地址属于哪个 Mach-O,并取得该镜像本次运行时的加载地址、UUID 和 CPU 架构。随后利用运行时加载地址和 Mach-O 链接时地址计算 ASLR slide:
swift
ASLR slide = 运行时加载地址 - 链接时虚拟地址
链接时虚拟地址 = 运行时指令地址 - ASLR slide
这里得到的是去除 ASLR 后的链接时虚拟地址,不是 Mach-O 文件中的字节偏移。对于同一份构建产物,每次启动时 ASLR slide 可以不同,但镜像内相对偏移和链接时地址保持不变。
得到链接时地址后,可以使用不同精度的符号信息进行查询:
swift
LC_FUNCTION_STARTS
→ 确定函数起始位置,但没有函数名
LC_SYMTAB
→ 得到函数符号名和函数内偏移
dSYM 中的 DWARF
→ 得到函数、源文件、代码行号和内联调用关系
dSYM 中记录的是与对应 Mach-O 一致的链接时地址映射,不包含某一次运行产生的 ASLR slide。必须使用 UUID 与 CPU 架构相匹配的 dSYM。否则,即使 App 名称和版本号相同,也可能因为重新编译导致地址布局不同,从而产生错误的符号化结果。
因此,完整符号化可以概括为:
swift
backtrace 指令地址
+ Binary Image 的加载地址、UUID 和架构
+ 完全匹配的 Mach-O 与 dSYM
= 函数名、函数内偏移、源文件和代码行号
另外,回答一下文章开头提出的三个问题:
- 这个地址属于哪个 Mach-O?
0x104000000 <= 0x104000958 < 0x104010000
所以它属于 APMDEMO,这个由 Binary Images 地址范围确定。
- 它对应这个 Mach-O 中的什么位置?
先计算镜像内偏移:
0x104000958 - 0x104000000 = 0x958
所以它位于:
APMDEMO + 0x958
再换算去除 ASLR 的链接时虚拟地址:
0x100000000 + 0x958 = 0x100000958
因此它有两种等价表示:
- 镜像内相对位置:
0x958 - 链接时虚拟地址:
0x100000958
- 哪一份符号能够解释这个位置?
先根据:
swift
UUID:8F89...
架构:arm64
找到完全匹配的 Mach-O 或 dSYM,然后查询 0x100000958:
LC_FUNCTION_STARTS:只能找到附近的函数起点LC_SYMTAB:可以得到add(_:_:) + 24- 匹配 dSYM 中的 DWARF:可以得到
add(_:_:)(main.swift:11)
如果目标是完整符号化,那么答案就是:
UUID 和架构与崩溃时 Mach-O 完全匹配的 dSYM