电脑是怎样执行编程语言的

在知乎上看到的一篇回答。

 

下面是原回答:

 

这个问题真的是很大,让我们自顶向下的解释。

 

 

在顶层,程序员编写出来的都是源代码。源代码可以使用各种高级语言写成,例如 c/c++ c# java python等等;也可以使用对应平台的低级语言写成,例如汇编。想必你已经了解其中的过程了。

 

到这一步为止,距离最终机器可以执行的指令还有一大步要走。

 

首先要面临的一个问题是:源代码都是以人类语言写成的。即便是能够和机器指令一对一翻译的汇编代码,依然是人类语言。计算机无法理解其中的含义,所以不可能执行。

 

所以我们需要将人类语言翻译为计算机语言。计算机能听懂的语言,就叫做机器语言,简称机器码。

 

 

在这里说几句题外话。

 

在计算机历史的上古时代,大概是上个世纪50年代之前。那时编译理论和形式语言还没有得到发展。几乎所有的程序都是直接由机器码写成的。比如由工程师直接将二进制机器码和数值编写在打孔卡上,通过读卡机读入计算机存储器,然后执行。

 

而打孔卡长这个样子:

 

 

(来自 wiki,80列标准IBM打孔卡,你能读出上面是什么意思吗?)

 

计算机的基本架构虽然经过了将近百年的发展,但是核心的模型倒是一直很稳定,都是存储程序模型。

 

首先将程序指令从外存(打孔卡,磁带,硬盘,软盘,光盘,闪存卡,网络等)读入内存,然后让处理器从内存按顺序取指执行,结果写回内存中。

 

在那个年代,人们对程序运行原理的理解是不存在什么障碍的。工程师怎么写,计算机就严格的按照指令执行。每一条指令对应一个步骤。最后的到结果。

 

在这种条件下,程序开发绝对是顶尖的职业,首先能够理解目标机的架构就需要相当的功夫了。其次还要按照机器的方式思考,写出正确无误的指令序列。

 

这样的开发过程无疑限制了计算机行业的发展。

 

同时,即便是擅长于按照机器方式思考的工程师,也认为机器指令太难记了。如你所见,在打孔卡上准确无误的写上指令真是头疼的要死。所以工程师们开发了一套助记符,用来指示对应的机器码,这样以来,程序的编写和 debug就方便多了。到上世纪40年代末期,就已经有一批成熟的助记符体系了。

 

 

(ARM v7汇编指令卡中的某一页)

 

关于助记符的话题,暂且搁置。

 

 

回到正题。为了将人类语言翻译成机器变成机器能够理解的语言,还需要进行翻译。就好像你不懂英语,英语可以翻译成汉语,这样你就能明白其中的含义。对于计算机来说,这个过程是一样的。不过计算机对于翻译有更高的要求。人类之间互相翻译语言,有一些微小的出入并不影响理解,计算机为了能够准确的得到结果,要求这个翻译的过程,必须保证“将一种语言翻译成涵义相同的等价的另一种语言”。

 

在早期,程序的规模还比较小,翻译的过程可以人工的进行。利用查表的方式,最终是可以得到等价的机器码序列。随着计算机科学的发展,程序规模膨胀的越来越快,人工翻译变的没有可行性。此时就有人提出,编写一套软件来进行这个翻译的过程。

 

一开始人们只用汇编语言进行程序开发。所以只需要将汇编语言翻译为机器语言就可以了。这是相当直截了当的过程,因为汇编语言的助记符和机器指令是一一对应的关系。所以只需要完成一个能够自动查表并转换的程序即可。很快,这样的程序就被发明了出来。我们称之为“汇编器”。

 

伴随着汇编器的发展,工程师又开始想要偷懒。他们认为,既然汇编器可以将汇编指令翻译成等价的机器码,那么在翻译之前一定也可以做一些预先处理的工作,将一个助记符转换为多个助记符组成的序列。这样以来,开发人员就可以使用较少的代码,写出较多的内容。同时将常用的一些程序结构编写成对应的助记符,在需要时就使用这个助记符,还可以帮助开发人员减少程序出错的可能。简直太好了。于是,人们又在汇编器中引入了宏指令。

 

