C语言编译与链接(29)


前言

  本篇同样是一篇修炼内功的文章
  很重要!它将会让你对程序的构建运行有一个更深的认识


一、翻译环境和执行环境

在ANSI C的任何一种实现中,存在两个不同的环境:

  1. 翻译环境,在这个环境中源代码被转换为可执行的机器指令(二进制指令
  2. 执行环境,它用于实际执行代码

在这里插入图片描述

在本篇中,我们会来详细介绍一下翻译环境

二、翻译环境

翻译环境是由编译和链接两个大过程组成的,将源代码转换为可执行的机器指令,编译又可以分解成:预处理(预编译)、编译、汇编三个过程
在这里插入图片描述

再简要一点,就像如下图
在这里插入图片描述

其中,几个.c文件经过编译器的处理,生成了几个.obj文件就叫做编译器,它们再与链接库一起经过链接器处理生成可执行程序.exe,这就叫做链接

具体来说,多个 .c 文件如何生成可执行程序呢?
在我细说之前,你先得有以下认识:

  1. 多个.c文件单独经历编译处理生产对应的目标文件(windows环境下后缀为.obj,Linux环境下后缀为.o)
  2. 多个目标文件和链接库一起经过链接器处理生成可执行程序
  3. 链接库是指运行时库(支持程序运行的基本库函数集合)或者第三方库

然后就跟我一起从头开始,构建一个完整的程序的过程吧~

预处理(预编译)

在预处理阶段,源文件和头文件会被处理成为.i为后缀的文件

此阶段主要处理那些源文件中#开始的预编译指令,例如#include,#define

具体规则如下:

  1. 将所有的 #define 删除,并展开所有的宏定义
  2. 处理所有的条件编译指令,如: #if、#ifdef、#elif、#else、#endif
  3. 处理#include预编译指令,将包含的头文件的内容插⼊到该预编译指令的位置
  4. 这个过程是递归进行的,也就是说被包含的头文件也可能包含其他文件
  5. 删除所有的注释
  6. 添加行号和文件名标识,方便后续编译器生成调试信息等,或保留所有的#pragma的编译器指令,编译器后续会使用

如果想在gcc环境下观察,可以输入以下命令行:
gcc -E test.c -o test.i

请注意!经过预处理后的.i文件中不再包含宏定义,因为宏已经被展开。并且包含的头文件都被插⼊到.i⽂件中。所以当我们无法知道宏定义或者头⽂件是否包含正确的时候,可以查看预处理后的.i⽂件来确认

如该图,我们发现#include <stdio.h>被展开,竟然有好几百行!且添加了文件名标识
至于上述其他规则特性,就交由大家自行验证了
在这里插入图片描述

编译

编译过程:将预处理后的文件进行一系列词法分析、语法分析、语义分析以及优化,生成相应的汇编代码文件

指令为:
gcc -S test.i -o test.s

假设这个时候来了个语句:

nums[index] = (index + 4) * (2 + 6);

词法分析

将源代码程序被输入扫描器,扫描器的任务就是简单的进行词法分析,把代码的字符分割成一系列的记号(关键字、标识符、字面量、特殊字符等。)
在这里插入图片描述

语法分析

将对扫描产生的记号进行语法分析,从而产生语法树(以表达式为节点的树)
在这里插入图片描述

语义分析

由语义分析器完成语义分析,即对表达式的语法层面分析。编译器所能做的分析是语义的静态分析。静态分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息
在这里插入图片描述

到这里,程序的后缀已经变成.s了
在这里插入图片描述

汇编

由汇编器将汇编代码转为机器指令(二进制指令),每一句汇编语句几乎都对应一条机器指令。按照汇编指令和机器指令的对照表进行一一的进行翻译,不做指令优化

指令为:
gcc -c test.s -o test.o

然后你会发现看不了,看不了就对了,就算能看也是乱码
在这里插入图片描述

链接

将一堆文件链接在一起生成可执行程序,为解决一个项目中多文件、多模块之间互相调用的问题,主要过程:地址和空间分配,符号决议和重定位等步骤,事实上,这很复杂,我也只是粗略点拨一下

现在我们来个具体例子,现在在一个C的项目里面,有两个.c文件(main.c、add.c)

//test.c
#include <stdio.h>

//声明外部函数
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;
}
//add.c
int g_val = 2024;

int Add(int x, int y)
{
	return x + y;
}

现在我们开始运行程序,首先通过前文我们知道,两个.c文件经过编译后生成两个.o目标文件,也就是说

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 也是类似的方法来修正地址

这个地址修正的过程也被叫做:重定位

事实上,我第一次知道链接是这样子运行的时候,我还是蛮激动的,这太巧妙了,不得不感叹计算机殿堂里先辈们的智慧

  我对这个过程有个还算贴切的比方,假如小举和小帆是好兄弟,小举年纪到了要买房子,首付三十万,此时手里只能拿出二十五万,剩下五万,要找小帆借,小帆答应了,小举就去签字了
  这时候,买房子就相当于是要调用Add函数和g_val变量,可是在自己的.c文件没有,对应差五万,找小帆借,小帆答应了,对应着声明,可是声明还需要有定义,此时还只是口头承诺,若另一个文件真的有声明,就能修正为正确的地址,对应小举真的拿到钱,另一种情况则是由于某些原因小帆无法借钱,就像是另一个文件里没有定义,这就导致之前错误的地址无法修正,承诺未能兑现,程序出错,小帆也就交不了首付了,Bad Ending~

三、运行环境

  可能你会觉得编译的过程好难啊,或者我讲得好浅啊!

那你感觉对了,我在这里只是讲了点皮毛,大学计科对于编译有一门专门的课程叫做《编译原理》
这可能是计算机类专业最无聊、相对来说最难的一门课了~

那我们接着来讲运行环境吧,在这里我就讲些有关注意点吧:

  1. 程序必须载入内存中。在有操作系统的环境中:⼀般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排(嵌入式的烧录),也可能是通过可执行代码置入只读内存来完成
  2. 程序的执行便开始接着便调用main函数
  3. 开始执行程序代码。这个时候程序将使用⼀个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程⼀直保留他们的值
  4. 终止程序。正常终止main函数;也有可能是意外终止

总结

在这里插入图片描述

  来个总览,本篇应该能让你有些启发,下篇我们会对预处理那一部分再详细介绍一下

评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值