程序编译的过程
预处理
这是我们将代码写好之后编译器做的第一步操作,所进行的内容有:
①展开头文件,就是将你代码中所包含的全部头文件拷贝到你打的代码中,而正是因为这一步,就会导致待编译的文件急速膨胀,后续编译动作的效率就会受到很大的影响,不过在c++中这一点正在改进,后续会得到有效处理的;
②展开宏,也就是进行宏替换;
③条件编译,就是对代码进行选择性的编译,这个等会会详细讲解的;
④去掉注释,注释是让我们人类能看懂代码干了些什么,但是编译器不需要这一点;
在Linux环境下,我么可以使用命令行通过gcc来生成预处理后的文件,预处理产生的结果放在“.i”后缀的文件中,以“test.c”为例,命令行为:(现在不必知道为什么用这个命令行,后面会讲到的)
gcc -E test.c -o test.i
编译
预处理后,生成的代码就是我们真正需要的代码了,接下来进行第二步操作-编译,在这一步中,编译器会对预处理生成的代码进行词法分析、语法分析、语义分析、中间代码生成、目标代码优化...最终使其变成汇编指令;
在Linux环境下,我么可以使用命令行通过gcc来生成编译后的文件,编译产生的结果放在“.s”后缀的文件中,以“test.i”为例,命令行为:
gcc -E test.i -o test.s
汇编
编译出来的代码将要进行第三步操作-汇编,这一步会将编译生成的代码转化为二进制的机器指令,到这里我们就已经看不懂“自己写的”代码了,这是计算机才能看懂的代码指令;
在Linux环境下,我么可以使用命令行通过gcc来生成汇编后的文件,汇编产生的结果放在“.o”后缀的文件中,以“test.s”为例,命令行为:
gcc -E test.s -o test.o
链接
实际开发过程中,我么不可能在一个源文件中就完成一个项目的开发,我们会将一个项目分成好多个源文件,然后由许多人一起来共同开发,所以我们在将这些分散的源文件进行上面的三个步骤之后,就会得到好多个“.o”文件,而链接的过程就是把这些“.o”合并到一起;
另外链接过程中除了用户自己写的编译的“.o”文件外,还需要链接一些库文件,因为在我们写的代码中的头文件里,库函数只有声明没有定义,而这些库函数的定义是包含在一个动态库/静态库中,这些都是通过连接过程找到的;
接下来就可以执行文件了,在Linux环境下,我么可以使用命令行通过gcc来执行,以“test.o”为例,命令行为:
gcc test.o -o test
程序执行的过程
1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2. 程序的执行便开始。接着便调用main函数。
3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
4. 终止程序。正常终止main函数;也有可能是意外终止。
预处理详解
预定义符号
#include <stdio.h> int main() { printf("当前的文件:%s\n", __FILE__);//__FILE__表示当前的文件 printf("当前的行号:%d\n", __LINE__);//__LINE__表示当前的行号
//这两个在平时我们打印日志时非常有用,可以帮我们定位
printf("文件被编译的日期:%s\n", __DATE__);//打印文件被编译的日期 printf("文件被编译的时间:%s\n", __TIME__);//打印文件被编译的时间
//这个可以用来区分程序的版本号,显示程序的具体编译时间
//printf("是否遵循ANSI C标准:%s\n", __STDC__ != 0 ? "是" : "否");
//用来检测编译器是否遵守C标准来实现的 //如果编译器遵循ANSI C标准,它就是个非零值 //需要注意的是这个在vs中不能使用,因为没有宏定义 //这也是为什么我们在今后的工作中不使用vs进行编程,而是选择使用Linux的gcc return 0; } //当前的文件:E:\vs\Project\Project3\test_everything.c //当前的行号:5 //文件被编译的日期:Nov 30 2020 //文件被编译的时间:13:05:10
#define详解
首要记住的一点就是:宏的本质就是,在预处理阶段进行文本替换;
#define定义标识符
//定义语法: #define name stuff
#define MAX 1000 //在后续出现的MAX就会被换成1000 #define reg register //为 register这个关键字,创建一个简短的名字 #define do_forever for(;;) //用更形象的符号来替换一种实现 #define CASE break;case //在写case语句的时候自动把 break写上。 // 如果宏定义时过长,可以分成几行写,此时除了最后一行外,每行的最后面都加一个反斜杠 “\"——续行符 #define DEBUG_PRINT printf("file:%s\tline:%d\t \ date:%s\ttime:%s\n" ,\ __FILE__,__LINE__ , \ __DATE__,__TIME__ ) //打印代码的文件、行号、程序编译的时间,定义成宏,方便书写代码
#define定义宏
//定义语法: #define name( parament-list ) stuff
其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现 在stuff中作为参数。参数列表的左括号必须与name紧邻。如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
#include <stdio.h> #define ADD(x, y) x + y #define MUL(x, y) x * y int main() { int a = ADD(10, 20) * ADD(10,20); printf("a = %d\n", a);//① int a2 = MUL(10, 10 + 10); printf("a = %d\n", a2);//② return 0; }
看看上面的代码,计算结果是多少呢?其中①的答案是:230;②的答案是:110。你们是不是算错了呢?这就是宏定义的一个缺点、难点,下面我们来看看宏定义和函数进行比较的优缺点:
优点:①能实现一些函数做不到或者难以做到的事情;
②宏的执行效率要略高于函数,因为函数调用需要传参,这个过程会有开销,而宏只是文本替换,很快;
③宏可以一定程度上实现泛型编程,也就是宏没有参数类型检查,同一个变量可以使用不同类型的数据;
④如果宏定义写的函数出错,编译器会明确的将代码出错的行数准确返回,可以第一时间确定出错位置;
缺点:①正是因为宏进行的只是简单的文本替换,所以运算的优先级不能被保证,表达式的运行结果和预期的结果会有差别,所以在定义宏的时候多加括号,避免出错;
②还是因为宏进行的只是简单的文本替换,所以没有参数类型检查,这会带来好处也会带来很多坏处;
③宏难以进行调试,在打断点的时候编译器会跳过宏,不能一步步运行宏;
④宏的可读性不如函数好,没有那么有条理,而且宏不能进行递归;
总的来说,宏的弊大于利,一个正经的编程语言不该有宏的概念,现在很多编程语言都没有宏这种东西,c/c++仍旧有,但是c++也已经在努力去掉宏,所以,希望大家在写代码的时候能不使用就尽量不用。
#undef
如果现在要使用一个NAME的宏定义,但是他已经被宏定义过其他的意思,那么仍旧要使用NAME该怎么办?#undef这条指令用于移除一个宏定义,使用如下:
#undef NAME
'#'和'##'
'#'
在平时打印一个字符串的时候,我们可以发现不同的字符串之间打印时是可以拼接的,那么我们可以使用宏来实现printf():
#include <stdio.h> #define PRINT(FORMAT, VALUE) \ printf("the value is "FORMAT"\n", VALUE); int main() { int i = 10; PRINT("%d", i + 1); return 0; } //结果:the value is 11
当我们需要将参数变成字符串的一部分该怎么办呢?其实我们可以使用'#'来解决,在宏的参数前加'#',能把参数变成一个字符串,然后这个字符串就可以在代码中进行文本拼接,举例如下:
#include <stdio.h> #define PRINT(FORMAT, VALUE) \ printf("the value of "#VALUE" is "FORMAT"\n", VALUE); int main() { int i = 10; PRINT("%d", i + 1); return 0; } //结果:the value of i+1 is 11
'##'
'##'的作用是进行字符串拼接,它可以把位于它两边的符号合成一个符号,且允许宏定义从分离的文本片段创建标识符,不过需要注意的是:这样的连接必须产生一个合法的标识符(也就是你创建过的变量,##只具有合并功能,并不具有定义功能),否则其结果就是未定义的。举例如下:
#include <stdio.h> #define ADD_TO_NUM(num , value) sum##num += value; int main() { int sum1 = 0; //##两边的变量进行拼接,参数传入后,此时sum##num会变为sum1 ADD_TO_NUM(1, 10); //此时给sum1+=10;那么sum1==10; printf("%d\n", sum1); return 0; } //结果:10
条件编译
条件编译,顾名思义就是满足某些条件才会编译某段代码,这就是条件编译,通常条件编译常常配合#define来使用,下面介绍一些常见的条件编译指令以及用法:
//1. #if 常量表达式//常量表达式为真执行下面代码,否则不执行 //... #endif //常量表达式由预处理器求值。 如: #define __DEBUG__ 1 #if __DEBUG__ //.. #endif //2.多个分支的条件编译 #if 常量表达式//某个常量表达式为真,就执行某个分支代码 //... #elif 常量表达式 //... #else //... #endif //3.判断是否被定义 #ifdef symbol//如果symbol被宏定义过,那么就执行,否则不执行 //... #endif #ifndef symbol//如果symbol没被宏定义过,那么就执行,否则不执行 //... #endif //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
文件包含
头文件包含方式
本地包含
#include "filename" //文件使用""包含
查找策略:先在源文件所在目录下查找,如何该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。 如果找不到就提示编译错误。
库文件包含
#include <filename.h>
//文件使用<>包含
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
文件重复包含
在写一个项目的时候,代码量往往很大、很繁琐,很容易就会造成头文件重复包含的问题,如果一个文件被包含了10次,那么就会被展开10次,之前说过,展开头文件会很耗费时间,效率不高,那么如何解决重复包含的问题呢?答案是:条件编译;
具体使用方法是将下面的两串代码二选其一放到头文件的开始位置,就可避免头文件的重复引入;
第一种:
#ifndef __TEST_H__ #define __TEST_H__ //头文件的内容 #endif //__TEST_H__ //原理是,在第一次条件编译时,没有宏定义__TEST_H__,所以通过条件编译,展开下面的头文件内容,而如果重复包含了头文件,
//那么第二次进入时,__TEST_H__已经被宏定义了,条件编译失败,就不会重复展开下面的内容
//这种方法其实大家已经用过很多次了,那就是_CRT_SECURE_NO_WARNINGS,当大家包含了#define _CRT_SECURE_NO_WARNINGS之后,使用scanf就不报错了
//没有这个宏的定义的时候, VS 就会多编译一些对于 scanf 等函数安全检查的逻辑,有这个宏定义, 相关的检查代码就不被编译了.
//这段检查的代码在 stdio.h 里头. 所以必须把这个宏定义到 stdio.h 的上方
第二种:
#pragma once //写代码的时候更推家大家使用这个,因为上一种写起来很繁琐,而且还要起名字,这样难免会重复命名,这个简单易写,效果还相同。
浙公网安备 33010602011771号