OC底层原理十五:dyld 应用程序加载

OC底层原理 学习大纲

实际开发中,大部分人都只知道main是程序的入口。但是app在启动前,具体做了哪些事情,如何保证进入main函数时,所有资源都准备好了?+(void)load函数为何能帮你把一些自定义事项在启动前就处理好?

如果你也有这些疑问,那本节,我们一起探索应用程序的整个启动加载过程。

1. 检查main、load、C++(constructor) 的执行顺序
2. 静态库与动态库
3. app启动加载过程

准备工作

1. main、load、C++ 的执行顺序

  • 测试代码:
__attribute__((constructor)) void htFunc() {
    printf("%s \n",__func__);
}

@interface HTPerson : NSObject
@end

@implementation HTPerson

+ (void)load {
    NSLog(@"%s", __func__);
}

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        NSLog(@"%s",__func__);
    }
    return 0;
}
  • 打印顺序: load -> c++(constructor) -> main
image.png
  • main函数作为程序入口,为什么是最后执行呢?

带着这个疑问,我们往下学习。

2. 静态库与动态库

代码库有静态库动态库两种,在开始探索app启动流程前,我们先了解两者的区别。

2.1 静态库:

静态编译的库,在编译时就将整个函数库的所有数据都整合进目标代码中。尾缀有.a.lib.framework等。

  • 优点: 模块化,分工合作,提高了代码的复用和核心技术的保密程度
  • 缺点: 会加大包的体积。如果静态函数库被改变,程序必须重新编译

2.2 动态库:

编译时不会将函数库编译进目标代码中,只有程序执行相关函数时,才调用函数库的相应函数。尾缀有.tbd.so.framework

  • 优点: 可执行文件体积小,多个应用程序共享内存中同一份库文件,节省内存资源,支持实时模块升级。

苹果的动态库支持所有APP共享内存(如UIKit),但APP动态库是写入app main bundle根目录中,运行在沙盒中,只支持当前APP内共享内存 。(iOS8后App Extension功能支持主app和插件之间共享动态库)

3. App加载过程

我们直观感受的App加载过程是:源文件(.h .m .cpp)-> 预编译(词法语法分析) -> 编译(载入静态库) -> 汇编 -> 链接(关联动态库) -> 生成可执行文件(mach-o)

作为程序员,我们知道代码是“死”的,只有当触发启动,按照我们设计好的流程一步步执行,才能让程序“活”起来。

程序启动过程中,当系统内核资源准备好后,dyld动态链接器就承担着管理者的角色:

配置应用环境->初始化主程序->加载共享缓存->加载动态库->链接主程序->链接动态库->弱符号绑定->执行初始化->调用main函数

到了main函数后,就交给程序员们自由发挥了。

dyld全称the dynamic link editor,动态链接器。是苹果操作系统的一个重要组成部分。在iOS/Mac OSX系统中,仅有很少量的进程只需要内核就能完成加载,基本上所有进程都是动态链接的,所以mach-o镜像文件中会有很多外部库和符号的引用,但这些引用并不能直接用,在启动时还需要通过这些引用进行内容的填补,这个填补工作就是dyld动态链接器来完成的,也就是符号绑定dyld动态链接器在系统中是以一个用户态的可执行文件存在,一般应用程序会在Mach-o文件部分指定一个LC_KIAD_DYLINKER的加载命令,此加载命令指定了dyld的路径,通常它的默认值是/usr/lib/dyld。系统内核在加载Mack-o文件时,都需要用dyld(位于/usr/lib/dyld)程序进行链接。

共享缓存机制

在iOS生态中,每个程序都会用到大量系统库,但如果我们每个程序运行时,都独立加载其依赖的相关动态库,势必会造成运行缓慢。为了优化启动速度程序性能共享缓存机制应运而生。所有默认的动态链接库被合并成一个大的缓存文件,按不同架构分别保存。

本节主要是梳理验证APP启动的完整流程。具体内部细节使用法决,后续在其他文章中进行拓展

  • 我们在load函数内部打断点bt打印堆栈信息
image.png

bt打印的堆栈信息中可以看到,每一步都是dyld在进行调用

  • 堆栈信息中展示了APP启动前的完整流程。接下来我们就沿着这个流程,从源码中寻找答案。

启动dyld

第一步:执行dyld中的_dyld_start

我们打开dyld源码,全局搜索_dyld_start,找到入口:

image.png
  • 我们从汇编代码中看到调用了dyldbootstrap::start,与我们的第二步完全吻合。

第二步:执行dyldbootstrap::start

  • 全局搜索dyldbootstrap,发现是个命名空间,折叠内部函数,找到start函数:
image.png

打开start函数,发现最后执行了dyld::_main函数,这也与我们第三步完全吻合

image.png

第三步:执行_main函数

进入main函数,发现有600多行😂 ,在这里,我们可以梳理出APP启动的完整流程:

image.png
3.1 设置运行环境
  • 设置运行参数、环境变量,获取当前运行框架
image.png
3.2 加载共享缓存
  • checkSharedRegionDisable检查共享缓存是否禁用后,调用mapSharedCache加载共享缓存。
image.png
3.3 实例化主程序
  • 主程序Mach-O加载进内存,返回一个ImageLoader类型的image对象,即主程序
image.png
3.4 加载插入的动态库
  • 遍历DYLD_INSERT_LIBRARIES环境变量,调用loadInsertedDylib加载库
image.png
3.5 链接主程序
image.png
3.6 链接插入的动态库
image.png
3.7 执行弱符号绑定
image.png
3.8 执行初始化方法
image.png
  • 进入initializeMainExecutable函数
