深刻iOS系统底层之静态库

少长咸集,群贤毕至。--《王羲之・兰亭集序》html

目标文件

目标文件结构

程序员编写的是源代码,而计算机运行的则是CPU能识别的机器指令,所以必需要有一系列工具或程序来将源代码转化为机器指令,这个转化的过程须要经历编译和连接两个主要阶段。所谓编译就是将源代码文件转化为中间的目标文件(Object file)。目标文件的后缀通常为.o。iOS系统的目标文件也是一种mach-o格式的文件,mach-o文件的头部结构体:struct mach_header中的filetype成员字段用来描述当前文件的类型,目标文件所对应的类型是MH_OBJECT。目标文件中的布局结构和内容和可执行文件中的布局结构和内容很是类似,编译后造成的目标文件中的代码段(__TEXT Segment)中的节(__text Section) 中的内容存放的是已经被编译为机器指令的二进制代码了。下面就是一个目标文件的布局结构: linux

目标文件结构

重定位表(Relocation table)

系统的编译操做是针对一个个源文件的独立行为。一般状况下在编写程序时会引用其余源文件或者动态库中定义的函数或者类方法以及全局变量,所以在编译阶段全部的外部引用符号的地址是没法被肯定的,此时生成的目标文件中的段(Segment)中的节(Section)中的外部函数调用指令的操做数部分以及外部全局变量符号的地址的值都将是0。在后续的连接过程当中须要调整这些指令的操做数的值来进行重定位(Relocation),为此系统在编译的目标文件中的对那些有外部符号引用的节(Section)中都会创建一个重定位表(Relocation table)。这个重定位表中的每一个条目会将全部须要进行重定位的指令或者数据访问的位置信息以及引用的外部符号的信息记录起来,以便在连接时进行更新处理。下面的图表展现了这个结构:git

目标文件的重定位信息

现假设工程中有一个源文件test.m,其内容以下:程序员

int testfn(NSString *str)
{
      return [str lenght];
}
复制代码

这个源文件中有一个OC方法调用[str length],方法在编译时会转化为对objc_msgSend函数的调用,可是由于objc_msgSend函数的定义在动态库libobjc.dylib中,所以对于源文件test.m来讲这是一个外部符号,在生成函数调用指令时编译器没法肯定objc_msgSend函数相对于当前指令的偏移量,所以指令中的函数调用没法肯定操做数的值,就如上图的调用指令0x00000094同样只有操做码而操做数被暂时设置为0。github

为了在连接时可以对全部的外部符号引用进行重定位,描述机制代码__text的Section结构:windows

//若是是64位系统则是section_64
struct section { /* for 32-bit architectures */
	char		sectname[16];	/* name of this section */
	char		segname[16];	/* segment this section goes in */
	uint32_t	addr;		/* memory address of this section */
	uint32_t	size;		/* size in bytes of this section */
	uint32_t	offset;		/* file offset of this section */
	uint32_t	align;		/* section alignment (power of 2) */
	uint32_t	reloff;		/* 重定位入口表的偏移 */
	uint32_t	nreloc;		/* 重定位的条目数量 */
	uint32_t	flags;		/* flags (section type and attributes)*/
	uint32_t	reserved1;	/* reserved (for offset or index) */
	uint32_t	reserved2;	/* reserved (for count or sizeof) */
};
复制代码

中的reloff和nreloc两个字段用来描述这个节中全部须要进行重定位的信息。就如上面的图例中的"Relocations Offset"和"Number of Relocations"中描述的是重定位表在文件的0x116c的偏移处,一共有3个须要进行重定位的信息。重定位表的条目是一个结构体:数组

struct relocation_info {
   int32_t	r_address;	/* offset in the section to what is being
				   relocated */
   uint32_t     r_symbolnum:24,	/* symbol index if r_extern == 1 or section
				   ordinal if r_extern == 0 */
		r_pcrel:1, 	/* was relocated pc relative already */
		r_length:2,	/* 0=byte, 1=word, 2=long, 3=quad */
		r_extern:1,	/* does not include value of sym referenced */
		r_type:4;	/* if not 0, machine specific relocation type */
};
复制代码

