概述
计算机的属性反映的是人类创造者的本性。
其内部复杂的系统,依赖于底层原理来驱动,很多计算机的原理是相同的,不同的编程语言,复杂的业务逻辑等等,都是在讲述同一个故事。
本篇从物理硬件上的 CPU 如何支撑源代码运行的角度来窥探计算机硬件和软件的合作关系。
首先明确程序的概念。程序是指令和数据的组合体。例如,C语言“printf ("你好"); ”这个简单的程序中,printf是指令,"你好"是数据。
你好 和 printf从人的视角是很容易理解的,但计算机只能理解0101 的二进制机器码。
所以编程代码是如何运行的呢。
本文分两层讲述:
第一层:概述高级语言如何变成机器码.
第二层:解释机器码如何驱动 CPU 运行。
两者结合就解释了程序运行的原理。
第一层: 概述高级语言如何变成机器码
计算机作为被造者,必然会朝向对人类越来越友好的轨迹发展,所谓的高级语言是从人类的视角出发,编程更友好。
从最开始通过纸带有孔无孔来表示 0 和 1。
到后来,汇编语言采用助记符(memonic)来编写程序,每一个原本是0101的机器语言指令都会有一个与其相应的助记符,助记符通常为指令功能的英语单词的简写。
而汇编指令与原有的二进制指令几乎保持着一一对应的关系。
所以汇编语言的产生,但依赖于 0101 机器码的年代,是一个很大的生产力提升。
但纵使这样,汇编编程在大型程序中,晦涩难懂,很为开发维护。因此后续衍生了 C 语言,再后来从面向程序的语言进化到更高级的语言。必然从这种趋势上看,未来肯定会有更好的语言,将编程的门槛降到更低。
但以上是建立在人类的视角,在计算机的视角,尤其是 CPU 和内存,纵使你千遍万换,CPU 能直接识别并使用的语言,对应的指令信息只有二进制的机器码。
所以要想人类编写的语言能让 CPU 真正执行,就需要层层剥离对人类友好的抽象,还原本真,变成机器码。
目前的高级语言可以分为两类: 解释型语言和编译型语言,这两种分别需要通过解释器和编译器变成汇编语言。然后借助汇编语言与机器码的映射关系,变为 CPU 可识别的程序。
所以总结下来,第一层就是从高级语言的源码层变成 CPU 能识别的机器码。
第二层: 解释机器码如何驱动 CPU 运行
前面讲到,程序中包含指令和数据。以上两个概念,在 CPU 中是怎么表示的呢。
这里先对 CPU 的物理结构简单展开以便于后续原理解释。
CPU 的物理结构和运行过程
物理结构上看,CPU 和内存都是由一堆具有 ON/OFF 开关功能的晶体管组成的电子器件。
从功能的角度,CPU的内部由寄存器、控制器、运算器、时钟四个部分构成,各部分之间由电流信号相互连通。
- 寄存器可用来暂存指令、数据等处理对象,可以将其看作是内存的一种。根据种类的不同,一个CPU内部会有20~100个寄存器。
- 控制器负责把内存上的指令、数据等读入寄存器,并根据指令的执行结果来控制整个计算机。
- 运算器负责运算从内存读入寄存器的数据。
- 时钟负责发出CPU开始计时的时钟信号。不过,也有些计算机的时钟位于CPU的外部。
- CPU 的运行过程大致可以解释为:
指令和数据存储在内存中,程序启动后,根据时钟信号,控制器会从内存中读取指令和数据。通过对这些指令加以解释和运行,运算器就会对数据进行运算,控制器根据该运算结果来控制计算机。
接下来就深入到寄存器内部,层层剥开机器码驱动 CPU 的运行的神秘面纱。
考虑到写一堆由 01 组成的机器码对于阅读和理解过于晦涩。
而汇编语言采用助记符(memonic)来编写程序,每一个原本是01的机器语言指令都会有一个与其相应的助记符,助记符通常为指令功能的英语单词的简写。例如,mov和add分别是数据的存储(move)和相加(addition)的简写。汇编语言和机器语言基本上是一一对应的。
我们利用汇编语言与机器码一一对应的关系来说明机器码驱动 CPU 运行的原理。
CPU 中的寄存器
寄存器根据功能的不同,分类也不同。
寄存器就是程序运行时,指令和数据的真实物理载荷。
这里的数据,可以分为用于运算的数值和表示内存地址的数值两种。
当然,数据类型不同,存储该数值的计数器也不同。
用于运算的数值放在累加寄存器中,表示内存地址的数值则放在基址寄存器和变址寄存器中。(所以值类型和引用类型在寄存器的存储方式就有区别)
所以综上能看出,CPU是具有各种功能的寄存器的集合体。其中,程序计数器、累加寄存器、标志寄存器、指令寄存器和栈寄存器都只有一个,其他的寄存器一般有多个。
寄存器的运作原理
程序计数器
程序运行启动后,操作系统会将硬盘中保存的程序复制到内存中,让 CPU 根据指令和数据信息来执行。
程序计数器也叫做指令计数器,是用于存放下一条指令所在单元的地址的地方。
当执行一条指令时,首先需要根据程序计数器中存放的指令地址,将指令由内存取到指令寄存器中,此过程称为取指令。与此同时,PC中的地址或自动加1或由转移指针给出下一条指令的地址。此后经过分析指令,执行指令。完成第一条指令的执行,而后根据程序计数器取出第二条指令的地址,如此循环,执行每一条指令。
为保证程序能连续自动执行下去,CPU 必须具有某些手段来确定下一条指令的地址,程序计数器就提供了物理基础。在程序开始执行前,必须将它的起始地址,即程序的第一条指令所在的内存单元地址送入程序计数器,因此程序计数器的内容即是从内存提取的一条指令的地址。当执行指令时,CPU 将自动修改程序计数器的内容,即每执行一条指令程序计数器增加一个量,这个量等于指令所含的字节数,以便使其保持的总是将要执行的下一条指令的地址。由于大多数指令都是按顺序来执行的,所以修改的过程通常只是简单的对程序计数器加1。
但是,当遇到转移指令如JMP(跳转、外语全称:JUMP)指令时,后继指令的地址(即PC的内容)必须从指令寄存器中的地址字段取得。在这种情况下,下一条从内存取出的指令将由转移指令来规定,而不像通常一样按顺序来取得。因此程序计数器的结构应当是具有寄存信息和计数两种功能的结构。
根据顺序,选择和循环不同方式,往程序计数器中送入不同的指令。
函数调用机制和函数调用堆栈
函数调用处理也是通过把程序计数器的值设定成函数的存储地址来实现的。不过,这和条件分支、循环的机制有所不同,函数的调用过程更为复杂,尤其对于嵌套函数的调用,单纯的跳转指令无法实现函数的调用。 函数的调用需要在完成函数内部的处理后,处理流程再返回到函数调用点(函数调用指令的下一个地址)。因此,如果只是跳转到函数的入口地址,处理流程就不知道应该返回至哪里了.
具体用一段程序讲解
其流程是:
int a = 123;
int b = 456;
c = MyFunc(a, b);
d = Nextfunc(a, b);
int Myfunc(int a, int b) {
if a > b {
return a - b;
} else {
return a + b
}
}
int Nextfunc(int a, int b) {
return a - b;
}
除了程序计数器来保证指令的调用之外,对于函数调用内部的函数,需要通过栈寄存器来记录并保存返回值。
机器语言的call指令和return指令能够解决这个问题。建议大家把二者结合起来来记忆。函数调用使用的是call指令,而不是跳转指令。在将函数的入口地址设定到程序计数器之前,call指令会把调用函数后要执行的指令地址存储在名为栈的主存内(此代码中代码的是 Nextfunc的指令)。函数处理完毕后,再通过函数的出口来执行return命令。return命令的功能是把保存在栈中的地址(Nextfunc的指令)设定到程序计数器中,继续执行。
简单来说,函数的调用会转化为 call 指令,将该函数的调用地址设定到程序计数器中;
函数结束会转换成 return 指令,将返回目的地的地址(下一条指令的地址)设定在程序计数器上,这样程序就可以流畅运行了。
至于栈寄存器的运行原理这里不作展开。
汇编语言和机器语言的种类
通过 CPU 的描述不难懂得,其实 CPU 依赖的硬件是有限的。所以任何复杂的逻辑,翻译成对应的机器之类,也就几种。
类型 | 功能 |
---|---|
数据转送指令 | 寄存器和内存,内存和内存,寄存器和外围设备之间的读写操作 |
运算指令 | 用累加寄存器执行算术运算,逻辑运算,比较运算和移位运算 |
跳转指令 | 实现条件分治,循环,强制跳转等 |
call/return 指令 | 函数的调用/返回函数的地址 |
总结
本文主要希望通过对程序的运行机制有一个整体宏观的描述能让大家更充分的理解编程,让抽象的世界不再那么晦涩难懂,给你恍然大悟的感觉。