计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术专业
学 号 200111026
班 级 20级计算机9班
学 生 陈弘毅
指 导 教 师 郑贵滨
计算机科学与技术学院
2021年5月
摘 要
本文主要讨论了一个hello程序在Linux系统中从hello.c经过预处理、编译、汇编、链接生成可执行文件的P2P过程和涉及进程管理、存储管理、I/O管理的020过程。在这两个过程中我们结合课本知识对每个过程每一步骤进行详细分析和测试,加深了对计算机系统的了解。
关键词:预处理;编译;汇编;链接;进程;存储;虚拟内存;I/O ;
**
**
目 录
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 11 -
7.7 hello进程execve时的内存映射 - 11 -
第1章 概述
1.1 Hello简介
P2P:在Windows或Linux环境下,在Visual Studio或者CodeBlocks软件下可以编写C语言代码hello.c,当我们点击运行按钮之后,GCC编译器会驱动程序读取源程序文件,在多步的操作下最终生成一个可执行目标文件,如下图所示:
hello.c文件先经过预处理器cpp,生成hello.i文件,再经过编译器ccl生成hello.s汇编程序,然后经过汇编器as生成可重定位目标文件hello.o,最后通过链接器ld链接所需要的printf.o可重定位目标文件,最终生成可执行文件hello。这些操作都可以在Linux终端执行gcc hello.c -o hello命令完成,接着执行./hello命令,shell为其产生一个子进程,这里便完成了P2P的过程。
O2O:在完成上面过程后,OS通过fork函数和execve函数创建子进程并将hello载入,接着进行虚拟内存映射,分配时间片,CPU按照流水线依次取指译码执行,内存管理系统利用虚拟内存地址从TLB或高速缓存中取出相应数据并加速访问过程,IO综合软硬件对信号进行处理,使其结果显示到屏幕上。当程序运行结束,shell接受到相应的终止信号,启动信号处理机制,操作系统的内核进行回收hello进程,释放其所占内存并删除进程的上下文,这便是O2O的过程。
1.2 环境与工具
硬件环境:X64 CPU 1.61Ghz、16G RAM、512GHD Disk
软件环境:Windows10 64位、VirtualBox11、Ubuntu 20.04 LTS 64位
开发与调试工具:Visual Studio 2022 64位、CodeBlocks 64位vi+gcc、gcc、readelf、objdump、edb
1.3 中间结果
文件名称 | 文件功能 |
---|---|
hello.c | 源程序 |
hello.i | 预处理后文件 |
hello.s | 汇编文件 |
hello.o | 可重定位目标文件 |
hello | 可执行文件 |
hello1.elf | hello.o的elf格式 |
hello1.asm | hello.o的反汇编 |
hello2.elf | hello的elf格式 |
hello2.asm | hello的反汇编 |
1.4 本章小结
本章首先通过对hello的自白进行计算机语言表达,介绍了hello程序在计算机系统中执行的具体流程。其次列出了实验硬件环境、软件环境、开发工具。最后列出实验中所生成的中间结果文件和作用
第2章 预处理
2.1 预处理的概念与作用
预处理是指由预处理器对程序源代码文本进行处理,预处理中会展开以#起始的行,将所引用的所有库展开,处理所有的条件编译,得到完整的文本文件hello.i。预处理的作用是:
1.处理宏定义,宏和常量标识符全部被相应的代码和值替换
2. 处理条件编译,预编译程序将对满足if条件的代码进行筛选,只有满足的代码才进行编译,将那些不必要的代码过滤掉。
3.处理头文件,根据以字符#开头的命令,修改原程序,将头文件中的定义均加入到所产生的输出文件中,以供后续处理。
2.2在Ubuntu下预处理的命令
预处理的命令为:gcc -E hello.c -o hello.i,示意如下:
2.3 Hello的预处理结果解析
经过预处理之后,hello.c变为hello.i文件。打开该文件可以发现,文件变为3000多行,内容大大增加。首先可以发现注释被清楚,其次我们在3000多行发现程序主体如下所示,且和原程序并无较大差异,结合预处理的作用我们可以得知前面3000多行,均为对头文件的展开,这些头文件的展开占据了大量的篇幅。
2.4 本章小结
本章介绍预处理的概念与作用,接着介绍Ubuntu环境下预处理的命令,并对hello.i文件进行分析,加深对预处理的理解。
第3章 编译
3.1 编译的概念与作用
编译是指从 hello.i 到 hello.s 即预处理后的文件到生成汇编语言程序的过程, 编译的作用是将高级语言源程序翻译成等价的目标程序,并进行语法检查、调试措施、修改手段、覆盖处理、目标程序优化等步骤。
3.2 在Ubuntu下编译的命令
编译的命令为:gcc -S hello.I -o hello.S,示意如下:
3.3 Hello的编译结果解析
我们查看hello.s,发现只有80来行,下面将对hello.s出现的汇编指令与操作进行分析。
3.3.1 汇编文件指令初步分析
首先我们可以看到hello.s文件头部出现内容如上图所示,通过查阅资料,我们可以得到这些汇编文件指令的作用如下所示:
指令 | 作用 |
---|---|
.file | 声明源文件 |
.text | 声明代码段 |
.section | 只读代码段 |
.globl | 全局变量 |
.type | 声明符号为数据类型或函数类型 |
.align | 声明存放数据或指令的存放地址对齐方式 |
.string | 声明字符串 |
.rodata | 只读代码段 |
3.3.2 数据
我们可以从C语言源码看出程序共有3个数据变量,分别是局部变量i,整数参数argc,字符串(通过字符指针数组实现)参数argv,且包括一系列立即数,没有表达式、类型、宏。下面我么们将逐一进行分析。
1.字符串
我们在上面起始汇编指令中可以看到程序中有两个字符串,且都在只读代码段中,在hello.s中我们可以看到字符串“\345\255\246\345\217\267“之类,其实是“学号 姓名”的UTF-8格式。由C语言代码我们知道他们是printf函数的参数,体现在汇编指令中如下:
2.局部变量i
在main函数中声明了一个局部变量i,我们可以看到在汇编代码中第一部分是处理main的参数传送指令,在第二部分可以看到main的第一行代码,定义的局部变量i存放在堆栈中,如图所示。而且在图中可见在51行,将i的值+1,和7进行比较,对应了原代码for循环中的操作
3.函数参数argc
argc是我们main函数的第一个参数,由课本知识我们可以知道argc,第一个参数存储在%edi寄存器中,而由于argc在后面还需要参与判断,编译器还将argc赋值给了-20(%rbp)。
4.立即数
在汇编代码中我们可以见到许多立即数,如下所示。
5.字符指针数组argv
argv字符指针数组是main函数的第二个参数,故初始存放在寄存器%rsi中。数组的每个元素都是一个指向字符类型的指针。数组的起始地址存放在栈中-32(%rbp)的位置,同时我们注意到C语言原程序三次调用字符指针数组,两次传递给printf,一次将字符类型变换为整数类型后传递给sleep函数,其汇编指令如下所示:
红色方框标注的是:获取argv[3]的地址
蓝色方框标注的是:分别获取argv[1]和argv[2]的地址
黑色方框标注的是:将argv数组内容放到%rax寄存器中
3.3.3 赋值
源程序中的赋值操作是对局部变量i的初始化,体现在汇编指令中如下:
除此之外,汇编指令中存在许多的mov指令,实现的是数据在各个寄存器之间的传送,便于各函数的调用或者分支判断等等。根据数据的类型有四种不同的赋值语句如下所示:
指令 | 功能 |
---|---|
movb | 传送一个字节数据 |
movw | 传送两个字节数据 |
movl | 传送四个字节数据 |
movq | 传送八个字节数据 |
3.3.4 类型转换
通过阅读C源码,可知hello.c中涉及的类型转换是:atoi(argv[3]),这是通过调用函数将字符串类型转换为整数类型。具体体现在汇编指令中是通过callq调用atoi函数,并将argv指针数组的第一项指针送入函数第一个参数,如下所示。
3.3.5 算术操作
首先可以翻阅课本查看所有的算术操作汇编指令如下所示,通过阅读C代码可以发现程序中只存在对i的加1运算。通过阅读汇编代码我们发现除了ADD指令还包括SUB指令,具体如下:
语句addl $1, -4(%rbp)是实现i++,而subq $32, %rsp是在函数开头,对栈指针进行减法操作,目的是开辟一段新空间接受main函数的两个参数。
3.3.6 关系操作
通过查阅课本我们可以知道关系操作的汇编指令如下,通过阅读C源码,我们可以知道共有两个关系操作,一个是argc!=4,这里汇编代码将argc和4进行比较,计算-20(%rbp)-4,并设置条件码。随之jle语句将根据条件码的值进行相应的跳转处理;第二个是i<8,这里汇编代码将它优化为了将i和7进行比较,计算-4(%rbp)-7,并设置条件码。随之jle语句将根据条件码的值进行相应的跳转处理,汇编代码如下:
3.3.7 控制转移
控制转移常常是配合指令CMP和TEST存在的。汇编代码中有三处出现了控制转移,正是上述关系操作的下一条指令,加上一处直接跳转语句。此处不另外赋图,仅作分析。
第一处当计算完-20(%rbp)-4的值后判断条件值,如果不相等则顺序执行指令,如果相等,则跳转到.L2,在.L2中对i初始化后,在直接跳转到循环条件判断语句,即第三处控制转移,计算完-4(%rbp)-7的值后判断条件值,如果-4(%rbp)小于7的话则跳转回.L4,否则继续执行。
3.3.8 数组操作
在C源码中包括对字符指针数组argv的赋值,对argv的读。体现在汇编指令中为将数组的首地址存储在栈中,利用栈指针的偏移量来读取。具体如下:
在向main函数传参时,把argv数组的首地址保存在栈中:
在后面的循环中分别读取argv[1]和argv[2],这是通过下述汇编代码实现的。
首先将数组的首地址放到%rax中,然后将它加8或者16,分别获取argv[1]和argv[2]的地址。由于argv中保存的是指针,占8个字节,通过movq (%rax), %rdx或者movq (%rax), %rax将%rax里保存的地址处的值转移到%rdx中或者%rax里面中。
3.3.9 函数操作
首先翻阅课本,我们可以知道函数调用时候将进行控制的传递,数据的传递,内存的分配和释放。观察源码,发现共有五个函数,分别为main,atoi,printf,exit,sleep函数,下面我们将逐一进行分析。
- main函数
一个最简单的 main 函数内部的三个步骤:CFI 初始操作 – 返回 – CFI 结束操作。我们可以看到在main函数开始时有.cfi_startproc,用于初始化一些内部数据结构,然后将下一个指令的地址压入栈中,分别使用%edi和%rsi存储向main函数传递的两个参数argc,argv。在函数结尾有.cfi_endproc,与.cfi_startproc相配套使用。在结束前将寄存器%eax设置为0,使用leave和ret退出,从当前过程中返回。
- printf函数
在C源码中我们可以看出共有两次对printf的调用,体现在汇编指令中,第一次调用被优化为puts函数,首先通过leaq .LC0(%rip), %rdi将rdi寄存器赋值为常量字符串的首地址,即“用法: Hello 学号 姓名 秒数! \n”字符串的首地址,然后调用了puts函数实现输出,汇编指令如下:
第二次则是直接调用printf函数,需要传递3个参数,%rdi保存的是“Hello %s %s\n”的首地址,%rsi保存的是argv[1],%rdx保存的是argv[2],将%eax置0,汇编指令如下:
- atoi函数
对atoi函数的调用如图所示,由于需要参数,此处先将从rbp寄存器存储的地址加上特定偏移量,取出该地址中的值送入寄存器%rdi,函数调用的第一个参数。
- sleep函数
对sleep函数的调用如图所示,由于需要参数,此处将atoi函数的返回值(存放在寄存器%eax)中的值赋给寄存器%edi,即函数调用的第一个参数。
-
exit函数
对exit函数的调用如图所示,由于exit函数需要参数1,此处还将%edi寄存器内容设置为1。
- getchar函数
对getchar函数的调用如图所示。
3.4 本章小结
本章首先介绍编译的概念与作用,接着介绍在Ubuntu下对hello.i进行编译的指令,根据编译结果hello.s,仔细查看并分析每一指令所对应的数据、赋值、类型转换、算术操作、关系操作、数组/指针/结构操作以及控制转移和函数操作。经过这一章,最初的hello.c已经被转换成了更底层的汇编程序。
第4章 汇编
4.1 汇编的概念与作用
汇编是将hello.s汇编程序翻译成机器语言,把这些机器语言指令打包成可重定位目标程序的格式,并将结果保存在hello.o文件中。汇编的作用是将高级语言转化为机器可直接识别执行的机器指令代码文件。
4.2 在Ubuntu下汇编的命令
汇编的命令为:gcc hello.s -c -o hello.o,示意如下:
4.3 可重定位目标elf格式
首先我们可以在Ubuntu中利用命令readelf -a hello.o > hello1.elf来查看ELF,并将其重定位为文本文件hello1.elf,如下所示。我们在这里部分通过txt文件查看elf格式文件部分通过readelf来分别读取每一节的内容。
首先我们翻阅课本查找重定位目标elf格式的相关信息如下,一个典型的ELF可重定位目标文件结构如右图所示,各个节的含义如左图所示。
下面我们用readelf列出各节基本信息并进行基本的分析。
4.3.1 ELF头
ELF头是以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。
4.3.2 节头部表
节头部表包含了文件中出现的各个节、节的类型、地址、大小和旗标等信息。在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小。
4.3.3 符号表
可以从符号表中看出符号的大小、类型、相对于目标节的起始位置偏移、全局或者本地变量等信息。如main大小为146字节,类型为FUNC,Bind为GLOBAL.
4.3.4 重定位节
重定位节包含.text 节中需要进行重定位的信息,包括需要被修改的引用节的偏移量、重定位的类型、重定向的目标的名称、一个有符号常数用来对被修改引用的值做偏移调整。当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
4.4 Hello.o的结果解析
利用objdump对Hello.o进行反汇编的指令为:objdump -d -r hello.o > hello1.asm,示意如下:下面将和hello.s进行对照分析。
4.4.1 机器语言与汇编语言
通过观察我们可以发现反汇编代码和hello.s在指令上差异并不大,但明显可以看到前面加上了一串十六进制,即机器语言。机器语言指的是二进制的机器指令集合,是机器可以直接识别的语言。汇编语言是直接表述CPU动作形成的语言机器指令。每一条汇编语言操作码和操作数都可以用机器二进制数据来表示,进而可以将所有的汇编语言和二进制机器语言建立一一映射的关系,进行两者之间的转换。
4.4.2 操作数比较
hello.s中操作数表示为十进制,而反汇编中操作数表示为十六进制,示意如下:
4.4.3 分支转移比较
hello.s中分支转移使用段名称进行跳转,而反汇编文件中分支转移是通过地址(函数名)进行跳转的,因为汇编语言汇编成机器语言之后段地址被确定的地址代替,示意如下:
4.4.4 函数调用比较
在hello.s文件中函数调用call后跟函数名。而在反汇编文件中,call的目标地址是当前下一条指令,这是因为这些函数均为库函数,地址不确定,call指令将相对地址全部设置为0,在.rela.text节中为其添加重定位条目,在下一步链接时才能确定函数的运行时地址,示意如下:
4.5 本章小结
本章实现从hello.s到hello.o的汇编过程,首先介绍汇编的概念和作用,在Ubuntu下汇编的命令,通过readelf命令查看了可重定位目标elf格式。使用了objdump工具得到了hello.o的反汇编代码,和hello.s进行比较分析,更加深刻地理解了汇编的过程。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为一个可被加载到内存并执行文件的过程,链接的作用是实现分离编译,将一个大型的应用程序组织分解成更小、更好管理的模块,可以独立地修改和编译这些模块,便于维护和管理。
5.2 在Ubuntu下链接的命令
在Ubuntu下使用ld进行链接的命令为
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
链接示意如下:
5.3 可执行目标文件hello的格式
在Ubuntu下分析hello的elf格式需要输入命令:readelf -a hello > _hello2.elf,示意如下。下面将用readelf列出个段的基本信息。
- ELF头
输入命令readelf -h hello可以查看hello的elf头,示意如下。Type类型为EXEC表明hello是一个可执行目标文件,有12个节。
2. 节头部表
输入命令readelf -S hello可以查看hello的节头部表,示意如下。节头部表对 hello中所有的节信息进行了声明,包括大小以及偏移量等等。
3. 符号表
输入命令readelf -s hello可以查看hello的符号表,示意如下。
重定位节示意如下:
5.4 hello的虚拟地址空间
使用edb加载hello,我们发现虚拟地址从0x401000开始,结束于0x401ff0。示意如下:
根据hello2.elf的节头部表,我们可以通过edb找到各个节的信息。下面以.text段为例,同理我们也可以得到其他段。从hello2.elf节头部表,我们可以知道.text段起始于地址0x4010f0,利用edb查看如下(第一行即为.text段起始):
5.5 链接的重定位过程分析
利用objdump对hello进行反汇编的指令为:objdump -d -r hello.o > hello2.asm,示意如下:下面将对hello2.asm和hello1.asm进行分析比较不同且对重定位项目和过程进行分析。
5.5.1 链接前后反汇编差异分析
- 节增加
hello2.asm相比hello1.asm增加了许多节,而且.text节中的内容也比之前有所增加,示意如下。通过翻阅资料我们知道这些节具有一定的功能,示意如下。
- 虚拟地址替换相对偏移地址
在hello2.asm文件中,因为hello是可执行文件,已完成重定位工作,虚拟地址已经确定,汇编代码中地址采用绝对地址。而hello1.asm由于未完成重定位过程,地址从0开始,采用基于main函数的相对地址,示意如下:
- 共享库函数链接
链接将把一些外部共享库函数添加到可执行文件中,示意如下:
5.5.2 链接的重定位过程分析
1. 定义
重定位由两步组成:1. 重定位节和符号定义。 2. 重定位节中的符号引用。链接器首先重定位节和符号定义,将所有类型相同的节合并在一起后,然后把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节以及输入模块定义的每个符号。接着重定位节中的符号引用,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。
2. 具体过程
首先我们查阅资料可以知道重定位过程的算法示意如下:
接着我们以puts函数的重定位为例进行分析:
在hello1.asm中,由于未进行重定位,该行二进制编码为e8 00 00 00 00。下面我们手动计算hello2.asm中puts函数所应该对应的二进制编码,观察是否和hello2.asm代码中一致。
首先,我们可以查看main的运行地址为0x401125,可以计算引用运行时的地址refaddr = addr(text)+offset = 0x401125+0x21=0x401146.而addr(r.symbol) = addr(puts) = 0x401090,我们可以更新该引用,*refptr = (unsigned) (addr(r.symbol) + r.addend - refaddr)=unsigned(0x401090+(-4)-0x401146)=ff ff ff 46,将其以小段序填入可得 46 ff ff ff ,与反汇编代码一致。
5.6 hello的执行流程
我们使用edb单步执行hello来说明hello的执行流程,具体示意如下:
打开edb后使用step into顺序执行,首先会进行一系列操作,包括调用一些函数和一系列操作,我们使用step out跳过这些函数后会进入_start函数,如下所示:
我们使用step into进入_start函数进行查看,发现其调用libc-2.31.so!__libc_start_main函数,这个函数里面先调用libc-2.31.so!__cxa_atexit函数,然后再调用hello!__libc_csu_init函数,然后再调用hello!_init函数、__libc_csu_fini函数、_setjump函数、sigsetjmp函数,最后call hello!main进入main流程,示意如下。在main函数中进行多次的函数调用,最后从主函数返回,调用exit退出程序。
调用与跳转的各个子程序名和程序地址表如下:
程序名 | 程序地址表 |
---|---|
Hello!_start | 0x4010f0 |
libc-2.31.so!__libc_start_main | 0x7f6d972d6fc0 |
libc-2.31.so!__cxa_atexit | 0x7f6d972f9e10 |
hello!__libc_csu_init | 0x4011c0 |
hello!_init | 0x401000 |
Hello! __libc_csu_fini | 0x401230 |
Libc-2.31.so!_setjmp | 0x7f6d972f5cb0 |
Libc-2.31.so!_sigsetjmp | 0x7f6d972f5be0 |
Hello!main | 0x401125 |
Hello!exit@plt | 0x401070 |
5.7 Hello的动态链接分析
1. 动态链接概念
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序。在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。
2. 动态链接项目更新
通过阅读hello2.elf的节头部表,我们可以知道.got和.got.plt的信息,示意如下:
由图5.3中的节头部表可知,hello的.got.plt段的起始位置是0x404000。使用edb进行分析,查看.got.plt节的内容,发现在调用dl_init之前0x404008后的16个字节均为0,示意如下。
调用dl_init后,再用edb查看.got.plt段的内容,0x404008后面16字节已经完成赋值,示意如下。在完成dl_init后进行动态函数调用已经准备就绪,在之后的函数调用时,首先跳转到PLT执行.plt中操作,第一次访问跳转时,GOT 地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在 PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。
5.8 本章小结
本章首先介绍了链接的概念与作用、在Ubuntu下链接的命令,接着利用edb、objdump对ELF文件、虚拟地址空间、重定位过程等进行分析。最后对hello的执行流程以及动态链接做了简要分析。到此,hello程序已经从程序代码转换成了可执行文件,只要执行hello文件便实现了P2P的过程。
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中的程序的实例,系统中的每个程序都运行在某个进程的上下文中。作用是为用户提供了两个假象:程序好像是独占的使用处理器和内存、程序中的代码和数据好像是系统内存中唯一的对象。
简述壳Shell-bash的作用与处理流程
1.Shell-bash的作用
壳Shell是一个交互型应用级程序,为用户访问操作系统内核提供交互界面,它代表用户运行程序。它接受用户命令,然后调用相应的应用程序。Linux系统中所有的可执行文件都可以作为Shell命令来执行。
2.Shell-bash的处理流程
1.在终端进程读取用户输入的命令行。
2.分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。
3.检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令,如果不是内部命令,调用fork( )创建新的子进程。
4.在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
5.如果用户没要求后台运行,则shell使用waitpid等待作业终止后返回。
6.如果用户要求后台运行,则shell返回。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。父进程和子进程之间的最大区别是他们有不同的PID。在父进程中,fork返回子进程的PID。在子进程中,fork返回0。
我们打开shell,输入 ./hello 200111026 陈弘毅 2 ,shell会对我们输入的命令进行解析,由于命令不是内置shell命令,因此shell会调用fork()创建一个子进程,示意如下:
6.4 Hello的execve过程
execve函数加载并运行可执行目标文件filename,带参数列表argv和环境变量列表envp。只有当出现错误时execve才会返回到调用程序。execve调用一次并从不返回。execve调用操作系统代码来执行程序,删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段,设置PC指向_start地址,_start最终调用main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制。我们的hello程序来说,execve首先在当前进程的上下文中加载并运行新程序hello,然后调用启动代码,启动代码设置栈,并将控制传递给新程序的主函数。 用户栈的组织结构示意如下:
6.5 Hello的进程执行
- 进程的相关概念
逻辑控制流是一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占,此进程暂时挂起,然后轮到其他进程。
进程时间片则是一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式指的是进程执行程序的两种模式,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文信息指的是上下文就是内核重新启动一个被抢占的进程所需要的状态,上下文切换指的是当内核选择一个新的进程运行时,它抢占当前进程并使用一种称为上下文切换的机制来将控制转移到新的进程:保存以前进程的上下文、恢复新恢复进程被保存的上下文、将控制传递给这个新恢复的进程。
进程调度指的是在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被强占的进程的决策。
- hello的进程调度过程
进程hello开始时运行在用户模式中,当hello进程执行系统调用函数sleep或者exit时进入到内核模式,sleep函数显式请求让调用进程休眠,调度器抢占当前的进程,并且利用上下文切换将当前进程的控制权交给其他进程。待内核中的处理程序完成对系统函数sleep的调用后执行上下文切换, hello进程可以继续进行自己的控制逻辑流。hello进程调用sleep的调度示意图如下:
当hello进程调用getchar,实际是执行系统调用read并进入到内核模式,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输。接着内核执行上下文切换并切换到其他进程执行。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核接受到信号后从其他进程进行上下文切换回hello进程继续执行。hello进程调用read的调度示意图如下:
6.6 hello的异常与信号处理
6.6.1 异常与信号
首先我们通过翻阅课本查看异常的分类如下所示:
接着我们分析hello程序可能产生的异常及处理方法:
hello程序出现的异常可能有:中断,在hello程序执行的过程中可能会出现外部I/O设备引起的异常。陷阱,陷阱是有意的异常,hello执行sleep、exit的时候会出现这个异常。故障,在执行hello程序的时候,可能会发生缺页故障。终止,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。在发生这些异常时会发出对应的信号给用户进程,然后执行对应信号的处理行为。在执行hello进程中,主要可能发生的异常为中断(Ctrl-Z,Ctrl-C)和陷阱(系统调用)。 其各自处理情况如下所示:
6.6.2 各键盘操作结果分析
-
不停乱按
当我们乱按不包括回车的时候,shell只是将屏幕的输入缓存到 stdin,当我们乱按包括回车的时候,那么乱按的内容将会在该程序结束之后作为命令输入,分别示意如下:
-
Ctrl-Z
在键盘下按下Ctrl-Z之后,会导致内核发送一个SIGTSTP信号到前台进程组的每个进程,默认情况下结果是停止(挂起)前台作业,此时进程并没有回收,而是运行在后台下。我们可以用ps命令查看hello进程,调用 fg命令将其调到前台,此时首先打印 hello 的命令行命令,继续运行打印剩下的次数,之后当我们输入字符串,程序结束,进程被回收,示意如下:
-
Ctrl-C
在键盘上按下Ctrl-C会导致内核发送一个SIGINT信号到前台进程组中的每个进程,默认情况下结果是终止前台作业。我们可以用ps命令查看,发现前台进程组发现没有hello进程,示意如下:
-
Ps命令
- Jobs命令
- Pstree命令
-
Fg命令
对于fg信号,内核发送SIGCONT信号,刚刚挂起的程序hello重新在前台运行。
-
Kill命令
对于kill -9 2742命令,内核发送SIGKILL信号给我们指定的hello程序,杀死了hello程序。
6.7本章小结
本章首先介绍了进程的概念与作用,然后简述Shell-bash的作用与处理流程。接着就我们的hello程序分析了调用 fork 创建新进程、调用 execve函数、进程执行的全过程以及异常与信号处理。经过这一章,我们的hello程序完成了P2P的过程。
第7章 hello的存储管理
7.1 hello的存储器地址空间
-
逻辑地址
机器语言指令中指定一个操作数或者是一条指令的地址,由段标识符和指定段内相对地址的偏移量构成。
-
线性地址
逻辑地址到物理地址变换之间的中间层,hello程序代码产生的逻辑地址,加上相应段基地址可生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换产生物理地址。若是没有采用分页机制,那么线性地址就是物理地址。
-
虚拟地址
程序运行在保护模式下,程序访问存储器所使用的逻辑地址:由hello程序产生,段选择符和段内偏移地址组成的地址。需要通过分段地址的变化处理后才会对应到相应的物理内存地址。在程序编译,链接的时候先映射进虚拟地址,在运行的时候会再映射进物理地址。
-
物理地址
物理地址用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
7.2 Intel逻辑地址到线性地址的变换-段式管理
- 逻辑地址组成
逻辑地址由48位组成的,前16位为段选择符,前13位是一个索引号,后面3位包含一些硬件细节,示意如下。后32位是段内偏移量。通过段选择符,我们可以获得段基址,再与段内偏移量相加,即可获得最终的线性地址。
索引号是段描述符的一个索引,段描述符具体地描述了一个段地信息,多个段描述符组成“段描述符表”,示意如下。TI表满段描述符为全局描述符表或者局部描述表。RPL:表示段的级别,有内核态和用户态两种。
- 变换过程分析
首先,给定一个完整的逻辑地址,根据段选择符的T1决定段描述符在GDT或者LDT表中,根据相应寄存器得到GDT或LDT的地址和大小,根据段选择符中前13位在表中查找到对应的段描述符和段基地址,加上段内偏移量,我们便得到了线性地址,示意如下:
7.3 Hello的线性地址到物理地址的变换-页式管理
- 变换过程分析
如果不考虑TLB与多级页表,虚拟地址可以分为虚拟页号和虚拟页偏移量。虚拟页号作为到页表中的索引,可以通过页表基址寄存器在页表中获得条目PTE:PTE中包含有效位和物理页号。如果有效位为0,则代表页面不在内存中,会触发缺页故障。如果有效位为1,则代表该页号已经缓存在了物理内存中,可以得到其物理页号,再与物理页偏移量共同构成物理地址。此处,物理页偏移量和虚拟页偏移量相同,均为P位。示意如下:
- 结合系统的变换过程
虚拟地址转换为物理地址的过程叫做地址翻译,需要CPU硬件和操作系统之间的紧密合作。主要为CPU上的内存管理单元(MMU)利用内存中的页表来动态翻译虚拟地址。考虑页面命中的情况,首先处理器生成一个虚拟地址传送给MMU, MMU生成PTEA,传送给高速缓存,高速缓存在页表内进行查询后向MMU返回PTE,MMU构造物理地址并传送给高速缓存,高速缓存返回所请求的数据字给处理器,地址翻译过程完成,示意如下:
7.4 TLB与四级页表支持下的VA到PA的变换
-
TLB
TLB是一个小的、虚拟寻址的缓存,称为翻译后备缓冲器,其中每一行都保存着一个由单个PTE组成的块,TLB通常具有高度的相联,虚拟地址的前n-p位中可分为TLB标记和TLB索引,示意如下:
-
四级页表
Intel corei7采用四级页表的层次结构,这种设计方法减少了对内存的需求:如果一级页表的PTE为空,那么该PTE的二级页表就不会存在,从而为进程节省了大量的内存,只有一级页表常常驻内存。虚拟地址的VPN被寄存器划分出来组成四个9位的片,分别为VPN1-4,每个片用作到一个页表的偏移量。CR3寄存器内储存了L1页表的物理起始基地址,指向第一级页表的一个起始。VPN1提供了到一个L1PTE的偏移量,这个PTE内包含L2页表的起始基地址.VPN2提供了到一个L2PTE的偏移量,一共四级,逐级以此层次类推,示意如下:
- VA到PA的变换分析
当TLB命中时地址翻译所包括的步骤如下:首先CPU产生一个虚拟地址VA,MMU传送VPN到TLB中,从TLB中取出相应的PTE。MMU将这个虚拟地址翻译成一个物理地址PA,并将它发送到高速缓存,高速缓存将所请求的数据字返回给CPU。示意如下:
7.5 三级Cache支持下的物理内存访问
-
高速缓存存储器结构
高速缓存的结构将物理地址位划分成了t个标记位,s个组索引位和b个块偏移位,和高速缓存内部结构如下所示,由于Intel corei7的L1Cashe有64组,每组8行,一行64字节。所以组索引位s为6,块偏移为为6,标记位为52-6-6=40位。
-
Cache支持下的物理内存访问
L1Cache的物理访存大致过程如下:组选择取出物理地址的组索引位,按照组索引找到相应的组。行匹配将物理地址的标记和相应的组中所有行的标记位进行比较,当物理地址的标记位和高速缓存行的标记位匹配并且高速缓存行的有效位是1,则高速缓存命中。一旦高速缓存命中,我们可以按照块偏移位得到我们想要的字节内容,把这个字节的内容取出返回给CPU即可。如果高速缓存不命中则需要从存储层次结构中的下一层取出被请求的块,然后将新的块存储按照组索引放入或替换该组中的某一行,具体替换哪一行取决于替换策略,再取出相应内容返回,整个从虚拟地址翻译到物理内存访问的过程示意如下:
7.6 hello进程fork时的内存映射
当fork函数被hello进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本,将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当任何一个进程进行写操作时,写时复制机制就会创建新页面,因此也就为每个进程保持了私有地址空间的抽象概念,示意如下:
7.7 hello进程execve时的内存映射
execve 函数加载并运行 hello 的步骤如下:
首先删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。其次映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构,这些区域均是私有、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,.bss 区域为请求二进制零且映射到匿名文件,栈和堆地址同样请求二进制零。然后映射共享区域,比如hello程序与共享对象 libc.so动态链接,再映射到用户虚拟地址空间中的共享区域内。最后设置程序计数器,设置当前进程上下文的程序计数器,使之指向代码区域的入口点。虚拟地址空间示意如下:
缺页故障与缺页中断处理
- 缺页故障
缺页故障指的是指令引用一个虚拟地址,而与该地址相应的物理页面不在内存中,这时候会触发缺页故障。故障处理流程示意如下:
- 缺页中断处理
当MMU在试图翻译某个虚拟地址时触发了一个缺页,这个异常会导致控制转移到内核的缺页处理程序,执行以下步骤:首先需要判断虚拟地址是否是在某个区域结构定义的区域内,若不是则说明指令不合法,缺页处理程序会触发一个段错误,终止这个进程。其次判断进程是否有读、写或执行这个区域内页面的权限,如果试图进行的访问不合法,那么缺页处理程序会触发一个保护异常,终止进程。最后,如果缺页是因为对合法的虚拟地址进行合法的操作造成的,会执行以下操作:首先通过查询页表PTE可以知道虚拟页在磁盘的位置,内核会选择一个牺牲页面,换入新的页面并更新页表PTE。当缺页处理程序返回时CPU重新启动引起缺页的指令,再次发送虚拟地址到MMU。MMU就能正常地翻译该虚拟地址,不会引起缺页中断。示意如下:
7.9动态存储分配管理
-
动态内存分配器
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。对每个进程,内核维护一个变量指向堆的顶部。分配器将堆视作一组不同大小的块的集合,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种基本风格:显式分配器和隐式分配器。显式分配器要求应用显式地释放任何已分配的块,隐式分配器要求分配器检测一个已分配块何时不再被程序所用,那么就释放这个块。
-
动态内存管理方法与策略
为实现动态内存分配器,可以使用隐式空闲链表,带边界标记的隐式空闲链表,显式空闲链表。隐式空闲链表的优点是简单。显著的缺点是任何操作的开销都要求对空闲链表进行搜索,该搜索时间与堆中已分配块和空闲块的总数呈线性关系。其示意如下:
带边界标记的隐式空闲链表允许在常数时间进行对前面块的合并,并且对许多不同类型的分配器和空闲链表组织都是通用的。其缺陷为它要求每个块都保持一个头部和一个脚部,在应用程序操作许多个小块时,会产生显著的内存开销。示意如下:
显式空闲链表方法是将堆组织成一个双向空闲链表,在每个空闲块中都包含一个前驱和后继指针,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。显式空闲链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部,导致了更大的最小块大小,也潜在地提高了内部碎片的程度,示意如下:
7.10本章小结
本章讨论了四种地址,接着分析了从逻辑地址到线性地址再到物理地址的转换过程。接着描述了从VA到PA的变换、物理内存访问、内存映射、缺页故障和缺页处理、动态存储分配管理。较为完整地阐明了hello的存储管理内容。由于动态存储分配管理并未学习,本章此处只进行简单介绍。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
-
设备的模型化:文件
一个Linux文件就是一个m字节的序列:B0,B1,B2……Bm。所有的 IO 设备都被模型化为文件,这使得所有的输入和输出都可以被当做对相应文件的读和写来执行。
-
设备管理:Unix io接口
这种将设备映射为文件的方式允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都被当做相应文件的读和写来执行。
8.2 简述Unix IO接口及其函数
下面介绍四种常用的Unix IO接口,分别为:打开文件,关闭文件,读文件,写文件。
-
打开文件
一个应用程序通过要求内核打开相应的文件,内核返回一个小的非负整数,即描述符。在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
函数原型为int open(char *filename, int flags, mode_t mode)。open函数将filename转换为一个文件描述符,并且返回描述符数字。flags参数指明了进程打算如何访问这个文件,mode参数则指定了新文件的访问权限位。
-
关闭文件
内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
函数原型为int close(int fd),close关闭描述符为fd的文件,注意关闭一个已关闭的描述符会出错。
-
读文件
读文件操作是从文件当前位置开始复制n个字节到内存。需要注意的是当读取字节数多于文件末尾到当前文件位置的时候执行读操作会触发一个EOF的条件。
函数原型为ssize_t read(int fd, void *buf, size_t n); read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf。返回值-1表示一个错误,而返回值0 表示EOF,否则,返回值表示的是实际传送的字节数量。
-
写文件
写文件操作就是从当前文件位置开始从内存复制n个字节到一个文件。
函数原型为 ssize_t write(int fd, const void *buf, size_t n); write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。成功则返回写的字节数,出错则为-1。
8.3 printf的实现分析
首先我们在c代码中查看printf的定义,示意如下,结合所参考地址内容,可得到printf的函数体如下:
首先我们研究代码,在形参列表里有一个…,是可变形参的写法。当传递参数的个数不确定时用这种方式来表示。(char*)(&fmt) + 4) 表示的是…中的第一个参数。接着我们可以查看vsprintf的内容
vsprintf返回的是要打印出来的字符串的长度,作用是格式化,它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。最后的write函数为写操作,把buf中的i个元素的值写到终端。我们追踪系统函数write,示意如下,在write函数中,将栈中参数放入寄存器,ecx存放字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用sys_call,sys_call的汇编代码示意如下:
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。接着字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点。于是我们的打印字符串就显示在了屏幕上。
8.4 getchar的实现分析
首先我们可以查看getchar的源码,示意如下,当我们运行到getchar函数时,程序将控制权交给操作系统。在进行输入时,内容会先进入缓存区,并在屏幕上回显。直到我们键入Enter,通知操作系统输入完成,这时才将控制权交还给程序。
-
异步异常-键盘中断的处理
当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程并运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
-
getchar函数的落实
getchar 函数调用了系统函数read读取存储在键盘缓冲区中的ASCII 码直到读到回车符然后返回整个字串。
8.5本章小结
本章介绍了hello的IO管理,包括Linux I/O的设备管理方法以及Unix I/O接口以及函数,分析了printf函数和getchar函数的实现。至此,我们完成了对hello的一生的考察。
结论
hello所经历的过程:
1.程序员通过高级语言编写hello.c
2.hello.c通过预处理得到hello.i
3.hello.i通过编译器处理得到汇编文件hello.s
4.hello.s通过汇编得到可重定位目标文件hello.o
5.hello.o通过链接得到可执行文件hello
6.在终端输入命令 ./hello 200111026 陈弘毅 1 运行程序
7.shell调用fork函数创建子进程并调用execve函数加载运行hello程序main函数
8.cpu为hello分配时间片, hello顺序执行自己的逻辑控制流
9.hello执行过程中访问内存,请求虚拟地址,MMU将程序中使用的虚拟内存地址通过页表映射成物理地址
10. 信号与异常:在hello的运行过程中可能会出现各种信号或者异常,内核会对这些信号进行处理
11. 当hello程序执行printf函数时,会调用 malloc申请堆中的内存,printf函数还和I/O的设备相关
12.hello最终被shell父进程回收,内核收回为其创建的所有数据结构和信息
对计算机系统设计与实现的深切感悟:
作为一名深圳校区的交流生,因为课程和深圳校区不一样,刚开学的时候我还不能理解计算机系统和计算机组成原理的区别差异。通过半个学期的学习,我想我初步认识理解了计算机系统,也深刻地感受到了计算机系统的精妙设计,当今的计算机发展以及人工智能,都离不开最底层的系统的设计与实现。我也知道未来仍然需要更多努力,通过更多的实践来加深对计算机系统的了解。
附件
文件名称 | 文件功能 |
---|---|
hello.c | 源程序 |
hello.i | 预处理后文件 |
hello.s | 汇编文件 |
hello.o | 可重定位目标文件 |
hello | 可执行文件 |
hello1.elf | hello.o的elf格式 |
hello1.asm | hello.o的反汇编 |
hello2.elf | hello的elf格式 |
hello2.asm | hello的反汇编 |
参考文献
[1] Randal E. Bryant, David R. O’Hallaon. 深入理解计算机系统. 第三版. 北京:机械工业出版社[M]. 2018:1-737.
[2] https://www.cnblogs.com/yjbjingcha/p/7040290.html.
[3] https://www.jianshu.com/p/fd2611cc808e.
[4] https://www.cnblogs.com/pianist/p/3315801.html