一文了解计算机组成原理

1,223 阅读43分钟

根据专栏 深入浅出计算机组成原理 总结而来

计算机基本的硬件组成

一般来说,一台计算机主要由CPU、内存、主板、显卡以及各种I/O设备组成。如下图所示,图片来源

image.png

  • CPU:中央处理器(Central Processing Unit),计算机的所有“计算”都是由 CPU 来进行的。
  • 内存:程序运行时的存储空间,同时还存储程序运行时所需的数据。比如我们撰写的程序、打开的浏览器、运行的游戏,都要加载到内存里才能运行;以及程序读取的数据、计算得到的结果,也都要放在内存里。
  • 主板:为CPU、内存、主板、显卡以及各种I/O设备提供电气连接和数据传输通道。比如 CPU 和内存之间通过主板的芯片组(Chipset)和总线(Bus)来实现相互通信;鼠标、键盘以及硬盘作为外部 I/O 设备,它们是通过主板上的南桥(SouthBridge)芯片组,来控制和 CPU 之间的通信的。
  • 显卡:显卡的主要作用是处理和渲染图形,将CPU发出的图像指令和数据转换成显示器能够理解的信号,从而将图像显示在屏幕上。
  • I/O设备:是指输入/输出设备,常见的有输入设备有鼠标、键盘,输出设备有显示器。而硬盘既是输入设备也是输出设备。

需要注意,上面提到的是计算机的基本组成,在手机中是不一样的。比如由于尺寸的原因,手机制造商们选择把 CPU、内存、网络通信,乃至摄像头芯片,都封装到一个芯片,然后再嵌入到手机主板上。这种方式叫SoC,也就是 System on a Chip(系统芯片)。

冯·诺依曼体系结构

不论是个人电脑、手机、还是服务器,都遵循着冯·诺依曼体系结构。该结构由 运算器控制器存储器输入设备输出设备 五部分组成,如下图所示,图片来源

  • 输入设备:读取输入信息
  • 运算器:进行运算
  • 控制器:控制一条条指令的执行
  • 存储器:存储指令和数据
  • 输出设备:将结果输出

冯·诺依曼体系结构(Von Neumann architecture)和哈佛结构(Harvard architecture)的对比

img_temp_65148694bdf011-59922329-57143150.png

计算机除了冯·诺依曼体系结构外,还有一个哈佛结构。如上图所示,它们的主要区别是是否区分指令与数据

冯·诺依曼体系结构的指令和数据都是由一个总线,因此指令和数据必须串行。而哈佛结构的指令和数据是分开的,因此获取指令和获取数据可以同时进行。 可以看出,哈佛结构比冯·诺依曼体系结构更加高效,但是实现也更复杂。更多关于冯·诺依曼体系结构和哈佛结构的对比可以看为什么电脑还沿用冯·诺伊曼结构而不使用哈佛结构?

冯·诺依曼计算机中指令和数据均以二进制形式存放在存储器中, CPU 如何区分它们

CPU 执行一个指令,会进行如下的步骤:

  1. 取指令:CPU必须从存储器(寄存器、cache、主存)读取指令
  2. 解释指令:必须对指令进行译码,以确定所要求的动作
  3. 取数据:指令的执行可能要求从存储器或输入/输出(I/O)模块中读取数据
  4. 执行指令:指令的执行可能要求对数据完成某些算术或逻辑运算
  5. 结果回写:执行的结果可能要求写数据到存储器或I/O模块

可以看到 CPU 根据指令周期(一个指令取出到执行所需要的时间)的不同阶段来区分。

图灵机和冯·诺依曼机是两种不同的计算机么?图灵机是一种什么样的计算机抽象呢?

两者有交叉但是不同。图灵机是一种思想模型(计算机的基本理论基础)它告诉我们什么样的问题是计算机解决得了的,什么样的问题是解决不了的。而冯诺依曼体系结构的计算机是对“可计算”式计算机的种实现,侧重于硬件的抽象。

计算机的性能

对于计算机的性能,主要有两个指标来体现,分别是响应时间(Response time)、吞吐率(Throughput)。

  • 响应时间,或者叫执行时间(Execution time):指的就是,我们执行一个程序,到底需要花多少时间。花的时间越少,自然性能就越好。
  • 吞吐率,或者叫带宽(Bandwidth):是指我们在一定的时间范围内,到底能处理多少事情。这里的“事情”,在计算机 里就是处理的数据或者执行的程序指令。

我们一般把性能定义成响应时间的倒数,即:性能 = 1 / 响应时间

直接获取时间的问题

在计算机中,由于CPU对程序的调度,导致统计程序运行的时间是不准确的。这时可以使用 linux 的 time 命令。

$ time seq 1000000 | wc -l
1000000

real 0m0.101s
user 0m0.031s
sys  0m0.016s

其中 real time,也就是运行程序整个过程中流逝掉的时间;第二个是 user time,也就是 CPU 在运行你的程序,在用户态运行指令的时间;第三个是 sys time,是 CPU 在运行你的程序,在操作系统内核里运行指令的时间。而程序实际花费的 CPU 执行时间(CPUTime),就是 user time 加上 sys time。(需要确保是单核运行,否则它们的和可能会超过 real time)

即使我们已经拿到了 CPU 时间,我们也不一定可以直接“比较”出两个程序的性能差异。即使在同一台计算机上,CPU 可能满载运行也可能降频运行,降频运行的时候自然花的时间会多一些。除了 CPU 之外,时间这个性能指标还会受到主板、内存这些其他相关硬件的影响。

使用CPU时钟周期数(CPU Cycles)和 时钟周期时间(Clock Cycle)的乘积

上面说到,直接获取的时间不可靠。代替的,可以使用 程序的 CPU 执行时间 = CPU 时钟周期数 × 时钟周期时间 来计算程序的 CPU 执行时间

其中CPU 时钟周期数是指 CPU 执行一段程序或完成一个特定任务所经历的时钟周期的数量;时钟周期时间是主频的反比,比如某 CPU 的主频为 2GHz,那么它的时钟周期时间就是1÷2×109=0.5×1091\div2\times10^{-9}=0.5\times10^{-9}秒,即 0.5 纳秒。