所谓“宏(macro)”就是一套预先定义好的指令序列。每当汇编进行的时候,先预处理一次将宏等价的展开,然后再进行翻译。如此,源程序变的更加容易理解了。

 

 

宏的引入,催生了程序结构化表达。在今天的汇编语言当中,我们也可以像使用高级语言的 if else for while语句一样,使用等价的结构语句。只不过,汇编中的结构语句都是宏实现的。

 

 

结构化表达给了一些计算机科学人员启发。能不能更进一步,使用完全结构化,脱离某个对应机器平台的形式化语言来描述一个源程序?于是,就有了高级语言及其编译器。

 

开发人员利用高级语言编写程序,然后利用对应的编译器生成中间代码,最后再将中间代码变成机器码。中间代码可以是等价的汇编代码,也可以是其它类型的代码例如 JVM的字节码。最终处理中间代码的程序可以是一个对应平台的汇编器,也可以是一个解释器。在这里姑且隐去这些细节,将编译的最终产物都视为一系列可以被执行的二进制机器码。关于编译器的更多内容,在网上可以找到很多详细的资料。在这个话题下,编译器不是核心问题,我就不再深入讨论了。

 

 

至此,就得到了一个可以被执行的程序了。这个文件的内容是一系列二进制指令和数据组成的序列。它能被装入机器的内存,并且可以被处理器解码执行。

 

 

但是,为什么是二进制?

 

 

说回来,计算机其实是长期使用的一个简称。严格的讲应该叫做“电子计算机”。但是计算机的形态并不限于电子式计算机。算盘,计算尺,对数计算表都可以算作广义上的计算机,同时在电子式计算机出现之前,它的还有一个机械式计算机的表亲。

 

 

(来自 Wiki。 查尔斯·巴贝奇  的分析机。蒸汽动力驱动,采用十进制,其内存能够存储1000个50位的十进制数,相当于20.7 KB 的 SRAM或DDRAM。采用打孔纸带读入程序,具有类似汇编语言的助记符编程系统,是图灵完备的。很蒸汽朋克,嗯?)

 

 

可是我们并不认为算盘以及计算尺和现代计算机是同一个东西。最核心的区别在于,现代计算系统是可编程的。按照这个定义,上面的分析机也是现代电子是计算机的鼻祖。它身上的核心模型一直继承至今。

 

在分析机上,已经实现了 “存储程序计算机”。

 

这也就是现代计算系统的基本概念:

 

以运算单元为中心

采用存储程序原理

存储器是按地址访问的,线性的空间

控制流由程序的指令流产生

指令由操作码和操作数组成

这一概念所描述的计算模型具有以下的过程:将完整的程序装入存储器后,运算单元依照地址按顺序的从存储器中取出指令和数据且执行。指令序列就像流水一样“流”入运算单元,当指令流尽,就意味着程序结束了。

 

 

对于计算机,自然是希望运算的速度越快越好。所以机械式运算很快就淘汰了。取而代之的就是电子式计算机。

 

 

电子式计算机的硬件基础,就是数字电路。因为二进制可以很自然的表示开和关的两种状态,高和低的两种状态,通和断的两种状态,等等。所以很快就取得了主导低位,其它进制的数字电子器件沦为小众。

 

理论上,二进制和十进制表示的数的范围是一样多的。因为实数集是一个连续同,不同进制实质上是对数集的不同分割。

 

基于二进制数字电子器件制造的电子式计算机自然就需要二进制的输入输出。

 

到了这个层次,我们基本上解释了高级语言源程序是如何成为计算机可以识别的二进制指令序列的。接下来的问题是,计算机如何识别并执行二进制指令呢?

 

通用处理器被称为“通用”,就是因为它不限定于特定用途。路边上买一个计算器。只能计算四则运算,而计算机还能进行字处理,可以玩游戏看电影。都有赖于通用处理器提供的运算能力。

 

为了实现通用的目标,处理器在设计之初就不能对未来可能进行的运算进行限制。但是未来的可能性是无穷的。处理器不可能穷尽所有可能。

 

