曾经在初读 CSAPP 之时,我初步了解了 C 语言的全部编译过程,即大概可分为以下三大步:

  • 预处理
  • 编译
  • 链接

今天,又看了节公开课,对这部分有了相对较为深入的理解,并且知道了一些有意思的事情。

开始之前,我们应该有这样一个概念:在使用 gcc 编译器进行编译之时,以上三步在某种意义上来说是相互分离的。

预处理阶段

对于此阶段,我之前所理解的全部,便是曾经所做的笔记:

通过预处理器( cpp ) 处理之后,将其变成了 .i 文件,.i 文件实际上对你写的 .c 文件里以井号 # 开头的语句进行了处理,其发现你引入了某个头文件,于是预处理器便将头文件的内容插入到了你的代码之中。

而如今,我想可以进一步扩展(或许不准确),预处理阶段所处理的内容,实质上就是对 C语言中的宏进行处理,而其中需要处理的部分,即通过宏语法限定的,在程序中有效的宏语言1。其中主要有两大类:

  • #define :对以 #define 打头的宏定义部分进行替换。
  • #include:对以 #include 格式引入的库文件进行添加。

#define

最为常见的宏定义,新手常常使用其表示变量,老手便会使用宏来表示一些简单的函数等等:

1
2
#define PI 3.14159	// 新手
#define MAX(a,b) ((a)>(b))?(a):(b) // 老手

一般来说,既然已经有了函数,但是还是有许多人使用宏来替代函数,那么必有原因,一般来说,宏的优点主要有以下几点:

  • 同函数一般,简单可复用!同时能够使代码含义更加清晰。
  • 与函数相比,其直接在预处理阶段进行替换,所以其省去了函数调用的时间,所以,我觉得宏适合在下列情境下使用:
    • 函数功能较为简单,只有一行代码,函数调用所花时间对函数功能影响较大
    • 函数需要多次重复使用(不适合有递归的情况)

#include

只要学过C语言,想必就不会对此陌生,人生中第一个 hello world 就使用了这个语句,也就是所谓的头文件,头文件的格式有以下两种:

  • #include<> :以 <> 引用的一般是系统自带的库文件
  • #include"" :以 "" 引用的一般是自己写的头文件

此部分无需多叙。

编译阶段

所谓编译,就是将C语言文件编译为汇编文件,以方便计算机执行,而此阶段所做工作的目的是: 将C语言进行初步编译,并对程序进行语法上的一次检查,对不符合语法规则的,进行报错,也就是 Error ,并会停止程序的编译 ;对有争议,或是不明确的内容提出警告 Warning ,但是并不会停止编译,最终还是会生成可执行文件 。

既然如此,那么有这样一个有趣的问题:如果,我们在写代码的时候,不加上头文件,那么该代码是否能通过编译呢?

示例

有这样一段毫无意义的代码,其目的仅是为了说明上述问题:

1
2
3
4
5
6
7
8
9
10
11
#include<stdio.h>	// printf
#include<stdlib.h> // malloc, free
#include<assert.h> // assert

int main() {
void* mom = malloc(400);
assert(mom != NULL);
printf("yeah!\n");
free(mom);
return 0;
}

我们首先将 #include<stdio.h> 注释掉,那么此时,编译到 printf 语句的时候,编译器由于找不到 printf 的声明,于是会提出警告,并将 printf 以默认的方式进行编译——将之认为是一个函数(参数为字符串,返回值为 int )。

所以在编译阶段,此代码即使去掉了 #include<stdio.h> 头文件,也依旧能够编译成功

链接阶段

链接阶段看似是根据编译的结果进行的,但是却不尽然,因为链接阶段是相对独立的,在链接阶段,编译器会根据编译之后的结果,在库中去依次寻找对应的函数。

所以,上述代码,在编译成功之后,进行链接之时,依旧能够从库中寻找到 printf 函数,并执行成功。也就是说,上述代码,去掉了 #include<stdio.h> 头文件,使用 gcc 编译,能够成功地编译并产生可执行文件,唯一的影响仅是:在编译过程中会产生一条警告信息。我自己实验的结果也同理论相同,结果如下图:

C的编译与链接1.png
C的编译与链接1.png

可以看到,仅产生了一个 warning,并且能够执行成功。

普适与特例

同样地,在去掉 #include<stdlib.h> 之后,也如是。但是,正如上面所说,链接阶段会重新在库函数中寻找函数原型,那么对于所有的库函数,都能够如此吗? 答案当然是否定的!

#include<assert.h> 头文件之中,assert() 的实现,实质上并不是一个函数,而是一个宏定义,所以如果去掉此头文件,在编译阶段便会将 assert() 解释为一个函数。但是显然,其并非一个函数,那么链接之时便不可能找得到了,所以如此便会出错!验证结果如下:

C的编译与链接2.png
C的编译与链接2.png

意义

乍一看,这种写法好像没什么意义,但是,如果需要追求极度的精简,减小文件体积,这样的写法还是有意义的。事实上,我觉得这种写法,其实是远古时期,内存极度短缺时候的小技巧【实际测试中,其最后产生的可执行文件大小是一样的】。

至于为何会影响文件大小?因为加上头文件之后,在预处理阶段就会将头文件中的所有内容加到你的程序当中,而尽管你只使用了该头文件中的几个函数。而以现在大内存当道的现状,估计很少会有人这么写了。

所以,此技巧更多地还是体现了C语言的 ”自由“,并且还是蛮有趣的。

深入

既然不加头文件,编译器会将其理解为一个函数,那么我们就会想到:我直接在自己的代码中以函数的方式进行声明,那么是不是就不会产生警告了?答案是可行的!2验证如下:

代码改为:

1
2
3
4
5
6
7
8
9
10
11
12
#include<stdlib.h>	// malloc, free
#include<assert.h> // assert

int printf(char* s);

int main() {
void* mom = malloc(400);
assert(mom != NULL);
printf("yeah!\n");
free(mom);
return 0;
}

编译结果如下:

C的编译与链接3.png
C的编译与链接3.png

拓展

有个很有趣的事情,如果不加库,并且利用一个库函数中已有的函数,但是将其参数进行改变(传参数目变多或变少),那么此时程序又会如何被编译,或是产生何种后果?

比如下面一段代码:

1
2
3
4
5
6
int mian() {
int num = 65;
int length = strlen((char*)&num, num);
printf("length = %d\n", length);
return 0;
}

话不多说,首先来看编译结果:

C的编译和链接4.png
C的编译和链接4.png

哈哈哈哈哈,报错了,本来还想着正确之后讲述一番教授的思路呢!看来时代变了啊,编译器是变得安全了不少!那这部分也只能提前结束了。


  1. 为何说是有效,因为宏拥有一套自己简单的语法,可以控制某段宏是否进行处理,处理几次等操作,所以在此使用了有效的宏语言一词

  2. 可以看到,最终编译结果和公开课教授所讲的还是有出入,可能是编译器进化了的缘故吧!毕竟是10 年的公开课