计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术学院
学 号 120L022217
班 级 2003008
学 生 蔡仕诚
指 导 教 师 吴锐
计算机科学与技术学院
2021年5月
本文介绍了一个简单的C程序运行背后的具体详细过程。讲述程序是如何从C程序一步一步变成方便机器执行的语言(编译,汇编等)。然后,并介绍了其具体执行所需要的额外操作,即C程序涉及到的一些库函数。简单的调用一个main函数外的库函数(如printf等),对应于计算机对程序的来链接操作,来满足对应指令的实现。同时,介绍了进程实现过程中对内存的占用情况以及操作系统对进程的管理,调度等。
关键词:C程序的具体实现;进程管理;内存与程序
目 录
第1章 概述
1.1 Hello简介
P2P过程:
P2P指的是程序从一个保存的hello.c文件(Program)到它变成一个可执行文件然后真正地在内存中运行(Process)的过程。
它变为一个可执行文件的过程需要预处理器对其进行预处理,然后编译器产生一个汇编程序,汇编器将其翻译成机器语言指令,生成一个可重定位目标程序,然后连接器合并该程序真正运行所需要的其他外部文件等,生成一个可执行文件。
可执行文件的运行,需要在shell命令行中,运行该可执行文件,其fork一个子进程,然后子进程会执行(Process)这个文件。
020过程:
020指的是hello在内存的存在形式:先是不存在内存之中(0),然后运行该过程中,加载到内存,运行完该程序后,hello被回收,其占用的内存被清楚(0)。
hello在变成一个可执行文件之前,他都是一个文件,存在电脑的磁盘等中,并没有进入到内存。当shell fork一个子进程时,子进程会为该进程的执行开辟一部分空间,然后会执行execve拷贝hello所需要的数据,代码等映射进入到虚拟内存中,并根据指令运行。
当程序运行结束后,会有子进程的父进程接受到子进程结束的信号,并对其进行回收,根据子进程的pid清除子进程的内存占用。
1.2 环境与工具
X64 CPU;2.6GHz;8G RAM;256GHD Disk
Windows11 64位; Ubuntu 20.04 LTS 64位
gcc
1.3 中间结果
hello.i文件:预处理后的文件,对源文件进行了一些简单的调整,如:针对#后的内容,加载对应的文件的内容到.c文件中。
hello.s文件:编译器翻译.i文件后的汇编语言程序。将文件变成了一系列的低级机器语言指令,为不同的高级语言的不同编译器提供了通用的输出语言。
hello.o文件:汇编器将.s文件翻译成机器语言指令,并将这些指令打包成可重定位的目标程序(二进制文件),为计算机将其运行奠定基础
hello/hello.out文件:最后经过链接后的可执行文件。这个文件已经可以被计算机运行了。
1.4 本章小结
了解了hello程序运行的大致全过程,其从程序到运行,在内存中从无到有再到无的过程。
第2章 预处理
2.1 预处理的概念与作用
预处理器(cpp)根据字符#开头的命令,修改原始的C程序。比如hello.c中第6,7,8行的#include<stdio.h>,#include<unistd.h>,#include<stdlib.h>命令,会告诉预处理器读取系统头文件对应的内容,并把它直接插入程序文本中。结果得到了另一个C程序,通常是以.i作为文件扩展名。
2.2在Ubuntu下预处理的命令
gcc -E hello.c hello.i
Cpp hello.c > hello.i
图2-1 Ubuntu下的一条预处理命令
2.3 Hello的预处理结果解析
图 2-2 预处理的结果
预处理添加文档过多,仅截取比较典型的几个部分。首先,预处理后的结果一定包含源程序图左3050行起,但同时,也会添加头文件对应的内容,可见,简简单单的三条头文件,对应了3000多行的代码。图右为头文件的一些相关内容,可见补充的东西有一些路径,一些类型的定义以及其他可能需要用到的代码
2.4 本章小结
本章介绍了gcc对原始c文件的预处理过程,主要是增加了对应的头文件内容,当然,还有其他的对应操作,如对应的宏替换。
第3章 编译
3.1 编译的概念与作用
编译器ccl将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。汇编使得不同的高级语言不同的编译器提供了通用的输出语言。
3.2 在Ubuntu下编译的命令
gcc -S hello.c -o hello.s
cc1 hello.i -o hello.s
objdump -d hello.o //不是汇编,是反汇编,结果也是产生汇编代码
图3-1 Ubuntu下的一条编译命令
图3-2Ubuntu下的反汇编指令
3.3 Hello的编译结果解析
图3-3 hello的源程序
图3-4 hello汇编程序开头结尾的伪指令等部分
3.3.1 if判断的分支实现:
图 3-5 汇编代码分支1
汇编代码第25行:实现了c程序中的if判断,做到了条件条件转移。其中,在判断是否进行控制转移的时候,需要先进行函数(main的argc等)的参数传递,第22行,将argc的值传递给-20(%rbp),当然,在此之前及传递过程中,也进行了传递前的一些汇编准备阶段(如21行栈指针的扩展,15行,19行的保留参数等)以及其他参数的传递(第23行),然后根据24行进行判断力argc是否为4,若为4,则进行条件控制转移,跳至L2,否则继续顺序执行(26行)。
我们顺便解决不进行跳转的这个分支:26行实际上将开头部分的常量字符串数据进行了传递即,输出的字符串“用法: Hello 学号 姓名 秒数!\n”,27行调用输出,输出了字符串,28,29行类似,给exit函数传递了参数1,执行了exit(1),然后结束了这部分的分支。
3.3.2 for循环的实现
图3-6汇编代码的循环实现
其中,31行,为循环变量整型变量i 分配了-4(%rbp),同时,进行了初始化赋值为0,然后开始53行循环条件的判断,在i小于等于7的条件下,进行控制转移,跳转到.L4,执行循环体的部分。
我们顺便解决循环跳出后的部分:55行进行了控制转移,执行了函数getchar(),由于函数没有输入参数,所以,其之前没有进行参数传递。(其函数参数返回默认在getchar中实现)注意第56行,作为一个函数,main函数也是有返回的(对应程序中的return 0),56行相当于将0作为返回参数传递给%eax,最后默认将%eax作为返回值返回。
3.3.3 for循环循环体的实现
图3-7汇编代码的循环体部分
在.c文件中,循环体的第一部分时一个函数调用,调用printf函数,这次的printf和第一次的printf并不完全一样,它的输出不再是一个字符串常量,而是包含了两个argc参数,这就要求,在调用之前,完成参数传递。根据汇编参数传递的定义,第41行,第40行,第36行依次完成了%rdi,%rsi,%rdx三个参数的传递,其中,第一个参数类似之前的字符串常量,在最开始的第八行进行了定义声明(并未完全赋值),第二三个参数所代表的字符串均是来自main中,保存在%rbp(类似%rsp的栈复制)的几个arg参数。这样就完成了一条printf语句。
其二条语句其实时调用了两次函数,以atoi的返回值作为sleep的形参,其实现的核心在于第49行,49行的%rax为atoi的返回值,而%edi为sleep的第一个参数,这样的传递,实现了两个函数间的调用。当然,和printf语句一样,47行也进行了atoi的arg【3】参数的传递。
51行时另外一个重点,它实现了i++的加法操作,将常数($)1和-4(%rbp)(代表变量i)作为加法的两个输入,-4(%rbp)(变量i)作为加法的存储输出,实现了加法的操作。同时,注意到51行之后是顺序的.L3循环判断,进而实现了整体循环的操作。
3.4 本章小结
本章具体分析了hello.c对应的汇编代码,介绍了每一条C语句,每一个数据类型,函数调用,循环,判断等控制转移的具体汇编代码实现。
第4章 汇编
4.1 汇编的概念与作用
(以下格式自行编排,编辑时删除)
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o
gcc -c hello.i -o hello.o
图4-1 Ubuntu 下的汇编指令
4.3 可重定位目标elf格式
在分析之前简单的了解一下readelf可以实现什么功能,具体功能见下图,我们不再逐一地输入命令,仅根据readelf -a hello.o命令来获取所有的有效信息。
图4-2 readelf的可选指令及具体指令下的显示信息
典型的ELF可重定位目标文件的格式有三部分,第一部分为ELF头。ELF头以一个16字节的序列开头,这个序列描述了字的大小和字节顺序。剩下的部分为帮助链接器工作的信息,如头大小,节头部表中条目数量等。第二部分为节,一般含有.text(已编译程序的机器代码),.rodata(只读数据,如printf语句的格式串与开关语句的跳转表),.bss(未初始化的全局/静态变量),.data(全局/静态变量),.rela.text(text节中位置的列表),.symtab(符号表),.strtab(一个字符串表,以null结尾的字符串序列)等,当然,本程序还具有其他的成员(如.comment,.eh_frame,.note.GNU-stack等)。最后,第三部分是一个描述目标文件的节节头部表(本程序无)。
图4-3第一部分ELF头 图4-4 第二部分节
图4-5 第三部分 描述目标文件的节
当然,对于节中的某些信息,elf文件还是比较具体的展示,如下图的字符表,就展现了字符的数量,类型,出现点,数据大小与数据值等信息。
图4-6 字符表的具体展示
在具体展示的一些节中,与重定位项目密切相关的主要是.rela.XX文件,展示其相对的偏移关系的相关信息(如偏移量,类型等)。
图4-7 与重定位相关的项目的信息
4.4 Hello.o的结果解析
图4-8 hello.o的反汇编内容
机器语言是一系列的二进制码(为方便观察,展示为16进制的数据),对于汇编语言中的很多指令,其实包括操作类型与操作数两部分(有的可能并不瓦努请安相同),这样的指令,可以唯一对应一条指令。形成机器语言与汇编语言的对应映射。
在立即数方面,图4-8的8号起,机器语言对应的操作数是20(第四个字节),对应的反汇编数也是$0x20,而图3-5中的21行,汇编数是$32,说明,汇编语言与机器语言对数字的进制表示是不同的。
在函数调用方面,图3-5第27行的汇编语言调用为call puts@PLT,使用符号来代替汇编的跳转行为,而机器语言(20号起)对应的则是00000000的占位符,对应的反汇编语言,列出了跳转符号对应的相关信息R-X86-64-PLT32 puts-0x4,具有差异。
在分支跳转方面,图3-5的25行,if的跳转仅仅列出了跳转对应的符号.L2,而机器代码(17号起)对应的则是跳转的相对路径(74代表je,而16带表相对位置大小,具体计算为下一条指令19+相对位置16 = 2f,注意16进制表示),而机器代码的反汇编代码则是跳转的绝对路径2f(对于main的相对路径)
4.5 本章小结
本章介绍了hello.o文件的具体内容,通过readelf工具,objdump工具, 明白了.o文件的相关内容,进而理解了汇编的过程。
第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
gcc hello.o -o hello.out
图5-1 Ubuntu下链接指令的实现
5.3 可执行目标文件hello的格式
与hello.o文件相比,这次的hello的elf文件在节上多了几个部分,同时,有了程序头的相关内容。
图5-2 elf文件的节信息
图5-3 elf文件的程序头
5.4 hello的虚拟地址空间
可以发现,.text在elf文件的地址为4010f0,而在虚拟内存中,也发现了对应的内容,同理,可查询其他节中的内容的对应。
图5-4 edb下与.text对应的虚拟地址空间
图5-5 edb下与.plt文件等对应的虚拟地址空间
图5-6存储时整体的内存布局
其中,图5-6大致讲述了一段虚拟内存空间整体的布局,涉及了存储点的读写权限,内存空间的起始位置,不同的项目(如用户栈stack,共享库等)
5.5 链接的重定位过程分析
图5-7链接后的反汇编
综合分析链接前后的反汇编代码,我们发现有以下的不同:
首先,有了<_init>,<_satrat>,<printf@plt>等部分,说明链接过程的操作对象除了可重定位的目标文件之外,还涉及到了printf的加载,为每一个片段都分配了一段确定的内存空间。
其次,在main函数之中,每一条跳转语句不在仅用符号等来表示了,每一条跳转语句都有确定的寻你存储空间,且对应的汇编代码时绝对地址,而不再是相对于main的相对地址。
最后,main的起始地址不再是全0,而是有了其特有的开头401125,表明了elf文件中,不再是只有main作为中心,而是其与其它片段共同组合的结果。
这种链接的不同汇编代码,也展示了重定位的大致思路过程。重定位对main中需要调用的片段(exit,printf等),分配给其独立的虚拟内存空间,在原有的基础上,以标号作为跳转的机制可以将标号替换成其绝对物理地址大小,这样,就使不同片段的程序代码成为了一个整体。由于在每个片段的内部,各个指令的相对路径差异是一定的,所以只需要给每个函数再加上一个固定的偏移量即可实现整体的重定位操作。
5.6 hello的执行流程
hello执行流程如下:
(1)ld-linux-x86-64.so!_dl_start
(2)ld-linux-x86-64.so!_dl_init
(3)hello!_start
(4)hello!__libc_csu_init
(5)hello!_init
(6)libc.so!_setjmp
(7)hello!main
(8)hello!puts@plt
(9)ld-linux-x86-64.so!_dl_runtime_resolve_xsave
(10)ld-linux-x86-64.so!_dl_fixup
(11)ld-linux-x86-64.so!_dl_lookup_symbol_x
(12)hello!exit@plt
(13)libc.so!exit
(14)hello!_fini
5.7 Hello的动态链接分析
在edb中运行hello程序之后,就会出现dl-init等前后动态链接的区别。
图5-8 链接后的相关位置的内存内容
图5-9链接后的结果
可以发现,在之前链接后的elf文件的404000位置,在运行程序的前后(即动态链接前后),对应的内存空间出现了不同,链接后增加了一些内容,说明动态链接会给elf文件增添一些新的动态库等内容。
5.8 本章小结
本章讲述了链接的概念,链接的过程。并学会通过readelf,edb等方式来研究elf文件,了解链接的作用。懂得了进程运行后的内存分配,一个简单的main函数运行所执行的流程等。
第6章 hello进程管理
6.1 进程的概念与作用
进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正常运行所需要的状态组成(如程序代码,数据,栈,程序计数器,环境变量等)。
进程的抽象使我们得到一种假象,好像我们的程序中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中地指令;我们程序中地代码和数据好像是系统内存中唯一地对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell是用户与内核交互的窗口,接受内核的信号,管理子进程。同时,解释命令行的内容,根据内容做出判断,执行相应的操作。
<1>获取命令行字符串,并根据命令行的一些格式(如空格)将其断成多个字符串参数(不同的arg参数)
<2>根据指令的的特点,进行判断,如果是一些內键命令,则会执行,否则,会跟据参数代表的地址去寻找一个可执性文件等
<3>若是执行某个程序,根据程序是前台还是后台,shell做出反应,若是后台,则shell返回,并等待下一条命令,若是前台,则等待前台程序的接续,然后等待下一条命令。
6.3 Hello的fork进程创建过程
父进程(如shell)通过调用fork函数创建一个新的运行的子进程(为hello进程的运行开辟了副本空间),新建的子进程近乎但不完全与父进程相同。子进程德奥与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段,堆,共享库以及用户栈,子进程还获得了父进程中打开文件描述符相同的副本。
6.4 Hello的execve过程
execve函数在fork的子进程的上下文中加载并运行hello程序。
execve函数加载并运行可执行文件hello和带参数列表argv和环境变量。其中,argv变量指向一个以null结尾的指针数组,每个指针指向一个字符串(通常argv[0]是可执行文件的名字);环境变量类似,只不过每个指针指向环境变量字符串,格式为“name=value”。
在execve加载hello之后,会调用一段启动代码。启动代码设置栈,并将控制传递给新程序的主函数(即main函数)。
6.5 Hello的进程执行
内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需要的状态。在进程执行的某些时刻,讷河可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这种决策叫做调度,有内核中称为调度器的代码处理。当内核选择一个新的进程运行时,我们说内核调度了这个进程,在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种成为上下文切换的机制来将控制转移到新的进程。当内核代表用户执行系统调用,或中断都可能发生上下文切换。
hello程序起始会正常运行,一段时间后,(可能,参数满足条件的时候)调用sleep()函数,发生了系统调用,要挂起进程一段时间,这个时候,内核就会抢占当前进程,保存hello的上下文,进入到用户模式,直到sleep函数结束后,得到信号,内核会保存当前的上下文(离开hello后执行的部分),并重新加入hello的上下文,将控制权还给hello程序,重新进入到用户态。
6.6 hello的异常与信号处理
图6-1 回车及CTRL-z演示
图6-2 乱码-回车及ctrl-c演示
图6-3 ctrl-z及ps,jobs,pstree演示
图6-4 ctrl-z后fg演示
图6-5 ctrl-z后kill指令演示
hello执行时,可以接受SIGINT(CTRL-C),SIGTSTP(CTRL-Z)信号(即接受异常),分别中断程序和停止程序(可再次恢复,如fg指令使中断进程再次运行)。同时,对于內键指令,jobs,fg等也可作出回应,也可对于其他的一些指令做出回应(如ps,pstree,kill等)。
当程序运行时,会执行sleep(25),执行之前,其实hello就已经产生了相应的信号来确保执行sleep函数,在sleep 的过程中,按回车或乱码都不起作用,相当于程序在执行类似waitfg的操作,不会对其它指令反应,按CTRL-C/Z实际上就是借助终端发送给程序一个SIGINT/SIGTSTP信号,程序接受信号,并根据信号所对应pid,对具体对象执行特定操作。按fg/jobs等实际上使让程序执行内建命令,,kill -9 7090实际上是对特定的pid发送了SIGKILL信号,然后shell程序执行了杀死程序的效果。
6.7本章小结
本章介绍了hello在真正运行后,可以对其进行的操作,这写操作,大多基于进程的上下文切换,并通过上下文切换,实现了对进程的管理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址(Logical Address) 是指由程序产生的与段相关的偏移地址部分。例如,你在进行C语言指针编程中,可以读取指针变量本身值(&操作),实际上这个值就是逻辑地址,它是相对于你当前进程数据段的地址,不和绝对物理地址相干。只有在Intel实模式下,逻辑地址才和物理地址相等(因为实模式没有分段或分页机制,Cpu不进行自动地址转换);逻辑也就是在Intel 保护模式下程序执行代码段限长内的偏移地址(假定代码段、数据段如果完全一样)。应用程序员仅需与逻辑地址打交道,而分段和分页机制对您来说是完全透明的,仅由系统编程人员涉及。应用程序员虽然自己可以直接操作内存,那也只能在操作系统给你分配的内存段操作。
线性地址(Linear Address) 是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386的线性地址空间容量为4G(2的32次方即32根地址总线寻址)。
物理地址(Physical Address) 是指出现CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
虚拟内存(Virtual Memory) 是指计算机呈现出要比实际拥有的内存大得多的内存量。因此它允许程序员编制并运行比实际系统拥有的内存大得多的程序。这使得许多大型项目也能够在具有有限内存资源的系统上实现。一个很恰当的比喻是:你不需要很长的轨道就可以让一列火车从上海开到北京。你只需要足够长的铁轨(比如说3公里)就可以完成这个任务。采取的方法是把后面的铁轨立刻铺到火车的前面,只要你的操作足够快并能满足要求,列车就能象在一条完整的轨道上运行。这也就是虚拟内存管理需要完成的任务。在Linux 0.11内核中,给每个程序(进程)都划分了总容量为64MB的虚拟内存空间。因此程序的逻辑地址范围是0x0000000到0x4000000。
7.2 Intel逻辑地址到线性地址的变换-段式管理
线性地址(Linear Address) 是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址:是进程使用的地址,虚拟的地址。人为抽象出一大片地址空间给进程使用,为了方便32位地址总线存取,linux内核定义为了4G。
物理地址:是采用32位总线存取物理内存某个字节时,地址总线上电位的高低。
页式管理需要地址翻译硬件和一个存放在物理内存中叫做页表的数据结构。页表将虚拟页映射到物理页。每次MMU将虚拟地址转换物理地址是,都会读取页表。
页表是一个页表条目PTE数组。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。假设PTE有一个有效位和n位地址字段。当有有效位,那么地址字段就表示DRAM中相应的物理页的初始位置,该物理页缓存了虚拟页,无有效位,则空地址代表虚拟页未反配,否则,这个地址指向虚拟页磁盘的起始位置。
7.4 TLB与四级页表支持下的VA到PA的变换
4级页表层次的地址翻译下,虚拟地址被划分成4个VPN和1个VPO。每个VPNi都是一个第i级页表的索引,其中(0<i<5)。第j级页表中的每个PTE(0<j<4)都指向下一级的某个页表的基址,为了确定PPN之前,MMU必须访问4个PTE。
为了方便查阅PTE,MMU中包括了一个关于PTE的小的缓存,成为后备缓存器TLB。
TLB的每一行都保存着一个由单个PTE组成的块。TLB通常具有高度的关联性。用于组选择和行匹配的索引和标记字段从虚拟地址的虚拟页号中提取出来。如果TLB有2^t个组,那么TLB索引是由VPN的t个地位组成,TLB标记是由VPN剩余的位组合。
其中地址翻译的实现如下:
n位的虚拟地址包含两个部分,一个p位的虚拟页面偏移VPO和一个n-p位的虚拟页号VPN。MMU利用VPN选择适当的PTE,而页表条目中物理页号PPN和虚拟地址中的VPO串联起来,就得到相应的物理地址。(PPO和VPO相同)
7.5 三级Cache支持下的物理内存访问
在了解三级cache支持下的物理内存访问之前,我们先简单的介绍一下cache实现的功能:
存储器访问越快,其容量越小。为了满足速度的要求,我们使用缓存的机制,即用一个小的但访问速度快的存储器件做一个存储量大但访问速度相对较慢的存储器件的缓存。当系统对相邻元素或同一元素(即局部性较高的内存)访问次数较多时,可以实现较快的访问速度。
而三级cache就是指应用三次这样的缓存的机制,具体实现过程三次都是统一的,我们只需要用一次来讲解即可。
对物理内存的访问,实际上,就是给定一个物理地址(m位),然后取出对应物理地址的一个字(w个相邻的字节)。Cache类似于一个数组,其中存储了一系列信息和标志。
首先,cache中可能有S(2^s)个组,每个组,都有各自的s位组索引,对于每个组中,都有E(2^e)行,我们可以将行理解为数组的基本单元。
在每一行中,都有一个(1位的)有效位,标志该行有没有存储实际信息。然后,是一个(t位的)标志位,最后是一个B(2^b)个字节块的数据(块就是作为相邻两级存储结构之间,存储,拷贝的统一大小的存储单位),一个块中,其字节数就是实际物理内存的字节数,就是我们将要访问的物理内容。
我们将物理地址划分为3部分,首先(地位开始)是b位的块偏移量(一个块中可能包含多个字,所以需要定位块中那个位置开始的w位才是需要访问的字)。然后是中间的s位组索引(用来在哪一组来寻找对应的块,全相联模式下,s为0),最后是高位的t位标志位(用来匹配组中的哪一行存储对应的块)。
这样也就确定了,如何根据地址来从cache中寻找对应的字,首先,根据地址中间的s位,来确认组号,在该组中,遍历所有行,来判断是否存在这样一行,有效位为1,标志位与地址的前s位相同。A若找到,则根据地址对吼的b位偏移量,来在对应行的块中进行偏移(块中左侧为0,从左向右偏移),根据字节大小,读出对应字节数的内容。B若没找到,则当前级别的存储器到下一级别的存储器中,根据地址的标志位及组索引,拷贝对应的块,然后在当前级别的存取器中,选择对应组的一个牺牲块(或者行),更新有效位,标志位,存储对应的块,再次访问时,即可访问到对应内存的内容。
三级cache,每级都采用如上思路,最终实现了从cpu到内存的一个物理内存访问。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并为它分配唯一的PID。为了给当前进程创建虚拟内存,它创建了当前进程的mm-struct,区域结构和页表的原样副本。将两个进程的每个页面都标记为制度,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当两个进程中的任意一个后来进行写操作时,写时复制机制就会常见新页面。因此,就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在某可执行文件中的程序时,将此程序有效替代了当前程序。其中执行了如下步骤:
删除已存在的用户区域
删除当前进程虚拟地址的用户部分中的已存在的数据结构。
映射私有区域
为新程序的代码,数据,bbs和栈区域创建新的区域结构,他们都是私有的,写时复制的。代码数据区域映射到可执行文件的.text,.data区,bbs时请求二进制0,映射到匿名文件中,大小包含在可执行文件当中。堆,栈区域也是请求二进制0,初始长度为0。
映射共享区域
如果可执行文件与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址了空间的共享区域内。
设置程序计数器
设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
当CPU音乐某个虚拟页面的一个字时,该虚拟页面没有缓存在DRAM中,地址翻译硬件从内存中读取该虚拟页面,根据其有效位推断其未被缓存,从而触发一个缺页异常,调用内核的缺页异常处理程序。
程序会选择一个牺牲页,若该页面被修改,内核就会将它复制回磁盘。无论修改与否,都会修改该牺牲页的页表条目,反映初该牺牲页不再缓存在主存中。
然后,内核从磁盘复制虚拟页面到内存中牺牲页面对应的位置,更新虚拟页面的页表头目,随后返回。当异常处理程序返回时,他会重新启动导致缺页的指令。
该指令会把缺页的虚拟地址重新发送给地址翻译硬件,额该虚拟页面已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制0的区域,它紧接着再未初始化的数据区域后开始,并向上生长。对于每个流程,内核维护着一个变量brk,指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块保持空闲,直到它显式地被应用所分配。一个已分配地块保持已分配状态,直到它被释放,这种释放,要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
7.10本章小结
本章讲述了hello运行时相关的内存分配内容。对其的地址进行不同种类的划分,并介绍了,cpu如何根据虚拟内存来得到物理内存。并介绍了在进行地址转换的时候,应用到的页表,Cache等方面的管理应用。最后,根据相关知识,解释了一些函数fork,execve等具体的内存映射实现。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
将每一个输入输出设备都模拟为文件,对应的,输入输出都被当作对相应文件的读或者写操作。
设备管理:unix io接口
将输入输出设备模拟成统一的文件之后,便允许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。特别的,当n大于文件地的字节数m是,当m大于等于m时执行读操作时,会触发一个EOF条件,系统可检测该条件,而文件的尾部并没有这个EOF符号;对应的,写操作就是从内存复制n个字节到文件,从文件当前位置k开始,然后更新k。
关闭文件,当应用结束对我呢见访问之后,其会通知内核关闭该文件。而内核会释放文件打开时创建的数据结构,并肩这个描述符恢复到可用的描述符池中。无论进程因何而中止,内核都会关闭所有打开的文件并释放它们的内存资源。
Open函数
int open(char *filename,int flags, mode_t mode)
打开文件或创建文件的操作,返回值int为对应文件的描述夫。Flags表示进程将如何访问文件(只读/只写/可读可写),其中当flags参数为一个或多个位掩码的或时,会提供一些额外指示,mode制定了新文件的访问权限。
Close函数
int close(int fd)
关闭一个打开的文件的操作,成功返回0,出错返回.
Read函数
sszie-t read(int fd,void *buf,szie-t n)
文件的读操作,若成功,返回读的字节数,若EOF返回0,若出错则返回1.从表舒服为fd的当前位置复制至多n个字节到内存位置buf。
Write函数
sszie-t write(int fd, const void *buf,size-t n)
文件的写操作,若成功,则返回写的字节数,若出错,则返回-1.从内存位置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;
}
执行时,先进行函数参数传递,传递参数列表的第一个参数。
然后,调用vsprintf函数,来实现一种格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。并返回要打印出来的字符串的长度。
然后,printf调用最基础的write函数。应用程序对操作系统请求打印出buf中的数据,而操作系统回应这种请求,调用一些硬件操作函数来实现打印功能,并在此过程中,限制了应用程序的权限。
在write中,会给寄存器传递几个参数(哟过来做出变形),然后调用一个sys-call函数。来确保自己后续能够根据参数的方式来进行相应的变化,而syscall也执行了显示格式化字符串的相关功能。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux系统通过文件的模型化处理,得到了对I/O设备统一的接口管理,将I/O设备当作文件,并执行文件的打开,关闭,读写等操作。最后,有简单分析了一下两个具体函数printf,getchar函数实现的具体原理。
结论
Hello的C程序首先经历了预处理,加入了相关的头文件代码,以及相关的宏常量替换等,相当于对C程序进行了扩展。
然后,对预处理程序进行编译操作,将其转换成汇编语言文件,这种文件,统一了不同的高级语言,使之有一个相对统一的低级语言输出。
然后,对汇编程序进行汇编操作,使之形成一个可重定位的目标文件,实现了将汇编程序转换成机器能够识别的机器语言指令,对对应的数据,代码,堆,栈都初步地分配了一下相应的地址空间(并未真正在内存中分配,只是根据这种初步的分配结果,后续进行重定位操作,得到真正的地址空间)。
然后,链接器将可重定位目标文件进行重定位,将其和一些其用到的外部共享库链接在一起,为其共同的分配对应的地址,然后,这就是一个可执行的目标文件了。
然后,便是对程序的进程管理。Shell如何实现其功能,发挥好与内核交互的作用。同时,也讲解了异常处理,在程序运行过程中,不同命令的执行,对不同的异常信号做出何种反应。最后,简单介绍了几个和进程管理的几个函数,如fork函数,execve函数的执行。
在实现进程的运行时,还涉及到了相关的内存管理,如何为每个进程分配对应的虚拟内存。以及如何进行虚拟内存和物理内存的地址翻译,怎么进行页式管理。以及如何在三级cache下,进行物理内存的访问。同时,结合进程的一些相关函数execve,fork,具体分析其在内存映射下的相关实现。
最后,介绍了文件在系统输入输出的模型化作用,了解Linux系统如何对文件进行模型化,并在该模型化的基础上,提供统一的接口,以实现对不同IO设备的统一管理。然后,简单介绍了一下hello运行需要的printf函数的相关介绍。
这样,我们就了解了一个简单的helloC程序如何从一个C文件变成一个可以在系统中执行的程序。
附件
hello.i文件:预处理后的文件,对源文件进行了一些简单的调整,如:针对#后的内容,加载对应的文件的内容到.c文件中。用来观察宏替换,头部库的引入,了解预处理对c程序的作用。
hello.s文件:编译器翻译.i文件后的汇编语言程序。将文件变成了一系列的低级机器语言指令,为不同的高级语言的不同编译器提供了通用的输出语言。用力啊研究C语言与汇编语言的对应,了解汇编语言的特点(如函数调用,控制转移等),并据此来了解汇编前后的区别。
hello.o文件:汇编器将.s文件翻译成机器语言指令,并将这些指令打包成可重定位的目标程序(二进制文件),为计算机将其运行奠定基础。用来研究其elf文件,并反汇编来与汇编语言进行对比,了解编译过程达到的效果,并于最终链接后的可执行文件形成对比,了解链接的作用。
hello/hello.out文件:最后经过链接后的可执行文件。这个文件已经可以被计算机运行了。来运行hello程序,并在命令行中输入命令来了解系统对进程的管理,通过对其的edb解析,了解程序的内存等方面的存在形式,根据其elf'文件与可重定位的elf文件进行对比来了解链接实现的作用。