C 语言预处理器、编译器、链接器简介

将 C 语言源代码,处理成可执行的文件,大致需要三步,预处理、编译和链接。

预处理器的作用是根据一定的规则,进行纯文本的替换,递归的执行,直到没有可替换的内容结束。使用 gcc -E 可以仅做预处理。

C 语言中,大致有以下几种做法。

第一种,定义一个常量。

#define WIDTH 480
#define HEIGHT 720
上述就定义了两个常量,宽和高。我相信,没人想在源代码的很多地方写上 480 和 720 这两个数字。使用宏定义可以给这两个数字起一个有意义的名字,代码中使用有意义的名字来提升代码的可读性。预处理器会把代码中的 WIDTHHEIGHT 全部替换成 480 和 720。

第二种,定义函数。

#define MAX(a,b) (((a)>(b))?(a):(b))
上述就定义了一个返回最大值的函数。

为啥要套这么多括号,因为 ab 可以是复杂的运算而不是简单的两个数,不写括号,可能会由于运算优先级被改变而导致出现非程序员期望的结果。

宏定义函数的优点是比普通的函数快,宏展开之后,不会有真正的函数调用。而普通调用需要保存上下文,传参,返回等等,浪费些许时间。

但是缺点很多:缺少必要的检查;写不好会导致效率低下,比如 int max = MAX(fib(a),fib(b)),展开后会调用三次 fib 函数;多次或者不正确的副作用,比如 int max = MAX(m++,n++),展开后,mn 会调用不同次 ++

assert 就是一个经典的宏定义函数,大致如下:

#ifdef NDEBUG
#define assert(cond) (void)0
#else
#define assert(cond) \
        ((cond)?(void)0:\
            fprint(xxxx), exit(0))
#endif
对于宏函数,我个人觉得弊大于利,所以还是应该谨慎使用。

联系到 C 语言被创造的时间,上个世纪六七十年代,信条是程序员知道他做得事情,让他去做,甚至包括硬件。所以 C 语言设计的是基于硬件层的抽象,都能直接的映射到硬件操作。但是时至今日,操作系统,软件,硬件都变得日益复杂,程序员这个行业也越来越庞大,codebase 也以数量级的方式增长,不能指望每个程序员都能明白他们在做的事情。所以,现代语言都从语言层面来限制程序员,最大效率的生产出软件,而软件本身的效率只是诸多关注点的一个罢了。

第三种,包含头文件。

#include <assert.h>
#include "yourheaderfile.h"
预处理器会查找对应文件并作替换,本来很小的文件会变的很大。如果出现了循环依赖,那么预处理器就会一直递归的处理下去,无穷无尽。如何解决这个问题呢?在需要的地方使用宏定义。

#ifndef XXX_H
#define XXX_H
// code
#endif
智能的现代预处理器应该会自己处理这种问题,即使你没写上述的代码。但是,写上述的东西很容易,而且就算没有循环依赖也没有什么副作用,所以最佳实践是,头文件都写上上述三行代码。

预处理器把 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;
}
生成的 main.o 文件大致如下:
CALL malloc
CALL printf
CALL free
RET  
为啥没有 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 numnum 地址进去,两个参数,SP=SP-8,保存 PC 上下文,调用 strlenstrlen 需要一个参数 char*,去 SP 的地址去拿,幸好,SP 指向地方还真是个指针,指向 numstrlennum 开始的地址作为char* 去处理,遇到第一个 0 结束。在小端机器上,num 内存是 65 0 0 0,遇到第一个 0 结束,所以它认为这个字符串长度为1。所以,这个诡异的程序输出了 1!