iOS 二进制调试

2,239 阅读9分钟

背景

Cocoapods 组件化已成为大项目架构的主流方式,其优势明显:

  • 高内聚、低耦合
    • 组件职责单一,功能聚合 
    • 通过协议路由的方式与其他组件通信
  • 编译提速
    • 工程编译时,其他依赖工程可以二进制库方式引入
    • 组件化单组件能独立运行,减少开发调试组件时间成本
  • 权限管理:便于业务划分,不同业务组件可添加权限管理

Cocoapods 组件化带来优势的同时,也存在阻碍,二进制调试虽然在编译速度上能胜一筹,却无法在调试的时候提供帮助,例如,开发在调试二进制内的问题的时候,Xcode 会崩溃到二进制的汇编语言中,并不能提供给开发更多的信息,大大增加了调试的难度。Apple 官方 提供了几种来支持二进制源码调试的方式,接下来,让我们一起揭开这面纱。

调试原理

LLDB 动态调试:程序运行起来时,通过下断点,打印日志、堆栈等方式查看程序的各个函数调用环境和流程的过程。

LLDB 动态调试通过 debugserver 程序来作为桥梁,连通着电脑上的 Xcode 和手机上的应用序,具体流程如下:

  • LLDB 会将终端命令传送给 debugserver(这里可以理解成 debugserver  是用来监听 LLDB 传出的指令的)
  • debugserver 接收到指令后,会执行到 App 上
  • App 执行这些调试指令后,会将反馈信息交给 debugserver 
  • debugserver 再将信息反馈给 LLDB 调试器,交由 LLDB 输出

flowchart.png

debugserver 最初存放位置/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/De viceSupport/16.1/DeveloperDiskImage.dmg/usr/bin/debugserver

image.png

当 Xcode 识别到 iPhone 设备时,会自动将 debugserver 安装到这个手机上 /Developer/usr/bin/debugserver

image.jpeg

默认情况下,/Developer/usr/bin/debugserver 只能调试通过 Xcode 安装的 App,因为没有权限,无法调试其他 App。要想调试其他 App,就需要对 debugserver 进行重新签名,并添加调试相关的权限,可参考全面剖析 LLDB 调试器 - 简书

Swfit 编译过程同步生成调试信息,LLDB 作为调试器,使用这些由编译器生成的调试信息来执行调试任务,流程如下:

  • 调试信息的生成
// -g  Emit debug info. This is the preferred setting for debugging with LLDB.
swiftc -g -o MyApp MyApp.swift
  • 调试信息文件

    • DWARF 调试信息内容,信息都存储在二进制文件的特定的 section 中(例如:debug_info、debug_line、debug_str 等)
      • 数据类型:描述源代码中使用的数据类型
      • 符号表:函数名、变量名及其对应的地址
      • 源代码映射:源代码行号与生成机器码的映射关系
      • 作用域和范围:变量和函数的作用域信息
    • .swiftsourceinfo 文件:swiftc 编译器根据 Swift 源代码生成,用于在调试时将源代码与编译后的代码进行关联,包含了源代码中每个语法单元的位置信息,以及其他相关的调试信息。
  • LLDB 调试器通过读取并使用编译器生成的 DWARF 调试信息来提供调试功能。

编译器生成的调试信息可知,lldb 主要通过下述文件来索引对应的文件信息

  • .swiftsourceinfo 用来将源代码与编译后的代码进行关联,索引到对应的源码文件。
  • debug_str 用来记录着调试源码的文件路径、变量名、函数名等信息。

Xcode 源码调试的时候,在源码中下断点能准确的跳转到对应的源码位置,由于 LLDB 程序是附着在应用程序上的,那程序编译后的输出会有文件记录着源码路径来索引到对应的源码文件,如果在二进制调试的时候,将这个映射关系索引到本地的源码路径,那是不是也能在调试二进制库的时候起到查看源码、调试变量、输出日志等效果呢?接下来,我们来一起看下生成二进制的结构,通过结构来找到调试源码路径映射关系

二进制库结构

静态库

Build Settings 中 Debug Infomation Format 设置为 DWARF with dSYM File

image6.png

