中国领先的IT技术网站
|
|

开发一个Linux调试器(七):源码级断点

这篇文章将会添加源码级断点到我们的调试器中。通过所有我们已经支持的功能,这要比起最初听起来容易得多。我们还将添加一个命令来获取符号的类型和地址,这对于定位代码或数据以及理解链接概念非常有用。

作者:佚名来源:Linux中国|2017-09-25 08:04

开发者大赛路演 | 12月16日,技术创新,北京不见不散


开发一个Linux调试器(七):源码级断点

在内存地址上设置断点虽然不错,但它并没有提供最方便用户的工具。我们希望能够在源代码行和函数入口地址上设置断点,以便我们可以在与代码相同的抽象级别中进行调试。

这篇文章将会添加源码级断点到我们的调试器中。通过所有我们已经支持的功能,这要比起最初听起来容易得多。我们还将添加一个命令来获取符号的类型和地址,这对于定位代码或数据以及理解链接概念非常有用。

系列索引

随着后面文章的发布,这些链接会逐渐生效。

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

断点

DWARF

Elves 和 dwarves 这篇文章,描述了 DWARF 调试信息是如何工作的,以及如何用它来将机器码映射到高层源码中。回想一下,DWARF 包含了函数的地址范围和一个允许你在抽象层之间转换代码位置的行表。我们将使用这些功能来实现我们的断点。

函数入口

如果你考虑重载、成员函数等等,那么在函数名上设置断点可能有点复杂,但是我们将遍历所有的编译单元,并搜索与我们正在寻找的名称匹配的函数。DWARF 信息如下所示:

  1. < 0><0x0000000b>  DW_TAG_compile_unit 
  2.                     DW_AT_producer              clang version 3.9.1 (tags/RELEASE_391/final) 
  3.                     DW_AT_language              DW_LANG_C_plus_plus 
  4.                     DW_AT_name                  /super/secret/path/MiniDbg/examples/variable.cpp 
  5.                     DW_AT_stmt_list             0x00000000 
  6.                     DW_AT_comp_dir              /super/secret/path/MiniDbg/build 
  7.                     DW_AT_low_pc                0x00400670 
  8.                     DW_AT_high_pc               0x0040069c 
  9. LOCAL_SYMBOLS: 
  10. < 1><0x0000002e>    DW_TAG_subprogram 
  11.                       DW_AT_low_pc                0x00400670 
  12.                       DW_AT_high_pc               0x0040069c 
  13.                       DW_AT_name                  foo 
  14.                       ... 
  15. ... 
  16. <14><0x000000b0>    DW_TAG_subprogram 
  17.                       DW_AT_low_pc                0x00400700 
  18.                       DW_AT_high_pc               0x004007a0 
  19.                       DW_AT_name                  bar 
  20.                       ... 