这个结构体在<mach-o/reloc.h>中被定义,它的结构定义我将在后续的文档中会有详细的介绍。这里你们只要了解一下这个结构中主要包括的是须要进行重定向的指令的位置,以及外部引用的符号信息。就如上图中展现的同样。安全

简要的说一下连接步骤所作的事情

当编译器对全部的源代码文件编译完成后,接下来的步骤就是连接了。连接的主要功能就是将全部目标文件中的各个相同段和节的信息依次链接起来拼装成一个单独的可执行文件。同时还会将全部目标文件中须要Relocation的部分的指令进行调整,由于此时能够知道每一个引用符号的位置了。在连接时系统会分析每一个目标文件中的依赖信息,也就是说连接成一个可执行文件中各段各节的内容老是无依赖的目标文件放在前面而有依赖的目标文件放置在后面。bash

基地址重定向(Rebase)

在连接时还有一个重要的信息就是添加基地址重定向(Rebase)信息。缘由是进程执行时每一个引用的动态库以及可执行文件所加载的基地址是不同的。而是一个随机的行为。而咱们的源代码或者系统实现中有不少地方保存的是一个绝对地址值,就好比runtime中每一个OC类的方法列表中的IMP部分的值就是一个函数地址指针。在对程序进行编译连接时会为生成的可执行文件或者动态库指定一个默认的虚拟基地址,后续全部生成的代码中的绝对地址值都是基于这个虚拟基地址来构建的。咱们能够在可执行文件的mach-o文件的名字为__TEXT的这个LC_SEGMENT或者LC_SEGMENT_64中的load command定义中找到程序加载的默认的基地址。名字为__TEXT的结构体struct segment_command中的vmaddr数据成员中的值保存的就是程序加载的默认基地址值,通常状况下可执行程序的默认基地址都是0x100000000。网络

而刚才说了虽然程序生成时的基地址是固定的,可是的每次程序加载到内存的基地址是不同的,而是一个随机值。所以程序加载的真实基地址和程序生成时的基地址值之间就有一个slide值,也就是地址差值。可是由于程序中不少地方的地址值都是以生成的虚拟基地址为基础的,因此在程序运行加载时须要对这部分函数地址进行基地址重定向(rebase)处理。为了实现rebase的能力,可执行文件的mach-o文件中会构造出一个LC_DYLD_INFO或者LC_DYLD_INFO_ONLY的load command,这个load command的结构描述是一个struct dyld_info_command,详细描述能够在<mach-o/loader.h>中看到。这个结构体中的rebase_off和rebase_size两个字段分别用来描述须要进行rebase的表的偏移以及须要进行rebase的数量。rebase表中记录着全部须要进行rebase的信息,这样当进程在加载时就会根据默认基地址的值和真实加载的基地址值之间的slide值来调整这部份内容的值。下面就是rebase段的内容:

rebae信息

能够看出在LC_DYLD_INFO_ONLY中不只有须要进行rebase的地址信息,还有弱绑定和懒加载的信息。每一个rebase条目中记录着rebase须要进行的操做(opcode)以及须要进行rebase的地址所在的段以及段内偏移值等信息。关于rebase的详细信息我将会在后面的文章中继续介绍。这里就再也不赘述了。

静态库代码连接规则

