iOS开发之常用技术点iOS高级开发Runtime源码

Mach-O 可执行文件

2019-02-02  本文已影响27人  ShannonChenCHN

这篇文章主要了介绍以下两点:

注:这篇文章的讨论和示例不使用 Xcode,只使用命令行。

准备工作:Xcode 工具链

xcrun 是 Xcode 基本的命令行工具,使用 xcrun 可以调用其他工具。

比如查看 clang 的版本,我们可以执行下面的命令:

$ xcrun clang -v

而不是:

$ clang -v

如果要使用某个工具,直接执行那个工具的命令就行了,为什么要使用 xcrun 呢?
因为如果你的电脑上安装有多个不同版本的 Xcode,借助 xcrunxcode-select 你可以:

如果你的电脑上只安装了一个 Xcode,就没必要使用 xcrun 了。

一、不使用 IDE 来实现一个 Hello World

使用 clang 编译一个简单的 Hello World 小程序,然后就可以直接执行最后生成的 a.out 文件了。

编写 helloworld.c:

#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("Hello World!\n");
    return 0;
}

然后使用 clang 将该文件编译成一个 Mach-O 二进制文件 a.out,并执行这个 a.out 文件:

$ xcrun clang helloworld.c
$ ./a.out

最终可以看到终端上输出了 Hello World!

这个 a.out 是怎么生成的呢?

二、编译器是如何工作的

在上面的例子中,我们所选用的编译器是 clang,编译器在将 helloworld.c 编译成一个可执行文件时,需要经过好几步。

编译器处理的几个步骤:

1. 预处理

这个过程主要是对源代码进行标记拆分、宏展开、#include 展开等等。

使用下面的命令可以看到 helloworld.c 预处理后的结果:

$ xcrun clang -E helloworld.c

我们也可以将输出的结果在文本编辑器中打开:

$ xcrun clang -E helloworld.c | open -f

最后得到的预处理结果大概有 542 行:

...

# 52 "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.14.sdk/usr/include/secure/_stdio.h" 3 4
extern int __snprintf_chk (char * restrict, size_t, int, size_t,
      const char * restrict, ...);

extern int __vsprintf_chk (char * restrict, int, size_t,
      const char * restrict, va_list);

extern int __vsnprintf_chk (char * restrict, size_t, int, size_t,
       const char * restrict, va_list);
# 412 "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.14.sdk/usr/include/stdio.h" 2 3 4
# 2 "helloworld.c" 2
int main(int argc, char *argv[])
{
    printf("Hello World!\n");
    return 0;
}

与处理结果中那些 # 开头的语句表示行标记(linemarker),告诉我们后面接下来的内容来自哪个文件的哪一行。

helloworld.c 中的 #include <stdio.h> 告诉预处理器要在那个地方插入 stdio.h 的内容。这是一个递归的过程,如果 stdio.h 中也引入了其他的 .h 文件,在预处理时同样也会把这些语句替换成源文件中的内容。

Tips: 在 Xcode 中打开菜单 Product -> Perform Action -> Preprocess,可以查看当前打开文件的预处理结果。

2. 编译

这个过程主要是对预处理后的代码进行语法分析、语义分析,并生成语法树(AST),然后再翻译成中间代码,并优化代码,最后再针对不同平台生成对应的代码,并转成汇编代码。

我们可以使用下面的命令生成汇编代码:

$ xcrun clang -S -o - helloworld.c | open -f

生成的汇编代码如下:

  .section  __TEXT,__text,regular,pure_instructions
  .macosx_version_min 10, 13
  .globl  _main                   ## -- Begin function main
  .p2align  4, 0x90
_main:                                  ## @main
  .cfi_startproc
## %bb.0:
  pushq %rbp
  .cfi_def_cfa_offset 16
  .cfi_offset %rbp, -16
  movq  %rsp, %rbp
  .cfi_def_cfa_register %rbp
  subq  $32, %rsp
  leaq  L_.str(%rip), %rax
  movl  $0, -4(%rbp)
  movl  %edi, -8(%rbp)
  movq  %rsi, -16(%rbp)
  movq  %rax, %rdi
  movb  $0, %al
  callq _printf
  xorl  %ecx, %ecx
  movl  %eax, -20(%rbp)         ## 4-byte Spill
  movl  %ecx, %eax
  addq  $32, %rsp
  popq  %rbp
  retq
  .cfi_endproc
                                        ## -- End function
  .section  __TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str
  .asciz  "Hello World!\n"


.subsections_via_symbols

. 开头的是汇编器的指令。

