ARM64 调用栈回溯原理

11 阅读6分钟

一、寄存器

要想了解调用栈的获取,需要先了解寄存器和 CPU 的关系(这里不展开介绍,可参考这篇文章)。只介绍用到的几个关键寄存器:

寄存器别名作用
x29FP(帧指针)指向当前函数的栈帧起始位置
x30LR(链接寄存器)保存函数返回地址
SP栈指针始终指向当前栈顶
PC程序计数器当前正在执行的指令地址

ARM64 的调用栈是"向下生长"的,即新的栈帧从低地址方向扩展。


二、函数调用时寄存器的变化

通过一段汇编代码来理解寄存器是如何被更新的:

// 调用者(Caller)
int caller(void) {
    int ret = myFunction();  // 对应汇编:bl _myFunction
    int x = 1;               // bl 返回后执行此处
    return x;
}

// 被调用者(Callee)
void myFunction(void) {
    // 编译器自动插入函数序言(stp / mov)
    // ... 函数体 ...
    // 编译器自动插入函数结尾(ldp / ret)
}

执行 bl(Branch with Link)指令时发生以下事情:

; 调用者代码
0x100001000:  bl    _myFunction      ; 调用函数,同时将下一条指令地址写入 LR
0x100001004:  mov   x0, #1           ; ← 返回地址(LR = 0x100001004)

