从0开始的汇编语言(十)
前言
从0开始的汇编语言系列,选用的参考书籍是清华大学出版社,王爽老师的《汇编语言第四版》。该系列属于博主的笔记系列,文中会采用一些书中的例子,图片以及思考题供读者阅读,如需详细学习汇编语言可以购入一本,谢谢。
学习之前我们做如下约定(随着学习深入还会出现新的约定):
- 十六进制数均以H结尾
- 使用8086CPU作为案例
- 我们使用(地址或寄存器名称)表示一个寄存器或一个内存单元的内容,()内地址是且一定是物理地址
- 我们将idata视作常量
- 我们以reg表示一个寄存器包括ax、ah、sp、bp、si、di等,sreg表示一个段寄存器包括ds、ss、cs、es。
话不多说我们马上开始。
ret和retf
我们书接上回,上一篇我们学习了一些转移指令的原理,接下来我们继续学习一些新的转移指令:
ret指令用栈中的数据,修改IP的内容,从而实现近转移,当CPU执行ret指令时,进行下面两步操作:(IP)=((SS)×16+(SP));(SP)=(SP)+2。用汇编语法来解释ref指令相当于进行了pop IP。
retf指令用栈中的数据,修改CS和IP的内容,从而实现远转移,当CPU执行retf指令时,进行下面四步操作:(IP)=((SS)×16+(SP));(SP)=(SP)+2;(CS)=((SS)×16+(SP));(SP)=(SP)+2。用汇编语法来解释ref指令相当于进行了pop IP,pop CS。
call指令
call指令也是一种转移指令,当CPU执行call指令的时候,进行两步操作:将下一条指令的IP或CS和IP压入栈中;转移。call指令不能实现短转移,除此之外,call指令实现转移的方法和jmp指令的原理相同。接下来我们仔细介绍一下。
依据位移进行转移的call指令
首先,看一下call指令的格式:call 标号,将下一条指令IP压入栈中后,转到标号处执行指令。也就是这么一个过程:(SP)=(SP)-2;((SS)×16+(SP))=(IP);(IP)=(IP)+16位位移。其中16位位移的计算方法是:位移=标号处偏移地址-call指令下一条指令的偏移地址,结果用补码表示,在编译程序编译时算出。当然了16位位移是有取值范围的,取值范围是-32768~32767,用补码表示。用汇编语法来解释call指令相当于进行了push IP;jmp near ptr 标号。
转移的目的地址在指令中的call指令
首先,还是看一下call指令的格式:call far ptr 标号,将下一条指令CS和IP压入栈中后,转到标号处执行指令。也就是这么一个过程:(SP)=(SP)-2;((SS)×16+(SP))=(CS);(SP)=(SP)-2;((SS)×16+(SP))=(IP);(IP)=(IP)+16位位移;(CS)=标号所在段的段地址;(IP)=标号在段中的偏移地址。这样的call指令实现了段间转移,用汇编语法来解释call指令相当于进行了push CS;push IP;jmp far ptr 标号。
转移地址在寄存器中的call指令
这个call指令的格式是:call 16位reg。它的过程是:(SP)=(SP)-2;((SS)×16+(SP))=(IP);(IP)=(16位reg),用汇编语法来解释call指令相当于进行了push IP;jmp 16位reg。
转移地址在内存中的call指令
call指令中的转移地址也可以在内存之中,它也分为两种格式:
call word ptr 内存单元地址
用汇编语言解释这句指令的话,那么当CPU执行这句指令的时候,相当于进行了:push IP;jmp word ptr 内存单元地址。
call dword ptr 内存单元地址
用汇编语言解释这句指令的话,那么当CPU执行这句指令的时候,相当于进行了:push CS;push IP;jmp dword ptr 内存单元地址。
call和ret的配合使用
我们学会了ret指令也学会了call指令,学这两个指令的目的是为了实现子程序的机制,什么是子程序呢?直观的来说就是我们在高级语言中写的函数,怎么样是不是一下子就感受到这两个指令的强大了,事不宜迟,我们马上开始。 我们先来看一个程序:
1 | assume cs:code 内存中的情况(假设程序从内存1000:0处装入) |
程序中给出了内存中的情况,我们结合内存的情况一点点分析整个程序做的事情。从start开始前三行执行后,栈的情况如下:
1000:0000 00 00 00 00 00 00 00 00
此时SS:SP指向栈底,当call指令读入后,(IP)=000EH,CPU指令缓冲器中的代码为:E8 05 00(call s)
程序执行完call s后,栈的情况变为
1000:0000 00 00 00 00 00 00 0E 00
此时SS:SP指向0E也就是倒数第二个字节,然后(IP)=(IP)+0005=0013H。之后CPU从CS:0013H处(即标号s处)开始执行,ret指令读入后(IP)=0016H,CPU指令缓存器中的代码为:C3 (ret)
程序执行完C3后,栈的情况变为
1000:0000 00 00 00 00 00 00 0E 00
此时SS:SP指向栈底,之后CPU回到CS:000EH处(即call指令后面的指令处)继续执行。
怎么样,是不是有点像我们在高级程序中做的那个样子,写一个函数,当我们需要它的时候就调用它,不需要的时候我们就不调用它。我们给出一个子程序的源程序的框架:
1 | assume cs:code |
怎么样现在的汇编语言是不是更有我们高级程序语言的模样啦!
mul指令
我们这里介绍一下mul指令,mul是乘法指令,在使用mul做乘法时要注意两点:
- 两个相乘的数:要不都是8位,要不都是16位。如果都是8位那么一个默认放在AL中,另一个默认放在8位reg或者内存字节单元中。如果都是16位那么一个默认放在AX中,另一个默认放在16位reg或者内存字单元中。
- 结果:如果8位乘法,结果默认在AX中;如果是16位乘法那么,结果高位默认在dx中,低位在ax中。
格式如下:
- mul reg
- mul 内存单元
内存单元可以通过不同的寻址方式给出,比如:
mul byte ptr ds:[0] 含义是(AX)=(AL)×((DS)×16+0);mul word ptr [BX+SI+8]含义是(AX)=(AX)×((DS)×16+(BX)+(SI)+8)结果的低16位;(DX)=(AX)×((DS)×16+(BX)+(SI)+8)结果的高16位。
参数与结果传递的问题
在高级语言中,我们都知道一个函数由返回值类型,函数名称、参数列表组成,我们将参数交给函数,函数在进行了指定的操作后,将结果交付给我们。在我们的汇编语言中也会使用这种模块化的程序设计。
我们现在思考这样的问题,现在我们有一个数N,设计一个子程序计算N的三次幂。这样一来我们就有了两个问题,参数N应该存放在哪里?得到的结果要存放在哪?很显然,可以用寄存器来存储,可以将参数放到BX中;因为子程序中要计算N的3次幂,所以可以使用多个mul指令,为了方便,可将结果存放到dx和ax中,我们来看程序,这个程序计算了data段第一组数据的3次方,结果保存到后一组的dword单元中:
1 | assume cs:code |
批量数据的传递
上面的程序中子程序cube只有一个参数,放在bx中。如果有两个参数,那么可以使用两个寄存器来放,可是如果需要传递3个,4个,N个怎么办?显然一味地使用寄存器是不可靠的。这种时候,我们将批量的数据放到内存之中,然后将它们所在的内存空间的首地址放在寄存器中,传递给需要的子程序,同样需要返回多个数据,我们也这样做。我们写一个将字符串转为大写的程序,体验一下:
1 | assume cs:code |
除了使用寄存器传递参数,我们还有更通用的做法,使用栈来传递参数,我们接下来结合C语言的函数调用来看一下使用栈传递参数的思想,我们设定一个场景,我们要设计一个子程序计算(a-b)的3次幂,a和b是字型数据,我们来看用到的程序片段:
1 | mov ax,1 |
其中ret idata的含义用汇编语法描述为:
pop IP;add sp,idata
我们来看看栈的变化,假设栈的初始情况如下:
1000:0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
此时SS:SP指向栈底。
当执行mov ax,1~push ax指令后,栈的情况为:
1000:0000 00 00 00 00 00 00 00 00 00 00 00 00 03 00 01 00
此时SS:SP指向03。
当执行call difcube指令后,栈的情况为:
1000:0000 00 00 00 00 00 00 00 00 00 00 XP XI 03 00 01 00
此时SS:SP指向XI(XIXPH即为call指令的下一条指令IP)。
执行push bp指令后,栈的情况为:
1000:0000 00 00 00 00 00 00 00 00 XB XA XI XP 03 00 01 00
此时SS:SP指向XB。
执行mov bp,sp后,SS:BP指向1000:8
之后执行mov ax,[bp+4]将栈中a的值送入ax中,sub ax,[bp+6]减去栈中b的值,mov bp,ax将ax中的值赋给bp,mul bp计算三次幂。
执行pop bp指令后,栈的情况为:
1000:0000 00 00 00 00 00 00 00 00 XB XA XI XP 03 00 01 00
此时SS:SP指向XI。
执行ret 4指令后,栈的情况为:
1000:0000 00 00 00 00 00 00 00 00 XB XA XI XP 03 00 01 00
此时SS:SP指向栈底。
我们接下来通过一个C语言程序编译后的汇编语言程序,看一下栈在参数传递中的应用,在高级语言中,局部变量也在栈中储存。
1 | void add(int a,int b,int c) |
编译后的汇编程序:
1 | mov bp,sp |
尝试画出栈的情况变化,理解栈在参数传递中的作用。
寄存器冲突问题
现在你已经学会了子程序的编写,那么我们思考一个问题当我们循环调用一个子程序时,且子程序中使用了寄存器CX会发生什么?这个问题,我们之前遇到过类似的,当使用嵌套循环时也会出现这样的问题,内层循环更改了寄存器CX使得,外层循环出现了问题。同样,我们的子程序修改了CX使得循环出现了问题,我们要如何解决呢?没错还是使用栈来解决,我们在进入子程序后,立马将CX的值压入栈中,待程序完成后从栈中取出CX的值,不只是CX,只要是主程序用到,子程序重复使用的寄存器,我们都要这样处理一下,当然了要注意出栈入栈的顺序。