MachO && dyld(一)

虚拟内存 && 地址重定位

  • 虚拟内存简介

    虚拟内存(Virtual Memory)是操作系统内存管理的一种技术。它使得应用程序认为自身拥有连续可用的内存空间(一个连续完整的地址空间),而实际上,应用程序的真实内存空间,通常是被分割成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,等需要使用时再进行数据交换。目前,大多数操作系统都使用虚拟内存技术

    操作系统通过 CPU 的内存管理单元(Memory Management Unit),使用页表等数据结构,维护虚拟地址空间与物理地址空间的映射关系,将虚拟内存地址(即虚地址)转换为实际的物理地址(即实地址)

    需求分页(Demand Paging)是操作系统虚拟内存管理的一种方法。在使用需求分页的操作系统中,只有在尝试访问页面并且页面尚未在内存中时(此时会发生缺页中断),操作系统才会将存储在磁盘的页面复制到内存中。换句话说,只有进程在执行过程中需要页面时,才会将页面带入内存(懒加载思想的应用)

    在使用需求分页的操作系统中,当一个进程刚开始执行时,其在内存中是没有任何页面的,伴随着进程的执行,会出现许多缺页中断,直到该进程所需的工作页面都被带入内存中为止

    ps:需求分页即我们在学习操作系统的时候讲的内存分页技术

  • 地址重定位简介

    在现代操作系统中,内存的使用情况复杂而又多变。为了让我们编写的程序运行在其他机器上时,可以在内存中的任何位置正确地执行,那么程序在进行汇编时,汇编指令就不能使用真实的物理地址,而是要使用相对地址或者逻辑地址。当程序被加载到内存时,这些汇编指令中的相对地址还要根据程序实际被加载的位置,重新进行计算

    操作系统根据用户程序被加载到内存中的位置重新计算用户程序中指令的地址的过程,就叫做 地址重定位(Relocation)

    在 8086 CPU 上,程序的地址重定位非常容易实现
    因为,8086 CPU 在访问内存时使用了分段机制
    所以 ,在 8086 CPU 执行程序时,如果 代码段 / 数据段 在内存中的位置发生了变化,只要把变化后相应的段地址传送给 代码段寄存器(CS) / 数据段寄存器(DS),程序就能正确地执行

    注意:如果程序在进行汇编时,汇编指令中使用了绝对内存地址(内存物理地址),这样的程序是无法进行重定位的

    对程序进行地址重定位的技术,按重定位的时机可分为 2 种:

    1. 静态地址重定位
    2. 动态地址重定位

    现代操作系统中,一般都采用动态地址重定位的方法

  • 静态地址重定位

    在目标程序装载到内存时,由操作系统的载入程序对目标程序中的指令和数据的地址进行修改(即把程序的逻辑地址都改成实际的物理地址)。对每个程序来说,这种地址变换只是在载入时一次完成,在程序运行期间不再进行地址重定位

    静态地址重定位,一般由操作系统中的重定位载入程序完成,其原理如下:
    ① 重定位载入程序的输入:用户把自己的作业链接装配成一个相对于 0 编址的目标程序
    ② 重定位载入程序根据当前内存的分配情况,按照分配区域的起始地址逐一调整目标程序指令中的地址部分。目标程序在经过重定位载入程序加工之后,不仅进入到分配给自己的绝对地址空间中,而且程序指令中的地址部分全部进行了修正,反映出了自己正确的存储位置,保证了程序的正确运行

    优点:

    1. 无需增加硬件地址转换机构,便于实现程序的静态链接
    2. 早期操作系统中大多采用这种地址重定位方案

    缺点:

    1. 每次把程序载入内存时,都要进行地址重定位
    2. 程序使用的物理内存空间只能是连续的一片区域,而且在重定位之后就不能再移动。这不利于内存空间的有效利用
    3. 各个用户进程很难共享内存中的同一程序的副本(比如:共享动态库)
  • 动态地址重定位

    在程序执行期间每次访问内存之前进行地址重定位,而程序汇编指令中的地址空间,在载入内存的过程中不发生变化
    动态地址重定位的变换是靠硬件地址变换机构实现的(通常是:重定位寄存器 + 地址加法器)
    重定位寄存器中(例如 8086 CPU 中的 CS 和 DS),放有当前正在执行的程序在内存空间中的起始地址
    地址加法器用于将段地址和偏移地址合成物理地址

    优点:

    1. 程序占用的内存空间动态可变,不必连续存放在一处
    2. 比较容易实现几个进程对同一程序副本的共享使用

    缺点:

    1. 程序每次访问内存时,都要进行地址重定位
    2. 需要额外的硬件地址变换机构的支持(目前市面上所有主流的 CPU 都有这种硬件地址变换机构)
    3. 实现内存管理的算法比较复杂

