程序人生-Hello’s P2P
计算机系统
大作业
题 目 程序人生-Hello's P2P
专 业 软件工程
学 号 1183710216
班 级 1837102
学 生 刘牧依
指 导 教 师 史先俊
计算机科学与技术学院
2019年12月
摘 要
本文将分析hello程序运行的全部过程,给出在执行每一步操作时计算机系统的操作方式来展现hello程序从开始到结束的生命历程。旨在通过hello程序的分析,更加深入地理解计算机系统各个部分地运作方式和作用,使读者能够对计算机系统产生一个较为整体的认知,同时对计算机系统中不同部分的任务和实现方式获得一定程度了解。
关键词:hello;计算机系统;处理器体系结构;存储器体系结构;进程。
目 录
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 11 -
7.7 hello进程execve时的内存映射 - 11 -
第1章 概述
1.1 Hello简介
hello是一个最基础却最核心的程序,运行hello的整个过程体现了计算机系统方方面面的知识,总的来说,可以总结为P2P和020。
P2P:From Program to Process。编辑完成的hello.c程序先经过cpp预处理器的预处理得hello.i文件,ccl编译器将其编译获得hello.s文件,as汇编器再将其翻译为机器语言指令获得hello.o文件,再经过ld链接器进行链接得可执行文件hello。shell输入执行命令后,进程管理为其fork()一个子进程。即完成了P2P的过程。
020:From Zero to Zero。进程管理给hello进行execve操作,进行mmap操作将其映射到内存中,接着给运行的hello分配时间片来执行逻辑控制流。当程序运行结束后,父进程会回收hello进程,内核删除相关的数据。即完成了020的过程。
1.2 环境与工具
硬件环境:Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
8G RAM
软件环境:Windows 10 64位
Ubuntu 18.04 64位
开发工具:gcc,gdb,vscode,readlf,HexEdit
1.3 中间结果
hello.i :hello.c预处理后生成文件
hello.s :hello.i编译后生成的汇编文件
hello.o :hello.s汇编后生成的可重定位目标文件
hello :链接后生成的可执行文件
hello.elf :hello.o的elf格式文件
helloo.elf :hello的elf格式文件
1.4 本章小结
本章对hello做了总体的介绍,简述了hello的p2p和020过程,列出并介绍了本次实验的环境和工具,阐明了这次实验中产生了中间产物,是本次实验的总领部分,后文将依据本章做详细展开。
第2章 预处理
2.1 预处理的概念与作用
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,比如C程序开头经常出现的#include<stdio.h>语句,预处理器会读取系统头文件的stdio.h的内容,并把它直接插入程序文本中,结果就得到了另一个C程序,通常是以.i作为文件扩展名。
2.2在Ubuntu下预处理的命令
命令:gcc -E hello.c -o hello.i
图2-1:预处理命令
2.3 Hello的预处理结果解析
图2-2预处理结果(1)
图2-3:预处理结果(2)
经过预处理器的预处理,生成的hello.i文件的开头部分和结尾部分如上图,可以看到容量扩大了很多。hello.i文件中不再包含#include命令,而是将其展开。预处理器在对应位置找到文件并展开后,如果有包含其他文件,就递归展开,直到将所有文件全部展开为止,将这些全部插入程序文本。而文本最后可以看到仍然是hello.c的内容,如上图所示。
2.4 本章小结
本章说明了预处理器(cpp)将hello.c进行预处理,生成hello.i文件的过程,理解了生成文件的内容和意义。
第3章 编译
3.1 编译的概念与作用
编译是指编译器(ccl)将预处理生成的后缀.i的文件进行编译,生成后缀.s文件的过程。编译器将预处理得到的文件进行语法分析,无错误后将预处理后得到的代码翻译为汇编语言,生成汇编程序文件。hello.s文件在这一步被生成。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
图3-1:编译命令
3.3 Hello的编译结果解析
3.3.1:整型局部变量
在hello.c程序main函数中定义了局部变量i:
图3-2:整型局部变量
编译处理会将它存储到栈中:
可见i存储在栈中-4(%rbp)的位置。
3.3.2:数组(char指针类型)
在hello.c程序main函数参数中出现了char指针数组:
图3-3:char指针数组
编译处理会将它存储到栈中:
图3-4:压栈
首地址在%rsi传入,执行时栈会向下生长32字节,之后将数组压栈。
之后再次用到该数组时,从栈中读出即可:
图3-5:查看地址
起始地址为-32(%rbp),取出直接访问即可。
3.3.3:字符串
在hello.c中,存在需要输出的字符串:
图3-6:字符串
编译处理时将这两个字符串常量提取出来,声明在.rodata节中:
3.3.4:关系操作
在hello.c中,有两处涉及到了关系操作:
图3-7:关系操作
编译处理时使用cmp指令处理了这些操作:
其中第二条是通过i和7的比较确定i是否小于8,
3.3.5:控制转移
在hello.c中,两次输出中包含常量字符串:
图3-8:控制转移
编译处理时,由于常量字符串被提前声明,在这里输出时使用了控制转移:
上图为argc与4的比较,如果相等则跳转到L2,在原程序里体现为不进入if语句,如果不相等,不跳转继续执行,体现为进入if语句。
上图为for循环的判断部分,当i小于等于7时,跳转到L4,L4实现的时输出语句的相关操作。
3.3.6:算术操作
例如在hello.c中的变量i不断累加的过程:
图3-9:算术操作(1)
在编译过程中:
图3-10:算术操作(2)
第一个是源操作数,第二个是目标操作数,实现了将i加1的工作。
事实上,算术操作不仅只在hello.c中出现算术操作的地方存在,比如在栈的存储过程中,栈指针的变动也依赖于算术操作:
图3-11:算术操作(3)
上图将栈指针减小32,意为将要存储数据。
3.3.7函数操作:
hello.c中调用了printf()函数、exit()函数、sleep()函数:
图3-12:函数
printf()函数:将字符串常量的首地址存入寄存器作为参数传递,并使用call调用。
图3-13:call调用(1)
exit()函数:将1存入寄存器作为参数传递,并使用call调用。
图3-14:call调用(2)
sleep()函数:将规定位置的值存入一个新的寄存器作为参数,使用call调用。
图3-15:call调用(3)
3.4 本章小结
本章说明了P2P过程中的编译部分,并对生成的汇编程序中涉及到的C语言各种数据类型和各类操作做了说明。编译的过程是编译器将预处理后得到的文件进一步翻译为汇编语言的过程。
第4章 汇编
4.1 汇编的概念与作用
汇编器将编译生成的.s后缀文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在.o后缀文件中。翻译成机器语言指令后机器可以直接读取分析。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
图4-1:汇编命令
4.3 可重定位目标elf格式
命令:readelf -a hello.o > hello.elf生成elf文件:
图4-2:生成elf文件命令
下面分段分析elf文件内容:
开始是一段elf头,包括了机器的相关信息,例如大小端信息、负数存储信息、
字的大小等。还有.o文件的相关信息,例如入口点地址、节头部的大小等。这些信息可以帮助链接器完成接下来的工作。
图4-3:ELF头
接下来是节头部表,目标文件中的每个节都有一个固定的条目体现在这个表中,指明了各个节的信息,包括名称、类型、起始地址和偏移量。
图4-4:节头部表
接下来是重定位节,说明了重定位的详细信息。
图4-5:重定位节
当汇编器生成一个目标模块时,它并不知道数据和代码最终存放在内存中的什么位置,也不知道这个模块引用的任何外部定义的函数和全局变量的位置。所以当汇编器遇到对位置未知的引用时,就会生成重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。
接下来介绍elf重定位条目的格式:
图4-6:重定位条目格式
其中offset是需要被修改的引用的节偏移。symbol标识被修改引用应该指向的符号。type告知链接器如何修改新的引用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
elf定义了32种不同的重定位类型,有些相当隐秘。我们只关心其中最基本的重定位类型:R_X86_64_PC32重定位一个使用32位PC相对地址的引用;R_X64_64_32重定位一个使用32位绝对地址的引用。
对于上述两种重定位类型,重定位算法分别是:
图4-7:重定位算法
其中ADDR(s)和ADDR(r.symbol)分别为节和符号的运行时地址。
从上表中获取的重定位信息结合算法即可完成重定位操作。
最后是一个符号表,存放在程序中定义和引用的函数和全局变量的信息:
图4-8:符号表
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o生成汇编代码:
图4-9:hello.o汇编代码
与hello.s对比发现:
(1)操作数在hello.s中为十进制,在这里为十六进制;
图4-10:对比(1)
(2)分支转移地址表示,hello.s上为".L1"等段名称,在这里为相对偏移的地址:
图4-11:对比(2)
(3)函数调用时,hello.s上为函数名,在这里call指令后是调用函数的相对偏移地址。
图4-12:对比(3)
(4)访问全局变量时,hello.s上是通过".LC1(%rip)"的形式访问,在这里是以"0x0(%rip)"的形式访问,添加了重定位条目:
图4-13:对比(4)
4.5 本章小结
汇编操作将汇编语言转化为机器语言,可执行部分已经完成,为下一步链接做好准备。
第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-1:链接命令
5.3 可执行目标文件hello的格式
图5-2:生成elf文件
执行上述命令生成elf文件。
首先仍然是elf头,给出了机器的基本信息,和之前的elf头相比,给出了程序的入口点,程序头等,表示已经完成了链接:
图5-3:elf头
之后为各节的信息,包括名称、类型、起始地址等。这些节已经被重定位至最终运行时的地址。
图5-4:各节信息
5.4 hello的虚拟地址空间
图5-5:程序头
上图给出了elf文件中的程序头部表,描述了可执行文件连续的片和连续的内存段的映射关系。其中包括段:
PHDR:程序头表
INTERP:程序执行前需要调用的解释器
LOAD:程序目标代码和常量信息
DYNAMIC:动态链接器使用信息
NOTE:保存辅助信息
GNU_EH_FRAME:保存异常信息
GNU_STACK:标志栈是否可用的标志信息
GNU_RELRO:保存在重定位之后只读信息的位置
以PHDR为例,在edb中查看PHDR段。
图5-6:PHDR段信息
起始地址为0x00400040,经对比与elf文件中所示一致。
5.5 链接的重定位过程分析
命令:objdump -d -r hello
得到hello的汇编代码。
图5-7:hello的汇编代码
对比本次生成的汇编代码和hello.o生成的汇编代码的不同。
(1)hello汇编代码中出现了更多函数,hello.o的汇编代码中只出现了main函数的名字,而hello的汇编代码中出现了_init,.plt等函数名。这体现了很多非hello.c源程序中生命的函数被链接到了hello中。
图5-8:.plt函数
(2)函数调用地址变化,在hello.o汇编代码中函数的地址不确定,在hello汇编代码中地址确定下来。如.plt函数的地址为0x401020。
图5-9:.plt函数地址
(3)数据引用地址变化,在hello.o汇编代码中很多数据的地址不确定,在hello汇编代码中地址确定下来。
图5-10:数据地址
结合hello.o的重定位项目,可以得出在重定位过程中,链接器将符号解析完成后,就将代码中的所有符号引用都与一个符号定义关联。这样链接器就可以按照各个内容的具体大小来进行重定位,合并输入模块,并为每个符号分配运行时的地址。
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的动态链接分析
动态链接库中的函数在程序执行的时候才会确定地址,所以编译器无法确定其地址。为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。延迟绑定通过两个数据结构之间简洁但又有些复杂的交互来实现,即过程链接表(PLT)和全局偏移量表(GOT)。
过程链接表(PLT):PLT是一个数组,其中每个条目是16字节代码。PLT [0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。每个条目都负责调用一个具体的函数。
全局偏移量表(GOT):GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT [0]和GOT [1]包含动态链接器在解析函数地址时会使用的信息。GOT [2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
调用前的GOT:
图5-11:调用前的GOT
调用后的GOT:
图5-12:调用后的GOT
对比信息,可见动态链接器解析函数的地址加入了信息中。
5.8 本章小结
本章理解了链接的过程,链接就是将多个代码、数据合并为一个可执行文件的过程。展示了hello的虚拟空间和反汇编代码,对重定位的过程分析更加深入。至此,可执行目标文件以及生成,接下来可以运行使用。
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程为用户提供了这样的假象,我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断地执行我们程序中地指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell俗称壳,是指"为使用者提供操作界面"的软件。同时它又是一种程序设计语言。作为命令语言,它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令。它作为用户操作系统与调用其他软件的工具。
处理流程:
(1)从终端读入输入的命令。
(2)将输入字符串切分,分析输入内容,解析命令和参数。
(3)如果命令为内置命令则立即执行,如果不是内置命令则创建新的进程调用相应的程序执行。
(4)在程序执行期间始终接受键盘输入信号,并对输入信号做相应处理。
6.3 Hello的fork进程创建过程
在终端输入命令运行hello程序:
图6-1:运行hello命令
由于hello不是一个内置命令,故解析后执行当前目录下的可执行目标文件hello,shell作为父进程通过fork函数为hello创建一个新的进程作为子进程。通过fork函数,子进程得到与父进程用户级虚拟地址空间相同但独立的一份副本,包括代码和数据段、堆、共享库、用户栈。hello进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程还可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
6.4 Hello的execve过程
子进程创建后,shell调用execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。之后当出现错误时,例如找不到hello,execve才会返回到调用程序。
在execve加载了hello后,它调用启动代码,启动代码设置栈,并将控制转移给新程序的主函数main,此时用户栈已经包含了命令行参数和环境变量,进入main函数后开始逐步运行程序。
6.5 Hello的进程执行
首先阐述一些概念。
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象构成。
时间片:一个进程执行它的控制流的一部分的每一个时间段。
调度:在执行过程中,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。
用户态:进程运行在用户模式中时,不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作,也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。
核心态:进程运行在内核模式中时,可以执行指令集中的任何指令,并且可以访问内存中的任意位置。
用户态与核心态转换:程序在涉及到一些操作时,例如调用一些系统函数,内核需要将当前状态从用户态切换到核心态,执行结束后再改回用户态。
hello执行时存在逻辑控制流,多个进程的逻辑控制流在时间上可以交错,表现为交替运行。进程控制权的交换需要上下文切换。操作系统内核使用一种成为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。
例如hello中对sleep的调用,内核中的调度器将hello进程挂起,进入内核模式,在执行结束后,内核会恢复hello被抢占时的上下文,回到用户模式。
6.6 hello的异常与信号处理
会出现四种异常:中断、陷阱、故障、终止。
会出现的信号:SIGSTP、SIGCONT、SIGKILL、SIFGINT等。
中断是来自I/O设备的信号,异步发生,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。
程序运行时通过键盘操作给信号:
(1)运行中不停乱按,将所输内容输出到屏幕上。
图6-2:运行时乱按
(2)运行中回车,会将按键操作的输入放到缓存区中,程序运行结束执行。
图6-3:运行时回车
(3)运行中按ctrl-c,shell父进程收到SIGINT信号,信号处理函数将hello进程终止并回收。
图6-4:运行时ctrl-c
(4)运行中按ctrl-z,shell父进程收到SIGSTP信号,信号处理函数将hello进程挂起。
图6-5:运行时ctrl-z
(5)ctrl-z后运行fg命令发送SIGCONT信号继续执行hello。
图6-6:ctrl-z后fg命令
(6)ctrl-z后运行kill命令发送SIGKILL信号杀死hello。
图6-7:ctrl-z后kill命令
(7)ctrl-z后运行ps、jobs、pstree命令,输出相关信息。ps命令输出当前系统中的进程;jobs命令输出当前已启动的任务状态;pstree命令输出进程间的树状关系。
图6-8:ctrl-z后ps命令、jobs命令、pstree命令
6.7本章小结
本章介绍了进程的概念和作用,结合fork和execve函数说明了hello进程的执行过程,之后分析了进程执行过程中异常和信号的处理问题。至此,可执行目标文件成功被加载至内存并执行。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址由选择符和偏移量组成,经过寻址方式的计算或变换得到内存储器中的物理地址。hello程序经过编译后出现在汇编代码中的地址。
线性地址:同虚拟地址。
虚拟地址:现代系统提供了一种对主存的抽象概念,叫做虚拟内存。使用虚拟寻址,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送到内存之前先转换为适当的物理地址。将一个虚拟地址转换为物理地址的任务叫地址翻译。如下图:
图7-1:虚拟地址翻译
物理地址:程序运行时加载到内存地址寄存器中的地址,是物理内存意义上真正的地址。表示hello程序运行时某指令或某数据在内存地址上具体的某个位置存储着相关信息。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理是实现逻辑地址到线性地址转换机制的基础,段的特征有段基址、段限长、段属性。这三个特征存储在段描述符中,用以实现从逻辑地址到线性地址的转换。段描述符存储在段描述符表中,通常,我们使用段选择符定位段描述符在这个表中的位置。每个逻辑地址由16位的段选择符和32位的偏移量组成。
段基址规定了线性地址空间中段的开始地址。在保护模式下,段基址长32位。因为基址长度和寻址地址的长度相同,所以段基址可以是0-4GB范围内的任意地址。
和一个段有关的信息需要8个字节来描述,这就是段描述符。为了存放这些描述符,需要在内存中开辟出一段空间。在这段空间里所有的描述符都在一起集中存放,这就构成了一个描述符表,描述符表分为两种,GDT和LDT。
一些全局的段描述符,就放在"全局段描述符表(GDT)"中,一些局部的,例如每个进程自己的段描述符,就放在的"局部段描述符表(LDT)"中。
介绍一个完整的变换过程,给出一个完整的逻辑地址[段选择符:段内偏移地址]。首先看段选择符判断当前转换时GDT中的段还是LDT中的段,再根据相应寄存器得到其地址和大小。之后拿出段选择符中的前13位,在对应地址中查找到对应的段描述符,这样就知道了基址。根据基址和偏移量结合,就得到了所求的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
分页机制是实现虚拟存储的关键,位于线性地址与物理地址的变换之间设置。虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘上的数据被分割成块,这些块作为磁盘和主存之间的传输单元。VM系统通过将虚拟内存分割为称为虚拟页为大小固定的块来处理这个问题。每个虚拟页的大小固定。类似地,物理内存被分割为物理页,大小与虚拟页相同。
同任何缓存一样,虚拟内存系统必须用某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM,替换这个牺牲页。
页表是一个存放在物理内存中的数据结构,将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时读取页表。操作系统负责维护页表中的内容,以及再磁盘与DRAM之间来回传送页。
内存分页管理的基本原理是将整个内存区域划分成固定大小的内存页面。程序申请使用内存时就以内存页位单位进行分配。转换通过两个表,页目录表PDE(也叫一级目录)和二级页表PTE。进程的虚拟地址需要首先通过其局部段描述符变换为CPU整个线性地址空间中的地址,然后再使用页目录表和页表PTE映射到实际物理地址上。
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE,以便将虚拟地址翻译为物理地址。为了降低时间开销,MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓冲器。
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块,下述为一个从TLB中获取物理地址的过程:
(1)CPU产生一个虚拟地址。
(2)MMU从TLB中取出相应的PTE。
(3)MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
(4)高速缓存/主存将所请求的数据字返回给CPU。
下图给出了含TLB的虚拟地址翻译流程:
图6-2:虚拟地址翻译
Inter Core i7实现支持48位虚拟地址空间和52位物理地址空间,使用4KB的页。X64 CPU上的PTE为64位,所以每个页表一共有512个条目。512个PTE条目需要9位VPN定位。再四级页表的条件下,一共需要36位VPN,因为虚拟地址空间是48位,故低12位是VPO。TLB四路组联,共有16组,需要4位TLBI,故VPN的低4位是TLBI,高32位是TLBT。
CPU产生虚拟地址VA,VA传送给MMU,MMU使用前36位VPN作为TLBT+TLBI向TLB中匹配,如果命中,则得到40位PPN+12位VPO组合成52位物理地址PA。如果没有命中,MMU向页表中查询,CR3确定第一级页表的起始地址,9位VPN1确定在第一级页表中的偏移量,查询出第一部分PTE,以此类推最终在四级页表都访问完后获得PPN,与VPO结合获得PA,并向TLB中更新。
7.5 三级Cache支持下的物理内存访问
得到物理地址PA后,通过其访问物理内存,物理地址由CI(组索引)、CT(标记位)、CO(偏移量)组成。首先使用CI进行组索引,每组8路,对8路的块分别匹配标记位CT,如果匹配成功且块的有效位为1则命中,根据数据偏移量CO取出数据返回。如果没有匹配成功则不命中,向下一级缓存中查询数据,顺序是L1缓存到L2缓存到L3缓存到主存。查询到数据后,放置策略是如果映射到的组有空闲块则直接放置,否则产生冲突,采用最近最少使用策略驱逐块并替换新块进入。
下图给出了三级Cache的大致构造:
图6-3:三级Cache构造
7.6 hello进程fork时的内存映射
Shell通过fork函数为hello创建新进程,当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。
7.7 hello进程execve时的内存映射
hello调用execve后,execve在当前进程中加载并运行包含在可执行目标文件中的程序,用hello程序有效地代替了当前程序。当加载并运行可执行目标文件时,需要以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。
(3)映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。下图给出了私有区域和共享区域在内存映射时的位置。
图6-4:内存映射位置
(4)设置程序计数器PC。execve做的最后一件事是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
物理内存缓存不命中称为缺页。假设CPU引用了磁盘上的一个字,而这个字所属的虚拟页并没有缓存在DRAM中。地址翻译硬件会从内存中读取虚拟页对应的页表,说明这个虚拟页没有被缓存,触发一个缺页故障。
这个异常导致控制转移到内核的缺页处理程序,处理程序首先判断虚拟地址A是否合法,如果不合法则触发段错误终止进程。如果合法则判断试图进行的内存访问是否合法,如果不合法则出发保护异常终止进程。如果合法则根据页式管理的规则,选择一个牺牲页,用新页替换掉,更新页表并再次触发地址翻译硬件进行翻译。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器分为两种:显式分配器和隐式分配器。显式分配器要求应用显式地释放人设已分配地块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾回收器,而自动释放未使用的已经分配的块的过程叫做垃圾收集。
malloc使用的是显式分配器,通过free函数释放已分配的块。
下面分别介绍两种分配器:
(1)隐式空闲链表分配器。我们可以将堆组织为一个连续的已分配块和空闲块的序列,空闲块是通过头部中的大小字段隐含地连接着的,这种结构为隐式空闲表。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块地集合。一个块是由一个字的头部、有效载荷、可能的填充和一个字的脚部,其中脚部就是头部的一个副本。头部编码了这个块的大小以及这个块是已分配还是空闲的。分配器就可以通过检查它的头部和脚部,判断前后块的起始位置和状态。
(2)显示空闲链表分配器。将堆组成一个双向空闲链表,在每个空闲块中,都包含一个pred和succ指针。一种方法是用后进先出(LIFO)的顺序来维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用。一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在的提高了内部碎片的程度。
7.10本章小结
本章介绍了hello的存储地址和存储空间,说明了虚拟内存相关知识,展示了各种地址表示的一步步转化,模拟了系统内部的一部分操作。并分析了进程的内存映射、缺页故障和缺页故障处理,还对动态内存分配器有了一定的了解。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
Unix I/O接口的统一操作:
(1)打开文件。一个应用程序要求通过内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个标识符。
(2)Linux shell创建的每个进程开始时都有三个打开的文件:标准输入、标准输出和标准错误。
(3)改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开始的字节偏移量。应用程序能够通过执行seek操作,现显式地设置文件的位置为k。
(4)读写文件。一个读操作就是从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file的条件,应用程序能够检测到这个条件。在文件结尾处并没有明确的"EOF符号"。
(5)关闭文件。当应用程序完成了对文件的访问后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
Unix I/O函数接口提供了以下函数共应用程序调用:
(1)open:进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的。open将filename转换为一个文件描述符,并且放回描述符数字。
(2)close:进程通过调用close函数关闭一个打开的文件。关闭一个已关闭的描述符会出错。
(3)read:应用程序通过read函数来执行输入。read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误;返回值0表示EOF;否则,返回值表示的是实际传扫的字节数量。
(4)write:应用程序通过write函数来执行输出。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
8.3 printf的实现分析
printf在stdio.h中声明,代码如下:
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;
}
其中,va_list是一个字符指针数据类型,代码中的赋值表示省略参数中的第一个参数,arg变量定位到了第二个参数,也就是第一个格式串。
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);
}
该函数的作用是格式化,按照格式fmt结合参数生成格式化之后的字符串。返回生成字符串的长度。
write的汇编代码如下:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。所以它先将参数传入寄存器中之后调用。
syscall的实现如下:
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
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中储存的是字节的ASCII码。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar函数在stdio.h中声明,代码如下:
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;
}
bb是缓冲区的开始,int变量n初始化为0,只有在n为0的情况下从缓冲区读入BUFSIZ个字节。返回时如果n大于0,那么返回缓冲区的第一个字符。否则返回EOF。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求。键盘中断服务程序先从键盘接口取得按键的扫描码,然后根据其扫描码判断用户所按的键并作相应的处理,最后通知中断控制器本次中断结束并实现中断返回。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux的IO设备管理方法、Unix IO接口及其函数,并分析了printf函数和getchar函数。
结论
hello的一生经历了这些过程:
(1)编写程序:源程序hello.c在编译器中完成。
(2)预处理:预处理器(cpp)将修改源程序,生成hello.i文件。
(3)编译:编译器(ccl)将hello.i文件翻译为汇编文件hello.s。
(4)汇编:汇编器(as)将hello.s文件翻译为二进制机器语言,生成可重定位目标文件hello.o。
(5)链接:链接器(ld)将可重定位目标文件hello.o和其他目标文件链接成为可执行文件hello。
(6)创建进程:shell进程调用fork函数为hello创建新进程,并调用execve函数运行hello。
(7)访问内存:通过MMU将需要访问的虚拟地址转化为物理地址,并通过缓存系统访问内存。
(8)动态申请内存:hello运行过程中可能会通过malloc函数动态申请堆中的内存。
(9)异常:hello运行过程中可能会产生各种异常和信号,系统会针对出现的异常和收到的信号做出反应。
(10)终止:hello运行结束后被父进程回收,内核删除相关数据。
以上,就是hello程序的一生。
hello是一个非常渺小的程序,hello的一生也注定是短暂的一生,然而正是这转瞬即逝的一段旅程,涉及到了计算机系统方方面面的内容,这些内容是计算机运行程序最本质、最核心的方式。
通过本次大作业对hello一生的研究,我系统地回顾了这个学期所学的几乎所有知识,了解了整个计算机系统运作的较浅层的一些道理。同时,通过对一个最基础程序运行需要步骤的直观感受,通过对计算机系统复杂结构的感知,我对于整个计算机科学产生了更深层的敬畏和好奇,希望在今后的学习中能触及更多,了解更多。
附件
- hello.c 源文件
- hello.i 与处理文件
- hello.s 汇编语言文件
- hello.o 可重定位目标文件
- hello 可执行目标文件
- hello.elf hello.o的elf格式文件
- helloo.elf hello的elf格式文件
参考文献
[1] 兰德尔·E·布莱恩特,大卫·R·奥哈拉伦著;深入理解计算机系统[M].北京:机械工业出版社,2016.7.
[2] C library – C++ Reference [http://www.cplusplus.com/reference].
[3] printf函数实现的深入剖析 [http://www.cnblogs.com/pianist/p/3315801.html].