
1. 指针不是玄学先弄清楚内存地址这件事1.1 一个变量在内存里的“门牌号”和“房间内容”想象你住在一栋楼里变量名是“住户姓名”变量的值就是“房间里放的东西”而内存地址就是“门牌号”。指针变量这个“特殊住户”它房间里的东西不是普通物品而是另一间房的地址纸条。C语言里每个变量在内存中都占据若干个字节这若干字节的第一个字节编号就是它的地址。int a 42;这段代码会从内存中申请4个字节大多数平台把42存进去同时记录下这4个字节的起始地址。a取地址运算符就是拿来取这个“起始门牌号”的。int *p a;就是说我找了一个新变量p专门用来记a的门牌号。这里最关键的认知是指针变量本身也有自己的地址。p是一个变量它占据的内存大小和平台上普通地址大小一致32位下通常是4字节64位下通常是8字节。你可以对p再取地址得到p类型是int **。指针的指针这个概念后面会专门讲现在先记住指针变量也是变量不是凭空存在的神秘对象。1.2 声明指针时从右往左读能避开九成误解很多人背“星号靠近谁就修饰谁”但实际一个声明里出现多个星号就乱了。我的习惯是从右往左读效果非常好。例如int *p;从右往左读p是一个变量它的类型是int *也就是“指向int的指针”。再看int **pp;从右往左读pp的类型是int **也就是“指向 int * 的指针”。这个规则放在复杂声明里尤其好用。比如int *arr[3];从右往左读arr是一个大小为3的数组每个元素的类型是int *所以这是指针数组数组里存了三个指针。而int (*ptr)[3];因为括号把*ptr圈住需要先把括号里读完ptr是一个指针指针指向一个大小为3的 int 数组这是数组指针。这两者一个“数组里存指针”一个“指针指向数组”差一个字含义天上地下。我见过不少人在写函数参数时把int *a[3]和int (*a)[3]搞混结果编译器报错报得莫名其妙。所以不管你基础如何先把这个“从右往左读”练熟后面遇到复杂声明就不会慌。1.3 亲手验证地址值、类型和指针变量自身的大小光看概念不靠谱我建议你在自己的机器上跑一下这段代码#include stdio.h int main(void) { int a 42; int *p a; printf(a 的值: %d\n, a); printf(a 的地址: %p\n, (void *)a); printf(p 的值: %p\n, (void *)p); printf(p 的地址: %p\n, (void *)p); printf(sizeof(a) %zu\n, sizeof(a)); printf(sizeof(p) %zu\n, sizeof(p)); return 0; }输出时把地址强制转成void *是因为%p只接受void *直接传int *在某些编译器下会有警告。你大概率会看到p的值和a一样但p是另一个完全不同的地址。这里有个新手容易混淆的点sizeof(int *)不是一定等于sizeof(int)。在常见64位Linux/macOS上sizeof(p)是8sizeof(a)是4在32位平台上两者都是4。指针类型决定的是“你通过这个指针读取数据时一次性读几个字节、把取到的字节解释成什么类型”而指针变量本身的体积只跟地址位数有关。把这个实验做一遍你对“指针是一个存地址的变量”会有非常直观的感受后面学数组指针、函数指针时也会轻松很多。2. *、和指针运算这三类操作符的组合别靠背2.1 取地址与解引用一对互逆但不完全对称的操作和*是一对操作符取出变量的地址*根据地址访问对应变量。*a等价于a*p等价于p前提是p已经初始化且有效。但这个“互逆”只停留在表达式层面实际使用时必须分清楚*p 100;是通过指针修改原变量不是修改p里存的地址p a;是修改p里存的地址不碰a。这个区别极其重要我见过太多初学者写交换函数时这样写void swap(int *a, int *b) { int *tmp a; a b; b tmp; }然后在主函数里发现两个变量根本没换。问题就出在a b;只是交换了形参指针的指向没有解引用去交换值。正确写法必须是void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; }变量名带不带星号、赋值语句用的是还是*这两组区别是C语言里最容易犯的错。我自己的习惯是写任何一行涉及指针的代码前先在脑子里问一句“这一行到底在改指针还是在改指针指向的数据”。如果答不上来就把这行拆开写。2.2 指针加1为什么不是内存地址加1对一个int *p执行p 1地址数值实际增加了sizeof(int)字节通常是4。这是因为指针加减运算的单位是“所指类型的大小”而不是1字节。这个设计的根本原因是指针加减法的目的是遍历同类型元素数组。你有一个int arr[5]arr 1应该指向arr[1]如果只加1字节读出来的数据完全是乱的。所以C语言选择让指针运算以类型宽度为步长这是为了和数组下标语义保持一致。数组下标arr[i]在编译器看来就是*(arr i)这是C标准明确规定的等价关系前提是arr在表达式中退化成指针。明白了这一点后面数组部分就顺了。顺便说一个常见判断题char *p执行p 1时地址值加1int *p执行p 1时地址值加4这并不是说char更“慢”或int更“快”只是类型宽度不同。你把指针当成“带类型的地址”这个逻辑就很好记指针知道自己指向的对象占多大空间所以走一步就跨多大。2.3 最容易算错的 *p 与 (*p)自增自减和指针组合在一起优先级是很多初学者的噩梦。先记住结论后缀优先级高于*前缀和*优先级相同结合方向从右往左。所以*p等价于*(p)先取p指向位置的值然后p自增到下一个元素。这个操作在遍历数组时非常常用。(*p)先取p指向位置的值然后把这个值自增。*p等价于*(p)先把p自增再取新位置的值。*p等价于(*p)把p指向的值自增。这四个组合看起来差不多实际行为完全不一样。我建议不要死背优先级而是用括号明确表达意图。很多静态检查工具会禁止裸写*p要求你写成*(p)就是为了可读性。但在读老代码时看到*p要能立刻反应过来。学习的时候可以自己写一小段测试程序分别打印int a[] {10, 20, 30}、int *p a在这四种写法执行后的结果观察p指向哪里、a[0]变成了多少。亲手试一遍比背十遍优先级有效。3. 指针和数组的纠缠数组名、下标与指针移动3.1 数组名到底是不是指针大多数时候是但不全是int arr[5];中的arr在绝大多数表达式中会隐式转换为指向首元素的指针类型是int *。但有两个例外sizeof(arr)得到整个数组的字节数而不是8arr得到的是“指向整个数组的指针”类型是int (*)[5]不是int *。这个细微差别让不少人踩坑printf(%p %p\n, (void *)arr, (void *)arr); // 打印的地址值可能一样地址值可能一样但类型不同。arr 1向后移动一个int而arr 1会直接越过整个5个int数组相当于从数组末尾再往后跳了一个数组那么大的距离。所以你要是写出int *p arr;编译器直接报类型不匹配正确写法是int (*p)[5] arr;。实践中我们更多用数组名退化后的指针遍历数组for (int *p arr; p arr n; p)。这种写法比下标更接近机器的工作方式也让编译器更容易生成高效的地址递增指令。但要注意数组作为函数参数传递时void f(int a[])和void f(int *a)完全等价因为参数传递时数组名会退化为指针函数里拿到的只是一个指向首元素的指针不再是“数组”本身。这也是为什么在函数里对数组参数用sizeof(a)得到的是指针大小而不是数组大小。3.2 二维数组的指针玩法用一个5x5鞍点问题做实验热词里有个“利用stdio.h和limits.h解决5x5鞍点问题”这其实是个很好的二维数组指针练习。鞍点定义矩阵中某个元素在它那一行最大同时在它那一列最小。思路是找到每一行的最大值位置检查该位置是不是所在列的最小值。用指针写的话#include stdio.h #define ROWS 5 #define COLS 5 int main(void) { int matrix[ROWS][COLS]; 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 *row matrix[i]; // 指向当前行首元素 int max_col 0; for (int j 1; j COLS; j) if (*(row j) *(row max_col)) max_col j; int value *(row max_col); int is_saddle 1; for (int k 0; k ROWS; k) { if (*(matrix[k] max_col) value) { is_saddle 0; break; } } if (is_saddle) { printf(鞍点: matrix[%d][%d] %d\n, i, max_col, value); return 0; } } printf(不存在鞍点\n); return 0; }这里用matrix[i]取得第i行的首地址*(row j)访问第j列*(matrix[k] max_col)固定列遍历行。注意matrix[k]的类型是int *而matrix本身在表达式中退化成int (*)[COLS]所以千万不要写成*(matrix k max_col)那会变成按整个一行跳步访问的位置完全错乱。如果你不用宏也可以用limits.h里的INT_MAX和INT_MIN做比较初始值但固定5x5时宏更直白。3.3 字符指针与字符串字面量能读不能写char *s hello;这里的s指向一个位于只读区的字符串字面量试图通过s修改内容比如写s[0] H;在大多数平台上是未定义行为可能崩溃。正确的可修改定义应该是char s[] hello;这样会在栈上复制一份字符数组。这个坑非常经典原因在于C语言里字符串字面量的类型是char[N]但C里是const char[N]而C语言放得比较松允许用char *指向它于是写操作就成了“合法声明加运行时爆炸”。所以我的建议是只要不打算修改字符串声明时统一用const char *。这样编译器能帮你挡掉后续的写操作错误。类似的坑还包括char *p abc; p[1] x;看起来像数组越界不是越界问题是只读区写入问题本质上是生命周期和存储位置的问题。理解字符串字面量存放在哪里比记住“不要改”更重要。3.4 双指针法翻转字符串一个while循环搞定字符串逆序题在不少在线判题平台反复出现比如“字符串逆序c语言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 world; reverse(str); printf(%s\n, str); return 0; }left指向开头right指向结尾每次交换两个位置的字符然后向中间移动。注意结束条件是left right。如果是left ! right遇到偶数长度字符串时两个指针会交错再解引用就出问题。双指针的思路不仅用于字符串链表判环、数组找两数之和也常用本质都是“用两个游标在数据结构上按规律移动”。写逆序时还有一个细节strlen返回的是不含\0的长度所以right要减1否则指向的是结尾的\0交换后字符串就乱了。4. 多一层间接指针的指针、指针数组与二维参数4.1 为什么需要指针的指针在函数里改写外面的指针变量先看一个经典需求写一个函数分配内存并把指针返回给调用方。如果只传一级指针void alloc_bad(int *p, int size) { p malloc(size * sizeof(int)); }调用后函数内部的p被改写但调用方的指针变量没变依然不知道分配出来的地址在哪。这其实就是值传递造成的函数参数是实参的一份拷贝你改这份拷贝并不会影响外面的变量。解决办法是把“指针变量”的地址传进去也就是int **p#include stdio.h #include stdlib.h void alloc_ok(int **p, int size) { *p malloc(size * sizeof(int)); } int main(void) { int *arr NULL; alloc_ok(arr, 10); if (arr) { arr[0] 42; printf(%d\n, arr[0]); free(arr); } return 0; }此时int **p的解读是p是一个指针它指向另一个指针变量那个指针变量指向int。*p malloc(...)的意思是“把malloc返回的地址写入调用方那个指针变量里”。这种模式不是炫技。链表的插入、二叉树的建树、需要修改链表头指针时经常要传二级指针。判断标准很简单如果函数需要修改调用方的那个指针变量本身就得用指针的指针如果只是修改指针指向的内容一级指针就够了。C里用引用确实能少写一级但引用的底层本质上还是指针理解了这个再看C的引用、指针、值传递的对比基本就是一层窗户纸。4.2 指针数组、数组指针一行声明的两种读法我在第1章说过int *arr[3]是指针数组int (*ptr)[3]是数组指针。这两个声明最容易混淆。用例子说话#include stdio.h int main(void) { int a 1, b 2, c 3; int *arr[3] {a, b, c}; // 数组里存了3个 int* for (int i 0; i 3; i) printf(%d , *arr[i]); int matrix[2][3] {{1, 2, 3}, {4, 5, 6}}; int (*ptr)[3] matrix; // ptr指向“长度为3的int数组” printf(%d\n, (*ptr)[1]); // 输出2 ptr; // 跳到下一行 printf(%d\n, (*ptr)[1]); // 输出5 return 0; }这里的关键matrix退化成指向第一行的指针类型是int (*)[3]。ptr移动的是“一整行”因为ptr所指对象的类型是int[3]。这和int *arr按单个元素移动完全不同。指针数组的用途非常广最常见的场景是保存多个字符串const char *fruits[] {apple, banana, cherry};fruits数组里每个元素都是const char *这就是字符串数组在C语言中的常见写法。在线判题平台里经常见到“指针数组存放字符串”的题目实际上考的就是这个声明和遍历方式。不要被名字吓到先从右往左读分清“数组里放指针”和“指针指向数组”后面就不会晕。4.3 main函数参数与字符串列表char *argv[]到底长什么样int main(int argc, char *argv[])里的argv本质上就是指针数组。argv[0]是程序名argv[1]起是命令行参数。它的内存布局是一共argc个连续的char *每个指针指向一个以\0结尾的字符串。面试里经常问char *argv[]和char **argv的区别。实际上在参数声明中char *argv[]会被自动调整为char **argv两者完全等价。所以当你写argv这类操作时它移动的是一个指针元素的大小而不是一个字节。理解了这个再去看那些封装命令行解析的库比如getopt就容易了——它们本质上就是在操作一个char **和一个计数器。很多初学者第一次在主函数里直接用argv[1]时以为它是个字符实际上它是一个字符串的首地址。想打印单个字符要写argv[1][0]想打印整个字符串就是argv[1]。把这道概念理清指针数组的用法基本就通了。5. 函数指针与复杂声明从函数指针数组到菜单程序5.1 函数指针怎么声明、怎么赋值、怎么调用函数不是变量但它也有地址函数名在表达式中会退化为函数地址。函数指针就是把函数地址存起来统一通过它调用从而实现回调机制这是C语言实现“运行时决定调用谁”的关键手段。声明形式int (*handler)(int, int);从右往左读handler是一个指针它指向一个函数这个函数接收两个int参数并返回int。注意必须加括号(*handler)如果写成int *handler(int, int)那就是“一个返回int *的函数”类型完全不一样。顺带一提“函数指针”和“指针函数”是两个概念int *func()是返回指针的函数int (*func)()是函数指针。后者才是指向函数的指针。赋值和调用#include stdio.h int add(int a, int b) { return a b; } int main(void) { int (*handler)(int, int) add; // 函数名add退化为地址 int r1 handler(3, 4); // 直接调用 int r2 (*handler)(3, 4); // 显式解引用后调用效果一样 printf(%d %d\n, r1, r2); return 0; }handler(3, 4)和(*handler)(3, 4)是等价的C标准允许直接通过函数指针调用。老派代码喜欢写(*handler)(3,4)新代码更倾向直接写handler(3,4)可读性更好。只要理解了函数名在表达式里会“退化”成地址这层就通了。5.2 函数指针数组告别一长串switch函数指针数组是函数指针的直接进阶。比如做一个小型计算器与其写一长串switch-case不如定义一个有4个元素的函数指针数组#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; } int divide(int a, int b) { return a / b; } int main(void) { int (*ops[])(int, int) {add, sub, mul, divide}; int a 10, b 2; for (int i 0; i 4; i) { printf(%d\n, ops[i](a, b)); } return 0; }int (*ops[])(int, int)从右往左读ops是数组数组元素是指针每个指针指向一个返回int、接收两个int的函数。在菜单驱动的程序、协议解析、状态机等场景里这种“表驱动”写法能极大地压缩代码。你只需要维护一张函数指针表再加上一个索引或字符串匹配就能代替大量if-else。我在实际项目里用这种方式写过命令解析器用户输入命令字符串查表找到对应的函数指针然后统一调用。新增命令时只需要在表里加一行不需要动业务逻辑扩展性好了很多。5.3 复杂声明读法右左法则实战遇到再复杂的声明都可以用“右左法则”从变量名开始先向右看遇到)再向左看每层括号剥开直到读完。举例void (*signal(int sig, void (*func)(int)))(int);不要慌。signal是一个函数名它有两个参数int sig和void (*func)(int)signal的返回值是一个void (*)(int)也就是指向“接收int返回void”函数的指针。这就是标准库signal函数的声明。实际阅读时可以借助typedef拆解typedef void (*handler_t)(int); handler_t signal(int sig, handler_t func);可读性好了很多。我的建议是业务代码里少写这种“天书声明”能用typedef就别硬扛。但阅读老代码、系统头文件时右左法则能救命。C语言里有一类题专门考复杂声明比如“解释以下声明的含义”你只要把右左法则练熟把signal当成普通函数名一层层剥开就算第一次见也能读明白。6. 指针与内存管理野指针、悬垂指针和缓冲区越界6.1 返回局部变量地址是最常见的悬垂指针来源初始化和生命周期问题是C语言指针翻车概率最高的地方。请看int *bad(void) { int x 42; return x; }x是栈上的局部变量函数返回后栈帧被回收x指向的内存已经“不属于你”了。但往往它不会立刻被覆盖于是你第一个打印可能还是42第二个打印可能就变成垃圾值这种“时好时坏”的bug最坑。正确做法是让数据生命周期跨越函数调用要么用static int x要么用malloc在堆上分配并由调用方负责释放。选择哪种要看语义需要线程安全、可重入就选堆分配只需要一个固定副本可以用static。很多人在写链表、树这种结构时喜欢在一个函数里创建节点然后返回节点指针结果节点是在栈上创建的返回后整个链表就废了。记住口诀栈上局部变量的地址不能作为返回值要么传指针参数带出来要么用堆内存。6.2 malloc与free之后释放了的指针为什么不能碰free(p)的含义是“把p指向的这块堆内存还给分配器”但它不会把p这个指针变量清空也不保证内存内容立刻被改写。于是p就成了悬垂指针。如果后续不小心再*p 1可能踩到已经被分配器复用的内存破坏别的数据而且不一定当场崩溃。业界通行做法是立刻把指针置为NULLfree(p); p NULL;这样后续哪怕误用了也大概率是空指针崩溃至少能快速定位要是加上if (p)判断也能挡住一次非法访问。注意free(NULL)是合法的所以很多清理函数会放心地直接free。另外malloc的返回值必须检查。很多人写int *p malloc(100 * sizeof(int));后直接p[0] 1;如果内存不足返回NULL这就是空指针解引用。虽然现代系统很少malloc失败但在嵌入式、长时间运行的服务里不可不防。如果你从C转到C会发现unique_ptr、shared_ptr这些智能指针本质上就是用RAII帮你自动做free这件事但底层的野指针、悬垂指针规则完全一样不理解裸指针就用不好智能指针。6.3 NULL、空指针和无效指针三者的边界有个容易绕晕的名词空指针。C标准里说“空指针”是一个不指向任何有效对象的指针用NULL表示代码里if (p NULL)就是判断它。还有一个“空指针常量”指值为0的整型常量表达式比如0、(void *)0。而“无效指针”是指一个地址值本身不是合法可访问对象比如未初始化的局部指针、已经free的悬垂指针、越界的野指针。它们的共性是解引用都是未定义行为但表现可能完全不同NULL通常在大多数平台解引用会立即触发段错误好排查。悬垂指针可能“看起来挺好”最难排查。未初始化指针的值是随机垃圾可能碰巧指向了可写区域那就更危险。所以我的建议就一条所有指针定义时都初始化要么指向明确对象要么置NULL。不要定义一个裸的int *p;然后等后面再赋值因为中间一旦有分支提前跳转p就是个未知值。这条习惯能挡住大量bug。类似地在使用野指针时先问自己这个指针指向的对象是否还活着对象的作用域够不够长特别是在函数之间传递指针时一定要想清楚“所有权”在哪里。6.4 文件指针和缓冲区C库函数里被忽略的指针细节FILE *fp fopen(...)里的FILE *也是指针但它是一个不透明结构体指针。很多人只把它当“句柄”用却忽略了一个事标准库内部有用户态缓冲区所有读写先经过缓冲区再由内核真正I/O。常见坑点是往文件写数据后不fclose或fflush直接_exit(0)或程序崩溃缓冲区数据没进磁盘。setbuf(fp, NULL)关掉缓冲后每次写都会触发系统调用性能明显下降但换来的是实时可观测。用fgets和fscanf混读同一文件时残留的换行符会把后续读取搞乱本质是因为fscanf不会消费行尾的\n。这些和指针的关系在于缓冲区本身通常就是一块char数组标准库内部用指针维护读写位置如果你自己实现一个行读取函数很容易就碰上char *指针要跨过缓冲区边界的问题。比如fgets读到一半换行符指针停在缓冲区中间下一次读取从哪里继续决定了你要不要清空残留字符。理解“文件指针背后还有读写位置指针”这个模型调试文件读写就会顺畅很多。7. 实战排查指针相关段错误的完整定位过程7.1 一次段错误的排查链路从崩溃到根因假设你写了个链表头插函数运行后一执行到new_node-next *head;就崩。不要急着怀疑编译器按下面链路排查看崩溃信息里有没有具体行号用gdb直接bt看调用栈。打印可疑指针的值是0x0还是类似0x7f...的合法堆栈地址还是0xdeadbeef这种被写坏的值。检查是否访问了已free的内存此时用valgrind或AddressSanitizer比肉眼可靠得多。检查是否数组越界char buf[10]; strcpy(buf, hello world);是经典翻车。大部分段错误归结起来只有几类空指针解引用、野指针未初始化、悬垂指针释放后使用、越界访问、栈溢出。排查时先分类能省很多时间。我见过不少人遇到段错误就直接从头看代码效率很低。先看崩溃地址再判断它属于哪一类基本能锁定问题区域。比如地址是0x0那多半是空指针地址是小数字比如0x10很可能是结构体指针为NULL然后访问了字段导致偏移地址地址看起来正常但数据错乱那就要怀疑悬垂指针或越界写了。7.2 用gdb和AddressSanitizer把指针问题逼出来我强烈建议每个人学会在编译时开-fsanitizeaddressGCC/Clang比如gcc -g -fsanitizeaddress -o test test.c ./test一旦有非法访问它会直接报出“heap-buffer-overflow”、“use-after-free”等具体类别并附上分配位置和访问位置的调用栈比你自己打印指针值高效得多。gdb里常用的三连gdb ./test (gdb) break main (gdb) run (gdb) print p (gdb) next (gdb) watch *pwatch *p能监视某个地址的内容什么时候被改写这是排查“指针指向的数据突然变了”的利器。我遇到过一个问题结构体指针明明赋值了运行几行后字段变乱码。最后就是watch定位到另一个地方越界写覆盖了这块内存。如果没有工具你只能在每个可能的地方加打印费时费力。有了AddressSanitizer很多指针问题在开发期就能暴露而不是等到上线后偶现。建议在写C语言课程设计、项目作业时一律开着这个选项等程序稳定了再关掉测试性能。7.3 const与指针常量指针和指针常量怎么记这个热词出现频率很高。规则其实只有一句先看const修饰的是谁。const int *pconst修饰int所以通过p不能修改指向的值但p本身可以指向别的地址。这叫“指向常量的指针”也就是很多人嘴里的“常量指针”。int *const pconst修饰p所以p本身不能改但可以通过p修改指向的值。这叫“指针常量”。const int *const p都不能改。记忆方法还是第1章说的从右往左读先看到* const还是const *。在函数参数里const int *arr表示“我只读这个数组不会改你数据”这是良好的接口契约。把const用对能防止一堆低级错误。比如你在一个函数里只想遍历数组写参数const int *arr编译器会在你误写arr[i] 0时报错提前拦住bug。实际写业务代码时能加const的地方就加这不只是风格问题是给未来的自己和同事留了一条安全带。指针相关的坑很多但大部分坑都指向同一个根源没有想清楚指针指向的对象是谁、生命周期有多长、能不能写。想清楚这三点再配合工具排查C语言指针真的没有传说中那么可怕。