本章介绍一个test.c文件是如何生成一个test.exe文件。首先了解程序环境和程序预处理的大致流程,本章会分别介绍各个流程,但重点是翻译中的编译中的预编译阶段。
文章目录:
1.程序翻译环境和运行环境
- 1.1程序翻译中的的编译和链接
2.预编译详解
-
2.1预定义符号
-
2.2 #define用法
-
2.2.1 #define定义标识符
-
2.2.2 #define 定义宏
-
2.2.3 #define 替换规则
-
2.2.4 #和##
-
2.2.5 宏和函数对比
-
2.2.6 宏和函数对比
-
2.2.7 命名约定
-
2.3#undef的用法
-
2.4命令行定义
-
2.5条件编译
-
2.6文件包含
-
2.6.1#include<>和#include" "
-
2.6.2嵌套文件包含
1.程序翻译环境和运行环境
假设一个test.c文件经过编译器编译运行后生成可执行文件test.exe,这中间存在两个过程:
一个是翻译,在这个环境中源代码被转换为可执行的机器指令。
一个是运行,它用于实际执行代码。
在翻译环境阶段,会进行编译和链接操作。
在汇编阶段,是将汇编指令转换成二进制指令。
1.1程序翻译中的的编译和链接
我们先来看这段代码:
extern Add(int a, int b);
int main()
{
printf("%d\n", Add(2, 5));
}
这是在test.c文件中的代码,
int Add(int a, int b)
{
return a + b;
}
这是在Add.c文件中的代码。
编译运行后,我们走到代码源文件所在目录下,
会发现有两个obj文件,这两个obj文件就是通过编译器编译源码生成的目标文件。
而这仅仅是目标文件,想要生成可执行程序,还需要通过链接器链接,调用链接库,才能生成可执行文件。
在链接器将目标文件链接成可执行程序期间,会做两件事:
1.合并段表
一个目标文件:可能是一个.o文件,该文件内部有一个许多关于该文件的信息,并且是分区存放的,也就是一段一段的,如下图:
以上面的例子为例,既然目标文件test.o是这样的,那么另一个目标文件Add.o的分段也应该是如上图,只是里面的内容存放不同而已。
所以我们可以将test.o和Add.o中的各个段的信息合并,就叫做合并段表
2.符号表的合并和重定位
在上面的两个文件中,我们假设main函数在内存中的地址是0x20000000,Add函数的地址是0x10000000,如下图:
执行test.c程序时,首先进入执行extern Add语句,发现这是一个函数声明,意思就是我只知道有Add这个函数,但是具体在哪里不知道,接着进入main函数,发现main函数在内存中的地址是0x20000000,记录下来,执行完test.c文件后,接着进入Add.c文件中,发现Add.c文件中有一个Add.c函数,地址是0x10000000,记录下来。
随后,将两个目标文件通过链接器合并时,会将test.c和Add.c文件中的地址合并,即
这就是符号表的合并,那么重定位呢?
重定位就是在符号表合并后,程序只认识新的合成后的符号表,并将该符号表作为运行时的信息,不再以之前的符号表作为信息,这个就是重定位。
当然,上述的讲解只是表层的介绍,具体的内容还会更加深入。
2. 预编译详解
2.1 预定义符号
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
这是几个c语言内置的符号,来看他们的用法:
1.__ FILE __
我们打印__FILE__时,显示的是该源文件所在的路径。
2.__ LINE__
__LINE __ 显示打印的位置。
3.__DATE __
显示编译代码的日期
4.__TIME __
显示文件被编译的时间
5.__ STDC__
而在VS2019环境下,__STDC __没有被定义,如果被定义,其值为1。
注意,上述的五个预定义符号,在书写时均为大写!
2.2 #define 用法
2.2.1#define定义标识符
语法:
#define name stuff
凡是以#开头的,都是预处理指令。 后续还会讲到#pragma,#include,#line等等
举个例子:
#define MAX 1000
int main()
{
printf("%d\n",MAX);
}
注意:#define定义标识符在程序运行的时候进行的是替换!是替换!替换!
#define定义的标识符不会参与任何运算。
#define还可以定义各式各样的东西,甚至可以定义代码
#define reg register //为 register这个关键字,创建一个简短的名字
#define do_forever for(;;) //用更形象的符号来替换一种实现
#define CASE break;case //在写case语句的时候自动把 break写上。
#define DEBUG_PRINT printf("file:%s\tline:%d\tdate:%s\ttime:%s\n",__FILE__,__LINE__ ,__DATE__,__TIME__ )
这样写也可以实现。
注意这里有个问题:
#define do_forever for(;;)
1.
int main()
{
do_forever
return 0;
}
2.
int main()
{
do_forever;
return 0;
}
当我们这样定义for循环时,请问运行1和2的结果分别是什么?
答案:第一个运行的结果是,直接程序什么都不做,就结束。
第二个运行的结果是,程序死循环。
因为
#define定义标识符在程序运行的时候进行的是替换!
所以do_forever会替换成for(; ; ) , 当for循环后面不跟大括号时,默认跟一条语句。
对于第一个代码,return 0 是for循环里面的语句,
对于第二个代码,for循环内部的语句是一个分号,
for(; ; )
;
也就是这样,所以会死循环。
那么就有一个问题,
在define定义标识符的时候,要不要在最后加上 ; ?
到底需不需要加呢?
举个简单的例子
#define MAX 100;
int main()
{
int a = MAX;
printf("%d\n", a);
return 0;
}
我们知道,MAX会被替换成100;
所以在赋值给a时,是这样的:
int a = 100;; 有两个分号
这样打印出来可能没什么问题,但是当我们把打印a换成打印MAX时,就有问题了
打印的是
printf("%d\n",100;);
就会有错误
所以,在使用#define定义标识符的时候最好还是不要加分号
2.2.2 #define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
下面是宏的申明方式:
#define name( parament-list ) stuff
其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中。
注意:
参数列表的左括号必须与name紧邻。
如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
意思就是:name要和小括号紧紧相连在一起,如果中间有空格,那么(parament-list)就会被当作是stuff的一部分。
举个例子:
#define SQUARE( x ) x * x
int main()
{
printf("%d\n",SQUARE(5));
}
结果会输出什么?
5将会被传入x中,x就是5,宏也一样,是被替换的,所以
SQUARE(5) 会被替换成 5*5。
但是,这样的写法会有一些问题:
看下面的例子:
#define SQUARE( x ) x * x
int main()
{
int a = 5;
printf("%d\n" ,SQUARE( a + 1) );
}
请问输出结果是什么呢?乍一看,你可能会认为是36,但是,不对。
原因是,还是那句话,
宏一样是被替换的!!!
宏一样是被替换的!!!
宏一样是被替换的!!!
a是5,a+1会被放入SQUARE(a+1), 然后被替换成 a+1a+1,计算的是这个结果,5+15+1,结果就是11.
所以记住这句话:
宏一样是被替换的!!!
所以我们这样改就可以了
#define SQUARE(x) (x) * (x)
这样结果就是36了。
这里还有一个宏定义:
#define DOUBLE(x) (x) + (x)
int main()
{
int a = 5;
printf("%d\n" ,10 * DOUBLE(a));
}
请问结果是什么?
可能会说,100,但是,结果不正确,记住那句话
宏一样是被替换的!!!
打印结果是10*(5)+ (5),结果是55
那么,我们怎么改呢?
#define DOUBLE(x) ((x) + (x))
这样改正才是正确的,才是完美的。
总结,对宏进行定义时,应该对每个替换后的参数都加上括号,避免操作顺序不当出现错误。
2.2.3 #define 替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先
被替换。 - 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上
述处理过程。
注意:
1.宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
4. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
2.2.4 #和##
首先来看这段代码:
void print(int x)
{
printf("the value of a is %d\n", x);
}
int main()
{
int a = 10;
int b = 20;
print(a);
print(b);
return 0;
}
我们想把a和b的值都打印出来,
然而结果不是我们想要的,两次都是打印a,虽然值不同。
那么我们应该怎么做才能达到我们想要的效果呢?
先看下面,
int main()
{
printf("Hello World\n");
printf("Hello " "World\n");
return 0;
}
这两个打印的结果一样吗?
一样的,原因是,在同一个printf中,两个双引号引起来的两个字符串会被当成同一个字符串处理。
当然,中间的空格可有可无,可以有很多个,也可以没有,这里放一个空格隔开只是方便看。
了解了这个之后,我们就可以改造上面如何打印a和b的值出来了。
#define PRINT(X) printf("the value of " #X "is %d\n",X)
当我们这样改造时,前面的双引号引起的是the value of(这里有个空格) ,后面的双引号引起的是is %d\n ,两个字符串之间使用一个#来吧X也变成一个字符串,这样三个字符串连在一起,就完成了。
相当于
printf("the value of ""a""is %d\n",a)
printf("the value of ""b""is %d\n",b)
所以#的作用是,把宏参数对应的内容变成一个字符串
有些东西是函数无法做到但是宏能够做到的。比如上面这个例子。
##
(2) ## 的作用
##可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符。
举个例子:
#define CAT(X,Y) X##Y
int main()
{
int student = 100;
printf("%d\n", CAT(stu,dent));
printf("%d\n", student);
return 0;
}
该段代码的输出结果是两个100,##的作用就是,把X,Y宏参数合成一个新的符号。
stu是参数X,dent是参数Y,合成后成为一个新的符号student,打印出来就是100.
注意:
这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。
2.2.5 带副作用的宏参数
副作用就是后遗症的意思,
x+1; 不带副作用
x++; 带有副作用
举个例子:
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
int main()
{
x = 2;
y = 5;
printf("%d\n", MAX(a++, b++));
printf("%d\n", a);
printf("%d\n", b);
}
请问上面的这段代码输出结果是什么?
输出结果是:6,3,7
原因是,宏是被替换的,首先x是2,y是5,在使用宏时,参数是a++和b++,然后进行替换,替换结果是:
printf("%d\n", ((a++) > (b++) ? (a++) : (b++)));
所以在比较时,先使用,后++,2和5比较完,再各自++,此时2小于5,执行b++,此时b已经是6了,先使用后++,所以打印第一个结果是6,对于a,a只++一次,结果是3,
最后再打印b出来时,b已经++完成了,所以b打印出来是7.
总结:当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能
出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
2.2.6 宏和函数对比
宏是经常被用来执行计算量较小的计算,如比较大小,那为什么不使用函数来比较呢?
1.我们知道,函数在调用的时候会有返回的开销,反观宏,则没有类似的问题。
以比较大小为例:
#define MAX(a,b) ((a)>(b)?(a):(b))
float MAX2(float c, float d)
{
return c > d ? c : d;
}
int main()
{
int a = 2;
int b = 5;
float c = 3.0f;
float d = 4.0f;
float max2 = MAX2(c, d);//函数调用
printf("%f\n", max2);
printf("%f\n", MAX(c, d));//宏调用
return 0;
}
我们分别使用函数和使用宏来比较大小,
我们调试起来,转到反汇编后,注意看,现在准备进入函数调用,在此之前是准备工作。
当我们调用该函数时,会发现这么一大堆东西,这些都是函数在调用时需要做的工作,以及返回值需要做的工作。
再来看宏的开销:
对比函数和宏调用的开销,会发现,仅仅是比较大小,函数的开销比宏的开销多出了很多。
所以宏比函数在程序的规模和速度方面更胜一筹。
2. 函数的参数必须声明为特定的类型,而宏是类型无关的。
以上面的例子为例:
#define MAX(a,b) ((a)>(b)?(a):(b))
float MAX2(float c, float d)
{
return c > d ? c : d;
}
int MAX1(int a, int b)
{
return a > b ? a : b;
}
int main()
{
int a = 2;
int b = 5;
float c = 3.0f;
float d = 4.0f;
float max2 = MAX2(c, d);//函数调用
printf("%f\n", max2);
int max1 = MAX1(c, d);//函数调用
printf("%d\n", max1);
printf("%f\n", MAX(c, d));//宏调用
printf("%d\n", MAX(a, b));//宏调用
return 0;
}
分别使用函数和宏对整型和浮点型数据进行大小比较,此时两个没有任何问题,但是接下来,
当我们更改图中数据,用浮点型函数比较整型大小时,回出现警告,可能会丢失数据,
结果也不符合,
所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于>来比较的类型。
记住,宏是类型无关的。
宏的缺点:
1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序
的长度。
#define TEST() printf("Test Successfully!\n")
int main()
{
TEST();
TEST();
TEST();
//等价于
printf("Test Successfully!\n");
printf("Test Successfully!\n");
printf("Test Successfully!\n");
}
我们这样复制三份宏,替换后就已经出现代码较冗余的情况, 假如宏定义的代码有五十行,复制三份后就有一百五十行,情况更加严重。
2. 宏是没法调试的
调试起来的时候,按下F11,并没有跳转到宏所在的地方,因为宏在预编译的时候就已经完成了替换。
3. 宏由于类型无关,也就不够严谨。
宏是类型无关的,既是优点,也是缺点。
4. 宏可能会带来运算符优先级的问题,导致程容易出现错。
5.宏还可以做到传一个类型,然而函数做不到。
比如说,宏的参数可以是一个int,但是没有函数传参传一个int的说法,函数只能传一个int类型的值,但是绝对不会传一个int。
举个例子,好好体会一下。
#define MALLOC(num,type) (type*)malloc(num*sizeof(type))
int main()
{
int ret1 = (int*)malloc(10 * sizeof(int));
int ret = MALLOC(10, int);
//等价于
printf("%d\n", ret1);
printf("%d\n", ret);
return 0;
}
2.2.7命名约定
一般函数的宏的使用语法很相似。
所以语言本身没法帮我们区分二者。
我们平时的一个习惯是:
把宏名全部大写
函数名不要全部大写
2.3#undef的用法
#undef 是用来移除一个宏定义的
举个例子:
#define MAX 100
int main()
{
printf("%d\n", MAX);
#undef MAX
printf("%d\n", MAX);
return 0;
}
可以发现,编译都无法编译成功,说明MAX已经被移除了。
2.4 命令行定义
通俗地讲,命令行定义就是再程序预编译的时候改变一些参数,使他们能够随时地发生变化。
比如说下面:
给一个数组赋值,ARRARY_SIZE代表数组的大小,数组的大小通过命令行定义,在预编译阶段是可以发生改变的。
#include <stdio.h>
int main()
{
int array [ARRAY_SIZE];
int i = 0;
for(i = 0; i< ARRAY_SIZE; i ++)
{
array[i] = i;
}
for(i = 0; i< ARRAY_SIZE; i ++)
{
printf("%d " ,array[i]);
}
printf("\n" );
return 0;
}
总结:命令行定义就是再程序预编译的时候改变一些参数,使他们能够随时地发生变化。
2.5 条件编译
条件编译,也就是有选择性地编译,把想要的留下。
比方说:
int main()
{
int arr[10] = { 0 };
for (int i = 0; i < 10; i++)
{
#ifdef DEBUG
arr[i] = i;
#endif
printf("%d ", arr[i]);
}
return 0;
}
请问这段代码输出结果是什么?
结果输出10个0。
因为这里我们使用了条件编译,#ifdef DEBUG,意思就是如果定义有DEBUG,就使用下面的语句,结束编译语句是#endif,在这区间内,如果条件成立,则执行,不成立就不执行。
由于未定义有DEBUG,所以条件不成立,不执行赋值语句,当我们在前面定义DEBUG,就可以了。
在这里可以给DEBUG一个替换对象,也可以仅仅定义DEBUG。
常见的条件编译指令:
1.常量表达式
int main()
{
#if 1
printf("hehe\n");
#endif
return 0;
}
2.多分支的条件编译
int main()
{
#if 1==1
printf("hehe\n");
#elif 2==1
printf("haha\n");
#else
printf("heihei\n");
#endif
return 0;
}
3.判断是否被定义过
#define DEBUG 0 //即使DEBUG被定义为0,为假,但是它已经被定义过了,就打印hehe
int main()
{
#if !defined(DEBUG) // 只要定义过,不管定义什么,满足条件就参与编译
printf("hehe\n");
#endif
return 0;
}
注意,只要被定义过,不管被定义成什么,都成立。
并且,define后面加了一个字母d,表示defined,定义过的意思。
还有一个是
#ifndef DEBUG //注意这里多了个n,表示no
printf("hehe\n",);
#endif
表示如果没有定义DEBUG,就打印hehe。
4,嵌套定义
嵌套定义可以跟嵌套的条件判断类比,也就是 if 中还有 if 。
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
2.6文件包含
2.6.1 #include <> 和#include " "
我们知道,对于文件来说,假如我们需要打印东西,就需要引一个头文件,引#include<stdio.h>
那假如我用 #include "stdio.h "
这样的写法呢?能否通过?
仍然可以打印出来。
#include "filename"
的查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。
如果找不到就提示编译错误。
用#include " "的方式包含文件,会先在源文件所在目录下查找,
也就是这些文件里查找,如果找不到,才会去标准库函数里面查找。
如上图.
而#include< >,则是直接在目录里面查找了。
所以,当我们有成千上万个源文件时,应该使用#include<>去查找。
2.6.2 嵌套文件包含
如果出现了这样的情况,也就是一个多个文件中都包含了同一个头文件,这会重复调用头文件,造成代码冗余,也会造成文件的速度的减慢。
解决办法:
1.条件编译
每个头文件的开头写:
#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST_H__
意思就是,如果没有定义TEST.H这个头文件,那么就定义它,如果定义了,就不再次定义。
比如说这个,上面的红色框框,是test.h文件的内容,下面红色框框是test.c文件内容,在test.c文件中包含test.h文件,然后进入test.h文件,执行#ifndef,如果自己没有被定义,那就定义,如果定义过了,那就不重复定义了。
直接写下面这句话就可以了。
#pragma once
更加推荐第二种写法。
这篇文章到这里就结束了!
如果对于有帮助,不妨点赞关注吧!