注:由于csdn限制,本文中图片无法上传,下面有含有图片的相关pdf链接上传在github上
csapppdf/ICS大作业论文.pdf at master · dv3lood/csapppdf (github.com)
本文是哈尔滨工业大学计算机系统课程的大作业。计算机系统采用深入理解计算机系统作为教材,通过对于计算机各方面的详细介绍,让我们加深对计算机的掌握程度,达到融会贯通的目的。本论文分为八个章节,按照hello程序的生命历程,从c源代码直到最终在操作系统中终止的全过程进行了详细的阐述,作为对于计算机系统课程的回顾与梳理。
关键词:计算机系统;操作系统;内存管理;程序产生过程
目 录
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是指program to progress,指系统将程序源文件变为最终可执行程序的过程。程序通过一系列的过程,预处理、编译、汇编、链接而生成可执行文件,在shell中执行可执行文件,而后由系统创建其进程也就是progress。
020:
020是指zero to zero,指从程序的运行到程序的终止不会残留任何空间占用。在执行本程序前,本程序相关代码仅会存在于硬盘中,而不存在与其他结构,这是第一个0。当执行本程序时,shell通过fork,execve进行执行,依次进行虚拟内存映射等操作,而后执行本程序中的main代码,程序调用一系列函数直至main停止,程序终止,shell父进程回收这个子进程,相关内容都会被释放,所以最后也是0。
1.2 环境与工具
1. 硬件环境:i7-9750H;16GB RAM; 512G Disk
2. 软件环境:win10专业版;VirtualBox 6.1.22;Kali Linux 20.04(Based on Ubuntu 20.04 LTS)
3. 工具:objdump;gdb;ld;readelf;objdump等
1.3 中间结果
hello.c:源代码文件
hello.i:预处理后文本文件
hello.s:编译后汇编文件
hello.o:汇编后可重定位的目标执行文件
hello:链接后的可执行文件
readelf-a.txt:链接前的readelf输出内容
readelf-b.txt:可重定位目标文件的readelf输出内容
objdump.txt:对hello的反汇编文件
objdump-a.txt:对hello.o的反汇编文件
1.4 本章小结
本章主要介绍了hello的p2p与020过程,以及完成本次大作业所使用的软硬件环境和开发工具等,并详细列举了每一个提交的文件的文件名及其含义。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理是指编译器在编译前,对程序中的预处理指令如#define等语句进行相应的替换,与此同时也会删去程序中的注释等比必要的内容,最终产生预处理后的文件。
预处理的作用:
- 宏展开:将程序中由#define定义的宏常量与宏函数进行展开替换;
- 文件包含:将#include引入的文件内容复制进入本文件的原位置;
- 条件编译:根据定义的宏等来对$ifdef,$endif等条件宏定义进行解析,删除条件失败的语句块。
2.2在Ubuntu下预处理的命令
指令为:gcc -E -o hello.i hello.c
图2-1 预处理指令运行
经过预处理展开后的文件过大,故仅仅简单截取部分截图以展示其预处理过程。
图2-2 预处理展开后文件内容
2.3 Hello的预处理结果解析
预处理后添加了大量的内容,文件达到了3060行。
图2-3 预处理文件包含行数
其中添加的内容为处理以#开头的预处理指令后,如#include添加的文件内容,或者#define定义的宏展开后的结果,以及其他等等预处理指令添加的内容。本文件中增加的主要内容为三个include头文件的添加。
2.4 本章小结
本章中详细叙述了预处理的概念和作用,通过在linux下对hello.c文件进行预处理,以实际实例阐释了预处理的过程,并对hello.i文件中添加的内容进行了说明。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译是指将经过预处理后的程序进行相关的语义分析与合法性检查,将其翻译甚至优化称等价的汇编代码的过程。
编译的作用:
- 通过对语句进行词法分析、语法分析,解析程序结构并产生相应的汇编代码;
- 对程序中可以进行优化的部分适当优化,进行部分等价变换,以达到优化程序的目的。
3.2 在Ubuntu下编译的命令
指令为:gcc -S -o hello.s hello.i
图3-1 编译指令运行
下面为hello.s文件截图:
图3-2 编译后汇编代码图1
图3-3 编译后汇编代码图2
3.3 Hello的编译结果解析
图3-4 常量字符串
- 常量字符串:在hello.s中,第6与8行处为保存的常量字符串,输入.rodata节,与之对应的是hello.c中16与18行printf的参数字符串。其中字符串保存的是utf-8编码格式的字符串,我们可以看到汉字在这里是由三个字节表示的,每一个\xxx\xxx\xxx代表一个汉字。
图3-5 循环变量i
- 循环变量i:我们可以通过.c文件的第27行中循环变量的引用来判断i的存储位置,与之对应的汇编代码在.s文件的30行,我们可以判断i存储在-4(%rbp)中。
图3-6
图3-7
- 函数传递与调用:argc与argv根据汇编代码查看可知,与.c文件13行向对应的.s文件23行,看到其访问argc是-20(%rbp),以及33~40行对于argv[1],argv[2]的引用,我们可以知道main的参数是保存在之前的栈中的。在看对于printf函数的调用,查看.s文件33~42行中,我们可以看到施加给你指定的参数放在%rdi,%rsi中进行传递的。通过在课上的学习我们知道64位程序的前6个参数是会使用寄存器进行传递,而之后的参数会使用栈进行传递。
图3-8
- 算术操作:.c文件中的for循环中的i++是一种算术操作,与之对应的是.s文件中的第50行,通过addl指令进行操作,本指令表示的是,将%rbp-4处存的4字节数加一。
图3-9
- 选择语句(关系操作):如图3-9,选择语句由一个计算语句与跳转语句构成,计算语句会将CPU中相应的符号寄存器赋值,而后的跳转语句会根据符号寄存器进行跳转。特别的是,计算语句可以被test语句等替换,test语句不会修改寄存器或内存的真实内容,仅会将符号寄存器赋值。
图3-10
- 循环结构:for语句会被翻译位如图3-10的汇编代码结构。首先是对于循环变量的初始化,这里是i=0和mov $0,-4(%rbp),而后进行循环体的相关操作,直至汇编代码中的.L3标记,这时循环体内部操作结束,进行相关的条件检查,这里是将i与7进行比较,如果i<=7就跳转至.L4,否则就结束了循环。
图3-11
- 数组操作:如图3-11,本程序中对于argv[2]的引用,首先将argv的地址读入%rax,而后将%rax加16,也就是两个char*的长度,而后通过(%rax)将这个算出的地址所存储的内容取出。
图3-12
- 函数返回:将返回值或者返回的指针保存在%eax(%rax)中进行返回。
3.4 本章小结
本章中详细叙述了编译的概念和作用,通过在linux下对hello.i文件进行编译,以实际实例阐释了编译的过程,并详细分析了将c语言程序中的数据类型与操作翻译为汇编语言时的表示。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编是指将由编译步骤生成的.s汇编语言程序翻译为机器指令,最终生成可重定位目标文件.o的过程。
汇编的作用:将计算机无法识别的汇编语言翻译为其能够执行的机器指令。
4.2 在Ubuntu下汇编的命令
图4-1 汇编指令运行
下面为hello.s文件截图:
图4-2 汇编后代码图1
图4-3 汇编后代码图2
4.3 可重定位目标elf格式
指令为:readelf -a hello.o > readelf-a.txt
图4-4 readelf指令生成信息输入文件(部分文件内容)
接下来我们详细分析其内容。
4.3.1 ELF Header
图4-5 ELF Header
- magic是幻数,用于表示二进制文件格式,以供操作系统或其他程序识别。其中7f 45 4c 46分别对应del,E,L,F,之后的几位为大小端序标识和版本号标识等,最后九个字节为零,未定义;
- 接下来分别是从这个magic中读取的内容,如class,data,version,OS/ABI,ABI Version等;
- 接下来是入口点地址以及程序头起点,在本例中入口地址为0x0,入口起点为0(由于是可重定位目标文件);
- 接下来写明了不同头的大小等。
-
-
- Section Headers
-
图4-6 Section Headers
- 对于本例中的可重定位目标文件,每个节都会从0开始,便于重定位;
- 在所有节中,仅.text有X flag,可以执行;
- 在所有节中,仅有.data和.bss有W flag,可写;
- .rodata段不可执行也不可写;
- .bss段大小为零。
-
-
- .rela.text .rela.eh_frame
-
图4-7 重定位节
在此节中我们可以看到需要重定位的符号,如puts,exit,printf,sleep,getchar等注意到其中的type仅有两种,即相对寻址和PLT表寻址。
-
-
- .symtab
-
图4-8 符号表节
本节中存储了本程序中相关的符号信息,如10~16为UNDEFINE的,也就是外部符号,main是本地符号,同时标明了type是FUNC。
4.4 Hello.o的结果解析
使用指令objdump -d -r hello.o生成相关的objdump-a.txt,其内容如下:
图4-9 objdump图1
图4-10 objdump图2
将左右两个汇编代码与机器码进行比较,我们可以得到其差别:
- 分支跳转:在机器码中存储的并不是跳转的绝对地址也不是在源文件中的代码名称,而是与其下一条指令的地址差,也就是相对地址;
- 函数调用:在可重定向目标文件中,对于函数的调用是将源文件中的函数名称替换为0,并在相应位置添加相应的重定向条目以便于后面的汇编器使用;
在本文件中没有另外一种情况,也就是对于全局变量的访问,与函数调用类似的是,它也将相应的字符名称替换为0,添加相应的重定向条目。
4.5 本章小结
本章介绍了汇编的概念与作用,以及linux中执行汇编的相应指令。并且通过对于readelf的输出进行了细致的解析,最终通过将原文件以及objdump的反汇编文件进行比较,分析编译后机器代码产生的区别。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是将各种可重定向目标文件以及其他一些链接库等文件进行重新组合生成单一可执行文件的过程。
链接的作用:对于可重定向目标文件中的相应符号进行解析,以及进行符号冲突的处理,如强弱符号关系的处理,与此同时对于每个符号进行相关的重定位操作以使得相关程序可以执行。
5.2 在Ubuntu下链接的命令
图5-1 链接指令运行
5.3 可执行目标文件hello的格式
指令为:readelf -a hello > readelf-b.txt
图5-2 readelf生成文件
图5-3 ELF Header
与前文的格式完全一致,但我们可以看到器header数显然增加了,这时由于我们向其中添加了一部分函数等内容,并且函数的进入点也进行了相应的变化,从0x0变成了0x401090。
图5-4 Section Headers1
图5-5 Section Headers2
上述两张图中介绍了section headers的内容,与前文相比多了12个header,其中主要添加了如.got.plt等内容,其为添加的got表和plt表。
图5-6 Program Headers
这里是新的节,存储着程序相应各页的头部。
图5-7 segment mapping
本届存储着由节到段的映射表。
图5-8 节头
本部分存储着相应的属于动态链接库部分的节头。
图5-9 引用的plt表
图5-10 动态符号
本部分存储着引用模块的相应plt表以及动态符号相关内容。
图5-11 符号表1
图5-12 符号表2
与上文类似,本部分存储着本程序相关的符号表。
图5-13 剩余部分
5.4 hello的虚拟地址空间
使用edb加载hello文件。
图5-14 edb加载hello程序
图5-15 edb窗体
图5-16 查看data dump窗体
图5-17 符号查看内存
图5-17中根据edb提供的符号表,观察其在内存中的内容。
通过edb进行相关的操作,可以看出其中段的排布方式与图5-4、图5-5中的内容顺序完全一致,所以可以判断其正确性。
5.5 链接的重定位过程分析
图5-18 objdump图1
图5-19 objdump图2
图5-20 objdump图3
图5-21 objdump图4
图5-22 objdump图5
图5-23 objdump图6
分析上图中的内容与之前hello.o的反汇编内容进行比较,我们可以发现一些区别:
- hello的反汇编代码中已将hello.o中待重定向的地址进行设置,已经是最终的确定的地址;
- 在hello的反汇编代码中多出了相应的节头:
- .gnu.hash :gnu的扩展符号的哈希表
- .dynsym:动态符号表
- .gnu.version:gnu版本节
- .interp:ld.so的引用
- .plt:存储相应plt表
- .init:程序初始化相关指令
- .got:存放本程序的got表
- .got.plt:存放本程序的函数plt表
- 还用其他的相应节头不在此一一列举。
- 与此同时,在hello文件中也删除了相应的无用节如.rel节。
重定向的过程:
- 首先,链接器将所有代链接的文件中所有节按照相同类型结合,而后将新文件的运行地址赋给每个新的节头;
- 其次,修改在所有代码中被标记的每个带引用或代重定位的符号,使其变为真正可执行的文件。
重定位条目:在教材中主要介绍了两种重定位类型:
- R_X86_64_PC32:重定位一个相对地址的引用;
- R_X86_64_32:重定位一个绝对地址的引用。
5.6 hello的执行流程
ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
libc-2.27.so!__libc_start_main
-libc-2.27.so!__cxa_atexit
-libc-2.27.so!__libc_csu_init
hello!_init
libc-2.27.so!_setjmp
libc-2.27.so!__sigjmp_save
hello!main
hello!puts@plt
hello!exit@plt
*hello!printf@plt
*hello!sleep@plt
*hello!getchar@plt
ld-2.27.so!_dl_runtime_resolve_xsave
-ld-2.27.so!_dl_fixup
-ld-2.27.so!_dl_lookup_symbol_x
libc-2.27.so!exit
5.7 Hello的动态链接分析
图5-24 dl_init前got表
图5-25 dl_init后got表
通过比较执行dl_init函数前后的got表,我们可以看到其对于指定内存块的改动,发现在0x403fe8与0x404008行出现了变化,我们可以看到0x403fe8中后半部分存储的内容为0x7ff4c1d2fc20是相应的地址,并且0x404008也是相应的地址变化,即0x7ff4c1f14180、0x7ff4c1efe540。
接下来通过教材中的相应学习对其进行分析,动态链接器通过PLT和GOT进行相应的逻辑访问来进行动态加载操作。
5.8 本章小结
本章介绍了链接的概念和作用,通过在linux执行链接操作相关的命令,以及通过readelf工具查看hello的elf,并对其进行了较为详细的分析,并与上章中相应部分进行了比较。而后使用objdump进行了反汇编操作,并对其进行了比较和分析。最后通过edb对于hello的执行过程进行相关跟踪,并且对于其动态连接过程进行了相应的分析。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程是指一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正常运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程的作用:
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;
- 一个私有地地址空间,它提供一个假象,好像我们地程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个应用程序,是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令。shell不仅能够解释用户输入的命令,还能通过管道与重定向机制,将数据在多个程序和文件中进行传递。在实际应用中,shell是我们与内核交互的用户界面,只有通过shell我们才能够访问到我们所要执行或查找的内容。
shell的处理流程:
- shell首先读入这个输入的字符串,将字符串切分以获得相关参数;
- 判断该命令行地第一个单词是否是一个内置地shell命令,如果是,就直接执行;
- 否则,shell就会假设这是一个可执行文件的名字,它将加载并运行这个文件,也就是分配一个子进程去执行这个文件。
6.3 Hello的fork进程创建过程
在用户输入了相关指令之后,也就是“./hello 学号 姓名 秒数”,按照上面的处理流程shell开始进行解析。首先shell将读入的字符串按空格进行分割,而后判断第一个指令是否是内置指令,这里的./hello显然不是一个内置指令,所以通过此相对路径进行查找,找到了当前目录下的文件hello,而后shell会调用fork函数创建一个父进程为自身的新的子进程。通过学习我们知道,子进程中会有着和父进程一样的各种数据的复制,但是并不是相同的,当子进程想要对其修改时会通过一种写时复制技术进行修改,保证了子进程与父进程之间数据的相对独立性。这里子进程在最初始阶段与父进程最大的不同就是它们有着不同的PID。
6.4 Hello的execve过程
在fork执行后产生的子进程中,子进程会调用execve函数执行前面解析的文件,同时将参数通过函数传参的形式传递给execve函数。在执行execve函数之后,它会依次执行以下步骤:
- 删除已存在的用户区域,即删除当前进程中用户部分已存在的数据结构;
- 映射私有区域,为新程序的代码、数据、bss和栈创建新的区域结构,并且这些新的结构都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,.bss、栈和堆映射到了匿名文件(请求二进制零页);
- 映射共享区域,这里的hello文件会与libc等进行动态链接,这些共享对象会先动态链接到这个程序,然后在映射到用户虚拟空间的共享区域内;
- 设置PC,execve函数执行的最后一步是将当前上下文中的程序计数器设置为指向新的代码区域的入口点。
6.5 Hello的进程执行
进程上下文信息:一个进程中的上下文就是内核重新启动一个被强占的进程所需状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈以及各种内核数据结构,如页表、进程表与文件表。
进程时间片:一个进程处在执行状态的所有时间片段。
进程调度的过程:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中被称作调度器的代码处理的。当内核选择了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来讲控制转移到新的进程。
上下文切换:首先系统保存当前进程的上下文,而后恢复某个先前被抢占的进程被保存的上下文,最后将控制传递给这个新恢复的进程。
用户模式与内核模式:处理器通常是通过一个在控制寄存器中的一个模式位提供内核模式这种功能。当设置了模式位时,进程就运行在内核模式中(有时也叫超级用户模式),一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置;没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程给你直接引用地址空间中内核区内的代码和数据。任何这样的操作都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
用户模式与内核模式的转换:与运行应用程序代码的进程初始时实在用户模式中的。进程从用户模式变为内核模式的唯一方法就是通过诸如终端、故障或者陷阱这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式该回到用户模式。
6.6 hello的异常与信号处理
图6-1 键盘乱按
图6-2 ctrl-z
图6-3 jobs
图6-4 ps
图6-5 pstree
图6-6 fg
图6-7 ctrl-c
图6-8 kill
以上从图6-1到图6-8分别执行了不停乱按(包括回车),Ctrl-Z,Ctrl-C,Ctrl-Z执行ps,jobs,pstree,fg,kill等操作。在以上的相应处理中,我们可以依次分析其程序运行方式。
- 不停乱按(包括回车):在hello执行过程中,不停乱按甚至于回车都是没有反应的,在这个过程中,程序将来自shell的命令读入并忽略。
- ctrl-c:从键盘输入ctrl-c命令会让内核发送信号SIGINT给前台进程组也就是hello所属的进程组,在程序中由于没有指定特定的异常处理函数,所以调用默认的异常处理,SIGINT所对应的处理是将终止其所在程序,所以hello终止了。
- ctrl-z及相关操作:从键盘输入ctrl-z命令会让内核发送信号SIGSTP给hello所属的进程组,默认的运行是让本程序挂起但是不终止。这时我们可以通过ps执行发现该进程仍然存在;通过调用jobs指令观察后台进程可以发现这个hello进程;pstree也可以发现本进程在进程树的位置;通过fg程序可以将已挂起的程序重新恢复执行,实际执行的方式是由内核向程序发送SIGCONT信号使其恢复,并在图中可以看到hello正常执行;最后通过kill操作发送9也就是SIGKILL信号给hello程序,观察到其停止运行。
6.7本章小结
在本章中,详细地介绍了从bash读入指令,到进程创建,再到最后终止的过程。本章中以hello程序为例,分析了程序与信号互相操作的相关机制,并演示了hello对于指定信号和指令的处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:由编译器产生的与段相关的偏移量被称作逻辑地址,也就是相对地址。对于hello程序,就是在hello程序的二进制表示中,每个诸如跳转指令等的操作数就是相对地址;
线性地址与虚拟地址:线性地址是从逻辑地址到物理地址的中间量,同时也被称作是虚拟地址,虚拟地址始于实际物理的内存无关的,每一个进程相独立的地址。
物理地址:指的是由MMU直接控制的Cache中的相关地址,对于每台计算机来说每个物理地址都是唯一对应于某个物理结构的,是地址查询的最后一步。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由段标识符与偏移量构成。段标识符是一个16位长的字段,前13位是一个索引号,程序可以通过前面的索引号在段描述符表中找到具体的段头位置地址,这个表有两种,对于全局的段描述符是GDT表,用于存放整个系统共用的描述符,存放在gdtr控制寄存器中,而对于局部的段描述符是LDT表,用于存放用户进程,存放在ldtr控制寄存器中。这样,我们获得了的段头地址,再结合后续的32位的偏移量,我们就能够获得相应的线性地址(虚拟地址)了。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存系统通过将虚拟内存分割为一系列虚拟页,每个虚拟页的大小为P=2^p,虚拟内存是一块在磁盘上的连续区域组成的数组。在现代计算机中,系统普遍采用分页机制将虚拟内存分页,并通过MMU建立从虚拟地址(线性地址)到物理地址的相关映射。这个映射主要依靠的是一个被称作PTE(页表)的数组。
图7-1 页表
这个页表是一个有效的通过虚拟地址查询相应物理地址的结构。
图7-2 页面命中与缺页
首先,一个虚拟地址会被分为VPN与VPO,VPO与PPO相同,所以不用进行计算,而VPN值用来插云页表。根据图7-2可知,MMU将处理器传来的虚拟地址进行分割,而后向PTE表中发送VPN进行请求,如果出现异常,就调用缺页异常处理程序进行处理,通过更新PTE表中的缓存内容,最终MMU会从PTE中获取到相应的PPN,PTE会将其与PPO结合而得到最终的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
部分寄存器实现了一种叫做TLB的快表,它是一个对于PTE的高速缓存,位于MMU旁。TLB的运行逻辑与高速缓存的运行逻辑类似,这里不做过多赘述。
随着现代计算机中的内存逐渐增加,其物理地址的数目也在增加,如果仅使用一个PTE表所需要的空间很大,且会有大量的未利用块存在。所以现代计算机基本采用了多级页表的形式,由一级页表查询二级页表,直到最后一级获得物理地址为止。如图是通过Core i7处理器的例子揭示了多级页表的工作流程。
图7-3 多级页表翻译过程
7.5 三级Cache支持下的物理内存访问
如图,为Core i7的内存系统,其为三级高速缓存。
图7-4 Core i7的内存系统
多级缓存中不同级别缓存的工作模式是相同的,以下仅通过一级缓存进行举例说明。
每级缓存是按照以下图结构组织的物理结构。
图7-5 缓存结构模型
一个缓存模块由S组构成(其中S组一定是2的幂),而后在每组中会有E行,每行中分为有效位、标记位和响应的高速缓存快(数据保存位置)。在执行的过程中,CPU会将地址分为三部分,分别是CT,CI,CO分别代表标记,组索引和块偏移。首先通过组索引确定相关的组序号,在遍历组中的每行,先查看其有效位,在查看其标记位,如果有效且标记位与标记相同,那么就直接从缓存中读取相应数据,如果未找到并且有无效的行的话,就从下一级缓存中读取并放入改行,如果没有无效的行,就会根据LRU准则确定一个牺牲行,从下一级缓存中读取并将这一牺牲行覆盖。
7.6 hello进程fork时的内存映射
当shell调用fork函数时,内核会为其分配一个新的且唯一的PID。同时为新进程创建各种数据结构。为了给这个新进程创建虚拟内存,它创建了hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
在执行execve函数之后,它会依次执行以下步骤:
- 删除已存在的用户区域,即删除当前进程中用户部分已存在的数据结构;
- 映射私有区域,为新程序的代码、数据、bss和栈创建新的区域结构,并且这些新的结构都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,.bss、栈和堆映射到了匿名文件(请求二进制零页);
- 映射共享区域,这里的hello文件会与libc等进行动态链接,这些共享对象会先动态链接到这个程序,然后在映射到用户虚拟空间的共享区域内;
- 设置PC,execve函数执行的最后一步是将当前上下文中的程序计数器设置为指向新的代码区域的入口点。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,我们将DRAM缓存不命中称作缺页。缺页所产生的异常属于异常类别中的故障,是由操作系统故意产生的,潜在可恢复的错误。
处理缺页需要硬件和操作系统内核写作完成,分为以下几步:
- 处理器生成一个虚拟地址,并把它传送给MMU
- MMU生成PTE地址,并从高速缓存/主存请求得到它
- 高速缓存/主存向MMU返回PTE。
- PTE中的有效位是零,所以MMU触发了-次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
- 缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。
- 缺页处理程序页面调入新的页面,并更新内存中的PTE
- 缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,在MMU执行了图7-6中的步骤之后,主存就会将所请求字返回给处理器。
图7-6 缺页
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向更高的地址生长。对于每个进程,内核负责维护一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用,空闲块可以用来分配,空闲块保持空闲,直到它显式地被应用所分配。一个已分配地块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它的不同之处在于由哪个实体来负责释放已分配的。
显式分配器:要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。
隐式分配器:分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块,隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
其中,显示分配器必须在一些相当严格的约束条件下工作:
- 处理任意请求序列;
- 立即响应请求;
- 只是用堆;
- 对齐块;
- 不修改已分配的块。
在这些条件下,分配器的编写者试图实现吞吐率最大化和内存使用率最大化,尽管这两个性能目标通常是冲突的。
造成堆利用效率低的主要原因是一种称为碎片的现象,当虽然有未使用的内存但不能用来满足分配请求时,就会发生这种现象。有两种形式的碎片:内部碎片和外部碎片。
7.10本章小结
本章主要介绍了与hello相关的存储管理机制。通过介绍了不同的地址概念、寻址、内存映射、内存分配等内容,详细地阐释了现代计算机关于内存管理的相关机制。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m个字节的序列,所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得输入和输出都能以一种统一且一致的方式的来执行。
8.2 简述Unix IO接口及其函数
Unix I/O接口:
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
- Linux shell创建的每个进程开始时都有三个打开的文件:标准输入,标准输出,标准错误。描述符分别标记位0、1、2。
- 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
- 读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会触发一个称为 EOF的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的EOF符号。类似地,写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为相应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O相关函数:
- 打开文件:int open(char *filename, int flags, mode_t mode);本函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。如果fd==-1说明发生错误;
- 关闭文件:int close(int fd);进程通过调用close函数关闭一个打开的文件。关闭一个已关闭的描述符会出错。
- 读文件:ssize_t read(int fd, void *buf, size_t n);read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
- 写文件:ssize_t write(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确定剩余参数的位置,取…中第一个参数复制给arg,其中va_list被定义为char*。
接下来我们查看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,将其中相应的格式化表示用参数arg进行替换。
最后我们回到原本的prinf函数,查看其调用的最后一步也就是write函数在汇编代码中的实现。
- write:
- mov eax, _NR_write
- mov ebx, [esp + 4]
- mov ecx, [esp + 8]
- int INT_VECTOR_SYS_CALL
这里其通过寄存器传递了相应的参数,然后调用了相应的系统中断调用。
接下来我们查看这个系统中断调用函数的实现:
- init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call, PRIVILEGE_USER);
我们可以发现这个函数需要调用一个称作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
- ret
这个函数的目的对于printf来说就是不断地打印字符,直到遇到’\0’位置。
接下来,会交由驱动相关程序进行操作,它们可以将ASCII字符变为vram,其中存储着每一个点的RGB颜色信息。由显卡按刷新频率逐行读取vram,并向显示屏传输每一个点。
这样,printf打印的东西就显示在了我们的屏幕上。
8.4 getchar的实现分析
我们先查看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函数,并返回buf中的第一个元素或者EOF。
当键盘有数据传入时,内核会产生一次终端一场,交由键盘中断处理子程序进行处理,将键盘输入的电信号转换为相应的符号,保存到系统的缓冲区。
getchar函数通过调用read这一系统函数,通过系统调用读取上文保存的数据,直到获取到EOF时停止。
8.5本章小结
本章通过对于hello I/O相关知识的分析,引申到对于Unix I/O接口的分析,详细列举了linux中I/O的管理方法,以及Unix I/O相关接口和函数。最后通过对于printf和getchar实现的分析详细的揭示了其使用方法。
(第8章1分)
结论
在hello从源码到执行完毕的过程中,经历了大量的过程:
- 预处理:将hello.c中所有预处理指令进行处理,将引入的外部库引入,宏定义替换,并输出为hello.i;
- 编译:将hello.i通过相应的语义语法分析产生等价的汇编代码,生成汇编文件hello.s;
- 汇编:将汇编文件中的汇编代码转换为相对应的机器码文件,并在其中放置重定向相关标识;
- 链接:将汇编生成的可重定向目标文件与其引用的外部库向链接生成最终可以执行的程序hello;
- 加载:通过在shell程序中执行hello程序,其解析命令行,通过fork、execve将hello程序加载进内存;
- 执行:hello在执行过程中按照时间片运行,并在此过程中响应相应的信号;
- 访存:为了将hello程序加载进内存,计算机通过一系列的地址及缓存获取,获取到相关内容;
- 回收:hello程序自动终止或由于收到相应的信号而终止,由shell将已死去的hello进程进行回收,删除该进程产生的所有数据。
看过了这茫茫漫长的执行过程,不禁感叹计算机如此庞大的系统的设计之精美与高效。希望未来我也能为计算机事业的繁荣献上自己的一份力量。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello.c:源代码文件
hello.i:预处理后文本文件
hello.s:编译后汇编文件
hello.o:汇编后可重定位的目标执行文件
hello:链接后的可执行文件
readelf-a.txt:链接前的readelf输出内容
readelf-b.txt:可重定位目标文件的readelf输出内容
objdump.txt:对hello的反汇编文件
objdump-a.txt:对hello.o的反汇编文件
参考文献
[1] 深入理解计算机系统. Randal E.Bryant,David R.O’Hallaron[M]. 北京:机械工业出版社,2019.3.
[2] https://www.cnblogs.com/pianist/p/3315801.html
[转]printf 函数实现的深入剖析 - Pianistx - 博客园 (cnblogs.com)
(参考文献0分,缺失 -1分)