从公式可以看出,提升性能有两种方向:一是缩短时钟周期时间,也就是提升主频(也就是换一块CPU);二是减少程序需要的 CPU 时钟周期数量。我们可以把 CPU 时钟周期数 看成 指令数 × 每条指令的平均时钟周期数(Cycles Per Instruction,简称 CPI),即 程序的 CPU 执行时间 = 指令数 × CPI × 时钟周期时间

如果我们想要解决性能问题,其实就是要优化这三者:

  1. 时钟周期时间,通过提高计算机的主频
  2. CPI,现代的 CPU 通过流水线技术(Pipeline),让一条指令需要的时钟周期尽可能地少
  3. 指令数,代表执行我们的程序到底需要多少条指令、用哪些指令。这个很多时候就把挑战交给了编译器。同样的代码,编译成计算机指令时候,就有各种不同的表示方式。

功耗墙

上文介绍过,我们可以从指令数、CPI 以及 CPU 主频这三个地方来提升计算机的性能。过去,行业一直是通过提高主频来让CPU更快。但是随着主频的提高,其带来耗电和散热的问题也越来越难解决。其公式为 功耗 ~= 1/2 ×负载电容×电压的平方×开关频率×晶体管数量

根据上面的公式为了降低功耗和减少散热问题,我们可以把CPU的电压减少。比如从 5MHz 主频的 8086 到 5GHz 主频的 Intel i9,CPU 的电压已经从 5V 左右下降到了 1V 左右。这也是为什么我们 CPU 的主频提升了 1000 倍,但是功耗只增长了 40 倍。电压的问题在于两个,一个是电压太低就会导致电路无法联通,因为不管用什么作为电路材料,都是有电阻的,所以没有办法无限制降低电压,另外一个是对于工艺的要求也变高了,成本也更贵啊。

由于提升主频遇到的困难比较多,开始推出多核的 CPU,通过提升“吞吐率”而不是“响应时间”,来达到提升计算机性能的目的。

并不是所有问题,都可以通过并行提高性能来解决。如果想要使用这种思想,需要满足这样几个条件。

  1. 第一,需要进行的计算,本身可以分解成几个可以并行的任务。好比上面的乘法和加法计算,几个人可以同时进行,不会影响最后的结果。
  2. 第二,需要能够分解好问题,并确保几个人的结果能够汇总到一起。
  3. 第三,在“汇总”这个阶段,是没有办法并行进行的,还是得顺序执行,一步一步来。

这就引出了我们在进行性能优化中,常常用到的一个经验定律,阿姆达尔定律(Amdahl’s Law)。这个定律说的就是,对于一个程序进行优化之后,处理器并行运算之后效率提升的情况。具体可以用这样一个公式来表示:优化后的执行时间 = 受优化影响的执行时间 / 加速倍数 + 不受影响的执行时间

在“摩尔定律”和“并行计算”之外,在整个计算机组成层面,还有这样几个原则性的性能提升方法。

  1. 加速大概率事件。最典型的就是,过去几年流行的深度学习,整个计算过程中,99% 都是向量和矩阵计算,于是,工程师们通过用 GPU 替代 CPU,大幅度提升了深度学习的模型训练过程。本来一个 CPU 需要跑几小时甚至几天的程序GPU 只需要几分钟就好了。
  2. 通过流水线提高性能。我们把 CPU 指令执行的过程进行拆分,细化运行,也是现代 CPU 在主频没有办法提升那么多的情况下,性能仍然可以得到提升的重要原因之一。
  3. 通过预测提高性能。通过预先猜测下一步该干什么,而不是等上一步运行的结果,提前进行运算,也是让程序跑得更快一点的办法。

在这一讲里面,介绍了三种常见的性能提升思路,分别是,加速大概率事件、通过流水线提高性能和通过预测提高性能。请你想一下,除了在硬件和指令集的设计层面之外,你在软 件开发层面,有用到过类似的思路来解决性能问题吗? 1.加速大概率事件 各种缓存(内存缓存、CDN缓存) 2.流水线 并发编程、异步编程

信息的表示和处理

整数

在计算机中,所有的数据都是由二进制表示的。其中整数的表示形式是原码和补码。其中,原码是一种计算机中对数字的二进制定点表示方法,原码的最高位为符号位,0 表示正数,1 表示负数,其余位表示数值的绝对值。假设使用 8 位二进制表示原码,示例如下:

5 的原码:0000 0101
-5 的原码:1000 0101

原码的优点是简单直观,但是缺点原码的符号位是无法直接参与运算的。 在计算机中,我们需要使用补码来做运算,正数的补码与原码相同,负数的补码是其原码的符号位不变,其余位取反后加 1。示例如下:

5 的原码:0000 0101,补码:0000 0101
-5 的原码:1000 0101,补码:1111 1011

计算机还有一个反码的概念,正数的反码与原码相同,负数的反码是其原码的符号位不变,其余位取反。比如 -5 的原码:1000 0101,反码:1111 1010。可以看到,补码 = 反码 + 1

这里我们分别使用原码和补码来做加法,来看看效果,示例如下:

原码相加:

  1000 0101  (-5 的原码)
+ 0000 0101  ( 5 的原码)
-----------------
  1000 1010  (-10 的原码)
  
补码相加:

  1111 1011  (-5 的补码)
+ 0000 0101  ( 5 的补码)
-----------------
  0000 0000  (0 的补码)

可以看到,使用补码相加的加法运算的结果才是正确的。计算机中使用反码就是为了解决正数和负数相加的结果不正确的问题,但是只使用反码会出现 +0-0 两种结果。为了解决这个问题,就出现了补码。具体内容可以看 原码、反码、补码

小数

在计算机中,小数一般有两种表示方法,分别为定点数浮点数

定点数

定点数约定了所有数值数据的小数点隐含在一个固定位置上。例如:就是我们用 4 个比特来表示 0~9 的整数,那么 32 个比特就可以表示 8 个这样的整数。然后我们把最右边的 2 个 0~9 的整数,当成小数部分;把左边 6 个 0~9 的整数,当成整数部分。这样,我们就可以用 32 个比特,来表示从 0 到 999999.99 这样 1 亿个实数了

缺点:

  1. 定点数的表示方式有点浪费
  2. 定点数的表示方式没办法同时表示很大的数字和很小的数字

定点数的运用非常广泛,最常用的是在超市、银行这样需要用小数记录金额的情况里。在超市里面,我们的小数最多也就到分。这样的表示方式,比较直观清楚,也满足了小数部分的计算。