应用程序连接的过程最开始是以主程序工程中的全部目标文件为单位进行的,不管这个工程中的目标文件中的代码是否有被引用或者被调用都会连接进可执行程序中。在连接的过程当中,若是发现某个符号没有在主程序工程中被定义,那么就会去导入的动态库文件或者静态库文件中查找。若是符号在动态库中被定义那么就会为动态库的中的符号(这里假设符号就是某个函数) 生成stub代码而且将引用信息放入导入符号表以便在后续程序运行时动态的加载真实的函数地址。而若是发现符号在静态库中被定义那么就会按以下的规则进行处理:

  • 默认状况下是以静态库中的目标文件为单位进行连接的,只要某个目标文件中定义的符号被主程序引用,则这个目标文件中的全部代码都会连接到可执行程序中去。若是这个目标文件中又引用了其余目标文件中定义的符号则连接会进行递归处理。若是静态库中某个目标文件中的代码没有被任何其余地方引用则这个目标文件将不会连接到可执行程序中去。
  • OC类的方法列表的构建是在编译阶段完成的,可是对其中的方法调用都是在运行时动态肯定的,因此在代码中的任何对静态库中定义的OC类的方法调用都不会被认为是对符号的引用,都不会产生连接行为。除非在代码中引用了这个OC类自己才会产生连接行为,此时会把静态库中定义的全部OC类的方法都连接到可执行程序中(由于OC类的方法列表在编译阶段已经构建完成)。也就是说静态库中的OC类定义的方法要么就所有都连接进可执行程序中,要么就一个方法也不会被连接

假设某个静态库中定义了一个名字为CA的OC类:

//类中定义了2个方法。
@interface CA:NSObject
-(void)fn1
-(void)fn2;
@end

//假如在同一个文件中还定义了CB类
@interface CB:NSObject

@end

复制代码

假设主程序中有两处会使用到静态库中定义的CA类的地方:

//虽然这里CA做为一个参数,而且里面调用了对应的方法,可是在连接时仍然不会将CA类连接进来,由于这个是一个运行时的间接方法调用过程。
void foo1(CA *p)
{
    [p fn1];
}

//假设没有foo2这个函数则CA类中的代码是不会连接进可执行程序中的。
void foo2()
{
    //只有明确的使用CA类来建立对象时,才代表是对CA类的引用。这样才会将CA类中的全部方法都连接进可执行程序中,这里虽然没有调用fn2可是fn2的实现也会被连接进去。
     CA *p = [CA new];
     [p fn1];
}

void main()
{
    foo1(nil);
    foo2();   
}

复制代码

由于CB和CA类在同一个.m文件中实现,因此即便CB类没有被引用,可是根据上述的按文件为单位的连接规则,CB类仍然会被连接到可执行程序中,除非CB类和CA类不在同一个文件中实现。

  • 静态库中的任意OC类所定义的分类方法默认状况下都不会连接到可执行程序中,即便这个方法在主程序中调用了也是如此(这也就是为何当咱们调用静态库中某个类的分类方法时总会报方法找不到的异常,缘由上面的OC类方法调用都是运行时被肯定而不是编译时就被肯定)。 除非是在主程序工程中的Other Linker Flags 中添加 -ObjC 选项。这个选项的意思是会把全部静态库中定义的OC类的方法都连接到可执行程序中去,而无论这个类是否有被引用,也无论方法是不是分类方法。

  • 若是静态库中定义的C语言函数没有被任何地方引用则这个函数将不会被连接到可执行程序中去。而若是相同文件中其余符号被引用则根据以文件为单为的连接规则即便这个函数没有被引用也会连接到可执行程序中去。

  • 若是静态库中定义的C++类的的某个普通的成员函数没有被任何地方引用则这个成员函数将不会被连接到可执行程序中去。若是这是一个虚函数则只要这个类被引用则即便这个虚函数没有被引用也会连接到可执行程序中去,由于虚函数须要在编译时参与虚表的构建。而若是相同文件中其余符号被引用则根据以文件为单为的连接规则即便这个文件中C++类中定义的成员函数没有被引用也会连接到可执行程序中去。

  • 若是静态库中某文件定义的Swift类没有被任何地方引用则不会连接到可执行程序中,若是类自己或者类中的方法被引用则类中定义的全部方法都会连接到可执行程序中去。对于Swift类定义的extension中的扩展方法而言若是扩展方法是和类方法定义在同一个文件,则一旦类被引用则扩展方法也会被连接到可执行程序中。若是扩展方法定义在不一样的文件中,则只有扩展方法被调用时才会被连接进可执行程序。

