HITICS大作业

计算机系统

大作业

计算机科学与技术学院

2019年12月

摘 要

本文以一个非常简单的hello程序为载体,深入浅出地展示了程序在linux系统上的整个生命周期,并基于这个过程较为详细地分析了其中涉及到的linux系统的各种软件与硬件的管理方式,包括C程序编译、链接过程、计算机存储体系、异常控制流、虚拟内存系统等几个方面。随着分析的进行,我们能在整体的层次上看清每个程序是如何运行的,同时也不失对一些重要细节实现的理解。也可以看到我们的计算机在操作系统的引领下,巧妙地利用一个个“概念”,使软件与硬件和谐、高效地工作着。

关键词:计算机系统;hello程序的生命周期;虚拟内存系统

目 录

第1章 概述 - 4 -

1.1 Hello简介 - 4 -

1.2 环境与工具 - 4 -

1.3 中间结果 - 4 -

1.4 本章小结 - 4 -

第2章 预处理 - 5 -

2.1 预处理的概念与作用 - 5 -

2.2在Ubuntu下预处理的命令 - 5 -

2.3 Hello的预处理结果解析 - 5 -

2.4 本章小结 - 5 -

第3章 编译 - 6 -

3.1 编译的概念与作用 - 6 -

3.2 在Ubuntu下编译的命令 - 6 -

3.3 Hello的编译结果解析 - 6 -

3.4 本章小结 - 6 -

第4章 汇编 - 7 -

4.1 汇编的概念与作用 - 7 -

4.2 在Ubuntu下汇编的命令 - 7 -

4.3 可重定位目标elf格式 - 7 -

4.4 Hello.o的结果解析 - 7 -

4.5 本章小结 - 7 -

第5章 链接 - 8 -

5.1 链接的概念与作用 - 8 -

5.2 在Ubuntu下链接的命令 - 8 -

5.3 可执行目标文件hello的格式 - 8 -

5.4 hello的虚拟地址空间 - 8 -

5.5 链接的重定位过程分析 - 8 -

5.6 hello的执行流程 - 8 -

5.7 Hello的动态链接分析 - 8 -

5.8 本章小结 - 9 -

第6章 hello进程管理 - 10 -

6.1 进程的概念与作用 - 10 -

6.2 简述壳Shell-bash的作用与处理流程 - 10 -

6.3 Hello的fork进程创建过程 - 10 -

6.4 Hello的execve过程 - 10 -

6.5 Hello的进程执行 - 10 -

6.6 hello的异常与信号处理 - 10 -

6.7本章小结 - 10 -

第7章 hello的存储管理 - 11 -

7.1 hello的存储器地址空间 - 11 -

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 -

7.9动态存储分配管理 - 11 -

7.10本章小结 - 12 -

第8章 hello的IO管理 - 13 -

8.1 Linux的IO设备管理方法 - 13 -

8.2 简述Unix IO接口及其函数 - 13 -

8.3 printf的实现分析 - 13 -

8.4 getchar的实现分析 - 13 -

8.5本章小结 - 13 -

结论 - 14 -

附件 - 15 -

参考文献 - 16 -


第1章 概述

1.1 Hello简介

人们接触到的第一个程序便是hello
world,虽然十分简单,但是只有学习过csapp的人才知道从一无所有到program到process最后再完美谢幕的整个过程,即P2P(From
Program to Process)
。程序员们在ide里一字一键地敲入hello
world,在磁盘中存储,按下编译键后,hello
world被预处理、编译、汇编、链接成为可执行文件,一个程序便诞生了。在终端中运行这个程序,加载器将程序加载到内存中,经历过一系列的cache命中与不命中,最终得以高速启动程序。这个时候,终端fork\execve一个子进程,操作系统组织维护上下文、分配时间片,一个属于hello
world的进程诞生了。CPU一行一行从内存中取出指令,顺序执行,MMU作为虚拟内存和物理内存的转换者,功不可没。执行的过程中软硬结合,各个进程与部件不断接受、发送信号,是他们的协调工作使hello
world在自己的时间片中”独占”整个电脑,并与其他系统进程一起和谐相处,并行走向自己的终点。进程时间片结束,hello
world向父进程发送SIGCHLD等信号,父进程(bash等)接受信号,着手回收hello
world进程,释放空间…终于,电脑又恢复了”平静”。

1.2 环境与工具

Ubuntu16.04 gdb objdump readelf

1.3 中间结果

hello.i(预处理后的程序文本)
hello.s(编译后的程序文本)
hello.o(汇编后的二进制文件)
hello.o.txt(hello.o文件readelf -a得到的文件)
hello.txt(hello程序readelf -a得到的文件)

1.4 本章小结

本章简单介绍了P2P的思想,下文将详细阐述。


第2章 预处理

