开发一个Linux调试器(八):堆栈展开

简介:

开发一个 Linux 调试器(八):堆栈展开

有时你需要知道的最重要的信息是什么,你当前的程序状态是如何到达那里的。有一个 backtrace 命令,它给你提供了程序当前的函数调用链。这篇文章将向你展示如何在 x86_64 上实现堆栈展开以生成这样的回溯。

系列索引

这些链接将会随着其他帖子的发布而上线。

  1. 准备环境
  2. 断点
  3. 寄存器和内存
  4. ELF 和 DWARF
  5. 源码和信号
  6. 源码级逐步执行
  7. 源码级断点
  8. 堆栈展开
  9. 读取变量
  10. 之后步骤

用下面的程序作为例子:

 
 
  1. void a() { 
  2.     //stopped here 
  3. void b() { 
  4.      a(); 
  5. void c() { 
  6.      a(); 
  7. int main() { 
  8.     b(); 
  9.     c(); 

如果调试器停在 //stopped here' 这行,那么有两种方法可以达到:main->b->a或main->c->a`。如果我们用 LLDB 设置一个断点,继续执行并请求一个回溯,那么我们将得到以下内容:

 
 
  1. * frame #0: 0x00000000004004da a.out`a() + 4 at bt.cpp:3 
  2.   frame #1: 0x00000000004004e6 a.out`b() + 9 at bt.cpp:6 
  3.   frame #2: 0x00000000004004fe a.out`main + 9 at bt.cpp:14 
  4.   frame #3: 0x00007ffff7a2e830 libc.so.6`__libc_start_main + 240 at libc-start.c:291 
  5.   frame #4: 0x0000000000400409 a.out`_start + 41 

这说明我们目前在函数 a 中,a 从函数 b 中跳转,b 从 main 中跳转等等。最后两个帧是编译器如何引导 main 函数的。

现在的问题是我们如何在 x86_64 上实现。最稳健的方法是解析 ELF 文件的 .eh_frame 部分,并解决如何从那里展开堆栈,但这会很痛苦。你可以使用 libunwind 或类似的来做,但这很无聊。相反,我们假设编译器以某种方式设置了堆栈,我们将手动遍历它。为了做到这一点,我们首先需要了解堆栈的布局。

 
 
  1.     High 
  2. |   ...   | 
  3. +---------+ 
  4. |  Arg 1  | 
  5. +---------+ 
  6. |  Arg 2  | 
  7. +---------+ 
  8. Return  | 
  9. +---------+ 
  10. |Saved EBP| 
  11. +---------+ 
  12. |  Var 1  | 
  13. +---------+ 
  14. |  Var 2  | 
  15. +---------+ 
  16. |   ...   | 
  17.     Low 

如你所见,最后一个堆栈帧的帧指针存储在当前堆栈帧的开始处,创建一个链接的指针列表。堆栈依据这个链表解开。我们可以通过查找 DWARF 信息中的返回地址来找出列表中下一帧的函数。一些编译器将忽略跟踪 EBP 的帧基址,因为这可以表示为 ESP 的偏移量,并可以释放一个额外的寄存器。即使启用了优化,传递 -fno-omit-frame-pointer 到 GCC 或 Clang 会强制它遵循我们依赖的约定。

我们将在 print_backtrace 函数中完成所有的工作:

 
 
  1. void debugger::print_backtrace() { 

首先要决定的是使用什么格式打印出帧信息。我用了一个 lambda 来推出这个方法:

 
 
  1. auto output_frame = [frame_number = 0] (auto&& func) mutable { 
  2.     std::cout << "frame #" << frame_number++ << ": 0x" << dwarf::at_low_pc(func) 
  3.               << ' ' << dwarf::at_name(func) << std::endl; 
  4. }; 

打印输出的第一帧是当前正在执行的帧。我们可以通过查找 DWARF 中的当前程序计数器来获取此帧的信息:

 
 
  1. auto current_func = get_function_from_pc(get_pc()); 
  2.     output_frame(current_func); 

接下来我们需要获取当前函数的帧指针和返回地址。帧指针存储在 rbp 寄存器中,返回地址是从帧指针堆栈起的 8 字节。

 
 
  1. auto frame_pointer = get_register_value(m_pid, reg::rbp); 
  2. auto return_address = read_memory(frame_pointer+8); 

现在我们拥有了展开堆栈所需的所有信息。我只需要继续展开,直到调试器命中 main,但是当帧指针为 0x0 时,你也可以选择停止,这些是你在调用 main 函数之前调用的函数。我们将从每帧抓取帧指针和返回地址,并打印出信息。

 
 
  1. while (dwarf::at_name(current_func) != "main") { 
  2.         current_func = get_function_from_pc(return_address); 
  3.         output_frame(current_func); 
  4.         frame_pointer = read_memory(frame_pointer); 
  5.         return_address = read_memory(frame_pointer+8); 
  6.     } 

就是这样!以下是整个函数:

 
 
  1. void debugger::print_backtrace() { 
  2.     auto output_frame = [frame_number = 0] (auto&& func) mutable { 
  3.         std::cout << "frame #" << frame_number++ << ": 0x" << dwarf::at_low_pc(func) 
  4.                   << ' ' << dwarf::at_name(func) << std::endl; 
  5.     }; 
  6.     auto current_func = get_function_from_pc(get_pc()); 
  7.     output_frame(current_func); 
  8.     auto frame_pointer = get_register_value(m_pid, reg::rbp); 
  9.     auto return_address = read_memory(frame_pointer+8); 
  10.     while (dwarf::at_name(current_func) != "main") { 
  11.         current_func = get_function_from_pc(return_address); 
  12.         output_frame(current_func); 
  13.         frame_pointer = read_memory(frame_pointer); 
  14.         return_address = read_memory(frame_pointer+8); 
  15.     } 

添加命令

当然,我们必须向用户公开这个命令。

 
 
  1. else if(is_prefix(command, "backtrace")) { 
  2.     print_backtrace(); 

测试

测试此功能的一个方法是通过编写一个测试程序与一堆互相调用的小函数。设置几个断点,跳到代码附近,并确保你的回溯是准确的。

我们已经从一个只能产生并附加到其他程序的程序走了很长的路。本系列的倒数第二篇文章将通过支持读写变量来完成调试器的实现。在此之前,你可以在这里找到这个帖子的代码。


原文发布时间为:2017-10-09

本文作者:Simon Brand

本文来自云栖社区合作伙伴“51CTO”,了解相关信息可以关注。

相关文章
|
4天前
|
Linux 编译器 Android开发
FFmpeg开发笔记(九)Linux交叉编译Android的x265库
在Linux环境下,本文指导如何交叉编译x265的so库以适应Android。首先,需安装cmake和下载android-ndk-r21e。接着,下载x265源码,修改crosscompile.cmake的编译器设置。配置x265源码,使用指定的NDK路径,并在配置界面修改相关选项。随后,修改编译规则,编译并安装x265,调整pc描述文件并更新PKG_CONFIG_PATH。最后,修改FFmpeg配置脚本启用x265支持,编译安装FFmpeg,将生成的so文件导入Android工程,调整gradle配置以确保顺利运行。
24 1
FFmpeg开发笔记(九)Linux交叉编译Android的x265库
|
1月前
|
NoSQL Linux 程序员
Linux:gdb调试器的解析+使用(超详细版)
Linux:gdb调试器的解析+使用(超详细版)
64 1
|
30天前
|
Linux 编译器 程序员
【Linux 调试秘籍】深入探索 C++:运行时获取堆栈信息和源代码行数的终极指南
【Linux 调试秘籍】深入探索 C++:运行时获取堆栈信息和源代码行数的终极指南
68 0
|
20天前
|
Linux API C语言
FFmpeg开发笔记(一)搭建Linux系统的开发环境
本文指导初学者如何在Linux上搭建FFmpeg开发环境。首先,由于FFmpeg依赖第三方库,可以免去编译源码的复杂过程,直接安装预编译的FFmpeg动态库。推荐网站<https://github.com/BtbN/FFmpeg-Builds/releases>提供适用于不同系统的FFmpeg包。但在安装前,需确保系统有不低于2.22版本的glibc库。详细步骤包括下载glibc-2.23源码,配置、编译和安装。接着,下载Linux版FFmpeg安装包,解压至/usr/local/ffmpeg,并设置环境变量。最后编写和编译简单的C或C++测试程序验证FFmpeg环境是否正确配置。
37 8
FFmpeg开发笔记(一)搭建Linux系统的开发环境
|
1月前
|
存储 缓存 Linux
探秘Linux块设备驱动程序:成为内核开发大师的第一步
探秘Linux块设备驱动程序:成为内核开发大师的第一步
92 0
|
NoSQL Linux 编译器
【Linux】——调试器-gdb的使用
【Linux】——调试器-gdb的使用
|
1月前
|
Linux
linux驱动开发-点亮第一个led灯
linux驱动开发-点亮第一个led灯
23 0
|
1月前
|
网络协议 Java Linux
Java 开发常用的 Linux 命令知识积累
Java 开发常用的 Linux 命令知识积累
38 0
|
1月前
|
网络协议 Ubuntu Linux
「远程开发」VSCode使用SSH远程linux服务器 - 公网远程连接
「远程开发」VSCode使用SSH远程linux服务器 - 公网远程连接
129 0