.section 指令表示的是接下来的 section 是什么内容。

.globl 指令表示 _main 是一个外部符号,也就是要暴露给其他模块使用的符号。

.p2align 指令表示的是字节对齐的规则是什么。

.cfi_startproc 表示一个函数的开始,相应地,.cfi_endproc表示一个函数的结束。cfi 是 Call Frame Information 的缩写。

.cfi_def_cfa_offset 16.cfi_offset %rbp, -16 也是 cfi 指令,用来输出一些函数堆栈展开信息和调试信息的。

L_.str 标签可以让我们在代码中通过指针访问到一个字符串常量。

.asciz 命令告诉汇编器输出一个字面量字符串。

最后的 .subsections_via_symbols 是留给静态链接编辑器使用的。

Tips: 类似地,在 Xcode 中打开菜单 Product -> Perform Action -> Assemble,可以查看当前打开文件的汇编代码。

3. 汇编

汇编的过程就是将汇编代码翻译成机器代码,生成目标文件。

当你用 Xcode 构建你的 iOS App 时,你可以在你的项目的 Derived Data 目录下找到一个 Objects-normal 文件夹,里面就是 .m 文件编译后生成的目标文件。

4. 链接

链接器负责将各个目标文件和库合并成一个完整的可执行文件。在这个过程中,链接器需要解析各个目标文件和库之间的符号引用。

helloworld.c 中调用了 printf() 函数,这个函数定义在 libc 库中,但是最终的可执行文件需要知道 printf() 在内存中的什么地方,也就是 _printf 符号的地址。

链接器在链接时就会把所有的目标文件(在我们这个例子中就是 helloworld.o)和库(libc)作为输入文件,然后解析它们之间符号引用(_printf 符号),最终生成一个可以运行的可执行文件。

二、可执行文件

一个可执行文件中包含多个不同的 segment,,一个 segment 又包含一个或多个 section。

我们可以使用 size 工具查看目标文件中的各个 section:

xcrun size -x -l -m a.out 

下面是 helloworld.c 的目标文件的各个 segment 和 section 的内容:

Segment __PAGEZERO: 0x100000000 (vmaddr 0x0 fileoff 0)
Segment __TEXT: 0x1000 (vmaddr 0x100000000 fileoff 0)
  Section __text: 0x34 (addr 0x100000f50 offset 3920)
  Section __stubs: 0x6 (addr 0x100000f84 offset 3972)
  Section __stub_helper: 0x1a (addr 0x100000f8c offset 3980)
  Section __cstring: 0xe (addr 0x100000fa6 offset 4006)
  Section __unwind_info: 0x48 (addr 0x100000fb4 offset 4020)
  total 0xaa
Segment __DATA: 0x1000 (vmaddr 0x100001000 fileoff 4096)
  Section __nl_symbol_ptr: 0x10 (addr 0x100001000 offset 4096)
  Section __la_symbol_ptr: 0x8 (addr 0x100001010 offset 4112)
  total 0x18
Segment __LINKEDIT: 0x1000 (vmaddr 0x100002000 fileoff 8192)
total 0x100003000

当我们运行可执行文件时,系统会把各个 segment 映射到进程的地址空间中,在映射时,各个 segment 和 section 被分配不同的属性,也就是权限。

我们来看看各个 segment 和 section 的具体含义:

1. Section Content

我们可以使用 otool 查看目标文件中指定 section 的内容:

xcrun otool -s __TEXT __text a.out 

得到的结果如下:

a.out:
Contents of (__TEXT,__text) section
0000000100000f50  55 48 89 e5 48 83 ec 20 48 8d 05 47 00 00 00 c7
0000000100000f60  45 fc 00 00 00 00 89 7d f8 48 89 75 f0 48 89 c7
0000000100000f70  b0 00 e8 0d 00 00 00 31 c9 89 45 ec 89 c8 48 83
0000000100000f80  c4 20 5d c3

上面的机器代码几乎没办法看懂,不过我们可以使用 otool 来查看反汇编后的代码:

xcrun otool -v -t a.out

得到的结果如下:

a.out:
(__TEXT,__text) section
_main:
0000000100000f50  pushq %rbp
0000000100000f51  movq  %rsp, %rbp
0000000100000f54  subq  $0x20, %rsp
0000000100000f58  leaq  0x47(%rip), %rax
0000000100000f5f  movl  $0x0, -0x4(%rbp)
0000000100000f66  movl  %edi, -0x8(%rbp)
0000000100000f69  movq  %rsi, -0x10(%rbp)
0000000100000f6d  movq  %rax, %rdi
0000000100000f70  movb  $0x0, %al
0000000100000f72  callq 0x100000f84
0000000100000f77  xorl  %ecx, %ecx
0000000100000f79  movl  %eax, -0x14(%rbp)
0000000100000f7c  movl  %ecx, %eax
0000000100000f7e  addq  $0x20, %rsp
0000000100000f82  popq  %rbp
0000000100000f83  retq

