2022计算机系统大作业

计算机系统

大作业

题     目  程序人生-Hellos P2P  

专       业         计算机               

计算机科学与技术学院

2021年5月

摘  要

本文通过解释hello.c文件如何经过预编译、编译、汇编、链接到运行,分析了计算机系统如何将我们编写好的程序由高级层次到接近底层运行的过程,对hello.c美妙的一生做了充分的理解与分析,结合 CSAPP 课本相关章节,理解计算机的行为,对我们本学期的学习进行了回顾。

关键词:编译;汇编;链接;存储;进程;存储;I/O

                            

目  录

第1章 概述

1.1 Hello简介

1.2 环境与工具

1.3 中间结果

1.4 本章小结

本章我们分析了hello.c程序的p2p,020过程,较为概括地说明了该程序从编写到运行的主要过程,以便进行其他章节的说明。

2.1 预处理的概念与作用

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

2.4 本章小结

第3章 编译

3.1 编译的概念与作用

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.4 本章小结

第4章 汇编

4.1 汇编的概念与作用

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

4.4 Hello.o的结果解析

4.5 本章小结

5链接

5.1 链接的概念与作用

5.2 在Ubuntu下链接的命令

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

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

5.6 hello的执行流程

5.7 Hello的动态链接分析

5.8 本章小结

6hello进程管理

6.1 进程的概念与作用

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

6.3 Hello的fork进程创建过程

6.4 Hello的execve过程

6.5 Hello的进程执行

6.6 hello的异常与信号处理

6.7本章小结

7hello的存储管理

7.1 hello的存储器地址空间

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

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

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

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

7.6 hello进程fork时的内存映射

7.7 hello进程execve时的内存映射

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

7.9动态存储分配管理

7.10本章小结

8hello的IO管理

8.1 Linux的IO设备管理方法

8.2 简述Unix IO接口及其函数

8.3 printf的实现分析

8.4 getchar的实现分析

8.5本章小结

结论

附件

参考文献


第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

P2P:先通过高级语言编写得到.c文件,之后通过预处理器(cpp)编译得到.i文件,然后编译器通过将高级语言转化为机器语言,编译得到.s文件,进行汇编将.s文件打包生成可重定位目标程序,保存在.o文件中,最后通过连接器与库函数进行连接生成可执行文件。执行该文件,系统将为其产生子进程,并通过execute函数加载进程。

020:操作系统调用execute函数时,先将虚拟内存中的数据结构删除,映射虚拟内存。进入程序入口后程序开始载入物理内存,之后进入main函数进行运行。执行完成后,父进程回收hello进程,然后删除相关痕迹,此时完成了020的实现

1.2 环境与工具

硬件环境:X64CPU, 2.40 GHz

软件环境:windows10 64位;VmwareUbuntu

开发与调试工具:gdb gcc readelf odjdump codeblocks gedit ld

1.3 中间结果

hello.i 预处理之后文本文件

hello.s 编译之后的汇编文件

hello.o 汇编之后的可重定位目标执行

hello 链接之后的可执行目标文件

1.4 本章小结

本章我们分析了hello.c程序的p2p,020过程,较为概括地说明了该程序从编写到运行的主要过程,以便进行其他章节的说明。

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

2.1.1 预编译的概念

预编译又称为预处理,是在整个编译过程中最先执行的命令,预处理器会根据.c文件中以#开头的行,修改代码。

2.2.2预编译的作用

  1. 文件包含相关:如果以#include开头,会告诉预编译器读取系统头文件stdio.h的内容,并将源文件插入到程序文本中。
  2. 宏定义相关:如果以#define开头,则将其定义的字符串用实际的值替换。
  3. 条件编译相关:如果以#if开头,则根据后面的条件决定要编译的内容。

2.2在Ubuntu下预处理的命令

指令:cpp hello.c > hello.i

图1.通过指令生成hello.i

图2.hello.i的文本

2.3 Hello的预处理结果解析

图3.hello.i中的main函数

可以看到hello.c文件中有三个头文件,故在预处理的过程中将这三个文件进行读取写入操作,在生成的hello.i文件中可以看到出现了很多以extern开头的函数,就是对头文件的展开的输入。在main函数的上部有一些例如“/usr/include/stdlib.h”表示,就是预处理器对头文件进行的读文件操作。而main函数没有发生变化,说明预编译对除了以#开头之外的代码不做改动。

