指针这东西我在面试和带新人的场合里见过太多次了语法背得滚瓜烂熟一上机就崩。int *p;谁都会写但一碰到int *arr[10]和int (*p)[10]就开始抓头发指针数组、数组指针、函数指针、指针函数这四个词能从嘴里念出来的人不少能当场写出正确声明的不到一半。更别说野指针、悬空指针、空指针这三兄弟几乎每个 C 语言项目里都能见到它们留下的案发现场——程序跑着跑着突然崩了日志里丢出一句无效指针然后就是漫长的排查。这篇内容我打算把指针从内存模型的底层一直讲到多级指针、const 限定、函数指针、结构体指针和快慢指针的实际用法力求把每一个为什么讲透。不管你是刚学 C 语言、被指针卡在第二章的初学者还是写了几年代码、但对多级指针和顶层底层 const 还有点含糊的开发者都能从这里拿到能直接落地的东西。我会尽量用生活化的比喻配合可编译的代码把抽象概念落到具体的地址和字节上。1. 从内存模型说起指针到底是个什么东西1.1 内存就是一排带门牌的储物柜要理解指针先得把内存这个抽象词变成看得见的东西。你可以把进程的内存想象成一栋超长的宿舍楼楼里排着无数个一字节大小的储物柜每个柜子都有唯一的门牌号这个门牌号就是内存地址。CPU 要读写数据靠的不是变量名变量名只是给编译器看的编译之后全变成了门牌号。你写int a 10;编译器做的事就是找四个连续的空柜子因为int通常占 4 字节把这四个柜子当成一个整体贴上a的标签然后把 10 按小端序塞进去。而指针变量本质上就是一个专门用来存门牌号的柜子。它自己也有门牌号但柜子里放的不是数据本身而是另一个柜子的地址。int *p a;这句代码翻译过来就是找一块能放下一个地址的空间给它贴个p的标签然后把a的起始门牌号写进去。这就是为什么指针变量的大小和类型无关——char *、int *、double *在 64 位机器上全都是 8 字节因为它们存的都是同一栋楼里的门牌号门牌号的位数只跟楼的规模地址总线宽度有关跟你指向什么类型的房间无关。这个类比能解释很多初学者的困惑。比如为什么*p能取到值*这个操作符的意思是拿着p里存的地址去那个柜子门口按p的类型决定打开几个柜子把里面的东西读出来。p是int *那就打开 4 个柜子p是char *就只打开 1 个。1.2 指针变量的声明、取地址与解引用声明指针的语法有个流传很广的坑int *a, b;里只有a是指针b是普通int。原因是星号在语法上是跟变量名结合的不是跟类型结合的。所以比较稳妥的写法是每个变量单独一行或者写成int *a, *b;。我个人更推荐int* a;这种把星号贴类型的形式虽然老派 C 程序员看了会皱眉但它能避免一个星号管俩变量的误读团队里统一就好。指针的两个基本操作是取地址和解引用*。a拿到a的地址类型是int **p拿到p指向的那个int。这两个操作互为逆运算*a等价于a*p等价于p前提是p有效。但有个细节经常被忽略不能作用于位域和寄存器变量因为它们没有可寻址的内存位置*不能作用于非指针类型*10是编译错误。实操中有个常见误区是把解引用和乘法搞混。编译器靠上下文区分a * b是乘法*p是一元解引用。真正容易出错的是*p这种组合。它等价于*(p)因为后置优先级高于*先把p指向下一个元素再解引用旧地址——注意是旧地址后置自增返回的是自增前的值。如果想先取值再移动指针写(*p)如果想移动指针再取值写*p。这几个表达式我建议你不要在真实项目里炫技拆成两行写清楚可读性比省一行重要得多。1.3 指针的类型为什么不能随便写既然指针存的都是地址那为什么还要区分类型为什么int *p不能直接赋给double *q答案有两层。第一层是解引用时的字节数int *解引用读 4 字节double *读 8 字节用错类型读出来的就是一团垃圾甚至可能因为跨页访问直接段错误。第二层是指针算术的步长p 1不是地址加 1而是地址加sizeof(*p)。int *p加 1 跳 4 字节char *p加 1 跳 1 字节struct Student *p加 1 跳一整个结构体的大小。这是指针最反直觉、也最实用的性质之一。我见过有人写void *p arr; ((char *)p) 4想跳到数组第 2 个 int结果是对的——因为他手动按字节算的。但如果是((int *)p) 1那就是编译器帮你按 int 步长跳。两种写法都能到同一个位置但后者更安全因为你不需要心算sizeof。所以当你需要按字节粒度操作内存时比如实现memcpy、序列化才用char *或unsigned char *其他场景老老实实用正确类型。2. 指针与数组那层说不清道不明的关系2.1 数组名到底是不是指针数组名就是指针这句话对一半错一半坑了不少人。准确的说法是数组名在大多数表达式里会退化成指向首元素的指针但在三个场景下它保持数组的身份不变。第一是sizeof(arr)得到的整个数组的字节数第二是arr类型是数组指针而不是元素指针第三是字符串字面量初始化数组时比如char s[] hello。这个区别用代码看最清楚#include stdio.h int main(void) { int arr[5] {10, 20, 30, 40, 50}; printf(%zu\n, sizeof(arr)); // 20整个数组 printf(%zu\n, sizeof(arr 0)); // 8退化后是指针64位 printf(%p\n, (void *)arr); // 首元素地址 printf(%p\n, (void *)arr); // 数组地址数值相同 printf(%p\n, (void *)(arr 1));// 跳过整个数组20 字节 printf(%p\n, (void *)(arr 1)); // 跳过 1 个 int4 字节 }arr和arr打印出来的数值是一样的因为数组的起始地址就是首元素的地址。但1之后差别就出来了arr 1走 4 字节arr 1走 20 字节。这就是数值相同、类型不同的典型例子。理解了这一点arr[i]、*(arr i)、*(i arr)、甚至i[arr]为什么都等价就顺理成章了——下标运算本质就是指针加偏移再解引用加法的交换律在这里依然成立。2.2 指针数组和数组指针一个口诀分清这两个词是中文语序的经典陷阱。指针数组重点在数组它是一个数组元素是指针数组指针重点在指针它是一个指针指向一个数组。分辨声明看括号int *p1[10]; // 指针数组10 个 int*每个元素是个指针 int (*p2)[10]; // 数组指针1 个指针指向含 10 个 int 的数组口诀是谁在后面谁是整体有括号先看括号。p1先跟[]结合所以是数组数组元素是int *p2被括号圈起来先跟*结合所以是指针再指向int[10]。它们的用法区别很实际。指针数组常用来管理一堆字符串或一组不定长的数据const char *names[] {Alice, Bob, Carol}; for (int i 0; i 3; i) { printf(%s\n, names[i]); }数组指针则多用于二维数组传参。二维数组int m[3][4]退化后是int (*)[4]也就是指向含 4 个 int 的数组的指针。如果你函数参数写成int **编译器会报类型不兼容因为int **和int (*)[4]完全是两种东西。正确写法是void print(int (*m)[4], int rows) { for (int i 0; i rows; i) for (int j 0; j 4; j) printf(%d , m[i][j]); }这里m[i]先取到第 i 行一个int[4]再[j]取列两步走的逻辑和内存里连续排布的方式完全吻合。2.3 指针数组存放字符串的正确姿势用指针数组存字符串写法简洁但有个只读陷阱必须知道char *strs[] {hello, world}; // 指向字符串字面量 strs[0][0] H; // 运行时崩溃写入只读段原因是字符串字面量存在进程的只读数据段里strs[0]指向的是那块只读内存写它就会触发段错误。如果你确实需要修改就得用二维数组把数据复制到可写的栈上char strs[][10] {hello, world}; strs[0][0] H; // 合法改的是栈上的副本另一种常见需求是把字符串按某种规则排序。用指针数组配合qsort就很自然因为交换的是指针而不是字符串本身代价低#include stdio.h #include string.h #include stdlib.h int cmp(const void *a, const void *b) { return strcmp(*(const char **)a, *(const char **)b); } int main(void) { const char *strs[] {banana, apple, cherry}; size_t n sizeof(strs) / sizeof(strs[0]); qsort(strs, n, sizeof(strs[0]), cmp); for (size_t i 0; i n; i) printf(%s\n, strs[i]); }注意比较函数里的*(const char **)a——qsort传给比较函数的是指向待比较元素的指针而元素类型是const char *所以参数是const char **得先转类型再解引用。这一步多绕了一层也是新手最容易写错的地方。3. 多级指针与顶层、底层 const进阶必备3.1 指针的指针什么时候真的需要它很多人学完int **pp之后除了课本例子再没见过它的实际用途。其实二级指针出现的场景很集中核心就一句话当你需要修改一个指针变量本身的时候就得把它的地址传出去。最典型的是动态二维数组的分配int **alloc_matrix(int rows, int cols) { int **m malloc(rows * sizeof(int *)); for (int i 0; i rows; i) m[i] malloc(cols * sizeof(int)); return m; }m是int **它指向一个指针数组每个元素指向一行数据。这里m[i]是一个int *m[i][j]就是具体的 int。另一个高频场景是传出参数。比如在链表头插时需要修改头指针本身typedef struct Node { int val; struct Node *next; } Node; void push_front(Node **head, int val) { Node *n malloc(sizeof(Node)); n-val val; n-next *head; // 解引用拿到原来的头 *head n; // 改的是调用方的头指针 }如果不传Node **而传Node *函数内修改的只是形参副本调用方的头指针纹丝不动——这是 C 语言里最经典的值传递坑。判断要不要用二级指针就看函数是否需要改变指针的指向而不是改变指针指向的内容。3.2 顶层 const 和底层 const 到底怎么分const修饰指针时位置不同含义完全不同const int *p1; // 底层 const指向的值不能改指针本身可以改 int *const p2 a; // 顶层 const指针本身不能改指向的值可以改 const int *const p3 a; // 两个都不能改判断技巧是从右往左读。const int *p1读作p1 是一个指针指向 const int所以*p1不可写int *const p2读作p2 是一个 const 指针指向 int所以p2不可写。这里p1的 const 作用在指向的对象上叫底层 constp2的 const 作用在指针自己身上叫顶层 const。3.3 顶层和底层 const 能不能相互赋值这是 C 面试里的高频题C 里也适用同样的规则。核心原则一句话顶层 const 在赋值时会被忽略底层 const 必须能够对应上而且只允许加 const不允许去 const。int a 1; const int b 2; int *p1 a; const int *p2 p1; // 合法int* 转 const int*加了底层 const // int *p3 p2; // 非法去掉了底层 const int *const p4 p1; // 合法顶层 const 可以忽略 p1 p4; // 合法把 p4 的值赋给 p1顶层 const 被忽略规则背后的逻辑是权限只能收紧不能放开。int *允许写转成const int *之后写权限被收紧了这很安全反过来把一个只读的指针赋给可写指针就绕过了 const 保护所以编译器必须拦住。还有个例外要记住const int *p虽然不能通过p写入但如果原始对象本身不是 const比如a你完全可以用另一个非 const 指针int *q a去改它*p的值也跟着变。const 保护的是这条访问路径不是这块内存本身。4. 函数指针与指针函数名字像命运完全不同4.1 指针函数返回指针的函数指针函数的重心在函数它是一个函数只是返回值是个指针int *find_max(int *arr, int n) { int *m arr; for (int i 1; i n; i) if (arr[i] *m) m arr[i]; return m; }这里返回的指针指向数组内部的元素是安全的因为数组的生命周期比返回值长。但如果你返回的是局部变量的地址就是典型的悬空指针int *bad(void) { int x 10; return x; // 危险函数返回后栈帧失效 }x所在的栈内存函数一返回就被回收了返回的地址虽然还能打印但内容随时可能被后续函数调用覆盖。要返回新数据就用malloc在堆上分配或者让调用方传缓冲区进来把所有权关系写进函数注释里——这一点比写对代码更重要团队协作里最容易出问题的就是这块内存该谁释放。4.2 函数指针指向函数的指针函数指针的重心在指针它指向一个函数可以像变量一样被传递、赋值、放进数组int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int (*op)(int, int) add; // 函数指针 printf(%d\n, op(3, 4)); // 7 op sub; printf(%d\n, op(3, 4)); // -1声明函数指针时参数类型必须完全匹配返回类型也要一致。int (*op)(int, int)里括号不能省省了就变成返回int *的函数声明——又是那个优先级问题。用typedef能大幅提高可读性typedef int (*BinOp)(int, int); BinOp table[] {add, sub};4.3 回调函数与函数指针数组的实战价值函数指针最大的价值在于回调和策略模式。C 标准库的qsort、bsearch都是靠函数指针把比较逻辑交给调用方事件驱动的框架里注册回调本质上就是把函数地址存起来等事件发生时调用。一个实用的例子是简易计算器分发表#include stdio.h #include string.h typedef int (*BinOp)(int, int); int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } typedef struct { const char *name; BinOp fn; } Entry; int main(void) { Entry table[] {{add, add}, {sub, sub}}; const char *cmd add; int a 10, b 5; for (size_t i 0; i sizeof(table) / sizeof(table[0]); i) { if (strcmp(table[i].name, cmd) 0) { printf(%d\n, table[i].fn(a, b)); break; } } }这种写法把命令名到实现的映射数据化加新命令只要往表里加一行不用改switch分支这正是函数指针在工程上的意义。踩过的坑是函数指针比较不能直接用判断功能相同只能判断是否指向同一个函数另外函数指针占用的字节数和普通指针一样可以安全地放进void *标准保证函数指针和对象指针互转是常见扩展但严格来说不是所有平台都保证跨平台代码里谨慎点。5. 结构体指针与内存布局5.1 箭头操作符的来历结构体指针访问成员有两种写法(*p).name和p-name后者是前者的语法糖。-的存在纯粹是因为*p.name会被解析成*(p.name)因为.优先级高于*所以必须加括号。为了少打两个字符C 引入了-。结构体指针做参数几乎是 C 工程里的默认选择原因是避免结构体拷贝。一个几百字节的结构体按值传递每次调用都要在栈上复制一份开销不小传指针只复制 8 字节。更关键的是传指针才能让函数修改调用方的结构体内容。typedef struct { char name[32]; int age; } Person; void birthday(Person *p) { p-age 1; // 改的是调用方的对象 }这里必须用Person *用Person的话p.age 1只改局部副本。这是新手面试最常翻车的地方之一。5.2 结构体指针与内存对齐结构体指针做加减或者用memcpy批量拷贝时对齐是绕不开的话题。编译器会根据成员类型插入填充字节让每个成员的地址满足对齐要求。比如struct A { char c; int i; char d; }; printf(%zu\n, sizeof(struct A)); // 常见输出 12不是 6布局是c(1) 填充(3) i(4) d(1) 尾部填充(3) 12。调整成员顺序可以省空间struct B { int i; char c; char d; }; printf(%zu\n, sizeof(struct B)); // 8规律是把大的成员往前放小的往后放能减少填充。但要注意#pragma pack之类的强制对齐虽然省内存却可能让某些平台访问未对齐地址时报错或变慢网络协议解析里常用通用业务代码里尽量不要动它。用结构体指针遍历数组时p 1跳的是一个完整结构体的大小含填充这一点必须心里有数否则手算偏移一定出错。5.3 链表、树结构体指针的主场所有动态数据结构都建立在结构体指针之上。单链表节点里那个struct Node *next就是自己指向自己同类型的自引用结构这也是为什么声明时必须有 struct 标签不能只用 typedef 名字typedef struct Node { int val; struct Node *next; // 这里必须写 struct Node } Node;遍历链表是结构体指针最基础的操作写法上有两种风格for (Node *cur head; cur ! NULL; cur cur-next) { /* ... */ }用cur ! NULL而不是cur-next ! NULL作为循环条件能让循环体里安全地访问cur不用在每处都判空。链表题里找中点判环用的快慢指针本质上也是结构体指针的两种移动速度下一节会展开。6. 万能指针、常见崩溃与快慢指针6.1 void* 的两个使用禁忌void *被叫做万能指针因为它能接收任何对象指针而不需要强制转换malloc的返回值就是void *。但它有两条硬性限制不能解引用不能做指针算术。*p在p是void *时编译不过因为编译器不知道要读几个字节p 1也一样步长无从确定。要用它必须先转成具体类型void *raw malloc(10 * sizeof(int)); int *arr (int *)raw; arr[0] 1; free(raw);C 里malloc的返回值可以不写强转C 必须写但我建议写上一是能在 C 编译器下复用二是明确告诉读者这块内存将来当什么用。另外把函数指针塞进void *是很多平台的扩展而非标准保证跨平台库里的回调注册一般会单独定义函数指针类型而不是图省事用void *。6.2 空指针、野指针、悬空指针的排查手册这三兄弟是 C 程序崩溃的主要来源先把它们区分清楚类型成因典型表现处理方式空指针指针值为 NULL 时被解引用段错误/空引用异常使用前判空函数入口加断言野指针声明后未初始化值随机随机崩溃难以复现声明即初始化为 NULL悬空指针指向的内存已释放或已出作用域数据被覆盖后出错free 后立即置 NULLtimer 执行查询时报空指针这类日志排查思路其实很固定先定位是哪个指针为空。日志里通常会带调用栈如果没有就在可疑的函数入口加判空和打印。我的习惯是给所有对外函数的指针参数加assert(p ! NULL)发布版本关掉断言但保留关键路径的判空返回错误码。这样开发期能第一时间炸在源头而不是等到几百行之后才崩。还有一种看起来正常的悬空指针free(p)之后忘了置NULL后面又if (p)判了一次非空于是放心地用了——这块内存可能已经被别的malloc复用数据全是乱的。所以我的规矩是free 和置空必须写成相邻两行中间不插入任何逻辑free(p); p NULL;至于某些工具报的无效指针错误比如在配置文件或工程属性页初始化时抛出无效指针绝大多数情况是传入了一个未初始化或已释放的对象句柄。处理办法是先确认对象的生命周期确保它在使用期间一直有效再检查是否在跨模块传递时被提前释放。6.3 快慢指针数组原地去重的通用解法快慢指针不止用在链表有序数组原地去重也是它的经典战场。核心思想是让slow指向已处理区间的最后一个有效元素fast负责扫描新元素遇到不等于nums[slow]的就把它挪到slow 1int removeDuplicates(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; }时间复杂度 O(n)空间 O(1)全程只用了两个整型下标换成指针也一样。注意这里的指针在 C 里可以直接用下标实现本质没变slow和fast就是两个游标。用法上有两个细节容易错一是数组为空时提前返回 0否则nums[slow]会越界二是返回值是slow 1因为slow是下标不是长度。这套模板稍微改一改还能解决移除指定元素移动零到末尾等一系列原地操作题。7. 智能指针的思路与指针使用的经验清单7.1 从裸指针到智能指针问题意识C 里没有智能指针但 C 的unique_ptr、shared_ptr、weak_ptr值得 C 程序员了解因为它们解决的正是裸指针最容易出的问题谁负责释放、什么时候释放、释放后还有没有人引用。unique_ptr表达独占所有权出了作用域自动释放不允许拷贝只允许移动shared_ptr用引用计数共享所有权计数归零才释放weak_ptr不增加计数用来打破shared_ptr之间的循环引用。这套机制的价值不在于语法而在于把所有权这个隐式约定变成了类型系统能检查的东西。写 C 代码时你也可以借用同样的思路明确哪个函数负责malloc、哪个负责free在头文件注释里写清楚所有权转移规则命名上区分借用和接管。7.2 我踩过的坑与几条硬规矩最后整理几条我这些年写 C 攒下来的经验。声明指针时一律初始化要么给NULL要么给有效地址绝不留下随机值。free 后立即置空避免重复释放和悬空访问。函数参数里的指针永远假设调用方可能传 NULL入口处判空或断言。返回指针的函数在注释里写清所有权是调用方负责释放还是返回静态/内部缓冲。指针算术只在数组范围内进行arr n是合法的尾后指针但解引用它属于未定义行为。不同类型指针之间不要随意强转除非你在处理字节流或硬件寄存器并且清楚对齐要求。这些规矩看起来琐碎但每一条背后都有真实崩溃案例。指针难难的不是语法而是它直接暴露了内存模型——你写的每一行代码都在跟地址和生命周期打交道。把内存摆在心里指针就不会再是玄学。