看懂堆栈:看懂堆栈

#AI生成 #GPT

Crash 排查指南

基于 khhicar_serv SIGSEGV 崩溃排查实践总结。

1. Crash Dump 基础

1.1 Dump 结构

Reason:Signal:SIGSEGV(SEGV_MAPERR)@0x003179786f725074
│                │              │
│                │              └── 崩溃地址(内核记录,CPU 访问的非法地址)
│                └── 信号类型(MAPERR=地址未映射,ACCERR=权限不足)
└── 崩溃信号
  • SEGV_MAPERR:访问的虚拟地址没有映射到任何物理内存或文件
  • SEGV_ACCERR:地址已映射,但权限不对(如写只读内存)

1.2 寄存器表

Registers:
x0:0000007548b44d60 x1:0000000000000081 ...
  • x0~x30 是 ARM64 通用寄存器
  • pc:程序计数器,指向当前执行的指令地址
  • lr:链接寄存器,存函数返回地址
  • sp:栈指针

2. 反汇编

2.1 为什么需要反汇编

crash dump 给出 PC 地址,但你不知道 CPU 在执行什么指令。反汇编把机器码翻译成人能读的汇编。

2.2 架构匹配

objdump 必须匹配目标架构。在 x86 主机上反汇编 aarch64 的 so:

# 错误:主机 objdump 不认识 aarch64
objdump -d lib.so  # → can't disassemble for architecture UNKNOWN!

# 正确:用交叉工具链
llvm-objdump -d lib.so
# 或
aarch64-linux-gnu-objdump -d lib.so  # 需要 sudo apt install binutils-aarch64-linux-gnu

2.3 读懂反汇编输出

1b6e8: 28 01 40 f9   ldr x8, [x9]
│        │            │
│        │            └── 汇编助记符(人能读的)
│        └── 机器码(CPU 实际执行的二进制,4 字节)
└── 该指令在 .text 段中的偏移地址

objdump 输出的地址是相对于 so 基地址的偏移量,不是运行时绝对地址。crash dump 中的 PC 也通常是偏移量(工具已自动减去基地址),所以可以直接对上。

2.4 安装交叉工具链

sudo apt install binutils-aarch64-linux-gnu

3. ARM64 汇编速查

3.1 常见指令

指令 含义 C 类比
ldr x8, [x9] 从 x9 指向的地址读 8 字节到 x8 x8 = *(uint64_t*)x9
ldr w8, [x9] 从 x9 指向的地址读 4 字节到 w8 x8 = *(uint32_t*)x9
str x8, [x9] 把 x8 的值写到 x9 指向的地址 *(uint64_t*)x9 = x8
mov x19, x0 把 x0 的值复制到 x19 x19 = x0
cbz x9, addr 如果 x9 == 0 则跳转 if (x9 == 0) goto addr
b addr 无条件跳转 goto addr
bl addr 调用函数(保存返回地址到 lr) func()
udiv x8, x23, x25 无符号除法 x8 = x23 / x25
msub x26, x8, x25, x23 乘减 x26 = x23 - x8 * x25(即取余)

3.2 ldr 的字节数

目标寄存器的位宽决定读取字节数:

  • ldr x8, [x9]:x8 是 64 位 → 读 8 字节
  • ldr w8, [x9]:w8 是 32 位 → 读 4 字节

3.3 ARM64 调用约定(ABI)

寄存器 用途
x0 第一个参数 / 返回值
x1~x7 第 2~8 个参数
x8 间接返回值地址
x9~x15 临时寄存器(caller-saved)
x16~x17 PLT/链接器用
x18 平台保留
x19~x28 callee-saved(函数保证不变)
x29 帧指针(FP)
x30 链接寄存器(LR,返回地址)
sp 栈指针

成员函数的第一个参数 x0 = this 指针。

3.4 数组寻址