静态链接 && 动态链接

  • 从源码到可执行程序:预处理、编译、汇编、链接

    一个程序从源代码到可执行程序,事实上经过了 4 个步骤:
    预处理 → 编译 → 汇编 → 链接
    以下简述每一个步骤都做了些什么

    ① 预处理 的主要过程如下:

    1. 删除所有 #define,并展开所有宏定义
    2. 将被包含的文件插入到预编译指令 #include 所在的位置(这个过程是递归的)
    3. 删除所有注释:// 、/* */ 等
    4. 添加行号和文件名标识,以便于编译时,编译器能够产生调试用的行号信息以及显示警告和错误所在的行号
    5. 保留所有的 #pragma 编译器指令,因为编译器需要使用它们

    ② 编译 的主要过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析以及代码优化后,产生相应的汇编代码文件,编译往往是整个程序构建的核心部分,也是最复杂的部分之一

    ③ 汇编 的主要过程是将汇编代码转变为机器指令,每一条汇编语句几乎都对应着一条机器指令。所以汇编器的汇编过程 相对于 编译器的编译过程 来讲 比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表 逐一翻译就可以了

    ④ 链接 的主要过程包括了地址和空间分配、符号解析和重定位。链接器将经过汇编器处理的所有目标文件和库进行链接形成最终的可执行文件。总而言之,链接的主要内容就是把各个模块之间相互引用的部分处理好,使得各个模块之间能够正确地衔接(符号解析,即 符号决议 / 地址绑定,符号决议倾向于静态链接,地址绑定倾向于动态链接,即适用范围的区别)

    ⑤ 补充:程序的装载
    可执行文件(程序)是一个静态的概念,在运行之前它只是硬盘上的一个文件
    进程是一个动态的概念,它是程序运行时的一个过程
    每个程序被运行起来后,都会拥有自己独立的虚拟地址空间,这个虚拟地址空间大小的上限是由计算机的硬件决定的(CPU 地址总线的宽度)
    比如 32 bit 的 CPU 理论上最大虚拟地址空间为 [ 0 ,   2 32 − 1 ] [0,~2^{32}-1] [0, 2321],即 0x00000000 ~ 0xFFFFFFFF
    当然程序运行在操作系统上时是不可能任意使用全部的虚拟地址空间的,操作系统为了达到监控程序运行等一系列目的,进程的虚拟地址空间都在操作系统的掌握之中,且在操作系统中会同时运行着多个进程,进程彼此之间的虚拟地址空间是互相隔离的,如果进程访问了操作系统分配给该进程以外的虚拟地址空间,会被操作系统当做非法操作而强制结束进程

    将硬盘上的可执行文件映射到虚拟内存中的过程就是程序的装载
    因为内存是昂贵且稀有的,所以将程序执行时所需的指令和数据全部装载到内存中显然是浪费和低效的
    于是人们研究发现了程序在运行时是有局部性原理的:可以只将程序最常用的部分驻留在内存中,而不太常用的部分存放在磁盘里(等需要使用时,再进行数据交换),这也是动态装载的基本原理
    覆盖装入和需求分页就是利用了局部性原理的两种经典的动态装载方法
    覆盖装入是在发明虚拟内存技术之前使用比较广泛动的动态装载方法,现在基本已经淘汰
    现在动态装载主要使用需求分页

    程序装载的过程也可以理解为进程建立的过程,操作系统只需要做以下三件事:

    1. 创建一个独立的虚拟地址空间
    2. 读取可执行文件头,并且建立虚拟地址空间与可执行文件的映射关系
    3. 将 CPU 的指令寄存器设置成可执行文件的入口地址,启动并运行程序
  • 预处理、编译、汇编、链接,对应的 Shell 指令

    假设在 Code 目录下存在 main.c 文件,代码如下

    #include <stdio.h>
    
    int main () {
    	printf("Hello World!\r\n");
    	return 0;
    }
    

    在终端输入如下指令,对 main.c 文件进行:预处理 → 编译 → 汇编 → 链接 → 装载

    # 进入到 Code 目录,此时 Code 目录下只有一个 main.c 文件
    ~/Desktop/Code > cd /Users/Airths/Desktop/Code
    ~/Desktop/Code > ls -l
    total 8
    -rw-r--r--@ 1 Airths  staff  83  9  6 22:09 main.c
    # ①.预处理过程相当执行了如下命令
    ~/Desktop/Code > gcc -E main.c -o main.i
    # ②.编译过程相当执行了如下命令
    ~/Desktop/Code > gcc -S main.i -o main.s
    # ③.汇编过程相当执行了如下命令
    ~/Desktop/Code > gcc -c main.s -o main.o
    # ④.链接过程相当执行了如下命令
    ~/Desktop/Code > gcc main.o -o main
    # 查看 预处理、编译、汇编、链接,所生产的文件
    ~/Desktop/Code > ls -l
    total 104
    -rw-r--r--@ 1 Airths  staff     83  9  6 22:09 main.c
    -rw-r--r--  1 Airths  staff  22948  9  6 22:14 main.i
    -rw-r--r--  1 Airths  staff    779  9  6 22:15 main.s
    -rw-r--r--  1 Airths  staff    784  9  6 22:15 main.o
    -rwxr-xr-x  1 Airths  staff  12556  9  6 22:15 main
    # ⑤.装载过程相当执行了如下命令
    ~/Desktop/Code > ./main
    Hello World!
    

    预处理过程调用:预处理器 cpp,输出 .i 文件(里面是 C 语言)
    编译过程调用:编译器 ccl,输出 .s 文件(里面是 汇编 语言)
    汇编过程调用:汇编器 as,输出 .o 文件(里面是分模块的 机器 语言)
    链接过程调用:链接器 ld,输出 可执行文件(里面是组合成一个整体的 机器 语言)
    实际上 gcc 这个命令只是这些后台程序的包装,它会根据不同的参数要求去调用:预处理器 cpp、编译器 ccl、汇编器 as、链接器 ld

    每个源文件都是独立编译和汇编的,即:
    每个 .c 文件经过编译后,都会生成一个对应的 .s 文件
    每个 .s 文件经过汇编后,都会生成一个对应的 .o 文件
    链接的过程就是将工程中一个个零散的 .o 文件,组合成一个完整的可执行文件

    通常情况下,由编译器产生的所有目标模块,其起始地址都为 0
    即,每个目标模块中的地址都是相对于 0 的相对地址

  • 为什么需要链接?

    链接是让很多人费解的一个过程:
    为什么汇编器不直接输出可执行文件,而是输出一个个的目标文件呢?
    链接的过程到底包含了什么内容?
    为什么需要链接?

    这就要扯一下计算机程序开发的历史了:
    最早的时候,程序员是在纸带上用机器语言通过打孔来实现程序的,连汇编语言都没有
    每当修改程序的时候,被修改的指令后面的指令的位置要相应的发生移动,程序员需要人工重新计算每个子程序或跳转的目标地址,这个过程叫做重定位
    很显然,这样修改程序的代价会随着程序规模的增大而变得高不可攀,并且很容易出错

    于是有先驱发明了汇编语言(和汇编器),汇编语言使用接近人类语言的各种符号和标记来帮助记忆,更重要的是,这种符号和标记使得程序员从具体的指令地址中逐步解放出来,当使用这种符号命名子程序或者跳转目标以后,不管在目标指令之前修改了多少条指令,导致目标指令的地址发生了变化,汇编器在每次汇编程序的时候都会重新计算目标指令的地址,然后把所有引用到目标指令的指令修正到正确的地址处,这个过程不需要人工参与

    有了汇编语言,生产力得到了极大的提高,随之而来的是软件的规模与日俱增,代码量快速膨胀,导致人们开始考虑将不同功能的代码以一定的方式组织起来,使代码更容易阅读和理解,更便于修改和复用。自然而然地,程序员开始习惯用若干个变量和函数组成一个模块(比如类),然后用目录结构来组织这些源代码文件

    在一个程序被分割成多个模块以后,这些模块最终如何组合成一个单一的程序是必须要解决的问题
    这个问题归根结底是模块之间如何通信的问题,也就是:访问函数需要知道函数的地址,访问变量需要知道变量的地址。这两个问题都是通过模块间符号的引用的方式来解决的。这个模块间符号引用的拼接过程就是链接

    链接的主要内容就是把各个模块之间相互引用的部分处理好,使得各个模块之间能够正确地衔接
    本质上跟前面描述的(程序员人工调整地址)没有什么区别,只不过现代的高级语言的诸多特性和功能,使得编译器、链接器更为复杂,功能更加强大

    链接的主要过程包括了
    ① 地址和空间分配:扫描所有的目标文件(即 .o 文件),合并相似段,收集当中所有的符号信息
    ② 符号解析和重定位:根据符号信息调整机器指令的代码位置
    链接器将经过汇编器处理的所有目标文件和库进行链接形成最终的可执行文件

    符号解析,也叫 符号决议 / 地址绑定
    符号决议倾向于静态链接,地址绑定倾向于动态链接,即适用范围的区别
    在符号解析的时候,有以下规则:
    ① 若符号来自静态库或目标文件(.o),则将其纳入链接产物并确定符号地址(常见的符号冲突就出现在这一步)
    ② 若符号来自动态库,则将其打个标记,等启动的时候再交由动态连接器(比如 dyld)去加载和链接符号

  • 链接的分类

    对目标文件进行链接的技术,按链接的时机可分为 2 种:
    ① 静态链接
    ② 动态链接

    其中,动态链接又可细分为 2 种:
    ① 装入时动态链接(Load-Time Dynamic Linking,目标模块在装入内存时,边装入边链接)
    ② 运行时动态链接(Run-Time Dynamic Linking,直到目标模块执行时,需要用到库函数才会链接)

    现代操作系统中,一般都采用动态链接的方法(装入时动态链接 + 运行时动态链接)

  • 静态链接

    在程序装载之前,将目标模块和它所需要的库函数链接成一个完整的可执行文件,以后不再拆开

    优点:

    1. 链接过程比较简单,容易理解
    2. 装载速度和执行速度 略快于 使用动态链接的程序
    3. 没有外部依赖,在可执行文件中已经具备了执行程序所需要的任何东西

    缺点:

    1. 浪费存储空间(内存 + 磁盘)。由于静态链接的机制,在每个使用静态链接的可执行文件中,都会保存一份所有需要的目标文件的副本。如果多个程序同时依赖一个目标文件(如:运行时库,RunTime Library,它是支持程序运行的基本函数的集合),则在内存和磁盘上会存在该目标文件的多个副本
    2. 模块更新困难。每当库函数进行修改或升级时,就需要对可执行文件重新进行链接
  • 动态链接

    动态链接的出现是为了解决静态链接中存在的 2 个问题:浪费存储空间、模块更新困难
    动态链接的基本思想是把程序按照模块拆分成各个相对独立的部分,在程序装载时才将它们链接在一起,形成一个完整的程序

    动态链接的过程如下:
    ① 假设有两个程序 program1.o 和 program2.o,这两者共用同一个库 lib.o
    ② 假设首先运行 program1,操作系统加载 program1.o,当操作系统发现 program1.o 中用到了 lib.o(即 program1.o 依赖于 lib.o),那么操作系统接着加载 lib.o
    ③ 如果 lib.o 还依赖于其他目标文件,那么操作系统会依次全部加载到内存中
    ④ 当 program2 运行时,操作系统加载 program2.o,当操作系统发现 program2.o 中用到了 lib.o(即 program2.o 依赖于 lib.o),因为此时 lib.o 已经存在于内存中,所以操作系统不会再重新加载 lib.o,而是将内存中已经存在的 lib.o 映射到 program2 的虚拟地址空间中,从而进行链接,形成可执行程序

    优点:

    1. 节省存储空间(内存 + 磁盘)。多个程序可以共享同一份库文件
    2. 模块容易更新。每当库函数进行修改或升级时,只需要替换原来的库文件,无需将程序再重新链接一遍。当程序下次启动运行时,新版本的库文件会被自动加载到内存并且链接起来,这样,程序就完成了升级的目的
    3. 因为不同进程(程序)间的数据和指令的访问,都集中在了同一个共享库文件上,所以能减少内存物理页面的换入换出,增加 CPU 缓存的命中率

    缺点:

    1. 小部分的性能损失。因为动态链接把链接过程推迟到了程序运行时,所以每次执行程序都需要进行链接,性能会有一定的损失。据估算,动态链接和静态链接相比,性能损失大约在 5% 以下。经过实践证明,用这点性能损失来换取:程序在空间上的节省、在构建和升级时的灵活性,是值得的
    2. 多个程序依赖同一个库文件,当库文件升级时,可能会带来兼容性问题
  • 动态链接如何进行程序的地址重定位

    Question:
    在静态链接过程中,程序的地址重定位很容易理解
    那么,在动态链接过程中,程序的地址重定位是如何进行的呢?


    Answer:
    虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库,并且需要进行基本的程序的地址重定位。比如,链接器在形成可执行文件时,发现目标文件中引用了一个外部的函数,此时链接器会检查动态链接库,发现这个函数名是一个动态链接符号,此时链接器就不会对目标文件中的这个动态链接符号进行地址重定位,而是把地址重定位的过程留到 可执行文件(装载或执行)时再进行


    更具体地说
    装入时动态链接 会把地址重定位的过程留到 可执行文件 装载时再进行
    运行时动态链接 会把地址重定位的过程留到 可执行文件 执行时再进行

    ① 装入时动态链接(Load-Time Dynamic Linking)
    用户源程序经编译后所得到的目标模块,在装入内存时,边装入边链接
    即,装入程序在装入一个目标模块时,如果发现该目标模块存在对外部模块的调用,则装入程序将去找出相应的外部模块,并将它装入内存,同时修改目标模块中对外部模块的调用地址

    优点:

    1. 节省存储空间(内存 + 磁盘),多个程序可以共享同一份库文件
    2. 模块容易更新

    缺点:

    1. 目标模块在装入内存之后是静态的,在整个进程执行期间,目标模块不会再改变
    2. 目标模块装载时,装入程序会将目标模块中用到的所有外部模块,全部装载到内存里面
      因为装入程序无法事先知道目标模块本次运行时会用到哪些外部模块
      所以装入程序只能将目标模块里面用到的所有外部模块,全部加装到内存并进行链接
      这样做,显然是低效的,因为在目标模块运行时,某些外部模块往往不会运行。比较典型的例子是错误处理模块,如果目标模块在整个运行过程中,都不出现错误,便不会用到该外部模块

    ② 运行时动态链接(Run-Time Dynamic Linking)
    用户源程序经编译后所得到的目标模块,在执行时,边执行边链接
    即,在目标模块执行过程中,如果发现被目标模块调用的外部模块还没有装入内存,则由操作系统去寻找该外部模块,然后将它装入到内存,并把它链接到目标模块上(运行时动态链接即懒绑定)
    运行时动态链接 能有效避免 装入时动态链接 把所有外部模块都装入内存导致内存浪费的缺点

