计组(2)CPU与指令

一、总体认识CPU

1、软硬件角度

CPU,全称就是中央处理器。从硬件上来说,CPU是一个超大规模集成电路,通过电路实现加法、乘法乃至各种各样的处理逻辑。从软件来说,CPU就是一个执行各种计算机指令的逻辑机器。

2、计算机指令

所谓的计算机指令,其实就是CPU能听懂的语言,我们可以叫做机器语言。不同的CPU能够听懂的语言也是不一样的。例如,一般的电脑所用的是Intel的CPU,而苹果手机用的是ARM的CPU。这两种CPU各自支持的语言,就是两组不同的计算机指令集。

所以,我们在电脑上写的一个C语言程序,经过编译、汇编之后形成exe文件,把这个文件复制到手机上一般是没法正常运行的。而把这台电脑上的exe文件复制到另一个相同OS的电脑上,是可以正常运行的。

3、存储程序型计算机

一个计算机程序,一般有很多指令组成,但是CPU里不能一直放着所有指令(CPU中主要用于存放当前正在执行即将执行的指令和数据。),所以计算机程序平时是存储在存储器中的(这个存储器一般称之为内存或主存)。

二、简单了解编译与汇编

1、整体过程

平时编写的代码,到底是怎么变成一条条计算机指令,最后被 CPU 执行的?以一段C语言程序举例:

cpp 复制代码
// test.c
int main()
{
  int a = 1; 
  int b = 2;
  a = a + b;
}

要让这段程序在一个 Linux 操作系统上跑起来,我们需要把整个程序翻译成一个汇编语言(ASM,Assembly Language)的程序,这个过程我们一般叫编译(Compile)成汇编代码。

针对汇编代码,我们可以再用汇编器(Assembler)翻译成机器码(Machine Code)。这些机器码由"0"和"1"组成的机器语言表示。这一条条机器码,就是一条条的计算机指令。这样一串串的 16 进制数字,就是我们 CPU 能够真正认识的计算机指令。

在一个 Linux 操作系统上,我们可以简单地使用 gcc 和 objdump 这样两条命令,把对应的汇编代码和机器码都打印出来。

bash 复制代码
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o

结果如下:

bash 复制代码
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    

可以看到,左侧有一堆数字,这些就是一条条机器码;右边有一系列的 push、mov、add、pop 等,这些就是对应的汇编代码。一行 C 语言代码,有时候只对应一条机器码和汇编代码,有时候则是对应两条机器码和汇编代码。汇编代码和机器码之间是一一对应的。

小问题:为什么要经过汇编而不是把代码直接编译成机器码?因为汇编代码其实就是"给程序员看的机器码",也正因为这样,机器码和汇编代码是一一对应的。我们人类很容易记住 add、mov 这些用英文表示的指令,而 8b 45 f8 这样的指令,由于很难一下子看明白是在干什么,所以会非常难以记忆。所以编译、汇编的整体过程如下:

但其实,这里是有更深层次的原因。将编译过程划分为"编译器 -> 汇编器"两步,而不是直接从高级语言生成机器码,主要是出于软件工程上的考量 ,即模块化、可移植性和简化设计

编译器和汇编器各自的任务是解耦的:

这样分工的好处是:

2、简单解析指令与机器码

(1)汇编语言的指令分类

①算数类指令

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

②数据传输类指令

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

③逻辑类指令

逻辑上的与或非。

④条件分支类指令

类似"if/else"编译形成的指令

⑤无条件跳转指令

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

(2)机器码是怎样生成的

不同的 CPU 有不同的指令集,也就对应着不同的汇编语言和不同的机器码。这里以MIPS指令集为例(MIPS 是一组由 MIPS 技术公司在 80 年代中期设计出来的 CPU 指令集)

MIPS 的指令是一个 32 位的整数,高 6 位叫操作码(Opcode),也就是代表这条指令具体是一条什么样的指令,剩下的 26 位有三种格式,分别是 R、I 和 J。

R 指令是一般用来做算术和逻辑操作,里面有读取和写入数据的寄存器的地址。如果是逻辑位移操作,后面还有位移操作的位移量,而最后的功能码,则是在前面的操作码不够的时候,扩展操作码表示对应的具体指令的。

I 指令,则通常是用在数据传输、条件分支,以及在运算的时候使用的并非变量还是常数的时候。这个时候,没有了位移量和操作码,也没有了第三个寄存器,而是把这三部分直接合并成了一个地址值或者一个常数。

J 指令就是一个跳转指令,高 6 位之外的 26 位都是一个跳转后的地址。

以一个简单的add指令为例:

复制代码
add $t0, $s2, $s1

这个指令的含义是,将s2寄存器和s1寄存器中的值相加,放到t0寄存器中。

这条指令对应的 MIPS 指令里 opcode 是 0,rs 代表第一个寄存器 s1 的地址是 17,rt 代表第二个寄存器 s2 的地址是 18,rd 代表目标的临时寄存器 t0 的地址,是 8。因为不是位移操作,所以位移量是 0。把这些数字拼在一起,就变成了一个 MIPS 的加法指令。

