問題
1:(虛擬內存、ASLR)(PE、ELF、Mach-O)
2:(Header、Load Commands 、segment)
3:Header(cputype、filetype)
4:Load Commands(動態鏈接器的位置、程序的入口、依賴庫的信息、代碼的位置、符號表的位置)
5:(main函數前、main函數后)重簽名 、ASLR、
6:(PageFault 、System Trace、order文件)
7:-fsanitize-coverage=func,trace-pc-guard Dl_info
目錄
1:基本概念
2:啟動優化
3:二進制重排
預備
正文
一:基本概念
1:虛擬內存 & 物理內存
早期的數據訪問是直接通過物理地址訪問
的,這種方式有以下兩個問題:
-
1、內存不夠用
-
2、內存數據的安全問題
內存不夠用的方案:虛擬內存
針對問題1,我們在進程和物理內存之間增加一個中間層
,這個中間層就是所謂的虛擬內存
,主要用於解決當多個進程同時存在時,對物理內存的管理。提高了CPU的利用率,使多個進程可以同時、按需加載
。所以虛擬內存其本質就是一張虛擬地址和物理地址對應關系的映射表
-
每個進程都有一個獨立的
虛擬內存
,其地址都是從0開始
,大小是4G固定的,每個虛擬內存又會划分為一個一個的頁
(頁的大小在iOS中是16K,其他的是4K
),每次加載都是以頁為單位加載的,進程間是無法互相訪問的,保證了進程間數據的安全性。 -
一個進程中,只有部分功能是活躍的,所以只需要
將進程中活躍的部分放入物理內存
,避免物理內存的浪費 -
當CPU需要訪問數據時,首先是訪問虛擬內存,然后通過虛擬內存去尋址,即可以理解為在表中找對應的物理地址,然后對相應的物理地址進行訪問
-
如果在訪問時,虛擬地址的內容未加載到物理內存,會發生
缺頁異常(pagefault)
,將當前進程阻塞掉,此時需要先將數據載入到物理內存,然后再尋址,進行讀取。這樣就避免了內存浪費
如下圖所示,虛擬內存與物理內存間的關系
內存數據的安全問題:ASLR技術
在上面解釋的虛擬內存中,我們提到了虛擬內存的起始地址與大小都是固定的,這意味着,當我們訪問時,其數據的地址也是固定的,這會導致我們的數據非常容易被破解,為了解決這個問題,所以蘋果為了解決這個問題,在iOS4.3開始引入了ASLR
技術。
ASLR的概念:(Address Space Layout Randomization ) 地址空間配置隨機加載
,是一種針對緩沖區溢出
的安全保護技術
,通過對堆、棧、共享庫映射等線性區布局的隨機化,通過增加攻擊者預測目的地址的難度,防止攻擊者直接定位攻擊代碼位置,達到阻止溢出攻擊的目的的一種技術。
其目的的通過利用隨機方式配置數據地址空間
,使某些敏感數據(例如APP登錄注冊、支付相關代碼)配置到一個惡意程序無法事先獲知的地址,令攻擊者難以進行攻擊。
由於ASLR的存在,導致可執行文件和動態鏈接庫在虛擬內存中的加載地址每次啟動都不固定
,所以需要在編譯時來修復鏡像中的資源指針,來指向正確的地址。即正確的內存地址 = ASLR地址 + 偏移值
2:可執行文件
不同的操作系統,其可執行文件的格式也不同。系統內核將可執行文件讀取到內存,然后根據可執行文件的頭簽名(magic
魔數)判斷二進制文件的格式
PE、ELF、Mach-O
這三種可執行文件格式都是
COFF
(Command file format)格式的變種,COFF的主要貢獻是目標文件里面
引入了“段”的機制
,不同的目標文件可以擁有不同數量和不同類型的“段”。
2.1:通用二進制文件
因為不同CPU平台支持的指令不同,比如arm64
和x86
,蘋果中的通用二進制格式就是將多種架構的Mach-O文件打包在一起
,然后系統根據自己的CPU平台,選擇合適的Mach-O,所以通用二進制格式
也被稱為胖二進制格式
,如下圖所示

