HIT-ICS程序人生大作业

计算机系统大作业

摘要

hello是我们每个程序员都接触过的文件。也是很容易被忽视的文件。而该实验讲述hello.c经过预处理,编译,汇编,链接后生成可执行文件的过程。生成的可执行文件在运行过程中的具体操作:shell读取命令,fork创建子进程,execve调用hello。在运行hello过程中,异常信号的产生和处理等。hello在内存中的存储。再到hello结束,父进程对其回收操作。这一过程恰好体现了计算机系统的理念。
关键词: CSAPP,程序,编译,线程

第一章 概述

1.1 Hello简介

在文件夹内新建一个为翁当并命名为hello.c。
通过Editor将代码输入到hello.c中。
P2P过程:.c源文件先被cpp翻译为一个ASCⅡ码的中间文件hello.i,之后驱动程序运行c编译器cc1,将main,i翻译为一个ASCⅡ汇编语言文件main.s,然后驱动程序运行汇编器as,将main.s翻译为一个可重定位目标文件main.o,随后驱动程序运行链接器ld,将main.o与一些必要的系统目标文件组合起来,创建一个可执行目标程序,执行目标程序时shell为其fork出一个子进程。这就是P2P。
请添加图片描述

020过程:之后,shell通过execve加载hello,映射虚拟内存并载入物理内存,之后进入main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流,运行结束后,通过shell父进程回收hello进程,内核将相关数据结构删除。

1.2 环境与工具

硬件环境:Intel® Core™ i7-10750H CPU @ 2.60GHz,16G RAM,512G SSD
软件环境:Windows 10,VMware® Workstation 16 Pro,Ubuntu 20.04
开发与调试工具:vgcc, edb , gdb, VS code,objdump,readelf等

1.3 中间结果

文件作用
hello.i预处理得到的文本
hello.o编译得到的汇编文件
hello链接生成的可执行目标文件
hello.objdumphello的反汇编代码
hello.s编译器翻译所得的汇编语言文件
hello.txtHello.o的反汇编代码
Hello.readelfHello的elf编码
Hello1.readelfHello.o的elf编码

1.4 本章小结

本章介绍了hello程序的P2P与020过程,列出了大作业的所用工具,软硬件环境,以及各种中间文件的作用,为大作业的后续做准备。

第二章 预处理

2.1 预处理的概念与作用

概念:预处理器cpp根据#开头的include,define等命令,修改原始的c程序,将引用的库展开,并与原始的c文件连接在一起合并为一个新的文件。
作用
(1):删除“#define”并展开所定义的宏
(2):处理所有条件预编译指令,如“#if”,“#ifdef”, “#endif”等
(3):插入头文件到“#include”处,可以递归方式进行处理
(4):删除所有的注释“//”和“/* */”
(5):添加行号和文件名标识,以便编译时编译器产生调试用的行号信息

2.2 在Ubuntu下预处理的概念与作用

命令

cpp hello.c > hello.i

请添加图片描述
请添加图片描述

输入后会发现其中多了一个hello.i文件

2.3 Hello的预处理结果解析

请添加图片描述
请添加图片描述
使用文本编辑器打开hello.i可以看到hello.i共有3060行,且前面的头文件被展开,而main函数别放在了最后面。

2.4本章小结

本章主要介绍了预处理的概念以及作用,使用cpp的命令,以及预处理的效果

第三章 编译

3.1 编译的概念与作用

概念: 指的是把高级语言文本程序翻译成等价的汇编语言文本程序。编译器ccl把预处理后的文本文件.i文件进行一系列语法分析及优化后生成相应的汇编语言文件.s文件的过程。
作用: 将人编写的高级语言优化并翻译为更接近机器语言的汇编语言,方便后续过程的处理

3.2 在Ubuntu下编译的命令

命令

gcc -S hello.i -o hello.s

在这里插入图片描述
在这里插入图片描述
可以看到输入命令后hello.s生成了。

3.3 Hello的编译结果解析

3.3.1 数据

