计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机
学 号 1190201909
班 级 1903008
学 生 辛帅
指 导 教 师 吴锐
计算机科学与技术学院
2021年5月
本文主要介绍了hello.c从编写到执行到介绍的全过程。其中包括预处理、编译、汇编、链接、运行的主要过程,以及hello程序的进程管理、存储管理与I/O管理。在Ubuntu下进行相关操作,合理运用了Ubuntu下的操作工具,进行细致的历程分析,来加深对计算机系统的了解。
关键词:P2P; 020;计算机系统;进程
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
P2P:From Program to Process
用高级语言编写得到.c文件,再经过编译器预处理得到.i文件,进而对其编译得到.s汇编语言文件。此后通过汇编器将.s文件翻译成机器语言,将指令打包成为可重定位的.o目标文件,再通过链接器与库函数链接得到可执行文件hello,执行此文件hello,操作系统会为其fork产生子进程,再调用execve函数加载进程。至此,P2P结束。
020:From Zero-0 to Zero-0
操作系统调用execve后映射虚拟内存,先删除当前虚拟地址的数据结构并为hello创建新的区域结构,进入程序入口后载入物理内存,再进入main函数执行代码。代码完成后,父进程回收hello进程,内核删除相关数据结构。
1.2 环境与工具
硬件环境:X64 CPU;Intel Core i5 8300HQ; 2.30GHz; 8G RAM;
软件环境:Windows10,Ubuntu 18.04.1LTS
开发与调试工具:gcc,edb,hexedit,objdump,Code:Blocks
1.3 中间结果
hello.c:源代码
hello.i:预处理后的文本文件
hello.s:编译之后的汇编文件
hello.o:汇编之后的可重定位目标执行文件
hello:链接之后的可执行文件
hello.elf:hello.o的ELF格式
hello1.elf:hello的ELF格式
hello.txt:hello.o反汇编代码
hello1.txt:hello的反汇编代码
1.4 本章小结
本章介绍了 hello 从编译生成、到执行、再到终止的全过程,从整体上大致介
绍了 hello 的一生,并且列出了做本次作业的软硬件环境以及工具,最后列出了本次作业从 hello.c 到 hello 的过程中产生的中间文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:
预处理是在编译之前,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第6行的#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。
作用:
将源文件中用#include形式声明的文件复制到新的程序中。
删除所有注释。比如:由“//”后的内容。
删除“#define”,用实际值替换#define定义的字符串。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
打开该文件之后可以发现, 文件hello.i的长度变为3000多行,而在main函数之前,预处理器读取头文件stdio.h 、stdlib.h 、和unistd.h中的内容,三个系统头文件依次展开。经过预处理之后,hello.c文件转化生成一个只有常量如数字、字符或变量的定义的输出文件。
2.4 本章小结
本章主要介绍了预处理的定义与作用、并结合预处理之后的程序对预处理结果进行了解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:
编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。编译器将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。 这个过程称为编译,同时也是编译的作用。
作用:
编译作用主要是将文本文件hello.i翻译成文本文件hello.s,并在出现语法错误时给出提示信息。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1 数据
1.int argc:作为第一个参数传入
2.int i: 局部变量, 通常保存在寄存器或栈中. i的数据类型占用4字节的栈空间
3.常量: 在文件中有一些如4、8的常量以立即数形式出现
3.3.2 赋值
主要使用mov指令来实现,而根据数据的类型又有不同的后缀。
对局部变量i的赋值: 编译器将C语言中的i = 0编译为
3.3.3 类型转换(隐式或显式)
对函数atoi来说, 它的功能时将字符串转换成整型数的一个函数, 编译器将C语言中atoi(argv[3])操作编译为
3.3.4 算术操作
1.i++,对计数器i自增,使用程序指令addl,后缀l代表操作数是一个4B大小的数据。
2.汇编中使用leaq .LC1(%rip),%rdi,使用了加载有效地址指令leaq计算LC1 的段地址%rip+.LC1并传递给%rdi。
3.3.5 关系操作
1.在最开始的时候,判断argc的大小,因为argc是main函数的第一个参数,所以,他应该存放在%edi中。判断argc是否为4,从而来决定程序接下来应该如何执行(其中4是立即数,-20(%rbp)是argc)——若相等,就跳转到.L2。
2. 在for循环中,每次都会比较i的大小来判断循环是否结束,比较i和7的大小,如果小于等于就跳转,所以循环的条件是i<=7
3.3.6 数组/指针/结构操作
指针数组: char argv[], 在该数组中,arvg[0]指向输入程序的路径和名称,argv[1]和argv[2]分别表示两个字符串, char数据类型占据8字节, 由下图知得到argv[1]和argv[2]两个字符串
3.3.7 控制转移
1.对于if判断,编译器使用跳转指令实现。首先cmpl比较argv和4,设置条件码,使用je判断ZF标志位,如果为0,说明argv-4=0,则不执行if中的代码直接跳转到.L2,否则顺序执行下一条语句,即执行if中的代码。
2.for(i=0;i<8;i++) :使用计数变量 i 循环 8 次。
编译器的编译逻辑是,首先无条件跳转到位于循环体.L4之后的比较代码,使用cmpl进行比较,如果i<=7,则跳入.L4 for循环体执行,否则说明循环结束,顺序执行for之后的逻辑。
3.3.8 函数操作
1.main函数
参数传递: 传入参数argc与*argv[], 存储在寄存器%edi和%rsi中
函数调用: 被系统启动函数调用
函数返回: 设置%eax为0并返回, 将C语言中的return 0编译为
2. printf函数
参数传递:
call puts时传入字符串参数首地址
for循环中call printf时传入argv[1]和argv[2]的地址
3.exit函数
参数传递: 传入的参数为常量1, 执行退出命令
函数调用: if条件判断满足后被调用
4.atoi函数
函数传递: 传入的参数为argv[3]的地址
函数调用: 满足for循环后, 在调用sleep函数时, 将argv[3]的字符串转换成整型数
5.sleep函数
参数传递: 传入被转换后的argv[3], 即atoi(argv[3])
函数调用: 满足佛如循环后被调用
6.getchar函数
函数调用: 在main函数中被调用
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.4 本章小结
本章主要阐述了编译器是如何处理 C 语言的各个数据类型以及各类操作的,基本都是先给出原理然后结合 hello.c C 程序到 hello.s 汇编代码之间的映射关系作出合理解释。编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:
汇编器将hello.s文件翻译成二进制机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存到目标文件hello.o中。
作用:
汇编过程将汇编代码转换为计算机能够理解并执行的二进制机器代码,这个二进制机器代码是程序在本机器上的机器语言的表示。
4.2 在Ubuntu下汇编的命令
gcc -c -o hello.o hello.s
4.3 可重定位目标elf格式
readelf -a hello.o > hello.elf
1.ELF头:描述了生成该文件的系统的字的大小和字节顺序,并且包含帮助链接器语法分析和解释目标文件的信息。
- 节头:记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
4.符号表:目标文件的符号表中包含用来定位、重定位程序中符号定义和引用的信息。
机器语言指的是二进制的机器指令集合,而机器指令是由操作码和操作数构成的。汇编语言的主体是汇编指令。汇编指令和机器指令的差别在于指令的表示方法上,汇编指令是机器指令便于记忆的书写格式。
在对比两个文件后发现有以下区别:
操作数: 在hello.s中的操作数是二进制, 而在hello.o的反汇编代码中的操作舒适十六进制。
分支转移: 跳转语句后, 在hello.s中是.L2和.L3等段名称, 而在反汇编代码中跳转指令之后是相对偏移的地址。
函数调用: hello.s中, call指令之后紧跟着的就是函数名称, 而反汇编代码中call指令之后的是函数的相对偏移地址, 即call的目标地址是当前下一条指令. 原因是hello.c中调用的函数是共享库中的函数, 最终需要通过动态链接器才能确定函数的运行执行的地址, 因此在.rela.text节中添加了重定位条目。
全局变量访问: 在hello.s文件中, 对于.rodata等全局变量的访问, 使用段名称+%rip, 在反汇编中0+%rip, 原因是.rodata中的数据地址在运行时才能确定, 所以访问也需要重定位. 所以在汇编成机器语言时, 需要将操作数全部置0, 并添加重定位条目。
4.5 本章小结
本章介绍了 hello 从 hello.s 到 hello.o 的汇编过程,通过查看 hello.o 的 elf 格式和使用 objdump 得到反汇编代码与 hello.s 进行比较的方式,了解到从汇编语言映射到机器语言汇编器需要实现的转换。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
作用:
把可重定位目标文件和命令行参数作为输入,产生一个完全链接的,可以加载运行的可执行目标文件。使得分离编译成为可能。
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的格式
1.ELF头:描述了生成该文件的系统的字的大小和字节顺序,并且包含帮助链接器语法分析和解释目标文件的信息。
2.节头:记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
- 程序头部表:ELF文件头结构就像是一个总览图,描述了整个文件的布局情况。因此在ELF文件头结构允许的数值范围内,整个文件的大小是可以动态增减的。告诉系统如何创建进程映像。
- 段节
- Dynamic section:如果目标文件参与动态链接,则其程序头表将包含一个类型为 PT_DYNAMIC 的元素。特殊符号 _DYNAMIC 用于标记包含以下结构的数组的节。
- 重定位节:包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
- 符号表:目标文件的符号表中包含用来定位、重定位程序中符号定义和引用的信息。
.dynsym用来保存与动态链接相关的导入导出符号,不包括模块内部的符号。
.symtab 段只保存函数名和变量名等基本的符号的地址和长度等信息。
5.4 hello的虚拟地址空间
通过查看edb,看出hello的虚拟地址空间开始于0x400000,结束与0x400ff0
根据节头部表,可以通过edb找到各个节的信息
5.5 链接的重定位过程分析
objdump -d -r hello > hello1.txt
与hello.o生成的反汇编文件对比发现,hello1.txt中多了许多节。hello0.txt中只有一个.text节,而且只有一个main函数,函数地址也是默认的0x000000.hello1.txt中有.init,.plt,.text三个节,而且每个节中有许多的函数。库函数的代码都已经链接到了程序中,程序各个节变的更加完整
主要有以下的不同:
1.链接增加新的函数:在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数。
2.增加的节:hello中增加了.init和.plt节,和一些节中定义的函数。
3.函数调用:hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
4.地址访问:hello.o中的相对偏移地址变成了hello中的虚拟内存地址。而hello.o文件中对于.rodata和sleepsecs等全局变量的访问,是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。
链接的过程为:
链接就是链接器ld将各个目标文件组装在一起,就是把.o文件中的各个函数段按照一定规则累积在一起,
hello重定位过程:
重定位过程合并输入模块,并为每个符号分配运行时地址,主要有以下两步:
1.重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。包括hello.o在内的所有可重定位目标文件中的.data节被全部合并成一个节,这个节成为输出的可执行目标文件hello中的.data节。然后,连接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。
2.重定位节中的符号引用。链接器依赖于hello.o中的重定位条目,修改代码节和数据节中对每个符号的引用,使得它们指向正确运行时的地址。
5.6 hello的执行流程
hello调用与跳转的各个子程序名或程序地址:
0x400430 init;
0x400460 puts@plt;
0x400470 printf@plt;
0x400480 __libc_start_main@plt;
0x400490 getchar@plt;
0x4004a0 exit@plt;
0x4004b0 sleep@plt;
0x4004d0 _start;
0x4004fe main;
0x400580 __libc_csu_init;
0x4005f0 __libc_csu_fini;
0x4005f4 _fini;
5.7 Hello的动态链接分析
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT 中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。
在dl_init调用之后
在dl_init调用之后的函数调用时,跳转到PLT执行.plt中逻辑,下一条指令压栈,第一次访问跳转时GOT地址为函数序号,然后跳转到PLT[0]。在PLT[0]中将重定位表地址压栈,然后访问动态链接器。
在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。根据jmp的原理,之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数。
5.8 本章小结
在本章中主要介绍了链接的概念与作用、hello的ELF格式,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情 况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数 据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动 过程调用的指令和本地变量。
作用:
1.每次运行程序时,shell创建一新进程,在这个进程的上下文切换中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。
2.进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统。
6.2 简述壳Shell-bash的作用与处理流程
shell的作用:
实际上Shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的Shell程序与其他应用程序具有同样的效果。
处理流程:
1.读入命令行、注册相应的信号处理程序、初始化进程组。
2. 通过paraseline函数解释命令行,如果是内置命令则直接执行,否则阻塞信号后创建相应子进程,在子进程中解除阻塞,将子进程单独设置为一个进程组,在新的进程组中执行子进程。父进程中增加作业后解除阻塞。如果是前台作业则等待其变为非前台程序,如果是后台程序则打印作业信息。
6.3 Hello的fork进程创建过程
根据shell的处理流程,输入命令执行当前目录下的可执行文件hello,父进程会通过fork函数创建一个新的运行的子进程hello。hello进程几乎但不完全与父进程相同,hello进程得到与父进程用户级虚拟空间相同的一份副本,包括代码和数据段、堆、共享库、以及用户栈。hello进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,hello进程可以读写父进程中打开的任何文件。父进程和hello进程最大的区别在于它们有不同的PID。ork函数只被调用一次,却会返回两次。在父进程中,fork返回Hello进程的PID,在Hello进程中,fork返回0 。
6.4 Hello的execve过程
1.为子进程调用函数fork之后,子进程调用execve函数在当前进程的上下文中加载并运行一个新程序hello。
2.为执行hello程序加载器、删除子进程现有的虚拟内存段,execve 调用驻留在内存中的、被称为启动加载器的操作系统代码,并创建一组新的代码、数据、堆和栈段。
3.新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC指向_start 地址,_start 最终调用 hello中的 main 函数。
4.除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到 CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
6.5 Hello的进程执行
1.进程上下文信息:内核重新启动一个被抢占的程序所需的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈、和各种内核数据结构。
2.进程时间片:一个进程和执行它的控制流的一部分的每一时间段。
3.用户模式和内核模式:处理器为了安全起见,不至于损坏操作系统,必须限制一个应用程序可执行指令能访问的地址空间范围。就发明了两种模式用户模式和内核模式,其中内核模式有最高的访问权限,甚至可以停止处理器、改变模式位,或者发起一个I/O操作,处理器使用一个寄存器当作模式位,描述当前进程的特权。进程只有当中断、故障或者陷入系统调用时,才会将模式位设置成上帝模式,得到内核访问权限,其他情况下都始终在用户权限中,就能够保证系统的绝对安全。
进程调度的过程:
在hello执行sleep函数时,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度。当内核调度了一个新的进程运行后,它就抢占hello进程,并且使用上下文切换机制来将控制转移到新的进程。
hello进程初始运行在用户模式中,直到hello进程中的sleep系统调用,它显式地请求让hello进程休眠,内核可以决定执行上下文切换,进入到内核模式。当定时器中断时,内核就能判断当前hello休眠运行了足够长的时间,切换回用户模式。
6.6 hello的异常与信号处理
1.运行时无输入:程序执行完后,此时按回车结束
- 运行时乱按。如图6-6,发现乱按的输入并不会影响进程的执行,当按到回车键时,getchar会读入回车符,并且后面的字符串会当作shell的命令行输入。
- 运行过程中按Ctrl+C,终止hello进程。
- 按下Ctrl+Z后运行ps命令,将hello进程挂起,ps命令列出当前系统中的进程。
- 按下Ctrl+Z后运行jobs命令,列出当前shell环境中已启动的任务状态
- 按下Ctrl+Z后运行pstree命令,以树状图显示进程间的关系。
- 按下Ctrl+Z后运行fg命令,将进程调到前台。
- 按下Ctrl+Z后运行kill命令,发送信号给一个进程或多个进程。
6.7本章小结
本章从进程的角度分别描述了hello子进程fork和execve过程,并针对execve过程中虚拟内存映像以及栈组织结构等作出说明。同时了解了逻辑控制流中内核的调度及上下文切换等机制。阐述了Shell和Bash运行的处理流程以及hello执行过程中可能引发的异常和信号处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址:程序代码经过编译后出现在 汇编程序中地址。逻辑地址由选择符和偏移量组成。
2.线性地址:逻辑地址经过段机制后转化为线性地址,分页机制中线性地址作为输入。
3.物理地址:CPU 通过地址总线的寻址,找到真实的物理内存对应地址。 CPU对内存的访问是通过连接着 CPU 和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成,段标识符,段内偏移量。段标识符是一个16位长的字段组成,称为段选择符,其中前13位是一个索引号。后面三位包含一些硬件细节。
索引号,可以直接理解成数组下标,它对应的“数组”就是段描述符表,段描述符具体描述了一个段地址,这样,很多段描述符就组成段描述符表。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。分段功能在实模式和保护模式下有所不同:
1.实模式:逻辑地址=线性地址=实际的物理地址,即不设防。段寄存器存放真实段基址,同时给出32位地址偏移量,可以访问真实物理内存。
2.保护模式:线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。32位段基址被称作选择符,段寄存器无法放下,用于引用段描述符表中的表项来获得描述符
所有的段描述符被保存在两个表中:全局描述符表GDT和局部描述符表LDT。gdtr 寄存器指向 GDT 表基址。
在保护模式下,分段机制:段选择符在段描述符表-> Index ->目标描述符条目 Segment Descriptor->目标段的基地址 Base address+偏移量 offset=线性地址 Linear Address。
首先,给定一个完整的逻辑地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。
拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。把Base + offset,就是要转换的线性地址了。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘上的数据被分割成块,这些块作为磁盘和主存之间的传输单元。VM 系统通过将虚拟内存分割位称为虚拟页的大小固定的块来处理这个问题。类似地,物理内存被分割为物理页,大小也为 P 字节。虚拟页面地集合被分为三个不相交的子集:已缓存、未缓存和未分配。页式管理中虚拟地址到物理地址的转换如下。
虚拟地址分为两部分:前一部分为虚拟页号,可以索引到当前进程的的物理页表地址,后一部分为虚拟页偏移量,将来可以直接作为物理页偏移量,页表是一个存放在物理内存中的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。
7.4 TLB与四级页表支持下的VA到PA的变换
Core i7 使用四级页表来将虚拟地址翻译成物理地址。36位的虚拟地址被分割成4个9位的片。CR3寄存器包含L1页表的物理地址。VPN1有一个到L1 PTE的偏移量,找到这个PTE以后又会包含到L2页表的基础地址;VPN2包含一个到L2PTE的偏移量,找到这个PTE以后又会包含到L3页表的基础地址;VPN3包含一个到L3PTE的偏移量,找到这个PTE以后又会包含到L4页表的基础地址;VPN4包含一个到L4PTE的偏移量,找到这个PTE以后就是相应的PPN。
7.5 三级Cache支持下的物理内存访问
CPU发送一条虚拟地址,随后MMU按照上述操作获得了物理地址PA。根据cache大小组数的要求,将PA分为CT(标记位)CS(组号),CO(偏移量)。根据CS寻找到正确的组,比较每一个cacheline是否标记位有效以及CT是否相等。如果命中就直接返回想要的数据,如果不命中,就依次去L2,L3,主存判断是否命中,当命中时,将数据传给CPU同时更新各级cache的cacheline。
7.6 hello进程fork时的内存映射
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用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 缺页故障与缺页中断处理
缺页故障是一种常见的故障,当指令引用一个虚拟地址,在 MMU 中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。
缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU 重新启动引起缺页的指令,这条指令再次发送 VA 到MMU,这次 MMU 就能正常翻译 VA 了。
7.9动态存储分配管理
动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。动态内存分配主要有两种基本方法与策略:
1.带边界标签的隐式空闲链表分配器管理
带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、可能的额外填充以及一个字的尾部组成的。隐式空闲链表:在隐式空闲链表中,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块。当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配合最佳适配。分配完后可以分割空闲块减少内部碎片。同时分配器在面对释放一个已分配块时,可以合并空闲块,其中便利用隐式空闲链表的边界标记来进行合并。
2.显示空间链表管理
显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。显式空闲链表:在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章主要介绍了hello的存储器地址空间、intel的段式管理、hello 的页式管理, 以intel Core7在指定环境下介绍了VA 到PA 的变换、物理内存访问,还介绍hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有IO设备都被模型化为文件,所有的输入和输出都能被当做相应文件的读和写来执行。
设备管理:Linux内核有一个简单、低级的接口,成为Unix I/O,是的所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix IO接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备。内核返回一个小的非负整数,叫做描述符。描述符在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
2.Shell 创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
3.改变当前的文件位置:内核保持着每个打开的文件的一个文件位置k。k初始为0。这个文件位置k表示的是从文件开头起始的字节偏移量。应用程序能够通过执行seek,显式地将改变当前文件位置 k,例如各种fread或fwrite。
4.读写文件:
读操作就是从文件复制n>0个字节到内存。从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的文件。当k>=m时,触发EOF。
写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k=k+n。
5.关闭文件:内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix IO函数:
1. int open(char* filename,int flags,mode_t mode) ,进程通过调用open函数来打开一个存在的文件或是创建一个新文件。open函数将文件名filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
2.int close(fd),fd是需要关闭的文件的描述符,close 返回操作结果。
3.ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
4.ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
查看printf代码:
int printf(const char fmt, …)
{
int i;
char buf[256];
va_list arg = (va_list)((char)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
首先arg获得第二个不定长参数,即输出的时候格式化串对应的值。
查看vsprintf代码:
int vsprintf(char *buf, const char fmt, va_list args)
{
char p;
char tmp[256];
va_list p_next_arg = args;
for (p = buf; *fmt; fmt++)
{
if (*fmt != ‘%’)
{
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt
{
case ‘x’:
itoa(tmp, ((int)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case ‘s’:
break;
default:
break;
}
}
return (p - buf);
}
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall,查看syscall的实现:
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
ret
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)
8.4 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 函数也通过 sys_call 调用内核中的系统函数,将读取存储在键盘缓冲区中的 ASCII 码,直到读到回车符,然后返回整个字符串,getchar 函数只从中读取第一个字符,其他的字符被缓存在输入缓冲区。
8.5本章小结
本章介绍了 Linux 的 I/O 设备的基本概念和管理方法,以及Unix I/O 接口及其函数。最后分析了printf 函数和 getchar 函数的工作过程。
(第8章1分)
结论
hello所经历的过程:
1.hello被IO设备编写,以文件的方式储存在主存中。
2.hello.c被预处理hello.i文件
3.hello.i被编译为hello.s汇编文件
4.hello.s被汇编成可重定位目标文件hello.o
5.链接器将hello.o和外部文件链接成可执行文件hello
6.在shell输入命令后,通过exceve加载并运行hello
7.在一个时间片中,hello有自己的CPU资源,顺序执行逻辑控制流
8.hello的VA通过TLB和页表翻译为PA
9.三级cache 支持下的hello物理地址访问
10.hello在运行过程中会有异常和信号等
11.printf会调用malloc通过动态内存分配器申请堆中的内存
12.shell父进程回收hello子进程,内核删除为hello创建的所有数据结构
感想:
即使是一个简单的 hello.c 也需要经这么多复杂的步骤,并且每一步都经过了设计者的深思熟虑,在有限的硬件水平下把程序的时间和空间性能都做到了近乎完美。通过对计算机系统的深入理解,我在编写代码的时候也要从计算机的底层考虑问题,思考自己该怎么优化程序的性能,编写对编译器有好的代码。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello.c:源代码
hello.i:预处理后的文本文件
hello.s:编译之后的汇编文件
hello.o:汇编之后的可重定位目标执行文件
hello:链接之后的可执行文件
hello.elf:hello.o的ELF格式
hello1.elf:hello的ELF格式
hello.txt:hello.o反汇编代码
hello1.txt:hello的反汇编代码
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] printf 函数实现的深入剖析:https://www.cnblogs.com/pianist/p/3315801.html
[2] 进程的创建过程:https://blog.csdn.net/lyl194458/article/details/79695110
[3] 逻辑地址和物理地址及线性地址:
https://blog.csdn.net/weixin_30539835/article/details/97201651
.
(参考文献0分,缺失 -1分)