指针入门:地址的世界
指针大概是 C 语言里名声最响也最容易劝退新手的特性了。如果你之前接触过 Python 或 Java,可能习惯了"变量就是对象本身"的思维——变量里存的就是数据。但到了 C 这边,多了一个关键概念:每个变量都住在内存的某个位置,这个位置有一个编号(地址)。指针就是用来存储和操作这些地址的变量。
说实话,指针初学的时候确实需要花点时间建立直觉。但先别急着害怕——我们先不碰什么多级指针、函数指针那些复杂的东西,今天只搞清楚一件事:指针就是地址,地址就是柜子编号。理解了这一点,后面所有跟指针相关的高级特性才有地基可站。
学习目标 完成本章后,你将能够:
- [ ] 用"储物柜"模型理解内存和地址的关系
- [ ] 正确声明和初始化指针变量
- [ ] 理解取地址(
&)和解引用(*)这一对互逆操作- [ ] 掌握指针的加减运算和距离计算
环境说明
我们接下来的所有实验都在这个环境下进行:
- 平台:Linux x86_64(WSL2 也可以)
- 编译器:GCC 13+ 或 Clang 17+
- 编译选项:
-Wall -Wextra -std=c17
第一步——先搞懂"地址"是什么
储物柜模型
在讲指针语法之前,我们先建立一个直觉。你可以把程序的内存想象成一排很长很长的储物柜。每个柜子都有编号(这就是地址),柜子里可以放东西(这就是数据)。当你声明一个变量的时候,编译器帮你分配了若干个连续的柜子,变量名就是你给这些柜子贴的标签。
int value = 42;这行代码做了两件事:在内存里分配了 4 个连续的柜子(因为 int 占 4 字节),在里面放上了值 42。value 是你给这 4 个柜子起的标签,但这 4 个柜子本身有一个起始编号——比如 0x7ffd1234。这个编号,就是地址。
指针就是一个专门存"柜子编号"的变量。普通变量存的是数据(柜子里的内容),指针存的是地址(柜子的编号)。
来验证一下——看看变量的地址
我们来写个最简单的程序,实际看看变量的地址长什么样:
#include <stdio.h>
int main(void)
{
int value = 42;
int other = 100;
printf("value 的值: %d\n", value);
printf("value 的地址: %p\n", (void*)&value);
printf("other 的地址: %p\n", (void*)&other);
return 0;
}编译运行:
gcc -Wall -Wextra -std=c17 addr_demo.c -o addr_demo && ./addr_demo运行结果(地址每次运行都不同,这是正常的):
value 的值: 42
value 的地址: 0x7ffd3a2b1c4c
other 的地址: 0x7ffd3a2b1c48%p 是打印指针地址的格式说明符,&value 是取 value 的地址。两个变量的地址挨得很近(只差 4 字节),因为它们都在栈上连续分配。每次运行程序地址都会变化,这是操作系统的地址空间随机化(ASLR)安全机制,不影响我们理解概念。
第二步——声明你的第一个指针
指针的声明语法
指针变量的声明语法是 类型* 变量名。* 出现在类型旁边,表示"这是一个指向该类型的指针"。我们采用的风格是让 * 靠左贴着类型名写,即 int* p,这样一眼就能看出"p 是一个 int 指针"。
int value = 42;
int* ptr = &value; // ptr 存储了 value 的地址& 是取地址运算符,它返回操作数的内存地址。ptr 现在持有 value 的地址,我们说"ptr 指向 value"。
千万别忘了初始化
这里有一个非常重要的习惯:指针声明时一定要初始化。未初始化的指针里面存的是随机值——它可能指向内存中的任意位置。如果你不小心解引用了一个未初始化的指针,轻则读到垃圾数据,重则直接段错误(segmentation fault),更阴险的情况下程序"看起来正常"但数据已经被悄悄改掉了。
int* good_ptr = NULL; // 好:明确表示"不指向任何东西"
int* bad_ptr; // 危险:包含随机地址,解引用是未定义行为⚠️ 踩坑预警
int* p, q;声明了一个int*和一个int——不是两个指针!*只修饰紧跟在后面的变量名p。如果要声明两个指针,必须写int *p, *q;。这是 C 声明语法的一个经典陷阱。
把未使用的指针初始化为 NULL 是一个好习惯。NULL 是一个特殊的指针值,表示"不指向任何有效的内存地址"。虽然解引用 NULL 也会导致段错误,但至少这个错误是可预测的、容易调试的——不像野指针那样给你制造薛定谔的 bug。
第三步——用 & 和 * 玩转地址
一对互逆的操作
&(取地址)和 *(解引用)是一对互逆的运算符:& 从变量拿到地址,* 从地址拿到变量。
int value = 42;
int* ptr = &value; // &value → 取得 value 的地址,赋给 ptr
printf("value 的地址: %p\n", (void*)ptr); // 打印地址
printf("ptr 指向的值: %d\n", *ptr); // *ptr → 解引用,得到 42解引用 *ptr 的意思是"顺着 ptr 里存的地址,去那块内存里取值"。既然能读,自然也能写:
*ptr = 100;
printf("value = %d\n", value); // 输出 100——通过指针修改了原始变量这就是指针的威力所在:你手里拿着一个地址,就能直接操作那块内存上的数据,不管那块内存是在当前函数的栈帧里、在堆上、还是在硬件寄存器的映射区域里。
来验证一下,把上面的操作串起来跑一遍:
#include <stdio.h>
int main(void)
{
int value = 42;
int* ptr = &value;
printf("初始: value = %d, *ptr = %d\n", value, *ptr);
printf("地址: &value = %p, ptr = %p\n", (void*)&value, (void*)ptr);
*ptr = 100;
printf("修改后: value = %d, *ptr = %d\n", value, *ptr);
return 0;
}运行结果:
初始: value = 42, *ptr = 42
地址: &value = 0x7ffd1234abcd, ptr = 0x7ffd1234abcd
修改后: value = 100, *ptr = 100很好,ptr 和 &value 的地址完全一致,通过 *ptr = 100 确实修改了 value 的值。
* 符号身兼两职
一个容易让新手困惑的地方是 * 这个符号身兼两职:在声明里它表示"这是一个指针类型",在表达式里它表示"解引用"。这两个是不同的东西,别搞混了。
int* p = &x;里的*是类型声明的一部分,告诉编译器"p 是一个 int 指针"*p = 10;里的*是解引用操作符,意思是"顺着 p 的地址去写数据"
虽然长得一样,但含义完全不同。区分的技巧是看上下文:如果 * 出现在类型名后面、变量名前面,那就是声明;如果出现在语句中变量名前面,那就是解引用。
第四步——指针也能做加减法
以类型大小为步长
指针不只是存地址,它还支持有限的算术运算。但这里的"加减"和普通整数的加减不是一回事——指针的加减是以所指向类型的大小为步长的。
打个比方:你站在一排储物柜前面,每个柜子宽 40 厘米。你说"往前走 1 格",实际上你移动了 40 厘米,不是 1 厘米。指针加减就是这种"以格子为单位"的移动——编译器知道每个 int 占 4 字节,所以 p + 1 实际上是地址加了 4。
int arr[5] = {10, 20, 30, 40, 50};
int* p = arr; // p 指向 arr[0]
p++; // p 现在指向 arr[1]
// 地址增加了 sizeof(int),即 4 字节
int val = *(p + 2); // p+2 跳过两个 int,指向 arr[3],val = 40p + 2 不是在地址值上加 2,而是加 2 * sizeof(int)。这个设计非常精妙——它让指针的加减天然适配数组的下标偏移。
指针之间的距离
两个指向同一个数组内元素的指针可以相减,结果是它们之间的元素个数(距离),而不是地址差的字节数:
int arr[5] = {10, 20, 30, 40, 50};
int* start = &arr[1];
int* end = &arr[4];
ptrdiff_t distance = end - start; // 3,不是 12ptrdiff_t 是 <stddef.h> 中定义的专门表示指针距离的类型。
⚠️ 踩坑预警 指针运算只有在指向同一个数组(或同一块连续分配的内存)时才有意义。两个毫不相干的指针相减是未定义行为。编译器不会报错,但结果不可预测。
来验证一下指针运算的效果:
#include <stdio.h>
#include <stddef.h>
int main(void)
{
int arr[5] = {10, 20, 30, 40, 50};
int* p = arr;
printf("arr[0] = %d, *p = %d\n", arr[0], *p);
p++;
printf("p++ 后: *p = %d (arr[1])\n", *p);
printf("*(p+2) = %d (arr[3])\n", *(p + 2));
int* start = &arr[1];
int* end = &arr[4];
printf("end - start = %td 个元素\n", end - start);
return 0;
}运行结果:
arr[0] = 10, *p = 10
p++ 后: *p = 20 (arr[1])
*(p+2) = 40 (arr[3])
end - start = 3 个元素一切如我们所预期。
C++ 衔接
C++ 在指针的基础上做了两个关键的改进。第一个是引用(reference),int& r = value 本质上是编译器自动解引用的 const 指针——它必须在声明时初始化,一旦绑定就不能改变,使用时不需要写 *,语法上就像直接操作原始变量。引用比指针安全得多,C++ 函数参数优先传引用。
第二个是智能指针,std::unique_ptr 和 std::shared_ptr 用 RAII 机制自动管理内存生命周期——指针超出作用域时自动释放内存,从根本上消除了手动 free 导致的内存泄漏和悬空指针问题。这些内容我们后续会深入讨论,现在只需要知道 C++ 的核心思路是"用类型系统和对象生命周期来自动化管理"就行。
小结
今天我们建立了指针的基本认知:指针就是存储内存地址的变量。& 取地址,* 解引用,它们是一对互逆操作。指针的加减以所指向类型的大小为步长,天然适配数组遍历。指针必须初始化(哪怕是初始化为 NULL),未初始化的指针是危险的。
到这里我们只学了指针的"地基"。接下来问题来了——数组和指针之间到底什么关系?const int* p 和 int* const p 怎么区分?NULL 指针和野指针有什么区别?这些就是我们下一篇要讨论的内容。
练习
练习 1:地址与值
写一个程序,声明三个不同类型的变量(int、double、char),打印它们的值、地址和 sizeof 结果。观察地址之间的间隔是否符合各类型的大小。
练习 2:指针遍历数组
用指针算术遍历一个 int 数组并打印所有元素。要求不使用 [] 运算符,只用指针加减和解引用:
/// @brief 使用指针算术遍历并打印 int 数组
/// @param data 数组首元素地址
/// @param count 元素个数
void print_int_array(const int* data, size_t count);