库(静态库 && 动态库)

  • 库(Library)简介

    库(Library)就是一段编译好的二进制代码(机器指令的集合),加上头文件就可供别人调用。库也可以理解为:一组代码编译成目标文件(.o)后的打包存放

    库的使用场景:

    1. 保护核心代码。例如,某些核心代码需要提供给别人使用,但是又不希望别人看到源码,就需要以库的形式进行封装,只暴露出头文件
    2. 提高调试效率。例如,对于一些不会进行大改动的代码,为了提高调试效率,可以把它打包成库。因为库是已经编译好的二进制机器指令,调试的时候只需要进行链接,不用浪费预处理、编译、汇编的时间
    3. 屏蔽编译选项的差异。例如,iOS 开发中,别人使用 MRC 写的开源库,放到自己的 ARC 项目中,需要对每个文件加一个编译参数 -fno-objc-arc(因为 MRC 代码和 ARC 代码的编译过程不一样),比较麻烦。可以将 MRC 的项目打包成静态库,直接放到 ARC 项目下使用,不用添加编译选项进行转换(因为此时 MRC 的代码已经编译好了)

    上面提到了,链接的方式有两种:静态链接 和 动态链接,于是便产生了静态库和动态库
    基于此,静态库的优缺点与静态链接的优缺点相同,动态库的优缺点与动态链接的优缺点相同,详细内容请参考前面一小节
    静态链接的时候,使用静态库(也叫:静态链接库)
    动态链接的时候,使用动态库(也叫:动态链接库)

    注意:静态库和动态库,都是由目标文件(.o)组成的

  • Framework 与 库(静态库 + 动态库)

    在 macOS 和 iOS 中:
    静态库的扩展名为 .a
    动态库的扩展名为 .dylib

    除了上面介绍的静态库(.a)、动态库(.dylib),在 macOS 和 iOS 中,还可以使用 Framework
    Framework 实际上是 Cocoa / Cocoa Touch 程序中使用的一种资源打包的方式,可以将二进制代码文件、头文件、资源文件、说明文档等按一定的结构打包在一起,方便管理和分发
    严格意义上讲,Framework 与 库(静态库 .a | 动态库 .dylib)这两个概念不在同一个维度上。Framework 不是库,它只是一种打包方式,Framework 既可以是静态库也可以是动态库

  • iOS 系统下 Framework 的分类
    iOS 系统 下的 Framework

    ① Dynamic Framework(动态库),具有所有动态库的特性,系统提供的 Framework 都是动态库,比如 UIKit.framework、Foundation.framework

    ② Static Framework(静态库),具有所有静态库的特性,开发者可以自己制作

    ③ Embedded Framework(受限制的动态库),具有部分动态库特性,开发者可以自己制作,但是受 iOS 平台机制的约束(沙盒机制、App 签名机制):

    1. 由于 iOS 平台 沙盒机制的存在,限制了 Embedded Framework 只能在(App Extension 可执行文件)和(App MachO 可执行文件)之间共享,不能像系统级的 Framework 一样,在不同的 App(进程) 间共享。在项目打包时,Embedded Framework 需要拷贝到 IPA 包 中(IPA 包中 .app 文件里面的 Frameworks 目录就是用来存放 Embedded Framework的),系统级的 Framework 不需要拷贝到 IPA 包中
    2. 由于 iOS 平台 App 签名机制的存在,限制了 Embedded Framework 的更新不能像正常的动态库那样做替换(如果替换了 IPA 包中的 Embedded Framework, IPA 包需要进行重签名,否则 App 安装后,签名验证不通过,无法正常启动)

    Embedded Framework 存在的意义

    在 iOS 8 之前,iOS 平台不支持开发者自己创建动态 Framework
    开发者可以使用的动态 Framework 只有 Apple 提供的系统级的 Dynamic Framework(UIKit.framework、Foundation.framework、…)
    因为,iOS 应用都是运行在沙盒当中,不同的应用之间不能共享代码和资源
    同时,动态下载代码又是被苹果明令禁止的
    再者,iOS 是但单进程的,同一时刻只有一个 App 处于运行状态
    所以,开发者也就没有必要创建用户级的动态 Framework 了

    在 iOS 8 之后,iOS 增加了 App Extesion 特性 && Swift 语言也诞生了
    因为 App MachO 需要和 App Extension 共享代码 && Swift 语言的机制也需要用到动态库
    这就意味着,开发者需要能自己创建动态 Framework
    但是,开发者创建的动态 Framework 需要符合 签名机制 和 沙盒机制 的约束
    所以苹果推出了 Embedded Framework(可以简单地理解为被阉割的动态库)
    Embedded Framework 和 Dynamic Framework 还是有很大区别的:
    Dynamic Framework 是给多个 App(进程)用的,Embedded Framework 是给单个 App(进程)里面的多个可执行文件用的
    Dynamic Framework 不需要拷贝到目标 IPA 包中,开发者自己创建的 Embedded Framework,哪怕是动态的,App 最后打包时,也还是要拷贝到 IPA 包中(App MachO 和 App Extension 的 Bundle 是共享的)
    所以苹果没有直接把 Embedded Framework 称作动态库,而是叫 Embedded Framework

  • Static Framework && Embedded Framework 目录结构对比

    通过查看 Static Framework(HcgStaticFramework.framework)和 Embedded Framework(HcgEmbeddedFramework.framework)的目录结构,发现:这两者的目录结构基本上没有什么区别,这也佐证了 Framework 只是一种打包方式

    # Static Framework 目录结构
    ~/Desktop/HCG/HcgStaticFramework.framework > tree -a
    .
    ├── HcgStaticFramework
    ├── Info.plist
    └── _CodeSignature
        ├── CodeDirectory
        ├── CodeRequirements
        ├── CodeRequirements-1
        ├── CodeResources
        └── CodeSignature
    
    1 directory, 7 files
    
    # Embedded Framework 目录结构
    ~/Desktop/HCG/HcgEmbeddedFramework.framework > tree -a
    .
    ├── HcgEmbeddedFramework
    ├── Info.plist
    └── _CodeSignature
       └── CodeResources
    
    1 directory, 3 files
    
  • 如何判断 Framework 是静态库还是动态库

    可以通过以下命令,判断 Framework 里面包含的是静态库(Static Framework)还是动态库(Embedded Framework):

    # Static Framework 输出结果
    ~/Desktop/HCG/HcgStaticFramework.framework > file HcgStaticFramework
    HcgStaticFramework: current ar archive random library
    
    # Embedded Framework 输出结果
    ~/Desktop/HCG/HcgEmbeddedFramework.framework > file HcgEmbeddedFramework
    HcgEmbeddedFramework: Mach-O 64-bit dynamically linked shared library arm64
    
  • 开源库 && 闭源库

    库(Library)除了可以根据链接时的特性分为静态库和动态库,还可以根据源码是否公开分为开源库和闭源库

    开源库:源代码是公开的,可以看到 .m 文件里面的实现,例如 GitHub 上常用的开源库:AFNetworking、SDWebImage 等

    闭源库:不公开源代码,是经过编译后的二进制文件,看不到具体的实现。例如,iOS 系统级的库,大多都是闭源库:UIKit、Foundation 等

