程序环境和预处理
- 程序的翻译环境和执行环境
- 编译、链接
- 翻译环境
- 编译
- 预处理(预编译)
- 编译
- 汇编
- 链接
- 编译环境几个阶段的总结
- 运行环境(执行环境)
- 预处理详解
- 预定义符号
- #define
- #define 定义标识符
- #define 定义宏
- #define 替换规则
- #和##
- 带副作用的宏参数
- 宏和函数对比
- #undef
- 命令行定义
- 条件编译
- 文件包含
- 头文件被包含的方式
- 嵌套文件包含
- 经典面试题
- 预处理指令的相关补充
程序的翻译环境和执行环境
在ANSI(标准) C的任何一种实现中,存在两个不同的环境
- 第一种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
- 第二种是执行环境(运行环境),它用于实际执行代码。
test.c最后编译成test.exe这个过程依赖的是翻译环境(在当前环境底下用的是VS2019集成开发环境)。可执行程序如果运行这段代码运行产生效果这时所依赖的环境是运行环境最后就会产生结果。
总结:任何C的程序要运行起来一定要有翻译环境和执行环境
编译、链接
翻译环境
- 组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
- 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
- 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。
补充:
- 编译是把每个源文件经过编译器处理会生成目标文件。
- 编译依赖的是编译器,链接依赖的是链接器
- cl.exe是VS底下的编译器,链接器在Windows底下是link.exe
- VS2019称为集成开发环境,这样的集成开发环境是把小的组件集成在一起。
编译
编译分为预编译(预处理)、编译、汇编这三个步骤。
预处理(预编译)
实例一:
注:在预处理阶段完成了头文件的包含,eg:#include这样指令的处理。
实例二:
注:在预处理阶段完成了#define定义的符号和宏的替换。
实例三:
注:在预处理阶段完成了注释的删除。
总结:在预处理阶段完成的事情都是文本操作。eg:头文件的包含、#define定义的符号和宏的替换、注释删除等等这些操作。
补充:
- 在预处理阶段注释被删除了,所以在代码中写再多的注释都不会给程序带来负担。注释写进去之后其实不会影响最终程序的编译。
- #define定义的符号和宏在预处理阶段完成替换了,在编译和汇编的时候不能看到,在程序运行的时侯调试时#define定义的符号和宏是看不到的。
编译
注:
- 编译阶段是把C语言代码转化成汇编代码
- 编译阶段进行了语法分析、词法分析、语义分析、符号汇总这样相关的事情。
汇编
注:
- 汇编阶段是把汇编代码转化成了二进制指令(机器指令)。
- 在汇编阶段会生成符号表、把汇编指令转换成二进制指令。
- test.o这个文件是elf(elf是文件的组织形式,是一种格式)格式的,其实是把test.o这个文件划分成一个一个的段,每个段存放的数据是不一样的,相同的.o文件里面段分的都是一样的,同一种段里面放的数据是不一样的。可以用readelf这个工具来看懂test.o文件。
实例一:
注:在编译阶段会把.c文件里面这种符号(全局的符号(函数名、全局变量之类的))全部汇总起来—符号汇总。
实例二:
首先对这两个文件进行预处理,再对这两个文件进行编译,在编译阶段它会把test.c文件里面的符号进行汇总,最后放到test.s文件里。把add.c文件里面的符号进行汇总,最后放到add.s文件里。他们各自把各自的符号(全局的符号)进行汇总,符号汇总完成之后,最后进行汇编,在汇编阶段进行了生成符号表,要把刚刚汇总的符号形成符号表(符号表里有符号和符号对应的地址)。
注:局部的变量是在程序运行的时候进去才创建的。
链接
链接阶段是把多个目标文件和链接库进行链接的,最后生成可执行程序。
实例一:
注:
-
链接阶段完成的是合并段表和符号表的合并和符号表的重定位。
-
生成的.o文件是elf格式的,这种elf格式的文件是由几个段组成的,如果有多个这样的目标文件,最终要把这些目标文件链接生成一个可执行程序(可执行程序的格式也是elf格式的)。把多个文件进行链接时就要把相同段的数据合并在一起——合并段表。
-
链接期间这样的相同的符号表来自多个.o文件的这种符号表也要合并在一起,符号表合并的时候会把有效的符号的地址进行保留——符号表的合并和重定位
-
当符号表合并之后,这时链接好的可执行程序调用函数时通过地址就可以找到函数。
实例二:
注:
-
符号表里提供的符号就是最后在链接的时候通过符号和它的地址去找对应的函数,链接期间通过地址去其他源文件中把想要找的函数找到(其他的.o文件里面要把想要找的这些函数找到),如果这些函数在链接期间都找不到这个函数就找不到了。
-
.o (二进制文件)、.out(可执行程序)文件都是按照elf格式存放的,这种格式就是把一个文件划分成一个一个的段。
编译环境几个阶段的总结
Linux环境下生成相关文件的命令:
- 预处理 选项 gcc -E test.c -o test.i
预处理完成之后就停下来,预处理之后产生的结果都放在test.i文件中。 - 编译 选项 gcc -S test.c
编译完成之后就停下来,结果保存在test.s中。 - 汇编 gcc -c test.c
汇编完成之后就停下来,结果保存在test.o中。
VIM的相关命令:Linux环境基础开发工具使用
运行环境(执行环境)
程序执行的过程:
-
程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
-
程序的执行便开始。接着便调用main函数。
-
开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
-
终止程序。正常终止main函数;也有可能是意外终止。
注:
- 程序开始执行的时候,每一次函数调用都会为自己开辟一块空间,这块空间就是运行时堆栈(函数栈帧)
- 运行时堆栈、堆栈指的都是栈。堆指的就是堆。
预处理详解
预定义符号
预定义符号在预处理阶段被处理的已经定义好的这种符号,这些符号是可以直接被使用的。
预定义符号是C语言已经内置好的。
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
这些预定义符号都是语言内置的。
实例一:
实例二:
int main()
{
int i = 0;
FILE* pf = fopen("log.txt", "a+");
if (pf == NULL)
{
perror("fopen\n");
return 1;
}
for (i = 0; i < 10; i++)
{
fprintf(pf, "%s %d %s %s %d\n", __FILE__, __LINE__, __DATE__, __TIME__, i);
}
fclose(pf);
pf = NULL;
return 0;
}
实例三:
注:__STDC__这个预定义符号在VS2019编译器中不支持,VS2019编译器也不支持ANSI C这个标准。Linux环境下GCC编译器是支持ANSI C的。
#define
#define 定义标识符
语法:#define name stuff
实例一:
#define M 1000
#define reg register
#define do_forever for(;;) //for(;;)循环的基本结构
int main()
{
reg int num = 0;
do_forever;
int m = M;
//在预处理阶段就会把M替换成1000,预处理完之后这个地方就没有M是1000。
printf("%d\n", m);
return 0;
}
实例二:
#define CASE break;case
int main()
{
int n = 1;
switch (n)
{
case 1:
CASE 2 :
CASE 3 :
;
}
return 0;
}
实例三:
#define MAX 1000
#define reg register //为 register这个关键字,创建一个简短的名字
#define do_forever for(;;) //用更形象的符号来替换一种实现
#define CASE break;case //在写case语句的时候自动把 break写上。
// 如果定义的 stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n" ,\
__FILE__,__LINE__ , \ //\是续行符
__DATE__,__TIME__ )
注:在define定义标识符的时候,不要在最后加上 ; 。因为从理论上讲加上;语法是没有问题的,但是从实际情况来讲是不建议加上;的。#define定义符号的时候内容的后面尽量不要加;,除非必须要加。
#define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
下面是宏的申明方式:
#define name( parament-list ) stuff
//name:名字
//parament-list:参数列表
//stuff:内容
//其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中。
注:参数列表的左括号必须与name紧邻。如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
实例一:
//版本一
//#define SQUARE( x ) x * x //这个宏接收一个参数 x
//版本二
#define SQUARE(x) (x) * (x)
int main()
{
//版本一
printf("%d\n", SQUARE(5)); //25
//把上面的代码置于程序中,预处理器就会用下面这个表达式替换上面的表达式
//printf("%d\n", 5*5);
//注意这个宏存在一个问题
int a = 5;
printf("%d\n", SQUARE(a + 1)); //11
//替换文本时,参数x被替换成a + 1,所以这条语句实际上变成了printf ("%d\n",a + 1 * a + 1 );
//由替换产生的表达式并没有按照预想的次序进行求值,这时就需要在宏定义上加上两个括号,这个问题便轻松的解决了
//版本二
//这样预处理之后就产生了预期的效果 printf ("%d\n",(a + 1) * (a + 1) );
printf("%d\n", SQUARE(a + 1)); //36
return 0;
}
实例二:
//版本一
//#define DOUBLE(x) (x) + (x)
//版本二
#define DOUBLE(x) ((x)+(x))
int main()
{
//版本一
int a = 5;
printf("%d\n", 10 * DOUBLE(a)); //55
//替换之后:
//printf("%d\n", 10 * (5) + (5));
//乘法运算先于宏定义的加法,所以出现了55
//这个问题的解决办法是在宏定义表达式两边加上一对括号就可以了
//版本二
printf("%d\n", 10 * DOUBLE(a));//100
return 0;
}
注:
- 宏它的参数是完成替换的,它不是先计算然后再传参的是替换进去再计算的。传进去之后替换完的表达式(预处理完的表达式),在对它进行计算(在编译完之后执行的时候进行计算)
- 用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用。
#define 替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤:
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
- 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。
注意:
- 宏参数和#define 定义中可以出现其他#define定义的常量。但是对于宏,不能出现递归。
- 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
#和##
#
#是可以把参数插入到字符串中。#参数名作用是将参数名的内容所对应参数名的字符串。
实例一:
#define PRINT(X, FORMAT) printf("the value of "#X" is "FORMAT"\n", X);
int main()
{
printf("hello world\n"); //hello world
printf("hello " "world\n"); //hello world
int a = 10;
PRINT(a, "%d");
//printf("the value of ""a"is ""%d""\n", a);
//the value of a is 10
int b = 20;
PRINT(b, "%d");
//printf("the value of ""b"is ""%d""\n", b);
//the value of b is 20
int c = 30;
PRINT(c, "%d");
//printf("the value of ""c"is ""%d""\n", c);
//the value of c is 30
float f = 5.5f;
PRINT(f, "%f");
//printf("the value of ""f"" is ""%f""\n", f);
//the value of f is 5.500000
return 0;
}
注:
- #只能在宏里使用。
- 字符串是有自动连接的特点的。
- #的作用是把一个宏参数变成对应的字符串。
##
##的作用是可以把位于它两边的符号合成一个符号。 它允许宏定义从分离的文本片段创建标识符。
实例一:
#define CAT(X,Y,Z) X##Y##Z
int main()
{
int theaugustcsdn = 100;
printf("%d\n", CAT(the, august, csdn));
//printf("%d\n", theaugustcsdn);
return 0;
}
注:这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。
带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
x+1;//不带副作用
x++;//带有副作用
实例一:
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
int a = 5;
int b = 8;
int m = MAX(a++, b++);
printf("a=%d b=%d\n", a, b); //a=6 b=10
//int m = ((a++) > (b++) ? (a++) : (b++));
printf("m = %d\n", m);//m=9
return 0;
}
注:带副作用的宏参数如果传到宏体内去,这种副作用一直会延伸到宏的体内而不仅仅是参数。
宏和函数对比
宏通常被应用于执行简单的运算。
实例一(在两个数中找出较大的一个):
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int Max(int x, int y)
{
return x > y ? x : y;
}
int main()
{
int a = 5;
int b = 8;
int m = MAX(a, b);
//如果是宏,他在预处理阶段就变了
//int m = ((a)>(b)?(a):(b));
m = Max(a, b);
return 0;
}
原因是因为:
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
- 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏可以适用于整形、长整型、浮点型等可以用于>来比较的类型。宏是类型无关的。
当然和宏相比函数也有劣势的地方:
- 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
- 宏是没法调试的(调试的是可执行程序,运行的时候调试)。
- 宏由于类型无关,也就不够严谨。
- 宏可能会带来运算符优先级的问题,导致程容易出现错。
宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。
实例二:
#define MALLOC(num, type) (type*)malloc(num*sizeof(type))
int main()
{
int*p = MALLOC(10, int); //类型作为参数
//预处理器替换之后:
//int*p = (int*)malloc(10 * sizeof(int));
return 0;
}
宏和函数的一个对比:
属性 | #define定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码 |
执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号。(因为宏是把代码替换到宏体里面去的这样的话宏在周围表达式的上下文环境中很可能还有其他的操作符会影响到替换进来的表达式里面的操作符就会导致操作符优先级的问题) | 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测。 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。 | 函数参数只在传参的时候求值一次,结果更容易控制。 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型。 | 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是不同的。 |
调试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递归 | 宏是不能递归的 | 函数是可以递归的 |
总结:如果运算的逻辑足够简单用宏更好一些,如果运算的逻辑相对复杂一些用函数更好一些。
补充:在C99之后引入了内联函数(inline),内联函数结合了宏的优点和函数的优点,内联函数就可以替换掉宏和函数这块的要求和好处。
命名约定
一般来讲函数的宏的使用语法很相似。所以语言本身没法区分二者。
程序员平时的一个习惯是:把宏名全部大写,函数名不要全部大写。
注:有时候把宏伪装成函数,这时就需要把它写成全小写
#undef
这条指令用于移除一个宏定义。
#undef NAME
//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。
实例一:
#define M 100
int main()
{
int a = M;
#undef M
//printf("%d\n", M); //err 未定义标识符M
return 0;
}
命令行定义
许多C的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。例如:当程序员根据同一个源文件要编译出不同的一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,程序员需要一个很小的数组,但是另外一个机器内存大些,程序员需要一个数组能够大些。)
实例一:
注:在代码中的M值不是在代码中指定的,而是在编译的时候通过命令行的方式对M进行相关的定义——命令行定义(在命令行的地方对它进行定义)。
条件编译
在编译一个程序的时候程序员如果要将一条语句(一组语句)编译或者放弃是很方便的。因为有条件编译指令。
调试性的代码,删除可惜,保留又碍事,所以可以选择性的编译。
实例一:
常见的条件编译指令:
1.
#if 常量表达式
//...
#endif
//常量表达式由预处理器求值。
如:
#define __DEBUG__ 1
#if __DEBUG__
//..
#endif
2.多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
3.判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
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
实例一:
#if 0 //以下代码不参与编译(相当于注释掉了)
#define PRINT 1
int main()
{
#if PRINT
printf("hehe\n"); //hehe
#endif
return 0;
}
#endif
实例二:
int main()
{
#if 1==1
printf("hehe\n"); //hehe
#elif 2==2
printf("haha\n");
#else
printf("heihei\n");
#endif
return 0;
}
实例三:
#define TEST 0
#define HEHE 1
int main()
{
//如果TEST定义了,下面参与编译
//1
#ifdef TEST
printf("test1\n");
#endif
//2
#if defined(TEST)
printf("test2\n");
#endif
//代码1和2是一个意思
//如果HEHE不定义,下面参与编译
//3
#ifndef HEHE
printf("hehe1\n");
#endif
//4
#if !defined(HEHE)
printf("hehe2\n");
#endif
return 0;
}
//代码3和4是一个意思
文件包含
#include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。
这种替换的方式很简单:
预处理器先删除这条指令,并用包含文件的内容替换。
这样一个源文件被包含10次,那就实际被编译10次。
注:
- #include是预处理指令
头文件被包含的方式
本地文件包含
#include "filename"
查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。
Linux环境的标准头文件的路径:
/usr/include
VS环境的标准头文件的路径:
C:\Program Files (x86)\Windows Kits\10\Include\10.0.18362.0\ucrt
注意:按照自己的安装路径去找
库文件包含
#include <filename.h>
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。这样可以说,对于库文件也可以使用 “” 的形式包含。但是这样做查找的效率就低些,建议库的用<>不要用"",当然这样也不容易区分是库文件还是本地文件了。
总结:
- C语言库中提供的函数的头文件使用<>(库文件包含)、自定义的函数的头文件使用""(本地文件包含)。
- <> 和 "“包含头文件的本质区别是(查找的策略的区别):”"包含头文件首先是在自己代码所在的目录下查找,其次如果第一种找不到,则在库函数的头文件目录下查找。<>包含头文件直接去库函数头文件所在的目录下查找。
嵌套文件包含
如果出现这样的场景:comm.h和comm.c是公共模块。test1.h和test1.c使用了公共模块。test2.h和test2.c使用了公共模块。test.h和test.c使用了test1模块和test2模块。这样最终程序中就会出现两份comm.h的内容。**这样就造成了文件内容的重复。**可以通过条件编译的方式解决头文件的重复包含
注:头文件的重复包含会造成代码冗余
实例一:
每个头文件的开头写:
#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST_H__可以换成其他的,名字随便起
//或者:
#pragma once
这样就可以避免头文件的重复引入。
经典面试题
- 头文件中的 ifndef/define/endif是干什么用的?
防止头文件被重复多次的包含 - #include <filename.h> 和 #include "filename.h"有什么区别?
同样是包含头文件"“是用来包含自定义的头文件的,而<>是用来包含库里面的头文件的,它们在查找策略上有所区别:”"是先去自定义的这个文件路径代码路径底下去查找,如果找不到头文件再去库目录底下查找,而<>直接去库目录底下查找(标准的头文件库目录下查找)。