阅读 1701

iOS程序员的自我修养-MachO文件动态链接(四)

目录

👍了么?

要是觉得写得还可以,看了有收获。不要吝啬👍,记得给一个👍呀

动态链接要比静态链接复杂多了,我要是直接分析MachO文件动态链接的具体实现,会让读者知其然不知其所以然。所以本文分成2部分,第一部分先讲理论知识,基本解答了如下几个问题:

  1. 动态链接产生原因、基本思想、工作过程。
  2. position-independent code (PIC 地址无关代码)产生原因和原理。
  3. 为什么要有相对寻址和间接寻址。
  4. 延迟绑定。

掌握了这些理论知识,再看第二部分讲MachO文件动态链接的具体实现,就容易理解苹果这么做的原因了。千万不要跳过第一部分,不然你会觉得生涩难懂,看了理论,第二部分其实很简单的。

动态链接的理论知识

为什么要动态链接

软件工程发展

  1. 远古时代,所有源代码都在一个文件上(想象下开发一个App,所有源代码都在main.m上,这个main.m得有几百万行代码。多人协同开发、如何维护、复用、每次编译几分钟....)。
  2. 为了解决上面问题,于是有了静态链接。极像我们平时开发了,每个人开发自己的模块功能,最后编译链接在一起。解决了协同开发、可维护、可复用、编译速度也很快了(未改动的模块用编译好的缓存)。
  3. 静态链接好像已经很完美了。那我们平时开发App,都会用到UIKit、Foundation等等许多系统库。假如都是通过静态链接的,我们iPhone手机里的微信、淘宝...所有App,每个App都包含了一份这些系统库,那每个App包体积是不是变大了,占用磁盘空间;我们一边微信聊天一边淘宝购物,那是不是每个App都要在内存里有这些库,占用了内存。还有UIKit里某个函数有bug,需要更新,那所有App是不是也要重新静态链接最新的UIKit库,然后发版。为了解决这些问题,于是乎,产生了动态链接。

动态链接基本思想

把程序的模块分割开来,不是通过静态链接在一起,而且推迟到程序运行时候链接在一起。

比如微信用到UIKit系统库,等到我们点击微信App,微信开始运行之前去链接依赖的UIKit,链接完成再运行App。那微信和淘宝是不是不需要在包里有UIKit,UIKit只需存一份在手机里,等App快运行时候,发现依赖UIKit,然后把UIKit加载到内存里,链接在一起。假如UIKit已经存在内存了,是不是直接链接就可以了。这个就做到了磁盘和内存里,都只有一份UIKit。同样的,升级也非常简单了,UIKit的bug解决了,直接在手机里存放新的UIKit,覆盖旧的,下次App运行时候,就加载这个新的UIKit了。这个链接和静态链接的工作原理非常相像,也是符号解析、地址重定位等。

动态链接基本实现

名称解析:

  1. dyld:the dynamic link editor 。后面dyld表示动态链接器
  2. dylib:动态链接库或者称共享对象

静态链接和动态链接都是把程序分割成一个个独立的模块,但是静态链接是运行前就用ld链接器链接成一个完整的程序;动态链接是程序主模块被加载时候,对应的Mach-O文件里有dyld加载命令,通过这个dyld然后去找依赖的dylib(Mach-O有动态链接库加载命令),把dylib加载到内存(如果对应的dylib不在内存),然后将程序中所有未决议的符号绑定到相应的 dylib中,并进行重定位工作。dyld和dylib加载命令如下:

//dyld加载命令
struct dylinker_command {
	uint32_t	cmd;		/* LC_ID_DYLINKER, LC_LOAD_DYLINKER or
					   LC_DYLD_ENVIRONMENT */
	uint32_t	cmdsize;	/* includes pathname string */
	union lc_str    name;		/* dynamic linker's path name */
};

//在dyld加载命令中,offset为sizeof(cmd)+sizeof(cmdsize)+sizeof(offset)=12;  ptr表示dyld的路径。表示偏移12位置是dyld的路径
//在加载命令中,假如有字符串,那都用lc_str表示,lc_str仅仅告诉去相对于加载命令头部多少的偏移位置取字符串,这个字符串都是放在加载命令结构体最后。 
union lc_str {
	uint32_t	offset;	/* offset to the string */ 
#ifndef __LP64__
	char		*ptr;	/* pointer to the string */
#endif 

======================================

//dylib加载命令
struct dylib_command {
	uint32_t	cmd;		/* LC_ID_DYLIB, LC_LOAD_{,WEAK_}DYLIB,
					   LC_REEXPORT_DYLIB */
	uint32_t	cmdsize;	/* includes pathname string */
	struct dylib	dylib;		/* the library identification */
};

//name 放在加载命令最后,lc_str是告诉偏移位置。加载命令是4字节倍数,字符串填充后,不满足这要求,填充0来满足4字节倍数。
struct dylib {
    union lc_str  name;			/* library's path name 名字*/
    uint32_t timestamp;			/* library's build time stamp 构建的时间戳*/
    uint32_t current_version;		/* library's current version number 版本号*/
    uint32_t compatibility_version;	/* library's compatibility vers number 兼容的版本号*/
};

};
复制代码

