
C 教程:指针是什么,怎么写才不容易出错
指针是 C 语言里很实用、也很容易误用的工具。它可以让函数访问调用者的数据,可以遍历数组,也可以管理运行时申请的内存。问题在于,C 通常不会替你检查指针是否有效、是否越界,以及它指向的对象是否还存在。写指针代码时,理解“它指向什么、能访问多大范围、有效到什么时候”,比背语法更重要。
本文从地址和解引用讲起,逐步介绍数组、函数参数、动态内存与指针运算,并给出可以直接编译运行的示例。示例以常见的 C11 编译器为目标;命令中的 cc 可以替换成你使用的编译器。
1. 指针是什么
程序运行时,变量通常占据一段内存。指针对象保存一个地址,这个地址可以指向某种类型的对象。例如,int * 是“指向 int 的指针类型”,而不是一个整数变量本身。
#include <stdio.h>
int main(void)
{
int score = 90;
int *p = &score;
printf("score = %d\n", score);
printf("*p = %d\n", *p);
*p = 95;
printf("score after update = %d\n", score);
return 0;
}
这里的 &score 是取地址运算,得到 score 的地址;p 保存这个地址;*p 是解引用,表示访问 p 指向的对象。因此,通过 *p 修改数据,也就修改了 score。
声明中的星号属于声明符的一部分。下面这种写法容易让人误以为两个变量都是指针:
int* a, b;
实际上,a 是 int 指针,b 是普通 int。为了减少误读,很多人选择每个声明单独写一行:
int *a;
int b;
2. 初始化和空指针
指针在使用前应当有明确的初始值。若暂时没有可指向的对象,可以初始化为空指针:
int *p = NULL;
NULL 是空指针常量,表示它不指向可供程序访问的对象。解引用空指针是未定义行为,可能崩溃,也可能表现出其他不可预测的结果。检查指针是否为空,并不能证明它非空时一定有效;它也可能已经悬垂、越界,或不再指向存活对象。
if (p != NULL) {
/* 这里只能说明 p 不是空指针,
不能单凭这个检查证明 *p 一定可以访问。 */
}
不要把整数 0、空指针和“地址为零的对象”混为一谈。C 语言保证空指针比较结果不等于指向任何对象或函数的有效指针;空指针的具体机器表示由实现决定,不要求一定是所有位都为零。使用 NULL 或适当的指针初始化表达式即可,不要自行构造所谓的空地址表示。
3. 指针作为函数参数:让函数访问调用者的数据
C 的函数参数按值传递。把一个普通变量传给函数,函数得到的是它的副本,直接修改副本不会改变调用者的变量。如果传入变量的地址,函数仍然是按值接收参数,只不过这份参数值是一个指针;通过指针访问对象,才可以修改调用者的数据。
#include <stdio.h>
static void set_to_zero(int *value)
{
if (value == NULL) {
return;
}
*value = 0;
}
int main(void)
{
int count = 12;
set_to_zero(&count);
printf("%d\n", count);
return 0;
}
这段程序输出 0。空指针检查是对接口的一种防御,但它不解决所有有效性问题:调用者如果传入悬垂指针,函数依然无法安全访问它。因此,调用双方必须约定指针所指对象在调用期间存活,并且至少有足够的可访问空间。
如果函数需要处理多个元素,仅有指针还不够,通常还要传入元素个数:
#include <stddef.h>
static long sum_ints(const int *values, size_t count)
{
long sum = 0;
if (count == 0) {
return 0;
}
if (values == NULL) {
return 0; /* 本示例将非零长度配空指针视为无效输入 */
}
for (size_t i = 0; i < count; ++i) {
sum += values[i];
}
return sum;
}
const int *values 表示函数不通过这个指针修改元素;它并不保证对象在程序其他位置绝不会被修改。示例采用了一个明确的接口约定:长度为零时不访问指针;长度非零时要求指针指向至少 count 个可读的 int。C 不会自动验证这个条件,调用者必须保证它成立。实际项目中,遇到无效参数时也可以选择返回错误码,而不是像示例一样返回零;要根据接口设计区分“有效的零结果”和“参数错误”。
4. 数组与指针有关,但不是同一种东西
数组是一组连续排列的元素;指针是保存地址的对象。两者类型和行为不同,不过数组表达式在许多上下文中会转换为指向首元素的指针,这就是常说的“数组退化”。例如,函数参数写成 int values[] 时,实际调整为指针参数,函数无法仅靠这个参数得知原数组长度。
#include <stdio.h>
static void print_values(const int values[], size_t count)
{
for (size_t i = 0; i < count; ++i) {
printf("%d\n", values[i]);
}
}
int main(void)
{
int numbers[] = { 4, 8, 15, 16, 23, 42 };
size_t count = sizeof numbers / sizeof numbers[0];
print_values(numbers, count);
return 0;
}
在 main 中,sizeof numbers 得到整个数组占用的字节数,除以单个元素的字节数可得到元素个数。这个技巧只适用于仍然是数组的表达式;在函数参数中,values 已经是指针,sizeof values 得到的是指针对象的大小,而不是数组长度。
指针和数组还存在另一个重要差别:数组名不能像普通指针变量那样重新赋值,而指针变量可以改为指向别处。不要假设指针的字节数与 int、long 或数组长度有固定关系。对象指针大小受平台与实现影响,使用 sizeof 取得当前实现中的实际大小。
5. 指针运算的范围
对指向数组元素的指针加减整数,运算单位是元素,而不是字节。若 p 指向一个 int,p + 1 指向下一个 int 元素的位置。
#include <stdio.h>
int main(void)
{
int values[] = { 10, 20, 30 };
int *p = values;
printf("%d\n", *p); /* 10 */
printf("%d\n", *(p + 1)); /* 20 */
return 0;
}
指针运算有严格边界:指针加减后,结果必须仍指向同一个数组对象中的元素,或恰好指向该数组最后一个元素之后的位置。这个“末尾之后”的指针可以用于比较、循环终止等用途,但不能解引用。不能再往后跨出这一格,也不能对它读写。
int values[] = { 10, 20, 30 };
int *begin = values;
int *end = values + 3; /* 指向数组末尾之后,不可解引用 */
for (int *p = begin; p != end; ++p) {
/* 此处 p 指向数组中的有效元素 */
}
对不属于同一个数组对象(或其末尾之后位置)的指针做相减,行为未定义;比较不同对象的指针也有标准规定的限制,不应把指针大小或地址排序当作通用的对象顺序。需要计算元素个数时,可以对同一数组中的两个适当指针相减,结果类型是 ptrdiff_t,定义在 <stddef.h> 中。
越界访问并不总会立即报错。它可能覆盖其他数据,也可能暂时看似正常,最终导致难以定位的问题。循环条件应与实际元素个数对应,切勿把“程序没崩溃”当作没有越界的证据。
6. 字符串和字符指针
C 风格字符串是以空字符 '\0' 结尾的字符序列。字符串字面量具有静态存储期,但尝试修改字符串字面量的内容会产生未定义行为;应通过指向 const char 的指针访问它。
#include <stdio.h>
int main(void)
{
const char *message = "hello";
printf("%s\n", message);
return 0;
}
如果要修改字符,应使用可写数组,并确保数组空间包含末尾的空字符:
#include <stdio.h>
int main(void)
{
char word[] = "cat";
word[0] = 'b';
printf("%s\n", word);
return 0;
}
这里数组至少需要四个字符位置:三个可见字符加结尾的 '\0'。直接把未经终止的字符缓冲区传给 printf("%s", ...) 等字符串函数,可能导致函数越过缓冲区继续读取。使用字符串函数时,要确认输入已正确终止,并在目标容量有限时选择能明确控制长度的接口,同时处理截断和错误情况。
7. 动态内存:指针负责访问,调用者负责生命周期
当对象大小在编译时不确定,或需要让对象超出创建它的函数继续存活时,可以使用动态内存分配。malloc 分配指定字节数,成功时返回指向该空间起始位置的指针,失败时返回空指针;返回空间未初始化。free 释放通过内存分配函数获得、且尚未释放的空间。
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
size_t count = 5;
int *values = malloc(count * sizeof *values);
if (values == NULL) {
fprintf(stderr, "内存分配失败\n");
return 1;
}
for (size_t i = 0; i < count; ++i) {
values[i] = (int)(i * 10);
}
for (size_t i = 0; i < count; ++i) {
printf("%d\n", values[i]);
}
free(values);
values = NULL;
return 0;
}
sizeof *values 以所指元素类型计算需要的字节数。C 中不需要把 malloc 的返回值强制转换成 int *;添加转换既不提升安全性,也可能掩盖未正确包含 <stdlib.h> 等问题。示例在分配成功后初始化每个元素,因为 malloc 不会把内容清零。
如果计算 count * sizeof *values 时发生整数溢出,传给分配函数的字节数可能小于预期,从而造成后续越界。对于来自输入或其他不可信来源的长度,应在乘法前检查范围:
#include <stdint.h>
#include <stddef.h>
#include <stdlib.h>
size_t count = /* 已验证的元素数量 */;
if (count > SIZE_MAX / sizeof(int)) {
/* 数量过大,不能安全计算所需字节数 */
} else {
int *values = malloc(count * sizeof *values);
/* 检查 values,并在适当时释放 */
}
这里的代码片段需要在实际程序中为“数量过大”和分配失败定义恰当处理方式。是否限制最大元素数,还取决于程序资源预算和接口要求。仅仅检查乘法不溢出,并不代表分配一定成功或输入长度符合业务限制。
若使用 realloc 调整大小,应先把返回值存入临时指针。失败时,原有分配仍然有效;直接覆盖原指针会丢失它,使内存无法再释放。
int *tmp = realloc(values, new_count * sizeof *values);
if (tmp == NULL) {
/* values 仍然指向原分配,可按程序策略继续或释放 */
} else {
values = tmp;
/* 若变大,新增加的部分尚未初始化 */
}
这段写法仍要求事先检查 new_count * sizeof *values 不溢出。若 new_count 为零,realloc 的行为在不同版本的 C 标准和实现间存在需要特别处理的差异;为了代码可移植且容易审查,建议对零长度单独设计逻辑,不把它当作普通扩容操作。
8. 生命周期、悬垂指针与重复释放
指针只有在所指对象仍然存活时才能用于访问。返回局部变量地址是典型错误:函数返回后,局部对象的生命周期结束,地址不能再用于读取或写入。
int *bad_function(void)
{
int local = 7;
return &local; /* 错误:local 离开作用域后不再存活 */
}
编译器可能对此给出警告,应当认真处理。可行的设计包括由调用者提供存储空间、返回一个值,或在需要动态生命周期时由明确的一方分配并负责释放。不能通过改变变量类型或强制转换,延长一个局部变量的生命周期。
动态内存释放后,所有指向该内存的指针都不能再解引用。把负责释放的那个指针设为 NULL,可以避免通过它再次误用,但不会自动清除程序其他位置保存的副本。
free(values);
values = NULL;
/* 如果另一个指针 alias 也曾指向同一块内存,
把 values 设为 NULL 不会让 alias 自动失效。 */
重复释放同一块内存、释放非分配函数返回的地址、释放已经失效的地址,都是未定义行为。一个清晰的所有权约定很重要:哪些函数负责分配,哪些函数负责释放,返回的指针由谁拥有,指针参数只是借用还是所有权转移,都应在接口中说清楚。
9. const 与指针声明
const 放在不同位置,约束的对象不同。下面两种声明含义不一样:
const int *p; /* 不能通过 p 修改所指 int;p 可改指向 */
int *const q = NULL; /* q 本身不能改指向;所指 int 可通过 q 修改 */
第一种常用于只读取数组或对象的函数参数;第二种是不可重新赋值的指针变量,实际声明时必须在合适的位置初始化。还可以同时约束指针和所指对象:
const int *const fixed = /* 一个有效的 int 对象地址 */;
const 表达的是通过该访问路径能否修改对象,不是内存保护机制,也不表示数据不可被程序其他路径修改。不要随意去掉 const 后写入对象;如果原对象本身定义为常量,修改它会产生未定义行为。
10. void 指针与函数指针
void * 是通用对象指针,可以保存指向对象的地址,并可在 C 中与对象指针之间进行适当转换。它不携带所指对象的类型信息,不能直接解引用;访问前需要先转换为正确的对象指针类型。它不能被视为通用函数指针,ISO C 不保证对象指针和函数指针可以互相转换或用同一种方式存储。
例如,通用排序接口常使用 void * 传递元素地址,再通过比较函数按实际元素类型处理。这样的接口依赖调用者遵守类型和长度约定,类型信息不会由 void * 自动保留。
函数指针则用于保存函数的地址,常见用途是回调和操作表。它的声明语法与对象指针不同,调用时必须符合函数类型。需要函数回调时,应使用相应的函数指针类型,不要把函数地址塞进 void * 再假设它可移植。
11. 常见错误及排查方法
- 未初始化就解引用:局部指针变量如果没有初始化,其值不确定。声明时就设为有效地址或
NULL,并在使用前建立清晰的赋值路径。 - 空指针解引用:可能由分配失败、查找失败或接口返回空值导致。检查返回值,并明确空值代表的含义。
- 数组越界:指针运算不会自动做边界检查。将指针和长度一同传递,循环条件严格使用长度。
- 访问已结束生命周期的对象:不要返回局部变量地址;不要在释放动态内存后继续使用旧地址。
- 重复释放或错误释放:只释放尚未释放、且确由兼容分配函数获得的地址。通过所有权约定避免多个位置都认为自己负责释放。
- 把地址当普通整数:指针与整数不是可任意互换的类型。地址转换受实现和标准规则限制,不要为了“方便”把地址存进整数变量。
- 误以为指针能记住数组长度:指针只提供地址信息,不附带可访问元素数量。长度需要通过其他参数或数据结构传递。
- 字符串缺少结尾空字符:可能导致读取超出缓冲区。分配或声明空间时为
'\0'留出位置,并确认字符串操作不超过容量。
编译阶段就可以发现一部分问题。开发时尽量启用警告,例如:
cc -std=c11 -Wall -Wextra -Wpedantic -g example.c -o example
部分编译器和平台还支持地址检查器等运行时工具,例如 Clang 或 GCC 的 AddressSanitizer。可在支持的环境中尝试:
cc -std=c11 -Wall -Wextra -g -fsanitize=address,undefined example.c -o example
./example
检查器能帮助发现某些越界、释放后使用等问题,但不能证明程序没有错误,也不能替代对标准规则、输入范围和对象生命周期的审查。某些错误在特定运行中没有被工具发现,并不代表行为已变得安全。
12. 什么时候适合使用指针
指针尤其适用于以下场景:函数需要修改调用者的对象;函数需要接收数组或缓冲区及其长度;数据量运行时才确定,需要动态内存;数据结构需要通过链接关系组织节点;需要实现回调或与底层接口交互。
如果只需要传递一个小型整数或结构体的只读副本,直接按值传递往往更简单。不要为了“显得底层”而把所有参数都改成指针。使用指针会引入有效性、别名、生命周期和所有权问题,只有当接口确实需要这些能力时才值得承担。
写指针代码时,可以在每个关键位置问四个问题:
- 这个指针当前指向什么对象?
- 从这里开始允许访问多少个元素或多少字节?
- 所指对象的生命周期何时结束?
- 谁负责分配、修改和释放这份资源?
这些问题都有明确答案,指针通常就容易控制。反过来,如果代码无法说明一个指针为什么有效、长度从哪里来、何时失效,就应先补全接口约定,再继续编写读写操作。指针本身并不危险;缺少边界和生命周期约束,才是大多数指针错误的来源。









