《深入理解计算机系统》阅读笔记

第一章 计算机系统漫游

编译系统

hello.c的文件中,每个字符都对应一个ASCII码,整个文件只有ASCII字符,这种文件为文本文件,其他都称为二进制文件
Unix系统上,编译器通过预处理、编译、汇编、链接四个操作,将hello.c文本文件翻译成hello可执行文件(二进制文件)。

编译系统

预处理:将文件中#include包含的.h头文本内容插入到原文本中,获得一个新的文本文件,后缀为i;
编译:将文本文件hello.i翻译为文本文件hello.s,即汇编语言下的文件,如:

main:
  mov $8,%rsp
  call puts
  ret

汇编:将文本文件hello.s翻译为机器语言指令hello.o,被称为可重定位目标程序.
链接:若hello中调用了printf()函数,此函数位于printf.o的目标文件中,链接器将这两个文件合并,生成一个新的hello可执行文件,从而可以被加载到内存中执行

硬件组成

硬件组成

CPU:处理器不断读取PC指向的指令
PC:程序计数器
ALU:算术/逻辑单元,可能执行的操作如下:
-加载:从主存复制字节到寄存器
-存储:从寄存器复制字节到主存
-操作:将两个寄存器的复制到ALU,计算结果存放在新寄存器中
-跳转:
I/O总线:将适配器、主存、CPU等连接起来
主存:临时存储设备,由一组动态随机存取存储器(DRAM)组成,存放程序和程序处理的数据

高速缓存

高速缓存

CPU中的寄存器是主存读取速度的近100倍,为提高数据传输速度,基于数据的局部性原理,CPU中存在一个高速缓冲存储器,一般包括L1、L2、L3三级,由静态随机存取存储器(SRAM)组成。
按照如下顺序,后一级为前一级的高速缓存:
寄存器 -> L1 -> L2 -> L3 -> 主存(DRAM) -> 本地二级存储(磁盘) -> 远程二级存储(分布式文件系统)

操作系统

操作系统结构

操作系统处于应用软件与硬件之间,作用:
-防止应用软件失控,对硬件造成破坏
-通过简单一致的方式使应用软件能够操作硬件
操作系统通过进程、虚拟内存、文件这几个抽象概念实现上述功能,抽象的内容如下:


image.png

进程:对执行程序的一种抽象
-并发:两个任务在同一个cpu上同时交错运行
-并行:两个任务在两个cpu上分别同时运行
虚拟内存:为每个进程提供独占内存地址的假象
文件:


进程的虚拟地址空间

第二章 信息的表示和处理

整数只能表示相对较小的一个范围,但是是精确的;分数范围较大,但是不精确。
逻辑右移:左端补k个0;
算术右移:左端补k个最高有效位的值;
C语言中几乎所有编译器都对有符号数做算术右移,对无符号数做逻辑右移;java中>>为算术右移,>>>为逻辑右移

整数表示

整数分有符号数和无符号数,对于位数表示【x[n-1],x[n-2]...,x[0]】,
无符号数结果为:


无符号数

有符号数(即补码表示)结果为:


有符号数

可以看出有符号数第一位为1时,表示负数,为0时表示正数
将k位的补码x转换为同一个位数表示下的无符号数(即强制转换),
x>0时T2U(X) = x;
x<0时T2U(X) = x + 2^k;
同理,将k位的无符号数x转换为同一个位数表示下的补码(即强制转换),
x>TMax时U2T(X) = x - 2^k;
x<=TMax时U2T(X) = x;

整数运算

k位无符号数加法:

x+y<=UMax时,x+y -> x+y;
x+y>UMax时,x+y -> x+y-2^k;
c语言中不会对无符号数的加法移除抛异常,因此需要注意检测计算结果是否发生溢出。
当且仅当s=x+y

k位有符号数加法:

x+y x+y+2^k;
TMin<=x+y<=TMax时,x+y -> x+y;
x+y>TMax时,x+y -> x+y-2^k;
c语言中不会对有符号数的加法移除抛异常,因此需要注意检测计算结果是否发生溢出。
当且仅当x>0,y>0,x+y<0时,或者x<0,y<0,x+y>0时,计算发生溢出,分别为正溢出和负溢出。

注意:有符号数与无符号数的加法运算是同样的位级表示,因此可以把有符号数看作无符号数做计算,最后将计算结果转换为有符号数。

k位无符号数乘法

