目录
一、编译和链接的过程
组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code),然后每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序
在整个的编译过程中,我们可以在 gcc编译器 上输入命令行,来查看每个步骤的结果
1.1预处理选项
gcc -E source.c -o output.i
这条命令会把 source.c 文件经过预处理后的结果保存在 output.i 文件中。可以打开output.i文件来查看预处理器对源代码做了哪些改变
我们可以发现,在预处理时,多了 大约800行的代码,这些代码是头文件的包含,同时我们 #define 定义的宏被直接替换成了 100,注释也被删除了
这些都是文本操作
1.2编译选项
gcc -S test.c
输入此命令行后,会生成 test.s 文件(即后缀为.s),其中包含了汇编语言代码,如下图所示
此阶段会进行符号汇总,即不同文件内的函数名称
1.3汇编选项
gcc -c test.c
我们输入此命令行后,会生成一个test.o(即后缀名为 .o )的目标文件,这个文件是二进制文件,其中包含了编译后的机器语言代码。
目标文件通常是用于链接,生成可执行文件或库文件的
此阶段会生成符号表,把编译阶段汇总的符号形成符号表,符号表有 函数名称与地址
1.4链接
分为合并段表,符号表的合并,符号表的重定位
1.4.1合并段表
将多个目标文件中的相同类型的段(如代码段、数据段)合并为一个单独的段,链接器还会计算每个定义的符号(如函数名、变量名)在虚拟地址空间的绝对地址
1.4.2符号表的合并
符号表是记录目标文件中所有用到的符号(如函数名、变量名)的表格。在链接过程中,链接器会根据符号表解析符号引用
例如,如果一个目标文件中有一个函数调用另一个目标文件中定义的函数,那么链接器会根据符号表找到被调用函数的地址,并将函数调用处的地址修改为被调用函数的地址。
1.4.3符号表的重定位
重定位是将可执行文件中的符号引用处修改为重定位后的地址信息
这一步骤是必要的,因为在编译时,编译器并不知道每个符号最终会被放置在内存的什么位置
所以,在编译时生成的目标文件中,符号引用处只是一个占位符,表示需要在链接时填入正确的地址信息
在链接过程中,链接器会根据符号表和重定位信息计算出每个符号的正确地址,并将可执行文件中的符号引用处修改为正确的地址信息
1.5运行环境
1.程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须手动安排,也可能是通过可执行代码置入只读内存来完成。
2. 程序的执行便开始。接着便调用main函数。
3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程 一直保留他们的值。
4. 终止程序。正常终止main函数;也有可能是意外终止。
二、预处理中的符号与定义宏
2.1预定义符号
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
#include <stdio.h>
#include <stdlib.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;
}
运行结果为
main.cpp
6
Jul 22 2023
03:34:58
1
预定义符号是C标准定义的宏定义符号,总共有以上五个
2.2#define
#define是C语言中的一个预处理指令,用于定义宏。宏是一种简单的文本替换机制,可以为常量值、表达式或代码片段定义一个名称,然后在程序中使用该名称来代替实际的值、表达式或代码片段。
2.2.1 #define 定义宏
常量
#define PI 3.14159
我们定义了一个名为 PI 的宏,3.14159则是它的值
当预处理器遇到 PI 时,它会用 3.14159 来替换它,如下
double circumference = 2 * PI * radius;
在预处理后会变成
double circumference = 2 * 3.14159 * radius;
除了定义常量值之外,#define
还可以用来定义带参数的宏。带参数的宏类似于函数,可以接受一组参数,并根据这些参数生成替换文本
带参的函数数
例如,下面的代码定义了一个名为MAX
的带参数的宏,用于计算两个值中较大者
#define MAX(a, b) ((a) > (b) ? (a) : (b))
当预处理器遇到 MAX 时,会替换掉它
int maximum = MAX(x, y);
预处理后变为
int maximum = ((x) > (y) ? (x) : (y));
需要注意的是,使用带参数的宏时,应该始终在参数和替换文本中使用括号来避免运算符优先级问题
我们再举一个例子
#define DOUBLE(x) (x) + (x)
int a = 5;
printf("%d\n" ,10 * DOUBLE(a));
当我们定义宏后,在预处理后,第三行的DOUBLE会被替换,如下
printf ("%d\n",10 * (5) + (5));
此时代码的优先级顺序就被改变了,打印的值为 55 ,而不是我们预想中的 100
为了避免这种情况。我们应改变宏定义
#define DOUBLE( x) ( ( x ) + ( x ) )
即加上括号,避免出现优先级问题
2.2.2#define的替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换
2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换
3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上 述处理过程
4. 宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归
5. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索 ,即定义的宏的名称出现在字符串中则不被替换
2.2.3 #和##
#的作用
#
运算符用于将宏的参数转换为字符串常量。当预处理器遇到带有#
运算符的宏定义时,它会用双引号括起来的参数名称来替换宏定义中的参数
int i = 10;
#define PRINT(FORMAT, VALUE)
printf("the value of " #VALUE "is " FORMAT "\n" , VALUE);
PRINT("%d", i+3);
代码中的 #VALUE 会预处理器处理为: "VALUE"
int i = 10;
printf("the value of " "i+3" " is " "%d" "\n", i+3);
最后输出结果是
the value of i+3 is 13
在上面代码中我们发现,FORMAT虽然出现在了双引号之间,但它并没有预处理器识别为一个宏参数,并对它替换
这是因为在C语言中,相邻的字符串字面量会被自动连接起来,例如
printf("Hello, " "world!");
这段代码中,两个相邻的字符串在输出时湖北自动连接起来,形成一个完整的字符串
打印结果如下:
Hello, world!
##的作用
###
用于连接两个标记(token),把它们合并成一个新的标记。这个操作也被称为“标记粘贴”(token pasting)
例如,我们定义了一个宏 CONS(a,b), 它的作用是连接两个参数a和b
#define CONS(a,b) a##b
int xy = 10;
printf("%d\n", CONS(x,y));
预处理器会把代码展开为
int xy = 10;
printf("%d\n", xy);
也就是说,CONS(a,b) 被展开为了 xy ,这是因为预处理器把宏中的参数a和b替换为了实际传递给宏的值,然后使用了预处理指令##
把它们连接起来
2.2.4带副作用的参数
x+1;//不带副作用
x++;//带有副作用
如上,当我们定义宏时,应避免使用如 x++ 等参数
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
x = 5;
y = 8;
z = MAX(x++, y++);
printf("x=%d y=%d z=%d\n", x, y, z);
当预处理之后
z = ( (x++) > (y++) ? (x++) : (y++));
当我们使用带有副作用的参数,可能会出现不可预测的错误
2.2.5#undef
#undef 用来取消之前定义的宏
#define PI 3.14159
#undef PI
如我们定义的宏 PI,当我们把它取消时,它便不再有效,之后如果在使用时,编译器会报错,提示我们这个宏未定义
需要注意的是,#underf指令只能取消宏定义,而不能取消其他类型的定义
2.3宏和函数的区别
2.3.1宏
宏的优点
- 宏可以提高代码的执行效率。由于宏是在预处理阶段展开的,所以它不会产生函数调用的开销
- 宏可以用来定义常量和编译时计算的表达式,即宏是类型无关的
宏的缺点
- 宏可能会导致代码难以理解。由于宏是在预处理阶段展开的,所以它可能会产生一些意想不到的副作用
- 宏不具有类型检查功能。当使用宏时,编译器无法检查我们传递给宏的参数是否符合预期,即宏无法调试
- 宏由于类型无关,也就不够严谨
- 宏可能会带来运算符优先级的问题,导致程容易出现错
2.3.2函数
函数的优点
- 函数具有类型检查功能。当调用一个函数时,编译器会检查我们需要传递给函数的参数是否符合预期
- 函数可以提高代码的可读性和可维护性。由于函数具有明确的输入和输出,所以它可以让代码更容易理解和调试
函数的缺点
- 函数调用会产生一定的开销。每次调用一个函数时,都需要在栈上分配空间,保存寄存器的值,然后跳转到函数定义的位置执行代码
2.3.3宏和函数的对比
属性 | #define定义宏 | 函数 |
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码 |
执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号 | 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果 | 函数参数只在传参的时候求值一次,结果更容易控制 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型 | 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是相同的 |
调试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递归 | 宏是不能递归的 | 函数是可以递归的 |
通常我们在定义宏时,会把宏的名称全部大写,函数名则是不全部大写
三、条件编译
在编译一个程序的时候我们可以根据预定义的条件来选择性地编译一部分代码
我们通常使用#if
、#elif
、#else
和#endif
这几个预处理指令来实现
#if defined(_WIN32)
// Windows平台特定的代码
#elif defined(__APPLE__)
// macOS平台特定的代码
#elif defined(__linux__)
// Linux平台特定的代码
#else
// 其他平台的代码
#endif
3.1单分支
#define __DEBUG__ 1
#if __DEBUG__
printf("hehe\n");
#endif
如果_DEBUG_的值为真,则打印 hehe ,否则不执行,此处可以放常量表达式
3.2多分支
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
与单分支的条件一样
3.3判断是否定义
//是否定义
//如果定义的话执行,未定义的话不执行
#if defined(symbol)
#ifdef symbol
//如果未定义的话执行,定义的话不执行
#if !defined(symbol)
#ifndef symbol
3.4嵌套
#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
四、其他预处理指令
- #error:用来在编译时输出一条错误信息并终止编译
#error This is an error message.
这行代码会在编译时输出一条错误信息,并终止编译
#pragma
:用来提供一些特定于编译器的功能
#pragma once
这行代码是一个常见的编译器指令,它用来防止头文件被多次包含
#line
:用来修改编译器记录的当前行号和文件名
#line 100 "foo.c"
这行代码会把编译器记录的当前行号修改为100
,并把当前文件名修改为"foo.c"