您的位置:首页 > 运维架构 > Linux

Linux下C编程知识整理

2015-07-22 23:31 483 查看
Linux下C编程概要

第一个问题,假设我是一个初学者,我需要学习的第一个知识并非C语言本身,而是可供我编辑、编译、验证乃至调试C语言程序的编程环境,如果你没有一个合适的编程环境,那么C语言的学习将是空中楼阁,无从谈起。

因此我们的第一个任务便是搭建一个属于自己的编程平台,由于针对Linux编程,我推荐大家直接使用Linux系统,Linux系统有许多发行版,选择一个易用的就行了,比如我比较推荐UbuntuRedHat也是一款很成熟的Linux操作系统,但是redhat作为服务器Linux加载的服务太多,我们拿着这么一台为服务器准备的系统当成PC来用不大合适,比如开关机时间就很长(服务器一般不会天天开关机)。

ubuntu操作系统的安装ISO镜像在网上到处都有,也可以通过ubuntu的官网下载:http://www.ubuntu.com/。下载下来之后可以安装在虚拟机里,也可以刻成光盘安装在硬盘上,随你喜好。

好了,下面来正式讲解C编程技术。这里,我们先不要着急去学习具体的语法点,而是来个统观全局,看看一个C程序是长成什么样子的,有哪些要注意的地方,撇开语法细节,注重整体的程序编写布局和规范,这是一个非常重要的步骤。就像厨师学厨艺,不是一来就马上学习每一道菜怎么做,而是先观察师傅做出来的菜是什么样的,从大的方面加以学习和模仿,学习如何将菜做的好看,摆放惹眼,然后再考虑好吃,营养丰富等方面,如果一盘菜看起来就让人倒胃口,即使再可口营养再丰富,也是大打折扣的。写代码也是一样的,一开始要学会如何组织你的代码,使人看起来舒服,样式规范,风格一致,然后再来讨论如何使得代码的性能更好,算法更紧凑。搞清写代码的总体要求,这样在后面的细节学习的时候,就有个总绳可以遵循。

下面是一个完整的C程序:

//example1.c

#include <stdio.h>

#include <string.h>

#include <strings.h>

int main(void)

{

char buf[100];

bzero(buf, 100);

fgets(buf, 100, stdin);

printf("you have input %d letters\n", strlen(buf));

return 0;

}



//example1.c

#include <stdio.h>

#include <string.h>

#include <strings.h>

int main(void)

{

char buf[100];

bzero(buf, 100);

fgets(buf, 100, stdin);

printf("you have input %d letters\n", strlen(buf));

return 0;

}



注意观察上面的程序,有这么几个要点需要谨记:

1, 写程序的时候,代码块内部的代码,一定要有缩进,注意到在main函数中,左花括号和右花括号分别各自独占一行,而里面的代码统统缩进8个空格。注意,一定要缩进而且最好统一缩进8个空格,这样做的好处是使得代码块逻辑清晰,结构分明。

2,注意适当的地方要有空格,就像英文文档的普遍书写习惯一样,一般在标点符号的右边都空一下。另外,如果程序中有赋值号“=”也最好在左右两边空格一下,不要让你的代码统统挤在一起。

3,要注意在适当的地方有空行,比如在不同的逻辑块之间,在上面的例子中,写完最开头的三行#include语句之后,是main函数,它们之间没有紧密的逻辑关系,可以空行一下。另外在程序代码中,也要适当空行,不然你的代码将会很快使人疲乏,适当的空行会使生活更美好,而且空行空格是免费的,不要吝啬它们。

上面几点是编程规范,下面再来看看example1.c中具体的代码:

1,最开始的一行是//example1.c 这是一个注释语句,因为它以两个正斜杠开头。凡是以两个正斜杠//开头的语句,在C语言中都将被视为注释语句,所谓的注释语句就是在编译的时候会被丢弃的语句,这样的语句用于开发者在程序中书写自然语言,来对复杂的代码加以解释,方便阅读。

另外,C语言中的注释还可以是这样的形式:

/* 这是一行注释,对代码的运行不起任何作用 */

/*

这样的注释是C语言的传统风格

可以同时注释多行,但是不能嵌套

这些注释语句在编译时都将被丢弃

*/

上面的注释语句是用/* ... ... */ 包含起来的。写注释是对阅读代码的人负责的表现,如果一个程序的实现逻辑非常复杂,并不能一眼能看懂,那么不加注释对于程序的阅读者和维护者而言将会是一场噩梦。那么注释写什么呢?答案是:写代码的功能,而不是写原理。比如你写了一个函数,这个函数用了高深的数学原理实现了求解某范围内的所有素数,那么你的注释写的就是它的功能:寻找素数。而不是写它是如何运用数学原理找到的。我们都是实用主义者,我们知道怎么用就行了,其他的不关心。

2,接下来的三行是预处理指令#include .... 所谓的预处理指令就是以#开头的语句。(除了黏贴符##之外,我们以后在讲解复杂宏的时候会讲解)预处理指令其实不是C语言的一部分,这些指令由专门的称之为预处理器的软件cpp来完成,而且#include <xxxxxx.h> 语句的作用就是将所指定的文件直接拷贝到当前行。这些文件的后缀是.h,h的意思就是head的意思,因此这些文件也被称为头文件,它们在哪儿呢?答案是系统的标准头文件路径中,一般是在/usr/include里面。

为什么要包含这三个头文件呢?我们就这个具体的例子而言,第一,我们用了库函数printf(),所以我们要包含stdio.h这个头文件,因为函数printf()的声明在stdio.h里面。同理,函数bzero()和函数strlen()的声明分别放在了strings.h和string.h里面,因此它们都需要被包含。关于函数的声明和使用,在后面详细讲解。

3,接下来是一个main函数:

int main(void)

{

.... ....

}

请注意,C程序中,必须包含这么一个东西。这个叫做main函数的东西是整个程序的入口,换句话讲,不管你的这个函数写在哪里,你的程序执行的时候,都是从这个函数开始的,所以它叫主函数。注意这个主函数的书写规范:int main(void) 前面的int表示这个函数的返回值类型是整型,不可写成void也不可不写,有些书籍上面介绍C语言的时候,主函数的返回值类型不是int,那不是因为书写错了,而是因为那些书所介绍的C语言不是LINUX下的C语言,LINUX是遵循GNU-C语法的,因此我们现在学习的其实是GNU-C,所谓的GNU-C其实是一套在标准C语言的基础上带增强功能的C规范,那些增强的功能需要编译器额外的支持,所以我们用的编译器是gcc,在gcc编译器下的主函数返回值类型必须是int类型。

另外,main函数的参数是void,这表示此程序不需要任何命令行参数。main函数的参数还可以写成这样:main(int argc, char **argv) 这个我们留到后面再讲。

在函数体中,首先是定义了一个数组buf,然后用bzero()将它清零,然后用fgets()函数等待用户的输入,并将用户输入的字符串放置到数组buf中,然后用strlen()函数计算用户刚刚输入了几个字符,然后再用printf()将结果打印到屏幕上。

再来观察一下,程序的最后一行是一个return语句:return 0; 首先,这个return关键字有两个作用,第一:出现在普通函数中的时候,代表返回调用者。第二,出现在主函数中的时候,代表退出整个进程。这里是第二种情况。一旦程序执行到这个return语句,我们的这个程序就game over了! 那么后面的那个0是神马呢?可不可以是1呢? 2呢? 答案是这样的:后面的那个数字就是main函数的退出值,这个退出值将会被传递给我们这个进程的“父进程”! 是的,这个进程是有爸爸的,就像每个人都���父母一样。我们的进程在退出的时候将会把自己的情况报告给它的父进程,一般规则是:正常退出返回0,非正常退出返回非0.
我们的进程寿终而寝,属于正常退出,因此返回了一个0,否则如果是碰到了权限不足、内存不够、参数有误等等异常情况,导致程序无法执行下去,这时可以返回一个非0值来告诉它的老爸。

好了,这就是example1.c 你学到了多少呢? 这个程序分析完了,我们想要来运行它,怎么运行呢? 很简单,我们使用一款名为gcc的编译器,来对它进行编译,编译的命令如下:

www.linuxidc.com@ubuntu:~$ gcc example1.c -o example1 -Wall

上面的命令,表示使用gcc编译器,将example1.c 编译生成一个叫 example1 的文件,-Wall的意思是将所有的Warning打开,也就是说如果我们写的代码有任何语法问题,都欢迎gcc帮我们显示出来,我们不会怪罪它啰嗦,因为我们要学习嘛。(有时gcc是很罗嗦的,会爆出很多无关痛痒的警告)

这样,你就拥有了一个可执行文件 example1 了,执行它:

上面的命令,表示使用gcc编译器,将example1.c 编译生成一个叫 example1 的文件,-Wall的意思是将所有的Warning打开,也就是说如果我们写的代码有任何语法问题,都欢迎gcc帮我们显示出来,我们不会怪罪它啰嗦,因为我们要学习嘛。(有时gcc是很罗嗦的,会爆出很多无关痛痒的警告)

这样,你就拥有了一个可执行文件 example1 了,执行它:

www.linuxidc.com@ubuntu:~$ ./example

hello

you have input 5 letters

www.linuxidc.com@ubuntu:~$