2.4 本章小结

本章对hello.c进行了预编译的处理,并进一步分析与说明了预编译的作用,通过对预编译生成的hello.i文件的分析,了解了预编译处理对源代码的改动与具体操作。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

3.1.1编译的概念:

编译指的是将预编译后的文件转化为汇编文件的过程。具体来说,编译器ccl通过词法分析、语法分析、语义分析以及一系列的优化过程,将一个C程序按照一定的语法规则分方法翻译成汇编代码的程序。在进行语法分析和词法分析过程中如果发现语法错误,会给出错误信息。

3.1.2编译的作用:

将生成的hello.i文件转化为hello.s文件,其执行的过程主要分为四个阶段。

  1. 词法分析:对输入的语句从左到右扫描,进行分割与分析,产生一个个的单词符号,并对不规范的字符进行标注。
  2. 语法分析:将词法分析后得到的字符串作为输入,判断是否符合语法规范。
  3. 语义分析:对结构上正确的源程序进行上下文有关性质的审查,分析是否存在语义错误。
  4. 代码优化:对程序进行多种等价代换,最后得到实现代价更低的代码        

3.2 在Ubuntu下编译的命令

指令:gcc -S hello.i -o hello.s

图4.hello.i生成hello.s文件

3.3 Hello的编译结果解析

3.3.1 汇编文件解析

图5.汇编文件解析

.file 说明了解析的内容是hello.c

.text 代码段

.section .rodata 只读代码段

.align 8 说明了对其的方法为8字节对齐

.string 字符串类型

.global main 声明一个全局变量

.type main, @function 声明对象类型或函数类型

3.2.2 数据的解析

1.整型变量:

(1)立即数:直接编码在在汇编代码中,前面有一个$标识,比如$32、$4。

(2)int i :这里的 i 是一个局部变量作为循环变量来进行计数,编译器将局部变量存放在栈和寄存器中。对应的汇编代码是movl $0, -4(%rbp),即将i放在在-4(%rbp)处,占据 4 字节。

(3)int argc :argc 是 main 函数的参数记录。记录传入参数个数(函数名算参数),在这里参数用寄存器来传递。如下图 3.3.2-3 所示。argc 通过寄存器%edi 传递并放在栈-20(%rbp)处,用于判断是否有足够的 4 个参数。通常情况下,%edi 保存函 数的第一个参数。 

2.字符串:

图6.字符串示例

其中第6行被编码成UTF-8的形式,每个汉字占三个字节;第7行编译时将两个字符串保存在.roda中。

  1. 数组:

argv[]就是一个字符串数组。argv 是一个二重指针,其中每一个数组元素都是一个指针指向相应的字符串的首地址。由于 argv 同 argc 一样是 main 的参数,因此 argv 同样使用寄存器来传递。 

图7.数组示例

argv[]存放在-32(%rbp)中,如第23行中显示,对 argv[]数组进行访问,通过每次+16 来实现对 argv[1],argv[2]的首地址的得到,然后间接引用这个地址的内容就可以得到各个字符串的内容。

3.3.3赋值的解析

使用赋值号“=”进行赋值,对应的汇编指令为movl,mov赋值指令有四种,分别为movb、movw、movl、movq,分别代表1字节,2字节,4字节,8字节。

3.3.4运算的解析

在该程序中出现了三种运算:

  1. 加法操作add

在对数据进行加1操作时,由于数组是指针类型,汇编中每次增加8个字节;

在对循环变量进行加1操作时,通过addl每次加1来实现

图8.加法操作

  1. 减法操作subq

在为main函数开辟栈帧时,将栈顶指针减32。

图9.减法操作

  1. 传送指令leaq

将 LC1 的有效地址传送给%rdi时,使用leaq操作。

图10.转移操作

3.3.5关系与控制转移操作的解析

汇编中的关系操作主要通过cmp与test指令来实现,cpu 维护着一组单个位的条件码,cmp 指令根据两个操作数之差来设置条件码。一般在此类指令之后都会跟着 jx 或检查条件码的操作,并进行跳转。

对于在hello.c中,argv!=4的操作,编译器先通过关系操作比较二者是否相等,如果相等的话就跳转到L2

图11.控制转移操作

3.3.6函数调用的解析

