汇编语言:子程序的实现
call 和 ret 是带返回的转移指令,和其他转移指令的区别在于:它们在跳转之前会先把返回地址压入栈中。
ret 与 retf 指令
ret 和 retf 都是利用栈数据来修改 IP(或 CS:IP)的转移指令,区别在于作用范围和修改对象不同。
| 指令 | 功能 |
|---|---|
| ret | 实现段内近转移,只从栈中恢复 IP,不涉及 CS。 |
| retf | 实现段间远转移,先从栈中恢复 IP,再恢复 CS。 |
从 CPU 执行角度看,这两条指令本质上就是出栈操作的封装:
- 执行
ret:CPU 先令(IP) = (SS × 16 + SP),即从栈顶读取一个字作为新的 IP,然后执行(SP) = (SP) + 2令栈顶上移,用汇编伪代码表示就是pop IP。 - 执行
retf:CPU 先执行(IP) = (SS × 16 + SP)和(SP) = (SP) + 2,然后再对(CS) = (SS × 16 + SP)进行操作并移动栈顶(SP) = (SP) + 2,用汇编伪代码表示就是:pop IP然后pop CS。
以下程序对 ret 进行展示,在初始化栈段时将 0 压入栈中,随后执行 ret。由于栈顶数据是 0000H,ret 将其弹出并赋给 IP,因此执行完 ret 后 (IP) = 0,CS:IP 指向代码段第一条指令。
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 ax
mov bx,0
ret
code ends
end start

考虑一个使 CPU 从内存 1000:0000 处开始执行指令的程序,先将 1000H 压栈作为 CS,再将 0000H 压栈作为 IP,随后执行 retf。CPU 就会从栈中取出这两个值,跳转到 1000:0000 处执行。
assume cs:code, ss:stack
stack segment
db 16 dup (0)
stack ends
code segment
start:
mov ax, stack
mov ss, ax
mov sp, 16
mov ax, 1000H
push ax
mov ax, 0000H
push ax
retf
code ends
end start

ret/retf在现代 x86 架构中被延续,ret near对应pop rip,ret far对应retf。只不过在 64 位模式下,远返回被严格限制,绝大多数函数调用都是近返回。此外,现代编译器在函数序言和尾声中会插入栈帧建立代码(push rbp; mov rbp, rsp),与 8086 中手动维护 SS:SP 的思想是一致的。
call 指令
call 指令主要用于实现子程序调用,CPU 执行 call 时固定做两件事:
- 保存返回地址:把当前 IP(段内调用)或 CS:IP(段间调用)压入栈中;
- 执行转移:跳转到子程序入口。
call 指令和 jmp 的区别就是跳转之前先把返回地址入栈,即 call = push 返回地址 + jmp。call 不支持短转移,即该指令不会产生l类似 E8 xx(8 位位移)的机器码,最小也是 16 位位移(E8 xx xx)。call 的转移机制与 jmp 一致,可以是基于位移、基于指令中的地址、基于寄存器或基于内存。
段内近转移的指定格式是 call 标号,对应的机器码中只有 16 位位移,没有绝对地址。CPU 实际执行的操作是移动栈顶 SP -= 2 后保存返回地址 SS:[SP] = IP,接着执行跳转 IP = IP + 16 位位移。位移的计算方式与 jmp near ptr 相同,即:16 位位移 = 标号地址 − call 指令后第一个字节的地址。用汇编伪代码表示就是:
push IP
jmp near ptr 标号
段间远转移的格式是 call far ptr 标号,它在跳转前会把 CS 和 IP 按顺序压栈,顺序与 retf 弹出顺序相反。CPU 实际操作等价于:
push CS
push IP
jmp far ptr 标号
段内间接调用的格式是 call 16位reg,它常用于根据运行结果动态调用不同子程序,例如函数指针、跳转表等。执行过程为:
push IP
jmp 16位reg
转移地址在内存中时,call 指令也与 jmp 的内存形式对应,只是多了一步压栈返回地址。段内调用时格式为 call word ptr 内存单元,等价于:
push IP
jmp word ptr 内存单元
段间调用的格式为 call dword ptr 内存单元,等价于:
push CS
push IP
jmp dword ptr 内存单元
考虑如下一个程序,栈段大小为 16 字节,初始 SP = 0010H。ds:[0EH] 指向栈中倒数第二个字(偏移 0EH),栈初始化为 0,因此 ds:[0EH] = 0000H。call word ptr ds:[0EH] 指令会压入返回地址 IP = 下一条指令(inc ax),然后跳转到 0000H。
assume cs:code, ss:stack
stack segment
dw 8 dup (0)
stack ends
code segment
start:
mov ax, stack
mov ss, ax
mov sp, 16
mov ds, ax
mov ax, 0
call word ptr ds:[0EH]
inc ax
inc ax
inc ax
mov ax,4c00h
int 21h
code ends
end start
0000H 处是 mov ax, stack 之类初始化代码,程序会再执行这些代码一遍然后会第二次运行到 call word ptr ds:[0EH]。此时 ds:[0EH] 的数据是之前压入栈中的下一条指令 inc ax 的偏移,因此跳转之后就回到三次 inc ax 的位置,最终得到 AX = 0003H。

