开头
经常崩溃的朋友对调用栈都不陌生,比如,下面是在一个空的 Swift 项目中的 ViewControoler 写入一个崩溃:
override func viewDidLoad() {
super.viewDidLoad()
let arrayA = [1...10]
print(arrayA[10])
}
执行到这里时,项目会崩溃,我们会得到这样的调用栈:
一眼就能看出崩溃是在哪个方法产生的,本地开发的时候可能习以为常,但是线上运行的时候,当你的代码已经被转成一堆二进制,怎么去恢复这个调用栈,从而能够定位到问题,就不再是一件简单的事情。
这篇文章就来探讨这个问题。
什么是调用栈?
这里有几个概念,线程栈、栈帧、调用栈。
线程栈和调用栈,可以理解成 "同一个东西的两个视角"。
线程栈是一段真实的内存地址范围。
每个线程都有自己的栈空间,用来管理函数调用产生的栈帧和部分局部数据。
某一次函数调用在当前线程栈中占用的部分,叫做一个栈帧。
而 调用栈,强调当前尚未返回的函数调用顺序。每调用一个函数,就增加一个栈帧;函数返回后,对应的栈帧消失。
其实就像一辆火车一样,线程栈是这辆火车,栈帧就像一节节车厢,每增加一个函数调用,就加一节车厢。
主线程的线程栈
└── main()
└── viewDidLoad()
└── startCrashFlow()
└── loadData()
└── triggerCrash() ← 当前执行位置/栈顶
知道这个结构之后,我们来看函数调用中究竟发生了什么,不过在此之前,需要先知道一些寄存器的概念。
寄存器
寄存器是 CPU 内部的一小组高速存储位置。CPU 执行机器指令时,需要用它们保存:
- 当前执行到哪条指令
- 当前线程栈在哪里
- 函数返回后去哪里
- 函数参数和返回值
- 正在参与计算的整数、地址或中间结果
- 最近一次 CPU 异常的底层信息
每条线程都有自己的一套 "逻辑寄存器状态"。
啥意思呢?
当操作系统把 CPU 从线程 A 切换到线程 B 的时候,会保存 A 的寄存器、恢复 B 的寄存器。这个过程就是所谓的 上下文切换。
寄存器可以分两块去看,一块是 basic,一块是 exception。
ARM64 下有 31 个 64 位通用寄存器:
x0~x7:整数 / 指针参数和返回值;调用者保存x8:间接返回大型结果x9~x15:临时寄存器;调用者保存x16、x17:IP0/IP1,连接器跳板和临时计算x18:Apple 平台保留,不应使用x19~x28:被调用函数需要负责恢复的寄存器x29 / fp:Frame Pointer,栈帧指针x30 / lr:Link Register,函数返回地址
另外,还有一些特殊的寄存器:
sp:Stack Pointer,线程当前栈顶pc:Program Counter,当前执行指令地址cpsr / nzcv:CPU 状态和条件标志
异常状态下,还可能包含:
far:发生内存访问错误的地址esr:异常原因编码
fp
FP 是 Frame Pointer,帧指针,在 ARM 64 中对应 x29。
一个函数被调用后,会在当前线程栈上建立自己的栈帧。FP 通常指向当前栈帧中的 frame record,frame record 保存:
- 上一层函数的 FP
- 返回上一层函数的地址
所以它可以形成:
当前 FP
-> 上一层 FP
-> 再上一层 FP
-> ...
这是展开调用栈的一条重要路径。
lr
LR 是 Link Register,链接寄存器,对应 x30。
ARM64 执行带链接的函数调用指令时,会把返回地址放入 LR:
函数 A 调用函数 B
↓
LR 保存 B 执行完后返回 A 的地址
sp
SP 是 Stack Pointer,栈指针。
指向当前线程栈的活动位置。
每条线程都有自己的栈,因此也有自己的 SP。
pc
Program Counter,程序计数器。
表示线程当前执行到的对应指令位置。
知道这个之后,再看函数调用。
函数调用
下面这段代码:
@inline(never)
func add(_ a: Int, _ b: Int) -> Int {
let result = a + b
return result
}
let total = add(10, 20)
使用编译器生成汇编代码,会得到(省略了一部分):
_main:
sub sp, sp, #64
stp x29, x30, [sp, #48]
add x29, sp, #48
mov w8, #10
mov x0, x8
mov w8, #20
mov x1, x8
bl _$s4main3addyS2i_SitF
adrp x8, _$s4main5totalSivp@PAGE
str x0, [x8, _$s4main5totalSivp@PAGEOFF]
; print(total) 对应的汇编不属于本节,省略
ldp x29, x30, [sp, #48]
add sp, sp, #64
ret
然后是 add 方法:
_$s4main3addyS2i_SitF: // add
sub sp, sp, #32
str xzr, [sp, #24]
str xzr, [sp, #16]
str xzr, [sp, #8]
str x0, [sp, #24]
str x1, [sp, #16]
adds x8, x0, x1
str x8, [sp]
cset w8, vs
tbnz w8, #0, LBB1_2
b LBB1_1
LBB1_1:
ldr x0, [sp]
str x0, [sp, #8]
add sp, sp, #32
ret
LBB1_2:
brk #0x1
汇编中的一些概念
首先是寄存器,前面已经说过了,但是这里还出现了一个 xzr 寄存器,它其实是 Zero Regsister,读取它永远得到零。
还有 w8 寄存器,其实它是 x8 的低 32 位,因为 ARM64 的寄存器是 64 位的,x8 是完整的 64 位。
执行:
mov w8, #10
会把 10 写入 w8,并把 x8 的高 32 位清空,所以最终:
x8 = 10
立即数
汇编中的 # 表示直接写在指令里的常量:
#64
#32
#10
#20
例如:
sub sp, sp, #64
表示:
sp = sp - 64
内存地址写法
[sp, #24]
表示 sp 指向的地址,再向高地址偏移 24 字节。
例如:
str x0, [sp, #24]
表示把 x0 的值写入:内存地址 sp + 24。
指令
一些常用指令的含义:
| 指令 | 含义 |
|---|---|
| mov | 把一个值复制到寄存器 |
| sub | 做减法 |
| add | 做加法,不更新状态标志 |
| adds | 做加法,同时更新 CPU 状态标志 |
| str | Store Register,把寄存器写入内存 |
| ldr | Load Register,从内存读取到寄存器 |
| stp | Store Pair,一次保存两个寄存器 |
| ldp | Load Pair,一次恢复两个寄存器 |
| bl | Branch with Link,调用函数 |
| ret | 返回调用者 |
| adrp | 取得某个符号所在内存页的基地址 |
| cset | 根据 CPU 状态设置寄存器为 0 或 1 |
| tbnz | 检查某一位,不为零就跳转 |
| b | 无条件跳转 |
| brk | 触发处理器断点异常 |
在这个例子中,最重要的是:
bl
ret
sub sp
add sp
调用函数、返回到调用者、做加法、做减法,这个过程就体现了函数的调用和返回。
下面逐句分析,先从 _main 开始。
_main
Swift 顶层代码被编译成了 Mach-O 的 _main 函数。
_main:
sub sp, sp, #64
stp x29, x30, [sp, #48]
add x29, sp, #48
mov w8, #10
mov x0, x8
mov w8, #20
mov x1, x8
bl _$s4main3addyS2i_SitF
adrp x8, _$s4main5totalSivp@PAGE
str x0, [x8, _$s4main5totalSivp@PAGEOFF]
; print(total) 对应的汇编不属于本节,省略
ldp x29, x30, [sp, #48]
add sp, sp, #64
ret
可以把 _main 分成几个部分:
- 建立栈帧
- 准备第一个参数
- 准备第二个参数
- 调用
add - 接收返回值
1. 建立栈帧
sub sp, sp, #64
新 sp = 旧 sp - 64
把 sp 减少 64,为 main 分配 64 字节栈空间,也就是当前 main 使用的栈帧空间。
为什么需要分配栈空间呢?
因为 main 需要保存:
- 原来的
x29 / fp - 原来的
x30 / lr - 局部变量
- 调用其他函数时使用的临时数据
x29 就是 fp,帧指针,x30 是 lr,这个是 main 最终应该返回的地址。
接下来:
stp x29, x30, [sp, #48]
把进入 main 时的旧 x29 和 x30 保存到:
sp + 48 = S0 - 16
sp + 56 = S0 - 8
得到:
S0 - 16:旧 x29
S0 - 8 :旧 x30
因为:
- 旧
x29指向调用者的 frame record,后面要靠它回到上一层 - 旧
x30是main自己的返回地址 main后面执行新的bl的话就会覆盖x30,所以要先提前保存
然后:
add x29, sp, #48
执行后:x29 = S0 - 16,也就是让 x29 指向广告保存旧 x29 的位置。
此时:
[x29] = 调用者的 x29
[x29 + 8] = main 的返回地址
这两个连续的 64 位值就叫做 frame record:
x29
│
▼
┌──────────────────────────┐
│ 调用者的 x29 │ ← [x29]
├──────────────────────────┤
│ 当前函数的返回地址 x30 │ ← [x29 + 8]
└──────────────────────────┘
这里不能指向 sp,因为 sp 在函数执行过程中可能继续改变,而 x29 建立后一般不动。
然后,每个 frame record 的第一个值,都是调用者的 x29:
当前 x29
→ [当前 x29]
→ 调用者 x29
→ [调用者 x29]
→ 更上一层 x29
这样就形成一条可以向上追溯的链表。
然后,只要知道 x29,就可以通过固定位置得到:
[x29] → 调用者的 frame record
[x29 + 8] → 当前函数的返回地址ß
这就是调试器还原调用链的一种基础方式。
ARM64 约定要求 frame record 包含 "前一个 FP" 和 "进入当前函数时的 LR",当前 FP 指向这条 frame record,但是 frame record 位于整个栈帧的哪个位置,由编译器决定。
这里偏移为什么是 48?
整个栈帧: 64 字节
frame record: 16 字节
64 - 16 = 48
在这里编译器把 16 字节的 frame record 放在栈帧高地址一端,低地址的 48 字节留给其他数据。
2. 准备参数
mov w8, #10
mov x0, x8
最终得到 x0 = 10。
调用约定规定,第一个普通整数参数使用 x0,所以这里使用的是 x0。
它对应着 a = 10。
然后:
mov w8, #20
mov x1, x8
得到:x1 = 20,对应的是 b = 20。
这里先经过 w8/x8 中转,我用的编译模式是 -Onone,然后这个是编译器生成代码时选择了这个方式,并不是要求必须中转。
这样处理完后,调用前的状态就是:
x0 = 10
x1 = 20
3. 调用 add
bl _$s4main3addyS2i_SitF
这个符号还原后是:
main.add(Swift.Int, Swift.Int) -> Swift.Int
bl 做两件事:
x30 = bl 后面那条指令的地址
pc = add 的入口地址
add 执行结束后,需要知道应该回到 main 的哪里继续执行,所以这里需要把下一条指令写入 x30。
这里需要注意的是:
ARM64 的
bl不会自动建立完整栈帧,也不会自动把所有东西压进栈中。
它只负责跳转并记录返回地址。函数需要多少栈空间,由函数自己的汇编决定。
4. 接收并保存返回值
add 返回后:x0 = 30。
然后:
adrp x8, _$s4main5totalSivp@PAGE
取得顶层变量 total 所在内存页的基地址。
机器指令最终需要的是内存地址,编译器必须先找到 total 的地址,才能写入。
接着:
str x0, [x8, _$s4main5totalSivp@PAGEOFF]
把 x0 中的 30 写进 total,此时 total = 30。
5. main 恢复并返回
ldp x29, x30, [sp, #48]
从进入 main 时建立的 frame record 中恢复:
x29 = 调用者的帧指针
x30 = main 的返回地址
这一步和前面的 stp x29, x30, [sp, #48] 相对应。
然后:
add sp, sp, #64
恢复进入 main 前的栈顶:sp = S0。
所谓的释放栈帧,并不是清空那 64 字节,而是移动 sp,宣布那片空间不再属于当前函数。
最后:
ret
把 x30 中的返回地址送回 pc,回到调用 main 的运行时。
add
_$s4main3addyS2i_SitF:
sub sp, sp, #32
str xzr, [sp, #24]
str xzr, [sp, #16]
str xzr, [sp, #8]
str x0, [sp, #24]
str x1, [sp, #16]
adds x8, x0, x1
str x8, [sp]
cset w8, vs
tbnz w8, #0, LBB1_2
b LBB1_1
LBB1_1:
ldr x0, [sp]
str x0, [sp, #8]
add sp, sp, #32
ret
LBB1_2:
brk #0x1
1. 分配栈空间
sub sp, sp, #32
为 add 分配 32 字节栈空间。
当前编译结果中的布局是:
sp + 24:参数 a
sp + 16:参数 b
sp + 8 :局部变量 result
sp :临时计算结果
这个布局是编译器决定的。
2. 初始化栈槽位
str xzr, [sp, #24]
str xzr, [sp, #16]
str xzr, [sp, #8]
xzr 永远表示零,所以这三条相关栈槽位初始化为零。
3. 保存参数
str x0, [sp, #24]
str x1, [sp, #16]
进入函数时:
x0 = 10
x1 = 20
所以执行后:
[sp + 24] = 10
[sp + 16] = 20
4. 计算加法
adds x8, x0, x1
计算:x8 = 10 + 20 = 30
这里使用的是 adds,Swift 普通 Int 加法需要检查是否有符号整数溢出,adds 在计算结果之外,还会设置 CPU 的状态标志,包括溢出标志 V。
然后:
str x8, [sp]
把结果 30 暂存到栈中,因为接下来的溢出检查会重新使用 w8,编译器先把计算结果保存下来,避免被后面的指令覆盖。
5. 检查溢出
cset w8, vs
vs 表示 Overflow Set:
发生溢出:w8 = 1
没有溢出:w8 = 0
这个例子中没有溢出,所以:w8 = 0。
然后:
tbnz w8, #0, LBB1_2
检查 w0 的第 0 位,如果不为 0,跳转到溢出处理 LBB1_2。
接着:
b LBB1_1
没有进入溢出分支,就跳到正常返回路径。
6. 准备返回值
正常路径:
LBB1_1:
ldr x0, [sp]
从栈中取回刚才保存的计算结果:x0 = 30。
调用约定从 x0 获取简单整数返回值。
然后:
str x0, [sp, #8]
把 30 保存到源码局部变量 result 对应的栈槽位。
7. 释放栈帧并返回
add sp, sp, #32
恢复进入 add 前的栈顶。
必须要先恢复 sp,因为调用者 main 期望看到调用前的栈状态。如果 add 不恢复,main 的栈位置就全部错位。
然后:
ret
跳转到 x30 保存的地址,也就是 main 中 bl add 后面的位置。
8. 溢出路径
LBB1_2:
brk #0x1
如果发生整数溢出,brk 触发处理器异常,不再正常返回。
这条路径不会按照正常函数返回流程继续执行,而是进入了异常处理,这里的 sp 没有恢复。
叶子函数
刚刚 add 函数中,没有设置新的 x29,因为 add 没有继续调用其他函数,它是 叶子函数。
在 add 中:
- 没有新的
bl覆盖x30 - 返回地址可以一直留在
x30 - 编译器不必再把
x30保存到栈上 - 也可以不建立新的
x29 frame record
所以:
分配了栈空间,不代表一定会建立新的帧指针记录。
此时 x29 仍然指向 main 的 frame record。
## 栈溢出
每个线程的栈空间是有限的。每调用一次函数,通常都需要使用一些栈空间,比如刚刚的 Demo 中调用 add:
sub sp, sp, #32
表示 add 需要使用 32 字节栈空间。
假设一个函数不断递归调用自己,那么调用关系就会不断增长,直到超出该线程可以使用的栈空间。此时当代码再访问越界后的栈地址时,就会发生内存访问失败,然后崩溃,这就是 栈溢出。
函数调用小结
在 add 函数中断个点,调试器会显示:
0 add
1 main
但内存里没有直接保存 add、main 这两个函数名称。
真正存在的是:
pc:当前正在执行addsp:add当前使用的栈位置x30:add返回main的地址x29:main的frame record[x29]:main调用者的frame record[x29 + 8]:main的返回地址- CFI:如何恢复上一层寄存器和栈位置
调试器把这些地址映射回 Mach-O 中的函数和符号,才最终显示:
add
main
关键就在于下面这些东西,把它们给串起来:
bl 负责记录 "返回到哪里"
sp 负责管理 "当前函数使用哪片栈空间"
x29/frame record 负责连接上一个栈帧
x0 负责传递参数和返回值
ret 负责回到调用者
CFI 告诉栈展开器怎样恢复上一层状态
结尾
本着短小精悍的态度,这篇文章先不展开如何获取线程调用栈和符号化的这个过程。