iOS-底層探索30:啟動優化(Clang插樁)

iOS 底層探索 文章匯總

目錄


一、查看APP啟動耗時

main函數之前的處理為pre-mian階段,這篇文章主要分析這個階段。
添加DYLD_PRINT_STATISTICS參數打印出pre-mian階段的耗時情況:

各時段處理耗時分析:
  1. Total pre-main time: 總耗時
  2. dylib loading time: 動態庫載入耗時
  3. rebase/binding time: rebase表示地址偏移修正(ASLR),binding表示符號綁定
  4. ObjC setup time: OC類注冊耗時
  5. initializer time: 執行load構造函數的耗時
    slowest intializers :
  6. libSystem.B.dylib : 系統的
  7. libMainThreadChecker.dylib :
  8. XXXXX : 項目主程序耗時
pre-main優化方向:
  1. 官方建議非系統動態庫的加載個數不超過6個,多于6個就要考慮動態庫的合并;
  2. 減少OC類,減少C++虛函數
  3. 減少load方法和構造函數
main方法之后優化方向:
  1. 延遲初始化、懶加載
  2. 刪除不使用類、方法、圖片資源
  3. 盡量不用XIBStoryboard,特別是首屏界面
    參考:
    iOS 腳本查看項目中未使用的類iOS 腳本查看項目未使用到的方法iOS 腳本查找項目中無用資源腳本原理

二、虛擬內存和物理內存

1、虛擬內存和物理內存的區別

當我們向系統申請內存時,系統并不會給你返回物理內存的地址,而是給你一個虛擬內存地址。CPU讀取數據時也是通過內存管理單元MMU虛擬地址映射到物理內存地址。每個進程都擁有相同大小的虛擬地址空間,對于32位的進程,可以擁有4GB的虛擬內存,64位進程則更多,可達18EB。只有我們開始使用申請到的虛擬內存時,系統才會將虛擬地址映射到物理地址上,從而讓程序使用真實的物理內存。

2、內存分頁

系統會對虛擬內存和物理內存進行分頁,虛擬內存到物理內存的映射都是以頁為最小粒度的。在OSX和早期的iOS系統中,物理和虛擬內存都按照4KB的大小進行分頁。iOS近期的系統中,基于A7A8處理器的系統,物理內存按照4KB分頁,虛擬內存按照16KB分頁。基于A9處理器的系統,物理和虛擬內存都是以16KB進行分頁。(終端輸入PAGESIZE可以查看到macOS的分頁大小)。

系統將內存頁分為三種狀態。
  • 活躍內存頁(active pages)- 這種內存頁已經被映射到物理內存中,而且近期被訪問過,處于活躍狀態。
  • 非活躍內存頁(inactive pages)- 這種內存頁已經被映射到物理內存中,但是近期沒有被訪問過。
  • 可用的內存頁(free pages)- 沒有關聯到虛擬內存頁的物理內存頁集合。

當可用的內存頁降低到一定的閥值時,系統就會采取低內存應對措施,在OSX中,系統會將非活躍內存頁交換到硬盤上,而在iOS中,則會觸發Memory Warning,如果你的App沒有處理低內存警告并且還在后臺占用太多內存,則有可能被殺掉。

3、如何解決內存浪費的?

應用程序加載到內存中時,并不會全部加載到物理內存中,屬于懶加載,用哪一部分就加載那一部分。當訪問進程的內存地址時,首先看頁表,查看所要訪問的對應頁表是否已經加載到內存中。如果這一頁沒有在物理內存中時,操作系統會阻塞當前進程,發出一個缺頁異常/缺頁中斷(pagefault),讓后將磁盤中對應頁的數據加載到內存中,完成虛擬內存和物理內存的映射。
當前進程的頁表數據加載到物理內存中時,不一定是連續的,也有可能會覆蓋其他進程的不活躍頁,這樣的按需分配,極大提高內存的使用效率。

4、虛擬內存的安全問題

虛擬內存通過頁表映射到物理內存上,因此直接訪問物理地址并不能實際正確的拿到進程的數據,但是進程的虛擬內存地址相對于自己來說也是絕對的,不管程序運行多少次,如果訪問同一個函數,它在虛擬內存中的地址都是一樣的這樣也存在安全問題(比如直接靜態注入)。
這樣也出現了新的技術--ASLR(Address Space Layout Randomization)
每次虛擬內存在加載之前,都加一個隨機偏移值。