举个动态链接🌰

//print.c 文件
#include <stdio.h>

char *global_var = "global_var";

void print(char *str)
{
    printf("wkk:%s\n", str);
}

=======================================

//main.c 文件 

void print(char *str);
extern char *global_var;

int main()
{
    print(global_var);
    return 0;
}

=========================================
//1. 编译main.c 
xcrun -sdk iphoneos clang -c main.c -o main.o -target arm64-apple-ios12.2

//2. 编译print.c 成动态库libPrint.dylib
xcrun -sdk iphoneos clang -fPIC -shared print.c -o libPrint.dylib -target arm64-apple-ios12.2

//3. 链接main.o 和 libPrint.dylib 成可执行文件main
xcrun -sdk iphoneos clang main.o -o main -L . -l Print -target arm64-apple-ios12.2

-target arm64-apple-ios12.2 ==> 运行的目标版本号iOS12.2
-l Print ==> 链接libPrint.dylib
-L . ==> libPrint.dylib在当前路径寻找(.代表当前路径)
复制代码

上面说过动态链接跟静态链接区别就是链接时机推迟到程序被加载时候,但是上面第三步将目标文件main.o链接成可执行文件时候,还是用到了动态库libPrint.dylib了。

通过静态链接,我们知道main.o目标文件里面,不知道global_var和print两个符号的地址。而libPrint.dylib里面有这两个符号,所以我们链接时候,用到libPrint.dylib,让链接器知道这两符号是来自dylib,只需要给这两符号做个标记就可以了,而不是此刻进行绑定和重定位(静态链接此刻就要绑定和重定位)。得到的main可执行文件知道这两个符号是来自dylib,做了标记。等到main被加载时候,再把这两符号绑定到libPrint.dylib里,并进行重定位。

如图,main可执行文件把这两个符号标记来自libPrint.dylib,但是没有解析符号的地址。

position-independent code (PIC 地址无关代码)

产生地址无关代码原因

dylib在编译时候,是不知道自己在进程中的虚拟内存地址的。因为dylib可以被多个进程共享,比如进程1可以在空闲地址0x1000-0x2000放共享对象a,但是进程2的0x1000-0x2000已经被主模块占用了,只有空闲地址0x3000-0x4000可以放这个共享对象a。

所以共享对象a里面有一个函数,在进程1中的虚拟内存地址是0x10f4,在进程2中的虚拟内存地址就成了0x30f4。那是不是机器指令就不能包含绝地地址了(动态库代码段所有进程共享;可修改的数据段,每个进程有一个副本,私有的)。

PIC原理

为了解决dylib的代码段能被共享,PIC(地址无关代码)技术就产生了。PIC原理很简单,就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分是每个进程都有一个副本。

dylib需要被修改的部分(对地址的引用),按照是否跨模块分为两类,引用方式又可以分两类:函数调用和数据访问。这样就分成了4类:

  1. 第一种是模块内部的函数调用、跳转等。
  2. 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。
  3. 第三种是模块外部的函数调用、跳转等。(比如动态库a调用动态库b中的函数)
  4. 第四种是模块外部的数据访问,比如访问其它模块中定义的全局变量。

第一种:模块内部的函数调用、跳转等。

由于调用者和被调用者都在同一个模块里,它们之间的相对位置不变。于是有了相对寻址,用相对寻址就可以做到是地址无关代码。

相对寻址

给出相对于当前地址的偏移地址,两者相加就可以得到寻找的地址。

第二种:模块内部的数据访问(静态变量)

上图中讲了arm64里的bl跳转指令,比较简单。为了讲解模块内部的数据访,我这里再讲一个比较难的指令adr和adrp指令,也是一个相对寻址指令。讲之前,大家想下,为什么要有相对寻址?有两个原因:

  1. 我们上面讲到,模块内部相对位置不变,可以产生地址无关代码。
  2. 根源问题是,所有的ARMv7 / ARMv8指令都是4字节长,但是对应地址是4字节/8字节长,一条指令是没办法容纳下绝对地址的。所以产生了相对地址。

