顺滑的为二进制库下断点

2,619 阅读8分钟

1. 前言

在目前的iOS组件化开发中,将组件进行二进制化已经成为业内比较主流的提升效率的方案,而随着二进制化后,如何对其断点调试,网上也出现众多方案。本文将结合目前网上已知或冷门的各种方案进行介绍和分析,并尽量提炼出一些关键点,希望读到本篇文章的同学对于如何友好调试二进制组件能够一个相对清晰的认识和解决现存问题提出更加简洁的方案。

2. DWARF文件

说到程序断点调试,现代程序断点调试往往需要三个角色(缺一不可):可执行文件、调试器、调试信息文件。而其中的调试信息文件,是我们相对接触更少的角色,所以首先我们将简单介绍调试信息文件,这里我们就讲述目前较为主流的DWARF格式。

2.1 简述

DWARF格式是可执行程序和源代码之间关系的一种简洁表示,调试器可以利用其相当有效地处理这种关系。

大多数现代编程语言都是块结构的,每个实体(例如类定义或函数定义)都包含在另一个实体中,故编译器很自然的在内部将程序表现为树。DWARF格式遵循这个模型,因为它也是块结构的,DWARF中每个描述性的实体DIE(除了描述源文件的最顶层DIE之外)都包含在父DIE中,并且可能包含子DIE,如果一个DIE包含多个子DIE,那么它们都是彼此关联的兄弟关系,所以DWARF也是类似于编译器的内部树结构。

DWARF通常与ELF对象文件相关联,但是其独立于ELF。它可以和其他任何目标文件格式一起使用(比如MachO)。所需要做的就是在对象文件或可执行文件中识别组成DWARF格式数据的不同section。

DWARF中的基本描述项为DIE(Debugging Information Entry),DIE具有一个标签,用于指定所描述的内容,以及一个属性列表用于填充详细信息而进一步描述该实体。属性可能包含各种值:常量(例如函数名称),变量(例如函数的起始地址)或对另一个DIE的引用(例如函数的返回值类型)。

2.2 DWARF和MachO

上面说到DWARF可以与各种目标文件格式一起使用,这里要讲述的是其在MachO文件格式中的利用。和ELF类似,在MachO中,DWARF几乎使用一样的section名表达其意图,包括:

.debug_abbrev    用在.debug_info section的缩写

.debug_aranges   内存地址与编译单元之间的一个映射

.debug_frame     栈帧信息

.debug_info      包含DIE的核心DWARF数据

.debug_line      行信息

.debug_loc       位置描述

.debug_macinfo   宏的描述

.debug_pubnames  全局对象及函数的一个查找表

.debug_pubtypes  全局类型的一个查找表

.debug_ranges    DIE所引用的地址范围信息

.debug_str       由.debug_info使用的字符串表

其中关系到可执行程序和源文件路径的对应关系的数据保存在 .debug_info section中的 Compile Unit DIE中,其对应的标签为DW_TAG_compile_unit,我们可以暂时记住它。

由于MachO格式文件存在多种可执行代码的载体,而每种载体对应的调试信息有不同的存储方式,我们接下来分别描述

  • 可执行文件

    最常见的当然是一个可执行文件了,其调试信息保存在一个独立的DWARF格式的文件中,后缀为DSYM。

  • 静态库

    静态库文件的调试信息直接保存在对象文件中的各个section中,而没有单独文件保存(最终通过链接被合并到宿主工程的DWARF文件中)。

  • 动态库

    动态库文件和可执行文件一样,其会生成一个单独的DSYM文件来保存DWARF格式数据。

这三种不同的对象文件的调试信息之所以如此保存,是由于每个调试信息文件对应的是一个运行时的镜像。

3. 实现方案和原理

断点能够被定位到源文件,实际上是由上面提到的DWARF信息中的DW_TAG_compile_unitDW_AT_comp_dirDW_AT_name两个属性值的几种组合(具体组合形式不清楚,有兴趣的可以去测试或者翻阅LLDB源码,这里我们只认为是简单的路径拼接)来得到其路径进行查找到的。所以我们要做的也就是如何让其映射到源码,让使用者在调试二进制文件时达到和源码调试一样的效果,甚至对其几乎完全透明。

现在我们开始介绍目前已知的3种二进制断点调试方案:

  1. 直接重建源代码路径
  2. 通过LLDB source-map映射
  3. Clang -fdebug-prefix-map

3.1 直接重建源代码路径

这个方案就是如字面意思,根据DWARF信息中DW_TAG_compile_unit中保存的DW_AT_comp_dirDW_AT_name的值合并(仅DW_AT_name为相对路径时)得到源代码路径,根据这个路径中的compile dir在调试机器上重建路径,并下载源码到其内。如果就这么直接用,这个方案就相对比较愚蠢了。

