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

ret 和 retf

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

CPU 指令 ret 指令时,相当于:

1
pop IP

CPU 指令 retf 指令时,相当于:

1
2
pop IP
pop CS

示例代码:

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
push cs
push ax
mov bx,0
retf ;执行后 CS:IP 指向第一条指令,程序退出
code ends

end start

call 指令

1
call 标号

将当前的 IP 压栈后,转到「标号」处执行指令。

  1. (SP) = (SP) - 2, ((SS)*16 + (SP)) = (IP)
  2. (IP) = (IP) + 16 位位移

相当于:

1
2
push IP
jmp near ptr 标号

机器指令中没有转移的目的地址,是相当于当前 IP 的转移位移

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

1
call far ptr 标号

实现的是段间转移。CPU 执行指令时进行如下操作:

  1. (SP) = (SP) - 2
    ((SS)*16 + (SP)) = (CS) CS 入栈
    (SP) = (SP) - 2
    ((SS)*16 + (SP)) = (IP) IP 入栈
  2. (CS) = 标号所在的段的地址
    (IP) = 标号所在的段的偏移地址

相当于:

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

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

1
call 16 位 reg

当前 IP 入栈

(SP) = (SP) - 2
((SS)*16 + (SP)) = (IP)
(IP) = (16 位 reg)

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

只修改 IP

1
call word ptr 内存单元地址

相当于:

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

同时修改 CS 和 IP

1
call dword ptr 内存单元地址

相当于:

1
2
3
push CS
push IP
jmp dword ptr 内存单元地址

检测点 10.5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
assume cs:code
data segment
dw 8 dup (0)
data ends
code segment
start: mov ax,data
mov ss,ax
mov sp,16
mov word ptr ss:[0],offset s
mov ss:[2],cs
call dword ptr ss:[0]
nop
s: mov ax,offset s
sub ax,ss
mov bx,cs
sub bx,ss:[0eh]
mov ax,4c00h
int 21h
code ends
end start

求 ax,bx 中的值。

ax = 1,bx = 0.

提示:

  1. 栈操作每次操作的单位是一个字,CS 地址是一个字,IP 地址是一个字。
  2. nop 占一个字节。
  3. 执行 call dword ptr 命令,会把 call 命令下一条的 CS、IP 入栈保存

call 和 ret 的配合使用

示例程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
assume cs:code
code segment
start: mov ax,1
mov cx,3
call s
mov bx,ax
mov ax,4c00h
int 21h
s: add ax,ax
loop s
ret
code ends
end start

可以写一个具有一定功能的程序段,称之为子程序,在需要的时候用 call 指令转去执行,执行完之后,子程序后面使用 ret 指令,再转移回 call 指令后面的代码继续执行。

子程序的框架:

1
2
3
标号:
指令
ret

mul 指令

mul 是乘法指令,使用 mul 做乘法时,需要注意以下两点:

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

指令格式:

先把第一个数放到 AX 或 AL,然后指定另一个数的位置:

1
2
mul reg ;另一个数在寄存器里
mul 内存单元 ;另一个数在内存单元里

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

1
mul byte ptr ds:[0]

含义:(ax) = (al) * ((ds)*16+0)

1
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
mov al,100
mov bl,10
mul bl

结果:(ax) = 1000

计算 100 * 10000

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

结果:(ax) = 4240h,(dx) = 000fh。f4340h = 1000000

参数和结果传递的问题

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

计算一组数据的三次方,将结果保存在一组 dword 单元中

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
assume cs:code
data segment
dw 1,2,3,4,5,6,7,8
dd 0,0,0,0,0,0,0,0
data ends

code segment
start: mov ax,data
mov ds,ax
mov si,0 ;ds:si 指向第一组 word 单元
mov di,16 ;ds:di 指向第二组 dword 单元

mov cx,8
s: mov bx,[si]
call cube
mov [di],ax
mov [di].2,dx
add si,2 ;让 ds:si 指向下一个 word 单元,准备读取要计算下一个数字
add di,4 ;让 ds:di 指向下一个 dword 单元,准备记录下一个结果
loop s

mov ax,4c00h
int 21h

