计算机组成原理
知识地图
#冯诺依曼体系结构
#提高 CPU 性能的方式
- 增加 CPU 核心的数量,通过并行计算来提升性能
- 使用更先进的 CPU 制造工艺
- 加速大概率事件(比如机器学习中,99% 都是向量和矩阵计算)
- 通过流水线提高性能,拆分 CPU 指令执行的过程,细化运行
- 通过预测提高性能
#MIPS 指令
MIPS 指令是一个 32 位的整数,高 6 位叫操作码,代表这条指令具体是一条什么样的指令。
- R 指令一般用来做算数和逻辑操作,里面有读取和写入数据的寄存器的地址。如果是逻辑位移操作,后面还有位移操作的位移量,而最后的功能码,则是在前面的操作码不够的时候,扩展操作码表示对应的具体指令的。
- I 指令通常是用在数据传输、条件分支以及在运算的时候使用的并非变量而是常数。这个时候没有了位移量和功能码,也没有了第三个寄存器,而是把这三个部分直接合并成了一个地址值或者一个常数。
- J 指令就是一个跳转指令,高 6 位之外的 26 位都是一个跳转后的地址。
#CPU 内部寄存器
- 条件码寄存器会记录下当前执行指令的条件判断状态,然后通过跳转指令读取对应的条件码,修改 PC 寄存器内的下一条指令的地址,最终实现 if…else 以及 for/while 这样的程序控制流程。
#ELF 文件
链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后在根据重定位表,把所有不确定要跳转地址的代码,根据符号表里存储的地址,进行一个修正。最后,把所有目标文件的对应段进行一个合并,变成了最终的可执行文件。
装载器不再需要考虑地址跳转的问题,只需要解析 ELF 文件,把对应的指令和数据,加载到内存里面供 CPU 执行就可以了。
Linux 下可执行文件的格式是 ELF
,Windows 下可执行文件的格式是 PE
, Linux 下的装载器只能解析 ELF 格式而不能解析 PE 格式。
TIP
Linux 下著名的开源软件 Wine,就是通过兼容 PE 格式装载器,使得我们能直接在 Linux 下运行 Windows 程序。
Windows 的 WSL 可以解析和加载 ELF 格式的文件。
#程序装载
#虚实转换
TIP
我们把指令里用到的内存地址叫做虚拟内存地址,实际在内存硬件里面的空间地址叫做物理内存地址。
对于任何一个程序来说,它看到的都是同样的内存地址,我们维护一个虚拟内存到物理内存的映射表,这样实际程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。因为是连续的内存地址空间,所有我们只需要维护映射关系的起始地址和对应的空间大小就可以了。
#内存分段/页
如上图所示,内存分段会带来内存碎片的问题,解决办法是内存交换。
硬盘的访问速度要比内存慢很多,如果内存交换的时候,交换的是一个很占内存空间的程序,这样整个机器都会显得卡顿。解决的办法是内存分页,在需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点。
分段是将一整段连续连续的空间映射给程序,分页是把整个物理内存空间切成一段段固定尺寸的大小。对应的应用程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间称为页。从虚拟内存到物理内存的映射,不再是拿整段连续内存的物理地址,而是按照一个一个页来的。在 Linux 下,我们通常只设置成 4KB。
由于内存空间都是预先划分好的,也就没有了不能使用的碎片,而只有被释放出来的很多 4KB 的页。即使内存空间不够,需要让现有的、正在运行的其它程序通过内存交换释放出一些内存页来,一次性写入磁盘的也只有少数的一个页或者几个页。
更进一步,分页的方式使得我们在装载程序的时候,不再需要一次性把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
#动态链接
想要在程序运行的时候共享代码,有一定的要求,就是这些机器码必须是地址无关的,即编译出来的共享库文件的指令代码是地址无关码。常见的地址相关的代码:
- 绝对地址代码
- 利用重定位表的代码
#PLT(Procedure Link Table) 和 GOT(Global Offset Table)
在动态链接对应的共享库时,我们在共享库的 data section 里面,保存了一张 GOT 表,GOT 表在内存里和对应的代码段之间的偏移量始终是确定的。虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。GOT 表里的数据,是在我们加载一个个共享库的时候写进去的。不同的进程,调用同样的 lib.so,各自 GOT 里面指向最终加载的动态链接库里面的虚拟内存地址是不同的。这有点像 C 语言里用函数指针来调用对应的函数,并不是通过预先已经确定好的函数名来调用,而是利用当时它在内存里面的动态地址来调用。
#二进制编码
TIP
原码和补码的区别:
原码使用最高位来表示符号,且最高位不参与数值运算;
补码也使用最高位表示符号,但是最高位也会参与数值运算。
用补码表示负数,使得整数相加变得更加容易,不需要特殊处理,也意味着它们是同样的电路。
#Unicode 和 UTF-8
Unicode 是一个字符集(Charset),包含了 150 种语言的 14 万个不同的字符。
UTF-8 是一种字符编码(Character Encoding),主要解决如何使用二进制来表示字符集里的字符。
如果某些字符在 Unicode 中并不存在,那么 Unicode 会统一把这些字符记录为 U+FFFD 这个编码,如果使用 UTF-8 的格式存储下来,就是 \xef\xbf\xbd。如果连续两个这样的字符放在一起,\xef\xbf\xbd\xef\xbf\xbd,这时如果用 GB2312 的方式进行 decode,就会变成“锟斤拷”。
#加法器
#半加器
#全加器
#8 位加法器
#乘法器
先拿乘数最右侧的个位乘以被乘数,把结果写入用来存放计算结果的开关里面,然后乘数左移一位,乘数右移一位,仍然用乘数去乘以被乘数,把结果加到刚才的结果上。反复重复这一步骤,直到不能再左移和右移位置。这样仅仅需要简单的加法器、一个左移一位的电路和右移一位的电路,就能完成整个乘法。
这种乘法器中的每一组加法都要依赖上一组加法后的结果,整个算法是顺序的。
#示例 13x9 (二进制 1101 x 1001)
#定点数
用 4 个比特来表示 0~9 的整数,那么 32 个比特就可以表示 8 个这样的整数。然后我们把最右边的 2 个 0~9 的整数当成小数部分;把左边 6 个 0~9 的整数当成整数部分,这样我们就可以用 32 个比特来表示从0~999999.99 这样 1 亿个实数。
这种用二进制来表示十进制的编码方式叫作BCD编码,常用在超市、银行这样需要小数记录金额的情况里。
WARNING
定点数的表示方式有几个缺点:
1. 表示方式有点“浪费”
2. 没有办法同时表示很大的数字和很小的数字
#浮点数
计算机使用科学计数法来表示实数,浮点数的科学计数法表示有一个 IEEE 标准,它定义了两个基本的格式,一个是用 32 比特表示单精度的浮点数,另外一个是用 64 比特表示双精度的浮点数。
TIP
在浮点数里,我们不像整数分符号数和无符号数,所有额浮点数都是有符号的。
浮点数可以表示成这样:
指数位 e
:8 个比特能够表示的整数空间是 0~255,IEEE 规定使用 1~254 映射到 -126~127 这 254 个有正有负的数上。因为浮点数不仅要表示很大的数,也要表示很小的数,所以指数位也会有负数。指数位的其他取值见下表:
e | f | s | 浮点数 |
---|---|---|---|
0 | 0 | 0 or 1 | 0 |
0 | !=0 | 0 or 1 | 0.f |
255 | 0 | 0 | 无穷大 |
255 | 0 | 1 | 无穷小 |
255 | !=0 | 0 or 1 | NAN |
#示例:浮点数的二进制转化(以 9.1 为例)
-
整数部分转化为二进制:1001
-
小数部分转化为二进制:
-
整数和小数部分拼在一起,
1001.000110011...
-
科学计数法表示:
-
符号位 s=0;有效位f=001000110011…,因为 f 最长只有 23 位,于是 f=00100011001100110011001;指数应该是 3, 但是因为指数有正有负,指数位在 127 之前代表负数,之后代表正数,那 3 对应就是 130,转化成二进制就是 e=10000010
#浮点数的加法和精度损失
浮点数加法原则:先对齐、再计算
指数位较小的数,需要将有效位右移,在右移的过程中,最右侧的有效位就被丢弃掉了,这会导致指数位较小的数,在加法发生之前就丢失精度。两个相加数的指数位差的越大,位移的位数越大,可能丢失的精度也就越大。
32位浮点数的有效长度一共只有23位,如果两个数的指数位相差23位,较小的数右移24位之后,所有的有效位就都丢失了。
public class FloatPrecision {
public static void main(String[] args) {
float sum = 0.0f;
for (int i = 0; i < 20000000; i++) {
float x = 1.0f;
sum += x;
}
System.out.println("sum is " + sum);
}
}
对应的输出结果是:sum is 1.6777216E7
,因为加到1600万之后的加法因为精度丢失都没有了。
解决办法是,在每次的计算过程中,都用一次减法,把当前加法计算中损失的精度记录下来,然后在后面的循环中,把这个精度损失放在要加的小数上,再做一次运算。
public class KahanSummation {
public static void main(String[] args) {
float sum = 0.0f;
float c = 0.0f;
for (int i = 0; i < 20000000; i++) {
float x = 1.0f;
float y = x - c;
float t = sum + y;
c = (t-sum)-y;
sum = t;
}
System.out.println("sum is " + sum);
}
}
#指令+运算=CPU
#指令周期
计算机每执行一条指令的过程可以分解成如下几个步骤:
- Fetch(取得指令):从 PC 寄存器里找到对应的指令地址,根据指令地址从内存里把具体的指令,加载到指令寄存器中,然后把 PC 寄存器自增。
- Decode(指令译码):根据指令寄存器里面的指令,解析成要进行什么样的操作,具体要操作哪些寄存器、数据或者内存地址。
- Execute(执行指令):进行算数逻辑操作、数据传输或者直接的地址跳转。
一轮 Fetch - Decode - Execute 的时间称为指令周期。
#机器周期
机器周期也叫 CPU 周期,CPU 内粗的操作速度很快,但是访问内存的速度却要慢很多,我们把从内存里面读取一条指令的最短时间称为 CPU 周期。
#通过 D 触发器实现存储功能
#RS 触发器
真值表:
S | R | Q |
---|---|---|
1 | 0 | 1 |
0 | 1 | 0 |
0 | 0 | Q |
1 | 1 | NA |
往这个电路里加两个与门和一个时钟信号,就可以实现一个利用时钟信号来操作一个电路了。
#D 触发器
把 R 和 S 两个信号通过一个反相器合并,我们可以通过一个数据信号 D 进行 Q 的写入操作。
CPU 里面的寄存器可以直接通过 D 型触发器来构造。
#自动计数器
这个自动计数器可以拿来当作 PC 寄存器。
加法计数、内存取值,乃至后面的命令执行,最终其实都是由我们一开始讲的时钟信号来控制执行时间点和先后顺序的,这也是我们需要时序电路最核心的原因。
在最简单的情况下,我们让每一条指令,从程序计数,到获取指令、执行指令,都在一个时钟周期内完成,这样的 CPU 设计称为单指令周期处理器。
#2-1 选择器
一个反向器只能有0和1两个状态,我们只能从两个地址中选择一个。如果输入的信号有三个不同的开关,我们就能从8个地址中选择一个了,这样的电路,就叫3-8译码器。现代的计算机,如果 CPU 是 64 位的,就意味着寻址空间是 ,那么我们就需要一个有 64 个开关的译码器。
当我们把译码器和内存连到一起时,通常会组成这样一个电路:
译码器的本质就是从输入的多位信号中,根据一定的开关和电路组合,选择出自己想要的信号。除了能够进行“寻址”之外,还可以把对应的需要运行的指令码,同样通过译码器,找出 opcode 以及后面对应的操作数或者寄存器地址。
#构造一个最简单的 CPU
#现代处理器的流水线设计
单指令周期处理器的时钟频率没法设的太高,因为有些复杂指令没有办法在一个时钟周期内运行完成。那么在下一个时钟周期到来,开始执行下一条指令的时候,前一条指令的执行结果可能还没有写入到寄存器里面,那下一条指令读取的数据就是不准确的,就会出现错误。
五级流水线就表示我们在同一个时钟周期里面,同时运行五条指令的不同阶段,这个时候虽然执行一条指令的始终周期变成了5,但是我们可以把CPU的主频提的更高了。我们不需要确保最复杂的那条指令在时钟周期里面执行完成,而只要保障一个最复杂的流水线的操作,在一个时钟周期内完成就好了。
每一条指令从开始到结束拿到结果的时间并没有变化(即响应时间没有变化),但是同样时间内,完成的指令数增多了(即吞吐率上升了)。
#超长流水线的性能瓶颈
每一级流水线对应的输出都要放到流水线寄存器里面,然后在下一个时钟周期,交给下一个流水线级去处理。随着流水线的不断加深,这些操作操作占整个指令的执行时间的比例就会不断增加。所以一味地增加流水线深度并不能无限地提高性能。
#冒险 (Hazard)
#结构冒险
结构冒险,本质上是一个硬件层面的资源竞争问题。上图中,在第 1 条指令执行到 MEM 阶段的时候,流水线里的第 4 条指令,在执行指令 Fetch 的操作。访存和取指令都要进行内存数据的读取,而我们的内存只有一个地址译码器,只能在一个时钟周期里面读取一条数据。
#哈佛结构
针对结构冒险的解决思路是增加资源,将内存拆分成两部分(存放指令的程序内存和存放数据的数据内存),各自有独立的地址译码器。这样把内存拆成两个部分的解决方案在计算机体系结构里称为哈佛架构。
哈佛结构虽然解决了资源冲突的问题,但是失去了灵活性,我们没有办法根据实际的应用去动态分配内存了。
现代了 CPU 虽然没有在内存层面进行对应的拆分,却在 CPU 内部的高速缓存部分进行了区分,把高速缓存分成了指令缓存和数据缓存。
#指令对齐
不同类型的指令在流水线的不同阶段会进行不同的操作,比如 LOAD
指令需要从内存里读取数据到寄存器,会经历 5 个完整的流水线。STORE
这样从寄存器往内存里写数据的指令,不需要有写回寄存器的操作,也就是没有WB
的流水线阶段。至于像ADD
和SUB
这样的加减法指令,所有的操作都在寄存器完成,所以没有实际的内存访问MEM
操作。
有些指令没有对应的流水线阶段,但是我们并不能跳过对应的阶段直接执行下一阶段,不然如果我们先后执行一条LOAD
指令和一条 ADD
指令,就会发生 LOAD
指令的 WB
阶段和 ADD
指令的 WB
阶段,在同一个时钟周期发生。这样,相当于触发了一个结构冒险事件,产生了资源竞争。所以,在实践当中,各个指令不需要的阶段,并不会直接跳过,而是会运行一次 NOP
操作。
#数据冒险
数据冒险是指同时执行的多个指令之间有数据依赖的情况,这些数据依赖,可以分成三大类:先写后读,先读后写和写后在写。除了读之后再进行读,对于同一个寄存器或者内存地址的操作,都有着明确强制的顺序要求。
解决数据冒险的一个简单办法就是流水线停顿,或者叫流水线冒泡。
流水线停顿是以牺牲 CPU 性能为代价的,因为实际上在最差的情况下,流水线架构的 CPU 又会退化成单指令周期的 CPU 了。
#操作数前推
假设有两条 ADD
指令先后发生:
add $t0, $s2,$s1
add $s2, $s1,$t0
后一条指令依赖寄存器 t0
里的值,而 t0
的值又来自前一条指令的计算结果,所以后一条需要等待前一条指令的数据写回阶段完成之后才能执行。
我们完全可以在第一条指令的执行阶段完成之后,直接将结果数据传输到下一条指令的 ALU,然后,下一条指令不需要再插入两个 NOP 阶段,就可以继续正常走到执行阶段。
#乱序执行(Out-of-Order Execution, OoOE)
从软件开发的维度思考,乱序执行好像是在指令的执行阶段,引入了一个“线程池”。
- 在取指令和指令译码的时候,乱序执行的CPU和其他使用流水线架构的CPU是一样的。它会一级一级顺序地进行取指令和指令译码的工作。
- 在指令译码完成之后,就不一样了。CPU不会直接进行指令执行,而是进行一次指令分发,把指令发到一个叫作保留站 (Reservation Stations) 的地方。顾名思义,这个保留站,就像一个火车站一样。发送到车站的指令,就像是一列列的火车。
- 这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。这就好像一列列的火车都要等到乘客来齐了才能出发。
- 一旦指令依赖的数据来齐了,指令就可以交到后面的功能单元 (Function Unit,FU),其实就是 ALU,去执行了。我们有很多功能单元可以并行运行,但是不同的功能单元能够支持执行的指令并不相同。就和我们的铁轨一样,有些从上海北上,可以到北京和哈尔滨;有些是南下的,可以到广州和深圳。
- 指令执行的阶段完成之后,我们并不能立刻把结果写回到寄存器里面去,而是把结果再存放到一个叫作重排序缓冲区 (Re-Order Buffer,ROB) 的地方。
- 在重排序缓冲区里,我们的 CPU 会按照取指令的顺序,对指令的计算结果重新排序。只有排在前面的指令都已经完成了,才会提交指令,完成整个指令的运算结果。
- 实际的指令的计算结果数据,并不是直接写到内存或者高速缓存里,而是先写入存储缓冲区 (Store Buffer) 里面,最终才会写入到高速缓存和内存里。
即便指令的执行过程中是乱序的,我们在最终指令的计算结果写入到寄存器和内存之前,依然会进行一次排序,以确保所有指令在外部看来仍然是有序完成的。
TIP
在现代 Intel 的 CPU 的乱序执行的过程中,只有指令的执行阶段是乱序的,后面的内存访问和数据写回阶段都仍然是顺序的。
#控制冒险
在 jmp
指令发生的时候,CPU 可能会跳转去执行其他指令,紧跟 jmp
后的那一条指令之否应该顺序加载执行,在流水线厘米那进行取指令的时候,我们没法知道,要等 jmp
指令执行完成,去更新了 PC 寄存器之后我们才能知道,是否执行下一条指令,还是跳转到另外一个内存地址,去取别的指令。这种为了确保能够取到正确的指令,而不得不进行等待延迟的情况,就是控制冒险。
#缩短分支延迟
条件跳转指令其实进行了两种电路操作,第一种是进行条件比较,需要的输入是,根据指令的 opcode,就能确认条件码寄存器。第二种是进行实际的跳转,即把要跳转的地址信息写入到 PC 寄存器。无论是 opcode 还是对应的条件码寄存器,还是跳转地址,都是在指令译码阶段就能获得的,而对应的条件比较电路,只要是简单的逻辑门电路就可以了,并不需要一个完整而复杂的 ALU。所以,我们可以将条件判断、地址跳转都提前到指令译码阶段进行,而不需要放在指令执行阶段。
#动态分支预测 (Branch Prediction)
这个状态机里,我们一共有4个状态,所以我们需要2个比特来记录对应的状态。这样这整个策略,就可以叫作2比特饱和计数,或者叫双模态预测器 (Bimodal Predictor)。
#多发射 (Multiple Issue) 和超标量 (Superscalar)
在乱序执行的过程中,取指令 (IF) 和指令译码 (ID) 部分并不是并行进行的。我们可以通过增加硬件的方式,一次性从内存里取出多条指令,然后分发给多个并行的指令译码器,进行译码,然后对应交给不同的功能单元去处理。这样,在一个时钟周期里,能够完成的指令就不止一条了。
TIP
超标量技术依赖于在硬件层面,能够检测到对应的指令的先后依赖关系,解决“冒险”问题。
在超标量的 CPU 里面,有很多条并行的流水线,而不是只有一条流水线。
#超线程 (Hyper-Threading)
无论是多个 CPU 核心运行不同的程序,还是在单个 CPU 核心里面切换运行不同线程的任务,在同一个时间点上,一个物理 CPU 核心只会运行一个线程的指令。
超线程的 CPU 把一个物理层面的 CPU 核心“伪装”成两个逻辑层面的 CPU 核心。这个 CPU 会在硬件层面增加很多电路,使得我们可以在一个 CPU 核心内部,维护两个不同线程的指令的状态信息。
超线程技术一般也被叫做同时多线程 (Simultaneous Multi-Threading, SMT)技术。
在 CPU 的其他功能组件上,无论是指令译码器还是 ALU,一个 CPU 核心仍然只有一份。超线程的目的,是在一个线程 A 的指令,在流水线里停顿的时候,让另外一个线程去执行指令。因为这个时候,CPU 的译码器和 ALU 就空出来了,那么另外一个线程 B,就可以拿来干自己需要的事情。
TIP
超线程只在特定的应用场景下效果比较好,一般是在那些各个线程“等待”时间比较长的应用场景下。
#单指令多数据流 (SIMD)
SIMD 在获取数据和执行指令的时候都做到了并行。对于存在大量“数据并行”的计算中(向量运算或者矩阵运算),使用 SIMD 是一个很划算的办法。
#异常
关于异常,它其实是一个硬件和软件组合到一起的处理过程。异常的前半生,也就是异常的发生和捕捉,是在硬件层面完成的。但是异常的后半生,也就是说,异常的处理,其实是由软件来完成的。
异常发生的时候,通常是 CPU 检测到了一个特殊的信号。比如按下键盘上的按键,输入设备就会给 CPU 发送一个信号。
计算机会为每一种可能发生的异常分配一个异常代码 (Exception Number)。这些异常代码里,I/O 发出的信号的异常代码,是由操作系统来分配的,也就是由软件来设定的。而像加法溢出这样的异常代码,则是由 CPU 预先分配好的,也就是由硬件来分配的。
计算机在内存里,会保留一个异常表 (Exception Table),存放的是不同的异常代码对应的异常处理程序所在的地址。
CPU 在拿到异常码之后,会先把当前程序执行的现场,保存到程序栈里面,然后根据异常码查询,找到对应的异常处理程序,最后把后续指令执行的指挥权交给异常处理程序。
#异常的分类
故障和陷阱、中断的一个重要区别是,故障在异常程序处理完成之后,仍然回来处理当前的指令,而不是去执行程序中的下一条指令。因为当前的指令因为故障的原因并没有成功执行完成。
这四种异常里,中断异常的信号来自系统外部,而不是程序自己执行的过程中,所以称之为“异步”类型的异常。而陷阱、故障以及中止类型的异常,是在程序执行的过程中发生的,称之为“同步”类型的异常。
#异常的处理:上下文切换
切换到异常处理程序,比起函数调用,要更复杂一些,原有有如下几点:
- 因为异常情况往往发生在程序正常执行的预期之外,比如中断、故障发生的时候。所以,除了本来程序压栈要做的事情之外,我们还需要把 CPU 内当前运行程序用到的所有寄存器都放到栈里面。
- 像陷阱这样的异常,涉及程序指令在用户态和内核态之间的切换,压栈的时候,对应的数据是压到内核栈里,而不是程序栈里。
- 像故障这样的异常,在异常处理程序执行完成后,从栈里返回出来,继续执行的不是顺序的下一条指令,而是故障发生的当前指令。
所以,对于异常这样的处理流程,不像是顺序执行的指令间的函数调用关系。而是更像两个不同的独立进程之间在 CPU 层面的切换,所以这个过程我们称之为上下文切换 (Context Switch)。
#CISC vs. RISC
CPU 的指令集里的机器码是固定长度还是可变长度,也就是复杂指令集 (Complex Instruction Set Computing, CISC) 和精简指令集 (Reduced Instruction Set Computing, RISC) 这两种风格的指令集一个最重要的差别。
#存储器的层次结构
#SRAM
SRAM 是静态随机存储器,只要处在通电状态,里面的数据就可以保持存在。而一旦断电,里面的数据就会丢失了。在 SRAM 里面,一个比特的数据需要 6~8 个晶体管,所以 SRAM 的存储密度不高。同样的物理空间下,能够存储的数据有限,不过因为 SRAM 的电路简单,所以访问速度非常快。
在 CPU 里,通常会有 L1
、L2
、L3
这样三层高速缓存。每个 CPU 核心都有一块属于自己的 L1 高速缓存,通常分成指令缓存和数据缓存,分开存放 CPU 使用的指令和数据。L1 Cache 往往就嵌在 CPU 核心的内部。L2 Cache同样是每个 CPU 核心都有的,不过它往往不在 CPU 核心的内部。所以,L2 Cache 的访问速度会比 L1 稍微慢一些。而 L3 Cache,则通常是多个 CPU 核心共用的,尺寸会更大一些,访问速度自然也就更慢一些。
#DRAM
DRAM 是动态随机存储器,因为 DRAM 需要靠不断的“刷新”,才能保持数据被存储起来。DRAM 的一个比特只需要一个晶体管和一个电容就能存储。所以,DRAM 在同样的物理空间下,能够存储的数据也就更多,也就是存储的“密度”更大。但是,因为数据是存储在电容里的,电容会不断漏电,所以需要定时刷新充电,才能保持数据不丢失。DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问延时也就更长。
#存储器的层次结构
每一种存储器设备,只和它相邻的存储设备打交道。比如,CPU Cache 是从内存里加载而来的,或者需要写回内存,并不会直接写回数据到硬盘,也不会直接从硬盘加载数据到 CPU Cache 中,而是先加载到内存,再从内存加载到 Cache 中。
#CPU Cache
CPU 从内存中读取数据到 CPU Cache 的过程中,是一小块一小块来读取数据的,而不是按照单个数组元素来读取数据的。这样一小块一小块的数据,在 CPU Cache 里面,我们把它叫作 Cache Line(缓存块)。
#直接映射 Cache
对于读取内存中的数据,我们首先拿到的是数据所在的内存块 (Block) 的地址。而直接映射 Cache 采用的策略,就是确保任何一个内存块的地址,始终映射到一个固定的 CPU Cache 地址 (Cache Line)。而这个映射关系,通常用mod 运算来实现,我们会把缓存块的数量设置成 2 的 N 次方,这样在计算取模的时候,可以直接取地址的低 N位。
因为不同的物理内存块可能会对应相同的 Cache Line,因此在 Cache Line 中会存储一个组标记 (TAG),它会记录当前 Cache Line 中存储的数据对应的内存块。
除此以外,Cache Line 中还有两个数据,一个是从主内存中加载来的实际存放的数据,另一个是有效位。
CPU 在读取数据的时候,并不是要读取一整个 Block,而是读取一个需要的整数。这样的数据,我们叫作 CPU 里的一个字 (Word)。具体是哪个字,就用这个字在整个 Block 里面的位置来决定。这个位置,我们叫作偏移量 (Offset)。
总结一下,一个内存的访问地址,最终包括高位代表的组标记、低位代表的索引,以及在对应的Data Block中定位对应字的位置偏移量。
如果内存中的数据已经在 CPU Cache 里了,那一个内存地址的访问,就会经历这样4个步骤:
- 根据内存地址的低位,计算在 Cache 中的索引
- 判断有效位,确认 Cache 中的数据是有效的
- 对比内存访问地址的高位,和 Cache 中的组标记,确认 Cache 中的数据就是我们要访问的内存数据,从Cache Line 中读取到对应的数据块(Data Block)
- 根据内存地址的 Offset 位,从 Data Block 中,读取希望读取到的字
如果在2、3这两个步骤中,CPU 发现,Cache 中的数据并不是要访问的内存地址的数据,那 CPU 就会访问内存,并把对应的 Block Data 更新到 Cache Line 中,同时更新对应的有效位和组标记的数据。
TIP
除了直接映射 Cache 之外,常见的缓存放置策略还有全连接 Cache (Fully Associative Cache)、组相连 Cache (Set Associative Cache)。
#CPU Cache 的写入策略
#写直达 (Write-Through)
写直达的策略很慢,无论数据是不是在 Cache 里面,都需要把数据写到主内存里面。
#写回 (Write-Back)
写回策略只有当 CPU Cache 里面的数据要被“替换”的时候,才把数据写入到主内存里面去。
TIP
volatile关键字会确保我们对于这个变量的读取和写入,都一定会同步到主内存里,而不是从Cache里面读取。
#缓存一致性
CPU 的每个核各有各的缓存,互相之间的操作又是各自独立的,就会带来缓存一致性 (opens new window)(Cache Coherence) 的问题。
为了解决缓存不一致的问题,我们需要一种机制来同步两个不同核心里面的缓存数据,该机制需要做到以下两点:
- 写传播,一个 CPU 核心里 Cache 数据的更新必须能够传播到其他的对应节点的 Cache Line 里。
- 事务的串行化,一个 CPU 核心里面的读取和写入,在其他节点看起来,顺序是一样的。
CPU Cache 里的事务串行化需要做到两点:
- 一个 CPU 核心对于数据的操作,需要同步通信给到其他 CPU 核心。
- 如果两个 CPU 核心里有同一个数据的 Cache,那么对于这个 Cache 数据的更新,需要有一个“锁”的概念。只有拿到了对应 Cache Block 的“锁”之后,才能进行对应的数据更新。
#MESI 协议
要解决缓存一致性问题,首先要解决的是多个 CPU 核心之间的数据传播问题。最常见的一种解决方案叫作总线嗅探 (Bus Snooping)。其本质上就是把所有的读写请求都通过总线广播给所有的 CPU 核心,然后让各个核心去“嗅探”这些请求,再根据本地的情况进行响应。基于总线嗅探机制,还可以分成很多种不同的缓存一致性协议,常见的有 MESI 协议。
MESI 协议,是一种叫作写失效 (Write Invalidate) 的协议。在写失效协议里,只有一个 CPU 核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个 CPU 核心写入 Cache 之后,它会去广播一个“失效”请求告诉所有其他的 CPU 核心。其他的 CPU 核心,只是去判断自己是否也有一个“失效”版本的 Cache Block,然后把这个也标记成失效的就好了。
MESI 协议的由来,来自于 Cache Line 的四个不同的标记,分别是:
- M: 代表已修改 (Modified),Cache Line 里面的内容已经更新过了,但还没有写回到主内存里。
- E: 代表独占 (Exclusive),对应的 Cache Line 只加载到了当前 CPU 核所拥有的 Cache 里,其他 CPU 核并没有加载对应的数据到自己的 Cache 里。这个时候,如果要向独占的 Cache Block 写入数据,我们可以自由地写入数据,而不需要告知其他 CPU 核。如果收到了一个来自于总线的读取对应缓存的请求,它就会变成共享状态。因为这个时候,另外一个 CPU 核心,也把对应的 Cache Block,从内存里面加载到了自己的 Cache 里来。
- S: 代表共享 (Shared),同样的数据在多个 CPU 核心的 Cache 里都有。当我们想要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的Cache,都变成无效的状态,然后再更新当前 Cache 里面的数据。这个广播操作,一般叫作 RFO (Request For Ownership),也就是获取当前对应 Cache Block 数据的所有权。
- I: 代表已失效 (Invalidated),Cache Line 里面的数据已经失效了,CPU 不可以相信这个 Cache Line 里面的数据。
#页表
把虚拟内存地址映射到物理内存地址,需要建立一张映射表——页表 (Page Table)。一个内存地址分成页号 (Directory) 和偏移量 (Offset) 两个部分,页表只需要保留虚拟内存地址的页号和物理内存地址的页号之间的映射关系就可以了。
#一级页表
这样一个页表需要占用 4MB 的内存空间(因为需要保存 个物理页号的映射关系,每一个映射关系占用 4 字节)。但是每一个进程都需要这样一个页表,占用的内存空间会非常大。
#多级页表
大部分的进程占用的内存是有限的,需要的页也是有限的,我们只需要保存那些用到的页之间的映射关系就好。
一个进程的内存地址空间,通常是“两头实、中间空”。在程序运行的时候,内存地址从顶部往下,不断分配占用的栈的空间。而堆的空间,内存地址则是从底部往上,是不断分配占用的。所以,在一个实际的程序进程里面,虚拟内存占用的地址空间,通常是两段连续的空间。而不是完全散落的随机的内存地址。而多级页表,就特别适合这样的内存地址分布。
以这样的分成 4 级的多级页表来看,每一级如果都用 5 个比特表示,那么每张页表只需要 个条目。如果每个条目是 4 个字节,那么一共需要 128 个字节。而一个 1 级索引表,可以映射 32 个 4KiB 的内存空间,共计 16KB 的大小。一个填满的 2 级索引表,对应的就是 32 个 1 级索引表,共计 512KB 大小的内存空间。
#TLB (Translation-Lookaside Buffer)
多级页表虽然节约了我们的存储空间,但是却带来了时间上的开销。但是连续的内存地址转换很有可能都是来自同一个虚拟页号,转换的结果自然也是同一个物理页号。我们可以通过“加缓存”的方式来将之前的内存地址转换结果缓存下来。CPU 内有一块缓存芯片,称之为TLB ,地址变换高速缓存。TLB 可以分成指令的 TLB (ITLB) 和数据的 TLB (DTLB),还可以根据大小进行分级,变成 L1、L2这样多层的 TLB。
为了性能,整个内存转换过程也要由硬件来执行。CPU 芯片里面封装了内存管理单元 (MMU, Memory Management Unit) 芯片,用来完成地址转换。和 TLB 的访问和交互,都是由这个 MMU 控制的。
#内存保护
#可执行空间保护
对于一个进程使用的内存,只把其中的指令部分设置成“可执行”的,对于其他部分,比如数据部分,不给予“可执行”的权限。
#地址空间布局随机化 (Address Space Layout Randomization)
原先一个进程的内存布局空间是固定的,任何第三方很容易就能知道指令、程序栈、数据、堆的位置。这个为想要搞破坏的人创造了很大的便利。而地址空间布局随机化这个机制,就是让这些区域的位置不再固定,在内存空间随机去分配这些进程里不同部分所在的内存空间地址,让破坏者猜不出来。
#SSD
对于 SSD 硬盘,我们也可以先简单地认为,它是由一个电容加上一个电压计组合在一起,记录了一个或者多个比特。
给电容里面充上电有电压的时候就是 1,给电容放电里面没有电就是 0。采用这样方式存储数据的 SSD 硬盘称之为使用了 SLC 的颗粒,全称是 Single-Level Cell,也就是一个存储单元中只有一比特数据。
为了进一步提升容量,诞生了 MLC (Multi-Level Cell),TLC (Triple-Level Cell) 以及 QLC (Quad-Level Cell),也就是能在一个电容里面存下 2 个、3 个乃至 4 个比特。通过给电容充上不同的电压以区分不同的比特,但是这对于精度的要求会更高,导致充电和读取的时候都更慢,因此 QLC 的 SSD 的读写速度要比 SLC 的慢上好几倍。
对于 SSD 硬盘来说,数据的写入叫作 Program。写入不能像机械硬盘一样,通过覆写 (Overwrite) 来进行的,而是要先去擦除 (Erase),然后再写入。
SSD 的读取和写入的基本单位,不是一个比特 (bit)或者一个字节 (byte),而是一个页 (Page)。SSD 的擦除单位就更夸张了,不仅不能按照比特或者字节来擦除,连按照页来擦除都不行,我们必须按照块来擦除。
SSD 的使用寿命,其实是每一个块 (Block) 的擦除的次数。SLC 的芯片,可以擦除的次数大概在 10 万次,MLC 就在 1 万次左右,而 TLC 和 QLC 就只在几千次了。
#SSD 读写的生命周期
TIP
SSD 特别适合读多写少的应用,用来做数据库,存放电商网站的商品信息很合适。但是用来作为 Hadoop 这样的Map-Reduce 应用的数据盘就不行了。因为 Map-Reduce 任务会大量在任务中间向硬盘写入中间数据再删除掉。
#磨损均衡 (Wear-Leveling)
在 FTL (Flash-Translation Layer) 里面,存放了逻辑块地址 (Logical Block Address,简称 LBA) 到物理块地址 (Physical Block Address,简称 PBA) 的映射。
操作系统访问的硬盘地址,其实都是逻辑地址。只有通过 FTL 转换之后,才会变成实际的物理地址,找到对应的块进行访问。操作系统本身,不需要去考虑块的磨损程度,只要和操作机械硬盘一样来读写数据就好了。
操作系统所有对于 SSD 硬盘的读写请求,都要经过 FTL。FTL 里面又有逻辑块对应的物理块,所以 FTL 能够记录下来,每个物理块被擦写的次数。如果一个物理块被擦写的次数多了,FTL 就可以将这个物理块,挪到一个擦写次数少的物理块上。但是,逻辑块不用变,操作系统也不需要知道这个变化。