我见过很多从零开始学C语言的初学者,前面学变量、分支、循环都挺顺利,一到指针就开始怀疑人生。老实说,C语言之指针入门这件事,卡住的根源通常不是智商,而是脑子里一直没有一个清晰的内存模型。今天这篇不讲应试技巧,也不灌鸡汤,我把平时给学生讲指针的那套思路完整写出来,争取让你读完就能在草稿纸上画出指针示意图,看懂每一行指针代码背后到底发生了什么。
1. 为什么这么多人卡在指针上:先建立内存模型
1.1 从“带编号的箱子”看内存编址
要理解指针,第一件事是忘掉“变量名等于变量本身”的直觉。我们平时写int a = 5;,编译器真正做的事,是在内存里找一块足够放 int 的空间,把 5 放进去,然后记住这块空间的起始地址。
你可以把内存想象成一长排编号连续的盒子。每个盒子有一个地址,比如0x1000、0x1004……盒子里存的是数据。变量名a只是给程序员看的标签,到了机器层面,CPU访问数据靠的是地址,不是靠名字。
所以int a = 5;的本质是:
- 在某个地址(假设是
0x1000)上,放了 5 这个值; - 编译器维护了一张符号表,记录“变量名 a 对应地址 0x1000”;
- 以后写
a,编译器会翻译成“访问地址 0x1000 的内容”。
这个模型说起来简单,但很多人学指针时压根没往这层想。后面所有指针概念,包括取地址、解引用、数组退化、函数传参,全部建立在这条基础之上。我建议你在纸上画一排格子,标上地址编号,再写代码,边写边对应格子,思路会通得特别快。
1.2 指针变量本身也是变量,只是存的东西特殊
搞清楚了内存的“编号格子”模型,指针就好定义了。
普通变量int a存的是整数;指针变量int *p存的是另一个变量的地址。它自己也有地址,也占内存空间,只是它的“值”不是普通数据,而是一个门牌号。
我需要强调一点:指针变量的大小不等于它指向的对象大小。在常见的 64 位桌面平台上,指针本身通常是 8 字节,不管你指向的是 char、int 还是 10 万个字节的结构体。因为地址就是一个整数,指针变量只是用来装这个整数的容器。
很多初学者问:“指针指向谁的?直接存不行吗?为什么非要绕一层?”这是个好问题。直接访问当然可以,但程序里经常需要“间接访问”。比如你在函数里要修改外面的变量,直接传值做不到,只能把地址传进去,通过地址去修改。再比如链表、树、图这些数据结构,节点之间都是靠指针串起来的,没有指针,动态数据结构根本无从谈起。
1.3 一个我常用的生活化比喻:快递单上的地址
我给零基础学员讲指针时,常用快递单来类比。
你要给一个住在大楼里的人送东西。如果你只知道“那人叫张三”,神仙也没法送;你必须知道他的门牌号,比如“幸福小区 3 栋 502”。这个门牌号,就是变量的地址。指针变量,就相当于你写在本子上的“张三:幸福小区 3 栋 502”。
当你写int *p = &a;,就相当于在本子上记录:“a 住在地址 &a 那个房间”。之后你想给 a 送东西,不需要重新找 a 的名字,直接根据 p 里记录的门牌号找过去就行。解引用*p,就是“按门牌号上门”,进到房间里取东西或者放东西。
正因为如此,指针学起来感觉“绕”,是因为它多了一层“先看地址,再根据地址访问”的动作。但这也是它强大的原因:只要手里攥着地址,你不需要抱着变量本体跑来跑去,也能随时操作它。
2. 第一行指针代码:声明、取地址、解引用
2.1 声明语法拆解:星号到底贴谁
先看最基础的代码:
#include <stdio.h> int main(void) { int a = 42; int *p; // 声明一个指向 int 的指针变量 p p = &a; // 把 a 的地址赋给 p printf("a 的值:%d\n", a); printf("p 保存的地址:%p\n", (void *)p); printf("通过 p 解引用得到 a 的值:%d\n", *p); *p = 100; // 通过指针修改 a 的值 printf("修改后 a 的值:%d\n", a); return 0; }这里有一个长期困扰新手的问题:int* p和int *p哪种写法对?
两种都行。从语言角度讲,空格无所谓。但有一个实际建议:如果你一行声明多个变量,比如:
int* p1, p2;很多人会误以为 p1 和 p2 都是指针。实际上只有 p1 是指针,p2 是普通的 int。因为*只作用于紧随其后的那个变量名。写成下面这种形式更不容易骗到自己:
int *p1, *p2;所以我不太建议把星号贴死在类型上。初学阶段,把*看成“p 这个变量是个指针”的修饰符,而不是类型的一部分,会少踩很多坑。
2.2 & 和 * 的关系:取地址与解引用
&操作符的作用是“取地址”。&a的结果就是 a 在内存中的起始地址。注意,取地址不是把变量“变没”,而是拿到一个值,这个值可以打印、可以赋值、可以比较。
*操作符的作用是“解引用”。对一个指针变量使用*p,意思是“访问 p 里保存的地址所对应的那个变量”。如果 p 保存的是 a 的地址,那么*p和a就是同一个变量的两种称呼。这在C语言里有专门的说法:*p是 a 的“别名”。
所以&和*是互逆操作。&a得到地址,*p根据地址访问变量;如果你写*&a,先取地址再解引用,等价于直接访问 a;如果你写&*p,先解引用再取地址,等价于 p 本身。
这个互逆关系看起来很绕,其实特别重要。以后你读别人代码,看到复杂的指针表达式,第一步永远是先找*作用在谁身上,再看&取的是谁的地址。
2.3 初始化:不给指针一个好地址,就不要用它
指针声明之后,如果没有任何初始化,它的值是随机的。这个随机的地址可能指向内存里任何一个地方,你解引用它,轻则读到垃圾数据,重则直接段错误。所以业界有一条朴素的规矩:指针必须初始化,要么指向一个合法对象,要么赋 NULL。
int *p = NULL; // 好的习惯:先置空NULL 的地址是 0,正常情况下不会指向任何有效对象。对 NULL 解引用同样会崩溃,但它至少在崩溃时很好定位:你一眼就能看出来是“空指针访问”,而不是莫名其妙地改坏了别的数据。
赋值和初始化是两回事。区分很简单:
int *p = &a; // 初始化:声明的时候给它地址 p = &b; // 赋值:之后重新让它指向 b很多新手会把p = &a;和*p = a;搞混。前者是把地址放进去,后者是往 p 指向的位置写入值。二者的区别,一句话总结:指针用=改的是“指向谁”,解引用用=改的是“指向的那个变量的值”。
3. 数组与指针:最容易让人顿悟的组合
3.1 数组名到底是个什么东西
很多人在学到数组和指针结合时,会有一次“原来如此”的顿悟。数组名的问题,就是这次顿悟的入口。
看代码:
int arr[5] = {10, 20, 30, 40, 50}; int *p = arr; // 数组名直接赋给指针这里arr不是“整个数组”,而是“指向数组首元素(arr[0])的指针”。说得更准确一点,在绝大多数表达式中,数组名会“退化”为首元素地址。所以p = arr;等价于p = &arr[0];。
但是有一个重要提醒:数组名不是指针变量。它是一个“常量地址”,不能自增、不能重新赋值。比如下面这段就是非法的:
arr++; // 错误:数组名不能作为左值而指针变量可以:
p++; // 正确:p 是变量,可以改变自己保存的地址3.2 指针算术:为什么 p+1 跳过一个元素而不是一个字节
这是初学者最容易懵的地方。假设int arr[5],每个 int 占 4 字节。如果p指向arr[0],地址是0x1000,那么p + 1的地址是多少?
答案是0x1004,不是0x1001。
原因是:指针加法是“以指针指向的类型大小为步长”的。p是int *,int 占 4 字节,所以p+1跳过 4 字节;如果p是char *,char 占 1 字节,那p+1就跳过 1 字节。这个规则叫“指针算术”,它保证了p+1指向的一定是“下一个同类型元素”,而不是某个元素的中间位置。
用这个规则,遍历数组就可以有两种等价写法:
for (int i = 0; i < 5; i++) { printf("%d ", *(p + i)); } for (int i = 0; i < 5; i++) { printf("%d ", p[i]); }p[i]实质上是*(p + i)的语法糖。所以指针跟数组在访问元素这件事上,是完完全全等价的。很多人觉得p[i]写法“像数组”,其实它的底层就是指针算术。
如果你想用指针自增的方式遍历,还可以这样:
int *p = arr; for (int i = 0; i < 5; i++) { printf("%d ", *p++); }注意*p++的优先级:++和*是同级的,但结合方向是自右向左,所以先取*p的值,然后 p 再自增。这条很经典,但要小心,读别人代码时一定要先搞清楚它到底是“取完再移”还是“移完再取”。
3.3 定长数组和指针的几个关键差异
虽然数组和指针在访问元素时等价,但它们并不相同。有几个关键差异,我列成表方便对照:
| 对比项 | 数组名 | 指针变量 |
|---|---|---|
| 是否变量 | 不是,是常量地址 | 是,可以改值 |
| sizeof 结果 | 整个数组占用字节数 | 指针本身大小(通常是8字节) |
| 能否自增 | 不能 | 能 |
| 赋值 | 不能整体赋值 | 可以指向别处 |
| 访问元素 | arr[i] | p[i] 或 *(p+i) |
这里最容易踩的坑是 sizeof。很多人写一个函数,想把数组传进去,然后在函数里用 sizeof 求长度:
void print_sum(int arr[], int n) { // 看起来是数组,实际上是 int *arr printf("数组大小:%d\n", sizeof(arr)); // 得到的是指针大小,不是数组大小 }问题出在数组作为函数参数时会发生“退化”。函数参数里的int arr[]会被编译器当作int *arr。所以在函数内部,sizeof(arr) 永远是指针的大小,而不是传入数组的大小。这也是为什么C语言里传数组时,必须额外传一个长度参数,因为函数内部没有办法通过 sizeof 获知数组真实长度。
4. 函数参数与指针:不改原值的swap都是白写
4.1 经典失败案例:函数形参是变量的拷贝
几乎每个学指针的人,都会经历那个经典的 swap 失败案例:
void swap(int x, int y) { int temp = x; x = y; y = temp; } int main(void) { int a = 3, b = 5; swap(a, b); printf("a = %d, b = %d\n", a, b); // 仍然 3 5 return 0; }为什么没交换?因为C语言的函数参数传递是“值传递”。调用swap(a, b)时,编译器把 a 和 b 的值拷贝一份,分别赋给形参 x 和 y。swap 函数里交换的是拷贝出来的 x 和 y,外面的 a、b 完全没有被动过。等函数返回,x 和 y 的栈空间被回收,交换结果也就没了。
这个案例特别适合拿来理解“为什么要用指针”。如果不想只交换拷贝,就必须让函数拿到原始变量的地址,然后通过地址去操作原始变量。
4.2 通过指针修改外部变量:传地址的本质
正确写法是:
void swap(int *x, int *y) { int temp = *x; *x = *y; *y = temp; } int main(void) { int a = 3, b = 5; swap(&a, &b); printf("a = %d, b = %d\n", a, b); // 5 3 return 0; }关键理解点:
- 形参 x 和 y 是指针变量;
- 调用时传入的是 a 和 b 的地址;
*x和*y分别代表 a 和 b 本身;- 交换
*x和*y,就是在交换变量 a 和 b 的内容。
很多人会问:“函数形参不是拷贝吗?指针 x 也是拷贝啊。” 没错,x 这个指针变量本身是拷贝过来的,但拷贝的是地址。地址这个东西很特殊——虽然地址值是个副本,但它指向的还是同一个房间。所以通过副本地址,依然能修改原地址房间里的内容。
这就好比你把快递单复印了一份递给快递员,快递单是复印件没错,但门牌号没变,快递员照样能找到你的家,往屋里放了东西。
以后凡是想在函数里修改一个变量,就必须把这个变量的地址传进去。这是C语言里一个非常朴素的结论。
4.3 数组参数退化成指针:需要显式传长度
数组作为参数也很容易踩坑,前面提到过退化。具体来看:
void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } } int main(void) { int nums[4] = {1, 2, 3, 4}; print_array(nums, 4); // 需要显式传长度 return 0; }int arr[]在参数列表中只是语法糖,真正的类型是int *arr。所以print_array(nums, 4);实际上就是把 nums 的首元素地址传进去,再单独传一个长度。调用者负责维护“这个数组有多长”的信息,被调函数不能用 sizeof 自己算出来。
很多从 Java、Python 转过来的人会不习惯,因为那些语言里数组自带长度信息。但在C语言里,数组名传递给函数后,长度信息就“丢”了。这也是为什么老的C接口里到处都是int n、size_t len这种参数。
如果想连长度一起传,可以定义一个结构体,把数组指针和长度包在一起;或者干脆用两组参数。但没有银弹,C语言就是这么直接。
4.4 函数指针:把函数当作参数传递
指针不仅能指向数据,还能指向函数。函数在内存里也有地址,称为入口地址。定义一个函数指针的语法是:
int add(int a, int b) { return a + b; } int main(void) { int (*fp)(int, int); // fp 是指向“返回 int、接收两个 int 参数”函数的指针 fp = add; // 函数名就是函数地址 printf("%d\n", fp(3, 4)); // 通过函数指针调用,输出 7 return 0; }函数指针最常见的用途是回调。比如你要写一个排序函数,希望排序规则由使用者决定。使用者把“比较函数”的地址传进来,排序函数在需要比较时回调它。这样排序逻辑和具体比较逻辑就解耦了。
初学者不用急着把函数指针玩出花来,但至少要能看懂这种声明:从变量名 fp 开始,先向右看是),再向左看是*,说明 fp 是指针;再向右看(int, int),说明它指向的函数接收两个 int;再向左看int,说明函数返回 int。读复杂声明时,这种“从变量名出发,向右再向左”的方式非常管用。
5. 字符串、结构体与动态内存:指针的主战场
5.1 字符串常量与字符数组:可改与不可改
C语言里没有专门的字符串类型,字符串本质上就是字符数组,或者更准确地说,是一段以\0结尾的连续内存空间。指针在其中扮演的角色特别重要。
char *s1 = "hello"; char s2[] = "hello";这两行看起来很像,实际区别很大:
s1是指针,指向一个字符串常量。字符串常量通常存放在只读区,你尝试修改它会出问题;s2是字符数组,它在栈上开辟了 6 个字节(5个字符加一个\0),内容可以修改。
所以下面的代码是危险的:
char *s1 = "hello"; s1[0] = 'H'; // 未定义行为,可能崩溃而下面的代码没有问题:
char s2[] = "hello"; s2[0] = 'H'; // 合法初学者经常会在这里翻车。区分方法也很简单:看到char *p = "...",就把它当成只读字符串;需要修改字符串内容时,一定要用字符数组,或者在堆上动态分配一块空间再拷贝进去。
另外,常写字符串处理的同学最好记住:"hello"这种字面量本身也有地址。可以说它是藏在只读区的匿名字符数组,s1只是保存了它的首地址。
5.2 结构体指针与箭头操作符
结构体是C语言组织数据的重要手段。结构体变量也可以有一个指针指向它:
struct Student { int id; int score; }; int main(void) { struct Student stu = {2024001, 88}; struct Student *p = &stu; // 两种访问方式 printf("%d\n", (*p).id); // 先解引用再取成员 printf("%d\n", p->score); // 用箭头直接取成员 p->score = 95; // 通过指针修改成员 printf("%d\n", stu.score); return 0; }p->score是(*p).score的简写。箭头操作符本身就是为“指向结构体的指针”设计的,读起来比一堆圆括号舒服得多。
结构体指针最常见的应用场景是链表。链表节点的核心定义一般是这样:
struct Node { int data; struct Node *next; // 指向下一个节点 };这里最妙也最“吓人”的地方在于:结构体里有一个指向同一类型结构体的指针。C语言允许这样定义,因为它只是保存地址,不需要包含完整结构体。有了这种自引用结构,我们才能用指针串起一条链。学指针没学链表,等于白学;链表不搞懂结构体指针,基本推不动。
5.3 malloc 与 free:动态内存分配的规矩
前面讲的变量和数组,大多在栈上,生命周期由系统管理。但实际项目里,数据规模经常在运行时才确定,这时候就需要动态内存分配。
动态内存分配的标准三件套是:malloc、free、sizeof。
int *arr = malloc(10 * sizeof(int)); if (arr == NULL) { // 分配失败,不能继续使用 return -1; } for (int i = 0; i < 10; i++) { arr[i] = i; } free(arr);这里有几条铁律,我强调过无数次:
malloc的参数是字节数,不是元素个数。要分配 n 个 int,应该写n * sizeof(int),直接写10会少得可怜的空间。- 分配完必须检查返回值是否为 NULL。内存耗尽时
malloc返回 NULL,这时候继续用就变成空指针操作。 free只能释放由malloc/calloc/realloc分配的堆内存。不能 free 栈上的普通数组,也不能 free 字符串常量。free(p)之后,p 仍然保存着那块地址,但它指向的内存已经归还给系统,这时的 p 称为“悬空指针”。正确习惯是 free 之后马上:
free(p); p = NULL;这样一来,如果后面不小心解引用 p,就会直接空指针崩溃。崩溃虽然不好,但至少能立刻发现,比“悬空指针偷偷改坏内存”容易排查得多。
动态内存分配失败、忘记释放、重复释放、释放后继续用,这四类问题几乎是每个C程序员都经历过的。入门阶段,只要每写一个malloc,都同步写好对应的free,就能避免一半以上的内存问题。
5.4 指针、数组与动态内存的组合应用
举个综合一点的例子:
#include <stdio.h> #include <stdlib.h> int main(void) { int n = 5; int *nums = malloc(n * sizeof(int)); if (nums == NULL) { return 1; } for (int i = 0; i < n; i++) { nums[i] = i * i; } int *p = nums; for (int i = 0; i < n; i++) { printf("%d ", *p); p++; } printf("\n"); free(nums); return 0; }这段代码把指针、数组、动态内存串起来了:nums是指针,但它被当作数组用;又用另一个指针p进行遍历。实际项目里这种写法很常见。关键在于明白:
malloc分配出来的连续内存,和普通数组在访问方式上没有区别;- 区别只在于生命周期:普通数组离开作用域自动消失,堆内存必须手动释放;
- 任何时候都不要丢掉起始地址。像
p++遍历没问题,但必须另存一个“头指针”用于最后的 free。如果直接 free(p),那就不是释放起始位置,后果很严重。
6. 踩坑与调试:指针常见错误排查思路
6.1 空指针与悬空指针:两种容易混淆的坏事
空指针和悬空指针,初学者经常混为一谈。其实:
- 空指针是“什么都没指向”,值为 NULL;
- 悬空指针是“曾经指向一个有效对象,但对象已经被释放了”,指针里存的是一个过期的地址。
两者的共同点是都不能安全解引用。不同点是空指针比较好识别,如果程序崩溃,错误信息里通常能直接看到空地址访问;悬空指针更隐蔽,因为你解引用它时,那个地址可能已经被系统重新分配给别人,程序可能不崩溃,但数据悄悄坏了。
初学者最需要养成的习惯就是前面说的:free(p)后立刻p = NULL。这样悬空指针就退化成空指针,问题变得可控。同样,一个指针用完想重置,也直接赋 NULL,不要留着一个“脑子不清醒”的地址在里面。
6.2 段错误排查的基本思路
段错误是初学指针时最常见的崩溃提示,英文是 segmentation fault。它意味着程序访问了不属于它的内存。常见原因包括:
- 解引用未初始化的指针;
- 解引用空指针;
- 数组越界,写到数组边界之外;
- 访问已经释放的堆内存;
- 写入了字符串常量所在的只读区。
排查段错误,我一般按以下顺序:
- 先看错误输出的代码行号,如果编译器或调试器能直接指出位置,优先检查那附近是否涉及指针。
- 用打印法缩小范围:在关键操作前后加
printf,看程序到底执行到哪一步才崩。注意打印完最好加fflush(stdout),避免缓冲区导致输出丢失。 - 用调试器在可疑位置打断点,单步执行,观察指针变量的值。重点看指针是不是 NULL、是不是一个明显可疑的地址(比如全是 0 或者非常大的地址)。
- 如果怀疑内存被改坏,把相关数组长度、循环次数、索引变量打出来,核对是否越界。
很多段错误不是写错了某个语法,而是逻辑上越了界。比如循环i <= n,数组长度是 n,那最后一次访问的就是arr[n],已经越界了。这种错误非常隐蔽,因为越界后的内存可能还能读,但写的话就可能破坏别的东西。我见过很多同学在for (int i = 0; i <= n; i++)这种地方卡到怀疑人生,其实就是一个等号的事。
6.3 const 修饰指针三种形态
const 放在指针的什么位置,含义完全不同。这个可以说是面试和笔试高频考点,也是实际代码里最容易被忽视的地方。
const int *p; // p 指向的值不能通过 p 修改 int * const p; // p 本身不能指向别处,但指向的值可以改 const int * const p; // 两者都不能改记忆方法其实不难:
const修饰的是紧跟在它后面的内容;const int *p中 const 修饰int,所以“指向的 int 对象”被限定为只读;int * const p中 const 修饰p本身,所以“指针变量”被限定为只读;- 两个都写,那就全都不能变。
实际工程里,函数参数如果只是想读取数据、不修改它,就应该写成const int *arr,这样写既明确表达意图,也防止在函数内部不小心改坏实参。很多初学者不写 const,等代码到几千行时,就会发现一个本不该被修改的数据不知在哪儿被改了。多写 const,就是给自己上一个保险。
6.4 几个特别容易踩的细节
第一,&p和p不是同一个东西。p保存的是“被指对象的地址”,&p是“指针变量自己的地址”。如果你需要一个指向指针的指针,才用**pp = &p。但绝大多数入门场景用不到二级指针,没必要自己吓自己。
第二,不要把整数直接赋给指针。比如:
int *p = 1000; // 应避免,除非有特殊场景这会直接把地址设为 1000,访问它几乎必然崩溃。正规写法要么通过&取一个变量地址,要么通过malloc得到堆地址,没有“拍脑袋写一个地址”这种操作。
第三,尽量多用编译器警告。GCC 编译时建议加上:
gcc -Wall -Wextra -g main.c -o main-Wall -Wextra会提示很多潜在问题,比如类型不匹配、变量未使用、printf 格式错误等;-g用于生成调试信息,方便后续用调试器。很多初学者不看警告信息,遇到诡异问题排查半天,才发现是函数声明和调用时参数类型对不上。
第四,不要在指针还没指向有效对象时,就尝试读它。比如:
int *p; printf("%d", *p); // 未定义行为编译器可能不报错,但运行结果完全不可控。这类“不报错但乱跑”的问题是C语言里最折磨人的,因为错误不会立刻暴露,而是随机出现。解决办法就是从一开始就养成初始化指针的习惯。
学指针到这个阶段,最重要的已经不是背语法,而是亲手画内存图、亲手跑程序、亲手踩几个坑。我自己的体会是:指针这东西,只要在一次崩溃中真正理解“这个地址根本不归我管”,后面的链表、二叉树、回调函数都会顺畅很多。建议你接下来找几道数组和字符串的练习题,强制自己用指针改写一遍,再用调试器单步观察地址变化,坚持两个星期,你会发现自己对C语言的掌控感完全不一样。