linux内核中打印栈回溯信息 - dump_stack()函数分析

本文详细介绍了Linux内核中dump_stack()函数的工作原理及其在ARM架构下的具体实现过程,包括关键寄存器作用、函数调用栈布局及如何通过帧指针回溯函数调用历史。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

linux内核中打印栈回溯信息 - dump_stack()函数分析

转载至: http://blog.youkuaiyun.com/jasonchen_gbd/article/details/45585133

简介

当内核出现比较严重的错误时,例如发生Oops错误或者内核认为系统运行状态异常,内核就会打印出当前进程的栈回溯信息,其中包含当前执行代码的位置以及相邻的指令、产生错误的原因、关键寄存器的值以及函数调用关系等信息,这些信息对于调试内核错误非常有用。

打印函数调用关系的函数就是dump_stack(),该函数不仅可以用在系统出问题的时候,我们在调试内核的时候,可以通过dump_stack()函数的打印信息更方便的了解内核代码执行流程。
dump_stack()函数的实现和系统结构紧密相关,本文介绍ARM体系中dump_stack()函数的实现。该函数定义在arch/arm/kernel/traps.c文件中,调用dump_stack()函数不需要添加头文件,基本上在内核代码任何地方都可以直接使用该函数。

相关基本知识

读者需要了解一些ARM汇编的基本知识。在讲代码之前,我先简单说说内核中函数调用的一般过程。

关键寄存器介绍:

寄存器含义
r0-r3用作函数传参,例如函数A调用函数B,如果A需要向B传递参数,则将参数放到寄存器r0-r3中,如果参数个数大于4,则需要借用函数的栈空间。
r4-r11变量寄存器,在函数中可以用来保存临时变量。
r9(SB)静态基址寄存器。
r10(SL)栈界限寄存器。
r11(FP)帧指针寄存器,通常用来访问函数栈,帧指针指向函数栈中的某个位置。
r12(IP)内部过程调用暂存寄存器。
r13(SP)栈指针寄存器,用来指向函数栈的栈顶。
r14(LR)链接寄存器,通常用来保存函数的返回地址。
r15(PC)程序计数器,指向代码段中下一条将要执行的指令,不过由于流水线的作用,PC会指向将要执行的指令的下一条指令。

内核中的函数栈

内核中,一个函数的代码最开始的指令都是如下形式:

    mov   ip, sp
    stmfd sp!, {r0 - r3} (可选的)
    stmfd sp!, {..., fp, ip, lr, pc}
    ……

从其中两条stmfd(压栈)指令可以看出,一个函数的函数栈的栈底(高地址)的结构基本是固定的,如下图:
这里写图片描述

首先我们约定被调用的函数称为callee函数,而调用者函数称为caller函数。
在进行函数调用的回溯时,内核中的dump_stack()函数需要做以下尝试:

1,首先读取系统中的FP寄存器的值,我们知道帧指针是指向函数栈的某个位置的,所以通过FP的值可以直接找到当前函数的函数栈的地址。
2,得到当前函数的代码段地址,这个很容易,因为当前正在执行的代码(可通过PC寄存器获得)就处在函数的代码段中。在函数栈中保存了一个PC寄存器的备份,通过这个PC寄存器的值可以定位到函数的第一条指令,即函数的入口地址。
3,得到当前函数的入口地址后,内核中保存了所有函数地址和函数名的对应关系,所以可以打印出函数名
4,在当前函数的函数栈中还保存了caller函数的帧指针(FP寄存器的值),所以我们就可以找到caller函数的函数栈的位置。
5,继续执行2-4步,直到某个函数的函数栈中保存的帧指针(FP寄存器的值)为0或非法。

发生函数调用时,函数栈和代码段的关系如下图所示:
这里写图片描述

dump_stack()函数

接下来我们就来看一下dump_stack()函数的实现。
dump_stack()主要是调用了下面的函数

c_backtrace(fp, mode);

