资讯动态

C语言指针进阶实战:数组、二级指针与const详解

发布时间:2026/10/15 8:51:26 来源:尧图企业网站定制
我教C语言这些年答疑时碰到最多的就是指针。基础语法讲完大部分同学都觉得自己懂了“指针就是存地址的变量嘛”结果一到写代码就原形毕露——数组传参传不明白、指针改不动实参、const一加就报警告、链表写两行就段错误。这篇“中篇”要解决的就是这些绕不开的核心问题指针到底怎么操作内存、它和数组是什么关系、为什么函数里改指针不生效、const到底锁的是谁。上篇如果你已经搞定了“指针是什么、怎么初始化、怎么解引用”那这篇就是帮你把指针真正用到实战里去。1. 指针与数组绕不开的那点纠葛1.1 下标访问的本质编译器偷偷干了什么先说一个很多人没意识到的真相C语言里的arr[i]并不是什么高级语法它本质上就是*(arr i)的语法糖。你写arr[3]编译器会翻译成“从数组首地址往后偏移3个元素的位置然后解引用”。这个理解特别重要因为它决定了你怎么看待数组越界。很多人觉得数组越界是“下标太大了”其实不是。C语言压根不检查下标是否合法arr[100]在编译阶段根本不会报错它只是老老实实地帮你计算出“首地址 100 × 元素大小”这个地址然后去访问那块内存。至于那块内存是不是你的它不管。再延伸一下既然arr[i]等价于*(arr i)那i[arr]理论上也是合法的因为加法交换律嘛。这个写法在真实的项目中没人会这么写但面试题里偶尔会出现你只要知道它背后的原理是同一个就不会被问懵。1.2 数组名和指针像但真的不一样这里几乎是所有新手的第一道坎。数组名在大多数表达式里会“退化”成指向首元素的指针但它本身并不是指针变量。区别在哪数组名没有自己的存储空间它只是那块内存区域的名字。所以你不能对数组名做自增自减arr直接编译失败因为arr不是一个可以修改的左值。但指针可以p完全合法。sizeof(arr)和sizeof(p)的结果也不同。数组名用在sizeof里时不会退化它返回的是整个数组的字节数而指针的sizeof返回的是指针本身的大小64位系统下通常是8个字节。我见过太多人在函数参数里写void foo(int arr[])然后一上来就sizeof(arr)想拿数组长度——拿到的永远是8不是数组大小。为什么因为数组作为函数参数时也会退化成指针这个坑后面细说。还有两个例外除了sizeofarr也是一个不退化的情况。arr的类型是“指向整个数组的指针”也就是int (*)[N]它和arr退化成int*的地址值一样但含义完全不同。arr 1跳过一个元素arr 1跳过整个数组。很多人在做内存操作时用错这两个表达式导致偏移量差了“数组大小”这么多。1.3 指针加减的步长为什么要分类型指针的加减运算单位不是字节而是“所指向类型的大小”。这句话是一切的基石。int arr[5] {10, 20, 30, 40, 50}; int *p arr; printf(%p\n, p); // 假设 0x1000 printf(%p\n, p 2); // 0x1008不是 0x1002p 2实际偏移了 2 × sizeof(int) 8 个字节。这是设计上的必然——因为解引用*(p 2)要拿到一个完整的 int如果偏移量不是元素大小的整数倍取出来的数就是错位的。理解了步长你就知道为什么void*不能做加减运算。void类型没有大小编译器不知道偏移单位是多少。这在后面讲泛型指针时还要展开。指针和指针之间可以相减得到的是“相隔多少个元素”不是字节数。但两个指针相加是没有意义的两个地址加在一起你要它干什么用这就像问“北京和上海加起来是多少公里”完全说不通。1.4 字符串指针和字符数组一个能改一个改了会崩char s1[] hello; char *s2 hello; s1[0] H; // 合法 s2[0] H; // 大概率崩溃s1是在栈上分配的一块字符数组内容是拷贝过来的副本改它没问题。s2指向的是字符串字面量在只读数据区改它属于未定义行为大多数系统上直接段错误。很多同学在这个坑上栽过而且是在项目收尾阶段才炸出来。排查手段其实很简单就是看清楚这个指针到底指向哪里是指向栈上的数组还是指向字面量常量区。这个判断习惯要培养起来写字符串处理代码时先问自己一句“这块内存我能写吗”2. 二级指针什么时候非它不可2.1 一个经典困局函数里改了指针外面为什么没变C语言里所有参数传递都是值传递。很多人能接受“传 int 进去改了没用”但到了指针这里就卡住了“我传的是指针啊怎么会改不了”关键在于传指针进去复制的也是这个指针变量的值也就是那个地址。你在函数内部拿到的是这个地址的一份拷贝通过它确实能修改指向的那块内存但你修改不了外面那个指针变量本身。如果函数里写了p something改的只是内部拷贝外部的p纹丝不动。void change_ptr(int *p) { p (int *)malloc(sizeof(int)); *p 42; } int main() { int *p NULL; change_ptr(p); // p 依然是 NULL内存也泄漏了 }想解决这个问题就得把“指针变量”本身也当作一块内存通过“指向这个指针变量的指针”去修改它。这就是二级指针存在的意义。2.2 二级指针的典型场景改实参、链表头、字符串数组最常见的应用之一就是在函数内部给外部指针分配内存void alloc_int(int **p) { *p (int *)malloc(sizeof(int)); **p 42; } int main() { int *p NULL; alloc_int(p); printf(%d\n, *p); // 42 free(p); }这里p的类型是int***p解引用拿到的是外部的p变量本身给它赋值就等于修改外部的指针。第二个高频场景是链表的头节点插入。如果你要在头部插入一个新节点而头指针又可能为空那就需要修改“头指针变量”的值。用二级指针传入head函数内部*head new_node就能直接更新链表头。如果只用一级指针函数内部改了头外面还是旧的整个链就断了。第三个场景是遍历字符串数组。假设你有一个char *strs[3]想让一个函数接收它并遍历形参就要写成char **strs。因为数组退化成指向首元素的指针首元素是char*所以指针的类型就是char**。2.3 二维数组为什么不是二级指针这个混淆太常见了。int a[3][4]作为函数参数时形参是int (*a)[4]也就是“指向含4个int的数组的指针”不是int**。为什么因为二维数组在内存里是连续排布的一整块没有“存放指针的数组”这种东西。a[i][j]的本质是*(*(a i) j)。a i跳过了 i 个长度为4的整型数组得到的还是一个指针。而int**的语义是“指针指向一个指针”需要内存里真的存在指针对象。这两者在内存布局上完全不同直接混用崩溃是必然的。我建议你用一个小程序打印一下两者的sizeof再实际取地址看看偏移比背十遍概念都管用。3. 指针和const到底在锁谁3.1 三种形态一个记忆方法const和指针的组合有三种写法新手容易混乱const int *p1; // p1 指向一个 const int int *const p2 x; // p2 本身是 const const int *const p3 x; // 两边都 const记法就一条从右往左读。const int *p1读出来是“p1 is pointer to const int”——指针指向的对象是const的所以不能通过*p1修改值但p1可以指向别处。int *const p2读出来是“p2 is const pointer to int”——指针本身是const的所以p2不能指向别处但*p2能改。这个区分不是文字游戏而是工程上的分工责权。写API的时候入参用const int *p是在告诉调用者“我只会读你传进来的数据不会改。”调用者看到这个签名就能放心地把数据交给你。如果哪天你试图在函数里写*p ...编译器会直接报错这就是用编译器帮你守住承诺。3.2 天天报错的一个场景const指针的隐式转换看这段代码char *p hello; const char *cp p; // 合法合法因为把char*赋给const char*是增加限制编译器允许。但反过来const char *cp hello; char *p cp; // 编译警告或错误char*意味着调用方“将来可以通过这个指针修改内容”而cp指向的是只读数据。如果你强行把const char*赋给char*等于把一个只读内存交给了可能写入的指针这是要被警告的。想绕过去可以用强制转换。但请你在写这种代码之前想清楚你真的确定这块内存可以写吗如果确定那转就转了如果不确定千万别转否则程序哪一天在某个神秘的地方崩溃你查三天三夜都查不到这里。还有一层更隐晦的规则int **不能隐式转换成const int **。很多初学者以为“加个const还能有啥问题”但C语言标准里这个赋值是非法的。原因是一个绕弯子的安全隐患如果你允许这种转换那某个作者就可能通过const int **去写一个原本指向const数据的指针从而绕过类型系统的保护。简而言之多级指针的const是不传递的。4. 指针做函数参数传辅助、传地址、传指针的指针4.1 每一层“传法”能做什么一张表格说清楚我把指针传参的三种形态放在一起做个对比写代码前先对照一下形参能改实参指向的内存能改实参指针的值典型场景int p不能不适用基础类型传值int *p能不能修改数组元素、结构体字段int **p能能分配内存、改链表头、字符串数组遍历关键结论就一条想改谁就传谁的地址。想改普通变量传一级指针想改指针变量本身传二级指针。这个规律往上扩展到三级四级都是一样的。4.2 数组参数退化的坑函数里拿不到数组长度void print_array(int arr[]) { printf(%zu\n, sizeof(arr)); // 8不是数组大小 }int arr[]写在形参列表里等价于int *arr。这是语法糖但也是坑的源头。在函数内部sizeof(arr)永远是指针大小不是数组大小。所以传数组的同时必须把长度也传进来void print_array(int *arr, size_t len) { for (size_t i 0; i len; i) { printf(%d\n, arr[i]); } }有人问“为什么C不直接在数组里存个长度这样就不用传了”因为C的设计哲学是“不为你做你不想要的事”。数组退化成指针一方面性能好不像某些语言传数组要复制或者引用计数另一方面也意味着调用者可以传“某个数组中间的指针”进来灵活性非常高。代价就是责任在程序员身上——你必须自己记着长度。4.3 一个实战级代码对比返回指针 vs 二级指针链表头插入这个场景我见过两种写法。一种是用返回值Node *insert(Node *head, int val) { Node *new_node (Node *)malloc(sizeof(Node)); new_node-val val; new_node-next head; return new_node; } head insert(head, 42);另一种是二级指针void insert(Node **head, int val) { Node *new_node (Node *)malloc(sizeof(Node)); new_node-val val; new_node-next *head; *head new_node; } insert(head, 42);两种都行但风格差异很大。返回值方式要求调用者记得接住返回值一旦忘记赋值头就丢了。二级指针方式更直白你传入头指针的地址函数直接更新它调用者不需要处理返回值出错率低一些。工程上我更推荐二级指针尤其是头节点可能为空的情况代码意图也更清晰。5. 常见问题与排查技巧实录5.1 段错误、野指针、悬空指针怎么定位日常开发里和指针相关的问题基本逃不出这几个类型。我把典型的错误和排查方法列成表症状常见原因排查入口段错误解引用了NULL指针解引用前检查指针是否为NULL段错误解引用了悬空指针返回局部变量地址检查函数是否返回了栈上变量的地址段错误越界写入了只读区检查是否修改字符串字面量输出乱值野指针未初始化初始化指针为NULL或有效地址内存泄漏指针被覆盖释放不到原块分配和释放尽量成对写在同一个函数崩溃偶发释放了同一块内存两次释放后立即置NULL悬空指针是典型中的典型比如int *bad() { int x 10; return x; }x是栈上的局部变量函数返回后它的内存就被回收了。指针虽然还保存着那个地址但地址里的内容已经不再合法。多调用几次别的函数那块内存可能被覆盖成乱七八糟的东西。这类问题用-Wall -fsanitizeaddress编译一下往往能直接定位到出错行。5.2 排查方法论打印、工具、读代码的顺序我排查指针问题有一套固定的顺序。第一步用printf(%p, p)打印关键指针的地址值先分清是“地址本身是垃圾”还是“地址没问题但内容被篡改”。第二步查看指针是否为NULL很多时候问题就是“调用前没判断直接解引用了一个NULL指针”。第三步用调试器打印调用栈比如GDB的bt命令看崩溃点在哪个函数、哪一行再往前找是谁传了错误的指针。第四步是在可疑的写操作前后打印内存内容确认是谁把数据改坏了。这套流程看起来朴素但实测非常管用。我见过某个同学链表写了一天各种崩。最后打印了一下头指针的地址发现第二次插入后头指针变成了之前已经释放掉的节点地址。原因就是插入函数里申请新节点后被误操作覆盖了next指向。这种情况如果不用打印去核对每一步的指针值光靠肉眼读代码真的很难发现。5.3 几个调试利器编译时加-g是基础配合gcc -Wall -Wextra能把很多可疑用法直接变成警告。更要推荐的是地址消毒器AddressSanitizer编译时加上-fsanitizeaddress运行时如果出现越界访问、释放后再用、双重释放程序会直接打印出出错的源代码行号。这在学习阶段说不上是“神器”说它是“救命工具”都不夸张。另外调试时别嫌打印地址麻烦。我自己的习惯是凡是指针改动的地方都加上一行打地址的日志。日志是最直观的线索尤其在链表、树这类结构里地址变了没变一目了然。写在最后我自己的一点体会指针这一块最难的不是语法而是脑海里有没有那块内存的画面。每次看到*p我在心里看到的是“根据地址找到一块内存”看到p我看到的是“指针移动到下一个元素”看到p我看到的是“指针变量在内存里的地址”。把这些操作和画面对应起来指针就不再是抽象的符号而是具体的动作。如果你现在还在被指针折磨我建议你做一件事把本篇每个例子都自己敲一遍然后在关键位置打印地址和值对照着看输出。别急着写链表和树先把指针运算、二级指针、const组合这些基础动作练熟后面数据结构部分会轻松非常多。下一篇我们可以聊聊函数指针、回调机制以及指针和动态内存管理的一些进阶话题。到时候你会发现基础这里越扎实后面越省力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