浮点数

浮点数的科学计数法的表示,有一个IEEE的标准,它定义了两个基本的格式。一个是用 32 比特表示单精度的浮点数,也就是我们常常说的 float类型。另外一个是用 64 比特表示双精度的浮点数,也就是我们平时说的 double类型

屏幕截图 2025-05-05 081940.png

其中:

  • 符号位:用来表示是正数还是负数
  • 指数位:8位表示指数位
  • 有效数位:表示有效数

计算公式为:浮点数=(1)s1.f2e浮点数 = (-1)^s * 1.f * 2^e

对应的 s、e、f 的取值如下图所示:

IEEE-754 Floating Point Converter 网站提供了直接交互式地设置符号位、指数位和有效位数的操作。我们可以直观地看到,32 位浮点数每一个 bit 的变化,对应的有效位数、指数会变成什么样子以及最后的十进制的计算结果是怎样的。

为什么浮点数不精确

在 Python 中,如果我们计算 0.1 + 0.2 可以看到其计算的结果是 0.30000000000000004

image.png

这是因为 0.1 无法被二进制精确表示。就像 1/3 在 十进制中是无法循环的小数,0.1 在二进制中的近似表示可能是 0.000110011001100...,但在计算机的浮点数表示中,它可能被截断或舍入为 0.00011001100110,这就导致了 0.1 + 0.2 在计算机中可能不等于 0.3,而是略微有所偏差。具体可以看0.1 + 0.2 不等于 0.3 ?这是为什么?一篇讲清楚!!!

如何解决浮点的精度损失问题

  • Kahan 求和

Kahan 求和 算法,又名补偿求和或进位求和算法,是一个用来 降低有限精度浮点数序列累加值误差 的算法。它主要通过保持一个单独变量用来累积误差(常用变量名为 c)来完成的。具体见 Kahan 求和

  • BigDecimal

在 Java 中提供了 BigDecimal 来实现高精度运算。其实现原理是采用整数数组来存储数值,同时用一个整数来表示小数点的位置,以此精确地表示任意大小和精度的十进制数。具体可以看掌握BigDecimal:详解其原理及最佳实践 - 技术栈

程序的机器码级表示

在计算机中,我们平时写的代码需要经过编译、汇编的过程,最后才能变成CPU能够真正认识的计算机指令。

image.png

编译器可以直接把代码编译成机器码,这里先编译成汇编代码是为了让程序员看懂。

CPU指令

常见的指令一般有五类:

  • 算术类指令。我们的加减乘除,在 CPU 层面,都会变成一条条算术类指令。
  • 数据传输类指令。给变量赋值、在内存里读写数据,用的都是数据传输类指令。
  • 逻辑类指令。逻辑上的与或非,都是这一类指令。
  • 条件分支类指令。日常我们写的“if/else”,其实都是条件分支类指令。
  • 无条件跳转指令。在调用函数的时候,其实就是发起了一个无条件跳转指令。

如下图所示,图片来源

image.png

指令跳转的实现

CPU里面的特殊的寄存器有:

  • PC 寄存器(Program Counter Register),我们也叫指令地址寄存器(Instruction Address Register)。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址。
  • 指令寄存器(Instruction Register),用来存放当前正在执行的指令。
  • 状态寄存器(Status Register),用里面的一个一个标记位(Flag),存放CPU 进行算术或者逻辑计算的结果。

在计算机中,一个程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。所以只要程序在内存中是连续存储的,就会顺序执行。而指令跳转的实现,就是该指令会修改 PC 寄存器里面的地址值,从而实现跳转。

高级编程语言就是通过状态寄存器会记录下当前执行指令的条件判断状态,然后通过跳转指令读取对应的条件码,修改 PC 寄存器内的下一条指令的地址,来最终实现 if…else 以及 for/while 这样的程序控制流程的。

除了这些特殊的寄存器,CPU 里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。我们通常根据存放的数据内容来给它们取名字,比如整数寄存 器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。

函数调用的实现

在计算机中,函数的调用是通过栈来实现的。栈是在内存中开辟的空间,它是个后进先出的结构。每次程序调用函数之前,我们都把调用返回后的地址压入栈中。如果函数执行完了,就从栈中取出这个地址。(由于cpu中寄存器的数量有限,所以才从内存中开辟空间)

image.png

stack overflow 就是程序在执行过程中遇到了栈溢出的情况。一般出现 stack overflow 有两种情况:

  1. 函数无限递归,递归层数过深
  2. 栈空间里面创建非常占内存的变量(比如一个巨大的数组)

程序

ELF

在计算机中,代码经过编译、汇编、链接后,才会得到一个可执行文件。我们通过装载器(Loader)把可执行文件装载(Load)到内存中。CPU 从内存中读取指令和数据,来开始真正执行程序。如下图所示

在 Linux 中,可执行文件和目标文件所使用的都是一种叫ELF(Execuatable and Linkable File Format)的文件格式,中文名字叫可执行与可链接文件格式。如下图所示,图片来源

image.png

  • File Header:文件头,用来表示这个文件的基本属性,比如是否是可执行文件,对应的 CPU、操作系统等等。
  • .text Section,也叫作代码段或者指令段(Code Section),用来保存程序的代码和指令
  • .data Section,也叫作数据段(Data Section),用来保存程序里面设置好的初始化数据信息
  • .rel.text Secion,叫作重定位表(Relocation Table)。重定位表里,保留的是当前的文件里面,哪些跳转地址其实是我们不知道的。比如上面的 link_example.o 里面,我们在 main 函数里面调用了 add 和 printf 这两个函数,但是在链接发生之前,我们并不知道该跳转到哪里,这些信息就会存储在重定位表里
  • .symtab Section,叫作符号表(Symbol Table)。符号表保留了我们所说的当前文件里面定义的函数名称和对应地址的地址簿。

链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地 址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。这也是为什么,可执行文件里面的函数调用的地址都是正确的。

Windows 的可执行文件格式是一种叫作PE(Portable Executable Format)的文件格式。同样一个程序只能在Linux上执行,而不能在Windows上执行,其中一个非常重要的原因就是两个操作系统的可执行文件的格式不一样。

程序如何被装载在内存中的