为了读起来方便,我们一般把对应的二进制数,用 16 进制表示出来。在这里,也就是 0X02324020。这个数字也就是这条指令对应的机器码。

小拓展:区分是R、I、J指令主要就是看 前 6 位的 opcode 字段。opcode = 0几乎是所有 R 型,真正的操作由 funct 字段决定。opcode ≠ 0->I型(区别不同 I 型指令)。opcode = 0x02(j) 或 0x03(jal)->J型。

3、python和java的执行

除了 C 这样的编译型的语言之外,不管是 Python 这样的解释型语言,还是 Java 这样使用虚拟机的语言,其实最终都是由不同形式的程序,把我们写好的代码,转换成 CPU 能够理解的机器码来执行的。只是解释型语言,是通过解释器在程序运行的时候逐句翻译,而 Java 这样使用虚拟机的语言,则是由虚拟机对编译出来的中间代码进行解释,或者即时编译成为机器码来最终执行。

三、CPU执行指令的过程

以Intel的CPU为例,里面差不多有几百亿个晶体管。实际上,一条条计算机指令执行起来非常复杂。但是在 CPU 在软件层面已经为我们做好了封装。对于我们这些做软件的程序员来说,我们只要知道,写好的代码变成了指令之后,是一条一条顺序执行的就可以了。

逻辑上,我们可以认为,CPU 其实就是由一堆寄存器组成的。而寄存器就是 CPU 内部,由多个触发器(Flip-Flop)或者锁存器(Latches)组成的简单电路。(触发器和锁存器,其实就是两种不同原理的数字电路组成的逻辑门。)

N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据。比方说,我们用的 64 位 Intel 服务器,寄存器就是 64 位的。(没错,一个寄存器就是这么小,换算成高级语言中相当于一个32位整型或64位长整型。但是寄存器只是用来保存当前正在计算的数据,不像内存需要存储程序等)

1、CPU的几种寄存器

一个 CPU 里面会有很多种不同功能的寄存器。这里介绍三种比较重要、特殊的。

(1)PC寄存器

我们也叫指令地址寄存器。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址。

(2)指令寄存器

用来存放当前正在执行的指令。

(3)条件码寄存器

用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。

(4)其他寄存器

除了这些特殊的寄存器,CPU 里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。我们通常根据存放的数据内容来给它们取名字,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。

2、执行指令(顺序)

一个程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。

注意:PC寄存器自增(包括后面所说的更新),一般是在CPU的指令周期 (取指 → 译码 → 执行 → 写回)的取指阶段

  • CPU 取到当前指令时 ,就会把 PC 自增(例如 +4)到下一条指令地址。

  • 如果当前指令是跳转/分支类指令(如 jmpbeq),那么译码/执行阶段可能会修改 PC 的值(覆盖掉刚才的 +4)。

而有些特殊指令,比如 J 类指令,也就是跳转指令,会修改 PC 寄存器里面的地址值。这样,下一条要执行的指令就不是从内存里面顺序加载的了。

3、指令的跳转------条件语句

下面是一个简单的包含条件判断if...else...的指令:

cpp 复制代码
// test.c


#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;
  } 

我们用 rand 生成了一个随机数 r,r 要么是 0,要么是 1。当 r 是 0 的时候,我们把之前定义的变量 a 设成 1,不然就设成 2。

用以下代码生成汇编代码:

bash 复制代码
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o 
bash 复制代码
    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 这两条指令。

cmp 指令比较了前后两个操作数的值,这里的 DWORD PTR 代表操作的数据类型是 32 位的整数,而[rbp-0x4]则是变量 r 的内存地址。所以,第一个操作数就是从内存里拿到的变量 r 的值。第二个操作数 0x0 就是我们设定的常量 0 的 16 进制表示。cmp 指令的比较结果,会存入到条件码寄存器当中去。

在这里,如果比较的结果是 True,也就是 r == 0,就把零标志条件码 (对应的条件码是 ZF,Zero Flag)设置为 1 。除了零标志之外,Intel 的 CPU 下还有进位标志符号标志 以及溢出标志,用在不同的判断条件下。

cmp 指令执行完成之后,PC 寄存器会自动自增,开始执行下一条 jne 的指令。

跟着的 jne 指令,是 jump if not equal 的意思,它会查看对应的零标志位 。如果 ZF 为 1,说明上面的比较结果是 TRUE,则继续往下顺序执行;如果是 ZF 是 0,也就是上面的比较结果是 False,会跳转到后面跟着的操作数 4a 的位置。这个 4a,对应这里汇编代码的行号,也就是上面设置的 else 条件里的第一条指令。当跳转发生的时候,PC 寄存器就不再是自增变成下一条指令的地址,而是被直接设置成这里的 4a 这个地址。这个时候,CPU 再把 4a 地址里的指令加载到指令寄存器中来执行。

