C 指针(重点)
指针是 C 最强大也最吓人的特性,但它本质就一句话:指针是存"内存地址"的变量。理解了指针,你就理解了 C;理解了 C,你就理解了所有语言里的"引用"、"传地址"、"对象句柄"。这是 C 区别于 Java/Python(把指针藏起来)的核心特征,也是学习 C 的最大门槛。本篇用大量示例带你彻底搞懂指针。
一、内存与地址:先理解比喻
把内存想象成一排带编号的抽屉,每个抽屉能放一个字节,编号就是地址。一个 int n = 42; 在内存里占了 4 个连续抽屉(假设 int 是 4 字节),这 4 个抽屉的起始编号就是 n 的地址。指针就是一张小纸条,上面写着"某号抽屉"的编号。
- 变量
n:抽屉里的内容(42)。 - 地址
&n:抽屉的编号(如 0x7ffee...)。 - 指针
p:写着编号的小纸条。 - 解引用
*p:照纸条找到抽屉,读/写里面的内容。
二、指针基础
三个核心符号要分清:
&x:取地址,得到变量 x 的地址。*p(声明时):声明 p 是一个指针。*p(使用时):解引用,取指针 p 指向的值。
#include <stdio.h>
int main(void) {
int n = 42;
// & 取地址:获得变量 n 在内存中的地址
// * 声明指针:int* 表示"指向 int 的指针"
int *p = &n;
printf("n 的值: %d\n", n); // 42
printf("n 的地址: %p\n", (void*)&n); // 0x7ffee...
printf("p 的值: %p\n", (void*)p); // 等于 &n
printf("p 指向的值: %d\n", *p); // 42(解引用)
// 通过指针修改原变量
*p = 100;
printf("现在 n = %d\n", n); // 100
return 0;
}注意 %p 是打印指针地址的占位符,默认输出十六进制。指针本身也有地址——指针也是一个变量,它存在某个内存位置。指向指针的指针(二级指针)就是存"指针地址"的变量。
三、指针与数组(关键关系)
数组名在 C 里会退化为首元素指针。这意味着 arr 和 &arr[0] 是等价的。指针可以像数组一样用下标,数组也可以像指针一样做加减运算:
#include <stdio.h>
int main(void) {
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; // 数组名 = 首元素地址 = &arr[0]
// 指针 + 整数:跳过 n 个元素
printf("%d\n", *p); // 10
printf("%d\n", *(p + 1)); // 20
printf("%d\n", *(p + 2)); // 30
// p[i] 就是 *(p + i) 的语法糖
printf("%d\n", p[3]); // 40
// 指针自增:每次移动一个元素的距离
int *q = arr;
printf("%d ", *q++); // 10,然后 q 移到下一个
printf("%d ", *q++); // 20
printf("%d\n", *q); // 30
// 指针 - 指针:得到两地址间的元素个数
int *start = &arr[0];
int *end = &arr[4];
printf("元素个数: %ld\n", end - start); // 4
// 注意:指针 +1 实际加的是 sizeof(int)=4 字节
// 编译器自动按类型大小缩放,不用手算
return 0;
}理解这条等式非常重要:arr[i] 永远等价于 *(arr + i)。这就是为什么数组传给函数后 sizeof 失效——它退化成了指针。也是为什么 arr[i] 和 i[arr] 居然都能编译通过(虽然后者是恶搞写法)。
四、指针运算
指针只能做有限的几种运算:
- 指针 + 整数:
p + n表示从 p 向后跳 n 个元素(不是 n 个字节!编译器自动按类型大小缩放)。 - 指针 - 整数:类似,向前跳。
- 指针自增/自减:
p++移动到下一个元素。 - 指针 - 指针:得到两地址之间的元素个数(要求两个指针指向同一数组内)。
- 指针比较:
<>==,判断哪个在前,或是否相等。
不能做的运算:指针 + 指针(无意义)、指针 * 整数、指针除以整数。
五、NULL 指针、野指针、悬垂指针(危险!)
这三类"问题指针"是 C 程序崩溃的头号原因:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
// 1. NULL:明确的"空指针",不指向任何地址
int *p = NULL;
if (p == NULL) {
printf("指针是空的,不能解引用!\n");
}
// *p = 10; // 错!解引用 NULL → 段错误崩溃
// 2. 野指针:未初始化,值随机
int *q; // 危险!未初始化
// *q = 10; // 极可能崩溃,或破坏数据
// 3. 悬垂指针:指向已释放的内存
int *r = malloc(sizeof(int));
*r = 42;
free(r);
// *r = 99; // 错!r 已被释放,行为未定义
r = NULL; // 好习惯:free 后立即置 NULL
// 安全使用指针的三原则:
// 1. 声明时初始化(没指向就赋 NULL)
// 2. 用前检查是否为 NULL
// 3. free 后立即置 NULL
return 0;
}- NULL 指针:明确的"空指针",值为 0。解引用它会段错误崩溃。用前要检查
if (p != NULL)。 - 野指针:未初始化的指针,值是随机地址。最危险——可能指向任何地方,读写会破坏数据或崩溃。声明指针时一定初始化(哪怕初始化为 NULL)。
- 悬垂指针:指向已释放内存(free 后)或已返回栈帧的指针。访问它行为未定义,可能拿到旧数据,也可能崩溃。free 后立即置 NULL。
三条铁律:(1) 声明指针就初始化(没指向就赋 NULL);(2) 用前检查是否为 NULL;(3) free 后立即置 NULL。养成这三个习惯,能避免 90% 的指针 bug。
六、指针的实战:为什么 swap 必须用指针
这是 C 指针最经典的入门案例。理解了它,你就理解了"为什么 C 没有 reference 就必须用指针":
#include <stdio.h>
// 经典:为什么 C 必须用指针才能交换两个变量
void bad_swap(int a, int b) { // 值传递,只交换了拷贝
int tmp = a; a = b; b = tmp;
}
void good_swap(int *a, int *b) { // 传指针,交换原变量
int tmp = *a;
*a = *b;
*b = tmp;
}
int main(void) {
int x = 10, y = 20;
bad_swap(x, y);
printf("x=%d, y=%d\n", x, y); // 10, 20(没变)
good_swap(&x, &y);
printf("x=%d, y=%d\n", x, y); // 20, 10(交换了)
return 0;
}bad_swap 失败的原因:参数是值传递的拷贝,函数内交换的是拷贝,原变量不受影响。good_swap 成功的原因:传的是地址,函数内通过解引用 *a 修改的就是原变量本身。这是 C 没有"引用"机制带来的设计模式——所有"要在函数内修改外部变量"的场景都要传指针。
七、函数指针(进阶)
指针不仅能指向数据,还能指向函数。函数名本质上就是函数代码的内存地址。函数指针是 C 实现回调、多态、事件分派的核心机制:
#include <stdio.h>
// 普通函数
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
// 函数指针类型:返回 (*名)(参数)
// 用法类似"把函数当变量传来传去"
// 参数 op 是一个函数指针
int calculate(int x, int y, int (*op)(int, int)) {
return op(x, y); // 通过指针调用函数
}
int main(void) {
// 声明函数指针
int (*f)(int, int);
f = add;
printf("%d\n", f(3, 5)); // 8
f = sub;
printf("%d\n", f(10, 3)); // 7
// 作为参数传递(回调函数模式)
printf("%d\n", calculate(4, 5, add)); // 9
printf("%d\n", calculate(4, 5, mul)); // 20
// 函数指针数组:实现"命令分派"
int (*ops[])(int, int) = {add, sub, mul};
printf("%d\n", ops[0](10, 3)); // 13 (add)
printf("%d\n", ops[1](10, 3)); // 7 (sub)
return 0;
}函数指针语法比较丑(int (*op)(int, int)),用 typedef 起别名可以更清晰:typedef int (*BinOp)(int, int);。函数指针的经典用途:
- 回调函数:C 标准库
qsort接受一个比较函数指针,实现"对任意类型排序"。 - 命令分派:把多个函数放进数组,按下标调用。
- 模拟面向对象:Linux 内核的 device driver 把函数指针放在 struct 里,实现"方法"。
- 事件循环:GUI 库、Node.js 底层的回调机制都源于此。
八、二级指针
指针本身也是一个变量,它也有地址。指向指针的指针叫二级指针(int **pp):
#include <stdio.h>
#include <stdlib.h>
// 二级指针:指向指针的指针
// 主要用于"在函数内动态分配指针"
void alloc_string(char **str, const char *content) {
*str = malloc(100); // 修改外面的 char*
// 把内容写进去...
}
int main(void) {
int n = 10;
int *p = &n; // 一级指针:指向 int
int **pp = &p; // 二级指针:指向 int*
printf("%d\n", **pp); // 10(两次解引用)
// 经典场景:函数内 malloc 一个指针
char *s = NULL;
alloc_string(&s, "hello"); // 传 s 的地址(二级指针)
free(s);
// 指针数组 + 二级指针(命令行参数就是这个结构)
char *names[] = {"Alice", "Bob", "Carol"};
char **np = names;
printf("%s\n", np[0]); // Alice
printf("%s\n", *np); // Alice
return 0;
}二级指针最常见的用途:在函数内动态分配一个指针。比如 alloc_string(&s, "hello")——要让函数能修改外面的 char *s,必须传 s 的地址(也就是二级指针 char **)。另一个常见场景是命令行参数 int main(int argc, char **argv)——argv 是一个指向字符串数组的指针,本质是二级指针。
九、const 与指针(易混淆)
const 和指针组合有三种情况,记忆口诀"const 在 * 左边修饰值,在 * 右边修饰指针":
const int *p(或int const *p):指向 const 的指针——不能通过 p 改值,但可以让 p 指向别处。int * const p:const 指针——不能让 p 指向别处,但可以改它指向的值。const int * const p:两者都不能改。
API 设计中常把只读参数声明为 const T *,既清晰表达意图,又让编译器帮你检查误改。
十、常见段错误原因
"段错误"(segmentation fault)是 C 程序员最常遇到的崩溃,几乎都是指针误用:
- 解引用 NULL 指针
- 解引用未初始化的野指针
- 解引用已 free 的悬垂指针
- 数组越界访问(读/写)
- 向只读内存(字符串字面量)写数据:
char *s = "hi"; s[0] = 'H'; - 函数返回栈上局部变量的地址
调试段错误的工具:gdb(运行到崩溃点看栈)、valgrind(查内存错误)、AddressSanitizer(编译时加 -fsanitize=address,运行时实时检查)。
小结
指针 = 存地址的变量。三个符号:& 取地址、* 解引用、声明时 T *。数组名会退化为首元素指针,arr[i] == *(arr+i)。NULL/野/悬垂指针是崩溃主因,养成初始化、检查、置 NULL 的习惯。函数指针实现回调,二级指针用于动态分配指针。下一篇看 C 的字符串——字符数组的进阶用法。
← 上一篇 C 数组
下一篇 C 字符串 →