指针基础:从地址到自由 —— 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) 遍历直到 '\0'
3. 核心概念深度解析
3.1 指针步长 —— 为什么 char*+1 和 int*+1 跳的不一样?
这是初学者最容易「脑子会了手不会」的概念。直接看内存布局:
假设地址 0x61FE3C 处存了一个 int 和一个 char:
内存地址: 0x61FE3C 0x61FE3D 0x61FE3E 0x61FE3F 0x61FE40
┌─────────┬─────────┬─────────┬─────────┬─────────┐
│ int a │ │ │ │ ??? │
│ (4字节) │ │ │ │ │
└─────────┴─────────┴─────────┴─────────┴─────────┘
↑ ↑
int* pa int* pa+1
char* pc (跳了4字节)
↑
char* pc+1
(跳了1字节)
当 pa 是 int* 时,pa+1 实际上是:
pa + 1 = pa + 1 * sizeof(int) = 0x61FE3C + 4 = 0x61FE40
当 pc 是 char* 时,pc+1 实际上是:
pc + 1 = pc + 1 * sizeof(char) = 0x61FE3C + 1 = 0x61FE3D
所以输出:
pa = 0x61FE3C
pa+1 = 0x61FE40 ← 差了 4
pc = 0x61FE3C
pc+1 = 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 修饰 pa,pa 本身是 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]
用代码验证:
int arr[10] = {0};
printf("%zu\n", sizeof(arr)); // 40(10个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 指针遍历数组的三种写法
#include <stdio.h>
int main()
{
int arr[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
int *p = arr; // 或者 int *p = &arr[0];
size_t sz = sizeof(arr) / sizeof(arr[0]);
// 方式一:下标访问
for (int i = 0; i < sz; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
// 方式二:指针+偏移(推荐理解底层原理时使用)
for (int i = 0; i < sz; i++)
{
printf("%d ", *(p + i));
}
printf("\n");
// 方式三:移动指针本身(注意用完后要重置!)
for (int i = 0; i < sz; i++)
{
printf("%d ", *p);
p++;
}
p = arr; // 重置!否则 p 指向了数组末尾之外
printf("\n");
return 0;
}
4.2 指针-指针实现 strlen
#include <stdio.h>
#include <assert.h>
// 用途:计算字符串长度,使用指针差值实现
// 原理:尾指针 - 头指针 = 中间的元素个数
int my_strlen(const char *p) // const 表明不会修改字符串内容
{
assert(p != NULL); // 防御性编程:空指针检查
const char *start = p; // 保存起始位置
while (*p) // 遇到 '\0' 停止
{
p++;
}
return p - start; // 指针差值 = 字符个数
}
int main()
{
char arr[] = "abcdef";
int len = my_strlen(arr);
printf("%d\n", len); // 输出 6
return 0;
}
4.3 传址调用实现 swap
#include <stdio.h>
// 错误示范:传值调用,无法交换外部变量
void Swap1(int x, int y)
{
int z = x;
x = y;
y = z;
// x 和 y 是局部变量,函数结束后销毁
// 外部的 a 和 b 完全没受影响
}
// 正确写法:传址调用,通过指针间接修改外部变量
void Swap2(int *pa, int *pb)
{
int z = *pa;
*pa = *pb;
*pb = z;
}
int main()
{
int a = 10;
int b = 20;
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); // 交换成功!
return 0;
}
4.4 冒泡排序(指针版)
#include <stdio.h>
void bubble_sort(int arr[], int sz)
{
int flag = 1; // 优化:标记是否发生交换
for (int i = 0; i < sz - 1; i++)
{
flag = 1; // 每趟开始前重置标记
for (int j = 0; j < sz - 1 - i; j++)
{
if (arr[j] > arr[j + 1])
{
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
flag = 0; // 发生了交换
}
}
if (flag == 1) // 一整趟没有交换,说明已经有序
{
break;
}
}
}
void print(int arr[], int sz)
{
int *p = arr;
for (int i = 0; i < sz; i++)
{
printf("%d ", *(p + i));
}
printf("\n");
}
int main()
{
int arr[] = {1, 7, 4, 6, 43, 5, 22, 76, 72, 100, 34};
int sz = sizeof(arr) / sizeof(arr[0]);
bubble_sort(arr, sz);
print(arr, sz);
return 0;
}
5. 注释提炼
5.1 知识注解:sizeof(arr) 在函数内外结果不同
void test(int arr[])
{
int sz = sizeof(arr) / sizeof(arr[0]); // 8 / 4 = 2
printf("%d\n", sz); // 输出 2,不是数组元素个数!
}
扩展科普:函数参数写 int arr[] 本质上等价于 int *arr,编译器看到的是指针。所以 sizeof(arr) 在函数内部求的是指针变量的大小(64 位系统下是 8 字节),而不是数组的大小。这就是为什么数组作为函数参数时,必须额外传递长度。
我的理解:可以把数组名想象成一张「写有家庭地址的纸条」。在你家门口(定义处),大家都知道纸条代表的是整个房子。但一旦把纸条递给别人(传给函数),别人只看到纸条上写的地址,不知道这个房子有多大。sizeof 在函数外能量出房子大小,在函数内只能量出纸条本身的大小。
5.2 知识注解:while(*p) 遍历字符串的工作原理
char arr[] = "abcdef";
char *p = arr;
while (*p)
{
printf("%c", *p);
p++;
}
扩展科普:字符串在 C 中以 '\0'(ASCII 码为 0)结尾。while(*p) 等价于 while(*p != '\0')。当 *p 取到 '\0' 时,其值为 0(假),循环结束。这是一种非常 C 语言风格的写法,简洁但需要理解「0 即假,非 0 即真」的条件判断规则。
5.3 知识注解:int *p = &arr[sz-1] 指向最后一个元素
int arr[] = {1, 2, 3, 4, 5, 7, 8};
int sz = sizeof(arr) / sizeof(arr[0]);
int *p = &arr[sz - 1]; // 指向最后一个元素
for (int i = 0; i < sz; i++)
{
printf("%d ", *p);
p--; // 递减指针实现逆序输出
}
扩展科普:这段代码展示了另一种逆序输出的思路 —— 让指针从末尾开始,逐步向前移动。for 循环的参数 i 只用来控制循环次数,没有在循环体内使用。这种写法利用了指针自带的「可移动」特性,比 *(p + (sz - 1 - i)) 更直观。
5.4 个人理解:指针-指针的绝对值 = 元素个数
我的理解:指针相减不关心谁大谁小,只要两个指针指向同一块连续内存,差值就是它们之间的元素个数。&arr[9] - &arr[0] = 9,&arr[0] - &arr[9] = -9。绝对值就是元素个数,正负号告诉你方向。可以想象成两栋楼之间的门牌号差值 —— 8 号到 2 号,中间隔了 6 栋楼,不管正着数还是倒着数,都是 6。
5.5 个人理解:arr[i] 为什么等价于 *(arr + i)?
我的理解:[] 运算符本质上就是指针运算的语法糖。编译器看到 arr[i],内部会转换成 *(arr + i)。这意味着 arr[i] 和 i[arr] 在语法上竟然是等价的(虽然正常人不会这么写)。理解这一点后,指针和数组的关系就打通了 —— 它们不是同一个东西,但访问方式共享同一套底层逻辑。
5.6 踩坑:指针越界后不重置
int arr[] = {1, 2, 3, 4, 5, 66, 7, 8, 9};
int *p = arr;
size_t sz = sizeof(arr) / sizeof(arr[0]);
// 第一轮:把数组所有元素赋值为 5
for (int i = 0; i < sz; i++)
{
*p = 5;
p++; // 循环结束后,p 指向 arr[9] 之外!
}
// ⚠️ 踩坑:此时 p 已经越界,直接使用会导致未定义行为
// 正确做法:重置指针
p = arr; // 必须重置!
for (int i = 0; i < sz; i++)
{
printf("%d ", *p);
p++;
}
原因:指针是「有状态」的。它不像 arr[i] 那样每次访问都重新计算地址,而是实实在在地移动了。当你用 p++ 遍历完数组,p 指向的内存已经不属于你的数组了。此时直接解引用就是越界访问,属于未定义行为(UB),可能崩溃,可能读到脏数据,也可能「看起来正常」—— 这是最危险的情况。
教训:用指针遍历数组时,养成「用完就重置」的习惯,或者改用 *(p + i) 这种不移动指针本身的写法,更安全。
5.7 踩坑:返回局部变量的地址
int *test()
{
int n = 100;
return &n; // ⚠️ 危险!n 在函数返回后已被销毁
}
int main()
{
int *p = test();
printf("%d\n", *p); // 未定义行为!可能打印 100,也可能打印垃圾值
return 0;
}
原因:局部变量 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 逻辑错误
修改前(原始代码中的版本):
void paixu(int arr[], int sz)
{
int flag = 1;
for (int i = 0; i < sz - 1; i++)
{
for (int j = 0; j < sz - 1; j++) // BUG 1: 内循环条件
{
if (arr[j] > arr[j + 1])
{
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
flag = 1; // BUG 2: 标记逻辑反了
}
}
if (flag == 0) // BUG 3: 和上面的标记逻辑矛盾
{
break;
}
}
}
问题分析:
- 内循环范围错误:
j < sz - 1应该改为j < sz - 1 - i。每一趟排序后,最大的元素已经「沉底」到数组末尾,不需要再比较已排好的部分。 - flag 标记逻辑颠倒:
flag = 1表示发生了交换,但后面用flag == 0判断提前退出 —— 逻辑矛盾。正确的做法是:发生交换时置flag = 0,没交换时flag保持为1,表示已经有序。 - flag 没有在每趟开始时重置:flag 需要在每趟比较开始前重新初始化。
修改后:
void bubble_sort(int arr[], int sz)
{
int flag = 1;
for (int i = 0; i < sz - 1; i++)
{
flag = 1; // 每趟开始前重置:假设已经有序
for (int j = 0; j < sz - 1 - i; j++) // 修正:已排好的部分不再比较
{
if (arr[j] > arr[j + 1])
{
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
flag = 0; // 发生了交换,说明还没完全有序
}
}
if (flag == 1) // 一整趟都没有交换,已经有序,提前退出
{
break;
}
}
}
6.2 数组初始化语法错误
修改前:
int arr [10] = 0; // 语法错误!不能用单个值初始化数组
修改后:
int arr[10] = {0}; // 正确:所有元素初始化为 0
原因:C 语言中数组初始化必须使用花括号 {}。int arr[10] = {0} 会将第一个元素设为 0,其余元素自动初始化为 0。
6.3 函数名拼写错误
修改前:
int mian() // 拼写错误
修改后:
int main() // 正确
这是 C 语言中最经典的拼写错误之一,编译器会报错 undefined reference to 'main',因为链接器找不到 mian 这个入口函数。
7. 小结
指针的核心就两件事:地址存了什么、跳一步走多远。 把这两件事搞明白,再配合 const 做约束、assert 做防御、数组退化做理解,指针就从「最怕的知识点」变成「最趁手的工具」。
本文基于 C 语言指针基础复习代码整理,涵盖指针声明、指针运算、const 修饰、数组与指针关系、函数传参等核心内容。
209



被折叠的 条评论
为什么被折叠?



