dyld:启动流程解析

前言

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,所以这个过程步骤为:

  1. 找出 LC_DYSYMTAB__LINKEDIT 这两个 Load Command,获取符号表的偏移;
  2. 对非懒加载表和符号表进行 rebase;
  3. 取出 relocation 对应位置的指针,加上 slide,进行 rebase;

具体代码就不贴了,贴张例图吧:

rebaseDyld

如上图,还未加载进入内存时,mh_header + offset 就等于 Dynamic Synbol Table 的位置,这个位置再加上 Slide 就是内存中动态符号表的位置了。

2. 符号表

符号主要分为两种:内部符号和外部符号。

外部符号是指本 Mach-O 之外的符号,保存在 Indirect Symbols 中。外部符号一般来自系统的动态库,比如 NSLog。例子:

Indirect Symbols

内部符号指本 Mach-O 内的符号。一般存储在 Symbol 表中,比如:

内部符号

另外,OC 相关的方法存储在 __objc_selrefs 中,和内部外部符号不同的是,OC 相关的方法通过方法查找流程动态调用:

objc_selrefs

因为 dyld 未引用其他动态库,其内部的 Indirect Symbols 都指向自己:

dyld外部符号表

可以观察到这些 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;
}

该函数的逻辑为:

  1. 遍历 load command 获取第一个 filesize 不为 0 的 segment 对应的 command;
  2. 获取 segCmd->vmaddr;
  3. 计算 slide = mh - segCmd->vmaddr;

所以,即使存在 __PAGEZERO 这个 Segment,最后也不会依据其 vmaddr 来计算 slide,这是因为 __PAGEZERO 对应的 filesize 为 0。而更深层次的原因是因为 mh_header 和 load command 都是存储在 __TEXT 中,也就是 __text 这个 section 之前;

还有一点需要注意,到此时有且仅有可执行文件(主工程)和 dyld 的 mach-O 文件被加载进入虚拟内存,可以使用 image list 进行查看:

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:

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";
}

这里需要注意几点:

  1. 注释上写的很清除了,实例化操作就是从已经映射到虚拟内存中的主程序来实例化一个 ImageLoaderMachO 对象;
  2. 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 即可以打印环境信息:

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 到了主工程,所以动态链接的过程基本都是针对动态库的,系统的动态库占大头。

这个过程分为:

  1. 递归加载依赖库;

这一步会加载主工程所有的依赖库。使用 context.inSharedCache(requiredLibInfo.name) 优先从共享缓存中查找,如果没有则最终会走到 ImageLoaderMachO::instantiateFromFile 方法,即从磁盘上加载动态库;

到这一步,App 执行所需要的所有代码都已经被加载进入了虚拟缓存中了,后续不会再有增量代码。即动态链接器 dyld 可以获取到所有符号相关的信息;

严格意义上来讲,后面还有插入的动态库的链接操作,仍然会加载新的代码进入虚拟内存。只不过插入的动态库中的功能是 Apple 自己用于做一些支持操作的,和 App 的功能相对独立,代码无关;

  1. 递归刷新层级;

这一步就是刷新依赖库的层级,按照注释,其目的是让被依赖的库在列表的前面,应该是为了后面的 rebase、rebind 操作做铺垫,否则依赖层级过于混乱,后面的步骤就需要很多条件判断或者重复操作。

  1. 递归 rebase;

rebase 第一步是找到 Dynamic Loader Info 的地址:

rebase

基地址 + 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 的大小:

dyld_info_command

除了 weak bind,所有需要进行 rebase 的位置信息都存储在 Dynamic Loader Info 表中:

rebase info

该步骤就是根据该表中的信息对指定位置进行 rebase,而 weak bind 则在后面单独出来;

opcode 的代码主要是对 opcode 相关数据的解码,具体用法暂不深究

  1. 递归 bind;

bind 主要是依据上一步中提到的 Dynamic Loader Info 表中的 binding info 进行符号绑定,ImageLoaderMachOCompressed::eachBind 的主要代码如下:

eachBind

与上一步不同的是,rebase 是去替换 __TEXT 段中对懒加载/非懒加载符号的调用时使用的指针,也就是在原来指针的基础上加上 slide,指向不变,仍然指向懒加载表/非懒加载表。

而 bind 则是找到函数的实际地址后,去替换懒加载/非懒加载表中指针具体的值。也就是将静态时期无法确认的函数地址替换成真实函数地址,如此才能真正调用到具体的函数代码;

  1. weakBind;

其实在 Link 主工程时不会进行 weak bind,因为设置了 linkingMainExecutable 为 true:

linkingMainExecutable

在 weak bind 之前进行了判断:

weak bind

只有在插入的动态库完成链接之后才进行 weak bind:


weak bind

什么是 weak bind?后文会讲~~

  1. 发送一些通知

略~

至此,主工程动态链接完毕,其所依赖的动态库的链接也全部完毕,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代码省略...
}

