load 方法全程跟踪

2017-08-03 归去来

我们都知道,每个类都有两个初始化方法,其中一个就是 load 方法,对于每一个 ClassCategory 来说,必定会调用此方法,而且仅调用一次。当包含 ClassCategory 的程序被库载入系统时,就会执行此方法,并且此过程通常是在程序启动的时候执行。

不同的是,现在 iOS 系统中已经加入了动态加载特性,这是从 macOS 应用程序中迁移而来的特性,等应用程序启动好之后再去加载程序库。如果 Class 和其 Category 中都重写了 load 方法,则先调用 Class 中的。那么为什么会先调用 Classload 方法呢?通过这篇文章想必你会有个答案。

因为 Objective-Cruntime 只能在 macOS 下才能编译,所以,文章中的所有代码都是在 macOS 下运行了,这里推荐大家直接使用 RetVal 封装好的 debug 版最新源码进行断点调试,来追踪一下 load 方法的全部处理过程,以便于了解这个函数以及 Objective-C 强大的动态性。

创建一个 Class 文件 GGObject 和两个分类 GGObject+GGNSString+GG ,然后分别在这三个文件中添加 load 方法。运行程序,会看到 load 方法的调用时机是在入口函数主程序之前。

然后在 GGObjectload 方法下增加断点,查看其调用栈并跟踪函数执行时候的上层代码:

调用栈显示栈情况如下:

0 +[GGObject load]
1 call_class_loads()
2 call_load_methods()
3 load_images(const char*, const mach_header *)
4 dyld::notifySingle(dyld_image_states, ImageLoader const*, ImageLoader::InitializerTimingList*)
11 _dyld_start

追其源头,从 _dyld_start 开始研究。 dyld(The Dynamic Link Editor) 是苹果的动态链接库,系统内核做好程序启动的初始准备后,将其他事务交给 dyld 负责。这里不再细究。

在研究 load_images 方法之前,先来研究一下什么是 imagesimages 表示的是二进制文件编译后的符号、代码等。所以 load_images 的工作是传入处理过后的二进制文件并让 runtime 进行处理,并且每一个文件对应一个抽象实例来负责加载,这里的实例是 ImageLoader ,从调用栈的方法4可以清楚的看到参数类型:

dyld::notifySingle(dyld_image_states, ImageLoader const*, ImageLoader::InitializerTimingList*)

ImageLoader 处理二进制文件的时机是在 main 入口函数以前,它在加载文件时主要做两个工作:

  • 在程序运行时它先将动态链接的 image 递归加载
  • 再从可执行文件 image 递归加载所有符号

我们可以通过断点来打印出所有加载的 image 。在刚才断点的调用栈中,选中 3 load_images(const char*, const mach_header *) ,并添加断点:

这样可以将当前的 image 全部显示,我们列出来 imagepathslice 信息:

(const char *) $0 = 0x000000010004d0b8 "/Users/guiyongdong/Library/Developer/Xcode/DerivedData/objc-gursabanmdkytddcknzhdonlrrvk/Build/Products/Debug/libobjc.A.dylib"
(const mach_header *) $1 = 0x00000001000ad000
(const char *) $2 = 0x00007fffd60caec8 "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"
(const mach_header *) $3 = 0x00007fffd60ca000
(const char *) $4 = 0x00007fffead2d9d0 "/usr/lib/libnetwork.dylib"
(const mach_header *) $5 = 0x00007fffead2d000
(const char *) $6 = 0x00007fffd52bbc50 "/System/Library/Frameworks/CFNetwork.framework/Versions/A/CFNetwork"
(const mach_header *) $7 = 0x00007fffd52bb000
(const char *) $8 = 0x00007fffda1a5610 "/System/Library/Frameworks/NetFS.framework/Versions/A/NetFS"
(const mach_header *) $9 = 0x00007fffda1a5000
(const char *) $10 = 0x00007fffe4ef0a20 "/System/Library/PrivateFrameworks/LanguageModeling.framework/Versions/A/LanguageModeling"
(const mach_header *) $11 = 0x00007fffe4ef0000
(const char *) $12 = 0x00007fffd5d42b10 "/System/Library/Frameworks/CoreData.framework/Versions/A/CoreData"
(const mach_header *) $13 = 0x00007fffd5d42000
(const char *) $14 = 0x00007fffeaa53ac0 "/usr/lib/libmecabra.dylib"
(const mach_header *) $15 = 0x00007fffeaa53000
...