你输入了一个字符串 hello,程序帮你计算出来,你刚刚输入了 5 个字符。有趣吧? 如果这是你第一次编写程序,能跟你的程序进行交互将会是一个极好的开端,下一节我们继续征战LINUX下C语言编程。


基本数据类型

C语言是一种强数据类型编程语言,换句话讲,不像弱数据类型的语言比如shell脚本语言那样,没有特殊的变量数据类型,统统都是字符串。而C语言是有严格的数据类型的规定的。来看一下下面这段代码:

//example2.c

#include <stdio.h>

int main(void)

{

char c = 'A';

int i = 100;

float f1 = 3.14;

double f2 = 2.69E-12;

printf("%c, %d, %f, %lf\n", c, i, f1, f2);

return 0;

}

这是一段最简单的代码,程序定义了4个类型不一的变量,它们分别是char型变量c,int型变量i,float型变量f1,和double型变量f2. 而且在定义的时候给它们分别进行了初始化。

首先来观察一下这个语句:

char c = 'A';

这是一个定义语句,这条语句的确切含义是:要求系统为我开辟一块内存空间,用来存放一种称之为 char 类型的数据。其中标识符 c 就是这块内存空间的名字,因此这是一块有名内存(后面会提到匿名内存),我们可以通过 c 这个名字来访问这块内存。

那么这块内存空间 c 有多大呢? 这跟定义时指明的数据类型相关,由于定义的时候指明是 char 类型,因此这块内存的大小就是 1 个字节(即 1 byte,亦即 8 bits)。

另外更为重要的是,由于定义时指明了这块内存的数据类型是 char ,因此以后程序在解释这块内存 c 时就会将它解释成一个 字符,而不是一个浮点数。这里必须搞清楚的一点是:在内存中只有1和0组成的数据序列,定义时规定的数据类型就是用来告诉系统,将来拿到一块充满了1和0的内存时,该怎么去解释它,因此不同的解释方法,将会得到完全不同的结果。

上面的定义 char c = 'A', 第一步,在内存中开辟了一块空间,名字叫 c ,而且专门用来存放字符(亦即char型)数据,紧接着我们就将字符常量 'A' 赋值给 c ,这里有个问题,字符常量 'A' 是怎么放进去这块内存的呢? 不可能在内存单元里面刻了一个字母 'A' 吧?

答案当然是否定的,内存中根本不可能存储一个字母'A',因为内存只能存储1和0. 怎么办呢? 很简单,人为规定一个数字来对应字母'A'就可以了,比如就用65吧,既然我们无法真正存储字母 ‘A',那就存储65吧! 不用担心你的'A'会丢失,因为按照这个规定,以后我们凡是遇到 char 型的单字节内存,而且里面如果恰好是一个65,我们就知道,哦!原来这家伙是'A'!

完全一样的道理,我们也无法真正存储字母'B','C','D',以及任何其他可见或不可见字符,比如 '@' '#' '$' '!' '~' '\n' '\b' 等等,所有这些字符都必须统统像'A'那样用一个数字来代替它们,于是,我们就有了所谓的ASCII码表。亦即数字 -- 字符 对照表。

我们可以在Ubuntu的man帮助手册中查看ASCII码表:

vincent@ubuntu:~$ man ascii



上图是ASCII码表的一部分,我们现在很清楚地知道:在计算机的内存中,实际上是不存在字符的,而仅仅存放代表这些字符的数字,我们将这些人为规定的数字称为这些字符的ASCII码值。



现在回过头来再看看这个定义语句: char c = 'A' ;

这句话的含义是:在内存中开辟一块空间(1个字节),并命名为 c , 并且与此同时,将字母‘A' 的ASCII码值65放到这个字节里面。



再来看定义语句 int i = 100;

现在理解起来就很容易了:在内存中开辟一块空间(4个字节),并命名为 i ,并且与此同时,将 100 放到这块内存中。



同理,对于float f2 = 3.14 和 float f2 = 2.69E-12 而言:在内存中开辟了两块空间(4字节和8字节),并且与此同时,将3.14和2.69 乘以 10 的 -12 次方放到这两块内存中。



现在来考虑一下整型int,int 的意思是 integer ,亦即整数。显然 int 就是专门用来存放整型数据的。比如 int i = 623723869; 其真实的物理存储如下:



这里的 int 类型占用了内存中的4个字节,但这并不是绝对的,实际上,int 类型究竟会占几个字节,要根据具体的软硬件平台而定,C语言标准并没有规定 int 类型要占用几个字节,这样的结果是不同平台的 int 型变量的长度的不一致性,这将导致程序的移植问题。(稍后我们会看到如何解决这个问题)

从上图中可以看到,程序在存储数字的时候,都是用二进制来存储的,比如上面的 i 的值623723869,相当于二进制 00100101 0010110101000101 01011101。这就是整型数据在内存中的存储。



另外我们要注意,最高位实际上是一个符号位,那意味着:假如最高位是0,则这是一个正数,假如最高位是1,则这是一个负数。当最高位是1时,这个负数是以补码的方式存储的,比如:



补码是真正的物理存储格式。也就是说,整型数据 -9, 在内存中的表示是: 11111111 11111111 11111111 11110111. (4个字节)



另外,有时候我们可能不需要4个字节来表达一个整型,比如一个人的年龄,一般范围是0岁 - 120岁 左右 ,这时不需要太大的内存,那样只会浪费空间。有些时候我们又需要表达更大的数据,比如表达银河系中的恒星个数,这时可能4个字节的整型都不足以表达。

针对这些特殊情况,我们可以用 short 或者 long 来修饰关键字 int,调整数据范围。比如:

short int a; // 经常省略 int ,写成:short a;

long int b; // 经常省略 int ,写成:long b;

我们称 a 是一个短整型的变量,它的长度不会比 int 长。称 b 为长整型变量,它的长度不会比 int 短。 大家有没有觉得这段叙述有点怪? 为什么不直接说它们占用多少个字节呢?因为我们也无法确定,C语言标准就是像刚才那么说的,并没有规定它们占用几个字节,具体的情况要根据软硬件平台而定。

但是也是有规律的,一般而言:

char 类型 占用1个字节

short 类型 占用2个字节

long 类型 占用的字节数跟CPU的字长相等

而 int 类型则不一定,比如在字长是64位的CPU平台下,可能是4个字节也可能是8个字节。



现在的问题是:如果我需要在不同的平台下移植我的程序,而且我要求我的变量的大小是固定的,比如4个字节大小,怎么办呢?

办法是,不能直接使用基本数据类型来定义,而是要将它们稍微封装一下,具体如下:比如在A平台中,int 占2字节,long 占4字节。 在B平台中,int 占4字节,long 占8字节。

那如果我要定义一个占用4个字节的整型变量 var , 就不能定义成 int var; 或者 long var; 因为这样无法让其同时在A平台中和B平台中长度一致,我们说这样的数据类型是不具可移植性的。那怎么才可以有可移植性呢?

在A 平台中,我们把 int 取个别名,叫做 int16_t, 把 long 也取个别名,叫 int32_t

在B平台中,我们把 int 取个别名,叫做 int32_t ,把 long 也取个别名,叫 int64_t

这样,我们如果想要定义一个长度固定为 4 个字节的整形变量var,我们可以这样定义: int32_t var; 这样,不管你将程序放在A平台中还是B平台中,都可以实现长度固定的想法。

至于如何给int 和 long 取别名就很简单了,在A平台中,我们提供这样的代码:

typedef int int16_t;

typedef long int32_t;

在B平台中,我们提供这样的代码:

typedef int int32_t;

typedef long int64_t;

typedef 就是给一种数据类型取别名的,这样,当你的程序需要从一个平台移植到另一个平台时,只需要重新编译就可以了,不需要修改任何代码。

另外,还有一个关键字可以用来修饰整型: unsigned,比如这样定义变量:

unsigned int a;

unsigned short int b;

unsigned long c;

上面定义了三个 “无符号” 整型变量a,b和c。这些变量不能表示负数,系统在解释它们的时候,不再将最高位解释成符号位,而是当做最高权值位。

现在来谈谈浮点数,我们可以定义 float 型单精度浮点变量,double 型双精度浮点变量,long double 型长双精度浮点变量。它们可以用来存储带小数的实数。比如:

float f1;

double f2;

long double f3;

一般而言,它们分别占用4个,8个和12个字节的内存,占用的内存空间越多,能表达的范围和精度越大。具体它们是怎么存储的,精度又怎么确定,请看我的另一篇博客:http://www.linuxidc.com/Linux/2014-05/101243.htm , 里面有详细的剖析。

浮点数常量一般有两种表达方式,一种就是最简单的方式,直接写,比如: 3.1415, 或者877.52 等。 另一种就是科学计数法,比如: 前面的 877.52 可以写成 8.7752E2,这的 E2 代表的是10的2次方,同样道理,如果是 4.21E-5, 则表示4.2 乘以 10的 -5 次方。 E可以写成小写的e。

最后一个问题,类型转换的问题。当在一个表达式中出现有不同的数据类型的时候,会有两种结果,第一:类型不兼容,编译出错! 比如,你将一个浮点数跟一个结构体相加,完全牛头不对马嘴,编译直接出错。 第二,类型不同但可兼容,比如 一个整型跟一个浮点型相加,则��将整型临时提升为浮点型,再相加。我们现在来讨论第二种情况。

