共计 1481 个字符,预计需要花费 4 分钟才能阅读完成。
理解函数调用机制是掌握操作系统和编译器设计的基础。就像搭积木需要知道每块的连接方式,搞清楚 CALL/RET 背后的栈操作,才能写出可靠的汇编代码,也能更好地理解高级语言的工作原理。

一、栈帧结构:函数调用的舞台
每次函数调用时,CPU 都会在栈上创建一块称为 ” 栈帧 ” 的内存区域。以 CALL myFunc 指令为例(机器码0xE8+ 偏移量):
- 调用前:假设 SS:SP=0x1000:0xFFFE
- 执行 CALL 时:
- 先将返回地址 CS:IP 压栈(占用 4 字节)
- SP 自动减 4 变为 0xFFFA
- 进入函数后:
- 典型开场白
PUSH BP(机器码 0x55) MOV BP, SP(机器码 0x89 0xE5)- 这时栈布局:
SS:0xFFFA -> 旧 IP SS:0xFFFC -> 旧 CS SS:0xFFFE -> 旧 BP <- BP 现在指向这里
二、调用约定:参数传递的交通规则
在 8086 汇编中,常见两种调用约定:
- Cdecl 约定(C 语言默认):
- 参数从右向左压栈
- 调用者清理栈
-
示例:
; 调用方 PUSH 1234 ; 参数 2 PUSH 5678 ; 参数 1 CALL add ADD SP, 4 ; 清理栈 ; 函数方 add PROC PUSH BP MOV BP, SP MOV AX, [BP+4] ; 取参数 1 ADD AX, [BP+6] ; 加参数 2 POP BP RET add ENDP -
StdCall 约定(WinAPI 常用):
- 参数同样从右向左
- 被调函数自己清理栈
- 对应的 RET 指令变为
RET n(机器码 0xC2+n)
三、参数传递实战:WORD 与 DWORD 的区别
处理不同尺寸参数时容易出错,关键要记住:
- WORD 参数(16 位):
- 直接 PUSH 一个寄存器或内存字
-
如
PUSH AX(机器码 0x50) -
DWORD 参数(32 位):
- 在 8086 上需要分两次 PUSH
- 示例传递 0x12345678:
PUSH 5678h ; 高字 PUSH 1234h ; 低字 - 函数内通过 [BP+4] 访问低字,[BP+6]访问高字
四、完整代码示例
下面这个可编译程序演示了参数传递和栈跟踪:
.MODEL SMALL
.STACK 100h
.CODE
main PROC
MOV AX, @data
MOV DS, AX
; 准备参数
PUSH 3 ; 第二个参数
PUSH 2 ; 第一个参数
CALL sum ; 机器码 E8 0007
ADD SP, 4 ; 清理栈
MOV AH, 4Ch
INT 21h
main ENDP
; 求和函数
sum PROC
PUSH BP ; 55
MOV BP, SP ; 89 E5
; 现在栈结构:; BP+6 -> 参数 2
; BP+4 -> 参数 1
; BP+2 -> 返回 IP
; BP+0 -> 旧 BP
MOV AX, [BP+4]
ADD AX, [BP+6]
POP BP ; 5D
RET ; C3
sum ENDP
END main
五、避坑指南
- 栈对齐问题:
- 8086 没有硬性对齐要求,但保持 WORD 对齐(SP 为偶地址)能提升性能
-
中断发生时,CPU 会自动将 FLAGS、CS、IP 压栈(共 6 字节)
-
寄存器保存:
- 函数内修改 BX/SI/DI/BP 等寄存器前应先 PUSH 保存
-
示例:
myFunc PROC PUSH SI PUSH DI ; 函数体 POP DI POP SI RET myFunc ENDP -
跨模块调用:
- 确保所有模块使用相同内存模型(TINY/SMALL/COMPACT 等)
- 外部函数声明要一致:
EXTRN someFunc:PROC
六、调试技巧(DOSBox)
- 用 DEBUG.EXE 加载程序后:
U命令反汇编查看代码T单步执行-
D SS:SP查看栈内容 -
关键断点设置:
-g 0x1234 ; 执行到 CS:IP=0x1234 -p ; 跳过 CALL/RET
思考题
- 可变参数函数(如 printf)如何通过栈确定参数个数?
- 当 CS 发生变化时的 FAR CALL 与普通 NEAR CALL 在机器码上有何区别?
- 递归调用超过栈空间时会出现什么现象?如何检测?
正文完
发表至: 未分类
近一天内