2.1 预处理的概念与作用

所谓预处理是指进行编译的第一遍扫描之前所做的工作,是C语言中的一个重要功能,由预处理程序负责完成。

C语言提供了三种预处理的功能:宏定义,文件包含,条件编译。下面一一说明:

宏非常常见,包括无参数宏和有参数宏。用#define标识,其中的#正是编译预处理命令。需要注意宏替换是完全替换,一不留神可能出现错误,请看下例:

在这里插入图片描述

文件包含也十分常见,我们从hello world开始就要#include
<stdio.h>,这里便是一次文件包含,同样#表示预处理命令。文件包命令功能是指定文件插入该位置取代该命令行,从而把指定文件和当前源文件程序连成一个源文件。

至于条件编译,在一些工程文件中有非常奇妙且方便的用途,如笔者在接触单片机编程的时候可以看到官方提供的库函数中大量使用条件编译,节省了篇幅,在某种程度上甚至成为了编程规范,下面是条件编译的一些用法:

综上所述可以看到,预处理在我们的程序中虽然不起眼,但确实是很有用的。预处理用#标识,一般在程序前部使用。预处理后得到的文件为.i,也是C代码。

2.2在Ubuntu下预处理的命令

gcc -E hello.c -o hello.i

2.3 Hello的预处理结果解析

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
2.4 本章小结

在本章我们深入了解了预处理的相关知识。

首先我们说,预处理是编译命令进行编译的第一遍扫描之前所做的工作,他是由预处理程序完成的。预处理器把hello.c文件预处理为hello.i文件。

一般在c源码中用#来标识预处理命令。而预处理命令大致包括三种:宏定义与宏替换、文件包含、条件编译。宏定义用#define
A B表示A
B两个字符串完全相等,在预处理时把A全部用B代替,称为宏替换,同时需要注意宏替换可能带来的粗心错误;文件包含如#include
<stdio.h>,预处理时把stdio.h文件和hello.c文件结合为源文件进行下一步操作;条件编译如#if等在工程文件中会方便库函数的书写。

在ubuntu下利用gcc进行预处理的命令为:gcc -E hello.c -o hello.i

打开生成的hello.i文件我们可以看到,短短的hello.c变成了一个十分长的hello.i,里面新添加了许多声明,而在文件的最末尾我们找到了hello.c的代码。可见预处理要做的工作还是很多、很重要的。


第3章 编译

3.1 编译的概念与作用

编译的过程实质上就是把高级语言(这里指C语言)翻译成汇编语言的过程

编译器主要对C文件做了如下工作:

(1)C语言词法分析,

(2)C语言语法分析

(3)C语言语义分析

(4)优化后生成相应的汇编代码

3.2 在Ubuntu下编译的命令

gcc -S hello.c -o hello.s 或

gcc -S hello.i -o hello.s (可以接着处理预处理得到的.i文件,下文将省略)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bjZsQQfU-1577365696258)(media/148847d17c89bb37e038b2269c300986.png)]

3.3 Hello的编译结果解析

3.3.1 算术操作

①‘argc!=4’、‘i<8’的计算


在这里插入图片描述
程序计算’argc!=4’的值是通过

mov语句将argc的值(用edi存储)放入栈中,用rbp的相对寻址找到argc,然后将其与4进行cmpl操作比较,通过查看操作位来判断argc与4的关系。

i<8类似,不再分析。

②‘i++’的计算

在这里插入图片描述
在这里插入图片描述

程序将局部变量i放入栈中,用rbp的相对寻址确定i的位置,首先mov将此位置设置为0,然后每一遍循环后均使用add语句实现i的自加,最后用cmp比较i与7的大小,实现跳转。

3.3.2 函数操作

在这里插入图片描述
在这里插入图片描述

拿第一个printf举例,程序调用函数会首先利用寄存器rdi\edi等寄存器传递参数,然后执行call指令,使RIP跳转到puts函数的首地址,开始执行puts函数。也正如前面所见,main函数的参数也是由rdi、rsi传递而来。同时观察atoi函数的调用可知返回值将存储在eax中。如下图

在这里插入图片描述

3.3.3 控制转移

控制转移包括了条件判断、函数调用等,在这里将阐述条件判断的具体步骤。

在这里插入图片描述
在这里插入图片描述

程序通过cmp等指令比较if中的两个值,利用je等指令查看标志寄存器中的值来判断if语句判断是否正确,利用标签.L2等标识将要跳到的位置。

For循环也是如此,每次比较i与7的大小,如果不满足要求则顺序执行,如果满足要求则跳到指定标签处。

关于标志位的比较指令详细如下图:

在这里插入图片描述
3.3.4 常量、数组的存储与寻址

①局部变量由前文分析已知存储在栈空间,利用rbp相对寻址,不再赘述。

