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

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

FAT 二进制

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

image

Mach-O 文件

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

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 查看自己的应用结构:

使用MachOView查看文件结构

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

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 */
};
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的偏移量和大小。

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

image
打印信息

我们可以看到程序首先调用了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 函数之前的操作做一个小总结吧:

  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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容

  • 背景 一个项目做的时间长了,启动流程往往容易杂乱,库也用的越来越多,APP的启动时间也会慢慢变长。本次将针对iOS...
    酱油瓶2阅读 3,413评论 0 12
  • 关键时刻,第一时间送达! 问题种类 时间复杂度 在集合里数据量小的情况下时间复杂度对于性能的影响看起来微乎其微。但...
    C9090阅读 839评论 0 1
  • 这是一篇 WWDC 2016 Session 406 的学习笔记,从原理到实践讲述了如何优化 App 的启动时间。...
    MTDeveloper阅读 736评论 0 1
  • 这是一篇 WWDC 2016 Session 406 的学习笔记,从原理到实践讲述了如何优化 App 的启动时间。...
    茗涙阅读 1,843评论 0 3
  • 文/村草 上次出去玩回来的路上,跟超子讨论一个话题,“现在有什么好项目可以投资,并且是投资小见效快的?” 最关键的...
    村草视角阅读 293评论 2 6