对于k位的无符号数x,y,相乘的结果可能需要截断到k位,即:
xy -> (xy) mod 2^k

k位有符号数乘法

对于k位的有符号数x,y,与无符号数相同,相乘的结果可能需要截断到k位,即:
xy -> (xy) mod 2^k

注意:有符号数与无符号数的乘法运算是同样的位级表示,因此可以把有符号数转换为无符号数之后做计算,最后将计算结果转换为有符号数。

乘以常数

编译器会通过移位和加法运算代替乘法,如x15 = x8 + x4 + x2 + x*1,相当于一次左移三位操作、一次左移两位操作、一次左移一位操作和三次加法操作。
由于无符号数和有符号数的加法和位移运算是位级相同的,所以都可以通过这种方式进行计算。

小数表示

定点表示法:

对于二进制表示下的m+n+1位的小数:

小数表示

如 123351.231,表示结果为:12^5 + 22^4 + 32^3 + 32^2 + 52^1 + 12^0 + 12^0 + 22^(-1) + 32^(-2) + 12^(-3)。

浮点表示法:

顶点表示法不能有效表达比较大的数字,如5*2100是101后面接100个0,因此,IEEE浮点标准决定用V=(-1)s * M * 2^E 来表示一个小数:
s:符号位,决定是正数(s=1)还是负数(s=0)
M:尾数,是一个二进制小数,由n位的小数字段frac进行编码
E:阶码,对浮点数加权,由k位的阶码字段exp进行编码
根据阶码的值,可以分为如下三种情况:

情况1:规格化的值

此时,exp的位不全为0,也不全为1。
E = e - bias,e为exp表示的无符号数,bias为常量2^(k-1) - 1,表示偏置值。
M = 1 + f, f为frac作为二进制小数部分、且整数部分为0所组成的值。因为总是可以调整阶码,使得M在(1,2]之间,第一位总是为1,所以可以省略表示,这种表示法称为隐含的以1开头的值

情况2:非规格化的值

此时,exp的位全为0。
E = 1 - bias,bias为常量2^(k-1) - 1,表示偏置值。
M = f, f为frac作为二进制小数部分、且整数部分为0所组成的值,不包含隐含的以1开头的值。

非规格化的值可以表示0和非常接近0的值。

情况3:特殊值

此时,exp的位全为1。
小数域全为0时,表示无穷;否则表示NaN(not a number),即错误值。

8位浮点数的浮点格式与数值表示示例如下图:


浮点数的浮点格式与数值表示

可以看到最小规格化数8/512与最大非规格化数7/512之间是平滑过渡的.

舍入

对于一个数,如1.50,无法准确用二进制小数进行表示,因此需要进行舍入,舍入方法有以下几种:向上舍入、向下舍入、向零舍入、向偶数舍入。
如把1.5进行舍入,结果如下:


舍入方式

第六章 存储器层次结构

储存技术

随机访问存储器(Random-Access Memery, RAM)

静态随机存储器(SRAM):多用于高速缓存存储器
动态随机存储器(DRAM):多用于主存


静态/动态RAM

SRAM和DRAM都是易失性(断电后会丢失信息),SRAM性能优于DRAM。

磁盘存储

磁盘是大数据的机械存储设备,但是读写比DRAM慢了100万倍。磁盘构造如下:


磁盘结构

磁盘读写方式

磁盘的读写速度主要包括三个部分:
寻道时间:移动传动臂到磁道所需的时间
旋转时间:盘片旋转所需的时间
传送时间:读取内容的时间,时间相对来说很短。

磁盘控制器维护着逻辑块号和实际(物理)磁盘扇区之间的映射关系,操作系统需要读取数据时,只需要对逻辑块号进行操作。

固态硬盘(Solid State Disk, SSD)

固态磁盘是大数据的电子存储设备,整体基于闪存,是一种非易失性的大数据存储设备。闪存由数据块组成,数据块由数据页组成,数据是以页为单位进行读写的,只有一个页被整个擦除后,才会重新对这一页进行写入。内部是通过半导体存储数据的,读的速度快于写的速度,而且都比旋转磁盘快很多,但是闪存经过反复写后会磨损,因此SSD更易磨损。

局部性原理

一个编写良好的程序具有良好的局部性,即,更倾向于引用临近于其最近引用过的数据项。局部性通常具有时间局部性和空间局部性这两种形式:良好的时间局部性,指被引用过一次的存储器位置在不远的将来可能被再次引用;良好的空间局部性,指如果一个存储器位置被引用了一次,那么在不远的将来,程序很可能引用附近的一个存储器位置。


