计算机系统
大作业
计算机科学与技术学院
2023年5月
摘 要
本文根据Hello的自白,从头到尾跟踪了编程界的传奇hello的一生。当hello.c源程序被编写完毕时,hello开启了它的传奇一生——从预处理、编译、汇编、链接生成可执行文件到在系统上运行hello,再到运行完毕被回收,hello结束了它的传奇一生,完成了P2P与020的过程。hello的传奇一生反映了计算机系统底层的基本内容与基本原理,跟踪hello一生的过程就是深入理解计算机系统的过程。
关键词:计算机系统 预处理 编译 汇编 链接 进程管理 存储管理
目 录
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 -
第1章 概述
1.1 Hello简介
P2P即From Program to Process,指的就是从程序到进程的过程。为了能够在计算机系统上运行,hello程序从hello.c(Program)开始,到成为一个可执行程序hello(Process),程序员的一次简单的点击鼠标的动作,无意识中将hello.c进行预处理、编译、汇编、链接,诞生了hello这个完美的生命。执行这四个阶段的程序(预处理器、编译器、汇编器和链接器)一起构成了编译系统。
首先是预处理阶段,预处理器(cpp)根据以字符#开头的命令,修改原始hello.c程序,结果就得到了另一个C程序hello.i。
第二个阶段是编译阶段,编译器(cc1)将文本文件hello.i翻译成文本文件hello.s ,它包含一个汇编语言程序。
然后是汇编阶段,汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中,它是一个二进制文件。
最后是链接阶段,链接器(ld)将一些预编译好的目标文件以某种方式与hello.o文件进行合并,得到可执行目标程序文件hello,它可以被加载到内存中执行。
当我们运行hello时,在shell中利用fork()函数创建子进程,再用execve加载hello程序,这时,hello就由程序(program)变成了一个进程(process),完成了hello的P2P的过程。
图1.1.1 编译系统
020,即From Zero to Zero,指的是调用fork()函数在shell中创建子进程,再用exceve()函数加载可执行程序hello,这时操作系统为其分配虚拟内存并映射到物理内存,hello完成了从无到有。内存管理器和CPU在执行过程中调用三级cache、TLB、内存等进行物理内存上的取数据操作,再通过I/O系统根据代码指令进行输出。程序运行结束后,该进程被其父进程回收,操作系统内核把它从操作系统清除,hello又完成了从有到无,完成了hello的020的过程。
1.2 环境与工具
硬件环境:
X64 CPU;3.2GHz;16G RAM;256GHD Disk
软件环境:
Windows 10、Vmware16.2.4、Ubuntu 22.04.4LTS
开发工具:
Vi/Vim/gedit/gcc
1.3 中间结果
hello.c:C语言源文件
hello.i:预处理生成的文件
hello.s:编译产生的汇编代码文件
hello.o:汇编产生的可重定位目标文件
hello:链接产生的可执行目标文件
elf.txt:hello.o的elf格式文件
elf1.txt:hello的elf格式文件
asm.txt:hello.o反汇编的结果文件
asm1.txt:hello反汇编的结果文件
1.4 本章小结
本章主要介绍了hello的P2P以及020的过程,并介绍了完成本次大作业所需的的软硬件环境及开发工具。同时还列出了完成大作业过程中产生的结果文件。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理是指预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,获得一个后缀为.i的文件,在本例中hello.c与处理后生成hello.i,预处理后的文件仍为文本文件。
预处理的作用:
(1)能够完成头文件的包含,将包含的文件插入到程序文本中(#include);
(2)可以进行宏替换,用实际的常量替换它的符号;
(3)删除源程序中所有的注释部分;
(4)实现特殊控制指令(如#error)。
2.2在Ubuntu下预处理的命令
预处理的命令为:gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i
图2.2.1 预处理命令
生成的hello.i文件如图所示:
图2.2.2 生成的hello.i文件截图
2.3 Hello的预处理结果解析
分别打开hello.c和hello.i,两个文件的内容如下图所示。观察可发现两文件的代码部分是相同的,即预处理没有对代码进行处理。同时我们发现hello.i文件中删除了hello.c文件中的注释部分,即预处理会删除源程序的注释部分。
图2.3.1源文件hello.c的内容
图2.3.2的hello.i文件
同时从下图中我们还可以看到hello.c文件中没有的内容,如被引用的stdlib.h等库文件代码,他们直接被插入程序中,使得文件代码的数量大大增加。
图2.3.3 hello.i文件特有内容
2.4 本章小结
本章介绍了预处理的概念和作用,给出了在Ubuntu下对hello.c进行预处理的命令,给出了预处理后的结果文件hello.i,并对hello.c源文件和hello.i文件进行比较,分析了其中的关系以及变化,发现与处理过程中会删除源文件的注释内容,并插入头文件引用的代码文件。
第3章 编译
3.1 编译的概念与作用
编译的概念:编译就是利用编译器(cc1)把用高级语言写出的源程序翻译成计算机可识别的二进制语言的过程。
编译的作用:编译把方便程序员编写的高级程序设计语言翻译成为更加贴近底层机器指令的汇编语言,能够让程序员更加清晰地“看到”程序的底层实现逻辑和过程。同时,在编译的过程中编译器会根据编译等级选项对程序进行一些适当的优化,以提高程序的执行效率。
3.2 在Ubuntu下编译的命令
命令:gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s
图3.2.1 Ubuntu下编译的命令
编译后生成hello.s文件如图所示:
图3.2.2生成hello.s文件
3.3 Hello的编译结果解析
3.3.1数据
(1)常量
第3行的.rodata节标注标明下面数据为只读数据,则后面的.string表示的字符串为字符串常量。而第21行中的$32表示一个立即数$32,是数字常量。
图3.3.1字符串常量
图3.3.2数字常量
(2)局部变量
局部变量通常保存在寄存器或栈中。hello.c程序中有三个局部变量:i、argc以及argv。以局部变量argc为例,hello.s的第22行表示要将%edi中的数据传入栈中,地址为-20(%rbp),因为argc为函数第一个参数,故其值默认保存在%rdi寄存器中,%rbp为栈指针,此处采用偏移量寻址。
图3.3.3局部变量
3.3.2赋值
MOV类是最简单形式的数据传送指令,根据操作的数据大小不同对应不同的字符后缀。hello.s第31行把立即数$0赋值给保存在-4(%rbp)中的局部变量i,对应hello.c中的i=0语句。
图3.3.4赋值
3.3.3类型转换
hello.s的第48行调用atoi函数把字符串显式转换成整型int
图3.3.5类型转换示例
3.3.4算术操作
在hello.s的第51行执行了一个加法操作,对应的是hello.c中的i++语句。
图3.3.6算术操作示例
3.3.5关系操作
CMP指令根据两个操作数之差来设置条件码,进而完成一个关系操作。如hello.s的第53行比较立即数$7和-4(%rbp)中的值的大小,完成i <8的关系操作。
图3.3.7关系操作示例
3.3.6数组/指针/结构操作
如图所示,源代码中对数组的操作在汇编程序中变为对栈地址的加减操作。其中-32(%rbp)存放数组的首地址argv[0],将其存放在寄存器%rax中,后续对其进行地址偏移(每8字节为一个单位),进而进行对数组的访问。
图3.3.8数组访问示例
3.3.7控制转移
hello.s的第54行是一个条件跳转,配合53行的cmp指令,当小于等于7时跳转到.L4节。
图3.3.9控制转移示例
3.3.8函数操作
如图所示,在hello.c源程序中共有6次函数调用。
图3.3.10 hello.c中的函数调用
对于printf函数的调用,编译器采用调用puts函数的方式进行优化,把要打印的数据放在%rdi寄存器中作为函数参数,然后call指令调用函数。其他函数调用的过程类似。
图3.3.11 hello.s中的函数调用
3.4 本章小结
本章主要介绍了编译的概念、作用,并在Ubuntu下把hello.i文件编译生成hello.s文件。同时根据C语言中的不同的数据类型、操作类型以及函数调用等对hello.s文件中的汇编语句与源文件进行对比和解析,其中数据类型包括常量和局部变量,操作有赋值、算术操作、关系操作、控制转移和函数调用等。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编是指汇编器(as)把hello.s翻译成机器语言指令,并把这些指令打包成可重定位目标程序的格式,并保存在hello.o文件中。
汇编的作用:把汇编语言翻译成计算机能够直接执行的0、1机器语言,把文本文件转化成二进制文件。
4.2 在Ubuntu下汇编的命令
命令:gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
图4.2.1 Ubuntu下汇编的命令
汇编后生成的hello.o文件如图所示:
图4.2.2 汇编后生成的hello.o文件
4.3 可重定位目标elf格式
用readelf把hello.o输出到文本文件elf.txtx中
图4.3.1生成elf.txt文件命令
图4.3.2生成的elf.txt文件
ELF文件包括ELF头、节头部表、重定位节、符号表等,并且在其中列出了各节的基本信息,包括位置、大小、类型等,具体分析如下:
(1)ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。具体内容如下图所示。
图4.3.3 ELF头
(2)节头部表
如下图所示,节头部表描述了文件中各个节的类型、位置、大小、偏移、权限等信息。
图4.3.4节头部表
(3)重定位节
重定位节包括两个部分,分别是.rela.text和.rela.eh_frame节。
.rela.text节是一个.text节中位置的列表,当链接器将这个目标文件与其他文件组合时,需要修改这些位置;
.rela.eh_frame节包含了对eh_frame节的重定位信息。
图4.3.5.rela.text重定位节
(4)符号表
符号表中列出了程序中所有定义和引用的全局变量,程序中定义的局部变量和类型,以及函数等信息:
图4.3.6符号表
4.4 Hello.o的结果解析
使用objdump -d -r hello.o >asm.txt命令将hello.o的反汇编内容保存在文件asm.txt中。
图4.4.1 hello.o反汇编指令
图4.4.2对应生成的反汇编文件
对比反汇编文件asm.txt内容与汇编程序hello.s内容,发现有一些不同,分析如下(图中左边为hello.s文件,右边为asm.txt文件):
图4.4.3;两种文件的对比分析
(1)数字进制变化:
左边的汇编程序使用十进制数,而右侧可重定位目标文件的反汇编文件变成了十六进制数,对应机器的二进制数。
(2)分支转移:
汇编程序中分支转移目标的目标位置用段名.L表示,类似C语言程序中的goto语句,而反汇编程序中直接给出跳转地址。
(3)函数调用:
hello.s中函数调用在call指令后直接引用函数名称,而在反汇编文件中,函数的调用使用的是call指令加上待引用函数的首地址的形式。
4.5 本章小结
本章介绍了汇编的概念和作用,并在Linux系统下实际操作进行汇编,将汇编程序hello.s转化成可重定位目标文件hello.o。并通过readelf命令分析了hello.o的ELF格式,列出了ELF头、节头表、重定位节以及符号表的功能与包含的信息。最后使用objdump反汇编生成asm.txt文件,并与汇编程序文件进行比较与分析。
第5章 链接
5.1 链接的概念与作用
链接的概念:链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
链接的作用:链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能,不用再将一个大型的应用程序组织为一个巨大的源文件,而是把它分解为更小、更好管理的模块,可以独立的修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
Ubuntu下进行链接
图5.2.1链接命令
生成的hello可执行程序
图5.2.2生成hello可执行程序
5.3 可执行目标文件hello的格式
使用readelf -a hello >elf1.txt来把elf文件写入到elf1.txt中
图5.3.1生成elf1.txt文件
查看elf1.txt的内容,如下图所示,可以看到可执行文件hello的elf格式,其中最左侧一列为节名,第二列为节类型,第三列为起始地址,第四列是节偏移量。
图5.3.2 hello的elf格式文件
5.4 hello的虚拟地址空间
使用edb加载hello,如下图所示查看本进程的虚拟地址空间各段信息。
图5.4.1在edb中加载hello
与5.3节对照分析:edb中显示的虚拟地址和elf文件中看到的虚拟地址是对应相同的,如下图所示:我们在edb中通过Symbol Viewer选项打开Symbols界面(左侧),在这里可看到各段的名称及对应起始地址,这些地址与elf文件中的地址相同(右侧)。
图5.4.2虚拟地址对应
我们还可以在edb的Data Dump界面中进行地址查找并找到对应地址存储的数据。比如我们查找地址0x400000,从而找到elf头,如下图所示:
图5.4.3 edb中查看elf头
5.5 链接的重定位过程分析
使用objdump -d -r hello > asm1.txt将hello反汇编内容写入asm1.txt,过程及结果文件如下图所示:
图5.5.1反汇编hello
图5.5.2反汇编生成asm1.txt
接下来我们对比asm1.txt(hello的反汇编文件)和asm.txt(hello.o的反汇编文件),分析如下:
(1)指令分配虚拟地址
如下图所示,左侧是链接后的反汇编,右侧是链接前的。在hello.o的反汇编程序中,函数中的语句前面的地址都是从函数开始从依次递增的,而不是虚拟地址;经过链接后,每一条指令都被分配了虚拟地址。
图5.5.3为指令分配虚拟地址
(2)函数调用
如下图所示,右侧helllo.o的反汇编程序中的函数调用指令由于还没有分配虚拟地址,所以只能用偏移量进行跳转,而左侧链接后已分配好虚拟地址,可以直接用call指令直接指出跳转的虚拟地址。
图5.5.4函数调用
(3)跳转指令
如下图所示,像函数调用一样,链接后采用虚拟地址跳转。
图5.5.5跳转指令
(4)调入C标准库函数
在hello.o中只有main函数段,还没有把标准库函数插入,链接后将调用的C标准库函数的代码被插入其中,下图所示。
图5.5.6链接过程插入C语言标准库函数
综上所述,链接的过程主要分为两个过程:符号解析和重定位。
符号解析时解析目标文件定义和引用符号,并建立每个符号引用和符号定义之间的关联。
重定位时先重定位节和符号定义,把相同类型的节合并,并为其分配内存。接下来进行符号引用的重定位,修改代码和数据中对符号的引用,使得他们指向正确地址。
5.6 hello的执行流程
执行过程中的各个函数名和对应的地址如下:
<_init>:401000
<.plt>:401020
<puts@plt> :401090
<printf@plt>:4010a0
<getchar@plt>:4010b0
<atoi@plt>:4010c0
<exit@plt>:4010d0
<sleep@plt>:4010e0
<_start>:4010f0
<_dl_relocate_static_pie>:401120
<main> :401125
<_libc_scu_init>:4011c0
<_libc_csu_fini>:401230
<_fini>: 401238
5.7 Hello的动态链接分析
当程序调用共享库中的函数时,编译器无法预测这个函数的地址,因为定义它的模块可以在运行时加载到任何位置。这时编译器采用延迟绑定的策略,把过程地址的加载推迟到第一次调用该进程。动态链接器使用GOT(全局偏移量表)和PLT(过程链接表)实现函数的动态链接。其中GOT 中存放函数目标地址,PLT使用 GOT中地址跳转到目标函数。GOT和PLT信息下图所示。
图5.7.1.got和.got.plt信息
我们可以在edb中找到该节的内容并观察其在运行dl_int前后内容的变化,如图5.7.2和5.7.3所示,有内容发生变化。
图5.7.2运行dl_int前的信息
图5.7.3运行dl_int后的信息
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
本章主要介绍并进行了以下内容:
(1)介绍了链接的概念以及链接的作用,在Ubuntu下完成了链接的过程,并分析了hello可执行文件的elf格式文件的信息。
(2)用edb查看了hello的虚拟地址空间,发现各节都与相应的一段虚拟地址相对应,同时查看了各节的起始位置与大小。
(3)使用objdump对可执行目标文件hello进行反汇编,并与前一章节中的hello.o的反汇编程序进行比较,发现相比于hello.o的反汇编程序来说,经过链接后,hello的反汇编程序代码量增加,插入了C标准库中的函数代码,各条指令都分配了虚拟地址,字符串常量的引用、函数调用以及跳转指令的地址都替换为了虚拟地址。
(3)介绍了链接的过程并分析了符号解析和重定位。
(4)简要介绍分析了hello程序动态链接的过程,通过edb调试,分析了在dl_init前后,.got.plt节的的内容变化,这是由动态链接的延迟绑定造成的。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程的经典定义就是一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存中的程序的代码和数据、它的栈、通用目的寄存器的内容,程序计数器、环境变量,以及打开文件描述的集合。
进程的作用:提供给应用程序两个关键抽象,①一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器,②一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
1.Shell-bash的作用:
Shell是一种命令行解释器,它为应用程序的执行提供一个界面,用户通过这个界面访问操作系统内核的服务,shell读取用户输入的字符解释并执行。
2.Shell-bash处理流程:
(1)shell从终端读入用户输入的命令。
(2)将输入字符串进行划分以获得所有参数。
(3)判断是否是内置命令,是则立即执行,否则调用相应的程序为其分配子进程并运行。
(4)Shell接受键盘输入信号并对这些信号进行相应处理。
6.3 Hello的fork进程创建过程
在终端输入./hello的输入命令后,shell进行命令行解释,由于命令行第一个参数不是内置shell命令,shell会调用fork函数创建一个子进程并执行可执行程序hello。新创建的子进程几乎与父进程相同,子进程得到与父进程用户及虚拟地址空间相同的但是独立的一份副本,包括代码和数据段、堆、共享库以及用户栈,子进程还获得与父进程任何打开文件,描述符相同的副本,这就意味着,当父进程调用fork函数时,子进程可以读写父进程中打开的任何文件。
6.4 Hello的execve过程
execve过程:在shell给hello进行fork()函数创建子进程之后,会调用execve函数,在进程的上下文中加载并运行hello,调用_start创建新的且被初始化为0的栈等,随后将控制给主函数main,并传入参数列表和环境变量列表。当出现错误时,execve才会返回到调用程序,否则,execve调用一次且不返回。在execve加载完毕可执行目标文件hello后,会调用启动代码,启动代码设置栈,将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序,由此将控制转移给新程序的主函数。
通过execve函数生成后用户栈的典型结构如下图所示:
图6.4.1新程序启动后的栈结构
6.5 Hello的进程执行
(1)上下文信息:上下文信息是操作系统内核重新启动一个挂起的进程所需要恢复的状态。它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的信息构成。
(2)时间片;一个进程执行它的控制流的一部分的每一时间段叫做时间片。
(3)进程调度:在进程执行过程中,操作系统内核可以决定抢占当前进程,并重新开始一个先前被挂起的进程,这样的一种决策叫做进程调度。当抢占进程时,要完成以下三个任务:①保存之前进程的上下文;②恢复要执行的新进程的上下文;③把控制转让给新恢复的进程完成上下文切换。
(4)用户模式和内核模式:处理器通常使用一个寄存器来区分两种模式,这个寄存器描述了当前进程的权限情况。简单来说,两种模式有不同的“权限”,用户模式权限较低,不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;内核模式权限较高,可以执行任何命令,并且可以访问系统中的任何内存位置。
(5)进程执行与用户态核心态转换:
当开始运行hello时,内存为hello分配时间片,若一个系统同时运行多个进程,则它们轮流使用处理器,物理控制流被划分成多个交错的逻辑控制流,存在并发执行的现象。然后在用户态下执行并保存上下文。如果在此期间内发生了异常或系统中断,则内核会休眠该进程,并在核心态中进行上下文切换,把控制权让给其他进程。当hello进程执行到sleep时,hello会进入休眠状态,此时再次进行上下文切换,控制交付给其他进程,一段时间后hello休眠结束,此时再次完成上下文切换,恢复休眠前的上下文信息,此时控制权送回hello并继续执行。循环结束后,程序调用 getchar() ,hello从用户模式进入内核模式,并再次上下文切换,控制交付给其他进程。最后,内核会从其他进程回到 hello 进程。
进程上下文切换的流程如下图所示。
图6.5.1进程上下文切换的流程
6.6 hello的异常与信号处理
6.6.1执行过程中可能出现的异常
(1)中断
中断属于异步异常,指在程序执行过程中由处理器外部IO设备引起的异常,如键盘输入的Ctrl+C。处理过程如下图所示。
图6.6.1中断的处理过程
(2)陷阱
陷阱属于同步异常,是一种有意的异常,是指令执行的结果,如系统调用。处理过程如下图所示。
图6.6.2陷阱的处理过程
(3)故障
故障也是一种同步异常,它不是有意的,同时也是可能被修复的,如访存时的缺页故障是可恢复的,但保护故障是不可恢复的,故障的处理过程如下图所示。
图6.6.3故障的处理过程
(4)终止
终止属于同步异常,它不是有意的,是由不可恢复的致命错误造成的,如非法指令。其处理过程如下图所示。
图6.6.4终止的处理过程
6.6.2运行过程中可能出现的信号
hello执行过程中可能出现的信号有:SIGINT、SIGKILL、SIGSEGV、SIALARM、SIGCHLD等。
6.6.3程序的运行
(1)正常运行
在终端输入./hello 2021111266 祝文鑫 1,终端每隔1秒会打印输出一次Hello 2021111266 祝文鑫,共8次。由于程序在循环结束后调用了一个getchar()函数,程序在输出结束后不会立刻结束,此时我们键入一个回车可以停止程序执行。该过程如下图所示。
图6.6.5 hello正常运行
(2)运行过程中乱敲键盘
如果程序运行过程中不断乱敲键盘,则会把键盘输入的内容显示在屏幕上,最后依然要通过getchar()函数接受键盘输入的一个回车后程序结束运行。该过程如下图所示。
图6.6.6 运行过程中不断乱按
(3)运行过程中键入回车
如果在程序运行过程中键入三个回车,它们会被保存在缓冲区中,程序运行结束时,第一个回车被getchar()读走,结束程序,还有两个回车会被当做在终端的输入而输出出来,此过程如下图所示。
图6.6.7运行过程中键入三个回车
(4)运行过程中键入Ctrl+C
如果在程序运行过程中键入Ctrl+C,则程序会立即停止执行。因为Ctrl+C这个键盘输入会发送SIGINT信号给hello(从终端可看出),该信号要求hello立刻终止该进程,该过程如下图所示。
图6.6.8键入Ctrl+C结束进程
(5)运行过程中键入Ctrl+Z并进行其他操作
如果在程序运行过程中键入Ctrl+Z,则会产生中断异常,并发送SIGSTP信号(从终端可看出),此时会暂时挂起hello进程并打印相关信息,如下图所示。
图6.6.9键入Ctrl+Z挂起进程
在挂起后我们还可以执行若干其他命令,例如我们输入ps,则打印各进程的PID,如下图所示;
图6.6.10键入ps打印各进程PID
输入jobs将打印出被挂起的hello的相关信息,如下图所示:
图6.6.11键入jobs打印hello的相关信息
输入pstree将打印进程树,如下图所示;
图6.6.12键入pstree打印进程树
输入fg会让其继续执行,如下图所示:
图6.6.13键入fg继续执行进程
输入kill将发送SIGINT信号,杀死进程,如下图所示:
图6.6.14键入kill指令杀死进程
6.7本章小结
本章主要介绍并进行了以下内容:
(1)介绍了进程的概念和作用;
(2)简要介绍了Shell-bash的作用与处理流程;
(3)给出了运行hello调用fork()函数创建进程以及调用execve()加载执行进程的过程,同时结合了上下文信息、时间片、进程调度、用户模式和内核模式、进程执行与用户态与核心态转换等概念,介绍了hello作为一个子进程是如何执行的。
(4)分析了hello运行过程中可能出现的异常,分析并实操了hello运行过程中出现诸如回车,Ctrl+Z,Ctrl+C等键盘输入会出现的信号处理,及键入Ctrl+Z后再键入ps,jobs,pstree,fg,kill等指令会产生的结果。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1、逻辑地址(Logical Address):指由程序产生的段内偏移地址,hello.c经过编译后出现在汇编代码中的地址即为逻辑地址。
2、线性地址(Linear Address):逻辑地址指的是虚拟地址向物理地址转换时的中间层,是处理器可寻址的内存空间(称为线性地址空间)中的地址,hello程序中的偏移地址加上相应段的基地址就是线性地址。
3、虚拟地址(Virtual Address):是由程序产生的由段选择符和段内偏移地址组成的地址。这2部分组成的地址并不能直接访问物理内存,而是要通过分段地址的变化处理后才会对应到相应的物理内存地址。有时我们也把逻辑地址称为虚拟地址。
4、物理地址(Physical Address):指内存中物理单元的集合,是地址转换的最终地址,进程在运行时执行指令和访问数据最后都要通过物理地址来存取主存。
7.2 Intel逻辑地址到线性地址的变换-段式管理
机器语言指令中出现的内存地址,都是逻辑地址,需要转换成线性地址,再经过MMU(CPU中的内存管理单元)转换成物理地址才能够被访问到。
一个逻辑地址由两部分组成,段标识符和段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。
在x86保护模式下,段的信息(段基线性地址、长度、权限等)即段描述符占8个字节,段信息无法直接存放在段寄存器中(段寄存器只有2字节)。Intel的设计是段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值(index)。
当我们得到一个完整的逻辑地址:段标识符+段内偏移量时,则通过以下步骤把它转换成线性地址:
①看段选择符的T1=0还是1,区分出当前转换的是GDT中的段还是LDT中的段,再根据相应寄存器的内容,得到其地址和大小。
②取出段选择符的前13位作为索引进行查找,找到对应的段描述符,从而确定了基地址。
③把基地址和偏移量相加,就得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址到物理地址的变换由页式管理实现,它通过分页机制对虚拟内存空间进行分页,然后把页式虚拟地址与物理内存地址建立一一对应页表,并用相应的硬件地址变换机构(MMU)来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。页表是一个由页表项(PTE)组成的数组,存储在内存中,将虚拟页地址映射到物理页地址。
如下图所示,一个虚拟地址(VA)包含两个部分:虚拟页号(VPN)和虚拟页偏移量(VPO),其中VPO和PPO(物理页偏移量)是相同的。MMU利用VPN选择适当的PTE,如果PTE的有效位为1,也即PTE命中,则直接将PTE中存储的物理页号(PPN)和虚拟地址中的虚拟页偏移量(VPO)串联起来就得到一个相应的物理地址。如果页表项(PTE)不命中,则会触发缺页故障,调用缺页处理子程序进行相应处理。
图7.3.1基于页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
TLB(Translation Lookaside Buffer),即快表,是MMU中的一个小、虚拟寻址的、具有高相联度的关于PTE的缓存,能实现虚拟页码向物理页码的映射。
TLB与四级页表支持下的VA到PA的变换过程如下。首先,CPU产生虚拟地址(VA),并将虚拟地址(VA)传送给MMU(内存管理单元),MMU将VPN分为两部分高位作为TLBT(TLB标记),低位作为TLBI(TLB索引),进而到快表(TLB)中匹配相应缓存在TLB中的PTE。如果命中,则由PTE可以直接得到物理页号(PPN),与虚拟页偏移量(VPO)组合得到物理地址(PA)。反之则将VPN均分为四份,分别为VPN1、VPN2、VPN3和VPN4, CR3寄存器存储了第一级页表的起始地址,我们通过VPN1访问第一级页表,对应页表项中存储的是下一级页表的起始地址,重复上述过程,我们就可以逐步访问到第四级页表,而第四级页表的页表项中存储的的就是物理页号,将其与虚拟页偏移量(VPO)组合即可得到物理地址(PA)。相应的过程如下图所示。
图7.4.1 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
高速缓存(cache)是一个小而快速的存储设备,他作为存储在更大、也更慢的设备中的数据对象的缓冲区域,使用高速缓存的过程称为缓存(caching)。
当我们获得物理地址(PA)之后,首先从物理地址中取出组索引对应的位,进而在一级cache中寻找对应组。如果存在该组,则我们比较tag位,相同时我们再检查有效位(valid)是否为1。如果上述条件都满足则一级cache命中,取出对应值传给CPU,否则按顺序逐级对二级cache、三级cache和内存进行访问,直到出现命中。然后再逐级写回,如果cache中有空位则直接写回,否则采用相应的替换算法驱逐出一块后再写回cache。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,操作系统内核为新进程创建各种数据结构,并分配给它一个唯一的 PID,然后通过以下步骤为其创建虚拟内存:
①创建当前进程的的mm_struct、区域结构和页表的原样副本;
②将两个进程中的每个页面都标记为只读;
③将两个进程中的每个区域结构都标记为私有的写时复制;
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个在后面进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行文件hello中的程序,用hello替代了当前bash中的程序。加载并运行hello需要如下几个步骤:
①删除已存在的用户区域,具体做法是删除当前进程虚拟地址的用户部分中的已存在的区域结构。
②映射私有区域,具体做法是为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
③映射共享区域,具体做法是hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
④设置程序计数器(PC),使其指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。下图展示了加载器是如何映射用户地址空间的区域的。
图7.7.1 加载器映射用户地址空间的区域
7.8 缺页故障与缺页中断处理
缺页故障是指CPU想要读取虚拟内存中的某个数据,但是该数据所在的页还没有缓存在主存中,此时就引发缺页中断,系统会调用却也中断处理程序对其进行处理。
当发生缺页中断时,系统的处理流程如下图所示:
①处理器将虚拟地址发送给MMU;
②MMU生成PTE地址(PTEA),并从高速缓存/主存请求得到它;
③高速缓存/主存向MMU返回PTE;
④PTE有效位为0, MMU触发缺页异常,传递CPU中的控制到操作系统内核的缺页异常处理程序;
⑤缺页处理程序确定出物理内存中的牺牲页 ,若页面被修改,则把它写回到磁盘中。
⑥缺页处理程序调入新的页面,并更新内存中的PTE;
⑦缺页处理程序返回到原进程,再次执行导致缺页的指令,CPU将VA重新送给MMU并执行相应访问操作,此时将不会再出现缺页的情况。
图7.8.1缺页异常时的处理过程
7.9本章小结
本章以hello为例,介绍了程序存储管理的相关内容。首先介绍了逻辑地址、线性地址、虚拟地址以及物理地址的概念,再到通过段式管理和页式管理详细介绍了从逻辑地址到线性地址,再到物理地址的变换,以及在TLB与四级页表支持下的虚拟地址到物理地址的变换,三级cache支持下的物理内存访问操作。最后介绍了hello进程的调用fork和execve时的内存映射以及缺页故障和缺页中断处理的方式。
结论
一、hello的传奇一生总结如下:
1、hello.c源文件的编写:使用C语言编写源程序hello.c,hello从此诞生。
2、预处理:预处理器(cpp)对源程序hello.c进行处理,生成hello.i文件。
3、编译:编译器(cc1)把hello.i文件进行编译,得到汇编文件hello.s。
4、汇编:汇编器(as)把hello.s转化为可重定位目标文件hello.o。
5、链接:链接器(ld)把可重定位目标文件hello.o和其他目标文件链接生成可执行目标文件hello。
6、在shell输入./hello 2021111266 祝文鑫 1运行hello程序。
7、创建进程:shell调用fork函数为hello创建子进程。
8、运行进程:调用execve函数运行hello,加载映射虚拟内存,在当前进程的上下文中加载运行hello。
9、访问内存:在运行hello时会涉及访存操作,这就需要通过MMU将需要访问的虚拟地址转化为物理地址并访问。
10、信号与异常:hello运行过程中可能会产生各种异常与信号,系统会做出处理。
13、hello运行结束,最终被父进程或init进程回收,hello的传奇一生就此结束。
二、收获与感悟
完成本次大作业的过程就是从头重新温习在CSAPP这门课程中学到的知识的过程,我们从hello入手,将每章散碎的知识点串联起来,真正从底层认识这个我们再熟悉不过的程序是如何运行的,他让我对计算机系统的底层逻辑与实现有了更深的认识,特别是优化程序性能一章,它对我今后在进行高质量程序的编写时能够真正从一个程序员的角度进行思考有很大的帮助。同时,这门课程也让我正式接触了Linux系统以及汇编代码和指令,这同样是一个不小的收获。
附件
hello.c:C语言源程序文件
hello.i:预处理后生成的文件
hello.s:编译产生的汇编程序文件
hello.o:汇编产生的可重定位目标文件
hello:链接产生的可执行目标文件
elf.txt:hello.o的elf格式文件
elf1.txt:hello的elf格式文件
asm.txt:hello.o反汇编的结果文件
asm1.txt:hello反汇编的结果文件
参考文献
[1]《深入理解计算机系统》原书第三版
[2] 静态链接(下)——重定位 http://t.csdn.cn/BpL7H
[3] 【Linux系统编程】——剖析shell运行原理 http://t.csdn.cn/1mcfr
[4] 逻辑地址,线性地址和物理地址转换 http://t.csdn.cn/2FJ5Y