从地址到自由:C语言指针核心概念深度复盘与实战指南

📅 2026/8/2 2:56:02
从地址到自由:C语言指针核心概念深度复盘与实战指南
指针基础从地址到自由 —— C 指针核心概念复盘1. 一句话总结指针不是魔法它就是一个存地址的变量 —— 但当你真正理解指针的步长、类型约束和内存模型后你就能用同一个地址玩出完全不同的花样。2. 知识地图指针基础 ├── 指针是什么 │ ├── 指针变量存放地址的变量 │ ├── 取地址运算符 │ └── * 解引用运算符 │ ├── 指针的类型 │ ├── 类型决定了「解引用时读几个字节」 │ ├── 类型决定了「指针1 跳几个字节」步长 │ │ ├── char* → 步长 1 │ │ ├── int* → 步长 4 │ │ └── double*→ 步长 8 │ └── 指针类型必须与指向对象的类型匹配 │ ├── 指针与数组 │ ├── 数组名在表达式中退化为首元素指针 │ ├── arr[i] 等价于 *(arr i) │ ├── sizeof(arr) 是特例不退化为指针 │ ├── arr 是特例指向整个数组的指针 │ ├── 指针-指针 两地址之间的元素个数 │ └── 指针的关系运算地址比较 │ ├── const 修饰指针 │ ├── const int *p → 不能改 *p能改 p │ └── int * const p → 能改 *p不能改 p │ ├── 指针作为函数参数 │ ├── 传值调用形参是实参的拷贝互不影响 │ └── 传址调用通过指针间接修改外部变量 │ ├── 指针的安全使用 │ ├── 野指针未初始化 │ ├── 悬垂指针指向已释放的内存 │ ├── 空指针检查assert(p ! NULL) │ └── 越界访问指针走出数组范围后要重置 │ └── 指针与字符串 ├── char* 指向字符串首字符 └── while(*p) 遍历直到 \03. 核心概念深度解析3.1 指针步长 —— 为什么char*1和int*1跳的不一样这是初学者最容易「脑子会了手不会」的概念。直接看内存布局假设地址 0x61FE3C 处存了一个 int 和一个 char 内存地址: 0x61FE3C 0x61FE3D 0x61FE3E 0x61FE3F 0x61FE40 ┌─────────┬─────────┬─────────┬─────────┬─────────┐ │ int a │ │ │ │ ??? │ │ (4字节) │ │ │ │ │ └─────────┴─────────┴─────────┴─────────┴─────────┘ ↑ ↑ int* pa int* pa1 char* pc (跳了4字节) ↑ char* pc1 (跳了1字节) 当 pa 是 int* 时pa1 实际上是 pa 1 pa 1 * sizeof(int) 0x61FE3C 4 0x61FE40 当 pc 是 char* 时pc1 实际上是 pc 1 pc 1 * sizeof(char) 0x61FE3C 1 0x61FE3D 所以输出 pa 0x61FE3C pa1 0x61FE40 ← 差了 4 pc 0x61FE3C pc1 0x61FE3D ← 差了 1关键理解指针的加减运算是以「它所指向类型的大小」为单位的。这不是语法糖而是编译器在背后帮你做了sizeof(type)的乘法。这也是为什么不同类型指针不能随意混用 —— 你用char*去指向一个int解引用时只会读 1 个字节剩下的 3 个字节就丢了。3.2 const 修饰指针 —— 一个星号两个世界这是面试高频考点也是写健壮代码的必备技能。规则只有一句话const 修饰谁谁就不能改。看 const 在*的左边还是右边。场景一const 在 * 的左边 → 修饰 *p即「指向的值」不能改 const int *pa a; 也可以写成 int const *pa a; │ │ │ └── pa 是指针 └─────────── const 修饰 int即 *pa 是 const int *pa 28; // ❌ 报错不能通过 pa 修改 a 的值 pa b; // ✅ 可以pa 本身可以指向别处 图示 ┌────┐ ┌────┐ │ pa │────→│ a │ ← 这个值被锁定了不能通过 pa 改 └────┘ └────┘ ↑ │ 但 pa 可以重新指向 b ↓ ┌────┐ │ b │ ← 重新指向后同样不能通过 pa 改 b └────┘ 场景二const 在 * 的右边 → 修饰 p即「指针本身」不能改 int * const pa a; │ │ │ └── const 修饰 papa 本身是 const └─────── pa 指向 int *pa 28; // ✅ 可以通过 pa 修改 a 的值 pa b; // ❌ 报错pa 不能指向别处 图示 ┌────┐ ┌────┐ │ pa │────→│ a │ ← 可以改 a 的值 └────┘ └────┘ ↑ │ 但 pa 本身被锁死在这个地址上 │ 永远指向 a不能改对比总结写法能改*p吗能改p吗记忆口诀int *p能能裸指针什么都拦不住const int *p不能能值锁死指针自由int * const p能不能指针锁死值自由const int * const p不能不能全锁死只读3.3 数组名退化 —— 什么时候 arr 是指针什么时候不是这是 C 语言最迷惑的设计之一。数组名 arr 在以下场景中「退化」为首元素指针int* arr 0 → 首元素地址类型 int* arr[i] → 等价于 *(arr i) func(arr) → 传给函数的是首元素地址类型 int* 数组名 arr 在以下场景中「不退化」保持数组身份 sizeof(arr) → 整个数组的大小不是指针大小 arr → 指向整个数组的指针类型 int(*)[N] 用代码验证intarr[10]{0};printf(%zu\n,sizeof(arr));// 4010个int不是8指针大小printf(%p\n,arr);// 首元素地址printf(%p\n,arr[0]);// 首元素地址和上面一样printf(%p\n,arr);// 也是首元素地址但类型不同// arr 和 arr[0] 地址相同但 1 的步长完全不同// arr[0] 1 → 跳 4 字节下一个元素// arr 1 → 跳 40 字节整个数组踩坑警示在函数内部用sizeof(arr)求数组长度是经典错误因为函数参数中的arr已经退化为指针了。4. 代码实战4.1 指针遍历数组的三种写法#includestdio.hintmain(){intarr[]{0,1,2,3,4,5,6,7,8,9};int*parr;// 或者 int *p arr[0];size_tszsizeof(arr)/sizeof(arr[0]);// 方式一下标访问for(inti0;isz;i){printf(%d ,arr[i]);}printf(\n);// 方式二指针偏移推荐理解底层原理时使用for(inti0;isz;i){printf(%d ,*(pi));}printf(\n);// 方式三移动指针本身注意用完后要重置for(inti0;isz;i){printf(%d ,*p);p;}parr;// 重置否则 p 指向了数组末尾之外printf(\n);return0;}4.2 指针-指针实现 strlen#includestdio.h#includeassert.h// 用途计算字符串长度使用指针差值实现// 原理尾指针 - 头指针 中间的元素个数intmy_strlen(constchar*p)// const 表明不会修改字符串内容{assert(p!NULL);// 防御性编程空指针检查constchar*startp;// 保存起始位置while(*p)// 遇到 \0 停止{p;}returnp-start;// 指针差值 字符个数}intmain(){chararr[]abcdef;intlenmy_strlen(arr);printf(%d\n,len);// 输出 6return0;}4.3 传址调用实现 swap#includestdio.h// 错误示范传值调用无法交换外部变量voidSwap1(intx,inty){intzx;xy;yz;// x 和 y 是局部变量函数结束后销毁// 外部的 a 和 b 完全没受影响}// 正确写法传址调用通过指针间接修改外部变量voidSwap2(int*pa,int*pb){intz*pa;*pa*pb;*pbz;}intmain(){inta10;intb20;printf(交换前: a %d, b %d\n,a,b);Swap1(a,b);printf(Swap1 后: a %d, b %d\n,a,b);// 没变Swap2(a,b);printf(Swap2 后: a %d, b %d\n,a,b);// 交换成功return0;}4.4 冒泡排序指针版#includestdio.hvoidbubble_sort(intarr[],intsz){intflag1;// 优化标记是否发生交换for(inti0;isz-1;i){flag1;// 每趟开始前重置标记for(intj0;jsz-1-i;j){if(arr[j]arr[j1]){inttemparr[j];arr[j]arr[j1];arr[j1]temp;flag0;// 发生了交换}}if(flag1)// 一整趟没有交换说明已经有序{break;}}}voidprint(intarr[],intsz){int*parr;for(inti0;isz;i){printf(%d ,*(pi));}printf(\n);}intmain(){intarr[]{1,7,4,6,43,5,22,76,72,100,34};intszsizeof(arr)/sizeof(arr[0]);bubble_sort(arr,sz);print(arr,sz);return0;}5. 注释提炼5.1 知识注解sizeof(arr)在函数内外结果不同voidtest(intarr[]){intszsizeof(arr)/sizeof(arr[0]);// 8 / 4 2printf(%d\n,sz);// 输出 2不是数组元素个数}扩展科普函数参数写int arr[]本质上等价于int *arr编译器看到的是指针。所以sizeof(arr)在函数内部求的是指针变量的大小64 位系统下是 8 字节而不是数组的大小。这就是为什么数组作为函数参数时必须额外传递长度。我的理解可以把数组名想象成一张「写有家庭地址的纸条」。在你家门口定义处大家都知道纸条代表的是整个房子。但一旦把纸条递给别人传给函数别人只看到纸条上写的地址不知道这个房子有多大。sizeof在函数外能量出房子大小在函数内只能量出纸条本身的大小。5.2 知识注解while(*p)遍历字符串的工作原理chararr[]abcdef;char*parr;while(*p){printf(%c,*p);p;}扩展科普字符串在 C 中以\0ASCII 码为 0结尾。while(*p)等价于while(*p ! \0)。当*p取到\0时其值为 0假循环结束。这是一种非常 C 语言风格的写法简洁但需要理解「0 即假非 0 即真」的条件判断规则。5.3 知识注解int *p arr[sz-1]指向最后一个元素intarr[]{1,2,3,4,5,7,8};intszsizeof(arr)/sizeof(arr[0]);int*parr[sz-1];// 指向最后一个元素for(inti0;isz;i){printf(%d ,*p);p--;// 递减指针实现逆序输出}扩展科普这段代码展示了另一种逆序输出的思路 —— 让指针从末尾开始逐步向前移动。for循环的参数i只用来控制循环次数没有在循环体内使用。这种写法利用了指针自带的「可移动」特性比*(p (sz - 1 - i))更直观。5.4 个人理解指针-指针的绝对值 元素个数我的理解指针相减不关心谁大谁小只要两个指针指向同一块连续内存差值就是它们之间的元素个数。arr[9] - arr[0] 9arr[0] - arr[9] -9。绝对值就是元素个数正负号告诉你方向。可以想象成两栋楼之间的门牌号差值 —— 8 号到 2 号中间隔了 6 栋楼不管正着数还是倒着数都是 6。5.5 个人理解arr[i]为什么等价于*(arr i)我的理解[]运算符本质上就是指针运算的语法糖。编译器看到arr[i]内部会转换成*(arr i)。这意味着arr[i]和i[arr]在语法上竟然是等价的虽然正常人不会这么写。理解这一点后指针和数组的关系就打通了 —— 它们不是同一个东西但访问方式共享同一套底层逻辑。5.6 踩坑指针越界后不重置intarr[]{1,2,3,4,5,66,7,8,9};int*parr;size_tszsizeof(arr)/sizeof(arr[0]);// 第一轮把数组所有元素赋值为 5for(inti0;isz;i){*p5;p;// 循环结束后p 指向 arr[9] 之外}// ⚠️ 踩坑此时 p 已经越界直接使用会导致未定义行为// 正确做法重置指针parr;// 必须重置for(inti0;isz;i){printf(%d ,*p);p;}原因指针是「有状态」的。它不像arr[i]那样每次访问都重新计算地址而是实实在在地移动了。当你用p遍历完数组p指向的内存已经不属于你的数组了。此时直接解引用就是越界访问属于未定义行为UB可能崩溃可能读到脏数据也可能「看起来正常」—— 这是最危险的情况。教训用指针遍历数组时养成「用完就重置」的习惯或者改用*(p i)这种不移动指针本身的写法更安全。5.7 踩坑返回局部变量的地址int*test(){intn100;returnn;// ⚠️ 危险n 在函数返回后已被销毁}intmain(){int*ptest();printf(%d\n,*p);// 未定义行为可能打印 100也可能打印垃圾值return0;}原因局部变量n存储在栈上函数返回后栈帧被回收n的那块内存不再属于你。虽然「可能」还能读到 100因为栈上的数据还没被覆盖但这完全靠运气。一旦后面调用了其他函数比如printf栈被重新使用数据就会被覆盖。我的理解这就像你把东西放在酒店的房间里退房后虽然东西还在原位但下一个住客随时可能把它扔掉或拿走。你不能指望退房后还能取回自己的东西。5.8 对比总结arr、arr[0]、arr的区别表达式含义类型1的步长arr首元素地址退化后int*4 字节arr[0]首元素地址int*4 字节arr整个数组的地址int(*)[N]整个数组大小三个地址值相同但类型不同步长不同。这一点在理解指针运算时至关重要。6. 代码修正6.1 冒泡排序中的 flag 逻辑错误修改前原始代码中的版本voidpaixu(intarr[],intsz){intflag1;for(inti0;isz-1;i){for(intj0;jsz-1;j)// BUG 1: 内循环条件{if(arr[j]arr[j1]){inttemparr[j];arr[j]arr[j1];arr[j1]temp;flag1;// BUG 2: 标记逻辑反了}}if(flag0)// BUG 3: 和上面的标记逻辑矛盾{break;}}}问题分析内循环范围错误j sz - 1应该改为j sz - 1 - i。每一趟排序后最大的元素已经「沉底」到数组末尾不需要再比较已排好的部分。flag 标记逻辑颠倒flag 1表示发生了交换但后面用flag 0判断提前退出 —— 逻辑矛盾。正确的做法是发生交换时置flag 0没交换时flag保持为1表示已经有序。flag 没有在每趟开始时重置flag 需要在每趟比较开始前重新初始化。修改后voidbubble_sort(intarr[],intsz){intflag1;for(inti0;isz-1;i){flag1;// 每趟开始前重置假设已经有序for(intj0;jsz-1-i;j)// 修正已排好的部分不再比较{if(arr[j]arr[j1]){inttemparr[j];arr[j]arr[j1];arr[j1]temp;flag0;// 发生了交换说明还没完全有序}}if(flag1)// 一整趟都没有交换已经有序提前退出{break;}}}6.2 数组初始化语法错误修改前intarr[10]0;// 语法错误不能用单个值初始化数组修改后intarr[10]{0};// 正确所有元素初始化为 0原因C 语言中数组初始化必须使用花括号{}。int arr[10] {0}会将第一个元素设为 0其余元素自动初始化为 0。6.3 函数名拼写错误修改前intmian()// 拼写错误修改后intmain()// 正确这是 C 语言中最经典的拼写错误之一编译器会报错undefined reference to main因为链接器找不到mian这个入口函数。7. 小结指针的核心就两件事地址存了什么、跳一步走多远。把这两件事搞明白再配合const做约束、assert做防御、数组退化做理解指针就从「最怕的知识点」变成「最趁手的工具」。本文基于 C 语言指针基础复习代码整理涵盖指针声明、指针运算、const 修饰、数组与指针关系、函数传参等核心内容。