②对于字符串常量、格式化字符串(全局变量等):

在这里插入图片描述

在.s文件中通过标签标识,此信息声明在main函数汇编代码之前,如下图:

在这里插入图片描述

③对于数组argv[]的引用来说

在这里插入图片描述

程序通过rax作为中间值不断地赋值,最后将栈上存储的argv[1]\argv[2]赋值给rsi\rdx,在调用printf前将格式化字符串赋值给edi,并将eax置0。

在这里插入图片描述

3.4 本章小结

从这一步开始程序员进入到一个全新的汇编世界,了解到C语言和汇编语言的对应关系。

关于数据存储与寻址,局部变量放在栈上,而全局变量、字符串常量如格式化字符串等暂时由标签指定。宏定义如#define
n (1+2),在赋值int m=n时查看汇编代码为mov
m,3(伪汇编),可见宏定义的计算是在编译过程中完成的。数组用mov
(%rdx,%rcx,4),%eax这样的格式来访存(其中i在rcx,首地址为rdx)。

赋值操作有mov\lea等指令,计算操作有add\sub等指令,移位有SAL\SHL等指令,类型转换中可以利用mov进行扩充,还有一些专门的扩充指令cbw等(8086系列)。sizeof()与宏处理一样,在编译过程中自动计算。

关于控制转移程序利用test\je等指令检查标记寄存器的内容判断是否应该跳转,函数调用时用call指令实现对函数的调用,控制转移的方式均为保存好上下文以后改变RIP。64位程序通过rdi、rsi等寄存器以及栈空间传递参数,通过rax传递返回值。

也观察到一些有趣的地方。可以看到短短几行的c语言赋值操作,在汇编语言中可能要反复调用一个中间变量(寄存器)才能计算出结果,看上去十分笨拙;同时程序会进行一些诡异的优化,使得从C语言到汇编的对应关系变得比较模糊;也观察到调用一些函数的时候会要求将某些寄存器赋值为0。


第4章 汇编

4.1 汇编的概念与作用

汇编就是把汇编语言转换成机器语言的过程,即生成机器看得懂的代码。

4.2 在Ubuntu下汇编的命令

gcc -c hello.s -o hello.o

在这里插入图片描述

4.3 可重定位目标elf格式

可重定位目标文件的格式如下:

最开始是ELF头,它以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,节头部表中条目的大小和数量等。

在这里插入图片描述

结尾为节头部表,它描述了不同节的位置和大小,其中目标文件的每个节都有一个固定大小的条目。可以看到.rela.text等重定位节。

在这里插入图片描述

下图为两个可重定位节的内容:

在这里插入图片描述

可以看到.text每个标识了重定位信息的偏移处的详细重定位内容,包括Name\Addend等,重定位结构体的成员与作用如下图。

在这里插入图片描述

其中type共有32种不同的重定位类型,其中最基本的两种为:
R_X86_64_PC32:重定位一个使用32位PC相对地址的引用。在指令中编码
的32位值加上PC的当前运行时值,得到有效地址。

R_X86_64_32:重定位一个使用32位PC绝对地址的引用。直接使用在指令
中编码的32位值作为有效地址。

还可以看到符号表,即.symtab段。他存放全局变量、静态变量等信息。

在这里插入图片描述

4.4 Hello.o的结果解析

objdump -d -r hello.o 后分析hello.o的反汇编:

可以看到hello.o反汇编大致上和hello.s是一样的,但也有很明显的区别。

hello.o反汇编后略去了所有标签,对于控制转移语句,它将原先hello.s标签指示的地址显式地用.text段的偏移给出,但具体内容还是一样的。

在这里插入图片描述
在这里插入图片描述
同时对于字符串常量以及函数名,在hello.o反汇编文件中都已不再出现,而由一个个重定位标签给出。

在这里插入图片描述
在这里插入图片描述
关于机器码的组成,可以看到它和汇编代码是一一对应的,有一套非常详细的规则来实现这个一一对应,一般一行机器码包括功能数、操作数等,如第一行:55唯一标识PUSH bp,其他命令也是类似,操作数会直接在机器码中给出。

在这里插入图片描述

实际上有下表的对应关系,其他指令也是类似。

50PUSH ax
51PUSH cx
52PUSH dx
53PUSH bx
54PUSH sp
55PUSH bp

4.5 本章小结

汇编过程将汇编代码.s文件转换成对应的机器码.o文件,这个ELF文件称为可重定位的目标文件,但是仍然不是可执行文件。注意gcc
-c将生成重定位目标文件,而gcc -C将生成可执行文件。

可以看到与编译过程不同,机器码与汇编代码可以一一对应,根据一些确定的规则进行相互准确翻译。

也能看到hello.o反汇编后生成的汇编代码与原本的hello.s是大同小异的,唯一的区别便是略去了所有的标签,并附上了一些可重定位的信息,便于链接器的进一步处理。


