从零构建内核:科学地使用C语言头文件组织工程

在这篇文章我将要分析C语言中头文件的作用,以及哪些东西应该包含在头文件中。其中包括几个工程实例的研究,并给出相关结论。

头文件

头文件是C工程中以”.h”结尾的文件,与”.c”结尾的源文件对应。我们要知道关于头文件的两点事实:

  • 头文件由”#include”编译指令在预处理过程中被完整复制进源文件
  • 头文件本身不能被编译

为什么要引入一个头文件,而不是把这些东西全部放在源文件里呢?就我之前编写C工程的些许经验而言,头文件的好处主要是减少重复的内容:我们可以把一些共享的内容写在一个头文件里,让多个源文件去包含。把重复写的任务交给预编译程序去做,减少工作量,降低出错率。

通常在编译多文件工程时,一个源文件编译生成一个目标文件。多个目标文件再通过链接,和库拼在一起并加入启动代码生成可执行文件。如果一个源文件被修改,其它源文件不变,那么我们紧紧需要编译这个被改动的源文件然后重新链接即可。这个过程便是模块编译。模块编译大大节约了编译时间。

对于某以模块A,可能引用了其他模块B中提供的函数B_fun。在编译模块A的过程中,编译器是不知道B_fun的定义在哪里的--编译器只知道在A的自身源文件里找,找不到就报错。如果我们加上一句B_fun的声明(最好前面在加个extern表明是其它模块符号),那么编译器虽然还是不知道B_fun的定义在哪里,但是它清楚自己将来会找到的。它就轻松愉快地把B_fun记录在自己的符号表中,等着时间来给它答案。

可是这样一来,所有需要用到B_fun的模块都要在自己的源文件里添加B_fun的声明--这多麻烦,还不好修改!与其让调用者每人加一个声明,不如自己准备好这个声明,方便他人。而且这个B_fun正是我们说的“重复的内容”或者说是要被共享的内容,适合放在头文件里。这样所有的调用者只要#include一下B模块的头函数一切就结束了。

函数声明是头文件里的重要内容。但头文件里还能包含很多其它内容。头文件的存在让工程结构更加清晰,但不当的使用将会带来完全相反的作用。下面我们将深入讨论头文件的使用。

头文件的使用

要清楚头文件里怎么用,我们先列一下C语言常用的组成部分:

  • 函数声明
  • 函数定义
  • 结构体定义
  • 全局变量
  • extern外部(全局)变量和外部(全局)函数
  • static局部变量和局部函数
  • inline函数
  • #include
  • #define的常量

在学习C++过程中,我们一般把类的定义放在头文件中,把类成员函数的实现写在源文件中。这个思路对我写C语言有了一些不良影响,让我习惯源文件中只有函数实现,而其它东西怎么处理呢?那统统放在头文件中里头!头文件成了垃圾场,导致比较复杂的多文件工程中常常出现问题。头文件里到底放些什么呢?我们先看看人家怎么科学地使用头文件。

几个例子

这一节我分析了几个比较有名的开源工程,包括:github上的红人gitredis,还有Linux上的eCryptfs文件系统,以及嵌入式大师Jean J.Labrosse编写的uC/OS-II操作系统。

Git的做法

Git作为Linus Torvalds的项目,代码风格规范,可从头文件http.h和对应源文件http.c中可见一斑。

在http.h中,我发现了所有的inline函数和结构体定义。在http.c中,我发现了所有的static修饰的局部变量和局部函数。http.c中的全局变量和需要导出的函数,在http.h中均被extern关键字导出。源文件和头文件中均有#define的常量,不过源文件中的出现的数量较少,并且grep结果表明源文件中的#define的常量只在源文件自身被使用,而头文件中的被其他模块所使用。源文件和头文件中均出现了#include导入了其它头文件。

Redis的做法

Redis与git做法类似。不过导出的函数并没有使用extern修饰。

为什么导出全局变量需要extern而导出函数不一定需要?因为函数声明和定义可以区分,而变量的声明就是定义。可以理解成extern在帮助编译器去分变量的声明。

不过用extern修饰导出函数显然看起更规范。

eCryptfs的做法

ecryptfs全工程共同使用一个头文件。改头文件包含了所有#define常量和inline函数和extern全局变量和函数。全局变量/函数以及static修饰的局部函数本身定义在各自的源文件中。源文件和头文件中均出现了#include导入了其它头文件。

uC/OS-II的风格

uC/OS-II的做法比较奇特,看它的总控源文件:

/*
*********************************************************************************************************
*                                                uC/OS-II
*                                          The Real-Time Kernel
*
*                          (c) Copyright 1992-2002, Jean J. Labrosse, Weston, FL
*                                           All Rights Reserved
*
* File : uCOS_II.C
* By   : Jean J. Labrosse
*********************************************************************************************************
*/

#define  OS_GLOBALS                           /* Declare GLOBAL variables                              */
#include "includes.h"


#define  OS_MASTER_FILE                       /* Prevent the following files from including includes.h */
#include "\software\ucos-ii\source\os_core.c"
#include "\software\ucos-ii\source\os_flag.c"
#include "\software\ucos-ii\source\os_mbox.c"
#include "\software\ucos-ii\source\os_mem.c"
#include "\software\ucos-ii\source\os_mutex.c"
#include "\software\ucos-ii\source\os_q.c"
#include "\software\ucos-ii\source\os_sem.c"
#include "\software\ucos-ii\source\os_task.c"
#include "\software\ucos-ii\source\os_time.c"

因为#include 指令的作用是把文件完全照搬进本文件,Jean通过这种方式来在预编译时组装成一个大的源文件然后进行编译,同时又能在平时保持工程的划分。而且可以预料,它的Makefile应该很简单,因为真正编译的只有 uCOS_II.C这一个总控源文件。不过,有利有弊。我认为这种方式用与uC/OS-II这样的只有几千行代码的项目是可以的,但不能用于大型工程项目(e.g. Linux内核)。原因在于它在编译时其实是编译了一个巨型的总控文件,没有发挥C语言支持模块化编译的优势。每修改一处代码,将会全部重新编译。

结论

通过分析,我发现之前自己对于头文件是垃圾堆的观念是错误的。头文件的地位是在模块化编译时作为模块和其他模块的接口。头文件和对应源文件里分别放置什么?原则应该是是:会被别的模块用到的放头文件,仅自己使用的放源文件。具体说:

  • 函数声明(.h)
  • 函数定义(.h)
  • 结构体定义(导出:.h,自己用:.c)
  • 全局变量(.h)
  • extern外部(全局)变量和外部(全局)函数(.h)
  • static局部变量和局部函数(.c)
  • inline函数(.h)
  • #include (导出:.h,自己用:.c)
  • #define的常量(导出:.h,自己用:.c)

我的DIY内核将会按照以上方式组织。