两个参数的含义为:
fp: current进程栈的fp寄存器。
mode: ptrace用到的PSR模式,在这里我们不关心。dump_stack传入的值为0x10。
这两个参数分别赋值给r0, r1寄存器传给c_backtrace()函数。
c_backtrace函数定义如下(arch/arm/lib/backtrace.S):

@ 定义几个局部变量
#define frame   r4
#define sv_fp   r5
#define sv_pc   r6
#define mask    r7
#define offset  r8

@ 当前处于dump_backtrace函数的栈中
ENTRY(c_backtrace)
        stmfd   sp!, {r4 - r8, lr}  @ 将r4-r8和lr压入栈中,我们要使用r4-r8,所以备份一下原来的值。sp指向最后压入的数据
        movs    frame, r0   @ frame=r0。r0为传入的第一个参数,即fp寄存器的值
        beq no_frame        @ 如果frame为0,则退出

        tst r1, #0x10       @ 26 or 32-bit mode? 判断r1的bit4是否为0
        moveq   mask, #0xfc000003   @ mask for 26-bit 如果是,即r1=0x10,则mask=0xfc000003,即pc地址只有低26bit有效,且末两位为0
        movne   mask, #0        @ mask for 32-bit 如果不是,即r1!=0x10,则mask=0

        @ 下面是一段和该函数无关的代码,用来计算pc预取指的偏移,一般pc是指向下两条指令,所以offset一般等于8
1:      stmfd   sp!, {pc}       @ 存储pc的值到栈中,sp指向pc。
        ldr r0, [sp], #4        @ r0=sp的值,即刚刚存的pc的值(将要执行的指令),sp=sp+4即还原sp
        adr r1, 1b              @ r1 = 标号1的地址,即指令 stmfd sp!, {pc} 的地址
        sub offset, r0, r1      @ offset=r0-r1,即pc实际指向的指令和读取pc的指令之间的偏移

/*
 * Stack frame layout:
 *             optionally saved caller registers (r4 - r10)
 *             saved fp
 *             saved sp
 *             saved lr
 *    frame => saved pc     @ frame即上面的fp,每个函数的fp都指向这个位置
 *             optionally saved arguments (r0 - r3)
 * saved sp => <next word>
 *
 * Functions start with the following code sequence:
 *                  mov   ip, sp
 *                  stmfd sp!, {r0 - r3} (optional)
 * corrected pc =>  stmfd sp!, {..., fp, ip, lr, pc} //将pc压栈的指令
 */
 @ 函数主流程:开始查找并打印调用者函数
for_each_frame: tst frame, mask     @ Check for address exceptions
        bne no_frame

        @ 由sv_pc找到将pc压栈的那条指令,因为这条指令在代码段中的位置有特殊性,可用于定位函数入口。
