转移指令 JMP, JCXZ, LOOP, RET 和 CALL

⚠ 转载请注明出处:作者:ZobinHuang,更新日期:July.15 2021


知识共享许可协议

    本作品ZobinHuang 采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,在进行使用或分享前请查看权限要求。若发现侵权行为,会采取法律手段维护作者正当合法权益,谢谢配合。


目录

有特定需要的内容直接跳转到相关章节查看即可。

    Section 1. OFFSET 伪指令:在 MASM 风格的汇编代码中,我们可以使用 OFFSET 伪指令获取偏移地址

    Section 2. jmp 无条件跳转指令:介绍了 jmp 指令的来龙去脉
        2.1 跳转指令原理:通过分析 jmp 指令编译后的机器码,剖析了 jmp 指令的原理
        2.2 跳转范围:介绍了使用 jmp 指令进行 8 位位移和 16 位位移的方法
        2.3 一个奇怪的例子:通过一个比较奇怪的例子总结理解 jmp 指令的用法
        2.4 段间转移 (远转移):描述了如何使用 jmp far ptr 指令进行段间转移
        2.5 转移地址在内存、寄存器中的 jmp 指令:介绍了 jmp 的操作数存储在寄存器、内存中的情况
        2.6 根据标志位寄存器进行跳转的指令:介绍了 根据标志位寄存器中的特定标志位进行跳转的跳转指令

    Section 3. jcxz 条件跳转指令:介绍了 jcxz 指令的用法,并且列举了一个例子

    Section 4. loop 条件跳转指令:介绍了 loop 指令的用法

    Section 5. 为什么要用位移来做跳转:总结了使用位移来做跳转的 motivation

    Section 6. 实验:向显存中写入数据:一个将数据写入显存从而在屏幕上进行显示的例子

    Section 7. RET 和 RETF 指令:介绍了从栈段中获取地址修改 CS 和 IP 寄存器的方法

    Section 8. CALL 指令:介绍了基于 CALL 指令的将代码根据不同逻辑进行切割的方法
        8.1 CALL 指令的运行流程:介绍了 CALL 指令的运行流程,分析了编译后的机器码
        8.2 CALL 跳转地址在寄存器中的情况:分析了 CALL 指令的操作数在寄存器中的情况
        8.3 CALL 跳转地址在内存中的情况:分析了 CALL 指令的操作数在内存中的情况
        8.4 CALL 和 RET 的配合使用:将 CALL 和 RET 指令搭配使用,可以获得 "函数" 的功能
        8.5 寄存器旧值保护:介绍了在 "函数调用" 时,保护函数内部使用到的寄存器的旧值的方法

1. OFFSET 伪指令

    我们可以使用 OFFSET 伪指令来获取某个位置的偏移。考虑下面代码:

1
2
3
4
5
6
7
8
9
10
11
code segment
start: mov ax, stack
mov ss, ax
mov sp, 128

s: mov ax, OFFSET start
mov ax, OFFSET s

mov ax, 4C00H
int 21H
code ends

    运行到 Line 7 时,我们往 AX 寄存器中写入的是 s 处在代码段中的偏移,如下图所示:

2. jmp 无条件跳转指令

2.1 跳转指令原理

    我们写一个 jmp 指令的源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
code segment
start: mov ax, stack
mov ss, ax
mov sp, 128

jmp s

mov ax, 0001H
mov ax, 0002H
mov ax, 0003H

s: mov bx, 0004H


mov ax, 4C00H
int 21H
code ends

    然后观察编译后的机器码:

    我们可以看见机器码的尾部是 0A,即十进制 10。这里的含义是向后跳转 10 个字节,到 007A:0014 位置。也就是说,这里编译后 jmp 的机器码中包含的信息是向后跳转多少个字节。jmp 指令通过将这个数与 IP 寄存器相加,就能够修改 IP 寄存器的值,然后跳转到相应位置。

    我们现在过一下整个指令的执行流程:

  1. CS:077A IP:0008,CS:IP 指向 EB 0A (jmp s 对应的机器码)
  2. 读取指令码 EB 0A 进入指令寄存器
  3. IP = IP + 所取指令长度 = IP + 2 = 000A
  4. CPU 执行指令缓冲器中的指令 EB 0A
  5. 执行 EB 0A 后,IP = IP + 0A = 000A + 0A = 14

    然后,我们现在观察一下,如果 jmp 指令是向前跳跃,会是什么情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