1.字符串
在这里插入图片描述
从hello.s文件中可以看到string类型数据,这是printf所用的“用法:Hello 学号 姓名 秒数!”其被存在了静态区,类似的还有“Hello %s %s\n”其中的汉字部分是以UTF-8编码的,每个汉字被编码为了三个字节。
2.整形数据
在这里插入图片描述
argc是传入main的参数,其被存到了%edi寄存器中,随后又被movl指令传输到了-20(%rbx)寄存器中
在这里插入图片描述
函数内部的局部变量i会被编译器存储在寄存器或者程序栈中,它没有标识符,也不需要被声明,而是直接使用。
3. 其余整数都是以$开头的立即数存在。

3.3.2 算数操作

1.赋值
在这里插入图片描述
图中为对函数循环体中的i赋值为0

2.算数操作
在这里插入图片描述
图中为进行的i++操作

3.3.3 关系操作

1.比较大小
在这里插入图片描述
将循环变量i与7进行比较,若小于7则继续循环

2.2.判断是否相等
在这里插入图片描述
判断argc是否等于4

3.3.4 数组

在这里插入图片描述
图中为*argv[]的各个操作,*argv[]为传入main的第二个参数

3.3.5 控制转移

在这里插入图片描述
图中为条件判断,如果i小于等于7,则跳转到.L4进行循环,否则的话顺序往下进行getchar等操作。
在这里插入图片描述

3.3.6 函数调用

函数通过跳转到特定代码执行待定函数之后再返回来实现功能。函数一般是在栈中实现的,函数调用可分为如下过程:
1,传递参数给被调用者
参数传递在64位栈结构中是通过寄存器和内存共同实现的,按照:%rdi,%rsi,%rdx,%rcx,%r8,%r9的顺序传递参数,从第七个参数开始放在调用者栈结构中。
2,call调用函数
call指令会将返回地址压入栈中,并且将rip的值指向所调用函数的地址,等函数执行完之后调用ret弹出原来的rip并且将栈帧结构恢复
3,函数进行操作
函数在自己栈帧内进行操作,返回值存入RAX寄存器。
4,函数返回
函数返回时,如果有返回值,则先将返回值存在%rax中,再用leave和ret等操作返回,控制权还给调用函数。
hello.c文件中调用的函数有:main()、printf()、exit()、sleep()、atoi()、getchar()。

3.4 本章小结

本章主要介绍了编译的概念与作用,同时通过对hello.s的解析了解了编译器是如何处理C语言的各个数据类型以及各类操作。为更好的理解计算机程序运行做基础。

第四章 汇编

4.1 汇编的概念与作用

汇编器(as)将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位 目标程序的格式,并将结果保存在.o目标文件中,.o是一个二进制文件,它包含程序的指令编码。

4.2 在Ubuntu下汇编的命令

as hello.s -o hello.o

在这里插入图片描述
在这里插入图片描述
可以看到输入命令后hello.o出现

4.3 可重定位目标ELF格式

1. ELF头
ELF头部以一个16字节的序列开始,描述生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器分析语法和解释目标文件的信息,其中包含ELF头大小、目标文件的类型、及其类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
在这里插入图片描述
2.ELF节头部表
包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。
在这里插入图片描述
3.重定位节
当汇编器生成一个目标模块是,它并不知道数据和代码最终将放在内存中的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行目标文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。
在这里插入图片描述
1)偏移量(offset):需要被修改的引用的节偏移;
**2)信息(info):**提供了符号表中的一个位置,同时还包括重定位类型的有个信息。这是通过将值划分为两部分来达到的。该值的低8位表示重定位入口的类型(type),高24位表示重定位入口的符号在符号表重的下标;
**3)类型(type):**告知连接器如何修改新的引用;
**4)符号名称(name):**重定位目标的名称;
**5)加数(addend):**一个有符号常数,计算重定位位置的辅助信息,共占8个字节。

而图中有两种最基本的重定位类型:
R_X86_64_PC32 : 重定位一个使用32位PC相对地址的引用。
R_X86_64_32 : 重定位一个使用32位PC绝对地址的引用。

5.符号表
symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
在这里插入图片描述

4,4 Hello.o的结果解析

在这里插入图片描述
在这里插入图片描述
输入objdump -d -r hello.o > hello.txt后结果如上两个图所示

经过对比后得到几个共同点
1.操作数
在这里插入图片描述
上图为,o反汇编代码里的
在这里插入图片描述
上图为.s文件中的

