iOS组件二进制源码调试热切换方案

Anyhong 2020年05月01日 532次浏览

现状

现在工程中大量组件已经是二进制形式接入,二进制接入带来的好处是工程编译时间短,但是弊端也很明显,那就是调试麻烦,打成二进制的组件就是一个黑盒,工程遇到crash或者断点调试的时候就没法看到源码,当前工程支持二进制和源码两种接入方式,可以通过调整podfile中的组件源切换成源码接入,但是这样又得重新install安装源码,再重新编译工程,整个过程最快也得耗时数分钟,且偶发的crash还无法还原堆栈情况。

崩溃堆栈

那么在组件是二进制接入的情况下,能不能够实现在断点中断或者crash产生的时候快速关联映射到源码?
答案是可以的!

DWARF

DWARF是一种调试信息格式,通常用于源码级别调试。能够为调试器提供必要的调试信息,例如PC地址对应的文件名及行号等信息。如果在打包静态库的时候,没有裁减掉调试信息,那么静态库Mach-O文件中存在一个__DWARF段,这个段就保存了相关调试信息,包含符号对应的源码文件位置等信息,在用lldb进行调试的时候,调试器就会更新__DWARF段的相关信息去查找源码,然后关联显示出来。可以使用系统自带的DWARF查看工具dwarfdump查看静态库的调试信息,可以看到如下类似调试信息,包含符号对应的源文件地址,源文件中行数等详细信息:

dwarfdump StaticFramework 
StaticFramework(armv7)(StaticFrameworkObj.o):	file format Mach-O arm

.debug_info contents:
0x00000000: Compile Unit: length = 0x00000168 version = 0x0004 abbr_offset = 0x0000 addr_size = 0x04 (next unit at 0x0000016c)

0x0000000b: DW_TAG_compile_unit
              DW_AT_producer	("Apple clang version 11.0.0 (clang-1100.0.33.17)")
              DW_AT_language	(DW_LANG_ObjC)
              DW_AT_name	("/Users/admin/Workspace/Sourcecode/Test/BinaryDebug/Framework/StaticFramework-0.1.0/StaticFramework/StaticFrameworkObj.m")
              DW_AT_stmt_list	(0x00000000)
              DW_AT_comp_dir	("/Users/admin/Workspace/Sourcecode/Test/BinaryDebug/Framework/StaticFramework-0.1.0")
              DW_AT_GNU_pubnames	(true)
              DW_AT_APPLE_optimized	(true)
              DW_AT_APPLE_major_runtime_vers	(0x02)
              DW_AT_low_pc	(0x0000000000000000)
              DW_AT_high_pc	(0x0000000000000098)

····

0x000000a3:   DW_TAG_subprogram
                DW_AT_low_pc	(0x0000000000000000)
                DW_AT_high_pc	(0x0000000000000050)
                DW_AT_frame_base	(DW_OP_reg29 W29)
                DW_AT_object_pointer	(0x000000c0)
                DW_AT_call_all_calls	(true)
                DW_AT_name	("-[StaticFrameworkObj sf_test]")
                DW_AT_decl_file	("/Users/admin/Workspace/Sourcecode/Test/BinaryDebug/Framework/StaticFramework-0.1.0/StaticFramework/StaticFrameworkObj.m")
                DW_AT_decl_line	(13)
                DW_AT_prototyped	(true)
                DW_AT_type	(0x00000147 "NSString*")
                DW_AT_APPLE_optimized	(true)

在打包静态库的时候,选择保留调试信息(Generate Debug Symbols = Yes,默认就是打开的),如果组件是用二进制接入,那么将静态库二进制对应版本的源码放到打包静态库时对应的源码位置,Xcode在调试的时候就会自动关联到源码并展示出来。但是往往打包静态库的源码路径变化不一,这就很难做到本地源码路径和静态库打包时候的源码路径保持一致。这个时候可以用到LLDB的source-map源码映射命令来自定义映射源码路径。

source-map

LLDB 全称 Low Level Debugger,轻量级的高性能调试器,默认内置于Xcode中,在开发调试的时候LLDB一些基本的命令都有使用到,不再介绍。
下图示例,当 crash 发生的时候,全局异常断点定位到如下位置:
断点位置

  • 使用 image lookup 命令查找崩溃地址所在模块相关信息
