iOS类加载流程(一):类加载流程的触发

首先,大家应该都知道 _objc_init 函数是 OC 中类加载比较关键的一个函数,这个函数的调用栈如下:

objc_init

那么,objc_init 这个函数是如何被调用的呢?又和 OC 中的类加载有什么关系?类又是如何被加载并以什么形式存在于运行时呢?OC 中的成员变量、方法、协议、分类,这些都是如何实现的?

1. objc_init 的调用流程

从调用栈可以看到,_objc_init 起始于 doModinitFunctions 这个方法。这个方法在 dyld 中,因为 dyld3 都已经在 iOS12 被全面使用了,dyld-433 仍然是 dyld2 的版本,dyld-655 已经是 dyld3 的版本了,所以这里以 dyld-655 的源码来探索 _objc_init 的调用流程。

首先,doModinitFunctions 这个函数属于 dyld 流程的“初始化方法调用”阶段。这一阶段是整个流程的倒数第二步,也就是执行 main 函数之前的阶段。

dyld 详细流程见dyld:启动流程解析

doModinitFunctions 函数在 ImageLoader::recursiveInitialization 中被调用,关键代码如下:

recursiveInitialization

先看看 doInitialization 方法的逻辑:

bool ImageLoaderMachO::doInitialization(const LinkContext& context) {
    CRSetCrashLogMessage2(this->getPath());

    // mach-o has -init and static initializers
    doImageInit(context);
    doModInitFunctions(context);
    
    CRSetCrashLogMessage2(NULL);
    
    return (fHasDashInit || fHasInitializers);
}

很明显,关键逻辑在于 doImageInitdoModInitFunctions 这两个函数。

doImageInit 内部经过逻辑主要是找出该 Image 对应的 mach-O 文件中 LC_ROUTINES 表内的函数进行调用:

doImageInit

LC_ROUTINES 的定义可以直接在 mach-o 库的 loader.h 中看到,如果 dyld 源码中无法跳转,可以在自己的项目中 import <mach-o/loader.h> 来看到具体的内容:

/*
 * The routines command contains the address of the dynamic shared library 
 * initialization routine and an index into the module table for the module
 * that defines the routine.  Before any modules are used from the library the
 * dynamic linker fully binds the module that defines the initialization routine
 * and then calls it.  This gets called before any module initialization
 * routines (used for C++ static constructors) in the library.
 */
struct routines_command { /* for 32-bit architectures */
    uint32_t    cmd;        /* LC_ROUTINES */
    uint32_t    cmdsize;    /* total size of this command */
    uint32_t    init_address;   /* address of initialization routine */
    uint32_t    init_module;    /* index into the module table that */
                        /*  the init routine is defined in */
    uint32_t    reserved1;
    uint32_t    reserved2;
    uint32_t    reserved3;
    uint32_t    reserved4;
    uint32_t    reserved5;
    uint32_t    reserved6;
};

根据注释来看,LC_ROUTINES 大概就是动态库在调用初始化函数之前需要被调用的函数。找了几个动态库,也没有找到包含 LC_ROUTINES 这个 load command 的动态库,暂时不深究吧~~~

紧接着,就来到了调用栈上最初的 doModInitFunctions 函数了,这个函数做了这么几件事:

  1. 递归寻找 Load Command,找到 S_MOD_INIT_FUNC_POINTERS 这个 section 对应的 Load Command;
  2. 根据 slide 计算 S_MOD_INIT_FUNC_POINTERS 的具体位置,并且取出这个表中的函数指针;
  3. 进行一系列判断之后调用这些函数;
  4. 在函数调用前后进行判断,如果函数调用使得 dyld::gLibSystemHelpers 有值了,证明 libSystem 初始化完成,此时将 dyld::gProcessInfo->libSystemInitialized 标志置为 true;

关键代码:

doModInitFunctions

