Skip to content

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:硬件会连续执行三个隐式操作:

    1. push cs (保存调用者的段基址)

    2. push offset back (下一跳指令的偏移)

    3. jmp far ptr f (修改 CS:IP 跳转至目标)

  • RETF (Return Far):硬件连续执行出栈恢复:

    1. pop ip

    2. 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) 模式:

    ; Prologue
    push ebp
    mov ebp, esp
    sub esp, <LocalSize>
    ; ... 执行体 ...
    ; Epilogue
    mov esp, ebp  ; (现代 x86 中可用 leave 指令替代此两句)
    pop ebp
    ret
    

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。