iOS应用 main 执行前发生的事情

这篇是对 iOS 应用启动时,main 函数执行前发生的事的一点总结,限于水平,如有错误请指正~

FAT 二进制

FAT 二进制文件,将多种架构的 Mach-O 文件合并而成。它通过 Fat Header 来记录不同架构在文件中的偏移量,Fat Header 占一页(64位16kb,32位4kb)的空间。
按分页来存储这些 segement 和 header 会浪费空间,但这有利于虚拟内存的实现。

iOS应用 main 执行前发生的事情_第1张图片
image

Mach-O 文件

Mach-O为 Mach Object 文件格式的缩写,它是一种用于可执行文件,目标代码,动态库,内核转储的文件格式。
在 Mac OS X 系统中使用 Mach-O 作为其可执行文件类型。
它的组成结构如下图所示:

iOS应用 main 执行前发生的事情_第2张图片
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 查看自己的应用结构:

iOS应用 main 执行前发生的事情_第3张图片
使用MachOView查看文件结构

东西有点多就没有截取全部。我们查看一下Mach-O Header部分

iOS应用 main 执行前发生的事情_第4张图片
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 */
};
iOS应用 main 执行前发生的事情_第5张图片
image

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 都包含这三个段:

  1. __TEXT:包含了执行代码和其它只读数据(如C 字符串)。权限:只读(VM_PROT_READ),可执行(VM_PROT_EXECUTE)
  2. __DATA:程序数据,包含全局变量,静态变量等。权限:可读写(VM_PROT_WRITE/READ) 可执行(VM_PROT_EXECUTE)
  3. __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 的结构体中,我们可以看到vmaddrvmsize两个成员变量,它们分别代表 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

dyld(the dynamic link editor),Apple 的动态链接器。在内核完成映射进程的工作后会启动dyld,负责加载应用依赖的所有动态链接库,准备好运行所需的一切。
在 App 启动的时候,首先会加载 App 的 mach-o 文件,从 load commands 中得到 dyld 的路径,并且运行。随后 dyld 做的事情顺序概括如下:

  1. 初始化运行环境
  2. 加载主程序执行文件 生成 image, 将image添加到一个全局容器中
  3. 加载共享缓存
  4. 根据依赖链递归加载动态链接库 dylib,如果在缓存中有加载好的 image 则直接拿出来,否则生成一个新的 image,将image添加到一个全局容器中
  5. link 主执行文件
  6. link dylib
    • 根据依赖链递归修正指针 Rebase
    • 根据依赖链递归符号绑定 Bind
  7. 初始化 dylib(runtime 的初始化就在这个时候)

在加载完所有的 dylib 之后,它们处于互相独立的状态,所以还需要将它们绑定起来。代码签名让我们不能修改指令,所以不能直接让一个 dylib 调用另一个 dylib,这时需要很多间接层。
这个时候需要 dyld 来修正指针(rebasing)和绑定符号(binding)。

详细可以查看 dyld 的源码中的_main函数。
下面会分析上述的其中几个步骤。

ImageLoader

ImageLoader是一个将 mach-o 文件里面二进制数据(编译过的代码、符号等)加载到内存的基类,它负责将 mach-o 中的二进制数据映射到内存,它的实例就是我们熟悉的 image。
每一个 mach-o 文件都会有一个对应的 image,实例的类型根据 mach-o 格式的不同也会不同。

image
  • 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的偏移量和大小。

iOS应用 main 执行前发生的事情_第6张图片
image

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的初始化方法。

iOS应用 main 执行前发生的事情_第7张图片
image
iOS应用 main 执行前发生的事情_第8张图片
打印信息

我们可以看到程序首先调用了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,下面是方法调用栈:

iOS应用 main 执行前发生的事情_第9张图片
断点调试

注意: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 函数之前的操作做一个小总结吧:

  1. 将 App 的 mach-o header 读取到内存中
  2. 根据 load commands 获取 dyld 的路径,运行 dyld
  3. 初始化运行环境,加载 dylib,如果缓存中存在则从缓存中拿出加载过的 image,否则新建一个 image,加载到内存中
  4. 修正指针(rebase),绑定符号(bind)
  5. 初始化 dylib,运行 runtime
  6. runtime 将 image 中有关 OBJC 的数据进行初始化
  7. 调用 +(load) 方法
  8. dyld 调用 main 函数

花了一周的时间用来研究这部分的内容,终于填完坑了~很舒服
最大的感受就是学习完后,看 clang 编译后的 C++ 代码能看懂的更多了。比如添加完一个类目之后,会将这个这个类目添加到__DATA的section __objc_catlist中,以前不知道啥意思现在就明白了。也明白 xcode 的许多设置是用来干嘛的,总之好处多多~
学习也是一个递归的过程,总之,也多加油吧!

引用

iOS 程序 main 函数之前发生了什么
优化 App 的启动时间
dyld源码分析-动态加载load
dyld与ObjC

你可能感兴趣的:(iOS应用 main 执行前发生的事情)