所以,处理器提供了一套它能够支持的运算操作的集合,称为“指令集”。指令集就限定了该处理器能够进行的所有运算。而且这些运算通常都是有关于数字的运算。如果我们想解决一个任意问题,那么首先要把这个问题转换为一个数字问题,再把数字问题的解答过程,用指令集当中的指令求解。

 

将其它问题转换为数学问题的一种方法就是编码。比如我们常见的 ASCII码表,就是把英语字符数字字符以及电报传输过程中的控制字编码成对应的数字。例如字符a就等于数字97。

 

处理器的指令集同样是经过编码的。所以我们才能用二进制数字流来表示指令。

 

举个例子。在一个典型的 Intel IA-32处理器上所支持的x86指令集。假设我们想将一个字节的数据从内存移动到al寄存器,不妨就让这个数据在内存中0x20(十六进制表示的32)号字节的位置好了。那么,我们要写出汇编代码:

 

mov al, 30h

将这一行代码送入汇编器,得到对应的机器码为:

 

0xB0 0x20

二进制的表示为:

 

1011 0000  0010 0000

其中 0xB0就是我们的指令,也就是执行第176号指令。这条指令的意思是:从内存中指定的位置搬移数据一个字节宽度的数据到al寄存器。地址由紧跟在本指令后的数给出,在这里就是0x20。

 

指令集中的每一个指令都可以这样编码。每一条指令都定义了一系列的操作。

 

如此,只要按照顺序的从存储器读入指令代号和数据,就可以让程序执行下去。

 

你又要说了,那如果我有循环,有条件判断怎么办?

 

简单。处理器为了能顺序的取指并执行,需要知道当前指令的下一条指令在哪里。为什么不是这一条指令在哪了?因为这一条指令已经取回来了,所以它在哪里就不重要了。为了记录当前指令的下一条指令的位置,处理器内部有一个存放了这个地址的电子装置,实现上它是一系列门电路组成的锁存器,叫做 IP 寄存器(也有叫做 PC的,这里统称为IP)。IP的值可以在运行时被修改。那么只要提供了能够修改IP值的指令,就能改变程序的执行流程。可以返回到之前的某个位置,也可以一次前进到之后的某个位置。这个过程叫做“跳转”。

 

所谓循环和判断,本质上都是判断并跳转。

 

用一个程序来做一个直观的说明。这个程序很简单。求出一个数组中所有数的和,然后返回这个值,如果这个值是0,则返回一个-1。

 

和它等价的 C代码如下,这里我们将结果返回运行时:

 

 

int main(void) {

    int numbers[5] = {1, 2, 3, 4, 5};

    int result = 0;

 

    for (size_t i = 0; i != 5; ++i) result += numbers[i];

 

    return (result == 0 ? -1 : result);

}

编译器产生的汇编文件长什么样子呢?长这样的:

 

CONSTSEGMENT

constNumbers: 0x01, 0x02, 0x03, 0x04, 0x05

CONSTENDS

 

TEXTSEGMENT

numbers SIZE 20 BYTE

 

mainPROC

 

subesp, 20

movapsxmm0, XMMWORD PTR constNumbers

 

xoreax, eax

push5

popedx

movupsXMMWORD PTR numbers[ebp], xmm0

movDWORD PTR numbers[ebp+16], edx

 

movecx, eax

Loop:

addeax, DWORD PTR numbers[ebp+ecx*4]

incecx

cmpecx, edx

jneSHORT Loop

 

orecx, -1

testeax, eax

cmoveeax, ecx

 

_mainENDP

_TEXTENDS

END

为了便于解释,这里隐去了很多细节,并且使用了很多伪代码。上面的汇编程序是不经修改是无法通过编译的。

 

等价的二进制文件又是什么样子呢?为了方便阅读,我稍稍整理了一下,并且加上了对应的汇编代码,它长这个样子:

 

(第8行操作数应当分为两列,这里有一个小错误。)

 

同样的,还是省去了很多细节。绿色的部分就是机器码。

 

我完全理解使用助记符和高级语言的重要性。否则谁能通过机器码一眼看出一段程序的含义呢?

 

 