这里会传入很多的动态链接库 .dylib 以及官方静态框架 .framework 的image,而 path 就是其对应的二进制文件的地址。在 <mach-o/dyld.h> 动态库头文件中,也为我们提供了查询所有动态库 image 的方法,如下:

#import <Foundation/Foundation.h>
#import <mach-o/dyld.h>
#import <stdio.h>


void listImages(){
 uint32_t i;
 uint32_t ic = _dyld_image_count();
 printf("image 的个数 %d n",ic);
 for (i = 0; i < ic; i++) {
 printf("%d: %pt%st(slide: %ld)n",
 i,
 _dyld_get_image_header(i),
 _dyld_get_image_name(i),
 _dyld_get_image_vmaddr_slide(i));
 }
}


int main(int argc, const char * argv[]){
 listImages();
 @autoreleasepool {
 NSLog(@"Application start");
 }
 return 0;
}

我们可以通过系统库提供的接口方法,来深入学习官方的动态库情况:

load_images

此时,系统已经将所有的 image 加载进内存,然后交由 load_images 函数来解析。我们来分析一下 load_images 函数:

extern bool hasLoadMethods(const headerType *mhdr);
extern void prepare_load_methods(const headerType *mhdr);

void
load_images(const char *path __unused, const struct mach_header *mh)
{
 // 先快速的查找image中是否有Class或者Category需要加载 如果没有 直接返回
 if (!hasLoadMethods((const headerType *)mh)) return;
 
 // 定义可递归锁对象
 // 由于 load_images 方法由dyld进行回调,所以数据需要上锁才能保证线程安全
 // 为了防止多次加锁造成的死锁情况,使用递归锁解决
 recursive_mutex_locker_t lock(loadMethodLock);
 
 // 收集所有的 load 方法
 {
 // 对 Darwin 提供的线程写锁的封装类
 rwlock_writer_t lock2(runtimeLock);
 // 提前准备好满足 load 方法调用条件的 Class
 prepare_load_methods((const headerType *)mh);
 }

 // 调用 所有的load 方法 (without runtimeLock - re-entrant)
 call_load_methods();
}

接下来我们一步一步分析。首先调用的是 hasLoadMethods 函数。其中为了查询 load 函数列表,会分别查询该函数在内存数据段上指定 section 区域是否有所记录。

// 快速查询image中是否有类列表或者分类类别
bool hasLoadMethods(const headerType *mhdr)
{
 size_t count;
 //查询image中是否有类
 if (_getObjc2NonlazyClassList(mhdr, &count) && count > 0) return true;
 //查询iamge中是否有Category
 if (_getObjc2NonlazyCategoryList(mhdr, &count) && count > 0) return true;
 return false;
}

objc-file.mm 文件中存在以下定义:

// 通过宏处理泛型操作
// 函数内容是从内存数据段的某个区下查询改位置的情况,并回传指针
#define GETSECT(name, type, sectname) 
 type *name(const headerType *mhdr, size_t *outCount) { 
 return getDataSection<type>(mhdr, sectname, nil, outCount); 
 } 
 type *name(const header_info *hi, size_t *outCount) { 
 return getDataSection<type>(hi->mhdr(), sectname, nil, outCount); 
 }

// 根据dyld 对images的解析来特定区域查询内存
GETSECT(_getObjc2NonlazyClassList, classref_t, "__objc_nlclslist");
GETSECT(_getObjc2NonlazyCategoryList, category_t *, "__objc_nlcatlist");

Apple 的官方文档中,我们可以在 __DATA 段中查询到 __objc_classlist 的用途,主要是用在访问 Objective-C 的类列表,而 __objc_nlcatlist 用于访问 Objective-C 的分类列表。这一块对类信息的解析是由 dyld 处理时期完成的,也就是我们上文提到的 map_images 方法的解析工作。而且从侧面可以看出, Objective-C 的强大动态性,与 dyld 前期处理密不可分。

