(C语言之路-----p13 程序的编译与链接)
(一)我们写的代码是如何变成可执行程序然后执行的?
我们以我们写的test.c文件为例
其中这个程序会经历两个环境,第一个是翻译环境,第二个是执行环境
翻译环境又分为两个阶段,就是编译和链接
编译器将我们写的test.c文件进行编译以后,会产生目标文件,其文件的后缀一般是**.obj**;当然,我们平时写程序的时候,可能不止写一个.c文件,此时如果是多个.c文件,都会经过编译器的编译,变成目标文件,目标文件会和预存好的库由链接器进行链接,变成可执行程序,其文件的后缀是**.exe**.(注意,以上的文件的后缀都是在windows的环境下的,在linux环境下的目标文件和可执行文件的后缀分别是.o和a.out(默认文件名))
其中,编译又分为几个阶段:预编译,编译,汇编
详细的图解如下:
(二)翻译阶段和执行阶段的详细讲解
1.编译阶段
1.1预编译
预编译阶段也被称为预处理阶段,在这段时间中,编译器主要干这3件事:
1.头文件的包含:编译器会将#include包含的头文件直接替换成头文件的内容
2.删除注释:其实说是把注释替换为空格更加合适,因为计算机不需要理解注释,注释是写给程序员看的,所以用空格替换,让接下来的阶段中不会对注释进行操作,这样操作就等价于把注释给删除了
3.对#define定义的常量和宏进行文本替换:例如#define M 100,在代码中,会将所有可以识别的M替换成100;#define MAX(x,y) ((x) < (y) ? (y) : (x)),遇到MAX(x,y)则会直接替换成这串代码
1.2编译
在这个阶段中,编译器会将预编译后的代码转化成汇编代码,编译器主要干了以下四件事
1.语法分析
2.词法分析
3.语义分析
4.符号汇总
以上的过程具体可以参考书籍**<<编译原理>>**,里面对于编译的过程有详细的介绍.在这里我们额外提一下第4点
符号指的是全局的标识符.
我们举个例子,假设我们写了两个文件,test1.cpp, test2.cpp,如下:
运行结果如下
在test1.cpp文件中,符号就包括Add,在test2.cpp中,符号包括main, printf, Add
对于每个符号,在编译这个阶段,对于一个文件来说,如果这个标识符对应的实现在这个文件中,则会给这个标识符一个确切的地址,反之则会放置到一个默认的地址0x00(不同的环境下的默认地址可能有所不同)
1.3汇编
在汇编阶段中,编译器就会把汇编代码转化二进制的指令,并形成符号表
2.链接阶段
链接阶段中,链接器主要干一下两件事
1.合并段表
2.符号表的合成和重定位
经历了预编译,编译和汇编阶段之后的代码,代码的结构大体一致,代码会分成很多段,每段的长度一致,编译器就会把这些一段一段的代码合并起来变成一段新的代码,这就叫合并段表.而符号表的合成与重定位则是看一个文件中的各个符号的地址,如果一个文件中的一个符号位于默认地址,在另外一个文件中出现了分配好的地址,那么就会把这个默认的地址重新改为那个已经分配好了的地址上;接着就把多个目标文件的符号表中的其他符号进行汇总.最后与链接库进行链接,就能得到可执行程序.
3.运行环境
程序执行的过程:
1.程序必须载入内存中.在有操作系统的环境中:一般这个由操作系统完成;在独立的环境中,程序的载入必须由手工安排,也可能事通过可执行代码置入只读内存来完成.
2.程序的执行便开始,接着调用main函数.
3.开始执行程序代码.这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址.程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程中一直保留他们的值
4.终止程序.正常终止main函数,也有可能意外终止
(三)预处理详解
3.1预定义符号
这些预定义符号都是语言内置的 .
3.2#define
3.2.1#define定义标识符
语法:
#define name stuff
注意:在#define后面不用加上;(大多数情况下),因为#define是直接将名字直接替换成后面所有的内容,如果加了分号之后,也会把;替换进去,
所以一般看情况加不加;
3.2.2#define定义宏
#define机制包括了一个规定,允许直接把参数替换到文本中,这种实现通常称为宏或者定义宏
下面是宏的声明方式:
#define name( paraments-list ) stuff
注意:
参数列表的左括号必须与name紧邻.
如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分.
上面第一张图的写法等价于第二张图的写法,在预处理阶段就会MAX(a,b)就会被替换成a > b ? a : b
但是上面的写法是不够完美的,我们尽可能地加上(),因为宏是直接替换,直接替换进去之后如果有操作符的运算,那么就会出现操作符优先级的问题.
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b6N9AtIA-1645803467096)(C:\Users\KAI\AppData\Roaming\Typora\typora-user-images\image-20220224200421196.png)]
3.2.3#define替换规则
在程序扩展#define定义符号和宏时,需要涉及以下几个步骤
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先
被替换. - 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上
述处理过程
注意:
1.宏参数和#define定义中可以出现其他#define定义的符号.但是对于宏来说,不能出现递归
2.当预处理器搜索#define定义的时候,字符串常量的内容不被检索
3.2.4带副作用的宏参数
在使用前缀+±-和后缀+±-这类带有副作用的符号的时候,一定要谨慎使用宏,因为宏参数会直接把+±-符号直接传入宏内(一般是函数只会传入一个特定的值),然后运算, 一旦运算之后,就会造成不可预测的永久性的后果.
3.2.5#和##
#的作用可以将参数插入到字符串中
比如:我们要打印"a = ?"“b = ?”“c = ?”…“z = ?”,我们要是直接用printf函数打印,那么要写很多次,我们想用一个函数实现这样的功能,很可惜函数没办法做到这一点.但是宏可以!我们可以用#把参数插入到字符串中./
首先,我们先介绍一下printf的一些比较冷门的知识:
可以看到,printf可以就算把字符串拆开来,也可以拼起来输出.
接下来,我们实现用#把字符串插入到参数中正是利用了这一特性
#x表示把x插入到字符串中,其中x不限类型.
##的作用则是将两个宏参数的标识符连接起来
实际用法如下:
3.2.6宏与函数对比
属性 | #define 定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,定义的宏都会被直接插入到代码中,除非是非常小的宏,代码的长度都会增加 | 函数代码只出现在一个地方;每一次调用函数都是调用同一份代码 |
执行速度 | 更快,直接替换 | 要调用堆栈,花费调用时间 |
操作符的优先级 | 因为是整体替换,所以可能出现操作符的优先级的问题,与上下文有关 | 函数参数在函数调用的时候只求值一次,表达式的结果更好预测 |
带有副作用的参数 | 整体替换,包括符号,可能会导致不可预测的结果 | 函数参数调用时只求值一次,结果好控制 |
参数类型 | 宏不会检查参数类型,不安全.(C++中不建议使用宏定义,而是使用const,因为前者不检查类型) | 附带类型检查,相对安全 |
调式 | 宏会在预编译阶段进行替换,而调试是在运行阶段,所以宏无法调试 | 函数可以逐句调试 |
递归 | 宏不能递归 | 函数可以递归 |
总的来说,宏的缺点较多,为数不多的优点是效率较高.
实际上,C99和C++中已经出现内联函数inline type_name function_name(paraments list)(写法跟普通函数相似,就算在前面加了一个inline)解决了宏大部分的问题,它类似于宏替换,但是包含类型检查,更安全,也不会出现操作符的问题,但是递归的函数使用内联函数无效
3.2.7命名约定
由于C/C++中,宏和函数两者很像,语言本身没办法帮我们区别两者,所以我们约定在命名的时候:
把宏名全部大写;
函数名不完全大写;
3.3#undef
这条指令用于移除一个定义
#undef NAME
//如果现有的一个名字需要被重新定义,那么他的旧名字首先要被移除
3.4条件编译
在编译一个程序的时候我们要将一个语句(或者一组语句)编译或者放弃是很方便的,因为我们有条件编译指令
举个例子:
#include <stdio.h>
#define DEBUG
int main(){
int arr[11] = {0};
for(int i = 0; i < 10; ++i){
arr[i] = i;
#ifdef DEBUG
printf("%d\n", arr[i]);
#endif
}
//如果定义了DEBUG,那么就执行打印arr[i]这条指令,观察arr是否赋值成功
}
常见的条件编译指令:
1.
#if 常量表达式
//...
#endif
//常量表达式由预处理器求值
例如:
#define DEBUG 1
#if DEBUG
//...
#endif
//注意,#if后面不能是变量表达式,因为在预处理阶段只替换常量表达式,编译器这个时候不认识变量,然后过了预处理阶段后,条件编译指令会消失(因为是预处理指令,只存在在预处理阶段),而变量表达式是在执行阶段开始赋值的
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
3.5文件包含
我们已经知道,#include指令可以使另外一个文件被编译,就像它实际出现于#include指令的地方一样
这种替换的方式很简单:
预处理器先删除这条指令,并用包含文件的内容替换
3.5.1头文件被包含的方式
1.本地文件包含:
#include “filename.h”;
查找的策略是:先在源文件所在的目录下查找,如果头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件.如果找不到就提示编译错误.
Linux环境的标准头文件的路径:
/usr/include
VS环境的标准头文件的路径:
C:\Program Files (x86)\Microsoft Visual Studio 12.0\vc\include
2.库文件包含:
#include <filename.h>
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误
其实对于库文件,也可以直接用""的形式包含,但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了.
3.5.2嵌套文件包含
这样最终的程序会同时出现两份common.h的内容,这样就造成了文件内容的重复
要解决这个问题,我们可以使用条件编译
我们可以在每个文件的开头加上
#ifdef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST_H__
//其中,__TEST_H__的名字取决于被包含的文件名
或者加上
#pragma once
可以避免头文件的重复引入
4.其他预处理指令
#error
#pragma
#line
...
[外链图片转存中…(img-xasSJsLi-1645803467099)]
这样最终的程序会同时出现两份common.h的内容,这样就造成了文件内容的重复
要解决这个问题,我们可以使用条件编译
我们可以在每个文件的开头加上
#ifdef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST_H__
//其中,__TEST_H__的名字取决于被包含的文件名
或者加上
#pragma once
可以避免头文件的重复引入
4.其他预处理指令
#error
#pragma
#line
...