objc系列译文(6.3):Mach-O 可执行文件

当我们在Xcode中构建一个程序的时候,其中有一部分就是把源文件(.m和.h)文件转变成可执行文件。这个可执行文件包含了将会在CPU(iOS设备上的arm处理器或者你mac上的Intel处理器)运行的字节码。

我们将会过一遍编译器这个过程的做了些什么,同时也看一下可执行文件的内部到底是怎样的。其实,里面的东西比你看到的要多很多。

让我们先把Xcode放一边,踏入Commond-Lines的大陆。当我们在Xcode中构建一个App时,Xcode只是简单的调用了一系列的工具而已。希望这将会让你更好的明白一个可执行文件(被称之为Mach-O可执行文件),是怎样组装起来的,并且是怎样在iOS或者os x上执行的

 

XCrun

先从一些基础性的东西开始:我们将会使用一个叫做Xcrun的命令行工具。他看起来很奇怪,但是的确相当出色。这个小工具是用来调用其他工具的。 原先的时候我们执行:

现在在终端中,我们可以执行:

Xcrun定位Clang,并且使用相关的参数来执行Clang。

为什么我们要做这个事情?这看起来毫无重点,胡扯八道。但是Xcrun允许我们使用多个版本的Xcode,或者使用特定Xcode版本里面的工具,或者针对特点的SDK使用不同的工具。如果你恰好有Xcode4.5和xcode5、使用xcode-select和xcrun你可以选择选择使用来自Xcode4.5里面的SDK的工具,或者来自Xcode5里面的SDK的工具。在大多数其他平台上,这将是一个不可能的事情。如果你看一下帮助手册上xcode-select和xcrun的一些细节。你就能在不安装命令行工具的情况下,使用在终端中使用开发者工具。

 

一个不使用IDE的Hello World

回到终端,创建一个包含一个c文件的目录:

现在使用你喜欢的文本编辑器来编辑这个文件,例如TextEdit.app:

录入下面的代码:

保存,并且回到终端执行:

现在你能够在终端上看到熟悉的Hello World!。你编译了一个C程序并且执行了它。所有都是在不使用IDE的情况下做的。深呼吸一下,高兴高兴。

我们在这里做了些什么?我们将hellowrold.c编译成了叫a.out的Mach-o二进制文件。a.out是编译器的默认名字,除非你指定一个别的。

 

Hello World和编译器

现在可选择的编译器是Clang(读作:/’kl /)。Chris写了一些更多关于Clang细节的介绍,可以参考: about the compiler

概括一下就是,编译器将会读入处理hellowrold.c,输出可执行文件a.out。这个过程包含了非常多的步骤。我们所要做的就是正确的执行它们。


预处理:

  • 序列化
  • 宏定义展开
  • #include展开(引用文件展开)

语法和语义分析:

  • 使用预处理后的单词构建词法树
  • 执行语义分析生成语法树
  • 输出AST (Abstract Syntax Tree)

代码生成和优化

  • 将AST转化成更低级的中间码(LLVM IR)
  • 优化生成代码
  • 目标代码生成
  • 输出汇编代码

汇编程序

  • 将汇编代码转化成目标文件

连接器

  • 将多个目标文件合并成可执行文件(或者一个动态库) 我们来看一个关于这些步骤的简单的例子。

 

预处理

编译器将做的第一件事情是处理文件。使用Clang展示一下这个过程:

欧耶。输出了413行内容。打开个编辑器看看到底发生了什么:

在文件顶部我们能看到很多以”#”开头的行。这些被称之为行标记语句的语句告诉我们它后面的内容来自哪里。我们需要这个。如果我再看一下hellowrold.c,第一行是:

我们都用过#include和#import。它们做的就是告诉于处理器在#include语句的地方插入stdio.h的内容。在刚刚的文件里就是插入了一个以#开头的行标记。跟在#后面的数字是在源文件中的行号。每一行最后的数字是在新文件中的行号。回到刚才打开的文件,接下来是系统头文件,或者一些被看成包裹着extern “C”的文件。

