代码从源程序(.c)到可执行文件(.exe)中间,经过了编译和链接的过程,所依赖的环境称为翻译环境,可执行文件运行所依赖的环境称为运行环境。
翻译环境
代码的源程序是文本文件。可执行文件中放的是二进制的信息,是二进制文件。
一个工程中可能存在多个C的源程序,任何一个源文件都会单独作为一个单元被编译器处理,生成各自对应的目标文件。(如果在一个工程中有test1.txt和test2.txt,编译器处理后会生成test1.obj和test2.obj。)所有目标文件链接在一起,再加上链接库,经过链接器的处理,生成可执行程序。 编译器处理的过程叫做编译,链接器处理的过程叫做链接。
在Linux下使用gcc -E对源文件test.c进行编译的过程是预编译/预处理过程,执行的是文本操作,生成test.i文件。 在这个过程中,会发生头文件的包含(文本复制)、注释删除(文本删除)、完成预处理指令如#define的替换(文本替换)等。 编译过程中,使用gcc -S处理预处理过程中生成的test.i,生成test.s文件。 test.s中存放的都是汇编指令。即编译过程是把C语言代码翻译成了汇编代码。在这个过程中,会进行语法分析、词法分析(编译原理)、语义分析、符号汇总(汇总函数名、全局变量等,汇编和链接器上使用)。 汇编过程中,使用gcc -c处理编译过程中生成的test.s,生成test.o文件,即Windows环境下的.obj文件。 gcc -c将汇编代码转换成了二进制代码(二进制指令)。汇编过程会形成符号表,符号表中既保存符号名,又保存符号对应的地址。 汇编形成的.o文件链接后形成可执行文件.exe。
在链接器内的动作是合并段表和符号表的合并及重定位。 链接器对生成的.o文件分成几个段,每一个段的都有固定的格式(elf文件格式),只是不同的.o文件的每个段内信息不一样。合并段表是将对应段上的数据合并在一起,如下:
ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且它们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。
合并段表后生成的可执行程序.exe的文件格式也是elf格式。 每一个.o文件都会生成一个符号表,链接器需要将多个符号表合并为一个表。当符号表中的符号名冲突时,保留有效的符号地址。 如下: 上图中extern int Add(int x, int y)只是在main函数中声明Add,并不是定义,所以test.o生成符号表中的Add的地址是无意义的,在符号表合并时被丢弃,即对符号Add的地址进行了重定位。
当源文件Add.c中的内容被注释,汇编过程形成的符号表中无数据,在链接器中与test.c文件的符号表合并后,符号表不变,符号表中Add的地址仍为test.c中声明Add产生的无意义地址,当test.c文件中调用Add函数时,在函数表中Add对应的地址处找不到Add函数,编译器会报错LNK2019:无法解析的外部符号_Add。 ::: tip 在链接期间合并符号表并在符号表中对应地址查看符号时出现错误,叫做链接错误。 :::
运行环境
程序运行: 1.程序必须载入内存中。在有操作系统的环境中,一般由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。 2.程序的执行开始,调用main函数。 3.开始执行程序代码。程序使用运行时堆栈(stack,为函数调用而开辟的空间)存储函数的局部变量和返回地址。程序同时也可以使用静态( static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。 4.终止程序。正常终止main函数,也有可能是意外终止。
预编译
预定义符号: ::: warning #define是自定义,不是预定义。 :::
int main()
{
printf("%s\n", __FILE__); //__FILE__表示当前文件的名称+绝对路径
printf("%d\n", __LINE__); //__LINE__表示代码所在的行号
printf("%s\n", __DATE__); //__DATE__表示当前的日期
printf("%s\n", __TIME__); //__TIME__表示当前的时间
return 0;
}
C:\Users\SUPER QiuYu\Desktop\code\2022\Project_7.29\test.c
40
Jul 29 2022
13:24:49
预定义符号的应用:
int main()
{
int arr[10] = { 0 };
int i = 0;
FILE* pf = fopen("log.txt", "w");
for (i = 0; i < 10; i++)
{
arr[i] = i;
fprintf(pf, "file:%s line:%d date:%s time:%s i:%d\n",
__FILE__, __LINE__, __DATE__, __TIME__, i);
}
fclose(pf);
pf = NULL;
for (i = 0; i < 10; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
0 1 2 3 4 5 6 7 8 9
预处理指令
#开头的指令都是预处理指令。 如#define、#include、#pragma、#if、#endif、#ifdef等。 #define可以定义标识符,也可以定义宏。
#define MAX 100
#define STR "hello"
函数中由#define定义的符号会在预编译阶段被符号内容替换掉。 符号内容可以是数字、字符串、代码等。
宏的声明方式:#define name(parament-list) stuff其中的parament-1ist是一个由逗号隔开的符号表(参数列表),可能出现在stuff中。 注意∶参数列表的左括号必须与name紧邻,中间不能有空格,如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分(即把name当成是符号,后面部分当作是符号内容)。 宏举例:
#define SQUARE(x) x*x
int main()
{
int ret = SQUARE(5);
printf("%d\n", ret);
return 0;
}
25
上述代码中,第4行代码相当于int ret = SQUARE(x);x=55; 。 ::: tip *宏的参数不是传参,而是替换。** :::
#define SQUARE(x) x*x
int main()
{
int ret = SQUARE(5 + 1);
printf("%d\n", ret);
return 0;
}
11
上述代码将x替换成5+1参与计算,5+15+1混合运算,结果为11。 *如果宏的参数是数值表达式,表达式中的操作符优先级和替换进去的表达式的某些操作符优先级不相符时,可能会导致表达式的计算顺序不是所期望的,就需要给宏替换进去的内容分别加上括号,对宏定义时的表达式整体也还要加括号。**
#define替换规则 在程序中扩展#define定义符号和宏时,需要涉及几个步骤:
- 在调用宏时,首先对参数进行检查,看是否包含任何由#define定义的符号。如果是,它们首先被替换。
- 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#defin重复上述处理过程。
::: warning 1.宏参数和#define定义中可以出现其他#define定义的变量。但是对于宏,不能出现递归。 2.当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。如printf("MAX=%d\n", MAX);等号前面的MAX不被替换。 :::
#和##
#可以把一个宏的参数直接转换成对应字符串插入到字符串中。
::: tip C语言会把放在一起的多个字符串默认当成一个字符串。 如下:
int main()
{
printf("Hello World\n");
printf("Hello " "World\n");
printf("He""llo W""orld\n");
return 0;
}
Hello World
Hello World
Hello World
::: #使用场景:
#define PRINT(X) printf("the value of "#X" is %d\n",X)
int main()
{
int a = 10;
int b = 20;
PRINT(a);
PRINT(b);
return 0;
}
the value of a is 10
the value of b is 20
上述代码中,在#define中使用“#”实现了替换字符串中内容的功能,这是函数无法实现的功能。 #a被替换成"a",#b被替换成"b",实际替换后的效果如下:
printf("the value of ""a"" is %d\n");
printf("the value of ""b"" is %d\n");
##可以把位于它两边的符号合成成一个符号
#define CAR(X,Y) X##Y
int main()
{
int HiCar = 666;
printf("%d\n", CAR(Hi,Car));
return 0;
}
666
上述代码第5行,在程序预编译过程中,程序被替换成printf("%d\n", Hi##Car),而##的作用是将两边的字符串合并成一个,即printf("%d\n", HiCar)。
带有副作用的宏参数
如自增自减的运算,不仅可以改变运算结果,还有改变自身的值的副作用。
#define MAX(X,Y) ((X)>(Y))?(X):(Y)
int main()
{
int a = 10;
int b = 11;
int max = MAX(a++, b++);
printf("%d\n", a);
printf("%d\n", b);
printf("%d\n", max);
return 0;
}
11
13
12
上述代码中第6行替换后相当于int max=MAX((a++)>(b++))?(a++):(b++),计算过程是a和b先比较,a>b不成立,a++变为11,b++变为12,执行冒号后的b++。b后置++,先把b赋给max,max变为12,b++变为13。 ::: warning 上述代码结果不在意料之中的原因是宏的参数有副作用。副作用不会显现在参数部分,参数替换后有副作用的参数在宏内多次出现,导致程序计算结果出乎意料之外。 需要着重小心使用带有副作用的宏参数,如能避免,尽量避免使用。 :::
::: tip 宏的参数是直接替换进去的,不是算好后替换进去的。 如下:
#define MAX(X,Y)
int main()
{
int max = MAX(a+1, b);
return 0;
}
上述代码中,是将a+1的表达式替换掉X,不是将a+1的结果替换成X。 :::
宏和函数
宏和函数在都可以完成某一个功能时,如下判断最大值:
int MAX(int x, int y)
{
return (x > y ? x : y);
}
#define MAX(x,y) ((x) > (y) ? (x) : (y))
int main()
{
int a = 10;
int b = 20;
int ret = MAX(a, b);
printf("%d\n", ret);
MAX(a, b);
printf("%d\n", ret);
return 0;
}
20
20
上述代码中,比较的两个数字都是整型的,如果换成浮点型,比较函数需要修改形参的类型和返回类型,相当于重新写一个比较函数,而宏的方法可以继续用原来的宏而无需修改。另外,函数在调用时,也会有调用返回开销。
::: tip 宏相对于函数的优势:
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
- 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于>来比较的类型。宏处理参数时不关心类型。
宏相对于函数的劣势:
- 每次使用宏的时候,一份宏定义的代码将插入到程序中(即完成替换)。除非宏比较短,否则可能大幅度增加程序的长度。
- 宏不能调试。(调试是针对可执行文件进行的,宏的类型符号替换是在编译阶段的预编译时完成的。)
- 宏不关心类型,就不够严谨。(不关心类型即不做类型检查。)
- 宏可能会带来运算符优先级的问题,导致运算结果出错。
- 宏参数的副作用可能会影响代码结果。(宏是替换,并不做运算;函数传参会传递运算结果。) :::
宏可以传递类型,函数不能传递类型,只能传递值,如下:
#define SIZEOF(type) sizeof(type)
int main()
{
int ret = SIZEOF(int);
printf("%d\n", ret);
return 0;
}
4
::: tip 宏和函数的对比 :::
函数和宏的语法比较相似,语法习惯上为做区分,一般宏的名全部大写,函数名不全部大写。
#undef指令用于移除一个宏定义。
命令行定义可以在编译时修改源文件中某一个参数。 在Linux系统下,命令:gcc 文件名 命令行参数-D 需要设置的值
条件编译可以有选择性的注释掉源文件中的某部分代码。
int main()
{
int arr[10] = { 0,1,2,3,4,5,6,7,8,9 };
int i = 0;
for (i = 0; i < 10; i++)
{
arr[i] = 0;
#ifdef DEBUG
printf("%d ", arr[i]);
#endif
}
return 0;
}
如上述代码,如果DEBUG被定义,#ifdef为真,第9行语句就参与编译,否则不参与编译,在预编译阶段就被会删除。 定义DEBUG的语句:#define DEBUG。
文件包含
头文件包含的两种方式:
- 本地文件包含:使用#include "本地文件名"。
- 库文件包含:使用#include <库文件名>。
本地文件名的查找策略是∶先在源文件所在目录下查找。如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。 库文件的查找策略是:直接去标准路径下查找,如果找不到,则提示编译错误。 ::: tip linux环境的标准头文件的路径是: /usr/include VS环境的标准头文件的路径∶ C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.32.31326\include ::: 库函数的引用也可以使用双引号,但是查找效率会下降,多了搜索源文件所在目录的步骤。
嵌套文件包含的使用场景: 头文件可能会存在嵌套文件包含:在一个工程中,一个头文件可能会被重复包含,造成代码冗余。 解决方法:条件编译。 如下:
#ifndef __TEST_H__ //头文件名
#define __TEST_H__ //头文件名
//头文件内容
#endif
在第一次引用该头文件时,没有定义过“TEST_H”,#ifdef为真,#define定义“TEST_H”,头文件内容参与编译,即包含成功,然后结束。第二次及以后再引用该头文件时,“TEST_H”已经被定义过,#ifdef为假,头文件内容不参与编译,直接跳到#endif结束。防止头文件被多次包含。 ::: tip 较新的写法是在头文件最开始添加#pragma once,保证头文件只被编译一次。 如下:
#pragma once
//头文件内容
:::
其他预处理指令还有:#error、#pragma、#line等。