<mach-o/fat.h>中
,可以在
下載xnu,然后根據
xnu -> EXTERNAL_HEADERS ->mach-o
中找到該文件,通用二進制文件開始的
Fat Header
是
fat_header
結構體,而Fat Archs是表示通用二進制文件中有多少個Mach-O,單個Mach-O的描述是通過
fat_arch
結構體。兩個結構體的定義如下:
/* - magic:可以讓系統內核讀取該文件時知道是通用二進制文件 - nfat_arch:表明下面有多個fat_arch結構體,即通用二進制文件包含多少個Mach-O */ struct fat_header { uint32_t magic; /* FAT_MAGIC */ uint32_t nfat_arch; /* number of structs that follow */ }; /* fat_arch是描述Mach-O - cputype 和 cpusubtype:說明Mach-O適用的平台 - offset(偏移)、size(大小)、align(頁對齊)描述了Mach-O二進制位於通用二進制文件的位置 */ 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 */ };
所以,綜上所述,
-
通用二進制文件是蘋果公司提出的一種新的二進制文件的存儲結構,可以
同時存儲多種架構的二進制指令
,使CPU在讀取該二進制文件時可以自動檢測並選用合適的架構,以最理想的方式進行讀取 -
由於通用二進制文件會同時存儲多種架構,所以比單一架構的二進制文件大很多,會占用大量的磁盤空間,但由於系統會自動選擇最合適的,不相關的架構代碼不會占用內存空間,且
執行效率高
了 -
還可以通過指令來進行Mach-O的合並與拆分
-
查看當前Mach-O的架構:
lipo -info MachO文件
-
合並:
lipo -create MachO1 MachO2 -output 輸出文件路徑
-
拆分:
lipo MachO文件 –thin 架構 –output 輸出文件路徑
-
2.2:Mach-O文件
Mach-O
文件是Mach Object
文件格式的縮寫,它是用於可執行文件、動態庫、目標代碼的文件格式。作為a.out
格式的替代,Mach-O
格式提供了更強的擴展性,以及更快的符號表信息訪問速度
熟悉Mach-O文件格式,有助於更好的理解蘋果底層的運行機制,更好的掌握dyld加載Mach-O的步驟。
查看Mach-O文件
如果想要查看具體的Mach-O文件信息,可以通過以下兩種方式,推薦使用第二種方式,更直觀
【方式一】otool終端命令:otool -l Mach-O文件名
【方式二】 MachOView
工具(推薦):將Mach-O可執行文件拖動到MachOView
工具打開

2.3:Mach-O文件格式
對於OS X 和iOS來說,Mach-O是其可執行文件的格式,主要包括以下幾種文件類型
Executable
:可執行文件Dylib
:動態鏈接庫Bundle
:無法被鏈接的動態庫,只能在運行時使用dlopen加載Image
:指的是Executable、Dylib和Bundle的一種Framework
:包含Dylib、資源文件和頭文件的集合
下面圖示是Mach-O 鏡像文件格式
Mach-O
文件主要分為三大部分:
-
Header Mach-O頭部
:主要是Mach-O的cpu架構,文件類型以及加載命令等信息 -
Load Commands 加載命令
:描述了文件中數據的具體組織結構,不同的數據類型使用不同的加載命令表示 -
Data 數據
:數據中的每個段(segment)的數據都保存在這里,段的概念與ELF文件中段的概念類似。每個段都有一個或多個部分,它們放置了具體的數據與代碼,主要包含代碼,數據,例如符號表,動態符號表等等
Mach-O的Header
包含了整個Mach-O文件的關鍵信息
,使得CPU能快速知道Mac-O的基本信息,其在Mach.h
(路徑同前文的fat.h一致)針對32
位和64
位架構的cpu,分別使用了mach_header
和mach_header_64
結構體來描述Mach-O頭部
。mach_header
是連接器加載時最先讀取的內容,決定了一些基礎架構、系統類型、指令條數等信息,這里查看64位架構的mach_header_64
結構體定義,相比於32
位架構的mach_header
,只是多了一個reserved
保留字段,
/* - magic:0xfeedface(32位) 0xfeedfacf(64位),系統內核用來判斷是否是mach-o格式 - cputype:CPU類型,比如ARM - cpusubtype:CPU的具體類型,例如arm64、armv7 - filetype:由於可執行文件、目標文件、靜態庫和動態庫等都是mach-o格式,所以需要filetype來說明mach-o文件是屬於哪種文件 - ncmds:sizeofcmds:LoadCommands加載命令的條數(加載命令緊跟header之后) - sizeofcmds:LoadCommands加載命令的大小 - flags:標志位標識二進制文件支持的功能,主要是和系統加載、鏈接有關 - reserved:保留字段 */ struct mach_header_64 { 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 */ uint32_t reserved; /* reserved */ };
其中filetype
主要記錄Mach-O的文件類型,常用的有以下幾種
#define MH_OBJECT 0x1 /* 目標文件*/ #define MH_EXECUTE 0x2 /* 可執行文件*/ #define MH_DYLIB 0x6 /* 動態庫*/ #define MH_DYLINKER 0x7 /* 動態鏈接器*/ #define MH_DSYM 0xa /* 存儲二進制文件符號信息,用於debug分析*/
相對應的,Header在MachOView
中的展示如下

