CSAPP 程序人生大作业

计算机系统大作业

程序人生-Hello’s P2P

 

文章目录

摘要

一个简单的hello却拥有着不平凡的一生。本文以hello为例,讲述Linux下从C文件到可执行文件,从程序到进程,到最后程序回收结束的全过程,涉及到预处理、编译、汇编、链接、进程管理、存储管理、I/O管理等,而这一切都依靠计算机系统软硬件的共同支持,在系统各组成部分的协调工作下,一个简单的应用程序,也显示出高效的系统运行机制。

关键词:计算机系统;hello,进程管理,存储管理,汇编,编译;

第一章 概述

1.1 hello简介

首先介绍一下Hello的P2P过程。Program to process,首先产生program,先编写好一个hello.c文件,然后经过预处理生成hello.i文件,然后汇编器将hello.i文件生成hello.s汇编文件,汇编器将hello.s文件转换为可重定位二进制文件hello.o,最后链接器将该文件与头文件中所需的库函数链接形成可执行elf文件。在命令行中输入./hello,Shell解析命令为外部命令,调用fork函数创建一个子进程,由此将program变成一个progress。调用execve函数执行程序、加载进程,sbrk函数开辟内存空间,mmap将其映射到内存中,这就是P2P的过程。
在运行hello程序的过程中,CPU也发挥着作用。CPU为hello程序分配内存和时间片。CPU通过取指、译码、执行、访存、写回、更新PC等执行hello程序。CPU访问hello程序的数据,首先需要将虚拟地址转换为物理地址,通过TLB和页表加速地址的翻译,cache加快数据的传输。IO管理和Shell的进程处理和信号处理机制可以处理hello运行过程中的信号,最终,当进程结束时,shell调用waitpid函数回收子进程 ,brk函数回收内存。这就是O2O的过程。

1.2 环境与工具

硬件:Inter core i5,X64 CPU,8GRAM
软件:Windows10,VMware 15,Ubuntu 18.04
调试工具:gcc,gdb,edb,readelf,objdump,vscode

1.3 中间结果

中间文件 作用
hello.i 预处理产生的文件
hello.s 将预处理文件变为汇编文件
hello.o 汇编器将汇编文件变为可重定位文件
hello ELF格式的可执行文件
hello l.d hello的反汇编文件,查看链接
hello .d hello的反汇编文件,查看汇编代码

1.4 文章小结

第一章对hello进行了简介,介绍了hello的P2P,O2O的过程,大致概括了从程序到进程,从编译执行到终止的过程,介绍了本次大作业的软硬件环境和调试工具,以及写了在hello的整个过程产生的中间文件和其作用。
(第1章0.5分)

第二章 预处理

2.1 预处理的概念与作用

预处理的概念:预处理是C语言的一个重要功能,它由预处理程序负责完成,当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理部分作处理。处理完毕后自动进入对源程序的编译。
预处理的作用:将源文件中以“include”格式包含的文件复制到编译的源文件中,用实际值替换用“#define”定义的字符串。根据“#if”后面的条件决定需要编译的代码。

2.2 在Ubuntu下预处理的命令

2.2在Ubuntu下预处理的命令
在这里插入图片描述

2.3 hello的预处理结果解析

用vscode查看hello.i的信息,发现在hello.i文件中把include格式包含的文件复制到了main函数之前,hello.i的末尾是main函数,之前都是include文件的源代码,总共有三千多行。且可以发现头文件的引用是层层嵌套的。

在这里插入图片描述
在这里插入图片描述
(main函数截图)

2.4 本章小结

这一章首先介绍了预处理的概念和作用,在Ubuntu下输入预处理操作,对hello.c进行了预处理操作,且查看了预处理文件的具体内容。

(第2章0.5分)

第三章 编译

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

编译的概念:利用编译程序从源语言编写的源程序产生目标程序的过程,把预处理文件转换成汇编语言文件,把高级语言变成计算机可以识别的2进制语言,编译分为五个阶段:词法分析、语法分析、语义检查和中间代码生成、代码优化、目标代码生成。
编译的作用:将高级语言程序解释称为计算机所需的详细机器语言指令集。

3.2 在Ubuntu下编译的命令

在这里插入图片描述

3.3 hello的结果解析