code segment
start: mov ax, stack
mov ss, ax
mov sp, 128

jmp start

mov ax, 0001H
mov ax, 0002H
mov ax, 0003H

s: mov bx, 0004H

mov ax, 4C00H
int 21H
code ends

    然后我们查看一下机器码:

    这里我们发现 jmp 在机器码后跟的数是 F6。这里运用到了补码的概念,不了解的同学可以查看我的另一篇文章 数值系统。简单来说,jmp 指令其实等价于 jmp short 指令,用于指出对 IP 寄存器进行 8位位移。IP 中的值 0008+2(所取指令长度)=000A 在加上 F6 后,低八位发生溢出,截断后的值为 0000,因此 IP 寄存器就又指向了 start 处。

2.2 跳转范围

    我们在上一章中看到了 jmp 或 jmp short 指令实现的 8 位位移效果。8 位位移使得 jmp 指令的有效跳转范围是 -128~127。jmp 指令还支持 16 位位移,即有效跳转范围为 -32768~32767。我们看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
code segment
start: mov ax, stack
mov ss, ax
mov sp, 128

db 130 dup(0)

jmp start

mov ax, 4C00H
int 21H
code ends

    明显地,由于我们在 Line 6 的位置插入了 130 个字节,如果仍然是 8 位位移,我们的 jmp 指令是跳不回 start 处的。我们来看一下机器码:

    如上第二张图所示,我们发现机器码已经自动地变成了 16位位移 的形式,我们可以计算一下:

当前 IP 值 + 2 + 跳转值 = 008AH + 0003H + FF73H = 10000H

    这样一来我们发现,当 jmp 指令会自动根据跳转的举例来变更跳转的形式(i.e. 8 位位移或者16 位位移)。我们也可以显式地指出 jmp 指令的跳转范围,"jmp short" 指代 8 位位移(段内短转移),"jmp near ptr" 指代 16 位位移(段内近转移)。注意到这两种转移都是段内转移,也就是说 jmp 指令只会去修改 IP 寄存器中的值,而不会去修改 CS 寄存器中的值。

2.3 一个奇怪的例子

    我们下面通过一个奇怪的例子,来小结 jmp 指令的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
code segment
s5: mov ax, 4C00H
int 21H

start: mov ax, 0
s: nop # CPU 遇到 nop 指令,什么行为都不发生。nop 指令占用一个字节
nop

s4: mov di, OFFSET s
mov si, OFFSET s2
mov ax, cs:[si]
mov cs:[di], ax # 将 jmp short s1 指令复制到 s 处

s0: jmp short s

s1: mov ax, 0
int 21H
mov ax, 0

s2: jmp short s1
s3: nop
code ends

    程序运行起来后,会首先在 s4 处的代码将 s2 处的代码 "jmp short s1" (两个字节) 拷贝到 s 处,然后会执行 jmp s 的指令。到了 s 的时候,实际上 nop 所占用的两个字节现在已经是 jmp short s1 指令。由于我们上面发现了 jmp 指令在被翻译成机器码的时候,使用的是相对位移来对 IP 寄存器做计算,所以我们现在好奇 jmp short s1 所对应的机器码是多少,以及如果在 s 处运行这段机器码,程序将跳到哪里去。

    我们现在 来到 s2 处的指令,我们通过像上面一节的计算方法一样计算一下此处的机器码的值:

跳转原地址 - 跳转目的地址 = (s2 + 0002H) - s1 = -10 = F6

    因此,当 EB F6 指令被拷贝到 s 处后,其在 s 处运行的效果也是向前跳 10 个字节,因此我们刚刚好最终会来到 s5 处的代码,s5 处的代码其实就是程序返回退出的代码。因此整段程序运行之后,这个代码段是可以正常退出的。