2. Mach-O

Mach-O 是 Mach object file 格式的缩写,Mach-O 是一种可执行文件,Mac OS 上的可执行文件都是 Mach-O 格式的。

使用下面的命令可以查看一下 a.out 的文件格式:

$ file a.out 
a.out: Mach-O 64-bit executable x86_64

我们可以使用 otool 查看可执行文件的 Mach-O header:

$ otool -v -h a.out

得到的结果如下:

Mach header
      magic cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
MH_MAGIC_64  X86_64        ALL LIB64     EXECUTE    15       1200   NOUNDEFS DYLDLINK TWOLEVEL PIE

ncmds 和 sizeofcmds 表示的是加载命令(load commands),可以通过 -l 参数查看详细信息:

otool -v -l a.out | open -f
a.out:
Load command 0
      cmd LC_SEGMENT_64
  cmdsize 72
  segname __PAGEZERO
   vmaddr 0x0000000000000000
   vmsize 0x0000000100000000
...

找到 Load command 1 部分的 initprot 字段,其值为 r-x,表示 read-only 和 executable。

load command 指定了每一个 segment 和每个 section 的内存地址以及权限保护。

下面是 __TEXT __text section 的信息:

...
Section
  sectname __text
   segname __TEXT
      addr 0x0000000100000f50
      size 0x0000000000000034
    offset 3920
     align 2^4 (16)
    reloff 0
    nreloc 0
      type S_REGULAR
attributes PURE_INSTRUCTIONS SOME_INSTRUCTIONS
 reserved1 0
 reserved2 0

 ...

这段代码的 addr 值是 0x0000000100000f50,跟上面用 xcrun otool -v -t a.out 查看的 _main 的入口地址是一样的。

三、一个更复杂的例子

我们现在有三个文件,Foo.h、Foo.m 和 helloworld.m,如下。

Foo.h:

#import <Foundation/Foundation.h>

@interface Foo : NSObject

- (void)run;

@end

Foo.m:

#import "Foo.h"

@implementation Foo

- (void)run
{
    NSLog(@"%@", NSFullUserName());
}

@end

helloworld.m:

#import "Foo.h"

int main(int argc, char *argv[])
{
    @autoreleasepool {
        Foo *foo = [[Foo alloc] init];
        [foo run];
        return 0;
    }
}

1. 编译

分别编译 Foo.m 和 helloworld.m 这两个文件:

$ xcrun clang -c Foo.m
$ xcrun clang -c helloworld.m

问题:为什么我们不需要编译 .h 文件?
因为头文件存在的目的,就是为了让我们能通过 importinclude 实现在多个不同的文件中共享一些代码(比如函数声明、变量声明和类声明等),这样我们就不用在每个用到相同声明的地方写重复代码了。

得到两个目标文件:

$ file Foo.o helloworld.o
Foo.o:        Mach-O 64-bit object x86_64
helloworld.o: Mach-O 64-bit object x86_64

为了能够得到一个可执行文件,我们需要将这两个目标文件以及 Foundation 框架链接起来:


xcrun clang helloworld.o Foo.o -Wl,`xcrun --show-sdk-path`/System/Library/Frameworks/Foundation.framework/Foundation

我们会得到一个最终的可执行文件 a.out,然后我们在执行这个文件,可以看到打印的结果:

$ ./a.out
2019-02-02 17:27:18.207 a.out[4181:265495] ShannonChen

2. 符号解析和链接

Foo.ohelloworld.o 都用到了 Foundation 框架,helloworld.o 中用到了 autorelease pool,而且 Foo.ohelloworld.o 都在 libobjc.dylib 的帮助下间接使用了 Objective-C runtime,因为 Objective-C 方法调用时发送消息需要用到 runtime。

什么是符号?

每一个我们定义的或者用到的函数、全局变量和类都是符号。

在链接时,链接器会解析各个目标文件以及库之间的符号,每个目标文件都有一个符号表来说明它的符号。

我们可以使用工具 nm 来查看目标文件 helloworld.o 的所有符号:

$ xcrun nm -nm helloworld.o
                 (undefined) external _OBJC_CLASS_$_Foo
                 (undefined) external _objc_autoreleasePoolPop
                 (undefined) external _objc_autoreleasePoolPush
                 (undefined) external _objc_msgSend