简而言之:

  1. dyld 在动态链接完成之后会执行所有动态库的初始化函数,最后执行主工程的初始化函数;
  2. 初始化函数需要使用 __attribute__修饰,编译器识别之后会存储在 Mach-O 文件的 __mod_init_func 中;
  3. 因为 libSystem 是一系列系统库的集合,被很多动态库依赖,优先级更高,libSystem 的初始化函数会在比较靠前的顺序开始执行(不是第一)。而 objc 就被包含在这个库中。objc 库的初始化方法 objc_init 就是在 libSystem 的初始化函数中被调用;
  4. objc_init 方法中包含了 OC 类的加载逻辑;

至此,可以做个阶段性总结了:

  1. dyld 初始化函数调用阶段会去递归调用 image 的初始化函数;
  2. libSystem 库在比较靠前的位置被调用,进而触发了 _objc_init 函数的调用;

2. _objc_init 方法做了什么

来看下 objc_init 方法里面的代码吧:

void _objc_init(void) {
    static bool initialized = false;
    if (initialized) return;
    initialized = true;
    
    // 环境初始化相关
    environ_init();
    // 线程相关
    tls_init();
    // objc库初始化方法调用,即objc库中被__attribute__修饰的方法
    static_init();
    // 暂无任何逻辑
    lock_init();
    // NSSetUncaughtExceptionHandler()的基础
    exception_init();

    _dyld_objc_notify_register(&map_images, load_images, unmap_image);
}

如上,两个点可以稍微关注下:

  1. static_init(); 方法调用了 objc 库内部的初始化方法。一般而言 image 的初始化方法在 dyld 的第八步中被调用,而 objc 则主动调用了自己的初始化函数,有兴趣的可以见后文;
  2. exception_init(); 方法内部实现是 iOS 中使用 NSSetUncaughtExceptionHandler() 的基础。该方法可以设置 crash 后的处理逻辑,也是早起友盟、bugly 等三方 crash 监控 SDK 获取 crash 堆栈信息的基础:
NSSetUncaughtExceptionHandler

接着看代码,从 objc_init() 代码来看,貌似 objc 并没有进行类的加载?此时就需要关注 _dyld_objc_notify_register 以及对应的三个回调了,这个方法是怎么个逻辑?

3. dyld 和 objc 的联系

_dyld_objc_notify_register 这个方法由 dyld 提供,定义如下:

//
// Note: only for use by objc runtime
// Register handlers to be called when objc images are mapped, unmapped, and initialized.
// Dyld will call back the "mapped" function with an array of images that contain an objc-image-info section.
// Those images that are dylibs will have the ref-counts automatically bumped, so objc will no longer need to
// call dlopen() on them to keep them from being unloaded.  During the call to _dyld_objc_notify_register(),
// dyld will call the "mapped" function with already loaded objc images.  During any later dlopen() call,
// dyld will also call the "mapped" function.  Dyld will call the "init" function when dyld would be called
// initializers in that image.  This is when objc calls any +load methods in that image.
//
void _dyld_objc_notify_register(_dyld_objc_notify_mapped    mapped,
                                _dyld_objc_notify_init      init,
                                _dyld_objc_notify_unmapped  unmapped);

注释的大意是:

  1. 该方法专门为 objc-runtime 提供;
  2. 三个回调会在 image 被 mapped、unmapped、initialized 时分别被触发;
  3. dyld 在调用 mapped 这个回调时,会传递一个 image 数组,这些 image 都是 objc 相关的 image;
  4. objc 不需要再调用 dlopen() 方法来加载或者维持这些 image。后续有新的 image 被载入时,仍然会调用 mapped 相关的回调;
  5. dyld 会在调用 image 初始化函数阶段触发 init 回调,而这个回调就是 objc 调用 +load 方法的时机;

紧接着,一一验证上述的注释。首先在 dyld 中找到这个函数:

_dyld_objc_notify_register

_dyld_objc_notify_register 只是一个对外包装接口,关键方法在 registerObjCNotifiers

registerObjCNotifiers

根据注释,dyld 会通过 notifyBatchPartial 函数触发 mapped 回调。因为 mapped 的回调被绑定到了 sNotifyObjCMapped 这个指针,所以我们看代码时只需要关注 sNotifyObjCMapped 的调用逻辑即可,来看看这个函数的关键代码

