
栈Stack可能是整个计算机体系里最容易被低估的一个数据结构。你刷算法题会用它写递归会碰到它函数调用背后有它连 JVM 的线程栈、操作系统的内核栈都离不开它。但偏偏很多同学学到“栈”的时候只是在纸上画了几个 push/pop 的箭头等到程序崩溃、栈回溯打出来一长串调用记录或者递归把栈空间烧干净时才意识到自己根本不了解这个天天打交道的东西。我最近用 Zig 和 C3 各写了一遍顺序栈算是把栈的原理、栈帧布局、调用约定、内存分配这些老知识完整过了一遍。这两门语言都是“看着像 C、想解决 C 老问题”的路线但设计哲学差异很明显用同一份需求各写一遍收获比单独学任何一门都大。这篇文章把原理、两套实现和几个真实的调试场景放在一起适合正在学数据结构、或者刚接触 Zig / C3、想找一个既能练语言又能打基础的小项目的人。不管你是写全栈应用还是底层驱动这个数据结构你迟早绕不开。1. 栈的核心机制LIFO 为什么会成为程序运行的地基1.1 一叠盘子LIFO 与基本操作讲到栈最经典的例子就是食堂里那一摞盘子。新洗好的盘子摞在最上面拿的时候也从最上面拿。这种“后进先出”的顺序用术语讲就是 LIFOLast In, First Out。栈提供的核心操作很少push压入、pop弹出、peek/top看一眼栈顶但不拿出来再加一个判断空栈的 isEmpty 和返回元素数量的 size基本就齐了。就这么点操作凭什么是程序运行的地基因为函数调用本身就是天然的 LIFO 结构函数 A 调用函数 BB 调用 CC 返回后必然回到 BB 返回后必然回到 A。先把 C 算完再接着算 B最后回到 A这个嵌套顺序和栈的后进先出完全吻合。编译器在设计函数调用机制时直接拿栈来保存返回地址、局部变量、寄存器现场你写代码时可以完全不管栈但程序跑起来之后CPU 每一步都在跟栈打交道。实现上栈有两种做法顺序栈和链式栈。顺序栈用一段连续内存加一个栈顶下标来模拟链式栈则用链表节点串起来。平时做算法题、写解析器、做工具库顺序栈更常见因为局部性好、操作少、不需要频繁申请小块内存。文章后面用 Zig 和 C3 实现的都是顺序栈。栈的应用远不止函数调用。编辑器里的撤销Undo就是一个栈每次操作压栈撤销就是弹出括号匹配、表达式求值中缀转后缀再计算、深度优先搜索、递归转迭代全是栈的直接应用。理解了一个数据结构等于同时理解了这些场景背后的共同规律。1.2 向下增长的原因硬件约定与地址空间布局不少初学者第一次看栈内存布局都会困惑为什么 push 一个元素栈指针的值反而变小这和直觉相反。原因要分两层看。第一层是硬件约定。x86 系列的 push 指令行为是先把栈指针 SP 减掉元素大小再把数据写到 SP 指向的位置。减就是从高地址往低地址移动。这套约定从 8086 时代就定下来了x86-64 也沿用了。其他主流架构如 ARM 的 AArch64、RISC-V栈默认也向下增长虽然某些模式可以配置成向上增长但几乎所有操作系统和编译器都采用向下增长为的是和 CPU 设计、调试工具保持一致。第二层是地址空间的利用效率。如果栈从比较高的地址往下长堆从比较低的地址往上长两者相向而行虚拟地址空间就不会因为各自预留一大块而浪费。反过来如果栈向上长、堆也向上长两个区域迟早撞在一起分配器还得做一堆协调工作。这个设计放到几十年前内存紧张的时代尤其重要现在更多是“历史惯性 实用主义”的叠加。1.3 栈与堆、队列的一页纸对比谈到栈就绕不开堆和队列。网上关于“堆和栈的区别”的讨论非常多但经常把概念混在一起。严格讲数据结构里的堆Heap是一种树形结构用于实现优先队列而程序运行时的“堆”是动态内存区域。这两个“堆”只是名字一样机制完全不同。运行时那个堆用 malloc/new 分配生命周期由开发者控制地址向上增长栈上的变量则由编译器自动分配和释放生命周期严格嵌套。一个需要手动管理一个自动管理这两种机制各管各的别混为一谈。队列和栈的区别就更直接栈是后进先出队列是先进先出FIFO。排队买东西先进先出一摞盘子后进先出。如果题目要求“最近最优先”的处理顺序用栈要求“先来先处理”用队列。热词里那个“单调栈”也值得一提——它在栈的基础上维护一个单调性约束用来解决“下一个更大元素”这类问题是算法题里的高频考点但在系统编程里用得不多。基础机制讲清楚之后接下来看函数调用是怎么在这块内存上搭积木的。2. 栈帧与调用约定从汇编层面拆解函数调用2.1 call / ret 与返回地址函数调用在硬件层面是怎么落地的x86-64 上call指令做两件事把 call 的下一条指令地址也就是返回地址压入栈然后跳转到目标函数。ret指令做相反的事从栈顶弹出返回地址跳回去。函数调用的返回机制硬件直接就是用栈实现的。这里有个细节值得琢磨为什么返回地址放在栈上而不是寄存器寄存器数量有限一个程序可能嵌套调用成千上万层函数每一层都需要保留自己的返回信息寄存器根本存不下。而栈天然支持嵌套每个调用压一份返回地址返回时弹出先后顺序完全不会乱。这就是“调用深度可以很大”的底层支撑。2.2 rbp / rsp 与栈帧布局在 x86-64 的 System V ABILinux 等平台默认的调用约定下函数进入后通常会有这样一段开头push rbp mov rbp, rsp sub rsp, 16第一句把上一个函数的基址指针保存到栈上第二句把当前栈指针复制给 rbp作为当前栈帧的基准第三句给局部变量腾出 16 字节空间。函数结束时对应mov rsp, rbp pop rbp ret一个典型的栈帧布局长这样高地址 --------------------- | 调用者栈帧 ... | --------------------- | 返回地址 | - [rbp8] --------------------- | 旧 rbp | - [rbp] --------------------- | 局部变量区 | - [rbp-8] 往下 --------------------- | 参数/临时区 | 低地址注意“往下”在地址上数值是减小的因为栈向下长。用 gdb 看的时候地址越小越靠近栈顶。2.3 不同 ABI 的讲究red zone 与 shadow space不要以为调用约定只有“参数放寄存器还是栈”这一个问题。System V ABI 里有一个叫 red zone 的区域栈指针 rsp 往下 128 字节信号处理器和调试器不会动它。叶子函数不再调用其他函数的函数如果局部变量不超过 128 字节可以省掉sub rsp和最后的恢复直接用 red zone 存数据性能白赚。Windows x64 约定则是另一套玩法调用者必须在栈上预留 32 字节的 shadow space给被调函数保存四个寄存器参数用。也就是说即使你的函数只有两个参数栈上也得留出 32 字节。这套规则和 Linux 下完全不同跨平台写汇编或者在调试器里看栈布局时很容易被这些差异坑到。2.4 虚拟机里的“栈”JVM 虚拟机栈与本地方法栈栈的概念不只存在于原生程序。JVM 运行 Java 字节码时每个线程都有自己的虚拟机栈里面存放栈帧每个栈帧对应一个 Java 方法调用方法里的局部变量表、操作数栈、动态链接信息都存在栈帧里。另外还有一个本地方法栈专门给 native 方法通过 JNI 调用的 C/C 代码使用。为什么分两个因为 Java 字节码的栈帧结构和 native 方法的栈帧结构完全不同各管各的互不干扰。它存在是因为 JVM 需要给自己管理的 Java 栈和外部 native 栈之间划一条清晰的边界同时避免 JNI 调用把 Java 栈帧结构搞乱。原理层面的栈帧和调用约定已经清楚接下来可以动手写代码了。3. Zig 实现显式分配器下的顺序栈3.1 为什么用 Zig 写数据结构先解释为什么选 Zig 而不是 C。Zig 保留了 C 的直接与可控但提供了泛型comptime 类型、错误联合、切片、defer 这些现代语言特性。写一个栈C 语言得用宏来模拟泛型或者干脆定义一堆 int 栈、float 栈Zig 可以通过Stack(comptime T: type)在编译期生成具体类型这一点对写数据结构的体验提升是根本性的。而且 Zig 没有隐式内存分配所有内存分配都要显式传入 Allocator这迫使我把“谁负责分配、谁负责释放”想清楚。如果你之前只写过 C 的链表、栈换成 Zig 之后最大的感受是类型信息更丰富但分配器还是那个分配器deinit 还是得自己调用没有 GC、没有 RAII。这是它和 Rust 最大的哲学分歧也是我欣赏它的地方——所有成本都摊在明面上。3.2 结构设计与扩容策略一个顺序栈的核心字段其实就三个指向连续内存的切片、当前元素数量、容量。Zig 的切片[]T自带长度但那个长度表示“已分配的元素个数”不能直接当栈长度用所以我单独用一个 len 字段表示当前栈内元素数量。扩容是最需要想清楚的部分每次 push 前检查 len 是否等于容量满了就扩容。常见策略是容量翻倍new_capacity capacity * 2这样均摊下来每次 push 的时间复杂度是 O(1)。为什么不是每次加固定大小因为固定大小扩容会反复触发 memcpy均摊复杂度退化成 O(n)。为什么初始容量选 4 而不是 1因为从 1 开始翻倍前几次 push 会连续触发扩容白白浪费几次分配和拷贝。这种细节平时不写不觉得写起来全是经验。3.3 push / pop / peek 的完整实现下面是一份基于 Zig 0.14 风格的完整实现const std import(std); pub fn Stack(comptime T: type) type { return struct { const Self This(); allocator: std.mem.Allocator, data: []T, len: usize, pub fn init(allocator: std.mem.Allocator) Self { return .{ .allocator allocator, .data [_]T{}, .len 0, }; } pub fn deinit(self: *Self) void { self.allocator.free(self.data); self.data [_]T{}; self.len 0; } pub fn push(self: *Self, value: T) !void { if (self.len self.data.len) { try self.grow(); } self.data[self.len] value; self.len 1; } pub fn pop(self: *Self) ?T { if (self.len 0) return null; self.len - 1; return self.data[self.len]; } pub fn peek(self: *const Self) ?T { if (self.len 0) return null; return self.data[self.len - 1]; } fn grow(self: *Self) !void { const new_capacity: usize if (self.data.len 0) 4 else self.data.len * 2; const new_data try self.allocator.alloc(T, new_capacity); memcpy(new_data[0..self.len], self.data[0..self.len]); self.allocator.free(self.data); self.data new_data; } }; }几个细节讲一下。pop 返回?T也就是 T 或 null空栈弹出得到 null而不是未定义行为。这个选择让调用方不需要在 pop 之前单独检查 isEmpty代码更安全。grow 里先分配新内存再把旧数据拷过去最后 free 旧内存顺序不能反。用 realloc 一类的接口可以省一次拷贝但 Zig 标准库里std.ArrayList内部也是类似思路自己实现一遍更容易看清内存生命周期。3.4 测试与内存分配器选择写完栈一定要配测试。下面这个测试验证 LIFO 顺序test stack reverses order { const allocator std.testing.allocator; var stack Stack(i32).init(allocator); defer stack.deinit(); try stack.push(1); try stack.push(2); try stack.push(3); try std.testing.expectEqual(as(?i32, 3), stack.pop()); try std.testing.expectEqual(as(?i32, 2), stack.pop()); try std.testing.expectEqual(as(?i32, 1), stack.pop()); try std.testing.expectEqual(as(?i32, null), stack.pop()); }用std.testing.allocator有一个额外好处它会在测试结束时检测内存泄漏。如果你的 deinit 漏了 free测试会直接失败。我第一次跑这段测试时因为忘记调用 deinit报出的泄漏信息把行号都标出来了排查很快。4. C3 实现切片、可空类型与错误处理4.1 C3 对 C 的现代化改造C3 是一门比较新的系统级语言目标是做“更好的 C”而不是“兼容 C 的 C”。它保留了 C 的语法骨架和心智模型同时加入了模块、泛型、切片、错误处理、方法语法等设施。写 C3 的体验有点像写 C但少了很多容易踩坑的角落。我之所以在学完 Zig 之后又用 C3 实现一遍同一个栈是因为这两门语言表面相似都是裸金属上的“新时代 C”实际设计取向差别很大Zig 强调编译期和显式分配C3 强调对 C 代码的平滑演进和更温和的类型系统。同一个数据结构写两遍语言差异就显形了。不过 C3 还处于快速迭代期语法和标准库在不同版本之间会有调整。下面的代码以接近 0.6/0.7 的写法为准如果版本不同优先参考随发行版自带的 examples 或文档。4.2 泛型结构体与核心函数C3 的泛型用T表达结构体定义大概这样struct Stack(T) { T* data; usz len; usz capacity; }我用指针加长度加容量没有用切片主要是为了兼容性更好。C3 也有真正的切片类型但不同版本里 API 变动比较大用指针反而更接近 C 的习惯逻辑也直白。stack_init 的职责是清零字段stack_free 的职责是释放 data 指向的内存并把指针置空。这个过程没有太多花活但一定要保证 init 和 free 成对出现。我特意把 init/free 设计成普通函数而不是方法就是想让调用方的生命周期一目了然。核心函数如下fn void stack_push(T, Stack(T)* s, T value) { if (s.len s.capacity) { stack_grow(s); } s.data[s.len] value; s.len 1; } fn T? stack_pop(T, Stack(T)* s) { if (s.len 0) return null; s.len - 1; return s.data[s.len]; } fn T? stack_peek(T, Stack(T)* s) { if (s.len 0) return null; return s.data[s.len - 1]; } fn void stack_grow(T, Stack(T)* s) { usz new_capacity s.capacity 0 ? 4 : s.capacity * 2; T* new_data malloc(T.sizeof * new_capacity); if (new_data null) { // 更规范的做法是把错误抛给上层 return; } mem::copy(new_data, s.data, T.sizeof * s.len); free(s.data); s.data new_data; s.capacity new_capacity; }看这份代码和 Zig 版本最大的差别在错误处理上。grow 分配失败时C 的做法通常很尴尬要么设置全局 errno要么返回特殊值。C3 提供了错误系统比我这里草草 return 要规范得多。更完整的做法是让 stack_push 返回fault!void扩容失败时用 try 把错误向上传播调用方再用 catch 决定怎么处理。Zig 的错误联合和 C3 的 fault 模型逻辑上类似语法上一个用!T一个用fault!T内核里那套“错误是值不是异常”的思路是一致的。4.3 调用方视角可选值与错误处理C3 的可选类型写作T?stack_pop的返回值是T?。调用方拿到值之后直接判断是否为空Stack(int) s; stack_init(s); defer stack_free(s); stack_push(s, 10); stack_push(s, 20); int? v stack_pop(s); if (v null) { // 空栈分支 } else { // 此时 v 是 int 值 }这里的 defer 和 Zig 的 defer 语义类似都是作用域结束时执行我用它保证栈内存一定被释放。C3 还有宏、编译期$if等设施不过写一个栈用不上那么复杂的东西。4.4 C3 版本差异提醒因为 C3 版本迭代很快我在实际写的时候踩过一个具体的坑旧版本里切片用[]T声明新版本对切片的内存所有权规则做了调整有些代码在新版编译器下会直接报错。如果你在照着这篇文章敲代码时发现malloc或者mem::copy不存在先看看是不是把标准库版本混用了。结构体和函数的逻辑不用变改一下 API 名字就行。5. 两个实现横向对照分配策略、空栈语义与语言设计哲学5.1 维度对照表把两个实现放到一起按维度列个表会更清楚维度Zig 版本C3 版本内存分配入口调用方传入 std.mem.Allocator内部直接 malloc / free泛型写法comptime 类型函数返回 structT泛型参数溢出返回?T空栈返回 nullT?空栈返回 null扩容失败传播!void 错误联合fault!void 错误值清理机制手动 deinit惯用 defer手动 free也有 defer切片支持原生切片长度编码在类型里有切片但版本差异较大对 C 源码兼容不兼容 C语法全新语法接近 C可平滑迁移这个表基本反映了两门语言的性格差异。Zig 更“新”把分配器显式作为参数传递整个标准库都围绕 Allocator 设计C3 更“旧”保留了很多 C 的现场感可以把现成的 C 算法一点点搬进 C3而不用重写所有内存管理代码。5.2 空栈语义与错误传播两个版本在空栈处理上是一致的pop 返回 null不触发未定义行为。这是现代语言相对 C 的一大进步。纯 C 里 pop 空栈通常只能返回特殊值或者 abort两种都不够优雅。特殊值需要调用方记得检查abort 则剥夺了错误处理的权利。错误传播方面Zig 用!T错误联合函数体里 try 一个表达式失败就返回该错误调用方用 catch 处理C3 用fault!T加 try/catch模型几乎一样。如果你从 C 过来刚开始会觉得“返回错误太麻烦不如返回 -1”但写了几百行之后会发现类型系统把错误路径显式标注出来之后代码的可读性和可维护性都上了一个台阶。5.3 扩容因子的选择空间换时间扩容因子选 2 还是 1.5是一个值得聊的话题。翻倍扩容实现简单均摊 O(1)但缺点是扩容后可能浪费大量内存尤其在小内存设备上。因子 1.5 更省空间但均摊复杂度稍微高一点而且乘法不是位运算CPU 上多一条指令。多数高级语言的标准库Rust 的 Vec、Go 的 slice都在 1.5 到 2 之间取一个值Java 的 ArrayList 是 1.5。我在这篇实现里选 2纯粹因为它是位运算、实现最直观。如果你的场景是内存受限的嵌入式设备建议改成 1.5或者干脆在初始化时预留足够容量让扩容尽量不发生。还有个容易忽略的点capacity 为 0 时不要直接capacity * 2那会一直得到 0。必须在扩容函数开头判断初始状态给一个初始容量。这个 bug 我见过不少人写过包括我自己第一次写顺序表时也栽过。5.4 什么时候该用链式栈顺序栈不是唯一答案。如果压入的元素大小不固定、单个元素很大或者根本不知道峰值容量链式栈可以避免“一次性分配一块大内存”的成本。但链式栈每个节点都要额外的 next 指针内存碎片也更严重局部性差很多。我在实际项目中能用顺序栈的场合绝对不用链式栈只有写解释器时因为操作数栈需要动态扩展且元素大小不一才会考虑链式结构。普通场景下顺序栈加翻倍扩容已经足够。6. 调试栈相关问题的实战记录溢出、越界与栈回溯6.1 案例一无限递归导致的 stack overflow栈溢出最常见的触发方式就是无限递归。我之前在调试一个解析器时词法分析函数里有个分支漏了终止条件结果递归一层套一层最终进程崩溃。崩溃信息里如果包含 “stack overflow” 或者 “protection stack overflow”十有八九就是栈空间耗尽。在 Linux 上主线程的栈大小默认 8 MB可以用ulimit -s查看。每次函数调用消耗一个栈帧一个栈帧少则几十字节多则几百字节。所以无限递归不一定要跑很久才崩——一个帧 200 字节8 MB 只能装四万层左右看起来并不深。写递归时要注意深度。比如递归遍历一棵深度五万的树用递归直接爆栈改用显式栈迭代就能轻松跑完。这正好用上前面实现的 Stack。6.2 案例二越界写坏返回地址后的栈回溯更隐蔽的问题是缓冲区越界。比如一个函数里定义了char buf[8]然后往里面写 16 个字节多出来的 8 个字节会先覆盖旧的 rbp再覆盖返回地址。函数返回时ret 指令从被污染的栈上读取“返回地址”程序就跳到完全错误的地方去了。这种崩溃在栈回溯里表现非常典型回溯的最后几个调用完全不合理甚至回溯本身都打不出来因为栈已经被写烂了。如果错误信息里出现 “stack (most recent call first)” 这样的输出说明回溯打印工具尝试从当前栈顶往回走但走到某一步就断掉了。排查这类问题的标准姿势是先复现然后用 gdb 或 lldb 跑到崩溃点bt看回溯。如果回溯里出现奇怪的地址再info frame和x/16gx $rsp看当前栈帧内容逐字节找哪一块被不该出现的模式覆盖了。有时候写坏的不是返回地址而是栈上的局部变量那就要用 watch 断点监控变量地址。6.3 用调试器观察栈指针的变化想真正理解栈调试器是最好的老师。下面这段 gdb 命令适合观察 push/pop 前后栈指针变化break my_function run info registers rsp rbp x/8gx $rsp在 Zig 或 C3 编译出来的程序里函数入口打断点记录 rsp单步执行到函数内部再记录一次。你会发现 rsp 确实变小了栈帧里依次排着返回地址、旧 rbp、局部变量。看多了汇编里的sub rsp, N就不再是抽象指令而是能立刻在脑海里映射成一块具体的内存区域。macOS 上如果是 lldb命令写法略有不同但思路一样。6.4 日常开发中保护栈的几条经验最后说几条我自己的经验。第一递归函数一定要先想清楚终止条件和最大深度超过一定深度直接改成迭代加显式栈。第二C/C 风格的固定大小缓冲区是栈破坏重灾区能不用就不用用了就要严格检查写入长度。第三在 Zig/C3 里写类似栈的数据结构时把空栈、扩容失败这些边界逐个用测试覆盖debug 分配器开着不要跳过。第四看到异常的栈回溯时先怀疑“谁在写不该写的内存”而不是“编译器是不是 bug 了”。绝大多数情况下bug 都在你自己的代码里。