[娱乐性任务][Rust] 获取内核调用栈

离.nvme0n1p2
🔖︎ 1 订阅
👍︎ 8 点赞
🔝︎ 已置顶

前言

相信大家或多或少都遇到过这样的窘境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
	// ...
void *va = disk_addr(blockno);
debugf("Checkpoint 1");
if (block_is_mapped(blockno)) {
debugf("Checkpoint 2");
if (isnew) {
*isnew = 0;
}
} else {
debugf("Checkpoint 3");
if (isnew) {
*isnew = 1;
}
try(syscall_mem_alloc(0, va, PTE_D));
debugf("Checkpoint 4");
ide_read(0, blockno * SECT2BLK, va, SECT2BLK);
}
// ...

当我们反复采用print大法debug时,我们其实是想知道哪一行代码导致了panic或非预期的结果。显然,逐行添加调试信息的方法既不优雅,又不高效。我们希望有一种更优雅的方式来定位错误代码,像 gdb 的 backtrace 命令那样:

image20240528204325548.png

经过一些搜索和研究,我发现在mips确实可以实现获取调用栈的功能。在本文中,我将介绍获取内核调用栈的原理。

image20240528204957745.png

(符号就无解了,与objdump对照着看吧......)

前置条件

请复习 MIPS Calling Convention 中关于栈帧、函数 Prologue 和 Epilogue、非页函数有关的内容。

实现原理

MIPS 调用约定

  1. 寄存器使用

    我们知道, 在MIPS的函数调用中,这些寄存器是比较重要的:

    • $sp:栈指针寄存器,指向当前栈顶
    • $fp:帧指针寄存器(不是在所有调用约定中都被使用)
    • $ra:返回地址寄存器,存储被调用函数的返回地址
  2. 函数的 Prologue 和 Epilogue

    在一个函数的函数体前后分别存在的两段代码。

    前面的代码被称为 Prologue,用于设置栈指针和帧指针,以及保存需要保存的寄存器;

    后面的代码被称为 Epilogue, 用于恢复调用前的状态。

    以一个简单的非叶函数为例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
       ; fn recursive_fn() {
    ; *** Prologue ***
    addiu $sp, $sp, -0x18 # 获取栈空间
    sw $ra, 0x14($sp) # 保存寄存器
    sw $fp, 0x10($sp)
    move $fp, $sp # 设置帧指针
    ; *** function body ***
    ; recursive_fn();
    jal recursive_fn # 函数调用
    nop
    ; }
    ; *** Epilogue ***
    move $sp, $fp # 设置栈指针,一般没什么用
    lw $fp, 0x10($sp) # 恢复寄存器
    lw $ra, 0x14($sp)
    addiu $sp, $sp, 0x18 # 释放栈空间
    jr $ra # 返回主调函数
    nop

    其中 Prologue 部分获取了 0x18 字节的栈空间,将当前 $ra$fp 存入其中;

    Epilogue (尽管在这个示例中它并不会执行) 部分按照 Prologue 的顺序恢复了 $fp$ra,并释放了栈空间。

实现思路

基本原理

假设我们有一个获取调用栈的函数fn backtrace(),显然,调用它的函数,调用调用它的函数的函数,... ...(套娃中),直到最上层的函数都是非叶函数,也就是说,它们的栈帧中都保存有返回地址$ra。如果我们能逐层解析栈帧中的返回地址,就可以追踪函数的调用。

mips-stackframe

根据这张栈帧结构图,我们已经知道栈指针$sp,如前所述,我们还需要得知return address,而为了在追踪完当前函数后,继续追踪它的调用者,我们还需要当前帧的大小fsize

获取 $rafsize

不幸的是,$ra在栈帧中保存的位置并不固定,fsize也不存储于栈帧中,我们必须通过其他方法获取它们的值。

函数的Prologue派上用场的时候到了。因为要获取栈空间,函数的Prologue总会出现这条指令:

1
addiu	$sp, $sp, IMM      # 0x27bdxxxx