从中可以看出.o中为16进制数,.s为10进制数

2.分支跳转
在这里插入图片描述
在这里插入图片描述
上图为.o中的,下图为.s中的,从中看出跳转语句之后,hello.s中是.L2和.L3等段名称,而反汇编代码中跳转指令之后是相对偏移的地址。

3.函数操作
在这里插入图片描述
在这里插入图片描述
上图为.o中的,下图为.s中的

由于hello.c中调用的都是共享库中的函数,需要通过动态链接器确定地址。对于这些不确定地址的函数调用,在汇编成为机器语言的时候,将其call指令后的相对地址设置为0,然后再重定位节中添加重定位条目,等待确认。

4.5 本章小结

本章介绍了 hello 从 hello.s 到 hello.o 的汇编过程,通过使用GNU的readelf工具查看elf格式的hello.o和使用objdump得到的反汇编代码与hello.s进行对照,了解到汇编器在汇编语言映射到机器语言时所做的转换。

第五章 链接

5.1 链接的概念与作用

概念: 链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。

作用: 当程序调用函数库(如标准C库)中的一个函数printf,printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个函数必须通过链接器(ld)将这个文件合并到hello.o程序中,结果得到hello文件,它是一个可执行目标文件,可以被加载到内存中,由系统执行。另外,链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能

5.2 在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

在这里插入图片描述
可以看到在输入命令后,hello可执行文件生成。

5.3 可执行目标文件Hello的格式

输入readelf hello > hello.readelf 命令
5.3.1 ELF头
在这里插入图片描述
ELF头描述文件的总体格式。它还包括程序的入口点,即程序运行时要执行的第一条指令的地址。

5.3.2 节头
在这里插入图片描述
节头对hello中所有的节信息进行了声明,其中包括大小Size以及在程序中的偏移量Offset,因此根据节头中的信息我们就可以用HexEdit定位各个节所占的区间(起始位置,大小)。其中Address是程序被载入到虚拟地址的起始地址。

5.3.3程序头
在这里插入图片描述
(1)PHDR包含程序头表本身
(2)INTERP:只包含了一个section,在这个节中,包含了动态链接过程中所使用的解释器路径和名称。
(3)四个LOAD:第一个是代码段,第二个是数据段。在程序运行时需要映射到虚拟空间地址。
(4)DYNAMIC:保存了由动态链接器使用的信息。
(5)NOTE: 保存了辅助信息。
(6)GNU_STACK:堆栈段。
(7)GNU_RELRO:在重定位之后哪些内存区域需要设置只读。

5.3.4 段节
在这里插入图片描述

5.3.5 重定位节
在这里插入图片描述

5.4 Hello的虚拟地址空间

在这里插入图片描述
通过edb可以看出ELF被加载到了400000的位置

以.data段为例
在这里插入图片描述
可以看到其确实是在404048处开始的

5.5 链接的重定位过程分析

输入objdump -d -r hello > hello.objdump命令
在这里插入图片描述
在这里插入图片描述
与hello.o反汇编出的hello.readelf文件进行对比可知
1.多出了很多函数: main函数通过链接器将这些函数从共享库中能够提取出来,并且把它们加入可执行目标文件使其完整。
2.调用函数的方式: 因为链接器已经计算出了位置,所以call后面跟着所调用的函数的实际地址,而不是下一条指令的地址。
3.rodata 引用: 链接器解析重定条目时发现 重定位,由于.rodata与.text节之间的相对距离确定,因此链接器直接修改call之后的值为目标地址与下一条指令的地址之差,指向相应的字符串。算法如下:
refptr = s + r.offset; refaddr = ADDR(s) + r.offset;
*refptr = (unsigned) (ADDR(r.symbol) + r.addend-refaddr)

5.6 Hello的执行流程

程序名:
ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
libc-2.27.so!__libc_start_main
-libc-2.27.so!__cxa_atexit
-libc-2.27.so!__libc_csu_init
hello!_init
libc-2.27.so!_setjmp
-libc-2.27.so!_sigsetjmp
–libc-2.27.so!__sigjmp_save
hello!main
hello!puts@plt
hello!exit@plt
*hello!printf@plt
*hello!sleep@plt
*hello!getchar@plt
ld-2.27.so!_dl_runtime_resolve_xsave
-ld-2.27.so!_dl_fixup
–ld-2.27.so!_dl_lookup_symbol_x
libc-2.27.so!exit

