06_函数
一、 内存寻址与指针体系演进 (Near vs Far)
在 16 位实模式下,由于寄存器宽度(16位)小于物理地址总线宽度(20位),内存采用“段基址:偏移地址”进行分段寻址。这种硬件层面的限制直接衍生了近指针与远指针的概念。
1. 近指针 (Near Pointer) 与 远指针 (Far Pointer)
-
近指针 (Near Pointer):仅包含 16 位偏移地址。默认引用当前数据段 (DS) 或代码段 (CS)。在 C 语言(如 16 位编译器 Turbo C)中对应常规的
void*。 -
远指针 (Far Pointer):包含 16 位段地址 + 16 位偏移地址(共 32 位)。用于跨段访问内存或代码。在 Turbo C 中需显式声明为
void far *。
2. 指针跨段操作指令 (LDS / LES)
当需要加载远指针中的 32 位数据以访问外部数据段时,使用专用装入指令:
-
LDS reg, mem:从内存mem处读取 32 位数据,将高 16 位(段基址)装入DS寄存器,低 16 位(偏移量)装入指定的 16 位通用寄存器reg。 -
LES reg, mem:机制相同,但将段基址装入ES(附加段寄存器)。
3. 数据宽度修饰符 (ptr)
-
word ptr:2 字节(16位)。实模式下常用于近指针。 -
dword ptr:4 字节(32位)。实模式下常用于定义远指针(16位段+16位偏移);在 32 位平坦模式下为默认近指针宽度。 -
fword ptr(Far Word):6 字节(48位)。这是 32 位保护模式下的远指针结构,包含 16 位段选择子 (Segment Selector) 和 32 位偏移地址。
4. 架构演进对指针的影响
- 32 位保护模式 / 64 位长模式:引入平坦内存模型 (Flat Memory Model)。段基址始终固定为 0(由操作系统接管段页式管理),偏移地址扩展至 32 位/64 位,足以覆盖整个物理/虚拟地址空间。因此,在现代 32/64 位操作系统及编译器(如 GCC/MSVC)中,远近指针的区分被彻底废弃,所有指针本质上都是“近指针”。
二、 CALL 与 RET 指令的核心动作与栈帧结构
函数调用的本质是对指令指针及栈帧的保存与切换。因跨段需求不同,底层行为存在差异。
1. 近调用 (Near Call) 机制
仅在同一代码段内跳转,不修改 CS。
-
CALL f(近):隐式执行push ip(将CALL后的下一条指令偏移(返回地址)压栈),然后jmp f。 -
RET:隐式执行pop ip。
2. 远调用 (Far Call) 机制与远栈帧
跨代码段调用,必须同时保存段基址 (CS) 和偏移地址 (IP)。
-
CALL far ptr f:硬件会连续执行三个隐式操作:-
push cs(保存调用者的段基址) -
push offset back(下一跳指令的偏移) -
jmp far ptr f(修改 CS:IP 跳转至目标)
-
-
RETF(Return Far):硬件连续执行出栈恢复:-
pop ip -
pop cs
-
远调用栈帧结构剖析:
当执行远调用时,栈帧中会多压入一个 CS。被调用函数内部获取参数的偏移量需要相应改变。
; 主程序 (Caller)
mov ax, 3
push ax ; 参数 3 入栈 (地址偏高)
mov ax, 5
push ax ; 参数 5 入栈
call far ptr f ; 压入 CS 和 IP
; 被调用程序 (Callee)
f:
push bp ; 保护原 BP
mov bp, sp ; 建立当前栈帧基址
; 此时栈结构如下:
; [bp+0] -> 保存的旧 BP
; [bp+2] -> 返回地址的 IP
; [bp+4] -> 返回地址的 CS <-- 远调用新增
; [bp+6] -> 参数 5 <-- 取第一参数需 [bp+6] 而非近调用的 [bp+4]
; [bp+8] -> 参数 3
mov ax, [bp+6] ; ax = 5
sub ax, [bp+8] ; ax = 5 - 3 = 2
pop bp
retf ; 远返回
三、 函数传参、局部变量与调用约定
调用约定规定了主程序与子程序之间的接口协议,包括参数传递顺序、寄存器使用以及谁负责清理堆栈。
1. 经典调用约定对比
| 调用约定 | 压栈顺序 | 传参方式 | 清栈方 | 适用场景 / 语言 | 汇编特征表现 |
|---|---|---|---|---|---|
| cdecl | 右 -> 左 | 纯堆栈 | 调用者 (Caller) | C/C++ 默认,支持不定长参数 (printf) |
ret 后紧跟 add sp, ? 平衡堆栈 |
| stdcall | 右 -> 左 | 纯堆栈 | 被调用者 (Callee) | Win32 API 核心标准 | 子程序末尾通过 ret 4 直接清栈 |
| Pascal | 左 -> 右 | 纯堆栈 | 被调用者 (Callee) | 早期 Pascal, Delphi | 无法支持可变参数 |
| fastcall | 右 -> 左 | 寄存器 + 堆栈 | 被调用者 (Callee) | 32位性能敏感程序 | 前两个参数存于 ECX, EDX,其余压栈 |
2. 局部动态变量的定义与引用
局部变量的生命周期仅存在于函数执行期间,它们不占用数据段 (DS),而是动态分配在栈上。
-
分配:在函数序言建立栈底基址 (
mov bp, sp) 后,通过减小栈指针开辟空间,如sub sp, 8(开辟 8 字节局部空间)。 -
访问:参数通过正向偏移访问 (
[bp+4],[bp+6]),局部变量通过负向偏移访问 ([bp-2],[bp-4])。 -
释放:在函数结束前,通过
mov sp, bp直接废弃局部变量空间,恢复栈顶。
3. 递归的汇编机制
递归的本质是函数的自身调用产生多个独立的栈帧。
-
每次执行
CALL,CPU 都会压入当前层级的返回地址。 -
每次进入函数,都会执行
push bp; mov bp, sp; sub sp, N,在物理内存栈中划出一块全新、独立的区域(包含这一层级独有的局部变量和状态)。 -
因为局部状态均以当前
BP为基准引用(非绝对地址),所以深层递归不会覆盖浅层的变量数据。 -
触发终止条件后,依次回退栈帧,执行
mov sp, bp; pop bp; ret逐层折叠返回。
注意事项: 先写 base case,注意入栈保护、出栈恢复寄存器,优先使用堆栈传递参数。
四、 跨越 16 位到 64 位的参数传递与栈帧演进
1. 16 位 / 32 位 (堆栈传参为主)
-
32 位时代引入了标准的序言 (Prologue) 和 结语 (Epilogue) 模式:
2. 64 位 x64 (寄存器传参为主,Shadow Space)
64 位彻底抛弃了 32 位混乱的调用约定分支,统一走向基于寄存器的高效传参路线。
-
System V ABI (Linux/Mac):前 6 个整型参数强制使用寄存器
RDI, RSI, RDX, RCX, R8, R9。超过 6 个的部分才从右向左压栈。 -
Win64 ABI (Windows):前 4 个参数强制使用
RCX, RDX, R8, R9。 -
影子空间 (Shadow Space/Spill Space):
-
在 Win64 ABI 中,即使参数通过寄存器传递,调用者 (Caller) 也必须在
CALL之前,在自己栈顶开辟 32 字节 (4 x 8 bytes) 的预留空间。 -
作用:为子程序提供“避风港”。如果子程序过于复杂,需要利用这 4 个传参寄存器做其他运算,子程序可直接将
RCX, RDX, R8, R9的内容“溢出”存入这 32 字节的影子空间中保存,而无需重新开辟栈空间。
-
64 位和 32 位系统调用时的传参顺序特殊,64 位第四个参数从 RCX 变为 R10,因为 RIP 会存入 RCX。 32 位全部寄存器传参,从左到右 ebx ecx edx esi edi ebp。