共计 1383 个字符,预计需要花费 4 分钟才能阅读完成。
背景:栈帧在函数调用中的核心作用
在 x86 架构中,栈是函数调用的基石。与 RISC 架构不同,CISC 风格的 8086 通过硬件直接支持栈操作指令(如 PUSH/POP),使得函数调用时参数的传递和局部变量的存储变得高效。栈帧(Stack Frame)是每个函数在栈上分配的独立内存区域,用于存放返回地址、参数、局部变量等数据。理解栈帧的变化,是调试底层代码和优化性能的关键。

函数调用栈帧技术细节
CALL/RET 指令对栈的影响
-
当执行
CALL func时,处理器会先将下一条指令的地址(返回地址)压入栈,然后跳转到目标函数。此时栈指针(SP)会自动减少 2 字节(8086 是 16 位架构)。 -
在函数内部,通常会通过
PUSH BP和MOV BP, SP来保存旧的基址指针(BP)并建立新的栈帧。这使得 BP 成为访问参数和局部变量的基准。 -
RET指令会从栈顶弹出返回地址到 IP 寄存器,实现函数返回。如果是RET n(如 stdcall 约定),还会额外调整 SP 跳过参数区域。
栈帧结构示意图
高地址
+----------------+
| 参数 n | ← BP + 6
+----------------+
| 参数 1 | ← BP + 4
+----------------+
| 返回地址 | ← BP + 2
+----------------+
| 保存的 BP | ← BP
+----------------+
| 局部变量 | ← BP - 2
+----------------+
| ... | ← SP
低地址
调用约定差异
- cdecl:调用者负责清理栈(如
ADD SP, 4),支持可变参数 - stdcall:被调函数通过
RET n清理栈,更紧凑但不可变参
MASM 代码示例与调试实践
; 示例:stdcall 约定的函数调用
.MODEL SMALL
.STACK 100H
.CODE
main PROC
PUSH 1122h ; 压入参数 2
PUSH 3344h ; 压入参数 1
CALL my_func ; SP -= 2(IP)
ADD SP, 4 ; cdecl 需要手动平衡栈
MOV AH, 4CH
INT 21H
main ENDP
my_func PROC
PUSH BP ; SP -= 2
MOV BP, SP ; BP = SP
SUB SP, 2 ; 分配局部变量空间
MOV [BP-2], AX ; 使用局部变量
MOV AX, [BP+4] ; 读取参数 1
MOV SP, BP ; 释放局部变量
POP BP ; SP += 2
RET 4 ; 返回并清理 4 字节参数
my_func ENDP
END main
在 DEBUG 中观察:
- 执行到
CALL前,栈顶(SP)指向参数区 - 进入函数后,BP 成为帧指针,
[BP+4]对应第一个参数 RET 4后 SP 会恢复到调用前的状态(加 4 跳过参数)
避坑指南
栈对齐问题
- 8086 虽然没有硬性对齐要求,但错误的对齐会导致访问速度下降
- 在 32/64 位模式下,未对齐的栈访问可能直接引发异常
缓冲区溢出攻击
- 如果函数内的数组越界写入(如
MOV [BP-10], AX但只有 8 字节空间) - 可能覆盖返回地址,导致执行任意代码
- 经典案例:通过精心构造的输入覆盖返回地址为 shellcode 地址
实践建议与延伸思考
建议使用 Turbo Debugger 或 DOSBox- X 逐步单步执行上述代码,观察:
– CALL 前后 SP/BP 的变化
– 局部变量区的内存内容
现代 CPU 的栈保护机制(如 Canary 值)会在返回地址前插入特殊值,函数返回前验证该值是否被修改。思考:如何用 8086 汇编模拟这种保护机制?可以尝试在 BP 和返回地址之间插入校验值,并在 RET 前验证。
正文完
发表至: 未分类
四天前