当程序装入内存以后,IP将被(另外的某个程序,可能来自操作系统,或者其它软件)设置为1,意思是:下一条要读取的指令在1的位置。然后处理器就开始读入指令。

 

为什么处理器会读入指令呢?它是收到某个信号才会读指令吗?简单的讲,处理器从上电到掉电的整个过程当中只做三件事情,那就是:

 

 

从内存读取一条指令和指令携带的操作数,同时 IP + 1

解码并执行指令

回到 1

 

所以不需要什么信号。在上一条指令将 IP的值修改为1之后,处理器就已经完成跳转,找到程序入口了。

 

处理器取指,读入第一条指令 0xce83。这里要插入一点,Intel的处理器采用的是小端数据格式,就是说一个数的高位放在地址较高的地方,低位放在地址较低的位置。所以要倒过来读,在这里就不详细解释了,略过。

 

处理器将这条啊指令送入解码器,解码的结果告诉处理器,应当执行“将esp寄存器中的值减去一个指定数,该数由紧随指令的连续四个字节指定”的操作。然后处理器通过数据总线连续读入四个字节,得出操作数应该是0x14(十六进制的20)。接着就执行了这个操作,IP + 1,继续取出下一条指令。

 

这个过程是很好理解的。总之就是这样的循环。直到断电。

 

 

再注意一下行号 11和12标识的代码。11行将执行比较ecx寄存器中的值和edx寄存器内的值。根据不同的结果,12行指令将有不同的行为:

 

 

两个值相同的时候,12行指令什么也不做,IP + 1。

两个值不同的时候,12行指令会将Loop标号的地址写入IP。IP = 9。

 

程序走着走着就走回去了。这就是比较与跳转。简单吧。

 

而 10行的代码将会使ecx寄存器内的值增长,每次经过10行都+1,随着循环的进行,程序流不断的跳转到9行,然后经过10行。在某一次经过后,ecx等增长正好令ecx = edx成立。这时候12行将什么也不做,IP指向13,程序又继续进行下去了。

 

 

接下来,进入处理器的层次来理解它如何工作的。在这里我们要讨论四个问题:

 

指令是如何表示的?

数据是如何取回的?

指令是如何解码的?

指令是如何执行的?

 

程序运行的过程,上面已经提到过了。程序是完整的装入内存中的。运算器能够直接操作的只有存储器中的数据。他们之间的硬件连接如图所示:

 

 

sorry,搞错了,是这个:

 

 

图上黄色的一根粗线其实以一排并列的导线,在这里是 8根导线并列在一起。只是看起来画在了一起,其实是互相分开的。

 

使用 8051及其外部扩展存储器接口电路来说明问题主要是为了简便。在不失准确性的前提下,我依然隐去一些细节,方便理解。

 

 

访问存储器的过程主要关注两个问题:

 

送出地址

取回数据

考虑一般的访问过程,当运算器执行如下操作时:

 

 

mov al, 0xD0D0

将会发生什么呢?

 

首先 mov指令指定了数据搬移的操作,第二个操作数是一个立即数参数,直接给出了地址。现在就要到存储器当中去找这个地方了。

 

处理器不能直接操作存储器的具体单元,但是它可以请存储器将对应单元中的数据准备好,然后取回来。你肯定有过取快递的经历,菜鸟驿站去过吧,门市点不会让你亲自进仓库去找快递的,但是你可以告诉快递小哥你的单号,然后他进去帮你找到,最后把包裹交给你。内存和这一个意思。

 

处理器首先将地址放到地址总线上,地址总线就是图上 D0-D7和A8-A15这15根导线组成的。

 

处理器将自己的端口设置成对应的值,就把地址放到了总线上。0xD0D0对应的端口状态应该是:

 

 

(图有点小)

 

然后,处理器告诉存储器,我准备好取数据了,地址在总线上,请你准备数据。具体的方式就是拉低 \overline{OE}端口的电压到地电位(一般就是0V)。存储器得到这个消息后,就从总线上取得地址。然后解码这个地址,找到对应的数据,假设数据是0x11吧,然后把数据再放回总线D7-D0上。

 