cube: mov ax,bx
mul bx
mul bx
ret
code ends
end start

批量数据的传递

寄存器数目有限,不能简单地用寄存器来存放多个需要传递的数据,对于返回值也有同样的问题。

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

将一个全是字母的字符串转化为大写:

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

code segment
start: mov ax,data
mov ds,ax
mov si,0 ;ds:si 指向字符串所在空间的首地址
mov cx,12 ;cx 存放字符串的长度
call capital
mov ax,4c00h
capital: and byte ptr [si], 11011111b ;将第 6 位设置为 0,就可以实现转大写,例:a 97 -> A 65,差 32
inc si
loop capital
ret
code ends
end start

还有一种通用的方法是用栈来传递参数。

原理是,由调用者将需要传递给子程序的参数压入栈中,子程序从栈中取得参数。

1
2
3
4
5
6
7
8
difcube: push bp
mov bp,sp
mov ax,[bp+4]
sub ax,[bp+6]
mul bp
mul bp
pop bp
ret 4

ret n 指令的含义:

1
2
pop IP
add SP,n

将 IP 地址从栈中弹出,恢复到 IP 寄存器,删掉栈顶的 n 个字节,从而将栈顶指针恢复成调用前的值。

调用上面代码的方法:

1
2
3
4
5
mov ax,1
push ax,1
mov ax,3
push ax ;注意参数压栈顺序
push ax\call difcube

寄存器冲突的问题

编写子程序时,要先将子程序要使用的寄存器内容保存一份到栈中,在返回之前,将栈中的内容再恢复到寄存器,避免子程序污染寄存器,影响主程序。记录现场,恢复现场。

编写子程序的标准框架:

1
2
3
4
子程序开始:子程序中使用的寄存器入栈
子程序内容
子程序中使用的寄存器出栈
返回(ret、retf)

实验 10 编写子程序

10.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
assume cs:code,ds:data
data segment
db 'Welcome to masm!',0 ;定义一个以0结尾的字符串
data ends

code segment
start: mov dh,2
mov dl,1
mov cl,2
mov ax,data
mov ds,ax
mov si,0
call show_str
mov ax,4c00h
int 21h

show_str: push es
push dx
push ax
push bx
mov ax,0b800h
mov es,ax
;dec dh 故意不减一,用于抵消 DOS 系统副作用,见下文分析
dec dl
mov al,160
mul dh ;行的偏移量
mov bx,ax
mov al,2
mul dl ;列的偏移量
add bx,ax
mov al,cl ;把颜色存在al里面,下面要用到cl

s: mov cl,ds:[si] ;读入当前字符
jcxz ok ;判断是否到达字符串末尾
mov es:[bx+di],cl ;将字符送入显存
mov es:[bx+di+1],al ;将颜色送入显存
inc si
add di,2
loop s
ok: pop bx
pop ax
pop dx
pop es
ret
code ends
end start

这个程序有两个细节,外部传入的行列参数都是从 1 开始的,在计算偏移量的时候,行、列要减一。

但考虑到这点,编写代码后有一个疑问,为什么 dh 是 1 的时候终端显示不出来内容呢?只有 dh 是 2 的时候才能在终端的第一行打印出内容,整个差了一行,为什么?

这个问题花费了我数个小时去探索,为此我还安装了纯 DOS 虚拟机环境。是程序写的有问题吗?偏移地址计算错误吗?是行列参数差一错误吗?

实际上是 DOS 系统自身的问题,去掉 mov ax,4c00hint 21h 两条指令,再运行,就能够显示出来了。

在终端执行命令 exp10.exe 结束的时候,终端窗口的内容会向上滚动,DOS 实现这种效果也是通过读写 b800h ~ b8f9fh 的显存来完成的。可以猜测过程大概如下:

  1. 在终端输入 exp10.exe,点击回车后,DOS 读写显存,让终端内容向下滚动一行。
  2. DOS 将 CPU 执行权交给 exp10.exe。
  3. exp10.exe 读写显存,在终端窗口第一行显示文字。
  4. exp10.exe 将 CPU 执行权交给 DOS。
  5. DOS 读写显存,让终端内容向下滚动一行。

