本文字數: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?
加入搜狐技術作者天團
千元稿費等你來!
???? 戳這裏!