Alamofire 静态库结构

  • Alamofire:二进制文件,包含 DWARF 调试信息内容 image1.png
  • Headers:头文件信息 Alamofire-Swfit.h ,供 OC 调用
  • Modules
    • arm64-apple-ios.abi.json:二进制接口信
    • arm64-apple-ios.private.swiftinterface:Swift 私有接口信息,包含更多详细信息,作用于提高编译器的增量编译性能和模块缓存的有效性
    • arm64-apple-ios.swiftinterface:Swift 公有接口信息
    • arm64-apple-ios.swiftdoc:文档文件,包含 Swift 模块相关的文档注释和接口信息、类型和成员信息、接口摘要等
    • arm64-apple-ios.swiftmodule:模块描述文件,包含 Swift 编译后的元数据和接口信息,帮助编译器理解公共接口,以便在模块之间进行类型检查、符号解析和代码提示等操作
    • arm64-apple-ios.swiftsourceinfo:调试信息文件,用于指导调试器在调试定位源代码位置,包含了源代码文件的路径映射信息,帮助调试器将二进制文件中的地址映射到源代码文件的对应位置。

动态库

Build Settings 中 Debug Infomation Format 设置为 DWARF with dSYM File

image2.png

Alamofire 动态库结构

  • Alamofire.framework 同静态库
  • Alamofire.framework.dSYM 调试信息文件,Alamofire.framework.dSYM -> 右击显示包内容
    image3.png image4.png

使用 MachO-View 软件查看文件内容

二进制源码调试方案

源码索引路径(.swiftsourceinfo)

索引到对应的源码位置,以便查看源码

source-info-import 工具可查看并修改  .swiftsourceinfo 二进制文件。

  • 查看  .swiftsourceinfo 文件
// 查看
./source-info-import ./Alamofire.framework/Modules/Alamofire.swiftmodule/Project/arm64-apple-ios.swiftsourceinfo > ./output.txt

xxx.png

  • 修改 .swiftsourceinfo 文件
./source-info-import --remap="/Users/xxx/Downloads/Alamofire-master=/Users/xxx/Desktop/bitcode_debug/xxx/Alamofire" ./Alamofire.framework/Modules/Alamofire.swiftmodule/Project/arm64-apple-ios.swiftsourceinfo arm64-apple-ios.swiftsourceinfo

imagesssss.png

最后,重新替换二进制库生成的 ./Alamofire.framework/Modules/Alamofire.swiftmodule/Project/arm64-apple-ios.swiftsourceinfo 文件即可。

源码调试路径(二进制文件)

下断点、变量、函数输出打印等

lldb 自定义命令切换

调试原理可知,Swift 编译器生成的二进制库内都包含着调试信息,lldb 仅借助编译器生成的调试信息(源码路径、变量、函数名等)来获取调试变量的输出。

images.png

上图可知,lldb 能通过 settings set targe.source-map 来重新映射调试源码路径

提示:不要在每个会话中执行此操作,而是创建一个在每个会话开始时运行的 lldb 初始化文件(在方案中指向此文件)

lldb 自定义命令

#!/usr/bin/env python3
#encoding=utf-8

import lldb
import re
import os
import json

# 重映射源码路径
# debugger: lldb 调试器
# command: lldb 指令
# result: 控制台输出结果
# internal_dict: 字典文件
def remapSourcePath(debugger, command, result, internal_dict):

    # 指令
    if command == "":
        print('command is null!!!')
        exit(1)

    # 编译源码与本地源码映射
    sourceMapFilePath = "~/Desktop/bitcode_debug/sourceMap.json"
    sourceMapFilePath = os.path.expanduser(sourceMapFilePath)

    # 获取 lldb 的命令交互环境,可以动态执行一些命令,比如 po obj
    interpreter = lldb.debugger.GetCommandInterpreter()
    
    # 创建一个对象,命令执行结果会通过该对象保存
    returnObject = lldb.SBCommandReturnObject()
    
    if '-a' in command:
        # 指令
        address = command.replace("-a ", "")
        # 通过 image loopup 命令查找输入符号地址所在的编译模块信息
        interpreter.HandleCommand('image lookup -v --address ' + address, returnObject)
        # 获取返回结果
        output = returnObject.GetOutput()
        # 正则匹配
        fileMatchs = re.match(r'(.|\n)*file = "(.*?)".*', output, re.M)
        if fileMatchs is not None:
            print('✔ bin compileSourcePath = ' + fileMatchs.group(2))
        else:
            print('✗ bin compileSourcePath not found, maybe the memory address is mistyped')
            
    else:
        with open(sourceMapFilePath, 'r') as f:
            sourceMapObject = json.load(f)
            customPathMap = sourceMapObject["customPathMap"]
            customItems = customPathMap.items()
            for libName, libInfo in customItems:
                if command.lower() in libName.lower():
                    # 库编译时源码路径
                    compileSourcePath = libInfo['compileSourcePath']
                    compileSourcePath = os.path.expanduser(compileSourcePath)
                    # 库调试时重新指向源码路径
                    localSourcePath = libInfo['localSourcePath']
                    localSourcePath = os.path.expanduser(localSourcePath)
                    # lldb 路径交换
                    interpreter.HandleCommand('settings set target.source-map ' + compileSourcePath + ' ' + localSourcePath, returnObject)
                    output = returnObject.GetOutput()
                    if output != "":
                        print('✓ remap failure!' + output)
                    else:
                        print('✓ remap success!')
                    break
      