notifyBatchPartial

打个断点来验证:

iOS15

咦?有点不一样?别慌,这个是用的 iOS15 的模拟器,很明显,dyld4 已经都被用上了。用 iOS12 的 iPhone7 看看:

notifyBatchPartial

完美,结论被完美验证~~~

即:

map_images() 是 objc 中类初始化的主要函数。该函数在 _objc_init() 调用 dyld 进行回调绑定时就会通过 notifyBatchPartial 被触发,进而 objc 会对当前所有 objc 相关的 image 进行类的初始化操作。

4. +load 函数的调用逻辑

+load 函数调用栈:

load

感觉核心在 notifySingle 这个函数,首先回到初始化函数的调用逻辑上,在recursiveInitialization 函数中对 notifySingle 调用如下:

recursiveInitialization

上图可看出:

  1. 在初始化操作之前调用了一次 notify,根据注释可以看出,应该是即将初始化对应 image 的一个通知;
  2. 初始化操作之后之后,发送了初始化完成的通知;

这里的重点在第一次 notify 的 dyld_image_state_dependents_initialized,来看看 notifySingle 函数中的关键代码:

if ( (state == dyld_image_state_dependents_initialized) && (sNotifyObjCInit != NULL) && image->notifyObjC() ) {
    uint64_t t0 = mach_absolute_time();

    (*sNotifyObjCInit)(image->getRealPath(), image->machHeader());

    uint64_t t1 = mach_absolute_time();
    uint64_t t2 = mach_absolute_time();
    uint64_t timeInObjC = t1-t0;
    uint64_t emptyTime = (t2-t1)*100;
    if ( (timeInObjC > emptyTime) && (timingInfo != NULL) ) {
        timingInfo->addTime(image->getShortName(), timeInObjC);
    }
}

不看 time 相关的代码,关键代码逻辑就是:

  1. 判断 sNotifyObjCInit 是否存在;
  2. 存在则执行 sNotifyObjCInit,传递 image 的 path 和 mh_header 的地址;

那么 sNotifyObjCInit 是个啥?全局搜一下找到:

registerObjCNotifiers

registerObjCNotifiers 又是啥呢?继续全局搜索:

_dyld_objc_notify_register

很明显,又是 _dyld_objc_notify_register 函数,这个函数注册了三个回调,load_image 就是在 image 的初始化函数即将被调用之前会被触发的回调。

其实 fishhook 也是用到了该文件下的 Api,只不过是 _dyld_register_func_for_add_image函数,该方法是添加 image 相关的回调,大概逻辑有点类似,具体就不赘述了,详见:iOS逆向:fishhook原理分析

总结下逻辑:

  1. objc 在初始化函数 _objc_init 调用 dyld 的 Api 设置了依赖库被加载时的回调;
  2. 依赖库即将被调用初始化方法时,通过通知触发回调;
  3. 回调执行预先设置的函数,也就是 objc 中的 load_images 函数;
  4. load_images 函数执行 objc 的类加载的逻辑,触发 +load 方法的调用;

另外,需要关注一点:notifySingle() 函数的触发时在初始化函数调用之前,也就是说,必须在所有依赖库的初始化函数执行 之前 (也就是两个通知的前者)进行 objc 的 +load 逻辑。

实现了 +load 方法的类会被添加到费懒加载类,在 map 的回调中就会调用 realizeClassWithoutSwift 进行加载和初始化。在 +load 调用之前,为了防止遗漏,仍然会进行一次 realizeClassWithoutSwift 的调用。另外,+load 方法最初的设计目的是什么?

5. initialize 方法调用流程

详见:彻底搞懂+load和+initialize

6. 补充:objc 自己调用初始化函数

比较好玩的一点是:objc 库中的初始化函数是 objc 自己调用的,而不是 dyld。

这里首先要从我们经常涉及到的 objc_init 来说起:

objc_init

该方法通过 static_init 方法完成了 objc 库中初始化方法的调用:

static_init

看看 getLibobjcInitializers 是个啥?

