32.iOS底層學習之啟動優化

本章提綱:
1、pre-Main階段的性能檢測
2、虛擬內存
3、二進制重排
4、Clang插裝

1、pre-Main階段的性能檢測

應用的啟動過程一般以Main函數為臨界點,分為Main函數之前和Main函數之后。
Main函數之前我們稱為pre-Main
Xcode為檢測pre-Main的耗時提供了環境變量,以便開發者了解pre-Main的時間。
在Xcode中的Schemes->Run->Arguments中添加DYLD_PRINT_STATISTICS的環境變量為YES。然后運行程序,可以看到如下打印:

Total pre-main time: 540.09 milliseconds (100.0%)
dylib loading time: 159.35 milliseconds (29.5%)
rebase/binding time: 39.06 milliseconds (7.2%)
ObjC setup time: 28.37 milliseconds (5.2%)
initializer time: 313.30 milliseconds (58.0%)
slowest intializers :
libSystem.B.dylib : 7.52 milliseconds (1.3%)
libMainThreadChecker.dylib : 48.67 milliseconds (9.0%)
GPUToolsCore : 26.26 milliseconds (4.8%)
libglInterpose.dylib : 113.10 milliseconds (20.9%)
KSAdSDK : 105.15 milliseconds (19.4%)
xxxx : 80.49 milliseconds (14.9%)

  • dylib loading time
    動態庫的載入耗時。系統的動態庫存在于共享緩存,但是自定義的動態庫就要通過依賴關系一個一個的加載。
    蘋果官方建議項目中不要超過6個自定義的動態庫,超過的部分最好進行多個動態庫合并,以此來減少動態庫的加載時間。

  • rebase/binding time
    這是一個非常核心而且重要的概念。重定位/符號綁定耗時。涉及到虛擬內存的相關技術,會在下面詳細介紹。
    rebase(重定位):采用了ASLR技術,保證地址的隨機化,加強了內存訪問的安全性。
    binding(符號綁定):使用外部符號,編譯時無法找到函數地址。在運行時,dyld加載共享緩存,加載鏈接動態庫之后,進行binding操作,重新綁定外部符號。

  • ObjC setup time
    注冊OC類的耗時。應用啟動時,系統會生成OC類和分類的兩張相關映射表,IMP到SEL的映射,分類的方法等合并到相關表中的等操作會造成一部分的耗時。
    減少項目中類和分類的數量可以優化這部分的時間。
    減少類和分類中的Load方法的使用,讓類以懶加載的方式加載。

  • initializer time
    執行load以及C++構造函數的耗時

  • slowest intializers
    最耗時的幾個動態庫。

2、虛擬內存

聊到虛擬內存我們就要聊起早期的計算機結構。早期的是馮·諾依曼計算機結構,在1945年就被提出了,在當時是很新穎的結構了,它是第一次將存儲器和運算器分離,開啟了以存儲器為核心的現代計算機的篇章。

馮·諾依曼計算機結構

但是馮·諾依曼結構有它自己的問題,就是存儲器之間的讀取速度遠遠小于CPU的工作效率。讀取效率低,CPU的運算能力又太快,就造成了CPU性能的浪費。為了解決這個問題,現行的解決方式就是采用多級存儲,來平衡存儲器的讀寫速率,容量,價格。

該結構下的CPU的尋址方式:內存可以被看成一個數組,數組元素是一個字節大小的空間,而數組索引則是所謂的物理地址。最簡單直接的方式就是CPU直接通過物理地址去訪問對應的內存,也叫做物理尋址。

這種尋址方式有非常嚴重的安全問題。因為直接暴露的是物理地址,所以進程通過地址偏移可以訪問到任何屋里地址,用戶進程想干嘛就干嘛。這是非常不安全的。

現代處理器使用的是虛擬尋址的方式。CPU通過訪問虛擬地址,經過翻譯獲得物理地址才能訪問內存。這個翻譯過程由CPU中的內存管理單元(Memory Management Unit,縮寫為MMU)完成。

