2013年5月18日星期六

深入研究C语言中.h和.c文件的关系



一些基本概念
在进入主题之前,我们先了解一些基本内容。
C语言是面向过程的编程语言,C语言没有包(命名空间),没有明确的私有和公有函数,一旦你定义了函数,理论上可以被整个程序调用(这里先不讨论折中的static类型的函数)。不论你编辑了几万个还是几十万个.c源程序,最终经过编译,会变成单个二进制可执行文件。在这个文件里所有函数都是平等的,没有公私之分。如果反汇编,可以看到这些函数按顺序一个接一个的排在代码段,并且入口地址是固定的。Linux内核也不例外,Linux内核其实就是一个巨大的二进制可执行文件。
由上可以得出,.c文件的多少,只是根据人的“管理难易程度”决定的。如果你不介意,你可以将所有的程序都写在一个.c文件中,这样反而提高了gcc的效率,gcc不用再去拼接.c文件了。
对于特殊的static类型的函数,它的特殊之处在于,它并不是保存在代码段,而是保存在静态区,因此造成了它的特殊性,这里不再展开讨论。


函数定义是有顺序的
这个概念比较简单,但也是万恶之源。对于标准C来说,函数有先后顺序。如果B函数定义在A函数之后,那么在A中不能直接调用B(对于编译器来说,可以成功编译运行,但会有警告),因此需要在A之前先声明B函数(关于声明与定义的区别见后文)。例子:
test.c
#include <stdio.h>

int main() {
        A();
        return 0;
}


void A() {
        printf("I'm function A.\n");
        B();
}

void B() {
        printf("I'm function B.\n");
}

编译器警告:
test.c:9:6: 警告:与‘A’类型冲突
test.c:4:2: 附注:‘A’的上一个隐式声明在此
test.c:14:6: 警告:与‘B’类型冲突
test.c:11:2: 附注:‘B’的上一个隐式声明在此
换句话说,标准C不会自动寻找函数,默认仅仅会查找已处理过的函数,没查到就认为违反标准了。还是上面的例子,编译器处理到A时,B还没有处理到,结果A中有对B的调用,就警告了。由于标准C在这方面的规定,就有了这一篇文章。
声明与定义的区别:声明仅仅是告诉编译器有这个东西,是一种描述,不会占用内存。定义则会根据声明的结构,产生实际的内存占用。换句话说,定义是对声明的一个实现。这就类似于面向对象中类与对象的关系。


解决函数定义的顺序问题
既然函数定义是有顺序的,那我们写代码就非常麻烦了,写一个A函数还得提心吊胆的看看将要调用的B函数是不是在A前面,一旦函数增多,程序员必然崩溃,这比goto语句都来的爽。当然,你也可以放任自流,结果会是换来编译器的一大堆警告。
为了解决这个问题,我们可以将所有的函数生命在.c文件的头部。这样我们在写代码时,想怎么调用就怎么调用了。刚才的程序我们可以这么写:
test.c
#include <stdio.h>
void A();
void B();

int main() {
        A();
        return 0;
}


void A() {
        printf("I'm function A.\n");
        B();
}

void B() {
        printf("I'm function B.\n");
}
因此,我们写完函数后,顺便在头部加个声明。这样在整个.c文件中都可以调用了。

显然我们不满足于在单个文件中无忧无虑的调用,我们还会在其它文件中调用。但是如果直接在其它文件中调用B,仍然有警告(GCC虽然可以编译通过并运行,但这种隐式声明比出错更隐蔽,更不容易发现问题)。

当然,我们不止这么一个问题。还有,如果我们在A中声明了一个结构体,那么在B中无法使用。如果想要在B中使用,需要在B中重新声明。如果我们定义了一个全局的结构体,那我们需要在每个.c文件中声明一遍。


.h文件的由来
上面提了两个问题,这正是.h文件要解决的。.h文件中主要包含的就是函数声明、结构体声明、宏定义。那.h文件是如何解决上文两个问题的呢?实际上,编译器在处理.h文件时,仅仅是将.h文件复制到.c文件的头部,这样我们就不难理解了。一旦将.h文件复制到.c中,那在.c中使用函数、结构体、宏定义都自由了,因为已经声明过了。
这也是为什么在.c文件中会加入对自己.h文件的调用,例如:
test.h
void A();
void B();
test.c
#include <stdio.h>
#include "test.h"

int main() {
        A();
        return 0;
}


void A() {
        printf("I'm function A.\n");
        B();
}

void B() {
        printf("I'm function B.\n");
}
加粗部分会被test.h的内容覆盖,这样可以自由调用test.c中的函数而不用担心顺序问题。
我们也可以很方便的在其它.c文件中使用test.c的函数,我们只需要包含test.h即可。例如:
test2.c
#include "test.h"

void C() {
        B();
}
这样,编译器会把test.h中的内容复制到test2.c的头部,然后再编译。这相当于在test2.c中声明了test.c中的函数,这样再使用test.c中的函数就不会出现“隐式声明”的警告了。如果test.h中还有结构体的声明,那么在test2.c中也可以自由使用test.h中的结构体。可以看出,.h文件的存在,就是为了解决函数定义顺序问题以及不同.c文件间的函数调用问题。下面还会证明,在编译器看来.h文件与.c文件没有区别。


#include "..."#include <...>的区别
他俩区别主要在于编译器搜索路径不一样。引号一般是引用用户自己的头文件,编译器会搜索用户目录。尖括号一般是引用系统提供的头文件,编译器会搜索系统头文件目录。
还有一点,对于大型工程来说,用户的头文件可能会分别放在不同的子目录下,引号可以加相对路径或者绝对路径来指定头文件的位置,例如:
#include "arch/test.h"
这样制定的是源码根目录下的arch目录下的test.h头文件。


.h.c必须在一起吗?
.h文件不一定非要和.c文件放在一起。上文我们说过,编译器只是将.h文件内容拷贝到.c文件中,我们大可以将他们分开。很多程序都是.h一伙,.c放到另一伙的。只要我们include时指定的位置正确即可。


后缀名必须是.h吗?
这是约定俗成的东西,实际上编译器不会根据后缀名区分代码文件,在编译器里,这些文件都是文本文件,性质都是一样的。你甚至可以把头文件命名为.c,代码文件命名为.h,然后在.h文件中“#include "xxx.c"”。
这样,我们就对.h文件有了更进一步的了解。实际上,.h文件只不过是程序员们约定的东西,用来方便大家写代码和集成代码。解决我们前面提到的那些不方便(不用在每个.c中重复声明某个函数、结构体或宏定义)。


为什么.h里尽量不要放函数的定义?
这点主要是因为,大家约定好.h用来声明函数,并且知道头文件可能会被很多不同的.c代码文件包含。如果.h文件包含函数定义,那么两个及以上的地方引用了这个头文件,就会出现在两个及以上的地方都包含了相同的函数定义,这属于重复定义了。例如:
test.h
void C() {
    return;
}
test1.c
#include "test.h"
void A() {
...
}
test2.c
#include "test.h"
void B() {
...
}
编译器合并这些.h.c文件,会产生如下的效果:
void C() {
    return;
}

void A() {
...
}

void C() {
    return;
}

void B() {
...
}
显然C重复定义了。

没有评论:

发表评论