在之前做debug工具的時(shí)候,就有一個(gè)想法,在頁面產(chǎn)生卡頓的時(shí)候,如果能夠獲取主線程的函數(shù)調(diào)用棧就好了,就可以分析出哪里出現(xiàn)了性能瓶頸。由于當(dāng)時(shí)對(duì)這部分內(nèi)容還不是很了解,就沒有繼續(xù)下去,現(xiàn)在重新來實(shí)踐一次。
原理
上篇說到的C方法的參數(shù)調(diào)用時(shí),描述了C函數(shù)調(diào)用的大致流程,我們也知道通過BL跳轉(zhuǎn)的函數(shù)調(diào)用會(huì)將返回地址存在LR寄存器中,如果還有后續(xù)的函數(shù)調(diào)用,則會(huì)把LR存入棧幀進(jìn)行保存。
還是拿出我們的棧幀分布圖:

FP當(dāng)前位置儲(chǔ)存的是上一個(gè)FP所在的地址,也就是FP = &FP0,而LR被儲(chǔ)存在FP的下一個(gè),由于棧是向上增長的,所以LR = *(FP + 1)。也就是說我們?nèi)绻苣玫疆?dāng)前的FP就可以依次獲得所有的二進(jìn)制中的調(diào)用順序:
while(fp) {
pc = *(fp + 1);
fp = *fp;
}
以上就是我們此次遍歷調(diào)用棧的最重要的思路,如果你了解匯編,這一部分應(yīng)該很簡(jiǎn)單。
MachO
MachO是MAC和iOS的可執(zhí)行文件格式,包括動(dòng)態(tài)庫靜態(tài)庫。想要從調(diào)用地址獲得方法名稱,就必須要了解MachO的基本結(jié)構(gòu),這次我們不需要了解每個(gè)字段和數(shù)值都代表什么,只需要關(guān)心特定的幾個(gè)字段。(蘋果官方有關(guān)MachO的文檔特別少,我們能夠獲得的相關(guān)文檔 MachORuntime 也是非常的古老,甚至現(xiàn)在在官網(wǎng)上已經(jīng)搜不到了,所以MachO是比較難以理解的一部分。)
關(guān)于MachO內(nèi)容查看和解析,官方有幾個(gè)命令行工具:
- The file-type displaying tool, /usr/bin/file, shows the type of a file. For multi-architecture files, it shows the type of each of the images that make up the archive.
- The object-file displaying tool, /usr/bin/otool, lists the contents of specific sections and segments within a Mach-O file. It includes symbolic disassemblers for each supported architecture and it knows how to format the contents of many common section types.
- The page-analysis tool, /usr/bin/pagestuff, displays information on each logical page that compose the image, including the names of the sections and symbols contained in each page. This tool doesn’t work on binaries containing images for more than one architecture.
- The symbol table display tool, /usr/bin/nm, allows you to view the contents of an object file’s symbol table.
這里我們使用GUI工具MachOView來說明,使用上更加簡(jiǎn)單方便。
一個(gè)MachO大致分為三部分:
- Header
- Load Commands
- 數(shù)據(jù)段

header
Header中保存了CPU架構(gòu),load commands的個(gè)數(shù)等信息,這次我們都在ARM64的基礎(chǔ)上進(jìn)行分析:
struct mach_header_64 {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};
load_commands
緊接著Header的就是load command了,這里存著一些加載信息,動(dòng)態(tài)庫,main函數(shù)和數(shù)據(jù)段等一些信息。所有的結(jié)構(gòu)前兩位都是一樣的:
struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize; /* total size of command in bytes */
};
這次我們會(huì)遇到的有segment, symbol table相關(guān)的load commands。這里我們先不說明每個(gè)字段的作用,之后在使用過程中再來說明。
struct segment_command_64 { /* for 64-bit architectures */
uint32_t cmd; /* LC_SEGMENT_64 */
uint32_t cmdsize; /* includes sizeof section_64 structs */
char segname[16]; /* segment name */
uint64_t vmaddr; /* memory address of this segment */
uint64_t vmsize; /* memory size of this segment */
uint64_t fileoff; /* file offset of this segment */
uint64_t filesize; /* amount to map from the file */
vm_prot_t maxprot; /* maximum VM protection */
vm_prot_t initprot; /* initial VM protection */
uint32_t nsects; /* number of sections in segment */
uint32_t flags; /* flags */
};
struct symtab_command {
uint32_t cmd; /* LC_SYMTAB */
uint32_t cmdsize; /* sizeof(struct symtab_command) */
uint32_t symoff; /* symbol table offset */
uint32_t nsyms; /* number of symbol table entries */
uint32_t stroff; /* string table offset */
uint32_t strsize; /* string table size in bytes */
};

