汇编语言学习笔记(十):CALL 和 RET 指令

学习参考:汇编语言(第2版)王爽

  • ret 和 retf
  • call 指令
  • 依据位移进行转移的 call 指令
  • 转移的目的地址在指令中的 call 指令
  • 转移地址在内存中的 call 指令
  • call 和 ret 的配合使用
  • mul 指令
  • 参数和结果传递问题
  • 批量数据的传递
  • 寄存器冲突问题

call 和 ret 指令都是转移指令,它们都是修改 IP,或同时修改 CS 和 IP。它们经常被共同用来实现子程序设计

ret 和 retf

ret 指令用栈中的数据,修改 IP 的内容,从而实现近转移
retf 指令用栈中的数据,修改 CS 和 IP 的内容,从而实现远转移。

CPU 执行 ret 指令时,进行下面两步操作

  1. (IP)=((ss)*16+(sp))
  2. (sp)=(sp)+2

相当于

1
pop IP

CPU 执行 retf 指令时,进行下面 4 步操作

  1. (IP)=((ss)*16+(sp))
  2. (sp)=(sp)+2
  3. (CS)=((ss)*16+(sp))
  4. (sp)=(sp)+2

相当于

1
2
pop IP
pop CS

call 指令

CPU 执行 call 指令时,进行两步操作:

  1. 将当前的 IP 或 CS 和 IP 压入栈中
  2. 转移

call 指令不能实现短转移,除此之外,call 指令实现转移的方法和 jmp 指令的原理相同

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

call 标号(将当前的IP压栈后,转到标号处执行指令)
CPU 执行此种格式的 call 指令时,进行如下的操作:

  1. (sp)=(sp)-2 ; ((ss)*16+(sp))=(IP)
  2. (IP)=(IP)+16位位移。

16 位位移 = 标号处的地址 - call 指令后的第一个字节的地址;
16 位位移的范围为 -32768-32767,用补码表示;
16 位位移由编译程序在编译时算出。

从上面的描述中,可以看出,如果我们用汇编语法来解释此种格式的call 指令,则:
CPU执行 call 标号 时,相当于进行:

1
2
push IP
jmp near ptr 标号

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

call far ptr 标号 实现的是段间转移

  1. (sp)=(sp)-2
    ((ss)16+(sp))=(CS)
    (sp)-(sp)-2
    ((ss)
    16+(sp))=(IP)

  2. (CS)=标号所在段的段地址
    (IP)=标号在段中的偏移地址

从上面的描述中可以看出,如果我们用汇编语法来解释此种格式的 call 指令,则 CPU执行 call far ptr 标号时,相当于进行:

1
2
3
push CS
push IP
jmp far ptr 标号

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

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

  1. call word ptr 内存单元地址
    CPU 执行此种格式的 call 指令,相当于进行:
    1
    2
    push IP
    jmp word ptr 内存单元地址
  1. call dword ptr 内存单元地址
    CPU 执行此种格式的 call 指令,相当于进行:
    1
    2
    3
    push CS
    push IP
    jmp dword ptr 内存单元地址

call 和 ret 的配合使用

可以写一个具有一定功能的程序段,称其为子程序,在需要的时候,用 call 指令转去执行。call 指令转去执行子程序之前,call 指令后面的指令的地址将存储在栈中,所以可在子程序的后面使用 ret 指令,用栈中的数据设置 IP 的值,从而转到 call 指令后面的代码处继续执行。

用我个人的理解,不恰当的说,我感觉这一功能可以看作是调用一个函数,标号就相当于函数名,call 标号 就相当于调用了标号所在的子程序,当子程序的功能执行完过后,就使用 ret 指令回到调用的位置,ret 就相当于 return 一样。

这样我们可以利用 call 和 ret 来实现子程序的机制。子程序的框架如下:

1
2
3
标号:
指令
ret

具有子程序的源程序的框架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
assume cs:codesg
codesg segment
main:
..
..
call sub1 ; 调用子程序 sub1
..
..
mov ax,4c00h
int 21h

sub1: ; 子程序 sub1 开始
..
..
call sub2 ; 调用子程序 sub2
..
..
ret ; 子程序返回

sub2: ; 子程序 sub2 开始
..
..
ret ; 子程序返回

mul 指令

mul 是乘法指令,使用 mul 做乘法的时候,注意一下两点

  1. 两个相乘的数:两个相乘的数,要么都是 8 位,要么都是 16 位。如果是 8 位,一个默认在 AL 中,另一个放在 8 位 reg 或内存字节单元中;如果是 16 位,一个默认在 AX 中,另一个放在 16 位 reg 或内存字单元中
  2. 结果:如果是 8 位乘法,结果默认放在 AX 中;如果是 16 位乘法,结果高位默认在 DX 中存放,低位在 AX 中放

