计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机学院
学 号 7203610614
班 级 2036104
学 生 孙健时
指 导 教 师 刘宏伟
计算机科学与技术学院
2021年5月
我们写过非常多的程序,从初识高级语言时的hello world,到或简单或复杂的算法解题程序,再到更加复杂的神经网络与编译系统、数据库,程序与我们相伴已经很久很久,久到我们都已经忘记了关注程序执行这一简单而精妙的过程。
但是一个程序的生命并不仅仅在于简单的输入输出过程和代码实现方式,从我们下令编译开始,计算机底层的一系列工具密切配合,最终为我们呈上结果和“请按任意键继续”。
本报告将解析hello这一最简单的程序的实现方式,向诸位展示看似简单,在一瞬间就产生结果的程序背后究竟有着何种实现方式,P2P这一耳熟能详的名词到底是怎么发生的,预处理,编译,汇编,链接这四个过程的具体内容,IO的交互方式,进程和存储管理的痕迹,最终总览hello的一生。
hello说它度过辉煌的一生却没有人记得它。它了无牵挂的离去,那么就让我们跟随它的脚步,了解它的生命周期中都发生了什么事情。我想,有机会了解与我们朝夕相处的程序,这正是一个程序作者最大的浪漫。
关键词:P2P、预处理、编译、汇编、链接、进程管理、存储管理、I/O;
(摘要0分,缺失-1分,根据内容精彩程度酌情加分0-1分)
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 5 -
5.3 可执行目标文件hello的格式........................................................................ - 8 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 10 -
6.3 Hello的fork进程创建过程......................................................................... - 10 -
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.2 简述Unix IO接口及其函数.......................................................................... - 13 -
第1章 概述
1.1 Hello简介
可执行程序的执行过程,在计算机科学的视角下共需要四步:预处理 编译 汇编 链接。此过程主要在编译器中进行。那么链接之后产生的存储在寄存器中的指令又如何被计算机执行呢?这时候就要请出shell与进程控制这两个工具了。指令被读取之后,需要信号控制程序和内存管理程序的帮助,才能被正确的执行。同时,由于我们希望看到程序具备的特性,往往还要进行输入与输出,这又需要IO等控制内容。当这些工具全都正确的联动并运行之后,我们才看到了程序从无到有再到无,从可执行文件到进程再到结果的运行过程。这是一个短暂却复杂的过程,为了实现这么一个短短的程序,人类在计算机上投入的智慧展现的淋漓尽致。下面我们将走进这一过程,体会计算机系统的精妙与美丽。
P2P过程(program to process):
hello.c文件是一个源程序,它可能是通过vs code、code:blocks等编译器编写的,当我们发出编译运行指令时,它通过预处理、编译、汇编、链接四个步骤转化为可执行程序hello,若我们使用./hello运行之,则shell会解析命令行并对应的操作信号,然后使用fork创建进程,execve运行函数,为hello映射内存,分配空间,操作信号,使它成为一个正确的,被执行的进程。这就是程序到进程过程,from program to process。
020过程(zero to zero):
一开始,程序还未被运行,什么也没有,这是以0开始。P2P过程中,程序因为fork、execve等操作拥有了进程和内存空间,拥有了自己的生命。然后,在程序结束之后,shell又会将其bash掉,删除进程,释放内存,这样程序又彻底的消失了,什么也不留下,这是以0结束。这就是0到0过程,zero to zero。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;3.8GHz;16G RAM;512GSD Disk 以上。
1.2.2 软件环境
Windows11 64位;VirtualBox/VMware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位 以上
1.2.3 开发工具
Visual Studio 2010 64位以上;Code:Blocks 64位;vi/vim/gedit+gcc
1.3 中间结果
1.hello-------------------链接后的可执行文件
2.hello.c-----------------hello的源文件
3.hello.elf---------------hello的elf文件
4.hello.i------------------hello.c经过预处理后的文件
5.hello.o-----------------hello.s汇编后的可重定位目标文件
6.hello.objdump-------hello可执行文件的反汇编
7.hello.s-----------------hello.i编译后的汇编文件
8.hello_o.objdump----hello.o的反汇编
9.hello_o_elf.txt-------hello.o的ELF文件。
1.4 本章小结
本章简要介绍了hello在计算机系统中被处理的过程以及进行本实验的计算机环境,分析了P2P和020过程的内在,为接下来实验部分打下了基础。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:
预处理是对编译器对程序进行处理的第一步,会由一个.c文件初步生成一个.i文件。预处理进行的是对源代码中#开头的操作,比如引入头文件#include、设定宏#define等等。
作用:
1.读取头文件中调用库的代码,将这个库直接插入源代码中。
2.删除全部#define并将宏插入代码中。
3.删除全部的注释。
4.添加行号和文件标识以便编译时产生行号信息。
2.2在Ubuntu下预处理的命令
终端输入 gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
根据前面的介绍,结合hello的具体情况,不难看出要进行预处理的仅有三个语句:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
对于它们的处理截图如下:
可以通过观察这个hello.i文件看出,预处理后仅有十几行的代码已经变成了3060行,但是仍然可以用c的语法去阅读。
2.4 本章小结
本章主要介绍了预处理的方式和作用,编译器对以#开头的代码进行处理,进行将库插入代码、宏替换、删除注释等功能,生成一个.i文件,后续对代码进行编译。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:将.i结尾的源程序翻译成.s结尾的汇编语言程序。
作用:以汇编语言或机器语言表示的程序作为输出,为下一步将代码转化为机器能看懂的二进制数打下基础。
3.2 在Ubuntu下编译的命令
终端输入 gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1 指令
.file ----------------- C文件声明
.text ----------------代码段
.globl ---------------声明全局变量
.data ---------------已初始化的全局和静态C变量
.align 4 ------------声明对指令或者数据的存放地址进行对齐的方式
.type ---------------指明函数类型或对象类型
.size ----------------声明变量大小
.long .string-------声明long型、string型数据
.section .rodata---只读数据段
3.3.2 数据解析
1 字符串
程序中共有两个字符串,如图所示
这两个字符串都存储在只读数据段中。
2 局部变量
程序中只有一个局部变量i
如图所示,局部变量i放在栈上-4(%rbp)的位置。
3 argc和立即数
这两个都作为参数直接体现在汇编代码中。
4 main函数
这说明main是作为全局函数出现的,与c代码中声明的相符。
同时,main使用的字符串常量也被存储在数据区。
5 数组
argv单个元素char大小为8位,argv指针指向已经分配好的、一片存放着字符指针的连续空间,起始地址为argv。
6 赋值
int i操作在L2中,使用movl(l后缀的mov)为其赋值。
7 算数操作
i++:使用add操作进行增加1。
leaq计算LC1段地址
8 控制转移
if语句
对于if判断,编译器使用跳转指令实现,首先使用cmpl $4, -20(%rbp),设置条件码,使用je判断ZF标志位,如果为0,说明argv-4=0, 即argv==4,则直接跳转到.L2,否则顺序执行下一条语句,即执行if中的代码。
for循环
还是使用cmpl语句,比较i(存在%rbp中)和7的大小,如果i比7大则结束循环,跳转到其他语句。
9 数组操作
数组操作每次循环都要做,主要是要访问argv[1][2]两个内存。
argv[1]:数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上偏移量$8,再将该位置内容放在%rsi中,成为下一个函数的第一个参数。
argv[2]:数组首地址存放于-32(%rbp),先将其存储到%rax中,再加上偏移量$24,再将该位置内容放在%rdi中,成为下一个函数的第二个参数。
3.4 本章小结
本章简要介绍了编译的概念和作用,着重分析了在hello编译过程中,各种操作的实现方式与代码体现,分析了C的数据和指令是如何被处理的,为接下来汇编打下了基础。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:驱动程序汇编器as将汇编语言汇编成机器语言。
作用:将高级语言最终转化成机器可以直接识别并执行的机器语言代码文件,汇编器将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,.o文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
在终端中输入 gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
在命令行输入: readelf -a hello.o > hello_o_elf.txt
这样能够将hello.o的elf格式读取至hello_o_elf_txt中。
ELF头:
以一个16字节的序列开始,描述了生成这个文件的系统的字长和字节顺序,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。
节头:
这里包括了节的全部信息。
重定位信息
表述了各个段引用的外部符号等,在链接时,需要通过重定位节对这些位置的地址进行修改。链接器会通过重定位条目的类型判断该使用什么样的方法计算正确的地址值,通过偏移量等信息计算出正确的地址。
符号表
负责存放在程序中定义和引用的函数和全局变量的信息。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
数的表示:hello.s中的操作数是十进制,hello.o反汇编代码中的操作数是十六进制。
分支转移:跳转语句之后,hello.s中是.L2和.LC1等段名称,而反汇编代码中跳转指令之后是相对偏移的地址,即间接地址。
函数调用:hello.s中,call指令使用的是函数名称,而反汇编代码中call指令使用的是main函数的相对偏移地址。因为函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
4.5 本章小结
本章介绍了hello从hello.s到hello.o的汇编过程,通过查看hello.o的ELF格式和使用objdump得到反汇编代码与hello.s进行比较的方式,间接了解到从汇编语言映射到机器语言汇编器需要实现的转换。
(第4章1分)
第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.3 可执行目标文件hello的格式
同第四章,在终端输入readelf -a hello > hello.elf
5.4 hello的虚拟地址空间
如图所示,从0x401000开始,到0x401ff0截止。
5.5 链接的重定位过程分析
对hello和hello.o的反汇编文件进行比较后,总结出主要有以下几个不同点:
1.地址的访问:
hello.o中的相对地址到了hello中变成了虚拟内存地址。而hello.o文件中对于.rodata的访问,是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全置为0,并且添加重定位条目。
2.链接增加新的函数:
在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数。
3.增加的节:
hello中增加了.init和.plt节,和一些节中定义的函数。
4.函数调用:
hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
重定位过程:
1.关联符号定义
链接器将代码中的每个符号引用和一个符号定义关联起来。此时,链接器知道输入目标模块中的代码节和数据节的确切大小。
2.合并输入模块
链接器将所有输入到hello中相同类型的节合并为同一类型的新的聚合节。
3.符号引用
链接器修改hello中的代码段和数据段中对每一个符号的引用,使其指向正确的运行地址。
5.6 hello的执行流程
程序地址 程序名称
0000000000401000 <_init>
0000000000401020 <.plt>
0000000000401090 puts@plt
00000000004010a0 printf@plt
00000000004010b0 getchar@plt
00000000004010c0 atoi@plt
00000000004010d0 exit@plt
00000000004010e0 sleep@plt
00000000004010f0 <_start>
0000000000401120 <_dl_relocate_static_pie>
0000000000401125
00000000004011c0 <__libc_csu_init>
0000000000401230 <__libc_csu_fini>
0000000000401238 <_fini>
5.7 Hello的动态链接分析
PLT和GOT都是数组,其中plt每个条目是16字节代码,got每个条目是8字节地址。每个库函数都有自己的PLT条目,PLT[0]是一个特殊的条目,跳转到动态链接器中。从PLT[2]开始的条目调用用户代码调用的函数。GOT和PLT联合使用时,GOT[2]是动态链接在ld-linux.so模块的入口点,其余条目对应于被调用的函数,在运行时被解析。每个条目都有匹配的PLT条目。
在函数调用时,首先跳转到PLT执行,第一次访问跳转时GOT地址为下一条指令,将函数序号入栈,然后跳转到PLT[0],之后将重定位表地址入栈,访问动态链接器,在动态链接器中使用在栈里保存的函数序号和重定位表计算函数运行时的地址,重写GOT,返回调用函数。之后如果还有对该函数的访问,就不用执行第二次跳转,直接参看GOT信息。
5.8 本章小结
本章分析了hello在链接操作中的行为,简要介绍了链接的作用和概念,着重分析了hello的虚拟存储空间、重定位与动态链接。
通过链接,我们将一个编译后的机器语言文件变成了可执行的目标文件,这样它就将在下一步中被处理器读取,变成一个进程。链接是程序执行时不可或缺的一步。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文之中。上下文是由程序正确运行所需的状态组成的。这种状态包括存放在内存中的程序的代码以及数据、栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用:进程能够提供给应用程序的一些关键抽象,比如:
(1) 一个独立的逻辑控制流,它提供一个假象,好像程序能够独占使用处理器。
(2) 一个私有的地址空间,它提供一个假象,好像程序能够独占使用内存系统。
此外,通过进程可以更方便于描述和控制程序的并发执行,实现操作系统的并发性和共享性,更好实现CPU、时间、内存等资源的分配和调度。
通过进程的使用可以做到在一个时间段内有多个程序并行,其中它们的资源均为独立分配,调度均为独立接受,运行也是独立的、互不打扰的。这样更方便实现各种控制与管理。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell是一个应用程序,shell在操作系统中提供了一个用户与系统内核进行交互的界面。用户输入的命令由shell处理并分配,然后送往内核中进行执行。
1.从终端读入命令。
2.将输入解读并获得所有的参数
3.如果是内置命令则立即执行
4.否则调用相应的程序为其分配子进程并运行
6.3 Hello的fork进程创建过程
Shell通过fork来创建新的子进程。新创建的这个子进程与父进程几乎完全相同,它会得到与父进程在用户级虚拟地址空间上相同的一份副本,但是这份副本是独立于父进程的,它包括了代码,数据,堆,共享库,用户的栈。同时子进程还会获得与父进程中打开的文件描述符相同的副本,这意味着父进程直接fork子进程就可以使子进程获得任何打开文件的读写权限。
6.4 Hello的execve过程
fork 创建子进程之后,子进程调用execve 函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即hello程序。
execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello 程序,加载器删除子进程现有的用户虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。换句话说,execve 函数将用目标程序的进程替换当前进程,并传入相应的参数和环境变量,控制转移到新程序的main函数。
执行流程如图。
6.5 Hello的进程执行
逻辑控制流:唯一的对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态连接到程序的共享对象中的指令的PC的值称为逻辑控制流。
上下文信息:操作系统内核使用一种称为上下文切换的异常控制流来实现多任务。内核为每个进程维护一个上下文,上下文就是内核重新启动一个被抢占进程所需的状态。它由一些对象的值组成,这些对象包括通用目的的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈核各种内核数据结构。
进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常使用控制寄存器中的一个模式位来记录当前进程运行的模式,如果模式位已经设置,则程序运行在内核模式中,一个运行在内核模式中的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。,没有设置模式位时,进程就运行在用户模式中,用户模式中的进程步韵熙执行特权指令。
调度:当进程执行的某些时刻内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,称为调度。
调度的过程:进程收到一个信号,进程A挂起,进入内核模式(发生了一次上下文切换)运行另外一个进程B,当运行过程中进程B收到一个信号,停止运行进程B,进入用户模式(又发生一次上下文切换)
具体对于hello中sleep函数的进程调度,在运行到sleep之前,程序未收到信号保持执行的状态,运行到sleep函数之后,进程收到挂起信号,挂起当前进程切换进入其他进程。2s后sleep调用截止,当前运行的进程又收到一个信号,此时再次发生上下文切换,返回hello程序的运行。上下文切换的具体流程图大致如下:
6.6 hello的异常与信号处理
要阐述甚么是异常的执行过程,首先要正常的执行一次这个程序:
这是什么也不输入时的执行情况。
1.输入ctrl+z
可以通过ps指令看到,这个进程并未被回收,而是因为收到了SIGTSTP指令而被挂起了。
执行pstree指令,显示进程树。
2.输入ctrl+c
这一次,进程被回收了。
通过ps指令查看,确实如此:
3.乱按
并没有什么影响,可以看到乱按只是将屏幕的输入缓存到stdin,当getchar()的时候读出一个字符串,其他字串会当作shell命令行输入。
6.7本章小结
本章中主要介绍了进程的概念以及进程在计算机中的调用过程。介绍了shell-bash的作用和一般处理流程,还介绍了进程中两个关键的抽象:逻辑控制流和私有空间。执行hello时的fork和execve过程。分析了hello的进程执行和异常与信号处理过程。通过运行hello程序,介绍了异常控制流的通知机制信号。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是指由程序产生的与段相关的偏移地址部分。逻辑地址由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离,表示为 [段标识符:段内偏移量]。
线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址是程序保护模式下,程序访问存储器所使用的逻辑地址称为虚拟地址,与实地址模式下的分段地址类似,虚拟地址也可以写为[段:偏移量]的形式,这里的段是指段选择器。
CPU通过地址总线的寻址,找到真实的物理内存对应地址。计算机主存被组织成由M个连续字节大小的内存组成的数组,每个字节都有一个唯一的地址,该地址被称为物理地址。在前端总线上传输的内存地址都是物理内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址是程序代码经过编译后出现在汇编程序中产生的与段相关的偏移地址,即在不同的机器上,使用相同的编译器来编译同一个源程序,则其逻辑地址是相同的,但是相同的逻辑地址,在不同的机器上运行,其生成的线性地址又不相同,因为把逻辑地址转换成线性地址的公式是:
线性地址=段基址*16+偏移的逻辑地址,而段基址由于不同的机器其任务不同,其所分配的段基址也会不相同,因此,其线性地址会不同。
即使,对于转换后线性地址相同的逻辑地址,也因为在不同的任务中,而不同的任务有不同的页目录表和页表把线性地址转换成物理地址,因此,也不会有相同的物理地址冲突。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(即虚拟地址VA)到物理地址(PA)之间的转换通过分页机制完成。而分页机制是对虚拟地址内存空间进行分页。
Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。
7.4 TLB与四级页表支持下的VA到PA的变换
前提如下:
虚拟地址空间48位,物理地址空间52位,页表大小4KB,4级页表。TLB 4路16组相联。CR3指向第一级页表的起始位置(上下文一部分)。
为了消除每次 CPU 产生一个虚拟地址,MMU 就查阅一个PTE带来的时间开销,许多系统都在MMU中包括了一个关于 PTE 的小的缓存,称为翻译后被缓冲器(TLB),TLB的速度快于L1 cache。
TLB通过虚拟地址VPN部分进行索引,分为索引(TLBI)与标记(TLBT)两个部分。这样,MMU在读取PTE时会直接通过TLB,如果不命中再从内存中将PTE复制到TLB。同时,为了减少页表太大而造成的空间损失,可以使用层次结构的页表页压缩 页表大小。core i7使用的是四级页表。
在四级页表层次结构的地址翻译中,虚拟地址被划分为4个VPN和1个VPO。每个VPNi都是一个到第i级页表的索引,第j级页表中的每个PTE都指向第j+1级某个页表的基址,第四级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问四个PTE。
7.5 三级Cache支持下的物理内存访问
图为地址翻译后返回结果。左侧是四级页表支持的虚拟地址翻译成物理地址,之后将物理地址分为三块。首先是从 L1cache 里寻找结果,行匹配把虚拟地址的标记为拿去和相应的组中所有行的标记位进行比较,判断是否命中。若字选择一旦高速缓存命中,则将寻址结果直接返回给CPU;否则需要向低一层的缓存中寻找,取出被请求的块,将新的块储存在组索引位所指示的组的一个高速缓存行中,并设置好tag和valid位。寻找到结果后返回。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,即创建hello进程时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,他创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。也就是说,此时新进程并不包括hello程序的相关内容,只有在新进程调用execve加载hello程序,要对虚拟内存进行写操作时,写时复制机制就会创建新页面,将hello的各个段映射到相对应区域。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
exeve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地代替了当前程序。加载并运行a.out需要以下几个步骤。
1
删除已经存在地用户区域,即删除当前进程虚拟地址地用户部分中的已经存在的区域结构。
2
映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些结构都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的.test和.data区。Bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。
3
映射共享区域,如果a.out程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4
设置程序计数器(PC)execve做的最后一件事情就是设置当前进程上下文的程序计数器,并使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
DRAM缓存的不命中称为缺页。DRAM缓存的不命中触发一个缺页故障,缺页故障调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果该牺牲页已经做了更改,那么内核会将它复制回磁盘,否则不会进行复制即写回,然后将牺牲页从DRAM中出去,更新该页的位置放入待取的页面。然后CPU重新执行造成缺页故障的命令此时将可以正常运行。
7.9动态存储分配管理
当运行时需要额外虚拟内存的时候,使用动态内存分配器会使得用户更加方便,同时也能够具有更好的可移植性。动态内存分配器维护着一个进程的虚拟内存区域,这个我们称为堆。堆是一个请求二进制零的区域,紧接着未初始化的数据区域后面开始,向着更高的地址生长。对于每个进程来说,内核维护着一个变量brk指向堆顶。分配器会将堆看作一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,可能是已经分配的,也可能是空闲的。已经分配的块显式地保留,供应用程序使用;空闲块可以被用来分配,在被分配之前,它将保持空闲。一个已经分配的块会保持已分配状态,直至其被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器:
共有两种分配器,显示分配器和隐式分配器。具体两种分配器的特点和相应的原理如下:
显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器(这也是c语言中最常用的分配器)。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。
隐式分配器(implicit allocator),要求分配器检测一个已分配块,如果这个块在某个时刻后不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbage collection)。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
7.10本章小结
本章节介绍了储存器的四种地址空间的概念,介绍了段式管理和页式管理,解析了TLB与四级页表支持下的VA到PA的变换,以及三级cache支持下的物理内存访问,分析了fork和execve函数的内存映射,缺页故障和缺页中断处理,以及动态存储分配管理。到这里,hello的操作就结束了,它从源代码来,最终什么也不留下,从0到0结束了它的一生。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备管理:Unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O设备管理方法。
8.2 简述Unix IO接口及其函数
Unix I/O 接口统一操作
1.打开文件
应用程序通过要求内核打开相应的文件。
2.shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
3.改变文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个k是 从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当 前文件位置。
4.读/写文件
读操作是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将 k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。
写操作是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5.关闭文件
内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
Unix I/O 函数
1.int open(char* filename,int flags,mode_t mode):进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
2.int close(fd):fd是需要关闭的文件的描述符,close返回操作结果。
3.ssize_t read(int fd,void *buf,size_t n):read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF, 否则返回值表示的是实际传送的字节数量。
4.ssize_t wirte(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的格式,然后将匹配到的参数按照fmt格式输出。我们看到printf函数中调用了两个系统调用分别是vsprintf和write,先看看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);
}
可以看到这个函数的作用是将所有参数内容格式化后存入buf,然后返回格式化数组的长度。而另一个函数write是一个输出到终端的系统调用在此不做赘述。
所以printf函数的执行过程是:从vsprintf生成显示信息,到write系统函数,到系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
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实现的,读入BUFSIZ字节到buf,然后返回buf的首地址。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章节介绍了 Linux的 IO 设备管理方法,接口及其函数,分析了printf和getchar函数的实现。
(第8章1分)
结论
在这里回顾hello的一生,可以发现它走过的路相当精彩。
hello从一出生(hello.c)便知道自己的使命和去向。它一直在等待,直到预处理器开启它的生命周期——将hello.c预处理变为hello.i。
然后它被编译,变为hello.s,变得更加接近机器语言。
之后汇编器又将hello会变成可重定位二进制文件hello.o就这样hello学会了机器语言,但是它还有很多困惑(外部文件尚未被链接,多种信息需要重定位)
那么接下来自然是链接,这样就解决了hello的困惑,使它成为了一个可以被机器执行的文件。
伟大的shell下达执行hello的指令时,会为它execve一个新的子进程出来,并赋予hello独属于它的pid,以及专属于它的顾问(代码段,数据段,bss,虚拟内存信息,堆栈信息等等),hello开始工作了。
这样,最终hello做完了它的工作。当接收到异常信息或者终止信息时,shell就要将hello的进程收回或者挂起,hello要休息了。
最后,当终端关闭或者收到进程销毁指令,shell会收回这个独一无二的pid,hello至此走完了它全部的生命周期,什么也不留下,等待下一次调用。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
Hello.c 源程序
Hello.i 预处理之后的文本文件
Hello.s 编译之后的汇编文件
Hello.o 汇编之后的可重定位文件
Hello 连接之后的可执行目标文件
Hello_o_elf.txt hello的反汇编代码
Hello.elf hello.o的反汇编代码
(附件0分,缺失 -1分)
参考文献
[1]深入理解计算机系统,Randal E.Bryant.
(参考文献0分,缺失 -1分)