作者:王森(天作)
C/C++ 具有天然的跨平台特性,丰富的构建工具、Native 的性能以及成熟的社区生态,近年来移动端也越来越多的集成了一些使用 C/C++ 开发一些逻辑内聚且对性能要求较高的模块,特别是各类引擎模块例如音视频编解码、RPC 网络库、数据库、神经网络库等。
双向黑盒
但并不完美的是,C/C++ 技术栈在获得上述收益的同时,也使得原本用原生语言进行开发的体验产生了割裂,导致了不小的“隐性成本”,这点 Dropbox 在其分享的一系列 C++ 文章中也有提到。
https://dropbox.tech/mobile/the-not-so-hidden-cost-of-sharing-code-between-IOS-and-Android
C/C++ 工具链和构建环境比较复杂,Makefile、cmake、gn、ninja 等构建工具繁多,对环境的要求极高,因此这些库通常都是以链接后的静态库或者动态库的方式进行二进制分发,App 再进行二次链接之后进行调用。
在模块层和接入层都保障自己 Unit 不出问题的情况下,这一切看似都井井有条。但一旦出现问题需要排查的时候,C/C++ 二进制库对双方来说都是个黑盒:
- 对于底层模块开发者来说,代码的调用是在平台代码层,提供出去的二进制没法调试,“破案”就只能靠程序员传统艺能“打日志”的方式了;
- 对于业务接入层开发者来说,对方提供的二进制也无法调试,只能看到一行行难以理解的汇编代码,问题只能定位到自己输入输出,无法定位到更深层次的原因。
因此导致出现问题后的排查效率极低,甚至出现过一个问题来来回回排查好几天,影响项目进展。
源代码调试
LLDB 源码调试的原理是根据二进制中的 DWARF 调试信息找到对应源代码路径和行号等信息,在 mach-O 格式文件中,这部分信息存放在 __DWARF 相关的 Section 中,我们可以使用 dwarfdump 命令查看结构化的调试信息,elf 格式文件可以使用 objdump 查看:
![](https://www.isolves.com/d/file/p/2022/09-19/47d9a26a456e77a32104221cd2eb245a.png)
由于 DWARF 中定义的源码路径本地并不存在,因此 LLDB 并不能进入源码调试模式,这时候要实现本地源码调试有两个方案:
- 在本机重新进行源码编译,这样生成的二进制库中定义的源码路径是本地真实存在的,但这又面临编译环境复杂问题;
- 免编译调试,想办法将二进制中的源码路径映射成本地真是存在的源码路径,这样不用重新编译也无需替换本地二进制库。
免编译调试
幸运的是 lldb 提供了命令可以修改路径,将二进制中的路径前缀映射到本地的真实路径,因此你可以在 Debug 控制台中输入以下指令实现本地调试:
![](https://www.isolves.com/d/file/p/2022/09-19/6e4e0ab552f1b64e804e665977fc7ed1.png)
再次点击单步调试后,我们就可以看到熟悉的源码调试界面:
![](https://www.isolves.com/d/file/p/2022/09-19/8b70615c722746493610315c6d9cfa46.png)
但这,还是太麻烦了,首先这个命令不一定记得住,然后是需要从二进制中找到需要映射的路径地址,而且每次重新运行 app 之后又得重新输入一遍;因此,我们利用 lldb 提供的 lldbinit 和 Python/ target=_blank class=infotextkey>Python API,这些步骤都自动化完成,真正实现一键调试体验。
lldbinit 是 LLDB 在启动调试的时候提供给开发者自定义调试命令的接口文件,我们可以在源码根目录下放置一个 lldbinit 用于自定义源码映射的逻辑,于是调试的流程就变成:
![](https://www.isolves.com/d/file/p/2022/09-19/8350bc8c4a7dec75e518a78bf5229d8f.png)
自动映射源码
增加一个 python 文件,例如 debug.py ,存放在源码目录下,用 Python 实现以下伪代码步骤:
1、通过 Python API 查找指定的 Symbol 调试信息
![](https://www.isolves.com/d/file/p/2022/09-19/ed758c0fe4c2f47e10c5ac1e240c0965.png)
2、约定规则,将 Python 文件默认认为存放在源代码的根目录,这样可以比较方便的获取本地源码路径:
![](https://www.isolves.com/d/file/p/2022/09-19/24c7d4b9ee4c40873c42f567fd3f6392.png)
3、然后需要找到调试信息中存储的路径与本地路径之间的前缀映射关系,可以通过遍历路径目录的方式逐级查找本地对应的源码是否存在:
![](https://www.isolves.com/d/file/p/2022/09-19/19576c0e2d7555ec1a1c123173e7875b.png)
4、再调用 lldb 命令将源码路径映射到本地路径:
![](https://www.isolves.com/d/file/p/2022/09-19/4bf2a168a9d657c7a0c5804148339c69.png)
5、以上替换的主要逻辑实现,需要通过一个 lldbinit 将这个方法添加给 lldb 运行时,我们可以通过添加 stop-hook 的方式让上述脚本自动运行:
![](https://www.isolves.com/d/file/p/2022/09-19/60e52070084ba5de6e19c301a88f98a1.png)
工程改造
最后,通过一些简单的工程改造,便可以将调试应用到 Android 和 iOS 的日常开发环境中:
- 编译参数增加变量调试信息,大部分情况下不需要处理,但基于不同工具链或编译参数构建的二进制库中,有的缺少了 变量 的调试信息(通过 dwarfdump 可以查看是否存在 DW_TAG_variable 信息),导致在单步调试的过程中无法打印或者查看变量,只需要编译参数中增加或修改 -gfull 即可;
- DoNotStrip,Android 工程中,为了包大小通常会将 so strip 掉调试信息,因此在 Debug 环境下,可以将 so 改造成 DoNotStrip。
最后
通过这几十行的脚本,让 C/C++ 二进制库在各平台上能够使用默认的 IDE 快速进行源码调试,有助于提升问题排查效率和打破上下游边界。
除了应用在 C/C++ 模块,以上原理同样适用于 Swift 或者 ObjC、Rust 等其他任何能够生成 DWARF 调试信息并支持使用 lldb 调试的语言。
附上实现源码:
- lldbinit:
![](https://www.isolves.com/d/file/p/2022/09-19/08b6dc47615ece47e24f1c837cb8e442.png)
- debug.py:
![](https://www.isolves.com/d/file/p/2022/09-19/ed1531de896d58f480fc0318823eab60.png)