1. 指针到底存了什么:从门牌号开始理解
1.1 变量的门牌号:&取地址,*访客进门
我当年学C语言,最先卡死的地方就是指针。老师在黑板上画方框、画箭头,说“指针就是指向变量的变量”,我听完更晕——变量还能指向变量?后来自己写代码,才慢慢悟明白一件事:指针解决的根本问题,就是“程序怎么找到一块内存”。
把内存想象成一条很长的街道,每个字节都有一个门牌号(地址)。定义int age = 25;的时候,系统在街道某处找了四个连续房间,把25存进去,然后让名字age跟那个门牌号绑定。你写代码时只需要用age,但计算机真正需要的是地址。&age就是“查询门牌号”,*则是“拿着门牌号进屋取东西或放东西”。
#include <stdio.h> int main(void) { int age = 25; int *p = &age; printf("age 的值: %d\n", age); // 25 printf("p 的值: %p\n", (void *)p); // 某个十六进制地址 printf("*p 的值: %d\n", *p); // 25 *p = 30; // 等价于 age = 30 printf("age 的新值: %d\n", age); // 30 return 0; }p保存的是age的地址,所以p是“指针变量”。*p表示“p 所指向的那个 int 变量”,因此*p = 30会直接修改age所在内存里的内容。新手最容易混淆的其实是声明和使用的区别:int *p;里的*是在告诉编译器“p 是指针”,而后面单独写*p是在解引用。我后来有个习惯——把代码里的*p读作“p 所指的那个东西”,很多混淆就自动消失了。
1.2 类型决定解释方式,但不决定指针的大小
有一个问题几乎人人都会问:int *p和char *p,指针变量占的内存一样大吗?在64位系统上,答案是一样大,通常8字节。因为指针变量的内容本质上是一个地址,地址就是整数,大小由系统位数决定。
那类型有什么用?类型决定的是“拿到地址之后怎么解释这块内存”。
| 指针类型 | 解引用读取长度(常见平台) | p + 1 的地址偏移 |
|---|---|---|
char * | 1 字节 | 1 |
short * | 2 字节 | 2 |
int * | 4 字节 | 4 |
double * | 8 字节 | 8 |
struct Node * | sizeof(struct Node) | sizeof(struct Node) |
int *p; p + 1不是简单地“地址+1”,而是让地址跳过一个 int 的长度,也就是4字节。很多人觉得指针的加减运算神出鬼没,根子全在这个“步长”上。写代码时如果发现指针加1后地址没有按预期跳,多半是类型写错了,或者用%p打印地址时没有转成void*。
1.3 打印地址的正确姿势
打印指针值要用%p,并且建议显式转成void *:
printf("addr = %p\n", (void *)p);%p匹配void*,虽然不转在很多编译器上也能过,但严格按标准来可以避免不必要的warning。还有一个细节:不要试图用printf打印“指针指向的地址的十进制值”来做所谓的“验证”,除非你在做底层调试,否则毫无必要,还容易引入未定义行为。
2. 数组与指针:别再说“数组名就是指针”了
2.1 数组名会退化,但它不是指针变量
网上有句话传播很广:“数组名就是指针。”这句话害了不少人。严格说,数组名在多数表达式中会“退化”成首元素的地址,所以下面的代码能编译通过:
int a[5] = {10, 20, 30, 40, 50}; int *p = a; // a 在这里退化为 &a[0]但数组名本身不是指针变量。它不能出现在赋值运算符左侧,比如a++、a = p都是非法的。区分两者的最简单方式是sizeof:
printf("%zu\n", sizeof(a)); // 整个数组大小,例如 20 printf("%zu\n", sizeof(p)); // 指针变量大小,例如 8a是一个包含5个int的数组,p只是保存地址的变量。它们的区别在C语言面试里几乎必考,理解这一点,后面看二维数组和函数参数才不会乱。
2.2 指针算术:p[i] 就是 *(p+i)
数组下标p[i],编译器最终会理解成*(p + i)。这个等价关系是理解数组和指针的钥匙。
int a[5] = {10, 20, 30, 40, 50}; int *p = a; printf("%d\n", *p); // 10 printf("%d\n", *(p + 2)); // 30 printf("%d\n", p[2]); // 30 printf("%d\n", 2[p]); // 30,合法但别这么写2[p]能跑是因为2[p]会被解析为*(2 + p),而*(2+p)和*(p+2)完全一样。知道有这回事就行,实际项目里没人这么写,但看见别人写了要能看懂。
数组作为函数参数时,形参写int arr[]和int *arr完全等价,因为数组在传参时退化成指针了。也正因如此,函数内部用sizeof(arr)拿到的是指针大小,不是数组大小。这就是为什么C语言里传数组必须额外传长度:
int sum(int *arr, int n) { int total = 0; for (int i = 0; i < n; i++) { total += *(arr + i); // 等价于 arr[i] } return total; }2.3 二维数组为什么不能直接当 int** 传
很多初学者刷题时写过类似代码:二维数组传进函数,形参写成int **arr,结果编译警告或者运行崩溃。原因在内存布局。int matrix[3][4]在内存里是一段连续12个int,而int**描述的是“指向指针的指针”,内存布局完全不同。
正确的写法有两种,本质是一样的:
void print_matrix(int m[][4], int rows) { ... } // 或 void print_matrix(int (*m)[4], int rows) { ... }这里int (*m)[4]是“指向一个含4个int数组的指针”,也就是数组指针。小括号是关键:int *m[4]是“含4个int指针的数组”,也就是指针数组,俩完全不是一回事。我一直用一个口诀:[]优先级比*高,所以int *m[4]是数组,用括号把*m包起来才是指针。
#include <stdio.h> void print_matrix(int (*m)[4], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 4; j++) { printf("%d ", m[i][j]); } putchar('\n'); } } int main(void) { int matrix[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; print_matrix(matrix, 3); return 0; }print_matrix(matrix, 3)调用时,matrix退化成指向第一行的指针,也就是int (*)[4]。记住这个“退化”方向,就不会再疑惑为什么形参写成int**不对了。
3. 字符串和指针:搞清楚可改和不可改
3.1 字符数组和字符指针:一个想改就崩,一个碰巧能跑
C语言里没有独立的字符串类型,字符串是用字符数组或字符指针表示的。下面两行长得像,实际天差地别:
char s1[] = "hello"; // 栈上分配6字节,内容从常量区拷贝过来,可修改 char *s2 = "hello"; // s2保存字符串字面量的首地址,内容在只读段s1[0] = 'H'安全,改的是自己栈上的数组。s2[0] = 'H'是未定义行为,在Linux上多半直接段错误,在Windows上可能“碰巧能跑”,然后留给你一个后半夜才能查出来的bug。为什么?字符串字面量"hello"在程序加载时被放在只读区域,任何写入都会触发保护机制。
我的习惯是:只要不打算改字符串内容,统一写成const char *s = "hello";。这样万一后面有人试图改它,编译器直接报错,把运行时问题提前到编译期。想要能改的字符串,就用char s[]。
3.2 字符串逆序:双指针移动的思路
字符串逆序是很多练习平台(比如PTA)的经典题。用指针写比用下标写更能展示“指针操作内存”的含义:
#include <stdio.h> #include <string.h> void reverse(char *s) { char *left = s; char *right = s + strlen(s) - 1; while (left < right) { char tmp = *left; *left = *right; *right = tmp; left++; right--; } } int main(void) { char str[] = "hello"; reverse(str); printf("%s\n", str); // olleh return 0; }left从头往后,right从尾往前,两者都通过指针运算移动。注意调用strlen(s)的前提是s指向以\0结尾的字符串,如果传进去的是不可写内存还是会出问题,所以调用reverse前要保证参数是char[]这类可写数组。
3.3 指针数组存放字符串:排序只需要换指针
处理多个字符串时,有人会下意识用char words[3][16]这种二维数组。好处是内存连续,坏处是浪费空间、长度写死,排序时交换两个字符串要strcpy,成本高。更常见也更推荐的是指针数组:
#include <stdio.h> #include <string.h> int main(void) { char *words[] = {"pear", "apple", "orange"}; int n = sizeof(words) / sizeof(words[0]); for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (strcmp(words[j], words[j + 1]) > 0) { char *tmp = words[j]; words[j] = words[j + 1]; words[j + 1] = tmp; } } } for (int i = 0; i < n; i++) { printf("%s\n", words[i]); // apple orange pear } return 0; }words是一个数组,里面每个元素是char *。排序时交换的是“指针指向的地址”,字符串本身在原地没动,所以效率很高。但如果想用scanf("%s", words[i])往里面读字符串,问题就来了——指针数组里的指针还没有指向任何可写的内存,必须先malloc或者改成二维数组。这算是我见过最频繁的崩溃原因之一。
4. 函数、参数和函数指针:指针不只是用来传地址
4.1 值传递:为什么swap必须传指针
C语言函数传参一律是“拷贝”。写一个交换函数,如果不传指针,等于白写:
void swap_wrong(int a, int b) { int tmp = a; a = b; b = tmp; } void swap_right(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; }swap_wrong里交换的是形参的副本,调用结束后什么都没改变。swap_right虽然也是值传递,但它拷贝的是两个地址。通过地址能定位到调用者的变量,于是交换成功。
理解这一步的关键是:指针本身也是值,只不过这个值是一个地址。要修改一个int,传int *;要修改一个int *,就得传int **。这个推导关系到二级指针时特别有用。
4.2 函数指针:把函数当成一种数据
函数不是变量,但函数有地址,也就是入口地址。函数名在表达式中也会退化成函数指针。所以可以写:
#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) = add; printf("add: %d\n", op(3, 4)); // 7 op = sub; printf("sub: %d\n", op(3, 4)); // -1 return 0; }声明int (*op)(int, int)的括号一定不能丢。int *op(int, int)会被解析成“返回int*的函数”,int (*op)(int, int)才是“指向函数的指针”。函数指针最常见的用途是回调,标准库qsort最后那个参数就是比较函数指针。我曾经写过一个基于qsort的字符串排序:
#include <stdlib.h> #include <string.h> int cmp_str(const void *a, const void *b) { return strcmp(*(const char **)a, *(const char **)b); } // 调用 qsort(words, n, sizeof(char *), cmp_str);qsort不知道你排的是int还是char *,它只拿到元素的起始地址和大小,所以比较函数的参数是const void *。在cmp_str里把void *转回char **,再解引用得到char *,也就是字符串本身。这段代码是函数指针、二级指针和指针数组的一次组合拳,建议在本地跑一遍。
4.3 空指针的防御:NULL不是用来“撞”的,是用来“查”的
NULL在C语言里是“不指向任何有效内存”的特殊指针值。很多运行时报错,比如网上常搜的“timer执行查询报空指针”,本质上就是拿了一个NULL去解引用,程序当然崩。
我的防御习惯有三条:
malloc、fopen这类可能失败的函数,返回值立刻判断是否为NULL。- 函数入口对形参做检查:
if (p == NULL) return; - 释放内存后把指针置
NULL:free(p); p = NULL;
为什么释放后要置NULL?因为free只是把内存还给了堆管理器,p里保存的地址还在。这个地址所指的内存可能马上被分配给别人,再解引用就是读或写未知数据。置NULL之后,解引用会立刻崩溃,反而比悄悄出错好排查得多。
5. 结构体指针与二级指针:链表和“改指针”的真相
5.1 结构体指针:使用 -> 的底层逻辑
p->data其实就是(*p).data的简写。先解引用拿到结构体,再取成员。箭头让代码更直观,但心里要清楚它在做什么。
结构体在C语言里的地位很高,因为它是组织复合数据的基础。链表节点就是结构体指针最典型的应用:
#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node; Node *create_node(int data) { Node *node = (Node *)malloc(sizeof(Node)); if (node == NULL) { return NULL; } node->data = data; node->next = NULL; return node; } int main(void) { Node *head = create_node(1); head->next = create_node(2); printf("%d -> %d\n", head->data, head->next->data); return 0; }结构体可能很大,函数传结构体指针比传整个结构体拷贝更高效。链表、二叉树这类动态数据结构更是离不开指针,next字段本身就是一个Node *。写链表的时候,新手最容易问的问题就是“为什么节点里要放一个指向自己类型的指针”,因为这样节点才能串起来,没有指针就没有链条。
5.2 二级指针:只有需要修改指针本身时才用
一级指针用于修改普通变量的值,二级指针用于修改一级指针的值。当函数需要让调用者的指针变量指向一块新内存时,必须传&head:
#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node; void init_list(Node **head) { *head = (Node *)malloc(sizeof(Node)); if (*head == NULL) { return; } (*head)->data = 0; (*head)->next = NULL; } int main(void) { Node *head = NULL; init_list(&head); if (head != NULL) { printf("head->data = %d\n", head->data); } free(head); return 0; }如果函数签名写成void init_list(Node *head),函数内部对head赋值,改的只是形参副本,调用结束后main里的head依然是NULL。这一点和 swap 是同一个道理:要改int用int *,要改int *就用int **。链表的头插法和删除头节点都需要这种能力,因为头指针本身会变。
5.3 指针赋值:分清是改指向还是改值
学习指针时有一个特别基础、特别容易混的操作:
int a = 10, b = 20; int *p = &a; p = &b; // 改指向:p 现在指向 b *p = 99; // 改值:b 变成 99,a 不受影响判断方法很简单:看等号左边是p还是*p。p = ...是在改“门牌号”,让指针指向别处;*p = ...是在改“房间里的东西”。我反复强调这个,是因为发现很多段错误其实是把这两行搞反了,尤其是传递参数时。
另一个经典错误是“未初始化指针直接解引用”:
int *p; *p = 10; // p 没初始化,里面是随机地址,往随机地址写数据,大概率崩溃正确的做法是让指针先指向某个合法变量,或者用malloc分配内存,或者直接置为NULL再判断。三者选其一,具体看场景。
6. 动态内存、文件指针与指针排错
6.1 malloc、realloc 和 free 的正确配合
谈到指针不可能绕开动态内存。malloc返回void *,它是一块“大小已知但类型未知”的内存,需要转换成具体类型的指针。分配的字节数建议用sizeof计算,不要手写数字。
free只释放指针指向的那块内存,不会把指针变量清掉,所以释放后要习惯性置NULL。还有一个更隐蔽的坑是realloc:
#include <stdio.h> #include <stdlib.h> int main(void) { int *p = (int *)malloc(10 * sizeof(int)); if (p == NULL) { return 1; } int *tmp = (int *)realloc(p, 20 * sizeof(int)); if (tmp == NULL) { // 失败:p 仍然指向原有内存,仍然有效 perror("realloc"); } else { p = tmp; } free(p); return 0; }不要写p = realloc(p, 20 * sizeof(int));。如果realloc失败,它会返回NULL,但原来那块内存还活着,你把NULL覆盖给p,原来的地址就丢了,既造成泄漏,后面再想释放也无法释放。
动态内存泄漏在每个malloc都必须对应一个free。长期运行的服务如果泄漏,内存会逐渐上涨直到被系统杀掉。我在本地排查泄漏时常用valgrind,后来也直接用编译器的 AddressSanitizer,效果很好。
6.2 文件指针:FILE* 的读写套路
C语言里文件操作的FILE *也是指针。fopen返回一个文件指针,失败时返回NULL。这跟malloc很像,第一步永远是判空:
#include <stdio.h> int main(void) { FILE *fp = fopen("scores.txt", "r"); if (fp == NULL) { perror("fopen"); return 1; } int score; while (fscanf(fp, "%d", &score) == 1) { printf("score = %d\n", score); } fclose(fp); return 0; }这里有个细节:循环条件用的是fscanf(...) == 1,不是!feof(fp)。feof要等到读操作已经触底之后才会返回真,用它做循环条件经常会多处理一次,这是C语言文件读写里非常经典的低级错误。写入用fprintf(fp, "score = %d\n", score);,模式改为"w"。文件操作结束后记得fclose,并且习惯性把fp置NULL,逻辑和动态内存的悬空指针一模一样。
6.3 指针Bug排查三板斧
指针出问题时,我最常干的三件事,按顺序来:
- 编译时开警告。
gcc -Wall -Wextra -g能把很多未初始化、类型不匹配的问题直接亮出来。 - 运行时开 AddressSanitizer。
gcc -fsanitize=address -g,越界读写、use-after-free、栈溢出这类问题会直接报出文件和行号。 - 小范围加打印。
printf("p=%p *p=%d\n", (void *)p, *p);看指针指向哪里、解引用得到什么,往往三五分钟就能定位。
还有一个多年积累的习惯:在代码里明确注释每个指针的“所有权”。谁malloc谁负责free;函数是“借”指针来用,还是“接管”这块内存。多人协作时很多Bug都源于“都以为对方会释放”。指针不是玄学,它只是地址,真正坑人的是“地址指向哪里、生命周期归谁管”这两件事没想清楚。把这套思路内化之后,你再看链表、二叉树、文件操作和回调,会发现它们都是同一套底层逻辑在不同场景的变体。