如果你滚动到文件末尾,你将会发现我们的helloworld.c的代码:

在Xcode中,你可以通过使用Product->Perform Action-> Preprocess来查看任何一个文件的预处理输出。一定要注意这将会花费一些时间来加载预处理输出文件(接近100,000行)。

 

编译

下一个步骤:文本处理和代码生成。我们可以调用clang输出汇编代码就像这样:

看一看输出。我们首先注意到的是一些以点开头的行。这些是汇编指令。其他的是真正的x86_64汇编代码。最后是些标记,就像C中的那些标记一样。

我们从前三行开始:

这三行是汇编指令,不是汇编代码。”.section”指令指出了哪一个段接下来将会被执行。比用二进制表示好看多了。

下一个,.global指令说明_main是一个外部符号。这就是我们的main()函数。它能够从我们的二进制文件之外看到,因为系统要调用它来运行可执行文件。

.align指令指出了下面代码的对齐方式。从我们的角度看,接下来的代码将会按照16比特对齐并且如果需要的时候用0x90补齐。

下面是main函数的头部:

这一部分有一些和C标记工作机制一样的一些标记。它们是某些特定部分的汇编代码的符号链接。首先是_main函数真正的开始地址。这个也是被抛出的符号。二进制文件将会在这个地方产生一个引用。

.cfi_startproc指令一半会在函数开始的地方使用。CFI是Call Frame Information的缩写。帧松散的与一个函数交互。当你使用调试器,并且单步执行的时候,你实际上是在调用帧中跳转。在C代码中,函数有自己的调用帧,除了函数之外的一些结构也会有调用站。.cfi_startproc指令给了函数一个.en_frame的入口,这个入口包含了堆栈展开信息(表示异常如何展开调用帧堆栈)。这个指令也会发送一些和具体平台相关的指令给CFI。文件后面的.cfi_endproc与.cfi_startproc相匹配,来表示结束main函数。

下一步,这里有另外一个Label ## BB#0.然后,终于来了第一句汇编代码:pushq %rbp。从这里开始事情开始变得有趣。在OS X上,我们将会有x84_64的代码。对于这种架构,有一个东西叫做ABI(application binary interface),ABI表示函数调用是怎样在汇编代码层面上工作的。ABI指出在函数调用时,rbp寄存器必须被保护起来。这是main函数的责任,来确保返回时,rbp寄存器中有数据。pushq %rbp将它的数据推进堆栈,以便我们以后使用。

下面是,两个CFI指令: .cfi_def_cfa_offset 16 和 .cfi_offset %rbp, -16. 这将会输出一些信息,这些信息是关于生成调用堆栈展开信息和调试信息的。我们改变了堆栈,并且这两个指令告诉编译器指针指向哪里,或者它们说出了之后调试器将会使用的信息。

现在movq %rsp, %rbp将会把局部变量加载进堆栈。subq $32,%rsp将堆栈指针移动32比特,也就是函数将会调用的位置。我们先在rbp中存储了老的堆栈指针,然后将此作为我们局部变量的基址,然后我们更新堆栈指针到我们将会使用的位置。

之后,我们调用了printf():

首先,leaq加载到L_.str的指针到寄存器rax。注意L_.str标记是怎样在下面的代码中定义的。它就是C字符串“hello world!\n”。寄存器edi和rsi保存了函数的第一个和第二个参数。直到我们调用其他函数,我们第一步需要存储它们当前值。这就是为什么我们使用刚刚存储的rbp偏移32比特的原因。第一个32比特是零,之后32个比特是edi的值(存储了argc),然后是64bit的rsi寄存器的值。我们在后面不会使用这些数据。但是如果编译器没有使用优化的时候,它们还是会被存下来。

现在,我们将会把第一个函数(printf)的参数加载进寄存器edi。printf函数是一个可变参数的函数。ABI调用约定指定,将会把使用来存储参数的寄存器数量存储在寄存器al中。对我们来讲是0。最后callq调用了printf函数。

