第十二章 生命周期
本章要点
第六章介绍了常见进程布局,并强调 C 标准真正规定的是对象的存储期等语义。本章继续回答“对象的存储何时存在、指针何时仍然有效”。
生命周期就是变量从被创建到被销毁的全过程。本章从这个角度出发,先简要回顾作用域,然后引出生命周期的三种形态,最后通过综合案例和方法论把两个概念串起来。具体涵盖:
- 作用域简要回顾(第六章已详细讲过,此处只做提炼)
- 生命周期的三种存储期:自动、静态、动态——创建和销毁的时机各有什么不同
- 作用域与生命周期脱钩的场景——
static局部变量 - 综合案例分析:用生命周期视角跟踪变量的“生与死“
- 方法论:如何选择堆还是栈
一、作用域——简要回顾
第六章在讲局部变量时已经引入了作用域:{ } 就是变量的“辖区“,出了辖区变量名就不可用了,内层可以看到外层的变量,外层看不到内层的变量。
C 语言中作用域有四种形式——块作用域({ } 内部)、文件作用域(所有函数之外)、函数原型作用域(函数声明中的参数列表内)和函数作用域(仅适用于 goto 标签)——但归根结底,作用域是一个编译时的概念:编译器用它来检查“你能不能在这里用这个变量名“。
关键认知:作用域只管名字能不能用,不管数据还活没活着。static 局部变量出了 { } 名字不能用了,但数据还活着。这个差异,引出了本章真正的主题。
二、生命周期——变量什么时候“活着“
先理解“生命周期“到底在说什么
存储期(storage duration)决定对象所需存储在何时保留;生命周期(lifetime)描述对象在何时存在并可按其类型访问。二者紧密相关,但不是同一个术语。本章会在不混淆标准概念的前提下,用“生与死”帮助理解。
接下来,我们以生命周期视角重新分析第六章的三个内存区域。什么叫以生命周期视角?就是以变量为主体,关注它什么时候被创建、什么时候被销毁。
本章重点比较三类常见情形:自动存储期、静态存储期,以及由 malloc 等函数获得的已分配存储期。调用栈和“堆”是常见实现术语,标准语义应以前述存储期为准。
1. 自动存储期——随块执行开始和结束
多数定义在块内部、没有 static 等存储类说明符的对象具有自动存储期(automatic storage duration)。常见实现可能使用调用栈或寄存器,程序员不需要也不能对这些对象调用 free。
开始时机:执行进入相应块时,对象的存储得到保留;变长数组的规则更细,本章不展开。
结束时机:执行离开相应块时,对象生命周期结束,其指针不能再解引用。
下面用一个稍复杂的例子来观察整个过程:
#include <stdio.h>
void inner(void)
{
int c = 30; // ③ c 的生命周期开始
printf("inner: c = %d\n", c);
} // ④ 离开 inner,c 销毁
void outer(void)
{
int b = 20; // ② b 的生命周期开始
printf("outer: b = %d\n", b);
inner();
// inner 返回后,c 已经不存在了
printf("outer: b = %d\n", b);
} // ⑤ 离开 outer,b 销毁
int main(void)
{
int a = 10; // ① a 的生命周期开始
printf("main: a = %d\n", a);
outer();
printf("main: a = %d\n", a);
return 0;
} // ⑥ 离开 main,a 销毁
从语言语义看,对象生命周期按嵌套调用顺序开始和结束;常见调用栈实现会相应地按后进先出管理栈帧。优化后的实际存储位置不影响这一语义。
对这里的普通自动对象而言,离开相应块后生命周期结束。此后即使某个指针还保留旧地址表示,也不能再通过它访问对象。这就是为什么不能返回指向普通局部对象的指针供调用者解引用。
2. 静态存储期——程序全程存在的“常驻居民“
具有静态存储期(static storage duration)的对象,其存储持续整个程序执行过程。常见实现把它们放入数据段、BSS 或只读数据映射,但标准不规定具体段名。
哪些变量具有静态存储期?两类:
- 全局变量:定义在所有函数外部的变量。即使没加
static关键字,它们也是静态存储期。 static修饰的局部变量:定义在函数内部但加了static关键字。
全局变量很简单——文件作用域 + 静态存储期,整个程序运行期间随处可用。真正值得细说的是 static 局部变量,因为它展示了作用域和生命周期可以“脱钩“:
#include <stdio.h>
void count_calls(void)
{
static int count = 0; // 静态初始化一次,值在调用之间保留
count++;
printf("该函数已被调用 %d 次\n", count);
}
int main(void)
{
count_calls(); // 输出:该函数已被调用 1 次
count_calls(); // 输出:该函数已被调用 2 次
count_calls(); // 输出:该函数已被调用 3 次
return 0;
}
逐次调用时发生了什么:
| 调用次数 | 进入 count_calls 时 | count 的值 | 离开后 |
|---|---|---|---|
| 第 1 次 | count 已完成静态初始化,然后自增为 1 | 1 | count 还在 |
| 第 2 次 | 不重新初始化,count 保持上次的值 1,自增为 2 | 2 | count 还在 |
| 第 3 次 | 同上,自增为 3 | 3 | count 还在 |
count 的静态初始化在程序启动阶段完成一次,并不会在每次调用时重复。普通自动局部对象每次进入块都会形成新的生命周期,而该 static 对象贯穿整个程序执行。
再强调一遍:static 只改变生命周期,不改变作用域。count 仍然只能在 count_calls 函数内部通过名字访问——你在 main 里写 count 会编译报错。但这个变量的“寿命“却覆盖了整个程序运行期。
3. 已分配存储期——由分配与释放控制
通过 malloc、calloc、realloc 获得的存储具有已分配存储期(allocated storage duration,口语中常称动态内存)。其生命周期需要程序显式管理:
- 出生:调用
malloc/calloc成功返回的那一刻 - 死亡:调用
free的那一刻 - 如果丢失最后一个指针且未调用
free:在进程余下时间里无法再释放,形成内存泄漏
动态存储期在第十章已经详细讨论过,这里不再重复函数的用法,只强调它和另外两种存储期的本质区别:自动和静态的回收时机是语言规则定死的,动态的回收时机是你决定的。 这个“自由“恰恰是 C 语言内存管理中最容易出错的地方。
4. 三种存储期对比
| 自动存储期 | 静态存储期 | 动态存储期 | |
|---|---|---|---|
| 常见实现 | 栈帧或寄存器 | 数据/只读映射 | 运行库动态分配区域 |
| 创建时机 | 进入 {} 时 | 程序启动时 | malloc 成功时 |
| 销毁时机 | 离开 {} 时(自动) | 程序结束时(自动) | free 时(手动) |
| 由谁管理 | 编译器 | 编译器 | 程序员 |
| 典型变量 | 普通局部变量、函数参数 | 全局变量、static 变量 | malloc 分配的内存 |
| 跨函数传递 | 生命周期内可以;不得逃逸后使用 | 可以 | 释放前可以 |
三类存储期各有一套管理规则,但不应把它们机械等同于固定的物理分区。接下来把作用域、生命周期和指针有效性放在一起分析。
三、综合案例分析
案例一:返回局部变量的地址(经典错误)
#include <stdio.h>
#if 0
int *get_value(void)
{
int n = 42;
return &n; // 返回局部变量 n 的地址
}
#endif
int main(void)
{
// 错误示范已由 #if 0 禁用:不能解引用指向已结束生命周期对象的指针。
return 0;
}
逐步分析:
get_value被调用,自动对象n的生命周期开始,值为 42。return &n试图把指向n的指针返回给调用者。get_value返回时,n的生命周期结束。- 旧指针不再指向存活对象,随后解引用会产生未定义行为。
这个错误的本质是:指针指向的内存的生命周期短于指针本身的使用期。 n 的生命周期在函数返回时结束,但 p 还想继续用它。这类错误编译器通常只会给一个警告,不会阻止编译,运行时也不一定立即崩溃——“有时能跑、有时炸“正是它危险的原因。
案例二:静态局部变量——安全返回地址
#include <stdio.h>
int *get_counter(void)
{
static int count = 0;
count++;
return &count; // 返回静态局部变量的地址
}
int main(void)
{
int *p1 = get_counter(); // count = 1
int *p2 = get_counter(); // count = 2
printf("%d %d\n", *p1, *p2); // 输出 2 2
return 0;
}
逐步分析:
- 第一次调用前,
count已完成静态初始化;调用时自增为 1并返回其地址。 get_counter返回——count的作用域结束了(名字不可见了),但它的生命周期没有结束,静态存储区的数据还在。- 第二次调用:
count的名字重新可见,值保持为 1,自增为 2,返回地址。 p1和p2指向的是同一块静态存储区地址,那块地址始终有效。所以*p1和*p2都输出 2——因为count只有一个,两次调用操作的是同一个变量。
这个案例展示了作用域和生命周期分离的含义:名字只是“访问权限“,生命周期才是“存在与否“。count 的名字在函数返回后暂时不可见,但它本身一直活着。
案例三:指针变量 vs 它指向的内存
#include <stdio.h>
#include <limits.h>
#include <stdint.h>
#include <stdlib.h>
int *create_array(size_t n)
{
if (n > SIZE_MAX / sizeof(int) || n > (size_t)INT_MAX / 10) return NULL;
int *arr = malloc(n * sizeof(int));
if (arr == NULL) return NULL;
for (size_t i = 0; i < n; i++)
arr[i] = (int)i * 10;
return arr; // 返回分配对象的地址
}
int main(void)
{
int *data = create_array(5);
if (data == NULL) return 1;
for (size_t i = 0; i < 5; i++)
printf("%d ", data[i]); // 输出 0 10 20 30 40
free(data); // 用完后释放
return 0;
}
这个案例中其实有两个不同生命周期的东西:
| 东西 | 存储位置 | 存储期 | 何时销毁 |
|---|---|---|---|
arr(指针变量本身) | 常见于栈帧 | 自动存储期 | create_array 返回时 |
arr 指向的分配对象 | 动态分配区 | 已分配存储期 | free(data) 时 |
create_array 返回后,局部指针变量 arr 的生命周期结束,但它保存的指针值已经作为返回值传出。main 中的 data 接收该值,因此可以在 free 前继续访问分配对象。一个指针变量的生命周期结束,并不会自动结束它所指分配对象的生命周期。
对比案例一:n 是自动对象,函数返回时其生命周期结束;案例三中的 arr 虽然也是自动对象,但它指向的是独立的分配对象,后者直到 free 才结束生命周期。判断指针是否有效,要看被指向对象,而不是只看指针变量本身。
案例四:多级指针与生命周期
再往深一层——如果函数需要修改调用者的指针,该怎么办?这涉及指针的指针,生命周期分析需要多绕一层:
#include <stdio.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
bool allocate(int **pp, size_t n)
{
if (pp == NULL || n > SIZE_MAX / sizeof(int)) return false;
*pp = malloc(n * sizeof(int)); // 修改调用者的指针
return *pp != NULL;
}
int main(void)
{
int *data = NULL;
if (!allocate(&data, 3)) return 1;
data[0] = 10; data[1] = 20; data[2] = 30;
for (int i = 0; i < 3; i++)
printf("%d ", data[i]);
free(data);
return 0;
}
生命周期分析:
data是main中的自动对象,离开相应块时生命周期结束data指向的对象具有已分配存储期,在free时结束生命周期pp是allocate的形参,也是自动对象*pp就是data——通过解引用pp,allocate修改了main中的data
pp 销毁了没关系,它只是临时用来“找到 data“的——关键操作 *pp = malloc(...) 已经把堆地址写进了 main 的 data 变量里,而 data 的生命周期还没结束。指针可以换来换去传递,只要最终指向的数据在目标生命周期内有效就行。
这四个案例聚焦同一条规律:形成悬空指针的原因,是被指向对象的生命周期已经结束。指针还可能因未初始化、为空、越界或类型不匹配而无效,因此每次使用前都要确认来源、边界、类型和生命周期。
四、内存管理——基于生命周期的方法论
理解了作用域和生命周期,我们现在可以正面回答一个更底层的问题:什么是内存管理?
内存管理就是确保程序中每一块内存的生命周期和它的使用需求精确匹配:
- 不早:使用的时候内存必须还在(不能访问已释放的变量)
- 不晚:不用的时候内存必须释放(不能占着不还,造成泄漏)
- 不混:每块内存有明确的“责任人“——谁分配、谁释放、谁使用,要说得清楚
这三个要求听起来简单,但在成百上千行的程序中,不借助一套系统性的思考方法,几乎不可能做到零差错。C 语言给出的设计框架其实非常清晰。面对一块数据,依次搞清楚三件事:
第一步:使用自动对象还是动态分配?
这是最优先的决策——选择不同的内存区域,意味着完全不同的管理方式。判据很直接:
- 普通局部变量、数组:大小固定、只在当前作用域使用时,优先声明为自动对象,例如
int x;或int arr[100];。常见实现会把其中一部分放在调用栈中,也可能放入寄存器或被优化掉;关键性质是离开作用域后生命周期自动结束。 - 动态内存分配:大小运行时才能确定,或者数据需要在函数返回后继续存活(传给调用者)→ 用堆。调用
malloc/calloc申请第十章已详细讲过,这里不再重复。
一条实用原则:能用作用域清晰的自动对象解决时,不必引入动态分配。
- 自动对象:生命周期由执行范围管理,不会形成动态内存泄漏
- 动态分配:需要明确所有权和
free路径,适合确有运行期容量或跨作用域生命周期需求的情况
很多初学者学会 malloc 之后到处用,觉得动态分配才“高级“——恰恰相反。在不需要动态存储期的场景下用 malloc,不是高级,是给自己找麻烦。
第二步:谁,在什么时候创建了变量?
这是变量生命周期的开始。创建者和创建时机取决于你第一步选了哪种内存:
- 自动对象:执行进入相应块时开始生命周期,编译器选择寄存器、栈帧或其他等价实现。比如
int a = 10;不需要手动分配或释放。 - 静态存储区变量:编译器和运行时系统在程序启动时统一分配。全局变量和
static变量在main函数执行之前就已经存在了。 - 动态分配内存:调用
malloc/calloc时,C 运行库的分配器提供一块满足要求的存储;分配器可能复用已有内存,也可能再向操作系统申请资源。该对象一直存活到被free,或进程终止为止。
第三步:谁,在什么时候销毁了变量?
这是变量生命周期的结束。销毁者和销毁时机同样因存储期而异:
- 自动对象:离开相应块时生命周期结束。你不需要、也不能对它调用
free;让指针逃逸并在之后解引用会产生未定义行为。 - 静态存储区变量:运行时系统在程序结束时统一回收。全局变量和
static变量从程序启动活到程序退出,整个过程中你不需要管。 - 堆内存:你调用
free的那一刻销毁。这是 C 语言内存管理中唯一需要你主动执行的释放操作。忘了free就是内存泄漏;free之后继续用就是悬空指针。
把这三步连在一起,就是 C 语言内存管理的全貌:
先选择合适的存储期 → 再确认对象何时开始和结束生命周期 → 最后保证每次访问都发生在有效期内。
日常编程中养成一个习惯:每写一个变量声明或 malloc 调用,心里默念一遍——它什么时候死的?谁杀的? 这两个问题有了明确答案,悬空指针和内存泄漏就离你很远。整个指针篇到此收束于这条准则——希望它成为你今后写每一行 C 代码时的底层直觉。