2.4 段间转移 (远转移)

    jmp 指令还可以直接跳转到另一个段中,也即 段间转移(远转移)。思考一下可以发现,如果要实现段间转移,则 jmp 指令后跟的立即数应该得有 32 位,其中 16 位是段基地址,另外 16 位是段内偏移地址。在汇编代码中,远转移的形式是 "jmp far ptr + 标号",如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
code segment
start: mov ax, data
mov ds, ax

jmp far ptr s

db 130 dup(0)

s: mov ax, 0

mov ax, 4c00H
int 21H
code ends

    我们可以查看一下机器码:

    我们发现 jmp 指令机器码中,后面跟的立即数就是 32 位目的地址0777:010A 的值。

2.5 转移地址在内存、寄存器中的 jmp 指令

    除了在 jmp 指令后加上跳转标签,我们也可以让 jmp 指令根据内存单元/寄存器中存储的地址进行跳转。

    对于基于寄存器跳转,jmp 指令将寄存器中的值赋值给 IP 寄存器。注意此处的寄存器只能是 16 位寄存器,也就是说只能进行16 位跳转,也即仅支持段内转移,我们在 寄存器和基本操作指令 中有相关的例子。

    对于基于内存单元跳转,不同的是,基于内存单元的跳转可以支持 both 段内转移 和 段间转移

    对于段内转移,jmp 指令的形式是 "jmp word ptr [内存单元地址]",例子如下所示:

    对于段间转移,jmp 指令的形式是 "jmp dword ptr [内存单元地址]"。在运行这个命令时,从这个内存地址处开始应该存放着两个 word,jmp 指令会以高地址处的 word 为目的段地址(CS),低地址处的 word 为目的段内偏移地址(IP) 进行转移。例子如下所示:

尝试使用 jmp 实现 "函数调用"

    这里基于我们在本小节所学习的从内存中获取地址进行跳转的机制,我们可以尝试实现一个简单的类似 "函数调用" 功能,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
code segment
start: mov ax, data
mov ds, ax

mov ax, OFFSET s1
mov ds:[0], ax

mov bx, OFFSET s2
mov ds:[2], ax

mov cx, OFFSET s3
mov ds:[4], ax

mov bx, 0

# 调用 s1 处的 "函数"
jmp word ptr ds:[bx]

over: mov ax, 4C00H
int 21H

s1: mov ax, 1000H
jmp over

s2: mov ax, 1001H
jmp over

s3: mov ax, 1000H
jmp over
code ends

3. jcxz 条件跳转指令

    jcxz (jmp cx zero) 条件跳转指令的作用是:当 cx 寄存器中的值位 0 的时候进行跳转。jcxz 转移指令是短转移 (段内转移)。实际上,所有的条件转移 (e.g. loop, jcxz) 指令都是短转移。

    我们可以通过下面的例子来理解一下 jcxz 的用法:编写一个程序将数据段中第一个为 0 的字节型数据的编号拷贝到 dx 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
data segment
db 64 dup (1)
db 64 dup (0)
data ends

stack segment stack
db 128 dup (0)
stack ends

code segment
start: mov ax, data
mov ds, ax
mov bx, 0

s: mov ch, 0
mov cl, ds:[bx]
jcxz ok
inc bx

ok: mov dx, bx

mov ax, 4C00H
int 21H
code ends

4. loop 条件跳转指令

    loop 条件跳转指令的作用是:当 CX 寄存器不为 0 时,首先 CX = CX-1,然后跳转到 loop 指令后的操作数所指示的内存地址继续执行;若 CX 为 0,则运行 loop 指令之后的语句。

    作为一个条件转移指令,loop 指令同样是一个 短转移 指令 (段内转移)。

    我们在 寄存器和基本操作指令 中对 loop 命令做了演示,这里不再赘述。

5. 为什么要用位移来做跳转

    在上面的例子中,除了段间转移指令外,我们看到的 jmp 命令的机器码中的操作数都是位移信息。这么做的 motivation 是为了使得程序装入内存的任何位置都能正常运行。如果跳转的地址使用的是绝对的内存地址的话,那么程序装入内存的位置就变得固定,失去了灵活性。