三、二進制重排原理

1、什么是二進制重排

缺頁中斷/缺頁異常:內存分頁管理,每一頁加載的時候都會發生。
在iOS中,在加載缺頁內存的時候,不僅發生缺頁阻塞從磁盤中加載數據,還要對加載的這頁做簽名驗證
App使用中不會發生大量的pagefault,我們一般感受不到這個過程。但是在啟動時,程序有大量的代碼需要加載、執行,那么這個缺頁中斷有可能就很明顯了。

如何優化?

假如我的App只有10頁數據,但是啟動的時候需要加載的代碼分散放在1、3、5頁。因為代碼在Mach-o文件中的位置是根據文件加載生成的順序來決定。那么這時候App啟動需要運行的代碼放在3個虛擬內存頁中就會出現3pagefault
如果我們將需要啟動用的代碼全部放在第1頁中,那么App啟動時便只會觸發一次pagefaultApp啟動加載的數據也會變少,這樣極大減少進程的阻塞。這就是二進制重排的原理。

2、查看pagefault

Xcode提供相關的調試工具,打開Instruments-System Trace,選中手機中的App,點擊System Trace左上角開始記錄后會自動打開手機中的App,進入首屏后點擊System Trace左上角停止。查看Main Thread中虛擬內存的File Backed Page In項目,它代表著啟動時產生的pagefault次數。

查看pagefault次數時受App冷啟動熱啟動影響很大,可以先開啟幾個其他App然后等一段時間再點擊System Trace左上角開啟記錄。

二進制重排的優化是發生在編譯鏈接階段,對即將生成的二進制可執行文件進行重排。
Xcode使用的連接器叫ld它可以指向一個order_file文件,在這個文件中指定排列符號,那么Xcode在編譯時會按照指定的排列編譯出可執行的文件,蘋果objc源碼項目中的libobjc.order文件就是實現二進制重排功能的。

四、實現二進制重排

1、查看方法排列順序

新建測試項目Test_TracingPCs
在項目的build settings中搜索link map開啟這個文件的輸出

重新編譯后就可以在工程的build目錄里面找到一份link map文件
路徑如下:
Xcode -> DerivedData-> 項目名-> Build-> Intermediates.noindex-> 項目名.build-> Debug-iphoneos-> 項目名.build-> 項目名-LinkMap-normal-arm64.txt

這個文件里面就記錄一些鏈接.o的文件、Mach-o文件里的一些信息、符號信息symbols等等…

注意,這個symbols就是關注的要點:默認情況下它是按照Build Phases-Compile Sources中編譯文件從上至下排序以及類中方法從上至下排序。

2、通過order文件重新排列加載順序:

在項目根目錄創建lcj.order文件,在工程配置中添加.order文件的路徑./lcj.order后,讓編譯器按照指定的順序重新排列二進制文件,把最需要加載的代碼段放在內存頁靠前的位置。

這里只是演示了讓viewcontroller中的幾個自定義方法優先靠排列在內存分頁中,實際中一個App啟動時的page fault可能多達幾千次,那么需要重排的函數遠不止這一點。

五、Clang插樁

1、引入Clang插樁

由于項目中存在大量的函數方法調用,此外還有Block、Swift、C、C++函數,因此僅僅HOOK msgSend方法不可行。因為Clang會讀取所有代碼,分析AST中所有節點,所有通過Clang插樁可以實現100%的符號覆蓋

抖音研發實踐:基于二進制文件重排的解決方案

官方插樁工具-Tracing PCs
Clang Documentation
Tracing PCs

2、使用Tracing PCs

根據官方文檔添加-fsanitize-coverage=trace-pc-guard標記

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

-[ViewController viewDidLoad]前添加斷點,運行項目,到斷點后打開匯編斷點(菜單欄Debug->Debug Workflow->Always Show Disassembly)

結合匯編中插入的__sanitizer_cov_trace_pc_guard代碼和控制臺打印的信息分析可知:添加-fsanitize-coverage=trace-pc-guard標記后Clang會在中間代碼IR中的每個方法、Block等調用邊緣插入__sanitizer_cov_trace_pc_guard方法的調用。
所以Clang插樁插入的就是__sanitizer_cov_trace_pc_guard方法調用。

