二進制重排原理
在上一篇啟動優化的概念中,我們理解了虛擬內存與物理內存,在加載APP不活躍的部分時,會訪問虛擬內存的page(映射表),而對應的物理內存沒有與映射表與之關聯的話,將會觸發
缺頁異常(page fault)
,這個時候就必須將應用在虛擬內存不活躍的部分在映射表進行與物理內存的關聯再加載應用到物理內存,可以理解觸發缺頁異常會對性能有一定的影響性。一般來說,App在冷啟動的過程中,會有很多的類,分類,第三方需要加載和執行,也就是會觸發最多次的缺頁異常,當許多的缺頁異常一起觸發會帶來大量的耗時,如下以WeChat為例,啟動階段觸發Page Fault的次數
打開
instruments
的System Trace
- 點擊啟動,為了表現冷啟動,需要重啟手機清除緩存數據,
- 從instruments測試結果,可以看到pagefault次數有3193次,可以看到這個是非常影響性能的
優化思路
測試
- 首先我們通過以下Demo查看方法在編譯時期的排列順序,在viewController中按下列順序定義,以下幾個方法
@implementation ViewController
void test1(){
printf("1");
}
void test2(){
printf("2");
}
- (void)viewDidLoad {
[super viewDidLoad];
test1();
}
+(void)load{
printf("3");
test2();
}
@end
- 在
Build Setting → Write Link Map File
設置為YES
。
- linkmap路徑 (或是上一張圖片的path to Link Map File)。
- CMD+B編譯demo,依據對應的路徑查找link map文件,如下所示,可以發現類中函數的加載順序是從上到下的。
* 而文件順序是根據Build Phases -> Compile Sources中的順序加載的。
思路
由上面的測試,可以看到,文件加載以及函數的調用的順序會影響page fault的數量,有極大的可能,在啟動時刻調用的方法是不同的page的,所以我們可以將我們在啟動時刻調用的方法排列在同一頁中,如此一來就可以減少觸發page fault的次數,這也就是二進制重排原理。
- 注意:在iOS生產環境的app,在發生page fault進行重新加載時,iOS系統還會對其做一次
簽名驗證
,因此iOS在生產環境的page Fault比Debug環境下所產生的耗時更多時間。
實現二進制重排
名詞解釋
Link Map
- Linkmap是iOS編譯過程中間的產物,
紀錄了二進制的文件佈局
,需要再Xcode的Build Settings
裡開啟Write Link Map File
, Link Map 主要包含三個部分:-
Object Files
生成二進制用到的link單元的路徑和文件編號 -
Sections
紀錄Mach-O每個Segment/section的地址範圍 -
Symbols
按順序紀錄每個符號的地址範圍
-
ld
- ld是Xcode使用的鏈接器,有一個參數order_file,我們可以通過在
Build Settings -> Order File
配置一個後綴為order的文件路徑。在這個order文件中,將所需要的符號按照順序寫在裡面,在項目編譯時,會按照這個文件的順序進行加載,以此來達到我們的優化。也就是說二進制重排的本質就是對啟動加載的符號進行重新排列。 - 但是我們要如何獲取我們的函數呢?以下有幾個思路...
- hook objc_msgSend:我們知道,函數的本質是發送消息,在底層都會來到
objc_msgSend
,但是由於objc_msgSend的參數是可變的,需要通過彙編獲取,對開發人員要求較高,且也只能拿到OC和swift中@objc後的方法。 - 靜態掃描:掃描Mach-O特定段ㄉ和節裡面所存儲的符號以及函數數據
- Clang插樁:批量hook,可以實現100%符號覆蓋,最完全獲取swift,OC,block函數
- hook objc_msgSend:我們知道,函數的本質是發送消息,在底層都會來到
Clang 插樁
- llvm內置了一個簡單的代碼覆蓋率檢測(
SanitizerCoverage
)。他對於基本塊級邊緣級插入對用戶定義函數的調用。接下來介紹的批量hook,就需要借助於sanitizerCoverage
。 - 關於clang的插樁覆蓋的官方文檔如下:
- 文檔中有詳細描述即簡短的Demo演示。
第一步:開啟SanitizerCoverage
- OC項目:
Build Settings
→Other C Flags
添加fsanitize-coverage=func,trace-pc-guard
- Swift項目:須額外在
Build Settings
→Other Swift Flags
中加入sanitize-coverage=func
和sanitize=undefined
- 所有鏈接到App中的二進制都需要開啟
SanitizerCoverage
,這樣才能完全覆蓋到所有調用 - 也可以通過
podfile
來配置參數
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
end
end
end
第二步:重寫方法,新建一個OC文件ACOrderFile
,重寫兩個方法
-
__sanitizer_cov_trace_pc_guard_init
方法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. }
-
參數1:start
是一個指針,指向無符號int類,4個字節,相當於一個數組的起始位置
,即符號的起始位置(是從高位往低位讀)
-
-
參數2:Stop
也是一個指針,由於取數據是從高地址往低地址讀取的,所以如果直接讀取stop的地址並不是stop真正的地址,而是讀取到最後的地址,所以讀取stop時,由於stop占4個字節,stop真實地址 = stop打印的地址-0x4
- stop內存地址中存儲的值表示什麼呢?我們可以增加一個方法/C++/屬性的方法(多3個),發現其值也會增加對應的數,例如我們增加一個test1方法。可以看到數據從1d變成了1e
-
__sanitizer_cov_trace_pc_guard
方法,主要是捕獲所有的啟動時刻的符號,將所有符號入隊 - 參數guard是一個哨兵,告訴我們是第幾個被調用的
- 符號的存儲需要借助於鏈表,所以需要定義鏈表節點ACNode
- 通過
OSQueueHead
創建原子隊列,其目的是保證讀寫的安全 - 通過
OSAtomicEnqueue
方法將node入隊,通過鏈表的next指針可以訪問下一個符號
//原子隊列,其目的是保證寫入安全,線程安全
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體,以鏈表的形式
typedef struct {
void *pc;
void *next;
}ACNode;
/*
- start:起始位置
- stop:並不是最後一個符號地址,而是整个符號表的最後一个地址,
最後一个符號的地址=stop-4
(因為讀取數據是從高地址往低地址讀取的,且stop是一个無符號int類型,占4个字節)。
stop存储的值是符號
*/
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_t N;
if (start == stop || *start) return;
printf("INIT: %p - %p\\n", start, stop);
for (uint32_t *x = start; x < stop; x++) {
*x = ++N;
}
}
/*
可以全面hook方法、函数、以及block調用,用於捕捉符號,是多線程進行的,
這個方式只儲存pc,以链表的形式
- guard 是一個哨兵,告訴我們是第幾個被調用的
*/
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//将load方法过滤掉了,所以需要注释掉
// if (!*guard) return;
//獲取PC
/*
- PC 當前函数返回上一個调用的地址
- 0 當前這個函数地址,即當前函数的返回地址
- 1 當前函数调用者的地址,即上一個函数的返回地址
*/
void *PC = __builtin_return_address(0);
//創建node,並赋值
ACNode *node = malloc(sizeof(ACNode));
*node = (ACNode){PC, NULL};
//加入隊列
//符号的訪問不是通過下標訪問,是通過链表的next指針,
//所以需要借用offsetof(結構體類型,下一個的地址即next)
OSAtomicEnqueue(&queue, node, offsetof(ACNode, next));
}
第三步:獲取所有符號並寫入文件
- while循環從隊列中取出符號,處理非OC方法的前綴,存入數組中
- 數組取反,因為入隊儲存的順序是反序的。
- 數組去重,並移除本身方法的符號
- 將數組中的符號轉成字符串並寫入到AC.oder文件中
- 另外也可以在第一個
didFinishLaunchingWithOptions
根視圖,獲取符號(這部分可以由自己決定,以下範例是在touchesBegan方法調用時獲取)
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSMutableArray <NSString *> * symbolNames = [NSMutableArray array];
//while 循環取符號
while (YES) {
//出隊
ACNode * node = OSAtomicDequeue(&symbolList, offsetof(ACNode, next));
if (node == NULL) {
break;
}
//取出PC存入info
Dl_info info;
dladdr(node->pc, &info);
//printf("%s \\n", info.dli_sname);
NSString * name = @(info.dli_sname);
//判斷是不是OC方法,如果不是需要加下滑線,反之,則直接存儲
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name: [@"_" stringByAppendingString:name];
[symbolNames addObject:symbolName];
}
//取反 (隊列的存儲是反序的)
NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
//去重
NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString * name;
while (name = [emt nextObject]) {
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
//去掉自己!
[funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
//將數組變成字符串
NSString * funcStr = [funcs componentsJoinedByString:@"\\n"];
//字符串寫入文件
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"AC.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
NSLog(@"%@",funcStr);
}
注意點:避免死循環
-
Build Settings -> Other C Flags
的如果配置的是-fsanitize-coverage=trace-pc-guard
,在while部分會出現死循環(我們在touchBegin方法中調適)
- 我們透過彙編查看,發現有三個地方調用
__sanitizer_cov_trace_pc_guard
的調用 - 第一次 touchBegin調用
__sanitizer_cov_trace_pc_guard
- 第二次bl跳轉跳轉因為while循環,只要跳轉就會被hook,即有b,bl指令,就會被hook
- 第三次bl是printf
解決方式是將BuildSetting中的other C Flags
改成-fsanitize-coverage=func,trace-pc-guard
第四步 拷貝文件,放入指定位置 並配置路徑
- 一般將該文件放入主項目的路徑下,並在
Build Settings -> Order File
中配置./AC.order,下面是配置前後的對比 - 沒有配置,照序加載
-
有配置,依照啟動時時刻所需(自己配置的.oder文件)加載