6. 实验:向显存中写入数据

    在 8086 CPU 中,B8000H ~ BFFFFH 这 32KB 的内存空间是显存。这 32KB 可以被分为 8 页,CPU 默认显示第 1 页的内容,也即显示一个 4KB 的页中的内容。在这 4KB 中,是一个 80x25 个字符的显示区域,每个字符占用 2 个字节,1 个字节用于描述显示内容,1 个字节用于描述字符的属性 (颜色,背景颜色等)。也就是说这 4KB = 80 x 25 x 2 Bytes,其中低地址(偶数字节)存放字符的 ASCII 码,高地址(奇数字节)存放字符的属性。

    现在我们尝试在屏幕中间显示不同颜色和底色的字符串,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
data segment
# 显示的字符串
# 0123456789ABCDEF
db 'hello zobinHuang'

# 显示的字符属性
db 00000010B #绿色字体
db 00100100B #绿底红色字体
db 01110001B #白底蓝色字体
data ends

stack segment stack
db 128 dup (0)
stack ends


code segment
start: #设置数据段
mov ax, data
mov ds, ax

#设置显存区域位置
mov bx, 0B800H
mov es, bx

#设置屏幕位置
mov di, 160*10 + 30*2

#设置字符 ASCII 码访问偏移量
mov si, 0

#设置字符属性访问偏移量
mov bx, 16

mov cx, 3

sw_line: #嵌套循环,暂存入栈
push bx
push cx
push si
push di

#设置显示字符串 16 个字符的循环限制
mov cx, 16

#初始化 dx,我们的字符和属性要在 dx 中完成组装
mov dx, 0

#获取属性
mov dh, ds:[bx]

show_row: #获取字符
mov dl, ds:[si]
#设置字符和属性
mov es:[di], dx
add di, 2
inc si
loop show_row

#内部循环结束,寄存器值出栈
pop di
pop si
pop cx
pop bx

#添加一行
add di, 160

#修改属性
inc bx

loop sw_line

mov ax, 4C00H
int 21H
code ends

    效果如下所示:

7. RET 和 RETF 指令

    当我们执行 ret 指令时,相当于执行了 "pop ip" 指令。当我们执行 retf 指令时,相当于执行了 "pop ip" 和 "pop cs" 指令。因此,这意味着 ret 和 retf 指令不仅会获取栈段中的数据,同时也会向 "pop" 指令那样影响栈顶指针。并且,对于 retf 指令,由于它 pop 的顺序是先 pop ip 后 pop cs,因此注意我们入栈的时候应该先入 cs 后入 ip。

    回顾我们在 段间转移 (远转移) 中使用 "jmp dword ptr [内存单元地址]" 修改 cs 和 ip 寄存器的操作,通过 ret 和 retf 指令,我们也能修改这两个代码段寄存器。下面我们给出一个例子:

8. CALL 指令

8.1 CALL 指令的运行流程

    结合我们理解的 CPU 执行指令的过程,我们现在来看一下 CALL 指令的工作流程:

  1. CPU 从 CS:IP 所组合出来的地址读取指令,读到指令缓存器中
  2. IP = IP + 所读指令的字节数
  3. push IP 寄存器中的值到栈段中
  4. jmp near ptr 16 位段内转移到标号处
  5. 转移到标号出后继续正常执行指令

    注意 call 指令并不支持段内短转移(i.e. 8位转移)。下面我们顺势来看一下 call 指令的运行原理,其实和上面的跳转指令一样,都是使用了位移的方法。我们有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
code segment
start: mov ax, data
mov ds, ax

call s

db 8 dub (0)

s: mov ax, 0003H

mov ax, 4C00H
int 21H
code ends

    我们来看一下机器码,我们观察到了段内近转移:

    我们也可以让 call 指令支持段间转移,形式是 "call far ptr [标号]"。在这种情况下,call 指令的运行流程为:

  1. CPU 从 CS:IP 所组合出来的地址读取指令,读到指令缓存器中
  2. IP = IP + 所读指令的字节数
  3. push CS 寄存器中的值到栈段中
  4. push IP 寄存器中的值到栈段中
  5. jmp far ptr 32 位段间转移到标号处
  6. 转移到标号出后继续正常执行指令