5.7 Hello的动态链接分析

对于动态共享链接库中 PIC 函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表 PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向 PLT中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。
在这里插入图片描述
在这里插入图片描述

5.8 本章小结

本章中主要介绍了链接的概念与作用、hello的ELF格式,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。

第6章 hello进程管理

6.1 进程的概念与作用

概念: 进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,包括文本区域、数据区域和堆栈,分别用来存储处理器执行的代码,存储变量和进程执行期间使用的动态分配的内存,存储区活动过程中调用的指令和本地变量。
作用: 使我们感到正在运行的程序是系统当前运行的唯一程序,处理器无间断地执行着我们当前程序中的指令,且内存中只存放了当前程序中的代码和数据。

6.2 简述壳Shell-bash的作用与处理流程

shell是一个应用程序,他在操作系统中提供了一个用户与系统内核进行交互的界面。他的处理过程一般是这样的:
1)从终端读入输入的命令。
2)将输入字符串切分获得所有的参数
3)如果是内置命令则立即执行
4)否则调用相应的程序为其分配子进程并运行
5)shell应该接受键盘输入信号,并对这些信号进行相应处理

6.3 Hello的fork进程创建过程

输入./hello之后,调用fork函数创建一个新的运行的子进程,新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,这就意味着,当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程与子进程之间最大的区别在于它们拥有不同的 PID。
在这里插入图片描述

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序。
在这里插入图片描述

6.5 Hello的进程执行

基本概念:
(1)逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
(2)时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
(3)用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
(4)上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。

下面看hello sleep进程调度的过程:
当调用sleep之前,如果hello程序不被抢占则顺序执行,假如发生被抢占的情况,则进行上下文切换,上下文切换是由内核中调度器完成的,当内核调度新的进程运行后,它就会抢占当前进程,并进行如下操作:
(1)保存以前进程的上下文
(2)恢复新恢复进程被保存的上下文
(3)将控制传递给这个新恢复的进程 ,来完成上下文切换。在这里插入图片描述
详情见上图,hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时(2.5secs)发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。

6.6 hello的异常与信号处理

在这里插入图片描述
执行的各种情况:
1.正常执行
在这里插入图片描述
若不乱按的话可以看到按下回车执行完getchar 后程序正常结束
2.乱按
在这里插入图片描述
结果是程序运行情况和前面的相同,不同之处在于shell将我们刚刚乱输入的字符除了第一个回车按下之前的字符当做getchar的输入之外,其余都当做新的shell命令,在hello进程结束被回收之后,将会在命令行中尝试解释这些命令。中间没有任何对于进程产生影响的信号被产生。
3.按下Ctrl-z
将会发送一个SIGTSTP信号给shell。然后shell将转发给当前执行的前台进程组,使hello进程挂起。
在这里插入图片描述
上图为按下Ctrl-z后的结果,在之后可以按下其他指令
1)输入ps
在这里插入图片描述
可以看到hello进程依然存在
2)输入jobs
在这里插入图片描述
3)输入pstree
在这里插入图片描述
在这里插入图片描述

4)输入fg
在这里插入图片描述
4.按下Ctrl-z
在这里插入图片描述
可以看到进程列表中已经没有了hello进程

6.7本章小结

本章简述了进程管理的一些简要信息,比如进程的概念作用,shell的基本原理,shell如何调用fork和execve我们的hello进程,我们的hello进程在执行时会遇到什么样的情况(包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令的处理),它对这些情况如何做出反应。又介绍了一些常见异常和其信号处理方法。显然面对执行时的多样的复杂的环境,我们的hello已经不能完全像预处理,编译汇编那样以自己为中心的生活了,它需要更多的关于信号的处理的知识。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址: 程序代码经过编译后出现在汇编程序中地址。逻辑地址由选择符(在实模式下是描述符,在保护模式下是用来选择描述符的选择符)和偏移量(偏 移部分)组成。
线性地址: 逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合 形式。分页机制中线性地址作为输入。
虚拟地址: 虚拟地址在这里跟线性地址相同,即程序访问存储器所使用的逻辑地址。
物理地址: CPU 通过地址总线的寻址,找到真实的物理内存对应地址。 CPU 对内存的访问是通过连接着 CPU 和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。

