编译一个C程序设计很多步骤,大致为预处理,编译,汇编和链接.
在讲解预处理阶段之前,先简单总述一下程序的编译和链接.
1. 程序的编译和链接
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行.
这里有两个源文件构成了一个程序
test.c
int sum (int *a, int n);
int array[2] = {1, 2};
int main()
{
int val = sum(array, 2);
return val;
}
sum.c
int sum(int *a, int n)
{
int i, s = 0;
for (i = 0; i < n; i++)
{
s += a[i];
}
return s;
}
若我想将这两个源文件最终形成一个可执行程序,就需要通过编译系统提供的编译器驱动程序来处理, 它代表用户在需要时调用语言预处理器,编译器,汇编器和链接器.
例如:
visual studio 使用的就是 MSVC 编译器
GNU 编译系统要构造实例程序,就要通过在 shell 输入指令调用 GCC 驱动程序
我在 shell 输入以下指令来构建目标程序:
随后在我的 build 文件夹中,可执行程序prog
就在里面
这其中经历了如下过程:
若分成预处理,编译,汇编和链接这四个步骤,指令如下:
预处理
驱动程序首先运行C预处理器(cpp),它将C的源程序test.c
,sum.c
翻译成一个
ASCII码的中间件test.i
,sum.i
编译
接下来,驱动程序运行C编译器(cc1),它将test.i
,sum.i
翻译成一个ASCII汇编语言文件test.s
,sum.s
现版本GCC使用 cc1 可以完成 预处理 和 编译 两个步骤.
汇编
然后,启动程序运行(as),它将test.s
,sum.s
翻译成一个可重定位目标文件test.o
,sum.o
链接
最后运行链接器ld, 将test.o
和sum.o
预计一些必要的系统目标文件组合起来,创建一个可执行目标文件prog
这里显示的collect2
是ld
的封装,最终还是要调用ld
来进行链接的
最后执行prog
shell
调用操作系统中一个叫做加载器(loader)的函数,它将可执行文件prog
中的代码和数据复制到内存,然后将控制转移到这个程序的开头.
在x86-64 Linux 环境下, 可重定位目标文件是可执行可链接(ELF)格式的,大致如下:
每个可重定位目标文件有一个节叫做.symtab
,这是符号表,用来存放每一个符号的信息的, 符号包括 函数, 全局变量或者静态变量.
使用readlf
指令可以看到test.o
和sum.o
的符号表
-
test.o
的符号表
-
sum.o
的符号表
链接所做的就是对符号进行解析和重定位(将段表和符号表进行类似合并的操作),最后生成可执行文件.
2. 预处理详解
C预处理器(preprocessor)在源代码编译之前对其进行一些文本性质的操作.
它的主要任务包括删除注释,插入被
#include
指令包含的文件的内容,定义和替换由#define
指令定义的符号,以及确定代码的部分内容是否应该根据一些条件编译指令进行编译.
2.1 预定义符号
下面有一些由预处理器预定的符号.
有助于调试,添加版本信息和结合条件编译
符号 | 示例值 | 含义 |
---|---|---|
__FILE__ | “name.c” | 进行编译的源文件名 |
__LINE__ | 25 | 文件当前行的行号 |
__DATE__ | “Jan 31 1997” | 文件被编译的日期 |
__TIME__ | “18:00:00” | 文件被编译的时间 |
__STDC__ | 1 | 如果编译器遵循 ANSI C, 其值就为 1 , 否则未定义 |
下面我在x86-64Linux环境下运行
#include<stdio.h>
int main()
{
printf("%s\n", __FILE__);
printf("%d\n", __LINE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
printf("%d\n", __STDC__);
return 0;
}
程序运行结果如下:
2.2 #define
下面是#define
的正式用途
#define name stuff
有了这条指令后,源文件每次出现name
,预处理阶段就会将其替换成stuff
替换文本不仅局限于数值字面值常量,使用#define
指令,可以把任何文本替换到程序中,例如:
#include <stdio.h>
#define N 100
#define do_forever for(;;)
#define CASE break;case
int main(void)
{
int a = N;
printf("%d", a);
do_forever;
switch(a)
{
case 1:
CASE 2:
CASE 3:
}
return 0;
}
#define N 100
将 100 命名为 N#define do_forever for(;;)
将更具代表性的符号来命名无限循环的for
语句`#define CASE break;case
省去了需要写break
的情况,但不推荐使用,对代码可读性有明显降低
我通过将test.c
预处理生成test.i
打开test.i
发现,文件上面多出了很多行代码,这些都是<stdio.h>头文件中的代码,定位到main
函数发现:通过#define
命令命名的符号都被替换成了对应数值:
如果定义的 stuff 过长,可以使用反斜杠\
来进行换行
#include<stdio.h>
#define DEBUG_PRINT printf("File %s line %d:"\
"x = %d, y = %d, z = %d",\
__FILE__, __LINE__,\
x, y, z)
int main(void)
{
int x = 1;
int y = 2;
int z = 3;
x *= 2;
y += x;
z = x * y;
DEBUG_PRINT;
return 0;
}
这里利用了相邻字符串常量被自动连接成为一个字符串的特性
程序运行结果如下:
注意:最好不要在宏定义末尾添加逗号;
,如果在使用if-else
判断会出错
if(...)
DEBUG_PRINT; //若宏定义尾部添加了; 则在这会有两个逗号
else //else没有相对应的if匹配,程序出错
...
当然宏定义也可以直接定义一序列语句,例如函数,循环语句
#define SUM_LOOP \
for (i = 0; i < 10; i++)\
{ \
sum += i; \
if (i > 0) \
prod *= i; \
}
但不推荐这样,如果一串长代码经常出现在很多地方,应该是把这段代码放入函数而不是通过#define
宏
2.2.1 宏
#define
机制包括一个规定,允许把参数替换在文本中,这种实现成为宏或定义宏(defined macro)
#define name(parameter-list) stuff
name
和(parameter-list)
之间不能有空格,如果有空格,(parameter-list)
也会成为stuff
的一部分parameter-list
是一个用逗号分隔的参数列表,它们可能出现在stuff
中- 当宏被调用时, 每个参数对应的实际值将被替换到
stuff
中
例如:我定义了一个计算平方值的宏
#include<stdio.h>
#define SQUARE(x) x * x
int main(void)
{
int x = 5;
printf("%d\n", SQUARE(x));
printf("%d\n", SQUARE(x + 1));
return 0;
}
但是程序运行结果如下:
为什么printf("%d\n", SQUARE(x + 1));
得到的结果不是 36 而是 21 呢?
通过预处理指令得到test.i
并打开后看到:
(x + 1)
并没有先计算再替换宏,而是原封不动的替换进去,在进行计算.
因为宏是在预处理阶段进行替换的,而x
的值则是在实际运行阶段,开辟栈空间创建赋值的,谁先谁后一目了然.
在宏定义添加两个括号就可以解决这个问题:
#define SQUARE(x) (x) * (x)
这样SQUARE(x + 1)
得到的就是(x + 1) * (x + 1)
这里有另外一个宏定义
#include<stdio.h>
#define DOUBLE(x) (x) + (x)
int main(void)
{
int x = 5;
printf("%d\n", DOUBLE(x + 1));
printf("%d\n", 10 * DOUBLE(x + 1));
return 0;
}
程序运行结果如下:
printf("%d\n", 10 * DOUBLE(x + 1));
没有是我预期的12
而是66
再次观察预处理后的文本:
还是运算优先的问题
对其进行修改
#define DOUBLE(x) ((x) + (x))
这样DOUBLE(x + 1)
得到的就是((x + 1) + (x + 1))
注意:事实上,所有对数值表达式求值都应该用上面的方式加上括号,避免因为操作符优先而造成不可预料的结果.
2.2.2 #define
替换
在程序中扩展#define
定义符号和宏时, 需要涉及几个步骤
- 在调用宏时, 首先对参数进行检查, 看看是否包含了任何由
#define
定义的符号. 如果是, 它们首先被替换.- 替换文本随后被插入到程序中原来的位置. 对于宏, 参数名被他们的值所替代
- 最后, 再次对结果文本进行扫描, 看看它是否包含了任何由
#define
定义的符号.如果是, 就重复上述处理过程.
这样,宏参数和#define
定义可以包含其他#define
定义的符号.例如
#define N 100
#define INC(x) ((x) + 1)
int x = INC(N); //x = 101
但是,宏中不可以出现递归.
当预处理器搜索#define
定义的符号时,并不检查字符串常量的内容.
- 例如:
#include<stdio.h>
#define N 100
int main(void)
{
printf("N = %d", N);
return 0;
}
程序运行结果如下:
预处理后文本也确实没有对常量字符串内的符号进行替换:
- 还比如:
#include<stdio.h>
#define PRINT(x) printf("The value of x is %d\n", x)
int main(void)
{
int x = 2;
int y = 3;
double z = 1.2;
PRINT(x);
PRINT(y);
PRINT(z);
return 0;
}
程序运行结果如下:
也只能限制整数的形式
预处理后文本也确实没有对常量字符串内的符号进行替换:
那么,如果想把宏参数插入到字符串常量中,可以使用两个技巧
- 第一个技巧,利用邻近字符串自动连接的特性
#include<stdio.h>
#define PRINT(VALUE, FORMAT) printf("The value is "FORMAT"\n", VALUE)
int main(void)
{
int x = 2;
int y = 3;
double z = 1.2;
PRINT(x, "%d");
PRINT(y, "%d");
PRINT(z, "%f");
return 0;
}
程序运行结果如下:
预处理后文本如下:
但是这样只有当字符串常量作为宏参数才可以使用
- 第二个技巧,使用预处理器把一个宏参数转换为一个字符串.
#argument
这种结构被预处理器翻译为"argument"
这样我可以更好的更改我的PRINT
#include<stdio.h>
#define PRINT(VALUE, FORMAT) \
printf("The value of "#VALUE \
" is "FORMAT"\n", VALUE)
int main(void)
{
int x = 2;
int y = 3;
double z = 1.2;
PRINT(x, "%d");
PRINT(y, "%d");
PRINT(z, "%f");
return 0;
}
程序运行结果如下:
预处理后文本如下:
##
结构则执行一种不同的任务
它把位于自己两边的符号连接成一个符号.
它允许宏定义从分离的文本片段创建表示符.
例如:
#define CAT(x, y) x##y
int main(void)
{
int VB12 = 13;
printf("%d\n", CAT(VB, 12));
return 0;
}
程序运行结果如下:
预处理后文本如下:
注意:连接之后的标识符必须合法!
2.2.3 带副作用的宏参数
当宏参数在宏定义中出现的次数超过一次时,如果这个参数具有副作用,那么在使用这个宏时就可能出现危险,导致不可预料的结果.
副作用就是表达式在求值时出现永久性的效果
例如:
x + 1;
这个表达式可以执行上百次,得到的结果每次都是一致的,x
的值仍然不变.这个表达式不具有副作用.
但是:
x++;
就具有副作用了.当这个表达式下一次执行时,x
的值就发生了改变.
观察下列代码,它会打印出什么呢?
#include <stdio.h>
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int main(void)
{
int x = 5;
int y = 8;
int z = MAX(x++, y++);
printf("x = %d, y = %d, z = %d\n", x, y, z);
return 0;
}
程序执行结果如下:
通过查看预处理后文件就可以知道了:
- 第一步:
(x++) > (y++)
,比较x
和y
的大小,表达式为假,再对x
和y
进行++
操作,x
为6
,y
为9
- 第二步, 因为前面的表达式为假,执行
z = y++
,先执行z = y
,z
为9
;再执行++
,y
为10
- 最后
x
为6
,y
为10
,z
为9
2.2.4 宏与函数
宏非常频繁的用于执行简单的运算,比如在两个表达式中寻找其中较大(或较小)的一个:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
如果用函数完成这个任务呢?
int max(int a, int b)
{
return (a > b ? a : b);
}
答案肯定是函数方法更消耗时间,调用函数会先开辟栈空间,并复制实参,再进行函数内部操作,最后再返回值,归还栈空间.
而宏只需要简单的在预处理阶段就进行进行文本替换.
通过对下面的代码生成汇编代码可以明显看出区别
#include <stdio.h>
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int max(int a, int b)
{
return (a > b ? a : b);
}
int main(void)
{
int a = 3;
int b = 2;
int c = 0;
c = MAX(a, b);
c = max(a, b);
return 0;
}
通过调试进行反汇编,明显函数的代码要比宏的代码要多
宏相比函数的优点:
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算所需要的时间
更多.
所以宏比函数在程序的规模和速度方面更胜一筹 - 更为重要的是函数的参数必须声明为特定的类型.
所以函数只能在类型合适的表达式上使用.反之这个宏可以适用于整形,长整型,浮点型等可以用来比较的类型.
宏是类型无关的
当然宏也有缺点:
- 每次使用宏的时候,一份宏定义的代码将插入到程序中.除非宏比较短,否则可能大幅增加程序的长度.
- 宏是没法调试的
- 宏由于与类型无关,也就不够严谨
- 宏可能会带来运算符优先级的问题,导致程序容易出错
宏有时候也可以做到函数做不到的事情
宏的参数可以出现类型,而函数做不到
#include<stdio.h>
#define MALLOC(n, type) (type*)malloc(sizeof(type) * n)
int main(void)
{
int* p = MALLOC(10, int);
free(p);
return 0;
}
可以看到预处理后的文件直接替换成对应符号:
下面是#define
宏和真正的函数相比存在的一些不同的地方
属性 | #define 宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都被插入到程序中.除了非常小的宏,程序的长度将大幅增长 | 函数代码只出现在一个地方;每次使用这个函数,都会调用在那个地方的同一块代码 |
执行速度 | 更快 | 存在函数调用/返回的额外开销 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非它们加上括号,否则邻近操作符的优先级可能会产生不可预料的结果 | 函数参数只在函数调用时求值一次,它的结果值传递给函数.表达式的求值结果更容易预测 |
参数求值 | 参数每次用于宏定义时,它们都将重新进行求值.由于多次求值,具有副作用的参数可能会产生不可预料的结果 | 参数在函数被调用前只求值一次.在函数中多次使用参数并不会导致多个求值过程.参数的副作用并不会造成任何特殊的问题 |
参数类型 | 宏与类型无关.只要对参数的操作是合法的,它可以适用于任何参数类型 | 函数的参数是与类型有关的.如果参数的类型不同,就需要不同的函数,即使它们执行的任务是相同的 |
在C99标准下,有内联函数将宏和函数的优点都结合在了一起,后面会详细讲解的
2.2.5 命名约定
但是语言本身并不能让程序员区分#define
宏和函数,就需要规定命名格式来方便区分.
一种常见的约定就是:
宏名字全部大写
这样也可以提醒程序员使用宏之前,注意传入有副作用的参数
但是有时候,库中会故意将宏设置成小写伪装成一个函数,例如offset
2.2.6 #undef
undef
用于移除一个宏定义
#undef name
如果一个现存的名字需要被重新定义, 那么首先必须用undef
移除它的旧定义.
#include <stdio.h>
#define N 100
int main(void)
{
printf("%d\n", N);
#undef N
printf("%d\n", N);
return 0;
}
我先宏定义了N
为 100 ,随后移除了N
的定义, 打开预处理后的文件如下:
移除定义后, N
没有被替换为 100
2.2.7 命令行定义
许多C编译器提供了一种能力, 允许在命令行定义符号,用于启动编译过程.
当根据同一个源文件编译一个按程序的不同版本时,这个特性是很有用的.例如:
假定某个程序声明了一个某种长度的数组.如果某个机器的内存很有限,这个数组必须很小,但在另一个内存充裕的机器上,你可以希望数组能够大一些
#include <stdio.h>
int main(void)
{
int arr[SIZE] = {0,};
int i = 0;
for (i = 0; i < SIZE; i++)
{
arr[i] = i;
}
for (i = 0; i < SIZE; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
上述代码,我没有规定SIZE
的值,可以在命令行执行SIZE
的值
在GCC
编译器下,-D
选项可以完成这项任务
-Dname
-Dname=stuff
第一种形式定义了符号name
,它的值为1
;
第二种形式定义了符号name
,定义为等号后面的值stuff
对于上面的程序,我在命令行对其进行编译链接:
运行可执行文件,得到如下结果:
将SIZE
定义为另外的值进行编译链接:
运行可执行文件,得到如下结果:
同样,在命令行也可以去除符号的定义,-U
完成这项任务.
-Uname
将导致程序中符号name
的初始定义被忽略.当它与条件编译结合使用时,这个特性还是很有用的.
2.3 条件编译
在编译一个程序的时候我们可以使用条件编译将一条语句(一组语句)编译或者放弃.
使用条件编译,可以选择代码的一部分是被正常编译还是完全忽略.
用于支持条件编译的基本结构是#if
指令和与其匹配的#endif
指令.
单分支条件编译
#if constant-expression
statements
#endif
其中constant-expression
常量表达式由预处理器对其进行求值.如果它的值是非零值(真),那么statements
部分就会被正常编译,否则预处理器就静默地删除它们.
常量表达式,需要是常量表达式或者是#define
定义的符号.
如果在程序执行前都不能得到它的值,那么它在常量表达式中就是非法的,因为它们的值在编译时是不可预测的.
例如,我可以通过条件编译保留调试性代码,这样在我需要对程序进行调试的时候,就可以选择性的进行编译
#include <stdio.h>
#define DEBUG 1
int main(void)
{
int i = 0;
int arr[10] = {0,};
for (i = 0; i < 10; i++)
{
arr[i] = i;
#if DEBUG
printf("%d\n", arr[i]);
#endif
}
return 0;
}
若DEBUG
的值为真,则程序运行结果如下:
若DEBUG
的值为假,(将DEBUG
的值设置为 0 ),程序没有进行任何输出:
多分支条件编译
条件编译也可以在编译时选择不同的代码部分.
为了支持这个功能,#if
指令还有可选的#elif
和#else
子句
#if constant-expression
statements
#elif constant-expression
other statements
#else
other statements
#endif
#elif
出现的次数不限,只有对应的分支的constant-expression
为真,才会编译对应语句,如果都为假,则只会编译#else
对应的语句
多分支条件编译可以用于一个程序有不同的版本,这样避免了为每个版本编写一组不同的源文件.
#include <stdio.h>
#define version1 0
#define version2 1
#define version3 0
int main(void)
{
#if version1
printf("use version1.\n");
#elif version2
printf("use version2.\n");
#elif version3
printf("use version3.\n");
#else
printf("no use.\n");
#endif
return 0;
}
程序运行结果如下:
2.3.1 是否被定义
测试一个符号是否已被定义也是可行的.
在条件编译中完成这个任务更为方便,因为如果程序并不需要控制编译的符号所控制的特性,就不需要定义符号
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
上面每对定义的两条语句是等价的,但是#if
形式功能更强,可以添加其他需要判断的条件.
例如:
#include <stdio.h>
#define TEST1 1 //只定义TEST1
int main(void)
{
#ifdef TEST1
printf("TEST1 is defined.\n");
#endif
#ifndef TEST1
printf("TEST1 is undefined.\n");
#endif
#ifdef TEST2
printf("TEST2 is defined.\n");
#endif
#ifndef TEST2
printf("TEST2 is undefined.\n");
#endif
return 0;
}
程序运行结果如下:
2.3.2 嵌套指令
和if
语句一样,条件编译也是可以嵌套的
#if defined(OS_UNIX)
#ifdef OPTION1
unix_version_option1();
#endif
#ifdef OPTION2
unix_version_option2();
#endif
#elif defined(OS_MSDOS)
#ifdef OPTION2
msdos_version_option2();
#endif
#endif
为了方便记住复杂的嵌套指令,可以为每一个#endif
加上一个注释标签,标签的内容就是#if
或#ifdef
后面的表达式,例如:
#ifdef OPTION1
...
#else
...
#endif /* OPTION1 */
2.4 文件包含
#include
指令是另一个文件的内容被编译,就像它实际出现于#include
指令出现的位置一样.
这种替换执行的方式很简单: 预处理器删除这条指令, 并用包含文件的内容取而代之.
这样,如果一个头文件被包含到 10 个源文件中,实际上它被编译了 10 次
提示: 这意味着#include
会涉及一些开销,但我们无需担心这种开销
-
- 如果两个源文件都需要同一组声明,我将这些声明放入一个头文件再声明和这些声明被分别包含所花费的时间相差无几
-
- 头文件包含是在预处理阶段执行的,并不会影响编译链接后程序实际的运行时间
提示:
当头文件被包含时,头文件中所有的内容都会被编译.这样最好将每组的函数或者数据的声明放在不同的头文件中.与将所有的声明放在一个巨大的头文件相比,前一种方法还是要更加好一点.
提示:
程序设计和模块化的原则也支持这种方法.只把必要的声明包含与一个头文件中要更加好一点,这样就不会访问到一些不想被访问的私有化的函数或者数据.同时也更好维护
2.4.1 函数库文件包含
编译器支持两种不同类型的
#include
文件包含: 函数库文件 和 本地文件.事实上,它们的区别很小.
函数库文件的包含使用以下语法:
#include <filename>
对于filename
没有限制,但根据规定,标准库文件最好以.h
后缀结尾(从技术上来说,函数库头文件并不需要以文件的形式存储,但是对于程序员来说,这并不会显而易见)
在Linux
系统中, 函数库头文件放置在/usr/include
中:
2.4.2 本地文件包含
#include "filename"
查找策略: 现在源文件所在目录下查找,如果该头文件未找到,编译器就会查找函数库文件位置进行查找.
如果都查找不到就会提示编译错误
当然可以将所有的头文件都以""
来包含
但是这样查找的效率就会低了,同时也分不清什么是函数库文件和本地文件了.
2.4.3 嵌套文件包含
嵌套的#include
将使我们很难看清楚文件包含关系
例如:
comm.h
和 comm.c
是公共模块
test1.h
和 test1.c
使用了公共模块
test2.h
和 test2.c
使用了公共模块
test.h
和 test.c
使用了test1
和test2
模块
这样最终程序出现了两份comm.h
的内容,造成了文件内容的重复.
如果comm.h
文件内容很多,如果被重复了10
次,源文件预处理后的代码量是不可以想象的!
使用条件编译就可以解决这个问题
#ifndef __TEST_H__
#define __TEST_H__
//头文件的包含
#endif //__TEST_H__
或者现在直接使用#pragma once
解决这个问题
#pragma once
2.5 其他指令
#error
#error text of error message
#error
允许生成错误信息.
#line
#line number "string"
#line
将修改__LINE__
的值为number
;如果添加了可选部分"string"
,则会修改__FILE__
的值为string
#prgama
#pragma
允许一些编译选项或者其他任何方式无法实现的一些处理方式
例如,有些编译器使用#pragma
指令在编译过程中打开或者关闭清单显示,或者把汇编代码插入到C程序中.
预处理器将忽视它不认识的#pragma
指令
本章完.