在之前做debug工具的时候,就有一个想法,在页面产生卡顿的时候,如果能够获取主线程的函数调用栈就好了,就可以分析出哪里出现了性能瓶颈。由于当时对这部分内容还不是很了解,就没有继续下去,现在重新来实践一次。
上篇说到的C方法的参数调用时,描述了C函数调用的大致流程,我们也知道通过BL
跳转的函数调用会将返回地址存在LR
寄存器中,如果还有后续的函数调用,则会把LR
存入栈帧进行保存。
还是拿出我们的栈帧分布图:
FP
当前位置储存的是上一个FP
所在的地址,也就是FP = &FP0
,而LR
被储存在FP
的下一个,由于栈是向上增长的,所以LR = *(FP + 1)
。也就是说我们如果能拿到当前的FP
就可以依次获得所有的二进制中的调用顺序:
while(fp) {
pc = *(fp + 1);
fp = *fp;
}
以上就是我们此次遍历调用栈的最重要的思路,如果你了解汇编,这一部分应该很简单。
MachO是MAC和iOS的可执行文件格式,包括动态库静态库。想要从调用地址获得方法名称,就必须要了解MachO的基本结构,这次我们不需要了解每个字段和数值都代表什么,只需要关心特定的几个字段。(苹果官方有关MachO的文档特别少,我们能够获得的相关文档 MachORuntime 也是非常的古老,甚至现在在官网上已经搜不到了,所以MachO是比较难以理解的一部分。)
关于MachO内容查看和解析,官方有几个命令行工具:
这里我们使用GUI工具MachOView
来说明,使用上更加简单方便。
一个MachO大致分为三部分:
数据段
Header中保存了CPU架构,load commands的个数等信息,这次我们都在ARM64的基础上进行分析:
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 */
};
紧接着Header的就是load command了,这里存着一些加载信息,动态库,main函数和数据段等一些信息。所有的结构前两位都是一样的:
struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize; /* total size of command in bytes */
};
这次我们会遇到的有segment, symbol table相关的load commands。这里我们先不说明每个字段的作用,之后在使用过程中再来说明。
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 */
};
数据段包括了很多内容,也是最复杂的部分,大致包含了 TEXT可执行代码,DATA数据段,符号表,字符表等内容,这里我们需要了解的是Section(_TEXT,__text)
和Symbol Table
。
其中TEXT段就是我们的代码执行部分,可以直接进行反汇编。比如下面就是从微信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
而符号表就是保存了我们代码中全部的公开符号,包括动态链接的符号。比如下面就是一个解析后的符号表内容:
这里我们简单的介绍了一下MachO和本次所需要了解的内容,由于MachO是一个非常庞大而且复杂的结构,这里就不再深入了。接下来我们来简单看看一个函数的动态调用过程,来理解如何通过符号(也就是函数名称),来获取执行的地址(也就是下一个PC的位置)。
我们以上面+[ObjcException test]
来进行说明。
首先我们从load_command中获取到符号表的位置。
然后在符号表中查找,得到上图的结构,其中value字段代表着在该文件中的偏移量0x1AF0
。
我们找到在(__TEXT,__text)
段中的这一行:
那么,要实现开头所说的符号查找,也就是该过程的一个逆过程,也就打通了道路。
我们从堆栈中获取的LR
值并不是该函数的起始位置,也就是符号表中所记录的位置,而是函数返回地址,我们再来看看微信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
这里bl _objc_msgSend
,LR
所记录的应该是0000000000001ea8
,而不是开头的0000000000001e94
,那么我们要怎么定位该符号呢?
我们知道,在执行代码区域,每个符号之间是连续的,而且符号会全部保存在符号表中,那么我们可以遍历符号表,查找到小于LR
位置,并且距离LR
最近的一个符号,那么我们就可以认为我们的函数跳转发生在该函数内部。
这样就找到了我们所需要的符号名称了。
下面就从实现角度来说明。
这里我们用纯C/C++来实现这部分,使用lambda来让代码更容易理解。这里的实现并不是完美的,只是作为说明整个流程。
在获取调用栈之前,我们最好将对应线程暂停:
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);
MachO提供了获取暂停线程上下文环境的接口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
可以看到不同架构的获取方式是完全不一样的,这是由于不同平台底层实现的不同所导致的,但是对于C语言层面上来说,都是一致的,都有最基本的几个概念PC
, SP
, FP
, LR
。
依照我们开头所说的方法来遍历:
do {
// print symbol of (pc);
pc = *((uint64_t *)fp + 1);
fp = *((uint64_t *)fp);
} while (fp);
一般来说,我们一个应用内会有多个动态库,也就是会有多个MachO被映射到内存空间,所以我们不是简单的查找某个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了,计算其相对于image的地址时,需要进行矫正:
uint64_t pcSlide = pc - slide;
在查找符号前,我们定义一个快捷的函数,来遍历load commands,因为之后会多次查找load commands:
void enumerateSegment(const mach_header *header, std::function<bool(struct load_command *)> func) {
// 这里我们只考虑64位应用。第一个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,来确定当前pc是否落在这个image的区域内。由于一个程序空间内,虚拟地址都是唯一的,动态库也会被映射到一段唯一的地址段,所以如果pc不在当前的地址段内,就可以确定不属于该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;
我们需要遍历符号表,首先要从load_command中定位到符号表的位置,而symtab_command
并没有给我们一个绝对的位置信息,只有一个stroff
和symoff
,也就是字符串表偏移量和符号表偏移量,所以我们还需要找出其真正的内存地址。而我们可以从LC_SEGMENT(__LINKEDIT)
段中获取到绝对位置vmaddr
和偏移量fileoff
,所以就可以得到:
uint64_t baseaddr = segCmd->vmaddr - segCmd->fileoff;
// 符号表
nlist_64 *nlist = (nlist_64 *)(baseaddr + slide + symCmd->symoff);
// 字符串表
uint64_t strTable = baseaddr + slide + symCmd->stroff;
这里我们就可以按照上面的想法,在nlist中找到最符合的符号字符串了。综合起来如下:
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;
});
我们再模拟器上实验,最后的结果来说是完全符合预期的,除了有部分系统符号不能打出来。这里整理一部分结果:
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所展示的调用关系:
以上是在模拟器的环境下,那么在真机上是什么表现呢?很遗憾,在真机上,很多私有API的符号都被去掉了,只能显示<redacted>
,但是部分公开的API和自己的符号均能被打印。所以还是能帮助我们对问题的分析。
MachO还是一个非常庞大的知识点,而且官方资料也特别少,和很多业务层代码不同,这些内容对开发能力的影响可能不大,毕竟平时业务层的东西很少需要这些东西。但是这些东西有时候能够产生一些新奇的想法和不同的思路。下面简单说几个相关的内容。
C方法的method swizziling,Facebook的fishhook
。
__attribute__(section("__DATA,custom"))
,自定义全局对象,React就是采用这种方式自动采集方法列表的。这个思路可以简化很多编码方式,但是可移植性会降低。
C方法的动态调用,我们可以运行时去调用指定的C方法。这个方式危险程度较高,但却是很多高级语言的基础。
KSCrash MachORuntime
本文来自网易实践者社区,经作者段家顺授权发布。