虚拟机指令集&栈与函数调用

指令集

save&load

  • IMM

全称load immidiatily

立即加载数据到寄存器

  • LEA

load effective address

加载地址

  • LC/LI/SC/SI

load char/load int:将char和int加载到寄存器

save char/save int:将char和int从寄存器加载到内存

  • PUSH

将寄存器的数据推到栈顶stack peek

举例

虚拟机指令集&栈与函数调用_第1张图片
  • ax是通用寄存器

  • pc是代码区指针program counter

指向当前正在执行的指令

这些指令背后的直接操作数也会存在代码区

比如IMM 2 把2这个数字这个常量(字面量)加载到ax寄存器

解析完这个指令放入code区

虚拟机指令集&栈与函数调用_第2张图片

当读到IMM之后 PC就会下移指向2的位置

代码中的op变量就会装刚刚读到的指令IMM

ax =(dereference)实际地址所在的内容即2

把它放在ax寄存器中

会有一个后置的pc++操作即继续移到下一步

LEA指令在x86里的作用,举例说明:

有一个寄存器bx 里面装了一个内存的地址

bx=430430

想算这个地址往后移8位的地址

bx+8的地址存入ax中

如果没有LEA的话 需要这么实现:

ADD bx 8 mov bx ax 然后再把bx数据转移动ax

这样操作会有一个问题:

1、改变了bx原本的值

2、加法操作有可能会导致溢出

有可能相加的结果会大于32位的值

又会改变CF OF这些标识位的数据

这样会对后续的计算带来更多的复杂性

所以通过LEA这样的操作来简化

LEA ax (bx+8)

在我们的实现下LEA 它不是基于任何地方去算地址的

因为实现也没有必要

主要是想基于栈里面去计算地址

最多是用来读取函数的参数和局部变量的参数

它们都是会基于栈的bp所在的位置

所以我们的LEA是基于bp来加减

可能会得到这个函数的第几个参数或第几个局部变量

比如LEA-1 就是取bp在栈里面的位置 减1其实就是加1 因为栈是从大到小的 减1其实就是bp的下一个位置 这个位置可能就是这个函数的第一个局部变量 比如它是35 把这个位置的地址拿出来 取里面的值就是35

LEA其实就是基于bp的地址来计算它的相对位置

继续分析代码

接下来就是load操作

ax本身装了一个地址

把这个地址所对应的内存的值加载到ax中去

即ax=*ax

由于不同的类型 则会使用不同的指针来做强制转换

sc就会把数据存在栈顶 sp指向栈顶 里面存了一个地址

把这个地址转换成相应的指针

比方说char指针(char*)

然后对这个地址取deference

就拿到了这个地址所在的空间

把这个ax的数据存在这个空间里

用完栈顶之后sp会加加

栈就会往回退一格

即这个栈用完了 就回到上一位

假设这个栈里存的地址是430430

假设说它是data区的一个位置

sp指向这个地址 先找到430430

把它取出来 转化成一个int的指针

指向了data区的一个位置

再对它取一个dereference 那就拿到了这块空间

假设ax=1 这个时候就会把430430这个地址的数据设置为1

就完成了把ax里面的数据存到了内存空间的data区的430430这个位置

再继续看代码

push指令 比如ax里面load了一个数据2,将该数据压栈:sp指针先-- 再把数据2放入栈中

运算相关指令

运算指令

算数运算

四则运算

ADD/SUB/MUL/DIV

MOD取模

位运算

OR

XOR 异或

AND

SHL/SHR左移/右移

逻辑运算

EQ相等

NQ不等

LT/LE/GT/GE小于/小于等于/大于/大于等于

分支跳转指令

JMP相关指令

  • JMP 将pc指针跳到一个指定的代码区域

假设430430是代码区的某个地址

jmp 430430 ,pc指针就会直接移动到430430的地址处 跳过中间的指令

  • JZ/JNZ 基于寄存器当前的值去判断是否要jump以及jump到什么位置

JZ 就是判断 ax是否等于0 如果等于0 就做JMP 如果不等于0 接着执行下一条指令 pc就直接加1了

