系列文章目录
文章目录
1. 程序的翻译环境和执行环境
C语言的程序开发涉及两个关键环境:翻译环境和执行环境。这两个环境分别定义了C程序从源代码到可执行代码的转换过程以及程序的运行过程。
1.1 翻译环境
翻译环境是C程序从源代码转换成可执行代码的过程。在这个环境中,编译器执行以下几个步骤:
-
预处理(Preprocessing):这是编译过程的第一步,处理源代码文件中的预处理指令,如宏定义(#define)、条件编译(#ifdef、#ifndef)和文件包含(#include)等。
-
编译(Compilation):在预处理之后,预处理器输出的结果被送往编译器。编译器分析和转换代码,检查语法错误,并将代码转换成中间代码或直接转换成机器语言。
-
汇编(Assembly):中间代码通常需要进一步转换成汇编代码,然后由汇编器转换成机器可执行的二进制格式。
-
链接(Linking):链接器接着将多个对象文件和库合并成一个单一的可执行文件。在这个阶段,外部函数和变量的引用被解决。
1.2 执行环境
执行环境则是指编译后的程序实际运行的环境。它从程序开始执行的那一刻起直到程序运行结束。在执行环境中,操作系统和硬件提供必要的资源和平台支持。执行环境主要涉及以下方面:
-
加载(Loading):操作系统负责将可执行文件加载到内存中。
-
执行(Execution):CPU按照程序指令顺序执行操作,处理数据。
-
运行时库支持(Runtime Library Support):C标准库提供一系列标准函数,如输入输出处理、内存管理等,这些都是在执行时调用和执行的。
-
资源管理(Resource Management):操作系统管理程序所需的所有资源,如内存、文件处理、并发执行线程等。
-
终止(Termination):程序执行完毕后,操作系统回收所有分配给程序的资源,并清理环境,准备下一次程序的运行。
1.3 代码过程示例
#include <stdio.h>
#define A 10
#define B 20
int main() {
int result = A + B;
printf("The result is %d\n", result);
return 0;
}
1. 预处理阶段
预处理器处理源代码中的预处理指令。在我们的示例中,预处理器将会执行以下操作:
- 处理#include <stdio.h>,将标准输入输出库的内容包含进来,这样程序中的printf函数才能被正确识别和使用。
- 处理#define A 10 和 #define B 20,这两个宏定义将在源代码中的所有A和B被替换为10和20。
2. 编译阶段
在预处理后,预处理过的代码被送到编译器。编译器做的工作包括:
- 语法分析:编译器检查代码是否符合C语言的语法规则。
- 语义分析:检查表达式和赋值操作等是否语义合法。
- 代码优化:编译器可能会进行一些优化,例如简化算术运算。
- 生成中间代码:编译器将C代码转换成中间代码(通常是汇编代码)。
例如,int result = A + B;在预处理后变成int result = 10 + 20;,然后可能会被编译器进一步优化为int result = 30;。
3. 汇编阶段
中间代码接下来被转换成机器语言对应的汇编代码。这些汇编代码是特定平台的指令,例如x86指令集。
4. 链接阶段
最后,汇编代码被转换成机器语言,并和其他必需的代码或库链接在一起形成最终的可执行文件。这包括解决外部库函数(如printf
)的引用和地址分配。
2. 预处理详解
2.1 预定义符号
- __FILE__:这个宏在程序编译时,会被替换成当前源文件的名称。它是一个字符串字面量。
- __LINE__:在源代码中的任何位置使用这个宏,它会被替换为一个整型字面量,代表宏所在行的行号。
- __DATE__:这个宏提供了一个字符串字面量,内容是源文件被编译的日期,格式为"MMM DD YYYY"(月 日 年)。
- __TIME__:这个宏会被替换成源文件编译时的具体时间,格式为"HH:MM:SS"(时:分:秒)。
- __STDC__:如果程序遵循ANSI C标准,这个宏会被定义。通常,如果__STDC__被定义,它会被赋值为1。
这些宏可以直接用在程序中,例如在打印语句中使用,在运行时输出文件名和行号。
printf("file:%s line:%d\n", __FILE__, __LINE__);
当这条printf语句被执行时,它会打印出当前源代码文件的名称和这条printf语句的行号。如果在main.c文件的第10行有这条语句,它的输出是:
file:main.c line:10
2.2 #define
2.2.1 #define 定义标识符
#define是一种宏定义,它告诉预处理器在实际编译之前将所有出现的宏名称替换为指定的代码片段或值。
#define name stuff
name是宏的名称,stuff是当宏被调用时要替换成的代码或值。
代码示例:
#define MAX 1000 //定义了一个常量MAX,它将在代码中替换为1000。
#define reg register //为关键字register定义了一个别名reg,在声明寄存器变量时可以使用。
#define do_forever for(;;) //定义了一个宏do_forever,在代码中扩展为一个无限循环的for语句。
#define CASE break;case //定义了一个宏CASE,它在switch语句中用来替换多个连续的case标签,在写case语句的时候自动把 break写上。
// 如果定义的 stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n" ,\
__FILE__,__LINE__ , \
__DATE__,__TIME__ )
#define
宏在使用时不应有分号结尾的原因。分号不是宏定义的一部分,如果在宏定义中包含分号,它会成为替换文本的一部分,可能导致编译错误或逻辑错误。
#define MAX 1000; // 错误:宏定义不应包含分号
#define MAX 1000 // 正确:宏定义应该没有分号
2.2.2 #define 定义宏
#define不仅可以定义常量,还可以定义带有参数的宏,这些宏被称为宏函数。它们看起来和实际的函数调用非常相似,但是在预处理阶段发生文本替换,没有函数调用。
宏函数的定义格式如下:
#define name(parameter_list) stuff
parameter_list是参数列表,而stuff是当宏被调用时展开的代码。参数在stuff中使用时,每次出现都会被相应的实参替换。
#define SQUARE(x) (x * x)
//这个宏函数,计算参数x的平方。
使用注意:
因为宏展开是基于文本的替换,可能会产生预期之外的行为。比如:
int a = 5;
printf("%d\n", SQUARE(a + 1));
期望输出的是36,但由于宏展开为(a + 1 * a + 1),实际输出的是11。这是因为*的优先级高于+,所以先进行了乘法运算。
为了避免这种情况,应该在宏定义中使用括号包围参数和整个宏体:
#define SQUARE(x) ((x) * (x))
#define DOUBLE(x) ((x) + (x))
在这个例子中,无论传入的是变量、常量还是表达式,宏都会正确地展开,过程为:printf ("%d\n",(a + 1) * (a + 1) );
下面另一个例子说明了操作符优先级可能导致的问题:
int a = 5;
printf("%d\n", 10 * DOUBLE(a));
如果没有适当的括号,期望输出的是100,但由于宏展开为10 * (a) + (a),实际输出的是55。
所以解决方法为:
#define DOUBLE( x) ( ( x ) + ( x ) )
2.2.3 #define的撤销
当用#define定义了一个宏,它就会在源文件中剩余的部分一直有效。但是,如果你想在某一点后不再使用这个宏,你可以使用#undef指令来取消它的定义。
使用#undef的一些情况:
- 避免命名冲突:当有多个库被包含在一个项目中,可能会出现宏命名冲突的情况。使用#undef可以撤销先前定义的宏,以确保不会发生意外的宏展开。
- 限制宏的作用域:如果你只想在源文件的某个特定部分使用宏,可以在宏不再需要时使用#undef来取消它,这样可以避免它在文件的其余部分产生影响。
- 重定义宏:有时可能需要根据不同的条件改变宏的定义。你可以先#undef掉旧的宏定义,然后重新用#define来定义新的。
注意事项:
- 你不能在宏定义中使用
#undef
来取消宏本身的定义。因为#undef
只能用于全局范围,不能在宏定义的展开中使用。 - 即使一个宏之前没有被定义,使用
#undef
也不会产生错误,编译器会忽略对未定义宏的撤销请求。
例如,如果你先定义了一个宏,然后在文件中的后续部分不再需要它,可以这么做:
#define PI 3.14159
/* 使用PI的代码 */
#undef PI
/* PI宏在这里不再有效 */
2.2.4 带副作用的宏参数
x+1;//不带副作用
x++;//带有副作用
代码示例,宏定义:计算两个值中的最大值:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
如果宏的参数中包含了诸如x++或y++这样的副作用操作(在C语言中,x++表示x的值会在使用后增加1),使用这个宏可能会导致意外的结果。因为在宏替换过程中,这些带副作用的表达式可能会被求值多次。
int x = 5;
int y = 8;
int z = MAX(x++, y++);
本意是要计算x
和y
之间的最大值,然后将它们各自增加1。但是,由于宏展开,这行代码变成了:
int z = ((x++) > (y++) ? (x++) : (y++));
这导致x或y可能增加了两次,而不是一次,因为>运算符先比较了x和y,然后选择的分支再次对x或y进行了自增操作。这就产生了副作用。因此,实际结果与预期不同,变量的最终值可能是:
x = 6,y = 10,z = 9
这是因为x首先增加到6,然后y增加到9,因为此时y大于x,所以y被选择为z的值,然后y再次增加,变为10。
2.2.5 宏和函数对比
宏(Macro)和函数(Function)在C语言中都用于代码重用,但它们在编译过程和运行时的行为有本质的不同。
宏是预处理器概念,是一种文本替换工具。宏不关心数据类型,因为它们在编译前就被文本替换了。
函数是编程的基本结构,用于封装代码以执行特定的任务。
属性
|
#define定义宏
|
函数
|
代码长度
|
每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长
|
函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码
|
执行速度 |
更快
|
存在函数的调用和返回的额外开销,所以相对慢一些
|
操作符优先级 |
宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括
号。
|
函数参数只在函数调用的时候求值一次,它的结果值传递给函
数。表达式的求值结果更容易预测。
|
带有副作用的参数 |
参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。
|
函数参数只在传参的时候求值一次,结果更容易控制。
|
参数类型 |
宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型。
|
函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是相同的。
|
调试 |
不方便调试
|
可以逐语句调试
|
递归 |
不能递归
|
可以递归
|
2.2.6 条件编译
条件编译是C语言预处理器提供的一种功能,它允许程序在编译时根据特定的条件来包含或排除代码部分。条件编译通常是用预处理指令来实现的,最常用的有#if、#ifdef、#ifndef、#else、#elif和#endif。
#if指令允许根据一个常量表达式的值来包含或排除代码段。如果表达式的结果为真(非零),则编译接下来的代码,否则跳过。
#if defined(WINDOWS)
// Windows平台特有的代码
#elif defined(LINUX)
// Linux平台特有的代码
#else
// 其他平台的代码
#endif
#ifdef 和 #ifndef 是 #if defined(...) 和 #if !defined(...) 的简写形式。它们用于检查一个宏是否已定义。
#ifdef DEBUG
// 仅在DEBUG模式下编译的代码
#endif
#ifndef PI
#define PI 3.14159
#endif
//如果DEBUG宏已定义,则编译与调试相关的代码。如果PI宏未定义,则定义它。
组合条件编译
可以组合这些指令来实现更复杂的条件编译逻辑。例如可以根据多个宏的定义来包含或排除代码。
#if defined(USE_FEATURE_X) && !defined(USE_FEATURE_Y)
// 仅当USE_FEATURE_X定义而USE_FEATURE_Y未定义时编译这部分代码
#endif
2.2.7 嵌套文件包含
嵌套文件包含是指在一个头文件中包含另一个头文件的情况。这通常用于管理和组织大型项目中的代码依赖。通过嵌套包含,开发者可以确保在编译源文件时,所有必要的声明和宏定义都是可用的。
代码示例
假设有两个头文件:file1.h和file2.h,以及一个源文件main.c。
// file1.h
#include "file2.h"
// 其他的声明和宏定义...
// file2.h
// 一些声明和宏定义...
// main.c
#include "file1.h"
// main函数和其他代码...
在这个例子中,当main.c包含file1.h时,file1.h又会包含file2.h,从而形成了嵌套包含。这意味着在main.c中,来自file2.h的声明和宏定义也将可用。
头文件保护示例
// file2.h
#ifndef FILE2_H
#define FILE2_H
// 一些声明和宏定义...
#endif // FILE2_H
// file1.h
#ifndef FILE1_H
#define FILE1_H
#include "file2.h"
// 其他的声明和宏定义...
#endif // FILE1_H
在这个示例中,FILE1_H和FILE2_H就是这样的预处理宏,它们确保了相关头文件的内容只被包含一次,无论它们在项目中被包含了多少次。