您的操作系统的堆栈跟踪

该帖子是从 exampleOS的存储库 导入

什么?

堆栈跟踪显示每个函数调用的位置,直到调用堆栈跟踪本身为止。 本质上,它告诉您代码所采用的路径。

为什么?

堆栈跟踪对于调试目的非常有用,因为函数调用可能有多个路径。 确定引起函数调用的路径很重要。

哪里?

在exampleOS中,只要内核出现紧急情况,我们就会自动打印堆栈跟踪。

怎么样?

调试器有两种打印堆栈跟踪的方式。 他们可以解析由编译器生成的DWARF调试格式,也可以通过遵循堆栈基本指针来“遍历堆栈”。 exampleOS使用后一种方法,因为它简单得多,并且无需加载内核符号即可工作。 这使我们甚至在内核启动过程的早期就产生了堆栈跟踪。

通话说明

使用汇编调用指令来调用函数:

call a_function 

在链接阶段,将a_function替换为函数的地址。 更重要的是调用指令的工作方式。

当处理器到达调用指令时,它将当前指令指针保存到堆栈中。 这称为“寄信人地址”。 这样一来,一旦调用的函数返回,它就知道在哪里继续。 我们稍后使用它来打印函数调用的位置。 当被调用函数返回时,处理器检查堆栈,弹出返回地址,然后跳转到返回地址。

框架指针

编译器可以选择向每个函数添加帧指针 。 启用帧指针后,在每个函数的开头插入了两个重要的汇编指令:

 push rbp 
mov rbp, rsp

这是调用函数时堆栈的外观:

(顺便说一句, rbp旨在表示寄存器基址指针 。这是因为它始终指向当前堆栈帧的顶部或底部 。如今,不再需要此功能,因此通常用作通用寄存器。)

从图中可以看出,返回地址始终位于rbp存储的地址之上。 因此,为了找到最后一个函数的返回地址,我们只需将8(因为在长模式下寄存器的大小为8字节)添加到rbp存储的地址。

启用框架指针

Rust编译器具有用于启用帧​​指针的标志:
RUSTFLAGS=-Cforce-frame-pointers=yes
这是在此处的编译器源中引用的。

对于C和C ++编译器,搜索词为no-omit-frame-pointer

代码

您可以在此处查看exampleOS的堆栈跟踪器的完整实现。

首先,我们必须获取rbp寄存器的内容:

 let mut base_pointer: *const usize; 
unsafe { asm!("mov rax, rbp" : "={rax}"(base_pointer) ::: "intel") }

然后,为了获得返回地址,我们增加指针并取消引用它:

 let return_address = unsafe { *(base_pointer.offset(1)) } as usize; 
println!("Call site: {}", return_address);

因为offset是基于数据类型的大小( usize ,大小为8个字节)而不是字节数,所以我们增加1。 请注意,在Rust中,取消引用原始指针被认为是不安全的。

最后,我们需要“遍历堆栈”并获取所有先前堆栈帧的返回地址。 为此,我们将当前基本指针设置为上一个堆栈帧的基本指针:

 base_pointer = unsafe { (*base_pointer) as *const usize }; 

然后我们只需要重复前面的所有代码,就可以得到一个不错的循环:

 let mut base_pointer: *const usize; 
unsafe { asm!("mov rax, rbp" : "={rax}"(base_pointer) ::: "intel") } loop {
let return_address = unsafe { *(base_pointer.offset(1)) } as usize;
println!("Call site: {}", return_address);
base_pointer = unsafe { (*base_pointer) as *const usize };
}

这不是一个无限循环吗?

没错,此循环将永远继续下去,直到取消引用无效的内存地址为止(并使处理器抛出错误)。 这是因为我们不知道堆栈何时结束。 有一个简单的解决方案:在进入内核函数之前,将基本指针设置为零,然后在达到等于零的基本指针时停止循环。

这是exampleOS中的相关文件:boot_entry.asm,具体来说,此行:

 xor rbp, rbp 

现在,当调用boot_entry函数时, boot_entry值0压入堆栈。 我们要做的就是更新循环:

 // ... 
while !base_pointer.is_null() {
// ...
}

将所有内容放到自己的函数中,然后就完成了!

最后的想法

现在,您只会在堆栈跟踪中看到数字。 您可以使用objdump将这些数字映射到内核的汇编代码。 稍后,我们将能够加载内核符号表,然后打印出函数名称。 exampleOS的堆栈跟踪实现可以做到这一点。