这篇是对 iOS 应用启动时,main 函数执行前发生的事的一点总结,限于水平,如有错误请指正~
FAT 二进制
FAT 二进制文件,将多种架构的 Mach-O 文件合并而成。它通过 Fat Header 来记录不同架构在文件中的偏移量,Fat Header 占一页(64位16kb,32位4kb)的空间。
按分页来存储这些 segement 和 header 会浪费空间,但这有利于虚拟内存的实现。
Mach-O 文件
Mach-O
为 Mach Object 文件格式的缩写,它是一种用于可执行文件,目标代码,动态库,内核转储的文件格式。
在 Mac OS X 系统中使用 Mach-O 作为其可执行文件类型。
它的组成结构如下图所示:
每个 Mach-O 文件包括一个 Mach-O Header,然后是一系列的载入命令 load commands,再是一个或多个段(segment),每个段包括0到255个节(section)。Mach-O使用REL再定位格式控制对符号的引用。Mach-O在两级命名空间中将每个符号编码成“对象-符号名”对,在查找符号时则采用线性搜索法。
Mach-O包含了几个 segment,每个 segment 又包含几个 section。segment的名字都是大写的,例如__DATA;section的名字都是小写的, 例如 __text。在 Mach-O 的类型不为MH_OBJECT
时,空间大小为页的整数倍。页的大小跟硬件有关,在 arm64 架构一页是16kb,其余为4kb。
section 虽然没有整数倍页大小的限制,但是 section 之间不会有重叠。
Mach-O Header
推荐使用MachOView
这个软件查看 Mach-O 的文件结构。注意需要手动关闭 processing
,不然会闪退。下面是用 MachOView 查看自己的应用结构:
东西有点多就没有截取全部。我们查看一下Mach-O Header
部分
下面是64位架构下header
的数据结构:
struct mach_header_64 {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};
Mach-O 全部的 filetype 和 flags在loader.h
中找到。
除了MH_OBJECT
以外的所有类型,段(Segment)的空间大小均为页的整数倍。页的大小跟硬件有关系,在 arm64 架构下一页为16kb,其它为4kb。
Load commands
Load commands
紧跟在头部之后, 当加载过 header 之后,会通过解析Load commands
来加载剩下的数据,确定其内存的分布。
下面是 load commands 的结构定义:
struct load_command {
uint32_t cmd; /* 载入命令类型 */
uint32_t cmdsize; /* total size of command in bytes */
};
所有load commands
的大小即为 Header->sizeofcmds, 共有 Header->ncmds 条load command
。
load command 以LC
开头,不同的加载命令有不同的专有的结构体,cmd 和 cmdsize 是都有的,分别为命令类型(即命令名称),这条命令的长度。这些加载命令告诉系统应该如何处理后面的二进制数据,对系统内核加载器和动态链接器起指导作用。如果当前 LC_SEGMENT 包含 section,那么 section 的结构体紧跟在 LC_SEGMENT 的结构体之后,所占字节数由 SEGMENT 的 cmdsize 字段给出。
cmd(命令名称) | 作用 |
---|---|
LC_SEGMENT_64 | 将对应的段中的数据加载并映射到进程的内存空间去 |
LC_SYMTAB | 符号表信息 |
LC_DYSYMTAB | 动态符号表信息 |
LC_LOAD_DYLINKER | 启动动态加载连接器/usr/lib/dyld程序 |
LC_UUID | 唯一的 UUID,标示该二进制文件,128bit |
LC_VERSION_MIN_IPHONEOS/MACOSX | 要求的最低系统版本(Xcode中的Deployment Target) |
LC_MAIN | 设置程序主线程的入口地址和栈大小 |
LC_ENCRYPTION_INFO | 加密信息 |
LC_LOAD_DYLIB | 加载的动态库,包括动态库地址、名称、版本号等 |
LC_FUNCTION_STARTS | 函数地址起始表 |
LC_CODE_SIGNATURE | 代码签名信息 |
注意:不同类型的 segment 会使用不同的函数来加载
Segment
Mach-O 文件中由许多个段(Segment),每个段都有不同的功能,每个段包含了许多个小的Section。LC_SEGMENT
意味着这部分文件需要映射到进程的地址空间去,几乎所有 Mach-O 都包含这三个段:
- __TEXT:包含了执行代码和其它只读数据(如C 字符串)。权限:只读(VM_PROT_READ),可执行(VM_PROT_EXECUTE)
- __DATA:程序数据,包含全局变量,静态变量等。权限:可读写(VM_PROT_WRITE/READ) 可执行(VM_PROT_EXECUTE)
- __LINKEDIT:包含了加载程序的"元数据",比如函数的名称和地址。权限:只读(VM_PROT_READ)
除了上面三个,还有一个常见的 segment:
- __PAGEZERO:空指针陷阱段,映射到虚拟内存空间的第一页,用于捕捉对 NULL 指针的引用
LC_SEGMENT_64
的结构定义为:
struct segment_command_64 { /* for 64-bit architectures */
uint32_t cmd; /* LC_SEGMENT_64 */
uint32_t cmdsize; /* includes sizeof section_64 structs */
char segname[16]; /* segment name */
uint64_t vmaddr; /* memory address of this segment */
uint64_t vmsize; /* memory size of this segment */
uint64_t fileoff; /* file offset of this segment */
uint64_t filesize; /* amount to map from the file */
vm_prot_t maxprot; /* maximum VM protection */
vm_prot_t initprot; /* initial VM protection */
uint32_t nsects; /* number of sections in segment */
uint32_t flags; /* flags */
};
可以看到这里大部分的成员变量都是帮助内核将 segment 映射到虚拟内存的。nsects
即表明该段中包含多少个 section,section是具体数据存放的地方。cmdsize
表示当前 segment 结构体以及它所包含的所有 section 结构体的总大小。
文件映射的起始位置由fileoff
给出,映射到地址空间的vmaddr
处。
Section
section 的名字均为小写。section 是具体数据存放的地方,它的结构体跟随在 LC_SEGMENT 结构体之后。在64位环境中它的结构定义为:
struct section_64 { /* for 64-bit architectures */
char sectname[16]; /* name of this section */
char segname[16]; /* segment this section goes in */
uint64_t addr; /* memory address of this section */
uint64_t size; /* size in bytes of this section */
uint32_t offset; /* file offset of this section */
uint32_t align; /* section alignment (power of 2) */
uint32_t reloff; /* file offset of relocation entries */
uint32_t nreloc; /* number of relocation entries */
uint32_t flags; /* flags (section type and attributes)*/
uint32_t reserved1; /* reserved (for offset or index) */
uint32_t reserved2; /* reserved (for count or sizeof) */
uint32_t reserved3; /* reserved */
};
其中flasg
字段储存了两个属性的值:type 和 attributes。type 只能有一个值,而 attributes 的值可以有多个。如果 segment 中任何一个 section 拥有属性 S_ATTR_DEBUG,那么该段所有的 section 都会拥有这个属性。属性详情可以参考loader.h
section name | 作用 |
---|---|
__text | 主程序代码 |
__stub_helper | 用于动态链接的存根 |
__symbolstub1 | 用于动态链接的存根 |
__objc_methname | Objective-C 的方法名 |
__objc_classname | Objective-C 的类名 |
__cstring | 硬编码的字符串 |
__lazy_symbol | 懒加载,延迟加载节,通过 dyld_stub_binder 辅助链接 |
_got | 存储引用符号的实际地址,类似于动态符号表 |
__nl_symbol_ptr | 非延迟加载节 |
__mod_init_func | 初始化的全局函数地址,在 main 之前被调用 |
__mod_term_func | 结束函数地址 |
__cfstring | Core Foundation 用到的字符串(OC字符串) |
__objc_clsslist | Objective-C 的类列表 |
__objc_nlclslist | Objective-C 的 +load 函数列表,比 __mod_init_func 更早执行 |
__objc_const | Objective-C 的常量 |
__data | 初始化的可变的变量 |
__bss | 未初始化的静态变量 |
虚拟内存
在 segment 的结构体中,我们可以看到vmaddr
和vmsize
两个成员变量,它们分别代表 segment 在虚拟内存中的地址以及大小。
虚拟内存
就是一层间接寻址(indirection)。软件工程中有句格言就是任何问题都能通过添加一个间接层来解决。虚拟内存解决的是管理所有进程使用物理 RAM 的问题。通过添加间接层来让每个进程使用逻辑地址空间,它可以映射到 RAM 上的某个物理页上。这种映射不是一对一的,逻辑地址可能映射不到 RAM 上,也可能有多个逻辑地址映射到同一个物理 RAM 上。针对第一种情况,当进程要存储逻辑地址内容时会触发 page fault;第二种情况就是多进程共享内存。
对于文件可以不用一次性读入整个文件,可以使用分页映射(mmap())
的方式读取。也就是把文件某个片段映射到进程逻辑内存的某个页上。当某个想要读取的页没有在内存中,就会触发 page fault,内核只会读入那一页,实现文件的懒加载。
也就是说 Mach-O 文件中的__TEXT
段可以映射到多个进程,并可以懒加载,且进程之间共享内存。__DATA
段是可读写的。这里使用到了Copy-On-Write
技术,简称 COW。也就是多个进程共享一页内存空间时,一旦有进程要做写操作,它会先将这页内存内容复制一份出来,然后重新映射逻辑地址到新的 RAM 页上。也就是这个进程自己拥有了那页内存的拷贝。这就涉及到了 clean/dirty page 的概念。dirty page 含有进程自己的信息,而 clean page 可以被内核重新生成(重新读磁盘)。所以 dirty page 的代价大于 clean page。
在多个进程加载 Mach-O 文件时__TEXT
和__LINKEDIT
因为只读,都是可以共享内存的。而__DATA
因为可读写,就会产生 dirty page。当 dyld 执行结束后,__LINKEDIT
就没用了,对应的内存页会被回收。
dyld
dyld(the dynamic link editor),Apple 的动态链接器。在内核完成映射进程的工作后会启动dyld
,负责加载应用依赖的所有动态链接库,准备好运行所需的一切。
在 App 启动的时候,首先会加载 App 的 mach-o 文件,从 load commands 中得到 dyld 的路径,并且运行。随后 dyld 做的事情顺序概括如下:
- 初始化运行环境
- 加载主程序执行文件 生成 image, 将image添加到一个全局容器中
- 加载共享缓存
- 根据依赖链递归加载动态链接库 dylib,如果在缓存中有加载好的 image 则直接拿出来,否则生成一个新的 image,将image添加到一个全局容器中
- link 主执行文件
- link dylib
- 根据依赖链递归修正指针 Rebase
- 根据依赖链递归符号绑定 Bind
- 初始化 dylib(runtime 的初始化就在这个时候)
在加载完所有的 dylib 之后,它们处于互相独立的状态,所以还需要将它们绑定起来。代码签名让我们不能修改指令,所以不能直接让一个 dylib 调用另一个 dylib,这时需要很多间接层。
这个时候需要 dyld 来修正指针(rebasing)和绑定符号(binding)。
详细可以查看 dyld 的源码中的_main
函数。
下面会分析上述的其中几个步骤。
ImageLoader
ImageLoader
是一个将 mach-o 文件里面二进制数据(编译过的代码、符号等)加载到内存的基类,它负责将 mach-o 中的二进制数据映射到内存,它的实例就是我们熟悉的 image。
每一个 mach-o 文件都会有一个对应的 image,实例的类型根据 mach-o 格式的不同也会不同。
- ImageLoaderMachOClassic: 用于加载
__LINKEDIT
段为传统格式的 mach-o 文件 - ImageLoaderMachOCompressed: 用于加载
__LINKEDIT
段为压缩格式的 mach-o 文件
因为dylib
之间有依赖关系,所以系统会沿着依赖链递归加载 image。
Rebasing
dylib
的二进制数据会随机的映射到内存的一个随机地址ASLR(Address space layout randomization,)中,这个随机的地址跟代码和数据指向的旧地址(preferred_address)会有一定的偏差,dyld
需要修正这个偏差(slide),做法就是将dylib
内部的指针地址都加上这个偏移值,偏移值的计算方法如下:
slide = actual_address - preferred_address
随后就是不断的将__DATA
段中需要修正的指针加上这个偏移值。
注意:每次程序启动后的地址都会变化,所以指针的地址都需要重新修正。
在 mach-o 的一个载入命令LC_DYLD_INFO_ONLY
可以查看到rebase
, bind
, week_bind
,lazy_bind
的偏移量和大小。
Binding
binding
处理那些指向dylib
外部的指针,它们实际上被符号名称(symbol)绑定,也就是个字符串。比如我们 objc 代码中需要使用到 NSObject, 即符号OBJC_CLASS$_NSObject,但是这个符号不存在当前的 image 中,而是在系统库 Foundation.framework中,因此就需要binding这个操作将对应关系绑定到一起。
Lazy Binding
lazyBinding
就是在加载动态库的时候不会立即 binding, 当时当第一次调用这个方法的时候再实施 binding。 做到的方法也很简单: 通过dyld_stub_binder
这个符号来做。lazy binding 的方法第一次会调用到 dyld_stub_binder, 然后 dyld_stub_binder负责找到真实的方法,并且将地址bind到桩上,下一次就不用再bind了。
多数符号都是 lazy binding 的
Runtime
每一个dylib
都有自己的初始化方法,当相应的 image 被加载到内存后,就会调用初始化方法。当然这不是调用名为initialize
方法,而是C++静态对象初始化构造器,__attribute__((constructor))
标记的方法以及Initializer
方法。你可以在程序中设置环境变量DYLD_PRINT_INITIALIZERS
来打印dylib
的初始化方法。
我们可以看到程序首先调用了libSystem
这个dylib的初始化方法。libSystem
是很多系统的lib的集合,包括 libdispatch(GCD), libsystem_c(c语言库), libsystem_blocks(block)。
在libSystem
的源码init.c中我们可以看到,它的初始化方法libSystem_initializer
会调用libdispatch_init();
, 然后逐步调用到_objc_init
,也就是 objc 和 runtime 的入口。
添加一个符号断点_objc_init
,下面是方法调用栈:
注意:runtime 和 objc 属于libobjc
下面是_objc_init
的实现:
void _objc_init(void)
{
// 省略...
_dyld_objc_notify_register(&map_images, load_images, unmap_image);
}
上面的map_images
不是将 image 添加到内存中的意思,在这个方法被调用的时候,已经完成了 image 的映射以及指针修正,绑定符号的工作了。
这个函数实际上是将 image 中 OBJC 相关的信息进行初始化,具体实现可以查看_read_image
的源码,因为代码太多所以这里就不贴出来了,下面是具体做的事情:
- 会将所有的 Class 存放在一张映射类名与 Class 的全局表中
gdb_objc_realized_classes
- 随后调用
readClass
函数将 每一个 Class 添加到gdb_objc_realized_classes
表中。 - 确定 selector 是唯一的
- read protocols: 读取protocol
- realizeClasses:这一步的意义就是动态链接好class, 让class处于可用状态,主要操作如下:
- 检查ro是否已经替换为rw,没有就替换一下。
- 检查类的父类和metaClass是否已经realize,没有就先把它们先realize
- 重新layout ivar. 因为只有加载好了所有父类,才能确定ivar layout
- 把一些flags从ro拷贝到rw
- 链接class的 nextSiblingClass 链表
- attach categories: 合并categories的method list、 properties、protocols到 class_rw_t 里面
- read categories:读取类目
在map_images
结束会调用load_images
函数。这一步做的事情比较少,就是调用我们熟悉的+(load)
函数。父类会先调用,除了 Class,每个类目的+(load)
方法也会被调用一次,但顺序就不一定了。
总结
在这里对 main 函数之前的操作做一个小总结吧:
- 将 App 的 mach-o header 读取到内存中
- 根据 load commands 获取 dyld 的路径,运行 dyld
- 初始化运行环境,加载 dylib,如果缓存中存在则从缓存中拿出加载过的 image,否则新建一个 image,加载到内存中
- 修正指针(rebase),绑定符号(bind)
- 初始化 dylib,运行 runtime
- runtime 将 image 中有关 OBJC 的数据进行初始化
- 调用 +(load) 方法
- dyld 调用 main 函数
花了一周的时间用来研究这部分的内容,终于填完坑了~很舒服
最大的感受就是学习完后,看 clang 编译后的 C++ 代码能看懂的更多了。比如添加完一个类目之后,会将这个这个类目添加到__DATA的section __objc_catlist
中,以前不知道啥意思现在就明白了。也明白 xcode 的许多设置是用来干嘛的,总之好处多多~
学习也是一个递归的过程,总之,也多加油吧!
引用
iOS 程序 main 函数之前发生了什么
优化 App 的启动时间
dyld源码分析-动态加载load
dyld与ObjC