mach-O

本质上是常见的 GETSECT 方法,但是这里最关键的是 __objc_init_func 。objc 用这个标记来在 mach-O 文件中来标识 objc 独有的初始化函数。

但是,初始化方法不是一般都存放在 __mod_init 这个 section 中吗? dyld 内部也是这个逻辑:

dyld初始化函数调用

这个 S_MOD_INIT_FUNC_POINTERS 在 mach-o 相关的源码中:

image.png

实际测试结果:

初始化函数

结论:dyld 通过 mach-O 文件中的 __mod_init 这个 section 来记录并调用初始化函数;

看到这里会想当疑惑,难道 objc 的初始化方法不是 dyld 加载的?继续查找 objc 源码,看看 objc 对这个 __objc_init_func 做了什么? 在 markgc 的 main 函数中做了这么一个操作:

imarkgc

markgc 是个啥?猜测是个脚本之类的东西?markgc 的 main 函数最终会触发这个 dosect 方法。也就是说 markgc 这个程序在 objc 的 mach-O 文件生成之后(可以理解成被编译成了动态库之后),手动修改了初始化方法对应的 sectionName 和 type。

而 dyld 调用初始化方法是通过 mach-O 文件中的 __mod_init 这个 section 来完成调用的。objc 做了这么个骚操作之后, dyld 就不会(没办法,因为找不到对应的 section)在初始化阶段(倒数第二步,即调用 main 函数之前)去调用这些初始化函数了。

按照 Apple 给出的理由是,dyld 调用 objc 的初始化函数的时机太晚,主要是晚于 libc 的调用:

libc calls _objc_init() before dyld would call our static constructors, so we have to do it ourselves

总结:

  1. libc 可能被包装在了 libSystem 中,而 libc 需要调用 objc,且这个调用发生在 dyld 调用 objc 初始化函数之前,所以 objc 需要自己来调用初始化函数;
  2. objc 通过 markgc 程序将 __mod_init 修改为 __objc_init_function,从而适配自己的 static_init 逻辑,同时也避免了 dyld 对 objc 初始化函数的重复调用;

7. 一个疑问

image list

如上图,断点打在 ImageLoaderMachO::doModInitFunctions 时,libSystem 显然不是第一个 image,此时就有个疑问:

  1. 为什么自己嵌入的动态库 UPBase 那么靠前?
  2. 如果按照这个 image list 顺序进行初始化调用,那么 UPBase 被初始化时肯定 libSystem 还没有初始化。虽然 map_images() 在后续被调用时会遍历所有 image,但是如果是涉及到 UPBase 中有初始化函数调用,那么此时 objc 仍然没有初始化的,这样会不会有问题?
  3. 如果没问题,那逻辑是怎样的呢? dyld 是进行了 image 顺序调整,类似于依赖层级调整?或者说 image list 指令打印的不是当前 dyld 中的 image list 中的顺序?

解释:主工程 image 虽然在第一个,但是给到 objc 的 image 数组进行了顺序调整,且给过去之后 objc 是逆序读取 image,所以 objc 相关的 libSystem 库比较靠前,优先加载。

可以通过在 map_image_nolock 方法中打断点,然后查看寄存区,获取入参,通过打印入参的方式查看 image list 的排序:

map_image_nolock
逆序读取image

因为字符串是多个字符加上 \0 组成,所以字符串本身就是一个数组,使用 char *表示。而 path 是字符串数组,所以指向一个字符串数组,需要使用 char ** 表示:

image list

如上图可以看到,因为打印 path[306] 出了异常,所以数组总个数是 306。另外,可以直接打印 x0 寄存器就可以知道 imageCount,只不过上图没有空间显示了。

上图可以看到,逆序之后 libSystem 并不是处于第一个位置,所以库的优先级和初始化顺序大概有几种:

  1. 优先级大于 libSystem 的系统库;
  2. libSystem;
  3. 系统库,如 libobjc 等;
  4. 工程中插入的动态库;
  5. 主工程;

8. 总结

一张图做个总结吧:

总结

推荐阅读更多精彩内容