做者:陳浩 貝聊科技移動開發部 iOS 工程師python
本文已發表在我的博客git
以前負責項目的包體積優化學習了 Mach-O 文件的格式,那麼 Mach-O 到底是怎麼樣的文件,知道它的組成以後咱們又能作點什麼?本文會從 Mach-O 文件的介紹講起,再看看認識它後的一些實際應用。github
先讓咱們看看 Mach-O 的大體構成shell
再使用 MachOView 一窺究竟swift
結合可知 Mach-O 文件包含了三部份內容:bash
Mach-O 文件的頭部定義以下:架構
MH_NOUNDEFS
目標文件沒有未定義的符號,MH_DYLDLINK
目標文件是動態連接輸入文件,不能被再次靜態連接,MH_SPLIT_SEGS
只讀 segments 和 可讀寫 segments 分離,MH_NO_HEAP_EXECUTION
堆內存不可執行…filetype 的定義有:app
flags 的定義有:ide
簡單總結一下就是 Headers 能幫助校驗 Mach-O 合法性和定位文件的運行環境。函數
Headers 以後就是 Load Commands,其佔用的內存和加載命令的總數在 Headers 中已經指出。
Load Commands 的定義比較簡單:
LC_SEGMENT、LC_SEGMENT_64
將 segment 映射到進程的內存空間,LC_UUID
二進制文件 id,與符號表 uuid 對應,可用做符號表匹配,LC_LOAD_DYLINKER
啓動動態加載器,LC_SYMTAB
描述在 __LINKEDIT
段的哪找字符串表、符號表,LC_CODE_SIGNATURE
代碼簽名等這裏先來看看 segment 的定義:
#define SEG_PAGEZERO "__PAGEZERO" // 可執行文件捕獲空指針的段
#define SEG_TEXT "__TEXT" // 代碼段,只讀數據段
#define SEG_DATA "__DATA" // 數據段
#define SEG_LINKEDIT "__LINKEDIT" // 包含動態連接器所需的符號、字符串表等數據
接着看看 section 的定義:
__Text
和 __Data
都有本身的 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 文件的基本概念,接着聊聊能夠怎樣減小項目的體積。
咱們的項目中不免會存在一些沒使用的類或方法,因爲 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類。這點就跟本文的主題沒什麼關係,不感興趣能夠略過。 壓縮 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
能夠看到 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 文件的 __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 文件打交道,可是對它有個基本概念,不管是作崩潰分析、逆向等都是有幫助的。