举个例子:

int a = 100;

double f1 = 3.14;

float f2;

f2 = a + f1;

代码中,三个变量类型都不相同,但是是兼容的,于是,在执行第5行的时候,系统会将所有的变量临时地提升为它们中精度最高的那种类型,比如在我们这个例子中,f1 是double 类型的,精度最高,因此其他的所有变量都将在这个运算过程中被临时地提升为 double 型参与运算,并最终的结果保存在一个 float 型的变量 f2 中。这么做的原因在于,系统在默认的情况下,只能采取最保守的方法,用最大的代价来保证用户数据在运算的过程中不丢失精度,因为如果你将一个浮点数转化成整型的话,其小数部分就会丢失了。这种系统帮我们自动将“小”类型转换成“大”类型的行为,称为隐式类型转换。



那如果我非要把精度高的数据类型转化成精度低的类型呢? 也可以,那就要显式地指明,比如上面的那个例子:

int a = 100;

double f1 = 3.14;

float f2;

f2 = a + (int)f1;

在 f1 的前面加了一对圆括号,里面写了一个 int ,表示要在中间的运算过程中强制将 f1 临时降格为 int 类型(这样可能会导致精度的丢失),然后参与运算。

最后要注意,不管是隐式类型转换,还是强制类型转换,转换的都是中间过程中的临时“替身”, 变量本身的类型和值不会发生变化。


基本IO函数操作

在学习C语言的时候,基本的输入输出是非常重要的,因为我们时常要接收用户的输入,而且时常要将结果输出到屏幕,因此非常有必要注重地学习一下。

最常用的两个基本IO函数莫过于 printf() 和 scanf() 了,下面首先来详细地介绍一下它们。来看第一个示例代码:

//example1.c

#include <stdio.h>

int main(void)

{

char a1 = 'A';

int a2 = 100;

float a3 = 3.14;

printf("a1=%c, a2=%d, a3=%f\n", a1, a2, a3);

short int a4 = 10;

long int a5 = 20;

long long int a6 = 30;

printf("a4=%hd, a5=%ld, a6=%lld\n", a4, a5, a6);

}

在上面的代码中,第8行和第13行就是将各种不同的数据类型打印出来,这里,%c %d %f %hd %ld %lld 这些称之为 格式控制符,其实,printf 和 scanf 函数之所以称为格式化IO函数,就是因为它们可以根据不同的格式控制符来处理各种类型的数据。

简单地讲,%d 用来表示 十进制 有符号 整型。%c 表示 字符。%f 表示浮点型数据。具体的所有的格式控制符的详细解释,请看下面的代码:

//example2.c

#include <stdio.h>

int main(void)

{

char a1 = 'A';

printf("%c", a1); // c就是character字符的意思

short a2 = 10;

printf("%hd", a2); // h就是half一半的意思,表示a2是“半个”整型

int a3 = 100;

printf("%d", a3); // d就是decimal十进制的意思

long a4 = 100;

printf("%ld", a4); // l就是long的意思,表示a4是一个长整型

long long a5 = 100;

printf("%lld", a5); // ll就是long long的意思,表示a4是一个长长整型

float f1 = 1.0;

printf("%f", f1); // f就是float的意思,表示f1是一个单精度浮点型

double f2 = 1.0;

printf("%lf", f2);

long double f3 = 1.0;

printf("%Lf", f3); // 注意下,长双精度不是 %llf ,而是 %Lf

return 0;

}

下面来看看 scanf() ,这个函数是用来接收用户输入的,比 printf() 要考虑的更多一点,但是他们的格式控制符是一致的,也就是说,对于example2.c 里面看到的所有在 printf() 函数中使用的 格式控制符,在 scanf() 函数中完全适用,具体而言,scanf() 函数的用法如下:

/example3.c

#include <stdio.h>

int main(void)

{

char a1;

scanf("%c", &a1); // 将用户输入的一个字符,放置在a1里

scanf("%hhd", &a1); // 将用户输入的一个数值,放置在a1里

short a2;

scanf("%hd", &a2); // 将用户输入的一个短整型,放置在a2里

int a3;

scanf("%d", &a3); // 将用户输入的一个整型,放置在a3里

long a4;

scanf("%ld", &a4); // 将用户输入的一个长整型,放置在a4里

long long a5;

scanf("%lld", &a5); // 将用户输入的一个长整型,放置在a5里

float f1;

scanf("%f", &f1); // 将用户输入的一个单精度浮点数,放置在f1里

double f2;

scanf("%lf", f2); // 将用户输入的一个双精度浮点数,放置在f2里

long double f3;

scanf("%Lf", f3); // 将用户输入的一个长双精度浮点数,放置在f3里

return 0;

}

值得注意的是第 6、7行。我在《LINUX-C成长之路(二)》中讲过,char 型其实就是整型,特点是单字节整型,因此 a1 其实就是用来存储一个整数的,这时,当用户从键盘按下“9” 这个按键的时候,你的程序究竟是要存储一个字符 '9' 呢? 还是要存储一个数字 9 呢? 注意字符 '9' 跟数字 9 是不同的,如果是字符 '9' 的话,我们内存中存储的实际上市它所对应的ASCII码值,也就是57, 如果是数字 9 ,那就是在内存中直接存储 9 。

所以,第6行是将用户的输入当成是字符,此时a1将会存储用户输入的字符的ASCII码值。 第7行是将用户的输入当成数字,而且要将这个数字放置在单字节变量 a1 里面,因此第 7 行的格式控制符是 %hhd ,前面的 hh 表示 half half ,也就是一半的一半的整型,也就是单字节整型。

下面,研究一下一个很重要的问题:对于 scanf() 而言,假如你要用户输入一个整数,或者输入一个浮点数,但是用户很调皮,偏不输入一个规规矩矩的你想要的数据,你的程序会如何呢? 是立即就罢工甚至爆炸呢? 还是有足够的智能来处理这些小顽皮的捣蛋呢?请看下面的例子:

//example4.c

#include <stdio.h>

int main(void)

{

printf("请输入一个0-127之间的整数\n");

char c;

scanf("%hhd", &c);

printf("这个数对应的字符是: %c\n", c);

return 0;

}

在example4.c 中,如果用户规规矩矩地输入你所要求的 0-127之间的 ASCII码值,程序当然可以顺利运行,打印出其对应的字符,但问题是假如用户输入诸如:

abcd 或者 80.5 或者 80abcd 或者 abcd80 , 但example4.c 遇到这样的输入的时候,它是无能为力的。因为它没有任何的输入确认的判断。

首先我们要知道一个事情,就是scanf() 这个函数的返回值,事实上,scanf() 这个函数的返回值代表了其正确地得到了用户输入的数据的个数,举个例子就明白了,就如有这个一段代码:

int a, b, c;

int n;

n = scanf("%d%d%d", &a, &b, &c);

那么用户的输入分别是:

1,依次输入 100 200 300



2,依次输入 100 abc 300



3,依次输入 abc 200 300



n 的结果,则分别是 3, 1, 0. 当用户是第一种情况的时候,100,200和300都正确地被赋值到了a,b和c3个变量中。因此scanf正确得到了 3 个值,所以返回3. 第二种情况,scanf 只能正确地得到 100 这个数据并将其赋值给 a,但是后面的 abc 不是一个整数,因此被 scanf 拒绝,且 scanf 此时立即返回 1,代表正确得到了1 个值。在第三种情况下,用户一来就输入了 abc,scanf() 函数发现状况不妙,后面的 200,300 都不被理睬,返回了一个 0 ,代表啥也没得到,啥也没赋值。



关键是:那些没有被正确匹配并且赋值的输入,到哪儿去了? 比如第二种情况下的 abc 和 300, 第三种情况下的 200 和300,它们会自动消失吗? 答案是否定的。事实上,这些用户输入的数据并非直接了当地传递给了程序中的变量,而是先由标准IO库函数“暂管”起来,将它们放在一个缓冲区中,然后,scanf() 函数从这个缓冲区中逐个地拿取数据,当数据的类型匹配时,就继续拿取,当数据的类型不匹配时,则立即停止拿取数据,并返回已经拿取的数据个数。



因此上面的第一种情况是:将100,200和300都拿取过来,分别赋值给a,b和c,然后返回一个3,代表 scanf() 从缓冲区中正确拿取了3个数据。



第二种情况是:将 100 拿取过来,赋值给a,然后碰到 abc 不符合要求,因此立即停止拿取数据,并返回 1, 代表拿了一个数据。注意,此时 abc 和 300 并未消失,而是残留在缓冲区中。



第三种情况是:一开始就遇到 abc,不符合格式要求,因此 sacnf() 立即返回一个 0 ,代表啥也没拿到。同理,此时用户输入的 abc,200 和 300 并未消失,也残留在缓冲区中。



知道了这个道理,我们在要求用户再次输入数据的时候,就要注意了,因为上次格式不匹配的数据会残留在缓冲区中,请看下面的错误的代码:

//example5.c

int main(void)

{

printf("请输入一个整数\n");

int n, ret;

while(1)

{

ret = scanf("%d", &n);

if(ret != 1)

printf("格式不对,请再输一遍!\n");

else

break;

}

printf("你输入的数是: %d \n", n);

}