Swift类的方法调用并非和OC类的方法调用在运行时才决定的,而是采用了相似C++虚函数的机制来实现多态的功能以及和虚函数的调用机制类似,而Swift中对extension的实现则是直接采用函数地址调用,也就是说extension中定义的函数就和普通的C函数很是类似。

  • 若是咱们在Other Linker Flags中添加**-all_load选项,则主程序工程会把全部静态库中的全部代码所有连接到可执行程序中去,而无论代码是以何种语言实现的代码,以及无论代码是否被引用和调用。若是咱们只想对某个静态库中的全部代码进行所有连接处理,则能够在Other Linker Flags中添加-force_load 静态库路径**来实现。

这也就是为何当咱们调用在静态库中定义的分类方法时若是不使用-Objc或者-all_load选项时,会出现方法不被识别的调用异常了。可是这两个选项的另一个问题是无论静态库中的类是否被引用都会将代码连接到可执行程序中去,从而增长了可执行程序的尺寸。

  • 咱们能够在主程序工程的项目中将DEAD_CODE_STRIPPING(Dead Code Stripping) 开关开启,用来优化可执行程序中的代码。须要注意的是这个开关是在代码连接完成后的优化行为。当这个开关被打开时连接器会删除可执行程序中全部没有被调用的C函数以及C++中的普通成员函数。可是不会删除没有被调用到的OC类的成员方法,以及Swift类的成员方法,以及C++类中的虚函数。在XCODE中这个开关默认是开启的。

从上面的规则中能够看出采用静态库的形式进行连接能够减小可执行文件的尺寸。有的时候咱们的应用可能会引用一些第三方的静态库,而这些第三方的静态库的尺寸很是的庞大(好比地图类的SDK,可能有好几百兆)。但是当应用最后生成的可执行文件却不是那么的大。如今的应用每每都集成了不少的功能,尤为是一些大型应用的尺寸都已经达到好几百M了,这么大尺寸的应用下载安装的时间每每很长,并且还会消耗用户的网络流量,甚至会影响程序的启动时间。

静态库的做用

每当咱们build一个工程项目时,系统老是会先将全部源代码编译为目标文件,再将目标文件连接为可执行程序。即便是咱们改变其中某一个文件中的源代码,而其余文件没有改变也是如此。所以为了加快编译速度,有些文件将再也不以源代码的形式提供,而是能够将一部分目标文件先集中起来造成一个静态库。这样就能够对这部分文件略过编译而直接进行连接从而加快编译的速度。

对于iOS系统来讲由于不支持第三方以动态库的形式集成到咱们的工程中以及上传到appstore。而第三方提供的库由于安全和知识产权以及保密的特性不大可能以源代码的形式提供给咱们,而是以静态库的形式提供给咱们。

可见静态库的做用主要是为了加快编译速度、进行模块划分、以及代码安全的功能。静态库是一个编译产生的结果,而动态库则是编译连接产生的结果。静态库的组成实际上是一个个目标文件。下面就是静态库和普通源代码参与编译和链接的流程图,从流程图中能够看出静态库存在的做用和意义:

静态库参与连接的流程

静态库文件结构

静态库是由文件头标志加符号表加目标文件集合组成的一个文件。可见静态库文件是一个文件的集合文件。静态库在unix/linux中通常以.a结尾,而在windows中通常以.lib结尾。静态库文件是一种档案文件(archive file),档案文件的格式并无造成统一的标准。

静态库的文件格式并非mach-o文件格式的一部分。可是目前大部分操做系统中静态库的文件格式和生成标准都很是的类似。由于在iOS系统中能够支持x64和arm两种体系结构,所以iOS系统中的静态库文件中还能够同时支持多种体系结构的目标文件的集合,咱们称这种静态库文件之为fat格式的静态库文件。下面分别展现的单体系结构下的静态库文件布局结构和多体系结构下的静态库文件布局结构:

静态库文件布局结构

1.静态库文件签名

正如大部分文件的开头老是有一个所谓的magic标识同样,单体系结构静态库文件的开头也有一个8字节长度的字符串签名:!\n。这个签名是全部档案文件(archive file)的通用头部签名。所以你能够经过读取文件的前8个字节的内容来和“!\n”进行比较判断来确认是不是一个有效的静态库。须要注意的是这里的\n是一个换行的转义字符。

2.符号表头结构