考虑如下一个程序,call dword ptr ss:[0] 指令会压栈 CS 和 IP(call 下一条指令),然后跳转到 s 处。此时 ss:[0CH] 中是刚压入的 IP,ss:[0EH] 中是刚压入的 CS,mov ax, offset s 得到标号 s 的偏移。
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 word ptr ss:[2], cs
call dword ptr ss:[0]
nop
s: mov ax, offset s
sub ax, ss:[0CH]
mov bx, cs
sub bx, ss:[0EH]
mov ax,4c00h
int 21h
code ends
end start
sub ax, ss:[0CH] 执行的是 AX = 标号偏移 − 返回地址偏移 = nop 指令长度,因此 AX = 0001H。mov bx, cs 得到当前 CS,sub bx, ss:[0EH] 执行的是 BX = 当前 CS − 原 CS = 0。

子程序的实现
call 和 ret 指令的小结如下,通过 call 指令记录返回的地址,ret 指令返回原来的位置,即可实现子程序。
| 指令 | 本质等价操作 | 是否改 CS | 返回地址来源 |
|---|---|---|---|
call near ptr |
push IP; jmp near ptr |
否 | IP(call 的下一条指令) |
call far ptr |
push CS; push IP; jmp far ptr |
是 | CS:IP |
call reg |
push IP; jmp reg |
否 | IP |
call word ptr |
push IP; jmp word ptr |
否 | IP |
call dword ptr |
push CS; push IP; jmp dword ptr |
是 | CS:IP |
ret |
pop IP |
否 | 栈顶 |
retf |
pop IP; pop CS |
是 | 栈顶 |
考虑一个计算 \(2^N\) 的子程序,其中 \(N\) 由 CX 提供。首先通过 mov ax, 1 初始化 AX = 1,用 mov cx, 3 初始化循环次数 CX = 3。接着执行 call s,CPU 先把 mov bx, ax 的地址压入栈,然后将 IP 设置为标号 s 的偏移地址跳转到子程序。进入标号 s 后执行 add ax, ax 令 AX 左移一位,相当于乘以 2。然后进入循环 loop s,若 CX ≠ 0 时跳转回 add ax, ax,循环 3 次后的 AX = 1 × 2³ = 8。
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
执行 ret 后 CPU 从栈中弹出之前保存的返回地址送入 IP,CS:IP 重新指向 mov bx, ax。最后 mov bx, ax 使得 BX = AX = 8,因此程序返回前的 BX = 0008H。

基于上述机制,可以编写一段具有一定功能的子程序:
sub1:
; 子程序体
ret
在主程序中需要使用该子程序时,可以通过 call 来使用,使用结束后将通过 ret 返回:
main:
call sub1
; 其他逻辑
如果子程序内部还需要调用别的子程序(即嵌套调用),框架会变成:
sub1:
call sub2
; 子程序 sub1 的其他逻辑
ret
sub2:
; 子程序 sub2 的逻辑
ret
每一次 call 都会往栈里压一个返回地址,每一次 ret 都会从栈里弹出一个返回地址。
虽然现代高级语言(C/C++、Rust 等)的函数调用远比这复杂,但仍然可见:call ≈ 函数调用、ret ≈ 函数返回、寄存器传参 ≈ fastcall / 部分 ABI、栈保存返回地址 ≈ 现代栈帧中的返回地址。现代 CPU 还在此基础上增加了
call/ret的预测优化、栈帧指针(RBP)、影子空间等机制,但调用前保存返回地址、返回时恢复的运行方式是一样的。
mul 指令
mul 是 8086 中唯一的乘法指令,它要求操作数长度必须一致,即两个乘数要么都是 8 位,要么都是 16 位。两种乘法方式的操作数和结果存放位置如下:
| 乘法 | 操作数 | 结果位置 |
|---|---|---|
| 8 位乘法 | 一个乘数在 AL 中,另一个在 8 位寄存器或内存字节单元中 | 结果放在 AX 中 |
| 16 位乘法 | 一个乘数在 AX 中,另一个在 16 位寄存器或内存字单元中 | 低 16 位在 AX,高 16 位在 DX |
例如计算 8 位乘法 100 × 10:
mov al, 64H
mov bl, AH
mul bl
结果为 AX = 03E8H = 1000:

计算:16 位乘法 100 × 10000:
mov ax, 64H
mov bx, 2710H
mul bx
结果为 DX:AX = 000F4240H = 1000000。