7.2 Intel逻辑地址到线性地址的变换-段式管理

一个逻辑地址由两部份组成,段标识符、段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如下图:
在这里插入图片描述
索引号,是“段描述符(segment descriptor)”,段描述符具体地址描述了一个段。这样,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,由8个字节组成,如下图:
在这里插入图片描述
Base字段:它描述了一个段的开始位置的线性地址。 Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。
当段选择符中的T1字段=0,表示用GDT;若为1,表示用LDT。 GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。再看下图要直观些:
在这里插入图片描述
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。 还是挺简单的,对于软件来讲,原则上就需要把硬件转换所需的信息准备好,就可以让硬件来完成这个转换了。

7.3 Hello的线性地址到物理地址的变换-页式管理

页式管理: 虚拟地址->物理地址
首先Linux系统有自己的虚拟内存系统,其虚拟内存组织形式如下图,Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。
在这里插入图片描述
而物理内存被划分为一小块一小块,每块被称为帧(Frame)。分配内存时,帧是分配时的最小单位,最少也要给一帧。在虚拟内存中,与帧对应的概念就是页(Page)。
线性地址的表示方式是:前部分是虚拟页号后部分是虚拟页偏移。
CPU通过将逻辑地址转换为虚拟地址来访问主存,这个虚拟地址在访问主存前必须先转换成适当的物理地址。CPU芯片上叫做内存管理单元(MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址。然后CPU会通过这个物理地址来访问物理内存。
页表就是一个页表条目(PTE)数组,虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个PTE。PTE是由一个有效位和一个n个字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。
MMU利用虚拟页号(VPN)来在虚拟页表中选择合适的PTE,当找到合适的PTE之后,PTE中的物理页号(PPN)和虚拟页偏移量(VPO)就会组合形成物理地址。其中VPO与PPO相同,因为虚拟页大小和物理页大小相同,所需要的偏移量位数也就相同。此时,物理地址就通过物理页号先找到对应的物理页,然后再根据物理页偏移找到具体的字节:
1.如果有效位是 0+NULL 则代表没有在虚拟内存空间中分配该内存;
2.如果是有效位 0+非 NULL,则代表在虚拟内存空间中分配了但是没 有被缓存到物理内存中;
3.如果有效位是 1 则代表该内存已经缓存在了物理内存中,可以得到其 物理页号 PPN,与虚拟页偏移量共同构成物理地址 PA。
在这里插入图片描述

7.4 TLB与四级页表支持下的VA到PA的变换

每次cpu产生一个虚拟地址,MMU需要查询一个PTE,如果运气不好,需要从内存中取得,这需要花费很多时间,通过TLB(翻译后备缓冲器)能够消除这些开销。TLB是一个小的,虚拟寻址的缓存,在MMU里,其每一行都保存着一个单个PTE组成的块,TLB通常具有高度相联度用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果是32位系统,我们有一个32位地址空间,4KB的页面和一个4字节的PTE,我们总需要一个4MB的页表驻留在内存中,而对于64位系统,我们甚至需要8PB的空间来存放页表,这显然是不现实的。
用来压缩页表的常见方式就是使用层次结构的页表。如果是二级页表,第一级页表的每个PTE负责一个4MB的块,每个块由1024个连续的页面组成。二级页表每一个PTE负责一个4KB的虚拟地址页面。这样的好处在于,如果一级页表中有一个PTE是空,那么二级页表就不会存在,这样会有巨大的潜在节约,因为4GB的地址空间大部分都是未分配的。现在的64位计算机采用4级页表,36位的VPN被封为4个9位的片,每个片被用作一个页面的偏移,CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1PET的偏移量,这个PTE包含L2页表的基地址,VPN2提供一个到L2PTE的偏移量,以此类推。

7.5 三级Cache支持下的物理内存访问

在这里插入图片描述
先在第一级缓存中寻找要找的数据,根据CI在cache中找到对应组,根据CT找到匹配的标志位,若改行的有效位为1,则命中,根据CO找到数据块传输,流程完毕;若找不到对应tag或者标志位0,则发生了不命中,则在第二级缓存中寻找,找到后需要再将其缓存在第一级,若有空闲块,则放置在空闲块中,否则根据替换策略选择牺牲块;若在二级缓存中未能找到,则在第三级缓存中寻找,找到后需要缓存在第一,二级,空闲块的处理和替换策略同上;若在第三级缓存中未能找到,则在第四级缓存中寻找,找到后需要缓存在第一,二,三级,空闲块的处理和替换策略同上。

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制,当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当两个进程中的任一个后来进行写操作时,写时复制就会创建新页面。因次,也就为每个进程保持了私有地址空间的概念。

7.7 hello进程execve时的内存映射

execve函数在hello中加载并运行包含在可执行目标文件hello中的程序,加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域。为新的代码,数据,bss和栈区域创建新的区域结构。所有这些新的区域都是私有的,写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。下图概括了私有区域的不同映射。
3.映射共享区域。如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器PC。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

7.8 缺页故障与缺页中断处理

当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生缺页故障。这时候会将控制传递给处理程序,故障处理程序会选择要么重新执行当前指令,或者终止。
缺页中断会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,来替换为该虚拟地址所对应的物理页。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件,从而使其可以正常进行翻译。

7.9动态存储分配管理

一.动态内存分配器维护着一个进程的虚拟内存区域,称为堆。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应程序使用。空闲块可用来分配。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
1.显式分配器,要求应用显式地释放任何已分配的块。
2.隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。

二.malloc函数
malloc函数返回一个指针,指向大小至少为size字节的内存块,这个块可能包含在这个块内的任何数据对象类型做对齐。
1.隐式空闲链表
任何实际的分配器都需要一些数据结构,允许它来区别块边界。大多数分配器将这些信息嵌入块本身,如下图:
在这里插入图片描述
假设块的格式如上图所示,我们可以将堆组织为一个连续的已分配块和空闲块的序列,如下图:
在这里插入图片描述
称这种结构为隐式空闲链表。
2.放置已分配的块
分配器执行这种搜索的方式是由放置策略确定的。一些常见的策略是首次适配,下一次适配和最佳适配。
(1)首次适配从头开始搜索空闲链表,选择第一个合适的空闲块
(2)下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。
(3)最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
3.分割空闲块
一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间。一个选择是用整个空闲块。虽然这种方式简单而快捷,但是主要的缺点就是会产生内部碎片。
4.获取额外的堆内存
如果分配器不能为请求块找到合适的空闲块,一个选择是合并。另一个选择就是调用sbrk函数,向内核请求额外的堆内存。
5.合并空闲块
当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫做假碎片。为了解决这个问题,任何实际的分配器都必须合并空闲块这个过程称为合并。
下面介绍一下带边界标记的合并:
在这里插入图片描述
如上图,在每个块的结尾处添加一个脚部, 其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断当前一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。
6.显示空闲链表
一种更好的方式是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred和succ指针.使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。
一种方法是后进先出的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于按照地址排序二点首次适配比LIFO排序的首次适有更高的内存利用率,接近最佳适配的利用率。

7.10本章小结

本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,VA到PA的变换、物理内存访问,ello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化: 文件
设备管理: unix io接口

8.2 简述Unix IO接口及其函数

Unix I/O 接口统一操作:
1) 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想 要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在 后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文 件的所有信息。
2) Shell 创建的每个进程都有三个打开的文件:标准输入,标准输出,标 准错误。
3) 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位 置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用 程序能够通过执行 seek,显式地将改变当前文件位置 k。
4) 读写文件:一个读操作就是从文件复制 n>0 个字节到内存,从当前文 件位置 k 开始,然后将 k 增加到 k+n,给定一个大小为 m 字节的而文 件,当 k>=m 时,触发 EOF。类似一个写操作就是从内存中复制 n>0 个字节到一个文件,从当前文件位置 k 开始,然后更新 k。
5) 关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢 复到可用的描述符池中去。
Unix I/O 函数:
1) int open(char* filename,int flags,mode_t mode) ,进程通过调用 open 函 数来打开一个存在的文件或是创建一个新文件的。 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 表示一个错误,0 表示 EOF,否则返回值表示的是实际传送的字节数量。
4) ssize_t wirte(int fd,const void *buf,size_t n),write 函数从内存位置 buf 复制至多 n 个字节到描述符为 fd 的当前文件位置。

