前言:
我们是否还记得书上的第一章,其中一个定义,而且还常考:在代码执行时,在经过编译器编译后生成目标文件后缀为.obj的文件,经过链接器生成可执行文件后缀为.exe文件。
这是为啥?我们从来都没有考虑过这些,那么今天我们就来讲解一下。
环境的组成:
环境分为编译环境和执行环境。
在执行C语言文件时,都会存在两个不同的环境:
- 第一种是翻译环境,在这个环境中源代码被转换为可执行的机器指令(二进制指令)
- 第二种是执行环境,他用实际执行代码
在代码执行时,在经过编译器编译后生成目标文件后缀为.obj的文件,经过链接器生成可执行文件后缀为.exe文件。在这个期间我们称之为翻译环境,生成可执行文件后,我们称之为执行环境。翻译环境中,源代码被转化为可执行的机械命令。翻译环境可以理解为编译加链接;执行环境,它用于实际执行代码。
一个C语言的项目种可能有多个.c文件,那多个.c文件如何生成可执行程序呢?
- 多个.c文件单独经过编译器,编译处理生成对应的目标文件
- 多个目标文件和链接库一起经过连接器处理生成最终的可执行文件
- 注:在Windows环境下的目标文件后缀是.obj,Linux环境下目标文件后缀是.o
翻译环境:
编译环境的组成:
对,编译环境也是由组成成分的。
在VS中,编译器的名字叫做:cl.exe
翻译环境是由编译和链接两个大的过程组成的,而编译又可以分解为:预处理(也叫预编译)、编译、汇编三个过程。
在编译期间会发生多个步骤:
第1个步骤叫预编译,也称预处理。在这期间会把所有的注释和宏都替换掉,这一小个步骤在Linux系统gcc上会生成后缀为.i的文件。
第2个步骤叫编译。把C语言代码翻译成二进制汇编代码,在这期间会进行语法分析,词法分析,符号汇总,语义分析。符号汇总是把每一个函数看作符号或者全局变量,也看作符号,把它们汇总起来,这一小个阶段,在Linux系统gcc上会生成一个后缀为.s的文件。
第3个步骤是汇编,把汇编代码转化为二进制指令,形成每个源文件的符号表。符号表可以理解为把每一个函数都填入对应的地址并形成一个表格。在这一步中,在Linux系统gcc上会生成一个后缀为.o的文件,在windows系统上会生成一个后缀为.obj的文件。
编译过程总览:
生成目标文件:
我们可以在VS中编译文件,周会在Debug目录下生成该目标文件。
注意:目标文件是二进制文件,不能直接打开。
此时需要通过链接才能生成可执行程序(VS连接器:link.exe)。
因为VS是高度集成的开发环境,所以有很多细节我们看不到。
预处理(预编译)过程:
#include相当于把stdio.h中的信息拷贝到所写的文件中去。
预处理阶段主要处理那些源文件中#开始的预编译指令。比如:#include,#define。规则如下:
- 将所有的#define删除,并展开所有的宏定义
- 处理所有的条件编译指令,如:#if、#ifdef、#elif、#else、#endif
- 处理#include预编译指令,将包含的头文件的内容插入到该预编译指令的位置。这个过程是递归进行的,也就是说被包含的头文件可能包含其他文件
- 删除所有注释
- 添加行号和文件名标识,方便后续编译器生成调试信息
- 或保留所有的#pragma的编译器指令,编译器后续会使用
经过与处理后的.i文件不再包含宏定义,因为宏已经被展开。并且包含的头文件都被插入到.i文件中。所以我们无法知道宏定义或者头文件是否包含正确的时候,可以查看预处理后的.i文件来确认。
编译过程和汇编过程:
编译过程就是将预处理(预编译)后的文件进行一系列的:词法分析、语法分析、语义分析及优化,生成相应的汇编代码文件。还会进行符号汇总。
汇编过程会形成符号表,把汇编的代码翻译成二进制的指令,生成.o文件(目标文件)。
我们在一个项目中,使用两个.c文件,其中一个实现一个函数,另外一个声明这个函数并使用。
链接是一个复杂的过程,链接的时候需要把一堆文件链接在一起才生成可执行程序。
链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。
链接解决的是一个项目中多文件、多模块之间的互相调用的问题。
运行环境:
程序执行的过程:
1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2. 程序的执行便开始。接着便调用main函数。
3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack,也成函数栈帧),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
4. 终止程序。正常终止main函数;也有可能是意外终止。(《程序员的自我修养》)
预处理详解:
宏的定义:
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)。
宏参数和#define 定义中可以出现其他#define 定义的符号。但是对于宏,不能出现递归。
宏的一些符号:
C语言设置了一些预定义的符号,也是在预处理期间处理的。
举个栗子:
- #define
- __FILE__ //进行编译的源文件
- __LINE__ //文件当前的行号
- __DATE__ //文件被编译的日期
- __TIME__ //文件被编译的时间
比如我们进行运行:
int main()
{
printf("%s\n", __FILE__);//代码所在的文件绝对路径
printf("%d\n", __LINE__);//代码所在行数
printf("%s\n", __DATE__);//文件被编译的日期
printf("%s\n", __TIME__);//文件被编译的时间
//printf("%d\n", __STDC__);//如果编译器遵循ANSI C,其值为1,否则未定义
//VS不支持ANSIC
return 0;
}
前面说预编译会将宏定义替换,其实宏定义也就是整体替换。
#define MAX 100;
int main()
{
int a = MAX;;
printf("%d\n", a);
return 0;
}
利用宏写一个死循环:
比如我们利用#define写一个死循环。
#define forever for(;;)
int main()
{
//for循环的判断部分什么都不写的时候,表示恒成立
//for (; ; );
forever;
return 0;
}
利用宏在switch不写break语句:
我们可以利用宏实现在switch语句中不写break。
#define CASE break;case
int main()
{
int n = 0;
switch (n)
{
case 1:
//
CASE 2:
//
CASE 3:
//
CASE 4:
}
return 0;
}
续行符:
只会出现在宏中,一般是语句太长了,我们需要提供更高的可读性,要使用续行符。
#define DEBUG_PRINT printf("file:%s\tline:%d\tdata:%s\n",__FILE__,__LINE__,__DATE__)
int main()
{
DEBUG_PRINT;
return 0;
}
因为这样不方便看,一条语句太长了,所以我们可以利用续行符来进行换行,这样方便观察。
宏不要加分号:
我们使用#define定义时,不要加上分号。
宏的注意事项:
前面说预编译会将宏定义替换,其实宏定义也就是整体替换。
我们在使用宏进行计算的时候,一定要加上括号!
#define SQUARE(X) X*X
int main()
{
//int ret = SQUARE(5);
//printf("%d\n", ret);
int ret = SQUARE(5 + 1);
printf("%d\n", ret);
return 0;
}
此时我们加上括号就对了。
#define SQUARE(X) (X)*(X)//带上括号就可以优化
int main()
{
//int ret = SQUARE(5);
//printf("%d\n", ret);
int ret = SQUARE(5 + 1);
printf("%d\n", ret);
return 0;
}
再比如:
#define DOUBLE(X) (X)+(X)
int main()
{
int a = 5;
int ret = 10 * DOUBLE(a);
//int ret=10*a+a;
printf("%d\n", ret);
return 0;
}
因为宏定义是整体替换,所以我们在使用的时候,最好整体也加上括号。宏的参数不运算,直接替换进去。
#define DOUBLE(X) ((X)+(X))
int main()
{
int a = 5;
int ret = 10 * DOUBLE(a);
//int ret=10*a+a;
printf("%d\n", ret);
return 0;
}
我们在使用宏定义的时候要注意不能传错值最好,不要++a,--a的传。
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
int a = 10;
int b = 11;
int max = MAX(a++, b++);
//int max=((a++)>(b++)?(a++):(b++));
printf("%d\n", max);//12
printf("%d\n", a);//11
printf("%d\n", b);//13
return 0;
}
#和##的使用:
有一些宏定义可以实现函数做不到的事情,宏定义也有一些自己的优点。我们先了解一下printf函数的另一种使用方法。
int main()
{
printf("hello world\n");
printf("hello " "world\n");
printf("hel""lo " "world\n");
return 0;
}
#运算符将宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中。
#运算符所执行的操作可以理解为“字符串化”。
#define PRINT(X) printf("the value of "#X" is %d\n",X)
int main()
{
//printf("hello world\n");
//printf("hello " "world\n");
//printf("hel""lo " "world\n");
int a = 10;
int b = 20;
PRINT(a);
//printf("the value of ""a"" is %d\n",a);
PRINT(b);
//printf("the value of ""b"" is %d\n",b);
return 0;
}
显而易见#X可以替换为传过来的符号。
我们再了解一下##的使用。
##可以把位于它两边的符号合成为一个符号,它允许宏定义从分离的文本片段创建标识符。##被称为记号粘合。这样的连接必须产生一个合法的标识符。否则其结果就是为定义的。
#define CAT(X,Y) X##Y
int main()
{
int Class84 = 2019;
//printf("%d\n", class84);
printf("%d\n", CAT(Class, 84));
//printf("%d\n",Class##84);
//printf("%d\n",Class84);
return 0;
}
字符串常量内容不会被搜索:
当预处理搜索#define 定义的符号时,字符串常量的内容并不被搜索。
#define M 10
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
int m = MAX(M, 3);
printf("M = %d\n", M);
return 0;
}
宏和函数的区别:
宏的参数可以适用于整形、长整形、浮点型等可以用于>来比较。宏的参数是于类型无关的。
宏只会运用在小型的运算上面,大型运算还是要使用函数。
//函数
int Max(int x, int y)
{
return (x > y ? x : y);
}
//宏
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
int a = 10;
int b = 20;
int max = Max(a, b);
max = MAX(a, b);
return 0;
}
可以发现宏不需要返回类型方便一些,而且宏定义还有一个好处,可以直接传入类型。
当我们比较大小时,如果定义为一个函数,那么只能对其中一种类型比较,如果需要其他类型求和,这就需要再写一个其他函数进行比较,显得非常繁琐。
于是我们可以利用宏和##来完成,相当于函数的重载。
//使用宏,定义一个通用的函数模具
#define GENERATE_MAX(type) \
type type##_max(type x, type y) \
{\
return (x > y ? x : y);\
}
GENERATE_MAX(int)
//就相当于以下代码
//int int_max(int x, int y)\
//{\
// return (x > y ? x : y); \
//}
int main()
{
int m = int_max(3, 5);
//当写下该宏定义时,就相当于完成了该函数的模具
printf("%d\n", m);
return 0;
}
这里你可能会说,这个##起到了连接符的作用,那么能不能将其去掉呢?
去掉##预编译期间没有问题,但是检测语法是会出现问题。
简单总结一下:在预处理期间宏会完全被替换,它的速度更快,可是如果多次调用红,那么会大大增加程序的长度。函数在使用的时候传入和返回都会增加代码的开销,所以相对宏会更慢一些,可是如果多次调用还是建议使用函数。有时候宏得到的结果不可预料。宏不能调试函数,函数可以调试函数;宏不能递归函数,函数可以递归函数。宏可以传入类型,函数不能传入类型。
命名约定:
一般来说函数和宏的使用语法和相似,所以我们平时的习惯是:把宏名全部大写;函数名不要全部大写。但是也有例外:offsetof
#undef:
undef用于移除宏定义。
#define MAX 100
int main()
{
printf("%d\n", MAX);
#undef MAX
//移除宏定义
printf("%d\n", MAX);
return 0;
}
除非再次定义才可以使用:
#define MAX 100
int main()
{
printf("%d\n", MAX);
#undef MAX
//移除宏定义
#define MAX 100
printf("%d\n", MAX);
return 0;
}
条件编译:
条件编译定义:
条件编译:在编译一个程序的时候我们如果要将一条语句编译或放弃是很方便的,因为可以使用条件编译指令。
比如我们调试代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
预处理和执行过程区分:
#define M 3
int main()
{
int a = 3;
#if M == a
printf("hehe\n");
#endif
return 0;
}
这里没有打印是因为,a是局部变量,只有在程序运行时进入函数才会创建,而预编译阶段只是完成了宏的替换。
#ifdef 和 #endif:
#ifdef必须要和#endif联合使用。就是如果你定义了这个符号,就会执行这两句中的语句。如果你没有定义这个符号,就不会执行这一句。
#define DEBUG
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,0 };
int i = 0;
for (i = 0; i < 10; i++)
{
arr[i] = 0;
#ifdef DEBUG//如果DEBUG定义了,就执行下一句
printf("%d ", arr[i]);
#endif
}
return 0;
}
#if:
其实和if判断语句使用方法相同,但是也要结合#endif使用。
#define DEBUG
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,0 };
int i = 0;
for (i = 0; i < 10; i++)
{
arr[i] = 0;
#if 1//if跟常量表达式,为0是假,非零是真
printf("%d ", arr[i]);
#endif
}
return 0;
}
多分支的条件编译:
int main()
{
#if 1==1
printf("haha\n");
#elif 2==2
printf("hehe\n");
#else
printf("hihi\n");
#endif
return 0;
}
也可以进行嵌套,这里就不再进行举例。
像取反一样,条件宏定义也可以取反。
int main()
{
#if !defined(DEBUG)
printf("hehe\n");
#endif
return 0;
}
#ifndef:
#define DEBUG 0
int main()
{
#ifndef DEBUG//如果没有定义DEBUG就执行下面语句
printf("hehe\n");
#endif
return 0;
}
offsetof模拟实现:
offsetof函数是求结构体中成员的偏移量的函数,因为第1个成员总是从零偏移量开始,所以说我们可以让零强制转化为一个结构体指针之后减零,就可以得到其字节大小(注意:使用这个宏要引入stddef.h头文件)。
struct S
{
char a;
int b;
char c;
};
我们先来观察一下结构体的一个成员变量占据几个字节:
我们利用宏offsetof可以得知每个结构体成员的相对偏移量。
struct S
{
char a;
int b;
char c;
};
int main()
{
printf("结构体大小为:%d\n", sizeof(struct S));
printf("%d\n", offsetof(struct S, a));
printf("%d\n", offsetof(struct S, b));
printf("%d\n", offsetof(struct S, c));
return 0;
}
我们是否可以自己实现一个offsetof宏?答案是可以,接下来我们就来模拟实现。
模拟实现offsetof函数。我们需要将零强制转换为指针,然后使指针指向结构体成员之后减去0就可以得到自结束,因为你是从0开始的,所以说你直接让零强制转化为结构体指针指向成员即可。
struct S
{
char c1;
int a;
char c2;
};
#define OFFSETOF(struct_name,member_name) (int)&(((struct_name*)0)->member_name)
//先将0强制转换为结构体指针地址,之后去指向它的成员变量
//之后取地址并强制转换为整形
//不需要减0,因为和没减一样
int main()
{
printf("%d\n", OFFSETOF(struct S, c1));
printf("%d\n", OFFSETOF(struct S, a));
printf("%d\n", OFFSETOF(struct S, c2));
return 0;
}
注意这里我们并没有-0这个操作,因为这个操作可以省略。
可是如果没有offsetof这个宏,我们也可以利用指针相加减来确定每个结构体成员变量的相对偏移量:
struct S
{
char c1;
int a;
int b;
char c2;
};
#define OFFSETOF(struct_name,member_name) (int)&(((struct_name*)0)->member_name)
int main()
{
//printf("%d\n", OFFSETOF(struct S, c1));
//printf("%d\n", OFFSETOF(struct S, a));
//printf("%d\n", OFFSETOF(struct S, c2));
struct S s;
struct S* p = &s;
printf("%d\n", (int)(&(p->c2) - &(p->a)));
//结构体其实在内存中是找到首个元素字节大小能被其整除的地址起始的
//当其中一个成员地址 - 另一个成员地址强制转换为整形后,结果为这两个元素的地址差值个数
//因为地址是以字节为单位的,所以强制转换为整形后结果为两个类型的地址差
//所以相减时最好不要用到第一个元素
return 0;
}
因为结构体在内存中是随机分布的,所以我们最好用指针指向第3个成员量,减去第4个成员量即可知道这两个成员之间相差几个字节,这样就可以知道偏移量。
头文件的包含:
当我们包含头文件时,如果使用的是双引号进行包含,它就会先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果还未找到则报错。
如果是尖括号,它就会在库函数头文件中去找。
头文件包含双引号查找范围大,但是效率低,所以应该使用合适的符号来包含头文件。
在编程中,我们无法避免包含重复的头文件,因为预编译阶段会将头文件全部展开,所以为了避免重复的包含,我们可以使用条件编译来避免包含重复的函数。
此时我们包含了两次头文件,但是加上了条件编译语句,也就是说,Sub函数只会包含一次。
头文件不能定义全局变量,否则如果有多个文件,那链接时会冲突。
练习一:
#define INT_PTR int*
typedef int*int_ptr;
INT_PTR a,b;
int_ptr c,d;
其中,b不是int*指针变量。 预处理的#define是查找替换,所以替换过后的语句是“int*a,b;”,其中b只是一个int变量,如果要让b也是指针,必须写成“int *a, *b;”。而typedef没有这个问题,c、d都是指针。
练习二:
#define N 4
#define Y(n) ((N+2)*n) /*这种定义在编程规范中是严格禁止的*/
则执行语句:z = 2 * (N + Y(5+1));后,z的值为70。
总结:
虽然编译和执行我们一般并不会关注,但是预先善其事必先利其器,我们身为程序员,应当有一种庖丁解牛的精神,对于哪怕在细小的细节也应该关注。
希望大家看完本篇文章有所收获。