静态库文件的第二部分就是一个符号表头结构。其实符号表也是能够单独成为一个文件的。所以符号表头结构其实就是用来对符号表进行描述的结构体。这是一个变长的结构体,结构体定义以下:

struct symtab_header
{
   char identifier[16];       //符号表的标识
   char timestamp[12];       //符号表生成的时间戳, 这里用数字字符串来表示从1970.1.1到如今的毫秒数。
   char ownerid[6];             //符号表文件的全部者标识
   char groupid[6];             //符号表文件的组标识
   char mode[8];                 //符号表文件的读写模式
   char size[10];                  //符号表的尺寸,用字符串形式表示的尺寸。
   char end[2];                    //头部结束标志。
   char name[0];                 //可选的符号表文件名称。
};
复制代码

符号表头结构体中全部的数据成员都是字符串类型,观察结构体的数据成员有不少是和文件属性关联的,好比时间戳、全部者、所属的组、以及读写模式。这样定义的做用是当咱们把静态库中的符号表信息单独提取出一个文件时能够设置提取出来文件的默认属性,同时这些信息也用来描述生成这个静态库的符号表文件的信息。 符号表头结构中的identifier和name两个数据成员均可以用来描述符号表的名字。name部分则是可选的。当identifier为正常的字符串时则identifier字段用来描述符号表的名字。而当identifier中的内容为一个特殊值: “#1/长度” 时则代表name部分是用来描述符号表名称的。name的长度则由identifier中指定的长度决定。好比当某个identifier中的内容为:“#1/20”时则代表符号表的名称存放在name字段中,而且名字的长度为20个字符。通常状况符号表的名称都是固定为:“__.SYMDEF”或者为"__.SYMDEF_64",而且保存在name字段中。

3.符号表

静态库中的符号表中保存的是全部目标文件中的符号表信息的集合。咱们知道在程序连接时须要读取目标文件中的符号表信息才能决定其余目标文件中引用的符号信息是否真实存在,当其余目标文件引用的符号信息不存在或者找不到时就会报经典的符号信息不存在的错误:

Undefined symbols for architecture arm64:
  "_fn", referenced from:
      -[ViewController viewDidLoad] in ViewController.o
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

复制代码

那么既然目标文件中都有符号表信息,为何还要在静态库的开头来构造一段静态库内全部目标文件导出的符号信息呢?答案就是为了加快连接的速度,由于每次都从目标文件中去读取符号信息确定会比单独从静态库中一处读取符号信息要慢不少。

符号表的结构体也是一个可变长度的结构体其定义以下:

struct symtab
{
     int size;    //符号表条目的尺寸。注意这里是整个符号表条目数组的尺寸,而不是条目的数量。
     struct ranlib[0];  //符号表条目数组,若是是64位的则是ranlib_64
};
复制代码

结构体struct ranlib的定义能够在<mach-o/ranlib.h>中找到。这个结构体的定义以下:

struct	ranlib {
    union {
	uint32_t	ran_strx;	 //符号名称在下面的字符串表中的开始偏移位置。
#ifndef __LP64__
	char		*ran_name;	/* symbol defined by */
#endif
    } ran_un;
    uint32_t		ran_off;	//符号归属的目标文件头结构的偏移。
};
复制代码

每一个符号条目由两部分组成:一个ran_strx是指定符号在下面字符串表中的开始偏移的位置。一个ran_off则是指定这个符号是在哪一个目标文件中被定义,这个值是对应目标文件的目标头结构在静态库中的偏移量值。所以能够经过这个值快速的定义到符号所在的目标文件。

4.字符串表

静态库里面的字符串表是专门用来为符号表服务的。字符串表跟在符号表的后面,最开始的4个字节保存的是字符串表的长度,然后面跟随的就是以\0结尾的字符串数组列表。字符串表的结构定义以下:

struct stringtab
{
    int size;     //字符串表的尺寸
    char strings[0];   //字符串表的内容,每一个字符串以\0分隔。
};
复制代码

5.目标文件头结构

目标文件头结构用来描述后面跟随的目标文件的信息。它的结构的定义和符号表头结构是如出一辙的。这里就再也不赘述了。

