前言
dyld2 和 dyld3 的主要变化体现在源码上就是 dyld-400 和 dyld-600的版本,比如较低版本的模拟器采用的仍然是 dyld-433 的版本,而 iOS12 之后的真机基本上都采用 dyld-655 以后的版本。
dyld3 在很早就引入,但是一开始只用于 Apple 相关的 App 或者系统库(库还是 App 有待考究)。而在 iOS13 之后,dyld3 正式替代 dyld2,用于加载所有的 App。
dyld-433 版本的源码是比较纯粹 dyld2 的逻辑,而 dyld-655 就能看到很多 dyld3 的优化代码了。dyld3 在流程上有所改进,且源码上也有了很多变化,但是 dyld2 仍然是基础,源码的参考价值仍然比较高,因此本文采用 dyld-433.5 的版本研究 dyld2 的基础流程。偶尔也会对比 dyld-655.1.1 和 dyld-733.6的版本;
一、dyld自举
一般而言,bootstrap 操作由 crt 或者 dyld 来完成,而 dyld 需要自己完成这个操作,所以称为 dyld 的自举;
这个过程包含以下步骤:
1. dyld 的重定位
rebase 操作算是自举中最重要的步骤。因为如果不进行 rebase,dyld 被加载到虚拟缓存中的所有地址都是相对于 mach-o 文件在磁盘中的位置来计算的,即默认计算方式为 slide 等于 0。因为 IPC 技术的存在,如果不 rebase,那么虚拟内存中的函数地址,全局变量的地址等等都是不正确的,直接进行访问肯定是不正确的,所以自举完成之后 dyld 才能使用自己内部的各种代码和数据。
dyld-655 版本中除了对非懒加载表重定位表的 rebase 操作,还新增了 opcode 的处理,这个暂时还不知道是啥,暂不深究~~
重定位操作在 dyld-433 的版本中逻辑相对简单, rebaseDyld 函数就是完成这个操作:
// if kernel had to slide dyld, we need to fix up load sensitive locations
// we have to do this before using any global variables
if ( slide != 0 ) {
rebaseDyld(dyldsMachHeader, slide);
}
dyld 未引用其他的共享库,所以 dyld 中的非懒加载符号都是指向自己内部的,需要在这个过程中进行 rebase,另外就是重定位表中的符号需要进行 rebase,所以这个过程步骤为:
- 找出
LC_DYSYMTAB
和__LINKEDIT
这两个 Load Command,获取符号表的偏移; - 对非懒加载表和符号表进行 rebase;
- 取出 relocation 对应位置的指针,加上 slide,进行 rebase;
具体代码就不贴了,贴张例图吧:
如上图,还未加载进入内存时,mh_header + offset 就等于 Dynamic Synbol Table 的位置,这个位置再加上 Slide 就是内存中动态符号表的位置了。
2. 符号表
符号主要分为两种:内部符号和外部符号。
外部符号是指本 Mach-O 之外的符号,保存在 Indirect Symbols 中。外部符号一般来自系统的动态库,比如 NSLog
。例子:
内部符号指本 Mach-O 内的符号。一般存储在 Symbol 表中,比如:
另外,OC 相关的方法存储在 __objc_selrefs
中,和内部外部符号不同的是,OC 相关的方法通过方法查找流程动态调用:
因为 dyld 未引用其他动态库,其内部的 Indirect Symbols 都指向自己:
可以观察到这些 Indirect Symbols 内部指向的是非懒加载符号表,所以猜测这些符号需要在链接过程中就初始化,否则 dyld 无法完成后续工作。而那些 Symbols 中的符号可以在用到时再初始化(懒加载)。
感觉这里有个潜规则,静态库会直接被 copy 到主工程,所以外部符号必定是动态库中的符号。而 dyld 的外部符号指向自己,这正好和 dyld 的自举相符合。即:dyld 是一个需要自举的动态库,所以外部符号放在 Indirect Symbol 表中且指向自身 Mach-O;
3. 初始化 mach 和参数
这里相对简单,就是直接调用函数:
// allow dyld to use mach messaging
mach_init();
// kernel sets up env pointer to be just past end of agv array
const char** envp = &argv[argc+1];
// kernel sets up apple pointer to be just past end of envp array
const char** apple = envp;
while(*apple != NULL) { ++apple; }
++apple;
具体什么含义,以后再看~~
4. 栈保护
// set up random value for stack canary
__guard_setup(apple);
5. 调用 dyld 初始化函数
#if DYLD_INITIALIZER_SUPPORT
// run all C++ initializers inside dyld
runDyldInitializers(dyldsMachHeader, slide, argc, argv, envp, apple);
#endif
这里就是调用 dyld 内部的 C++ 的初始化函数,也就是调用被 __attribute__((constructor))
修饰的函数。大家最熟悉的 objc_init
也是通过这种方法来调用的。这里先不深究,后面的流程中还会重点讨论;
6. 获取主工程 slide 并调用 dyld 的 _main 函数;
// now that we are done bootstrapping dyld, call dyld's main
uintptr_t appsSlide = slideOfMainExecutable(appsMachHeader);
return dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue);
至此,dyld 的自举流程完毕,这里先获取主工程在内存中的起始位置,然后调用 dyld::main
函数正式开始 dyld 的工作流程;
其实,这里可以看下 slideOfMainExecutable
这个函数的源码:
static uintptr_t slideOfMainExecutable(const struct macho_header* mh)
{
const uint32_t cmd_count = mh->ncmds;
const struct load_command* const cmds = (struct load_command*)(((char*)mh)+sizeof(macho_header));
const struct load_command* cmd = cmds;
for (uint32_t i = 0; i < cmd_count; ++i) {
if ( cmd->cmd == LC_SEGMENT_COMMAND ) {
const struct macho_segment_command* segCmd = (struct macho_segment_command*)cmd;
if ( (segCmd->fileoff == 0) && (segCmd->filesize != 0)) {
return (uintptr_t)mh - segCmd->vmaddr;
}
}
cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
}
return 0;
}
该函数的逻辑为:
- 遍历 load command 获取第一个 filesize 不为 0 的 segment 对应的 command;
- 获取 segCmd->vmaddr;
- 计算 slide = mh - segCmd->vmaddr;
所以,即使存在 __PAGEZERO 这个 Segment,最后也不会依据其 vmaddr 来计算 slide,这是因为 __PAGEZERO 对应的 filesize 为 0。而更深层次的原因是因为 mh_header 和 load command 都是存储在 __TEXT 中,也就是 __text 这个 section 之前;
还有一点需要注意,到此时有且仅有可执行文件(主工程)和 dyld 的 mach-O 文件被加载进入虚拟内存,可以使用 image list
进行查看:
这里需要区分后面步骤的实例化主程序,实例化主程序是解析虚拟内存中的 mach-O 文件并且实例化一个 image 对象,以支持后面对主程序的 link 等操作。而 addImage 也是发生在实例化主程序的过程中,即: addImage 和 实例化主程序都不代表主程序的加载,主程序文件的加载在 dyld 自举之前已经完成;
二、_main 函数
经过上述步骤,dyld 完成了自举操作,接下来调用的 _main
函数才是 dyld 的正式工作流程。其大致步骤为:
1. 模拟器的处理逻辑
#if __MAC_OS_X_VERSION_MIN_REQUIRED
// if this is host dyld, check to see if iOS simulator is being run
const char* rootPath = _simple_getenv(envp, "DYLD_ROOT_PATH");
if ( rootPath != NULL ) {
// Add dyld to the kernel image info before we jump to the sim
notifyKernelAboutDyld();
// look to see if simulator has its own dyld
char simDyldPath[PATH_MAX];
strlcpy(simDyldPath, rootPath, PATH_MAX);
strlcat(simDyldPath, "/usr/lib/dyld_sim", PATH_MAX);
int fd = my_open(simDyldPath, O_RDONLY, 0);
if ( fd != -1 ) {
const char* errMessage = useSimulatorDyld(fd, mainExecutableMH, simDyldPath, argc, argv, envp, apple, startGlue, &result);
if ( errMessage != NULL )
halt(errMessage);
return result;
}
}
#endif
如上代码,检测到如果是模拟器,则直接使用了 my_open
加载固定路径下的 dyld_sim 程序,并且直接将执行结果返回,_main 函数;这也是为什么在模拟器上跑程序,紧跟 dyld 的是 dyld_sim:
2. 设置环境变量
这里就不赘述了,开启启动日志的设置就是在这个阶段判断并起作用;常用的两个:
DYLD_PRINT_ENV
:打印环境信息;
DYLD_PRINT_STATISTICS_DETAILS
:打印启动信息,如时间等;
3. 实例化主程序
来看下实例化的代码:
// instantiate ImageLoader for main executable
sMainExecutable = instantiateFromLoadedImage(mainExecutableMH, mainExecutableSlide, sExecPath);
gLinkContext.mainExecutable = sMainExecutable;
gLinkContext.mainExecutableCodeSigned = hasCodeSignatureLoadCommand(mainExecutableMH);
继续看 instantiateFromLoadedImage
这个函数:
// The kernel maps in main executable before dyld gets control. We need to
// make an ImageLoader* for the already mapped in main executable.
static ImageLoaderMachO* instantiateFromLoadedImage(const macho_header* mh, uintptr_t slide, const char* path)
{
// try mach-o loader
if ( isCompatibleMachO((const uint8_t*)mh, path) ) {
ImageLoader* image = ImageLoaderMachO::instantiateMainExecutable(mh, slide, path, gLinkContext);
addImage(image);
return (ImageLoaderMachO*)image;
}
throw "main executable not a known format";
}
这里需要注意几点:
- 注释上写的很清除了,实例化操作就是从已经映射到虚拟内存中的主程序来实例化一个 ImageLoaderMachO 对象;
- addImage 不代表加载,只是代表向全局数组中添加主 image,这个数组和 image list 展示的 image 内容没有关系;
后面的实例化函数就不具体看了,总结下来实例化的过程就是按照 Mach-O 文件的规则来解析整个 mach-O 文件并以对象的形式保存下来,以供后续的使用。
具体解析规则就不赘述了,不了解的可以看 mach-O文件结构分析;
后面的插入动态库的实例化、依赖库的实例化都会有这个过程。和主程序实例化不同的是,这些过程中包含 load 进入内存的过程,而主工程不需要,主工程在进程(App)启动时就被加载进入内存了;
dyld3 的主要优化点之一就是将 mach-O 文件的解析结果保存,下次启动时直接读取而不用重复解析 mach-O,以此来节省启动时间,这些优化点在 dyld-655 上也有体现,感兴趣的可以看看;
4. 加载共享缓存信息
这里不是直接把共享缓存加载进入虚拟内存(用脚也想得到),而是获取当前共享缓存中共享库相关的信息,以备后续使用:
// load shared cache
checkSharedRegionDisable();
#if DYLD_SHARED_CACHE_SUPPORT
if ( gLinkContext.sharedRegionMode != ImageLoader::kDontUseSharedRegion ) {
mapSharedCache();
} else {
......
}
#endif
checkSharedRegionDisable
方法源码的注释中写到 iPhoneOS cannot run without shared region
,此时该方法啥也没干,所以 iOS 中必须开启共享缓存:
操作系统会将常用的系统动态库存入内存中特定的位置,这样多个程序都使用到这个动态库时,就不需要重复加载了,也不需要每个进程都 Copy 一份,优化了启动时间,同时节省了内存。
和系统动态库不同的是,我们自己生成并嵌入到工程中的动态库依然会被 Copy 到内存且和主工程是两个 Image,内存位置独立。所以可以通过这种方法来规避一些符号表重复的问题。比如主工程中使用到了 FMDB,而 SDK 中也使用到了 FMDB,如果 SDK 是静态库,SDK 在链接阶段会以二进制的形式复制到主工程中,此时就会报符号重复的错误。将 SDK 改成动态库,静态链接阶段,动态库还没有被链接。动态链接阶段,动态库以独立的 Image 形式被加载进入内存,也就不会符号重复了。
dyld-433 和 dyld-655 中实例化主程序和加载共享缓存的顺序不一致,具体原因未知,以后再深究~~~
5. 加载插入的动态库
代码如下:
// load any inserted libraries
if ( sEnv.DYLD_INSERT_LIBRARIES != NULL ) {
for (const char* const* lib = sEnv.DYLD_INSERT_LIBRARIES; *lib != NULL; ++lib)
loadInsertedDylib(*lib);
}
// record count of inserted libraries so that a flat search will look at
// inserted libraries, then main, then others.
sInsertedDylibCount = sAllImages.size()-1;
如上,根据 sEnv.DYLD_INSERT_LIBRARIES
参数来加载插入的动态库,这里实际使用就不演示。
Apple 会通过插入动态库的功能来做一些额外的支持,比如调试功能。在 scheme 中设置 DYLD_PRINT_ENV
即可以打印环境信息:
总结:
- 插入动态库和依赖库没有什么关系,其实是 Apple 为自己预留的功能,Apple 通过这个功能实现了 MainThreadCheck 和栈帧查看的调试功能。只不过这个入口后来被用在了逆向技术上,逆向技术上,可以使用这个入口来调试其他 App 、查看 App 的布局等;
6. 链接主程序
该步骤的真实作用代码在 ImageLoader::link
中,精简如下:
// 递归加载依赖的动态库
this->recursiveLoadLibraries(context, preflightOnly, loaderRPaths, imagePath);
context.notifyBatch(dyld_image_state_dependents_mapped, preflightOnly);
// 递归刷新依赖库层级
context.clearAllDepths();
this->recursiveUpdateDepth(context.imageCount());
// 递归rebase
this->recursiveRebase(context);
context.notifyBatch(dyld_image_state_rebased, false);
// 递归bind
this->recursiveBind(context, forceLazysBound, neverUnload);
// weakBind
if ( !context.linkingMainExecutable )
this->weakBind(context);
context.notifyBatch(dyld_image_state_bound, false);
......
这是 _main
函数中的重头戏,也就是动态链接的过程。因为静态库在静态链接时期就以二进制的形式被 Copy 到了主工程,所以动态链接的过程基本都是针对动态库的,系统的动态库占大头。
这个过程分为:
- 递归加载依赖库;
这一步会加载主工程所有的依赖库。使用 context.inSharedCache(requiredLibInfo.name)
优先从共享缓存中查找,如果没有则最终会走到 ImageLoaderMachO::instantiateFromFile
方法,即从磁盘上加载动态库;
到这一步,App 执行所需要的所有代码都已经被加载进入了虚拟缓存中了,后续不会再有增量代码。即动态链接器 dyld 可以获取到所有符号相关的信息;
严格意义上来讲,后面还有插入的动态库的链接操作,仍然会加载新的代码进入虚拟内存。只不过插入的动态库中的功能是 Apple 自己用于做一些支持操作的,和 App 的功能相对独立,代码无关;
- 递归刷新层级;
这一步就是刷新依赖库的层级,按照注释,其目的是让被依赖的库在列表的前面,应该是为了后面的 rebase、rebind 操作做铺垫,否则依赖层级过于混乱,后面的步骤就需要很多条件判断或者重复操作。
- 递归 rebase;
rebase 第一步是找到 Dynamic Loader Info 的地址:
基地址 + rebase_off 得到 Dynamic Loader Info 表的实际位置,其中基地址就是 __LINKEDIT 之前的 segment 在 vm 上相对于 file 中多出的 size,rebase_off 是指 Dynamic Loader Info 表在文件中相对于起始位置的偏移;
Load Command 中有一个 dyld_info_command,该 command 记录了 Dynamic Loader Info 表的偏移以及单个 rebase info 的大小:
除了 weak bind,所有需要进行 rebase 的位置信息都存储在 Dynamic Loader Info 表中:
该步骤就是根据该表中的信息对指定位置进行 rebase,而 weak bind 则在后面单独出来;
opcode 的代码主要是对 opcode 相关数据的解码,具体用法暂不深究
- 递归 bind;
bind 主要是依据上一步中提到的 Dynamic Loader Info 表中的 binding info 进行符号绑定,ImageLoaderMachOCompressed::eachBind
的主要代码如下:
与上一步不同的是,rebase 是去替换 __TEXT 段中对懒加载/非懒加载符号的调用时使用的指针,也就是在原来指针的基础上加上 slide,指向不变,仍然指向懒加载表/非懒加载表。
而 bind 则是找到函数的实际地址后,去替换懒加载/非懒加载表中指针具体的值。也就是将静态时期无法确认的函数地址替换成真实函数地址,如此才能真正调用到具体的函数代码;
- weakBind;
其实在 Link 主工程时不会进行 weak bind,因为设置了 linkingMainExecutable
为 true:
在 weak bind 之前进行了判断:
只有在插入的动态库完成链接之后才进行 weak bind:
什么是 weak bind?后文会讲~~
- 发送一些通知
略~
至此,主工程动态链接完毕,其所依赖的动态库的链接也全部完毕,dyld 已经完成了大部分工作,所有程序运行所需要的代码、符号、数据等都已经处于可用状态了;
7. 链接插入的动态库
这里不难理解,插入了动态库当然需要链接插入的动态库。其过程和主工程的链接大同小异,不再赘述;
8. 调用初始化函数
代码精简如下:
void initializeMainExecutable() {
// run initialzers for any inserted dylibs
ImageLoader::InitializerTimingList initializerTimes[allImagesCount()];
initializerTimes[0].count = 0;
const size_t rootCount = sImageRoots.size();
if ( rootCount > 1 ) {
for(size_t i=1; i < rootCount; ++i) {
sImageRoots[i]->runInitializers(gLinkContext, initializerTimes[0]);
}
}
// run initializers for main executable and everything it brings up
sMainExecutable->runInitializers(gLinkContext, initializerTimes[0]);
...print代码省略...
}
如上代码:
- 先从 index = 1 开始执行每个依赖库的初始化函数;
- 执行主 image 的初始化函数(注意主工程初始化方法最后调用!!!!);
代码通过 imageloader 的 recursiveInitialization
方法走到 doInitialization
,该方法比较简单,代码如下:
bool ImageLoaderMachO::doInitialization(const LinkContext& context) {
CRSetCrashLogMessage2(this->getPath());
// mach-o has -init and static initializers
doImageInit(context); // LC_ROUTINES 优先级更高
doModInitFunctions(context); // 静态初始化方法,attribute
CRSetCrashLogMessage2(NULL);
return (fHasDashInit || fHasInitializers);
}
doImageInit(context)
方法内部会寻找 LC_ROUTINES
这个 Command,然后去对应的表中获取方法地址并调用。LC_ROUTINES
在
中的注释如下:
根据注释可以大概知道,动态共享缓存库的初始化程序的调用需要知道两个信息:
- 初始化程序的地址;
- 初始化程序在哪个模块,模块指的应该就是 Image,也就是哪个库;
也就是说,动态共享缓存库的初始化程序(可以理解成函数?)需要优先被调用,而这个函数不一定是定义在自己内部的,类似于 libSystem 是多个动态库的包装,所以 libobjc 库的初始化程序代码可能存储在 libSystem 中?所以需要知道 module + address 两个信息才能找到这个初始化程序。而 LC_ROUTINES
的作用就是存储这两个东西。
这个 initialization routine 可能更偏向于库和静态编译的概念,和后文的初始化函数应该不是同一个概念。因为没有找到哪个动态库中包这个
LC_ROUTINES
,所以没办法实践,暂时先不要太关注这个东西。
我们需要关注的是 doModInitFunctions
方法,源码精简如下:
// 省略 S_MOD_INIT_FUNC_POINTERS 的寻找流程
......
Initializer* inits = (Initializer*)(sect->addr + fSlide);
const size_t count = sect->size / sizeof(uintptr_t);
for (size_t j=0; j < count; ++j) {
Initializer func = inits[j];
if ( ! dyld::gProcessInfo->libSystemInitialized ) {
// libSystem initializer must run first
const char* installPath = getInstallPath();
if ( (installPath == NULL) || (strcmp(installPath, LIBSYSTEM_DYLIB_PATH) != 0) )
dyld::throwf("initializer in image (%s) that does not link with libSystem.dylib\n", this->getPath());
}
func(context.argc, context.argv, context.envp, context.apple, &context.programVars);
}
初始化函数的寻找逻辑如下:
其步骤为:
- 遍历 load command,找到 segment 类型的 command;
- 遍历 segment command 中的 section command,找到
S_MOD_INIT_FUNC_POINTERS
对应的 section command; - 根据 offset 找到
__mod_init_func
表,遍历表中的函数,经过一些判断之后执行;
上述代码需要注意的是:
- 省略了很多判断代码,但是保留了 libSystem initializer;
- libSystem 可以看做是包含了很多系统库的一个包装库,其初始化函数需要优先被调用,其中就包括 objc 的初始化;
- 寻找 section command 的代码看太多了,都是一样的,就省略了;
初始化方法内部的调用逻辑很简单,但是初始化方法调用之前的通知机制、 libSystem 如何触发自身以及 libobjc 库的初始化函数,这些逻辑很重要,也相对复杂。这个逻辑和
_objc_init
方法的调用直接相关,调用流程见下文;
9. 执行 main 函数
至此,所有的准备工作已经完成,可以查找 App 的入口函数正式执行程序了:
// notify any montoring proccesses that this process is about to enter main()
notifyMonitoringDyldMain();
// find entry point for main executable
result = (uintptr_t)sMainExecutable->getThreadPC();
if ( result != 0 ) {
// main executable uses LC_MAIN, needs to return to glue in libdyld.dylib
if ( (gLibSystemHelpers != NULL) && (gLibSystemHelpers->version >= 9) )
*startGlue = (uintptr_t)gLibSystemHelpers->startGlueToCallExit;
else
halt("libdyld.dylib support not present for LC_MAIN");
} else {
// main executable uses LC_UNIXTHREAD, dyld needs to let "start" in program set up for main()
result = (uintptr_t)sMainExecutable->getMain();
*startGlue = 0;
}
......
return result;
}
上述代码首先通过 getThreadPC
函数寻找入口函数的指针,如果有返回值,则进行跳转,而 getThreadPC 的代码如下:
void* ImageLoaderMachO::getThreadPC() const {
const uint32_t cmd_count = ((macho_header*)fMachOData)->ncmds;
const struct load_command* const cmds = (struct load_command*)&fMachOData[sizeof(macho_header)];
const struct load_command* cmd = cmds;
for (uint32_t i = 0; i < cmd_count; ++i) {
if ( cmd->cmd == LC_MAIN ) {
entry_point_command* mainCmd = (entry_point_command*)cmd;
void* entry = (void*)(mainCmd->entryoff + (char*)fMachOData);
// verify entry point is in image
if ( this->containsAddress(entry) )
return entry;
else
throw "LC_MAIN entryoff is out of range";
}
cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
}
return NULL;
}
如上代码,可以很清楚的看到通过 LC_MAIN
来查找入口函数指针并返回,在 Mach-O 上可以很直观的看到:
获取到指针 result 之后会作为结果返回给上层,层层 return 之后会给到汇编函数 __dyld_start
,这个函数就是调用 dyld 的自举函数,进而完成 dyld 自举、dyld::_main
函数调用的最初入口:
所以,main 函数的调用是以汇编 __dyld_start
来开始,利用 dyldbootstrap::start
函数完成 dyld 的自举、dyld 工作流程之后,dyld 将 main 函数的位置作为 result 返回给汇编代码层 ,进而开始执行 main 函数的代码(机器指令)。
总结下初始化方法的优先级和顺序吧:
- libsystem 依赖的库最先执行初始化方法;
- 依赖库的初始化方法执行完毕之后,libsystem 执行初始化方法,优先级很高;
- libsystem 的初始化方法中完成了很多初始化操作,如 LibSystemHelpers,还有例如 _objc_init 等的调用;
- libsystem 的初始化方法调用完毕之后执行主工程依赖库的初始化方法;
- 依赖库的初始化方法执行完毕之后,执行主工程的初始化方法;
至此,dyld 的流程全部分析完毕。
三、几点补充
1. weak bind
关于 weak bind 有两个概念:
- 弱符号定义;
- 弱符号引用;
先说弱符号定义,弱符号定义的代码如下:
void weak_function(void) __attribute__((weak));
或者:
__attribute__((weak)) void weak_function(void) {
...implementation...
}
这么定义有什么用呢?
默认情况下 Symbol 是 strong 的,其特点如下:
- strong symbol 必须有实现,否则会报错;
- 不可以存在两个名称一样的 strong symbol;
- strong symbol 可以覆盖 weak symbol 的实现;
所以,若两个或两个以上全局符号(函数或变量名)名字一样,而其中之一声明为 weak symbol(弱符号),则这些全局符号不会引发重定义错误,且链接器会忽略弱符号,去使用普通的全局符号来解析所有对这些符号的引用。但当普通的全局符号不存在时,链接器会使用弱符号。
应用场景:当有函数或变量名可能被用户覆盖时,该函数或变量名可以声明为一个弱符号。
实际应用代码:
// 声明弱符号
void function(void) __attribute__((weak));
// 声明弱符号并实现
__attribute__((weak)) void function(void) {
NSLog(@"weak function");
};
main函数:
int main(int argc, char * argv[]) {
function();
return 0
}
如果在其他地方没有 function
的强实现,则结果为:
weak function
如果在其他文件中有以下实现:
// strong symbol
void function(void) {
NSLog(@"strong function");
};
则结果输出为:
strong function
注意,强弱符号声明在同一个文件也会报错;
接下来就是弱符号引用,弱符号引用一般用来做兼容。当引入的一个符号不一定存在时,可以使用弱引用。链接器识别到弱符号引用时,如果这个弱引用不存在,则会将这个弱引用置为 0,这样不至于发生 symbol not fuound
而导致程序直接出错。业务层也可以通过一些判断来决定是否调用符号。
其定义有多种方式,官方定义是这样的:
#define WEAK_IMPORT __attribute__((weak_import))
WEAK_IMPORT
void clear_name(void);
或者直接使用 weak_import
:
extern int weak_func(int) __attribute__((weak_import));
官方文档没有什么价值,连后文提到的编译选项都没有提到,就不贴了~~~
实际使用场景如下:
extern int weak_func(int) __attribute__((weak_import));
- (void)main {
if (weak_func) {
NSLog(@"weak func exist!");
} else {
NSLog(@"weak func not exist!");
}
return 0;
}
上述代码编译之后仍然会报错,需要设置编译选项:
OTHER_LDFLAGS=$(inherited) -Xlinker -U -Xlinker _weak_func
设置完成后如下:
这个选项的意义是:只是单纯的告诉静态链接器,该符号会在动态链接时被处理,静态链接时期即使找不到也不需要报错。
代码运行之后结果如下:
weak func not exist!
如果将 weak_import
去掉,那么这个符号就是正常的符号了。虽然 Other Link Flag 中设置了静态链接时期不报错,但是这个符号并不是弱符号,所以这个符号如果不存在,动态链接器也不会将其置为 0,所以最终的结果就是,boom:
一般而言,我们工程都不会配置这个弱符号的编译选项,那么弱符号对于我们而言,基本用不上?就算是系统的动态库中使用到了这些,我们工程不配置,仍然用不了这个链接器特性。所以,弱符号的实际使用场景是什么呢?
总结:
- weak symbol 就是相对于 strong symbol 的一个优先级更低的符号,一般用来做依赖注入,而 weak bind 就是对这种符号进行绑定,猜测其大概流程是先判断强符号是否存在,没有则使用弱符号的地址,有则使用强符号的地址;
- 这也是为什么 weak bind 要在主工程和所有的依赖库全部被加载并绑定完毕之后才做 weak bind,因为只有在这个时候才能确定 weak symbol 是否存在被 strong symbol 覆盖的情况,如果没有才进行 weak bind;
2. libSystem 何时被初始化
上文第八步中说到,libSystem 的初始化方法必须首先被调用,判断代码如下:
上述代码只是判断,那么 libSystem 是何时被初始化的呢?这里需要看看上述代码中 libSystemInitialized
的值何时被赋值为 true,搜索之后找到:
可以看到,赋值的逻辑在 recursiveInitialization
中,而这个函数在 ImageLoader 中也存在,估计这是个父子类的关系。
其实 ImageLoader 有很多子类:
看看最关键的两个:
根据注释可以知道,ImageLoaderMachO
负责 mach-o 的加载,而 ImageLoaderMegaDylib
表示共享缓存中的动态库;
所以,在进行初始化方法调用流程中,走到 recursiveInitialization
时,会进入到不同子类的调用逻辑,搜索该方法,只有两个实现:
也就是说只有根类和 ImageLoaderMegaDylib
类实现了这个方法,而 ImageLoaderMegaDylib
就是在这个方法中完成了 libSystem 的初始化,并且将 dyld 中的 dyld::gProcessInfo->libSystemInitialized
设置为了 true;
所以,后文中的 objc_init 也就是在这个方法中被调用的。也就是说,发生在第一个 notifySingle
调用之前?
然而并不是!!!来看一下 _objc_init
的调用栈就知道了:
很明显,压根就没有出现 ImageLoaderMegaDylib
这个类,这是为啥?因为上述代码是依据 dyld-433 版本来进行分析的,猜测新版本的 dyld 代码发生了更新,切换到 dyld-633 版本的代码会发现:
有两个地方对 libSystem 初始化完成的状态进行了赋值,仍然来看看和 dyld 第八步相关的 doModInitFunctions
方法:
很明显,相比于 dyld-433 ,dyld-655 中新增了上述代码,另外结合 _objc_init
调用栈来看,大致猜测新版本 dyld 中不再使用 ImageLoaderMegaDylib
类,而是调整了 image 的顺序,让 libSystem 拍在比较靠前的位置。当优先级比 libSystem 高的库在初始化完成后,再来初始化 libSystem 并做一些标志位处理。
按照代码注释,libsystem初始化函数需要优先被调用,但是符号断点打在
ImageLoaderMachO::doModInitFunctions
中时,实际测试发现并不是第一个doModInitFunctions
就走到了libSystem_initializer
的断点。即:第一个执行初始化方法的动态库不是libSystem.B.dylib
,猜测可能是其依赖库,这一点也可以通过image list
指令得到验证,还有一些库在 libSystem 之前,暂不深究吧~~~
另外,在第九步中的 registerThreadHelper
的调用栈也可以看出,libSystem 就是在第八步的初始化函数调用阶段被初始化(见后文)。
总结:dyld 在第八步中对所有 image 进行初始化函数调用时,libSystem 作为几个比较靠前的 image(不是第一个)被调用到初始化方法,进而完成了 malloc()
初始化、objc_init
等和 OC 比较基础的函数的调用。
所以,看源码要注意几点:
- 按需自取,最好以某个点来学习源码,想要面面俱到反而会迷失,进入死胡同;
- 切勿轻信往上博客,最好直接研究一手资料。关键的地方一定要通过实际的项目来验证自己的结论;
- 因为很多环境变量、硬件区分等的存在,源码调试也不一定能够代表真实的应用情况;
3. startGlueToCallExit 函数调用时机
这里的分析仅仅是当做 libSystem 初始化逻辑的一个实践,算是对 libSystem 初始化流程的一个深化理解~~~
在上述 dyld 流程最后一步中,在返回 main 函数入口之前还调用了一个 startGlueToCallExit
函数。
这里可以来看看这个函数的调用流程。注意,这里和主流程无关,而且 startGlueToCallExit
这里的代码也是看不到的,所以并没有什么研究价值。这里纯属娱乐,或者是作为理解 libSystem 初始化函数的一个补充例子或者实战吧;
获取 result 之后,如果 result 存在,就开始执行函数 gLibSystemHelpers->startGlueToCallExit;
,这是个什么呢?这个函数被定义在 LibSystemHelpers 结构体中:
其实 LibSystemHelpers
这个结构体在很多地方起到作用,比如刚刚初始化函数的执行时,在 ImageLoaderMegaDylib
的 doModInitFunctions
函数中就根据这个结构体来判断了 libsystem 的初始化函数是否有被执行:
dyld-655 版本中,ImageLoaderMegaDylib 的逻辑被用到了 ImageLoaderMachO 这个类中,main 函数的查找逻辑也稍微有点变化;
那么这个结构体在什么时候被赋值的呢?全局查找到如下函数:
static void registerThreadHelpers(const dyld::LibSystemHelpers* helpers) {
dyld::gLibSystemHelpers = helpers;
#if !SUPPORT_ZERO_COST_EXCEPTIONS
if ( helpers->version >= 5 ) {
// create key use by dyld exception handling
pthread_key_t key;
int result = helpers->pthread_key_create(&key, NULL);
if ( result == 0 )
__Unwind_SjLj_SetThreadKey(key);
}
#endif
}
很明显,可以打个符号断点来确定:
运行之后:
结论:该函数在 libsystem 初始化函数中被赋值;
至于
startGlueToCallExit
具体代码肯定是在 libsystem 里面了,自然是看不到了~~~LibSystemHelpers
这个结构体在 dyld 中很多地方被使用,估计这就是为什么需要优先调用 libsystem 的初始化方法的原因吧~~
4. objc_init 函数调用逻辑
知道了 libSystem 的初始化逻辑,那么 _objc_init
的调用逻辑就很简单了:
也就是说,libSystem 初始化函数被调用时,内部出发了 _objc_init
方法的调用,而 _objc_init
方法的重点在于 map_image、load_image、unmap_image 的回调触发,详见:xxx;
PS:本文是对 dyld 的流程解析,_objc_init
虽然和 dyld 强相关,但是仍然放到类加载的文章中吧,这样不至于该文章过于臃肿~~~
5. load函数的调用逻辑
load 函数调用栈:
这里也就是提一嘴,详情还是见类加载文章吧~~~
四、dyld3 相关
这块研究不多,贴 dyld-655 或者 dyld-750 的代码意义也不大,暂略吧~~~
PS:如果以后有启动优化的需求,dyld3 这块的研究价值其实还是挺大的,到时候再看吧~~~
五、总结
dyld 大体上只干了三件事:
- 文件加载;
- 动态链接;
- 初始化函数调用;
理解这块要有几个核心认知:
首先 CPU 执行命令离不开源码,所以程序需要的二进制源码都需要被加载或者映射到虚拟内存中。
再者,因为安全的原因,IPC 技术被引入,程序每次被加载时 slide 都不一样,这就注定了源文件被加载到虚拟内存中时必须进行 rebase、rebind等操作,而且动态库在静态链接时并不知道符号对应的实际地址,这些事情都是由动态链接来完成。
最后,objc 是基于 C/C++ 实现的语言,具有动态性,还有 runtime 来动态管理内存,所以在程序运行之前有些必要的操作需要完成。不仅是 objc,大部分系统库,甚至是主工程都有一些逻辑需要在比较靠前的位置被执行,而初始化函数就是在静态时期在 mach-O 的 __mod_init
表中记录,配合动态链接来实现这个目的;
知道了 why,再来梳理一下 How。从上面三个维度来过一遍三个部分包含的内容:
- 文件加载这部分
首先主工程是以进程的形式被启动,主工程代码必定是存在虚拟内存上的(部分加载到内存,整个文件整个映射到虚拟内存,然后按需分页加载?)。
dyld 的代码是根据 mach-O 中 dyld 的路径来进行加载的。
再就是主工程依赖的动态库的加载,这部分动态库如果存在于共享缓存中,就直接把指针哪来用就好了,否则需要按照路径加载。
最后就是插入的动态库及其依赖库的加载。插入的动态库本质上是 Apple 为自己留的一个口子,一些调试类的库以这种形式被插入。这部分代码在 release 包下不会存在。只不过这个入口在后来被用到了逆向技术上。
- 链接这部分
包括 dyld 的自举,动态库的链接,插入的动态库的链接。这三部分的流程大体都差不多,最主要的工作就两个:根据 slide 进行 rebase、将动态库的 symbol 进行 bind。这也是 dyld 最主要的工作。
- 初始化函数调用
初始化函数包括两部分工作,首先是初始化函数的调用,也就是被 __attribute__
修饰的函数。这部分是比较定式的调用,流程就是递归寻找 image 中的 __mod_init
表中对应的函数并调用。
另外,初始化函数中比较关键的是 libSystem 的初始化。这里也正是 libSystem 初始化函数中,dyld 和 objc 相互配合,利用回调机制完成了 OC 相关类的加载。即:map、load、unmap三个类加载相关的主要逻辑;
dyld 承担的是一个“唤醒”的角色。就像一个人,不能一起床就直接去上班,还需要洗脸刷牙吃饭。dyld 流程、初始化函数的调用等等,就是类似于洗脸刷牙吃饭的作用;
除了上述三个主要工作,dyld 在完成工作之后还需要正式执行程序入口。 dyld 的工作完成之后,通过 LC_MAIN 获取到函数位置,最终传递给汇编代码,从而开始 main 函数的执行。
程序的运行起于汇编函数 __dyld_start
,CPU 在 dyld 中绕了一圈之后,获取到 main 函数入口,最终又回到 __dyld_start
函数,正式执行程序自身的代码。
简述 dyld 流程:
- dyld自举;
- 一些初始化操作,比如环境初始化;
- 实例化主程序,本质是映射成 ImageLoader 对象,后续的 Image 被加载之后都使用这个对象来表示;
- 获取共享缓存位置,后续加载依赖库时,如果共享缓存中有,就不需要再加载;
- 加载插入的动态库,插入的动态库本身是 Apple 为自己留的口子,比如 MainTheadChecker、层级调试,只不过后来被用到了逆向技术上;
- 链接主程序,这其中包括了 依赖库层级刷新、依赖库加载、rebase、bind、weak-bind;
- 链接插入的动态库,过程和链接主程序差不多;
- 初始化函数调用,这里 libSystem 的初始化在比较靠前的位置被调用(不是第一个),进而初始化了 malloc() 等基础函数。另外触发了 _objc_init ,利用 dyld 专门为 objc 提供的 register 方法完成了 map_image、load_image、unmap_image 三个关键回调的绑定,完成了 objc 相关类的加载逻辑;
- dyld 执行完毕后通过 LC_MAIN 获取到 main 函数位置,并且将这个位置返回给汇编代码 __dyld_start,最终执行 main 函数;