文章目录
C语言程序环境与预处理
1.程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码。
为了更好的体会以上两个环境作用的时段,笔者以vs 2022举例:
翻译环境:
首先我们创建一个名为test . c的源文件。
然后在创建的test . c源文件中输入我们的代码,并且编译。
然后在我们的项目工程目录下会产生一个叫test . exe 的可执行文件。
以上的过程都是翻译环境的作用时段。
执行环境:
我们双击打开test.exe,在我们运行产生的test . exe可执行程序进行输入或输出直到程序结束过程都是执行环境的作用时段。
2.详解编译+链接(翻译环境)
翻译环境所处的过程可细分为预编译(预处理)、编译、汇编、链接。
在程序编译的过程中会把像test . c这样的源文件通过编译器转换成目标文件,然后几个目标文件和链接库通过链接器链接成一个可执行程序。其中链接库就是提供函数的库,比如我们调用printf函数就要调用库。
目标文件会在编译后出现在项目工程文件夹内。
以obj为文件后缀。
组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人 的程序库,将其需要的函数也链接到程序中。
在这里补充一个点:
以笔者用的vs 2022举例,vs 2022由叫做集成开发环境(IDE),之所以叫做集成开发环境是因为集成了编辑(写代码)、编译、链接、调试功能,也就是集成了编辑器、编译器、连接器、调试器在一起。
2.1.预编译(预处理)
程序在翻译环境中第一个进行的操作就是预编译,预编译包含以下内容:
1.头文件的包含(把头文件的内容包含到一起)。
2.注释的删除。
3.#define 符号的替换(假如在代码部分#define MAX 100,在预编译时代码里的所有MAX都替换成了100)。
结论:预编译期间进行的是文本操作,因为得到的结果是一个文本内容,这个文本内容包含了(头文件内容和把#define 符号替换后的代码)。
2.2.编译
编译过程会把C语言代码转化成汇编代码,编译具体所作的内容如下:
1.语法分析
2.词法分析
3.语义分析
4.符号汇总
注:关于以上过程的详解内容可以在《编译原理》相关的课程中学习,在此就不细说了。
前三点不过就是编译器在阅读代码含义,好用来转换成汇编代码。
在这里要提一下符号汇总:符号汇总汇总的符号包括各个函数的名字(比如main、自定义函数名和库函数名字)和全局变量。
比如我在test . c和add . c两个源文件中写代码,符号汇总就会汇总g_val、Add(两个源文件都含有Add符号他们会分别在各自的文件下)、main和printf。
2.3.汇编
汇编过程会把汇编代码转换成二进制代码(计算机能读懂的代码),生成目标文件。
在汇编过程中会形成符号表。
继续据上面的函数的例子(忽略printf、g_val)会生符号表:
因为Add函数是定义在add.c源文件内的,而test.c文件中的Add只是个声明,所以add.c的符号表内Add符号有个具体的地址,
而test.c的符号表中Add的地址是个虚拟的地址(图内地址均虚构只为举例说明)。
2.4.链接
在汇编会生成的目标文件内会包含段表和符号表,在链接时会把所有源文件段表内的相同数据合并起来,还会把符号表合并并且重定义。
符号表重定义就是在把符号表的符号合并到一个符号表后,如果多个符号表有相同的符号,该符号后的地址会变成几个符号表内有效的那个地址 (程序执行时调用函数就是利用的符号表中的地址调用) ,这就是为什么如果函数只是声明而没有定义,编译器会报错,因为合并符号表时,符号表内的函数地址还是无效地址,调用函数时调用的是无效地址(如下图)。
2.5.总结
3.执行环境
程序执行的过程:
- 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序 的载入必须由手工安排,可能是通过可执行代码置入只读内存来完成
- 程序的执行便开始。接着便调用main函数。
- 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回 地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程 一直保留他们的值。
- 终止程序。正常终止main函数;也有可能是意外终止。
4.预处理指令
4.1预定义符号
__FILE__ //进行编译的源文件 __LINE__ //文件当前的行号 __DATE__ //文件被编译的日期 __TIME__ //文件被编译的时间 __STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义 // 这些预定义符号都是语言内置的。
//代码实现
#include <stdio.h>
int main()
{
printf("%s\n", __FILE__);
printf("%d\n", __LINE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
return 0;
}
结果演示:
显然笔者的vs 2022是不遵顼ANSI C的。
4.2#define
4.2.1#define 定义标识符
//语法
#define name stuff
//定义的内容可以指数字、字符串甚至是代码。
//代码实现
#include <stdio.h>
#define MAX 100
#define NAME "csdn"
#define do_forever for(;;)
int main()
{
printf("%d\n", MAX);
printf("%s\n", NAME);
do_forever;
return 0;
}
结果演示:
说明:
#define定义标识符是将定义的内容原原本本的替换,比如上面的代码替换后就变成了:
//代码实现
#include <stdio.h>
#define MAX 100
#define NAME "csdn"
#define do_forever for(;;)
int main()
{
printf("%d\n", 100);
printf("%s\n", "csdn");
for(;;);
return 0;
}
注意:
不要随意在定义标识符后加;
//
#include <stdio.h>
#define MAX 100;
int main()
{
printf("%d\n", 100;);//如果我定义的MAX为100;替换后就变成了100;出现程序错误
return 0;
}
4.2.2#define定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义 宏(define macro)。
下面是宏的申明方式:
#define name( parament - list ) stuff
其中的 parament - list 是一个由逗号隔开的符号表,它们可能出现在stuff中。
注意:
参数列表的左括号必须与name紧邻。 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
//代码实现
#include <stdio.h>
#define ADD(x,y) (x + y)
int main()
{
int x = 10;
int y = 20;
printf("%d\n", ADD(x, y));
return 0;
}
结果演示:
注意:
以下是运用#define定义宏可能出现的问题:
//示例1.1
#include <stdio.h>
#define SQUARE(x) x * x
int main()
{
int x = 3;
printf("%d\n", SQUARE(x));
return 0;
}
结果看似没问题,但如果我们看下面的代码呢?
//示例1.2
#include <stdio.h>
#define SQUARE(x) x * x
int main()
{
int x = 3;
printf("%d\n", SQUARE(x + 1));
return 0;
}
为什么变成了7?是因为#define定义宏和#define定义标识符一样是原原本本的替换
//示例1.2替换后
#include <stdio.h>
#define SQUARE(x) x * x
int main()
{
int x = 3;
printf("%d\n", 3 + 1 * 3 + 1);
return 0;
}
解决实例1的方法很简单:只要加上一个括号:
//示例1解决
#include <stdio.h>
#define SQUARE(x) (x) * (x)
int main()
{
int x = 3;
printf("%d\n", SQUARE(x + 1));
return 0;
}
结果演示:
我们再来看下面的示例:
//示例2.1
#include <stdio.h>
#define DOUBLE(x) (x) + (x)
int main()
{
int x = 3;
printf("%d\n", DOUBLE(x));
return 0;
}
结果演示:
我们加了括号规避上面的问题,可以我们再看下面的代码呢?
//示例2.2
#include <stdio.h>
#define DOUBLE(x) (x) + (x)
int main()
{
int x = 3;
printf("%d\n", 2 * DOUBLE(x));
return 0;
}
结果演示:
//示例2.2替换后
//示例2.2
#include <stdio.h>
#define DOUBLE(x) (x) + (x)
int main()
{
int x = 3;
printf("%d\n", 2 * (3) + (3));
return 0;
}
//解决这个问题还是只需要加一个括号
//示例2解决
#include <stdio.h>
#define DOUBLE(x) ((x) + (x))
int main()
{
int x = 3;
printf("%d\n", 2 * DOUBLE(x));
return 0;
}
结果演示:
4.2.3#define的替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先 被替换。
- 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上 述处理过程。
关于第1点:
//示例
#include <stdio.h>
#define M 10
#define DOUBLE(x) ((x) + (x))
int main()
{
int x = 3;
printf("%d\n", 2 * DOUBLE(M));
return 0;
}
//替换成:
#include <stdio.h>
#define M 10
#define DOUBLE(x) ((x) + (x))
int main()
{
int x = 3;
printf("%d\n", 2 * DOUBLE(10));
return 0;
}
注意:
1**.宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。**
- 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
关于第二点:
//示例
#include <stdio.h>
#define M "csdn"
int main()
{
printf("hello M\n");
return 0;
}
结果演示:
4.2.4#和##
4.2.4.1
在了解#之前我们先需要知道在C语言中有一种语法规则:
#include <stdio.h>
int main()
{
printf("hello ""world!\n");
printf("hello world!\n");
return 0;
}
结果演示:
字符串是能够自动连接到一起。
接下来我们还需要看一段代码:
#include <stdio.h>
int main()
{
int a = 3;
printf("the value of a is %d\n", a);//(1)
double b = 4;
printf("the value of b is %f\n", b);//(2)
return 0;
}
结果演示:
我们看这段代码的(1)和(2),如果只有两行还好,如果有很多行就会造成代码的冗余,为了解决这个问题我们可以使用#。
#include <stdio.h>
#define PRINT(value, format) printf("the value of "#value" is "format"\n", value)
int main()
{
int a = 3;
PRINT(a, "%d");
double b = 4;
PRINT(b, "%f");
return 0;
}
//替换后
int main()
{
int a = 3;
printf("the value of ""a"" is ""%d""\n", a);
double b = 4;
printf("the value of ""b"" is ""%f""\n", b);
return 0;
}
结果演示:
使用 # ,可以把一个宏参数变成对应的字符串。
4.2.4.2
##可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符。
//示例
#include <stdio.h>
#define CAL(A, B) A##B
int main()
{
int my_int = 100;
printf("%d\n", CAL(my_, int));
return 0;
}
结果演示:
4.2.5带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能 出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
// x + 1 无副作用
// x++ 有副作用
//示例
#include <stdio.h>
#define MAX(a, b) ((a)>(b)?(a):(b))
int main()
{
int a = 3;
int b = 4;
printf("%d\n", MAX(++a, ++b));
printf("a is %d b is %d\n", a, b);
return 0;
}
结果演示:
4.2.6宏和函数的对比
宏的优点:
1.用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。 所以宏比函数在程序的规模和速度方面更胜一筹。
2.更为重要的是函数的参数必须声明为特定的类型。 所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以 用于>来比较的类型。 宏是类型无关的。
宏的缺点:
- 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序 的长度。
- 宏是没法调试的。(宏会在预处理时期替换,在汇编时变成汇编语言无法调试)
- 宏由于类型无关,也就不够严谨。
- 宏可能会带来运算符优先级的问题,导致程容易出现错。
宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。
比如:
#include <stdio.h>
#define MALLOC(num, type) malloc(num * sizeof(type))
int main()
{
int* p = MALLOC(10, int);
return 0;
}
宏和函数的一个对比
属性 | #define定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常 小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每 次使用这个函数时,都调用那个 地方的同一份代码 |
执行速度 | 更快 | 存在函数的调用和返回的额外开 销,所以相对慢一些 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里, 除非加上括号,否则邻近操作符的优先级可能会产生 不可预料的后果,所以建议宏在书写的时候多些括 号 | 函数参数只在函数调用的时候求 值一次,它的结果值传递给函 数。表达式的求值结果更容易预 测。 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作 用的参数求值可能会产生不可预料的结果。 | 函数参数只在传参的时候求值一 次,结果更容易控制。 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的, 它就可以使用于任何参数类型。 | 函数的参数是与类型有关的,如 果参数的类型不同,就需要不同 的函数,即使他们执行的任务是 相同的。 |
调试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递归 | 宏是不能递归的 | 函数是可以递归的 |
4.2.7宏的命名
一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。 那我们平时的一个习惯是:
把宏名全部大写
把函数名不要全部大写
4.3#undef
用来取消宏定义
//示例代码
#include <stdio.h>
#define MAX 10
int main()
{
printf("%d\n", MAX);
#undef MAX
printf("%d\n", MAX);
return 0;
}
4.4命令行定义
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。 例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处。(假定某个 程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器 内存大些,我们需要一个数组能够大些。)
//示例
//比如如下的代码,虽然未定义SZ的值,但是可以在预编译之前,通过命令行语句定义SZ的值
//从而实现不改动代码,改动程序功能
#include <stdio.h>
int main()
{
int arr[SZ];
for (int i = 0; i < SZ; i++)
{
scanf("%d", &arr[i]);
}
return 0;
}
4.5条件编译
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件 编译指令。
比如说:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
//示例1
#include <stdio.h>
#define __DEBUG__
int main()
{
int i = 0;
int arr[10] = { 0 };
for (i = 0; i < 10; i++)
{
arr[i] = i;
#ifdef __DEBUG__//如果我们定义了__DEBUG__条件成立下面的代码会在预处理时留下,注意:在这里只要__DEBUG__符号定义即可
//无需定义具体内容
printf("%d\n", arr[i]);//为了观察数组是否赋值成功。
#endif //ifdef包含的代码范围结束
}
return 0;
}
//预处理时
#include <stdio.h>
#define __DEBUG__
int main()
{
int i = 0;
int arr[10] = { 0 };
for (i = 0; i < 10; i++)
{
arr[i] = i;
printf("%d\n", arr[i]);
}
return 0;
}
//示例2
#include <stdio.h>
//#define __DEBUG__
int main()
{
int i = 0;
int arr[10] = { 0 };
for (i = 0; i < 10; i++)
{
arr[i] = i;
#ifdef __DEBUG__//如果我们定义了__DEBUG__条件成立下面的代码会在预处理时留下
printf("%d\n", arr[i]);//为了观察数组是否赋值成功。
#endif //ifdef包含的代码范围结束
}
return 0;
}
//预处理
#include <stdio.h>
int main()
{
int i = 0;
int arr[10] = { 0 };
for (i = 0; i < 10; i++)
{
arr[i] = i;
}
return 0;
}
常见的条件编译指令:
#if 常量表达式
//代码
#endif
如:
#define DEBUG 1
#if DEBUG
//代码
#endif
2.多个分支的条件编译
#if 常量表达式
//代码
#elif 常量表达式
//代码
#else
//代码
#endif
3.判断是否被定义
#if defined (symbol)
#if def 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
条件编译与普通的判断语句使用起来十分相似,但条件编译的优点是在预编译时期,就删掉了不需要的代码,省去了在运行时期再判断的时间。
关于条件编译的使用时非常有必要的,比如我打开vs2022的stdio . 头文件随便找个一段代码:
条件编译是十分需要被使用的。
4.6文件包含
我们已经知道了如果我们用#include 指令包含头文件,会使该头文件的内容在预处理时期被包含,就像头文件里的内容实际出现于#include指令后面。
这个过程十分简单:
首先把#include 头文件这条指令删除,然后把头文件里的内容包含进来
如果包含了几次头文件,头文件的内容就会被几次包含。
4.6.1头文件的包含方式
1.自定义头文件类型:
比如 #include “head.h”
用""包含的头文件首先会在源文件所在文件目录中寻找,如果找不到该头文件,会在库函数头文件里寻找该头文件。
如果没有找到头文件会提示编译错误。
2.库文件:
比如 #include <stdio.h>
用<>包含的头文件会直接在标准库函数头文件里寻找,如果找不到,会提示编译错误。
看完两种文件包含方式,我们是不是可以说无论什么头文件我们都可以用""来包含?
结论当时是: 可以
但问题是如果我们包含的是库文件,那样会多出一步在源文件所在文件目录下寻找的步骤,降低程序执行效率,也不容易区分那些包含是库文件哪些是自定义文件。
4.6.2嵌套文件包含
由于test1.h文件和test.c文件需要包含test2.h、test2.c和test3.h、test3.c文件,
而test2.h、test2.c和test3.h、test3.c文件都需要包含common.h、common.c文件,
因此common.h、common.c文件被包含了两次。
如上图的场景,我们之前提到:文件#include 文件出现一次,文件内容就会被包含一次,这样就会造成同样的文件内容多次包含的问题,导致代码的冗余。
那么如何解决这个问题呢?
答案是:条件编译。
//我们可以在自定义的头文件内加入如下的语句
#ifndef __TEST_H__
#define __TEST_H__
//头文件内容
#endif
还可以在自定义头文件中加入一句
#pragma once
//这条语句会比条件编译来的简单,但是在一些古老的编译器中该语句是不适用的。
4.7其他预处理指令
当然C语言的预处理指令是十分多的,只靠一篇小小的文章是介绍不完的,在本文中就不过多介绍了。
创作不易,感谢阅读,如果问题还请指出。
1.自定义头文件类型:
比如 #include “head.h”
用""包含的头文件首先会在源文件所在文件目录中寻找,如果找不到该头文件,会在库函数头文件里寻找该头文件。
如果没有找到头文件会提示编译错误。
2.库文件:
比如 #include <stdio.h>
用<>包含的头文件会直接在标准库函数头文件里寻找,如果找不到,会提示编译错误。
看完两种文件包含方式,我们是不是可以说无论什么头文件我们都可以用""来包含?
结论当时是: 可以
但问题是如果我们包含的是库文件,那样会多出一步在源文件所在文件目录下寻找的步骤,降低程序执行效率,也不容易区分那些包含是库文件哪些是自定义文件。
4.6.2嵌套文件包含
[外链图片转存中…(img-6yqEQRnw-1665822329407)]
由于test1.h文件和test.c文件需要包含test2.h、test2.c和test3.h、test3.c文件,
而test2.h、test2.c和test3.h、test3.c文件都需要包含common.h、common.c文件,
因此common.h、common.c文件被包含了两次。
如上图的场景,我们之前提到:文件#include 文件出现一次,文件内容就会被包含一次,这样就会造成同样的文件内容多次包含的问题,导致代码的冗余。
那么如何解决这个问题呢?
答案是:条件编译。
//我们可以在自定义的头文件内加入如下的语句
#ifndef __TEST_H__
#define __TEST_H__
//头文件内容
#endif
还可以在自定义头文件中加入一句
#pragma once
//这条语句会比条件编译来的简单,但是在一些古老的编译器中该语句是不适用的。
4.7其他预处理指令
当然C语言的预处理指令是十分多的,只靠一篇小小的文章是介绍不完的,在本文中就不过多介绍了。
创作不易,感谢阅读,如果问题还请指出。