2.3.2:Load Commands
在Mach-O文件中,Load Commands
主要是用於加載指令
,其大小和數目在Header中已經被提供,其在Mach.h
中的定義如下
/* load_command用於加載指令 - cmd 加載命令的類型 - cmdsize 加載命令的大小 */ struct load_command { uint32_t cmd; /* type of load command */ uint32_t cmdsize; /* total size of command in bytes */ };
我們在MachOView
中查看Load Commands,其中記錄了很多信息,例如動態鏈接器的位置、程序的入口、依賴庫的信息、代碼的位置、符號表的位置
等等,如下所示
其中LC_SEGMENT_64
的類型segment_command_64
定義如下
/* segment_command 段加載命令 - cmd:表示加載命令類型, - cmdsize:表示加載命令大小(還包括了緊跟其后的nsects個section的大小) - segname:16個字節的段名字 - vmaddr:段的虛擬內存起始地址 - vmsize:段的虛擬內存大小 - fileoff:段在文件中的偏移量 - filesize:段在文件中的大小 - maxprot:段頁面所需要的最高內存保護(4 = r,2 = w,1 = x) - initprot:段頁面初始的內存保護 - nsects:段中section數量 - flags:其他雜項標志位 - 從fileoff(偏移)處,取filesize字節的二進制數據,放到內存的vmaddr處的vmsize字節。(fileoff處到filesize字節的二進制數據,就是“段”) - 每一個段的權限相同(或者說,編譯時候,編譯器把相同權限的數據放在一起,成為段),其權限根據initprot初始化。initprot指定了如何通過讀/寫/執行位初始化頁面的保護級別 - 段的保護設置可以動態改變,但是不能超過maxprot中指定的值(在iOS中,+x和+w是互斥的) */ struct segment_command_64 { /* for 64-bit architectures */ uint32_t cmd; /* LC_SEGMENT_64 */ uint32_t cmdsize; /* includes sizeof section_64 structs */ char segname[16]; /* segment name */ uint64_t vmaddr; /* memory address of this segment */ uint64_t vmsize; /* memory size of this segment */ uint64_t fileoff; /* file offset of this segment */ uint64_t filesize; /* amount to map from the file */ vm_prot_t maxprot; /* maximum VM protection */ vm_prot_t initprot; /* initial VM protection */ uint32_t nsects; /* number of sections in segment */ uint32_t flags; /* flags */ };
2.3.3:Data
Load Commands后就是Data
區域,這個區域存儲了具體的只讀、可讀寫代碼
,例如方法、符號表、字符表、代碼數據、連接器所需的數據(重定向、符號綁定等)。主要是存儲具體的數據。其中大多數的Mach-O文件均包含以下三個段:
__TEXT 代碼段
:只讀,包括函數,和只讀的字符串__DATA 數據段
:讀寫,包括可讀寫的全局變量等__LINKEDIT
: __LINKEDIT包含了方法和變量的元數據(位置,偏移量),以及代碼簽名等信息。
在Data
區中,Section
占了很大的比例,Section
在Mach.h
中是以結構體section_64
(在arm64架構下)表示,其定義如下
/* Section節在MachO中集中體現在TEXT和DATA兩段里. - sectname:當前section的名稱 - segname:section所在的segment名稱 - addr:內存中起始位置 - size:section大小 - offset:section的文件偏移 - align:字節大小對齊 - reloff:重定位入口的文件偏移 - nreloc:重定位入口數量 - flags:標志,section的類型和屬性 - reserved1:保留(用於偏移量或索引) - reserved2:保留(用於count或sizeof) - reserved3:保留 */ struct section_64 { /* for 64-bit architectures */ char sectname[16]; /* name of this section */ char segname[16]; /* segment this section goes in */ uint64_t addr; /* memory address of this section */ uint64_t size; /* size in bytes of this section */ uint32_t offset; /* file offset of this section */ uint32_t align; /* section alignment (power of 2) */ uint32_t reloff; /* file offset of relocation entries */ uint32_t nreloc; /* number of relocation entries */ uint32_t flags; /* flags (section type and attributes)*/ uint32_t reserved1; /* reserved (for offset or index) */ uint32_t reserved2; /* reserved (for count or sizeof) */ uint32_t reserved3; /* reserved */ };
Section
在MachOView
中可以看出,主要集中體現在TEXT
和DATA
兩段里,如下所示

