
我线下带C语言课这些年指针基本上是课程里被问得最多、也最容易让人打退堂鼓的知识点。前面讲变量、运算符、函数的时候多数人靠背诵和模仿还能撑过去一到指针就露馅了。原因也很简单指针学起来不是语法问题而是思维习惯问题。指针要解决的用一句话讲就是“让程序能够间接地访问内存”。这听起来并不复杂但背后牵扯出地址、类型、生命周期、内存布局一堆概念哪一环断掉都会让代码跑得莫名其妙。这篇“指针(1)”是给已经能写if/for循环、会封装普通函数但还没彻底掌握指针的读者准备的。我会把地址与变量、指针声明与初始化、解引用、算术运算、指针与数组、字符串、函数传参这几块一次讲透再配合调试经验和常见坑位把指针的第一层窗户纸捅破。读完你至少能看清楚指针变量里到底装的是什么为什么有时候要传指针为什么数组名和指针不能简单划等号。1. 指针变量里到底装的什么地址就是内存里的门牌号1.1 从普通变量到指针变量多出的一层间接关系先看普通的变量声明int age 25;编译器会在内存里找一块空间大小够放一个int然后把25这个值放进去。我们平时说“age等于25”其实是在说“age这块内存里的内容是25”。在C语言层面变量名就是一个方便书写的标签你写age编译器就帮你翻译成“去那块内存读数据”。指针变量和普通变量的区别就在于它里面存的不是普通的数据而是另一个变量的地址。如果你把内存想象成一条街道每个房间有门牌号普通变量就是房间里住的人指针变量就是一张写着门牌号的便签。它本身也有一个房间房间里放的不是人而是另一个房间的地址。这种多出来的“间接层”就是理解指针的关键。定义指针变量需要明确两件事这个指针指向什么类型的变量以及它本身叫什么名字。比如int *p;这一行读作“声明一个指向整型变量的指针p”。星号*在这里不是乘法而是“指向...的指针”类型的标记它配合int表示p能够保存一个int变量的地址。1.2 取地址运算符和解引用运算符*一对互逆操作C语言提供了两个最基础也最容易混淆的运算符取地址。用在变量前面得到这个变量在内存中的起始地址。*解引用。用在指针变量前面代表“访问这个地址里存的东西”。写一段代码把这两个操作都跑一遍#include stdio.h int main(void) { int age 25; int *p age; printf(age 的值: %d\n, age); printf(age 的地址: %p\n, (void*)age); printf(p 的取值: %p\n, (void*)p); printf(*p 的值: %d\n, *p); return 0; }运行结果大致是age 的值: 25 age 的地址: 0x7ffe0f2a3c4c p 的取值: 0x7ffe0f2a3c4c *p 的值: 25看清楚那张表p和age不是“恰好相等”而是同一个东西。*p和age也不是“碰巧相同”它们访问的是同一块内存。理解“指针保存地址”之后这一段就再也不神秘了。1.3 指针大小与32位/64位环境初学的人容易把“char指针”当成“更小的指针”这是误区。指针变量的大小只取决于平台能表示的地址范围与它指向什么类型没有关系。在32位系统上地址总线用32位表示指针通常占4字节在64位系统上地址用64位表示指针通常占8字节。环境指针大小寻址能力32位4字节约4GB地址空间64位8字节远超实际物理内存你可以用下面这行确认自己的环境printf(%zu\n, sizeof(int*));实际写代码时不要用int去存指针也尽量不要把指针强转成整数后再转回来。指针需要的是能够完整保存地址的类型C标准给出了intptr_t这类专用类型普通应用场景下做好类型匹配就行。2. 声明指针的时机与方法类型、星号和初始化缺一不可2.1 指针类型为什么不能省略你可能见过这样的声明int *p; float *fp; char *cp;星号前面那个类型不是装饰品它告诉编译器两件事第一通过p读写内存时按int的大小和规则去解释第二后面讲指针算术运算时p1跳几个字节也要靠这个类型决定。如果把不同类型地址塞给彼此编译器通常会给出警告。比如把float*赋给int*这不是简单的“换了个标签”而是让编译器按错误规则解释内存。有些场合确实需要把不同类型指针互转那必须用显式强制类型转换并且你自己得清楚内存里到底存的是什么。2.2 初始化和赋值从指向明确目标开始声明一个指针后最危险的就是立刻使用它。常见的初学写法int *p; *p 100; // 问题很大p指向哪里p没有初始化它的值是不确定的。这块随机地址可能是只读内存、系统保护区、别的变量所在的区域写进去轻则数据错乱重则段错误。先让它指向一个明确存在的变量再去操作它int num 0; int *p; p num; // 让p指向num *p 100; // 现在写的是num printf(%d\n, num); // 输出100这里有个细节p num;改变的是指针的指向*p 100;改变的是所指向对象的值。两者别混淆。这也解释了为什么下一讲学习“指向指针的指针”时必须分清“指针自身的地址”和“指针里存的地址”。2.3 NULL、野指针和悬空指针的早期习惯给指针一个“明确的不存在”状态通常用NULLint *p NULL;NULL在C语言中通常被定义为((void*)0)它是一个谁都不该用的地址。养成“要么指向有效对象要么赋NULL”的习惯会让调试轻松很多。不过在解引用之前一定要判断if (p ! NULL) { *p 1; }还有一种“悬空指针”指的是指针曾经指向一块合法的内存但那块内存已经被释放或离开了作用域指针里的地址变成了非法地址。函数内部定义数组然后返回数组名就是典型的悬空指针来源。这一讲先记下来等学到内存管理时你会频繁遇到它。3. 解引用是读写内存的“钥匙”读、写与改指向3.1 通过指针改写外面变量的值指针最常见的使用场景是在一个函数里修改“外面的”变量。比如我们想在线下课程里演示一个最简单的指针用法#include stdio.h void add_one(int *x) { *x *x 1; } int main(void) { int score 59; add_one(score); printf(score %d\n, score); // 60 return 0; }add_one拿到的不是score的副本而是score的地址。*x *x 1的意思是“去x指向的那个地址把里面的值加1”。于是外面main里的score也被改了。这个思路后面讲swap交换两个数时会再次用到。3.2 *p 10与p x的差别把这两行代码并排看p x; // 让p指向x *p 10; // 把x的值改成10第一行操作的是指针本身第二行操作的是指针指向的目标。很多人把这两个概念混淆之后会写出类似下面的代码int x 1, y 2; int *p x; p y; // 错误y是intp期待int类型的地址 *p y; // 正确把y的值赋给p所指向的变量编译器能帮你拦截“把int赋给int*”这种明显问题但拦不住更隐蔽的逻辑错误。比如你本意是想让p从指向x变成指向y却写成了*p y结果是把y的值写进了xp仍然指向x。调试这种问题最有效的方法就是随时用printf把p和*p分别打出来。3.3 const的组合读保护该放在哪一层const与指针组合起来非常容易绕晕这里一次性说清楚const int *p;指向const int的指针p指向的值不能通过p修改p本身可以改变指向。int * const p;本身是const的指针p一旦初始化就不能再指向别处但可以通过它修改指向的对象。const int * const p;指向的值和指针本身都不能改。代码示例int a 10, b 20; const int *p a; // *p 30; // 编译错误不能通过p修改a p b; // 合法p可以改变指向 int * const q a; q b; // 编译错误q不能再指向别处 *q 99; // 合法通过q修改a本身没问题为什么要有这些修饰因为函数的形参如果只读数据、不改数据写成const int *比写成int *更安全它防止你在函数里手滑改坏调用者的数据也让阅读代码的人一眼明白意图。4. 指针算术运算p1不是单纯加14.1 步长由指向类型决定普通变量的加法是数值计算指针的加法则是“地址偏移”。关键规律p n不是把地址值直接加n而是加n * sizeof(指向的类型)。不同类型的指针同一个1跳跃的字节数完全不同#include stdio.h int main(void) { char *cp (char*)0x1000; int *ip (int*)0x1000; printf(char指针1: %p\n, (void*)(cp 1)); // 0x1001 printf(int指针1: %p\n, (void*)(ip 1)); // 0x1004 (假设int为4字节) return 0; }用生活类比就是你站在一排停着的公交车旁边不同车型长度不同。走“一步”对一个4字节车位来说是4个字节对一个1字节的车位来说就是1个字节。编译器知道你手里的指针指向什么类型所以p 1会跳过“一个元素”的宽度。4.2 指针相减元素个数而不是字节差两个指向同一数组内部的指针相减结果表示它们之间隔了多少个元素int arr[5] {2, 4, 6, 8, 10}; int *p1 arr[0]; int *p2 arr[4]; printf(%ld\n, (long)(p2 - p1)); // 输出4这里的结果是4不是16个字节。指针相减的结果类型是ptrdiff_t打印推荐用%td或者先转成long。反过来想如果地址相差16字节而int占4字节中间正好隔了4个元素。指针之间做大小比较也要求它们指向同一块数组区域才有意义。两个毫无关系的指针比大小结果依赖地址值本身写了容易产生可移植性问题。4.3 越界运算与比较的坑虽然p1很灵活但C语言只保证“指向数组元素或数组末尾下一个位置”的指针运算是合法有效的。如果你把一个指针移到数组范围之外甚至直接解引用它行为就是未定义的。int arr[3] {1, 2, 3}; int *p arr; int *end arr 3; // 合法指向末尾“下一个位置”但不解引用它 while (p end) { printf(%d , *p); p; }这段安全。如果把end改成arr 5p继续走下去读取的就是这块数组之外的内存输出完全不可预测。写底层代码时越界访问是段错误的头号原因后面专门讲调试。5. 指针和数组数组名、下标与函数传参5.1 数组名是地址常量但并非指针变量C语言里数组名在大多数表达式中会退化成“首元素的地址”。所以下面这些写法很常见int arr[4] {1, 2, 3, 4}; int *p arr; // 相当于 int *p arr[0];但数组名不是指针变量。你不能让arr重新指向另一块内存数组名是地址常量不是可以改来改去的变量。arr这种写法是错的而p是合法的。两者本质区别要记住arr对应一块真实存在的内存空间大小由元素个数和元素类型决定。p只是一个保存地址的变量它的大小通常固定为4或8字节。形如arr和arr也不一样arr是首元素地址类型为int*arr是整个数组的地址类型为int(*)[4]。这类指针数组类型能写成代码但不建议初学者从这条线深入容易把数组、指针、多维数组搅成一团留到后面的进阶部分专门讲。5.2 函数形参里的int a[]其实是个指针写函数接收数组时很多人觉得void print(int a[], int n)是把整个数组传进来了其实不是。数组作为函数参数时会被“调整”成指针void print(int a[], int n) { ... } // 等价于 void print(int *a, int n) { ... }所以形参里写int a[]只是写法上的糖背后拿到的还是首元素地址。函数内部根本不知道数组原来有多大这就是为什么必须额外传一个n作为长度。如果你在函数里对形参做sizeof(a)结果是指针的大小不是数组的大小。5.3 用指针下标遍历并完成冒泡排序把以上知识串起来写一个经典冒泡排序的指针版本。很多面试题和练习题都会考察这两种等价写法掌握指针写法能让后面读源码顺畅很多#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 temp *(arr j); *(arr j) *(arr j 1); *(arr j 1) temp; } } } } int main(void) { int data[] {34, 7, 23, 32, 5, 62}; int n sizeof(data) / sizeof(data[0]); bubble_sort(data, n); for (int i 0; i n; i) { printf(%d , data[i]); } putchar(\n); return 0; }这里*(arr j)和arr[j]完全等价。C语言的下标运算本质上就是指针运算a[j]会被翻译成*(a j)。理解了这一点你就明白为什么数组名能作为指针传给函数也明白为什么函数里再写arr[j]完全正常。6. 字符串与字符指针常量区、栈区和堆区的生命周期6.1 char s[]和char *p的存储位置差异字符串在C语言里没有独立的“string”类型通常用字符数组或字符指针表示。两者看起来很像实际行为差异很大char s[] hello; char *p hello;s是一块可写的字符数组里面复制了hello及相关字符你可以改s[0]。p指向的是字符串字面量这个字符串通常放在只读的常量区通过p修改它属于未定义行为多数现代环境下会直接段错误。判断成败的最好方式是动手试#include stdio.h int main(void) { char s[] hello; char *p hello; s[0] H; // 正常 // p[0] H; // 危险可能崩溃 printf(%s\n, s); printf(%s\n, p); return 0; }如果只是读取内容两者都可以用。但心里始终要有数char *p指向的字符串不能随便改特别是以命令行参数或字符串常量形式传入的。6.2 用fgets读入字符串并拆开单词很多练习会要求“按空格把字符串拆开”。使用gets是不推荐的因为它不检查缓冲区长度很容易造成缓冲区溢出使用fgets则安全得多#include stdio.h #include string.h int main(void) { char line[128]; if (fgets(line, sizeof(line), stdin) NULL) { return 1; } // 去掉fgets可能保留的换行符 line[strcspn(line, \n)] \0; char *p line; while (*p) { while (*p || *p \t) { p; } if (*p \0) break; char *start p; while (*p *p ! *p ! \t) { p; } if (*p ! \0) { *p \0; p; } printf(%s\n, start); } return 0; }这段思路是用字符指针在字符串上滑动跳过空白记下单词起点再一直走到空白处把那里改成字符串结束符\0从而把当前单词独立打印出来。注意这里直接修改了line所以line必须是可写字符数组不能是字符串常量指针。6.3 函数返回字符指针的生命周期陷阱如果函数内部定义了一个局部字符数组然后返回它的首地址char *get_string(void) { char buf[64] temp; return buf; // 危险buf是局部数组函数返回后内存已被回收 }函数执行完buf所在的内存不再属于这个函数指针仍然保留着那块地址但里面的内容随时可能被覆盖。正确的做法是调用者传入足够大的缓冲区或者使用动态内存分配之后专门有章节讲malloc/free。这也是为什么很多C语言库函数喜欢让调用者自己准备缓冲区比如fgets(char *s, int size, FILE *stream)。把“谁负责管理内存”这件事说得清清楚楚整个程序才不会到处埋雷。7. 传值还是传址从swap函数看指针的不可替代性7.1 值传递拷贝出来的“替身”C语言函数参数默认是值传递调用函数时实参的值被复制一份给形参。你可以把形参想成实参的“替身”在函数里打替身真身毫发无伤。想验证这点只需写一个很典型的交换函数#include stdio.h void swap_wrong(int a, int b) { int temp a; a b; b temp; } int main(void) { int x 3, y 5; swap_wrong(x, y); printf(x%d y%d\n, x, y); // 还是 3 5 return 0; }这段代码我几乎每轮课都要拿出来讲一次。新手写完就困惑我明明交换了怎么外面没变原因就是swap_wrong里的a,b只是x,y的一份拷贝交换的是拷贝不是原件。7.2 用指针版本交换两个整数想让函数真正“动外面变量的值”就传地址。函数通过指针间接访问调用者的内存这样才能改到真实变量void swap(int *a, int *b) { int temp *a; *a *b; *b temp; } int main(void) { int x 3, y 5; swap(x, y); printf(x%d y%d\n, x, y); // 5 3 return 0; }调用时传x、y就是把门牌号交给函数函数按门牌号找到真正的房间把两个房间里的东西互换。这个过程完美说明“什么时候该用指针”当你希望函数修改调用者的变量时就必须把变量的地址传进去或者干脆返回值重新赋值。很多数据结构操作之所以大量使用指针正是这个原因。7.3 传结构体时传地址比传值更稳更快结构体变量也可以作为参数传递但是结构体可能很大值传递会把整个结构体完整拷贝一份既慢又占栈空间。比较好的做法是传结构体指针struct Student { char name[32]; int score; }; void print_student(const struct Student *s) { if (s NULL) return; printf(%s %d\n, s-name, s-score); }这里用const struct Student *是很好的习惯既能避免拷贝开销又能防止函数内部修改结构体内容。访问结构体成员时使用箭头运算符-本质是(*s).name的简写。把值和地址的选择想清楚写出的函数接口会清晰很多只读且数据小传值数据大或需要修改原数据传地址。这也是从入门迈向进阶的一个分水岭。8. 指针调试经验段错误、空指针和野指针的排查链路8.1 遇到segmentation fault后的第一反应新手看到“Segmentation fault”或者中文环境下的“段错误”第一反应往往是惊慌然后是到处猜测。我的建议是不要猜按链路走。第一步确认崩溃位置。最直接的办法是先用最小的程序复现或者用调试器跑。Linux下用gdbgcc -g -o test test.c gdb ./test run崩溃后gdb会停在出错行输入bt查看调用栈输入print p查看指针变量当前的值。很多时候一眼就能看到p 0x0说明空指针解引用了或者看到一个很奇怪的地址说明是野指针。如果没有gdb先用printf在手稿里怀疑的位置打印指针的值和*p的值缩小范围。不要一次性打印几十行要二分缩小猜前半段还是后半段逐渐锁定。8.2 用printf查看指针值定位问题排查指针问题我习惯同时打印三样东西指针自身的地址、指针保存的目标地址、目标地址上的内容。int *p ...; printf(p 所在地址: %p\n, (void*)p); printf(p 保存地址: %p\n, (void*)p); if (p ! NULL) { printf(*p 的值: %d\n, *p); }这个习惯来自一次真实的排错课有学生代码里大量使用了数组下标突然出现乱码他怀疑是数组越界结果越查越乱。后来我们把所有关键数组名和指针变量全部打印出来发现一个指针在中途被赋了未知值源头是结构体里一个成员没初始化。打印地址信息至少能快速判断“它指向的区域是不是我们以为的那块区域”。8.3 面试里最常见的指针坑题指针相关笔试题翻来覆去就那么几个经典模型我在这里顺手总结一下int array[5] {1, 2, 3, 4, 5}; int *p array 2; printf(%p\n, (void*)p); printf(%p\n, (void*)(p 1)); printf(%d\n, *p); printf(%d\n, *(p - 1));p指向array[2]p1指向array[3]*(p-1)是array[1]。这种题考查的就是指针算术运算的步长理解。再看一个更阴的char *s hello; s[0] H; // 危险以及一个很多公司都爱考的二维数组指针int a[2][3] {{1,2,3},{4,5,6}}; int (*row)[3] a; // row指向包含3个int的一维数组第一层理解a是二维数组名a 1跳过的是一整行。第二层理解row是指向“包含3个int的数组”的指针row[i][j]等价于a[i][j]。这类内容我会在后续的指针进阶里专门展开但现在至少不要被这种题吓住它考的重点依然是“步长由指向类型决定”。写代码这么多年我对指针的态度始终是它该被当成一种需要尊重的工具而不是炫技的资本。每讲指针我都会让学生在本子上把内存里的格子和地址画一遍把p x; *p 10; p y;这几步用箭头画清楚。你画过一遍就比背十遍定义都管用。这一讲先把基础打牢下一讲我们继续深入指针数组、函数指针和二级指针那时候你会发现今天这些扎实的地基能让你走得很稳。