欢迎光临
我们一直在努力

【调试】代码crash后 用gdb调试

【调试】代码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

    file 输出,可看到 ELF 与 with debug_info

    关注两个点:

  • 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 文件:

    生成的 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 启动后直接显示崩溃位置

    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 → 栈是否正常

    作用x86-64ARM64一句话说明
    指令指针 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为例)

    函数被调用时(序言):

  • call 指令硬件自动把下一条指令地址写进 ra,然后跳转到被调函数
  • 被调函数序言:sp -= 栈帧大小 分配空间
  • 把 ra 存到 fp-8(固定偏移)
  • 把旧 fp 存到 fp-16(固定偏移)
  • 设置新 fp = sp,后续局部变量都通过 fp 加偏移访问
  • 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 超出栈页范围为止。

    赞(0)
    未经允许不得转载:171主机测评 » 【调试】代码crash后 用gdb调试
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址