section - __TEXT | 說明 |
---|---|
__TEXT.__text |
主程序代碼 |
__TEXT.__cstring |
C語言字符串 |
__TEXT.__const |
const 關鍵字修飾的常量 |
__TEXT.__stubs |
用於 Stub 的占位代碼,很多地方稱之為樁代碼 |
__TEXT.__stubs_helper |
當 Stub 無法找到真正的符號地址后的最終指向 |
__TEXT.__objc_methname |
Objective-C 方法名稱 |
__TEXT.__objc_methtype |
Objective-C 方法類型 |
__TEXT.__objc_classname |
Objective-C 類名稱 |
section - __DATA | 說明 |
---|---|
__DATA.__data |
初始化過的可變數據 |
__DATA.__la_symbol_ptr |
lazy binding 的指針表,表中的指針一開始都指向 __stub_helper |
__DATA.nl_symbol_ptr |
非 lazy binding 的指針表,每個表項中的指針都指向一個在裝載過程中,被動態鏈機器搜索完成的符號 |
__DATA.__const |
沒有初始化過的常量 |
__DATA.__cfstring |
程序中使用的 Core Foundation 字符串(CFStringRefs) |
__DATA.__bss |
BSS,存放為初始化的全局變量,即常說的靜態內存分配 |
__DATA.__common |
沒有初始化過的符號聲明 |
__DATA.__objc_classlist |
Objective-C 類列表 |
__DATA.__objc_protolist |
Objective-C 原型 |
__DATA.__objc_imginfo |
Objective-C 鏡像信息 |
__DATA.__objc_selfrefs |
Objective-C self 引用 |
__DATA.__objc_protorefs |
Objective-C 原型引用 |
__DATA.__objc_superrefs |
Objective-C 超類引用 |
二:啟動優化
1. 冷啟動和熱啟動
首次啟動
應用、kill
應用后重新打開
應用、應用置於后台
隔一段時間再返回前台
等情況,都是應用
的啟動
。
有時啟動
很快
,有時啟動
很慢
。這是冷啟動
和熱啟動
的原因:
冷啟動是指內存中不包含該應用程序相關的數據,必須要從磁盤載入到內存中的啟動過程。
注意:重新打開 APP, 不一定就是冷啟動。
- 當內存不足,APP被系統自動殺死后,再啟動就是冷啟動。
- 如果在重新打開 APP 之前,APP 的相關數據還存儲在內存中,這時再打開 APP,就是熱啟動
- 冷啟動與熱啟動是由系統決定的,我們無法決定。
- 當然設備重啟以后,第一次打開 APP 的過程,一定是冷啟動。
2. 啟動性能檢測和分析
測試APP啟動,分為兩個階段:
- main函數前:
dyld
負責的啟動流程
系統處理,我們從
dyld應用加載
的流程來優化
。(借助系統工具
分析耗時)
- main函數后:
開發者
自己的業務代碼
通過
檢測業務流程
來優化
(main函數
打個時間點
、第一個頁面
渲染完成打個時間點
。測算耗時)
2.1 main函數前
Edit Scheme -> Run -> Arguments ->Environment Variables
點擊+添加環境變量
DYLD_PRINT_STATISTICS
設為
1
),然后運行,以下是iPhone7p正常啟動的pre-main時間(以WeChat為例)

