作者:陳浩 貝聊科技移動開發部 iOS 工程師
本文已發表在個人部落格
之前負責專案的包體積優化學習了 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 檔案打交道,但是對它有個基本概念,無論是做崩潰分析、逆向等都是有幫助的。