我们想要匹配 DW_AT_name 并使用 DW_AT_low_pc(函数的起始地址)来设置我们的断点。

  1. void debugger::set_breakpoint_at_function(const std::string& name) { 
  2.     for (const auto& cu : m_dwarf.compilation_units()) { 
  3.         for (const auto& die : cu.root()) { 
  4.             if (die.has(dwarf::DW_AT::name) && at_name(die) == name) { 
  5.                 auto low_pc = at_low_pc(die); 
  6.                 auto entry = get_line_entry_from_pc(low_pc); 
  7.                 ++entry; //skip prologue 
  8.                 set_breakpoint_at_address(entry->address); 
  9.             } 
  10.         } 
  11.     } 

这代码看起来有点奇怪的唯一一点是 ++entry。 问题是函数的 DW_AT_low_pc 不指向该函数的用户代码的起始地址,它指向 prologue 的开始。编译器通常会输出一个函数的 prologue 和 epilogue,它们用于执行保存和恢复堆栈、操作堆栈指针等。这对我们来说不是很有用,所以我们将入口行加一来获取用户代码的第一行而不是 prologue。DWARF 行表实际上具有一些功能,用于将入口标记为函数 prologue 之后的第一行,但并不是所有编译器都输出它,因此我采用了原始的方法。

源码行

要在高层源码行上设置一个断点,我们要将这个行号转换成 DWARF 中的一个地址。我们将遍历编译单元,寻找一个名称与给定文件匹配的编译单元,然后查找与给定行对应的入口。

DWARF 看上去有点像这样:

  1. .debug_line: line number info for a single cu 
  2. Source lines (from CU-DIE at .debug_info offset 0x0000000b): 
  3. NS new statement, BB new basic block, ET end of text sequence 
  4. PE prologue end, EB epilogue begin 
  5. IS=val ISA number, DI=val discriminator value 
  6. <pc>        [lno,col] NS BB ET PE EB IS= DI= uri: "filepath" 
  7. 0x004004a7  [   1, 0] NS uri: "/super/secret/path/a.hpp" 
  8. 0x004004ab  [   2, 0] NS 
  9. 0x004004b2  [   3, 0] NS 
  10. 0x004004b9  [   4, 0] NS 
  11. 0x004004c1  [   5, 0] NS 
  12. 0x004004c3  [   1, 0] NS uri: "/super/secret/path/b.hpp" 
  13. 0x004004c7  [   2, 0] NS 
  14. 0x004004ce  [   3, 0] NS 
  15. 0x004004d5  [   4, 0] NS 
  16. 0x004004dd  [   5, 0] NS 
  17. 0x004004df  [   4, 0] NS uri: "/super/secret/path/ab.cpp" 
  18. 0x004004e3  [   5, 0] NS 
  19. 0x004004e8  [   6, 0] NS 
  20. 0x004004ed  [   7, 0] NS 
  21. 0x004004f4  [   7, 0] NS ET 

所以如果我们想要在 ab.cpp 的第五行设置一个断点,我们将查找与行 (0x004004e3) 相关的入口并设置一个断点。

  1. void debugger::set_breakpoint_at_source_line(const std::string& file, unsigned line) { 
  2.     for (const auto& cu : m_dwarf.compilation_units()) { 
  3.         if (is_suffix(file, at_name(cu.root()))) { 
  4.             const auto& lt = cu.get_line_table(); 
  5.             for (const auto& entry : lt) { 
  6.                 if (entry.is_stmt && entry.line == line) { 
  7.                     set_breakpoint_at_address(entry.address); 
  8.                     return
  9.                 } 
  10.             } 
  11.         } 
  12.     } 

我这里做了 is_suffix hack,这样你可以输入 c.cpp 代表 a/b/c.cpp 。当然你实际上应该使用大小写敏感路径处理库或者其它东西,但是我比较懒。entry.is_stmt 是检查行表入口是否被标记为一个语句的开头,这是由编译器根据它认为是断点的最佳目标的地址设置的。

符号查找

当我们在对象文件层时,符号是王者。函数用符号命名,全局变量用符号命名,你得到一个符号,我们得到一个符号,每个人都得到一个符号。 在给定的对象文件中,一些符号可能引用其他对象文件或共享库,链接器将从符号引用创建一个可执行程序。

可以在正确命名的符号表中查找符号,它存储在二进制文件的 ELF 部分中。幸运的是,libelfin 有一个不错的接口来做这件事,所以我们不需要自己处理所有的 ELF 的事情。为了让你知道我们在处理什么,下面是一个二进制文件的 .symtab 部分的转储,它由 readelf 生成:

  1. Num:    Value          Size Type    Bind   Vis      Ndx Name 
  2.  0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
  3.  1: 0000000000400238     0 SECTION LOCAL  DEFAULT    1 
  4.  2: 0000000000400254     0 SECTION LOCAL  DEFAULT    2 
  5.  3: 0000000000400278     0 SECTION LOCAL  DEFAULT    3 
  6.  4: 00000000004002c8     0 SECTION LOCAL  DEFAULT    4 
  7.  5: 0000000000400430     0 SECTION LOCAL  DEFAULT    5 
  8.  6: 00000000004004e4     0 SECTION LOCAL  DEFAULT    6 
  9.  7: 0000000000400508     0 SECTION LOCAL  DEFAULT    7 
  10.  8: 0000000000400528     0 SECTION LOCAL  DEFAULT    8 
  11.  9: 0000000000400558     0 SECTION LOCAL  DEFAULT    9 
  12. 10: 0000000000400570     0 SECTION LOCAL  DEFAULT   10 
  13. 11: 0000000000400714     0 SECTION LOCAL  DEFAULT   11 
  14. 12: 0000000000400720     0 SECTION LOCAL  DEFAULT   12 
  15. 13: 0000000000400724     0 SECTION LOCAL  DEFAULT   13 
  16. 14: 0000000000400750     0 SECTION LOCAL  DEFAULT   14 
  17. 15: 0000000000600e18     0 SECTION LOCAL  DEFAULT   15 
  18. 16: 0000000000600e20     0 SECTION LOCAL  DEFAULT   16 
  19. 17: 0000000000600e28     0 SECTION LOCAL  DEFAULT   17 
  20. 18: 0000000000600e30     0 SECTION LOCAL  DEFAULT   18 
  21. 19: 0000000000600ff0     0 SECTION LOCAL  DEFAULT   19 
  22. 20: 0000000000601000     0 SECTION LOCAL  DEFAULT   20 
  23. 21: 0000000000601018     0 SECTION LOCAL  DEFAULT   21 
  24. 22: 0000000000601028     0 SECTION LOCAL  DEFAULT   22 
  25. 23: 0000000000000000     0 SECTION LOCAL  DEFAULT   23 
  26. 24: 0000000000000000     0 SECTION LOCAL  DEFAULT   24 
  27. 25: 0000000000000000     0 SECTION LOCAL  DEFAULT   25 
  28. 26: 0000000000000000     0 SECTION LOCAL  DEFAULT   26 
  29. 27: 0000000000000000     0 SECTION LOCAL  DEFAULT   27 
  30. 28: 0000000000000000     0 SECTION LOCAL  DEFAULT   28 
  31. 29: 0000000000000000     0 SECTION LOCAL  DEFAULT   29 
  32. 30: 0000000000000000     0 SECTION LOCAL  DEFAULT   30 
  33. 31: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS init.c 
  34. 32: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS crtstuff.c 
  35. 33: 0000000000600e28     0 OBJECT  LOCAL  DEFAULT   17 __JCR_LIST__ 
  36. 34: 00000000004005a0     0 FUNC    LOCAL  DEFAULT   10 deregister_tm_clones 
  37. 35: 00000000004005e0     0 FUNC    LOCAL  DEFAULT   10 register_tm_clones 
  38. 36: 0000000000400620     0 FUNC    LOCAL  DEFAULT   10 __do_global_dtors_aux 
  39. 37: 0000000000601028     1 OBJECT  LOCAL  DEFAULT   22 completed.6917 
  40. 38: 0000000000600e20     0 OBJECT  LOCAL  DEFAULT   16 __do_global_dtors_aux_fin 
  41. 39: 0000000000400640     0 FUNC    LOCAL  DEFAULT   10 frame_dummy 
  42. 40: 0000000000600e18     0 OBJECT  LOCAL  DEFAULT   15 __frame_dummy_init_array_ 
  43. 41: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS /super/secret/path/MiniDbg/ 
  44. 42: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS crtstuff.c 
  45. 43: 0000000000400818     0 OBJECT  LOCAL  DEFAULT   14 __FRAME_END__ 
  46. 44: 0000000000600e28     0 OBJECT  LOCAL  DEFAULT   17 __JCR_END__ 
  47. 45: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS 
  48. 46: 0000000000400724     0 NOTYPE  LOCAL  DEFAULT   13 __GNU_EH_FRAME_HDR 
  49. 47: 0000000000601000     0 OBJECT  LOCAL  DEFAULT   20 _GLOBAL_OFFSET_TABLE_ 
  50. 48: 0000000000601028     0 OBJECT  LOCAL  DEFAULT   21 __TMC_END__ 
  51. 49: 0000000000601020     0 OBJECT  LOCAL  DEFAULT   21 __dso_handle 
  52. 50: 0000000000600e20     0 NOTYPE  LOCAL  DEFAULT   15 __init_array_end 
  53. 51: 0000000000600e18     0 NOTYPE  LOCAL  DEFAULT   15 __init_array_start 
  54. 52: 0000000000600e30     0 OBJECT  LOCAL  DEFAULT   18 _DYNAMIC 
  55. 53: 0000000000601018     0 NOTYPE  WEAK   DEFAULT   21 data_start 
  56. 54: 0000000000400710     2 FUNC    GLOBAL DEFAULT   10 __libc_csu_fini 
  57. 55: 0000000000400570    43 FUNC    GLOBAL DEFAULT   10 _start 
  58. 56: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__ 
  59. 57: 0000000000400714     0 FUNC    GLOBAL DEFAULT   11 _fini 
  60. 58: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@@GLIBC_ 
  61. 59: 0000000000400720     4 OBJECT  GLOBAL DEFAULT   12 _IO_stdin_used 
  62. 60: 0000000000601018     0 NOTYPE  GLOBAL DEFAULT   21 __data_start 
  63. 61: 00000000004006a0   101 FUNC    GLOBAL DEFAULT   10 __libc_csu_init 
  64. 62: 0000000000601028     0 NOTYPE  GLOBAL DEFAULT   22 __bss_start 
  65. 63: 0000000000601030     0 NOTYPE  GLOBAL DEFAULT   22 _end 
  66. 64: 0000000000601028     0 NOTYPE  GLOBAL DEFAULT   21 _edata 
  67. 65: 0000000000400670    44 FUNC    GLOBAL DEFAULT   10 main 
  68. 66: 0000000000400558     0 FUNC    GLOBAL DEFAULT    9 _init 

你可以在对象文件中看到用于设置环境的很多符号,最后还可以看到 main 符号。

我们对符号的类型、名称和值(地址)感兴趣。我们有一个该类型的 symbol_type 枚举,并使用一个 std::string 作为名称,std::uintptr_t 作为地址:

  1. enum class symbol_type { 
  2.     notype,            // No type (e.g., absolute symbol) 
  3.     object,            // Data object 
  4.     func,              // Function entry point 
  5.     section,           // Symbol is associated with a section 
  6.     file,              // Source file associated with the 
  7. };                     // object file 
  8. std::string to_string (symbol_type st) { 
  9.     switch (st) { 
  10.     case symbol_type::notype: return "notype"
  11.     case symbol_type::object: return "object"
  12.     case symbol_type::func: return "func"
  13.     case symbol_type::sectionreturn "section"
  14.     case symbol_type::file: return "file"
  15.     } 
  16. struct symbol { 
  17.     symbol_type type; 
  18.     std::string name
  19.     std::uintptr_t addr; 
  20. }; 

我们需要将从 libelfin 获得的符号类型映射到我们的枚举,因为我们不希望依赖关系破环这个接口。幸运的是,我为所有的东西选了同样的名字,所以这样很简单:

  1. symbol_type to_symbol_type(elf::stt sym) { 
  2.     switch (sym) { 
  3.     case elf::stt::notype: return symbol_type::notype; 
  4.     case elf::stt::object: return symbol_type::object; 
  5.     case elf::stt::func: return symbol_type::func; 
  6.     case elf::stt::sectionreturn symbol_type::section
  7.     case elf::stt::file: return symbol_type::file; 
  8.     defaultreturn symbol_type::notype; 
  9.     } 
  10. }; 

最后我们要查找符号。为了说明的目的,我循环查找符号表的 ELF 部分,然后收集我在其中找到的任意符号到 std::vector 中。更智能的实现可以建立从名称到符号的映射,这样你只需要查看一次数据就行了。

  1. std::vector<symbol> debugger::lookup_symbol(const std::string& name) { 
  2.     std::vector<symbol> syms; 
  3.     for (auto &sec : m_elf.sections()) { 
  4.         if (sec.get_hdr().type != elf::sht::symtab && sec.get_hdr().type != elf::sht::dynsym) 
  5.             continue
  6.         for (auto sym : sec.as_symtab()) { 
  7.             if (sym.get_name() == name) { 
  8.                 auto &d = sym.get_data(); 
  9.                 syms.push_back(symbol{to_symbol_type(d.type()), sym.get_name(), d.value}); 
  10.             } 
  11.         } 
  12.     } 
  13.     return syms; 

添加命令

一如往常,我们需要添加一些更多的命令来向用户暴露功能。对于断点,我使用 GDB 风格的接口,其中断点类型是通过你传递的参数推断的,而不用要求显式切换:

  • 0x<hexadecimal> -> 断点地址
  • <line>:<filename> -> 断点行号
  • <anything else> -> 断点函数名
  1. else if(is_prefix(command, "break")) { 
  2.     if (args[1][0] == '0' && args[1][1] == 'x') { 
  3.         std::string addr {args[1], 2}; 
  4.         set_breakpoint_at_address(std::stol(addr, 0, 16)); 
  5.     } 
  6.     else if (args[1].find(':') != std::string::npos) { 
  7.         auto file_and_line = split(args[1], ':'); 
  8.         set_breakpoint_at_source_line(file_and_line[0], std::stoi(file_and_line[1])); 
  9.     } 
  10.     else { 
  11.         set_breakpoint_at_function(args[1]); 
  12.     } 

对于符号,我们将查找符号并打印出我们发现的任何匹配项:

  1. else if(is_prefix(command, "symbol")) { 
  2.     auto syms = lookup_symbol(args[1]); 
  3.     for (auto&& s : syms) { 
  4.         std::cout << s.name << ' ' << to_string(s.type) << " 0x" << std::hex << s.addr << std::endl; 
  5.     } 

测试一下

在一个简单的二进制文件上启动调试器,并设置源代码级别的断点。在一些 foo 函数上设置一个断点,看到我的调试器停在它上面是我这个项目最有价值的时刻之一。

符号查找可以通过在程序中添加一些函数或全局变量并查找它们的名称来进行测试。请注意,如果你正在编译 C++ 代码,你还需要考虑名称重整

本文就这些了。下一次我将展示如何向调试器添加堆栈展开支持。

你可以在这里找到这篇文章的代码。

【编辑推荐】

  1. 漫谈传统的Linux初始化系统的运行级别
  2. Sublime Text 3.0正式发布!提供Linux软件包仓库支持
  3. 十二条Linux运维面试必备经典笔试/面试题,来挑战一下吧!
  4. 如何在Windows上运行Linux容器
  5. 在Linux中运行sudo命令不需要密码
【责任编辑:庞桂玉 TEL:(010)68476606】

点赞 0
分享:
大家都在看
猜你喜欢

读 书 +更多

超级网管员——网络设备

本书深入细致地介绍了用于构建网络的最重要的硬件设备——交换机、路由器、安全设备和无线设备,涵盖了原理、参数、分类、适用、规划、接口...

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊
× 51CTO学院双十二活动