计算机指令

Wednesday, August 7, 2019

CPU帮我们做了什么事

  • 从软件工程师的角度来讲,CPU就是一个执行各种计算机指令的逻辑机器。这里的计算机指令,就是机器语言。

  • 不同的CPU能够听懂的语言不一样,比如ARM和X86的CPU,就是用了不同的计算机指令集。

代码如何变成机器码

比如这段C语言

<code>// test.c
int main()
{
  int a = 1; 
  int b = 2;
  a = a + b;
}</code>

我们可以把这段代码,变成汇编语言,再用汇编语言变成机器码,这一条条的机器码,就是计算机指令。

再Linux上,我们可以这样操作。

<code>$ gcc -g -c test.c
$ objdump -d -M intel -S test.0</code>

然后我们就能看到这个test.o文件里

<code>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    </code>

左边的一堆数字,就是机器码,右边的push mov add 等,就是对应的汇编代码。一行C语言代码,可能会对应多行汇编,但是汇编代码和机器码是一一对应的。

解析指令和机器码

一般来讲,CPU有五大类指令

  • 算术类指令,比如加减乘除
  • 数据传输指令, 比如给变量赋值,从内存读写数据
  • 逻辑类指令, 逻辑上的与或非
  • 条件分支指令, if else这类
  • 无条件跳转指令, 比如调用函数,就是用无条件跳转指令

file

跳转指令

CPU是如何执行指令的

从软件层面来讲,我们只需知道,写好的代码变成了指令之后,是一条条顺序执行的就可以了。

逻辑上,我们可以认为,CPU其实就是由一堆寄存器组成的。而寄存器就是CPU内部,由多个触发器或者锁存器组成的简单电路。

N个触发器或者锁存器,就可以组成一个N位(Bit)的寄存器,比如64位处理器,寄存器就是64位的。

CPU的三种特殊寄存器

  • PC寄存器,也叫指令地址寄存器,用来存放下一条要执行的指令的内存地址
  • 指令寄存器,用来存放当前正在执行的指令。
  • 条件码寄存器,用里面的一个一个标记位(Flag),存放CPU进行算术或者逻辑计算的结果。

file

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

但是有些特殊的指令,比如跳转指令,就会修改PC寄存器里面的地址值,这也是我们可以在程序里写if else或者for循环语句的原因了。

if else 的程序

<code>// 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;
  } </code>

我们依然把这个程序编译成汇编和机器码,我们只关注if else这部分

<code>    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
    } </code>

r == 0 的条件判断,被编译成cmp和jne这两条指令,这里的DWORD PTR代表操作的数据类型是32位的整数,而[rbp-0x4]是一个寄存器的地址,所以这里就是从寄存器里拿到r的值,再跟0x0这个16进制的常量0进行对比。

cmp指令比较的结果,会存入到条件码寄存器当中去。

如果比较的结果是True,就把零标志条件码设置为1。除了零标志,intel的CPU还有进位标志,符号标志,溢出标志,用在不同的判断条件中。

比较完之后,就会进入到下一条指令,也就是jne指令,jne的意思是jump if not equal,它会查看零标志位,如果是0则,跳转到后面的操作数,上面的4a,对应着汇编的行号,也就是else语句的一条指令。

当发生跳转时,PC寄存器就不再是自增到下一条指令,而是直接设置到这里的4a的这个地址。

这里跳转后的第一条指令是mov指令,就是把16进制的0x2赋值到另一个32位整型的寄存器里去,就是一个赋值操作。

下一条指令 mov eax,实际上没有实际作用,就是一个占位符的作用,回头看看if里面,如果满足的话,最后一条是jmp到51,也就是这条指令,因为这个main函数没有设定返回值,所以mov eax 0x0,相当于给main函数生成了一个默认为0的返回值到累加器里面。

file

for循环的语句

首先是C语言代码

<code>int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        a += i;
    }
}</code>

再看看循环的汇编和机器码