处理器在发出取指指令后会等待一段时间,然后就从总线上取回数据。取回的数据就当做存储器的回应。至于这个等待的时间具体多长,这是两个设备间相互约定好的。不需要关心。

 

最后,将总线上取得的数据 0x11放入al中,指令完成。

 

 

可能有的读者就很迷惑了,为什么放到总线上就能传递数据呢。

 

真实的情况是,总线上传递的是电压的信号。这也是为什么使用二进制方便的原因。总线就是一组导线,在这一组导线上,一一对应的连接了处理器和存储器的端口。虽然电子在导体内的移动速度很慢,但是电场的传播速度却是光速。所以当总线一端的端口建立了电位之后,另一端的电位将立刻改变。此时信号就已经从一个器件传递到了另一个。器件之间信号的传递,依赖的就是端口上电压的改变。器件对总线数据的读取,就是读取端口上电压的高低。而二进制可以使这个问题变的很简单。只要端口上能够反应电压的高和低区别就足以传递信号了,一般的,高电位的区间在 3.3V - 5.0V 之间,而低电位在 0V -  2.2V之间。考虑到总线都是板级的传输,距离很近,总线上电场传播所需要的时间可以忽略掉。那么一组总线传播数据的速度就取决于其两端端口上电位改变的速度。这可比读卡器读卡高了不知道哪去了。也比磁盘寻道和读取快的多。

 

 

在数字电路中,我们一般用 0表示低电平,用1表示高电平。

 

上面提到过,mov指令的编码是0xB0。这个编码是什么意思呢?将其写作二进制会发现

 

 

0xB0 = 1011 0000

刚刚我们已经介绍过了。0和是表示的就是电压的高低。现在一切都清楚了。数据的编码其实就说说的端口上电压的高低状态。如果处理器的输入端口在读入指令时读入的端口情况是从D7到D0为 高低高高 低低低低。那么就读入了1011 0000。

 

 

那么我们已经知道如何取数据了。取指令也是一个方法。只不过取指令的过程是自动的,指令的地址总是 IP的值。取回的指令总是送入指令解码器当中。

 

根据读入数据时处理器所处的不同阶段,将会给读入的数据一个不同的解释。读指令阶段就会把数据送入解码器。读数据阶段就会把数据送入另外的地方。

 

 

接下来,就需要进行指令解码。指令解码本身也是一个非常大的话题,其实单独拿出来也可以写出和本篇一样长的文章了。在这里只能概略的介绍一下。

 

 

处理器本身要完成某些特定的运算,在硬件上是需要某些特定结构的电路的支持的。比如你要完成一次加法,就需要一个带有加法器的电路。完成一次位移,就要有带移位寄存器的电路。简单的说,任何一条指令,都需要一组特定的电路来提供支持。但是人们通过长期的对数字电路的研究发现。几乎所有的运算,都可以通过有限的几种器件的不同组合来完成。这样的话,我们的通用运算器当中,可以包含一些要素器件,然后通过运行时改变它们的连接来实现不同的功能。这就是我们思考指令编码的方向。

 

其实在电子式计算机刚刚诞生的时候,就已经实现基本运算器的复用了。运算中心中包含了一组基础的运算器,它们的输入输出端口上同时连接了很多组不同的电路,每执行一条指令的时候,都选择其中特定的一组电路,使其生效,而让剩下的电路失效。这样在指令执行的过程中,这一组执行电路就可以独占整个运算器。当运算结束拿到结果后,电路再将运算器释放掉。就可以准备下一次的运算了。

 

在早期,还没有指令编码技术。要使用不同的指令,必须改变电路间的硬连接。也就是要把一组插头从这里拔下来插到另外的地方去。世界上第一台通用电子计算机  ENIAC的操作方式就是如此。编程的方式是女工进机房去接插头。

 

 

