计算机指令-机器码

计算机指令

CPU是一个可以执行各种指令的逻辑机器,而计算机指令就是指特定CPU认识的指令集。

每种CPU的指令集不同

我们平时用的PC是Intel CPU,他的指令集与苹果最新的M1芯片指令集不同,两边编译后的程序自然也不能直接运行。

编译->汇编->机器码

高级语言 ----编译---> 汇编语言 ----汇编器----> 机器码(计算机指令,16进制)

一行汇编代码对应一行机器码,也就是一行计算机指令,一般CPU有2000多种指令,但是大致可以分为五种:

image-20201212190410230

MIPS指令集

MIPS 技术公司在 80 年代中期设计出来的 CPU 指令集,现在开源了,以这个指令集,我们尝试理解一下这个CPU指令集。

image-20201212190410230

指令位数设计

MIPS 的指令是一个 32 位的整数,高 6 位叫操作码(Opcode),也就是代表这条指令具体是一条什么样的指令。

剩下的 26 位有三种格式,分别是 R、I 和 J。

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

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

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

可以看出每种指令的每一位都有专门的用途。

人肉指令翻译

add $t0,$s2,$s1

这是一个加法语句,将s2+s1相加,并将值赋予t0,我们尝试将其翻译成MIPS指令集的指令。

  1. 首先它是加法,应该是R指令,那我们参考R指令的位数设计
  2. operCode使用add,rs可以放入s2的地址,rt可以放入s1的地址,rd可以放入t0的地址,CPU就会将s2+s1的结果放入t0变量中。
    3.我们将每个位数的二进制位合并,最终转换为16进制就如下图
image-20201212190410230

上图的内容按16进制转换成打孔纸,就如下所示:

image-20201212190410230

总结:

这一节,我们学习了指令集的一些常识,并且基于MIPS指令集,将一个加法指令转换成了打孔纸,我们理解了软件是如何转换成CPU指令的。
而平时使用的高级语言,也是一步步最终转换成16进制的指令,由CPU执行的。

指令跳转

高级语言编程是通过编译器、汇编器变成指令送给CPU执行的,这节说明了CPU是如何执行这些指令的。

CPU结构

在了解指令执行之前,先得了解CPU内部结构,如下这些寄存器
image-20201212190410230

  • PC寄存器(程序计数器) 用来存储下一条指令的地址。
  • 指令寄存器,用来存储当前执行的指令。
  • 条件码寄存器,用来存储条件和逻辑运算的结果。
  • 其他各种寄存器

看懂指令

如下是C写的一段小代码,转换成汇编之后,主要略熟悉指令含义,就可以读懂汇编代码。

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

汇编代码:

    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
    } 
    for (int i = 0; i <= 2; i++)
   b:   c7 45 f8 00 00 00 00    mov    DWORD PTR [rbp-0x4],0x0 //将0赋值给i
  12:   eb 0a                   jmp    1e  //无条件跳转到1e
    {
        a += i;
  14:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x4] //将i赋值到eax累加器中
  17:   01 45 fc                add    DWORD PTR [rbp-0x8],eax //将eax累加器的值加到a中

  1a:   83 45 f8 01             add    DWORD PTR [rbp-0x4],0x1 //将1加到i上
  1e:   83 7d f8 02             cmp    DWORD PTR [rbp-0x4],0x2 //比较i和2
  22:   7e f0                   jle    14 //若小于等于则跳转到14行
  24:   b8 00 00 00 00          mov    eax,0x0 //将0赋值给累加器
    }
  • cmp指令(compare) 比较指令,比较后面两位的值,第一位是内存地址,第二位是16进制的0,并把结果存到条件码寄存器里。
  • jne指令(jump if not equal) 判断跳转指令,它会读取上一个指令的条件码寄存器结果值进行判断,若为1,顺序执行,若为0,跳转到4a行代码执行。
  • mov指令(move) 移动指令,将后一位的值,移动到前一位处,第一位是变量a的地址值,第二位是16进制的1,其实就相当于给a赋值了。
  • jmp指令(jump)无条件跳转指令,可以指定当前程序跳到某一行。
  • jle指令(或jng)(jump if less or equal,or not greater)若小于等于则跳转,它会读取上一个指令的条件码寄存器结果值进行判断。
  • add指令,将两个地址的值相加
  • EAX 是"累加器"(accumulator), 它是很多加法乘法指令的缺省寄存器。
  • EBX 是"基地址"(base)寄存器, 在内存寻址时存放基地址。
  • ECX 是计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器。
  • EDX 则总是被用来放整数除法产生的余数。

函数栈

这节讲的是函数栈的原理,栈已经很熟悉了,先入后出的数据结构,刚好符合方法调用逻辑。这里要说清楚几个概念:

  • 函数栈内部组成
  • 函数栈指令层面的算法
  • 栈溢出概念
  • 函数内联概念

函数栈内部组成

如下图所示,函数栈分为栈底和栈顶(实际是上底下顶),栈内每个函数一个栈帧(Stack Frame),栈帧内部保存了函数的局部变量、参数、返回地址等内容。

函数栈指令层面算法


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    
  • 栈内并不会存储指令,指令执行依旧按照指令寄存器和PC寄存器顺序执行。
  • rbp 当前栈帧的栈底位置,即帧指针。
  • rsp 永远是栈顶位置,即栈指针。
  1. call 调用函数,会做两件事:①将PC寄存器的下一个地址压栈(如上图所示)②会跳转到指定方法的起始位置
  2. push rbp 将之前的rbp地址压栈(如上图所示)
  3. mov rbp,rsp 将现在栈指针地址赋值给rbp,也就是创建了新的栈帧地址。
  4. 然后开始执行指令,其中的参数和变量会不断压栈缓存到当前栈帧中。
  5. pop rbp 将当前栈帧弹出,恢复到main函数的帧指针地址。
  6. ret 弹出返回地址,跳出当前过程,继续执行调用者的代码。此时会将栈顶的返回地址弹出到PC,然后程序将按照弹出的返回地址继续执行。

栈溢出概念

栈溢出就是指栈内的空间不足,导致异常,例如函数调用链路过长,函数局部变量过多;单个java的栈一般可以存入1000多个栈帧,不使用递归很难会导致栈溢出。

函数内联

函数内联是编译器优化的一种策略,如果A调用B,将B的代码直接嵌入到A中,就可以删减掉对栈操作的指令,提高性能;
但是很明显,函数代码无法复用后,会导致编译后的汇编代码量急速膨胀。

内联后的函数,没有调用其他函数,只会被调用的函数,我们一般称之为叶子函数(或叶子过程)。

posted @ 2022-07-04 19:57  来焕明  阅读(886)  评论(0)    收藏  举报