函数操作涉及到参数传递、函数调用、函数返回 return 等。在汇编代码中涉及到函数的调用与返回的指令是 call与 ret,函数的调用一般与运行时栈联系紧密。

源代码中的函数有 main 函数,printf 函数,sleep 函数,getchar 函数和 exit 函数。

1.main函数:main函数的形参有两个,其传递参数功能的实现在汇编代码的第22,23行即函数原来将我们要传入的参数储存在%edi 和%rsi 中,然后在栈上保存。

图12.main函数的参数传递

2.printf函数:该函数的参数传递是通过寄存器来实现,在该文件中printf函数被调用了两次,第一次将%rdi赋值为字符串的“用法: Hello 学号 姓名 秒数!\n”的首地址,然后调用puts函数(printf函数的优化)。第二次将%rdi赋值为“Hello %s %s\n”的首地址,设置%rsi 为 argv[1],%rdx 为 argv[2]。然后调用printf函数。

图13.printf函数的调用

3.exit函数:传入的参数为1,先将1赋值给%edi,然后调用exit函数。

图14.exit函数的调用

4.sleep函数:传入参数argv[3]放入%edi中,然后调用sleep函数。

图15.sleep的调用

5.getchar函数:在main函数中直接调用。

图16.getcahr的调用

3.4 本章小结

在本章中,我们将高级语言转化为汇编语言,它更加贴近于机器的思维与指令,同时编译器还可以检查高级语言代码的语法正确性,并通过一系列的分析,详细地说明了编译器如何将C代码中各类型的数据与操作转化为汇编指令,解释了hello.c文件与hello.s的文件的映射关系。

(第3章2分)


第4章 汇编

4.1 汇编的概念与作用

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

4.1.2汇编的作用:通过汇编这个过程将汇编代码转换成了机器可以理解的机器代码,这个二进制文件是一个可重定位文件。

4.2 在Ubuntu下汇编的命令

指令:gcc -c hello.s -o hello.o

图17..s文件生成.o文件

4.3 可重定位目标elf格式

图18.hello.o的elf格式

ELF头:以 16 字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信

