文章目录
摘要
指针作为C语言中的重难点,并不是因为 * 这个符号难记,而是它把变量、内存地址、数据类型和对象生命周期连在了一起。本文从一个普通整数变量出发,逐步观察地址怎样被取得、保存和再次访问;再解释指针类型、解引用、const、指针运算,以及空指针、野指针和悬空指针的区别。
先建立一个正确的目标:指针不是“另一个变量类型”
刚接触指针时,很容易把它当成一种很特殊的数据:好像它既不是整数也不是字符,只有记住 *p、&a 才能使用。这样学习会很累,因为每多一个写法都像新规则。
更稳妥的理解是:指针变量本身也是变量,只不过它保存的内容是一个对象的位置。 这个位置通常叫地址。知道地址后,程序就能回到那块内存,读取或修改其中的数据。
例如,交换两个整数时,下面的函数并不能交换调用者的变量:
void Swap(int a, int b)
{
int temp = a;
a = b;
b = temp;
}
调用 SwapWrong(x, y) 时,a 和 b 分别得到 x、y 的副本。函数中确实完成了交换,但交换的是副本;函数结束后副本消失,x、y 保持原样。
如果希望函数真正找到 x、y,就要把它们所在的位置交给函数:
void Swap(int *a, int *b)
{
int temp = *a;
*a = *b;
*b = temp;
}
希望此文可以帮助你把 int *a、*a 和 &x 分别读成清楚的含义,而不是把它们当成一串需要死记的符号。
1、变量、内存和地址:先分清“放的内容”和“放在哪里”
1.1一个变量至少有三件值得关注的事
int score = 95;
对初学者而言,可以先把这句理解成:程序申请了一块能够放下一个 int 的空间,为它取名 score,并把数值 95 放进去。程序运行时,这块空间还有一个可用于定位的地址。
#include <stdio.h>
int main()
{
int score = 95;
printf("score 的值:%d\n", score);
printf("score 的地址:%p\n", &score);
return 0;
}
可能的运行结果(32位环境运行为8位十六进制,64位环境运行为16位十六进制):


第二行的具体数字不重要,也不保证每次运行相同。它只是告诉我们:score 在这一次运行中位于哪里。%p 是打印指针的格式说明符;
如果是使用的Visual Studio开发的话,可以调试验证一下:


可以观察到,得到变量经过取地址展示的地址值后,再通过内存窗口查询该地址的内容的结果发现与赋予变量的值相同(十六进制5f == 十进制95)。
注意: “内存地址”是 C 语言程序观察和操作对象位置的一种抽象。不要把它理解成固定的物理内存门牌号,也不要把一次运行输出的地址写死到代码里。操作系统、编译器和运行环境都可能让下一次地址不同。

注意:变量名是源码中使用的名字,地址是运行时定位对象的方式;
int类型则告诉程序这块空间应按一个整数来读取。地址只有和类型、有效范围一起使用才有意义。
1.2 &:取到对象的地址
& 用在变量前面时表示“取地址”:
int number = 10;
printf("%p\n", &number);
这里得到的不是 10,而是 number 所在位置。下面两句因此完全不同:
printf("%d\n", number); // 打印对象里的值
printf("%p\n", &number); // 打印对象的位置
把“值”和“地址”混在一起,是学习指针最常见的第一步错误。以后看到函数参数是 int *,你就应该想到它要的是一个 int 对象的地址,而不是一个普通整数。
2、指针变量:保存地址,也保留类型信息
2.1最基本的声明、赋值和访问
int value = 10;
int *p = &value;
第二行可以逐部分读:
p是变量名;*p说明p是指针;int *说明它应指向一个int对象;&value是value的地址,正好能赋给int *。
此时内存中的关系不是“把 10 复制给了 p”,而是 value 中保存 10,p 中保存 value 的地址。通过 p 再次访问目标时,需要解引用:
#include <stdio.h>
int main()
{
int value = 10;
int *p = &value;
printf("value = %d\n", value);
printf("*p = %d\n", *p);
*p = 20;
printf("修改后 value = %d\n", value);
return 0;
}
输出中的 *p 和 value 都是同一个对象的值:

