计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 未来技术学院2+X人工智能
计算机科学与技术学院
2023年4月
本文运用了计算机系统的知识,借助各种使用工具,对hello.c的一生进行分析。预处理、编译、汇编、链接成为可执行文件程序到载入内存、成为进程,再到执行完毕、内存回收。通过跟随着hello的脚步,更加强了对计算机知识的认识。
关键词:计算机系统;Linux;P2P;进程;虚拟内存;
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 6 -
5.3 可执行目标文件hello的格式...................................................................... - 21 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 33 -
6.3 Hello的fork进程创建过程......................................................................... - 33 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................... - 40 -
7.3 Hello的线性地址到物理地址的变换-页式管理.......................................... - 40 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................ - 41 -
7.5 三级Cache支持下的物理内存访问............................................................. - 42 -
7.6 hello进程fork时的内存映射..................................................................... - 43 -
7.7 hello进程execve时的内存映射................................................................. - 43 -
7.8 缺页故障与缺页中断处理.............................................................................. - 44 -
8.2 简述Unix IO接口及其函数.......................................................................... - 47 -
第1章 概述
1.1 Hello简介
1.P2P(From Program to Process)
GCC编译器驱动程序读取源程序文件hello.c,并把它翻译成一个可执行目标文件hello。这个转化过程共分为四个阶段,流程如图1-1-1所示。
在预处理阶段,预处理器根据以字符#开头的命令,修改原始的C程序,得到了hello.i文件;在编译阶段,编译器将文本文件hello.i翻译成文本文件hello.s;在汇编阶段,汇编器将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标文件的格式,并将结果保存在hello.o中;在链接阶段,链接器将hello.o所需的外部库与hello.o进行合并,得到hello文件。而最终shell指令为hello调用了fork()函数创建子进程,并加载hello程序,并初始化为可执行文件。最终完成了P2P的过程。
图1-1-1 hello.c从源文件到目标文件的转化过程
2.020(From Zero-0 to Zero-0)
在初始时内存没有hello的相关数据,此即“From Zero-0”。在shell命令下,frok函数为hello创建了子进程,并用execave将其载入内存。而程序结束时,被分配给hello的进程空间都被回收,相关数据均被删除或弃用,此即“to Zero-0”。至此,即完成了020的过程。
1.2 环境与工具
1.硬件环境:
笔记本电脑(x64 Intel Core i7 11800H CPU; 2.30GHz; 16G RAM; 512GHD Disk)
2.软件环境:
Windows10 64位; Vmware 17.0 pro; Ubuntu 20.04.2 LTS 64位
3.开发工具:
Visual Studio 2019 64位; Codeblocks 64位; Ubuntu 20.04.2 LTS 64位; vi/vim/gedit+gcc; GDB;Visual Studio Code 64位; EDB; Vim
1.3 中间结果
文件名 | 文件作用 |
hello.c | 源程序 |
hello.i | hello的预处理文件 |
hello.s | hello的汇编文件 |
hello.o | 汇编之后的可重定位目标文件 |
hello | 链接之后的可执行文件 |
hello-asm.txt | hello的反汇编文件 |
hello-o-astm.txt | hello.o的反汇编文件 |
hello-o.elf | hello.o的ELF格式文件 |
hello.elf | hello的ELF格式文件 |
1.4 本章小结
本章主要介绍了hello的一生中的P2P概念以及020概念,并标明了本机的环境与工具。
第2章 预处理
2.1 预处理的概念与作用
预处理是在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。其作用通常包括将源代码分割处理,删除注释、对宏和文件包进行替换,并处理各种预编译指令等。
图 2-2-1 使用gcc命令对hello.c进行预处理 |
![](https://i-blog.csdnimg.cn/blog_migrate/32b00b04d5ecaa6bf9c04e61068ccf71.png)
2.3 Hello的预处理结果解析
由图2-3-1与图2-3-2、图2-3-3中可以看到,在经过预处理过后,程序由原来的23行扩展到了3091行。而在hello.c中的代码注释已被删去,其声明引用的各种头文件也按顺序被读取使用。
图 2-3-2 预处理结果1 |
图 2-3-3 预处理结果2 |
2.4 本章小结
本章主要介绍了预处理的概念以及作用,并对预处理结果进行了分析。
第3章 编译
3.1 编译的概念与作用
概念:编译是指利用编译器将源语言编写的源程序转换为目标程序的过程,即将源语言翻译为计算机能够识别的2进制语言的过程。
作用:将源语言转化为汇编语言,为后续程序做准备。
图 3-2-1 使用gcc对hello.i进行编译 |
3.3 Hello的编译结果解析
3.3.1数据
(1)字符串常量:
如图3-3-1所示,在源程序hello.c中一共出现两个字符串常量。在处理时编译
器将其声明在了.section和.rodata中,其中汉字用UTF-8格式进行编码表示。字符串常量①对应图3-3-2中的.LC0中所声明的.string。相应的,字符串常量②则对应.LC1中所声明的.string。
图 3-3-1 hello.c中的字符串常量 |
图 3-3-2 hello.s中字符串常量的声明 |
(2)局部变量
图 3-3-4 hello.s中对i进行初始化 |
图 3-3-3 hello.c中设置局部变量i |
如图3-3-3所示,在源程序中的主函数开始时声明了局部变量i,并在接下来的for循环中对i进行操作。对应的,在hello.s中的.L2段,对局部变量i进行初始化操作
(3)立即数常量
hello.c中出现了一些数字,这些数被称之为立即数。立即数即不需要经过计算单元计算得到的数字,在编译器处理后的hello.s中,所有的立即数以“$”+数字的方式进行表示。
3.3.2赋值
图3-3-5 简单的数据传送指令 |
对于hello.c中出现的赋值操作,编译器通过简单的数据传送指令——MOV类进行实现。
3.3.3算术操作
图 3-3-6 通过add指令对变量i加1 |
对于hello.c中for循环的调整部分,即表达式i++,编译器通过如图3-3-6中的add指令操作实现对i的递增。
3.3.4关系操作
对于hello.c中的关系判断,在hello.s中通过使用cmpl设置条件位进行实现。例如对于hello.c中循环条件i<5的判断,hello.s使用“cmpl $4, -4(%rbp)”进行实现。
3.3.5数组/指针操作
在hello.c中,argv[]数组通过指针传递到main()函数之中,在hello.s中对应为图3-3-7操作,即将指向argv[]的指针的地址存入寄存器中。对于argv[]中元素的访问操作,则通过寄存器中存储的的地址加上相应的偏移量字节大小进行实现。例如对于hello.c中的argv[1]、argv[2]的访问,在hello.s中对应图3-3-8所示的操作。
图 3-3-7 记录数组首地址 |
图 3-3-8 访问argv[1]与argv[2]的操作 |
3.3.6控制转移
在hello.c中,对于if的控制转移指令,在hello.s中通过如图3-3-9所示的jump跳转指令来实现。
图 3-3-9 jump指令 |
3.3.7函数操作
如图3-3-10所示,在hello.c中调用函数的操作,在hello.s中通过call指令调用进行实现,但在hello.s中调用函数时,只显示要调用的函数的名称,并不知道函数的地址。
图 3-3-10 call指令调用函数
--
3.4 本章小结
本章首先介绍了编译的概念和作用,然后介绍了编译器对C语言中各种数据类型的操作。
第4章 汇编
4.1 汇编的概念与作用
概念:指汇编器将.s翻译成机器语言指令,并将这些指令打包成一种可重定位目标程序的格式,并将结果保存在文件.o文件的这个过程。
作用:将汇编代码转变成机器可以执行的命令,每一句汇编语句几乎都对应一条机器指令。
4.2 在Ubuntu下汇编的命令
图 4-2-1 Ubuntu下通过gcc进行汇编 |
4.3 可重定位目标elf格式
4.3.1 ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。剩下的部分则包含帮助链接器语法分析和解释目标文件的信息。
图 4-3-1 hello.o中的ELF头 |
4.3.2 节头部表
节头部表中包含有节的名称,类型,地址,偏移量以及对齐信息等,hello.o中的节头部表如图4-3-2所示。
图 4-3-2 节头部表 |
4.3.3重定位节
重定位节包含有偏移地址、基址信息、链接器识别修改类型、重定位目标的名称等,这些信息将帮助链接器在后续将目标文件合并成可执行文件时的修改引用的操作。hello.o中的重定位节如图4-3-3所示。
图 4-3-3 重定位节
4.3.4符号表
图 4-3-4 符号表 |
每个可重定位模块均有一个符号表,其包含m定义和引用的的符号的信息,其不包含对应于本地非静态程序变量的任意符号。在hello.o中的符号表如图4-3-4所示。
4.4 Hello.o的结果解析
利用objdump -d -r hello.o可以得到hello.o的反汇编,将其与hello.s进行对照分析,可以发现一些变化:
(1)操作数进制不同:
如图4-4-1与图4-4-2所示,在hello.s中,汇编语言使用的操作数为10进制数字,而在hello.o中,机器语言使用的操作数则为16进制。
图 4-4-2 hello.s中汇编语言使用的操作数
图4-4-2 hello.o反汇编中机器语言使用的操作数
(2)分支转移不同:
如图4-4-3所示,在hello.s中,不同的跳转被分成了不同的段,跳转时通过段名称进行跳转定位。而在hello.o的反汇编中,如图4-4-4所示,hello.s中的段名称被确定的相对偏移地址所替代,每一个操作都有对应的偏移地址,跳转指令根据相对偏移地址进行跳转定位。
图 4-4-3 hello.s
图 4-4-4 hello.o的反汇编
(3)函数调用不同
如图4-4-5所示,在hello.s文件中,函数调用后直接引用了所调用函数的名称。而在hello.o的文件中,如图4-4-6所示,函数调用给出的地址是下一条地址。这是因为需要调用的函数最终需要动态链接器才能确定函数的运行时的执行地址。在机器语言中,对于这些不确定地址的函数的调用时,其调用指令后的相对地址全部设置为0,等待静态链接的进一步确定。
图 4-4-5 hello.s
图 4-4-6 hello.o的反汇编
4.5 本章小结
本章介绍了hello从hello.s到hello.o的汇编过程,通过对hell.o的ELF文件的阅读,对hell.o的反汇编代码的查看以及同hello.s的汇编代码进行比较,加深了对汇编过程、重定位的方式等内容有了更深入的了解。
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,最终这个文件可被加载到内存并被执行。
作用:将程序调用的函数所在的目标文件以某种方式合并到hello.o程序中,最终得到hello文件,它是一个可执行目标文件。
5.2 在Ubuntu下链接的命令
在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-2-1 在Ubuntu下链接的命令 |
5.3 可执行目标文件hello的格式
5.3.1ELF头
ELF头以一个序列开始,描述了文件的总体格式,同时也包括了程序要执行的
图5-3-1 hello的ELF头 |
第一条指令的地址。
5.3.2节头部表
对hello中所有的节信息进行了声明,描述了各个节的大小、偏移量和其他属性。
图 5-3-2 hello的节头部表
5.3.3程序头
程序头的主要作用是描述磁盘上可执行文件的内存布局以及如何映射到内存中。
图5-3-3 hello的程序头
5.3.4段节
段节的作用主要是罗列了hello中各个节在段中的分布。
图5-3-4 hello中的段节
5.3.5动态节
动态节是动态段的核心部分,hello的动态节如图5-3-5所示。
图5-3-5 hello中的动态区域
5.3.6重定位节
重定位节包含有偏移地址、基址信息、链接器识别修改类型、重定位目标的名称等。hello中的重定位节如图5-3-6所示。
图5-3-6 hello中的重定位节
5.3.7符号表
hello的符号表如图5-3-7所示,其中包含hello的模块定义和引用的符号的信息。
图 5-3-7 hello中的符号表
5.4 hello的虚拟地址空间
使用edb加载hello,可以查看hello进程的虚拟地址空间各段信息,如图5-4-1所示。在运行进程后,其起始虚拟地址为00000000:00401000,与hello的ELF文件中节头部表中的init节虚拟地址相同(如图5-4-2)。
图 5-4-1 加载到虚拟地址的hello程序
图 5-4-2 节头部表中init节相关信息
同样,借助edb中的Plugins中的SymbolViewer功能,可以看到hello的各节点的起始地址,如图5-4-3所示。对照前文的图5-3-2,可以看到二者显示的各个节对应的虚拟地址相同。
图 5-4-3 SymbolViewer
5.5 链接的重定位过程分析
在Ubuntu中可以通过“objdump -d -r hello > hello-asm.txt”指令生成hello的反汇编文件“hello-asm.txt”,操作如图5-5-1所示,最终得到文件内容如图5-5-2所示。
图 5-5-1 生成hello的反汇编文件
图 5-5-2 hello-asm.txt |
通过对hello.o的反汇编文件同hello得到反汇编文件进行对比,可以看出如下变化:
(1)函数变化:
在hello的反汇编文件中,链接器将hello.c中用到的库函数,例如puts,printf等的位置加入到了文件中(如图5-5-3所示)。
图 5-5-3 新增函数
(2)地址变化:
图 5-5-4 hello反汇编文件中均为虚拟地址 |
相较于在hello.o的反汇编文件,hello的反汇编文件中的jmp、call指令后跟的地址由相对地址变成了虚拟地址,如图5-5-4所示
图 5-5-5 hello.o的反汇编中均为相对地址 |
计算机通过链接器实现链接的过程,在链接其对符号引用进行解析的时候,会将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对那些和引用定义在相同模块的局部符号的引用,符号解析是非常简单明了的,因为在先前的处理中编译器只允许每个模块中的每个局部符号有一个定义。对于全局符号,链接器则会按照编译器事先生成的链接器符号表在其所有的输入模块中寻找相应的定义。如果找不到的话,最终会输出一条错误信息并终止。
而对其重定位的过程分由两步组成:
(a)重定位节和符号定义
链接器对于hello.o中的所有相同类型的节合并为同一类型的聚合节,使得程序中的每条指令和全局变量都有唯一的运行时的内存地址了,因而,在hello的反汇编中的相对地址被虚拟地址所代替。
(b)重定位节的符号引用:
链接器借助汇编器事先生成的重定位条目,修改代码节和数据节对每个符号的引用,使得他们指向正确的运行时地址。因此,相较于hello.o的反汇编,hello的反汇编中的call指令能够指向所调用的函数的虚拟地址。
5.6 hello的执行流程
hello的执行流程如下表所示:
子程序名 | 程序地址 |
_init | 0x401000 |
puts@plt | 0x401090 |
printf@plt | 0x4010a0 |
getchar@plt | 0x4010b0 |
atoi@plt | 0x4010c0 |
exit@plt | 0x4010d0 |
sleep@plt | 0x4010e0 |
_start | 0x4010f0 |
_dl_relocate_static_pie | 0x401120 |
main | 0x40112d |
_fini | 0x4011b4 |
5.7 Hello的动态链接分析
如图5-7-1,在5.3中ELF的头节部表中可以看到.got.plt的虚拟地址。借助edb,可以看到在程序未运行时,其虚拟地址所对应的存储内容如图5-7-2所示。而在运行后,其内容如图5-7-3所示。
图5-7-1 .got.plt的虚拟地址
图5-7-2 未运行时存储的内容
图 5-7-3 运行后存储的内容
由此可以看出,动态链接器在程序加载的时候解析调用的共享库函数,并使用PLT和GOT来实现函数的动态链接,在解析过程中,GOT中用来存放函数目标地址,PLT使用GOT中存储的地址跳到目标函数。
5.8 本章小结
本章介绍了链接的概念与作用,通过查看虚拟地址,对比分析反汇编代码等一系列过程,加深了对重定位、执行流程、动态链接的理解。
第6章 hello进程管理
6.1 进程的概念与作用
概念:一个执行中程序的示例,是操作系统资源分配和调度的基本单位。
作用:进程会提供给应用程序一个独立的逻辑控制流和一个私有的地址空间。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell是用户与操作系统间交互的接口,它为用户提供命令行界面,使用户可以在这个界面输入shell命令,使得计算机能够执行相应的程序,从而完成用户与计算机的交互使得能够按照用户的想法操纵计算机。
处理流程:shell先读取命令,如果是内置命令就直接执行。如果不是的话就创建子进程,加载命令程序运行,并根据用户的需求在前台或后台运行。在前台时,控制权由程序控制;若在后台运行,则shell仍能继续输入与执行命令。最终当子程序终止后,shell会回收分配给子进程的空间等资源。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈,子进程还获得与父进程任何打开文件描述符相同的副本。他们之间最大的区别就是他们有不同的PID。
当运行hello程序时,在交互窗口输入./hello后,此时程序的父进程是shell,它会对这条命令进行解析,并调用fork函数创建一个新的子进程以便接下来将hello加载到这个进程中执行。
6.4 Hello的execve过程
execve函数在当前进程下的上下文中加载并运行一个新程序。其函数声明如下:
int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时候,例如找不到filename,execve才会返回到调用程序中。子进程通过execve函数系统调用finame会调用加载器,创建一组新的代码、数据、堆和栈段,并对其进行初始化,接下来,加载器会跳转到_start函数的地址点来设置用户栈,经典的用户栈结构如图6-4-1所示。最后,再将控制权交给main函数。
当为hello程序创建进程时,会调用execve函数加载并运行可执行目标文件hello。这样hello就由程序转变为进程,可以开始执行函数运行。
图 6-4-1 一个新程序开始时,用户栈的典型组织结构
6.5 Hello的进程执行
由于系统一般会运行多个进程,所以处理器的物理控制流就会被分成多个逻辑控制流。由于进程间轮流使用处理器,内核为每个进程会维持一个上下文,上下文信息就是内核重新启动一个被抢占的进程所需要而定状态,由一些对象的值组成,包括寄存器、程序计数器、用户栈等。而进城间的上下文切换模式如图6-5-1所示。其中,进程执行他的控制流的一部分的每一时间段都叫作“控制流时间片”。
图 6-5-1 进程上下文切换的剖析
为使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及可以访问的地址空间范围。处理器会通过某个控制寄存器的一个模式位来提供这个功能。当设置了模式位的时候,进程就运行在内核模式中,它就可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式。
当执行hello进程时,系统进行调度,为其分配时间片,当hello下的进程从磁盘内存中读取数据的时候,就会出发陷阱异常,切换到内核模式。此时系统就会重新开始一个先前被抢占了的进程,进行上下文切换。当切换回的进程运行了一段时间后,系统就会将hello由内核态切换为用户态,进行抢占执行,继续执行下一条指令。
6.6 hello的异常与信号处理
在hello的执行过程中可能出现的异常可以分为四类:中断、陷阱、故障和终止。这四种异常的产生信号与处理操作如图6-6-1所示。
图6-6-1 异常的类别
下面将介绍一些情况发生时出现的异常与信号的处理:
(1)运行时乱按键盘(不敲击回车):
由图6-6-2可以看出,乱按键盘时,进程并没有接收到信号,依然按照原来的流程进行操作。
图 6-6-2 运行时乱按键盘(不敲击回车)
(2)运行时按回车
如图6-6-3所示,在运行时不断敲击回车,那么在结束hello进程后,shell会把之前敲击的回车当做命令行,读入终端。
图 6-6-3 运行时不断敲击回车
(3)运行时按Ctrl-C
如图6-6-4所示,在运行时按住Ctrl-C,会使进程收到终止信号,导致进程终止。
图 6-6-4 运行时按Ctrl-C
(4)运行时按Ctrl-Z
如图6-6-5所示,在运行时按Ctrl-Z,会使进程收到停止信号,导致进程停止。
图 6-6-5 运行时按Ctrl-Z
(5)运行时按Ctrl+Z后输入ps
如图6-6-6所示,hello进程会被挂起,并在随后shell会列出后台进程。
图 6-6-6 运行时按Ctrl+Z后输入ps
(6)运行时按Ctrl+Z后输入jobs
如图6-6-7所示,hello进程会被挂起,并会显示在当前shell中的进程。
图 6-6-7 运行时按Ctrl+Z后输入jobs
(7)运行时按Ctrl+Z后输入pstree
如图6-6-8所示,hello进程会被挂起,并会列出进程间的联系。
图 6-6-8 运行时按Ctrl+Z后输入pstree
(8)运行时按Ctrl+Z后输入fg
如图6-6-9所示,hello进程会先被挂起,输入fg指令后被挂起的进程收到SIGCONT信号,重新切回到前台继续运行。
图 6-6-9 运行时按Ctrl+Z后输入fg
(9)运行时按Ctrl+Z后输入kill
如图6-6-10所示,hello进程会被挂起,并会给hello进程发送SIGKILL信号,终止hello进程。
图 6-6-10 运行时按Ctrl+Z后输入kill
6.7本章小结
本章首先介绍了进程得到概念和作用,然后以hello进程为例,介绍了hello进程的过程,并说明了一些可能会遇到的异常情况以及相应的信号处理,使得对进程管理有更深的认识。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
逻辑地址是由CPU发送给内存的地址,它通常由一个段地址加一个偏移地址组成。对于hello中的函数,其对应的逻辑地址就由段地址和偏移地址两部分组成。
7.1.2线性地址
线性地址指的是虚拟地址到物理地址变换之间的中间层。CPU将逻辑地址中的段选择器和描述符中的信息结合起来,就可以得到线性地址[2]。
7.1.3虚拟地址
虚拟地址是由操作系统分配给应用程序使用的地址,使得应用程序不需要知道物理地址的实际位置就可以调用。对于hello来说,main函数中的段内偏移量就是虚拟地址。
7.1.4物理地址
物理地址是内存中数据的实际位置,它由线性地址通过页式内存管理转化而成,是最终结果地址。将虚拟地址转换为物理地址,使得应用程序可以访问内存中的数据。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理就是把虚拟地址空间中的虚拟内存组织成一些名为“段”的内存单元。每个段由段基地址、段偏移量以及段属性组成。最终变换成的线性地址由段基地址与端内偏移两部分构成。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存机制使用页表的数据结构进行页式管理,它把线性地址空间和物理地址空间划分成大小相同的页,然后通过建立线性地址空间的页同物理地址空间中的页的映射,实现线性地址到物理地址的转化。其对应过程如图7-3-1所示,MMU利用虚拟页号(VPN)找到对应的物理页号(PPN),然后将找到的PPN与由虚拟页偏移量(VPO)得到物理页偏移量(PPO)组合就构成了实际的物理地址。
图 7-3-1 使用页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
TLB转换检测缓冲区是一个内存管理单元,其结构如图7-4-1所示。其功能是用于改进虚拟地址到物理地址转换速度的缓存,其中每一行都保存着一个由单个PTE(Page Table Entry)组成的块[3]。在使用TLB时,首先会查找相应的组索引中是否有相应的条目,若没有则会按照一定策略进行条目替换。而多级页表解决了页表条目过多的问题,在四级页表的规格下,虚拟地址被分为了4个VPN和1个VPO,根据VPN依次访问各级页表就可以找到对应的PPN,进而实现物理地址的寻址,在Core i7中,VA到PA的变化过程如图7-4-2所示。
图 7-4-2 TLB表结构[3]
图 7-4-2 在Core i7中VP与PA的变化过程
7.5 三级Cache支持下的物理内存访问
Cache是一种在CPU寄存器和物理内存之间的高速存储设备,在三级Cache中分为L1,L2,L3,它们的容量依次增大。
在现在流行的CPU中,Cache的物理地址一般由三部分组成,分别是缓存标记(CT),缓存组索引(CI)以及缓存偏移(CO)。当CPU进行访问时,会依次访问L1,L2,L3,直到命中。如果三级均没命中,则会访问主存。由于在现有的计算机中,3级Cache的结构基本可以涵盖5%的内存,因而能够极大的提升CPU的运行效率。在Core i7中的三级Cache结构如图7-5-1所示。
图 7-5-1 Core i7中的三级Cache
7.6 hello进程fork时的内存映射
当fork被函数hello进程调用的时候,内核会为新进程创建各种数据,并分配给他一个唯一的PID。为了给这个新进程创建虚拟内存,它会创建当前进程的mm_struct、区域结构和页表的原样副本。它将为两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork函数时存在的虚拟内存相同。当两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,如图7-6-1所示。
图 7-6-1 私有的写时复制对象
7.7 hello进程execve时的内存映射
当hello进程执行了execve调用时,execve函数在当前进程中加载并运行包含在可执行目标文件hello.out中的程序,用hello.out程序有效地替代了当前程序。其加载并运行hello.out需要一下几个步骤:
(1)删除已存在的用户区域。
(2)映射私有区域。
(3)映射共享区域。
(4)设置程序计数器。
图 7-7-1 加载器映射用户地址空间
7.8 缺页故障与缺页中断处理
缺页故障:就是要访问的页不在主存,需要操作系统将其调入主存后再进行访问。在这个时候,被内存映射的文件实际上成了一个分页交换文件[4]。
缺页中断处理:如果出现缺页故障,那么此时控制权会交给相应的异常处理程序,该程序会根据一定策略淘汰物理内存中的某个页,然后再从硬盘中调出所需新的所需的页,并更新PTE,并返回到原来的进程,再次执行当前指令。
7.9动态存储分配管理
计算机中动态内存分配器维护着一个进程的虚拟内存区域,称为堆,其结构如图7-9-1所示。分配器将堆视为一组不同大小的块的集合来进行维护,每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用,空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个被分配的块保持已分配状态,直到它被释放。这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。在执行hello过程中,会调用malloc函数。C标准库提供一种叫做malloc程序包的显式分配器,C程序通过调用malloc函数来分配一块,并通过调用free函数来释放一个块。因而,在hello进程的执行过程中会通过显式分配器进行释放,下面介绍显式分配器中常用的数据结构:
图7-9-1 堆的结构
在显式分配器中常使用隐式空闲链表的数据结构进行实现,其结构如图7-9-2所示。分配器可以通过遍历堆中的所有块,从而间接的遍历整个空闲块的集合。
图 7-9-2 隐式空闲链表,阴影部分是已分配块,没有阴影部分是空闲块
然而,对于通用的分配器,隐式空闲链表是不适合的,一种更好的方法是将空闲块组织为某种形式的显示数据结构,如图7-9-3所示。通过使用双向链表能够有效的减少分配时间。
图 7-9-3 显式空闲列表
7.10本章小结
本章主要介绍了hello的存储管理,先介绍了如何由指令的虚拟地址从逻辑地址通过段式管理转化到线性地址,然后介绍了hello的页式管理,以及VA到PA的变换,再然后介绍了hello进程调用fork和execve进程时的内存映射情况,最后介绍了缺页故障异常处以及动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.1.1设备的模型化:文件
所有的IO设备都被抽象成文件,而对设备的输入和输出都被当做对文件的读和写来执行。
8.1.2设备管理:unix io接口
通过unix io接口,系统能够将设备映射为文件。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口
8.2.2 open函数
open函数定义如下:
int open(char *filename, int flags, mode_t mode);
open函数可以打开或创建一个文件,mode参数为新文件的访问权限位。
8.2.3 close函数
close函数定义如下:
int close(int fd);
close函数可以对读文件关闭。
8.2.4 read函数
read函数定义如下:
ssize_t read(int fd, void *buf, size_t n);
read函数可以从打开的文件中最多复制n个字符到内存位置buf,实现读取文件的功能。
8.2.5 write函数
write函数定义如下:
ssize_t read(int fd, const void* buf, size_t n);
write函数可以从内存位置buf最多复制n个字符到fd的位置,实现写入文件的功能。
8.2.6 lseek函数
lseek函数定义如下:
off_t lseek(int fd, off_t offset, int whence);
lseek函数可以修改文件的字节偏移量。
8.3 printf的实现分析
C语言中的printf函数的函数体如图8-3-1所示[5]。
参数列表中的“…”表示printf函数可以接受不确定量的参数,而va_list arg = (va_list)((char*)(&fmt + 4)表明了arg记录printf函数可变形参中的第一个参数的地址。
vsprintf的定义为:int vsprintf(char *buf,const char *fmt,va_list args),作用是依次把传入的字符串传入buf缓冲区中。
而write函数的作用是把buf中的i个字符写到终端上。在调用write系统函数后,陷阱-系统调用 int 0x80或syscall等。接着字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。最终呈现了输出显示效果。
图 8-3-1 printf函数的函数体
8.4 getchar的实现分析
getchar函数的函数体如图8-4-1所示。
getchar函数从标准输入里读取下一个字符,当函数被调用时候,程序等待键盘输入字符串,当用户按下按键,会触发键盘中断异常,执行相应的中断处理程序。程序会将接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。getchar的返回值一般是用户输入的字符的ASCII吗,若是文件结尾(EOF)则会返回-1。
图 8-4-1 getchar函数的函数体
8.5本章小结
本章主要介绍了Linux的I/O设备的基本概念,并介绍了一些简单函数的函数体构造。
结论
hello的一生十分精彩,从Program到Process,从Zero-0到Zero-0,只缘程序员的想要执行。
在预处理阶段,预处理器修改原始的C程序,得到了hello.i文件。
在编译阶段,编译器将文本文件hello.i翻译成文本文件hello.s。
在汇编阶段,汇编器将hello.s翻译成机器语言指令,并将这些指令打包成可重定位目标文件的格式,hello.o就此诞生。
在链接阶段,链接器将hello.o所需的外部库与hello.o合并,得到了hello文件,一个可执行文件就此诞生。
当在shell中输入./hello命令时,fork函数为其创建了子进程,又调用了execave函数将其加载到内存之中,让它能够按照系统安排的控制流时间片执行自己的进程,让它能够感受着I/O设备的信号,响应着系统的信号,调用着相关的函数。
最终,它执行结束,变成了僵死的进程。waitpid函数的Shell父进程会帮助它履行最后的职责——将进程回收,释放空间。
至此,它完成了他自己平淡无奇却又波澜壮阔的一生。
通过对hello的一生的探索,我更加详细的领略了程序的诞生于执行的过程,加深了自己对计算机系统知识的理解,有了更加全面的认识,更领悟了计算机之美。同时,也对前人——那些计算机系统领域的开拓者与建设者充满了无以复加的崇高敬佩。他们从无到有,从小到大,又点滴聚成江海,最终实现了如此复杂的计算机架构。这更激励着我要不断的努力学习,学习更多的计算机知识,为以后在计算机领域的更深入的学习打好基础。
附件
文件名 | 文件作用 |
hello.c | 源程序 |
hello.i | hello的预处理文件 |
hello.s | hello的汇编文件 |
hello.o | 汇编之后的可重定位目标文件 |
hello | 链接之后的可执行文件 |
hello-asm.txt | hello的反汇编文件 |
hello-o-astm.txt | hello.o的反汇编文件 |
hello-o.elf | hello.o的ELF格式文件 |
hello.elf | hello的ELF格式文件 |
参考文献
[1]深入理解计算机系统Randal E. Bryant David R.O`Hallaron