息,其中包括 ELF 头的大小、目标文件的类型、机器类型(如 x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。

.text:已编译程序的机器代码。

.rodata:只读数据。

.data:已初始化的全局和静态 C 变量。局部 C 变量在运行时被保存在栈中,

既不出现在.data 节中,也不出现在.bss 中。

.bss:未初始化的全局和静态 C 变量,以及所有被初始化为 0 的全局或静 C 变量。

.symtab:一个符号表,他存放在程序中定义和引用的函数和全局变量的信息。

图19.节头部表

节头部表:包含了节的名称,节的类型,节的属性各个节的名称,大小,其中的数据类型,地址,相对于头的偏移量,以及对齐方式以及可以对各个节进行的操作。

图20.重定位节

.rela 重定位节。该节包括的内容是:偏移量,信息,类型,符号值,符名称和加数。可以看到8条节信息,分别对应.L0,puts,exit,.L1,printf,atoi,sleep,getchar。

4.4 Hello.o的结果解析

输入命令 objdump -d -r hello.o >hello.asm,打开 hello.asm。

分析 hello.o 的反汇编代码与 hello.s 文件的区别

1. 分支转移:hello.s 文件中分支转移是使用段名称进行跳转的,而

hello.o 文件中分支转移是通过地址进行跳转的。

2. 函数调用:hello.s 文件中,函数调用 call 后跟的是函数名称;而在我

们的 hello.o 文件中,call 后跟的是下一条指令。

              

4.5 本章小结

本章主要分析了汇编的过程与作用。先生成了hello.o文件,然后用不同的汇编指令进行了查看。对可重定位目标elf格式进行了具体分析,并对hello.o文件进行了反汇编。将 hello.asm 与之前生成的 hello.s 文件进行了对比。使得我们对该内容有了更加深入地理解。

(第4章1分)


5链接

5.1 链接的概念与作用

5.1.1链接的概念:链接器通过将已经单独编译好的C语言库函数的目标文件与hello.o合并,得到可执行目标文件hello,可以加载到内存中,由系统执行。

5.1.2链接的作用:链接在编译,加载,甚至运行时都可以执行,他将程序分为更小更好管理的模块,使得分离编译成为可能,方便了模块化编程。

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/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello.out

图21.链接生成.out文件

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

通过指令:readelf -a hello.out >hello_elf得到hello的ELF格式文件。可以看到ELF头中给出了一个16字节的序列描述了生成该文件系统字的大小和字节顺序,还包含了ELF 文件的大小,节头部的起始位置,程序的入口地点,目标文件的类型,机器类型,节头部表的文件偏移,以及节头部表中条目的大小与数量的信息。

图22.hello的elf格式文件

与先前生成的hello.o的elf格式进行对比,发现有一定的差异:新增加了程序头;程序的入口地址发生了改变,指明了程序的第一条语句的地址;节头数量与字符串表索引节头改变,elf 节的数量改变。

5.4 hello的虚拟地址空间

使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。

查看edb中的Data Dump界面,如图所示

图23.edb加载hello后的Data Dump界面

而回过头去看elf文件中的程序头,发现这个 datadump 的开始的字节与 elf 头的 magic 进行对比可以发现,二者相同,说明程序正是从 0x400000 开始加载,而这段数据也正描述了整个 elf 文件的一些总体信息。并且代码段也开始于 0x400000。其次我们观察 0x600e10 开始的地址空间代表了数据段。

图24.程序头

5.5 链接的重定位过程分析

图25.使用 objdump 反汇编目标文件

与hello.o文件的反汇编文件相对比:连接过程中,会将库函数加入到可执行文件的内部;增加了.init和.plt节;跳转和函数调用的地址都变成了虚拟内存地址;相对偏移地址变成了虚拟内存地址。

链接过程:

链接就是链接器ld将各个目标文件组装在一起,就是把.o文件中的各个函数段按照一定规则累积在一起,指定了动态链接器为64的/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o中主要定义了程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入。

重定位:

在这个步骤中,将核并输入模块。并未每个符号分配运行时的地址。重定位由两步组成:重定位节与符号定义、重定位节中的符号引用。

在重定位节与符号定义这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节,而后,链接器将运行时内存地址赋值给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。

在重定位节中的符号引用中,链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行地址,这一步依赖hello.o中的重定位条目。

除此之外重定位类型分为两种,分别为R_X86_64_PC32与R_X886_64_32,这两种分别为PC相对寻址与绝对寻址。对于hello.o中使用PC相对寻址的指令使用R_X86_64_PC32类型进行重定位,而对hello.o直接引用地址的指令,采用R_X886_64_32类型进行重定位。

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!atoi@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的动态链接分析

   动态链接是为了减少共享库函数的代码频繁出现在各个程序中从而占用宝贵

而昂贵的内存空间从而提出的一个有效的方法,由一个叫做动态链接器的程序来执行的,将共享库目标模块在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。

图26.调用dl_init函数之前

图26.调用dl_init函数之后

通过对两个图的对比,注意到 0x404000 开始的地方内发生了变化,由前面的

分析可以知道 GOT 表的位置就在 0x404000 处,这里执行 ld_init 之后填入了 GOT的信息。

5.8 本章小结

本章介绍了有关链接的相关知识,通过使用edb以及objdump的使用更好的区分了重定位目标文件与可执行文件之间的区别。从 ld 链接器将 hello.o 的链接命令,然后到可执行文件 ELF 的查看,分析可执行文件的相关信息,hello 的重定位过程,执行流程,hello 的动态链接分析,进一步加深了对链接以及动态链接过程细节的理解。

(第5章1分)


6hello进程管理

6.1 进程的概念与作用

6.1.1进程的概念:进程是正在运行的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量、以及打开文件描述符的集合。

6.1.2进程的作用:进程给应用程序提供的关键抽象有两种:

a) 一个独立的逻辑控制流,提供一个假象,程序独占地使用处理器。

b) 一个私有的地址空间,提供一个假象,程序在独占地使用系统内存。

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

6.2.1作用:Shell 是一种命令解释器,它解释由用户输入的命令并且把它们送到内核去执行。Shell 可以解析命令行将其划分为各个字符串存在 main 的参数 argv[]中,对于各个命令,shell 负责判断命令的正确性以及分类(内置还是可执行文件等),并 execve 加载运行这个可执行程序,同时 shell 还可以处理各种信号,负责回收终止的子程序,防止僵死进程的出现。

