iOS底层 -- dyld是如何加载app的

3,405 阅读15分钟

欢迎阅读iOS底层系列(建议按顺序)

iOS底层 - alloc和init探索

iOS底层 - 包罗万象的isa

iOS底层 - 类的本质分析

iOS底层 - cache_t流程分析

iOS底层 - 方法查找流程分析

iOS底层 - 消息转发流程分析

iOS底层 - dyld是如何加载app的

iOS底层 - 类的加载分析

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在启动时做了大量的计算和查找操作,系统为了优化启动时间,设计了dyld3dyld3的接口和dyld2是兼容的,因此开发者没有任何感知。

dyld3的特点是进程外的且有缓存的,启动app前把很多耗时操作提前处理好了。据统计,在冷启动时,dyld3dyld2快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.oLoad Commands目录下,LC_UUID所对应的值,即为dyldUUID,通过调用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文件的兼容性,主要通过头部信息的magiccputypecputype等属性。

如果符合以下任一条件,则视为兼容:

  • 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_INFOLC_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_DYLIBLC_LOAD_WEAK_DYLIBLC_REEXPORT_DYLIBLC_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_PATHLD_LIBRARY_PATHDYLD_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完成工作后,类是怎么被加载的。敬请关注。