JNX就相反

举例说明

while(a>b){
...
}

这样一个while循环 怎么用最简单的vm命令实现?

假设已经实现了a>b表达式的值 要么是0要么是1 并且将结果保存在ax中

在代码区定义2个位置 一个是loop point,一个是end point

然后JZ指令判断 ax是否等于0 如果等于0的话 就JMP到end point

如果不等于0 直接执行下一条语句

循环体代码执行完了 要循环 回到开始的位置 有一个JMP loop point

虚拟机指令集&栈与函数调用_第3张图片

再举一个例子

if(a>b){
...
}else{
...
}

在代码区有3个锚点 true point,false point,end point

如果是true 则从true point执行

如果是false 则从false point执行

true执行到false point位置 也需要跳过false point后面的指令

跳转到end point

首先需要计算a>b的值存到ax中去

当为true的时候 直接执行 不需要跳转

当不为true的时候 即JZ false point 跳转到false point

执行完就结束了

如果执行ture的逻辑 执行完了之后 走到 JMP end point

直接跳转到end point

然后执行if之后的语句

虚拟机指令集&栈与函数调用_第4张图片

有了while和if这两个分支判断指令 不涉及函数的情况下 可以实现

图灵完备的计算了

为什么要有statck(code和data空间)

假如没有栈 如何实现函数调用

int add(int a,int b){
   int ret;
   ret = a+b;
   reuturn ret;
}

int main(){
   int a=3;
   int b=4;
   int ret=add(a,b);
   return 0;
}

main函数调用add函数 代码从main函数开始执行

需要知道这几个信息

a、要调用的函数的地址

函数在翻译成汇编的时候 在代码区的位置

b、在执行的过程中需要知道传递的参数的值

add函数内的局部变量和参数值在函数执行完后就没用了 然后找到返回地址 返回到调用处就可以了

关键是在函数执行的过程中要把局部变量a、b和返回地址存起来

add函数在data区先存3再存4再存返回地址(比如是代码区的430430位置)

虚拟机指令集&栈与函数调用_第5张图片

函数调用结束之后 把这个data区删除 把430430地址数据清空

data区在不同的地方就会有不同函数的空间

得有一个统一的地方去管理这些空间

这个地方逻辑上的概念可以定义成一个hash散列表

能够让每次函数调用的时候 能够快速的定位到一个地方

比如说add方法调用完了 我要回到main方法的时候

mian的这些参数值返回值等得恢复

得找到这个空间 去hash散列表中查

这种方案也是可以的 但会导致data区的维护很复杂

计算机中任何复杂的东西都可以通过增加一个中间层或者说抽象层来解决

函数在调用的过程中 最后调用的也就是最近调用的

正在执行的这个函数是最先释放的

我执行完了 就回到调用我的地方

是一个后进先出的过程

那么就会想到stack

所以一般会用stack去描述函数的局部空间

虚拟机指令集&栈与函数调用_第6张图片

add栈中保存了返回地址

参数一般放在main栈的最底下 也可以定义在add栈里面

main栈的栈基base point 即bp

局部变量ret

最后就是栈顶

当add调用结束之后 直接回到bp位置

所有的局部变量就不需要了

然后把add栈中的bp放到main栈中的bp中去

返回值给pc

然后代码区就会有一个跳转

这就是一个函数调用的过程

通过一个后进先出的一个空间的抽象

极大的简化

不同的函数栈之间的内存的关系

但不是一个非有不可的概念 只是这个概念大大简化了维护成本

是否一定要有函数调用呢

有一个Brain-Fuck语言 <>+=[],.

整个代码里面全都有这8种符号组成 可读性为0

读这个代码需要你的大脑很痛苦的运算

所以叫Brain-Fuck

这种语言其实就是假设有2个纸带

第一条纸带 装语言本身就是代码

第二条纸带 装数据的

有个探针只在这个数据的某个起始位置

小于号就是探针左移一格

大于号就是探针右移动一格

加号就是探针所指的这个数据的地方

