Mach-O 可执行文件

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

  • 从源代码到可执行文件,编译器都做了什么?
  • Mach-O 可执行文件里面是什么?

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

准备工作:Xcode 工具链

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

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

$ xcrun clang -v

而不是:

$ clang -v

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

  • 选择指定 Xcode 版本下的工具
  • 选择指定 Xcode 版本下的 SDK

如果你的电脑上只安装了一个 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 编译成一个可执行文件时,需要经过好几步。

编译器处理的几个步骤:

  • Preprocessing
    • Tokenization
    • Macro expansion
    • #include expansion
  • Parsing and Semantic Analysis
    • Translates preprocessor tokens into a parse tree
    • Applies semantic analysis to the parse tree
    • Outputs an Abstract Syntax Tree (AST)
  • Code Generation and Optimization
    • Translates an AST into low-level intermediate code (LLVM IR)
    • Responsible for optimizing the generated code
    • target-specific code generation
    • Outputs assembly
  • Assembler
    • Translates assembly code into a target object file
  • Linker
    • Merges multiple object files into an executable (or a dynamic library)

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 的具体含义:

  • __PAGEZERO:从上面的信息中可以看出,这块区域占 4 个 G 的大小,不可读不可写,不可执行,
  • __TEXT:代码区,具有只读、可执行的权限
    • __text:编译后生成的机器码
    • __stubs:用于动态链接
    • __stub_helper:用于动态链接
    • __cstring:字面量字符串,也就是写在代码里的字符串
    • __unwind_info
    • __const:常量
  • __DATA:数据区,可读可写,但是不可执行
    • __nl_symbol_ptr:non-lazy symbol pointers,局部符号,也就是定义在该文件内的符号
    • __la_symbol_ptr:lazy symbol pointers,外部符号,也就是定义在该文件外的符号
    • __const:需要重定位的常量
    • __bss:未初始化的静态变量
    • __common:未初始化的外部全局变量
    • __dyld:给动态链接器使用的
  • __LINKEDIT

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 上的应用启动速度。

参考

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

推荐阅读更多精彩内容