C语言程序环境与预处理—从源文件到执行程序,这里面有怎么的工序?绝对0基础!
正文开始前,我们简单聊上一聊!
众所周知!编译器的功能非常强大的,我们在编译软件上敲的每一行代码,点击执行,就会输出结果,从代码-->输出结果,这中间经历了怎样的一个过程?编译器难道直接读你的代码吗?看完此篇,相信你会感到惊讶!
我会一步步详细解说!放心食用!安全无毒!
目录
程序的翻译环境和执行环境
翻译环境详解
运行环境详解
以下为预处理详解
预处理符号
预处理指令#define
带副作用的宏参数
宏和函数的对比
#undef 移除宏定义
条件编译
文件包含
程序的翻译环境和执行环境
注:以下介绍的内容属于C语言知识范畴(这些掌握了也是不错的!)在我们介绍的各个环节里面,实际会更加复杂
在C语言标准(ANSI C)中,任何一种实现,都存在2个环境:翻译环境 执行环境
翻译环境:将源码转化为可执行的机器指令
执行环境:实际执行代码
翻译环境详解
组成一个程序的每个源文件通过编译过程分别转换成目标代码
每个目标文件由链接器捆绑在一起,形成一个单一且完整的可执行程序
链接器同时也会引入标准C语言库函数中任一被该程序用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中
在下面这个图中我们可以看到,多个源文件经过编译器处理得到对应的目标文件,最后再经过链接器加工,得到可执行程序(下面会拆分编译环节进行讲解!)
我们把编译分为以下3个阶段: 预编译 编译 汇编
下面是画图演示:
编译 |
预编译 | 编译 | 汇编 |
编译特点:从左到右,依次执行各个阶段
下面我们将每个阶段进行讲解!
预编译
在预编译阶段,主要包含以下几个操作:
1:宏的替换。预处理阶段会处理#define定义的宏。比如#define MAX 100,那么在接下来出现MAX的地方,都会被替换成100。此外,预处理还会处理条件编译指令,比如:#ifdef,#ifndef,#endif等等
2:注释的删除。因为注释不影响程序的逻辑,因此在编译前将其删除可以减少编译时间并减少最终生成的目标文件大小
3:头文件的包含。预处理阶段会处理源代码中的#include指令,将指定的头文件内容插入源文件中。比如:某个源文件中包含#include<stdio.h>,预处理程序会将stdio.h头文件的内容插入源文件中,再进行后续处理
编译
编译阶段,主要进行以下几个操作:
1:语法分析。将代码分解成一个个单词或者符号,然后检查这些单词或者符号是否符合C语言的语法规则
2:语义分析。在语法分析的基础上,检查变量类型、函数调用等是否符合C语言的语义规则,来确保代码的含义是正确的,为后续代码生成做准备
3:中间代码生成。编译器将源代码转化为中间表示,通常是一种与机器无关的代码形式,这步是为了简化后续的编译过程,提高编译效率
4:优化。编译器对中间代码进行优化,以提高运行效率,优化可以通过消除冗余的代码、优化循环等方式实现,这一步可以人为选择是否进行
5:目标代码生成。最后编译器将中间代码,转化为汇编代码,这一步完成了源代码到可执行代码的转换(源代码翻译成了汇编语言)
汇编
主要任务是将:汇编语言转化为目标文件的过程(将汇编语言转化为二进制存放到目标文件中)
解释:汇编器将汇编代码转化为机器语言,并生成目标文件,目标文件包含了可执行代码、符号和调试信息.......
汇编语言主要组成:
数据段:定义程序中使用的数据
代码段:包含程序的指令
堆栈段:管理函数调用和局部变量
编译阶段我们逐个解释完了!最后我们来看看链接做些什么!
(下面是对 编译 跟 链接 的细分图:)
编译 | 链接 |
预编译 | 编译 | 汇编 | 链接 |
链接
1:符号解析。链接器需要找到每个符号(比如函数变量)的定义位置。如果某个符号在多个文件中定义,链接器需要解决冲突,确保每一个符号只有一个定义
2:重定位。链接器调整目标文件中的代码和数据的位置,确保它们在可执行文件中的正确布局。链接器将不同的目标文件和库函数中的代码段、数据等合并成一个完整的可执行文件
运行环境详解
下面看程序运行环境的过程:
1:程序必须载入内存中。在有操作系统的环境中,一般由两个操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成
2:调用main函数,程序开始运行。
3:开始执行代码。这时程序将使用一个运行时堆栈,存储的函数的局部变量和返回地址。程序同时也可以使用静态内存,存储于静态内存中的变量在程序的整个执行过程中一直保留它们的值
4:终止程序。分为正常终止和意外终止(比如程序崩溃)
以下为预处理详解
下面我们对编译时的预处理阶段各个指令以及标识符进行讲解!
预处理符号
预处理符号
预处理符号都是语言内置的:
__FILE__ 进行编译的源文件
__LINE__ 文件当前的行号
__DATE__ 文件被编译的日期
__TIME__ 文件被编译的时间
__STDC__ 如果编译器遵循 ANSI C,其值为1,否则未定义
我们可以打印出来这些预处理符号的效果:
预处理指令#define
#define定义标识符
我们知道#define可以用来定义标识符,让接下来出现这个标识符的地方都被替代!比如:
问:在定义标识符的最后要不要加上“ ; ” ?
答:最好不要加上分号,不然容易出现问题,比如对比下面两幅图:
#define定义宏
宏:#define机制包括了一个规定,允许把参数替换到文本中,这种实现称为宏或者定义宏
下面是宏的申明方式:
#define name(parament-list) stuff
(其中,parament-list可以理解为一个参数符号,可能替换掉stuff中的某些符号)
注意:参数的左括号必须与name紧邻
如果两者之间出现空格,那么编辑器就将宏定义理解成了定义标识符
宏的替换不会出现其它的操作,只会原方不动的被替换(下面第2个例子会解释)
下面看宏的使用例子:
解释:这里宏接收到了一个参数5,也就是MAX(5)
紧接着MAX(5)替换成了 5*5
好,那么下面我们来看一个代码,大家先猜猜 a 的值会是多少?
我们知道,宏定义的替换只会原原本本的替换,不会按预想的操作进行,那么可以这么解释上面代码: MAX(5)* 3 被替换成了 5+5 * 3,只会原原本本的替换
如果你想按照预想的进行,咋办?对X+X添个括号就行了!比如:
千万要注意括号的位置,比如下面这种种情况,括号位置的不同输出结果截然不同:
#宏参数的替换
我们先来看以下代码:
我们刚刚学了宏定义,那么如果使用宏定义来打印,是不是更加灵活?比如:
解释:#插入参数到字符串,这个很好理解吧,就是把有 x 的地方替换成了括号里面的内容!
那么我们来看有2个参数的#define替换:
解释:1:我们知道#define里面的参数是会发生替换的,那么不难理解format直接替换成“%f”
2:#参数,会把对应的宏参数变成对应的字符串,下面我们进行演示:
总结:#参数需要格外注意,它是将x传过去的参数变成字符串,再进行替换
##左右两边进行合成
作用:连接左右两边
这个跟直接替换参数很像,就是在宏定义里面使用了##,然后在接下来运用的时候直接去掉##
比如,我们训练一题:遵循:先直接替换,再去掉##
带副作用的宏参数
如果宏参数本身存在一定副作用,那么这个副作用随着程序的进行,这个作用会一直延伸,无法预测,我们来看下面一段代码:
解释:作为宏的参数, 直接传参,不进行任何处理,然后a++ > b++吗?也就是5小于6吗?然后a跟b自加1,接着走b++,最后结果也就是6+1=7,b自身也变成了8
这种宏的使用有很大的副作用,随着调用参数,我们本是简单比较两个值的大小,但是最后结果出乎意料!
宏和函数的对比
我们知道函数可以实现上面的代码,比如:
采用函数的形式虽然也可以,但是从调用函数到返回值,这期间的时间可能远远比宏使用更长!我们看下面的函数调用反汇编代码:
函数刚准备调用结束:
从反汇编我们可以看到做了很多准备 :
我们实际只需要几句代码!但是编译器本身却做了这么多准备!所以函数调用的时间相较于宏是很长的!
如果使用宏来实现比较大小,在预处理阶段就替换成宏的对应代码的了,因此:使用宏实现的话是无法调试的,因为我们调试的是可执行程序,而宏在预处理阶段就替换了!
所以宏的缺点:
1:每次使用宏的时候,将一段代码直接插入程序中,除非宏比较短,否则大幅度增加代码的长度
2: 宏无法参与调试
3:宏由于是直接替换,因此与类型无关,不够严谨
4:宏可能带来算数优先级问题,导致bug出现
那么我们什么时候使用宏来实现?什么时候使用函数呢!
如果实现一些小功能,可以使用宏,而相较于一些稍大的工程,最好是使用函数
#undef 移除宏定义
比如,我现在创建了一个宏定义:
如果我们在写程序的使用,不想使用这个宏定义了,那么可以在中间移除它,比如:
条件编译
在编译一条语句或者一组语句时,我们可以选择这条语句或者这组语句是否参加编译!因为我们有条件编译指令,下面我们来学习学习!
单个选择性编译
理解:根据宏是否被定义,判断是否执行语句 (只要被定义即可,不论参数内容)
比如:
我们看代码:
多个分支的条件编译
理解:一次性实现多个单分支
比如:
看代码实现:
判断是否被定义
理解:就是判断这个宏是否被定义
比如:
我们看代码实现:
嵌套指令
理解:就是从外到内进行嵌套!套娃哈哈哈!
比如:
我们看代码实现:
好了!下面我们做一下总结:
1:如果遇到#if 要格外注意,它判断的是内容
2:除了 #if 之外,其它一律判断宏是否被定义,不管什么内容
3:一般一个分支的头和尾分别是 #..... 跟 #endif ,根据这个来进行划分,遇到这种条件编译的,找头和尾来作为一个分支
文件包含
我们知道,#include作为指令可以使另外一个文件被编译,就像它实际出现在#include的地方一样
那么编译器怎么替换呢?
预处理时先删除这条指令,并用包含文件的内容替换
那么如果出现了10个这样的头文件,是不是就被替换了10次?
是的,因此在写代码时要考虑这个问题
接下来我们看看#include<.........> 跟 #include“..........” 的区别!
#include<......>(比如:#include<stdio.h>)
如果头文件的形式是上面这样
查找策略:那么它的意思是这个包含文件的内容在库里,编译器直接从库里面寻找这个头文件对应库函数的声明
如果编译器在库里没有找到,那么就会发生报错
#include“...........”(比如:#include“text.h”)
查找策略:编译器直接去找对应文件里面去寻找这个自定义函数的定义,就比如:告诉编译器这个自定义函数在哪里有声明
如果编译器没有找到,同样发生报错
如果我们使用库函数时,用这种头文件形式,就无法区分是本地文件还是库里面的文件
那么以上两种方式都直接告诉了编译器这个函数在哪里声明,如果我们使用了重复的头文件呢?
如果A程序员使用了一次这个头文件,B程序员又写了一个,C程序员又写了一个,那么就会让编译器重复查找,效率很低,我们有以下2中解决办法:(这种办法只针对非库里的头文件,也就是#include“.......”类型,原因:库里面的头文件开发者已经设置好了,不会出现这种问题)
比如:(这两种方法都可以避免头文件的重复使用)
我们直接看代码实现:(假如我们的自定义函数声明在text.h这个文件里面)