目录
一、程序环境
①翻译环境
②运行环境
③C语言程序的编译名词+步骤详解
名词详解
编译步骤详解
二、预处理详解
①预定义符号
②预处理指令
#define定义标识符
#define 定义宏
#define的替换规则
#undef
③宏和函数的对比
命定约定
④#和##的介绍
#的作用
##的作用
⑤条件编译
1.单分支的条件编译
2.多分支的条件编译
3.判断是否被定义
4.嵌套指令
⑥文件包含
在ANSIC的任何一种实现中,存在两个不同的环境:
- 第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。机器只认识0&1的二进制指令。
- 第2种是执行环境,它用于实际执行代码。
图解1:
图解2:
图解:
- 组成一个程序的每个源文件通过编译过程分别转换成目标代码 (object code)。
- 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
- 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库, 将其需要的函数也链接到程序中。
程序执行的过程:
- 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须 由手工安排,也可能是通过可执行代码置入只读内存来完成。
- 程序的执行便开始。接着便调用main函数。
- 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同 时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
- 终止程序。正常终止main函数;也有可能是意外终止。
源文件:.c为后缀的文件,如 test.c,game.c
头文件:.h为后缀的文件,如 game.h
目标文件:.obj为后缀的文件,由源文件编译后生成,如 test.obj
链接库:库是写好的现有的,成熟的,可以复用的代码。
现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:静态库(.a、.lib)和动态库(.so、.dll)。
- windows上对应的是.lib.dll
- linux上对应的是.a.so
静态库:是因为在链接阶段,会将汇编生成的目标文件 .o 与引用到的库一起链接打包到可执行文件中。因此对应的链接方式称为静态链接。
链接库:
编译器:
链接器:
编译阶段分为以下三步骤:
预处理、编译、汇编
预处理阶段:
①头文件的包含
②#define定义的符号和宏的替换
③注释的删除(所以我们要大胆写注释!不会影响程序的运行和性能!)
注:这些都是文本操作
编译阶段:
①把c语言代码转换为汇编代码
②语法分析、词法分析、语义分析、符号汇总
汇编阶段:
①将汇编语言转换为机器语言
②生成符号表
链接阶段:
①把多个目标文件(.obj(windows) / .o(Linux))和链接库进行链接
②合并段表
③符号表的合并和重定位
- C语言允许在源程序中加入一些“预处理指令”(preprocessing directive), 以改进程序设计环境,提高编程效率。
- 这些预处理指令是由C标准建议的, 但它不是C语言本身的组成部分,不能用C编译系统直接对它们进行编译(因为编译程序不能识别它们)。
- 必须在对程序进行正式编译(包括词法和语法分析、代码生成、优化等)之前,先对程序中这些特殊的指令进行“预处理”(preprocess, 也称“编译预处理”或“预编译”)。
- 把预处理指令转换成相应的程序段,它们和程序中的其他部分组成真正的C语言程序, 对预处理指令进行的预处理工作,是由称为C预处理器(preprocessor)的程序负责处理的。
- 在预处理阶段,预处理器把程序中的注释全部删除; 对预处理指令进行处理, 如把#include指令指定的头文件(如stdio.h)的内容复制到#include指令处; 对#define指令,进行指定的字符替换(如将程序中的符号常量用指定的字符串代替), 同时删去预处理指令。
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
代码演示:
#include
int main()
{
printf("%s\n", __FILE__);
printf("%d\n", __LINE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
printf("%s\n", __FUNCTION__);
//printf("%d\n", __STDC__);
//因为 VS2019 不遵循 ANSI C,该符号未定义,所以进行了注释
return 0;
}
执行结果:
写一个日志文件:
代码如下:
#include
int main()
{
int i = 0;
int arr[10] = { 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;
}
执行结果:
日志展示:
语法:
#define name stuff
举例:
#define MAX 10
#define reg register
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
例1:
#include
#define SQUARE(x) x*x//求x的平方
int main()
{
int ret = SQUARE(5);
//相当于int ret = 5*5;
printf("%d\n", ret);//结果为25
return 0;
}
执行结果:
例2:
#include
#define SQUARE(x) x*x
int main()
{
int ret = SQUARE(4+1);
printf("%d\n", ret);
return 0;
}
执行结果:
思考:为什么例1跟例2的结果会不一致呢?
- 例2中,当传入的宏参数为4+1时,宏完成的是替换,它不会先把4+1的值算出来再进行替换,而是直接替换,所以传入4+1替换后相当于:int ret = 4+1*4+1 = 9
- *的优先级高于+,所以这样算出的结果就为9了。
为了避免这种情况的发生,用宏实现求一个数的平方应该这样:
#define SQUARE(x) ((x)*(x))
这里将 (x)*(x) 整体再用括号括起来的原因也是一样的,都是为了避免在使用宏时,因操作符的优先级问题而导致不可预料的后果。
注:在使用#define定义宏时,不要吝啬括号,该加括号的地方就要加上。
在程序中替换 #define 定义的宏和标识符时,会涉及几个步骤:
- 在调用宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果是,它们首先被替换。
- 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。
例:
#include
#define SQUARE(x) ((x)*(x)*100)
int main()
{
int ret = SQUARE(3);
printf("%d\n", ret);
return 0;
}
等价于:
#include
int main()
{
int ret = ((3)*(3)*100);
printf("%d\n", ret);
return 0;
}
注意:
1. 宏参数和#define 定义中可以出现其他#define定义的变量。但是对于宏,不能出现递归。 2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
如:
#define FAC(x) (x)*FAC(x-1)//error
//不能出现如上所述的代码
#include
#define MAX 100
int main()
{
printf("MAX = %d\n", MAX);//结果为MAX = 100
return 0;
}
//代码字符串中的MAX不会被替换为100,而字符串外的MAX会被替换
#undef可以移除一个#define定义的标识符或宏。
例:
#include
#define MAX 100
int main()
{
printf("%d\n", MAX);//正常使用
#undef MAX
printf("%d\n", MAX);//报错,MAX未定义
}
宏通常被应用于执行简单的运算。比如在两个数中找出较大的一个。
#define MAX(a,b) ((a)>(b)?(a):(b))
那为什么不用下面这个函数来实现这个功能呢?
int Max(int a, int b)
{
return a > b ? a : b;
}
原因:
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
- 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。但是宏可以适用于整形、长整型、浮点型等可以用于来比较的类型。宏是类型无关的。
而且,宏有时候可以做到函数做不到的事情。
例如,宏的参数可以出现类型,但是函数却不可以。我们使用malloc函数开辟内存空间时,可能会觉得代码太多。
例:
#include
#include
int main()
{
int *p = (int*)malloc(10 * sizeof(int));
if (p == NULL)
{
printf("p开辟失败\n");
return 1;
}
free(p);
p = NULL;
return 0;
}
此时我们就可以通过宏来使我们用malloc开辟空间时,只用传入开辟的类型和该类型的元素个数即可。
例:
#include
#include
#define MALLOC(num,type) (type*)malloc(num*sizeof(type))
int main()
{
int* p2 = MALLOC(10, int);
if (p2 == NULL)
{
printf("p2开辟失败\n");
return 1;
}
free(p2);
p2 = NULL;
return 0;
}
当然和宏相比函数也有劣势的地方:
- 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
- 宏是没法调试的。
- 宏由于类型无关,也就不够严谨。
- 宏可能会带来运算符优先级的问题,导致程容易出现错。
对比图
一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。 那我们平时的一个习惯是:把宏名全部大写 函数名不要全部大写
这里所说的#并不是#define和#include中的#,这里所说的#的作用是:把一个宏参数变成对应的字符串。
我们首先需要知道:字符串是有自动连接的特点的。
例:
char* arr = "hello ""world";
printf("hello"," world\n");
printf("%s", arr);
例2:
#include
int main()
{
int age = 10;
printf("The value of age is %d\n", age);
double pi = 3.14;
printf("The value of pi is %f\n", pi);
int* p = &age;
printf("The value of p is %p\n", p);
return 0;
}
我们发现,printf要打印的内容大部分是一样的,那么,为了避免代码冗余,我们可不可以将其封装成一个函数或是宏呢?
经过思考与实验,发现函数和普通的宏都不能实现该功能。
#的使用常例:
#include
#define print(data,format) printf("The value of "#data" is "format"\n",data)
int main()
{
int age = 10;
print(age, "%d");
double pi = 3.14;
print(pi, "%f");
int* p = &age;
print(p, "%p");
return 0;
}
##可以把位于它两边的符号合成一个符号。 它允许宏定义从分离的文本片段创建标识符。
例:
#include
#define CAT(x,y) x##y
int main()
{
int workhard = 100;
printf("%d\n", CAT(work, hard));//打印100
return 0;
}
条件编译,即满足条件就参与编译,不满足条件就不参与编译。
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
例:调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
#if 表达式
//待定代码
#endif
如果#if后面的表达式为真,则“待定代码”的内容将参与编译,否则“待定代码”的内容不参与编译。
#if 表达式
//待定代码1
#elif 表达式
//待定代码2
#elif 表达式
//待定代码3
#else 表达式
//待定代码4
#endif
多分支的条件编译类似于if-else语句,“待定代码1,2,3,4”之中只会有一段代码参与编译。
//第一种的正面
#if defined(表达式)
//待定代码
#endif
//第一种的反面
#if !defined(表达式)
//待定代码
#endif
如果“表达式”被#define定义过,则“第一种的正面”的“待定代码”将参与编译,否则不参与编译。“第一种的反面”的执行机制与“第一种的正面”恰好相反。
//第二种的正面
#ifdef 表达式
//待定代码
#endif
//第二种的反面
#ifndef 表达式
//待定代码
#endif
如果“表达式”被#define定义过,则“第二种的正面”的“待定代码”将参与编译,否则不参与编译。“第二种的反面”的执行机制与“第二种的正面”恰好相反。
#include
#define MIN 10
int main()
{
#if !defined(MAX)
#ifdef MIN
printf("hello\n");
#else
printf("world\n");
#endif
#endif
return 0;
}
注意:未满足条件编译指令的代码,在预处理阶段将被编译器自动删除,不参与后面的代码编译过程。
例:
#include
int main()
{
int i = 0;
for (i = 0; i < 10; i++)
{
printf("%d\n", i);
#if 0
printf("hello world!\n");
#endif
}
return 0;
}
因为#if后面的表达式为假,语句 #if 0 和 #endif 之间的代码将不参与编译,所以在预处理阶段过后,编译器编译的代码是:
//#include
//预处理阶段头文件也被包含了
int main()
{
int i = 0;
for (i = 0; i < 10; i++)
{
printf("%d\n", i);
}
return 0;
}
执行结果:
我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。 这种替换的方式很简单: 预处理器先删除这条指令,并用包含文件的内容替换。 这样一个源文件被包含10次,那就实际被编译10次。
头文件被包含的方式:
#include "filename"
#include
而文件的包含有两种:
#include
#include "stdio.h"
一种是用尖括号将要包含的文件括起来
另一种是用双引号将要包含的文件引起来
这两种方法,在某些情况下似乎都可行,那么这两种方法到底有什么区别呢?
< >:如果使用尖括号的方式对头文件进行包含,那么当代码运行到预处理阶段,将对头文件进行包含时,编译器会自动去自己的安装路径下查找库目录,若库目录中含有该头文件,则将其进行包含,若库目录下不存在该头文件,则提示编译错误。
" ":如果使用双引号的方式对头文件进行包含,那么当代码运行到预处理阶段,将对头文件进行包含时,编译器会首先去正在编译的源文件目录下进行查找,若没有找到目标头文件,则再去库目录下进行查找,若两处都没有找到目标头文件,则提示编译错误。
< >:一般用于包含C语言提供的库函数的头文件。
" ":一般用于包含自定义的头文件。关于头文件,还有一点值得注意的是,当我们使用#include来包含头文件时,如果我们重复包含同一个头文件,那么在预处理阶段就会重复包含该头文件的内容,会大大加长代码量,导致代码冗余。
解决方案:
方案1:
#ifndef __ADD_H__
#define __ADD_H__
//头文件内容
#endif
当第一次包含该头文件时,会用 #define 定义符号 __ADD_H__ ,当第二次重复包含该头文件时,因为 __ADD_H__ 已经被定义过,就无法再次包含该头文件的内容了。
方案2:
#pragma once
//头文件内容
只需在头文件开头加上这句代码,那么该头文件就只会被包含一次。