07.计算机指令编程的原理
- 01.指令编程的历史
- 1.1 来看一个案例
- 1.2 高级语言和机器码
- 1.3 编译解释过程
- 02.从编译到汇编
- 2.1 代码案例分析
- 2.2 打印程序汇编代码
- 2.3 解析指令
- 2.4 如何翻译成机器码
- 03.CPU执行指令
- 3.1 思考一个问题
- 3.2 CPU如何执行指令
- 3.3 看if-else执行和跳转
- 3.4 程序控制流程
01.指令编程的历史
1.1 来看一个案例
- 一种古老的物理设备,叫作“打孔卡(Punched Card)”。
- 用这种设备写程序,可没法像今天这样,掏出键盘就能打字,而是要先在脑海里或者在纸上写出程序,然后在纸带或者卡片上打洞。
- 这样,要写的程序、要处理的数据,就变成一条条纸带或者一张张卡片,之后再交给当时的计算机去处理。
- 人们在特定的位置上打洞或者不打洞,来代表“0”或者“1”。
- 为什么早期的计算机程序要使用打孔卡,而不能像我们现在一样,用 C 或者 Python 这样的高级语言来写呢?
- 原因很简单,因为计算机或者说 CPU 本身,并没有能力理解这些高级语言。即使在 2019 年的今天,我们使用的现代个人计算机,仍然只能处理所谓的“机器码”,也就是一连串的“0”和“1”这样的数字。
1.2 高级语言和机器码
- 我们每天用高级语言的程序,最终是怎么变成一串串“0”和“1”的?
- 这一串串“0”和“1”又是怎么在 CPU 中处理的?今天,我们就来仔细介绍一下,“机器码”和“计算机指令”到底是怎么回事。
- 在软硬件接口中,CPU 帮我们做了什么事?
- 从硬件的角度来看,CPU 就是一个超大规模集成电路,通过电路实现了加法、乘法乃至各种各样的处理逻辑。
- 从软件工程师的角度来讲,CPU 就是一个执行各种计算机指令(Instruction Code)的逻辑机器。这里的计算机指令,就好比一门 CPU 能够听得懂的语言,我们也可以把它叫作机器语言(Machine Language)。
- 不同的 CPU 能够听懂的语言不太一样。
- 比如,我们的个人电脑用的是 Intel 的 CPU,苹果手机用的是 ARM 的 CPU。这两者能听懂的语言就不太一样。
- 类似这样两种 CPU 各自支持的语言,就是两组不同的计算机指令集,英文叫 Instruction Set。
- 如果我们在自己电脑上写一个程序,然后把这个程序复制一下,装到自己的手机上,肯定是没办法正常运行的,因为这两者语言不通。
- 而一台电脑上的程序,简单复制一下到另外一台电脑上,通常就能正常运行,因为这两台 CPU 有着相同的指令集,也就是说,它们的语言相通的。
1.3 编译解释过程
- 高级语言和机器码之间存在一个编译或解释的过程。
- 高级语言的代码需要通过编译器或解释器将其转换为机器码,以便计算机可以执行。
- 编译器将高级语言代码转换为机器码的可执行文件,而解释器则逐行解释高级语言代码并将其转换为机器码执行。
- 大多数开发人员使用高级语言来编写程序
- 机器码通常由编译器或解释器生成和执行。高级语言提供了更高层次的抽象和易用性,使得编程更加方便和可靠。
02.从编译到汇编
2.1 代码案例分析
- 了解了计算机指令和计算机指令集,接下来我们来看看,平时编写的代码,到底是怎么变成一条条计算机指令,最后被 CPU 执行的呢?我们拿一小段真实的 C 语言程序来看看。
//test.c int main(){ int a = 1; int b = 2; a = a + b; return 0; }
- 这是一段再简单不过的 C 语言程序。
- 我们给两个变量 a、b 分别赋值 1、2,然后再将 a、b 两个变量中的值加在一起,重新赋值给了 a 这个变量。
- 要让这段程序在一个 Linux 操作系统上跑起来,需要把整个程序翻译成一个汇编语言(ASM,Assembly Language)的程序,这个过程我们一般叫编译(Compile)成汇编代码。
- 针对汇编代码,可以再用汇编器(Assembler)翻译成机器码(Machine Code)。这些机器码由“0”和“1”组成的机器语言表示。这一条条机器码,就是一条条的计算机指令。这样一串串的 16 进制数字,就是我们 CPU 能够真正认识的计算机指令。
2.2 打印程序汇编代码
- 在一个 Linux 操作系统上,我们可以简单地使用 gcc 和 objdump 这样两条命令,把对应的汇编代码和机器码都打印出来。
yangchongdeMBP:test yangchong$ gcc -g -c test.c yangchongdeMBP:test yangchong$ 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 编译器的时候,可以直接把代码编译成机器码呀,为什么还需要汇编代码呢?
- 原因很简单,你看着那一串数字表示的机器码,是不是摸不着头脑?但是即使你没有学过汇编代码,看的时候多少也能“猜”出一些这些代码的含义。
- 因为汇编代码其实就是“给程序员看的机器码”,也正因为这样,机器码和汇编代码是一一对应的。
- 我们人类很容易记住 add、mov 这些用英文表示的指令,而 8b 45 f8 这样的指令,由于很难一下子看明白是在干什么,所以会非常难以记忆。
image
2.3 解析指令
- 了解了这个过程,下面我们放大局部,来看看这一行行的汇编代码和机器指令,到底是什么意思。
- 我们就从平时用的电脑、手机这些设备来说起。这些设备的 CPU 到底有哪些指令呢?这个还真有不少,我们日常用的 Intel CPU,有 2000 条左右的 CPU 指令,实在是太多了,所以我没法一一来给你讲解。
- 不过一般来说,常见的指令可以分成五大类。
- 第一类是算术类指令。我们的加减乘除,在 CPU 层面,都会变成一条条算术类指令。
- 第二类是数据传输类指令。给变量赋值、在内存里读写数据,用的都是数据传输类指令。
- 第三类是逻辑类指令。逻辑上的与或非,都是这一类指令。
- 第四类是条件分支类指令。日常我们写的“if/else”,其实都是条件分支类指令。
- 最后一类是无条件跳转指令。写一些大一点的程序,我们常常需要写一些函数或者方法。在调用函数的时候,其实就是发起了一个无条件跳转指令。
- 举例子说明一下,帮助理解、记忆。
image
2.4 如何翻译成机器码
- 下面我们来看看,汇编器是怎么把对应的汇编代码,翻译成为机器码的。
- 不同的 CPU 有不同的指令集,也就对应着不同的汇编语言和不同的机器码。为了方便你快速理解这个机器码的计算方式,我们选用最简单的 MIPS 指令集,来看看机器码是如何生成的。
- MIPS 是一组由 MIPS 技术公司在 80 年代中期设计出来的 CPU 指令集。
- MIPS 的指令是一个 32 位的整数,高 6 位叫操作码(Opcode),也就是代表这条指令具体是一条什么样的指令,剩下的 26 位有三种格式,分别是 R、I 和 J。
- R 指令是一般用来做算术和逻辑操作,里面有读取和写入数据的寄存器的地址。如果是逻辑位移操作,后面还有位移操作的位移量,而最后的功能码,则是在前面的操作码不够的时候,扩展操作码表示对应的具体指令的。
- I 指令,则通常是用在数据传输、条件分支,以及在运算的时候使用的并非变量还是常数的时候。这个时候,没有了位移量和操作码,也没有了第三个寄存器,而是把这三部分直接合并成了一个地址值或者一个常数。
- J 指令就是一个跳转指令,高 6 位之外的 26 位都是一个跳转后的地址。
03.CPU执行指令
3.1 思考一个问题
- 思考一个问题,一行代码是怎么变成计算机指令的。
- 你平时写的程序中,肯定不只有 int a = 1 这样最最简单的代码或者指令。我们总是要用到 if…else 这样的条件判断语句、while 和 for 这样的循环语句,还有函数或者过程调用。
- 对应的,CPU 执行的也不只是一条指令,一般一个程序包含很多条指令。因为有 if…else、for 这样的条件和循环存在,这些指令也不会一路平铺直叙地执行下去。
3.2 CPU如何执行指令
- CPU 是如何执行指令的?
- 实际上,一条条计算机指令执行起来非常复杂。好在 CPU 在软件层面已经为我们做好了封装。对于软件的程序员来说,我们只要知道,写好的代码变成了指令之后,是一条一条顺序执行的就可以了。
- CPU 其实就是由一堆寄存器组成的。
- 寄存器就是 CPU 内部,由多个触发器(Flip-Flop)或者锁存器(Latches)组成的简单电路。触发器和锁存器,其实就是两种不同原理的数字电路组成的逻辑门。
- 64位寄存器是怎么来的
- N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据。比方说,我们用的 64 位 Intel 服务器,寄存器就是 64 位的。
- 一个 CPU 里面会有很多种不同功能的寄存器。介绍三种比较特殊的。
7_2_4_image.png - 第一个是 PC 寄存器(Program Counter Register),我们也叫指令地址寄存器。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址。
- 第二个是指令寄存器(Instruction Register),用来存放当前正在执行的指令。
- 第三个是条件码寄存器(Status Register),用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。
- 除了这些特殊的寄存器,CPU 里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。我们通常根据存放的数据内容来给它们取名字,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。
7_2_5_image
- 那么现在看一下CPU如何执行程序指令
- 一个程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。
- 可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。
- 什么是跳转指令,如何理解?
- 有些特殊指令,比如跳转指令,会修改 PC 寄存器里面的地址值。这样,下一条要执行的指令就不是从内存里面顺序加载的了。事实上,这些跳转指令的存在,也是我们可以在写程序的时候,使用 if…else 条件语句和 while/for 循环语句的原因。
3.3 看if-else执行和跳转
- 现在就来看一个包含 if…else 的简单程序。
#include <printf.h> #include "time.h" #include "stdlib.h" int main() { srand(time(NULL)); int r = rand() % 2; int a = 10; if (r == 0) { a = 1; } else { a = 2; } printf("a %d", a); return 0; }
- 我们用 rand 生成了一个随机数 r,r 要么是 0,要么是 1。当 r 是 0 的时候,我们把之前定义的变量 a 设成 1,不然就设成 2。
$ gcc -g -c test.c $ objdump -d -M intel -S test.o
- 我们把这个程序编译成汇编代码。你可以忽略前后无关的代码,只关注于这里的 if…else 条件判断语句。对应的汇编代码是这样的:
if (r == 0) 3b: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0 3f: 75 09 jne 4a <main+0x4a> { a = 1; 41: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1 48: eb 07 jmp 51 <main+0x51> } else { a = 2; 4a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2 51: b8 00 00 00 00 mov eax,0x0 }
- 可以看到,这里对于 r == 0 的条件判断,被编译成了 cmp 和 jne 这两条指令。
- 1.cmp 指令比较了前后两个操作数的值,这里的 DWORD PTR 代表操作的数据类型是 32 位的整数,而[rbp-0x4]则是变量 r 的内存地址。所以,第一个操作数就是从内存里拿到的变量 r 的值。第二个操作数 0x0 就是我们设定的常量 0 的 16 进制表示。cmp 指令的比较结果,会存入到条件码寄存器当中去。
- 2.在这里,如果比较的结果是 True,也就是 r == 0,就把零标志条件码(对应的条件码是 ZF,Zero Flag)设置为 1。除了零标志之外,Intel 的 CPU 下还有进位标志(CF,Carry Flag)、符号标志(SF,Sign Flag)以及溢出标志(OF,Overflow Flag),用在不同的判断条件下。
- 3.cmp 指令执行完成之后,PC 寄存器会自动自增,开始执行下一条 jne 的指令。跟着的 jne 指令,是 jump if not equal 的意思,它会查看对应的零标志位。如果 ZF 为 1,说明上面的比较结果是 TRUE,如果是 ZF 是 0,也就是上面的比较结果是 False,会跳转到后面跟着的操作数 4a 的位置。这个 4a,对应这里汇编代码的行号,也就是上面设置的 else 条件里的第一条指令。当跳转发生的时候,PC 寄存器就不再是自增变成下一条指令的地址,而是被直接设置成这里的 4a 这个地址。这个时候,CPU 再把 4a 地址里的指令加载到指令寄存器中来执行。
- 4.跳转到执行地址为 4a 的指令,实际是一条 mov 指令,第一个操作数和前面的 cmp 指令一样,是另一个 32 位整型的内存地址,以及 2 的对应的 16 进制值 0x2。mov 指令把 2 设置到对应的内存里去,相当于一个赋值操作。然后,PC 寄存器里的值继续自增,执行下一条 mov 指令。
- 5.这条 mov 指令的第一个操作数 eax,代表累加寄存器,第二个操作数 0x0 则是 16 进制的 0 的表示。这条指令其实没有实际的作用,它的作用是一个占位符。我们回过头去看前面的 if 条件,如果满足的话,在赋值的 mov 指令执行完成之后,有一个 jmp 的无条件跳转指令。跳转的地址就是这一行的地址 51。我们的 main 函数没有设定返回值,而 mov eax, 0x0 其实就是给 main 函数生成了一个默认的为 0 的返回值到累加器里面。if 条件里面的内容执行完成之后也会跳转到这里,和 else 里的内容结束之后的位置是一样的。
7_2_6_image
3.4 程序控制流程
- 程序里的多条指令,究竟是怎么样一条一条被执行的
- 通过 PC 寄存器自增的方式顺序执行外,条件码寄存器会记录下当前执行指令的条件判断状态,然后通过跳转指令读取对应的条件码,修改 PC 寄存器内的下一条指令的地址,最终实现 if…else 以及 for/while 这样的程序控制流程。
- 用高级语言,可以用不同的语法
- 比如 if…else 这样的条件分支,或者 while/for 这样的循环方式,来实现不同的程序运行流程,但是回归到计算机可以识别的机器指令级别,其实都只是一个简单的地址跳转而已。
3.5
更多内容推荐
- GitHub:https://github.com/yangchong211
- 博客:https://juejin.cn/user/1978776659695784
- 博客汇总:https://github.com/yangchong211/YCBlogs
- 设计模式专栏:https://github.com/yangchong211/YCDesignBlog
- Java高级进阶专栏:https://github.com/yangchong211/YCJavaBlog
- 网络协议专栏:https://github.com/yangchong211/YCNetwork
- 计算机基础原理专栏:https://github.com/yangchong211/YCComputerBlog
- 系统性学习C编程:https://github.com/yangchong211/YCStudyC
- C++学习案例:https://github.com/yangchong211/YCStudyCpp
- Leetcode算法专栏:https://github.com/yangchong211/YCLeetcode
- Android技术专栏:https://github.com/yangchong211/YCAndroidBlog