You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

171 lines
13 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 05 | 计算机指令:让我们试试用纸带编程
你在学写程序的时候,有没有想过,古老年代的计算机程序是怎么写出来的?
上大学的时候我们系里教C语言程序设计的老师说他们当年学写程序的时候不像现在这样都是用一种古老的物理设备叫作“打孔卡Punched Card”。用这种设备写程序可没法像今天这样掏出键盘就能打字而是要先在脑海里或者在纸上写出程序然后在纸带或者卡片上打洞。这样要写的程序、要处理的数据就变成一条条纸带或者一张张卡片之后再交给当时的计算机去处理。
![](https://static001.geekbang.org/resource/image/5d/d7/5d407c051e261902ad9a216c66de3fd7.jpg "上世纪60年代晚期或70年代初期Arnold Reinold拍摄的FORTRAN计算程序的穿孔卡照片")
你看这个穿孔纸带是不是有点儿像我们现在考试用的答题卡那个时候人们在特定的位置上打洞或者不打洞来代表“0”或者“1”。
为什么早期的计算机程序要使用打孔卡而不能像我们现在一样用C或者Python这样的高级语言来写呢原因很简单因为计算机或者说CPU本身并没有能力理解这些高级语言。即使在2019年的今天我们使用的现代个人计算机仍然只能处理所谓的“机器码”也就是一连串的“0”和“1”这样的数字。
那么我们每天用高级语言的程序最终是怎么变成一串串“0”和“1”的这一串串“0”和“1”又是怎么在CPU中处理的今天我们就来仔细介绍一下“机器码”和“计算机指令”到底是怎么回事。
## 在软硬件接口中CPU帮我们做了什么事
我们常说CPU就是计算机的大脑。CPU的全称是Central Processing Unit中文是中央处理器。
我们上一节说了,从**硬件**的角度来看CPU就是一个超大规模集成电路通过电路实现了加法、乘法乃至各种各样的处理逻辑。
如果我们从**软件**工程师的角度来讲CPU就是一个执行各种**计算机指令**Instruction Code的逻辑机器。这里的计算机指令就好比一门CPU能够听得懂的语言我们也可以把它叫作**机器语言**Machine Language
不同的CPU能够听懂的语言不太一样。比如我们的个人电脑用的是Intel的CPU苹果手机用的是ARM的CPU。这两者能听懂的语言就不太一样。类似这样两种CPU各自支持的语言就是两组不同的**计算机指令集**英文叫Instruction Set。这里面的“Set”其实就是数学上的集合代表不同的单词、语法。
所以如果我们在自己电脑上写一个程序然后把这个程序复制一下装到自己的手机上肯定是没办法正常运行的因为这两者语言不通。而一台电脑上的程序简单复制一下到另外一台电脑上通常就能正常运行因为这两台CPU有着相同的指令集也就是说它们的语言相通的。
一个计算机程序不可能只有一条指令而是由成千上万条指令组成的。但是CPU里不能一直放着所有指令所以计算机程序平时是存储在存储器中的。这种程序指令存储在存储器里面的计算机我们就叫作**存储程序型计算机**Stored-program Computer
说到这里你可能要问了难道还有不是存储程序型的计算机么其实在没有现代计算机之前有着聪明才智的工程师们早就发明了一种叫Plugboard Computer的计算设备。我把它直译成“插线板计算机”。在一个布满了各种插口和插座的板子上工程师们用不同的电线来连接不同的插口和插座从而来完成各种计算任务。下面这个图就是一台IBM的Plugboard看起来是不是有一股满满的蒸汽朋克范儿
![](https://static001.geekbang.org/resource/image/99/51/99eb1ab1cdbdfa2d35fce456940ca651.jpg "一台IBM的Plugboard")
## 从编译到汇编,代码怎么变成机器码?
了解了计算机指令和计算机指令集接下来我们来看看平时编写的代码到底是怎么变成一条条计算机指令最后被CPU执行的呢我们拿一小段真实的C语言程序来看看。
```
// test.c
int main()
{
int a = 1;
int b = 2;
a = a + b;
}
```
这是一段再简单不过的C语言程序即便你不了解C语言应该也可以看懂。我们给两个变量 a、b分别赋值1、2然后再将a、b两个变量中的值加在一起重新赋值给了a这个变量。
要让这段程序在一个Linux操作系统上跑起来我们需要把整个程序翻译成一个**汇编语言**ASMAssembly Language的程序这个过程我们一般叫编译Compile成汇编代码。
针对汇编代码我们可以再用汇编器Assembler翻译成机器码Machine Code。这些机器码由“0”和“1”组成的机器语言表示。这一条条机器码就是一条条的**计算机指令**。这样一串串的16进制数字就是我们CPU能够真正认识的计算机指令。
在一个Linux操作系统上我们可以简单地使用gcc和objdump这样两条命令把对应的汇编代码和机器码都打印出来。
```
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o
```
可以看到左侧有一堆数字这些就是一条条机器码右边有一系列的push、mov、add、pop等这些就是对应的汇编代码。一行C语言代码有时候只对应一条机器码和汇编代码有时候则是对应两条机器码和汇编代码。汇编代码和机器码之间是一一对应的。
```
test.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
int main()
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = a + b;
12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
15: 01 45 fc add DWORD PTR [rbp-0x4],eax
}
18: 5d pop rbp
19: c3 ret
```
这个时候你可能又要问了我们实际在用GCCGUC编译器套装GNU Compiler Collectipon编译器的时候可以直接把代码编译成机器码呀为什么还需要汇编代码呢原因很简单你看着那一串数字表示的机器码是不是摸不着头脑但是即使你没有学过汇编代码看的时候多少也能“猜”出一些这些代码的含义。
因为汇编代码其实就是“给程序员看的机器码”也正因为这样机器码和汇编代码是一一对应的。我们人类很容易记住add、mov这些用英文表示的指令而8b 45 f8这样的指令由于很难一下子看明白是在干什么所以会非常难以记忆。尽管早年互联网上到处流传大神程序员着拿小刀在光盘上刻出操作系统的梗但是要让你用打孔卡来写个程序估计浪费的卡片比用上的卡片要多得多。
![](https://static001.geekbang.org/resource/image/67/5b/67cf3c90ac9bde229352e1be0db24b5b.png)
从高级语言到汇编代码再到机器码就是一个日常开发程序最终变成了CPU可以执行的计算机指令的过程。
## 解析指令和机器码
了解了这个过程,下面我们放大局部,来看看这一行行的汇编代码和机器指令,到底是什么意思。
我们就从平时用的电脑、手机这些设备来说起。这些设备的CPU到底有哪些指令呢这个还真有不少我们日常用的Intel CPU有2000条左右的CPU指令实在是太多了所以我没法一一来给你讲解。不过一般来说常见的指令可以分成五大类。
第一类是**算术类指令**。我们的加减乘除在CPU层面都会变成一条条算术类指令。
第二类是**数据传输类指令**。给变量赋值、在内存里读写数据,用的都是数据传输类指令。
第三类是**逻辑类指令**。逻辑上的与或非,都是这一类指令。
第四类是**条件分支类指令**。日常我们写的“if/else”其实都是条件分支类指令。
最后一类是**无条件跳转指令**。写一些大一点的程序,我们常常需要写一些函数或者方法。在调用函数的时候,其实就是发起了一个无条件跳转指令。
你可能一下子记不住,或者对这些指令的含义还不能一下子掌握,这里我画了一个表格,给你举例子说明一下,帮你理解、记忆。
![](https://static001.geekbang.org/resource/image/eb/97/ebfd3bfe5dba764cdcf871e23b29f197.jpeg)
下面我们来看看,汇编器是怎么把对应的汇编代码,翻译成为机器码的。
我们说过不同的CPU有不同的指令集也就对应着不同的汇编语言和不同的机器码。为了方便你快速理解这个机器码的计算方式我们选用最简单的MIPS指令集来看看机器码是如何生成的。
MIPS是一组由MIPS技术公司在80年代中期设计出来的CPU指令集。就在最近MIPS公司把整个指令集和芯片架构都完全开源了。想要深入研究CPU和指令集的同学我这里推荐[一些资料](https://www.mips.com/mipsopen/),你可以自己了解下。
![](https://static001.geekbang.org/resource/image/b1/bf/b1ade5f8de67b172bf7b4ec9f63589bf.jpeg)
MIPS的指令是一个32位的整数高6位叫**操作码**Opcode也就是代表这条指令具体是一条什么样的指令剩下的26位有三种格式分别是R、I和J。
**R指令**是一般用来做算术和逻辑操作,里面有读取和写入数据的寄存器的地址。如果是逻辑位移操作,后面还有位移操作的位移量,而最后的功能码,则是在前面的操作码不够的时候,扩展操作码表示对应的具体指令的。
**I指令**,则通常是用在数据传输、条件分支,以及在运算的时候使用的并非变量还是常数的时候。这个时候,没有了位移量和操作码,也没有了第三个寄存器,而是把这三部分直接合并成了一个地址值或者一个常数。
**J指令**就是一个跳转指令高6位之外的26位都是一个跳转后的地址。
```
add $t0,$s2,$s1
```
我以一个简单的加法算术指令add $t0, $s1, $s2,为例,给你解释。为了方便,我们下面都用十进制来表示对应的代码。
对应的MIPS指令里opcode是0rs代表第一个寄存器s1的地址是17rt代表第二个寄存器s2的地址是18rd代表目标的临时寄存器t0的地址是8。因为不是位移操作所以位移量是0。把这些数字拼在一起就变成了一个MIPS的加法指令。
为了读起来方便我们一般把对应的二进制数用16进制表示出来。在这里也就是0X02324020。这个数字也就是这条指令对应的机器码。
![](https://static001.geekbang.org/resource/image/8f/1d/8fced6ff11d3405cdf941f6742b5081d.jpeg)
回到开头我们说的打孔带。如果我们用打孔代表1没有打孔代表0用4行8列代表一条指令来打一个穿孔纸带那么这条命令大概就长这样
![](https://static001.geekbang.org/resource/image/1e/7c/1e5ecb8c92b01defee1c2af8c864887c.png?wh=781x511)
好了,恭喜你,读到这里,你应该学会了怎么作为人肉编译和汇编器,给纸带打孔编程了,不用再对那些用过打孔卡的前辈们顶礼膜拜了。
## 总结延伸
到这里,想必你也应该明白了,我们在这一讲的开头介绍的打孔卡,其实就是一种存储程序型计算机。
只是这整个程序的机器码不是通过计算机编译出来的而是由程序员用人脑“编译”成一张张卡片的。对应的程序也不是存储在设备里而是存储成一张打好孔的卡片。但是整个程序运行的逻辑和其他CPU的机器语言没有什么分别也是处理一串“0”和“1”组成的机器码而已。
这一讲里我们看到了一个C语言程序是怎么被编译成为汇编语言乃至通过汇编器再翻译成机器码的。
除了C这样的编译型的语言之外不管是Python这样的解释型语言还是Java这样使用虚拟机的语言其实最终都是由不同形式的程序把我们写好的代码转换成CPU能够理解的机器码来执行的。
只是解释型语言是通过解释器在程序运行的时候逐句翻译而Java这样使用虚拟机的语言则是由虚拟机对编译出来的中间代码进行解释或者即时编译成为机器码来最终执行。
然而单单理解一条指令是怎么变成机器码的肯定是不够的。接下来的几节我会深入讲解包含条件、循环、函数、递归这些语句的完整程序是怎么在CPU里面执行的。
## 推荐阅读
这一讲里我们用的是相对最简单的MIPS指令集作示例。想要对我们日常使用的Intel CPU的指令集有所了解可以参看《计算机组成与设计软/硬件接口》第5版的2.17小节。
## 课后思考
我们把一个数字在命令行里面打印出来背后对应的机器码是什么你可以试试通过GCC把这个的汇编代码和机器码打出来。
欢迎你在留言区写下你的思考和疑问,你也可以把今天的文章分享给你朋友,和他一起学习和进步。