; 被调用函数
_myFunction:
0x100002000:  stp   x29, x30, [sp, #-16]!   ; ① sp -= 16;② [sp+0] = FP;③ [sp+8] = LR
0x100002004:  mov   x29, sp                  ; 更新 FP 指向新栈帧
              ; ... 函数体 ...
0x100002100:  ldp   x29, x30, [sp], #16      ; 恢复 FP 和 LR
0x100002104:  ret                             ; 跳转回 LR 指向的地址

三条关键指令的作用:

  • bl:自动将下一条指令地址(0x100001004)写入 LR,然后跳转
  • stp x29, x30, [sp, #-16]!:在栈上保存旧的 FP 和 LR,并下移 SP
  • mov x29, sp:将 FP 更新为当前 SP,即新栈帧的起始位置

三、ARM64 栈帧结构

每个函数的栈帧头部固定为 16 字节,布局如下:

┌────────────────┐ ← FP + 8
│  返回地址 (LR)  │   调用者调用本函数时的下一条指令地址
├────────────────┤ ← FP + 0(FP 寄存器指向此处)
│  上一个 FP      │   调用者的帧指针(形成链表)
└────────────────┘

完整的多级调用栈布局(高地址 → 低地址):

High Address(栈底)
    ├─ main() 的栈帧
    │  ┌────────────────┐
    │  │ 0x100004000    │  ← [FP₀+8] main 的返回地址
    │  ├────────────────┤
    │  │ NULL           │  ← FP₀(链表终点)
    │  └────────────────┘
    │
    ├─ funcA() 的栈帧
    │  ┌────────────────┐
    │  │ 0x100001234    │  ← [FP₁+8]main 中调用 funcA 的位置
    │  ├────────────────┤
    │  │ FP₀            │  ← FP₁(指向 main 的栈帧)
    │  └────────────────┘
    │
    ├─ funcB() 的栈帧
    │  ┌────────────────┐
    │  │ 0x100002456    │  ← [FP₂+8] 在 funcA 中调用 funcB 的位置
    │  ├────────────────┤
    │  │ FP₁            │  ← FP₂(指向 funcA 的栈帧)
    │  └────────────────┘
    ↓
Low Address(栈顶,当前执行位置)

四、栈回溯原理

两条核心信息链

信息链作用来源
FP 链遍历栈帧,向上追溯调用者[FP+0] → Previous FP
LR 链定位每一层的调用位置[FP+8] → 返回地址

逐步回溯过程

假设调用链为:main() → funcA() → funcB() → funcC()

步骤 0:读取当前执行位置(第 0 帧)

从寄存器读取:
PC = 0x100003000  ← funcC 内部正在执行的位置

栈帧 #0: 0x100003000(funcC 内部)

步骤 1:回溯到 funcB(第 1 帧)

从寄存器读取:
FP = 0x16fdff0a0  ← funcC 的栈帧起始地址

读取内存 [0x16fdff0a0]:
┌─────────────────┐
│ 0x100002456     │ ← [FP+8] 返回地址(funcB 调用 funcC 后的位置)
├─────────────────┤
│ 0x16fdff0b0     │ ← [FP+0] Previous FP(funcB 的栈帧)
└─────────────────┘

栈帧 #1: 0x100002456(funcB 中调用 funcC 的位置)
下一个 FP = 0x16fdff0b0

步骤 2:回溯到 funcA(第 2 帧)

读取内存 [0x16fdff0b0]:
┌─────────────────┐
│ 0x100001234     │ ← [FP+8] 返回地址(funcA 调用 funcB 后的位置)
├─────────────────┤
│ 0x16fdff0c0     │ ← [FP+0] Previous FP(funcA 的栈帧)
└─────────────────┘

栈帧 #2: 0x100001234(funcA 中调用 funcB 的位置)
下一个 FP = 0x16fdff0c0

步骤 3:回溯到 main(第 3 帧)

读取内存 [0x16fdff0c0]:
┌─────────────────┐
│ 0x100000f00     │ ← [FP+8] 返回地址(main 调用 funcA 后的位置)
├─────────────────┤
│ 0x16fdff0d0     │ ← [FP+0] Previous FPmain 的栈帧)
└─────────────────┘

栈帧 #3: 0x100000f00main 中调用 funcA 的位置)
下一个 FP = 0x16fdff0d0

步骤 4:到达栈底

读取内存 [0x16fdff0d0]:
┌─────────────────┐
│ 0x100004000     │ ← [FP+8] 系统入口点
├─────────────────┤
│ NULL            │ ← [FP+0] Previous FP = NULL → 回溯结束
└─────────────────┘

五、代码实现

// 第一帧:从寄存器直接读取当前 PC
buffer[0] = threadState.__pc;

// 后续帧:遍历 FP 链表
uintptr_t currentFP = threadState.__fp;

while (index < maxSize && currentFP != 0) {
    FrameEntry frame;
    memcpy(&frame, (void *)currentFP, sizeof(frame));
    // 栈帧结构(16 字节):
    // [FP+0] frame.previous       ← 上一个 FP,用于继续遍历
    // [FP+8] frame.returnAddress  ← 返回地址,即调用位置

    if (frame.previous == NULL || frame.returnAddress == 0) {
        break;  // 到达栈底
    }

    buffer[index++] = frame.returnAddress;       // 记录调用位置
    currentFP = (uintptr_t)frame.previous;       // 移动到上一个栈帧
}

六、地址符号化

回溯得到的是原始内存地址,需要通过 dladdr() 转换为可读的函数名:

// 回溯得到的地址数组:
// [0x100003000, 0x100002456, 0x100001234, 0x100000f00]

for (uintptr_t addr : addresses) {
    Dl_info info;
    dladdr((void *)addr, &info);

    // info.dli_fname  镜像路径(如 "/path/to/MyApp")
    // info.dli_sname  符号名(如 "funcC")
    // info.dli_saddr  符号起始地址

    printf("%s + %ld\n", info.dli_sname, addr - info.dli_saddr);
    //                                    ↑ 函数内的字节偏移量
}

偏移量的含义

funcA:
0x100001000:  ...
0x100001230:  bl    funcB        ;  调用 funcB
0x100001234:  mov   x0, #1       ; ← 返回地址(LR = 0x100001234)

回溯时看到的 0x100001234bl funcB下一条指令地址,而非 funcA 的入口地址。
因此输出的 funcA + 52 表示:在 funcA 入口偏移 52 字节处,调用了下一个函数


七、最终输出效果

📚 堆栈信息:
═══════════════════════════════════════
 0  0x100003000  MyApp  funcC + 0
 1  0x100002456  MyApp  funcB + 86
 2  0x100001234  MyApp  funcA + 52
 3  0x100000f00  MyApp  main + 240
 4  0x100004000  dyld   start + 0