現代的操作系統都引入了虛擬內存。對于每個進程來說,操作系統可以為其提供一個獨立的私有的連續的地址空間。對于進程來說,它的可見部分只有分配給它的虛擬內存。而虛擬內存實際可能映射到物理內存以及硬盤的任何區域。由于硬盤的讀寫速度不如內存快,所以操作系統會優先使用物理內存空間,但是當物理內存空間不夠時,就會將部分內存數據交換到硬盤上去存儲,這也是所謂的Swap內存交換機制。有了內存交換機制以后,相比起物理尋址,虛擬內存實際上利用了磁盤空間擴展了內存空間。

虛擬內存的優勢同時也彰顯了出來:
1、保護了進程的地址空間,將進程和物理地址完全阻隔開,無法跨進程訪問。
2、由于操作系統分配的虛擬內存是連續的,簡化了內存管理。
3、利用硬盤空間拓展了內存空間。
4、可以按需加載內容到內存中,避免內存浪費。

內存分頁

虛擬內存和物理內存存在映射關系,為了方便映射和管理,虛擬內存和物理內存都被分割成大小相同的單位,物理內存的最小單位稱為幀(Frame),而虛擬內存的最小單位被稱為頁(Page)

在iOS中,一頁的大小為16KB,當進程被加載到內存中是,虛擬內存會給該進程開辟最大4個G的虛擬內存空間。

內存分頁的最大意義在于:
1、支持了物理內存的離散使用;
2、提高MMU的翻譯效率,采用一些頁面調度(Paging)算法,利用翻譯過程中也存在局部性原理,將大概率被使用的幀地址加入到TLB或者頁表之中,提高翻譯效率。

缺頁中斷

現代計算機都是分級緩存的,內存命中的查找也是分級的。

  • 首先會在TLB(Translation Lookaside Buffer)中進行查詢,這個表位于CPU內部,查詢速度最快;
  • 如果沒有命中,那么接下來會在頁表(Page Table)中進行查詢,頁表位于物理內存中,所以查詢速度較慢,如果發現目標不在物理內存中,那么成為缺頁
  • 如果物理內存沒有命中查找,此時會去磁盤中查找,如果還找不到就報錯了。

所以當發生缺頁時,操作系統會阻塞當前進程,把需要的數據載入到物理內存中,然后再尋址讀取。當缺頁頻繁發生時,也是非常耗時的。

頁面置換

由于物理內存是有限的,當物理內存沒有空間時,操作系統會通過算法找到最不經常使用的物理頁驅逐回磁盤,為新的內存頁讓出空間。這個過程稱為頁面置換,也稱內存交換

然而!!!iOS并不支持內存交換機制!!
大多數移動設備都不支持內存交換機制。移動設備上的大容量存儲器通常是閃存(Flash),它的讀寫速度遠遠小于電腦所使用的的硬盤,這就導致了在移動設備上,就算使用了內存交換也不能提升性能。其次,移動設備本身容量就經常短缺,閃存的讀寫壽命也非常有限,所以這種情況下還有進行內存交換就非常不劃算了。

ASLR

程序的代碼在不修改的情況下,每次加載到虛擬內存的地址是一樣的,這樣的方式并不安全,為了解決地址固定的問題,出現了ASLR技術。
ASLR(Address space layout randomization):地址空間配置隨機加載,是一種防范內存損壞漏洞被利用的計算機安全技術。

地址空間配置隨機加載利用隨機方式配置數據地址空間,使某些敏感數據配置到一個惡意程序無法事先獲知的地址,令攻擊者難以進行攻擊。

以上就簡單的介紹了下虛擬內存的相關知識。接下來是二進制重排部分。

3、二進制重排

3.1缺頁中斷時間消耗的檢測

前面我們已經提到了缺頁中斷,接下來我們通過Profile來檢測一下缺頁中斷的發生。
Xcode頂部菜單Product->Profile->Instruments->System Trace