3.3.1数据
1、全局变量
hello.c定义了一个全局变量int型sleepsecs,且赋初值为2,将sleepsecs存放在.data段,.data段4字对齐,sleepsecs变为long型,数值为2,经过编译之后存放在.rodata段。
在这里插入图片描述
在这里插入图片描述
2、局部变量
main函数中定义了int型局部变量i,局部变量存储在栈上,从汇编代码中可见,i存储在-4(%rbp)的地址上,初始值为0,每一次循环+1,跳出循环的条件为i>9。
在这里插入图片描述
在这里插入图片描述
3、字符串
在hello.c的main函数中共有两个printf输出的字符串,查看汇编代码发现存储在.rodata段。
LC0存储的是“Usage: Hello 学号 姓名!”,其中汉字和!一个字符使用三个utf-8编码,LC1存储的是从终端输入的两个参数,argv[0],argv[1],

在这里插入图片描述
3.3.2 赋值
hello.c中将全局变量sleepsecs赋初值为2.5,sleepsecs一开始存储在.data段,而赋值完成在.data段,已经将sleepsecs赋值为long型的2,还有对局部变量i的赋值,赋初值为0,由 movl $0, -4(%rbp)完成。

3.3.3 类型转换
hello.c中存在一个隐式类型转换,即int型全局变量sleepsecs的转换,sleepsecs赋初值为2.5,但sleepsecs为int型即向0舍入为2。

3.3.4 函数及其参数
1、main函数
main函数存储在.text段中,程序运行时调用。 main函数的参数为int型的argc和char**的argv, argc为命令行输入的个数,argv[]为输入的字符串,这两个参数都从命令行输入。其中argc存储在%rdi,argv存储在%rsi。然后将argc,argv都存储在栈中,其中,argc存储在-20(%rbp),argv存储在-32(%rbp),在运算时,直接使用栈中存储的数据。
在这里插入图片描述

2、printf函数
hello.c中使用了两个printf函数,其中第一个printf函数打印常量字符串,该字符串存储在.LC0中,当判断条件argc!=3满足时,将LC0的值赋给%rdi,其中%rip是下一条指令的地址,然后调用puts函数输出字符串。第二个printf函数打印的是从命令行写入的字符串,分别为argv[0],argv[1],还有.LC1。编译器使用多次movq指令,将argv[0]存储在%rdx,argv[1]存储在%rsi,然后leaq指令将.LC1存储在%rdi,最后调用printf函数输出字符串。
在这里插入图片描述
在这里插入图片描述

3、getchar函数
getchar函数没有参数,在循环结束之后直接调用。

4、sleep函数
sleep函数的参数是sleepsecs(%rip),movl将参数赋值给%eax,然后再赋值给%edi,最后调用sleep函数。
在这里插入图片描述

5、exit函数
将1赋值给%edi,然后call exit函数,1位exit的唯一参数,表示exit非正常运行退出程序。

3.3.5、数组操作
hello.c中使用了argv数组。数组通过地址访存,由首地址为基准进行地址的计算。从汇编代码中可以看到,编译器通过movq取内容指令和addq指令得到argv[0],argv[1],其中argv的首地址存储在-32(%rbp),然后根据指针8字节大小+8,+16得到地址。
在这里插入图片描述

3.3.6、关系运算and控制转移
hello.c中使用到了比较运算和控制转移,比较运算分别为argc!=3和i<=9,若argc!=3,跳转到.L2执行,若i<=9,跳转到.L4执行。
编译器通过cmp指令进行比较,然后调用je,jle等条件跳转指令(设置CF,ZE,SF,OF条件码,条件码不同,条件不同)跳转到某一地址执行,执行完之后返回下一条指令,这是控制转移,常常和关系运算一起使用。在这里只使用了cmp指令。其实还有test指令,和cmp指令类似。
常在if else,while,for等选择结构、循环结构中使用。

3.4 本章小结

这一章主要讲述了编译器将预处理文件转换为汇编语言文件时,对于不同的数据类型和各种操作,编译器是如何处理的。以hello.c和hello.s为例,包含了变量、常量、函数、关系运算、数组、控制转移等操作,通过对这些操作汇编代码的解读,了解了编译器的编译功能和汇编语言的基本操作。

(第3章2分)

第四章 汇编

4.1 汇编的概念与作用

汇编的概念:汇编器把汇编语言转换为相对应的机器语言,再把生成的机器语言变成可重定位目标文件,即由.s文件生成.o文件,其中,可重定位目标文件包含二进制代码和数据。
汇编的作用:把编译器产生的汇编语言翻译成二进制机器语言,由.s文件生成.o文件。

4.2 在Ubuntu下汇编的命令

在这里插入图片描述

4.3 可重定位目标ELF格式

ELF头
.text
.rodata
.data
.bss
.symtab
.rel.txt
.rel.data
.debug
.line
.strtab
节头部表

