【调试】代码crash后 用gdb调试
本文通过一个 C 语言段错误的小例子,完整走一遍 Linux 下 Crash 定位的思路:
先走「有调试信息」的快速路线(GDB + 符号表),再走「没有调试信息」的硬核路线(寄存器 + 反汇编)
测试环境:Ubuntu 20.04
文章目录
- 【调试】代码crash后 用gdb调试
-
- 整个文章脉络
- 1. 实验准备:写一个会崩的程序
-
- 1.1 先理解段错误
- 1.2 示例代码
- 2. 编译生成 ELF,并确认调试信息
- 3. 运行程序:程序挂了
- 4. 生成 core 文件:crash 的现场快照
-
- 4.1 WSL 的坑:core 不生成在当前目录
- 5. 路线一:有调试信息,用 GDB 快速定位
- 6. 路线二:没有调试信息,纯汇编定位
-
- 6.1 第一步:看寄存器现场
- 6.2 第二步:看当前汇编指令
- 6.3 第三步:为什么需要 objdump?
- 6.4 第四步:为什么 objdump 的地址和 RIP 不一样?
- 6.5 第五步:怎么找基地址?
- 6.6 第六步:objdump 反汇编,定位偏移处的指令
- 6.7 第七步:结合寄存器,得出结论
- 8. 总结:两条路线对比
- 附录 A: x/i \\$rip
- 附录B :寄存器与 backtrace
-
- 三个寄存器
- 一次函数调用发生了什么
- 关于LR寄存器
- 附录C 函数栈trace
-
- 函数执行顺序与回溯(rsic-v为例)
- 函数栈帧
- 函数调用时栈是指怎么长出来的
整个文章脉络
全文围绕一个问题展开:程序崩溃了,怎么定位?
制造一次崩溃:写一个会段错误的 C 程序(sensor_process 向空指针写入 123),用 -g 编译生成带调试信息的 ELF,运行触发 SIGSEGV,并生成 core 文件(crash 现场快照)。
路线一:有调试信息—— 用 GDB + 符号表,一条 bt 直接看到崩溃函数和调用栈,info registers / disassemble 进一步确认现场。
路线二:无调试信息—— 从寄存器现场(rip / rsp / rax)出发,用 x/i $rip 反汇编崩溃指令,再通过 info proc mappings 计算加载基址、objdump 定位 ELF 内的指令偏移,手工还原崩溃原因。
两条路线 最终都指向同一个结论:空指针写入 → SIGSEGV。
1. 实验准备:写一个会崩的程序
1.1 先理解段错误
段错误(Segmentation Fault,简称 Segfault)是指程序访问了无效的内存地址,或者访问了没有权限访问的内存区域:CPU 触发异常,操作系统向进程发送 SIGSEGV 信号,默认情况下终止程序运行。
本质上是程序违反了操作系统的内存保护机制。
Linux 通过虚拟内存 + 页表管理进程地址空间,每个虚拟地址都有对应的权限(是否存在、是否可读、是否可写、是否可执行)。当程序访问的地址无法通过页表转换,或者权限不满足时,CPU 触发缺页异常或保护异常,最终由内核发送 SIGSEGV 信号。
1.2 示例代码
#include <stdio.h>
void sensor_process(int *data)
{
printf("sensor processing\\n");
// 模拟驱动访问非法地址
*data = 123;
}
void camera_task()
{
int *buffer = NULL;
sensor_process(buffer);
}
int main()
{
printf("camera start\\n");
camera_task();
return 0;c
}
这是一个典型的段错误:往地址 0 写数据。
2. 编译生成 ELF,并确认调试信息
gcc main.c -o camera\\_app -g
-g 表示保留调试信息。
什么是 ELF? ELF(Executable and Linkable Format)是 Linux 可执行文件的标准格式,内核通过它加载程序。调试信息是 ELF 里一张「地址 → 源码行」的映射表,GDB 正是靠它把崩溃地址翻译成具体的函数名和代码行。
确认一下生成的文件:
file camera\\_app