image.png
  • 发现都是调用ImageLoader对象的runInitializers方法来初始化dylib主程序

  • 全局搜索runInitializers,在ImageLoader.cpp文件中找到实现函数

image.png
  • 核心代码为processInitializers函数的调用,进入:
image.png
  • recursiveInitializationImageLoader对象的调用方法,全局搜索:
image.png
  • 递归完成了所有对象的初始化,并将镜像初始化进度实时告知外部关联对象。

3.9 寻找main入口
image.png

以上就是完整的app启动流程。


这里对3.8 执行初始化方法 最后一步的2个内容进行继续探究:

  • notifySingle如何告知外部
  • doInitialization初始化

1. notifySingle如何告知外部

  • 全局搜索notifySingle

    image.png

  • 核心代码:(*sNotifyObjCInit)(image->getRealPath(), image->machHeader()),我们全局搜索sNotifyObjCInit,发现没有找到实现,但是有赋值操作

image.png
  • 搜索 registerObjCNotifiers在哪里被调用:
image.png
  • 发现在_dyld_objc_notify_register中调用。而dyld_objc需要在libobjc源码中搜索。
  • 我们打开objc4源码,搜索_dyld_objc_notify_register(
image.png
  • 发现在_objc_init方法中调用了_dyld_objc_notify_register方法,并传入了入参,所以sNotifyObjCInit的赋值是objc传入的load_images函数指针。因为入参是指针,所以notifySingle是一个回调函数

回调函数通过函数指针调用的函数
函数指针(地址)作为参数传递给另一个函数,当该指针用来调用所指向的函数时,我们就说这是回调函数
回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应

我们探索一下load_images函数内部:

load_images函数

  • 进入load_images函数内部,核心代码为call_load_methods的调用
image.png
  • 进入call_load_methods函数,核心代码循环调用call_class_loads函数
image.png
  • 进入call_class_loads函数内部,此处明确了load方法的调用。
image.png
    1. 明确+load方法的加载时机;
    1. 明确只有+load这个名称才有效(因为sel已固定,系统只检查load这个方法名)

对比在+load函数断点处打印的堆栈信息,与我们源码分析过程完全吻合

image.png

notifySingledyld跨库到objc,调用了load_images函数,调用了所有+load函数

HTPerson类Load函数被调用的完整流程

  • 程序启动_dyld_start
    -> 调用dyldbootstrap::start函数 -> 调用dyld::_main函数
    -> 主程序初始化initializeMainExecutable -> 镜像初始化ImageLoader::runInitializers
    -> 进程初始化 ImageLoader::processInitializers -> 递归初始化ImageLoader::recursiveInitialization
    -> 消息发送dyld::notifySingle -> 跨到objc源码库调用load_images -> 调用+load方法

但是,_objc_init什么时候调用的呢? 我们继续往下探索:


2. doInitialization初始化

  • 回到3.8步骤,我们理清楚了notifySingle的消息流程(调用回调函数),接下来看doInitialization初始化动作:
image.png
  • 进入doInitialization
image.png

发现有doImageInitdoModInitFunctions2个初始化操作

  • doImageInit函数,for循环实现镜像的初始化(macho内获取地址和偏移值,拿到初始化函数),libSystem系统库的初始化优先级较高。
image.png
  • doModInitFunctions函数: 该函数内实现了所有Cxx文件:
image.png

在测试代码的c++构造函数constructor处加入断点bt打印堆栈信息检验,确实是在doModInitFunctions函数内完成了实现。

image.png

探索_objc_init调用时机

objc4源码中搜索_objc_init,加入断点,运行测试代码。

image.png
  • 发现也是在doModInitFunctions函数后,调用了libSystem库的initializer方法。
    image.png

验证流程

  • 打开libSystem源代码,搜索libSystem_initializer
image.png
  • 进入libdispatch_init,发现什么在libdispatch.dylib库中实现。
image.png
  • 打开libdispatch源码,搜索libdispatch_init:

    image.png

  • 发现调用了os_object_init,搜索_os_object_init

image.png

在此处调用了_objc_init

_objc_init的完整调用流程

  • 程序启动_dyld_start
    -> dyldbootstrap::start -> dyld::_main
    -> dyld::initializeMainExecutable -> ImageLoader::runInitializers
    -> ImageLoader::processInitializers -> ImageLoader::recursiveInitialization
    -> doInitialization ->libSystem_initializer(libSystem.B.dylib)
    -> _os_object_init(libdispatch.dylib) -> _objc_init(libobjc.A.dylib)

此刻,回到文初的问题,main、load、C++ 的执行顺序?是否已非常清晰。

  • load: 在 3.8 执行初始化方法recursiveInitialization函数中,第一次调用notifySingle时完成了所有+load的调用。

  • c++: 在第一次调用notifySingle函数之后,调用doInitialization函数中,完成了所有c++函数的调用和所有库的初始化

  • main: 在 3.9 寻找main入口后,开始调用main函数。

强烈建议阅读以下官方资源:

  1. WWDC 2016 Optimizing App Startup Time
  • 快速熟悉Mach-O结构(后续有变动)
  • dyld如何将mach-o信息映射到内存中
  • app启动流程(旧版)和优化建议
  1. WWDC 2017 App Startup Time: Past, Present, and Future
  • 介绍dyld历史,引出dyld3(围绕性能、安全、占用资源进行优化)
  1. WWDC 2019 Optimizing App Launch
  • 介绍App Launch工具,优化启动时间

本文仅简单记录dyld的大致启动流程,部分细节并未展开拓展。源码的探索之旅继续进行...

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343