通过这一步,会将 image 中的类列表和分类列表的个数快速的查询出来,只要满足其中一个条件就能继续进行,否则 image 中连类列表和分类列表都没有,就一定不会有 load 方法。

可递归锁

接下来需要定义锁,然后加锁。在 load_image 方法所在的 objc-runtime-new.mm 中,全局 loadMethodLock 是一个 recursive_mutex_t 类型的变量。这个是苹果通过 C 实现的一个互斥递归锁 Class ,来解决多次上锁而不会发生死锁的问题。之所以用递归锁,是因为接下来会递归类的父类直到 NSObject

recursive_mutex_t 其作用与 NSRecursiveLock 相同,但不是由 NSLock 再封装,而是通过 Cruntime 的使用场景而写的一个 Class 。更多关于线程锁的知识,可以看看我这篇 iOS多线程之各种锁的简单介绍

准备 load 运行的从属Class

void prepare_load_methods(const headerType *mhdr)
{
 size_t count, i;

 runtimeLock.assertWriting();

 //收集有load方法的Class
 //获取所有的类的列表
 classref_t *classlist = 
 _getObjc2NonlazyClassList(mhdr, &count);
 for (i = 0; i < count; i++) {
 // 通过remapClass 获取类指针
 // schedule_class_load 递归到父类逐层载入
 schedule_class_load(remapClass(classlist[i]));
 }
 // 收集有load方法的Category
 // 获取所有的Category列表
 category_t **categorylist = _getObjc2NonlazyCategoryList(mhdr, &count);
 for (i = 0; i < count; i++) {
 category_t *cat = categorylist[i];
 // 通过remapClass 获取Category对象存有的Class对象
 Class cls = remapClass(cat->cls);
 if (!cls) continue;
 // 对类进行第一次初始化,主要用来分配可读写数据空间并返回真正的类结构
 realizeClass(cls);
 assert(cls->ISA()->isRealized());
 // 将需要执行load的Category添加到一个全局列表中
 add_category_to_loadable_list(cat);
 }
}

prepare_load_methods 作用是为load方法做准备,从代码中可以看出 Classload 方法是优先于 Category 。其中在收集 Classload 方法中,因为需要对 Class 关系树的根节点逐层遍历运行,在 schedule_class_load 方法中使用深层递归的方式递归到根节点,优先进行收集。

// 用来递归检查Class是否有load方法,包括父类
static void schedule_class_load(Class cls)
{
 if (!cls) return;
 // 查看 RW_REALIZED 是否被标记
 assert(cls->isRealized());
 // 查看 RW_LOADED 是否被标记
 if (cls->data()->flags & RW_LOADED) return;

 // 如果有父类 递归到深层运行
 schedule_class_load(cls->superclass);
 
 // 将有load方法的Class添加到一个全局列表中
 add_class_to_loadable_list(cls);
 // 标记 RW_LOADED 符号
 cls->setInfo(RW_LOADED); 
}

schedule_class_load 中, Class 的读取方式是 cls 指针方式,其中有很多内存符号位用来记录状态。 isRealized() 查看的就是 RW_REALIZED 位,改位记录的是当前 Class 是否初始化一个类的指标。而之后查看的 RW_LOADED 是记录当前类的 load 方法是否已经被检测。

// 检测Class是否有load函数 并将其添加到全局静态数组中
void add_class_to_loadable_list(Class cls)
{
 //标记方法
 IMP method;

 loadMethodLock.assertLocked();
 //获取类的load方法的IMP
 method = cls->getLoadMethod();
 //如果没有load方法 返回
 if (!method) return;
 
 if (PrintLoading) {
 _objc_inform("LOAD: class '%s' scheduled for +load", 
 cls->nameForLogging());
 }
 //判断数组是否已满
 if (loadable_classes_used == loadable_classes_allocated) {
 // 动态扩容 为线性表释放空间
 loadable_classes_allocated = loadable_classes_allocated*2 + 16;
 loadable_classes = (struct loadable_class *)
 realloc(loadable_classes,
 loadable_classes_allocated *
 sizeof(struct loadable_class));
 }
 // 将cls method 存储到loadable_classes 指针中
 loadable_classes[loadable_classes_used].cls = cls;
 loadable_classes[loadable_classes_used].method = method;
 // 索引++
 loadable_classes_used++;
}