第5章 链接

5.1 链接的概念与作用

链接为处理可重定位文件,把各种符号引用和符号定义转换成为可执行文件中的合适信息的过程,主要包括符号解析与重定位两大部分,链接将多个可重定位文件结合在一起,共同生成可执行文件。

5.2 在Ubuntu下链接的命令

ld -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 /usr/lib/gcc/x86_64-linux-gnu/5/crtbegin.o
hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/5/crtend.o
/usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello

5.3 可执行目标文件hello的格式

ELF头描述文件的总体格式,还包括程序的入口点。

在这里插入图片描述

可以看到可执行文件的节很多,各个段的大小、地址如下图显示:

在这里插入图片描述

不同段各自的功能如下(参考博客):

.text节是保存了程序代码指令的代码节。一段可执行程序,存在Phdr,.text就会存在于text段中。由于.text节保存了程序代码,因此节的类型为SHT_PROGBITS。

.rodata 保存只读数据。类型SHT_PROGBITS。

.plt 过程链接表(Procedure Linkage
Table),包含动态链接器调用从共享库导入的函数所必须的相关代码。存在于text段中,类型SHT_PROGBITS。

.bss节保存未初始化全局数据,是data的一部分。程序加载时数据被初始化成0,在程序执行期间可以赋值,未保存实际数据,类型SHT_NOBITS。

.got节保存全局偏移表。它和.plt节一起提供了对导入的共享库函数访问的入口。由动态链接器在运行时进行修改。如果攻击者获得堆或者.bss漏洞的一个指针大小写原语,就可以对该节任意修改。类型SHT_PROGBITS。

.dynsym节保存共享库导入的动态符号信息,该节在text段中,类型SHT_DYNSYM。

.dynstr保存动态符号字符串表,存放一系列字符串,代表了符号的名称,以空字符作为终止符。

.rel节保存重定位信息,类型SHT_REL。

.hash节,也称为.gnu.hash,保存一个查找符号散列表。

.symtab节,保存了ElfN_Sym类型的符号信息,类型SHT_SYMTAB。

strtab节,保存符号字符串表,表中内容被.symtab的ElfN_Sym结构中的st_name条目引用。类型SHT_SYMTAB。

.shstrtab节,保存节头字符串表,以空字符终止的字符串集合,保存了每个节节名,如.text,.data等。有个e_shsrndx的ELF文件头条目会指向.shstrtab节,e_shstrndx中保存了.shstrtab的偏移量。这节的类型是SHT_SYMTAB。

.ctors和.dtors节,前者构造器,后者析构器,指向构造函数和析构函数的函数指针,构造函数是在main函数执行前需要执行的代码,析构是main函数之后需要执行的代码。

5.4 hello的虚拟地址空间

拿.text段举例,由5.3节节头部表可知
.text段开始于0x400550段,大小为0x01f2。运行程序以后观察内存中0x400550内容确实可以看到这就是代码段,从_start()函数开始的0x01f2个字节均为代码。

在这里插入图片描述

可以看到加载入内存后确实为下图所示结构:

在这里插入图片描述

5.5 链接的重定位过程分析

在这里插入图片描述

正如4.4节所述,可以看到有两种基本的重定位方式:PC相对寻址和PC直接寻址。下图为重定位节
在这里插入图片描述

对于PC直接寻址:如红色框中,这些字段告诉链接器要修改从偏移量为0x16开始的绝对引用,这样运行时它将指向正确的位置。

对于PC间接寻址:如蓝色框中,这些字段告诉连接器要修改偏移量为0x25的间接寻址,程序加载入内存后已知exit()的地址为0x400520,main()为0x400646

0x400520-(0x400646+0x25)-0x4=FFFF FFFF FFFF FEB1,小端存储便是图中结果。
在这里插入图片描述

其他重定位计算方法相同,不再一一赘述

5.6 hello的执行流程

使用edb执行hello,说明从加载hello到_start,到call
main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。

在程序入口处设下断点(0x400550),将进入到_start函数

在这里插入图片描述

_start函数call __libc_start_main@plt后进入到这个函数

在这里插入图片描述

然后进入到_dl_runtime_resolve_xsavec()

在这里插入图片描述

又跳到_dl_fixup()等函数,最后返回到_dl_runtime_resolve_xsavec()

在这里插入图片描述

而_dl_runtime_resolve_xsavec()返回到__libc_start_main()

在这里插入图片描述

接着又调用了__cxa_atexit()\_setjmp()等函数

在这里插入图片描述
在这里插入图片描述
最后终于进入到main()函数

在这里插入图片描述

Main()函数中调用printf()\atoi()\sleep()\getchar()等函数

在这里插入图片描述

在这里插入图片描述

