dyld加载应用启动原理详解

Qinz
我们都知道APP的入口函数是main(),而在main()函数调用之前,APP的加载过程是怎样的呢?接下来我们一起来分析APP的加载流程。

一、利用断点进行追踪

  • 首先我们创建一个工程,什么代码都不写,在main()函数处进行断点,会看到情况如下图:


    01
  1. 通过上图我们可以看到,在调用堆栈中,我们只看到了star和main,并开启了主线程,其它的什么都看不到。那要怎么才能看到调用堆栈详细点的信息了?我们都知道,有一个方法比main()函数调用更早,那就是load()函数,此时在控制器中写一个load函数,并断点运行,如下图:
02
  1. 通过上图,我们看到了比较详细的函数调用顺序,从第13行的_dyld_start到第3行的dyld:notifySingle,频率出现最多的就是这个dyld的家伙,那么dyld是什么?它在做什么?简单来说dyld是一个动态链接器,用来加载所有的库和可执行文件。接下来我们将通过图2的调用关系,去追踪dyld到底在什么?

二、 dyld加载流程分析

1. 首先下载dyld源码
2. 打开dyld源码工程,根据图2的第12行dyldbootstrap:start为关键字搜索dyldbootstrap中调用的start方法,如下图:
03
3. 该方法源码如下,接下来我们对该方法的重点部分进行分析:
uintptr_t start(const struct macho_header* appsMachHeader, int argc, const char* argv[], intptr_t slide)
{
    // 读取macho文件的头部信息
    const struct macho_header* dyldsMachHeader =  (const struct macho_header*)(((char*)&_mh_dylinker_header)+slide);
    
    // 滑块,设置偏移量,用于重定位
    if ( slide != 0 ) {
        rebaseDyld(dyldsMachHeader, slide);
    }
    
    uintptr_t appsSlide = 0;
        
    // 针对偏移异常的监测
    dyld_exceptions_init(dyldsMachHeader, slide);
    
    // 初始化machO文件
    mach_init();

    // 设置分段保护,这里的分段下面会介绍,属于machO文件格式
    segmentProtectDyld(dyldsMachHeader, slide);
    
    //环境变量指针
    const char** envp = &argv[argc+1];
    
    // 环境变量指针结束的设置
    const char** apple = envp;
    while(*apple != NULL) { ++apple; }
    ++apple;

    // 在dyld中运行所有c++初始化器
    runDyldInitializers(dyldsMachHeader, slide, argc, argv, envp, apple);
    
    // 如果主可执行文件被链接-pie,那么随机分配它的加载地址
    if ( appsMachHeader->flags & MH_PIE )
        appsMachHeader = randomizeExecutableLoadAddress(appsMachHeader, envp, &appsSlide);
    
    // 传入头文件信息,偏移量等。调用dyld的自己的main函数(这里并不是APP的main函数)。
    return dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple);
}

  • 3.1 函数的参数中我们看到有一个macho_header的参数,这是一个什么东西呢?Mach-O其实是Mach Object文件格式的缩写,是mac以及iOS中的可执行文件格式,并且有自己的文件格式目录,苹果给出的mach文件如下图:


    04
  • 3.2 首先我们点击进入macho_header这个结构体看它的定义如下:

struct mach_header_64 {
    uint32_t    magic;      /* 区分系统架构版本 */
    cpu_type_t  cputype;    /*CPU类型 */
    cpu_subtype_t   cpusubtype; /* CPU具体类型 */
    uint32_t    filetype;   /* 文件类型 */
    uint32_t    ncmds;      /* loadcommands 条数,即依赖库数量*/
    uint32_t    sizeofcmds; /* 依赖库大小 */
    uint32_t    flags;      /* 标志位 */
    uint32_t    reserved;   /* 保留字段,暂没有用到*/
};
  • 3.3 这里macho_header就是读取macho文件的头部信息,header里面会包含该二进制文件的一些信息:如字节顺序、架构类型、加载指令的数量等。可以用来快速确认一些信息,比如当前文件用于32位还是64位、文件的类型等。那么macho文件在哪里可以找到了呢?如下图,我们找到macho,并用MachOView来查看:


    05
  • 3.4 上面那个黑不溜秋的就是macho文件,是一个可执行文件,我们来看下它加载的头部信息有哪些?这些信息将会被传到下一个函数中。这里简单说下Number of Load Commands数字为22,代表22个库文件,在LoadCommands有加载库的对应关系,Section中就是我们的数据DATA,包含了代码,常量等数据。


    06
  • 3.5 小结:star函数主要就是先读取macho文件的头部信息,设置虚拟地址偏移,这里的偏移主要用于重定向。接下来就是初始化macho文件,用于后续加载库文件和DATA数据,再运行C++的初始化器,最后进入dyly的主函数。