这样一来,我们在第一行显示的内容就让 DOS 滚动终端信息的动作冲掉了。

所以实际上我们要从第二行开始写入,这样 DOS 修改显存让终端信息整体向上移动一行后,我们显示的内容正好是输入参数指定的位置。

这个程序没有办法使用 debug.exe 来调试看效果,debug.exe 输出调试信息的基本原理就是修改显存。所谓“观测会影响实验结果”,颇有一些量子力学的味道。

排除所有不可能的因素之后,无论剩下的多么难以置信,那就是真相。

10.2 解决除法溢出的问题

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
assume cs:code,ds:data,ss,stack
data segment
dd 1000000
data ends

stack segment
db 128 dup (0)
stack ends

code segment
start:
mov ax,data ;初始化数据段
mov ds,ax
mov ax,stack ;初始化栈段
mov ss,ax
mov sp,100

mov ax,ds:[0] ;被除数低 16 位
mov dx,ds:[2] ;被除数高 16 位
mov cx,10 ;除数

push ax ;暂存 ax
mov bp,sp ;之后可以用 bp 访问栈内的数据
call divdw
mov ax,4c00h
int 21h

divdw:
mov ax,dx
mov dx,0
div cx ;ax = int(H/N) => dx dx = rem(H/N)*65536
push ax ;暂存一下结果的高 16 位
mov ax,ss:[bp+0] ;L
div cx ;dx:ax 就是是 rem(H/N)*65536+L,运算后 ax = [rem(H/N)*65536+L]/N 的商,是最终结果商的低 16 位 dx = 余数
mov cx,dx ;余数送入 cx 寄存器
pop dx ;结果商的高 16 位送入 dx 寄存器
ret
code ends
end start

10.3 数值显示

编写一个整数转字符串的程序。

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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
assume cs:code,ds:data,ss:stack
data segment ;用于存储数字转换后的字符串数据
db 10 dup (0)
data ends

stack segment
db 128 dup (0)
stack ends

code segment
start:
mov ax,data ;初始化数据段
mov ds,ax
mov ax,stack ;初始化栈段
mov ss,ax
mov sp,100

mov ax,12666 ;传入参数,要显示的数字和字符串首地址
mov si,0
call dtoc

mov dh,8 ;定义行(1-25)
mov dl,3 ;定义列(1-80)
mov cl,2 ;定义颜色
call show_str ;调用子程序,打印字符

mov ax,4c00h
int 21h

dtoc: push dx
push cx
push ax
push si
push bx

mov bx,0 ;bx 存放位数,用栈来存放数字转成的字符串

s1: mov cx,10d ;d 表示 10 进制,cx 作为除数
mov dx,0

div cx ;除以 10
mov cx,ax ;得到商赋给 cx
jcxz s2 ;如果商为 0 就结束

add dx,30h ;将余数加上 30h 得到相应的 ascii 码
push dx

inc bx
jmp short s1

s2: add dx,30h ;尾处理,当商为 0 时,余数为个位
push dx
inc bx ;再一次进行栈操作(商为 0 而余数不为 0 的情况)

mov cx,bx ;总共有 bx 位进栈了,所以要循环次数为 bx
mov si,0

s3: pop ax ;依次出栈将数据存放到指定的数据段中(内存中)

mov [si],al
inc si

loop s3

pop bx
pop si
pop ax
pop cx
pop dx

ret

show_str: push es
push dx
push ax
push bx
mov ax,0b800h
mov es,ax
;dec dh 故意不减一,用于抵消 DOS 系统副作用
dec dl
mov al,160
mul dh ;行的偏移量
mov bx,ax
mov al,2
mul dl ;列的偏移量
add bx,ax
mov al,cl ;把颜色存在al里面,下面要用到cl

s: mov cl,ds:[si] ;读入当前字符
jcxz ok ;判断是否到达字符串末尾
mov es:[bx+di],cl ;将字符送入显存
mov es:[bx+di+1],al ;将颜色送入显存
inc si
add di,2
loop s
ok: pop bx
pop ax
pop dx
pop es
ret
code ends
end start