很多人觉得C语言最独特、最强大的特性就是指针。有些人认为它很简单,而另一些人觉得很难。当然,这里的“简单”或“难”并不等同于对指针真正的理解程度。
本文将对C语言中的指针进行全面的总结,从底层的内存分析入手,彻底理清指针的本质。我认为应该把C指针和C语言中的普通变量类型放在一起理解,因为指针本质上还是一个变量。但现在大部分教材将其单独拿出来讲解,容易让初学者误以为指针是一个与变量毫无关系的全新概念。
指针变量
首先要明确,指针是一个变量。我们可以通过下面这段代码来验证:
#include "stdio.h"
int main(int argc, char **argv)
{
unsigned int a = 10;
unsigned int *p = NULL;
p = &a;
printf("a=%d\n",a);
printf("&a=%d\n",&a);
*p = 20;
printf("a=%d\n",a);
return 0;
}

运行后可以看到变量 a 的值被成功修改了。这个例子清楚地表明,指针实质上是一个用于存放变量地址的特殊变量,其本质仍然是变量。
既然是变量,那必然有变量类型。在C语言中,所有的变量都有类型:整型、浮点型、字符型、指针类型、结构体、联合体、枚举等。变量类型是内存管理的必然结果。所有的变量都保存在计算机的内存中,并且会占用一定的空间。那么,一个变量到底占用多少空间呢?类型就是为了规定这个而诞生的。对于32位编译器来说,int类型占用4个字节(32位),long类型占用8字节(64位)。
这里简单介绍类型,主要是为了引出指针的特殊性。在计算机中,将要运行的程序都保存在内存中,程序中对变量的操作,本质上就是对内存的操作。我们可以将计算机内存想象成一栋大楼,每个房间对应一个内存地址,而内存中的数据就相当于房间里住的人。

既然指针也是一个变量,那么它也应该被存放在内存中。对于32位编译器,其寻址空间为2^32=4GB。为了能够操作所有内存(实际上普通用户不可能操作所有内存),存放一个地址需要32位数,即4个字节。因此,指针变量本身也要占用4个字节的空间。这样,我们就有了指针的地址 &p。指针、指针的地址和原变量三者的关系可以用下图表示:

从上图可以看到,&p 是指针变量 p 本身的地址,用来存放 p;而指针 p 存放的是变量 a 的地址 &a。操作符 * 在C语言中称为“解引用”,意思是告诉编译器:取出该地址中存放的内容。

前面提到过指针类型的问题。对于32位编译器,任何指针变量本身都只占用4个字节来存放地址。那么,为什么还需要引入指针类型呢?仅仅是为了约束它指向相同类型的变量吗?实际上,这与指针的运算密切相关。请先思考下面两个操作的区别:

这两个操作的含义是不同的。先说第一种:p+1。如下图所示:

对于不同类型的指针,p+1 所指向的地址是不同的。这个“加1”的步进取决于指针所指向的数据类型所占用的内存大小。而对于 ((unsigned int)p)+1,意思是将指针 p 所保存的地址值直接转换为一个无符号整数,然后进行数字加1。这样,无论 p 是何种类型的指针,结果都是原地址值加1后的新数字(可以理解为指向了物理上的下一个字节)。
从上述可以看到,指针的存在使得程序员可以相当轻松地操作内存。这也使得有些人认为指针相当危险,这一观点在C#和Java等语言的设计中有所体现。然而,用好指针可以极大地提高程序效率。下面我们更深入一点,通过指针对指定内存地址进行写入操作。假设我们需要向内存地址 6422216 中填入数据 125,可以这样操作:
unsigned int *p=(unsigned int*)(6422216);
*p=125;
当然,上面的代码显式使用了一个指针变量。实际上,C语言中可以直接利用解引用操作符对内存进行赋值,下面就来详细说说解引用操作 *。
解引用
所谓解引用操作,就是对一个地址所代表的内存空间进行操作。比如,现在想给变量 a 赋值 125,通常的操作是 a=125。现在我们用解引用操作来完成:
*(&a)=125;
可以看到,解引用操作符是 *。这个操作符对于指针有两个不同的意义:在声明时,它用于声明一个指针变量;而在使用已声明的指针时,它表示解引用操作。解引用操作符右边需要是一个地址,该操作的含义就是访问该地址内存中的数据。这样,我们向内存地址 6422216 中写入数据 125 就可以使用如下更直接的操作:
*(unsigned int*)(6422216)=125;
上面需要将数值 6422216 强制转换为一个指针类型(地址),这是为了告诉编译器该数值代表一个内存地址。 值得注意的是,上面指定的内存地址不能是任意值,必须是计算机已分配给当前程序的有效内存地址,否则计算机会认为指针越界,导致程序被操作系统终止(段错误)。
结构体指针
结构体指针和普通变量指针一样,在32位编译器下也只占4个字节。只不过,结构体指针可以很方便地访问结构体中的任何成员,这是通过指针的成员运算符 -> 实现的。