6.2.2处理流程:

  1. 从终端进程读取用户由键盘输入的命令行。
  2. 将命令行以空格为间隙分解,获取命令行参数,并构造传递给 execve 的 argv 向量。
  3. 如果是内置命令则立即执行。
  4. 否则调用 fork( )创建新进程/子进程。
  5. 在子进程中,传入步骤二得到的参数,调用 execve( )执行指定程序。
  6. 如果用户没要求后台运行(命令末尾没有&号)否则 shell 使用 waitpid

(或 wait...)等待作业终止后返回。

  1. 如果用户要求后台运行(如果命令末尾有&号),则 shell 返回。

6.3 Hello的fork进程创建过程

输入命令执行当前目录下的可执行文件hello,父进程通过调用fork函数创建一个新的运行的子进程。

子进程与父进程基本一样,除了他们拥有不同的PID。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得父进程打开任何文件描述符相同的副本,这意味着当父进程调用fork函数时,子进程可以读写父进程中任何打开的文件。同时 fork 函数还会返回两次,在子进程中返回 0,而在父进程中返回子进程的 PID。

之后会将可执行程序 hello 加载到内存中,开始执行了。

6.4 Hello的execve过程

Hello程序在使用fork创建了子进程之后,在子进程中分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量,调用execve( )执行指定程序。
在execve加载了Hello之后,它调用启动代码。

execve 函数在当前的进程上下文中加载并运行一个新程序。他会覆盖当前进程的地址空间,将其替换为这个可执行文件固有的一些信息,而且新的进程仍然有相同的 PID。主要进行的操作有:删除已经存在的用户区域、映射私有区域、映射共享区域、设置程序计数器 PC。

6.5 Hello的进程执行

6.5.1 逻辑控制流:

在操作系统中,每一个时刻通常有许多程序在进行,但是我们通常会认为每

一个进程都独立占用 CPU 内存以一些其他资源,如果单步调试我们的程序可以发现在执行时一系列的程序计数器的值,这个 PC 值的序列就是逻辑控制流。事实上,多个程序在计算机内部执行时,采用并行的方式,他们的执行是交错的,每个程序都交错运行一小会儿,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。

6.5.2 进程的时间片:

正是由于进程执行的并发性,在宏观上,我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。但是在微观上:由于只有一个 CPU,一次只能处理程序要求的一部分,一个进程执行它的控制流的一部分的每一时间段叫做时间片。

6.5.3内核模式与用户模式:

处理器通过用某个控制寄存器中的一个模式位来限制一个应用可以执行的指令和它可以访问的地址空间范围。当设置了模式位时,进程就运行着在内核模式,可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。

6.5.4 上下文与上下文切换:

内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。并通过上下文切换的机制来将控制转移到新的进程。

上下文切换的过程:

1.保存当前进程的上下文

2.恢复现在调度进程的上下文

3.将控制传给新恢复进程

例如,在hello的运行过程中,sleep函数的调用就是一个上下文切换的过程,hello初始运行在用户模式,在 hello 进程调用 sleep 之后陷入内核模式,内核处理休眠请求主动挂起当前进程,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时发送一个中断信号,此时进入内核状态执行中断处理,将 hello 进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello 进程就可以继续进行自己的控制逻辑流了。

6.6 hello的异常与信号处理

6.6.1正常运行:

程序正常执行,总共循环 8 次每次输出提示信息之后等待我们从命令行输入的秒数,最后需要输入一个字符回车结束程序。

图27.正常运行

6.6.2中途按下ctrl-Z

内核向前台进程发送一个 SIGSTP 信号,前台进程被挂起,直到通知它继续的信号到来,继续执行。当按下 fg 1 后,输出命令行后,被挂起的进程从暂停处,继续执行。

图28.中途按下ctrl+Z

6.6.3中途按下 ctrl-C

内核向前台进程发送一个 SIGINT 信号,前台进程终止,内核再向父进程发送一个 SIGCHLD 信号,通知父进程回收子进程,此时子进程不再存在。

图29.中途按下ctrl+C

6.6.4中途按下随意键

运行途中乱按后,只是将乱按的内容输出,程序继续执行,但是我们所输入的内容到第一个回车之前会当做 getchar 缓冲掉,后面的输入会简单的当做我们即将要执行的命令出现在 shell 的命令行处。

图30.中途乱按

6.6.5输入pstree指令后

详细显示从开机开始的各个进程父子关系,以一颗树的形式展现。

图31.输入pstree

6.6.6输入kill

