作者:王森
C/C++二进制库对底层模块开发者和业务接入层开发者来说都是个黑盒,通过这几十行的脚本,让 C/C++二进制库在各平台上能够使用默认的 IDE 快速进行源码调试,有助于提升问题排查效率和打破上下游边界。
一、双向黑盒
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++二进制库对双方来说都是个黑盒:
1)对于底层模块开发者来说,代码的调用是在平台代码层,提供出去的二进制没法调试,“破案”就只能靠程序员传统艺能“打日志”的方式了;
2)对于业务接入层开发者来说,对方提供的二进制也无法调试,只能看到一行行难以理解的汇编代码,问题只能定位到自己输入输出,无法定位到更深层次的原因。
因此导致出现问题后的排查效率极低,甚至出现过一个问题来来回回排查好几天,影响项目进展。
二、源代码调试
LLDB 源码调试的原理是根据二进制中的 DWARF 调试信息找到对应源代码路径和行号等信息,在 Mach-O 格式文件中,这部分信息存放在__DWARF 相关的 Section 中,我们可以使用 dwarfdump 命令查看结构化的调试信息,elf 格式文件可以使用 objdump 查看:
0x00000ed8: DW_TAG_inlined_subroutine
DW_AT_abstract_origin (0x00000ec6 "AbcAppContext")
DW_AT_ranges (0x000004e0
[0x00000506, 0x00000560)
[0x00000576, 0x000005a2))
DW_AT_call_file ("/Users/abc/.abc/build/123456/workspace/ios_out/arm/../../abc_core/src/abc_engine_impl.cc")
DW_AT_call_line (37)
DW_AT_call_column (0x0a)
0x00000ee4: DW_TAG_inlined_subroutine
DW_AT_abstract_origin (0x00000ec1 "AbcAppContext")
DW_AT_ranges (0x000004f8
[0x00000506, 0x00000560)
[0x00000576, 0x000005a2))
DW_AT_call_file ("/Users/abc/.abc/build/123456/workspace/ios_out/arm/../../abc_core/public/abc_core/abc_app_context.h")
DW_AT_call_line (12)
DW_AT_call_column (0x08)
复制代码
由于 DWARF 中定义的源码路径本地并不存在,因此 LLDB 并不能进入源码调试模式,这时候要实现本地源码调试有两个方案:
三、免编译调试
幸运的是 LLDB 提供了命令可以修改路径,将二进制中的路径前缀映射到本地的真实路径,因此你可以在 Debug 控制台中输入以下指令实现本地调试:
settings set target.source-map [二进制中的源码路径] [本地代码路径]
复制代码
再次点击单步调试后,我们就可以看到熟悉的源码调试界面:
但这还是太麻烦了,首先这个命令不一定记得住,然后还要从二进制中找到需要映射的路径地址,而且每次重新运行 app 之后又得重新输入一遍;因此,我们利用 LLDB 提供的 lldbinit 和 Python API,这些步骤都自动化完成,真正实现一键调试体验。
lldbinit 是 LLDB 在启动调试的时候提供给开发者自定义调试命令的接口文件,我们可以在源码根目录下放置一个 lldbinit 用于自定义源码映射的逻辑,于是调试的流程就变成:
四、自动映射源码
增加一个 Python 文件,例如 debug.py,存放在源码目录下,用 Python 实现以下伪代码步骤:
1)通过 Python API 查找指定的 Symbol 调试信息:
# symbol_name 为这个库中的任意一个符号名
funcs = target.FindGlobalFunctions(symbol_name, 0, lldb.eMatchTypeNormal)
# 通过 lldb API 获取到这个符号的源码文件信息
symbol = funcs.GetContextAtIndex(0)
symbol_file = "%s" % symbol.GetCompileUnit().GetFileSpec()
复制代码
2)约定规则,将 Python 文件默认认为存放在源代码的根目录,这样可以比较方便的获取本地源码路径:
local_source_path = os.path.dirname(__file__)
复制代码
3)然后需要找到调试信息中存储的路径与本地路径之间的前缀映射关系,可以通过遍历路径目录的方式逐级查找本地对应的源码是否存在:
src_comps = symbol_file.split("/")
for i in range(len(src_comps)):
if len(src_comps[i]) == 0:
continue
suffix_path = "/".join(src_comps[i:])
detect_path = "/".join([local_source_path, suffix_path])
detect_file = Path(detect_path)
if detect_file.is_file() == False:
continue
print("-> Matched:", detect_path)
prefix_path = "/".join(src_comps[:i])
复制代码
4)再调用 lldb 命令将源码路径映射到本地路径:
target.GetDebugger().HandleCommand("settings set target.source-map '%s' '%s'" % (prefix_path, local_source_path))
复制代码
5)以上替换的主要逻辑实现,需要通过一个 lldbinit 将这个方法添加给 lldb 运行时,我们可以通过添加 stop-hook 的方式让上述脚本自动运行:
lldbinit:
command script import -c debug.py
# 这里通过指定一个 Library 中一定存在符号名用于查找调试符号中的源码目录
target stop-hook add -P debug.DebugHook -k "symbol" -v "::FuncAbc"
复制代码
五、工程改造
最后,通过一些简单的工程改造,便可以将调试应用到 Android 和 iOS 的日常开发环境中:
1)编译参数增加变量调试信息,大部分情况下不需要处理,但基于不同工具链或编译参数构建的二进制库中,有的缺少了变量的调试信息(通过 dwarfdump 可以查看是否存在 DW_TAG_variable 信息),导致在单步调试的过程中无法打印或者查看变量,只需要编译参数中增加或修改-gfull 即可;
2)DoNotStrip,Android 工程中,为了包大小通常会将 so strip 掉调试信息,因此在 Debug 环境下,可以将 so 改造成 DoNotStrip。
六、最后
通过这几十行的脚本,让 C/C++二进制库在各平台上能够使用默认的 IDE 快速进行源码调试,有助于提升问题排查效率和打破上下游边界。
除了应用在 C/C++模块,以上原理同样适用于 Swift 或者 ObjC、Rust 等其他任何能够生成 DWARF 调试信息并支持使用 lldb 调试的语言。
附实现源码
lldbinit:
# LLDB commands for quickly debug C/C++ libraries with source code
command script import -c debug.py
target stop-hook add -P debug.DebugHook -k "symbol" -v "::FuncAbc"
复制代码
debug.py:
import lldb
import os
from pathlib import Path
class DebugHook:
def __init__(self, target, extra_args, internal_dict):
self.source_mapped = False
self.symbol_name = extra_args.GetValueForKey("symbol").GetStringValue(100)
print("With Symbol:", self.symbol_name)
def handle_stop(self, exe_ctx, stream):
if self.source_mapped and exe_ctx != None:
# 已经做过源码映射,无需再次映射
return
target = exe_ctx.GetTarget()
print("-> Target Stopped:", target)
print("-> Searching Symbol:", self.symbol_name)
funcs = target.FindGlobalFunctions(self.symbol_name, 0, lldb.eMatchTypeNormal)
if funcs.GetSize() == 0:
print("** No Symbol Found")
return
symbol = funcs.GetContextAtIndex(0)
symbol_file = "%s" % symbol.GetCompileUnit().GetFileSpec()
print("-> Symbol Source:\n ", symbol_file)
local_source_path = os.path.dirname(__file__)
print("-> Local Source:\n ", nest_path)
# 便利二进制中源码路径的各层级目录,查找与本地源码匹配的目录路径
src_comps = symbol_file.split("/")
source_mapped = False
for i in range(len(src_comps)):
if len(src_comps[i]) == 0:
continue
suffix_path = "/".join(src_comps[i:])
detect_path = "/".join([nest_path, suffix_path])
detect_file = Path(detect_path)
if detect_file.is_file() == False:
continue
print("-> Matched:", detect_path)
prefix_path = "/".join(src_comps[:i])
print("-> Mapping Source:\n '%s' -> '%s'" % (prefix_path, nest_path))
target.GetDebugger().HandleCommand("settings set target.source-map '%s' '%s'" % (prefix_path, nest_path))
print("-> Done, Feel free to deubg now!")
# Mark this time as mapped
source_mapped = True
break
if source_mapped == False:
print("-> Source Mapping Failed: No local source path matched")
def __lldb_init_module(debugger, internal_dict):
print('source-debug enabled')
复制代码
评论