一、二進制重排介紹
1、App啟動
進程如果能直接訪問物理內存無疑是很不安全的,所以操作系統在物理內存的上又建立了一層虛擬內存。蘋果在這個基礎上還有 ASLR(Address Space Layout Randomization) 技術的保護,不過不是這次的重點。
iOS系統中虛擬內存到物理內存的映射都是以頁為最小單位的。當進程訪問一個虛擬內存Page而對應的物理內存卻不存在時,就會出現Page Fault缺頁中斷,然后加載這一頁。雖然本身這個處理速度是很快的,但是在一個App的啟動過程中可能出現上千(甚至更多)次Page Fault,這個時間積累起來會比較明顯了
另外,還有兩個重要的概念:冷啟動、熱啟動。可能有些同學認為殺掉再重啟App就是冷啟動了,其實是不對的。
- 冷啟動:程序完全退出,之間加載的分頁數據被其他進程所使用覆蓋之后,或者重啟設備、第一次安裝,才算是冷啟動。
- 熱啟動:程序殺掉之后,馬上又重新啟動。這個時候相應的物理內存中仍然保留之前加載過的分頁數據,可以進行重用,不需要全部重新加載。所以熱啟動的速度比較快。
2、二進制重排原理
編譯器在生成二進制代碼的時候,默認按照鏈接的Object File(.o)順序寫文件,按照Object File內部的函數順序寫函數。
靜態庫文件.a就是一組.o文件的ar包,可以用
ar -t
查看.a包含的所有.o。
簡化問題:假設我們只有兩個page:page1/page2,其中綠色的method1和method5啟動時候需要調用,為了執行對應的代碼,系統必須進行兩個Page Fault。
但如果我們把method1和method5排布到一起,那么只需要一個Page Fault即可,這就是二進制文件重排的核心原理。
實際項目中的做法是將啟動時需要調用的函數放到一起 ( 比如 前10頁中 ) 以盡可能減少 page fault , 達到優化目的 . 而這個做法就叫做 : 二進制重排 。
注意:在iOS生產環境的app,在發生Page Fault進行重新加載時,iOS系統還會對其做一次簽名驗證,因此 iOS 生產環境的 Page Fault 比Debug環境下所產生的耗時更多
二、重排的order文件
1、文件順序
Build Phases 中 Compile Sources 列表順序決定了文件執行的順序(可以調整)。如果不進行重排,文件的順序決定了方法、函數的執行順序。我們在 ViewController 和 AppDelegate 中加入以下代碼:
+ (void)load {
NSLog(@"%s", __FUNCTION__);
}
我們調整 Compile Sources 中這兩個類的順序,然后分別執行對比??梢钥吹剑S著 Compile Sources 中的文件順序的修改,+load 方法的執行順序也發生了改變。
2、符號表順序
Link Map
是iOS編譯過程的中間產物,記錄了二進制文件的布局,需要在Xcode的Build Settings
里開啟Write Link Map File
,Link Map
主要包含三部分:
-
Object Files
生成二進制用到的link單元的路徑和文件編號 -
Sections
記錄Mach-O每個Segment/section的地址范圍 -
Symbols
按順序記錄每個符號的地址范圍
1)Build Settings中修改Write Link Map File為YES
2)查找Link Map符號表txt
文件
編譯后會生成一個Link Map符號表txt
文件,選擇Product中的App,在Finder中打開,選擇Intermediates.noindex文件夾,找到LinkMap文件,這里是ZJHBinaryLaunchDemo-LinkMap-normal-x86_64.txt。。詳細路徑請看下圖。
3)查看Link Map符號表txt
文件
打開文件之后來到第一部分的最后。我們可以看到這個順序和我們Compile Sources中的順序是一致的。接下來的部分:
可以看到,整體的順序和Compile Sources的中的順序是一樣的,并且方法是按照文件中方法的順序進行鏈接的。ViewController中的方法添加完后,才是AppDelegate中的方法,以此類推。
-
Address
? 表示文件中方法的地址。 -
Size
表示方法的大小。 -
File
表示在第幾個文件中。 -
Name
表示方法名。
3、導入order文件
ld
是Xcode使用的鏈接器,有一個參數order_file
,我們可以通過在Build Settings -> Order File
配置一個后綴為order的文件路徑.在這個order文件中,將所需要的符號按照順序寫在里面,在項目編譯時,會按照這個文件的順序進行加載,以此來達到我們的優化
來到工程根目錄 , 新建一個文件 touch ZJHBinaryLaunchDemo.order
. 隨便挑選幾個啟動時就需要加載的方法 , 例如我這里選了以下幾個 .
+[AppDelegate load]
+[ViewController load]
_main
-[ViewController someMethod]
然后在Build Settings中找到Order File,填入./ZJHBinaryLaunchDemo.order
。然后重新比納音,再次查看生成符號表txt
文件。
可以看到Link Map中的最上面幾個方法和我們在ZJHBinaryLaunchDemo.order文件中設置的方法順序一致!
Xcode的連接器ld還忽略掉了不存在的方法 -[ViewController someMethod]。如果提供了link選項 -order_file_statistics,會以warning的形式把這些沒找到的符號打印在日志里。
注意:有部分同學可能配置完運行會發現報錯說
can't open
這個order file
. 是因為文件格式的問題 . 不用使用mac
自帶的文本編輯 . 使用命令工具touch
創建即可 .
三、檢測啟動時方法
要真正的實現二進制重排,我們需要拿到啟動時所有用到的方法、函數等符號,并保存其順序,然后寫入order
文件,實現二進制重排。這里我們使用Clang插樁
的方式
1、Clang插樁原理
其實就是一個代碼覆蓋工具,更多信息可以查看官網。
1)首先 , 添加編譯設置
Build Settings中 Other C Flags添加配置
-fsanitize-coverage=trace-pc-guard
編譯的話會報以下錯誤,意思是找不到這兩個函數
Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
Undefined symbol: ___sanitizer_cov_trace_pc_guard
2)添加 hook
代碼
我們把面的代碼,添加到 ViewController.m
中
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
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)運行工程 , 查看打印
INIT: 0x1024153e0 0x102415420
guard: 0x1024153f8 7 PC
guard: 0x1024153ec 4 PC ?
guard: 0x102415414 e PC
guard: 0x102415418 f PC ?
guard: 0x102415414 e PC
guard: 0x102415414 e PC
guard: 0x1024153fc 8 PC $
guard: 0x102415414 e PC
guard: 0x102415414 e PC ?
guard: 0x102415414 e PC \300\202\3605?
代碼命名 INIT
后面打印的兩個指針地址叫 start
和 stop
. 那么我們通過 lldb
來查看下從 start
到 stop
這個內存地址里面所存儲的到底是啥 .
4)驗證 start
到 stop
內存地址存儲值
在viewDidLoad
方法中添加斷點,執行項目。在lldb
分別輸入 x start地址
和 x stop地址-0x4
,讀取內存地址。 x stop地址-0x4
是結束位置,按顯示是4位的,所以向前移動4位,打印出來的應該就是最后一位。
發現存儲的是從 1 到 16(0x10) 這個序號 ,我們再新增兩個方法,再次運行查看:
INIT: 0x102fb93f0 0x102fb9438
(lldb) x 0x102fb9438-0x4
0x102fb9434: 12 00 00 00 f0 92 0c 03 01 00 00 00 00 00 00 00 ................
0x102fb9444: 00 00 00 00 5f 33 fb 02 01 00 00 00 00 00 00 00 ...._3..........
(lldb)
發現從 0x10
變成了 0x12
. 也就是說存儲的 1 到 16 這個序號變成了 1 到 18 。那么我們得到一個猜想 , 這個內存區間保存的就是工程所有符號的個數 。
5)驗證guard
調用次數
在 touchesBegan
方法中,打印語句,點擊屏幕。每點擊一次就會調用一次 guard :
。而且 guard :
是在前面調用的。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"touchesBegan");
}
// 控制臺的輸出
guard: 0x1006053fc 4 PC ?
2021-04-21 13:29:51.936925+0800 ZJHBinaryLaunchDemo[13644:5077278] touchesBegan
在 touchesBegan
方法中,執行調用test1
方法,會發現 guard :
調用了兩次
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"touchesBegan");
[self test1];
}
- (void)test1 {
NSLog(@"test1");
}
// 控制臺的輸出
guard: 0x102d893f8 3 PC ?
2021-04-21 13:31:57.152720+0800 ZJHBinaryLaunchDemo[13646:5077923] touchesBegan
guard: 0x102d893fc 4 PC d?\330?\201\226P\314\370\223\330??
2021-04-21 13:31:57.152915+0800 ZJHBinaryLaunchDemo[13646:5077923] test1
由此,發現我們實際調用幾個方法 , 就會打印幾次 guard :
。
6)查看匯編代碼驗證
我們在 touchesBegan:touches withEvent:
開頭設置一個斷點,并開啟匯編顯示(菜單欄Debug → Debug Workflow → Always Show Disassembly
)。
通過匯編我們發現 , 在每個函數調用的第一句實際代碼 ( 棧平衡與寄存器數據準備除外 ) , 被添加進去了一個 bl 調用到 __sanitizer_cov_trace_pc_guard
這個函數中來 。而實際上這也是靜態插樁的原理和名稱由來 。
靜態插樁總結:靜態插樁實際上是在編譯期就在每一個函數內部二進制源數據添加
hook
代碼 ( 我們添加的__sanitizer_cov_trace_pc_guard
函數 ) 來實現全局的方法hook
的效果 .
2、獲取所有函數符號
1)獲取原函數地址
我們在 __sanitizer_cov_trace_pc_guard
函數中的這一句代碼 :
void *PC = __builtin_return_address(0);
它的作用其實就是去讀取 x30
中所存儲的要返回時下一條指令的地址 . 所以他名稱叫做 __builtin_return_address
. 換句話說 , 這個地址就是我當前這個函數執行完畢后 , 要返回到哪里去 。也就是說 , 我們現在可以在 __sanitizer_cov_trace_pc_guard
這個函數中 , 通過 __builtin_return_address
數拿到原函數調用 __sanitizer_cov_trace_pc_guard
這句匯編代碼的下一條指令的地址 。及上圖中,拿到-[ViewController touchesBegan:withEvent:]
地址
2)根據內存地址獲取函數名稱
拿到了函數內部一行代碼的地址 , 如何獲取函數名稱呢 ? 熟悉安全攻防 , 逆向的同學可能會清楚 . 我們為了防止某些特定的方法被別人使用 fishhook
hook
掉 , 會利用 dlopen
打開動態庫 , 拿到一個句柄 , 進而拿到函數的內存地址直接調用 .是不是跟我們這個流程有點相似 , 只是我們好像是反過來的 . 其實反過來也是可以的 .
與 dlopen
相同 , 在 dlfcn.h
中有一個方法如下 :
typedef struct dl_info {
const char *dli_fname; /* 所在文件 */
void *dli_fbase; /* 文件地址 */
const char *dli_sname; /* 符號名稱 */
void *dli_saddr; /* 函數起始地址 */
} Dl_info;
//這個函數能通過函數內部地址找到函數符號
int dladdr(const void *, Dl_info *);
緊接著我們來實驗一下 , 先導入頭文件#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("fname=%s \nfbase=%p \nsname=%s\nsaddr=%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
char PcDescr[1024];
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
查看打印結果,可以看到我們要找的符號了 :
3、可能遇到的問題
1)多線程問題
項目各個方法肯定有可能會在不同的函數執行 , 因此 __sanitizer_cov_trace_pc_guard
這個函數也有可能受多線程影響 , 所以你當然不可能簡簡單單用一個數組來接收所有的符號就搞定了??紤]到這個方法會來特別多次 , 使用鎖會影響性能 , 這里使用蘋果底層的原子隊列 ( 底層實際上是個棧結構 , 利用隊列結構 + 原子性來保證順序 ) 來實現 .
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
//遍歷出隊
while (true) {
//offsetof 就是針對某個結構體找到某個屬性相對這個結構體的偏移量
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;
//定義符號結構體
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 用在這里是為了入隊添加下一個節點找到 前一個節點next指針的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
但此方法會導致死循環的問題
2)死循環問題
通過匯編會查看到 一個帶有 while
循環的方法 , 會被靜態加入多次 __sanitizer_cov_trace_pc_guard
調用 , 導致死循環.
Other C Flags
修改為如下 :
-fsanitize-coverage=func,trace-pc-guard
代表進針對 func
進行 hook
. 再次運行就可以了。
3) load 方法不打印問題
load
方法時 , __sanitizer_cov_trace_pc_guard
函數的參數 guard
是 0。上述打印并沒有發現 load
.
解決 : 屏蔽掉 __sanitizer_cov_trace_pc_guard
函數中的
if (!*guard) return;
效果如下
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 用在這里是為了入隊添加下一個節點找到 前一個節點next指針的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
// 打印結果
INIT: 0x104d8d400 0x104d8d444
-[ViewController touchesBegan:withEvent:]
-[SceneDelegate sceneDidBecomeActive:]
-[SceneDelegate sceneWillEnterForeground:]
-[ViewController viewDidLoad]
-[SceneDelegate window]
-[SceneDelegate window]
-[SceneDelegate window]
-[SceneDelegate scene:willConnectToSession:options:]
-[SceneDelegate window]
-[SceneDelegate window]
-[SceneDelegate setWindow:]
-[SceneDelegate window]
-[AppDelegate application:didFinishLaunchingWithOptions:]
main
+[AppDelegate load]
+[ViewController load]
這樣的話,load
方法就有了。這里也為我們提供了一點啟示:如果我們希望從某個函數之后/之前開始優化 , 通過一個全局靜態變量 , 在特定的時機修改其值 , 在 __sanitizer_cov_trace_pc_guard
這個函數中做好對應的處理即可 .
4、符號信息寫入文件
完整代碼如下 :
#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
@interface ViewController ()
@end
@implementation ViewController
+ (void)load{
}
- (void)viewDidLoad {
[super viewDidLoad];
testCFunc();
[self testOCFunc];
}
- (void)testOCFunc{
NSLog(@"oc函數");
}
void testCFunc(){
LBBlock();
}
void(^LBBlock)(void) = ^(void){
NSLog(@"block");
};
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)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (true) {
//offsetof 就是針對某個結構體找到某個屬性相對這個結構體的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, 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);
//將結果寫入到文件
NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lb.order"];
NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (result) {
NSLog(@"%@",filePath);
}else{
NSLog(@"文件寫入出錯");
}
}
//原子隊列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體
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 用在這里是為了入隊添加下一個節點找到 前一個節點next指針的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
@end
文件寫入到了 tmp
路徑下 , 運行 , 打開手機下載查看 :
5、swift 工程 / 混編工程問題
通過如上方式適合純 OC
工程獲取符號方式 .由于 swift
的編譯器前端是自己的 swift
編譯前端程序 , 因此配置稍有不同 .
搜索 Other Swift Flags
, 添加兩條配置即可 :
-sanitize-coverage=func
-sanitize=undefined
swift
類通過上述方法同樣可以獲取符號 .
四、驗證
1、打印啟動時間
在系統執行應用程序的main函數并調用應用程序委托函數(applicationWillFinishLaunching)之前,會發生很多事情。我們可以通過添加環境變量可以打印處APP的啟動分析(Edit scheme -> Run -> Argument)
.
DYLD_PRINT_STATISTICS
設置為1
(dyld_print_statistics)。如果需要更詳細的信息,那就將DYLD_PRINT_STATISTICS_DETAILS
設置為1
運行一下,對比控制臺的輸出(因筆者是在demo驗證,時間優化的效果不明顯,這里就不做對比展示了):
Total pre-main time: 97.73 milliseconds (100.0%)
dylib loading time: 28.02 milliseconds (28.6%)
rebase/binding time: 21.70 milliseconds (22.2%)
ObjC setup time: 5.16 milliseconds (5.2%)
initializer time: 42.85 milliseconds (43.8%)
slowest intializers :
libSystem.B.dylib : 6.26 milliseconds (6.4%)
libBacktraceRecording.dylib : 9.88 milliseconds (10.1%)
libMainThreadChecker.dylib : 22.10 milliseconds (22.6%)
ZJHBinaryLaunchDemo : 2.81 milliseconds (2.8%)
2、System Trace
日常開發中性能分析是用最多的工具無疑是Time Profiler,但Time Profiler是基于采樣的,并且只能統計線程實際在運行的時間,而發生Page Fault的時候線程是被blocked,所以我們需要用一個不常用但功能卻很強大的工具:System Trace。
選中主線程,在VM Activity中的File Backed Page In次數就是Page Fault次數,并且雙擊還能按時序看到引起Page Fault的堆棧:
五、CocoaPods相關
對于 cocoapod
工程引入的庫 , 由于針對不同的 target
. 那么我們在主程序中的 target
添加的編譯設置 Write Link Map File
, -fsanitize-coverage=func,trace-pc-guard
以及 order file
等設置肯定是不會生效的 . 解決方法就是針對需要的 target
去做對應的設置即可 .
對于直接手動導入到工程里的 sdk
, 不管是 靜態庫 .a
還是 動態庫
, 默認主工程的設置就可以了 , 是可以拿到符號的 .
更多CocoaPods相關問題,可參考這篇文章:https://juejin.cn/post/6844904192193085448
參考鏈接:
iOS 啟動優化之Clang插樁實現二進制重排
我是如何讓微博綠洲的啟動速度提升30%的
懶人版二進制重排
抖音研發實踐:基于二進制文件重排的解決方案 APP啟動速度提升超15%
懶人版二進制重排
手淘架構組最新實踐 | iOS基于靜態庫插樁的?進制重排啟動優化