栈的先入后出

N0va7
2025-09-21 / 0 评论 / 1 阅读 / 正在检测是否收录...
温馨提示:
本文最后更新于2025年09月21日,已超过255天没有更新,若内容或图片失效,请留言反馈。

栈的向下增长特性

栈是一种后进先出(LIFO)的数据结构
在x86和x86_64架构下,栈从高地址向低地址方向增长
每次push操作,栈顶指针(ESP或者RSP)都会减小;每次pop操作则会增大
栈的主要作用包括保存函数调用现场、保存局部变量、保存返回地址

栈的数据结构

通过这个图可以比较直观的看到栈的结构与对应的入栈和出栈操作(注意:此时只是为了便于观察,故而展示栈底在下,栈顶在上,实际情况下应该以高低地址为判断标准

栈的push和pop

栈并非一个可以无限填充数据的结构,他的大小是有限的,比方说一个水杯只能装100ml,但是你倒了800ml,就会导致水满溢出,栈也是一样的,如果他只能存放100字节,那么你放了800字节就会导致栈溢出

函数调用过程

参数压栈顺序:超出寄存器的部分从右向左压入栈中
call指令执行时:将当前指令地址压入栈中(为后续返回做准备)跳转到目标函数的入口
被调用函数prologue过程:
push rbp 保存旧帧地址
mov rbp, rsp 建立新的栈帧
sub rsp, xxx 为局部变量预留空间
函数退出时使用 leave 与 ret 恢复现场

上面这些看不懂?没关系,继续向下看看,我会逐步拆解

明确核心概念:x64 函数调用的 “寄存器传参优先” 规则

在 x64(Windows/Linux)中,函数调用并非直接压栈所有参数,而是优先用寄存器传参,仅当参数数量超出寄存器上限时,剩余参数才从右向左压栈。这是理解 “超出寄存器部分压栈” 的前提:

平台前 N 个参数的寄存器(从第 1 个到第 N 个)超出部分的处理方式
Windows x64rcx(第 1 个)、rdx(第 2 个)、r8(第 3 个)、r9(第 4 个)从右向左压栈(第 5 个及以后)
Linux x64rdi(第 1 个)、rsi(第 2 个)、rdx(第 3 个)、rcx(第 4 个)、r8(第 5 个)、r9(第 6 个)从右向左压栈(第 7 个及以后)
例:Windows 下调用func(a, b, c, d, e, f),前 4 个参数a(b1)、b(b2)、c(b3)、d(b4)分别存在rcx、rdx、r8、r9中,剩余e(b5)、f(b6)从右向左压栈(先压f,再压e)。

逐步骤拆解:从 “调用函数” 到 “函数退出”

假设调用func(p1,p2,p3,p4,p5,p6,p7),前 6 个参数用寄存器传递,第 7 个参数p7需要压栈,完整流程如下:

步骤 1:调用者准备参数(寄存器 + 栈传参)

  • 前 6 个参数存入寄存器:
    rdi=p1rsi=p2rdx=p3rcx=p4r8=p5r9=p6
  • 第 7 个参数p7(超出寄存器上限)从右向左压栈(因只有 1 个超出参数,直接压p7);
  • 压栈操作:push p7 → rsp减少 8 字节(x64 栈元素为 8 字节)。

此时栈状态(高地址→低地址):

步骤 2:执行call func指令(保存返回地址 + 跳转)

call func执行两件事:

  1. 压入返回地址ret_addrcall下一条指令的地址)→ rsp再减 8 字节;
  2. 跳转到func入口(rip指向func起始地址)。

此时栈状态(rsp指向ret_addr):

步骤 3:被调用函数执行Prologue(建立栈帧)

Linux x64 函数的 Prologue 与 Windows 类似,核心是用rbp固定栈帧:

  1. push rbp → 保存调用者的rbpold_rbp),rsp减 8 字节;
  2. mov rbp, rsp → rbp指向当前栈顶(old_rbp的地址),作为新栈帧基准;
  3. sub rsp, N → 预留局部变量空间(N为局部变量总字节数,需 8 字节对齐)。
mov rbp, rsp 是 Intel 语法(Windows、NASM 等常用)
mov %rsp, %rbp 是 AT&T 语法(Linux、GCC 等常用)
Intel 语法mov 目标操作数, 源操作数
(先写要写入的寄存器,再写提供数据的寄存器)
AT&T 语法mov 源操作数, 目标操作数
(先写提供数据的寄存器,再写要写入的寄存器),且寄存器前必须加 % 前缀
两者等价,生成的机器码也完全一致

假设func有 3 个局部变量(共 24 字节,N=24),此时栈状态:

步骤 4:函数执行核心逻辑

  • 访问寄存器传参:直接用rdi(p1)、rsi(p2)等寄存器;
  • 访问栈传参数p7mov rax, [rbp+16]
  • 访问局部变量var2mov rbx, [rbp-16]

步骤 5:函数退出(leave + ret 恢复现场)

执行leave指令

等价于:

mov rsp, rbp   ; 释放局部变量空间,rsp指向old_rbp
pop rbp        ; 恢复调用者的rbp,rsp指向ret_addr

执行后栈状态:

执行ret指令
  • 弹出ret_addrrip → rsp增加 8 字节(指向p7);
  • CPU 跳回ret_addr,继续执行调用者代码。

代码调试:观察函数调用栈帧

#include <stdio.h>
void func3() {
    int c = 3;
    printf("In func3: c = %d\n", c);
}
void func2() {
    int b = 2;
    printf("In func2: b = %d\n", b);
    func3();
}
void func1() {
    int a = 1;
    printf("In func1: a = %d\n", a);
    func2();
}
int main() {
    func1();
    return 0;
}

编译指令:

gcc -m32 -g -fno-stack-protector -no-pie -o stack stack.c

call下栈顶的变化

call之前栈顶地址以及call之后会返回的地址

call之后,esp-0x4,然后对应压栈ret_addr,发现确实将返回地址加进去了

后面也是一样的,我就不一一复现了,主要是了解原理即可。

0

评论 (0)

取消