这个代码,原意是想让用户输入一个整数,然后将其输出,如果输入的不是整数,就要求用户再输入一遍。可惜这是一段错误的代码,因为假如用户输入了一个 字母 a,这个 字母将会残留在缓冲区中,影响用户的下一次输入,也就是说,不管用户再次输入什么,scanf() 函数永远会先读到那个 a,因此就变成死循环了。正确的代码如下:

//example5.c

int main(void)

{

printf("请输入一个整数\n");

int n, ret;

while(1)

{

ret = scanf("%d", &n);

if(ret != 1)

{

while(getchar() != '\n'); // 用 getchar() 清空非法字符

printf("格式不对,请再输一遍!\n");

}

else

break;

}

printf("你输入的数是: %d \n", n);

}

其中,我们增加了第 15 行,这是一个空循环,循环条件是 getchar() != '\n' ,这句话的意思是,用getchar() 这个函数去缓冲区拿取数据,每次拿去一个字符,只要不是回车符 '\n' ,就继续循环,直到拿到回车符为止。 为什么是回车符呢? 因为用户再输入的时候,最后一定是按了一下回车键来结束输入的(本质原因是标准输入流是行缓冲类型的),因此在缓冲区中的数据一定是以 回车符 '\n' 作为结尾的,当我们用 getchar() 获取到回车符时,就表示缓冲区就被我们清空了!


运算符

C语言提供了丰富的运算符,比如算术运算符、关系运算符、逻辑运算符等,下面的表格是C语言中所有的运算符一览:
算术运算符

运算符功能说明举例
+加法,或单目取正a+b, +n
-减法,或单目取负a-b, -n
*乘法a * b
/除法a / b
%取模(求余)a % b
++自加1a++,++b
--自减1a--, --b
说明:取模 % 的左右两边的操作数必须都是整型。而自加和自减则分为前缀和后缀两种,请看下面的代码:

int a = 100, b;

int x = 200, y;

b = ++a; // 前缀,则a先进行自加1,然后再参与运算,因此此时 a 和 b 都等于101

x = y++; // 后缀,则 y 先参与运算,然后再自加1,因此此时 x等于200, 而y等于201
关系运算符

运算符功能说明举例
>大于a > b
>=大于等于a >= b
<小于a < b
<=小于等于a <= b
!=不等于a != b
==等于a == b
说明:由关系运算符组成的表达式称为关系表达式,每一个关系表达式的值都为布尔值,即非真即假。要注意的是,最后一个是 == 等于号,比如: a == b, 假如 a 跟 b 确实相等,那么此表达式的值为真,否则为假,等于号是用来判断左右两边是否相等的,不是数学意义上的“等于号”。



再来:
逻辑运算符

运算符功能说明举例
!逻辑反!(a == 100)
&&逻辑与a==100 && b==200
||逻辑或a==100 || b==200
说明:

1, 逻辑反,就是取表达式的布尔值的相反值,例如 !(a==b) 假如 表达式 (a==b) 的值为真,那么 !(a==b) 的值就为假,反之亦然。

2,逻辑与, 就是当且仅当其两边的表达式都为真时,整个表达式才为真,例如 (a==100 && b==200) ,只有当 a==100 而且 b==200 同时为真时,整个表达式才为真。换句话说,在逻辑与表达式中,只要有一边的表达式为假,那么整个表达式必为假。

3,逻辑或, 就是当且仅当其两边的表达式都为假时,整个表达式才为假,例如 (a==100 && b==200) ,只有当 a==100 而且 b==200 同时为假时,整个表达式才为假。换句话说,在逻辑或表达式中,只要有一边的表达式为真,那么整个表达式必为真。



由上面的表述得知,有时候,第一个表达式的布尔值可以决定整个逻辑表达式的值,事实上,当逻辑与表达式的左边表达式的布尔值为假时,右边表达式将被忽略,同理,当逻辑或表达式的左边表达式的布尔值为真时,右边表达式也将被忽略。


位运算符

运算符功能说明举例
~位逻辑反~a
&位逻辑与a & b
|位逻辑或a | b
^位逻辑异或a ^ b
<<左移a<<2
>>右移b>>2
位运算符中,前面三种跟逻辑运算符基本是一致的,只不过它们的操作对象不同,逻辑运算符操作对象是表达式,位运算符的操作对象是位,比如 ~a 结果就是对 a 的每一位求反, 而 a & b 或者 a | b 是对a 和 b 的每一位相应地进行与操作和或操作。位异或的逻辑是: 一样为0 不一样为 1,比如 a = 0000 1001 , b = 0000 1100 , 那么 a ^ b 的结果就是 0000 0101。

左移和右移则分别是对数位进行移动的操作,移出去的数位丢掉,空出来的补 0, 比如 原本 a 的二进制表示是 0000 1001, 如果执行 a = a<<2; 的话,那么结果是 a 的二进制表示就变成 (00)00 1001 00 最左边的两位被丢弃,最右边补上两个0, 右移是完全一样的道理,比如 b 的二进制表示是 0001 0110 ,如果执行 b = b >> 2; 的话,那么结果是 b 的二进制表示就变成 0000 0101(10) 。

有一个例外是,当被移动的数是一个有符号数,而且是一个负数的时候。 我们知道,负数在内存中的存储是以补码的方式存储的,即它们的最高位是 1, 此时如果被右移,为了保持其原有的正负号,系统会为其补 1 而不是补0. 比如有符号数 c 的二进制表示是 1000 1001, 那么执行 c = c >> 2; 的话,那么结果是 c 的二进制表示就变成 1110 10(01) 。

除了这几种运算符之外,C语言还包含以下几个特殊的运算符:

1, 赋值运算符 = 赋值运算符算是最常用最简单的运算符了,比如 int a; a = 100; 这里的 = 就是赋值运算符,代表将右边的值赋值给左边的变量。 注意不要跟逻辑等号==混淆了。

2,计算内存大小运算符 sizeof , 这个运算符用来计算一个变量或者一种数据类型在内存占用的字节数,比如 sizeof(int) 代表计算 int 类型所占的字节数,sizeof(a) 代表计算变量 a 所占的字节数。

3, 条件运算符 ? : 这个运算符比较特殊,它是唯一一个所谓的 三目 运算符,也就是说它有三个目标表达式,比如 (a>b) ? ( a ) : ( b ); 其中 (a>b) , ( a ) 和 ( b ) 分别是其三个目标表达式,它的逻辑很简单:首先问一下第一个表达式(即(a>b) ),如果它为真,那么就把第二个表达式 ( a ) 的值作为 整个表达式的值,否则就将 第三个表达式(即( b ) 的值作为 整个表达式的值。

4, 逗号运算符 , 逗号其实是用来分割各个其他的表达式的,所以它的优先级最低,比如: int a = (x = 100, y = 200, z = x + y, z + 1); 对于由逗号连起来的表达式,我们记住三点即可: 第一,逗号的优先级最低,全部算完了你再来考虑它。 第二,计算次序从左到右,先算 x = 100, y = 200, 再来 z = x + y, 再到 z +1 。 第三,整个逗号表达式的值取决于最右边的表达式的值,也就是最后a 的值为 z + 1。

5,复合赋值运算符: += -= *= /= %= >>= <<= &= ^= |=

这些操作符的用法是: a += b 相等于 a = a + b 。 a -= (x * y) 相当于 a = a - (x*y) 以此类推。 在可以使用复合赋值运算符的情形下,推荐尽量使用,比如使用 a += b 比使用

a = a + b 要好,因为可以使得程序少一条机器指令。

另外讲几个常见的关键字:

1, return

这个运算符在函数当中使用,请看下面的解释:

int func(void)

{

...

...

return 100; // 在普通函数中使用,代表返回到调用者处

}

int main(void)

{

...

...

return 0; // 在main函数中使用,代笔退出当前进程

}

由以上示例可知,return 有两个作用,在普通函数中使用和在主函数中使用是不同的。 在主函数中的返回值,将会被传递给其父进程,在非多进程程序里面,这个值无意义。 在多进程程序中,main函数的返回值一般情况下是这么约定的: 返回0 代表正常,返回非0 代表异常。

另外,在LINUX 下C编程,使用的是GNU 扩展语法,gcc还支持以下关键字:

2, typeof

取得变量的类型。比如

int a;

typeof(a) b; // 等价于 int b;

typeof 在复杂宏中用得比较多。比如典型在求最大值的“标准宏”如下:

#define MAX(a, b) ({ \

typeof(a) _a = a; \

typeof(b) _b = b; \

(void)( &_a == &_b); \

_a > _b ? _a : _b; \

)}

这个宏之所以这么写,而不是直接写第 5 行,是因为要避免宏参数如果出现 a++ 这样的自加自减符的时候的副作用,因为如果直接写 (a>b) ? (a) : (b) 的话可能会将自加自减符运算两遍,而用typeof 来分别取得 a 和 b 的类型,再定义另两个变量来替换 a 和 b ,就能避免这种副作用。看下面的例子:

#define MAX_BAD(a, b) ((a>b)?(a):(b))

int a = 100, b = 1, c;

c = MAX_BAD(a++, b); // 按照调用者的思路,运算之后c 应该等于100, a 应该等于101,但是情况并非如此,因为宏替换将使得 a++ 出现两次

上面的例子中,如果我们使用的是 MAX() 而不是 MAX_BAD() 情况就会好转。