3、修改__sanitizer_cov_trace_pc_guard方法,獲取函數的調用方法名
#import <dlfcn.h>
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    //排除load方法
    //if (!*guard) return;
    
    //當前函數返回到上一個方法繼續執行的地址
    void *PC = __builtin_return_address(0);
    Dl_info info;
    dladdr(PC, &info);
    printf("fname:%s \nfbase:%p \nsname:%s \nsaddr:%p\n\n\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);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
}

點擊屏幕輸出:

fname:/private/var/containers/Bundle/Application/BAE470B2.../Test_TracingPCs.app/Test_TracingPCs 
fbase:0x10236c000 
sname:-[ViewController touchesBegan:withEvent:] 
saddr:0x102371ad4
4、將獲取到的符號寫入到. order文件中
#import <libkern/OSAtomic.h>//用于定義原子隊列

//定義原子隊列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

//定義符號結構體
typedef struct{
    void *pc;
    void *next;
} SYNode;

+ (void)load {
    
}
- (void)viewDidLoad {
    [super viewDidLoad];
    
    testCFunc();
    [self testOCFunc];
}
- (void)testOCFunc {
    NSLog(@"OC函數");
}
void testCFunc() {
    CJBlock();
}
void(^CJBlock)(void) = ^(void) {
    NSLog(@"Block");
};

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    //排除load方法
    //if (!*guard) return;
    
    //當前函數返回到上一個方法繼續執行的地址
    void *PC = __builtin_return_address(0);
    //創建結構體!
    SYNode * node = malloc(sizeof(SYNode));
    *node = (SYNode){PC,NULL};
    
    //該方法在子線程中調用,因此需要使用線程安全的Atomic原子隊列
    //加入結構
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}


- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    //定義數組
    NSMutableArray<NSString *> *symbolNames = [NSMutableArray array];
    
    while (YES) {//一次循環!也會被HOOK一次!!(Tracing PCs只要有跳轉(匯編中b/bl指令)就會被HOOK)
        SYNode *node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
        
        if (node == NULL) {
            break;
        }
        Dl_info info = {0};
        dladdr(node->pc, &info);
        printf("%s \n",info.dli_sname);
        NSString *name = @(info.dli_sname);
        free(node);
        
        //C函數前需加 _
        BOOL isObjc = [name hasPrefix:@"+["]||[name hasPrefix:@"-["];
        NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
        
        if (![symbolNames containsObject:symbolName]) {
            [symbolNames addObject:symbolName];
        }
    }
    //反向數組
    symbolNames = (NSMutableArray<NSString *>*)[[symbolNames reverseObjectEnumerator] allObjects];
    //去掉當前方法
    [symbolNames removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
    
    //數組轉成字符串
    NSString *funcStr = [symbolNames componentsJoinedByString:@"\n"];
    //字符串寫入文件
    NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lcj.order"];
    //文件內容
    NSData *fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}

由于Tracing PCs只要有跳轉(匯編中b/bl指令)就會被HOOK,因此while也會被HOOK,為了避免循環調用需要修改Other C Flags為:-fsanitize-coverage=func,trace-pc-guard

運行后點擊屏幕拿到.order文件

六、其他問題

1、Swift 工程 / 混編工程問題

通過上面的方法可以拿到OC項目中的符號,想要拿到Swift中的符號還需要做以下配置:
-sanitize-coverage=func
-sanitize=undefined

2、cocoapod 工程問題

對于cocoapod工程引入的庫 , 由于針對不同的target。那么我們在主程序中的target添加的編譯設置Write Link Map File , -fsanitize-coverage=func,trace-pc-guard以及order file等設置肯定是不會生效的。解決方法就是針對需要的target去做對應的設置即可(配置target自己的Order File)。

對于直接手動導入到工程里的SDK , 不管是靜態庫.a還是動態庫, 默認在主工程設置就可以可以拿到符號的。

手動導入的三方庫如果沒有導入使用的話 , 是不會加載的,添加了load方法也是如此。


參考

iOS 優化篇 - 啟動優化之Clang插樁實現二進制重排

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

推薦閱讀更多精彩內容