call和ret指令

都是用来修改ip的或者同时修改cs和ip。

ret和retf指令作用如下:一个是近转移,一个是远转移。

image-20230323193028491

执行ret指令相当于执行

1
pop IP

CPU执行retf指令时相当于执行

1
2
3
pop IP
pop CS
//注意是先出IP再出CS

举个例子我们来看看。下面的程序中ret指令执行之后,(ip)=0,cs:ip指向代码段第一条指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
assume cs:code

stack segment
db 16 dup (0)
stack ends

code segment
mov ax,4c00h
int 21h

start:mov ax,stack
mov ss,ax//定义栈
mov sp,16//栈的指针位置。
mov ax,0//置0
push ax//将0入栈
mov bx,0
ret//0出栈到ip
code ends
end start

那么下面retf执行之后,CS:IP指向代码段第一条指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
assume cs:code

stack segment
db 16 dup (0)
stack ends

code segment
mov ax,4c00h
int 21h

start:mov ax,stack
mov ss,ax//定义栈
mov sp,16//栈的指针位置。
mov ax,0//置0
push cs//先入栈cs,因为后出栈cs
push ax//将0入栈
mov bx,0
retf//0出栈到ip,后出栈到cs
code ends
end start

来看一个小程序,补全,实现从内存1000:0000处开始执行指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
assume cs:code

stack segment
db 16 dup (0)
stack ends

code segment
start:mov ax,stack
mov ss,ax
mov sp,16
mov ax,_1000__//显然这里先入栈cs应该为1000
push ax
mov ax,__0_//这里应该为0,出栈到ip
push ax
retf
code ends
end start

call指令

call和jmp原理类似,只是比jmp多了一步,也就是先将ip,或cs,ip压栈,之后再转移。

注意:不能实现短转移。

依据位移进行转移的call指令

1
call 标号(将当前ip压栈后,转到标号处执行指令)转移过程中用的是16位位移。(ip)=(ip)+16位位移。

显然我们已经知道16位位移的范围是-32768~32767,用补码表示。

位移大小在编译器编译时算出,和之前一样。

执行call时,相当于执行

1
2
push IP
jmp near ptr 标号//也就是先入栈了,再执行跳转

看一个例题:

下面程序执行后,ax中数值为多少。

image-20230327200016846

显然第一步ax=0。

当cs:ip指向1000:3时,‘call s’指令的机器码进入指令缓冲器,ip指向下一条指令开始处(6);十分注意

第二步将0006入栈,然后跳转到s,计算0006到0007的位移,也就是0001(如机器码所示)。

然后就跳转到s开始执行了,出栈6到ax,结束。

转移的目的地址在指令中的call指令

前面讲的call指令在执行的时候机器指令中并没有目的地址,只有相对于当前ip的位移。

call far ptr 标号实现段间转移。

执行call far ptr标号的时候,相当于执行

1
2
3
push cs
push ip
jmp far ptr 标号//显然先入cs再入ip,正好出栈retf就先出ip后出cs

下面看例题

image-20230327200940704

这里将cs1000入栈然后再将ip0008入栈然后跳转到s,再出栈0008到ax,然后ax+ax=0010,然后出栈1000到bx然后ax+bx=1010h

转移地址在寄存器中的call指令

格式:call 16位reg。也是先入栈,再位移,只不过只有ip

相当于执行

1
2
push ip
jmp 16位reg//即直接跳转到reg位置

看一个例题

image-20230327201219126

首先ax=6,然后将0005入栈,之后跳转到0006的位置执行mov bp,sp。此时sp指针指向的内容为0005,所以[bp]=0005,之后ax+[bp]也就是0006+0005=000bh。

转移地址在内存中的call指令

转移地址在内存中的call指令有两种格式

(1) call word ptr 内存单元地址

相当于执行

1
2
push ip
jmp word ptr 内存单元地址

比如下面的指令

1
2
3
4
mov sp,10h
mov ax 0123h
mov ds:[0],ax
call word ptr ds:[0]

执行后,(ip)=0123h,(sp)=0Eh(也就是10-2=0E),注意调用call的时候sp先-2,所以到这里变成10-2=0E。

(2) call dword ptr 内存单元地址

看见dword就想到了把cs,ip一起改变,正好四个字节。

相当于执行

1
2
3
push cs
push ip
jmp dword ptr 内存单元地址

比如下面的指令:

1
2
3
4
5
mov sp,10h
mov ax,0123h
mov ds:[0],ax//也就是[0]和[1]的两个内存单元正好两个字节,一个字,也就是0123h
mov word ptr ds:[2],0//也就是[2][3]两个内存单元正好两个字节,都是0所以cs为0因为高位cs低位ip
call dword ptr ds:[0]