.text:已编译程序的机器代码
.rodata:只读数据
.data:已初始化的全局和静态C变量
.bss:未初始化的全局和静态变量
.symtab:一个符号表,存放在程序中定义和引用的函数和全局变量的信息。
.rel.txt:一个.text节中位置的列表
.rel.data:被模块引用或定义的所有全局变量的重定位信息。
.debug:一个调试符号表,包括程序中定义的局部变量和类型定义,程序中定义的全局变量,以及原始的C源文件
.line:原始C源程序中的行号和.text节中及其指令之间的映射
.strtab:一个字符串表,包括.symtab,.debug节中的符号表。

下面为hello.o各节的基本信息,通过readelf -a hello.o指令得到
首先看到的是ELF头的相关信息,包括ELF头的字节大小,文件的类型,节头的数量,字符串表索引节头,入口地址等信息,这些信息帮助链接器解释目标文件的信息。

在这里插入图片描述
接下来可以看到节头部表,节头部表记录了每个节的信息,包括节的名称、地址、类型和偏移量。
在这里插入图片描述
接下来可以看到.text节重定位的信息。重定位节中包括所有需要重定位的符号的信息,包括偏移量、信息、类型、符号值和符号名称+加数。当链接器把这个可重 定位目标文件与其他文件相结合时,需要修改这些符号的位置。
其中,这些符号有两种类型。第一种类型为R_X86_64_PC32,表示重定位PC相对引用。链接器首先计算出引用的运行时地址,refaddr=ADDR(.text)+偏移量,然后更新该引用,使得它在运行时指向符号。这里的加数就是r.append,偏移量就是r.offset。故*refptr=ADDR(运行时地址)+r.append-refaddr,因此PC的值为PC+refptr,之后CPU调用call指令。
第二种类型为R_X86_64_PLT32,为位置无关代码,即无需重定位的代码。
另外,还有一种类型在这里没有显示,R_X86_64_32,表示重定位PC绝对引用。地址计算为
fefptr=ADDR(运行时地址)+r.append
在.rela.text表之后是.rela.eh_frame,保存eh_frame的重定位信息。
在这里插入图片描述
接下来我们可以看到.symtab表,记录了hello.c中调用的函数和全局变量的的名称,类型,地址等信息,value地址信息,在可重定位文件中是起始位置的偏移量。bind表示符号是全局的还是本地的。UND表示为在本文件中定义的符号。
在这里插入图片描述

4.4 Hello.o的结果解析

在这里插入图片描述(.s见第三章)
1、机器语言的构成
机器语言是计算机能够直接识别的二进制代码,在hello.o的反汇编文件中,使用16进制表示,由多个字节表示,每个字节由两个十六进制数表示。
2、机器语言与汇编语言的映射
机器语言对应的反汇编的每一条语句由多个字节表示,每一组字节都是一条汇编指令,不过由.o文件生成的反汇编文件与hello.s的汇编语言存在差别。
(1)、操作数的差别
hello.s中立即数表示使用十进制,而生成的反汇编立即数使用16进制表示。
(2)、函数调用
hello.s中函数调用直接call+函数名即可,call printf等,而在反汇编中则不同,链接器为函数调用找到匹配的函数的可执行代码的位置,故在call指令调用函数写的是地址,地址是符号重定位之后的地址,即运行的地址,同时也要记下PC下一条指令的地址。如图中的getchar、sleep函数,会加载可重定位信息以计算重定位后的地址。
(3)、分支转移
hello.s中的跳转指令是像jmp .L1一样的格式,.L1是代码段。而反汇编中的跳转指令是直接跳转到某个地址,或是直接的地址,或是函数相对偏移地址。
(4)、全局变量
hello.s中调用全局变量,如将全局变量值直接赋给寄存器,全局变量的值即为.LC0。而在反汇编代码中,将可重定位文件重定位之后计算出全局变量的地址,然后将该全局变量地址的值赋值给寄存器。如lea 0x0(%rip) %rdi。

4.5 本章小结

这一章将.s文件转换成.o可重定位文件。分析了ELF格式下可重定位文件的组成和各个节存储的内容,使用readelf看具体查看。用objdump工具对可重定位文件进行了反汇编,并将反汇编代码与汇编代码进行比较,比较两者之间的不同之处,得出机器语言和汇编代码之间的映射关系。

(第4章1分)

第5章 链接

5.1 链接的概念与作用

链接的概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存中并执行,也就是从.o文件到可执行文件的过程。链接是由链接器的程序自动执行的。链接包含符号解析和重定位两步。链接器将每个符号引用与符号的定义相关联,将符号在可重定位文件的位置重定位至可执行文件的位置。
链接的作用:可以将一个大型的应用程序分解成更小、更好管理的模块,可以独立地修改和编译这些模块,当我们改变这些模块中的一个时,只需要简单地重新编译,并重新链接应用,而不必重新编译其他文件。
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
在这里插入图片描述

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

