栈的概念¶
栈是理解函数调用、局部变量、调试回溯和栈内存错误的共同基础。本文面向 CTF Pwn 初学者,以 Linux x86-64、ELF、System V AMD64 ABI 为基线。这里的“栈”主要指用户态线程栈,不是抽象数据结构课程中的通用 LIFO 容器。
学习范围
文中的程序和命令只用于自己编写的本地示例、课程实验与授权 CTF 环境。本文解释运行时结构,不提供针对真实目标的利用步骤或武器化 payload。
先建立三个层次¶
初学者常把源码变量、汇编栈槽和进程内存混为一谈。可以先把问题分成三个层次:
| 层次 | 关注对象 | 典型问题 |
|---|---|---|
| C 源码 | 函数、参数、局部变量、作用域 | local 在语义上何时存在? |
| ABI 与机器指令 | 寄存器、栈指针、调用约定 | 参数从哪个寄存器进入? |
| 操作系统进程 | 虚拟地址空间、页权限、线程 | 栈映射在哪里,是否可执行? |
C 标准并不要求所有局部变量都放在机器栈上。具体布局由编译器、优化选项、ABI 和运行环境共同决定。
进程虚拟内存布局¶
Linux 为进程提供虚拟地址空间。不同区域通常具有不同来源、生命周期和页权限。 一个简化示意如下:
高地址
+------------------------------+
| 线程栈(通常向低地址增长) |
+------------------------------+
| 内核保留的用户态边界附近 |
+------------------------------+
| 共享库、mmap 映射 |
+------------------------------+
| 堆(传统 brk 区域) |
+------------------------------+
| .bss:未显式初始化的全局数据 |
| .data:可写的已初始化数据 |
| .rodata:只读常量 |
| .text:可执行代码 |
+------------------------------+
低地址
这只是概念图,不是固定地址表。ASLR、PIE、动态链接器、内核版本和分配行为都会改变实际映射。
| 区域 | 常见内容 | 常见权限 | 生命周期 |
|---|---|---|---|
.text |
机器指令 | r-x |
程序映像存在期间 |
.rodata |
字符串字面量、只读常量 | r-- |
程序映像存在期间 |
.data/.bss |
全局变量、静态变量 | rw- |
进程生命周期 |
| heap / mmap | 动态分配对象、映射文件 | 常见为 rw- |
由分配与解除映射决定 |
| thread stack | 调用状态、部分局部对象 | 常见为 rw- |
线程生命周期 |
在本地 Linux 中可以观察映射:
也可以在 GDB 中运行:
不要从“地址较高”直接推断某地址一定属于栈。应以映射边界、权限和实际对象地址为证据。
每个线程都有自己的栈¶
一个进程可以包含多个线程。每个线程通常有独立的线程栈和独立的 rsp,但线程共享进程的代码、全局数据和大部分地址空间。
因此:
- “进程只有一个栈”不准确。
- 主线程栈通常在进程启动时建立。
- 新线程的栈通常由线程库通过映射创建。
- 栈大小有限,深递归或巨大局部数组可能造成栈耗尽。
- 栈附近通常存在保护页,用于尽早发现越界增长。
线程局部存储 TLS 也与线程相关,但它不是线程栈本身。栈 Canary 的参考值常通过线程相关状态访问,这也不意味着 Canary“存放在 TLS 栈里”。
栈为什么常说向低地址增长¶
在本文基线下,压入数据通常使 rsp 变小:
可以近似理解为:
弹出数据通常使 rsp 变大:
近似理解为:
“向低地址增长”描述的是分配更多当前栈空间时地址方向的常见变化。它不表示数组索引、字符串写入或所有对象内部访问都向低地址进行。
rsp:当前栈顶¶
rsp 是 x86-64 的栈指针寄存器。在任意时刻,[rsp] 表示当前栈顶地址处的内存。
rsp 会因多种指令变化:
分析时要逐条累计:
| 操作 | 对 rsp 的典型影响 |
|---|---|
push reg |
rsp -= 8 |
pop reg |
rsp += 8 |
sub rsp, N |
分配 N 字节栈空间 |
add rsp, N |
释放 N 字节栈空间 |
call target |
压入 8 字节返回地址 |
ret |
取出 8 字节返回地址 |
不要只在函数入口记录一次 rsp。函数内部的对齐、临时空间和嵌套调用都会改变它。
rbp:可能的帧指针¶
未优化或显式保留帧指针时,常见函数序言是:
此时 rbp 在函数执行期间提供相对稳定的基准:
对应尾声可能是:
leave 近似等价于:
但 rbp 不保证永远是帧指针。优化构建常使用 -fomit-frame-pointer,把 rbp 当普通 callee-saved 寄存器。现代回溯还可依赖 .eh_frame 等展开信息。
call 到底做了什么¶
执行:
CPU 完成两个关键动作: 1. 把call 后下一条指令的地址压入栈。
2. 把控制流转移到 target。
假设调用前:
进入目标函数时可概念化为:
返回地址是控制数据。它不是 C 函数显式声明的局部变量,却决定函数结束后从哪里继续执行。
ret 到底做了什么¶
普通近返回指令:
可以近似理解为: 因此函数返回前必须把rsp 恢复到返回地址所在位置。若局部空间没有释放、保存寄存器没有弹出或栈已损坏,ret 读取的内容就可能不再是原返回地址。
ret imm16 还会额外调整栈,但在 Linux x86-64 普通 C 函数中不常见。
什么是栈帧¶
“栈帧”是某次函数调用在栈上使用的逻辑区域。它可能包含:
- 返回地址。
- 保存的旧帧指针。
- 保存的 callee-saved 寄存器。
- 局部数组和取地址的局部变量。
- 寄存器溢出槽与临时值。
- 超过寄存器传参数量的参数。
- 为对齐预留的填充。
栈帧不是硬件自动创建的完整对象。硬件只为 call 压入返回地址;其余布局通常由编译器生成的指令建立。
一个带帧指针的概念图:
高地址
+-----------------------+
| 调用者的栈上参数 |
+-----------------------+
| 返回地址 | [rbp+8]
+-----------------------+
| 保存的旧 rbp | [rbp]
+-----------------------+
| 保存寄存器 / 填充 |
+-----------------------+
| 局部变量与临时栈槽 | [rbp-N]
+-----------------------+ <- rsp(函数体某时刻)
低地址
参数如何进入函数¶
System V AMD64 ABI 中,普通整数和指针类参数的前六个位置通常是:
返回值通常放在rax。第七个及后续适合该分类的参数通常通过栈传递。
例如:
优化后可能没有栈帧:
这说明“调用函数”不等于“必须把全部参数压栈”。浮点、向量和聚合类型还要按 ABI 分类,不能只套用前六个通用寄存器规则。
更完整的调用约定说明见函数与调用约定。
局部变量不一定在栈上¶
观察源码:
优化编译器可能直接生成:y 没有独立栈槽。更容易落到栈上的情况包括:
- 局部对象地址被取用。
- 存在固定大小数组。
- 调试构建降低优化。
- 寄存器不足,需要 spill。
- 值要跨越函数调用保存。
- ABI 要求保存某些寄存器或对象。
调试信息显示的变量位置也可能随指令范围变化。变量可能先在寄存器中,后来移到栈上,或者被优化掉。
16 字节栈对齐¶
System V AMD64 ABI 对函数调用边界有对齐要求。实用记忆方式是:调用者在执行 call 前安排好 16 字节对齐。
由于 call 会压入 8 字节返回地址,普通函数刚进入时常见:
push 或 sub rsp, N 重新安排调用前对齐。
示例:
错误对齐可能让依赖对齐的向量指令或库实现失败。不要死记某个固定 sub rsp, 40 模板;应从函数入口和每次调整逐步计算。
red zone¶
System V AMD64 ABI 为用户态代码定义了 rsp 以下 128 字节的 red zone。符合约定的信号或中断处理不会破坏这一区域。叶函数可在不移动 rsp 的情况下把临时数据放入其中。
概念示例:
rsp 的低地址方向。
- 它不是额外映射的保护区域。
- 它不是 Windows x64 的 shadow space。
- Windows x64 ABI 没有 System V red zone。
- 内核代码通常禁用 red zone,例如使用 -mno-red-zone。
- 非叶函数不能假设被调用函数会保留自己放在其栈顶以下的数据。
调试时看到 [rsp-8] 的访问而没有 sub rsp,不一定是越界,可能是在使用 red zone。
源码、汇编与 GDB 对照实验¶
创建仅供本地观察的 stack_demo.c:
#include <stdio.h>
__attribute__((noinline))
long inspect(long a, long b) {
long local = a + b;
printf("local=%ld\n", local);
return local * 2;
}
int main(void) {
return inspect(20, 22) == 84 ? 0 : 1;
}
set disassembly-flavor intel
break inspect
run
info registers rdi rsi rsp rbp rip
disassemble /m inspect
x/8gx $rsp
nexti
info registers rsp rbp
bt
inspect 时,预期先验证:
| 观察项 | 预期含义 |
|---|---|
$rdi |
第一个整数参数 a,值为 20 |
$rsi |
第二个整数参数 b,值为 22 |
$rsp |
当前栈顶,入口处指向返回地址 |
x/gx $rsp |
查看调用者压入的返回地址候选 |
$rip |
当前将执行的指令位置 |
逐条走过函数序言后,再比较 rsp 与 rbp。如果断点落在源码行而不是函数第一条指令,序言可能已经执行,应使用:
si 逐条观察。
更完整的命令说明见GDB 在 Pwn 中的使用和工具、反汇编与调试。
对照优化构建¶
再编译一个优化版本:
可能看到: -rbp 不再作为帧指针。
- local 长时间保存在寄存器中。
- 指令顺序与源码行顺序不完全一致。
- 常量传播或内联改变函数边界。
- 尾调用使 call; ret 变成 jmp。
noinline 只抑制该函数被内联,不会关闭其他优化。分析 CTF 二进制时,应以实际机器指令为准,而不是期待它长得像 -O0 教材示例。
在 GDB 中画出当前帧¶
可以按以下顺序记录证据:
随后手工标注:地址范围 暂定含义 证据
0x... - 0x... 当前局部栈槽 汇编 [rbp-N]
0x... 保存的旧 rbp 函数序言与 info frame
0x... 返回地址 call 后地址与反汇编匹配
0x... 调用者区域 超出当前帧边界
常见误区¶
误区一:栈就是局部变量区¶
栈还保存返回地址、部分寄存器、对齐填充和临时状态。而局部变量也可能完全在寄存器中。
误区二:rsp 总是指向当前局部变量¶
rsp 指向当前栈顶。栈顶内容可能是返回地址、保存寄存器、临时空间或对齐区域。
误区三:rbp 总是当前帧基址¶
只有编译器选择使用帧指针时才成立。优化代码必须结合序言、展开信息和访问模式判断。
误区四:源码声明顺序等于内存顺序¶
编译器可以重排对象、复用栈槽、加入 Canary 和填充,或删除变量。
误区五:函数参数都在栈上¶
x86-64 System V 的前六个普通整数或指针参数通常从寄存器进入。
误区六:栈地址每次都相同¶
ASLR 会随机化栈等映射。调试记录应优先使用相对关系、符号和模块偏移。
误区七:call 创建整个栈帧¶
call 只压入返回地址并转移控制流。局部空间和保存动作由函数代码完成。
误区八:red zone 是栈保护机制¶
red zone 是 ABI 允许叶函数使用的临时区域,不是越界检测或安全边界。
误区九:栈一定可执行¶
现代 Linux 通常通过 NX 把栈映射为不可执行。是否可执行应检查 ELF 与运行时映射,不能凭历史印象判断。
阅读一个函数的检查清单¶
- 确认架构、位数、操作系统和 ABI。
- 找到函数入口及所有调用点。
- 记录入口处参数寄存器和栈参数。
- 识别
push、sub rsp与保存寄存器。 - 判断
rbp是帧指针还是普通寄存器。 - 逐步计算
rsp,不要依赖模糊的“栈顶附近”。 - 标记局部栈槽、填充和 red zone 使用。
- 在每个
call前检查参数与 16 字节对齐。 - 记录 caller-saved 寄存器可能被破坏。
- 检查返回值位置和函数尾声。
- 对照 GDB 的寄存器、内存和
info frame。 - 用优化版本验证结论是否只依赖教材式序言。
学完后应能回答¶
- 进程地址空间中的线程栈与堆、代码段有什么区别?
rsp和rbp分别提供什么信息?call如何产生返回地址,ret从哪里读取它?- 为什么局部变量不一定对应一个固定栈槽?
- 为什么函数入口常见
rsp % 16 == 8? - red zone 能做什么,不能做什么?
- 如何用源码、反汇编和 GDB 三方验证一个栈帧判断? 下一步可阅读栈溢出基础,理解错误写入如何从普通数据破坏发展为控制数据破坏。同时建议结合面向 Reverse 与 Pwn 的汇编建立完整的二进制分析视角。