程序被装载在内存中需要满足两个要求:

  1. 可执行程序加载后占用的内存空间应该是连续的。这是因为执行指令的时候,程序计数器是顺序地一条一条指令执行下去。这也就意味着,这一条条指令需要连续地存储在一起。
  2. 我们需要同时加载很多个程序,并且不能让程序自己规定在内存中加载的位置。 虽然编译出来的指令里已经有了对应的各种各样的内存地址,但是实际加载的时候,我们其实没有办法确保,这个程序一定加载在哪一段内存地址上。因为我们现在的计算机通常会同时运行很多个程序,可能你想要的内存地址已经被其他加载了的程序占用了。

这里为了满足这两个要求,计算机中使用了虚拟内存的机制。虚拟内存使程序无法直接访问系统上搭载的内存,取而代之的是通过虚拟地址间接访问。程序可以看见的是虚拟内存地址,系统上搭载的内存的实际地址称为物理内存地址

对于任何一个程序来说,在虚拟内存中,它看到的都是同样的内存地址。我们维护一个虚拟内存到物理内存的映射表,这样实际程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。因为是连续的内存地址空间,所以我们只需要维护映射关系的起始地址和对应的空间大小就可以了。

映射关系有两种,一种是分段,一种是分页。

  • 分段

找出一段连续的物理内存和虚拟内存地址进行映射的方法,我们叫分段(Segmentation)。这里的段,就是指系统分配出来的那个连续的内存空间。如下图所示,图片来源。从图中可以看出,该方式会造成内存碎片化

image.png

解决方法有,内存交换。即先把内存写到硬盘,然后再读出来,不过读出来的位置是跟在那已经被占用了的内存后面。这样内存就连续了,不过这个的缺点是硬盘的访问速度要比内存慢很多,而每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。这样会导致整个机器非常卡顿。

  • 分页

内存分页是把整个物理内存空间切成一段段固定尺寸的大小,在Linux中一般是4kB。如下图所示,图片来源

image.png

由于内存空间都是预先划分好的,也就没有了不能使用的碎片,而只有被释放出来的很多4KB 的页。即使内存空间不够,需要让现有的、正在运行的其他程序,通过内存交换释放出一些内存的页出来,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,让整个机器被内存交换的过程给卡住。

动态链接和共享库

多个程序加载到内存中,如果它们都使用了相同的代码库,那么会占用大量内存。我们把相同的库分出来,程序使用时动态链接。如下图所示,图片来源

image.png

注意:这里共享的指令代码,数据是不共享的。

Windows下,共享库文件就是.dll文件,在Linux下,这些共享库文件就是.so文件。

由于共享代码是动态链接的,我们调用共享库的变量和方法时就不知道它的地址。在计算机中,是通过 PLT 和 GOT 来解决的。它的本质是通过在动态链接对应的共享库的 data section 里面,保存了一张全局偏移表(GOT,Global Offset Table)。虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。 所有需要引用当前共享库外部的地址的指令,都会查询 GOT,来找到当前运行程序的虚拟内存里的对应位置。而 GOT 表里的数据,则是在我们加载一个个共享库的时候写进去的。如下图所示,图片来源

image.png

这样就可以让不同的进程,调用同样的 lib.so,各自 GOT 里面指向最终加载的动态链接库里面的虚拟内存地址是不同的。具体调用过程可以看动态链接函数调用是如何实现的

字符集和字符编码

字符集:表示字符的一个集合,常见的字符集有ASCII 字符集、Unicode 字符集。

字符编码:对于字符集里的这些字符,怎么用二进制表示出来的一个字典。常见字符编码介绍看这里

一般开发中,我们使用的字符集是 Unicode,而字符编码是UTF-8,它们之间的关系可以看 有了 Unicode ,为什么还需要UTF-8

在使用电脑的时候,可能会碰到“锟斤拷”、“烫烫烫”等莫名其妙的结果,这里介绍一下它们是怎么来的。

  • “锟斤拷”:一些遗留的老字符集内的文本在 Unicode 中可能并不存在,这导致 Unicode 会统一把这些字符记录为 U+FFFD 这个编码。如果用 UTF-8 的格式存储下来,就是\xef\xbf\xbd。如果连续两个这样的字符放在一起,\xef\xbf\xbd\xef\xbf\xbd,这个时候,如果程序把这个字符,用 GB2312 的方式进行 decode,就会变成“锟斤拷”。
  • “烫烫烫”: Visual Studio 的调试器,默认使用 MBCS 字符集。“烫”在里面是由 0xCCCC 来表示的,而 0xCC 又恰好是未初始化的内存的赋值。于是,在读到没有赋值的内存地址或者变量的时候,就显示“烫烫烫”了

计算机电路实现加法和乘法

具体可以看计算机中是如何实现加法乘法的?

建立数据通路,构造一个最简单的 CPU

CPU 实现的抽象逻辑图,如下图所示,图片来源

屏幕截图 2025-05-05 212306.png

  1. 首先,我们有一个自动计数器。这个自动计数器会随着时钟主频不断地自增,来作为我们的 PC 寄存器。
  2. 在这个自动计数器的后面,我们连上一个译码器。译码器还要同时连着我们通过大量的 D 触发器组成的内存。
  3. 自动计数器会随着时钟主频不断自增,从译码器当中,找到对应的计数器所表示的内存地址,然后读取出里面的 CPU 指令。
  4. 读取出来的 CPU 指令会通过我们的 CPU 时钟的控制,写入到一个由 D 触发器组成的寄存器,也就是指令寄存器当中。
  5. 在指令寄存器后面,我们可以再跟一个译码器。这个译码器不再是用来寻址的了,而是把我们拿到的指令,解析成 opcode 和对应的操作数。
  6. 当我们拿到对应的 opcode 和操作数,对应的输出线路就要连接 ALU,开始进行各种算术和逻辑运算。对应的计算结果,则会再写回到 D 触发器组成的寄存器或者内存当中。

这样的一个完整的通路,也就完成了我们的 CPU 的一条指令的执行过程。

CPU 还会有满载运行和 Idle 闲置的状态, 指的系统层面的状态。即使是idle空闲状态,cpu也在执行循环指令

现代CPU架构

现代CPU架构如下图所示,图片来源

现代 CPU 架构,借鉴了哈佛架构,在高速缓存层面拆分成指令缓存(Instruction Cache)数据缓存(Data Cache)