Main()函数结束后将返回到__libc_start_main()函数

在这里插入图片描述
__libc_start_main()最终调用exit()函数,结束整个过程。

5.7 Hello的动态链接分析

程序调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定的技术解决这个问题,将过程地址的延迟绑定推迟到第一次调用该过程时。

延迟绑定要用到全局偏移量表(GOT)和过程链接表(PLT)两个数据结构。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT。

PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,跳转到动态链接器中。每个条目都负责调用一个具体的函数。PLT[[1]]调用系统启动函数(__libc_start_main)。从PLT[[2]]开始的条目调用用户代码调用的函数。

GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[[1]]包含动态链接器在解析函数地址时会使用的信息。GOT[[2]]是动态链接器在ld-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。

下图可以看到GOT的起始地址为0x601000

在这里插入图片描述

_dl_start()执行前如下图,GOT表部分为空:

在这里插入图片描述

_dl_start()执行后如下图,空的部分已被补齐,这一块便是动态共享库

在这里插入图片描述

由前文提到的内存映射可知这一块确实是.so动态库

在这里插入图片描述

5.8 本章小结

本章深入浅出,详细讨论了链接器所作的工作以及程序加载后在内存中的样子。可以看到链接的重定向机制依赖于重定向结构体,通过直接和间接两种方式进行重定向。而链接从最初的静态库一步一步演化,到加载时链接的动态库、到程序执行时的链接手段以及最后的库打桩机制,逐渐走向成熟,极大地促进了计算机科学的发展。


第6章 hello进程管理

6.1 进程的概念与作用

进程的经典定义就是一个执行中的程序的实例。

操作系统拥有进程概念之后,对于我们来说会得到一些假象,好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器好像是无间断地一条接一条地执行我们程序中的指令。最后,我们的程序中的代码和数据好像是系统内存中唯一的对象。这些都离不开进程。

6.2 简述壳Shell-bash的作用与处理流程

Shell是一个交互型的应用级程序,他代表用户运行其他程序。最早的shell是sh程序,后面出现了许多变种,如csh\tcsh\bash等。它能执行一系列的读、求值步骤,然后终止。shell的简单定义就是命令行解释器,功能是将使用者的命令翻译给核心处理,同时将核心处理的结果翻译给使用者,如下图所展示

在这里插入图片描述

处理流程:

1)从终端读入输入的命令。

2)将输入字符串切分获得所有的参数

3)如果是内置命令则立即执行

4)否则调用相应的程序为其分配子进程并运行

5)shell 应该接受键盘输入信号,并对这些信号进行相应处理

6.3 Hello的fork进程创建过程

在终端中敲入./hello 1180301024 石力
1,终端解析命令,判断目标为运行可执行程序hello。于是将输入字符串切分获取所有的参数,调用fork()函数,创建一个新的子进程,这个子进程和父进程几乎但不完全相同,他们拥有完全相同但独立的虚拟内存空间,包括代码和数据段、堆、共享库和用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。子进程与父进程有不同的pid。fork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0。父进程与子进程是并发运行的独立进程,两者没有明确的先后关系。

6.4 Hello的execve过程

终端调用fork()产生一个新的子进程后,在子进程中执行execve()函数,传递参数数组与环境变量数组,加载并执行可执行目标文件hello。Execve()函数只有发生错误才会返回,正常调用从不返回,程序运行到hello。加载并执行的过程大致分为以下四个步骤:

1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。

2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。

3.映射共享区域。如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

4.设置程序计数器。设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。

6.5 Hello的进程执行

我们知道,操作系统内核利用异常控制流来实现多任务,毕竟整个电脑现在不可能只运行一个hello程序,而要了解hello的进程执行与其中的多任务时间片切换,我们需要先明确下面几个概念:

  1. 上下文:指内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表,包含有关当前进程信息的进程表,以及包含进程已打开文件信息的文件表。

  2. 调度:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就成为调度,是由内核中成为调度器的代码处理的。

Hello执行时,操作系统为其分配时间片与内存空间,程序开始执行,当执行
到某些函数时,内核会代表用户执行系统调用,发生上下文切换。如sleep()函数,它显式地请求让调用进程休眠。操作系统接受到信号,将hello当前时间片截断,保存上下文后转而执行另外的进程B,当sleep()倒计时结束后根据保存的hello上下文恢复hello的时间片与数据等,继续执行hello进程,如下图。

在这里插入图片描述

6.6 hello的异常与信号处理

异常与信号种类如下:

在这里插入图片描述
在这里插入图片描述

程序在运行的过程中内核接受到一个信号,内核首先会检查hello程序的未被阻塞的待处理信号的集合,如果其中有此信号,则内核强制hello程序接受此信号,触发某种行为。如果程序遇到异常,内核会将程序转到内核态执行异常处理子程序,最后可能会到原程序,也可能终止原程序。