最后一行之所以变成 20,是因为 *p = 20 不是给指针变量 p 赋值,而是沿着 p 中保存的地址找到 value,再修改 value。

2.2 指针类型为何不能随便换
int count = 3;
char letter = 'A';
int *count_ptr = &count;
char *letter_ptr = &letter;
地址都可以理解为“位置”,为什么还分 int * 和 char *?原因是程序需要知道:
- 从这个位置读多少字节;
- 读出的字节按什么类型解释;
- 指针加 1 时跨过多大距离。
int * 解引用时按 int 访问,char * 解引用时按字符访问。int *指针加1是跳过4字节,而char *指针加1是跳过1字节.
把不匹配的地址强行转换为另一种指针类型,可能导致对齐问题、错误解释数据,甚至未定义行为。初学阶段最可靠的做法是:指针类型和目标对象类型保持一致。
2.3 * 在声明里和表达式里不是同一个角色
int *p; // 声明:p 是指向 int 的指针
*p = 20; // 表达式:访问 p 指向的对象
第一行的 * 属于声明语法;第二行的 * 是解引用运算符。看到 * 时先看它是否处在声明位置,不要急着把所有星号都理解成“取值”。
另一个容易踩坑的声明是:
int *p, q;
这里 p 是 int *,但 q 是 int,并不是指针。为了减少误读,建议一行只声明一个指针:
int *p;
int *q;
3、指针最实际的用途:让函数修改外部对象
下面是完整的交换示例:
#include <stdio.h>
void Swap(int *left, int *right)
{
int temp;
if (left == NULL || right == NULL)
{
return;
}
temp = *left;
*left = *right;
*right = temp;
}
int main()
{
int x = 3;
int y = 8;
printf("交换前:x = %d, y = %d\n", x, y);
Swap(&x, &y);
printf("交换后:x = %d, y = %d\n", x, y);
return 0;
}
运行结果:

调用 Swap(&x, &y) 时,传入的是两个地址。进入函数后,left、right 也是按值传递的局部变量,但它们保存的地址能定位到调用者的 x、y。因此修改 *left 和 *right 会影响原变量。
NULL 判断表达的是接口约定的一部分:本函数选择在传入空地址时什么也不做。并非所有函数都必须这样处理;有些函数会规定参数绝不能为 NULL。重点是,调用者和函数实现者都应清楚指针参数是否允许为空。
4、使用指针前的三个核心问题
每次准备写 *p 时,停下来问三个问题:
p是否已经被初始化为明确的值?p此刻是否指向一个有效对象,而不是NULL?- 这个对象是否仍在生命周期内,并允许我以这种方式访问?
前两问解决“地址从哪里来”,第三问解决“这个地址现在还管不管用”。很多指针错误能通过这三问提前发现,是初学时避免错误的重要习惯。
4.1 空指针:明确表示当前没有目标
int *p = NULL;
if (p != NULL)
{
printf("%d\n", *p);
}
NULL 是一个表示空指针的宏。它的含义是“当前不指向任何可访问对象”,不是一个可以解引用的零地址。空指针可以比较,也可以作为“暂时没有结果”的状态返回;但不能直接使用 *p。
下面是一个更贴近函数接口的例子:
int SetZero(int *p)
{
if (p == NULL)
{
return 0;
}
*p = 0;
return 1;
}
返回值告诉调用者是否成功修改。这样读代码时就能知道:p 允许为空,但为空时不会执行解引用。
4.2 野指针:没有可靠来源的地址
int *p;
*p = 10; // 错误:p 从未初始化
这段代码的危险不在于 * 写错了,而在于 p 变量从未被赋成一个已知地址。
当程序执行 *p = 10 时,它会试图把 10 写到这个未知位置。后果并不固定:
- 有时会立刻访问冲突并终止;
- 有时覆盖了别的变量,稍后才出现奇怪结果;
- 这类行为属于未定义行为,不能通过多运行几次来证明安全。
正确的选择只有两类:让指针马上指向一个有效对象,或者明确置空。
int value = 10;
int *valid = &value;
int *later = NULL;
“先声明,之后再说”对普通整数有时只是逻辑问题;对指针则可能直接变成向未知地址读写的问题。因此,指针声明时初始化是值得长期坚持的习惯。
4.3 悬空指针:过去有效,不代表现在有效
int *GetNumber()
{
int local = 42;
return &local; // 错误
}
函数执行期间,local 确实有地址,&local 也确实能取得它。但 local 是局部变量,函数返回后它的生命周期结束。返回的指针仍保留旧地址,却再也没有一个仍然存在的 int 对象可以合法访问,这就是悬空指针。
可以把它和野指针区分开:野指针从一开始就没有可靠目标;悬空指针曾经有可靠目标,但目标已经消失。后者更隐蔽,因为代码中往往能看到它“原来指向了谁”。
int *GetStaticNumber(void)
{
static int value = 42;
return &value;
}
这个版本不会因为函数返回而失效,因为静态局部变量存储在内存的静态存储区,和全局变量放同一个区域,程序运行全程都存在,不会随函数结束销毁,不过它只有一份,所有调用者共享同一个对象。

