对C语言输入输出流和缓冲区的深入理解

合集下载
  1. 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
  2. 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
  3. 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。

对C语言输入输出流和缓冲区的深入理解
C语言缓冲区(缓存)详解
缓冲区又称为缓存,它是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。
缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
为什么要引入缓冲区
比如我们从磁盘里取信息,我们先把读出的数据放在缓冲区,计算机再直接从缓冲区中取数据,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。
又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的CPU可以处理别的事情。
现在您基本明白了吧,缓冲区就是一块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调工作,避免低速的输入输出设备占用CPU,解放出CPU,使其能够高效率工作。
缓冲区的类型
缓冲区 分为三种类型:全缓冲、行缓冲和不带缓冲。
1) 全缓冲
在这种情况下,当填满标准I/O缓存后才进行实际I/O操作。全缓冲的典型代表是对磁盘文件的读写。
2) 行缓冲
在这种情况下,当在输入和输出中遇到换行符时,执行真正的I/O操作。这时,我们输入的字符先存放在缓冲区,等按下回车键换行时才进行实际的I/O操作。典型代表是标准输入(stdin)和标准输出(stdout)。
3) 不带缓冲
也就是不进行缓冲,标准出错情况stderr是典型代表,这使得出错信息可以直接尽快地显示出来。
ANSI C( C89 )要求缓存具有下列特征:
?当且仅当标准输入和标准输出并不涉及交互设备时,它们才是全缓存的。
?标准出错决不会是全缓存的。
但是,这并没有告诉我们如果标准输入和输出涉及交互作用设备时,它们是不带缓存的还是行缓存的,以及标准输出是不带缓存的,还是行缓存的。
大部分系统默认使用下列类型的缓存:
?标准出错是不带缓存的。
?如果是涉及终端设备的流,则它们是行缓存的;否则是全缓存的。
我们经常要用到标准输入输出流,而ANSI C对stdin、stdout和stderr的缓存特征没有强行的规定,以至于不同的系统可能有不同的stdin、stdout和stderr的缓存特征。目前主要的缓存特征是:stdin和stdout是行缓存;而stderr是无缓存的。?
缓冲区的大小
如果我们没有自己设置缓冲区的话,系统会默认为标准输入输出设置一个缓冲区,这个缓冲

区的大小通常是512个字节的大小。
缓冲区大小由 stdio.h 头文件中的宏 BUFSIZ 定义,如果希望查看它的大小,包含头文件,直接输出它的值即可:

1.printf("%d", BUFSIZ);

缓冲区的大小是可以改变的,也可以将文件关联到自定义的缓冲区,详情可以查看 setvbuf()?和 setbuf() 函数。
缓冲区的刷新(清空)
下列情况会引发缓冲区的刷新:
?缓冲区满时;
?行缓冲区遇到回车时;
?关闭文件;
?使用特定函数刷新缓冲区。
深入理解缓冲区请查看:结合缓冲区谈谈C语言getchar()、getche()、getch()的区别
结合缓冲区谈谈C语言getchar()、getche()、getch()的区别
本文将用到C语言缓冲区的概念,如果您不了解缓冲区,请查看:C语言缓冲(缓存)
三个函数的对比
-- 缓冲区 头文件 回显
getchar() 有缓冲区 stdio.h 有回显
getch() 无缓冲区 conio.h 无回显
getche() 无缓冲区 conio.h 有回显
getchar()函数
先来看一下getchar(),其原型为:
? ? int getchar(void);
当程序调用getchar()函数时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中,直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar()函数才开始从键盘缓冲区中每次读入一个字符。也就是说,后续的getchar()函数调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才重新等待用户按键。
通俗一点说,当程序调用getchar()函数时,程序就等着用户按键,并等用户按下回车键返回。期间按下的字符存放在缓冲区,第一个字符作为函数返回值。继续调用getchar()函数,将不再等用户按键,而是返回您刚才输入的第2个字符;继续调用,返回第3个字符,直到缓冲区中的字符读完后,才等待用户按键。
下边的一个实例,会让你有深刻的体会:

1.#include
2.int main()
3.{
4.char c;
5.//第一次调用getchar()函数
6.//程序执行时,您可以输入一串字符并按下回车键,按下回车键后该函数才返回
7.c=getchar();
8.//显示getchar()函数的返回值
9.printf("%c\n",c);
10.//暂停
11.system("PAUSE");
12.while((c=getchar())!='\n')
13.{
14.printf("%c",c);
15.}
16.//暂停
17.system("PAUSE");
18.return 0;
19.}



















这段小代码很简单,并且在代码内部都有注释。
getchar()函数的执行就是采用了行缓冲。第一次调用getchar()函数,会让程序使用者(用户)输入一行字符并直至按下回车键 函数才返回。此时用户输入的字符和回车符都存放在行缓冲区。
再次调用getchar()函数,会逐步输出行缓冲区的内容。
请再看下面一个例子:

1.#include
2.int main()
3.{
4.char ch1;
5.char ch2;
6.
7.ch1 = getchar

();
8.ch2 = getchar();
9.printf("%d %d", ch1, ch2);
10.return 0;
11.}










程序的本意很简单,就是从键盘读入两个字符,然后打印出这两个字符的ASCII码值。可是执行程序后会发现出了问题:当从键盘输入一个字符后,就打印出了结果,根本就没有输入第二个字符程序就结束了。例如用户输入字符’a', 打印结果是97,10。这是为什么呢?
getchar()函数是从输入流缓冲区中读取数据的,而不是从键盘(终端)缓冲区读取。当读取遇到回车(\n)结束时,这个'\n'会一起读入到输入流缓冲区的,所以第一次接收输入时取走字符后会留下字符\n,这样第二次getchar()直接从缓冲区中把\n取走了,显然读取成功了,所以不会再从终端读取!其实这里的10恰好是回车符!这就是为什么这个程序只执行了一次输入操作就结束的原因!
getch()和getche()函数
在TC2.0时代,C程序员总是喜欢在程序末尾加上getch(),来实现程序运行完了暂停不退出的效果。如果不这样做,在TC2.0的环境中Ctrl+F9编译并运行后会立即退出程序,根本来不及看到结果。这时如果要看结果,就要按Alt+F5回到DOS环境中去,很麻烦。而如果在程序的结尾加上一行getch();语句,就可以省掉回DOS看结果这个步骤,因为程序运行完了并不退出,而是在程序最后把屏幕停住了,按任意键才退出程序。

