程序是如何运行的

2021年9月15日 10点热度 0条评论 来源: 何朴尧

转载https://www.jianshu.com/p/7d11045a40f8

对于任何一个学习过C语言的来说,“HelloWorld”程序都不会陌生。因为它应该是你打开新世界的看到的第一束光。至今我还记得第一次敲出这个程序的时候激动了好久。但是你们知道短短的几行代码,是怎么让程序运行起来的么?

// hello.c
#include <stdio.h>
int main(int argc, char *argv[]) {
printf(“Hello World!\n”);
return 0;
}
程序是如何运行起来的?很多人可能会说,不就是五个步骤:预处理(Prepressing),编译(Compilation),汇编(Assembly)和链接(Linking),装载(Loading)么?是这样的。但是你知道每一步背后都做过一些什么吗?如果你能回答上以下的问题,我想这个文章就没有必要看下去了。

在main()函数调用之前,程序做过一些什么?

编译出来的可执行文件里面有什么,在内存中是什么样子的,是怎么来组织的?

静态链接、动态链接,有什么区别?

不同的编译器(Micrsoft VC/VS, GCC)和不同的硬件平台(X86,SPARC,MIPS,ARM),以及不同的操作系统(Windows,Linux,Unix,Solaris),最终编译出来的结果一样么?

ELF文件,PE文件,COFF文件,是什么?

如果你发现对其中的一些问题,不是很了解的话,甚至没有想过这些问题的时候,而你有向了解一下,那么就可以,跟着我的步伐一步俩步,往下看啦。这个文章是为你准备的。需要声明的是,本文主要针对gcc编译器,也就是针对C和C++,不一定适用于其他语言的编译。下图为总览。

GCC编译过程
预处理
预处理的过程,其实,主要是处理那些源代码中以#开始的预编译指令。比如#include,#define等,处理的规则如下:

将所有的#define删除,并且展开所有的宏定义

处理所有的条件预编译指令,比如#if, #ifdef, #elif, #else, #endif等

处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。在这个插入的过程中,是递归进行的,也就是说被包含的文件,可能还包含其他文件。

删除所有注释 //和/**/.

添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号。

保留所有的#pragma编译器指令,因为编译器需要使用它们。

对于第一步预编译的过程,可以通过以下方式完成:

gcc -E hello.c -o hello.i
或者

cpp hello.c > hello.i
编译
编译过程可分为6步:词法分析、语法分析、语义分析、源代码优化、代码生成、目标代码优化。对应与下图的每一步。下面我们以一个具体的表达式进行分析:

array[index] = (index + 4)*(2 + 6);
Compilation
词法分析:扫描器(Scanner)将源代的字符序列分割成一系列的记号(Token)。
记号 类型
array 标记符
[ 左方括号
index 标记符
] 右标记符
= 赋值
( 左圆括号
index 标记符

  • 加号
    4 数字
    ) 右圆括号
  • 乘号
    ( 左圆括号
    2 数字
  • 加号
    6 数字
    ) 右圆括号
    注:lex工具,可实现按照用户描述的词法规则将输入的字符串分割为一个一个记号。

语法分析:语法分析器将记号(Token)产生语法树(Syntax Tree)。

Syntax Tree

注:yacc工具(yacc: Yet Another Compiler Compiler)可实现语法分析,根据用户给定的语法规则对输入的记号序列进行解析,从而构建一个语法树,所以它也被称为“编译器编译器(Compiler Compiler)”。

语义分析:编译器所分析的语义是静态语义,所谓静态语义就是指在编译期可以确定的语义,通常包括声明,和类型的匹配,类型的转换。

Commented Syntax Tree

注:与之对于的为动态语义分析,只有在运行期才能确定的语义。

源代码优化:源代码优化器(Source Code Optimizer),在源码级别进行优化,例如(2 + 6)这个表达式,其值在编译期就可以确定。优化后的语法树。
Paste_Image.png

但是直接作用于语法树比较困难,所以源代码优化器往往将整个语法数转化为中间代码(Intermediate Code)。
注:中间代码是与目标机器和运行环境无关的。中间代码使得编译器被分为前端和后端。编译器前端(1-4步)负责产生机器无关的中间代码;编译器后端(5-6步)将中间代码转化为目标机器代码。

目标代码生成:代码生成器(Code Generator)。

目标代码优化:目标代码优化器(Target Code Optimizer)。

最后的俩个步骤十分依赖与目标机器,因为不同的机器有不同的字长,寄存器,整数数据类型和浮点数据类型等。

汇编
汇编器是将汇编代码转变成机器可以执行的命令,每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,所以根据汇编指令和机器指令的对照表一一翻译即可。汇编过程可以通过以下方式完成。

as hello.s -o hello.o
或者

