The two most important days in your life are the day you are born and the day you find out why.-- Mark Twain
“你是誰?從哪裡來?到哪裡去?”,這三個富有哲學氣息的問題,是每一個人在不斷解答的問題。我們Code,Build,Run,一個活生生的App躍然方寸屏上,這一切是如何發生的?從用戶點擊App到執行main函數這短短的瞬間發生了多少事呢?探尋App的啟動新生,可以幫助我們更了解App開發本身。
下圖是App啟動流程的關鍵節點展示:
App啟動流程
下面我們就來一一解讀。
1. App文件的組成
在詳細研究啟動流程之前,首先我們需要了解下iOS/OSX的App執行文件。
一個應用,通常都是經過“編譯-》鏈接-》打包”幾個步驟之後,生成一個可在某平台上運行應用。應用文件在不同的平台上以不同的格式存在,如Windows上的exe,Android上的pkg,以及我們接下來要說的ipa。
iOS系統是由OS X發展而來,而OS X是由NeXTSTEP與Mac OS Classic的融合。因此iOS/OS X系統很多的特性都是源於NeXTSTEP系統,如Objective-C、Cocoa、Mach、XCode等,其中還有應用/庫的組成——Bundle。Bundle的官方解釋是a standardized hierarchical structure that holds executable code and the resources used by that code.,也就是包含執行代碼和相關資源的標准層次結構;可以簡單地理解為包(Package)。
OS X應用和iOS應用兩者的bundle結構有些許差別,OS X的應用程序的層次結構比較規范,而iOS的App則相對來說比較散亂,而且與OS不同的是,iOS只有Apple原生的應用才會在/Applications目錄下,從App Store上購買的應用會安裝在/var/mobile/Applications目錄下;OSX的應用不再本文討論范圍之內,所以我們先來看看iOS的App Bundle的層次結構:
其中xxx.app就是我們的app應用程序,主要包含了執行文件(xxx.app/xxx, xxx為應用名稱)、NIB和圖片等資源文件。接下來就主要看看本節的主角: Mach-O
1.1 Universal Binary
大部分情況下,xxx.app/xxx文件並不是Mach-O格式文件,由於現在需要支持不同CPU架構的iOS設備,所以我們編譯打包出來的執行文件是一個Universal Binary格式文件(通用二進制文件,也稱胖二進制文件),其實Universal Binary只不過將支持不同架構的Mach-O打包在一起,再在文件起始位置加上Fat Header來說明所包含的Mach-O文件支持的架構和偏移地址信息;
Fat Header的數據結構在
#define FAT_MAGIC 0xcafebabe #define FAT_CIGAM 0xbebafeca /* NXSwapLong(FAT_MAGIC) */ struct fat_header { uint32_t magic; /* FAT_MAGIC */ uint32_t nfat_arch; /* number of structs that follow */ }; struct fat_arch { cpu_type_t cputype; /* cpu specifier (int) */ cpu_subtype_t cpusubtype; /* machine specifier (int) */ uint32_t offset; /* file offset to this object file */ uint32_t size; /* size of this object file */ uint32_t align; /* alignment as a power of 2 */ };
結構struct fat_header:
1). magic字段就是我們常說的魔數(與UNIX的ELF文件一樣),加載器通過這個魔數值來判斷這是什麼樣的文件,胖二進制文件的魔數值是0xcafebabe;
2). nfat_arch字段是指當前的胖二進制文件包含了多少個不同架構的Mach-O文件;
fat_header後會跟著fat_arch,有多少個不同架構的Mach-O文件,就有多少個fat_arch,用於說明對應Mach-O文件大小、支持的CPU架構、偏移地址等;
可以用file命令來查看下執行文件的信息,如新浪微博:
ps:上述說“大部分情況”是因為還有一部分,由於業務比較復雜,代碼量巨大,如果支持多種CPU架構而打包多個Mach-O文件的話,會導致ipa包變得非常大,所以就並沒有支持新的CPU架構的。如QQ和微信:
ps:QQ V5.5.1版本單個Mach-O文件大小為51M
1.2 Mach-O
雖然iOS/OS X采用了類UNIX的Darwin操作系統核心,完全符合UNIX標准系統,但在執行文件上,卻沒有支持UNIX的ELF,而是維護了一個獨有的二進制可執行文件格式:Mach-Object(簡寫Mach-O)。Mach-O是NeXTSTEP的遺產,其文件格式如下:
由上圖,我們可以看到Mach-O文件主要包含一下三個數據區:
(1). 頭部Header:在
/* * The 32-bit mach header appears at the very beginning of the object file for * 32-bit architectures. */ struct mach_header { uint32_t magic; /* mach magic number identifier */ cpu_type_t cputype; /* cpu specifier */ cpu_subtype_t cpusubtype; /* machine specifier */ uint32_t filetype; /* type of file */ uint32_t ncmds; /* number of load commands */ uint32_t sizeofcmds; /* the size of all the load commands */ uint32_t flags; /* flags */ }; /* Constant for the magic field of the mach_header (32-bit architectures) */ #define MH_MAGIC 0xfeedface /* the mach magic number */ #define MH_CIGAM 0xcefaedfe /* NXSwapInt(MH_MAGIC) */
以上引用代碼是32位的文件頭數據結構,
(2). 加載命令 Load Commends:
在mach_header之後的是加載命令,這些加載命令在Mach-O文件加載解析時,被內核加載器或者動態鏈接器調用,指導如何設置加載對應的二進制數據段;Load Commend的數據結構如下:
struct load_command { uint32_t cmd; /* type of load command */ uint32_t cmdsize; /* total size of command in bytes */ };
OS X/iOS發展到今天,已經有40多條加載命令,其中部分是由內核加載器直接使用,而其他則是由動態鏈接器處理。其中幾個主要的Load Commend為LC_SEGMENT, LC_LOAD_DYLINKER, LC_UNIXTHREAD, LC_MAIN等,這裡不詳細介紹,在
ps:
otool是查看操作Mach-O文件的工具,類似於UNIX下的ldd或readelf工具。
MachOView是查看Mach-O文件的可視化工具。
(3). 原始段數據 Raw segment data
原始段數據,是Mach-O文件中最大的一部分,包含了Load Command中所需的數據以及在虛存地址偏移量和大小;一般Mach-O文件有多個段(Segement),段每個段有不同的功能,一般包括:
1). __PAGEZERO: 空指針陷阱段,映射到虛擬內存空間的第一頁,用於捕捉對NULL指針的引用;
2). __TEXT: 包含了執行代碼以及其他只讀數據。該段數據的保護級別為:VM_PROT_READ(讀)、VM_PROT_EXECUTE(執行),防止在內存中被修改;
3). __DATA: 包含了程序數據,該段可寫;
4). __OBJC: Objective-C運行時支持庫;
5). __LINKEDIT: 鏈接器使用的符號以及其他表
一般的段又會按不同的功能劃分為幾個區(section),標識段-區的表示方法為(SEGMENT.section),即段所有字母大小,加兩個下橫線作為前綴,而區則為小寫,同樣加兩個下橫線作為前綴;更多關於常見section的解析,請查看 https://developer.apple.com/library/mac/documentation/DeveloperTools/Conceptual/MachORuntime/
2. 內核Kernel
了解了App執行文件之後,我們從源碼來看看,App經過了什麼樣的內核調用流程之後,來到了主程序入口main()。
2.1 XNU開源代碼
雖然內核XNU是開源的,但只限於OS X, iOS的XNU內核一直是封閉的,但從歷史角度來說,iOS是OS X的分支,兩者比較大的區別就是支持的目標架構不一樣(iOS目標架構為ARM,而不是OS X的Intel i386和x86_64),內存管理以及系統安全限制;而執行文件都是Mach-O。所以,本文預設兩者在App啟動執行這方面並沒有太大差別。
本文參考的XNU版本為v2782.1.97;
2.2 內核調用流程
可執行文件的內核流程如下圖:
啟動進程的流程
引用自《Mac OS X and iOS Internals : To the Apple's Core》P555
上述流程對應到源代碼的調用樹為:
ps: 由於源代碼較多,篇幅所限,只引用關鍵性的代碼,並有簡單的注釋,本人注釋以oncenote為前綴.
// oncenote: /bsd/kern/ker_exec.c line: 2615 execve(proc_t p, struct execve_args *uap, int32_t *retval) { __mac_execve(proc_t p, struct __mac_execve_args *uap, int32_t *retval) {// oncenote: /bsd/kern/ker_exec.c line: 2654 // oncenote: /bsd/kern/ker_exec.c line: 2735 // 加載執行文件鏡像並設置環境 exec_activate_image(struct image_params *imgp) { // oncenote: /bsd/kern/kern_exec.c line: 1328 // 遍歷execsw執行格式,執行對應的ex_imgact函數 for(i = 0; error == -1 && execsw[i].ex_imgact != NULL; i++) { // 1.對於Mach-o Binary,執行exec_mach_imgact // 2.對於Fat Binary,執行exec_fat_imgact // 3.對於Interpreter Script,執行exec_shell_imgact // 由於只支持Mach-O這種執行格式,所以exec_fat_imgact和exec_shell_imgact最終都會調到exec_mach_imgact // 返回錯誤碼0,則表示mach file被正確加載處理;只有exec_mach_imgact會返回0 error = (*execsw[i].ex_imgact)(imgp); // oncenote: 對於Mach-o,執行(*execsw[i].ex_imgact)(imgp) = exec_mach_imgact(imgp) exec_mach_imgact(struct image_params *imgp) { // oncenote: /bsd/kern/kern_exec.c line: 893 load_machfile(struct image_params *imgp, ...) {// oncenote: /bsd/kern/mach_loader.c line: 287 // oncenote: oncenote: /bsd/kern/mach_loader.c line: 336 // 設置內存映射 if (create_map) { vm_map_create(); } // oncenote: /bsd/kern/mach_loader.c line: 373 // 設置地址空間布局隨機數 if (!(imgp->ip_flags & IMGPF_DISABLE_ASLR)) { aslr_offset = random(); } // oncenote: /bsd/kern/mach_loader.c line: 392 parse_machfile(struct vnode *vp, ..., load_result_t *result) { // oncenote: 遞歸深度解析mach file, 在2.3中詳細講解 } } // oncenote: /bsd/kern/kern_exec.c line: 973 if (load_result.unixproc) { /* Set the stack */ //oncenote thread_setuserstack(thread, ap); } // oncenote: /bsd/kern/kern_exec.c line: 1014 // 設置入口點(寄存器狀態來自LC_UNIXTHREAD) /* Set the entry point */ thread_setentrypoint(thread, load_result.entry_point); /* Stop profiling */ stopprofclock(p); /* * Reset signal state. */ execsigs(p, thread); ... } } } } }
由於篇幅所限,本文就不對源碼進行展開講解。通過上述的調用樹,App啟動在內核中的大概流程已非常清晰,如想更深入研究,請下載源代碼,並輔以文末參考資料,進行閱讀;
2.3 加載並解析Mach-O文件
前一節描述了可執行文件的執行流程,本節探討下,內核是如何加載解析Mach-O文件的。
函數load_machfile()加載Mach-O文件,然後調用函數parse_machfile()解析Mach-O文件。函數load_machfile()本身並沒有太復雜的邏輯,因此parse_machfile()函數是加載解析Mach-O文件的核心邏輯。在閱讀具體代碼觀察解析流程之前,先明確下parse_machfile()三個特別的邏輯:
首先,parse_machfile()是遞歸解析的,最初的遞歸深度為0,最高深度到6,防止無限遞歸。使用遞歸解析,主要是將不同Mach-O文件類型按照依賴關系,分前後進行解析。如解析可執行二進制文件類型(MH_EXECUTABLE)的Mach-O文件需要調用load_dylinker來處理加載命令LC_LOAD_DYLINKER,而動態鏈接器也是Mach-O文件,所以就需要遞歸到不同的深度進行解析;
其次,parse_machfile()的每一次遞歸,在解析加載命令時,會將內核需要解析的加載命令按照加載循序劃分為三組進行解析,在代碼的體現上就是通過三次循環,每趟循環只關注當前趟需要解析的命令: (1):解析線程狀態,UUID和代碼簽名。相關命令為LC_UNIXTHREAD、LC_MAIN、LC_UUID、LC_CODE_SIGNATURE (2):解析代碼段Segment。相關命令為LC_SEGMENT、LC_SEGMENT_64; (3):解析動態鏈接庫、加密信息。相關命令為:LC_ENCRYPTION_INFO、LC_ENCRYPTION_INFO_64、LC_LOAD_DYLINKER
最後,關於Mach-O的入口點。解析完可執行二進制文件類型的Mach-O文件(假設為A)之後,我們會得到A的入口點;但線程並不立刻進入到這個入口點。這是由於我們還會加載動態鏈接器(dyld),在load_dylinker()中,dyld會保存A的入口點,遞歸調用parse_machfile()之後,將線程的入口點設為dyld的入口點;動態鏈接器dyld完成加載庫的工作之後,再將入口點設回A的入口點,程序啟動完成;
理解了上述邏輯之後,我們通過源代碼最直觀地探索解析流程:
// oncenote: oncenote: /bsd/kern/mach_loader.c line: 483 static load_return_t parse_machfile( struct vnode *vp, vm_map_t map, thread_t thread, struct mach_header *header, off_t file_offset, off_t macho_size, int depth, int64_t aslr_offset, int64_t dyld_aslr_offset, load_result_t *result ) { /* * Break infinite recursion */ //oncenote: 最大深度6的控制 if (depth > 6) { return(LOAD_FAILURE); } depth++; //oncenote: 不同的深度解析不同的Mach-o文件類型, //如可執行二進制文件類型MH_EXECUTE,只在第一次深度,因此不存在MH_EXECUTE依賴MH_EXECUTE的情況 switch (header->filetype) { case MH_OBJECT: case MH_EXECUTE: case MH_PRELOAD: if (depth != 1) { return (LOAD_FAILURE); } break; case MH_FVMLIB: case MH_DYLIB: if (depth == 1) { return (LOAD_FAILURE); } break; case MH_DYLINKER: if (depth != 2) { return (LOAD_FAILURE); } break; default: return (LOAD_FAILURE); } // ... //oncenote: 將所有的加載命令都映射到內核內存中,准備解析 /* * Map the load commands into kernel memory. */ addr = 0; kl_size = size; kl_addr = kalloc(size); addr = (caddr_t)kl_addr; if (addr == NULL) return(LOAD_NOSPACE); error = vn_rdwr(UIO_READ, vp, addr, size, file_offset, UIO_SYSSPACE, 0, kauth_cred_get(), &resid, p); // ... //nocenote: 開始解析加載命令(Load Command),分三趟進行解析 /* * Scan through the commands, processing each one as necessary. * We parse in three passes through the headers: * 1: thread state, uuid, code signature * 2: segments * 3: dyld, encryption, check entry point */ for (pass = 1; pass validentry == 0)) { thread_state_initialize(thread); ret = LOAD_FAILURE; break; } /* * Loop through each of the load_commands indicated by the * Mach-O header; if an absurd value is provided, we just * run off the end of the reserved section by incrementing * the offset too far, so we are implicitly fail-safe. */ offset = mach_header_sz; ncmds = header->ncmds; while (ncmds--) { /* * Get a pointer to the command. */ lcp = (struct load_command *)(addr + offset); oldoffset = offset; offset += lcp->cmdsize; switch(lcp->cmd) { case LC_SEGMENT: if (pass != 2) //oncenote: 第二趟進行解析 break; ret = load_segment(lcp, header->filetype, control, file_offset, macho_size, vp, map, slide, result); break; case LC_SEGMENT_64: //oncenote: 與命令LC_SEGMENT相同 break; case LC_UNIXTHREAD: if (pass != 1) break; //oncenote: load_unixthread() 依次調用load_threadstack()、load_threadentry()和load_threadstate() //oncenote: 啟動一個unix線程,加載線程的初始化狀態,並載入入口點 ret = load_unixthread((struct thread_command *) lcp, thread, slide, result); break; case LC_MAIN: if (pass != 1) break; if (depth != 1) break; //oncenote: 代替LC_UNIXTHREAD,與LC_UNIXTHREAD類似 ret = load_main((struct entry_point_command *) lcp, thread, slide, result); break; case LC_LOAD_DYLINKER: if (pass != 3) break; //在第一次深度的遞歸調用,解析到LC_LOAD_DYLINKER,設置dlp,用於後續加載動態鏈接庫 if ((depth == 1) && (dlp == 0)) { dlp = (struct dylinker_command *)lcp; dlarchbits = (header->cputype & CPU_ARCH_MASK); } else { ret = LOAD_FAILURE; } break; case LC_UUID: //oncenote: 省略 break; case LC_CODE_SIGNATURE: //oncenote: 省略 break; #if CONFIG_CODE_DECRYPTION case LC_ENCRYPTION_INFO: //oncenote: 省略 case LC_ENCRYPTION_INFO_64: break; #endif default: //內核不處理其他命令,其他命令交由動態鏈接器dyld來處理 /* Other commands are ignored by the kernel */ ret = LOAD_SUCCESS; break; } if (ret != LOAD_SUCCESS) break; } if (ret != LOAD_SUCCESS) break; } //oncenote: 前面解析命令操作成功,加載動態鏈接器 if (ret == LOAD_SUCCESS) { if ((ret == LOAD_SUCCESS) && (dlp != 0)) { /* * load the dylinker, and slide it by the independent DYLD ASLR * offset regardless of the PIE-ness of the main binary. */ ret = load_dylinker(dlp, dlarchbits, map, thread, depth, dyld_aslr_offset, result); } } // ... return(ret); }
再來看load_dylinker()的代碼:
static load_return_t load_dylinker( struct dylinker_command *lcp, integer_t archbits, vm_map_t map, thread_t thread, int depth, int64_t slide, load_result_t *result ) { //oncenote: 獲取dyld vnode ret = get_macho_vnode(name, archbits, header, &file_offset, &macho_size, macho_data, &vp); if (ret) goto novp_out; *myresult = load_result_null; /* * First try to map dyld in directly. This should work most of * the time since there shouldn't normally be something already * mapped to its address. */ //oncenote: 遞歸調用parse_machfile()解析dyld ret = parse_machfile(vp, map, thread, header, file_offset, macho_size, depth, slide, 0, myresult); // ... if (ret == LOAD_SUCCESS) { //oncenote: 解析成功,設置線程入口為dyld的入口,dyld開始加載共享庫 result->dynlinker = TRUE; result->entry_point = myresult->entry_point; result->validentry = myresult->validentry; result->all_image_info_addr = myresult->all_image_info_addr; result->all_image_info_size = myresult->all_image_info_size; if (myresult->platform_binary) { result->csflags |= CS_DYLD_PLATFORM; } } // ... return (ret); }
3. 總結
之前對App流程有個大體的概念,但於細節並不甚清楚,耗時1個多月,邊學邊復習邊寫文章,終於在出行旅游前完成。原計劃是准備在第三段講解下動態鏈接器dyld加載共享庫的流程的,但限於本文篇幅實在太長,所以新起一篇文章來寫會好一點。
關於App啟動流程還有許多細節,如代碼簽名驗證、虛存映射、iOS的觸屏應用加載器SpringBoard如何進行切換應用等,本文並未涉及到,有興趣的同學可以繼續深入研究。
參考資料:
《Mac OS X Internals: A Systems Approach》
《Mac OS X and iOS Internals : To the Apple's Core》
XNU源代碼
The App Launch Sequence on iOS
Mach-O Programming Topics
DYLD Detailed