指针、地址与内存:C 语言最重要也最锋利的工具
指针是 C 的核心。
它让函数修改外部对象,让数组和动态内存可以被统一处理,让系统调用和硬件接口成为可能。
但指针也是大量崩溃、越界、悬垂访问和安全漏洞的来源。
学指针不能只记 * 和 & 的写法,必须理解它们在内存和生命周期上的含义。
地址是什么
程序运行时,对象占据内存中的某段存储。 地址用于定位这段存储。
int x = 42;
printf("%p\n", (void*)&x);
&x 取得 x 的地址。
打印地址用 %p,并转换为 void*。
地址值本身不应被当作普通整数随意运算。
指针变量保存地址
int x = 42;
int* p = &x;
p 是一个指向 int 的指针。
它保存 x 的地址。
p ─────> x
42
读法:p points to x。
类型 int* 表示通过这个指针解引用时,会按 int 解释目标存储。
解引用访问目标对象
int x = 42;
int* p = &x;
printf("%d\n", *p);
*p = 100;
*p 表示访问 p 指向的对象。
第一处读取 x。
第二处修改 x。
如果 p 没有指向有效 int 对象,解引用就是风险。
空指针表示不指向对象
int* p = NULL;
解引用空指针是未定义行为。 必须先检查。
if (p != NULL) {
*p = 1;
}
短路逻辑可以保护解引用。
if (p != NULL && *p > 0) {
use(*p);
}
顺序不能反。
指针和函数参数
C 参数按值传递。 传指针可以让函数修改调用方对象。
void set_value(int* out, int value) {
if (out != NULL) {
*out = value;
}
}
调用:
int x = 0;
set_value(&x, 10);
参数名 out 表示这是输出参数。
命名能帮助表达所有权和方向。
指针和数组关系密切但不相同
数组名在很多表达式中会转换为指向首元素的指针。
int values[3] = {1, 2, 3};
int* p = values;
p[1] 等价于 *(p + 1)。
printf("%d\n", p[1]);
但数组不是指针。
sizeof values 和 sizeof p 不同。
指针算术按元素大小移动
int values[3] = {1, 2, 3};
int* p = values;
++p;
p 向后移动一个 int,不是移动一个字节。
如果 int 是 4 字节,地址通常增加 4。
指针算术只在同一数组对象内安全。 越界指针不能解引用。
void* 是无类型地址
void* 可以保存任意对象指针。
但不能直接解引用,因为编译器不知道目标类型大小。
void* raw = &x;
int* p = raw;
printf("%d\n", *p);
malloc 返回 void*。
它表示“这里有一段存储,你需要用类型解释它”。
二级指针
指针本身也是对象,也有地址。 指向指针的指针叫二级指针。
int x = 1;
int* p = &x;
int** pp = &p;
常见用途是让函数修改调用方的指针。
int allocate_int(int** out) {
*out = malloc(sizeof **out);
return *out == NULL ? -1 : 0;
}
二级指针可读性较差。 使用时要保证命名和释放协议清楚。
指针和 const
const int* p = &x;
不能通过 p 修改 x。
int* const q = &x;
q 这个指针不能再指向别处。
const int* const r = &x;
指针本身和目标对象都不能通过 r 修改。
这类声明要多练习。
它们是 C API 表达只读借用的重要工具。
悬垂指针
指针指向的对象生命周期结束后,指针变成悬垂指针。
int* bad(void) {
int x = 1;
return &x;
}
函数返回后 x 不存在。
返回的地址不能再使用。
堆内存释放后也一样:
int* p = malloc(sizeof *p);
free(p);
*p = 1; // use-after-free
这是严重安全风险。
指针不是所有权
裸指针只说明“这里有一个地址”。 它不自动说明谁负责释放。
char* read_file(const char* path);
void free_buffer(char* buffer);
这组函数通过命名表达所有权。 没有释放协议的指针 API 很危险。
指针调试
调试指针问题时要问:
- 指针是否初始化。
- 是否为空。
- 是否指向正确类型对象。
- 对象生命周期是否仍然有效。
- 是否越过数组边界。
- 是否被释放。
- 是否有其他别名同时修改。
工具:
clang -std=c23 -g -O1 -fsanitize=address,undefined pointer.c
ASan 能快速发现很多越界和释放后使用。 但它不能替你设计所有权。
常见指针模式
输出参数:
int parse_int(const char* text, int* out);
只读借用:
void print_name(const char* name);
可写缓冲区:
int read_bytes(unsigned char* buffer, size_t capacity);
不透明句柄:
typedef struct Engine Engine;
Engine* engine_create(void);
void engine_destroy(Engine* engine);
每种模式都要明确生命周期和释放责任。
工程风险
指针常见风险:
- 未初始化指针。
- 空指针解引用。
- 数组越界。
- 返回局部变量地址。
- 释放后使用。
- 重复释放。
- 指针类型强转破坏别名规则。
- 只传指针不传长度。
- API 没有说明所有权。
- 多线程同时访问指针指向对象。
这些错误往往不会在第一时间暴露。 指针 API 一旦跨线程或跨模块传递,就必须明确并发边界、资源释放责任和观测日志,否则崩溃点通常离真正的错误写入很远。 所以要用清晰 API、边界检查和 sanitizer 组合防御。
小结
指针是地址的类型化表达。
& 取得地址。
* 解引用地址。
指针算术按元素移动。
空指针、悬垂指针和越界指针都不能安全使用。
学会指针,不是学会几个符号,而是学会在每次访问前确认对象仍然存在、类型正确、边界有效、所有权清晰。