关注两个点:
ELF:这是 Linux 可执行文件。
with debug_info:地址 → 源码行的映射已保留,后面 GDB 定位要依赖它。
准备工作完成。
3. 运行程序:程序挂了
./camera_app

此时你只知道「程序挂了」,但不知道挂在哪一行、为什么挂 —— 这正是后面调试的意义。
4. 生成 core 文件:crash 的现场快照
core 文件是程序 crash 时的现场快照,里面保存:
-
PC / RIP(崩溃时正在执行的指令地址)
-
寄存器现场
-
调用栈
-
进程内存
默认情况下 Linux 禁止生成 core 文件,先放开限制:
ulimit -c unlimited
再运行一次:
./camera_app
此时 ls 应该能看到生成的 core 文件:

补充:ulimit -c unlimited
ulimit
是 Linux 对进程资源的限制命令。执行
ulimit -a
可以看到
core file size 0
即默认
禁止生成 core 文件
ulimit -c unlimited
= 允许生成 core 文件,且大小不限。
-c
:针对 core 文件大小
unlimited
:不限大小
4.1 WSL 的坑:core 不生成在当前目录
如果你用的是 WSL,core 文件可能不会出现在当前目录。
原因:WSL 不是一个完整独立的 Linux 发行版环境,它的内核与 Windows 有特殊集成,crash 处理机制被 WSL 接管了。
普通 Ubuntu 的流程:
camera_app
│
▼
访问非法地址
│
▼
CPU 触发异常
│
▼
Linux Kernel 收到 SIGSEGV
│
▼
根据 core\\_pattern 生成 core 文件
而 WSL 下查看内核参数:
cat /proc/sys/kernel/core_pattern
会得到:
|/wsl-capture-crash %t %E %p %s
开头的 | 表示管道模式:程序崩溃后由 wsl-capture-crash 接管,而不是直接落盘。
解决办法:修改内核参数,把接管者改回默认的文件名模式 core:
echo "core" | sudo tee /proc/sys/kernel/core_pattern
这样 core 文件就会生成在当前目录。
5. 路线一:有调试信息,用 GDB 快速定位
gdb camera_app core

GDB 启动后会直接告诉你:程序崩在 sensor_process 函数,并给出 PC 地址(崩溃时正在执行的指令地址)。
**为什么 GDB 知道是 **
sensor_process 因为 ELF 里有符号,建立了「地址 → 函数」的映射:
地址 函数
0x58e6d6fb5169 sensor_process
输入 bt(backtrace)查看调用栈:
调用栈是怎么 trace 出来的?看寄存器。输入:
info registers

再反汇编崩溃函数,确认崩溃指令:
disassemble sensor_process

小结
有调试信息时,GDB 借助符号表直接把崩溃点翻译成函数名和调用栈,一条 bt
就能定位问题 —— 这是日常工作里最快的定位方式。
6. 路线二:没有调试信息,纯汇编定位
再准备一个去掉调试信息的版本:
gcc main.c -o camera_ap_no_debug # 不加 -g,模拟无调试信息
此时 GDB 只知道 CPU 崩溃时的 PC / RIP 地址,不知道这个地址对应哪个函数、哪一行代码 —— 它只能告诉你「程序 crash 时 CPU 正在执行的位置」,但 ELF 里没有符号,查不到函数名:

接下来按下面完成定位
6.1 第一步:看寄存器现场
info registers

