本章是介绍call和ret指令,它们都是转移指令,都修改IP,或者同时修改CS和IP。它们经常被共同用来实现子程序的设计,本章内容属于汇编语言中实现子程序的基础。 由于内容也很复杂,故一步一步进行解析。
一、ret和retf指令
这两个指令相对简单:
1、ret指令用栈中的数据,修改IP的内容,从而实现近转移
CPU执行ret指令时,相当于进行:pop IP。
而栈中的数据,则由((ss)*16+(sp))决定,即:
(IP)=((ss)*16+(sp)),并且指令指针寄存器sp要自加2,即:(sp)=(sp)+2。
当然,这些操作会由CPU在执行ret指令时自动完成。
2、retf指令用栈中的数据,修改CS和IP的内容,从而实现远转移
CPU执行retf指令时,相当于进行:
pop IP
pop CS
同理,在CUP执行reft时,会进行下面4步操作:
1)(IP)=((ss)*16+(sp))
2)(sp)=(sp)+2
3)(CS)=((ss)*16+(sp))
4)(sp)=(sp)+2
也就是说,当IP指向栈底的sp、在CUP执行reft时,会将栈底的第一个字单元的内容,出栈后赋值给IP,然后将栈底的第二个字单元的内容,赋值给CS,从而改变CS和IP的内容。
所谓的“实现近转移”(ret指令)和“实现远转移”(retf指令),区别就在于,ret指令只修改IP,而retf指令既要修改IP,还要修改CS。
二、call指令
call指令相对复杂,从对栈单元内容进行操作(push和pop)的角度来看,实际上ret指令和reft指令是call指令的逆向操作;或者说,在调用子程序的时候,先会用call指令,然后用ret或reft进行返回,如下图:
根据call指令的工作类型,并结合第9章转移指令的原理的思维导图:
同样可以画一个call指令的思维导图进行区分:
1、依据位移进行转移
1)call 标号
是将当前的IP压栈后,转到标号处执行指令,相当于:
push IP
jmp near ptr 标号
由于jmp near ptr 标号称为“段内近转移”,故把call 标号也归为“段内近转移”这种类型,后面的call指令类型也以此类推。
而“jmp near ptr 标号””的功能:(IP) = (IP) + 16 位位移。16 位位移的范围是 -32768~32767。
可以看出,“call 标号”这一指令,实际上是在指令“jmp near ptr 标号” 的基础上,多了一个push IP的动作。
这里需要注意的是:push IP
并不是将本条指令所指示的IP压栈,所谓的“将当前的IP压栈”,实际上是将本条指令的下一条指令的IP压栈。
如原书第193的检测点10.2:
最后,在执行完pop ax 之后,ax为6。
原因就在于,当执行 call s的时候,IP并不是3,而已经指向了6。即:
第一步:CPU读取call s到指令缓冲区,(IP) = (IP) + 所执行指令的长度(call s指令的长度)。此时的IP为3,再加上call s指令的长度3,就是6。相当于 mov ip,6,而不是mov ip,3。
第二步:将当前的IP(值为6)压栈。
第三点:转到标号s处,执行pop ax。
而这一点知识是最容易搞混的,应当认真去领会。其他类型的call指令关于IP的压栈方式,也要参照这个知识点。
2)call far ptr 标号
这种指令实现的是段间转移,换句话说就是转移的过程,会涉及代码段寄存器CS的改变,指令的跳转可以在不同的代码段之间进行。
CUP执行“call far ptr 标号”中,相当于进行:
push CS
push IP
jmp far ptr 标号
需要注意的是,“jmp far ptr 标号”指令所对应的机器码,高地址(16位)是转移的段地址,低地址(16位)是偏移地址。
当然,在执行“call far ptr 标号”指令时,上述细节都是由CPU自动执行的。我们只需要了解这些原理。
2、转移地址在内存中
1)call word ptr 内存单元地址
相当于进行:
push IP
jmp word ptr 内存单元地址
这里不难发现,“call word ptr 内存单元地址”指令与“call 标号”有异曲同工之妙,其实都是在同一个段中执行跳转。
不同之处在于:
“call 标号”指令相当于在执行“push IP”之后,执行“jmp near ptr 标号”指令,而标号所指示的偏移量,是由CPU自动计算得来。偏移量是16 位位移,范围是 -32768~32767。
而“call word ptr 内存单元地址”指令的偏移量,是由内存单元地址的内容提供,也就是说,这个偏移量要事先写入相应的内存单元。
由于有“word”这个关键词,会将2个内存单元地址(每个单元1个字节),也就是1个字用来保存偏移量,这是16 位位移,范围是 -32768~32767。
如果指令是:call word ptr ds:[0],则相当于mov ip ds:[0] 。当然,汇编语言是没有mov ip ds:[0] 这个指令的,这里只是从原理上讲相当于这种表述。
2)call dword ptr 内存单元地址
相当于进行:
push CS
push IP
jmp dword ptr 内存单元地址
同样,不难发现,“call dword ptr 内存单元地址”指令与“call far ptr 标号”有异曲同工之妙,都是在不同段间执行转移。
不同之处在于:
“call far ptr 标号”指令相当于在执行“push IP”之后,执行“jmp far ptr 标号”指令,而标号所指示的目的段地址CS(16位)和目的偏移量IP(16位),是由CPU自动计算得来。
而“call dword ptr 内存单元地址”指令的目的段地址CS(16位)和目的偏移量IP(16位),是由内存单元地址的内容提供,也就是说,这个CS和IP要由事先写入相应的内存单元。
由于有“dword”这个关键词 ,会将4个内存单元地址(每个单元1个字节),也就是2个字来保存转移的目的段地址和转移的目的偏移地址。
而“jmp dword ptr 内存单元地址”指令本身,就是从内存单元地址开始存放2个字,高地址处的字(16位)是转移的目的段地址,低地址处(16位)是转移的目的偏移地址。然后实施目的地址的跳转。
3、转移地址在寄存器中
这种类型的call 指令就相对简单,只有一种指令格式:
call 16位reg
从语法上讲,相当于:
push IP
jmp 16位reg
例如:
mov ax,6
call ax
其中的call ax 相当于:
push IP
jmp ax
而jmp ax则相当于:mov IP,ax
从上述知识内容中不难发现,从jmp指令到call指令,其语法结构都是层层嵌套的,只要搞懂了jmp指令,call指令也迎刃而解。
三、call和ret 的配合使用
前面提到,在调用子程序的时候,先会用call指令,然后用ret或reft进行返回,原书中给出了这样一个示例:
我们来看一下 CPU 执行这个程序的主要过程。
1、CPU 将 call s指令的机器码读入,IP 指向了 call s后的指令 mov bx,ax,然后CPU 执行 call s指令,将当前的IP 值(指令 mov bx,ax 的偏移地址)压栈,并将IP的值改变为标号s处的偏移地址。
2、CPU 从标号s处开始执行指令,loop 循环完毕后,(ax)=8。
3、CPU 将 ret 指令的机器码读入,IP 指向了 ret 指令后的内存单元,然后 CPU 执行ret 指令,从栈中弹出一个值(即 call s先前压入的 mov bx,ax 指令的偏移地址)送入 IP中。则 CS:IP 指向指令 mov bx.ax。
4、CPU 从 mov bx,ax 开始执行指令,直至完成,bx等于8。
理解了call指令与ret指令相配合的原理之后,自然也就理解了call指令与retf