在存储静态表的方法中,方法对象会以指针的方式作为传递参数,然后用名为 loadable_classes 的静态类型数组对即将运行的 load 方法进行存储,以及方法所属的 Class 。其下标索引 loadable_classes_used 为(从0开始)的全局量,并在每次录入方法后自加操作实现索引的偏移。

赛选过 Class 以后,接下来会继续赛选 Category 。通过 _getObjc2NonlazyCategoryList 获取到 image 中所有的 Category 后,遍历执行 add_category_to_loadable_list 方法,将有 load 方法的 Category 添加到全局 loadable_categories 静态类型的数组中。 add_category_to_loadable_list 方法的实现原理与 add_class_to_loadable_list 几乎一样。这里不再细说。

由此可以看出,在 prepare_load_methods 方法中, runtimeClassCategory 进行了筛选工作,并且将即将执行的 load 方法以指针的形式组织成一个线性表结构,为之后执行操作打下基础。

通过函数指针让load方法跑起来

通过加载镜像(image)、缓存类和分类列表后,开始执行 call_load_methods 方法。

void call_load_methods(void)
{
 //是否已经录入
 static bool loading = NO;
 //是否有关联的Category
 bool more_categories;

 loadMethodLock.assertLocked();

 // 由于loading是全局静态布尔值,如果已经录入方法则直接退出
 if (loading) return;
 //修改全局标记 开始录入
 loading = YES;

 //声明一个autoreleasePool 对象
 // 使用push操作其目的是为了创建一个新的 autoreleasePool 对象
 void *pool = objc_autoreleasePoolPush();

 do {
 // 检查全局 load 方法数组的长度 并调用load 方法 知道调用完毕
 while (loadable_classes_used > 0) {
 call_class_loads();
 }

 // 调用 Category 中的load 方法
 more_categories = call_category_loads();

 // 只要 Class 或者 Category 其中一个有load 都会继续调用
 } while (loadable_classes_used > 0 || more_categories);
 // 将创建的 autoreleasePool 对象释放掉
 objc_autoreleasePoolPop(pool);
 // 修改全局标记 录入完毕
 loading = NO;
}

其实 call_load_methods 由以上代码可知,仅是运行 load 方法的入口,其中最重要的方法 call_class_loadscall_category_loads 会分别从 loadable_classesloadable_categories 列表中找出对应的 ClassCategory ,并分别使用 selector(load) 的实现并加载。

static void call_class_loads(void)
{
 //声明下标
 int i;
 
 // 分离加载的 Class列表
 struct loadable_class *classes = loadable_classes;
 // 调用标记
 int used = loadable_classes_used;
 //重置之前的列表 标记
 loadable_classes = nil;
 loadable_classes_allocated = 0;
 loadable_classes_used = 0;
 
 // 调用列表中的Class 类的load方法
 for (i = 0; i < used; i++) {
 //获取 Class指针
 Class cls = classes[i].cls;
 // 获取load 方法
 load_method_t load_method = (load_method_t)classes[i].method;
 if (!cls) continue; 

 if (PrintLoading) {
 _objc_inform("LOAD: +[%s load]n", cls->nameForLogging());
 }
 //方法调用
 (*load_method)(cls, SEL_load);
 }
 
 // 释放classes列表
 if (classes) free(classes);
}

(*load_method)(cls, SEL_load) 通过这一句就可以调用 load 方法。这是一个函数指针。其中 load_method_t 的定义如下:

typedef void(*load_method_t)(id, SEL);

可以看到,我们将 ClassSEL 传递过去,至此完成 load 方法的动态调用。 call_category_loadscall_class_loads 的调用机制类似,只是后续会继续做很多内存操作,有兴趣的可以看看。

至此完成了 load 方法的动态调用。

总结