重点看 rip、rsp、rax,类似:
rax 0x0
rip 0x58e6d6fb5169
为什么看 rax? 因为我们怀疑崩溃语句 *(data) = 123 最终被编译成:
mov [rax],123
如果 rax == 0,说明写入的是空地址 —— 空指针。
6.2 第二步:看当前汇编指令
x/i $rip
输出:
这一步非常重要。我们只能问:CPU 当时正在执行什么机器指令?(x/i $rip 的完整拆解见附录 A。)
6.3 第三步:为什么需要 objdump?
GDB 不知道 0x58e6d6fb5169 是什么,但 ELF 里还保留着机器码。退出 GDB,把整个 ELF 反汇编成文本:
quit
objdump -d camera\\_app\\_strip > asm.txt
grep -n "movl" asm.txt
但这里会遇到一个问题 —— 见下一步。
(注意,后续camera_app_no_debug 都改成了camera_app_strip)
6.4 第四步:为什么 objdump 的地址和 RIP 不一样?
你之前看到的 RIP 是 0x58e6d6fb5169(运行时地址),而 objdump 里可能只有 1169(文件内偏移)。为什么?
因为程序运行时,Linux 加载器会给 ELF 加一个加载基地址:
ELF 内代码地址: 0x1169
运行时加载基址: + 0x58e6d6fb4000
───────────────────────────────────
实际运行地址: 0x58e6d6fb5169 (= RIP)
所以要反算:
ELF 内偏移 = PC – 加载基址
6.5 第五步:怎么找基地址?
重新进入 GDB,输入:
gdb camera_app_strip core
info proc mappings
输出 
类似输出:
Start End
0x58e6d6fb4000 0x58e6d6fb5000
第一个起始地址 0x58e6d6fb4000 就是加载基址。计算偏移:
0x58e6d6fb5169 – 0x58e6d6fb4000 = 0x1169
6.6 第六步:objdump 反汇编,定位偏移处的指令
objdump -d camera_app_strip | less
找到 0x1169 附近:
1165:
mov -0x8(%rbp),%rax
1169:
movl $0x7b,(%rax)
即使没有函数名,我们也还原出了崩溃指令。