另外,MAX() 中的 语句: (void)(&_a == &_b); 是用来判断 _a 和 _b 的类型是否一致的, 假如它们类型不一致,那么它们的指针也必然不一致,不同类型的指针相比较,编译器就会为我们发出警告。 这就是表达式 (&_a == &_b)的作用,而它前面的 (void) 是用来避免编译器因为表达式 (&_a == &_b) 而发出" no effect " 的警告用的。(因为编译器会认为你写了一个逻辑等表达式但是没有使用它,我们用(void)来骗过编译器使其不要发出" no effect "
的警告)


控制流

C 语言是一种 “高级” 语言,所谓的高级,就是拥有逻辑控制语句,可以使得我们实现诸如 循环、分支、跳转等操作。我们来逐一分析。

第一,循环语句。 C语言中,总共有3种循环语句,它们分别是 while 循环, do_while 循环 , 和 for 循环。

1,while循环,下面是示例代码:

int a = 0;

while( a < 100 ) // 每循环一次a加1,总共循环100次。

{

printf("%d\n", a);

a++;

}

上面的例子虽然简单,但已经说明白了while循环的用法了,当程序碰到 while (表达式) 时,系统会计算表达式的值,如果表达式的值为真,那么就执行后面的循环体,即后面用一对花括号包含起来的所有语句。 执行完之后,再来计算一遍这个表达式,如果还是为真,那就把循环体在执行一遍,就这样不断地重复,一直到表达式的值为假为止。如果 while(表达式) 中的表达式永远为真,那就是所谓的 死循环,永不退出。

上面的例子由于 a 的值会不断地递增,当 a 等于 100 时, 表示 a < 100 不成立,值为假,循环退出。

仔细观察 while 循环结构,你会发现,while 循环中的循环体有可能不会被执行,因为循环条件可能一开始就不成立。如果你需要你的循环体代码至少被执行一遍,那么推荐你使用另一种循环结构:

2,do-while 循环,下面是示例代码:

int a = 0;

do{

printf("%d\n", a);

a++;

}while( a < 100 ); // 每循环一次a加1,总共循环100次。

注意,在 do-while 循环结构中,while语句后面有一个分号,不能省略。上面这段代码的意思是:开始的时候不管三七二十一,先来打印一遍a的值,然后 加1,然后再来判断条件是否成立,成立则继续循环,不成立则退出循环。

除此之外,C语言还有第三种循环结构,也是最常用的循环结构:

3,for循环,先来看示例代码:

int a;

for(a = 0; a < 100; a++)

{

printf("%d\n", a);

}

从代码中得知,for循环中,for语句里面有 3 个表达式,第一个表达式是 a = 0, 这个表达式仅在最开始的时候被执行一遍,然后就跟for循环没有任何关系了,这个表达式一般称为循环变量初始化表达式,示例中 a 就是循环变量,被初始化为0。 第二个表达式是 a < 0, 这个表达式相当于 while 循环和 do-while 循环中的循环条件,当这个条件为真时,执行循环体,否则退出循环,这个表达式一般称为条件测试表达式。 第三个表达式是 a++ , 这个表达式在执行完整个循环体之后执行,一般用来更新条件变量。

事实上,以上代码完全等价于:

int a;

a = 0;

for(; a < 100; )

{

printf("%d\n", a);

a++;

}

注意到,for 语句当中的表达式可以被移除,但是两个分号不可省略。

C语言中除了循环结构,还有分支跳转结构,它们分别是:

1, if 语句 和 if - else 语句,请看示例代码:

int a = 100;

if( a%2 == 0)

{

printf("a 是偶数");

}

else

{

printf("a 是奇数");

}

上述代码中,使用了if - else 语句,在if 语句中,有一个表达式 a%2 == 0, 这个表达式的值决定了程序是否执行 if 语句所包含的代码块,即第 5 行。 如果该语句为真,则执行第 5 行,否则执行 else所包含的语句,即第 9 行。

注意,else语句不可单独使用, 它必须要跟if 语句配套使用。 if 语句所带的代码块和else 语句所带的代码块是互斥的,换句话说,它们是程序执行的两路分支,非此即彼,不可同时执行。 那如果不是两路分支,而是多路分支呢? 此时可以写成 阶梯形 的if - else 语句,例如:

int color = red;

if(color == yellow)

{

printf("***");

}

else if(color == blue)

{

printf("蓝色");

}

else if(color == red)

{

printf("红色");

}

else if(color == pink)

{

printf("粉色");

}

else

{

printf("不认识的颜色");

}

这段代码展示了所谓的阶梯形的 if - else 语句,用来实现多路分支,代码中的各种颜色是互斥的,运行时只会打印其中的一种。

事实上,要实现上述的多路分支,有一个更好的选择:switch。请看打印颜色的另一个版本代码:

int color = red;

switch(color)

{

case yellow:

printf("***");

break;

case blue:

printf("蓝色");

break;

case red:

printf("红色");

break;

case pink:

printf("粉色");

break;

default:

printf("不认识的颜色");

}

这段代码实现跟上面 阶梯形 if - else 代码完全一样的功能,但是明显更加简洁易读,其主要的语法要点有:

1, switch(表达式) 语句中的那个表达式的类型必须是一个整型(包括字符型)

2, case语句后面的表达式必须是一个整型常量。比如100, 200, 或者yellow,blue等宏常量或者枚举常量,或者'A', 'X', 等字符常量。

3, case语句是一个入口标签,程序会先计算switch语句的值,然后跟下面的所有case语句相比较,如果相等则执行下面的代码,否则跳过。 另外, case 标签是只管进去不管出来的,换句话讲,如果要让 color 的值为 yellow 时只打印“***”,那在第 6 行下面必须有一句break 语句,否则,程序将会将“蓝色”也打印出来,直到遇到右花括号或者break语句为止。

4, default语句是可选的。 如果有这条语句,它的意思是:假如所有的情况都不匹配,那么就执行default的语句。

最后,C语言中还有一位“臭名昭著”的家伙: goto,在任何书籍中都被警告为会破坏程序逻辑的语句,编程者少用为妙。情况到底是怎样的呢?

goto 语句在语法结构上,确实跟刚刚提到的那些“正常”的控制流不同,它几乎不受约束,它可以跳转到任意你所指定的位置,这样做的后果是程序代码太自由,滥用将会使得整个程序面临无法阅读的境地,但是goto语句并不可怕,翻开LINUX源代码,你不仅可以看到goto语句的身影,而且它出现的频率还相当地高,但是用的多不代表用的广,goto语句往往只被用在一个地方:出错处理。

C语言跟C++比较,缺少所谓的异常捕获机制,在工程中,为了更好地管理代码,往往会将出错处理代码统一放在一个地方,程序中当发现有错误的时候,就可以使用goto 语句直接跳转到该代码处进行处理。这么做的好处是,程序中出错的地方可能被深层嵌套在函数或者循环语句当中,要一层一层地返回无疑极其繁琐, goto语句的直接跳转特性使得这一切都不再是问题。


函数要义

C语言之所以被称为模块化语言,原因在于C语言的程序结构是由一个个的“模块”搭建起来的,这些所谓的模块就是函数,因此,函数是构成C程序的最基本的组件,我们的程序的功能可能很复杂,但是我们可以通过函数来分解,然后在组装它们,这种做法在遇到大规模软件工程之前,是非常主流的想法。在目前的软件开发中,也大量使用像C语言这样的模块化语言来描述问题,只不过当今世界,在面临大规模软件工程的开发时,面向对象语言也许是更好的选择。

C语言程序要完成一个复杂的功能,不能一个函数搞定,就像一家公司要做的事情有很多,不可能一个人做完,那就必须雇佣不同技能的人才。C的函数就像是一个个具有不同技能的人才一样,各自完成自己的本分工作,其中,任何一个C程序,都必须包含而且只能包含一个称为 main 的函数,这个函数就像是公司里的boss,他主要的工作是指挥或者说组织各个人才的工作,使之完成更大的目标。这个 main 函数是系统规定的。它的样子如下:

// example1.c

int main(void) // 函数头

{

... ... // 函数体

}

或者长成这样:

// example2.c

int main(int argc, char **argv) // 函数头

{

... ...  // 函数体

}

以上两种形式是 main 函数的标准形式。

下面来分析一下一个函数的各个部分,以 example1.c 为例子,其中,int main(void) 称为函数头,而 函数名字就是 main, 写在函数名前面的 int 指的是这个函数最终的返回值的数据类型,简单讲就是这个函数最后会得到一个什么东西。 而跟在函数名 main 后面的圆括号,用来装这个函数执行的时候需要的参数,就像一个人要干一个活儿所需要的材料一样,比如你调用你的一位员工,帮你去采购一批货物,你不可能让他空手去,你可能会先给他一些样本做参考,也许你还会给他一些钱,他需要这些材料才能成功地采购你所需要的东西,这些东西就称之为参数,假如这个函数不需要参数,那么我们就在这个圆括号里面写一个
void 表示。

下面,我们来让这个 Boss 调用一个小兵,这个小兵的工作是,帮我们找出两个整数中的比较大的一个,我承认这件事情很简单,但是我们的 main 身为一个 Boss 不想亲历亲为,于是,我们的第一个自己设计的小兵屁颠屁颠地上场了:

// example3.c

int max(int a, int b); // 这是函数max()的声明

int main(void) // 这是main函数,他是老板!

