Mach-O 可执行文件
这篇文章主要了介绍以下两点:
- 从源代码到可执行文件,编译器都做了什么?
- Mach-O 可执行文件里面是什么?
注:这篇文章的讨论和示例不使用 Xcode,只使用命令行。
准备工作:Xcode 工具链
xcrun 是 Xcode 基本的命令行工具,使用 xcrun 可以调用其他工具。
比如查看 clang 的版本,我们可以执行下面的命令:
$ xcrun clang -v
而不是:
$ clang -v
如果要使用某个工具,直接执行那个工具的命令就行了,为什么要使用 xcrun
呢?
因为如果你的电脑上安装有多个不同版本的 Xcode,借助 xcrun
和 xcode-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
文件?
因为头文件存在的目的,就是为了让我们能通过import
和include
实现在多个不同的文件中共享一些代码(比如函数声明、变量声明和类声明等),这样我们就不用在每个用到相同声明的地方写重复代码了。
得到两个目标文件:
$ 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.o
和 helloworld.o
都用到了 Foundation 框架,helloworld.o
中用到了 autorelease pool,而且 Foo.o
和 helloworld.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 上的应用启动速度。