从指针开始讲起的 C 语言教程 2
上一篇最后留下了一个问题:
int *wrong(void) {
int x = 42;
return &x;
}
为什么 wrong 返回以后,x 就失效了?
如果它占用的那几个字节还在内存里,为什么我们不能继续用?如果确实需要让一块内存活得比一次函数调用更久,又该把它放在哪里?
这些问题通常会被简单地回答成:局部变量在栈上,动态内存在堆上。
这句话大体没错,但只背住这句话并没有什么用。真正重要的不是某块内存被画在示意图的上面还是下面,而是:
它什么时候开始存在,什么时候结束,以及由谁负责结束它。
函数调用不是瞬移
先看一个普通函数:
int add(int a, int b) {
int result = a + b;
return result;
}
int main(void) {
int answer = add(10, 20);
return answer;
}
执行到 add(10, 20) 时,计算机不能只是跳到 add 的第一行,然后忘掉自己从哪里来的。
它至少需要记住几件事:
- 参数
a和b的值 add的局部变量resultadd结束以后应该回到哪里- 调用者当前需要保留的一些状态
这些信息通常被放进一个叫作栈帧的区域。可以粗略画成:
高地址
+----------------------+
| main 的栈帧 |
| answer |
+----------------------+
| add 的栈帧 |
| a, b, result, 返回点 |
+----------------------+
低地址
当 main 调用 add 时,add 的栈帧被建立;当 add 返回时,这个栈帧被撤销,执行位置回到 main。
这里的“栈”来自一种数据结构:后进去的东西先出来。
main 先调用 add,所以 add 必须先结束,main 才能继续结束。如果 add 又调用了 multiply,那就会变成:
main 进入
add 进入
multiply 进入
multiply 返回
add 返回
main 返回
这正好是后进先出的顺序。
不过要注意,C 标准主要规定程序应该表现成什么样,并没有要求所有实现都必须把局部变量塞进某一段物理内存。编译器可能把变量放进寄存器,也可能彻底优化掉它。
“栈帧”是理解普通机器实现的好模型,不是要求每台 C 机器内部都长得一模一样的神谕。
为什么不能返回局部变量的地址
现在再看开头的错误代码:
int *wrong(void) {
int x = 42;
return &x;
}
调用函数时,x 随着这次函数调用开始存在。函数返回时,x 的生命周期结束。
如果用栈帧模型理解,就是:
调用 wrong 前:
+----------------------+
| 调用者的栈帧 |
+----------------------+
调用 wrong 时:
+----------------------+
| 调用者的栈帧 |
+----------------------+
| wrong 的栈帧 |
| x = 42 |
+----------------------+
wrong 返回后:
+----------------------+
| 调用者的栈帧 |
+----------------------+
| 这里不再属于 x |
+----------------------+
&x 得到的地址数字可以被返回,但它指向的对象已经不存在了。
下一次函数调用很可能复用同一片位置:
int *p = wrong();
some_other_function();
printf("%d\n", *p);
some_other_function 的栈帧可能盖在原来 x 的位置上。此时 p 里的地址没有变化,地址指向的内容却早已不再是那个 x。
所以,问题不是“内存里的 42 有没有立刻被擦掉”。对象生命周期结束,也不意味着机器必须马上把那几个字节清零。
问题是从函数返回的那一刻起,C 语言不再允许你通过这个地址访问 x。那块存储可以被拿去做任何别的事情。
递归为什么会耗尽栈
栈帧也能解释递归:
int factorial(int n) {
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
计算 factorial(4) 时,并不是只有一个 n 在反复变化。每次调用都有自己的一份参数和返回位置:
factorial(4)
factorial(3)
factorial(2)
factorial(1)
最里面的调用先返回,然后一层层拆回来。
如果递归永远停不下来,新的栈帧就会不断出现。栈空间通常有一个有限大小,最终会被耗尽,这就是所谓的 stack overflow。
它不是因为递归在数学上邪恶,而是因为每个尚未返回的函数调用都需要保存状态。调用层数无限增加,保存状态所需的空间当然也会无限增加,而真实机器没有无限内存。
“局部变量”并不等于“栈变量”
上一篇看过这个例子:
void count(void) {
static int n = 0;
n++;
}
n 写在函数里面,所以它的名字只在 count 中可见。但 static 让它拥有静态存储期:它在程序开始时就已经存在,并一直活到程序结束。
它不会随着每次 count 调用重新出现和消失。
这再次说明作用域和生命周期必须分开:
普通局部变量
作用域:所在的大括号
生命周期:执行进入声明处以后,到离开这次块执行为止
static 局部变量
作用域:所在的大括号
生命周期:整个程序
日常交流中,人们常说普通局部变量“在栈上”,全局变量和 static 变量“在静态区”。这些说法描述的是常见实现。
从 C 语言的角度,更准确的词是自动存储期、静态存储期和动态存储期。它们关注的是对象能活多久,而不是强迫编译器采用某一张固定的内存地图。
当生命周期不应该跟着函数走
假设我们要写一个函数,根据调用者给出的长度创建一个数组:
int *make_array(size_t length) {
int a[length];
return a;
}
这仍然是错的。
无论数组长度是不是运行时才知道,a 都是当前函数调用里的对象。函数一返回,它就失效了。
我们需要一种不依附于当前函数栈帧的存储。它应该在我们明确申请时开始存在,在我们明确表示不再需要时结束。
这就是动态内存分配。
#include <stdlib.h>
int *make_array(size_t length) {
int *a = malloc(length * sizeof(int));
return a;
}
malloc 的参数是要申请的字节数。它会尝试找到一块至少这么大的、满足对齐要求的动态存储,然后返回这块存储开头的地址。
这里需要 length 个 int,所以字节数是:
length * sizeof(int)
不要直接把它写成 length * 4。int 在许多机器上确实是 4 字节,但 C 标准没有保证它永远是 4。sizeof(int) 才是在问当前实现里一个 int 到底占多少字节。
malloc 返回的是一块还没有类型故事的内存
malloc 的声明大致是:
void *malloc(size_t size);
void * 可以被理解成一种“只记录地址,暂时不说明那里是什么”的指针。
malloc 只知道你需要多少字节。它不知道你准备用来放 int、结构体,还是某种别的东西。
int *numbers = malloc(10 * sizeof(int));
这里是左边的 int * 和之后的访问方式,把这片存储当作 int 数组使用。
在 C 语言中,不需要给 malloc 的返回值加显式类型转换:
int *numbers = malloc(10 * sizeof(int)); // C 中正常的写法
int *numbers = (int *)malloc(10 * sizeof(int)); // 没有必要
有些人从 C++ 教程里学到了第二种写法。C++ 不允许 void * 自动转换成其他对象指针,但 C 允许。我们现在写的是 C,不需要为了让它长得更“完整”而添加无用的转换。
更常见的写法是:
int *numbers = malloc(10 * sizeof(*numbers));
sizeof(*numbers) 表示一个 numbers 所指元素的大小。这样以后即使把 numbers 改成别的类型,也不容易忘记同步修改 sizeof 里的类型。
注意,sizeof(*numbers) 不会真的解引用一个尚未初始化的指针。除少数特殊情况外,sizeof 只根据表达式的类型计算大小,并不会运行这个表达式。
申请可能失败
malloc 并不承诺一定成功。
int *numbers = malloc(10 * sizeof(*numbers));
如果无法提供所需存储,它会返回空指针。于是,在使用前应该检查:
int *numbers = malloc(10 * sizeof(*numbers));
if (numbers == NULL) {
// 处理分配失败
}
如果不检查就写:
numbers[0] = 42;
而 numbers 恰好是 NULL,那就相当于解引用空指针。
在某些短命的命令行程序中,分配失败以后直接结束程序可能是合理策略;在服务器、编辑器或数据库里,处理方式又可能完全不同。C 不知道你的程序应该如何恢复,因此它只把失败告诉你,决定权仍然留给程序。
还有一个容易忽略的问题:乘法本身可能溢出。
如果 length 大得离谱,length * sizeof(*numbers) 可能绕回一个很小的数。malloc 成功分配了这块过小的内存,后面的代码却以为它足够容纳 length 个元素。
完整的通用分配函数应该先检查:
#include <stdint.h>
#include <stdlib.h>
int *make_array(size_t length) {
if (length > SIZE_MAX / sizeof(int)) {
return NULL;
}
return malloc(length * sizeof(int));
}
初学时不必每写一次 malloc 就被整数溢出吓得睡不着,但应该知道:分配大小也是一次计算,而 C 的整数计算同样有边界。
动态内存不会自己跟着作用域消失
现在来看 make_array:
int *make_array(size_t length) {
int *a = malloc(length * sizeof(*a));
return a;
}
函数返回时,局部变量 a 确实结束了生命周期。但 a 只是一个存放地址的指针变量,它与 malloc 创建的那块动态存储不是同一个对象。
可以画成:
make_array 的栈帧
+-------------------+
| a: 地址 5000 | --------+
+-------------------+ |
v
动态存储 +-------------------+
| 地址 5000 开始的 |
| 一组 int |
+-------------------+
函数返回以后,左边的 a 消失,右边的动态存储仍然存在。地址值作为返回值被复制给调用者:
int *numbers = make_array(10);
现在调用者手中的 numbers 继续指向那块存储。
这就是动态分配最重要的能力:对象的生命周期不再自动绑在某一次函数调用上。
但自动消失这件事被拿掉以后,新的问题立刻出现了:它应该什么时候消失?
free 是生命周期的句号
动态分配的存储需要用 free 释放:
int *numbers = make_array(10);
if (numbers == NULL) {
return 1;
}
numbers[0] = 42;
numbers[1] = 100;
free(numbers);
free(numbers) 告诉分配器:这块动态存储不再需要,可以被未来的分配重新利用。
它并不保证把所有字节清零,也不保证操作系统立刻少占用这么多物理内存。它表达的核心意思是:
从现在开始,程序不再拥有通过这个指针访问这块存储的权利。
释放以后,numbers 这个指针变量本身还在,而且里面通常仍然保存原来的地址。
free(numbers);
numbers[0] = 7; // 未定义行为
这叫 use-after-free,也就是释放后使用。
它与返回局部变量地址导致的悬空指针本质上是同一类错误:地址仍在,对象已经结束。
把指针设为 NULL 可以减少某些误用:
free(numbers);
numbers = NULL;
这样再次通过 numbers 使用时更容易立刻暴露为空指针错误,也能让 free(numbers) 再执行一次而不出问题,因为 free(NULL) 什么也不做。
但这不是万能药。如果还有另一个指针也指向同一块存储:
int *numbers = malloc(10 * sizeof(*numbers));
int *alias = numbers;
free(numbers);
numbers = NULL;
alias[0] = 42; // alias 仍然悬空
把 numbers 设为 NULL 并不会隔空修改 alias。地址按值复制以后,每个指针变量都是独立的,但它们可能指向同一个对象。
忘记 free 会发生什么
另一个方向的错误是只申请,不释放:
void leak(void) {
int *numbers = malloc(1000 * sizeof(*numbers));
if (numbers == NULL) {
return;
}
// 使用 numbers
}
函数返回时,局部指针 numbers 消失了,但动态存储不会跟着消失。
更糟的是,程序已经丢失了那块存储的地址。没有地址,就再也无法把它交给 free。这叫内存泄漏。
一次泄漏几 KB,然后程序立刻退出,操作系统通常会回收整个进程占用的资源,现实后果可能不大。
但如果这是一个运行几个月的服务,每处理一次请求就泄漏一点,内存占用就会像没有关紧的水龙头一样不断上涨。最终,程序可能耗尽可用内存,或者因为频繁换页而慢得无法使用。
所以“程序退出时操作系统会回收”不是不管理内存的理由。它只说明短命程序与长命程序面对的实际风险不同。
真正困难的是所有权
malloc 和 free 的语法非常简单。一个申请,一个释放,五分钟就能背下来。
真正困难的问题是:谁来调用 free?
int *numbers = make_array(10);
consume(numbers);
执行完 consume(numbers) 以后,numbers 还能不能用?
只看函数声明:
void consume(int *numbers);
你根本不知道。
可能 consume 只是读取数组;可能它会修改数组;可能它会保存这个地址以后再用;也可能它承诺接管内存,并在合适的时候调用 free。
C 的指针本身不记录这些信息。于是程序必须建立约定:
- 谁拥有这块动态内存
- 谁只是暂时借用指针
- 所有权是否会转移给另一个函数
- 最后由谁负责
free
一个简单而常见的规则是:谁申请,谁释放。
int *numbers = make_array(10);
if (numbers == NULL) {
return 1;
}
print_array(numbers, 10); // 暂时借用
free(numbers); // 调用者负责释放
make_array 创建并返回内存,调用者接过所有权;print_array 只在调用期间借用,不保存、不释放;最终调用者释放。
这个规则并不适合所有程序,但一个不够完美的明确规则,通常比完全没有规则强。
C++ 的 RAII、Rust 的所有权和借用、Java 的垃圾回收,都在用不同方式处理这里暴露出来的问题。它们不是凭空增加的高级概念,而是在回答同一个朴素问题:
当许多代码都拿着地址时,我们怎样确定对象还活着,以及谁负责让它结束?
malloc 不是“创建数组”的魔法
最后把动态数组完整写一次:
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
int *make_array(size_t length) {
if (length > SIZE_MAX / sizeof(int)) {
return NULL;
}
return malloc(length * sizeof(int));
}
int main(void) {
size_t length = 5;
int *numbers = make_array(length);
if (numbers == NULL) {
return 1;
}
for (size_t i = 0; i < length; i++) {
numbers[i] = (int)(i * 10);
}
for (size_t i = 0; i < length; i++) {
printf("%d\n", numbers[i]);
}
free(numbers);
return 0;
}
这里没有任何神秘的“动态数组对象”。程序只有:
- 一个表示长度的
size_t - 一个指向首元素的
int * - 一块足够容纳这些元素的动态存储
- 一个谁负责释放它的约定
numbers[i] 仍然只是 *(numbers + i)。malloc 没有让指针记住长度,也没有让越界访问变得安全。
如果写成:
numbers[length] = 42;
仍然越过了数组末尾。动态分配只改变存储的生命周期和获取方式,并没有改变 C 的数组边界规则。
这一节真正要记住的东西
栈和堆最容易被画成两块方向相反的内存,但那张图不是重点。
真正值得记住的是:
- 普通局部对象通常随一次函数调用开始和结束
- 每次递归调用都有自己独立的参数与局部状态
- 动态存储从成功分配开始,一直存在到被
free malloc只返回一块指定大小的存储,不记录元素数量free结束的是动态对象,不会自动清除所有指针副本- 忘记释放会泄漏,释放后继续使用会产生悬空指针
- 内存管理最困难的部分不是函数语法,而是所有权约定
上一篇里,悬空指针来自一个已经返回的函数。这一篇里,悬空指针也可能来自一次过早的 free。
两者的共同点从来不是“地址变成了 0”,而是:地址还在,对象已经不在。
下一节,我们会继续处理动态数组真正遇到的问题:如果原来的空间不够了怎么办?calloc、realloc 与 memcpy 分别在做什么?为什么字符串复制会成为 C 程序中如此著名的事故来源?
这会把我们从“得到一块内存”,带到“如何正确地搬运一块内存”。