前言
听完了中国慕课上面的刘教授的《计算机组成原理》,总觉得知识有点太旧了,于是就自己去找了极客时间上面徐文浩老师的《深入浅出计算机组成原理》,一共55讲,花了大概四天时间全部听完(2020年11月19日到2020年11月23日),作为补充材料非常不错,对一些原来的知识有了更加深入的认识,同时也对一些内容有了新的认识,这里记录下原先那些我掌握的不好的或者有新的认识的内容。
入门篇
现代的程序员真的需要理解硬件吗?一开始我以为只要有个粗略的了解就行了,因为硬件提供了接口、操作系统为我们进行了封装、高级语言更是对这些细节进行了自己的优化,所以我们只需要有个抽象的理解就好了。真的吗?我看完徐老师的这一个系列课程之后就完全颠覆了这个认识,理解底层的知识,不仅能满足自己刨根的求知欲,而且对知识的掌握会非常踏实(当然面试的时候能够和面试官更好的扯皮)。
01 冯诺依曼体系结构
外部IO设备(通常就是键鼠、硬盘)是通过南桥这个芯片组,来和CPU进行通信的。北桥用来连接CPU和内存还有显卡的连接,目前北桥已经被移动到CPU内部了。
在手机里,则是把CPU、内存、网络芯片、摄像头芯片都集成在了一起,叫SoC(System on a Chip)。
冯诺依曼体系的计算机两个重要特点:可编程和存储。
硬盘既是IO设备,也是存储器。同时如果是云服务器,那么网卡既是输入设备,又是输出设备。
阅读材料:冯诺依曼的论文
思考题:冯诺依曼体系结构的计算机和图灵机的区别是什么?
我个人觉得图灵机主要解决的是什么问题可以让计算机处理,而冯诺依曼则是直接的物理架构。
02 知识图谱
存储器其实也在扮演IO设备的角色。
03 CPU的性能是什么
计算机性能的两大核心:响应时间和吞吐率。一个是让程序尽可能快,另一个是让指令执行的尽可能多,而且如果程序尽可能快,那么自然也能执行的多。
性能公式:
程序的 CPU 执行时间 = CPU 时钟周期数 × 时钟周期时间
而CPU 时钟周期数 = 指令数 × 每条指令的平均时钟周期数(CPI)
所以最后就是 程序的 CPU 执行时间 = 指令数×CPI×Clock Cycle Time
这个性能公式贯穿了之后的所有内容。第一个指令数的多少主要靠编译器的优化;第二个CPI主要靠的是流水线技术让每条指令的平均时间缩短;第三条则是靠提升主频。
04 从主频优化性能
确实,主频高了性能确实可以提高,但是也对散热提出了更高的要求。
为了散热,一种可能的思路就是把CPU做的大点,但是由于做的大了,物理间隔大了,会导致速度变慢(由于CPU实在太快了,就算那么一丝丝的大小也会影响)。
功耗的性能:
功耗 = 负载电容×电压的平方×开关频率×晶体管数量
晶体管数量想要提升,就需要提高制程,这就是现在的更小的纳米技术。而电压也可以让功耗减小,所以笔记本使用了低压U。当然还有多核来提高吞吐率,这就是并行的思想。
原理篇-指令和运算
05 计算机指令
为什么我们现在的计算机是存储型计算机?因为CPU没办法保存信息,需要把指令和数据放到内存中,然后再去取回来。
这里还可以解释下为什么C语言会比Python快。因为C语言编译、链接之后最后形成的就是机器码,而Python则是由解释器在执行的时候,一条条翻译指令的。
06 if-else的本质
三种重要的寄存器:PC寄存器、IR寄存器和条件码寄存器。就是这三个寄存器能直接实现我们的if-else和循环。通用寄存器是因为它又能放数据、又能放地址。
if-else编译成汇编代码之后,其实就是cmp + jne这种组合。同样的,for和while循环其实也是cmp + jne类似的,只不过它们往前面跳。
07 函数调用
函数调用就是把参数入栈,然后跳转到对应的位置就可以了。当然如果寄存器够用就不需要入栈了,只需要把参数放到寄存器里面就可以了,放好对应的参数之后,还需要往栈里放对应的返回地址,然后才进行跳转(往栈里面放返回地址+跳转这两步其实是由call一条指令完成的)。
到了被调用的函数里面,有一个固定的套路,就是把rbp入栈,并且把rsp的值放入到rbp中,最后函数执行完毕之后再恢复。同理ret这个指令其中也隐含了从栈中弹出CS:IP的值并且返回。
栈溢出还可以通过在栈里面分配超级大的数组。
这里顺便还学习了内联函数。简单来说就是直接把函数的代码拷贝到调用它的地方展开,这样可以省去栈的操作。比较适用于一个不调用其他函数的函数。
08 ELF
只有通过链接器把多个目标文件变成一个可执行文件之后,才可以被执行。
ELF文件里面有一个重定位表,里面有所有它所不知道地址的函数;也有一张符号表,是它所知道的函数的地址。所以链接器说白了也就是收集了所有的目标文件的信息生成全局符号表,然后就能知道所有的函数所对应的地址了。
linux下可执行文件是ELF,windows下则是PE格式的,所以两者不兼容。
09 程序装载
分段,是把虚拟空间映射到一段连续的物理空间上去。这样内存碎片会比较多,所以就有了分页机制。
10 动态链接
共享库的地址无关是很重要的,也就是把动态链接库扔到内存的任意一个位置都可以。
动态链接是靠PLT和GOT来解决的。这部分说实话我觉得他讲的不够深入,我也不太理解。
11 二进制编码
对于一个在int范围的整数,我们只需要4个字节就可以表示了,但是如果把这个数字当成字符串,那么就会浪费非常多的空间,这个道理告诉我们在序列化的时候,如果从空间层面考虑,还是利用二进制序列化比较好。
unicode是一个字符集合,而utf-8则是一个映射关系(函数)、一个编码方式。但是实际中应用中,我们不可能脱离字符集谈编码方式,而且对于程序员来说,也完全不需要接触到字符集合,只需要了解编码和解码方式就可以了。
GB2312、GBK等都是常见的中文编码方式。
烫烫烫的来源:未初始化的内存默认是0xCCCC,而在调试器使用的字符集里面,0xCCCC刚好就是烫,也就是说你访问了未初始化的内存地址。
12 电报机如何传输
就是讲了可以通过电磁效应,来完成中继的功能。
13 加法器
两个只有一位的二进制加法,如果完全不考虑进位,那么其实就是一个异或门。那进位怎么办呢?其实也很简单,因为当且仅当两个数都是1的时候,相加才会产生进位。
这就是最简单的半加器。因为实际中其实是当前位置的两个比特和来自上一位的进位,一共三个1bit的二进制相加。最后实现其实也很简单,只需要把得到的和与上一位的进位也进行加法操作,如果和与上一位的进位操作能够进位,那么这一位必然进位,下图是全加器的实现:
接下来我们只需要根据需要,把这些全加器串联在一起就可以了。最后记得最右侧的那一位让进位为0就可以了。
理解了电路,现在也能理解为什么计算机能够知晓计算结果是否溢出了吧。虽然寄存器只有64位,但是实际可以获取电路的最高位的进位的值,如果该值是1,那么就说明发生了溢出,而且这个值会被送到条件寄存器里面。
14 乘法器
计算机内部的乘法其实和我们手写的乘法运算也是一样的。但是由于乘数只可能由零和一组成,所以其实只需要对被乘数进行移位相加操作就够了。
当然如果真这么做,效率是很低,相当于是顺序进行的,实际中不论是加法器还是乘法器,都通过电路的优化(通过更复杂的电路)来达成并行计算以提升效率。
15 浮点数-上
由于有无限个小数,而我们的位数是有限的,所以显然不可能精确表达出所有的数字的。比如32位就只能映射40亿个数字。
在超市收银机跑的系统一般用的都BCD编码,用24位(0-9用4个比特来表示)来表示整数,8位来表示两位小数,所以表示范围是0.00——999999.99。由于小数点固定,所以BCD被叫做是定点数。
16 浮点数-下
这里有一个在线的浮点数转换器,蛮好用的。
浮点数的加法是怎么做的。先对齐,然后进行计算。对齐的时候会把较小的指数位通过移位操作来变成和较大的那个一致的(当然有效位也要操作),然后进行加法操作。但是移位的过程中,较小的那个数需要右移来放大,右移会导致精度丢失,如果移动过大,甚至就全部移没了(在float下面是24位,大约1600万),就会发生所谓的(绝对值)大数吃(绝对值)小数现象的发生。
当然如果接受不了精度损失,可以使用相应的算法来保证不丢失。算法的思想很简单,就是把损失的精度给记起来,然后补上去就好了。
原理篇-处理器
17 建立数据通路(上)
指令周期 = 取指令+翻译指令+执行指令的时间和。
机器周期/CPU周期 = 从内存中读取一条指令的最短时间。
时钟周期 = CPU滴答一次的时间。
操作元件,也叫组合逻辑元件,其实就是ALU,给定特殊的输入就会给出特定的输出。
存储元件,也叫状态元件,寄存器就是一种。
把它们连接起来,就可以完成数据的存储、处理和传输了,这就是所谓的建立数据通路了。
CPU看上去永远都在读取指令并且执行,那么为什么还有满载运行和闲置状态呢?
网上找到的答案:其实,CPU在空闲状态就会停止执行,具体来说就是切断时钟信号,CPU的主频就会瞬间降低为0,功耗也会瞬间降低为0。由于这个空闲状态是十分短暂的,所以在任务管理器里面也只会看到CPU频率下降,不会看到降低为0。当CPU从空闲状态中恢复时,就会接通时钟信号,这样CPU频率就会上升。所以会在任务管理器里面看到CPU的频率起伏变化。
18 建立数据通路(中)
如何产生定时的周期时间信号呢?一个简单的思路就是可以利用一个电路,这个电路一旦通电就会产生磁力,而磁力会让电路断开;一旦断开之后电路又会接通,周而复始,就能产生对应的周期时间信号了。
上面这个图就是一个触发器电路。这个电路非常非常有意思,当把SR这两个开关都关掉的时候,输出的结果保存的是上一次输出的结果,非常有意思。这个电路就可以用来帮助存储信息了。
19 建立数据通路(下)
有了上面的时间信号和触发器电路来存储信息,那PC自然就很容易实现了,只需要每隔一个时间信号把触发器电路里面的值加一就好了。
译码器就更简单了,就是通过输入的线的信号来输出特定的信号。
这样所有的CPU内部部件都完成了。
20 流水线(上)
流水线只需要保证,最复杂的一个流水线阶段能够在一个时钟周期里完成即可。
流水线提升效率的本质是提升吞吐率。
流水线也不是越深越好,因为其实两个流水线之间还需要有一个流水线寄存器来保存数据,而这个的开销在超长流水线里面也是不容小觑的。
21 流水线(下)
因为把指令周期拆分成了N个步骤,而这N个步骤可以放到流水线里面,同时只需要保持在一个时钟周期里面完成一个流水线的一个stage阶段即可,所以可以把主频做的很高很高。
在相同主频下,流水线越长效率越低。相当于一条指令的周期被硬生生拉长了。
奔腾4设计了超长流水线,惨遭失败。流水线深度一加,必然要提升主频,且还需要更多的晶体管,散热就挡不住了。同时超长流水线也导致了其严重影响了乱序执行等技术,效率进一步下降。
22 冒险和预测(一)
流水线技术的三大冒险:结构冒险、数据冒险和控制冒险。
结构冒险就是两条指令的不同stage需要用到相同的电路。这个的解决方案也很简单,我们目前的CPU中的三级缓存中的L1其实是分成数据缓存和指令缓存的。
数据冒险也很简单,除了读后读不会发生问题外,其他都可能发生问题。有一个最简单解决的办法就是:既然会发生依赖,那就再等等好了。实际中时钟信号不会停,所以其实实际上是插入了一个NOP操作,当然如果当前指令插入了NOP,那么接下来的所有指令都需要插入NOP。
23 冒险和预测(二)
操作数前推:我们没必要把执行结果放入到寄存器,然后让接下来的指令再从寄存器中取。而是直接把结果丢给ALU就好了。在物理上的实现就是通过一条线把ALU的输出又接回到自己的输入中。
24 冒险和预测(三)
在完成指令的译码之后,会把指令放到一个叫做保留站的地方,然后只有当所依赖的数据传递给它们之后才会执行。执行完成之后,会放到一个叫作重排序缓冲区(Re-Order Buffer,ROB)的地方,CPU会再次对它们进行排序,只有前面的指令完成了,才会提交指令。
所以乱序只是在CPU的内部,到寄存器层面,甚至更后面的高速缓存、内存都是有序的。
注意,虽然上面说了乱序在CPU内部,但是多线程还是会因为指令乱序执行而发生问题。
25 冒险和预测(四)
最简单的分支预测,就是假设不发生分支。如果猜错的话,不仅需要丢弃,还需要做对应的清除操作。稍微复杂点就是根据上一次的结果来推测下一次结果。
下面的java代码就很好的说明了分支预测对代码的影响:
1 | public static void main(String args[]) { |
上面的循环会比下面的快很多。因为在循环中都是假设分支不发生,也就是会预测循环继续下去。那么第一个预测错误次数是100 x 1000 x1,而第二个循环预测错误的次数则是10000 x 1000 x1,显然第一种错的少。
26 Superscalar和VLIW
把重心放到一次多取几条指令,然后分发给多个并行的指令译码器就可以了。这就是多发射和超标量。
超标量就是取指令和译码阶段都是并行的,而多发射就是把指令发送给多条流水线。
27 超线程和SIMD
一直很好奇,为什么CPU的贩卖页面上会说CPU支持多少线程,这里的线程是什么东西啊?因为在同一个线程中的代码会有依赖关系,所以才会需要解决这些问题。但是如果是两个线程里的代码呢?超线程就是通过增加相应的硬件(用来保存状态这些),这样一个物理的单核的CPU,也可以看成是两个逻辑上的CPU。本质上是,CPU在执行A线程某条指令的时候,发生了等待,那就去指令线程B的指令。
所以超线程只有对IO密集型任务作用良好,如果是CPU密集的,因为它也只有一个ALU。
SIMD其实就是利用了并行这个特性,一次性加载4个int的数字来进行加速(取多个数据,让一条指令来完成),所以特别适合矩阵的运算。也正是有了它,我们的计算机才能直接使用芯片来解析MP3和视频。
28 异常和中断
异常包含四种:interupt、trap、fault和abort。这四种处理程序都是一样的,如果要压栈的话压入的是内存栈。
29 CISC和RISC
是因为二八规则才有了RISC。因为都是简单的指令,所以电路简单,这样可以放更多的寄存器,相比从内存中取数据,自然快很多。
32位的x86是英特尔搞得,但是x86-64是AMD搞的。
目前其实指令译码阶段,译码得到的也是很多微指令。当然译码器也更加复杂,时间效率有所降低。所以新增了L0的cache,让译码的结果保存在L0中,进行加速。
30 GPU(上)
3D图形的渲染过程:
- 顶点处理:把三维空间的顶点映射到二维的屏幕上来
- 图元处理:把顶点连起来。当然可以通过一些计算让看不到的图形不进行计算。
- 栅格化:把图元染上像素点。
- 片段处理:给像素点上色。
- 像素操作:混合这些上完色的像素。
这些操作交给CPU是完全可以执行的,但是由于CPU里面还有复杂的电路,而在图形渲染过程中根本用不到,所以就有了GPU,一种为了上述这些流程而特地打造的“CPU”。
31 GPU(下)
然后GPU慢慢发展,也可以实现一些简单的指令了,有了一个通用的计算模块。然后把CPU那些复杂的电路一砍,性能就提升了。
32 FPGA、ASIC
FPGA亲自玩过,一点都不好玩。本质上就是能够编码硬件的东西,其实就是把硬件实现的东西,比如与门,里面用更多的电路,允许你进行真值表的编写,这样就能达成“编程硬件”。ASIC则是用来专业领域的芯片,是烧制好的。
33 TPU
TPU则是谷歌用来做机器学习识别的一种专门的芯片,特异化的一种ASIC。主要用来做机器学习的推断过程。
34 虚拟机
AWS是世界上最大的云服务提供商,阿里云则是国内最大的云服务提供商,看来做电商确实需要很多服务器。
最早的虚拟机其实是模拟器,简单说做的工作就是翻译指令。比如Android模拟器就是把ARM的指令翻译成x86的。但是翻译终究要损失不少性能,所以现在的虚拟机解决是靠虚拟机监视器这个中间层。这个中间层既可以通过type2的方式,利用翻译的方法来做(基本就是个人PC上的虚拟机),也可以通过type1的方法直接把指令传递给硬件,而且在type2下虚拟机监视器可以直接嵌在操作系统里面,也就是操作系统里面的kvm、xen和Intel的hyper-V,这也可以解释为什么开启了hyper-V之后,那些安卓模拟器就用不了了(因为ARM和x86肯定是不兼容的嘛)。但是docker又必须开启hyper-V。所以docker其实不算虚拟化技术,只是一种隔离技术而已。
存储和IO系统
35 存储器
硬盘既是一个IO设备,也是存储器。
内存和L1 Cache之间速度差了100倍。
36 局部性原理
没什么好说的。
37 CPU cache(上)
1 | public static void main(String[] args) { |
明显循环2的次数是循环1的16分之一,但是由于CPU cache的存在,导致其实这两个循环消耗的时间几乎是一样的。
CPU cache的最小是64字节,所以正好是16个int。
38 CPU cache(下)
java的线程是从主内存取数据,然后把数据放到线程自己的工作内存(也叫本地内存中)然后进行执行。而这个本地内存其实不一定是CPU cache,也可以是寄存器、写缓冲区,这个只是java给我们的一个抽象。当然从理解上来说,可以把它当做CPU cache。
39 缓存一致性
缓存一致性问题的通用解决方法:写传播(就是这里更新了之后,需要让别的地方也知道)、事务串行化(所有组件做的操作都是同一个顺序)。
数据传播可以通过总线嗅探技术解决,基于这个总线嗅探机制,就有了MESI协议。MESI协议的核心就是有一个master的核心,然后这个核心写入了cache之后,就会广播失效的广播给其他核心。
MESI还有一个核心的机制,就是有一个独占和共享状态。独享的话就可以随便修改,而共享的话就需要发送无效的广播。
40 虚拟内存和多级页表
所述内容之前已经了解了。
41 TLB和内存保护
TLB其实还分成ITLB和DTLB(指令和数据),也可以根据大小进行分级。首先是CPU里面的MMU会和TLB进行交互,看能不能命中。
42 总线
CPU需要和CPU cache进行数据交流,通过本地总线(也叫后端总线)进行沟通。
然后是有一个IO桥接器,这个桥接器通过系统总线和CPU连接,通过内存总线和内存连接,通过IO总线和IO设备连接。
43 输入输出设备
其实每个IO设备,都会有一个接口(里面有控制电路、三种寄存器,还可能有缓冲区)+实际IO设备。接口相当于充当了一个中介,CPU是和接口进行通信,然后接口再和实际的IO设备通信。有的设备是自己带着接口欧的,而有些接口则是在主板上的。比如IDE硬盘就是自己带接口,而U盘的接口则是在主板上。
设备的三类寄存器都在接口上。三类寄存器是数据、命令和状态寄存器。
CPU向这些IO设备发送信息,会把IO设备的寄存器映射到内存中。同时Intel也支持使用端口来映射:

上图就是内存映射+端口映射的显卡。
44 IO_WAIT
顺序读写和随机读写的速率真的天差地别。显然我们在读取数据库的时候看重的是随机读写能力,而从性能优化角度,我们应该尽可能把随机访问变成顺序读写。
45 google硬盘黑科技
普通机械硬盘的随机读写为什么差?因为需要旋转,物理上花的时间太多了。
那一秒钟到底支持多少的读写呢?首先需要转到对应的位置,平均是转半圈,那么按照转速是7200转,0.5/120 = 4ms
悬臂还需要找到指定的位置的扇区,这个移动时间是4ms-10ms,所以最终耗时大概是8ms-14ms,这样大概刚好1秒随机访问100次左右。SSD大概是2万次。
所以性能的提升就从这两个时间中缩短:要么提高硬盘的转速,要么缩短悬臂到指定磁道的时间。只要用黑科技让所有的数据都放到最外侧的磁道里,就不需要寻道时间了,但是这样太极限了,实际中就用最外侧的一半的磁道。
46 SSD(上)
SSD除了耐用性差点,其他几乎无懈可击。
电容可以“保留”一些电压,所以其实SSD就是利用这个特性,给电压分了16级,这样就可以表示4个比特(QLC)了。
然后SSD保存数据的结构从大到小是这样子的:裸片、平面、块、页。
SSD的读取写入基本单位是页,擦除的话必须以块为单位进行擦除。所以实际中SSD会等到块中所有页都被逻辑删除了,才会去做擦除操作。当然如果空间紧张它也会选择一个块,把其中有效的数据搬走,然后再擦除。
所以我们不能对SSD进行碎片整理。
同样也可以解释为什么SSD大多是240G,因为它实际有一部分空间用来做闪展腾挪了,其实内部总量还是256G。
所以SSD适合用来做读多写少的任务,不适合用来做电影库这种的。
47 SSD(下)
SSD还有一个映射层,来保证不同块之间的磨损均匀,类似于虚拟内存和物理内存的映射。不过这个是在物理层面里面的了,完全对操作系统透明。
删除文件其实只是删除了inode而已,这个对机械硬盘无所谓,但是对于SSD就有关系了。因为SSD需要搬动对应的数据,而只有当新的inode被使用的时候,SSD才知道发生了删除,而此时可能它已经搬动了好几次那个被应该被删除的数据。
当然现在的所有主流操作系统都支持直接告知SSD此文件已经被删除了。
48 DMA
DMA在传输超大的数据、或者是超小但是很慢的数据的时候很有用。
其实只有CPU才能向硬盘发送数据,或者从硬盘写数据。硬盘其实只能发送信号给CPU,而真正去读取啥的还是CPU做的。当然现在其实就依赖DMA来做了。
CPU只负责设置好源、目的地址和数据量。
DMA和硬盘通信用的是另外一根总线。
Kafka消息队列依靠DMA来进行加速,因为消息队列,所以需要从磁盘中读取数据,并且把数据通过网卡发送出去,那么这之间的过程是这样子的:硬盘到内核的缓冲区,内核缓冲区到应用内存,应用内存到socket缓冲区,socket缓冲区到网卡。显然从内核缓冲区到内存,再从内存到socket缓冲区,数据的搬运是由CPU完成的。
其实真的没必要,直接用java的NIO库,把数据从磁盘搬动到内核,然后直接从内核给到socket缓冲区就可以了。
49/50 数据完整性
可以利用海明码进行错误的检查和纠错,只要符合相应的条件就好了,其中数据位N位,校验码K位:
K + N + 1 <= 2N
应用篇
51 分布式计算
垂直扩展可以理解为给机器加配置;水平扩展可以理解为加更多一样的机器。显然只有水平扩展能够提高可用性。
水平扩展相当于是把内存中传递变成了消息间传递。可用性指的是系统正常服务的时间的占比,所以就算是系统要进行升级,也是一种不可用状态。
54/55 Disruptor
java写的一个高性能队列,速度逼近硬件的极限。
利用前后填充7个long类型的数字,让想要的常量数字永远在cpu cache中,保持最高的效率。这么做其实是为了防止这个变量的前后的那些变量被其他的线程所访问。
同时,在能用数组和链表的时候,显然数组的性能会更加好。
无锁可以比有锁快得多的多。靠的是CAS,而CAS底层用的是cmpxchg系统调用,底层的汇编指令是:
cmpxchg %ecx, %ebx如果EAX与EBX相等,则ECX送EBX且ZF置1;否则EBX送EAX,且ZF清0
AtomicLong底层用的也是CAS,不过由于volatile的存在,导致其实还是会去内存刷新值,所以其实速度没那么快。