目录
一、基于 DYLD_PRINT_STATISTICS 的分析
编译
预处理
编译过程中, 首先是宏展开, 引入头文件等的预处理, 因为头文件的引入会破坏源文件的结构, 所以引入时会给源文件的内容加上行号, 用于调试. 因为预处理只是进行简单的文本处理, 所以对于宏定义来说, 应该避免使用宏来实现函数功能; 而对于头文件, 应该尽可能地减少没必要的头文件引入. 由此也可以看出头文件用于在实现文件中进行共享, 在预处理过程中被引入, 所以不需要被编译.
词法分析
预处理完成后的文件可以看做是一个字符串流, 词法分析的作用就是根据词法规则把这个字符串流拆分成一个个 token, 并分析出这些 token 中, 哪些是标识符, 哪些是字面量, 数字等. 注意标识符拼写错误, 比如 return 写成了 retrun, 是不会在这一步中被发现, 它只会被识别为一个字符串字面量. 只有非法的命名拼写和数字格式等错误才会在词法分析出被发现.
语法分析
语法分析会按照语法规则把词法分析得到的 token 组成抽象语法树(AST), 比如赋值表达式, 计算表达式等. 上面提到的 retrun 的拼写错误就会在这里被发现, 因为一个字符串后面直接接一个数字是不符合语法规则的. 但是词法分析的 token 分类不会非常细致, 比如整数和浮点数都会被认为是数字 token, 所以类型检查以及更复杂的分析不会在语法分析中进行.
语义分析
编译器会对语法树进行分析, 因为是编译阶段的分析, 所是是静态分析. 静态分析包括类型检查, 定义后未使用的变量等, ARC 中对象内存的管理也是在这一步进行的. 动态的语句中, 一些东西是在运行时才可以确定的, 在编译器的语义分析中无法检查这部分, 只能在运行时中进行检查.
中间代码生成
语义分析通过后, 会生成 LLVM 中间代码, 以中间代码为界可以把编译器分为前端和后端, 前端负责进行词法, 语法, 语义分析, 输入为高级语言代码, 输出为中间代码, 我们使用的 clang 就是编译器的前端工具. 生成中间代码之后, 由后端对中间代码进行优化, 最终汇编成可以在物理机上执行的机器码. LLVM 就是编译器后端工具. 前端把重点放在具体高级语言的解析, 只要生成中间代码即可, 不用在意底层机器具体的硬件架构. 后端把重点放在中间代码的优化以及具体硬件平台的适配, 不用在意中间代码是由哪种高级语言生成的. 比如 swift 就是使用 swift 做为前端编译的, 程序的机器码还是由 LLVM 转换生成, 所以只是语法同 oc 不同, 同样可以运行在 iOS 和 macOS 上.
在编写高级语言时不会在意具体的硬件架构, 所以中间代码也不会根据硬件进行优化. LLVM 会对中间代码进行硬件级别的优化, 比如循环展开, 尾递归优化, 分支优化等, 优化后的代码会被最终汇编成机器码. 编译器中的 -O 选项所控制的优化等级就是控制这一部分优化的程度.
链接
如果我们的代码文件只有一个, 而且没有引用其他的库, 那么编译出来的文件直接就可以运行了. 不过大多数情况我们不可能不去引用其他的库, 多个代码文件就会编译成多个目标文件, 因为目标文件之间彼此不知道对方的具体实现, 就需要链接过程对未知的变量和函数调用进行处理.
最简单的情况就是静态链接, 链接过程中, 把目标文件和库拼接成一个文件. 因为在编译过程中, 目标文件不知道调用的库函数和变量的地址, 所以暂时做了标记, 链接过程中会把做了标记的符号一一修改为正确的地址.
静态链接要求把依赖的库文件都添加进来, 所以库文件有更新了, 所以引用这个库的程序都要重新进行链接; 而且多个程序引用同一个库时会产生很大的空间浪费. 所以可以牺牲部分时间, 在程序运行时动态地把依赖库加载进来, 进行动态链接, 这样在系统中只要有一份动态链接库就可以了, 使用这个库的程序在运行中共用这一段内存. 动态库中的数据段可以被程序修改, 所以每个程序中都包含一部分动态库的数据段副本.
签名
链接过程中会生成可以运行的程序, 其他资源文件会复制进来一并打包生成 app. asset, storyboard 文件等也会经过一个编译的步骤, 进行压缩等操作, 减少体积, 并且编译成二进制后加载更快. 生成的 app 会进行代码签名, 苹果要求使用代码签名的原因有两个: 首先所有的 iOS 设备只能从被苹果信任的渠道安装并运行 app, 保证 app 是安全的; 其次对于个人或企业开发者等下发的非 app store 下载的 app, 要控制它们的安装数量和有效时间. 代码签名可以实现这两个需求.

在讲代码签名之前, 先介绍一下签名, 签名保证了被加密的数据没有被修改过. 首先, 使用 hash 函数生成未加密数据的摘要, 数据发生很小的变化摘要就会发生很大变化, 这个摘要就叫做签名. 把签名和加密后的数据放在一起发送给对方, 对方把数据解密后使用同样的 hash 函数计算出摘要, 如果摘要和签名相同, 说明数据没有被修改.

生成签名
1. 首先, 开发者本地会生成一个密钥对
开发者生成的 app 会使用私钥加密, 通过用公钥解密的方式就可以验证这个 app 是由该开发者生成的
2. 苹果也会生成一个密钥对
苹果的公钥会下发到所有的 iOS 设备上, 通过这个公钥就可以验证一个 app 是不是由苹果认证的
3. 开发者把自己的公钥 L 发给苹果进行认证
苹果会使用自己的私钥 A 对开发者公钥 L 进行签名, 代表这个开发者是通过苹果认证的
4. iOS 设备安装开发者的 app 时, 会先安装开发者的签名
iOS 设备使用苹果的私钥 A 对签名进行解密, 验证了开发者身份是经过苹果认证的, 顺便得到开发者的公钥 L. 除此之外, 苹果还会用自己的私钥给给 设备限制, AppID 限制等信息一同签名发给 iOS 设备, 来进行限制开发者 app 的有效时间及安装设备数量等.
验证签名
1. iOS 设备会使用苹果的公钥 A 对开发者公钥和附加的信息进行解密并验证签名, 验证数据是由苹果认证的
2. iOS 设备比对附加信息, 验证开发者证书的有效时间, 允许安装的设备等
3. iOS 设备使用开发者的公钥对开发者 app 进行解密, 验证 app 确实是由该开发者生成
4. 如果验证都通过, 运行 app
装载
程序是一个静态的概念, 要想变成进程被操作系统运行, 首先要把程序读入内存, 同时还要进行上面提到过的动态链接的步骤, 最后才会把控制权交给程序进行执行.
虚拟内存
现代的操作系统使用了虚拟内存来减轻程序员的负担, 使其不用考虑具体物理内存的大小等问题, 并且提供了硬件层面的权限保护以及虚拟-物理内存的转换. 每个进程都有自己独立的虚拟内存空间, 但这个空间不都是进程可以操控的, 操作系统为了控制进程, 其内核要在虚拟内存中占用一部分空间, 同时用户进程进行系统调用时也会使用虚拟内存中的内核空间.
64位操作系统中, 虚拟内存空间只有48位, 即12位16进制数, 用户空间和内核空间各占一半, 用户空间在低位, 内存空间在高位. 用户空间在 0x0000 0000 0000 0000 到 0x0000 7FFF FFFF FFFF 地址之间, 内核空间在 0xFFFF 8000 0000 0000 到 0xFFFF FFFF FFFF FFFF 地址之间. 中间的虚拟内存地址没有被使用.

在用户空间中, 二进制程序代码在最下面, 不过空间不是从 0 开始, 所以 NULL 指针被指向0代表着未分配的地址. 之后是堆区, 堆区是自下向上增长的, 而栈区在用户地址空间的最上面, 自上向下生长, 同样, 栈区空间也不是从最末的地址开始. 堆区和栈区中间一般用于把文件映射到内存中, 对应着 mmap 系统调用. 值得一提的是这些区域在虚拟内存中的位置会有所偏移且每次加载程序后偏移都会发生改变, 原因是 ASLR(Address Space Layout Random) 机制, 避免了利用固定位置偏移进行攻击(比如2018年初使用 CPU 预先执行漏洞), 同样内核空间布局也有这种机制(KALSR).
创建虚拟内存空间
加载程序的第一步, 就是为进程创建虚拟内存空间, 其实这一步很简单, 只需分配虚拟内存页目录即可.
建立程序与虚拟内存的映射关系
当 CPU 运行进程时, 发生缺页要把对应的数据从磁盘读入内存, 所以程序文件本身要和虚拟内存进行映射, 上面一直在提的装载就是指这一过程. 在 iOS 中, 装载相关操作是 Mach-O 文件的 Load Commands 部分来定义的, 放在下一节具体讲.
把 CPU 的 PC 设置为进程入口
当程序本身的装载和链接库的装载都完成后, 操作系统进行内核态和用户态的转换, CPU 从进程入口执行程序, 进程开始执行.
Mach-O 文件的加载
Mach-O 文件格式
iOS 中可执行文件的格式就是 Mach-O, Mach-O 文件的加载就是上面所说建立程序与虚拟内存映射关系的过程, Mach-O 的格式如下:

Header 部分保存着该文件可以运行的 CPU 平台, 文件类型, load command 的个数等信息.
Load Commands 部分保存着该文件的加载指令, 包括把程序本身装载到内存, dyld 的启动, 使用 dyld 加载动态链接库, 把 main 函数设为入口等.
Data 部分保存着程序的具体代码, 数据. 在程序中, 数据是按照 section 来组织的, 比如 text section, data section, bss section 等, 程序中有很多 section, 如果按照 section 的方式映射到虚拟内存中, 会因为段对齐和页对齐造成很多空间浪费, 而且不同的读写权限也会很复杂. 内存映射时只关心这块内存的读写权限, 所以在映射到内存时会按照读写权限把不同的 section 放到 segment 中, 按照 segment 的方式映射到虚拟内存.
dyld 加载
按照 Load Commands 部分的指令, 首先会把程序本身的 segment 加载到内存中, 然后就是加载 dyld. dyld 是苹果的动态链接器, 用于对程序进行装载过程中的动态链接, 同时进行 runtime 的初始化.
刚装载进内存的程序还没有经过 dyld 的动态链接, 所以要装载 dyld 进行链接, 但因为没有动态链接, 所以 dyld 不能使用动态链接的方式装载进来, 产生了一个鸡生蛋, 蛋生鸡的问题. 在 load command 中, dyld 的装载地址是指定的, 使用这个地址就可以进行 dyld 的装载, 当然, 在装载 dyld 的过程中也不能使用外部变量和函数.
dyld 装载进来后, 就开始递归进行链接库的链接, 因为链接库本身可以会依赖其他的链接库, 所以这个过程是递归的.
装载完成之后, 进行 rebase. 程序和链接库中有些寻址是使用自己和地址加上偏移的方式进行的, 默认自己的地址是0, 而实际装载时, 二进制文件的地址是不固定的, 所以要进行 rebase 修复, 理由同上一条, 这个过程也是递归的.
rebase 之后, 开始进行符号的绑定. 在编译过程的链接中, 对于动态链接的符号, 编译器只是将其标记为需要动态链接, 符号的具体地址并没有给出, 在这一步就是给这些未定义的符号填写上正确的地址. 这一步也是递归的. 绑定也分很多种, 在装载过程中进行绑定的为直接绑定; 如果所有动态符号都是直接绑定, 那么程序的装载会花掉很多时间, 所以可以把一些符号延迟绑定, 只有进程运行中使用到这些符号的时候才进行绑定, 这种就叫做懒绑定.
在绑定过程中, dyld 会通知 runtime, runtime 会对所有的类调用 +load 方法进行类对象的初始化.
直到最后一步, main 函数的地址才被设为进程的入口, 核心态切换到用户态, 开始了程序的执行.
最后自我总结粗分两部分:dyld与runtime
细分主要包括四大部分:
1)加载可执行文件(Mach-O文件:二进制文件)
2)加载动态库()
3)Objc初始化,入口_obj_init(Objc相关类的注册,Category分类,selector唯一性检查等)
4)初始化加载进内存,如load方法
过程:
Load dylibs ----> Rebase ----> Bind ---->Objc ----> initializer

为什么要rebase,rebind?
Objc初始化讲解:
加载可执行文件和动态链接库后,dyld开始对这些二进制文件进行初始化,调用map_images做解析和处理,这里进行第一次初始化realizeClass,作用为类提前分配好内存,即类的一些方法、属性和协议是在编译期就决定的。接下来load_image中调用call_load_methods方法,遍历所有加载进来的class,按继承层次依次调用class,也就是父类的load方法比子类加载内存的时机要早,然后再处理分类,分类的load方法单独加入分类列表。
至此,可执行文件中和动态库所有的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,被 runtime 所管理,再这之后,runtime 的那些方法(动态添加 Class、swizzle 等等才能生效)
一、基于 DYLD_PRINT_STATISTICS 的分析
分析方法
将 DYLD_PRINT_STATISTICS = 1加入Xcode环境变量,将会打印出App启动进入main函数之前各阶段的时间占比
可参见WWDC - Optimizing App Startup Time, http://useyourloaf.com/blog/slow-app-startup-times/
Pre-main部分的优化方法主要包括:
1. 减少dylib的引入(可以排查下是否有无用的系统dylib,将来使用自定义dylib时尽量进行合并)
2. 减少OC Class的数量、Category的数量、方法的数量(排查App中的无用代码)
3. 减少静态初始化操作,如+load、constructor,建议更改为使用+initialize (主要集中在减少+load的使用,预期收益较大的优化主要在+load的处理上)
4. 使用swift(目前尚不靠谱)
参考博客:
http://blog.sunnyxx.com/2014/08/30/objc-pre-main/
http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time/
本文详细介绍了iOS程序的编译、链接、签名、装载等过程。编译包含预处理、词法分析等步骤;链接分静态和动态;签名用于保证app安全和控制安装;装载涉及虚拟内存等操作。还介绍了Mach - O文件加载和dyld加载过程,最后基于DYLD_PRINT_STATISTICS给出App启动Pre - main部分的优化方法。

556

被折叠的 条评论
为什么被折叠?



