写点什么

字节跳动 DanceCC 工具链系列之 Xcode LLDB 耗时监控统计方案

  • 2022 年 9 月 07 日
    浙江
  • 本文字数:4859 字

    阅读完需:约 16 分钟

字节跳动 DanceCC 工具链系列之Xcode LLDB耗时监控统计方案

作者:李卓立 仲凯宁

背景介绍

《字节跳动 DanceCC 工具链系列之Swift 调试性能的优化方案》[1]一文中,我们介绍了如何使用自定义的工具链,来针对性优化调试器的性能,解决大型 Swift 项目的调试痛点。

在经过内部项目的接入以及一段时间的试用之后,为了精确测量经过优化后的 LLDB 调试 Xcode 项目效率提升效果,衡量项目收益,需要开发一套能够同时获取 Xcode 官方工具链与 DanceCC 工具链调试耗时的耗时监控方案。

一般来说,LLDB 内置的工作耗时,可以通过输入log timers dump来获取粗略的累计耗时,但是这个耗时只包括了源代码中插入了LLDB_SCOPED_TIMER()宏的函数,并不代表完整的真实耗时。并且这个耗时统计需要用户手动触发,如果要单独获取某次操作的耗时还需要先进行 reset 操作清空之前的耗时记录;对于我们目前的需求而言不够精确也不够自动。

因此 DanceCC 提出了一套专门的方案。方案原理基于 LLDB Plugin[2],利用 Fishhook[3],从 LLDB 的 Script Bridge API[4]层面拦截 Xcode 对 LLDB 调用,以此来进行耗时监控统计。

注:LLDB 论坛也有贡献者,讨论另一套内置的 LLDB metries 方案[5],但是目标侧重点和我们略有不同,并且截至发稿日未有完整的结论,因此仅在引用链接提及供读者延伸阅读。

方案原理

LLDB Plugin

Apple 在其 LLDB 和早期 Xcode 集成中,为了不侵入一些容易改动的上层逻辑,引入了 LLDB Plugin 的设计和支持。

每个 Plugin 是一个动态链接库,需要实现特定的 C++/C 入口函数,由 LLDB 主进程在运行时通过dladdr找到函数入口并加载进内存。目前有两种 Plugin 的接口形式(网上常见第一种)

  • 新 Plugin 接口:

namespace lldb {bool PluginInitialize(SBDebugger debugger);}
复制代码

这种 Plugin,需要用户在脚本中手动按需加载,并常驻在内存中:

plugin load /path/to/plugin.dylib
复制代码
  • 老 Plugin 接口:

extern "C" bool LLDBPluginInitialize(void);extern "C" void LLDBPluginTerminate(void);
复制代码

将编译的动态库放入以下两个目录,即可自动被加载,无法手动控制时机,在当前调试 Session 结束时卸载:

/path/to/LLDB.framework/Resources/Plugins~/Library/Application Support/LLDB/PlugIns
复制代码

注入动态库



正常流程中,Xcode 开始调试时会启动一个 lldb-rpc-server 的进程,这个进程会加载 Xcode 默认工具链,或指定工具链中的 LLDB.framework,并且通过这个动态库中暴露出的 Script Bridge API 调用 LLDB 的各功能。



监控流程中,我们向 lldbinit 文件中添加了command script import ~/.dancecc/dancecc_lldb.py,用于在 LLDB 启动时加载脚本,脚本内会执行plugin load ~/.dancecc/libLLDBStatistics.dylib,加载监控动态库。

监控动态库在被加载时,因为被加载的动态库和 LLDB.framework 不在一个 MachO Image 中,我们能够通过 Fishhook 方案,对 LLDB.framework 暴露出的我们关心的 Script Bridge API 进行 hook。

hook 成功之后,每次 Xcode 对 Script Bridge API 进行调用都会先进入我们的监控逻辑。此时我们记录时间戳来计时,然后再进入 LLDB.framework 中的逻辑,获取结果后返回给 lldb-rpc-server,并在 Xcode 的 GUI 中展示。

Hook SB API

Hook SB API 时,需要一份含有要部署的 LLDB.framework 的头文件(Xcode 并未内置)。由于上述的流程使用了动态链接的 LLDB.framework,我们选择了 Swift 5.6 的产物,并 tbd 化避免仓库膨胀。

由于 LLDB Script Bridge API 相对稳定,因此可以使用一个动态库实现,通过运行时来应对不同版本的 API 变化(极少出现,截止发文调研 5.5~5.7 之间 Xcode 并没有改变调用接口)。

对于 hook C++函数的方式,这里借用了 Fishhook 进行替换。原 C++的函数地址,可通过 dlsym 调用得到。注意 C++函数名使用 mangled 后的名称(在 tbd 文件中可找到)。