image.png

可以看到我們的項目冷啟動時,缺頁次數大概是1200多次,耗時130毫秒,如果項目再大一些,缺頁發生的更多那么也是一個不小的影響啟動時間的一個因素。

3.2二進制重排原理

創建測試項目,查看代碼的順序,在Build Settings->Write Link Map File,設置為YES,然后編譯項目,來到工程的Build目錄下,找到LinkMap文件

image.png

Build目錄找不到的話從Xcode->Preferences->Locations,可以看到Derived Data的路徑,可以直接跳轉過去。

具體看到LinkMap文件保存了項目再編譯鏈接時的符號順序,以方法/函數為單位排列。

image.png

可以看到和編譯的文件順序是一樣的,目前ViewController中只有一個方法viewDidLoad,所以在這個文件下面ViewController只排列了這一個方法。

如果按照默認配置,在啟動時會加載大量的與啟動無關的代碼,導致缺頁。那么如果可以將啟動時需要的方法/函數排在最前面,就能降低缺頁的發生,從而提高應用的啟動速度,這就是二進制重排的核心原理。

3.2二進制重排準備

在工程目錄下創建一個.order文件,按照固定的格式,將啟動時需要的方法/函數順序排列,然后再去把排列好的.order文件放到Xcode中使用。在.order中寫入測試順序

-[ViewController viewDidLoad]
_main

最后通過LinkMap文件查看來驗證.order是否生效。
在Xcode中進行配置.order文件,在Build Settings->Order File中配置

image.png

結果新的LinkMap中的前兩位的順序確實是我寫入Lucky.order文件的順序。
image.png

以上就完成了重排的準備工作,并且測試也生效了,接下來的難點就是,怎么能獲取到啟動時需要調用的所有方法和函數。

4、Clang插莊

如果只對于OC方法,可以對objc_msgSend方法進行Hook,但是系統調用的方法中會有一些c、c++的方法函數,以及一些block回調,這些通過objc_msgSend是無法攔截到的。

LLVM內置了一個簡單的代碼覆蓋率檢測的工具(SanitizerCoverage)。它在函數級、基本塊和邊緣級上插入了對用戶自定義函數的調用,通過方式,可以順利對OC方法、C函數、Block塊、Swift等函數進行更加全面的攔截。
(官方文檔鏈接)https://clang.llvm.org/docs/SanitizerCoverage.html

4.1配置SanitizerCoverage

搭建測試項目,在Build Settings->Other C Flags中,增加-fsanitize-coverage=trace-pc-guard的配置。
根據官方文檔的示例,在測試項目中添加以下代碼:

#import "ViewController.h"
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>
@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
}

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;
}

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;

    void *PC = __builtin_return_address(0);
    char PcDescr[1024];

//    printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
@end

如果不添加__sanitizer_cov_trace_pc_guard_init方法和__sanitizer_cov_trace_pc_guard編譯會報錯。

image.png

添加完就可以正常編譯運行了。
打印如下:
image.png

  • __sanitizer_cov_trace_pc_guard_init
    函數__sanitizer_cov_trace_pc_guard_init是回調函數,startstop表示一個section的首地址和結束地址。這個方法能反應項目中的符號個數。

// This callback is inserted by the compiler as a module constructor
// into every DSO. 'start' and 'stop' correspond to the
// beginning and end of the section with the guards for the entire
// binary (executable or DSO). The callback will be called at least
// once per DSO and may be called multiple times with the same parameters.

  • __sanitizer_cov_trace_pc_guard
    而函數__sanitizer_cov_trace_pc_guard則是可以監聽到編譯器所有的emit,例如官方給的注釋中的例子:

/ This callback is inserted by the compiler on every edge in the
// control flow (some optimizations apply).
// Typically, the compiler will emit the code like this:
// if(*guard)
// __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
// __sanitizer_cov_trace_pc_guard(guard);

4.2 __sanitizer_cov_trace_pc_guard的測試

