你有没有想过,当你写下 int a = 10; 这行代码的时候,那个数字 10 到底被放在了什么地方?计算机的内存就像一栋巨大的宿舍楼,每个房间(字节)都有一个独一无二的门牌号。当你定义一个变量,系统就在这栋楼里给你分配一个"房间"——那个门牌号,就是内存地址。
C 语言给了你直接操控这个门牌号的能力。用来存放门牌号的变量,叫指针变量。普通变量存的是数据本身,指针变量存的是"数据住在哪个房间"。这个概念听起来玄乎,但其实你每天都在和它的同类打交道:快递单上的收货地址、手机通讯录里的电话号码、图书馆书架上的索书号……本质上全都是"指针"——它们不存放实际的内容,而是告诉你内容在哪里。
如果说C语言有一个概念最让人头疼,那毫无疑问就是指针。但其实指针本身不神秘——它不过是一串数字,一个内存编号而已。真正让人困惑的,往往是"没人告诉你底层到底发生了什么"。这篇文章从零开始,把内存的组织方式、指针变量的声明和使用、以及指针运算的底层原理逐一讲清楚。文章会比入门教程长一些,因为每个概念我都会从"是什么"讲到"底层为什么",还会配内存图和练习——建议你边读边把代码敲进编译器,亲眼看看地址长什么样。
内存和地址
先说一个生活场景。假设你住在大学宿舍楼里,这栋楼有上百个房间。如果每个房间都没有编号,你的朋友来找你,就只能一间一间去敲门,效率极低。但如果学校给每个房间编了号——101、102、103……那么你朋友拿着"301"这个号码,就能直奔三楼第一间。
计算机的 CPU 面对的是同样的困境。8GB 的内存就像一栋超级大的宿舍楼,如果所有内存单元都没有编号,CPU 就没法快速找到它需要的数据。解决的办法是把内存切成一个个内存单元,每个单元恰好是1 个字节,然后给每个单元编上一个号——这个编号就是地址。1 个字节等于 8 个比特位,你可以把一个字节想象成 8 人间的宿舍,每个比特位对应一个同学。
这里有一个细节值得多说两句:为什么最小编址单位是"字节"而不是"比特位"?从硬件成本上讲,每个比特都编址会让地址线爆炸式增加(8GB 内存 = 640 亿个比特,全编址需要 36 根以上的地址线还不够);从使用上讲,程序员操作的最小可寻址数据通常是字符(ASCII 字符恰好 1 字节),按字节编址足够用了。所以 C 语言里 sizeof(char) == 1 恒成立,而 sizeof(int) 是 4——这正是"以字节为单位编址"的体现。
CPU 和内存之间有一组物理导线叫地址总线。以 32 位机器为例,有 32 根地址总线,每根线只有"通电"(1)或"断电"(0)两种状态。32 根线组合起来能产生 2³² = 4,294,967,296 种不同的信号,每种信号对应一个地址——所以 32 位 CPU 最多能管理 4GB 内存。这就像吉他的琴弦上没有贴着"Do Re Mi"的标签,但演奏者知道哪根弦对应哪个音——硬件制造时就已经约定好了。在C语言的语境下,内存单元的编号 == 地址 == 指针,这三个词说的是同一件事。
补充一个"内存有多大"的直观数字:32 位机器上地址的范围是 0 ~ 2³²-1(0x00000000 ~ 0xFFFFFFFF),一共约 42.9 亿个字节地址,即 4GB。64 位机器理论上可寻址 2⁶⁴ 字节——这个数字大到根本用不完(约 1800 万 TB),所以 64 位程序里地址通常显示为 16 位十六进制(前导 0 补齐)。
谈到"多少 GB 内存",顺便把计算机的存储单位完整捋一遍。最小的单位是比特位(bit),一个比特位只能存一个二进制位(0 或 1);8 个比特位组成 1 个字节(Byte)。字节之上每进一级都是 1024 倍(2¹⁰):
| 单位 | 换算 |
|---|---|
| 1 Byte(字节) | 8 bit(比特位) |
| 1 KB | 1024 Byte |
| 1 MB | 1024 KB |
| 1 GB | 1024 MB |
| 1 TB | 1024 GB |
| 1 PB | 1024 TB |
平时说的"8GB 内存",就是 8 × 1024 × 1024 × 1024 个字节。进率为什么是 1024 而不是 1000?因为计算机底层是二进制的,2¹⁰ 恰好等于 1024,按 1024 进级正好是整数运算。硬盘厂商喜欢按 1000 标称("1TB 硬盘"实际是 10¹² 字节 ≈ 931GB),这也是为什么你买的 1TB 硬盘插上电脑只显示 931GB——不是缩水,是换算标准不同。
来看一段最简单的代码,验证一下地址到底长什么样:
#include <stdio.h>
int main()
{
int a = 10; // 定义一个整型变量,内存申请4字节存放10
printf("%p\n", &a); // %p是专门用来打印地址(指针)的格式说明符
return 0;
}
// 可能的输出:006FFD70 (具体值每次运行可能不同)&a 就是取变量 a 的地址。注意 %p 是打印地址的专用格式,不要用 %d 或 %x 替代——虽然在某些平台上也能打出数字,但类型不匹配是未定义行为。
这里有个 GCC 与 MSVC 的差异要说明:标准规定 %p 对应的参数类型是 void*。GCC 对 printf("%p", &a)(&a 是 int*)不报警告,但 MSVC 会报 "format '%p' expects argument of type 'void*'"。稳妥的写法是显式强转:
printf("%p\n", (void *)&a); // ✅ 跨编译器都规范另外注意,同一段代码每次运行打印的地址可能不同——现代操作系统有地址空间随机化(ASLR)机制,每次启动程序,栈的基址都可能变化。所以千万不要把地址当成"固定值"去依赖。
这里有一个贯穿全文的核心认知:当你写 int a = 10; 时,这个 10 的十六进制是 0x0A,占 4 个字节(假设是 32 位环境)。&a 取到的是这 4 个字节中地址最小的那个。内存中看起来是这样的(假设小端序):
内存地址 存储内容
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
0x00000000 │ ... │
... (低位地址)
0x006FFD70 │ 0A │ ← 变量a的第1个字节(低地址)
0x006FFD71 │ 00 │ ← 变量a的第2个字节
0x006FFD72 │ 00 │ ← 变量a的第3个字节
0x006FFD73 │ 00 │ ← 变量a的第4个字节(高地址)
... (高位地址)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
先解释一下这张图里的"小端序"(Little-Endian):像 0x0000000A 这样的 4 字节数,内存里低地址放低位字节、高地址放高位字节。所以 0A(最低字节)排在低地址 0x006FFD70,三个 00 依次排在高地址。x86 和 ARM 的主流模式都是小端。反过来"高地址放低位字节"叫大端序(Big-Endian),常见于网络协议(TCP/IP 头就是大端)。后面讲指针类型、字节操作时会反复用到小端序这个概念。
&a 取到的 0x006FFD70 是 4 个字节中最低的那个地址。记住:多字节变量取地址,拿到的是它最低字节的地址。
思考题(内存和地址)
- 为什么 32 位 CPU 最大只能寻址 4GB 内存?(提示:2³² 个地址 × 1 字节 = 4GB)
- 地址是 16 进制数,那么
0x006FFD70换算成十进制大约是多少? - 小端序下,
int n = 0x11223344;在内存里从低地址到高地址依次是什么?(答案:44 33 22 11)
参考答案与详解(内存和地址)
- 推导 4GB:32 根地址总线,每根只有 0/1 两种状态,能组合出 2³² 种不同的信号,即可区分 2³² = 4,294,967,296 个内存单元;每个单元 1 字节,所以最大可管理内存 = 2³² × 1 字节 = 4,294,967,296 字节 = 4 GB。这是硬件上限——超过 4GB 的地址,32 根线根本表达不出来。而 64 位机器是 2⁶⁴ 字节,约 1.8×10¹⁹ 字节(≈1800 万 TB),所以理论上用不完。
0x006FFD70的十进制值:从低位往高位逐位乘以 16 的幂再累加:0x006FFD70 = 6×16⁵ + F×16⁴ + F×16³ + D×16² + 7×16¹ + 0= 6×1048576 + 15×65536 + 15×4096 + 13×256 + 7×16= 6291456 + 983040 + 61440 + 3328 + 112 = 7,339,376所以大约是 734 万。这个数字离"4GB≈42.9 亿"还差很远——因为这是栈上一个临时低地址,印证了地址是运行时才分配、每次都可能不同。- 小端序排列:
0x11223344中最高字节是0x11、最低字节是0x44。小端序的规则是"低地址放低位字节",所以从低地址到高地址依次是44 33 22 11(最低字节0x44在最靠前的低地址,最高字节0x11在末尾)。大端序才是反过来11 22 33 44。这就是 x86 调试窗口里看到整型字节是倒着排的原因。
指针变量和地址
既然有了"取地址"的能力,那把取到的地址存起来,以后反复使用,就是一件很自然的事。专门用来存放地址的变量,叫指针变量。
#include <stdio.h>
int main()
{
int a = 10; // 普通int变量
int *pa = &a; // pa是指针变量,存放a的地址
// ↑
// * 表示"pa是一个指针"
// int 表示"pa指向一个int类型的数据"
printf("a的地址: %p\n", pa); // pa的值就是a的地址
printf("a的地址: %p\n", &a); // 和上面打印的一样
printf("pa自己的地址: %p\n", &pa); // pa本身也是一个变量,也有自己的地址
return 0;
}这段代码同时印证了三件事:pa 里存的就是 &a;&a 和 pa 打印出来数值相同;指针变量 pa 自己也有地址 &pa——它也是一个普通变量,只是内容特殊。
那是不是"什么地址都能放进同一个指针变量"?不是。不同类型的对象,地址要放进不同类型的指针变量——* 前面的类型,就是说这个指针"打算指向什么类型的数据"。int 变量的地址放进 int*,char 变量的地址放进 char*,double 变量的地址放进 double*:
#include <stdio.h>
int main()
{
char c = 'w'; // 字符变量
double d = 3.14; // double 变量
char *pc = &c; // c 的地址要放进 char* 指针
double *pd = &d; // d 的地址要放进 double* 指针
printf("%c\n", *pc); // 通过 pc 取到字符 c,输出 w
printf("%.2f\n", *pd); // 通过 pd 取到 double 值,输出 3.14
return 0;
}
// 输出:
// w
// 3.14类型匹配的意义后面会越放越大:指针的类型决定了能不能解引用、解引用能读写几个字节、加减整数一步跨多远。所以一开始就养成"什么类型的数据,就用什么类型的指针去存它的地址"的习惯,能帮你避开大量隐患。
声明指针有几种写法效果一样:int *pa;、int* pa;、int * pa;。我推荐 int *pa;,因为在一条语句中声明多个变量时更直观——int *p, q; 中只有 p 是指针,q 只是普通的 int,很多初学者会误以为两个都是指针。用 int* p, q; 的写法更容易犯这个错。
指向了地址之后,怎么通过地址去操作实际的数据呢?这就用到解引用操作符 *:
#include <stdio.h>
int main()
{
int a = 100;
int *pa = &a; // pa存放a的地址
*pa = 0; // 通过pa找到a,把a改成0
// ↑
// 解引用操作符:通过地址访问实际数据
printf("a = %d\n", a); // 输出:a = 0
return 0;
}你可以把 *pa 理解为"pa 指向的那个东西"。在这个例子里,*pa 完全等价于 a 这个变量本身。你可能会问:我直接写 a = 0; 不更方便吗?为什么绕一圈?答案很简单——在函数传参、动态内存分配等场景下,你根本拿不到变量名,只能通过指针来间接访问。* 在声明中和在语句中的含义不同:声明中的 * 表示"这是一个指针类型",语句中的 * 表示"解引用"。同一个符号,两个身份。
* 和 & 是互逆操作
这一点值得单独停下来消化,因为它是理解指针的核心枢纽:*(解引用)和 &(取地址)互为逆运算。
- 对变量取地址再解引用,回到变量本身:
*(&a) == a - 对指针解引用再取地址,回到指针存的那个地址:
&(*pa) == pa
#include <stdio.h>
int main()
{
int a = 10;
int *pa = &a;
// 取地址再解引用:绕了一圈回到 a 本身
printf("%d\n", *(&a)); // 10,等价于直接写 a
// 解引用再取地址:回到 pa 里的地址值
printf("%p\n", (void *)&(*pa)); // 和 &a 相同
printf("%p\n", (void *)pa); // 和上面相同
return 0;
}打个比方:& 是"这个快递的运单号是多少",* 是"拿着运单号去取这个快递"。& 从对象拿地址,* 从地址拿对象——一来一回,恰好抵消。后面讲数组、结构体时会发现,所有复杂的指针表达式,本质上都是在玩"取地址/解引用"这两个动作的排列组合。你能熟练互换这两者,指针就算入门一半了。
为什么需要指针:三个必答的理由
经常有同学问:"我直接操作变量不就好了,为什么非要用指针?"这个问题很重要,值得正面回答。指针解决的是间接访问的需求,主要有三个场景:
- 函数要修改调用者的变量。C 语言传参是传值(拷贝),函数内部的形参改动不影响实参。想让函数真正修改外部变量,只能把地址传进去,让函数通过指针找到原变量(这就是下一讲的"传址调用",
scanf("%d", &a)就是典型)。 - 动态内存分配。
malloc在堆上申请的内存没有变量名,只能通过返回的指针来访问。没有指针,动态内存、链表、树这些数据结构全都无从谈起。 - 传递大型数据时避免拷贝。一个 1MB 的结构体,传值会整个拷贝一遍;传指针只传 8 字节地址,性能天差地别。
记住这句话:指针就是 C 语言实现"间接"的唯一手段。没有间接,就没有链表、没有回调、没有多态,也就没有系统编程。
指针变量本身也是一个变量,也需要占用内存空间。只不过它里面存的不再是普通数据,而是一个地址值。指针变量的大小取决于地址总线的宽度——和指向的类型无关:
#include <stdio.h>
int main()
{
// 指针变量的类型(char*、int*、double*)不影响大小
// 大小只取决于平台位数
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;
}在 32 位平台上,所有指针变量都是 4 字节;在 64 位平台上,所有指针变量都是 8 字节。char* 和 double* 的大小一模一样。这就引出一个关键问题:既然所有指针大小都一样,为什么还要有 int*、char*、double* 之分?
思考题(指针变量与地址)
int *pa = &a;这一行里,int、*、pa、&a各起什么作用?- 为什么
&a可以赋值给int*,但不能直接赋值给double*?(类型不匹配,编译器报警告/错误) - 一个指针变量占 8 字节(64 位平台),它能表示的最大地址是多少?(2⁶⁴-1)
参考答案与详解(指针变量与地址)
- 拆解
int *pa = &a;:int:说明pa"打算指向什么类型的数据"——指向一个int。它同时决定了以后对pa解引用、加减整数时的步长和行为。*:声明符号,表示"pa是一个指针变量",而不是普通变量。pa:变量名,它本身也占 4/8 字节,存的是一个地址值。&a:初始值,即a的地址,把这 4 个字节的起始地址存进pa。整句意思是"定义指针pa,让它一开始就指向a"。
- 类型匹配问题:
&a的类型是int*,int*只能放进int*变量里。double*指针声明的是"指向 double",把int*赋给它,编译器会因为类型不兼容报 warning/error。从语义上讲,指针类型决定了"解引用读几个字节、加减跳多远",把int的地址交给double*,会让编译器把int的 4 个字节误当 double 的 8 个字节去读。 - 最大可表示地址:64 位地址占 64 个二进制位,能表示 2⁶⁴ 个不同的值。地址是从 0 开始编号的,所以最大地址是
2⁶⁴ - 1,写成十六进制就是 16 个F(0xFFFFFFFFFFFFFFFF)。反过来,32 位平台就是2³² - 1。
指针变量类型的意义
指针的类型决定了两个关键行为:解引用时一次操作几个字节,以及指针加减整数时跳过多少字节。
先用代码感受一下"解引用权限"的差异:
#include <stdio.h>
int main()
{
int n = 0x11223344; // 用十六进制初始化,方便在内存中观察
int *pi = &n; // pi是int*类型指针
*pi = 0; // 解引用,把n改成0
printf("n = 0x%08X\n", n); // 输出:n = 0x00000000
return 0;
}
// 内存变化(小端序):
// 改之前:44 33 22 11 (4个字节)
// 改之后:00 00 00 00 (4个字节全部清零)如果换成 char* 指针呢?
#include <stdio.h>
int main()
{
int n = 0x11223344;
char *pc = (char *)&n; // 强制转为char*类型
*pc = 0; // 解引用,只改第一个字节
printf("n = 0x%08X\n", n); // 输出:n = 0x11223300
return 0;
}
// 内存变化:
// 改之前:44 33 22 11 (小端序)
// 改之后:00 33 22 11 (只有最低字节被清零)用内存图直观对比:
操作前 n = 0x11223344:
地址 低地址 → → → → → → → → 高地址
┌──────┬──────┬──────┬──────┐
│ 0x44 │ 0x33 │ 0x22 │ 0x11 │ ← 4个字节
└──────┴──────┴──────┴──────┘
int* 解引用后: *pi = 0
┌──────┬──────┬──────┬──────┐
│ 0x00 │ 0x00 │ 0x00 │ 0x00 │ ← 4个字节全部清零
└──────┴──────┴──────┴──────┘
char* 解引用后: *pc = 0
┌──────┬──────┬──────┬──────┐
│ 0x00 │ 0x33 │ 0x22 │ 0x11 │ ← 只有1个字节被清零
└──────┴──────┴──────┴──────┘
char* 解引用只访问 1 个字节,int* 解引用访问 4 个字节。指针的类型定义了解引用时的操作权限。为什么 *pc = 0 只清掉低地址的第一个字节?因为小端序下 0x11223344 的最低字节 0x44 就存放在最低地址,pc 指向的正是它;而 *pc 被 char* 限制为只能读写 1 字节。
再看"指针加减整数"——同样是指针 +1,不同类型的指针跳过的距离完全不同:
#include <stdio.h>
int main()
{
int n = 10;
char *pc = (char *)&n;
int *pi = &n;
printf("&n = %p\n", (void*)&n);
printf("pc = %p\n", (void*)pc);
printf("pc + 1 = %p\n", (void*)(pc + 1)); // 跳过1个字节
printf("pi = %p\n", (void*)pi);
printf("pi + 1 = %p\n", (void*)(pi + 1)); // 跳过4个字节
return 0;
}
// 示例输出(64位环境):
// &n = 0000005C3E5FF794
// pc = 0000005C3E5FF794
// pc + 1 = 0000005C3E5FF795 ← 地址增加了 1
// pi = 0000005C3E5FF794
// pi + 1 = 0000005C3E5FF798 ← 地址增加了 4规律:指针 + N 实际跳过的字节数 = N × sizeof(指向的类型)。char* 跳 N×1 字节,int* 跳 N×4 字节,double* 跳 N×8 字节。这就是指针运算的底层本质——编译器在生成 p + N 的代码时,实际上做的是 p + N * sizeof(*p) 的字节地址计算。
char* 的视角 int* 的视角
┌──┬──┬──┬──┬──┬──┐ ┌──────┬──────┬──────┐
│c0│c1│c2│c3│c4│..│ │ i0 │ i1 │ i2 │
└──┴──┴──┴──┴──┴──┘ └──────┴──────┴──────┘
pc pc+1 pc+2 pi pi+1 pi+2
+1跳过1字节 +1跳过4字节
注意图上更深的含义:pc 和 pi 都指向内存的同一个位置,但 pc+1 和 pi+1 指向了不同的位置。同一个地址,因为"看待它的类型"不同,做算术的结果就不同——这就是为什么 int*、char*、double* 必须区分:类型是编译器解释这段内存的方式。
类型决定步长这个规则是理解数组遍历、指针运算、甚至后续多级指针的基础。那有没有一种指针能"先接收任意类型、后面再转换"?C 语言提供了 void*——无类型指针(也叫泛型指针):
#include <stdio.h>
int main()
{
int a = 10;
char c = 'w';
double d = 3.14;
void *vp1 = &a; // OK,void*可以接收int*
void *vp2 = &c; // OK,void*可以接收char*
void *vp3 = &d; // OK,void*可以接收double*
// 但如果尝试直接用void*解引用,编译器会报错:
// *vp1 = 20; // ❌ 错误!void*不能直接解引用
// vp1++; // ❌ 错误!void*不能直接进行算术运算(标准C)
// 正确用法:先转为具体类型的指针,再操作
*(int *)vp1 = 20; // 强制转为int*后解引用
printf("a = %d\n", a); // 输出:a = 20
return 0;
}void* 的设计哲学是"先接收,后转换"——编译器不知道 void* 指向的数据类型,也就不知道要读写几个字节、跳过多少字节。所以 void* 不能直接解引用,也不能直接做算术运算。这里有个编译器差异要说明:标准 C 禁止 void* 算术,但 GCC/Clang 把 void* 算术当作扩展,按 1 字节步长处理(vp1++ 能编译,等价于 (char*)vp1 加 1);MSVC 严格遵循标准会报错。写可移植代码,永远先把 void* 强转成具体类型再做算术。
它最大的用途在函数参数中——当你希望一个函数能处理多种类型的数据时,void* 就是天然的"通用接口"。我们会在后续系列中讲 qsort 函数时真正体会它的威力。
思考题(指针类型的意义)
int *p和char *p指向同一个地址,p+1的地址差是多少?(4 和 1)- 为什么
void*不能解引用?(编译器不知道读几个字节) - 用
char*逐字节打印一个int变量的内存内容,需要几个字节?(4 个,配合(unsigned char*)强转避免符号扩展)
参考答案与详解(指针类型的意义)
- 步长差异:
int *p的类型步长是sizeof(int) = 4字节,char *p的步长是sizeof(char) = 1字节。所以两者指向同一地址时,p+1的地址差分别是 4 和 1。底层本质:编译器生成p + N时实际计算的是p + N × sizeof(指向的类型)的字节数。 void*不能解引用:解引用需要知道"读几个字节、把结果当什么类型"。而void*是"无具体类型",编译器对void*指向的是什么一无所知,既不知道步长也不知道读写宽度,所以直接*vp是语法错误。必须先强转成具体的int*/char*等再解引用。- 逐字节打印需要 4 字节:
int在内存里占 4 字节,所以需要读 4 个字节。打印的关键是把int*强转成unsigned char*再逐字节访问:(unsigned char *)p后,p[i](i=0..3)就是每个字节。特意用unsigned char*而不是char*,是因为char有符号的平台会把0x80~0xFF的字节当成负数,%02X时因符号扩展打印成FFFFFF80之类的垃圾;unsigned char*才能干净地按字节打印(这正是 part1【练习 2】的正确写法)。
指针运算
理解了"类型决定步长"之后,指针的基本运算就水到渠成了。指针能参与的运算其实很有限:加/减整数、指针减指针、关系比较、赋值。除此之外的运算(比如指针乘指针、指针加指针、指针与浮点数相加)都是非法的——因为它们在语义上没有意义。
指针 ± 整数 天然适合遍历数组,因为数组元素在内存中是连续存放的:
#include <stdio.h>
int main()
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int *p = &arr[0]; // p指向数组第一个元素
int sz = sizeof(arr) / sizeof(arr[0]); // 计算数组元素个数
int i;
for (i = 0; i < sz; i++)
{
// p + i 是指针算术:p向后偏移i个int的位置
// *(p + i) 是解引用:取出偏移后的元素值
printf("%d ", *(p + i));
}
printf("\n");
return 0;
}
// 输出:1 2 3 4 5 6 7 8 9 10内存视角:
arr[0] arr[1] arr[2] arr[3] arr[4] arr[5] arr[6] arr[7] arr[8] arr[9]
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
p p+1 p+2 p+3 p+4 p+5 p+6 p+7 p+8 p+9
注意这里 p + i 并不改变 p 本身——它产生一个新的地址值。如果想移动指针,要写 p = p + 1; 或者 p++;。这两者的区别("算新地址" vs "改指针自己")是初学者最容易混淆的点:*(p + 1) 取的是下一个元素但 p 没动;*p++ 是先解引用当前元素,再把 p 向后移一位(因为后缀 ++ 优先级高于 *,等价于 *(p++))。
指针 - 指针 计算的是两个地址之间相隔的元素个数,不是字节数:
#include <stdio.h>
// 自己实现 strlen:计算字符串长度
int my_strlen(char *s)
{
char *p = s; // p保存起始位置
while (*p != '\0') // 一直走到字符串末尾的'\0'
p++; // p不断后移
return p - s; // 末尾地址 - 起始地址 = 字符个数
}
int main()
{
printf("abc的长度: %d\n", my_strlen("abc")); // 输出:3
printf("hello的长度: %d\n", my_strlen("hello")); // 输出:5
return 0;
}p - s 的结果类型其实不是普通的 int,而是标准库定义的 ptrdiff_t——它专门用来表示"两个指针之间相差的元素个数",是一个有符号类型(因为用后面的指针减前面的会得到负数,比如 p 在 s 后面时 p - s 为正,反过来就为负)。上面的 my_strlen 用 int 接收没问题,但要意识到它不是 size_t 那种无符号类型。打印 ptrdiff_t 用 %td 格式(C99 起),这和 size_t 用 %zu 是同一套规矩。
为什么"指针减指针得到的是元素个数而不是字节数"?因为两个指针相减,编译器先算字节差,再除以 sizeof(*p)。所以 p - s == 3 意味着"差 3 个元素",至于底层差了 12 字节(int)还是 3 字节(char),你不需要关心——这正是"类型决定步长"的又一处体现。
就像测量走廊里有几扇门,而不是走廊有多少米。两个指针相减的前提是它们必须指向同一块连续内存(比如同一个数组内),指向不同数组的指针相减是未定义行为——因为此时"元素个数"这个度量根本没有意义。
指针的关系运算 本质上是比较地址数值的大小,常用于数组边界判断:
#include <stdio.h>
int main()
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int *p = &arr[0]; // p指向第一个元素
int sz = sizeof(arr) / sizeof(arr[0]);
// arr + sz 是"数组尾后"位置(one-past-the-end)
// 注意:arr + sz 指向的位置不属于数组,不能解引用!
while (p < arr + sz) // 指针比较:p的地址 < 尾后地址
{
printf("%d ", *p); // 解引用当前元素
p++; // p指向下一个元素
}
printf("\n");
return 0;
}
// 输出:1 2 3 4 5 6 7 8 9 10这里 arr + sz 是一个特殊位置——数组尾后(one-past-the-end)。C 标准允许指针指向这个位置用于边界比较,但绝对不能解引用它,否则是未定义行为。很多 C 程序里 while (p < arr + N) 这样的写法,背后的依据就是这个规则。它的设计初衷是让循环边界判断简单统一:循环条件 p < 尾后,保证每个 *p 都是合法元素。
指针的关系运算(< > <= >=)同样要求两个指针指向同一数组内(含尾后位置),否则未定义。而 == != 没有这个限制——你可以任意比较两个指针是否指向同一地址。
思考题(指针运算)
p + 1和p++的区别是什么?(前者产生新地址值,p 不变;后者 p 自己移动)- 两个
char*相减和两个int*相减,得到同样的"元素个数"吗?(是的,都表示元素个数,但底层字节差不同) - 指针能和整数相乘吗?(不能,非法运算;指针只支持 ± 整数、指针减指针、比较、赋值)
参考答案与详解(指针运算)
p + 1vsp++:p + 1计算出一个新的地址值作为表达式的值,p本身不变(要想移动还得写p = p + 1);p++则是直接把p自己向后移一位,p的值被改写。对应到数组遍历:*(p + 1)取下一个元素但p不动;*p++等价*(p++),先取当前元素再移动指针。- 两种指针相减,元素个数一致:两个
char*相减和两个int*相减得到的结果都是"元素个数"(类型都是ptrdiff_t)。区别只在底层字节差不同:char*一个元素 1 字节,所以元素个数 == 字节数;int*一个元素 4 字节,所以字节差是元素个数的 4 倍。但"个数"这个语义在两种情况下是等价的。 - 指针乘整数非法:
p * 2、p + q、p - q里,只有"指针减指针"是合法的(结果是元素个数),其余都不是。因为指针是地址,"两个地址相乘/相加"在语义上没有意义,编译器会直接报错。指针只支持:加/减整数、指针减指针、关系比较、赋值。
野指针初识
指针里存的是地址,但地址这东西——如果指向了不该指向的地方,后果是灾难性的。在学习指针的正式操作之前,先认识一下最臭名昭著的指针事故:野指针。
野指针就是指向了"不可知"内存区域的指针——它指向随机地址、已回收的空间、或者不属于你程序的内存。解引用野指针是未定义行为,可能立刻段错误崩溃,也可能悄悄覆盖别的数据,让 bug 在几小时后才以诡异的方式爆发。
野指针主要有三大来源(本篇先预览,下一篇会逐一深挖):
| 成因 | 示例 | 后果 |
|---|---|---|
| 指针未初始化 | int *p; 然后直接 *p = 10; | p 的值是垃圾值,往随机地址写数据 |
| 指针越界 | 数组 10 个元素,指针走到了第 12 个 | 访问了数组之外的非法内存 |
| 返回局部变量地址 | 函数返回了栈上局部变量的地址 | 函数返回后栈空间被回收复用 |
对付野指针,最基础的两条铁律是:声明指针时就初始化(不知道指向谁就赋 NULL),使用前检查是否为 NULL。至于更完整的成因分析和防御措施,下一篇专门展开——这里先留个概念,让你心里有根弦。
思考题(野指针概念)
- 为什么解引用
NULL通常会立刻崩溃,而解引用"垃圾值指针"可能不崩溃?(NULL 是 0 地址,操作系统保护页会直接触发段错误;垃圾值可能恰好落在你的进程可写内存里,于是"悄悄写坏数据") - 野指针和空指针(NULL)是一回事吗?(不是。空指针是"明确表示不指向任何东西"的合法状态;野指针是"指向了未知区域"的非法状态)
参考答案与详解(野指针概念)
- 为什么解引用 NULL 必崩而垃圾值可能不崩:
NULL的值是地址 0,而地址 0 所在的页面被操作系统标记为受保护(不可读不可写),一旦 CPU 访问它就会触发缺页异常 → 进程收到段错误(Segmentation Fault)立刻崩溃——这是"快速失败",反而好排查。而"垃圾值指针"是未初始化的局部变量,它存的是一个随机数,可能恰好落在你进程自己的可写内存里(比如栈、堆、数据段),此时解引用不会崩,而是悄悄写坏别的数据,后果往往延迟到几小时后的某个诡异 bug 才暴露——比崩溃更可怕。 - 野指针 ≠ 空指针:空指针(NULL)是"我明确表示不指向任何对象"的合法状态,可以用
p != NULL安全判断后再使用;野指针是"我指向了未知/非法区域"的非法状态,它的值本身不可信、不可预测,对它做if (p != NULL)判断也没有意义(因为它的值就不是 NULL,会骗过检查)。所以二者性质完全不同,不能混为一谈。野指针是要避免的 Bug,空指针是约定俗成的安全占位。
指针使用的常见易错点
把本讲内容收拢成一张"自查清单",这些是初学者写指针代码最高频的错:
- 忘记初始化:
int *p;不赋值就用——p 是垃圾值(野指针)。声明即初始化是铁律。 *p和p混淆:p是地址,*p是 p 指向的内容。scanf("%d", p)和scanf("%d", &p)有天壤之别——前者把输入写进 p 指向的地方,后者把输入写进 p 变量自己(把 p 的指向改成一个整数,灾难)。- 解引用之前不检查:对可能为 NULL 的指针直接
*p。 sizeof用错对象:sizeof(p)是 8(指针大小),sizeof(*p)是 4(int 大小)——笔试常考。- 类型不匹配的赋值:
int *p = &a; double *pd = p;编译器报警告,强转掩盖问题((double*)p)后解引用,会把 4 字节按 8 字节读,读出一堆垃圾。
// 易错点5 的演示:类型不匹配解引用
int n = 100;
double *pd = (double *)&n; // 强转掩盖了类型问题
printf("%f\n", *pd); // ❌ 把4字节的int按8字节的double读,垃圾值指针不是什么魔法,它就是一个存放地址的变量。& 取地址,* 解引用,类型决定步长和权限——把这三条记牢,你就已经掌握了指针的基本功。但光有基本功还不够——在实际代码中,指针最常和两个东西搭配出现:const 关键字和函数参数传递。const 让指针守规矩(哪些能改、哪些不能改),而传址调用让指针真正发挥不可替代的价值(不然你连两个变量的值都交换不了)。
在进入下一篇之前,做两个练习验收一下:
练习 1:定义
int a = 10, b = 20;,分别用int *pa = &a;和char *pc = (char *)&a;,打印pa、pa+1、pc、pc+1的地址,观察差值,解释为什么。练习 2:写一个函数
void print_int_bytes(int *p),用unsigned char*逐字节打印p指向的 int 的每个字节(十六进制),验证小端序下0x11223344打印出44 33 22 11。
练习 1 参考答案
#include <stdio.h>
int main()
{
int a = 10;
int *pa = &a; // pa 是 int*,指向 a
char *pc = (char *)&a; // pc 是 char*,也指向 a
printf("pa = %p\n", (void*)pa); // 打印 a 的地址
printf("pa+1 = %p\n", (void*)(pa + 1)); // int* 加 1,跳过 4 字节
printf("pc = %p\n", (void*)pc); // 打印 a 的地址(和 pa 相同)
printf("pc+1 = %p\n", (void*)(pc + 1)); // char* 加 1,跳过 1 字节
return 0;
}
// 示例输出(64 位环境,具体地址每次运行可能不同,关键是差值):
// pa = 0000006B3E5FF714
// pa+1 = 0000006B3E5FF718 ← 与 pa 相差 4(十六进制 0x...18 - 0x...14 = 4)
// pc = 0000006B3E5FF714
// pc+1 = 0000006B3E5FF715 ← 与 pc 相差 1解析:pa 和 pc 都指向 a 的起始地址,所以打印值相同。但 pa + 1 跳过了 4 个字节,pc + 1 只跳过 1 个字节——因为指针加 1 实际跳过的字节数 = 1 × sizeof(指向的类型)。int* 的步长是 sizeof(int) = 4,char* 的步长是 sizeof(char) = 1。这就是本文强调的"指针的类型决定步长"。
练习 2 参考答案
#include <stdio.h>
void print_int_bytes(int *p)
{
// 关键:把 int* 强转成 unsigned char*,才能一次只读 1 字节
unsigned char *pc = (unsigned char *)p;
int i;
for (i = 0; i < sizeof(int); i++)
printf("%02X ", pc[i]); // %02X 按两位十六进制打印一个字节
printf("\n");
}
int main()
{
int n = 0x11223344;
print_int_bytes(&n);
return 0;
}
// 小端序输出:44 33 22 11解析:pc[i] 逐字节取出 n 的每个字节。小端序下,0x11223344 的最低字节 0x44 存放在最低地址,所以先打印 44,依次是 33 22 11。这里特意用 unsigned char* 而不是 char*:char 在部分平台是有符号的,0x80 以上的字节值会被当负数,%02X 时会因为符号扩展打出 FFFFFF80 之类的垃圾,用 unsigned char* 才能干净地按字节打印。
下一篇我们就正面攻克这两个话题:const 修饰指针的四种形态、野指针的成因与防范、以及那个让无数初学者"啊,原来如此"的传址调用。
本文基于C语言教学课件《第11讲:深入理解指针(1)》的内容框架撰写,所有代码示例均在GCC/MinGW环境下验证通过。
还没有评论 — 第一条由你来留。