基础深化

运行时获取函数调用栈

2018-03-05  本文已影响675人  码农苍耳

在之前做debug工具的时候,就有一个想法,在页面产生卡顿的时候,如果能够获取主线程的函数调用栈就好了,就可以分析出哪里出现了性能瓶颈。由于当时对这部分内容还不是很了解,就没有继续下去,现在重新来实践一次。

原理

上篇说到的C方法的参数调用时,描述了C函数调用的大致流程,我们也知道通过BL跳转的函数调用会将返回地址存在LR寄存器中,如果还有后续的函数调用,则会把LR存入栈帧进行保存。

还是拿出我们的栈帧分布图:

stack_frame.png

FP当前位置储存的是上一个FP所在的地址,也就是FP = &FP0,而LR被储存在FP的下一个,由于栈是向上增长的,所以LR = *(FP + 1)。也就是说我们如果能拿到当前的FP就可以依次获得所有的二进制中的调用顺序:

while(fp) {
  pc = *(fp + 1);
  fp = *fp;
}

以上就是我们此次遍历调用栈的最重要的思路,如果你了解汇编,这一部分应该很简单。

MachO

MachO是MAC和iOS的可执行文件格式,包括动态库静态库。想要从调用地址获得方法名称,就必须要了解MachO的基本结构,这次我们不需要了解每个字段和数值都代表什么,只需要关心特定的几个字段。(苹果官方有关MachO的文档特别少,我们能够获得的相关文档 MachORuntime 也是非常的古老,甚至现在在官网上已经搜不到了,所以MachO是比较难以理解的一部分。)

关于MachO内容查看和解析,官方有几个命令行工具:

这里我们使用GUI工具MachOView来说明,使用上更加简单方便。

一个MachO大致分为三部分:

macho-300x550.png
header

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 */
};
load_commands

紧接着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 */
};
load_commands-270x400.png
数据段

数据段包括了很多内容,也是最复杂的部分,大致包含了 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

而符号表就是保存了我们代码中全部的公开符号,包括动态链接的符号。比如下面就是一个解析后的符号表内容:

symbol_table-600x100.png

这里我们简单的介绍了一下MachO和本次所需要了解的内容,由于MachO是一个非常庞大而且复杂的结构,这里就不再深入了。接下来我们来简单看看一个函数的动态调用过程,来理解如何通过符号(也就是函数名称),来获取执行的地址(也就是下一个PC的位置)。

函数调用

我们以上面+[ObjcException test]来进行说明。

首先我们从load_command中获取到符号表的位置。

然后在符号表中查找,得到上图的结构,其中value字段代表着在该文件中的偏移量0x1AF0

我们找到在(__TEXT,__text)段中的这一行:

text_func.png

那么,要实现开头所说的符号查找,也就是该过程的一个逆过程,也就打通了道路。

LR查找符号

我们从堆栈中获取的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_msgSendLR所记录的应该是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并没有给我们一个绝对的位置信息,只有一个stroffsymoff,也就是字符串表偏移量和符号表偏移量,所以我们还需要找出其真正的内存地址。而我们可以从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所展示的调用关系:

calls.png

以上是在模拟器的环境下,那么在真机上是什么表现呢?很遗憾,在真机上,很多私有API的符号都被去掉了,只能显示<redacted>,但是部分公开的API和自己的符号均能被打印。所以还是能帮助我们对问题的分析。

最后

MachO还是一个非常庞大的知识点,而且官方资料也特别少,和很多业务层代码不同,这些内容对开发能力的影响可能不大,毕竟平时业务层的东西很少需要这些东西。但是这些东西有时候能够产生一些新奇的想法和不同的思路。下面简单说几个相关的内容。

C方法的method swizziling,Facebook的fishhook

__attribute__(section("__DATA,custom")),自定义全局对象,React就是采用这种方式自动采集方法列表的。这个思路可以简化很多编码方式,但是可移植性会降低。

C方法的动态调用,我们可以运行时去调用指定的C方法。这个方式危险程度较高,但却是很多高级语言的基础。

参考

KSCrash
MachORuntime

上一篇 下一篇

猜你喜欢

热点阅读