【iOS】底层原理:理解dyld
文章目录
- 前言
- dyld介绍
- dyld加载流程
- 具体步骤分析
- dyld版本演进
- dyld 1.0:预绑定时代(1996–2004)
- dyld 2.0:最经典的版本(macOS Tiger ~ iOS 12)
- dyld 3.0:启动闭包时代(WWDC 2017,iOS 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 还在系统的某个动态库文件里躺着。加上 ASLR(Address Space Layout Randomization)这个安全机制让每次启动的地址都随机变化,Mach-O 内部的地址引用也需要修正。这个时候就要用到dyld了
dyld介绍
dyld(Dynamic Linker)是macOS和iOS系统里的动态链接器,是负责运行时加载和链接动态分享库(dylib)或者可执行文件的组件,主要有以下几个作用:
- 加载动态库,把dylib这些动态库映射到内存
- 修地址(rebase),统一把内部的地址进行修正
- 绑定外部符号(bind),找到如NSLog符号等的真正地址
- 通知Runtime,dyld加载后会开始注册类,分类和方法等
- 最后开始执行初始化
核心概念
重定位(Relocation)
程序编译阶段生成的代码、数据中,大量地址都属于未确定的虚拟偏移地址,并非进程运行时的真实内存地址。而重定位就是在装载或者链接阶段,把这些未定死的偏移地址统一修改为当前进程内存空间中真实可用的物理内存地址,让程序能够正常访问函数、全局变量、外部符号等资源。
自举(Bootstrap)
dyld自身同样是Mach-O文件,其内部的全局变量、静态变量、函数调用地址都需要完成重定位才能正常使用,进而出现了一个逻辑矛盾——执行代码需要重定位完成,完成重定位又需要代码执行。
为解决该问题,dyld内置了一段无需依赖全局变量、静态变量,也不用调用任何外部函数,仅依靠寄存器完成基础逻辑的特殊启动代码。这段代码就是dyld的自举过程,它让dyld能够在自身重定位完成之前先跑起来,然后再完成自身的重定位。
dyld加载流程
完整流程速览:
内核创建进程 -> dyld自举 -> dyld::_main环境配置 -> 映射共享缓存 -> 实例化主程序 -> 加载插入动态库 -> 广度优先递归加载依赖库 -> Rebase + Bind -> 执行初始化 -> 调用main
具体步骤分析
内核创建进程,交给dyld
用户点击App图标,系统内核收到启动指令后创建一个新进程,分配独立的虚拟内存空间,将主程序Mach-O文件映射到内存,然后将CPU执行控制权移交给dyld
dyld自举(bootstrap)
dyld本身也是一个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)){launchTraceID=dyld3::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*mainExecutableCDHash=nullptr;// apple数组:内核传给dyld的启动参数包// _simple_getenv(apple, key):apple数组中按key取值,从启动参数包里读取对应的启动标志信息// 通过_simple_getenv,即读取dyld启动标志、读取主程序的CDHash、读取dyld和主程序对应的文件路径if(hexToBytes(_simple_getenv(apple,"executable_cdhash"),40,mainExecutableCDHashBuffer))mainExecutableCDHash=mainExecutableCDHashBuffer;#if!TARGET_OS_SIMULATOR// 真机环境下,向内核登记 dyld 自己和主程序在内存中的加载路径与位置notifyKernelAboutImage((macho_header*)&__dso_handle,_simple_getenv(apple,"dyld_file"));// Trace the main executable's loadnotifyKernelAboutImage(mainExecutableMH,_simple_getenv(apple,"executable_file"));#endif//将主程序 Header 和 ASLR 偏移量存入全局变量,留给后面的 Rebase 和 Bind 阶段使用uintptr_tresult=0;sMainExecutableMachHeader=mainExecutableMH;sMainExecutableSlide=mainExecutableSlide;
以上三步的核心目标是是让 dyld 自己先具备运行能力
由于 dyld 本身也是一个 Mach-O 文件,它同样面临动态链接与重定位问题。因此 dyld 必须先通过自举代码完成自身初始化,随后进入 dyld::_main,开始正式接管整个 App 启动流程
当这一阶段结束的时候,dyld 已经完成基础运行环境搭建,具备了后续加载动态库、执行链接与初始化的能力。
映射系统共享缓存(dyld_shared_cache)
iOS 系统库(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*image=ImageLoaderMachO::instantiateMainExecutable(mh,slide,path,gLinkContext);// 封装主程序内存镜像,纳入dyld统一镜像管理列表addImage(image);return(ImageLoaderMachO*)image;}// 文件格式非法则抛出异常,阻止 App 启动throw"main executable not a known format";}加载插入的动态库
检查
DYLD_INSERT_LIBRARIES环境变量(如果有的话),把指定的注入动态库加载进来。这个机制常用于代码注入、线上调试、底层hook等场景// load any inserted libraries // 加载所有需要被插入的动态库// dyld检查是否设置了DYLD_INSERT_LIBRARIES环境变量,如果有就按顺序加载列表里的所有动态库,插入到当前进程中// sEnv:dyld全局环境变量结构体// DYLD_INSERT_LIBRARIES:dyld最著名的环境变量,用来指定要插入进程的动态库路径列表if(sEnv.DYLD_INSERT_LIBRARIES!=NULL){// 该变量不为空时说明要插入第三方dylibfor(constchar*const*lib=sEnv.DYLD_INSERT_LIBRARIES;*lib!=NULL;++lib)loadInsertedDylib(*lib);}
这一阶段的核心目标,是建立 App 的基础运行环境
dyld 会先映射系统共享缓存,将 UIKit、Foundation等系统动态库快速接入当前进程。
随后,dyld 会把主程序封装成 ImageLoader 对象,并加载额外插入的动态库,
将所有镜像统一纳入 dyld 管理体系。这一阶段结束后,主程序与系统库已经全部进入内存,整个进程的镜像运行环境正式建立完成。
广度优先递归加载所有依赖动态库
这里最关键的一步:解析主程序的动态库依赖列表,采用**广度优先(BFS)**算法逐层递归加载。
举个例子 —— App 依赖 AFNetworking,AFNetworking 又依赖 Foundation,Foundation 又依赖 libobjc,dyld会一层一层全部加载进来,每加载完一个库就把它的符号表合并到全局符号表中,这样才能保证后续的符号查找能找到所有需要的地址。
重定位 + 符号绑定
所有动态库加载完成后,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 12)
dyld 2 是一次完全重写,也是应用时间最长、影响最广的版本。三个核心改进:
共享缓存(dyld_shared_cache):
把所有系统动态库(UIKit、Foundation、CoreGraphics、libobjc 等)打包成一个巨大的缓存文件,系统启动时加载一次,所有 App 共享映射,不重复拷贝、不重复解析,节省内存。
ASLR(Address Space Layout Randomization):
每次启动时,库和可执行文件的加载地址都随机变化,防止攻击者预测内存布局。但这也意味着 Mach-O 内部记录的地址每次都不对,需要 dyld 在运行时修正——这就是前面流程里的 Rebase。
代码签名
确保动态库在运行前没有被篡改。
dyld 2 的缺点也很明显:所有操作都在主线程串行执行,依赖的库越多,启动越慢。
dyld 3.0:启动闭包时代(WWDC 2017,iOS 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),缺符号时首次调用才 crash;
dyld 3 预计算时已经校验了所有符号,缺符号一启动就 crash。
dyld 4.0:双模式引擎(iOS 16+)
dyld 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,本质上就是在理解:一个程序究竟是如何真正运行起来的
