深入浅出计算机组成原理¶
目录
备注
操作系统也是一个 “软件”,开发操作系统,只需要关注到 “组成原理” 或者 “体系结构” 就好了,不需要真的了解硬件。操作系统,其实是在 “组成原理” 所讲的 “指令集” 上做一层封装。体系结构、操作系统、编译原理以及计算机网络,都可以认为是组成原理的后继课程。体系结构不是一个系统软件,它更多地是讲,如何量化地设计和研究体系结构和指令集。操作系统、编译原理和计算机网络都是基于体系结构之上的系统软件。
计算机组成原理: Computer Organization
南桥(SouthBridge)
北桥
一台计算机应该有哪些部分组成:
1. 一个处理器单元(Processing Unit)
包含算术逻辑单元(Arithmetic Logic Unit,ALU)和处理器寄存器(Processor Register)
用来完成各种算术和逻辑运算。
因为它能够完成各种数据的处理或者计算工作,因此也有人把这个叫作数据通路(Datapath)或者运算器
2. 一个控制器单元(Control Unit/CU)
包含指令寄存器(Instruction Register)和程序计数器(Program Counter)
用来控制程序的流程,通常就是不同条件下的分支和跳转。
在现在的计算机里,上面的算术逻辑单元和这里的控制器单元,共同组成了我们说的 CPU
3. 用来存储数据(Data)和指令(Instruction)的内存,以及更大容量的外部存储
外部存储在过去,可能是磁带、磁鼓这样的设备,现在通常就是硬盘
4. 各种输入和输出设备,以及对应的输入和输出机制
备注
具体来说,学习组成原理,其实就是 1. 学习控制器、运算器的工作原理,也就是 CPU 是怎么工作的,以及为何这样设计;2. 学习内存的工作原理,从最基本的电路,到上层抽象给到 CPU 乃至应用程序的接口是怎样的;3. 学习 CPU 是怎么和输入设备、输出设备打交道的。
论文: First Draft of a Report on the EDVAC
计算机行业的两大祖师爷,冯・诺依曼机、图灵(Alan Mathison Turing)
图灵机是一种思想模型(计算机的基本理论基础),是一种有穷的、构造性的问题的问题求解思路,图灵认为凡是能用算法解决的问题也一定能用图灵机解决;
冯诺依曼提出了 “存储程序” 的计算机设计思想,并 “参照” 图灵模型设计了历史上第一台电子计算机,即冯诺依曼机
图灵机是一个抽象的” 思维实验 “,而冯诺依曼机就是对应着这个” 思维实验 “的” 物理实现 “。相互之间颇有理论物理学家和实验物理学家的合作关系的意思,可谓是一个问题的一体两面。
哈弗结构算是冯诺依曼结构的一个特殊形式吧,把指令和数据分开存储,不过现在很少见有人提了。
计算机的性能衡量标准中主要有两个指标:
1. 响应时间(Response time)或者叫执行时间(Execution time)
2. 吞吐率(Throughput)或者带宽(Bandwidth)
time 命令。它会返回三个值,第一个是 real time,也就是我们说的 Wall Clock Time,也就是运行程序整个过程中流逝掉的时间;第二个是 user time,也就是 CPU 在运行你的程序,在用户态运行指令的时间;第三个是 sys time,是 CPU 在运行你的程序,在操作系统内核里运行指令的时间。而程序实际花费的 CPU 执行时间(CPU Time),就是 user time 加上 sys time:
$ time seq 1000000 | wc -l
1000000
real 0m0.101s
user 0m0.031s
sys 0m0.016s
程序的 CPU 执行时间 = CPU 时钟周期数 × 时钟周期时间
CPU 时钟周期数 = 指令数 × 每条指令的平均时钟周期数(CPI)
=>
程序的 CPU 执行时间 = 指令数 ×CPI×Clock Cycle Time
1. 时钟周期时间,就是计算机主频,这个取决于计算机硬件
2. 每条指令的平均时钟周期数 CPI,就是一条指令到底需要多少 CPU Cycle
现代的 CPU 通过流水线技术(Pipeline),让一条指令需要的 CPU Cycle 尽可能地少
对于 CPI 的优化,也是计算机组成和体系结构中的重要一环
3. 指令数,代表执行我们的程序到底需要多少条指令、用哪些指令
编译器做优化
从 1978 年 Intel 发布的 8086 CPU 开始,计算机的主频从 5MHz 开始,不断提升。1980 年代中期的 80386 能够跑到 40MHz,1989 年的 486 能够跑到 100MHz,直到 2000 年的奔腾 4 处理器,主频已经到达了 1.4GHz,2019 年的最高配置 Intel i9 CPU,主频也只不过是 5GHz 而已。相较于 1978 年到 2000 年,这 20 年里 300 倍的主频提升,从 2000 年到2019年的这 19 年,CPU 的主频大概提高了 3 倍。
在 “摩尔定律” 和 “并行计算” 之外,在整个计算机组成层面,还有这样几个原则性的性能提升方法:
1. 加速大概率事件
2. 通过流水线提高性能
3. 通过预测提高性能
指令和运算¶
纸带编程¶
把对应的汇编代码和机器码都打印出来:
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o
CPU 指令可以分成五大类:
1. 第一类是算术类指令。我们的加减乘除,在 CPU 层面,都会变成一条条算术类指令。
2. 第二类是数据传输类指令。给变量赋值、在内存里读写数据,用的都是数据传输类指令。
3. 第三类是逻辑类指令。逻辑上的与或非,都是这一类指令。
4. 第四类是条件分支类指令。日常我们写的 “if/else”,其实都是条件分支类指令。
5. 最后一类是无条件跳转指令。
MIPS 的指令是一个 32 位的整数,高 6 位叫操作码(Opcode),也就是代表这条指令具体是一条什么样的指令,剩下的 26 位有三种格式,分别是 R、I 和 J:
1. R 指令是一般用来做算术和逻辑操作,里面有读取和写入数据的寄存器的地址。
2. I 指令,则通常是用在数据传输、条件分支,以及在运算的时候使用的并非变量还是常数的时候。
3. J 指令就是一个跳转指令,高 6 位之外的 26 位都是一个跳转后的地址。
寄存器¶
一个 CPU 里面会有很多种不同功能的寄存器。我这里给你介绍三种比较特殊的:
1. PC 寄存器(Program Counter Register),也叫指令地址寄存器(Instruction Address Register)
顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址。
PC 寄存器还有一个名字,就叫作程序计数器
顾名思义,就是随着时间变化,不断去数数。数的数字变大了,就去执行一条新指令。
2. 指令寄存器(Instruction Register),用来存放当前正在执行的指令。
3. 条件码寄存器(Status Register),用里面的一个一个标记位(Flag),
存放 CPU 进行算术或者逻辑计算的结果。
还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个:
我们通常根据存放的数据内容来给它们取名字,比如:
1. 整数寄存器
2. 浮点数寄存器
3. 向量寄存器
4. 地址寄存器
有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。
备注
核心在于理解几个寄存器的作用,从而理解 cpu 运行程序的过程:cpu 从 PC 寄存器中取地址,找到地址对应的内存位子,取出其中指令送入指令寄存器执行,然后指令自增,重复操作。所以只要程序在内存中是连续存储的,就会顺序执行这也是冯诺依曼体系的理念吧。而实际上跳转指令就是当前指令修改了当前 PC 寄存器中所保存的下一条指令的地址,从而实现了跳转。当然各个寄存器实际上是由数电中的一个一个门电路组合出来的,而各个门电路的具体电路形式也是属于模电的东西
汇编¶
C源码:
int main()
{
srand(time(NULL));
int r = rand() % 2;
int a = 10;
if (r == 0)
{
a = 1;
} else {
a = 2;
}
}
执行命令:
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o
生成汇编:
if (r == 0)
3b: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0
3f: 75 09 jne 4a <main+0x4a>
{
a = 1;
41: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
48: eb 07 jmp 51 <main+0x51>
}
else
{
a = 2;
4a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
51: b8 00 00 00 00 mov eax,0x0
}
可以看到,这里对于 r == 0 的条件判断,被编译成了 cmp 和 jne 这两条指令。 cmp 指令比较了前后两个操作数的值,这里的 DWORD PTR 代表操作的数据类型是 32 位的整数,而 [rbp-0x4] 则是变量 r 的内存地址。所以,第一个操作数就是从内存里拿到的变量 r 的值。第二个操作数 0x0 就是我们设定的常量 0 的 16 进制表示。cmp 指令的比较结果,会存入到条件码寄存器当中去。 在这里,如果比较的结果是 True,也就是 r == 0,就把零标志条件码(对应的条件码是 ZF,Zero Flag)设置为 1。除了零标志之外,Intel 的 CPU 下还有进位标志(CF,Carry Flag)、符号标志(SF,Sign Flag)以及溢出标志(OF,Overflow Flag),用在不同的判断条件下。 cmp 指令执行完成之后,PC 寄存器会自动自增,开始执行下一条 jne 的指令。 跟着的 jne 指令,是 jump if not equal 的意思,它会查看对应的零标志位。如果为 0,会跳转到后面跟着的操作数 4a 的位置。这个 4a,对应这里汇编代码的行号,也就是上面设置的 else 条件里的第一条指令。当跳转发生的时候,PC 寄存器就不再是自增变成下一条指令的地址,而是被直接设置成这里的 4a 这个地址。这个时候,CPU 再把 4a 地址里的指令加载到指令寄存器中来执行。 跳转到执行地址为 4a 的指令,实际是一条 mov 指令,第一个操作数和前面的 cmp 指令一样,是另一个 32 位整型的内存地址,以及 2 的对应的 16 进制值 0x2。mov 指令把 2 设置到对应的内存里去,相当于一个赋值操作。然后,PC 寄存器里的值继续自增,执行下一条 mov 指令。 这条 mov 指令的第一个操作数 eax,代表累加寄存器,第二个操作数 0x0 则是 16 进制的 0 的表示。这条指令其实没有实际的作用,它的作用是一个占位符。我们回过头去看前面的 if 条件,如果满足的话,在赋值的 mov 指令执行完成之后,有一个 jmp 的无条件跳转指令。跳转的地址就是这一行的地址 51。我们的 main 函数没有设定返回值,而 mov eax, 0x0 其实就是给 main 函数生成了一个默认的为 0 的返回值到累加器里面。if 条件里面的内容执行完成之后也会跳转到这里,和 else 里的内容结束之后的位置是一样的。
stack¶
https://blog.holbertonschool.com/hack-virtual-memory-stack-registers-assembly-code/
汇编指令操作栈的步骤一步步画: https://manybutfinite.com/post/journey-to-the-stack/
How Stacks are Handled in Go: https://blog.cloudflare.com/how-stacks-are-handled-in-go/
How Stacks are Handled in Go: https://studygolang.com/topics/491
ELF 与静态链接¶
除了这些基本属性之外,大部分程序还有这么一些 Section:
1. 首先是.text Section,也叫作代码段或者指令段(Code Section),用来保存程序的代码和指令;
2. 接着是.data Section,也叫作数据段(Data Section),用来保存程序里面设置好的初始化数据信息;
3. 然后就是.rel.text Secion,叫作重定位表(Relocation Table)。
重定位表里,保留的是当前的文件里面,哪些跳转地址其实是我们不知道的。
在链接发生之前,我们并不知道该跳转到哪里,这些信息就会存储在重定位表里;
4. 最后是.symtab Section,叫作符号表(Symbol Table)。符号表保留了我们所说的当前文件里面定义的函数名称和对应地址的地址簿。
ELF
PE
Wine
WSL
命令:
$ readelf -s link_example.o //查看符号表
$ objdump -r link_example.o //查看重定位表
装载器¶
装载器需要满足两个要求:
第一,可执行程序加载后占用的内存空间应该是连续的
第二,我们需要同时加载很多个程序,并且不能让程序自己规定在内存中加载的位置
虚拟内存地址(Virtual Memory Address)
物理内存地址(Physical Memory Address)
内存碎片(Memory Fragmentation)
内存交换(Memory Swapping)
内存分页(Paging)
全局符号表是虚拟内存内的内存寻址和跳转。
页表是虚拟内存和物理内存之间的映射关系。
备注
和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小。而对应的程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。从虚拟内存到物理内存的映射,不再是拿整段连续的内存的物理地址,而是按照一个一个页来的。页的尺寸一般远远小于整个程序的大小。在 Linux 下,我们通常只设置成 4KB。
Linux 系统设置的页的大小:
$ getconf PAGE_SIZE
备注
由于内存空间都是预先划分好的,也就没有了不能使用的碎片,而只有被释放出来的很多 4KB 的页。即使内存空间不够,需要让现有的、正在运行的其他程序,通过内存交换释放出一些内存的页出来,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,让整个机器被内存交换的过程给卡住。
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
实际上,我们的操作系统,的确是这么做的。当要读取特定的页,却发现数据并没有加载到物理内存里的时候,就会触发一个来自于 CPU 的缺页错误(Page Fault)。我们的操作系统会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到物理内存里。这种方式,使得我们可以运行那些远大于我们实际物理内存的程序。同时,这样一来,任何程序都不需要一次性加载完所有指令和数据,只需要加载当前需要用到就行了。
备注
通过虚拟内存、内存交换和内存分页这三个技术的组合,我们最终得到了一个让程序不需要考虑实际的物理内存地址、大小和当前分配空间的解决方案。这些技术和方法,对于我们程序的编写、编译和链接过程都是透明的。这也是我们在计算机的软硬件开发中常用的一种方法,就是加入一个间接层。
备注
通过引入虚拟内存、页映射和内存交换,我们的程序本身,就不再需要考虑对应的真实的内存地址、程序加载、内存管理等问题了。任何一个程序,都只需要把内存当成是一块完整而连续的空间来直接使用。
动态链接¶
动态链接(Dynamic Link)
静态链接(Static Link)
共享库(Shared Libraries)
地址无关码(Position-Independent Code)
相对地址(Relative Address)
PLT: Procedure Link Table(程序链接表)
$ gcc lib.c -fPIC -shared -o lib.so
$ gcc -o show_me_poor show_me_poor.c ./lib.so
说明:
-fPIC: 编译成一个地址无关代码
备注
为什么要采用 PLT 和 GOT 两级跳转,直接用 GOT 有问题吗?PLT 是为了做延迟绑定,如果函数没有实际被调用到,就不需要更新 GOT 里面的数值。因为很多动态装载的函数库都是不会被实际调用到的。
二进制编码¶
字符集(Charset)和字符编码(Character Encoding)
Unicode 是一个字符集,包含了 150 种语言的 14 万个不同的字符。
字符编码则是对于字符集里的这些字符,怎么一一用二进制表示出来的一个字典。如: Unicode,就可以用 UTF-8、UTF-16,乃至 UTF-32 来进行编码,存储成二进制。
电路¶
继电器(Relay)
反向器(Inverter)
电报传输的信号有两种:
1. 一种是短促的点信号(dot 信号)
2. 一种是长一点的划信号(dash 信号)
把“点”当成“1”,把“划”当成“0”
例:
电影里最常见的电报信号是“SOS”,这个信号表示出来就是 “点点点划划划点点点”
加法器¶
半加器(Half Adder)
全加器(Full Adder)
组合电路套件(淘宝上买)
备注
出于性能考虑,实际 CPU 里面使用的加法器,比起我们今天讲解的电路还有些差别,会更复杂一些。真实的加法器,使用的是一种叫作超前进位加法器的东西。实际的加法器,并不是由全加器串联组成的,实际高位的计算结果直接来自低位的组合电路里面的输入。封装意味着我们提供了更多的“简单电路”或者说“简单指令”来操作。但这也意味着同样复杂的操作需要更多条指令。可以像人一样,逐位计算,但线性带来时间复杂度高。从而可以考虑通过增加线路 / 硬件复杂度,从空间换时间的思路,加快乘法速度。但 CPU 毕竟也是很珍贵的资源,晶体管也不宜太多,这中间需要相互平衡。这个也是为什么在计算机体系结构里面会有 RISC 和 CISC 这样的复杂/精简 指令之争最朴素的由来。
乘法器¶
门延迟(Gate Delay)
定点数&浮点数¶
浮点数公式:
说明:
s: 符号位, 用来表示是正数还是负数
f: 有效数位, 23 个比特
e: 指数位, 8 个比特
例:
表示成二进制就是 1.11111... ^(2^127)
差不多就是 1.9999999 ^(2^127)
差不多正好是 3.4028235 x (10 ^ 38)
最小的数就是 1.000..... ^ (2^-126)
差不多就是 1.0000 ^ (2^-126)
差不多正好就是 1.17549435 x (10^-38)
备注
32 位浮点数的有效位长度一共只有 23 位,如果两个数的指数位差出 23 位,较小的数右移 24 位之后,所有的有效位就都丢失了。例:用一个循环相加 2000 万个 1.0f,最终的结果会是 1600 万左右,而不是 2000 万。这是因为,加到 1600 万之后的加法因为精度丢失都没有了。用 Kahan Summation
的算法来解决这个问题。
备注
Kahan Summation 算法
原理其实并不复杂,就是在每次的计算过程中,都用一次减法,把当前加法计算中损失的精度记录下来,然后在后面的循环中,把这个精度损失放在要加的小数上,再做一次运算。
处理器¶
数据通路¶
指令周期(Instruction Cycle)
组合逻辑元件(Combinational Element)
状态元件(State Element)
锁存器(Latch)
组合逻辑电路(Combinational Logic Circuit)
时序逻辑电路(Sequential Logic Circuit)
反馈电路(Feedback Circuit)
反相器(Inverter)
触发器(Flip-Flop)
D 触发器(Data/Delay Flip-flop)
复位置位触发器(Reset-Set Flip Flop)
计算机每执行一条指令的过程,可以分解成这样几个步骤:
1. Fetch(取得指令)
也就是从 PC 寄存器里找到对应的指令地址,
根据指令地址从内存里把具体的指令,加载到指令寄存器中
然后把 PC 寄存器自增,好在未来执行下一条指令。
2. Decode(指令译码)
就是根据指令寄存器里面的指令,解析成要进行什么样的操作,
是 R、I、J 中的哪一种指令,具体要操作哪些寄存器、数据或者内存地址。
3. Execute(执行指令)
也就是实际运行对应的 R、I、J 这些特定的指令,
进行算术逻辑操作、数据传输或者直接的地址跳转。
4. 重复进行 1~3 的步骤。
数据通路就是我们的处理器单元。它通常由两类原件组成:
1. 操作元件,也叫组合逻辑元件(Combinational Element),其实就是我们的 ALU
2. 存储元件,也叫状态元件(State Element)。
如我们在计算过程中需要用到的寄存器,无论是通用寄存器还是状态寄存器,其实都是存储元件
3. 通过数据总线的方式,把它们连接起来,就可以完成数据的存储、处理和传输了,这就是所谓的建立数据通路
要想搭建出来整个 CPU,需要 4 种基本电路:
1. ALU 这样的组合逻辑电路
2. 用来存储数据的锁存器和 D 触发器电路
3. 用来实现 PC 寄存器的计数器电路
4. 用来解码和寻址的译码器电路
组合逻辑电路: 只需要给定输入,就能得到固定的输出
时序逻辑电路解决这样几个问题:
1. 自动运行的问题。时序电路接通之后可以不停地开启和关闭开关,进入一个自动运行的状态。这个使得我们上一讲说的,控制器不停地让 PC 寄存器自增读取下一条指令成为可能 2. 存储的问题。通过时序电路实现的触发器,能把计算结果存储在特定的电路里面,而不是像组合逻辑电路那样,一旦输入有任何改变,对应的输出也会改变。 3. 本质上解决了各个功能按照时序协调的问题。无论是程序实现的软件指令,还是到硬件层面,各种指令的操作都有先后的顺序要求。时序电路使得不同的事件按照时间顺序发生。
指令设计¶
指令周期
机器周期(或者 CPU 周期)
时钟周期
指令流水线(Instruction Pipeline)
流水线阶段或者流水线级(Pipeline Stage)
流水线寄存器(Pipeline Register)
备注
我们不需要确保最复杂的那条指令在时钟周期里面执行完成,而只要保障一个最复杂的流水线级的操作,在一个时钟周期内完成就好了。
备注
虽然我们不能通过流水线,来减少单条指令执行的 “延时” 这个性能指标,但是,通过同时在执行多条指令的不同阶段,我们提升了 CPU 的 “吞吐率”。在外部看来,我们的 CPU 好像是 “一心多用”,在同一时间,同时执行 5 条不同指令的不同阶段。在 CPU 内部,其实它就像生产线一样,不同分工的组件不断处理上游传递下来的内容,而不需要等待单件商品生产完成之后,再启动下一件商品的生产过程。
冒险和预测¶
流水线设计需要解决的三大冒险,分别是:
1. 结构冒险(Structural Hazard)
2. 数据冒险(Data Hazard)
3. 控制冒险(Control Hazard)
结构冒险¶
备注
解决方案是增加资源,通过添加指令缓存和数据缓存,让我们对于指令和数据的访问可以同时进行。这个办法帮助 CPU 解决了取指令和访问数据之间的资源冲突。
数据冒险¶
三种不同的依赖关系:
1. 先写后读(Read After Write,RAW)
数据依赖,也就是 Data Dependency
2. 先读后写(Write After Read,WAR)
反依赖,也就是 Anti-Dependency
3. 写后再写(Write After Write,WAW)
输出依赖,也就是 Output Dependency
备注
解决方案是直接进行等待:再等等:通过流水线停顿解决数据冒险。通过插入 NOP 这样的无效指令,等待之前的指令完成。这样我们就能解决不同指令之间的数据依赖问题。
流水线停顿(Pipeline Stall),或者叫流水线冒泡(Pipeline Bubbling)
操作数前推(Operand Forwarding),或者操作数旁路(Operand Bypassing)
乱序执行(Out-of-Order Execution,OoOE)
控制冒险¶
备注
用于解决if…else 这样的条件分支,或者 for/while 循环
分支预测(Branch Prediction)技术
一级分支预测(One Level Branch Prediction)或 1 比特饱和计数(1-bit saturating counter)
2 比特饱和计数,或 双模态预测器(Bimodal Predictor)
分支预测:
1. 假装分支不发生
2. 静态预测
3. 动态分支预测
SIMD¶
超线程(Hyper-Threading)技术 or 同时多线程(Simultaneous Multi-Threading,简称 SMT)技术
指令级并行(Instruction-level parallelism,简称 IPL)的技术方案
备注
超线程的目的,是在一个线程 A 的指令,在流水线里停顿的时候,让另外一个线程去执行指令。因为这个时候,CPU 的译码器和 ALU 就空出来了,那么另外一个线程 B,就可以拿来干自己需要的事情。这个线程 B 可没有对于线程 A 里面指令的关联和依赖。CPU 通过很小的代价,就能实现 “同时” 运行多个线程的效果。通常我们只要在 CPU 核心的添加 10% 左右的逻辑功能,增加可以忽略不计的晶体管数量,就能做到这一点。
SISD,单指令单数据(Single Instruction Single Data)
MIMD,多指令多数据(Multiple Instruction Multiple Dataa)
SIMD,单指令多数据流(Single Instruction Multiple Data)
备注
使用循环来一步一步计算的算法一般被称为 SISD;而使用向量方式计算的算法则是SIMD。SIMD 指令能快那么多的原因是:SIMD 在获取数据和执行指令的时候,都做到了并行。一方面,在从内存里面读取数据的时候,SIMD 是一次性读取多个数据。
异常&中断¶
异常的分类:
1. 中断(Interrupt): I/O 设备的输入
2. 陷阱(Trap): 程序主动触发的状态切换
3. 故障(Fault): 异常情况下的程序出错
4. 中止(Abort): 出错之后无可挽回的退出程序
备注
故障和陷阱、中断的一个重要区别是,故障在异常程序处理完成之后,仍然回来处理当前的指令,而不是去执行程序中的下一条指令。因为当前的指令因为故障的原因并没有成功执行完成。
上下文切换(Context Switch)
指令集: CISC&RISC¶
备注
UC Berkeley 的大卫・帕特森(David Patterson)教授发现,实际在 CPU 运行的程序里,80% 的时间都是在使用 20% 的简单指令。于是,他就提出了 RISC 的理念。自此之后,RISC 类型的 CPU 开始快速蓬勃发展。
备注
“开源硬件” 也慢慢发展起来了。一方面,MIPS 在 2019 年宣布开源;另一方面,从 UC Berkeley 发起的 RISC-V 项目也越来越受到大家的关注。而 RISC 概念的发明人,图灵奖的得主大卫・帕特森教授从伯克利退休之后,成了 RISC-V 国际开源实验室的负责人,开始推动 RISC-V 这个 “CPU 届的 Linux” 的开发。
GPU¶
图像进行实时渲染的过程,可以被分解成下面这样 5 个步骤:
1. 顶点处理(Vertex Processing)
2. 图元处理(Primitive Processing)
3. 栅格化(Rasterization)
4. 片段处理(Fragment Processing)
5. 像素操作(Pixel Operations)
图形流水线(Graphic Pipeline)
可编程管线(Programable Function Pipeline)
统一着色器架构(Unified Shader Architecture)
现代 GPU 的三个核心创意:
1. 芯片瘦身
2. 多核并行和 SIMT
3. GPU 里的“超线程”
FPGA 和 ASIC¶
FPGA,也就是现场可编程门阵列(Field-Programmable Gate Array)
ASIC(Application-Specific Integrated Circuit),也就是专用集成电路
备注
一块 FPGA 这样的板子,可以在“现场”多次进行编程。它不像 PAL(Programmable Array Logic,可编程阵列逻辑)这样更古老的硬件设备,只能“编程”一次,把预先写好的程序一次性烧录到硬件里面,之后就不能再修改了。
FPGA 的解决方案:
1. 用存储换功能实现组合逻辑
2. 对于需要实现的时序逻辑电路,我们可以在 FPGA 里面直接放上 D 触发器,作为寄存器
3. FPGA 是通过可编程逻辑布线,来连接各个不同的 CLB,最终实现我们想要实现的芯片功能
备注
FPGA 本质上是一个可以通过编程,来控制硬件电路的芯片。我们通过用 LUT 这样的存储设备,来代替需要的硬连线的电路,有了可编程的逻辑门,然后把很多 LUT 和寄存器放在一起,变成一个更复杂的逻辑电路,也就是 CLB,然后通过控制可编程布线中的很多开关,最终设计出属于我们自己的芯片功能。FPGA,常常被我们用来进行芯片的设计和验证工作,也可以直接拿来当成专用的芯片,替换掉 CPU 或者 GPU,以节约成本。
“FPGA 的历史、现状和未来”: https://www.infoq.cn/article/wmijrofwZCEHRk0OySjP
TPU¶
模型的训练和推断有什么不同:
1. 深度学习的推断工作更简单,对灵活性的要求也就更低
2. 深度学习的推断的性能,首先要保障响应时间的指标
3. 深度学习的推断工作,希望在功耗上尽可能少一些
备注
第一代 TPU 的设计目标。那就是,在保障响应时间的情况下,能够尽可能地提高能效比这个指标
TPU 的论文 In-Datacenter Performance Analysis of a Tensor Processing Unit: https://arxiv.org/ftp/arxiv/papers/1704/1704.04760.pdf
Google 官方专门讲解 TPU 构造的博客文章 An in-depth look at Google’s first Tensor Processing Unit(TPU): https://cloud.google.com/blog/products/gcp/an-in-depth-look-at-googles-first-tensor-processing-unit-tpu
虚拟机¶
解释型虚拟机¶
备注
这种解释执行方式的最大的优势就是,模拟的系统可以跨硬件。比如,Android 手机用的 CPU 是 ARM 的,而我们的开发机用的是 Intel X86 的,两边的 CPU 指令集都不一样,但是一样可以正常运行。
两个明显的缺陷:
1. 第一个是,做不到精确的 “模拟”。
很多的老旧的硬件的程序运行,要依赖特定的电路乃至电路特有的时钟频率,
想要通过软件达到 100% 模拟是很难做到的。
2. 第二个缺陷就更麻烦了,那就是这种解释执行的方式,性能实在太差了。
因为我们并不是直接把指令交给 CPU 去执行的,而是要经过各种解释和翻译工作。
Type-1 和 Type-2¶
虚拟化技术,能够克服上面的模拟器方式的两个缺陷。同时,需要放弃掉模拟器方式能做到的跨硬件平台的这个能力。解决方案就是加入一个中间层:在虚拟机技术里面,这个中间层就叫作虚拟机监视器,英文叫 VMM(Virtual Machine Manager)或者 Hypervisor。
Docker¶
存储¶
备注
NVME 是一个接口规范,现在存储用的也还是 SSD,只是这个接口带宽比 PCI-E 要高,顺序读写的吞吐率能做得更高。
🔥局部性原理¶
局部性原理(Principle of Locality):
1. 时间局部性(temporal locality)
这个策略是说,如果一个数据被访问了,那么它在短时间内还会被再次访问
2. 空间局部性(spatial locality)
如果一个数据被访问了,那么和它相邻的数据也很快会被访问
把访问次数多的数据,放在贵但是快一点的存储器里
把访问次数少的数据,放在慢但是大一点的存储器里
高速缓存¶
备注
为了弥补两者之间的性能差异,我们能真实地把 CPU 的性能提升用起来,而不是让它在那儿空转,我们在现代 CPU 中引入了高速缓存。在各类基准测试(Benchmark)和实际应用场景中,CPU Cache 的命中率通常能达到 95% 以上。
常见的缓存放置策略:
1. 直接映射 Cache(Direct Mapped Cache)
2. 全相连 Cache(Fully Associative Cache)
3. 组相连 Cache(Set Associative Cache)
如果内存中的数据已经在 CPU Cache 里了,那一个内存地址的访问,就会经历这样 4 个步骤:
1. 根据内存地址的低位,计算在 Cache 中的索引;
2. 判断有效位,确认 Cache 中的数据是有效的;
3. 对比内存访问地址的高位和 Cache 中的组标记,
确认 Cache 中的数据就是我们要访问的内存数据,
从 Cache Line 中读取到对应的数据块(Data Block);
4. 根据内存地址的 Offset 位,从 Data Block 中,读取希望读取到的字。
两种写入策略:
1. 写直达(Write-Through)
在这个策略里,每一次数据都要写入到主内存里面:
先去判断数据是否已经在 Cache 里面了。
如果数据已经在 Cache 里面了,我们先把数据写入更新到 Cache 里面,再写入到主内存里面
如果数据不在 Cache 里,我们就只更新主内存
2. 写回(Write-Back)
这个策略里,我们不再是每次都把数据写入到主内存,而是只写到 CPU Cache 里。
只有当 CPU Cache 里面的数据要被 “替换” 的时候,我们才把数据写入到主内存里面去。
备注
无论是写回还是写直达,其实都还没有解决这个问题:就是多个线程,或者是多个 CPU 核的缓存一致性的问题。这也就是我们在写入修改缓存后,需要解决的第二个问题。
MESI¶
多核缓存如何解决缓存不一致的问题:
1. 写传播(Write Propagation)
在一个 CPU 核心里,我们的 Cache 数据更新,
必须能够传播到其他的对应节点的 Cache Line 里
2. 事务的串行化(Transaction Serialization)
在一个 CPU 核心里面的读取和写入,在其他的节点看起来,顺序是一样的
备注
CPU Cache 里做到事务串行化,需要做到两点,第一点是一个 CPU 核心对于数据的操作,需要同步通信给到其他 CPU 核心。第二点是,如果两个 CPU 核心里有同一个数据的 Cache,那么对于这个 Cache 数据的更新,需要有一个 “锁” 的概念。只有拿到了对应 Cache Block 的 “锁” 之后,才能进行对应的数据更新。MESI 协议正是实现了这2点。
备注
总线嗅探(Bus Snooping):本质上就是把所有的读写请求都通过总线(Bus)广播给所有的 CPU 核心,然后让各个核心去 “嗅探” 这些请求,再根据本地的情况进行响应。基于总线嗅探机制,其实还可以分成很多种不同的缓存一致性协议。不过其中最常用的,就是 MESI 协议。和很多现代的 CPU 技术一样,MESI 协议也是在 Pentium 时代,被引入到 Intel CPU 中的。
总线嗅探有两种实现方式:
写失效(Write Invalidate)的协议(MESI 协议使用这种)
写广播(Write Broadcast)的协议
备注
写失效: 只有一个 CPU 核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个 CPU 核心写入 Cache 之后,它会去广播一个 “失效” 请求告诉所有其他的 CPU 核心。其他的 CPU 核心,只是去判断自己是否也有一个 “失效” 版本的 Cache Block,然后把这个也标记成失效的就好了。
备注
写广播: 一个写入请求广播到所有的 CPU 核心,同时更新各个核心里的 Cache。
对 Cache Line 的四个不同的标记,分别是:
注: MESI 协议,是已修改、独占、共享以及已失效这四个缩写的合称
M: 代表已修改(Modified)
表示“脏” 的 Cache Block
E: 代表独占(Exclusive)
表示“干净” 的 Cache Block
在独占状态下,对应的 Cache Line 只加载到了当前 CPU 核所拥有的 Cache 里
要向独占的 Cache Block 写入数据,我们可以自由地写入数据,而不需要告知其他 CPU 核
在独占状态下的数据,如果收到了一个来自于总线的读取对应缓存的请求,它就会变成共享状态
S: 代表共享(Shared)
表示“干净” 的 Cache Block
在共享状态下,因为同样的数据在多个 CPU 核心的 Cache 里都有
所以,当我们想要更新 Cache 里面的数据的时候,不能直接修改,
而是要先向所有的其他 CPU 核心广播一个请求,
要求先把其他 CPU 核心里面的 Cache,都变成无效的状态,
然后再更新当前 Cache 里面的数据
这个广播操作,一般叫作 RFO(Request For Ownership),
也就是获取当前对应 Cache Block 数据的所有权
I: 代表已失效(Invalidated)
内存¶
页(Page)
虚拟内存地址(Virtual Address)
物理内存地址(Physical Address)
地址转换(Address Translation)
页表(Page Table)
页号(Directory)
偏移量(Offset)
页表树(Page Table Tree):因为多级页表就像一个多叉树的数据结构
备注
【问题-简单页表】32 位的内存地址空间,页表一共需要记录 2^20 个到物理页号的映射关系。一个页号是完整的 32 位的 4 字节(Byte),这样一个页表就需要 4MB 的空间。但是:这个空间可不是只占用一份哦。我们每一个进程,都有属于自己独立的虚拟内存地址空间。这也就意味着,每一个进程都需要这样一个页表。
备注
在程序运行的时候,内存地址从顶部往下,不断分配占用的栈的空间。而堆的空间,内存地址则是从底部往上,是不断分配占用的。在一个实际的程序进程里面,虚拟内存占用的地址空间,通常是两段连续的空间(堆和栈)。而不是完全散落的随机的内存地址。而多级页表,就特别适合这样的内存地址分布。
备注
为啥用多级页表而不是哈希表:哈希表是数组 + 链表组成的,充分的结合了数组和链表的优势,互补!但是哈希表存在哈希冲突,并且是无序的!不符合局部性原理!
备注
【问题-多级页表】多级页表虽然节约了我们的存储空间,却带来了时间上的开销,所以它其实是一个 “以时间换空间” 的策略。原本我们进行一次地址转换,只需要访问一次内存就能找到物理页号,算出物理内存地址。但是,用了 4 级页表,我们就需要访问 4 次内存,才能找到物理页号了。
内存保护(Memory Protection):
1. 可执行空间保护(Executable Space Protection)
2. 地址空间布局随机化(Address Space Layout Randomization)
备注
【安全】通过让数据空间里面的内容不能执行,可以避免了类似于 “注入攻击” 的攻击方式。通过随机化内存空间的分配,可以避免让一个进程的内存里面的代码,被推测出来,从而不容易被攻击。
总线¶
总线(Bus)
双独立总线(Dual Independent Bus,缩写为 DIB)
总线裁决(Bus Arbitraction)
备注
总线的设计思路,核心是为了减少多个模块之间交互的复杂性和耦合度。
1. 快速的本地总线(Local Bus)
后端总线(Back-side Bus)
现代的 CPU 里,通常有专门的高速缓存芯片。这里的高速本地总线,就是用来和高速缓存通信的
2. 速度相对较慢的前端总线(Front-side Bus)
而前端总线主要是用于CPU、内存、IO 设备间的通信
根据总线本身的电路功能,通常有三类线路:
1. 数据线(Data Bus)
用来传输实际的数据信息
2. 地址线(Address Bus)
用来确定到底把数据传输到哪里去,是内存的某个位置,还是某一个 I/O 设备。
3. 控制线(Control Bus)
用来控制对于总线的访问
输入输出¶
平时说的,设备里面有三类寄存器,其实都在这个设备的接口电路上,而不在实际的设备上:
1. 状态寄存器(Status Register)
2. 命令寄存器(Command Register)
3. 数据寄存器(Data Register)
对于输入输出设备不同角度的描述:
1. Devices: 看重的是实际的 I/O 设备本身
2. Controllers: 看重的是输入输出设备接口里面的控制电路
3. Adaptors: 看重接口作为一个适配器后面可以插上不同的实际设备
磁盘¶
IO_WAIT¶
硬盘性能报告的两个指标:
1. 一个是响应时间(Response Time)
2. 一个是数据传输率(Data Transfer Rate)
数据传输率指标:
SATA 3.0 的接口: 带宽是 6Gb/s(768MB)
PCI Express 的接口:
在读取的时候就能做到 2GB/s 左右,差不多是 HDD 硬盘的 10 倍
在写入的时候也能有 1.2GB/s
随机读取磁盘上某一个 4KB 大小的数据:
在随机读写的时候,数据传输率也只能到 40MB/s 左右,是顺序读写情况下的几十分之一
IOPS 也就是在 2 万左右
HDD 硬盘:
数据传输率,差不多在 200MB/s
IOPS 通常在 100 左右
响应时间指标:
SSD 硬盘: 几十微秒
HDD 硬盘: 几毫秒到十几毫秒
机械硬盘¶
进行一次硬盘上的随机访问,需要的时间由两个部分组成:
1. 平均延时(Average Latency)
随机情况下,平均找到一个几何扇区,我们需要旋转半圈盘面
在7200 转的硬盘,那么一秒里面,就可以旋转 240 个半圈
平均延时:
1s / 240 = 4.17ms
2. 平均寻道时间(Average Seek Time)
现在用的 HDD 硬盘的平均寻道时间一般在 4-10ms
结论:
如果随机在整个硬盘上找一个数据,需要 8-14 ms
则:一秒钟随机的 IO 访问次数,也就是
1s / 8 ms = 125 IOPS 或者 1s / 14ms = 70 IOPS
备注
【黑科技】Partial Stroking 或者 Short Stroking:根据场景提升性能,可以使用缩短寻道时间来提升硬盘的 IOPS 的解决方案。如果我们只用其中 1/4 的磁道,那么,它的 IOPS 就变成了 1s / (4.17ms + 9ms/4) = 155.8 IOPS
。方案就是:只需要简单地把 C 盘分成整个硬盘 1/4 的容量,剩下的容量弃而不用就
SSD¶
1. SLC(Single-Level Cell)
2. MLC(Multi-Level Cell)
3. TLC(Triple-Level Cell)
4. QLC(Quad-Level Cell)
备注
SLC 的芯片,可以擦除的次数大概在 10 万次,MLC 就在 1 万次左右,而 TLC 和 QLC 就只在几千次了。
备注
【问】为什么 ssd 断电后不会丢数据?现在大家用的 SSD 的存储硬件都是 NAND Flash。实现原理和通过改变电压,让电子进入绝缘层的浮栅 (Floating Gate) 内。断电之后,电子仍然在 FG 里面。但是如果长时间不通电,比如几年,仍然可能会丢数据。所以换句话说,SSD 的确也不适合作为冷数据备份。
磨损均衡(Wear-Leveling)
FTL(Flash-Translation Layer),也就是闪存转换层
在 FTL 里面,存放了逻辑块地址(Logical Block Address,简称 LBA)到物理块地址(Physical Block Address,简称 PBA)的映射
DMA¶
傲腾(Optane)
DMA 技术,也就是直接内存访问(Direct Memory Access)
DMA 控制器(DMA Controller,简称 DMAC)。在主板上放一块独立的芯片,这块芯片,我们可以认为它其实就是一个协处理器(Co-Processor)
零拷贝(Zero-Copy)
总线上的设备呢,其实有两种类型:
一种我们称之为主设备(Master)
另外一种,我们称之为从设备(Slave)
想要主动发起数据传输,必须要是一个主设备才可以:
CPU 就是主设备
硬盘 就是从设备
DMAC 既是一个主设备,又是一个从设备:
对于 CPU 来说,它是一个从设备
对于硬盘这样的 IO 设备来说呢,它又变成了一个主设备
论文: Kakfa:a Distrubted Messaging System for Log Processing
数据完整性¶
纠错码(Error Correcting Code)
纠删码(Erasure Code)
海明码(Hamming Code)
备注
海明码的纠错能力是有限的,在 7-4 海明码里面,我们只能纠正某 1 位的错误。如果我们的数据位有 K 位,校验位有 N 位。那么我们需要满足下面这个不等式,才能确保我们能够对单比特翻转的数据纠错。这个不等式就是: K + N + 1 <= 2^N
海明码、hamming code: https://blog.csdn.net/Yonggie/article/details/83186280a
分布式计算¶
三个核心问题:
第一个核心问题,叫作垂直扩展和水平扩展的选择问题
第二问题叫作如何保持高可用性(High Availability)
第三个问题叫作一致性问题(Consistency)
1. 低响应时间(Low Response Time)
2. 高可用性(High Availability)
3. 高并发(High Concurrency)
4. 海量数据(Big Data)
5. 付得起对应的成本(Affordable Cost)
投递简历到 hr@bothub.ai 或者 recruit@abukito.com
书籍¶
网站¶
传世之文《Teach Yourself Programming in Ten Years》: http://norvig.com/21-days.html
中文版: https://blog.csdn.net/sinat_33487968/article/details/84504352
Build Software Systems at Google and Lessons Learned: http://www.valleytalk.org/wp-content/uploads/2011/08/101110-slides.pdf
[访谈]David Patterson 老爷爷的 <计算机体系结构新黄金时代:历史、挑战和机遇> 这个访谈: https://www.bilibili.com/video/av46710093/
北大的计算机组成原理公开课
入门书籍¶
《计算机是怎样跑起来的》
《程序是怎样跑起来的》
Coursera 上的北京大学免费公开课《Computer Organization》:
硬件层面的基础实现,比如寄存器、ALU 这些电路
深入学习书籍¶
《计算机组成与设计:硬件 / 软件接口》
经典的CSAPP《深入理解计算机系统》
视频课程: Bilibili 版和 Youtube 版
辅助的参考书: 操作系统大神塔能鲍姆(Andrew S. Tanenbaum)的《计算机组成:结构化方法》
课外阅读¶
Redhat 的 What Every Programmer Should Know About Memory 是写出高性能程序不可不读的经典材料
LMAX 开源的 Disruptor,则是通过实际应用程序,来理解计算机组成原理中各个知识点的最好范例
理解计算机硬件和操作系统层面代码执行的优秀阅读材料: * 《编码:隐匿在计算机软硬件背后的语言》 * 《程序员的自我修养:链接、装载和库》是
CSAPP-深入理解计算机系统¶
可以仔细读一下《深入理解计算机系统(第三版)》的 3.7 小节《过程》,进一步了解函数调用是怎么回事
编码:隐匿在计算机软硬件背后的语言¶
关于二进制和编码,我推荐你读一读这本书。从电报机到计算机,这本书讲述了很多计算设备的历史故事,当然,也包含了二进制及其背后对应的电路原理。
第 6~11 章: 是一个很好的入门材料,可以帮助你深入理解数字电路
想要深入了解计算机里面的各种功能组件,是怎么通过电路来实现的,推荐你去阅读《编码:隐匿在计算机软硬件背后的语言》这本书的第 14 章和 16 章
《编码:隐匿在计算机软硬件背后的语言》的第 17 章,用更多细节的流程来讲解了 CPU 的数据通路。
程序员的自我修养:链接、装载和库¶
想要更深入了解程序的链接过程和 ELF 格式,我推荐你阅读《程序员的自我修养——链接、装载和库》的 1~4 章。这是一本难得的讲解程序的链接、装载和运行的好书
想要更深入地了解代码装载的详细过程,推荐你阅读《程序员的自我修养 —— 链接、装载和库》的第 1 章和第 6 章。
想要更加深入地了解动态链接,我推荐你可以读一读《程序员的自我修养:链接、装载和库》的第 7 章,里面深入地讲解了,动态链接里程序内的数据布局和对应数据的加载关系。
计算机组成与设计:硬件 / 软件接口¶
想要对我们日常使用的 Intel CPU 的指令集有所了解,可以参看《计算机组成与设计:软 / 硬件接口》第 5 版的 2.17 小节
《计算机组成与设计:软 / 硬件接口》(第 5 版)的 1.7 和 1.10 节,也简单介绍了功耗墙和阿姆达尔定律,你可以拿来细细阅读
3.3 节:乘法器更多相关内容
想要更深入地了解浮点数的乘法乃至除法,可以参看《计算机组成与设计 硬件 / 软件接口》的 3.5.2 和 3.5.3 小节。
想要了解数据通路,可以参看《计算机组成与设计 硬件软件接口》的第 5 版的 4.1 到 4.4 节。专栏里的内容是从更高一层的抽象逻辑来解释这些问题,而教科书里包含了更多电路的技术细节。
《计算机组成与设计 硬件 / 软件接口》的 4.1 到 4.4 小节,从另外一个层面和角度讲解了 CPU 的数据通路的建立
想要了解 CPU 的流水线设计,可以参看《计算机组成与设计 硬件 / 软件接口》的 4.5 章节。
深入理解计算机系统¶
第 3 章,详细讲解了 C 语言和 Intel CPU 的汇编语言以及指令的对应关系,以及 Intel CPU 的各种寄存器和指令集
如果你想对阿姆达尔定律有个更细致的了解,《深入理解计算机系统》(第 3 版)的 1.9 节
想要了解 CPU 的流水线设计,可以参看《深入理解计算机系统》的 4.4 章节
数字逻辑应用与设计¶
对于数字电路和数字逻辑特别感兴趣,想要彻底弄清楚数字电路、时序逻辑电路,也可以看一看计算机学科的一本专业的教科书《数字逻辑应用与设计》