实际上,getch()的作用是从键盘接收一个字符,且不带回显。就是说,你按了一个键后它并不在屏幕上显示你按的什么,而继续运行后面的代码,所以在C语言中可以用它来实现“按任意键继续”的效果,即程序中遇到getch();语句,就会停下来,等你按任意键,它接收了这个字符键后再继续执行后面的代码。
getche()和getch()很相似,它也需要引入头文件conio.h,它们之间的区别就在于:getch()无回显,getche()有回显。请看下面的例子:

1.#include
2.#include
3.void main()
4.{
5.char ch;
6.int i;
7.for(i=0;i<5;i++)
8.{
9.ch=getch();
10.printf("%c",ch);
11.}
12.}












首先这是个连续5次的循环来实现5次停顿,等待你输入。编译并运行这个程序,假设输入的是abcde,那么屏幕上显示的结果也是abcde,这个abcde并不是在ch=getch();中输出的。把printf("%c",ch);这行语句去掉,就会发现按5次任意键程序就结束了,但屏幕上什么都没有显示。
你可以把代码中的getch()换成getche()看看有什么不同。如果还是输入abcde,那么屏幕上显示的结果是aabbccddee,我们把printf("%c",ch);这行语句再去掉,显示的结果就是abcde了,说明程序在执行ch=getche();这条语句的时候就把我们输入的键返回显示在屏幕上,有无回显就是它们的唯一区别。
请大家

再看下面一个例子:

1.#include
2.#include
3.void main()
4.{
5.char ch='*';
6.while(ch=='*')
7.{
8.printf("\n按 * 继续循环,按其他键退出!");
9.ch=getch();
10.}
11.printf("\n退出程序!");
12.}












你可以在这个循环体中添加你想要的功能,程序中按*继续循环,其他任意键退出,而且利用getch()无回显的特性,不管你按什么键,都不会在屏幕上留下痕迹,使你的界面达到美观效果。
还有getchar是很值得研究的:getchar()是stdio.h中的库函数,它的作用是从stdin流(标准输入流)中读入一个字符,也就是说,如果stdin有数据的话不用输入它就可以直接读取了。而getch()和getche()是conio.h中的库函数,它的作用是从键盘接收字符。
与前面两个函数的区别在于: getchar()函数等待输入直到按回车才结束(前提是缓冲区没有数据),回车前的所有输入字符都会逐个显示在屏幕上。但只有第一个字符作为函数的返回值。

1.#include
2.#include
3.void main()
4.{
5.char c;
6.// 从键盘读入字符直到回车结束
7.//getchar()在这里它只返回你输入字符串的第一个字符,并把返回值赋值给c
8.c=getchar();
9.// 显示输入的第一个字符
10.putchar(c);
11.}











看到这个程序,相信你肯定会有疑问。这个就是从缓冲区中读取字符的例子。第一次getchar()时,确实需要人工的输入,但是如果你输了多个字符,以后的getchar()再执行时就会直接从缓冲区中读取了。

1.#include
2.#include
3.void main()
4.{
5.char c;
6.// 每个getchar()依次读入一个字符
7.while ((c=getchar())!='\n')
8.printf("%c",c); // 按照原样输出
9.}









程序运行后,首先停下来,等待输入一个字符串,输入完毕后,它会把你输入的整个字符串都输出来了。
这是为什么?getchar()不是只返回第一个字符么,这里为什么全部输出了?
因为我们输入的字符串并不是取了第一个字符就把剩下的字符串丢掉了,它还在我们的缓冲区中,就好像开闸放水,你把水放到闸里去以后,开一次闸就放掉一点,开一次就放掉一点,直到放光了为止,这里开闸动作就相当于调用一次getchar()。我们输入的字符串也是这么一回事,首先我们输入的字符串是放在内存的缓冲区中的,我们调用一次getchar()就把缓冲区中里出口最近的一个字符输出,也就是最前面的一个字符输出,输出后,就把它释放掉了,但后面还有字符串,所以我们就用循环把最前面的一个字符一个个的在内存中释放掉,直到不满足循环条件退出为止。
例子中循环条件里的'\n'实际上就是你输入字符串后的回车符,所以意思就是说,直到遇到回

车符才结束循环,而getchar()函数就是等待输入(或缓冲区中的数据)直到按回车才结束,所以实现了整个字符串的输出。当然,我们也可以把循环条件改一下,比如while ((c=getchar())!='a'),就是遇到字符'a'就停止循环,当然意思是如果你输入“12345a213123/n”那么只会输出到a,结果是12345a。
请注意:用getchar()是到标准输入流中读取数据,所以第一个getchar()接受的是刚刚中断的流队列中即将出列的第一个字符(不限于回车符,上面举过例子了),如果流队列不为空,执行getchar()就继续放水,直到把回车符也放空为止,空了之后再在执行getchar()就停下等待你的输入了。
那么为什么getch()每次都是等待用户的输入呢?因为getch()是从键盘接收,即时的接收,并不是从stdin流(标准输入流)中去读取数据。
C语言FILE结构体以及缓冲区深入探讨
在C语言中,用一个指针变量指向一个文件,这个指针称为文件指针。通过文件指针就可对它所指的文件进行各种操作。
定义文件指针的一般形式为:
??? FILE? *fp;
这里的FILE,实际上是在stdio.h中定义的一个结构体,该结构体中含有文件名、文件状态和文件当前位置等信息。我们通过fopen返回一个文件指针(指向FILE结构体的指针)来进行文件操作。
注意:FILE是文件缓冲区的结构,fp也是指向文件缓冲区的指针。
不同编译器 stdio.h 头文件中对 FILE 的定义略有差异,这里以标准C举例说明:

1.#define NULL 0
2.#define EOF (-1)
3.#define BUFSIZ 1024
4.#define OPEN_MAX 20 // 一次打开的最大文件数
5.
6.// 定义FILE结构体
7.typedef struct _iobuf {
8.int cnt; // 剩余的字符,如果是输入缓冲区,那么就表示缓冲区中还有多少个字符未被读取
9.char *ptr; // 下一个要被读取的字符的地址
10.char *base; // 缓冲区基地址
11.int flag; // 读写状态标志位
12.int fd; // 文件描述符
13.} FILE;
14.
15.extern FILE _iob[OPEN_MAX];
16.
17.#define stdin (&_iob[0]) // stdin 的文件描述符为0
18.#define stdout (&_iob[1]) // stdout 的文件描述符为1
19.#define stderr (&_iob[2]) // stdout 的文件描述符为2
20.
21.enum _flags {
22._READ =01, // 读文件
23._WRITE =02, // 写文件
24._UNBUF =04, // 无缓冲
25._EOF = 010, // 文件结尾EOF
26._ERR = 020 // 出错
27.};
28.int _fillbuf(FILE *); // 函数声明,填充缓冲区
29.int _flushbuf(int, FILE *); // 函数声明,刷新缓冲区
30.
#define feof(p) ((p)->flag & _EOF) != 0)
31.#define ferror(p) ((p)->flag & _ERR) != 0)
32.#define fileno(p) ((p)->fd)
33.#define getc(p) (--(p)->cnt >= 0 \
34.? (unsigned char) *(p)->ptr++ : _fillbuf(p))
35.#define putc(x,p) (--(p)->cnt >= 0 \
36.? *(p)->ptr++ = (x) : _flushbuf((x),p))
37.#define getchar() getc(stdin)
38.#define putcher(x

) putc ((x), stdout)


































看吧,我们经常使用的 NULL、EOF、feof()、getc() 等都是 stdio.h 中定义的宏。
注意:一个长的 #define 语句可以用反斜杠(\)分成多行。
下面说一下如果控制缓冲区。
我们知道,当我们从键盘输入数据的时候,数据并不是直接被我们得到,而是放在了缓冲区中,然后我们从缓冲区中得到我们想要的数据 。如果我们通过setbuf()或setvbuf()函数将缓冲区设置10个字节的大小,而我们从键盘输入了20个字节大小的数据,这样我们输入的前10个数据会放在缓冲区中,因为我们设置的缓冲区的大小只能够装下10个字节大小的数据,装不下20个字节大小的数据。那么剩下的那10个字节大小的数据怎么办呢?暂时放在了输入流中。请看下图:

上面的箭头表示的区域就相当是一个输入流,红色的地方相当于一个开关,这个开关可以控制往深绿色区域(标注的是缓冲区)里放进去的数据,输入20个字节的数据只往缓冲区中放进去了10个字节,剩下的10个字节的数据就被停留在了输入流里!等待下去往缓冲区中放入!接下来系统是如何来控制这个缓冲区呢?
再说一下 FILE 结构体中几个相关成员的含义:
? ? cnt ?// 剩余的字符,如果是输入缓冲区,那么就表示缓冲区中还有多少个字符未被读取
? ? ptr ?// 下一个要被读取的字符的地址
? ? base ?// 缓冲区基地址
在上面我们向缓冲区中放入了10个字节大小的数据,FILE结构体中的 cnt 变为了10 ,说明此时缓冲区中有10个字节大小的数据可以读,同时我们假设缓冲区的基地址也就是 base 是0x00428e60 ,它是不变的 ,而此时 ptr 的值也为0x00428e60 ,表示从0x00428e60这个位置开始读取数据,当我们从缓冲区中读取5个数据的时候,cnt 变为了5 ,表示缓冲区还有5个数据可以读,ptr 则变为了0x0042e865表示下次应该从这个位置开始读取缓冲区中的数据 ,如果接下来我们再读取5个数据的时候,cnt 则变为了0 ,表示缓冲区中已经没有任何数据了,ptr 变为了0x0042869表示下次应该从这个位置开始从缓冲区中读取数据,但是此时缓冲区中已经没有任何数据了,所以要将输入流中的剩下的那10个数据放进来,这样缓冲区中又有了10个数据,此时 cnt 变为了10 ,注意了刚才我们讲到 ptr 的值是0x00428e69 ,而当缓冲区中重新放进来数据的时候这个 ptr 的值变为了0x00428e60 ,这是因为当缓冲区中没有任何数据的时候要将 ptr 这个值进行一下刷新,使其指向缓冲区的基地址也就是0x0042e860这个值!因为下次要从这个位置开始读取数据!
在这里有点需要说明:当我们从键盘输入字符串的时候需要敲一下

回车键才能够将这个字符串送入到缓冲区中,那么敲入的这个回车键(\r)会被转换为一个换行符\n,这个换行符\n也会被存储在缓冲区中并且被当成一个字符来计算!比如我们在键盘上敲下了123456这个字符串,然后敲一下回车键(\r)将这个字符串送入了缓冲区中,那么此时缓冲区中的字节个数是7 ,而不是6。
缓冲区的刷新就是将指针 ptr 变为缓冲区的基地址 ,同时 cnt 的值变为0 ,因为缓冲区刷新后里面是没有数据的!
C语言为指针动态分配内存
C语言程序员要严防内存泄漏,这个“内存泄漏”就是由动态内存分配引起的。指针是C语言和其它语言的最大区别,也是很多人不能跨入C语言的一道门槛。既然指针是这么一个“危险”的坏东西,干吗不取消它呢?
其实指针本身并没有好坏,它只是一种操作地址的方法,学会了便可以发挥其它语言难以匹敌的功能,没学会的话,只能做其它语言的程序员,也同样发挥你的光和热。站长本人也在C语言门外徘徊多年,至今仍不属于高手。
变量和数组可以通过指针来转换
“int *x”中的x究竟是不是数组?光看这一句无法确定,因为它既可表示单个变量内容,也可表示数组。请理解下面的例子:

1.#include
2.int main(void){
3.int *num = NULL;
4.int *x, y[] = {12, 22,32}, z = 100;
5.
6.//下面演示,指针既可充当变量、也可充当数组
7.x=&z; //整型变量的地址赋给x
8.printf("*x=%d, x[0]=%d\n", *x, x[0]);
9.
10.x = y; //数组的地址赋给x
11.printf("*x=%d, x[ 0]=%d, x[ 1]=%d, x[2]=%d\n", *x, x[0], x[1], x[2]);
12.
13.x = y + 1; //数组的第二位地址赋给x
14.printf("*x=%d, x[-1]=%d, x[ 0]=%d, x[1]=%d\n", *x, x[-1], x[0], x[1]);
15.
16.x = y + 2; //数组的第三位地址赋给x
17.printf("*x=%d, x[-2]=%d, x[-1]=%d, x[0]=%d\n", *x, x[-2], x[-1], x[0]);
18.
19.return 0;
20.}















运行结果:
*x=100, x[0]=100
*x=12, x[ 0]=12, x[ 1]=22, x[2]=32
*x=22, x[-1]=12, x[ 0]=22, x[1]=32
*x=32, x[-2]=12, x[-1]=22, x[0]=32
动态分配内存
前面讲到的指针,基本上将已经定义好的变量的地址赋给指针变量,现在要学的是向操作系统申请一块新的内存。申请到的内存,必须在某个地方手动释放,因此下面2个函数必须配对使用。malloc()和free(),都是标准函数,在stdlib.h中定义。
根据不同的电脑使用状况,申请内存有可能失败,失败时返回NULL,因此,动态申请内存时一定要判断结果是否为空。malloc()的返回值类型是“void *”,因此,不要忘记类型转换。(许多人都省略了。)

1.#include
2.#include
3.#include
4.int main(void){
5.char *p ;
6.
7.p = (char *)malloc(60 * sizeof(char)) ;
8.if (p == NULL) { //这个

判断是必须的
9.printf("内存分配出错!");
10.exit(1);
11.}
12.strcpy(p, "/cpp/u/jiaocheng/\n"); //不要忘记给新内存赋值
13.printf("%s", p);
14.
15.free(p); //过河一定要拆桥
16.p = NULL ; //释放后的指针置空,这是非常好的习惯,防止野指针。
17.
18.return 0;
19.}
















运行结果:
/cpp/u/jiaocheng/
隐蔽的内存泄漏
?内存泄漏主要有以下几种情况:
?内存分配未成功,却使用了它。
?内存分配虽然成功,但是尚未初始化就引用它。
?内存分配成功并且已经初始化,但操作越过了内存的边界。
?忘记了释放内存,造成内存泄露。
?释放了内存却继续使用它。
下面的程序造成内存泄漏,想想错在何处?如何修改?

1.#include
2.#include
3.int main(void){
4.int *p, i;
5.
6.p = (int *)malloc(6 * sizeof(int)) ;
7.if (p == NULL) { //判断是否为空
8.printf("内存分配出错!");
9.exit(1);
10.}
11.
12.for (i=0; i<6; i++) {
13.p++;
14.*p = i;
15.printf("%2d", *p);
16.}
17.printf("\n");
18.
19.free(p); //这句运行时出错
20.
21.return 0;
22.}


















对动态内存的错误观念
有人对某一只在函数内使用的指针动态分配了内存,用完后不释放。其理由是:函数运行结束后,函数内的所有变量全部消亡。这是错误的。动态分配的内存是在“堆”里定义,并不随函数结束而消亡。
有人对某动态分配了内存的指针,用完后直接设置为NULL。其理由是:已经为NULL了,这就释放了。这也是错误的。指针可以任意赋值,而内存并没有释放;相反,内存释放后,指针也并不为NULL。
C语言模块化编程,C语言多文件编译
C语言头文件深入理解
C语言程序中,源文件通常分为两种:一种用于保存程序的声明(declaration),称为头文件;另一种用于保存程序的实现(implementation),称为定义(definition)文件。 C程序的头文件以“.h”为后缀,C 程序的定义文件以“.c”为后缀。
可以将 .h 文件的内容写在 .c 文件中,也可以将 .c 文件的内容写在 .h 中,但这是很不好的习惯。许多初学者用了头文件,却不明其理。在此略作说明。
在以下场景中会使用头文件:
?通过头文件来调用库功能。在很多场合,源代码不便(或不准)向用户公布,只要向用户提供头文件和二进制的库即可。用户只需要按照头文件中的接口声明来调用库功 能,而不必关心接口怎么实现的。
?多文件编译。将稍大的项目分成几个文件实现,通过头文件将其他文件的函数声明引入到当前文件。
?头文件能加强类型安全检查。如果某个接口被实现或被使用时,其方式与头文件中的声明不

一致,编译器就会指出错误,这一简单的规则能大大减轻程序员调试、改错的负担。
编译时只有函数声明没有函数定义是完全正确的。函数声明告诉编译器该函数已经存在,但是入口地址还未确定,暂时在此做个标记,链接时编译器会找到函数入口地址,并将标记替换掉。