Linux:地址无关代码 && 懒绑定

  • 背景介绍:链接技术的发展

    懒绑定机制是指将符号的绑定工作推迟到符号第一次被程序调用的时候,需要地址无关代码技术的支持
    为了让大家更好地理解什么是地址无关代码,以及我们为什么需要懒绑定
    在这里首先介绍一下链接技术的发展过程

    一般来说,程序的构建过程分为 4 个阶段:预处理 → 编译 → 汇编 → 链接
    其中,链接是程序构建过程中的最后一步,也是十分重要的一步,它主要有 3 方面的工作:

    1. 地址和空间分配:扫描所有的目标文件(即 .o 文件),合并相似段,收集当中所有的符号信息
    2. 符号解析:将符号的引用和符号的定义联系起来
    3. 重定位:将符号的定义和具体的地址对应起来,并修改所有对这些符号的引用,使它们指向相应的地址
      总而言之,链接主要是为了解决程序各个模块间符号引用的问题

    在很久很久以前,链接过程所使用的技术被称为静态链接,也就是将所需的静态库全部都拷贝一份到程序中,最终形成一个可执行文件。静态链接在技术实现上是没有什么问题的,但是随着时间的推移,人们发现它身上存在一些硬伤:

    1. 浪费存储空间(内存 + 磁盘)
    2. 模块更新困难

    静态链接问题的根源在于:它使应用程序和静态库的联系过于紧密,解决问题的关键是降低二者间的耦合度。为此, 装入时动态链接应运而生。与静态链接相比,装入时动态链接将相关符号的绑定工作推迟到程序被加载到内存时,这不仅使得模块的更新变得容易,而且也使得动态库能够真正地被不同的程序所共享。此处的共享有 2 点含义:

    1. 动态库在操作系统的内存和磁盘上都只存在一份(而不是像静态链接那样给每个程序都拷贝一份静态库)
    2. 在程序运行的过程中,动态库 text 段的内容可以被不同的进程所共享(动态库 data 段的内容则是每个进程自己独立一份)

    动态链接所遇到的符号重定位问题:
    虽然,静态链接与动态链接最主要的目的都是把对符号的引用修正为符号所对应的地址
    但是,静态链接与动态链接为修正符号地址所采取的机制却完全不同
    静态链接是在程序的构建阶段进行的,会把所需的静态库直接拷贝一份到可执行文件中,然后直接修正静态库 text 段和 data 段的符号地址
    动态链接是在程序的装入阶段进行的,因为动态库在内存中只有一份并且会被多个进程共享,所以不能直接修改动态库 text 段和 data 段的符号地址

    如果程序在装入阶段进行动态链接时可以直接修改动态库 text 段和 data 段的符号地址,会出现什么情况呢?举个例子:

    假设动态库被加载到进程 A 的地址 0xAA000000 处,链接结束后动态库相关符号的地址都被修改,如果此后没有进程再加载该动态库,那么一切都没有问题。可是直到某一天,进程 B 也要加载此动态库,并且需要把它加载到地址 0xBB000000 处。这样麻烦就来了,动态库中符号的相关地址是基于 0xAA000000 的,在进程 B 中是无法工作的,那么要基于 0xBB000000 修改符号的地址吗?这样进程 A 就不能工作了。再重新拷贝一份动态库到进程 B 中吗?那动态库还共享个啥???
    而且,这么做还有一个潜在的问题,那就是动态库的 text 段必须是可写的,否则在加载的时候就无法修改相关的符号进行重定位。但是在现代操作系统中,出于安全考虑,不允许程序在运行时修改代码段,只允许程序在运行时修改数据段

    既然不能通过修改动态库的符号地址来达到修复程序中外部符号引用的目的,那么我们就换个思路
    虽然动态库在内存中只有一份,但是调用动态库的进程却有多个。既然不能通过修改同一个动态库的符号地址来满足多个进程的调用需求,那就反过来,通过修改多个进程中调用动态库的外部符号的地址,让多个进程的调用指向同一个动态库。毕竟链接最主要的目的是把对符号的引用修正为符号所对应的地址,又不一定是修改动态库的符号地址,修改进程的符号地址未尝不可。想法固然很好,但是非常抱歉,这样做同样行不通。因为进程中调用动态库的外部符号的指令也位于代码段中,同样不能在运行时修改

    既然进程和动态库中的符号地址都不能修改,那么我们再换个思路:在编译时,程序与动态库共同约定一个地址,同一个动态库在不同进程的虚拟地址空间中恒定被映射到的同一个固定地址,然后不同进程恒定到自己虚拟地址空间的此固定地址处调用动态库。如果这样做了,且不论如何处理数以万计的动态库的虚拟地址空间分配问题,我就问一点:你的动态库还动得了吗???是个程序员,都知道不能这么做!!!

    问题到这里,似乎陷入了死胡同

  • 地址无关代码(Position Independent Code)

    上述问题的根源在于:应用程序在调用外部符号时,(调用外部符号的指令)和(外部符号的地址)的联系过于紧密,解决问题的关键是降低二者间的耦合度。为此, 地址无关代码应运而生

    那么地址无关代码是如何实现的?它又是如何在不修改 text 段的情况下,让同一动态库可以被加载到不同进程的不同位置?使用地址无关代码的程序在加载动态库时又需要做哪些工作?

    答案其实很简单,就是多一层引用关系
    既然程序的 text 段不让写,那我就写程序的 data 段呗。因为程序的 data 段是可写入的,所以就把动态库加载后符号的地址放在程序的 data 段中的相关位置,而当程序的 text 段使用该外部符号时,就去程序的 data 段中找到相应的位置,从中取出外部符号的地址。举个例子:
    当主程序 A 需要调用动态库 B 的某个函数 C 时,主程序 A 会从自己的数据段中找到动态库 B 对应的函数 C 的地址,然后再进行函数调用(其实,这就是一个间接寻址的过程)

    这样做,虽然可以对程序中(调用外部符号的指令)和(外部符号的地址)进行解耦,但是又带来了新的问题:如果要从 data 段获取外部符号的地址,那么 data 段的地址你总要知道吧,但是根据程序加载位置的不同,其 data 段的地址也是不同的,那么地址无关代码又是怎么从 data 段获取外部符号地址的呢?这依赖于以下两个原理:

    1. text 段和 data 段间的距离是常量
      链接器知道程序中每个段的大小以及它们之间的相对位置,并且在进程的虚拟地址空间中,data 段总是被映射到紧随 text 段的地方,所以这就造成了一个重要的事实:无论程序被加载到内存中的什么位置,text 段中的任意指令和 data 段中的任意数据之间的距离在运行时都是一个确定的常量,而与 text 段和 data 段加载的绝对内存地址无关。即使某一天编译技术变了,data 段不再紧随 text 段了,那也是没有问题的,因为链接器知道每个段的大小及位置,所以是有办法知道指令和数据间在运行时的距离的
    2. 获取当前指令地址的技巧
      指令和数据间的相对距离知道了,如果能够再知道指令的当前地址,就可以得到数据的绝对地址,就能够做到代码与位置无关了,那么怎么拿到当前指令的地址呢?汇编语言中没有为此提供方便,但是我们可以利用以下代码来巧妙地获取当前指令的地址:
      	call get_pc
      get_pc:
      	pop  %ebx
      
      pc 寄存器总是保存 cpu 要执行的下一条指令的地址,而我们对 get_pc 的调用会导致程序将 pc 寄存器的值压入栈顶(在上述程序中 pc 寄存器的值就是 pop 指令的地址),随后 pop 指令将这个地址弹出到 ebx 寄存器中。最终的结果就是将 pc 寄存器的值保存到 ebx 寄存器中,这样就达到了目的,拿到了指令的地址

    总结:
    所谓的地址无关代码,即相关代码加载到任何位置都可以正常运行,其核心思想是:
    做到(调用外部符号的指令)和(外部符号的地址)的分离。即,将代码段中对外部数据和外部函数的引用剥离出来放到数据段中,以保证代码段指令不变

  • 懒绑定(Lazy Binding)

    介绍完地址无关代码的基本概念之后,紧接着我们来看一下,Linux 操作系统是如何运用地址无关代码的:

    ① Linux 下,使用地址无关代码,对外部数据进行引用
    编译器会在程序的 data 段中创建一个全局偏移表(Global Offset Table,GOT),表中记录了对动态库全局数据的引用。当加载动态库时,动态链接器会修改程序 GOT 表中相应的条目,使其包含正确的绝对地址。在程序运行时,如果需要访问动态库中的全局数据,则通过 GOT 表相应的条目进行间接的引用

    ② Linux 下,使用地址无关代码,对外部函数进行引用
    对动态库函数的引用,虽然可以按照引用动态库全局数据的方式进行处理,也就是加载动态库的时候,修正每个外部函数对应的 GOT 条目,但是大多数编译系统不会直接这么做,因为这会非常耗时
    根据程序运行时的局部性原理,程序会将 80% 的时间用于执行 20% 的代码。这就意味着,程序中多数代码并不会被执行,况且程序中引用的外部函数要比引用的外部数据多得多。如果程序在加载动态库的时候,就修正每个外部函数对应的 GOT 条目,这会使程序做很多无用功,导致加载时间非常长
    因此对于外部函数的引用,编译系统会对其进行懒绑定,也就是推迟到相关外部函数第一次被调用的时候再进行符号绑定(懒绑定即运行时动态链接)。在使用懒绑定机制的程序中,需要有过程链接表(Procedure Linkage Table,PLT)与全局偏移表(Global Offset Table,GOT)相互配合:
    懒绑定_00
    上述流程解释如下:

    1. 程序调用 func 函数,随后控制流程传递到 PLT 表中与 func 函数相对应的条目
    2. PLT 条目包含 3 条指令
      第 1 条指令:跳转到 GOT 条目中所记录的 func 函数的地址
      第 2 条指令:准备符号解析所需的相关信息
      第 3 条指令:跳转到 PLT[0] 中,开始执行符号解析(与符号绑定)
    3. 因为是第一次调用 func 函数,所以 GOT 条目中并没有记录 func 函数的地址,此时它记录的是相关 PLT 条目的第 2 条指令的地址。最终结果是:程序跳回到 PLT 条目中,在准备好符号解析的信息后继续执行
    4. 接下来,程序跳转到 PLT[0] 条目。PLT[0] 包含了一连串的指令:
      首先,它将 GOT[1] 的内容入栈,GOT[1] 中记录的是符号绑定所需的信息
      其次,它跳转到 GOT[2] 中记录的地址,GOT[2] 包含的是动态链接器的入口地址
      最后,动态链接器开始执行符号绑定
    5. 当动态链接器完成符号的绑定后,GOT 相关条目的内容就会被更新为 func 函数的真实地址

    当程序再次调用 func 函数时,就无需再次进行符号绑定了,只需要根据 GOT 条目所记录的地址来调用 func 函数即可
    懒绑定_01

  • PLT 和 GOT 的结构

    plt (Procedure Linkage Table):过程链接表,位于可执行文件的代码段中,它的每个条目(item)都是一小段用于调用外部符号的指令,由上图可以看出, plt 会从 got 中获取外部符号的真实地址(在底层:变量、函数统称为符号)
    plt[0] 是调用动态链接函数的代码
    从 plt[1] 开始是每个外部函数的 plt 条目(item)

    got(Global Offset Table):全局偏移表,位于可执行文件的数据段中,它的每个条目(item)都是外部符号的真实地址(在底层:变量、函数统称为符号)
    got[0]:address of .dynamic,也就是本 ELF 动态段(.dynamic 段)的装载地址
    got[1]:address of link_map object(编译时填充0),也就是本 ELF 的 link_map 数据结构描述符地址。作用:通过 link_map 结构,结合 .rel.plt 段的偏移量,才能真正找到该 ELF 的 .rel.plt 条目
    got[2]:address of _dl_runtime_resolve function(编译时填充为0),也就是_dl_runtime_resolve 函数的地址,_dl_runtime_resolve 函数用来得到目标函数的真实地址,并回写到 got 表对应的位置中
    从 got[3] 开始,是每个外部函数的 got 条目(item)
    plt and got

  • 装入时动态链接 和 运行时动态链接 的区别在于:got 表的填充时机

    装入时动态链接(不使用懒绑定):每当有动态库链接到主程序上时,动态链接器就会查找主程序中用到该动态库的所有符号的真实地址,然后填充到 got 的对应条目(item)中

    运行时动态链接(使用懒绑定):只有在主程序第一次调用动态库的函数时,才会动态的解析该函数的地址并填充到 got 的对应条目(item)中。更详细地:
    每当有动态库链接到主程序上时,动态链接器不会去查找主程序中该动态库符号的真实地址,也不会进行 got 的填充
    此时, got 的条目(item)会被初始化为指向懒绑定函数的调用(懒绑定函数用于获取外部符号的真实地址并将结果回写到 got 对应的 item 中)
    当主程序中第一次调用动态库的某个函数时,got 会调用懒绑定函数查找该函数的真实地址并将结果回写到对应的条目(item)中
    当主程序再次调用动态库的该函数时,就能直接获取到存储在 got 对应条目中的该函数的真实地址

  • 使用地址无关代码时,程序和动态库对于符号的引用情况

    在底层中:变量、函数统称为符号。在使用地址无关代码技术时,程序和动态库对于符号的引用情况分为以下 3 种:

    ① 程序调用自身的全局数据或者函数

    不需要通过 plt 与 got,直接使用绝对地址进行调用(可以加快调用速度)
    因为 调用者 与 被调用者 位于同一个可执行文件内,所以 调用者 与 被调用者 之间的相对位置在运行时是一个确定的常量,再加上指令指针寄存器(PC 寄存器),调用者就可以知道被调用者的绝对地址

    ② 程序调用动态库的全局数据或者函数

    正如前面所说的,需要通过 plt 与 got 进行间接地调用
    因为,动态库符号的地址需要等到动态库加载完成之后才能确定(即,程序在编译时无法事先知道动态库符号的地址)
    所以,程序在编译时,会预先在自己的数据段中建立一个用于存储 动态库符号地址 的指针数组(即,got 表),然后程序中所有访问动态库符号的指令都通过 got 表间接地获取外部符号的地址。等到程序加载或者运行时,再动态地查找 动态库符号的地址 以填充 got 表
    并且,程序访问 got 表属于访问内部数据,可以很容易地通过相对地址获取到存储在 got 表中的数据

    ③ 动态库调用自身的全局数据或者函数

    当动态链接器将动态库加载到程序中时,会将它们的符号放在全局符号表中(Global Symbol Table)

    在 Linux 下,因为使用 flat namespace,所以动态库在链接时,可能会出现符号冲突问题。为了避免符号冲突问题,动态库调用自身的全局数据或者函数时,也会生成相应的 got 与 plt 条目

    在 macOS 下,程序编译时默认使用 two-level namespace,也就是说在引用符号的同时还要指出该符号所属的库的名称,这样做有以下优点:

    1. 提高符号解析效率。链接器明确地知道该去哪个库中搜索符号,而不是像 flat namespace 那样去搜索所有的库
    2. 避免符号冲突

    因为 two-level namespace 的存在,即便动态库使用了自身的全局数据或是函数,在 macOS 平台上编译后也是采用相对地址调用,不会生成相应的 got 与 plt 条目

