⚡面试有道⚡
面试官:
- “小伙子,你能讲一讲一个程序是怎么从.c文件到.exe可执行文件的吗?”
- “代码的运行编译过程是怎么样的呢?”
- “链接它是在链接什么东西? 怎么链接的?”
- “各个文件之间,是如何交互起来的呢? 在哪个阶段完成的? 比如:test.c文件想用到add.c文件的代码~”
- “宏和函数有哪些区别?”
- “为什么VS生成的头文件第一行会带 #pragma once ? 它有何作用? ”
本章我要注意的预处理中的点:
- #define定义宏的参数,要带括号,而且要写全括号!
- #define与typedef的区别是啥?
目录
本章重点
- 程序的翻译环境
- 程序的执行环境
- 详解:C语言程序的编译+链接
- 预定义符号介绍
- 预处理指令 #define
- 宏和函数的对比
- 预处理操作符#和##的介绍
- 命令定义
- 预处理指令 #include
- 预处理指令 #undef
- 条件编译
引子:
敲好代码后的,test.c 是生成的源文件/源代码,他是以文本形式存在的(人可以直接看得懂);而 test.c文件,又是如何变为能够执行的 test.exe 文件(二进制文件)的呢?
一、程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境。
- 第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
- 第2种是执行环境,它用于实际执行代码。
详解翻译环境
比如以前写过的通讯录,他生成了test.c文件、contact.c文件等,他们经过编译器(VS下的编译器是cl.exe),生成对应的目标文件;众多目标文件经过链接器(VS下的链接器是link.exe)合并,同时加上链接库,最终就生成了可执行程序.exe。图示如下:
- 组成一个程序的每个源文件通过编译过程分别转换成目标文件( .obj )。
- 每个目标文件由链接器(linker)捆绑在一起。
- 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中,最终形成一个单一而完整的可执行程序。
举例说明:
那么链接库是什么东西呢?
那拿我们所用的printf函数举例,一旦我们用了printf函数,链接的时候就会把printf有关的库链接到工程中:
VS是集成开发环境,不好观察这些文件的生成过程,此处用Linux来演示:
二、Linux下演示
首先,思考:两个.c文件是如何通过“ 声明 ”,就将两个文件链接起来的呢?
(1)每个阶段要做的事情总结
(2)每个阶段完成的事情(详细分析)
预处理阶段完成的事情:
test.c文件 ——> test.i文件
- 头文件的包含 #include
- #define 定义符号的替换
- 删除注释
编译阶段完成的事情:
test.i文件 ——> test.s文件(C语言代码转汇编代码~)
- 语法分析
- 词法分析
- 语义分析
- 符号汇总
汇编阶段完成的事情:
test.s文件 ——> test.o文件(二进制文件)
- 把汇编代码,形成二进制指令(机器指令~)
- 形成符号表
链接阶段完成的事情:
test.o文件 ——> test.exe文件
- 合并段表
- 符号表的合并和符号表的重定位
三、预处理阶段详解
1.预定义符号
预编译阶段提供的预定义符号
__FILE__ //进行编译的源文件的文件地址
__LINE__ //文件当前代码处于哪一行的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1; 否则未定义
这些预定义符号都是语言内置的,是不需要定义就可以使用的。如下:
__STDC__在VS下是未定义的,但是Linux下的gcc编译器下是有定义的,如下:
所以,可以看出__STDC__在Linux下的gcc编译器是有定义的,说明gcc是支持的
gcc对C语言语法的支持非常好,当对C语言的某个语法存疑时,可以去gcc编译器下验证,以其验证的结果作为标准。
2.#define
2.1 #define定义标识符
语法:
#define name stuff
举例说明:
#define MAX 1000
#define reg register //为 register 这个关键字,创建一个简短的名字
#define do_forever for(;;) //用更形象的符号来替换一种实现
#define CASE break;case //在写case语句的时候自动把 break写上。
// 如果定义的 stuff 过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n" ,\
__FILE__,__LINE__ , \
__DATE__,__TIME__ )
#define MAX 1000;
#define MAX 1000
(1)#define MAX 100
Linux下演示具体替换过程:
(2)#define reg register
说明:register是一个寄存器变量类型。如果我们嫌他太长,可以用#define将register类型,重新定义一个简短的名字。
同理,也可以#define定义一个名字表示字符串等等
(3)提问:#define name stuff可以加分号" ; "结尾吗?
说明:不可以像普通的代码语句一样,结尾加分号表示结束。加分号是错❌的。在预处理阶段,会把 100; 看成一个整体,那么m就会被替换成:100;
你会觉得,这样写不会报错?的确不会报错,📈。(因为多一个分号只不过是多一条语句,只不过该语句是空,并不影响编译)
但是如果这样写,加了分号的#define语句就会原形毕露了🧨
所以,#define name stuff不能加分号结尾!
2.2 #define定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为 宏 或 定义宏 。
语法:(宏的申明方式)
#define name( parament-list ) stuff //其中的 parament-list 是一个由逗号隔开的参数表,它们可能出现在stuff中。
思考:#define定义宏的写法来写 一个数的平方 ,那种写法对呢? 还是都行?
建议定义宏,采用第三种,这种多括号的方式。因为如果不加括号,就会产生很多问题,比如写法1:
不加括号(或者括号不全)导致的结果出错问题:
本想求a+5这个表达式的平方,(结果应该是100的),而现在结果是35??
解释:因为表达式会有优先级。因为宏体内不加括号,而参数又会严格遵守宏的定义式子,所以才会先算5*a。结果出错.......
再比如:
不加括号,导致计算结果错误
总结:
宏的参数,要带括号,而且要写全括号!
另外,还需要注意的点:
参数列表的左括号必须与name紧邻。 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分
提问:#define与typedef的区别是啥?
简而言之,#define实现的是替换; typedef实现的是重命名
2.3#define替换规则
在程序中扩展#define定义符号和宏时,需要涉及以下几个步骤。
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
- 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。
注意:
1.宏参数和#define 定义中可以出现其他#define定义的变量。但是对于宏,定义的时候不能出现递归。
2.当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
2.4 # 和 ##
(1)#
作用:#n转化成参数对应的字符串
比如我们想实现一个这种效果的代码:
可是代码太过冗余! 能否封装成一个函数? 这种是函数实现不了的~ 比如:
发现封装成函数,但是字符串中的那个变量是固定下来的,所以该怎么解决这个问题呢? ———— 用 #
首先解释一下,C语言中的实现字符串的 “ ” 方式
所以,一个#的作用是:参数部分,转化为对应的字符串
解释:
(2)##
作用:##可以把位于它两边的符号合成一个符号。 它允许宏定义以分离的文本片段创建标识行。
##可以把位于其两端的符号,合成一个符号。所以,class和102传入定义的宏CAT(X,Y)中,拼接合成了一个符号class102,所以,得到了输出100。
2.5带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。例如:
x+1; //不带副作用
x++; //带副作用
首先,什么是 “ 副作用 ” ?
m为什么是9而不是8呢? 5和8比较,不应该是8大吗?9从哪来的?
分析:
注意:
- 宏的参数是不计算直接替换进去的,替换进去之后再参与运算;
- 函数的参数是计算后再传进去的
2.6宏与函数对比
紧接2.5,宏和函数的写法对比,既然“ 面对宏参数带有副作用时,使用宏定义求出的结果可能会出现不可预知的错误 ”,那么我们直接写函数不就行了吗?? 宏好还是函数好?
比如在两个数中找出较大的一个
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
那为什么不用函数来完成这个任务? 原因有二:
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
- 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于来比较的类型。而宏是类型无关的。
解释:
1、函数是需要调用的,而调用函数和从函数返回都是有时间开销的!而这些时间开销,甚至有可能是大于实现程序功能本身要执行的时间~
函数写法的反汇编代码(用于观察执行的铺垫汇编代码的多少--->反应了时间大小)
大量铺垫~ 浪费时间
宏的写法的反汇编代码(用于观察执行的铺垫汇编代码的多少--->反应了时间大小)
直接执行,时间快!
当然和宏相比函数也有劣势的地方:
- 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
- 宏是没法调试的。
- 宏由于类型无关,也就不够严谨。(类型不限,既是好处,也是坏处~)
- 宏可能会带来运算符优先级的问题,导致程容易出现错。
解释:
1、如果宏体比较长,那么每次传参要写的东西就会很长。如果多次使用,就会显得程序长度冗长。
2、宏是没法调试的。因为宏是在预处理阶段定义的,而 Ctrl + F10运行调试代码,是生成.exe文件后进行调试的,所以,宏是不能调试的。
宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。比如:
宏与函数的区别总结:
注:(命名约定)宏的name一般都大写;函数的name一般不都大写
2.7undef
这条指令用于移除一个宏定义
#undef name
//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。
3.命令行定义
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。 例如:当我们根据同一个源文件要编译出不同的一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大写,我们需要一个数组能够大写。)
比如:Linux下的gcc编译器
4.条件编译
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
简而言之,条件编译是指代码满足条件就参与编译,不满足条件就不参与编译~
Linux下演示:
所以,我们可以通过控制编译条件,来使得代码 参与 / 不参与 编译~
4.1常见的条件编译指令
1.单句条件编译
#if 常量表达式
//...
#endif
2.多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
3.判断是否被定义
#if defined(symbol)
#ifdef 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
短期内可能会用不到这个条件编译,但是以后项目中的大量工程中,可能会用到。比如<stdio.h>头文件中就有好多类似的条件编译指令
5.文件包含
我们知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。
这种替换的方式很简单: 预处理器先删除这条指令,并用包含文件的内容替换。 这样一个源文件被包含10次,那就
实际被编译10次。
5.1头文件被包含的方式
(1)本地文件包含( 双引号 )
#include "filename.h"
查找策略:
- 首先是在源文件所在的目录下查找
- 如果第一步找不到,再去库函数所在的目录下找。如果还找不到,就会编译错误
(2)库函数目录下的文件包含( < > )
#include <filename.h>
查找策略:
直接去库函数所在的目录下找。如果还找不到,就会编译错误
提问1:对于库文件也可以使用 “ ” 的形式包含?
答案是:可以。
因为即使自己没有写该文件,也没事:第一步找不到,就会去进行第二步(去库函数所在的目录下找)。但是这样做查找的效率就会低一些,当然这样也不容易区分是库文件还是本地文件了。所以不建议这样写。
5.2嵌套头文件包含
提问2:如下嵌套文件包含,test.c嵌套包含了两次comm.h文件,那么这样重复包含同一头文件,会在编译阶段多编译一次吗?
Linux下演示:
那么如何避免这种重复编译呢?
答案:用条件编译 (非常妙啊~)
方式1:
方式2:
其实我们发现,VS下的.h文件中的自动生成 #pragma once 存在的意义,就是防止头文件重复包含影响预编译的时候重复编译。(注意,头文件重复包含是不可避免的;我们能做的只是,重复包含下防止他重复预编译~)
深入了解需要后续自学《C语言深度解剖》