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

目录

👍了么?

要是以为写得还能够,看了有收获。不要吝啬👍,记得给一个👍呀html

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

  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了。这个连接和静态连接的工做原理很是相像,也是符号解析、地址重定位等。缓存

动态连接基本实现

名称解析:bash

  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了。app

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

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

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时候,至少得知道两个信息(一、这是什么符号;二、这个符号来自哪一个模块),才能找到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。

相关文章
相关标签/搜索