5、const 和指针:“不能改的究竟是谁”
const 与指针结合后常让人混淆。不要直接背句子,可以从变量名开始看。
5.1 const int *p:不能通过 p 改目标
int value = 10;
const int *p = &value;
*p = 20; //错误:不能通过 p 修改
value = 20; //合法:value 本身不是 const
这适合只读参数,例如“只读取数组内容,不修改数组”的函数:
int Sum(const int *arr, int count)
{
int i;
int total = 0;
for (i = 0; i < count; i++)
{
total += arr[i];
}
return total;
}
const 在这里既是在告诉读者意图,也让编译器阻止函数体意外写入 arr[i]。
5.2 int *const p:指针本身不能改指向
int first = 1;
int second = 2;
int *const p = &first;
*p = 10; //合法:修改 first
p = &second; //错误:p 不能改为指向 second
const 靠近变量名 p,所以限制的是 p 这个地址变量。它必须在声明时初始化,因为之后不能再给它指定目标。
5.3 const int *const p:两边都固定
int value = 10;
const int *const p = &value;
*p = 20; //错误
p = NULL; //错误
| 声明 | 能否通过指针改值 | 能否改变指向 |
|---|---|---|
int *p | 可以 | 可以 |
const int *p | 不可以 | 可以 |
int *const p | 可以 | 不可以 |
const int *const p | 不可以 | 不可以 |
6、指针运算:在同一个数组范围内理解
指针运算不是对任意地址做数学题。最清晰的场景是数组,因为数组元素在内存中连续排列:
int numbers[4] = {10, 20, 30, 40};
int *p = numbers;
此时 p 指向 numbers[0]。下列访问等价:
numbers[2]
*(numbers + 2)
*(p + 2)
p[2]
6.1 指针与整数:移动的是“元素个数”
printf("%d\n", *p); // 10
printf("%d\n", *(p + 1)); // 20
printf("%d\n", *(p + 3)); // 40
p + 1 的含义是“移动到下一个 int 元素”,不是简单把地址数字加 1。编译器会根据 p 的类型计算正确步长。这也是为什么指针类型不能被忽略。
常用的移动形式包括:
p++; // 指向下一个元素
p += 2; // 向后跳两个元素
p--; // 回到前一个元素,前提是不会越过开头
p - 1; // 得到前一个位置,但不修改 p
下面的两行非常容易看错:
*p++; // 等价于 *(p++):先使用旧的 p 形成 *p,再让 p 后移
(*p)++; // 让 p 指向的元素值加 1,p 自己不移动
遇到星号和自增同时出现时,主动加括号思考,比凭感觉判断可靠得多.
6.2 指针与指针相减:得到相隔的元素数
#include <stdio.h>
#include <stddef.h>
int main()
{
int arr[] = {10,20,30,40};
int *p1 = &arr[0];
int *p2 = &arr[3];
ptrdiff_t diff = p2 - p1; // 3,相差3个int元素
printf("diff = %td\n", diff);
return 0;
}
运行结果:

right - left 得到的是从下标 1 到下标 4 跨过了多少个 int 元素,而不是字节数。结果类型 ptrdiff_t 专门用于保存两个指针的差值,打印时使用 %td。
这一规则有严格前提:两个指针必须都属于同一个数组对象,或者其中一个是该数组的尾后位置。两个完全无关变量的地址不能通过相减得到有意义的“距离”
6.3 指针之间的关系运算:判断数组中的先后
int numbers[4] = {10, 20, 30, 40};
int *p = &numbers[1];
int *q = &numbers[3];
if (p < q)
{
printf("p 指向的元素在 q 前面\n");
}
在同一个数组内,<、>、<=、>= 可以比较元素顺序;==、!= 可以判断是否正好指向同一位置。遍历数组时常会写:
int *cur = numbers;
int *end = numbers + 4;
while (cur != end)
{
printf("%d ", *cur);
cur++;
}
这里 end 是尾后位置,只用于判断“是否走完”,从不解引用。
6.4 哪些运算不应该写
以下写法没有定义好的用途,不能当作普通数学运算:
p + q; // 两个指针不能相加
p * 2; // 指针不能相乘
p / 2; // 指针不能相除
还有一些看起来很像数组访问、实际上越界的写法:
int data[3] = {1, 2, 3};
int *begin = data;
int *end = data + 3; // 可以形成尾后位置
*(begin - 1); //越过开头,错误
*end; //尾后位置没有元素,错误
形成 data + 3 是允许的,因为它帮助我们表示结束;访问 *(data + 3) 则不允许。请牢牢记住:指针位置可以到达尾后,但解引用只能落在真正存在的元素上。

7、练习:“会写”也要“会判断”
7.1 这段代码会打印什么
int value = 7;
int *p = &value;
*p = *p + 3;
printf("%d\n", value);
答案: 输出 10。*p 和 value 指向同一个整数对象,右侧先读取 7,再写回 10。
7.2 指出错误并修正
int *p;
if (p != NULL)
{
*p = 1;
}
答案: 错在 p 在比较前都没有初始化,读取它本身就是问题。若暂时没有目标,应写成:
int *p = NULL;
if (p != NULL)
{
*p = 1;
}
当然,这段修正后的代码不会写入任何数据;它只是安全地表达“当前没有可写对象”。真正需要修改时,应让 p 指向某个有效变量。
7.3 预测差值和访问结果
int a[] = {2, 4, 6, 8, 10};
int *p = &a[1];
int *q = &a[4];
printf("%d\n", *(p + 2));
printf("%td\n", q - p);
答案: 第一行是 8:p 从 a[1] 出发,向后两个元素到 a[3]。第二行是 3:a[4] 与 a[1] 相隔三个元素。
总结
到这里,指针可以先概括为一条通往对象的路线:地址告诉程序去哪里,类型告诉程序怎样读,数组边界限制能走多远,生命周期决定这条路线何时失效。只记住“指针保存地址”还不够;能否安全地使用这个地址,取决于后面三项。
-- 从内存地址开始理解指针&spm=1001.2101.3001.5002&articleId=164020293&d=1&t=3&u=a1393c734bfd43d19b442add9d3bd94e)
1407

被折叠的 条评论
为什么被折叠?