执行后(cs)=0(后出栈),(ip)=0123h(先出栈),(sp)=0Ch(这里也就是10-04=0c显然!)

来看一个例题

image-20230327211825167

显然一开始定义了8个字也就是16个字节都为0。

显然执行的时候应该把下一条ip先入栈,然后再jmp到ds:[0E]的位置也就是数据段的最后两个字节(因为数据段和栈段在一起,所以是一个东西)!用来表示新的ip,其实也就是原来的ip。

程序中一开始就定义好了栈段和数据段,都是那16个字节的0。

ax=0,ip入栈,sp = sp - 2 = 16 - 2 = 14然后新的ip跳转到数据段0E的位置也就是原来的ip。那么位移也就为0.

然后显然就继续执行了,ax自增3次变为3结束。

再来看第二个例题

image-20230327213225032

同样地定义了16个字节的0作为初始数据段。然后把它设置为栈段。并且初始化栈底指针。

我们看到ss:[0]栈顶,被置为s的偏移地址。然后把cs地址(两个字节)赋值给ss:[2]的两个字节。

然后调用call,先让sp = sp - 2然后把下一条ip也就是nop所在ip和cs放入栈底,此时栈指针推向0ch,之后ip跳转到ss:[0]也就是s的偏移地址处,然后[2]和[3]的数据作为cs,那么也就和原来的cs相同不变。

所以下一步直接跳转到s的位置。

执行s位置后ax=s的偏移地址。

然后用ax也就是s的偏移地址 - nop的偏移地址,结果显然为1。所以ax = 1此时。

而后面的ss:[0e]存放的是cs的值,bx也=cs,所以两者相减为0。

综上所述ax=1,bx=0

注意!这里cs和ip的入栈是有顺序的!cs后入栈在高位,把ip推向低位!书上写的解释没有注意顺序!但是必须要分清先后!

call和ret的配合使用(入栈出栈来回用)

问题10.1

image-20230327222116059

首先ax=1,cx=3然后将call s的下一句的ip入栈,之后ip跳转到s执行ax+ax=2然后cx = cx - 1 = 2,然后ax+ax=4,cx = cx - 1 = 1,然后ax+ax = 8,cx=cx - 1 - 0然后跳出循环,执行ret,也就是出栈将mov bx,ax的ip给当前ip,这样跳转到mov bx,ax此时bx = 8结束!

我们再来看一个稍微难一些的程序

image-20230329094149032

首先定义栈为16个字节然后开始程序。定义栈底指针等等,然后ax = 1000,然后把mov ax,4c00也就是结束符的地址先入栈,然后ip跳转到s执行,ax + ax = 2000,然后执行ret也就是跳转到结束,那么最终ax = 2000

我们可以发现call指令可以用来执行子程序,但是子程序执行完毕后如何接着原来call的下面继续执行呢,这时正好call把下一条指令的ip放入栈中,而我们在执行完子程序之后只需要执行一个ret就可以出栈ip并且跳转过来!

我们可以用如下方法

1
2
3
标号:
指令
ret

具有子程序的源程序如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
assume cs:code
code segment
main:
call sub1//调用子程序sub1



mov ax,4c00h
int 21h
sub1:
call sub2//调用子程序sub2



ret//子程序返回
code ends
end start

mul指令

mul是乘法指令。

注意!

要么是8位✖️8位,要么是16位✖️16位。

8位✖️8位:一个默认放在al中另一个在8位reg或一个内存单元中

16位✖️16位:一个默认放在ax中另一个在16位reg或内存字单元中

结果:如果是8位乘法,默认结果放ax,如果是16位乘法,结果高位默认在dx中存放,低位在ax。

内存单元的寻址方式

1
mul byte ptr ds:[0]//(ax)=(al)*((ds)*16 + 0)

或者16位乘法

1
2
3
mul word ptr [bx+si+8]
//(ax)=(ax)*((ds)*16+bx+si+8)的低16位
//(dx)=(ax)*((ds)*16+bx+si+8)的高16位

计算100*10

1
2
3
4
//显然都是8位
mov al,100
mov bl,10
mul bl

显然都是低位✖️低位

计算100*10000

100是8位,然后10000需要16位,所以都用16位。

1
2
3
mov ax,100
mov bx,10000
mul bx

结果(ax)=4240,(dx)=000f。正好f4240h=1000000

模块化程序设计

利用call和ret指令,用简洁的方法实现多个相互联系,功能独立的子程序来解决一个复杂的问题!

参数和结果传递的问题

实际上就是研究如何存储子程序需要的参数和产生的返回值。

这里有两个问题

1
2
将参数n存储在什么地方
计算结果存储在什么地方

显然可以这样

参数:(bx)=N

结果:(dx:ax)=N^3

1
2
3
4
cube:mov ax,bx
mul bx
mul bx
ret