3.2 通过LLDB source-map映射

这种方式实际上在网上有被其他大佬引用,不过在我看来操作相对繁琐,且对使用者不够友好,操作步骤较多,无法对使用者透明,故这里给出我的完整方案。

这个方案是这3种方案中相对最复杂的,但也是最为灵活的,其利用的是LLDB的settings set target.source-map 命令,这个命令等价于GDB的set substitute-path命令。通过这个命令可以建立多个路径替换规则,LLDB在进行源码查找时,会根据这些替换规则的定义顺序进行匹配,其匹配策略为前缀匹配,具体可以通过LLDB源码获得:

// https://github.com/llvm-mirror/lldb
// 节点: (commit: d01083a850f577b85501a0902b52fd0930de72c7)
// PathMappingList.cpp
bool PathMappingList::RemapPath(llvm::StringRef path,
                                std::string &new_path) const {
  if (m_pairs.empty() || path.empty())
    return false;
  LazyBool path_is_relative = eLazyBoolCalculate;
  for (const auto &it : m_pairs) {
    auto prefix = it.first.GetStringRef();
    if (!path.consume_front(prefix)) {
      // Relative paths won't have a leading "./" in them unless "." is the
      // only thing in the relative path so we need to work around "."
      // carefully.
      if (prefix != ".")
        continue;
      // We need to figure out if the "path" argument is relative. If it is,
      // then we should remap, else skip this entry.
      if (path_is_relative == eLazyBoolCalculate) {
        path_is_relative =
            FileSpec(path).IsRelative() ? eLazyBoolYes : eLazyBoolNo;
      }
      if (!path_is_relative)
        continue;
    }
    FileSpec remapped(it.second.GetStringRef());
    remapped.AppendPathComponent(path);
    new_path = remapped.GetPath();
    return true;
  }
  return false;
}

我们了解了这个匹配规则,所以可以不必建立每个源文件的替换规则,而是只需要添加一条compile dir的替换规则即可。当然知道了这句命令是不够的,因为我们希望的是能够达到和源码调试一样的断点调试效果,甚至能在你的Xcode run之前下断点,我们按以下步骤来:

  1. 既然想在run之前下断点,所以源码本身你应该通过你自己设计的方案已经提前下载到了本地,并且已经集成在当前工程下而可以通过你的鼠标去下断点(比如通过一个不参与编译的Pod)。
  2. 通过LLDB Init File来在LLDB会话开始时就自动运行一段脚本,脚本内容这里我不会给出,你可以自己完成:
    1. 通过你自己的方案将需要映射的库的路径(原始路径默认已知)和源码进行source map。
    2. 扫描当前LLDB会话已有的 pending断点们(这里实际上就是你run之前的断点未被解析出)信息并保存。
    3. 将上一步获得的所有pending断点遍历进行重新添加(由于第1步的操作,这里重新添加将激活pending断点),此时run之前的断点便恢复正常。
  3. 这里就和正常源码下断点没两样了。

此种方案之所以称之为最为灵活,是因为你可以根据需要对路径做任意的映射。

3.3 Clang -fdebug-prefix-map

这种方案实际上算是比较简单,算是对第1种方案的扩展,据我所知百度的EasyBox也在使用。其使用的是Clang的-fdebug-prefix-map选项来完成的,这样便能在编译时直接进行路径的替换,然后重建路径进行源码下载(这里的路径相对友好点)。

3.4 动态库的特殊处理

在前面我们看到了,动态库的DWARF文件是另外保存的,按照前面说的三个角色缺一不可原则,我们必须要提前设计好保存每个动态库的DWARF文件,在需要调试时一并下载,并通过LLDB Init File使用add-dsym指令进行添加。

3.5 Debug和Release

以上所有讨论都适用于Debug和Release模式(编译时记得添加 -g 生成调试信息)的二进制文件,不过需要注意的一点是,Release模式的代码因为经过了优化,所以在断点调试时可能会有一些比较奇怪的情况发生(因为没实际测试和遇到过,哈哈😂)。

4. 结语

本文某些地方没点明细节我都是一笔带过,因为网上有很多更为细致的文章已经说过,但是在需要注意的地方我也会较为详细的叙述和点出,因为初衷是授人以渔,各种细节上的小坑你需要自己去摸索比较好,这些方案如何抉择和使用,也全凭你自己的意向,由于笔者能力有限,文中难免会出现一些错误,如果有发现希望能联系我进行修正,十分感谢阅读!

原文地址:www.notion.so/nakahira/dd…

5. 参考资料