在这篇文章中,我们主要探究hello程序从几行源代码到真正变成一个程序,实现自身功能,最后被回收的过程。在这个过程中,我们按照课程知识体系的流程,一步步对hello进行分析阐述,分析程序在计算机中较为底层的实现过程,以此来更深入的了解计算机体系的知识体系。
关键词: 计算机系统 底层实现 程序
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 6 -
3.2 在Ubuntu下编译的命令............................................................................. - 9 -
4.2 在Ubuntu下汇编的命令........................................................................... - 14 -
5.2 在Ubuntu下链接的命令........................................................................... - 19 -
5.3 可执行目标文件hello的格式.................................................................. - 20 -
5.5 链接的重定位过程分析............................................................................... - 23 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 28 -
6.3 Hello的fork进程创建过程..................................................................... - 28 -
6.6 hello的异常与信号处理............................................................................ - 30 -
第7章 hello的存储管理............................................................................... - 33 -
7.1 hello的存储器地址空间............................................................................ - 33 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 34 -
7.3 Hello的线性地址到物理地址的变换-页式管理...................................... - 35 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 37 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 38 -
7.6 hello进程fork时的内存映射.................................................................. - 38 -
7.7 hello进程execve时的内存映射.............................................................. - 39 -
7.8 缺页故障与缺页中断处理........................................................................... - 39 -
8.1 Linux的IO设备管理方法.......................................................................... - 42 -
8.2 简述Unix IO接口及其函数....................................................................... - 42 -
第1章 概述
1.1 Hello简介
P2P意思是program to process即从程序到进程。在Linux下,程序hello.c在经过cpp预处理、ccl编译、as汇编、ld链接后最终生成可执行目标程序文件hello,在shell中输入启动命令后,shell就会为其fork产生一个子进程,然后hello便从程序变为了一个进程。
020: shell为此子进程execve,将其映射到虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。在程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发与调试工具:gcc,vim,edb,readelf,HexEdit等
1.3 中间结果
名称 | 作用 |
hello.i | hello.c经过预处理得到的文件 |
hello.s | hello.i进行编译后得到的汇编文件 |
hello.o | hello.s经过汇编后得到的可重定位文件 |
hello | 链接后的可执行文件 |
hello.out | hello反汇编后的可重定位文件 |
1.4 本章小结
这一章主要介绍了hello.c的P2P和O2O,接着列举了整个大作业中所需的实验环境与工具,最后展示了完成过程中所需要的的中间文件及其作用。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。预处理会由预处理器对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。
作用:进行预处理时,预处理器会尝试展开以符号#作为起始字符的行,试图将其解释为预处理指令,比如#if / #ifdef / #ifndef / #else / #elif / #endif、#define、#include、#line、#error、#pragma以及一些单独定义的#。这样经过预处理后得到的文件可以在不同的环境中被修改,编译。
2.2在Ubuntu下预处理的命令
输入指令: cpp hello.c > hello.i
可以得到预处理文件hello.i,如下图所示:
2.3 Hello的预处理结果解析
我们打开hello.i,可以发现这个文件比hello.c大了很多,文件行数扩展到3000多行,如下图所示:
但在这个文件里我们仍然可以找到hello.c文件对于的代码,只不过这些代码被放在了文件的最后,如下图:
而在代码之前的则是对源代码的解释声明。
比如在文件开头的是源代码用到的各种库文件,如下图:
之后还有各种变量和函数的声明,如下图:
源代码被放在了文件的结尾处,如下图:
2.4 本章小结
这一章主要介绍了预处理的概念和起到的作用,并且学习了在unbuntu中如何从源代码文件生成预处理文件,最后我们通过浏览hello.i的代码文件,对预处理后的到的文件有了进一步的认识和感受。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:编译是将我们使用自然语言描述的代码转换成计算机能够看懂的二进制结构代码的过程,即把高级语言转换成汇编语言。
在这个实例中编译是通过编译器把hello.i文件翻译为汇编语言的hello.s文件。
作用:编译主要有以下几个作用:
1.语法分析:编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,方法分为两种:自上而下分析法和自下而上分析法。
2.中间代码:源程序的一种内部表示,或称中间语言。中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码。
3.代码优化:指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。
4.目标代码:生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。此处指汇编语言代码,须经过汇编程序汇编后,成为可执行的机器语言代码。
3.2 在Ubuntu下编译的命令
输入指令:gcc -S hello.i -o hello.s
得到hello.s文件如下图所示:
3.3 Hello的编译结果解析
3.3.1 数据
1) 常量
在源代码中有这样一行:
其中的4作为一个常量保存在hello.s中,如图:
同样的对于中的0和8也在hello.s中体现出来:
对于中的字符串来说,它们被储存在了:
2) 变量
变量分为全局变量和局部变量。
对于全局变量来说,如果它们已经初始化并且初始值非零,那么它的初始化不需要汇编语句,而是直接通过虚拟内存请求二进制零的页完成。
对于局部变量,如,它们存储在寄存器或栈中。在这个实例中i作为一个局部变量,在hello.s中储存在中,表示i被保存在了栈中%rbp-4的位置上。
3.3.2 算术操作
在源代码中的使用了i++的赋值操作,在汇编代码中,这个赋值操作表现为
,即栈上存储的变量i的值加1,从而完成对i的自加操作。
3.3.3 关系操作
在源代码中,使用了!=的操作符,用来判断argc这个参数是否等于4,它在hello.s中表现为
,je用于判断cmpl产生的条件码,若两个操作数的值不相等则跳过“本该”执行的语句。
在中,循环执行条件使用了<,这个循环在汇编代码表现为:
L3中的jle用于判断cmpl产生的条件码:若后一个操作数的值小于等于前一个,则跳转到.L4,这样就会重新执行循环。
3.3.4 数组/指针/结构操作
在主函数中的参数里定义了一个指针类型的数组*argv[],在这个数组中,argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别表示两个字符串。
在汇编代码里,我们可以找到对应的存储形式
这样就通过m%rbp-32+16和m%rbp-32+8,分别得到argv[1]和argv[2]两个字符串的首地址。
3.3.5 函数操作
1) main函数
参数传递:传入参数argv[],用寄存器存储。
函数调用:系统启动函数进行调用。
函数返回:设置%eax为0并且返回,对应return 0 。
源代码:
对应的汇编代码:
2)printf函数
参数传递:传入字符串参数首地址;
函数调用:在hello.c中printf会在判断满足if函数所给条件后进行调用,
源代码:
对应的汇编代码:
3)getchar函数
函数调用:在main函数执行过程中被调用
源代码:
对应的汇编代码:
3.4 本章小结
在这一章,我们利用编译成功生成了以汇编语言描述源代码的hello.s文件,之后通过对hello.s文件代码的学习和描述,对比hello.c文件,我们学习了c语言是如何转变为汇编语言,汇编语言如何描述并实现源代码中诸如常量,变量传递参数,算术操作,关系操作,结构操作和函数操作等各种操作的问题。
通过编译,我们把源代码的高级程序语言向更低级更底层的方向转化,生成汇编语言,从而将它变成计算机可识别的语言。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:汇编是将汇编程序翻译成机器语言的过程,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
作用:虽然我们通过编译,成功得到了源代码文件的汇编语言,不过汇编语言对于cpu来讲还是显得太难以理解了,因此为了让cpu能够理解程序指令,从而执行程序,我们就需要把汇编语言进一步汇编得到cpu可以识别的机器语言,在这个实例中即通过hello.s文件生成hello.o文件。
4.2 在Ubuntu下汇编的命令
命令:gcc hello.s -c -o hello.o
得到hello.o文件如下图所示:
4.3 可重定位目标elf格式
1) ELF Header
通过输入命令:readelf -h hello.o,我们可以查看hello.o的ELF格式。
如下图所示:
ELF头文件以序列 Magic为开始,它描述了系统信息,编码方式,ELF头大小节的大小和数量等一系列的信息。
2)Section Headers
输入命令:readelf -S hello.o ,我们可以看到节头目表,如下图:
它包含了文件中各个节的语义,其中包括节的类型、位置、大小等,根据节头表中的字节偏移信息可以知道各节的起始位置以及所占空间的大小。我们还可以看到代码段可执行不能写,数据段和只读数据段不可执行,而每个节都从零开始因为是可重定位目标文件
3).symtab
输入命令:readelf -s hello.o,可以得到符号表,如下图:
它存放了定义和引用的函数和全局变量的信息。其中name为名称,对应可重定位目标模块;value为起始位置偏移;size为目标大小;Bind表示是本地的还是全局的;type表示类型,要么是函数要么是数据。
4)重定向节
输入命令:readelf -r hello.o,可以查看hello.o中的重定向节,如下图:
.real.text:包含需要进行重定位的信息。
Offset:需要被修改的引用节的偏移
Info:包括symbol和type,symbol在前面三个字节,type在后面三个字节,
symbol:标识被修改的引用应该指向的目标
type:重定位的类型
Type:告知链接器应该如何修改新的应用
Attend:一个有符号常数,一些重定位要使用它对被修改引用的值做偏移调整
Name:重定向到的目标的名称。
4.4 Hello.o的结果解析
输入命令:objdump -d -r hello.o,如下图:
通过比较反汇编代码和汇编语言,我们发现汇编语言的指令是相同的,反汇编代码不仅显示汇编代码还显示机器语言。机器语言是二进制语言,是cpu可以识别并执行的语言,它由操作码和操作数组成,而且每条汇编语言都可以由机器语言表示,建立一一映射关系。
在操作数上,汇编语言表现为十进制,而机器语言表示为十六进制。
在分支转移上,汇编代码使用如L2等段进行跳转,而反汇编代码使用目标代码的虚拟地址进行跳转。
在函数调用上,汇编代码直接call函数名称,而反汇编代码中call的是目标的虚拟地址,只有在链接之后才能确定运行执行的地址。
4.5 本章小结
这一章我们学习了汇编,把汇编语言转换成机器语言,这样就得到了cpu可以理解并能够执行的语言。我们通过学习helll.o,研究了elf格式文件,了解并实际操作了readelf命令,认识了elf头、节头部表、重定位节、符号表等。我们还通过对比hello.s和hello.o,分析了汇编语言和机器语言的相似点和不同点,更深刻地理解了从汇编语言得到机器语言的过程。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种不同文件(主要是可重定位目标文件)的代码和数据综合在一起,通过符号解析和重定位等过程,最终组合成一个可以在程序中加载和运行的单一的可执行目标文件的过程。
作用:通过上一章的汇编,我们成功得到了cpu可以识别的hello.o文件,但是如果hello.o不和系统内核代码数据、库的代码和数据等合为一体,CPU仍然无法执行这个程序。而链接就是让hello.o和上述联合在一起的过程,最终会生成一个能够被cpu执行的可执行程序。
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 -W hello
1)ELF头
2)节头
3)程序头
4)重定位节
5)符号表
5.4 hello的虚拟地址空间
使用edb加载hello,可以在data dump窗口可以查看加载到虚拟地址中的hello程序。如下图所示:
我们可以看到虚拟地址从0x401000开始,到0x401ff0结束。
根据前面的节头部表,我们可以通过edb查看各个节的信息。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
输入命令:objump -d -r hello > helloasm
这样我们就得到了反汇编文件helloasm。
打开helloasm如下图所示:
比较hello.o的重定位项目,我们可以发现
1)新增了许多在源代码中涉及的函数,如如exit、printf、sleep、getchar等函数。
这些函数在hello.o的重定向文件里是没有的。
2)新增了一些节以及节中定义的函数,如.init .plt
3)函数调用地址不同
由于这时我们实现了调用函数的重定位,因此在调用函数时的地址是函数确切的虚拟地址。
这里再附上hello.o重定向文件中main函数的地址,如下图:
我们可以看到main函数的地址为一串0,还没有完成重定位。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
子程序名和程序地址:
5.7 Hello的动态链接分析
5.8 本章小结
在这一章,我们研究了链接的过程。通过edb查看hello的虚拟地址空间,对比hello与hello.o的反汇编代码,学习了链接的过程中重定位的过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
作用:进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell是用户级的应用程序,代表用户控制操作系统中的任务。同时shell提供了一个界面,用户可以通过访问这个界面来访问操作系统内核的服务。Bash则是Linux操作系统缺省的shell。
处理流程:
1)读取我们输入的命令
2)通过输入的字符串获取输入参数
3)判断输入是否非内置命令,若是内置命令,则可以执行,否则要调用相关的函数来执行
在我们的hello实例中,处理流程大致如下:
1) 在shell命令行中输入命令:$./hello
2) shell命令行解释器构造argv
3) 调用fork()函数创建子进程。这个子进程的地址空间与shell父进程完全相同。
4) 调用execve()函数在当前进程的上下文中加载并运行hello程序。还要将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间中
5) 调用hello程序的main()函数,
这样hello程序就能开始运行了。
6.3 Hello的fork进程创建过程
以我们的hello为例,当输入hello运行命令后,shell对我们输入的命令进行解析,由于我们输入的命令不是一个内置命令,于是调用fork 函数创建一个新的子进程。
在子进程中,fork返回0;在父进程中,返回子进程的PID;新创建的子进程几乎但不完全与父进程相同。
子进程得到与父进程完全相同但是相互独立的副本,包括代码段、段、数据段、共享库以及用户栈,子进程还获得与父进程任何打开文件描述符相同的副本。
但父进程和子进程的PID是不同的,它们是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。
在子进程执行期间,父进程默认选项是显示等待子进程的完成。
。
6.4 Hello的execve过程
Execve的参数包括需要执行的程序,通常是argv[0]、参数argv、环境变量envp。 1. 删除已存在的用户区域
2. 映射私有区:为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的
3. 映射共享区:比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内
4. 设置PC:exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点
其中需要指出的是:execve在调用成功的情况下不会返回,只有当出现错误时,例如找不到需要执行的程序,execve才会返回到调用程序
对于我们的hello程序来说,fork函数创建完进程后,需要调用execve函数到目标路径中寻找hello文件,同时映射私有区,然后映射共享区,最后设置当前进程上下文的程序计数器,将其指向代码区域的入口点,并将控制传递给新程序的主函数。
6.5 Hello的进程执行
6.5.1 逻辑控制流和时间片:
进程的运行本质上是CPU不断从程序计数器 PC 指示的地址处取出指令并执行,值的序列叫做逻辑控制流。
操作系统会对进程的运行进行调度, 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。
当内核选择一个新的进程运行,我们说内核调度了这个进程。内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。在一个程序被调运行开始到被另一个进程打断,中间的时间就是运行的时间片。
6.5.2 用户模式和内核模式:
1)用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据。
2)内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
6.5.3 上下文:
上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。
6.5.4 调度的过程:
在对进程进行调度的过程,操作系统主要做了两件事:
1)加载保存的寄存器
2)切换虚拟地址空间。
6.5.5 用户态与核心态转换:
为了能让处理器安全运行,需要限制应用程序可执行指令所能访问的地址范围。因此划分了用户态与核心态。
核心态可以说是拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证了系统的安全性。
6.6 hello的异常与信号处理
1. 可能出现的异常
(1) 中断:来自I/O设备的信号。比如输入CTRL -C或者CTRL-Z
(2) 陷阱:有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。
(3)故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
(4)终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。
2. 可能产生的信号
SIGINT,SIGSTP,SIGCONT,SIGWINCH等等
3.各种处理的分析
(1)正常运行,程序结束后,被正常回收,如下图所示:
(2)运行过程中按下CTRL-C,此举会给进程发送SIGINT信号,程序将被终止回收,如下图:
(3)运行过程中按下CTRL-Z,此举会给进程发送SIGSTP信号,hello程序将被挂起,用ps命令可以看到hello进程并没有回收,其进程号为6433,再使用jobs命令可以查看job的ID为1,状态为已停止,最后用fg 1命令,即可让其回到前台继续运行。如下图:
接着我们继续将其挂起,用命令pstree查看进程,找到hello进程的位置,如图:
最后输入kill – 9 4093终止进程,再用jobs命令查看,可见已被终止,最后用ps命令查看,也再也没有hello进程了,说明进程已经彻底终止被回收
6.7本章小结
这一章主要介绍了进程的概念及其作用,对shell的功能和处理流程也进行了介绍,然后详细分析了hello程序从fork进程的创建,到execve函数执行,最后具体执行过程以及出现异常的处理。我们还通过对hello的实践处理,输入各种指令,之后再查看进程,对整个进程管理有了更为深入的理解。
另外需要特别说明的一点是,在编写这一章时,我使用了大量网络资源来帮助我完成这一章的文字描述部分。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
1. 逻辑地址:
逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。一个逻辑地址,是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。
2. 线性地址:
线性地址也叫虚拟地址,它和逻辑地址类似,并不是真实的地址。线性地址是逻辑地址到物理地址的一个中间转化层,逻辑地址加上相应段中的基地址就变成了线性地址。具体来说,我们对于一个完整逻辑地址,通过段标识符和段选择符来在段描述符中找到对应的基地址,再加上段内偏移量就组成了一个线性地址。
其中如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
值得指出的是在Linux下,逻辑地址与线性地址总是一致的。
3. 虚拟地址:
虚拟地址包括VPO(虚拟页面偏移量)、VPN(虚拟页号)、TLBI(TLB索引)、TLBT(TLB标记)。在linux下我们可以认为虚拟地址和线性地址一样的。
物理地址:
CPU通过地址总线的寻址,找到真实的物理内存对应地址。它与处理器和CPU连接的地址总线相对应,是地址变换的最终结果地址
对于我们的程序实例hello来说,如下图所示起始地址为0x4010c1,这就是逻辑地址,这部分再加上段地址便可以得到虚拟地址,而虚拟地址经过MMU处理后便可以得到物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
对于一个完整的逻辑地址,它的组成为[段选择符:段内偏移地址]。当它转变为一个线性地址时要经过以下操作:
1)首先看段选择符的T1=0还是1,判断当前要转换是GDT中的段还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组。
2)拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样我们就获取了基地址(base)。
3)Base + offset,就得到了要转换的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU的页式内存管理单元,负责把一个线性地址,转换为物理地址。
线性地址被分为以一个固定的长度作为为一个单位的组,而这个包含了一个单位的组我们称为页(page)。
例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这样,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。
这个包含了所有的页的大数组我们称之为页目录。这个目录中的每一个目录项,就是一个地址——对应的页的地址。其实页目录的每一个项就是一个页 ,而页对应了一个单位的线性地址,所以页目录包含了整个线性地址。但页目录只是保存了页的编号信息,并没有直接保存线性地址的信息,想要具体知道线性地址,需要先找的页的编号,从而找到这个页,再查看页的信息来获取地址信息。
另一类“页”,我们称之为物理页,或者是页框(frame)、页桢。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。 这里注意到,这个total_page数组有2^20个成员,每个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。
如下图所示:
分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。
每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。
运行一个进程,需要将它的页目录地址放到cr3寄存器中,将别个的保存下来。
每一个32位的线性地址被划分为三部份:面目录索引(10位),页表索引(10位),偏移(12位) 依据以下步骤进行转换:
(1) 从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
(2)根据线性地址的前十位,在页目录找到对应的项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。即又引入了一个数组来充当一个中间环节,而页的地址被放到页表中去了。此时我们得到了一个页表的地址。
(3)根据线性地址的中间十位,在页表中找到页的起始地址;
(4)将页的起始地址与线性地址中最后12位相加,得到最终我们想要的地址。
7.4 TLB与四级页表支持下的VA到PA的变换
我们前面的地址变换分析都是在二级页管理架构下进行的,实际上有些cpu还有三级,四级的架构。Linux为了来兼容所有的这些架构,提供了一个四级页管理架构。这四级分别为: 页全局目录PGD、页上级目录PUD、页中间目录PMD、页表PT。 和二级的架构相比只是多了两个页表,转换时的流程是一样的。如下图:
这些多级的页表可以防止页表过大造成空间浪费。
如下图所示,四级页表层次结构中,虚拟地址被划分为4个VPN和1个VPO。第i个VPN是一个到第i级页表的索引,第j级页表中的每个PTE都指向第j+1级某个页表的基址,第四级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了得到物理地址,MMU必须需要访问四个PTE,不断通过索引 – 地址 – 索引 - 地址重复四次进行寻找。
7.5 三级Cache支持下的物理内存访问
L1Cashe的物理访存大致过程如下:
CPU发送一条虚拟地址,随后MMU按照7.4所述的操作获得了物理地址PA。根据cache大小组数的要求,将PA分为CT(标记位)CI(组索引),CO(块偏移)。根据CI寻找到正确的组,依次与每一行的数据比较,有效位有效且标记位一致则命中。如果命中,直接返回想要的数据。如果不命中,就依次去L2和L3主存判断是否命中,命中时将数据传给CPU同时更新各级cache。
1) 组选择:取出虚拟地址的组索引位,把二进制组索引转化为一个无符号整数,找到相应的组
2) 行匹配:把虚拟地址的标记为拿去和相应的组中所有行的标记位进行比较,当虚拟地址的标记位和高速缓存行的标记位匹配时,而且高速缓存行的有效位是1,则高速缓存命中。
3) 字选择:一旦高速缓存命中,我们就知道我们要找的字节在这个块的某个地方。因此块偏移位提供了第一个字节的偏移。把这个字节的内容取出返回给CPU。
4)不命中:如果高速缓存不命中,那么需要从存储层次结构中的下一层取出被请求的块,然后将新的块存储在组索引位所指示的组中的一个高速缓存行中。
7.6 hello进程fork时的内存映射
当进程调用fork函数时,内核会为进程创建各种数据结构,并分配给它一个唯一的PID,同时还为这个进程创建虚拟内存,创建当前进程的mm_struct、区域结构和页表的原样副本。
它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。
7.7 hello进程execve时的内存映射
在创建了一个子进程后,子程序调用execve函数在上下文加载hello程序。
(1)删除当前虚拟地址中已存在的用户区域。
(2)为新程序建立新的区域结构,这些区域结构是私有的,虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区,bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
(3)如果hello与共享对象链接,那么这些对象都被动态链接到这个程序,然后映射到用户虚拟地址空间中的共享区域。
(4)设置程序计数器,使之指向代码区域的入口点,下次调用这个进程时,从这个入口点开始执行。
7.8 缺页故障与缺页中断处理
页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的。
1)处理器生成一个虚拟地址,并将它传送给MMU
2)MMU生成PTE地址,并从高速缓存/主存请求得到它
3)高速缓存/主存向MMU返回PTE
4)PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
5)缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
6)缺页处理程序页面调入新的页面,并更新内存中的PTE
7)缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。
7.9动态存储分配管理
动态储存分配管理使用动态内存分配器(如malloc)来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合。每个块就是一个连续的虚拟内存页,要么是已分配的,要么是空闲的。
已分配的块显式地保留为供应用程序使用。
空闲块保持空闲,直到它显式地被应用所分配。
一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。动态内存分配主要有两种基本方法与策略:
1)带边界标签的隐式空闲链表分配器管理
这种情况下,每个块是由一个字的头部、有效载荷、可能会有的额外填充以及一个字的尾部组成。头部编码了这个块的大小以及这个块是已分配的还是空闲的。块的头最后一位指明这个块是已分配的还是空闲的。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配和最佳适配。分配器在面对释放一个已分配块时,可以合并相邻的空闲块,其中一种简单的方式,是利用隐式空闲链表的边界标记来进行合并。
2)显式空间链表管理
显式空闲链表是将堆的空闲块组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。进行内存管理。在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
这一章介绍了存储器地址空间的类别,我们尝试分析了段式管理、页式管理下一个逻辑地址一步步变为物理地址的过程,此外还了解了VA 到 PA 的变换,物理内存访问, 但由于我也还不是很理解,因此后面的hello 进程fork时和execve 时的内存映射、缺页故障与缺页中断处理、包括隐式空闲链表和显式空闲链表的动态存储分配管理这些部分参考了很多网络资源。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux将文件所有的I/O设备都模型化为了文件,甚至内核也被映射为文件。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。Linux就是基于Unix I/O实现对设备的管理。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
接口:
1)打开文件
2)关闭文件
3)把数据写入文件
4)读取文件数据
5)修改文件的偏移量
6)判断文件是否可写可读可执行或是否存在
7)创建文件描述符
函数如下一系列图所示:
8.3 printf的实现分析
我们先来看看printf函数的函数体:
我们一步步来分析这个函数
首先是va_list arg = (va_list)((char*)(&fmt)+4)
va_list的定义:
typedef char *va_list
这说明它是一个字符指针。
其中的: (char*)(&fmt) + 4) 表示的是其中的第一个参数。
下一句:i = vsprintf(buf,fmt,atg)
我们来看一下vsprintf的函数体:
这个函数会返回要打印出来的字符串的长度,这样i就代表了要打印的字符串的长度。
下一句:write(buf,i)
这个函数有点复杂,我们先来具体看一下write这个函数:
这里是给几个寄存器传递了几个参数,然后一个int结束,这样的int表示要调用中断门了。通过中断门,来实现特定的系统服务。对于这个例子int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
我们来继续查看一下sys call 这个函数:
她的功能就是显示格式化的字符串。
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
当程序调用getchar函数时,程序会等待用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车,回车也在缓冲区中。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。
如果用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:当用户按键时触发键盘终端,操作系统将控制转移到键盘中断处理子程序,中断处理程序执行,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区,显示在用户输入的终端内。当中断处理程序执行完毕后,返回到下一条指令运行。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
这一章我们简单介绍了Linux 的 I/O 设备的基本概念和管理方法,之后了解了Unix I/O的接口以及它们对应的函数。最后我们分析了printf 函数和 getchar 函数的工作过程。
(第8章1分)
结论
我们总结hello所经历的过程:
1)hello.c预处理到hello.i文本文件
2)hello.i编译到汇编文件hello.s
3)hello.s汇编到可重定位目标文件hello.o
4)hello.o链接生成可执行文件hello
5)输入命令运行hello程序
6)shell读取我们的命令,调用fork和execve为hello加载虚拟内存,分配时间片。
7)hello请求一个虚拟地址,再由MMU将其转化为物理地址。
8)hello的输入输出与外界交互,与linux I/O息息相关,
9)hello最终被shell父进程回收,内核会收回为其创建的所有信息
这样hello走完了它的一生。
通过一个hello程序,我们发现了计算机系统的复杂精妙,一个个步骤有条不紊的执行,各个系统各司其职,才能让hello从几行代码真正变成在计算机上运行的程序,实现自己的功能价值。在编写大作业时我也加深了对计算机系统的了解,我也意识到自己对计算机系统的了解和掌握程度远远不够,期间遇到了很多困难,大部分通过查找网络资料得以解决,但也有一些问题没有找到合理而明确的解释,我还需要多学习,多思考,才能更好的掌握这门课程庞大的知识。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
名称 | 作用 |
hello.i | hello.c经过预处理得到的文件 |
hello.s | hello.i进行编译后得到的汇编文件 |
hello.o | hello.s经过汇编后得到的可重定位文件 |
hello | 链接后的可执行文件 |
hello.out | hello反汇编后的可重定位文件 |
hello_elf | hello.o的elf文件格式,查看其各节信息 |
hello.asm | hello.o反汇编得到的文件,查看汇编后的汇编代码 |
helloasm | hello的反汇编得到的文件,查看链接器链接后的汇编代码 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
readelf命令使用说明_木虫下的博客-CSDN博客_readelf
逻辑地址、线性地址、物理地址和虚拟地址理解_do2jiang的博客-CSDN博客
[转]printf 函数实现的深入剖析 - Pianistx - 博客园 (cnblogs.com)
Linux下的文件I/O编程 | 《Linux就该这么学》 (linuxprobe.com)
兰德尔 E. 布莱恩特,大卫 R. 奥哈拉伦. 深入理解计算机系统(第3版)[M]. 北京:机械工业出版社. 2016.7.
此外还查阅参考了许多网络资料,难以一一列举
(参考文献0分,缺失 -1分)