这将设置ecx寄存的值为0,并且把eax的值压栈。然后从ecx复制0到eax。ABI指定eax将会存储函数的返回值,我们man函数的返回值是0:

函数执行完成后,将恢复堆栈指针,通过上移32bit在rsp中的堆栈指针。我们将会出栈我们早先存储的rbp的值,然后调用ret来返回,ret将会读取离开堆栈的地址。.cfi_endproc平衡了.cfi_startproc指令。

下一步是一个字一个字的输出我们的字符串:“hello world!\n”:

之后.section指令指出下面将要跳入的段。L_.str标记允许获取一个字符转的指针。.asciz指定告诉汇编器输出一个0的字符串结尾。

__TEXT __cstring开始了一个新的段。这个段包含了C字符串:

这两行创建了一个没有结束符的字符创。注意L_.str是怎样命名,和来获取字符串的。

最后.subseciton_via_symbols指令是静态链接编辑器使用的。

更多关于汇编指令的信息可以从苹果的Apple’s assemebler reference获取。AMD64网站有关于ABI for x86的文档。同时也有Gentle Introduction to x86-64 Assemble。 再一次,Xcode允许你查看任何文件的汇编代码通过 Product->Perform Action -> Assemble.

 

汇编编译器:

汇编编译器,只是简单的将汇编代码转换成机器码。它创建了一个目标文件。这些文件以.o结尾。如果你使用Xcode构建一个app,你将会在Derived Data目录下面的你的工程目录中的objects-normal目录下面发现这些文件。

 

连接器:

我们将会多谈一点关于链接的东西。但是简单的说,连接器确定了目标文件和库之间的链接。这是什么意思? 重新调用 callq _printf. printf是在libc库中的一个函数。无论怎样,最后的可执行文件需要能知道printf()在内存中的什么位置。例如符号_printf的地址。连接器将会读取所有的目标文件,所有的库和结束任何未定义的符号。然后将它们编码进最后的可执行文件,然后输出最后的可执行文件:a.out。

 

就像我们上面提到的一样,这里有些东西叫做段。一个可执行文件包含多个段。可执行文件不同的部分将会加载进不同的段。并且每个段将会转化进一个“Segment”中。这对我们随便写的app如此,对我们用心写的app也一样。

我们来看看在a.out中的段。我们可以使用size:

a.out文件有四个段。其中一些有section。

当我们执行一个可执行文件。虚拟内存系统会将segment映射到进程的地址空间中。映射完全不同于我们一般的认识,但是如果你对虚拟内存系统不熟悉,可以简单的想象VM会将整个文件加载进内存,虽然在实际上这不会发生。VM使用了一些技巧来避免全部加载。

当虚拟内存系统进行映射时,数据段和可执行段会以不同的参数和权限被映射。

__TEXT段包含了可执行的代码。它们被以只读和可执行的方式映射。进程被允许执行这些代码,但是不能修改。这些代码也不能改变它们自己,并且这些页从来不会被污染。

__DATA段以可读写和不可执行的方式映射。它包含了将会被更改的数据。

第一个段是__PAGEZERO。这个有4GB大小。这4GB并不是文件的真实大小,但是说明了进程的前4GB地址空间将会被映射为,不能执行,不能读,不能写。这就是为什么在去写NULL指针或者一些低位的指针的时候,你会得到一个EXC_BAD_ACCESS错误。这是操作系统在尝试防止你引起系统崩溃。

在每一个段内有一些片段。它们包含了可执行文件的不同的部分。在_TEXT段,_text片段包含了编译得到的机器码。_stubs和_stub_helper是给动态链接器用的。着允许动态链接的代码延迟链接。_const是不可变的部分,就像_cstring包含了可执行文件的字符串一样。

_DATA段包含了可读写数据。从我们的角度,我们只有_nl_sysmol_ptr 和__la_symble_ptr,它们是延迟链接的指针。延迟链接的指针被用来执行未定义的函数。例如,那些没有包含在可执行文件本身内部的函数。它们将会延迟加载。那些非延迟链接的指针将会在可执行文件被夹在的时候确定。

