在日常开发中经常需要依赖线程的栈信息来解决疑难杂症,但我们自己记录的栈信息是没有符号化过的。本文主要讲了如何符号化这类栈信息,并简单探索了背后的原理。

简介

在解决客户端的疑难杂症时,我们常常需要依赖当前线程的栈信息。比如crash就是系统在收到特定信号的时候记录当前所有线程的信息,然后退出程序。我们就依赖系统提供的栈信息来查明客户端崩溃的原因。既然系统提供了记录线程栈信息的能力,那我们在其他某些非crash的场景下也将这个能力用起来,就能给排查问题带来巨大帮助。

比较有用的场景包括:

  • 页面卡顿
  • 手机发热

iOS系统提供了非常方便的函数来支持打印当前线程的栈信息。

// 输出的是当前调用栈的符号
[NSThread callStackSymbols]

AppStore包输出一般是这样的:

0   DesymNonCrashStack                  0x000000010b8dc58c <redacted> + 348
1   UIKit                               0x000000010cf7b01a <redacted> + 1235
......
18  DesymNonCrashStack                  0x000000010b8dc94f <redacted> + 111
19  libdyld.dylib                       0x000000010c83065d <redacted> + 1
20  ???                                 0x0000000000000001 0x0 + 1

看到日志里面输出这个,一般都是直接懵逼的,需要解析之后才能恢复其庐山真面目。

符号化

atos

这里要用到的解析工具atos在上一篇文章已经提到过了。先来回顾一下atos的用法。

xcrun atos -o SampleApp.app.dSYM/Contents/Resources/DWARF/SampleApp -l 0x29000 -arch armv7

用atos来解符号需要传一个关键参数: -l

  -l <load-address> The load address of the binary image.

也就是说我们先要找到image的加载地址。

image加载地址

怎么拿到image的加载地址呢?

iOS系统和很多Linux系统都有动态库加载相关的一个头文件#include <dlfcn.h>。该头文件提供了动态库加载相关的函数,比如dlopendlsymdlclose等等我们比较熟悉的方法。iOS系统的dlfcn.h还提供了一个方法:

typedef struct dl_info {
        const char      *dli_fname;     /* Pathname of shared object */
        void            *dli_fbase;     /* Base address of shared object */
        const char      *dli_sname;     /* Name of nearest symbol */
        void            *dli_saddr;     /* Address of nearest symbol */
} Dl_info;

extern int dladdr(const void *, Dl_info *);

函数说明如下:

The function dladdr() takes a function pointer and tries to resolve name and file where it is located. Information is stored in the Dl_info structure。

说明很直截了当,使用该函数能知道函数所在库的名字和加载地址。很好,通过这个函数就能拿到image加载地址了。话不多说,直接上代码。

Dl_info info = {0};
dladdr(__builtin_return_address(0), &info);
NSString *dladdrStr = [NSString stringWithFormat:@"DLAddrStr: fname=%s, fbase=%p, sname=%s, saddr=%p", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr];
NSLog(@"%@", dladdrStr);

// output
// 2017-08-02 23:54:33.011 DesymNonCrashStack[60581:436913] DLAddrStr: 
// fname=/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks/UIKit.framework/UIKit,
// fbase=0x10cdb0000, 
// sname=-[UIViewController loadViewIfRequired], 
// saddr=0x10cf7ab47

好了有image的加载地址了,我们尝试解析一下。

xcrun atos -o /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks/UIKit.framework/UIKit -l 0x10cdb0000 -arch x86_64
0x000000010cf7b01a

# output
-[UIViewController loadViewIfRequired] (in UIKit) + 1235

dladdr函数返回的sname和我们自己解析出来的结果是一样的。

但是用这种方法只能记录一个image的加载地址,我想要知道所有image的加载地址怎么搞呢?自然是需要更多的api了!

uint32_t imageCount = _dyld_image_count();
for (uint32_t i=0; i<imageCount; i++) {
  NSLog(@"name: %s", _dyld_get_image_name(i));
  NSLog(@"slide :%#08lx", _dyld_get_image_vmaddr_slide(i));
  NSLog(@"load addr: %#08lx", (uintptr_t)_dyld_get_image_header(i));
}

// output
// 2017-08-02 23:54:32.768 DesymNonCrashStack[60581:436913] name: xxx/usr/lib/dyld_sim
// 2017-08-02 23:54:32.769 DesymNonCrashStack[60581:436913] slide :0x10b8e3000
// 2017-08-02 23:54:32.769 DesymNonCrashStack[60581:436913] load addr: 0x10b8e3000
// ......
// 2017-08-02 23:54:32.769 DesymNonCrashStack[60581:436913] name: xxx/DesymNonCrashStack
// 2017-08-02 23:54:32.769 DesymNonCrashStack[60581:436913] slide :0xb8db000
// 2017-08-02 23:54:32.769 DesymNonCrashStack[60581:436913] load addr: 0x10b8db000

