编译和链接+linux基础讲解
一、翻译环境和运行环境
在 ANSI C(c语言标准) 的任何⼀种实现中,存在两个不同的环境。
- 第1种是翻译环境,在这个环境中源代码被转换为可执⾏的机器指令(⼆进制指令)。
- 第2种是执⾏环境,它⽤于实际执⾏代码。
解读:
c语言代码在程序中敲的,本质上是写的文本信息,经过编译环境的处理将其翻译成二进制可执行的机器指令(因为计算机只能看懂0/1),此时计算机需要执行这个可执行机器指令,就需要执行环境,这个环境一般是电脑自带的。
- 就像是中国人和美国人语言不通,此时需要一个翻译官将中文翻译成英文给美国人,美国人才能听懂。
也就是我们写的源程序,经过翻译环境的编译和链接操作,生成了可执行程序,在执行环境中,计算机便可以运行这个可执行的二进制机器指令了。
1、翻译环境和运行环境
前面说了源程序(我们写的程序文本)经过翻译环境的编译和链接操作生成可执行文件,具体是怎么做到的呢?
翻译环境是由编译和链接两个大的过程组成的,而编译又可以分解成:预处理(有些书也叫预编译)、编译、汇编三个过程
⼀个C语⾔的项⽬中可能有多个 .c ⽂件⼀起构建,那多个 .c ⽂件如何⽣成可执⾏程序呢?
- 多个.c文件单独经过编译器,编译处理⽣成对应的目标文件。
- 注:在Windows环境下的目标文件的后缀是 .obj ,Linux环境下⽬标⽂件的后缀是 .o
- 多个⽬标⽂件和链接库⼀起经过链接器处理生成最终的可执⾏程序。
- 链接库是指运⾏时库(它是⽀持程序运⾏的基本函数集合)或者第三方库。
可以对应到程序和文件中看:当然如果在一个c语言项目中存在多个.c文件,那么可以查到到多个.c文件经过编译生成的对应的.obj文件,以及最终这些文件生成的可执行文件。
所谓的编译器和链接器可以在everything中查找:注意!先打开vs在打开everything,他才能在vs的文件中查找
- 编译器:有好几个,应该是不同版本的
- 链接器:里面有好有好几个link.exe,可能是好几个版本的。
如果将编译器展开为3个过程,就变成了下图:
二、接下来展开讲解编译和链接和运行的详细过程:
三、编译环境
1、预处理/预编译
在预处理阶段,源文件和头文件会被处理成为 .i 为后缀的文件。
由于vs叫集成开发环境,集成指的是他将这些底层的细节给封装起来了,当你点击三角(或者ctrl+F5)运行的时候其实他就完成编译链接了,然后运行产生了结果
编译器进行编译过程我们可以通过编译器运行来时查看,可以查看预处理,编译,汇编过程,那么我们以linux环境下的gcc编译器在vscode远程链接到linux服务器上操作。
这里编译器是gcc,其实编译器都是可以进行编译的,他们区别并不是很大,在有些实现上可能有细微的差异。集成开发环境采用的编译器会有不同,vs采用的就是vs编译器,dev c++采用的就是gcc等等…
2、vscode远程链接到linux服务器
这里我采用的是ubuntu系统,利用wsl进行连接。
什么是虚拟机,为什么能承载linux系统?
其实我们使用的电脑(pc端),手机,平板(移动端),都需要操作系统来控制。
一个完整的计算机:硬件+软件
硬件:计算机系统中由电子,机械,光电元件等组成的各种物理装置的总称。
软件:是用户与计算机系统之间的接口和桥梁,用户通过软件与计算机进行交流,而操作系统就是软件的一类。
常见的操作系统
操作系统是计算机软件的一类,主要负责作为用户和计算机硬件之间的桥梁,调度和管理计算机硬件进行工作。操作系统可以做的:调度计算机主机硬件(cpu,内存)以及外设(输入设备,输出设备,外部存储器, 通讯设备)
- 调度CPU进行工作
- 调度内存进行工作
- 调度硬盘进行数据存储
- 调度键盘进行文字输入
- 调度显示屏显示内容
- 调度网卡进行网络通讯
- 调度音响发出声音
- 调度打印机打印内容 …
pc端:windows,macos,linux
前两个是常见的桌面操作系统,linux是服务器的操作系统(来到服务器的领域,linux是当之无愧的王者)
移动端:鸿蒙系统,Android,IOS
linux系统的组成
linux系统内核+系统级的应用程序
linux内核:是linux的核心所在,提供系统核心功能,比如调度cpu,调度内存,调度文件系统,调度网络通讯,调度IO等。
系统级应用程序:可以理解为电脑出厂自带的应用程序,帮助我们快速上手操作系统,比如文件资源管理器,音乐播放器等等。
注:无论是第三方应用程序,还是系统级的应用程序,都是通过调度内核,再由内核调度相关硬件工作。比如你使用酷狗音乐或者是电脑自带的音乐播放器,他们都是调度内核,由内核调度CPU解码,音响发声等硬件进行工作。可以理解为程序调度内核,内核调度硬件。
linux发行版
由于linux的内核是开源的,意味着所有的人都可以获得内核并对其修改,再装上系统级应用程序便可以做出一个linux,这就叫做linux的发行版,市面上linux的发行版一般有这些:
- 国外比较火的使用ubuntu,我国经常使用centos,其实无论是哪个发行版的linux,学到的内容都一样,只是有些命令不一样。比如centos下载程序的时候使用sudo yum install 程序安装包,ubuntu则采用sudo apt install 程序安装包
虚拟机
一般我们电脑只使用上一个操作系统,比如windos,macos,如果想用linux操作系统怎么办,需要把电脑重装linux操作系统吗,这样的话,你平日办公会很不方便,linux操作系统主要是基于服务器端的,而且大部分都是使用终端去操作的,它的图形化界面也并不成熟。所以呢,有没有其他办法,能够让你的电脑同时拥有linux和windows/macos系统呢。-----虚拟机/wsl
- 借助虚拟化的技术,可以使用虚拟化软件模拟出硬件系统,再给虚拟的硬件系统装上真实的操作系统,就获得了一个完整小电脑,以便于我们使用linux系统。
那么便可以使用虚拟机vm ware 软件去模拟硬件系统,装上linux系统去学习linux。这里vmware软件,我推荐跟着黑马程序员下载,地址在这vm ware,wm上安装linux系统windos系统,Macos系统,进行远程链接:远程链接linux,进行wsl配置:wsl配置。
wsl简介:windos subsystem for linux:windows为linux提供的子系统,这是windos电脑自带的,win10,win11都有,但是配置有些不一样,我的电脑就是win11,老师教的是win10的,win11操作升级了,又不一样的地方,但是好在视频弹幕都有教win11怎么操作
- 这里注意win11系统,wsl老版本咱们可能用不了了,打开Ubuntu的时候它跟你说并不能打开,具体会有什么我忘了,当时我去网上找到了新版本的wsl安装上就可以打开了:
- 内核安装博客
3、好啦,现在我们开始远程链接wsl(ubuntu)基于linux环境来调试代码吧~
- 现在我们打开vscode,打开这个
搜索remote ssh,远程连接插件,下载,在搜索c/c++下载下面两个
点击左下角:连接到wsl
可以看到这种界面,@前面是用户名,后面是主机名。
接下来输入touch main.c创建main.c文件,在输入touch add.c创建add.c文件。
接下来安装gcc编译器:sudo apt install gcc,输入密码即可以安装。
预处理/预编译:生成.i文件
预处理阶段主要处理那些源⽂件中#开始的预编译指令。⽐如:#include,#define,处理的规则如下:
- 将所有的 #define 删除,并展开所有的宏定义。
- 处理所有的条件编译指令,如: #if、#ifdef、#elif、#else、#endif 。
- 处理#include 预编译指令,将包含的头⽂件的内容插⼊到该预编译指令的位置。这个过程是递归进⾏的,也就是说被包含的头⽂件也可能包含其他⽂件。
- 删除所有的注释
- 添加⾏号和⽂件名标识,⽅便后续编译器⽣成调试信息等。
- 或保留所有的#pragma的编译器指令,编译器后续会使⽤。
经过预处理后的 .i ⽂件中不再包含宏定义,因为宏已经被展开。并且包含的头⽂件都被插⼊到 .i⽂件中。所以当我们⽆法知道宏定义或者头⽂件是否包含正确的时候,可以查看预处理后的 .i ⽂件来确认。
- 编写程序内容
main.c:
//将#include头文件内容添加到文件指令对应位置
//这个过程是递归的,也就是说如果头文件中包含其他的头文件
//那么会递归的加上其他头文件的内容,如果这个头文件中还有其他的头文件
//那么还会加上其他的头文件内容,一直这样反复,便是递归添加头文件内容的过程
#include<stdio.h>
//替换宏定义的值,并删除宏定义信息
#define M 100
//保留#pragram语句,后续会使用
#pragma pack(8)
//处理条件预编译指令,这个鹏哥说下节课讲
extern int add(int x,int y);
//声明外部函数,外部变量也可以
int mian()
{
int a = M;
int b = 10;
//删除注释内容
//这是一行注释
int ret = add(a,b);
printf("%d \n",ret);
return 0;
}
add.c:
int add(int x,int y)
{
return x+y;
}
- 对main.c进行编译:输入此命令 gcc -E main.c -o main.i 使用gcc编译器对main.c源文件进行编译输出main.i文件。
- 可以看到头文件的内容被添加进去了,并且添加了对应的行号,和文件标识,方便编译器生成调试信息
- 可以看到函数语句保留了,注释删除了,并且define语句被删除了,将信息转换为对应的数值了。
- 包含了递归的其他头文件内容,还可以看到这个头文件中包括了好多函数的声明呀,我们上下节课讲的文件操作的fscanf函数也在这里。这也可以看出,头文件中包含相应的库函数,包含了相应的头文件,我们便可以使用库函数了。
编译:生成.s汇编文件
编译过程就是将预处理后的⽂件进行一系列的:词法分析、语法分析、语义分析及优化,生成相应的汇编代码文件。
对下面的代码进行编译:
array[index] = (index+4)*(2+6);
这个代码在程序员眼里:右边的值经过按照优先级的计算赋值给左边的数组下标为index的元素。
在编译器中进行编译操作就会这样处理这段代码:
- 词法分析:
将源代码程序被输⼊扫描器,扫描器的任务就是简单的进⾏词法分析,把代码中的字符分割成⼀系列的记号(关键字、标识符、字⾯量、特殊字符等)。
上⾯程序进⾏词法分析后得到了16个记号:
- 语法分析:
接下来语法分析器,将对扫描产⽣的记号进⾏语法分析,从⽽产⽣语法树。这些语法树是以表达式为节点的树。
这个也不难理解,从树的右边最下层开始index+4,再计算同级的子树2+6,得出两个结果之后再乘起来,再计算左子树array[index],最后经过赋值操作得出结果
可以发现每个子树的根节点都是操作符哦,孩子节点都是操作数,操作数通过操作符连接计算。
- 语义分析:
由语义分析器来完成语义分析,即对表达式的语法层⾯分析。编译器所能做的分析是语义的静态分析。静态语义分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息。
- 比如你等号右边表达式推导出是整形,但是左边表达式是float,当你运行的时候就会报警告:int转为浮点型会精度丢失,这个就是编译过程找到的错误。
现在我们从编译器上看看编译后的汇编文件吧
gcc -S main.i -o main.s
一堆堆的汇编指令,up没学过编译emmm很遗憾不能给各位解释了。
汇编:生成.o文件/.obj文件
汇编器是将汇编代码转转变成机器可执⾏的指令,每⼀个汇编语句⼏乎都对应⼀条机器指令。就是根据汇编指令和机器指令的对照表⼀⼀的进⾏翻译,也不做指令优化。汇编的命令如下:gcc -c main.s -o main.o
当打开文件时,你会发现二进制文件和文本文件的本质区别:
编码:内存中的数据写入到文件中
- 二进制文件
直接存储原始字节流,无需任何字符编码转换。
必须按照特定格式解析(如可执行文件的ELF头、图片的PNG结构)。
示例:一个float值3.14存储为0x40 48 F5 C3(IEEE 754格式)。 - 文本文件
通过字符编码规则将字符映射为字节序列,包括:
ASCII(1字节,仅英文)
Unicode(如UTF-8变长编码、UTF-16固定2/4字节)
本地化编码(如GBK中文、ISO-8859-1西欧)
示例:汉字"中"在UTF-8中存储为0xE4 B8 AD。
解码:文件中的数据被读取打开
- 工具行为差异
文本编辑器(如VS Code,记事本)会按编码解析字节为字符,编码和解码的方式要一致才不会发生乱码,若强行打开二进制文件会显示乱码。
二进制工具(如HexFiend)直接展示原始字节,不尝试解码。
文本文件中换行符(\n、\r\n)会被工具自动转换,而二进制文件中会保留原始字节。
所以将二进制机器指令目标文件main.o,使用文本方式进行打开,尝试解码,就会出现乱码,实际上二进制直接展开原始字节,不能尝试解码。
链接
链接是⼀个复杂的过程,链接的时候需要把⼀堆文件链接在⼀起才生成可执行程序。
链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。链接解决的是⼀个项目中多文件、多模块之间互相调用的问题。
⽐如:
在⼀个C的项⽬中有2个.c⽂件( test.c 和 add.c ),代码如下:
main.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;
}
add.c:
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 也是类似的方法来修正地址。这个地址修正的过程也被叫做:重定位
- 大概的过程就是:
- 首先进行全局变量的空间和地址的分配,产生一个表,存放全局变量和函数名,以及对应的地址。
- 由于每个文件都单独编译,并不知道从外部文件引入的函数和外部全局变量地址,所以存放一个临时值地址。
- 将所有文件进行链接的时候,就会在其他文件中查找这些函数和外部变量的地址,然后合并这个表的时候进行符号决议去掉重复的函数或者外部变量值,进行重定位把临时值替换为有效地址,而局部变量(非静态)不参与链接,它们的地址在编译时已由编译器分配(位于栈或寄存器)。只有静态局部变量(如static int x;)会被放入符号表,且作用域限定在单文件。
- 最终通过查表进行多个文件,多个模块的互相调用。
- 程序头表(Program Header):指导操作系统如何加载文件(如代码段/数据段的内存位置)。程序头表是ELF格式(Linux)的概念,Windows的PE文件(.exe)中对应的是“PE头”。
调试信息(可选):如DWARF格式的调试符号。加上代码和数据形成可执行文件.exe。
注:如果我们把main.c中的add写成了Add,那么表中的Add是临时地址,而真正的add地址讲没有办法在链接的时候进行重定位,替换为真是的地址,相当于表中有两个地址了,Add的临时地址,add的真实地址,当我们运行程序时调用Add的时候去查表,发现是一个伪地址,病不起作用,就会报一个常见的错误:未解析的外部符号Add,所以函数出现这个错误的时候是链接检查出来的,并且原因极有可能是你吧函数名称写错了,同理全局变量,局部变量,静态变量出现说未解析的外部符号,都极有可能是因为把名字写错了
前⾯我们⾮常简洁的讲解了⼀个C的程序是如何编译和链接,到最终⽣成可执⾏程序的过程,其实很多内部的细节⽆法展开讲解。⽐如:⽬标⽂件的格式elf,链接底层实现中的空间与地址分配,符号解析和重定位等,如果你有兴趣,可以看《程序员的⾃我修养》⼀书来详细了解。或者大学开设的一门课程叫做编译原理也很清晰的讲解了这个过程。
四、运行环境
- 程序必须载⼊内存中。在有操作系统的环境中:⼀般这个由操作系统完成。在独⽴的环境中,程序的载⼊必须由⼿⼯安排,也可能是通过可执⾏代码置⼊只读内存来完成。
- 这里讲的是比如单片机/stm32这种板子,由于他们没有操作系统,无法自动的将程序挂载到内存上,就没有办法运行,我们经常有一个操作叫做烧板子,就是手动的将程序烧入到内存上,才得以运行程序以c语言调用相应的接口进行工作。
- 程序的执⾏便开始。接着便调⽤main函数。
- 开始执⾏程序代码。这个时候程序将使⽤⼀个运⾏时堆栈(stack),存储函数的局部变量和返回地址。每当遇到一个函数时,就使用一个运行时堆栈,进行压栈,调用函数维护运行时堆栈,结束时收回运行时堆栈,这就是函数栈帧的创建和销毁的过程,程序同时也可以使⽤静态(static)内存,存储于静态内存中的变量在程序的整个执⾏过程⼀直保留他们的值。
- 终⽌程序。正常终⽌main函数;也有可能是意外终⽌。比如程序还没运行完成,就断带电了。