指针到底是什么
学 C 语言绕不开指针,而指针之所以让人发怵,往往是因为一开始就被“指针就是地址”这句话带偏了。更准确的说法是:内存是一排带门牌号的抽屉,变量是抽屉里放着的东西,指针是写着门牌号的纸条。当你写下 int a = 5;,编译器为你安排一个抽屉、贴上地址、放进 5;当你写下 int *p = &a;,你拿到的是那个抽屉的门牌号。
理解了“值”和“地址”是两个层次,后面所有语法就都顺了:p 是门牌号本身,*p 是“顺着门牌号打开抽屉看到的东西”,&a 是“取 a 的门牌号”。解引用(dereference)不是魔法,只是“按地址访问”。
#include <stdio.h>
int main(void) {
int a = 5;
int *p = &a; /* p 保存 a 的地址 */
printf("a 的值 = %d\n", a);
printf("a 的地址 = %p\n", (void *)&a);
printf("p 保存的地址 = %p\n", (void *)p);
printf("p 指向的值 = %d\n", *p); /* 顺着地址取到 5 */
*p = 42; /* 通过指针改写 a */
printf("修改后 a = %d\n", a); /* 输出 42 */
return 0;
}
指针本身也有地址,指针的类型决定“一次解引用读几个字节、地址加一时跳多远”。忘记后者,就会写出一半的数组越界 bug。
- 指针存放地址的变量,类型决定解引用宽度与步长。
- 内存分区栈、堆、静态区、常量区、代码区,生命周期各不相同。
- malloc/free堆内存手动申请必须手动释放,且一一配对。
- 三类错误悬垂指针、越界访问、内存泄漏,可用工具自动定位。
栈、堆与静态区:内存怎么分区
一个进程的地址空间被人为划分成几个区域,理解它们的生命周期,是判断“这块内存该由谁释放”的唯一依据。局部变量在栈上,随函数返回自动回收;malloc 拿到的内存在堆上,只能手动释放;全局变量和 static 变量在静态区,程序运行期间一直存在。
| 区域 | 存放什么 | 生命周期 | 谁负责回收 |
|---|---|---|---|
| 栈 stack | 局部变量、函数参数、返回地址 | 函数调用期间 | 编译器自动 |
| 堆 heap | malloc / calloc / realloc 申请的内存 | 从申请到 free | 程序员手动 |
| 静态区 data/bss | 全局变量、static 变量 | 整个程序运行期 | 进程结束时由系统回收 |
| 常量区 rodata | 字符串字面量、const 常量 | 整个程序运行期 | 只读,不可释放 |
三条实用推论:
- 函数里返回局部数组的地址是错的——函数一返回,栈帧就没了,那块内存随时会被别的调用覆盖。
- 字符串字面量
"hello"落在只读区,试图写它会直接崩溃,需要可改的字符串要用char buf[] = "hello";。 - 栈空间通常只有几 MB,递归太深或超大局部数组会“爆栈”,这类数据应改用堆。
指针运算与数组的天然关系
在 C 里,数组名在多数表达式中会“退化”为指向首元素的指针。于是 arr[i] 与 *(arr + i) 完全等价,而这里的 + i 不是加 i 个字节,而是加 i 个元素——编译器用元素类型的大小替你做了换算。
#include <stdio.h>
int main(void) {
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; /* 等价于 &arr[0] */
printf("%d %d\n", arr[2], *(p + 2)); /* 都是 30 */
printf("步长(字节) = %zu\n", sizeof(int));
/* 用指针遍历,注意用 end 作为边界,避免每轮重算长度 */
for (int *q = p; q < p + 5; ++q) {
printf("%d ", *q);
}
printf("\n");
return 0;
}
指针减指针得到的是“相隔多少个元素”(类型为 ptrdiff_t),不是字节差;p + 5 指向数组最后一个元素的下一个位置,可以比较、不可解引用。sizeof(arr) 与 sizeof(p) 的结果天差地别,原因是数组名未退化时才保留“数组类型”。这正是把数组传进函数后长度信息丢失、必须额外传 len 的原因。
动态内存:malloc 与 free 的配对
当所需内存大小要到运行时才知道,就必须上堆。malloc 只申请不初始化,calloc 申请并清零,realloc 在保留原内容的前提下调整大小。三者都可能失败返回 NULL,且返回值不能直接赋给有类型的指针变量(C 语言里可隐式转换,但显式转换更清晰)。
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int n = 100;
int *buf = malloc((size_t)n * sizeof(int));
if (buf == NULL) { /* 1. 必须检查失败 */
perror("malloc");
return 1;
}
for (int i = 0; i < n; ++i) buf[i] = i * i;
printf("buf[9] = %d\n", buf[9]); /* 81 */
free(buf); /* 2. 释放后立即置空 */
buf = NULL;
return 0;
}
三条纪律:申请后必须判空、使用后必须释放、释放后必须置空。把 free 之后的指针置为 NULL,能让“释放两次”这类错误立刻变成可发现的崩溃,而不是潜伏下来。不要对同一块内存 free 两次,也不要 free 非 malloc 家族返回的指针(比如栈数组地址),否则行为未定义。
悬垂、越界与泄漏三类问题
内存相关 bug 难查,是因为它们往往不会立刻报错,而是“污染”了别处的数据后才在某个不相干的地方崩掉。按症状可分为三类:
| 类型 | 典型成因 | 症状 | 修法 |
|---|---|---|---|
| 悬垂指针 | free 后继续用、返回局部变量地址 | 读到垃圾值、随机崩溃 | 释放后置 NULL,不返回栈地址 |
| 越界访问 | 下标超长、strcpy 不查长度 | 破坏相邻变量、栈保护报错 | 用长度参数、改用带界函数 |
| 内存泄漏 | 申请后未 free、异常路径漏 free | 长跑进程内存持续上涨 | 每条成功路径都要有对应释放 |
- 越界写最隐蔽:它可能恰好改掉函数返回地址,直到函数返回时才“爆雷”。
- 泄漏在短命的小程序里看不出来,但在服务器进程里会持续吃掉内存。
- 推荐用
valgrind或编译器自带的 AddressSanitizer 在开发阶段就把它们揪出来。
用工具定位内存错误
靠 printf 猜内存 bug 效率极低。正确做法是打开编译器的消毒器(sanitizer),让运行时替你做检查。GCC / Clang 只需一个编译选项,就能把越界、使用已释放内存、泄漏都能报出来,并给出出错时的调用栈。
# 用 AddressSanitizer + LeakSanitizer 编译并运行
clang -g -fsanitize=address,undefined -o demo demo.c
./demo
# Linux/macOS 上也可用 valgrind 逐行报告泄漏点
valgrind --leak-check=full --show-leak-kinds=all ./demo
# 典型输出(节选):直接指出哪一行分配、哪一行泄漏
# 40 bytes in 1 blocks are definitely lost ... in main (demo.c:12)
养成习惯:本地调试构建默认带上 -fsanitize=address,undefined,发布构建再去掉。注意 Sanitizer 与 -O2 也能共存,不必为了检查而牺牲调试体验。此外,把编译警告开满(-Wall -Wextra)并当作错误处理(-Werror),能提前拦下大量“看起来能编过”的隐患。
实战:手写一个可增长数组
把前面所有知识点串起来,写一个能自动扩容的动态数组。核心思路是维持“容量 capacity”与“长度 length”两个量,长度触顶时按倍数扩容并搬迁数据。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int *data;
size_t len;
size_t cap;
} IntVec;
static int vec_push(IntVec *v, int x) {
if (v->len == v->cap) { /* 需要扩容 */
size_t ncap = v->cap ? v->cap * 2 : 8;
int *nd = realloc(v->data, ncap * sizeof(int));
if (nd == NULL) return -1; /* 失败时原内存仍在 */
v->data = nd;
v->cap = ncap;
}
v->data[v->len++] = x;
return 0;
}
int main(void) {
IntVec v = {0}; /* data=NULL,len=0,cap=0 */
for (int i = 0; i < 20; ++i) vec_push(&v, i * 3);
printf("len=%zu cap=%zu\n", v.len, v.cap); /* len=20 cap=32 */
for (size_t i = 0; i < v.len; ++i) printf("%d ", v.data[i]);
printf("\n");
free(v.data); /* 只释放一次,别忘置空 */
v.data = NULL;
return 0;
}
这段代码里藏着两个关键细节:realloc 失败时不释放原指针,所以必须用临时变量接返回值再判断;容量按 2 倍增长,是为了让 push 的均摊复杂度保持 O(1),而不是每次都搬家变成 O(n)。
内存管理的心智模型与检查清单
把内存管理记成一句话:“谁申请,谁释放;申请处与释放处尽量闭环。”在函数内申请的内存,最安全的做法也是在同一层或由明确的契约(写进函数注释)交给调用者。层层转手而无人负责,就是泄漏的温床。
提交代码前,用这份清单快速自查:
- 所有
malloc / calloc / realloc的返回值都判空了吗? - 每条成功路径(含提前 return、break)都能走到
free吗? free之后指针置NULL了吗?- 有没有把栈地址或只读区地址交给
free? - 字符串拷贝是否用了带长度上限的版本?
- 调试构建是否开启了 Sanitizer 与
-Wall -Wextra?
指针没有让 C 变难,它只是把“内存怎么用”这件事摊开给你看。规律一旦掌握,你写出的就不只是能跑的代码,而是可控的程序。
下一篇我们会把视角转向 C++:在继承了指针能力的同时,现代 C++ 用智能指针与移动语义把“谁负责释放”写进了类型系统,让内存安全成为默认选项。