目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 5 -
3.2 在Ubuntu下编译的命令............................................................................. - 6 -
4.2 在Ubuntu下汇编的命令............................................................................. - 7 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
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 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
第1章 概述
1.1 Hello简介
- P2P过程
P2P是指from program to process的过程,也就是从程序到进程的过程。其间,要经过四个流程。
- 预处理:C预处理器扩展源代码,插入所有用#include命令指定的文件, 并扩展所有用#define声明指定的宏。
- 编译:产生源文件的汇编代码
- 汇编:汇编代码转化为二进制代码
- 链接:将目标代码文件与实现库函数的代码合并。
- 020过程
当shell通过execve加载并执行该程序时,操作系统为程序分配一部分虚拟空间,将程序加载到虚拟空间所映射的物理内存空间中,可执行目标文件hello中的代码和数据从磁盘复制到物理内存,然后通过跳转到程序入口点_start函数的地址,最终调用执行hello中的main函数,内核为hello进程分配时间片执行逻辑控制流。程序运行结束后,shell父进程回收这个僵死子进程,内核会从系统中删除它的所有痕迹。
1.2 环境与工具
- 硬件环境
Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz 2.59 GHz
机带RAM:8.00GB
Disk: 1T
- 软件环境
物理机:Windows 10 21H2 x86_64
虚拟机:Ubuntu 20.03 LTS x86_64 in VMWare Workstation Pro 16
- 开发工具
物理机:VS2019
虚拟机:Codeblocks,gcc/gdb x64
- 调试工具
Gcc,gdb,edb,Winhex,vim,ld,readelf,objdump
1.3 中间结果
hello-------------------链接后的可执行文件
hello.c-----------------hello的源文件
hello.elf---------------hello的elf文件
hello.i------------------hello.c经过预处理后的文件
hello.o-----------------hello.s汇编后的可重定位目标文件
hello.objdump-------hello可执行文件的反汇编
hello.s-----------------hello.i编译后的汇编文件
hello_o.objdump----hello.o的反汇编
hello_o_elf.txt-------hello.o的ELF文件
1.4 本章小结
对hello进行了简单介绍,列出本次任务的硬件、软件环境和调试工具,并且列举了任务过程中出现的中间产物,是后续实验部分的基础。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
- 概念
预处理是在编译之前进行的处理,一般指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。预处理器并不对程序的源代码进行解析,但它把源代码分割或处理为特定的段。预处理中会展开以#起始的行,修改原始的C程序,它检查包含预处理指令的语句和宏定义,并对源代码进行转换,还会删除注释和多余的空白字符。
- 作用
程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。
2.2在Ubuntu下预处理的命令
打开中断并输入:
Gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
对“#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>”三条语句进行预处理
2.4 本章小结
介绍了预处理,结合预处理之后的程序进行分析,预处理对.c文件进行初步的解释,对头文件,宏定义和注释进行操作,并最终生成.i文件。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
- 概念
编译阶段是编译器对.i文件进行处理的过程。此阶段编译器会完成一系列对代码的语法和语义的分析,生成汇编代码,并保存在.s文件中。
- 作用
- 语法检查:检查源程序是否合乎语法。如果不符合语法,编译程序要指出语法错误的部位、性质和有关信息。编译程序应使用户一次上机,能够尽可能多地查出错误。
- 调试措施:检查源程序是否合乎设计者的意图。为此,要求编译程序在编译出的目标程序中安置一些输出指令,以便在目标程序运行时能输出程序动态执行情况的信息,如变量值的更改、程序执行时所经历的线路等。这些信息有助于用户核实和验证源程序是否表达了算法要求。
- 修改手段:为用户提供简便的修改源程序的手段。编译程序通常要提供批量修改手段(用于修改数量较大或临时不易修改的错误)和现场修改手段(用于运行时修改数量较少、临时易改的错误)
3.2 在Ubuntu下编译的命令
Ctrl + alt + t打开终端并输入
Gcc -s hello.i -o hello.s
3.3 Hello的编译结果解析
- 3.3.1 汇编指令
.file -------------- C文件声明
.text -------------- 代码段
.globl ------- -----声明全局变量
.data -------------已初始化的全局和静态C变量
.align 4 ----------声明对指令或者数据的存放地址进行对齐
.type -------- -----指明函数类型或对象类型
.size -------------- 声明变量大小
.long .string----- 声明long型、string型数据
.section .rodata-- 只读数据段
- 3.3.2 数据解析
- 整数
Int argc:进入函数的第一个参数保存在%edi中
Int i(局部变量):存储在寄存器或者占空间内。由下图可以看到i占据了4位栈空间。
- 数组
Argv单个元素的大小为8位。下图分析了argv传入函数时存储的位置。%rsi保存第二个参数,就是数组argv的首地址。
- 字符串
“hello 学号 姓名! \n” :此printf输出的参数存放在.rodata中,由下图可知该字符串被编码成utf-8
“hello %s %s\n”: 此printf输出参数也存放在.rodata中。
- 3.3.3 赋值
i是保存在栈中的局部变量,使用mov语句并以l为后缀构成movl对i进行赋值
- 3.3.4 算数操作
I++:采用add指令
Leaq计算LC1段地址:leaq计算lc1的短地址%rip + LC1并上传给%rdi.
- 3.3.5控制转移
if (argv!=4)
对于if判断,编译器使用 跳转指令实现,首先使用cmpl $4, -20(%rbp),设置条件码,使用je判断ZF 标志位,如果为0,说明argv-4=0, 即argv==4,则直接跳 转到.L2,否则顺 序执行下一条语句,即执行if中 的代码。
For(i=0;i<8;i++)
先跳到.l4之后的比较码,用cmpl比较
- 3.3.6 数组操作
C源程序中的数组操作。
Argv[1]:数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上8,该位置放在%rsi中,成为下一个函数的参数。
Argv[2]: 数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上24,该位置放在%rsi中,成为下一个函数的参数。
- 3.3.7 函数操作
函数是一种过程,过程提供了一种封装代码的方式,用一组指定的参数和 可选的返回值实现某种功能。
hello.c中涉及的函数操作有:
main函数:
(1)传递控制:
系统启动函数调用,call指令将下一条指令的地址压栈,然后跳转到 main函数。
(2)传递数据:
外部调用过程向main函数传递参数argc和argv,分别使用%edi和%rsi 存储,函数正常出口为return 0,将%eax设置0返回。
(3)分配和释放内存:
使用%rbp记录栈帧的底,函数分配栈帧空间在%rbp之上,程序结束时, 调用leave指令,leave相当于
mov %rbp,%rsp
pop %rbp
恢复栈空间为调用之前的状态,然后ret返回,ret相当pop IP。
printf函数:
(1)传递数据:
第一次printf将%rdi设置为“Usage: Hello 学号 姓名!\n”字符串的首地址。 第二次printf设置%rdi为“Hello %s %s\n”的首地址,设置%rdx为argv[1],%rsi 为argv[2]。
(2)控制传递:
第一次printf因为只有一个字符串参数,所以call puts@PLT;第二次printf 使用call printf@PLT。
exit函数:
(1)传递数据
将%edi设置为1。
(2)控制传递
call exit@PLT。
sleep函数:
(1)参数传递:
将atoi的返回值%eax通过%rdi传递给sleep函数
(2)控制传递:
调用了sleep函数,将控制传送。
(3)函数返回:
从sleep中返回。
getchar函数:
(1)控制传递
call gethcar@PLT
6.atoi函数
(1)参数传递:将argv[3](字符串)通过%rdi传递给atoi函数。
(2)控制传递:通过call atoi@PLT函数,进行函数调用。
(3)函数返回:从atoi中返回。
3.4 本章小结
本章主要介绍了关于编译的内容,使用gcc生成.s文件。并分析了.s文件中,c语言的数据与操作在机器之中如何被处理执行,为下一步的汇编做了准备工作。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
- 概念
汇编指的是汇编器(Assembler)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标文件,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,包含hello程序执行的机器指令。
- 作用
把.s汇编代码转变成机器语言。机器语言是计算机能够看懂的语言。
4.2 在Ubuntu下汇编的命令
打开一个新的中断
Gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
打开新终端
readelf -a hello.o > hello_o_elf.txt
把hello。O的ELF格式读到txt中
- ELF头
包括节的全部信息
- 重定位信息
重定位是将EFL文件中的未定义符号关联到有效值的处理过程。在hello.o中,对printf,exit等函数的未定义的引用和全局变量(sleepsecs)替换为该进程的虚拟地址空间中机器代码所在的地址
- 符号表
.symtab用来存放程序中的函数或者全局变量的信息,需要重定位的符号都得在这里声明
4.4 Hello.o的结果解析
打开新的终端
objdump -d -r hello.o > hello_o.objdump 得到反汇编代码文件
将新生成的文件与.s文件对照分析
- 分支转移
段名称知识一种助记符,在汇编成机器语言之后使用得是确定得地址
- 函数调用
在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0,然后在.rela.text节中为其添加重定位条目,在链接后再进一步确定。
- 全局变量访问
在.s文件中,访问.rodata(printf中的字符串),使用段名称+%rip,在反汇编代码中0+%rip,因为.rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
4.5 本章小结
本章主要介绍了hello从.s到.o得汇编过程,查看ELF格式并进行了objdump与.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的格式
打开新的终端
Readelf -a hello > hello.elf
把hello得elf格式读到hello.Elf中
5.4 hello的虚拟地址空间
使用edb,在datadump中可以看到虚拟地址
可以知道ELF被映射到了0x401000
5.5 链接的重定位过程分析
打开新的终端
objdump -d -r hello > hello.objdump
为了得到hello得反汇编文件
通过对比hello.o与objdump可以得到一下结论
hello.objdump多一些节
之前未重定位的全局变量引用、过程调用、控制转移全部定位了。
链接过程:
链接函数:在使用 ld 命令链接的时候,指定了动态链接器为 64 的 /lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o 中主要定义了程序入口_start、初始化函数_init,_start 程序调用 hello.c 中的 main 函数,libc.so 是动态链接共享库, 其中定义了 hello.c 中用到的 printf、sleep、getchar、exit 函数和_start 中调用的 __libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入。
函数重定位:链接器解析重定条目时发现对外部函数调用的类型为 R_X86_64_PLT32的重定位,此时动态链接库中的函数已经加入到了 PLT 中,.text 与.plt节相对距离已经确定,链接器计算相对距离,将对动态链接库中函数的调用值改为 PLT 中相应函数与下条指令的相对地址,指向对应函数。对于此类重定位 链接器为其构造.plt 与.got.plt。
.rodata 引用:链接器解析重定条目时发现两个类型为 R_X86_64_PC32 的 对.rodata 的重定位(printf 中的两个字符串),.rodata 与.text 节之间的相对距离确 定,因此链接器直接修改 call 之后的值为目标地址与下一条指令的地址之差,指向相应的字符串。
5.6 hello的执行流程
使用edb查看执行程序hello
利用stepover一步步查看代码
5.7 Hello的动态链接分析
·动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。如果一个目标模块调用定义在共享库中的任何函数,那么就有自己的GOT和PLT。
·PLT是一个数组,其中每个条目是16字节代码。每个库函数都有自己的PLT条目,PLT[0]是一个特殊的条目,跳转到动态链接器中。从PLT[2]开始的条目调用用户代码调用的函数。
·GOT也是一个数组,每个条目是8字节的地址,和PLT联合使用时,GOT[2]是动态链接在ld-linux.so模块的入口点,其余条目对应于被调用的函数,在运行时被解析。每个条目都有匹配的PLT条目。
·在函数调用时,首先跳转到PLT执行。plt中操作,第一次访问跳转时GOT地址为下一条指令,将函数序号入栈,然后跳转到PLT[0],之后将重定位表地址入栈,访问动态链接器,在动态链接器中使用在栈里保存的函数序号和重定位表计算函数运行时的地址,重写GOT,返回调用函数。之后如果还有对该函数的访问,就不用执行第二次跳转,直接参看GOT信息。
5.8 本章小结
本章在本章中主要介绍了链接的概念与作用、hello 的 ELF 格式,分析了 hello 的 虚拟地址空间、重定位过程、执行流程、动态链接过程,让人感叹计算机系统的复杂、精巧。
经预处理、编译、汇编、链接,一个完整的hello诞生了
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
- 概念
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。
- 作用
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell 是一个用C 语言编写的程序,他是用户使用Linux 的桥梁。
处理流程:1)从终端读入输入的命令。2)将输入字符串切分获得所有的参数3)如果是内置命令则立即执行4)否则调用相应的程序为其分配子进程并运行5)shell 应该接受键盘输入信号,并对这些信号进行相应处理
6.3 Hello的fork进程创建过程
首先在终端中键入命令./hello 7203610115 翟玉铂,shell通过上述流程处理该命令,判断这不是内置命令,会认定这是当前目录文件下的可执行目标文件hello,之后终端会调用fork函数创建一个新的子进程,新的子进程几乎但不完全和父进程相同,子进程可以得到父进程用户级虚拟地址空间相同(但独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈,子进程还获得与父进程任何打开文件描述符相同的副本,意味着子进程可以读写父进程中打开的任何文件。他们最大的不同是拥有不同PID。
父进程和子进程是并发运行的独立进程,内核可以任意方式交替执行他们的逻辑控制流中的指令,所以这会导致我们不能简单的凭直觉判断指令执行的顺序。
父进程会默认等待子进程执行完之后回收子进程,但是也会有产生僵死进程的情况,父进程可以调用waitpid函数等待其子进程终止或停止。
6.4 Hello的execve过程
execve函数在新创建的子进程的上下文中加载并运行hello程序。execve函数的功能是加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有发生错误时execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次且从不返回。
execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行程序。
具体步骤包括四步:1.删除存在得用户区域 2.映射私有区域 3.映射共享区域 4.设置程序计数器
6.5 Hello的进程执行
- 上下文信息
上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成
- 进程时间片
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
- 用户模式与内核模式
处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
在hello运行过程中,若hello进程不被抢占,则正常执行;若被抢占,则进入内核模式,进行上下文切换,转入用户模式,调度其他进程。
- Hello sleep进程的调度过程
在调用sleep函数之前,如果没有别的原因使得scheduler将hello挂起的话,sleep函数会显式地请求kernel将hello挂起,此时进入内核模式切换到其他进程,切换回用户模式运行那个进程,直到atoi(argv[3])秒之后,收到了sleep结束的信号,又会切换到内核模式,回到hello,输出我的学号和姓名,接着又一次调用sleep函数。
6.6 hello的异常与信号处理
- 正常执行结果
- 程序执行过程遇到ctrl+c
父进程收到SIGINT信号,中止hello进程,并且回收hello进程
- 程序执行过程遇到ctrl+z
- 再执行pstree命令
- 再执行jobs,fg,ps,kill指令
- 再乱按
6.7本章小结
本章中主要介绍了进程的概念以及进程在计算机中的调用过程。介绍了shell-bash的作用和一般处理流程,还介绍了进程中两个关键的抽象:逻辑控制流和私有空间。执行hello时的fork和execve过程。分析了hello的进程执行和异常与信号处理过程。通过运行hello程序,介绍了异常控制流的通知机制信号。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
- 逻辑地址
逻辑地址是指由程序产生的与段相关的偏移地址部分。逻辑地址由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离,表示为 [段标识符:段内偏移量]。
- 线性地址
线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
- 虚拟地址
虚拟地址是程序保护模式下,程序访问存储器所使用的逻辑地址称为虚拟地址,与实地址模式下的分段地址类似,虚拟地址也可以写为[段:偏移量]的形式,这里的段是指段选择器。
- 物理地址
CPU通过地址总线的寻址,找到真实的物理内存对应地址。计算机主存被组织成由M个连续字节大小的内存组成的数组,每个字节都有一个唯一的地址,该地址被称为物理地址。在前端总线上传输的内存地址都是物理内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由两部分组成:段选择符 + 段内偏移量
- 段选择符
由16位长的字段组成。前13位是索引号TI:0是GDT,1是LDT.。index指出选择哪个条目,RPL请求特权级。
- 段内偏移量
最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。
7.3 Hello的线性地址到物理地址的变换-页式管理
Linux下线性地址到物理地址的转换是通过分页机制实现的。内核为每一个进程维护了单独的任务结构(task_struct),其中包括了指向内核运行该进程所需要的所有信息(PID,指向用户栈指针,可执行目标文件,程序计数器),其中我们关心的是一个mm条目,指向mm_struct,他描述了当前进程的虚拟内存状态,其中pgd指向第一级页表基址,mmap指向一个vm_area_structs(区域结构)的链表,每一个vm_area_structs都描述了当前虚拟地址空间的一个区域。具体结构如下
注意到每个区域都有一些相同的条目:
Vm_start 指向该区域起始处
Vm_end 指向该区域终止处
Vm_prot 描述该区域包含所有页的读写许可权限
Vm_flags 描述了该区域页面是否私有\共享及其他信息
Vm_next 指向下一个区域
总的来说,一个Linux进程的虚拟内存结构如下
分页机制:通过将虚拟和物理内存分页,并且通过MMU建立起相应的映射关系,可以充分利用内存资源,便于管理。我们将物理内存分成4KB大小的页帧。之前所说线性地址和物理地址都是32位的(不涉及多级页表和TLB),n位的虚拟地址分成VPN+VPO,VPN是虚拟页号,VPO表示页内偏移。一个页面大小是4KB,所以VPO需要20位,VPN需要32-20=12位。CPU中的一个控制寄存器PTBR(页表基址寄存器)指向当前页表,MMU通过VPN来选择PTE(Page Table Entry),PTE(假设有效)中存放的即物理页号(PPN)。VPO和PPO是相同的。所以通过MMU,我们得到了线性地址相应的物理地址。注意页表一定是连续的,如果我们找到的PTE中有效位为0,MMU触发一次异常,调用缺页异常处理程序,程序更新PTE,控制交回原进程,再次执行触发缺页的指令。
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支持下的物理内存访问
下面我们将给出从VA到取回数据的整个过程。
首先VA经过MMU地址翻译:
CPU给出VA
MMU用VPN到TLB中找寻PTE,若命中,得到PA;若不命中,利用VPN(多级页表机制)到内存中找到对应的物理页面,得到PA。
PA分成PPN和PPO两部分。我们利用其中的PPO,将其分成CI和CO,CI作为cache组索引,CO作为块偏移,PPN作为tag。Cache的读写命中\不命中都有对应的处理流程。
7.6 hello进程fork时的内存映射
内存映射:Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射。虚拟内存可以映射到两种类型的对象。
Linux文件系统中的普通文件:如可执行文件
文件区被分割成页大小的片,若页比文件大,用零填充。按需进行页面调度
匿名文件:由内核创建的一个全二进制零文件。
共享区域和私有区域的内存映射:
若多个进程共用一个对象,并且都将该对象作为共享对象。任意一个进程对该对象的操作对其他进程而言都是可见的。,同时也会反应在磁盘的原始对象中。
若多个进程共用一个对象,并且都将该对象作为私有对象,任意一个进程对该对象的操作对其他进程而言都是不可见的。linux系统通过写时复制机制减少内存开销。
Fork函数内存映射:
Fork函数被当前进程调用时,内核为新进程创建各种数据结构,并且分配唯一PID。为了给新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表原样副本,将两个进程中的每个页面到标记为只读,同时设置每个区域结构为私有对象。
7.7 hello进程execve时的内存映射
利用execve函数加载hello程序:
删除已存在的用户区域:删除当前进程虚拟地址用户部分的区域结构
映射私有区域:为新程序的代码、数据、.bss和栈区域创建新的区域结构,同时标记为私有的写时复制的。代码和数据段映射到hello的.text及.data段,.bss请求二进制零,映射到匿名文件,其大小在第5章链接中的程序头部表中,堆栈也是请求二进制零,初始长度为零。
映射共享区:hello与系统执行文件链接,如lib.so,这部分映射到共享区域。
设置程序计数器PC:设置当前进程上下文中的PC,指向entry point。
7.8 缺页故障与缺页中断处理
缺页故障处理:当CPU发送一个VA,发现对应的物理地址不在内存中,必须从磁盘中取出,就会发生故障,缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。
缺页中断处理:确定缺页是由于对合法虚拟地址进行合法的操作造成之后,系统选择一个牺牲页面,如果这个牺牲页面被修改过,将其交换出去,换入新页面并更新页表,当缺页处理程序返回后,CPU重启引起缺页指令。
7.9动态存储分配管理
动态存储分配管理由动态内存分配器完成。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆是一个请求二进制零的区域,它紧接在未初始化的数据区后开始,并向更高的地址。分配器将堆视为一组不同大小的块的集合来维护
- 隐式空闲列表
头部一共四个字节,前三个字节存储的是块的大小,最后一个字节存储的是当前这个块是空闲块还是已分配的块,0代表空闲块,1代表已分配的块。中间的有效载荷就是用于存放已分配的块中的信息用的。最后的填充部分是为了地址对齐等一些要求用的。
隐式链表的结构就是根据地址从小到大进行连接的,如图7-11所示。其中的每一个元素表示的是一个空闲块或者一个分配块,由于空闲块会合并的特性,链表中的元素的连接一定是空闲块的分配块交替连接的。
- 显式空闲链表
显示结构在空闲块中增加了8个字节,分别保存当前空闲块的前驱空闲块的地址和后继空闲块的地址。显式的结构比隐式结构多维护了一个链表,就是空闲块的链表。这样做的好处就是在malloc的时候,隐式的方法是要遍历所有的块,包括空闲块了分配块。但是显式的结构只需要在空闲块中维护的链表检索就可以了,这样降低了在malloc时候的复杂度。
关于空闲块的维护方式一共有两种,一种是后进先出的方式,另一种是按照地址的方式。按照地址维护很好理解,与隐式的结构大致相同。后进先出的方式的思想是,当一个分配块被free之后,将这个块放到链表的最开头,这样在malloc的时候会首先看一下最后被free的块是否符合要求。这样的好处是释放一个块的时候比较高效,直接放在头部就可以。
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 I/O接口:
打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入,标准输出,标准错误。
改变当前文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时执行读操作时触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件:当应用完成了访问,它就通知内核关闭这个文件,并释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O函数:
int open(char* filename,int flags,mode_t mode) :进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
返回:若成功则为新文件描述符,若出错为-1。
int close(fd),fd是需要关闭文件的描述符。
返回:若成功则为新文件描述符,若出错为-1。
ssize_t read(int fd,void *buf,size_t n),该函数从描述符为fd的当前位置最多赋值n个字节到内存buf的位置,返回值为实际传送的字节数量。返回:若成功则为新文件描述符,若出错为-1。
ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
返回:若成功则为新文件描述符,若出错为-1
8.3 printf的实现分析
printf需要做的事情是:接受fmt的格式,然后将匹配到的参数按照fmt格式输出。下面是printf的代码,我们可以发现,他调用了两个外部函数,一个是vsprintf,还有一个是write。
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;
}
而经过分析可以得出,vsprintf的作用是将所有的参数内容格式化后存入buf,然后返回格式化数组的长度。write函数是将buf中的i个元素写到终端的函数。
那么我们便知道了printf的运行过程:从vsprintf生成显示信息,显示信息传送到write系统函数,write函数再陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序。从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成ASCII码,保存到系统的键盘缓冲区之中。
下面展示了getchar的代码,可以看出,这里面的getchar调用了一个read函数,这个read数是将整个缓冲区都读到了buf里面,然后将返回值是缓冲区的长度。我们可以发现,如果buf长度为0,getchar才会调用read函数,否则是直接将保存的buf中的最前面的元素返回。
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
}
8.5本章小结
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,了解了 printf 函数和 getchar 函数的实现。
(第8章1分)
结论
hello虽然简单,但它的执行过程却不简单。预处理时需要预处理器将源程序中的宏定义进行替换和插入;编译过程中程序由高级语言转变成了汇编语言,无论是逻辑还是程序的表达方式都大有变化;汇编把汇编语言翻译成机器可以明白的机器语言;链接把hello运行所依赖的各种预编译库都加入到了机器程序中;hello从开始执行到绥中结束,都离不开shell和内核的进程管理;hello能从硬盘读到内存,再进入CPU运行,全依赖系统的储存管理;hello可以和用户进行交互,离不开系统对I/O的管理。不仅是hello,其它的计算机程序也是如此,若想完美执行,必须依赖于及计算机系统对程序执行每一步的精准管理。在惊叹于计算机系统强大功能的同时,通过本次实验,我也更深入地理解了计算机系统的工作原理。
附件
文件名 | 文件说明 |
Hello.c | 源程序。 |
Hello.i | 源程序的预处理产物。 |
Hello.s | hello.i编译得到的汇编程序。 |
Hello.o | hello.s汇编得到的可重定位目标程序。 |
Hello_o_asm.s | hello.o反汇编得到的汇编程序。 |
Hello | hello.o链接得到的可执行目标程序。 |
Hello_asm.s | hello反汇编得到的汇编程序。 |
(附件0分,缺失 -1分)
参考文献
[1] (美)Randal E. Bryant等著. 深人理解计算机系统(原书第3版)[M]. 龚奕利,贺莲译. 北京:机械工业出版社, 2016.7.
[2] FZHvampire. CSAPP大作业:程序人生-Hello's P2P [M/OL], 2019 [2019-01-05]. https://blog.csdn.net/fzhvampire/article/details/85810811.
[3] Pianistx. printf 函数实现的深入剖析 [M/OL],2013 [2013-09-11]. https://blog.csdn.net/fzhvampire/article/details/85810811.
[4] sorvik. 寄存器1 [M/OL],2020 [2020-02-20]. https://www.minazuki.cn/post/blog_os/blog_os-1btagnqm2aabq/blog_os-1btago8uhn9is/
(参考文献0分,缺失 -1分)