在这里插入图片描述
在这里插入图片描述
通过readelf -a hello得到了hello的ELF 格式,hello共由24个段组成,从.interp到.shstrtab。在地址这一栏中我们可以看到各段的起始地址,在大小一栏中即可得到各段的大小。

5.4 hello的虚拟地址空间

打开edb,加载hello,打开symbolviewer查看虚拟地址各段信息。
在这里插入图片描述
在这里插入图片描述
从这里我们看出各段的起始地址,和5.3中使用readelf指令看到的是一样的,如.interp都是0x0400200,另外,这里按照地址从小到大的顺序写出了在各段之中含有的其他信息。如.interp之后是.interp+0x1C

5.5 链接的重定位过程分析

在这里插入图片描述

观察hello和hello.o,可以发现在汇编代码上没有发生实质性的变化,主要是地址发生了改变,链接之前,.o文件中main函数的反汇编代码从地址0开始往下,可以认为是相对偏移地址,而在链接之后,在main函数之前还链接上了其他的库文件,因此hello的main函数是从地址0x400532开始的,这时,在main函数中每一条指令的地址,每一个函数的地址都可认为是绝对地址,是CPU可以直接访问的地址。在hello main函数中的绝对地址是通过可重定位文件中地址的偏移量加上起始地址得到的。
另外,hello和hello.o在ELF格式下的段的个数种类发生了变化,hello增加了如.interp, .hash等段。
接下来以hello.o为例,说明链接的过程。链接主要包括解析符号和重定位两步。在重定位之前,汇编器在hello.o文件的重定位段记录了需要重定位的符号和相应的类型和偏移量。链接器通过对符号的解析(包括局部符号和全局符号),将每个符号的引用和符号的定义相关联。这之后还需要将命令行输入的静态库链接,然后就开始重定位,在重定位过程中,将合并输入模块,并为每个符号分配运行时的地址。
首先需要对符号和节进行重定位。链接器将所有相同类型的节合并为同一类型的新的聚合节,然后链接器将运行时内存地址赋给新的聚合节,赋给定义的每个节和符号,此时程序中的每条指令和全局变量都有唯一的运行时内存地址。
然后重定位节中的符号引用,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
运行时地址的计算和重定位引用的计算在第四章中做了介绍。

5.6 hello的执行流程

从加载hello到_start
程序名
ld -2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!start
到call main
程序名
libc-2.27.so!_libc_start_main
libc-2.27.so!_al_fixup
libc-2.27.so!_libc_csu_init
libc-2.27.so!_setjmp
hello!main

到结束
程序名
hello!puts@plt
hello!exit@plt
ld-2.27.so!_dl_fixup
ld-2.27.so!exit

5.7 Hello的动态链接分析

延迟绑定(将过程地址的绑定推迟到第一次调用该过程时)通过两个数据结构之间简洁但又有些复杂的交互来实现,这两个数据结构是:GOT和过程链接表(PLT)。GOT是数据段的一部分,而PLT是代码段的一部分。
编译器在数据段开始的地方创建全局偏移量表(GOT),在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。
每个被可执行程序调用的库函数都有它自己的PTL条目,每个条目负责调用一个具体的函数。
接下来通过edb调试,观察在dl_init前后,动态链接项目的变化。
.got.plt的起始地址为0x601000,在datadump中找到该位置。
在dl_init之前:
在这里插入图片描述
edb单步调试至dl_init时,可以发现.got.plt发生了改变
在这里插入图片描述
可以看到dl.init之后出现了两个地址,为0x7f9ea6a80170和0x7f9e4686e750而这两个就是GOT[1]和GOT[2],GOT[1]包含动态链接器在解析函数地址时会使用的信息,GOT[2]是动态链接器在ld-linux.so模块的入口点。查看该地址的内容发现是动态链接函数。
在这里插入图片描述

5.8 本章小结

这一章介绍了链接的过程和重定位的过程,以hello.o链接静态库形成hello可执行文件为例,对hello可执行文件的格式进行分析,并使用edb调试工具和objdump工具分析hello的虚拟地址空间,hello的可重定位的过程(包括地址的计算方式,hello.o和hello的比较等),还有对动态链接的分析。

(第5章1分)

第6章 hello进程管理

6.1 进程的概念与作用

进程的概念:进程是一个执行中程序的实例,系统的每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的。包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程的作用:提供给应用程序一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;提供给应用程序一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。

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

