探祕 Mach-O 文件

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

本文已發表在我的博客git

以前負責項目的包體積優化學習了 Mach-O 文件的格式,那麼 Mach-O 到底是怎麼樣的文件,知道它的組成以後咱們又能作點什麼?本文會從 Mach-O 文件的介紹講起,再看看認識它後的一些實際應用。github

Mach-O 文件格式

先讓咱們看看 Mach-O 的大體構成shell

再使用 MachOView 一窺究竟swift

結合可知 Mach-O 文件包含了三部份內容:bash

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

flags 的定義有:ide

簡單總結一下就是 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_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 的選項

能夠看到 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_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 裏的定義:

咱們順着 __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 文件打交道,可是對它有個基本概念,不管是作崩潰分析、逆向等都是有幫助的。

參考連接

深刻剖析Macho (1)

iOS中線程Call Stack的捕獲和解析(一)

iOS中線程Call Stack的捕獲和解析(二)

獲取任意線程調用棧的那些事

相關文章
相關標籤/搜索