比如hello程序在运行是按下ctrl+Z,hello接受此信号并挂起放入后台,执行ps命令显示当前进程
(process)
,可以看到当前进程有终端bash、hello以及ps;执行,jobs查看当前后台运行的任务,如下图:

在这里插入图片描述

执行pstree可以看到整个的进程树:

在这里插入图片描述

这时输入fg命令使后台中的进程hello调至前台运行

在这里插入图片描述

也可以使用kill命令将进程结束,如下图:

在这里插入图片描述

可以看到hello执行的过程中确实可以接受各种各样的信号,并进行一系列的异常处理。

6.7本章小结

在此之前我们探寻了一个程序,它存在于磁盘上,由数据和代码构成,而这个程序只有通过加载入内存,也就是成为进程之后才算运行。于是从本章开始接触进程这个非常重要的概念,操作系统在这个概念上建立起异常处理、上下文切换等技术,让许许多多的程序运行时好像独占整个电脑资源,同时也让在它们之间的切换变得游刃有余。

这一节从进程的概念开始,依据linux
shell的工作原理,详细地展示了hello的进程管理:从shell解析命令开始,fork()一个子进程以后调用execve()加载并运行hello,为其提供虚拟内存空间并分配时间片,执行程序。而在程序执行时,操作系统中会产生各种各样的信号、异常,有些时候会强迫hello进程接受某些信号,并作出响应行为,或者切换到内核态执行异常处理子程序。最后hello进程时间片用完,父进程(也就是bash)也会回收hello进程,一切仿佛又恢复了平静。


第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:在有地址变换功能的计算机中,访内指令给出的地址 (操作数)
叫逻辑地址,也叫相对地址。它由由段选择符和段偏移组成,要经过寻址方式的计算或变换才得到内存储器中的实际有效地址。

线性地址(Linear
Address)是逻辑地址物理地址变换之间的中间层。在分段部件中段内的偏移地址加上基地址就是线性地址。

虚拟地址:在虚拟内存系统中的地址

物理地址:在物理内存中的地址

7.2 Intel逻辑地址到线性地址的变换-段式管理

CPU有许多段寄存器,如CS、DS等,而在.c文件中所有出现的地址均为逻辑地址,系统会根据段寄存器和段内偏移计算出线性地址

在实模式下,逻辑地址CS:EA对应线性地址的CS*16+EA,或者说不存在线性地址的概念,此时计算的结果就是虚拟地址。

在保护模式下,以段描述符作为下标,到GDT、LDT表查看获得短地址,再利用段地址+偏移地址得到线性地址。如下图:

在这里插入图片描述

7.3 Hello的线性地址到物理地址的变换-页式管理

当CPU发送一个地址请求,它将地址(虚拟地址)发送给MMU,MMU根据虚拟地址计算出物理地址,送往主存。这个过程可以利用页表、快表甚至是多级页表来提高速度,而它们之间进行数据交换的最小单位称为页,如何有序、准确地执行这个过程便称为页式管理。

7.4 TLB与四级页表支持下的VA到PA的变换

下图比较准确地描述了整个过程:

在这里插入图片描述

首先CPU发送请求,将虚拟地址发送到TLB,根据虚拟地址的VPN确定TLB的索引与标记位,并在快表里查看是否命中,命中则直接从快表中得到PPN。如果不命中则将VPN分解为四级页表的偏移,利用CR3存储的一级页表起始地址,MMU先通过VPN1找到在一级页表中对应的条目,利用此条目获取对应二级页表的首地址,再利用VPN2找到二级页表中对应的条目,如此获取直到从第四级页表中获取PPN。然后将PPN与VPO结合得到物理内存PA

7.5 三级Cache支持下的物理内存访问

继续以上图说明,MMU计算出物理内存地址以后发送给一级cache,将物理内存分解为标记、组索引、块偏移,并利用这些数据在一级cache中查看是否命中,如果命中则直接将结果返回给CPU执行。如果不命中,则将物理内存地址发送给二级cache,查看是否命中,不命中则继续向下一级存储器传递,直到找到所需地址内容,找到之后将此内容向上传递,在每一级cache中缓存,并最后传递到cpu执行。

7.6 hello进程fork时的内存映射

在虚拟内存系统的体系下的fork()函数执行过程如下:

当fork()函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork()在新进程中返回时,新进程现在的虚拟内存刚好和调用fork()时存在的虚拟内存相同,当这两个进程中任意一个后来进行了写操作时,写时复制机制就会创建新页面,下图为写时复制的示意图:

在这里插入图片描述

因此,也就为每个进程保持了私有地址空间的抽象概念,程序通过某些结构体标识对应部分,如下图

在这里插入图片描述

