古老年代的编程
以前编程都用一种叫“打孔卡”的物理设备,首先是需要先把程序构思出来或者写在纸上,然后在纸带上或者卡片上打洞,这样,要写的程序、要处理的数据,就变成一条条纸带或者一张张卡,之后再交给当时的计算机去处理。在特定的位置打洞或者不打洞来代表“0”或者“1”。
原因很简单,就是计算机或者CPU没有能力直接理解这些高级语言,就算是现代的个人PC也只能处理所谓的“机器码”,也就是一连串的“0”和“1”这样的数字。
机器码和指令
高级语言如何变成一串串“0”和“1”的,这一串串的“0”和“1”有事怎么在CPU中处理的,就需要理解 “机器码“ 和 “计算机指令” 了。
从硬件的角度来看,CPU就是一个超大规模集成电路,通过电路实现了加法、乘法乃至各种各样的处理逻辑。而从软件的角度来看,CPU就是一个执行各种计算机指令的逻辑机器,这里的计算机指令,就好比一门CPU能够听的懂的语言,我们也叫它为机器语言。
不同的CPU能听懂的语言不太一样,不同的CPU就有两种不同的计算机指令集,代表不同的语法,单词。
一个计算机程序,不可能只有一条指令,而是由成千上万条指令组成的。但是 CPU 里不能一直放着所有指令,所以计算机程序平时是存储在存储器中的。这种程序指令存储在存储在存储器里面的计算机,我们就叫作存储程序型计算机(Stored-program Computer)。
从编译到汇编,代码怎么变成机器码?
了解了计算机指令和计算机指令集,接下来我们来看看,平时编写的代码,到底是怎么变成一条条计算机指令,最后被 CPU 执行的呢?哪一段C程序语言代码,要让它在Linux系统上跑起来,我们需要把整个程序翻译成一个汇编语言的程序,这个过程一般叫编译成汇编代码。
针对汇编代码,我们可以再用汇编器(Assembler)翻译成机器码(Machine Code)。这些机器码由“0”和“1组成的机器语言表示。这一条条机器码,就是一条条的计算机指令。一行 C 语言代码,有时候只对应一条机器码和汇编代码,有时候则是对应两条机器码和汇编代码。汇编代码和机器码之间是一一对应的。因为汇编代码其实就是“给程序员看的机器码”,也正因为这样,机器码和汇编代码是一一对应的。
指令和机器码
Intel CPU 有2000条左右的CPU指令,可分为五大类:
第一类:算术指令。我们的加减乘除,在 CPU 层面,都会变成一条条算术类指令。
第二类:数据传输指令。给变量赋值、在内存里读写数据,用的都是数据传输类指令。
第三类:逻辑指令。逻辑上的与或非,都是这一类指令。
第四类:条件分支指令。日常写的“if/else”,其实都是条件指令。
第五类:无条件跳转指令。程序中,在调用函数的时候,其实就是在发起了一个无条件跳转指令
汇编器是怎么把对应的汇编代码,翻译成为机器码的
以MIPS指令为例:
MIPS 的指令是一个 32 位的整数,高 6 位叫操作码(Opcode),也就是代表这条指令具体是一条什么样的指令,剩下的 26 位有三种格式,分别是 R、I 和 J。
R 指令是一般用来做算术和逻辑操作,里面有读取和写入数据的寄存器的地址。如果是逻辑位移操作,后面还有位移操作的位移量,而最后的功能码,则是在前面的操作码不够的时候,扩展操作码表示对应的具体指令的。
I 指令,则通常是用在数据传输、条件分支,以及在运算的时候使用的并非变量还是常数的时候。这个时候,没有了位移量和操作码,也没有了第三个寄存器,而是把这三部分直接合并成了一个地址值或者一个常数。
J 指令就是一个跳转指令,高 6 位之外的 26 位都是一个跳转后的地址。
add $t0,$s2,$s1
以一个简单的加法算术指令add $t0,$s1,$s2,为例,进行举例。方便起见用十进制表示代码
对应的 MIPS 指令里 opcode 是 0,rs 代表第一个寄存器 s1 的地址是 17,rt 代表第二个寄存器 s2 的地址是 18,rd 代表目标的临时寄存器 t0 的地址,是 8。因为不是位移操作,所以位移量是 0。把这些数字拼在一起,就变成了一个 MIPS 的加法指令。
又为了读起来方便,一般将对应的二进制数,用16进制表示出来,也就是0X02324020,这个数字也就是这条指令对应的机器码。
用打孔卡表示,即用二级制表示16进制中的每一位数字,如下图
参考资料
《计算机组成与设计:软 / 硬件接口》第 5 版的 2.17小节。