ldr x9, [x8, x26, lsl #3]

等价于 x9 = *(x8 + x26 * 8),即 x9 = array[x26](每个元素 8 字节)。lsl #3 = 左移 3 位 = 乘 8。

4. 从崩溃指令到根因

4.1 排查链路

SIGSEGV @非法地址
    ↓
反汇编 PC 处指令 → 确定是哪条指令、用哪个寄存器存地址
    ↓
查寄存器表 → 找到寄存器的值 = 崩溃地址
    ↓
分析寄存器值 → 是合法地址?是字符串?是 0?
    ↓
判断破坏类型 → 空指针?use-after-free?数据竞争?栈溢出?
    ↓
grep 代码找所有访问点 → 分析并发、生命周期、锁保护

4.2 寄存器值分析

判断寄存器值的类型:

特征 含义
0x0000000000000000 空指针
0x00000075... 用户态堆地址(正常)
0xFFFF... 可能是内核地址或 -1
高位为 0 + 低位全是可打印 ASCII 字符串内容覆盖了指针(内存被踩)
小整数(1、2、8 等) 计数器、标志位、size

ASCII 快速判断:把值按字节拆开,看是否连续可打印:

x9:003179786f725074
→ 74='t' 50='P' 72='r' 6f='o' 78='x' 79='y' 31='1' 00='\0'
→ "tProxy1" — 字符串,不是地址

4.3 常见崩溃模式

空指针解引用:寄存器值为 0 或接近 0。 Use-after-free:寄存器值看起来像地址,但指向已释放的内存,可能被其他数据覆盖。 数据竞争:多线程并发读写共享数据,无锁保护,导致内部数据结构被破坏。 栈溢出:SP 寄存器值异常,或递归过深。

5. 虚拟地址空间

5.1 64 位 Linux/OpenHarmony 布局

0xFFFF_FFFF_FFFF_FFFF  ┌─────────────┐
                       │   内核空间    │  用户态不可访问
0xFFFF_0000_0000_0000  ├─────────────┤
                       │   空洞      │  未映射,访问即 SIGSEGV
0x0000_7FFF_FFFF_FFFF  ├─────────────┤
                       │   栈 ↓      │  高地址往低增长
                       │   mmap 区   │  共享库、大块分配
                       │   堆 ↑      │  malloc 分配
                       │   .data     │  全局变量
                       │   .text     │  程序代码
0x0000_0000_0000_0000  └─────────────┘
  • 用户态地址范围:0x0000_0000_0000_0000 ~ 0x0000_7FFF_FFFF_FFFF
  • 内核态地址范围:0xFFFF_8000_0000_0000 ~ 0xFFFF_FFFF_FFFF_FFFF
  • 中间空洞:未映射,访问触发 SIGSEGV,不占物理内存
  • 堆地址典型范围:0x75...0x7f...
  • 设计目的:用户态和内核态之间隔巨大无效区域,防止越界访问

5.2 为什么不需要全部映射

虚拟地址空间可以远大于物理内存。进程看到的是虚拟地址,物理内存在真正访问时才按需分配(page fault)。空洞 = 没有映射的虚拟地址,不消耗物理内存。

6. C++ 数据结构崩溃排查

6.1 unordered_map 内部结构

class unordered_map {
    bucket*  bucket_array;  // 偏移 0:bucket 数组指针
    size_t   bucket_count;  // 偏移 8:桶数量
    size_t   element_count; // 偏移 16:元素数量
    float    max_load_factor; // 偏移 24
    ...
};

bucket 数组每个元素是一个链表头指针:

bucket[0] → node → node → NULL
bucket[1] → NULL
bucket[2] → node → NULL
...

6.2 数据竞争的表现

  • bucket 指针变成字符串内容(内存被其他对象占用)
  • 链表指针变成非法值
  • 崩溃点在 emplaceerasefind 等内部操作

6.3 排查方法

# 找所有访问某个容器的代码
grep -rn 'khhicarCallbacks_' --include='*.cpp' --include='*.h'

对每个访问点问:

  1. 读还是写?
  2. 在哪个线程?
  3. 有没有锁保护?

如果多个线程并发读写且无锁 → 数据竞争。

相关笔记

  • 看懂堆栈
  • crash分析
  • gdb 调试
  • processdump命令