{

int i = 100, j = 200;

int k;

k = max(i, j); // 调用 max() 这个小兵,给他两个数:i和j,让它帮忙找出他们的最大值,返回给k

}

// 以下是函数max()的定义

int max(int a, int b)

{

int x; // 用来存放最大值

x = a > b ? a : b;

return x; // 将最大值返回给调用者

}

在以上例子中,我们要注意 3 个地方:

第一,函数的声明。每一个函数在使用之前,都必须先声明,函数的声明是要向编译器说明这个函数的样子,他会返回什么,叫什么名字,需要什么参数,将情况说清楚,才能使用这个函数,否则编译器会认为你在使用一个它也不认识的东西,会报错或者警告。

第二,函数的调用。example3.c 中第 9 行就是对函数 max() 的调用,调用函数来实现他的功能,注意,一旦调用函数,程序就会从调用语句那里直接跳转到函数的定义语句的地方执行,直到从该函数返回为止(除非是内联函数)。



第三,函数的定义。函数一被调用,就跳转到定义的地方。这是一个函数具体做什么事情的地方,比如example3.c 中的max()函数的定义,里面通过一个条件运算符计算出了a和b的最大值,然后将最大值 x 通过return语句返回给调用者。



下面是重要内容,重要程度 五颗星 ★★★★★

仔细观察函数调用和函数定义的代码:

函数调用: k = max(i, j);

函数定义:

int max(int a, int b)

{

......

}

在函数的调用中,我们将传递给函数max()的两个变量 i 和 j 称为实参(arguments),注意这两个变量是在main函数中定义的。而在函数 max( ) 的定义代码中,我们将接收实参值的两个变量 a 和 b 称为形参(parameters)。

实参和形参的关系至关重要,也很简单,记住两点:

1, 它们是相对独立的(意思是它们分别占用不同的内容,你是你我是我)

2, 形参是由实参来初始化的,你看形参a和b并没有给他们赋值,但是它们实际上初始值分别是 i 和 j,它们拿 i 和 j 的值来进行运算。

实参一般定义在调用函数中(除非是全局变量),在 example3.c中,i 和 j 都是在 main函数中定义的,意味着只有在main函数中才能对他们进行直接访问,实际上,i 和j 存在于 main函数的栈帧(stack frame)中。当退出main函数之后它们就会消失(被释放)。

形参定义在被调用函数中,比如example3.c中的 a 和b,它们只能在函数 max() 中被使用,事实上它们存在于函数 max() 的栈帧中,其他函数是看不见它们的,一旦函数 max( ) 退出,它们也会被立即释放。

函数形参和实参的这些特点,使得我们在使用函数的时候,有以下几点需要注意:

1,永远不要返回局部变量的地址,因为局部变量在函数返回的时候会被立即释放,亦即该块内存已被系统收回,此时它的地址就不能再被使用了。

2,按值传递的形参是实参的一个副本,或者说一个拷贝,他们是不同的两个变量,不能指望通过形参来修改实参(除非形参是一个指向实参的指针)。比如example3.c中所示,形参 a 是实参 i 的拷贝,对a的任何操作都不会对 i 有任何影响。

3,如果一个函数想要修改它的实参,那么必须要传递实参的地址,比如有两个变量 x 和 y,想要用一个函数 swap() 来交换他们的值,由于需要修改它们,因此我们的代码应该这么写:

// example4.c

void swap(int *px, int *py) // px 和 py 是整型指针

int main(void)

{

int x = 100; y = 200;

printf("%d, %d\n", x, y); // 打印100和200

swap(&x, &y); // 传递的是变量的地址而不是内容

printf("%d, %d\n", x, y); // 打印200和100



}

void swap(int *px, int *py)

{

int tmp;



// 以下代码利用形参指针px和py,来间接地交换

// 实参 x 和 y 的值。

tmp = *px;

*px = *py;

*py = tmp;

}

example4.c 中,我们不再把 x 和 y 当做实参直接传递,而是把它们的地址 &x 和 &y 当做实参来传递,这时由于传递的是整型的地址,所以形参就应该用一个专门存放整型地址的变量来接收,所以形参是整形指针 px 和 py。

后面讲完指针之后 会更了解这方面的内容。

只有这样,我们才能通过形参来修改实参。

C语言中,有以下几种特殊的函数:

1,递归函数。所谓的递归函数指的是嵌套地调用自己,比如经典的求阶乘函数,因为我们知道要得到一个数N的阶乘就要知道它的前一个数N-1的阶乘,要知道 N-1 的阶乘就要知道 N-2 的阶乘,而且求某个数的阶乘的算法是一样的,都是 n! = n * (n-1)* (n-2) * …… * 2 *1 ,你发现这是个递归的问题,代码如下:

// example5.c

int factor(int n)

{

if(n == 1)

return 1;

int m;

m = n * factor(n-1); // 递归地调用自己

return m;

}

使用递归函数有几个要注意的地方,第一,一定要有一个无需递归就能直接返回的条件,比如example5.c中的第5,6行,当n为1时,阶乘为1,不需要递归。如果没有这样的条件,递归函数将会走向“不归路”。 第二,递归函数不适合处理递归层次较深的场合,因为每一次递归调用自己的时候都会产生一个新的函数,产生新的局部变量,嵌套太深效率就会很低,而且还会有栈溢出的风险。最后,什么问题可以用递归函数解决呢? 当你发现一个问题可以分解为更小的问题,而且这个小问题的算法跟原先的一样的时候就可以考虑使用递归函数了。

2,内联函数。

内联函数是这样的函数:

inline void func(int a)

{

... ...

}

注意到 函数 func 的前面有个修饰符 inline,这个修饰符的含义有点像宏,它会将该函数的定义在所有调用它的地方展开,这样做的目的在于省却函数切换的时间,但是会浪费内存空间,因为普通函数只需要一段代码就够了,而内联函数会在所有调用的地方统统展开,相当于多了很多个副本。

所以使用内联函数有一定的要求:第一,函数足够短小,这里的短小指的是函数本身的执行时间要跟函数的切换开销时间在同等量级,否则,一个函数很复杂,执行起来需要10秒钟,而函数的切换只需要0.001ms,这样的话省略这个切换时间就毫无意义了。 第二,这个函数被频繁地调用,它的执行速度或成程序性能的瓶颈,这时我们需要考虑更快速的执行它,甚至可以为了这个而牺牲一部分内存空间。

内联函数是典型的用空间换取时间的例子。

3,回调函数。

回调函数的概念有点抽象,让我用一个例子来给你说明白:假如你老妈有一天没空,要你帮忙做顿中午饭,本来如果你做得一桌好菜,你老妈“调用”你这个函数也没啥好担心的,但可惜你其实只会炒鸡蛋但不会蒸排骨,于是你老妈预先给你准备了一位专门蒸排骨的保姆(是���,这位保姆除了蒸得一手好排骨啥也不会),告诉了你她的电话号码,你到时候给她电话随传随到。这个保姆,就是一个回调函数。

下面再来用计算机语言阐述一遍:当你调用一个函数 f( ) 的时候,你原本指望这个函数可以帮你做完一顿午饭,但无奈这个函数 f( ) 只会炒鸡蛋,不会蒸排骨,于是你在调用这个函数的时候,必须事先将如何蒸排骨这件事情写好,比如叫做 void zhengpaigumiji( int a, char b, float c),这样你就可以在调用 f( ) 的同时将 zhengpaigumiji 作为参数传递给 f( ) ,让他在做午饭的时候知道怎么蒸排骨,站在你的角度,这个 zhengpaigumiji( ) 是你写的,但是你不直接调用,而是让另一个函数
f( ) “回过头来调用” ,因此将这个函数 zhengpaigumiji( ) 称为回调函数。

再举个实际的例子,比如你要调用函数 signal( ) 来帮你处理某个信号,这个工作的内容内核都帮会你搞定,除了该信号的自定义响应函数之外,换句话讲,内核会搞定除了“蒸排骨”之外的一切事情,那你要让signal( ) 正常工作,就要传递一个所谓的“自定义信号响应函数”给内核,比如 signal( SIGINT, my_func) , 这样调用signal,就等于告诉内核:你来帮我搞一下SIGINT 这个信号,顺便说一下,它的响应函数是 my_func( ) 。

回调函数在实现软件分层设计时十分有用,试想,当A模块要实现的部分功能须由B模块提供时,我们可以事先定义好该功能接口,只要A,B双方共同遵循接口原则,那么它们就可以分工协作,哪怕处于不同的时空。


数组与指针

谈到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函数,因此这个函数也被称为回调函数,回调函数实现了不同的程序模块由不同的人开发,而且又可以协调合作的目的。


存储类,动态内存

C语言有几个关键字,在定义一个变量或者一个函数的时候,指定其存储区域类型,被称为存储类关键字,它们是:

static,extern,register 和 auto

下面逐一讲解。

1,static

其实这个关键字有三个作用,而不仅仅是存储类型。请看下面代码:

// 1.修饰函数,使其只能在本文件可见

static void func(void)

{

static int n = 0; // 2. 修饰局部变量,使其存储在静态区(存储类型)

printf("%d\n", n);

}

static int global; // 3. 修饰全局变量,使其只能在本文件可见

