前言
本文为 《图解系统》系列文章的个人学习笔记,对具体知识点与示例进行了归纳整理,详细内容参考小林coding 。
文章目录
前言
本文为个人学习【小林coding - 图解系统】过程中的归纳整理。
一、CPU 是如何执行程序的?
1.1 冯诺依曼模型
冯诺依曼模型定义了计算机基本结构为 5 个部分:运算器、控制器、存储器、输入设备、输出设备。
其中,运算器、控制器位于中央处理器(CPU)中,存储器、输入/输出设备通过总线与 CPU 通信。
中央处理器(CPU)
CPU 内部组件包括控制单元、逻辑运算单元、寄存器等。其中,控制单元负责控制 CPU 工作,逻辑运算单元负责计算,寄存器负责存储计算时的数据。
- 32 位和 64 位 CPU的区别在于:32 位 CPU 一次可以计算 4 个字节,64 位 CPU 一次可以计算 8 个字节。这里的 32 位和 64 位,通常称为 CPU 的位宽,代表的是 CPU 一次可以计算(运算)的数据量。
- 常见的寄存器种类:通用寄存器(用于存放需要进行运算的数据)、程序计数器(用来存储 CPU 要执行下一条命令「所在的内存地址」)、指令寄存器(用来存放当前正在执行的指令)。
总线
总线是用于 CPU 和内存以及其他设备之间的通信,总线可分为 3 种:
- 地址总线,用于指定 CPU 将要操作的内存地址。
- 数据总线,用于读/写内存的数据。
- 控制总线,用于发送和接收信号(中断、复位等)。
存储器(内存)
在计算机数据存储中,存储数据的基本单位是字节(byte),每一个字节都对应一个内存地址。内存的地址是从 0 开始编号的,然后自增排列,最后一个地址为内存总字节数 - 1,内存的读写任何一个数据的速度都是一样的。
输入/输出设备
输入设备向计算机输入数据,计算机经过计算后,把数据输出给输出设备。
1.2 线路位宽与 CPU 位宽
线路位宽一般指的是「总线」的位宽。以地址总线为例,CPU 想要操作「内存地址」就需要「地址总线」:
- 如果地址总线只有 1 条,那每次只能表示 「0 或 1」这两种地址,所以 CPU 能操作的内存地址最大数量为 2(2^1)个。
- 如果地址总线有 2 条,那么能表示 00、01、10、11 这四种地址,所以 CPU 能操作的内存地址最大数量为 4(2^2)个。
所以 CPU 能操作的内存地址最大数量为 4(2^2)个。
那么,想要 CPU 操作 4G 大的内存,那么就需要 32 条地址总线,因为 2 ^ 32 = 4G。
CPU 位宽,代表的是 CPU 一次可以计算(运算)的数据量,CPU 的位宽最好不要小于线路位宽。一般情况下, 64 位 CPU 的地址总线是 48 位,而 32 位 CPU 的地址总线是 32 位。
1.3 程序执行的基本过程
程序实际上是一条一条指令,所以程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是 CPU 了。
那 CPU 执行程序的过程如下:
-
第一步:取指令
CPU 读取「程序计数器」的值,这个值是指令的内存地址,然后 CPU 的「控制单元」操作「地址总线」指定需要访问的内存地址,接着通知内存设备准备数据,数据准备好后通过「数据总线」将指令数据传给 CPU,CPU 收到内存传来的数据后,将这个指令数据存入到「指令寄存器」。 -
第二步:更新程序计数器
「程序计数器」的值自增,表示指向下一条指令。 -
第三步:执行指令
CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行。
下面来看看 a = 1 + 2
在 32 位 CPU 的执行过程。
- 程序编译过程中,编译器通过分析代码,发现 1 和 2 是数据,于是程序运行时,内存会有个专门的区域来存放这些数据,这个区域就是「数据段」。如下图,数据 1 和 2 的区域位置:数据 1 被存放到 0x200 位置;数据 2 被存放到 0x204 位置。
- 编译器会把 a = 1 + 2 翻译成 4 条指令,存放到正文段中。如图,这 4 条指令被存放到了 0x100 ~ 0x10c 的区域中。
- 编译完成后,具体执行程序的时候,程序计数器会被设置为 0x100 地址,然后依次执行这 4 条指令。
1.4 指令
CPU 是不认识 a = 1 + 2
这个字符串,这些字符串只是方便我们程序员认识,要想这段程序能跑起来,还需要把整个程序翻译成汇编语言的程序,这个过程称为编译成汇编代码。针对汇编代码,我们还需要用汇编器翻译成机器码,这些机器码由 0 和 1 组成的机器语言,这一条条机器码,就是一条条的计算机指令,这个才是 CPU 能够真正认识的东西。
每条指令都有对应的机器码,CPU 通过解析机器码来知道指令的内容。不同的 CPU 有不同的指令集,也就是对应着不同的汇编语言和不同的机器码(如 MIPS 指集)。
二、磁盘比内存慢几万倍?
2.1 存储器的层次结构
存储器通常可以分为这么几个级别:
- 寄存器
- CPU Cache
- L1-Cache
- L2-Cache
- L3-Cache
- 内存
- SSD/HDD 硬盘
将不同存储层次与现实场景类比:
寄存器
最靠近 CPU 的控制单元和逻辑计算单元的存储器,就是寄存器。寄存器的访问速度一般要求在
半个 CPU 时钟周期
内,每个寄存器可以用来存储一定的字节(byte)的数据。比如:
- 32 位 CPU 中大多数寄存器可以存储 4 个字节;
- 64 位 CPU 中大多数寄存器可以存储 8 个字节;
CPU Cache
CPU 的高速缓存使用了 SRAM(Static Random-Access Memory,静态随机存储器) 芯片,通常可以分为 L1、L2、L3 这样的三层高速缓存,也称为一级缓存、二级缓存、三级缓存。
- L1 高级缓存
L1 高速缓存的访问速度几乎和寄存器一样快,通常只需要2~4
个时钟周期。每个 CPU 核心都有一块属于自己的 L1 高速缓存,指令和数据在 L1 是分开存放的,所以 L1 高速缓存通常分成指令缓存和数据缓存。- L2 高级缓存
L2 高级缓存的访问速度则慢于 L1,速度在10~20
个时钟周期,L2 高速缓存同样每个 CPU 核心都有。- L3 高级缓存
L3 高速缓存则通常是多个 CPU 核心共用的,其访问速度在20~60
个时钟周期。
内存
内存使用的是 DRAM(Dynamic Random Access Memory,动态随机存取存储器) 芯片。DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问的速度会更慢,内存速度大概在200~300
个 时钟周期之间。
SSD/HDD 硬盘
SSD(Solid-state disk) 就是我们常说的固体硬盘,结构和内存类似,但是它相比内存的优点是断电后数据还是存在的,而内存、寄存器、高速缓存断电后数据都会丢失。内存的读写速度比 SSD 大概快 10~1000 倍。
当然,还有一款传统的硬盘,也就是机械硬盘(Hard Disk Drive, HDD),它是通过物理读写的方式来访问数据的,因此它访问速度是非常慢的,它的速度比内存慢 10W 倍左右。
2.2 存储器的层次关系
CPU 并不会直接和每一种存储器设备直接打交道,而是每一种存储器设备只和它相邻的存储器设备打交道。
比如,CPU Cache 的数据是从内存加载过来的,写回数据的时候也只写回到内存,CPU Cache 不会直接把数据写到硬盘,也不会直接从硬盘加载数据,而是先加载到内存,再从内存加载到 CPU Cache 中。
另外,当 CPU 需要访问内存中某个数据的时候,如果寄存器有这个数据,CPU 就直接从寄存器取数据即可,如果寄存器没有这个数据,CPU 就会查询 L1 高速缓存,如果 L1 没有,则查询 L2 高速缓存,L2 还是没有的话就查询 L3 高速缓存,L3 依然没有的话,才去内存中取数据。所以,存储层次结构也形成了缓存的体系。
三、如何写出让 CPU 跑得更快的代码?
当 CPU 访问数据的时候,先是访问 CPU Cache,如果缓存命中的话,则直接返回数据,就不用每次都从内存读取数据了。因此,缓存命中率越高,代码的性能越好。
CPU访问内存
需要注意的是,当 CPU 访问数据时,如果 CPU Cache 没有缓存该数据,则会从内存读取数据,但是并不是只读一个数据,而是一次性读取一块一块的数据存放到 CPU Cache 中(CPU Cache Line/内存块),之后才会被 CPU 读取。
CPU Cache - 内存块映射方式
内存地址映射到 CPU Cache 地址里的策略有很多种,其中比较简单是直接映射 Cache,它巧妙的把内存地址拆分成「索引 + 组标记 + 偏移量」的方式,使得我们可以将很大的内存地址,映射到很小的 CPU Cache 地址里。
提高 CPU Cache 命中率
要想写出让 CPU 跑得更快的代码,就需要写出缓存命中率高的代码,CPU L1 Cache 分为数据缓存和指令缓存,因而需要分别提高它们的缓存命中率:
- 对于数据缓存:按序遍历
在遍历数据的时候,应该按照内存布局的顺序操作。这是因为 CPU Cache 是根据 CPU Cache Line 批量操作数据的,所以顺序地操作连续内存数据时,将提高数据缓存命中率,因此性能能得到有效的提升;- 对于指令缓存:先排序后处理
有规律的条件分支语句能够让 CPU 的分支预测器发挥作用(先排序后处理),进一步提高执行的效率;- 对于多核 CPU 系统:线程绑核
线程可能在不同 CPU 核心来回切换,这样各个核心的缓存命中率就会受到影响,于是要想提高线程的缓存命中率,可以考虑把线程绑定 CPU 到某一个 CPU 核心。
具体内容可见文字【如何写出让 CPU 跑的更快的代码?】 的2.3节:基于组标记、CPU Cache Line 索引、偏移量,CPU 就能够访问所需的数据片段(即字)。
四、CPU 缓存一致性
4.1 CPU与内存数据一致性
CPU 在读写数据的时候,都是在 CPU Cache 读写数据的。对于Cache 里没有缓存 CPU 所需要读取的数据的这种情况,CPU 则会从内存读取数据,并将数据缓存到 Cache 里面,最后 CPU 再从 Cache 读取数据。
而对于数据的写入,CPU 都会先写入到 Cache 里面,然后再在找个合适的时机写入到内存,那就有「写直达」和「写回」这两种策略来保证 Cache 与内存的数据一致性:
写直达:只要有数据写入,都会直接把数据写入到内存里面,这种方式简单直观,但是性能就会受限于内存的访问速度。
写回: 对于已经缓存在 Cache 的数据的写入,只需要更新基数据就可以,不用写入到内存,只有在需要把缓存里面的脏数据交换出去的时候,才把数据同步到内存里,这种方式在缓存命中率高的情况,性能会更好。
4.2 多核CPU下的缓存一致性
当今 CPU 都是多核的,每个核心都有各自独立的 L1/L2 Cache,只有 L3 Cache 是多个核心之间共享的。所以,我们要确保多核缓存是一致性的,否则会出现错误的结果。
要想实现缓存一致性,关键是要满足2点:
1)写传播,也就是当某个 CPU 核心发生写入操作时,需要把该事件广播通知给其他核心。
2)事物的串行化,以保障数据是真正一致的,且程序在各个不同的核心上运行的结果也是一致的。
基于总线嗅探机制的 MESI协议,就满足上面了这两点,因此它是保障缓存一致性的协议。
MESI协议,是已修改、独占、共享、已失效这四个状态的英文缩写的组合。整个 MSI状态的变更,则是根据来自本地 CPU 核心的请求,或者来自其他 CPU 核心通过总线传输过来的请求,从而构成一个流动的状态机。另外,对于在「已修改」或者「独占」状态的Cache Line,修改更新其数据不需要发送广播给其他 CPU 核心。
4.3 伪共享问题
CPU 读写数据的时候,并不是按一个一个字节为单位来进行读写,而是以 CPU Cache Line 大小为单位,CPU Cache Line 大小一般是 64 个字节,也就意味着 CPU 读写数据的时候,每一次都是以 64 字节大小为一块进行操作。
因此,如果我们操作的数据是数组,那么访问数组元素的时候,按内存分布的地址顺序进行访问,这样能充分利用到 Cache,程序的性能得到提升。但如果操作的数据不是数组,而是普通的变量,并在多核CPU 的情况下,我们还需要避免 Cache Line 伪共享的问题,
所谓的 Cache Line 伪共享问题就是,多个线程同时读写同一个 CacheLine 的不同变量时,而导致 CPU Cache 失效的现象。那么对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同-个 Cache Line 中,避免的方式一般有 Cache Line 大小字节对产以及字节填充等方法。
详细可见文章【CPU 是如何执行任务的?】中对伪共享问题的分析以及对应解决方法。
五、什么是软中断?
中断是一种异步的事件处理机制,可以提高系统的并发处理能力。
操作系统收到了中断请求,会打断其他进程的运行。为了避免由于中断处理程序执行时间过长,而影响正常进程的调度,Linux 将中断处理程序分为上半部和下半部:
【上半部】对应硬中断:由硬件触发中断,用来快速处理中断。
【下半部】对应软中断,由内核触发中断,用来异步处理上半部未完成的工作。
Linux 中的软中断包括网络收发、定时、调度、RCU 锁等各种类型,以通过查看 /proc/softirqs 来观察软中断的累计中断次数情况。
每一个 CPU 都有各自的软中断内核线程,我们还可以用 ps 命令来查看内核线程,一般名字在中括号里面到,都认为是内核线程。
六、为什么 0.1 + 0.2 不等于 0.3 ?
我们来思考几个问题:
- 为什么负数要用补码表示?
- 十进制小数怎么转成二进制?
- 计算机是怎么存小数的?
- 0.1 + 0.2 == 0.3 吗?
别看这些问题都看似简单,但是其实这些问题都涉及了。
6.1 为什么负数要用补码表示?
用了补码的表示方式,对于负数的加减法操作,实际上是和正数加减法操作一样的。
6.2 十进制小数与二进制的转换
小数部分的转换不同于整数部分,它采用的是乘 2 取整法,小数部分的转换不同于整数部分,将十进制中的小数部分乘以 2 作为二进制的一位,然后继续取小数部分乘以 2 作为下一位,直到不存在小数为止。
但是,并不是所有小数都可以用二进制表示。如果我们用相同的方式,来把 0.1 转换成二进制,过程如下。可以发现,0.1 的二进制表示是无限循环的。
由于计算机的资源是有限的,所以是没办法用二进制精确的表示 0.1,只能用「近似值」来表示,于是就会造成精度缺失的情况。
对于二进制小数转十进制时,需要注意一点,小数点后面的指数幂是负数。
6.3 计算机是怎么存小数的?
1000.101
这种二进制小数是「定点数」形式,代表着小数点是定死的。
然而,计算机并不是这样存储的小数的,计算机存储小数的采用的是「浮点数」,「浮点」表示小数点可以浮动。
通常将 1000.101
这种二进制数,规格化表示成 1.000101 x 2^3
,其中3
称为指数(小数点在数据中的位置),000101
称为尾数(小数点后面的数字)。
现在绝大多数计算机使用的浮点数,一般采用的是 IEEE 制定的国际标准,这种标准形式如下图:
- 符号位:表示数字是正数还是负数。
- 指数位:指定了小数点在数据中的位置,可以为负。(指数位的长度越长则数值的表达范围就越大)
- 尾数位:小数点右侧的数字,也就是小数部分。
我们就以 10.625 作为例子,看看这个数字在 float 里是如何存储的。
首先,我们计算出 10.625 的二进制小数为 1010.101。
然后把小数点,移动到第一个有效数字后面,即将 1010.101
右移 3
位成1.010101
,右移 3 位就代表 +3,左移 3 位就是 -3。
float 中的「指数位」就跟这里移动的位数有关系,把移动的位数再加上「偏移量」,float 的话偏移量是 127,相加后就是指数位的值了,即指数位这 8 位存的是 10000010
(十进制 130),因此你可以认为「指数位」相当于指明了小数点在数据中的位置。
1.010101
这个数的小数点右侧的数字就是 float 里的「尾数位」,,由于尾数位是 23 位,则后面要补充 0,所以最终尾数位存储的数字是01010100000000000000000
。
在算指数的时候,你可能会有疑问为什么要加上偏移量呢?
前面也提到,指数可能是正数,也可能是负数,即指数是有符号的整数,而有符号整数的计算是比无符号整数麻烦的,所以为了减少不必要的麻烦,在实际存储指数的时候,需要把指数转换成无符号整数。
float
的指数部分是 8 位,IEEE 标准规定单精度浮点的指数取值范围是-126 ~ +127
,于是为了把指数转换成无符号整数,就要加个偏移量,比如 float 的指数偏移量是127
,这样指数就不会出现负数了。
移动后的小数点左侧的有效位(即 1)消失了,为什么它并没有存储到 float 里?
因为 IEEE 标准规定,二进制浮点数的小数点左侧只能有 1 位,并且还只能是 1,既然这一位永远都是 1,那就可以不用存起来了。这样就可以节约 1 位的空间,尾数就能多存一位小数,相应的精度就更高了一点。
对于从 float 的二进制浮点数转换成十进制时,要考虑到这个隐含的 1,转换公式如下:
举个例子,我们把下图这个 float 的数据转换成十进制,过程如下:
6.4 为什么0.1 + 0.2 不等于 0.3 ?
在计算机中,并不是所有小数都可以用「完整」的二进制来表示的。比如十进制
0.1
在转换成二进制小数的时候,是一串无限循环的二进制数,计算机只能取其「近似值」。
现在基本都是用 IEEE 754 规范的「单精度浮点类型」或「双精度浮点类型」来存储小数的,根据精度的不同,近似值也会不同。
相关问题
64 位相比 32 位 CPU 的优势在哪?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?
64 位相比 32 位 CPU 的优势主要体现在两个方面:
- 64 位 CPU 可以一次计算超过 32 位的数字。但是大部分应用程序很少会计算那么大的数字,所以只有运算大数字的时候,64 位 CPU 的优势才能体现出来,否则和 32 位 CPU 的计算性能相差不大。
- 通常来说 64 位 CPU 的地址总线是 48 位,而 32 位 CPU 的地址总线是 32 位,所以 64 位 CPU 可以寻址更大的物理内存空间。
你知道软件的 32 位和 64 位之间的区别吗?32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?
64 位和 32 位软件,实际上代表指令是 64 位还是 32 位的:
- 如果 32 位指令在 64 位机器上执行,需要一套兼容机制就可以做到兼容运行了。但是如果 64 位指令在 32 位机器上执行,就比较困难了,因为 32 位的寄存器存不下 64 位的指令。
- 操作系统其实也是一种程序,我们也会看到操作系统会分成 32 位操作系统、64 位操作系统,其代表意义就是操作系统中程序的指令是多少位。
总之,硬件的 64 位和 32 位指的是 CPU 的位宽,软件的 64 位和 32 位指的是指令的位宽。
为什么负数要用补码表示?
负数之所以用补码的方式来表示,主要是为了统一和正数的加减法操作一样。
十进制小数怎么转成二进制?
十进制整数转二进制使用的是「除 2 取余法」,十进制小数使用的是「乘 2 取整法」。
计算机是怎么存小数的?
计算机是以浮点数的形式存储小数的,大多数计算机都是 IEEE 754 标准定义的浮点数格式,包含三个部分:
- 符号位:表示数字是正数还是负数,为0表示正数,为1表示负数。
- 指数位:指定了小数点在数据中的位置,指数可以是负数,也可以是正数,指数位的长度越长则数值的表达范围就越大。
- 尾数位:小数点右侧的数字,也就是小数部分,比如二进制
1.0011x2^(-2)
尾数部分就是0011
,而且尾数的长度决定了这个数的精度,因此如果要表示精度更高的小数,则就要提高尾数位的长度。
0.1 + 0.2 == 0.3 吗?
不是的,0.1 和 0.2 这两个数字用二进制表达会是一个一直循环的二进制数。比如 0.1
的二进制表示为 0.0 0011 0011 0011…
(0011 无限循环),所以计算机里只能采用近似数的方式来保存,那两个近似数相加,得到的必然也是一个近似数。