运行时获取函数调用栈

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

  • 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
  • 数据段
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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,881评论 4 368
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,052评论 1 301
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,598评论 0 250
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,407评论 0 217
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,823评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,872评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,037评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,778评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,505评论 1 247
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,745评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,233评论 1 264
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,568评论 3 260
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,231评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,141评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,939评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,954评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,784评论 2 275