C 语言指针:从内存地址到灵活的数据访问
在 C 语言中,指针既是最有力量的工具之一,也是初学者最容易困惑的概念之一。它并不是一种神秘的"特殊数据",而是把内存地址显式地交给程序员管理。理解了地址、类型和解引用之间的关系,就能自然地掌握数组遍历、函数传参、动态内存以及复杂数据结构的基础。
一、指针到底是什么
计算机内存可以看成许多连续的小单元,每个单元通常占用 1 个字节,并拥有一个唯一的编号,这个编号就是地址。程序中的变量存放在某个内存位置,变量的地址可以通过取地址运算符 & 得到。
c
#include <stdio.h>
int main(void)
{
int value = 10;
printf("value = %d\n", value);
printf("address = %p\n", (void *)&value);
return 0;
}
如果希望把这个地址保存起来,就需要使用指针变量:
c
int value = 10;
int *ptr = &value;
这里的 ptr 是一个指针变量,它保存的是 value 的地址。可以把它理解为一张"内存位置的便签":便签上记录了目标数据在哪里,而不是目标数据本身。
指针变量自身也要占用内存。32 位环境中,地址通常占 4 个字节,因此指针变量常见大小为 4 个字节;64 位环境中,地址通常占 8 个字节,因此指针变量常见大小为 8 个字节。指针的大小取决于平台的地址宽度,而不是它所指向的数据类型。
二、指针类型决定了访问方式
指针声明的一般形式是:
c
type *name;
例如:
c
char *char_ptr;
short *short_ptr;
int *int_ptr;
float *float_ptr;
double *double_ptr;
这些指针都保存地址,但它们指向的数据类型不同。类型信息主要影响两个动作:指针移动时跨过多大的距离,以及解引用时访问多少字节。
1. 指针加减整数
假设 int 占 4 个字节:
c
int number = 10;
int *int_ptr = &number;
int_ptr + 1 并不是让地址只增加 1 个字节,而是向后移动一个 int 的大小,通常增加 4 个字节。类似地,char * 每次移动通常跨过 1 个字节,double * 每次移动通常跨过 8 个字节。
因此,指针类型实际上为地址运算提供了"步长"。这也是指针能够方便遍历数组的原因。
2. 解引用
解引用运算符 * 用来访问指针所指向的对象:
c
int value = 10;
int *ptr = &value;
printf("%d\n", *ptr); // 读取 value
*ptr = 20; // 修改 value
char * 解引用时按照字符大小访问,int * 解引用时按照整型大小访问。指针类型不只是为了让声明更清晰,它还决定了程序读写内存的范围。使用错误的指针类型可能造成数据截断、覆盖相邻字节,甚至引发未定义行为。
三、野指针:最需要警惕的指针问题
野指针是指向未知、无效或已经不再属于当前对象的地址的指针。通过野指针读写内存,结果可能是程序崩溃,也可能悄悄破坏其他数据。
1. 未初始化
局部指针变量如果没有显式初始化,里面可能是任意值:
c
int *ptr; // 未初始化
*ptr = 20; // 错误:ptr 指向未知位置
声明指针时,如果暂时没有有效目标,应将其初始化为空指针:
c
int *ptr = NULL;
2. 越过有效范围
指针遍历数组时,必须严格限制在数组范围内:
c
int values[10] = {0};
int *ptr = values;
for (int i = 0; i < 10; ++i) {
*(ptr + i) = i;
}
当指针移动到数组之外并继续解引用时,就会访问不属于数组的内存。数组最后一个元素后的位置可以用于比较或作为"结束位置",但不能解引用。
3. 指向的空间已经失效
动态内存释放后,原指针仍然保留旧地址,但那块空间已经不能再使用。释放后应立即置空:
c
free(ptr);
ptr = NULL;
此外,不要从函数中返回局部变量的地址,因为函数返回后局部变量的生命周期已经结束。
4. 使用前检查有效性
在解引用前,确认指针指向的对象仍然存在,并在需要时判断它是否为 NULL:
c
if (ptr != NULL) {
*ptr = 20;
}
空指针检查只能排除 NULL,不能自动证明一个非空地址一定有效。指针的生命周期和指向范围仍然需要程序员自己维护。
四、指针运算的三种形式
1. 指针与整数相加减
ptr + n 和 ptr - n 表示以当前指针类型为步长,向后或向前移动 n 个元素。它们最适合用于数组遍历:
c
int values[5] = {10, 20, 30, 40, 50};
int *ptr = values;
for (int i = 0; i < 5; ++i) {
printf("%d\n", *(ptr + i));
}
2. 指针相减
两个指针相减,得到的是它们之间相隔的元素个数,而不是字节数。这个操作要求两个指针指向同一个数组中的元素,或者一个指向数组末尾的"尾后位置":
c
#include <stddef.h>
int my_strlen(const char *str)
{
const char *end = str;
while (*end != '\0') {
++end;
}
return (int)(end - str);
}
在这个例子中,end - str 得到字符串中字符的数量。
3. 指针关系比较
同一数组中的指针可以进行大小比较。常见的遍历写法是把数组末尾的尾后位置作为边界:
c
int values[5] = {0};
int *begin = values;
int *end = values + 5;
for (int *ptr = begin; ptr < end; ++ptr) {
*ptr = 0;
}
这种写法清楚地表达了"从首元素走到尾后位置"。不要把指针移动到数组首元素之前,也不要对尾后指针解引用。
五、指针与数组的关系
在大多数表达式中,数组名会转换为数组首元素的地址:
c
int values[5] = {1, 2, 3, 4, 5};
printf("%p\n", (void *)values);
printf("%p\n", (void *)&values[0]);
上面两行通常会打印相同的地址,因此可以这样让指针指向数组:
c
int *ptr = values;
数组下标表达式和指针表达式本质上是等价的:
c
values[i] // 等价于 *(values + i)
ptr[i] // 等价于 *(ptr + i)
完整遍历示例:
c
#include <stdio.h>
int main(void)
{
int values[] = {1, 2, 3, 4, 5};
int size = (int)(sizeof(values) / sizeof(values[0]));
int *ptr = values;
for (int i = 0; i < size; ++i) {
printf("%d ", *(ptr + i));
}
putchar('\n');
return 0;
}
需要注意,数组名并不总是等同于普通指针。在 sizeof 中,数组名代表整个数组;对数组名使用 & 时,得到的是整个数组的地址。这两种情况不能简单地按首元素指针理解。
六、二级指针:保存指针变量的地址
指针变量也是变量,因此它同样有自己的地址。保存指针变量地址的指针,就是二级指针:
c
int value = 10;
int *ptr = &value;
int **pptr = &ptr;
三者的关系可以分成两层:
pptr保存ptr的地址;*pptr访问ptr;**pptr访问value。
二级指针可以修改一级指针本身指向的对象:
c
int first = 10;
int second = 20;
int *ptr = &first;
int **pptr = &ptr;
*pptr = &second; // 等价于 ptr = &second
**pptr = 30; // 等价于 *ptr = 30
当函数需要修改调用者传入的指针,或者需要动态创建二维结构时,二级指针尤其常见。
七、指针数组:数组中的每个元素都是指针
看到 int *array[5] 时,应该先结合变量名判断整体结构:方括号优先级高于星号,所以它是一个数组,数组中有 5 个元素,每个元素的类型是 int *。换句话说,这是"存放指针的数组",而不是"指向数组的指针"。
c
int a = 10;
int b = 20;
int c = 30;
int *array[3] = {&a, &b, &c};
for (int i = 0; i < 3; ++i) {
printf("%d\n", *array[i]);
}
指针数组可以把多个彼此独立的对象组织在一起,常见用途包括保存多组字符串的地址、实现不规则二维数组,以及作为函数参数表。
八、写好指针代码的几个习惯
- 声明指针时立即初始化,暂时没有目标就使用
NULL。 - 明确每个指针的所有权和生命周期,释放后及时置空。
- 进行数组遍历时使用清晰的起点和尾后位置,避免越界。
- 解引用前确认指针有效,并确保指针类型与目标对象匹配。
- 阅读复杂声明时从变量名开始,结合
[]、*和类型逐层分析。 - 调试地址问题时使用
%p,并将地址转换为(void *)传给printf。
指针的核心并不在于记住许多符号,而在于始终回答三个问题:这个指针保存了什么地址?它按照什么类型解释目标内存?它当前是否仍然指向一个有效对象?只要这三个问题保持清晰,指针就会从容易出错的"黑盒"变成一种精确、高效的数据访问工具。