一些原理性知识

什么是Mach-O

Mach-O是OSX和iOS中可执行文件的格式。iOS中可执行文件的类型如下:

  • 可执行文件—应用程序主要的二进制文件。
  • Dylib—动态库(类似于其他平台的DSO或者DLL)
  • Bundle—不可被链接的动态库,只能通过在运行时通过dlopen()调用

Image:表示上面三种类型中的任何一种。

Framework: 是一个动态链接库,它由一个特殊目录结构组成,目录存储着该动态库所需要的文件。

Mach-O Image组成

mach-o格式

Mach-O Image被分为多个段(segment),如上图有三个端分别是:_TEXT、_DATA、_LINKEDIT。每一个段都是页面大小的n倍。如上图中_TEXT段是页大小的3倍,_DATA和_LINKEDIT段是页面大小的1倍。页的大小由硬件决定,arm64架构下是16KB、其他架构是4KB。

Mach-OImage的另一种查看方式是分区(sections)。编译器会忽略分区,但分区是段的字集,它与页大小没有关系,不遵循页大小的约束,且分区之间不会互相重叠。

最常见的段是:_TEXT、_DATA、_LINKEDIT,几乎每个的二进制文件都包含这三个段,你可以添加一个自定义的段,但通常不会添加任何值。

_TEXT是文件的开头,它包含着Mach的头文件,任何机器指令以及只读常量,比如C的字符串。

_DATA段是重写段,它包含了所有的全局变量。

_LINKEDIT段不包含全局函数,但它包含函数变量的信息,比如它们的名字或者地址。

通用文件

对于同时支持64位和32位的iOS应用而言,编译的时候Xcode生成了两个Mach-O文件,一个是为32位系统使用的,一个是为64位系统使用的。进而将这两个Mach-O文件合并出另外一个Mach-O,这就是Mach-O通用文件。格式如下:

通用文件格式

Mach-O通用文件开始位置有一个头,它包含了所有系统结构的列表,以及他们在通用文件中的偏移位置。该头的大小是一个页。

为什么段的空间都要是页的整数倍,为什么Header段要占用一个页?这与虚拟内存有关。

虚拟内存

虚拟内存要解决的问题是:当多个进程要执行要执行的时候如何关系物理内存?它通过添加一个中间层来解决这个问题。

每个进程都是一个逻辑地址空间映射到物理内存的页面。这种映射关系不是一对一的,逻辑地址可以不对应任何物理内存,也可以多个逻辑内存对应同一个物理内存。

当逻辑地址不对应任何物理内存时,进程访问该地址就会产生页面错误,内核将停止该线程,并试图找到解决办法。

当两个逻辑地址映射到同一物理页面时,两个进程共享同一物理内存,这样进程之间就可以共享了。

基于文件的映射

文件映射不用将真实的所有文件内容写入内存,而是通过调用mmap告知虚拟内存,需要将文件的这一部分映射到进程的某个地址区间。

基于文件映射,当你每次访问一个地址的时候,如果该地址之前没有被访问过,它将会导致一个页错误,内核将读取该页的内容。这就会形成文件的懒加载。

小结: 通过上面的分析我们可以知道,动态库或者Image的TEXT段可以映射到多个进程,它将通过懒加载的形式被读取,并且所有的页可以被多个进程共享。那么DATA段呢?DATA段是可读可写的,所以我们需要在写入的时候复制它。它对所有的进程共享读操作,但一旦某一个进程想要想DATA页写入数据的时候,写时复制将会发生,这会使得内核将这页的数据拷贝一份到另一个物理内存中并重新设置它的映射关系。这是这个进程就拥有了这页的副本。这也就引入了”干净VS脏”页。脏页是指拷贝出来的那个页,它包含了进程的特定信息,干净页是指内核可以按照需要重新建立的页面。所以脏页要比干净页昂贵的多。

页的权限界限

任何一个页可以标记为可读可写或者可执行,或他们的任何组合。

动态库访问过程

动态库访问过程

如上图,两个进程Process1和Process2都访问同一个Mach-O动态库。动态库是基于文件进行内存映射的。当进程访问_TEXT和_LINKEDDIT 段的时候,如果没有进行映射关系,则会发生缺页中断,内核就会读取一页的内容并建立映射关系,但当访问_DATA端的时候,由于要向该端里写回内容,所以发生了写时复制,产生脏页。假设上图中动态库有8个页大小,两个进程会产生2个脏页,如果将整个动态库读入内存,则会产生16个脏页。

启动流程分析

exec() to main()

exec(): 是一个系统调用。

当我们为一个新程序开启一个新的进程时,内核会抹去整个地址,并映射你指定的可执行程序,ASLR把它映射到一个随机地址。’

下一步要做的是从该随机地址回溯到0地址,将整个区域标记为不可访问即不可读,不可写,不可执行。该区域在32位处理器下至少4KB大小,64位处理器下至少4GB大小。这样捕获任何空指针引用和任何指针截断。

在Unix的最初几十年里,生活很简单, 因为我们只需映射一个程序然后把指针指向它,之后开始运行它既可。

当引入共享库之后,内核并不是直接管理共享库,而是通过一个帮助程序,在iOS平台下它叫:dyld,其他平台,如Unix叫作LD。

所以,当内核映射完进程之后就会将dyld程序加载到改进程的地址空间,dyld开始在该进程空间运行,它的主要工作是加载该进程所依赖的动态库。

dyld流程

dyld流程

  • 映射所有依赖的dylibs

    通过分析主程序的头文件,找到所有的依赖库列表,然后对其进行解析,找到每一个依赖的mach-o文件。如果每个Dylib都找到了,它需要读取每一个文件的开头以验证它是否是mach-o文件。然后可以对mach-o的每一段调用mmap。进程直接依赖的Dylib加载完成之后,会加载这些Dylib所依赖的Dylib,如此循环直到加载完所有依赖的动态库。通常一个进程所依赖的Dylib能达到100-400个,好在大部分都是系统dylib,系统dylib的加载速度是非常快的。

  • 修复

    加载完所有的dylib之后,我们需要将它们绑定在一起,因为它们之间是彼此独立的,这个过程叫作 修复。

    由于代码签名的原因我们无法修改指令,那么dylib如何调用另一个dylib呢?这需要添加很多间接层。code-gen(动态PIC-Position independent Code、地址无关代码)。为了调用code-gen在DATA段里新建了一个指针,该指针指向我们想要调用的位置,代码加载该指针、跳向该指针。所有所有的dylib都在修复指针和数据,修复有两种:

    • Rebase 重设基址

      重设基址是指如果一个指针指向Image范围内需要作出的所有修改。由于ASLR的原因dylib被随机的加载到地址上,但它内部的指针还指向旧地址,所以为了修复它们我们需要计算滑动值,也就是移动距离,并且对每一个内部指针都添加该滑动值。所以重设基址是指遍历所有的内部数据指针然后为它们添加一个滑动值。

    • Bind 绑定

      绑定是指如果指针指向Image范围外。这些指针名称(字符串)进行绑定。比如说DATA段里调用malloc方法,这需要经常在符号表里进行查找操作,找到之后将对应的地址修改。

  • Objc运行时

    • 实例化所有的类,并注册到一张表格,包含所有名称及其映射的类。
    • 处理分类并将分类中的方法插入到方法列表。
  • Initializer

    • C++ 为初始化静态生成的对象。
    • 执行ObjC +load方法。

启动流程优化

启动速度要达到什么标准

不同的平台的标准不一样,400毫秒是一个常用的标准,但不要超过20秒。在最慢的支持的设备中进行测试是很重要的。

如何测量启动速度

设置环境变量DYLD_PRINT_STATISTICS

环境变量设置

控制台会输出启动时间。

为什么启动会慢

如何解决启动慢的问题

  • 尽量少使用dylib

    • 合并存在的dylib
    • 使用静态存档
    • 使用延时加载,也就是使用dlopen函数,但它会带来细微性能和正确性问题。(不建议这么做)。
  • ReBase/Binging

    • 减少DATA段的指针
    • 减少OC元数据- 类、方法、类别
    • 减少C++虚函数
    • 使用Swift struct
    • 检查机器生成的代码
      • 使用偏移代替指针
      • 使它只读
  • 初始化阶段

    • 使用initialize替代load方法
    • C/C++使用 site initializers替代__attribute__
      • dispatch_once()
      • pthread_once()
      • std::once()
    • 不要调用dlopen()在初始化阶段
    • 不要在初始化阶段创建线程

相关资料

Objc中国

wwdc-2016-406
Linux基础之虚拟内存文件映射mmap

iOS启动时间优化