内存的访问速度远比 CPU 的速度要慢,所以现代的 CPU 并不会直接读取主内存。它会从主内存把指令和数据加载到高速缓存中,这样后续的访问都是访问高速缓存。而指令缓存和数据缓存的拆分,使得我们的 CPU 在进行数据访问和取指令的时候,不会再发生资源冲突的问题了。

现代CPU的优化

指令流水线

指令流水线通过将指令的执行过程分解为多个子过程,并使这些子过程并行执行,从而提高处理器执行指令的效率。

需要解决三大冒险,分别是结构冒险(Structural Hazard)、数据冒险(Data Hazard)以及控制冒险(Control Hazard)。

乱序执行

乱序执行是在指令执行的阶段通过一个类似线程池的保留站,让系统自己去动态调度先执行哪些指令。这个动态调度巧妙地解决了流水线阻塞的问题。指令执行的先后顺序,不再和它们在程序中的顺序有关。我们只要保证不破坏数据依赖就好了。CPU 只要等到在指令结果的最终提交的阶段,再通过重排序的方式,确保指令“实际上”是顺序执行的。

乱序执行所依赖的Tomasulo 算法

分支预测

分支预测通过分析分支指令的历史执行情况来预测其未来行为。

更多分支预测内容可以看Branch predictor

多发射(Mulitple Issue)和超标量(Superscalar)

多发射是指同一个时间,可能会同时把多条指令发(Issue)到不同的译码器或者后续处理的流水线中去。

超标量是指本来在一个时钟周期里面,只能执行一个标量(Scalar)的运算。在多发射的情况下,我们就能够超越这个限制,同时进行多次计算。

超标量(Superscalar)技术能够让取指令以及指令译码也并行进行;在编译的过程,超长指令字(VLIW)技术可以搞定指令先后的依赖关系,使得一次可以取一个指令包。

超线程

在同一时间点上,一个物理的 CPU 核心只会运行一个线程的指令。而超线程的 CPU,其实是把一个物理层面 CPU 核心,“伪装”成两个逻辑层面的 CPU 核心。这个 CPU,会在硬件层面增加很多电路,使得我们可以在一个 CPU核心内部,维护两个不同线程的指令的状态信息。

比如,在一个物理 CPU 核心内部,会有双份的 PC 寄存器、指令寄存器乃至条件码寄存器。这样,这个 CPU 核心就可以维护两条并行的指令的状态。在外面看起来,似乎有两个逻辑层面的 CPU 在同时运行。所以,超线程技术一般也被叫作同时多线程(Simultaneous Multi-Threading,简称 SMT)技术。

超线程的目的,是在一个线程 A 的指令,在流水线里停顿的时候,让另外一个线程去执行指令。因为这个时候,CPU 的译码器和 ALU 就空出来了,那么另外一个线程B,就可以拿来干自己需要的事情。这个线程 B 可没有对于线程 A 里面指令的关联和依赖。这样,CPU 通过很小的代价,就能实现“同时”运行多个线程的效果。通常我们只要在CPU 核心的添加 10% 左右的逻辑功能,增加可以忽略不计的晶体管数量,就能做到这一点。

单指令多数据流(SIMD) 和 多指令多数据(MIMD)

SIMD 在获取数据和执行指令的时候,都做到了并行。一方面,在从内存里面读取数据的时候,SIMD 是一次性读取多个数据。

在数据读取到了之后,在指令的执行层面,SIMD 也是可以并行进行的。4 个整数各自加1,互相之间完全没有依赖,也就没有冒险问题需要处理。只要 CPU 里有足够多的功能单元,能够同时进行这些计算,这个加法就是 4 路同时并行的,自然也省下了时间。

对于那些在计算层面存在大量“数据并行”(Data Parallelism)的计算中,使用SIMD 是一个很划算的办法。

更多关于现代CPU的知识可见现代CPU Guide

异常

异常是一个硬件和软件组合到一起的处理过程。异常的前半生,也就是异常的发生和捕捉,是在硬件层面完成的。但是异常的后半生,即异常的处理,其实是由软件来完成的。

计算机会为每一种可能会发生的异常,分配一个异常代码(Exception Number)。这些异常代码里,I/O 发出的信号的异常代码,是由操作系统来分配的,也就是由软件来设定的。而像加法溢出这样的异常代码,则是由 CPU 预先分配好的,也就是由硬件来分配的。

拿到异常代码之后,CPU 就会触发异常处理的流程。计算机在内存里,会保留一个异常表(Exception Table)。CPU 拿到了异常码之后,会先把当前的程序执行的现场,保存到程序栈里面,然后根据异常码查询,找到对应的异常处理程序,最后把后续指令执行的指挥权,交给这个异常处理程序。如下图所示,图片来源

异常的分类

  • 中断(interrupt):程序执行到一半的时候,被打断了。这个打断的信息来自CPU外部的IO设备
  • 陷阱(trap):程序员故意主动触发的异常。例如系统调用
  • 故障(fault):程序执行出错。与陷阱的区别:故障不是故意触发异常,而陷阱是
  • 中止(abort):发生故障,但是无法恢复

如下图所示,图片来源

我们的应用程序通过系统调用去读取文件、创建进程,其实也是通过触发一次陷阱来进行的。这是因为,我们用户态的应用程序没有权限来做这些事情,需要把对应的流程转交给有权限的异常处理程序来进行。

异常的处理:上下文切换

在实际处理异常之前,计算机需要先去做一个“保留现场”的操作。有了这个操作,我们才能在异常处理完成之后,重新回到之前执行的指令序列里面来。这个保留现场的操作,和指令的函数调用很像。但是,因为“异常”和函数调用有一个很大的不同,那就是它的发生时间。函数调用的压栈操作我们在写程序的时候完全能够知道,而“异常”发生的时间却很不确定。所以,“异常”发生的时候,我们称之为发生了一次“上下文切换”(Context Switch)。这个时候,除了普通需要压栈的数据外,计算机还需要把所有寄存器信息都存储到栈里面去。

什么是软中断,什么是硬中断

  • 硬中断类似键鼠,网卡这些外接设备发出的中断请求,同比于上文的中断。
  • 软中断类似程序内部IO的操作,由程序内部发出中断请求,同比上文的陷阱

精简指令

根据 CPU 的指令集里的机器码是固定长度还是可变长度,可以把指令集分成:复杂指令集(Complex Instruction Set Computing,简称 CISC)和精简指令集(Reduced Instruction Set Computing,简称 RISC)。如下图所示,图片来源

