Appearance
指针上:地址与间接访问
预计学习时长:约 4 小时(仅 L1 主线,建议分 2~3 次完成)· 运行环境:VS2026 / gcc / clang 均可
🎯 学习目标
学完本讲,你将能够:
- 说清"内存单元、地址、指针"三者的关系,记住 32/64 位平台下指针分别占 4/8 字节
- 用
&取地址、用指针变量存地址、用*解引用读写指向的变量 - 说出指针类型的两个作用:决定解引用一次访问几字节、决定
±1走多远 - 正确使用
const修饰指针;识别野指针的三种成因,会用NULL和assert做防护 - 区分数组名的两个例外,用指针遍历数组,说出一维数组传参的本质
- 独立写出冒泡排序(含"一趟无交换提前结束"的优化版)
- 看懂二级指针和指针数组,并用指针数组模拟二维数组
🔗 先修知识
- 第 7 讲:数组:本讲大量用数组做指针实验
- 第 8 讲:函数:形参、实参、传值调用是理解传址调用的前提
- 第 10 讲:字符串和字符串函数:
\0与strlen在指针相减中出场
先修自检(三问)
- 数组在内存中是怎么存放的?元素个数公式
sizeof(arr)/sizeof(arr[0])是怎么来的? - 函数里修改形参,会影响 main 里的实参吗?为什么?
scanf("%d", &x);里的&是干什么用的?
答不上来先快速复习,再回来继续。
一、内存和地址
1.1 每个字节都有一个编号
想象一栋宿舍楼:如果 100 个房间全都没有门牌号,朋友找你只能挨个房间敲门;一旦给房间编上 101、102、201……这样的号码,凭号码就能直接找到房间。
计算机内存也是同样的设计。CPU 读写数据都要在内存中进行,为了能快速定位,内存被划分为一个个内存单元,每个单元的大小是 1 个字节,每个字节都有一个唯一编号。
常见的容量单位:
| 单位 | 换算关系 |
|---|---|
| bit(比特) | 存放一个 0 或 1 |
| Byte(字节) | 1 Byte = 8 bit |
| KB | 1 KB = 1024 Byte |
| MB | 1 MB = 1024 KB |
| GB | 1 GB = 1024 MB |
| TB | 1 TB = 1024 GB |
这个编号就是生活中说的地址,在 C 语言里它还有一个专门的名字——指针。所以:
内存单元的编号 == 地址 == 指针,三者只是同一件东西的不同叫法。
1.2 为什么地址有 4 字节和 8 字节之分
编号需要多少个二进制位才能覆盖整个内存,取决于系统能表示多少个地址:
- 32 位平台:一个地址用 32 个 bit 表示,需要 4 字节存放,最多编址 2³² ≈ 4 GB
- 64 位平台:一个地址用 64 个 bit 表示,需要 8 字节存放
📘 提高(L2):地址总线、虚拟地址是怎么回事
CPU 和内存之间靠一组叫做地址总线的线路传递地址信号。32 根线每根只有 0/1 两种状态,组合起来就是 2³² 种地址;64 根线同理。地址送到内存后,对应字节里的数据再通过数据总线传回 CPU。
我们在程序里看到的地址其实是操作系统给的虚拟地址:每个进程都以为自己独占整套地址空间,由操作系统和 CPU 负责把虚拟地址映射到真实的内存条上。所以即使两台电脑内存大小不同,程序里地址的长度也只取决于"程序是 32 位还是 64 位编译的"。
二、指针变量:把地址存起来
2.1 用 & 取出地址
创建变量就是向内存申请空间。下面的程序用取地址操作符 & 拿到变量 a 的地址,并用 %p 打印出来:
📄 01_address.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int a = 10;
printf("a 的值是:%d\n", a);
printf("a 的地址是:%p\n", (void *)&a);
return 0;
}一次运行结果(每次运行地址可能不同,属正常现象):
text
a 的值是:10
a 的地址是:00000057e4bffb3ca 占 4 个字节、对应 4 个地址,&a 取出的是其中地址最小的那个字节的地址。知道起点、再知道类型是 int,往后连续 4 个字节就是 a 的全部内容。
注意 %p 在标准里要求参数是 void*,所以代码里写了 (void *)&a——这是个顺手就能养成的好习惯,VS 和 clang 下更严格。
2.2 用指针变量保存地址,用 * 使用地址
地址是一个数值,可以像其他数据一样存进变量。专门存放地址的变量叫指针变量:
📄 02_pointer_deref.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int a = 100;
int *pa = &a; // 把 a 的地址存进指针变量 pa
printf("改之前:a = %d\n", a);
*pa = 0; // 解引用:顺着 pa 里的地址找到 a,把它改成 0
printf("改之后:a = %d\n", a);
return 0;
}运行结果:
text
改之前:a = 100
改之后:a = 0两个关键符号:
int *pa = &a;中的*表示 pa 是一个指针变量;它前面的int表示 pa 指向的对象是 int 类型*pa = 0;中的*是解引用操作符:顺着地址找到它指向的那块空间。*pa在这里就是a本身
也就是说,现在修改 a 有了两条路:直接写 a = 0;,或者写 *pa = 0;。这种"间接操作"在函数传参时会非常有用。
📘 提高(L2):* 写在谁旁边?一行两个变量的坑
int* pa;、int *pa;、int * pa; 三种写法完全等价,* 只是在声明里表示"这是个指针"。团队协作时挑一种风格保持一致即可。
但要小心一行声明多个变量:int* p, q; 中只有 p 是指针,q 只是普通 int——* 修饰的是紧跟它的那个变量,而不是类型名。想两个都要写成指针,得写 int *p, *q;。
2.3 指针变量占多大
📄 03_pointer_size.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
printf("char* 占 %zu 字节\n", sizeof(char *));
printf("short* 占 %zu 字节\n", sizeof(short *));
printf("int* 占 %zu 字节\n", sizeof(int *));
printf("double* 占 %zu 字节\n", sizeof(double *));
return 0;
}64 位编译时的运行结果:
text
char* 占 8 字节
short* 占 8 字节
int* 占 8 字节
double* 占 8 字节结论(🟡 大小取决于编译目标平台):
- 32 位平台下任何指针都占 4 字节
- 64 位平台下任何指针都占 8 字节
- 指针的大小和它指向什么类型无关,只和平台有关
三、指针类型的意义
既然所有指针大小都一样,为什么还要区分 char*、int*、double*?因为指针类型有两个实实在在的作用:决定解引用的权限和决定走一步的距离。
3.1 作用一:解引用一次能访问几字节
📄 04_deref_permission.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int n = 0x11223344;
int *pi = &n;
char *pc = (char *)&n;
printf("最初: n = %#x\n", n);
*pi = 0; // int* 解引用:一次操作 4 个字节
printf("int* 改完: n = %#x\n", n);
n = 0x11223344;
*pc = 0; // char* 解引用:一次只操作 1 个字节
printf("char* 改完: n = %#x\n", n);
return 0;
}本机运行结果(小端字节序,详见下方说明):
text
最初: n = 0x11223344
int* 改完: n = 0
char* 改完: n = 0x11223300int* 一把改了全部 4 个字节;char* 只改了地址最低的那 1 个字节,所以最低两位变成 00,高位原样保留。这就是"解引用权限":类型决定了一次能读写几个字节。
3.2 作用二:指针 ±1 走多远
📄 05_pointer_step.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int n = 10;
char *pc = (char *)&n;
int *pi = &n;
printf("pc = %p\n", (void *)pc);
printf("pc + 1 = %p\n", (void *)(pc + 1));
printf("pi = %p\n", (void *)pi);
printf("pi + 1 = %p\n", (void *)(pi + 1));
printf("char* 走一步跨过 %td 字节\n", (char *)(pc + 1) - pc);
printf("int* 走一步跨过 %td 字节\n", (char *)(pi + 1) - (char *)pi);
return 0;
}一次运行结果:
text
pc = 00000057e4bffb34
pc + 1 = 00000057e4bffb35
pi = 00000057e4bffb34
pi + 1 = 00000057e4bffb38
char* 走一步跨过 1 字节
int* 走一步跨过 4 字节指针 ±1 不是地址简单加 1,而是跳过一个"它所指向的元素":char* 跳 1 字节,int* 跳 4 字节,double* 跳 8 字节。这个设计正好与数组配套——p + i 就天然指向数组的第 i 个元素。
3.3 特殊的 void*:无类型指针
void* 是一种"暂时不带类型信息"的指针,可以接收任何类型的地址,且编译器不报错;但它既不能解引用、也不能加减整数。
📄 06_void_pointer.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int a = 10;
void *p = &a; // OK:任何地址都能放进来
/* 因为 p 没有元素大小信息,下面两行都不能编译:
*p = 20;
p + 1;
*/
*(int *)p = 20; // 转回具体类型后才能使用
printf("转回 int* 使用后:a = %d\n", a);
return 0;
}void* 的典型用途是作为函数参数,接收不同类型数据的地址(比如标准库的 qsort),这样一个函数就能处理多种类型。下一讲的回调函数和 qsort 会专门用到它。
📘 提高(L2):为什么 void* 不能加减、不能解引用
p + 1 的步长要靠"指向元素的大小"决定,而 void* 压根没有这个信息;*p 要读写也得知道读几个字节。想用时必须先强转回具体类型,如 *(int *)p。另外注意:C 语言允许 void* 和其他指针类型隐式互转,但把文件存成 .cpp 用 C++ 编译时必须显式强转,否则 VS 报 C2440。
四、指针的三种运算
4.1 指针 ± 整数:遍历数组的基础
数组在内存中连续存放,有了首元素地址就能顺藤摸瓜找到所有元素:
📄 07_visit_array.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int *p = &arr[0];
int sz = sizeof(arr) / sizeof(arr[0]);
for (int i = 0; i < sz; i++)
{
printf("%d ", *(p + i));
}
printf("\n");
return 0;
}4.2 指针 − 指针:中间隔了几个元素
两个同数组指针相减,结果是它们之间元素的个数(类型是有符号的 ptrdiff_t,用 %td 打印)。经典应用是手写一个 strlen:
📄 08_my_strlen.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <assert.h>
size_t my_strlen(const char *s)
{
assert(s != NULL); // 使用前先断言,防止 NULL 传进来
const char *p = s;
while (*p != '\0')
{
p++;
}
return (size_t)(p - s); // 终点 - 起点 = 字符个数
}
int main(void)
{
printf("\"abc\" 长度 = %zu\n", my_strlen("abc"));
printf("\"hello\" 长度 = %zu\n", my_strlen("hello"));
return 0;
}运行结果:
text
"abc" 长度 = 3
"hello" 长度 = 5📜 标准卡(L2 选读):指针相减的前提
- 标准依据:C11 §6.5.6p9——两个指针相减,只有当它们指向同一个数组对象中的元素(或越过尾端一个位置)时结果才有定义,值为两个下标之差,类型为
ptrdiff_t - gcc:同数组外相减照常算数值,但语义已无保证
- MSVC:不做拦截,结果同样只按地址差计算
4.3 指针的关系运算:比大小
指针之间可以用 <、<=、> 比较地址先后,常用于循环条件:
📄 09_pointer_relation.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int *p = &arr[0];
int *end = &arr[0] + 10; // 指向"尾后"位置,只用来比较,不解引用
while (p < end)
{
printf("%d ", *p);
p++;
}
printf("\n");
return 0;
}📜 标准卡(L2 选读):"尾后指针"合法,但不能解引用
- 标准依据:C11 §6.5.6p8——指向数组元素的指针可以加/减整数,允许指向最后一个元素之后的那个位置,但对该位置解引用是 🔴 UB;再往外走一步同样是 🔴 UB
- gcc / MSVC:编译期不阻拦,运行时越界读写可能崩溃,也可能碰巧"看起来正常"
- 实战建议:循环边界统一写
p < arr + sz,永远不要写成p <= arr + sz后还解引用
五、const 修饰指针
5.1 先看一个"绕道改 const"的反面例子
const 修饰的变量不能直接改。但如果把它的地址硬塞进一个普通指针,编译器会发出警告,而修改动作本身是 🔴 未定义行为:
🧩 待改错片段 · 仅用于演示错误,不要模仿
c
#include <stdio.h>
int main(void)
{
const int n = 0;
int *p = &n; // 警告:丢弃 const 限定
*p = 20; // 🔴 UB:试图修改 const 对象
printf("n = %d\n", n);
return 0;
}gcc 的警告原文:
text
warning: initialization discards 'const' qualifier from pointer target type
[-Wdiscarded-qualifiers]📜 标准卡(L2 选读):为什么这是 UB
- 标准依据:C11 §6.7.3p6——如果通过一个不带 const 的左值,去修改定义时带 const 限定的对象,行为未定义
- gcc:给出 -Wdiscarded-qualifiers 警告后照常编译;运行可能"改成功",也可能读到原值(编译器可能把 const 优化成立即数)
- MSVC:报 C4090 警告;Release 优化下结果同样不可靠
要让别人拿到地址也改不了,就得让指针本身也带上 const。
5.2 const 在 * 的左边还是右边
const 相对 * 的位置不同,限制的对象完全不同:
| 写法 | 能改指向的内容吗 | 能让指针指向别处吗 |
|---|---|---|
int *p; | 能 | 能 |
const int *p; | 不能 | 能 |
int *const p; | 能 | 不能 |
const int *const p; | 不能 | 不能 |
口诀:const 在 * 左边,锁的是内容;在 * 右边,锁的是指针本身。
📄 10_const_positions.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int n = 10, m = 20;
const int *p1 = &n;
// *p1 = 99; // 编译错误:不能通过 p1 改内容
p1 = &m; // OK:指针本身可以指向别处
printf("p1 指向的值 = %d\n", *p1);
int *const p2 = &n;
*p2 = 99; // OK:可以通过 p2 改内容
// p2 = &m; // 编译错误:指针本身不能再指向别处
printf("n = %d\n", n);
const int *const p3 = &n;
// *p3 = 0; // 错误
// p3 = &m; // 错误
printf("p3 指向的值 = %d\n", *p3);
return 0;
}运行结果:
text
p1 指向的值 = 20
n = 99
p3 指向的值 = 99📘 提高(L2):const int *p 和 int const *p 是同一个类型
两种写法完全等价,const 修饰的都是 *p(即指向的内容)。标准库字符串函数的形参写成 const char *str,意思就是"我承诺只读取、不修改你传进来的字符串",阅读库函数原型时可以按这个思路理解。
六、野指针
野指针指的是指向位置不可知(随机、已失效、未申请)的指针。使用野指针是最常见的崩溃来源,它有三种典型成因。
6.1 成因一:指针未初始化
🧩 待改错片段 · 仅用于演示错误
c
#include <stdio.h>
int main(void)
{
int *p; // 局部指针变量未初始化,里面是随机地址
*p = 20; // 🔴 UB:程序可能直接崩溃,也可能悄悄破坏别的数据
return 0;
}gcc 的警告原文:
text
warning: 'p' is used uninitialized [-Wuninitialized]6.2 成因二:指针越界
🧩 待改错片段 · 仅用于演示错误
c
#include <stdio.h>
int main(void)
{
int arr[10] = {0};
int *p = arr;
for (int i = 0; i <= 11; i++)
{
*p = i;
p++; // i 等于 10、11 时,p 已经指向数组之外
}
return 0;
}6.3 成因三:指向的空间已经失效
🧩 待改错片段 · 仅用于演示错误
c
#include <stdio.h>
int *bad(void)
{
int n = 100;
return &n; // n 是局部变量,函数返回后空间即失效
}
int main(void)
{
int *p = bad();
printf("%d\n", *p); // 🔴 UB:p 指向已不属于自己的栈空间
return 0;
}gcc 的警告原文:
text
warning: function returns address of local variable [-Wreturn-local-addr]6.4 规避四条守则
- 指针必须初始化:明确指向哪就直接赋值;暂时不确定就赋
NULL - 小心越界:只访问申请过的空间,循环边界写
i < sz - 用完及时置
NULL,用前检查非空 - 不要返回局部变量的地址(想让结果活得比函数久,要用第 15 讲的动态内存)
📄 11_null_guard.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int num = 10;
int *p1 = #
int *p2 = NULL;
if (p1 != NULL)
{
printf("通过 p1 读到:%d\n", *p1);
}
if (p2 != NULL)
{
printf("通过 p2 读到:%d\n", *p2);
}
else
{
printf("p2 是空指针,已跳过\n");
}
return 0;
}📘 提高(L2):NULL 到底是什么
在 C 语言里 NULL 通常被定义为 ((void *)0),即地址 0(C++ 中则直接定义为整数 0)。地址 0 所在的页在现代操作系统里被故意设为不可访问,所以对 NULL 解引用会立刻崩溃——这反而是一种"及早暴露问题"的保护。判断空指针建议写 if (p != NULL),也可以简写 if (p) / if (!p)。
七、assert 断言
assert.h 提供的 assert(表达式); 会在运行时检查条件:条件为真就什么都不做;条件为假则打印表达式、文件名、行号并终止程序。
📄 12_assert_demo.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <assert.h>
int main(void)
{
int good = 5;
int *p = &good;
assert(p != NULL);
printf("第一个断言通过,*p = %d\n", *p);
p = NULL;
assert(p != NULL);
printf("这一行不会被打印\n");
return 0;
}运行结果(断言失败即中止):
text
第一个断言通过,*p = 5
Assertion failed: p != NULL, file 12_assert_demo.c, line 13assert 的好处是不用改业务逻辑就能定位"哪个条件在第几行不成立"。它属于"程序员自查"工具:Debug 阶段大量使用,把不应该发生的情况挡在门口。
📘 提高(L2):一行宏关闭所有 assert
在 #include <assert.h> 之前定义 #define NDEBUG,重新编译后本文件所有 assert 都会被预处理成空操作,运行时零开销。VS 的 Release 配置默认就等价于定义了 NDEBUG。注意 NDEBUG 只影响 assert,不要把真正的业务校验也寄托在它身上。
八、传值调用 vs 传址调用
8.1 交换两个变量:直觉写法为什么失败
📄 13_swap_by_value.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
void swap(int x, int y)
{
int tmp = x;
x = y;
y = tmp;
}
int main(void)
{
int a = 2023, b = 2024;
printf("交换前:a=%d, b=%d\n", a, b);
swap(a, b);
printf("交换后:a=%d, b=%d\n", a, b);
return 0;
}运行结果:
text
交换前:a=2023, b=2024
交换后:a=2023, b=2024没有交换!因为这是传值调用:实参 a、b 只是把自己的值复制一份给形参 x、y,x、y 是函数里独立的临时变量,在函数内部怎么交换都与 main 里的 a、b 无关。
8.2 传址调用:把地址交出去
📄 14_swap_by_address.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
void swap(int *px, int *py)
{
int tmp = *px;
*px = *py;
*py = tmp;
}
int main(void)
{
int a = 2023, b = 2024;
printf("交换前:a=%d, b=%d\n", a, b);
swap(&a, &b); // 把地址传进去
printf("交换后:a=%d, b=%d\n", a, b);
return 0;
}运行结果:
text
交换前:a=2023, b=2024
交换后:a=2024, b=2023这次成功了。把 a、b 的地址交给函数,函数内通过 *px、*py 操作的就是 main 里的变量本身,这叫传址调用。
选择规则:
| 场景 | 调用方式 |
|---|---|
| 函数只需要值来计算结果 | 传值 |
| 函数需要修改主调函数里的变量(如交换、带回多个结果) | 传址 |
九、数组名的两个例外
前面说过"数组名是首元素地址",但它有两个例外,这是笔试和实战的高频考点:
📄 15_array_name.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
printf("sizeof(arr) = %zu 字节\n", sizeof(arr));
printf("&arr[0] = %p\n", (void *)&arr[0]);
printf("arr = %p\n", (void *)arr);
printf("&arr = %p\n", (void *)&arr);
printf("arr + 1 = %p(向后 4 字节)\n", (void *)(arr + 1));
printf("&arr + 1 = %p(向后 40 字节)\n", (void *)(&arr + 1));
return 0;
}一次运行结果:
text
sizeof(arr) = 40 字节
&arr[0] = 00000057e4bffb10
arr = 00000057e4bffb10
&arr = 00000057e4bffb10
arr + 1 = 00000057e4bffb14(向后 4 字节)
&arr + 1 = 00000057e4bffb38(向后 40 字节)三个地址打印值相同,但 +1 的行为暴露了类型差异:
- 例外一:
sizeof(数组名)——数组名表示整个数组,算的是整个数组的字节数(10 个 int = 40),不是 4/8 - 例外二:
&数组名——取出的是整个数组的地址,类型是"指向数组的指针",所以&arr + 1一次跳过整个数组(40 字节) - 除此之外,任何位置的数组名都是首元素地址,
arr + 1只跳一个元素(4 字节)
十、用指针访问数组
既然数组名就是首元素地址,让指针从数组名出发,就能完成输入和输出。注意 p[i] 和 *(p + i) 完全等价——下标访问本质上就是"地址加偏移再解引用":
📄 16_pointer_io.c · ✅ 完整程序(可直接复制编译)
c
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
int main(void)
{
int arr[5] = {0};
int *p = arr;
printf("请输入 5 个整数:");
for (int i = 0; i < 5; i++)
{
scanf("%d", p + i); // 等价于 scanf("%d", &p[i]);
}
printf("你输入的是:");
for (int i = 0; i < 5; i++)
{
printf("%d ", p[i]); // 等价于 *(p + i)
}
printf("\n");
return 0;
}输入 3 7 2 9 4 后的运行结果:
text
请输入 5 个整数:3 7 2 9 4
你输入的是:3 7 2 9 4十一、一维数组传参的本质
数组传参时,传的是数组名,也就是首元素地址。所以函数形参本质上是个指针,函数内部无法据此得知数组长度:
📄 17_array_param.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
void test(int arr[])
{
int sz2 = sizeof(arr) / sizeof(arr[0]);
printf("函数里:sizeof(arr)=%zu,算出的元素个数 = %d\n",
sizeof(arr), sz2);
}
int main(void)
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int sz1 = sizeof(arr) / sizeof(arr[0]);
printf("main 里:sizeof(arr)=%zu,元素个数 = %d\n",
sizeof(arr), sz1);
test(arr);
return 0;
}64 位编译时的运行结果:
text
main 里:sizeof(arr)=40,元素个数 = 10
函数里:sizeof(arr)=8,算出的元素个数 = 2main 里 40/4 = 10;函数里 arr 已是指针,64 位下 sizeof 得 8,8/4 = 2(32 位下会得 1)。所以正确做法是:调用方把元素个数算好,作为另一个参数一起传进去。
📜 标准卡(L2 选读):形参里的数组为什么会变指针
- 标准依据:C11 §6.7.6.3p7——函数形参中写成"数组类型"的声明,会被调整为"指向该数组元素类型的指针"。所以
int arr[]、int arr[10]、int *arr作为形参时三者完全等价 - gcc / MSVC:行为一致,调整在编译期完成
- 注意:调整只发生在函数形参这一层,main 里真实的数组类型不受影响
十二、冒泡排序
冒泡排序的核心思想:相邻两个元素两两比较,顺序不对就交换,每一趟把当前最大的"冒泡"到末尾。
📄 18_bubble_basic.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
void bubble_sort(int arr[], int sz)
{
for (int i = 0; i < sz - 1; i++)
{
for (int j = 0; j < sz - 1 - i; j++)
{
if (arr[j] > arr[j + 1])
{
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
}
int main(void)
{
int arr[] = {3, 1, 7, 5, 8, 9, 0, 2, 4, 6};
int sz = sizeof(arr) / sizeof(arr[0]);
bubble_sort(arr, sz);
for (int i = 0; i < sz; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}运行结果:
text
0 1 2 3 4 5 6 7 8 9优化思路:如果某一趟一次交换都没发生,说明数组已经有序,没必要再跑后面的趟数,直接结束:
📄 19_bubble_best.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
void bubble_sort(int arr[], int sz)
{
for (int i = 0; i < sz - 1; i++)
{
int flag = 1; // 先假设本趟已经有序
for (int j = 0; j < sz - 1 - i; j++)
{
if (arr[j] > arr[j + 1])
{
flag = 0; // 发生交换,说明假设不成立
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
if (flag == 1)
{
printf("第 %d 趟没有交换,提前收工\n", i + 1);
break;
}
}
}
int main(void)
{
int arr[] = {1, 2, 3, 5, 4, 6, 7, 8, 9, 10};
int sz = sizeof(arr) / sizeof(arr[0]);
bubble_sort(arr, sz);
for (int i = 0; i < sz; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}运行结果:
text
第 2 趟没有交换,提前收工
1 2 3 4 5 6 7 8 9 10十三、二级指针
指针变量本身也是变量,它也有地址;存放"指针变量的地址"的指针,就是二级指针。
📄 20_double_pointer.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int a = 10;
int *pa = &a;
int **ppa = &pa; // ppa 存放 pa 的地址
printf("a = %d\n", a);
printf("*pa = %d\n", *pa);
printf("**ppa = %d\n", **ppa);
**ppa = 30; // 等价于 *pa = 30,即 a = 30
printf("执行 **ppa = 30 后,a = %d\n", a);
int b = 20;
*ppa = &b; // 等价于 pa = &b
printf("执行 *ppa = &b 后,*pa = %d, a = %d\n", *pa, a);
return 0;
}运行结果:
text
a = 10
*pa = 10
**ppa = 10
执行 **ppa = 30 后,a = 30
执行 *ppa = &b 后,*pa = 20, a = 30理解链条:*ppa 找到的是 pa;再解引用一次,**ppa 找到的才是 a。
十四、指针数组:用指针数组模拟二维数组
指针数组首先是数组,只不过每个元素都是指针。类比:整型数组存放整型,指针数组存放地址。
📄 21_pointer_array_2d.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int arr1[] = {1, 2, 3, 4, 5};
int arr2[] = {2, 3, 4, 5, 6};
int arr3[] = {3, 4, 5, 6, 7};
int *parr[3] = {arr1, arr2, arr3}; // 3 个元素,每个是 int*
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 5; j++)
{
printf("%d ", parr[i][j]);
}
printf("\n");
}
return 0;
}运行结果:
text
1 2 3 4 5
2 3 4 5 6
3 4 5 6 7parr[i] 取到第 i 个指针(即第 i 行数组的首地址),parr[i][j] 就是该行第 j 个元素。它用起来像二维数组,但并不是真正的二维数组:三行之间在内存中并不连续,每一行的长度也可以不同。
⚠️ 常见坑与报错(本讲汇总)
| 你看到的现象 | 原因 | 修法 |
|---|---|---|
| 程序闪退 / 0xC0000005 access violation | 解引用了野指针或 NULL | 指针初始化、用前判空、加 assert |
| warning: 'p' is used uninitialized | 局部指针没初始化就使用 | 明确赋值,暂时不用就 = NULL |
| warning: initialization discards 'const' qualifier | 把 const 对象地址交给普通指针 | 形参写成 const int * |
| warning: function returns address of local variable | 返回了局部变量的地址 | 不返回;需要持久结果用动态内存(第 15 讲) |
| 交换函数调用完变量没变 | 写成了传值调用 | 改成传址:传 &a,形参用指针 |
函数里 sizeof(arr) 得到 8(或 4) | 数组形参已被调整为指针 | 元素个数由调用方算好后另传参数 |
| char* 只改了一个字节,与预期不符 | 不是错误,是解引用权限不同 | 需要改 4 字节就用 int* |
| VS 报 C2440: 无法从 void* 转换 | 文件按 C++ 编译,C++ 不允许隐式转换 | 加显式强转 (int *)p |
| 程序运行到 assert 处中止 | 断言条件为假,属正常保护机制 | 根据行号检查逻辑;发布版用 NDEBUG 关闭 |
| 指针相减得到奇怪结果 | 两个指针不属于同一数组 | 只在同一数组内做相减和大小比较 |
🛠️ 动手练习
规则:先独立做,卡住了再依次展开提示,不要一上来就看答案。
📊 本讲网页练习进度0 / 8(0%)
进度自动保存在本浏览器;编程题不计数,请在编辑器中完成。
练习 1 · ⭐ 基础 · 选择判断
知识点:指针大小与平台
题干:64 位编译的程序中,int *p; sizeof(p) 的值是?
练习 2 · ⭐ 基础 · 选择判断
知识点:数组名的两个例外
题干:已有 int arr[10];,下列哪些写法中数组名代表整个数组?(多选)
练习 3 · ⭐ 基础 · 程序填空
知识点:指针步长
题干:阅读代码,填写三个空(只填数字,表示字节数)。
🧩 题意片段 · 只有声明,不需要 main
c
int arr[10];
int *p = arr;
char *c = (char *)arr;p + 3 的地址比 p 大 ____ 字节;c + 3 比 c 大 ____ 字节;&arr + 1 比 &arr 大 ____ 字节。
空①空②空③
练习 4 · ⭐⭐ 提高 · 选择判断
知识点:const 的位置
题干:已有 int a = 1, b = 2; const int *p = &a;,下列哪个操作能通过编译?
练习 5 · ⭐⭐ 提高 · 选择判断
知识点:野指针的识别
题干:关于野指针,下列哪些说法正确?(多选)
练习 6 · ⭐⭐ 提高 · 写小程序
知识点:指针防护 + 指针遍历
题干:实现一个安全版 my_strlen:传入 NULL 时返回 0(不崩溃),正常字符串返回 \0 之前的字符数。
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
实现一个安全版 my_strlen:传入 NULL 时返回 0(不崩溃),正常字符串返回 \0 之前的字符数。
✅ 过关标准:my_strlen("hello") 返回 5;my_strlen("") 返回 0;my_strlen(NULL) 返回 0 且程序不崩溃。
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
💡 提示 1(思路)
函数内部第一件事就是判断 s == NULL,成立直接 return 0;,把"不能碰的地址"挡在循环外面。正常情况下用指针从头走到 \0,边走边计数。
🧭 提示 2(步骤)
- 定义
size_t my_strlen(const char *s) - 先做 NULL 判断
- 定义计数器
size_t count = 0; while (*s != '\0') { count++; s++; }- 返回 count;main 里用三组数据测试
🔑 参考答案与解释
c
#include <stdio.h>
size_t my_strlen(const char *s)
{
if (s == NULL)
{
return 0;
}
size_t count = 0;
while (*s != '\0')
{
count++;
s++;
}
return count;
}
int main(void)
{
printf("%zu\n", my_strlen("hello"));
printf("%zu\n", my_strlen(""));
printf("%zu\n", my_strlen(NULL));
return 0;
}这里选择"温和返回 0"而不是 assert:题目要求 NULL 也不崩溃。实际开发中两种策略的选择取决于——NULL 到底是"预期内的合法输入"还是"绝不应该发生的程序员错误",前者判空返回,后者用 assert 拦截。
练习 7 · ⭐⭐ 提高 · 写小程序
知识点:传址调用带回多个结果
题干:写一个函数,在一次调用中同时找出整型数组的最大值和最小值,通过两个指针参数带回 main。
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
写一个函数,在一次调用中同时找出整型数组的最大值和最小值,通过两个指针参数带回 main。
✅ 过关标准:对数组 {3, 7, 2, 9, 4, 1},main 中打印 最小=1, 最大=9;结果必须由被调函数通过指针写出,不能只用 return。
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
💡 提示 1(思路)
return 一次只能带回一个值,而题目要两个结果——这正是传址调用的用武之地:main 准备两个普通变量,把它们的地址传进去,函数内部通过指针直接写这两个变量。
🧭 提示 2(步骤)
- 函数形参:数组、长度、
int *pmin、int *pmax - 先把
*pmin和*pmax都初始化为arr[0] - 从第 2 个元素起遍历:比
*pmin小就写*pmin,比*pmax大就写*pmax - 想更稳可以先 assert 三个指针非空
🔑 参考答案与解释
c
#include <stdio.h>
#include <assert.h>
void find_min_max(const int *arr, int n, int *pmin, int *pmax)
{
assert(arr != NULL && pmin != NULL && pmax != NULL);
*pmin = arr[0];
*pmax = arr[0];
for (int i = 1; i < n; i++)
{
if (arr[i] < *pmin)
{
*pmin = arr[i];
}
if (arr[i] > *pmax)
{
*pmax = arr[i];
}
}
}
int main(void)
{
int arr[] = {3, 7, 2, 9, 4, 1};
int n = sizeof(arr) / sizeof(arr[0]);
int mn, mx;
find_min_max(arr, n, &mn, &mx);
printf("最小=%d, 最大=%d\n", mn, mx);
return 0;
}这种"输出参数"模式在 C 标准库里很常见(比如 fread、sscanf 都靠指针带出额外结果),记住这个套路:一个函数需要交出多个结果时,就让调用方把存放位置的地址传进来。
练习 8 · ⭐⭐⭐ 挑战 · 写小程序
知识点:指针数组 + 字符串比较
题干:把 3 个字符串放进一个指针数组,用冒泡排序的思路只交换指针,让它们按字典序排列后逐行输出。
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
把 3 个字符串放进一个指针数组,用冒泡排序的思路只交换指针,让它们按字典序排列后逐行输出。
✅ 过关标准:初始为 {"banana", "apple", "cherry"},输出依次为 apple、banana、cherry;排序过程中不得移动字符串内容,只交换数组里的指针。
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
💡 提示 1(思路)
字典序比较直接用 <string.h> 里的 strcmp:返回值大于 0 表示第一个字符串"更大"。交换的不是字符内容,而是数组中两个 const char * 指针——交换后数组元素指向的先后顺序就变了。
🧭 提示 2(步骤)
- 定义
const char *strs[3] = {...}; - 套两层冒泡循环,比较条件换成
strcmp(strs[j], strs[j+1]) > 0 - 交换时用临时变量
const char *tmp = strs[j]; - 最后遍历数组,用
%s输出每个指针指向的字符串
🔑 参考答案与解释
c
#include <stdio.h>
#include <string.h>
int main(void)
{
const char *strs[3] = {"banana", "apple", "cherry"};
for (int i = 0; i < 2; i++)
{
for (int j = 0; j < 2 - i; j++)
{
if (strcmp(strs[j], strs[j + 1]) > 0)
{
const char *tmp = strs[j];
strs[j] = strs[j + 1];
strs[j + 1] = tmp;
}
}
}
for (int i = 0; i < 3; i++)
{
printf("%s\n", strs[i]);
}
return 0;
}只交换指针是处理字符串排序的标准做法:字符串本体可能很长,搬动字符又慢又需要额外空间;而交换指针只是交换两个地址,代价恒定且不碰原数据。
📝 小结与自测
本讲核心清单:
- 地址(指针)是内存字节的编号;32 位平台指针 4 字节、64 位 8 字节,与类型无关
&取地址,*在声明中表示指针、在代码中表示解引用- 指针类型两作用:解引用访问的字节数、±整数的步长;
void*可接收任意地址但不能直接运算 - 指针三运算:±整数、同数组指针相减(
ptrdiff_t)、关系运算;尾后位置可比不可解引用 - const 在
*左锁内容、在*右锁指针;修改 const 对象是 🔴 UB - 野指针三成因:未初始化、越界、空间失效;用初始化、NULL、判空、assert 来防
- 数组名两例外:
sizeof(数组名)和&数组名代表整个数组 - 一维数组传参退化为首元素地址,长度要另传;冒泡排序相邻比较,可加 flag 提前结束
- 二级指针操作的是指针变量;指针数组是"装着指针的数组",可模拟不规则的二维结构
自测三问(答完再往下):
- 如果函数只想读取一个数组、不修改它,形参怎么写最能表达这个意图?
int arr[5];中sizeof(arr)、sizeof(arr+0)、sizeof(&arr)分别是多少?- 为什么
while (p <= arr + sz)配合解引用是危险写法?
🔑 点击核对答案
- 写成
const int *arr(或同时带长度参数):const 向调用方承诺不修改内容。 - 64 位下依次为 20、8、8(
arr在 sizeof 里代表整个数组;arr+0已是指针;&arr是数组指针,其大小仍是一个指针的大小);32 位下后两个为 4。 - 当
p == arr + sz时 p 指向尾后位置,循环体内再*p就是 🔴 UB;正确写法是p < arr + sz。
🔬 选学拓展(L3)
以下内容超出主线要求
看懂 L1/L2 即可放心进入下一讲。本小节是一次"满足好奇心"的小实验,不影响后续学习。
动手观察:栈在往哪个方向生长
函数里的局部变量都放在叫做**栈(stack)**的内存区域。每调用一层函数,栈就向"地址更小"的方向扩展一段;同一函数内先定义的变量地址通常更高。下面这个程序可以让你亲眼看到这个规律:
📄 22_stack_growth.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
void foo(void)
{
int x = 1;
int y = 2;
printf("foo 里:x 在 %p, y 在 %p\n", (void *)&x, (void *)&y);
}
int main(void)
{
int a = 1;
int b = 2;
int arr[3] = {0};
printf("main 里:a 在 %p, b 在 %p\n", (void *)&a, (void *)&b);
printf("数组元素:%p %p %p\n",
(void *)&arr[0], (void *)&arr[1], (void *)&arr[2]);
foo();
return 0;
}一次运行结果:
text
main 里:a 在 00000005313ff69c, b 在 00000005313ff698
数组元素:00000005313ff68c 00000005313ff690 00000005313ff694
foo 里:x 在 00000005313ff64c, y 在 00000005313ff648可以观察到三个现象:数组元素地址逐个增大(元素向上排列);main 的局部变量地址高;被调用的 foo 里局部变量地址明显更低(函数调用让栈向低地址方向生长)。
需要强调(🟡):变量在栈上的具体摆放顺序是编译器决定的实现细节,C 标准不做规定,不同编译器、不同优化等级下结果都可能不同。这个实验的价值是建立直观感受,而不是把"后定义的变量地址一定更小"写进你的代码逻辑里。