格式如下

1
2
mul reg
mul 内存单元

内存单元可以用不同的寻址方式给出

1
mul byte ptr ds:[0]

举例如下

  • 计算 100 * 10
    1
    2
    3
    4
    5
    6
    7
    ; 100 和 10 小于 255,做 8 位i乘法

    mov al,100
    mov bl,10
    mul bl

    ; 结果(ax)=1000(03E8H)
  • 计算 100 * 10000
    1
    2
    3
    4
    5
    6
    7
    ; 100 小于 255, 可 10000 大于 255,所以必须做 16 位乘法

    mov ax,100
    mov bx,10000
    mul bx

    ; 结果(ax)=4240H,(dx)=000FH (F4240H=1000000)

参数和结果传递问题

这个问题实际上就是在探讨,应该如何存储子程序需要的参数和产生的返回值

比如,设计一个子程序,可以根据提供的N,来计算N的3次方。
这里面就有两个问题:

(1) 将参数N存储在什么地方?
(2)计算得到的数值,存储在什么地方?

很显然,可以用寄存器来存储,可以将参数放到 bx 中; 因为子程序中要计算
NNN,可以使用多个mul指令。为了方便。可将结果放到d和ax中。子程序如下。

1
2
3
4
5
6
7
8

;说明: 计算N的3次方
;参数: (bx)-N
;结果: (dx:ax)=N^3
cube:mov ax,bx
mul bx
mul bx
ret

批量数据的传递

有时候我们需要传多个参数,那么寄存器的数量又是有限的,那么这个时候,我们将批量数据放到内存中,然后将它们所在内存空间的首地址放在寄存器中,传递给需要的子程序。对于具有批量数据的返回结果,也可用同样的方法。

下面看一个例子,设计一个子程序,功能: 将一个全是字母的字符串转化为大写。

这个子程序需要知道两件事,字符串的内容和字符串的长度。因为字符串中的字母可能很多,所以不便将整个字符串中的所有字母都直接传递给子程序。但是,可以将字符串在内存中的首地址放在寄存器中传递给子程序。因为子程序中要用到循环,我们可以用 loop指令,而循环的次数恰恰就是字符串的长度。出于方便的考虑,可以将字符串的长度放到 cx 中。

1
2
3
capital: and byte ptr [si]:11011111b ; 将 ds:si 所指单元中的字母转化为大写
inc si ; ds:si 指向下一个单元
loop capital

寄存器冲突问题

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

程序要处理的字符串以 0 作为结尾符,这个字符串可以如下定义:

1
db 'conversation',0

应用这个子程序,字符串的内容后面一定要有一个0,标记字符串的结束。子程序可以依次读取每个字符进行检测,如果不是0,就进行大写的转化;如果是0,就结束处理。由于可通过检测 0 而知道是否已经处理完整个字符串,所以子程序可以不需要字符串的长度作为参数。可以用jcxz 来检测 0 。

1
2
3
4
5
6
7
8
9
10
;说明: 将一个全是字母,以0结尾的字符串。转化为大写
;参数: ds:si指向字符串的首地址
;结果: 没有返回值
capital:mov cl,[si]
mov ch,0 ;如果(cx)=0,结束; 如果不是0,处理
jcxz ok ;将ds:si 所指单元中的字母转化为大写
and byte ptr [si],11011111b ;ds:si 指向下一个单元
inc si
jmp short capital
ok:ret

上面的代码没有问题,但是我们要考虑一个问题,我们这个使用 cx 寄存器,那么带着其他地方就没法使用该寄存器,可是在 CPU 中寄存器的数量是有限的,那么如何解决这个寄存器冲突问题?

解决这个问题的简捷方法是,在子程序的开始将子程序中所有用到的寄存器中的内容都保存起来,在子程序返回前再恢复。可以用栈来保存寄存器中的内容。

以后,我们编写子程序的标准框架如下:

子程序开始:子程序中使用的寄存器入栈

           子程序内容

           子程序中使用的寄存器出栈

           返回(ret.retf)

我们改进一下子程序capital 的设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
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

要注意寄存器入栈和出栈的顺序。

文章作者: Inno Fang
文章链接: https://innofang.github.io/2017/11/27/汇编语言学习笔记(十):CALL-和-RET-指令/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来自 Inno's Blog