1001:       ldr sv_pc, [frame, #0]      @ 获取保存在callee栈里的sv_pc,它指向callee的代码段的某个位置
1002:       ldr sv_fp, [frame, #-12]    @ get saved fp,这个fp就是caller的fp,指向caller的栈中某个位置

        sub sv_pc, sv_pc, offset    @ sv_pc减去offset,找到将pc压栈的那条指令,即上面注释提到的corrected pc。
        bic sv_pc, sv_pc, mask      @ mask PC/LR for the mode 清除sv_pc中mask为1的位,例如,mask=0x4,则清除sv_pc的bit2。

        @ 定位函数的第一条指令,即函数入口地址
1003:       ldr r2, [sv_pc, #-4]    @ if stmfd sp!, {args} exists, 如果在函数最开始压入了r0-r3
        ldr r3, .Ldsi+4             @ adjust saved 'pc' back one. r3 = 0xe92d0000 >> 10
        teq r3, r2, lsr #10         @ 比较stmfd指令机器码是否相同(不关注是否保存r0-r9),目的是判断是否为stmfd指令
        subne   r0, sv_pc, #4       @ allow for mov: 如果sv_pc前面只有mov   ip, sp
        subeq   r0, sv_pc, #8       @ allow for mov + stmia: 如果sv_pc前面有两条指令
        @ 至此,r0为callee函数的第一条指令的地址,即callee函数的入口地址

        @ 打印r0地址对应的符号名,传给dump_backtrace_entry三个参数:
        @ r0:函数入口地址,
        @ r1:返回值即caller中的地址,
        @ r2:callee的fp
        ldr r1, [frame, #-4]    @ get saved lr
        mov r2, frame
        bic r1, r1, mask        @ mask PC/LR for the mode
        bl  dump_backtrace_entry

        @ 打印保存在栈里的寄存器,这跟栈回溯没关系,本文中不太关心
        ldr r1, [sv_pc, #-4]    @ if stmfd sp!, {args} exists, sv_pc前一条指令是否是stmfd指令
        ldr r3, .Ldsi+4
        teq r3, r1, lsr #10 
        ldreq   r0, [frame, #-8]    @ get sp。frame-8指向保存的IP寄存器,由于mov   ip, sp,所以caller的sp=ip
                                    @ 所以r0=caller的栈的低地址。
        subeq   r0, r0, #4      @ point at the last arg. r0+4就是callee的栈的高地址。
                                @ 由于参数的压栈顺序为r3,r2,r1,r0,所以这里栈顶实际上是最后一个参数。
        bleq    .Ldumpstm       @ dump saved registers

        @ 打印保存在栈里的寄存器,这跟栈回溯没关系,本文中不太关心
1004:       ldr r1, [sv_pc, #0]     @ if stmfd sp!, {..., fp, ip, lr, pc}
        ldr r3, .Ldsi       @ instruction exists, 如果指令为frame指向的指令为stmfd sp!, {..., fp, ip, lr, pc}
        teq r3, r1, lsr #10
        subeq   r0, frame, #16 @ 跳过fp, ip, lr, pc,即找到保存的r4-r10
        bleq    .Ldumpstm       @ dump saved registers,打印出来r4-r10

        @ 对保存在当前函数栈中的caller的fp做合法性检查
        teq sv_fp, #0       @ zero saved fp means 判断获取的caller的fp的值
        beq no_frame        @ no further frames   如果caller fp=0,则停止循环

        @ 更新frame变量指向caller函数栈的位置,将上面注释中的Stack frame layout
        cmp sv_fp, frame        @ sv_fp-frame
        mov frame, sv_fp        @ frame=sv_fp
        bhi for_each_frame      @ cmp的结果,如果frame<sv_fp,即当前fp小于caller的fp,则继续循环
        @ 这时frame指向caller栈的fp,由于函数中不会修改fp的值,所以这个fp肯定是指向caller保存的pc的位置的。

1006:       adr r0, .Lbad       @ 否则就打印bad frame提示
        mov r1, frame
        bl  printk
no_frame:   ldmfd   sp!, {r4 - r8, pc}
ENDPROC(c_backtrace)
@ c_backtrace函数结束。

        @ 将上面的代码放到__ex_table异常表中。其中1001b ... 1006b是指上面的1001-1006标号。
        .section __ex_table,"a"
        .align  3
        .long   1001b, 1006b
        .long   1002b, 1006b
        .long   1003b, 1006b
        .long   1004b, 1006b
        .previous

#define instr r4
#define reg   r5
#define stack r6

@ 打印寄存器值
.Ldumpstm:  stmfd   sp!, {instr, reg, stack, r7, lr}
        mov stack, r0
        mov instr, r1
        mov reg, #10
        mov r7, #0
1:      mov r3, #1
        tst instr, r3, lsl reg
        beq 2f
        add r7, r7, #1
        teq r7, #6
        moveq   r7, #1
        moveq   r1, #'\n'
        movne   r1, #' '
        ldr r3, [stack], #-4
        mov r2, reg
        adr r0, .Lfp
        bl  printk
2:      subs    reg, reg, #1
        bpl 1b
        teq r7, #0
        adrne   r0, .Lcr
        blne    printk
        ldmfd   sp!, {instr, reg, stack, r7, pc}

.Lfp:       .asciz  "%cr%d:%08x"
.Lcr:       .asciz  "\n"
.Lbad:      .asciz  "Backtrace aborted due to bad frame pointer <%p>\n"
        .align
.Ldsi:  
        @ 用来判断是否是stmfd sp!指令,并且参数包含fp, ip, lr, pc,不包含r10
        .word   0xe92dd800 >> 10    @ stmfd sp!, {... fp, ip, lr, pc}
        @ 用来判断是否是stmfd sp!指令,并且参数不包含r10, fp, ip, lr, pc
        .word   0xe92d0000 >> 10    @ stmfd sp!, {}
<think>我们讨论的是Linux内核溢出时如何利用dump_stack()分析函数调用链。根据引用[2]和引用[3],我们可以得到以下关键点: 1. **内核溢出检测机制**:当内核配置了`CONFIG_DEBUG_STACKOVERFLOW`时,内核会在任务的底部(或顶部,取决于架构)设置一个特殊的魔数(canary值)。如果这个值被改变,说明发生了溢出,此时内核会触发溢出处理。 2. **dump_stack()的作用**:当检测到溢出时,内核会调用`dump_stack()`函数,该函数打印出当前的函数调用链(即堆回溯信息),帮助开发者定位问题。3.**如何启用**:需要在内核配置中启用以下选项:-`CONFIG_DEBUG_STACKOVERFLOW`:启用溢出检测。- `CONFIG_FRAME_POINTER`(引用[2]):启用帧指针,这样`dump_stack()`可以得到更准确的调用链。4.**分析步骤**:-在发生溢出时,内核打印出调用信息(通过`dump_stack()`)。-开发者可以通过这些信息查看是从哪个函数开始,调用链是怎样的,从而定位导致溢出的函数。 5. **示例**:引用[3]虽然展示的是用户空间的溢出(在GDB中使用`bt`命令查看调用),但在内核空间类似,只是获取方式不同(通过内核日志)。 现在,我们具体说明如何在Linux内核溢出时利用`dump_stack()`分析函数调用链:###步骤1:配置内核以支持溢出检测和帧指针在编译内核前,确保以下配置被启用:```kconfigCONFIG_DEBUG_STACKOVERFLOW=yCONFIG_FRAME_POINTER=y```可以通过`makemenuconfig`或直接修改`.config`文件来设置:- `Kernel hacking` ->`DebugOops,Lockupsand Hangs`-> `[*]Detectstack overflows`(即`CONFIG_DEBUG_STACKOVERFLOW`) -`Kernel hacking` ->`Compile-timechecks andcompiler options` ->`[*] Compile thekernel withframe pointers`(即`CONFIG_FRAME_POINTER`)###步骤2:触发溢出并获取调用链当内核任务溢出时,内核会检测到魔数被破坏,然后调用`dump_stack()`。该函数会将当前任务的调用链打印内核日志(如通过`printk`)。例如,日志中可能出现类似以下的信息: ```------------[ cuthere ]------------kernel stackoverflow in: funcA+0x20/0x40CPU:0 PID:123 Comm: test_program Tainted:GW5.10.0#1Call Trace: [<ffffffff81234567>] funcA+0x20/0x40[<ffffffff81234567>]funcB+0x30/0x50[<ffffffff81234567>]funcC+0x40/0x60...更多调用链... ```###步骤3:分析调用链从调用链(Call Trace)中,我们可以从上到下(或从下到上,取决于架构)查看函数调用的顺序。最顶部的函数(在x86上通常是最后调用的函数)就是导致溢出的最近函数。我们需要检查这些函数中是否有大量的分配(如大数组、深度递归等)。 ###示例:模拟溢出并分析假设我们在内核模块中编写了一个递归函数导致溢出: ```c#include <linux/module.h> #include<linux/kernel.h>static voidrecursive_func(int depth) {charbuffer[1024];//每次递归分配1KB空间if (depth >0) {recursive_func(depth -1);} }static int__inittest_init(void){printk(KERN_INFO "Starting stackoverflow test\n");recursive_func(20); //递归20次,约20KB,超过默认内核大小(通常8KB或16KB)return0;} staticvoid __exit test_exit(void){printk(KERN_INFO "Module unloaded\n"); }module_init(test_init); module_exit(test_exit);MODULE_LICENSE("GPL");``` 当加载此模块时,会触发溢出,内核会检测到并打印调用链。我们通过`dmesg`查看内核日志,可以看到类似:``` [123.456789] Startingstack overflowtest[123.456790] kernelstack overflowin:recursive_func+0x20/0x30[test_module][123.456791] CPU:0 PID:123 Comm: insmod Tainted:GO5.10.0 #1[123.456792] CallTrace:[123.456793][<ffffffffc0001000>] recursive_func+0x20/0x30 [test_module] [123.456794][<ffffffffc0001020>] recursive_func+0x40/0x30 [test_module][123.456795][<ffffffffc0001020>] recursive_func+0x40/0x30 [test_module] ...重复多次 ...[123.456796][<ffffffffc0001020>]recursive_func+0x40/0x30[test_module][123.456797][<ffffffffc0001040>]test_init+0x20/0x1000 [test_module] [123.456798][<ffffffff810022a7>]do_one_initcall+0x57/0x1e0...其他内核初始化函数 ...``` 从调用链中,我们看到`recursive_func`被递归调用了多次,最终导致溢出。 ###注意事项1. **大小**:内核大小有限(通常8KB或16KB),因此应避免在上分配大数组或深度递归。 2. **调试符号**:为了调用链中的函数名可读,需要确保内核或模块编译时带有调试符号(`CONFIG_DEBUG_INFO`)。3.**实时性**:溢出可能导致系统不稳定,因此有时可能无法完整打印调用链。在这种情况下,可以结合`kdump`和`crash`工具分析vmcore(引用[1]提到的coredump机制在内核层面的应用)。###相关问题1.如何配置Linux内核以捕获溢出?2.内核大小是多少?如何调整?3.除了`dump_stack()`,还有哪些内核调试手段可以分析溢出?4.用户态程序溢出和内核溢出有何异同?5.如何在内核模块中安全地使用递归?通过以上步骤,我们可以利用`dump_stack()`在内核溢出时获取函数调用链,从而定位问题代码。</think>### Linux 内核溢出时利用 `dump_stack()` 分析函数调用链指南 #### 一、核心原理与配置 1. **溢出检测机制** - 内核通过 `CONFIG_DEBUG_STACKOVERFLOW` 启用溢出保护[^2] -底设置 "金丝雀值" (canary),当该值被覆盖时触发溢出检测 - 溢出时会强制切换到安全(Overflow Stack)执行诊断代码 2. **关键配置选项** 编译内核时需启用: ```kconfig CONFIG_DEBUG_STACKOVERFLOW=y # 启用溢出检测 CONFIG_FRAME_POINTER=y # 启用帧指针(确保完整调用链)[^2] ``` #### 二、`dump_stack()` 工作流程 当检测到溢出时,内核自动触发: ```mermaid graph LR A[溢出发生] --> B[检测金丝雀值损坏] B --> C[切换到 Overflow Stack] C --> D[调用 dump_stack()] D --> E[打印函数调用链到内核日志] E --> F[安全终止进程] ``` #### 三、实战分析步骤 1. **触发溢出** 示例代码(内核模块): ```c #include <linux/module.h> void recursive_func(int depth) { char buffer[1024]; // 大局部变量消耗空间 if (depth > 0) recursive_func(depth - 1); // 深度递归 } static int __init test_init(void) { recursive_func(20); // 触发溢出 return 0; } module_init(test_init); ``` 2. **获取调用链信息** 内核日志会输出类似内容: ``` [ 123.456789] Kernel stack overflow in process: test_module/pid:789 [ 123.456790] Call Trace: [ 123.456791] [<ffffffff81234567>] recursive_func+0x20/0x40 [ 123.456792] [<ffffffff81234589>] recursive_func+0x42/0x40 [ 123.456793] [<ffffffff81234589>] recursive_func+0x42/0x40 [ 123.456794] [<ffffffff810022a7>] test_init+0x57/0x80 [ 123.456795] [<ffffffff81001234>] do_one_initcall+0x44/0x1d0 ``` 3. **符号解析与定位** 使用 `addr2line` 工具解析地址: ```bash addr2line -e vmlinux 0x81234567 # 解析 recursive_func 位置 addr2line -e vmlinux 0x810022a7 # 解析 test_init 位置 ``` 输出示例: ``` /path/to/kernel/source/file.c:123 # 精确到源码行号 ``` #### 四、关键分析技巧 1. **调用链解读** - 从下往上读:最底部是初始调用点(`test_init`) - 从上往下读:最顶部是崩溃点(最近的递归调用) - 重复出现的函数:表明递归深度(如示例中的 `recursive_func`) 2. **消耗分析** 计算单次调用消耗: ```c sizeof(buffer) + 函数调用开销 ≈ 1024 + 32 = 1056 bytes ``` 总消耗 = 递归深度 × 单次消耗(示例中 20 × 1056 ≈ 20KB) 3. **结合 GDB 调试** 加载内核镜像: ```bash gdb vmlinux (gdb) list *recursive_func+0x20 # 查看对应汇编代码 (gdb) disas recursive_func # 反汇编函数 ``` #### 五、典型错误模式识别 | 模式 | 原因 | 解决方案 | |------|------|----------| | 重复同一函数 | 无限递归 | 添加终止条件 | | 分配大数组 | 局部变量过大 | 改用动态分配 | | 中断嵌套过深 | 中断处理耗时 | 优化中断处理 | #### 六、高级调试技巧 1. **跟踪增强** 启用 `CONFIG_STACKTRACE` 获取更详细信息: ```c dump_stack(); print_stack_trace(current->stack_trace, 0); // 增强版堆 ``` 2. **动态探测** 在可疑函数插入探测点: ```c #include <linux/stacktrace.h> void debug_func() { unsigned long entries[32]; struct stack_trace trace = { .entries = entries, .max_entries = ARRAY_SIZE(entries), }; save_stack_trace(&trace); print_stack_trace(&trace, 0); } ``` 3. **性能监控** 使用 `CONFIG_DEBUG_STACK_USAGE` 跟踪使用峰值: ```bash cat /proc/<pid>/stack # 查看实时使用 ``` #### 七、常见问题解决 1. **调用链不完整** - 确保启用 `CONFIG_FRAME_POINTER`[^2] - 检查编译器优化级别(-O0 调试更准确) 2. **符号解析失败** - 保存内核编译时的 `System.map` 文件 - 使用 `vmlinux-with-debuginfo` 镜像 3. **日志被冲刷** - 增大内核日志缓冲区: ```bash dmesg -n 8 # 提高日志级别 ``` - 使用 `klogd` 实时保存日志 > 案例:某驱动模块因 8KB 局部数组导致溢出,通过 `dump_stack()` 定位到 `ioctl` 处理函数中的数组声明行[^3]。 --- ### 相关问题 1. 如何配置内核使 `dump_stack()` 包含更多调试信息? 2. 用户态程序溢出(如 `__stack_chk_fail`)与内核态有何差异?[^4] 3. 如何在内核模块中主动触发 `dump_stack()` 进行调试? 4. ARM 架构与 x86 架构的回溯实现有何区别? 5. 如何通过 `perf` 工具监控内核使用情况?
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值