程序的环境和预处理
文章目录
程序的翻译环境和执行环境
在ANSI C的任何一种实现中,都存在两个不同的环境:
- 第一种是翻译环境,在这个环境中源代码被转换为可执行指令,test.c->test.exe依赖于翻译环境
- 第二种执行环境,它用于实际执行代码,test.exe->运行结果依赖于执行环境
编译+链接
翻译环境
翻译环境其实进行的就是编译+链接的工作
我们在写程序的时候,可能会有很多的.c文件,假如test.c、contact.c、common.c,每个.c文件都会各自单独经过编译器处理,生成目标文件:
目标文件一起与链接库经过链接器,链接器会把目标文件和链接库链接在一起然后生成可执行程序。
链接库是什么呢?
Libraries这是库,里面的.LIB文件叫静态库,这些静态库里就包含了printf函数的相关信息,如果我们的代码使用到了printf函数,那么链接库就是链接这些静态库进去的。
我们刚刚大体上说了一下我们的翻译环境的编译和链接。
test.c源文件到test.exe可执行程序需要经过编译(编译器 cl.exe)链接(链接器 link.exe),那么是怎么编译的呢?
==注意:==编译器是,链接器是
在我们的编译器安装路径下都有。
那么编译的细节是什么呢?,它又分为几部分工作呢?
编译也分为三个阶段
1.预编译(预处理)
2.编译
3.汇编
预处理阶段
- 完成了头文件的包含#include的处理
我们以一个简单的代码为例:
#include<stdio.h>
int g_val = 2021;
int Add(int x,int y)
{
return x+y;
}
int main()
{
int a=10;
int b=20;
int ret=Add(a,b);
printf("%d\n",ret);
return 0;
}
在预处理阶段,会将#include包含的头文件拷贝放在了代码最前面,在这里其实将stdio.h头文件的内容拷贝放在我们的代码最前面了
- 完成了#define定义的符号和宏的替换
#include<stdio.h>
int g_val = 2021;
#define M 100
#define MAX(x,y) ((x)>(y)?(x):(y))
int Add(int x,int y)
{
return x+y;
}
int main()
{
int num=M;
int m=MAX(100,200);
int a=10;
int b=20;
int ret=Add(a,b);
printf("%d\n",ret);
return 0;
}
在预处理之后,上面main函数中的代码会变为:
int main()
{
int num=100;
int m=((100)>(200)?(100):(200));
int a=10;
int b=20;
int ret=Add(a,b);
printf("%d\n",ret);
return 0;
}
在预处理阶段,完成了#define定义的符号和宏的替换
- 注释的删除
int main()
{
printf("haha\n");
//这里打印haha
return 0;
}
上面的代码在预处理阶段会变成:
int main()
{
printf("haha\n");
return 0;
}
在预处理阶段,完成了注释的删除,所以我们写多少注释都不会有影响。
预处理阶段的这些操作也都是一些文本上的操作。下面我们看编译阶段:
编译阶段
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一
-
语法分析
-
词法分析
-
语义分析
-
符号汇总
比如我们有这两个源文件:test.c、add.c,编译阶段,符号汇总将test.c和add.c中的符号汇总,汇总全局的符号。
比如我们写了下面两个源文件时:
test.c
extern int ADD(int x,int y);
int main()
{
int a=10;
int b=20;
int ret= ADD(a,b);
return 0;
}
add.c
int ADD(int x,int y)
{
return x+y;
}
add.c和test.c要单独经过编译器处理,在编译阶段符号汇总时,他会把test.c中的符号(Add、main)汇总,会把add.c中的符号(Add)汇总(只汇总全局的符号)
汇编阶段
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了,“汇编”这个名字也来源于此。
- 汇编代码转换成机器指令
这个阶段生成目标文件test.obj,这个文件是有格式的,这个文件是ELF格式,它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用种格式存储。从广义上看,目标文件与可执行文件的格式其实几乎是一样的,所以我们可以广义地将目标文件与可执行文件看成是一种类型的文件 在Windows下,我们可以统称它们为PE-COFF文件格式。在Linux下,我们可以将它们统称为ELF文件。这类文件包含了代码和数据,可以被用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类。目标文件中还包括了链接时所须要的一些信息,比如符号表、调试信息、字符串等。一般目标文件将这些信息按不同的属性,以“节”( Section)的形式存储,有时候也叫“段”(Segment),在一般情况下,它们都表示一个一定长度的区域,基本上不加以区别。
- 生成符号表
#include<stdio.h>
int g_val = 2021;
int Add(int x,int y)
{
return x+y;
}
int main()
{
int a=10;
int b=20;
int ret=Add(a,b);
printf("%d\n",ret);
return 0;
}
在编译阶段时,进行符号汇总,会把.c文件中的全局的这些符号(在这段代码中汇总的符号是:g_val、Add、main、printf)(函数名、全局变量等)全部都会汇总起来,然后生成一个符号表
比如我们写了下面两个源文件时:
test.c
extern int ADD(int x,int y);
int main()
{
int a=10;
int b=20;
int ret= ADD(a,b);
return 0;
}
add.c
int ADD(int x,int y)
{
return x+y;
}
add.c和test.c要单独经过编译器处理,在编译阶段符号汇总时,他会把test.c中的符号(Add、main)汇总,会把add.c中的符号(Add)汇总(只汇总全局的符号)
之后在汇编阶段时生成符号表,为编译阶段汇总的全局符号生成符号表,是什么意思呢?请看下面解释:
链接阶段
把多个目标文件和链接库进行链接
- 符号表的合并和重定位
将汇编阶段生成的符号表进行合并,将无效的符号地址删除,有效的符号地址保留
- 合并段表
我们的目标文件的格式是ELF,而ELF的格式是我们上面讲过的那个段表,它存储目标文件的一些信息,而我们这个阶段就需要将多个这样的目标文件的段表合并,将重复的信息合并在一起
最后生成可执行程序
通过可执行程序调用我们的函数时,就可以通过符号和地址找对应的函数,所以链接的阶段要把我们代码里的函数要找到。
运行环境(执行环境)
程序执行的过程
- 程序必须载入内存中,在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由程序员手工进行,也可能是通过可执行代码置入只读内存来完成。
- 程序的执行开始,调用main函数
- 开始执行程序代码,这时程序将使用一个运行的堆栈,存储函数的局部变量和返回地址。程序同时也可以使用静态内存,存储于静态内存中的变量在程序的整个执行过程一直保留它们的值。
给函数分配的这块空间叫运行时堆栈。
- 终止程序。可能是正常终止main函数,也有可能是意外终止。
预处理详解
编译一个C程序需要涉及很多步骤。其中第一个步骤被称为预处理阶段。C预处理器在源代码编译之前对其进行一些文本性质的操作,它的主要任务包括删除注释、插入被#include指令包含的文件的内容、定义和替换那些被#define指令定义的符号以及确定代码的部分内容是否应该根据一些条件编译指令进行编译。
预定义符号
预定义符号都是语言内置的。
_LINE_:在源代码中插入当前源代码行号;
_FILE_:在源文件中插入当前源文件名;
_DATE_:在源文件中插入当前的编译日期;
_TIME_:在源文件中插入当前编译时间;
_STDC_:当要求程序严格遵循ANSI C标准时该标识被赋值为1;
int main()
{
printf("%s\n",__FILE__);
return 0;
}
会打印什么呢?
它会打印当前的源文件名
int main()
{
printf("%s\n",__LINE__);
return 0;
}
它会打印我们的代码在多少行
int main()
{
printf("%s\n",__DATE_);
return 0;
}
它会打印我们当前的日期
int main()
{
printf("%s\n",__TIME__);
return 0;
}
打印我们当前的时间
int main()
{
printf("%s\n",_FUNCTION_);
return 0;
}
查看位于哪个函数里
那么说这么多预定义符号有什么用呢?在程序庞大时,会有用处
int main()
{
int i = 0;
FILE* pf = fopen("log.txt", "a+");//追加
if (pf == NULL)
{
perror("fopen");
return 1;
}
for (i = 0; i < 10; i++)
{
fprintf(pf, "%s %d %s %d\n", __FILE__, __LINE__, __DATE__, __TIME__);
}
fclose(pf);
pf = NULL;
return 0;
}
当我们在文件中保存这些信息,在代码数量庞大时,我们在确认调试输出的的来源方面很有用处。程序在执行的过程中我们可以记录这些信息
#define
#define定义标识符
语法:
#define name stuff
有了这个指令,每当有符号name出现在这条指令后面时,预处理就会把它替换成stuff
例如:
#define M 100
int main()
{
int m=M;
printf("%d\n",m);
return 0;
}
替换文本并不仅限于数值字面常量。使用define指令,你可以把任何的文本替换到程序中。
例如:
#define reg register
int main()
{
reg int num=0;
return 0;
}
reg int num = 0;这句话在预处理阶段被替换成register int num = 0;
#define do_forever for(;;)
int main()
{
do_forever;
return 0;
}
这个声明用一个具有描述性的符号来代替一种用于实现无限循环的for语句类型。
#define CASE break;case
int main()
{
int n=0;
switch(n)
{
case 1:
CASE 2:
CASE 3:
}
return 0;
}
这个声明定义了一种记法,在switch中使用,它自动的把一个break放在每个case之前。
#define定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)
宏的声明方式:
#define name(paramen-list) stuff
其中paramen-list是一个由逗号隔开的符号表,它们可能出现在stuff中
注意:
- 参数列表的左括号必须与name紧邻,如果两者之间有任何空白的存在,参数列表就会被解释为stuff的一部分。
当宏被调用时,名字后面是一个由逗号分隔的值的列表,每个值都与宏定义中的参数相对应,整个列表用一对括号包围。当参数出现在程序中,与每个参数对应的值都将被替换到stuff中
#define SQUARE(X) X*X
int main()
{
printf("%d\n",SQUARE(3));
return 0;
}
实际上在预处理阶段,上面printf代码会被替换成:
printf("%d\n",3*3);
然后我们再看下面这个代码:
#define SQUARE(X) X*X
int main()
{
printf("%d\n",SQUARE(3+1));//7
return 0;
}
可能你猛地一看,以为答案是16,实际上并不是!!!
宏是直接替换进去,不会给你计算的,所以此时X是3+1,将参数替换进文本实际上得到:
printf("%d\n",3+1*3+1);//7
所以结果为7
现在我们找到了问题所在:由替换产生的表达式并没有按照预想的次序进行求值。我们在宏定义中加上两个括号,问题就被很好的解决了:
#define SQUARE(X) (X)*(X)
#define SQUARE(X) X*X
int main()
{
printf("%d\n",SQUARE(3+1));
return 0;
}
在前面的例子里,现在将printf语句里面的SQUARE(3+1)进行替换:
printf("%d\n",(3+1)*(3+1));//16
我们得到了预期的结果。
这里有另外一个宏定义:
#define DOUBLE(X) (X)+(X)
定义中使用了括号,用于避免前面的问题。但是使用这个宏会出现另外一个不同的错误。下面这段代码会打印什么呢?
#define DOUBLE(X) (X)+(X)
int main()
{
printf("%d\n",10*DOUBLE(5));
return 0;
}
看上去他好像要打印100,但事实上它打印的是55,我们再一次通过观察宏替换产生的文本,我们可以发现问题所在:
printf("%d\n",10*(5)+(5));//55
这个问题我们很容易纠正,我们只要在两边加上一对括号就可以了
#define DOUBLE(X) ((X)+(X))
注意:
所有用于对数值表达式求值的宏定义都应该用这种方式加上括号,避免在使用宏时,由于参数中的操作符或邻近的操作符之间有不可预料的作用。
所以第一个宏定义我们应该这样写:
#define SQUARE(X) ((X)*(X))
int main()
{
printf("%d\n",SQUARE(3+1));//7
return 0;
}
写宏时,括号很重要,不要吝啬它
#define替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤:
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先
被替换。
#define M 100
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
int max = MAX(101,M);
return 0;
}
首先会看调用宏,对参数进行检查,看是不是包含#define定义的符号,上面代码则首先被替换成 int max = MAX(101,100);
-
替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。
-
最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上
述处理过程。
注意:
- 宏参数和#define 定义中可以出现其他#define定义的变量。但是对于宏,不能出现递归。
#define M 100
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
int max = MAX(10,M);
return 0;
}
- 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
#define M 100
int main()
{
printf("M=%d\n",M);
return 0;
}
printf字符串内容中的M不会被检查
#和##
#
把参数插入到字符串中去,把一个宏参数变成对应的字符串
首先我们看下面的讲解:
int main()
{
printf("hello world\n");
printf("hello" "world\n");
return 0;
}
首先,把一个字符串分成两个字符串打印是一模一样的。
int main()
{
int a=10;
int b=20;
int c=30;
return 0;
}
如果我们想要分别对于a、b、c变量打印这样的一句话the value of a is 10、the value of b is 20、the value of c is 30,很多人脑子里可能第一时间想到的是那就设计一个函数吧,可是函数真的能完成这件事吗?
void print()
{
printf("the value of a is %d\n",)
}
int main()
{
int a=10;
int b=20;
int c=30;
print(a);
print(b);
print(c);
return 0;
}
我们通过这样设计函数发现是不对的,我们函数内部printf中的字符abc该如何打印弄成通用的呢?我给你函数传给a,你打印a,传b,打印b;传c,打印c,事实上,函数是做不到这件事情的。
所以就到了重头戏==#==
#的作用是把参数插入到字符串中去,把一个宏参数变成对应的字符串
我们进行一个宏定义声明:
#define PRINT(X) printf("the value of "#X" is %d",X);
#X的作用就是,编译器在预处理阶段,若宏定义中的参数前面有#,我们不进行替换,我们把调用宏的宏参数名字变成对应的字符串
我们刚开始就说过**把一个字符串分成两个字符串打印是一模一样的。**所以在#X前后加上双引号,以便#X不被包含进字符串,能够识别到#X。
#define PRINT(X) printf("the value of "#X" is %d\n",X);
int main()
{
int a = 10;
//the value of a is 10
PRINT(a);
//相当于替换为:printf("the value of ""a"" is %d",a);
int b = 10;
//the value of b is 20
PRINT(b);
//相当于替换为:printf("the value of ""b"" is %d",b);
int c = 30;
//the value of c is 30
PRINT(c);
//相当于替换为:printf("the value of ""c"" is %d",c);
//函数不可以
return 0;
}
这就是#的基本用法,下面我们看##。
##
##可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符。
例如:
#define CAT(X,Y) X##Yint main(){ int helloworld=100; printf("%d\n",CAT(hello,world)); //替换为:printf("%d\n",hello##world); //但是##可以把位于它两边的符号合成一个符号。 //相当于替换为: printf("%d\n",helloworld);}
将hello和world代入宏定义中为:hello##world,但是##可以把位于它两边的符号合成一个符号。
相当于最后替换为: printf("%d\n",helloworld);
带副作用的宏参数
//1.int a=1;int b=a+1;//b=2,a=1//2.int b=++a;//a=2.b=2//它也把a改了//这种情况就说++a是有副作用的
我们想给b赋值a+1,上面两者做法都可以将b变为a+1,但是第二种在改变好b时,把a的值也改变了,这种情况我们就说++a是有副作用的
宏参数也不例外:
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
int a=5;
int b=8;
int m=MAX(a++,b++);
//a++和b++会直接替换的:int m=((a++)>(b++)?(a++):(b++));
printf("a = %d\n",a);
printf("b = %d\n",b);
printf("m = %d\n",m);
return 0;
}
你们可能看了这个代码,脑海出有很多答案,实际上我们将a++和b++之间代入替换为:
int m=((a++)>(b++)?(a++):(b++));
首先,它们都是后置++,先使用后++,5>8?;在执行5>8时,此时a变为了6,b变为了9,5不大于8,所以选择执行b++,先使用后++,所以m的值为9,别忘了现在的b值变为了10。
宏和函数的对比
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int Max(int x,int y)
{
return x > y ? x : y;
}
int main()
{
int max = MAX(101,100);
return 0;
}
在实现上面这一个功能的时候,你是会选择函数呢还是宏呢?
给我选择的话,我会选择宏,为什么呢?
原因一:
我们先来看看如果使用函数是怎么工作的:
我们使用函数,函数的调用工作和返回工作就那么多,用于调用和从函数返回的代码很可能比实际执行这个小型计算工作的代码更大,所以使用宏比使用函数在程序的规模和速度方面都更胜一筹。
原因二:
更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之上面这个宏可以适用于整形、长整型、浮点型等,这些都可以用于>来比较的类型。宏是类型无关的。
当然和宏相比函数也有劣势的地方:
-
每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序
的长度。 -
宏是没法调试的。
-
宏由于类型无关,也就不够严谨。
-
宏可能会带来运算符优先级的问题,导致程容易出现错。
还有一些任务函数根本没法做到,比如,宏的参数可以出现类型:
#define MALLOC(num,type) (type*)malloc(num*sizeof(type))int main(){ //malloc(10*sizeof(int));//我想着这样麻烦,我这样写 int *p = MALLOC(10,int); //int *p = (int*)malloc(10*sizeof(int)) return 0;}
*int p = MALLOC(10,int);这个语句被预处理器转换为int *p = (int*)malloc(10*sizeof(int));
命名约定
#define宏的行为和真正的函数相比存在一些不同的地方,由于这些不同,让我们知道一个标识符究竟是一个宏还是一个函数是非常重要的,函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。
我们的书写习惯应该是:
- 把宏名全部大写
- 函数名不要全部大写
#undef
这条指令用于移除一个宏定义。
如果一个现存的名字需要被重新定义,那么它的旧定义首先用#undef移除
#define M 100
int main()
{
int a=M;
//可以取消宏定义的符号
#undef M
printf("%d\n",M);
return 0;
}
命令行定义
一些C编译器有一种能力,允许在命令行中定义符号,用于启动编译过程。
当我们根据同一个源文件编译一个程序的不同版本时,这个特性是很有用的,假如一个程序声明了某种长度的数组,某个机器的内存很有限,这个数组必须很小,但在另一个内存比较充裕的机器上,希望这个数组能够大一些,我们就可以以下面方式声明:
#include<stdio.h>
int main()
{
int arr[M]={0};
int i=0;
for(i=0;i<M;i++)
{
arr[i]=i;
}
for(i=0;i<M;i++)
{
printf("%d ",arr[i]);
}
return 0;
}
在编译程序时,M的值可以在命令行中指定。
在linux操作系统中,-D可以完成这个任务,我们可以这样指定:-D M=10
条件编译
满足条件编译,不满足条件,则不编译,使用条件编译你可以选择代码的一部分是被正常编译还是完全忽略。
常见的条件编译指令
- ```c
#if 常量表达式
//…
#endif
//常量表达式由预处理器求值。
例如:
#define PRINT
int main()
{
#if 1//常量
printf("hehe\n");
#endif
return 0;
}
常量表达式为真,则进行编译,为假,则忽略。
- 多分支的条件编译
在编译时选择不同部分的代码,为了支持该功能,#if指令还具有可选的#elif和#else子句。
int main()
{
#if 1==3
printf("haha\n");
#elif 1==2
printf("hehe\n");
#else
printf("heihei\n");
#endif
return 0;
}
- 判断是否被定义
判断一个符号是否已被定义,#ifdef…#endif语句和#if defind()…#endif语句
int main()
{
//如果TEST定义了,下面参与编译
//1.
#ifdef TEST
printf("test\n");
#endif
//2.
#if defined(TEST)
printf("test2\n");
#endif
return 0;
}
- 判断是否没有定义
相反的,还有判断是否没有被定义的语句,#ifndef…#endif语句和#if !defined()…#endif语句
int main()
{
//如果HEHE不定义,下面参与编译
//3.
#ifndef HEHE
printf("hehe\n");
#endif
//4.
#if !defined(HEHE)
printf("hehe2\n");
#endif
}
- 嵌套指令
前面提到的这些指令可以嵌套于另一个指令内部。
int main()
{
#ifdef M
#ifndef N
printf("haha\n");
#endif
#elif defined Q
printf("Qhaha\n");
#else
printf("xixi\n");
#endif
return 0;
}
文件包含
#include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方
一样。这种替换的方式很简单:
预处理器先删除这条指令,并用包含文件的内容替换。
这样一个源文件被包含10次,那就实际被编译10次。
本地文件包含
自定义函数的头文件使用
#include"filename"
库文件的包含
C语言库中提供的函数的头文件使用
#include<filename>
例如在写一个加法函数时,我们分为测试文件test.c、函数的声明以及库文件的包含放在add.h文件中、函数的定义放在add.c中,如下:
test.c
#include"add.h"//本地文件包含,
int main()
{
int a=10;
int b=20;
int ret=Add(a,b);
printf("%d\n",ret);
return 0;
}
add.c
int Add(int x,int y)
{
return x+y;
}
add.h
#include<stdio.h>//库文件的包含,c语言库中提供的函数的头文件使用
int Add(int x,int y);
我们test.c要使用add函数就要包含头文件add.h
<>和""包含头文件本质区别是:查找策略的区别
“”: 先在源文件所在的目录下查找,如果头文件没有查找到,编译器就像查找库函数头文件一样,在库函数的头文件目录下查找。
<>: 直接在库函数头文件所在的目录下查找。
嵌套文件包含
test3.h和test3.c是公共模块。
test1.h和test1.c使用了公共模块。
test2.h和test2.c使用了公共模块。
test.h和test.c使用了test1模块和test2模块。
这样最终程序中就会出现两份test.h的内容。这样就造成了文件内容的重复。
如何解决这个问题呢?
条件编译。
防止头文件的重复包含
每个头文件的开头写:
#ifndef _ADD_H_
#define _ADD_H_ 1
//头文件的内容
#endif
这样多重包含的危险就消除了,当头文件第一次包含时,它被正常处理,_ADD_H_会被定义为1,如果头文件被再次包含,通过条件编译,它的所有内容就被忽略。
定义部分也可以写作:
#define _ADD_H_
它的效果完全一样,只是现在它的值是一个空字符,这个符号仍然被定义。
或者:
#pragma once
这样就可以避免头文件的重复引入。