gcc -c hello.s -o hello.o
链接
静态链接
把一个程序分割为多个模块,然后通过某种方式组合形成一个单一的程序,这就是链接。而模块间如何组合的问题,归根到底,就是模块如何进行通信的俩个问题:(1) 模块间的函数调用,(2) 模块间的变量访问。但无论是那一个问题,其本质是获取一个地址,函数运行的地址、或者变量存放的地址。

如果熟悉汇编的,应该会知道hello.o文件,既目标文件,是以分段的形式组织在一起的。其简单来说,把程序运行的地址划分为了一段一段的片段,有的片段是用来存放代码,叫代码段,这样,可以给这个段加个只读的权限,防止程序被修改;有的片段用来存放数据,叫数据段,数据经常修改,所以可读写;有的片段用来存放标识符的名字,比如某个变量 ,某个函数,叫符号表;等等。由于有这么多段,所以为了方便管理,所以又引入了一个段,叫段表,方便查找每个段的位置。

当文件之间相互需要链接的时候,就把相同的段合并,然后把函数,变量地址修改到正确的地址上 。这就是静态链接,如下图。

静态链接
但是这里有俩个问题:

对于计算机的内存和磁盘的空间浪费比较严重

想想一下,现在一个静态库,至少都是1MB以上。但是假如有1000个或者更多的程序在链接的时候,都静态链接了它,那么当这些程序运行起来的时候,内存中就会存在1000+相同的副本,还是一模一样的。这样,至少1GB空间就浪费了。

程序的更新,部署,和发布会带来很多麻烦

比如一个程序Program所使用的Lib.o是使用的第三方厂商提供的,那么当该厂商更新了Lib.o(比如修复了一个bug,或者优化了性能),那么Program的厂商就必须要拿到最新版的Lib.o,然后与Program.o链接。将新的Program发给用户。这样,一旦程序任何位置有一个小小的改动,都会导致重新下载整个程序。

动态链接
我们的想法很简单,就是当第一个例子在运行时,在内存中只有一个副本;第二个例子在发生时,只需要下载更新后的lib,然后链接,就好了。那么其实,这就是动态链接的基本思想了:把链接这个过程推迟到运行的时候在进行。在运行的时候动态的选择加载各种程序模块,这个优点,就是后来被人们用来制作程序的插件(Plug-in)。

这里,我们不得不介绍一个东西,叫做动态链接器。它会在程序运行的时候,把程序中所有未定义的符号(比如调了动态库的一个函数,或者访问了一个变量)绑定到动态链接库中。简单的来说就是把程序中函数的地址改正到动态库,之后动态链接器会把控制权交给程序,然后程序执行。

这种在装载时修正地址,经常被称为装载时重定位(Load Time Relocation)。而静态链接时修正,则被称为链接时重定位(Link Time Relocation)。

可能有的人,就要问了,多个程序应用一个库不会有问题么?变量冲突?是这样的。动态链接文件,把那些需要修改的部分分离了出来,与数据放在了一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本,这种方案就是目前被称为地址无关代码(PIC,Position-independent Code)的技术。

链接库
通过上面,我们了解到了动态链接,静态链接。一组相应目标文件的集合,我们称它为库。因而也就有了静态链接库,动态链接库。

静态链接库:在Linux平台上,常以.a或者.o为拓展名的文件,我们最常用的C语言静态库,就位于/usr/lib/libc.a;而在Windows平台上,常以.lib为拓展名的文件,比如Visual C++附带的多个版本C/C++运行库,在VC安装的目录下的lib\目录。

动态链接库:在Linux平台上,动态链接文件为称为动态共享对象(DSO,Dynamic Shared Objects),简称共享对象。他们一般常以.so为拓展名的文件;而在Windows平台上,动态链接文件被称为动态链接库(DLL,Dynamical Linking Library),通常就是我们常见的.dll为拓展名的文件。

装载
介绍装载就不得不介绍三种文件格式了:ELF,PE,COFF。现在PC平台上流行的可执行文件格式(Executable),无论是Windows下的PE(Portable Executable)文件,还是Linux下的ELF(Executable Linkable Format)文件,都是COFF(Common file format)文件格式的变种。可执行文件例如,Windows下的*.exe,Linux下的/bin/bash。其实目标文件,内部结构上来说和可执行文件的结构几乎是一样的,所以一般跟可执行文件格式一起用一种格式进行存储。

下面以ELF文件为例子,介绍。

每一个ELF文件,都会有一个ELF文件头,里面会记录很多关于这个程序相关信息,通过它确定段表,进而确定各个段。总的来说,装载做了以下三件事情:

创建虚拟地址空间

读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系

将CPU的指令寄存器设置为运行库的初始函数(初始函数不止一个,第一个启动函数为:_start),初始了main()函数的环境,然后指向可执行文件的入口

作者:Torival
链接:https://www.jianshu.com/p/7d11045a40f8
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

    原文作者:何朴尧
    原文地址: https://blog.csdn.net/qq_39054069/article/details/84337209
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系管理员进行删除。