计算机科学与技术学院
2022年5月
本文从整体上介绍了hello程序P2P和020的过程,阐述了hello代码经过怎样的过程最终又是怎样在计算机上运行,以及hello进程创建直到回收的全部过程,通过对这些过程的分析,我们能更好的了解计算机系统的底层结构,并且进一步了解整个程序运行的的生命周期
关键词:P2P,O2O,计算机系统,程序生命周期
目 录
第1章 概述
1.1 Hello简介
P2P:From Program to Process。
如图所示,在Linux中,hello.c经过cpp预处理得到heiio.i、再经ccl编译得到hello.s、再经as的汇编得到可重定位目标程序、ld链接成为可执行目标程序hello,在shell中输入启动命令后,shell为其fork产生子进程。
020:之后shell调用execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。
1.2 环境与工具
1.2.1 硬件环境
Intel i7 9750H 4内存+450G硬盘 百兆局域网
1.2.2 软件环境
1.2.3 开发工具
gcc、g++、as、ld、vi/vim/gedit、Code Blocks IDE、Visual Studio 2022、EDB
1.3 中间结果
hello: 链接之后的可执行目标文件
hello.c: 源文件
hello.elf: hello的ELF格式
hello.i: 预处理产生的文本文件
hello.o: 汇编产生的可重定位目标文件
hello.objdmp: hello的反汇编代码
hello.s: 编译产生的汇编文件
hello.txt: hello.o的ELF格式
1.4 本章小结
本章根据hello自白介绍了hello的p2p,020的过程,说明了开发环境与使用的工具,以及实验中间的各种文件。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器cpp根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,将引用的所有库展开合并成为一个完整的文本文件。
主要作用如下:
(1):删除“#define”并展开所定义的宏
(2):处理所有条件预编译指令,如“#if”,“#ifdef”, “#endif”等
(3):插入头文件到“#include”处,可以递归方式进行处理
(4):删除所有的注释“//”和“/* */”
(5):添加行号和文件名标识,以便编译时编译器产生调试用的行号信息
2.2在Ubuntu下预处理的命令
在Ubuntu下预处理的命令为gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
如图所示,在Linux中执行预处理命令,得到hello.i(预处理产生的文本文件),hello共23行,而hello.i中有3091行,并且不含“#define”,“#if”,“#ifdef”, “#endif”,“//”,“/* */”,并且在hello的最后是我们最初hello.c中的main函数,而调用的头文件都进行了插入展开。
2.4 本章小结
本章介绍了预处理的概念与作用,介绍了在Linux中预处理命令的使用方法,以及对hello.c预处理结果进行了解析。
第3章 编译
3.1 编译的概念与作用
概念:编译是指将高级语言程序程序翻译成汇编语言文本程序,编译器把预处理后的文本文件hello.i文件进行一系列语法分析及优化后生成相应的汇编语言文件hello.s文件的过程。
编译的作用:
编译能够将不同的高级语言转化为通用的低级语言指令,即汇编语言
3.2 在Ubuntu下编译的命令
在Ubuntu下编译的命令为:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1编译结果
如图所示,在Linux中使用编译命令生产了.s的汇编程序。
3.3.2数据
(1)整型变量
由汇编程序的内容可知,argc作为main函数的第一个参数是一个int整型变量,在执行时先被存储在寄存器%edi中,然后又通过movl指令被存入-20(%rbp)。而根据.L2所示,函数内部的局部变量i被存储在-4(%rbp)(即用户栈)中,初始化为0,占用4B大小,它没有标识符,也不需要被声明。
- 字符串
在hello.s中,字符串的信息都被放在了.rodata节中。如下图:LC0为默认输出,第一个字符串.LC0包含汉字,每个汉字被编码为三个字节,LC1为argc=4时的输出
- 指针数组
argv[]数组存放我们在命令行中输入的字符串。hello.s的argv的首地址被存放在寄存器%rsi中,后来被存放在栈中,在L4中可以看到-32(%rbp)中的首地址被再次调用,如下图所示:
3.3.3赋值与操作
(1)赋值
汇编程序的赋值通过mov语句实现,如下图对i赋值为零。
(2)算术计算
算术计算一般通过add,sub,imul,inc语句实现,而移位操作可以通过SAL、SAL等语句实现(在hello中没有体现),如下图执行i++,
下图为算术运算和移位操作常用的语句。
(3)关系操作
main函数中需要判断argc是否为4,而argc作为第一个参数应该存放在%edi中,且结合前面的分析可知它又被存入-20(%rbp)中,如下图,程序判断argc是否为4,若相等,就跳转到.L2。
(4)数组/指针/结构操作
argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别指向我们从shell终端输入的字符串。如下图所示,通过语句addq $16,%rax以及addq $8,%rax分别得到argv[1]和argv[2]。
3.3.4控制转移
在main函数中存在if(argc!=4)这样的语句,而在汇编程序中它通过控制转移实现,如下图
第24行语句中的内容将argc与4比较,根据条件码选择跳转,如果argc==4,那么程序就顺序执行,否则跳转至.L2
3.3.5函数调用
函数通过跳转到特定代码执行待定函数之后再返回来实现功能。函数一般是在栈中实现的,函数调用可分为如下过程:
1,传递参数给被调用者
在64位栈结构中按照:%rdi,%rsi,%rdx,%rcx,%r8,%r9的顺序传递参数,从第七个参数开始放在调用者栈结构中。
2,调用函数
call指令会将返回地址压入栈中,并且将rip的值指向所调用函数的地址,等函数执行完之后调用ret恢复栈帧。
3,函数进行操作
函数在自己栈帧内进行操作,返回值存入rax中。
4,函数返回
函数返回时,如果有返回值,则先将返回值存在%rax中,再返回调用函数。
hello.c文件中调用的函数有:main()、printf()、exit()、sleep()、atoi()、getchar()。
(1)main函数
参数传递:第一个参数是argc(int型),第二个参数是argv[](char *型),分别存放在寄存器%rdi和%rsi中;
调用:main函数被系统函数__libc_start_main调用,call指令将main函数的地址分配给%rip,随后调用main函数。
函数操作中的栈维护:main函数使用栈指针,同时使用栈帧%rbp来记录使用情况。如图main函数在进入时先将rsp减去32形成一个栈空间结构,然后开始进行各种操作。
返回:可以看到main函数的尾部将0压入到eax中,然后调用了leav平衡栈帧,调用ret返回退出。
(2):printf函数
参数传递:call puts时只传入了字符串参数首地址;for循环中call printf时传入了 argv[1]和argc[2]的地址;
调用:第一个printf()由call puts@PLT调用,第二个printf()由call printf@PLT调用;
返回:从printf中返回。
(3):exit函数
参数传递:将1传给了%edi,完成参数传递。
调用:通过call exit@PLT函数,进行函数调用。
返回:从exit返回。
(4):sleep函数
参数传递:将atoi的返回值%eax通过%rdi传递给sleep函数
调用:调用了sleep函数,将控制传送。
返回:从sleep中返回。
(5):atoi函数
参数传递:将argv[3](字符串)通过%rdi传递给atoi函数。
调用:通过call atoi@PLT函数,进行函数调用。
返回:从atoi中返回。
(6):getchar函数
参数传递:无;
调用:call getchar@PLT调用getchar;
返回:从getchar中返回
3.4 本章小结
本章介绍了编译器如何用汇编语言实现对各种数据类型和操作的处理,以及hello中各种函数在汇编程序中的实现。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编是指as将汇编程序翻译成机器语言,把这些机器语言指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中。
作用:将汇编语言的程序翻译生成机器语言(二进制文件)。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
Linux汇编结果如图
4.3 可重定位目标elf格式
可重定位目标文件ELF格式如下图
现在使用readelf -a hello.o > hello.elf指令得到hello.elf文件,如下图所示
ELF头部以一个16字节的序列开始,描述生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器分析语法和解释目标文件的信息,其中包含ELF头大小、目标文件的类型、及其类型、节头部表的文件偏移,以及节头部表中条目的大小和数量,如下图所示。
4.3.2:节头部表
节头部表描述了不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。具体的描述包括节的名称、类型、地址和偏移量等,如下图所示:
4.3.3:重定位条目
当汇编器生成一个目标模块是,它并不知道数据和代码最终将放在内存中的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行目标文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。
4.3.4:符号表
.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
4.4 Hello.o的结果解析
接下来用objdump -d -r hello.o 分析hello.o的反汇编代码,并和hello.s对比
经过对比我们可以发现不同之处大致一下几点:
(1)操作数:
hello.s中的操作数时十进制,hello.o反汇编代码中的操作数是十六进制。
(2)分支转移:
跳转语句之后,hello.s中是.L2和.L3等段名称,而反汇编代码中跳转指令之后是相对偏移的地址。
(3)函数调用:
hello.s中,call指令之后直接是函数名称,而反汇编代码中call指令之后是函数的相对偏移地址。
(4)指令:
汇编中mov、push、sub等指令都有表示操作数大小的后缀,反汇编得到的代码中则没有。
4.5 本章小结
本章介绍了汇编的概念和作用,ELF的格式,并且比较了反汇编程序和汇编程序的异同。
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
作用:当程序调用函数库(如标准C库)中的一个函数printf,printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个函数必须通过链接器(ld)将这个文件合并到hello.o程序中,结果得到hello文件,它是一个可执行目标文件,可以被加载到内存中,由系统执行。另外,链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。
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 > hello1.elf生成hello的ELF格式文件,如下图所示
5.3.1.ELF头:
如下图所示,ELF头描述文件的总体格式,还包括程序的入口点。
5.3.2.节头:
Section Headers对hello中所有的节信息进行了声明,其中包括大小Size以及在程序中的偏移量Offset,因此根据Section Headers中的信息我们就可以用HexEdit定位各个节所占的区间(起始位置,大小)。其中Address是程序被载入到虚拟地址的起始地址。如下图所示
5.3.3.程序头:
如下图所示,Program Headers一共有8个段
(1)PHDR包含程序头表本身
(2)INTERP:只包含了一个section,在这个节中,包含了动态链接过程中所使用的解释器路径和名称。
(3)两个LOAD:第一个是代码段,第二个是数据段。在程序运行时需要映射到虚拟空间地址。
(4)DYNAMIC:保存了由动态链接器使用的信息。
(5)NOTE: 保存了辅助信息。
(6)GNU_STACK:堆栈段。
(7)GNU_RELRO:在重定位之后哪些内存区域需要设置只读。
5.3.4.段节:
5.3.5.重定位节:
5.4 hello的虚拟地址空间
使用edb加载hello,如下图。
可根据上述的程序头来查看各段虚拟空间的映射。
5.5 链接的重定位过程分析
5.5.1hello与hello.o的反汇编代码比较:
使用objdump指令查看hello的反汇编代码,如下图
与hello.o文件的反汇编代码进行比较可知:hello.o中的相对地址到了hello中变成了虚拟内存地址;在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数;hello中增加了.init和.plt节,和一些节中定义的函数;hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。
5.5.2hello的重定位过程分析:
重定位过程主要分为两个步骤:
(1)重定位节和符号定义。链接器将所有相同类型的节合并为同一类型的新的聚合节。包括hello.o在内的所有可重定位目标文件中的.data节被全部合并成一个节,这个节成为输出的可执行目标文件hello中的.data节。然后,连接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,hello中每条指令和包括.rodata、sleepsecs等全局变量都有唯一的运行时内存地址了。
(2)重定位节中的符号引用。链接器依赖于hello.o中的重定位条目,修改代码节和数据节中对每个符号的引用,使得它们指向正确运行时的地址。
5.6 hello的执行流程
分析hello的反汇编程序可知hello的执行流程如下:
(1)调用start函数
(2)调用__libc_start_main函数
(3)调用libc-2.27.so!__cxa_atexit
(4)调用libc-2.27.so!__new_exitfn
(5)调用__libc_csu_init
(6)调用__libc_csu_init
(7)调用_init函数
(8)调用libc-2.27.so!_setjmp函数
(9)调用-libc-2.27.so!_sigsetjmp函数
(10)调用–libc-2.27.so!__sigjmp_save函数
(11)调用main
(12)调用put
(13)调用exit
(14)调用ld-2.27.so!_dl_runtime_resolve_xsave
(15)调用ld-2.27.so!_dl_fixup
(16)调用–ld-2.27.so!_dl_lookup_symbol_x
(17)调用libc-2.27.so!exit
5.7本章小结
本章介绍了链接的概念与作用、hello的ELF格式,通过edb与反汇编分析了hello的虚拟地址空间、重定位过程和执行流程。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程就是一个执行中程序的实例.每次用户通过向shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程。
作用:进程为用户提供以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
2 简述壳Shell-bash的作用与处理流程
shell是一个应用程序,他在操作系统中提供了一个用户与系统内核进行交互的界面。他的处理过程一般是这样的:从终端读入输入的命令,将输入字符串切分获得所有的参数,如果是内置命令则立即执行,否则调用相应的程序为其分配子进程并运行,shell应该接受键盘输入信号,并对这些信号进行相应处理。
6.3 Hello的fork进程创建过程
Shell(父进程)通过fork 函数创建一个新的运行的子进程.新的子进程几乎但不完全与父进程相同.子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈.子进程进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork 时,子进程可以读写父进程中打开的任何文件。
Hello的fork过程如图所示
6.4 Hello的execve过程
execve 函数加载并运行可执行目标文件filename, 且带参数列表argv 和环境变量列表envp .只有当出现错误时,例如找不到filename, execve 才会返回到调用程序.所以,与fork 一次调用返回两次不同, execve 调用一次并从不返回。在hello中,execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段.新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容.最后加载器设置PC指向_start地址,_start最终调用main函数.除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制.直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
6.5 Hello的进程执行
在hello进程执行过程中使用上下文切换的方法,如图所示
hello在用户模式下运行,当hello调用sleep后进入内核模式,内核处理休眠主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程继续进行自己的控制逻辑流。
6.6 hello的异常与信号处理
异常可分为四类分别是中断,陷阱,故障和终止
(1)中断处理:中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序。
(2)陷阱处理:陷阱是有意的异常,是执行一条指令后的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
(3)故障处理:故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果故障处理程序能够修正这个错误,它就将控制返回给引起故障的指令,从而重新执行它,否则,处理程序返回到内核中的 abort 例程,abort 例程会终止引起故障的应用程序。
(4)终止处理:终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如 DRAM 或者 SRAM 位被损坏时发生的奇偶错误。终止处理程序不会将控制返回给应用程序。
下面展示hello进程面对的各种情况:
(1)正常执行。如下图所示,为hello程序正常运行的结果,接着输入命令ps后执行,程序后台并没有hello进程正在执行了,说明进程正常结束,已经被回收了。
(2)不停乱按:结果是程序运行情况和前面的相同,不同之处在于shell将我们刚刚乱输入的字符除了第一个回车按下之前的字符当做getchar的输入之外,其余都当做新的shell命令,在hello进程结束被回收之后,将会在命令行中尝试解释这些命令。中间没有任何对于进程产生影响的信号被产生。
(3)运行CTRL+Z:运行中按CTRL+Z之后,将会发送一个SIGTSTP信号给shell。然后shell将转发给当前执行的前台进程组,使hello进程挂起。此时,我们输入ps命令,查看当前存在的进程,如图所示
输入jobs命令:
输入pstree命令:以树状图显示进程间的关系
使用fg指令完成剩下的执行。
使用kill命令:运行hello程序,将其挂起一次,使用kill函数杀死它。
CTRL+C命令:在hello程序运行时输入CTRL+C会导致内核发送一个SIGINT信号到前台进程组的每个进程。默认情况下,结果是终止前台作业。
6.7本章小结
本章介绍了进程的概念和作用,shell的基本原理,shell如何调用fork和execve进程hello,hello进程在执行时会遇到的各种情况。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:程序代码经过编译后出现在 汇编程序中地址.逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址.
线性地址&虚拟地址:逻辑地址经过段机制后转化为线性地址(虚拟地址),是逻辑地址到物理地址变换之间的中间层.在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址.是一个32位无符号整数,可以用来表示高达4GB的地址,也就是,高达4294967296个内存单元.线性地址通常用十六进制数字表示,值得范围从0x00000000到0xfffffff)程序代码会产生逻辑地址,通过逻辑地址变换就可以生成一个线性地址.如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址.如果没有启用分页机制,那么线性地址直接就是物理地址.
物理地址:CPU地址总线传来的地址,由硬件电路控制(现在这些硬件是可编程的了)其具体含义.物理地址中很大一部分是留给内存条中的内存的,但也常被映射到其他存储器上(如显存、BIOS等).在没有使用虚拟存储器的机器上,虚拟地址被直接送到内存总线上,使具有相同地址的物理存储器被读写;而在使用了虚拟存储器的情况下,虚拟地址不是被直接送到内存地址总线上,而是送到存储器管理单元MMU,把虚拟地址映射为物理地址.
在hello程序中,他就表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符、段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。
索引号,是“段描述符(segment descriptor)”,段描述符具体地址描述了一个段。这样,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,由8个字节组成。
Base字段:它描述了一个段的开始位置的线性地址。 Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。
当段选择符中的T1字段=0,表示用GDT;若为1,表示用LDT。 GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。再看图7-3比起来要直观些:
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
(1)看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
(2)拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
(3)把Base + offset,就是要转换的线性地址了。 还是挺简单的,对于软件来讲,原则上就需要把硬件转换所需的信息准备好,就可以让硬件来完成这个转换了。
7.3 Hello的线性地址到物理地址的变换-页式管理
首先Linux系统有自己的虚拟内存系统,其虚拟内存组织形式如下图,Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。
而物理内存被划分为一小块一小块,每块被称为帧(Frame)。分配内存时,帧是分配时的最小单位,最少也要给一帧。在虚拟内存中,与帧对应的概念就是页(Page)。
线性地址的表示方式是:前部分是虚拟页号后部分是虚拟页偏移。
CPU通过将逻辑地址转换为虚拟地址来访问主存,这个虚拟地址在访问主存前必须先转换成适当的物理地址。CPU芯片上叫做内存管理单元(MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址。然后CPU会通过这个物理地址来访问物理内存。
页表就是一个页表条目(PTE)数组,虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个PTE。PTE是由一个有效位和一个n个字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。
MMU利用虚拟页号(VPN)来在虚拟页表中选择合适的PTE,当找到合适的PTE之后,PTE中的物理页号(PPN)和虚拟页偏移量(VPO)就会组合形成物理地址。其中VPO与PPO相同,因为虚拟页大小和物理页大小相同,所需要的偏移量位数也就相同。此时,物理地址就通过物理页号先找到对应的物理页,然后再根据物理页偏移找到具体的字节:
1.如果有效位是 0+NULL 则代表没有在虚拟内存空间中分配该内存;
2.如果是有效位 0+非 NULL,则代表在虚拟内存空间中分配了但是没 有被缓存到物理内存中;
3.如果有效位是 1 则代表该内存已经缓存在了物理内存中,可以得到其 物理页号 PPN,与虚拟页偏移量共同构成物理地址 PA。
7.5 三级Cache支持下的物理内存访问
获得了物理地址VA之后,使用CI(倒数7-12位)进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中,根据数据偏移量CO(后6位)取出数据返回.
如果没有匹配成功或者匹配成功但是标志位是1,则不命中,向下一级缓存中查询数据(L2 Cache->L3 Cache->主存),查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU(Least frequently used)进行替换.也就是替换掉最不经常访问的一次数据。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制,当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当两个进程中的任一个后来进行写操作时,写时复制就会创建新页面。因次,也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序.加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构.
2.映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的.代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零.
3.映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内.
4.设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点.
7.8 缺页故障与缺页中断处理
首先确认是不是一个合法的地址,即通过不断将这个地址与每个区域的vm_start&vm_end进行比对,如果并不是在一个区域里的话,就给出segmentation fault,因为它引用了一个不合法的地址。
然后确认访问权限是不是正确的。即如果这一页是只读页,但是却要做出写这个动作,那明显是不行的。如果做出了这类动作,那么处理程序就会触发一个保护异常,默认行为是结束这个进程。
确认了是合法地址并且是符合权限的访问,那么就用某个特定算法选出一个牺牲页,如果该页被修改了,就将此页滑出(swap out)并且swap in那个被访问的页,并将控制传递到触发缺页中断的那条指令,这条指令继续执行的时候就不会触发缺页中断,这样就可以继续执行下去。
7.9动态存储分配管理
7.9.1.动态内存分配的基本原理:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆.系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) .对于每个进程,内核维护着一个变量brk, 它指向堆的顶部.
分配器将堆视为一组不同大小的块的集合来维护.每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的.已分配的块显式地保留为供应用程序使用.空闲块可用来分配.空闲块保持空闲,直到它显式地被应用所分配.一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的.
显式分配器(explicit allocator):要求应用显式地释放任何已分配的块。
隐式分配器(implicit allocator):要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块.隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集( garbage collection).例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块.
7.9.2:简单的放置策略:
(1)首次适配:从头搜索,遇到第一个合适的块就停止;
(2)下次适配:从头搜索,遇到下一个合适的块停止;
(3)最佳适配:全部搜索,选择合适的块停止。
7.9.3.两种堆的数据结构组织形式:
隐式空闲链表:一个块是由一个字的头部,有效载荷,以及可能的一些额外的填充组成的.头部编码了这个块的大小,以及这个块是已分配的还是空闲的.如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零.因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息.在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的.
带边界标签的隐式空闲链表:在隐式空闲链表堆块的基础上,在每个块的结尾处添加一个脚部(footer),边界标记),其中脚部就是头部的一个副本。隐式链表的好处在于它简单,易于操作,但是它的坏处在于搜索时间实在是太长了,如果采用首次适配算法的话内存利用率会低,但如果采用最佳适配的话需要对于一整个堆进行搜索。
显式空间链表:根据定义,程序不需要一个空闲块的主体,所以实现空闲链表数据结构的指针可以存放在这些空闲块的主体里面。
显式空闲链表结构将堆组织成一个双向空闲链表,在每个空闲块的主体中,都包含一个pred(前驱)和succ(后继)指针。使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。
7.10本章小结
本章介绍了hello的存储器地址空间、intel的段式管理、页式管理、三级cache支持下物理内存访问, hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有的IO设备都被模型化为文件,而所有的输入输出都被当作对相应文件的读和写来执行。
设备管理:将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。
8.2 简述Unix IO接口及其函数
Unix IO接口:打开文件,内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k。关闭文件。当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中
Unix I/O函数:
(1)open 函数:打开一个已存在的文件或者创建一个新文件的
函数原型:int open(char *filename, int flags, mode_t mode);
open 函数将filename 转换为一个文件描述符,并且返回描述符数字.返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。返回:若成功则为新文件描述符,若出错为-1。
(2)close 函数:关闭一个打开的文件.
函数原型:int close(int fd);返回:若成功则为0, 若出错则为-1。
(3)read 和write 函数:执行输入和输出的.
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf .返回值-1表示一个错误,而返回值0 表示EOF.否则,返回值表示的是实际传送的字节数量.返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。返回:若成功则为写的字节数,若出错则为-1。
8.3 printf的实现分析
Printf函数体如图所示,参数中明显采用了可变参数的定义,可以看到*fmt是一个char 类型的指针,指向字符串的起始位置。
printf接受一个fmt的格式,然后将匹配到的参数按照fmt格式输出,fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。
printf调用的外部函数vsprintf。
它接受一个格式化的命令,并把指定的匹配的参数格式化输出。
看看语句i = vsprintf(buf, fmt, arg);
vsprintf返回的是一个长度,其实就是要打印出来的字符串的长度
再看看printf中后面的一句:write(buf, i); write,顾名思义:写操作,把buf中的i个元素的值写到终端。
所以说:vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
8.4 getchar的实现分析
getchar函数通过调用read函数来读取字符。read函数由三个参数,第一个参数为文件描述符fd,fd为0表示标准输入;第二个参数为输入内容的指针;第三个参数为读入字符的个数。read函数的返回值是读入字符的个数,若出错则返回-1。
当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回.
8.5本章小结
本章介绍了linux下IO设备的管理方法,以及Unix IO和Unix IO函数,并且分析了printf函数和getchar函数的实现。
结论
本文较为详细的介绍了hello的生命周期,从最初通过IO设备编写最初的C语言代码,存储在计算机磁盘中,经过一系列的编译:首先,在预处理器中,hello.c经过预处理,与所有的外部库合体成为了hello.i;再在编译器中经过编译,成为了hello.s;之后,汇编器又将hello.s转换为可重定位的目标文件hello.o;最后,连接器会把hello.o进行链接,生成可执行的目标程序hello。
在程序的运行过程中,首先我们在shell中输入“./hello”时,bash会新建一个进程,先fork一个子进程,然后清空当前进程的数据并加载hello,从函数的入口进入,开始执行,由于各种各样的原因,我们的hello可能会暂时的休息(系统调用或者计时器中断),这时我们保留当前进度,并切换上下文,内核去处理别的进程,提高效率。我们还可以输入信号来终止或挂起hello进程,hello输出信息时需要调用printf和getchar,而printf和getchar的实现需要调用Unix I/O中的write和read函数,而它们的实现需要借助系统调用I/O,在最后结束之后bash等到exit,作为hello的父进程回收hello。随后,内核删除他所有的数据,hello在计算机上的运行至此圆满结束。
附件
hello: 链接之后的可执行目标文件
hello.c: 源文件
hello.elf: hello的ELF格式
hello.i: 预处理产生的文本文件
hello.o: 汇编产生的可重定位目标文件
hello.objdmp: hello的反汇编代码
hello.s: 编译产生的汇编文件
hello.txt: hello.o的ELF格式
参考文献
[1] BryantO’Hallaron, D.R.R.E.,. (2019). 深入理解计算机系统(第三版). 北京: 机械工业出版社.
[2] https://blog.csdn.net/alanwalker1/article/details/103848576
[3] 百度安全验证