Hotaru's Notebook

Manually Symbolicate Stack Trace for iOS App

今天我又来大战 Xcode 了, 哎。
ObjC 太垃圾, 垃圾到我用 Rust 写完功能然后链接到 iOS App 里, 但这也带来一个问题: Xcode 不 认识 Rust 也就没办法调试 Rust, 那如果 Rust 代码崩溃了怎么办?
接下来就来讲讲如何手动定位崩溃位置。

注: 本文将使用 stack 和 heap 而不是其中文译名因为它的中文译名极易混淆。

写个 demo

iOS App 工程

用 Xcode 新建一个 iOS App 工程, 文件 main.m 内容如下:

@import UIKit;

#import "AppDelegate.h"

// Prototype of the function where it crashes once called.
void libmansymb_crash(void);

int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }

    libmansymb_crash(); // Crash the app.

    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

再在要编译的目标的 Build Settings 里修改 ENABLE_BITCODE 值为 false, 否则这个目标将 无法与静态库链接。

Rust 静态库工程

再用命令创建一个 Rust 工程:

cargo new --lib libmansymb

在 Rust 工程内的 Cargo.toml 文件里定义一个 lib:

[lib]
name = "mansymb"
crate-type = ["staticlib"]

再修改 Rust 工程的 src/lib.rs 文件, 内容如下:

#[no_mangle]
pub extern "system" fn libmansymb_crash() -> () {
  crash_now();
}

fn crash_now() -> () {
  let to_unwrap: Option<i32> = Option::None;
  let unwrapped = to_unwrap.unwrap();
  println!("unwrapped -> {}", unwrapped);
}

然后用 cargo 编译 Rust 工程为可以给 iOS 用的静态库:

cargo build --target aarch64-apple-ios --lib

编译后的静态库文件在 工程目录/target/aarch64-apple-ios/debug/libmansymb.a, 然后再把 这个静态库链接到之前创建的 iOS App 工程, 然后运行, 然后这个 app 就应该立刻崩溃了, 并且 Xcode 会展示具体的崩溃的位置:

A screenshot dipicts the exact line of rust code where the crash happends.

当然, 这是 Xcode 发挥良好的情况下能让你看到发生崩溃的位置, 当 Xcode 抽风的时候就需要自己动手 了。

Worst-Case Scenario

假如这个软件上架了 App Store, 不久后很多用户给这个软件写差评说是软件打开就崩溃。现在我们假定 以下最糟糕的情况:

  1. 用 Rust 编写的库在编译时使用了 --release 参数;
  2. 文件 Cargo.toml 内打开了各种优化:
    [profile.release]
    strip = true
    opt-level = "z"
    lto = true
    codegen-units = 1
    panic = "abort"
    
  3. 无法使用 Xcode;
  4. 你能找到的错误信息只有第三方平台(比如 Firebase Crashlytics)提供的错误信息;

Crashlytics 会要求开发者上传 symbol table 并自动 symbolicate stack trace, 于是 Crashlytics 给出的 symbolicated stack trace 应该是这样的:

Crashed: com.apple.main-thread
0   libsystem_kernel.dylib        	0x?????? __pthread_kill + 8
1   libsystem_pthread.dylib       	0x?????? pthread_kill + 272
2   libsystem_c.dylib             	0x?????? abort + 104
3   DemoApp                       	0xca58   __rust_alloc + 0
4   DemoApp                       	0xca4c   panic_abort::__rust_start_panic::abort::hf63d08400a9da3bd + 51788 (lib.rs:42)
5   DemoApp                       	0x298a4  std::panic::get_backtrace_style::haac55d35f9a930ed + 170148 (panic.rs:287)
6   DemoApp                       	0x2dc28  std::panicking::rust_panic_with_hook::h450c8571944e8e06 + 187432 (panicking.rs:698)
7   DemoApp                       	0x2d858  std::panicking::begin_panic_handler::_$u7b$$u7b$closure$u7d$$u7d$::hd8204fd933fb4070 + 186456 (panicking.rs:589)
8   DemoApp                       	0x2d7f0  std::panicking::begin_panic_handler::_$u7b$$u7b$closure$u7d$$u7d$::hd8204fd933fb4070 + 186352 (panicking.rs:584)
9   DemoApp                       	0x2d7bc  core::option::Option$LT$T$GT$::unwrap::h4b52fdbeb7909c35 + 186300 (option.rs:750)
10  DemoApp                       	0xd4f3bc core::slice::index::slice_start_index_len_fail::h90de969d8504534e + 13956028 (index.rs:34)
11  DemoApp                       	0xd4f494 core::slice::index::slice_index_order_fail::hd4ba12df6229b864 + 13956244 (index.rs:50)
12  DemoApp                       	0x7c80   addr2line::path_push::h8dcd4c3de6b339f4 + 0
13  DemoApp                       	0x7c2c   main + 31788 (main.m:17)
14  libdyld.dylib                 	0x?????? start + 4