局部性示例代码

上图中,代码局部性的排序为:b > c > d

存储器层次结构

存储器层次结构

上一级的存储器以下一级的存储器作为缓存,数据以块为单位进行缓存,两层之间的数据单位保持不变,但各层级之间不一致,如L0与L1之间的块大小为1个字,但L1与L2之间为几十个字节。因为底层的访问时间较长,为了弥补这些访问时间,因此传送的数据块比较大。

缓存命中

程序需要k+1层的数据,获取数据时在k层就成功获得了,那么这种情况称为缓存命中。

缓存不命中

程序需要k+1层的数据,获取数据时在k层失败,k+1层才能获取到,那么这种情况称为缓存不命中。此时会往k层中记录这个块的数据,当k层中的数据块满了的时候,会驱逐一个块并将这个新的块写入,决定替换哪个块的方法被称为牺牲策略

(查询缓存的具体方法参见原书)

第七章 链接

链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存中并执行。链接可执行于编译时,即原代码被翻译为机器代码时;也可以执行于加载时,即被加载器加载到内存中的时候;也可以执行于运行时。

链接器

链接是通过链接器进行的,链接器将多个可重定位执行文件组合为一个可执行文件。


链接器

链接器的目标文件分为三类:
可重定位目标文件:包含二进制代码和目标数据,可以与其他可重定位目标文件合并,生成可执行目标文件
可执行目标文件:包含二进制代码和目标数据,可以直接复制到内存中被执行
共享目标文件:特殊的可重定位目标文件,可以在运行时被动态加载到内存中并进行链接

链接器对目标文件的解析主要包括下面两个部分:
符号解析:目标文件会定义自己的符号和引用外部的符号,符号指函数、全局变量或者静态变量。符号解析的作用是指将目标文件的定义和引用关联起来。
重定位:可重定位文件的起始地址为0,连接器把每个符号与其实际内存地址进行关联,从而重定位这些节,进而修改对这些符号的引用,使得他们指向实际位置。

可重定位目标文件

以.c文件对应的.ELF文件为例:


ELF文件

其中每个方块均称为节,部分节的含义如下:
.text:已编译程序的机器代码
.dodata:只读数据,如printf中的语句串
.data:已初始化的全局C变量(局部C变量保存在栈中,不出现在.data或者.bss中)
.bss:未初始化的全局C变量
.symtab:符号表,存放被定义和引用的函数和全局变量的信息
.rel.text:当链接器把这个目标文件和其他可执行文件结合时,.text节中的很多位置都需要修改。一般来说,任何调用外部函数或者引用全局变量的指令都需要修改,而调用本地函数的指令则不需要修改
.rel.data:被模块定义或引用的任何全局变量的信息

符号解析

每个可重定位目标文件m都有一个符号表(.symtab),包含文件m定义和引用的符号信息,具体分为三类:
-由m定义并由其他模块引用的全局符号,对应于非静态的C函数和全局变量
-由其他模块定义并由m引用的全局符号,对应于其他模块中定义的非静态的C函数和全局变量
-只被m定义和引用的局部符号,对应于static属性的C函数和全局变量

多重定义的全局符号

函数和已初始化的全局符号是强符号,未初始化的全局变量是弱符号,对于多重定义的全局符号,链接器的处理原则为:
-不允许多个强符号存在
-一个强符号与其他弱符号同名,使用强符号
-多个弱符号同名,任选一个弱符号

与静态库链接

对于一组可以被共同使用的标准函数目标文件,需要采用一种方式将其与开发的可重定位目标文件链接起来,链接方法有哪些呢?
方法一:让编译器辨认出标准函数,并直接生成相应的代码。但是这样会显著提高编译器的复杂性,且每次增加新函数时都需要更新编译器版本
方法二:将所有标准函数放到一个可重定位目标文件中。但是这样每个可执行文件都会包括整个标准函数的目标文件,这是对磁盘空间的巨大浪费,且每次增加新的函数时都需要重新编译整个文件
方法三:静态库。相关的函数被编译为单独的目标文件,再封装到一个文件库中,链接时,链接器只复制被用到的目标模块。(注:实际引用方法可参照原书)

重定位