总结来说,如果是自己通过调用[NSThread callStackSymbols]方法打印栈信息,一定记得把image的加载地址也要打出来,然后调用atos来解析,否则就只能面对一堆数字仰天长叹了。

原理

为什么只有有了加载地址才能解析出符号呢?

符号表

符号表就相当于字典,我们符号化,实际上就是拿着地址到符号表里面去查,将一个一个地址翻译成符号。UIKit的符号表我们可以通过MachOView工具来查看。MachOView就是那个图标是被咬了一口的烂苹果的小工具,非常好用。

UIKit符号表

从上图可以看出,符号表中就是一堆符号,每个符号都有一个对应的地址,所以我们接下来要做的就是根据调用栈地址找到正确的符号地址

调用栈

当调用[NSThread callStackSymbols]的时候,我们得到的是一个调用栈。wikipedia给了我们一个非常好的例子。

call stack example

上面这个例子是DrawSquare调用DrawLine。可以看到一个调用栈是由一堆栈帧(stack frame)组成的,每个栈帧都有一个返回地址(return address),返回地址就是返回调用者的地址。那我们上面的例子来说。

1 UIKit 0x000000010cf7b01a + 1235

0x000000010cf7b01a是返回地址,这个返回地址是真实的物理地址,并不是我们想要的符号地址。从返回地址得到符号地址我们还需要了解ASLR这个概念。

ASLR

ASLR全称是address space layout randomization,也即是位址空间布局随机化。ASLR通过随机安排关键数据的地址空间防止恶意程序对已知地址进行攻击。也正是因为这个随机地址,导致我们不能轻松地根据符号表和返回地址来实现调用栈的符号化。在上面的例子中,UIKit的ASLR随机地址就是下面的slide地址。

2017-08-02 23:54:32.773 DesymNonCrashStack[60581:436913] name: xxx/System/Library/Frameworks/UIKit.framework/UIKit
2017-08-02 23:54:32.773 DesymNonCrashStack[60581:436913] slide :0x10cdb0000
2017-08-02 23:54:32.773 DesymNonCrashStack[60581:436913] load addr: 0x10cdb0000

这个地址是每次运行程序的时候随机生成的,所以只能在运行之后拿到,有了这个地址,我们就知道程序的起始加载地址是0x10cdb0000了。

这里额外提一句,并不是每个程序的加载地址都和slide是一样的。DesymNonCrashStack可执行文件的加载地址要比ASLR偏移地址多了0x100000000

2017-08-02 23:54:32.769 DesymNonCrashStack[60581:436913] name: xxx/DesymNonCrashStack.app/DesymNonCrashStack
2017-08-02 23:54:32.769 DesymNonCrashStack[60581:436913] slide :0xb8db000
2017-08-02 23:54:32.769 DesymNonCrashStack[60581:436913] load addr: 0x10b8db000

有心的朋友可能记得,前面我们提到过,加载命令决定了代码和数据在内存中的分配,那我们现在就来看看为啥多出来了这个0x100000000

__PAGEZERO

可以发现,在__TEXT代码段之前,有个__PAGEZERO段,其VM大小正好是0x100000000。查了文档,__PAGEZERO主要是用来捕获对NULL指针访问的,可以马上停止程序执行。正式因为这个__PAGEZERO段的存在,导致代码的加载地址多了0x100000000

解析符号

算符号地址的公式如下:

返回地址 - 加载地址 - 偏移量 = 符号地址(也就是符号表中对应符号的地址)

公式也比较好理解,返回函数物理地址,减去加载物理地址,得到相对地址,也就是虚拟地址,然后减去偏移,就得到符号地址了。 把我们上面得到的数据代入公式可以算出符号地址为:

0x10cf7b01a - 0x10cdb0000 - 1235 = 0x1cab47

loadviewIfRequired符号表

一查表,我们就知道了调用栈中的0x10cf7b01a的符号是-[UIViewController loadViewIfRequired]了。

后记

本文涉及的很多概念都是跟Mach-O相关的,上面我们说的image其实就是一种Mach-O文件。 Mach-O文件在苹果文件系统中有非常重要的作用,后面再写篇文章详细阐述Mach-O文件的结构和作用。

参考文献

微信一键关注