0000000000000000 (__TEXT,__text) external _main

_OBJC_CLASS_$_Foo 符号就是我们定义的 Objective-C 类 Foo,我可以看到,这个符号的解析状态是 undefined(因为 helloworld.o 中引用了 Foo 类,但是没有定义这个类),属性是 external(表示这个 Foo 类不是私有的)。

_main 符号对应的就是我们的 main() 函数,它的属性也是 external,因为它是入口函数,需要暴露出来被系统调用(值得注意的是,它的地址是 0)。

然后,我们再看看目标文件 Foo.o 中的所有符号:

xcrun nm -nm Foo.o
                 (undefined) external _NSFullUserName
                 (undefined) external _NSLog
                 (undefined) external _OBJC_CLASS_$_NSObject
                 (undefined) external _OBJC_METACLASS_$_NSObject
                 (undefined) external ___CFConstantStringClassReference
                 (undefined) external __objc_empty_cache
0000000000000000 (__TEXT,__text) non-external -[Foo run]
0000000000000060 (__DATA,__objc_const) non-external l_OBJC_METACLASS_RO_$_Foo
00000000000000a8 (__DATA,__objc_const) non-external l_OBJC_$_INSTANCE_METHODS_Foo
00000000000000c8 (__DATA,__objc_const) non-external l_OBJC_CLASS_RO_$_Foo
0000000000000110 (__DATA,__objc_data) external _OBJC_METACLASS_$_Foo
0000000000000138 (__DATA,__objc_data) external _OBJC_CLASS_$_Foo

在这里,_OBJC_CLASS_$_Foo 符号不再是 undefined 的了,因为 Foo.o 中定义了 Foo 这个类。

当这两个目标文件和 Foundation 库链接时,链接器就会根据上面的这些符号表解析目标文件中的符号,解析成功后就能知道这个符号的地址了。

最后,我们再看看最终生成的可执行文件的符号表信息:

xcrun nm -nm a.out
                 (undefined) external _NSFullUserName (from Foundation)
                 (undefined) external _NSLog (from Foundation)
                 (undefined) external _OBJC_CLASS_$_NSObject (from libobjc)
                 (undefined) external _OBJC_METACLASS_$_NSObject (from libobjc)
                 (undefined) external ___CFConstantStringClassReference (from CoreFoundation)
                 (undefined) external __objc_empty_cache (from libobjc)
                 (undefined) external _objc_autoreleasePoolPop (from libobjc)
                 (undefined) external _objc_autoreleasePoolPush (from libobjc)
                 (undefined) external _objc_msgSend (from libobjc)
                 (undefined) external dyld_stub_binder (from libSystem)
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100000e90 (__TEXT,__text) external _main
0000000100000f10 (__TEXT,__text) non-external -[Foo run]
0000000100001138 (__DATA,__objc_data) external _OBJC_METACLASS_$_Foo
0000000100001160 (__DATA,__objc_data) external _OBJC_CLASS_$_Foo

我们可以看到,跟 Foundation 和 Objective-C runtime 相关的符号依然是 undefined 状态(这些需要在加载程序进行动态链接时来解析),但是这个符号表中已经有了如何解析这些符号的信息,也就是从哪里可以找到这些符号。

比如,符号 _NSLog 后面有一个 from Foundation 的说明,这样在动态链接时就知道是去 Foundation 库找 _NSLog 这个符号的定义了。

而且,可执行文件知道去哪里找到这些需要参与链接的动态库:

$ xcrun otool -L a.out
a.out:
  /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 1555.10.0)
  /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1252.200.5)
  /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 1555.10.0)
  /usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)

这些 undefined symbols 会在运行时被动态链接器 dyld 解析,当我们运行这个可执行文件时, dyld 可以保证 _NSFullUserName 这些符号能够指向它们在 Foundation 以及其他动态库中的实现。

3. dyld 的共享缓存

有些应用程序可能会用到大量的 framework 和动态库,这样在链接时就会有成千上万的符号需要解析,从而影响链接速度。

为了缩短这个流程,在 macOS 和 iOS 上会针对每个架构,预先将所有的动态库链接成一个库,缓存到 /var/db/dyld/ 目录下。当一个 Mach-O 文件被加载到内存中时,动态链接器首先去缓存目录中检查是否有缓存,如果有就直接使用缓存好的动态库。通过这种方式,大大提高了 macOS 和 iOS 上的应用启动速度。

参考

上一篇下一篇

猜你喜欢

热点阅读