<code>    for (int i = 0; i < 3; i++)
   b:   c7 45 f8 00 00 00 00    mov    DWORD PTR [rbp-0x8],0x0
  12:   eb 0a                   jmp    1e <main+0x1e>
    {
        a += i;
  14:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  17:   01 45 fc                add    DWORD PTR [rbp-0x4],eax
    for (int i = 0; i < 3; i++)
  1a:   83 45 f8 01             add    DWORD PTR [rbp-0x8],0x1
  1e:   83 7d f8 02             cmp    DWORD PTR [rbp-0x8],0x2
  22:   7e f0                   jle    14 <main+0x14>
  24:   b8 00 00 00 00          mov    eax,0x0
    }</code>

可以看到,对应的循环也是用1e这个比较指令,和紧接着的jle条件判断来跳转地址

file

函数调用

还是一样,来一段简单C语言代码

<code>// function_example.c
#include <stdio.h>
int static add(int a, int b)
{
    return a+b;
}

int main()
{
    int x = 5;
    int y = 10;
    int u = add(x, y);
}</code>

汇编和机器码

<code>int static add(int a, int b)
{
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi
   7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi
    return a+b;
   a:   8b 55 fc                mov    edx,DWORD PTR [rbp-0x4]
   d:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  10:   01 d0                   add    eax,edx
}
  12:   5d                      pop    rbp
  13:   c3                      ret    
0000000000000014 <main>:
int main()
{
  14:   55                      push   rbp
  15:   48 89 e5                mov    rbp,rsp
  18:   48 83 ec 10             sub    rsp,0x10
    int x = 5;
  1c:   c7 45 fc 05 00 00 00    mov    DWORD PTR [rbp-0x4],0x5
    int y = 10;
  23:   c7 45 f8 0a 00 00 00    mov    DWORD PTR [rbp-0x8],0xa
    int u = add(x, y);
  2a:   8b 55 f8                mov    edx,DWORD PTR [rbp-0x8]
  2d:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  30:   89 d6                   mov    esi,edx
  32:   89 c7                   mov    edi,eax
  34:   e8 c7 ff ff ff          call   0 <add>
  39:   89 45 f4                mov    DWORD PTR [rbp-0xc],eax
  3c:   b8 00 00 00 00          mov    eax,0x0
}
  41:   c9                      leave  
  42:   c3                      ret    </code>

这里可以看到,跟之前的跳转的主要区别是,jump指令换成了call指令

我们来看add函数,代码里面先执行了一条push指令和一条mov指令,在结束的时候,执行了pop指令和ret指令。这里,其实就是压栈和出栈了。

为什么要跟if else那边不一样呢,原因就是if else那边,跳过去就不再回来了,跳过去到新的地址,就一直在新的地址执行了。但是很明显,函数调用这里,跳过去之后执行完之后,还是要回到原来的地址的,这里就需要有一个程序调用寄存器,记录这个地址。但是如果是多层调用,寄存器就存不了这么多数据的。所以这里,就用到了栈这个后进先出的数据结构。

真实程序里,压栈的不只是函数调用完成后的返回地址,还有一些参数数据,也会被压入占中。整个函数A所占用的内存空间,就是函数A的栈帧。

另外,与一般的栈不同,这个栈底在最上面,顶在最下面。

file

调用34行的call指令时,会把当前的PC寄存器的下一条指令压栈,保留函数调用结束后要执行的指令地址。add函数的第0行,push rbp这个指令,就是进行压栈。这里的rbp又叫栈帧指针,存放了当前栈帧位置的寄存器。push rbp就把之前调用函数,也就是main函数的栈帧的栈底地址,压到栈顶。

接着执行命令mov rbp,rsp里,把rsp的值赋值给rbp里,而rsp始终指向栈顶,这个命令意味着,rbp这个栈帧指针指向的地址,变成当前最新的栈顶,也就是add函数的栈帧的栈底地址了。

执行完add指令后,又会分别调用第12行的pop rbp来将当前栈顶出栈,然后调用13行的ret指令,这时候同时要把call调用的时候压入的PC寄存器里的下一条指令出栈,更新到PC寄存器中,将程序的控制权返回到出栈后的栈顶。

计算机组成原理

Linux 管理

如何处理重复消息