其他在_DATA中共同的段是_const。她包含了那些需要重定位的不可变数据。一个例子是chat* const p = “foo”; p指针指向的数据不是静态的。_bss片段包含了没有被初始化的静态变量例如static int a; ANSI C标准指出这些静态变量将会被设置为零。但是在运行时可以被改变。_common片段包含了被动态链接器使用的占位符片段。

苹果的文档OSX Assembler Reference有更多关于片段定义的内容。

 

段内容:

我们能检查每一个片段的内容,使用otool像这样:

这就是我们app的代码。从-s __TEXT __text非常普通,otool有一个对此的缩写,使用-t.我们甚至可以看反汇编的代码通过在后面加上-v:

这里有些内容反汇编的代码中的一样,你应该感觉很熟悉,这就是我们在前面编译时候的代码。唯一的不同就是,在这里我们没有任何的汇编指令在里面。这是纯粹的二进制执行文件。

同样的方法,我们可以查案一下其他片段:

或者:

关于性能的脚注

从侧面来讲,_DATA和_TEXT段会影响性能。如果你有一个非常大的二进制文件,你可能回想查看苹果的代码大小优化指南。将数据移到__TEXT段是个不错的选择,因为这些页从来不会变脏。

 

任意的片段

你可以以片段的方式向你的二进制文件添加任何的数据,通过-sectcreate链接参数。这就是你怎样添加info.plist到一个独立的二进制文件。Info.plist的数据需要被放在_TEXT段的_info_plist片段。你可以使用连接器的命令-sectcreate segname sectname file来实现:

同样的,-sectalign也致命了对齐方式。如果你添加一个全新的段,通过-segprot来制定数据的保护方式。这些都是在连接器中的帮助手册中的。

你能够到达在/usr/include/mach-o/getsect.h中定义的函数在二进制文件中的那些片段,通过使用getsectdata(),它将会返回片段数据的指针和大小。

 

Mch-o

在OS X和iOS中可执行文件是Mach-o格式的:

对于GUI的程序来说也是这样:

你可以从这里找到关于mach-o文件格式的详细资料。

我们可以使用otool来看一看mach-o文件的头部。这说明了这个文件是什么,和怎样被加载的。我们将会使用-h参数来打印头部信息。

cputype和cpusubtype指明了可执行文件的目标架构。ncmds和sizeofcmds将会加载一些命令,这些命令我们可以通过-l参数来查看:

加载命令指明了文件的逻辑结构和文件在虚拟内存中的布局。绝大多数otool打印的信息都是从这些加载命令中来的。看一下Load comand 1部分,我们看到了initprot r-x,这指明了我们上面提到的数据保护模式:只读并且可执行。

对于每一个段和每一个段中的片段,加载命令说明了它们会在内存中的位置和它们的保护模式,例如,这是关于__TEXT __text片段的输出:

我们的代码将截止在0x100000f30.它在文件中的偏移量通常是3888。如果你看一下a.out的范汇编输出。你能够在0x100000f30处看到我们的代码。

我们同样可以看一下在可执行文件中,动态链接库是怎样使用的:

这是你能够在二进制文件中的__printf符号链接将要用到的库。

 

一个更复杂的例子

让我们来看一个有三个文件的复杂的例子:

编译多个文件 非常明显,我们现在有多个文件。所以我们需要对每一个文件调用clang来生成目标文件:

我们从来不编译头文件。头文件的目的是在实现文件中贡献代码,并通过这种方式来呗编译。通过#import语句Foo.m和helloworld.m中都被插入了foo.h的内容。 我们得到了两个文件:

为了生成可执行文件,我们需要链接这两个目标文件和Foundation系统库:

现在,我们可以运行我们的程序了。

 

符号表和链接

我们这个简单的app是通过两个目标文件合并到一起得到的。Foo.o包含了Foo类的实现,同事helloworld.o包含了调用Foo类方法run的main函数。 进一步,两个文件都使用了Foundation库。在helloworld.o中autorelease pool使用了这个库,以简洁的方式使用了libobjc.dylib中的Objctive-c运行时。它需要使用运行时的函数来发送消息调用。foo.o也是一样的。