Kill 之后会根据不同的发送信号的值,以及要发送的进程的 pid 发送相应的信号,这里我们将 hello 杀死。

图32.输入kill

6.7本章小结

本章介绍了 hello 程序在计算中的加载与运行的过程。Hello 是以进程的形式运行,每个进程都处在某个进程的上下文中,每个进程也都有属于自己的上下文,用于操作系统通过上下文切换进行进程调度。用户通过 shell 和操作系统交互,向内核提出请求,shell 通过 fork 函数和 execve 函数来运行可执行文件。通过对 hello 执行过程中对其发送各种信号对各个信号的处理以形式有了更深的理解。

(第6章1分)


7hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:由程式产生的和段相关的偏移地址部分,是相对于你当前进程数据的地址。

物理地址:用于内存芯片级的单元寻址,与处理器和 CPU 连接的地址总线相应。

对于hello程序为当hello运行时通过MMU将虚拟内存地址映射到内存中的地址。

虚拟地址:现在计算机系统提供了一种对主存的抽象概念,叫做虚拟内存。虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美结合,他为每个程序提供了一个大的、一致的和私有的地址空间。

线性地址:逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。

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

分段功能在实模式和保护模式下有所不同。

实模式:逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。

保护模式:线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。

段寄存器用于存放段选择符,通过段选择符可以得到对应段的首地址。处理器在通过段式管理寻址时,首先通过段描述符得到段基址,然后与偏移量结合得到线性地址,从而得到了虚拟地址。

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

Linux 下,虚拟地址到物理地址的转化与翻译是依靠页式管理来实现的,虚

拟内存作为内存管理的工具。概念上而言,虚拟内存被组织为一个由存放在磁盘

上的 N 个连续的字节大小的单元组成的数组. 磁盘上数组的内容被缓存在物理内存中。这些内存块被称为页。

32 位的虚拟地址分成 VPN+VPO,VPN 是虚拟页号,这个值用来查询页表,VPO

表示页内偏移。一个页面大小是 4KB,所以 VPO 需要 20 位,VPN 需要 32-20=12

位。CPU 中的一个控制寄存器 PTBR指向当前页表,MMU 通过VPN 来选择 PTE,PTE中存放的即物理页号 。所以通过 MMU,我们得到了线性地址相应的物理 地址。如果我们找到的 PTE 中有效位为 0,MMU 触发一次异常,调用缺页异常处理

程序,程序更新 PTE,控制交回原进程,再次执行触发缺页的指令。

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

为了消除每次 CPU 产生一个虚拟地址,MMU 就查阅一个 PTE 带来的时间开 销,许多系统都在 MMU 中包括了一个关于 PTE 的小的缓存,称为翻译后被缓冲器(TLB),TLB 的速度快于 L1 cache。

首先一个虚拟地址被分割成几个部分 VPN VPO,VPN 不是直接当做索引去

搜索页表,而是分割成 4 个部分,对应于四个页表,只有一级页表时常住在内存

中,其余的依靠上级的页表得到下一级也表的首地址,在通过 VPN 的对应位,在

这个第 k 级页表中搜索内容,再结合 TLB 找到对应的页表项就可以翻译得到 PPA,这个就是物理地址的前 40 位,VPO=PPO,现在利用 cache 的知识就可以对物理地址进行一级一级的分析与访问了。

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

当我们将一个虚拟地址翻译成了一个物理地址之后,下面要做的就是找到这 个物理地址的数据并取出给 CPU。如下图所示,一个物理地址可以分成两部分,PPO 与 PPN。由于内存的组织方式采用多级 cache 的方式,PPO 还可以继续分割成 CO 与 CI,CO 是块偏移,即得到一个 cache 的一个命中行之后从哪个字节开始取出数据,CI 作为组索引,PPN 作为 tag 标记串。

图33.多级cache下的分块

因为 L1L2L3 这几级 cache 都是通过 S 组,E 行,B 位这种方式组织,先根据CI,CO 找到对应的行,如果本级 cache 不命中找到会从更下一级 cache 中找到合适的行,通过某些替换策略进行替换与对应的牺牲,这样一级一级的访问极大的提高了对内存访问的效率,找到这个行之后根据 PPO 就可以找到所需要的数据,传给 CPU 了,同时 Cache 的读写命中\不命中都有对应的处理流程。

7.6 hello进程fork时的内存映射

