运行时获取函数调用栈

阿凡达2018-07-04 09:31

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

原理

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

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

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内容查看和解析,官方有几个命令行工具:

  • 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来说明,使用上更加简单方便。

一个MachO大致分为三部分:

  • Header
  • Load Commands
  • 数据段

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 */
};
数据段

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

我们从堆栈中获取的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所展示的调用关系:

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

最后

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

C方法的method swizziling,Facebook的fishhook

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

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

参考

KSCrash MachORuntime

本文来自网易实践者社区,经作者段家顺授权发布。