adr 和 adrp

adr指令是可以寻找+/- 1MB的相对地址;adrp指令可以寻找+/-4GB的相对地址。

  1. adr指令:

immhi(immediate value high 立即数高位)和immlo(immediate value low立即数低位)一起是21位,1位是符号位(往前/后跳),剩下20位表示1MB(2的10次方=1KB,2的20次方=1MB...)。立即数(offset)+PC(base)=目标地址。

  1. adrp指令:

adrp类似于adr,但它将12个较低位归零并相对于当前PC页面偏移。所以我们可以寻找+/-4GB的相对地址,代价是adrp指令后面,要跟着add指令去设置较低的12位。

adrp指令将21位立即数左移12位,将其和PC(程序计数器)相加,最后较低12位清零,然后将结果写入通用寄存器。这允许计算4KB对齐的存储区域的地址。 结合add指令,设置较低12位,可以计算或访问当前PC的±4GB范围内的任何地址。

模块内部的数据访问也是用相对寻址,因为模块内部数据相对指令的相对位置也是固定的。在arm64中用adrp来相对寻址。

第三种:模块外部的数据访问。

模块外部的数据访问的目标地址,要等到模块被装载时才决定,例如上面的动态链接🌰,main函数(可以当作是主模块的一个函数)访问外部的global_var全局变量。global_var被定义在libPrint.dylib模块,要等这个模块被装载了,然后链接器才决定global_val目标地址。前面提到了PIC基本思想就是把跟地址相关的部分放到数据段里面。mach-o文件的数据段有一个got section(got:Global Offset Table 全局偏移表),当代码需要引用该全局变量时,可以通过got中相对应的项间接引用。

例如下图,访问global_var时,地址是0x100008000,说明global_var的真实地址存放在地址0x100008000里面(注意:global_var真实地址不是0x100008000,而是放在0x100008000里面。想下C语言中的指针)。链接器在装载模块时候会查找每个外部变量所在的地址,然后填充got中的各个项,确保got里面(下图的绿框)存放的地址是正确的。got在数据段,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。

got如何做到PIC呢。从模块内部数据访问,我们知道用相对寻址就可以做到PIC。而got也在模块内部,我们指令访问got,可以用相对寻址做到PIC。然后取got地址存放的值,就是模块外部的数据的目标地址。做到了PIC。这种寻址也成为间接寻址。

第四种:模块外部的函数调用、跳转等。

模块外部的函数调用,跟上面一样的,也是间接寻址,此时got里面存放的是模块外部的函数地址。

通过上面,可以看到模块内部的函数访问和数据访问,都是用相对寻址做到PIC;模块外部的函数访问和数据访问,都是用间接寻址做到PIC。

延迟绑定

延迟绑定基本思想

延迟绑定基本思想跟iOS的objc_msgSend基本一样的,都是第一次调用函数时候,去查找函数的地址。而不是程序启动时候,先把所有地址查找好。

模块外部的函数和数据访问,都是通过got来间接寻址的。程序被加载时候,动态链接要进行一次链接工作,比如加载依赖的模块,修改got里面的地址(符号查找、地址重定位)等工作,减慢了程序的启动速度。比如我们引入了Foundation动态库,就一定会使用里面的全部函数吗?肯定不是的,那我们可以类似objc_msgSend,等第一次调用时候,再去查找函数的地址。(got在数据段,程序运行期间可修改,所以第一次调用后,把函数的真实地址填入即可。objc_msgSend是第一次调用后,把函数地址放入cache里,加速查找。)

MachO文件动态链接的具体实现

dysymtab_command

上面已经讲了两个和动态链接有关系的加载命令:dylinker_command(LC_LOAD_DYLINKER)和dylib_command(LC_LOAD_DYLIB)。接下来讲下加载命令:dysymtab_command(LC_DYSYMTAB)
//定义在<mach-o/loader.h>中
struct dysymtab_command {
    uint32_t cmd;	/* LC_DYSYMTAB */
    uint32_t cmdsize;	/* sizeof(struct dysymtab_command) */
    ... 这里省略了好多暂时不需关心的字段(这个命令太多字段)
    uint32_t indirectsymoff; /* file offset to the indirect symbol table 指向间接符号表位置*/
    uint32_t nindirectsyms;  /* number of indirect symbol table entries 间接符号表里元素的个数*/
    .... 省略
};	
复制代码

