摘要:以hello.c程序为例,文章探究了从C语言代码文件到进程回收这一过程。基于Linux操作系统,运用gcc,edb等工具,详细介绍了程序编译的流程,分析了运行可执行文件时系统的进程管理与存储管理原理与机制,到最后hello被回收的整个过程。文章按照程序生成与执行的顺序,逐步分析计算机系统系统利用了什么原理,以及是如何实现这些过程。
关键词:计算机系统;编译;存储器层次;进程;虚拟内存;
目录
第一章 概述
1.1 Hello简介
Hello的P2P,即from Program to Process,指的是从C语言代码文件hello.c到Linux内核为可执行文件开辟进程的过程。C语言源文件经过预处理、编译、汇编、链接四大步骤,最终形成可执行目标文件hello,存储在磁盘中。执行该文件时,shell为其创建子进程,并且在子进程中加载该程序。操作系统提供异常控制流,由缺页故障处理程序将其载入物理物理内存。
Hello的020,指的是当hello程序终止,父进程将其回收,操作系统内核删除相关数据结构,释放其占据的资源,hello的一生就此结束。
1.2 环境与工具
本台机器CPU为x86-64架构,主频2.5GHz。主存大小8GB。操作系统为Windows 10, 64位。虚拟机平台使用VMware Workstation 16 Player,虚拟机操作系统Ubuntu 18.04,64位。开发工具使用gcc+gedit。
1.3 中间结果
- hello.i 预处理程序。
- hello.s 汇编程序。
- hello.o 可重定位目标程序。
- hello 可执行目标程序。
1.4 本章小结
本章简要描述了hello.c的一生,介绍机器硬件与软件配置,并展示中间结果文件,为后续探究打下基础。
第二章 预处理
2.1 预处理的概念与作用
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。例如,程序hello.c中第一行#include <stdio.h>命令告诉预处理器读取头文件stdio.h的内容,并直接插入程序文本中。此外,对于hello.c文件前四行注释语句,预处理器也会在hello.i文件中将其删除。
2.2 在Ubuntu下预处理的命令
在Linux Terminal中输入预处理语句:
gcc hello.c -E -o hello.i
生成的文件hello.i部分如图1所示:
2.3 Hello的预处理结果解析
经过预处理后,程序文本由原先的23行扩展到3105行。
在hello.i文件的尾部,能发现hello.c文件的程序语句,如图2所示。证明预处理后的.i文件仍保留了原先大部分代码。
同时,预处理器会将#define语句后宏定义进行替换。例如,将ITER替换为相应的8。
此外,分别在预处理文件hello.i与被引用的头文件stdio.h搜索printf()函数,如图3所示。发现hello.i拷贝了stdio.h中函数的定义,也就证明了预处理器会将头文件插入文本。
2.4 本章小结
本部分阐述了预处理的概念、作用、Linux预处理指令。并且经过程序hello.c的预处理文件hello.i与原文件、头文件的对比,验证了预处理器的几大功能:#define定义符号的替换;注释的删除以及头文件文本拷贝等等。
第3章 编译
3.1 编译的概念与作用
编译阶段是编译器(ccl)将文本文件hello.i翻译成文本文件hello.s。hello.s是汇编语言程序,主要包括低级机器语言指令。这一步骤主要有四个任务:语法分析,词法分析,符号汇总与语义分析。
3.2 在Ubuntu下编译的命令
在Linux Terminal中输入预处理语句:
gcc -m64 -Og -no-pie -fno-PIC hello.i -S -o hello.s
生成的hello.s文件部分如图4所示。
3.3 Hello的编译结果解析
3.3.1 数据类型处理
对于hello.c中的整数常量,编译器将其转换为立即数,与代码一起保存在.text段中。例如,原文件中表达式i = 0的编译结果为movl $0 %ebx(%ebx为存放变量i的寄存器)。原文件中argc与4进行判断,对应的编译结果也是相应的寄存器与立即数比较。注意,编译器并不一定直接拷贝原文件中的立即数。例如,对于循环条件i<8,对应的机器语言为cmpl $7, %ebx。如图5所示。
对于局部变量,编译器将其存储在栈或寄存器中。例如原程序定义变量的语句int i,编译器将其转换为subq $8, %rsp,也就是在栈中开辟8Byte的空间。其中低4Byte用于存放整型变量i。其中立即数$8是由于SSE指令集要求数据地址16字节对齐(.cfi_def_cfa_offset 32意味着此时栈顶指针相对于调用main前栈顶指针偏置32Byte)。虽然该程序中变量i始终保存在寄存器中,栈仍然为其开辟内存空间,如图6所示。
对于字符串常量,hello.s文件将其保存在.rodata段中。其中,汉字使用UTF-8编码,一个汉字占用3~4字节。如图7所示。
当printf()函数将字符串常量作为参数时,指令movl $.LC1, %edi表示将相应字符串的地址$.LC1存入对于的寄存器,如图8所示。
对于数组,例如将argv[0]与argv[1]作为函数参数,由于寄存器%rbp存储数组argv首地址,因此(%rbp)+8与(%rbp)+16分别为对应的函数参数。如图9所示。在这个过程中,编译器需要确定数组第i个元素地址相对首地址的偏移量。偏移量取决于索引i与数组元素的数据类型大小。
3.3.2 操作处理
对于赋值操作,通过movx Src, Dst的数据传送指令完成。其中,x为操作数指示符,Src为源操作数,Dst为目的操作数。例如,如图5所示,对于i = 0语句,编译器生成的汇编指令为movl $0 %ebx。
对于关系操作与控制转移操作,通过cmpx S1, S2指令完成S1与S2的判断,用S2-S1置标志寄存器中的标志位。同时,用jc Label指令完成条件跳转。其中,c表示跳转条件,根据标志位判断是否跳转。Label为跳转目的地的标号。例如,对于原程序判断语句if(argc != 4),先将立即数$4与寄存器%edi中的值进行比较。此时,若argc == 4,则argc – 4 = 0,即标志位ZF=1。汇编语句jne .L6的跳转条件为~ZF,即ZF=0时跳转。因此,当argc=4时,继续顺序执行,否则跳转到.L6处执行。如图10所示。
对于算数操作,例如原程序中的自增操作i++,编译器使用双字整数加法指令addl $1, %ebx,即%ebx + 1,再将运算结果送回%ebx。
对于函数操作,包括参数传递、函数调用、局部变量与函数返回等问题。调用函数时,参数1~n首先依次存放在%rdi,%rsi,%rdx,%rcx,%r8,%r9寄存器中,剩余的参数从后往前依次入栈。例如,向printf()函数传递参数时,%rdi存字符串常量地址,%rsi存放argv[1],%rdx存放argv[2],如图11所示。
调用函数时,编译器使用指令call Label,其中Label为被调用过程的起始地址。该指令把当前PC入栈,再设置PC为Label。当过程调用结束时,对应汇编指令ret,从栈中弹出返回地址,并设置PC,回到调用过程的下一条语句。
在3.3.1中已经阐明局部变量存储在栈中。此外,函数返回值存储在寄存器%rax中。
3.4 本章小结
本章阐述了编译的概念与作用,以及Linux下从预处理文件.i生成汇编程序.s的指令与过程。此外,详细介绍并验证了编译器如何处理各个数据类型以及各类操作。深刻认识到了编译器在高级程序语言与二进制机器代码之间发挥了至关重要的桥梁作用。
第4章 汇编
4.1 汇编的概念与作用
汇编是指汇编器(as)将.s文本文件翻译成机器指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o二进制文件中。将高级语言转化为机器可直接识别执行的代码文件,方便后续操作。
4.2 在Ubuntu下汇编的命令
Linux下从.s文件生成.o文件的指令为:
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
4.3 可重定位目标elf格式
4.3.1 ELF头
使用指令readelf -h hello.o查看可执行目标文件的ELF头,如图13所示。
ELF头包括十六字节标识信息、文件类型(ELF64)、数据编码(补码,小端)、节头表偏移(1136,0x470Bytes)、节头表大小(64Bytes)以及节头表项个数(14个)等重要信息。
4.3.2 节头表
使用指令readelf -S hello.o查看节头表信息,如图14所示。
节头表包含每个节的节名、偏移和大小等信息。例如,符号表(.symtab)偏移0x150字节,大小为0x198字节,每一条目大小0x18字节,8字节对齐。
4.3.3 重定位节
使用指令readelf -r hello.o查看ELF文件的重定位节信息,如图15所示。重定位信息用于对被模块使用或定义的全局变量进行重定位的信息。
例如,图15显示hello.o需要对.rodata的字符串、puts、exit、printf、atoi、sleep、getchar进行重定位。其中,R_X86_64_32表示重定位绝对引用,R_X86_64_PC32表示重定位PC相对引用。
4.3.4 符号表
使用指令readelf -s hello.o查看ELF文件的符号表,如图16所示。
.symtab符号表存放函数和全局变量信息,但不包括全局变量。
4.4 Hello.o的结果解析
使用指令objdump -d -r hello.o查看hello.o的反汇编,如图17所示。
机器语言由二进制指令构成,其与汇编指令基本上一一对应。相比汇编指令,机器语言没有助记符,且跳转指令的目标被修改为相对地址。此外,在引用函数与全局变量时,都会生成一条重定位条目。利用重定位条目,可以找到引用函数或全局变量的虚拟空间地址。
4.5 本章小结
本章阐释了汇编的概念与作用,将.s汇编语言文件转化为.o可重定位目标文件。详细分析ELF格式文件,并查看了ELF头、节头表、重定位节与符号表的内容。对比.s与.o文件的相同与不同之处,理解汇编器的作用,为下一步链接做好准备。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。链接可以执行于编译时;也可以执行于加载时;甚至于运行时。链接主要执行符号解析、重定位的过程。
5.2 在Ubuntu下链接的命令
在Linux中输入ld链接指令:
ld -dynanic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello.out
5.3 可执行目标文件hello的格式
在Linux中输入指令:readelf -S hello,可以查看hello的ELF各段的基本信息,如图19所示。
5.4 hello的虚拟地址空间
在Linux运行命令:edb --run hello,使用edb加载hello,在Data Dump中查看本进程的虚拟地址空间各段信息。
由图19可知,.rodata段起始地址为0x400670,在虚拟内存的相应位置可以看到printf()函数的两个输出模式字符串,如图20所示。
其余段在Data Dump中显示为乱码,虚拟内存空间与ELF节头表内容之间对应关系不明显。
5.5 链接的重定位过程分析
在Linux中输入objdump -d -r hello,部分结果如图21所示:
5.5.1 链接的过程
将图17与图21进行比较,发现新增了许多hello.c引用并使用的库函数,例如printf()、atoi()与sleep()等,如图22所示。
此外,在主函数部分,重定位条目被对应的虚拟空间地址替代,并且原先从0x0开始的地址空间被映射到虚拟地址空间。在虚拟地址空间中,主函数第一条指令的地址为0x400582。
从可重定位文件与可执行文件的反汇编对比中,可以看出,链接器负责合并多个可重定位文件,将多个.o文件的各段按照一定规则累积在一起。根据.o提供的重定位条目,将函数调用和控制流跳转的地址填写为确定的虚拟的地址。
5.5.2 重定位过程
在Linux中输入指令readelf -s hello查看可执行文件的符号表,如图24所示。
如图17所示,在可重定位文件中,每当程序引用函数与全局变量时,都会生成一条重定位条目。根据重定位条目中的offset,symbol,type与addend四个字段,根据可执行文件的ELF,计算出相应函数或全局变量在虚拟存储空间中的地址。
5.6 hello的执行流程
从加载hello开始,到进入main函数,各函数名及其地址依次如表1所示。
序号 | 函数名 | 虚拟空间地址 |
---|---|---|
1 | _dl_start | 0x00007efdb5fe9ea0 |
2 | _dl_init | 0x00007efdb5ff87d0 |
3 | _start | 0x000000000400550 |
4 | _libc_start_main | 0x00007f43e92cfba0 |
5 | _libc_sta | 0x00007f43e92f1420 |
6 | _cxa_atexit | 0x00007f43e92ecc00 |
7 | _setjmp | 0x00007f43e96bc270 |
8 | main | 0x0000000000400582 |
从执行main函数开始到程序终止,各函数名与函数地址如表2所示。
函数名 | 函数地址 |
---|---|
hello!init | 0x4004c0 |
hello!puts@plot | 0x4004f0 |
hello!printf@plot | 0x400500 |
hello!getchar@plot | 0x400510 |
hello!atoi@plot | 0x400520 |
hello!exit@plot | 0x400530 |
hello!sleep@plot | 0x400540 |
5.7 Hello的动态链接分析
在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件hello。此时共享库中的代码和数据没有被合并到hello中。加载hello时,动态链接器对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。
动态链接采用了延迟加载的策略,即在调用函数时才进行符号的映射。使用全局偏移量表GOT与过程链接表PLT实现函数的动态链接。GOT是数据段的一部分,存放函数目标地址,为每个全局函数创建一个副本函数,并将对函数的调用转换成对副本函数调用。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息,GOT[2]是动态链接器ld-linux.so模块中的入口点。PLT是代码段的一部分,使用GOT中地址跳转到目标函数。
由5.6可知,函数dl_init函数地址0x00007efdb5ff87d0,设置断点。由图19可知,.got与.got.plot段的虚拟地址为0x600ff0。在dl_init前后,.got与.got.plot段的内容如图所示。
5.8 本章小结
本章介绍了链接的概念和作用,重点分析了hello的ELF格式。通过edb调试查看程序的虚拟地址空间、重定位和执行过程。简述了动态链接的原理和过程。
第6章 hello进程管理
6.1 进程的概念与作用
进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。进程提供给应用程序两个的关键抽象:一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用系统内存。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个命令解释器,它解释用户输入的命令并且把它们送到内核。Linux系统上的所有可执行文件都可以作为shell命令来执行。当用户提交了一个命令后,shell首先判断它是否为内置命令,如果是就通过shell内部的解释器将其解释为系统功能调用并转交给内核执行;若是外部命令或实用程序就试图在硬盘中查找该命令并将其调入内存,再将其解释为系统功能调用并转交给内核执行。
对于用户输入的一条命令,shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序。然后shell在搜索路径里寻找这些应用程序。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。基本的执行步骤为:
1. 读取用户由键盘输入的命令行。
2. 分析命令,以命令名作为文件名,并将其它参数改造为系统调用execve()内部处理所要求的形式
3. 终端进程调用fork()建立一个子进程。
4. 如果命令末尾有&,则终端进程不用执行系统调用wait4(),立即发提示符让用户输入下一条命令;否则终端进程会一直等待,当子进程完成工作后,向父进程报告,此时中断进程醒来,作必要的判别工作后,终端发出命令提示符,重复上述处理过程。
6.3 Hello的fork进程创建过程
以可执行文件hello的进程创建过程为例,当用户向命令行中输入./hello 2021113449 陈辰 1时,由于./hello不是系统内置命令,因此shell认为这是可执行文件,找到当前所在目录下的可执行文件hello,并执行它。
具体而言,shell通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
6.4 Hello的execve过程
函数execve()的功能是在当前进程的上下文中加载并运行一个新程序。在执行fork()得到子进程后随即使用解析后的命令行参数调用execve,启动加载器来执行hello程序。加载器执行的操作是,删除子进程现有的虚拟内存段,并创建新的代码、数据、堆和栈段。代码和数据段被初始化为hello的代码和数据。堆和栈被置空。然后加载器将PC指向hello程序的起始位置,即从下条指令开始执行hello程序。
6.5 Hello的进程执行
进程给我们一种程序独立使用处理器的假象。为了让多个进程在并行运发的时候还能维持这一假象,需要用到上下文切换。
操作系统内核使用上下文切换的较高层形式的异常控制流来实现多任务。上下文切换机制是建立在较低层异常机制之上的。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用上下文切换的机制来将控制转移到新的进程。上下文切换保存当前进程的上下文,恢复某个先前被抢占的进程被保存的上下文,将控制传递给这个新恢复的进程。
hello程序与操作系统其他进程通过操作系统的调度,切换上下文,拥有各自的时间片从而实现并发运行。
6.6 hello的异常与信号处理
异常是指为响应某个事件将控制权转移到操作系统内核中的情况。异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。异常的同步异步指的是异常的发生和程序的关系。异步异常是由处理器外部的I/O设备中的事件产生的。同步异常时执行了一条指令的直接产物。比如从键盘输入crtl+c作为异步异常与程序的执行没有关系。而缺页异常这样的同步异常是随着程序的执行产生的。
Linux信号是一种更高层的软件形式的异常,它允许进程和内核中断其他进程。一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件。
下面分析hello在执行过程中可能遇到的异常。
在hello运行时,若遇到键盘输入,如果按键过程中没有回车键,会把输入屏幕的字符串缓存起来;如果按键过程中有回车键,则当程序运行完成后,缓存区中的换行符前的字符串会被shell当作指令执行,如图28所示。
hello运行时,输入Ctrl-Z键会发送一个SIGTSTP信号给前台进程组的每一个进程,故hello进程停止,如图29所示。
此时,输入ps命令,shell显示当前进程的状态及其pid,如图30所示。
同样地,在hello停止时输入jobs命令,将显示Linux中的所有任务,如图31所示。
同样,若在hello停止时运行pstree命令,将所有进程以树状图显示,可以看到所有进程之间的父子关系。由图32可以看出,hello进程是shell(bash)创建的进程。
相同条件下,运行fg命令,将后台作业(在后台运行的或者在后台挂起的作业)放到前台终端运行。由于后台作业只有hello,于是hello被转到前台运行,继续循环输出字符串,如图33所示。
最后,在hello停止的条件下,运行kill命令:kill -9 <进程号>,将杀死进程,如图34所示。
此外,在程序运行过程中输入Ctrl+C,会让内核发送一个SIGINT信号给到前台进程组中的每个进程,终止前台进程,如图35所示。
6.7本章小结
本章介绍了有关进程管理的多个概念。描述了Shell的作用和处理流程,以及利用fork创建子进程、利用execve加载进程的方法。展示hello程序执行的具体过程,以及异常信号的处理机制。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是指在有地址变换功能的计算机中,访问指令给出的地址(操作数),也叫相对地址。是在汇编代码中通过偏移量加上段基址得到的地址,在内部和编程使用,并不唯一。在hello反汇编代码中我们能够看到的就是逻辑地址。
线性地址是指逻辑地址向物理地址转化过程中的一步。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址是指保护模式下程序访问存储器所用的逻辑地址。不能直接用来访存,需要通过MMU翻译得到物理地址来访存。在hello反汇编代码计算后就能得到虚拟地址。
物理地址是在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,又叫实际地址或绝对地址。CPU通过地址总线的寻址,找到真实的物理内存对应地址。在前端总线上传输的内存地址都是物理内存地址。在hello中通过翻译得到的物理地址来得到我们需要的数据。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址的表示形式为[段标识符:段内偏移量],这个表示形式包含完成逻辑地址到虚拟地址(线性地址)映射的信息。
段描述符就是保存在全局描述符表或者局部描述符表中,当某个段寄存器试图获取对应的段描述符时,会将获取到的段描述符放到自己的非编程寄存器中,这样就不用每次访问段都要跑到内存中的段描述符表中获取。
全局描述符表和局部描述符表保存的都是段描述符。
系统中每个CPU有属于自己的一个全局描述符表(GDT),其所在内存的基地址和其大小一起保存在CPU的GDTR寄存器中,大小为64K。
而对于局部描述符表,CPU设定是每个进程可以创建属于自己的局部描述符表(LDT),当前被使用的LDT的基地址和大小一起保存在LDTR寄存器中。不过大多数用户态的Linux程序都不使用局部描述符表,所以Linux内核只定义了一个缺省的LDT供大多数进程共享。描述这个局部描述符表的局部描述符表描述符保存在GDT中。
7.3 Hello的线性地址到物理地址的变换-页式管理
Linux采用了分页的方式来记录对应关系。所谓的分页,就是以更大尺寸的单位页来管理内存。在Linux中,通常每页大小为4KB。CPU中的一个控制寄存器,页表基址寄存器(PTBR)指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个n–p位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE。例如,VPN0选择PTE0,VPN1选择PTE1,以此类推。将页表条目中物理页号(PPN) 和虚拟地址中的VPO串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是P字节的,所以物理页面偏移(PPO)和VPO是相同的。页表的地址翻译流程如图36所示。
页命中时,CPU硬件执行的步骤如图37所示:
1. 处理器生成一个虚拟地址,并将其传送给MMU。
2. MMU 生成PTE地址发出请求。
3. 高速缓存/主存向MMU返回PTE。
4. MMU 将物理地址传送给高速缓存/主存;
5. 高速缓存/主存返回所请求的数据字给处理器。
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虛拟地址,MMU就必须查阅一个PTE,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会要求从内存多取一次数据,代价是几十到几百个周期。
如果PTE碰巧缓存在L1中,那么开销就下降到1个或2个周期。然而,许多系统都试图消除即使是这样的开销。它们在MMU中包括了一个关于PTE的小的缓存,称为后备缓冲器(TLB)。TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。
如图38所示,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。这样能够是单个页表在较小的占用小却实现对空间很大的虚拟内存的抽象。图39展示了多级页表的地址翻译过程。重复k次查找,即可得到VPN对应的PPN。
类似地,对于四级页表,4i位VPN,被划分成了4个i位的片,每个片被用作到一个页表的偏移量。PTBR寄存器存储L1页表的物理首地址。VPN1提供到一个L1 PTE的偏移量,这个PTE包含L2页表的基地址,VPN2提供一个到L2 PTE的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
结合L1 Cache与虚拟内存的存储系统如图所示。
对Cache的访问需要把一个物理地址分为标记、组索引、块偏移三个部分。检测物理地址是否L1 Cache命中。若命中,则直接将PA对应的数据内容取出返回给CPU;若不命中则在下一级中寻找,并重复L1 Cache中的操作。
通过MMU将虚拟地址转化成物理地址后,计算机就通过提取中的组索引在L1中搜索组,再通过标记位匹配。如果匹配成功且有效位是1,则将块偏移指向的块中的内容交还给CPU,否则未命中,需要从下一级Cache中在重复上述操作。当我们找到内容后需要将内容写回我们的L1中,如果L1中没有空闲块,即有效位为0的块则需要牺牲一块内容,我们通常采用LRU算法来进行这一过程。对L2、L3的访问也是这样。
7.6 hello进程fork时的内存映射
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
hello进程execve时的内存映射的流程如下:
1. 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2. 映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3. 映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4. 设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页是指虚拟内存中的字不在物理内存中(DRAM缓存不命中)。图41中展示了在缺页之前页表的状态。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。
假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
1. 判断虚拟地址A是否合法,即A是否在某个区域结构定义的区域内。缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start 和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。
2. 判断试图进行的内存访问是否合法。即进程是否有读、写或者执行这个区域内页面的权限。例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。
3. 如果不是上述两点,则内核知道这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。
7.9 动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆,向上生长,对于每个进程,内核维护着一个变量brk,它指向堆的顶部。分配器将堆视为一组不同大小的块的集合。每个块就是一个连续的虚拟内存页,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块保持空闲,直到它显式地被应用所分配。
一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。动态内存分配主要有两种基本方法与策略:带边界标签的隐式空闲链表,以及显示空间链表。
7.10 本章小结
在本章中整理了有关内存管理的知识,介绍了四种地址空间,以及intel环境下的段式管理和页式管理,同时以intel i7处理器为例,介绍了基于四级页表、三级Cache的虚拟地址空间到物理地址的转换,阐述了fork和exceve的内存映射,并介绍缺页故障和缺页中断管理机制。
结论
程序从源代码hello.c到可执行文件hello需要经历编译系统一系列处理的过程,包括预处理、编译、汇编和链接。首先,预处理器cpp修改源程序hello.c为hello.i,读取头文件内容并插入到程序文本中,替换宏定义并删除注释文本;编译器cc1将hello.i翻译为汇编语言程序hello.s;汇编阶段汇编器as将hello.s翻译成机器语言指令,打包成可重定位目标程序hello.o;最后链接器ld将所有可重定位目标程序合并,得到可执行目标文件hello。
在终端运行hello程序时,我们通过shell键入./hello 2021113449 陈辰 1的命令。其中./hello是让shell运行可执行文件hello,而后面的参数则是我们要通过命令行传递给hello的参数。shell接受到命令后会解析命令,当确定./hello非内置命令时会创建为hello创建一个子进程。此时内核还会为其分配一段虚拟内存,通过execve来执行hello函数。这样一个hello进程就被创建出来了。当hello进入函数入口开始运行时,虚拟内存会产生缺页异常,从而将hello程序所需要使用的信息交换到主存,并为其分配物理地址。当hello进程运行结束,最终被shell父进程回收,内核会收回为其创建的所有信息。至此,hello.c结束了“他”的一生。
参考文献
[1] Randal E. Bryant, David R O’Hallaron:《深入理解计算机系统》龚奕利,贺莲译,机械工业出版社.
[2] 知乎:c语言入门---预处理 https://zhuanlan.zhihu.com/p/559168506
[3] CSDN:Linux C :C的汇编码生成
https://blog.csdn.net/superSmart_Dong/article/details/115920429?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522168482731616782427428705%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fall.%2522%257D&request_id=168482731616782427428705&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~first_rank_ecpm_v1~rank_v31_ecpm-6-115920429-null-null.142^v87^control_2,239^v2^insert_chatgpt&utm_term=.cfi_def_cfa_offset&spm=1018.2226.3001.4187
[4] CSDN:arm64入栈出栈_X86-64和ARM64用户栈的结构 (3) ---_start到main https://blog.csdn.net/weixin_39593593/article/details/111519934
[5] CSDN:解析目标文件 https://blog.csdn.net/Al_xin/article/details/38613613
[6] CSDN:哈尔滨工业大学2022春计算机系统大作业-Hello的程序人生 https://blog.csdn.net/qq_56780378/article/details/124856668