7.7 hello进程execve时的内存映射

Execve()在新的子进程中加载并执行了hello文件,使hello程序有效地代替了当前程序,整个过程大致分为以下四个步骤:

1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。

2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。

3.映射共享区域。如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

4.设置程序计数器。设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。
在这里插入图片描述

7.8 缺页故障与缺页中断处理

页表中每个条目由有效位与内容构成,每个条目可能有三种状态:

未分配:虚拟内存系统还未分配的页,它与任何数据都没有关联,也不占用任何磁盘空间。对应条目的有效位为0,内容为NULL。

已缓存:已经缓存在物理内存中的已分配页。对应条目的有效位为1,内容为指针,指向物理内存对应地址。

未缓存:没有缓存在物理内存中的已分配页。对应条目的有效位为0,内容为指针,指向虚拟内存对应地址。

当CPU请求一个地址时,MMU在锁定页表项后发现此页表条目为为缓存,如下图,此时便称为发生了缺页故障,如下图对VP3引用:

在这里插入图片描述

这时程序会触发缺页故障,内核从磁盘中复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟内存重新发送到地址翻译硬件,但这个时候VP3已经缓存在主存中了,于是缺页故障不会再次发生,程序得以正常运行,如下图。

在这里插入图片描述
7.9动态存储分配管理

当程序动态申请内存(如malloc)时,动态内存分配器便参与进来,组织并分配堆空间,这便是动态内存分配。

动态内存分配器分为两种:显式分配器(要求显式地释放任何已分配的块)与隐式分配器(分配器会自动检测一个已分配块什么时候不再被程序使用,那么就会自动释放这个块),以显式分配器为例:

它能够接受动态内存分配的命令,并立即开始分配、回收工作,为了提高运行效率、更快地找到空闲块、更好地组织堆内存,分配器会维护某些数据结构,称为空闲链表,主要分为以下几类:

隐式空闲链表:每个块由一个字的头部、有效载荷,以及可能的一些额外的填充组成。头部编码了这个块的大小,以及这个块是已分配的还是空的,如下图:

在这里插入图片描述

显式空闲链表:显式地存放下一个空闲块与上一个空闲块的地址,浪费了一定的空间但是提高了执行效率,如下图:

在这里插入图片描述

分离空闲链表:将空闲块大小分为不同的大小类,每一个类维护一个空闲链表,这样可以在操作时无需遍历所有空闲链表,只需在这个大小类中寻找对应空闲块,提高了执行效率。

利用上述数据结构,我们可以很轻松地组织起堆空间,这样构建起来的分配器便能满足malloc\free等要求:

在这里插入图片描述

7.10本章小结

本章接触到一个非常重要的概念:虚拟内存系统。可以说这是建立在进程概念上的,每个程序运行起来都有自己的进程与地址空间,但他们表面上看上去都是一样的:不同的程序代码段、数据段的地址好像并没有什么变化,每个进程好像独享整台电脑存储资源,这便是虚拟内存的功劳。可以看到,虚拟内存的建立大大的简化了链接的工作,同时它也是作为缓存、内存管理、内存保护的工具。

虚拟内存系统给操作系统提供虚拟内存,CPU中MMU模块负责将虚拟内存地址转换为实际物理内存的地址,其中运用到许多提高效率的方法:如使用多级页表、快表等。整体来看,一个局部性良好的程序能完美驾驭整个虚拟内存系统,并大大地提高了效率。而在虚拟内存之上,我们可以理解fork()和execve()的过程,也能设计出能够组织堆空间来完成动态内存分配的动态内存分配器。

至此,hello程序的在系统上运行的过程已经变得透明了起来。


第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:将设备抽象成文件

设备管理:通过unix io接口管理

8.2 简述Unix IO接口及其函数

接口:

打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息

Linux shell创建的每个进程开始时都有三个打开的文件:标准输入,标准输出,标准错误

改变当前文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k

读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时执行读操作时触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k

关闭文件:当应用完成了访问,它就通知内核关闭这个文件,并释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去

函数:

int open(char* filename,int flags,mode_t mode)
:进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位

int close(fd),fd是需要关闭文件的描述符

ssize_t read(int fd,void *buf,size_t
n),该函数从描述符为fd的当前位置最多赋值n个字节到内存buf的位置,返回值为实际传送的字节数量

