问题:
a = 1 + 2
这条代码是怎么被 CPU 执行的吗?图灵的基本思想是用机器来模拟人们用纸笔进行数学运算的过程,而且还定义了计算机由哪些部分组成,程序又是如何执行的。
图灵机的基本组成如下:
有一条「纸带」,纸带由一个个连续的格子组成,每个格子可以写入字符,纸带就好比内存,而纸带上的格子的字符就好比内存中的数据或程序;
有一个「读写头」,读写头可以读取纸带上任意格子的字符,也可以把字符写入到纸带的格子;
读写头上有一些部件,比如存储单元、控制单元以及运算单元:
1、存储单元用于存放数据;
2、控制单元用于识别字符是数据还是指令,以及控制程序的流程等;
3、运算单元用于执行运算指令;
图灵机主要功能就是:
定义计算机基本结构为 5 个部分,分别是运算器、控制器、存储器、输入设备、输出设备,这 5 个部分也被称为冯诺依曼模型。
运算器、控制器是在中央处理器(CPU)里的,存储器就我们常见的内存,输入输出设备则是计算机外接的设备,比如键盘就是输入设备,显示器就是输出设备。
存储单元和输入输出设备要与中央处理器打交道的话,离不开总线。所以,它们之间的关系如下图:
我们的程序和数据都是存储在内存,存储的区域是线性的。
在计算机数据存储中,存储数据的基本单位是字节(byte),1 字节等于 8 位(8 bit)。每一个字节都对应一个内存地址。
内存的地址是从 0 开始编号的,然后自增排列,最后一个地址为内存总字节数 - 1,这种结构好似我们程序里的数组,所以内存的读写任何一个数据的速度都是一样的。
中央处理器也就是我们常说的 CPU。
32 位和 64 位 CPU 最主要区别在于一次能计算多少字节数据:
这里的 32 位和 64 位,通常称为 CPU 的位宽,代表的是 CPU 一次可以计算(运算)的数据量。
CPU 内部还有一些组件,常见的有寄存器、控制单元和逻辑运算单元等。
为什么有了内存还需要寄存器?
因为内存离 CPU 太远了,而寄存器就在 CPU 里,还紧挨着控制单元和逻辑运算单元,自然计算时速度会很快。
常见的寄存器种类:
总线是用来通信的,在 CPU 和内存以及其他设备之间,总线可分为 3 种:
当 CPU 要读写内存数据的时候,是如何通过总线的?
地址总线 -> 控制总线 -> 数据总线
输入设备向计算机输入数据,计算机经过计算后,(运算器)把数据输出给输出设备。期间,如果输入设备是键盘,按下按键时是需要和 CPU 进行交互的,这时就需要用到控制总线了。
线路位宽
数据是如何通过线路传输的呢?
其实是通过操作电压,低电压表示 0,高压电压则表示 1。
如果构造了高低高这样的信号,其实就是 101 二进制数据,十进制则表示 5,如果只有一条线路,就意味着每次只能传递 1 bit 的数据,即 0 或 1,那么传输 101 这个数据,就需要 3 次才能传输完成,这样的效率非常低。
为了避免低效率的串行传输的方式,线路的位宽最好一次就能访问到所有的内存地址。
CPU 想要操作「内存地址」就需要「地址总线」:
那么,想要 CPU 操作 4G 大的内存,那么就需要 32 条地址总线,因为 2 ^ 32 = 4G
。
CPU 位宽
CPU 的位宽最好不要小于线路位宽,比如 32 位 CPU 控制 40 位宽的地址总线和数据总线的话,工作起来就会非常复杂且麻烦,所以 32 位的 CPU 最好和 32 位宽的线路搭配,因为 32 位 CPU 一次最多只能操作 32 位宽的地址总线和数据总线。
为什么 64 位 CPU 性能不一定就比 32 位 CPU 高很多?
因为很少应用需要算超过 32 位的数字,
所以如果计算的数额不超过 32 位数字的情况下,32 位和 64 位 CPU 之间没什么区别的,只有当计算超过 32 位数字的情况下,64 位的优势才能体现出来。
程序实际上是一条一条指令,所以程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是 CPU 了。(寄存器)
那 CPU 执行程序的过程如下:
简单总结一下就是
一个程序执行的时候,CPU 会根据程序计数器里的内存地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。
CPU 从程序计数器读取指令、到执行、再到下一条指令,这个过程会不断循环,直到程序执行结束,这个不断循环(执行程序)的过程被称为 CPU 的指令周期。
CPU 是不认识 a = 1 + 2
这个字符串,这些字符串只是方便我们程序员认识,要想这段程序能跑起来,还需要把整个程序翻译成汇编语言的程序,这个过程称为编译成汇编代码。
针对汇编代码,我们还需要用汇编器翻译成机器码,这些机器码由 0 和 1 组成的机器语言,这一条条机器码,就是一条条的计算机指令,这个才是 CPU 能够真正认识的东西。
下面来看看 a = 1 + 2
在 32 位 CPU 的执行过程。
程序编译过程中,编译器通过分析代码,发现 1 和 2 是数据,于是程序运行时,内存会有个专门的区域来存放这些数据,这个区域就是「数据段」。如下图,数据 1 和 2 的区域位置:
编译器会把 a = 1 + 2
翻译成 4 条指令,存放到正文段中。如图,这 4 条指令被存放到了 0x100 ~ 0x10c 的区域中:
load
指令将 0x200 地址中的数据 1 装入到(指令)寄存器 R0
;load
指令将 0x204 地址中的数据 2 装入到寄存器 R1
;add
指令将寄存器 R0
和 R1
的数据相加,并把结果存放到寄存器 R2
;store
指令将寄存器 R2
中的数据存回数据段中的 0x208 地址中,这个地址也就是变量 a
内存中的地址;简单总结就是:
add
指令将寄存器R0
和R1
的数据相加,并把结果放入到R2
,从而翻译成机器码
编译完成后,具体执行程序的时候,【程序计数器】会被设置为 0x100 地址,然后依次执行这 4 条指令。(编译 -> 执行)
不难发现上面的例子中,地址之间都是相隔 4 个字节
这是因为上面的例子是在 32 位 CPU 执行的,因此一条指令是占 32 位大小,所以你会发现每条指令间隔 4 个字节。
而数据的大小是根据你在程序中指定的变量类型,比如 int
类型的数据则占 4 个字节,char
类型的数据则占 1 个字节
string
类型的话,在 UTF-8 编码下,一个英文字母通常占用 1 个字节,一个汉字通常占用 3 个字节。
指令的内容是一串二进制数字的机器码,每条指令都有对应的机器码,CPU 通过解析机器码来知道指令的内容。
不同的 CPU 有不同的指令集,也就是对应着不同的汇编语言和不同的机器码。
最简单的 MIPS 指集
MIPS 的指令是一个 32 位的整数。
三种类型的含义:
编译器在编译程序的时候,会构造指令,这个过程叫做指令的编码。
CPU 执行程序的时候,就会解析指令,这个过程叫作指令的解码。
执行指令的方式
大多数 CPU 都使用来【流水线】的方式来执行指令,所谓的流水线就是【把一个任务拆分成多个小任务】,于是一条指令通常分为 4 个阶段,称为 4 级流水线。
四个阶段的具体含义:
上面这 4 个阶段,我们称为指令周期(Instrution Cycle),CPU 的工作就是一个周期接着一个周期,周而复始。
指令从功能角度划分,可以分为 5 大类:
store/load
是寄存器与内存间数据传输的指令,mov
是将一个内存地址的数据移动到另一个内存地址的指令;if-else
、switch-case
、函数调用等。trap
;nop
,执行后 CPU 会空转一个周期传输、运算、跳转、信号、闲置
CPU 的硬件参数都会有 GHz
这个参数(主频),比如一个 1 GHz 的 CPU,指的是时钟频率是 1 G,代表着 1 秒会产生 1G 次数的脉冲信号,每一次脉冲信号高低电平的转换就是一个周期,称为时钟周期。
对于 CPU 来说,在一个时钟周期内,CPU 仅能完成一个最基本的动作,时钟频率越高,时钟周期就越短,工作速度也就越快。(v=f/T)
一个时钟周期一定能执行完一条指令吗?
答案是不一定的,大多数指令不能在一个时钟周期完成,通常需要若干个时钟周期。不同的指令需要的时钟周期是不同的,加法和乘法都对应着一条 CPU 指令,但是乘法需要的时钟周期就要比加法多。
如何让程序跑的更快?
程序的 CPU 执行时间 越少 程序就跑得 越快。
主频越高说明 CPU 的工作速度就越快,比如我手头上的电脑的 CPU 是 2.4 GHz 四核 Intel Core i5,这里的 2.4 GHz 就是电脑的主频,时钟周期时间就是 1/2.4G。
CPU 时钟周期数 = 指令数 x 每条指令的平均时钟周期数(Cycles Per Instruction,简称 CPI
)
因此,要想程序跑的更快,优化这三者即可:
冯诺依曼模型
你知道软件的 32 位和 64 位之间的区别吗?再来 32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?
64 位和 32 位软件,实际上代表指令是 64 位还是 32 位的:
总之,硬件的 64 位和 32 位指的是 CPU 的位宽,软件的 64 位和 32 位指的是指令的位宽
64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?
64 位相比 32 位 CPU 的优势主要体现在两个方面:
2^48
,远超于 32 位 CPU 最大寻址能力。为什么通常说 64 位 CPU 的地址总线是 48 位的?
这涉及到物理寻址和虚拟内存的设计。实际上,64 位 CPU 的地址总线并不是固定为 48 位,而是有一定的范围。
如果大家自己想组装电脑的话,肯定需要购买一个 CPU 和存储器方面的设备。
相信大家都知道内存和硬盘都属于计算机的存储设备,断电后内存的数据是会丢失的,而硬盘则不会,因为硬盘是持久化存储设备,同时也是一个 I/O 设备。
但其实 CPU 内部也有存储数据的组件,比如寄存器、CPU L1/L2/L3 Cache 也都是属于存储设备,只不过它们能存储的数据非常小。
那机械硬盘、固态硬盘、内存这三个存储器,到底和 CPU L1 Cache 相比速度差多少倍呢?
寄存器,处理速度是最快的,但是能存储的数据也是最少的。
CPU Cache,中文称为 CPU 高速缓存,处理速度相比寄存器慢了一点,但是能存储的数据也稍微多了一些。
L1 Cache 通常分成「数据缓存」和「指令缓存」,L1 是距离 CPU 最近的,因此它比 L2、L3 的读写速度都快、存储空间都小。
对于存储器,它的速度越快、能耗会越高、而且材料的成本也是越贵的,以至于速度快的存储器的容量都比较小。
CPU 里的寄存器和 Cache,是整个计算机存储器中价格最贵的
寄存器的数量通常在几十到几百之间,每个寄存器可以用来存储一定的字节(byte)的数据。比如:
4
个字节;8
个字节。如果寄存器的速度太慢,则会拉长指令的处理周期,从而给用户的感觉,就是电脑「很慢」。
CPU Cache 用的是一种叫 **SRAM(Static Random-Access Memory,静态随机存储器)**的芯片。
SRAM 之所以叫「静态」存储器,是因为只要有电,数据就可以保持存在,而一旦断电,数据就会丢失了。
内存用的芯片和 CPU Cache 有所不同,它使用的是一种叫作 **DRAM(Dynamic Random Access Memory,动态随机存取存储器)**的芯片。
相比 SRAM,DRAM 的密度更高,功耗更低,有更大的容量,而且造价比 SRAM 芯片便宜很多。
因为数据会被存储在电容里,电容会不断漏电,所以需要「定时刷新」电容,才能保证数据不会被丢失,这就是 DRAM 之所以被称为「动态」存储器的原因
SSD(Solid-state disk)就是我们常说的固态硬盘,结构和内存类似,但是它相比内存的优点是断电后数据还是存在的,而内存、寄存器、高速缓存断电后数据都会丢失。内存的读写速度比 SSD 大概快 10~1000
倍。
当然,还有一款传统的硬盘,也就是机械硬盘(Hard Disk Drive, HDD),它是通过物理读写的方式来访问数据的,因此它访问速度是非常慢的,它的速度比内存慢 10W
倍左右。
由于 SSD 的价格快接近机械硬盘了,因此机械硬盘已经逐渐被 SSD 替代了。
CPU 并不会直接和每一种存储器设备直接打交道,而是每一种存储器设备只和它相邻的存储器设备打交道。
比如,CPU Cache 的数据是从内存加载过来的,写回数据的时候也只写回到内存,CPU Cache 不会直接把数据写到硬盘,也不会直接从硬盘加载数据,而是先加载到内存,再从内存加载到 CPU Cache 中。
另外,当 CPU 需要访问内存中某个数据的时候,
各存储器之间的区别:
CPU 从 L1 Cache 读取数据的速度,相比从内存读取的速度,会快 100
多倍
数据结构
CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按照单个数组元素来读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 Cache Line(缓存块)。
读取过程
CPU 读取数据的时候,无论数据是否存放到 Cache 中,CPU 都是先访问 Cache,只有当 Cache 中找不到数据时,才会去访问内存,并把内存中的数据读入到 Cache 中,CPU 再从 CPU Cache 读取数据。
内存地址映射问题
内存地址映射到 CPU Cache 地址里的策略有很多种,其中比较简单是直接映射 Cache,它巧妙的把内存地址拆分成「索引 + 组标记 + 偏移量」的方式,使得我们可以将很大的内存地址,映射到很小的 CPU Cache 地址里。
在前面我也提到,L1 Cache 通常分为「数据缓存」和「指令缓存」,这是因为 CPU 会分别处理数据和指令,比如 1+1=2
这个运算,+
就是指令,会被放在「指令缓存」中,而输入数字 1
则会被放在「数据缓存」里。
因此,我们要分开来看「数据缓存」和「指令缓存」的缓存命中率。
举一个遍历二维数组的例子。
同样的输出结果,顺序访问会比跳跃式的访问速度更快。
因为不连续性、跳跃式访问数据元素的方式,可能不能充分利用到了 CPU Cache 的特性,从而代码的性能不高。
那访问 array[0][0]
元素时,CPU 具体会一次从内存中加载多少元素到 CPU Cache 呢?
这跟 CPU Cache Line 有关,它表示 CPU Cache 一次性能加载数据的大小,可以在 Linux 里通过 coherency_line_size
配置查看 它的大小,通常是 64 个字节。
也就是说,当 CPU 访问内存数据时,如果数据不在 CPU Cache 中,则会一次性会连续加载 64 字节大小的数据到 CPU Cache,那么当访问 array[0][0]
时,由于该元素不足 64 字节,于是就会往后顺序读取 array[0][0]~array[0][15]
到 CPU Cache 中。顺序访问的 array[i][j]
因为利用了这一特点,所以就会比跳跃式访问的 array[j][i]
要快。
因此,遇到这种遍历数组的情况时,按照内存布局顺序访问,将可以有效的利用 CPU Cache 带来的好处,这样我们代码的性能就会得到很大的提升。
我们以一个例子来看看,现有一个元素为 0 到 100 之间随机数字组成的一维数组。
int array[N];
for (i = 0; i < N; i++) {
array[i] = rand() % 100;
}
接下来,对这个数组做两个操作:
第一个操作,循环遍历数组,把小于 50 的数组元素置为 0;
// 操作一:数组遍历
for(i = 0; i < N; i++) {
if (array[i] < 50) {
array[i] = 0;
}
}
第二个操作,将数组排序;
// 操作二:排序
sort(array, array + N);
那么问题来了,你觉得先遍历再排序速度快,还是先排序再遍历速度快呢?
在回答这个问题之前,我们先了解 CPU 的分支预测器。对于 if 条件语句,意味着此时至少可以选择跳转到两段不同的指令执行,也就是 if 和 else 中的指令。那么,如果分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快。
当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。
因此,先排序再遍历速度会更快,这是因为排序之后,数字是从小到大的,那么前几次循环命中 if < 50
的次数会比较多,于是分支预测就会缓存 if
里的 array[i] = 0
指令到 Cache 中,后续 CPU 执行该指令就只需要从 Cache 读取就好了。
现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问内存的频率。
要想写出让 CPU 跑得更快的代码,就需要写出缓存命中率高的代码,CPU L1 Cache 分为数据缓存和指令缓存,因而需要分别提高它们的缓存命中率:
另外,对于多核 CPU 系统,线程可能在不同 CPU 核心来回切换,这样各个核心的缓存命中率就会受到影响,于是要想提高线程的缓存命中率,可以考虑把线程绑定 CPU 到某一个 CPU 核心。
在多核处理器系统中,每个核心都有自己的本地缓存(CPU Cache),用于存储最近访问的内存数据。
CPU 缓存一致性是多核处理器中需要处理的复杂问题,处理器通过采用缓存一致性协议和其他同步机制来解决这些问题。程序员也需要在编写并发程序时注意缓存一致性,以避免出现意料之外的行为。
在计算机系统中,虚拟地址与物理地址之间的映射是由操作系统的内存管理单元(MMU,Memory Management Unit)来实现的。MMU 将虚拟地址映射到物理地址,使得程序访问虚拟地址时,可以被正确地映射到对应的物理地址,从而实现正确的内存访问。
在计算机系统中,虚拟地址与物理地址之间的映射是由操作系统的内存管理单元(MMU,Memory Management Unit)来实现的。MMU 将虚拟地址映射到物理地址,使得程序访问虚拟地址时,可以被正确地映射到对应的物理地址,从而实现正确的内存访问。
2.1 CPU 是如何执行程序的? | 小林coding (xiaolincoding.com)