# 添加一个 lldb 扩展命令 binReMap
def __lldb_init_module(debugger, internal_dict):
    debugger.HandleCommand('command script add binReMap -f BinReMapSource.remapSourcePath')

lldb 命令加载

~/.lldb 同级目录 ~/.lldbinit 文件追加命令(如下),lldb 启动时会自动加载命令。

command script import ~/Desktop/bindebug/BinMapSource.py

.lldbinit 文件不存在则自行创建

lldb 命令调试

Xcode 运行时会自动加载 LLDB 程序

  • 二进制源码路径指向未存在源码文件,则断点进入到汇编语言中

image11.png

  • 二进制源码路径指向存在源码文件,则断点后输入命令 binReMap Alamofire 进入到源码文件中,并可以调试

image12.png

image13.png

dSYM 配置 DBGSourcePathRemapping(调研中...)

image14.png

Swift 重映射路径

编译器编译过程中会生成 DWARF 格式的调试信息,并可以保存在 dSYM 文件中,但直接修改 dSYM 文件并不是一个直接支持的操作。

image1.png

上图可知,Swiftc 编译器也提供了重新映射调试路径的 -debug-prefix-map 编译选项,-debug-prefix-map 支持绝对路径和相对路径的映射关系关联,

  • 绝对路径:-debug-prefix-map /User/xxx/buildSourcePath=/User/xxx/debugSourcePath

  • 相对路径(Linker 支持):-debug-prefix-map /User/xxx/buildSourcePath=.

相对路径,目前尝试是 framework 文件的同级目录

image2.png

image3.png

Swiftc 编译器使用  -debug-prefix-map 编译选项,添加至 other swift flag

Clang 编译器使用  -fdebug-prefix-map 编译选项,添加至 other C flag

工程配置

Build Settings -> Other Swift Flags 添加 -debug-prefix-map,映射的路径可以是绝对路径也可以是相对路径.

orginSourceCodePath:编译出二进制库源码的路径

debugSourceCodePath:调试二进制库源码的路径

二进制调试信息验证

dwarfdump 工具可解析 Alamofire 二进制库调试信息

dwarfdump --debug-str ./Alamofire -o ./debug_info.txt

image4.png

二进制库文件(未重新映射调试路径),下图可看到源码路径记录的是工程编译时的路径

image5.png

二进制库文件(重新映射调试路径),下图可看到源码路径记录的是修改后的路径

image6.png

二进制源码调试

  • 修改调试源码路径,未修改 .swiftsourceinfo 映射路径

lldb 调试 step info 可以进入调试源码

image7.png

但在外部无法跳转到对应的函数实现中,只能看到暴露的接口信息。

image8.png

  • 未修改调试源码路径,修改 .swiftsourceinfo 映射路径(下图可以看到无法下断点调试)

image9.png

  • 修改调试源码路径,修改 .swiftsourceinfo 映射路径,可以正常跳转到源文件,并且能正常下断点调试

image10.png

若有不对之处,欢迎指正交流😄~~~

参考文件

🐛 LLDB

全面剖析 LLDB 调试器 - 简书

iOS 二进制切换源码调试 - 吴晓明

GHWBinaryMapSource/README.md at master · guohongwei719/GHWBinaryMapSource · GitHub

lldb 源码映射的技术实现 — 名字还没想好

www.wwdcnotes.com/notes/wwdc2…

github.com/qyang-nj/so…

milen.me — Apple's Linker & Deterministic Builds