////// Hook a SB API using the stub method defined with the macros above///#define LLDB_HOOK_METHOD(MANGLED, CLASS, METHOD) \Logger::Log("Hook "#CLASS"::"#METHOD" started!"); \ptr_##MANGLED.pvoid = dlsym(RTLD_DEFAULT, #MANGLED); \if (!ptr_##MANGLED.pvoid) { \    Logger::Log(dlerror()); \    return; \} \if (rebind_symbols((struct rebinding[1]){{#MANGLED, (void *) hook_##MANGLED, (void **) & ptr_##MANGLED.pvoid }}, 1) < 0) { \    Logger::Log(dlerror()); \    return; \} \Logger::Log("Hook "#CLASS"::"#METHOD" succeed!");
复制代码

C++的成员函数的函数指针第一个应该是 this 指针,这里用 self 命名。也可以调用原实现先获取结果,再根据结果进行相关的统计逻辑。

////// Call the original implementation for member function///#define LLDB_CALL_HOOKED_METHOD(MANGLED, SELF, ...)  (SELF->*(ptr_##MANGLED.pmember))(__VA_ARGS__)
复制代码

最终整体代码中 Hook 一个 API 就可以写为:

// 假设期望Hook方法为:char * ClassA::MethodB(int foo, double bar)// 这里写被Hook的方法实现LLDB_GEN_HOOKED_METHOD(mangled, char *, ClassA, MethodB, int foo, double bar) {  return LLDB_CALL_HOOKED_METHOD(mangled, self, 1, 2.0);}// 这里是执行Hook(只执行一次)LLDB_HOOK_METHOD(mangled, ClassA, MethodB);
复制代码

耗时监控场景

目前耗时监控包含下列场景:

  • 展示 frame 变量

  • 展开变量的子变量

  • 输入 expr 命令(p, po 命令也是 expr 命令的 alias)

  • Attach 进程耗时

  • Launch 进程耗时

展示 frame 变量场景

经过观察,我们发现当在 Xcode 中进入断点,GUI 显示当前 frame 的变量时,lldb-rpc-server 调用 SB API 的流程为先调用SBFrame::GetVariables方法,返回一个表示当前 frame 中所有变量的SBValueList对象,然后再调用一系列方法获取它们的详细信息,最后调用SBListener::GetNextEvent等待下一个 event 出现。因此我们计算展示 frame 变量的流程为,当SBFrame::GetVariables方法被调用时记录当前时间戳,等待直至SBListener::GetNextEvent方法被调用,再记录此时时间戳算出耗时。

展示子变量场景

经过观察,我们发现当在 Xcode 中展开变量,需要显示当前变量的子变量时,lldb-rpc-server 调用 SB API 的流程为先调用SBValue::GetNumChildren方法,返回表示当前变量中子变量的数目,然后再调用SBValue::GetChildAtIndex获取这些子变量以及它们的的详细信息,最后调用SBListener::GetNextEvent等待下一个 event 出现。因此我们计算展示 frame 变量的流程为,当SBValue::GetNumChildren方法被调用时记录当前时间戳,等待直至SBListener::GetNextEvent方法被调用,再记录此时时间戳算出耗时。

输入 expr 命令场景

Xcode 中用户直接从 debug console 中输入 LLDB 命令的方式是不走 SB API 的,因此无法直接通过 hook 的方式获取耗时。我们发现大多数开发者,都习惯在 debug console 中使用 po/expr 等命令而不是 GUI 点击输入框。因此我们专门做了支持,通过 SB API 的 OverrideCallback 方法进行了拦截。

LLDB.framework 暴露了一个用于注册在 LLDB 命令前调用自定义 callback 的接口:SBCommandInterpreter::SetCommandOverrideCallback;我们利用了这个接口注册了一个用于拦截并获取用户输入命令的 callback 函数,这个 callback 会记录当前耗时,然后调用SBDebugger::HandleCommand来处理用户输入的命令。但是当SBDebugger::HandleCommand被调用时,我们注册的 callback 一样会生效,并再次进入我们拦截的 callback 流程中。

为了解决这个递归调用自己的问题,我们通过一个static bool isTrapped变量表示当前进入的 expr 命令是否被 OverrideCallback 拦截过。如果未被拦截,将 isTrapped 置 true 表示 expr 命令已经被拦截,则调用 HandleCommand 方法重新处理 expr 命令,此时进入的 HandleCommand 方法同样会被 OverrideCallback 拦截到,但是此时 isTrapped 已经被置 true,因此 callback 返回 false 不再进入拦截分支,而是走原有逻辑正常执行 expr 命令



Attach 进程场景

Attach 进程时,lldb-rpc-server 会调用SBTarget::Attach方法,常见于真机调试的场景。这里在调用前后记录时间戳,计算出耗时即可。

Launch 进程场景

Launch 进程时,lldb-rpc-server 会调用SBTarget::Launch方法,常见于模拟器启动并调试的场景。这里在调用前后记录时间戳,计算出耗时即可。

上报部分

数据上报

为了进一步还原耗时的细节,除了标记场景的类型以外,我们还会统一记录这些非敏感信息:

  • 正在调试的进程名,用于区分多调试 Session 并存的场景

  • 正在调试的 App 的 Bundle ID

  • 当前断点位置在哪个文件

  • 当前断点位置在哪一行

  • 当前断点位置在哪个函数

  • 当前断点位置在哪个 Module

  • 表示当前使用的工具链是 Xcode 的还是 DanceCC 的

  • 表示当前使用的 Swift 版本(与 Xcode 版本一一对应)

在内网提供的版本中,也通过外部环境变量,得知对应的 App 的仓库标识,用于在内网的数据统计平台上展示和区分。如图,这是内网大型 Swift 工程,飞书 iOS App 接入 DanceCC 工具链之后,某时间的耗时数据,可以明显看出,DanceCC 相比于 Xcode 的变量显示耗时,优化了接近一个数量级。




极端耗时场景堆栈收集

除了基本的耗时时间收集以外,我们还希望能够及时发现新增的极端耗时场景和新问题,因此设计了一套极端耗时情况下的调试器堆栈收集机制,目前只要发现,展示变量场景和输入 expr 命令耗时超过 10 秒种,则会记录 LLDB.framework 的当前调用堆栈的每个函数耗时,并将数据上报到后台进行统计和人工分析。堆栈收集使用了log timers dump所产出的堆栈和耗时信息,本质上是 LLDB 代码中通过LLDB_SCOPED_TIMER()宏记录的函数,其会使用编译器的__PRETTY_FUNCTION__能力来在运行时得到一个用于人类可读的函数名。在获取到调用前和调用后的两条堆栈后,我们会对每个函数进行 Diff 计算和排序,将最耗时的前 10 条进行了采样记录,使用字符串一同上传到统计后台中。



总结

无论是 App 还是工具链,在做性能优化的同时,数据指标建设是必不可少的。这篇文章讲述的监控方案,在后续迭代 DanceCC 工具链的时候,能够明确相关的优化对实际的调试体验有所帮助,能避免了主观和片面的测试来评估调试器的可用性。除了调试器之外,DanceCC 工具链还包括诸如链接器,编译器,LLVM 子工具(如 dsymutil)等相关优化,系列文章也会进一步进行相关的分享,敬请期待。

引用链接

  1. https://mp.weixin.qq.com/s/MTt3Igy7fu7hU0ooE8vZog

  2. https://reviews.llvm.org/rG4272cc7d4c1e1a8cb39595cfe691e2d6985f7161

  3. https://lldb.llvm.org/design/api.html

  4. https://github.com/facebook/fishhook

  5. https://discourse.llvm.org/t/rfc-lldb-telemetry-metrics/64588

关于字节终端技术团队

字节跳动终端技术团队 (Client Infrastructure) 是大前端基础技术的全球化研发团队(分别在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队),负责整个字节跳动的大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率;支持的产品包括但不限于抖音、今日头条、西瓜视频、飞书、瓜瓜龙等,在移动端、Web、Desktop 等各终端都有深入研究。

加入我们

我们是字节的 Client Infrastructure 部门下的编译器工具链团队,团队成员由编译器专家及构建系统专家组成,我们基于开源的 LLVM/Swift 项目提供深度定制的 clang/swift 编译器、链接器、lldb 调试器和语言基础库等工具及优化方案,覆盖构建性能优化应用性能稳定性优化等场景,并在业务研发效率和应用品质提升方面取得了显著的效果,同时,在实践的过程中我们也看到了很多令人兴奋的新机会,希望有更多对编译工具链技术感兴趣的同学加入我们一起探索。

工作地点

深圳、北京

职位描述

  1. 设计与实现高效的编译器/链接器/调试器优化

  2. 自定义 LLVM 工具链的维护和开发

  3. 提升 Client Infrastructure 编译工具链的性能及稳定性

  4. 协同业务团队推动技术方案的落地

职位要求

  1. 至少熟练掌握 C++/Objective-C/Swift 其中一门语言,熟悉语言特性的实现细节

  2. 熟悉编程语言的实现技术,如解释器、编译器、内存管理方面的实现

  3. 熟悉某个构建系统 (CMake/Bazel/Gradle/XCBuild 等)

  4. 有编译器、链接器、调试器等工具的开发和优化经验优先,有 LLVM、GCC 等项目项目开发经历优先

  5. 有移动端技术栈开发经验优先

职位链接

点击链接投递简历:https://job.toutiao.com/s/FBS9cLk!

发布于: 刚刚阅读数: 3
用户头像

还未添加个人签名 2021.05.17 加入

字节跳动终端技术团队是大前端基础技术行业领军者,负责整个字节跳动的大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率,在移动端、Web、Desktop等各终端都有深入研究。

评论

发布
暂无评论
字节跳动 DanceCC 工具链系列之Xcode LLDB耗时监控统计方案_ios_字节跳动终端技术_InfoQ写作社区