注意到,static在C语言中的三个作用,其中第1和第3个作用其实都是一样的,改变的是函数或者变量的可见范围。只有当用static来修饰局部变量的时候,它的作用才是代表一个存储区域。

2,extern

extern关键字用来修饰一个标识符(变量或者函数)的声明,表明该标识符在“别的地方”有定义,参考以下代码:

extern int a; // 声明一个“别处”定义的全局变量,此处extern不可省略

extern int f(); // 声明一个“别处”定义的函数,此处extern可省略

“别处”的意思仅仅指不在当前位置,可能是别的文件,也可能是该文件的其他地方。注意,如果声明的是一个变量,那只能是全局变量,局部变量不能用extern来声明。

3,register

顾名思义,register用来修饰一个希望存放在寄存器中的变量,但是这仅仅是对系统的一个建议,寄存器是一种稀缺资源,能不能满足要求要看系统当时的情况。但是,不管系统是否真的将变量存入寄存器,程序都不能对该变量进行取址运算。另外,并不是什么变量都可以定义为register类型,因为寄存器的大小跟CPU的字长相关,一般在32位系统当中,寄存器也是32位的,因此可以将小于等于32位的变量声明为register型。

被声明位register类型的变量一般是因为程序对其读写性能很敏感,比如在底层操作系统中,要频繁地对某个变量进行读写,而且这个变量的读写速度直接影响到了程序的性能,是整个程序的热点也是瓶颈,那么就很有必要将其声明为register存储型。

4,auto

auto这个关键字就像signed一样,一般都会被省略。因为auto的含义是将一个变量存储在“自动”存储区,所谓的自动存储区就是进程的栈空间,而普通的局部变量默认就是存储在 栈空间的,所以没有必要再用auto来修饰。而auto又不能修饰全局变量,因为全局变量一定是存储在静态区的。请看下面的分析:

// 所有的全局变量统统存储在静态区

int g1;

static int g2; // static 跟存储类型无关,它改变的是g2的可见范围。

int main(void)

{

int a; // 不需要auto修饰,默认就是自动变量,存储在栈空间

static int b; // 被static修饰,存储在静态区

}

下面是每一种变量在内存空间中的分布情况:





注意上图中的用户栈和运行时堆,它们是会在程序运行时动态变化的,里面的变量时而诞生时而消亡,具体而言指的是:在栈中存在的变量都是局部变量(包括函数的形参),其生命周期是从定义它的语句开始,到离开其作用域为止。在堆中存在的变量都是所谓的动态内存,这些内存的生命周期都是由程序员指定的,从malloc()分配某块内存开始,到free()释放这块内存结束。堆和栈分别向上和向下的箭头代表它们在内存中的增长的趋势。

相对地,读写段(即静态区)、代码段、常量区等内存区域则在程序运行期间是“稳定”的,即不会被释放,这些区域的内容将会被一直保留,直到整个程序退出为止。

这里来着重强调一下堆和栈。

对于栈而言,要明确的一点是栈空间的大小是有限的,通常来讲,对于一个32位的系统而言,一个进程的默认栈空间是8M,如果是嵌入式LINUX系统,其栈空间的大小可能经过调整会更小,比如1M。(如果是多线程,多个线程的栈空间会一字排开,但是中间用零权限内存来构建一个警戒区加以保护)。而栈空间存放的是局部变量,因此,以下情况不适合使用局部变量:

第一,巨大的变量。比如一个2000个元素的数组,那么大的数据量很有可能使得栈溢出。

第二,动态的数据。比如一个动态增长的链表,虽然程序运行初时不大,但是程序无法预料以后该链表的节点数目,像这种动态的数据也不能用栈空间来存放。

由于栈空间的分配跟释放是由系统自动完成的,其执行效率高速度快,所以适合用来存放一些可以被临时释放的数据。

而堆空间,则是程序运行时大部分数据的真正的演练场,这部分空间的分配和释放完全由程序员来控制,因此也叫做内存的自由区,自由意味着谨慎,如果只分配不释放,内存就会很快被用光,因此,堆空间是一个需要你更加负责任的地方,请看下面的代码:

char * func(void)

{

char *p = malloc(100); // 在堆空间中申请一块100bytes的内存

return p; // 返回后,堆内存不会因为函数的退出而释放

}

int main(void)

{

char *k;

k = func();

if(k == NULL)

return -1; // 堆空间申请失败!

... // 堆空间申请成功,并使用这块内存

...

free(k); // 释放这块内存

}

动态内存的使用有几点要注意:

1,使用完毕之后,一定要释放。

2,如果不释放,那么该块内存只能等到程序全部退出之后才能被释放。

3,free() 只能释放动态内存(即由malloc( ) 或者 calloc() 或者 realloc() 分配的内存)。

4,访问动态内存的唯一方式是采用指针,因为动态内存是匿名的。



以上代码中的第3行,用一个char型指针来索引这块动态内存,是因为char型指针可以很方便地遍历内存中的每一个字节。如果这块内存是用来存放特殊的数据类型,就用需要的类型指针来遍历即可。


复合数据类型

咱们知道,C语言中有许多基本数据类型,比如int型,float型,double型等,我们经常使用这些基本数据类型来表达一些简单的数据,比如一个人的年龄可以用 int 型数据来表示,一本书的价格可以用 float 型数据来表示等等。

但另一方面,在我们的日常生活中遇到更多的数据是复合的数据类型,比如一个学生,或者一本书。一个学生包含很多元素:姓名、性别、年龄、电话、住址等等,一本书也包含很多信息:价格、页码、出版社、售价等等,如果我们使用一堆基本数据类型来表示一个学生就会显得非常笨拙,需要动用很多相对分散的数据来表示一个逻辑上完整的独立的学生,很不方便,相对而言,我们更希望有一种叫做“学生”的数据类型,它本身就包含了“学生”这种数据的相关信息,是一个完整的独立的整体,同理我们也希望有一个叫做“书”的数据类型,来玩完整地表达一本书的属性。

很显然,C语言本身不可能提供这样的”自定义“的数据类型,因为一个具体的数据类型将会包含什么样的元素,要是具体情况而定,世界上的事物何止千万种,C语言不可能包含对它们的定义。那怎么办呢?不用担心,C语言虽然不可能为我们定义好每个具体的复合数据类型,但是它给我们提供了一套机制,让我们可以自定义我们需要的复合数据类型,来表达我们想要表达的数据。这种机制,就是所谓的结构体。

闲话少说,来一睹结构体的芳容:

struct student // 表达一个学生的结构体

{

char name[32];

int age;

float score;

};

struct book // 表达一本书的结构体

{

char book_name[50];

int pages;

float price;

char author[20];

};

如你所见,以上就是我们自定义的两种结构体数据类型,一种叫做“student”的结构体,一种叫做“book”的结构体,没错,这两种数据类型是我们自己刚刚创造的,C语言本身并没有这两种数据类型,我们使用了C语言提供的struct 关键字,创造了它们!!

仔细观察一下以上代码,语法要点是:

1,struct 关键字不能写错。

2,student和book被称为结构体的标签,就像你花了一张设计图纸,你给这张图纸命了一个名字。这个标签是可以省略的。

3,将结构体包含的元素一一罗列在一对花括号 { } 里面

4,结构体的元素,可以是 int, float, char数组等等,事实上,除了函数(包含了函数就变成C++的类了呵呵),结构体可以包含任何数据元素(甚至包含另一个结构体)。

5,最后,以一个分号结束。

此外,非常值得关注的一点是:以上定义被称为结构体的模板,也就是相当于一张设计图纸。你此时仅仅完成了对某一种你需要的具体复合数据类型的设计,你尚未定义任何该种类型的变量。如果你要创造几个实实在在的代表学生的变量,或者创造几个实实在在的代表图书的变量,就可以使用以上模板来定义了:

int main(void)

{

struct student Jack, Rose; // 定义了两个代表学生的变量,Jack和Rose

struct book APUE, LKD; // 定义了两个代表图书的变量,APUE(《UNIX环境高级编程》)和LKD(《LINUX内核设计与实现》)

}

像以上代码那样,你就定义了几个实实在在的复合数据类型变量。

咱们现在知道了所谓的结构体是怎么回事了,无非就是使用struct机制将一堆数据组合在一起,形成一种新的自定义的复合数据类型,来统一地表达一种事物而已。而且我们知道,我们应该先设计好我们想要的结构体模板,然后使用它来定义具体的变量。

下面,来看看结构体的使用,跟以前学习的基本数据类型有什么异同:

1,结构体的初始化

struct student Jack = {"Jack", 20, 88.5}; // 对Jack的初始化

struct book APUE = {"Advance Programming in UNIX Environment", 500, 99.0, "W.Richard Stevens"}; // 对APUE的初始化

大家看到,结构体的初始化跟数组的初始化很相似,都是用一对花括号将初始化列表括起来,里面一一对应地放着结构体中的每一个元素。对每一个元素的初始化,跟对每一种基本数据类型的初始化是一致的。

另外我们关注到,以上初始化方式事实上是有缺陷的,因为初始化列表里面的元素的位置固定死了,所以,万一以后需要对该结构体进行升级维护,增加某些新的成员,或者调整已有成员的次序,都将导致这些初始化语句失效。好在,我们有更好的办法来进行初始化:

struct student Jack = {

.name = "Jack",

.score = 88.5,

.age = 20

}; // 对Jack的指定元素初始化,score和age的次序可以任意



