代码写了那么多,你知道 a = 1 + 2
这条代码是怎么被 CPU 执行的吗?
软件用了那么多,你知道软件的 32 位和 64 位之间的区别吗?再来 32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?
CPU 看了那么多,我们都知道 CPU 通常分为 32 位和 64 位,你知道 64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?
不知道也不用慌张,接下来就循序渐进的、一层一层的攻破这些问题。
要想知道程序执行的原理,我们可以先从「图灵机」说起,图灵的基本思想是用机器来模拟人们用纸笔进行数学运算的过程,而且还定义了计算机由哪些部分组成,程序又是如何执行的。
图灵机长什么样子呢?你从下图可以看到图灵机的实际样子:
图灵机的基本组成如下:
知道了图灵机的组成后,我们以简单数学运算的 1 + 2
作为例子,来看看它是怎么执行这行代码的。
+
号是运算符指令,作用是加和目前的状态,于是通知「运算单元」工作。运算单元收到要加和状态中的值的通知后,就会把状态中的 1 和 2 读入并计算,再将计算的结果 3 存放到状态中;通过上面的图灵机计算 1 + 2
的过程,可以发现图灵机主要功能就是读取纸带格子中的内容,然后交给控制单元识别字符是数字还是运算符指令,如果是数字则存入到图灵机状态中,如果是运算符,则通知运算符单元读取状态中的数值进行计算,计算结果最终返回给读写头,读写头把结果写入到纸带的格子中。
事实上,图灵机这个看起来很简单的工作方式,和我们今天的计算机是基本一样的。接下来,我们一同再看看当今计算机的组成以及工作方式。
在 1945 年冯诺依曼和其他计算机科学家们提出了计算机具体实现的报告,其遵循了图灵机的设计,而且还提出用电子元件构造计算机,并约定了用二进制进行计算和存储,还定义计算机基本结构为 5 个部分,分别是中央处理器(CPU)、内存、输入设备、输出设备、总线。
这 5 个部分也被称为冯诺依曼模型,接下来看看这 5 个部分的具体作用。
我们的程序和数据都是存储在内存,存储的区域是线性的。
数据存储的单位是一个二进制位(bit),即 0 或 1。最小的存储单位是字节(byte),1 字节等于 8 位。
内存的地址是从 0 开始编号的,然后自增排列,最后一个地址为内存总字节数 - 1,这种结构好似我们程序里的数组,所以内存的读写任何一个数据的速度都是一样的。
中央处理器也就是我们常说的 CPU,32 位和 64 位 CPU 最主要区别在于一次能计算多少字节数据:
这里的 32 位和 64 位,通常称为 CPU 的位宽。
之所以 CPU 要这样设计,是为了能计算更大的数值,如果是 8 位的 CPU,那么一次只能计算 1 个字节 0~255
范围内的数值,这样就无法一次完成计算 10000 * 500
,于是为了能一次计算大数的运算,CPU 需要支持多个 byte 一起计算,所以 CPU 位宽越大,可以计算的数值就越大,比如说 32 位 CPU 能计算的最大整数是 4294967295
。
CPU 内部还有一些组件,常见的有寄存器、控制单元和逻辑运算单元等。其中,控制单元负责控制 CPU 工作,逻辑运算单元负责计算,而寄存器可以分为多种类,每种寄存器的功能又不尽相同。
CPU 中的寄存器主要作用是存储计算时的数据,你可能好奇为什么有了内存还需要寄存器?原因很简单,因为内存离 CPU 太远了,而寄存器就在 CPU 里,还紧挨着控制单元和逻辑运算单元,自然计算时速度会很快。
常见的寄存器种类:
总线是用于 CPU 和内存以及其他设备之间的通信,总线可分为 3 种:
当 CPU 要读写内存数据的时候,一般需要通过两个总线:
输入设备向计算机输入数据,计算机经过计算后,把数据输出给输出设备。期间,如果输入设备是键盘,按下按键时是需要和 CPU 进行交互的,这时就需要用到控制总线了。
数据是如何通过地址总线传输的呢?其实是通过操作电压,低电压表示 0,高压电压则表示 1。
如果构造了高低高这样的信号,其实就是 101 二进制数据,十进制则表示 5,如果只有一条线路,就意味着每次只能传递 1 bit 的数据,即 0 或 1,那么传输 101 这个数据,就需要 3 次才能传输完成,这样的效率非常低。
这样一位一位传输的方式,称为串行,下一个 bit 必须等待上一个 bit 传输完成才能进行传输。当然,想一次多传一些数据,增加线路即可,这时数据就可以并行传输。
为了避免低效率的串行传输的方式,线路的位宽最好一次就能访问到所有的内存地址。
CPU 要想操作的内存地址就需要地址总线,如果地址总线只有 1 条,那每次只能表示 「0 或 1」这两种情况,所以 CPU 一次只能操作 2
个内存地址;如果想要 CPU 操作 4G 的内存,那么就需要 32 条地址总线,因为 2 ^ 32 = 4G
。
知道了线路位宽的意义后,我们再来看看 CPU 位宽。
CPU 的位宽最好不要小于线路位宽,比如 32 位 CPU 控制 40 位宽的地址总线和数据总线的话,工作起来就会非常复杂且麻烦,所以 32 位的 CPU 最好和 32 位宽的线路搭配,因为 32 位 CPU 一次最多只能操作 32 位宽的地址总线和数据总线。
如果用 32 位 CPU 去加和两个 64 位大小的数字,就需要把这 2 个 64 位的数字分成 2 个低位 32 位数字和 2 个高位 32 位数字来计算,先加个两个低位的 32 位数字,算出进位,然后加和两个高位的 32 位数字,最后再加上进位,就能算出结果了,可以发现 32 位 CPU 并不能一次性计算出加和两个 64 位数字的结果。
对于 64 位 CPU 就可以一次性算出加和两个 64 位数字的结果,因为 64 位 CPU 可以一次读入 64 位的数字,并且 64 位 CPU 内部的逻辑运算单元也支持 64 位数字的计算。
但是并不代表 64 位 CPU 性能比 32 位 CPU 高很多,很少应用需要算超过 32 位的数字,所以如果计算的数额不超过 32 位数字的情况下,32 位和 64 位 CPU 之间没什么区别的,只有当计算超过 32 位数字的情况下,64 位的优势才能体现出来。
另外,32 位 CPU 最大只能操作 4GB 内存,就算你装了 8 GB 内存条,也没用。而 64 位 CPU 寻址范围则很大,理论最大的寻址空间为 2^64
。
在前面,我们知道了程序在图灵机的执行过程,接下来我们来看看程序在冯诺依曼模型上是怎么执行的。
程序实际上是一条一条指令,所以程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是 CPU 了。
那 CPU 执行程序的过程如下:
简单总结一下就是,一个程序执行的时候,CPU 会根据程序计数器里的内存地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。
CPU 从程序计数器读取指令、到执行、再到下一条指令,这个过程会不断循环,直到程序执行结束,这个不断循环的过程被称为 CPU 的指令周期。
知道了基本的程序执行过程后,接下来用 a = 1 + 2
的作为例子,进一步分析该程序在冯诺伊曼模型的执行过程。
CPU 是不认识 a = 1 + 2
这个字符串,这些字符串只是方便我们程序员认识,要想这段程序能跑起来,还需要把整个程序翻译成汇编语言的程序,这个过程称为编译成汇编代码。
针对汇编代码,我们还需要用汇编器翻译成机器码,这些机器码由 0 和 1 组成的机器语言,这一条条机器码,就是一条条的计算机指令,这个才是 CPU 能够真正认识的东西。
下面来看看 a = 1 + 2
在 32 位 CPU 的执行过程。
程序编译过程中,编译器通过分析代码,发现 1 和 2 是数据,于是程序运行时,内存会有个专门的区域来存放这些数据,这个区域就是「数据段」。如下图,数据 1 和 2 的区域位置:
注意,数据和指令是分开区域存放的,存放指令区域的地方称为「正文段」。
编译器会把 a = 1 + 2
翻译成 4 条指令,存放到正文段中。如图,这 4 条指令被存放到了 0x200 ~ 0x20c 的区域中:
load
指令将 0x100 地址中的数据 1 装入到寄存器 R0
;load
指令将 0x104 地址中的数据 2 装入到寄存器 R1
;add
指令将寄存器 R0
和 R1
的数据相加,并把结果存放到寄存器 R2
;store
指令将寄存器 R2
中的数据存回数据段中的 0x108 地址中,这个地址也就是变量 a
内存中的地址;编译完成后,具体执行程序的时候,程序计数器会被设置为 0x200 地址,然后依次执行这 4 条指令。
上面的例子中,由于是在 32 位 CPU 执行的,因此一条指令是占 32 位大小,所以你会发现每条指令间隔 4 个字节。
而数据的大小是根据你在程序中指定的变量类型,比如 int
类型的数据则占 4 个字节,char
类型的数据则占 1 个字节。
上面的例子中,图中指令的内容我写的是简易的汇编代码,目的是为了方便理解指令的具体内容,事实上指令的内容是一串二进制数字的机器码,每条指令都有对应的机器码,CPU 通过解析机器码来知道指令的内容。
不同的 CPU 有不同的指令集,也就是对应着不同的汇编语言和不同的机器码,接下来选用最简单的 MIPS 指集,来看看机器码是如何生成的,这样也能明白二进制的机器码的具体含义。
MIPS 的指令是一个 32 位的整数,高 6 位代表着操作码,表示这条指令是一条什么样的指令,剩下的 26 位不同指令类型所表示的内容也就不相同,主要有三种类型R、I 和 J。
一起具体看看这三种类型的含义:
接下来,我们把前面例子的这条指令:「add
指令将寄存器 R0
和 R1
的数据相加,并把结果放入到 R3
」,翻译成机器码。
加和运算 add 指令是属于 R 指令类型:
000000
,以及最末尾的功能码是 100000
,这些数值都是固定的,查一下 MIPS 指令集的手册就能知道的;00000
;00001
;00010
;00000
把上面这些数字拼在一起就是一条 32 位的 MIPS 加法指令了,那么用 16 进制表示的机器码则是 0x00011020
。
编译器在编译程序的时候,会构造指令,这个过程叫做指令的编码。CPU 执行程序的时候,就会解析指令,这个过程叫作指令的解码。
现代大多数 CPU 都使用来流水线的方式来执行指令,所谓的流水线就是把一个任务拆分成多个小任务,于是一条指令通常分为 4 个阶段,称为 4 级流水线,如下图:
四个阶段的具体含义:
上面这 4 个阶段,我们称为指令周期(Instrution Cycle),CPU 的工作就是一个周期接着一个周期,周而复始。
事实上,不同的阶段其实是由计算机中的不同组件完成的:
指令从功能角度划分,可以分为 5 大类:
store/load
是寄存器与内存间数据传输的指令,mov
是将一个内存地址的数据移动到另一个内存地址的指令;if-else
、swtich-case
、函数调用等。trap
;nop
,执行后 CPU 会空转一个周期;CPU 的硬件参数都会有 GHz
这个参数,比如一个 1 GHz 的 CPU,指的是时钟频率是 1 G,代表着 1 秒会产生 1G 次数的脉冲信号,每一次脉冲信号高低电平的转换就是一个周期,称为时钟周期。
对于 CPU 来说,在一个时钟周期内,CPU 仅能完成一个最基本的动作,时钟频率越高,时钟周期就越短,工作速度也就越快。
一个时钟周期一定能执行完一条指令吗?答案是不一定的,大多数指令不能在一个时钟周期完成,通常需要若干个时钟周期。不同的指令需要的时钟周期是不同的,加法和乘法都对应着一条 CPU 指令,但是乘法需要的时钟周期就要比加法多。
如何让程序跑的更快?
程序执行的时候,耗费的 CPU 时间少就说明程序是快的,对于程序的 CPU 执行时间,我们可以拆解成 CPU 时钟周期数(CPU Cycles)和时钟周期时间(Clock Cycle Time)的乘积。
本文系作者在时代Java发表,未经许可,不得转载。
如有侵权,请联系nowjava@qq.com删除。