一、指针变量
首先要明白指针就是一个变量,可以使用如下代码来验证:
# 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 = 10
& a = 6422216
a = 20
可以看到 a 的值被更改了,因此可以清楚地明白指针实质上是一个放置变量地址的特殊变量,其本质仍然是变量。既然指针是变量,那必然会有变量类型。 在 C 语言中,所有的变量都有变量类型,整型、浮现型、字符型、指针类型、结构体、联合体、枚举等,这些都是变量类型。变量类型的出现是内存管理的必然结果,我们都知道,所有的变量都是保存在计算机的内存中,既然是放到计算机的内存中,那必然会占用一定的空间,那么一个变量会占用多少空间呢?或者说应该分出多少内存空间来放置该变量呢? 为了规定这个问题,类型由此诞生,对于 32 位编译器来说,int 类型占用 4 个字节,即 32 位,long 类型占用 8 字节,即 64 位。在计算机中,将要运行的程序都保存在内存中,所有的程序中的变量其实就是对内存的操作。计算机的内存结构较为简单,这里暂时不详细谈论内存的物理结构,只谈论内存模型。将计算机的内存可以想象为一个房子,房子里面居住着人,每一个房间对应着计算机的内存地址,内存中的数据就相当于房子里的人。
既然指针也是一个变量,那个指针也应该被存放在内存中,对于 32 位编译器来说,其寻址空间为 232 = 4GB,为了能够都操作所有内存(实际上普通用户不可能操作所有内存),指针变量存放也要用 32 位数即 4 个字节,这样就有指针的地址 &p,指针和变量的关系可以用如下图表示:
可以看到 &p 是指针的地址,用来存放指针 p,而指针 p 来存放变量 a 的地址,也就是 &a,还有一个 *p 在 C 语言中是“解引”,意思是告诉编译器取出该地址存放的内容。可以自行思考一下:&(*p) 和 *(&p) 是什么意思,该怎么去理解? 关于指针类型的问题,针对 32 位编译器而言,既然任何指针都只占用 4 个字节,那为何还需要引入指针类型呢?仅仅是为了约束相同类型的变量么?实际上不得不提到指针操作,先思考如下两个操作:p+1 和 ((unsignedint)p)+1,该怎么理解? 这两个操作的意思是不同的,先说下第一种 p+1 操作,如下图所示:
对于不同类型指针而言,其 p+1 所指向的地址不同,这个递增取决于指针类型所占的内存大小,而对于 ((unsigned int)p)+1,则意思是将地址 p 所指向的地址的值直接转换为数字,然后 +1,这样无论 p 是何种类型的指针,其结果都是指针所指的地址后一个地址。 从上述的分析可以看到,指针的存在使得程序员可以相当轻松的操作内存,这也使得当前有些人认为指针相当危险,这一观点表现在 C# 和 Java 语言中,然而实际上用好指针可以极大的提高效率。 再深入一点来通过指针对内存进行操作,现在需要对内存 6422216 中填入一个数据 125,可以进行如下操作:
unsigned int * p = ( unsigned int * ) ( 6422216 ) ;
* p = 125 ;
当然,上面的代码使用了一个指针,实际上 C 语言中可以直接利用解引操作对内存进行更方便的赋值。
二、解引用
所谓解引操作,实际上是对一个地址操作,比如现在想将变量 a 进行赋值,一般操作是 a = 125,现在用解引操作来完成,操作如下:
* ( & a) = 125 ;
可以看到解引操作符为*,这个操作符对于指针有两个不同的意义,当在申明的时候是申明一个指针,而当在使用 p 指针时是解引操作,解引操作右边是一个地址,这样解引操作的意思就是该地址内存中的数据,这样对内存 6422216 中填入一个数据 125 就可以使用如下操作:
* ( unsigned int * ) ( 6422216 ) = 125 ;
上面的操作将 6422216 数值强制转换为一个地址,这个是告诉编译器该数值是一个地址,值得注意的是上面的所有内存地址不能随便指定,必须是计算机已经分配的内存,否则计算机会认为指针越界而被操作系统杀死即程序提前终止。
三、结构体指针
结构体指针和普通变量指针一样,结构体指针只占 4 个字节(32 位编译器),不过结构体指针可以很容易的访问结构体类型中的任何成员,这就是指针的成员运算符 ->。 如下所示,p 是一个结构体指针,p 指向的是一个结构体的首地址,而 p->a 可以用来访问结构体中的成员 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 ;
}
student-> name = 10
student-> gender = 20
可以看到 a[100] 被强制转换为 stu 结构体类型,当然不使用强制类型转换也是可以的,只是编译器会报警报。如下所示,数组 a[100] 的前 12 个字节被强制转换为一个 struct stu 类型,对数组进行了说明,其它数据类型也是一样的,本质上都是一段内存空间:
五、void 指针
void 类型很容易让人想到是空的意思,但对于指针而言,其并不是指空,而是指不确定。在很多时候指针在申明的时候可能并不知道是什么类型或者该指针指向的数据类型有多种,再或者仅仅是想通过一个指针来操作一段内存空间,这个时候可以将指针申明为 void 类型。 那么问题来了,由于 void 类型原因,对于确定的数据类型解引时,编译器会根据类型所占的空间来解引相应的数据,例如 int p,那么 p 就会被编译器解引为 p 指针的地址的 4 个字节的空间大小。但对于空指针类型来说,编译器如何知道其要解引的内存大小呢? 先看如下一段代码:
# include <stdio.h>
int main ( int argc, char * * argv) {
int a = 10 ;
void * p;
p = & a;
printf ( "p = %d\n" , * p) ;
return 0 ;
}
编译上面的代码可以发现,编译器报错,无法正常编译:
error:invalid use of void expression
这说明编译器确实是在解引时无法确定 *p 的大小,因此必须告诉编译器 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 ;
}
p = 10
可以看到结果确实是正确的,也和预期的想法一致,由于 void 指针没有空间大小属性,因此 void 指针也没有 ++ 操作。 总结:void 指针仅仅是一个没有指定类型的指针,即该指针只有地址数据属性,不具备解引时的空间大小属性。
六、函数指针
① 函数指针的使用说明
函数指针在 Linux 内核中用的非常多,而且在设计操作系统的时候也会用到,既然函数指针也是指针,那函数指针也占用 4 个字节(32 位编译器)。 以一个简单的例子说明:
# 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 ;
}
add ( 10 , 20 ) = 30
返回类型(* 函数名)(参数列表)
函数指针的解引操作与普通的指针有点不一样,对于普通的指针而言,解引只需要根据类型来取出数据即可,但函数指针是要调用一个函数,其解引不可能是将数据取出,实际上函数指针的解引本质上是执行函数的过程,只是这个执行函数是使用的 call 指令并不是之前的函数,而是函数指针的值,即函数的地址。其实执行函数的过程本质上也是利用 call 指令来调用函数的地址,因此函数指针本质上就是保存函数执行过程的首地址。 函数指针的调用如下:
函数指针调用(* ( 实参列表)
为了确认函数指针本质上是传递给 call 指令一个函数的地址,如下所示两段代码:
# include <stdio.h>
void add ( void ) {
printf ( "hello add\n" ) ;
}
int main ( int arg, char * * argv) {
void ( * p ( void ) ;
p = add;
( * р) О;
return 0 ;
}
# include <stdio.h>
void add ( void ) {
printf ( "hello add\n" ) ;
}
int main ( int arg, char * * argv) {
add ( ) ;
return 0 ;
}
0 ×4015 d5 push ebp
0 ×4015 d6 mov ebp, esp
0 ×4015 d8 and esp, 0xfffffff0
0 ×4015 db sub esp, 0x10
0 ×4015 de call 0x401690 < __main>
0 ×4015e3 mov exa, DWORD PTR [ esp+ 0xc ]
0 ×4015 eb call exa
0 ×4015 ef mov exa 0x0
0 ×4015f 1 leave
0 ×4015f 6 left
0 ×4015f 7 ret
0 ×4015 d5 push ebp
0 ×4015 d6 mov ebp, esp
0 ×4015 d8 and esp, 0xfffffff0
0 ×4015 db call 0x401680 < __main>
0 ×4015e0 call 0x4016c0 < add>
0 ×4015e5 mov exa 0x0
0 ×4015 ea leave
0 ×4015 eb left
可以看到,使用函数指针来调用函数时,其汇编指令多了如下:
0x4015e3 mov DWORD PTR [ esp+ 0xc ] , 0x4015c0
0x4015eb mov eax, DWORD PTR [ esp+ 0xc ]
0x4015ef call eax
第一行 mov 指令将立即数 0x4015c0 赋值给寄存器 esp+0xc 的地址内存中,然后将寄存器 esp+0xc 地址的值赋值给寄存器 eax(累加器),然后调用 call 指令,此时 pc 指针将会指向 add 函数,而 0x4015c0 正好是函数 add 的首地址,这样就完成了函数的调用。细心的您是否发现一个有趣的现象,上述过程中函数指针的值和参数一样是被放在栈帧中,这样看起来就是一个参数传递的过程,因此可以看到,函数指针最终还是以参数传递的形式传递给被调用的函数,而这个传递的值正好是函数的首地址。 函数指针并不是和一般的指针一样可以操作内存,因此函数指针可以看作是函数的引用申明。
② 函数指针的应用
在 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 封装成一个对象,结构体成员中最后一个没有初始化,在 Linux 中用的非常多,最常见的是 file_operations 结构体,该结构体一般来说只需要初始化常见的函数,不需要全部初始化,采用的结构体初始化方式也是在 Linux 中最常用的一种方式,这种方式的好处在于无需按照结构体的顺序一对一。
③ 回调函数
有时候会遇到这样一种情况,当 A 将一个功能交给 B 完成时,A 和 B 同步工作,这个时候该功能函数并未完成,这个时候 A 可以定义一个 API 来交给 B,而 A 只要关心该 API 就可以了,而无需关心具体实现,具体实现交给 B 完成即可,这种情况下就会用到回调函数(Callback Function),现在假设 A 需要一个 FFT 算法,这时 A 将 FFT 算法交给 B 来完成,现在来让实现这个过程:
# include <stdio.h>
int InputData[ 100 ] = { 0 } ;
int OutputData[ 100 ] = { 0 } ;
void FFT_Function ( int * inputData, int * outputData, int num) {
while ( num-- ) {
}
}
void TaskA_CallBack ( void ( * fft) ( int * , int * , int ) ) {
( * fft) ( InputData, OutputData, 100 ) ;
}
int main ( int argc, char * * argv) {
TaskA_CallBack ( FFT_Function) ;
return 0 ;
}
可以看到 TaskA_CallBack 是回调函数,该函数的形参为一个函数指针,而 FFT_Function 是一个被调用函数,回调函数中申明的函数指针必须和被调用函数的类型完全相同。