Shell-bash是一个交互型的应用级程序,它代表用户运行其他程序。Shell执行一系列的读/求值步骤,然后终止。度步骤读取来自用户的一个命令行,求值步骤解析命令行,并代表用户运行程序。
对命令行求值的函数解析以空格分隔的命令行参数,并构造最终会传递给execve的argv向量。第一个参数被假设为要么是一个内置的shell命令名,马上会解释这个命令,要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。
在解析命令行之后,调用函数检查第一个命令行参数是否是一个内置的shell命令。如果是,就立即解释。否则,shell创建一个子进程在子进程中执行所请求的程序,如果用户要求在后台运行该程序,那么shell返回到循环的顶部,等待下一个命令行。Shell使用waitpid函数等待作业终止,当作业终止时,shell就开始下一轮迭代。

6.3 Hello的fork进程创建过程

在命令行输入./hello 学号 姓名,shell检查该命令是否为内置命令,显然这不是内置命令。于是,shell调用fork函数创建一个新的子进程,子进程得到与父进程用户级虚拟地址空间相同的一份副本,这意味着父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大区别在于有不同的PID。
之后,更改进程组编号,准备hello的execve。

6.4 Hello的execve过程

在shell新创建的子进程中,execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp,只有出现错误是,execve才会返回到调用程序。
在execve加载hello之后,调用启动代码来执行hello,新的代码和数据段初始化为可执行文件的内容,跳转到_start调用libc_start_main设置栈,并将控制传递给新程序的主函数,

6.5 Hello的进程执行

在这里插入图片描述
在执行hello程序之后,hello进程一开始运行在用户模式,进程从用户模式变为内核模式的唯一方法是通过中断、故障等异常的调用,当进程处于内核模式时,可以访问任何内存位置,调用任何指令。当处理程序返回到应用程序代码时,从内核模式改为用户模式。
内核为每一个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
当内核选择一个新的进程运行时,即内核调度这个进程,内核使用上下文切换的机制来控制转移到新的进程:保存当前进程的上下文,恢复某个先前被强占的进程被保存的上下文,将控制传递给这个新恢复的进程。
在hello程序运行时,会有其他进程并发地运行,这些进程时间与hello重叠,为并发流,这些进程轮流运行,一个进程执行它的控制流的一部分的每一时间段叫做时间片。
接下来根据上述知识分析一下hello的进程调度。hello一开始运行在用户模式,内核保存一个上下文,继续运行调用printf函数,系统调用使得进程从用户模式变成内核模式,在printf函数执行完之后又返回到用户模式,继续运行调用sleep函数,此时会有些不同,由于该进程进行休眠,内核进行上下文切换,调用其他进程运行,同时计数器记录休眠的时间,等到休眠的时间到时,系统发生中断,再次进行上下文切换,转换到hello进程原先运行的位置。继续运行,遇到循环之后,hello进程会多次进行用户模式和内核模式的转变。之后调用getchar函数,进入内核模式,需要完成从键盘缓冲区到内存的数据传输,故上下文切换,运行其它进程,当数据传输结束,发生中断,再次上下文切换,回到hello进程,此时hello进程就运行结束了,return,hello进程运行终止。

6.6 hello的异常与信号处理

6.6.1、hello的异常
(1)、中断
中断时异步发生的,是来自处理器外部的I/O设备的信号的结果,中断处理程序运行之后,返回到下一条指令。
(2)、陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果,就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令,在用户程序和内核之间提供一个像过程一样的接口,即系统调用。如读一个文件、创建一个进程、加载一个新的程序等。
(3)、故障
故障是由错误情况引起的,当故障发生时,是利器将控制转移给故障处理程序,如果错误情况可以修正,则将控制返回到引起故障指令,重新执行,否则处理程序返回到内核abort,终止故障的应用程序。
(4)、终止
终止是不可恢复的致命错误造成的结果,会终止应用程序。

6.6.2、hello的信号
SIGINT中断信号
在这里插入图片描述
当用户输入ctrl-c时产生中断信号,导致内核发送一个SIGINT信号到前台工作组中的每个进程,默认终止前台作业,在这里,hello被终止。

SIGTSTP信号
在这里插入图片描述
输入ctrl-z会发送一个SIGTSTP信号到前台进程组中的每个进程,默认情况下,停止(挂起)前台作业。

在程序执行过程中,可以在命令行中乱按,包括回车,对于程序运行来说没有影响。
通过ps命令我们可以看到hello进程的pid,ctrl-z后hello进程被挂起
在这里插入图片描述
jobs命令看到hello进程的状态
在这里插入图片描述
通过pstree命令在进程树中找到bash的hello进程
在这里插入图片描述
fg命令可以让hello进程重新运行
在这里插入图片描述
使用kill -9 PID杀死hello进程,在这里,hello的PID为11153
在这里插入图片描述

