计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能领域方向(2+X)
学 号 2021112522
班 级 21WL021
学 生 郭家合
指 导 教 师 史先俊
计算机科学与技术学院
2023年4月
本文介绍了一个c语言程序“hello”是如何一步步地从.c文件开始,历经历经预处理、编译、汇编、链接的一系列步骤变成可执行文件,再经过shell的fork和execve变成一个进程,这就是hello的P2P(Program2Process)的过程。除此之外,本文还讨论了hello程序在运行时涉及到的进程管理,内存管理,io管理等过程。从可执行文件hello被执行前,内存中没有它的存在,到最后运行结束被shell回收进程,抹去它的痕迹,这就是hello的020过程。本文通过详细介绍这两个过程,解释linux中程序的运行原理和过程,加深我们对整个计算机系统的理解。
关键词:CSAPP;内存管理;编译;链接 ;汇编
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
P2P是指程序到进程(Program to Process),020是指程序从0开始,运行结束后又被删除回到0。
P2P:
程序从一个.c文件开始,首先经过预处理器cpp,进行预处理得到.i文件(执行宏替换等操作),之后用编译器ccl进行编译,得到.s文件(汇编语言),之后用汇编器as生成.o文件(可重定位文件),之后用链接器ld将它与它用到的库链接,得到可执行文件hello。我们在shell里执行命令,shell会fork一个子进程,调用execve,并映射虚拟内存,这就是hello.c这个program运行时得到的process
在子进程中调用execve函数装载hello程序,将相关可执行文件中的段映射入虚拟内存。当该进程的时间片到达时,操作系统设置CPU上下文环境,并跳到程序开始处。程序执行完毕,shell回收这个子进程,完成hello相关内存的释放。hello本不存在于内存上,这个让hello映射到虚拟内存,并在最后被shell回收再次消失在内存上的过程就是hello的020过程。
1.2 环境与工具
硬件环境:Intel 11th Gen core i5-11400H @2.70GHz 16.0G RAM;512GHD Disk
软件环境:Windows10 64位 VMware Workstation16.0 Ubuntu 22.04.4 LTS 64位
开发工具:CodeBlocks 64位,vim,edb,gedit,gcc
1.3 中间结果
hello.i:C预处理器产生的一个ASCII码的中间文件,用于分析预处理过程。
hello.s:C编译器产生的一个ASCII汇编语言文件,用于分析编译的过程。
hello.o:汇编器产生的可重定位目标程序,用于分析汇编的过程。
hello:链接器产生的可执行目标文件,用于分析链接的过程。
hello.txt:hello.o的反汇编文件,用于分析可重定位目标文件hello.o。
hello2.txt:hello的反汇编文件,用于分析可执行目标文件hello。
hello.elf:hello.o的ELF格式,用于分析可重定位目标文件hello.o。
hello2.elf:hello的ELF格式,用于分析可执行目标文件hello
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理是指,预处理器cpp根据以字符#开头的命令修改原始的C 程序,将引用的所有库展开,得到一个完全展开的,完整的文本文件。
作用:预处理可以
1.文件包含:将#include引用的文件插入文本
2.宏替换:用#define和#undef添加和删除宏
3.条件编译:有选择地编译代码,实现版本控制等功能,如#if,#elif,#endif
4.编译器指令:用#pragma添加,如之前优化实验里的O2优化
5.#error,当遇到标准错误时,输出错误信息
2.2在Ubuntu下预处理的命令
命令为gcc -m64 -no-pie -fno-PIC hello.c -E -o hello.i 或 cpp hello.c > hello.i
图 2.2.1 预处理命令
2.3 Hello的预处理结果解析
图2.3.1 hello.i开头部分
图2.3.2 hello.i结尾部分
打开hello.i,看到文件试可读的文本文件,共3091行,查看文件头部代码,发现有加载.c文件中include的头文件;查看文件尾部代码,发现与.c文件中的main函数部分相同
2.4 本章小结
本章介绍了预处理的概念和作用,以hello.c程序为例,演示了预处理的命令,并简单地对得到的.i文件进行解析。得到.i文件,是下一步编译的基础。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译概念:编译是指编译器把预处理之后生成的.i文件翻译成.s文件,其中.s文件里是汇编语言程序
编译作用:编译器会检查代码的语法,看是否有错误。检查无误后将代码转变成汇编语言。在这个过程中还会对代码进行分析,对代码进行多种变换,实现速度和占用空间上的优化(如GCC的O2模式)。
3.2 在Ubuntu下编译的命令
gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s
图3.2.1 编译命令
3.3 Hello的编译结果解析
1.数据常量
图3.3.1 hello.s立即数记录方式
立即数采用$+对应数字的方式记录
图3.3.2 hello.s字符串记录方式
字符串会被保存在数据段里,.LC0标号代表了对源代码中一个字符串常量的引用
2.数据变量
图3.3.3 hello.s数据变量记录方式
局部变量存在栈上,当用到局部变量时,会通过改变栈指针的指向位置,在堆栈上开辟出一块空间,储存局部变量的值。
3.赋值
图3.3.4 hello.s赋值方式
在循环开始时,将变量i赋值为0
4.算术操作
图3.3.5 hello.s算术操作
每次循环结束对给i加1,对应i++操作
5.关系操作
图3.3.6 hello.s关系操作“<”
用cmp来实现比较大小的关系符号”<”,但是代码实际上是把i<5改成i<=4实现的。
图3.3.7 hello.s关系操作“!=”
同理,实现判断是否相同的关系符号”!=”
6.控制转移
已在关系操作中说明,即用关系操作完成判断后进行跳转
7.数组操作
图3.3.7 hello.s数组操作
数组操作需要数组的首地址,以及具体的索引(地址偏移量)。比如这里把首地址(%rbp-32)赋给rax,之后的+8和+16代表了argv[1]和argv[2]两个数。
8.函数调用,参数传递,返回值
图3.3.8 hello.s函数操作调用printf
将.LC1中的字符串作为第一个参数,将数组操作中提到的两个数作为第二个参数,调用printf函数。
图3.3.9 hello.s函数操作调用atoi和sleep
这里是先把argv[3]作为第一个参数,调用atoi函数,调用结果会返回到%rax中,把这个返回值赋给%edx,再作为第一个参数去调用sleep函数。
9.循环操作
图3.3.10 hello.s循环操作
其中i=0在赋值操作中已有说明,i<5在关系操作中有说明,i++在算术操作中有说明,通过判断,自增和跳转,实现循环操作。
3.4 本章小结
本章介绍了在编译阶段,编译器是如何处理程序和数据的,帮助我们理解程序执行的过程以及c语言和汇编语言的转化。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编是指将汇编程序翻译为机器语言指令,然后以可重定位目标程序的格式存成.o文件。
汇编的作用:把机器不能直接识别的汇编语言翻译成机器语言,生成.o文件为程序的链接做准备
4.2 在Ubuntu下汇编的命令
命令为 gcc -m64 -no-pie -fno-PIC hello.s -c -o hello.o
图4.2.1 汇编命令
4.3 可重定位目标elf格式
用命令readelf -a hello.o > hello.elf得到elf格式文本文件
图4.3.1 查看elf命令
ELF头部分说明了文件的一些信息,这些信息描述了文件的结构,类型,架构等。比如Magic是ELF 文件的魔数,用于标识文件格式,说明这是一个ELF文件;类别表示 ELF 文件类型;数据表示数据编码方式,比如这里是小端序;还说明了当前的操作系统架构等信息(OS/ABI和系统架构)
图4.3.2 ELF头
查看ELF文件结构,除了ELF头部分外,其余部分都存放着各自的信息,节头说明了有哪些节。比如.text存放已经编译的程序的机器语言,.rodata存放只读数据,.data存放已初始化的全局变量和局部静态变量,.bss存放未初始化的全局变量和局部静态变量等。
图4.3.3 ELF节头 图4.3.4 .o文件结构
当汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data。 重定位条目包含以下几个部分,一是需要修改的引用的节偏移量,而是需要修改引用应该指向的符号值,类型表明如何修改新引用,还有可能的偏移量
图4.3.5 重定位条目
符号表是目标文件中非常重要的一部分,它为链接器和加载器提供了符号的信息和引用关系,并保证程序可以正确地运行。
图4.3.6 符号表
4.4 Hello.o的结果解析
用命令objdump -d -r hello.o得到hello.o反汇编,与hello.s对照分析
图4.4.1 hello.s
图4.4.2 hello.o的反汇编结果第一部分
图4.4.3 hello.o的反汇编结果第二部分
可以看到二者内容基本一致,但是反汇编的结果左边有对应的原机器语言,且操作数中的立即数是十六进制,而.s文件中是10进制。此外,在调用函数时,.s文件显示函数名,而反汇编显示函数的地址;在跳转时,.s文件显示定义的符号,而反汇编显示地址(以上两个地址均为相对地址,写成main+偏移的形式)。
4.5 本章小结
本章节先介绍汇编操作的概念和作用,再以hello.s为例进行汇编的实践。接着使用readelf工具得到elf文件,用于查看hello.o文件的ELF头,节头表,可重定位信息和符号表等。最后将其与hello.s比较,说明机器语言与汇编语言的一一对应关系。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,得到一个可执行文件。
链接作用:链接器在软件开发中起关键作用,因为它可以实现分离编译的功能。对于一个大型文件,我们不用把它用到的所有模块全都放在一起形成一个巨大的源文件,而是可以把它分为一个个更小的的模块,使用时通过链接器联系在一起即可。如果要修改某一部分,不用全都重新编译,只要重新编译修改的那个模块就行。
5.2 在Ubuntu下链接的命令
ld -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 /usr/lib/gcc/x86_64-linux-gnu/11/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/11/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
图5.2.1 链接命令
5.3 可执行目标文件hello的格式
用readelf -a hello > hello2.elf得到ELF文件,为与之前用hello.o文件得到的区分,我们将它命名为hello2.elf。
ELF头
图5.3.1 ELF头
节头(包含各节大小,地址等信息)
图5.3.2 节头第一部分
图5.3.3 节头第二部分
程序头
用于描述可执行文件或共享库在内存中的布局信息。包含节的类型,段标志(是否可读写,是否可执行等),还包含文件偏移和虚拟地址,以及节的大小,还要节在文件和内存中的对齐方式。
图5.3.4 程序头
重定位节
包含.text节中需要对目标进行重定位的函数的相关信息。
图5.3.5 重定位节
5.4 hello的虚拟地址空间
edb --run hello
使用edb加载hello
图5.4.1 hello的反汇编(edb)
查看其虚拟地址空间
图5.4.2 hello的虚拟地址空间
可以发现它的虚拟地址空间范围为0x400000到0x405000
查看上一节中的节头,可以找到一些节的位置
比如.rodata在0040200处
图5.4.3 .rodata
比如.text在004010f0处
图5.4.4 .text
比如.data在00404048处
图5.4.5 .data
5.5 链接的重定位过程分析
objdump -d -r hello得到hello的反汇编
图5.5.1 hello的反汇编
对照之前对hello.o得到的反汇编,有以下几个不同点
1.hello.o中存的是相对于main的相对地址,而hello中是从0040000开始的绝对地址(虚拟地址),其中main位于004011d6处。
2.除了有main之外,hello的程序中增加了很多外部函数(链接而来),如getchar,printf等,程序中还增加了.init(初始化),.plt(动态链接)等部分。
分析:
综上,链接就是将多个可重定位目标文件合并到一起,生成可执行文件。链接需要进行符号解析、重定位及计算符号引用地址三个步骤。重定位将合并输入模块。并为每个符号分配运行地址。重定位由两个步骤组成:重定位节与符号定义、重定位节中的符号引用。
5.6 hello的执行流程
1.ld-2.31.so!_dl_start 0x7f8586bea770
2.ld-2.31.so!_dl_init 0x7f8586bea9a0
3.hello!_start 0x4010f0
4.libc-2.31.so!_libc_start_main 0x7f634a96ce60
5.hello!printf@plt 0x401040
6.hello!sleep@plt 0x401080
7.hello!getchar@plt 0x401050
8.libc-2.31.so!exit 0x7f634a7540d0
5.7 Hello的动态链接分析
在hello2.elf的节头里找到动态链接调用函数的部分的位置
图5.7.1 节头里动态链接调用函数的部分
图5.7.2 init之前
图5.7.3 init之后
如图所示,init前后,数据内容发生变化。
在进行动态链接前,先进行静态链接,生成部分链接的可执行目标文件hello。此时共享库中的代码和数据还在hello外面。是在加载hello时,动态链接器才对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,才能生成完全链接的可执行目标文件。动态链接只有在调用函数时才进行符号的映射(延迟加载)。系统使用偏移量表GOT+过程链接表PLT实现函数的动态链接。GOT中存放函数目标地址,为每个全局函数创建一个副本函数,并将对函数的调用转换成对副本函数调用。
5.8 本章小结
本章分析了hello程序的链接过程,hello.o经过链接生成可执行文件hello,通过对比二者反汇编结果,加深了对链接和重定位的认识。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程概念:进程是指计算机中已运行的程序, 是系统进行资源分配和调度的基本单位, 是操作系统结构的基础。
进程作用:给用户两种假象,一种是让我们感觉我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存;另一种是让我们感觉处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个用户与操作系统内核交互的命令行解释器。它的主要作用是接收来自用户的命令,并将它们转换为操作系统内核可以理解的指令,然后在操作系统上执行这些指令。Bash是一种常见的壳程序,它是一个在Linux和Unix系统上广泛使用的命令行解释器。
流程如下:先通过shell读用户的键盘输入,得到用户输入的命令行。分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。现在查看这个argv向量的首个元素,看它是不是shell内置的命令。我们的程序不属于内置命令,因此会调用fork来新建一个子进程,在子进程中,用argv的其他参数,调用execve执行指定程序。如果最后一个参数是&,进程会后台运行,当前shell还可以干别的,否则就要等进程执行完毕。如果第一个参数是nohup且最后一个参数是&,那么关了shell程序也会后台执行,否则进程会在shell关闭的时候被终止。
6.3 Hello的fork进程创建过程
Shell调用fork创建子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库还有用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,因此fork后子进程可以读写父进程中打开的任意文件。父进程和创建的子进程最大的区别在于PID不同。所以父进程和子进程是并发运行的独立进程,内核是可以以任意方式交替执行他们的逻辑控制流指令的(fork之后)。
6.4 Hello的execve过程
execve函数会在当前进程的上下文中加载并运行一个新程序。execve函数加载并运行可执行目标文件hello,且带参数列表数组argv和环境变量envp,用一组新的代码、数据、堆和栈段覆盖当前的,设置PC 指向_start 的地址,调用main函数,并将控制传递给新程序的主函数。只有当出现错误时,execve才会返回到调用程序,否则不会返回。与fork不同,fork一次调用两次返回,execve一次调用从不返回(不报错)。
6.5 Hello的进程执行
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,还包括环境变量和打开的文件操作符。
进程时间片:多个逻辑控制流并发执行时,其中一个进程执行它的控制流的一部分的一个时间段叫做一个时间片,到了这个进程的时间片就切换到这个进程。
进程调度:当内核选择一个新的进程运行时,称内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。先是保存当前进程的上下文,让一会切回当前进程的时候工作进度不会丢失;之后恢复某个先前被抢占的进程被保存的上下文,它就是当前内核所调度的进程;最后将控制传递给这个新恢复的进程。
用户态和核心态:用户态和核心态是操作系统中的两种运行模式,它们的主要区别在于进程所能访问的资源和运行级别不同。在用户态中,进程只能访问有限的资源,例如进程自身的地址空间、打开的文件、用户数据等,而不能访问操作系统内核的资源。在用户态中运行的进程是受操作系统保护的,它们只能通过系统调用向操作系统内核请求服务。在核心态中,进程可以访问所有的系统资源,包括内存、IO设备、中断处理等,进程可以直接执行特权指令和访问内核空间的数据结构。在核心态中运行的进程具有更高的优先级和更广泛的控制权,它们可以对系统进行更深入的控制和管理。
我们的hello程序初始是在用户模式中的,进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当hello执行过程中异常发生时,如键盘按下ctrl-c,控制传递到异常处理程序,处理器将用户模式变为内核模式。处理器运行在内核模式中,当他返回到用户代码时,处理器就把模式从内核模式改回用户模式,以上。
6.6 hello的异常与信号处理
乱按
图6.6.1 乱摁的结果
如图所示,乱按的内容会被放进缓存区,并在当前shell执行完hello程序后一起输入,输了回车的地方,就会被认为是一条指令。
Ctrl-Z之后接各种命令
图6.6.2 Ctrl-Z的结果
接ps(用于列出当前系统后台进程),可以看到其实hello程序还是在的。
图6.6.3 Ctrl-Z后接ps的结果
接jobs(列出当前shell中正在运行或暂停的任务)
图6.6.4 Ctrl-Z后接jobs的结果
接pstree(以树状结构显示进程间的关系,可以清晰地显示出当前系统中所有进程的层级关系,包括父子进程和兄弟进程)
图6.6.5 Ctrl-Z后接pstree的结果第一部分
图6.6.6 Ctrl-Z后接pstree的结果第二部分
图6.6.7 Ctrl-Z后接pstree的结果第三部分
接fg(将一个在后台运行的任务调到前台运行,并且将其设置为当前任务,即使它正在运行或者处于暂停状态。如果同时有多个任务在后台运行,则需要指定要调到前台的任务编号或者 ID)发现我们的hello程序被重新调度到前台,并正常运行。
图6.6.8 Ctrl-Z后接fg的结果
接kill
图6.6.9 Ctrl-Z后接kill的结果
Kill了之后用fg恢复,会提示进程已终止(与停止不同)
图6.6.10 Ctrl-Z后接kill再接fg的结果
Ctrl-C
与Ctrl-Z不同,用Ctrl-C结束程序,无论是ps还是jobs,都无法找到hello。使用fg恢复,也找不到可以恢复的任务,也就是程序被终止。
图6.6.9 Ctrl-C后接各种命令的结果
综上,Ctrl-Z会给程序一个挂起的信号,Ctrl-C会给程序一个终止的信号。挂起的信号可以用fg恢复,程序收到fg的信号后会读取上下文并继续运行。Ctrl-C和kill都会直接停止程序,且无法恢复。
6.7本章小结
本章节我们主要学习了程序时如何从一个可执行文件变成一个运行着的进程的,介绍了shell的工作原理,以及fork和execve这两个重要机制。到这里我们已经完成了一开始提到的Program到Process的过程。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址是指由程序产生的与段相关的偏移地址部分。例如,在进行C语言指针编程中,可以使用&操作读取指针变量的值,这个值就是逻辑地址,是相对于当前进程数据段的地址。一个逻辑地址由两部份组成:段标识符和段内偏移量。
线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址生成了一个线性地址。如果启用了页式管理,那么线性地址可以再变换产生物理地址。若没有启用页式管理,那么线性地址直接就是物理地址。
虚拟地址:因为虚拟内存空间的概念与逻辑地址类似,因此虚拟地址和逻辑地址实际上是一样的,都与实际物理内存容量无关。
物理地址:存储器中的每一个字节单元都给以一个唯一的存储器地址,用来正确地存放或取得信息,这个存储器地址称为物理地址,又叫实际地址或绝对地址
2 Intel逻辑地址到线性地址的变换-段式管理
核心是段表
段式管理中,线性地址等同于虚拟地址,程序从逻辑地址直接转换为虚拟地址。
段式存储管理按照段为单位分配内存,每段分配一个连续的内存区域,但各段之间不一定要求连续。类似于动态分区分配,内存的分配和回收是动态进行的。由于段式存储管理系统中作业的地址空间是二维的,因此地址结构包括段号和段内位移两个部分。
在段式管理地址变换的过程中,进程需要建立一个段表,其中包含段号、段长、存储权限、状态、起始地址、修改位和增补位等信息。
段的共享和保护机制:在段式系统中,分段的共享是通过两个作业的段表中相应的表目都指向被共享部分的同一个物理副本来实现的。由于段是一个完整的逻辑信息,因此可以进行共享。相反,页不完整,难以实现共享。纯过程或可重入过程是指不能被修改的过程,这样的过程和不能修改的数据可以进行共享,而可修改的程序和数据则不可共享。
从逻辑地址到线性地址的变换过程为:给出一个完整的逻辑地址[段选择符:段内偏移地址]。首先看段选择符判断当前转换时GDT中的段还是LDT中的段,再根据相应寄存器得到其地址和大小。之后拿出段选择符中的前13位,在对应地址中查找到对应的段描述符,这样就知道了基址。根据基址和偏移量结合,就得到了所求的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
核心是
通过分页机制,可以实现线性地址(虚拟地址VA)到物理地址(PA)之间的转换。该机制将虚拟内存空间分为大小固定的页,每一页映射到物理内存中的一页,这样就可以将虚拟地址映射到物理地址。
在Linux系统中,虚拟内存被组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。当一个进程运行时,内核会为该进程维护一个段的任务结构(task_struct),其中包含了该进程的页表等信息。
虚拟页是指系统将每个段分为大小固定的块,Linux下一页的大小通常为4KB。物理页和虚拟页的大小相同,它们是物理内存的分割,MMU使用页表来实现虚拟页和物理页之间的映射。
当程序访问虚拟地址时,CPU会将该地址发送到MMU进行转换。MMU查找页表,将虚拟页的地址转换为物理页的地址,然后将物理地址发送给内存进行访问。如果虚拟页没有被映射到物理内存中,就会发生缺页异常,操作系统会将物理页从磁盘中加载到内存中,并更新页表以便下次访问时可以直接访问物理页。这样,程序就可以使用虚拟地址访问物理内存,而不必关心实际的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB:在MMU中包括一个关于PTE的缓存,称为翻译后备缓冲器(TLB)。TLB是一个小的、虚拟寻址的缓存,每一行保存着一个由单个PTE组成的块。由于VA到PA的转换过程中,需要使用VPN确定相应的页表条目,因此TLB需要通过VPN来寻找PTE。和其他缓存一样,需要进行组索引和行匹配。如果TLB有2t个组,那么TLB的索引TLBI由VPN的t个最低位组成,TLB标记TLBT由VPN中剩余的位组成。
TLB的作用原理与主存和cache基本一致,都是利用程序的局部性原理。当MMU进行地址翻译时,会先将VPN传给TLB,看TLB中是否已经缓存了需要的PTE,如果TLB命中,可以直接从TLB中获取PTE,将PTE中的物理页号和虚拟地址中的VPO连接起来就得到相应的物理地址。如果不命中,那和cache一样,到PTE中去取
图7.4.1 TLB示意图
图7.4.2 TLB命中机制
四级页表的支持:传统的页表结构将整个虚拟地址空间划分成固定大小的页,每一页映射到物理内存的一段连续地址空间。然而,当虚拟地址空间非常大时,这种结构需要大量的页表项来描述整个虚拟地址空间,导致页表非常庞大,占用大量内存,并且在访问虚拟内存时需要多次访存,降低了访问效率。相反,多级页表将整个虚拟地址空间划分成多个层级,每个层级都有一个页表。每个页表项指向下一级页表的起始地址,最后一级页表中的每一项指向物理内存中对应的页面。这种结构大大减少了页表的大小,因为每一级页表只需要存储少量的页表项,同时访问虚拟内存时只需要访问少量的页表项,提高了访问效率。
7.5 三级Cache支持下的物理内存访问
图7.5.1 三级cache与内存
MMU完成了虚拟地址到物理地址的转换之后,便可以使用物理地址来进行内存访问。现代cpu大多采用了三级缓存来加速物理内存的访问,其中L1级缓存作为L2级缓存的缓存,L2级缓存作为L3级缓存的缓存,而L3级缓存则作为内存(DRAM)的缓存。
如果L1级缓存未命中,则会继续向L2级缓存发送数据请求。同样需要进行组索引、行匹配和字选择,将数据传输给L1级缓存。如果L2级缓存未命中,则会继续向L3级缓存发送数据请求。最后,如果L3级缓存也未命中,则只能从内存中请求数据。三级缓存不仅支持数据指令的访问,也支持页表条目的访问。在MMU进行虚拟地址到物理地址的翻译过程中,三级缓存也会起到作用。
7.6 hello进程fork时的内存映射
当一个进程调用fork函数时,内核会为新进程创建各种数据结构并为它分配一个唯一的PID。为了为这个新进程创建虚拟内存空间,内核会创建新的mm_struct结构体、区域结构和页表的副本。
为了保证子进程和父进程之间的内存数据独立性,内核会将子进程和父进程中的每个页面标记为只读,并将每个区域结构标记为私有写时复制。这样,当新进程或父进程之一进行写操作时,写时复制机制会创建新的页面,从而保证了每个进程都有自己独立的地址空间。
7.7 hello进程execve时的内存映射
execve()函数是一个系统调用,用于在当前进程上下文中加载并运行指定的可执行程序。该函数的实现大致分为以下几个步骤:
一是删除已存在的用户区域,目的是删除当前进程虚拟地址空间中已存在的区域结构,给新程序留出位置。这些区域包括代码区、数据区、堆栈等。
二是映射私有区域。为新程序的代码、数据、bss和栈等区域创建新的区域结构。这些区域是私有的、写时复制的,其中代码和数据区域被映射到可执行文件中的.text和.data区,bss区域映射到匿名文件,其大小包含在可执行文件中,栈和堆区域也是请求二进制零的,初始长度为零。
三是映射共享区域。如果新程序与共享对象或库链接,那么这些共享对象将动态链接到新程序中,并映射到用户虚拟地址空间中的共享区域内。
最后设置程序计数器。最后设置当前进程上下文中的程序计数器,使之指向代码区域的入口点,开始执行新程序。
总体来说就是用新的代码数据栈覆盖当前的,并改变程序计数器。
7.8 缺页故障与缺页中断处理
缺页故障是指在访问虚拟内存中的某个页面时,该页面并未在物理内存中,因此需要将该页面从磁盘中读取到物理内存中。这个过程需要操作系统的支持(内核态),操作系统会在发生缺页故障时,通过中断来处理。
当CPU访问一个虚拟地址时,如果对应的页面不在物理内存中,那么CPU会产生一个缺页故障中断,通知操作系统需要将该页面从磁盘中加载到内存中。操作系统会根据虚拟地址找到对应的页面,然后将该页面加载到物理内存中,并且将虚拟地址与物理地址进行映射,最后重新执行该指令。
保存现场:当CPU产生中断时,操作系统会保存当前进程的状态和上下文信息,以便后续处理完成后能够恢复现场。
查找页面:操作系统会根据缺页故障中的虚拟地址,查找对应的页面是否在物理内存中。如果在物理内存中,则直接重新执行该指令,否则需要将该页面从磁盘中读取到物理内存中。
选择牺牲页:如果物理内存中没有空闲的页面可供使用,操作系统需要选择一个牺牲页来替换成要加载的页面。常见的页面置换算法包括FIFO,LRU等。
加载页面:将要加载的页面从磁盘中读取到物理内存中,并将其与虚拟地址进行映射。
恢复现场:操作系统将保存的进程状态和上下文信息恢复回来,并让该进程继续执行。
7.9动态存储分配管理
动态存储分配管理是计算机中的一种内存管理技术,其目的是为程序运行时动态分配所需的内存空间,并且在不需要该空间时能够及时地释放掉,以便其它程序可以继续使用该空间。
动态存储分配管理一般分为两个部分:动态内存分配和动态内存释放。
动态内存分配是指在程序运行时,根据需要动态地分配所需的内存空间。常见的动态内存分配方式有两种:
堆内存分配:通过调用系统提供的动态内存分配函数(如C语言中的malloc函数),在堆区中分配所需大小的内存空间。堆内存分配方式具有灵活性高、分配速度快的优点,但需要手动管理内存,容易造成内存泄漏等问题。
栈内存分配:栈内存分配是指在函数调用时,在栈区中分配所需大小的内存空间。栈内存分配方式具有方便快捷、自动管理内存的优点,但是由于栈区空间有限,所以无法满足大量内存需求。
动态内存释放是指在不需要某个内存空间时,将该空间及时释放,以便其它程序继续使用。常见的动态内存释放方式有两种:
手动释放:通过调用系统提供的动态内存释放函数(如C语言中的free函数),手动释放先前分配的内存空间。手动释放的方式需要程序员手动管理内存,释放操作需要及时和准确,否则容易造成内存泄漏等问题。
自动释放:自动内存释放是指使用一些自动内存管理技术,如智能指针、垃圾回收等,在不需要某个内存空间时自动释放。自动释放的方式省去了手动管理内存的烦恼,但是由于需要消耗一定的系统资源,因此需要权衡资源开销和程序性能等方面的考虑。
总之,动态存储分配管理为程序提供了更加灵活的内存管理方式,但也需要程序员注意内存分配和释放的问题,以免出现内存泄漏等严重后果。
7.10本章小结
本章总结了hello运行过程中有关内存管理的内容。简述了在TLB、多级页表支持下的地址翻译的过程,在三级的cache支持下的内存访问,访存时缺页的处理过程,fork+execve过程的内存映射以及动态存储分配和回收的过程。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
在linux中,一切皆文件
一个Linux文件就是一个m个字节的序列:B0,B1 ,B2……Bm-1
所有的 IO 设备(例如网络、磁盘和终端)都被模型化为文件,所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux内核引出一个简单、低级的应用接口,称为 Unix I/O,使得所有的输入和输出都能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix IO 接口,使得所有的输入和输出都能以一种统一且一致的方式来执行,它有以下几个功能:
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备
Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。
改变当前文件的位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0
读写文件。一个读操作就是从文件复制n > 0个字符到内存,从当前文件位置k开始,然后k += n。对给定一个大小为m字节的文件,当k>=m时执行读操作会出发一个称为EOF的条件。
关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。
Unix I/O 函数:
进程通过调用open函数打开一个存在的文件或者创建一个新文件。
int open(char* filename,int flags,mode_t mode);
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件;mode参数指定了新文件的访问权限位。
int close(fd),fd是需要关闭的文件的描述符(C中表现为指针),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的实现分析
参考https://www.cnblogs.com/pianist/p/3315801.html
查看printf的实现代码:
int printf(const char *fmt, ...) {
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt)+4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
arg得到第一个参数的起始地址,调用vsprintf生成显示信息
1. intvsprintf(char *buf, const char *fmt, va_list args)
2. {
3. char* p;
4. chartmp[256];
5. va_list p_next_arg = args;
6.
7. for(p=buf;*fmt;fmt++) {
8. if(*fmt != '%') {
9. *p++= *fmt;
10. continue;
11. }
12.
13. fmt++;
14.
15. switch (*fmt) {
16. case'x':
17. itoa(tmp, *((int*)p_next_arg));
18. strcpy(p, tmp);
19. p_next_arg += 4;
20. p +=strlen(tmp);
21. break;
22. case's':
23. break;
24. default:
25. break;
26. }
27. }
28.
29. return (p - buf);
30. }
vsprintf函数:作用为格式化,接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化输出,并返回字符串的长度。
接下来到write系统函数。
1. write:
2. mov eax, _NR_write
3. mov ebx, [esp + 4]
4. mov ecx, [esp + 8]
5. int INT_VECTOR_SYS_CALL
这里会给寄存器传递几个参数,以一个int结束,int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。接下来是到陷阱-系统调用 syscall。
1. sys_call:
2. call save
3. push dword [p_proc_ready]
4. sti
5. push ecx //打印出的元素个数
6. push ebx //要打印的buf字符数组中的第一个元素
7. call [sys_call_table + eax * 4] //不断打印字符,直到遇到’\0’
8. add esp, 4 * 3
9.
10. mov [esi + EAXREG - P_STACKBASE], eax
11.
12. cli
13. ret
当字符显示驱动子程序,从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)后,显示芯片按照显示器的刷新率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),把画面显示在显示器上。
8.4 getchar的实现分析
查看getchar的源代码:
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
bb=buf;
}
return (--n>=0)?(unsigned char)*bb++:EOF;
}
可以看到getchar的底层实现是通过系统函数read实现的。调用getchar时,会等待用户输入,输入回车后,输入的字符会存放在缓冲区中。第一次调用getchar时,需要从键盘输入,但如果输入了多个字符,之后的getchar会直接从缓冲区中读取字符。getchar返回读入的第一个字符,若读入失败(读到EOF)则返回-1。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求。中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码并转换成 ASCII 码,保存到系统的键盘缓冲区之中。程序执行完之后键盘缓冲区中的内容会被输入到shell里,这个在之前第六章已经试验过了。
8.5本章小结
本章介绍了linux下I/O设备的管理方法,由于linux一切皆文件的特性,将所有设备映射为文件,用读写的方式控制I/O。允许linux内核引出一个简单、低级的应用接口——Unix I/O,且分析了printf函数和getchar函数这两个基于Unix I/O中的实现函数的封装函数的实现方式。
(第8章1分)
结论
Hello:
1.用c语言编写hello程序,保存为.c文件
2.预处理,主要是处理include(展开用到的外部库)和define,保存为.i文件
3.编译,把.i文件编译成汇编语言,保存成.s文件
4.汇编,把.s文件变为可重定位目标文件,保存为.o
5.链接,把各种代码和数据片段收集并组合成为一个单一文件,得到可执行文件hello
6.运行,在shell中运行可执行文件
7.fork,shell调用fork,创建一个子进程
8.execve,shell调用execve,execve 调用启动加载器,加映射虚拟内存(覆盖之前),进入程序入口后程序开始载入物理内存,然后进入 main 函数。
9.程序的运行过程,如果运行途中键入 ctr-c ctr-z 则调用 shell 的信号处理函数分别停止、挂起,挂起的程序可用fg指令恢复
10.资源回收:shell 父进程回收子进程,内核删除为这个进程创建的所有数据结构。
Hello的一生短暂而辉煌。在用户看来,它只是被一句输入到shell里的命令,短暂地占据了shell,输出几句简单的句子,之后就被shell带走销声匿迹,它的一生无疑是短暂的。但是在每个CS学习者眼里,它的一生精彩而辉煌,它从诞生开始,就被OS和硬件高高举起,一路保驾护航,它走的每一步上都是历代计算机研究者们的严密论证和多次实践的结果,每一步都凝聚着前人的智慧。我喜欢计算机科学,因为它是由人类创造的科学,如果我有个想法,可以很快编写程序去验证,一个人一台电脑,就有可能改变世界。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello.i:C预处理器产生的一个ASCII码的中间文件,用于分析预处理过程。
hello.s:C编译器产生的一个ASCII汇编语言文件,用于分析编译的过程。
hello.o:汇编器产生的可重定位目标程序,用于分析汇编的过程。
hello:链接器产生的可执行目标文件,用于分析链接的过程。
hello.txt:hello.o的反汇编文件,用于分析可重定位目标文件hello.o。
hello2.txt:hello的反汇编文件,用于分析可执行目标文件hello。
hello.elf:hello.o的ELF格式,用于分析可重定位目标文件hello.o。
hello2.elf:hello的ELF格式,用于分析可执行目标文件hello
图 附件 各个中间文件
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
(参考文献0分,缺失 -1分)
[1] RANDALE.BRYANT, DAVIDR.O‘HALLARON. 深入理解计算机系统[M]. 机械工业出版社, 2011.
[2] https://www.cnblogs.com/clover-toeic/p/3851102.html
[3] https://www.runoob.com/linux/linux-comm-pstree.html
[4] https://www.runoob.com/cprogramming/c-function-vsprintf.html
[5] https://www.cnblogs.com/pianist/p/3315801.html
[6] https://www.cnblogs.com/jiqing9006/p/8268233.html
[7] CMU的csapp课程,bilibili网站提供翻译