1:新建APP
文件夾,放入砸殼后
的包、
2:加入appSign.sh
重簽名腳本:
# ${SRCROOT} 它是工程文件所在的目錄 TEMP_PATH="${SRCROOT}/Temp" #資源文件夾,我們提前在工程目錄下新建一個APP文件夾,里面放ipa包 ASSETS_PATH="${SRCROOT}/APP" #目標ipa包路徑 TARGET_IPA_PATH="${ASSETS_PATH}/*.ipa" #清空Temp文件夾 rm -rf "${SRCROOT}/Temp" mkdir -p "${SRCROOT}/Temp" #---------------------------------------- # 1. 解壓IPA到Temp下 unzip -oqq "$TARGET_IPA_PATH" -d "$TEMP_PATH" # 拿到解壓的臨時的APP的路徑 TEMP_APP_PATH=$(set -- "$TEMP_PATH/Payload/"*.app;echo "$1") # echo "路徑是:$TEMP_APP_PATH" #---------------------------------------- # 2. 將解壓出來的.app拷貝進入工程下 # BUILT_PRODUCTS_DIR 工程生成的APP包的路徑 # TARGET_NAME target名稱 TARGET_APP_PATH="$BUILT_PRODUCTS_DIR/$TARGET_NAME.app" echo "app路徑:$TARGET_APP_PATH" rm -rf "$TARGET_APP_PATH" mkdir -p "$TARGET_APP_PATH" cp -rf "$TEMP_APP_PATH/" "$TARGET_APP_PATH" #---------------------------------------- # 3. 刪除extension和WatchAPP.個人證書沒法簽名Extention rm -rf "$TARGET_APP_PATH/PlugIns" rm -rf "$TARGET_APP_PATH/Watch" #---------------------------------------- # 4. 更新info.plist文件 CFBundleIdentifier # 設置:"Set : KEY Value" "目標文件路徑" /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier >$PRODUCT_BUNDLE_IDENTIFIER" "$TARGET_APP_PATH/Info.plist" #---------------------------------------- # 5. 給MachO文件上執行權限 # 拿到MachO文件的路徑 APP_BINARY=`plutil -convert xml1 -o - $TARGET_APP_PATH/Info.plist|grep -A1 Exec|tail -n1|cut -f2 -d\>|cut -f1 -d\<` #上可執行權限 chmod +x "$TARGET_APP_PATH/$APP_BINARY" #---------------------------------------- # 6. 重簽名第三方 FrameWorks TARGET_APP_FRAMEWORKS_PATH="$TARGET_APP_PATH/Frameworks" if [ -d "$TARGET_APP_FRAMEWORKS_PATH" ]; then for FRAMEWORK in "$TARGET_APP_FRAMEWORKS_PATH/"* do #簽名 /usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$FRAMEWORK" done fi #注入 #yololib "$TARGET_APP_PATH/$APP_BINARY" >"Frameworks/HankHook.framework/HankHook"
3:Demo
工程添加
腳本指令./appSign.sh
真機運行
后,可看到:
Total pre-main time: 1.2 seconds (100.0%) dylib loading time: 326.38 milliseconds (25.4%) rebase/binding time: 146.54 milliseconds (11.4%) ObjC setup time: 40.49 milliseconds (3.1%) initializer time: 767.04 milliseconds (59.9%) slowest intializers : libSystem.B.dylib : 6.86 milliseconds (0.5%) libMainThreadChecker.dylib : 38.26 milliseconds (2.9%) libglInterpose.dylib : 447.73 milliseconds (34.9%) marsbridgenetwork : 48.86 milliseconds (3.8%) mars : 30.85 milliseconds (2.4%) 砸殼應用 : 212.00 milliseconds (16.5%)
2.2 分析DYLD耗時元素:
Total pre-main time: main函數前
的總耗時
dylib loading time
: dylib庫
的加載耗時
(官方建議,動態庫不超過6個
)
此應用的Frameworks
:

rebase/binding time
:
重定向
和
綁定
操作的
耗時
MachO
中
image鏡像
到
內存中
)
[binding綁定]:
MachO
中每個文件
使用其他庫
的
符號
時,綁定
庫名
和
地址
安全
考慮,
編譯時
和
運行時
地址不一樣。使用了
ASLR
(Address space layout randomization)
地址空間配置隨機加載
,每次
載入內存
后,需要將
原地址
加上
ASLR隨機偏移值
來進行
內存讀取
。 具體原因,
下面
分析
虛擬內存
與
物理內存
時,就
清楚
了
ObjC setup time
: OC類
的注冊耗時
(OC類越多,越耗時)
swift
沒有OC類
,所以在這一步有優越性
。
initializer time
:初始化耗時(load非懶加載類和c++構造函數的耗時)
slowest intializers: 最慢
的啟動對象
:
libSystem.B.dylib
: 系統庫libMainThreadChecker.dylib
: 系統庫libglInterpose.dylib
: 系統庫(調試使用的,不影響)砸殼應用
:自己的APP耗時
2.2 main函數后
main函數階段的優化建議主要有以下幾點:
在main函數之后的didFinishLaunching
方法中,主要是執行了各種業務,有很多並不是必須在這里立即執行的,這種業務我們可以采取延遲加載,防止影響啟動時間。
1:業務層面:
-
減少啟動初始化的流程
,能懶加載的懶加載,能延遲的延遲,能放后台初始化的放后台,盡量不要占用主線程的啟動時間 -
優化代碼邏輯,
去除非必須的代碼邏輯
,減少每個流程的消耗時間 -
啟動階段能
使用多線程
來初始化的,就使用多線程 -
盡量
使用純代碼
來進行UI框架的搭建,尤其是主UI框架,例如UITabBarController。盡量避免使用Xib或者SB,相比純代碼而言,這種更耗時 -
刪除廢棄類、方法
2:技術層面
- 1.
二進制重排
(重排的是編譯階段
的文件順序
,減少
啟動時刻,硬盤
到內存
的操
作次數
)
三:二進制重排
導致Page Fault次數過多的根本原因是啟動時刻需要調用的方法,處於不同的Page導致的
。因此,我們的優化思路就是:
將所有啟動時刻需要調用的方法,排列在一起,即放在一個頁中,這樣就從多個Page Fault變成了一個Page Fault
。這就是二進制重排的
核心原理
1:二進制重排原理
- 應用
啟動前
,頁表
是空
的,每一頁
都是PageFault
(頁缺省),啟動時用到的
每一頁都需要
cpu從硬盤讀取
到物理內存
中,雖然加載一頁
的耗時沒什么感覺
。但如果同時
加載幾百頁
,這個耗時就得考慮了。
本節我們研究的就是APP啟動優化
,所以這里也是一個優化點
。
- 優化核心:
減少
在啟動時
需要加載
的頁數
iOS
中每一頁
是16K
大小,但是16K中
,可能真正
在啟動時刻需要
用到的,可能不到1K
。但
是啟動需要
訪問到這1K
數據,不得不
把整頁
都加載
。- 我們的
二進制重排
,就是為了把
啟動用到的
這些數據
,整合
到一起,然后再
進行內存分頁
。這樣啟動用到的
數據都在前幾頁
中了。啟動時
,只需
要加載幾頁數據
就可以了。
1.1 二進制重排
中的二進制
二進制: 只有0
和1
的兩個數的數制
。是機器識別
的進制
。
-
此處
的二進制
,主要是指
我們代碼文件
中的函數
,編譯后
變成的機器識別符號
,再轉換
的二進制
文件。 -
所以二進制重排,
重排
的是代碼文件
和函數
的順序
。只加載用到的數據。用到的頁。
1.2 二進制數據順序
創建個Demo
項目,加入測試代碼:
#import "ViewController.h" @interface ViewController () @end @implementation ViewController void test1() { printf("1"); } void test2() { printf("2"); } - (void)viewDidLoad { [super viewDidLoad]; printf("viewDidLoad"); test1(); } +(void)load { printf("load"); test2(); } @end
在Build Settings
中搜索link Map
,設置Write Link Map File
為YES
:

Command + B
編譯后,
右鍵 Show In Finder
打開
包文件夾
:
沿路徑
找到並打開Demo-LinkMap-normal-x86_64.txt
文件:
函數順序:(書寫順序)
文件順序:(加入順序)
在Build Setting -> Write Link Map File
設置為YES
link map
文件,如下所示,可以發現 類中
函數的加載順序是從上到下
的,而
文件
的順序是根據
Build Phases -> Compile Sources
中的順序加載的

總結
- 二進制的排列順序:先
文件
按照加載順序
排列,文件內部
按照函數
書寫順序從上到下
排列
我們要做的,就是把啟動
會用到
的函數
排列在一起
2.PageFault檢測
1:連接真機
,運行自己項目
,打開Instruments
檢測工具:

System Trace
:

3:選擇真機
,選擇自己的項目
,點擊
第一個按鈕運行
,等APP啟動后
,點擊
第一個按鈕停止
。
4:選擇
自己項目
,選中主線程
,選擇虛擬內存
,查看File Backed Page In
(就是PageFault缺省頁):
可以看到這里啟動
加載了1783頁
,總耗時278毫秒
,平均耗時156微秒
。
(多試幾次
,可能
物理內存中存在
已有數據
,加載頁數
會少一些
。完全冷啟動
的話,加載頁數
應該會更多
,耗時更明顯)
3.體驗二進制重排
二進制重排,關鍵是order
文件
前面講objc源碼時,會在工程中看到order
文件:
打開.order
文件,可以看到內部都是排序好
的函數符號
。
這是因為蘋果
自己的庫
,也
都進行了二進制重排
。
- 我們打開創建的
Demo
項目,我想把排序改成load
->test1
->ViewDidAppear
->main
。
1:在Demo
項目根目錄
創建一個.order文件
2:在ht.order
文件中手動
順序寫入函數
(還寫了個不存在的hello函數)
3:在Build Settings
中搜索order file
,加入./ht.order
4:Command + B
編譯后,再次去查看link map文件
:
- 發現
order文件
中不存在的函數
(hello),編譯器
會直接跳過
。 - 其他
函數符號
,完全按照我們order
順序排列。 order
中沒有的函數
,按照默認順序
接在order
函數后面
。
4:二進制重排實踐
下面,我們來進行具體的實踐,首先理解幾個名詞
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文件中,將所需要的符號按照順序寫在里面,在項目編譯時,會按照這個文件的順序進行加載,以此來達到我們的優化
所以二進制重排的本質就是對啟動加載的符號進行重新排列
。
到目前為止,原理我們基本弄清楚了,如果項目比較小,完全可以自定義一個order文件,將方法的順序手動添加,但是如果項目較大,涉及的方法特別多,此時我們如何獲取啟動運行的函數呢?有以下幾種思路
-
1、hook objc_msgSend
:我們知道,函數的本質是發送消息,在底層都會來到objc_msgSend
,但是由於objc_msgSend的參數是可變的,需要通過匯編
獲取,對開發人員要求較高。而且也只能拿到OC
和 swift中@objc
后的方法 -
2、靜態掃描
:掃描Mach-O
特定段和節里面所存儲的符號以及函數數據 -
3、Clang插樁
:即批量hook,可以實現100%符號覆蓋,即完全獲取swift、OC、C、block
函數
四:clang插樁
官方介紹: https://clang.llvm.org/docs/SanitizerCoverage.html#tracing-pcs
-
官方提供了
LLVM
的代碼覆蓋監測工具
。其中包含了Tracing PCs
(追蹤PC)。 -
我們創建
TranceDemo
項目,按照官方
給的示例
,來嘗試開發
1 添加trace
-
按照官方描述,可以加入
跟蹤代碼
,並給出了回調函數
。
打開TranceDemo
, Build Settings
中搜索Other C
,加入-fsanitize-coverage=trace-pc-guard,需要實現兩個方法
下面實現這兩個函數
// trace-pc-guard-cb.cc #include <stdint.h> #include <stdio.h> #include <sanitizer/coverage_interface.h> // 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. extern "C" 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. } // 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); extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { if (!*guard) return; // Duplicate the guard check. // If you set *guard to 0 this code will not be called again for this edge. // Now you can get the PC and do whatever you want: // store it somewhere or symbolize it and print right away. // The values of `*guard` are as you set them in // __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive // and use them to dereference an array or a bit vector. void *PC = __builtin_return_address(0); char PcDescr[1024]; // This function is a part of the sanitizer run-time. // To use it, link with AddressSanitizer or other sanitizer. __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr)); printf("guard: %p %x PC %s\n", guard, *guard, PcDescr); }
2:復制
項目案例
,粘貼
到項目的ViewController
中,去除注釋
和extern 聲明
,加入幾個測試函數
:
#import "ViewController.h" #include <stdint.h> #include <stdio.h> #include <sanitizer/coverage_interface.h> @interface ViewController () @end @implementation ViewController +(void)load {} void (^block)(void) = ^{ printf("123"); }; void test() { block(); } - (void)viewDidLoad { [super viewDidLoad]; } 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); } - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { test(); } @end
Command+B
編譯,發現找不到
符號__sanitizer_symbolize_pc
(需要導入庫),我們暫時把這一行注釋掉
3:運行程序
touchBegin
->
__sanitizer_cov_trace_pc_guard
->
test
->
__sanitizer_cov_trace_pc_guard
->
block
->
__sanitizer_cov_trace_pc_guard
確實
每個函數
在觸發
時,都調用了__sanitizer_cov_trace_pc_guard
函數。原因:
- 只要在
Other C Flags
處加
入標記
,開啟了trace
功能。LLVM
會在每個函數邊緣
(開始位置),插入
一行調用__sanitizer_cov_trace_pc_guard
的代碼。編譯期
就插入
了。所以可以100%覆蓋。
- 以上,就是
Clang插樁
。插樁
操作完成
后,我們需要獲取
所有函數符號
、存儲
並導出order文件
。
3. 獲取函數符號
__builtin_return_address
: return的地址。
函數
return
,是返回到上一層
的函數
。
- 通過
return
的地址,拿到的是上一層級
的函數信息
。- 參數:
0
: 表示當前函數的上一層
。1
:是上一層
的上一層
地址。
- 導入
#import <dlfcn.h>
,通過Dl_info
拿到函數信息:
typedef struct dl_info { const char *dli_fname; /* 文件地址*/ void *dli_fbase; /* 起始地址(machO模塊的虛擬地址)*/ const char *dli_sname; /* 符號名稱 */ void *dli_saddr; /* 內存真實地址(偏移后的真實物理地址) */ } Dl_info;
- 在
__sanitizer_cov_trace_pc_guard
函數加入代碼:
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { if(!*guard) return; void *PC = __builtin_return_address(0); //0 當前函數地址, 1 上一層級函數地址 Dl_info info; // 聲明對象 dladdr(PC, &info); // 讀取PC地址,賦值給info printf("dli_fname:%s \n dli_fbase:%p \n dli_sname:%s \n dli_saddr:%p \n ", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr); }
- 運行程序,可以看到:

dli_fname
: 文件地址dli_fbase
: 起始地址(machO模塊的虛擬地址)dli_sname
: 符號名稱dli_saddr
: 內存真實地址(偏移后的真實物理內存地址)
- 此時,我們
成功
拿到函數符號
。
4.存儲符號
注意:__sanitizer_cov_trace_pc_guard
函數是在多線程
環境下,所以需要注意寫入安全
寫入安全
,就是上鎖
。此處我使用OSAtomic原子鎖
。存儲方式
,也有很多種, 此處我使用隊列
進行存儲
。
- 導入
#include <libkern/OSAtomic.h>
原子頭文件,創建原子隊列
,定義節點結構體
:
#import "ViewController.h" #include <stdint.h> #include <stdio.h> #include <sanitizer/coverage_interface.h> #import <dlfcn.h> #import <libkern/OSAtomic.h> // 原子操作 @interface ViewController () @end @implementation ViewController +(void)load {} void (^block)(void) = ^{ printf("123"); }; void test666() { block(); } - (void)viewDidLoad { [super viewDidLoad]; } // 定義原子隊列 static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT; // 原子隊列初始化 // 定義符號結構體 typedef struct { void * pc; void * next; }SYNode; 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) { // 這里是多線程,會有資源搶奪。 // 這個會影響load函數,所以需要移除哨兵 // if(!*guard) return; void *PC = __builtin_return_address(0); //0 當前函數地址, 1 上一層級函數地址 Dl_info info; // 聲明對象 dladdr(PC, &info); // 讀取PC地址,賦值給info // 創建結構體 SYNode * node = malloc(sizeof(SYNode)); // 創建結構體空間 *node = (SYNode){PC, NULL}; // node節點的初始化賦值(pc為當前PC值,NULL為next值) // 加入結構 (offsetof: 按照參數1大小作為偏移值,給到next) // 拿到並賦值 // 拿到symbolList地址,偏移SYNode字節,將node賦值給symbolList最后節點的next指針。 OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next)); } - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { // 創建可變數組 NSMutableArray<NSString *> * symbolNames = [NSMutableArray array]; // 每次while循環,都會加入一次hook (__sanitizer_cov_trace_pc_guard) 只要是跳轉,就會被block // 直接修改[other c clang]: -fsanitize-coverage=func,trace-pc-guard 指定只有func才加Hook while (1) { // 去除鏈表 SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next)); if(node ==NULL) break; Dl_info info = {0}; // 取出節點的pc,賦值給info dladdr(node->pc, &info); // 釋放節點 free(node); // 存名字 NSString *name = @(info.dli_sname); // 三目運算符 寫法 BOOL isObjc = [name hasPrefix: @"+["] || [name hasPrefix: @"-["]; NSString * symbolName = isObjc ? name : [NSString stringWithFormat:@"_%@",name]; [symbolNames addObject:symbolName]; } // 反向集合 NSEnumerator * enumerator = [symbolNames reverseObjectEnumerator]; // 創建數組 NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbolNames.count]; // 臨時變量 NSString * name; // 遍歷集合,去重,添加到funcs中 while (name = [enumerator nextObject]) { // 數組中去重添加 if (![funcs containsObject:name]) { [funcs addObject:name]; } } // 移除當前touchesBegan函數 (跟啟動無關) [funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]]; // 數組轉字符串 NSString * funcStr = [funcs componentsJoinedByString:@"\n"]; // 文件路徑 NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"ht.order"]; // 文件內容 NSData * fielContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding]; // 創建文件 [[NSFileManager defaultManager] createFileAtPath:filePath contents:fielContents attributes:nil]; NSLog(@"%@",funcs); NSLog(@"%@",filePath); NSLog(@"%@",fielContents); } @end
坑點:
-
if(!*guard) return;
需要去掉,會影響+load
的寫入
-
while循環
,也會觸發__sanitizer_cov_trace_pc_guard
:

【原因】:
- 通過看匯編,可以看到while也觸發了
__sanitizer_cov_trace_pc_guard
的跳轉。原因是,trace
的觸發
,並不是
根據函數
來進行hook
的,而是hook
了每一個跳轉(bl)
。 while
也有跳轉
,所以進入了死循環
。
【方案】:
Build Settings
的Other C Flags
配置,添加一個func
指定條件:-fsanitize-coverage=func,trace-pc-guard

根據打印路徑,查看ht.order
文件,完美!
從真機沙盒中拿到ht.order文件。
復制ht.order
文件,放到根目錄
,就完成了。
可以根據上一節的內容,打開
link Map
查看最終
的符號排序
,使用Instruments
檢查自己應用的PageFault數量
和耗時
注意
- 【二進制重排
order文件
】需要代碼封版后
,再生成
。 (代碼還在變動,生成就沒意義了)- 【二進制重排
相關代碼
】不要寫到
自己項目中
去。寫個小工具
跑一下,拿到order文件
即可。
注意