6.7 第七步:结合寄存器,得出结论
回到寄存器现场:rax = 0,汇编是 movl $0x7b,(%rax),含义是 *(rax) = 123。代入:
*(0) = 123
结论:空指针写入 → 访问 NULL 地址 → SIGSEGV。
小结 没有调试信息时,定位链路变成「寄存器现场 → 反汇编 → 算偏移 → 定位指令 → 结合寄存器还原现场」。
如果没有符号表,则会根据 PC 地址找到对应代码段,通过反汇编分析对应指令,并结合寄存器现场还原当时执行状态。
| PC 地址 | rip = 0x58e6d6fb5169 |
| 找到代码段 | 计算 ELF 偏移 0x1169 |
| 反汇编 | objdump -d |
| 分析指令 | movl $0x7b,(%rax) |
| 查看寄存器 | rax = 0 |
| 还原现场 | 空指针写入 |
8. 总结:两条路线对比
| 符号表 | 有 | 无 |
| 核心手段 | bt / info registers / disassemble | x/i $rip / objdump / info proc mappings |
| 定位速度 | 快,直接看到函数与调用栈 | 慢,需手工还原指令与地址 |
| 适用场景 | 开发期、本地可复现 | 线上现场、release 版、嵌入式日志 |
实战提示 真实嵌入式 crash 往往只有一行日志(如 第一反应应该是:找到 PC → 反汇编 → 看指令是否访问了非法地址 → 查对应寄存器是否为空指针。
如果没有符号表,则会根据 PC 地址找到对应代码段,通过反汇编分析对应指令,并结合寄存器现场还原当时执行状态。
| PC 地址 | rip = 0x58e6d6fb5169 |
| 找到代码段 | 计算 ELF 偏移 0x1169 |
| 反汇编 | objdump -d |
| 分析指令 | movl $0x7b,(%rax) |
| 查看寄存器 | rax = 0 |
| 还原现场 | 空指针写入 |
附录 A: x/i $rip
一句话:x/i $rip = 查看当前 RIP 地址处的机器码,并反汇编成汇编指令。
拆开看:
-
x:examine,即「按某种格式查看某个地址里的内容」。例如 x 0x400000 就是查看地址 0x400000 里存的内容。
-
/i:查看格式,把机器码翻译成汇编指令。
GDB 常见格式:
| x | 十六进制 |
| d | 十进制 |
| u | 无符号十进制 |
| c | 字符 |
| s | 字符串 |
| i | 汇编指令 |
- $rip:读取 CPU 的 RIP 寄存器(x86-64 的指令指针,即「CPU 当前正在执行的下一条指令地址」),ARM 上对应的是 PC。
执行流程:
RIP
│
▼
找到该地址的机器码
│
▼
翻译成汇编指令
结合实验:x/i $rip 把 0x58e6d6fb5169 处的机器码翻译成了 movl $0x7b,(%rax),告诉你 CPU 正在执行「把 123 写入 rax 指向的地址」。
为什么它很重要? 因为真实场景下你常常没有源码。分析 crash 的第一步永远是:找到 PC → 反汇编 → 看指令 → 查寄存器。这条链路在 x86 和 ARM 上是通用的:
| x86-64 | $rip | x/i $rip | movl $0x7b,(%rax) | 向 rax 指向的地址写 123,rax = 0 → 空指针写入 |
| ARM64 | $pc | x/i $pc | ldr x1,[x0] | 从 x0 指向的地址读取,x0 = 0 → 空指针读取 |
附录B :寄存器与 backtrace
三个寄存器
PC → 当前哪里崩
LR → 谁调用过来
SP → 栈是否正常
| 指令指针 PC | RIP | PC | CPU 正在执行的指令地址,crash 时最先看它 |
| 栈指针 SP | RSP | SP | 栈顶位置,压栈 / 弹栈由它维护 |
| 返回地址 | 存在栈上(无专用寄存器) | LR(X30) | 函数返回后要回到的地址 |
| 帧指针 FP | RBP | X29(FP) | 当前栈帧的底,回溯调用链的关键 |
-
PC / RIP:程序计数器。x/i $rip 就是在读它(见附录 A)。
-
SP / RSP:栈指针。函数调用时栈向下增长,每压一个数 SP 减小。
-
LR(Link Register,ARM64):ARM 用 BL(Branch with Link)调用函数时,会把返回地址(调用处的下一条指令)自动存入 LR;函数用 RET 返回时,CPU 跳回 LR 指向的地址。
-
x86-64 的差异:x86 没有 LR,call 指令把返回地址压进栈,ret 再从栈弹出。返回地址链完全靠栈维护。
一次函数调用发生了什么
以 x86-64 为例,调用 func2() 时:
call func2:把返回地址压栈(RSP -= 8),跳转进入 func2;
push %rbp:保存调用者(func1)的帧指针;
mov %rsp, %rbp:把当前栈顶设为 func2 的帧底(frame pointer);
分配局部变量空间,RSP 继续减小。
于是每一层函数都在栈上留下一个栈帧(stack frame)
高地址
┌───────────────────────────┐
│ func1 的栈帧 │
│ … │
│ func1 的 RBP(帧底) │
├───────────────────────────┤
│ 返回地址 → func1 │ ← call func2 压入
│ 保存的 RBP → func1 │ ← push %rbp
│ func2 的局部变量 │
│ … │
│ func2 的 RBP(帧底) │
├───────────────────────────┤
│ 返回地址 → func2 │
│ 保存的 RBP → func2 │
│ func3 的局部变量 │
│ … │
│ RSP(栈顶) │
└───────────────────────────┘
低地址
注意每个帧的开头两个字段:上一帧的 RBP 和返回地址。它们把所有帧串成一条链。
bt 的原理:沿着帧指针链往上走
-
ARM64 的帧指针是 X29(FP),返回地址存在 X30(LR)。
-
函数开头通常执行 stp x29, x30, [sp, #-16]!:把 FP 和 LR 一起压栈,然后 mov x29, sp 建立新帧。
-
回溯同样沿 FP 链走:每帧的 [fp] 保存上一帧 FP,[fp + 8] 保存返回地址(LR)。
嵌入式 crash 日志里最常见的三样就是:PC(崩溃点指令)、LR(上一层调用点)、SP(崩溃时的栈顶)。有了这三样 + 反汇编,就足以还原现场
关于LR寄存器
LR 是 Link Register,用于保存函数调用的返回地址。ARM执行 BL 指令跳转到目标函数时,会同时把下一条指令地址保存到 LR,函数执行结束后通过 LR 返回调用者。在异常分析时,PC 用于定位当前崩溃位置,而 LR 可以帮助恢复调用路径,判断是哪个函数调用导致的问题。如果发生栈破坏或内存越界,LR可能被覆盖,导致返回地址异常,这也是定位内存踩坏的重要依据。
ARM 的 BL 指令在跳转到目标函数的同时,会把下一条指令地址保存到 LR(x30),作为函数返回地址。由于 ARM64 指令长度固定为4字节,所以通常表现为 LR 等于 BL 指令地址加4。函数通过 ret 指令将 LR 恢复到 PC,从而返回调用者。
- ARM 用 BL(Branch with Link)指令调用函数,它同时做两件事:
把下一条指令的地址写入 LR(X30),作为返回地址;
跳转到目标函数。
-
函数结束时用 RET 返回:CPU 把 LR 的值写回 PC,程序回到调用处继续执行。本质上就是 PC = LR。
-
因为 ARM64 指令定长 4 字节,所以返回地址通常表现为 LR = BL 指令地址 + 4。
-
因此在日志里看到 LR,可以反推「上一条 BL 调用发生在哪里」—— 这就是 LR 回答 “谁调用过来的” 的原理。
附录C 函数栈trace
函数执行顺序与回溯(rsic-v为例)
函数被调用时(序言):
fp 指向栈帧的高地址端(天花板),栈从高往低长,所以:
- 第一个存的 ra 离 fp 最近 → fp – 8
- 第二个存的旧 fp 紧跟其后 → fp – 16
backtrace 回溯逻辑:
backtrace 回溯逻辑:
当前 fp 指向本帧天花板
↓
读 fp-8 → 拿到本帧的返回地址 ra (这是"我从哪被调进来的")
↓
读 fp-16 → 拿到上一层的 fp (这是"上一层帧在哪")
↓
把上一层 fp 设为当前 fp,重复
backtrace 不找变量,它只做一件事:打印每层的返回地址,还原调用链。变量在哪是调试器的事,不是 backtrace 的事。
回溯时(backtrace):
从当前 fp 出发,读 fp-8 得到返回地址,读 fp-16 得到上一层 fp,跳上去重复,直到栈底。本质就是一个链表遍历,每个节点存了 “返回地址” 和 “上一层节点指针” 两个字段。
函数栈帧
每个函数的栈帧里,有两个位置是固定的:
- fp – 8:存放返回地址(ra),即这个函数执行完后要回到调用者的哪条指令
- fp – 16:存放调用者的 fp,指向上一层栈帧的天花板
局部变量、参数这些位置不固定,编译器想怎么放就怎么放,但这两个偏移是 ABI 调用约定规定死的,backtrace 只依赖这两个固定位置。
函数调用时栈是指怎么长出来的
以 A 调 B、B 调 C 为例:
- call 指令执行时,CPU 硬件自动把下一条指令地址写进 ra,然后跳转到被调函数
- 被调函数的序言(prologue)做三件事:sp -= 栈帧大小(分配空间)、把 ra 存到栈上、把旧 fp 存到栈上并设置新 fp
- 栈从高地址向低地址生长,每调一层 sp 就往下减一块
- 函数返回时,尾声反向操作:从栈上恢复 ra 和 fp,sp += 栈帧大小,然后 ret 跳回 ra 指向的地址
核心就是一个链表遍历:当前 fp 指向本帧天花板 → *(fp-8) 拿到本帧的返回地址 → *(fp-16) 拿到上一层的 fp → 重复,直到 fp 超出栈页范围为止。