struct book APUE = {

.book_name = "Advance Programming in UNIX Environment",

.pages = 500,

//.price = 99.0,

.author = "W.Richard Stevens"

}; // 对APUE的指定元素初始化,价格成员.price可以不写

我们用”指定元素初始化”的方法,对结构体中的元素“指定”初始化,这样就能避免上述的结构体模板升级问题。另外注意到,指定元素初始化时,不一定要初始化所有的成员,也不一定要按照模板定义的成员次序来初始化,这样就使得我们的初始化非常灵活。

在上述代码中,.name 中的小圆点被称为“成员引用符”,用来引用复合数据类型中的某一个成员。

2,结构体的赋值操作

struct student Mike;

Mike = Jack; // 将结构体Jack赋值给Mike

以上代码就是结构体的赋值操作,你会发现跟普通的基本数据类型的赋值操作没有任何区别。事实上,C语言的设计者当初在发明结构体这种东西的时候,其中一个要求就是要让结构体使用起来就跟普通基本数据类型一样,让用户感觉没有什么区别。

两个结构体可以直接赋值,当然也是有前提的,前提就是这两个结构体必须是同一种类型的,比如上面的Mike和Jack的类型都是 struct student,因此它们可以相互直接赋值,赋值的结构就是右边的操作数的每一个成员一一对应地赋值到左边的操作数中去。

一个有趣的地方是:数组不能这样直接赋值,但是如果两个结构体里面包含数组,结构体却可以直接赋值。

3,结构体数组

struct student class[50]; // 定义了一个具有50个元素的数组,每个元素都是一个结构体

class[0] = Jack; // 直接赋值

strcpy(class[1].name, "Michael"); // 对class[1]的成员分别赋值,不能写成class[1] = "Michael",因为数组不能直接赋值(除非是初始化)

class[1].age = 23;

class[1].score = 80.0;

4,结构体的取成员操作

struct book holy_bible;

holy_bible.price = 59.0;

holy_bible.pages = 700;

strcpy(holy_bible.book_name, "Holy Bible");

strcpy(holy_bible.author, "Unknown");

代码中的小圆点,就是成员引用符,使用了成员引用符的表达式应该被看做一个整体,比如holy_bible.price,这是一个float型的变量,可以在任何使用 float 型变量的地方使用。

5,指向结构体的指针(结构体指针):

struct student *p; // 定义了一个专门指向struct student 型数据的指针p

p = &Jack; // 将Jack的地址赋给了p

(*p).age = 25; // 通过指针的解引用访问Jack中的成员age

p->age = 25; // 使用->来简化上一行代码

由于结构体一般体型巨大,因此很多情况下直接操作结构体显得不那么廉价(比如函数传参时),更好的方式是使用一个指向结构体的指针,定义结构体指针跟定义一个基本数据类型指针一样,给结构体指针赋值也跟普通基本数据类型一样。

对结构体指针进行解引用再取成员:(*p).age 显得很笨拙,因此C语言发明了另一个运算符来替代:p->age 请注意:箭头-> 的前面一定是一个复合数据类型的指针。

6,结构体的大小

乍看起来,一个结构体变量的大小,就应该等于其各个成员的大小之和,但事实并非如此,各个成员之间,会常常由于所谓的“地址对齐”的问题而被填充一些零,因此一般来说一个结构体的大小往往要大于其各个成员的大小之和(至少是相等)。比如:

struct node

{

char a;

int b;

short c;

};

printf("%d\n", sizeof(struct node));

以上代码,将会打印出 12,��不是 7(1+4+2),原因就是所谓的地址对齐问题。要详细的搞明白这个问题,我们得从CPU的存取数据的能力说起。



我们经常说一个CPU是32位的,或者是64位的,这个数值也称为该CPU的字长,这个字长的概念,说的是CPU每次到内存中存取的数据的长度,32位的CPU指的是每次到内存中可以存取4个字节(32位),或者换个角度说:CPU每次到内存中存取数据时都是4字节对齐的,4字节4字节地进行存取!

试想,站在32位CPU的角度,对于一个4字节的 int 型数据,其起始地址最好是4的倍数,因为那样的话就可以一次存取完成了,否则,如果一个 int 型数据横跨了两个4字节单元,比如其起始地址是0xFFFF00A5(即其占用的4个字节的地址分别是0xFFFF00A5,0xFFFF00A6,0xFFFF00A7,0xFFFF00A8),那么CPU就需要进行两次存取。看下图:



如前所述,一个变量的存放位置并非可以是随意的,而是会影响CPU的性能的,CPU对数据的存放位置不规范可以有不同的反应,有些CPU直接罢工,有些则以牺牲性能为代价可以继续运行。数据存放位置的问题,就是数据的地址对齐问题。注意到地址对齐问题的源头,是CPU对存取数据的性能要求这一点很重要,有助于我们对此概念的理解。

那么,问题是:一个变量究竟要存放到哪里,CPU才高兴呢?答案是:

1,如果一个变量的长度 length <= CPU的字长,那么要求该变量自然对齐

2,如果一个变量的长度 length >= CPU的字长,那么该变量只要按CPU的字长对齐即可。

所谓的自然对齐,指的是变量的起始地址是其长度的整数倍。比如 double 型数据的起始地址如果是8的整数倍,就称之为自然对齐, int 型数据的起始地址如果是4的整数倍,那么也是自然对齐的,同理,如果一个 short 型数据的起始地址是偶数,也是自然对齐的,char 型数据则不管放到哪里,都是自然对齐的。

如果是 double 型数据,占用了8个字节,比CPU的字长还要长,则不需要自然对齐,只需要按4字节对齐即可,因为即使这个 double 型的起始地址是8的整数倍,CPU也至少要存取两次才能正确操作该数据,自然对齐并没有为CPU提高效率。所以我们不需要他8字节对齐,只要4字节对齐就可以了。



注意到,每个变量因为要讨好CPU,所以每个变量的存放地址都需要是“某个数的整数倍”,这某个数,我们称之为一个变量的 m 值。

比如在32位系统里面:

int 数据的 m 值是4

short 数据的 m 值是2

char 数据的 m 值是1

double 数据的 m 值是4

... ...

请着重品味一下,m值不是数据的大小!而是CPU对这个数据的起始地址的要求!!(必须是m值的整数倍,否则CPU可能生气罢工)

那么结构体呢? 比如 struct node 这个结构体,它的 m 值是多少呢? 答案是:取决于其成员中 m 值最大的那个。

从上面的结构体定义得知,struct node 这个结构体包含了三个成员,分别是char 型、int 型和short型数据,m值分别是1、4和2,因此最大值是4,结构体的m值就是4。因此毫无疑问,结构体的起始地址必须是4的整数倍,如下图:

仔细查看上图,几个你可能不是很明白的地方:

1,a 是 char 型数据,m值是1,理论上应该可以随便放,为什么一定要放在4的倍数的地址上? 原因是:a的地址也是整个结构体的地址,而整个结构体的m值是4

2,a 的后面为什么要补三个零?原因是:b是 int 型数据,m值是4,其起始地址必须是4的整数倍,因此必须补三个零。

3,为什么 short 型数据 c 的后面还要补两个零?原因是:一个变量的m值,其实不仅仅是对其起始地址做出要求(必须是m的整数倍),而且还同时对其末端地址也做出要求(必须是m的整数倍),因此c的后面要补两个零,使得整个结构体的末端地址也必须是4的整数倍。(试想:如果不对末端地址做出要求,那么我如果定义一个该类型结构体数组会如何?考虑一下紧挨着的下一个结构体的地址分布)

变量的 m 值,甚至可以人为地指定:

struct node

{

char a;

int b __attribute__((aligned(256))); // 人为指定变量b的m值为256,即要求b的起始地址必须是256的整数倍

short c;

};

代码中的__attribute__((aligned(256))) 是GNU的扩展语法(gcc编译器支持该类语法),用来改变一个变量的地址对齐属性(即m值),这是GNU扩展语法中众多attribute机制当中的比较常用的一种。注意:我们可以增大变量的m值但是不能减小它。

尝试计算该变量的大小,可以通过实验,来验证你的理解。

C语言的复合数据类型除了结构体之外,还有所谓的联合体,长成这个样:

union example

{

int a;

char b;

double c;

};

可以看到,联合体(亦称为共用体)跟结构体非常类似,不同点在于:

1,关键字名字不同:union和struct

2,内部成员的内存分布不同,我们刚刚讨论了结构体内部各个成员的内存分布情况,它们每个人独自占用自己的内存,由于地址对齐的问题有时还可能要在彼此之间填充一些零。二联合体完全不同,联合体的所有成员的起始地址都是一样的,换句话讲,它们将来将会相互覆盖!最后对哪一个成员赋值,哪一个成员就有效,其他的统统失效。结构体的大小计算比较复杂,要考虑地址对齐,联合体的大小非常简单,决定于最大成员。

什么时候会使用这样的联合体呢?答案是:当你要表达的东西本质上是互斥的时候:比如人的性别:男或者女,不会出现既是男又是女的情况;黑或白,不会出现既是黑又是白的情况;运行和睡眠,不会出现一个进程既正在运行又正在睡眠的情况。这些时候,使用联合体不仅可以节省代码尺寸,更为重要的是使得程序更具有可读性。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: