iOS 移动端的 bug 的排查办法,你确定不看吗?
前言
不积跬步无以至千里,不积小流无以成江海。学如逆水行舟,不进则退。我是平平无奇游荡于各平台的搬运工。优秀的人已经点赞了。本文主要记录了 iOS 移动端的一个疑难 bug 的排查过程,以及介绍通过给 bitcode 打补丁重新生成机器码,为有问题的第三方库修复 bug 的方法。废话不多说,直接给大家上干货,希望能对你有所帮助,
主要涉及到的知识点如下:
- ARM 汇编
- C++ 运行时
- 静态库文件的结构
- bitcode 及 LLVM IR
\
平台监控找崩溃
通过内部的崩溃监控发现,有一个内部 App,近期出现了较多的崩溃现象。其中数量占比最多的崩溃,其崩溃线程捕获到的调用栈如下:
libsystem_kernel.dylib 0x00000001cc78c414 ___pthread_kill + 7
libsystem_c.dylib 0x00000001a7db2b74 _abort + 103
App 0x0000000103092868 ___48-[BLYLogicManager abortAfterSendingReportIfNeed]_block_invoke + 87
libdispatch.dylib 0x000000019e60824c __dispatch_call_block_and_release + 31
libdispatch.dylib 0x000000019e609db0 __dispatch_client_callout + 19
libdispatch.dylib 0x000000019e61aa68 __dispatch_root_queue_drain + 655
libdispatch.dylib 0x000000019e61b120 __dispatch_worker_thread2 + 115
libsystem_pthread.dylib 0x00000001ea1e77c8 __pthread_wqthread + 215
\
调用现场出端倪
这个调用栈并没有提供什么有效的信息,只能看出来是 bugly 框架已经检测到了崩溃创建了新的 dispatch queue 并终止进程,也就是说,其实有效的崩溃信息被 bugly 给吃掉了。
看一下其他线程,是否有可用的信息,一般可以在其他线程的调用栈上搜索以下内容:
-
_ZSt9terminateEv
: C++ 的终端异常处理(std::terminate(void)
) -
__sigtramp
: 信号中断处理例程入口
终于搜索到了以下内容:
Thread #52: id=1a6c6, name=
libsystem_kernel.dylib 0x00000001cc78cf5c ___ulock_wait + 7
libdispatch.dylib 0x000000019e60a528 __dispatch_thread_event_wait_slow + 55
libdispatch.dylib 0x000000019e618708 ___DISPATCH_WAIT_FOR_QUEUE__ + 351
libdispatch.dylib 0x000000019e6182b0 __dispatch_sync_f_slow + 147
App 0x00000001030925f0 -[BLYLogicManager executeEmergencyLogic:] + 695
App 0x000000010308b6a8 -[BLYCrashManager sendLiveCrashReport] + 203
App 0x000000010305f478 _BLYCrashHandlerCallback + 5555
App 0x000000010305bc2c _BLYBSDSignalHandlerCallback + 95
libsystem_platform.dylib 0x00000001ea1e1290 __sigtramp + 55
App 0x00000001029543dc *redacted*
App 0x00000001029543dc *redacted*
App 0x00000001028a1918 *redacted*
App 0x00000001027ea9c4 *redacted*
App 0x00000001027ea794 *redacted*
App 0x00000001027ead60 *redacted*
libsystem_pthread.dylib 0x00000001ea1e5b40 __pthread_start + 319
内部应用同时集成了 Bugly 和自有的崩溃捕获,通常情况下 Bugly 会在自己捕获完成后,将崩溃现场转交给其他框架,使两次捕获的崩溃现场相同。而这个崩溃则不然, Bugly 捕获了崩溃后,直接调用 abort
结束了应用,导致自有崩溃只捕获到了 SIGABRT
。
通过检查主线程调用栈,发现了一些不同:
Thread #0: id=1a0d3, name=
libsystem_kernel.dylib 0x00000001cc78c1ac ___psynch_cvwait + 7
libc++.1.dylib 0x00000001b3a25328 __ZNSt3__118condition_variable4waitERNS_11unique_lockINS_5mutexEEE + 27
App 0x000000010280e5c8 *redacted*
App 0x00000001027e9414 *redacted*
App 0x00000001027e9380 *redacted*
libsystem_c.dylib 0x00000001a7d930b8 ___cxa_finalize_ranges + 423
libsystem_c.dylib 0x00000001a7d93400 _exit + 27
UIKitCore 0x00000001a13d4bdc -[UIApplication _terminateWithStatus:] + 503
UIKitCore 0x00000001a0a23648 -[_UISceneLifecycleMultiplexer _evalTransitionToSettings:fromSettings:forceExit:withTransitionStore:] + 127
UIKitCore 0x00000001a0a23278 -[_UISceneLifecycleMultiplexer forceExitWithTransitionContext:scene:] + 219
UIKitCore 0x00000001a13ca644 -[UIApplication workspaceShouldExit:withTransitionContext:] + 211
FrontBoardServices 0x00000001ae6d2780 -[FBSUIApplicationWorkspaceShim workspaceShouldExit:withTransitionContext:] + 87
FrontBoardServices 0x00000001ae701390 ___63-[FBSWorkspaceScenesClient willTerminateWithTransitionContext:]_block_invoke_2 + 79
FrontBoardServices 0x00000001ae6e54a0 -[FBSWorkspace _calloutQueue_executeCalloutFromSource:withBlock:] + 239
FrontBoardServices 0x00000001ae701328 ___63-[FBSWorkspaceScenesClient willTerminateWithTransitionContext:]_block_invoke + 131
libdispatch.dylib 0x000000019e609db0 __dispatch_client_callout + 19
libdispatch.dylib 0x000000019e60d738 __dispatch_block_invoke_direct + 267
FrontBoardServices 0x00000001ae72a250 ___FBSSERIALQUEUE_IS_CALLING_OUT_TO_A_BLOCK__ + 47
FrontBoardServices 0x00000001ae729ee0 -[FBSSerialQueue _targetQueue_performNextIfPossible] + 447
FrontBoardServices 0x00000001ae72a434 -[FBSSerialQueue _performNextFromRunLoopSource] + 31
CoreFoundation 0x000000019e99176c ___CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 27
CoreFoundation 0x000000019e991668 ___CFRunLoopDoSource0 + 207
CoreFoundation 0x000000019e9909cc ___CFRunLoopDoSources0 + 375
CoreFoundation 0x000000019e98aa8c ___CFRunLoopRun + 823
CoreFoundation 0x000000019e98a21c _CFRunLoopRunSpecific + 599
GraphicsServices 0x00000001b648e784 _GSEventRunModal + 163
UIKitCore 0x00000001a13c8fe0 -[UIApplication _run] + 1071
UIKitCore 0x00000001a13ce854 _UIApplicationMain + 167
App 0x00000001037dc93c *redacted*
App 0x00000001032625c4 *redacted*
App 0x00000001027bc618 main (main.swift:9:13)
libdyld.dylib 0x000000019e64a6b0 _start + 3
可以看到调用栈中有 exit
,这表明应用正在正常退出。
调用栈中,在 exit
这一项的上面,可以看到 __cxa_finalize_ranges
,这是由 C++ 代码产生的调用,通过 __cxa_atexit
注册回调,在应用退出时调用,用来正在进行全局变量的销毁。
由此可以看出,这是一个由于 C++ 全局变量在应用退出时销毁,导致其他线程引用到了销毁的资源产生的崩溃。这也可以解释为什么 Bugly 没有把崩溃现场交给其他框架进行处理了: Bugly 检测到了应用正在退出,直接调用了 executeEmergencyLogic:
方法,优先保证自己的处理。
\
全局变量会析构
__cxa_atexit
是 Itanium C++ ABI 运行时规范的一部分,它用来支持 C++ 语法中的全局变量。我们知道,C++ 对象分为 POD 和非 POD 两类,其中 POD 以及 constexpr 构造器可以在编译期初始化,而非 constexpr 构造的类型只能通过构造器在运行期间来构造。C++ 对于这两类对象,都支持它们作为全局变量并提供初始值,那么这些全局变量就要在 dso 加载期间调用构造器来初始化。
同样地,为了防止内存/资源泄漏,C++ 规定这样初始化的全局变量要在 dso 卸载时析构。
我们可以通过查看反汇编,对它的实现机制一探究竟。
举个例子,有如下 C++ 代码:
#include <iostream>
class Test {
public:
virtual ~Test();
Test();
};
Test::Test() {}
Test::~Test() {
std::cout << "Test: dtor" << std::endl;
}
static Test t = Test();
使用 clang++ 对以上文件进行编译,查看生成的汇编代码:
xcrun clang++ -sdk iphoneos -arch arm64 1.cc -s -o 1.s
.section __TEXT,__StaticInit,regular,pure_instructions
.p2align 2 ; -- Begin function __cxx_global_var_init
___cxx_global_var_init: ; @__cxx_global_var_init
.cfi_startproc
; %bb.0:
sub sp, sp, #32 ; =32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
add x29, sp, #16 ; =16
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
adrp x0, __ZL1t@PAGE
add x0, x0, __ZL1t@PAGEOFF
str x0, [sp, #8] ; 8-byte Folded Spill
bl __ZN4TestC1Ev
ldr x1, [sp, #8] ; 8-byte Folded Reload
adrp x0, __ZN4TestD1Ev@PAGE
add x0, x0, __ZN4TestD1Ev@PAGEOFF
adrp x2, ___dso_handle@PAGE
add x2, x2, ___dso_handle@PAGEOFF
bl ___cxa_atexit ; ②
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32 ; =32
ret
.cfi_endproc
; -- End function
.section __TEXT,__StaticInit,regular,pure_instructions
.p2align 2 ; -- Begin function _GLOBAL__sub_I_1.cc
__GLOBAL__sub_I_1.cc: ; @_GLOBAL__sub_I_1.cc
.cfi_startproc
; %bb.0:
stp x29, x30, [sp, #-16]! ; 16-byte Folded Spill
mov x29, sp
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
bl ___cxx_global_var_init
ldp x29, x30, [sp], #16 ; 16-byte Folded Reload
ret
.cfi_endproc
; -- End function
.section __DATA,__mod_init_func,mod_init_funcs
.p2align 3
.quad __GLOBAL__sub_I_1.cc ; ①
从反汇编代码中,可以看出实际的方案是:
- 生成一个函数用来构造当前编译单元内的所有全局(以及静态)变量,将该函数写到
__mod_init_funcs
中去,这样 dso 加载时,动态链接器会主动执行它们(位于汇编代码①处); - 在这个生成的函数中,调用
__cxa_atexit
,传入已经构造的对象的指针和 deleting destructor,这样 dso 卸载时,这些构造的对象会被销毁(位于汇编代码②处)。
很多使用 C++ 的库都会分配线程资源进行并发执行。如果这些正在执行的线程需要引用全局变量,同时触发了 dso 卸载,那么就会发生线程跑着跑着,全局变量析构了,于是进程就崩溃了。
理论上讲,dso 的卸载是可控的,因为我们总可以控制逻辑,让动态库的资源都释放掉以后,再去卸载动态库/退出进程。
但是在 iOS 移动应用上,有一个例外——
用户可以通过多任务手势,杀死应用。如果被杀的应用恰巧在前台运行,那么 iOS 会给这个应用发送 SIGTERM
信号。UIKit 收到信号后会调用应用代理的 applicationWillTerminate(_:)
方法,使得应用有机会保存一些状态数据,然后正常退出应用。
这个时候我们是没有机会释放线程资源的,因为 terminate 的生命周期很短,没有时间给我们等待异步线程结束,所以这个崩溃就无法避免了。
所幸的是,这种崩溃并不会被用户感知到:即使应用不崩溃,也会立即正常退出,对于用户来说表现是一样的。
\
解决需要重编译?
其实同类的问题以前在该 App 中也是发生过的——我们有一个内部 SDK 同样也是 C++ 写成,拥有全局状态变量,开启异步线程池访问这些变量,用户在前台杀死应用时触发崩溃。
当时的解决方案是:升级工具链。根据 Apple 发布的 Xcode 11 更新日志,apple clang++ 编译器增加了禁用全局变量析构的编译参数 -fno-c++-static-destructors
。使用该标记编译的 C++ 源文件,不会生成对全局变量进行析构的代码。
这对 iOS 应用来说是安全的——因为 iOS 应用几乎不会在运行时卸载动态库,无需考虑动态库卸载的资源泄漏问题。
然而这次的问题又有所不同——出现问题的是一个由第三方提供的二进制库,我们手里是没有它的源代码的,也就无法通过修改编译参数的方式来重新编译生成机器码。
但是我们能否再深入一下,帮助三方库来修复这个 bug 呢?
修复该问题的直接方案,就是修改机器码,消除对 __cxa_atexit
的调用。
\
静态库里有什么
一个三方静态库 SDK,一般由以下文件组成:
- 一组头文件,提供了公开的函数/OC 类及方法声明;
- 一个 .a 静态库,包含了这个库的代码实现,由多个编译单元生成的 .o 目标文件打包而成;
- 一组资源文件,提供代码运行时的外部数据(图片、以及其他资源)。
无论是采用零散的文件,还是采用 .framework
封装,它们的组成基本上是一致的。
我们要修改的是它的部分机器码,所以要将其中的 .a 静态库解开,再进行编辑。
首先来查看一下 .a 文件的内容:
❯ lipo -info libsample.a
Architectures in the fat file: libsample.a are: armv7 arm64
这是一个 Universal binary,包含了两种 iOS 真机的 CPU 架构的代码。我们先针对主流机型使用的 arm64 架构尝试调整。
使用 lipo
命令将 arm64 架构单独抽取出来:
❯ lipo -thin arm64 libsample.a -o libsample_arm64.a
只有把 Universal binary 中特定的架构抽取出来,才能使用 ar(1) 操作:
❯ mkdir objects
❯ cd objects
# 打印 .a 中包含的文件列表
❯ ar t ../libsample_arm64.a
__.SYMDEF
sample.o
sample.o
# 解包 .a 文件
❯ ar -x ../libsample_arm64.a
使用上述命令进行 .a 文件的展开后,出现了一个问题: ar t
命令中,列出了两个 sample.o
文件,但是 ar x
命令只解出来了一个。这是因为 ar 归档中,没有目录的概念,不同目录下的同名目标文件,在 ar 归档的过程中,会被打平,导致 ar 归档中包含多个同名文件。
这会导致我们使用 ar x
解包的时候,相同的文件会被覆盖成一个,也没法把它们单独解压出来。
那么如何才能把 ar 归档中的同名文件分别解包出来呢……那么就得提到「不务正业」的 7-zip 了……
\
压缩软件有妙用
7-zip 作为一个压缩软件,除了支持常规的压缩文件格式之外,还支持了很多归档文件以及 PE 可执行文件(特别地,支持了部分安装器的 SFX 模块)。我们来尝试一下它是否支持 .a 归档:
❯ 7z l ./libsample_arm64.a
7-Zip [64] 17.04 : Copyright (c) 1999-2021 Igor Pavlov : 2017-08-28
p7zip Version 17.04 (locale=utf8,Utf16=on,HugeFiles=on,64 bits,16 CPUs x64)
Scanning the drive for archives:
1 file, 78960 bytes (78 KiB)
Listing archive: ./libsample_arm64.a
--
Path = ./libsample_arm64.a
Type = Ar
Physical Size = 78960
SubType = a:BSD
Date Time Attr Size Compressed Name
------------------- ----- ------------ ------------ ------------------------
2021-06-23 15:05:09 ..... 1710 1710 1.txt
2021-06-23 15:03:54 ..... 38616 38616 1.sample.o
2021-06-23 15:04:02 ..... 38616 38616 2.sample.o
------------------- ----- ------------ ------------ ------------------------
2021-06-23 15:05:09 78942 78942 3 files
可以看到,7-zip 自动为 .a 中的文件名进行了修正。同时,7-zip 在解压的时候遇到同名文件,会提供是否覆盖及自动重命名文件的选项:
❯ 7z x ./libsample_arm64.a
7-Zip [64] 17.04 : Copyright (c) 1999-2021 Igor Pavlov : 2017-08-28
p7zip Version 17.04 (locale=utf8,Utf16=on,HugeFiles=on,64 bits,16 CPUs x64)
Scanning the drive for archives:
1 file, 78960 bytes (78 KiB)
Extracting archive: ./libsample_arm64.a
--
Path = ./libsample_arm64.a
Type = Ar
Physical Size = 78960
SubType = a:BSD
Would you like to replace the existing file:
Path: ./sample.o
Size: 2736 bytes (3 KiB)
Modified: 2017-05-15 11:59:49
with the file from archive:
Path: sample.o
Size: 76088 bytes (75 KiB)
Modified: 2017-05-15 11:58:47
? (Y)es / (N)o / (A)lways / (S)kip all / A(u)to rename all / (Q)uit? u
Everything is Ok
Files: 3
Size: 78942
Compressed: 78960
只要我们选择 Auto rename all,7-zip 就会自动帮我们处理文件重名的问题了。而我们重新打包 .a 文件时,.o 文件的名称并不重要,可以随便取,所以这里改成其他名字也没有关系。
\
人肉写出机器码
我们再来回顾一下典型的全局变量析构调用的注册:
LDR X1, [SP,#0x10+var_8]
ADRP X0, #__ZN4TestD1Ev@PAGE ; Test::~Test()
ADD X0, X0, #__ZN4TestD1Ev@PAGEOFF ; Test::~Test()
ADRP X2, #___dso_handle@PAGE
ADD X2, X2, #___dso_handle@PAGEOFF
BL ___cxa_atexit
LDP X29, X30, [SP,#0x10+var_s0]
ADD SP, SP, #0x20
RET
通过阅读 Itanium C++ ABI,可以看到 __cxa_atexit
的函数签名如下:
// 3.3.6.3 Runtime API
extern _LIBCXXABI_FUNC_VIS int __cxa_atexit(void (*f)(void *), void *p, void *d);
对比反汇编代码,可以看到 X0 传入了对象类型的删除析构( ...D1Ev
)函数的指针,X1 传入了对象地址,X2 传入了 dso 句柄,与函数签名相符。
要消除对 __cxa_atexit
的调用,只需要把其中的 bl
指令改成 nop
即可。
反汇编软件 IDA 提供了即时汇编的功能,可以通过手写汇编指令,由 IDA 生成机器码直接写入文件中。可惜这个功能对于 arm64 架构没有支持,我们需要找另外的方法。
好在我们可以查阅 AArch64 指令集架构文档,其中提到:
image通过文档,我们看到了在 AArch64 架构下, NOP
指令的具体编码。
由于 Apple arm64 CPU 是小端序,那么我们应该把 bl
指令对应的四个字节替换为:
1F 20 03 D5 ; NOP
除此之外,还有数种不同的情况,需要针对性地做不同的修改。以下列出了两种不同情况。
尾调用:
; 各种填写参数...
B ___cxa_atexit
; end of function
此时要把 B
指令改为 RET lr
。
返回值校验:
; 各种填写参数...
BL ___cxa_atexit
CBZ W0, check_pass
BL assert_fail
check_pass:
; ... 正常逻辑
此时要把 B
指令改为 MOV w0, wzr
,才能通过校验。
至此我们可以看出,通过这种方式修改机器码,存在很大的局限性:
- 做人肉汇编器真的很难;
- 对象文件中的跳转记录在 GOT 表中,直接删除它们的引用会导致链接失败;
- 不是所有 CPU 架构上都存在可以等长替换的指令,因此对于部分 CISC 指令集架构无能为力。
那么是否存在更好的解决方案呢?
\
苹果又有新科技
在使用 otool
检查解包出来的 .o 文件时,发现了如下区段:
Section
sectname __bitcode
segname __LLVM
addr 0x0000000000000ee8
size 0x0000000000005f70
offset 4928
align 2fn:0 (1)
reloff 0
nreloc 0
flags 0x00000000
reserved1 0
reserved2 0
这意味着,这个目标文件内嵌了 bitcode。众所周知,Clang/LLVM 是苹果亲儿子,苹果基于这一套体系搞出了许多新鲜玩意儿,bitcode 就是其中之一。
clang 编译器会先将源文件编译为 LLVM IR,再把 IR 编译到机器码。IR 的大部分设计都是平台中立的,少部分平台相关的代码在 CPU 架构不发生大变化时基本兼容,而且从 IR 生成机器码的过程可以单独优化。
LLVM IR 有不同的表示方案,有文本形式的 IR 汇编、二进制编码的 bitcode。
Apple 允许应用在编译时将 bitcode 内嵌在二进制文件内,随应用一起提交给 Apple。一旦 Apple 推出了效率更高的机器码生成方案,或者是推出了新款 CPU,Apple 可以根据你提交的 bitcode 重新生成更高效的机器指令,开发者无需做任何事即可享受到这个优化。
比如 iPhone X 的 CPU 架构有小升级,内嵌了 bitcode 的应用就可以免费获得 arm64e CPU 架构的支持。
使用开源项目 LibEBC 可以提取 .o 文件中的 bitcode :
❯ /path/to/ebcutil -e ./1.sample.o
Mach-O arm64
File name: 1.sample.o
Arch: arm64
UUID: 00000000-0000-0000-0000-000000000000
Wrapper: D809E5ED-7D43-4E42-B829-7EFF246EE28C
IR: 250BD0A9-67D6-499B-9E63-9D628FB0D7C7
❯ mv ./250BD0A9-67D6-499B-9E63-9D628FB0D7C7 ./1.sample.bc
使用 LLVM 项目(需要通过 Homebrew 安装 llvm)提供的 llvm-dis
工具可以将 bc 文件转换为可读的 IR 汇编格式:
❯ llvm-dis ./1.sample.bc
这会生成一个同名的 .ll 文件,可以用文本编辑器打开。其中关于全局变量初始化的部分如下:
; 省略无关代码
; Function Attrs: noinline ssp uwtable
define internal void @__cxx_global_var_init() #3 section "__TEXT,__StaticInit,regular,pure_instructions" {
%1 = call %class.Sample1* @_ZN7Sample1C1Ev(%class.Sample1* @_ZL2s1)
%2 = call i32 @__cxa_atexit(void (i8*)* bitcast (%class.Sample1* (%class.Sample1*)* @_ZN7Sample1D1Ev to void (i8*)*), i8* bitcast (%class.Sample1* @_ZL2s1 to
i8*), i8* @__dso_handle) #4
ret void
}
; Function Attrs: nounwind
declare i32 @__cxa_atexit(void (i8*)*, i8*, i8*) #4
IR 的详细语法在此就不展开介绍了,有兴趣的同学可以查看LLVM 官方文档。其中比较重要的有:
-
declare
用来声明对外部符号的引用,例如此处引用了外部函数__cxa_atexit
。 -
call
用来做函数调用
需要注意的是,在 IR 中,所有 %
加数字组成的标号必须连续。例如如果我注释了上述代码中的 %1
所在的一行,就会产生 IR 汇编错误,此时就必须把下一行的 %2
改成 %1
,才能符合规则汇编通过。
在上述代码中,我们只需要把 %2
所在的一行给注释掉,即可完成修复。如果一个 IR 函数内有多个调用,就需要按照标号连续的规则,将注释掉的代码后面的所有标号依次提前了。
正确的 IR 操作姿势是写一个 IR pass,然后通过 llvm-opt 去加载这个 pass,读取 .bc 文件而不是人类可读的 .ll 文件,来对原有的 bitcode 做变换。但是写一个 pass 需要的成本比临时修复问题要高得多,对于少数几个目标文件的修复,可以通过文本替换工具或脚本语言来替换标号。例如使用 node.js:
function replaceLabels(from, to, diff) {
let source = fs.readFileSync('tmp.ll', 'utf8');
for (let i = from; i <= to; ++i) {
// 修改 % 变量标号
let re = new RegExp('%'+i+'\b', 'g');
source = source.replace(re, '%'+(i - diff));
// 修改 jump label 标号
let re2 = new RegExp('\b'+i+':', 'g');
source = source.replace(re2, ''+(i - diff)+':');
}
fs.writeFileSync('tmp.ll', source)
}
\
重新组装静态库
修改过后的 .ll 文件,可以通过以下方式重新生成机器码:
# 生成 arm64 汇编文件
❯ llc ./1.sample.ll
# 调用汇编器重新生成目标文件
❯ xcrun -sdk iphoneos as -arch arm64 ./1.sample.s -o ./1.sample.o
这样做有一个缺点,就是生成的目标文件没有内嵌 bitcode,以后再想改就不好改了。
好在 clang driver 功能齐全,可以直接接受 bitcode 以及 IR 汇编文件:
❯ xcrun -sdk iphoneos clang -arch arm64 -target arm64-apple-ios6.0.0 -fembed-bitcode -c ./1.sample.ll -o ./1.sample.o
对存在问题的 .o 文件打补丁后,即可将所有的 .o 文件重新合成静态库:
❯ xcrun libtool -static -o ../libsample_arm64_patched.a *.o
\
实机验证大成功
通过调用堆栈,我们已经可以知道这个问题的复现方式:
- 在应用中进入使用该三方库内部触发多线程工作的场景
- 直接开启多任务手势,杀死应用
但是在连接调试器的情况下,通过多任务手势杀应用会导致调试器断开,不容易观察是否有崩溃的现象。
所以,需要找到一个让应用正常退出,而又不影响调试器的方法。
通过查询 iOS system framework class dump,可以知道 UIApplication
有一个未公开的方法: UIApplication.terminateWithSuccess()
。
经过实际试验,这个方法确实可以使应用直接退出。
因此,我们可以修改应用代码,在进入能够触发问题的场景下,通过代码来让应用退出,就可以通过调试器来观察应用是否触发崩溃了。分别使用修复前、修复后的库进行实机验证,结果为:
- 使用旧版库时,有概率引发调试器由于崩溃触发断点;
- 使用修改机器码的库后,不会触发崩溃;
- 不影响正常的业务功能。
这表明我们的修复是成功的。
总结
本文通过修改 bitcode,成功地在没有源码的情况下,修复了一个三方库的 bug。其中用到的知识点总结如下:
- 崩溃现场中,在主线程发现
exit
,多半是由于 C++ 全局变量析构 + 多线程导致的; - 在有源码的情况下,可以通过调整编译参数消除全局变量析构;
- 使用 7-zip 可以无损解包静态库文件;
- 使用 otool 可以看到目标文件是否嵌入了 bitcode;
- 使用 llvm 提供的工具,可以对 bitcode 进行修改、重新生成机器码;
- 可以通过私有 API 来模拟应用退出,制造复现场景。
作者
郭同学,便利蜂客户端基础框架团队的一名 iOS 工程师,负责移动客户端的基础建设。对跨端技术、App 框架及系统有所研究,专治各种客户端疑难杂症。
推荐观看:Fluuter手把手教你从入门到精通
参考资料
[1]Xcode 11 更新日志: https://developer.apple.com/documentation/xcode-release-notes/xcode-11-release-notes
[2]Itanium C++ ABI: https://itanium-cxx-abi.github.io/cxx-abi/abi.html#dso-dtor-runtime-api
[3]AArch64 指令集架构文档: https://developer.arm.com/architectures/cpu-architecture/a-profile/exploration-tools
[4]LibEBC: https://github.com/Guardsquare/LibEBC
[5]LLVM 官方文档: https://llvm.org/docs/LangRef.html
[6]iOS system framework class dump: https://developer.limneos.net/?ios=14.4&framework=UIKitCore.framework&header=UIApplication.h
搬运自知乎,如有侵犯,请联系小编删除哦。