6.目标文件

目标文件是一个mach-o格式的文件,在上面关于目标文件的介绍中有大致介绍目标文件的格式,要想了解更多关于目标文件的格式信息请参考一些相关的mach-o格式介绍的文档,以及后续我也会在相关的文章中进行详细介绍。

由于在静态库中是目标文件的集合,所以每一个静态库文件中都会有很是多的目标文件头结构和目标文件。下面就是一个静态库文件结构的例子:

静态库文件结构实例

7.Fat静态库头结构

静态库文件中可能只有一个体系结构的库,可能包括多个体系结构的库的集合,就好比第三方提供给咱们的静态库可能会有模拟器版本和真机版本。所以静态库也是能够支持多体系结构的,当一个静态库中包含有多种体系结构的内容时,在静态库文件的开头将是一个Fat静态库的头结构,而不是以"!\n"开头了。而是一个以下定义的结构体:

struct fat_header {
	uint32_t	magic;		/* FAT_MAGIC or FAT_MAGIC_64 */
	uint32_t	nfat_arch;	/* number of structs that follow */
};
复制代码

这个结构体的定义能够在<mach-o/fat.h>中找到,能够看出不管是静态库仍是可执行文件,当文件中包含多个体系结构的代码时,文件的开头都是一个fat_header的结构体。结构体后面跟随着多个体系结构的描述信息。

8.体系结构头

体系结构头信息描述具体的体系结构的信息,这个结构体的定义以下:

//若是是64位系统则是fat_arch_64
  struct fat_arch {
	cpu_type_t	cputype;	/* cpu specifier (int) */
	cpu_subtype_t	cpusubtype;	/* machine specifier (int) */
	uint32_t	offset;		/* file offset to this object file */
	uint32_t	size;		/* size of this object file */
	uint32_t	align;		/* alignment as a power of 2 */
};
复制代码

这个结构体的定义也能够在<mach-o/fat.h>中找到,能够很清楚的看到结构体中有描述具体的CPU的类型,以及对于的内容的偏移offset和size。对于静态库来讲每一个fat_arch的offset位置就是一个单体系结构的静态库的文件的内容,而可执行文件来讲offset位置指定的就是可执行文件的image内容。

上面就是我要介绍的关于静态库文件结构的全部内容了。经过上面的介绍我想你应该对静态库的做用和其文件布局结构有了更进一步的了解。咱们能够经过XCODE工程来生成一个静态库文件,咱们还能够经过lipo命令来构造一个多体系结构的静态库。(其实了解了静态库的文件结构后咱们就很容易本身编写出一个lipo命令出来了!)

静态库的一些操做命令。

对于静态库文件一般状况下咱们能够借助lipo命令在构建多体系结构的静态库,还能够经过ar命令来构建和显示一个静态库中的文件,以及提取这些文件,或则将某个目标文件从静态库中删除,以及将某个目标文件添加到静态库中。另外你还能够用nm命令来查看一个静态库中的全部符号信息。

lipo命令使用入口blog.csdn.net/SoaringLee_…

ar命令使用入口: www.cnblogs.com/woxinyijiu/…

nm命令使用入口: www.jianshu.com/p/6d5147347…

静态库中的一个应用场景

静态库的目标文件中的relocation信息是保存的外部符号的引用信息,那么咱们能够对目标文件的这部分信息进行修改,使得在不改变源代码的状况下实现原生对函数A的调用改成对函数B的调用!一个很是有意思的应用就是咱们能够改动全部对objc_msgSend的调用!来实现对OC方法调用的HOOK处理。至于为何要对静态库中的目标文件修改的缘由是XCODE对源代码的编译和连接是一体的咱们没法在编译以后和连接以前插入脚原本修改目标文件中的内容。可是静态库中的内容则是咱们能够任意预先去修改的。

参考

1.本文对静态库结构的介绍主要是来自于machOView的源代码。 2.en.wikipedia.org/wiki/Ar_(Un…

👉【返回目录


欢迎你们访问个人github地址

相关文章
相关标签/搜索