编译产生的 .obj 文件(Linux下为 .o 文件)已经是二进制文件,与 .exe 的组织形式类似,只是有些函数的入口地址还未找到,程序不能执行。链接的作用就是找到函数入口地址,将所有的源文件组织成一个可以执行的二进制文件。
关于头文件的内容,初学者还必须注意:
?头文件中可以和C程序一样引用其它头文件,可以写预处理块,但不要写具体的语句。
?可以申明函数,但不可以定义函数。
?可以申明常量,但不可以定义变量。
?可以“定义”一个宏函数。注意:宏函数很象函数,但却不是函数。其实还是一个申明。
?结构的定义、自定义数据类型一般也放在头文件中。
?#include ,编译系统会到环境指定的目录去引用。#include "filename.h",系统一般首先在当前目录查找,然后再去环境指定目录查找。
好的风格是成功的关键,版本申明、函数功能说明、注释等是C语言程序的一部分。不养成很好的习惯则不能成为C语言高手(专业人员)。
C标准库中,每一个库函数都在一个头文件中声明,可以通过 #include 预处理命令导入。
头文件只是声明,不占内存空间,编译时会被合并到源文件;要想知道它的具体实现,要看头文件所声明的函数是在哪个 .c 文件里定义的,然后查看源代码。
C标准库共包含 15 个头文件,可以分为 3 组,如何正确并熟练的使用它们,可以相应的可区分出 3 个层次的程序员:
?合格程序员:
?熟练程序员:
?优秀程序员:
各个头文件的具体内容请查看:C语言标准库
C语言头文件具有以下几个特性:
?幂等性。可以多次包含相同的标准头文件,但效果与只包含一次相同。
?相互独立。任何标准头文件的正常工作都不需要以包含其他标准头文件为前提。也没有任何标准头文件包含了其他标准头文件。
?和文件级别的声明等同。必须先把某标准头文件包含到你的程序中, 然后才能使用该头文件已定义或声明的东西。不能在声明中包含标准头文件。并且,也不能在包含标准头文件之前用宏定义去代替关键字。
等幂性是很容易实现的,对于大多数的头文件可以使用宏保护。例如,在 stdio.h 中可以有如下的

宏定义:

1.#ifndef _STDIO_H
2.#define _STDIO_H
3./* 主要实现部分 */
4.#endif




在C程序员中所达成的一个约定是:C源文件的开头部分要包含所有要用到的头文件。在 #include 指令之前只能有一句注释语句。引入的头文件可以按任意顺序排列。
如果我们自己编写的头文件可能会用到标准头文件中的定义或者声明,最好把标准头文件包含在自定义头文件的开头。这样,就不会在程序中忘记引入该标准头文件,也不会有顺序问题。这正是利用了头文件的等幂性。

注意一个约定,引入标准头文件用尖括号,引入自定义头文件用双引号,例如:

1.#include
2.#include "myFile.h"


C语言库函数是头文件的最佳实践,仔细阅读各个头文件的内容,尤其是 stdio.h,能够学到很多东西。
在 VC6.0 中找到头文件
C标准头文件,例如 stdio.h、string.h 等在 VC6.0 的安装目录中是可以找到的。我的 VC6.0 安装在?C:\Program Files\Microsoft Visual Studio\ 目录,那么 VC6.0 附带的所有头文件(包括但不限于标准头文件)都在?C:\Program Files\Microsoft Visual Studio\VC98\Include\ 目录下。
如果忘记 VC6.0 的安装目录或者头文件不在安装目录下,可以通过以下方式找到:
1) 在工具栏中点击“工具”按钮
2) 在二级菜单中选择“选项”
3) 在弹出的对话框中选择“目录”标签
4) 然后选择名字为“目录”的下拉菜单中的“Include files”一项,如下图所示:

第一个C语言多文件编译的例子:C语言多文件编程,10分钟快速上手
这一节通过一个简单的例子,向大家展示如何有效地将各个文件联系在一起。
在 VC6.0 中新建一个工程,添加 fun.c、main.c 两个源文件和 fun.h 一个头文件,内容如下:
fun.c

1.#include
2.int fun1(){
3.printf("The first function!\n");
4.return 0;
5.}
6.int fun2(){
7.printf("The second function!\n");
8.return 0;
9.}
10.int fun3(){
11.printf("The third function!\n");
12.return 0;
13.}













fun.h

1.#ifndef _FUN_H
2.#define _FUN_H
3.
4.extern int fun1(void);
5.extern int fun2(void);
6.extern int fun3(void);
7.
8.#endif






main.c

1.#include
2.#include
3.#include "fun.h"
4.
5.int main(){
6.fun1();
7.fun2();
8.fun3();
9.
10.system("pause");
11.return 0;
12.}










对上面的每个 .c 文件都进行编译,然后链接并运行:
The first function!
The second function!
The third function!
上面的例子,函数定义放在 fun.c 文件中,在 fun.h 头文件中对函数进行声明,暴露接口,然后在主文件 main.c 中引入 fun.h。
注意:编译是针对单个 .c 文件的,如果项目中有多个 .c 文件,需要逐一编译,然后链接,或者使用“组建 -> 全部重建”选项

,一次性编译并链接所有文件。
多文件编程时,只能有一个文件包含 main() 函数,因为一个工程只能有一个入口函数。我们把包含 main() 函数的文件称为主文件。
可以在其他 .c 文件中对函数进行定义,在 .h 中对函数进行声明,只要主文件包含进相应的头文件,就能使用这些函数。实际开发中,很少有简单到只有几十行代码的C语言项目,合理的组织代码和文件,是开发大中型项目的必备技能。
为了更好的组织各个文件,一般情况下一个 .c 文件对应一个 .h 文件,并且文件名要相同,例如 fun.c 和 fun.h。如果 fun.c 使用到了 fun.h 的宏定义、类型定义等,还需要在 fun.c 中 #include "fun.c"。
.c 文件主要包含各个函数的定义,.h 文件声明函数原型,向外暴露接口,供主文件调用。另也可以在 .h 中包含宏定义、类型定义。
注意:.h 文件头文件中不能有可执行代码,也不能有变量定义,只能有宏、类型( typedef,struct,union,menu )定义和变量、函数的声明。
这倒不是说在 .h 中定义变量或函数会有语法错误,实际上#icnlude机制很简单,就是把#include所包含的文件中的内容直接复制到#include所在的位置并替换#include语句。但是这样做不符合模块化编程的惯例,也不利于文件的组织,不利于二次开发,不利于团队协作。
头文件要遵守幂等性原则,即可以多次包含相同的头文件,但效果与只包含一次相同。
可以使用下面的宏防止一个头文件被重复包含。???????
复制纯文本新窗口
1.#ifndef MY_INCLUDE_H
2.#define MY_INCLUDE_H
3.//头文件内容
4.#endif




如果该头文件已被包含,那么会定义宏 MY_INCLUDE_H,再次包含时,就不会对头文件内容进行编译了。
动态链接库(dll)简介
DLL 是 Dynamic Link Library 的缩写,译为“动态链接库”。DLL也是一个被编译过的二进制程序,可以被其他程序调用,但与 exe 不同,DLL不能独立运行,必须由其他程序调用载入内存。
DLL 中封装了很多函数,只要知道函数的入口地址,就可以被其他程序调用。
Windows API中所有的函数都包含在DLL中,其中有3个最重要的DLL:
?Kemel32.dll:它包含那些用于管理内存、进程和线程的函数,例如CreateThread函数;
?User32.dll:它包含那些用于执行用户界面任务(如窗口的创建和消息的传送)的函数,例如 CreateWindow 函数;
?GDI32.dll:它包含那些用于画图和显示文本的函数。
静态链接库和动态链接库
1) 静态库
函数和数据被编译进一个二进制文件(通常扩展名为.LIB)。在使用静态库的情况下, 在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其他模块组合起来创建最终的可执行文

件(.EXE文件)。当发布产品时,只需要发布这个可执行文件,并不需要发布被使用的静态库。
2) 动态库
在使用动态库的时候,往往提供两个文件:一个引入库(.lib)文件和一个DLL (.dll) 文件。虽然引入库的后缀名也是“lib”,但是,动态库的引入库文件和静态库文件有着本质上的区别,对一个DLL来说,其引入库文件(.lib)包含该DLL导出的函数和变量的符号名,而.dll文件包含该DLL实际的函数和数据。在使用动态库的情况下,在编译链接可执行文件时,只需要链接该DLL的引入库文件,该DLL中的函数代码和数据并不复制到可执行文件中,直到可执行程序运行时,才去加载所需的DLL,将该DLL映射到进程的地址空间中,然后访问DLL中导出的函数。这时,在发布产品时,除了发布可执行文件以外,同时还要发布该程序将要调用的动态链接库。
使用动态链接库的好处
1) 可以采用多种编程语言来编写
我们可以采用自己熟悉的开发语言编写DLL,然后由其他语言编写的可执行程序来调用这些DLL。例如,可以利用VB来编写程序的界面,然后调用利用VC++或Delphi编写的完成程序业务逻辑的DLL。
2) 增强产品的功能
在发布产品时,可以发布产品功能实现的动态链接库规范,让其他公司或个人遵照这个规范开发自己的DLL,以取代产品原有的DLL,让产品调用新的DLL,从而实现功能 的增强。在实际工作中,我们看到许多产品都提供了界面插件功能,允许用户动态地更换程序的界面,这就可以通过更换界面DLL来实现。
3) 提供二次开发的平台
在销售产品的同时,可以采用DLL的形式提供一个二次开发的平台,让用户可以利用该DLL调用其中实现的功能,编写符合自己业务需要的产品,从而实现二次开发。
4) 简化项目管理
在一个大型项目开发中,通常都是由多个项目小组同时开发,如果采用串行开发,则效率是非常低的。我们可以将项目细分,将不同功能交由各项目小组以多个DLL的方式实现,这样,各个项目小组就可以同时进行开发了。
5) 可以节省磁盘空间和内存
如果多个应用程序需要访问同样的功能,那么可以将该功能以DLL的形式提供,这样在机器上只需要存在一份该DLL文件就可以了,从而节省了磁盘空间。另外,如果多个应用程序使用同一个DLL,该DLL只需要放入内存一次,所有的应用程序就都可以共亨它了。这样,内存的使用将更加有效。

我们知道,当进程被加载时,系统会为它分配内存,接着分析该可执行模块,找到该程序将要调用哪些DLL,然后系统搜索这些DLL,找到后就加载它们,并为它们分配内存空间。DLL的内存空间只有一份,如果有第二个程序

也需要加载该DLL,那么它们共享内存空间,相同的DLL不会再次加载。
6) 有助于资源的共享
DLL可以包含对话框模板、字符串、图标和位图等多种资源,多个应用程序可以使用DLL来共享这些资源。在实际工作中,可以编写一个纯资源的动态链接库,供其他应用程序访问。
7) 有助于实现应用程序的本地化
如果产品需要提供多语言版本,那么就可以使用DLL来支持多语言。可以为每种语言创建一个只支持这种语言的动态链接库。
第一个DLL程序:动态链接库DLL教程,30分钟快速上手
DLL 程序的入口函数是 DllMain(),就像 DOS 程序的入口函数是 main()、Win32 程序的入口函数是 WinMain() 一样。前面我们一直在讲的就是DOS程序。
DllMain() 函数的原型为:

1.BOOL APIENTRY DllMain(
2.HANDLE hModule,
3.DWORD ul_reason_for_call,
4.LPVOID lpReserved
5.);





其中:
?hModule 表示本DLL程序的句柄。
?ul_reason_for_call 表示DLL当前所处的状态,例如DLL_PROCESS_ATTACH表示DLL刚刚被加载到一个进程中,DLL_PROCESS_DETACH表示DLL刚刚从一个进程中卸载。
?lpReserved 表示一个保留参数,目前已经很少使用。
一个简单的DLL程序并不比 "Hello World" 程序难,下面就开始介绍如何利用VC6.0创建DLL及其调用方式。
首先利用VC6.0新建一个 Win32 Dynamic-Link Library 类型的工程,工程取名为 dllDemo,并选择“An empty Dll project"选项,即创建一个空的动态链接库工程。然后,为该工程添加 一个C源文件 main.c,并在其中编写完成加法运算和减法运算的函数,代码如下所示:

1.#include ? // 也可以 #include
2.#include
3.
4._declspec(dllexport) int add(int a, int b){
5.??? return a+b;
6.}
7._declspec(dllexport)int sub(int a, int b){
8.??? return a-b;
9.}
10.
11.BOOL APIENTRY DllMain(
12.??? HANDLE hModule,
13.??? DWORD? ul_reason_for_call,
14.??? LPVOID lpReserved
15.){
16.??? if(ul_reason_for_call == DLL_PROCESS_ATTACH){
17.??????? printf("Congratulations! DLL is loaded!");
18.??? }
19.}

















然后利用Build命令生成dllDemo这一动态链接库程序。之后,在该工程的Debug目录下, 可以看到有一个dllDemo.dll文件,这就是生成的动态链接库文件。
读者要记住,应用程序如果想要访问某个DLL中的函数,那么该函数必须是已经被导出的函数。为了导出一些函数,需要在函数前面添加标识符?_declspec(dllexport)。
为了查看一个DLL中有哪些导出函数,可 以利用VC6.0提供的命令行工具Dumpbin来实现。
Dumpbin.exe文件位于VC6.0安装目录下的VC98\bin目录下。在该目录下还有 一个批处理文件VCVARS32.bat,该文件的作用是用来建立VC6.0使用的环境信息。如果读者在其他目录下无法执行Dumpbin命令

,原因可能就是你的VC6.0安装的环境信息被破坏了,那么可以运行VCVARS32.bat这个批处理文件,之后在其他目录下,就可以 执行Dumpbin命令了。
注意:当在命令行界面下执行VCVARS32.bat文件后,该文件所设置的环境信息只是在当前命令行窗口生效。如果关闭该窗口,并再次启动一个新的命令行窗口后,仍需要运行VCVARS32.bat文件。
在命令行界面下,cd 到工程目录下的debug目录,输入dumpbin -exports dllDemo.dll 命令,然后回车,即可查看DLL中的导出函数,如下

注意红色方框标出的信息:
ordinal ? ?hint ? ? RVA ? ? ? ? ? ? ? ?name
? ? ? ? ? 1 ? ? ? ? 0 ? ? 00001005 ? ? ? ?add
? ? ? ? ? 2 ? ? ? ? 1 ? ? 0000100A ? ? ? ?sub
在这段信息中,"ordinal" 列列出的信息 '1' 和 '2' 是导出函数的序号;"hint" 列列出的数字是提示码,该信息不重要;"RVA" 列列出的地址值是导出函数在DLL模块中的位置,也就是说,通过该地址值,可以在DLL中找到它们;最后一列 "name" 列出的是导出函数的名称。
将 add 函数前面的 _declspec(dllexport) 标识符去掉,再次编译 dllDemo 工程,然后执行?dumpbin -exports dllDemo.dll 命令,输出如下图所示:

可以看到,add 函数已经不是导出函数了。
打开项目目录下的Debug目录,发现有 dllDemo.dll 和 dllDemo.lib 两个文件。上节已经说过,.lib 文件包含DLL导出的函数和变量的符号名,.dll 文件才包含实际的函数和数据。主程序调用 DLL 需要这两个文件,下节会讲解如何使用。
注意:DllMain() 函数在DLL程序载入和卸载时执行,可以用来做一些初始化和清理的工作,如果仅仅是向外暴露函数,就可以省略 DllMain() 函数。但是如果有 DllMain() 函数,就一定要?#include ? 或 #include
例如,上面DLL如果只想暴露 add() 和 sub() 函数,而不想进行其他操作,那么可以这样写:

1._declspec(dllexport) int add(int a, int b){
2.return a+b;
3.}
4._declspec(dllexport)int sub(int a, int b){
5.return a-b;
6.}
动态链接库DLL的加载:隐式加载(载入时加载)和显式加载(运行时加载)
静态链接库在链接时,编译器会将 .obj 文件和 .LIB 文件组织成一个 .exe 文件,程序运行时,将全部数据加载到内存。
如果程序体积较大,功能较为复杂,那么加载到内存中的时间就会比较长,最直接的一个例子就是双击打开一个软件,要很久才能看到界面。这是静态链接库的一个弊端。
动态链接库有两种加载方式:隐式加载和显示加载。
?隐式加载又叫载入时加载,指在主程序载入内存时搜索DLL,并将DLL载入内存。隐式加载也会有静态链接库的问题,如果程序稍大,加载时间就会过长,用户不能接受。
?显式加载又叫运行时加载,指主程序在

运行过程中需要DLL中的函数时再加载。显式加载是将较大的程序分开加载的,程序运行时只需要将主程序载入内存,软件打开速度快,用户体验好。
隐式加载
首先创建一个工程,命名为 cDemo,添加源文件 main.c,内容如下:

1.#include
2.
3.extern int add(int, int); // 也可以是 _declspec(dllimport) int?add(int, int);
4.extern int sub(int, int); // 也可以是 _declspec(dllimport) int?sub(int, int);
5.
6.int main(){
7.int a=10, b=5;
8.printf("a+b=%d\n", add(a, b));
9.printf("a-b=%d\n", sub(a, b));
10.return 0;
11.}









找到上节创建的 dllDemo 工程,将 debug 目录下的?dllDemo.lib 和?dllDemo.dll 复制到当前工程目录下。

