C 语言预处理器、编译器、链接器简介
将 C 语言源代码,处理成可执行的文件,大致需要三步,预处理、编译和链接。
预处理器的作用是根据一定的规则,进行纯文本的替换,递归的执行,直到没有可替换的内容结束。使用 gcc -E
可以仅做预处理。
C 语言中,大致有以下几种做法。
第一种,定义一个常量。
上述就定义了两个常量,宽和高。我相信,没人想在源代码的很多地方写上 480 和 720 这两个数字。使用宏定义可以给这两个数字起一个有意义的名字,代码中使用有意义的名字来提升代码的可读性。预处理器会把代码中的WIDTH
和 HEIGHT
全部替换成 480 和 720。
第二种,定义函数。
上述就定义了一个返回最大值的函数。为啥要套这么多括号,因为 a
和 b
可以是复杂的运算而不是简单的两个数,不写括号,可能会由于运算优先级被改变而导致出现非程序员期望的结果。
宏定义函数的优点是比普通的函数快,宏展开之后,不会有真正的函数调用。而普通调用需要保存上下文,传参,返回等等,浪费些许时间。
但是缺点很多:缺少必要的检查;写不好会导致效率低下,比如 int max = MAX(fib(a),fib(b))
,展开后会调用三次 fib
函数;多次或者不正确的副作用,比如 int max = MAX(m++,n++)
,展开后,m
和 n
会调用不同次 ++
。
assert
就是一个经典的宏定义函数,大致如下:
#ifdef NDEBUG
#define assert(cond) (void)0
#else
#define assert(cond) \
((cond)?(void)0:\
fprint(xxxx), exit(0))
#endif
联系到 C 语言被创造的时间,上个世纪六七十年代,信条是程序员知道他做得事情,让他去做,甚至包括硬件。所以 C 语言设计的是基于硬件层的抽象,都能直接的映射到硬件操作。但是时至今日,操作系统,软件,硬件都变得日益复杂,程序员这个行业也越来越庞大,codebase 也以数量级的方式增长,不能指望每个程序员都能明白他们在做的事情。所以,现代语言都从语言层面来限制程序员,最大效率的生产出软件,而软件本身的效率只是诸多关注点的一个罢了。
第三种,包含头文件。
预处理器会查找对应文件并作替换,本来很小的文件会变的很大。如果出现了循环依赖,那么预处理器就会一直递归的处理下去,无穷无尽。如何解决这个问题呢?在需要的地方使用宏定义。 智能的现代预处理器应该会自己处理这种问题,即使你没写上述的代码。但是,写上述的东西很容易,而且就算没有循环依赖也没有什么副作用,所以最佳实践是,头文件都写上上述三行代码。预处理器把 C 语言源代码处理完之后,编译器会处理成对象文件(比如 .o 文件),链接器会找到相关文件,链接组成一个二进制文件。下面看一个例子:
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
int main()
{
void *mem = malloc(400);
assert(mem != NULL);
printf("HAHA\n");
free(mem);
return 0;
}
assert
呢?因为 assert
是宏,预处理器处理完就没有了。
链接器把 main.o 和对应库文件链接生成 main.out 这个可执行文件。运行这个文件,会打印HAHA。
如果我们把 include <stdio>
注释掉:预处理器处理完成的文件是没有 printf
的声明的,编译时发现语法都是正确的,gcc 会推测 printf
是一个输入 string
,返回 int
的函数,随之产生一个 warning,链接器根据编译器给出的结果载入对应的库文件,生成的可执行文件和正常的是一样的。这里涉及默认链接哪些文件。
如果我们把 include <stdlib>
注释掉:编译器发现 malloc
free
没有声明,推测其函数原型,产生 warning,后续和上面情况类似,一切都正常。
如果我们把 include <assert>
注释掉:预处理不会做对应的宏展开,编译器发现 assert
,推测原型,.o 文件中多了一行 CALL assert
,链接器在库里面找不到 assert
函数,报错。
对于上面三种情况的一和二,自己写一个假的原型来欺骗编译器也可以,就去掉了 warning。
其实,声明函数,定义一系列的参数列表,只要是为了能够在 stack 上正确的记录上下文活动,而给编译器看是附带效果。
看下面的示例,程序是诡异的,但是能够正常输出的。
int strlen(char*, int);
int main()
{
int n = 65;
int length = strlen((char*)&num, num);
printf("%d\n", length);
return 0;
}
strlen
的原型,但是和库里面的不一致,使用 gcc 编译之后,程序能正常运行,输出多少呢?编译器看到 strlen
需要两个参数,且返回 int
。链接器去找 strlen
定义,很开心,找到了,链接,生成文件。这里需要说明下,.o 文件没有参数类型,链接器只看名字。
运行时,栈里面是什么情况呢?栈最底下是 n
,然后分配 length
需要的空间,再 push num
和 num
地址进去,两个参数,SP=SP-8,保存 PC 上下文,调用 strlen
,strlen
需要一个参数 char*
,去 SP 的地址去拿,幸好,SP 指向地方还真是个指针,指向 num
。strlen
把 num
开始的地址作为char*
去处理,遇到第一个 0 结束。在小端机器上,num
内存是 65 0 0 0,遇到第一个 0 结束,所以它认为这个字符串长度为1。所以,这个诡异的程序输出了 1!