ssize_t wirte(int fd,const void *buf,size_t
n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。

8.3 printf的实现分析

printf的代码:

  1. int printf(const char *fmt, …)

  2. {

  3. int i;

  4. char buf[256];

  5. va_list arg = (va_list)((char*)(&fmt) + 4);

  6. i = vsprintf(buf, fmt, arg);

  7. write(buf, i);

  8. return i;

  9. }

其中,va_list是一个字符指针,arg表示函数的第二个参数。
vsprintf的代码:

int vsprintf(char *buf, const char *fmt, va_list args)

  1. {

  2. char* p;

  3. char tmp[256];

  4. va_list p_next_arg = args;

  5. for (p=buf;*fmt;fmt++) {

  6. if (*fmt != ‘%’) {

  7. *p++ = *fmt;

  8. continue;

  9. }

  10. fmt++;

  11. switch (*fmt) {

  12. case ‘x’:

  13. itoa(tmp, *((int*)p_next_arg));

  14. strcpy(p, tmp);

  15. p_next_arg += 4;

  16. p += strlen(tmp);

  17. break;

  18. case ‘s’:

  19. break;

  20. default:

  21. break;

  22. }

  23. }

  24. return (p - buf);

  25. }

vsprintf的作用是格式化。它接受确定输出格式的格式字符串fnt。用格式字符串对个数变化的参数进行格式化,产生格式化输出,并返回要打印的字符串的长度。
write的代码:

mov eax, _NR_write

mov ebx, [esp + 4]

mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

先给寄存器传了几个参数,然后通过系统调用sys_call

sys_call:

call save

push dword [p_proc_ready]

sti

push ecx

push ebx

call [sys_call_table + eax * 4]

add esp, 4 * 3

mov [esi + EAXREG - P_STACKBASE], eax

cli

retsyscall

将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

1.int getchar(void)

  1. {

  2. static char buf[BUFSIZ];

  3. static char *bb = buf;

  4. static int n = 0;

  5. if(n == 0)

  6. {

  7. n = read(0, buf, BUFSIZ);

  8. bb = buf;

  9. }

  10. return(–n >= 0)?(unsigned char) *bb++ : EOF;

  11. }

getchar函数调用read函数,将整个缓冲区都读到buf里,并将缓冲区的长度赋值给n。返回时返回buf的第一个元素,除非n<0。

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章介绍了linux的IO设备管理方法和及其接口和函数

结论

由上面的章节可以体会到,hello程序的一生看似非常短暂且枯燥,但却是许许多多个强大的功能部件与有趣的软件管理理念共同作用的结果,是无数计算机科学家刻苦钻研、潜心研究的成果。计算机科学家们将自己的苦心埋藏进历史的角落,转而为后世提供一个个十分便利的用户级接口。而作为用户的我们,了解这些较底层的细节不仅能提高自己对计算机的理解,更能提高自己的编程能力并拓宽思路。而笔者更把这次学习过程当作一次对科学家们的致敬,同时由于时间和精力有限难免存在疏漏与错误,敬请谅解。

hello的一生主要分为三个层次:

  1. 预处理、编译、汇编、链接(静态链接)。显而易见高级语言才是更适合人类的语言,我们用高级语言编程,操作系统不辞辛苦地将其转变为机器语言,转变成为可以在机器上直接运行的文件,这便是第一层次。

  2. 链接(动态链接)、执行、回收。一个可以在机器上直接运行的文件到底是什么运行的呢?它会被抽象为进程,被CPU调度;它会拥有属于自己的时间片与内存空间;它会接受、发出信号也会处理信号;取指结束后它也会被父进程回收。可执行文件hello运行的全过程便是第二层次。

  3. 存储。在hello运行时,CPU对它的访问离不开各个层次的cache:我们有指令cache,有数据cache,还有页表cache。存储体系结构配合着虚拟内存系统让hello的存储与加载变得简单高效,而提供的io接口等更能使其在磁盘上留下属于自己的痕迹。这便是第三层次。

通过本学期的学习,我们对计算机系统有了一个大概的整体认知,其中一些
有趣的实现细节也难以忘却。我由衷地珍视《csapp》这本书,它带给我的宝贵知识财富可能在学长眼里不值一提,但多年以后我一定不会忘记这本书带给我的启蒙。也正如ppt上所说“历史长河中一个个菜鸟与我擦肩而过,只有CS知道我的生、我的死,我的坎坷”。我学习了,我确实知道了。


附件

hello.c(源代码)

hello.i(预处理后的程序文本)

hello.s(编译后的程序文本)

hello.o(汇编后的二进制文件)

hello.o.txt(hello.o文件readelf -a得到的文件)

hello(可执行程序)

hello.txt(hello程序readelf -a得到的文件)

实验报告

实验PPT


参考文献

为完成本次大作业你翻阅的书籍与网站等

[1] https://blog.youkuaiyun.com/a15929748502/article/details/82623257

[2]
https://baike.baidu.com/item/线性地址/9013682?fr=aladdin

[3] https://www.cnblogs.com/pianist/p/3315801.html

[4] https://blog.youkuaiyun.com/xjy1999123/article/details/85470924

[5] 《CSAPP》 第三版

[6] https://blog.youkuaiyun.com/fndfnd/article/details/85381044

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值