目录
1.程序的翻译环境和执行环境
在ANSI (国际标准环境)C的任何一种实现中,存在两个不同的环境
- 翻译环境:源代码被转换为可执行的机器指令
- 执行环境:用于实际执行代码
2.关于编译+链接
2.1编译环境
- 组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)
- 每个目标文件由链接器(linker)捆绑在一起,形成一个单一 而完整的可执行程序
- 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中
2.2编译三阶段
编译三阶段:预编译,编译,汇编
2.2.1预编译
gcc编译器中,预处理选项:gcc -E test.c -o test.i
预处理完成之后就停下来,产生的结果都存放在test.i文件中
预编译阶段主要进行文本操作:
- 头文件的包含
- #define定义的符号替换并删除
- 删除注释
2.2.2编译
gcc编译器中,预处理选项:gcc test.i -s
编译完成之后就停下来,产生的结果都存放在test.s文件中
编译阶段把C语言代码转换成汇编代码
- 语法分析
- 词法分析
- 语义分析
- 符号汇总
符号汇总:主要是对全局符号,如全局变量,函数的汇总
2.2.3汇编
gcc编译器中,预处理选项:gcc -c test.c
汇编完成之后就停下来,产生的结果都存放在test.o文件中
汇编阶段主要进行以下操作:
- 形成符号表(每一个源文件都会生成其对应的符号表)
- 汇编指令转换成二进制指令
2.3链接
链接阶段主要执行以下操作:
- 合并段表
- 符号表的合并和符号表的重定位
3.预处理详解
3.1预定义符号
符号 | 示例值 | 含义 |
__FILE__ | "name.c" | 进行编译的源文件 |
__LINE__ | 25 | 文件当前的行号 |
__DATE__ | "May 5 2023" | 文件被编译的日期 |
__TIME__ | "18:20:30" | 文件被编译的时间 |
__STDC__ | 1 | 如果编译器遵循ANSI C,其值为1,否则未定义 |
VS不遵循ANSI C,gcc遵循ANSI C
#include<stdio.h>
#include<windows.h>
int main()
{
int i = 0;
FILE* pf = fopen("text,txt", "w");
if (pf == NULL)
{
perror("fopen error!\n");
return EXIT_FAILURE;
}
for (i = 0; i < 10; i++)
{
fprintf(pf, "file:%s line=%d date:%s time:%s i=%d\n", __FILE__, __LINE__, __DATE__, __TIME__, i);
}
fclose(pf);
pf = NULL;
return 0;
}
3.2#define
3.2.1#define定义标识符
#define name stuff
有了这条指令后,每当由符号name出现在这条指令后面时,预处理器就会把它替换成stuff
替换文本并不仅限于数值字面值常量,使用#define指令,可以把任何文本替换到程序中
例1:
#define reg register
这个定义为关键字register创建了一个简短的别名
例2:
#define do_forever for(;;)
这个声明用一个更具描述性的符号来代替一种用于实现无限循环的for语句
例3:
#define CASE break;case
这条声明定义了一种在switch语句中的简便用法,它自动地把一个break放在每个case之前,这种用法可以避免break语句的漏用
#define定义标识符举例:
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<windows.h>
int x = 1;
int y = 0;
int z = 0;
#define DEBUG_PRINT printf("file:%s line=%d"\
" x=%d, y=%d, z=%d",\
__FILE__, __LINE__,\
x,y,z)
int main()
{
x *= 2;
y += x;
z = x * y;
DEBUG_PRINT;
return 0;
}
如上例:当我们定义的stuff非常长时,可以将其分成几行书写,除了最后一行,其余每行的末尾都要加上'\',作为续行符
可以发现stuff中利用了"相邻的字符串常量被自动连接为一个字符串"这一特性
在测试一个存在许多涉及一组变量的不同计算过程的程序时,这种#define定义的标识符非常有用,我们可以很容易的插入一条调试语句,打印出变量的当前值
📖Warning:
当我们使用#define定义的标识符时,会在其后面加一个分号,如上例中DEBUG_PRINT;所以在标识符定义时,不应该在stuff后加分号;如果加了,就会产生俩条语句,即一条printf函数后面再加一条空语句,这在有些场合下可能会产生问题
📖Note:
不要滥用#define的这种技巧,如果相同的代码需要出现在程序的几个地方,通常情况下是把它封装成一个函数,本文后续会分析函数和#define宏之间的优缺点
3.2.2#define定义宏
#define机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏或者定义宏,以下是宏的声明方式:
#define name(parameter-list) stuff
其中parameter-list(参数列表)是一个由逗号分隔的符号表,它们可能出现在stuff中。参数列表的左括号必须与name紧邻,如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分
当宏被调用时,名字后面是一个由逗号分隔的值的列表,每个值都与宏定义中的一个参数相对应,整个列表用一对括号包围,当参数出现在程序中时,与每个参数对应的实际值都将被替换到stuff中
📖Example:
#define SQUARE(x) x*x
在上述声明之后,程序中出现SQUARE(5);预处理器就会使用5*5替换SQUARE(5);
📖Warning:
1️⃣SQUARE(5+1);
当程序中出现SQUARE(5+1);时,预处理器的替换结果是:5+1*5+1,最终结果是11
其实我们想要计算的是6*6=36,但这里替换时并不会先计算参数表达式的值
解决方案:在宏定义中适当加上括号,改变运算次序
#define SQUARE(x) (x)*(x)
2️⃣有以下宏定义,计算两数之和
#define DOUBLE(x) (x)*(x)
DOUBLE(3*2)
当程序中出现SQUARE(3*2);时,预处理器的替换结果是:(3*2)+(3*2),最终结果是12
这种情况下结果正确
10*DOUBLE(3)
当程序中出现10*SQUARE(3);时,预处理器的替换结果是:10*3+3,最终结果是33
其实我们想要计算的是10*(3+3)=60,但这里乘法运算会在定义的加法运算之前执行
解决方案:在宏定义中适当加上括号,改变运算次序
#define DOUBLE(x) ((x)*(x))
📖Note:
所有用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时,参数中的操作符或邻近的操作符之间发生不可预料的相互作用
3.2.3#define替换规则
在程序中扩展#define定义的符号和宏时,需要涉及几个步骤:
1️⃣在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号,如果有,则首先替换这些符号
2️⃣替换文本随后被插入到程序原来文本的位置;对于宏,参数名被它们的值所替换
3️⃣最后,再次对结果文件进行扫描,看看是否包含任何由#define定义的符号,如果是,则重复上述处理过程
📖Note:
1️⃣宏参数和#define定义中可以出现其他#define定义的符号;但是对于宏,不能出现递归
#define M 10 #define MAX M//1 #define DOUBLE(x) ((x)*(x)) DOUBLE(M)//2 #define MUL(x) ((M)*(x))//3
上例中:1,2是允许的,3是不被允许的
2️⃣当预处理器搜索#define定义的符号时,字符串常量的内容并不被搜索
#define m 10 ...... char arr[] = "MNP";
上例中,字符串中的M不会被替换
3.2.4#和##
当预处理器搜索#define定义的符号时,字符串常量的内容并不被搜索,那如何把宏参数插入到字符串常量中,可以使用俩个技巧:
1️⃣邻近字符串自动连接的特性
2️⃣使用预处理器把一个宏参数转换为一个字符串;#argument这种结构被预处理器译为"argument"
#define PRINT(FORMAT,VALUE) printf("The value 0f "#VALUE" is " FORMAT"\n",VALUE) ...... int x = 10; PRINT("%d", x+3);
以上代码会产生如下输出:
The value of x+3 is 13
其中#VALUE被预处理器替换成宏的实际参数x+3,FORMAT即被替换成宏的实际参数%d
##结构则执行一种不同的任务:它把位于自己两边的符号连接成一个符号;它允许宏定义从分离的文本片段创建标识符,如下例:
#define CAT(Class,num) Class##num ...... int Class106 = 100; printf("%d\n", CAT(Class,106));
CAT(Class,106)即替换为Class##106,##结构把位于自己两边的符号连接成一个符号,即为Class106,所以这段代码最终打印变量Class106的值100
📖Note:
这样的连接必须产生一个合法的标识符,否则其结果就是未定义的
3.2.5带副作用的宏参数
当宏参数在宏定义中出现的次数超过一次时,如果这个参数具有副作用,那么在使用这个宏时就可能出现危险;副作用就是在表达式求值时出现永久性的效果
📖Example:
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);
MAX(x++,y++);宏替换之后的代码 ((x++) > (y++) ? (x++) : (y++));
比较时,x和y的值都自增加一次,比较完成后较大值还会再自增一次
getchar
3.2.6宏和函数对比
宏非常频繁的用于执行简单的计算,比如在两个表达式中寻找较大(小)者
#define MAX(a,b) ((a) > (b) ? (a) : (b))
为什么不适用函数实现的原因如下
1️⃣用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作需要的时间更多,而宏再预处理阶段完成替换,所以宏比函数在程序的规模和速度方面更胜一筹
2️⃣函数的参数必须声明为特定的类型,所以函数只能在类型适合的表达式上使用;而这个宏可以适用于整型,长整型,浮点型等可以用于比较的类型,宏与类型是无关的
宏的缺点:
1️⃣每次使用宏时,一份宏定义的代码将插入到程序中;除非宏比较短,否则可能大幅度增加程序的长度
2️⃣宏是没法调试的(宏在预处理阶段已经被替换)
属性 | #define宏 | 函数 |
代码长度 | 每次使用,宏代码都被插入到程序中,可能增加程序的长度 | 函数只的定义一次,每次函数调用都调用同一个地方的代码 |
执行速度 | 较快 | 存在函数调用/返回的额外开销 |
操作符 优先级 | 宏参数的求值是在所有周围表达式的上下文环境中,需要适当添加括号,否则邻近操作符的优先级可能会产生不可预料的后果 | 函数参数只在函数调用时求值一次它的结果值传递给函数,表达式的求值结果更容易预测 |
参数求值 | 参数每次用于宏定义时,它们都将重新求值,由于多次求值,具有副作用的参数可能产生不可预料的结果 | 函数参数在被调用之前只求值一次,在函数中多次使用参数并不会导致多个求值过程,参数的副作用不会产生影响 |
参数类型 | 宏与类型无关,只要对参数的操作是合法的,它可以适用于任何参数类型 | 函数的参数与类型有关,如果参数的类型不同,就需要使用不同的函数,即便执行相同的任务 |
3.2.7命名约定
我们已经比较了#define宏和函数不同,但是在调用时,宏的语法和函数的语法是完全一样的,所以如何区分是宏的调用还是函数的调用呢?
📖Note:
一个常见的约定:宏名字全部大写
如果宏使用可能具有副作用的参数,这个约定就提醒我们在使用宏之前先把参数进行拷贝
3.3#undef
下面这条预处理指令用于移除一个宏定义:
#undef name
如果一个现存的名字需要被重新定义,那么首先必须使用#undef移除它的旧定义
3.4命令行定义
许多C编译器提供了一种能力,允许在命令行中定义符号,用于启动编译过程
📖Example:
当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性很有用:假定某个程序中声明了某个长度的数组,如果机器内存有限,这个数组就很小,但在另一个内存充裕的机器上,我们希望数组能够大一些
数组定义:int array[ARRAR_SIZE];
在编译程序时,ARRAR_SIZE的值可以在命令行中指定
在gcc编译器中,-D选项可以完成这项任务:
-Dname //定义符号name,它的值为1
-Dname = stuff //把name的值定义为stuff
3.5条件编译
在编译一个程序时,如果可以翻译或忽略选定的某条语句或某组语句,例如只用于调试程序的语句,它们不应该出现在程序的产品版本中,但是我们可能并不想把这些语句从源代码中物理删除,因为在需要一些维护性修改时,可能需要重新调试这个程序,此时还需要这些语句。因此可以使用条件编译,选择代码的一部分是被正常编译还是完全忽略。
条件编译的基本结构
#if constant-expression statements #endif
📖Note:
其中,constant-expression(常量表达式)由预处理器进行求值,如果它的值是非零值(真),那么statement部分就被正常编译,否则预处理器就静默地删除它们
常量表达式:要么是字面值常量,要么是一个由#define定义的符号,如果变量在执行期之前无法获得它们的值,那么它们出现在常量表达式中就是非法的
例如有以下调试代码:
#if DEBUG
printf("x=%d y=%d\n", x,y);
#endif
当我们想编译这段代码时,使用#define把符号DEBUG的值定义为1即可;如果要忽略,则定义DEBUG的值为0。无论哪种情况,这段代码都可以保留在源文件中
多个分支的条件编译
条件编译的另一个用途是在编译时选择不同的代码部分,为了支持这个功能,#if指令还具有可选的#elif和#else子句,语法如下:
#if constant-expression statements #elif constant-expression other statements ... ... #else other statements #endif
#elif子句出现的次数可以不限。与if-else if-else 语句类似,每个constant-expression(常量表达式)只有当前面所有常量表达式额值都为假使才会被编译,#else子句中的语句只有当前面所有常量表达式的值都为假时编译,其他情况下都会被忽略。
测试符号是否被定义:
在条件编译中测试一个符号是否被定义更为方便,因为如果程序并不需要控制编译的符号所控制的特性,就不需要定义符号
#if defined(symbol) #ifdef symbol #if !defined(symbol) #ifndef symbol
每对定义的两条语句是等价的,但#if形式功能更强,因为常量表达式可能包含额外的条件,如下例:
#if X>0 || defined( ABC ) && defined( BCD )
嵌套指令:
以上指令都可以嵌套到一个指令内部:
#if defined( OS_UNIX ) #ifdef OPTION1 unix_version_of_option1(); #endif #ifdef OPTION2 unix_version_of_option2(); #endif #elif defined( OS_MSDOS ) #ifdef OPTION3 unix_version_of_option3(); #endif #endif
操作系统的选择将决定不同的选项可以使用哪些方案
3.6文件包含
在预处理阶段,会进行头文件的包含操作,#include指令使另一个文件的内容被编译,就像它实际出现于#include指令出现的位置一样。这种替换执行的方式很简单:预处理器删除这条指令,并用包含文件的内容取而代之。这样,一个头文件如果被包含到10个源文件中,它实际被编译了10次
📖Note:
这个事实意味着使用#include文件时会涉及一些开销,但这种开销实际上并不大。如果两个源文件都需要同一组声明,把这些声明复制到每个源文件中所花费的编译时间和把这些声明放入一个头文件,然后再用#include指令把他包含于每个源文件所花费的编译时间相差不大。同时,这个开销只是在程序编译时才存在,对运行时效率并无影响。
程序设计和模块化的原则也支持这种方法,只把必要的声明包含于一个头文件,这样文件中的语句就不会意外地访问应该属于私有的函数和变量。同时,这种方法使我们也不需要在数百行无关代码中寻找所需要的那组声明,更容易进行维护。
函数库文件包含
编译器支持两种不同类型的#include包含:函数库文件和本地文件
函数库头文件包含语法:
#include <filename>
filename不存在任何限制,但根据约定,标准文件以一个.h后缀结尾
本地文件包含
本地头文件包含语法:
#include "filename"
可以在所有的#include语句中使用双引号而不是尖括号,但由于双引号和尖括号包含的查找策略不同,尖括号包含直接去库目录下查找,而双引号先去源文件所在路径下查找,再去库目录下查找,所以若使用这种方法,有些编译器在查找函数库头文件时可能会浪费少许时间
避免头文件的多重包含
多重包含在绝大多数情况下出现于大型程序中,它往往需要使用很多头文件,因此这种情况不易被发现,解决这个问题可以使用条件编译:
#ifdef __TEST_H #define __TEST_H 1 ... ... #endif
当头文件第一次被包含时,它被正常处理,符号__TEST_H被定义为1。如果头文件被再次包含,通过条件编译,它的所有内容被忽略。符号__TEST_H按照被包含文件的文件名进行取名,以避免由于其他头文件使用相同的符号而引起冲突
📖Note:
#pragma once
#pragma once也可以防止头文件被多次重复包含
offsetof宏的实现
offsetof是一个函数,可以计算结构体中某变量相对于首地址的偏移量
以下我们一个使用宏实现offsetof函数的功能
对于一个如下结构
struct S { char c1; int i; char c2; };
其存储结构如下:
变量c1的偏移量为0,变量i的偏移量为4,变量c2的偏移量为8
对于这个结构,使用时其开辟的内存块是连续的,一个变量相对于首地址的偏移量其实就是其地址减去首地址。
定义一个宏OFFSETOF(type,m_name),其参数为type(数据类型),m_name(成员变量名)
注意:宏的参数可以是类型
#include<stdio.h>
#define OFFSETOF(type,m_name) (size_t)(&((type*)0)->m_name)
struct S
{
char c1;
int i;
char c2;
};
int main()
{
int i = 0;
printf("%d\n", OFFSETOF(struct S, c1));
printf("%d\n", OFFSETOF(struct S, i));
printf("%d\n", OFFSETOF(struct S, c2));
return 0;
}