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

相关推荐
ii_best2 小时前
ios群控鹰眼中控系统越狱版 PC 端 v3.1.9 版本更新|三大核心功能迭代,优化群控投屏作业流程
ios
二流小码农2 小时前
鸿蒙开发:了解Context
android·ios·harmonyos
时代分流3 小时前
云手机推荐2026:安卓全能、批量挂机、原生iOS全覆盖
android·ios·智能手机
catchadmin14 小时前
NativePHP v4 让 Blade 构建原生 iOS 与 Android 界面
android·ios·ai·php
weixin_4038101317 小时前
iOS免越狱自动化三种方式对比:代理模式、蓝牙HID与OTG HID怎么选
ios·自动化·跨境电商·ios脚本·苹果群控·苹果投屏·苹果脚本
_瑞20 小时前
APM_函数调用栈展开
ios·编译原理
极客猴子1 天前
会议记录带日历功能的iOS录音软件推荐:按日期查找会议内容
ios
2501_915918411 天前
Rust 程序抓包解密,rustls 不认系统证书的几种办法
开发语言·后端·网络协议·ios·adb·https·rust
MaoJiu1 天前
「Landmark-Driven Beauty Rendering:实时磨皮与腮红的算法与 GPU 实现」
ios