上图中,p 是一个结构体指针,它指向一个结构体变量的首地址。p->a 可以用来访问结构体中的成员 a。当然,p->a 和 (*p).a 是等价的。
强制类型转换
为什么要在这里特别提一下强制类型转换呢?在上面的测试代码中,编译器可能会产生很多“类型不匹配”的警告。虽然这些警告通常不影响程序的正确运行,但堆积的警告总会让人感到不安。为了明确地告诉编译器“这里没问题”,程序员可以使用强制类型转换,将一段内存解释为需要的数据类型。
例如,下面有一个整型数组 a,我们将其强制转换为一个结构体类型 stu:
#include <stdio.h>
typedef struct STUDENT
{
int name;
int gender;
} stu;
int a[100]={10,20,30,40,50};
int main(int argc, char **argv)
{
stu *student;
student=(stu*)a;
printf("student->name=%d\n",student->name);
printf("student->gender=%d\n",student->gender);
return 0;
}
上面的程序运行结果如下,它访问了数组前两个整型数据,并将其解释为结构体的两个成员:

可以看到,a[100] 被强制转换为 stu 结构体类型。当然,不使用强制类型转换编译器只会报警告而非错误,但为了代码清晰和消除警告,转换是有必要的。

上图为程序的示意图。图中数组 a[100] 的前8个字节(假设 int 为4字节)被强制转换为了一个 struct stu 类型的变量。这里仅对数组进行了说明,其他数据类型也是一样的,本质上都是对一段内存空间的重新解释。
void指针
为什么在这里单独提到 void 指针类型?主要是因为该类型非常特殊。void 类型很容易让人想到“空”,但对于指针而言,它并不是指空指针(NULL),而是指类型不确定。很多时候,在声明指针时可能并不知道它将来会指向什么类型的数据,或者该指针需要指向多种数据类型,又或者程序员仅仅想通过一个指针来操作一段原始内存空间。这时可以将指针声明为 void* 类型。
但问题来了:对于确定类型的指针(如 int* p),解引用时编译器会根据类型知道要操作多少字节(如4字节)。但对于 void 指针类型,编译器如何知道要解引用多大的内存空间呢?先看一段代码:
#include <stdio.h>
int main(int argc, char **argv)
{
int a=10;
void *p;
p=&a;
printf("p=%d\n",*p);
return 0;
}
编译上面的程序会发现,编译器报错,无法正常编译。

这说明编译器在解引用时确实无法确定 *p 操作的内存大小。因此,我们必须告诉编译器 p 所指向的内存应被如何看待。如何告诉呢?很简单,使用强制类型转换即可,如下:
*(int*)p
这样,上面的程序就可以修改为:
#include <stdio.h>
int main(int argc, char **argv)
{
int a=10;
void *p;
p=&a;
printf("p=%d\n",*(int*)p);
return 0;
}
编译运行后,结果正确:

由于 void 指针没有关联的空间大小属性,因此 void 指针也不能进行 ++、-- 等算术运算。

函数指针
函数指针使用
函数指针在Linux内核和操作系统设计中应用非常广泛。既然函数指针也是指针,那么它在32位编译器下同样占用4个字节。下面用一个简单例子说明:
#include <stdio.h>
int add(int a,int b)
{
return a+b;
}
int main(int argc, char **argv)
{
int (*p)(int,int);
p=add;
printf("add(10,20)=%d\n",(*p)(10,20));
return 0;
}
程序运行结果如下:

可以看到,函数指针的声明格式为:

函数指针的解引用操作与普通指针有所不同。普通指针解引用是取出数据,而函数指针的“解引用”本质上是调用函数。其调用格式如下:

实际上,执行函数的过程本质上是CPU使用 call 指令跳转到函数的入口地址。函数指针保存的就是这个入口地址。为了确认函数指针调用本质上就是传递一个函数地址给 call 指令,我们可以对比一下直接调用和通过函数指针调用的汇编代码:

上面是编译后的反汇编指令。可以看到,使用函数指针来调用函数时,其汇编指令多了如下几条:
0x4015e3 mov DWORD PTR [esp+0xc],0x4015c0
0x4015eb mov eax,DWORD PTR [esp+0xc]
0x4015ef call eax
分析:第一条 mov 指令将立即数 0x4015c0(即函数 add 的入口地址)赋值给栈上某个位置(esp+0xc)。然后,将该栈位置的值加载到寄存器 eax 中,最后执行 call eax 指令。此时程序计数器(PC)会跳转到 add 函数的首地址 0x4015c0,从而完成函数调用。
细心的读者可能会发现一个有趣的现象:上述过程中,函数指针的值(即函数地址)和普通参数一样被放在了栈帧中,看起来就像一个参数传递的过程。因此,函数指针最终还是以参数传递的形式将函数的首地址传递给了最终的 call 指令。
从上面可以看出,函数指针并非像普通指针那样直接操作内存数据,因此也有人将函数指针看作是函数的“引用”。
函数指针应用
函数指针在Linux驱动开发中实现面向对象编程思想时用得最多,常用来实现封装和多态。下面以一个简单的显示屏驱动模型为例:
#include <stdio.h>
typedef struct TFT_DISPLAY
{
int pix_width;
int pix_height;
int color_width;
void (*init)(void);
void (*fill_screen)(int color);
void (*tft_test)(void);
} tft_display;
static void init(void)
{
printf("the display is initialed\n");
}
static void fill_screen(int color)
{
printf("the display screen set 0x%x\n",color);
}
tft_display mydisplay=
{
.pix_width=320,
.pix_height=240,
.color_width=24,
.init=init,
.fill_screen=fill_screen,
};
int main(int argc, char **argv)
{
mydisplay.init();
mydisplay.fill_screen(0xfff);
return 0;
}
上面的例子将一个 tft_display 封装成一个“对象”。结构体成员中的函数指针 tft_test 没有被初始化,这在Linux内核中非常常见(例如 file_operations 结构体)。通常只需要初始化常用的函数指针,而不必初始化全部。代码中采用的结构体初始化方式(指定成员初始化)也是Linux内核中常用的一种方式,其好处在于无需严格按照结构体声明的顺序进行赋值。
回调函数
在模块化协作开发中,常会遇到这种情况:模块A需要模块B实现某个功能(例如一个算法),但模块B的实现尚未完成。此时,模块A可以先定义一个函数接口(API),并将一个函数指针作为参数传给模块B。模块A只关心这个接口被调用,而具体实现则留给模块B在将来完成。这种机制就是回调函数(Callback Function)。
假设程序员A需要一个FFT算法,并交由程序员B实现。我们可以用以下模型模拟这个过程:
#include <stdio.h>
int InputData[100]={0};
int OutputData[100]={0};
void FFT_Function(int *inputData, int *outputData, int num)
{
// 程序员B将来在此实现FFT算法
while(num--)
{
// FFT计算过程...
}
}
void TaskA_CallBack(void (*fft)(int*, int*, int))
{
// 程序员A的模块:调用传入的函数指针
(*fft)(InputData, OutputData, 100);
}
int main(int argc, char **argv)
{
// 将函数FFT_Function作为回调函数传入
TaskA_CallBack(FFT_Function);
return 0;
}
在上面的代码中,TaskA_CallBack 是回调函数,它的一个形参是函数指针 fft。FFT_Function 是被调用的具体函数。可以看到,回调函数中声明的函数指针类型必须和被调用函数的类型完全一致。
希望这篇从内存底层到高级应用的指南,能帮助你真正理解C语言指针的精髓。指针是C语言的灵魂,深入掌握它,意味着你能够更直接地与计算机系统对话。如果你对内存管理或计算机底层原理有更多兴趣,欢迎在云栈社区与更多开发者一起交流探讨。