前言
符号化(Symbolication) 就是把一个内存地址,翻译成人能看懂的函数名、源文件和代码行号的一个过程。
未符号化前,调用栈中的地址:
swift
0 APMDEMO 0x0000000104000958
1 APMDEMO 0x00000001040008a0
符号化后:
swift
0 APMDEMO add(_:_:) main.swift:11
1 APMDEMO main main.swift:15
在开始之前,需要了解一些基本的概念:
- DWARF
- dSYM
- Mach-O
DWARF
DWARF 是一种 "调试信息的数据格式标准"。它把编译后的机器地址、寄存器和内存位置,重新关联到函数、源文件、行号、变量和类型。它通常由编译器、汇编器或链接器生成,由调试器和符号化工具读取。DWARF 官方标准
就是一种数据格式,记录了调试信息,通过机器指令就能找到对应的源码:
swift
源代码
UserService.swift 第 42 行
loadUser()
↓ 编译
机器指令地址
0x10401234
↑ DWARF 保存对应关系
它的原始内容是二进制字节,可以使用 dwarfdump/llvm-dwarfdump 来解码。
假设源码是:
swift
int add(int a, int b) {
int sum = a + b;
return sum;
}
CPU 在执行的时候,看不到 add、sum 这些东西,只能看到:
swift
地址 0x00:某条 arm64 指令
地址 0x0c:某条 arm64 指令
地址 0x1c:某条 arm64 指令
编译器就使用 DWARF 来记录:
swift
函数 add 对应地址范围 [0x00, 0x28)
变量 sum 的类型是 int
变量 sum 位于函数帧基准位置 + 4
地址 0x1c 对应源文件第 3 行、第 12 列
然后通过符号化的工具就可以通过地址,找到:
swift
0x1c
→ 位于函数 add
→ 对应第 3 行、第 12 列
→ return sum
这是编译器在构建时候提前记录好的映射关系。
数据结构
原始 DWARF 是二进制字节:
swift
00000000000000c8 00000066 08010005 00000000 001d0001
00000000000000d8 08030201 00000000 04000000 00005000
dwarfdump 解码之后:
swift
DW_TAG_subprogram
DW_AT_low_pc [DW_FORM_addrx] (0x0000000000000000)
DW_AT_high_pc [DW_FORM_data4] (0x00000028)
DW_AT_frame_base[DW_FORM_exprloc](DW_OP_reg31 WSP)
DW_AT_name [DW_FORM_strx1] ("add")
DW_AT_decl_line [DW_FORM_data1] (1)
DW_AT_type [DW_FORM_ref4] (0x00000065 "int")
DW_TAG_variable
DW_AT_location [DW_FORM_exprloc](DW_OP_fbreg +4)
DW_AT_name [DW_FORM_strx1] ("sum")
DW_AT_decl_line [DW_FORM_data1] (2)
DW_AT_type [DW_FORM_ref4] (0x00000065 "int")
...
几个常见前缀的含义:
| 名称 | 身份和作用 |
|---|---|
| DIE | Debugging Information Entry,一条描述函数、变量、类型等对象的记录 |
DW_TAG_* |
记录的种类,例如函数、变量或类型 |
DW_AT_* |
记录的属性,例如名称、地址、行号和类型 |
DW_FORM_* |
属性在二进制中使用什么编码形式 |
DW_OP_* |
计算变量位置或值的小型表达式指令 |
| CU | Compilation Unit,一次编译输入对应的一组调试信息 |
例如:
swift
DW_TAG_variable
DW_AT_name ("sum")
DW_AT_type (指向 int 类型)
DW_AT_location (DW_OP_fbreg +4)
整体含义是:
这是一条变量记录,变量名为
sum,类型是int,在当前函数帧基准位置加 4 的地方可以找到它。
主要 sections
DWARF 不是把所有信息塞进一张表,而是拆成多个彼此引用的 Section,在 Mach-O 中通常放到 __DWARF segment 中。
Apple Mach-O 使用 __debug_* 命名,DWARF 标准文档通常写作 .debug_*。
| 标准 DWARF 名 | Mach-O 中的名字 | 作用 |
|---|---|---|
.debug_info |
__DWARF,__debug_info |
主体 DIE 树,描述函数、变量、参数、类型和作用域 |
.debug_abbrev |
__DWARF,__debug_abbrev |
.debug_info 的格式字典 |
.debug_line |
__DWARF,__debug_line |
机器地址与源文件、行号、列号的映射 |
.debug_line_str |
__DWARF,__debug_line_str |
行号表使用的文件名和目录字符串 |
.debug_str |
__DWARF,__debug_str |
公共字符串池,保存函数名、变量名和类型名等 |
.debug_str_offsets |
__DWARF,__debug_str_offs |
从字符串编号映射到 .debug_str 偏移 |
.debug_addr |
__DWARF,__debug_addr |
可供其他记录按索引引用的地址表 |
.debug_names |
__DWARF,__debug_names |
名称到 DIE 的快速查找索引 |
.debug_loclists |
__DWARF,__debug_loclists |
变量在不同指令范围内分别位于哪里 |
.debug_rnglists |
__DWARF,__debug_rnglists |
函数或作用域对应的非连续代码范围 |
.debug_frame |
__DWARF,__debug_frame |
恢复调用者寄存器、支持栈展开的调用帧信息 |
.debug_aranges |
__DWARF,__debug_aranges |
从地址快速定位到编译单元的可选索引 |
.debug_macro |
__DWARF,__debug_macro |
宏定义、取消定义及文件关系 |
它们之间大致这样协作:
swift
.debug_abbrev
记录格式字典
↓
.debug_names ───────→ .debug_info ───────→ .debug_line
名称索引 程序实体主体 地址---源码位置
│ │
│ └→ .debug_line_str
│
├→ .debug_str_offsets → .debug_str
├→ .debug_addr
├→ .debug_loclists
└→ .debug_rnglists
.debug_frame:相对独立地提供栈帧恢复规则
__debug_info
保存:
- 函数
- 参数
- 局部变量
- 类型
- 结构体成员及偏移
- 命名空间
- 语法作用域
- 内联函数
- 函数地址范围
- 声明位置
- 变量位置规则
它是真正的程序描述,也是 DWARF 最核心的 Section。
__debug_abbrev
编码模板。
如果每个 DIE 都重复:
swift
这是一个函数
下面有子节点
属性一是 name
属性二是 low_pc
属性三是 high_pc
......
数据会非常大。
因此 __debug_abbrev 先定义模板:
swift
模板 2:
类型 = DW_TAG_subprogram
有子节点
属性 = name、low_pc、high_pc、type......
__debug_info 中的 DIE 只需要记录 "我使用模板 2" 以及具体属性值。
所以:
__debug_abbrev决定如何解码__debug_info,两者必须配合读取。
__debug_str
共享字符串。
函数名、类型名、编译器名称和路径会大量重复。
__debug_info 可以不直接存储完整字符串,而只保存索引或偏移:
swift
DW_AT_name → 字符串表中的第 N 项
再通过 __debug_str_offsets 找到 __debug_str 中真正的内容。
__debug_ranges / __debug_rnglists
优化后,一个函数或作用域对应的机器指令不一定连续。
例如一个函数的热路径和冷路径可能被分开放置:
swift
函数 foo:
0x1000~0x1050
0x3000~0x3020
这时仅使用一对 low_pc/high_pc 不够,需要地址范围列表。
DWARF 5 使用 .debug_rnglists 取代了之前版本的 .debug_ranges。
__debug_loclists
它描述的是:
在某段机器地址范围内,调试器应该去哪里找这个变量。
例如优化后,变量 count 可能经历:
swift
0x1000~0x1010:在 x0 寄存器
0x1010~0x1040:在栈上的 SP + 32
0x1040~0x1050:无法恢复,已被优化掉
DWARF 保存的是这种寻找规则,而不是保存崩溃时 count 的实际数值。
__debug_names
名称索引。
例如调试器搜索 add 时,可以先在 __debug_names 中找到对应 DIE,而不必扫描整个 __debug_info。
它主要解决:
swift
函数名 -> DIE
它是加速结构,不是全部调试信息本身。
__debug_line
行号程序。
它不是简单保存:
swift
地址 A → 第 7 行
地址 B → 第 8 行
它保存的是一段由 DWARF 定义的小型指令序列,叫做 Line Number Program。
执行这些指令时,会维护一组状态,例如:
- 当前机器地址
- 当前文件
- 当前源码行
- 当前列
- 是否适合作为断点
- 是否是函数序言或尾声
比如:
swift
Address Line Column
0x100000378 7 0
0x100000380 8 24
0x100000384 8 34
0x100000388 8 26
0x10000038c 8 9
0x100000390 9 12
因此查询 0x100000388,可以得到:
swift
函数:add
文件:<stdin>
行:8
列:26
.debug_info 和 .debug_line 的区别
这是最容易混淆的地方。
.debug_info 中可能有:
swift
DW_AT_name ("add")
DW_AT_decl_line (1)
这里的第 1 行表示:
函数
add声明在第 1 行。
.debug_line 中可能有:
swift
Address 0x1c → Line 3, Column 12
这里的第 3 行表示:
CPU 执行到地址
0x1c时,对应源码第 3 行。
因此:
swift
DW_AT_decl_line: 这个函数或变量在哪里声明?
.debug_line: 当前机器地址对应哪一行正在执行的源码?
崩溃地址恢复源码行,主要依赖 .debug_line。
Mach-O
Mach-O 是 Apple/Darwin 平台使用的一种二进制文件格式。
它解决的问题是:编译完成后,只有一串机器指令还不够,系统还必须知道:
- 这些指令属于
arm64还是x86_64 - 文件是可执行程序、动态库还是目标文件
- 哪些字节应该映射到内存的什么位置?
- 哪些区域可读、可写或可执行
- 程序依赖哪些动态库
- 程序从哪里开始执行
- 哪些地址需要在加载时调整
- 符号表、代码签名、调试信息位于哪里
Mach-O 就是组织这些信息的 "二进制容器格式"。
swift
Objective-C / Swift / C
↓ 编译
机器指令
↓ 按 Mach-O 格式组织
Mach-O 文件
↓ 被系统解析和加载
内存中的可执行映像
整体结构
一个单架构 Mach-O,也称为 thin Mach-O,可以简化为:

真实文件里,各块的具体位置由 Load Commands 中的偏移和大小决定。
另外,__TEXT 的文件范围通常从偏移 0 开始,因此 Mach Header 和 Load Commands 往往也处于 __TEXT 的映射范围内。
| 部分 | 作用 |
|---|---|
| Mach Header | 说明文件是什么架构、什么类型 |
| Load Commands | 描述有哪些内容、位于哪里、应该如何加载 |
| Segment | 说明一段文件数据怎么映射到内存 |
| Section | 说明 Segment 中某一小块数据是什么 |
__TEXT |
机器代码、只读常量、栈展开信息 |
__DATA |
可写全局数据 |
__DWARF |
调试信息,包括地址到源码行的映射 |
__LINKEDIT |
动态链接、符号、字符串表、代码签名等辅助数据 |
Mach Header
64 位 Mach-O 使用 mach_header_64,大小为 32 字节,字段字段包括:
swift
mach_header_64
├── magic 是否为 64 位 Mach-O
├── cputype CPU 架构
├── cpusubtype CPU 子类型
├── filetype 文件类型
├── ncmds Load Command 数量
├── sizeofcmds 所有 Load Command 的总大小
└── flags 文件特性
常见 filetype:
| 外在形式 | Mach-O 类型 | 作用 |
|---|---|---|
编译生成的 .o |
MH_OBJECT |
尚未完成最终链接的目标文件 |
| App 内的主程序 | MH_EXECUTE |
可以启动的主可执行文件 |
.dylib |
MH_DYLIB |
动态库 |
| 可加载插件 | MH_BUNDLE |
运行时加载的代码模块 |
| dSYM 内部的 DWARF 文件 | MH_DSYM |
只保存配套调试信息 |
Mach-O 不只代表可执行程序,很多不同文件都可以是 Mach-O。
这里有几个容易混淆的地方:
.app是一个目录包,不是 Mach-O 文件,它里面的主程序才是 Mach-O.framework也是目录结构,动态 Framework 里面的主二进制通常是MH_DYLIB.a是 archive 容器,里面通常装着多个 Mach-O.o文件.dSYM是目录包,里面真正保存 DWARF 的文件依然是一个 Mach-O,类型为MH_DSYMMH_BUNDLE不等于.app bundle,这里指可动态加载的 Mach-O 类型
Load Commands
Load Commands 是文件中的 "目录和加载说明"。
例如:
swift
LC_SEGMENT_64:__DWARF
├── __debug_info 的位置和大小
├── __debug_line 的位置和大小
└── __debug_str 的位置和大小
它们和实际数据的关系是:
swift
Load Commands 中的 Section 描述
│
│ offset + size
▼
文件中真正的 Section 数据
Segment 与 Section
Segment 是内存映射单位,Section 是数据分类单位。
完整名称通常写成:
Segment 名, Section 名
例如:
__TEXT是 Segment,包含代码和只读数据__text是__TEXT中保存机器指令的 Section__DWARF是可选 Segment__debug_line是其中保存地址与源码行号映射的 Section
Universal / Fat Mach-O
一个文件也可以同时包含多种 CPU 架构:
swift
┌─────────────────────────────────┐
│ Fat Header │
│ 记录每个架构的位置和大小 │
├─────────────────────────────────┤
│ arm64 Mach-O slice │
│ ├── Mach Header │
│ ├── Load Commands │
│ └── Segment Data │
├─────────────────────────────────┤
│ x86_64 Mach-O slice │
│ ├── Mach Header │
│ ├── Load Commands │
│ └── Segment Data │
└─────────────────────────────────┘
每个 slice 都是一份独立、完整的 Mach-O,外层 Fat Header 只负责把它们装在一个文件中。
怎样产生并运行?
swift
源代码
↓ 编译器、汇编器
Mach-O .o(MH_OBJECT)
↓ 链接器 ld
最终 Mach-O(MH_EXECUTE / MH_DYLIB)
↓ 启动程序
内核识别 Mach-O
↓
dyld 读取 Load Commands
↓
映射 Segment、加载动态库、修正地址
↓
进入程序入口
- 编译器把源码转换成机器指令
.o已经是 Mach-O,但还不能作为完整程序运行- 链接器合并多个
.o,生成最终 Mach-O dyld是 Apple 提供的动态加载器,用来读取 Mach-Odyld根据 Load Commands 加载依赖库并修正运行时地址
dSYM
dSYM 是某一次构建生成的二进制文件的外置调试信息包。
它不参与 App 运行,也不负责捕获崩溃。它的作用是留在开发侧,供调试器或符号化服务,把崩溃中的十六进制地址还原为:
- 函数名
- 源文件
- 源码行号
- 内联调用关系
整体结构
虽然经常说是 "dSYM 文件",但实际上它是一个目录包:
swift
MyApp.app.dSYM
└── Contents
├── Info.plist
└── Resources
└── DWARF
└── MyApp
| 部分 | 作用 |
|---|---|
| Myapp.app.dSYM | 最外层目录包 |
| Info.plist | dSYM 包的基本元数据 |
| Resources/DWARF/MyApp | 真正保存调试信息的二进制文件 |
里面的 MyApp 是一个 MH_DSYM 类型的 Mach-O:
swift
MyApp.app.dSYM
└── Contents/Resources/DWARF/MyApp
┌──────────────────────────────┐
│ Mach Header │
│ filetype = MH_DSYM │
├──────────────────────────────┤
│ Load Commands │
│ LC_UUID │
│ LC_SEGMENT_64:__DWARF │
├──────────────────────────────┤
│ __DWARF │
│ ├── __debug_info │
│ ├── __debug_line │
│ ├── __debug_str │
│ ├── __debug_abbrev │
│ └── ... │
└──────────────────────────────┘
Apple 的 Mach-O 定义将 MH_DSYM 描述为只包含调试 Section 的配套文件。
所以:
dSYM 是一个目录包,包里面使用 Mach-O 文件承载调试数据。
怎样生成
基本生成流程是:
swift
源文件
↓ 编译
多个 .o 目标文件
每个 .o 中带有调试信息片段
↓ 链接
最终可执行文件
同时保留"最终地址对应哪个 .o"的信息
↓ dsymutil
收集各个 .o 中的调试信息
按照最终链接地址进行合并和调整
↓
MyApp.app.dSYM
dsymutil 是 LLVM/Xcode 工具链提供的命令行工具。
它只负责在链接完成后收集并链接调试信息,不负责编译源码、不负责运行程序,也不负责捕获崩溃。
dsymutil会根据最终可执行文件中的调试映射信息,找到各个目标文件里的 DWARF,将它们链接成默认的.dSYMbundle。LLVM: dsymutil
可以这么理解:
swift
Foo.o
├── foo() 的调试信息
└── foo() 在 Foo.o 中的临时地址
最终链接结果
└── foo() 被放到了最终地址 0x100003400
dsymutil
└── 生成最终调试映射:
0x100003400
→ foo()
→ Foo.m
→ 第 42 行
所以 dSYM 里的地址信息对应的是最终链接结果,而不是某个 .o 中尚未链接的临时位置。
Xcode 什么时候生成
在 Build Setting 中搜 Debug Information Format,默认 Release 下的设置是:
DWARF with dSYM File
Archive 之后的目录结构:
swift
MyApp.xcarchive
├── Products
│ └── Applications
│ └── MyApp.app
└── dSYMs
└── MyApp.app.dSYM
- Debug 构建默认可以把调试符号放在编译产物中
- Release 构建通常把调试符号放入配套 dSYM,减少分发 App 的大小
- Archive 时,Xcode 会将二进制和对应 dSYM 一起保存进
.xcarchive
dSYM 与构建怎么匹配
dSYM 最重要的身份信息是构建 UUID。
如果 App 的二进制:
swift
UUID: A12B3456-7890-1234-5678-ABCDEF123456 (arm64)
那么对应的 dSYM 必须是:
swift
UUID: A12B3456-7890-1234-5678-ABCDEF123456 (arm64)
只有两者的 UUID 一致,才能认为它们匹配。
即使用相同源码重新构建,只要工具链或构建设置发生变化,生成的二进制和 UUID 就可能不同,旧的 dSYM 不能用于新的二进制。
一个 App,多个 dSYM
一个 App 包可能有多个 dSYM:
swift
主 App Mach-O → 主 App 的 dSYM
动态 Framework → Framework 自己的 dSYM
App Extension → Extension 自己的 dSYM
其他动态库 → 各自对应的 dSYM
每个二进制 ------ 主程序、Framework、App Extension,都有自己的 dSYM。
对于多架构二进制,一个 dSYM 内部也可能包含多个架构 slice,每个架构有自己的 UUID。
三者的关系
| 概念 | 本质 | 解决的问题 |
|---|---|---|
| DWARF | 调试信息的数据格式 | 怎样表示函数、变量、源码文件、行号和地址映射 |
| Mach-O | Apple 平台的二进制容器格式 | 怎样组织代码、数据、加载信息和可选调试信息 |
| dSYM | 某个二进制的外置调试信息包 | 怎样把该二进制的调试信息独立保存、归档和上传 |
在结构关系上:
App 中的主程序:
swift
MyApp.app
└── MyApp ← Mach-O 可执行文件
├── Mach Header
├── Load Commands
│ ├── LC_UUID
│ ├── LC_SEGMENT_64:__TEXT
│ ├── LC_SEGMENT_64:__DATA
│ └── ...
├── __TEXT ← 机器代码
├── __DATA ← 运行数据
├── __LINKEDIT ← 链接、符号、签名信息
└── __DWARF ← 可选
对应的 dSYM:
swift
MyApp.app.dSYM ← dSYM 目录包
└── Contents
└── Resources
└── DWARF
└── MyApp ← MH_DSYM 类型的 Mach-O
├── Mach Header
├── Load Commands
│ ├── LC_UUID
│ └── LC_SEGMENT_64:__DWARF
└── __DWARF
├── __debug_info
├── __debug_line
├── __debug_str
├── __debug_abbrev
└── ...
所以其实是两个配套的 Mach-O:
swift
运行用 Mach-O 调试用 Mach-O
MyApp dSYM/.../DWARF/MyApp
├── LC_UUID = A ├── LC_UUID = A
├── __TEXT └── __DWARF
├── __DATA ├── __debug_info
└── __LINKEDIT ├── __debug_line
└── ...
它们通过 UUID 来对应。
所以:
dSYM 外层是目录包,里面仍然是一个 Mach-O,这个内部 Mach-O 主要承载 DWARF。
在 Release 构建中:
swift
源代码
│
│ 编译
▼
多个 .o 文件
每个 .o 本身也是 Mach-O
├── 机器代码片段
└── DWARF 调试信息片段
│
│ 链接
▼
最终 App Mach-O
├── 最终机器代码
├── 最终地址布局
└── 调试映射信息
│
│ dsymutil 收集并链接各个 .o 中的 DWARF
▼
MyApp.app.dSYM
└── 内部 MH_DSYM Mach-O
└── 最终地址对应的 DWARF
所以,三个阶段:
- 编译器产生 DWARF
- 链接器产生最终 Mach-O 地址布局
dsymutil产生与该地址布局对应的 dSYM
符号化
假设你的崩溃报告中:
swift
运行地址:0x104001234
映像加载地址:0x104000000
映像 UUID:A12B...
对应 App Mach-O 的链接基址是:
0x100000000
首先,算出 ASLR,因为每次运行都会有一个随机地址偏移,根据运行加载地址和基地址可以算出来:
swift
ASLR slide
= 0x104000000 - 0x100000000
= 0x04000000
然后可以拿到链接时的地址:
swift
0x104001234 - 0x04000000
= 0x100001234
然后根据 UUID(Mach-O 中有),找到对应的 dSYM,找到后:
swift
__debug_info
└── 0x100001234 属于函数 sendRequest()
__debug_line
└── 0x100001234 对应 FRNetworkManager.m:128
最终得到:
swift
0x104001234
→ -[FRNetworkManager sendRequest:]
→ FRNetworkManager.m:128
因此三者的分工是:
swift
Mach-O
└── 确认这是哪个映像,并完成运行地址换算
dSYM
└── 为这个具体构建提供独立调试资料
DWARF
└── 将链接地址解释为函数、文件和行号
总结
符号化的本质,是利用构建阶段提前生成的调试映射,把运行时机器地址反向转换为函数名、源文件和代码行号。
三个核心概念:
| 概念 | 核心作用 |
|---|---|
| DWARF | 规定函数、类型、变量、地址和源码行号等调试信息怎样编码 |
| Mach-O | 组织 Apple 平台的机器代码、运行数据、加载信息和可选调试数据 |
| dSYM | 独立保存某个具体 Mach-O 构建所对应的 DWARF 调试信息 |
它们在构建阶段形成了下面的关系:
swift
源代码
↓ 编译
Mach-O .o
├── 机器代码片段
└── DWARF 调试信息片段
↓ 链接
最终 App Mach-O
├── 最终机器代码
├── 最终地址布局
└── 构建 UUID
↓ dsymutil 收集并链接 DWARF
dSYM 目录包
└── MH_DSYM 类型的 Mach-O
├── 相同构建 UUID
└── 最终地址对应的 DWARF
App 运行后,Mach-O 被加载为进程中的一个映像。由于 ASLR,调用栈记录的是运行时地址,而 DWARF 描述的是链接时地址,因此符号化需要以下数据:
- 调用栈中的运行时地址
- 映像的运行时加载地址
- Mach-O 的链接基址
- 映像的 CPU 架构和 UUID
- UUID、架构相匹配的 dSYM
完整符号化过程是:
swift
运行时地址
↓ 根据地址范围确定所属映像
读取映像加载地址、链接基址和 UUID
↓
ASLR slide = 运行加载地址 - 链接基址
↓
链接地址 = 运行时地址 - ASLR slide
↓ 根据 UUID 和架构选择正确 dSYM
读取其中的 DWARF
↓
__debug_info 确定函数、作用域和内联关系
__debug_line 确定源文件、行号和列号
↓
函数名 + 源文件 + 代码行号
因此三者最终分工:
swift
Mach-O
└── 描述二进制映像、地址布局、架构和 UUID
dSYM
└── 保存这个具体构建对应的外置调试资料
DWARF
└── 将链接地址解释为函数、文件、行号和变量信息
需要注意的是:
- Mach-O 不一定包含完整 DWARF
- dSYM 不是 DWARF 格式本身,而是承载 DWARF 的目录包
- dSYM 不负责捕获崩溃,也不参与 App 正常运行
- 文件名、版本号和源码相同都不能证明 dSYM 匹配
- 二进制与 dSYM 必须具有相同架构和构建 UUID
__debug_info中的声明行号不等于崩溃执行行号,运行地址到源码行主要依赖__debug_line- 优化可能导致函数内联、代码重排或变量被删除,因此即使 dSYM 正确,也不保证恢复所有源码级状态