通過前面的探討赁豆,我們知道內(nèi)存分頁觸發(fā)中斷異常 Page Fault 后椎镣,會阻塞進程杂曲,這個問題是會對性能產(chǎn)生影響庶艾。
實際上在 iOS 系統(tǒng)中,生產(chǎn)環(huán)境的應(yīng)用擎勘,在發(fā)生缺頁中斷進行重新加載時 咱揍,iOS 系統(tǒng)還會對其做一次簽名驗證,因此 iOS 生產(chǎn)環(huán)境的 Page Fault
所產(chǎn)生的耗時要更多棚饵。
對用戶而言煤裙,使用App時第一個直接體驗就是啟動 App 時間,而啟動時期會有大量的類
噪漾、分類
硼砰、三方
等等需要加載和執(zhí)行,此時多個 Page Fault
所產(chǎn)生的的耗時往往是不能小覷的欣硼,下面我們就通過二進制重排
來優(yōu)化啟動耗時题翰。
抖音團隊分享的一個
Page Fault
,開銷在0.6 ~ 0.8ms
诈胜。實際測試發(fā)現(xiàn)不同頁會有所不同 , 也跟 cpu 負荷狀態(tài)有關(guān) , 在0.1 ~ 1.0 ms
之間 豹障。
二進制重排
這個方案最早也是 抖音團隊 分享的,不過他們的解決方案有瑕疵耘斩,下面我們會針對性的解決沼填。
一、原理
假設(shè)在啟動時期我們需要調(diào)用兩個函數(shù) method1
與 method4
括授,函數(shù)編譯在 mach-O
中的位置是根據(jù) ld
( Xcode 的鏈接器) 的編譯順序
并非調(diào)用順序來的坞笙,因此很可能這兩個函數(shù)分布在不同的內(nèi)存頁上。
page1
與 page2
都需要從無到有加載到物理內(nèi)存中,從而觸發(fā)兩次 Page Fault
版述。二進制重排 的做法就是將
method1
與 method4
放到一個內(nèi)存頁中梯澜,那么啟動時則只需要加載一次 page 即可,也就是只觸發(fā)一次 Page Fault
渴析。在實際項目中晚伙,我們可以將啟動時需要調(diào)用的函數(shù)放到一起 ( 比如 前10頁中 ) 以盡可能減少
Page Fault
吮龄,進而減少啟動耗時。
二咆疗、調(diào)試 Page Fault
最好是卸載App漓帚,重新安裝,調(diào)試第一次啟動的效果午磁。
- 打開
Instruments
尝抖,選擇System Trace
肾请。 - 選擇真機秉颗,選擇工程,選擇啟動嫉你,當(dāng)頁面加載出來的時候登颓,停止搅荞。
- 查看
Page Fault
,如圖標(biāo)注挺据。
Page Fault.png
File Backed Page In
:即為 Page Fault取具,對應(yīng)的有count,一頁Page Fault最大耗時扁耐,最小耗時等參數(shù)暇检。
如果多次啟動調(diào)試,你會發(fā)現(xiàn)
count
的波動范圍很大婉称。所以如果想獲取準(zhǔn)確的數(shù)據(jù)块仆,最好重新安裝App或者打開多個App之后,再來調(diào)試王暗。
這是因為內(nèi)存管理機制悔据,殺掉進程時,他所占用的物理內(nèi)存空間俗壹,如果沒有被覆蓋使用科汗,那么這部分內(nèi)存有很大可能一直存在。重新打開绷雏,內(nèi)存就不需要全部初始化头滔。所以 冷熱啟動的界定不能以是否后臺殺死來簡單判斷。
三涎显、二進制重排
3.1 Order File
前面說了這么多坤检,那么具體該怎么操作呢?蘋果其實已經(jīng)給我們提供了這個機制期吓。
實際上 二進制重排就是對即將生成的可執(zhí)行文件重新排列早歇,即它發(fā)生在鏈接階段。
首先,Xcode 用的鏈接器叫做
ld
箭跳,ld
有一個參數(shù)叫 Order File
晨另,我們可以通過這個參數(shù)配置一個 后綴名
為order的文件路徑。在這個 order 文件
中谱姓,將你需要的符號按順序?qū)懺诶锩嬲蟆.?dāng)工程 build 的時候,Xcode 會讀取這個文件逝段,打的二進制包就會按照這個文件中的符號順序進行生成對應(yīng)的 mach-O
。可以參考一下
libObjc
項目割捅,它已經(jīng)使用了二進制重排
進行優(yōu)化奶躯。是不是看到了ios應(yīng)用啟動加載過程中熟悉的方法。
1亿驾、order 文件里符號寫錯了或不存在會不會有問題:ld 會忽略這些符號嘹黔,如果提供了 link 選項
-order_file_statistics
,他們會以 warning 的形式把這些沒找到的符號打印在日志里莫瞬。
2儡蔓、會不會影響上架:不會,order文件只是重新排列了所生成的 mach-O
(可執(zhí)行文件) 中函數(shù)表與符號表的順序
疼邀。
3.2 如何查看項目符合順序
- 可以設(shè)置
Write Link Map File
來設(shè)置是否輸出喂江,默認(rèn)是no
。Link Map
是編譯期間產(chǎn)生的 旁振,( ld 的讀取二進制文件順序默認(rèn)是按照Compile Sources
里的順序 )获询,它記錄了二進制文件的布局。 - 修改
Write Link Map File
為YES
拐袜,然后clean項目并重新編譯 -
Products -> show in finder
吉嚣,上上層文件夾,然后找到一個xxxxx-LinkMap-normal-arm64.txt
的txt文件蹬铺。
Link map.png
這個文件的# Symbols:
部分存儲了所有符號的順序尝哆,前面的.o
等內(nèi)容忽略 。
Symbols.png
我們發(fā)現(xiàn)符號順序明顯是按照Compile Sources
的文件順序來排列的甜攀。
文件中最左側(cè)地址就是 方法真實實現(xiàn)地址(實際代碼地址)而并非符號地址 , 因此我們二進制重排并非只是修改符號地址 , 而是利用符號順序 , 重新排列整個代碼在文件的偏移地址 , 將啟動需要加載的方法地址放到前面內(nèi)存頁中 , 以此達到減少 page fault 的次數(shù)從而實現(xiàn)時間上的優(yōu)化秋泄。
終端查看符號表命令(不準(zhǔn)確,僅供參考)赴邻。找到可執(zhí)行文件:
nm (file)
:查看符號表
nm -p (file)
:按照orderfile順序
nm -up (file)
: 只看系統(tǒng)
nm -Up (file)
:只看自定義
3.3實戰(zhàn)
1印衔、 新建一個項目,添加方法:
3、新建一個order文件:
touch binary.order
与帆,加入幾個方法
-[ViewController test3]
-[ViewController test2]
-[ViewController test1]
4了赌、修改Order File配置為:$(SRCROOT)/Binary/binary.order
或 ./Binary/binary.order
。
5玄糟、
clean
勿她,編譯
,再次查看xxx.txt
文件阵翎。mach-O
中首地址偏移量最小位置砍聊。假設(shè)這三個方法原本在不同的三頁,那么意味著我們已經(jīng)優(yōu)化掉了兩個 Page Fault贰军。
3.4 獲取啟動執(zhí)行的函數(shù)
到這里玻蝌,離啟動優(yōu)化就只差一步了,如何獲取啟動運行的函數(shù)词疼?大致有三種方案俯树,僅供參考:
-
hook
objc_MsgSend
:只能拿到oc
以及swift @objc dynamic
后的方法,并且由于可變參數(shù)個數(shù)贰盗,需要用匯編來獲取參數(shù) 许饿。 - 靜態(tài)掃描
machO
特定段和節(jié)里面所存儲的符號以及函數(shù)數(shù)據(jù)。 -
clang 插樁:完全拿到
swift
舵盈、oc
米辐、c
、block
全部函數(shù)书释。
四翘贮、Clang插樁
關(guān)于 clang
的插樁覆蓋的官方文檔如下 : clang 自帶代碼覆蓋工具 文檔中有詳細概述,以及簡短Demo演示爆惧。
思路:一是自己編寫 clang 插件狸页,另外一個就是利用 clang 本身已經(jīng)提供的一個工具來實現(xiàn)我們獲取所有符號的需求。
4.1 靜態(tài)插樁代碼
下面我們來探索一下這個靜態(tài)插樁代碼覆蓋工具的機制和原理扯再。
1芍耘、添加編譯設(shè)置:直接搜索 Other C Flags
來到 Apple Clang - Custom Compiler Flags
中 , 添加配置:-fsanitize-coverage=trace-pc-guard
。
2熄阻、在ViewController.m添加代碼:
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_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; // Guards should start from 1.
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
char PcDescr[1024];
//__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
3斋竞、運行(最好是一個空工程,注釋我們前面手動添加的方法)秃殉,查看打影映酢:
start
和stop
兩個指針地址浸剩,會發(fā)現(xiàn)他存儲的實際上是 1-15
幾個序號。4鳄袍、添加一個
oc
方法绢要,我們再次打印start
和stop
指針,你會發(fā)現(xiàn)序號變?yōu)?1-16
拗小。繼續(xù)添加一個
c函數(shù)
重罪,一個block
,一個touch函數(shù)
哀九,是不是驚喜的發(fā)現(xiàn)剿配,序號增加到 19
了。此時阅束,我們是不是可以大膽的猜想:這個內(nèi)存區(qū)間保存的就是工程所有符號的個數(shù)惨篱。
5、繼續(xù)围俘,清空打印,點擊屏幕琢融。是不是發(fā)現(xiàn)有兩次輸出界牡,看代碼,此時有兩次方法的調(diào)用漾抬。最終我們發(fā)現(xiàn):調(diào)用幾個方法宿亡,就會打印幾次 guard:。
此時查看匯編纳令,你會發(fā)現(xiàn):在每個函數(shù)調(diào)用的第一句實際代碼挽荠,會被添加進去了一個
bl
指令, 調(diào)用到__sanitizer_cov_trace_pc_guard
這個函數(shù)中來 平绩。
bl
圈匆,匯編跳轉(zhuǎn)指令,即調(diào)用方法捏雌。bl之前是棧平衡與寄存器數(shù)據(jù)準(zhǔn)備跃赚,不用關(guān)心。
這就是靜態(tài)插樁:靜態(tài)插樁實際上是在編譯期性湿,在每一個函數(shù)內(nèi)部第一行代碼處纬傲,添加 hook 代碼 ( 即我們添加的 __sanitizer_cov_trace_pc_guard 函數(shù) ) ,實現(xiàn)全局的方法 hook肤频,即AOP效果叹括。
4.2 獲取函數(shù)符號
通過上面的分析我們知道,所有函數(shù)的第一步都會調(diào)用__sanitizer_cov_trace_pc_guard宵荒,那我們是不是可以通過這個函數(shù)獲取函數(shù)符號呢汁雷?
熟悉匯編的應(yīng)該知道:函數(shù)嵌套時 , 在跳轉(zhuǎn)子函數(shù)時净嘀,都會保存下一條指令的地址在 x30 ( 又叫 lr 寄存器) 里 。
例如 , A 函數(shù)中調(diào)用了 B 函數(shù)摔竿,在 arm 匯編中即 bl + 0x****
指令面粮,該指令會首先將下一條匯編指令的地址保存在 x30
寄存器中。然后在跳轉(zhuǎn)到 bl
后面?zhèn)鬟f的指定地址去執(zhí)行继低。
bl
能實現(xiàn)跳轉(zhuǎn)到某個地址的匯編指令熬苍,其原理就是修改 pc 寄存器的值來指向到要跳轉(zhuǎn)的地址,而且實際上 B 函數(shù)中也會對 x29 / x30
寄存器的值做保護袁翁,防止子函數(shù)又跳轉(zhuǎn)其他函數(shù)會覆蓋掉 x30
的值 , 當(dāng)然葉子函數(shù)除外柴底。
當(dāng) B 函數(shù)執(zhí)行 ret
也就是返回指令時,就會去讀取 x30
寄存器的地址粱胜,跳轉(zhuǎn)過去柄驻,因此也就回到了上一層函數(shù)的下一步。
在 __sanitizer_cov_trace_pc_guard
函數(shù)中的這一句代碼:
void *PC = __builtin_return_address(0);
它的作用其實就是去讀取 x30
中所存儲的要返回時下一條指令的地址焙压。所以他名稱叫做 __builtin_return_address
鸿脓。換句話說,這個地址就是我當(dāng)前這個函數(shù)執(zhí)行完畢后涯曲,要返回到哪里去野哭。
bt
函數(shù)調(diào)用棧也是這種思路來實現(xiàn)的。也就是說 , 我們可以在 __sanitizer_cov_trace_pc_guard
這個函數(shù)中 , 通過 __builtin_return_address
函數(shù)拿到原函數(shù)調(diào)用 __sanitizer_cov_trace_pc_guard
這句匯編代碼的下一條指令的地址幻件。
如圖拨黔,
PC
的指向就是,當(dāng)test1
函數(shù)執(zhí)行完__sanitizer_cov_trace_pc_guard
后绰沥,下一行代碼NSLog
篱蝇。
那么問題又來了,如果通過函數(shù)內(nèi)部內(nèi)存地址徽曲,獲取函數(shù)名稱呢零截?
熟悉安全攻防,逆向的同學(xué)可能會清楚秃臣。我們?yōu)榱朔乐鼓承┨囟ǖ姆椒ū粍e人使用
fishhook hook
掉瞻润,會利用dlopen
打開動態(tài)庫,拿到一個句柄甜刻,進而拿到函數(shù)的內(nèi)存地址
直接調(diào)用绍撞。那我們可以反過來
使用。
與 dlopen.h
相同 , 在 dlfcn.h
中有一個方法如下 :
typedef struct dl_info {
const char *dli_fname; /* 所在文件 */
void *dli_fbase; /* 文件地址 */
const char *dli_sname; /* 符號名稱 */
void *dli_saddr; /* 函數(shù)起始地址 */
} Dl_info;
//這個函數(shù)能通過函數(shù)內(nèi)部地址找到函數(shù)符號
int dladdr(const void *, Dl_info *);
我們在項目中實踐一下得院,先導(dǎo)入頭文件 #import <dlfcn.h>
傻铣,然后修改代碼如下 :
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
Dl_info info;
dladdr(PC, &info);
printf("\nfname:%s \nfbase:%p \nsname:%s\nsaddr:%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
char PcDescr[1024];
//__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
打印結(jié)果:
fname:/Users/00393998/Library/Developer/CoreSimulator/Devices/23342248-4844-41AB-9851-2023D815FAA2/data/Containers/Bundle/Application/9A86A08B-5411-4909-B62B-B27097CA2EC9/Binary.app/Binary
fbase:0x10beee000
sname:-[ViewController touchesBegan:withEvent:]
saddr:0x10beef9d0
guard: 0x10bef468c 6 PC ?
fname:/Users/00393998/Library/Developer/CoreSimulator/Devices/23342248-4844-41AB-9851-2023D815FAA2/data/Containers/Bundle/Application/9A86A08B-5411-4909-B62B-B27097CA2EC9/Binary.app/Binary
fbase:0x10beee000
sname:testFunc
saddr:0x10beef9b0
guard: 0x10bef4688 5 PC \367\371\356??
4.3 寫入order文件
寫入文件時有許多需要注意的地方,即坑點
1祥绞、多線程
考慮到這個方法會來特別多次非洲,使用鎖會影響性能鸭限,這里使用蘋果底層的原子隊列
( 底層實際上是個棧結(jié)構(gòu),利用隊列結(jié)構(gòu) + 原子性
來保證順序 ) 來實現(xiàn)两踏。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
//遍歷出隊
while (true) {
//offset 通過next指針在結(jié)構(gòu)體的偏移量败京,進而知道next的指向
//offsetof 就是針對某個結(jié)構(gòu)體找到某個屬性相對這個結(jié)構(gòu)體的偏移量
// offsetof(SymbolNode, next) 可以替換為 8
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info);
printf("%s \n",info.dli_sname);
}
}
//原子隊列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定義符號結(jié)構(gòu)體
typedef struct{
void * pc;
void * next;
}SymbolNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入隊
// offsetof 用在這里是為了入隊添加下一個節(jié)點找到 前一個節(jié)點next指針的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
2、死循環(huán)
上述這種 clang 插樁的方式梦染,會在while循環(huán)中同樣插入 hook 代碼赡麦。
通過匯編會查看到 while 循環(huán)
,會被多次靜態(tài)加入 __sanitizer_cov_trace_pc_guard
調(diào)用帕识,導(dǎo)致死循環(huán)泛粹。
解決方式:Other C Flags
修改為如下:-fsanitize-coverage=func,trace-pc-guard
。func:表示僅 hook函數(shù)
時調(diào)用肮疗。
cbnz
:匯編執(zhí)行晶姊,while循環(huán)。
3伪货、load方法
有load
方法時们衙,__sanitizer_cov_trace_pc_guard
函數(shù)的參數(shù) guard
是 0
,所以打印并沒有發(fā)現(xiàn) load
碱呼。屏蔽掉 __sanitizer_cov_trace_pc_guard
函數(shù)中的:if (!*guard) return;
拓展:如果我們希望從某個函數(shù)之后/之前開始優(yōu)化蒙挑,那么我們可以通過一個全局靜態(tài)變量,在特定的時機修改其值巍举,在
__sanitizer_cov_trace_pc_guard
這個函數(shù)中做好對應(yīng)的處理即可。
4凝垛、其他處理
- 由于用的先進后出原因 , 我們要
倒敘
一下 去重
-
order
文件格式要求:c函數(shù)
懊悯、block
前面還需要加_
下劃線。
核心代碼(不要忘記編譯配置哦):
//引入頭文件
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
//核心代碼
#pragma mark - 獲取order文件
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (YES) {
//offsetof 就是針對某個結(jié)構(gòu)體找到某個屬性相對這個結(jié)構(gòu)體的偏移量
SymbolNode * node = OSAtomicDequeue(&symbolList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);
// 添加 _
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
//去重
if (![symbolNames containsObject:symbolName]) {
[symbolNames addObject:symbolName];
}
}
//取反
NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
NSLog(@"%@",symbolAry);
//將結(jié)果寫入到文件
NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"binary.order"];
NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (result) {
NSLog(@"%@",filePath);
}else{
NSLog(@"文件寫入出錯");
}
}
//原子隊列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定義符號結(jié)構(gòu)體
typedef struct{
void * pc;
void * next;
}SymbolNode;
#pragma mark - 靜態(tài)插樁代碼
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_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; // Guards should start from 1.
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入隊
// offsetof 用在這里是為了入隊添加下一個節(jié)點找到 前一個節(jié)點next指針的位置
OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
}
最后運行梦皮,下載.order
文件到本地炭分,就可以愉快的玩耍了。
五剑肯、補充
5.1 swift / OC 混編工程問題
通過如上方式適合純 OC
工程獲取符號捧毛。由于 swift
的編譯器前端是自己的 swift
編譯前端程序,因此配置稍有不同让网。搜索 Other Swift Flags
呀忧,添加兩條配置即可:-sanitize-coverage=func、 -sanitize=undefined
溃睹。swift類
同樣可以通過這個方式獲取而账。
5.2 cocoapod 工程問題
cocoapod
工程引入的庫,會產(chǎn)生多 target
因篇,我們在主target
添加的配置是不會生效的泞辐,我們需要針對需要的target
做對應(yīng)的設(shè)置笔横。
對于直接手動導(dǎo)入到工程里的 sdk
,不管是 靜態(tài)庫 .a
還是 動態(tài)庫
咐吼,會默認(rèn)使用主工程的設(shè)置吹缔,也就是可以拿到符號的。