dysymtab_command可称为间接符号表(Indirect Symbol Table),可以看做指向一个数组,里面元素是整型数字。例如dysymtab[0]值为2,意义:间接符号表第0项对应的符号在符号表第2项中(不清楚符号表:见上篇文章静态链接)。

got和la_symbol_ptr

前面讲模块间的函数调用和数据访问,都是通过got间接寻址,然后又讲到延迟绑定。

具体到macho文件,因为模块间的数据访问很少(模块间还提供很多全局变量给其它模块用,那耦合度太大了,所以这样的情况很少见),所以外部数据地址,都是放到got(也称Non-Lazy Symbol Pointers)数据段,非惰性的,动态链接阶段,就寻找好所有数据符号的地址;而模块间函数调用就太频繁了,就用了延迟绑定技术,将外部函数地址都放在la_symbol_ptr(Lasy Symbol Pointers)数据段,惰性的,程序第一次调用到这个函数,才寻址函数地址,然后将地址写入到这个数据段。下面通过上面的动态链接🌰来分析:

got

上图,从main函数中,看到访问的global_var在got数据段。在程序装载时候,就重定位got里面的地址,可是重定位global_var时候,至少得知道两个信息(1、这是什么符号;2、这个符号来自哪个模块),才能找到global_var的地址,修改got。

在上面我们已经知道了,符号表里描述了每个符号的信息(包括外部符号来自哪个模块),所以我们需要知道global_var对应符号表的index。在MachO文件结构分析最后,讲了section_64,里面有一个字段reserved1。

struct section_64 { /* for 64-bit architectures */
	...
	uint32_t	reserved1;	/* reserved (for offset or index) */
    ...
};
复制代码

在got数据段的section_64里,这个reserved1表示got里面的符号在间接符号表(IndirectSymbolTable)的起始index,然后根据间接符号表含义。可得到

value = IndirectSymbolTable[got.section_64.reserved1];
symbolTable[value] 就是got数据段的第一个符号。
symbolTable[value+1] 就是got数据段的第二个符号。
...依次类推

//从got的section_64可以找到got数据段里面元素对应的符号
复制代码

la_symbol_ptr

模块间的函数调用,是一个很频繁的操作。具体到macho的动态链接中,将外部函数地址都放在la_symbol_ptr(Lasy Symbol Pointers)数据段,惰性的,程序第一次调用到这个函数,才寻址函数地址,然后将地址写入到这个数据段。用上面同样的方法,在la_symbol_ptr数据段的section_64,先找到reserved1,然后三步找到这个函数符号是什么,来自哪个模块,但是程序加载时候不重定位。下面我们以上面的动态链接🌰来分析:分析一下第一次调用时候,是如何寻址到函数地址的。

  1. 第一步,在la_symbol_ptr数据段,第一项就是print函数。有没有发现居然有print“地址”0x100007fac(got里都是0,动态链接才重定位,写入地址)

  1. 第二步,跳到0x100007fac(在stub_helper代码段)

  1. 第三步,跳到0x100008008,到了got数据段;我们上面分析时候,都说got里面存放的都是外部数据符号。但是动态链接时候,会重定位dyld的dyld_stub_binder函数地址,放在这里。其实dyld_stub_binder是一个寻址外部函数地址的函数,所以必须提前重定位好。那么第一次调用print函数时候,会调用dyld_stub_binder函数去寻址地址,寻址到了,就把print的地址写入到第一步的la_symbol_ptr数据段,替换掉0x100007fac,然后调用print函数,后面再次调用print函数时候,就没有第二三步了,直接调用了print函数。(dyld_stub_binder函数跟objc_msgSend一样的,也是用汇编写的)

总结一下MachO文件动态链接的具体实现

有木有发现其实访问模块外部(可以简单理解主模块,访问dylib)的变量和函数,为了做到PIC,都是把变量和函数的地址放到数据段,因为数据段可修改。只是写入变量和函数地址的时机不同,变量是动态链接时候写入,主要很少访问模块外部变量,对程序启动速度影响小。而函数是第一次调用时候,才去寻址,写入地址。到这里,应该很好理解fishhook为啥可以修改模块外部的函数/变量的地址,外部函数/变量的地址都放在数据段啊,数据段本来就是可以修改的。如果我们不理解动态链接,以为函数地址在代码段,那就很难理解fishhook是什么黑魔法了。下一篇我们将好好分析一下fishhook。

关注下面的标签,发现更多相似文章
评论