注意

  • 关于为什么需要链接的另外一种解释

    在我们的实际开发中,不可能将所有代码都放在一个源文件中,所以会出现多个源文件,而且多个源文件之间不是独立的,而是会存在多种依赖关系,如一个源文件可能要调用另一个源文件中定义的函数,但是每个源文件都是独立编译的,即每个 *.c 文件会被编译成一个 *.o 文件,为了满足前面说的依赖关系,则需要对这些源文件产生的目标文件进行链接,从而形成一个可以执行的程序

    链接的过程可以简单描述如下:
    假设在主程序 main.c 中使用了 math.c 模块的 add() 函数
    那么在 main.c 的编译过程中,遇到调用 add() 函数的指令,对于调用指令的目标地址暂时搁置,待到链接的时候,再由链接器来填写 add() 函数的真实地址

  • 静态库补充

    静态库可以简单地看成是一组目标文件(.o)的集合
    即,静态库是由很多目标文件(.o)经过压缩打包后形成的一个文件

    可以通过以下命令查看静态库的组成

    ~ > cd /usr/local/Cellar/gcc/10.2.0/lib/gcc/10
    /usr/local/Cellar/gcc/10.2.0/lib/gcc/10 > ar -t libobjc-gnu.a
    __.SYMDEF SORTED
    NXConstStr.o
    Object.o
    Protocol.o
    accessors.o
    linking.o
    class.o
    encoding.o
    error.o
    gc.o
    hash.o
    init.o
    ivars.o
    ...
    

    这里有个需要注意的细节:从上面的输出结果中可以看出,静态库里面一个目标文件(.o)只包含一个函数
    例如:libobjc-gnu.a 里面,init.o 只有 init 函数,ivars.o 只有 ivars 函数

    因为,链接器在链接静态库的时候,是以目标文件(.o)为单位的。例如:模块中引用了 libobjc-gnu.a 的 init 函数,那么链接器就会把 libobjc-gnu.a 中包含 init 函数的那个目标文件(init.o)链接进来。如果多个函数存放在同一个目标文件(.o)中,那么链接时,很可能很多没用的函数都会被一起链接进最终的输出文件中

    由于运行时库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件(.o)中可以尽量减少空间的浪费,那些没被用到的目标文件(.o)就不会被链接到最终的输出文件中

  • iOS 共享缓存机制

    因为同一份动态库可以被多个程序使用,所以动态库也被称为共享库
    所谓的共享缓存,其实就是共享库的缓存(即动态库的缓存)
    特别地,因为 macOS 和 iOS 都使用 dyld 来加载和链接动态库,所以共享缓存有时也被称为 dyld 缓存

    iOS 有很多系统库几乎是每个 App 都会用到的(比如,Foundation.framework、UIKit.framework)
    与其等 App 需要时,再将这些系统库一个一个加载进内存;不如在一开始时,就先把这些系统库打包好一次加载进内存
    从 iOS 3.1 开始,为了提高系统的性能,所有的系统库文件都被打包合并成了一个大的缓存文件,存放在 /System/Library/Caches/com.apple.dyld/ 目录下(并按不同的 CPU 架构类型分别保存)
    并且为了减少冗余,/System/Library/Frameworks/(iOS 用于存放系统库的默认目录)下的系统库文件都被删除掉了

    iOS 系统共享缓存的路径为:/System/Library/Caches/com.apple.dyld/
    macOS 系统共享缓存的路径为:/var/db/dyld/

    可以通过以下工具将共享缓存当中的系统库二进制文件提取出来:

    1. dyld_cache_extract(一个可视化的工具,使用简单)
    2. dsc_extractor(dyld 源码中自带的工具)
    3. jtool(不能一次提取缓存中全部的库,只能一个一个提取)
    4. dyld_decache
  • iOS 更新共享缓存

    update_dyld_shared_cache 程序位于 /usr/bin/ 目录下,用于更新系统的共享缓存,它会扫描 (共享缓存路径)/shared_region_roots/ 目录下的 paths 路径文件,这些 paths 路径文件 包含了需要加入到共享缓存中的 MachO 文件的路径列表,update_dyld_shared_cache 会逐个将这些 MachO 文件及其依赖的动态库都加载到共享缓存中去

    update_dyld_shared_cache 会在共享缓存目录下,为每种系统构架构生成一个缓存文件与对应的内存地址映射表,以 macOS 的共享缓存目录为例,如下所示:

    ls -l /var/db/dyld/
    total 1741296
    -rw-r--r--   1 root  wheel  333085108 Apr 22 15:02 dyld_shared_cache_i386
    -rw-r--r--   1 root  wheel      65378 Apr 22 15:02 dyld_shared_cache_i386.map
    -rw-r--r--   1 root  wheel  558259294 Apr 25 16:18 dyld_shared_cache_x86_64h
    -rw-r--r--   1 root  wheel     129633 Apr 25 16:18 dyld_shared_cache_x86_64h.map
    drwxr-xr-x  10 root  wheel        340 Apr  7 09:19 shared_region_roots
    

    update_dyld_shared_cache 程序通常只在系统的安装器安装软件 或 系统更新时调用
    我们也可以手动运行 sudo /usr/bin/update_dyld_shared_cache 来更新系统共享缓存
    新的共享缓存会在系统下次启动后自动更新

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值