本文字数:3141字
预计阅读时间:25分钟
一、背景
最近看到一篇有意思的技术文章:《抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%》。
原文结尾提到该方案无法覆盖100%的符号:
-
基于静态扫描+运行时trace的方案仍然存在少量瓶颈:
-
-
initialize hook不到
-
-
部分block hook不到
-
-
C++通过寄存器的间接函数调用静态扫描不出来
-
-
目前的重排方案能够覆盖到
80%~
90%的符号,未来我们会尝试编译期插桩等方案来进行
100%的符号覆盖,让重排达到最优效果。
实际上,除上面的场景外,抖音研发团队的方案还存在一些无法覆盖的场景:
无法覆盖代码行级别的检测
-
当某些复杂的函数存在
if/else/switch
等场景时,开发者可以将函数拆成多个子函数进行优化
OC/C 语言的函数调用同样很难被静态扫描
无法对第三方的静态库或者动态库进行有效处理
无法检测
__attribute__((constructor))
修饰的函数
今天我们将尝试通过 llvm
和 IR
配合实现解决上面提到的各类场景。
二、效果展示
本质上,上面提到的各类场景,都可以通过 对代码进行 基本块(BasicBlock-Level)
级别插桩 的方式解决。
为了方便读者能够继续将本文全部阅读下去,我们先看看一个给 微信SDK 插桩的实际效果。
基本块(BasicBlock-Level)
的概念会在下一章节进行讲解
1、微信SDK
微信SDK(OpenSDK1.8.7.1)[1] 提供了3个公开的头文件,其中 WXApi.h
的暴露了一个类方法 [WXApi registerApp: universalLink:]
:
-
/*! @brief 微信Api接口函数类
-
*
-
* 该类封装了微信终端SDK的所有接口
-
*/
-
@
interface WXApi : NSObject
-
-
/*! @brief WXApi的成员函数,向微信终端程序注册第三方应用。
-
*
-
* 需要在每次启动第三方应用程序时调用。
-
* @attention 请保证在主线程中调用此函数
-
* @param appid 微信开发者ID
-
* @param universalLink 微信开发者Universal Link
-
* @return 成功返回YES,失败返回NO。
-
*/
-
+ (BOOL)registerApp:(NSString *)appid universalLink:(NSString *)universalLink;
-
-
@end
2、 main.m
新建一个工程,添加回调函数并增加对微信SDK的接口调用:
-
@
import Darwin;
-
int main(
int argc, char * argv[]) {
-
// 调用微信SDK
-
[WXApi registerApp:@
"App" universalLink:@
"link"];
-
return
0;
-
}
-
-
// 提供回调函数
-
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
-
Dl_info info;
-
// 获取当前函数的返回地址
-
)
-
void *PC = __builtin_return_address(
0);
-
// 根据返回地址,获取相关的信息
-
dladdr(PC, &info);
-
// 打印与 PC 最近的符号名称
-
printf(
"guard:%p 开始执行:%s \n", PC, info.dli_sname);
-
}
-
更多内容,可以阅读参考资料的相关链接 dladdr[2] __builtin_return_address[3]
3、运行
通过在 __sanitizer_cov_trace_pc_guard
函数增加断点,我们可以看到下面的调用栈:
整理后的流程图如下所示:
我们可以很容易地从流程图看出来:
微信SDK 调用了开发者提供的回调函数 __sanitizer_cov_trace_pc_guard
。
下面,我们开始进入正题。
三、插桩与代码覆盖率
为了强调一下本文与抖音技术方案的区别,我们需要先了解一下插桩中常用的代码覆盖率计量单位。
通常情况下,代码覆盖率有 3 种计量单位:
函数(Fuction-Level)
基本块(BasicBlock-Level)
边界(Edge-Level)
1、函数(Fuction-Level)
函数(Fuction-Level)
比较容易理解,就是记录哪些函数执行过。是一种粗糙但高效的统计方式。
从抖音的技术文章看,他们勉强算是做到了这个级别的代码覆盖率检测。
2、基本块(BasicBlock-Level)
基本块(BasicBlock)
通常是只包含顺序执行的代码块。
以下面的代码为例:
-
void foo(
int *a) {
-
if (a)
-
*a =
0;
-
}
通过编译器将代码转为汇编时,它会被拆成3个部分:
每个部分都是一个 基本块(BasicBlock)
。
代码行覆盖率可以通过
基本块(BasicBlock-Level)
级别的代码插桩实现。
3、边界(Edge-Level)
边界(Edge)
的概念比较难理解,我们仍然以上面的代码为例进行说明。
上面的代码包含3个 基本块(BasicBlock)
: A
、B
、C
。
即使代码行覆盖测试报告显示 A
、B
、C
三块都被执行过,我们仍然无法得到以下结论:
路径A
-->C
出现过。
此时,我们可以添加一个虚拟路径 D
:
如果测试报告显示 虚拟路径 D
被执行过,则 路径A
-->C
就一定出现过;反之, 路径A
-->C
就一定没有出现过。
路径覆盖率可以通过
边界(Edge)
级别的代码插桩实现。
三、SanitizerCoverage
根据 llvm
的官方文档 SanitizerCoverage[4],我们可以搭配 -fsanitize-coverage=trace-pc-guard
或者其它编译参数控制编译器插入不同级别的 桩
。
下面,我们以 -fsanitize-coverage=trace-pc-guard
为例进行演示效果:
1、配置 编译开关
2、准备源码文件
-
// 文件 A
-
int f(void) __attribute__((constructor));
-
-
int f(void) {
-
NSLog(@
" int f() __attribute__((constructor)) 被调用");
-
return
0;
-
}
-
// 文件 ViewController.mm
-
#
import <
string>
-
-
static std::
string cxx_static_str(
"cxx_static_str");
-
-
+ (void)load {
-
NSLog(@
"load 被执行");
-
}
-
-
// 文件 main.m
-
@
import Darwin;
-
-
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
-
uint32_t *stop) {
-
static uint32_t N;
// Counter for the guards.
-
if (start == stop || *start)
return;
// Initialize only once.
-
printf(
"INIT: %p %p\n", start, stop);
-
for (uint32_t *x = start; x < stop; x++)
-
*x = ++N;
-
}
-
-
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
-
Dl_info info;
-
-
void *PC = __builtin_return_address(
0);
-
dladdr(PC, &info);
-
printf(
"guard:%p 开始执行:%s \n", PC, info.dli_sname);
-
}
-
-
void foo(
int *a) {
-
if (a)
-
*a =
0;
-
}
-
-
-
int main(
int argc, char * argv[]) {
-
dispatch_async(dispatch_get_main_queue(), ^{
-
NSLog(@
"main block");
-
});
-
int i=
0;
-
foo(&i);
-
-
NSString * appDelegateClassName;
-
@autoreleasepool {
-
// Setup code that might create autoreleased objects goes here.
-
appDelegateClassName = NSStringFromClass([AppDelegate class]);
-
}
-
return UIApplicationMain(argc, argv,
nil, appDelegateClassName);
-
}
-
3、运行
运行日志如下所示,我们可以发现以下场景都能够被正常覆盖:
load
方法c++ 变量
__attribute__((constructor))
修饰的函数函数
foo
的两个基本块(BasicBlock-Level)
block
四、编译流程简析
我们先通过一个简单例子,看看源码是如何成为二进制文件的。
1、准备源码文件
命令行输入:
-
cat <<EOF > main.m
-
int main() {
-
return
0;
-
}
-
EOF
2、打印构建顺序
命令行输入:
xcrun clang main.m -save-temps -v -mllvm -debug-pass=Structure -fsanitize-coverage=trace-pc-guard
输出如下所示(有删减):
-
clang -cc1 -E --fsanitize-coverage-
type=
3 -fsanitize-coverage-trace-pc-guard main.mi -x objective-c main.m
-
-
clang -cc1 -emit-llvm-bc -disable-llvm-passes -fsanitize-coverage-
type=
3 -fsanitize-coverage-trace-pc-guard -o main.bc -x objective-c-cpp-output main.mi
-
-
clang -cc1 -S -fsanitize-coverage-
type=
3 -fsanitize-coverage-trace-pc-guard -o main.s -x ir main.bc
-
-
clang -cc1as -o main.o main.s
-
-
ld -o a.out -L/usr/local/lib main.o
整理后,如下图所示:
-
graph LR
-
subgraph 示例
-
流程:::流程
-
文件:::文件
-
classDef 流程 fill:#f96;
-
end
-
-
-
main.m-->preprocessor:::流程-->main.mi
-
--> compiler:::流程--> main.bc
-
-
-
main.bc_fake[main.bc] --> backend:::流程 --> main.s
-
--> assembler:::流程 --> main.o
-
--> linker:::流程 --> a.out
-
因为 main.bc
是二进制版本的 bitcode
,可读性比较差。
开发者可以通过 llvm-dis main.bc -o -
命令转为更具有可读性的版本:
-
; ModuleID =
'main.bc'
-
source_filename =
"~/main.m"
-
target datalayout =
"e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
-
target triple =
"x86_64-apple-macosx10.15.0"
-
-
; Function Attrs: noinline nounwind optnone ssp uwtable
-
define i32 @main() #
0 {
-
%
1 = alloca i32, align
4
-
store i32
0, i32* %
1, align
4
-
ret i32
0
-
}
-
-
attributes #
0 = { noinline nounwind optnone ssp uwtable
"correctly-rounded-divide-sqrt-fp-math"=
"false"
"darwin-stkchk-strong-link"
"disable-tail-calls"=
"false"
"frame-pointer"=
"all"
"less-precise-fpmad"=
"false"
"min-legal-vector-width"=
"0"
"no-infs-fp-math"=
"false"
"no-jump-tables"=
"false"
"no-nans-fp-math"=
"false"
"no-signed-zeros-fp-math"=
"false"
"no-trapping-math"=
"false"
"probe-stack"=
"___chkstk_darwin"
"stack-protector-buffer-size"=
"8"
"target-cpu"=
"penryn"
"target-features"=
"+cx16,+cx8,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87"
"unsafe-fp-math"=
"false"
"use-soft-float"=
"false" }
-
-
!llvm.module.flags = !{!
0, !
1, !
2, !
3, !
4, !
5, !
6, !
7}
-
!llvm.ident = !{!
8}
-
-
!
0 = !{i32
2, !
"SDK Version", [
3 x i32] [i32
10, i32
15, i32
6]}
-
!
1 = !{i32
1, !
"Objective-C Version", i32
2}
-
!
2 = !{i32
1, !
"Objective-C Image Info Version", i32
0}
-
!
3 = !{i32
1, !
"Objective-C Image Info Section", !
"__DATA,__objc_imageinfo,regular,no_dead_strip"}
-
!
4 = !{i32
1, !
"Objective-C Garbage Collection", i8
0}
-
!
5 = !{i32
1, !
"Objective-C Class Properties", i32
64}
-
!
6 = !{i32
1, !
"wchar_size", i32
4}
-
!
7 = !{i32
7, !
"PIC Level", i32
2}
-
!
8 = !{!
"Apple clang version 12.0.0 (clang-1200.0.32.21)"}
再与 main.s
文件的内容对照一下:
-
.p __TEXT,__text,regular,pure_instructions
-
.build_version macos,
10,
15 sdk_version
10,
15,
6
-
.globl _main ## -- Begin function main
-
.p2align
4,
0x90
-
_main: ## @main
-
.cfi_startproc
-
## %bb
.0:
-
pushq %rbp
-
.cfi_def_cfa_offset
16
-
.cfi_offset %rbp,
-16
-
movq %rsp, %rbp
-
.cfi_def_cfa_register %rbp
-
subq $
16, %rsp
-
leaq l___sancov_gen_(%rip), %rdi
-
callq ___sanitizer_cov_trace_pc_guard
-
## InlineAsm Start
-
## InlineAsm End
-
xorl %eax, %eax
-
movl $
0,
-4(%rbp)
-
addq $
16, %rsp
-
popq %rbp
-
retq
-
.cfi_endproc
-
## -- End function
-
.p2align
4,
0x90 ## -- Begin function sancov.module_ctor_trace_pc_guard
-
_sancov.module_ctor_trace_pc_guard: ## @sancov.module_ctor_trace_pc_guard
-
.cfi_startproc
-
## %bb
.0:
-
pushq %rax
-
.cfi_def_cfa_offset
16
-
leaq p$start$__DATA$__sancov_guards(%rip), %rax
-
leaq p$end$__DATA$__sancov_guards(%rip), %rcx
-
movq %rax, %rdi
-
movq %rcx, %rsi
-
callq ___sanitizer_cov_trace_pc_guard_init
-
popq %rax
-
retq
-
.cfi_endproc
-
## -- End function
-
.p __DATA,__sancov_guards
-
.p2align
2 ## @__sancov_gen_
-
l___sancov_gen_:
-
.space
4
-
-
.p __DATA,__mod_init_func,mod_init_funcs
-
.p2align
3
-
.quad _sancov.module_ctor_trace_pc_guard
-
.no_dead_strip l___sancov_gen_
-
.p __DATA,__objc_imageinfo,regular,no_dead_strip
-
L_OBJC_IMAGE_INFO:
-
.long
0
-
.long
64
-
-
.subps_via_symbols
通过两份文件对比,我们可以发现经过 backend
流程后,___sanitizer_cov_trace_pc_guard
相关的调用才开始出现。
所以,我们可以得到第一个重要的结论:
在具有 bc 文件
的情况下,就可以通过 backend
流程 进行插桩处理。
再结合我们之前发过的公众号文章: 检查第三方库是否包含 bitcode 信息,我们可以得到第二个结论:
通过导出第三方库的 bitcode,我们可以实现任意 cpu 架构下的插桩。
五、实战
讲解完基础知识后,我们开始以 微信SDK(OpenSDK1.8.7.1) 为例进行实际讲解。
1、对微信SDK进行处理
检测 微信SDK 的文件类型
命令行输入:
file ~/Downloads/OpenSDK1.8.7.1/libWeChatSDK.a
输出如下:
-
~/Downloads/OpenSDK1
.8
.7
.1/libWeChatSDK.a: Mach-O universal binary with
4 architectures: [i386:current ar archive] [arm_v7] [x86_64] [arm64]
-
~/Downloads/OpenSDK1
.8
.7
.1/libWeChatSDK.a (
for architecture i386): current ar archive
-
~/Downloads/OpenSDK1
.8
.7
.1/libWeChatSDK.a (
for architecture armv7): current ar archive
-
~/Downloads/OpenSDK1
.8
.7
.1/libWeChatSDK.a (
for architecture x86_64): current ar archive
-
~/Downloads/OpenSDK1
.8
.7
.1/libWeChatSDK.a (
for architecture arm64): current ar archive
因为 微信SDK包含多个架构,所以需要先用
lipo
命令导出一份单架构文件lipo -thin armv7 ~/Downloads/OpenSDK1.8.7.1/libWeChatSDK.a -o ~/Downloads/OpenSDK1.8.7.1/libWeChatSDK_armv7.a
检测单架构文件的类型
命令行输入:
file -b ~/Downloads/OpenSDK1.8.7.1/libWeChatSDK_armv7.a
输出如下:
current ar archive
因为
libWeChatSDK_armv7.a
是ar
文件,通过tar
命令解压缩tar -xf ~/Downloads/OpenSDK1.8.7.1/libWeChatSDK_armv7.a
产出12个
.o
文件-
tree
-
.
-
├── AppCommunicate.o
-
├── AppCommunicateData.o
-
├── WXApi+ExtraUrl.o
-
├── WXApi+HandleOpenUrl.o
-
├── WXApi.o
-
├── WXApiObject.o
-
├── WXLogUtil.o
-
├── WapAuthHandler.o
-
├── WeChatApiUtil.o
-
├── WeChatIdentityHandler.o
-
├── WechatAuthSDK.o
-
└── base64.o
-
-
0 directories, 12 files
-
-
依次判断
.o
文件的类型并进行处理 命令行输入:file -b AppCommunicate.o
输出:
Mach-O object arm_v7
通过
segedit
命令导出bitcode
segedit AppCommunicate.o -extract __LLVM __bitcode .AppCommunicate.bc
通过
clang
将bitcode
转为.s
文件注意事项:
为了避免编译器错误:
fatal error: error in backend: Cannot select: intrinsic %llvm.objc.clang.arc.use
,这里需要传入-O1
或者更高级别的优化开关,以启用-objc-arc-contract
Passxcrun clang -O1 -target armv7-apple-ios7 -S AppCommunicate.bc -o AppCommunicate.s -fsanitize-coverage=trace-pc-guard -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.1.sdk
截取 AppCommunicate.s
部分内容如下:
-
Ltmp0:
-
.loc
9
16
0 prologue_end ; AppCommunicate/AppCommunicate.m:
16:
0
-
Lloh0:
-
adrp x0, l___sancov_gen_@PAGE
-
Ltmp1:
-
;DEBUG_VALUE: +[AppCommunicate getDataPasteboardName]:self <- [DW_OP_LLVM_entry_value
1] $x0
-
Lloh1:
-
add x0, x0, l___sancov_gen_@PAGEOFF
-
bl ___sanitizer_cov_trace_pc_guard
-
Ltmp2:
-
;DEBUG_VALUE: +[AppCommunicate getDataPasteboardName]:_cmd <- [DW_OP_LLVM_entry_value
1] $x1
2、Demo
将处理后的文件直接放到工程中:
3、运行
我们仍然用本文开头的代码进行演示。
如下所示,可以通过 console
区域看到微信SDK内部的执行流程
总结
首先,我们先回顾一下本文的重点知识:
代码覆盖率 分为 函数(Fuction-Level)、基本块(BasicBlock-Level)、边界(Edge-Level) 三种级别。
llvm 编译器
通过SanitizerCoverage
支持以上三种级别的代码覆盖率插桩。通过导出第三方库的
bitcode
,我们可以实现任意cpu架构下的插桩。
本文通过介绍 代码覆盖率 、SanitizerCoverage
和 编译流程 ,并以 微信SDK 为例,对如何实现第三方SDK插桩进行了详细的讲解。
参考资料
[1]
微信SDK(OpenSDK1.8.7.1): https://developers.weixin.qq.com/doc/oplatform/Downloads/iOS_Resource.html
[2]dladdr: https://gcc.gnu.org/onlinedocs/gcc/Return-Address.html
[3]__builtin_return_address: https://gcc.gnu.org/onlinedocs/gcc/Return-Address.html
[4]SanitizerCoverage: https://releases.llvm.org/10.0.0/tools/clang/docs/SanitizerCoverage.html#instrumentation-points
上期获奖名单公布
恭喜“盖上被子的...”、“阿策~”、“beat you”!以上读者请及时添加小编微信:sohu-tech20兑书~
也许你还想看
(▼点击文章标题或封面查看)
【周年福利Round2】都0202年了,您还不会Elasticsearch?
加入搜狐技术作者天团
千元稿费等你来!
???? 戳这里!
转载:https://blog.csdn.net/SOHU_TECH/article/details/111027153