Showing Posts From

书评

《程序员的自我修养(链接、装载与库)》学习笔记三(装载和动态链接)

继续学习《程序员的自我修养 - 链接、装载与库》的第三个大的部分,前两篇内容参见: 《程序员的自我修养(链接、装载与库)》学习笔记一(温故而知新) 《程序员的自我修养(链接、装载与库)》学习笔记二(编译和链接) 这一篇包含本书的六、七章节,这两个章节中作者给我们讲解了可执行文件的装载以及动态链接的过程,操作系统是如何将程序装载到内存中运行,如何为程序的代码、数据、堆、 栈在进程地址空间中分配,分布。动态链接是如何有效的利用内存和磁盘资源,如何让程序代码的重用变得更加可行和有效等等。 经过上面几个章节章节的学习,我们已经知道了什么是可执行文件,以及可执行文件的静态链接过程,下面我们思考几个问题:为什么有了静态链接,还需要动态链接?静态链接和动态链接有什么区别呢? 可执行文件只有被装载到内存以后才能被 CPU 执行,装载的基本过程是什么样的呢? 共享对象根据模块位置和引用方式的不同分为:模块内跳转、模块内数据访问、模块外跳转、模块外数据访问,这四种类型的寻址方式有何不同? 装载时重定位和地址无关代码是解决绝对地址引用问题的两个方法,这两种方式的利弊都是什么?下面我们带着这些问题进行第六七章节的学习。首先看下这两个章节的知识点分布:可执行文件的装载 装载的方式全部载入内存程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的办法就是将程序运行所需要的指令和数据全装入内存中。根据局部性原理进行载入程序运行时是有局部性原理的,所以可将程序最常用的部分驻留在内存中,不太常用的数据存放在磁盘里面,这就是动态装入的基本原理,**覆盖装入(Overlay)和页映射(Paging)**是两种很典型的动态装载方法。 覆盖装入 一个程序有主模块 main,main 分别会调用到模块 A 和模块 B,但是 A 和 B 之 间不会相互调用,这三个模块的大小分别是 1024 字节、512 字节和 256 字节。假设不考虑内存对齐、装载地址限制的情况,理论上运行这个程序需要有 1792 个字节的内存,当采用内存覆盖装入的办法,会按照下图的方式安排内存,我们可以把模块 A 和模块 B 在内存中相互覆盖,即两个模块共享块内存区域,除了覆盖管理器,整个程序运行只需要 1536 个字节,比原来的方案节省了 256 字节的空间。在多个模块的情况下,程序员需要手工将模块按照它们之间的调用依赖关系组织成树状结构。例如下图,模块 main 依赖于模块 A 和 B,模块 A 依赖于 C 和 D,模块 B 依赖于 E 和 F,则它们在内存中的覆盖方式如下图:覆盖管理器需要保证两点:这个树状结构中从任何一个模块到树的根模块都叫调用路径。当该模块被调用时,整个调用路径上的模块必须都在内存中。 禁止跨树间调用。任意一个模块不允许跨过树状结构进行调用。覆盖装入的方法把挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉,一旦模块没有在内存中,还需要从磁盘或其他存储器读取相应的模块,所以覆盖装入的速度肯定比较慢,不过这也是一种折中的方案,是典型的利用时间换取空间的方法,现在已经几乎被淘汰了。 页映射 页映射是将内存和所有磁盘中的数据和指令按照页**(Page)**为单位划分成若干个页,装载和操作的单位就是页。假设我们的 32 位机器有 16 KB 的内存,每个页大小为 4096 字节,共有 4 个页,假设程序所有的指令和数据总和为 32 KB,那么程序总共被分为 8 个页。我们将它们编号为 P0~P7。16 KB 的内存无法同时将 32 KB 的程序装入,于是我们将按照动态装入的原理来进行装入。如果程序刚开始执行时的入口地址在 P0,这时装载管理器发现程序的 P0 不在内存中,于是将内存 F0 分配给 P0,并且将 P0 的内容装入 F0,运行一段时间以后,程序需要用到 P5,于是装载管理器将 P5 装入F1,当程用到 P3 和 p6 的时候,它们分别被装入到了 F2 和 F3,映射关系如下图:但如果这时候需要访问第 5 个页,那么装载管理器必须做出抉择,它必须放弃目前正在使用的 4 个内存页中的其中一个来装载新的页。至于选择哪个页,我们有很多种算法可以选择:使用 FIFO 先进先出算法选择第一个被分配掉的内存页。 使用 LRU 最少使用算法选择很少被访问到的页。装载的过程 进程建立创建一个独立的虚拟地址空间。创建一个虚拟空间实际上是创建映射函数所需要的相应的数据结构。 创建虚拟地址实际只是创建页目录,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该缺页从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这种映射关系只是保存在操作系统内部的一个数据结构。Linux 中将进程虚拟空间中的一个段叫做虚拟内存区域 VMA,在 Windows 中将这个叫做虚拟段 Virtual Section。将 CPU 的指令寄存器设置成可执行文件的入口地址,启动运行。从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。页错误 上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚拟之间的映射关系而已。 当 CPU 开始打算执行这个地址的指令时,发现是个空页面,于是它就认为这是一个页错误(Page Fault)。 CPU 将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。操作系统将查询这个数据结构,然后找到空页面所在的 VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还给进程,进程从刚才页错误的位置重新开始执行。 随着进程的执行,页错误会不断的产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求,如下图所示:缺页本身是一种中断,与一般的中断一样,需要经过 4 个处理步骤:  1. 保护 CPU 现场。  1. 分析中断原因。    1. 转入缺页中断处理程序进行处理。    1. 恢复 CPU 现场,继续执行。 页面错误会降低系统性能并可能导致抖动,程序或操作系统的性能优化通常涉及减少页面错误的数量。优化的两个主要重点是减少整体内存使用量和改进内存局部性。为了减少页面错误,开发人员必须使用适当的页面替换算法来最大化页面命中率。 进程虚存空间分布 ELF 文件的装载 ELF 文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍,如果不是,那么多余部分也将占用一个页。一个 ELF 文件中往往有十几个段,那么内存空间的浪费是可想而知的。而操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读、可写、可执行):以代码段为代表的权限为可读可执行的段。 以数据段和 BSS 段为代表的权限为可读可写的段。 以只读数据段为代表的权限为只读的段。相同权限的段可合井到一起当作一个段进行映射。 比如有两个段分別叫 .text 和 .init,它们包含的分别是程序的可执行代码和初始化代码,并且它们的权限相同,都是可读并且可执行的。假设 .text 为 4097 字节,.init 为 512 字节,这两个段分别映射的话就要占用三个页面,但是如果将它们合并成一起映射的话只须占用两个页面,如下图所示:ELF 可执行文件引入了一个概念叫做 Segment,一个 Segment 包含一个或多个 Section,如果将 .text 段和 .init 段合并在一起看作是一个 Segment ,那么装载的时候就可以将它们看作一个整体一起映射,也就是说映射以后在进程虚存空间中只有一个相对应的 VMA,而不是两个,这样做的好处是可以减少页面内部碎片,节省了内存空间。 Segment 的概念实际上是从装载的角度重新划分了 ELF 的各个段。 Segment 和 Section 是从不同的角度来划分同一个 ELF 文件。这个在 ELF 中被称为不同的视图 View:从Section 的角度来看 ELF 文件就是链接视图 LinkingView 从 Segment 的角度来看就是执行视图 ExecutionView。当我们在谈到 ELF 装载时,段专门指 Segment,而在其他的情况下,段指的是 Section,ELF 可执行文件与进程虚拟空间映射关系如下图所示:ELF 可执行文件中有一个专门的数据结构叫做程序头表**(Program Header Table)**用来保存 Segment 的信息。因为 ELF 目标文件不需要被装载,所以它没有程序头表,而 ELF 的可执行文件和共享库文件都有。它的结构体以及各个成员的含义如下: typedef struct { Elf32_Word p_type; // 类型,基本上我们在这里只关注 LOAD 类型的 Segment Elf32_Off p_offset; // Segment 在文件中的偏移 Elf32_Addr p_vaddr; // Segment 第一个字节进程虚拟地址空间的起始位置 Elf32_Addr p_paddr; // Segment 的物理装载地址 Elf32_Word p_filesz;// 在 ELF 文件中所占空间的长度 Elf32_Word p_memsz; // Segment 在进程虚拟地址空间中所占用的长度 Elf32_Word p_flags; // Segment 权限属性(可读 R、可写 W、可执行 X) Elf32_Word p_align; // Segment 对齐属性(2 的 p _align 次方字节) } Elf32_Phdr堆和栈 在操作系统里面,VMA 除了被用来映射可执行文件中的各个 Segment ,还使用 VMA 来对进程的地址空间进行管理。我们知道进程在执行的时候它还需要用到堆和栈等空间,事实上它们在进程的虚拟空间中的表现也是以 VMA 的形式存在的,很多情况下,一个进程中的堆和栈分别都有一个对应的 VMA。 Linux 下,我们可以通过查看 /proc 来查看进程的虚拟空间分布:cat /proc/21963/maps 08048000-080b9000 r-xp 00000000 08:01 2801887 ./SectionMapping.elf 080b9000-080bb000 rwxp 00070000 08:01 2801887 ./SectionMapping.elf 080bb000-080de000 rwxp 080bb000 00:00 0 [heap] bf7ec000-bf802000 rw-p bf7ec000 00:00 0 [stack] ffffe000-fffff000 r-xp 00000000 00:00 0 [vdso]第一列是 VMA 的地址范围。 第二列是 VMA 的权限,r 表示可读,w 表示可写,x 表示可执行,p 表示私有 (COW, Copy on Write) ,s 表示共享。 第三列是偏移, 表示 VMA 对应的 Segment 在映像文件中的偏移。 第四列表示映像文件所在设备的主设备号和次设备号。 第五列表示映像文件的节点号。 最后一列是映像文件的路径。操作系统通过给进程空间划分出一个个 VMA 来管理进程的虚拟空间,基本原则是将相同权限属性的、有相同映像文件的映射成一个 VMA,一个进程基本上可以分为如下几种 VMA 区域:代码 VMA,权限只读,可执行,有映像文件。 数据 VMA,权限可读写,可执行,有映像文件。 堆 VMA,权限可读写,可执行,无映像文件,匿名,可向上扩展。 栈 VMA,权限可读写,不可执行,无映像文件,匿名,可向下扩展。常见进程的虚拟空间如下图所示:堆的最大的申请数量也就是 malloc 的最大申请数量会受到哪些因素的影响呢?具体的数值会受到操作系统版本,程序本身大小,用到的动态共享库数量、大小,程序栈数量、大小等。 有可能每次运行的结果都会不同,因为有些操作系统使用了一种叫做随机地址空间分布的技术 (主要是出于安全考虑, 防止程序受恶意攻击) ,使得进程的堆空间变小。ASLR 在计算机科学中,地址空间配置随机加载称为 ASLR,又称地址空间配置随机化或地址空间布局随机化,是一种防范内存损坏漏洞被利用的计算机安全技术,通过随机放置进程关键数据区域的地址空间来防止攻击者跳转到内存特定位置来利用函数。 Linux 已在内核版本 2.6.12 中添加 ASLR。 Apple 在 Mac OS X Leopard 10.5 中某些库导入了随机地址偏移,但其实现并没有提供 ASLR 所定义的完整保护能力。而 Mac OS X Lion 10.7 则对所有的应用程序均提供了 ASLR 支持。 Apple 在 iOS 4.3 内导入了 ASLR。 段地址对齐 可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的。在映射过程中,页是映射的最小单位。 假设我们有一个 ELF 可执行文件,它有三个段需要装载,我们将它们命名为 SEG0、SEG1 和 SEG2。每个段的长度、在文件中的偏移如表所示:段 长度 (字节) 偏移 (字节) 权限SEG0 127 34 可读可执行SEG1 9899 164 可读可写SEG2 1988只读这属于大多常见的情况,就是每个段的长度都不是页长度的整数倍,一种最简单的映射办法就是每个段分开映射,对于长度不足一个页的部分则占一个页。通常 ELF 可执行文件的起始虚拟地址为 0x08048000,所以这三个段的虚拟地址和长度如表所示:段 起始虚拟地址 大小 有效字节 偏移 权限SEG0 0x08048000 0x1000 127 34 可读可执行SEG1 0x08049000 0x3000 9899 164 可读可写SEG2 0x0804C000 0x1000 1988只读 三个段的总长度只有 12014 字节,却占据了 5 个页,即 20480 字节,空间使用率只有 58. 6 %。导致文件段的内部会有很多碎片,浪费磁盘空间。为了解决这种问题,就是让那些各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次,如下图:这样映射的话,不仅进程中的某一段区域就是整个ELF 的映像,对于一些须访问 ELF 文件头的操作可以直接通过读写内存地址空间进行,而且内存空间得到了充分的利用,ELF 文件的映射方式如下表所示:段 起始虚拟地址 大小 偏移 权限SEG0 0x08048022 127 34 可读可执行SEG1 0x080490A4 9899 164 可读可写SEG2 0x0804C74F 1988可读可写进程栈初始化 操作系统在进程启动前将系统环境变量和进程的运行参数等提前保存到进程的虚拟空间的栈中,进程启动后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给 main 函数,并通过函数的两个参数 argc 和 argv,传递命令行参数数量和命令行参数字符串指针数组。 Linux 内核装载 ELF 过程简介 首先,用户层面,bash 进程会调用 fork 系统调用创建一个新的进程,然后新的进程调用 execve 系统调用执行指定的 ELF 文件,原先的 bash 进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。 execve 系统调用被定义在 unistd.h,它的原型如下: int execve(const char *filename,char *const argv[],char *const envp[]); 它的三个参数分别是被执行的程序文件名,执行参数和环境变量,相关函数执行顺序如下:在内核中execve 系统调用相应的入口是 sys_execve。 sys_execve 进行一些参数的检查复制之后,调用 do_execve。 do_execve 会首先查找被执行的文件,如果找到文件,则读取文件的前 128 个字节,判断文件的格式,每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头 4个字节,常常被称做魔数。 调用 search_binary_handle 通过判断文件头部的魔数确定文件的格式去搜索和匹配合适的可执行文件装载处理过程。 调用ELF 可执行文件的装载处理过程 load_elf_binary。 当 load_elf_binary 执行完毕,返回至 do_execve 再返回至 sys_execve 时已经把系统调用的返回地址改成了被装载的 ELF 程序的入口地址了。 load_elf_binary这个函数的代码比较长,它的主要步骤是:检查 ELF 可执行文件格式的有效性,比如魔数,程序头表中段的数量。寻找动态链接的 .interp 段,设置动态链接器路径。根据 ELF 可执行文件的程序头表的描述,对 ELF 文件进行映射,比如代码、数据、只读数据。初始化 ELF 进程环境,比如进程启动时 edx 寄存器的地址应该是 DT_FINI 的地址。将系统调用的返回地址修改成 ELF 可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的 ELF 可执行文件,这个程序入口就是 ELF 文件的文件头中 e_entry 所指的地址. 对于动态链接的 ELF 可执行文件,程序入口点是动态链接器。动态链接 静态链接的方式对于计算机内存和磁盘的空间浪费非常严重,作者讲了一个静态链接的例子,Program1 和Program2 分別包含 Program1.o 和 Program2.o 两个模块,并且它们还共用 Lib.o 这个模块,静态链接下,当同时运行 Program1 和Program2 时,Lib.o 在磁盘中和内存中都有两份副本,想象如果是静态链接的库,很多程序共用的情况下,那么将会有大量的内存空间被浪费。除此之外,如果是使用静态链接,假设 Lib.o 修改了一个 bug,那么 Program1 和 Program2 的厂家都需要拿到最新的 Lib.o,然后再与 Program1.o 或者 Program2.o 重新链接后,将最新的程序发布给用户,以至于每个小的改动,都会导致整个程序重新下载。动态链接的出现就是要解决空间浪费和更新困难这两个问题的。 把链接这个过程推迟到了运行时再进行,这就是动态链接的基本思想。工作的原理与静态链接类似,包括符号解析、地址重定位,回到上面的例子,如果改成动态链接,Lib.o 在磁盘和内存中只存在一份,这么做不仅仅减少内存的使用,还可以减少物理页面的换入换出,也可以增加 CPU 缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享的模块上。升级变得更加容易只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链接一遍。 除了上述优点外,动态链接还可以被拿来做插件,为程序增加动态的功能扩展,也可以通过动态链接库给程序和操作系统之间增加了一个中间层,消除程序对不同平台之间依赖的差异性,虽然有很多优点,动态链接也是存在着一些缺点的,例如某个模块更新后,会产生新的模块与旧的模块之间接口不兼容的问题,这个问题也经常被称为 DLL Hell 。 动态链接的例子 /*Program1.c */ #include "Lib.h"int main() { foobar(1); return 0; }/*Program2.c*/ #include "Lib.h"int main() { foobar(2); return 0; }/*Lib.c*/ #include <stdio.h> void foobar(int i) { printf("Printing from Lib.so %d\n",i); }/*Lib.h*/ #ifndef LIB_H #define LIB_H void foobar(int i); #endif两个程序的主要模块 Program1.c 和 Program2.c 分别调用了 Lib.c 里面的 foobar 函数。 使用 GCC 将 Lib.c 编译成一个共享对象文件:gcc - fPIC -shared -o Lib.so Lib.c 两个程序 Program1 和 Program2,这两个程序都使用了 Lib.so 里面的 foobar 函数 。 从 Program1 的 角度看 ,整个编译和链接过程如下图所示:上图的步骤中只有一个步骤与静态链接不一致,那就是 Program1.o 被链接成可执行文件的这一步,在静态链接中,会把 Program1.o 和 Lib.o 链接到一起,并且产生输出可执行文件 Program1,但是这里,Lib.o 没有被链接进来,链接的输入目标文件只有 Program1.o。 当链接器将 Program1.o 链接成可执行文件时,这时候链接器必须确定 Programl.o 中所引用的 foobar 函数的性质。如果 foobar 是一个定义于其他静态目标模块中的函数,那么链接器将会按照静态链接的规则,将 Programl.o 中的 foobar 地址引用重定位,如果 foobar 是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。 动态链接下,程序分为可执行文件和程序依赖的共享对象 Lib.so,Lib.so 中保存了完整的符号信息,通过将 Lib.so 作为链接的输入之一,就能够知道 foobar 的引用是一个静态符号还是一个动态符号。 地址无关代码 共享对象的最终地址在装载时确定,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。那么装载地址是怎么获取的呢?在早期,有种做法叫静态共享库(将程序的各种模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的空间)。 这种做法现在已经被淘汰了,之所以被淘汰,主要原因就是升级时,必须保持共享库中全局函数和变量地址的不变,如果应用程序在链接时己经绑定了这些地址,一但更改就必须重新链接应用程序。 为了能够使共享对象在任意地址装载,基本思路是在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。假设函数 foobar 相对于代码段的起始地址是 0x100,当模块被装载到 0x10000000 时,我们假设代码段位于模块的最开始,即代码段的装载地址也是 0x10000000,那么我们就可以确定 foobar 的地址为 0x10000100。这时系统遍历模块中的重定位表,把所有对 foobar 的地址引用都重定位至0x10000100,这种装载时重定位义被叫做基址重置 Rebasing。 装载时重定位是解决动态模块中有绝对地址引用的方法之一,但是指令部分无法再多个进程之间共享,就失去了节省内存的优势。我们希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据放在一起,这样指令部分就可以保持不变,而数据部分可以在每一个进程中拥有一个副本。这种方案称之为地址无关代码 PIC 技术。 共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用。模块外部引用按照不同的引用方式又可以分成两类:指令引用。数据访问。于是我们就得到了 4 中情况:第一种是模块内部的函数调用、跳转。 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。 第三种是模块外部的函数调用、跳转。 第四种是模块外部的数据访问,比如其他模块中定义的全局变量。示例代码如下: static int a; extern int b; extern void ext();void bar() { a = 1; b = 2; } void foo() { bar(); ext(); }类型一 模块内部调用或跳转 例如上面例子中 foo 对 bar 的调用,属于模块内部的调用,会产生如下代码: 8048344 <bar>: 8048344: 55 push %ebp .... 8048349 <foo>: 8048357: e8 e8 ff ff ff call 8048344<bar> 804835C: ...对于模块内部调用,因为被调用的函数和调用者在同一个模块,他们之间的相对位置是固定的。模块内部的跳转和函数调用都可以是相对地址调用,或者基于寄存器的相对调用,这些指令是不需要重定位的。 0xFFFFFFE8 是 -24 的补码形式 bar 的地址为 0x804835c + (-24) = 0x8048344 类型二 模块内部数据访问 例如上面例子中 bar 访问内部变量 a,属于模块内部的数据访问,会产生如下代码: 0000044c <bar>: ..... 44f: e8 40 00 00 00 call 494<__i686.get_pc_thunk.cx> 454: 81 c1 8c 11 00 00 add $0x118c,%ecx 45a: c7 81 28 00 00 00 01 movl %0x1,0x28(%ecx) 461: 00 00 00 ..... 494: <__i686.get_pc_thunk.cx>: 494: 8b 0c 24 mov (%esp),%ecx 497: c3 ret一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了,现代的体系结构中,数据的相对寻址往往没有相对与当前指令地址 PC 的寻址方式,所以 ELF 用了一个很巧妙的办法来得到当前的 PC 值。 __i686.get_pc_thunk.cx 这个函数的作用就是把返回地址的值放到 ecx 寄存器,即把 call 的下一条指令的地址放到 ecx 寄存器。 变量 a 的地址,是 add 指令地址加上两个偏移量 0x118c 和 0x28,即如果模块被装载到 0x10000000 这个地 址的话,变量 a 的实际地址将是 0x10000000 + 0x454 + 0x118c + 0x28 = 0x10001608 例外: 对于全局变量来说,无论是在模块内部还是模块外部,都只能使用 GOT 的方式来访问,因为编译器无法确定对全局变量的引用是跨模块的还是模块内部的,关于 GOT 下面会介绍。 类型三 模块间数据访问 例如上面例子中 bar 访问内部变量 b,属于模块间的数据访问,会产生如下代码: 0000044c <bar>: ..... 44f: e8 40 00 00 00 call 494 <__i686.get_pc_thunk.cx> 454: 81 c1 8c 11 00 00 add $0x118c,%ecx //%ecx=0x454 + 0x118C,GOT表地址 45a: c7 81 28 00 00 00 01 movl $0x1,0x28(%ecx) //a = 1 461: 00 00 00 464: 8b 81 f8 ff ff ff mov 0xfffffff8(%ecx),%eax 46a: c7 00 02 00 00 00 movl $0x2,(%eax) //b = 2 ..... 494: <__i686.get_pc_thunk.cx>: 494: 8b 0c 24 mov (%esp),%ecx 497: c3 retELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表 (Global Offset Table,GOT) ,当代码需要引用该全局变量时,可以通过 GOT 中相对应的项间接引用,基本机制如下图:当指令中需要访问变量 b 时,程序会先找到 GOT,然后根据 GOT 中变量所对应的项找到变量的目标地址。每个变量都对应一个 4 个字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充 GOT 中的各个项,以确保每个指针所指向的地址正确。由于 GOT 本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。 GOT 如何做到指令的地址无关性: 模块在编译时可以确定模块内部变量相对与当前指令的偏移,那么我们也可以在编译时确定 GOT 相对于当前指令的偏移。确定 GOT 的位置跟上面的访问变量 a 的方法基本一样,通过得到 PC 值然后加上一个偏移量,就可以得到 GOT 的位置,然后我们根据变量地址在 GOT 中的偏移就可以得到变量的地址。 我们的程序首先计算出变最 b 的地址在 GOT 中的位置,即 0x10000000 + 0x454 + 0x118c + (-8) = 0x100015d8 ,(0xfffffff8 为 -8 的补码表示,也就是 在 GOT 中偏移 8),然后使用寄存器间接寻址方式给变最 b 赋值 2。 这边解释下寄存器的直接寻址和间接寻址: 寄存器寻址:指令所要的操作数已存储在某寄存器中,或把目标操作数存入寄存器。 寄存器间接寻址:寄存器内存放的是操作数的地址,而不是操作数本身,即操作数是通过寄存器间接得到的。 因为上面我们在 GOT 拿到的是变量 b 在外部模块的地址,所以更改变量 b 的值的过程是通过间接寻址来做的。 类型四 模块间调用、跳转 例如上面例子中 foo 对 ext 的调用 ,属于模块间的函数调用,会产生如下代码: call 494 <__i686.get_pc_thunk.cx> add $0x118c,%ecx //%ecx=0x454 + 0x118C,GOT表地址 mov 0xfffffffc(%ecx),%eax call *(%eax) ..... 494: <__i686.get_pc_thunk.cx>: 494: 8b 0c 24 mov (%esp),%ecx 497: c3 ret模块需要调用目标函数时,可以通过 GOT 中的项进行间接跳转,调用 ext 函数的方法与上面访问变量 b 的方法基本类似,先得到当前指令地址 PC,然后加上一个偏移得到函数地址在 GOT 中的偏移,然后一个间接调用,如下图:4 种地址引用方式在理论上都实现了地址无关性,总结如下:指令跳转、调用 数据访问模块内部 (1)相对跳转和调用 (2)相对地址访问模块外部 (3)间接跳转和调用(GOT) (4)直接访问(GOT)共享模块的全局变量问题 当一个模块引用了一个定义在共享对象的全局变量的时候,比如一个共享对象定义了一个全局变量 global,下面这块代码我们将它定义在 module.c 中,当编译时它无法根据这个上下文判断 global 是定义在同一个模块的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。 extern int global; int foo() { global = 1; }假设 module.c 是程序可执行文件的一部分,由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来。为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的 bss 段创建一个 global 变量的副本,然而由于 global 是定义在共享对象中的,那么这个 global 变量会同时存在于多个位置中,这显然是不行的。 解决的办法那就是所有的使用这个变量的指令都指向位于可执行文件中的那个副本。ELF 共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,通过 GOT 来实现变最的访问。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把 GOT 中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变最在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本,如果该全局变量在程序主模块中没有副本,那么 GOT 中的相应地址就指向模块内部的该变量副本。假设 module.c 是一个共享对象的一部分,那么 GCC 编译器在 -fPIC 的情况下,就会把对 global 的调用按照跨模块模式产生代码。原因是编译器无法确定对 global 的引用是跨模块的还是模块内部的。即使是模块内部的,还是会产生跨模块代码,因为 global 可能被可执行文件引用,从而使得共享模块中对 global 的引用要执行可执行文件中的 global 副本。 数据段地址无关性 static int a; static int *p = &a;如果某个共享对象里面有这样一段代码的话,那么指针 p 的地址就是一个绝对地址,它指向变量 a,而变量 a 的地址会随着共享对象的装载地址改变而改变。对此,我们可以选择装载时重定位的方法来解决数据段中绝对地址引用问题,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,表中包含重定位的入口,当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。 那问题来了,为什么数据段可以采用装载时重定位,而代码段不可以呢? 原因其实很简单,因为对于数据段来说,它在每个进程都有一份独立的副本,所以并不担心被进程改变,而代码段则没有独立的副本,如果让代码段也使用这种装载时重定位的方法,而不使用地址无关代码的话,它就不能被多个进程之间共享,于是也就失去了节省内存的优点。 如果可执行文件是动态链接的,那么 GCC 会使用 PIC 的方法来产生可执行文件的代码段部分,以便于不同的进程能够共享代码段,节省内存。 延迟绑定 PLT 首先我们需要认清一个问题,那就是动态链接比静态链接要慢,还会减慢程序的启动速度,主要原因是:动态链接下对于全局和静态的数据访问都要进行复杂的 GOT 定位,然后间接寻址。对于模块间的调用也要先定位 GOT,然后再进行间接跳转。动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都要进行一次链接工作(动态链接器会寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作等)。在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,如果一开始就把所有函数都链接好实际上是一种浪费。所以 ELF 采用了一种叫做延迟绑定的做法,基本的思想就是当函数第一次被用到时才进行绑定 (符号查找、重定位等),这样可以加快程序的启动速度。ELF 使用 PLT (Procedure Linkage Table) 的方法来实现。 例如 liba.so 需要调用 libc.so 中的 bar 函数,第一次调用时首先会需要调用动态链接器中的某个函数来完成地址绑定工作,这个函数的名字是 _dl_runtime_resolve具体过程如下(解析符号仅执行一次): bar@plt jmp *(bar@GOT) push moduleID jump _dl_runtime_resolve调用函数并不直接通过 GOT 跳转,而是通过一个叫作 PLT 项的结构来进行跳转,bar 两数在 PLT 中的项的地址我们称之为 bar@plt。 bar@plt 指令通过 GOT 进行间接跳转指令,bar@GOT 表示 GOT 中保存 bar 这个函数相应的项。 如果链接器初始化阶段并未将 bar 的地址填入该项,而是将 push n (n 为 bar 这个符号引用在重定位表 .rel.plt 中的下标)的地址填入到 bar@GOT 中。。 接着又是一条 push 指令将模块的 ID 压入到堆栈,然后跳转到 _dl_runtime_resolve。 _dl_runtime_resolve 函数来完成符号解析和重定位工作。 _dl_runtime_resolve 在进行一系列工作以后将 bar 的真正地址填入到 bar@GOT。 bar 这个函数被解析完,当我们再次调用 bar@plt 时,第一条 jmp指令就能够跳转到真正的 bar 函数中。ELF 将 GOT 拆分成了两个表叫做 got 和 got.plt 。其中 got 用来保存全局变量引用的地址,.got.plt 用来保存函数引用的地址,所有对于外部函数的引用全部被分离出来放到了 got.plt 中,got.plt 还有一个特殊的地方是它的前三项:第一项保存的是 .dynamic 段的地址,描述了本模块动态链接相关的信息。 第二项是本模块的 ID(在动态链接器在装载共享模块的时候初始化)。 第三项是保存的 _dl_runtime_resolve 的地址(在动态链接器在装载共享模块的时候初始化)。 .got.plt 的其余项分别对应每个外部函数的引用,整体结构如下图。动态链接相关结构 动态链接步骤:在动态链接情况下,操作系统会先加载一个动态链接器(实际上是一个共享对象)。 加载完成后就将控制权交给动态链接器的入口地址( 与可执行文件一样,共享对象也有入口地址)。 当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作。 根据当前的环境参数,开始对可执行文件进行动态链接工作。 当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。下面开始介绍一些动态链接中比较重要的段。 .interp 段 动态链接器的位置不是由系统配置指定的,也不是由环境变量决定的,而是由 ELF 可执行文件的 .interp 段指定的。里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径。 .dynamic 段 .dynamic 段保存了动态链接所需要的基本信息(依赖于哪些共享对象、动态键接符号表的位置、动态链接重定位表的 位置、共享对象初始化代码的地址等)。也是动态链接 ELF 中最重要的结构。.dynamic 段里面保存的信息有点像 ELF 文件头,只是我们前面看到的 ELF 文件头中保存的是静态链接时相关的内容,比如静态链接时用到的符号表、重定位表等,这里换成了动态链接下所使用的相应信息,具体结构如下: typedef struct { Elf32_Sword d_tag; union { Elf32_Word d_val; Elf32_Addr d_ptr; } d_un; } Elf32_Dyn;我们这里列举几个比较常见的类型值,如下表:d_tag 类型 d_un 的含义DT_SYMTAB 动态链接符号表的地址,d_ptr 表示 .dynsym 的地址DT_STRTAB 动态链接字符串表地址,d_ptr 表示 .dynstr 的地址DT_STRSZ 动态链接字符串表大小,d_val 表示大小DT_HASH 动态链接哈希表地址,d_ptr 表示 .hash 的地址DT_SONAME 本共享对象的 SO_NAMEDT_RPATH 动态链接共享对象搜索路径DT_INIT 初始化代码地址DT_FINIT 结束代码地址DT_NEED 依赖的共享对象文件,d_ptr 表示所依赖的共享对象文件名DT_REL 动态链接重定位表地址DT_RELA 动态链接重定位表地址DT_RELENT 动态重读位表入口数量DT_RELAENT 动态重读位表入口数量动态符号表 ELF 为了表示动态链接的模块之间的符号导入导出关系,使用了 .dynsym 段,也称为动态符号表,用来保存这些符号的信息,动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表 .dynstr,为了加快符号的查找过程,往往还有辅助的符号哈希表 .hash。 动态链接重定位相关结构 对于动态链接来说,共享对象不是以 PIC 模式编译的,那么它需要在装载时被重定位的。 共享对象是以 PIC 模式编译的,也需要重定位,因为数据段还包含了绝对地址的引用。装载时的重定位和静态链接中的重定位区别 时机不同 重定位表共享对象的重定位 装载时 .rel.text 和 .rel.data静态链接的目标文件的重定位 链接时 .rel.dyn 和 .rel.plt.rel.dyn 实际上是对数据引用的修正,它所修正的位置位于 .got 以及数据段,而 .rel.plt 是对函数引用的修正,它所修正的位置位于 .got.plt .got.plt 的前三项是被系统占据的,从第四项开始才是真正存放函数地址的地方。 而第四项刚好是 0x000015c8 + 4* 3 = 0x000015d4 即 __gmon_start__,第五项是 printf,第六项是 sleep,第七项是 __cxa_finalize,结构如下图所示:当动态链接器需要进行重定位时 ,它先查找 printf 的地址,printf 位于 libc-2.6.1.so。 假设链接器在全局符号表里面找到 printf 的地址为 0x08801234,那么链接器就会将这个地址填入到 .got.plt 中的偏移为0x000015d8 的位置中去,从而实现了地址的重定位,即实现了动态链接最关键的一个步骤。 稍微麻烦点的是,共享对象的数据段是没有办法做到地址无关的,它可能会包含绝对地址的用,对于这种绝对地址的引用,我们必须在装载时将其重定位。 例如上面的这段代码 static int a; static int *p = &a;在编译时, 共享对象的地址是从 0 开始的,我们假设该静态变量 a 相对于起始地址 0 的偏移为 B,即 p 的值为 B。一旦共享对象被装载到地址 A,那么实际上该变量 a 的地址为 A+B。ELF 文件的编译方式 外部函数的重定位入口的位置PIC 方式 .rel.plt共享对象方式 .rel.dyn动态链接时进程堆栈初始化信息 操作系统通过进程的堆栈传递给动态链接器可执行文件和本进程的一些信息,堆栈里面保存了关于进程执行环境和命令行参数等信息。事实上,堆栈里面还保存了动态链接器所需要的一些辅助信息数组。 typedef struct { uint32_t a_type; union { uint_32_t a_val; } a_un; } Elf32_auxv_t;结构与前面的 .dynamic 段里面的结构如出一辙,32 位的类型值,常见的类型如下:a_type 定义 a_type 值 a_val 的含义AT_NULL 0 表示辅助信息数组结束AT_EXEFD 2 表示可执行文件的句柄AT_PHDR 3 可执行文件中程序头表AT_PHENT 4 可执行文件中程序头表中每一个入口(Entry)的大小AT_PHNUM 5 可执行文件中程序头表中入口(Entry)的数量AT_BASE 7 表示动态链接器本身的装载地址AT_ENTRY 9 可执行文件入口地址,即启动地址它们在进程堆栈位于环境变量指针的后面:动态链接的步骤和实现 动态链接的步骤基本上分为 3 步:启动动态链接器本身。 装载所有需要的共享对象。 重定位和初始化。Bootstrap 动态链接器本身不可以依赖于其他任何共享对象,其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。这种具有一定限制条件的启动代码往往被称为自举 (Bootstrap)。 动态链接器入口地址即自举代码的入口,自举代码首先会找到他自己的 GOT。而 GOT 的第一个入口是 .dynamic 段的偏移地址,通过 .dynamic 中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始动态链接器代码才可以使用自己的全局变量和静态变量。动态链接器实际上使用 PIC 模式编译的共享对象,对于模块内部的函数调用也是采用跟模块外部函数调用一样的方式,即使用 GOT/PLT 的方式,所以在 GOT/PLT 没有被重定位之前,自举代码不可以使用任何全局变量,也不可以调用函数。 装载共享对象 完成自举后,动态链接器将可执行文件和链接器本身的符号都合并到全局符号表,然后链接器通过 .dynamic 段找到可执行文件依赖的所有共享对象,并将这些对象放入一个装载集合中,然后把这些对象映射到进程中,如果这些共享对象还依赖其他共享对象,那么将所依赖的共T享对放到装载集合中。如此反复,直到所有依赖的共享对象都被装载进来。装载时符号的优先级是按照加入全局符号表的先后来排序的,当一个符号需要被加入全局符号表时,如果相同的符号名己经存在,则后加入的符号被忽路。 **小 Tip: **为了提高模块内部函数调用的效率,可使用 static 定义函数编译单元私有函数,就可以使用模块内部调用指令,可以加快函数的调用速度,前提是编译器要确保函数不被其他模块覆盖。 重定位和初始化 当上面的步骤完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表, 将它们的 GOT/PLT 中的每个需要重定位的位置进行修正。因为此时动态链接器己经拥有了进程的全局符号表。重定位完成后如果某个共享对象有 .init 段,那么动态链接器会执行 .init 段中的代码,用以实现动态共享对象特有的初始化过程,相应地,共享对象中还可能有 .finit 段, 当进程退出时会执行 .finit 段中的代码。 Linux动态链接器实现 对于静态链接的可执行文件来说,程序的入口就是 ELF 文件头里面的 e_entry 指定的入口。 对于动态链接的可执行文件来说,内核会分析它的动态链接器地址,将动态链接器映射至进程地址空间,然后把控制权交给动态链接器。 关于动态链接器有个值得思考的问题:动态链接器本身是动态链接的还是静态链接的? 动态链接器本身应该是静态链接的,它不能依赖于其他共享对象。动态链接器本身必须是 PIC 的吗? 动态链接器可以是 PIC 的也可以不是,但往往使用 PIC 会更加简单一些。原因如下:不是 PIC 的动态链接器,代码段无法共享,浪费内存。不是 PIC 的动态链接器本身初始化会更加复杂,因为自举时还需要对代码段进行重定位 。动态链接器可以被当作可执行文件运行,那么的装载地址应该是多少? 动态链接器作为一个共享库,内核在装载它时会为其选择一个合适的装载地址。显式运行时链接(运行时加载) 一般支持动态链接的系统,都支持程序的运行时加载,也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。满足运行时装载的共享对象往往被叫做动态装载库。 动态装载库的好处如下:使得程序的模块组织变得很灵活,可以用来实现一些诸如插件、驱动等功能。 不需要从一开始就将他们全部装载进来,从而减少了程序启动时间和内存使用。 可以在运行的时候重新加载某个模块,程序本身不必重新启动就可以实现模块的增加、删除、更新等, 这对于很多需要长期运行的程序来说是很大的优势。动态库和一般的共享对象主要区别是: 共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都由动态链接器自动完成,对于程序本身是透明的。 动态库的装载则是通过一系列由动态链接器提供的 API: dlopen、dlsym、 dlerror 、dlclose 进行操作。 dlopen 这个函数用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程。函数的原型如下: void * dlopen(const char *filename, int flag);第一个参数 filename 是动态库的路径,路径可能是绝对路径也可能是相对路径,不同的路径存在不同的加载顺序。 如果将 filename 设置为 0 的话,dlopen 返回的是全局符号表的句柄,也就是说我们可以在运行时找到全局符号表里面的任何一个符号,并且可以执行它们。 第二个参数 flag 表示函数符号的解析方式,可以是 PLT 方式(也就是延迟绑定的机制),也可以是加载时即完 成所有的函数的绑定工作,两种方式必须二选其一。 函数的返回值是被加载的模块的句柄,这个向柄在 dlsym 或者 dlclose 时需要用到。 此外 dlopen 中还会执行模块中初始化部分的代码。 dlsym 这个函数是运行时装载的核心部分,我们可以通过这个函数找到所需要的符号。函数的原型如下: void * dlsym(void *handle, char *symbol);第一个参数是由 dlopen 返回的动态库的句柄 第二个参数即所要查找的符号的名字 如果 dlsym 找到了相应的符号,则返回该符号的值,没有找到相应的符号则返回 NULL。 符号的优先级 是当多个同名符号冲突时,先装入的符号优先,我们把这种优先级方式称为装载序列,由动态链接器装入和由 dlopen 装入的共享对象,动态链接器在进行符号的解析以及重定位时,都是采用装载序列,然而使用 dlsym 进行查找时,优先级却分两种类型:如果我们是在全局符号表中进行符号查找,那么由于全局符号表使用的是装载序列,所以 dlsym 使用的也是装载序列。 如果我们是对某个通过 dlopen 打开的共享对象进行符号查找的话,那么采用的是一种叫做依赖序列的优先级。它是以被 dlopen 打开的那个共享对象为根节点,对它所有依赖的共享对象进行广度优先遍历,直到找到符号为止。dlerror 监听 dlopen dlsym dlclose 是否成功执行,如果返回 NULL,则表示上一次调用成功,如果不是则返回相应的错误消息。 dlclose 函数作用与 dlopen 相反,系统对于已经加载的模块会存在一个计数,当计数为 0 时,会对模块进行卸载,之后执行模块的 .finit 段的代码,然后将相应的符号从符号表中去除,取消进程空间跟模块的映射关系,然后关闭模块文件。 dyld 关于 dyld (The dynamic link editor) 网上介绍的博客非常多,这里简单提一下,感兴趣的可以看下源码。 它是 Apple 的动态链接器, Mach-O 可执行文件会交由 dyld 负责链接 ,装载。目前发展了好几个版本:dyld 1.0 1996–2004) dyld 2.0 (2004–2007) dyld 2.x (2007–2017) dyld 3.0 (2017) dyld 4.0 (2022)下面是针对内参中介绍 dyld 各个版本的简单整理,从版本的差异中,也能看出苹果对于动态链接的过程的一个优化历程。是在大多数系统使用大型 C++ 动态库之前编写的,导致动态链接器必须做很多工作,而且速度非常慢。首先 dyld 1.0 使用了预绑定的技术预绑定是一种技术,我们试图为系统和应用程序中的每个 dylib 找到固定地址。动态加载器会尝试加载这些地址的所有内容,如果它成功了,它会编辑所有这些二进制文件,让这些预先计算的地址在里面。然后下一次当它把它们放到相同的地址时,它不需要做任何额外的工作。dyld 2.0 是对 dyld 1.0 的完全重写稍微扩展了Mach-o 格式并更新了 dyld 以便我们可以获得高效的 C++ 库支持。 它还具有完整的 dlopen 和 dlsym 实现以及正确的语义。 2.0 存在一些安全问题,因为它是为速度而设计的,所以它的健全性检查有限。 由于启动速度的提升,因此减少了预绑定的工作量。dyld 2.x 做了很多显著的改进,在程序进程内执行。添加了大量的架构和平台。增加安全性codeSigning 代码签名。 增加了 ASLR 机制。 对 Mach-o 标头中的许多内容添加了边界检查,这样就无法对格式错误的二进制文件执行某些类型的附加操作。摆脱预绑定并用称为共享缓存(share cache)的东西取而代之,合并了大部分系统动态库,并进行了优化:重新排列二进制文件以提高加载速度。 预链接动态库。 预构建 dyld 和 ObjC 使用的数据结构。dyld 3 出 3.0 版本主要是为了性能、可测试性、安全等方面考虑的。将大部分 dyld 移出进程,增加了可测试性;留在进程中的 dyld 位尽可能小,从而减少应用程序中的攻击面。移出进程的方式通过: 确定安全敏感组件。 确定它的昂贵部分,它们是可缓存的,这些是符号查找。大多数启动使用缓存,永远不必调用进程外的 Mach-o 解析器或编译器,而是简单地验证它们,增加启动速度,缓存步骤: 进程外的 Mach-o 解析器,解析所有搜索路径、所有@rpaths、所有可能影响您的启动的环境变量,解析 Mach-o 二进制文件,执行所有这些符号查找,用结果创建闭包。 进程内引擎,它验证启动闭包是正确的,然后它只是映射到 dylibs,并跳转到 main。 启动关闭缓存,系统应用程序关闭我们只是直接构建到共享缓存。dyld 4 目标是通过保持相同的 Mach-o 解析器来改进 dyld3,支持不需要预构建闭包的即时加载,也就是 Prebuilt + JustInTime 的双解析模式。新的抽象基类Loader,为进程中加载的每个 Mach-o 文件实例化一个 Loader 对象,Loader 有两个具体的子类 PrebuiltLoader 和 JustInTimeLoader。 PrebuiltLoader 只读的。它包含有关其 Mach-o 文件的预先计算的信息,包括其路径、验证信息、其依赖的 dylib 和一组预先计算的绑定目标。在启动时,dyld 会为程序寻找预构建的 PrebuiltLoader,验证完有效,则使用它。 如果没有有效的 PrebuiltLoader,那么创建并使用新的 JustInTimeLoader,JustInTimeLoader 然后通过解析 Mach-o 找到它的依赖项,进行实时的解析。总结程序可通过覆盖装入和页映射的两种模式,被操作系统装载到内存中运行,目前几乎所有的主流操作系统都是按页映射的方式装载可执行文件的,页映射的时候,段地址对齐处理不当会造成空间的浪费。 进程建立,首先创建一个独立的虚拟地址空间,然后建立起可执行文件和进程虚存之间的映射结构,设置可执行文件的入口,执行程序。随着程序的执行,会不断的产生页错误,操作系统会通过映射结构为进程分配相应的物理页面来满足进程执行的需求。 在 ELF 文件中使用 Program Header Table 保存 Segment 的信息,操作系统使用 VMA 来映射可执行文件中的各个 Segment,另外堆和栈等空间也是以 VMA 的形式存在的,除此之外还有称为 vdso 的 VMA 可与系统内核进行通信。 Linux 内核装载 ELF 时,首先会检查 ELF 可执行文件格式的有效性,再者设置动态链接器路径,对 ELF 文件进行映射,再初始化 ELF 进程环境,最后系统调用的返回地址修改成 ELF 可执行文件的入口。 由于静态链接对于计算机内存和磁盘的空间浪费非常严重,于是开始使用动态链接,把链接这个过程推迟到了运行时再进行,这就是动态链接的基本思想。 共享对象模块的访问根据模块所属内部或外部,指令调用或数据访问,一共分成了四种地址引用情况,针对这四种情况,都可实现了地址无关性,访问全局变量默认使用 GOT 的方式,数据段的绝对地址引用,装载时进行重定位,此外 ELF 还会使用 PLT 延迟绑定的方式,也就是一次被用到时才进行绑定。 动态链接中存在一些比较重要的段,.interp 段,.dynamic 段,动态符号表,以及一些动态链接重定位相关结构,除此之外操作系统通过进程的堆栈传递给动态链接器可执行文件和本进程的一些信息。 动态链接的步骤基本上分为 3 步,启动动态链接器本身,装载所有需要的共享对象,重定位和初始化。 满足运行时装载的共享对象往往被叫做动态装载库,它的装载是通过一系列由动态链接器提供的 API: dlopen、dlsym、 dlerror 、dlclose 进行操作。 dyld 是 Apple 的动态链接器, Mach-O 可执行文件会交由 dyld 负责链接 ,装载。已经从 1.0 版本发展到了 4.0 版本。

《程序员的自我修养(链接、装载与库)》学习笔记二(编译和链接)

继续学习《程序员的自我修养 - 链接、装载与库》的第二个大的部分,这一部分包含本书的二、三、四、五章节,作者深入浅出的给我们讲解了静态链接的相关的知识,干货满满,受益良多。 作为一名 iOS 开发人员,我们几乎每天都会用 Xcode 构建我们的程序,但是编译和链接的过程,我们却很少关注, Xcode 作为一种 IDE(集成开发环境)功能十分强大,它能够和 Mac OS 系统中其它的工具协作,例如编译器 gcc,它们提供的默认配置、编译和链接参数对于大部分的应用开发已经足够使用了,也正是由于这些集成工具的存在,我们也忽略了软件的运行机制与机理。 如果我们能深入的了解这些软件的运行机制,也许我们就能在解决问题的时候,多上一种思路,甚至是打破一些瓶颈。所以马上回到今天打算研究的部分,静态链接。大体来说,静态链接是由链接器在链接时将库的内容加入到可执行程序中的做法。具体分为四个部分来讲解:编译和链接,目标文件里有什么,静态链接,Windows PE/COFF,具体的知识点分布如下:还记得连载上一文提到的 Hello World 程序吗? #include <stdio.h>int main() { printf("Hello World\n"); return 0; }在 Linux 下执行,需要使用使用 gcc 来编译,首先通过 gcc hello.c,这会产生默认命名为 a.out 的可执行文件,然后通过 ./a.out 执行这个文件,输出 Hello World,事实上,这里要分为4个步骤:预处理,编译,汇编,链接。编译 在编译之前会有个预编译的过程,使用到的指令为:gcc -E hello.c hello.i 预编译主要做了如下的操作:处理 # 开头的指令,例如 #define 进行宏替换,#if、#ifdef,#elif,#else,#endif。 删除注释。 添加行号和文件名标识,用于编译时产生调试用的信息等。 保留 #pragma 编译器指令。 包含的头文件展开。预编译过后就是编译阶段,编译器就是将高级语言翻译成机器语言的一个工具,之所以使用高级语言,是因为它能使得程序员更加关注程序本身的逻辑,而不是计算机本身的限制(字长、内存大小、通信方式、存储方式等)。高级语言虽然提高了开发的效率,但是机器却无法识别,需要通过编译器,将其翻译成机器认识的语言,翻译的具体过程如下所示。具体的过程分为 6 步: 扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化,现代的 gcc 会将预编译和编译合并成一个步骤 cc1。 编译使用的指令:gcc -S hello.i hello.s gcc 这个命令只是一些后台程序的包装,它会根据不同的参数要求去调用预译编程序 cc1、汇编器 as、链接器 ld。 顺道我们回顾一下 Clang, Clang 是一个由 Apple 主动编写,是 LLVM 项目中的一个子项目。基于 LLVM 的轻量级编译器,之初是为了替代 GCC,提供更快的编译速度。他是负责编译 C、C++、OC 语言的编译器。 测试代码 CompilerExpression.c 如下: void test() { int index = 1; int array[3]; array[index] = (index + 4) * (2 + 6); }词法分析 CompilerExpression.c 源代码输入到扫描器,运用一种类似于有限状态机的算法将源代码的字符序列分割成一系列的记号,记号分为关键字、标识符、字面量(数字、字符串)、特殊符号(加号、等号)等。 同时会将标识符号放入符号表,将数字、字符串常量放到文字表,以备后续使用。 针对上面 CompilerExpression.c 里面的代码,我们可以使用 clang 进行词法分析:clang -fmodules -fsyntax-only -Xclang -dump-tokens CompilerExpression.c ,打印如下: void 'void' [StartOfLine] Loc=<CompilerExpression.c:1:1> identifier 'test' [LeadingSpace] Loc=<CompilerExpression.c:1:6> l_paren '(' Loc=<CompilerExpression.c:1:10> r_paren ')' Loc=<CompilerExpression.c:1:11> l_brace '{' [LeadingSpace] Loc=<CompilerExpression.c:1:13> int 'int' [StartOfLine] [LeadingSpace] Loc=<CompilerExpression.c:2:5> identifier 'index' [LeadingSpace] Loc=<CompilerExpression.c:2:9> equal '=' [LeadingSpace] Loc=<CompilerExpression.c:2:15> numeric_constant '1' [LeadingSpace] Loc=<CompilerExpression.c:2:17> semi ';' Loc=<CompilerExpression.c:2:18> int 'int' [StartOfLine] [LeadingSpace] Loc=<CompilerExpression.c:3:5> identifier 'array' [LeadingSpace] Loc=<CompilerExpression.c:3:9> l_square '[' Loc=<CompilerExpression.c:3:14> numeric_constant '3' Loc=<CompilerExpression.c:3:15> r_square ']' Loc=<CompilerExpression.c:3:16> semi ';' Loc=<CompilerExpression.c:3:17> identifier 'array' [StartOfLine] [LeadingSpace] Loc=<CompilerExpression.c:4:5> l_square '[' Loc=<CompilerExpression.c:4:10> identifier 'index' Loc=<CompilerExpression.c:4:11> r_square ']' Loc=<CompilerExpression.c:4:16> equal '=' [LeadingSpace] Loc=<CompilerExpression.c:4:18> l_paren '(' [LeadingSpace] Loc=<CompilerExpression.c:4:20> identifier 'index' Loc=<CompilerExpression.c:4:21> ...上文有说过,词法分析的结果会将源代码分解成一个个小的 Token,标明了所在的行数和列数。 语法分析 对词法分析的结果进行语法分析,生成语法树(以表达式为节点的树),复杂的语句就是很多表达式的组合,编译器的开发者仅仅需要改变语法规则,即可适配多种编程语言。 上面的代码中的语句,就是由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组合成的复杂语句,经过语法分析之后,就会形成下图所示的语法树,在这个阶段如果出现表达式不合法(括号不匹配、表达式缺少操作符)编译器就会报告语法分析阶段的错误:语法分析会形成抽象语法树 AST,我们继续使用 clang 命令进行语法分析 clang -fmodules -fsyntax-only -Xclang -ast-dump CompilerExpression.c,得到的结果如下: TranslationUnitDecl 0x7fccdc822808 <<invalid sloc>> <invalid sloc> |-TypedefDecl 0x7fccdc823048 <<invalid sloc>> <invalid sloc> implicit __int128_t '__int128' | `-BuiltinType 0x7fccdc822dd0 '__int128' `-FunctionDecl 0x7fccdd04d048 <CompilerExpression.c:1:1, line:5:1> line:1:6 test 'void ()' `-CompoundStmt 0x7fccdd04d568 <col:13, line:5:1> `-BinaryOperator 0x7fccdd04d548 <line:4:5, col:40> 'int' '=' |-ArraySubscriptExpr 0x7fccdd04d3f0 <col:5, col:16> 'int' lvalue | |-ImplicitCastExpr 0x7fccdd04d3c0 <col:5> 'int *' <ArrayToPointerDecay> | | `-DeclRefExpr 0x7fccdd04d320 <col:5> 'int[3]' lvalue Var 0x7fccdd04d2a0 'array' 'int[3]' | `-ImplicitCastExpr 0x7fccdd04d3d8 <col:11> 'int' <LValueToRValue> | `-DeclRefExpr 0x7fccdd04d358 <col:11> 'int' lvalue Var 0x7fccdd04d160 'index' 'int' `-BinaryOperator 0x7fccdd04d528 <col:20, col:40> 'int' '*' |-ParenExpr 0x7fccdd04d488 <col:20, col:30> 'int' | `-BinaryOperator 0x7fccdd04d468 <col:21, col:29> 'int' '+' | |-ImplicitCastExpr 0x7fccdd04d450 <col:21> 'int' <LValueToRValue> | | `-DeclRefExpr 0x7fccdd04d410 <col:21> 'int' lvalue Var 0x7fccdd04d160 'index' 'int' | `-IntegerLiteral 0x7fccdd04d430 <col:29> 'int' 4 `-ParenExpr 0x7fccdd04d508 <col:34, col:40> 'int' `-BinaryOperator 0x7fccdd04d4e8 <col:35, col:39> 'int' '+' |-IntegerLiteral 0x7fccdd04d4a8 <col:35> 'int' 2 `-IntegerLiteral 0x7fccdd04d4c8 <col:39> 'int' 6上面的示例代码比较简单,我们来分析下这个 AST 中的几个节点:对于 Clang来说,顶层结构是TranslationUnitDecl (translation unit declaration :翻译单元声明),对 AST 树的遍历,实际上是遍历整个 TranslationUnitDec。 TypedefDecl 类型描述,对应typedef。 FunctionDecl 代表 C/C++方法定义。 CompoundStmt 代表了像 { stmt stmt } 这样的statement的集合。实际上就是用 {} and {{}} 包裹的代码块。 之前说过语法分析的结果,会生成以表达式为节点的树,clang AST 中的所有表达式都由 Expr 的子类表示。 BinaryOperator 类是 Expr 类的子类,其包括两个子节点。上文也说过在这个阶段如果出现表达式不合法(括号不匹配、表达式缺少操作符)编译器就会报告语法分析阶段的错误,所以我将 CompilerExpression.c 中的 array[index] = (index + 4) * (2 + 6) 中的 ; 去掉试一下: CompilerExpression.c:4:41: error: expected ';' after expression array[index] = (index + 4) * (2 + 6) ^ ; ... 1 error generated.果然报告了一个错误,提示 array[index] = (index + 4) * (2 + 6) 后面应该加上 ; 语义分析 经过词法分析和语法分析之后,语句是否真的有意义呢,这时候就需要进行语义分析,查看语句在语法上是否合法,需要注意的是编译器只能查看静态语义,动态语义需要运行时才能进行确定。 如果过程中出现了类型不匹配,编译器就会报错,经过语义分析之后的语法树如下:可以看出,编译期可以确定的表达式类型,都已经被确定好了(如果有隐式转换,语义分析会在语法树中插入转换节点),除此之外,符号表里面的类型也做了更新。 中间语言生成 编译器存在多个层次的优化行为,源代码级别的称之为源代码优化器,上述的经过语义分析之后的整个语法树,(2+6)会被优化成了8,其实并不是直接在语法树上进行优化,而是将整个语法树转化成中间代码,来顺序的标识语法树,常见三地址码和 P-Code 法两种方式。 中间代码层作为中间层的存在,使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。 针对上面代码采用三地址码法进行优化 : 最基本的三地址码长这样: x = y op z 表示变量 x 和变量 y 经过 op 操作后赋值给 x 例如函数内部的代码 array[index] = (index + 4) * (2 + 6);,经过中间层的源代码优化器 (Optimizer)优化后的代码最终为: t2 = index + 4 t2 = t2 * 8 array[index] = t2使用 clang 命令将语法树自顶向下遍历逐步翻译成 LLVM IR:clang -S -fobjc-arc -emit-llvm test.c -o test.ll 得到 .ll 文件,IR 代码如下: ; ModuleID = 'CompilerExpression.c' source_filename = "CompilerExpression.c" target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128" target triple = "x86_64-apple-macosx12.0.0"; Function Attrs: noinline nounwind optnone ssp uwtable define void @test() #0 { %1 = alloca i32, align 4 %2 = alloca [3 x i32], align 4 store i32 1, i32* %1, align 4 %3 = load i32, i32* %1, align 4 %4 = add nsw i32 %3, 4 %5 = mul nsw i32 %4, 8 %6 = load i32, i32* %1, align 4 %7 = sext i32 %6 to i64 %8 = getelementptr inbounds [3 x i32], [3 x i32]* %2, i64 0, i64 %7 store i32 %5, i32* %8, align 4 ret void } ...BitCode 这里简单提一下我们经常听说的 BitCode,BitCode 是 iOS 9 引入的新特性,官方文档解释 BitCode 是一种中间代码,包含 BitCode 的应用程序会在 App Store 上编译和链接, BitCode 允许苹果在后期对我们的应用程序的二进制文件进行优化。其实就是 LLVM IR 的一种编码形式,如下图我们可以看到 BitCode 在编译环节所处的位置:但是在 Xcode 14 中 BitCode 被废除,iOS、tvOS 以及 watchOS 应用程序默认将不再支持 BitCode,在未来的 Xcode 版本中,BitCode 将被移除,主要原因是 Bitcode 并不是一个稳定的格式,因为在 LLVM 的设计里,它只是一个临时产生的文件,并不期望被长期存储,这导致它的兼容性很差,几乎 LLVM 每次版本更新时都会修改它,其次是对生态的要求很高,如果应用的任何一个依赖没有支持 Bitcode,那最终就无法使用。 目标代码生成与优化 编译器的后端主要包含代码生成器和目标代码优化器。代码生成器依赖于目标机器的字长、寄存器、整数数据类型和浮点数数据类型等,将中间代码转换目标机器代码。 array[index] = (index + 4) * (2 + 6); 经过源代码优化器又经过代码生成器变成如下代码: movl index, %ecx addl $4, %ecx mull $8, %ecx movl index, %eax movl %ecx, array(, eax, 4)上面代码经过目标代码优化器,会选择合适的寻址方式,使用位移来代替乘法运算,删除多余指令等,经过目标代码优化器之后代码如下(其中乘法改用相对复杂的基地址比例变址寻址的指令完成): movl index, %edx leal 32(, %edx, 8), %eax movl %eax, array(, %edx, 4)还有,我们都知道 Xcode 是使用 Clang 来编译 Objective-C 语言的,而 Xcode 供给我们 7 个等级的编译选项,在 Xcode -> Build Setting -> Apple LLVM 9.0 - Code Generation -> Optimization Level 中进行设置。None [-O0]:不优化 Fast [-O1]:大函数所需的编译时间和内存消耗都会稍微增加 Faster [-O2]:编译器执行所有不涉及时间空间交换的所有的支持的优化选项 Fastest [-O3]:在开启Fast [-O1]项支持的所有优化项的同时,开启函数内联和寄存器重命名选项 Fastest, Smallest [-Os]:在不显着增加代码大小的情况下尽量提供高性能 Fastest, Aggressive Optimizations [-Ofast]:与Fastest, Smallest [-Os]相比该级别还执行其他更激进的优化 Smallest, Aggressive Size Optimizations [-Oz]:不使用LTO的情况下减小代码大小设置不同优化选项,中间代码的大小会相应变化。 链接 代码经过标代码优化器,已经变成了最优的汇编代码结构,但是如果此时的代码里面使用到了别的目标文件定义的符号怎么办?这就引出了链接,之所以称之为链接,就是因为链接时需要将很多的文件链接链接起立,才能得到最终的可执行文件。 最开始的时候,程序员采用纸带打孔的方式输入程序的,然而指令是通过绝对地址进行寻址跳转的,指令修改过后,绝对的地址就需要进行调整,重定位的计算耗时又容易出错,于是就出现了汇编语言,采用符号的方式进行指令的跳转,每次汇编程序的时候修正符号指令到正确的地址。汇编使得程序的扩展更加方便,但是代码量开始膨胀,于是需要进行模块的划分,产生大量的模块,这些模块互相依赖又相对独立,链接之后模块间的变量访问和函数访问才有了真实的地址。模块链接的过程,本书的作者很形象生动的比作了拼图的过程,链接完成之后,才能产生一个可以真正执行的程序。链接的原理无非是对一些符号的地址加以修正的过程,将模块间的互信引用的部分都处理好。具体包括地址和空间分配、符号决议、重定位等步骤。多数情况下,目标文件和库需要一起进行链接,常用的一些基本函数大多属于运行时库(Runtime Library),链接器会根据引用的外部模块的符号,自动的去查找符号的地址,进行地址修正。 空间地址分配 到了比较重要的静态链接,上面说的 “拼图” 的过程,其实就是静态链接的过程,即将多个目标文件链接起来,形成一个可执行文件。 有这样两个文件 a.c 和 b.c,gcc -c a.c b.c 经过编译后形成 a.o 和 b.o: // a.c extern int shared; void swap(int* a, int* b); int main() { int a = 100; swap(&a, &shared); }// b.c int shared = 1; void swap(int* a, int* b) { *a ^= *b ^= *a ^= *b; }在 a.c 定义了两个外部符号 shared 和 swap ,b.c 中定义了一个 main 为全局符号,我们可以查看下通过clang进行编译,通过 MachOView 查看 a.o:可见 mov 这条指令中,shared 的部分的地址为 0x00000000,swap 的地址也为 0x00000000 (其中0xE8 为操作码)。这部分只是用来代替,真正的地址计算留给链接器。 接下来就是将两个目标文件进行链接,两个目标文件怎么合并呢? 方式一:直接将目标文件拼接起来这种拼接方式虽然简单,但是缺点很明显,段数量太多了不说,由于 x86的硬件来说,段的装载和空间对齐的单位是页,4096个字节,这就会导致即便是仅有1个字节的段,在内存中也会被分配4096个字节。 方式二:相似段合并现在的链接器大多都是采用两步链接的方法:空间与地址分配 扫描所有的目标文件 获取各个段的长度,属性和位置 收集符号表中的所有符号定义和符号引用,放入全局符号表 获得所有目标文件的长度,并且将其合并 建立合并后的文件的映射关系符号解析和重定位 获取上一步收集的段数据和重定位信息 进行符号解析 重定位 调整代码中的地址使用 ld 将 a.o 和 b.o 链接起来: ld a.o b.o -e main -o ab ,链接后使用的地址是进程中的虚拟地址。 小Tip:如果在 MacOS 系统中可直接使用 clang 命名链接目标文件 clang a.o b.o -o ab,如果直接使用 ld 进行链接可能会导致异常如下: ld: dynamic executables or dylibs must link with libSystem.dylib for architecture x86_64 即便添加了指定 libSystem:ld a.o b.o -e _main -o ab -lSystem ,也会报如下错误: ld: library not found for -lSystem 发现是因为指定库的地址,最后解决方案如下: ld a.o b.o -e _main -o ab -macosx_version_min 12.6 -L /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib -lSystem 我分析报错主要原因是因为,MacOS 系统在链接的时候,会默认使用 libSystem.dylib,在 Mach-O 中也能看到这个库的存在符号解析&重定位 何使用外部符号呢?比如 a.c 文件中就使用到了 shared 和 swap 两个外部符号,在 a.c 编译成目标文件的时候, shared 和 swap 两个的地址还不知道, 编译器会使用地址 0 当做 shared 的地址,函数的调用是一条叫 进址相对位移调用指令,这个我们放到最后来讲 swap 在目标文件中的地址也是一个临时的假地址 0xFFFFFFFC,在经过上一步的地址和空间分配之后,就已经可以确定所有符号的虚拟地址了。 我们看一下 a.o 和 经过链接之后的 ab,首先 objdump -d a.o 如下: a.o: file format mach-o 64-bit x86-64Disassembly of section __TEXT,__text:0000000000000000 <_main>: 0: 55 pushq %rbp 1: 48 89 e5 movq %rsp, %rbp 4: 48 83 ec 10 subq $16, %rsp 8: c7 45 fc 64 00 00 00 movl $100, -4(%rbp) f: 48 8d 7d fc leaq -4(%rbp), %rdi 13: 48 8b 35 00 00 00 00 movq (%rip), %rsi ## 0x1a <_main+0x1a> 1a: e8 00 00 00 00 callq 0x1f <_main+0x1f> 1f: 31 c0 xorl %eax, %eax 21: 48 83 c4 10 addq $16, %rsp 25: 5d popq %rbp 26: c3 retq13行 和 1a行地址是临时给到的,需要进行重定位,再使用 objdump 看下 ab ,objdump -d ab : ab: file format mach-o 64-bit x86-64Disassembly of section __TEXT,__text:0000000100003f50 <_main>: 100003f50: 55 pushq %rbp 100003f51: 48 89 e5 movq %rsp, %rbp 100003f54: 48 83 ec 10 subq $16, %rsp 100003f58: c7 45 fc 64 00 00 00 movl $100, -4(%rbp) 100003f5f: 48 8d 7d fc leaq -4(%rbp), %rdi 100003f63: 48 8d 35 96 00 00 00 leaq 150(%rip), %rsi ## 0x100004000 <_shared> 100003f6a: e8 11 00 00 00 callq 0x100003f80 <_swap> 100003f6f: 31 c0 xorl %eax, %eax 100003f71: 48 83 c4 10 addq $16, %rsp 100003f75: 5d popq %rbp 100003f76: c3 retq ...可见经过链接 swap 和 shared 符号的地址已经确定。 对比上面的 a.o 的 MachOView 结果,我们查看一下 ab:shared 的地址修正属于绝对地址修正:例如 b.o 文件中的 shared 函数的段偏移是 X 合并后的段的 b.o 的代码段的虚拟地址假设为 0x08048094 那么合并后的 shared 函数的地址为 0x08048094 + Xswap 是一条近址相对位移调用指令,它的地址是调用指令的下一条指令的偏移量,地址修正方式为:首先找到下一条指令的偏移量 0x00000011 找到下一条指令的地址,由上图可以看到 callq 指令的下一条指令地址为 0x00003F6F 所以 swap 的地址可以计算得出 0x00003F6F + 0x00000011 = 0x00003F80 至于链接器怎么就知道 shared 和 swap 是需要进行调整的指令呢?这里就涉及到了一个叫做重定位表的段,也叫做重定位段,其实上面也有说过,.rel.text 是针对代码段的重定位表,.rel.data 是针对数据段的重定位表, objdump -r a.o 结果如下: a.o: file format mach-o 64-bit x86-64RELOCATION RECORDS FOR [__text]: OFFSET TYPE VALUE 000000000000001b X86_64_RELOC_BRANCH _swap 0000000000000016 X86_64_RELOC_GOT_LOAD _shared@GOTPCRELRELOCATION RECORDS FOR [__compact_unwind]: OFFSET TYPE VALUE 0000000000000000 X86_64_RELOC_UNSIGNED __texta.o 中就存在了两个重定位入口,上图代表是代码段的重定位表,两个 offset 标识代码段中需要调整的指令的偏移地址。 C++相关问题 C++ 由于模板、外部内联函数、虚函数表等导致会产生很多重复的代码,目前的 GNU GCC 将每个模板代码放入一个段里,每个段只有一个模板的实例,当别的编译单元以相同的类型实例化模板函数的时候,也会生成和之前相同名称的段,最终在链接的时候合并到最后的代码段。C++ 还提供了一个叫做函数级别链接的编译选项,这个选项可以使所有函数都会被编译到单独的段里面,链接合并时,没有用到的函数就会被抛弃,减少了文件的长度,但是增加段的数量以及编译的时间。 C++ 的 main 之前需要初始化进程的执行环境等, main 之后需要做一些清理的工作,于是 ELF 文件还定义两个特殊的段 .init 和 .fini,一个放在main前由系统执行,一个放在main函数返回后执行。 目标文件可能是被两个不同的编译器产出的,那么两个目标文件能够进行链接的条件是:采用相同的目标文件格式 拥有同样的符号修饰标准 变量的内存分布方式相同 函数调用方式相同 ...其中 2,3,4 等是与可执行文件的二进制兼容性相关**(ABI)** ABI稳定 人们总是希望二进制和数据不加修改能够得到重用,但是实现二进制级别的重用还是很困难的,因为影响 ABI 的因素非常多,硬件、编程语言、编译器、链接器、操作系统都会影响 ABI。 C 代码层面的 ABI 稳定 从 C 语言的目标代码来说,下面几个因素会影响二进制是否兼容:内置类型的大小和在存储器中的放置方式(大端、小端、对齐方式)。 组合类型的存储方式和内存分布。 外部符号与用户定义的符号之间的命名方式和解析方式。 函数调用方式。 堆栈分布方式。 寄存器的使用约定。C ++ ABI 稳定 到了 C++ 时代,做到二进制兼容更是不易,需要考虑:继承类体系的内存分布。 指向成员函数的指针的内存分布。 如何调用虚函数,vtable 的内容和分布形式,vtable 指针在 object 中的位置。 模板如何实例化。 外部符号的修饰。 全局对象的构造和析构。 异常产生和捕获机制。 标准库的细节问题和 RTTI 如何实现。 内嵌函数访问细节等。二进制的兼容,一直都是语言发展过程中的重要事务,比如还有很多人还在致力于 C++的标准的统一。 Swift ABI稳定 从16年就接触了 Swift3.0 的开发,当时当时的 Swift 语言还是在飞速迭代的过程中,每次一个小的版本的升级,就会有大量的代码需要改动,我甚至还误以为这个是由于 Swift 的 ABI 不稳定造成的,这其实是错的,直到 Swift 5 发布,Swift 5 最重要的变化就是 ABI Stability,ABI 稳定之后,OS 发行商就可以把 Swift 标准库和运行时作为操作系统的一部分嵌入。也就是说 Apple 会把 Swift runtime 放到 iOS 和 macOS 系统里,我们的 Swift App 包里就不需要包含应用使用的标准库 和 Swift runtime 拷贝了。同时在运行的时候,只要是用 Swift 5 (或以上) 的编译器编译出来的 Binary,就可以跑在任意的 Swift 5 (或以上) 的 runtime 上。 ABI & API 此外有个与之对应的有个概念叫做 API,实际上它们都是应用程序接口,只是接口所在层面不同:API 是指源代码级别的接口 ABI 是指二进制层面的接口。大端小端就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。静态库链接 静态库可以看做是一组目标文件的集合,举例几个 C 语言的运行的库:C运行库 相关 DLL 相关 DLLlibcmt.libMultithreaded Static 多线程静态库msvert.lib msver90.dll Multithreaded Dynamic 多线程动态库libcmtd.libMultitbreaded Static Debug 多线程静态调试库msvertd.lib msvert90d.dll Multichreaded Dynamic Debug 多线程动态调试库链接使用到静态库的过程如下所示,其实目标文件中使用了printf函数:然而我们直接将 hello.o 和 printf.o 链接在一起 ld hello.o print.o,却报错了,其原因是 printf.o 中使用到了其他的库文件。 链接过程控制 特殊情况下我们需要控制链接规则,这就引出了链接过程控制。一共有三种办法做到控制:指定链接器参数,-o -e 之类。 将链接指令放在目标文件里面。 使用链接控制脚本。本身是存在默认的链接脚本的,例如在 Intel IA32下,我们使用 ld 链接生成可执行文件时,默认使用的 elf_i386.x 脚本,使用 ld 链接生成共享目标文件时,默认使用的是 elf_i386.xs 脚本。当然我们可以自己写脚本,来控制链接过程。 作者举了一个使用 ld 脚本干预链接的例子,程序代码结合的是 GCC 内嵌汇编,不借助库函数,可以打印 Hello World 程序,代码如下: char* str = "Hello world! \n"; void print () { asm("movl $13, %%edx \n\t" "movl %0, %%ecx \n\t" "movl $0, %%ebx \n\t" "movl $4, %%eax \n\t" "int $0x80 \n\t" :: "r"(str): "edx", "ecx", "ebx"); } void exit () { asm("movl $42, %ebx \n\t" "movl $1, %eax \n\t" "int $0x80 \n\t"); } void nomain () { print(); exit(); }编译:gcc -c -fno-builtin TinyHelloWorld.c 链接:ld -static -e nomain -o TinyHelloWorld TinyHelloWorld.o 这段代码的可执行代码一共会生成4个段,使用 ld 脚本可以合并部分段,并删除多余的段,脚本如下: ENTRY (nomain) SECTIONS { . = 0x08048000 + SIZEOF_HEADERS; tinytext : { *(.text) *(.data) *(.rodata) } /DISCARD/ : { *(.comment) ) }脚本一共做了几件事呢?第一行执行了程序的入口为 nomain。 SECTIONS 里面是变换规则,第一句的意思是设置 tinytext 的段的起始虚拟地址为 0x08048000 + SIZEOF_HEADERS。 第二句意思是将 .text 段、.data 段、.rodata 段合并为 tinytext 段。 第三句意思是将 .comment 段丢弃。Note:除了 tinytext 段之外,其实还会同时存在 .shstrtab 段、.symtab 段、.strtab 段(段名字符串表、符号表、字符串表) 此外,现在的 GCC 都是通过 BFD 库来处理目标文件的,BFD 库会把目标文件抽象成一个统一的模型,然后就可以操作所有支持 BFD 支持的目标文件格式。 目标文件里有什么 整体结构 目前流程的可执行文件的格式分为两种,Windows 下的 PE 和 Linux 下的 ELF,均为 COFF 的变种,目标文件是源代码经过编译后但是没有进行链接的中间文件,结构和内容和可执行文件类似,Windows 下目标文件和可执行文件统称为 PE/COFF 文件格式,Linux 系统下统称为 ELF 的文件。 此外,除了可执行性文件,动态链接库(Linux 下的 .so)、静态链接库(Linux 下的 .a)都是按照可执行文件的格式存储,在 Linux 下可以通过 file 命令查看具体类型。 ELF 目标文件的总体结构如下,之所以 Section Table 和 .rel.text 与前一个段有间隔,是因为内存对齐的原因:ELF 开头是一个头文件,描述了整个文件的文件基本属性,了 ELF 魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI 版本、ELF 重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等。 头文件还包括一个段表 Section Table,是一个描述各个段的数组,描述了段名、段的长度、在文件的偏移位置、读写权限以及段的其他属性。编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性。 头文件后是各个段的内容。 执行语句编译成的机器代码在代码段(.text 段)。 已经初始化的全局变量和局部静态变量都在数据段(.data 段)。 未初始化的全局变量和局部静态变量一般放在一个叫 BSS 段中。(BSS 段只是预留位置,在文件中不会占据空间) 其他段...总体说,程序代码被编译后主要分为两种,程序指令和程序数据,为什么指令和数据分开来放呢?方便权限划分(只读和可读写等)。 划分了空间,提高 CPU 缓存的命中率。 方便指令或者数据的复用性,节省内存。比如说“共享指令”。段表 段表 Section Header Table 用于保存 ELF 文件中的所有段的基本属性的结构,是除了文件头以外中重要的结构,描述了各个段的段名、段的长度、在文件中的偏移、读写权限、以及段的其他属性。编译器、链接器、装载器都是依靠段表来定位各个段的和访问各个段的,段表在 ELF 文件的位置是由 ELF 文件头中的 e_shoff 成员决定的。段表其实是一个 EIf32_Shdr 的结构体的数组,里面包含段名 sh_name、段类型 sh_type、段的标志位 sh_flags、段的虚拟地址 sh_addr、段的偏移 sh_offset、段的长度 sh_size、段的链接信息 sh_link 和 sh_info、段的地址对齐 sh_addralign 等等。 段的名字只是在链接和编译过程中有意义,但是不能真正标识段的类型,段的属性是由段的类型 sh_type 和段的标志位 sh_flag 共同来决定的。段的类型是由 SHT_ 开头的,例如 SHT_PROGBITS 代表代码段、数据段,SHT_SYMTAB 代表符号表,SHT_STRTAB 代表字符串表。 段的标志位 sh_flag 标识了段是否可写、可执行,以 SHF_ 开头,例如SHF_WRITE 代表段在进程空间中可写, SHF_ALLOC 代表进程空问中须要分配空间 下面列举几种常见的系统保留段的段类型和段标志如下:Name sh_type sh flag.bss SHT_NOBITS SHF_ALLOC + SHF_WRITE.data SHT_PROGBITS SHF_ALLOC + SHF_WRITE.shstrtab SHT_STRTAB none.strtab SHT_STRTAB 如果该 ELF 文件中有可装載的段须要用到该字符串表,那么该字符串表也将被装载到进程空问,则有 SHF_ALLOC 标志位.symtab SHT_SYMTAB 同字符串表.text SHT_PROGBITS SHF_ALLOC + SHF_EXECINSTR下面介绍段的链接信息,如果段是与链接有关的(静态链接或者动态链接),比如说重定位表、符号表,在 sh_link 和 sh_info 字段中会包含链接相关的信息。sh_type sh_link sh_infoSHT_DYNAMIC 该段所使用的字符串表在段表中的下标 0SHT_HASH 该段所使用的符号表在段表中的下标 0SHT_REL 该段所使用的相应符号表在段表中的下标 该重定位表所作用的段在段表中的下标SHT_RELA 该段所使用的相应符号表在段表中的下标 该重定位表所作用的段在段表中的下标SHT_SYMTAB 操作系统相关的 操作系统相关的SHT_DYNSYM 操作系统相关的 操作系统相关的other SHN UNDEF 0常见段常用的段名 说明.text 程序指令.data 已经初始化的全局静态变量和局部静态变量.rodata 程序里的只读数据.bss 未初始化的全局变量和局部静态变量.comment 注释信息段.note.GNU-stack 堆栈提示段.rodata1 Read only Data,这种段里存放的是只读数据,比如字符串常量、全局 const变量。跟 “.rodata” 一祥.comment 存放的是编译器版本信息,比如字符串:"GCC: (GNU) 4.2.0”.debug 调试信息.dynamic 动态链接信息.hash 符号哈希表.line 调试时的行号表,即源代码行号与编译后指令的对应表.note 额外的编译器信息。比如程序的公司名、发布版本号等.strtab String Table 字符串表,用于存储ELF文件中用到的各种字符串.symtab Symbol Tabie 符号表.shstrtab Section String Table 段名表.plt.got 动态链接的跳转表和全局入口表.init.fini 程序初始化与终结代码段为了了解常见的段,我们先自己编译一段代码 SimpleSection.c: int printf (const char* format, ...); int global_init_var = 84; int global_uninit_var;void func1(int i) { printf( "%d\n",i); } int main(void) { static int static_var = 85; static int static_var2; int a = 1; int b; func1(static_var + static_var2 + a + b); return a; }只编译不链接该 SimpleSection.c 文件: gcc -c SimpleSection.c 得到 SimpleSection.o,SimpleSection.o 即为目标文件。 我们使用 objdump -h SimpleSection.o 查看其内部结构:除了上面我们提到过的代码段,数据段,BSS 段之外,还有三个段分别为只读数据段(.rodata 段),注释信息段(.comment 段),堆栈段(.note.GNU-stack),首先 BSS 段和堆栈段认为在文件中不存在,实际存在的段的分布情况如下:使用 size 命令可以查看各个段的长度:size SimpleSection.o text data bss dec hex filename 95 8 4 107 6b SimpleSection.o代码段 使用 objdump -s -d SimpleSection.o 可以查看所有段的十六进制内推以及所有包含指令的反汇编,我们着重看下代码段的内容:可见代码段里面包含就是两个函数 func1() 和 main() 函数的指令。 数据段和只读数据段 中存放的是已经初始化的全局静态变量和局部静态变量,上述代码中的中的 global_init_var 和 static_var 为全局静态变量和局部静态变量,加一起一共是 8 个字节,在调用 printf 函数的时候,用到了一个字符串常量,是一种只读数据,放入了 .rodata 段,刚好是 4 个字节。 BSS段 编译单元内部可见的未初始化的静态变量的确放到了BSS 段,但是未初始化的全局变量却不一定放在 BSS 段,例如上述代码中的 global_uninit_var 和 static_var2 就应该放在了BSS 段,但是我们看到该段只有四个字节的大小,其实只有 static_var2 放在了BSS 段,而 global_uninit_var 却没有放进任何段,只是一个未定义的 COMMON 符号,具体的原因我们放到下面关于符号的位置来讲解。 重定位表 链接器在处理目标文件的时候,需要对代码段和数据段中绝对地址的引用位置进行重定位,重定位的信息会记录在 ELF 文件的重定位表里,对于需要进行重定位的代码段或者数据段,都会需要一个重定位表,.rel.text 是针对代码段的重定位表,.rel.data 是针对数据段的重定位表,重定位表的 sh_type 为 SHT_REL,而 sh_link 字段记录该段所使用的相应符号表在段表中的下标,sh_info 表示该重定位表所作用的段在段表中的下标。 字符串表 ELF 文件中有许多类似于段名、变量名之类的字符串,使用字符串表,通过定义在表中的偏移来引用。字符串表在 ELF 中也是以段的形式存在..strtab 字符串表 .shstrtab 段表字符串表结合 ELF 头文件中的 e_shstrndx 即可找到段表和段表字符串表的位置,从而解析 ELF 文件。 自定义段 我们可以自己插入自定的段,做一些特定的事情,但是自定义段不能使用 . 开头,我们在全局变量或者函数加上 __attribute__((section("name"))) 就可以将相应的变量或者函数放到 name 为名的段中。 符号 链接的本质是将不同的目标文件互相 “粘” 到一起,还记得上文中的拼图吗,很形象生动,而链接中需要使用到的就是符号的名字,我们将函数和变量统称为符号 Symbol,函数名和变量名即符号名。每个目标文件中都会有一个符号表 Symbol Table,每个符号都有对应的符号值,对于变量和函数,符号值就是它们的地址。 常见的符号类型定义在本目标文件的全局符号,可以被其他目标文件引用。 外部符号,即在本目标文件中引用的全局待号,却没有定义在本目标文件。 段名,这种符号往往由编译器应生,它的值就是该段的起始地址。 局部符号,这类符号只在编译单元内部可见。 行号信点。使用 nm 可以查看目标文件的符号表: nm SimpleSection.o 打印的所有符号如下: 00000000 T funcl 00000000 D global_init_var 00000004 C global_uninit_var 0000001b T main U printf 00000004 d static_var.1286 00000000 b static_var2.1287符号表结构 符号表也是属于 ELF 文件中的一个段, 段名叫 .symtab,它是一个结构体的数组,结构体里面有几个重要的元素。 我们查看下 64 位的 Elf64_Sym 结构定义如下: typedef struct { Elf64_Word st_name; unsigned char st_info; unsigned char st_other; Elf64_Half st_shndx; Elf64_Addr st_value; Elf64_Xword st_size; } Elf64_Sym; //64位st_info 符号类型和绑定信息,低4位表示符号类型,高28位表示符号绑定信息。 st_shndx 这个值如果符号在目标文件中,这个符号就是表示符号所在的段在段表中的下标,如果符号不在目标文件中,sh_shndx 可能会有些特殊,例如 SHN_ABS 表示该符号包含一个绝对的值,SHN_COMMON 表示该符号是一个 COMMON 块类型的符号,SHN_UNDEF 表示符号未定义,可能定义在其他的目标文件中。 st_value 符号值。 如果段不是 COMMON 块(即 st_shndx 不为 SHN_COMMON),则符号对应的函数或者变量位于由 st_shndx 指定的段经过 st_value 偏移所在的位置,这种是目标文件中定义全局变量的最常见的情况。 如果是 COMMON 块(即 st_shndx 不为 SHN_COMMON), st_value 表示符号对齐属性。 如果在可执行文件中,st_value 表示符号的虚拟地址,对于动态链接器十分有用。此外还有一些特殊符号,例如 __executable_start 为程序起始地址,__etext 或 _etext 或 etext 标识代码段结束地址等等。我们使用 readelf 查看 ELF 文件的符号: readelf -s SimpleSection.o:Num 表示符号数组的下标,Value 是符号值,Size 为符号大小,st_info 为符号类型和绑定信息,Ndx 即 st_shndx 表示符号所属的段,举几个例子:func1 和 main 位置在代码段, Ndx 是1,类型为 STT_FUNC,由于是全局可见的,所以是 STB_GLOBAL, Size 表示指令所占字节数, Value表示函数相对于代码段的起始位置的偏移。 printf 这个符号,在 SimpleSection.c 中被引用,,但是没有定义,所以 Ndx 是 SHN_UNDEF。 global_init_var 是已初始化的全局变量,定义在 BSS 段,下标为 3. global_uninit_var 是未初始化的全局变量,是一个 SHN_COMMON 类型的符号,本身未存在于 BSS 段。 static_var 和 static_var2 绑定属性是 STB_LOCAL,编译内部单元可见。 STT_SECTION,表示下标为 Ndx 的段的段名,符号名没有显示,其实符号名即为段名。符号修饰和函数签名 为了防止符号的冲突,Unix系统规定 C 语音源代码的所有的全局变量和函数经过编译后,会在符号前面加上下划线。C++ 为了符号命名冲突的问题,增加了命名空间。 C++ 拥有类、继承、虚机制、重载、命名空间等特性,使得符号管理更为复杂,于是引入了函数签名,函数签名包含函数名。参数类型,所在类以及命名空间等一些列信息,用于识别不同的函数,即便函数名相同,编译器也会更具修饰后的名称,认为它们是不同的函数,修饰后的名称如表:函数签名 修饰后名称(符号名)int func(int) __Z4funcifloat func(float) __Z4funcfint C::func(int) __ZN1C4funcEiint C::C2::func(int) __ZN1C2C24funcEiint N::func(int) __ZNIN4funcEiint N::C::func(int) __ZNINICAfuncEi变量的类型没有加入到修饰后的名称中。 C++ 为了和 C 兼容, 还引入了 extern "C" 关键字: extern "C" { int func(int); int var; }在 {} 中的代码会被 C++ 编译器当做 C 的代码来处理, 当然 C++ 的名称机制也将不起作用。 如果是单独的某个函数或者变量定义为 C 语言的符号也可以使用 extern: extern "C" int func(int); extern "C" int var;强符号和弱符号 开发中我们经常会遇到符号被重复定义的错误,比如说我们在两个目标文件中都定义了相同的全局整形变量 global,并将它们同时初始化,那么链接器将两个目标文件链接的时候就会报错,对于 C/C++ 语言来说,这种已初始化的全局符号可以称之为强符号,有些符号的定义称之为弱符号,比如说未初始化的全局符号,强符号和弱符号是针对定义来说的,而不是针对符号的引用。我们也可以使用 __attribute__((weak)),来定义强符号为弱符号,下面我们看一段代码: extern int ext; // 非强符号也非弱符号int weak; // 弱符号 int strong = 1; // 强符号 __attribute__((weak)) weak2 = 2; // 弱符号int main() { // main 强符号 return 0; }上段代码的强弱符号已经进行了标注,ext 由于是一个外部变量的引用,非强符号也非弱符号。 链接器会按照下面的三个规则处理与选择多次定义的全局符号:不允许强符号被定义多次(不同的目标文件中不能有同名的强符号,否则链接报错)。 如果有一个是强符号其余的是弱符号,则选择强符号。 如果不同的目标文件中都是弱符号,则选择占用空间最大的一个。现在的链接器在处理弱符号的时候,采用的 COMMON 块一样的机制来处理,编译器将未初始化的局部静态变量定义为弱符号,还记得上面我留了一个问题,编译器将未初始化的局部静态变量 static_var2 放在 BSS 段,而 global_uninit_var 属于未初始化的全局符号,没有直接放入 BSS 段,先是标记了 COMMON,按照 COMMON 链接规则,global_uninit_var 的大小以输入文件中最大的那个为准,最终确认了符号的大小,就能放入 BSS 段了。但是有种情况,如果是同时存在强符号和弱符号,那么输出文件和强符号相同,但是如果链接过程中有弱符号大于强符号,那么 ld 就会报出警告。 对于目标文件被链接成可执行文件的阶段,如果是强符号没有被定义将会报错,如果弱符号没有被定义,链接器对于该引用不会报错,一般都会被赋予一个默认值,便于程序代码识别到。但是在运行阶段,可能会发生非法地址访问的错误。 强符号和弱符号的设计对于库来说十分有用,比如:库中定义的弱符号可以被强符号进行覆盖,从而使用自定义的库函数。 程序的某些扩展功能模块定义为弱引用,扩展模块和程序一起链接的时候就能使用模块功能,去掉模块功能也能正确链接,只是缺少部分功能。这个设计使得程序的设计更为灵活,我们可以对大的功能模块进行自由的组合和裁切。调试信息 调试信息似使得我们进行源代码级别的调试,可以设置断点,监测变量的变化,单步运行,确定目标代码的地址对应源代码中的哪一行、函数和变量的类型等等。 ELF 文件中采用一个叫做 DWARF 标准的调试信息格式,调试信息占用空间比较大,我们再发布程序的时候,往往需要使用 strip 命令来去掉 ELF 文件中的调试信息。 DWARF 一种通用的调试文件格式,支持源码级别的调试,调试信息存在于 对象文件中,一般都比较大。Xcode 调试模式下一般都是使用 DWARF 来进行符号化的。 通过 DWARF 清晰的看到函数的描述、行号、所在文件、虚拟地址等重要信息,有了这些信息,就可以实现单步调试以及查看 Crash 堆栈等能力。 说到 DWARF 可能我们还不是很熟悉,但是有一个文件,iOS 的程序员应该不陌生,那就是 dSYM 文件,日常开发时会遇到 Crash,Crash 里面有很多的堆栈信息,以及 Crash 时所执行的代码的行号,这些信息对定位问题非常重要,这个能力就是依赖 DWARF 和 dSYM 实现的。当然 DWARF 和 dSYM 是公共的标准,并不是只有苹果特有的,只不过主要是苹果在用而已。使用 Xcode 编译打包的时候会先通过可执行文件的 Debug Map 获取到所有对象文件的位置,然后使用 dsymutil 来将对象文件中的 DWARF 提取出来生成 dSYM 文件。 Strip 上文说到可以使用 Strip 命令来去掉 ELF 文件中的调试信息,在 Xcode 中其实已经给我们提供了 Strip 编译选项,之所以要在 Release 环境中去掉符号信息,主要是因为调试信息占用的空间太大了,需要进行 App 的瘦身操作。 Strip 命令就是为了去除调试信息,其中符号占据了绝大部分,而可执行文件中的符号是指程序中的所有的变量、类、函数、枚举、变量和地址映射关系,以及一些在调试的时候使用到的用于定位代码在源码中的位置的调试符号,符号和断点定位以及堆栈符号化有很重要的关系。 Xcode 编译实际的操作步骤是:生成带有 DWARF 调试信息的可执行文件 -> 提取可执行文件中的调试信息打包成 dSYM -> 去除符号化信息。去除符号是单独的步骤,使用的是 strip 命令,下面介绍两个有关于 strip 命令的 Xcode 编译选项: Strip Style Strip Style 表示的是我们需要去除的符号的类型的选项,其分为三个选择项:All Symbols Non-Global Symbols Debug Symbols去除所有符号,一般是在主工程中开启 (保留全局符号,Debug Symbols 同样会被去除),链接时会被重定向的那些符号不会被去除,此选项是静态库/动态库的建议选项。 去除调试符号,去除之后将无法断点调试。Strip Linked Product Xcode 提供给我们 Strip Linked Product 来去除不需要的符号信息(Strip Style 中选择的选项相应的符号),去除了符号信息之后我们就只能使用 dSYM 来进行符号化了,所以需要将 Debug Information Format 修改为 DWARF with dSYM file。 去除符号之后,调试阶段怎么办 去除符号化信息之后我们只能使用 dSYM 来进行符号化,那我们怎么使用 Xcode 来进行调试呢? Strip Linked Product 选项在 Deployment Postprocessing 设置为 YES 的时候才生效,而在 Archive 的时候 Xcode 总是会把 Deployment Postprocessing 设置为 YES 。所以我们可以打开 Strip Linked Product 并且把 Deployment Postprocessing 设置为 NO,而不用担心调试的时候会影响断点和符号化,同时打包的时候又会自动去除符号信息。 Mach-O 文件 经过 ELF 文件的学习,我们重温一下 iOS 里的 Mach-O 文件格式,Mach-O 是 Mach object 文件格式的缩写,是一种可执行文件、目标代码、共享程序库、动态加载代码和核心 dump,它类似于 Linux 和大部分 UNIX 的原生格式 ELF 以及 Windows 上的 PE。可见其主要包含三个部分: Header:记录了Mach-O文件的基本信息,包括CPU架构、文件类型和Load Commands等信息。 Load Commands:加载命令部分描述了需要内核加载器或动态连接器等进行的操作指令,如加载数据段、加载动态库等。 Section Data:每一个Segment的数据都保存在此,描述了段名、类型、段偏移,段大小等信息,每个 Segment 拥有一个或多个 Section ,用来存放数据和代码。 Mach-O文件中 中 Data 段之后就都是 __LINKEDIT 部分,具体如下:Dynamic Loader Info 动态加载信息Function Starts 函数起始地址表Symbol Table 符号表信息Data in Code Entries 代码入口数据Dynamic Symbol Table 动态符号表String Table 字符串表信息Code Signature 代码签名String Table 字符串表所有的变量名、函数名等,都以字符串的形式存储在字符串表中。 Symbol Table 符号表,这个是重点中的重点,符号表是将地址和符号联系起来的桥梁。符号表并不能直接存储符号,而是存储符号位于字符串表的位置。 Header struct mach_header_64 { uint32_t magic; /* 标识当前 Mach-O位32位(0xfeedface)/ 64位 (0xfeedfacf) */ cpu_type_t cputype; /* CPU 类型 */ cpu_subtype_t cpusubtype; /* CPU 子类型 */ uint32_t filetype; /* 文件类型 */ uint32_t ncmds; /* Load Commands 数量 */ uint32_t sizeofcmds; /* Load Commands 的总大小 */ uint32_t flags; /* 标识位,记录文件的详细信息 */ uint32_t reserved; /* 64位文件特有的保留字段 */ }Load Commands Load command描述了文件中数据的具体组织结构,不同的数据类型使用不同的加载命令。它的大小和数目在header中已经被提供。 struct load_command { uint32_t cmd; /* cmd 类型 */ uint32_t cmdsize; /* cmd size */ };Load Commands 的部分信息如下:LC_SEGMENT_64 将文件中的段映射到进程地址空间中LC_DYLD_INFO_ONLY 动态链接相关信息LC_SYMTAB 符号表地址LC_DYSYMTAB 动态符号地址LC_LOAD_DYLINKER 指定内核执行加载文件所需的动态连接器LC_UUID 指定图像或其对应的dSYM文件的128位UUIDLC_VERSION_MIN_MACSX 文件最低支持的操作系统版本LC_SOURCE_VERSION 源代码版本LC_MAIN 程序main函数加载地址LC_LOAD_DYLIB 依赖库路径LC_FUNCTION_STARTS 函数起始表地址LC_CODE_SIGNATURE 代码签名几种常见的命令简介如下: 使用最多的是 LC_SEGMENT_64 命令,该命令表示将相应的 segment 映射到虚拟地址空间中,一个程序一般会分为多个段,每一个段有唯一的段名,不同类型的数据放入不同的段中,LC_SEGMENT_64 中包含了五种类型:PAGEZERO:可执行文件捕获空指针的段 TEXT:代码段和只读数据 DATA_CONST:常态变量 DATA:全局变量和静态变量 LINKEDIT:包含动态链接器所需的符号、字符串表等数据动态链接相关信息:LC_DYLD_INFO_ONLY:Rebase:进行重定向的位置信息。当 Mach-O 加载到内存里,系统会随机分配一个内存偏移大小 ASLR,和 rebase 里面的 offset,对接(位置相加)获取代码在内存中的实际位置。再根据 size 开辟实际内存。 Binding:绑定的位置信息 Weak Binding:弱绑定的位置信息 Lazy Binding:懒加载绑定的位置信息 Export:对外的位置信息LC_SYMTAB 标识了 Symbol Table 和 String Table 的位置。 LC_LOAD_DYLINKER 标识了动态连接器的位置,用来加载动态库等。 Mach-O 程序入口:设置程序主线程的入口地址和栈大小 LC_MAIN,反编译后根据 LC_MAIN 标识的地址可以找到入口 main 代码,dyld 源码中 dyld::_main 可以看到 LC_MAIN 的使用,获取入口和调用。 LC_LOAD_DYLIB 是比较重要的加载动态库的指令,Name 标识了具体的动态库的路径,对一个 Mach-O 注入自定义的动态库时就是在 Load Commands 和 Data 中间添加 LC_LOAD_DYLIB 指令和信息进去。 Data Data 分为 Segment 和 Section 两个部分,存放代码、数据、字符串常量、类、方法等。 Segment 结构体定义如下: struct segment_command_64 { /* for 64-bit architectures */ uint32_t cmd; /* Load Commands 部分中提到的cmd类型 */ uint32_t cmdsize; /* cmd size */ char segname[16]; /* 段名称 */ uint64_t vmaddr; /* 段虚拟地址(未偏移),真实虚拟地址要加上 ASLR 的偏移量 */ uint64_t vmsize; /* 段的虚拟地址大小 */ uint64_t fileoff; /* 段在文件内的地址偏移 */ uint64_t filesize; /* 段在文件内的大小 */ vm_prot_t maxprot; /* maximum VM protection */ vm_prot_t initprot; /* initial VM protection */ uint32_t nsects; /* 段内 section数量 */ uint32_t flags; /* 标志位,用于描述详细信息 */ };而对于**__TEXT** 和 __DATA 这两个 Segment,则可以继续分解为 Section,从而形成 Segment -> Section 的结构。之所以要这样设计,是因为在同一个 Segment 下的 Section 可以拥有相同的控制权限,并且可以不完全按照 Page 的大小进行内存对齐,从而达到节约内存的效果。 Section 结构体定义如下: struct section_64 { /* for 64-bit architectures */ char sectname[16]; /* section名称 */ char segname[16]; /* 所属的segment名称 */ uint64_t addr; /* section在内存中的地址 */ uint64_t size; /* section大小 */ uint32_t offset; /* section在文件中的偏移*/ uint32_t align; /* 内存对齐边界 */ 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) */ uint32_t reserved3; /* reserved */ };常见的__TEXT Segment 的 Section 如下:__text: 可执行文件的代码区域 __objc_methname: 方法名 __objc_classname: 类名 __objc_methtype: 方法签名 __cstring: 类 C 风格的字符串常见的__DATA Segment 的 Section 如下__nl_symbol_ptr: 非懒加载指针表,dyld 加载会立即绑定 __ls_symbol_ptr: 懒加载指针表 __mod_init_func: constructor 函数 __mod_term_func: destructor 函数 __objc_classlist: 类列表 __objc_nlclslist: 实现了 load 方法的类 __objc_protolist: protocol 的列表 __objc_classrefs: 被引用的类列表 __objc _catlist: Category 列表我们可以使用系统自带查看 Mach-O 的工具:file : 查看 Mach-O 的文件类型 nm: 查看 Mach-O 文件的符号表 otool: 查看 Mach-O 特定部分和段的内容 lipo: 常用于多架构 Mach-O 文件的处理总结编译过程主要是分为 词法分析、语法分析、语义分析、生成中间代码、目标代码的生成与优化。链接的过程主要涉及到空间地址的分配、符号的解析、重定位等过程,我们可以对链接的过程通过脚本等加以控制,合并部分段,忽略个别段等。ELF 文件的主要构成,文件头、段表、各种常见段(代码段、数据段、BSS 段、只读数据段等)。关于符号大家也有了基本的认知,常见符号类型(全局符号、外部符号、段名等)。符号表提供的值得关注的信息(符号类型和绑定信息,符号所占位置、符号值),为了解决符号的冲突,C 编译后会在符号前加上下划线,C++ 编译器提供了修饰后的名称。符号分为强符号和 弱符号,对于 C/C++ 语言来说,已初始化的全局符号可以称之为强符号,未初始化的全局符号为弱符号。DWARF 一种通用的调试文件格式,支持源码级别的调试,但是所占体积较大,我们可以使用 Strip 命令来去掉 ELF 文件中的调试信息。Mach-O 是 MacOS/iOS 系统下的执行文件等的格式,有 Header、Load Command、Data 组成。

《程序员的自我修养(链接、装载与库)》学习笔记一(稳固而知新)

温故而知新 静态链接 装载与动态链接 库与运行库计算机发展 编译和链接 可执行文件的装载与进程 内存软件体系结构 目标文件里有什么 动态链接 运行库操作系统 静态链接 Linux 共享库的组织 系统调用与 API内存、线程 Windows PE/COFF Windows 下的动态链接 运行库实现在这书里有一句话,是之前认识的一个研发长者经常挂在嘴边的,今天在书中看到了感触颇多:经常听很多人谈起,IT 技术日新月异,其实真正核心的东西数十年都没怎么变化,变化的仅仅是它们外在的表现,大体也是换汤不换药吧。本书的作者介绍之所以想写这本书,其实主要也是因为不满足于技术的表面,想探索问题的根源。就像上面写的,技术发展日新月异,但是核心的东西却是相对稳定不变的,那么对于从事软件开发的工程师,研究人员,学习这些底层的知识就很有必要了,很多技术都是相通的,认识了底层才更能看清事情的表象,达到触类旁通的效果,毕竟只会写代码不是好程序员,这也是我想学习这本书的原因之一。 除此之外,这本书被大家评价为国人难得写的比较不错的一本计算机技术书籍,并且成为很多大厂人员的必读书籍,肯定是有其魅力所在的,那么是时候认真阅读一下了。 温故而知新 本书的第一章主要分为五个部分,除了第一部分主要是抛出几个问题让大家一起思考之外,剩下部分分别为计算机的发展,软件体系结构,内存和线程。具体知识点分布如图所示:Hello World引发的思考 #include <stdio.h>int main() { printf("Hello World\n"); return 0; }针对这小段代码,本书抛出了一些问题。为什么程序编译了才能运行? 编译器将 C 代码转化为机器码,做了什么? 编译出的可执行文件中有什么,存放机制是什么? ....关于上述问题,本书会从基本的编译、链接开始讲解,然后到装载程序、动态链接等。 计算机基本结构以及CPU的发展 结构 计算机基本结构:CPU、内存、I/O 控制芯片,如下图:CPU 的发展史早期 CPU 的核心频率很低,几乎等于内存频率,每个设备都会有一个 I/O 控制器,链接在一条总线(Bus)上。 随着 CPU 核心频率的提升,内存访问速度低于 CPU,于是增加了处理高速 I/O 的北桥芯片和处理低速 I/O 的南桥芯片。 CPU 速度达到极限后,又增加了多核处理器 SMP。软件体系结构 下图为计算机的软件体系结构分层:计算机软件体系结构是分层的,层与层之间通信的协议,称之为接口。 开发工具与应用程序都使用操作系统的应用程序编程接口。 运行库使用操作系统的系统调用接口。 接口需精心设计,尽量保持稳定,基于接口,具体实现层可以被任意替换。 中间层作为下面层级的包装和扩展,中间层的存在,保证了软硬件的相对独立。 操作系统提供抽象接口,管理软件、硬件资源。操作系统内核作为硬件接口的使用者,需定制硬件规格,硬件逐渐被抽象成一套接口,交给厂商,厂商写各自的驱动程序,硬件交互细节交给操作系统(驱动),程序员无需和硬件打交道。分层 分层设计的思想,其实渗透在计算机的各个领域。其中有我们最熟悉的 OSI 七层网络模型,它从低到高分别是:物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。参考计算机的软件体系结构,OSI 网络模型同样是通过制定层层的通信协议,界定各个分层的具体责任和义务。 OSI七层网络模型 TCP/IP四层概念模型 对应网络协议 应用层 应用层 HTTP、TFTP、FTP、NFS、WAIS、SMTP 表示层 Telnet、Riogin、SNMP、Gopher 会话层 SMTP、DNS 传输层 传输层 TCP、UDP 网络层 网络层 IP、ICMP、ARP、RARP、AKP、UUCP 数据链路层 数据链路层 FDDI、Ethernet、Arpanet、PDN、SLIP、PPP 物理层 IEEE 802.1A、IEEE 802.2 到 IEEE 802.11 那么为什么在架构设计的时候,采用分层设计的实现方案呢?之所以要设计分层,主要有以下几点考虑:降低复杂度,上层不需要关注下层细节。 提高灵活性,可以灵活替换某层的实现。 减小耦合度,将层次间的依赖减到最低。 有利于重用,同一层次可以有多种用途。 有利于标准化。中间层 除了分层设计,中间层的设计,也是非常巧妙的存在。 在计算机软件体系结构中,中间层作为下面层级的包装和扩展,中间层的存在,保证了软硬件的相对独立。 中间层的强大之处在 LLVM 设计的过程中也深有体现。 首先解释下 LLVM: LLVM 是构架编译器(compiler)的框架系统,以 C++ 编写而成,用于优化以任意程序语言编写的程序的编译时间(compile-time)、链接时间(link-time)、运行时间(run-time)以及空闲时间(idle-time),对开发者保持开放,并兼容已有脚本。 LLVM 的大体结构设计如下图:它的设计主要可以分为编译器前端(Frontend)、优化器(Optimizer)、后端和代码生成器(Backend And CodeGenerator)。 笔者理解优化器(Optimizer)不仅仅作为编译过程中的一道工序(做各种优化并且改善代码的运行时间,减少冗余计算),优化器还作为 LLVM 设计最为精妙的地方--中间层。 为什么这么说呢? 前端语法种类繁多,后端硬件架构种类繁多,而正是中间层的存在,使得 LLVM 的架构即可以为各种语言独立编写前端,也可以为任意硬件架构编写后端,实现了开发语言和硬件架构之间相对独立,这才是其真正的强大之处。 类似软件的设计原则的体现 虽说是大的软件体系的结构设计,但是也能让笔者感触到一些软件设计原则的体现,毕竟万物皆对象,而面向对象设计原则如下:设计原则名称 简单定义开闭原则 对扩展开放,对修改关闭单一职责原则 一个类只负责一个功能领域中的相应职责里氏替换原则 所有引用基类的地方必须能透明地使用其子类的对象依赖倒置原则 依赖于抽象,不能依赖于具体实现接口隔离原则 类之间的依赖关系应该建立在最小的接口上合成/聚合复用原则 尽量使用合成/聚合,而不是通过继承达到复用的目的迪米特法则 一个软件实体应当尽可能少的与其他实体发生相互作用感受到了哪些设计原则?这列举一二,当然应该还会有更多。单一职责 分层设计,每层只负责特定的职责,拥有清晰的职责范围。 单一职责 层与层之间交互应该依赖抽象,任何满足每层协议的实体,都可以进行层的替换。 开闭原则 采用类似工厂设计原则,增加一种硬件类型,仅需要增加一种符合硬件规格厂商即可。总结:我们进行日常软件架构设计的时候,其实也可以参考计算机软件设计的一些思想,做一些合适的分层,制定层与层之间的协议,制定合适的中间层。 内存 早期内存采用扇形内存分区,磁盘中所有的扇区从0开始编号,直到最后一个扇区,编号为逻辑扇区号,设备会将逻辑扇区号,转换成真实的盘面、磁道位置。 早期程序直接运行在物理内存,存在的问题地址空间不隔离,一个程序内容容易被另一个程序修改。内存使用效率低,使用中的内存需要等到释放了,才能继续被使用。程序运行的地址不固定,程序重新装载时,内存地址变化了。虚拟地址&物理地址 为了解决地址空间不隔离的问题,引入了虚拟地址的概念,于是地址就分为了两种,虚拟地址空间和物理地址空间。 MMU是内存管理单元,有时也称作分页内存管理单元,MMU在操作系统的控制下负责将虚拟内存实际翻译成物理内存,其与CPU以及物理内存的关系如下图:物理地址空间是由地址总线条数决定的。 虚拟地址是想象出来的,每个进程都拥有独立的虚拟空间,这样做到了进程的地址隔离。 将一段程序所需要的虚拟空间,映射到某个实际的物理地址空间,映射函数由软件完成,实际转换由硬件完成。分段和分页 仅仅增加虚拟地址只能解决地址空间不隔离的问题,剩下两个问题还没解决,于是又引入了分段和分页。分段的基本思路是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间,映射关系如下图所示,通过使用分段,可以解决不隔离和不固定的问题,因为程序A和程序B被映射到了两块不同的物理空间。但是分段内存使用效率低下,内存映射以程序为单位,如果内存不足,被换出的是整个程序,其实程序内的很多数据,都不会被频繁用到,没必要被一起移除内存。分页就是将地址空间分为固定大小的页,进程的虚拟地址空间按页分隔,不常用的放入磁盘,用到时取出来即可,内存使用效率低下的问题得到了解决。内存共享的实现机制 虚拟空间页称之为虚拟页,物理内存的页为物理页,磁盘中的页为磁盘页,不同虚拟页被同时映射到同一个物理页,即可实现内存共享。 Page Fault 虚拟页不在内存中,当需要用到时,就会捕获 Page Fault。 对于 iOS 开发来说,虚拟内存也是通过分页管理的,当访问到某些数据并没有加载到内存时,操作系统就会阻塞当前线程,新加载一页到物理内存,并且将虚拟内存与之对应,这个阻塞的过程就叫做缺页中断,App 启动的时候 Page Fault次数多了会影响启动速度,而我们优化启动速的方式之一就是通过二进制重排,减少 Page Fault 的次数。当 App 的启动过程中如果需要启动符号1、启动符号2、启动符号3、启动符号4,那么 page1,page2,page3,page4 就都需要加载到内存中。而我们可以做的就是通过二进制的重排,将启动符号1、启动符号2、启动符号3、启动符号4放到了同一页,那么只需要 page1加载到内存即可。 大概的优化步骤:通过 Clang 插桩的方式找到 App 启动时,都加载了哪些符号,尽可能的将启动时用到的符号,通过自定义 Order File 放到同一个 启动时加载的 Page 当中,从而减少 Page Fault 的发生次数。线程 线程与进程的区别在于,进程是操作系统分配资源的最小单位,线程是程序执行的最小单位。 线程一些概念线程称之为轻量级进程,有线程 ID,当前指令指针 PC,寄存器,堆栈组成,线程是系统进行调度的最小单位。 各个线程共享程序的内存空间(代码段、数据段、堆),和一些进程级的资源(程序员角度:全局变量、堆、函数里的静态变量、代码)。 线程拥有私有的空间,栈、线程局部存储、寄存器(程序员角度:局部变量、函数参数)。 多处理器的线程并发才是真正并发,单个处理器的线程并发只不过是时间片轮流,调度。 线程至少三种状态:运行(时间片当中)、就绪(离开运行状态)、等待(时间片结束)。 线程调度分为优先级调度(容易出现饿死)和轮转发调度。 Linux 线程相关的操作通过 pthread 库实现。线程安全 多线程程序处于一个多变的环境当中,可访问的全局变量和堆数据随时都可能被其他的线程改变,于是产生了原子操作和锁。原子操作++操作不是原子操作(编译为汇编代码之后,不止一条指令)因为需要经历 ① 读取值到寄存器,② 值+1 ③ 将值写会寄存器。 复杂场景,原子操作就不满足了,需要使用锁,实现同步。锁实现数据访问的原子化,即一个线程未访问结束,另一个线程不能访问,访问数据时获取锁,访问结束释放锁,锁已经占用的时候,获取锁线程就会进行等待,直到锁资源可以重用。二元信号量,有两个状态,占用和非占用,适合只能被为一个线程独占访问的资源。 多元信号量(Semaphore),设置一个初始值 N,可实现 N 个线程并发访问。 互斥量,类似二元信号量,二元信号量可以被其他线程获取到释放,但是互斥量要求哪个线程获取,哪个线程释放。 临界区,区别于互斥量和信号量,互斥量和信号量在其他进程是可见的,但是临界区的范围仅仅限于本进程。 读写锁,对于读取频繁但是偶尔写入的时候,使用信号量和互斥锁效率比较低,读写锁有共享状态和独占状态。下图为读写锁的几种状态:读写锁状态 已共享方式读取 以独占方式读取自由 成功 成功共享 成功 等待独占 等待 等待表中读写锁具体状态解析如下:锁自由状态时,任何方式获取锁都可以获取成功。 共享状态下,共享方式获取可成功,独占方式获取不可成功。 独占状态下,共享方式获取、独占方式获取都不可成功 。 写操作作为独占状态,读操作作为共享状态,读读并发,不用等待,读写互斥,写写互斥。多线程可放心使用的函数之-可重入函数满足如下条件的函数即为可重入函数不使用任何(局部)静态或全局的非 const 变量。 不返回任何 (局部)静态或全局的非 const 变量的指针。 仅依赖于调用方提供的参数。 不依赖任何单个资源的锁(mutex 等)。 不调用任何不可重入的函数。 可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用。加了锁就安全了吗? 作者给我们举了一种情况: x = 0; // Thread1中 lock(); x++; unlock();// Thread2中 lock(); x++; unlock();上文已经介绍了 ++ 操作并非原子操作,编译器为了提高 x 的 访问速度,需要把 x 的值放入某个寄存器里面。 ++ 需要经历 ① 读取值到寄存器,② 值+1 ③ 将值写会寄存器三步。 那么就可能出现这种情况:Thread1读取 x 的值到寄存器 R1(此时 R1 = 0)。 R1 ++,此时 Thread2紧接着还要进行访问,但是 Thread1还没有将 R1值写回 x。 Thread2读取 x 的值到寄存器 R2(此时 R2 = 0)。 Thread2执行 R2 ++。 这时 Thread1将 R1 写回x(问题就来了,此时 R1 = 1,那么就出错了)。还有就是,CPU 发展出了动态调度的功能,在执行程序的时候,为了提高效率,有可能会交换指令的顺序,同样编译器在进行优化的时候,也是可能出现为了提高效率而交换毫不相干的两条相邻指令的顺序。 我们可以使用 volatile 关键字来阻止编译器为了提高速度将一个变量缓存到寄存器而不写回,也可阻止编译器调整操作 volatile 变量的指令顺序,但是 volatile 仅仅能够阻止编译器调整顺便,CPU 的动态交换顺序却没有办法阻止。 这里有个典型的例子,是关于单例模式的: volatile T* pInst = 0; T* GetInstance { if (pInst == NULL) { lock(); if (pInst == NULL) pInst = new T; unlock(); } return pInst; }这是单例模式 double-check 的案例,其中双重 if 可以令 lock 的开销降到最低,但是上面的代码其实是存在问题的,而问题就是来自于 CPU 的乱序执行。 pInst = new T 一共会分为三步: 1、分配内存 2、调用构造函数 3、将内存地址的值赋值给 pInst。 在这三步中 2、3 可能会被 CPU 颠倒顺序,那么就会出现这种情况: pInst 已经不是 NULL 了,但是还没有构造完毕,这时候另一个线程调用单例方法,发现 pInst 不为 NULL,就会将尚未构造完成的对象地址返回,这时候类就有可能产生异常。 为解决这个问题,我们可以使用 barrier 指令,来阻止 CPU 将该指令之前的指令交换到 barrier 之后, POWERPC 体系结构使用 barrier 优化后的单例方法如下: #define barrier() __asm__ volatile ("lwsync") volatile T* pInst = 0; T* GetInstance { if (!pInst) { lock(); if (!pInst) { T* temp = new T; barrier(); pInst = temp; } unlock(); } return pInst; }线程使用模型一对一模型 一个用户使用的线程就唯一对应一个内核使用的线程,这样用户线程就有了和内核线程一致的优点。这种情况下,才是真正的并发,如下图:优点:一个线程受阻时,其他的线程不会受到影响。 缺点1:内核线程数量的限制,导致用户线程受到限制。 缺点2:内核线程调度时,上下文开销较大,导致用户的执行效率降低。多对一模型 多个用户线程映射一个内核线程,如下图:优点:高效的上下文切换和几乎无限制的线程数量。 缺点1:一个线程阻塞,其他线程都将无法执行。 缺点2:多处理器不会对,对多对一模型性能没有明显帮助。多对多模型 将多个用户线程映射到不止一个的内核线程,如下图:优点1:一个线程的阻塞不会导致所有线程阻塞。 优点2:多对多模型,线程的数量没有什么限制。 优点3:多处理器系统上,多对多模型性能有提升。 缺点:实现较为困难。

《学习之道》书评

《学习之道》的作者是芭芭拉·奥克利,她是美国奥克兰大学的工程学教授。她在Coursera上开设了一门课程叫“Learning How to Learn”,即讲如何学习的,该课程非常受欢迎,有200多万人注册学习。该课程地址在这里:https://www.coursera.org/learn/ruhe-xuexi。这门课跟这本《学习之道》内容重合度很高,毕竟都出自同一人,可以作为配套视频来看。如果不想看书又想详细学习课程精髓的话,只看视频也是完全可以的。这本书讲了什么 我感觉可以用Coursera课程上的中文翻译来概括:学习困难科目的实用思维方法。这里突出困难科目其实是指常让人感觉难学的突出逻辑性的理工科,但我认为不仅是「困难科目」,任何新的概念都可以借鉴书中提到的方法进行学习。 本书引用了很多脑科学和心理学的最新研究,很多方法都是有实验依据的,以下是我认为比较有益的内容。 核心概念 专注思维 vs 发散思维 理论知识 我们的大脑有两种截然不同的思维模式 --- 专注模式和发散模式。专注模式是利用理性分析,按照特定步骤解决问题的模式。 发散模式没有特定的步骤,它是弥散在大脑中的,我们常说的灵感一般都来源于发散模式。应用 1、通常情况我们的大脑会在这两种模式之间来回切换。利用这一点我们在学习数学或者计算机等知识时,可以先用专注模式打头阵,掌握特定的概念和原理。对于一些更复杂的问题,不要急于解决,做点别的事情,让这个问题由大脑带入潜意识,虽然我们不在关注它了,但其实大脑还在后台「消化」这个知识。 2、学好数学和计算机科学最好的方法是“每天进步一点点”,不要担心学的慢,细嚼慢咽反而会让你比那些脑子快的同学学习得更加深入。 3、紧凑的专注模式之后,发散模式就是一种对大脑的奖励。通常有以下激发发散模式的方法:去健身房 慢跑、散步 淋个浴或者泡个澡 听音乐,尤其是纯音乐 睡觉 冥想记忆组块 大脑能够记忆独立内容是有限的,为了让它更高效的工作,我们需要将发散的信息碎片组合起来,这个就叫做记忆组块。构建组块的步骤如下,比如我们想学习一本教材: 1、浏览目录,了解这本教材大致有哪些内容。 2、按顺序,深入了解各个章节的内容。在学习新知识的时候有一个重要的方法可以应用:每完成一个章节,试着回想学习材料,即提取练习,这种效果比单纯阅读材料好得多。再强调一遍,看完内容,一定要自己回想这个章节讲了什么东西,最好用自己的话把内容复述一下。 3、章节结束之后做测试,测试是一种高效的巩固学习的方法。 4、了解知识背后的背景知识。 5、再看目录,将这些内容串联起来,形成具有联系的组块。 改变拖延症 预防和改变拖延的方式就是:养成良好的习惯,用好习惯去对抗拖延。 对于习惯有深刻见解的一本书是《习惯的力量》,这里介绍习惯分为四个部分:信号、反应程序、奖励机制、信念。我们可以从这四个阶段入手解决拖延问题。 信号 找到让自己陷入拖延,大脑出窍的信号。比如我们在学习中,因为想看下手机时间,结果拿起手机就玩了几个小时。找到这个信号,下次学习时我们就把手机放到远离自己的地方。 反应程序 反应程序是身体的一项习惯机能,比如看到床就想躺下,踩着橡胶跑道就想跑步,坐到图书馆就想看书一样。我们需要找到把自己代入拖延的反应程序,用更好的程序去替换它。 奖励机制 习惯的强大之处在于它能造成精神层面的欲望,要想克服之前的欲望,就需要用一个新的奖励覆盖之前的欲望,而不是意味的强迫自己戒掉欲望。 信念 相信自己一定可以克服拖延养成好的习惯,可以利用“心理对照”,想象你达成好习惯所获得的成就来为自己增加信心。 还有一些改变拖延的方法和工具值得尝试: 1、任务清单,把当天或者一个阶段重要的事情用清单列出来,每解决一项就划掉一项,用于提醒自己哪些还没有完成。 2、利用番茄工作法,通过集中注意力学习,然后适当休息,之后再来几个循环,以提高自己的效率。 3、对于复杂的工作可以拆分为几个微小任务,确保自己及时得到反馈,享受完成一项任务的愉悦感。 增强记忆力 记忆是学习中非常重要的一种手段,不要认为那些记忆力强的人天生记忆力就好,每个人的记忆能力相差都是不大的。很多记忆高手在训练之前也跟普通人差不多,所以我们也可以通过训练提升自己的记忆力。 视觉图像 图像对记忆很重要,部分原因在于图像与右脑的视觉中枢直接相连。我们把一个或者几个有联系的内容,通过与一些图像建立联系,会有助于提高我们对这类内容的记忆。 记忆宫殿法 这需要我们对需要记忆的内容,编造出一个“宫殿”来,也可以说是一个你熟悉的场景,将这些内容一点一点串起来。 打造生动形象的比喻或类比 把学习概念有趣化,拟人化也有助于我们记忆内容。 间隔重复 需要记忆的内容不会一直留存在大脑中,需要我们间隔性的重复,以加深印象。这个就是艾宾浩斯记忆法,在事物被遗忘前需要复习巩固它。 肌肉记忆 这里的肌肉说的是写和说带动的手部和嘴部肌肉,我们调动越多的身体器官去参与记忆,越有助于我们加深对内容的记忆。 体育锻炼 最近几项动物和人类的实验发现,规律的锻炼可以让记忆力和学习能力得到实质性的提升。锻炼是有助于促进记忆力相关脑区中新神经元的形成,有氧运动和阻力训练都会对学习和记忆发挥强大的效果。 塑造大脑 塑造大脑有以下方法: 1、在科学、数学、技术领域取得成功的专业人士,逐渐习得的一个特质就是学会如何组块,即:提炼关键思想。 2、对于复杂的知识进行简化,并给这些抽象概念赋予生命。一位获得金苹果奖的化学高级讲师说:学习有机化学的难度和去认识一些新人物比起来没什么两样。每一个元素都有自己独特的个性。你越了解它们的性格,就越能读懂它们的处境,并能预知他们在相互作用中会产生的结果3、把所学的知识迁移到新背景中。数学能应用到物理,那能否应用化学或者别的学科,工作中的一些方法能否应用到生活中去。 4、自主学习是一种最深入、最有效的学习方式。能通过自己努力解答的问题就尽量不要向外界寻求答案。 5、锻炼自己去接触那些你敬仰的人,往往他们一句话就会改变你的未来。但要记住好的导师通常都是大忙人,请爱惜他们的时间。 6、考试本身就是一种很好的学习经历。即使没有考试,我们也可以创造一些考试场景用于更好的学习。这里引申一个由难入简做题法。 该方法建议:对于一场考试,我们应该从最难的题目做起,通常从后往前的顺序就是先难后易。对于难题如果我们一两分钟仍没有一丝头绪,就跳过去做下一题,没有思路再跳一题,如果中间突然想到了某道难题的解决思路就赶紧记下来,做完简单题之后再回去处理。 这个方法的核心思想就是把难题的内容让大脑先有个印象,后面做更简单题时,大脑潜意识还留存着难题的内容,说不定这时就灵光乍现,想出了难题的解决思路。相对而言这样可以对大脑利用最大化。 十个学习法则 这是本书最后一章的内容,是对以上内容的总结提炼。 1、运用回想。读完一篇文章,回想一下它讲的到底是什么。 2、自我测试。通过测试检验自己的学习成果。 3、对问题进行组块。搭建组块的过程就是理解问题、练习解题方法的过程。 4、间隔开重复动作。不要过多重复一件事情。 5、在练习中交替使用不同解题技巧。换不同思路去解决问题。 6、注意休息。学习累了就休息,善用大脑潜意识。 7、使用解释性的提问和简单类比。对于难懂的概念先类比一个熟悉的类似的概念,这有助于理解。 8、专注。每个人的时间是平等的,但注意力可不平等,有注意力的时间才是更有价值的,我们需要想办法让自己更专注。番茄工作法就是一种很好的帮助我们专注的方法。 9、困难的事情最先做。最清醒的时候,要去做一天中最困难的事情。 10、心理对照。想象过去的你,对比通过学习能够成就的那个自己。