这些被形象的称之为符号。我们可以把符号看成一些在运行时将会变成指针的东西。虽然实际上并不是这样能够。 每一个函数,全局变量,类等等都是通过符号的方式来使用的。当我们为可执行文件连接一个目标文件,连接器将会按需要决定目标文件和动态库之间的所有符号。 可执行文件和目标文件都有一个符号表来存储这些符号。如果你使用nm工具来查看一下helloworld.o你会发现:

这就是文件中所有的符号链接。__OBJC_CLASS_$_Foo是类Foo的符号链接。它还没有被决定成Foo类的外部链接。外部表示它对不是私有的。与此相反non-external表明符号链接对于特定的文件是私有的。 我们的helloworld.o文件引用了Foo类,但是并没有实现它。于是符号最后以未确定结尾。

下面,main函数同样是外部链接,因为它需要能够被外部看到并被调用。无论怎样,main函数是在helloworld中实现的。并且放在了地址0,和放在__TEXT __text片段中。然后是四个objc运行时的函数。它们同样是未定义的,需要连接器来决定。

我们再来看看Foo.o文件:

末五行指出_OBJC_CLASS_$_Foo是一个已定义的并且是个外部符号,同时包含Foo的实现。 Foo.o也有未定义的符号。最前面的是它使用过的NSFullUserName(),NSLog()和NSObject。 当我们连接着两个文件还有Foundation库的时候,将会确定这些在动态链接库中的符号。临界期记录了输出文件以来特定的动态链接库和它们的位置。这就是NSFullName()等将会发生的事情。

我们可以看一下最后的执行文件a.out的符号表,就能够发现连接器是怎样确定这些符号的:

我们发现Foundation和Objctive-C运行时的一些符号依然是未确定的。但是符号表中,记录了怎样去确定它们。例如那些它们可以去查找的动态链接库。

可执行文件一样也知道去哪找这些库:

这些未定义的符号将会在运行时被dyld(1)确定。当我们执行程序的时候,dyld将会在Foundation中确定指向_NSFullUserName等的实现的指针,等等等等

我们可以再次使用nm来查看你这些符号在Foundation中的情况,实际上,如下:

动态链接编辑器

这里有一些环境变量能帮助我们看一下dyld到底做了些什么。首先是DYLD_PRINT_LIBRARIES.如果设置了,dyld将会输出已经加载的东戴链接库:

这显示了七十多个在加载Foundation的时候加载的动态链接库。这是因为Foundation库也依赖于其他很多动态链接库, 你可以运行:

来查看五十多个Foundation依赖的库。

 

dyld的共享缓存

当你构建一个真正的程序的时候,你将会链接各种各样的库。它们又会依赖其他的一些框架和动态链接库。于是要加载的动态链接库会非常多。同样非独立的符号也非常多。这里就会有成千上万的符号要确定。这个工作将会话费很多时间——几秒钟。 为了优化这个过程,OS X和iOS上动态链接器使用了一个共享缓存,在/var/db/dyld/。对于每一种架构,操作系统有一个单独的文件包含了绝大多数的动态链接库,这些库已经互相连接并且符号都已经确定。当一个Mach-o文件被加载的时候,动态链接器会首先检查共享缓存,如果存在相应的库就是用。每一个进程都把这个共享缓存映射到了自己的地址空间中。这个方法戏剧性的优化了OS X和iOS上程序的加载时间。

收藏 2 评论

关于作者:一水流年

(新浪微博:@流年一水) 个人主页 · 我的文章 · 2

相关文章

可能感兴趣的话题



直接登录
最新评论
  • lymons   2013/12/19

    对这种底层技术相关的文章,应该翻译的更严谨一些。
    比如,文章中把bytes翻译成比特,意思就错了。
    byte是字节,比特是bit。

跳到底部
返回顶部