很多学C语言的朋友,学到数组还能硬着头皮写,一碰到指针就开始打退堂鼓。指针到底是什么?为什么函数里传了指针就能改外面的变量?为什么int **p一出现就头晕?这篇文章就是把这堂课彻底补上。指针是C语言绕不开的核心概念,也是绝大多数新手的分水岭:理解了它,C语言就算真正入门了;没理解,后面的链表、树、进程地址空间、操作系统课程都会像听天书。我会从内存地址讲起,把指针变量、指针数组、数组指针、函数指针、指针的指针、双指针这些高频考点串成一条线,每个知识点配上完整可运行的代码和踩坑记录,适合刚学完基础语法准备啃指针的新手,也适合应付期末或机试前做系统查漏补缺。
1. 指针到底是什么:从内存地址说起
1.1 内存就是一排带编号的柜子
想象一个快递站,柜子有几千个格子,每个格子有唯一编号。你要存东西,就得把东西放进某个编号的格子,记下编号,下次凭编号去取。计算机内存就是这样的柜子,每个“格子”是1个字节(Byte),每个格子都有一个编号,这个编号就叫内存地址。
程序里的变量,本质上是内存里的一块区域。普通变量存的是“数据本身”,比如int a = 10,就是从内存里挑一块4字节的区域,把数值10写进去。而指针变量存的是另一块内存的门牌号,也就是地址。
&运算符的作用就是取地址,拿到变量所在的内存单元编号。我们常说的“变量名”,其实只对编译器有意义,CPU根本不认识a和b,它只认地址。这就是为什么指针如此重要——因为它让我们直接跟内存地址打交道。
1.2 指针变量存的是门牌号
声明一个指针变量的写法很直观:
int a = 10; int *p = &a;读法是“p是一个指向int类型的指针”,*p就代表“p指向的那个变量的值”。定义时int *是一个整体,表示这是存放int变量地址的指针。
这里有个新手常见困惑:为什么指针变量还要分类型?因为指针不只是存地址,在解引用和指针算术时需要知道“这个地址后面多少个字节属于这个变量”。p++对于int *会跳过4个字节,对于char *只跳1个字节,所以类型起了“步长”的作用。
试跑一下:
#include <stdio.h> int main(void) { int a = 10; int *p = &a; printf("a 的地址 = %p\n", &a); printf("p 的值 = %p\n", p); // 和上面一样 printf("解引用 = %d\n", *p); // 输出 10 *p = 20; // 改的是 p 指向的那块内存 printf("a 的值 = %d\n", a); // 输出 20 return 0; }*p = 20这一句,相当于顺着p里存的门牌号找到a的那块内存,把数值改成20。所以运行后a变成了20。这就是指针最基本的用法。
1.3 初始化与“绝对不能碰”的野指针
指针变量定义后一定要初始化,要么指向一个明确的变量,要么赋NULL。原因很简单:如果只声明不初始化,指针里的值是随机的垃圾数据,解引用它等于拿着一个随机门牌号去打开别人的柜子,轻则数据错乱,重则直接段错误崩溃。
int *p; // 错误示范:未初始化的指针,值是随机的 *p = 100; // 崩溃风险极大,这叫野指针 int *q = NULL; // 正确做法:先指向空判断指针是否有效,只要解引用之前检查if (p != NULL)就可以避免大部分灾难。这也是为什么之后谈到的所有正经代码,都会在指针被free之后立刻置为NULL,防止继续使用已经释放的内存。
注意:
*在声明和表达式里的含义不同。声明时int *p里的*是类型说明符的一部分;表达式中*p是解引用运算符。两者别混。
2. 指针与数组、字符串、结构体
2.1 数组名就是退化的指针
C语言里,数组名和指针的关系非常紧密。对一维数组int arr[5]来说,arr本质上是一个指向数组首元素的指针常量,它等价于&arr[0]。因此这两个写法完全等价:
arr[i]等价于*(arr + i)&arr[i]等价于arr + i
也就是说,数组访问本质上是“指针算术 + 解引用”。arr + i并不是简单地把地址加i,而是加上i * sizeof(int)字节,编译器会按指针类型自动换算步长。
有个高频考点是sizeof的差异:对数组变量执行sizeof(arr)得到整个数组占用的字节数,比如5个int就是20字节;但一旦数组作为参数传给函数,就会退化成指针,sizeof(p)在64位系统上只会得到8字节。两者差得多,很多同学在计算数组长度时踩坑。
#include <stdio.h> void print_len(int arr[]) { printf("函数内 sizeof(arr) = %zu\n", sizeof(arr)); // 8字节,是指针 } int main(void) { int arr[5] = {1, 2, 3, 4, 5}; printf("main 中 sizeof(arr) = %zu\n", sizeof(arr)); // 20字节 print_len(arr); return 0; }2.2 指针数组和数组指针:别把名字搞混
指针数组是一个数组,数组的每个元素都是指针。例如:
char *names[3] = {"C", "Java", "Python"};这里names[0]指向字符串字面量"C"的首字符,sizeof(names)在64位环境下是24字节(3个指针×8字节)。指针数组常用于保存一组字符串、处理命令行参数。
数组指针是一个指针,它指向一个“完整数组”。声明语法比较反直觉:
int (*p)[5]; // p 指向“含有5个int的数组”这里圆括号不能丢,否则int *p[5]就变成元素为int *的指针数组。数组指针通常用于指向二维数组的某一行,接收二维数组参数时会用到。
| 写法 | 含义 | 常见用途 |
|---|---|---|
int *p[5] | 指针数组:5个int *元素 | 保存多个地址,如字符串数组 |
int (*p)[5] | 数组指针:指向含5个int的数组 | 遍历二维数组的行 |
int *p | 指向int的指针 | 操作一维数组、函数参数 |
2.3 字符串:指针和字符数组怎么选
C语言没有原生字符串类型,字符串本质上是字符数组,或者是指向字符常量区的指针。两种写法:
char s1[] = "hello"; // 字符数组,栈上分配,可修改 char *s2 = "hello"; // 指向字符串字面量,通常位于只读区,不要修改s2[0] = 'H'这种操作在大多数平台上会直接崩溃,因为字面量是只读的;s1[0] = 'H'则合法。所以“需要修改字符串”时用数组,“只读访问、节省拷贝”时用指针。
字符串逆序是经典练习,用双指针(一头一尾)最自然:
#include <stdio.h> #include <string.h> void reverse(char *s) { int i = 0, j = strlen(s) - 1; while (i < j) { char tmp = s[i]; s[i] = s[j]; s[j] = tmp; i++; j--; } } int main(void) { char str[] = "abcdef"; reverse(str); printf("%s\n", str); // fedcba return 0; }这个例子同时练习了“字符数组传入函数退化成指针”“下标写法与指针等价”两个知识点。
2.4 结构体指针:为什么大家都用箭头
定义结构体和指针:
struct Student { char name[20]; int age; }; struct Student stu = {"Alice", 20}; struct Student *p = &stu;用指针访问成员,两种写法等价:
(*p).age = 21; // 先解引用再取成员 p->age = 21; // 箭头运算符,本质是 (*p).age 的语法糖为什么要传结构体指针而不是直接传结构体?因为结构体可能很大,传值会把整个结构体复制一份到函数栈上,时间和空间开销都大;传指针只复制一个8字节的地址,函数内部通过指针修改原结构体也顺理成章。这就是C语言里“传指针效率高”的最直观场景。
3. 指针进阶:二级指针、函数指针与双指针
3.1 指针的指针:什么时候必须用
如果函数内需要修改“外部指针变量本身”,而不是修改指针指向的内容,就要用二级指针int **p。典型场景是链表插入节点、删除头节点。
举个例子,往链表头部插入一个新节点,如果只在函数里传一级指针,函数外部的头指针根本不会被改变,因为指针变量本身是按值传递的。用二级指针就能直接操作外部头指针:
#include <stdio.h> #include <stdlib.h> struct Node { int data; struct Node *next; }; /* 在链表头部插入新节点 */ void insert_head(struct Node **pp_head, int val) { struct Node *new_node = (struct Node *)malloc(sizeof(struct Node)); new_node->data = val; new_node->next = *pp_head; // 新节点指向原来的头 *pp_head = new_node; // 修改外面的头指针 } int main(void) { struct Node *head = NULL; insert_head(&head, 10); insert_head(&head, 20); printf("head->data = %d\n", head->data); // 20 return 0; }读int **pp的方式是:pp指向一个int *,那个int *再指向一个int。类似地,三级指针就是“指向二级指针的指针”,实践不常用,但理解递归本身有助于读懂复杂声明的规律:从右向左读,每遇到一个*就加一层“指向”。
3.2 函数指针与指针函数:一字之差,天壤之别
指针函数定义:返回值是指针的函数,比如char *my_strchr(char *s, char c),它本身是一个函数,只是返回指针类型。
函数指针定义:指向函数的指针。函数在内存中也有地址,函数指针本质上存的就是这个函数的入口地址,用来做回调、状态机、菜单分发非常合适。
#include <stdio.h> int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int main(void) { int (*op)(int, int); // 声明一个函数指针 op = add; // 指向 add printf("3 + 4 = %d\n", op(3, 4)); op = sub; // 重新指向 sub printf("3 - 4 = %d\n", op(3, 4)); return 0; }函数指针类型很长,可以用typedef简化:
typedef int (*BinaryOp)(int, int); BinaryOp op = add;理解函数指针的关键是区分两个东西:op本身是被调用的入口,op(3, 4)就是通过入口调用函数;而int (*op)(int, int)的声明则要和“指针函数int *fun()”分开看。考试最爱考这种辨析,花10分钟对比记忆非常值。
3.3 双指针法:解决“单链表删除”的一招鲜
双指针法是链表中很常用的一招。比如删除链表中第一个值为val的节点,你用prev和cur两个指针一前一后也能做,但边界处理容易出错。改用二级指针pp指向next字段,代码会变得非常简洁:
#include <stdio.h> #include <stdlib.h> struct Node { int data; struct Node *next; }; void delete_first(struct Node **pp, int val) { while (*pp != NULL && (*pp)->data != val) { pp = &(*pp)->next; // pp 指向下一个节点的 next 字段 } if (*pp != NULL) { struct Node *to_delete = *pp; *pp = (*pp)->next; // 让前一个节点的 next 指向待删节点的 next free(to_delete); } }这种做法的精妙之处是pp从头到尾都是“某个指针变量的地址”,删除头节点和删除中间节点的逻辑完全统一,不需要单独判断“是不是第一个”。
双指针在数组题里也常用,比如有序数组去重:
int remove_duplicates(int *nums, int n) { if (n == 0) return 0; int slow = 0; for (int fast = 1; fast < n; fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }这里的 slow 和 fast 本质是两个数组上的索引指针,快指针负责探路,慢指针负责写入结果,面试和机试中高频出现。
4. 指针实战:从两数交换到鞍点问题
4.1 为什么两数交换必须用指针
很多教程用两数交换作为指针入门的第一个例子。写一个失败的版本:
void swap_bad(int a, int b) { int tmp = a; a = b; b = tmp; }在main里调用swap_bad(x, y)后,x和y根本没变。原因是C语言函数参数默认按值传递,a、b是实参x、y的副本,函数里交换的是副本,出了函数什么都没发生。
改用指针:
void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; }调用时传地址swap(&x, &y),函数通过地址直接操作原来那两个内存单元。理解这个例子后,对“引用传递”这个概念才能真正吃透。
顺带说一下C++的情况:C++里可以用void swap(int &a, int &b),语法上看起来不需要取地址,但编译器在底层生成的代码和指针版本几乎一样。C++的引用本质就是“受限的指针”:引用一旦绑定不能换,指针可以任意指向别处;引用不能为NULL,指针可以。初学者最好先把C指针玩熟,再学引用就不会被绕晕。
4.2 用指针实现冒泡排序
冒泡排序通常用arr[i]的写法,但如果想强化指针理解,可以全部用指针算术实现:
#include <stdio.h> void bubble_sort(int *arr, int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (*(arr + j) > *(arr + j + 1)) { int tmp = *(arr + j); *(arr + j) = *(arr + j + 1); *(arr + j + 1) = tmp; } } } } int main(void) { int nums[] = {9, 3, 7, 1, 5}; int n = sizeof(nums) / sizeof(nums[0]); bubble_sort(nums, n); for (int i = 0; i < n; i++) { printf("%d ", nums[i]); } return 0; }注意*(arr + j + 1)这个写法,它和arr[j + 1]完全等价。既然数组参数传进来就是指针,那么两种写法本质都在做同一件事:从首地址出发偏移j * 4字节再解引用。掌握这个等价关系对后面读复杂代码很有帮助,比如操作系统内核、开源项目里大量使用指针算术。
4.3 5×5鞍点问题的完整解法
浙大PTA、翁恺课程的作业题里,经常出现“求5×5矩阵的鞍点”。鞍点的定义是:在该行最大、同时在该列最小的元素。解题思路分两步:先找到每一行的最大值位置,再检查它是不是所在列的最小值。
#include <stdio.h> #define ROWS 5 #define COLS 5 int main(void) { int matrix[ROWS][COLS]; int found = 0; for (int i = 0; i < ROWS; i++) { for (int j = 0; j < COLS; j++) { scanf("%d", &matrix[i][j]); } } for (int i = 0; i < ROWS; i++) { int col_max = 0; for (int j = 1; j < COLS; j++) { if (matrix[i][j] > matrix[i][col_max]) { col_max = j; // 当前行最大值的列下标 } } int row_min = 0; for (int k = 1; k < ROWS; k++) { if (matrix[k][col_max] < matrix[row_min][col_max]) { row_min = k; // 当前列最小值的行下标 } } if (row_min == i) { printf("鞍点: matrix[%d][%d] = %d\n", i, col_max, matrix[i][col_max]); found = 1; } } if (!found) { printf("不存在鞍点\n"); } return 0; }这个题目非常综合:二维数组、循环、条件判断都被串起来了。如果进一步用指针改写,可以把内层循环里的matrix[i][j]换成*(*(matrix + i) + j),虽然看起来别扭,但理解二维数组的“数组指针退化”会深入很多。调试时其实建议先用下标版本写对,再用指针版本对照,一步到位最容易把自己绕进去。
5. 指针与内存管理:堆区、栈区与那些坑
5.1 程序运行时,数据都放在哪
C语言程序运行时,内存大致分成几个区域:
- 栈区(stack):局部变量、函数参数,函数调用结束自动释放。空间小,默认几MB,但速度快。
- 静态区/全局区(static/global):全局变量、static变量,从程序启动到结束一直存在。
- 堆区(heap):动态分配的内存,由程序员手动申请和释放,空间大但必须自己管理。
- 代码区:存放机器指令,只读。
指针变量本身可能存放在栈上或作为静态变量,但指针指向的对象可能存放在任何区域。判断一个指针能不能安全使用,关键是搞清楚它指向的是哪个区域、生命周期多长。
5.2 malloc 和 free 必须配对
用malloc在堆上分配内存,用完必须用free释放,这是硬规矩。一个最容易被忽略的问题是:分配后没有检查返回值。在极低内存环境或连续分配大量内存时,malloc可能返回NULL,此时直接解引用会崩溃。
#include <stdio.h> #include <stdlib.h> int main(void) { int *arr = (int *)malloc(10 * sizeof(int)); if (arr == NULL) { printf("分配失败\n"); return 1; } for (int i = 0; i < 10; i++) { arr[i] = i * i; } free(arr); // 释放堆内存 arr = NULL; // 防止野指针 return 0; }free(p)后,内存交由系统重新分配,但指针p里存的地址还在,如果不把p置NULL,代码里稍不留神再次解引用就会操作一块已经被别人使用的内存,这种问题极难排查。养成习惯:释放后立刻赋值NULL。
C++里提供了智能指针来替我们“记住释放”,比如std::unique_ptr<int[]> arr = std::make_unique<int[]>(10);,离开作用域自动析构释放。这是对C手动管理内存的改进,思路值得借鉴,但真正理解它,还是先扎实掌握malloc/free才好。
5.3 野指针、悬空指针、内存泄漏
这三个概念容易混淆:
| 问题 | 定义 | 后果 | 避免方式 |
|---|---|---|---|
| 野指针 | 指针未初始化,存的是随机地址 | 解引用即崩溃 | 声明时赋初值或置NULL |
| 悬空指针 | 指向的内存已被释放,但指针仍保留旧地址 | 解引用访问无效内存 | free后置NULL |
| 内存泄漏 | 动态分配的内存从未释放,且指针丢失 | 程序常驻内存持续增长 | malloc/free严格配对 |
内存泄漏在大型服务里尤其致命。举个例子,每次循环malloc一段内存,用完只把局部指针变量重新赋值,原来的地址就“找不到了”,没人能帮你释放,内存越用越多。排查手段一般是工具,Linux上用valgrind或AddressSanitizer,Windows上也可以用 VS 的诊断工具。
提示:不要试图依赖“程序结束操作系统会回收内存”来掩盖泄漏。长期进程、嵌入式系统、服务端程序都经不起这种挥霍。
6. 经典误区排查:C语言指针学习避坑实录
6.1 新手最容易踩的五个雷
我把帮人改代码时最常见的错误列成一张速查表:
| 错误写法 | 后果 | 修正 |
|---|---|---|
int *p; *p = 5; | 段错误 | 先让p指向一个int变量,或malloc |
return &local_var; | 函数返回后局部变量销毁,指针悬空 | 改用静态变量、堆内存或传出参数 |
arr[i] = ...但越界访问 | 破坏相邻内存 | 严格检查数组长度 |
free(p); p[0] = ...; | 访问已释放内存 | free后置NULL并检查 |
p++后free(p) | free的不是原地址,崩溃 | 保留原始头指针用于free |
第二行“返回局部变量地址”很典型:
int *bad_func(void) { int x = 10; return &x; // x 在函数结束后被回收,返回的地址是悬空的 }表面上第一次调用还能得到10,一旦后面再有别的函数调用,栈上那部分被覆盖,结果就无法预料。正确做法是malloc后在堆上分配,或者通过指针参数接收值。
6.2 定位问题:GDB 和 AddressSanitizer
遇到指针崩溃,别慌。优先做三件事:
- printf大法:在解引用前后打印指针值和指针指向的值,定位崩溃前最后一次正常输出的位置。
- GDB:
gdb ./a.out,运行崩溃后输入bt查看调用栈,能准确告诉你崩溃在哪一行。调试时p命令打印变量,x/4bx查看内存字节。 - AddressSanitizer:编译时加
-fsanitize=address,运行后直接告诉你“堆越界”“栈溢出”“释放后使用”发生在哪一行。这个工具对自学阶段的纠正效果极好,强烈推荐。
举个例子,出现Segmentation fault时第一反应不是瞎改,而是打开GDB运行bt,看到栈顶函数名后思考:这个函数里哪个指针可能是空的或指向无效地址。大多数情况下问题就藏在解引用前少了NULL判断。
6.3 学习环境与练习资源怎么搭
环境建议二选一:
- Windows上:用 VSCode + MinGW 的 gcc,扩展安装 C/C++ 插件即可。记得在头文件包含路径里确认 MinGW 安装目录,否则
#include <stdio.h>都会报错找不到。 - Linux上:Ubuntu 最省心,终端直接
sudo apt install gcc build-essential gdb,写.c文件后gcc -g -o app app.c,./app运行,gdb app调试。没有图形界面需求的话,纯终端完全够用。
资料方面,翁恺在浙大开的C语言基础公开课逻辑清楚、例子好懂,适合零基础;谭浩强的教材胜在体系完整,把每章习题在PTA上刷一遍就能快速发现自己的薄弱点,比如指针和数组的等价转换、字符串处理等。学有余力再翻《C和指针》,书中对复杂声明和指针算术的解释非常经典,读完后理解会再上一个台阶。
6.4 给学习路线一点具体建议
如果目标只是“看懂指针并通过考试”,我建议按这个顺序推进:先会写int *p = &a; *p = ...;能解释清楚每一步干什么;然后做“指针遍历数组”和“字符串逆序”;再做“结构体指针作为函数参数”;最后做链表插入/删除题。完成这四步,常见的指针用法就基本覆盖了。
练综合题可以试试“C语言打字游戏”和“网吧计费管理小项目”,这类项目不复杂,但涉及结构体数组、指针传参、文件操作,属于把指针揉进真实场景的好办法。比单纯刷题有趣,完成度反而更高。
我个人的体会是:指针之所以难,不是因为规则复杂,而是因为符号*在不同位置表达不同意思,加上“内存地址”本身看不见摸不着,第一印象容易恐慌。我当时是把所有指针问题都翻译成“门牌号”来理解:一个地址是门牌号,找到门牌号再进去拿东西就是解引用,改门牌号就能换一个柜子。想通这层之后,函数指针、二级指针、双指针这些概念都是同一个模型的延伸。
最后再分享一个小技巧:无论你看到多复杂的指针声明,都从右往左读,遇到*就补一句“指向”。像int (*p)[5]读作“p是指向含5个int数组的指针”,一次读顺就不会再和int *p[5]混了。指针这关过了,C语言很多大门都会向你敞开。