Reference:
iOS 底层 - 从头梳理 dyld 加载流程
iOS进阶 – 程序启动那些事
Dyld系列之一:_dyld_start之前
DYLD动态链接器
dyld与ObjC
从 dyld 到 runtime
Executable
APP的加载过程就是APP中可执行文件的加载过程,了解这个加载过程之前需要先了解iOS中的可执行文件。MacOS中可以使用chmod+x
命令来赋予一个文件可执行权限,但实际上可以执行的文件只有两类,第一类是脚本文件,第二类是可执行二进制文件,可执行二进制文件又分为Mach-O和Fat Binary,它们之间的区别如下:
- 脚本文件:脚本文件是以
#!
开头的文本文件,执行时会由shell根据#!
后面指明的地址,找到脚本解释器,然后由脚本解释器,对文件中的脚步代码,进行解释执行; - Mach-O:Mach-O是以魔数
0xfeedface
(32位) 或0xfeedfacf
(64位)作为文件头的二进制文件,Mach-O有多种type,可执行文件的type是Executable(MH_EXECUTE),运行一个可执行的Mach-O时,系统会fork一个新进程,然后执行execve
调用,解析Mach-O,执行其中的的Load Commands,启动动态链接器dyld,链接主程序与依赖库,最终得到一个新的进程镜像,在内存中运行; - Fat Binary:Fat Binary就是针对不同芯片架构的Mach-O(Executable)的集合,是以魔数
0xcafebabe
(小端)或0xbebafeca
(大端)为文件头的二进制文件,执行时根据Fat Header找到当前架构的Mach-O,再解析并加载对应的Mach-O;
iOS中的App在不使用BitCode的情况下,其内部的二进制文件是一个Fat Binary,使用BitCode时,App中是与设备的芯片架构所对应的Mach-O,它们在App启动时,最终都是解析并加载一个Mach-O。
Fat Binary的大端模式和小端模式:大端模式,是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中;小端模式,则是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中。
Mach-O
Mach-O的文件结构如图:
Mach-O主要由Header、Load Commands和Data所构成:
- Header:包含魔数、芯片类型与子类型、文件类型、加载命令数量与大小、动态链接器标识、字节顺序等信息;
- Load Commands:程序的加载命令,包括段与进程地址的映射、指定动态链接器、指明需要动态链接的库等等;
- Data:程序的原始数据段,包括_TEXT、_DATA等,加载时被映射到进程的内存空间中。
Load Commands
Mach-O中Load Commands部分是程序的加载命令,这些加载命令在内核在执行execve
调用时,完成对Mach-O的文件解析之后,有的由内核直接执行,有的则由动态链接器处理,这部分可以参考文章—Mach-O文件介绍之loadcommand,下面列出一些常见的加载命令及其含义:
命令 | 含义 |
---|---|
LC_SEGMENT | 将数据段映射到地址空间中 |
LC_DYLD_INFO_ONLY | 动态链接相关信息 |
LC_SYMTAB | 符号表地址 |
LC_DYSYMTAB | 动态符号表地址 |
LC_LOAD_DYLINKER | 动态链接器 |
LC_UUID | 文件的唯一标识 |
LC_VERSION_MIN_IPHONEOS | 支持的最低iOS版本 |
LC_SOURCE_VERSION | 源码版本 |
LC_MAIN | 设置进程的主线程入口地址和栈大小 |
LC_ENCRPYTION_INFO | 加密信息 |
LC_LOAD_DYLIB | 加载动态库 |
LC_LOAD_WEAK_DYLIB | 弱加载动态库(不因库不存在而中止) |
LC_FUNCTION_STARTS | 函数起始地址表 |
LC_CODE_SIGNATURE | 代码签名信息 |
Dyld
Executable类型的Mach-O的Load Commands中通过LC_LOAD_DYLINKER指明了程序的动态链接器,MacOS/iOS中默认的动态链接器是dyld(/usr/lib/dyld),dyld主要负责将主程序及其依赖库加载为镜像文件(images),并链接这些镜像文件,dyld的代码是开源的,本文中引用的源码是732.8版本。
_dyld_start
dyld本身也是一个Mach-O,类型为MH_DYLINKER,在execve的调用中,完成主程序Mach-O的解析后,根据其中的LC_LOAD_DYLINKER,由内核解析并加载dyld,根据dyld的LC_UNIXTHREAD得到dyld的entry_point,即_dyld_start函数,然后通过_dyld_start执行dyld程序,开始dyld的工作。
从execve到_dyld_start的函数调用栈大致如下:
1 | ▼ execve // 启动app时, 用户态发送一个系统调用execve到内核 |
start
_dyld_start中调用了start函数,start中主要是完成一些dyld的引导工作,包括rebaseDyld、栈溢出保护、获取主程序的ASLR随机数等,在start的末尾调用并返回了_main函数:
1 | uintptr_t start(const dyld3::MachOLoaded* appsMachHeader, int argc, const char* argv[], |
_main
内核通过_dyld_start开始执行dyld程序,再调用start函数完成引导工作,最后调用_main函数开始执行dyld程序,_mian函数是dyld实际的逻辑入口,源码中对于_main函数的注释如下:
1 | // |
_main中所做的工作较多,大体上可以分为以下几步:
1. 加载共享缓存库
在加载共享缓存库之前,_main函数中会先设置运行环境,设置运行环境的工作包括包括:
- 配置环境变量,将传入的参数赋值给一些环境变量;
- 设置上下文信息
setContext
,包括一些回调函数、参数、标志信息等; - 检测进程是否受限
configureProcessRestrictions
; - 根据环境变量配置打印信息
DYLD_PRINT_OPTS
、DYLD_PRINT_ENV
; - 获取程序的架构信息
getHostInfo
;
在设置了运行环境之后,_mian函数中通过mapSharedCache
再调用了loadDyldCache
函数,已确保共享缓存库已被加载:
1 | bool loadDyldCache(const SharedCacheOptions& options, SharedCacheLoadInfo* results) |
共享缓存库:实际上就是一个iOS系统动态库的集合,iOS中的系统动态库可以被不同的App所共享,所以系统动态库只需要在第一个App使用它时被加载,后续的App使用时,使用它第一次加载后在内存中的缓存即可,但每个App使用到的系统动态库较多,如果使用到的系统动态库需要逐个的去判断并加载的话,对App的启动速度依然有较大的影响,于是iOS中便将常用的系统动态库都合并到了一个较大的缓存文件中,这样所有App对系统动态库的使用,都只需要确保这个缓存文件已经被加载到了内存中即可,这个缓存文件就是共享缓存库,关于共享缓存库可以参考链接。
共享缓存区与共享缓存库:动态库又被称为共享库,它们会被加载到内存的共享缓存区,供不同的进程共享,共享缓存库是常用系统动态库的集合,它也会被加载到共享缓存区,供所有的进程所共享,但共享缓存区不止共享缓存库,它还可能存在App的Embedded Framework,Embedded Framework不能与外部的App共享,但可以与自己的Extension等共享。
2. 实例化主程序
App使用到的二进制可执行文件都需要被加载、解析并实例化为内存镜像,每一个Mach-O都对应一个镜像实例。实例化主程序就是生成主程序的镜像,主程序二进制文件的读取和解析是在execve中完成的,然后作为参数传递给dyld的,在dyld的_main中通过instantiateFromLoadedImage
将主程序实例化为镜像,再添加到全局的镜像列表中。
1 | static ImageLoaderMachO* instantiateFromLoadedImage(const macho_header* mh, uintptr_t slide, const char* path) |
3. 加载插入库
这一步是将环境变量DYLD_INSERT_LIBRARIES中配置的动态库加载到内存中,先判断环境变量DYLD_INSERT_LIBRARIES中是否有需要插入的动态库,如果有存在则调用loadInsertedDylib
来依次加载,利用DYLD_INSERT_LIBRARIES环境变量是MacOS和iOS上非常知名的一种注入技术,通常越狱插件就是基于此来注入到应用中。
1 | // load any inserted libraries |
Inserted Libraries与Embedded Framework:iOS的App在启动时涉及三类动态库,首先是System Libraries(系统动态库),它们被合并到共享缓存库中,在加载到手机内存之后,由所有App所共享;然后便是Inserted Libraries,它们在启动过程中主程序实例化之后,根据DYLD_INSERT_LIBRARIES环境变量依次进行加载,越狱设备中安装的应用插件通常就是这类动态库;最后便是Embedded Framework,它们是由开发者创建的,属于App自己的动态库,iOS8之前的iOS App是不能有自己的动态库的,在iOS8之后为了满足App和Extension之间代码共享的需求,于是便有了Embedded Framework的概念,它们不能与外部App共享,在App启动过程中链接主程序时,根据LC_LOAD_DYLIB命令和依赖关系递归的加载到内存中。除了这些启动时加载的动态库,MacOS的App还可以通过dlopen()在运行时加载动态库插件,但这在iOS的App提交审核时是被禁止的。
4. 链接镜像
_main函数中调用link
来链接程序的镜像,启动时先链接主程序再链接插入库,通常link
函数链接一个镜像时有三项主要的工作:递归的加载依赖库(App的Embedded Framework的实例化)、递归的Rebase和递归的Binding,这里732.8版本的源码与更早版本源码的做法有些许差异,它将主程序和插入库链接时的Binding先暂时跳过了,放在了主程序和插入库都链接完成之后再进行:
1 | void ImageLoader::link(const LinkContext& context, bool forceLazysBound, bool preflightOnly, bool neverUnload, const RPathChain& loaderRPaths, const char* imagePath) |
Rebase:为了增加攻击者对程序内部地址预测的难度,程序在加载到内存中时会有一个随机的地址偏移值,这项安全技术被称为ASLR(iOS中的ASLR可参考链接),因为这个随机偏移的存在,在程序每次加载到内存后都需要对内部代码中的地址做修正,启动过程中这步操作被称为Rebase,它是修复指向当前镜像内部的地址。
5. 符号绑定
符号绑定包括binding和weak binding,先进行binding,再是weak binding,从源码中可以看出Binding的耗时统计并不包括weak binding:
1 | // Bind and notify for the main executable now that interposing has been registered |
Binding: 通常一个程序中都会包含对外部地址的引用,比如对动态库的使用,动态库不会在编译时被合并到程序中,运行时有自己的地址空间,所以无法在编译时就确定对其相关引用的具体地址,于是就有了PIC(Position Independent Code)技术:程序中指向外部的地址,在编译时先被设置为符号地址,在加载时再根据外部镜像的符号表等信息,重新把符号地址绑定为实际的地址,启动过程中符号地址重新绑定这步操作被称为Binding,它是绑定指向当前镜像外部的地址。
6. 初始化主程序
_main函数调用initializeMainExecutable
函数来初始化程序,初始化时先初始化插入库中的根镜像,再初始化主程序。镜像通过调用recursiveInitialization
方法进行初始化,recursiveInitialization
中会根据依赖关系递归的先执行依赖库的初始化工作。
Xcode中可以通过配置环境变量DYLD_PRINT_INITIALIZERS=1来打印各个依赖库的初始化方法及其调用顺序,可以发现
libSystem
的初始化方法始终是最先调用的。
Objc_init
程序中镜像在初始化时会递归的先进行依赖库的初始化工作,从dyld的源码中可以得知,在没有插入库的情况下,libsystem
通常会是第一个进行初始化的库,而libsystem
的初始化方法中则调用了runtime的初始化方法_objc_init
:
1 | void _objc_init(void) |
_objc_init
中调用dyld的_dyld_objc_notify_register
注册了三个回调方法,来接收dyld加载镜像相关事件的回调,以完成对每个镜像中的Objc相关部分的处理:
1 | void _dyld_objc_notify_register(_dyld_objc_notify_mapped mapped, |
关于处理镜像中Objc相关信息的三个回调方法:
map_images
:在镜像完成加载(即加载进内存中)后被调用,通过_read_images
函数完成对镜像中Objc的类、协议、类别等的初始化工作;load_images
:在镜像的初始化完成后被调用,主要是调用镜像中各种Objc类的+load
方法,完成Objc类的加载;unmap_image
:在移除镜像时被调用,主要是做镜像的Objc相关信息的清理工作。
libsystem
的初始化时调用_objc_init
才注册Objc的处理回调方法,这时主程序和插入库,以及它们依赖库的镜像都已经加载到内存中,所以在**注册回调的方法时,就触发了一次map_images
来批量的初始化已加载镜像中的Objc信息,然后也循环的触发了load_images
,来加载那些在libsystem
初始化之前就已经随镜像载入内存中的Objc类。
7. 返回主程序入口
最后dyld的_main函数会根据不同的情况,查找到对应的主程序入口(通常是主程序中的main函数地址)作为返回值返回,这个值最终会返回给一开始fork创建的进程,进程据此跳转到主程序入口继续运行,主程序中的代码便开始执行。