(lldb) image lookup -v --address 0x10000a4b8
      Address: HostApp[0x00000001000064b8] (HostApp.__TEXT.__text + 1860)
      Summary: HostApp`-[StaticFrameworkObj sf_test_crash] + 60 at StaticFrameworkObj.m
       Module: file = "/Users/admin/Library/Developer/Xcode/DerivedData/HostApp-dakqulhvgxmjhoboteitwxxvpzro/Build/Products/Debug-iphoneos/HostApp.app/HostApp", arch = "arm64"
  CompileUnit: id = {0x00000000}, file = "/Users/admin/Workspace/Sourcecode/Test/BinaryDebug/Framework/StaticFramework-0.1.0/StaticFramework/StaticFrameworkObj.m", language = "objective-c"
     Function: id = {0x500000104}, name = "-[StaticFrameworkObj sf_test_crash]", range = [0x000000010000a47c-0x000000010000a4d4)
     FuncType: id = {0x500000104}, byte-size = 0, decl = StaticFrameworkObj.m:22, compiler_type = "void (void)"
       Blocks: id = {0x500000104}, range = [0x10000a47c-0x10000a4d4)
    LineEntry: [0x000000010000a4b8-0x000000010000a4bc): /Users/admin/Workspace/Sourcecode/Test/BinaryDebug/Framework/StaticFramework-0.1.0/StaticFramework/StaticFrameworkObj.m
       Symbol: id = {0x00000089}, range = [0x000000010000a47c-0x000000010000a4d4), name="-[StaticFrameworkObj sf_test_crash]"

找到调试信息中记录的源文件原始地址:/Users/admin/Workspace/Sourcecode/Test/BinaryDebug/Framework/StaticFramework-0.1.0/StaticFramework/StaticFrameworkObj.m

  • 使用 source-map 命令映射本地源码地址。图中示例的二进制对应源码文件存在于本地 /Users/admin/lldb_source_cache/StaticFramework-1/StaticFramework/StaticFrameworkObj.m 路径,使用如下命令进行调试源码本地路径映射:
settings set target.source-map /Users/admin/Workspace/Sourcecode/Test/BinaryDebug/Framework/StaticFramework-0.1.0/StaticFramework/StaticFrameworkObj.m /Users/admin/lldb_source_cache/StaticFramework-1/StaticFramework/StaticFrameworkObj.m
  • 执行完上面源文件路径映射命令后,在Xcode控制台执行Step Over,然后在Xcode堆栈视图中点击选择崩溃发生的堆栈,对应的源文件就显示出来了。

调试过程

工程化

上面通过LLDB的 image lookup 和 source-map 两个命令已经能够实现自定义映射源码地址了,但是每个文件路径都去手动输入指令查找然后再输入指令去映射自定义路径会显得略繁琐,这个时候可以通过自定义LLDB扩展python脚本来简化操作。

  • 打开 ~/.lldbinit 文件(如果没有就创建),在文件中添加LLDB自定义扩展python脚本路径,Xcode在启动的时候会自动加载 ~/.lldbinit 里的脚本命令。
command script import /Users/admin/Desktop/workflow/lldb_source/lldb_source.py
  • 实现自定义扩展脚本, lldb_source.py 文件实现
#!/usr/bin/python
#encoding=utf-8

from __future__ import print_function

import inspect
import lldb
import optparse
import shlex
import sys
import os
import re

class MapsourceCommand:
    # 命令名称
    program = 'mapsource'
    # 二进制对应版本的本地源码文件路径,eg./Users/admin//lldb_source_cache
    cache_path = os.environ['HOME'] + '/lldb_source_cache'

    @classmethod
    def register_lldb_command(cls, debugger, module_name):
        parser = cls.create_options()
        cls.__doc__ = parser.format_help()
        # Add any commands contained in this module to LLDB
        command = 'command script add -c %s.%s %s' % (module_name, cls.__name__, cls.program)
        debugger.HandleCommand(command)
        print('The "{0}" command has been installed, type "help {0}" for detailed help.'.format(cls.program))

    @classmethod
    def create_options(cls):
        usage = "usage: %prog 0x10413e47c"
        description = ('')
        parser = optparse.OptionParser(
            description=description,
            prog=cls.program,
            usage=usage,
            add_help_option=False)
        return parser

    def get_long_help(self):
        return self.help_string

    def __init__(self, debugger, unused):
        self.parser = self.create_options()
        self.help_string = self.parser.format_help()

    def __call__(self, debugger, command, exe_ctx, result):
        command_args = shlex.split(command)
        try:
            (options, args) = self.parser.parse_args(command_args)
        except:
            result.SetError("option parsing failed")
            return

        interpreter = lldb.debugger.GetCommandInterpreter()
        returnObject = lldb.SBCommandReturnObject()
        interpreter.HandleCommand('image lookup -v --address ' + command, returnObject)
        output = returnObject.GetOutput();

        filePath = re.match(r'(.|\n)*file = "(.*?)".*', output,re.M).group(2)
        fileName = re.match(r'/.*/(.*)', filePath).group(1)
        sourcePath = os.popen('mdfind -onlyin ' + self.cache_path + ' ' +fileName).read().replace('\n','')

        if len(filePath) == 0:
            print('Debuginfo source path:'.ljust(30) + 'No found')
            return
        else:
            print('Debuginfo source path:'.ljust(30) + filePath)


        if len(sourcePath) == 0:
            print('Local source path:'.ljust(30) + 'No found')
            return
        else:
            print('Local source path:'.ljust(30) + sourcePath)

        interpreter.HandleCommand('settings set target.source-map ' + filePath + ' ' + sourcePath, returnObject)


def __lldb_init_module(debugger, dict):
    for _name, cls in inspect.getmembers(sys.modules[__name__]):
        if inspect.isclass(cls) and callable(getattr(cls, "register_lldb_command", None)):
            cls.register_lldb_command(debugger, __name__)
  • 使用方式:
    当断点中断时,使用 mapsource + [断点处符号地址]即可快速查找当前堆栈调试信息中的源码路径,然后再去查找本地指定路径中是否存在对应的源码文件,如果有则进行源码文件路径映射。例如:
mapsource 0x10466e4b8

执行完上面步骤源文件映射命令后,Xcode 控制台执行 Step Over,然后再选中堆栈中崩溃发生的符号位置,源文件就显示出来了。操作步骤