在现代体系结构中,mul 指令早已被更灵活的
imul取代,支持多种操作数形式。
汇编的模块化程序设计
使用 call 和 ret 可以实现模块化编程,对于一块独立功能可以写成子程序,在通过 call / ret 把它们组合起来。
参数和结果传递
子程序在调用时需要交换输入参数和返回结果,即程序的输入和输出。在 8086 中,最常见、最简单的传递方式就是使用寄存器。例如编写一个计算 N 的三次方的子程序,令输入参数 N 存放在 BX 中,返回值存放在 DX:AX 中。子程序如下代码:
; 功能:计算 N 的 3 次方
; 参数:(bx) = N
; 返回:(dx:ax) = N^3
cube:
mov ax, bx ; AX = N
mul bx ; DX:AX = N * N
mul bx ; DX:AX = N^2 * N = N^3
ret
调用子程序时,程序需要把参数放进 BX,调用完后从 DX:AX 取结果。子程序会从 BX 取参数,然后把结果写回 DX:AX。
assume cs:code, ds:data
data segment
dw 1,2,3,4,5,6,7,8 ; N
dd 0,0,0,0,0,0,0,0 ; N^3
data ends
code segment
start:
mov ax, data
mov ds, ax
mov si, 0 ; si 指向第一组 word
mov di, 16 ; di 指向第二组 dword(8*2=16)
mov cx, 8 ; 循环 8 次
s:
mov bx, [si] ; 取 N
call cube ; 计算 N^3
mov [di], ax ; 保存低 16 位
mov [di+2], dx ; 保存高 16 位
add si, 2 ; 下一个 word
add di, 4 ; 下一个 dword
loop s
mov ax, 4c00h
int 21h
cube:
mov ax, bx
mul bx
mul bx
ret
code ends
end start
运行后,内存第二组双字单元中依次保存 1, 8, 27, 64, 125, 216, 343, 512,每个结果占 4 字节。

批量数据的传递
前面的 cube 子程序只有一个参数 N,用 BX 一个寄存器就够了。但如果参数变多,比如要把一个 100 字节的字符串传给子程序,显然不可能用 100 个寄存器。解决方案是把批量数据放在内存中,只把内存首地址和数据长度传给子程序。
例如考虑一个将字符串转为大写的子程序,ASCII 码中的小写字母与大写字母的区别仅在 bit 5(大写 'A' = 01000001B,小写 'a' = 01100001B)。因此将小写转为大写,只需将对应字节的 bit 5 清零。程序中数据的首地址通过 si 传递,长度通过 cx 传递,子程序通过 loop 遍历整个字符串。
assume cs:code, ds:data
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
int 21h
capital:
and byte ptr [si], 11011111b
inc si
loop capital
ret
code ends
end start
运行程序后, data 段中的字符串由 'conversation' 变为 'CONVERSATION';

寄存器冲突
子程序用到的寄存器,主程序很可能也在用。例如如下程序中使用了一个循环调用子程序:
; 将多个以 0 结尾的字符串转为大写
mov cx, 4
s:
mov si, bx
call capital
add bx, 5
loop s
子程序 capital 内部也使用 cx 来判断字符串是否结束,当子程序执行完返回时,cx 已经被改成 0,主程序的 loop s 将失效。
capital:
mov cl, [si]
mov ch, 0
jcxz ok ;检测 0
and byte ptr [si], 11011111b
inc si
jmp short capital
ok: ret
寄存器冲突问题很难靠自觉解决。如果要求调用者避开子程序用到的寄存器,则他必须逐个检查子程序实现,成本太高,不可维护。当要求子程序不使用调用者的寄存器时,子程序也不知道将来谁会调用它,不可能提前规避。因此需要调用子程序时,先把用到的寄存器压栈;返回前再恢复原样。**改进后的 capital 程序如下:
assume cs:code
data segment
db 'word', 0
db 'unix', 0
db 'wind', 0
db 'good', 0
data ends
code segment
start:
mov ax, data
mov ds, ax
mov bx, 0
mov cx, 4
s: mov si, bx
call capital
add bx, 5
loop s
mov ax, 4c00h
int 21h
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
code ends
end start
子程序在调用时按使用顺序压栈 cx、si,返回时按照相反顺序出栈 si、cx。这样子程序在内部就能使用 cx、si,返回后寄存器状态与原先一致,不影响主程序。

至此可以总结出子程序的编写框架:
子程序名:
; 保存寄存器状态
push reg1
push reg2
...
; 子程序体
...
; 恢复寄存器状态
pop reg2
pop reg1
...
ret
在现代体系结构中,通过栈维护寄存器状态的思想演化为函数序言(prologue)和尾声(epilogue),以及 ABI 中对 caller-saved / callee-saved 寄存器的划分。C 语言函数在编译后生成的汇编代码,依然能看到
push rbp; mov rbp, rsp的保护程序状态的操作。高级语言中的局部变量、栈帧,本质上就是 8086 中push/pop的延伸。这样就不难理解为什么现代程序栈溢出会导致函数返回地址被覆盖,从而引发安全问题。
参考资料
《汇编语言(第四版)》,王爽 编著,清华大学出版社

浙公网安备 33010602011771号