跳转到执行地址为 4a 的指令,实际是一条 mov 指令,第一个操作数和前面的 cmp 指令一样,是另一个 32 位整型的内存地址,以及 2 的对应的 16 进制值 0x2。mov 指令把 2 设置到对应的内存里去,相当于一个赋值操作 。然后,PC 寄存器里的值继续自增,执行下一条 mov 指令。

这条 mov 指令的第一个操作数 eax,代表累加寄存器,第二个操作数 0x0 则是 16 进制的 0 的表示。这条指令其实没有实际的作用,它的作用是一个占位符。我们回过头去看前面的 if 条件,如果满足的话,在赋值的 mov 指令执行完成之后,有一个 jmp 的无条件跳转指令。跳转的地址就是这一行的地址 51。我们的 main 函数没有设定返回值,而 mov eax, 0x0 其实就是给 main 函数生成了一个默认的为 0 的返回值到累加器里面。if 条件里面的内容执行完成之后也会跳转到这里,和 else 里的内容结束之后的位置是一样的。

指令可以往后跳转也可以向前跳转------这就是for/while循环实现的原理。

4、指令的跳转------循环语句

下面是段简单的c语言for循环指令:

cpp 复制代码
int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        a += i;
    }
}

循环自增变量 i 三次,三次之后,i>=3,就会跳出循环。整个程序,对应的 Intel 汇编代码就是这样的:

cpp 复制代码
    for (int i = 0; i <= 2; i++)
   b:   c7 45 f8 00 00 00 00    mov    DWORD PTR [rbp-0x4],0x0
  12:   eb 0a                   jmp    1e 
    {
        a += i;
  14:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x4]
  17:   01 45 fc                add    DWORD PTR [rbp-0x8],eax

  1a:   83 45 f8 01             add    DWORD PTR [rbp-0x4],0x1
  1e:   83 7d f8 02             cmp    DWORD PTR [rbp-0x4],0x2
  22:   7e f0                   jle    14 
  24:   b8 00 00 00 00          mov    eax,0x0
    }

对应的循环也是用 1e 这个地址上的 cmp 比较指令,和紧接着的jle 条件跳转指令来实现的。主要的差别在于,这里的 jle 跳转的地址,是在这条指令之前的地址 14,而非 if...else 编译出来的跳转指令之后。往前跳转使得条件满足的时候,PC 寄存器会把指令地址设置到之前执行过的指令位置,重新执行之前执行过的指令,直到条件不满足,顺序往下执行 jle 之后的指令,整个循环才结束。

小拓展:jne和jle

x86 汇编 里,像 jnejle 这样的条件跳转指令,本身并不会做比较运算,它们只是检查标志寄存器 (EFLAGS)(这个寄存器叫做条件码寄存器) 的状态。而这个状态一般需要由 cmp 指令 或者 test 指令 先设置。

5、总结

对于多条指令,除了简单地通过 PC 寄存器自增的方式顺序执行外,条件码寄存器会记录下当前执行指令的条件判断状态,然后通过跳转指令读取对应的条件码,修改 PC 寄存器内的下一条指令的地址,最终实现 if...else 以及 for/while 这样的程序控制流程。

虽然我们可以用高级语言,可以用不同的语法,比如 if...else 这样的条件分支,或者 while/for 这样的循环方式,来实现不同的程序运行流程,但是回归到计算机可以识别的机器指令级别,其实都只是一个简单的地址跳转而已,也就是一个类似于 goto 的语句。

想要在硬件层面实现这个 goto 语句,除了本身需要用来保存下一条指令地址,以及当前正要执行指令的 PC 寄存器、指令寄存器外,我们只需要再增加一个条件码寄存器,来保留条件判断的状态。这样简简单单的三个寄存器,就可以实现条件判断和循环重复执行代码的功能。

相关推荐
mu_guang_2 天前
计算机算术8-浮点加法
算法·cpu·计算机体系结构
小Lu的开源日常3 天前
为什么计算机用“补码”存储整数?
设计模式·面试·计算机组成原理
triticale3 天前
【计算机组成原理】LRU计数器问题
cache·计算机组成原理·lru
杰克逊的日记3 天前
gpu与cpu各厂商的优劣
运维·cpu·gpu
大模型铲屎官4 天前
【数据结构与算法-Day 20】从零到一掌握二叉树:定义、性质、特殊形态与存储结构全解析
人工智能·python·深度学习·二叉树·大模型·计算机组成原理·数据结构与算法
伊织code10 天前
PyTorch API 2
pytorch·api·cpu·cuda·微分·autograd
岑梓铭15 天前
考研408《计算机组成原理》复习笔记,第五章(1)——CPU功能和结构
笔记·考研·408·计算机组成原理·计组
岑梓铭15 天前
考研408《计算机组成原理》复习笔记,第五章(2)——CPU指令执行过程
笔记·考研·408·计算机组成原理·计组
岑梓铭17 天前
考研408《计算机组成原理》复习笔记,第五章(3)——CPU的【数据通路】
笔记·考研·408·计算机组成原理·计组