如上代码:

  1. 先从 index = 1 开始执行每个依赖库的初始化函数;
  2. 执行主 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 中的注释如下:

LC_ROUTINES

根据注释可以大概知道,动态共享缓存库的初始化程序的调用需要知道两个信息:

  1. 初始化程序的地址;
  2. 初始化程序在哪个模块,模块指的应该就是 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);
}

初始化函数的寻找逻辑如下:

mod_init_func

其步骤为:

  1. 遍历 load command,找到 segment 类型的 command;
  2. 遍历 segment command 中的 section command,找到 S_MOD_INIT_FUNC_POINTERS 对应的 section command;
  3. 根据 offset 找到 __mod_init_func 表,遍历表中的函数,经过一些判断之后执行;

上述代码需要注意的是:

  1. 省略了很多判断代码,但是保留了 libSystem initializer;
  2. libSystem 可以看做是包含了很多系统库的一个包装库,其初始化函数需要优先被调用,其中就包括 objc 的初始化;
  3. 寻找 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 上可以很直观的看到:

LC_MAIN

获取到指针 result 之后会作为结果返回给上层,层层 return 之后会给到汇编函数 __dyld_start,这个函数就是调用 dyld 的自举函数,进而完成 dyld 自举、dyld::_main 函数调用的最初入口:

__dyld_start汇编

所以,main 函数的调用是以汇编 __dyld_start来开始,利用 dyldbootstrap::start 函数完成 dyld 的自举、dyld 工作流程之后,dyld 将 main 函数的位置作为 result 返回给汇编代码层 ,进而开始执行 main 函数的代码(机器指令)。

总结下初始化方法的优先级和顺序吧:

  1. libsystem 依赖的库最先执行初始化方法;
  2. 依赖库的初始化方法执行完毕之后,libsystem 执行初始化方法,优先级很高;
  3. libsystem 的初始化方法中完成了很多初始化操作,如 LibSystemHelpers,还有例如 _objc_init 等的调用;
  4. libsystem 的初始化方法调用完毕之后执行主工程依赖库的初始化方法;
  5. 依赖库的初始化方法执行完毕之后,执行主工程的初始化方法;

至此,dyld 的流程全部分析完毕。

三、几点补充

1. weak bind

关于 weak bind 有两个概念:

  1. 弱符号定义;
  2. 弱符号引用;

先说弱符号定义,弱符号定义的代码如下:

void weak_function(void)  __attribute__((weak));

或者:

__attribute__((weak)) void weak_function(void) {
    ...implementation...
}

这么定义有什么用呢?

默认情况下 Symbol 是 strong 的,其特点如下:

  1. strong symbol 必须有实现,否则会报错;
  2. 不可以存在两个名称一样的 strong symbol;
  3. 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

设置完成后如下:


image.png

这个选项的意义是:只是单纯的告诉静态链接器,该符号会在动态链接时被处理,静态链接时期即使找不到也不需要报错。

代码运行之后结果如下:

weak func not exist!

如果将 weak_import 去掉,那么这个符号就是正常的符号了。虽然 Other Link Flag 中设置了静态链接时期不报错,但是这个符号并不是弱符号,所以这个符号如果不存在,动态链接器也不会将其置为 0,所以最终的结果就是,boom:

crash

一般而言,我们工程都不会配置这个弱符号的编译选项,那么弱符号对于我们而言,基本用不上?就算是系统的动态库中使用到了这些,我们工程不配置,仍然用不了这个链接器特性。所以,弱符号的实际使用场景是什么呢?

总结:

  1. weak symbol 就是相对于 strong symbol 的一个优先级更低的符号,一般用来做依赖注入,而 weak bind 就是对这种符号进行绑定,猜测其大概流程是先判断强符号是否存在,没有则使用弱符号的地址,有则使用强符号的地址;
  2. 这也是为什么 weak bind 要在主工程和所有的依赖库全部被加载并绑定完毕之后才做 weak bind,因为只有在这个时候才能确定 weak symbol 是否存在被 strong symbol 覆盖的情况,如果没有才进行 weak bind;

2. libSystem 何时被初始化

上文第八步中说到,libSystem 的初始化方法必须首先被调用,判断代码如下:

libSystem

上述代码只是判断,那么 libSystem 是何时被初始化的呢?这里需要看看上述代码中 libSystemInitialized 的值何时被赋值为 true,搜索之后找到:

libSystemInitialized

可以看到,赋值的逻辑在 recursiveInitialization 中,而这个函数在 ImageLoader 中也存在,估计这是个父子类的关系。

其实 ImageLoader 有很多子类:

ImageLoader子类

看看最关键的两个:

ImageLoaderMachO
ImageLoaderMegaDylib

根据注释可以知道,ImageLoaderMachO 负责 mach-o 的加载,而 ImageLoaderMegaDylib 表示共享缓存中的动态库;