RISC 架构的 CPU 的想法其实非常直观。既然我们 80% 的时间都在用 20% 的简单指令,那我们能不能只要那 20% 的简单指令就好了呢?答案当然是可以的。因为指令数量多,计算机科学家们在软硬件两方面都受到了很多挑战。

Intel 就开始在处理器里引入了微指令(Micro-Instructions/Micro-Ops)架构。而微指令架构的引入,也让 CISC 和 RISC 的分界变得模糊了。

到了 21 世纪的今天,CISC 和 RISC 架构的分界已经没有那么明显了。Intel 和 AMD 的 CPU 也都是采用译码成 RISC 风格的微指令来运行。而 ARM 的芯片,一条指令同样需要多个时钟周期,有乱序执行和多发射。我甚至看到过这样的评价,“ARM 和 RISC 的关系,只有在名字上”。

开源的RISC-V项目,想要“打造一个属于自己 CPU”可以关注这个项目。

GPU

图像渲染

  1. 顶点处理(Vertex Processing)

顶点处理:将顶点在三维空间里面的位置转换到屏幕这个二维空间里面

顶点处理转化后的顶点,仍然是在一个三维空间里,只是第三维的 Z 轴,是正对屏幕的“深度”

2.图元处理

图元处理:将顶点连接起来变成多边形,并将不在屏幕里面,或者一部分不在屏幕里面的内容给去掉,减少接下来流程的工作量。

  1. 栅格化

栅格化:将完成图元处理的多边形转换成在屏幕里面的一个个像素点

  1. 片段处理

片段处理:计算每一个像素的颜色、透明度等信息,给像素点上色。

  1. 像素操作

像素操作:把不同的多边形的像素点混合到一起,最终输出到显示设备

经过这完整的 5 个步骤之后,我们就完成了从三维空间里的数据的渲染,变成屏幕上你可以看到的 3D 动画了。这样 5 个步骤的渲染流程呢,一般也被称之为图形流水线(Graphic Pipeline)。

上面的步骤都需要渲染整个画面里面的每一个像素,所以其实计算量是很大的。CPU 这个时候就跑不动了。因此厂商推出了 3D 加速卡使用硬件来完成图元处理开始的渲染流程。这些加速卡和现代的显卡还不太一样,它们是用固定的处理流程来完成整个 3D 图形渲染的过程。

GPU的历史可以看The History of the Modern Graphics Processor | TechSpot

现代GPU的核心创意

  1. 芯片瘦身

GPU的整个处理过程是一个流式处理的过程,不需要进行乱序执行、进行分支预测等。只需要留下取指令指令译码、ALU以及执行这些计算需要的寄存器和缓存就好。如下图所示,图片来源

  1. 多核并行和SIMT

SIMT:可以把多条数据交给不同的线程去处理

  1. GPU里的超线程

现代GPU的原理可以看 haifux.org 上的 Introduction to GPU architecture

计算机体系结构

FPGA

现场可编程门阵列,可以像软件一样对硬件编程,可以反复烧录,还有海量的门电路,可以组合实现复杂的芯片功能

ASIC

ASIC(Application-Specific Integrated Circuit),也就是专用集成电路,它是针对专门用途设计的,所以它的电路更精简,单片的制造成本也比 CPU 更低。而且,因为电路精简,所以通常能耗要比用来做通用计算的 CPU 更低。

最知名、最具有实用价值的 ASIC 就是 TPU 了

TPU的论文

Google官方介绍TPU的文章

David Patterson 的介绍硬件的讲话:A New Golden Age for Computer Architecture - EECS at Berkeley

虚拟机

虚拟机技术:指在现有硬件的操作系统上,能够模拟一个计算机系统的技术

解释型虚拟机

模拟器:有能力去模拟指令执行的软件。其中被模拟出来的系统叫做客户机,原先的操作系统叫做宿主机

这种解释执行方式的最大的优势就是,模拟的系统可以跨硬件

缺陷:

  1. 做不到精确模拟
  2. 性能太差

虚拟机监视器

在物理服务器硬件和操作系统上再跑一个操作系统(不是模拟),通过中间件来实现客户机操作系统来使用宿主机操作系统,这个中间件叫做虚拟机监视器

  • Type-1

常用于数据中心,虚拟机监视器是操作系统内核里面的一部分

  • Type-2

常用于个人电脑,虚拟机监视器是应用程序

Docker

在操作系统上运行其他操作系统会耗费资源。但有时我们并不需要运行其他操作系统,只需要隔离资源就行,这就需要Docker技术。Docker 并没有再单独运行一个客户机的操 作系统,而是直接运行在宿主机操作系统的内核之上。所以,Docker 也是现在流行的微服务架构底层的基础设施。

更多关于虚拟机、Docker这些技术可以看A Beginner-Friendly Introduction to Containers, VMs and Docker

存储器

存储器层次结构

  • 寄存器:只能存放及其有限的信息,但速度非常快和CPU同步
  • CPU高速缓存:有L1、L2、L3三层.使用SRAM实现
  • 内存:使用DRAM实现,访问速度比SRAM慢
  • SSD固体硬盘
  • HDD机械硬盘

存储器的层次关系图如下所示:

各种存储器成本的对比表格如下所示,或者看硬件发展后,访问延时的变化

在 CPU 里,通常会有 L1、L2、L3 这样三层高速缓存。每个 CPU 核心都有一块属于自己的 L1 高速缓存,通常分成指令缓存和数据缓存,分开存放 CPU 使用的指令和数据。L1 的 Cache 往往就嵌在 CPU 核心的内部。L2 的 Cache 同样是每个 CPU 核心都有的,不过它往往不在 CPU 核心的内部。所以,L2 Cache 的访问速度会比 L1 稍微慢一些。而 L3 Cache,则通常是多个 CPU 核心共用的,尺寸会更大一些,访问速度自然也就更慢一些。

PC 上各种操作的大致时间

存储器的局部性原理

局部性原理包括时间局部性和空间局部性

  • 时间局部性

如果一个数据被访问了,那么它在短时间内还会被再次访问

  • 空间局部性

如果一个数据被访问了,那么和它相邻的数据也很快会被访问

