计算机系统
大作业
计算机科学与技术学院
2022年5月
本文以hello程序的生命周期为线索,对hello的预处理、编译、汇编和链接等各个阶段的实现过程进行分析。当hello成为可执行文件后,本文将继续从hello的进程管理、存储管理、IO管理等方面进行实践与探讨。通过对hello一生的追踪,体会计算机系统多方面的分工与配合,进一步深入地理解计算机系统。
关键词:hello;预处理;编译;汇编;链接;进程;存储;IO;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
P2P(Program to Process)过程:
在linux环境下,hello的 P2P的过程指的是hello从源程序(program)到一个被执行的进程(process)。即hello的源程序hello.c经过cpp的预处理得到hello.i文件、经过cc1编译生成hello.s的汇编文件、经过as的处理便为可重定位目标文件hello.o、最后由ld链接生成可执行文件hello。之后用户通过shell键入./hello命令开始执行程序,shell通过fork函数创建一个子进程,再由子进程执行execve函数加载hello。
020(Zero-0 to Zero-0)过程:
020过程指的是再execve执行hello程序后,内核为hello进程映射虚拟内存。在hello进入程序入口后,hello相关的数据就被内核加载到物理内存中,hello程序开始正式被执行。为了让hello正常执行,内核还需要为hello分配时间片、逻辑控制流。最后,当hello运行结束,终止成为僵尸进程后,由shell负责回收hello进程,删除与hello有关的数据内容。
1.2 环境与工具
硬件环境:AMD Ryzen 5 4600U with Radeon Graphics、2.10GHz、16G RAM
软件环境:Windows10 64位、Vmware、Ubuntu 18.04
开发与调试工具:Codeblocks、gcc、Objdump、edb、readelf
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 文件作用 |
hello.c | 程序的源代码 |
Hello.i | hello.c文预处理后的文本文件 |
Hello.s | hello.i文件编译后的汇编文件 |
Hello.o | hello.s汇编后的可重定位目标文件 |
hello | ld得到可执行目标文件 |
helloelf.txt | ELF格式下的hello.o |
1.4 本章小结
本章简述Hello的P2P,020的整个过程,同时介绍实验相关的软硬件环境与开发工具。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
2.1.1 概念
预处理是指cpp预处理器根据include命令修改C源程序并生成.i目标文件的过程。
2.1.2 作用
1、宏定义
在预处理阶段用定义的实际数值将宏替换
2、文件包含
把多个源文件连接成一个源文件进行编译,结果将生成一个目标文件。
这主要是实现头文件引用,将头文件的内容复制到源程序中。
3、条件编译。
条件编译允许只编译源程序中满足条件的程序段,使生成的目标程序较短,从而减少了内存的开销并提高了程序的效率。
2.2在Ubuntu下预处理的命令
预处理命令:gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
经过预处理,生成了hello.i
因为.c头文件中的内容被复制到了.i文件中,hello.i的文本量要比hello.c大很多。
另外,.c文件中的注释也被删除了。
2.4 本章小结
第二章的内容是与预处理阶段相关的知识,其中包括了预处理指令概念和作用,并在Ubuntu下执行预处理命令生成.i文件,并解析.i文件。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
编译是用编译器(ccl)将预处理文件hello.i转成汇编文本文件(hello.s)
3.1.2 编译的作用
1、能够检测代码的正确性
2、进行词法分析和语法分析,并对程序进行一定的优化
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1 数据
(1)常量:
查看.c文件内容,可知printf函数中的字符串是常量,在.s文件中分别是.LC0,.LC1后面的。
(2)变量:
有3个变量:i、argc、argv,均为局部变量。
- 其中argc存储于%edi中,通过rbp的相对偏移来访问。
- i存储于栈中,通过rbp的相对偏移来访问。
- argv存储于%rsi中,通过rbp的相对偏移来访问。
3.3.2 赋值
对计数器变量i的赋值,循环开始时赋初值0,
还有每次循环结束后,i加一
3.3.3 类型转换
在for循环体内部,使用了atoi函数将字符串显式转换为int类型。
3.3.4 算数操作
算数操作只有一个加法 i++
.s文件中是
3.3.5 关系操作
- C语言中 “argc != 4" 在.s文件中的操作是
- C语言中 "i < 8" 在.s文件中的操作是
3.3.6 数组操作
数组操作只涉及到了对argv数组的引用
3.3.7 控制转移
- 条件判断在.s文件中的操作是
- 循环语句在.s文件中的操作是
首先,赋初值
然后开始循环
3.3.8 函数操作
1、调用printf
第一个printf的参数是字符串(开头的.LC0)
第二个printf的参数有3个,是字符串(开头的.LC1)和数组中的两个数(通 过%rax赋值给%rsi和%rdx)
2、调用exit,将1放在%edi中作为参数传递
3、调用sleep,一个参数(将%eax赋值给%edi)
4、调用stoi,一个参数(argv[3])
5、调用getchar
另外,函数一般都通过ret指令返回,返回去往往要通过leave函数等方式进 行堆栈平衡,返回值一般都存放在rax中,如图:
3.4 本章小结
本章.i文件被编译为汇编代码。另外,分析了高级语言中各个操作是如何在汇编程序中进行的。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
4.1.1 概念
汇编的概念是指的将汇编语言程序翻译成机器指令,并将这些指令打包成一种叫做可重定位目标程序,并将这个结果保留.o目标文件中。
4.1.2 作用
将汇编指令转换成一条条机器可以直接读取分析的机器指令,实现了文本文件到二进制文件的转化。
4.2 在Ubuntu下汇编的命令
汇编指令:gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
首先使用命令readelf -a hello.o > helloelf.txt
4.3.1 ELF头
ELF头中的Magic魔数是一个定值,在程序执行时会检查魔数是否正确,如果不正确则拒绝加载。
ELF头告诉了我们文件的基本信息:小端序排列,大小为64字节,文件是可重定位的类型,机器为X86-64,节头部表的文件偏移为0,节头数量为14,字符串表索引节头为13。
4.3.2节头
节头描述了所有节的基本信息,而他们本身的意义如下:
节名 | 意义 |
.text节 | 已编译程序的机器代码 |
.rela.text节 | 一个.text节中的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置 |
.data节 | 已初始化的静态和全局C变量。类型为PROGBITS,意为程序数据,旗标为WA,即权限为可分配可写 |
.bss节 | 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。类型为NOBITS,意为暂时没有存储空间,旗标为WA,即权限为可分配可写 |
.rodata节 | 存放只读数据,例如printf中的格式串和开关语句中的跳转表。类型为PROGBITS,意为程序数据,旗标为A,即权限为可分配 |
.comment节 | 包含版本控制信息 |
.note.GNU_stack节 | 用来标记executable stack(可执行堆栈) |
.eh_frame节 | 处理异常 |
.rela.eh_frame节 | .eh_frame的重定位信息 |
.shstrtab节 | 该区域包含节区名称 |
.symtab节 | 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息 |
.strtab节 | 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部的节名字 |
4.3.3 .rela.text节和.rela.eh_frame节
图中信息介绍了两条重定位节的一些信息,这里同时涉及到了重定位的绝对引用与相对引用,还需要了解绝对引用、相对引用的重定位算法。
绝对引用重定位算法:
refaddr = ADDR(s) + r.offset;
*refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr);
相对引用重定位算法:
*refptr = (unsigned) (ADDR(r.symbol) + r.addend);
4.3.4 符号表
.symtab:存放程序中定义和引用的函数和全局变量的信息。一共包含了11个符号。
一些符号解释:
Ndx:
ABS表示不该被重定位、UND表示未定义(在这个地方被引用,但是在其他地方进行定义)、COM表示未初始化数据(未初始化的全局变量)
Bind:
绑定属性:全局符号、局部符号
Type:
符号类型:函数、数据、源文件、节、未知
4.4 Hello.o的结果解析
objdump -d -r hello.o
机器语言:是二进制机器指令的集合,是纯粹的二进制数据表示的语言,是电脑可以真正识别的语言。机器指令由操作码和操作数构成。
机器语言与汇编语言的映射关系:每一条汇编语言操作码都可以用机器二进制数据来表示,进而可以将所有的汇编语言(操作码和操作数)和二进制机器语言建立一一映射的关系。
不同之处 | ||
方面\对象 | 汇编语言 | 机器语言的反汇编程序 |
分支转移 | 分支转移命令是由助记符来标识,通过用符号.L2等助记符,跳转到相应的位置 | 分支转移命令是直接跳转入目的地址(以主函数地址为基址的一个偏移地址) |
函数调用 | 函数调用之后直接跟着函数名称 | call的目标地址是下一条指令的地址 |
申请栈时 | 十进制 | 16进制 |
4.5 本章小结
本章主要了解了.s文件到.o文件的转换,通过readelf查看hello.o的ELF、反汇编的方式查看hello.o反汇编的内容,比较其与hello.s之间的差别,充分理解这一章的内容能够帮助我们理解链接的过程。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1 概念
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接执行符号解析、重定位过程。
5.1.2 作用
把可重定位目标文件和命令行参数作为输入,产生一个完全链接的,可以加载运行的可执行目标文件。使得分离编译成为可能。
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
5.3 可执行目标文件hello的格式
5.3.1 ELF头
通过查询hello文件的ELF头信息,我们可以知道hello文件类型为EXEC,即是一个可执行目标文件,文件中共有27个节。
5.3.2 节头表
通过readelf -S hello 我们可以知道文件中各个段的基本信息
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
根据节头表的信息,我们可以知道ELF是从0x400000开始的。
.text节是从0x4010f0开始的,和ELF头中程序的入口一致
在edb反汇编代码部分查看这部分内容,确实可以看到这里是函数的入口
.rodata段地址从0x402000开始,偏移量为0x2000
5.5 链接的重定位过程分析
objdump -d -r hello
5.5.1 hello与hello.o的不同,
1,我们发现hello.asm比hello.o的反汇编代码多出了许多文件节。比如.init节和.plt节(hello.o反汇编代码只有.text节)
2,hello反汇编的代码有确定的虚拟地址,也就是说已经完成了重定位,而hello.o反汇编代码中代码的虚拟地址均为0,未完成可重定位的过程
5.5.2 链接的过程
1、符号解析
目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即C语言中任何以static属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
2、重定位
编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与--个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。
5.5.3 hello中的重定位
1、重定位节和符号定义。
图 1 hello.o重定位节
在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输人模块定义的每个节,以及赋给输人模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
2、重定位节中的符号引用。
在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
这个地址是跳转到:
然后程序终止
5.7 Hello的动态链接分析
对于动态共享链接库中PIC函数,编译器没办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表(PLT)+全局偏移量表(GOT)实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
通过上面截图我们可以找到.GOT.PLT的数据从0x404000开始
这是未调用init前的GOT.PLT表。
这是调用init后的GOT.PLT表。
经过初始化后,PLT和GOT表就可以协调工作,一同延迟解析库函数的地址了。
5.8 本章小结
本章介绍了程序链接已经加载动态库的过程。这是程序生成可执行文件的最后一步,也是将大型程序项目分解成小模块的关键所在。本章通过可执行文件的
程序头来分析重定位的过程,并解析了一个程序运行的全过程。最后简单介绍了动态链接这一现代计算机中极为重要的部分是怎么运作的
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 概念
进程的经典定义是一个执行中程序的实例,系统的每个程序都运行在某个进程的上下文。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存里的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。
6.1.2 作用
进程主要为用户提供了下列两个假象
(1)一个独立的逻辑流,提供程序独占使用处理器的假象。
(2)一个私有的虚拟地址空间,提供程序独占使用整个系统内存的假象。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 作用
- 作为命令处理器,接受用户输入的命令,然后根据命令进行相关操作,比如调用相关的程序。
- 作为命令语言,它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令。
- 作为程序设计语言,它定义了各种变量和参数,并提供了许多在高级语言中才具有的控制结构,包括循环和分支。
6.2.2 处理流程
1、从终端读入用户输入的命令
2、对命令解析,并判断其是否为内置命令
若是内置命令,则直接执行
若不是,则调用execve函数创建子进程运行
3、再判断是否为前台运行程序,
若是,则调用等待函数,等待前台程序结束。
否则程序转入后台,接受用户下一步输入的命令
4、Shell接受键盘输入的信号,并且应该对信号产生相应的反应
5、回收僵死进程
6.3 Hello的fork进程创建过程
输入命令执行hello后,如果不是内部指令,即会通过fork函数创建子进程。子进程与父进程近似,并得到一份与父进程用户级虚拟空间相同且独立的副本——包括数据段、代码、共享库、堆和用户栈。父进程打开的文件,子进程也可读写。二者之间最大的不同或许在于PID的不同。Fork函数只会被调用一次,但会返回两次,在父进程中,fork返回子进程的PID,在子进程中,fork返回0.
6.4 Hello的execve过程
上一步成功创建了一个子进程之后,子进程调用execve()在当前进程的上下文中加载并运行一个新程序。execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到hello,execve才会返回到调用程序。
execve的处理过程大概如下:
1、删除当前进程已经存在的用户区域;
2、映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。如下图所示。
3、映射共享区域,如果hello要与共享对象进行动态链接,那么共享对象需要映射到用户虚拟地址空间中的共享区域;
4、设置程序计数器PC。设置hello进程上下文中的程序计数器,使之指向代码区的入口点。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
1、上下文:
上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。
2、进程时间片:
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
多任务也叫做多时间片。
3、调度:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被强占的进程。这种决策就叫调度(是由内核中的调度器的代码处理的)。
4、用户模式和内核模式:
处理器使用一个寄存器提供两种模式的区分。用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据;内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
5、用户态核心态转换:
以hello为例,hello程序在调用了sleep程序后会陷入内核状态,内核可能会进行上下文切换。到程序运行到getchar的时候,内核也会进行上下文切换,让其他进程运行。除了这些,系统还会为hello程序分配时间片,即使没有执行到getchar或者sleep函数,只要hello时间片被用完,系统就会判断当前程序以及执行够久了,从而进行上下文切换,将处理器让给其他进程。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1 hello程序出现的异常与信号
hello程序出现的异常
1、中断
在hello程序执行的过程中可能会出现外部I/O设备引起的异常。
处理过程:
2、陷阱:
陷阱是有意的异常,是执行一条指令的结果,hello执行sleep函数的时候会出现这个异常。
处理过程:
3、故障:
在执行hello程序的时候,可能会发生缺页故障。
处理过程:
4、终止:
终止时不可恢复的错误,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。
处理过程:
可能产生的信号有:SIGINT、SIGKILL、SIGSEGV、SIALARM、SIGCHLD
6.6.2 各命令及运行结果截屏
1、按下回车
输入回车后程序的行为和乱按几乎是一致的,只是回车没有被识别为无效指令而是被无视。
2、按下Ctrl-Z,并运行ps
输入ctrl-z,内核会发送SIGSTP。SIGSTP默认挂起前台hello作业,但 hello进程并没有回收,而是运行在后台下,通过ps指令可以对其进行查看。
3、按下Ctrl-C
在键盘上输入Ctrl+c会导致内核发送一个SIGINT信号到前台进程组的每个进程,默认情况是终止前台作业。
4、Ctrl-z后运行jobs
通过jobs命令我们可以看到所有在执行的命令。
5、Ctrl-z后运行pstree
通过pstree我们可以看到所有进程之间的父子关系,可以看到我们的hello进程是shell(bash)创建的进程。
6、Ctrl-z后运行fg
通过执行fg命令我们可以让暂停的进程重新开始工作。
7、Ctrl-Z后按下kill 命令:
内核会发送SIGKILL信号给我们指定的pid(hello程序),结果是杀死了hello程序。
6.7本章小结
通过对hello进程的管理,如创建、加载和终止等,使得我对hello执行过程中产生信号和信号的处理过程有了更多的认识,加深了对异常的理解。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
1、逻辑地址
由程序hello产生的与段相关的偏移地址部分(hello.o)。在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。
2、线性地址
逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生逻辑地址,或者说是(即hello程序)段中的偏移地址,它加上相应段的基地址就生成了一个线性地址。
3、虚拟地址
虚拟地址也就是线性地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。
4、物理地址
物理地址是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么hello的线性地址会使用页目录和页表中的项变换成hello的物理地址;如果没有启用分页机制,那么hello的线性地址就直接成为物理地址了。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段标识符:段内偏移量组成。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。如图:
所有的段由段描述符描述,而多个段描述符能组成一个数组,我们称成功数组为段描述表。段描述符中的BASE字段对我们翻译线性地址至关重要的。
BASE字段,表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。
为了得到BASE字段,我们利用索引号从GDT(全局段描述表)或LDT(局部段描述符表)中得到段描述符。选择GDT还是LDT取决于段选择符中的T1,若T1等于0则选择GDT,反之选择LDT。
这样我们就得到了BASE。最后通过BASE加上段偏移量就得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
计算机会利用页表,通过MMU来完成从虚拟地址到物理地址的转换。而页表是一个页表条目(PTE)的数组,将虚拟页地址映射到物理页地址。
线性地址即虚拟地址,用VA来表示。VA被分为虚拟页号(VPN)与虚拟页偏移量(VPO),CPU取出虚拟页号,通过页表基址寄存器(PTBR)来定位页表条目,在有效位为1时,从页表条目中取出信息物理页号(PPN),通过将物理页号与虚拟页偏移量(VPO)结合,得到由物理地址(PPN)和物理页偏移量(PPO)组合的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB(后备缓冲器)是MMU中的一个关于PTE的小的缓存,其每一行都由一个PTE组成。有了TLB后,VPN又分为了TLB标记(TLBT)和TLB索引(TLBI)。
在访问时与cache几乎一致,先通过组索引找到所在组,在通过标记位判断是否是我们要访问的虚拟地址,如果命中则从中读取物理页号,并通与VPO组合成物理地址访问数据并将数据返回给CPU。如果不命中则必须从下一级TLB或者内存中寻找。
为了通过多级页表的VA到PA的变化我们需要重新划分虚拟地址。假设我们有一个48位的虚拟地址,我们将其划分成四个九位的VPN和一个12位的VPN。
翻译地址的过程如下:首先通过CR3寄存器中存放的一级页表的地址结合VPN读取其中的数据。
一到三级页表中存放的数据是指向下一级页表的首地址,而不是物理页号。逐步访问到第四级页表,第四级页表中装的就是物理页号,通过第四级页表读出的物理页号链接上虚拟地址中的VPO就可以获得物理地址了。翻译过程如图示:
7.5 三级Cache支持下的物理内存访问
首先使用物理地址的CI进行组索引(每组8路),对8路的块分别匹配 CT进行标志位匹配。如果匹配成功且块的valid标志位为1,则命中hit。然后根据数据偏移量 CO取出数据并返回。
若没找到相匹配的或者标志位为0,则miss。那么cache向下一级cache,这里是二级cache甚至三级cache中寻找查询数据。然后逐级写入cache。
在更新cache的时候,需要判断是否有空闲块。若有空闲块(即有效位为0),则写入;若不存在,则进行驱逐一个块(LRU策略)。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并给他分配一个唯一的PID。为了给这个进程创建虚拟内存,它创建了mm_struct、区域结构和页表的原样副本。他将两个进程中的每个页面都标记从只读,并将两个进程中的每一个区域结构都标记位私有的写时复制。当这两个进程中的任何一个后来进行写操作时,写时复制机制就会创建新的页面。
7.7 hello进程execve时的内存映射
execve 函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。而加载程序的步骤如下:
(1)删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构
(2)映射私有区域,为新程序的代码、数据、bss、和栈区域创建新的区域,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data、.bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
(3)映射共享区域,hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器(PC),execve做的最后一件事就是设置当前进程上下文的PC,使其指向代码区的入口。
7.8 缺页故障与缺页中断处理
缺页故障:当CPU想要读取虚拟内存中的某个数据,而这一片数据恰好存放在主存当中时,就称为页命中。相对的,如果DRAM缓存不命中,则称之为缺页。如果CPU尝试读取一片内存而这片内存并没有缓存在主存当中时,就会触发一个缺页异常。
缺页故障处理:发生缺页故障时,处出发缺页异常处理程序,缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。缺页处理程序调入新的页面,并更新内存中的PTE,缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经缓存在物理内存中,所以就会命中。
7.9动态存储分配管理
动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
1,隐式空闲链表:
空闲块通过头部中的大小字段隐含地连接着。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。
(1)放置策略:首次适配、下一次适配、最佳适配。首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
(2)合并策略:立即合并、推迟合并。立即合并就是在每次一个块被释放时,就合并所有的相邻块;推迟合并就是等到某个稍晚的时候再合并空闲块。
2,显式空闲链表:
将空闲块组织为某种形式的显示数据结构是一种更好的方法,因为根据定义,程序不需要一个空闲块的主体,所以实现空闲链表数据结构的指针可以存放在这些空闲块的主体里面。
显式空闲链表是将对组织成双向链表。在每个空闲块的主体中,都包含一个pred(前驱)和succ(后继)指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。
另一种方法是按照地址顺序来维护链表,其中链表中的每一个块的地址都小于它后一个块的地址,在这种情况下释放一个块需要线性时间的搜索来定位合适的前驱。
3,分离的空闲链表
维护多个空闲链表,每个链表中的块有大致相等的大小。将所有可能的块大小分成一些等价类,也叫做大小类。
7.10本章小结
本章主要了解了内存管理的的相关知识,包括了虚拟地址空间,如何映射,地址翻译等。同时在Intel环境下,段式管理和页式管理、fork和exceve的内存映射也同样重要,还知道了缺页故障和缺页中断管理机制,以及如何根据缓存或页表寻找物理內存。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口
所有的输入和输出都能以一种统一且一致的方式来执行:
1、打开文件。 一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2、Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)、头文件< unistd.h> 定义了常量STDIN_FILENO、STDOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
3、改变当前的文件位置。 对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
4、读写文件。 一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为EOF的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号”,
类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5、关闭文件。 当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 函数
1、打开文件函数:int open(char *filename, int flags, mode_t mode);
flag参数为写提供一些额外的指示,mode指定了访问权限。
2、关闭文件函数:int close(int fd);
fd是打开文件时的返回值。
3、读文件函数:ssize_t read(int fd, void *buf, size_t n);
4、写文件函数:ssize_t write(int fd, const void *buf, size_t n);
8.3 printf的实现分析
首先来看printf函数的函数体
static int printf(const char *fmt, ...) { va_list args; int i; va_start(args, fmt); write(1,printbuf,i=vsprintf(printbuf, fmt, args)); va_end(args); return i; } |
其中传递参数中的...表示不确定个数。
函数中的va_list实际上就是typedef后的char*。而va_list arg = (va_list)((char*)(&fmt) + 4);这句操作实际上就是得到了...中的第一个量。
再看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': itoa(tmp, *((int*)p_next_arg)); strcpy(p, tmp); p_next_arg += 4; p += strlen(tmp); break; case 's': break; default: break; } } return (p - buf); } |
vsprintf函数将我们需要输出的字符串格式化并把内容存放在buf中。并返回要输出的字符个数i。然后调用系统函数write来在屏幕上打印buf中的前i个字符,也就是我们要输出的格式串。
查看write函数的反汇编代码:
write: mov eax, _NR_write mov ebx, [esp + 4] mov ecx, [esp + 8] int INT_VECTOR_SYS_CALL |
在printf中调用系统函数write(buf,i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,
int INT_VECTOR_SYS_CALLA代表通过系统调用syscall
发现反汇编语句中的int INT_VECTOR_SYS_CALL,它表示要通过系统来调用sys_call这个函数。
查看sys_call函数
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 |
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取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函数将整个缓冲区都读到了buf中,并用静态变量n来保存缓冲区的长度,当n为0,即buf长度为0时,getchar进行调用read函数,否则直接将保存的buf中的最前面的元素返回。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章了解了Linux下IO设备的管理方法,IO接口及其函数,对于printf和getchar函数有了更深刻的理解。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
1、编写代码,形成hello.c文件
2、hello.c文件通过预处理,转换为hello.i文件。
3、hello.i文件通过编译阶段,转换为hello.s汇编代码文件。
4、hello.s文件通过汇编阶段,转换为hello.o可重定位目标文件。
5、hello.o与其他相关文件链接成为可执行目标文件hello
6、打开终端,输入命令行。
7、终端调用fork()创建新的进程。
8、新的进程调用execve()加载hello程序,execve完成虚拟内存的映射,进入程序入口开始执行。
9、hello调用sleep函数后进入内核模式,内核进行上下文切换,当sleep执行完后,上下文切换再次回到hello进程,回到用户模式继续执行。
10、当程序运行时,输入Ctrl-Z,内核会发送SIGSTP信号给进程,前台作业暂时被挂起,而当程序运行时输入Ctrl-C,内核会发送SIGINT信号给进程并终止前台作业。
11、当hello进程执行完成时,内核会安排终端对其进行回收,将子进程的推出状态传递给父进程,内核删除为这个进程创建的所有数据结构。hello的一生终于完美谢幕。
感悟:
从最简单的电路,到现在复杂精彩的功能,计算机系统的设计与实现十分精妙而又完备,各个步骤的执行环环相扣,每个细节的实现精益求精。如此微小的电路能实现这么复杂的功能实在令人感叹,也让我敬佩古人的智慧。一层一层的抽象,让我们使用起计算机更加的方便。
计算机系统的未来也许应该有量子计算的一席,量子计算一定能让现在的计算机更上一层楼。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名 | 文件作用 |
hello.c | 程序的源代码 |
Hello.i | hello.c文预处理后的文本文件 |
Hello.s | hello.i文件编译后的汇编文件 |
Hello.o | hello.s汇编后的可重定位目标文件 |
hello | ld得到可执行目标文件 |
helloelf.txt | ELF格式下的hello.o |
(附件0分,缺失 -1分)