当 fork 函数被 shell 进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID,为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只 读,并将两个进程中的每个区域结构都标记为私有的写时复制。

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。在这两个进程中的任一个后来进行写操作时,写时赋值机制就会创建新页面。

7.7 hello进程execve时的内存映射

execve函数在当前进程中建在并运行包含在可执行目标文件ahello中的程序,用hello程序有效代替了当前长须,如图7.12所示,记载并运行hello程序需要以下几个步骤:

1.删除已存在的用户区域:

2.删除当前进程虚拟地址的用户部分中的已存在的区域结构。

2.映射私有区域:

为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。

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

7.8.1缺页故障:进程线性地址空间里的页面不必常驻内存,在执行一条指令时,如果发现他要访问的页没有在内存中,那么停止该指令的执行,并产生一个页不存在的异常,对应的故障处理程序可通过从外存加载该页的方法来排除故障,原先引起的异常的指令就可以继续执行,而不再产生异常。

7.8.2缺页中断处理:确定缺页是由于对合法虚拟地址进行合法的操作造成之后,系统选择一个牺牲页面,如果这个牺牲页面被修改过,将其交换出去,换入新页面并更新页表,当缺页处理程序返回后,CPU 重启引起缺页指令。

7.9动态存储分配管理

动态内存管理的基本方法与策略:

在程序运行时程序员使用动态内存分配器 (比如 malloc) 获得虚拟内存. 数据结构的大小只有运行时才知道.动态内存分配器维护者一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块(blocks)的集合来维护,每个块要么是已分配的,要么是空闲的。

分配器的类型分为两种,分别是:

显式分配器: 要求应用显式地释放任何已分配的快

隐式分配器: 应用检测到已分配块不再被程序所使用,就释放这个块

两种堆的数据结构组织形式:

带标签的隐式空闲链表:空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。

显式空闲链表:显式空闲链表将链表的指针存放在空闲块的主体里面。堆被组织成一个双向空闲链表,在每个空闲块中,都包含一个pred和succ指针。

7.10本章小结

本章介绍了本章首先讲述了虚拟地址、线性地址、物理地址的概念与区别,通过与 intel Core7 指定环境下分析了VA 到 PA 的变换、物理内存访问, 还介绍了 hello 进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。对动态分配函数 malloc 系列函数有了更深的认识。

(第7章 2分)


8hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

一个 Linux 文件就是一个 m 个字节的序列:B0,B1,B2…….所有的 I/O 设备(如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对文件的读写操作。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 UnixI/O,这使得所有的输入输出都能以一种统一的方式且一致的方式来执行。

8.2 简述Unix IO接口及其函数

8.2.1Unix I/O 接口:

1.打开文件:程序要求内核打开文件,内核返回一个小的非负整数(描述符),用于标识这个文件。程序在只要记录这个描述符便能记录打开文件的所有信息。

2. shell 在进程的开始为其打开三个文件:标准输入、标准输出和标准错误。

3.改变当前文件的位置:对于每个打开的文件,内核保存着一个文件位置 k,初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行 seek 操作显式地设置文件的当前位置为 k。

4.读写文件:一个读操作就是从文件复制 n>0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k+n。给定一个大小为 m 字节的文件, 当 k>=m 时执行读操作会出发一个称为 EOF 的条件,应用程序能检测到这个条件,在文件结尾处并没有明确的 EOF 符号。

5. 关闭文件:内核释放打开文件时创建的数据结构以及占用的内存资源, 并将描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

8.2.2Unix I/O 函数:

open()函数

功能描述:用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。

函数原型:int open(const char *pathname,int flags,int perms)

参数:pathname:被打开的文件名(可包括路径名如"dev/ttyS0")flags:文件打开方式,

返回值:成功:返回文件描述符;失败:返回-1

close()函数

功能描述:用于关闭一个被打开的的文件

所需头文件: #include <unistd.h>

函数原型:int close(int fd)

参数:fd文件描述符

函数返回值:0成功,-1出错

read()函数

功能描述: 从文件读取数据。

所需头文件: #include <unistd.h>

函数原型:ssize_t read(int fd, void *buf, size_t count);

参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。

返回值:返回值-1 表示一个错误,0 表示 EOF,否则返回值表示的是实际传送的字节数量。

write()函数

功能描述: write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文 件位置

所需头文件: #include <unistd.h>

函数原型:ssize_t write(int fd, void *buf, size_t count);

返回值:写入文件的字节数(成功)或-1(出错)

lseek()函数

功能描述: 用于在指定的文件描述符中将将文件指针定位到相应位置。

所需头文件:#include <unistd.h>,#include <sys/types.h>

函数原型:off_t lseek(int fd, off_t offset,int whence);

参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负

返回值:成功:返回当前位移;失败:返回-1

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;

}

