Faye:孤独让我们与我们所爱的人相处的每个瞬间都无比珍贵,让我们的回忆价值千金。它还驱使你去寻找那些你在我身边找不到的东西。
---------《寻找天堂》
目录
一、编译和链接的介绍
1.1 程序的翻译环境和执行环境
1.1.1 翻译环境
1.1.2 运行环境
1.2 预处理
1.2.1 预定义符号
1.2.2 #define
#define的语法:
#define 替换规则:
#和##
带副作用的宏参数
编辑 宏和函数对比
#undef
1.3 编译
1.3.1 词法分析
1.3.2 语法分析
1.3.3 语义分析
1.4 汇编
1.5 链接
一、编译和链接的介绍
1.1 程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码。
注:ANSI C是由美国国家标准协会(ANSI)及国际标准化组织(ISO)推出的关于C语言的标准。ANSI C 主要标准化了现存的实现, 同时增加了一些来自 C++ 的内容 (主要是函数原型) 并支持多国字符集 (包括备受争议的三字符序列)。 ANSI C 标准同时规定了 C 运行期库例程的标准。
1.1.1 翻译环境
- 组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
- 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
- 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中
翻译的几个环节,通过下面的图进行初步的了解:
1.1.2 运行环境
- 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
- 程序的执行便开始。接着便调用main函数。
- 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程 一直保留他们的值。
- 终止程序。正常终止main函数;也有可能是意外终止。
1.2 预处理
在预处理阶段,源⽂件和头⽂件会被处理成为 .i 为后缀的⽂件。在 gcc 环境下想观察⼀下,对 test.c ⽂件预处理后的.i⽂件,命令(以下所有的命令在Linux下的指令)如下:
gcc -E test.c -o test.i
在Linux下执行这条指令后,生成.i文件,查看里面内容大多都是宏
预处理阶段主要处理那些源⽂件中#开始的预编译指令。
⽐如:#include,#define,处理的规则如下:
- 将所有的 #define 删除,并展开所有的宏定义。
- 处理所有的条件编译指令,如: #if、#ifdef、#elif、#else、#endif 。
- 处理#include 预编译指令,将包含的头⽂件的内容插⼊到该预编译指令的位置。这个过程是递归进行的,也就是说被包含的头⽂件也可能包含其他⽂件。
- 删除所有的注释
- 添加⾏号和⽂件名标识,⽅便后续编译器⽣成调试信息等。
- 或保留所有的#pragma的编译器指令,编译器后续会使⽤。
经过预处理后的 .i ⽂件中不再包含宏定义,因为宏已经被展开。并且包含的头⽂件都被插⼊到 .i⽂件中。所以当我们⽆法知道宏定义或者头⽂件是否包含正确的时候,可以查看预处理后的 .i ⽂件来确认。
1.2.1 预定义符号
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
这些预定义的符号都是语言内置的。下面使用部分宏:
#include<stdio.h>
int main() {
//__FILE__进行编译的源文件 __LINE__文件当前的行号
printf(" file:%s \n line:%d\n", __FILE__, __LINE__);
//__DATE__ 文件被编译的日期 __TIME__ 文件被编译的时间
printf(" date:%s \n time:%lld\n", __DATE__, __TIME__);
return 0;
}
运行结果如下:
1.2.2 #define
#define是一种定义标识符,用来定义宏,下面是#define的功能介绍
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏( macro )或定义宏(define macro )。
举个梨子:
#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定义标识符的时候,建议不要加上 ; 这样容易导致问题。比如下面的场景:
#include<stdio.h>
#define MAX 1000;
int main() {
int condition = 0, max = 0;
if (condition)
max = MAX;
else
max = 0;
return 0;
}
在vs下进行编译,这里会出现语法错误。
#define的语法:
语法: #define name stuff
下面是宏的申明方式:
把宏名全部大写函数名不要全部大写
- 参数列表的左括号必须与name紧邻。
- 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分
举个小梨子,定义一个数的平方的宏:
#define MUL(x) x*x //参数列表的左括号必须与name紧邻
这个宏接收一个参数 x ,如果在上述声明之后,MUL(5)。置于程序中,预处理器就会用下面这个表达式替换上面的表达式:5*5
#include<stdio.h>
#define MUL(x) x*x //参数列表的左括号必须与name紧邻。
int main() {
printf("%d\n", MUL(5)); // ==> printf("%d\n", 5*5)
return 0;
}
那么如果输入的参数是一个表达式呢?MUL宏输出的结果是否还是符合预期呢?此时将MUL(5)替换为 MUL(2+3),它的预期结果应该也是25,运行一下看看
结果是11,为什么呢?这时候把(2+3)参数带入MUL宏中看看 ===> 2+3* 2+3 ====> 2+6+3,所以输出的结果变为了11。这样就比较清晰了,由替换产生的表达式并没有按照预想的次序进行求值。这里涉及到了运算符优先级的问题。在宏定义上加上两个括号,这个问题便轻松的解决了:
#define MUL(x) (x)*(x)
举另外一个小梨子,定义一个数跟自己加和的宏:
#include<stdio.h>
#define SADD(x) (x)+(x) //参数列表的左括号必须与name紧邻。
int main() {
printf("%d\n", 10*SADD(2)+ SADD(2)); //预期结果 10*4+4=44
return 0;
}
欸,这又是怎么回事?参数我也加上了小括号,不应该呀。依旧是上面的分析方法,将宏在表达式中进行展开,10*SADD(2)+ SADD(2) =====> 10 * (2) + (2)+ (2) + (2)====> 20+6=26。乘法运算先于宏定义的加法,所以出现了26。这个问题的解决办法是在宏定义表达式两边加上一对括号就可以了:
#define SADD(x) ((x)+(x))
这样运行结果便符合预期啦
通过上面两个小梨子,得出以下的经验:
用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中 的操作符或邻近操作符之间不可预料的相互作用。
#define 替换规则:
- 1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先 被替换。
- 2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
- 3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。
- 1. 宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
- 2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
#和##
字符串是有自动连接的特点的,将两个或者多个字符串紧挨着它们会自动连接,形成一个组合后的字符串,通过下面的小梨子看看:
#include<stdio.h>
int main() {
char* p = "hello ""world""!!!\n";
printf("hello ""world""!!!\n");
printf("%s", p);
return 0;
}
如果把这些写到宏里是不是实现同样的效果呢?
使用 # ,把一个宏参数变成对应的字符串。还可以添加部分参数,进行打印:
#include<stdio.h>
//使用 # ,把一个宏参数变成对应的字符串
//FORMAT 数据输出格式,VALUE 数据
#define PRINT(FORMAT,VALUE) printf("the value of " #VALUE " is "FORMAT"\n", VALUE);
int main() {
int i = 10;
PRINT("% d" , i + 5)
return 0;
}
代码中的 #VALUE 会预处理器处理为: "VALUE"
## 的作用
## 可以把位于它两边的符号合成一个符号。 它允许宏定义从分离的文本片段创建标识符。
通过一个小梨子看看:
#include<stdio.h>
#define ADD_TO_SUM(num, value) s##num += value;
int main() {
int s1 = 0, s2 = 0, s3 = 0;
ADD_TO_SUM(1, 10) // 作用是:给s1增加10.
ADD_TO_SUM(2, 20) //给s2增加20.
ADD_TO_SUM(3, 30) //给s3增加30.
printf("s1: %d s2: %d s3: %d", s1, s2, s3);
return 0;
}
带副作用的宏参数
x + 1 ; // 不带副作用 不会改变参数的数值x ++ ; // 带有副作用 参数的数值被永久修改
借用上面MUL的宏,运行下列代码:
#define MUL(x) (x)* (x)
int main() {
int i = 2;
printf("MUL: %d i: %d\n", MUL(i++),i);
return 0;
}
发现宏替换后MUL(i++) ====> (i++)* (i++) 。此后i被加加两次,产生了副作用
宏和函数对比
- 1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
- 2. 更为重要的是函数的参数必须声明为特定的类型。 所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以 用于>来比较的类型。
- 1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
- 2. 宏是没法调试的。
- 3. 宏由于类型无关,也就不够严谨。
- 4. 宏可能会带来运算符优先级的问题,导致程容易出现错。
- 宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。
下面表格是将宏与函数进行对比:
属性 |
#define
定义宏
|
函数
|
代码长度
|
每次使用时,宏代码都会被插入到程序中。除了非常
小的宏之外,程序的长度会大幅度增长
|
函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码
|
执行速度 |
更快
|
存在函数的调用和返回的额外开销,所以相对慢一些
|
操作符优 先级
|
宏参数的求值是在所有周围表达式的上下文环境里, 除非加上括号,否则邻近操作符的优先级可能会产生 不可预料的后果,所以建议宏在书写的时候多些括号。
|
函数参数只在函数调用的时候求
值一次,它的结果值传递给函
数。表达式的求值结果更容易预
测
|
带有
副作
用的
参数
|
参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。
|
函数参数只在传参的时候求值一
次,结果更容易控制。
|
参数 类型
|
宏的参数与类型无关,只要对参数的操作是合法的, 它就可以使用于任何参数类型
|
函数的参数是与类型有关的,如
果参数的类型不同,就需要不同
的函数,即使他们执行的任务是
相同的。
|
调试
|
宏是不方便调试的
|
函数是可以逐语句调试的
|
递 归
|
宏是不能递归的
|
函数是可以递归的
|
#undef
这条指令用于移除一个宏定义。
#undef NAME// 如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。
1.3 编译
编译过程就是将预处理后的文件进行⼀系列的:词法分析、语法分析、语义分析及优化,⽣成相应的汇编代码文件。编译过程的命令如下:
gcc -S test.i -o test.s
在Linux下执行这条指令后,生成.s文件,查看.s文件里面内容是相应的汇编代码
对下面的代码进行编译的时候,流程会是怎么样的呢?
num=(z+6)*(9/3)
1.3.1 词法分析
将源代码程序被输⼊扫描器,扫描器的任务就是简单的进⾏词法分析,把代码中的字符分割成⼀系列的记号(关键字、标识符、字⾯量、特殊字符等)。
上⾯程序进行词法分析后得到了13个记号
记号 | 类型 |
num | 标识符 |
= | 赋值 |
( | 左圆括号 |
z | 标识符 |
+ | 加号 |
6 | 数字 |
) | 右圆括号 |
* | 乘号 |
( | 左圆括号 |
9 | 数字 |
+ | 加号 |
3 | 数字 |
) | 右圆括号 |
1.3.2 语法分析
接下来语法分析器,将对扫描产⽣的记号进行语法分析,从⽽产⽣语法树。这些语法树是以表达式为节点的树
1.3.3 语义分析
由语义分析器来完成语义分析,即对表达式的语法层⾯分析。编译器所能做的分析是语义的静态分析。静态语义分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息。
1.4 汇编
汇编器是将汇编代码转转变成机器可执行的指令,每⼀个汇编语句几乎都对应⼀条机器指令。就是根据汇编指令和机器指令的对照表⼀⼀的进行翻译,也不做指令优化。汇编的命令如下:
gcc -c test.s -o test.o
在Linux下执行这条指令后,生成.o文件,查看里面内容为机器语言
1.5 链接
链接是一个复杂的过程,链接的时候需要把一堆文件链接在⼀起才生成可执行程序。链接过程的命令如下:
gcc test.o -o test
链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。链接解决的是一个项目中多文件、多模块之间互相调用的问题。比如: 在一个C的项目中有2个.c文件( test.c 和 add.c ),代码如下:
#include <stdio.h>
//test.c
//声明外部函数
extern int Add(int x, int y);
//声明外部的全局变量
extern int g_val;
int main()
{
int a = 10;
int b = 20;
int sum = Add(a, b);
printf("%d\n", sum);
return 0;
}
int g_val = 2022;
int Add(int x, int y)
{
return x+y;
}
我们已经知道,每个源⽂件都是单独经过编译器处理⽣成对应的⽬标⽂件。
- test.c 经过编译器处理⽣成 test.o
- add.c 经过编译器处理⽣成 add.o
我们在 test.c 的⽂件中使⽤了 add.c ⽂件中的 Add 函数和 g_val 变量。
我们在 test.c ⽂件中每⼀次使⽤ Add 函数和 g_val 的时候必须确切的知道 Add 和 g_val 的地址,但是由于每个⽂件是单独编译的,在编译器编译 test.c 的时候并不知道 Add 函数和 g_val 变量的地址,所以暂时把调⽤ Add 的指令的⽬标地址和 g_val 的地址搁置。等待最后链接的时候由链接器根据引⽤的符号 Add 在其他模块中查找 Add 函数的地址,然后将 test.c 中所有引⽤到Add 的指令重新修正,让他们的⽬标地址为真正的 Add 函数的地址,对于全局变量 g_val 也是类似的⽅法来修正地址。这个地址修正的过程也被叫做:重定位