8.3 printf的实现分析

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的代码:
int vsprintf(char *buf, const char fmt, va_list args)
{
char
p;
char tmp[256];
va_list p_next_arg = args;

for (p=buf;*fmt;fmt++) { 
if (*fmt != '%') {  //忽略无关字符 
*p++ = *fmt; 
continue; 
} 

fmt++; 

switch (*fmt) { 
case 'x': //只处理%x一种情况 
itoa(tmp, *((int*)p_next_arg)); //将输入参数值转化为字符串保存在tmp
strcpy(p, tmp); //将tmp字符串复制到p处 
p_next_arg += 4; //下一个参数值地址 
p += strlen(tmp);  //放下一个参数值的地址 
break; 
case 's': 
break; 
default: 
break; 
} 
} 

return (p - buf); //返回最后生成的字符串的长度

}
vsprintf 程序按照格式 fmt 结合参数 args 生成字符串,并返回字串的长度。
通过观察可以发现,printf函数在执行过程中调用了系统函数write(buf,i)将长度为i的buf输出到屏幕上。write函数如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在 write 函数中,将栈中参数放入寄存器,ecx 是字符个数,ebx 存放第一个 字符地址,int INT_VECTOR_SYS_CALLA 代表通过系统调用 syscall,查看 syscall 的实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
此函数将字符串中的字节从寄存器中通过总线复制到显卡的显存中,存储的是字符的ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。于是我们的打印字符串就显示在了屏幕上。