默认值是0 加号就是把它加1变成1了

减号就是减1变成0了

左括号就是代码区会根据一个条件去判断是否要跳转

条件是你现在这个探针所指的位置是否等于0

如果不等于0 就直接执行

如果等于0 就跳转到对应的右括号

右括号的意思是当指针所指的位置数据等于0

它就直接继续执行

如果不等于0 就跳到对应的左括号

左括号相当于是定义的虚拟机指令的JZ

右括号相当于JNZ

逗号就是从IO设备输入一个数

句号就是输出一个数

如果基于这样简单的语法 如何实现两数相加

虚拟机指令集&栈与函数调用_第7张图片

首先逗号输入一个数 探针在初始位置

就是data区的第一个位置

比如输入的是3

探针右移

然后又输入一个数 比如是4

然后一个左括号

判断探针所指的这个值是否为0

不为0就不跳转就进行下一位

下一位就是左移 探针又移回了

给它加1 ,3就变成了4

加1之后 探针右移

右移之后下一个代码是减

4-1=3

此时探针在3的位置 然后判断它是否为0

不为0 就跳转到对应的左括号

然后又继续执行左移

左移之后又继续加 4变成5

右移就是减 3变成2

又是循环跳转 直到减为了0

为0了之后 就不跳转了(JNZ)

执行下一个代码 EOF即结束了

最终的效果就是把4加到了3上面得到结果是7

这就是brain fuck语言 怎么实现一个加法的过程

这种语言是目前最接近图灵机的一种语言

所以没有函数调用也能完成这些复杂的运算

通过函数调用可以简化

但在brain fuck里面没有stack概念 如果想实现函数调用也是相当复杂的

如果多了一个纸带 那么就很容易实现函数调用

有了函数调用之后 整个主要逻辑的编码就会变得非常简单

所以就明白了 为什么要有代码区、数据区、stack区 这样的分区设计

c、执行完了之后 需要知道return结果的返回值

计算完之后 把结果存在某个寄存器中 比如约定存在通用寄存器ax中

再回到调用处的时候ax里面的值就是被调用函数的返回值

然后把ax赋值到ret

d、返回地址

函数调用(跳转)相关指令

  • CALL
  • RETURN
  • NVAR:new statck frame for variable
  • DARG:delete statck frame for argument
虚拟机指令集&栈与函数调用_第8张图片

还是以上面 main调用add的方法举例

首先main在调用之前需要准备好两个参数

假设这里是stack区 从大到小

前面的main栈先不管了

但接下来的2个地方用来存放2个参数

假设一个是3一个是4

虚拟机指令集&栈与函数调用_第9张图片

接下来调用add方法了

代码区的情况

虚拟机指令集&栈与函数调用_第10张图片

紧接着就要Call了

虚拟机指令集&栈与函数调用_第11张图片

main代码区和add代码区是code区中连续的代码区 为了方便说明 分开画的

add代码区的地址是430430

main代码区就是Call 430430

call完之后 会对这个地方做一个清理 DARG2

虚拟机指令集&栈与函数调用_第12张图片

假设call位置的地址是430420

参数也会占用一个地址 430424

需要返回的这个地址是430428

执行完add方法之后 需要把pc寄存器返回到430428

执行下一条代码

虚拟机指令集&栈与函数调用_第13张图片

call完之后 pc要跳转到430430的位置

虚拟机指令集&栈与函数调用_第14张图片

pc就会等于430430这个地址里面的数

然后把sp下移了一位

结果存到了430428这个地址

虚拟机指令集&栈与函数调用_第15张图片

把返回地址存进去了

执行完add之后 要返回的时候

通过这个地方就能知道要返回到哪了

接下来看看add函数中的vm指令

首先需要做nvar 给函数的局部变量申请stack frame的一些初始空间

这个初始空间首先要存bp地址

存的是老bp地址

因为一旦跳转了之后 进入这个新的栈 那这个新的位置就是bp了

老bp其实就是main的bp也得存进来

因为回头要恢复这个栈的原貌

虚拟机指令集&栈与函数调用_第16张图片

