本文由 简悦 SimpRead 转码, 原文地址 www.objc.io
Objc.io 出版有关 iOS 和 macOS 开发高级技术的书籍、视频和文章。
在 Xcode 中构建应用程序时,源文件(".m "和".h")会变成可执行文件。该可执行文件包含可在 CPU(iOS 设备上的 ARM 处理器)或 Mac 上的 Intel 处理器上运行的字节代码。
我们将介绍编译器的部分工作以及可执行文件的内容。编译器的作用远不止这些。
让我们把 Xcode 放在一边,进入命令行工具的世界。当我们在 Xcode 中构建程序时,它会简单地调用一系列工具。弗洛里安将详细介绍其工作原理。我们将直接调用这些工具,并了解它们的作用。
希望这能让你更好地理解 iOS 或 OS X 上的可执行文件(即所谓的 Mach-O 可执行文件)是如何工作和组合的。
xcrun
首先是一些基础结构: 我们会经常用到一个名为 "xcrun "的命令行工具。它看似奇怪,但却非常棒。这个小工具用来运行其他工具。而不是运行
% clang -v
在终端上,我们将使用
% xcrun clang -v
xcrun 的作用是找到 clang 并使用 clang 后面的参数运行它。
我们为什么要这么做?这似乎毫无意义。但 xcrun 允许我们 (1) 拥有多个版本的 Xcode 并使用特定 Xcode 版本的工具,以及 (2) 使用特定 SDK(软件开发工具包)的工具。如果您碰巧同时拥有 Xcode 4.5 和 Xcode 5,那么通过 xcode-select 和 xcrun,您可以选择使用 Xcode 5 中 iOS SDK 的工具(和头文件等),或者 Xcode 4.5 中 OS X 的工具。在大多数其他平台上,这几乎是不可能的。详情请查看 xcrun 和 xcode-select 的手册。您还可以通过命令行使用开发工具,而无需安装 Command Line Tools。
没有集成开发环境的 Hello World
回到终端,创建一个包含 C 文件的文件夹:
% mkdir ~/Desktop/objcio-command-line
% cd !$
% touch helloworld.c
现在用你最喜欢的文本编辑器编辑这个文件 - TextEdit.app 也可以:
% open -e helloworld.c
填写这段代码:
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("Hello World!\n");
return 0;
}
保存并返回终端运行:
% xcrun clang helloworld.c
% ./a.out
现在,你应该会在终端上看到一条可爱的 "Hello World!"信息。你编译了一个 C 程序并运行了它。这一切都离不开集成开发环境。深吸一口气。欣喜若狂。
我们刚才做了什么?我们将 helloworld.c 编译成了一个名为 a.out 的 Mach-O 二进制文件。这是编译器的默认名称,除非我们指定其他名称。
这个二进制文件是如何生成的?有多个部分需要研究和理解。我们先来看编译器。
Hello World 和编译器
现在的编译器是 clang(发音为 /klæŋ/)。Chris 在 关于编译器 中写得更详细。
简而言之,编译器将处理 helloworld.c 输入文件并生成可执行文件 a.out。处理过程包括多个步骤/阶段。我们刚才所做的就是依次运行所有这些步骤:
预处理
-
标记化
-
宏扩展
-
扩展
解析和语义分析
-
将预处理器标记翻译成解析树
-
对解析树进行语义分析
-
输出抽象语法树 (AST)
代码生成和优化
-
将 AST 翻译成低级中间代码(LLVM IR)
-
负责优化生成的代码
-
生成目标代码
-
输出汇编
汇编器
- 将汇编代码转换为目标对象文件
链接器
- 将多个目标文件合并为可执行文件(或动态库)
让我们看看这些步骤在我们的简单示例中是如何实现的。
预处理
编译器要做的第一件事就是预处理文件。如果我们在这一步之后停止,可以告诉 clang 向我们展示它的样子:
% xcrun clang -E helloworld.c
哇 这将输出 413 行。让我们用编辑器打开它,看看到底发生了什么:
% xcrun clang -E helloworld.c | open -f
在最上面,你会看到很多很多以 #(读作 "hash")开头的行。这些就是所谓的 linemarker 语句,它告诉我们下面的行来自哪个文件。我们需要它。如果你再看一遍 helloworld.c 文件,你会发现第一行是
#include <stdio.h>
我们以前都用过 #include 和 #import。它的作用是告诉预处理器将文件 stdio.h 的内容插入#include语句所在的位置。这是一个递归过程: stdio.h 头文件反过来又包含其他文件。
由于存在大量的递归插入,我们需要能够跟踪结果源代码中的行来自何处。为此,每当源代码的起始行发生变化时,预处理器就会插入一个以 # 开头的 linemarker。# 后面的数字是行号,然后是文件名。行末尾的数字是标志,表示新文件的开始(1)、返回文件(2)、下面的内容来自系统头(3),或者表示该文件将被视为封装在 "外部 "C""块中。
如果将输出滚动到最后,就会看到我们的 helloworld.c 代码:
# 2 "helloworld.c" 2
int main(int argc, char *argv[])
{
printf("Hello World!\n");
return 0;
}
在 Xcode 中,您可以通过选择 Product -> Perform Action -> Preprocess 查看任何文件的预处理器输出。请注意,编辑器需要几秒钟才能加载预处理后的文件--文件长度很可能接近 100,000 行。
编译
接下来是解析和代码生成。我们可以告诉 clang 像这样输出生成的汇编代码:
% xcrun clang -S -o - helloworld.c | open -f
让我们看看输出结果。首先,我们会注意到有些行是以点". "开头的。这些是汇编指令。其他的是实际的 x86_64 汇编。最后是标签,与 C 语言中的标签类似。
让我们从头三行开始:
.section __TEXT,__text,regular,pure_instructions
.globl _main
.align 4, 0x90
这三行是汇编指令,不是汇编代码。.section指令指定了下面的内容将进入哪个部分。稍后将详细介绍章节。
接下来,.globl 指令指定 _main 为外部符号。这是我们的 main() 函数。它需要在二进制文件之外可见,因为系统需要调用它来运行可执行文件。
.align 指令指定了后续代码的对齐方式。在我们的例子中,下面的代码将以 16 (2^4) 字节对齐,并在需要时填充 0x90。
接下来是主函数的前言:
_main: ## @main
.cfi_startproc
## BB#0:
pushq %rbp
Ltmp2:
.cfi_def_cfa_offset 16
Ltmp3:
.cfi_offset %rbp, -16
movq %rsp, %rbp
Ltmp4:
.cfi_def_cfa_register %rbp
subq $32, %rsp
这部分有许多标签,其工作方式与 C 标签相同。它们是汇编代码某些部分的符号引用。首先是函数 _main 的实际开始。这也是导出的符号。因此,二进制文件将有一个指向该位置的引用。
大多数函数的开头都会使用 .cfi_startproc 指令。CFI 是调用帧信息(Call Frame Information)的简称。一个 frame 粗略地对应一个函数。当你使用调试器 step in 或 step out 时,你实际上是在步入/步出调用帧。在 C 代码中,函数有自己的调用框架,但其他东西也可以。.cfi_startproc指令为函数提供了一个进入.eh_frame的入口,其中包含了展开信息--这就是异常如何展开调用框架栈的。该指令还将为 CFI 发送依赖于体系结构的指令。与之匹配的是输出中相应的 .cfi_endproc,以标记我们的 main() 函数的结束。
接下来是另一个标签 ### BB#0:,最后是第一个汇编代码: pushq %rbp。这就是事情变得有趣的地方。在 OS X 上,我们使用的是 x86_64 代码,这种架构有一个所谓的 application binary interface (ABI),它规定了函数调用在汇编代码级的工作方式。该 ABI 的一部分规定,"rbp "寄存器(基本指针寄存器)必须在函数调用时保留。主函数有责任确保 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(%rip), %rax
movl $0, -4(%rbp)
movl %edi, -8(%rbp)
movq %rsi, -16(%rbp)
movq %rax, %rdi
movb $0, %al
callq _printf
首先,leaq将指向L_.str的指针载入rax寄存器。请注意 L_.str 标签是如何在汇编代码中进一步定义的。这就是我们的 C 语言字符串 "Hello World!\n"。edi 和 rsi 寄存器分别存放第一个和第二个函数参数。由于我们将调用另一个函数,因此首先需要存储它们的当前值。这就是我们要使用的基于 rbp 的 32 字节。首先是一个 32 位的 0,然后是 edi 寄存器(存放 argc)的 32 位值,然后是 rsi 寄存器(存放 argv)的 64 位值。我们稍后不会使用这些值,但由于编译器在运行时没有进行优化,因此还是会存储这些值。
现在,我们将把 printf() 的第一个函数参数 rax 放入第一个函数参数寄存器 edi。printf() 函数是一个变量函数。根据 ABI 调用约定,用于保存参数的向量寄存器的数目需要存储在 al 寄存器中。最后,callq调用了printf()函数:
movl $0, %ecx
movl %eax, -20(%rbp) ## 4-byte Spill
movl %ecx, %eax
将 ecx 寄存器设置为 0,将 eax 寄存器保存(溢出)到堆栈,然后将 ecx 中的 0 值复制到 eax 中。ABI 规定 eax 将保存函数的返回值,而我们的 main() 函数返回 0:
addq $32, %rsp
popq %rbp
ret
.cfi_endproc
完成后,我们会将堆栈指针 rsp 向后移动 32 个字节,以恢复堆栈指针,从而消除上面 subq $32, %rsp 的影响。最后,我们将弹出先前存储的 rbp 值,然后用 ret 返回给调用者,这将从堆栈中读取返回地址。.cfi_endproc平衡了.cfi_startproc指令。
接下来是字符串文字 "Hello World!\n" 的输出:
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz "Hello World!\n"
同样,.section指令指定了以下内容需要放在哪个部分。L_.str标签允许实际代码获得指向字符串字面的指针。.asciz 指令告诉汇编器输出一个 0 结尾的字符串字面量。
这将开始一个新的部分 __TEXT __cstring。该部分包含 C 字符串:
L_.str: ## @.str
.asciz "Hello World!\n"
这两行创建了一个空尾字符串。请注意,"L_.str "是后面访问字符串时使用的名称。
最后的 .subsections_via_symbols 指令被静态链接编辑器使用。
有关汇编指令的更多信息,请参阅 Apple 的 OS X Assembler Reference。AMD 64 网站上有关于 x86_64 应用程序二进制接口 的文档。它还提供了一份x86-64 汇编简介。
同样,Xcode 可让您通过选择 Product -> Perform Action -> Assemble 查看任何文件的汇编输出。
汇编程序
简单地说,汇编器将(人类可读的)汇编代码转换为机器代码。它创建目标对象文件,通常简称为 对象文件。这些文件以 .o 文件结尾。如果使用 Xcode 构建应用程序,您会在项目的 derived data 目录下的 Objects-normal 文件夹中找到这些对象文件。
链接器
稍后我们将详细介绍链接器。但简单地说,链接器将解析对象文件和库之间的符号。这意味着什么?回想一下
callq _printf
语句。printf() 是 libc 库中的一个函数。最终的可执行文件需要知道 printf() 在内存中的位置,即 _printf 符号的地址。链接器会获取所有对象文件(在我们的例子中只有一个)和库(在我们的例子中隐含 libc),并解析所有未知符号(在我们的例子中就是 _printf)。然后,链接器将在 libc 中找到该符号的信息编码到最终可执行文件中,并输出可运行的最终可执行文件: a.out。
章节
正如我们上面提到的,有一种东西叫做章节。一个可执行文件会有多个部分,即部件。可执行文件的不同部分将各自归入自己的部分,而每个部分又将归入一个段。这不仅适用于我们的小应用程序,也适用于完整应用程序的二进制文件。
让我们来看看二进制文件 a.out 中的各个部分。我们可以使用 size 工具来做这件事:
% xcrun size -x -l -m a.out
Segment __PAGEZERO: 0x100000000 (vmaddr 0x0 fileoff 0)
Segment __TEXT: 0x1000 (vmaddr 0x100000000 fileoff 0)
Section __text: 0x37 (addr 0x100000f30 offset 3888)
Section __stubs: 0x6 (addr 0x100000f68 offset 3944)
Section __stub_helper: 0x1a (addr 0x100000f70 offset 3952)
Section __cstring: 0xe (addr 0x100000f8a offset 3978)
Section __unwind_info: 0x48 (addr 0x100000f98 offset 3992)
Section __eh_frame: 0x18 (addr 0x100000fe0 offset 4064)
total 0xc5
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
我们的 a.out 文件有四个段。其中一些有分段。
当我们运行一个可执行文件时,VM(虚拟内存)系统会将这些段映射到进程的地址空间(即内存)中。映射在本质上有很大不同,但如果你不熟悉虚拟机系统,只需假定虚拟机会将整个可执行文件加载到内存中,尽管实际情况并非如此。虚拟机会使用一些技巧来避免这样做。
当虚拟机系统进行这种映射时,段和段之间会映射出不同的属性,即不同的权限。
__TEXT 段包含我们要运行的代码。它被映射为只读和可执行。进程可以执行代码,但不能修改代码。代码不能改变自身,因此这些映射页面永远不会变脏。
__DATA段被映射为可读/可写,但不可执行。它包含需要更新的值。
第一个段是 __PAGEZERO。它有 4GB 大小。这 4GB 实际上并不在文件中,但文件规定进程地址空间的前 4GB 将被映射为不可执行、不可写、不可读。这就是为什么在读取或写入 "NULL "指针或其他(相对)较小的值时,会出现 "EXC_BAD_ACCESS "的原因。这是操作系统试图阻止你造成破坏。
在段中,还有节。这些部分包含了可执行文件的不同部分。在 __TEXT 段中,__text 部分包含编译后的机器代码。__stubs 和 __stub_helper用于动态链接器(dyld)。这样就可以在动态链接代码中懒散地进行链接。__const (我们的例子中没有)是常量,同样,__cstring 包含可执行文件的字面字符串常量(源代码中的引号字符串)。
__DATA段包含读/写数据。在我们的例子中,只有 __nl_symbol_ptr 和 __la_symbol_ptr,它们分别是 non-lazy 和 lazy 符号指针。懒惰符号指针用于可执行文件调用的所谓未定义函数,即不在可执行文件内部的函数。它们被懒惰地解析。非懒惰符号指针则在加载可执行文件时解析。
在 __DATA 段中的其他常见部分是 __const,其中包含需要重新定位的常量数据。例如 char * const p = "foo"; --p 指向的数据不是常量。__bss 部分包含未初始化的静态变量,如 static int a; -- ANSI C 标准规定静态变量必须设置为零。但它们可以在运行时更改。__common 部分包含未初始化的外部全局变量,类似于 static 变量。例如,函数块外的 int a;。最后,__dyld 是一个占位符部分,由动态链接器使用。
苹果公司的 OS X Assembler Reference 提供了更多关于部分类型的信息。
节段内容
我们可以使用 otool(1) 查看一节的内容,如下所示:
% xcrun otool -s __TEXT __text a.out
a.out:
(__TEXT,__text) section
0000000100000f30 55 48 89 e5 48 83 ec 20 48 8d 05 4b 00 00 00 c7
0000000100000f40 45 fc 00 00 00 00 89 7d f8 48 89 75 f0 48 89 c7
0000000100000f50 b0 00 e8 11 00 00 00 b9 00 00 00 00 89 45 ec 89
0000000100000f60 c8 48 83 c4 20 5d c3
这就是我们应用程序的代码。由于 -s __TEXT __text 非常常见,所以 otool 用 -t 参数为它提供了快捷方式。我们甚至可以通过添加 -v来查看反汇编后的代码:
% xcrun otool -v -t a.out
a.out:
(__TEXT,__text) section
_main:
0000000100000f30 pushq %rbp
0000000100000f31 movq %rsp, %rbp
0000000100000f34 subq $0x20, %rsp
0000000100000f38 leaq 0x4b(%rip), %rax
0000000100000f3f movl $0x0, 0xfffffffffffffffc(%rbp)
0000000100000f46 movl %edi, 0xfffffffffffffff8(%rbp)
0000000100000f49 movq %rsi, 0xfffffffffffffff0(%rbp)
0000000100000f4d movq %rax, %rdi
0000000100000f50 movb $0x0, %al
0000000100000f52 callq 0x100000f68
0000000100000f57 movl $0x0, %ecx
0000000100000f5c movl %eax, 0xffffffffffffffec(%rbp)
0000000100000f5f movl %ecx, %eax
0000000100000f61 addq $0x20, %rsp
0000000100000f65 popq %rbp
0000000100000f66 ret
这是同样的内容,这次是反汇编。看起来应该很熟悉 -- 这就是我们编译代码时看到的内容。唯一不同的是,代码中不再有任何汇编指令;这是裸二进制可执行文件。
同样,我们还可以查看其他部分:
% xcrun otool -v -s __TEXT __cstring a.out
a.out:
Contents of (__TEXT,__cstring) section
0x0000000100000f8a Hello World!\n
或者
% xcrun otool -v -s __TEXT __eh_frame a.out
a.out:
Contents of (__TEXT,__eh_frame) section
0000000100000fe0 14 00 00 00 00 00 00 00 01 7a 52 00 01 78 10 01
0000000100000ff0 10 0c 07 08 90 01 00 00
关于性能的题外话
题外话:__DATA 和 __TEXT段对性能有影响。如果你有一个非常大的二进制文件,你可能需要查看苹果公司关于代码大小性能指标的文档。将数据移入 __TEXT 段是有益的,因为这些页面永远不会变脏。
任意段
你可以使用 -sectcreate 链接器标志将任意数据作为一个段添加到可执行文件中。这就是在单个文件可执行文件中添加 Info.plist 的方法。Info.plist 数据需要放入 __TEXT 段的 __info_plist 部分。您可以通过以下方式将 -sectcreate segname sectname file 传递给链接器
-Wl,-sectcreate,__TEXT,__info_plist,path/to/Info.plist
给 clang。类似地,-sectalign 指定了对齐方式。如果你要添加一个全新的程序段,可以查看 -segprot 来指定程序段的保护(读/写/可执行)。这些都在链接器的主页,即 ld(1)中有所说明。
您可以使用 /usr/include/mach-o/getsect.h"中定义的函数(即 getsectdata())来获取片段,该函数将为您提供一个指向片段数据的指针,并通过引用返回其长度。
Mach-O
OS X 和 iOS 上的可执行文件是 Mach-O可执行文件:
% file a.out
a.out: Mach-O 64-bit executable x86_64
GUI 应用程序也是如此:
% file /Applications/Preview.app/Contents/MacOS/Preview
/Applications/Preview.app/Contents/MacOS/Preview: Mach-O 64-bit executable x86_64
Apple 提供了有关 Mach-O 文件格式 的详细信息。
我们可以使用 otool(1) 查看可执行文件的 Mach header。它说明了这个文件是什么以及如何加载。我们将使用 -h 标志来打印头信息:
% otool -v -h a.out
a.out:
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 X86_64 ALL LIB64 EXECUTE 16 1296 NOUNDEFS DYLDLINK TWOLEVEL PIE
cputype 和 cpusubtype 指定此可执行文件可运行的目标架构。ncmds和 sizeofcmds 是加载命令,我们可以使用 -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
...
加载命令指定文件的逻辑结构及其在虚拟内存中的布局。otool 打印出的大部分信息都来自这些加载命令。查看 Load command 1 部分,我们会发现 initprot r-x,它指定了上述保护:只读(不写)和可执行。
对于每个程序段和程序段中的每个部分,加载命令都会指定其在内存中的位置、保护方式等。下面是 __TEXT __text 部分的输出结果:
Section
sectname __text
segname __TEXT
addr 0x0000000100000f30
size 0x0000000000000037
offset 3888
align 2^4 (16)
reloff 0
nreloc 0
type S_REGULAR
attributes PURE_INSTRUCTIONS SOME_INSTRUCTIONS
reserved1 0
reserved2 0
我们的代码最终将存入内存 0x100000f30。它在文件中的偏移量是 3888。如果查看 xcrun otool -v -t a.out 之前的反汇编输出,就会发现代码实际上位于 0x100000f30。
我们还可以看看可执行文件使用了哪些动态链接库:
% otool -v -L a.out
a.out:
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 169.3.0)
time stamp 2 Thu Jan 1 01:00:02 1970
我们的可执行文件将在这里找到正在使用的 _printf 符号。
更复杂的示例
让我们来看一个更复杂的示例,它包含三个文件:
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;
}
}
编译多个文件
在这个示例中,我们有多个文件。因此,我们需要告诉 clang 首先为每个输入文件生成对象文件:
% xcrun clang -c Foo.m
% xcrun clang -c helloworld.m
我们从不编译头文件。它的作用只是在编译的实现文件之间共享代码。Foo.m 和 helloworld.m 都通过 #import 语句引入了 Foo.h 的内容。
我们最终得到两个对象文件:
% file helloworld.o Foo.o
helloworld.o: Mach-O 64-bit object x86_64
Foo.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
2013-11-03 18:03:03.386 a.out[8302:303] Daniel Eggert
符号和链接
我们的小应用程序由两个对象文件组成。Foo.o "对象文件包含了 "Foo "类的实现,而 "helloworld.o "对象文件包含了 "main() "函数并调用/使用了 "Foo "类。
此外,这两个文件都使用了 Foundation 框架。helloworld.o 对象文件将其用于自动释放池,并以 libobjc.dylib 的形式间接使用 Objective-C 运行时。它需要运行时函数来进行消息调用。这与 Foo.o 对象文件类似。
所有这些都以所谓的 symbols 表示。我们可以把符号看作是应用程序运行后的指针,尽管其本质略有不同。
定义或使用的每个函数、全局变量、类等都会产生一个符号。当我们将对象文件链接到可执行文件时,链接器 (ld(1)) 会根据需要在对象文件和动态链接库之间解析符号。
可执行文件和对象文件都有一个符号表来指定它们的符号。如果我们用 nm(1) 工具查看 helloworld.o 对象文件,会得到以下结果:
% xcrun nm -nm helloworld.o
(undefined) external _OBJC_CLASS_$_Foo
0000000000000000 (__TEXT,__text) external _main
(undefined) external _objc_autoreleasePoolPop
(undefined) external _objc_autoreleasePoolPush
(undefined) external _objc_msgSend
(undefined) external _objc_msgSend_fixup
0000000000000088 (__TEXT,__objc_methname) non-external L_OBJC_METH_VAR_NAME_
000000000000008e (__TEXT,__objc_methname) non-external L_OBJC_METH_VAR_NAME_1
0000000000000093 (__TEXT,__objc_methname) non-external L_OBJC_METH_VAR_NAME_2
00000000000000a0 (__DATA,__objc_msgrefs) weak private external l_objc_msgSend_fixup_alloc
00000000000000e8 (__TEXT,__eh_frame) non-external EH_frame0
0000000000000100 (__TEXT,__eh_frame) external _main.eh
这些是该文件的所有符号。_OBJC_CLASS_$_Foo 是 Foo Objective-C 类的符号。它是 Foo 类的一个 未定义的外部符号。External 意味着它不是该对象文件的私有符号,而与之相反的是,non-external 符号是特定对象文件的私有符号。我们的 helloworld.o 对象文件引用了 Foo 类,但并没有实现它。因此,它的符号表最终有一个标记为未定义的条目。
接下来,main() 函数的 _main 符号也是 外部的,因为它需要可见才能被调用。不过,它也是在 helloworld.o 中实现的,位于地址 0,需要放到 __TEXT,__text 部分。还有四个 Objective-C 运行时函数。这些函数也是未定义的,需要链接器来解决。
如果我们转向 Foo.o 对象文件,会得到以下输出:
% xcrun nm -nm Foo.o
0000000000000000 (__TEXT,__text) non-external -[Foo run]
(undefined) external _NSFullUserName
(undefined) external _NSLog
(undefined) external _OBJC_CLASS_$_NSObject
(undefined) external _OBJC_METACLASS_$_NSObject
(undefined) external ___CFConstantStringClassReference
(undefined) external __objc_empty_cache
(undefined) external __objc_empty_vtable
000000000000002f (__TEXT,__cstring) non-external l_.str
0000000000000060 (__TEXT,__objc_classname) non-external L_OBJC_CLASS_NAME_
0000000000000068 (__DATA,__objc_const) non-external l_OBJC_METACLASS_RO_$_Foo
00000000000000b0 (__DATA,__objc_const) non-external l_OBJC_$_INSTANCE_METHODS_Foo
00000000000000d0 (__DATA,__objc_const) non-external l_OBJC_CLASS_RO_$_Foo
0000000000000118 (__DATA,__objc_data) external _OBJC_METACLASS_$_Foo
0000000000000140 (__DATA,__objc_data) external _OBJC_CLASS_$_Foo
0000000000000168 (__TEXT,__objc_methname) non-external L_OBJC_METH_VAR_NAME_
000000000000016c (__TEXT,__objc_methtype) non-external L_OBJC_METH_VAR_TYPE_
00000000000001a8 (__TEXT,__eh_frame) non-external EH_frame0
00000000000001c0 (__TEXT,__eh_frame) non-external -[Foo run].eh
倒数第五行显示,_OBJC_CLASS_$_Foo 已被定义,并处于 Foo.o 的外部 - 它拥有该类的实现。
Foo.o 也有未定义的符号。首先是我们正在使用的 NSFullUserName()、 NSLog() 和 NSObject 的符号。
当我们链接这两个对象文件和 Foundation 框架(这是一个动态链接库)时,链接器会尝试解析所有未定义的符号。它可以这样解析 _OBJC_CLASS_$_Foo。对于其他符号,则需要使用基金会框架。
当链接器通过动态库(在我们的例子中是 Foundation 框架)解析一个符号时,它会在最终链接的映像中记录该符号将通过该动态库解析。链接器会记录输出文件依赖于该特定动态链接库,以及该动态链接库的路径。在我们的例子中,_NSFullUserName、_NSLog、_OBJC_CLASS_$_NSObject、_objc_autoreleasePoolPop 等符号就是如此。
我们可以查看最终可执行文件 a.out 的符号表,看看链接器是如何解析所有符号的:
% xcrun nm -nm a.out
(undefined) external _NSFullUserName (from Foundation)
(undefined) external _NSLog (from Foundation)
(undefined) external _OBJC_CLASS_$_NSObject (from CoreFoundation)
(undefined) external _OBJC_METACLASS_$_NSObject (from CoreFoundation)
(undefined) external ___CFConstantStringClassReference (from CoreFoundation)
(undefined) external __objc_empty_cache (from libobjc)
(undefined) external __objc_empty_vtable (from libobjc)
(undefined) external _objc_autoreleasePoolPop (from libobjc)
(undefined) external _objc_autoreleasePoolPush (from libobjc)
(undefined) external _objc_msgSend (from libobjc)
(undefined) external _objc_msgSend_fixup (from libobjc)
(undefined) external dyld_stub_binder (from libSystem)
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100000e50 (__TEXT,__text) external _main
0000000100000ed0 (__TEXT,__text) non-external -[Foo run]
0000000100001128 (__DATA,__objc_data) external _OBJC_METACLASS_$_Foo
0000000100001150 (__DATA,__objc_data) external _OBJC_CLASS_$_Foo
我们看到,所有 Foundation 和 Objective-C 运行时符号仍未定义,但符号表中已包含如何解析这些符号的信息,即在哪个动态链接库中可以找到这些符号。
可执行文件也知道在哪里可以找到这些动态链接库:
% xcrun otool -L a.out
a.out:
/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 1056.0.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1197.1.1)
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 855.11.0)
/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
这些未定义的符号将在运行时由动态链接器 dyld(1) 解决。当我们运行可执行文件时,dyld 会确保 _NSFullUserName 等指向 Foundation 等内部的实现。
我们可以针对基金会运行 nm(1),检查这些符号是否确实在基金会中定义:
% xcrun nm -nm `xcrun --show-sdk-path`/System/Library/Frameworks/Foundation.framework/Foundation | grep NSFullUserName
0000000000007f3e (__TEXT,__text) external _NSFullUserName
动态链接编辑器
有几个环境变量可以用来查看 dyld 在做什么。首先是 DYLD_PRINT_LIBRARIES。如果设置了这个变量,dyld 就会打印出加载了哪些库:
% (export DYLD_PRINT_LIBRARIES=; ./a.out )
dyld: loaded: /Users/deggert/Desktop/command_line/./a.out
dyld: loaded: /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation
dyld: loaded: /usr/lib/libSystem.B.dylib
dyld: loaded: /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
dyld: loaded: /usr/lib/libobjc.A.dylib
dyld: loaded: /usr/lib/libauto.dylib
[...]
这将显示加载 Foundation 时加载的全部 70 个动态链接库。这是因为 Foundation 依赖于其他动态链接库,而其他动态链接库又依赖于其他动态链接库,如此循环。您可以运行
% xcrun otool -L `xcrun --show-sdk-path`/System/Library/Frameworks/Foundation.framework/Foundation
以查看基金会使用的 15 个动态库的列表。
dyld 的共享缓存
在构建实际应用程序时,你会与各种框架进行链接。而这些框架又会使用无数其他框架和动态链接库。需要加载的所有动态库的列表很快就会变得很大。相互依赖的符号列表更是如此。需要解决的符号将数以千计。这项工作需要很长时间:几秒钟。
为了缩短这一过程,OS X 和 iOS 上的动态链接器使用了位于 /var/db/dyld/内的共享缓存。对于每种架构,操作系统都有一个单一文件,其中包含几乎所有已链接到单一文件中的动态链接库,并解决了它们之间相互依赖的符号问题。当加载一个 Mach-O 文件(可执行文件或库)时,动态链接器会首先检查它是否在这个 共享缓存 映像中,如果是,就从共享缓存中使用它。每个进程的地址空间都已映射了 dyld 共享缓存。这种方法大大缩短了 OS X 和 iOS 上的启动时间。