所以,在进行初始化方法调用流程中,走到 recursiveInitialization 时,会进入到不同子类的调用逻辑,搜索该方法,只有两个实现:

recursiveInitialization

也就是说只有根类和 ImageLoaderMegaDylib 类实现了这个方法,而 ImageLoaderMegaDylib 就是在这个方法中完成了 libSystem 的初始化,并且将 dyld 中的 dyld::gProcessInfo->libSystemInitialized 设置为了 true;

所以,后文中的 objc_init 也就是在这个方法中被调用的。也就是说,发生在第一个 notifySingle 调用之前?

然而并不是!!!来看一下 _objc_init 的调用栈就知道了:

_objc_init调用栈

很明显,压根就没有出现 ImageLoaderMegaDylib 这个类,这是为啥?因为上述代码是依据 dyld-433 版本来进行分析的,猜测新版本的 dyld 代码发生了更新,切换到 dyld-633 版本的代码会发现:

dyld-633

有两个地方对 libSystem 初始化完成的状态进行了赋值,仍然来看看和 dyld 第八步相关的 doModInitFunctions 方法:

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 比较基础的函数的调用。

所以,看源码要注意几点:

  1. 按需自取,最好以某个点来学习源码,想要面面俱到反而会迷失,进入死胡同;
  2. 切勿轻信往上博客,最好直接研究一手资料。关键的地方一定要通过实际的项目来验证自己的结论;
  3. 因为很多环境变量、硬件区分等的存在,源码调试也不一定能够代表真实的应用情况;

3. startGlueToCallExit 函数调用时机

这里的分析仅仅是当做 libSystem 初始化逻辑的一个实践,算是对 libSystem 初始化流程的一个深化理解~~~

在上述 dyld 流程最后一步中,在返回 main 函数入口之前还调用了一个 startGlueToCallExit 函数。

这里可以来看看这个函数的调用流程。注意,这里和主流程无关,而且 startGlueToCallExit 这里的代码也是看不到的,所以并没有什么研究价值。这里纯属娱乐,或者是作为理解 libSystem 初始化函数的一个补充例子或者实战吧;

获取 result 之后,如果 result 存在,就开始执行函数 gLibSystemHelpers->startGlueToCallExit;,这是个什么呢?这个函数被定义在 LibSystemHelpers 结构体中:

startGlueToCallExit

其实 LibSystemHelpers 这个结构体在很多地方起到作用,比如刚刚初始化函数的执行时,在 ImageLoaderMegaDylibdoModInitFunctions 函数中就根据这个结构体来判断了 libsystem 的初始化函数是否有被执行:

libSystemHelper

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 的调用逻辑就很简单了:

_objc_init调用栈

也就是说,libSystem 初始化函数被调用时,内部出发了 _objc_init 方法的调用,而 _objc_init 方法的重点在于 map_image、load_image、unmap_image 的回调触发,详见:xxx;

PS:本文是对 dyld 的流程解析,_objc_init 虽然和 dyld 强相关,但是仍然放到类加载的文章中吧,这样不至于该文章过于臃肿~~~

5. load函数的调用逻辑

load 函数调用栈:

image.png

这里也就是提一嘴,详情还是见类加载文章吧~~~

四、dyld3 相关

这块研究不多,贴 dyld-655 或者 dyld-750 的代码意义也不大,暂略吧~~~

PS:如果以后有启动优化的需求,dyld3 这块的研究价值其实还是挺大的,到时候再看吧~~~

五、总结

dyld 大体上只干了三件事:

  1. 文件加载;
  2. 动态链接;
  3. 初始化函数调用;

理解这块要有几个核心认知:

首先 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 流程:

  1. dyld自举;
  2. 一些初始化操作,比如环境初始化;
  3. 实例化主程序,本质是映射成 ImageLoader 对象,后续的 Image 被加载之后都使用这个对象来表示;
  4. 获取共享缓存位置,后续加载依赖库时,如果共享缓存中有,就不需要再加载;
  5. 加载插入的动态库,插入的动态库本身是 Apple 为自己留的口子,比如 MainTheadChecker、层级调试,只不过后来被用到了逆向技术上;
  6. 链接主程序,这其中包括了 依赖库层级刷新、依赖库加载、rebase、bind、weak-bind;
  7. 链接插入的动态库,过程和链接主程序差不多;
  8. 初始化函数调用,这里 libSystem 的初始化在比较靠前的位置被调用(不是第一个),进而初始化了 malloc() 等基础函数。另外触发了 _objc_init ,利用 dyld 专门为 objc 提供的 register 方法完成了 map_image、load_image、unmap_image 三个关键回调的绑定,完成了 objc 相关类的加载逻辑;
  9. dyld 执行完毕后通过 LC_MAIN 获取到 main 函数位置,并且将这个位置返回给汇编代码 __dyld_start,最终执行 main 函数;

你可能感兴趣的:(dyld:启动流程解析)