指针是C语言的灵魂,也是区分程序员水平的一道重要分水岭。想要真正掌握指针,不仅需要对C语言本身有深刻的理解,还离不开对计算机硬件与操作系统底层原理的认知。这篇文章将从最基础的概念出发,系统性地为你拆解指针的方方面面。
为什么需要指针?
指针解决了编程中的几个根本性问题:
第一,高效的数据共享。 使用指针可以让不同代码区域轻松地共享同一份内存数据。虽然通过复制数据也能达到目的,但对于结构体这类大型数据,复制操作会消耗大量性能。指针则能完美避开这个问题,因为任何类型的指针所占用的内存大小通常都是固定的(例如在32位系统上是4字节)。
第二,构建复杂数据结构。 指针使得链表、链式二叉树等需要链接性的复杂数据结构的实现成为可能。
第三,突破“按值传递”的限制。 C语言中所有的函数调用默认都是“按值传递”。如果要在函数内部修改外部传入的数据对象,就必须通过指向该对象的指针来完成。此外,操作动态申请的堆内存也必须使用指针。
计算机如何访问内存?
计算机通过总线系统与内存通信,其中与我们理解指针最相关的是数据总线和地址总线。
- 数据总线负责传送数据信息,其宽度(位数)通常与CPU的字长一致,也决定了
int等基本类型的长度。例如,32位机器的数据总线宽32位,一次最多能存取32位数据。
- 地址总线专门用于寻址。CPU通过它发送要访问的内存地址,然后通过数据总线传送该地址处的数据。地址总线的宽度决定了CPU可直接寻址的内存空间大小(如32位总线对应4GB寻址空间)。
通常,我们说32位CPU,其数据总线和地址总线的宽度都是32位。计算机访问数据时,先通过地址总线传送目标地址,再通过数据总线读写数据。int等基本类型的位数等于数据总线的宽度,而指针的位数等于地址总线的宽度。
内存的基本访问单元
C语言中,最小的基本数据类型是char,占8位即1个字节。我们可以认为,计算机以字节(Byte)为基本访问单元。访问小于一个字节的数据,需要通过位操作来完成。

如上图所示,计算机在进行数据访问时,总是从某个字节(例如第p个字节)开始,访问的长度则由编译器根据该数据的实际类型(通过sizeof得知)来决定。
sizeof 关键字
sizeof是编译器在编译期用来计算类型或变量所占字节数的关键字。例如:
sizeof(char)=1;
sizeof(int)=4;
sizeof(Type)的值是一个编译期常量。
指针到底是什么?
我们常说int数组、char数组。同样,指针也泛指一类数据类型:int指针类型、char指针类型等等。
任何程序数据载入内存后,都有一个唯一的编号来标识其存储位置,这个编号就是地址,也就是指针。而用来保存这个地址的变量,就是指针变量。
所以:指针是程序数据在内存中的地址,指针变量是用来保存地址的变量。

你可以简单地将指针变量理解为一个特殊的整型变量,它存储的不是普通数值,而是内存的“门牌号”——地址值。图2清晰地展示了指针与内存地址的指向关系。

指针的长度
我们这样定义指针:Type *p;,表示p是一个指向Type类型的指针。这里的Type可以是任意类型,包括基本类型、结构体、函数,甚至是指针本身(如int **,即指向指针的指针)。
一个关键结论是:在特定平台上,所有数据指针的长度都是相同的,因为它存储的是地址,其宽度由地址总线决定。在32位系统上,指针长度是4字节;在64位系统上,通常是8字节。因此,sizeof(char*)、sizeof(int*)、sizeof(void*)在同一个平台下的结果是一样的。
程序中的数据为何有地址?
我们需要从操作系统的抽象视角来看内存。对程序员而言,内存就像一个巨大的、线性的字节数组(平坦寻址),每个字节都有一个从0开始的唯一编号,这个编号就是地址。
例如,一个256MB的内存,共有 256 1024 1024 = 268435456 个字节,其地址范围就是 0 ~ 268435455。

因此,程序中使用的变量、常量等数据被载入内存后,都有自己唯一的地址。
#include <stdio.h>
int main(void)
{
char ch = 'a';
int num = 97;
printf("ch 的地址:%p\n",&ch); //ch 的地址:0028FF47
printf("num的地址:%p\n",&num); //num的地址:0028FF40
return 0;
}

指针的值(虚拟地址)实质上就是内存单元的编号,通常用十六进制表示。对于一个机器字长为w位的计算机,其程序最多能访问 2^w 个字节。
假设char占1字节,int占4字节,变量ch和num在内存中的存储模型可能如下:

变量与内存的深度剖析
以局部变量int num = 97为例,我们来剖析它在内存中的方方面面。

- 内存的数据:即变量值的二进制形式。97的二进制是
00000000 00000000 00000000 01100001。图中显示为小端模式存储(低位在低地址)。
- 内存数据的类型:类型(这里是
int)决定了数据占用的字节数(4字节),以及计算机如何解释这些字节(解释为一个整数)。
- 内存数据的名称:即变量名
num。这是高级语言提供的抽象,方便我们操作。并非所有内存数据都有名称(如malloc分配的堆内存)。
- 内存数据的地址:如果一个类型占用多个字节,则其变量的地址是这些字节中地址值最小的那个。所以
num的地址是0028FF40。
- 内存数据的生命周期:局部变量
num的生命周期与main函数同步。程序使用的内存被划分为栈区、堆区、静态数据区等,不同区域的数据生命周期不同。
指针运算
指针运算,特别是加减运算,是理解指针行为的关键。p++这样的操作,并非简单地将地址值加1,而是加上sizeof(Type),即指向下一个同类型数据的位置。
// 示例:观察不同类型指针+1的地址变化
#include<iostream>
using namespace std;
int main() {
short sv=1, *psv=&sv;
int iv=1, *piv=&iv;
long lv=1, *plv=&lv;
// ... 其他类型
cout<<"psv:"<<psv<<" psv+1:"<<psv+1<<endl;
cout<<"piv:"<<piv<<" piv+1:"<<piv+1<<endl;
// ... 输出其他
return 0;
}

运行结果会显示,地址的增加量恰好是各自类型的sizeof值(short:2, int:4, long:4等)。这背后的原理可以通过查看反汇编代码来验证。
int iv=1,*piv=&iv;
piv++;
cout<<piv<<endl;
piv=piv+4;
cout<<piv<<endl;

对应的汇编指令中,可以看到对指针变量直接进行了加4(sizeof(int))和加16(4*sizeof(int))的操作。


指针变量与指向关系
保存指针的变量即指针变量。如果指针变量p1保存了变量num的地址,我们就说“p1指向num”。

定义指针变量
在变量名前加*号即可定义指针变量。
int *p_int; // 指向int的指针
double *p_double;
struct Student *p_struct; // 结构体指针
int (*p_func)(int,int); // 函数指针
int (*p_arr)[3]; // 指向数组的指针
int **p_pointer; // 指向指针的指针(二级指针)
指针的两个关键属性
- 指针的值:即它所保存的内存地址。
- 指针的类型:决定了它指向的内存区域有多大,以及如何解释那块内存中的数据。
int main(void)
{
int num = 97;
int *p1 = #
char* p2 = (char*)(&num);
printf("%d\n",*p1); // 输出 97,按4字节整数解释
putchar(*p2); // 输出 ‘a’,按1字节字符解释,取低地址字节
return 0;
}
p1和p2的值相同(都是&num),但类型不同,解引用时访问和解释内存的方式就不同。
取地址(&)与解地址(*)
- 取地址:使用
&运算符获取变量的地址。
int* p_num = #
int (*p_arr)[3] = &arr; // 数组指针
int (*fp_add)(int ,int ) = add; // 函数指针
注意:数组名、函数名作为右值时,本身就代表地址,可不用&。
- 解地址:在指针变量前加
*,可以读写它指向的内存数据。
int age = 19;
int*p_age = &age;
*p_age = 20; // 通过指针修改数据
printf(“age = %d\n”,*p_age); // 通过指针读取数据
指针的赋值
指针赋值是浅拷贝,仅仅拷贝了地址值,使得多个指针可以共享同一份数据。
int* p1 = #
int* p3 = p1; // p3 和 p1 现在指向同一个num

空指针(NULL)
NULL是一个标准定义的宏,表示空指针,不指向任何有效对象。在C语言中通常是((void*)0),在C++中是0。对NULL指针解引用会导致未定义行为(通常导致程序崩溃)。
重要实践:动态分配的内存被释放后,应立即将指针置为NULL,防止出现“悬空指针”和重复释放。
int *p = (int*)malloc(sizeof(int));
*p = 23;
free(p);
p = NULL; // 良好习惯
更安全的做法是封装释放函数或使用宏:
// 方法1:传入指针的地址
void my_free(void *p){
void **tp = (void **)p;
if(NULL == *tp) return;
free(*tp);
*tp = NULL;
}
// 使用:my_free(&p);
// 方法2:使用宏
#define FREE(x) if(x) free(x); x=NULL
// 使用:FREE(p);