用寄存器来调用存储参数和结果是最常使用的办法。

调用者将参数送进参数寄存器,从结果寄存器中取得返回值;子程序则相反。

编程计算data段中第一组数据的3次方,结果保存在后面一组dword单元中。(十分重要的程序!好好练习)

1
2
3
4
5
assume cs:code
data segment
dw 1,2,3,4,5,6,7,8
dd 0,0,0,0,0,0,0,0//上面是字,下面是double字!
data ends

下面看我先自己编写一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
code segment
start:mov ax,data
mov ds,ax
mov si,0
mov di,16//指向后面
mov cx,8//一共8次循环

s:mov bx,[si]
call cube
mov [di],ax
mov [di+2],dx//存放高位dx
add si,2//也就是上面一次向右移动两个字节
add di,4//因为是double字所以一次移动4个字节
mov ax,4c00h
int 21h
loop s
cube:mov ax,bx
mul ax
mul ax
ret//返回到母程序执行下一个循环!注意这里是字✖️字也就是16位!
code ends
end start

批量数据的传递

上面的程序中cube只有一个参数放在了bx中,如果有多个,我们将批量数据放到内存中,然后将它们所在内存空间的首地址放在寄存器中。传递给需要的子程序。

设计一个子程序,将一个全是字母的字符串转换为大写。

显然我们需要一个一个将其进行与运算11011111b然后得到大写。

我们可以将字符串在内存中的首地址放在寄存器中传递给子程序,让子程序循环读取。循环的次数就是字符串长度!

1
2
3
4
capital:and byte ptr [si],11011111b
inc si//继续遍历
loop capital
ret//返回母程序。

我们看标准程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
assume cs:code
data segment
db 'conversation'
data ends

code segment
start:mov ax,data
mov ds,ax
mov si,0//初始化位置
mov cx,12//循环12次!
call capital
mov ax,4c00h
int 21h


capital:and byte ptr [si],11011111b
inc si//继续遍历
loop capital
ret//返回母程序。
code ends
end start

除了用寄存器传递参数,还可以用栈传递参数!

寄存器冲突问题

设计一个子程序,功能:将一个全是字母,以0结尾的字符串转化为大写。

1
db 'conversation',0

其实可以不通过处理字符串长度,只要用jcxz判断最后一位是0就可以知道处理完了。

子程序如下:

1
2
3
4
5
6
7
capital:mov cl,[si]//cx是16位寄存器,所以用半个来放一个字节的字符串就行。
mov ch,0
jcxz ok//如果为0就跳出了,如果不为0继续执行,回忆jcxz判断上面寄存器是否为0,为0就跳转ok
and byte ptr [si],11011111b
inc si
jmp short capital//因为没办法用loop,cx被占用了。不为0就继续循环。
ok:ret//如果ok,跳出来之后直接再跳出子程序。

(1)将data段中字符串为大写

1
2
3
4
assume cs:code
data segment
db 'conversation',0
data ends

代码段中程序应该如下:

1
2
3
4
mov ax,data
mov ds,ax
mov si,0
call capital

(2)将下面数据段中字符串转换为大写

image-20230329122855028

显然主程序应该如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
start:mov ax,data
mov ds,ax
mov bx,0//但是注意这有五行,所以应该再按照行循环,cx=4
mov cx,4

s:mov si,bx
call capital
add bx,5
loop s//为什么不能只用si呢

mov ax,4c00h
int 21h

capical:mov cl,[si]
mov ch,0
jcxz ok
and byte ptr [si],11011111b
inc si
ok:ret//当jcxz判断cx=0的时候就跳出。
code ends
end start

这里其实又犯了小错误,也就是我们的循环次数用cx表示,但是子程序中同样修改cx用于判断字符串是否到最后一位。

所以这就是寄存器冲突!

所以我们在编写主程序的时候就要检查子程序是否用到cx,bx等等。

所以我们希望

(1)调用子程序的时候,不必关心子程序到底使用了哪些寄存器

(2)编写子程序时不关心调用者使用了哪些寄存器

(3)不会发生寄存器冲突

寄存器冲突的解决办法是在子程序开始前将子程序中所有用到寄存器的内容保存起来(入栈!)

改进一下capital

1
2
3
4
5
6
7
8
9
10
11
12
capital:push cx
push si//这里也就存储起来了

change:mov cl,[si]
mov ch,0
jcxz ok
and byte ptr [si],11011111b//如果判断错误,继续执行
inc si//向下遍历
jmp short change
ok:pop si
pop cx
ret//也就是ok之后并不能直接ret而是先出栈恢复原来循环之中cx的值和si的值(行标)。因为si,cx都出栈之后ip才能出栈。

要注意出栈入栈的顺序。

实验10 编写子程序

下期再来补这个。