Skip to content

第8讲:堆栈段与函数调用

本周核心:理解栈段的定义和作用,掌握 call/ret 的执行机制,学会用栈传递参数。


一、堆栈段的定义

1.1 为什么需要栈段?

栈用于:

  • 保存函数返回地址(call/ret)
  • 传递函数参数
  • 保存局部变量
  • 保存寄存器的值
asm
stk segment stack           ; ★ stack 关键字告诉链接器这是栈段
db 100h dup('S')            ; 100h 字节的栈空间(dup = duplicate 重复)
stk ends

如果源程序不定义栈段,DOS 会让 ss = 程序首段的段地址,sp = 0,此时栈从段末尾往下长。

1.2 程序启动时的寄存器状态

(1) 程序开始执行时:
    ds = es = 首段的段地址 - 10h(即 PSP 的段地址)
    PSP (Program Segment Prefix) 是 DOS 加载程序时创建的
    100h 字节的前缀块,位于首段前一个内存块
    byte ptr PSP:[80h] = 命令行参数的长度
    PSP:81h 开始 = 命令行参数的内容

(2) ss = 栈段的段地址, sp = 栈的大小

(3) cs = 代码段的段地址, ip = main 的偏移地址

1.3 栈段内存布局示例

假设 data 段地址 = 1000h,段长度 = 10h,code 段长度 = 20h:

1000:0000 ~ 1000:000F   数据段
1000:0010 ~ 1000:002F   代码段
1000:0030 ~ 1000:FFFF   ← 空闲空间(可被栈使用)
2000:0000 ~ 9000:FFFF   也属于当前程序(DOS 分配)

二、多个数据段的访问

当程序需要多个数据段时:

asm
data1 segment
abc dw 1234h, 5678h
data1 ends

data2 segment
xyz dw 89ABh, 0CDEFh
data2 ends

code segment
assume cs:code, ds:data1, es:data2
main:
   mov ax, data1
   mov ds, ax         ; ds → data1
   mov ax, data2
   mov es, ax         ; es → data2
   mov ax, abc[0]     ; 编译成: mov ax, ds:[0]
   mov xyz[0], ax     ; 编译成: mov es:[0], ax
   ; 不能直接写: mov xyz[0], abc[0]
   ; 因为一条指令中两个操作数不能都是内存操作数
   push abc[2]        ; push word ptr ds:[2]
   pop xyz[2]         ; pop word ptr es:[2]
   ; push + pop 效果: xyz[2] = abc[2]
   mov ah, 4Ch
   mov al, 0
   int 21h
code ends

stk segment stack
db 100h dup('S')
stk ends

end main

三、函数定义与调用

3.1 最基本的 call / ret(用寄存器传参)

asm
code segment
assume cs:code
f:                  ; 用标号定义函数
   shl ax, 1        ; 返回值放在 ax 中
   ret              ; CPU 执行 ret 时: pop ip
main:
   mov ax, 3        ; 用寄存器 ax 传参
   call f
   ; CPU 执行 call f 时:
   ;   ① push offset back   (压入返回地址)
   ;   ② jmp f              (跳转到目标地址)
back:   
   mov ah, 4Ch
   mov al, 0
   int 21h
code ends

stk segment stack
db 100h dup('S')
stk ends

end main

call 执行过程

call f  →
  ① push offset back    ; 把 call 下一条指令的偏移地址压栈
  ② jmp f               ; 跳转到函数

ret →
  ① pop ip              ; 从栈中弹出返回地址 → ip

3.2 用栈传递参数

当参数多了,寄存器不够用,需要用栈传参。

原型:f(参数1, 参数2),参数从右往左压栈:

asm
code segment
assume cs:code
f:                  ; f(a, b) = a - b
   push bp          ; (4) 保存调用者的 bp
   mov bp, sp       ; ★ 建立栈帧:bp 指向当前栈顶
   mov ax, ss:[bp+4]; 读取参数 1(a)
   sub ax, ss:[bp+6]; ax = 参数 1 - 参数 2(b)
   pop bp           ; (5) 恢复调用者的 bp
   ret              ; (6) 返回
main:
   mov ax, 3        ; 参数 2 = 3
   push ax          ; (1) 先压参数 2
   mov ax, 5        ; 参数 1 = 5
   push ax          ; (2) 再压参数 1
   call f           ; (3) 调用函数
back:   
   add sp, 4        ; (7) 清理栈中的 2 个参数
   mov ah, 4Ch
   mov al, 0
   int 21h
code ends

stk segment stack
db 100h dup('S')
stk ends

end main

3.3 栈帧详解

设程序运行时 ss = 2000h, sp = 1000h,则执行过程中栈的变化:

调用前 (main):
  ss:1000  ← sp

push 参数2 (3) 后:
  ss:0FFE  0003h  ← sp

push 参数1 (5) 后:
  ss:0FFC  0005h  ← sp

call f 后:
  ss:0FFA  back 的偏移  ← sp

push bp 后:
  ss:0FF8  old bp  ← bp ← sp

mov bp, sp 后:
  bp = 0FF8, 此时访问参数:
    ss:[bp+4] = ss:[0FFC] = 5  ← 参数 1
    ss:[bp+6] = ss:[0FFE] = 3  ← 参数 2

栈帧结构(从高地址到低地址):

ss:[bp+6] = 参数 2  (先压入的)
ss:[bp+4] = 参数 1  (后压入的)
ss:[bp+2] = 返回地址 (call 压入的)
ss:[bp]   = old bp   (push bp 压入的)

关键规律:在栈帧中,参数从 [bp+4] 开始,因为 [bp+2] 存放返回地址,[bp] 存放 old bp。


四、本周易错点

  1. 不定义栈段时栈空间有限,递归/深层调用可能溢出
  2. 忘记 push bppop bp,导致调用者的 bp 被破坏
  3. 参数偏移算错:[bp+4] 不是 [bp+2](bp+2 是返回地址)
  4. 调用后忘记 add sp, n 清理参数(内存泄漏)
  5. 一条指令中两个操作数不能都是内存操作数(如 mov xyz[0], abc[0] 非法)

五、速查表

操作写法
定义栈段stk segment stack + db N dup('S')
用寄存器传参mov ax, value → call f → 函数内直接用 ax
用栈传参push param2 → push param1 → call f
建立栈帧push bp / mov bp, sp
访问参数ss:[bp+4](参数1), ss:[bp+6](参数2)
清理参数add sp, 参数总字节数
函数返回pop bp / ret