我們來測試一下是不是函數方法block都會被攔截,添加如下測試代碼:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSLog(@"touchesBegan方法執行");
    test();
}

void(^block)(void) = ^(void){
    NSLog(@"Block執行");
};

void test(){
    NSLog(@"test函數執行");
    block();
}

image.png

可以看到這些方法確實都被函數__sanitizer_cov_trace_pc_guard能攔截到。通過查看匯編指令:
image.png

image.png

image.png

可以看到這幾個測試方法后邊都有callq指令,調用的都是__sanitizer_cov_trace_pc_guard

可以初步的了解到,Clang插裝的原理是,只要添加了插裝的標記,編譯器就會在當前項目中,在所有的方法、函數、block的代碼實現的邊緣,插入一句__sanitizer_cov_trace_pc_guard達到方法、函數、block的全覆蓋。

4.4獲取符號名稱

官方示例代碼中,用了__builtin_return_address函數,該函數的作用會獲取到當前的返回地址,也就是函數的調用者。
通過Dl_info

typedef struct dl_info {
        const char      *dli_fname;     /* Pathname of shared object */
        void            *dli_fbase;     /* Base address of shared object */
        const char      *dli_sname;     /* Name of nearest symbol */
        void            *dli_saddr;     /* Address of nearest symbol */
} Dl_info;

dli_fname:當前的路徑
dli_fbase:地址
dli_sname:調用的函數名稱
dli_saddr:函數地址
所以我們通過dli_sname來拿到函數名稱。接下來的工作就是拿到這些名稱(去重),然后把名稱寫入到前面說的.order文件中去,也就完成了重排的工作。

4.5實踐
  • 存儲返回地址
    為了保證線程安全,定義一個原子隊列,隊列中存儲帶有返回地址的結構體。
//定義原子隊列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體
typedef struct {
    void * pc;
    void * next;
} SYNode;

通過SYNode來存儲,方法__sanitizer_cov_trace_pc_guard中通過函數__builtin_return_address得到的pc
函數__sanitizer_cov_trace_pc_guard的實現如下:

//HOOK一切的回調函數!!
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    void *PC = __builtin_return_address(0);
    //創建結構體
    SYNode * node = malloc(sizeof(SYNode));
    *node = (SYNode){PC,NULL};
    //結構體入棧     
    //offsetof:參數1傳入類型,將下一個節點的地址返回給參數2
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
  • 獲取函數符號并去重排序
    獲取完畢返回的地址,我們進行排序和去重處理
 //定義數組
    NSMutableArray<NSString *> * symbleNames = [NSMutableArray array];
    while (YES) {
  //循環體內!進行了攔截!!
        SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode,next));
        if (node == NULL) {
            break;
        }
        
        Dl_info info;
        dladdr(node->pc, &info);
        NSString * name = @(info.dli_sname);//獲取函數名稱,并轉字符串
        //oc方法直接返回,其余的前面加"_"
        BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
        NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
        //符號加到符號數組里
        [symbleNames addObject:symbolName];
    }

    //反向遍歷數組
    NSEnumerator * em = [symbleNames reverseObjectEnumerator];

//去重
    NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbleNames.count];
    NSString * name;
    while (name = [em nextObject]) {
        if (![funcs containsObject:name]) {//數組沒有name
            [funcs addObject:name];
        }
    }
    //去掉自己!
    [funcs removeObject:[NSString stringWithFormat:@"%s",__func__]];
  • 寫入文件并配置
    處理完要進行重排的相關符號,下一步就是把這些寫入.order文件中。
 //寫入文件
    NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"Lucky.order"];
    NSData * file = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:file attributes:nil];
    NSLog(@"%@",funcStr);

寫入完畢之后,我們根據前邊編譯.order的經驗來編譯,至此我們就完成了重排和插裝的過程!可以對實際項目進行測試一下是不是有作用。


慢慢都堅持這么久了,繼續加油!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容