局部性原理的应用有:热门商品被访问得多,就会始终被保留在内存里,而冷门商品被访问得少,就只存放在 HDD 硬盘上,数据的读取也都是直接访问硬盘。即使加载到内存中,也会很快被移除。越是热门的商品,越容易在内存中找到,也就更好地利用了内存的随机访问性能。

CPU Cache

由于 CPU 和 内存之间的性能差距越来越大,CPU很多时候是在空转等待内存响应。为了弥补两者之间的性能差异,真实地把 CPU 的性能提升用起来,而不是让它在那儿空转,我们在现代 CPU 中引入了高速缓存(CPU Cache)。

数据读取

image.png

现代 CPU 进行数据读取的时候,无论数据是否已经存储在 Cache 中,CPU 始终会首先访问 Cache。只有当 CPU 在 Cache 中找不到数据的时候,才会去访问内存,并将读取到的 数据写入 Cache 之中。当时间局部性原理起作用后,这个最近刚刚被访问的数据,会很快再次被访问。而 Cache 的访问速度远远快于内存,这样,CPU 花在等待内存访问上的时间 就大大变短了。

CPU 从内存中读取数据到 CPU Cache 的过程中,是一小块一小块来读取数据的,而不是按照单个数组元素来读取数据的。这样一小块一小块的数据,在 CPU Cache 里面,我们把它叫作 Cache Line(缓存块)。

MESI

由于CPU的每个核各有各的缓存,互相之间的操作又是相互独立的,这就会带来缓存一致性(Cache Coherence)的问题

要解决缓存不一致的问题,需要做到如下两点:

  1. 写传播(Write Propagation)。写传播是说,在一个CPU 核心里,我们的Cache 数据更新,必须能够传播到其他的对应节点的 Cache Line 里。
  2. 事务的串行化(Transaction Serialization),事务串行化是说,我们在一个 CPU核心里面的读取和写入,在其他的节点看起来,顺序是一样的。比如cpu1修改a为1,cpu2修改a为2,其他cpu需要这个变更的顺序,即a先变成1后变成2.

在计算机中,最常用的解决缓存一致性问题的是 MESI 协议。更多关于它的信息可以看MESI协议

内存

虚拟内存

image.png

前文介绍过,内存被分成固定大小的页(Page),然后再通过虚拟内存地址(Virtual Address)到物理内存地址(Physical Address)的地址转换(Address Translation),才能到达实际存放数据的物理内存位置。而我们的程序看到的内存地址,都是虚拟内存地址。

虚拟内存与物理内存的映射需要一张表,在计算机中被叫做页表。我们每一个进程,都有属于自己独立的虚拟内存地址空间。这也就意味着,每一个进程都需要这样一个页表。

  • 简单页表

image.png

如上图,简单页表指只保留虚拟内存地址的页号和物理内存地址的页号之间的映射关系的页表。简单页表的不足是,页表占用了太大的内存空间

  • 多级页表

解决页表占用太大内存空间的方法是使用多级页表,如下图所示。多级页表通过只去存那些用到的页之间的映射关系才达到减少内存占用的功能。

不足:访问速度慢,比如用了 4 级页表,我们就需要访问 4 次内存(内存相对cpu太慢了,多次访问内存会严重拖慢cpu性能),才能找到物理页号了。解决方法:使用缓存,即地址变换高速缓冲(TLB)

在一个实际的程序进程里面,虚拟内存占用的地址空间,通常是两段连续的空间。而不是完全散落的随机的内存地址。而多级页表,就特别适合这样的内存地址分布,因此这里不使用哈希表。

内存保护

  • 可执行空间保护:我们对于一个进程使用的内存,只把其中的指令部分设置成“可执行”的,对于其他部分,比如数据部分,不给予“可执行”的权限
  • 地址空间布局随机化,让内存布局空间的位置不再固定,在内存空间随机去分配这些进程里不同部分所在的内存空间地址

image.png

更多内存保护的知识可以看 Memory protection - Wikipedia

2017 年暴露出来的Spectre 和 Meltdown 漏洞的相关原理:幽灵漏洞 - 维基百科,自由的百科全书

总线

如果各个设备之间的通信是单独进行的,那么连接需要n^2,这时就需要总线,将复杂度降到n

image.png

image.png

对应的设计思路,在软件开发中也是非常常见的。我们在做大型系统开发的过程中,经常会用到一种叫作事件总线(Event Bus)的设计模式。

进行大规模应用系统开发的时候,系统中的各个组件之间也需要相互通信。模块之间如果是两两之间单独去定义协议,这个软件系统一样会遇到一个复杂度变成了 n^2 的问题。所以常见的一个解决方案,就是事件总线这个设计模式。

image.png

总线一般有三类:

  • 数据线(Data Bus),用来传输实际的数据信息,也就是实际上了公交车的“人”。
  • 地址线(Address Bus),用来确定到底把数据传输到哪里去,是内存的某个位置,还是某一个 I/O 设备。这个其实就相当于拿了个纸条,写下了上面的人要下车的站点。
  • 控制线(Control Bus),用来控制对于总线的访问。虽然我们把总线比喻成了一辆公交车。那么有人想要坐公交车的时候,需要告诉公交车司机,这个就是我们的控制信号。

输入输出设备

实际上,输入输出设备,并不只是一个设备。大部分的输入输出设备,都有两个组成部分。第一个是它的接口(Interface),第二个才是实际的 I/O 设备(Actual I/O Device)。我们的硬件设备并不是直接接入到总线上和 CPU 通信的,而是通过接口,用接口连接到总线上,再通过总线和 CPU 通信。

CPU 如何控制IO设备

image.png

  1. 首先是数据寄存器(Data Register)。CPU 向 I/O 设备写入需要传输的数据,比如要打印的内容是“GeekTime”,我们就要先发送一个“G”给到对应的 I/O 设备。
  2. 然后是命令寄存器(Command Register)。CPU 发送一个命令,告诉打印机,要进行打印工作。这个时候,打印机里面的控制电路会做两个动作。第一个,是去设置我们的状态寄存器里面的状态,把状态设置成 not-ready。第二个,就是实际操作打印机进行打印。
  3. 而状态寄存器(Status Register),就是告诉了我们的 CPU,现在设备已经在工作了,所以这个时候,CPU 你再发送数据或者命令过来,都是没有用的。直到前面的动作已经完成,状态寄存器重新变成了 ready 状态,我们的 CPU 才能发送下一个字符和命令。

信号和地址