數(shù)據(jù)段
數(shù)據(jù)段包括了很多內(nèi)容,也是最復(fù)雜的部分,大致包含了 TEXT可執(zhí)行代碼,DATA數(shù)據(jù)段,符號(hào)表,字符表等內(nèi)容,這里我們需要了解的是Section(_TEXT,__text)和Symbol Table。
其中TEXT段就是我們的代碼執(zhí)行部分,可以直接進(jìn)行反匯編。比如下面就是從微信SDK中獲取的一段反匯編代碼:
-[AppCommunicateData MakeCommand:]:
0000000000001e94 stp x29, x30, [sp, #-0x10]!
0000000000001e98 mov x29, sp
0000000000001e9c adrp x8, #0x4000
0000000000001ea0 ldr x1, [x8, #0x998]
0000000000001ea4 bl _objc_msgSend
0000000000001ea8 orr w0, wzr, #0x1
0000000000001eac ldp x29, x30, [sp]!, #0x10
0000000000001eb0 ret
而符號(hào)表就是保存了我們代碼中全部的公開符號(hào),包括動(dòng)態(tài)鏈接的符號(hào)。比如下面就是一個(gè)解析后的符號(hào)表內(nèi)容:

這里我們簡(jiǎn)單的介紹了一下MachO和本次所需要了解的內(nèi)容,由于MachO是一個(gè)非常龐大而且復(fù)雜的結(jié)構(gòu),這里就不再深入了。接下來我們來簡(jiǎn)單看看一個(gè)函數(shù)的動(dòng)態(tài)調(diào)用過程,來理解如何通過符號(hào)(也就是函數(shù)名稱),來獲取執(zhí)行的地址(也就是下一個(gè)PC的位置)。
函數(shù)調(diào)用
我們以上面+[ObjcException test]來進(jìn)行說明。
首先我們從load_command中獲取到符號(hào)表的位置。
然后在符號(hào)表中查找,得到上圖的結(jié)構(gòu),其中value字段代表著在該文件中的偏移量0x1AF0。
我們找到在(__TEXT,__text)段中的這一行:

那么,要實(shí)現(xiàn)開頭所說的符號(hào)查找,也就是該過程的一個(gè)逆過程,也就打通了道路。
LR查找符號(hào)
我們從堆棧中獲取的LR值并不是該函數(shù)的起始位置,也就是符號(hào)表中所記錄的位置,而是函數(shù)返回地址,我們?cè)賮砜纯次⑿臩DK的這一段代碼:
-[AppCommunicateData MakeCommand:]:
0000000000001e94 stp x29, x30, [sp, #-0x10]!
0000000000001e98 mov x29, sp
0000000000001e9c adrp x8, #0x4000
0000000000001ea0 ldr x1, [x8, #0x998]
0000000000001ea4 bl _objc_msgSend
0000000000001ea8 orr w0, wzr, #0x1
0000000000001eac ldp x29, x30, [sp]!, #0x10
0000000000001eb0 ret
這里bl _objc_msgSend,LR所記錄的應(yīng)該是0000000000001ea8,而不是開頭的0000000000001e94,那么我們要怎么定位該符號(hào)呢?
我們知道,在執(zhí)行代碼區(qū)域,每個(gè)符號(hào)之間是連續(xù)的,而且符號(hào)會(huì)全部保存在符號(hào)表中,那么我們可以遍歷符號(hào)表,查找到小于LR位置,并且距離LR最近的一個(gè)符號(hào),那么我們就可以認(rèn)為我們的函數(shù)跳轉(zhuǎn)發(fā)生在該函數(shù)內(nèi)部。
這樣就找到了我們所需要的符號(hào)名稱了。
下面就從實(shí)現(xiàn)角度來說明。
實(shí)現(xiàn)
這里我們用純C/C++來實(shí)現(xiàn)這部分,使用lambda來讓代碼更容易理解。這里的實(shí)現(xiàn)并不是完美的,只是作為說明整個(gè)流程。
準(zhǔn)備工作
在獲取調(diào)用棧之前,我們最好將對(duì)應(yīng)線程暫停:
pthread_t thread;
pthread_create(&thread, nullptr, [](void *p) {
thread_suspend(main_thread);
// generate symbols of (main_thread);
thread_resume(main_thread);
void *ptr = nullptr;
return ptr;
}, nullptr);
獲得線程當(dāng)前狀態(tài)
MachO提供了獲取暫停線程上下文環(huán)境的接口thread_get_state
#if defined(__x86_64__)
_STRUCT_MCONTEXT ctx;
mach_msg_type_number_t count = x86_THREAD_STATE64_COUNT;
thread_get_state(thread, x86_THREAD_STATE64, (thread_state_t)&ctx.__ss, &count);
uint64_t pc = ctx.__ss.__rip;
uint64_t sp = ctx.__ss.__rsp;
uint64_t fp = ctx.__ss.__rbp;
#elif defined(__arm64__)
_STRUCT_MCONTEXT ctx;
mach_msg_type_number_t count = ARM_THREAD_STATE64_COUNT;
thread_get_state(thread, ARM_THREAD_STATE64, (thread_state_t)&ctx.__ss, &count);
uint64_t pc = ctx.__ss.__pc;
uint64_t sp = ctx.__ss.__sp;
uint64_t fp = ctx.__ss.__fp;
#endif
可以看到不同架構(gòu)的獲取方式是完全不一樣的,這是由于不同平臺(tái)底層實(shí)現(xiàn)的不同所導(dǎo)致的,但是對(duì)于C語言層面上來說,都是一致的,都有最基本的幾個(gè)概念PC, SP, FP, LR。
遍歷調(diào)用棧
依照我們開頭所說的方法來遍歷:
do {
// print symbol of (pc);
pc = *((uint64_t *)fp + 1);
fp = *((uint64_t *)fp);
} while (fp);
查找符號(hào)
一般來說,我們一個(gè)應(yīng)用內(nèi)會(huì)有多個(gè)動(dòng)態(tài)庫,也就是會(huì)有多個(gè)MachO被映射到內(nèi)存空間,所以我們不是簡(jiǎn)單的查找某個(gè)Image就可以了,而是要遍歷所有已載入的Images。
uint64_t count = _dyld_image_count();
for (uint32_t i = 0; i < count; i++) {
const struct mach_header *header = _dyld_get_image_header(i);
const char *name = _dyld_get_image_name(i);
uint64_t slide = _dyld_get_image_vmaddr_slide(i);
}
這里我們就能夠拿到各自的mach_header了,計(jì)算其相對(duì)于image的地址時(shí),需要進(jìn)行矯正:
uint64_t pcSlide = pc - slide;
在查找符號(hào)前,我們定義一個(gè)快捷的函數(shù),來遍歷load commands,因?yàn)橹髸?huì)多次查找load commands:
void enumerateSegment(const mach_header *header, std::function<bool(struct load_command *)> func) {
// 這里我們只考慮64位應(yīng)用。第一個(gè)command從header的下一位開始
struct load_command *baseCommand = (struct load_command *)((struct mach_header_64 *)header + 1);
if (baseCommand == nullptr) return;
struct load_command *command = baseCommand;
for (int i = 0; i < header->ncmds; i++) {
if (func(command)) {
return;
}
command = (struct load_command *)((uintptr_t)command + command->cmdsize);
}
}
回到上面,首先我們需要遍歷segment,來確定當(dāng)前pc是否落在這個(gè)image的區(qū)域內(nèi)。由于一個(gè)程序空間內(nèi),虛擬地址都是唯一的,動(dòng)態(tài)庫也會(huì)被映射到一段唯一的地址段,所以如果pc不在當(dāng)前的地址段內(nèi),就可以確定不屬于該MachO的方法。
bool found = false;
enumerateSegment(header, [&](struct load_command *command) {
if (command->cmd == LC_SEGMENT_64) {
const struct segment_command_64 *segCmd = (struct segment_command_64 *)command;
uintptr_t start = segCmd->vmaddr;
uintptr_t end = segCmd->vmaddr + segCmd->vmsize;
if (pcSlide >= start && pcSlide < end) {
std::cout << segCmd->segname << std::endl;
found = true;
return true;
}
}
return false;
});
if (!found) continue;
定位符號(hào)
我們需要遍歷符號(hào)表,首先要從load_command中定位到符號(hào)表的位置,而symtab_command并沒有給我們一個(gè)絕對(duì)的位置信息,只有一個(gè)stroff和symoff,也就是字符串表偏移量和符號(hào)表偏移量,所以我們還需要找出其真正的內(nèi)存地址。而我們可以從LC_SEGMENT(__LINKEDIT)段中獲取到絕對(duì)位置vmaddr和偏移量fileoff,所以就可以得到:
uint64_t baseaddr = segCmd->vmaddr - segCmd->fileoff;
// 符號(hào)表
nlist_64 *nlist = (nlist_64 *)(baseaddr + slide + symCmd->symoff);
// 字符串表
uint64_t strTable = baseaddr + slide + symCmd->stroff;
這里我們就可以按照上面的想法,在nlist中找到最符合的符號(hào)字符串了。綜合起來如下:
enumerateSegment(header, [&](struct load_command *command) {
if (command->cmd == LC_SYMTAB) {
struct symtab_command *symCmd = (struct symtab_command *)command;
uint64_t baseaddr = 0;
enumerateSegment(header, [&](struct load_command *command) {
if (command->cmd == LC_SEGMENT_64) {
struct segment_command_64 *segCmd = (struct segment_command_64 *)command;
if (strcmp(segCmd->segname, SEG_LINKEDIT) == 0) {
baseaddr = segCmd->vmaddr - segCmd->fileoff;
return true;
}
}
return false;
});
if (baseaddr == 0) return false;
nlist_64 *nlist = (nlist_64 *)(baseaddr + slide + symCmd->symoff);
uint64_t strTable = baseaddr + slide + symCmd->stroff;
uint64_t offset = UINT64_MAX;
int best = -1;
for (int k = 0; k < symCmd->nsyms; k++) {
nlist_64 &sym = nlist[k];
uint64_t d = pcSlide - sym.n_value;
if (offset >= d) {
offset = d;
best = k;
}
}
if (best >= 0) {
nlist_64 &sym = nlist[best];
std::cout << "SYMBOL: " << (char *)(strTable + sym.n_un.n_strx) << std::endl;
}
return true;
}
return false;
});
結(jié)論
我們?cè)倌M器上實(shí)驗(yàn),最后的結(jié)果來說是完全符合預(yù)期的,除了有部分系統(tǒng)符號(hào)不能打出來。這里整理一部分結(jié)果:
Found: cfunction.app/cfunction
SYMBOL: -[ViewController viewDidLoad]
Found: UIKit.framework/UIKit
SYMBOL: -[UIViewController loadViewIfRequired]
Found: UIKit.framework/UIKit
SYMBOL: -[UIViewController view]
Found: UIKit.framework/UIKit
SYMBOL: -[UIWindow addRootViewControllerViewIfPossible]
Found: Frameworks/UIKit.framework/UIKit
SYMBOL: -[UIWindow _setHidden:forced:]
Found: /UIKit.framework/UIKit
SYMBOL: -[UIWindow makeKeyAndVisible]
......
Found: CoreFoundation.framework/CoreFoundation
SYMBOL: ___CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
Found: CoreFoundation.framework/CoreFoundation
SYMBOL: ___CFRunLoopDoSource0
Found: CoreFoundation.framework/CoreFoundation
SYMBOL: ___CFRunLoopDoSources0
Found: CoreFoundation.framework/CoreFoundation
SYMBOL: ___CFRunLoopRun
Found: CoreFoundation.framework/CoreFoundation
SYMBOL: _CFRunLoopRunSpecific
Found: GraphicsServices.framework/GraphicsServices
Found: UIKit.framework/UIKit
SYMBOL: _UIApplicationMain
Found: cfunction.app/cfunction
SYMBOL: _main
和xcode所展示的調(diào)用關(guān)系:

以上是在模擬器的環(huán)境下,那么在真機(jī)上是什么表現(xiàn)呢?很遺憾,在真機(jī)上,很多私有API的符號(hào)都被去掉了,只能顯示<redacted>,但是部分公開的API和自己的符號(hào)均能被打印。所以還是能幫助我們對(duì)問題的分析。
最后
MachO還是一個(gè)非常龐大的知識(shí)點(diǎn),而且官方資料也特別少,和很多業(yè)務(wù)層代碼不同,這些內(nèi)容對(duì)開發(fā)能力的影響可能不大,畢竟平時(shí)業(yè)務(wù)層的東西很少需要這些東西。但是這些東西有時(shí)候能夠產(chǎn)生一些新奇的想法和不同的思路。下面簡(jiǎn)單說幾個(gè)相關(guān)的內(nèi)容。
C方法的method swizziling,F(xiàn)acebook的fishhook。
__attribute__(section("__DATA,custom")),自定義全局對(duì)象,React就是采用這種方式自動(dòng)采集方法列表的。這個(gè)思路可以簡(jiǎn)化很多編碼方式,但是可移植性會(huì)降低。
C方法的動(dòng)態(tài)調(diào)用,我們可以運(yùn)行時(shí)去調(diào)用指定的C方法。這個(gè)方式危險(xiǎn)程度較高,但卻是很多高級(jí)語言的基礎(chǔ)。
參考
KSCrash
MachORuntime