8.4 getchar的实现分析

异步异常-键盘中断的处理: 键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数 ,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数。

结论

一个简单的hello程序背后所蕴含的知识包括了计算机系统的方方面面,本文较为详细的介绍了hello的坎坷一生:从诞生开始由I/O设备编写为hello.c,存储在计算机磁盘中,它将以hello.c的身份开始此次旅途。
最开始,hello要经过一系列的编译:首先,在预处理器中,hello.c经过预处理,与所有的外部库合体成为了hello.i;再在编译器中经过编译,成为了hello.s;之后,汇编器又将hello.s转换为可重定位的目标文件hello.o;最后,连接器会把hello.o进行链接,于是,可执行的目标程序hello就诞生了!
紧接着运行程序,当我们在shell中输入“./hello”时,bash会新建一个进程,先fork一个子进程,然后清空当前进程的数据并加载hello,从函数的入口进入,开始执行,由于各种各样的原因,我们的hello可能会暂时的休息(系统调用或者计时器中断),这时我们保留当前进度,并切换上下文,内核去处理别的进程,提高效率。我们还可以输入信号来终止或挂起hello进程,hello输出信息时需要调用printf和getchar,而printf和getchar的实现需要调用Unix I/O中的write和read函数,而它们的实现需要借助系统调用I/O,在最后结束之后bash等到exit,作为hello的父进程回收hello。随后,内核删除他所有的数据,hello的此次旅途也就到达终点。它仍在磁盘中等待下一次旅途的开始。

计算机可以说非常有用,但只有真正的了解它,你才能真正的利用好它。前方还有很长的路要走,但是我不会退缩,我要尽力掌握计算机的奥秘。

附件

名字作用
Hello.i预处理后所得的中间文件
Hello.s编译器翻译所得的汇编语言文件
Hello.o汇编器翻译所得的可重定位目标文件
Hello链接器创建的可执行目标文件
Hello.objdumphello的反汇编代码
Hello.txtHello.o的反汇编代码
Hello.readelfHello的elf编码
Hello.readelfHello.o的elf编码

参考文献

[1] Bryant,R.E. 《深入理解计算机系统 》. 北京:机械工业出版社
[2] https://chinese.freecodecamp.org/news/command-line-for-beginners/
[3]https://blog.csdn.net/u010732356/article/details/53606832
[4] https://www.cnblogs.com/pianist/p/3315801.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值