其中 IMM 是一个16位的有符号数,它的绝对值就是栈帧的大小 fsize,而指令的高16位则是固定的 0x27bd。这样我们可以从函数的开头向后遍历其指令,从而获取fsize

类似地,我们可以利用

1
sw     $ra, offset($sp)    # 0xafbfxxxx

找到$ra在栈中的偏移量,从而获取$ra

递归获取调用栈

找到fsize后,用$sp加上这个值,就得到上一层函数的栈帧位置。我们从$ra指向的指令向前遍历,同样可以从Prologue中获取fsize$ra,重复这一步骤直到无法获得合法的$ra,就得到了完整的内核调用栈。

(因为调用过程中的函数一般都遵循MIPS调用约定,所以不必担心没有遍历到Prologue就退出的情况)

调用栈与Debug

我获得了调用栈信息,然后呢?

  1. 确保你在用Debug模式构建

    如果你不清楚自己是不是在用Debug模式构建,那么你就是在用Debug模式构建

  2. 使用objdump反编译

    • 你在使用MOS

      make objdump

    • 你在使用Rust

      rust-objdump -DS <kernel_file> > <output_file>

  3. 打开反编译的文件,搜索 RA 或 Subroutine 字段的值

image20240528224034696.png

image20240528224157978.png

image20240528224226572.png

​ 4. 针对性检查对应函数实现

参考代码

实现了基于Rust的内核栈追踪,可以很轻易地迁移到C语言或用户空间。

码风略烂,欢迎提出改进意见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
fn backtrace() -> String {
let mut current_ra: usize;
let mut current_sp: usize;

unsafe {
asm!(
"move $8, $29",
"move $9, $31",
out("$8") current_sp,
out("$9") current_ra,
);
}
let mut result = String::new();
unsafe {
// only works when "-C", "force-frame-pointers=yes" is set
// haven't investigated why
let mut depth = 0;
let mut stack_size: usize = 0;
let mut ra_offset: usize;
const INST_OP_MASK: usize = 0xffff0000;
const ADDIU_SP_INST: usize = 0x27bd0000;
const JR_RA_INST: usize = 0x03e00008;
const SW_RA_INST: usize = 0xafbf0000;

result.push_str("Backtrace:\n");

let mut current_addr = backtrace as *const () as usize;

while stack_size == 0 {
let inst = *(current_addr as *const usize);
if (inst & INST_OP_MASK) == ADDIU_SP_INST {
stack_size = ((inst & 0xffff) as i16).unsigned_abs() as usize; // Bytes
} else if inst == JR_RA_INST {
break;
}
current_addr += size_of::<usize>();
}

current_sp += stack_size;

// only track backtrace within kernel space
while (KSEG0..KSEG1).contains(&current_ra) {
result.push_str(&format!(
" {:02}: RA:0x{:08x} SP:0x{:08x}",
depth, current_ra, current_sp
));
depth += 1;
current_addr = current_ra;
ra_offset = 0;
stack_size = 0;
while stack_size == 0 || ra_offset == 0 {
let inst = *(current_addr as *const usize);
if (inst & INST_OP_MASK) == ADDIU_SP_INST {
stack_size = ((inst & 0xffff) as i16).unsigned_abs() as usize;
// Bytes
} else if (inst & INST_OP_MASK) == SW_RA_INST {
ra_offset = inst & 0xffff; // Bytes
stack_size = 0; // Stack size is always set BEFORE ra_offset,
// so anything after that is garbage
} else if inst == 0x3c1c0000 { // LUI GP, 0x0
// Not quite sure why it's here, just
// copied
return result;
}
current_addr -= size_of::<usize>();
}

current_ra = *((current_sp + ra_offset) as *const usize);
current_sp += stack_size;
result.push_str(&format!(
" Subroutine:0x{:08x}\n",
current_addr + size_of::<usize>()
));
}
}
result
}

参考资料

  1. arch/mips/kernel/stacktrace.c
  2. Back-tracing in MIPS-based Linux Systems
0%