05 | 计算机指令:让我们试试用纸带编程

你在学写程序的时候,有没有想过,古老年代的计算机程序是怎么写出来的?

上大学的时候,我们系里教C语言程序设计的老师说,他们当年学写程序的时候,不像现在这样,都是用一种古老的物理设备,叫作“打孔卡(Punched Card)”。用这种设备写程序,可没法像今天这样,掏出键盘就能打字,而是要先在脑海里或者在纸上写出程序,然后在纸带或者卡片上打洞。这样,要写的程序、要处理的数据,就变成一条条纸带或者一张张卡片,之后再交给当时的计算机去处理。

你看这个穿孔纸带是不是有点儿像我们现在考试用的答题卡?那个时候,人们在特定的位置上打洞或者不打洞,来代表“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,看起来是不是有一股满满的蒸汽朋克范儿?

从编译到汇编,代码怎么变成机器码?

了解了计算机指令和计算机指令集,接下来我们来看看,平时编写的代码,到底是怎么变成一条条计算机指令,最后被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操作系统上跑起来,我们需要把整个程序翻译成一个汇编语言(ASM,Assembly 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    

这个时候你可能又要问了,我们实际在用GCC(GUC编译器套装,GNU Compiler Collectipon)编译器的时候,可以直接把代码编译成机器码呀,为什么还需要汇编代码呢?原因很简单,你看着那一串数字表示的机器码,是不是摸不着头脑?但是即使你没有学过汇编代码,看的时候多少也能“猜”出一些这些代码的含义。

因为汇编代码其实就是“给程序员看的机器码”,也正因为这样,机器码和汇编代码是一一对应的。我们人类很容易记住add、mov这些用英文表示的指令,而8b 45 f8这样的指令,由于很难一下子看明白是在干什么,所以会非常难以记忆。尽管早年互联网上到处流传,大神程序员着拿小刀在光盘上刻出操作系统的梗,但是要让你用打孔卡来写个程序,估计浪费的卡片比用上的卡片要多得多。

从高级语言到汇编代码,再到机器码,就是一个日常开发程序,最终变成了CPU可以执行的计算机指令的过程。

解析指令和机器码

了解了这个过程,下面我们放大局部,来看看这一行行的汇编代码和机器指令,到底是什么意思。

我们就从平时用的电脑、手机这些设备来说起。这些设备的CPU到底有哪些指令呢?这个还真有不少,我们日常用的Intel CPU,有2000条左右的CPU指令,实在是太多了,所以我没法一一来给你讲解。不过一般来说,常见的指令可以分成五大类。

第一类是算术类指令。我们的加减乘除,在CPU层面,都会变成一条条算术类指令。

第二类是数据传输类指令。给变量赋值、在内存里读写数据,用的都是数据传输类指令。

第三类是逻辑类指令。逻辑上的与或非,都是这一类指令。

第四类是条件分支类指令。日常我们写的“if/else”,其实都是条件分支类指令。

最后一类是无条件跳转指令。写一些大一点的程序,我们常常需要写一些函数或者方法。在调用函数的时候,其实就是发起了一个无条件跳转指令。

你可能一下子记不住,或者对这些指令的含义还不能一下子掌握,这里我画了一个表格,给你举例子说明一下,帮你理解、记忆。

下面我们来看看,汇编器是怎么把对应的汇编代码,翻译成为机器码的。

我们说过,不同的CPU有不同的指令集,也就对应着不同的汇编语言和不同的机器码。为了方便你快速理解这个机器码的计算方式,我们选用最简单的MIPS指令集,来看看机器码是如何生成的。

MIPS是一组由MIPS技术公司在80年代中期设计出来的CPU指令集。就在最近,MIPS公司把整个指令集和芯片架构都完全开源了。想要深入研究CPU和指令集的同学,我这里推荐一些资料,你可以自己了解下。

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。这个数字也就是这条指令对应的机器码。

回到开头我们说的打孔带。如果我们用打孔代表1,没有打孔代表0,用4行8列代表一条指令来打一个穿孔纸带,那么这条命令大概就长这样:

好了,恭喜你,读到这里,你应该学会了怎么作为人肉编译和汇编器,给纸带打孔编程了,不用再对那些用过打孔卡的前辈们顶礼膜拜了。

总结延伸

到这里,想必你也应该明白了,我们在这一讲的开头介绍的打孔卡,其实就是一种存储程序型计算机。

只是这整个程序的机器码,不是通过计算机编译出来的,而是由程序员,用人脑“编译”成一张张卡片的。对应的程序,也不是存储在设备里,而是存储成一张打好孔的卡片。但是整个程序运行的逻辑和其他CPU的机器语言没有什么分别,也是处理一串“0”和“1”组成的机器码而已。

这一讲里,我们看到了一个C语言程序,是怎么被编译成为汇编语言,乃至通过汇编器再翻译成机器码的。

除了C这样的编译型的语言之外,不管是Python这样的解释型语言,还是Java这样使用虚拟机的语言,其实最终都是由不同形式的程序,把我们写好的代码,转换成CPU能够理解的机器码来执行的。

只是解释型语言,是通过解释器在程序运行的时候逐句翻译,而Java这样使用虚拟机的语言,则是由虚拟机对编译出来的中间代码进行解释,或者即时编译成为机器码来最终执行。

然而,单单理解一条指令是怎么变成机器码的肯定是不够的。接下来的几节,我会深入讲解,包含条件、循环、函数、递归这些语句的完整程序,是怎么在CPU里面执行的。

推荐阅读

这一讲里,我们用的是相对最简单的MIPS指令集作示例。想要对我们日常使用的Intel CPU的指令集有所了解,可以参看《计算机组成与设计:软/硬件接口》第5版的2.17小节。

课后思考

我们把一个数字在命令行里面打印出来,背后对应的机器码是什么?你可以试试通过GCC把这个的汇编代码和机器码打出来。

欢迎你在留言区写下你的思考和疑问,你也可以把今天的文章分享给你朋友,和他一起学习和进步。

精选留言

  • ack

    2019-05-29 21:08:42

    opcode(6位)
    000000 10001 10010 01000 00000 100000
    =0000 0010 0011 0010 0100 0000 0010 0000(对应纵向打孔带)
    =0X02324020
  • lzhao

    2019-05-03 06:13:12

    机器码不是二进制吗?为什么gcc把汇编编译成16进制?
    作者回复

    l'hao同学你好,是二进制,16进制只是为了显示方便,毕竟一串0和1在显示上太没有效率了

    2019-05-03 08:00:40

  • 神奇小懒懒

    2019-05-22 10:51:33

    gjw@gjw:~/csapp/000$ cat simplest.c
    int main(){
    int a=1,b=20;
    int c=a+b;
    }
    生成的汇编及其具体解释:
    push %rbp 压栈 ,基址指针
    mov %rsp,%rbp 将堆栈寄存器内容移动到基址寄存器
    movl $0x1,-0xc(%rbp) 将栈基地址偏移12字节设为1,对应变量a=1
    movl $0x14,-0x8(%rbp) 将栈基地址偏移8个字节设为20(16进制14=10进制20),变量b=20
    mov -0xc(%rbp),%edx 将栈基地址偏移12字节的数据移动到edx寄存器
    mov -0x8(%rbp),%eax 将栈基地址偏移8个字节的数据移动到eax寄存器
    add %edx,%eax edx 数据和eax数据相加,结果保存到eax寄存器
    mov $0x0,%eax 重置eax寄存器
    pop %rbp 弹出栈数据
    retq main函数返回并退出
  • 周曙光爱学习

    2019-05-09 00:10:49

    指令和机器码的关系是?可以理解为cpu指令类似数学公式,我们写的程序在套用这些公式,然后公式+参数生成机器码?
    作者回复

    👍,周曙光爱学习同学你好,这个理解和比喻很形象

    2019-05-10 15:36:28

  • coder

    2019-05-05 20:08:47

    老师您好,说到指令,让我想起来困惑我本科时期很久的一个问题:

    用C或者其他的高级语言可以实现更多的语言,那么这么一直追问下去,就有个类似蛋生鸡的问题,第一个编程语言是怎么来的?

    编程语言一般会有"自举"的功能,那么自举是怎么实现的?比如说Go语言就是自己实现自己,那么在没有Go编译器的基础上,怎么做到自己编译自己的?
    作者回复

    json同学你好,第一台计算机ENIAC,如果你去计算机历史博物馆看一下真机就会明白,他的各种输入都是一些旋钮,可以认为是类似用机器码在编程,后来才有了汇编、C这样越来越高级的语言。

    编程语言是自举的,并不需要第一个编译器就是用自己这个语言来写的。通常是先有了别的语言写好的编译器,然后再用自己来写自己语言的编译器。

    更详细的关于鸡蛋问题,可以直接看Wikipedia上,讲了多种这个问题的解决方案
    https://en.wikipedia.org/wiki/Bootstrapping_(compilers)

    2019-05-06 13:39:51

  • 2019-05-04 15:28:08

    我们说过,不同的 CPU 有不同的指令集,也就对应着不同的汇编语言和机器码 这句话中,在不同的指令集中 汇编语言和机器码 的关系是怎么对应的呢? 还有一个问题就是在高级语言转换为机器码的时候 是不是要读取CPU的具体的型号呢? 然后在转换为 对应CPU型号的机器码。 如果是,那么物理机器是不是要维护一个很大的对应关系表???

    作者回复

    一步同学你好,

    不同指令集里,对应的汇编代码会对应这个指令集的机器码呀。

    大家不要把“汇编语言”当成是像C一样的一门统一编程语言。

    “汇编语言”其实可以理解成“机器码”的一种别名或者书写方式,不同的指令集和体系结构的机器会有不同的“机器码”

    高级语言在转换成为机器码的时候,是通过编译器进行的,需要编译器指定编译成哪种汇编/机器码。

    物理机自己执行的时候只有机器码,并不认识汇编代码。

    编译器如果支持编译成不同的体系结构的汇编/机器码,就要维护很多不同的对应关系表,但是这个表并不会太大。以最复杂的Intel X86的指令集为例,也只有2000条不同的指令而已。

    2019-05-06 13:57:45

  • 二星球

    2019-05-04 06:33:54

    老师您好,我想问一个问题,C语言>汇编语言>机器语言 一般是这样的编译顺序,为什么不是 C语言>机器语言 一步到位这样编译呢?
    作者回复

    杨怀同学你好,其实有一步到位的,就是两个步骤都通过一个命令先后执行,顺序完成,gcc现在就可以一个命令直接变成可执行的binary。

    只是为了方便debug,你可以认为通过机器语言我们也可以反推出汇编语言长什么样子。

    2019-05-06 14:13:34

  • 码农桃花源

    2019-05-12 08:21:58

    老师请教个问题,

    像python这种解释型的语言,需要一行行地编译,那它就不能综合所有的源文件来考虑了,像什么重定向这些怎么实现?或者跳转到某个地址,怎么实现,我担心的是一行一行的解释,看不到程序的全貌,怎么知道我要跳到哪个地址?

    谢谢!
  • Ant

    2019-05-10 11:34:07

    最后的卡片 02324020 ,为啥不是从最底下或最上面开始, 为啥3 打了俩孔, 为啥4 又 往上了一行
    作者回复

    顺序是根据用什么样的机器读取来的,我们这里假设就是从左往右一列一列读取的。

    一列卡片代表一个数,白色代表没有打孔,黑色代表打孔。3换算成二进制就是 0011,也就是上面两个不打孔,下面两个打孔,4就换算成 0100,也就是第二个打孔,其他的不打孔

    2019-05-10 14:32:45

  • 梨子🍐

    2019-05-03 01:50:53

    没有理解 `0X02324020` 是如何计算出来的?
    作者回复

    梨子同学你好,你把把上一行的二进制,四位四位一读,每四位当成是一个16进制数,就会得到这个结果

    2019-05-03 04:15:20

  • ginger

    2019-05-24 09:05:29

    看到指令,联想到上一讲的cpu性能和指令数的关系,这里想提问下:
    是否可以通过将指令更加细分(功能上的细分,比如指令ab完成a+b,指令abc完成a+b+c)
    来实现一个高级语言本身需要对应到10条指令时候,变成了只需要对应7条指令的效果,

    我想这个一定是可行的,但应该没什么意义,因为cpu的指令集,发展了这么多年了,应该也是没有优化的空间了吧.
    作者回复

    可以的,这个就是历史上的CISC和RISC的争论。

    其实指令集都在不断更新微调。而体系结构最近RISC-V又火起来了。

    因为纯粹靠提升频率硬件的方法已经没有什么空间了,所以其实又进入了优化指令集乃至整个体系结构的阶段了。

    2019-05-29 18:35:13

  • 寇云

    2019-05-20 21:06:57

    有幸去了山景城的计算机历史博物馆,对老师讲的课程非常有感觉
    作者回复

    👍,我也去了,那个地方超棒,我决定明年再去呆一天。

    2019-05-29 18:24:23

  • 冰激凌的眼泪

    2019-05-12 10:43:24

    打孔卡要纵向读,建议Opcode补足6个0,这样好和打孔卡对照
    汇编是不是可以看做机器码的助记符?
  • Nevermore

    2019-05-12 07:57:22

    老师shell脚本是一步编译成机器码的吗?还是转换成汇编再编译成机器码
    作者回复

    如果在这个映射关系里面,可以认为shell脚本是被bash解释器来运行的,调用对应的编译好的二进制可执行文件相当于bash解释器在做对应的“翻译”动作

    2019-05-13 19:38:40

  • Kelly.W

    2019-05-04 21:35:44

    老师您好,课程里说汇编代码和机器码是一一对应的,我现在知道在不同平台的机器码是不一样的(linux/windows),那么不同平台也有不同的高级语言->汇编代码的对应规则吗?
    作者回复

    机器码的差异不在于操作系统(也就是不在于Linux/Windows)。而是在于体系结构(Intel X86/ARM/MIPS)。
    不同平台的汇编语言也是不同的,所以同样的高级语言编译器在不同的平台上编译出来的代码也是不一样的。

    2019-05-06 13:50:41

  • Sharongo

    2019-05-20 12:58:37

    计算机小白想问一个很蠢的问题:为什么CPU不能放着所有指令,为什么要区分cpu和内存以及其它硬件不可以把功能都放在一个器件上吗?
    作者回复

    Sharongo同学你好,

    因为放不下啊,也放不起啊,而且这样怎么做到前面讲的“可编程”和“可存储”呢?

    2019-05-29 19:14:35

  • imicode

    2019-05-27 08:44:36

    学习打卡:
    1. CPU就是一个执行各种计算机指令的逻辑机器。这里的计算机指令就是CPU能懂的机器语言。不同体系结构的CPU支持不同的计算机指令集。
    2. 汇编语言是一种易读和易写的机器代码,可以理解成“机器码”的一种别名或者书写方式。
    3. 常见的五大类指令:算术类指令、数据传输类指令、逻辑类指令、条件分支类指令、无条件跳转类指令。
  • 发条橙子 。

    2019-05-04 09:21:02

    刷了两遍大致清晰了 ,又学到不少知识 , 看完感觉从高级语言-到汇编-机器码的转换实际上不是很复杂 ,重要的是刻纸袋是个体力活 。

    老师, 这边还有两个细节点想请教一下:
    1. 前面的文章讲冯诺依曼体系结构的时候说到 。 运算器、控制器、存储器、输入输出设备 , 这里将Cpu指令集存储在存储器 , 这里的存储器指的是硬盘么 ?
    2. 汇编语言和Cpu指令集先后的问题 , 老师上面 add 的例子是从 汇编 推导出 MIPS 对应的二进制 Cpu指令 。 按照历史发展 ,是MIPS公司先推出了一款自己的CPU指令集, 然后对应的汇编语言厂商在按照这个指令集开发相应的汇编语言么 ?

    希望老师能指点一二 ,另外老师五一节快乐 (虽然快过完了)
    作者回复

    发条橙子同学你好,也希望你过了一个愉快的五一啊。

    CPU的“指令集”是一个抽象的概念,你可以认为实际是被变成了CPU的电路。

    我们具体的一个个程序的指令,是存在内存里的,或者说,是程序用一个文件格式存在磁盘上,然后通过装载器加载到内存里面,再一条条加载到指令寄存器里面,然后执行的。

    关于汇编语言,你可以这样认为,不过厂商一般会直接提供对应的汇编语言和汇编器,但是也的确有支持多种指令集的第三方汇编语言和汇编器。

    2019-05-06 14:11:23

  • 连边

    2019-06-15 19:33:48

    老师你好,通篇看下来,有一个地方没有怎么懂得。
    “对应的 MIPS 指令里 opcode 是 0,rs 代表第一个寄存器s1的地址是17,rt代表第二个寄存器s2的地址是18,rd代表目标的临时寄存器t0的地址,是8.”
  • 子杨

    2019-05-18 18:18:57

    徐老师,每一条计算机指令由 CPU 执行的时候,实际上是不是都是电路的连通或关闭?
    作者回复

    是啊,我们在讲解CPU的时候,你可以从电路的角度来理解指令是怎么在硬件层面执行的。

    2019-05-29 20:28:07