汇编语言:子程序的实现


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

image

考虑一个使 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

image

ret / retf 在现代 x86 架构中被延续,ret near 对应 pop rip,ret far 对应 retf。只不过在 64 位模式下,远返回被严格限制,绝大多数函数调用都是近返回。此外,现代编译器在函数序言和尾声中会插入栈帧建立代码(push rbp; mov rbp, rsp),与 8086 中手动维护 SS:SP 的思想是一致的。

call 指令

call 指令主要用于实现子程序调用,CPU 执行 call 时固定做两件事:

  1. 保存返回地址:把当前 IP(段内调用)或 CS:IP(段间调用)压入栈中;
  2. 执行转移:跳转到子程序入口。

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。
image

考虑如下一个程序,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。
image

子程序的实现

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。
image

基于上述机制,可以编写一段具有一定功能的子程序:

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:
image

计算:16 位乘法 100 × 10000:

mov ax, 64H
mov bx, 2710H
mul bx

结果为 DX:AX = 000F4240H = 1000000。
image

在现代体系结构中,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 字节。
image

批量数据的传递

前面的 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';
image

寄存器冲突

子程序用到的寄存器,主程序很可能也在用。例如如下程序中使用了一个循环调用子程序:

; 将多个以 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,返回后寄存器状态与原先一致,不影响主程序。
image

至此可以总结出子程序的编写框架:

子程序名:
    ; 保存寄存器状态
    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 的延伸。这样就不难理解为什么现代程序栈溢出会导致函数返回地址被覆盖,从而引发安全问题。

参考资料

《汇编语言(第四版)》,王爽 编著,清华大学出版社

posted @ 2026-08-14 01:20  乌漆WhiteMoon  阅读(13)  评论(0)    收藏  举报