可以看到,printf()函数将变长参数的指针arg作为参数,传给vfprintf函数。然后vfprintf函数解析格式化字符串,调用write()函数。

Vsprintf的示意函数(仅格式化16进制字符)如下:

vsprintf返回的是要打印出来的字符串的长度,而write,写操作,把buf中i个元素的值写到终端,在再追踪write函数

write函数的反汇编:

write:

mov eax, _NR_write

mov ebx, [esp + 4]

mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

再来看看 sys_call 的实现:

sys_call:

call save //保存中断前进程的状态

push dword [p_proc_ready]

- 71 -计算机系统基础课程报告

sti

push ecx //ecx 中是要打印出的元素个数

push ebx //ebx 中的是要打印的 buf 字符数组中的第一个元素

call [sys_call_table + eax * 4] //不断的打印出字符,直到遇到:’

\0’

add esp, 4 * 3

mov [esi + EAXREG - P_STACKBASE], eax

//[gs:edi]对应的是 0x80000h:0 采用直接写显存的方法显示字符串

cli

ret

Syscall 将字符串中的字节从寄存器复制到显卡的显存中,以 ASCII 字符形

式。

字符显示驱动子程序:从 ASCII 到字模库到显示 vram(存储每一个点的 RGB

颜色信息)。

显示芯片按照刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一

个点(RGB 分量)。

8.4 getchar的实现分析

首先我们看一下 getchar()函数的具体实现代码,主要通过调用 read 函数

以及一些控制的代码来实现。

#include "sys/syscall.h"

#include <stdio.h>

int getchar(void)

{

char c;

return (read(0,&c,1)==1)?(unsigned char)c:EOF

//EOF 定义在 stdio.h 文件中

}

getchar有一个int型的返回值.当程序调用getchar时.程序就等着用户按键.用户输入的字符被存放在键盘缓冲区中.直到用户按回车为止(回车字符也放在缓冲区中).当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符.getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕.如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。

8.5本章小结

本章主要讲述了 Linux 的 IO 设备管理方法,Unix I/O 接口及其函数,以及

printf 函数实现的分析和 getchar 函数的实现。所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。

(第8章1分)

结论

hello程序的运行过程主要有如下几个阶段

  1. 首先通过高级语言的编写得到hello.c文件;
  2. 然后通过预处理器cpp将hello.c文件修改以#开头的命令,得到了hello.i文件。
  3. 之后通过编译将高级语言转化成低一等级汇编语言,同时编译器还可以检查高级语言代码的语法正确性,生成hello.s文件。
  4. 汇编器将 hello.s 转化为二进制的机器代码,生成 hello.o 的可重定位目标

程序。

  1. 链接器对 hello.o 中引用的外部函数、全局变量等进行符号解析,并重定位为可执行文件 hello。
  2. 在shell中输入一个./hello 120L022415 鲁铭希 1,shell 为 hello fork 一个子进程,并在子进程中调用 execve,加载运行 hello。
  3. 执行指令,CPU为其分配时间片,在一个时间片中,hello有自己的CPU资源,顺序执行逻辑控制流。
  4. hello在运行过程中会有异常和信号等,shell 为各种信号提供了各种信号处理程序。
  5. 调用malloc向动态内存分配器申请堆中的内存。
  6. Unix I/O 帮助 hello 实现了与外接显示设备以及输入设备的连接,即实现了输出到屏幕和从键盘输入的功能。
  7. shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。

感悟:

通过这次大作业,将我们这学期所学习的有关CSAPP的内容知识又重新复习了一遍,加深了理解,通过这次大作业,使得我更加清晰地知道了计算机的运行原理,对一些操作有了更深层次的理解。


 


参考文献

  1. 兰德尔 E.布莱恩特 大卫 R.奥哈拉伦 深入理解计算机系统(第三版)

[2] https://www.cnblogs.com/pianist/p/3315801.html printf 函数的深入剖析

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值