谈到C语言编程,数组和指针是很多人的心头大石,总觉得它们是重点难点,重点是没错的,但绝不是什么难点,要说C语言的难点,客观地讲应该是带参宏,而数组和指针,概念浅显易懂,操作简洁方便,根本不是很多初学者想象的那么困难,所以一开始就要有充分的信心,其次,恰恰是因为它们的“方便”,导致如果一不小心会用错,所以数组和指针,尤其是指针,与其说它难,还不是说他容易用错,我们在使用的时候要格外小心。
指针和数组,都涉及一个核心概念,就是地址,因此,我们从内存的地址开始给大家理清问题。
内存是一个个的存储单元,每一个存储单元称之为一个字节(byte),一个字节有8位(即8bit)。我们存储数据的基本单位是字节,在32位的CPU架构下,最大能支持4G的内存,也就是1024 * 1024 * 1024 个字节,这些字节统统都要一个编号,用来方便访问它们,就像一幢大厦里面有很多房间,每个房间都有个门牌号,比如101,102,103,和201,202等等,不同房间的功能差别巨大,比如101是个会议室,102是个储物室,103可能是个厕所,功能千差万别,但是门牌号是一样的。
完全一样的道理,内存单元的每一个字节都有编号,这个编号就是该字节的“地址”,比如0x00000001, 0x 0000FFFF, 这里之所以没有用101和102,只不过是因为内存地址太多了,三位数不足以表达,实际上我们需要一个32位的二进制数来表达,或者8位的十六进制数来表达。反正,每一个字节都有一个地址。
好了,至此,我们明白了内存单元至少会有两个属性,一个是这个内存单元里面装的内容,比如一个整数,或者一个浮点数,或者一个字符,或者一个结构体,甚至是一段代码都可以,另一个属性是这块内存单元的地址,也就是门牌号。当这块内存单元包含很多字节的时候,我们拿最小的地址,也就是基地址作为整块内存的地址,也称为起始地址。
比如 int a = 100, 这个变量 a 就是一块内存,内存里面放的内容是 100, 而这块内存的地址是 &a
再来 void f ( void ) { printf("helloworld"); } , 这个函数 f( ) 是一块内存,内存里面放的内容是一个打印 helloworld 的代码,而这块内存的地址是 &f
明白了内存单元的地址这个概念之后,要理解数组和指针,就很简单了。首先来谈谈数组。
在 C语言中我们是这样定义数组的: int a [10] ;
在上面的这个定义中,a 就是一个数组,是一个具有10个整型元素的数组,关键在于:这10个整型变量是一个挨着一个,紧密地排列在一起的,它们连成一片,我们将这整块内存起个名字,叫做 a。显然,由于每个整型变量的大小是 4 个字节,所以 整个数组的大小就是 4 * 10 = 40个字节。我们在来考虑 a 这个变量,这个变量的类型是 int [ 10] , 亦即 a 是一个具有10个整型元素的数组,那么它的值呢? 它的值就是 这块内存的基地址,也就是 第一个元素的地址。
下面是重点,不管你以前是如何理解数组的,请抛弃你头脑中所有模棱两可的概念,重新站在编译器的角度(是的,编译器的角度,不是我的角度)理解数组的定义:
当C编译器看到这样的定义语句:int a[10] 的时候,它会将这条语句拆分中两部分来看待,第一部分是 a[10] ,除此之外统统称为第二部分,在这里第二部分就是 int
第一部分: a[10 ] ,这里确凿无误地告诉编译器,请你给我一块连续的内存,而且这块内存要包含10个元素在里面。 说完之后你是不是觉得少了一点什么呢? 对了,你还没说这10个元素是什么呢?? 你要10个粽子还是要10根葱啊? 得说明白,这就是第二部分的事情了。
第二部分:int ,这里确凿无误地告诉编译器,刚才那10个元素,既不是粽子也不是小葱,而是10个整型变量。 ok,一切明白,我们要的就是一块连续的内存,里面装有10个整型变量,我们将这样的内存称为数组,准确地讲,这是一个具有10个元素的整型一维数组。
问个问题,刚才我们的10个元素是 int ,那能不能是 float呢? 能不能是 char 呢? 能不能是结构体呢?
答案是肯定的。下面再来从易到难再看两个例子:
int b[3][10]
有人看到以上定义可能会大叫:这是个二维数组! 是的,我们通常都会那么称呼它,但是现在咱们站在编译器的角度,编译器它可不认识什么二维数组,在它的法眼里,世界上只有一维数组,它实际上是这么看的: int (b[3]) [10];
第一部分:b[3] ,确凿无疑地告诉编译器,请你给我一块连续的内存,而且这块内存要包含3个元素在里面。这3个元素是什么呢?
第二部分:int [10] ,确凿无疑地告诉编译器,刚才那3个元素,既不是粽子也不是小葱,而是3个 int [10] 。 OK,一切明白,我们要的就是一块连续的内存,里面装有3个int [10] 变量,二 int [10] 是什么家伙呢? int [10] 就是上面说了半天的那个 int a[10], 准确地讲,这是一个包含了3个【具有10个整型变量的一维数组】的一维数组,这样说比较拗口,所以我们人为地发明了一个单词:二维数组。
再来一个例子:
char *c[10];
因为方括号 [ ] 的优先级比星号高,因此这个定义语句要这么看: char * (c[10]) ; 编译器拿到这样的语句,毫无疑问地也会 分成两部分来分析:第一部分 c[10] ,因此这是一个具有10个元素的数组,那么这10个元素又是啥呢? 答案就是 第二部分: char * ,也就是说,这是一个存放了10个 char * 的数组,称之为 char 型指针数组,也就是专门用来存放 char * 的数组。
好了,数组先到此打住,再来看指针的定义,你会发现编译器原来是有一套既定的统一的规则的。
比如 int *p;
这个定义无比简单,就是定义了一个整型指针p,同样地不管你以前是怎么理解指针的,现在请你跟编译器站在一起,从它的角度来看看什么是指针,没错,我们又要将这个定义分成两部分了:
第一部分: *p ,确凿无疑地告诉编译器,请你给我分配一块内存 p, 这块内存用来干嘛呢?因为p 的前面有个 星号,所以 p 既不是用来装猪饲料的,也不是用来装鸡蛋的,而是用来存放地址的! 前面已经说过,每一个字节都有一个编号,这个编号就是一个32位的二进制数,我们称之为该字节的地址,现在的这个 p,就是专门用来装地址的。既然是用来装地址的,那么要多大的变量才能装得下这个地址呢? 答案是在32位的系统里面需要4个字节,因为只有4个字节才能足以表达从 0b00000000 00000000 00000000 00000000 到 0b11111111 1111111 11111111 11111111 这样的内存单元地址。容易发现,每一个地址都是32位的一个二进制数,也就是需要4个字节来存放这个门牌号。
第二部分:int , 上面第一部分已经确凿无疑地知道了p是一个用来装地址的变量了,关键是那个地址所对应的内存是什么呢? 这个问题有第二部分来回答,int,说明 p 将来存放的地址所对应的内存是一个 int,换句话讲,p 是一个专门用来存放 int 型量的地址的,我们亲切地将 p 称为 int 型指针。
假如现在就有一个 int 型变量: int w = 100; 那么我们很自然地就可以将 w 的地址存放在 p 里面: p = &w ;这样,我们就说 p 指向了 w,如图:
现在明白了吧,所谓的指针,只不过就是用来装一个地址的内存而已,又因为我们可以将很多不同的量的地址交给指针来存储,所以又分为不同类型的指针,比如专门用来存放整型数据的地址的指针 int *p,我们把它称为整形指针,专门用来存放字符型数据的地址的指针 char *q ,我们把它称为字符指针,专门用来存放某一种函数的地址的指针
int (*k)( char ), 我们把它称为函数指针。
所有的指针都是用来存放地址的,而地址都是一个32位的形如 0b 00001101 00101101 00001110 11011101 这样的二进制数,(其实一般我们会用十六进制表示,比如0xFFFF1234),所以32位平台下的指针大小都是一样的,都是4字节的。
指针的区别不在于本身,而在于其所存放的地址所对应的数据不同,C语言中有各种各样的不同的数据类型,也就有各种各样不同的类型的指针,一般情况下不同类型的指针不能一起运算,或者那样的运算没有意义。指针的运算跟指针的类型是密切相关的。
循序渐进,再来举一个例子:
int **q;
看到此定义语句,也一定会有人大呼:二级指针! 没错,民间惯称二级指针,但是站在编译器的角度看,事情就更简单了,我们沿用上面的规则,这条语句其实是这样的:
int * (*q);
第一部分,没错,是 *q ,所以,你可以很淡定地说,这个 q 跟上面的那个 p 没什么本质的区别,它们都是一个 4 字节的变量,都是专门用来存放别人的地址的。
关键是第二部分: int * ,这里说明,q 是一个专门用来存放 int * 型变量的地址的,刚好上面的那个 p 它就是 int * 的变量,于是我们就可以很自然地将 p 的地址赋值给q。
看到上面这幅图,p被称为一级指针,q 被称为二级指针,指针是一种可以间接访问的机制,比如现在要访问变量w,使得它的值变成200,可以有3种办法:
1, w = 200,
2. *p = 200,
3. **q = 200
以上三个式子都是等价的。
下面讨论一下两个主题来结束本节内容,第一是函数指针问题,第二是函数中的数组参数问题。
函数指针,就是指向函数的指针,函数指针的用处非常广泛,在C程序开发中,函数指针的作用主要有两个,第一个是在结构体中增加函数指针,在C中实现面向对象,就像linux内核中的VFS子系统,是一个典型的面向对象思想的东西,但是都是用C语言写得,函数指针提供了操作数据的可能。第二个是用函数指针定义回调函数,来实现软件设计的分层。
具体而言,函数指针的定义如下:
根据以上所述,函数指针有两种用法,第一是在结构体中实现面向对象,实际就是用函数指针指向操作结构体数据的函数,用LINUX中VFS子系统中的例子来说明问题:
函数指针的第二个用途,是用来实现软件的分层设计,这个比较抽象的概念,我们也用LINUX系统编程的一个实例来加以说明。
我们知道LINUX系统提供信号机制,当某个进程收到一个信号的 时候,进程将会首先保存这个信号,当进程被调度时检查信号的阻塞掩码,然后检查该信号是否被用户层捕捉,然后再执行用户自定义例程或者执行默认动作,然后返回进程的被信号中断的代码继续执行,这个过程中用户定义的例程是LINUX内核没办法提供的功能,但是又是整个信号的响应过程的不可分割的一部分,因此内核就对用户层提供了一个接口,让用户自己来定义这个例程(即信号响应函数),然后在用户调用信号捕捉函数signal的时候顺便告诉内核用户自定义的信号响应函数,即传递一个函数指针来实现。
下面是具体的代码:
这样,当进程收到SIGINT时,就会进入内核,执行内核的相关动作,然后内核按照用户层提供的func这个函数指针,回到用户控件调用func函数,因此这个函数也被称为回调函数,回调函数实现了不同的程序模块由不同的人开发,而且又可以协调合作的目的。