6.7本章小结

这一章介绍了hello可执行文件在进程中的执行过程。介绍了shell-bash的工作流程,shell利用fork和execve运行hello程序的过程,用户模式和内核模式,上下文的切换。最后,通过在命令行的各种命令的演示,讲述了hello进程的异常处理和信号机制。
(第6章1分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:由程序产生的段偏移地址。由段标识和段偏移量组成。以段标识为下标到GDT/LDT查表获得段地址。段地址+端偏移量=线性地址

线性地址:一个非负整数地址的有序集合,如果此时地址是连续的,则称这个空间为线性地址空间。

虚拟地址:在保护模式下,程序运行在虚拟内存中。虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。虚拟地址由VPO(虚拟页面偏移量)、VPN(虚拟页号)、TLBI(TLB索引)、TLBT(TLB标记)组成。

物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,每字节都有一个唯一的物理地址,比如第一个字节地址为0,第二个地址为1,以此类推。物理地址空间对应于系统中物理内存的M个字节:{0,1,2……M-1}。

在hello中,main函数的地址为0x400532,这是逻辑地址中的段偏移量,加上段地址就是main函数的虚拟地址,虚拟地址与物理地址之间存在一种映射关系,MMU利用页表实现这种映射,可得到实际的物理地址。

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

实模式:逻辑地址CS:EA=EA+16*CS
保护模式:逻辑地址由段标识和段偏移量组成。以段标识为下标,去索引段描述符表,若T1=0,索引全局段描述符表(GDT),若T1=1,索引局部段描述符表(LDT)。将段描述符表中的段地址(base字段)加上段偏移量,即为线性地址。
在这里插入图片描述

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

在这里插入图片描述
hello的线性地址到物理地址的变换,也就是从虚拟地址寻址物理地址,在虚拟地址和物理地址之间存在一种映射,MMU通过页表实现这种映射。
虚拟地址由虚拟页号(VPN)和虚拟页偏移量(VPO)组成,页表中由有效位和物理页号组成,VPN作为到页表的索引,去页表中寻找相应的PTE,其中PTE有三种情况,分别为已分配,未缓存,未分配。已分配表示已经将虚拟地址对应到物理地址,有效位为1,物理页号不为空。未缓存表示还未将虚拟内容缓存到物理页表中,有效位为0,物理页号不为空。未分配表示未建立映射关系,有效位为0,物理页号为空。
如果有效位为0,表示缺页,进行缺页处理,从磁盘读取物理页到内存,若有效位为1,则可以查询到相对应的PPN,物理页偏移量和VPO相同,PPN和PPO组成物理地址。

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

为减少内存读取数据的次数,在MMU中包括了一个关于PTE的小的缓存,即TLB,每一行都保存着一个由单个PTE组成的块。TLB索引由VPN的t个最低位组成,剩余的为为TLB标记。
CPU产生一个虚拟地址,当TLB命中时,MMU从TLB中取出相应的PTE,MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存中,高速缓存/主存将所请求的数据字返回给CPU。若TLB不命中,MMU必须从页表中的PTE取出PPN复制到PTE,而在得到PTE还会发生缺页或者是缓存不命中的情况。
在这里插入图片描述
MMU使用四级页表,将36位VPN划分为4个9位的片L1,L2,L3,L4,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址,VPN1提供到一个L1 PET的偏移量,这个PTE包含L2页表的基地址,VPN2提供到一个L2 PTE的偏移量以此类推。使用四级页表可以节省内存,提高地址翻译的速度。

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

知道虚拟地址对应的物理地址之后,需要对物理地址进行访问。CPU访问物理地址是访问三级cache L1、L2、L3。MMU将物理地址发送给L1缓存,从物理地址中得出CT(缓存标记)、CI(缓存组索引)、CO(缓存偏移)。根据缓存组索引找到L1缓存中对应的组,若缓存标记为1,根据缓存偏移直接从缓存中读取数据并返回。如果缓存标记为0,即缓存不命中,需要从L2、L3中去读取,如果在三级缓存中都不存在,需要到主存中读取。

7.6 hello进程fork时的内存映射

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

7.7 hello进程execve时的内存映射

execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello有效替代当前程序。加载并运行hello需要以下几个步骤:
删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
映射私有区域。为hello程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data段,bss是请求二进制零的,映射到匿名文件,大小包含在hello中,栈和堆也是请求二进制零的,初始长度为0。
映射共享区域。如果hello程序与共享对象链接,如libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器。execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时将从这个入口点开始执行。
在这里插入图片描述

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

缺页是指DRAM缓存不命中。如下图,CPU引用VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断VP3未被缓存,出发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在这里假设是VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。
在这里插入图片描述
接下来,内核从磁盘复制VP3到内存中的PP 3,更新PTE 3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重新发送到地址翻译硬件,现在VP3已经在缓存中了,那么页命中也可以正常处理了。
在这里插入图片描述

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆,分配器将堆视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存分配器分为显式分配器和隐式分配器两种。
7.9.1、带标签的隐式空闲链表
将堆组织为一个连续的已分配块和空闲块的序列的结构是隐式空闲链表,空闲块通过头部的大小字段隐含地链接。而带边界标签的隐式空闲链表则在每个块的结尾处添加一个脚部——头部的副本,脚部总是在距当前块开始位置一个字的距离。分配器可以通过检查它的脚部,判断前面一个块的起始位置和状态。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以防止所请求块的空闲块。这种搜索方式由放置策略确定,包括首次适配、下一次适配和最佳适配。
一旦分配器找到匹配的空闲块之后,作出另一个决定——分配这个空闲块多少空间。通常选择将空闲块分割成两个部分,剩下的一部分变成新的空闲块。
当分配器释放一个已分配块之时,可能有其他空闲块与新释放的空闲块相邻,可能会导致假碎片问题,因此需要合并相邻的空闲块。而带有边界标签的隐式空闲链表分配器就可以在常数时间内完成对前面块的合并。简单来说,就是双向合并。
带标签的隐式空闲链表
带标签的隐式空闲链表
显式空闲链表

7.9.2、显式空闲链表
将空闲块组织成某种形式的显式数据结构,实现这个数据结构的指针可以存放在这些空闲块的主体里。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继指针。
使用双向链表可以使首次适配的分配时间减少到空闲块数量的线性时间,释放块的时间取决于选择的空闲链表中块的排序策略。
一种方法是后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处,分配器会最先检查最近使用过的块。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索定位合适的前驱。

7.10本章小结

这一章介绍了hello的存储器地址空间的概念和相关的地址计算方法,缺页和缺页处理,重点介绍了虚拟地址转换成物理地址的过程,包括四级页表、TLB加速、三级cache等。除此以外,介绍了内存映射,以及fork创建进程和execve函数运行hello时的具体过程。最后讲述了动态内存分配管理的不同结构的链表的操作。
(第7章 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

一个Linux文件就是一个m个字节的序列:B0,B1……Bk……Bm-1,所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有输入和输出都能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口

8.2 简述Unix IO接口及其函数

打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个叫做描述符的小的非负整数,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息,应用程序只需记住这个描述符。
对应的函数为int open(char *filename, int flags, mode_t mode);filename为文件名,flags参数指明了进程打算如何访问这个文件,可以是只读、只写、可读可写。mode参数指定了新文件的访问权限位。若open成功则返回新文件描述符,若失败则返回-1.
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。
改变当前文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
读写文件:一个读操作就是从文件复制n个字节到内存,从当前文件位置k开始,然后k增加到k+n,给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个EOF的条件,应用程序能检测到这个条件,在文件结尾处并没有明确地EOF符号。
类似的,写操作就是从内存复制n个文件到一个文件,从当前文件位置k开始,更新k。
读文件对应的函数为ssize_t read(int fd, void *buf, size_t n);fd为当前文件的描述符,buf是内存位置,n是复制最多n个字节。若成功则为读的字节数,若EOF则为0,若出错-1。
写文件对应的函数是sisze_t write(int fd, const void *buf, size_t n);若成功则为写的字节数,若出错则为-1。
关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核丢回关闭所有打开的文件并释放他们的内存资源。
关闭文件的函数为int close(int fd);若成功为0,若出错为-1.关闭一个已经关闭的描述符会出错。
I/O重定向:Linux shell提供了I/O重定向操作符,允许用户将磁盘文件和标准输入输出联系起来。
函数为int dup2(int oldfd, int newfd);dup2函数复制描述符表象项oldfd到描述符表项newfd,覆盖newfd之前的内容,如果newfd已经打开了,dup2会在复制oldfd之前关闭newfd。

8.3 printf的实现分析

首先观察一下Linux下printf的函数体

static int printf(const char *fmt, ...)
{
     va_list args;
     int i;
   
     va_start(args, fmt);
     write(1,printbuf,i=vsprintf(printbuf, fmt, args));
     va_end(args);
     return i;
}

定义va_list型变量args,指向参数的指针。va_start和va_end是获取可变长度参数的函数,首先调用va_start函数初始化args指针,通过对va_arg返回可变的参数,然后va_end结束可变参数的获取。
重点需要看write函数和vsprintf函数。
vsprintf函数的作用是以fmt为格式字符串,根据args中的参数,向printfbuf输出格式化后的字符串。然后调用write函数,write函数是Unix I/O函数,用以在屏幕上输出长度为i的在printfbuf处的内容。查看write函数的汇编代码可以看出它将栈中参数存入寄存器,然后执行 INT_VECTOR_SYS_CALL,代表通过系统调用syscall,syscall将寄存器中存储的字符串通过总线复制到显卡的现存中,字符显示驱动子程序通过ASCII码在字模库中找到点阵信息并将其存储到vram中。接下来显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。此时在屏幕上显示一个已经格式化的字符串。

8.4 getchar的实现分析

当程序调用getchar函数,程序等待用户的按键。用户按键时,键盘接口会得到一个键盘扫描码,同时产生一个中断请求,进行上下文切换,运行键盘中断子程序,该程序将按键扫描码转换为ASCII码,保存到键盘缓存区。直到用户按回车为止,然后将用户输入的字符显示到屏幕。

getchar的代码:

int getchar(void)
{
    static char buf[BUFSIZ];
    static char* bb=buf;
    static int n=0;
    if(n==0)
    {
        n=read(0,buf,BUFSIZ);
        bb=buf;`
    }
    return (--n>=0)?(unsigned char)*bb++:EOF;
}

getchar函数通过调用read函数来读取字符,read函数的返回值是读入字符的个数,若出错则返回-1.read函数通过调用内核中的系统函数,读取键盘缓冲区的ASCII码,直到读到回车为止,然后将整个字符串返回。

8.5本章小结

这一章介绍了Linux的I/O管理方法、I/O接口及其函数,以及通过阅读printf函数和getchar函数的代码了解如何通过Unix I/O实现功能。总的说,Unix I/O使得所有的输入和输出都能以一种统一且一致的方式来执行。
(第8章1分)

结论

hello from program to process, from zero to zero, this is hello’s life:
1、 用户从键盘输入,得到hello.c的C源文件。
2、 预处理器对hello.c进行预处理,得到hello.i,编译器将hello.i翻译成汇编文件hello.s,汇编器将hello.s翻译成机器指令得到hello.o可重定位文件。
3、 链接器对hello.o进行链接,得到可执行文件hello,此时,hello已经可以被系统加载和运行。
4、 在终端中输入命令,shell-bash调用fork函数创建一个新的子进程,并在新的子进程中调用execve函数,加载并运行hello。
5、 hello会与多个进程并行运行,当发生中断或异常时,发生上下文切换,转到内核模式,内核调度另一个进程运行。
6、 hello在运行过程中可能会遇到各种信号和键盘输入,shell为其提供信号处理程序。
7、 CPU为hello分配内存空间,hello从磁盘加载到内存。
8、 hello访存时,请求一个虚拟地址,通过MMU、TLB、四级页表得到虚拟地址对应的物理地址,在三级cache中进行访存。
9、 hello输出信息调用printf函数和getchar函数,这两个函数需要调用Unix I/O接口函数实现。
10、hello执行return 0,结束进程,bash会回收hello子进程。

我的感悟:
一个简单的hello.c要真正变成可执行文件被系统加载和运行,需要有软件和硬件的共同支持,也需要经过大量的流程。hello.c变成hello就需要预处理器、编译器、汇编器、链接器等。而让hello真正运行更是需要shell的函数调用、信号处理、并发处理、访存机制等,在hello运行过程中,进程管理、存储管理更是起到了重要的作用。可以说,一个程序的运行依靠系统各个组成部分的协调运行,缺一不可。
hello可以说囊括了这一个学期学习CSAPP的大部分内容,用一个大作业的形式将这些内容很好的串联在一起。
(结论0分,缺失 -1分,根据内容酌情加分)

附件

列出所有的中间产物的文件名,并予以说明起作用。
中间文件 作用
hello.i 预处理产生的文件
hello.s 将预处理文件变为汇编文件
hello.o 汇编器将汇编文件变为可重定位文件
hello ELF格式的可执行文件
hello l.d hello的反汇编文件,查看链接
hello .d hello的反汇编文件,查看汇编代码

(附件0分,缺失 -1分)

参考文献

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

[1]深入理解计算机系统
[2] printf函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html
[3] Linux 逻辑地址、线性地址、虚拟地址、物理地址
https://blog.csdn.net/baidu_35679960/article/details/80463445

(参考文献0分,缺失 -1分)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值