APM_符号化

前言

符号化(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
  = 函数名、函数内偏移、源文件和代码行号

另外,回答一下文章开头提出的三个问题:

  1. 这个地址属于哪个 Mach-O?

0x104000000 <= 0x104000958 < 0x104010000

所以它属于 APMDEMO,这个由 Binary Images 地址范围确定。

  1. 它对应这个 Mach-O 中的什么位置?

先计算镜像内偏移:

0x104000958 - 0x104000000 = 0x958

所以它位于:

APMDEMO + 0x958

再换算去除 ASLR 的链接时虚拟地址:

0x100000000 + 0x958 = 0x100000958

因此它有两种等价表示:

  • 镜像内相对位置:0x958
  • 链接时虚拟地址:0x100000958
  1. 哪一份符号能够解释这个位置?

先根据:

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

相关推荐
传奇开心果编程5 小时前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
tink7 小时前
告别 Xcode IDE:用 VS Code + SweetPad + XcodeGen 开发 iOS 应用的完整指南
ios·swiftui·xcode
传奇开心果编程8 小时前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程9 小时前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程9 小时前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
黑科技iOS上架9 小时前
iOS隐私合规扫描工具
ios·审核
传奇开心果编程10 小时前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer
传奇开心果编程10 小时前
【Compose Multiplatform 跨端开发学与练】第2课 Compose 基础语法
android·windows·学习·ui·ios·kotlin·composer
可乐鸡翅yeah_10 小时前
hls.js 缓冲区参数 maxBufferLength、maxBufferSize 通俗讲解与业务调参
开发语言·javascript·ios·音视频·safari·m3u8
茶底世界之下1 天前
Metal 冷启动并发下,Pipeline Cache 为什么必须合并在途编译
ios·swift