前面已经说过:.lib 文件包含DLL导出的函数和变量的符号名,只是用来为链接程序提供必要的信息,以便在链接时找到函数或变量的入口地址;.dll 文件才包含实际的函数和数据。所以首先需要将 dllDemo.lib 引入到当前项目。
选择”工程(Project) -> 设置(Settings)“菜单,打开工程设置对话框,选择”链接(link)“选项卡,在”对象/库模块(Object/library modules)“编辑框中输入 dllDemo.lib,如下图所示:


但是这样引入 .lib 文件有一个缺点,就是将源码提供给其他用户编译时,也必须手动引入 .lib 文件,麻烦而且容易出错,所以最好是在源码中引入 .lib 文件,如下所示:
#pragma comment(lib, "dllDemo.lib")
更改上面的代码:

1.#include
2.#pragma comment(lib, "dllDemo.lib")
3.
4._declspec(dllimport) int add(int, int);
5._declspec(dllimport) int sub(int, int);
6.
7.int main(){
8.int a=10, b=5;
9.printf("a+b=%d\n", add(a, b));
10.printf("a-b=%d\n", sub(a, b));
11.return 0;
12.}










点击确定回到项目,编译、链接并运行,输出结果如下:
Congratulations! DLL is loaded!
a+b=15
a-b=5
在 main.c 中除了用 extern 关键字声明 add() 和 sub() 函数来自外部文件,还可以用?_declspec(dllimport) 标识符声明函数来自动态链接库。
为了更好的进行模块化设计,最好将 add() 和 sub() 函数的声明放在头文件中,整理后的代码如下:
dllDemo.h

1.#ifndef _DLLDEMO_H
2.#define _DLLDEMO_H
3.
4.#pragma comment(lib, "dllDemo.lib")
5._declspec(dllexport) int add(int, int);
6._declspec(dllexport) int sub(int, int);
7.
8.#endif






main.c

1.#include
2.#include "dllDemo.h"
3.
4.int main(){
5.int a=10, b=5;
6.printf("a+b=%d\n", add(a, b));
7.printf("a-b=%d\n", sub(a, b));
8.return 0;
9.}








显式加载
显式加载动态链接库时,需要用到 LoadLibrary() 函数,该函数的作用是将指定的可执行模块映射到调用进程的地址空间。LoadLibrary() 函数的原型声明如下所示:
HMODULE ?LoadLibrary(LPCTSTR 1pFileName);

LoadLibrary() 函数

不仅能够加载DLL(.dll),还可以加载可执行模块(.exe)。一般来说,当加载可执行模块时,主要是为了访问该模块内的一些资源,例如位图资源或图标资源等。LoadLibrary() 函数有一个字符串类型(LPCTSTR)的参数,该参数指定了可执行模块的名称,既可以是一个.dll文件,也可以是一个.exe文件。如果调用成功, LoadLibrary() 函数将返回所加载的那个模块的句柄。该函数的返回类型是HMODULE。?HMODULE类型和HINSTANCE类型可以通用。
当获取到动态链接库模块的句柄后,接下来就要想办法获取该动态链接库中导出函数的地址,这可以通过调用 GetProcAddress() 函数来实现。该函数用来获取DLL导出函数的 地址,其原型声明如下所示:
FARPROC ?GetProcAddress(HMODULE hModule, LPCSTR 1pProcName);
可以看到,GetProcAddress函数有两个参数,其含义分别如下所述:
?hModule:指定动态链接库模块的句柄,即 LoadLibrary() 函数的返回值。
?1pProcName:字符串指针,表示DLL中函数的名字。
首先创建一个工程,命名为 cDemo,添加源文件 main.c,内容如下:

1.#include
2.#include
3.#include // 必须包含 windows.h
4.
5.typedef int (*FUNADDR)(); // 指向函数的指针
6.
7.int main(){
8.int a=10, b=5;
9.HINSTANCE dllDemo = LoadLibrary("dllDemo.dll");
10.FUNADDR add, sub;
11.if(dllDemo){
12.add = (FUNADDR)GetProcAddress(dllDemo, "add");
13.sub = (FUNADDR)GetProcAddress(dllDemo, "sub");
14.}else{
15.printf("Fail to load DLL!\n");
16.system("pause");
17.exit(1);
18.}
19.
20.printf("a+b=%d\n", add(a, b));
21.printf("a-b=%d\n", sub(a, b));
22.
23.system("pause");
24.return 0;
25.}





















找到上节创建的 dllDemo 工程,将 debug 目录下的 dllDemo.dll 复制到当前工程目录下。注意,只需要 dllDemo.dll,不需要 dllDemo.lib。
运行程序,输出结果与上面相同。
HMODULE 类型、HINSTANCE 类型在 windows.h 中定义;LoadLibrary() 函数、GetProcAddress() 函数是Win32 API,也在 windows.h 中定义。
通过以上的例子,我们可以看到,隐式加载和显式加载这两种加载DLL的方式各有 优点,如果采用动态加载方式,那么可以在需要时才加载DLL,而隐式链接方式实现起来比较简单,在编写程序代码时就可以把链接工作做好,在程序中可以随时调用DLL导出的函数。但是,如果程序需要访问十多个DLL,如果都采用隐式链接方式加载它们的话, 那么在该程序启动时,这些DLL都需要被加载到内存中,并映射到调用进程的地址空间, 这样将加大程序的启动时间。而且,一般来说,在程序运行过程中只是在某个条件满足时才需要访问某个DLL中的某个函数,其他情况下都不需要访问这些DLL中的函数。但是这时所有的DLL

都已经被加载到内存中,资源浪费是比较严重的。在这种情况下,就可以采用显式加载的方式访问DLL,在需要时才加载所需的DLL,也就是说,在需要时DLL才会被加载到内存中,并被映射到调用进程的地址空间中。有一点需要说明的是,实际上, 采用隐式链接方式访问DLL时,在程序启动时也是通过调用LoadLibrary() 函数加载该进程需要的动态链接库的。

相关文档
最新文档