欢迎阅读iOS底层系列(建议按顺序)
1.写在前面
本文旨在初步了解app启动前系统的行为,主要分析dyld的操作流程,这对于后续做启动优化
是很有必要的。
2.概念解读
2.1 dyld
dyld
是苹果操作系统的动态链接器。dyld
和操作系统的关系准确来说,操作系统通过映射的方式将它加载到进程的地址空间中。操作系统加载完dyld
后,就把控制权交给dyld
。当dyld
得到控制权后,其自身开始一系列的初始化操作,然后后根据设置的环境变量,对可执行文件
进行链接工作,这个链接步骤称为动态链接
(dynamic link)。当所有链接工作完成后,dyld
会把控制权交给可执行文件
的入口,程序开始执行。
那可执行文件
又是什么,既然有动态链接
,是否也有静态链接
?是的,静态链接
就是生成可执行文件
的一个重要步骤。
2.2 可执行文件
可执行文件
是程序的源代码文件经过预编译、编译、汇编生成的目标文件,进一步通过链接合并成可供系统执行的文件,也就是mach.o
文件。可执行文件需要被装载进内存,才能被系统读取。
这是mach.o文件结构的官方图。其主要分为三个部分:
- Header(头部信息),说明整个Mach.o文件的基本信息
- Load Commands(加载命令),说明系统应该怎么加载文件中的数据
- Data(文件数据),包含符号表,代码签名,程序代码等数据。
可执行文件查看步骤:
在项目Products
文件夹下的.app
结尾的文件 Show In Finder
,然后显示包内容
但是想要可视化可执行文件,还需要借助第三方软件MachOView
。
可执行文件生成的步骤如果展开来,不是三言两语能讲清楚的,这里只做个简单的介绍:
预编译:**预编译的过程就是处理源代码文件中以"#"开头的预编译指令,将其展开。 **
编译:编译的过程就是把预编译后文件进一步做词法分析,语法分析,生成抽象语法树,经过语义分析后转换成中间代码,优化后生成汇编代码文件。
汇编:汇编的过程就是把汇编代码转变成机器可以执行的指令。
链接:链接的过程就是把各个源代码模块相互引用的部分进行处理,让它们之间可以正确链接。这里的链接即为静态链接。
装载:** 装载的过程就是把程序执行所需要的指令和数据都装入内存中**
静态链接是编译时的链接工作,动态链接是运行时的链接工作,虽然它们做的事很相似,但是不可混为一谈。
本人也曾经把两者合并理解,这是严重错误的。此文的dyld隶属于动态链接的流程,需要读者加以区分。
2.3 dyld的版本
dyld2:在iOS13之前使用的版本
dyld3:在iOS13及之后使用的版本,iOS11之前的系统库优化已经使用
dyld2
在启动时做了大量的计算和查找操作,系统为了优化启动时间,设计了dyld3
,dyld3
的接口和dyld2
是兼容的,因此开发者没有任何感知。
dyld3
的特点是进程外的且有缓存的,启动app前把很多耗时操作提前处理好了。据统计,在冷启动时,dyld3
比dyld2
快20%。
但是由于dyld3
是未开源的,因此本文是基于dyld2
展开流程分析的,这些工作流程在dyld3
也是存在的,可能只是时机会有所不同。
2.4 dyld相关的技术
- ASLR(Address space layout randomization):地址空间布局随机化,虚拟地址在内存中会发生的偏移量。增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码位置,达到阻止溢出攻击的目的。
- PIC(Position Independent Code):程序中共享指令部分在装载时不需要因为装载地址的改变而改变,实现共享。
- 页错误(Page Fault):在当前页中找不到所需指令。操作系统需要计算相应页面在可执行文件中的偏移,在物理内存中分配物理页面,在虚拟页和物理页建立映射关系。程序才能从发生页面错误的位置重新执行。
- 延迟绑定(Lazy Binding):函数第一次被使用时才进行绑定(符号查找,重定位等)。
3.dyld初探
3.1 寻找切入点
dyld
既然是在程序执行之前工作的,那可以从程序的入口main()
函数入手。在main()
函数打上断点,
可以看到调用堆栈内main
函数之前有个start
函数,点击start
,
看到start
函数在libdyld.dylib
库中,并且它是开源的
http://opensource.apple.com/tarballs/dyld
老规矩,因为是个函数,直接搜索"strat("进行定位,
可以看到,在汇编文件中的__dyld_start
调用了dyldbootstrap::start
,找到dyldbootstrap
命名空间内的start
函数,发现也就是图中标记的地方。
3.2 start()函数分析
start
函数做了dyld
初始化的相关工作,主要包括:
1.根据虚拟地址空间是否分配来决定是否需要重定位dyld
2.允许dyld使用mach消息传递
3.栈溢出保护
初始化工作完成后,计算mach.o头部信息的偏移值,调用dyld::main()
,再将返回值传递给__dyld_start
去调用真正的main()
函数`。
3.3 dyld::_main()函数分析
dyld::_main()是dyld的核心部分,里面做了非常多的事。其代码非常之多,只截取重要部分展示。
**第一步:设置运行环境 **
① setContext(mainExecutableMH, argc, argv, envp, apple);
static void setContext(const macho_header* mainExecutableMH, int argc, const char* argv[], const char* envp[], const char* apple[]){
gLinkContext.loadLibrary = &libraryLocator;
gLinkContext.terminationRecorder = &terminationRecorder;
...
}
主要是设置运行参数、环境变量等。mainExecutableMH
是一个macho_header
类型的结构体,表示主程序的mach.o
的头部信息,根据头部信息就可以解析整个mach.o
文件,方法内的操作就是保存上下文信息,包括一些回调函数,属性,标志位等。
② checkEnvironmentVariables(envp);
检测环境变量,如果不允许环境变量路径和环境变量打印则返回,否则调用processDyldEnvironmentVariable
设置环境变量。
③ defaultUninitializedFallbackPaths
如果没有设置环境变量,设置默认未初始化的回调路径。
④ getHostInfo(mainExecutableMH, mainExecutableSlide)
获取程序架构,以便后续根据架构做相应的操作
关于环境变量的设置,在如下位置:
可以看到,我设置了几个环境变量,但是只启用了DYLD_PRINT_STATISTICS_DETAILS
,在程序启动时,控制台会输出各个环节的启动时间,这是做启动优化时必要打开的环境变量。
在dyld源码中,也给出了dyld使用的所有环境变量的状态:
struct EnvironmentVariables {
const char* const * DYLD_FRAMEWORK_PATH;
const char* const * DYLD_FALLBACK_FRAMEWORK_PATH;
const char* const * DYLD_LIBRARY_PATH;
const char* const * DYLD_FALLBACK_LIBRARY_PATH;
const char* const * DYLD_INSERT_LIBRARIES;
const char* const * LD_LIBRARY_PATH; // for unix conformance
const char* const * DYLD_VERSIONED_LIBRARY_PATH;
const char* const * DYLD_VERSIONED_FRAMEWORK_PATH;
bool DYLD_PRINT_LIBRARIES_POST_LAUNCH;
bool DYLD_BIND_AT_LAUNCH;
bool DYLD_PRINT_STATISTICS;
bool DYLD_PRINT_STATISTICS_DETAILS;
bool DYLD_PRINT_OPTS;
bool DYLD_PRINT_ENV;
bool DYLD_DISABLE_DOFS;
}
感兴趣的同学可以自行添加测试下效果。
** 第二步:加载共享缓存 **
共享缓存简单来说,就是多个进程之间共享同一份指令。它是基于动态链接的,所以具备节约内存空间,程序更新方便的特性。因为采用了PIC(Position Independent Code)技术,而同时拥有了共享的特性。目前的共享缓存都是系统库。
① checkSharedRegionDisable((dyld3::MachOLoaded*)mainExecutableMH, mainExecutableSlide);
先检查共享区域是否被禁用,不含iOS系统,因为iOS系统内存相对较小,共享缓存必须开启才能正常启动系统。
② mapSharedCache();
其内部通过调用loadDyldCache
,来实现共享缓存加载的三种情况:
-
模拟器时或设置
forcePrivate
,仅加载到当前进程。 -
共享缓存已加载,不做任何处理。
-
当前进程首次加载共享缓存。
** 第三步:将dyld本身加入UUID列表**
① addDyldImageToUUIDList();
主要目的是开启堆栈符号化。根据地址偏移,读取mach.o
内Load Commands
目录下,LC_UUID
所对应的值,即为dyld
的UUID
,通过调用addNonSharedCacheImageUUID
添加到非共享缓存镜像的UUID
列表。
以下为猜想:
dyld
身为系统库,会很自然的觉得它是动态库共享缓存。原先我也是这样认为,但是读到
addNonSharedCacheImageUUID
时,产生了些疑问。此方法意为 添加非共享缓存镜像UUID ,研究其实现,
void addNonSharedCacheImageUUID(const dyld_uuid_info& info){
dyld::gProcessInfo->uuidArray = NULL;
sImageUUIDs.push_back(info);
dyld::gProcessInfo->uuidArrayCount = sImageUUIDs.size();
dyld::gProcessInfo->uuidArray = &sImageUUIDs[0];
}
发现是被添加至uuidArray
中,找到其定义,
const struct dyld_uuid_info* uuidArray;
/* only images not in dyld shared cache */
uuidArray
存的是只有不在dyld
共享缓存中的镜像。
或许可以有以下结论:
dyld
没有采用PIC
技术,它本身是静态链接的。因为它是用来解决共享对象的依赖问题的,如果它也依赖于其他的共享对象,那岂不是依赖无穷无尽。因此它一定是自成一体的,依靠自身的力量完成初始化,不依赖于任何共享对象,甚至本身所需要的全局和静态变量的重定位工作也由它自身完成,这称为自举(Bootstrap)。
**第四步:主程序实例化 **
内核在dyld
获得控制之前在主可执行文件中进行映射,为主可执行文件中已经映射的文件创建一个ImageLoader*。
ImageLoader是一个抽象基类。创建ImageLoader的一个具体子类,以支持加载特定的
可执行文件。对于使用中的每个可执行文件(动态共享对象),将实例化一个ImageLoader。
ImageLoader基类负责将图像链接在一起,但它不关心也不知道关于任何特定的文件格式。
后续的ImageLoaderMachOClassic和ImageLoaderMachOCompressed都继承于它。
① isCompatibleMachO(mem, moduleName)
首先调用isCompatibleMachO
判断mach.o
文件的兼容性,主要通过头部信息的magic
,cputype
,cputype
等属性。
如果符合以下任一条件,则视为兼容:
- mach_header子类型在运行处理器的兼容子类型列表中
- mach_header子类型与正在运行的处理器子类型相同
- mach_header子类型可以在所有处理器变体上运行
② sniffLoadCommands
初始化主程序需要根据加载命令(Load Commands),因此先嗅探下LoadCommands
来获取必要的一些数据。主要获取以下信息:
- compressed:判断是否是压缩类型的
mach.o
,来决定ImageLoaderMachO
的哪个子类初始化主程序
case LC_DYLD_INFO:
case LC_DYLD_INFO_ONLY:
if ( cmd->cmdsize != sizeof(dyld_info_command) )
throw "malformed mach-o image: LC_DYLD_INFO size wrong";
dyldInfoCmd = (struct dyld_info_command*)cmd;
*compressed = true;
break;
如果LC_DYLD_INFO
或LC_DYLD_INFO_ONLY
存在,说明是压缩的mach.o
- segCount:根据
LC_SEGMENT_COMMAND
,其实就是LC_SEGMENT_64
加载命令来统计段数量,如果大于255个抛出异常。
#define LC_SEGMENT_COMMAND LC_SEGMENT_64
case LC_SEGMENT_COMMAND:
segCmd = (struct macho_segment_command*)cmd;
break;
- libCount:根据
LC_LOAD_DYLIB
、LC_LOAD_WEAK_DYLIB
、LC_REEXPORT_DYLIB
、LC_LOAD_UPWARD_DYLIB
这几个加载命令来统计依赖库的数量,超过4095个抛出异常。
case LC_LOAD_DYLIB:
case LC_LOAD_WEAK_DYLIB:
case LC_REEXPORT_DYLIB:
case LC_LOAD_UPWARD_DYLIB:
*libCount += 1;
- codeSigCmd: 签名信息,系统加载库时需要检查签名信息
还有一些其他数据不一一列出,
可以根据Load Commands
目录下的段自行对照分析。
③ instantiateFromMemory
根据是否是压缩的mach.o
,来初始化主程序。压缩使用ImageLoaderMachOCompressed子类,非压缩使用ImageLoaderMachOClassic子类。
④ addImage(image);
将主程序image
加入到全局镜像列表,并映射到申请的内存中。
**第五步:加载任何插入的动态库 **
① loadInsertedDylib(*lib);
内部通过调用load
方法,在load
方法内部通过调用一系列loadPhase
方法,查找出DYLD_ROOT_PATH
、LD_LIBRARY_PATH
、DYLD_FRAMEWORK_PATH
等不同路径下所有要插入的动态库。查找结束之后,先在共享缓存中搜索,如果库存在就从缓存中实例化,找不到,就打开库对应路径的文件读取数据到内存后,再实例化。这里的实例化类似第四步中的流程。最终的镜像文件也同样需要调用addImage
加入全局镜像列表中。
** 第六步:链接主程序和插入的动态库**
这一步调用link()函数将实例化后的主程序缺失的符号和动态库进行链接,让二进制变为可正常执行的状态。这里使用类延迟绑定(lazy binding)技术,加快链接速度。
① 把所有依赖库加载进内存
② 递归更新依赖库的层级,循环直到最底层的依赖库
③ 递归进行依赖库重定位,保证从最底层开始
④ 如果不是链接主程序,递归依赖进来的动态库全部执行符号表绑定
⑤ 如果不是链接主程序,执行弱符号表绑定
** 第六步:执行初始化方法 **
通过调用initializeMainExecutable()
,先初始化动态库,在初始化主程序。内部在依次调用processInitializers()
,recursiveInitialization()
递归初始化。
recursiveInitialization()
内部有个很重要的函数:
context.notifySingle(dyld_image_state_dependents_initialized, this, &timingInfo);
搜索下notifySingle
实现,其内部有个回调函数,
(*sNotifyObjCInit)(image->getRealPath(), image->machHeader());
这个函数能不能调用,取决于sNotifyObjCInit
是否赋值,这是我们关心的,继续搜索,
void registerObjCNotifiers(_dyld_objc_notify_mapped mapped, _dyld_objc_notify_init init, _dyld_objc_notify_unmapped unmapped)
{
// record functions to call
sNotifyObjCMapped = mapped;
sNotifyObjCInit = init;
sNotifyObjCUnmapped = unmapped;
...
}
在registerObjCNotifiers()
函数内,sNotifyObjCInit
被赋值,那继续搜索registerObjCNotifiers()
何时被调用,
void _dyld_objc_notify_register(_dyld_objc_notify_mapped mapped,
_dyld_objc_notify_init init,
_dyld_objc_notify_unmapped unmapped)
{
dyld::registerObjCNotifiers(mapped, init, unmapped);
}
继续往上搜索谁调用了_dyld_objc_notify_register
,发现全是声明和实现。
只能去下个符号断点,运行后bt
打印堆栈信息:
发现_dyld_objc_notify_register
是由libobjc
库中的_objc_init
调用的。
那直接来到之前分析过很多次的libobjc
库中搜索下下_objc_init(
,
看其实现:
在_objc_init
内部对镜像文件进行map
,load
,umap
操作,但是镜像文件是dyld
持有的,libobjc
没有权利获得它。因此在内部注册了几个通知,从dyld那边接手了这几个活。当镜像文件要被初始化时,dyld
会通知libobjc
进行类结构初始化。这时,load
方法被调用。
而递归初始化总是从最底层的库开始,同时根据堆栈信息,libSystem
是最优先的,系统级别的库优先处理也是可以理解的。
notifySingle()函数执行后,来到
bool hasInitializers = this->doInitialization(context);
里面调用doImageInit()
和doModInitFunctions()
。doImageInit()
负责初始化执行镜像的初始化函数。doModInitFunctions
负责执行__attribute__((constructor)
的C函数
bool ImageLoaderMachO::doInitialization(const LinkContext& context)
{
CRSetCrashLogMessage2(this->getPath());
// mach-o has -init and static initializers
doImageInit(context);
doModInitFunctions(context);
CRSetCrashLogMessage2(NULL);
return (fHasDashInit || fHasInitializers);
}
总结下初始化流程:
** runInitializers调用processInitializers进行初始化准备,遍历recursiveInitialization第一次执行时,总是最优先进行libsystem初始化, libsystem的初始化,会调用libdispatch_init,libdispatch初始化会调用_os_object_init, 内部调用了_objc_init _objc_init中注册并保存了map_images、load_images、unmap_image函数地址, 注册完毕继续回到recursiveInitialization递归下一次初始化下一个镜像。 **
** 第七步:寻找程序入口 **
dyld的工作将要结束,需要把控制权交给可执行文件的入口。
这个工作较为简单,直接读取mach.o中的LC_MAIN
段下的数据,不存在读取LC_UNIXTHREAD
下的数据。main
函数开始执行。
4.dyld流程总结
系统先读取可执行文件,从中获取dyld
的路径,然后加载dyld
并转移控制权。dyld
开始初始化运行环境,开启共享缓存,加载程序所依赖的动态库和可执行文件,并对它们进行链接,最后调用每个依赖库的初始化方法。当所有的依赖库初始化结束时,准备初始化可执行文件,这时,会对项目中所有的类结构初始化,然后调用load
方法。最后dyld
读取main
函数的地址,转移控制权给可执行文件,main
函数开始执行。
4.dyld启动优化的思路
- 动态链接需要处理所有依赖的动态库,可以减少动态库的数量。把项目中用到的第三方动态库改为静态库。
- main函数前需要执行load方法,实现load方法的类是非懒加载类。尽量减少load方法调用。
- 类结构初始化会在main函数之前,可以尽量减少类的数量,删除无用类。
- 程序装载的方式一般是使用页映射,页映射就存在页错误,页错误是耗时的。尽可能的把首页出现前的指令放入同一个页。这就是二进制重排的原理。
5.写在最后
此文仅是对dyld主要流程和概念浅显的分析,dyld及其背后蕴藏的启动原理是个庞大的工程,其中每个概念展开都是一次对认知的刷新。因此,这篇文章对dyld的分析会存在错误和曲解的地方,希望开发者能够指出。
虽然dyld很难,而且开发过程中可能也不需要涉及它,但是它的学习价值的巨大的。理解其过程,在遇到一些疑难杂症时,会有透过现象看本质的感觉。
下一篇将要探索dyld完成工作后,类是怎么被加载的。敬请关注。