符号解析完成后,代码中的每个符号和函数的引用和定义就都联系起来了,接下来可以通过重定位,对模块进行合并,并为每个符号分配运行时地址。
汇编器生成一个目标模块时,它并不知道数据和代码最终会被放到内存的什么位置,所以它会生成一个重定位条目,告诉链接器目标文件合并时将如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目在.rel.data中。(实际重定位的方案可 参照原书)

动态链接库

使用静态链接库时,开发人员需要了解到库的更新情况,并显式得进行重新链接;另外,对于printf这种比较常用的函数,多个文件都会存在拷贝,这是对内存资源的极大浪费。共享库是用来解决这些问题的新产物。
共享库是在程序执行的时候动态完成加载过程的,而不是像静态库一样,在创建可执行文件时静态完成加载的,流程图如下:


共享库的动态链接

每个库只有一个.so文件,所有引用该库的可执行目标文件共享这个库,另外,共享库的.text节可以被不同的运行中的进行共享。
c语言中有一个接口,允许程序在运行时动态加载和链接共享链接库:


c语言中的动态链接接口

Java中有一个调用规则:java本地方法(JNI),允许Java程序调用本地的c和c++程序,其基本思想是将本地C函数(如foo)编译到一个共享库中(foo.so),当一个正在运行时的Java试图调用foo函数时,解释器利用dlopen或者类似的接口动态加载foo.so,再调用foo。

第九章 虚拟内存

计算机的主存是由M个连续的字节大小的单元组成的数组,每个字节有一个唯一的物理地址,最直接的访问方式是直接使用物理地址进行访问。


物理寻址

现代处理器使用了一种虚拟寻址的方式。CPU通过生成虚拟地址访问主存,这个地址被送到主存之前会先通过cpu中的内存管理单元(memory management unit,MMU),进行查询主存中的查询表来动态翻译地址,查询表的内容由操作系统进行管理。


虚拟寻址

地址空间

地址空间是一个非负整数地址的有序集合,如{0,1,2,3,4,...},一个包含N=2^n个地址的空间叫做n位地址空间,表示为{0,1,2,3,4,...,N -1},现代系统通常支持32位或者64位虚拟地址空间。
一个系统还存在一个物理地址空间,对应于物理内存中的M个字节,{0,1,2,...,M-1}。
需要认识到的是,每个数据对象都需要允许存在多个独立的地址,其中每个地址都选自一个不同的地址空间,这就是虚拟内存的基本思想。

虚拟内存被组织为一个存放在磁盘上的N个连续的字节大小的单元组成的数组,每个字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上的数组被缓存到主存中。类似于存储器结构中的缓存,磁盘(较低层)的数据被分割成块,作为磁盘和主存(较高层)之间的传输单元,VM系统中,虚拟内存被分割为虚拟页,物理内存被分割为物理页,两种页的大小相等。

虚拟页面的集合分为三个不相交的子集:
未分配的:VM系统还为分配(或创建)的页,没有数据与它们关联,不占用任何磁盘空间
未缓存的:未缓存在主存中的已分配页
缓存的:已缓存在主存中的已分配页
示例如下:


VM系统中的主存作为缓存

我们使用DRAM缓存表示主存中缓存的虚拟页。

页表:

页表存放在主存中,操作系统通过MMU中的地址翻译硬件和页表,将虚拟页的地址映射到物理页,每次地址翻译硬件将一个虚拟地址转换为一个物理地址时,都会读取页表。
页表是一个页表条目(Page Table Entry)PTE的数组,可假定为由一个有效位和一个n位地址字段组成。

页表查询时命中示例如下:


VM页命中

当VM根据虚拟地址需要读取PTE2时,查询页表得到有效位为1,表明对应虚拟页在内存中已被缓存,并将从内存中读取

页表查询时缺页示例如下:


VM缺页(之前)

VM查询页表发现有效位为0,内存中未缓存对应的物理页,将触发一个缺页异常。缺页异常处理程序会选择一个牺牲页,如下图中的物理地址PP3,其中已经缓存的虚拟页VP4将被替换为新的虚拟页VP3


VM缺页(之后)

接下来,内核会更新PTE3,并返回,重新启动导致缺页的命令,这是VP3已经缓存在主存中了,因此将能够从页表中查询到虚拟页的数据。

需要注意的是,由于局部性的存在,程序趋向于在一个较小的活动页面集合上工作,这个集合称为工作集。初始开销时,工作集的页面被调度到内存中,接下来对虚拟内存的大多数查询将命中页表中的PTE条目,从而能够直接从内存中获取到数据页。

