文章目录前言dyld介绍dyld加载流程具体步骤分析dyld版本演进dyld 1.0预绑定时代1996–2004dyld 2.0最经典的版本macOS Tiger ~ iOS 12dyld 3.0启动闭包时代WWDC 2017iOS 13 起强制dyld 4.0双模式引擎iOS 16各个版本小结总结前言我们先从源代码怎么变成可运行的程序说起。我们平常写代码时候编译器需要经过四个步骤才能生成可执行文件预处理处理#开头的预处理指令替换宏展开头文件删除注释输出中间文件.i编译 对.i文件进行词法、语法和语义分析执行代码优化生成汇编代码输出中间文件.s汇编对.s汇编文件翻译成机器码输出目标文件.o链接将多个.o文件与系统库、框架等一起链接成可执行文件解决函数/变量引用、地址重定位等输出最终文件即可执行程序前三个步骤把源代码变成机器码链接是最后一步。链接要解决的核心问题就是把多个目标文件整合到一起让它们能相互找到对方。具体来说做了三件事地址空间分配每个 .o 文件编译时都是从 0 开始编址的链接器需要给它们分配唯一的虚拟地址空间把代码段、数据段等放到正确的位置符号决议Symbol Resolution目标文件 A 调用了目标文件 B 里的函数链接器需要在全局范围内找到这个函数的定义位置把引用和定义对应上重定位Relocation编译阶段生成的地址都是临时的链接器要把这些临时地址修正为最终的运行地址注意这里的链接是编译时的静态链接而 dyld 做的是运行时的动态链接后面会看到两者的区别搞定这些之后就面临一个关键选择要链接的库是直接打包进程序还是等运行时再加载这就引出了 库文件的差别库文件分为两种静态库.a在链接阶段会将可汇编生成的目标程序与引用的库一起链接打包到可执行文件当中。此时的静态库就不会在改变了因为它是编译时被直接拷贝一份复制到目标程序里的好处编译完成后库文件实际上就没有作用了目标程序没有外部依赖直接就可以运行缺点由于静态库会有两份所以会导致目标程序的体积增大对内存、性能、速度消耗很大动态库.dylib / Framework程序编译时并不会链接到目标程序中目标程序只会存储指向动态库的引用在程序运行时才被载入优势减少打包之后app的大小因为不需要拷贝至目标程序中所以不会影响目标程序的体积与静态库相比减少了app的体积大小共享内存节约资源同一份库可以被多个程序使用通过更新动态库达到更新程序的目的由于运行时才载入的特性可以随时对库进行替换而不需要重新编译代码缺点动态载入会带来一部分性能损失使用动态库也会使得程序依赖于外部环境如果环境缺少了动态库或者库的版本不正确就会导致程序无法运行iOS 里大量使用动态库比如 UIKit、Foundation等。这样做的好处是多个 App 共享系统库的一份内存节省空间也方便单独更新系统库而不用重新编译 App。编译链接完成后生成的就是 iOS 上的可执行文件格式 ——Mach-O类似 Windows 的 .exe。Mach-O 记录了代码和数据在哪、依赖哪些动态库、程序入口在哪等信息本质上就是一个规范的二进制文件。但这个文件放在硬盘上是不能直接运行的。需要操作系统把它加载进内存CPU 才开始执行。可问题来了Mach-O 里只记录了我依赖 UIKit这个信息并没有把 UIKit 的代码真正链接进来此时 UIKit 还在系统的某个动态库文件里躺着。加上 ASLRAddress Space Layout Randomization这个安全机制让每次启动的地址都随机变化Mach-O 内部的地址引用也需要修正。这个时候就要用到dyld了dyld介绍dyld(Dynamic Linker)是macOS和iOS系统里的动态链接器是负责运行时加载和链接动态分享库(dylib)或者可执行文件的组件主要有以下几个作用加载动态库把dylib这些动态库映射到内存修地址(rebase)统一把内部的地址进行修正绑定外部符号(bind),找到如NSLog符号等的真正地址通知Runtimedyld加载后会开始注册类分类和方法等最后开始执行初始化核心概念重定位Relocation程序编译阶段生成的代码、数据中大量地址都属于未确定的虚拟偏移地址并非进程运行时的真实内存地址。而重定位就是在装载或者链接阶段把这些未定死的偏移地址统一修改为当前进程内存空间中真实可用的物理内存地址让程序能够正常访问函数、全局变量、外部符号等资源。自举Bootstrapdyld自身同样是Mach-O文件其内部的全局变量、静态变量、函数调用地址都需要完成重定位才能正常使用进而出现了一个逻辑矛盾——执行代码需要重定位完成完成重定位又需要代码执行。为解决该问题dyld内置了一段无需依赖全局变量、静态变量也不用调用任何外部函数仅依靠寄存器完成基础逻辑的特殊启动代码。这段代码就是dyld的自举过程它让dyld能够在自身重定位完成之前先跑起来然后再完成自身的重定位。dyld加载流程完整流程速览内核创建进程 - dyld自举 - dyld::_main环境配置 - 映射共享缓存 - 实例化主程序 - 加载插入动态库 - 广度优先递归加载依赖库 - Rebase Bind - 执行初始化 - 调用main具体步骤分析内核创建进程交给dyld用户点击App图标系统内核收到启动指令后创建一个新进程分配独立的虚拟内存空间将主程序Mach-O文件映射到内存然后将CPU执行控制权移交给dylddyld自举bootstrapdyld本身也是一个Mach-O文件它自己也依赖动态库也需要重定位。但这就产生了一个经典问题dyld 自己本身也是一个 Mach-O它同样需要重定位后才能正常运行。可问题是想完成重定位需要先执行代码想执行代码又必须先完成重定位。dyld解决这个问题靠的是自举——dyld内置了一段不需要全局变量、静态变量也不用调用外部函数的启动代码仅依靠寄存器完成基础逻辑。这段代码从汇编入口__dyld_start开始执行完成dyld自身的地址重定位、栈保护初始化最后跳转到C主函数dyld::_main。这一阶段结束后dyld 才真正具备后续动态链接能力。dyld::_main 环境配置进入dyld::_main后开始读取系统环境变量、获取当前设备架构arm64 / x86_64、读取主程序的CDHash校验值和本地路径、向内核上报加载状态、启动耗时统计CDHash代码签名哈希用于系统安全检验uintptr_t_main(constmacho_header*mainExecutableMH,uintptr_tmainExecutableSlide,intargc,constchar*argv[],constchar*envp[],constchar*apple[],uintptr_t*startGlue){// mainExecutableMH: 主程序 Mach-O 的头部指针// mainExecutableSlide: ASLR 随机偏移量// apple: 内核传给 dyld 的特殊参数包if(dyld3::kdebug_trace_dyld_enabled(DBG_DYLD_TIMING_LAUNCH_EXECUTABLE)){launchTraceIDdyld3::kdebug_trace_dyld_duration_start(DBG_DYLD_TIMING_LAUNCH_EXECUTABLE,(uint64_t)mainExecutableMH,0,0);}//Check and see if there are any kernel flagsdyld3::BootArgs::setFlags(hexToUInt64(_simple_getenv(apple,dyld_flags),nullptr));// Grab the cdHash of the main executable from the environmentuint8_tmainExecutableCDHashBuffer[20];constuint8_t*mainExecutableCDHashnullptr;// apple数组内核传给dyld的启动参数包// _simple_getenv(apple, key)apple数组中按key取值从启动参数包里读取对应的启动标志信息// 通过_simple_getenv即读取dyld启动标志、读取主程序的CDHash、读取dyld和主程序对应的文件路径if(hexToBytes(_simple_getenv(apple,executable_cdhash),40,mainExecutableCDHashBuffer))mainExecutableCDHashmainExecutableCDHashBuffer;#if!TARGET_OS_SIMULATOR// 真机环境下向内核登记 dyld 自己和主程序在内存中的加载路径与位置notifyKernelAboutImage((macho_header*)__dso_handle,_simple_getenv(apple,dyld_file));// Trace the main executables loadnotifyKernelAboutImage(mainExecutableMH,_simple_getenv(apple,executable_file));#endif//将主程序 Header 和 ASLR 偏移量存入全局变量留给后面的 Rebase 和 Bind 阶段使用uintptr_tresult0;sMainExecutableMachHeadermainExecutableMH;sMainExecutableSlidemainExecutableSlide;以上三步的核心目标是是让 dyld 自己先具备运行能力由于 dyld 本身也是一个 Mach-O 文件它同样面临动态链接与重定位问题。因此 dyld 必须先通过自举代码完成自身初始化随后进入 dyld::_main开始正式接管整个 App 启动流程当这一阶段结束的时候dyld 已经完成基础运行环境搭建具备了后续加载动态库、执行链接与初始化的能力。映射系统共享缓存dyld_shared_cacheiOS 系统库UIKit、Foundation、libobjc等都打包在一个巨大的缓存文件中叫dyld_shared_cache这个缓存在系统启动时加载一次所有App共享映射(共享区域存放苹果所有系统底层代码框架代码运行时数据和符号地址表)共享缓存的核心特点是系统启动时加载一次所有App共同映射、共同使用不重复拷贝、不重复解析、不重复占用内存只读映射、安全稳定。这样可以大幅减少App冷却时间极大降低设备整体内存占用// load shared cachecheckSharedRegionDisable((dyld3::MachOLoaded*)mainExecutableMH,mainExecutableSlide);if(gLinkContext.sharedRegionMode!ImageLoader::kDontUseSharedRegion){#ifTARGET_OS_SIMULATORif(sSharedCacheOverrideDir)mapSharedCache();#elsemapSharedCache();#endif}实例化主程序调用instantiateFromLoadedImage函数将已经在内存中的主程序Mach-O包装成一个ImageLoader对象验证Mach-O文件合法性把主程序镜像纳入dyld的统一管理列表// The kernel maps in main executable before dyld gets control. We need to// make an ImageLoader* for the already mapped in main executable.staticImageLoaderMachO*instantiateFromLoadedImage(constmacho_header*mh,uintptr_tslide,constchar*path){// try mach-o loader// 验证 Mach-O 的文件头、CPU 架构及文件类型是否合法if(isCompatibleMachO((constuint8_t*)mh,path)){// instantiateMainExecutable内部读取Mach-O文件头解析所有Load Command加载指令完成基础信息校验ImageLoader*imageImageLoaderMachO::instantiateMainExecutable(mh,slide,path,gLinkContext);// 封装主程序内存镜像纳入dyld统一镜像管理列表addImage(image);return(ImageLoaderMachO*)image;}// 文件格式非法则抛出异常阻止 App 启动throwmain executable not a known format;}加载插入的动态库检查DYLD_INSERT_LIBRARIES环境变量如果有的话把指定的注入动态库加载进来。这个机制常用于代码注入、线上调试、底层hook等场景// load any inserted libraries // 加载所有需要被插入的动态库// dyld检查是否设置了DYLD_INSERT_LIBRARIES环境变量如果有就按顺序加载列表里的所有动态库插入到当前进程中// sEnvdyld全局环境变量结构体// DYLD_INSERT_LIBRARIESdyld最著名的环境变量用来指定要插入进程的动态库路径列表if(sEnv.DYLD_INSERT_LIBRARIES!NULL){// 该变量不为空时说明要插入第三方dylibfor(constchar*const*libsEnv.DYLD_INSERT_LIBRARIES;*lib!NULL;lib)loadInsertedDylib(*lib);}这一阶段的核心目标是建立 App 的基础运行环境dyld 会先映射系统共享缓存将 UIKit、Foundation等系统动态库快速接入当前进程。随后dyld 会把主程序封装成 ImageLoader 对象并加载额外插入的动态库将所有镜像统一纳入 dyld 管理体系。这一阶段结束后主程序与系统库已经全部进入内存整个进程的镜像运行环境正式建立完成。广度优先递归加载所有依赖动态库这里最关键的一步解析主程序的动态库依赖列表采用**广度优先BFS**算法逐层递归加载。举个例子 —— App 依赖 AFNetworkingAFNetworking 又依赖 FoundationFoundation 又依赖 libobjcdyld会一层一层全部加载进来每加载完一个库就把它的符号表合并到全局符号表中这样才能保证后续的符号查找能找到所有需要的地址。重定位 符号绑定所有动态库加载完成后dyld执行两大核心链接操作Rebase内部重定位由于ASLR机制每次启动的基地址都不同Mach-O内部原先记录的所有地址偏移都作废了。Rebase就是把这些内部地址全部加上一个slide偏移值修正到当前进程的真实地址。Bind外部符号绑定代码里调用了NSLog、NSString等外部符号这些符号在编译时不知道地址。Bind阶段去全局符号表里匹配把每个符号绑定到它在内存中的真实地址。经过 Rebase 和 Bind 后程序中的所有内部地址和外部符号才都指向了真实可执行地址这一阶段是真正的动态链接阶段也是 dyld 最核心的工作dyld 会递归加载所有依赖动态库构建完整的动态库依赖树。随后通过 Rebase 修正镜像内部地址再通过 Bind 完成外部符号绑定经过这一阶段后程序中的所有代码、函数与符号终于都拥有了真实可执行地址。直到这里App 才真正具备可以运行的条件执行初始化按照先依赖库、后主程序的顺序触发初始化方法。这一步会做三件事load_images执行所有Objective-C类的load方法doModInitFunctions执行C全局对象构造函数执行__attribute__((constructor))修饰的函数dyld在这一步结束时通知objc runtime“初始化完成”。找到main函数移交控制权dyld解析Mach-O文件中的LC_MAIN加载命令定位到main函数的内存地址跳转过去。此后CPU由我们写的main函数接管App进入业务启动阶段调用 UIApplicationMain、展示首页等这一阶段标志着 dyld 启动流程马上结束dyld 会按照依赖顺序执行初始化逻辑包括 Objective-C 的 load、C 全局构造函数、以及 constructor 初始化函数随后 dyld 定位 main 函数入口并将 CPU 执行控制权正式交给开发者代码。从这一刻开始App 才真正进入业务启动阶段开始执行UIApplicationMain、首页渲染等逻辑dyld版本演进dyld 从诞生到现在经历了四代这几代演进本质上就是尽量不在 App 启动时临时处理能提前缓存的提前缓存能提前计算的提前计算实在提前不了的再运行时处理。其核心目标一直没变让 App 启动更快。dyld 1.0预绑定时代1996–2004最早的 dyld 出现在 NeXTStep 3.3 时代当时需要解决的问题是C 初始化器在动态环境下导致 dyld 做大量重复工作。优化手段叫Prebinding预绑定给系统动态库和应用程序分配固定的内存地址启动前就把地址写进二进制文件运行时直接使用省去重定位的计算。但代价很大每次启动都要修改二进制数据安全性差系统库一更新所有依赖它的 App 都得重新预绑定dyld 2.0最经典的版本macOS Tiger ~ iOS 12dyld 2 是一次完全重写也是应用时间最长、影响最广的版本。三个核心改进共享缓存dyld_shared_cache:把所有系统动态库UIKit、Foundation、CoreGraphics、libobjc 等打包成一个巨大的缓存文件系统启动时加载一次所有 App 共享映射不重复拷贝、不重复解析节省内存。ASLRAddress Space Layout Randomization:每次启动时库和可执行文件的加载地址都随机变化防止攻击者预测内存布局。但这也意味着 Mach-O 内部记录的地址每次都不对需要 dyld 在运行时修正——这就是前面流程里的 Rebase。代码签名确保动态库在运行前没有被篡改。dyld 2 的缺点也很明显所有操作都在主线程串行执行依赖的库越多启动越慢。dyld 3.0启动闭包时代WWDC 2017iOS 13 起强制dyld 3 的核心理念是把耗时的操作移出 App 的启动过程它引入了一套三段式的工作流程Out-of-Process进程外预计算App 安装、更新或重启后系统后台守护进程预先解析 Mach-O 文件计算所有依赖关系、符号地址和偏移量生成一个二进制文件 —— Launch Closure启动闭包In-Process进程内执行App 启动时直接读取预计算好的 Launch Closure跳过耗时的符号查找和依赖解析Launch Closure 本身系统 App 的闭包内置在 shared cache 里第三方 App 在安装或更新时生成效果冷启动速度提升代价如果 App 或其依赖库被修改比如热修复、签名变更预计算的闭包就失效了需要回退到类似 dyld 2 的慢速模式。另外有一个值得注意的行为差异dyld 2 采用懒加载lazy symbol缺符号时首次调用才 crashdyld 3 预计算时已经校验了所有符号缺符号一启动就 crash。dyld 4.0双模式引擎iOS 16dyld 4 解决了 dyld 3 在热修复等动态场景下闭包失效的问题结合了 dyld 2 的灵活性和 dyld 3 的高性能。核心是双模式引擎PrebuiltLoader预构建加载器类似 dyld 3 的闭包但更轻量只存必要元数据优先使用JustInTimeLoader即时加载器类似 dyld 2 的实时解析当预构建加载器失效时无缝切换解析完成后再生成新的预构建缓存供下次使用简单理解dyld 3 是闭包失效就回退慢速模式dyld 4 是预构建不行就即时解析顶上同时把结果缓存起来下次就能用预构建了。各个版本小结dyld 1.0(预绑定)固定地址省去运行时计算但安全和维护成本高dyld 2.0(共享缓存 ASLR)经典版本系统库共用一份内存启动时随机地址dyld 3.0 (启动闭包)安装时预计算启动直接读速度提升但热修复场景失效dyld 4.0(双模式引擎)预构建 即时加载智能切换兼顾速度与灵活性总结回到最开始的 App 启动流程现在我们可以完整回答从点击图标到 main 函数之间发生了什么点击App - 内核创建进程、加载Mach-O - dyld自举 - 环境配置- 映射共享缓存 - 实例化主程序 - 加载插入库- 广度优先递归加载依赖库 - Rebase Bind- 执行 load / C构造器 / constructor- 找到 main 并跳转 - UIApplicationMain - 首页出现dyld 的核心价值可以归结为一句话把 Mach-O 从硬盘上的一堆静态数据变成内存中可以实际运行的程序为了做到这一点它解决了三个关键问题自己怎么先跑起来——自举代码不依赖任何外部函数和全局变量所有依赖怎么拉进来——共享缓存 广度优先递归加载地址和符号怎么修正确——Rebase 解决 ASLR 导致的内部地址偏移Bind 解决外部符号查找这四个版本的历史演进也反映了 Apple 在启动性能上的更迭从 dyld 2 的共享缓存减少重复加载到 dyld 3 的启动闭包预计算再到 dyld 4 的双模式引擎兼顾灵活性与性能。所以理解 dyld本质上就是在理解一个程序究竟是如何真正运行起来的