这是一个非常重要的概念,因为它既是数据结构,也是 C 语言程序运行时的核心机制。

(图片来源网络,侵删)
我会从以下几个方面来解释:
- 栈的本质:一种数据结构
- 栈的两种形态:静态栈 vs. 动态栈
- C 语言程序运行时的栈
- 栈的核心思想
栈的本质:一种数据结构
栈是一种“后进先出”(Last-In, First-Out, LIFO)的数据结构。
你可以把它想象成一个只有一个开口的容器,比如一摞盘子或者一盒子弹。
- 压栈:你只能把新盘子放到最上面,或者把新子弹压到弹夹的顶端,在数据结构中,这个操作叫做 Push。
- 出栈:你只能从最上面拿走盘子,或者从顶端打出子弹,在数据结构中,这个操作叫做 Pop。
核心特点:

(图片来源网络,侵删)
- LIFO (后进先出):最后被放入栈中的元素,将是第一个被取出的元素。
- 操作受限:只能在栈顶进行插入和删除操作,不能在中间或底部操作,这使得它非常简单高效。
栈的基本操作:
push(item):将一个元素压入栈顶。pop():弹出并返回栈顶元素。peek()或top():查看栈顶元素,但不移除它。isEmpty():检查栈是否为空。size():返回栈中元素的数量。
栈的两种形态:静态栈 vs. 动态栈
在 C 语言中,我们可以自己实现一个栈,根据底层数组的实现方式,可以分为静态栈和动态栈。
A. 静态栈
静态栈使用固定大小的数组来实现。
- 优点:实现简单,内存分配在编译时完成。
- 缺点:大小固定,如果数据量超过数组大小,会导致“栈溢出”;反之,会造成内存浪费。
示例代码:
#include <stdio.h>
#include <stdbool.h>
#define MAX_SIZE 100
// 定义一个静态栈结构体
typedef struct {
int data[MAX_SIZE]; // 存储元素的数组
int top; // 栈顶指针,指向最后一个元素的位置
} StaticStack;
// 初始化栈
void initStack(StaticStack *s) {
s->top = -1; // 栈为空时,top 初始化为 -1
}
// 检查栈是否为空
bool isEmpty(StaticStack *s) {
return s->top == -1;
}
// 检查栈是否已满
bool isFull(StaticStack *s) {
return s->top == MAX_SIZE - 1;
}
// 压栈
void push(StaticStack *s, int value) {
if (isFull(s)) {
printf("Stack Overflow! Cannot push %d\n", value);
return;
}
s->data[++s->top] = value; // top 先加 1,再赋值
}
// 出栈
int pop(StaticStack *s) {
if (isEmpty(s)) {
printf("Stack Underflow! Cannot pop from an empty stack.\n");
return -1; // 返回一个错误值
}
return s->data[s->top--]; // 先取值,top 再减 1
}
// 查看栈顶元素
int peek(StaticStack *s) {
if (isEmpty(s)) {
printf("Stack is empty.\n");
return -1;
}
return s->data[s->top];
}
int main() {
StaticStack myStack;
initStack(&myStack);
push(&myStack, 10);
push(&myStack, 20);
push(&myStack, 30);
printf("Top element is: %d\n", peek(&myStack)); // 输出 30
printf("Popped element: %d\n", pop(&myStack)); // 输出 30
printf("Popped element: %d\n", pop(&myStack)); // 输出 20
push(&myStack, 40);
printf("Top element is: %d\n", peek(&myStack)); // 输出 40
return 0;
}
B. 动态栈
动态栈使用动态内存分配(如 malloc, calloc, realloc)来实现,其大小可以在运行时改变。
- 优点:灵活性高,可以根据需要动态调整大小,避免了静态栈的浪费和溢出问题。
- 缺点:需要手动管理内存,有内存泄漏的风险。
实现思路:
通常使用链表来实现动态栈,每个节点包含数据和指向下一个节点的指针。push 操作在链表头部进行,pop 操作也从头部进行。
#include <stdio.h>
#include <stdlib.h>
// 定义链表节点
typedef struct StackNode {
int data;
struct StackNode *next;
} StackNode;
// 定义动态栈
typedef struct {
StackNode *top; // 栈顶指针,指向链表的头节点
} DynamicStack;
// 初始化栈
void initDynamicStack(DynamicStack *s) {
s->top = NULL;
}
// 压栈
void pushDynamic(DynamicStack *s, int value) {
StackNode *newNode = (StackNode*)malloc(sizeof(StackNode));
if (!newNode) {
printf("Memory allocation failed!\n");
return;
}
newNode->data = value;
newNode->next = s->top; // 新节点指向原来的栈顶
s->top = newNode; // 新节点成为新的栈顶
}
// 出栈
int popDynamic(DynamicStack *s) {
if (s->top == NULL) {
printf("Stack is empty.\n");
return -1;
}
StackNode *temp = s->top;
int value = temp->data;
s->top = s->top->next; // 栈顶指向下一个节点
free(temp); // 释放旧栈顶节点的内存
return value;
}
// ... (可以继续实现 isEmpty, peek 等函数)
int main() {
DynamicStack myStack;
initDynamicStack(&myStack);
pushDynamic(&myStack, 100);
pushDynamic(&myStack, 200);
printf("Popped: %d\n", popDynamic(&myStack)); // 输出 200
printf("Popped: %d\n", popDynamic(&myStack)); // 输出 100
return 0;
}
C 语言程序运行时的栈
这是 C 语言中“栈”更核心、更底层的含义,当你运行一个 C 程序时,操作系统会为其分配一块内存,这块内存被组织成几个区域,其中最重要的就是栈区。
程序内存布局大致如下:
+------------------+
| 栈区 | <- 高地址
| (Stack) | - 局部变量
| - 函数参数 | - 函数返回地址
| - 返回地址 | - 函数调用帧
| - 局部变量 |
+------------------+
| 堆区 | <- 动态内存分配 (malloc, new)
| (Heap) |
+------------------+
| (BSS段) | <- 未初始化的全局/静态变量
+------------------+
| (数据段) | <- 已初始化的全局/静态变量
+------------------+
| (代码段) | <- 程序的机器码
| (Text) |
+------------------+
栈区的工作原理:
- 函数调用:当程序调用一个函数时,系统会创建一个“栈帧”(Stack Frame)并压入栈顶,这个栈帧包含了:
- 函数的参数
- 函数的局部变量
- 函数执行完后返回到调用点的地址
- 函数执行:函数在它的栈帧内执行,所有局部变量的内存都在这个栈帧上分配。
- 函数返回:当函数执行完毕并返回时,它的栈帧会被“弹出”栈,内存被自动释放。
栈区的重要特性:
- 自动管理:内存的分配和释放是自动的,由编译器和运行时环境完成,程序员无需手动干预。
- 速度快:因为内存分配和释放只是在移动一个栈顶指针,所以速度非常快。
- 大小有限:栈的大小是有限的(Linux 上通常是几 MB),如果递归调用太深,或者在函数中定义了过大的局部变量(比如一个巨大的数组),就会导致栈溢出,程序会崩溃。
- 生命周期:变量的生命周期与函数绑定,函数返回,变量就销毁了。
示例:理解函数调用栈
#include <stdio.h>
void functionB(int b) {
int varB = b * 2;
printf("In functionB: varB is at address %p, value is %d\n", &varB, varB);
}
void functionA(int a) {
int varA = a + 10;
printf("In functionA: varA is at address %p, value is %d\n", &varA, varA);
functionB(varA);
}
int main() {
int mainVar = 5;
printf("In main: mainVar is at address %p, value is %d\n", &mainVar, mainVar);
functionA(mainVar);
printf("Back in main.\n");
return 0;
}
当你运行这个程序时,调用栈的变化如下:
main()函数被调用,mainVar被压入栈。main()调用functionA(5),functionA的栈帧被压入栈,varA在functionA的栈帧中创建。functionA调用functionB(15),functionB的栈帧被压入栈,varB在functionB的栈帧中创建。functionB执行完毕,其栈帧被弹出。functionA执行完毕,其栈帧被弹出。- 最终只剩下
main()的栈帧,程序结束。
注意:你可以打印出这些变量的地址,会发现 &mainVar > &varA > &varB(地址从高到低),这证明了它们是按照“后进先出”的顺序被分配在内存中的。
栈的核心思想
| 特性 | 作为数据结构的栈 | 作为程序运行时的栈 |
|---|---|---|
| 本质 | 一种 LIFO 的数据结构,用于存储数据。 | 程序运行时的一块内存区域,用于管理函数调用。 |
| 目的 | 提供一种受限的、高效的存取方式(如括号匹配、表达式求值、函数调用历史记录等)。 | 管理函数的执行流程、局部变量、参数和返回地址。 |
| 实现 | 可以用数组(静态)或链表(动态)手动实现。 | 由编译器和操作系统在底层自动实现和管理。 |
| 生命周期 | 由程序员手动创建和销毁(如果是自定义的)。 | 随函数调用自动创建和销毁。 |
| 核心操作 | push, pop, peek。 |
函数调用(压栈)和函数返回(出栈)。 |
- 当你手动实现一个数据结构来解决特定问题时,你创建的是数据结构上的栈。
- 当你编写 C 程序时,系统为你自动创建和管理了一个运行时栈,它支撑着整个程序的执行。
理解这两种“栈”的区别和联系,对于掌握 C 语言的内存管理和程序运行机制至关重要。