4. 接下来我们继续追踪,根据图2的调用堆栈,我们知道在dyldbootstrap:star方法中调用了dyld::_main方法,也就是我们上面说到的进入dyld的主程序,如下图:
07
  • 4.1 我们进入方法继续追踪,截取部分源如下图,我们发现这里有几个if判断,此处是在设置环境变量,也就是如果设置了这些环境变量,Xcode就会在控制台打印相关的详细信息:
if ( sProcessIsRestricted )
        pruneEnvironmentVariables(envp, &apple);
    else
        checkEnvironmentVariables(envp, ignoreEnvironmentVariables);
    if ( sEnv.DYLD_PRINT_OPTS ) 
        printOptions(argv);
    if ( sEnv.DYLD_PRINT_ENV ) 
        printEnvironmentVariables(envp);
    getHostInfo();  
  • 4.2 当我们设置了相关的环境变量,此时Xcode就会打印程序相关的目录、用户级别、插入的动态库、动态库的路径等,演示图下图:
08
  • 4.3 设置环境变量之后,接下来会调用getHostInfo()来获取machO头部获取当前运行架构的信息,函数代码如下:
static void getHostInfo()
{
#if 1
    struct host_basic_info info;
    mach_msg_type_number_t count = HOST_BASIC_INFO_COUNT;
    mach_port_t hostPort = mach_host_self();
    kern_return_t result = host_info(hostPort, HOST_BASIC_INFO, (host_info_t)&info, &count);
    if ( result != KERN_SUCCESS )
        throw "host_info() failed";
    
    sHostCPU        = info.cpu_type;
    sHostCPUsubtype = info.cpu_subtype;
#else
    size_t valSize = sizeof(sHostCPU);
    if (sysctlbyname ("hw.cputype", &sHostCPU, &valSize, NULL, 0) != 0) 
        throw "sysctlbyname(hw.cputype) failed";
    valSize = sizeof(sHostCPUsubtype);
    if (sysctlbyname ("hw.cpusubtype", &sHostCPUsubtype, &valSize, NULL, 0) != 0) 
        throw "sysctlbyname(hw.cpusubtype) failed";
#endif
}
  • 4.4 接着往下看,这里会对macho文件进行实例化:
    try {
        // 实例化主程序,也就是machO这个可执行文件
        sMainExecutable = instantiateFromLoadedImage(mainExecutableMH, mainExecutableSlide, sExecPath);
        sMainExecutable->setNeverUnload();
        gLinkContext.mainExecutable = sMainExecutable;
        gLinkContext.processIsRestricted = sProcessIsRestricted;
        // 加载共享缓存库
        checkSharedRegionDisable();
    #if DYLD_SHARED_CACHE_SUPPORT
        if ( gLinkContext.sharedRegionMode != ImageLoader::kDontUseSharedRegion )
            mapSharedCache();
    #endif
  • 4.5 进入实例化主程序代码如下,加载完毕后会返回一个ImageLoader镜像加载类,这是一个抽象类,用于加载特定可执行文件格式的类,对于程序中需要的依赖库、插入库,会创建一个对应的image对象,对这些image进行链接,调用各image的初始化方法等等,包括对runtime的初始化。
{
    // isCompatibleMachO 是检查mach-o的subtype是否是当前cpu可以支持
    if ( isCompatibleMachO((const uint8_t*)mh, path) ) {
        ImageLoader* image = ImageLoaderMachO::instantiateMainExecutable(mh, slide, path, gLinkContext);
//将image添加到imagelist。所以我们在Xcode使用image list命令查看的第一个便是我们的machO
        addImage(image);
        return image;
    }
    
    throw "main executable not a known format";
}
  • 4.6 使用image list命令演示如下图,看到的第一个0x000000010401c000地址就是macho这个可执行文件的地址。


    09
  • 4.7 对macho文件进行实例化后,会看到一个checkSharedRegionDisable()的方法,这里是在加载共享缓存库。这个共享缓存库是个什么东西呢? 其实我们可以理解为是系统公用的动态库(苹果禁止第三方使用动态库)。如我们最常用的UIKit框架就在共享缓存库中,举个例子,微信、QQ、支付宝、天猫等APP都会使用到UIKit这个框架,如果每个应用都加载UIKit,势必会导致内存紧张。所以实际是这些APP都会共享一套UIKit框架,应用中用到了对应了UIKit框架中的方法,dyld就会去拿对应的资源供给这些APP使用。如下图展示了越狱手机中System的library中framework的库,也证明了这一点:


    共享缓存库
5. 插入库:我们继续看该方法中的剩余源码,这里将会加载所有插入库,逆向中的代码注入就是在这一步完成的,framework的详细代码注入流程请看我的这篇文章。这里有一个sAllImages.size()-1的操作,实际上是排除了主程序。
    // 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;


6. 链接主程序:内部通过imageLoader的实例对象去调用link方法,递归加载所依赖的系统库和第三方库。
        // link main executable
        gLinkContext.linkingMainExecutable = true;
        link(sMainExecutable, sEnv.DYLD_BIND_AT_LAUNCH, ImageLoader::RPathChain(NULL, NULL));
        gLinkContext.linkingMainExecutable = false;
        if ( sMainExecutable->forceFlat() ) {
            gLinkContext.bindFlat = true;
            gLinkContext.prebindUsage = ImageLoader::kUseNoPrebinding;
        }
        result = (uintptr_t)sMainExecutable->getMain();
7. 初始化函数
10
8. 运行初始化程序:
11
  • 8.1 递归:加载我们所需要的依赖的系统库和第三方库。


    12
9. notifySingle函数,这是一个与运行时建立联系的关键函数:
13
  • 9.1 我们发现notifySingle这个函数中调用了load_images方法,点进去发现这是一个函数指针,里面并没有找到load_images的调用,通过对dyld文件的全局搜索,也没有发现。所以此时我们推断它是在运行时调用的,正好objc运行时代码也是开源的,接下来我们下载objc源码进行分析。
void     (*notifySingle)(dyld_image_states, const ImageLoader* image);
  • 9.2 在objc_init中我们会发现调用,这里load_images。
  _dyld_objc_notify_register(&map_images, load_images, unmap_image);
14
  • 9.3 在load_images中完成call_load_methods的调用,这里就是加载所有类文件及分类文件的load方法:
load_images(const char *path __unused, const struct mach_header *mh)
{
    // 如果这里没有+load方法,则返回时不带锁
    if (!hasLoadMethods((const headerType *)mh)) return;

    recursive_mutex_locker_t lock(loadMethodLock);

    // 发现load方法
    {
        mutex_locker_t lock2(runtimeLock);
        prepare_load_methods((const headerType *)mh);
    }

    // 加载所有load方法
    call_load_methods();
}
  • 9.4 call_load_methods方法调用,在call_load_methods中,通过doWhile循环来调用call_class_loads加载每个类的load方法,然后再加载分类的loads方法。
void call_load_methods(void)
{
    static bool loading = NO;
    bool more_categories;

    loadMethodLock.assertLocked();

    // Re-entrant calls do nothing; the outermost call will finish the job.
    if (loading) return;
    loading = YES;

    void *pool = objc_autoreleasePoolPush();

    do {
        // 1. 循环调用所有类文件的laod方法
        while (loadable_classes_used > 0) {
            call_class_loads();
        }

        // 2.调用所有分类方法
        more_categories = call_category_loads();

        // 3. Run more +loads if there are classes OR more untried categories
    } while (loadable_classes_used > 0  ||  more_categories);

    objc_autoreleasePoolPop(pool);

    loading = NO;
}
  • 9.5 根据上面的调用顺序,我们知道是先加载类文件中的load方法,然后再加载分类文件中的load方法,演示如图:


    15
10. 在调用完notifySigin后,我们发现继续调用了doInitialization,doModInitFunctions会调用machO文件中_mod_init_func段的函数,也就是我们在文件中所定义的全局C++构造函数。
// let objc know we are about to initalize this image
fState = dyld_image_state_dependents_initialized;
oldState = fState;
context.notifySingle(dyld_image_state_dependents_initialized, this);

// initialize this image
this->doInitialization(context);
  • 10.1 所以通过上述代码的调用顺序我们知道先类文件load,再分类文件load,然后再是C++构造函数,最后就进入了我们的main主程序!演示如下:


    16

通过上面的分析,我们从断点开始,查看方法的堆栈调用顺序,一步一步追踪dyld的加载流程,也就将main函数调用前的神秘面纱揭露无疑,你也可以根据上述的步骤自己动手追踪APP的加载过程,这样会更加印象深刻!

总结:main()函数调用之前,其实是做了很多准备工作,主要是dyld这个动态链接器在负责,核心流程如下:

1. 程序执行从_dyld_star开始
  • 1.1. 读取macho文件信息,设置虚拟地址偏移量,用于重定向。
  • 1.2. 调用dyld::_main方法进入macho文件的主程序。
2. 配置一些环境变量
  • 2.1. 设置的环境变量方便我们打印出更多的信息。
  • 2.1. 调用getHostInfo()来获取machO头部获取当前运行架构的信息。
3. 实例化主程序,即macho可执行文件。
4. 加载共享缓存库。
5. 插入动态缓存库。
6. 链接主程序。
7. 初始化函数。
  • 7.1. 经过一系列的初始化函数最终调用notifSingle函数。
  • 7.2. 此回调是被运行时_objc_init初始化时赋值的一个函数load_images
  • 7.3. load_images里面执行call_load_methods函数,循环调用所用类以及分类的load方法。
  • 7.4. doModInitFunctions函数,内部会调用全局C++对象的构造函数,即_ _ attribute_ _((constructor))这样的函数。
8. 返回主程序的入口函数,开始进入主程序的main()函数。

我是Qinz,希望我的文章对你有帮助。

推荐阅读更多精彩内容