此系列文章分为三篇,本文对应CSAPP的第二卷:在系统上运行程序,目标是让读者理解程序与OS的交互关系。
第一卷:程序结构与执行——信息表示、指令、处理器、性能优化、储存层次
第二卷:在系统上运行程序——链接、异常控制流、虚拟内存
第三卷:程序间的交流与通信——系统级IO、网络编程、并发编程
多个文件分别编译,形成若干.o文件,最后链接,形成一个可执行文件。链接的功能就是将多个部分合并为一个可执行程序。
为什么要用链接器?说白了就是把程序拆了。
链接分为两种:
本章基于·Linux x86-64系统,使用标准的ELF-64文件格式(简称ELF)。无论是什么系统,什么格式,基本的链接概念和方法,文件结构都是共通的。
首先记住这两个程序,以后要一直用:
当我们写了这两个程序,在电脑里直接按个F5或者F11就可以编译运行,实际上其中过程很复杂,我们只是简单的叫他编译器,其实上却是一套流程,总称为编译器驱动程序。
分为4个流程:
静态链接,指的是在链接过程把第三方库的.o文件也一起连到可执行目标文件中。动态链接则是在运行的时候才链接。为了辅助链接工作,.o文件是被分成一节一节的,有的放数据,有的放代码,节有很多,携带了各种辅助信息。
具体来说,链接器的作用与工作流程:
有三种目标文件,这三种格式归属于可执行和可连接格式(ELF),统称为ELF二进制。结构类似,略有不同。
ELF文件由一节一节组成,这种分段的结构比较清晰。三类文件都是ELF文件格式,只不过进行了内容的调整,以及去掉一些特殊的节。下面通过可重定位目标文件展示一个总体结构:
后面的几节提供了辅助链接的信息:
最后是一些其他节:
符号表里有当前可重定位目标模块m所定义或者引用的符号,有三种:
注意,符号表里没有非静态局部变量:
符号表是汇编器构造的,链接器只是用这个现成的表罢了。符号表是一个符号数组,每个符号都是一个结构体,描述了一个对象的信息,但是注意,symtab中只储存对象的元数据,而对象本身,甚至是对象的名字都存在其他的节中,比如静态局部变量在data和bss中,而不是在symtab中:
查看main.o的符号表,最下面的三个符号是我们要看的。可以看到,array和main都有节,但是sum函数在外部,所以用UNDEF标识。
链接器的输入是一组可重定位目标模块,每个模块的符号表里都定义了一组符号,分为不同的类型。如果不重名也就罢了,关键是,重名了以后怎么办?
Linux中将符号分为强弱类型:
根据强弱,有三种规则:
这种规则是很合理的,但是当检测到同名变量的时候,只要不是两强就不会报错,甚至不会提醒,所以用的时候会给新手带来困惑,下图中,foo3中是强符号,bar3中是弱符号,所以bar3.c引用了foo3.c的x。关键在于,bar3把这个符号的值修改了,但是用户不知道。所以在main里,把x初始化为15213,结果f函数把x篡改成15212了,而这一切不会提示。
更狗的是,如果有多个弱符号,你完全不知道会选择哪个,这就会造成无法把控的问题。有经验的程序员会保证自己掌控一个强符号。
强弱符号也可以解释符号结构中section字段中,COMMON和.bss有所不同的原因。如果全局变量没初始化,就不能确定是强符号,就有可能是引用,所以编译器把决定权交给链接器。而初始化为0后,就确定是强符号了,编译器直接把变量放到.bss中,符号表的section字段对应.bss节。
所以,程序员使用全局变量的时候非常小心,甚至干脆就不用全局变量:
#include
,我们经常这么干,但是却不知道底层是怎么运行的,这里就解释一下。
一个头文件对应一个静态库,之所以要有库,就是为了代码复用,我只管用,不需要去实现细节。问题来了,.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
来看一下链接器是如何扫描并解析引用的:
可以看到,我们是顺序扫描的,且一个存档只会扫描一次。假如main里引用了.a文件的一个函数,但是.a文件先于main被扫描,则a不会消除main里的U引用,最后就会在U里剩下一个未解析的引用。反之,如果把.a文件放到main后面就没问题了。总之,引用者一定要排在被引用者之前,如果存在互相引用的情况,可以重复写,比如libx.a liby.a libx.a,或者干脆弄一个.a文件里也ok。
重定位是真正的合并,当链接器完成符号解析,就证明所有符号的引用都有其对应的定义,即重定位是可以进行完毕不会出错的。重定位只管无脑去做就行了:
编译过程生成.o文件的时候,因为后面会发生合并,地址会变,所以他是不知道代码和数据最后要被放在哪的,但凡是编译过程中不确定引用目标的时候,就会生成一个重定位条目,告诉链接器在链接的时候去修改条目对应的位置。text节的重定位条目是.rel.text,data节是.rel.data,其他部分不需要进行重定位。
下图为一个重定位条目的格式,一个条目类似于符号,描述了需要重定位的目标,记录了其元数据,并不是目标本身:
这一部分比较直观,就是单纯的拼起来了,重点在后面,略过。
来举个例子:
可以看到,main有两个重定位条目,全局的array需要重定位,是绝对值。call sum需要重定位,是PC相对寻址。
给出call sum的重定位条目信息。
给定节位置和要引用符号的位置:
绝对引用就很简单了,计算量很小,流程一样。比如前面那个汇编代码中,有一条mov指令将array地址放到寄存器%edi中。我们就是要修改mov指令后的值。先给出重定位条目:
赋值后,指令要mov的立即数被我们修改成0x601018,注意小端法表示,以字节为单位反序存放。
可执行目标文件的结构也是ELF结构,只是略有不同,需要注意程序头部表(program header table),这个表记录了可执行文件的连续的片如何被映射到内存段中,可以看到,左图中同一颜色的连续区域与虚拟内存同一颜色段是一一对应的,顺序看起来是反的,其实是一致的。
前面介绍了静态链接。在实际使用中,还有一种动态链接方式,程序在链接的时候,声明动态链接,则只进行部分链接。通过加载时或运行时链接库文件来进一步减少程序体积,这样的话,内存中就可以放更多的程序。
动态链接:
加载时动态链接只是让磁盘文件减少,内存没有减少,该链接的还是链接进去了。但是运行时动态链接可以让不同程序共享一段共享链接库,真正减少内存占用。
库打桩是链接技术的应用,让程序员截获任何的函数调用过程。比如main代码调用了sum,库打桩就可以截获出来这个调用行为。
库打桩可以发生在:
库打桩的应用:
接下来具体解释
第一步:先把函数调用套壳,写一个自己的调用函数。函数会打印出运行状态。
链接时打桩不会修改编译代码,但是会在链接阶段符号消解的时候,把真正的malloc调用替换成我们自己写的函数符号。
技术比较复杂。首先要修改动态库代码源文件,插入库打桩调用的函数。则程序在动态链接加载/调用动态库的时候,会自动执行打桩插入的代码。
可以把共享模块的代码段加载到内存的任何位置,而无需链接器做任何修改
略。
异常控制流是程序加载以后,运行的时候,操作系统对程序的状态控制。
这一部分我讲的比较简略,宏观,因为我学过了操作系统。
CPU内部很复杂,但是对外的表现上很简单。从通电开始,不断读入,执行指令,就像一条河流一样,不停运行。CPU本质上是串行的,顺序执行的,这就是控制流。
为了实现复杂功能,就要对CPU执行防线进行控制,这就是改变控制流。比如跳转,分支,调用,这一部分主要由OS实现,即使是硬件中断,也需要操作系统配合。
在运行的过程中,会有各种信息触发控制流的改变,比如一件事情干完了,发出一个结束的信息,又比如出现了某些错误,这些都会改变CPU的执行,处理这种异常信息的机制就叫异常控制流。这个机制存在于系统的各个层次:
异步异常是CPU外部的异常,此时CPU正在做事件A,结果CPU外面来了一个中断,将你的执行打断,处理完中断后你再继续处理事件A。
这是很常见的,因为中断本身就是一种信号机制,并不是真的异常。
同步异常来源于CPU内部。此时CPU正在执行任务A,结果A的指令有问题(代码有bug),于是CPU就从内部产生一个异常。同步与异步的区别就在于,异常是来源于CPU内部的指令,还是CPU外部的中断。
CPU内部指令执行有三种异常:
为了更好的控制程序,OS将程序以及一些其他的相关信息封装成了一个进程。进程是CPU调度的单位(现在有线程,更加精细)
进程的特点在于:
对于一个CPU,多个进程宏观上是并发执行的,实际上是切来切去的。其中有上下文切换机制,关键在于保护现场。上下文切换是由操作系统管理的,操作系统本身也是一个进程,在Linux中,有0号内核线程和1号线程,负责系统的管理。
系统调用本身是函数,比如fork函数,或者malloc函数。函数一般是有返回值的,所以系统调用最常用的错误处理机制是利用返回值。
系统不会自动处理错误,错了不处理就退出,要想人为处理,就在外面套if条件,更高级的做法是吧这个套了if的系统调用变成一个包装函数:
进程操作本身是系统调用,有丰富的库函数。
进程图用于描述并发程序语句中的偏序关系。有点拗口,实际上就是描述一大堆并发程序之间是先后,还是并列(就是离散数学中的偏序关系):
一个进程图本身是偏序图,偏序图就可以从入口开始进行拓扑排序。通过进程图,可以判断一个执行流是合法的还是非法的:
例子非常简单,你就顺着执行控制流,如果发现其执行顺序是反着箭头来的,那就是非法的。
两个进程之间,经常是并发的,但是很多时候我们又需要他们有先后关系,此时就需要同步机制:
父进程创建子进程后,要管理子进程,否则会出现很大的问题。但是总是会有一些特殊情况,父进程无法管理子进程,比如直接把父进程kill掉了,子进程还在,此时就变成了孤儿/僵尸进程。这个时候,OS会有子进程捕获机制,防止各种进程造成的内存泄漏,资源泄露。
捕获孤儿进程的是内核线程1,又叫1号进程(init),1号进程可以捕获到长时间不动僵死的进程,成为其父进程后kill掉。
首先明白,Linux的进程之间是树的关系,是上下级管理的关系。
shell本身是一种程序,不断读取命令行输入,解析,去进行对应的操作。
shell程序很多,什么bach,zsh之类的,具有不同的特点。
shell有一个问题,就是shell只负责接收前台信息并进行对应操作的调用,而不会跟踪这个操作执行的情况。为了让shell明白操作执行完了,需要在操作执行完后给shell发一个信息,这个信息就是信号机制。
信号类似于中断,通过数字区分类型,但是是软件层面上的,由内核进行宏观管理的,是内核发送给进程的。
内核为每个进程维护一个挂起向量和阻塞向量。这两个向量控制着信号的挂起和阻塞。
发送信号的原因:
但是无论如何,一定是经手内核,由内核控制的。这也很好理解,不能随便来个进程就能把另一个kill掉吧,得服从OS管理。
还有就是,可以对一个进程发信号,也可以对一组进程发信号。需要注意的是,前台进程归属于前台进程组。
在shell里敲命令就可以发送信号。我们平时常用的kill命令就是给进程发信号,目标可以是前台进程和后台进程。
键盘发送的信号仅限于当前shell前台进程组,不会影响后台程序。
平时shell卡住了,就可以ctrl Z终止掉。
写代码,调用kill函数。至于目标进程是否接受,接受的策略,由OS以及目标代码决定。
其实并不存在“接收”。OS是高于其他进程的最高级进程,所以其他进程没有拒绝的权利。进一步说,OS是直接修改进程的上下文的,进程会根据上下文执行操作,修改了上下文后控制流自然就变了。看起来好像是接受了信息。
信号接收前要进行判断,要找到挂起且非屏蔽的信号,去响应。否则就忽略。信号“响应”的方式有三种:
可以看到,很像是中断,但是是软件层次的,而且有一个共通的管理者:OS
如果要修改默认响应行为,需要写一个handler。当然,handler也可以忽略或者选择默认行为,但是我们一般是去指定自己写的处理函数了。
这个handler比较特别,他在逻辑上和进程是并行的,但是handler本身不是进程,只是依附于进程的信号监控者,如果信号来了,他就会处理。
将控制转移到任意位置的强大(但比较危险)用户级机制。略。
因为我学过OS,所以对虚拟内存比较熟悉,假定你已经学过OS,如果没有,可以看我之前的OS文章:
内存管理基本模型
Linux实例分析——内存管理
注意区分几个名词:
以前,CPU使用物理地址,但是这样比较危险。比如我把0地址的内容改了,计算机就直接崩了。
使用虚拟地址后,需要将逻辑地址翻译成物理地址,有很多好处:
虚拟内存远大于物理内存,如何放得下这么多东西呢?自然是放到磁盘上。那这个时候,物理内存装载了一部分磁盘上的虚拟内存,从这个角度来说,物理内存是磁盘的缓存,是虚拟内存的缓存。
下图左边是虚拟内存的页表,其中有一部分在物理内存,是被cached了,另一部分在磁盘,如果内存中没有,访问的时候就要先从磁盘加载到内存。可以感觉出来,这和cache非常相似。
访问的时候,虚拟地址去页表中去找页,如果页在内存中,就是hit,否则就会发生缺页中断。缺页中断会使用置换算法,将一些页淘汰,换入要用的页。之后再重新访问。
上图中还有null页表项,这代表一个空项,将空项对应到虚拟内存的过程就是页分配。注意,只是对应到虚拟内存,并不代表就要交换到物理内存中,也可能只是告诉程序我后面会用,但暂时不用。
虚拟内存之所以有效,是因为局域性。就如同cache一样,局域性发挥了作用。进一步讨论,我们把进程频繁访问的页总称为工作集,如果工作集小于物理内存,那么是一个好的状态。如果工作集大于物理内存,就会频繁发生交换,此时就会产生抖动现象。
现在虚拟内存采用虚拟段页式管理。在每个进程看来,自己都是独占4GB的空间,便于进程管理管理。实际上进程的空间可以以页为单位任意分布,充分利用零碎的内存空间。同时,段的存在也便于进程之间共享内存。
每个程序认为自己独占内存,则每个程序在链接之前都有类似的虚拟地址空间,这样就可以有一个统一的链接方法,简化了链接器的逻辑。加载和运行的时候,也更加方便。
页表项中,不仅仅记录了页的索引,还记录了各种权限标记。因此,在访问的过程中,会有各种特权级验证,这就是保护机制。
记住这张表,后面会用。
这里先假设页表只有一级。下图是一个正常流程,MMU先去cache/内存中的页表中找到页描述符,再去内存中第二次寻找页的内容。
下图是一个异常流程,如果第一次访问页表没有找到页描述符或者在磁盘,就发生缺页中断,从磁盘中提取页到内存。
之后再重新来过。
因为访问页表本身就是一次对内存的访问,相对CPU还是有点慢,所以有大聪明把一部分页表缓存到cache里,变成了TLB快表。
访问块表本质上是访问cache,所以要比访问内存中的页表快很多,而且很多系统都是快表页表并行访问的,所以即使快表没有命中,也不会带来额外的负担。
实际上,页表有很多级。这是因为当虚拟空间很大的时候,页表的占用将会很大,甚至超出内存,此时就出现了二级页表:
二级页表体系下,只有一级页表才会驻留内存,大量的二级页表存放在磁盘中,按需使用。多级页表也是类似。
多级页表具体的地址翻译很复杂,但是也是一个基本功:
本节展示一个实例,实例中有很多计算。
这是一个通过TLB访问的例子,这个TLB是全相联的,没有组索引位。直接用虚拟地址的tab位去匹配TLB的tab位。
如果miss了,就和普通的cache一样。
可以看到,虽然TLB也是cache,但是独立于内存系统的cache,是依附在MMU上的。
TLB的cache也是分级的,甚至也是有d-TLB和i-TLB。
i7的多级地址翻译基本也就是那一套,ppt里的太多了,考试的时候肯定得给图计算。懂基本的转换思路就够了。
最后就是Linux的虚拟地址空间。这个在OS里也说到了:
也是操作系统的内容。将内存中的一片区域映射到磁盘中,通过原型页表进行通信,有两个作用:
之所以要有动态区,是因为有的数据结构只有在运行时才知道大小。动态区域的前提,是OS在虚拟内存空间中预留了空白的地方。运行时用malloc函数在堆上开空间。堆是从低地址像高地址生长,与栈方向相反。
分配器以块为单位,管理空闲与分配的空间:
分配和释放的具体函数:
假设内存按照字对齐,每次分配用整数个字的大小对齐,若干个字构成一个大块。深色字的是分配的,白色的字是释放的。
分配,回收比较简陋,限制很多:
下图也可以看到暴露出的缺点,因此,为了解决这些限制,程序员要考虑很多。
在进行性能改进之前,我们先定义几个指标去衡量:
逻辑上,堆是一片连续的储存。理论上内存利用率可以达到100%,但是现实使用,会产生各种碎片,碎片难以被再次利用,就会削弱内存利用率的上限。
内存碎片分两种:
通过前面的讲解,我们大致理解了堆分配的特性,接下来就要对堆的分配进行宏观管理了。宏观管理涉及到很多方面:
先看一下第一个问题。可以在块的开头,用第一个字存放分割信息,释放的时候,只需要从指针开始,找到下一个分割字就可以了:
跟踪管理块的方法有隐式和显式。
隐式管理,在第一个字里面保存这个块的大小,最后一位当标记位。
凭什么a可以用于标记分配/空闲呢?这是因为按字对齐(4的倍数)以后,size一定是4的倍数,所以其低两位一定是0,既然都已经是0了,不如就利用一下,当成标记位。在读的时候,把这两位用掩码-2屏蔽掉即可。
下图给出例子,8对应两个字的大小,第一个块头字已经被size包括进去了。
这个箭头不是真的指针,只是可以通过大小算出下一个块的地址。
遍历算法思路:
查找算法有多种:
本身没什么说的,主要是副作用:
分配的空间很有可能小于空闲空间,所以剩下的要拆分出去。拆分出去以后,剩下的块要补上块头字。
回收最简单的实现方式就是清空分配标记。但是这个方法没有考虑到合并,不可取。合并的话,目前的数据结构只能进行单向合并,就是去检查下一个块的标记位,如果是0,那就合并。
为了实现双向合并,需要引入新的数据结构。前面只是有块头字,现在块尾部也维护一个字,这个叫边界标记。有了这个字,就可以进行双向合并了。
隐式链表不是真正地链表,指针只是记录一个长度。而显式空闲链表里的Next和Prev是真正的指针,所以是可以乱序的。
将要分配的块拆分,分配的脱离链表,剩下的部分继续串联前后块。
释放策略比较复杂,因为空闲块是可以乱序的,那么把释放的空闲块放到链表的哪个位置呢?据此分出两种思路:
下面介绍一下LIFO的特点:
这是最基本的思路。root指针指向新释放的块,新释放的块变成头结点,原来的头结点变成第二个。
如果涉及到合并操作,释放的块会把周边可以合并的块合并,然后一起作为头结点。
什么意思呢?看下图,本来旁边的一个块是连在链表的某个里的,合并操作将这一块从链表中抽取出来,吞并,之后再作为一个整体按照基本思路插到头结点里去。
也就是说,释放时的合并,会让链表中间被合并的块移动到头节点。
与隐式链表相比:
既然显式链表都可以乱序了,那是不是有更好的组织方式呢?
回顾一下OS中的伙伴系统,有一个空闲页框分配器。本身是一个有序数组,数组上的每个元素都是一个链表。也就是说,我们将不同长度的块,串到不同级别的链表中去了(注意,伙伴系统是严格大小,这个没那么严格)。这样有什么好处呢?可以加快搜索:
这样,可以用logn的时间完成原来线性时间的工作,性能接近最佳适应法。兼顾高吞吐率和较少的内存碎片。
垃圾收集是隐式分配的自动回收机制。
想要彻底的确定哪个块不会被用到,不是很容易实现。但是可以放低要求,我虽然不能确定哪个块还会被用到,但是可以确定哪些块不会被用到,因为我们知道哪些块没有被指针指向。在java中,如果一片内存空间,没有被任何一个变量引用,则这片内存空间就算孤儿,直接回收。
这其中有个关键,内存管理器必须能够区分指针和非指针。本质上来说,指针也只是一个存放地址的变量,所以为了区分,指针变量有额外的限制:
垃圾回收算法主要有两种,其他的不需要去学:
标记清除比较经典,下面着重讲解。
首先把内存当一个图:
C语言基本和上面的思路相同,但是C语言太自由了,不能保证指针一定指向开头。当指针指向一个块的中间,是无法判断这个块是不是已分配的块,也无法判断其大小。要想确定是否分配,就得找到块的开头。
最直白的思路就是去从头遍历,找到这个块的开头。但是效率显然很低,为了解决这个问题,C语言中块中额外设立了左指针和右指针的字,用于构建平衡二叉树,加快搜索。指针用自己的地址在平衡二叉树上找,就可以找到自己块的开头。
即使是这样,C语言的自动回收也和其他语言差很多,所以经常有人说C语言没有垃圾回收机制。
C语言的内存风险很大,需要小心使用。下面给出各种意外情况:
下面给出各种情况的具体例子:
scanf没给地址,而是直接把变量值送进去。scanf将变量值当做地址,这样就会导致另一片空间被写入,而变量本身的空间却没有被写入。
free函数只是把内存释放了,但是内存里还是有其他程序遗留的信息,统称为垃圾值。在你用之前,需要memset清空一下。
优先级可能要考,所以记一下。指针的用法比较多,如果优先级考虑不当,就会出现很大的问题。
多次重复释放块:
好像没啥影响,但是会干扰程序。
内存泄漏:
在没有垃圾回收机制或者很保守的时候,如果只malloc,不free,就会导致内存泄漏。