计算机系统大作业
计算机科学与技术学院
2023年4月
摘 要
1972年,伴随着显示在屏幕上的" Hello world! ",C语言在贝尔实验室问世。随后,C语言设计者Dennis Ritchie和计算机科学家Brian W. Kernighan,联合编著了一本介绍C语言及其程序设计方法的经典著作The C Programming Language,其中的第一个示例程序,就是在屏幕上输出一行字符" Hello world! "。此后,程序员之间形成了一个约定俗成的习惯,即在学习任何编程语言时,将在显示屏上打印字符串" Hello world! "作为所写的第一个程序。
在hello程序背后,蕴含着计算机系统的核心机制。其源程序文件hello.c是如何经编译系统翻译成可执行目标文件hello的?可执行文件的内部结构是什么样的?程序是如何运行起来的?程序运行时在内存中又是如何储存的?本文通过对hello程序生命周期的逐步剖析,配合CMU参考教材《深入理解计算机系统》(CSAPP),对计算机系统编译、运行、存储等机制进行深入的分析和介绍。
关键词:hello;计算机系统;编译系统;进程管理;存储管理;
目 录
第1章 概述
1.1 Hello简介
P2P (Program to Process):名为hello.c的Source program被GCC编译器的驱动程序读取并翻译为可执行目标文件hello,而后,操作系统外壳shell利用函数fork为hello创建进程process,可在其中加载程序。自此,hello的P2P过程已经完成,hello由一个Program变为Process。
如图1所示,GCC的翻译过程分为预处理,编译,汇编,链接四个阶段,预处理器(cpp)根据以字符 # 起始的命令扩充并修改源文件hello.c,得到被修改的源文件hello.i,其经过编译器(ccl)处理被翻译成汇编语言程序hello.s,随后汇编器(as)将hello.s翻译成由机器语言指令打包生成的可重定位目标程序hello.o,其与printf.o文件由链接器(ld)合并为可执行目标文件hello,可被加载到内存中由系统执行。
图1
O2O (Zero-0 to Zero-0):shell调用execve函数在创建子进程中加载程序hello。该过程首先删除当前虚拟地址的用户部分已存在的数据结构,为hello的代码、数据、bss和栈区域创建新的区域结构,而后映射共享区域并设置程序计数器指向代码区域入口,CPU在流水线上执行指令。程序运行结束时,shell接收到相应信号,回收hello进程,内核删除相关数据并释放内存。自此,hello的O2O过程已经完成,hello由0到有,最终归为0。
1.2 环境与工具
硬件环境:X64 CPU;2.30GHz;16.0GB RAM;1TD
软件环境:Windows11 64位;VMware 17.0.2;Ubuntu 20.04 LTS 64位
开发工具:Visual Studio 2022 64位;gdb;edb;vi/vim;gcc
1.3 中间结果
文件名称 | 文件作用 |
hello.c | 源代码 |
hello.i | 预处理后的代码 |
hello.s | 汇编代码 |
hello.o | 可重定位目标文件 |
hello | 链接后的可执行目标文件 |
hello.o.elf | hello.o的ELF |
hello.o.s | hello.o反汇编后的代码 |
hello.elf | hello的ELF |
hello_obj.s | hello的反汇编代码 |
1.4 本章小结
本章介绍了 hello 的 P2P 和 O2O 过程,描述了实验环境与开发工具,并列出生成的中间结果文件名称及其作用。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理器(cpp)在编译源程序文件之前,由预处理程序对程序源文件进行文本方面的操作,如文本替换、文件包含、删除代码等,生成被修改的源程序文件。
预处理的作用:
预处理根据以字符 # 开头的命令,修改原始的C程序。预处理指令主要分为以下五类:
1)定义声明
使用 #define 命令定义一个标识符(宏名)和一个串(字符集),每当预处理程序在源程序中发现标识符时,都用相应串替换标识符,该替换过程称为宏代换。
一般形式如下:
#define MACRO_NAME(args) tokens(opt)
2)条件编译
编译指令#if, #elif, #else, #endif等允许程序员有选择的编译程序源代码的不同部分,预处理程序根据条件有选择性的保留或者放弃源文件中的内容。
一般形式如下:
#if 常量表达式
语句...
#elif 常量表达式
...
#elif 常量表达式
...
...
#else
...
...
#endif
3)文件包含
#include 指令用于文件包含,要求编译程序读入一个源文件。预处理程序搜索指定文件并将其内容放在当前所在位置。
一般形式如下:
#include "headfile"
#include <headfile>
#include 预处理标记
4)预定义宏
标准C中定义了一些对象宏, 这些宏的名称以 __ 开头和结尾,并且都是大写字符,可以被 #undef,也可以被重定义。
5)扩展控制
#line指令用来修改预定义宏__LINE__和__FILE__的内容,其中预定义宏 __LINE__ 的内容是当前被编译代码行的行号,__FILE__的内容是当前编译源文件的文件名。
一般形式如下:
#line number "filename"
其中number是被赋予__LINE__ 的正整数,filename是被赋予 __FILE__的合法文件标识符。
2.2在Ubuntu下预处理的命令
Linux下使用gcc预处理的命令为:
gcc -E hello.c -o hello.i
截图1
2.3 Hello的预处理结果解析
如截图2所示,预处理得到的文本文件hello.i被扩充至3092行,起始代码段是hello.c拼接的各种库文件,中间代码段对内部函数进行声明,而后是stdio.h unistd.h stdlib.h的源代码展开,源程序的代码在文件末尾。
截图2
2.4 本章小结
本章主要介绍预处理的概念与作用,并对hello的预处理结果进行解析,分析了被修改的源文件hello.i的结构组成。预处理过程与其说是扩充文本,不如说是补全了文本内容,使其最终能够运行在操作系统的上下文中。
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译器(ccl)将预处理生成的文本文件,翻译成汇编语言程序的文本文件。汇编语言程序的每条语句都以一种固定文本格式描述一条低级机器语言指令,为不同高级语言的不同编译器提供了通用的输出语言。
编译的作用:
在编译过程中,编译器将将源代码输入扫描器,将源代码的字符序列分割成一系列记号并生成生成语法树,再由语义分析器判断语句是否合法。编译过程中对程序进行多种等价变换,以生成更有效的目标代码。最后由目标代码生成器将语法分析或优化后的中间代码变换为汇编语言代码。
3.2 在Ubuntu下编译的命令
Linux下使用gcc编译的命令为:
gcc -S hello.i -o hello.s
截图3
3.3 Hello的编译结果解析
3.3.1 数据
1)立即数常量
如截图4所示,hello.c中出现的数字被视为立即数常量,以 $立即数 的形式直接存放在汇编代码中。
截图4
2)格式串
如截图5所示,printf 函数打印的格式串包括格式控制字符串和转义字符,是一种特殊的字符串常量,以UTF-8编码形式存放在.rodata节的段.LC0与.LC1中,访问时通过rip + 偏移量间接寻址。
截图5
3)局部变量
如截图6所示,hello.c中的局部变量在程序运行时储存在函数调用栈中,访问时通过rbp + 偏移量间接寻址。
截图6
3.3.2 赋值
hello.c中的赋值操作主要通过MOV类指令实现,其形式如图2所示:
图2
如截图7所示,程序中使用指令movl将0赋予局部变量i。
截图7
3.3.3 算术操作
如截图8所示,hello.c中涉及算术操作i++,该操作使用addl指令实现。
截图8
图3列出了x86-64的一些算术和逻辑操作:
图3
3.3.4 关系操作
汇编语言的关系操作主要通过比较测试指令实现,指令如图4所示:
图4
如截图9所示,hello.c中使用cmpl实现对argc,i的比较。
截图9
3.3.5 数组操作
main函数形式如下:
int main(int argc, char *argv[ ])
函数参数中含有指针数组argv[ ],数组单元存放指向输入字符串的指针,参数argc为输入字符串个数。
如截图10所示,程序向printf传送参数argv[1]与argv[2],向atoi函数传送参数argv[3],访问时采用地址 + 偏移量的方式定位数组中元素,可知存放在寄存器rsi的参数argv[ ]首地址存放在%rbp - 32,存放在寄存器rdi的参数argc存放在%rbp - 20,通过%rbp - 32存放的argv[ ]首地址加偏移量访问argv[1]、argv[2]、argv[3]。
截图10
3.3.6 控制转移
hello.c中if条件与for循环语句涉及控制转移,程序通过cmpl与je、jle指令实现比较与跳转,如截图11所示:
截图11
汇编语言通过跳转指令jump与CPU存放的条件码实现控制转移,jump指令如图5所示:
图5
3.3.7 函数操作
汇编语言使用call指令调用函数,ret指令返回, 函数参数保存在寄存器和栈中,规则如图6所示:
图6
如截图12,hello.c依次调用了printf、atoi、sleep等函数,将参数argv[1],argv[2]分别放入寄存器rsi,rdx中,将参数argv[3]放入寄存器rdi中,将函数位于寄存器rax的函数atoi返回值作为函数sleep的参数放入寄存器rdi中。
截图12
3.4 本章小结
本章围绕hello.i编译生成的汇编程序文本hello.s进行分析,通过对其中汇编指令的解读,探究汇编器将C语言中各个数据以及操作转换为汇编语言指令,为不同高级语言的不同编译器之间搭建了沟通的桥梁,以便最终生成机器可执行的二进制指令代码。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
汇编器(as)将汇编语言指令翻译成机器语言指令,并将指令打包生成一种可重定位目标程序的格式,存储在二进制目标文件的过程。
汇编的作用:
将文本形式的汇编语言指令代码转换为对应机器可执行的二进制指令。
4.2 在Ubuntu下汇编的命令
Linux下使用gcc编译的命令为:
gcc -m64 -no-pie -fno-PIC -c -o hello.o hello.s
截图13
4.3 可重定位目标elf格式
4.3.1 ELF头
x86-64 Linux系统中使用可执行可链接格式(ELF)存储可重定位目标程序,典型的ELF可重定位目标文件格式如图7所示:
图7
使用readelf -h hello.o命令查看ELF头,如截图14:
截图14
EFL头以16字节的Magic序列开始,该序列描述了生成文件的系统字节大小和字节顺序,其余部分包含ELF头的大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等信息,帮助链接器语法分析和解释目标文件。
4.3.2 节头部表
使用readelf -S hello.o命令查看节头部表,如截图15:
截图15
节头部表描述可重定位目标文件中各个节信息,包括节的名称、类型、地址及其偏移量、大小等信息。
4.3.3 符号表
使用readelf -s hello.o命令查看符号表,如截图16:
截图16
符号表依次记录了程序中出现的各种符号及其重定位值、大小、类型、是否为全局符号、是否可见、位置、名称等信息。在重定位前初始化value值均为0,Ndx为UND表明符号需经过链接从外部获取定义。
4.3.4 重定位条目
使用readelf -r hello.o命令查看符号表,如截图17:
截图17
汇编器遇到对位置未知的目标引用时,会生成一个重定位条目,包含有符号的偏移量、基址信息、类型、符号名称等,以便链接器后续将目标文件合并成可执行文件时修改引用。
4.4 Hello.o的结果解析
使用objdump -d -r hello.o命令反汇编hello.o,与hello.s进行对照分析,发现以下不同点:
1)进制转换
如截图18所示,hello.s文件中的十进制操作数在反汇编后表示为hello.o.s文件中的十六进制操作数。
截图18
2)控制转移
如截图19所示,hello.s中控制转移的目标位置.L1、.L2等助记符,在反汇编后表示为hello.o.s文件中的指令偏移地址。
截图19
3)函数调用
如截图19所示,hello.s文件中函数调用直接引用函数的名称,而在反汇编后生成的hello.o.s文件中,函数调用地址是下一条程序的地址。在机器语言中,不确定地址的函数调用时,指令后的相对地址初始化为0,等待链接器的进一步处理。
4.5 本章小结
本章介绍了hello.s汇编生成hello.o的过程,通过readelf工具查看可重定位目标文件hello.o的ELF内容,使用反汇编工具objdump得到反汇编文件hello.o.s并与hello.s进行比较,探究汇编的具体作用。
第5章 链接
5.1 链接的概念与作用
链接的概念:
链接器(ld)将各文件的代码与数据综合,通过符号解析和重定位等过程,最终生成一个可以在程序中加载并运行的可执行目标文件的过程。
链接的作用:
链接过程将多个可重定位目标文件合并以生成可执行目标文件,令分离编译成为可能。在程序修改后无需重新编译整个工程,而是仅编译修改的文件,极大的方便了大型应用程序的修改和编译。链接还有利于构建共享库,可将常用函数(如printf函数)储存在常用函数文件中,等待链接时与源程序进行合并生成可执行文件,极大的节省空间。
5.2 在Ubuntu下链接的命令
Linux下使用gcc编译的命令为:
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
截图20
5.3 可执行目标文件hello的格式
5.3.1 ELF头
如截图21,使用readelf -h hello命令查看hello的ELF头,并与hello.o的ELF头比较,发现程序类型由可重定位文件REL变为可执行文件EXEC (Executable file),入口点地址由0变为0x4010f0,ELF头的其余参数信息也被填充完整。
截图21
5.3.2 节头部表
如截图22,使用readelf -S hello命令查看hello的节头部表,并与hello.o的节头部表比较,发现链接器将各个文件对应段合并,且重新分配计算了相应节的类型、位置、大小等信息,各个节的地址也从 0 开始进行分配。可以看到.text节起始地址为 0x4010f0,符合ELF头中的程序入口地址。
截图22
5.3.3 符号表
如截图22,使用readelf -s hello命令查看hello的符号表,并与hello.o的符号表比较,发现符号表的内容在合并后有所扩充。
截图22
5.3.4 重定位条目
如截图23,使用readelf -r hello命令查看hello的重定位条目,并与hello.o的重定位条目比较,发现重定位条目的内容在链接后有较大改变。
截图23
5.3.5 程序头
如截图24,程序头描述磁盘上可执行文件的内存映射关系。
截图24
5.4 hello的虚拟地址空间
使用edb打开hello可以查看hello的虚拟地址空间,如截图25所示,hello的起始于虚拟地址0x401000,与hello的ELF文件中init的虚拟地址相同,类似的,可看到hello各节的起始地址与ELF文件中的虚拟地址一一对应。
截图25
5.5 链接的重定位过程分析
使用objdump -d -r hello命令反汇编hello,与hello.o反汇编生成的文件hello.o.s进行对照分析,发现以下不同点:
1)新增函数
如截图26所示,hello的反汇编文件中在链接后新增了许多库函数,如puts,printf,getchar,atoi,exit,sleep等。
截图26
2)新增节
如截图27所示,hello的反汇编文件中在链接后新增了.init节和.plt节
截图27
3)地址变化
如截图28所示,hello的反汇编文件中函数地址表示由hello.o.s中的相对地址变为虚拟地址。
截图28
重定位:链接器(ld)在完成符号解析以后,将代码中的符号引用与对应符号定义建立关联,同时获得目标文件中代码节和数据节的大小信息。随后,链接器合并输入模块,并为每个符号分配运行时地址,即重定位过程。
重定位分为两步:首先进行重定位节和符号定义,链接器将输入模块中相同类型的节合并,为程序中的每条指令和全局变量赋予唯一的运行时地址。随后进行重定位符号引用,链接器根据重定位条目中存储的节偏移量与修正值,结合起始地址,计算出引用符号的运行时地址并更新符号引用。
5.6 hello的执行流程
截图29
从加载hello到_start,到call main,至程序终止的所有过程,调用与跳转的各个子程序如下:
<ld-2.31.so!_dl_start>
<ld-2.31.so!_dl_init>
<hello!_start>
<libc-2.31.so!__libc_start_main>
<hello!main>
<hello!printf@plt>
<libc-2.31.so! printf >
<hello!atoi@plt>
<libc-2.31.so! atoi >
<libc-2.31.so! strtoq >
<hello!sleep@plt>
<libc-2.31.so! sleep >
<libc-2.31.so! nanosleep >
<libc-2.31.so! clock_nanosleep >
<libc-2.31.so! _IO_file_xsputn>
<hello!getchar@plt>
<libc-2.31.so!getchar>
5.7 Hello的动态链接分析
在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件 hello。此时共享库中的代码和数据没有被合并到hello中,而是在加载 hello 时,动态链接器对共享目标文件中相应模块内的代码和数据进行重定位,加载共享库并生成完全链接的可执行目标文件,hello中的printf、sleep、atoi等都通过动态链接与源程序建立关系。
由于调用共享库函数时,无法预测函数的运行时地址,因此要为该引用生成一条重定位记录,由动态链接器在程序加载时解析。Linux使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定通过数组GOT和PLT实现:数组PLT中每个条目大小为16字节,PLT[0]是跳转到动态链接器中的特殊条目,每个条目负责调用一个具体函数;数组GOT中每个条目存放8字节大小的地址,GOT[O]和GOT[1]包含动态链接器在解析函数地址时使用信息,GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,地址在运行时被解析。
5.8 本章小结
本章解释链接的概念及作用,通过分析hello的ELF格式,虚拟地址空间,重定位过程,执行流程,和动态连接过程,探究可重定位文件hello.o链接生成可执行文件hello的各个过程。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:
进程是一个执行中程序的实例,在现代系统上运行一个程序时,会得到一个假象:我们的程序好像是系统中当前运行的唯一程序,独占使用处理器和内存;处理器好像是无间断的执行我们程序中的指令;最后,我们程序的代码和数据好像是系统内存中唯一的对象。
进程的作用:
进程提供给应用程序两个关键假象:一个独立的逻辑控制流、一个私有的地址空间。没有进程,如此庞大的计算机系统不可能设计出来。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用:
Shell-bash是交互型的应用程序,为用户与操作系统内核之间搭建桥梁,负责进程创建、程序加载运行、前后台控制、作业调用、信号发送与管理等工作。
Shell-bash的处理流程(以运行可执行文件hello为例):
1)在shell命令行中输入命令:./hello
2)shell命令行解释器构造argv和envp
3)调用fork()函数创建子进程,将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间中
4)调用execve()函数跳转到程序入口点,开始执行_start函数,hello开始执行
6.3 Hello的fork进程创建过程
shell调用fork函数创建子进程,子进程得到与父进程用户级虚拟地址空间相同但独立的一份副本,包括代码和数据段、堆、共享库以及用户栈,同时获得与父进程打开文件描述符相同的副本,但与父进程拥有着不同且唯一的PID。
6.4 Hello的execve过程
子进程调用execve函数在当前进程的上下文中加载并运行程序hello,execve调用内存中的加载器执行hello程序,execve函数拥有三个参数,包括可执行目标文件名filename、参数列表argv和环境变量列表envp。加载器删除子进程现有的虚拟内存,并创建一组新的代码、数据、堆和栈段,其中栈和堆被初始化为零。通过内存映射将代码和数据段初始化为可执行文件中的内容。最后加载器设置PC指向_start地址,以调用hello中的main函数。在加载过程中没有任何数据从磁盘复制到内存,直到CPU引用一个被映射的虚拟页时才会进行复制,图8展示了新程序开始时用户栈的典型结构。
图8
6.5 Hello的进程执行
为节省时间,系统会运行多个进程,进程间轮流使用处理器。内核为每个进程维持一个上下文,存有内核重新启动一个被抢占的进程所需要的状态,如寄存器、程序计数器、用户栈等。为提供一个无懈可击的进程抽象,处理器须限制应用可执行指令及可访问地址空间的范围。处理器使用某寄存器的一个模式位设定运行模式,以圈定处理器访问范围。
执行hello进程时,由于从磁盘中读取数据的时间较长,会通过陷阱异常切换到内核模式。此时系统进行进程调度,重新开始一个先前被抢占了的进程。切换进程运行了足够长时间后,系统再次进行进程调用,抢占执行进程hello,hello由内核态切换为用户态。
6.6 hello的异常与信号处理
如图9所示,异常可分为中断、陷阱、故障、终止四类。
图9
图10展示了Linux信号的种类:
图10
通过调试,可在hello程序运行过程时对于异常与信号的处理:
1)乱按键盘
如截图30所示,在程序运行时乱按键盘,触发中断,hello进程并没有接收到信号,乱序输入的字符串被认为是命令,缓存在stdin中。
截图30
2)按下Ctrl + Z
如截图31、32、33所示,在程序运行时按下Ctrl + Z,hello进程接收到SIGSTP信号被挂起。使用命令ps查看后台进程,发现hello的PID是11064;再使用命令jobs查看当前作业,此时hello的job号为1;输入命令pstree查看进程树;输入fg向进程发送信号SIGCONT将其调回前台继续运行;最后输入kill向进程发送SIGKILL信号,终止hello进程。
截图31
截图32
截图33
3)按下Ctrl + C
如截图34所示,在程序运行时按下Ctrl + C,hello进程因收到SIGINT信号而终止。
截图34
6.7本章小结
本章介绍了进程的概念和作用,简要介绍了shell的作用及处理流程。以hello程序的执行为例,分析了shell通过函数fork与函数execve创建进程并运行程序hello的过程,通过调试对可能发生的异常和信号处理进行分析。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址时由程序产生的和段相关的偏移地址部分,在hello.o中表示为相对偏移地址形式。
线性地址:线性地址是逻辑地址到物理地址间的中间层,其地址空间是一个非负整数地址的有序集合,若地址空间中的整数连续,则称其为线性地址空间,hello段中的偏移地址与其基地址组合生成了一个线性地址。
虚拟地址:虚拟地址是由操作系统分配给应用程序使用的地址,被组织为一个由存放在磁盘上,N个连续的字节大小单元组成的数组,hello中main函数的段偏移量就是虚拟地址。
物理地址:物理地址是加载到内存地址寄存器中的地址,是数据存放在内存单元的真正地址,为CPU定位物理内存对应地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
如图11所示,对于给定的逻辑地址,Intel平台下,逻辑地址以selector:offset形式表示,其中selector存放在寄存器CS,offset存放在寄存器EIP。段式管理采用selector从全局描述符表GDT中获得段基址,加上段内偏移offset得到线性地址。
图11
7.3 Hello的线性地址到物理地址的变换-页式管理
VM系统将虚拟内存分割为大小固定的块,称其为虚拟页,类似的,物理内存也被分割为大小固定的物理页,或称为页帧。虚拟页面的集合被分为三种情况,如图12:
图12
未分配的:VM系统还未分配的页,无任何相关联数据,不占用磁盘空间。
已缓存的:当前已缓存在物理内存中的已分配页。
未缓存的:未缓存在物理内存的已分配页。
虚拟内存机制使用页表数据结构进行页式管理,将虚拟页映射到物理页,地址翻译硬件通过读取页表将虚拟地址转换为物理地址,如图13所示:
图13
如图14,内存管理单元MMU利用CPU中的页表基址寄存器定位进程对应页表,利用虚拟页号(VPN)选择对应页表条目(PTE),将其中物理页号(PPN)与虚拟页面偏移(VPO)串联得到相应物理地址。
图14
7.4 TLB与四级页表支持下的VA到PA的变换
如图15、16,Core i7 采用四级页表层次结构,虚拟地址的46位VPN被划分为四个9位的片,每个篇被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址,VPN1提供一个L1 PET的偏移量,该PTE包含L2页表的基地址,VPN2提供一个L2 PET的偏移量,以此类推,得到L4中页的物理地址,将其与偏移量VPO串联得到物理地址。
图15
图16
7.5 三级Cache支持下的物理内存访问
如图17所示,CPU发送一条虚拟地址,MMU经地址翻译得到物理地址PA,该地址不在TLB中,需在cache中寻找。根据cache规模将其分为标记位(CT),组索引位(CI)以及偏移位(CO)根据,通过组索引,行匹配,字抽取三个步骤获得数据。命中则直接返回数据,不命中则依次访问L2、L3判断是否命中,命中时将数据传送给CPU并更新各级cache。
图17
7.6 hello进程fork时的内存映射
如图18,当新进程创建时,记录了当前进程的mm_struct、区域结构和页表的原样副本,同时进程中的每个页面标记为只读,区域设置为私有的写时复制。当任一个进程试图进行写操作时,触发保护故障程序创建新页面,更新页表条目指向新的副本,恢复其可写权限,当故障处理程序返回时可正常执行。
图18
7.7 hello进程execve时的内存映射
如图19,当加载器加载并运行hello程序时,先删除已存在的用户区域,再为hello的代码、数据、bss和栈创建新的区域结构,并将其设为私有的、写时复制的。代码和数据区域被映射为hello文件中的.text区和.data区。请求二进制令的bss区映射到匿名文件,栈和堆初始长度为零,动态链接程序如libc.so映射到用户虚拟地址空间中的共享区域内,最后设置该进程上下文的PC指向代码区域入口点。
图19
7.8 缺页故障与缺页中断处理
如图20,DRAM缓存不命中称为缺页,MMU通过有效位推断读取的数据未被缓存,触发缺页异常。内核中的异常处理程序选择牺牲页,处理牺牲页存储数据后,将所读取虚拟页从磁盘复制到牺牲页中,并更新页表条目。随后异常处理程序返回,重启导致缺页的指令,此时页命中,继续地址翻译过程。
图20
7.9动态存储分配管理
7.9.1 堆
动态内存分配器维护着一个进程的虚拟内存区域,被称为堆。分配器将堆视为一组大小不同的块的集合,其地址是连续的。将块标记为两种,已分配的块供应用程序使用,空闲块用来分配。
7.9.2 隐式空闲链表管理
每个空闲块的底部与头部均为4个字节,用来存储块的大小以及块是否空闲。
其中头部和底部的高29位存储块的大小,剩下3位的最低位来指明这个块是否空闲,000表示块空闲,001表示已分配。
当应用请求k字节的块时,分配器搜索空闲链表,查找一个足够大的空闲块。分配器有三种放置策略:首次适配、下一次适配和最佳适配,在释放一个已分配块时,可利用隐式空闲链表的边界标记合并空闲块。
7.9.3 显式空闲链表管理
操作系统实际使用显示空闲链表管理,过程中维护多个空闲链表,链表中的块大小大致相等。分配器维护一个空闲链表数组,分配块时只需在对应空闲链表中搜索,释放时有两种分离存储的方法:
1)简单分离存储
块从不合并与分离,每个块的大小就是大小类中最大元素的大小,当需分配块的大小在某链表对应区间时进行分配,分配和释放均为常数级,但是空间利用率较低。
2)分离适配
每个大小类的空闲链表包含大小不同的块,分配完一个块后,将这个块进行分割,并根据剩余块的大小将其插入到适当大小类的空闲链表中,平衡了搜索时间与空间利用率,C标准库提供的GNU malloc包采此方法。
7.10本章小结
本章分析了hello的存储管理,包括存储器地址空间、段式管理和页式管理的机制,TLB与四级页表支持下的VA到PA的变换,三级cache支持下的物理内存访问。结合hello进程探究函数fork与exeve的内存映射原理,最后对缺页故障和动态储存分配管理内容进行了解。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
文件是Linux的重要思想,系统将I/O设备抽象为文件,允许Linux内核引出一个简单、低级的Unix I/O应用接口,从而将所有设备的输入和输出都能以一种统一的方式执行。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口:
1)打开文件
一个应用程序通过要求内核打开相应的文件以宣告其访间I/O设备。内核返回一个称为描述符的非负整数,在后续对此文件的操作中标识文件。同时内核记录文件的所有信息。
2)shell
Linux创建的进程起始时有三个打开的文件:描述符为0的标准输入、描述符为1的标准输出和描述符为2的标准错误。头文件< unistd.h >中定义的常量STDIN_FILENO、STOOUT_FILENO和STDERR_FILENO,可用于替代显式的描述符。
3)改变当前的文件位置
对于每个打开的文件,内核保持着一个初始化为0文件位置k,标识文件起始的字节偏移量。应用程序通过执行seek操作,显式地设置文件当前位置。
4)读写文件
读操作通过从当前文件位置k开始,从文件中复制大于0的n个字节到内存中实现,操作后位置k增加到k+n。对于超过文件大小的文件位置,执行读操作会触发一个称为end-of-file(EOF)的条件;类似地,写操作由当前文件位置k开始,从内存中复制大于0的n个字节到一个文件并然后更新k。
5)关闭文件
应用完成了对文件的访问之后,通知内核关闭文件,内核释放文件打开时创建的数据结构,并将描述符恢复。
8.2.2 Unix IO函数:
1)int open(char* filename, int flags, mode_t mode)
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,文件位置移动至EOF返回0,其余情况返回实际传送的字节数量。
4)ssize_t write(int fd, const void *buf, size_t n)
write函数从内存buf复制至多n个字节到描述符为fd的文件位置。
8.3 printf的实现分析
如截图35,36所示,printf函数调用vsprintf函数,vsprintf函数将所有参数内容格式化之后存入buf,并返回格式化数组的长度,而后调用write函数将字节从寄存器中通过总线复制到显卡的显存中,随后字符显示驱动子程序通过显存中存储的ASCII码在字模库中找到点阵信息,并将点阵信息存储到vram中,显示芯片按照一定频率逐行读取vram,并通过信号线向液晶显示器传输每一个点。此时程序的输出显示在屏幕上。
截图35
截图36
8.4 getchar的实现分析
如截图37所示,用getchar函数调用函数read等待用户按键,当用户键入回车后开始从stdio流中读入一个字符并返回该字符的ASCII码,发生错误返回-1,将用户输入的字符显示在屏幕中。若用户回车前输入不止一个字符,其他字符保留在键盘缓存中等待后续调用读取。因此,后续调用getchar不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符取尽。
截图37
8.5本章小结
本章简述了Linux的IO设备管理,介绍了Unix IO接口及其函数,并对printf和getchar函数的实现进行分析。
结论
用计算机系统的语言,逐条总结hello所经历的过程:
预处理:对hello.c程序进行预处理,填入其调用的函数库得到hello.i文件
编译:hello.i程序经编译得到汇编语言编写的hello.s文件
汇编:将hello.s中的汇编语言指令转化为二进制的机器语言指令,得到可重定位目标文件hello.o
链接:hello.o与其他可重定位目标文件和动态库进行链接,得到可执行文件hello
输入运行命令:./hello 2022111614 王昀潼 2
创建子进程:shell调用fork函数为程序hello创建子进程
加载运行程序:shell调用execve函数启动加载器,映射虚拟内存并进入程序入口执行
访问物理内存:MMU将程序hello中使用的虚拟地址经地址翻译为物理地址
动态申请内存:printf调用malloc向动态内存分配器申请空闲块
异常、信号处理:运行过程中键入Ctrl + C/Z发送信号,执行shell的异常信号处理函数。
回收子进程:hello运行结束后,shell回收子进程,内核删除为进程hello创建的所有数据结构。
感悟:
在小小的hello程序背后,蕴含着计算机系统的核心机制。由最初的编写C语言代码,到拆解编译,shell运行回收……每个步骤的拆解分析都是一项巨大的工程,计算机系统的工作原理逐渐清晰,加深了我对于CSAPP课程脉络的认识。
附件
文件名称 | 文件作用 |
hello.c | 源代码 |
hello.i | 预处理后的代码 |
hello.s | 汇编代码 |
hello.o | 可重定位目标文件 |
hello | 链接后的可执行目标文件 |
hello.o.elf | hello.o的ELF |
hello.o.s | hello.o反汇编后的代码 |
hello.elf | hello的ELF |
hello_obj.s | hello的反汇编代码 |
参考文献
[1] Tanenbaum, A. S., & Bos, H. (2015). 深入理解计算机系统 (原书第3版). 机械工业出版社.