传送门
此系列文章分为三篇,本文对应CSAPP的第二卷:在系统上运行程序,目标是让读者理解程序与OS的交互关系。
第一卷:程序结构与执行——信息表示、指令、处理器、性能优化、储存层次
第二卷:在系统上运行程序——链接、异常控制流、虚拟内存
第三卷:程序间的交流与通信——系统级IO、网络编程、并发编程
链接
基础
链接器的意义
多个文件分别编译,形成若干.o文件,最后链接,形成一个可执行文件。链接的功能就是将多个部分合并为一个可执行程序。
为什么要用链接器?说白了就是把程序拆了。
- 模块化:不用就没办法把程序拆开,就要写到一个文件里。
- 编译效率:只需要修改一部分程序,只需要将该部分程序重新编译即可,不需要编译所有文件。
- 开发效率:有利于代码复用
链接分为两种:
- 静态链接
- 编译时链接。最基本的链接。
- 动态链接
- 运行时链接。对储存最友好,但最复杂。
- 加载时链接。将静态链接部分延迟
本章基于·Linux x86-64系统,使用标准的ELF-64文件格式(简称ELF)。无论是什么系统,什么格式,基本的链接概念和方法,文件结构都是共通的。
编译器驱动程序
首先记住这两个程序,以后要一直用:
- 对main来说,sum函数是外部引用。对sum.c来说,sum函数是定义
- 对main来说,array是全局变量。
当我们写了这两个程序,在电脑里直接按个F5或者F11就可以编译运行,实际上其中过程很复杂,我们只是简单的叫他编译器,其实上却是一套流程,总称为编译器驱动程序。
分为4个流程:
- cpp。预处理器:宏替换
- ccl。编译器:将C文件变成汇编语言asm文件
- as。汇编器:将ASCII格式的asm文件汇编成二进制的.o文件。
- ld。链接器:将若干.o文件链接成一个可执行目标文件。
静态链接
静态链接,指的是在链接过程把第三方库的.o文件也一起连到可执行目标文件中。动态链接则是在运行的时候才链接。为了辅助链接工作,.o文件是被分成一节一节的,有的放数据,有的放代码,节有很多,携带了各种辅助信息。
具体来说,链接器的作用与工作流程:
- 符号解析。
- 符号就是变量名或者函数名,即函数,public变量,static变量,extern变量。
- 符号定义就是将符号引用和符号定义联系起来,比如这个文件里的一个extern引用了另一个文件的public变量,链接器建立他们之间的联系。
- 符号解析要用到符号表,在.o文件的一节里。
- 重定位。这一步真正进行合并。
- 在此之前,每个.o文件中的符号,都是一个文件内的相对位置,每一个文件内部都是从0地址开始的。现在要合并成一个,变成可执行文件在内存中的绝对位置。
- 这一步的关键在于要重新修改引用的地址。需要修改的地址都被记录在了重定位条目中。
ELF目标文件格式
有三种目标文件,这三种格式归属于可执行和可连接格式(ELF),统称为ELF二进制。结构类似,略有不同。
- 可重定位目标文件。汇编器汇编出来的.o文件,每个.o对应一个.c。其可以用于生成可执行文件,但是本身不可执行。
- 可执行目标文件。若干个.o文件经过链接后生成的文件,可以直接加载到内存中执行,也就是说已经完成了重定位。Unix为为a.out文件,现在的Linux中也有这种传统。
- 共享目标文件。用于动态链接,能在加载时或者运行时装入内存。在Windows上通常是.dll文件,Linux为.so文件
ELF文件由一节一节组成,这种分段的结构比较清晰。三类文件都是ELF文件格式,只不过进行了内容的调整,以及去掉一些特殊的节。下面通过可重定位目标文件展示一个总体结构:
可重定位目标文件
前面的与链接没有太大关系,是程序本身的信息:
- ELF头。储存硬件的配置信息
- 段头表。与虚拟地址有关
- .text节。代码段
- .rodata节。只读数据,比如跳转表
- .data节。全局数据(初始化过)
- .bss节。全局数据(未初始化),不占用磁盘空间,运行的时候再分配。
后面的几节提供了辅助链接的信息:
- .symtab节。符号表,存放全局符号信息,不存放局部变量符号。
- .rel.text。一个列表,每一条都指向.text节里一条指令,每一个调用外部函数或者引用外部全局变量的指令都需要修改目标地址。链接器链接的时候,会修改.rel.text指向的位置。
- .rel.data。一个列表,每一条指向一个全局变量的定义或者引用。同样是链接的时候修改。
最后是一些其他节:
- debug节等特殊节。略过
- 节头表。储存了每个节的起始偏移和大小
符号和符号表
符号表里有当前可重定位目标模块m所定义或者引用的符号,有三种:
- m定义的全局符号。包括全局变量和非静态函数
- m引用的外部符号。这是其他文件中的全局符号。
- m内部的静态全局变量。
- 函数里定义的静态局部变量。如果有两个函数中定义了同名的static局部变量,符号表里会稍作变化以作区分,比如x.1,x.2
注意,符号表里没有非静态局部变量:
- 局部变量,局部变量运行的时候在栈上开。
符号表是汇编器构造的,链接器只是用这个现成的表罢了。符号表是一个符号数组,每个符号都是一个结构体,描述了一个对象的信息,但是注意,symtab中只储存对象的元数据,而对象本身,甚至是对象的名字都存在其他的节中,比如静态局部变量在data和bss中,而不是在symtab中:
- name:实际上是一个32位的char*指针,指向符号名。符号名存在字符串表中。
- type:函数还是数据
- binding:全局还是本地
- reserved:不用。
- section:在哪个节中,是数字索引,比如1,2,3。还有一些特殊的标志
- ABS:绝对符号,进制重定位
- UNDEF:未定义,是对符号的引用,要重定位
- COMMON:未分配,与.bss的不同在于,COMMON只针对全局变量,而.bss是未初始化的静态变量和初始化为0的特殊变量。
- value:指针,指向符号的值
- size:目标的大小
查看main.o的符号表,最下面的三个符号是我们要看的。可以看到,array和main都有节,但是sum函数在外部,所以用UNDEF标识。
链接过程
符号解析
解析规则
链接器的输入是一组可重定位目标模块,每个模块的符号表里都定义了一组符号,分为不同的类型。如果不重名也就罢了,关键是,重名了以后怎么办?
Linux中将符号分为强弱类型:
- 强:函数和已初始化的全局变量。
- 弱:未初始化的全局变量,或者是带了extern的。
根据强弱,有三种规则:
- 两个同名强符号,报错
- 一强多弱,选强的
- 多个弱的,随便选一个
这种规则是很合理的,但是当检测到同名变量的时候,只要不是两强就不会报错,甚至不会提醒,所以用的时候会给新手带来困惑,下图中,foo3中是强符号,bar3中是弱符号,所以bar3.c引用了foo3.c的x。关键在于,bar3把这个符号的值修改了,但是用户不知道。所以在main里,把x初始化为15213,结果f函数把x篡改成15212了,而这一切不会提示。
更狗的是,如果有多个弱符号,你完全不知道会选择哪个,这就会造成无法把控的问题。有经验的程序员会保证自己掌控一个强符号。
强弱符号也可以解释符号结构中section字段中,COMMON和.bss有所不同的原因。如果全局变量没初始化,就不能确定是强符号,就有可能是引用,所以编译器把决定权交给链接器。而初始化为0后,就确定是强符号了,编译器直接把变量放到.bss中,符号表的section字段对应.bss节。
所以,程序员使用全局变量的时候非常小心,甚至干脆就不用全局变量:
- 尽可能用static
- 初始化使得全局符号变成强符号。
- 使用extern显式声明外部引用
静态链接库
#include<stdio.h>
,我们经常这么干,但是却不知道底层是怎么运行的,这里就解释一下。
一个头文件对应一个静态库,之所以要有库,就是为了代码复用,我只管用,不需要去实现细节。问题来了,.a静态库和.o文件有什么不同,.a静态库又和.h头文件有什么关系?为了说明这个问题,我们要从最久远的时代说起。
最早的时候啥也没,只有.o文件。程序员把所有标准库函数都放到一个.o文件中,比如libc.o。然后直接链接到我自己编写的代码中就可以。但是这样有个缺点,不管我用没用到某个函数,我都会把一整个.o文件链接进去,很不划算。而且,一旦修改一个库函数,整个库文件就都得重新编译。
既然不想把所有的函数都连接进去,我可以把每个函数都编译成一个.o文件,用的时候,用多少就链接几个函数:linux> gee main. c /usr /li b/printf. o /usr /lib/ scanf. o … .。但是这样虽然体积小,也便于修改,但是用起来更麻烦了,写程序用的函数多了去了,哪能一个又一个地去写命令行呢?
所以就产生了一种折中的方法,把若干个相关的函数.o文件,封装到一个.a文件里,成为一个模块。这个模块和.o文件很不一样,虽然两者都会把若干函数封装到一起,但是在链接的时候,如果指定.o文件,会把.o整个链接进去,而指定.a文件后,只会把用到的函数的.o文件链接进去。
可以说.a就是用于抽取部分函数的.o文件的。下图中,可以看到若干.o文件被从.a里抽取出来,进行链接。这样完美兼顾了库开发的效率与使用效率。
带有静态链接库的解析过程
下面这两种静态链接写法是等价的,静态链接要输入若干目标文件,链接器会从左到右扫描命令行参数。注意,从左到右就涉及到顺序,这可能会引发错误,后面会说。
linux> gcc -static -o prog2c main2.o ./libveetor.a
linux> gcc -static -o prog2c main2.o -L. -lveetor
来看一下链接器是如何扫描并解析引用的:
- 链接器维护三个集合,一个文件集合,两个符号集合,初始都是空的。
- E:.o目标文件集合。
- U:未解析的符号,即引用了但还没有找到定义的符号。
- D:已解析的符号,即在前面的扫描中已经找到定义的符号。
- 从左到右扫描,要判断参数的文件f是什么类型
- f是.o文件。放到E中,同时把E符号表中的符号根据类型分别放到U和D中
- f是存档文件。遍历存档,如果存档中有一个.o定义了被引用的符号,则把这个.o丢到E中,同时把U中的对应符号移到D中。之后继续扫描,直到把存档都遍历完毕。
- 当扫描完毕后,如果U中还有符号,说明存在未解析的引用(这是个很常见的报错),否则就是可以链接了。
可以看到,我们是顺序扫描的,且一个存档只会扫描一次。假如main里引用了.a文件的一个函数,但是.a文件先于main被扫描,则a不会消除main里的U引用,最后就会在U里剩下一个未解析的引用。反之,如果把.a文件放到main后面就没问题了。总之,引用者一定要排在被引用者之前,如果存在互相引用的情况,可以重复写,比如libx.a liby.a libx.a,或者干脆弄一个.a文件里也ok。
重定位
重定位是真正的合并,当链接器完成符号解析,就证明所有符号的引用都有其对应的定义,即重定位是可以进行完毕不会出错的。重定位只管无脑去做就行了:
- 重定位节和符号定义。链接器将所有同类节都合到一起
- 重定位节里的符号引用。在步骤1中,符号原有的地址会发生改变,此时就要修改对那些地址的引用。哪些地址呢?有专门的重定位条目去描述这些需要修改的部分。
重定位条目
编译过程生成.o文件的时候,因为后面会发生合并,地址会变,所以他是不知道代码和数据最后要被放在哪的,但凡是编译过程中不确定引用目标的时候,就会生成一个重定位条目,告诉链接器在链接的时候去修改条目对应的位置。text节的重定位条目是.rel.text,data节是.rel.data,其他部分不需要进行重定位。
下图为一个重定位条目的格式,一个条目类似于符号,描述了需要重定位的目标,记录了其元数据,并不是目标本身:
- offset:要修改的部分的首地址(比如有一条指令调用sum函数,指令的节内地址就是offset)
- type:如何重定位,类型很多,主要两种
- R_X86_64_PC32:PC相对寻址
- R_X86_64_32:PC绝对寻址
- symbol:标识了被修改引用应该指向的符号(比如一条指令调用sum函数,symbol就对应sum符号条目)
- addend:与一些特殊的偏移调整有关
重定位节
这一部分比较直观,就是单纯的拼起来了,重点在后面,略过。
重定位符号引用
- 外循环是对每个节,每个条目遍历
- 内循环
- 首先是用refptr储存要修改的目标的地址
- 判断重定位类型,根据类型去修改refptr对应的目标,比如把指令call的地址修改了。
来举个例子:
可以看到,main有两个重定位条目,全局的array需要重定位,是绝对值。call sum需要重定位,是PC相对寻址。
重定位相对引用
给出call sum的重定位条目信息。
给定节位置和要引用符号的位置:
计算出要修改目标的新值后修改目标:
这里注意PC相对寻址:
- PC指针指向下一条指令的位置,即4004de+5(指令长度)=0x4004e3
- 将PC压入栈中
- PC+=0x5(call的相对目标),此时PC=0x4004e8
重定位绝对引用
绝对引用就很简单了,计算量很小,流程一样。比如前面那个汇编代码中,有一条mov指令将array地址放到寄存器%edi中。我们就是要修改mov指令后的值。先给出重定位条目:
可以看到是绝对引用:
赋值后,指令要mov的立即数被我们修改成0x601018,注意小端法表示,以字节为单位反序存放。
加载可执行目标文件
可执行目标文件的结构也是ELF结构,只是略有不同,需要注意程序头部表(program header table),这个表记录了可执行文件的连续的片如何被映射到内存段中,可以看到,左图中同一颜色的连续区域与虚拟内存同一颜色段是一一对应的,顺序看起来是反的,其实是一致的。
动态链接共享库
前面介绍了静态链接。在实际使用中,还有一种动态链接方式,程序在链接的时候,声明动态链接,则只进行部分链接。通过加载时或运行时链接库文件来进一步减少程序体积,这样的话,内存中就可以放更多的程序。
动态链接:
- 加载时动态链接。
- so要在刚加载到内存时进行完全的链接。
- 这种方式只是延迟了链接的时间。
- 运行时动态链接。
- 动态链接和静态链接最大的不同在于,动态链接并没有真正的吧.so代码链接到程序中,而是让程序直接调用外部的接口。相当于直接消灭了一部分的链接。
- 如果外部接口不在内存中,就把动态库加载进去,如果在内存中,就直接调用。
- 平时更常用,可以在运行时打桩。
加载时动态链接只是让磁盘文件减少,内存没有减少,该链接的还是链接进去了。但是运行时动态链接可以让不同程序共享一段共享链接库,真正减少内存占用。
库打桩技术
概述
库打桩是链接技术的应用,让程序员截获任何的函数调用过程。比如main代码调用了sum,库打桩就可以截获出来这个调用行为。
库打桩可以发生在:
- 编译时。编译时插入宏替换代码替换套壳调用。
- 静态链接。更改符号消解方式,链接时将引用符号解析成套壳调用符号。
- 加载/运行时。在动态库文件中插入打桩代码,则程序在动态链接时会额外执行一些代码。
库打桩的应用:
- 安全
- 沙箱。可以做成陷阱去捕获病毒
- 幕后加密
- 调试
- 运行时调试。有的bug只会在运行时体现,很难发现。
- 监控程序状态
- 了解函数调用过程
- 跟踪内存分配
打桩举例
这是基准程序。
编译时打桩
- 套壳写自己的函数
- 编译的时候插入宏替换代码,实现函数调用套壳。
接下来具体解释
第一步:先把函数调用套壳,写一个自己的调用函数。函数会打印出运行状态。
第二步:编译的时候将源文件修改,进行宏替换。
链接时打桩
链接时打桩不会修改编译代码,但是会在链接阶段符号消解的时候,把真正的malloc调用替换成我们自己写的函数符号。
加载/运行时打桩
技术比较复杂。首先要修改动态库代码源文件,插入库打桩调用的函数。则程序在动态链接加载/调用动态库的时候,会自动执行打桩插入的代码。
位置无关代码(PIC,Position-Independent Code)
可以把共享模块的代码段加载到内存的任何位置,而无需链接器做任何修改
略。
异常控制流
异常控制流是程序加载以后,运行的时候,操作系统对程序的状态控制。
这一部分我讲的比较简略,宏观,因为我学过了操作系统。
概述
CPU内部很复杂,但是对外的表现上很简单。从通电开始,不断读入,执行指令,就像一条河流一样,不停运行。CPU本质上是串行的,顺序执行的,这就是控制流。
为了实现复杂功能,就要对CPU执行防线进行控制,这就是改变控制流。比如跳转,分支,调用,这一部分主要由OS实现,即使是硬件中断,也需要操作系统配合。
在运行的过程中,会有各种信息触发控制流的改变,比如一件事情干完了,发出一个结束的信息,又比如出现了某些错误,这些都会改变CPU的执行,处理这种异常信息的机制就叫异常控制流。这个机制存在于系统的各个层次:
- 底层次机制
- 异常。包括中断,陷阱等等,操作系统中涉及较多。
- 高层机制
- 进程切换。软硬件配合,是OS的主场
- 信号机制。有OS实现,比如信号量
- 非局部跳转。C语言库实现
异常
概述
- 用户态发生异常。
- 切核心态处理异常
- 处理完毕后继续执行用户态代码。
为了实现异常处理,需要一张异常表格。异常表里记录了对应处理程序的地址,本质上就是个跳转表。发生了异常后,会在异常表中进行匹配,然后跳到对应的程序去处理。
异步异常
异步异常是CPU外部的异常,此时CPU正在做事件A,结果CPU外面来了一个中断,将你的执行打断,处理完中断后你再继续处理事件A。
这是很常见的,因为中断本身就是一种信号机制,并不是真的异常。
同步异常
同步异常来源于CPU内部。此时CPU正在执行任务A,结果A的指令有问题(代码有bug),于是CPU就从内部产生一个异常。同步与异步的区别就在于,异常是来源于CPU内部的指令,还是CPU外部的中断。
CPU内部指令执行有三种异常:
- 陷阱Trap:这个也不算是真正的异常,是软中断机制。
- 比如汇编使用INT n;指令,又或者进行系统调用
- 在切换到核心态执行完处理后,自行恢复用户态,CPU继续执行原来的控制流。
- 故障Faults:意料之外的异常,但是还可以恢复
- 比如缺页中断,保护异常
- 不太严重,很多可以重新执行,也有的直接终止。比如碰到了非法内存,已经跑步下去了,就kill调进程。
- 终止Aborts:严重错误,直接终止
- 比如非法指令,校验错误
进程
概述
为了更好的控制程序,OS将程序以及一些其他的相关信息封装成了一个进程。进程是CPU调度的单位(现在有线程,更加精细)
进程的特点在于:
- 让每个程序看起来独占CPU,但是实际上是切来切去的。CPU和OS提供了进程切换的上下文切换机制,保存一些临时状态,比如寄存器组的值。
- 每个程序看起来独占主存空间,实际上是虚拟内存。
并发与上下文切换
对于一个CPU,多个进程宏观上是并发执行的,实际上是切来切去的。其中有上下文切换机制,关键在于保护现场。上下文切换是由操作系统管理的,操作系统本身也是一个进程,在Linux中,有0号内核线程和1号线程,负责系统的管理。
进程控制
系统调用的错误处理
系统调用本身是函数,比如fork函数,或者malloc函数。函数一般是有返回值的,所以系统调用最常用的错误处理机制是利用返回值。
- 如果返回值是一个正常的,就继续
- 否则,就退出或者执行处理。比如malloc返回一个-1。
系统不会自动处理错误,错了不处理就退出,要想人为处理,就在外面套if条件,更高级的做法是吧这个套了if的系统调用变成一个包装函数:
进程操作函数
进程操作本身是系统调用,有丰富的库函数。
- 获得进程。getpid,getppid
- 进程终止。
- 收到终止信号,比如命令行按下ctrl C
- return。终止只是return的副作用
- exit函数族。单纯终止,没有返回值
- 创建进程。
- fork。父进程返回子进程pid,子进程返回0。两个进程并发,复制一份独立的资源,复制fork后的所有代码,需要加exit阻断。
- vfork。父子进程资源共享,父进程阻塞等待子进程结束。
- 加载程序。exce函数族,有一大堆函数,用法不同,但是都是去执行一个可执行文件
- 和fork不同,是直接把当前程序替换为了目标的可执行程序,是完全的替换,包括代码段,数据段,堆栈,全换了。
- pid不变
- 没有返回值,仅仅是用于调用程序。
进程图
进程图用于描述并发程序语句中的偏序关系。有点拗口,实际上就是描述一大堆并发程序之间是先后,还是并列(就是离散数学中的偏序关系):
- 节点代表语句(函数调用)
- 带箭头的线代表程序执行方向,不可反向执行
- 边或者顶点上都可以加标注
一个进程图本身是偏序图,偏序图就可以从入口开始进行拓扑排序。通过进程图,可以判断一个执行流是合法的还是非法的:
例子非常简单,你就顺着执行控制流,如果发现其执行顺序是反着箭头来的,那就是非法的。
进程监管
进程同步
两个进程之间,经常是并发的,但是很多时候我们又需要他们有先后关系,此时就需要同步机制:
- wait。父进程等待子进程。
- waitpid。等待特定进程,选项很多。
孤儿子进程捕获
父进程创建子进程后,要管理子进程,否则会出现很大的问题。但是总是会有一些特殊情况,父进程无法管理子进程,比如直接把父进程kill掉了,子进程还在,此时就变成了孤儿/僵尸进程。这个时候,OS会有子进程捕获机制,防止各种进程造成的内存泄漏,资源泄露。
捕获孤儿进程的是内核线程1,又叫1号进程(init),1号进程可以捕获到长时间不动僵死的进程,成为其父进程后kill掉。
shell
首先明白,Linux的进程之间是树的关系,是上下级管理的关系。
shell本身是一种程序,不断读取命令行输入,解析,去进行对应的操作。
shell程序很多,什么bach,zsh之类的,具有不同的特点。
shell有一个问题,就是shell只负责接收前台信息并进行对应操作的调用,而不会跟踪这个操作执行的情况。为了让shell明白操作执行完了,需要在操作执行完后给shell发一个信息,这个信息就是信号机制。
信号
信号类似于中断,通过数字区分类型,但是是软件层面上的,由内核进行宏观管理的,是内核发送给进程的。
挂起与阻塞信号
内核为每个进程维护一个挂起向量和阻塞向量。这两个向量控制着信号的挂起和阻塞。
- 挂起,就是信号发送了但没被接受
- 挂起向量是对挂起信号的one-hot编码,所以同一时间只能有一个信号挂起
- 挂起的信号被接收后,one-hot编码清零
- 挂起信号不排队。挂起一个信号以后,再来新的信号会直接被抛弃
- 阻塞,就是进程屏蔽发过来的信号
- 阻塞向量是对信号的掩码,1就是屏蔽,0就是接收
- 可以用sigprocmask函数操作。函数名的mask也揭示了其掩码的本质。
发送信号
发送信号的原因:
- 发生了系统事件,比如内核检测到执行错误或者子进程执行完毕
- 另一个进程调用了kill信号
但是无论如何,一定是经手内核,由内核控制的。这也很好理解,不能随便来个进程就能把另一个kill掉吧,得服从OS管理。
还有就是,可以对一个进程发信号,也可以对一组进程发信号。需要注意的是,前台进程归属于前台进程组。
通过/bin/kill发送信号
在shell里敲命令就可以发送信号。我们平时常用的kill命令就是给进程发信号,目标可以是前台进程和后台进程。
通过键盘发送信号
键盘发送的信号仅限于当前shell前台进程组,不会影响后台程序。
- Ctrl C:SIGINT信号,睡眠
- Ctrl Z:SIGSTP信号,终止进程。
平时shell卡住了,就可以ctrl Z终止掉。
通过kill函数发信号
写代码,调用kill函数。至于目标进程是否接受,接受的策略,由OS以及目标代码决定。
接受信号
其实并不存在“接收”。OS是高于其他进程的最高级进程,所以其他进程没有拒绝的权利。进一步说,OS是直接修改进程的上下文的,进程会根据上下文执行操作,修改了上下文后控制流自然就变了。看起来好像是接受了信息。
信号接收前要进行判断,要找到挂起且非屏蔽的信号,去响应。否则就忽略。信号“响应”的方式有三种:
- 忽略
- 终止进程
- 调用自定义的信号处理函数
可以看到,很像是中断,但是是软件层次的,而且有一个共通的管理者:OS
如果要修改默认响应行为,需要写一个handler。当然,handler也可以忽略或者选择默认行为,但是我们一般是去指定自己写的处理函数了。
这个handler比较特别,他在逻辑上和进程是并行的,但是handler本身不是进程,只是依附于进程的信号监控者,如果信号来了,他就会处理。
总结
- 信号可以从程序中产生
- 信号由OS管理
- 可以自定义信号处理程序
- 编写信号的处理函数要非常小心,可以作为攻击系统的漏洞。
非局部跳转
将控制转移到任意位置的强大(但比较危险)用户级机制。略。
虚拟内存
因为我学过OS,所以对虚拟内存比较熟悉,假定你已经学过OS,如果没有,可以看我之前的OS文章:
概念
地址空间
注意区分几个名词:
- 线性地址空间。值地址值是线性增长的,从0开始
- 虚拟地址空间/逻辑地址空间。从0开始,有尽头的线性地址,对应逻辑上的地址
- 物理地址空间。从0开始,有尽头的线性地址,实际内存中的地址
以前,CPU使用物理地址,但是这样比较危险。比如我把0地址的内容改了,计算机就直接崩了。
使用虚拟地址后,需要将逻辑地址翻译成物理地址,有很多好处:
- 翻译过程中,可以加入各种保护,验证机制,做到安全,高效管理。
- 给程序一个隔离的地址空间,便于编程与OS调度
- 虚拟内存通过交换技术在逻辑上扩大内存空间
虚拟内存的缓存机制
虚拟内存远大于物理内存,如何放得下这么多东西呢?自然是放到磁盘上。那这个时候,物理内存装载了一部分磁盘上的虚拟内存,从这个角度来说,物理内存是磁盘的缓存,是虚拟内存的缓存。
下图左边是虚拟内存的页表,其中有一部分在物理内存,是被cached了,另一部分在磁盘,如果内存中没有,访问的时候就要先从磁盘加载到内存。可以感觉出来,这和cache非常相似。
访问的时候,虚拟地址去页表中去找页,如果页在内存中,就是hit,否则就会发生缺页中断。缺页中断会使用置换算法,将一些页淘汰,换入要用的页。之后再重新访问。
上图中还有null页表项,这代表一个空项,将空项对应到虚拟内存的过程就是页分配。注意,只是对应到虚拟内存,并不代表就要交换到物理内存中,也可能只是告诉程序我后面会用,但暂时不用。
虚拟内存之所以有效,是因为局域性。就如同cache一样,局域性发挥了作用。进一步讨论,我们把进程频繁访问的页总称为工作集,如果工作集小于物理内存,那么是一个好的状态。如果工作集大于物理内存,就会频繁发生交换,此时就会产生抖动现象。
虚拟内存管理机制
现在虚拟内存采用虚拟段页式管理。在每个进程看来,自己都是独占4GB的空间,便于进程管理管理。实际上进程的空间可以以页为单位任意分布,充分利用零碎的内存空间。同时,段的存在也便于进程之间共享内存。
每个程序认为自己独占内存,则每个程序在链接之前都有类似的虚拟地址空间,这样就可以有一个统一的链接方法,简化了链接器的逻辑。加载和运行的时候,也更加方便。
虚拟内存的保护机制
页表项中,不仅仅记录了页的索引,还记录了各种权限标记。因此,在访问的过程中,会有各种特权级验证,这就是保护机制。
地址翻译
记住这张表,后面会用。
这里先假设页表只有一级。下图是一个正常流程,MMU先去cache/内存中的页表中找到页描述符,再去内存中第二次寻找页的内容。
下图是一个异常流程,如果第一次访问页表没有找到页描述符或者在磁盘,就发生缺页中断,从磁盘中提取页到内存。
之后再重新来过。
因为访问页表本身就是一次对内存的访问,相对CPU还是有点慢,所以有大聪明把一部分页表缓存到cache里,变成了TLB快表。
访问块表本质上是访问cache,所以要比访问内存中的页表快很多,而且很多系统都是快表页表并行访问的,所以即使快表没有命中,也不会带来额外的负担。
实际上,页表有很多级。这是因为当虚拟空间很大的时候,页表的占用将会很大,甚至超出内存,此时就出现了二级页表:
- 一级页表:每个描述符指向一个页表,总是驻留内存
- 二级页表:本身是页表,描述符指向一个页。二级页表可能在外存。、
二级页表体系下,只有一级页表才会驻留内存,大量的二级页表存放在磁盘中,按需使用。多级页表也是类似。
多级页表具体的地址翻译很复杂,但是也是一个基本功:
- 首先要经过分段部件得到线性地址。
- 线性地址再逐级提供索引,配合CR3或者页描述符中提取出的基址锁定页描述符,具体过程在操作系统文章里会有详细介绍。
体系
本节展示一个实例,实例中有很多计算。
TLB例子
这是一个通过TLB访问的例子,这个TLB是全相联的,没有组索引位。直接用虚拟地址的tab位去匹配TLB的tab位。
如果miss了,就和普通的cache一样。
Core i7/Linux内存系统
可以看到,虽然TLB也是cache,但是独立于内存系统的cache,是依附在MMU上的。
TLB的cache也是分级的,甚至也是有d-TLB和i-TLB。
i7的多级地址翻译基本也就是那一套,ppt里的太多了,考试的时候肯定得给图计算。懂基本的转换思路就够了。
最后就是Linux的虚拟地址空间。这个在OS里也说到了:
- 进程的虚拟内存分为多段
- 每一段都用一个vm_area结构体来进行规划。
- 多个vm_area结构通过红黑树和单链表串联。
- 进程的mm_struct指向虚拟段链表的头结点。
- 进程的PCB(task_struct)指向mm_struct
- 具体的每段,又是通过多级页表管理的,转换流程就是基本的那一套。
内存映射
也是操作系统的内容。将内存中的一片区域映射到磁盘中,通过原型页表进行通信,有两个作用:
- 跳过文件系统,加速磁盘访问
- 两个进程共享一片映射区,实现进程通信。
动态分配内存
概述
之所以要有动态区,是因为有的数据结构只有在运行时才知道大小。动态区域的前提,是OS在虚拟内存空间中预留了空白的地方。运行时用malloc函数在堆上开空间。堆是从低地址像高地址生长,与栈方向相反。
分配器以块为单位,管理空闲与分配的空间:
- 显式分配器。应用程序用malloc分配,也要用free释放
- 隐式分配器。应用只负责分配,回收是语言里垃圾收集机制的事情,比如JVM的垃圾收集器。
分配和释放的具体函数:
- malloc。老面孔了,返回一个指针。
- 如果是-1就分配失败。
- 内存分配的时候有对齐
- malloc函数族:calloc,带初始化为0,realloc,改变之前分配块的大小
- free。释放malloc族分配的内存。
假设内存按照字对齐,每次分配用整数个字的大小对齐,若干个字构成一个大块。深色字的是分配的,白色的字是释放的。
分配,回收比较简陋,限制很多:
- 必须及时响应,没有额外的机制去调度内存申请
- 不可以移动内存块,不能压缩,会造成碎片。
下图也可以看到暴露出的缺点,因此,为了解决这些限制,程序员要考虑很多。
在进行性能改进之前,我们先定义几个指标去衡量:
- 吞吐率:单位时间内完成的malloc和free数量,比如10秒内5000次malloc,5000次free,就是1000的吞吐量。
- 内存利用率:最大的总负荷/堆大小就是利用率。注意,最大的总负荷可能出现在一段时间内的任意一个时间节点,堆中总会有一个分配空间最多的时候。
内存碎片
逻辑上,堆是一片连续的储存。理论上内存利用率可以达到100%,但是现实使用,会产生各种碎片,碎片难以被再次利用,就会削弱内存利用率的上限。
内存碎片分两种:
- 内部碎片。指块内部的未利用空间,这一部分因为已经被分配,所以是可以度量,监控到的。
- 对齐碎片。给定一个块,假设其大小为40,但是实际上只需要38,因为要对齐,就会有2的内存被浪费。
- 维护堆数据结构的开销。块和块之间要通过数据结构组织,比如加指针,而这个指针是被嵌入块中的,这一部分并不储存数据。
- 策略导致的碎片。
- 外部碎片。堆上连续但是长度不够的块,很难被分配。这一部分不归任何指针管,所以难以度量。
- malloc和free导致的碎片
- malloc和free导致的碎片
空闲块管理
通过前面的讲解,我们大致理解了堆分配的特性,接下来就要对堆的分配进行宏观管理了。宏观管理涉及到很多方面:
- 给一个指针,指针本身只记录地址,那我们怎么知道释放多大空间。
- 怎么跟踪管理空闲块
- 如果分配的区域小于空闲块,怎么把剩下的拆出去
先看一下第一个问题。可以在块的开头,用第一个字存放分割信息,释放的时候,只需要从指针开始,找到下一个分割字就可以了:
跟踪管理块的方法有隐式和显式。
隐式空闲块链表
基本模型
隐式管理,在第一个字里面保存这个块的大小,最后一位当标记位。
凭什么a可以用于标记分配/空闲呢?这是因为按字对齐(4的倍数)以后,size一定是4的倍数,所以其低两位一定是0,既然都已经是0了,不如就利用一下,当成标记位。在读的时候,把这两位用掩码-2屏蔽掉即可。
下图给出例子,8对应两个字的大小,第一个块头字已经被size包括进去了。
这个箭头不是真的指针,只是可以通过大小算出下一个块的地址。
查找空闲块
遍历算法思路:
- 从链表开始搜索直到末尾,每次加上size。注意,size需要用掩码去屏蔽标志位。
- 检查标志位,如果是1,就跳过
- 检查size,如果比我们要求的小(等于也不行,因为要多一个头部),也跳过
查找算法有多种:
- 首次适应法:从链表头开始,第一个可用块。每次都要从头开始,搜索时间与块数成正比
- 下次适应法:从上次搜索的头开始,第一个可用快。比首次适应块,但是容易造成碎片
- 最佳适应:扫描完所有链表,将最适合的块分配出去。很慢,但是会减少碎片。
分配和回收
本身没什么说的,主要是副作用:
- 分配要拆分
- 回收要合并
分配的空间很有可能小于空闲空间,所以剩下的要拆分出去。拆分出去以后,剩下的块要补上块头字。
回收最简单的实现方式就是清空分配标记。但是这个方法没有考虑到合并,不可取。合并的话,目前的数据结构只能进行单向合并,就是去检查下一个块的标记位,如果是0,那就合并。
为了实现双向合并,需要引入新的数据结构。前面只是有块头字,现在块尾部也维护一个字,这个叫边界标记。有了这个字,就可以进行双向合并了。
隐式链表总结
- 数据结构简单
- 分配开销:最差是线性时间
- 释放开销:稳定在常量时间,即使算上合并也是
- 内存使用:一个块需要维护额外的两个字,这属于内部碎片。
显式空闲块链表
隐式链表不是真正地链表,指针只是记录一个长度。而显式空闲链表里的Next和Prev是真正的指针,所以是可以乱序的。
分配与回收
将要分配的块拆分,分配的脱离链表,剩下的部分继续串联前后块。
释放策略比较复杂,因为空闲块是可以乱序的,那么把释放的空闲块放到链表的哪个位置呢?据此分出两种思路:
- LIFO:利用可以乱序的优势,直接插到空闲链表头。速度快,利用率低。
- 地址排序策略:通过排序算法,保证空闲块是按照虚拟地址顺序排的,比较费时间,但是利用率高。
下面介绍一下LIFO的特点:
这是最基本的思路。root指针指向新释放的块,新释放的块变成头结点,原来的头结点变成第二个。
如果涉及到合并操作,释放的块会把周边可以合并的块合并,然后一起作为头结点。
什么意思呢?看下图,本来旁边的一个块是连在链表的某个里的,合并操作将这一块从链表中抽取出来,吞并,之后再作为一个整体按照基本思路插到头结点里去。
也就是说,释放时的合并,会让链表中间被合并的块移动到头节点。
显式链表总结
与隐式链表相比:
- 只需要管理空闲块,所以搜索的时候会更快。
- 需要额外的数据结构与机制,执行的时候更加复杂
- 注意,不会产生额外的碎片。因为额外的指针只会出现在空闲块里,分配的时候就没有了
多级空闲链表(分离的空闲链表)
既然显式链表都可以乱序了,那是不是有更好的组织方式呢?
回顾一下OS中的伙伴系统,有一个空闲页框分配器。本身是一个有序数组,数组上的每个元素都是一个链表。也就是说,我们将不同长度的块,串到不同级别的链表中去了(注意,伙伴系统是严格大小,这个没那么严格)。这样有什么好处呢?可以加快搜索:
- 先使用二分查找,找到符合要求大小的链表
- 在链表里用首次适应法找一个
这样,可以用logn的时间完成原来线性时间的工作,性能接近最佳适应法。兼顾高吞吐率和较少的内存碎片。
垃圾收集
概述
垃圾收集是隐式分配的自动回收机制。
想要彻底的确定哪个块不会被用到,不是很容易实现。但是可以放低要求,我虽然不能确定哪个块还会被用到,但是可以确定哪些块不会被用到,因为我们知道哪些块没有被指针指向。在java中,如果一片内存空间,没有被任何一个变量引用,则这片内存空间就算孤儿,直接回收。
这其中有个关键,内存管理器必须能够区分指针和非指针。本质上来说,指针也只是一个存放地址的变量,所以为了区分,指针变量有额外的限制:
- 指针必须指向块的开始地址,开始地址会有指针的相关信息(比如size记录了这个指针指向的空间大小)
- 指针一旦声明以后,就不要将其转换为非指针类型的变量,否则会导致指针类型的信息丢失。
垃圾回收算法主要有两种,其他的不需要去学:
- 标记清除法。
- 引用计数法。现在比较常用,思路比较直观,实现起来稍微复杂一些,略过不讲。
标记清除比较经典,下面着重讲解。
标记清除法
首先把内存当一个图:
- 每个块都是一个节点
- 指针是一个边
- 根节点是不在堆上,但是指向堆的指针。比如寄存器,栈里的元素,全局变量
根据指针关系,可以构造一个有向图:
- 标记:从根节点开始dfs搜索,在路径上不断标记。搜索完毕以后,凡是可达的块,都会有标记位。如果不可达,就不会被标记。
- 清除:遍历内存
- 如果有标记,就清除,下一次就又可以标记了。
- 如果没有标记,说明不可达,就可以当成垃圾回收了。
C语言保守的回收机制
C语言基本和上面的思路相同,但是C语言太自由了,不能保证指针一定指向开头。当指针指向一个块的中间,是无法判断这个块是不是已分配的块,也无法判断其大小。要想确定是否分配,就得找到块的开头。
最直白的思路就是去从头遍历,找到这个块的开头。但是效率显然很低,为了解决这个问题,C语言中块中额外设立了左指针和右指针的字,用于构建平衡二叉树,加快搜索。指针用自己的地址在平衡二叉树上找,就可以找到自己块的开头。
即使是这样,C语言的自动回收也和其他语言差很多,所以经常有人说C语言没有垃圾回收机制。
内存风险
C语言的内存风险很大,需要小心使用。下面给出各种意外情况:
- 经典的scanf写法bug
- 未初始化数据的垃圾值
- 覆盖内存问题
- malloc的时候,分配的尺寸出问题。下面应该是int*类型,而不是int
- 遍历的时候,可能导致数组越界。C语言越界是不报错的
- 使用不检查长度的函数。比如gets函数,如果让自己的字符串超过缓冲区大小,就可以通过缓冲区溢出技术修改IP指针。
- 指针加减和普通变量加减不同,带有类型信息。
- 自减操作优先级高于*操作,容易出现优先级错误。
- 指针指向错误的区域
- 引用局部变量很危险。局部变量在栈上,当函数返回以后,局部变量已经被清空,换上了其他信息。如果把局部变量地址返回,这个引用就会指向错误的区域。
- 引用已经释放的块,这个和局部变量很像。建议free后再赋NULL。
- 空指针引用。这个一般会被检测出来。
- 多次释放
- 不释放导致内存泄漏
下面给出各种情况的具体例子:
scanf没给地址,而是直接把变量值送进去。scanf将变量值当做地址,这样就会导致另一片空间被写入,而变量本身的空间却没有被写入。
free函数只是把内存释放了,但是内存里还是有其他程序遗留的信息,统称为垃圾值。在你用之前,需要memset清空一下。
覆盖内存:
- malloc的时候,分配的尺寸出问题。下面应该是int*类型,而不是int
- 遍历的时候,可能导致数组越界。C语言越界是不报错的
- 使用不检查长度的函数。比如gets函数,如果让自己的字符串超过缓冲区大小,就可以通过缓冲区溢出技术修改IP指针。
- 指针加减和普通变量加减不同,带有类型信息。
- 自减操作优先级高于*操作,容易出现优先级错误。
优先级可能要考,所以记一下。指针的用法比较多,如果优先级考虑不当,就会出现很大的问题。
错误引用:
- 引用局部变量很危险。局部变量在栈上,当函数返回以后,局部变量已经被清空,换上了其他信息。如果把局部变量地址返回,这个引用就会指向错误的区域。
- 引用已经释放的块,这个和局部变量很像。建议free后再赋NULL。
- 空指针引用。这个一般会被检测出来。
多次重复释放块:
好像没啥影响,但是会干扰程序。
内存泄漏:
在没有垃圾回收机制或者很保守的时候,如果只malloc,不free,就会导致内存泄漏。