谈到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语言写得,函数指针提供了操作数据的可能。第二个是用函数指针定义回调函数,来实现软件设计的分层。
具???而言,函数指针的定义如下:
int func(int i, char ch)
{
/* some codes */
}
int (*p)(int, char); // p就是一个专门指向形如func那样的函数的指针
p = &func; // 使得p指向func
p = func; // 取址符可省略
func(100, 'a'); // 直接调用函数
(*p)(100, 'a'); // 使用函数指针间接地调用函数
p(100, 'a'); // 解引用符可省略
根据以上所述,函数指针有两种用法,第一是在结构体中实现面向对象,实际就是用函数指针指向操作结构体数据的函数,用LINUX中VFS子系统中的例子来说明问题:
// VFS子系统的其中一个核心结构体,当系统打开一个文件时,内核用这个结构体来表达一个文件
struct file {
/*
* fu_list becomes invalid after file_free is called and queued via
* fu_rcuhead for RCU freeing
*/
union {
struct list_head fu_list;
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path;
#define f_dentry f_path.dentry
#define f_vfsmnt f_path.mnt
const struct file_operations *f_op;
/*
* Protects f_ep_links, f_flags, f_pos vs i_size in lseek SEEK_CUR.
* Must not be taken from IRQ context.
*/
spinlock_t f_lock;
#ifdef CONFIG_SMP
int f_sb_list_cpu;
#endif
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
loff_t f_pos;
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra;
u64 f_version;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
/* needed for tty driver, and maybe others */
void *private_data;
#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct list_head f_ep_links;
struct list_head f_tfile_llink;
#endif /* #ifdef CONFIG_EPOLL */
struct address_space *f_mapping;
#ifdef CONFIG_DEBUG_WRITECOUNT
unsigned long f_mnt_write_state;
#endif
};
// 在上面的file结构体中,有个f_op的成员如下所示,里面都是函数指针,这些函数指针就是被用来操作表达文件的那些数据的。
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
int (*readdir) (struct file *, void *, filldir_t);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*aio_fsync) (struct kiocb *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
};
函数指针的第二个用途,是用来实现软件的分层设计,这个比较抽象的概念,我们也用LINUX系统编程的一个实例来加以说明。
我们知道LINUX系统提供信号机制,当某个进程收到一个信号的 时候,进程将会首先保存这个信号,当进程被调度时检查信号的阻塞掩码,然后检查该信号是否被用户层捕捉,然后再执行用户自定义例程或者执行默认动作,然后返回进程的被信号中断的代码继续执行,这个过程中用户定义的例程是LINUX内核没办法提供的功能,但是又是整个信号的响应过程的不可分割的一部分,因此内核就对用户层提供了一个接口,让用户自己来定义这个例程(即信号响应函数),然后在用户调用信号捕捉函数signal的时候顺便告诉内核用户自定义的信号响应函数,即传递一个函数指针来实现。
下面是具体的代码:
void func(int sig)
{
/* 用户自定义信号响应函数 */
}
... ...
signal(SIGINT, func); // 捕捉SIGINT,顺便告诉内核其响应函数是func,函数名func就是函数指针
这样,当进程收到SIGINT时,就会进入内核,执行内核的相关动作,然后内核按照用户层提供的func这个函数指针,回到用户控件调用func函数,因此这个函数也被称为回调函数,回调函数实现了不同的程序模块由不同的人开发,而且又可以协调合作的目的。
联系客服