call 和 ret 都是转移指令,它们都修改 IP 或同时修改 CS 和 IP。共同用来实现子程序。
ret 和 retf
ret 指令用栈中的数据,修改 IP 的内容,实现近转移。
retf 指令用栈中的数据,修改 CS 和 IP 的内容,实现远转移。
CPU 指令 ret 指令时,相当于:
1 | pop IP |
CPU 指令 retf 指令时,相当于:
1 | pop IP |
示例代码:
1 | cs:code |
call 指令
1 | call 标号 |
将当前的 IP 压栈后,转到「标号」处执行指令。
- (SP) = (SP) - 2, ((SS)*16 + (SP)) = (IP)
- (IP) = (IP) + 16 位位移
相当于:
1 | push IP |
机器指令中没有转移的目的地址,是相当于当前 IP 的转移位移
转移的目的地址在指令中的 call 指令
1 | call far ptr 标号 |
实现的是段间转移。CPU 执行指令时进行如下操作:
- (SP) = (SP) - 2
((SS)*16 + (SP)) = (CS) CS 入栈
(SP) = (SP) - 2
((SS)*16 + (SP)) = (IP) IP 入栈 - (CS) = 标号所在的段的地址
(IP) = 标号所在的段的偏移地址
相当于:
1 | push CS |
转移地址在寄存器中的 call 指令
1 | call 16 位 reg |
当前 IP 入栈
(SP) = (SP) - 2
((SS)*16 + (SP)) = (IP)
(IP) = (16 位 reg)
转移地址在内存中的 call 指令
只修改 IP
1 | call word ptr 内存单元地址 |
相当于:
1 | push IP |
同时修改 CS 和 IP
1 | call dword ptr 内存单元地址 |
相当于:
1 | push CS |
检测点 10.5
1 | cs:code |
求 ax,bx 中的值。
ax = 1,bx = 0.
提示:
- 栈操作每次操作的单位是一个字,CS 地址是一个字,IP 地址是一个字。
- nop 占一个字节。
- 执行 call dword ptr 命令,会把 call 命令下一条的 CS、IP 入栈保存
call 和 ret 的配合使用
示例程序:
1 | cs:code |
可以写一个具有一定功能的程序段,称之为子程序,在需要的时候用 call 指令转去执行,执行完之后,子程序后面使用 ret 指令,再转移回 call 指令后面的代码继续执行。
子程序的框架:
1 | 标号: |
mul 指令
mul 是乘法指令,使用 mul 做乘法时,需要注意以下两点:
- 两个相乘的数:要么都是 8 位,要么都是 16 位。
- 如果是 8 位,一个默认放在 AL,另一个放在 8 位 reg 或内存字节单元中
- 如果是 16 位,一个默认放在 AX,另一个放在 16 位 reg 或内存字单元中
- 结果:
- 如果是 8 位乘法,结果默认放在 AX 中
- 如果是 16 位乘法,结果高位默认放在 DX,低位放在 AX
指令格式:
先把第一个数放到 AX 或 AL,然后指定另一个数的位置:
1 | mul reg ;另一个数在寄存器里 |
内存单元可以用不同的寻址方式给出:
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 | mov al,100 |
结果:(ax) = 1000
计算 100 * 10000
1 | mov ax, 100 |
结果:(ax) = 4240h,(dx) = 000fh。f4340h = 1000000
参数和结果传递的问题
用寄存器来存储参数和结果是最常用的方法。
计算一组数据的三次方,将结果保存在一组 dword 单元中
1 | cs:code |
批量数据的传递
寄存器数目有限,不能简单地用寄存器来存放多个需要传递的数据,对于返回值也有同样的问题。
我们可以批量将数据放到内存,然后将它们所在的内存空间的首地址放在寄存器中,传递给需要的子程序。对于具有批量数据的返回结果,也可用同样的方法。
将一个全是字母的字符串转化为大写:
1 | cs:code |
还有一种通用的方法是用栈来传递参数。
原理是,由调用者将需要传递给子程序的参数压入栈中,子程序从栈中取得参数。
1 | difcube: push bp |
ret n 指令的含义:
1 | pop IP |
将 IP 地址从栈中弹出,恢复到 IP 寄存器,删掉栈顶的 n 个字节,从而将栈顶指针恢复成调用前的值。
调用上面代码的方法:
1 | mov ax,1 |
寄存器冲突的问题
编写子程序时,要先将子程序要使用的寄存器内容保存一份到栈中,在返回之前,将栈中的内容再恢复到寄存器,避免子程序污染寄存器,影响主程序。记录现场,恢复现场。
编写子程序的标准框架:
1 | 子程序开始:子程序中使用的寄存器入栈 |
实验 10 编写子程序
10.1 显示字符串
1 | cs:code,ds:data |
这个程序有两个细节,外部传入的行列参数都是从 1 开始的,在计算偏移量的时候,行、列要减一。
但考虑到这点,编写代码后有一个疑问,为什么 dh 是 1 的时候终端显示不出来内容呢?只有 dh 是 2 的时候才能在终端的第一行打印出内容,整个差了一行,为什么?
这个问题花费了我数个小时去探索,为此我还安装了纯 DOS 虚拟机环境。是程序写的有问题吗?偏移地址计算错误吗?是行列参数差一错误吗?
实际上是 DOS 系统自身的问题,去掉 mov ax,4c00h
和 int 21h
两条指令,再运行,就能够显示出来了。
在终端执行命令 exp10.exe
结束的时候,终端窗口的内容会向上滚动,DOS 实现这种效果也是通过读写 b800h ~ b8f9fh 的显存来完成的。可以猜测过程大概如下:
- 在终端输入
exp10.exe
,点击回车后,DOS 读写显存,让终端内容向下滚动一行。 - DOS 将 CPU 执行权交给 exp10.exe。
- exp10.exe 读写显存,在终端窗口第一行显示文字。
- exp10.exe 将 CPU 执行权交给 DOS。
- DOS 读写显存,让终端内容向下滚动一行。
这样一来,我们在第一行显示的内容就让 DOS 滚动终端信息的动作冲掉了。
所以实际上我们要从第二行开始写入,这样 DOS 修改显存让终端信息整体向上移动一行后,我们显示的内容正好是输入参数指定的位置。
这个程序没有办法使用 debug.exe 来调试看效果,debug.exe 输出调试信息的基本原理就是修改显存。所谓“观测会影响实验结果”,颇有一些量子力学的味道。
排除所有不可能的因素之后,无论剩下的多么难以置信,那就是真相。
10.2 解决除法溢出的问题
1 | cs:code,ds:data,ss,stack |
10.3 数值显示
编写一个整数转字符串的程序。
1 | cs:code,ds:data,ss:stack |