为了让已经足够复杂的 CPU 尽可能简单,计算机会把 I/O 设备的各个寄存器,以及 I/O 设备内部的内存地址,都映射到主内存地址空间里来。主内存的地址空间里,会给不同的I/O 设备预留一段一段的内存地址。CPU 想要和这些 I/O 设备通信的时候呢,就往这些地址发送数据。这些地址信息,就 是通过地址线来发送的,而对应的数据信息呢,自然就是通过数据线来发送的了。

image.png

而我们的 I/O 设备呢,就会监控地址线,并且在 CPU 往自己地址发送数据的时候,把对应的数据线里面传输过来的数据,接入到对应的设备里面的寄存器和内存里面来。CPU 无论 是向 I/O 设备发送命令、查询状态还是传输数据,都可以通过这样的方式。这种方式呢,叫作内存映射IO(Memory-Mapped I/O,简称 MMIO)。

IO_WAIT 和 IOPS

image.png

从 AS SSD 测试的性能看,硬盘的速度实际上非常快。但是在开发中感觉不到呢?这是因为在顺序读写和随机读写的情况下,硬盘的性能是完全不同的。图中 4k 的指标是我们的程序,去随机读取磁盘上某一个 4KB 大小的数据,一秒之内可以读取到多少数据。从这个可以看出随机读写的速度非常低。

IOPS:是每秒读写的次数(我们更应该关注这个性能指标)

IO_WAIT: top 命令的输出结果里面,有一行是以 %CPU 开头的。这一行里,有一个叫作 wa 的指标,这个指标就代表着 iowait。 CPU 等待 IO 完成操作花费的时间占 CPU 的百分 比。

更多关于 IOPS 的知识可以看 Understanding IOPS, latency and storage performance

AS SSD 测算硬盘的性能

机械硬盘

机械硬盘的硬件,主要由盘面、磁头和悬臂三部分组成。我们的数据在盘面上的位置,可以通过磁道、扇区和柱面来定位。实际的一次对于硬盘的访问,需要把盘面旋转到某一个“几何扇区”,对准悬臂的位置。然后,悬臂通过寻道,把磁头放到我们实际要读取的扇区上。受制于机械硬盘的结构,我们对于随机数据的访问速度,就要包含旋转盘面的平均延时和移动悬臂的寻道时间。通过这两个时间,我们能计算出机械硬盘的 IOPS。7200 转机械硬盘的 IOPS,只能做到 100 左右。

想要对机械硬盘的各种性能指标有更深入的理解,可以看 Home - Broadcom Community - VMTN - Discussion Forums, Technical Docs, Ideas and Blogs

SSD

image.png

AeroSpike 是市面上最优秀的 KV 数据库之一,通过深入地利用了 SSD 本身的硬件特性,最大化提升了作为一个 KV 数据库的性能。相关的可以看 Getting The Most Out Of Your Flash/SSDs | PPT

更多关于 SSD 的硬件实现可以看Understanding TLC NAND

DMA(直接内存访问技术)

本质上,DMA 技术就是我们在主板上放一块独立的芯片。在进行内存和 I/O 设备的数据传输的时候,我们不再通过 CPU来控制数据传输,而直接通过DMA 控制器(DMA Controller,简称 DMAC)。这块芯片,我们可以认为它其实就是一个协处理器(Co-Processor)。DMAC 最有价值的地方体现在,当我们要传输的数据特别大、速度特别快,或者传输的数据特别小、速度特别慢的时候。

比如说,我们用千兆网卡或者硬盘传输大量数据的时候,如果都用 CPU 来搬运的话,肯定忙不过来,所以可以选择DMAC。而当数据传输很慢的时候,DMAC 可以等数据到齐了,再发送信号,给到 CPU 去处理,而不是让 CPU 在那里忙等待。

Kafka 就利用了 DMA 实现了非常大的性能提升。

从磁盘读数据发送到网络上去如下图所示,会有4次复制

屏幕截图 2025-05-06 200438.png

Kafka采用Java NIO 库,具体是 FileChannel 里面的transferTo 方法,可以只有两次复制,并且没有通过 CPU 来进行数据搬运,所有的数据都是通过 DMA 来进行传输的。

image.png

在这个方法里面,我们没有在内存层面去“复制(Copy)”数据,所以这个方法,也被称之为零拷贝(Zero-Copy)。具体可以看 Efficient data transfer through zero copy - IBM Developer

kakfa的论文看netdb-2011

数据完整性

因为内存的制造质量造成的漏电,还是外部的射线,都有一定的概率,会造成单比特错误。而内存层面的数据出错,软件工程师并不知道,而且这个出错很有可能是随机的。

奇偶校验

ECC 内存的全称是 Error-Correcting Code memory,中文名字叫作纠错内存。顾名思义,就是在内存里面出现错误的时候,能够自己纠正过来。

纠错码(Error Correcting Code)。它还有一个升级版本,叫作纠删码(Erasure Code),不仅能够纠正错误,还能够在错误不能纠正的时候,直接把数据删除。无论是我们的 ECC 内存,还是网络传输,乃至硬盘的 RAID,其实都利用了纠错码和纠删码的相关技术。

更多可以看Erasure code - Wikipedia

分布式计算

High Scalability很多讲解怎么做到高扩展性的文章

推荐阅读

  • 入门书籍

《计算机是怎样跑起来的》和《程序是怎样跑起来的》

Coursera 上的北京大学免费公开课

计算机组成 Computer Organization | Coursera

  • 深入学习

《计算机组成与设计:硬件 / 软件接口》和《深入理解计算机系统》

2015CMU 15-213 CSAPP 深入理解计算机系统 课程视频含英文字幕(精校字幕视频见av31289365!!!)_哔哩哔哩_bilibili

《计算机组成:结构化方法》

《计算机体系结构:量化研究方法》

  • 课外阅读

来自 Redhat 的What Every Programmer Should Know About Memory是写出高性能程序不可不读的经典材料

LMAX 开源的 Disruptor,则是通过实际应用程序,来理解计算机组成原理中各个知识点的最好范例

《编码:隐匿在计算机软硬件背后的语言》和《程序员的自我修养:链接、装载和库》是理解计算机硬件和操作系统层面代码执行的优秀阅读材料

《计算机程序的构造与解释》

《数字逻辑应用与设计》

《数据密集型应用系统设计》

参考