探祕 Mach-O 檔案

貝聊科技發表於2018-03-23

作者:陳浩  貝聊科技移動開發部  iOS 工程師

本文已發表在個人部落格

之前負責專案的包體積優化學習了 Mach-O 檔案的格式,那麼 Mach-O 究竟是怎麼樣的檔案,知道它的組成之後我們又能做點什麼?本文會從 Mach-O 檔案的介紹講起,再看看認識它後的一些實際應用。

Mach-O 檔案格式

先讓我們看看 Mach-O 的大致構成

探祕 Mach-O 檔案

再使用 MachOView 一窺究竟

探祕 Mach-O 檔案

結合可知 Mach-O 檔案包含了三部分內容:

  • Header(頭部),指明瞭 cpu 架構、大小端序、檔案型別、Load Commands 個數等一些基本資訊
  • Load Commands(載入命令),正如官方的圖所示,描述了怎樣載入每個 Segment 的資訊。在 Mach-O 檔案中可以有多個 Segment,每個 Segment 可能包含一個或多個 Section。
  • Data(資料區),Segment 的具體資料,包含了程式碼和資料等。

Headers

Mach-O 檔案的頭部定義如下:

探祕 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 的定義有:

探祕 Mach-O 檔案

flags 的定義有:

探祕 Mach-O 檔案

簡單總結一下就是 Headers 能幫助校驗 Mach-O 合法性和定位檔案的執行環境。

Load Commands

Headers 之後就是 Load Commands,其佔用的記憶體和載入命令的總數在 Headers 中已經指出。

探祕 Mach-O 檔案

Load Commands 的定義比較簡單:

探祕 Mach-O 檔案

  • 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 的定義:

探祕 Mach-O 檔案

  • 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 的定義:

探祕 Mach-O 檔案

__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 中
      探祕 Mach-O 檔案
    • 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 檔案的符號

探祕 Mach-O 檔案

看到 _printf 是未定義的,也就是說並沒有該函式的記憶體地址。nm 列印出的資訊表明dyld_stub_binder 也是未定義的。 開啟 Hopper 檢視 .o 檔案

探祕 Mach-O 檔案

可以看出 printf 會跳入 __stubs 中,地址也與 MachOView 看到的相對應

探祕 Mach-O 檔案

雙擊剛才 __stubs 中的地址,會跳轉到 __la_symbol_ptr

探祕 Mach-O 檔案

在 MachOView 中檢視 0x100001010 對應的資料為 0x10000f9c

探祕 Mach-O 檔案

用 Hopper 搜尋 0x10000f9c,跳轉到 stub_helper,可知 __la_symbol_ptr 裡的資料被 bind 成了 stub_helper

探祕 Mach-O 檔案

由此可知,__la_symbol_ptr 中的資料被第一次呼叫時會通過 dyld_stub_binder 進行相關繫結,而 __nl_symbol_ptr 中的資料就是在動態庫繫結時進行載入。

探祕 Mach-O 檔案

所以 __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_classlistotool -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 的選項

探祕 Mach-O 檔案

可以看到 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);
複製程式碼

獲取當前指令的地址,也就是當前的棧幀,即當前被呼叫的函式。下面先講下關於棧幀的概念。

棧幀是什麼

探祕 Mach-O 檔案

如上圖,一個函式呼叫棧是由若干個棧幀組成,每個棧幀通過 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_LINKEDITLC_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 裡的定義:

探祕 Mach-O 檔案

探祕 Mach-O 檔案

我們順著 __objc_classname 的偏移offset 109518 即 0x1ABCE 來到:

探祕 Mach-O 檔案

同理 __objc_methname 的偏移為 0x165E8:

探祕 Mach-O 檔案

那麼,怎樣像 class-dump 那樣將類和自個的方法名對應起來呢? 由於每個類的虛擬地址都在Data 段 __objc_classlist 中:

探祕 Mach-O 檔案

我們看到起始地址對應的是 0x1000298A8 這個地址,為了得到實際的地址需要用虛擬地址 - 段起始地址 + 檔案偏移,經過一番計算,結果是0x298A8,來到檔案偏移處,已經在DATA 段的 __objc_data

探祕 Mach-O 檔案

在這裡會對應著類的結構體,程式碼拷自 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找到那裡:

探祕 Mach-O 檔案

這裡就是對應著 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

探祕 Mach-O 檔案

總結

最初學習 MachO 檔案格式覺得挺抽象的,後來經過各種原始碼的閱讀和融合,終於在一次次地探索中比較直觀地認識了 MachO 檔案,特別是在 MachO 檔案關聯類的方法名時對類在記憶體中的佈局有了更進一步的認識。雖然我們平常開發基本不和 MachO 檔案打交道,但是對它有個基本概念,無論是做崩潰分析、逆向等都是有幫助的。

參考連結

深入剖析Macho (1)

iOS中執行緒Call Stack的捕獲和解析(一)

iOS中執行緒Call Stack的捕獲和解析(二)

獲取任意執行緒呼叫棧的那些事

相關文章