8.2 CALL 跳转地址在寄存器中的情况

    call 指令也可以用于跳转到存储与寄存器中的地址。由于 8086 寄存器仅仅为 16 位,所以 这种情况下的跳转只能是段内近转移。在这种情况下,call 指令的运行流程为:

  1. CPU 从 CS:IP 所组合出来的地址读取指令,读到指令缓存器中
  2. IP = IP + 所读指令的字节数
  3. push IP 寄存器中的值到栈段中
  4. jmp 16位 reg
  5. 转移后继续正常执行指令

8.3 CALL 跳转地址在内存中的情况

    显然 call 指令的操作数也可以存储在内存中。也显然有两种情况:除了段内短转移以外的段内近转移段间转移

    对于段内近转移,其运行流程如下:

  1. CPU 从 CS:IP 所组合出来的地址读取指令,读到指令缓存器中
  2. IP = IP + 所读指令的字节数
  3. push IP 寄存器中的值到栈段中
  4. jmp word ptr [内存单元地址]
  5. 转移到标号出后继续正常执行指令

    对于段间转移,其运行流程如下:

  1. CPU 从 CS:IP 所组合出来的地址读取指令,读到指令缓存器中
  2. IP = IP + 所读指令的字节数
  3. push CS 寄存器中的值到栈段中
  4. push IP 寄存器中的值到栈段中
  5. jmp dword ptr [内存单元地址]
  6. 转移到标号出后继续正常执行指令

8.4 CALL 和 RET 的配合使用

    通过上面的内容我们知道,call 指令在跳转前把当前的 IP 值或者 CS:IP 值保存到栈中,而 ret 指令会将栈中的值取回到 IP 或者 CS:IP 寄存器中。这两个操作明显是相反的。这就是我们意识到,call 指令相当于一个函数调用,函数运行结束后,我们可以用 ret 来返回。举个例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
code segment
start: mov ax, data
mov ds, ax

call s

mov ds, ax

mov ax, 4C00H
int 21H

s: mov ax, 0003H
ret #函数段运行结束后将返回 Line 7 执行。

code ends

    基于此,我们可以对我们在 实验:向显存中写入数据 编写的代码进行修改,将寄存器初始化、打印字符串分别作为一段函数来写,然后我们就可以通过 call 指令来实现打印不同的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
code segment
start:

call init_reg

#设置屏幕位置
mov di, 160*10 + 30*2
#设置字符 ASCII 码访问偏移量
mov si, 0
#设置字符属性访问偏移量
mov bx, 16
call show_str

mov ax, 4C00H
int 21H

# 显示函数
show_str: mov cx, 3

sw_line: #嵌套循环,暂存入栈
push bx
push cx
push si
push di

#设置显示字符串 16 个字符的循环限制
mov cx, 16

#初始化 dx,我们的字符和属性要在 dx 中完成组装
mov dx, 0

#获取属性
mov dh, ds:[bx]

show_row: #获取字符
mov dl, ds:[si]
#设置字符和属性
mov es:[di], dx
add di, 2
inc si
loop show_row

#内部循环结束,寄存器值出栈
pop di
pop si
pop cx
pop bx

#添加一行
add di, 160

#修改属性
inc bx

loop sw_line

ret

# 寄存器初始化函数
init_reg: #设置数据段
mov ax, data
mov ds, ax

#设置显存区域位置
mov bx, 0B800H
mov es, bx
ret

code ends

    注意到我们在 Line 6 - Line 11 的地方把设置数据从哪里来和数据到哪里去的寄存器配置放在了函数外面,这里可以理解为把函数的传参放在了函数外面,这样一来我们就能够在函数外面通过修改参数信息,来使得函数的复用能力更强。

    我们再来看一个问题,在内存中读取字符串并显示在屏幕上,遇到内存单元为 0 时停止读取。我们发现这个问题是和 0 与跳转有关系的问题,自然想到能用 jcxz 来解决,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
data segment
db 'Hello Zobin', 0
data ends