第一可以把代码的位置恢复到之前的状态

第二可以把栈的状态恢复到调用之前的状态

所以记录老的bp就是为了调用结束恢复用的

然后再给栈 增加一些空间

存局部变量 这里的局部变量只有一个ret

虚拟机指令集&栈与函数调用_第17张图片

然后执行 ret = a+b

首先要把赋值的部分ret地址存下来

等赋值完成之后 有个地方去装这个结果

所以需要把这个地址拿出来

由于它是第一个局部变量 那么就是栈基减1的位置

所以通过LEA -1 把它的地址拿出来

存到了所谓的ax寄存器里了

即执行完了之后 ax寄存器就会是ret的地址

虚拟机指令集&栈与函数调用_第18张图片

把这个地址存放到栈里面 所以会有一个push的操作

暂存到栈里面

假设是330330

虚拟机指令集&栈与函数调用_第19张图片

这个时候ax就是空闲的 就可以用来计算了

add指令是把ax里面的内容和stack peek(栈顶)的内容相加 然后存回ax

并且把栈顶销毁

先把a拿出来

它在基于bp的上三格的位置

虚拟机指令集&栈与函数调用_第20张图片

所以是LEA 3

虚拟机指令集&栈与函数调用_第21张图片

拿到了这个地址 然后加载到ax中

然后加了一个整数LI

虚拟机指令集&栈与函数调用_第22张图片

把ax数据加载到栈里

虚拟机指令集&栈与函数调用_第23张图片

栈顶就变成了3

虚拟机指令集&栈与函数调用_第24张图片

然后再把第二个参数 就是bp的上两位

虚拟机指令集&栈与函数调用_第25张图片

拿到这个地址之后 就可以把数据加载到ax中

ax就等于4了

此时栈顶就是3 ax就是4

调用ADD方法 把栈顶销毁

并且把它加到4里面

ax就等于7了

虚拟机指令集&栈与函数调用_第26张图片

这个时候加法就完成了 然后就是赋值语句

找到ret地址

因为栈顶已经已经销毁了 所以sp在这里

虚拟机指令集&栈与函数调用_第27张图片

然后把ax数据存到sp对应的地址里面即SI(save integer)方法

save完了之后 存地址的栈也会销毁

然后把7写进去

虚拟机指令集&栈与函数调用_第28张图片

然后return这个变量

就是第一个局部变量 bp-1的位置

然后把bp-1的位置继续加载进入ax

因为返回值在ax里面 所以还得lea bp-1

虚拟机指令集&栈与函数调用_第29张图片

ret的内容又回到了ax里面

然后再return

虚拟机指令集&栈与函数调用_第30张图片

看看retuan干了什么

首先sp=bp

虚拟机指令集&栈与函数调用_第31张图片

无论有多少个局部空间 这些空间都会被销毁

bp等于sp里面的地址

sp里面具体的值 也就是main bp

所以bp又回到了老bp的位置

sp+1了 所以新的bp也被销毁了

虚拟机指令集&栈与函数调用_第32张图片

然后pc跳转回这里

虚拟机指令集&栈与函数调用_第33张图片

430428的位置

也就是调用call之后的位置

darg2(delete stack frame for args)

为参数做的这个空间 然这个空间delete掉

然后sp继续加加 430428被销毁

虚拟机指令集&栈与函数调用_第34张图片

delete带的参数是2 就会会被3和4这两个参数delete掉

虚拟机指令集&栈与函数调用_第35张图片

然后sp又回到了这个位置

像一切都未发生过一样

Native-Call

虚拟机指令集&栈与函数调用_第36张图片

这里就是完全copy from c4

IO相关指令

OPEN/CLOS/READ/

为什么没有write 因为c4设计Native-CALL主要是为了完成自举 c4的源码里面没有用到write方法

PRTF:将数据写到标准输出(fd=1)

动态内存相关指令

  • MALC

  • FREE

  • MSET

  • MCMP

  • EXIT 用于终结程序

你可能感兴趣的:(平凡人笔记,c语言,单片机,开发语言)