操作系统为每一个进程维护了一个页表,多个虚拟页面可以映射到同一个共享物理页面上。

每个进程存在一个独立的页表

按需页面调度和独立的虚拟地址空间的结合,对系统中内存的使用和管理具有深远的影响。
简化链接:独立的地址空间允许每个进程的内存映像使用相同的基本格式,对于64位地址空间,代码总是从虚拟地址的0x400000开始,数据段跟在代码段之后,中间存在一大段对齐空白,栈占据用户进程地址空间的最高部分并向下生长。这种一致性极大简化了链接器的设计和实现。
简化加载:为将目标文件中的.data和.text节加载到一个新创建的进程中,加载器为代码和数据段分配虚拟页,将虚拟页标记为无效(即未缓存的)。加载器不会从磁盘复制数据到内存,每个页被初次引用时,VM会自动调入数据页。

实际加载时,每次CPU产生一个虚拟地址,MMU就必需查阅一次页表中的PTE,即从CPU的访问时间(一个周期)减慢到主存的访问时间(几百个周期),因此,在查询页表之前,MMU中维护了一个小的PTE的缓存,称为翻译后备缓冲器(TLB)。TLB是一个小的虚拟寻址的缓存,每一行都保存着一个由单个PTE组成的块,如下图,VPN是用来查询PTE的索引信息,VPO是偏移值。


访问TLB的地址组成部分

因此,实际查询流程如下:


TLB命中

1.CPU产生一个虚拟地址
2+3.MMU从TLB中取出相应的PTE

4.MMU将虚拟地址翻译成物理地址,并发送到高速缓存/主存
5.高速缓存/主存返回数据字到CPU
整个翻译过程都在MMU中,速度很快。

TLB不命中

不命中时,MMU必需从L1缓存中获取PTE。

(注:地址翻译练习过程参照原书9.6)

linux虚拟内存系统

linux为每个进程维护了一个单独的虚拟地址空间


一个linux进程的虚拟内存

其中,最上方区域,即内核虚拟内存中与进程相关的数据结构如下:


image.png

这个数据结构每个进程都不相同,其中的pgd指向第一级页表,vm_area_struct中具体包括以下字段:
vm_start:指向区域的起始处

vm_end:指向区域的结束处
vm_prot:指向区域包含所有页的读写权限
vm_flags:指向区域是共享的还是私有的
vm_next:指向下一个区域

MMU翻译虚拟地址时,会根据这个结构触发缺页异常处理,翻译的地址可能有下面着三种情况:


缺页处理时的内核虚拟内存区域

内存映射

linux将一个虚拟内存区域与磁盘上的一个对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射。虚拟内存
区域可以映射到两种类型:
1.linux文件系统中的普通文件,如可执行文件
2.匿名文件,匿名文件是由内核创建出来的(如调用fork()函数时),磁盘和内存之间没有数据传送。

再看共享对象

一个对象被映射到虚拟内存的一个区域时,这个对象可以作为共享对象,也可以作为私有对象。
如果一个进程将一个共享对象映射到他的虚拟内存中,进程对这个共享对象的任何写操作,对其他将此对象映射为共享对象的进程,都是可见的。


共有对象被共享

而对于私有对象,进程的写操作不会修改磁盘中的对象。私有对象采用了写时复制的技术,进程不做写操作时,多个进程就可以共享物理内存中对象的同一个副本,但当一个进程试图进行写操作时,会触发一个保护故障,被写的那个页会在内存中被复制,生成一个副本,同时,这个进程的页表会被更新。


私有对象被共享

再看fork函数

我们知道fork()函数会创建一个带有自己独立虚拟地址空间的新进程,两个进程的虚拟内存在最初是相同的,当任意一个进程进行了写操作时,通过写时复制的机制,将会创建一个新的页面出来。

再看execve函数

execve函数通过内存映射虚拟内存进行加载和执行程序,如加载a.out函数分以下几步:
删除已存在的用户区域
映射私有区域:为新程序的代码、数据、.bss、和栈创建新的区域结构,这些区域都是私有的、写时复制的
映射共享区域:如标准C库libc.so
设置程序计数器

加载器映射虚拟内存

mmap可以用来在进程的虚拟内存中创建新的虚拟内存区域,并将磁盘文件映射过来


mmap的可视化解释

你可能感兴趣的:(《深入理解计算机系统》阅读笔记)