void* 类型指针
void*是“指向任意类型的指针”。任何类型的指针都可以直接赋给void*指针,而无需强制转换。void*指针无法直接解引用,必须转换回具体类型后才能使用。它常用于内存管理相关函数(如malloc, free)的接口。
结构体与指针
访问结构体指针的成员使用->运算符。
typedef struct {
char name[31];
int age;
float score;
}Student;
int main(void) {
Student stu = {“Bob”, 19, 98.0};
Student*ps = &stu;
ps->age = 20; // 通过指针修改成员
printf(“name:%s age:%d\n”, ps->name, ps->age);
return 0;
}
数组与指针
- 数组名作为右值时,是首元素的地址。
int *p_first = arr;
- 指向数组元素的指针支持递增递减运算,移动单位是
sizeof(元素类型)。
- 指针差值:同一数组内,两指针之差等于其下标之差。
- 下标与指针的等价性:
p[n] 等价于 *(p+n)。
- 对数组名使用
sizeof:得到整个数组的字节大小。数组名赋值给指针后,对指针用sizeof得到的是指针本身的大小(如4或8字节)。这就是为什么传递数组给函数时,通常需要额外传递元素个数的原因。
函数与指针
函数的参数和指针
C语言参数传递是“按值传递”。若要修改实参,必须传递指针。
// 错误:无法交换
void swap_bad(int a, int b) { int t=a; a=b; b=t; }
// 正确:传递指针
void swap_ok(int*pa, int*pb) { int t=*pa; *pa=*pb; *pb=t; }


即使不想修改数据,传递大型结构体时使用指针(常指针)也比传递整个结构体副本更高效。
void show(const Student * ps) { // const 防止误修改
printf(“name:%s , age:%d , score:%.2f\n”, ps->name, ps->age, ps->score);
}
函数指针
函数名本身也是一个指针常量,指向函数的代码。函数指针常用于实现回调机制,是编程范式中强大而灵活的工具。
typedef int (*compare)(const void *x, const void *y); // 定义函数指针类型
int my_compare(const void *x, const void *y){
const int *a = (const int *)x; // 注意:这里原文有误,应为 const int *a = ...;
const int *b = (const int *)y;
if(*a > *b) return 1;
if(*a == *b) return 0;
return -1;
}
void my_sort(void *data, int length, int size, compare cmp){
// ... 利用 cmp 函数指针进行比较
}
int main() {
int arr[] = {2,4,3,656,23};
my_sort(arr, 5, sizeof(int), my_compare); // 传递函数指针作为回调
return 0;
}

const 与指针
理解const修饰的是谁,关键在于看const后面紧跟的是什么。
const int *p 或 int const *p:const修饰*p,即指针指向的数据是常量,不能通过p修改。
int * const p:const修饰p,即指针本身是常量,不能指向别处。
const int * const p:指针本身和指向的数据都是常量。
深拷贝与浅拷贝
- 浅拷贝:只拷贝了指针(地址),多个指针共享同一份实际数据。
- 深拷贝:拷贝了指针指向的实际数据,产生一份独立的副本,修改互不影响。

附加知识
大端模式(Big-Endian)与小端模式(Little-Endian)
- 小端模式:低位字节存储在低地址。Intel x86/ARM常用此模式。
- 大端模式:高位字节存储在低地址。网络字节序通常为大端。
例如,对于short a = 1(十六进制0x0001):
- 小端存储:地址
0x30存0x01,地址0x31存0x00。
- 大端存储:地址
0x30存0x00,地址0x31存0x01。

判断当前机器是否为小端模式的简单方法:
bool isLittleEndian() {
unsigned int val = ‘A’; // ‘A’ 的 ASCII 为 0x00000041
unsigned char* p = (unsigned char*)&val;
return *p == ‘A’; // 小端模式下,低地址存的是最低字节 0x41(即‘A’)
}
指针与引用的关系
指针和引用在本质上是相似的,都是对内存数据的间接访问。指针是C/C++中的显式概念,提供了更灵活(也可能更危险)的操作。而引用则出现在Java、C#等语言中,是语言层面封装了指针操作、更安全的语法糖。
理解指针,不仅仅是记忆语法,更是建立对计算机基础运行机制的心智模型。希望这篇全面的梳理能帮助你查漏补缺,更自信地驾驭C语言中最强大的工具之一。如果在实践中遇到具体问题,欢迎在云栈社区与更多开发者一起交流探讨。