Skip to content

指针入门:地址的世界

指针大概是 C 语言里名声最响也最容易劝退新手的特性了。如果你之前接触过 Python 或 Java,可能习惯了"变量就是对象本身"的思维——变量里存的就是数据。但到了 C 这边,多了一个关键概念:每个变量都住在内存的某个位置,这个位置有一个编号(地址)。指针就是用来存储和操作这些地址的变量。

说实话,指针初学的时候确实需要花点时间建立直觉。但先别急着害怕——我们先不碰什么多级指针、函数指针那些复杂的东西,今天只搞清楚一件事:指针就是地址,地址就是柜子编号。理解了这一点,后面所有跟指针相关的高级特性才有地基可站。

学习目标 完成本章后,你将能够:

  • [ ] 用"储物柜"模型理解内存和地址的关系
  • [ ] 正确声明和初始化指针变量
  • [ ] 理解取地址(&)和解引用(*)这一对互逆操作
  • [ ] 掌握指针的加减运算和距离计算

环境说明

我们接下来的所有实验都在这个环境下进行:

  • 平台:Linux x86_64(WSL2 也可以)
  • 编译器:GCC 13+ 或 Clang 17+
  • 编译选项:-Wall -Wextra -std=c17

第一步——先搞懂"地址"是什么

储物柜模型

在讲指针语法之前,我们先建立一个直觉。你可以把程序的内存想象成一排很长很长的储物柜。每个柜子都有编号(这就是地址),柜子里可以放东西(这就是数据)。当你声明一个变量的时候,编译器帮你分配了若干个连续的柜子,变量名就是你给这些柜子贴的标签。

c
int value = 42;

这行代码做了两件事:在内存里分配了 4 个连续的柜子(因为 int 占 4 字节),在里面放上了值 42value 是你给这 4 个柜子起的标签,但这 4 个柜子本身有一个起始编号——比如 0x7ffd1234。这个编号,就是地址。

指针就是一个专门存"柜子编号"的变量。普通变量存的是数据(柜子里的内容),指针存的是地址(柜子的编号)。

来验证一下——看看变量的地址

我们来写个最简单的程序,实际看看变量的地址长什么样:

c
#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;
}

编译运行:

bash
gcc -Wall -Wextra -std=c17 addr_demo.c -o addr_demo && ./addr_demo

运行结果(地址每次运行都不同,这是正常的):

text
value 的值:   42
value 的地址: 0x7ffd3a2b1c4c
other 的地址: 0x7ffd3a2b1c48

%p 是打印指针地址的格式说明符,&value 是取 value 的地址。两个变量的地址挨得很近(只差 4 字节),因为它们都在栈上连续分配。每次运行程序地址都会变化,这是操作系统的地址空间随机化(ASLR)安全机制,不影响我们理解概念。

第二步——声明你的第一个指针

指针的声明语法

指针变量的声明语法是 类型* 变量名* 出现在类型旁边,表示"这是一个指向该类型的指针"。我们采用的风格是让 * 靠左贴着类型名写,即 int* p,这样一眼就能看出"p 是一个 int 指针"。

c
int value = 42;
int* ptr = &value;  // ptr 存储了 value 的地址

& 是取地址运算符,它返回操作数的内存地址。ptr 现在持有 value 的地址,我们说"ptr 指向 value"。

千万别忘了初始化

这里有一个非常重要的习惯:指针声明时一定要初始化。未初始化的指针里面存的是随机值——它可能指向内存中的任意位置。如果你不小心解引用了一个未初始化的指针,轻则读到垃圾数据,重则直接段错误(segmentation fault),更阴险的情况下程序"看起来正常"但数据已经被悄悄改掉了。

c
int* good_ptr = NULL;     // 好:明确表示"不指向任何东西"
int* bad_ptr;             // 危险:包含随机地址,解引用是未定义行为

⚠️ 踩坑预警int* p, q; 声明了一个 int* 和一个 int——不是两个指针!* 只修饰紧跟在后面的变量名 p。如果要声明两个指针,必须写 int *p, *q;。这是 C 声明语法的一个经典陷阱。

把未使用的指针初始化为 NULL 是一个好习惯。NULL 是一个特殊的指针值,表示"不指向任何有效的内存地址"。虽然解引用 NULL 也会导致段错误,但至少这个错误是可预测的、容易调试的——不像野指针那样给你制造薛定谔的 bug。

第三步——用 &* 玩转地址

一对互逆的操作

&(取地址)和 *(解引用)是一对互逆的运算符:& 从变量拿到地址,* 从地址拿到变量。

c
int value = 42;
int* ptr = &value;     // &value → 取得 value 的地址,赋给 ptr

printf("value 的地址: %p\n", (void*)ptr);    // 打印地址
printf("ptr 指向的值: %d\n", *ptr);          // *ptr → 解引用,得到 42

解引用 *ptr 的意思是"顺着 ptr 里存的地址,去那块内存里取值"。既然能读,自然也能写:

c
*ptr = 100;
printf("value = %d\n", value);  // 输出 100——通过指针修改了原始变量

这就是指针的威力所在:你手里拿着一个地址,就能直接操作那块内存上的数据,不管那块内存是在当前函数的栈帧里、在堆上、还是在硬件寄存器的映射区域里。

来验证一下,把上面的操作串起来跑一遍:

c
#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;
}

运行结果:

text
初始: 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。

c
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 = 40

p + 2 不是在地址值上加 2,而是加 2 * sizeof(int)。这个设计非常精妙——它让指针的加减天然适配数组的下标偏移。

指针之间的距离

两个指向同一个数组内元素的指针可以相减,结果是它们之间的元素个数(距离),而不是地址差的字节数:

c
int arr[5] = {10, 20, 30, 40, 50};
int* start = &arr[1];
int* end   = &arr[4];

ptrdiff_t distance = end - start;   // 3,不是 12

ptrdiff_t<stddef.h> 中定义的专门表示指针距离的类型。

⚠️ 踩坑预警 指针运算只有在指向同一个数组(或同一块连续分配的内存)时才有意义。两个毫不相干的指针相减是未定义行为。编译器不会报错,但结果不可预测。

来验证一下指针运算的效果:

c
#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;
}

运行结果:

text
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_ptrstd::shared_ptr 用 RAII 机制自动管理内存生命周期——指针超出作用域时自动释放内存,从根本上消除了手动 free 导致的内存泄漏和悬空指针问题。这些内容我们后续会深入讨论,现在只需要知道 C++ 的核心思路是"用类型系统和对象生命周期来自动化管理"就行。

小结

今天我们建立了指针的基本认知:指针就是存储内存地址的变量。& 取地址,* 解引用,它们是一对互逆操作。指针的加减以所指向类型的大小为步长,天然适配数组遍历。指针必须初始化(哪怕是初始化为 NULL),未初始化的指针是危险的。

到这里我们只学了指针的"地基"。接下来问题来了——数组和指针之间到底什么关系?const int* pint* const p 怎么区分?NULL 指针和野指针有什么区别?这些就是我们下一篇要讨论的内容。

练习

练习 1:地址与值

写一个程序,声明三个不同类型的变量(intdoublechar),打印它们的值、地址和 sizeof 结果。观察地址之间的间隔是否符合各类型的大小。

练习 2:指针遍历数组

用指针算术遍历一个 int 数组并打印所有元素。要求不使用 [] 运算符,只用指针加减和解引用:

c
/// @brief 使用指针算术遍历并打印 int 数组
/// @param data 数组首元素地址
/// @param count 元素个数
void print_int_array(const int* data, size_t count);

参考资源

基于 VitePress 构建