从 Crashlytics 给出的 stack trace 来看, 看上去编译器已经把我们自己写的 Rust 代码优化没了。

如果是从 “Xcode -> Window -> Devices and Simulators -> Your iOS Device -> View Device Logs” 取得的 stack trace 应该是这样的:

...

Exception Type:  EXC_CRASH (SIGABRT)
Exception Codes: 0x0000000000000000, 0x0000000000000000
Exception Note:  EXC_CORPSE_NOTIFY
Triggered by Thread:  0

Application Specific Information:
abort() called

Thread 0 name:  Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0   libsystem_kernel.dylib        	0x00000001bd49b414 0x1bd473000 + 164884
1   libsystem_pthread.dylib       	0x00000001d99b3b40 0x1d99b1000 + 11072
2   libsystem_c.dylib             	0x00000001998c0b74 0x19984a000 + 486260
3   DemoApp                       	0x0000000100368a58 0x10035c000 + 51800
4   DemoApp                       	0x0000000100368a4c 0x10035c000 + 51788
5   DemoApp                       	0x00000001003858a4 0x10035c000 + 170148
6   DemoApp                       	0x0000000100389c28 0x10035c000 + 187432
7   DemoApp                       	0x0000000100389858 0x10035c000 + 186456
8   DemoApp                       	0x00000001003897f0 0x10035c000 + 186352
9   DemoApp                       	0x00000001003897bc 0x10035c000 + 186300
10  DemoApp                       	0x00000001010ab3bc 0x10035c000 + 13956028
11  DemoApp                       	0x00000001010ab494 0x10035c000 + 13956244
12  DemoApp                       	0x0000000100363c80 0x10035c000 + 31872
13  DemoApp                       	0x0000000100363c2c 0x10035c000 + 31788
14  libdyld.dylib                 	0x00000001901d16c0 0x1901d0000 + 5824

Thread 0 crashed with ARM Thread State (64-bit):
    x0: 0x0000000000000000   x1: 0x0000000000000000   x2: 0x0000000000000000   x3: 0x0000000000000000
    x4: 0x0000010100000180   x5: 0x0000000000000000   x6: 0x00000101000001c0   x7: 0x0000000000000100
    x8: 0x00000000000005b9   x9: 0xffcc0a2413708e9d  x10: 0x0000000000000101  x11: 0x0000000000000000
   x12: 0x0000000000000000  x13: 0x00000000000001c0  x14: 0x0000000000000005  x15: 0x00000000000000ca
   x16: 0x0000000000000148  x17: 0x00000001032f38c0  x18: 0x0000000000000000  x19: 0x0000000000000006
   x20: 0x0000000000000407  x21: 0x00000001032f39a0  x22: 0x0000000101225c38  x23: 0x00000001012b7588
   x24: 0x0000000000000001  x25: 0x0000000000000000  x26: 0x0000000000000000  x27: 0x0000000000000000
   x28: 0x000000016faa3ae0   fp: 0x000000016faa3830   lr: 0x00000001d99b3b40
    sp: 0x000000016faa3810   pc: 0x00000001bd49b414 cpsr: 0x40000000
   esr: 0x56000080  Address size fault

Binary Images:
0x10035c000 - 0x101223fff DemoApp arm64  <8b4866a72fc43581b2476ba19303c8b6> /var/containers/Bundle/Application/C35088BC-4B98-4305-8BB6-AFCFC4A42F8A/DemoApp.app/DemoApp

...

虽然 macOS 自带了 atos 这个类似于 addr2line 的工具可以自动 symbolicate stack trace, 但它们是怎么工作的? 以及如何手动 symbolicate stack trace 而不使用这些工具?

Manually Symbolicate Stack Trace

ASLR(Address Space Layout Randomization)