你过去可能会听说,对于 load 方法的调用顺序有两条规则:

  1. 父类先于子类调用
  2. 类先于分类调用

通过我们的整体分析,你会发现这种现象是很有原因的。在 schedule_class_load 递归方法中,会保证父类先于子类加入到 loadable_classes 数组红,从而确保类的调用顺序的正确性。

而在 call_load_methods 方法中:

do {
 while (loadable_classes_used > 0) {
 call_class_loads();
 }

 more_categories = call_category_loads();

} while (loadable_classes_used > 0 || more_categories);

会一次性将所有类的 load 方法调用完毕,之后才会调用分类的 load 放法。至此,整个 load 调用流程图如下:

load 可以说是我们日常开发中接触到调用时间最靠前的方法,这就成为了我们玩黑魔法的绝佳时机。

但是由于 load 方法的运行时间过早,所以这里可能不是一个理想的环境,因为某些类可能需要在在其它类之前加载,但是这是我们无法保证的。不过在这个时间点,所有的 framework 都已经加载到了运行时中,所以调用 framework 中的方法都是安全的。

扩展initialize

说到 load 方法就不得不提 initialize 方法,我们都知道 load 会在程序启动的时候加载,而 initialize 方法会在类或者类的子类收到第一条消息之前被调用。现在,我们已经非常清楚 load 方法的调用原理,至于 initialize 呢?我们现在继续分析。

紧接着我们刚才的例子,新建类 GGSuperObject ,并实现 initialize 方法,让 GGObject 继承 GGSuperObject ,接着实现 GGObjectGGObject (GG)initialize 方法,在 main 中,我们创建 GGObject 的实例,运行程序如下:

运行结果很符合我们的预期,父类会优先调用,分类会覆盖本类的 initialize ,下面我们通过代码来看具体的实现原理。当我们向某个类发送消息时, runtime 会调用 lookUpImpOrForward 这个函数。

IMP lookUpImpOrForward(Class cls, SEL sel, id inst,
 bool initialize, bool cache, bool resolver)
{
 IMP imp = nil;
 bool triedResolver = NO;

 ...

 // 类没有初始化 对类进行初始化
 if (initialize && !cls->isInitialized()) {
 runtimeLock.unlockRead();
 //初始化
 _class_initialize (_class_getNonMetaClass(cls, inst));
 runtimeLock.read();
 // If sel == initialize, _class_initialize will send +initialize and
 // then the messenger will send +initialize again after this
 // procedure finishes. Of course, if this is not being called
 // from the messenger then it won't happen. 2778172
 }

 
 ...

 return imp;
}

从中可以看到当类没有初始化时,会调用 _class_initialize 对类进行初始化, _class_getNonMetaClass 这里主要是对类进行一些转换,我们这里不用过多考虑。

void _class_initialize(Class cls)
{
 assert(!cls->isMetaClass());

 Class supercls;
 bool reallyInitialize = NO;

 // 先找到父类
 supercls = cls->superclass;
 // 如果父类没有初始化 对父类进行初始化
 // 我们发现 又有递归调用 从这里我们可以发现,父类的initialize比子类先调用
 if (supercls && !supercls->isInitialized()) {
 _class_initialize(supercls);
 }
 
 ...
 
 if (reallyInitialize) {
 ...
 @try {
 // 发送调用类的initialize的消息
 callInitialize(cls);

 ...
 }
 ...
 }
 
 ...
}

在这里,显示对入参的父类进行递归调用,以确保父类优先于子类初始化,还有一个关键的地方,我们来看 callInitialize 发送消息的具体实现:

void callInitialize(Class cls)
{
 ((void(*)(Class, SEL))objc_msgSend)(cls, SEL_initialize);
 asm("");
}

有没有很熟悉, runtime 使用了发送消息 objc_msgSend 的方式对 initialize 方法进行调用,这样, initialize 方法的调用就是与普通方法的调用是一致的,都是走的发送消息的流程,那么我们再回到 lookUpImpOrForward 方法中:

IMP lookUpImpOrForward(Class cls, SEL sel, id inst,
 bool initialize, bool cache, bool resolver)
{
 IMP imp = nil;
 bool triedResolver = NO;

 runtimeLock.assertUnlocked();

 // 这里会先从缓存中查找 imp
 if (cache) {
 imp = cache_getImp(cls, sel);
 if (imp) return imp;
 }
 runtimeLock.read();
 // 注册类
 if (!cls->isRealized()) {
 runtimeLock.write();
 
 realizeClass(cls);

 runtimeLock.unlockWrite();
 runtimeLock.read();
 }
 // 初始化类
 if (initialize && !cls->isInitialized()) {
 runtimeLock.unlockRead();
 _class_initialize (_class_getNonMetaClass(cls, inst));
 runtimeLock.read();
 }

 
 retry: 
 runtimeLock.assertReading();

 // 先从缓存中查找 imp (本例中的imp 就是initialize)
 imp = cache_getImp(cls, sel);
 if (imp) goto done;

 // 缓存中没有 去方法列表中找 imp 的实现
 {
 Method meth = getMethodNoSuper_nolock(cls, sel);
 if (meth) {
 log_and_fill_cache(cls, meth->imp, sel, inst, cls);
 //找到了就调用
 imp = meth->imp;
 goto done;
 }
 }

 // 去父类的缓存列表和方法列表中找imp 的实现
 {
 unsigned attempts = unreasonableClassCount();
 //循环遍历父类
 for (Class curClass = cls;
 curClass != nil;
 curClass = curClass->superclass)
 {
 if (--attempts == 0) {
 _objc_fatal("Memory corruption in class list.");
 }
 // 去缓存中寻找
 imp = cache_getImp(curClass, sel);
 if (imp) {
 if (imp != (IMP)_objc_msgForward_impcache) {
 log_and_fill_cache(cls, imp, sel, inst, curClass);
 goto done;
 }
 else {
 break;
 }
 }
 
 //去方法列表中找
 Method meth = getMethodNoSuper_nolock(curClass, sel);
 if (meth) {
 log_and_fill_cache(cls, meth->imp, sel, inst, curClass);
 imp = meth->imp;
 goto done;
 }
 }
 }

 // 无论是类 或者父类 都没有找到 接下来走消息转发机制
 if (resolver && !triedResolver) {
 runtimeLock.unlockRead();
 _class_resolveMethod(cls, sel, inst);
 runtimeLock.read();
 
 triedResolver = YES;
 goto retry;
 }
 imp = (IMP)_objc_msgForward_impcache;
 cache_fill(cls, sel, imp, inst);

 done:
 runtimeLock.unlockRead();

 return imp;
}

至此,整个调用流程我们已经很清晰了,其实 initialize 走的就是完整的一个消息发送流程。

当我们第一次调用某个类的方法时,首先会递归遍历此类的父类,给父类发送 initialize 消息。接着又回调消息发送机制上,先查类的缓存,之后查类的方法列表,然后沿着继承链查父类的缓存,之后查父类的方法,如果都没有查到IMP,则走消息转发流程。至此,我们也明白为何子类会覆盖父类的方法,其实都是 runtime 的作用。

可能你还有个疑惑,为什么分类的 initialize 方法会覆盖本来的 initialize 方法呢?通过下面一段代码你会发现端倪:

Method* methodList_f = class_copyMethodList(object_getClass([GGObject class]),&count_f);
 
for(int i=0;i<count_f;i++) {
 Method temp_f = methodList_f[i];
 //方法名字符串
 const char* name_s =sel_getName(method_getName(temp_f));
 NSLog(@"方法名:%@",[NSString stringWithUTF8String:name_s]);
}
free(methodList_f);

你会发现打印了两个 initialize ,其实这是因为类先于分类加载,在加载分类的时候,会将分类的方法放在类的方法的前面,所以类的方法列表中有两个 initialize 方法,并不是分类中的方法覆盖了本类中的方法,只是 runtime 在遍历方法列表的时候,只要找到一个就会返回, runtime 不知道后面还有一个 initialize 方法。想必你现在知道类和分类的调用关系了吧。

好了,至此 load 方法和 initialize 方法咱们已经说完。


用户评论
开源开发学习小组列表