前言
在 ANSI C 的任何一种实现中,存在两个不同的环境。分别是翻译环境和执行环境,由翻译环境处理后我们的程序才能被计算机接收,所以搞懂翻译环境便显得格外重要,由于VS之上的环境无法直接地观察到整个翻译过程,因而此次翻译环境的讲解可能会些许生硬。
一个 .c 文件在输出结果之前必须经过翻译环境转换成 .exe 的可执行程序并经由执行环境输出结果,了解这部分的内容作为基本功还是相当有必要的,这样才能从根源上了解其来龙去脉,真正做到知识的透彻,感兴趣的朋友还可以去看看《程序员的自我修养》这本书,里面对于编译链接的讲解十分地清晰透彻。(最近刚刚在看)
翻译环境
整个环境即用于将.c文件转化成.exe文件,并分作了以下几个步骤,下面就一个一个开始讲解。
预编译
作为翻译环境的第一步,预编译的主要功能有:
1. 注释的删除 。
2. 头文件的包含 。
3. 删除所有的 #define ,并且展开所有的宏定义。
4.处理所有预编译指令,如 #if #ifdef #endif 。
5.保留所有的 #pragma 编译器指令。
走完这些步骤后,头文件会被直接复制到该源文件里头,#define定义的内容就会被直接替换到代码里面去,经过预编译的指令,决定一段代码是否需要编译,经过这样一系列的处理代码就会变得直观了些。经过这样解释我们也可以知道为什么头文件的包含要放在代码的最上方。
值得注意的是,若无 #prama 的情况下对于头文件的多次包含便会造成代码的冗余,产生多余不必要的代码,对程序来说有害无利。
编译
经过预编译,代码将不需要的内容剔除,编译则是经过词法分析、语法分析、语义分析、符号汇总(将全局变量跟函数名汇总)及优化后生成汇编代码文件,这个部分是整个程序构建的核心部分,由于过于复杂因而在这里便不具体讲解,即将C语言代码转换成了汇编代码。
汇编
汇编即将汇编指令转换成机器可以执行的指令(即二进制指令),并且形成符号表,记录下代码之中引用的函数的地址。但是引用外部函数时,该函数的符号表内的地址没有实际价值。
链接
经由上面的操作,一个个 .c 文件就被转换成了 .obj 文件将这些目标文件与库链接起来最终形成了 .exe 的可执行文件。这个过程之中还会进行段表的合并以及符号表的合并和重定位。每个文件都拥有自己的一个段表,于是各各文件之间的相同段便开始合并,最终统合成一个段表。在上面我们提到引用的外部函数的符号表内的地址没有实际价值,但定义这个函数的源文件的地址便是其实际的地址,因此经过合并便会保留有实际意义的表。以此在链接这个阶段编译器就可以查询被引用的函数是否被定义。现在我们看系统的报错是不是就觉得合理了许多。
执行环境
执行环境对我们来说可能就相对熟悉了:
1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2. 程序的执行便开始。接着便调用main函数。
3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
4. 终止程序。正常终止main函数;也有可能是意外终止。
经过执行环境我们编写的代码才能最终通过输出流将结果反馈给我们。
预处理
预定义符号
C语言为我们提供了一些预定义符号
__FILE__ 表示文件路径
__LINE__ 查找此行行数
__DATE__ 编译时的日期
__TIME__ 编译时的时间
例如:
#define
#define定义符号
通过#define定义字符可直接地替换到代码之中去。
我们只是在外部定义了N却可以在主函数中直接输出,即验证了前面所说的,被定义的字符可以直接替换到代码中去。但是还应该考虑优先级的问题,才避免出现错误的结果。例如:
如此输出的结果便与我们预想的不同,通过直接替换字符,代码就变成这个样子。
所以输出的便是 9 而非 14 。如果我们想要输出 14 的话该怎么办呢?
通过加括号来保证其优先级,这样我们输出的结果就是 14 了
再比如用一个字符代表 int* 对变量进行定义,但我们看到真正成为指针的只有 a 而 b 还是 int 类型的变量。这些常见的易错点一定要好好把握,不能想当然。
#define定义宏
宏跟函数有点类似但各有千秋,可以用于较为简便的运算之中。
宏会将定义的运算替换到代码之中,即经过预编译我们见到的该部分内容应该是这样的:
与定义字符一样要注意的还是优先级的问题,当优先级出现错误时,便会造成错误的结果。
#
在将 # 之前先介绍一个背景:
我们会发现这两串指令的结果是相同的。
# 的作用就是把一个宏参数变成对应的字符串,若我们想要打印出一个参数的大小并且还要提示出这个参数的名字的话就需要使用到 # 。
即将a作为字符串而非数值插入到我们所要打印的语句之中,而做到了函数所做不到的事。
##
##可以把位于它两边的符号合成一个符,
它允许宏定义从分离的文本片段创建标识符。
举个例子,我们定义了一个数据,然后用宏实现对其的运算。
我们可以看到即便 lin_ 跟 Alpaca 是分开的但是我们使用 ## 将其连接起来后还是可以对其赋值以及运算。但是这样的连接必须产生一个合法的标识符,否则其结果就是未定义的。
#undef
这条指令用于移除一个宏定义。如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。
注意
1. 宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
宏与函数的比较
#define定义宏 |
函数 | |
代 码 长 度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每 次使用这个函数时,都调用那个 地方的同一份代码 |
执 行 速 度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些 |
操 作 符 优 先 级 | 宏参数的求值是在所有周围表达式的上下文环境里, 除非加上括号,否则邻近操作符的优先级可能会产生 不可预料的后果,所以建议宏在书写的时候多些括 号。 |
函数 参数只在函数调用的时候求 值一次,它的结果值传递给函 数。表达式的求值结果更容易预 测。 |
带 有 副 作 用 的 参 数 | 参数可能被替换到宏体中的多个位置,所以带有副作 用的参数求值可能会产生不可预料的结果。 |
函数参数只在传参的时候求值一 次,结果更容易控制。 |
参 数 类 型 | 宏的参数与类型无关,只要对参数的操作是合法的, 它就可以使用于任何参数类型。 |
函数的参数是与类型有关的, 如 果参数的类型不同,就需要不同 的函数,即使他们执行的任务是 相同的。 |
调 试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递 归 | 宏是不能递归的 | 函数是可以递归的 |
若宏的长度偏长,替换到程序之中则会大幅度地提升代码量,给机器带来负担。但正因为是直接替换于程序之中,所以宏并不需要像函数那样去创建函数栈帧,因而运算的速度更快。同时也有优先级的问题需要程序员把握,只能说二者是各有千秋,没有绝对的优劣,只有在最适合的时候使用最适合的方式才能使效率最大化。
条件编译
为了避免重复定义一般会对其加以条件限制。
通过 #ifdef 来判断是否被定义,为真则执行下列语句。
为假则直到 #endif 的语句都不会执行。
也可以使用#if进行判断,但是要注意if 跟endif是一对只要有其中之一另一个必须要出现,否则就会像这样报错。
文件包含
对我们来说文件包含应该相当熟悉了吧, #include 可以加 < > 也可以加 " " 。
加上 " " 则代表先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。
若加上 < > 查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
因此,理论上包含库文件也是可以使用 " " 的,但为了便于区分究竟是本地文件还是库文件还是要稍作区别。