我十几岁的那时候流行用易语言做游戏作弊工具, 当时大陆某视频网站里还有很多比我年龄还小的人讲解如何 写外挂, 其中有个令我印象很深刻的词: 基地加偏移。
在很久以前的计算机上运行的程序都是需要在特定内存地址运行的, 当时计算机里只有一个程序在运行, 在代码内使用绝对地址位置访问内存也没什么问题。
后来计算机飞速发展导致现在许多程序运行在同一个计算机里, 再加上操作系统的存在, 使得程序必须能在 任意内存地址运行, 于是 PIE(Position-Independent Executable) 就诞生了。这就意味着: 每次程序运行的时候, 程序本体都有可能被存放在不同的内存地址里, 也就导致了 上面提到的 “基址” 会有些变化。

上面从 Xcode 给出的崩溃信息里有这样一段内容:

Binary Images:
0x10035c000 - 0x101223fff DemoApp arm64

其中 0x10035c000 就是 DemoApp 的机器码的起始地址(被 MMU 映射后的虚拟地址), 每次软件运行 时都可能是不一样的值, 所以它不是上面所提到的 “基址”。
如果想 symbolicate stack trace 就需要找到 DemoApp 软件的 “基址”, 通过如下命令就能找到它 了:

otool -l ./DemoApp

会得到如下输出:

...
Load command 1
      cmd LC_SEGMENT_64       # Load Command: Segment
  cmdsize 952
  segname __TEXT              # Segment Name
   vmaddr 0x0000000100000000  # Segment starts at
   vmsize 0x0000000000ec8000  # and its size.
  fileoff 0
 filesize 15499264
  maxprot 0x00000005
 initprot 0x00000005
   nsects 11
    flags 0x0
...

在 ELF 文件中 segment name 为 __TEXT 的部分就是机器码的起始位置, 它所在的地址是 0x100000000。上面提到的 DemoApp 在内存里的起始地址是 0x10035c000, 这个值与 __TEXT 的起始位置也就是 0x100000000 的差就是 ASLR Slide 了, 也就是 iOS 把软件载入 到了内存地址 0x100000000 + 0x35c000 的位置, 者差不多就是 “基址加偏移” 的含义了。
(实际上 “基址+偏移” 的偏移是指每次指针从当前地址去往另一个内存地址的差, 只是上面将错就错的用了 这个概念。)

跟踪函数调用

现在再来看在 strack trace 最底部的函数调用:

13  DemoApp                       	0x0000000100363c2c 0x10035c000 + 31788
14  libdyld.dylib                 	0x00000001901d16c0 0x1901d0000 + 5824

看上去是从 libdyld.dylib 跳到了 DemoApp 的地址 0x100363c2c, 把它减去前面算得的 ASLR Slide 0x35c000 得到地址 0x100007c2c, 这个地址就是 DemoApp 的某个函数的某个位置的地址 了。

接下来去看这个函数的汇编, 用 lldb 载入 DemoApp 可执行文件:

lldb ./DemoApp

然后反汇编上面的地址 0x100007c2c:

(lldb) disassemble -a 0x100007c2c

得到如下输出:

DemoApp`main:
DemoApp[0x100007bdc] <+0>:   stp    x22, x21, [sp, #-0x30]!
DemoApp[0x100007be0] <+4>:   stp    x20, x19, [sp, #0x10]
DemoApp[0x100007be4] <+8>:   stp    x29, x30, [sp, #0x20]
DemoApp[0x100007be8] <+12>:  add    x29, sp, #0x20
DemoApp[0x100007bec] <+16>:  mov    x19, x1
DemoApp[0x100007bf0] <+20>:  mov    x20, x0
DemoApp[0x100007bf4] <+24>:  bl     0x100d64a94               ; symbol stub for: objc_autoreleasePoolPush
DemoApp[0x100007bf8] <+28>:  mov    x21, x0
DemoApp[0x100007bfc] <+32>:  adrp   x8, 3923
DemoApp[0x100007c00] <+36>:  ldr    x0, [x8, #0xd80]
DemoApp[0x100007c04] <+40>:  nop
DemoApp[0x100007c08] <+44>:  ldr    x1, [x8, #0x8f0]
DemoApp[0x100007c0c] <+48>:  bl     0x100d64af4               ; symbol stub for: objc_msgSend
DemoApp[0x100007c10] <+52>:  bl     0x100d64548               ; symbol stub for: NSStringFromClass
DemoApp[0x100007c14] <+56>:  mov    x29, x29
DemoApp[0x100007c18] <+60>:  bl     0x100d64b30               ; symbol stub for: objc_retainAutoreleasedReturnValue
DemoApp[0x100007c1c] <+64>:  mov    x22, x0
DemoApp[0x100007c20] <+68>:  mov    x0, x21
DemoApp[0x100007c24] <+72>:  bl     0x100d64a88               ; symbol stub for: objc_autoreleasePoolPop
DemoApp[0x100007c28] <+76>:  bl     0x100007c60               ; libmansymb_crash
DemoApp[0x100007c2c] <+80>:  mov    x0, x20
DemoApp[0x100007c30] <+84>:  mov    x1, x19
DemoApp[0x100007c34] <+88>:  mov    x2, #0x0
DemoApp[0x100007c38] <+92>:  mov    x3, x22
DemoApp[0x100007c3c] <+96>:  bl     0x100d64680               ; symbol stub for: UIApplicationMain
DemoApp[0x100007c40] <+100>: mov    x19, x0
DemoApp[0x100007c44] <+104>: mov    x0, x22
DemoApp[0x100007c48] <+108>: bl     0x100d64b0c               ; symbol stub for: objc_release
DemoApp[0x100007c4c] <+112>: mov    x0, x19
DemoApp[0x100007c50] <+116>: ldp    x29, x30, [sp, #0x20]
DemoApp[0x100007c54] <+120>: ldp    x20, x19, [sp, #0x10]
DemoApp[0x100007c58] <+124>: ldp    x22, x21, [sp], #0x30
DemoApp[0x100007c5c] <+128>: ret

快看, 是 main 函数! 而位于 0x100007c2c 的汇编代码是:

DemoApp[0x100007c28] <+76>:  bl     0x100007c60               ; libmansymb_crash
DemoApp[0x100007c2c] <+80>:  mov    x0, x20                   ; 这一行

0x100007c2c 并没有跳转到 libmansymb_crash 函数, 而上一条汇编指令才是跳转到 libmansymb_crash 函数。
在 Apple 的文档 Examining the Fields in a Crash Report 一文有如下解释:

The address of the machine instruction that is executing. For frame 0 in each backtrace, this is the address of the machine instruction executing on a thread when the process terminated. For other stack frames, this is the address of first machine instruction that executes after control returns to that stack frame.

所以必须回到 0x100007c2c 的上一条指令的地址也就是 0x100007c28 才是调用函数 libmansymb_crash 的地方。

位于 0x100007c28 的汇编指令是:

bl 0x100007c60

也就是跳到地址 0x100007c60, 那就用 lldb 反汇编一下这个地址的函数:

(lldb) disassemble -a 0x100007c60

得到如下输出:

DemoApp`libmansymb_crash:
DemoApp[0x100007c60] <+0>:  stp    x29, x30, [sp, #-0x10]!
DemoApp[0x100007c64] <+4>:  mov    x29, sp
DemoApp[0x100007c68] <+8>:  adrp   x0, 3427
DemoApp[0x100007c6c] <+12>: add    x0, x0, #0xb7b
DemoApp[0x100007c70] <+16>: adrp   x2, 3777
DemoApp[0x100007c74] <+20>: add    x2, x2, #0x860
DemoApp[0x100007c78] <+24>: mov    w1, #0x2b
DemoApp[0x100007c7c] <+28>: bl     0x100d4f45c               ; core::panicking::panic::h60d1e56a524bcbed at panicking.rs:41

函数末尾直接跳到 rust 的 panic 函数了, 除此之外再无任何跳转命令, 也就证实了上面提到的观点:

看上去编译器已经把我们自己写的 Rust 代码优化没了。

说明编译器 100% 确定这段代码一定会 panic。

Symbolicate with dwarfdump

DWARF 是一个标准化的调试数据文件格式, 文件里包含了尽可能详细的调试信息, 工具 addr2line 和 macOS 的 atos 就是通过这个文件将指令地址映射到对应的代码文件的位置的。
使用 dwarfdump 工具可以把文件内容翻译至人类可读的文本格式, 找到随同 DemoApp 的 DWARF 文件, 一般情况下位于 ./DemoApp.app.dSYM/Contents/Resources/DWARF/DemoApp, 然后使用 dwarfdump 将其内容转换为文本格式并保存到一个文件里:

dwarfdump --all ./DemoApp.app.dSYM/Contents/Resources/DWARF/DemoApp > ./DemoApp.dwarfdump.txt

上一小节里有提到 3 个地址, 它们分别是:

地址 含义
0x100007c28 main 函数内调用 libmansymb_crash 函数地址
0x100007c60 函数 libmansymb_crash 的起始地址
0x100d4f45c Rust panic 函数的起始地址

现在再在 DemoApp.dwarfdump.txt 文件内搜索 100007c28, 记得去掉前面的 0x 因为 0x 后面可能会补一些 0 并且搜索时要不区分大小写, 大概会找到这样的内容:

debug_line[0x0000c4ac]
Line table prologue:
    total_length: 0x000001bf
          format: DWARF32
         version: 4
 prologue_length: 0x0000018a
 min_inst_length: 1
max_ops_per_inst: 1
 default_is_stmt: 1
       line_base: -5
      line_range: 14
     opcode_base: 13
standard_opcode_lengths[DW_LNS_copy] = 0
standard_opcode_lengths[DW_LNS_advance_pc] = 1
standard_opcode_lengths[DW_LNS_advance_line] = 1
standard_opcode_lengths[DW_LNS_set_file] = 1
standard_opcode_lengths[DW_LNS_set_column] = 1
standard_opcode_lengths[DW_LNS_negate_stmt] = 0
standard_opcode_lengths[DW_LNS_set_basic_block] = 0
standard_opcode_lengths[DW_LNS_const_add_pc] = 0
standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1
standard_opcode_lengths[DW_LNS_set_prologue_end] = 0
standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0
standard_opcode_lengths[DW_LNS_set_isa] = 1
include_directories[  1] = "DemoVpnApp/src"
include_directories[  2] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.5.sdk/System/Library/Frameworks/Foundation.framework/Headers"
include_directories[  3] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.5.sdk/System/Library/Frameworks/UIKit.framework/Headers"
file_names[  1]:
           name: "main.m"
      dir_index: 1
       mod_time: 0x00000000
         length: 0x00000000
file_names[  2]:
           name: "NSObjCRuntime.h"
      dir_index: 2
       mod_time: 0x00000000
         length: 0x00000000
file_names[  3]:
           name: "UIApplication.h"
      dir_index: 3
       mod_time: 0x00000000
         length: 0x00000000

Address            Line   Column File   ISA Discriminator Flags
------------------ ------ ------ ------ --- ------------- -------------
0x0000000100007bdc      8      0      1   0             0  is_stmt
0x0000000100007bf4     10     22      1   0             0  is_stmt prologue_end
0x0000000100007bfc     12     50      1   0             0  is_stmt
0x0000000100007c10     12     32      1   0             0
0x0000000100007c18      0     32      1   0             0
0x0000000100007c20     13      5      1   0             0  is_stmt
0x0000000100007c28     15      5      1   0             0  is_stmt
0x0000000100007c2c     17     12      1   0             0  is_stmt
0x0000000100007c44     18      1      1   0             0  is_stmt
0x0000000100007c60     18      1      1   0             0  is_stmt end_sequence

这段内容花个几分钟就能看明白了。
那么之前提到的位于 0x100007c28 地址的指令参照上面的表格来看位于文件 main.m#15:5, 打开 main.m 文件定位到第 15 行第 5 列后找到源码的是:

libmansymb_crash(); // Crash the app.

这正是之前调用 rust 崩溃代码的代码。

按照这个思路, 几乎所有的 stack trace 都能找到对应的源代码的位置了。但要注意一点: 编译器优化 开得越高, symbol table 的定位就越不精确。

其实 lldb 可以在反汇编的时候引用 DWARF 文件内容, 使用下面的命令:

(lldb) target symbols add <DwarfFile>

lldb 就能在反汇编的时候带上一些信息, 比如下面代码片段里分号(;)右侧的信息就是来源于 DWARF 文件:

DemoApp[0x100007c7c] <+28>: bl     0x100d4f45c               ; core::panicking::panic::h60d1e56a524bcbed at panicking.rs:41

Conclusion

如果有对线上软件查找错误信息的需求的话, 可以按照下面的方法编译:

  1. 适当的调低或关掉编译器优化;
  2. 链接静态库时保存好对应的 symbol table;
  3. 按照本文的方法跟踪崩溃。

References:

  1. Symbolication: Beyond the basics

#Coding #iOS #stack trace #lldb #Disassemble #ASM