最近又重读了《程序员的自我修养》一书。有了许多新的感受。书需要重复的读。最开始的一遍很多都看不懂,也觉得没必要知道。现在很多知识都已经有了印象,看书的过程更像是查漏补缺的过程。以下是这本书的部分笔记。
温故而知新
内存
虚拟地址
物理地址对应于物理内存,是实际存在的。虚拟地址是不存在的地址空间,每个进程有自己独立的虚拟空间,然后通过某些映射方式,将虚拟地址转化为实际的物理地址。
虚拟地址主要为了解决使用物理地址时,地址空间不隔离,内存使用效率低,运行地址不确定等问题。对于程序来说,它永远从 0x00000000 开始的连续地址,不需要考虑重定位。
分段
将一个完整的虚拟空间分为若干段,每段都与物理地址上的某个区域对应,叫做分段。虚拟地址中的每个字节都对应于物理空间中的每个字节。
分段的映射过程由操作系统完成,实际的地址转换由硬件完成。
分段解决了地址空间不隔离,运行地址不确定的问题。
分页
分段没有解决内存使用效率低的问题。从磁盘到物理内存的数据置换如果以段为单位,仍将进行大量的置换。因此将段更细的拆分为页,一般为 4kb 大小。
虚拟内存到物理内存的映射关系是以页为单位的。
可以看到不同进程的页可能映射到相同的物理内存中。这会造成两种情况:
- 非活跃进程某页的物理地址上的数据会被置换为活跃进程的某页的数据。
- 两个进程通过共享这一物理地址上的数据进行 IPC (应用进程间通信)
线程
线程的访问权限
线程可以访问进程里的所有数据,甚至包括其他线程的堆栈(如果它知道其他线程的堆栈地址)
Linux 多线程
Linux 中并不存在真正的线程。它的执行实体(无论线程还是进程)都被称为任务。每个任务相当于一个单线程的进程。
它有三种方式创建任务:
- fork
- exec
- clone
其中,fork 产生新任务的速度最快。因为 fork 不会立即复制任务的内存,而是写时复制。
线程安全
线程安全就不得不提锁。iOS 中最常见的有两种锁:
- NSLock,@synchronize:本质上都是互斥锁
- dispatch_semaphone_t:信号量
多元信号量与互斥锁的差别在于:多元信号量可以实现在多个任务完成后的回调,互斥锁显然是无法完成的。
那么二元信号量和互斥锁有什么区别呢?一般情况下我们认为二元信号量和互斥锁相同。其实他们的本质区别是:同一个信号量可以被系统中的一个线程获取,由另一个线程释放。而互斥锁则要求哪个线程获取了互斥锁,哪个线程就要释放,其他线程无法越俎代庖的去释放。如图所示:
由于在主线程中释放二元信号,可以看到第二个任务没有等第一个结束就开始执行了。而如果主线程没有释放信号量,那么第二个任务是要等第一个任务结束,释放信号量,才能执行的。这就是信号量和互斥锁的区别
多线程内部
上面我们所说的多线程是指由操作系统调度的内核线程。而用户实际使用的线程并不是内核线程,而是出于用户态的用户线程。用户线程的数量不一定对应于内核线程的数量。一般有三种线程模型:
- 一对一模型:即一个用户线程对应一个内核线程
- 多对一模型:即多个用户线程对应一个内核线程
- 多对多模型:即多个用户线程对应多个内核线程
对于一对一模型,用户的线程切换也就是内核线程的切换,存在线程切换上下文时的开销。对于多对一模型,用户的线程切换不是内核线程的切换,因此也就不存在切换上下文时的开销。但是对于多对一模型,如果一个用户线程阻塞了,那么内核线程阻塞,其他用户线程也就无法继续执行了。
Linux 中的线程模型是一对一模型
编译和连接
基本流程
当我们 Build 一个项目的时候,一般分为四个步骤:预处理,编译,汇编,链接
预处理主要处理代码中以 #
开头的预编译指令。编译将文件转为汇编代码文件。汇编将汇编代码替换为对应的机器指令。链接将各个汇编后产生的文件链接。
静态链接
静态链接主要包含地址空间分配,符号解析,重定位等步骤。
链接不只是把代码堆叠到一起(代码堆叠可以理解为地址空间分配)。在编译的时候,会有一个符号表记录出现的所有类,全局变量,方法等符号。这些符号所指向的是当前文件的偏移位置,而在链接的时候将会被修改为链接后完整文件的偏移。编译的同时,有一些引用的类,方法等的地址是未知的,这些未知的地址就是一个个空洞。
符号表中的符号以及这些位置的空洞都是需要在链接的时候进行地址偏移修正的。因此,编译的时候还会生成一个重定位表需要地址修正的各个符号以及需要链接的空洞。
符号解析就是将每个符号引用和其对应的符号定义联系起来。也就是修正符号表中符号所指向的全局变量、方法的地址。
重定位就是链接器把每个符号定义与一个虚拟地址联系起来。也就是将代码段中原本未链接的符号的地址修改为链接后修正偏移后的符号的地址。
其实可以更朴素的理解:
链接之后有两部分地址发生变化,一部分是符号指向的全局变量、方法的地址的偏移,还有一部分是各个文件的符号表中符号本身的地址发生偏移。
因此就要有两步操作,第一步纠正全局符号表中符号指向的全局变量或方法的位置,第二步纠正代码中使用的未链接时相对于本文件的符号的地址,将其改为全局符号表中修正过的符号的地址。
目标文件
可执行文件中的段
目标文件即在汇编之后,在链接之前的文件。除了包含编译后的机器指令、数据外,还包含符号表,调试信息等。目标文件将不同属性保存在不同的段(Section)中。
除了 Section,我们再看 Mach-o 文件的时候,经常会碰到 Segment。这两者都可以成为段(虽然 Segment 的意思才是段,Section 的意思是节)。
Section 和 Segment 的区别在于:目标文件是以 Section 为单位分段的,但是加载到内存中是以 Segment 为单位加载。
我们所说的 .text, .data, .bss 指的都是 Section。而代码段,数据段指的都是 Segment。
可执行文件的文件头包含了文件属性,还包含一个段表(Section Table),段表是一个用来描述文件中各个段的数组,包含了文件中各个段的偏移位置及属性。
一个可执行文件一般包含以下的段:
- .text段:即代码段,保存了编译后的执行语句。
- .data段:和 .bss 段统称数据段,保存了已初始化的全局变量和局部静态变量的值。
- .bss 段:存放未初始化的全局变量和局部静态变量。由于是未初始化的变量,所以可执行文件中不会分配空间给 bss 段,只会在段头中记录 bss 段的大小。bss 段空间由系统初始化。bss 段 的目的就是减少程序大小。
除了上面所述的三个段,还有很多常见的段如下图:
自定义段
处了上述的段,我们还可以自定义段:__attribute__((section("name")))
。如:
1 | __attribut__((section("FOO"))) int global = 42; |
就是创建了一个叫 FOO 的段,并且把全局变量 global 放了进去。使用的时候再通过相应的 c 方法获取即可。
阿里的模块化框架 BeeHive 就是通过这种方式全局注册模块信息。这样的好处是不需要维护一个数组取保存,使用的时候直接把自定义段中的所有变量拿到即可。自定义段本身就相当于是一个数组。(当然,其实就把模块信息保存在 .data 段中的一个数组中其实也没什么问题)
ELF 文件结构
ELF 文件结构如下:
ELF 文件头
ELF 文件头主要包含可执行文件的基本信息。包含 ELF 魔数,版本,运行平台,ABI 版本,程序头入口和长度,段表的位置和长度,段的数量等。
关于什么是 ABI。ABI 涵盖了各种细节:如数据类型、大小和对齐;调用约定(控制着函数的参数如何传送以及如何接受返回值);系统调用的编码和一个应用如何向操作系统进行系统调用。
关于什么是魔数。魔数用来确定文件的类型,操作系统在加载可执行文件的时候会确定是否正确。
段表
段表是一个数组,描述了段的段名,段的长度,文件中的偏移,读写权限等属性。
重定位表
每个文件的重定位表都记录了该文件中在链接时需要重定位的符号以及引用的外部符号的信息,以及它们在当前文件的偏移位置。
字符串表
可执行文件中的很多字符串,如段名,变量名保存在这里。使用字符串在表中的偏移来引用字符串。
如图所示,就是字符串表的保存方式,每个字符串以 \0
结尾,这样就只需要给出一个数字下标就可以表示一个字符串了。
符号
什么是符号表
链接就是把变量或函数的地址填到编译后空洞的过程。链接中的变量和函数统称为符号
每一个目标文件都有一个相应的符号表(Symbol Tabel),记录了用到的所有的符号,每个符号有一个对应的值,叫做符号值,对于变量和函数来说,符号值就是它们的地址。
代码中的变量方法最终编译后都替换为了符号表中的偏移,然后在找到对应的符号后,到相应的位置取出对象(可以是 .data 段,字符串表,堆栈等)。
符号表结构
符号表结构如下:
其中 st_info
用来标识符号是局部符号,全局符号,还是弱符号;以及符号的类型,是变量还是数组,还是函数。
一般通过 st_shndx
和 st_value
定位符号的所表示的位置。然后通过 st_size
拿出相应大小的值。
强符号和弱符号
编译期认为函数和初始化了的全局变量为强符号,没有初始化的全局变量为弱符号。也可以通过 __attribute__((weak))
声明弱符号:
1 | extern int ext; |
上面 weak
和 weak2
为弱符号,strong
为强符号。ext
即非强符号也非弱符号,它是一个外部变量的引用。
编译期针对强符号和弱符号制定了一些规则:
- 强符号不可以定义多次。否则报错
- 如果一个符号在某个目标文件是强符号,其他文件是弱符号,那么它的类型以强符号为准。
- 如果一个符号在所有文件中都是弱符号,那么选择占用控件最大的一个符号的类型。
由上可见,符号是不带类型的。
静态链接
链接过程
对于多个输入目标文件,链接器如何将它们合并到一个输出文件的?一般讲一个个文件的相似度段合并:
一般会经历两步:
- 空间和地址分配
- 符号解析和重定位
空间和地址分配
首先扫描所有的目标文件,获得所有各个段的长度属性和位置,然后将目标文件的所有符号定义和符号引用收集起来,放到一个全局符号表中。这一步中,链接器能获取所有段的长度,并将它们合并,计算出合并后的各个段的长度和位置,并建立映射关系。
这其实就是纠正符号指向的变量、方法的位置偏移
符号解析和重定位
ELF 文件中有一个重定位表专门用来保存于重定位相关的信息。重定位表是一个数组,数组的元素非常简单:
r_offset
表示符号在当前文件的偏移量,r_info
表示这个符号的信息。
在重定位的时候,会根据 r_info
的信息,在全局符号表中查找相应的符号。然后把符号的地址写到 r_offset
所指向的其在当前文件的位置偏移。
这其实就是将单个文件中原本相对于本文件的偏移,转化为相对于链接后所有文件的位置的偏移。
ABI
我们把符号修饰标准,变量内存布局,函数调用方式等跟可执行代码二进制兼容性相关的内容称为 ABI。
ABI 不稳定就是说变量的内存布局,函数调用方式等可能会变,也就是编译规则可能会变。
ABI 稳定就是在不同编译器下,编译出来的可执行文件能够被正确的链接到一起。
影响 ABI 的因素很多,比如:
- 内置类型(如 int float 等)的大小和在存储器中的放置方式(大端,小端,对其方式)
- 组合类型(如 struct, union,数组等)的存储方式和内存布局
- 外部符号与用户定义的符号之间的命名方式和解析方式,如函数名 func 在 C 语言的目标文件中是否被解析成外部符号 _func
- 函数调用方式,如参数入栈熟悉怒,返回值如何保持
- 堆栈分布方式,如参数和局部变量在堆栈中的位置,参数传递方式
- 寄存器使用约定,函数调用时哪些寄存器可以修改哪些需要保存
由此可见 ABI 主要影响着编译和汇编的过程:
编译主要影响函数调用相关的汇编代码的生成
链接主要影响生成的可执行文件的内存分布
可执行文件的装载与进程
进程虚拟地址空间
程序被运行起来后,将拥有独立的虚拟地址空间(Virtual Address)。这个虚拟地址空间大小由计算机的硬件平台决定,具体地说是由 CPU 位数决定的。硬件决定了地址空间的最大理论上限,即硬件寻址空间的大小。一个32位处理器的最大寻址空间是 4GB。
对于一个 32 位的处理器,他的最大寻址空间是 4GB。一般在 Linux 系统中将会有 1 GB被划分给操作系统。我们的进程最多使用 3GB 的虚拟空间。也就是说,整个进程在执行的时候,所有的代码,数据包包括 C 语言 malloc() 等方法申请的虚拟空间之和不可以超过 3GB。
装载方式
很多时候,我们的程序实际需要的内存要大于物理能够同时装载的内存。在这种情况下,需要有一种动态装载的方式可以将最常用的部分驻留内存,而将不常用的数据存放在磁盘中。
页映射是现在最常用的动态装载方法。思想史程序用到哪个模块,九江哪个模块装入内存,如果不用就暂不装入,存放在磁盘中。
页映射
将内存和磁盘中的所有数据和指令按照页(page)为单位划分成若干个页,以后所有的装载和操作单位都是页。一般以 4kb 作为一个页。
操作系统角度看可执行文件的装载
创建一个进程,操作系统主要需要做以下三件事情:
- 创建独立的虚拟地址空间。创建一个页目录,用于确定物理内存和虚拟空间的映射关系。
- 读取可执行文件头,建立虚拟空间和和执行文件的映射关系。
- 将 CPU 的指令寄存器设置成可执行文件的入口地址,启动运行
当 CPU 将要执行某个地址的指令,并且该地址是一个空页的时候(即页所指向的物理地址上存放的不是虚拟空间想要的数据和指令),就被认为是一个页错误(Page Fault)。CPU 将控制权交给操作系统。操作系统会在物理内存中分配一个物理页面,或者收回一个已使用的物理页,将虚拟页所需的数据和指令加载进来,然后将进程中的虚拟页和分配的物理页建立映射关系,最后把控制权交还给进程,进程从刚才页错误的位置重新执行。
进程虚拟控件分布
section 和 segment
前文说过,可执行文件的区域是以 section 为单位区分的,装载进内存中是以 segment 为单位区分的。segment 中包含了多个权限相同的 section。
这是因为 ELF 文件被映射时,是以系统的页长度为单位的,每个段在映射时的长度应该是系统页长度的整数倍。如果不是,那么多余部分也要占用一个页。一个 ELF 文件中往往有十几个段,会造成内存空间的浪费。因此把相同权限的 section 合并到一起当做一个 segment 进行映射。
如图所示,在虚拟内存中占用三个页的 .text段和 .init段,会被合并为一个 segment 载入。在物理内存中就只占两个页:
堆和栈
当我们以 Segment 划分的时候,进程可以划分为以下几个区域:
- 代码段
- 数据段
- 堆段
- 栈段
动态链接
动态链接的目的
静态链接浪费内存和磁盘,动态链接在磁盘和内存中只会保存一份,因此节约了内存。动态链接把链接的过程推迟到了运行时再进行。
地址无关代码
什么是地址无关代码
地址无关代码(PIC,Position-independent Code),简单来说就是把需要用到的外部模块的变量和方法的地址抽离出来,作为变量和其他数据放在一起。这样指令部分就和具体的地址无关了。
对于模块间的全局变量和方法,ELF 在数据段中创建了一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT)。当代码需要引用全局变量时,可以通过 GOT 中相应的项间接引用:
共享模块的全局变量
动态库只有一份,那么动态库中的全局变量也是只有一份吗?
不会。当共享对象呗两个进程加载时,他的数据段部分在每个进程中都有独立的副本。从这个角度看,共享对象中的全局对象实际上和定义在程序内部的全局变量没有什么区别。任何一个进程访问的只是自己的那个副本,不会影响其他进程。
延迟绑定
动态链接相对于静态链接性能略差,主要原因有以下两方面:
- 模块间对于全局数据的访问以及函数调用,需要进行复杂的 GOT 定位。
- 程序开始执行时,动态链接器会进行链接工作。动态链接会装载所需的未装载的共享对象,并进行符号查找以及重定位。
针对上面的第二个原因,可以使用延迟绑定(Lazy Binding)的方式,基本思想是当函数第一次被用到时才进行绑定(符号查找,重定位等)。
ELF 使用 PLT(Procedure Linkage Table)实现。不直接使用 GOT 跳转,而是先通过 PLT。(具体代码上如何实现的不探究了)
ELF 将 GOT 拆分成了两个表 .got 和 .got.plt。前者用来保存全局变量的引用地址,后者用来保存函数的引用地址。
动态链接相关结构
.interp 段
.interp 段中保存了可执行文件所需的动态链接器的路
.dynamic 段
保存了动态链接器所需的基本信息。包括依赖于哪些共享对象,动态链接符号表的位置,动态链接符号表的位置,共享对象初始化代码的地址。
.dynsym 段
静态链接中有一个符号表 .symtab(Symbol Table),保存了目标文件的符号和引用。ELF 有一个专门的动态符号表(Dynamic Symbol Table)。由于在动态链接下,需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表(.hash)
动态链接步骤
- 动态链接器自举
- 装载共享对象
- 重定位和初始化
动态链接具体如何实现的不做深入探究。
内存
程序内存布局
程序的内存布局如下:
其中 dynamic libraries 为动态链接库映射区,该区域用于映射装载的动态链接库。Linux 下,系统默认从 0x40000000 开始分配相应的控件,用于共享库载入到内存。
图中的箭头表明了堆和栈的增长方向,可以看到,堆向高地址增长,栈向低地址增长,直到预留的控件用完为止。
栈的调用
栈相关的五个寄存器
栈的调用会用到四个 CPU 寄存器:
- BP(Base Pointer) :分割当前调用的方法和上一个调用的方法
- SP(Stack Pointer):保存栈顶的位置
- SS(Stack Segment ):保存栈段的位置
- IP(Instruction Pointer):保存下一条指令的地址
- AX:保存当前方法的返回值
方法调用与栈的变化关系
一个方法调用流程如下
- 将方法的入参从右至左依次入栈
- 将该方法执行完后返回的地址入栈
- 将当前 bp 指向的地址入栈,并将 bp 指向当前 sp 指向的位置
- 将方法中创建的局部变量压栈(实际是直接给 sp 增加一个固定的值,让局部变量不听的往里 push,而不是 push 一个局部变量,变一次 sp 的指向)
- 方法执行完毕,将返回值的地址赋给 ax 寄存器
- 出栈直到 sp 和 bp 指向同一个地址
- 把保存的返回地址出栈,并赋给 ip
- 出栈直到将方法的入参都 pop 完
系统调用
系统调用原理
特权级与中断
在操作系统中通常有两种特权级别用户模式(User Mode)和内核模式(Kernel Mode),也被成为用户态和内核态。
系统调用运行在内核态,应用程序运行在用户态。内核态可以运行用户态的代码。但是用户态想要运行内核态的代码必须通过中断(interrupt)。中断是一个硬件或软件发出的请求,要求CPU暂停当前的工作转手去处理更重要的事情。
比如按下键盘,键盘上的芯片发送一个信号给 CPU,CPU 收到信号后知道键盘按下了,再去询问键盘被按下的键。这个信号就是一个中断。
中断一般具有两个属性,一个是中断号(从0开始),一个是中断处理程序。内核中,存在一个数组叫做中断向量表,这个数组保存着中断号和中断处理程序的映射。
Linux 使用 int 0x80 来用户触发中断。用户将中断号放入 AX 寄存器中,然后使用 int 0x80 触发中断。中断服务程序就可以通过 AX 获取中断号,调用对应的函数。
可以看到 0x80 是中断向量中的一个,代表着用户触发的中断
切换堆栈
Linux 中用户态和内核态使用不同的栈,当应用程序调用 0x80 号中断时,程序的执行流程从用户态切换到内核态,程序的当前栈也相应的从用户栈切换到内核栈。中断完成后,程序的当前栈从内核栈切换回用户栈。
当 0x80 号中断发生的时候,过程如下:
- 保存当前用户栈的 SP 和 SS 的值
- 找到进程的内核栈(每一个进程都有自己的内核栈)
- 将 SP 和 SS 设置为内核栈的相应值
- 在内核栈中依次亚茹用户态的寄存器 SS, SP 等以供恢复调用
∴切换堆栈就是切换 CPU 的寄存器。
趣谈 Linux 系统(极客时间)
系统初始化
CPU 的组成
CPU 由三部分组成
- 运算单元:只管算,如做加法或者位移
- 数据单元:CPU 内部的寄存器组
- 控制单元:读取执行指令,这个指令会指导运算单元取出数据单元中数据,计算结果,然后放到数据单元的某个地方
x86 的由来
早期各家的 CPU 架构各不相同。直到英特尔的 8086 芯片做到了开放统一和兼容。因此被称为 x86 架构。
BIOS
计算机主板上有一个 ROM(只读存储器),在上面固化了初始化程序,也就是 BIOS。
当电脑刚加电的时候,会做一些重置的工作,将 CS 设置为 0xFFFF,将 IP 设置为 0x0000,所以第一条指令就会指向 0xFFFF0,正是在 ROM 的范围内。在这里,有一个 JMP 命令会跳到 ROM 中做初始化工作的代码,于是,BIOS 开始进行初始化的工作。