Hello是每一位编程者接触编程世界的起点,其成功运行的背后暗含着计算机系统中多道“工序”的严谨配合。Hello表意为程序运行后计算机终端显示Hello,是计算机向我们say Hello,实则也是我们编程者向一门程序语言开拓的世界say Hello!
本文拟在学习完计算机系统后,对Hello程序从“诞生”到“消亡”的过程进行一步步的分析,探索从hello.c到hello的“进化”过程,探索hello的进程与存储,运行与回收,从而更加深入地理解计算机系统的奥秘。
关键词:计算机系统 Hello 进程 存储 探索
目 录
2.2在Ubuntu下预处理的命令............................................................................ - 5 -
5.3 可执行目标文件hello的格式....................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程....................................................... - 10 -
6.3 Hello的fork进程创建过程........................................................................ - 10 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................... - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理......................................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................ - 11 -
7.5 三级Cache支持下的物理内存访问............................................................ - 11 -
7.6 hello进程fork时的内存映射.................................................................... - 11 -
7.7 hello进程execve时的内存映射................................................................ - 11 -
7.8 缺页故障与缺页中断处理.............................................................................. - 11 -
第1章 概述
1.1 Hello简介
Hello是每一位编程者探索计算机程序语言道路的起点,Hello的成功运行,看似只是最基本的程序运作,其实其背后经历了复杂的运作流程,标志着程序运行环境的成功搭建,标志着程序世界运作框架初步建立的成功。
Hello表意为程序运行后计算机终端显示Hello,是计算机向我们say Hello,实则是我们编程者向一门程序语言开拓的世界say Hello!
P2P:全称为From Program to Progress,从项程序到进程。这个看似简单的过程需要经过预处理(cpp)、编译(ccl)、汇编(as)、链接(ld)等一系列的复杂动作才可以生成一个可执行目标文件。在运行时,我们进入Shell,输入指令。通过输入./hello,使Shell创建新的进程用来执行hello。 操作系统会使用fork产生子进程,然后通过execve将其加载,不断进行访存、内存申请等操作。最后,在程序结束返回后,由父进程或祖先进程进行回收,程序结束。
图 1 hello.c的编译运行过程
020:全称为From 0 to 0,从开始到结束。在执行Hello程序后,shell通过调用execve将程序内容加载到虚拟内存中,之后程序载入物理内存,实现了从无到有;程序执行结束后,进程保持终止状态,最后被父进程或祖先进程回收,shell返还最初状态,回归了“0”。
1.2 环境与工具
处理器:Intel CORE i7 10th GEN
系统类型:X64 CPU; 2GHz; 16G RAM; 256G HD Disk
软件环境:
Windows10 ;VMware Workstation pro2022;Ubuntu20.04
开发与调试工具:
VS 2022;gedit+gcc
1.3 中间结果
表 1 生成文件列表
文件名称 | 作用 |
hello.c | 储存hello程序源代码 |
hello.i | 源代码经过预处理产生的文件 |
hello.s | 源代码经过编译产生的文件 |
hello.o | 可重定位目标文件 |
hello | 二进制可执行目标文件 |
1.4 本章小结
本章对hello进行了简单介绍,分析了P2P和020的过程,概述了Hello的生成运行环境,列出了任务过程中出现的中间文件及其作用。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理顾名思义预先处理,是指在程序代码被翻译为目标代码的过程中,预处理器(cpp)根据以#开头的命令对直观的C程序进行修改,并将其所引用的库进行合并。这个过程一般包括包含头文件、宏定义等工作。
2.1.2预处理的作用
预处理为编译做准备工作,主要进行代码的文本替换工作,它会根据预处理指令来修改源代码。在源代码中,以#开头的代码段即为预处理工作的对象。有以下几个功能:
·头文件包含:例如 #include <stdio.h>,即为包含标准输入输出头文件。
·条件编译指令:相当于一个选择装置,可以让程序员通过定义不同的宏(宏定义)来决定对哪些代码进行处理,而那些代码要被忽略。以下为一些条件编译指令简要介绍:
表 2 条件编译指令
指令名称 | 功能 |
#if | 如果判断条件为真,则编译下面的代码 |
#ifdef | 判断是否宏定义,若是,则编译下面的代码 |
#ifndef | 判断是否宏定义,妥否,则编译下面的代码 |
#elif | else语句,若前置条件判断为假此条为真,则编译下面的代码 |
#endif | 结束一段if......else的条件编译指令判断 |
2.2在Ubuntu下预处理的命令
Linux中使用如下命令进行预处理:
输入:gcc hello.c -E -o hello.i
图 2 预处理指令运行效果
2.3 Hello的预处理结果解析
我们打开hello.i文件查看其内容并分析:
图 3 hello.i文件内容(截取)
观察发现,hello.i中增加了很多内容,将hello.c中几十行的代码扩充到了上千行,但大多数语句是对源文件中定义的宏进行了展开,将头文件中的内容包含到这个文件里来。例如函数的定义,以及一些结构体类型的声明。
2.4 本章小结
本章主要分析探索了hello生成的第一步,即预处理。分析了在这一步中进行的基本操作以及linux终端下的执行语句,并对生成的hello.i文件作了分析。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译是利用编译程序从源语言编写的源程序产生目标程序的过程,也是用编译程序产生目标程序的动作。也就是说编译器会将通过预处理产生的.i文件翻译成一个后缀为.s的汇编语言文件,编译就是从高级程序设计语言到机器能理解的汇编语言的转换过程。
图 4 编译转换过程
3.1.2编译的功能
编译的功能就是产生汇编语言文件,并交给机器。产生汇编语言文件又可细化为六个过程:词法分析阶段,计算机从左到右一个字符一个字符的读入源程序,对构成源程序的字符流进行扫描和分解,从而识别出一个个单词;语法分析阶段,在词法分析的基础上,将单词分解成各类语法短语;语义分析阶段,计算机按照语法树的层次关系和先后次序,逐个语句地进行语义处理;后经中间代码生成、代码优化以及可进行目标代码生成。从而将高级语言程序转为机器能理解的汇编语言。
图 5 编译过程
3.2 在Ubuntu下编译的命令
Linux中使用如下命令进行编译:
输入:gcc hello.i -S -o hello.s
图 6 编译指令运行效果
3.3 Hello的编译结果解析
在键入编译语句后,已生成hello.s文件,我们首先查看该文件:
图 7 hello.s文件内容(截取)
3.3.1工作伪指令
我们阅读hello.s文件,发现文件开篇1~11行汇编代码是以“.”作为开头的代码段(如下图)。这些代码段是指导汇编器和连接器工作的伪指令,我们通常可以忽略这些代码。
图 8 hello.s文件代码段(节选)
部分指导伪代码的含义如下表:
表 3 伪指令含义
伪指令 | 含义 |
.file | 声明源文件(此处为hello.c) |
.text | 声明代码节 |
.section | 文件代码段 |
.rodata | Read-only只读文件 |
.align | 数据指令地址对齐方式(此处为8对齐) |
.string | 声明字符串(此处声明了LC0和LC1) |
.globl | 声明全局变量 |
.type | 声明变量类型(此处声明为函数类型) |
3.3.2数据格式和寄存器结构
在解析汇编代码之前,我们需要先了解数据存储的格式以及寄存器的存储结构,Intel数据类型令2 bytes为字,4 bytes为双字,各种数据类型的大小如下表:
表 4 数据格式
变量类型 | Intel数据类型 | 汇编代码后缀 | 大小(字节) |
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l | 4 |
long | 四字 | q | 8 |
char * | 四字 | q | 8 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
以%rax寄存器为例,其结构如下图:
图 9 寄存器结构
3.3.3数据
Hello.s文件中大多数指令有一个或多个操作数,指示出执行一个操作中要使用的原数据值,以及放置结果的目的位置。源操作数值可以以常数形式给出,或是从寄存器或内存中读出。结果可以存放在寄存器或内存中。因此,各种不同的操作数可被分为三种类型:
立即数:
立即数用来表示常值。在ATT格式的汇编代码中,通常以$美元符号作为标识。其在汇编代码中的呈现形式最为简单易辨认。即数顾名思义,是直接显式使用的一类数据,在汇编代码中例如下图hello.s代码第24行,表示的含义是比较(cmp compare)简接寻到地址中的值和4,根据结果设置条件码。
图 10 hello.s文件代码段(节选)
寄存器及内存引用:
在汇编代码中,指令后面出现过许许多多的形如%rax形式的代码声明,这些就是寄存器存储的变量。此外,内存引用会根据计算出来的地址访问某个内存位置,如图10中第22行代码表示的就是将寄存器%edi中存储的值,加载到以现在栈指针%rbp指向的位置基础上,减去20所对应的地址中去。类似的加载使用的例子可同理算得,在此不做赘述。
除了上述的数据类型,一个hello.s中值得注意的数据类型为字符串类型:
字符串:
.LC0和.LC1作为两个字符串变量被声明。而在.LC0中出现的\347\224等是因为中文字符没有对应的ASCII码值无法直接显式显式,所以这样的字符方式显示。这两个字符串都在.rodata下面,是只读数据。
随后有两句leaq指令,这个指令为加载有效地址,相当于转移操作。
图 11 与字符串相关的定义和操作
3.3.4数据传送指令
数据传送指令可能是整个程序运行过程中使用的最频繁的指令。汇编代码中数据传送指令使用MOV类,这些指令把数据从源位置复制到目的位置,不做任何变化。其中,源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个寄存器,要么是一个内存地址。
MOV类中最常用的有四条指令:movb、movw、movl、movq,这些指令执行相同的操作,区别在于他们所移动的数据大小不同,如下表所示。指令的最后一个限制字符必须和寄存器所对应的数据大小保持一致。
表 5 简单的数据传送指令
指令 | 效果 | 描述 |
MOV S,D | D←S | 传送 |
movb movw movl movq | 传送字节 传送字 传送双字 传送四字 |
除此之外,还有一些指令会先将数据进行零扩展或者符号扩展之后再进行传送。典型实例就是MOVZ(零扩展)和MOVS(符号扩展),因为比价少见并且在hello.s中暂时没有相应的体现,故不在此赘述。
3.3.5压入和弹出栈数据
压入数据使用指令pushq,弹出数据使用指令popq,理解起来其实可以看做一个由两句指令组成的结合体,即如下表所示:
表 6 入栈和出栈指令
指令 | 效果 | 描述 |
pushq S popq D | R[%rsp]←R[%rsp] - 8; M[R[%rsp]]←S D←M[R[%rsp]]; R[%rsp]←R[%rsp] + 8 | 将四字压入栈 将四字弹出栈 |
栈在处理过程调用中起到至关重要的作用。在x86-64中,程序栈存放在内存的某个区域,栈向下增长,即栈顶元素的地址是所有栈中元素地址最低的,指针%rep保存着栈顶元素的地址。
3.3.6算术操作与逻辑操作
算术运算也是十分常用的一些指令类,同样的,每种算术运算指令的末尾也有b、w、l、q(例如addb)来限制数据的大小。
逻辑操作常见的有两类,一类是加载有效地址,一类是位移操作。加载有效地址指令类似于MOV类指令,它的作用是将有效地址写入到目的操作数,相当于C语言中大家所熟知的取址操作,可以为以后的内存引用产生指针。位移操作顾名思义就是将二进制数进行整体左移或者右移。
常见的算数与逻辑操作见下表:
表 7 常见的算数与逻辑操作
指令 | 效果 | 描述 |
Leaq S, D INC D DEC D NEG D NOT D | D←&S D←D + 1 D←D - 1 D← -D D← ~D | 加载有效地址 加1 减1 取负 取补 |
ADD S, D SUB S, D IMUL S, D | D←D + S D←D - S D←D * S | 加 减 乘 |
XOR S, D | D←D ^ S | 异或 |
OR S, D | D←D | S | 或 |
AND S, D | D←D & S | 与 |
3.3.7条件控制和条件分支
跳转指令会根据条件码当前的值来进行相应的跳转,其可改变一组机器代码指令的执行顺序。比较常见的是直接跳转,测试数据值,然后根据测试的结果来改变控制流或数据流。
这在hello.s中也有体现,如图12第24行指令所示。cmpl指令判断地址中的值和立即数4的大小关系,设置条件码,再处理je指令。je的含义是jump if equal,也就是说,如果此时的条件码所表示含义为相等,则控制会跳转到相应的.L2指令行。因此,跳转指令用来实现条件分支。
汇编语言中,一些指令会改变条件码,例如cmpl指令和setl指令。这种指令一般不会单独使用,会根据比较结果进行后续操作。仍如下图代码第24行,将寄存器中存储的值和立即数4进行比较,设置条件码,然后进行跳转或者其他操作。
图 12 hello.s文件代码段(节选)
3.3.8数组/指针/结构操作
C语言中的数组是一种将标量数据聚集成更大数据类型的方式,优化编译器非常善于简化数组索引所使用的地址计算。C语言另一个不同寻常的特点是可以产生指向数组元素的指针,并对这些指针进行运算。在机器代码中,这些指针会被翻译成地址运算。
我们的hello.c源文件中,主程序第二个参数为char *argv[](参数字符串数组指针),一般而言,在该指针数组中,argv[0]指向输入程序的路径和名称字符串的起始位置,argv[1]、argv[2]、…、argv[n-1]为其后跟着的参数。
如下图截取的代码(由34行到40行),该组指令表意为从栈上取这一参数并按照变址寻址的方法访问argv[1]和argv[2]。
图 13 hello.s文件代码段(节选)
3.3.9函数调用和控制转移
将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码的起始位置。不过,当稍后从Q返回时,处理器必须记录好它需要继续P的执行的代码位置。call指令用来进行函数的调用。该指令会把地址A压入栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call指令后面的那条地址。对应指令ret会从栈中弹出地址A,并将PC设置为A。
如下图所示的示例,call依次调用了atoi、sleep、getchar函数。它会先将函数的返回地址压入运行时栈中,之后跳转到相应的函数代码段进行执行。执行结束通过ret指令返回。
图 14 hello.s文件代码段(节选)
3.4 本章小结
本章较为细致地分析了编译的概念和作用,在linux系统下进行编译实践,接着分析编译得到的hello.s文件。在分析的过程中,对汇编指令做了以下简单的介绍,以及查看了Hello的机器级实现。经过思考,我们可以发现这些汇编指令和C语言代码语句之间的对应关系,理解汇编语句执行背后的逻辑,即可根据一个程序的汇编代码翻译出相应的C语言程序的大致样貌。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
汇编程序是指把汇编语言书写的程序翻译成与之等价的机器语言程序的翻译程序。汇编程序输入的是用汇编语言书写的源程序,输出的是用机器语言表示的目标程序。也就是说,汇编器会把输入的汇编指令文件重新打包成可重定位目标文件,并将结果保存成.o文件。它是一个二进制文件,包含程序的指令编码。
4.1.2汇编的作用
完成从汇编语言文件到可重定位目标文件的转化过程。即实现了文本文件到二进制文件的转化,将汇编指令转换成一条条机器可以直接读取分析的二进制机器指令。
4.2 在Ubuntu下汇编的命令
Linux中使用如下命令进行汇编:
输入:gcc hello.s -c -o hello.o
图 15 编译指令运行效果
4.3 可重定位目标elf格式
4.3.1 ELF头信息
键入readelf –h hello.o
查看hello.o文件的ELF头信息如下图:
图 16 hello.o文件ELF头信息
ELF头以一个16字节的序列(Magic)开始,这个序列描述了生成文件的系统的字的大小和字节顺序。ELF头剩下部分的信息包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型和机器类型等。例如上图中,Data表示了系统采用小端法,文件类型Type为REL(可重定位文件),节头数量Number of section headers为14个等信息。
4.3.2 Section头信息
键入readelf –S hello.o
查看hello.o文件的Section头信息如下图:
图 17 节头部表信息
夹在ELF头和节头部表之间的都为节,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。各部分含义如下:
表 8 部分节内容含义
名称 | 包含内容含义 |
.text | 已编译程序的机器代码 |
.rodata | 只读数据 |
.data | 已初始化的全局和静态C变量 |
.bss | 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量 |
.symtab | 一个符号表,存放一些在程序中定义和引用的函数和全局变量的信息 |
.rel.text | 一个.tex节中位置的列表 |
.rel.data | 被模块引用或定义的所有全局变量的重定位信息 |
.debug | 一个调试符号表 |
.line | 原始C源程序中的行号和.text节中机器指令之间的映射 |
.strtab | 一个字符串表(包括.symtab和.debug节中的符号表) |
4.3.3符号表
键入readelf –s hello.o
查看hello.o文件的符号表信息如下图:
图 18 符号表信息
其中,Num为某个符号的编号,Name是符号的名称。Size表示他是一个位于.text节中偏移量为0处的146字节函数。Bind表示这个符号是本地的还是全局的,由上图可知main函数名称这个符号变量是一个全局变量。
4.3.4重定位信息表
键入readelf –r hello.o
查看hello.o文件的重定位信息表信息如下图:
图 19 重定位信息表
offset是需要被修改的引用的节偏移,Sym.标识被修改引用应该指向的符号。Type告知连接器如何修改新的引用,Addend是一个有符号常数,一些类型的重定位要使用它对被修改的引用的值做偏移调整。
4.4 Hello.o的结果解析
键入objdumo -d -r hello.o命令对hello.o可重定位文件进行反汇编
得到的反汇编结果如下图:
图 20 hello.o文件反汇编结果
看到hello.o的反汇编文件我们很是熟悉,它使用的汇编代码和hello.s汇编文件的汇编代码是一样的,但是在这反汇编文件的字里行间中,也混杂着一些我们相对陌生的面孔,也就是机器代码。
这些机器代码是二进制机器指令的集合,每一条机器代码都对应一条机器指令,到这儿才是机器真正能识别的语言。每一条汇编语言都可以用机器二进制数据来表示,汇编语言中的操作码和操作数以一种相当于映射的方式和机器语言进行对应,从而让机器能够真正理解代码的含义并且执行相应的功能。机器代码与汇编代码不同的地方在于:
- 分支跳转方面:汇编语言中的分支跳转语句使用的是标识符(例如je .L2)来决定跳转到哪里,而机器语言中经过翻译则直接使用对应的地址来实现跳转。
- 函数调用方面:在汇编语言.s文件中,函数调用直接写上函数名。而在.o反汇编文件中,call目标地址是当前指令的下一条指令地址。这是因为hello.c中调用的函数都是共享库中的函数,需要等待链接之后才能确定响应函数的地址。因此,机器语言中,对于这种不确定地址的调用,会先将下一条指令的相对地址设置为0,然后再.rela.text节中为其添加重定位条目,等待链接时确定地址。
4.5 本章小结
本章较为细致地分析了汇编的概念和作用,在linux系统下进行编译实践,接着分析编译得到的hello.o文件。分析过程中,着重对hello.o的ELF头,Section头以及符号表进行了分析,可以看到Hello的更深处的信息。本节还对hello.o的反汇编文件进行了解析,比较了相对于hello.s文件.o文件中部分机器语言与汇编语言的映射关系。对分支跳转与函数调用两方面作了具体分析
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
链接是将各种代码和数据片段和搜集并组成成为一个但以文件的过程,这个文件可被夹在到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被记载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
5.1.2连接的作用
链接使得程序可进行模块化编写,其将程序调用的各种静态链接库和动态连接库整合到一起,完善重定位目录,使之成为一个可运行的程序。
5.2 在Ubuntu下链接的命令
键入指令:
ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
运行效果如下图:
图 21 编译指令运行效果
5.3 可执行目标文件hello的格式
5.3.1 ELF头信息
键入readelf –h hello > hello.elf
生成.elf文件,查看hello文件的ELF头信息如下图:
图 22 hello文件ELF头信息
观察到文件的Type发生了变化,从REL变成了EXEC(Executable file可执行文件),节头部数量也发生了变化,变为了27个。
5.3.2 Section头信息
键入readelf –S hello
查看hello文件的Section头信息如下图:
图 23 节头部表信息
节头部表对hello中所有信息进行了声明,包括了大小(Size)、偏移量(Offset)、起始地址(Address)以及数据对齐方式(Align)等信息。根据始地址和大小,我们就可以计算节头部表中的每个节所在的区域。
5.3.3符号表
键入readelf –s hello
查看hello文件的符号表信息如下图:
图 24 符号表信息(截取)
对比于4.3.3提到的hello.o的符号表,我们发现该表中符号数有了显著的增长,推测是链接过程进行了不同库符号表的汇总合并导致。
5.3.4重定位信息表
键入readelf –r hello
查看hello文件的重定位信息表如下图:
图 25 重定位信息表
同4.3.4一样,offset是需要被修改的引用的节偏移,Sym.标识被修改引用应该指向的符号。Type告知连接器如何修改新的引用,Addend是一个有符号常数,一些类型的重定位要使用它对被修改的引用的值做偏移调整。
5.4 hello的虚拟地址空间
在linux下用edb打开hello,找到其对应的虚拟地址空间,其起始地址为00401000,结束地址为(下图中未显示完整)00401ff0
图 26 hello的虚拟地址空间
根据5.3.2节里面的Section头部表,我们可以找到对应的节的其实空间对应位置,例如.init初始化节,起始位置地址为0x401000在edb中有其对应位置
5.5 链接的重定位过程分析
键入指令objdump -d -r hello
得到hello反汇编结果如下图:
图 27 hello文件反汇编结果(截取)
对比hello与hello.o反汇编结果的长度,不难发现hello反汇编结果代码量可达到对应hello.o文件的数倍。Hello反汇编代码中新增了很多节,这些节都是经过连接之后新增被加进来的。例如.init节是程序初始化需要执行的代码所在的节,.dybamic节是存放被ld.so调用过的动态链接信息的节等。
此外,还可以发现hello反汇编代码中函数调用时不再仅仅储存call当前指令的下一条指令,而是已经完成了重定位,调用的相应函数已经有对应的明确的虚拟地址空间。
重定位的过程分为两大步:
重定位节和符号定义:在这一步中,连接器将所有相同类型的节合并成为同一类型的新的聚合节。例如,来自所有输入模块的.data节全部被合并成一个节,这个节成为输出的可执行目标文件的.data节。
2)重定位节中的符号引用:在这一步中,连接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。要执行这一步,连接器依赖于可重定位条目,及5.3节中分析的那些数据。
5.6 hello的执行流程
使用edb执行hello,依次记录下程序调用流程地址,其调用与跳转的各个子程序名及程序地址见下表。
表 9 程序名称及程序地址对照表
程序名称 | 程序地址 |
_start | 0x4010f0 |
_libc_start_main | 0x7ffff7de2f90 |
__GI___cxa_atexit | 0x7ffff7e05de0 |
__new_exitfn | 0x7ffff7e05b80 |
__libc_csu_init | 0x4011c0 |
_init | 0x401000 |
_sigsetjump | 0x7ffff7e01bb0 |
main | 0x401125 |
do_lookup_x | 0x7ffff7fda4c9 |
dl_runtime_resolve_xsavec | 0x7ffff7fe7bc0 |
_dl_fixup | 0x7ffff7fe00c0 |
_dl_lookup_symbol_x | 0x7ffff7fdb0d0 |
check_match | 0x7ffff7fda318 |
strcmp | 0x7ffff7fee600 |
5.7 Hello的动态链接分析
动态共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个程序链接起来,这个过程就是动态链接。
加载时动态链接:应用程序第一次加载和运行时,通过ld-linux.so动态链接器重定位动态库的代码和数据到某个内存段,再重定位当前应用程序中对共享库定义的符号引用,然后将控制传递给应用程序(此后共享库位置固定不变)。
运行时动态链接:程序执行过程,通过dlopen/dlsys等函数加载和链接动态链接库,实现符号重定位。
动态链接把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。本次大作业中hello在动态连接器加载前后的重定位是不一样的,在加载之后才进行重定位。
5.8 本章小结
本章较为细致地分析了链接的概念和作用,在linux系统下进行编译实践,接着分析编译得到的hello文件。分析过程中,着重对hello的ELF头,Section头以及符号表和重定位信息表进行了分析。本节还对hello的虚拟空间地址、反汇编文件以及链接的重定位过程进行了解析,同hello.o文件作了对比分析。最后,由edb分析了hello的执行流程并进行了动态链接的原理方法分析。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程是执行中程序的一个具体的实例,是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
6.1.2进程的作用
在运行一个进程时,我们的这个程序好像是系统当中唯一一个运行的程序,进程的作用是提供给程序两个关键的抽象,分别是独立的逻辑控制流和私有的地址空间;进程的作用是合理的隔离资源、运行环境、提升资源利用率。
6.2 简述壳Shell-bash的作用与处理流程
Shell是命令语言解释器。他的本质是一个应用程序,它连接了用户和 Linux 内核,让用户能够更加高效、安全、低成本地使用 Linux 内核。它在操作系统的最外层,负责直接与用户进行对话,把用户的输入解释给操作系统,并处理各种各样的操作系统的输出结果,输出到屏幕反馈给用户。例如我们经常使用的Windows下的cmd命令行和Bash以及Linux中的Shell。
其基本功能为解释并运行用户的指令,重复以下处理过程:
- 终端进程读取用户由键盘输入的命令行,分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量;
- 检查首个命令行参数是否是一个内置的shell命令,如不是内部命令则调用fork函数创建子进程
- 在子进程中,用步骤2获取1的参数,调用exerve执行指定程序
- 如果用户要求后台运行,则shell使用waitpid等待作业终止后返回;如果用户未要求后台运行,则shell返回
6.3 Hello的fork进程创建过程
在Linux系统中,用户可以通过 ./ 指令来执行一个可执行目标文件。在程序运行时,Shell就会创建一个新的进程,并且新创建的进程更新上下文,在这个新建进程的上下文中便可以运行这个可执行目标文件。fork()函数是有趣的,它只被调用一次,却可以返回两次,在父进程中,fork返回子进程的PID,而在子进程中,fork返回0。新创建的进程与父进程几乎相同但有细微的差别。父子进程并发执行,子进程得到与父进程相同而独立的地址空间,每个进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值以及相同的代码。而父进程与子进程有不同的PID。
图 28 进程的地址空间
6.4 Hello的execve过程
当Hellol的进程被创建之后,他会调用execve函数加载并调用程序。exevce函数在被调用时会在当前进程的上下文中加载并运行一个新程序。它被调用一次从不返回,执行过程如下:
1)删除已存在的用户区域
2)映射私有区:为 hello 的代码、数据、.bss 和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的
3)映射共享区:比如 hello 程序与共享库 libc.so 链接
4)设置 PC:exceve() 做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点
5)execve() 在调用成功的情况下不会返回,只有当出现错误时,例如找不到需要执行的程序时,execve() 才会返回到调用程序
6.5 Hello的进程执行
上下文切换:进程在运行时会依赖一些信息和数据,包括通用目的寄存器、浮点寄存器等的状态,这些进程运行时的依赖信息成为进程的上下文。而在进程进行的某些时刻,操作系统内核可以决定抢占当前的进程,并重新开始一个新的或者之前被抢占过的进程,这一过程成为调度。而抢占进程前后由于进程发生改变依赖信息也变得不同,这个过程就是上下文切换。
并发流与时间片:两个流如果在执行的时间上有所重叠,那么我们就说这两个流是并发流,每个流执行一部分的时间就叫做时间分片。
用户模式:在用户模式中,进程不允许执行特权指令,例如发起一个I/O操作等,更重要的是不允许直接引用地址空间中内核区内的代码和数据。如果在用户模式下进行这样的非法命令执行,会引发致命的保护故障。
内核模式:在内核模式下,进程的指令执行相对没有限制,这有点类似于在Linux操作系统中,是否使用sudo(SuperUser do)作为指令的前缀一样。而在内核模式下运行的进程相当于获得了超级管理员的许可。
Hello的执行:从Shell执行hello程序时,会先处于用户模式运行。在运行过程中,由内核不断进行上下文切换,配合调度器,与其他进程交替运行。如果在运行过程中收到了信号,那么就会陷入到内核中进入内核模式运行信号处理程序,之后再进行返回。
6.6 hello的异常与信号处理
6.6.1异常
异常可以分为四类:中断、陷阱、故障和终止,各类异常产生原因和一些行为总结成下表:
表 10 异常种类
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
Hello中常见的异常是缺页故障。在hello中如果我们使用的虚拟地址相对应的虚拟页面不在内存中,就会发生此类缺页故障。故障是可能会被修复的,例如缺页故障触发的故障处理程序,会按需调动页面,再返回到原指令位置重新执行。但对于无法恢复的故障则直接报错退出。
此外,在进程运行的过程中,我们施加一些I/O输入,比如说敲键盘,就会触发中断。系统会陷入内核,调用中断处理程序,然后返回。用户模式无法进行的内核程序,可通过引发一个陷阱,陷入内核模式下再执行相应的系统调用。终止是不可恢复的,通常是由硬件错误导致,调用abort函数结束。
6.6.2信号
可能产生的信号有:SIGINT、SIGKILL、SIGSEGV、SIALARM、SIGCHLD。
按下Ctrl+C: 在键盘上输入Ctrl+C会导致内核发送一个SIGINT信号到前台进程组的每个进程,默认情况是终止前台作业。
图 29 按下Ctrl+C效果
按下Ctrl+Z后,内核会发送SIGSTP信号,SIGSTP默认挂起前台hello作业,但 hello进程并没有回收,而是运行在后台下。
键入ps:通过ps指令可以对其进行查看。
图 30 键入ps后效果
键入jobs:进程仍在后台。
图 31 键入jobs后效果
键入pstree:
图 32 键入pstree后效果(截取)
键入fg:进程继续运行
图 33 键入fg后效果
键入kill 命令:内核会发送SIGKILL信号给指定的pid(hello程序),杀死hello程序
图 34 键入kill后效果
经测试,除上述内置语句外,乱按键对程序执行无影响。
6.7本章小结
本章较为细致地介绍了进程的概念和作用,简述了壳Shell-bash的基本概念。着重对建立进程过程的两个常用函数fork及exerve作了细致的分析,研究了他们的执行原理和运行过程,最后分析了hello的进程执行并给出了hello带参执行情况下各种异常与信号处理的结果。
第7章 hello的存储管理(自学内容)
7.1 hello的存储器地址空间
在CPU中当然不是仅仅只有hello一个进程,而是许多进程共享CPU和主存资源。那么为了使内存管理更加高效,操作系统提出了一种十分重要的抽象,即虚拟内存。它的优点可概括为以下三点:
1)可以有效使用主存;
2)可以简化内存管理;
3)提供独立的地址空间。
接下来介绍几个概念:
物理地址:它是在地址总线上,以电子形式存在的,使得数据总线可以访问 主存的某个特定存储单元的内存地址。
虚拟地址:CPU启动保护模式后,程序运行在虚拟地址空间中。保护模式下,hello 运行在虚拟地址空间中,它访问存储器所用的逻辑地址。
逻辑地址:逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元、存储单元、网络主机的地址。逻辑地址往往不同于物理地址,通过地址翻译器或映射函数可以把逻辑地址转化为物理地址。
线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel平台下,逻辑地址的格式为段标识符:段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。分段机制将逻辑地址转化为线性地址的步骤如下:
1)使用段索引在GDT或LDT表中定位相应的段描述符。
2)利用段索引检验段的访问权限和范围,以确保该段可访问。
3)把段索引中取到的段基地址加到偏移量上,最后形成一个线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续字节大小的单元组成的数组。每字节都有唯一的虚拟地址,作为到数组的索引。如下图,虚拟内存被分为一些固定大小的块,这些块称为虚拟页块。这些页块根据不同的映射状态也被划分为三种状态:未分配、为缓存、已缓存。
未分配:虚拟内存中未分配的页
未缓存:已经分配但是还没有被缓存到物理内存中的页
已缓存:分配后缓存到物理页块中的页
图 35 VM系统使用主存作为缓存
7.4 TLB与四级页表支持下的VA到PA的变换
页表是 PTE(页表条目)的数组,它将虚拟页映射到物理页,MMU利用页表来实现这种映射。每个 PTE 都有一个有效位和一个 n 位地址字段,有效位表明该虚拟页是否被缓存在 DRAM 中。虚拟地址又包含两个部分,(n-p)位虚拟页号(VPN)p位的虚拟页面偏移量(VPO)。其中VPN需要在PTE中查询对应,而VPO则直接对应物理地址偏移(PPO)。
图 36 页表地址翻译
7.5 三级Cache支持下的物理内存访问
在寻找一个虚拟地址时,CPU会首先优先到TLB中寻找,查看VPN是否已经缓存。如果页命中的话,就直接获取PPN;如果没有命中的话就需要查询多级页表,得到物理地址PA,之后再将其分解为标记位(CT)、组索引位(CI)、块偏移位(CO),之后再检测物理地址是否在下一级缓存中命中。若命中,则将PA对应的数据内容取出返回给CPU;若不命中,则重复上述操作,直到找到目标地址。
7.6 hello进程fork时的内存映射
当fork函数被shell调用后,系统内核就为hello进程创建各种数据结构,并为其分配PID。Fork建立了hello进程的mm_struct、所有区段构造和页表的原样拷贝。它将两个进程中的所有页面都标识为只读,并将两个进程中的所有区段构造都标识为私有的写时复制。使用fork所建立的子进程,具有与父进程相同的区域结构、页表等的一个副本,并且子进程也能够访问任何父进程中已打开的文档。当fork从新进程中返回时,由于新进程中现在的虚拟内存刚好与调用fork时存在的虚拟内存相同,当这二个进度中的任何一项后来完成写操作时,写时复制机制也会产生新页面,这样,也就给各个进程都保留了私有地址空间。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:
1)删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2)映射私有区域:为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3)映射共享区域:hello 程序与共享对象 libc.so 链接,则libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4)设置程序计数器(PC):execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障:在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。如果发生了缺页故障,则触发缺页故障处理程序,这个程序会选择一个牺牲页,例如下图中的P4,将其在物理内存中删除,加入所需要访问的VP3。随后返回重新执行原指令,则页命中。这种策略称为按需页面调度。
图 37 VM缺页前,对VP3中字引用不命中引发缺页
7.9本章小结
本章介绍了hello的存储管理,引入了一个非常重要的概念即虚拟内存,从物理地址、逻辑地址、线性地址、虚拟地址开始,再到页表,到分析fork与exerve函数,最后到分析缺页故障与按需页面调度机制,展现了Hello是如何经过地址翻译从而找到最终的物理地址。
但由于本部分未深入学习,且考试后时间较紧张,故部分内容只浅谈框架,并未细致分析,希望在之后的时间里可以对该部分内容以及还未学到的I/O管理部分进行系统深入的学习,更好地了解hello的一生,更深入地了解计算机系统的奥秘。
结论
- Hello的起点为hello.c源程序的编写,可以将其比作hello的胚
- 在linux终端下键入gcc hello.c -E -o hello.i,对hello.c进行预处理,hello.c成长为hello.i
- 在linux终端下键入gcc hello.i -S -o hello.s,对hello.i进行编译,hello.i成长为hello.s
- 在linux终端下键入gcc hello.i -c -o hello.s,对hello.s进行汇编,hello.s成长为hello.o
- 对Hello进行链接,与系统上运行hello所需的动态库进行动态链接,hello可执行目标文件成型
- 打开Shell,键入正确指令,Shell接受后调用fork为hello程序创建一个进程
- Shell调用exerve函数,将新创建的子进程的区域结构删除,然后将其映射到hello程序的虚拟内存,然后设置当前进程上下文中的程序计数器,使其指向hello程序的入口点
- 接下来,hello运行于创建进程的上下文中,在MMU、三级cache等等存储硬件的配合下进行工作
- 当进程执行结束后,Hello被父进程或祖先进程回收。至此,Hello的一生结束。
感悟
本次完成计算机系统大作业收获颇多,感觉与一纸开卷相同的是,在完成过程中也是对计算机系统已学知识的巩固与复习;不同的是,Hello作为每一个编程者对于一门崭新语言世界的开篇,富于内涵,值得探寻,其更偏于从实例化的一点开始对一门学科进行辐射。在作业的完成过程中,我从计算机系统的书籍、网络资料两方面进行学习,解决问题,完善方法,并在Linux系统中用实践去检验自己的知识与方法,并于这篇论文中总结记录。
本次大作业总体完成很充实,略感遗憾的一点是由于与考试相连,准备的时间并不充分,第七章由于是考试后自学的,对于部分内容浅尝辄止,尚不完善。希望在今后的时间里对后续遗留的篇章与问题展开系统的学习,并将前后的知识内容融会贯通,更好地理解Hello的一生!
附件
文件名称 | 作用 |
hello.c | 储存hello程序源代码 |
hello.i | 源代码经过预处理产生的文件 |
hello.s | hello程序对应的汇编语言文件 |
hello.o | 可重定位目标文件 |
hello | 二进制可执行文件 |
hello.elf | Hello可执行文件的ELF头内容 |
参考文献
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.