计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2022110773
班 级 2203103
学 生 李鹏展
指 导 教 师 史先俊
计算机科学与技术学院
2023年12月
摘 要
本文重点分析hello程序的一生。Hello虽然功能简单,但在实现它的过程中却涉及到编译、链接、进程、虚拟内存等众多计算机领域的重要概念,因此梳理hello程序对于深入理解计算机系统而言是十分有必要的。本文基于X86-64硬件环境,在Ubuntu下利用vs code、edb等工具完整地分析了hello从编写到运行终止的所有过程,在进程管理和内存管理部分进行了着重的分析。全文系统全面地回顾了计算机系统课程中的内容。
**关键词:**计算机系统;计算机体系结构;操作系统;
**
**
目 录
第1章 概述 – 4 –
1.1 Hello简介 – 4 –
1.2 环境与工具 – 4 –
1.3 中间结果 – 4 –
1.4 本章小结 – 5 –
第2章 预处理 – 6 –
2.1 预处理的概念与作用 – 6 –
2.2在Ubuntu下预处理的命令 – 6 –
2.3 Hello的预处理结果解析 – 6 –
2.4 本章小结 – 8 –
第3章 编译 – 9 –
3.1 编译的概念与作用 – 9 –
3.2 在Ubuntu下编译的命令 – 9 –
3.3 Hello的编译结果解析 – 9 –
3.4 本章小结 – 12 –
第4章 汇编 – 14 –
4.1 汇编的概念与作用 – 14 –
4.2 在Ubuntu下汇编的命令 – 14 –
4.3 可重定位目标elf格式 – 14 –
4.4 Hello.o的结果解析 – 17 –
4.5 本章小结 – 18 –
第5章 链接 – 19 –
5.1 链接的概念与作用 – 19 –
5.2 在Ubuntu下链接的命令 – 19 –
5.3 可执行目标文件hello的格式 – 19 –
5.4 hello的虚拟地址空间 – 21 –
5.5 链接的重定位过程分析 – 22 –
5.6 hello的执行流程 – 23 –
5.7 Hello的动态链接分析 – 24 –
5.8 本章小结 – 24 –
第6章 hello进程管理 – 26 –
6.1 进程的概念与作用 – 26 –
6.2 简述壳Shell-bash的作用与处理流程 – 26 –
6.3 Hello的fork进程创建过程 – 26 –
6.4 Hello的execve过程 – 27 –
6.5 Hello的进程执行 – 27 –
6.6 hello的异常与信号处理 – 28 –
6.7本章小结 – 31 –
第7章 hello的存储管理 – 32 –
7.1 hello的存储器地址空间 – 32 –
7.2 Intel逻辑地址到线性地址的变换-段式管理 – 32 –
7.3 Hello的线性地址到物理地址的变换-页式管理 – 32 –
7.4 TLB与四级页表支持下的VA到PA的变换 – 34 –
7.5 三级Cache支持下的物理内存访问 – 35 –
7.6 hello进程fork时的内存映射 – 36 –
7.7 hello进程execve时的内存映射 – 36 –
7.8 缺页故障与缺页中断处理 – 37 –
7.9动态存储分配管理 – 37 –
7.10本章小结 – 37 –
第8章 hello的IO管理 – 38 –
8.1 Linux的IO设备管理方法 – 38 –
8.2 简述Unix IO接口及其函数 – 38 –
8.3 printf的实现分析 – 38 –
8.4 getchar的实现分析 – 40 –
8.5本章小结 – 40 –
结论 – 40 –
附件 – 41 –
参考文献 – 42 –
第1章 概述
1.1 Hello简介
P2P过程(From Program to Process)的含义为从编写c程序(program)到在机器上运行进程(process)之间的转换过程。这之间包含以下几个阶段:预处理、编译、汇编、链接、加载、运行等。首先,编译器驱动程序运行预处理器(cpp)将原始的.c文件转为.i文件,再运行C编译器(cc1)将.i文件转为.s文件,之后运行汇编器(as)将.s文件转为.o文件,然后运行链接器程序(ld)将目标文件组合形成可执行目标文件。最后通过加载器将程序加载到内存中并运行,这样就完成了hello.c程序从program到process的转换。
020过程(From Zero to Zero)的含义是hello.c程序从开始运行(zero)到运行结束(zero)的全过程。程序经过编译得到可执行文件后,shell解析用户要运行hello的命令,之后调用fork函数创建子进程并在子进程使用系统调用execve启动加载器,删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。然后将虚拟内存页映射到可执行文件中的片。程序在运行过程中,通过异常控制流将磁盘中的文件加载到内存中并完成运行过程。在程序运行结束后进程终止,shell对子进程进行回收,这样就结束了hello程序的运行全过程。
1.2 环境与工具
硬件环境:Intel Core i7 12700H处理器
软件环境:Windows11 64位/VMware 11/Ubuntu 16.04 LTS 64位
开发与调试工具:VS code
1.3 中间结果
hello1(采用指定编译选项得到的可执行目标文件)
hello.i(hello.c预处理后的文件)
hello.s(hello.i编译后的文件)
hello.o(hello.s汇编后得到文件)
hello(hello.o经过链接获得的可执行目标文件)
elf.txt(使用readelf查看hello.o的结果)
dump_hello.txt(hello.o反汇编得到的文件)
dump_hello_exe.txt(hello可执行文件反汇编得到的文件)
1.4 本章小结
本章简要介绍了hello.c程序的P2P过程和020过程,描述了程序从开始编译到执行结束的大致流程。同时给出了硬件环境、软件环境和开发工具等。最后列出了完成本次作业过程中所生成的中间文件。
第2章 预处理
2.1 预处理的概念与作用
预处理概念:对源文件进行文本操作而不进行分析,执行去注释、宏替换、条件编译、头文件展开等操作生成后缀为.i的文件。
预处理作用:完善代码、帮助编译、代码简化。在C程序中可以使用#include包含头文件,这样会便捷程序的编写,但会使单独的C程序失去完整性。预处理器可以将头文件中的内容插入到源文件中,实现完善代码的功能。同样的,程序员在编写过程中还可能会使用宏定义以简化编程的复杂性,使用条件编译以实现特定的功能。这些操作都会使编译变得复杂。而预处理器在编译之前就可以解决这些问题,从而提高编译的效率。而预处理器去除注释的功能显然可以实现代码的简化过程。
2.2在Ubuntu下预处理的命令
Ubuntu下预处理命令:gcc -E -o hello.i hello.c
2.2.1 Ubuntu下预处理
2.3 Hello的预处理结果解析
原始C程序为18行,经过预处理后扩展为3092行。扩展后文件的末尾为源程序中main函数代码。在这之上则是包含的头文件的展开,主要包括4个部分:头文件路径、数据类型重定义、外部函数声明、枚举类型(常量)。
2.3.1 预处理后文件中的源代码
头文件路径主要出现在文件的头部,但在下文需要的地方也可能会出现。数据类型的重定义部分关联了C语言标准数据类型与头文件中定义的数据类型。其余主要为外部函数的声明,这一部分占据了插入代码的最大部分。另外也有相当一部分内容为库函数中大量出现的常量,并用枚举类型进行归类。
2.3.2 预处理后文件中的部分头文件路径
2.3.3 预处理后文件中的部分类型重定义
2.3.4 外部函数声明
2.3.5 库函数常量和枚举类型
2.4 本章小结
本章对预处理的概念和作用进行了简要介绍,之后给出了在Ubuntu下进行预处理的命令,最后详细地解析了预处理的结果,讨论了预处理后文件各个部分的含义和作用。
第3章 编译
3.1 编译的概念与作用
概念:预处理得到的仍然是C语言程序,编译就是将C语言转化为汇编语言。
作用:汇编将C语言转化为汇编语言,这是从高级抽象到底层实现之间不可缺少的过程,是机器能够运行程序的前提。
3.2 在Ubuntu下编译的命令
Ubuntu下编译命令:gcc -S hello.i -o hello.s
3.2.1 Ubuntu下编译命令
3.3 Hello的编译结果解析
本节将从常量与变量、算术操作与关系操作、函数调用、数组内存引用和控制转移这五个方面对hello的实现进行分析。首先展示编译结果。
3.3.1 汇编代码
3.3.1 常量、变量
C源程序中的常量被存储在.rodata只读数据区域。程序hello.c中使用到的常量有两个printf的输出字符串参数,分别被标记为.LC0和.LC1。如上图5~11行。
在hello.c中的变量只有局部变量,而局部变量存储在栈中。例如,在hello程序中,局部变量i位于栈中rbp-4的位置。
3.3.2算术操作与关系操作
在hello.c中出现了算术操作“++”,这一操作通过add来完成,在汇编代码中对应于第53行,“addl $1, -1(%rbp)”。
出现了关系操作“!=”,这一操作通过cmp来完成,对应于汇编代码中“cmpl $7, -4(%rbp)”。
3.3.2 运行时栈结构
3.3.3 参数传递、函数调用、返回
在hello.c中函数调用出现7次:main(), printf(), exit(), printf(), sleep(), atoi(), getchar()。其中函数的传参、调用、返回原理一致,过程也相似。在x86-64中,函数参数优先以寄存器来传递,当参数数量超过6个时以栈来传递。本例中各个函数都是寄存器传参,寄存器传递参数的顺序为:%rdi, %rsi, %rdx, %rcx, %r8, %r9(以上假设参数为64位,若不是64位而为x位则取同一寄存器的低x位)。
在进入main()后,参数argc和argv已经分别存入到寄存器%edi和%rsi中,之后又赋值到栈中,具体见图3.3.1中22、23行及图3.3.2栈结构。对于除main()之外的其它函数,都有相应的传参语句,如29行为exit()传参,37行41行43行为第二处printf()传参,51行为sleep()传参,49行为atoi()传参。
函数调用通过call实现,返回通过ret实现。
3.3.4 数组操作与内存引用
在hello.c程序中出现了数组argv[][],这是作为main()函数参数的指针数组。他的寻址语句有3处:35、36行,38、39行,46、47行。64位系统机器地址为8字节,因此通过加8、加16、加24分别可以得到main()函数的第2、3、4个参数的指针。
3.3.3 指针数组的寻址
3.3.5 控制转移
在hello.c中的控制转移有两处,第一处为if条件判断,通过cmpl和je两句汇编指令实现。第二处为for循环,对-4(%rbp)处的局部变量i使用cmpl判断并结合jle跳转,在每轮循环中i自加1(53行)。
3.3.4 for循环控制结构
3.4 本章小结
本章简单介绍了编译的概念与作用,并给出了在Ubuntu下的编译命令。之后详细地对hello.c的汇编代码进行了分析,介绍了hello的汇编实现方式。从常量与变量、算术操作与关系操作、函数调用、数组内存引用和控制转移这五个方面对hello的实现进行了剖析。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编是指汇编器(as)将文本格式的汇编代码转化为二进制文件的过程,其中一项重要的工作是向文件中添加符号表。
作用:汇编过程将代码转化为二进制使得文件可以被机器识别,添加符号表使文件成为可重定位目标文件(文件后缀从.s转换为.o)
4.2 在Ubuntu下汇编的命令
Ubuntu下汇编命令为:gcc -c -o hello.o hello.s
4.2.1 Ubuntu下汇编命令
4.3 可重定位目标elf格式
可重定位目标文件hello.o的ELF格式包括ELF头、.text、.rodata、.data、.bss、.sy mtab、.rel.text、.rel.data、.debug、.line、.strtab、节头部表这些节。ELF头中包含系统的字大小、目标文件类型、机器类型、节头部表文件偏移等部分。使用readelf查看得到的信息已导出到附录文件elf.txt中。
4.3.1 ELF格式基本信息
.text中为已编译程序的机器代码,.rodata中包含只读数据,如printf语句的格式串和开关语句的跳转表,.data中包含已初始化的全局和静态C变量,.bss中未初始化的全局和静态C变量。
.symtab为符号表,存放程序中定义的函数和全局变量的信息,.rel.text包含有代码的重定位条目,.rel.data中包含有已初始化数据的重定位条目。.debug为调试符号表,.line为源程序行号与机器指令之间的映射,.strtab为字符串表。
4.3.2 ELF头内容
4.3.3 各个节的基本信息
4.3.4 符号表中各条目
4.3.5 重定位节.rela.text各条目
由上图可以看出,这一ELF文件中包含8个重定位条目。每个重定位条目包括偏移量、信息、类型、符号值、符号名称和加数。其中偏移量为这一重定位条目对应的符号引用在节中的偏移量。类型决定了重定位时计算的方式,常用的有R_X86_64_PC32和R_X86_64_32,前者对应与PC相对引用,后者对应于绝对引用。上图中R_X86_64_PLT32为动态链接中的过程链接表条目。
4.4 Hello.o的结果解析
首先使用obidump对hello.o进行反汇编并导出到dump_hello.txt中,之后进行对比。机器语言的指令由操作数和操作码组成,只是以十六进制数表示,与汇编语言基本满足一一对应的关系。反汇编与汇编文件主要有以下三点区别:
- 反汇编以十六进制表示操作数,而汇编文件以十进制表示操作数。
- 汇编文件中跳转语句jmp以段名表示跳转目标位置,反汇编文件中则为目标位置与PC的相对距离。
- 汇编文件中call后面为函数名,而反汇编文件中call后面为PC相对地址。
4.4.1 反汇编代码
4.5 本章小结
本章简要介绍了汇编的概念和作用,同时给出了Ubuntu下的汇编命令。之后分析了ELF格式的结构,包括ELF头、符号表条目和重定位条目等。最后比较了hello.s汇编文件与hello.o文件反汇编得到的代码,给出了二者之间的不同。
第5章 链接
5.1 链接的概念与作用
链接的概念:链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存中并执行。链接由链接器自动执行,将可重定位目标文件或共享文件转化为可执行目标文件。
链接的作用:链接使分离编译成为可能,这样我们在开发大型软件中可以将源文件分解为更小、更好管理的模块,可以独立地修改和编译这些模块,从而提高开发和维护效率。
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
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.1 ELF头信息
5.3.2 ELF格式hello各节信息
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
5.4.1 edb查看虚拟内存信息
加载器执行加载时会在代码段、数据段与虚拟内存之间建立映射关系(建立映射的有.init, .text, .rodata, .data, .bss以及其它动态链接部分)。在5.3的节头部表中可以看到,.text节的起始地址为0x4010f0,.data起始地址为0x404048。在edb中查看这两处地址。
5.4.2 虚拟内存中.text节
5.4.3 虚拟内存中.data节
5.5 链接的重定位过程分析
使用objdump反汇编可执行程序hello后得到信息已导出到dump_hello_exe.txt文件中。将它与dump_hello.txt文件进行对比,发现hello.o反汇编后只有.text一节,而hello反汇编之后则有5节。另外,hello.o反汇编中的重定位条目也已经替换为了虚拟内存地址,包括内存引用与函数调用,如下图所示。
链接器在完成符号解析之后,就要合并各个目标文件中的相同节并进行重定位。它首先赋予各节和各符号虚拟内存空间地址,之后根据重定位条目修改代码区与数据区中出现的符号引用,将占位符改为虚拟地址的算法根据重定位条目中的类型项来选取。
5.5.1 hello.o反汇编中的重定位条目
5.5.2 hello反汇编中已替换为虚拟内存地址
5.6 hello的执行流程
使用edb将断点打在0x4010f0(_start)处,开始调试,查看程序执行的流程。
5.6.1 使用edb查看hello的执行流程
调用与跳转的各个子程序名:
Ld-2.27.so! _dl_start
Ld-2.27.so! _dl_init
_start
_libc_start_main
_GI__cxa_atexit
_new_exitfn
_setjmp
__sigsetjmp
__sigjmp_save
_libc_csu_int
Init
Main
Puts
Printf
Atoi
Sleep
Getchar
__GI_IO_puts
__strlen_avx2
_IO_new_file_xsputn
IO_valodate_vtable
Exit
__GI_exit
__run_exit_handlers
5.7 Hello的动态链接分析
动态链接器使用过程链接表(PLT)和全局偏移量表(GOT)来实现函数的动态链接。GOT中存储着函数的目标地址,PLT使用GOT中的地址跳转到目标函数。在加载时,动态链接器会重定位 GOT 中的每个条目,使其包含目标的虚拟地址。
5.7.1 动态条目查看位置
5.7.2 dl_init前条目
5.7.3 dl_init执行后条目
由上图可以看出,选中区域的数据发生了变化,dl_init在执行后,修改了GOT[1]保存的指向已加载共享库的链表地址和动态链接器在ld-linux.so模块中的入口,使得程序在接下来能够通过PLT和GOT进行动态链接。
5.8 本章小结
本章介绍了链接的概念和作用,并给出了Ubuntu下的链接命令。之后详细阐述了可执行文件hello的结构,对hello的虚拟地址空间进行了说明。然后详细地分析了链接的重定位过程以及hello的执行流程,最后对hello的动态链接过程做了简要的分析。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程就是一个执行中的程序的实例,是一段可执行程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元。
进程的作用:进程为应用程序提供了两个关键的抽象:独立的逻辑控制流和私有的地址空间。这两个抽象使程序好像独占处理器和内存空间,从而使程序运行中的管理过程更加的方便高效。
6.2 简述壳Shell-bash的作用与处理流程
shell的作用:shell负责各进程的创建、程序加载运行,具有前后台控制、作业调用、信号的发送与管理等功能。
shell的处理流程:shell首先读取用户的命令行,将该命令行转存到参数列表之后对其进行求值解析。如果是内置命令则直接运行,如果不是的话,则建立一个新的进程并在新的进程中加载程序。shell通过fork函数创建新进程并通过execve函数将可执行文件的.text、.data、.bss等段加载到当前进程的内存空间,之后调用加载器跳转到_start处开始运行程序。shell负责全程管理子进程与父进程在运行时的信号处理,直到该命令行的所有任务都已完成,之后开始下一轮的交互行为。
6.3 Hello的fork进程创建过程
在shell中输入命令行./hello 2022110773 李鹏展 1后,shell将命令行转存到参数列表并进行解析。
6.3.1 输入命令行运行程序
shell在判断输入命令不是内置命令后,调用fork函数创建一个子进程。fork函数的原型为pid_t fork(void); 新创建的子进程中fork函数返回的PID为0,而在父进程中fork函数返回的为子进程的PID。除此之外,子进程与父进程信息基本相同。子进程得到与父进程用户级虚拟地址空间相同但独立的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这说明子进程可以读写在父进程中打开的文件。
6.4 Hello的execve过程
在使用fork创建新进程之后,shell调用execve函数在当前的进程上下文中加载并运行hello。execve函数的原型为:int execve(const char *filename, const char *argv[], const char *envp[]); argv变量指向一个以null结尾的指针数组,其中有4个指针,分别指向4个字符串:“./help”, “2022110773”, “李鹏展”, “1”。envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name = value”的名字-值对。
6.4.1 参数列表
函数execve通过调用加载器loader创建内存映像,之后在程序头部表的引导下将可执行文件的片映射到代码段和数据段。然后加载器跳转到程序的入口点_start函数并由_start函数调用系统启动函数__libc_start_main,__libc_start_main函数会初始化执行环境,调用用户层的main函数,处理main函数的返回值并在需要的时候把控制返回给操作系统内核。
6.5 Hello的进程执行
内核会为每一个进程维持一个上下文,也就是内核重新启动一个被抢占的进程所需的状态,包括寄存器值、栈、页表、进程表等信息。在运行hello时,子进程同样拥有一个独立的上下文,当执行系统调用或发生中断时,内核会抢占hello进程,保存hello进程上下文并执行另外的进程。系统通常会产生周期性定时器中断以划分时间片,每当时间片结束时都会发生上下文切换。
当hello程序执行系统调用时,会从用户态转变为内核状态并执行系统调用程序,执行结束时会检查并处理信号,然后从内核态转变为用户态,继续执行用户程序。
6.5.1 进程上下文切换
6.6 hello的异常与信号处理
异常分为4种:中断、陷阱、异常、终止。程序hello执行过程中会产生以下几类异常:系统定时器中断、系统调用处的陷阱、缺页故障。除此之外,还可能产生向硬件中断这样的异常(如键盘中断)。当产生定时器中断时,系统会执行上下文切换,调度其它进程;当遇到系统调用时,会从用户态转变为内核态并执行fork、execve等系统调用函数,之后回到下一条指令处;当遇到缺页故障时,内核会执行缺页处理程序,执行完毕后回到原来指令处再次执行。
6.6.1 异常处理流程
执行hello过程中可能产生的信号主要有3种:SIGINT、SIGTSTP、SIGCHLD。键盘输入Ctrl+C可以向前台进程发送信号SIGINT,当进程收到SIGINT信号后会终止;输入Ctrl+Z可以向前台进程发送SIGTSTP信号,进程收到后会停止(挂起);当子进程终止时,内核会向它的父进程发送SIGCHLD,父进程收到后会回收它已终止的子进程。
6.6.2 按Ctrl+Z
6.6.3 继续按fg
6.6.4 按下Ctrl+C
6.6.5 乱按不回车
6.6.6 乱按并回车
6.6.7 停止时输入命令ps、jobs
6.6.8 停止时输入命令pstree部分结果
6.6.9 停止时输入命令kill终止hello进程
6.7本章小结
本章首先简要介绍了进程的概念和作用,并对Shell的作用和处理流程加以解释。之后具体分析了fork创建进程的过程和execve加载程序的过程。接下来解释了hello程序的进程执行过程,最后具体分析了hello的异常与信号处理机制。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:在程序编写过程中使用的段基址加段偏移形式的地址表示。
线性地址:由非负连续整数地址构成的有序集合{0,1,2,……}。
虚拟地址:在一个带虚拟内存的系统中,CPU能够生成的具有N=2^n个地址的地址空间,这样的地址即为虚拟地址。
物理地址:计算机主存被组织成一个由M个连续的字节大小的单元组成的数组,每个字节都有的一个唯一的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel8086中,有4个段寄存器用来保存段地址:CS,DS,SS,ES,分别为代码段寄存器、数据段寄存器、堆栈段寄存器和附加段寄存器。在程序运行时内存的段起始位置和大小会被确定,各个寄存器的值也会被确定,同时有关段的管理属性也会确定下来。
Intel的段式管理分为实模式和保护模式。在实模式下逻辑地址为CS:EA,到线性地址的转换方式为:CS左移4位再与EA相加。在保护模式下则相对复杂,需要以段描述符为下标,通过查全局描述符表(GDT)和局部描述符表(LAT)获得段地址,之后与偏移地址相加就得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
计算机系统通常将虚拟地址作为作为页式管理的工具,通过地址翻译完成从虚拟地址(线性地址)到物理地址的变换。
- 页表
页表是一个存放在物理内存中的数据结构,以页表条目(PTE)作为基本单元。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。
7.3.1 页表条目结构
- 基本的地址翻译过程
第一步:处理器生成一个虚拟地址并传送给内存管理单元MMU。
第二步:MMU结合页表基址寄存器PTBR生成页表条目地址,并向高速缓存或者主存请求获取这一地址上的内容。
第三步:高速缓存或主存向MMU返回页表条目信息PTE。
第四步:MMU根据页表条目中的物理页号和虚拟地址中的页内偏移构造出物理地址,并传送给高速缓存或主存。
第五步:高速缓存或主存将该地址下的数据传送给处理器。
在不发生缺页的情况下,通过以上5个步骤就可以将虚拟地址(线性地址)转化为物理地址,完成页式管理的过程。如果MMU收到页表条目后发现缺页,则会引发异常,内核会调用异常处理程序完成页面的加载并更新页表条目PTE,之后返回原来进程,重新执行指令,CPU再次向MMU发送虚拟地址。
7.3.2 不缺页情况下的地址翻译
7.3.3 缺页时的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
- TLB加速从VA到PA的地址翻译
TLB是一个小的、虚拟寻址的缓存,每一行中都保存有单个PTE组成的块。使用TLB做内存中页表的高速缓存可以加速MMU对页表条目的获取。在CPU产生虚拟地址并发送给MMU后,MMU从TLB中取出相应的PTE,如果TLB中不存在那么还是从内存中页表中取出。之后过程与上一节中基本的地址翻译过程类似。
7.4.1 TLB加速地址翻译
- 多级页表减少内存存储页表的开销
假设页表条目大小为4字节的话,那么32位系统虚拟地址空间对应的所有页表大小为4MB,对内存而言是一种浪费。如果是64位系统,内存甚至无法装载所有的页表。由于虚拟内存中有大量未使用的区域,因此使用多级页表可以减少内存在存储页表方面的开销。
使用多级页表管理时,虚拟地址被分为k个VPN和1个VPO。以36位VPN四级页表为例,每个VPN被划分为四个9位的片,每个片又被用作到一个页表的偏移量。例如,VPN1提供到一级页表的偏移,一级页表中条目包含了二级页表的基地址。VPN2、VPN3、VPN4同样如此。以这种方式,就可以根据虚拟地址的不同位来确定各级页表,最终确定页表条目。对于未被使用的区域,则不在上级页表中设置页表,从而大大降低了内存存储页表的开销。
7.4.2 四级页表支持下的地址翻译
7.5 三级Cache支持下的物理内存访问
三级cache支持下物理内存访问在上图7.4.2中同样有所体现。MMU在将物理地址发送给缓存后,缓存从物理地址中抽取缓存偏移(CO)、缓存组索引(CI)以及缓存标记(CT)。缓存根据CI查找组,之后将CT与组内块中数据依次进行比较,命中则直接从缓存中取出,否则就依次在L2、L3、主存中查找,查找到后更新各级缓存。
7.5.1 使用物理地址读取缓存示例
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用,内核会为新进程创建各种数据结构并分配唯一的PID,创建的数据结构包括:mm_struct、区域结构、页表副本。之后内核将两个进程中的每个页表标为只读,并将每个区域标为写时复制,当这两个进程中任一个进行写操作时,都会触发一个保护故障。由于被标为写时复制,故障处理程序会创建一个新副本并更新页表条目指向这个新副本,建立新的内存映射。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件中的程序,需要以下几个操作:
- 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的 .text 和 .data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
- 映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC),execv() 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
当发生缺页的情况下,系统分为6个步骤来处理,其中1~3步和7.3中介绍的前3步相同。第四步将CPU的控制传递到缺页处理程序。第五步中缺页处理程序选择物理内存中的牺牲页,若以修改则写回磁盘。第六步中缺页处理程序调入新的页面并更新内存中的PTE。具体过程参见图片7.3.3。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间虽然会有细节不同,但是都具有一些共同的特点。假设堆是一个请求二进制零的区域,它紧接在未初始化的区域后开始,并向上生长。对于每个进程,内核维护着变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显示地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显示地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显示地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
1.显式分配器,要求应用显式地释放任何已分配的块。
2.隐式分配器,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配块的过程叫做垃圾收集。
7.10本章小结
本章首先介绍了hello的几种存储器地址空间,包括逻辑地址、线性地址、虚拟地址和物理地址。之后详细地分析了逻辑地址到线性地址以及线性地址到物理地址的变换过程。接下来分析了使用TLB加速地址翻译的原理以及多级页表(四级页表)节省页表存储空间的策略。然后具体阐释了三级Cache支持下的物理内存访问过程。在7.6和7.7节,简要说明了hello进程中fork和execve函数执行时的内存映射。在7.8节再次分析了缺页故障和缺页中断的处理策略。最后在7.9节介绍了动态存储分配的基本方法与策略。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:Unix I/O接口
一个Linux文件就是一个m个字节的序列:B0,B1,B2……Bk……。所有的I/O设备都会被模型化为文件,所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,即为Unix I/O。
8.2 简述Unix IO接口及其函数
Unix I/O的基本接口包括:打开文件、关闭文件、读文件、写文件。
- 打开文件
int open(char* filename,int flags,mode_t mode),进程调用 open 函数来打开一个存在的文件或是创建一个新文件的。open 函数将 filename 转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
- 关闭文件
int close(fd),fd 是需要关闭的文件的描述符,close 返回操作结果。
- 读文件
ssize_t read(int fd,void *buf,size_t n),read 函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置 buf。返回值 -1 表示错误,0 表示 EOF,否则返回值表示的是实际传送的字节数量。
- 写文件
ssize_t wirte(int fd,const void *buf,size_t n),write 函数从内存位置 buf 复制至多 n 个字节到描述符为 fd 的当前文件位置。
8.3 printf的实现分析
首先查看printf函数的定义:
8.3.1 printf函数的定义
8.3.2 函数vsprintf定义
函数vsprintf将参数格式并存入buf中,返回值为格式化数组长度。之后write函数将参数存入寄存器并使用int 21h调用sys_call。系统调用通过总线将字符串中字节从寄存器中复制到显存中,此时显存存储字符的ASCII码值。
接下来字符显示驱动子程序通过ASCII码在字模库中找到点阵信息并存入到vram中。显卡会以一定刷新频率逐行读取vram并通过信号线向液晶显示器传输每一个点作为RGB分量。经过以上步骤,就将hello中要显示的字符打印出来了。
8.4 getchar的实现分析
异步异常-键盘中断的处理:用户从键盘中输入,触发中断,内核将控制转移到键盘中断处理子程序。处理程序接受按键扫描码并转成ascii码,之后保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用在内核态下读取按键ascii码,直到接受到回车键才返回,返回后回到当前进程成功读取字符。
8.5本章小结
本章首先简要介绍了Linux的IO设备的管理方法,即以读写文件的方式处理与设备的IO。之后分条列举了Unix的IO接口并给出了对应的函数。最后分析了printf函数和getchar函数的实现过程。
结论
在对hello程序一生各个阶段进行详细的分析之后,有必要对各个过程进行总结。在hello的一生中,会经历一下过程:
- 编程:形成C语言文件hello.c。
- 预处理:hello.c去注释、宏替换、条件编译、头文件展开之后形成hello.i。
- 编译:将hello.i编译为文本形式的汇编代码文件hello.s。
- 汇编:将hello.s转化为可重定位目标文件hello.o。
- 链接;调用链接器将hello.o与其它目标文件链接形成可执行目标文件hello。
- 加载:shell调用fork创建子进程,调用execve加载hello到进程上下文中。
- 运行:在被内核调度到的时候,进入CPU执行。
- 访存:不仅需要从虚拟地址转换到物理地址,还要经历Cache中的查找。
- 异常:在hello运行时可能会经历中断、陷阱、故障、终止等。
- 信号:键盘输入Ctrl+Z、Ctrl+C可以让进程停止、终止。
- 结束:shell回收子进程,然后有内核删除hello进程相关的所有数据结构。
经过梳理hello程序的一生,我重新梳理了相关的多方面的知识,在这一过程中对计算机系统的认识有了很大的提高。
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello1:采用指定编译选项得到的可执行目标文件
hello.i:hello.c预处理后的文件
hello.s:hello.i编译后的文件
hello.o:hello.s汇编后得到文件
hello:hello.o经过链接获得的可执行目标文件
elf.txt:使用readelf查看hello.o的结果
elf_exe.txt:使用readelf查看hello的结果
dump_hello.txt:hello.o反汇编得到的文件
dump_hello_exe.txt:hello可执行文件反汇编得到的文件
参考文献
[1] Randal E.Bryant等.深入理解计算机系统(原书第3版)[M]. 北京:机械工业出版社 2016.7:2.
[2] Printf函数实现的深入剖析. https://www.cnblogs.com/pianist/p/3315801
[3] 理解链接之链接的基本概念. https://www.jianshu.com/p/4c660bea1fa9