ICS大作业
摘 要
本文从普通的hello程序出发,展示了hello从.c文件如何通过预处理、编译、汇编、链接阶段转变为可执行文件的全过程。还介绍了hello进程在shell执行的过程,存储管理的过程,I/O处理的过程这些运行机制。
关键词:计算机系统;P2P;O2O;
目 录
目录
第1章 概述
1.1 Hello简介
·P2P过程
P2P是From Program to Process,从hello.c这一个程序开始,经过预处理、编译、汇编、链接生成可执行目标,在shell中,输入运行指令后,shell解析参数,为其fork一个子进程,内核为其创建数据结构,此时hello就从程序变为进程(Process)。
·020过程
020是From Zero-0 to Zero-0的过程。shell首先fork一个子进程,然后通过execve加载并执行Hello,映射虚拟内存,进入程序入口后将程序载入物理内存,进入 main函数执行目标代码,CPU为运行的Hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收Hello进程,内核删除相关数据结构。即,从0开始,以0结束,为020。
1.2 环境与工具
1.2.1 硬件环境
·AMD X64 CPU 1.80GHZ; 16G RAM; 500GB SSD
1.2.2 软件环境
·Windows10 64位; Vmware 16 PRO; Ubuntu 20.04 LTS 64位
1.2.3 开发工具
·Visual Studio 2022 64位以上;CodeBlocks 64位;EDB
1.3 中间结果
1.hello.c-----------------hello的源文件
2.hello.i------------------hello.c经过预处理后的文件
3.hello.s-----------------hello.i编译后的汇编文件
4.hello.o-----------------hello.s汇编后的可重定位目标文件
5.hello_o_elf.txt-------hello.o的ELF文件
6.hello_o.objdump----hello.o的反汇编文件
7.hello-------------------链接后的可执行文件
8.hello.elf---------------hello的elf文件
9.hello.objdump-------hello的反汇编文件
1.4 本章小结
对hello进行了简单的介绍,分析了其P2P和020的过程,也列出了本次任务的硬件、软件环境和调试工具,并且列举了任务过程中出现的中间产物,是后续实验部分的基础。
第2章 预处理
2.1 预处理的概念与作用
·概念
预处理是预处理器cpp对一个程序处理的第一步,修改原始的C程序,将.c文件进行初步处理成一个.i文件。根据以字符#开头的命令(头文件、define等),修改原始的C程序,将引用的所有库展开合并成为一个完整的文本文件。
·作用
- 展开所包含的头文件
2.有宏的地方进行替换,并删除宏
3.删除所有注释。
4.添加行号和文件名标识,以便编译时编译器产生调试用的行号信息。
5.不进行语法检查c->i
预处理阶段的作用是让编译器在随后对文本进行编译的过程中,更加方便,因为访问库函数这类操作在预处理阶段已经完成,减少了编译器的工作。
2.2在Ubuntu下预处理的命令
通过命令gcc -E hello.c -o hello.i对hello.c文件进行预处理
2.3 Hello的预处理结果解析
·针对以下三条语句的预处理
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
2.4 本章小结
本章主要介绍了预处理的定义与作用、并结合预处理之后的程序对预处理结果进行了解析。预处理是计算机对程序进行操作的第一个步骤,在这个过程中预处理器会对.c文件进行初步的解释,对头文件、宏定义和注释进行操作,最后将初步处理完成的文本保存在.i文件中。
第3章 编译
3.1 编译的概念与作用
·概念
编译阶段是编译器对.i文件进行处理的过程。此阶段编译器会完成一系列对代码的语法和语义的分析,生成汇编代码,并保存在.s文件中。
·作用
检查C语言的语法,然后生成汇编代码。
3.2 在Ubuntu下编译的命令
通过命令gcc -S hello.i -o hello.s对hello.i文件进行编译
3.3 Hello的编译结果解析
·汇编指令
.file -------------- 声明源文件
.text -------------- 代码段
.globl ------- -----声明全局变量
.data -------------已初始化的全局和静态C变量
.align 4 ----------声明对指令或者数据的存放地址进行对齐的方式
.type -------- -----指明函数类型或对象类型
.size -------------- 声明变量大小
.long ----- 声明long型数据
.string----- 声明string型数据
.section .rodata-- 只读数据段
·数据解析
1.整数
1.1 int argc
main函数参数argc被%edi保存,并储存在-20(%rbp)。
1.2 int i
局部变量被存储在寄存器或者栈空间中,i被存储在%rbp-4中,从图中可以看出i占据了栈中的4位,符合int类型的size。
2.数组
2.1 char argv[]
%rsi保存传入函数的第二个参数,argv作为第二个参数%rsi被保存在-32(%rbp)中。
3.字符串
3.1 “用法: Hello 学号 姓名 秒数!\n”
第一个printf传入的输出格式化参数,存放在只读数据段.rodata中,字符串被编码成utf-8格式,一个汉字在utf-8编码中占三个字节,一个\ 代表一个字节。
3.2 “Hello %s %s\n”
第二个printf传入的输出格式化参数,存放在只读数据段.rodata中。
·赋值
1.int i
整型数据的赋值使用mov指令,因为i是4B的int类型,所以使用movl进行赋值
·算数操作
1.i++
采用add指令,用addl对i进行增加。
2.leaq计算LC1段地址
for循环内部需要对LC1处字符串进行打印,使用了加载有效地址指令leaq 计算LC1的段地址%rip+.LC1并传递给%rdi。
·控制转移
1.if (argv!=4)
对于if判断,首先使用cmpl $4, -20(%rbp),设置条件码,使用je判断ZF 标志位,如果为0,说明argv==4,则直接跳转到.L2,否则顺序执行下一条语句。
2.for(i=0;i<8;i++)
首先跳转到位于循环体.L4之后的比较代码,使用cmpl进行比较,如果 i<=7,则跳入.L4 for循环体执行,否则说明循环结束,顺序执行for之后的逻 辑。
·数组操作
C源程序中的数组操作出现在循环体for循环中,每次循环中都要访问 argv[1]、argv[2]这两个内存。
argv[1]:数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上 偏移量$8,再将该位置内容放在%rsi中,成为下一个函数的第一个参数。
argv[2]:数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上 偏移量$24,再将该位置内容放在%rdi中,成为下一个函数的第二个参数。
·函数操作
函数是一种过程,过程提供了一种封装代码的方式,用一组指定的参数和 可选的返回值实现某种功能。
hello.c中涉及的函数操作有:
1.main函数:
(1)传递控制:
系统启动函数调用,call指令将下一条指令的地址压栈,然后跳转到 main函数。
(2)传递数据:
外部调用过程向main函数传递参数argc和argv[],分别使用%edi和%rsi 存储,函数正常出口为return 0,将%eax设置0返回。
(3)分配和释放内存:
使用%rbp记录栈帧的底,函数分配栈帧空间在%rbp之上,程序结束时,调用leave指令。
2.printf函数:
(1)传递数据:
第一次printf将%rdi设置为“用法: Hello 学号 姓名 秒数!\n”字符串的首地址。
第二次printf设置%rdi为“Hello %s %s\n”的首地址,设置%rdx为argv[1],%rsi 为argv[2]。
(2)控制传递:
第一次printf因为只有一个字符串参数,所以call puts@PLT;第二次printf 使用call printf@PLT。
3.exit函数:
(1)传递数据
将%edi设置为1。
(2)控制传递
call exit@PLT。
4.sleep函数:
(1)参数传递:
将atoi的返回值%eax通过%rdi传递给sleep函数
(2)控制传递:
调用了sleep函数,将控制传送。
(3)函数返回:
从sleep中返回。
5.getchar函数:
(1)控制传递
call gethcar@PLT
6.atoi函数
(1)参数传递:将argv[3](字符串)通过%rdi传递给atoi函数。
(2)控制传递:通过call atoi@PLT函数,进行函数调用。
(3)函数返回:从atoi中返回。
3.4 本章小结
本章主要介绍了有关编译的概念作用,然后使用gcc -S hello.c -o hello.s生成了编译后的文件。对于生成的.s文件,对汇编代码hello.s从数据、赋值、算数操作、控制转移、数组操作、函数操作等几个方面进行了分享,对汇编代码有了进一步的了解。为下一步汇编打下了基础。
第4章 汇编
4.1 汇编的概念与作用
·概念
汇编指的是汇编器(Assembler)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标文件,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,包含hello程序执行的机器指令。
·作用
将在hello.s中保存的汇编代码翻译生成机器语言,因为机器语言是计算机能直接识别和执行的一种语言。
4.2 在Ubuntu下汇编的命令
通过命令gcc -c hello.s -o hello.o对hello.o文件进行汇编
4.3 可重定位目标elf格式
通过命令readelf -a hello.o > hello_o_elf.txt读取hello.o文件的ELF格式至hello_o_elf_txt中
·ELF头
ELF头以一个16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。
·节头部表
节头部表包括节的全部信息
·重定位信息
重定位是将EFL文件中的未定义符号关联到有效值的处理过程。在hello.o中,对printf,exit等函数的未定义的引用和全局变量(sleepsecs)替换为该进程的虚拟地址空间中机器代码所在的地址。
·符号表
符号表(.symtab)是用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明。
4.4 Hello.o的结果解析
通过命令objdump -d -r hello.o > hello_o.objdump 获取hello.o文件的反汇编文件hello_o.objdump
·分支转移
反汇编代码跳转指令的操作数使用的不是段名称如.L2,段名称只是在汇编语言中便于编写的助记符,在汇编成机器语言之后使用地是确定的地址。
·函数调用
在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0,然后在.rela.text节中为其添加重定位条目,在链接后再进一步确定。
·全局变量访问
在.s文件中,访问.rodata(printf中的字符串),使用段名称+%rip,在反汇编代码中0+%rip,因为.rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
4.5 本章小结
本章简述了hello.s汇编指令被转换成hello.o机器指令的过程,通过readelf查看hello.o的ELF,通过反汇编的方式查看了hello.o反汇编的内容,比较和hello.s之间的不同。了解到从汇编语言映射到机器语言汇编器需要实现的转换
第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生成hello
5.3 可执行目标文件hello的格式
通过命令readelf -a hello > hello.elf读取hello文件的ELF格式
5.4 hello的虚拟地址空间
用edb加载hello程序,在DataDump里可以看到hello的虚拟地址空间。
可见ELF被映射到了0x401000
5.5 链接的重定位过程分析
通过命令objdump -d -r hello > hello.objdump获取hello的反汇编文件hello.objdump
将它与hello.o文件的反汇编代码进行比较后,可得下面不同:
1.地址的访问:
hello.o中的相对地址到了hello中变成了虚拟内存地址。而hello.o文件中对于.rodata的访问,是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全置为0,并且添加重定位条目。
2.链接增加新的函数:
在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数。
3.增加的节:
hello中增加了.init和.plt节,和一些节中定义的函数。
4.函数调用:
hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
·重定位过程
1.关联符号定义
链接器将代码中的每个符号引用和一个符号定义关联起来。此时,链接器知道输入目标模块中的代码节和数据节的确切大小。
2.合并输入模块
链接器将所有输入到hello中相同类型的节合并为同一类型的新的聚合节。
3.符号引用
链接器修改hello中的代码段和数据段中对每一个符号的引用,使其指向正确的运行地址。
5.6 hello的执行流程
程序名称 | 程序地址 |
ld-2.31.so!_dl_start | 0x7f80b5ce8df0 |
ld-2.31.so!_dl_init | 0x7f80b5cf8c20 |
hello!_start | 0x4010f0 |
hello!_init | 0x400488 |
hello!puts@plt | 0x4004b0 |
hello!exit@plt | 0x4004e0 |
hello!main | 0x4005e7 |
hello!printf@plt | 0x4004c0 |
hello!sleep@plt | 0x4004f0 |
hello!getchar@plt | 0x4004d0 |
5.7 Hello的动态链接分析
共享链接库代码是一个动态的目标模块,在程序开始运行或者调用程序加载时,可以自动加载该代码到任意的一个内存地址,并和一个在目标模块内存中的应用程序链接了起来,这个过程就是对动态链接的重定位过程。程序调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定的技术解决这个问题,将过程地址的延迟绑定推迟到第一次调用该过程时。
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模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。
而在一个动态的共享链接库中仍然存在着一个可以调用程序加载而动态链接无需重定位的位置无关代码,编译器在程序中的函数开始运行时是不能自动预测各个函数的开始运行时间和地址的,这就可能需要系统添加重定位的记录,交给一个动态共享链接器或者采用它来进行重定位的动态共享链接,动态共享链接器本身就是负责执行对动态链接的重定位过程,这样做就有效地防止了程序运行时自动修改或者调用目标模块的位置无关代码段。
5.8 本章小结
本章分析了链接过程中对程序的处理。主要介绍了链接的概念与作用、hello的ELF格式,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。
经过链接,ELF可重定位的目标文件变成可执行的目标文件,链接器会将静态库代码写入程序中,以及调用动态库等相关信息,将地址进行重定位,从而保证寻址的正确进行。我们可以将一个大型的源文件分解为更小、更好管理的模块,可以独立地修改和编译这些模块,大幅度提高了效率。
第6章 hello进程管理
6.1 进程的概念与作用
·概念
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间。用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程。进程是系统进行资源分配和调度的基本单位,是操作系统结构的基础。系统中的每个程序都运行在某个进程的上下文中,由程序正确运行的状态组成的。
·作用
1.提供给应用进程两个假象:
a)我们的程序好像是系统中当前运行的唯一程序,处理器好像是无间断的执行我们程序中的指令。
b)我们的程序好像是独占的使用处理器和内存。我们程序中的代码和数据好像是系统内存中唯一的对象。
2.每次用户向shell输入一个可执行目标文件的名字运行时,shell就会创建一个新的进程,然后在这个进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。
6.2 简述壳Shell-bash的作用与处理流程
·作用
shell是一个交互型的应用程序,他在操作系统中提供了一个用户与系统内核进行交互的界面。它解释由用户输入的命令并且把它们送到内核。
·处理流程
1.从终端读入命令。
2.分析输入内容,获得输入参数
3.如果是内置命令则立即执行,否则调用相应的程序为其分配子进程并运行
6.3 Hello的fork进程创建过程
Shell通过fork函数创建一个新的运行的子进程,也就是Hello程序。新的子进程几乎但不完全与Shell相同。子进程得到与 Shell用户级虚拟地址空间相同(但是独立)的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork 时,子进程可以读写父进程中打开的任何文件。子进程与父进程有不同的pid。fork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0.
6.4 Hello的execve过程
execve函数在新创建的子进程的上下文中加载并运行hello程序,且带参数列表argv 和环境变量列表envp 。只有发生错误时execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次且从不返回。execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行程序。
具体步骤:加载并运行hello需要删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。
6.5 Hello的进程执行
·上下文信息
上下文是内核重新启动一个被抢占的进程所需要的状态,系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。上下文是由程序正确运行所需的状态组成的。
·进程时间片
是分时操作系统分配给每个正在运行的进程的控制流上的一段CPU时间。
·用户模式与内核模式
运行 Windows 的计算机中的处理器有两个不同模式:“用户模式”和“内核 模式”。根据处理器上运行的代码的类型,处理器在两个模式之间切换。应用 程序在用户模式下运行,核心操作系统组件在内核模式下运行。多个驱动程序 在内核模式下运行时,某些驱动程序可能在用户模式下运行。
当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执 行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式 位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访 问系统中的任何内存位置。
·hello sleep进程的调度过程
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程的决定,如当调用sleep之前,如果hello程序不被抢占则顺序执行,假如发生被抢占的情况,则进行上下文切换,上下文切换是由内核中调度器完成的,当内核调度新的进程运行后,它就会抢占当前进程,并进行如下操作:保存以前进程的上下文,恢复新恢复进程被保存的上下文,将控制传递给这个新恢复的进程 ,来完成上下文切换。
6.6 hello的异常与信号处理
·hello执行过程中会出现的几类异常
1.中断
2.陷阱
3.故障
4.终止
·处理方式
1.中断
中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。硬件 中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件 中断的异常处理程序常常称为中断处理程序。
2.陷阱
陷阱是有意的异常,是执行一条指令后的结果。就像中断处理程序一样, 陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内 核之间提供一个像过程一样的接口,叫做系统调用。
3.故障
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时, 处理器将控制转移给故障处理程序。如果故障处理程序能够修正这个错误,它 就将控制返回给引起故障的指令,从而重新执行它,否则,处理程序返回到内 核中的 abort例程,abort例程会终止引起故障的应用程序。
4.终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如 DRAM 或者 SRAM 位被损坏时发生的奇偶错误。终止处理程序不会将控制 返回给应用程序。
程序运行结束后按回车,正常回收
程序运行中按Ctrl-Z:父进程收到SIGTSTP信号,将hello进程挂起,ps命令列出当前系统中的进程(包括僵死进程)。运行jobs命令。jobs命令列出当前shell环境中已启动的任务状态。
程序运行中按Ctrl-Z后,再执行pstree命令。
程序运行中按Ctrl-Z后,再执行jobs、fg、ps、kill指令。
程序运行中按Ctrl-C:父进程收到SIGINT信号,终止hello进程,并且回收hello进程。
程序运行中不停乱按
6.7本章小结
本章中主要介绍了进程的概念以及进程在计算机中的调用过程。介绍了shell的基本操作以及各种内核信号和命令,还介绍了进程中两个关键的抽象:逻辑控制流和私有空间。总结了shell是如何fork新建子进程、execve如何执行进程。。分析了hello的进程执行和异常与信号处理过程。通过运行hello程序,介绍了异常控制流的通知机制信号。
第7章 hello的存储管理
7.1 hello的存储器地址空间
·逻辑地址
逻辑地址是指由程序产生的与段相关的偏移地址部分。逻辑地址由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离,逻辑地址是指就是hello.o里面的相对偏移地址。
·线性地址
地址空间是一个非负整数地址的有序集合,是经过段机制转化之后用于描述程序分页信息的地址。是对程序运行区块的一个抽象映射。如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间。就是hello里面的虚拟内存地址。
·虚拟地址
CPU 通过生成一个虚拟地址。就是hello里面的虚拟内存地址。
·物理地址
CPU通过地址总线的寻址,找到真实的物理内存对应地址。计算机主存被组织成由M个连续字节大小的内存组成的数组,每个字节都有一个唯一的地址,该地址被称为物理地址。在前端总线上传输的内存地址都是物理内存地址。就是hello在运行时虚拟内存地址对应的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成:段选择符和段内偏移量。
段选择符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。TI:0为GDT,1为LDT。Index指出选择描述符表中的哪个条目,RPL请求特权级。
最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。
7.3 Hello的线性地址到物理地址的变换-页式管理
分页的基本原理是把内存划分成大小固定的若干单元,每个单元称为一页(page),每页包含4k字节的地址空间。这样每一页的起始地址都是4k字节对齐的。为了能转换成物理地址,我们需要给CPU提供当前任务的线性地址转物理地址的查找表,即页表(page table)。
在这个转换中要用到翻译后备缓冲器(TLB),首先我们先将线性地址分为VPN(虚拟页号)+VPO(虚拟页偏移)的形式,然后再将VPN拆分成TLBT(TLB标记)+TLBI(TLB索引)然后去TLB缓存里找所对应的PPN(物理页号)如果发生缺页情况则直接查找对应的PPN,找到PPN之后,将其与VPO组合变为PPN+VPO就是生成的物理地址了。
7.4 TLB与四级页表支持下的VA到PA的变换
前提如下:
虚拟地址空间48位,物理地址空间52位,页表大小4KB,4级页表。TLB 4路16组相联。CR3指向第一级页表的起始位置(上下文一部分)。
为了消除每次 CPU 产生一个虚拟地址,MMU 就查阅一个PTE带来的时间开销,许多系统都在MMU中包括了一个关于 PTE 的小的缓存,称为翻译后被缓冲器(TLB),TLB的速度快于L1 cache。
TLB通过虚拟地址VPN部分进行索引,分为索引(TLBI)与标记(TLBT)两个部分。这样,MMU在读取PTE时会直接通过TLB,如果不命中再从内存中将PTE复制到TLB。同时,为了减少页表太大而造成的空间损失,可以使用层次结构的页表页压缩 页表大小。core i7使用的是四级页表。
在四级页表层次结构的地址翻译中,虚拟地址被划分为4个VPN和1个VPO。每个VPNi都是一个到第i级页表的索引,第j级页表中的每个PTE都指向第j+1级某个页表的基址,第四级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问四个PTE。
7.5 三级Cache支持下的物理内存访问
得到物理地址之后,先将物理地址拆分成CT(标记)+CI(索引)+CO(偏移量),然后L1cache内部找,了L1里面以后,寻找物理地址检测是否命中,如果未能寻找到标记位为有效的字节(miss)的话就去二级和三级cache中寻找对应的字节,找到之后返回结果。到这里就是使用到我们的CPU的高速缓存机制了,使得机器在翻译地址的时候的性能得以充分发挥。
7.6 hello进程fork时的内存映射
当fork函数被shell调用时,并分配给hello一个唯一的PID。为了给hello创建虚拟内存,fork创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello中返回时,hello现在的虚拟内存刚好和调用shell的虚拟内存相同。当这两个进程中的任何一个进行写操作时,写时复制机制会创建新页面。因此也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。
·加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域
删除当前进程虚拟地址的用户部分中的已存在的区域结构
2.映射私有区域
为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的 区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text 和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3.映射共享区域
hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的, 然后再映射到用户虚拟地址空间中的共享区域内。
4.设置PC
设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障是一种常见的故障,在指令请求一个虚拟地址时,MMU中查找页表,如果对于的物理地址没有存在主存内部,以至于我们必须要从磁盘中读出数据,这就是缺页故障(中断)。
情况1:段错误:首先,先判断这个缺页的虚拟地址是否合法,那么遍历所有的合法区域结构,如果这个虚拟地址对所有的区域结构都无法匹配,那么就返回一个段错误(segment fault)
情况2:非法访问:接着查看这个地址的权限,判断一下进程是否有读写改这个地址的权限。
情况3:如果不是上面两种情况那就是正常缺页,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。
处理:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去, 换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页 的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态存储分配管理由动态内存分配器完成。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆是一个请求二进制零的区域,它紧接在未初始化的数据区后开始,并向更高的地址。分配器将堆视为一组不同大小的块的集合来维护。
·隐式空闲链表
头部一共四个字节,前三个字节存储的是块的大小,最后一个字节存储的是当前这个块是空闲块还是已分配的块,0代表空闲块,1代表已分配的块。中间的有效载荷就是用于存放已分配的块中的信息用的。最后的填充部分是为了地址对齐等一些要求用的。
隐式链表的结构就是根据地址从小到大进行连接的,如图7-11所示。其中的每一个元素表示的是一个空闲块或者一个分配块,由于空闲块会合并的特性,链表中的元素的连接一定是空闲块的分配块交替连接的。
·显式空闲链表
显示结构在空闲块中增加了8个字节,分别保存当前空闲块的前驱空闲块的地址和后继空闲块的地址。显式的结构比隐式结构多维护了一个链表,就是空闲块的链表。这样做的好处就是在malloc的时候,隐式的方法是要遍历所有的块,包括空闲块了分配块。但是显式的结构只需要在空闲块中维护的链表检索就可以了,这样降低了在malloc时候的复杂度。
关于空闲块的维护方式一共有两种:
(1)放置策略:首次适配、下一次适配、最佳适配。
首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
(2)合并策略:立即合并、推迟合并。
立即合并就是在每次一个块被释放时,就合并所有的相邻块;推迟合并就是推迟到某个稍晚的时候再合并空闲块。
在每个块的结尾添加一个脚部,分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,从而使得对前面块的合并能够在常数时间之内进行。
后进先出(LIFO),释放的块在链表的头部,使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。
7.10本章小结
本章简要介绍了hello的存储地址空间。简单讲述了逻辑地址、虚拟地址、物理地址与线性地址的概念与转换方法,介绍了进程fork和execve时的内存映射的内容,描述了系统如何应对那些缺页异常,介绍了动态内存分配管理的方法。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
·设备的模型化:文件
·设备管理:unix io接口
·所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O设备管理方法。
8.2 简述Unix IO接口及其函数
·Unix I/O 接口统一操作
1.打开文件
应用程序通过要求内核打开相应的文件。
2.shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
3.改变文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个k是 从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当 前文件位置。
4.读/写文件
读操作是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将 k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。
写操作是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5.关闭文件
内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述 符池中。
·Unix I/O 函数
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;
}
·printf函数功能:接受字符串指针数组fmt,然后将匹配到的参数按照fmt格式输出。
·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的作用是格式化。接受确定输出格式的格式字符串fnt。用格式字符串对个数变化的参数进行格式化,产生格式化输出,并返回要打印的字符串的长度。
·write函数中,先给寄存器传了几个参数,然后通过系统调用sys_call。
·syscall函数将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
·syscall函数将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
·字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
·显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
·getchar函数调用read函数,将整个缓冲区都读到buf里,并将缓冲区的长度赋值给n。返回时返回buf的第一个元素,除非n<0。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
·本章中简单的描述了linux的io的接口及其设备和管理模式,unixio的接口及其使用的函数,以及printf函数和pritgetchar函数的实现方法以及操作过程。。
结论
一个最最普通的hello程序其实并不像我们平时所想的那样简单,这一次大作业的实验从一个最常见不过的hello程序引申出很多值得我们学习和思考的问题的方面,很有意义。
·总结hello所经历的过程:
1.编写hello.c代码。
2.预处理:预处理器扩展源代码插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏,合并为一个hello.i文件。
3.编译:编译器将hello.i编译成hello.s文件,包含汇编代码。
4.汇编:汇编器将hello.s可重定位为hello.o文件。
5.链接:链接器将hello.o与实现库函数的代码合并,产生最终的可执行代码文件。
6.带参数运行:在命令行输入:./hello 1190201715 叶扬帆 1
7.创建子进程:调用fork创建一个子进程。
8.运行:调用execve,execve调用启动加载器,加映射虚拟内存,进入hello程序入口后将程序载入物理内存,进入main函数执行hello。
9.执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行相应的控制逻辑流。
10.访问内存:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行相应的控制逻辑流。
11.申请内存:将虚拟内存地址通过页表映射成物理地址。
12.发出/接收信号:如果运行中键入Ctrl + C或Ctrl + Z,则调用shell的信号处理函数分别停止、挂起。
- 结束进程:exit,hello的父进程回收hello,内核也删除它的所有数据。
附件
列出所有的中间产物的文件名,并予以说明起作用。
1.hello
链接后的可执行文件
2.hello.c
hello的源文件
3.hello.elf
hello的elf文件
4.hello.i
hello.c经过预处理后的文件
5.hello.o
hello.s汇编后的可重定位目标文件
6.hello.objdump
hello可执行文件的反汇编
7.hello.s
hello.i编译后的汇编文件
8.hello_o.objdump
hello.o的反汇编
9.hello_o_elf.txt
hello.o的ELF文件