code segment
start: call init_reg

mov si, 0
mov di, 160*10 + 30*2

call show_str

# 显示字符串函数
show_str: mov cx, 0
show: mov cl, ds:[si]
jcxz stop_show
mov es:[di], cl
add di, 2
inc si
jmp show # 无限循环,直到 jcxz 跳出这个循环
stop_show: ret

# 寄存器初始化函数
init_reg: #设置数据段
mov ax, data
mov ds, ax

#设置显存区域位置
mov bx, 0B800H
mov es, bx
ret
code ends

8.5 寄存器旧值保护

    我们现在把问题加大难度:我们现在要求显示 4 行字符串,而非 1 行。我们的代码将如下面所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
data segment
#123456789ABCDEF
db 'Hello Zobin 1 !', 0
db 'Hello Zobin 2 !', 0
db 'Hello Zobin 3 !', 0
db 'Hello Zobin 4 !', 0

# 上面四行数据的偏移
dw 0, 15, 32, 2FH

data ends

code segment
start:
call init_reg
call show_four

mov ax, 4C00H
int 21H

show_four:
# 数据段中,存储各行偏移量的那部分数据的偏移量
mov bx, 3DH

# 设置数据要到哪去的初始值(显存偏移量起始位置)
mov di, 160*10 + 30*2

# 一共有四行
mov cx, 4


show_one: # 设置读取位置
mov si, ds:[bx+0]
call show_str
add di, 160
add bx, 2
loop show_one


# 显示字符串函数
show_str:
# 保护函数外写入这些寄存器的数据
push cx
push ds
push es
push si
push di

mov cx, 0
show: mov cl, ds:[si]
jcxz stop_show
mov es:[di], cl
add di, 2
inc si
jmp show # 无限循环,直到 jcxz 跳出这个循环
stop_show:
# 恢复函数外写入这些寄存器的数据
pop di
pop si
pop es
pop ds
pop cx
ret

# 寄存器初始化函数
init_reg: #设置数据段
mov ax, data
mov ds, ax

#设置显存区域位置
mov bx, 0B800H
mov es, bx
ret
code ends

    注意到我们的方案是使用一个外层函数 show_four 去调用 show_str 函数 (Line 34),show_four 函数中会有一个四次的循环,每次循环会显示一行数据。这里就会产生一个问题,我们在外层的四次循环中会使用到 cx 寄存器,我们在 show_str 中的 jcxz 指令也会使用到 cx 寄存器,这样一来就会产生冲突。因此可以注意到我们在 Line 43 - 47 的位置通过 push 的方式保护了我们在 show_str 中使用到的寄存器的原始值,然后在 Line 58 - 62 的位置通过 pop 的方式恢复了这些寄存器的原始值。这也就是我们这段代码想要强调的一个重点:在编写函数块代码的时候要养成保护寄存器原始值的习惯!

    但是我们也不能够那么死板的去保护所有在函数中使用到的寄存器的值,我们考虑下面的例子:编写一个函数,实现计算一个数的立方的功能。我们的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
data segment
db 0 dup(8)
data ends

code segment
start: mov ax, data
mov ds, ax

mov di, 0

mov bx, 5
call get_cube

mov es:[di+0], ax
mov es:[di+2], dx

mov ax, 4C00H
int 21H

get_cube:
push bx

mov ax, bx
mul bx
mul bx # 这里存在疑问:上一行 mul 的结果存在 ax 和 dx 中,然后这里只使用 ax (低 16 位)进行计算?

pop bx
ret
code ends

    在上面的代码中,注意到虽然我们在 get_cube 函数中使用到了 bx, ax, dx(乘法结果的高 16 位存放在了 dx 中),但是实际上我们只保护了 bx 寄存器。原因是如果我们对 ax 和 dx 寄存器也使用 push 和 pop 进行保护和恢复,则我们必须在 get_cube 函数内对乘法结果进行存储。这样一来,我们的 get_cube 函数的功能就不再单一,因此功能会变得混乱。我们完全可以不保护 ax 和 dx 寄存器,因为我们正是想使用 get_cube 函数去修改这两个寄存器的值。