(假设我们有三条可编程指令流水线,那么如果我们想依次执行数据转移,异或,求和的操作,就需要连接 #1的move,#2的xor和#3的add)

 

而后出现了指令编码。送入的指令被解码器解码后,自动启动一组对应的电路。

 

 

这样说也许很难让人明白“自动”的含义。所以我在这里实现一个简单的编码指令处理器。在这里我们只实现3条指令:

 

指令0:将输入端的数据存入寄存器a

指令1:将输入端的数据存入寄存器b

指令2:取寄存器a寄存器b中的值求和,将结果放入寄存器a

在这里我们只研究解码,不管其它的因素,这样就简化了问题。不多说,直接上图:

 

 

最顶上的 instraction register和instraction decoder的部分就是指令解器。首先将指令读入一个寄存器,然后解码。实际的运算器也是这样的流程。图中蓝色的就是数据总线,寄存器内的值分别是两个寄存器Qx端口上的值。

 

让我们启动他它,来算一下 0x10 + 0x0F(16 + 15)是多少。

 

 

上电之后,我们注意到:

 

寄存器 a和寄存器b(右下两个)都被初始化为0xFF

输入端口(左下角)上的值为 0x00

指令寄存器(上方)当中当前的指令为 0x0F(15号),这是一条未定义的指令,所以没有任何效果。

首先我们要执行

 

 

mov a, 0x10

当指令读入后,在指令输入端将会是 0x00的状态,译码输出端口上输出全0,指示出目前要执行0号指令。同时选中了0号指令的执行电路。

 

数据端的输入为 0001 0000。mov命令的状态下,输入选择器选择输入端口的数据放到总线上。同时,寄存器a被激活,将总线上的值存入:

 

 

(可以看到 0x10已经被存入寄存器了)

 

第二条指令,我们将启动寄存器 b,然后存入数据。指令为:

 

 

mov b, 0x0f

 

instraction decoder的1号输出被选中,此时激活了1号指令的电路。输入端的0x0F被存入了寄存器b。而寄存器a中的值保持不变。

 

第三条指令,2号指令,求值。

 

 

add

没有给出操作时是因为操作是已经隐含的指明了,就是 a和b:

 

 

译码器的 2号输出选中了。全加器完成了运算(左侧是第四位,右侧是高四位),结果放上了总线,被锁存到了加法器的输出缓冲器当中。

 

同一时间,双输入选择器也被激活。它截断了输入端口的连接而选择加法器输出缓冲值作为输入,将其放上了总线。

 

寄存器 a从总线取得数据,存入。完成了指令。

 

 

看看,结果是 0x1F,恰好就是我们预期的31。

 

 

实际中的处理器的处理过程比这个复杂得多。这里为了方便理解,做了很多简化。但是概念都是相同的。处理器自动的从内存中读入指令和数据,然后解码,启动对应的电路,最后拿到结果。如此往复。

 

 

到此为止,已经几乎完全说明了计算机的运算原理,以及高级语言和机器语言的关系。但是我们依然可以更进一部,探究一下数字电路的构成。编码器是怎么运行的?寄存器是怎么锁存数据的?

 

 

上面一直在说解码器,那么解码器到底是什么?

 

处理器内部的指令解码器可能非常复杂,也许是一个器件,也有可能是一组器件,或者是可编程的硬件电路(对的,硬件电路也是可以编程的,例如 FPGA)。

 

而这里,我在上面的例子中使用的解码器:74LS42 4 Lines to 10  Lines BCD to Decimal Decoder(4线10线BCD译码器)的内部结构是这样的:

 

 

可以看到,BCD输入端(左边)输入后首先连接了非门(NOT),然后进入一个选择矩阵,最后通过三入与非门(NAND)输出。

 

 

与门(AND)、或门(OR)、非门(NOT)是数字电路中,最基础的三种逻辑门电路。它们的组后构建了大量的实用器件。

 

关于三种逻辑门,它们的特性可以使用真值表来表示:

 

 

(1代表真,0代表假)

 

与门:所有输入全为真,输出为真;

 

或门:任意一个输入为真,则输出真;

 

非门:输出总是输入的反。

 

 

利用这三个门就可以做很多有趣的事情了。

 

编辑于 2017-05-10

著作权归作者所有

若作者不愿转载此处,可以私信,侵删。。。

 

 

 

  • 18
    点赞
  • 57
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值