之前負責項目的包體積優化學習了 Mach-O 文件的格式,那麼 Mach-O 究竟是怎麼樣的文件,知道它的組成之後我們又能做點什麼?本文會從 Mach-O 文件的介紹講起,再看看認識它後的一些實際應用。 Mach-O 文件格式 先讓我們看看 Mach-O 的大致構成 再使用 MachOView 一窺 ...
之前負責項目的包體積優化學習了 Mach-O 文件的格式,那麼 Mach-O 究竟是怎麼樣的文件,知道它的組成之後我們又能做點什麼?本文會從 Mach-O 文件的介紹講起,再看看認識它後的一些實際應用。
Mach-O 文件格式
先讓我們看看 Mach-O 的大致構成
再使用 MachOView 一窺究竟
結合可知 Mach-O 文件包含了三部分內容:
- Header(頭部),指明瞭 cpu 架構、大小端序、文件類型、Load Commands 個數等一些基本信息
- Load Commands(載入命令),正如官方的圖所示,描述了怎樣載入每個 Segment 的信息。在 Mach-O 文件中可以有多個 Segment,每個 Segment 可能包含一個或多個 Section。
- Data(數據區),Segment 的具體數據,包含了代碼和數據等。
Headers
Mach-O 文件的頭部定義如下:
- magic 標誌符 0xfeedface 是 32 位, 0xfeedfacf 是 64 位。
- cputype 和 cpusubtype 確定 cpu 類型、平臺
- filetype 文件類型,可執行文件、符號文件(DSYM)、內核擴展等
- ncmds 載入 Load Commands 的數量
- flags dyld 載入的標誌
MH_NOUNDEFS
目標文件沒有未定義的符號,MH_DYLDLINK
目標文件是動態鏈接輸入文件,不能被再次靜態鏈接,MH_SPLIT_SEGS
只讀 segments 和 可讀寫 segments 分離,MH_NO_HEAP_EXECUTION
堆記憶體不可執行…
filetype 的定義有:
flags 的定義有:
簡單總結一下就是 Headers 能幫助校驗 Mach-O 合法性和定位文件的運行環境。
Load Commands
Headers 之後就是 Load Commands,其占用的記憶體和載入命令的總數在 Headers 中已經指出。
Load Commands 的定義比較簡單:
- cmd 欄位,如上圖它指出了 command 類型
LC_SEGMENT、LC_SEGMENT_64
將 segment 映射到進程的記憶體空間,LC_UUID
二進位文件 id,與符號表 uuid 對應,可用作符號表匹配,LC_LOAD_DYLINKER
啟動動態載入器,LC_SYMTAB
描述在__LINKEDIT
段的哪找字元串表、符號表,LC_CODE_SIGNATURE
代碼簽名等
- cmdsize 欄位,主要用以計算出到下一個 command 的偏移量。
Segment & Section
這裡先來看看 segment 的定義:
- cmd 就是上面分析的 command 類型
- segname 在源碼中定義的巨集
#define SEG_PAGEZERO "__PAGEZERO" // 可執行文件捕獲空指針的段
#define SEG_TEXT "__TEXT" // 代碼段,只讀數據段
#define SEG_DATA "__DATA" // 數據段
#define SEG_LINKEDIT "__LINKEDIT" // 包含動態鏈接器所需的符號、字元串表等數據
- vmaddr 段的虛存地址(未偏移),由於 ALSR,程式會在進程加上一段偏移量(slide),真實的地址 = vm address + slide
- vmsize 段的虛存大小
- fileoff 段在文件的偏移
- filesize 段在文件的大小
- nsects 段中有多少個 section
接著看看 section 的定義:
__Text
和 __Data
都有自己的 section
- segname 就是所在段的名稱
- sectname section名稱,部分列舉:
Text.__text
主程式代碼Text.__cstring
c 字元串Text.__stubs
樁代碼Text.__stub_helper
Data.__data
初始化可變的數據Data.__objc_imageinfo
鏡像信息 ,在運行時初始化時objc_init
,調用load_images
載入新的鏡像到 infolist 中
* `Data.__la_symbol_ptr`
* `Data.__nl_symbol_ptr`
* `Data.__objc_classlist` 類列表
* `Data.__objc_classrefs` 引用的類
這節最後探究下 stubs,在 Xcode 中新建 C 項目,代碼如下:
#include <stdio.h>
int main(int argc, const char * argv[]) {
printf("Hello, coder\n");
return 0;
}
使用 gcc -c main.c
將其編譯成 a.out 文件,調用 nm 命令查看 .o 文件的符號
看到 _printf
是未定義的,也就是說並沒有該函數的記憶體地址。nm 列印出的信息表明dyld_stub_binder
也是未定義的。 打開 Hopper 查看 .o 文件
可以看出 printf 會跳入 __stubs
中,地址也與 MachOView 看到的相對應
雙擊剛纔 __stubs
中的地址,會跳轉到 __la_symbol_ptr
在 MachOView 中查看 0x100001010 對應的數據為 0x10000f9c
用 Hopper 搜索 0x10000f9c,跳轉到 stub_helper
,可知 __la_symbol_ptr
里的數據被 bind 成了 stub_helper
由此可知,__la_symbol_ptr
中的數據被第一次調用時會通過 dyld_stub_binder
進行相關綁定,而 __nl_symbol_ptr
中的數據就是在動態庫綁定時進行載入。
所以 __la_symbol_ptr
中的數據在初始狀態都被 bind 成 stub_helper
,接著 dyld_stub_binder
會載入相應的動態鏈接庫,執行具體的函數實現,此時 __la_symbol_ptr
也獲取到了函數的真實地址,完成了一次近似懶載入的過程。
寫到這裡,算是快速過了一遍 Mach-O 文件的基本概念,接著聊聊可以怎樣減少項目的體積。
減少包大小
iOS 的包主要由可執行文件、資源文件(圖片)等文件組成,所以可以從這兩大頭文件入手優化。
可執行文件瘦身
我們的項目中難免會存在一些沒使用的類或方法,由於 OC 的動態特性,編譯器會對所有的源文件進行編譯,找出並刪除沒用到的類或方法可以減少可執行文件大小。 上文中提到了 __objc_classlist
和 __objc_classrefs
,它們分別表示項目中全部類列表和項目中被引用的類列表,那麼取兩者之差,就能刪除一些項目中沒使用的類文件。但是在刪除過程中記住要在項目中全局搜索確認下,看看有沒有通過字元串調用無引用的類的方法,原因還是 OC 是動態語言。 在看具體做法之前,順帶提一下我公司的項目組成。我們維護著倆客戶端,共用著一個基礎庫(lib 庫),可能有時由於產品的需求變更或者為了產品功能的預留導致 lib 庫中只有著某個端使用的代碼,我在上述的做法中對腳本做了稍微改進,以防刪除了 lib 庫的代碼,導致另一個端跑不起來,下麵介紹通用的做法:
- 在控制台輸入
otool -v -s __objc_classlist
和otool -v -s __objc_classrefs
命令,逆向__DATA. __objc_classlist
段和__DATA. __objc_classrefs
段獲取當前所有oc類和被引用的oc類。 - 取兩者差集,得到沒被引用的類的段地址
- otool -o 二進位文件,獲取段信息
- 通過腳本使用沒被引用的類的段地址去段信息中匹配出具體類名
壓縮圖片資源
這點就跟本文的主題沒什麼關係,不感興趣可以略過。 壓縮 app 中的圖片是我做的另一個努力,雖然 Xcode 會壓一遍,但是經我壓縮後打包發現包還是會少個將近 1m,這裡用到的工具是 ImageOptim,貼出我的三腳貓 python:
all_file_size = 0
all_file_count = 0
def fileDriector(filePath):
global all_file_size, all_file_count
for file in os.listdir(filePath):
if os.path.isdir(filePath + '/' + file):
if file != 'Pods' and not file.startswith('.') and not file.endswith('.framework') \
and not file.endswith('.bundle') and not file.endswith('.a') and file != 'libs' \
or file.endswith('.xcassets') or file.endswith('.imageset'):
the_path = filePath + '/' + file
fileDriector(the_path)
elif file.endswith('.png') or file.endswith('.jpg'):
fileName = filePath + '/' + file
comand_line = "echo %s | imageoptim" % fileName
test = subprocess.Popen(comand_line, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
output = test.communicate()[0]
numberList = re.findall('\.?\d+\.?\d*kb', output)
lastSize = numberList[-1]
lastSizeList = re.findall('\.?\d+\.?\d*', lastSize)
saveSize = lastSizeList[0]
if saveSize.startswith('.'):
saveSize = '0' + saveSize
finalSize = float(saveSize)
all_file_size += finalSize
all_file_count += 1
print output
其他的一些減包方案就不展開了,接下來我試著分析一下 bestswifter 大神的 BSBacktraceLogger
獲取調用堆棧
說到調用堆棧,我們很容易聯想到 DSYM 文件,我們知道 Xcode build setting 有個 DEBUG INFOMATION FORMAT 的選項
可以看到 Debug 模式下,符號表文件會存入可執行文件中,而 Release 模式則會生成出 DSYM 文件,我們平常使用 Bugly 等工具上傳的就是這份 DSYM 文件,DSYM 也是種 Mach-O 文件。在 Debug 模式,由於符號表在記憶體中,這為我們符號化堆棧提供了可能性。
bool bs_fillThreadStateIntoMachineContext(thread_t thread, _STRUCT_MCONTEXT *machineContext) {
mach_msg_type_number_t state_count = BS_THREAD_STATE_COUNT;
kern_return_t kr = thread_get_state(thread, BS_THREAD_STATE, (thread_state_t)&machineContext->__ss, &state_count);
return (kr == KERN_SUCCESS);
}
thread_get_state
函數獲取線程執行狀態(例如寄存器),傳入 _STRUCT_MCONTEXT
結構體,_STRUCT_MCONTEXT
在不同的 cpu 架構會有所不同。
uintptr_t bs_mach_instructionAddress(mcontext_t const machineContext){
return machineContext->__ss.BS_INSTRUCTION_ADDRESS;
}
const uintptr_t instructionAddress = bs_mach_instructionAddress(&machineContext);
獲取當前指令的地址,也就是當前的棧幀,即當前被調用的函數。下麵先講下關於棧幀的概念。
棧幀是什麼
如上圖,一個函數調用棧是由若幹個棧幀組成,每個棧幀通過 FP 和 SP 劃分界線,fun1 函數 SP 和 FP 的指向就是 main 函數的棧幀。所以說只要知道當前函數的棧幀就能獲取上一個函數的棧幀,從而回溯出函數調用棧。
程式計數器(PC)作用是給出將要執行的下一條指令在記憶體中的地址,上面代碼的 BS_INSTRUCTION_ADDRESS
。其中 16 位為 %ip,32 位為 %eip,64 位為 %rip,arm 是 pc。
SP 是棧指針寄存器,指向棧頂。
FP 是棧基址寄存器,指向棧起始位置。
LR 寄存器在子程式調用時會存儲 PC 的值,即返回值。
為了方便獲取棧幀,乾脆構造一個棧幀的結構體,以下代碼來自 KSCrash,它的註釋已經很好的講明瞭結構體的原由,BSBacktraceLogger 與之類似。
/** Represents an entry in a frame list.
* This is modeled after the various i386/x64 frame walkers in the xnu source,
* and seems to work fine in ARM as well. I haven't included the args pointer
* since it's not needed in this context.
*/
typedef struct FrameEntry
{
/** The previous frame in the list. */
struct FrameEntry* previous;
/** The instruction address. */
uintptr_t return_address;
} FrameEntry;
之後,遞歸獲取函數棧幀
for(; i < 50; i++) {
backtraceBuffer[i] = frame.return_address;
if(backtraceBuffer[i] == 0 ||
frame.previous == 0 ||
bs_mach_copyMem(frame.previous, &frame, sizeof(frame)) != KERN_SUCCESS) {
break;
}
}
符號化
符號化地址的大致思路分三步:1. 獲取地址所在的記憶體鏡像;2. 定位到記憶體鏡像的符號表;3. 再從符號表中找到目標地址的符號。
找到地址所在的記憶體鏡像
uint32_t bs_imageIndexContainingAddress(const uintptr_t address) {
const uint32_t imageCount = _dyld_image_count();
const struct mach_header* header = 0;
for(uint32_t iImg = 0; iImg < imageCount; iImg++) {
header = _dyld_get_image_header(iImg);
遍歷 image,得到指向 image header 的指針
uintptr_t addressWSlide = address - (uintptr_t)_dyld_get_image_vmaddr_slide(iImg);
uintptr_t cmdPtr = bs_firstCmdAfterHeader(header);
對指針 +1 操作,返回指向 load command 的指針
for(uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
const struct load_command* loadCmd = (struct load_command*)cmdPtr;
if(loadCmd->cmd == LC_SEGMENT) {
const struct segment_command* segCmd = (struct segment_command*)cmdPtr;
if(addressWSlide >= segCmd->vmaddr &&
addressWSlide < segCmd->vmaddr + segCmd->vmsize) {
return iImg;
}
}
如果某個 segment 包含這個地址,那麼該地址應大於 segment 的起始地址,小於 segment 的起始地址 + segment 的大小。
定位鏡像的符號表
__LINKEDIT
段包含了符號表(symbol),字元串表(string),重定位表(relocation)。LC_SYMTAB
指明瞭 __LINKEDIT
段查找字元串和符號表的位置。我們可以結合 SEG_LINKEDIT
和 LC_SYMTAB
來找到 image 的符號表。 接下來看看段基址的獲取: 虛擬地址偏移量 = 虛擬地址(vmaddr) - 文件偏移量(fileoff) 段基址 = 虛擬地址偏移量 + ASLR的偏移量
const uintptr_t imageVMAddrSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(idx);
// ALSR
const uintptr_t addressWithSlide = address - imageVMAddrSlide;
const uintptr_t segmentBase = bs_segmentBaseOfImageIndex(idx) + imageVMAddrSlide;
有了段基址,獲取符號表和字元串表就只是計算下 symoff 和 stroff 偏移量了:
const BS_NLIST* symbolTable = (BS_NLIST*)(segmentBase + symtabCmd->symoff);
const uintptr_t stringTable = segmentBase + symtabCmd->stroff;
找到最匹配的符號
遞歸查找離 addressWithSlide 更近的函數入口地址,因為 addressWithSlide 肯定大於某個函數的入口。
for(uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) {
// If n_value is 0, the symbol refers to an external object.
if(symbolTable[iSym].n_value != 0) {
uintptr_t symbolBase = symbolTable[iSym].n_value;
uintptr_t currentDistance = addressWithSlide - symbolBase;
if((addressWithSlide >= symbolBase) &&
(currentDistance <= bestDistance)) {
bestMatch = symbolTable + iSym;
bestDistance = currentDistance;
}
}
}
如何用 MachO 文件關聯類的方法名
MachO 文件的 __Text
段有 __objc_classname
和 __objc_methname
來表示類名和方法名,但是這兩者之間是如何做到關聯的呢?下麵我以系統的計算器做例子,試著進一步研究下 MachO 文件。 使用 MachOView 打開系統電腦,先來看看 __objc_classname
和 __objc_methname
在 load commands 里的定義:
我們順著 __objc_classname
的偏移offset 109518 即 0x1ABCE 來到:
同理 __objc_methname
的偏移為 0x165E8:
那麼,怎樣像 class-dump 那樣將類和自個的方法名對應起來呢? 由於每個類的虛擬地址都在Data 段 __objc_classlist
中:
我們看到起始地址對應的是 0x1000298A8 這個地址,為了得到實際的地址需要用虛擬地址 - 段起始地址 + 文件偏移,經過一番計算,結果是0x298A8,來到文件偏移處,已經在DATA 段的 __objc_data
在這裡會對應著類的結構體,代碼拷自 class-dump
struct cd_objc2_class {
uint64_t isa;
uint64_t superclass;
uint64_t cache;
uint64_t vtable;
uint64_t data; // points to class_ro_t
uint64_t reserved1;
uint64_t reserved2;
uint64_t reserved3;
};
data 是我們感興趣的,它指向 class_ro_t
,熟悉 runtime 的話應該知道 class_ro_t
存儲了類在編譯器就確定的屬性、方法、協議等。 所以上圖 isa 的數據是 0x1000298D0,繼續順著找下去 0x100020A68 就是 data 的記憶體地址,再用上面的公式計算得到 0x20A68,我們在 __objc_const
找到那裡:
這裡就是對應著 class_ro_t
,來看看它在 class-dump 里的定義:
struct cd_objc2_class_ro_t {
uint32_t flags;
uint32_t instanceStart;
uint32_t instanceSize;
uint32_t reserved; // *** this field does not exist in the 32-bit version ***
uint64_t ivarLayout;
uint64_t name;
uint64_t baseMethods;
uint64_t baseProtocols;
uint64_t ivars;
uint64_t weakIvarLayout;
uint64_t baseProperties;
};
最終 0x20A80 就是name,0x20A88 就是 baseMethods。name 對應的正好是 0x1ABCE,類名是 BitFieldBox。baseMethods 指向記憶體 0x100020A00,該地址對應的數據是 18 00 00 00 04 00 00 00 表示 entsize 和 count 方法數,在這8個位元組之後就是 name 方法名,types 方法類型, imp 函數指針了,所以方法名處的數據為 0x1000165e8 剛好對應 initWithFrame: 將結論用 class-dump 驗證可得 BitFieldBox 的第一個方法是 initWithFrame
總結
最初學習 MachO 文件格式覺得挺抽象的,後來經過各種源碼的閱讀和融合,終於在一次次地探索中比較直觀地認識了 MachO 文件,特別是在 MachO 文件關聯類的方法名時對類在記憶體中的佈局有了更進一步的認識。雖然我們平常開發基本不和 MachO 文件打交道,但是對它有個基本概念,無論是做崩潰分析、逆向等都是有幫助的。
面試資料:
面試題持續整理更新中,如果你想一起進階去大廠,不妨添加一下交流群1012951431
面試題資料或者相關學習資料都在群文件中 進群即可下載!