2021-06-28

计算机系统

大作业

题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学   号 1190202420
班   级 1903005
学 生 孟子恒   
指 导 教 师 史先俊

计算机科学与技术学院
2021年5月
摘 要
摘要是论文内容的高度概括,应具有独立性和自含性,即不阅读论文的全文,就能获得必要的信息。摘要应包括本论文的目的、主要内容、方法、成果及其理论与实际意义。摘要中不宜使用公式、结构式、图表和非公知公用的符号与术语,不标注引用文献编号,同时避免将摘要写成目录式的内容介绍。

关键词:编译;进程;虚拟内存;系统级I/O;计算机系统

目 录

第1章 概述 - 4 -
1.1 HELLO简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 5 -
2.1 预处理的概念与作用 - 5 -
2.2在UBUNTU下预处理的命令 - 5 -
2.3 HELLO的预处理结果解析 - 5 -
2.4 本章小结 - 5 -
第3章 编译 - 6 -
3.1 编译的概念与作用 - 6 -
3.2 在UBUNTU下编译的命令 - 6 -
3.3 HELLO的编译结果解析 - 6 -
3.4 本章小结 - 6 -
第4章 汇编 - 7 -
4.1 汇编的概念与作用 - 7 -
4.2 在UBUNTU下汇编的命令 - 7 -
4.3 可重定位目标ELF格式 - 7 -
4.4 HELLO.O的结果解析 - 7 -
4.5 本章小结 - 7 -
第5章 链接 - 8 -
5.1 链接的概念与作用 - 8 -
5.2 在UBUNTU下链接的命令 - 8 -
5.3 可执行目标文件HELLO的格式 - 8 -
5.4 HELLO的虚拟地址空间 - 8 -
5.5 链接的重定位过程分析 - 8 -
5.6 HELLO的执行流程 - 8 -
5.7 HELLO的动态链接分析 - 8 -
5.8 本章小结 - 9 -
第6章 HELLO进程管理 - 10 -
6.1 进程的概念与作用 - 10 -
6.2 简述壳SHELL-BASH的作用与处理流程 - 10 -
6.3 HELLO的FORK进程创建过程 - 10 -
6.4 HELLO的EXECVE过程 - 10 -
6.5 HELLO的进程执行 - 10 -
6.6 HELLO的异常与信号处理 - 10 -
6.7本章小结 - 10 -
第7章 HELLO的存储管理 - 11 -
7.1 HELLO的存储器地址空间 - 11 -
7.2 INTEL逻辑地址到线性地址的变换-段式管理 - 11 -
7.3 HELLO的线性地址到物理地址的变换-页式管理 - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 11 -
7.5 三级CACHE支持下的物理内存访问 - 11 -
7.6 HELLO进程FORK时的内存映射 - 11 -
7.7 HELLO进程EXECVE时的内存映射 - 11 -
7.8 缺页故障与缺页中断处理 - 11 -
7.9动态存储分配管理 - 11 -
7.10本章小结 - 12 -
第8章 HELLO的IO管理 - 13 -
8.1 LINUX的IO设备管理方法 - 13 -
8.2 简述UNIX IO接口及其函数 - 13 -
8.3 PRINTF的实现分析 - 13 -
8.4 GETCHAR的实现分析 - 13 -
8.5本章小结 - 13 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -

第1章 概述
1.1 Hello简介

  1. P2P简介
    使用编译器编写hello.c程序,再经过C预处理器得到中间文件hello.i,接着通过C编译器得到汇编语言文件hello.s,然后通过汇编器得到可重定位目标文件hello.o,最后通过链接器得到可执行文件hello。在shell中输入./hello启动程序,shell将调用fork函数产生子进程,此时hello便成为了进程
  2. O2O简介
    shell调用execve函数在子进程中运行hello,CPU需要为其分配内存、时间片等资源。系统的进程管理将帮助hello切换上下文,同时信号处理程序将帮助hello处理各种信号,当我们按下ctrl+z或者hello自行运行结束,将由shell将其回收,内核将打扫干净其所有痕迹
    1.2 环境与工具
    硬件环境:Intel Core i7-6700HQ x64CPU,16G RAM
    软件环境:Ubuntu18.04.1 LTS;
    开发与调试工具:gcc ld edb readelf gedit hexedit objdump vim
    1.3 中间结果
    hello.c:源代码
    hello.i:hello.c经预处理生成的文本文件。
    hello.s:hello.i经过编译器翻译成的文本文件hello.s,含汇编语言程序。
    hello.o:hello.s经汇编器翻译成机器语言指令打包成的可重定位目标文件
    hello:经过hello.o链接生成的可执行目标文件。
    1.4 本章小结
    本章漫游式地概述hello在系统中生命周期,对每个部分需要有系统地了解,并且本章列出进行本次实验的本机信息。

第2章 预处理
2.1 预处理的概念与作用
概念:预处理是指是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
作用:预处理器根据以字符#开头的命令,修改原始的初始C程序。如将include所包含的头文件全部拷贝过来,将代码中由define宏定义的符号引用全部替换成所定义的值,同时删除代码中的注释。最终得到便于编译器工作的.i文件。
2.2在Ubuntu下预处理的命令
在经过gcc的一个简单的命令行后,文件夹里神奇的出现了一个hello.i文件!
这就是hello.c经过预处理后的文件。

图 2.1 预处理指令

2.3 Hello的预处理结果解析

图 2.2 预处理文件

为何小小23行的hello.c文件经过一个简单的gcc -E命令后竟会产生如此巨大、竟然长达3060行的hello.i文件!不妨一探究竟:我们发现预处理有三个功能1、删注释 2、换宏定义 3、加头文件。显然,前两个功能怎么也不至于让小小.c文件增加了一百多倍!那必然是头文件搞的鬼了!观察这个.i文件

图 2.3 预处理文件

发现好多extern!果不其然,这些杂乱的代码都是外来库拷贝过来的!
2.4 本章小结
预处理是编译器对程序所要进行的首要工作,它像一个化妆师,让一个本身平平无奇的程序变得雍容华贵,多了很多内容。让我们对预处理和.i文件有了更多的认识。

第3章 编译
3.1 编译的概念与作用
概念:编译是一个将高级语言转化为机械语言的文本形式——汇编语言的过程。即编译器ccl将预处理得到的.i文件编译成.s文件的过程。
作用:编译器将文本文件.i翻译成文本文件.s,它包含一个汇编语言程序。相比于即将要转化成的机械代码,汇编代码有着可读性更好的文本格式。
3.2 在Ubuntu下编译的命令

图 3.1 编译命令

3.3 Hello的编译结果解析
3.3.1伪指令

图 3.2 伪指令

所有以“.”开头的行都是指导汇编器和连接器工作的伪指令。我们通常可以忽略这些行。另一方面,也没有关于指令的用途以及它们与源代码之间关系的解释说明。
3.3.2字符串常量
对于字符串常量,观察.i文件有两个字符串常量

图 3.3 字符串常量

分别是"\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201"和"Hello %s %s\n"
我们来查看具体的main函数的内容:

图 3.4 字符串在main函数的内容

其对应的汇编代码:

图 3.5 字符串对应的汇编代码

第一处是把.LC0(%rip)即第一个字符串所在的地址的内存引用的值赋给%rdi,因为printf()是一个函数,其参数就是这个字符串

图 3.6 字符串在main函数的内容

第二处同理,.LC1(%rip)即第一个字符串所在的地址的内存引用的值赋给%rdi,然后调用printf函数。
综上,字符串常量存储在内存中,以内存引用的方式实现。
3.3.3整数常量
观察程序有哪些整型常量

图 3.7 整型常量

分析对应的汇编语言

图 3.8 整型常量对应的汇编语言

因此,整数常量是以立即数的形式表现。
3.3.4变量
分析程序中的变量,一共有三个:
1、int i
根据i的位置,i的定义是在if(argc != 4)之前,而汇编代码如图所示。

图 3.9 汇编代码内容

我们可以发现,这里没有定义i,因此可知只有当i被赋值时,才会在栈中给他分配空间。
我们找到int i=0语句对应的汇编代码

图 3.10汇编代码内容

可知局部变量是分配在内存用户栈中的,赋值时分配,只定义不赋值对栈无影响。
2、int argc
3、char *argv[]
2和3都是main函数的参数,2是int型变量,3是char **型变量。
argc指的是argv[]中非空的字符串格数,argv[0]指的是文件名,argv[1]…指的是参数列表。
从汇编代码

图 3.11 汇编代码内容

可看出,这两个变量是存储在寄存器%edi和寄存器%rsi中的。
3.3.5关系操作
程序包含两个表达式

图 3.12 表达式在main函数的内容

是由汇编语言中的条件跳转命令实现的

图 3.13 汇编代码内容

3.3.6赋值表达式
在3.3.4中提过,赋值表达式是由汇编语言中的数据传送指令
movl $0, -4(%rbp) 实现的。
3.3.7算术操作符
对于算术操作符i++,用操作数指示符实现的

图 3.14 汇编代码内容

3.3.8数组操作
函数中数组操作如下

图 3.15 数组在main函数的内容

对于数组,其实就是一个char **类型的指针,即指向一个指针的指针,因为在Linux下一个地址的长度为8字节,所以argv[i]所在的地址是argv[0]+4*i,查看分析汇编代码:

图 3.16 汇编代码分析

可得出对数组操作,是用了内存引用,首先将数组的首地址记录在寄存器%rax中,接着加上相应的偏移量,例如A[1]的偏移量为8,A[2]的偏移量是16,使用内存引用将它存放在相应参数对应的寄存器中。需要注意,函数的参数依次使用寄存器的顺序是%rsi,%rdi,%rdx,%rcx。
3.3.9控制转移
控制转移的实现离不开上面3.3.5节中的关系操作符,简单来说,编译器解析控制转移就是通过首先设置某个条件,随后检查这个条件是否满足跳转条件(也有可能是无条件跳转),如果满足就跳转,反之继续顺序执行这个模板做的。

图 3.17 汇编代码分析

3.3.10

图 3.18 汇编代码分析

函数调用与函数返回:当控制从函数P转移到函数Q只需要简单地把程序计数器设置为Q的代码的起始位置。不过,当稍后从Q返回的时候,处理器必须记录好它需要继续P的执行的代码位置。在x86-64机器中,这个信息使用指令call Q调用过程Q来记录的。该指令会把地址A压入栈中,并将PC设置为Q的起始地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。
参数传递:通过寄存器,调用函数可以传递最多六个整数值(也就是指针和整数),如果被调用者需要更多的参数,P可以在调用Q之前在自己的栈帧里存储好这些参数。
3.4 本章小结
在本章中,我们窥视了C语言提供的抽象层下面的东西,以了解机器级编程。通过让编译器产生机器级程序的汇编代码表示,我们了解了编译器和它的优化能力,以 及机器、数据类型和指令集。
机器级程序和它们的汇编代码表示,与C程序的差别很大。在汇编语言程序中, 各种数据类型之间的差别很小。程序是以指令序列来表示的,每条指令都完成一个单独的操作。部分程序状态,如寄存器和运行时栈,对程序员来说是直接可见的。 编译器必须用多条指令来产生和操作各种数据结构,来实现像条件、循环和过程这样的控制结构。

第4章 汇编
4.1 汇编的概念与作用
概念:汇编器将.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件.o中。.o是一个二进制文件。
作用:生成可重定位目标文件,包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
4.2 在Ubuntu下汇编的命令

图 4.1 汇编命令
4.3 可重定位目标elf格式
ELF格式:
在ELF头和节头部表之间的都是节:
1) .text 已经编译的机器代码
2) .rodata 只读数据。如字符串常量或是开关语句的跳转表
3) .data 已经初始化的全局变量或是初始化的静态c变量
4) .bss 未初始化的全局和静态c变量, 以及所有初始化为0的全局或静态变量。 目标文件中,.bss 这个节不占有实际的空间,仅仅只是一个占位符。当加载到内存空间的时候才真正分配具体的空间;
5) .symtab 一个符号表,存放在程序中定义和引用的函数的和全局变量的信息。与编译器中的符号表不一样, 可执行文件中的符号表不包含局部变量的条目;(因为局部变量在栈中,不是链接器的对象);
6) .rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。
7) .rel.data:被模块引用或定义的所有全局变量的重定位信息。任何已初始化的全局变量,如果他的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改;
8).debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才会得到这张表
9).line: 原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g 选项调用编译器驱动程序时,才会得到这张表。
10).strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串的序列。
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。

图 4.2 ELF头
readelf列出各节的基本信息。

图 4.3 各节基本信息
重定位项目分析:

图 4.4 重定位节
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。
对于重定位节,节中给出了每个需要重定位的信息:偏移量、符号地址、符号信息、重定义类型,有符号常数。
4.4 Hello.o的结果解析
整体上说,反汇编得到的代码和hello.s相比,在行为上几乎完全一致。只是部分表示方式发生了变化:
1.跳转的表示:在汇编代码种,跳转使用的是.L开头的标记,jmp及条件jmp指令的操作数也是使用对应的.L开头的标记。但是在反汇编代码中,跳转使用的是一个常数,这个常数表示的是相对main函数起始地址的偏移量。
2.函数调用:这个类似于跳转,在汇编代码中函数的调用使用的是函数名称,而在反汇编代码中使用的也是main加上偏移量。除此之外,还会在.rela.text节中添加一条重定位条目以便于链接器进行重定位。
3.访问全局变量:在汇编代码中,访问全局变量使用的是类似于.L0(%rip)这样的方式,而在反汇编代码中使用的是0x0(%rip)这样暂时没有意义的表示方法,同时在.rela.text节中添加重定位条目等待处理。

图 4.5 对比分析
有关机器语言:
机器语言又若干二进制字节构成。一般包含的信息有:指令类型、寄存器指示符、常数字,根据指令类型的不同,后面两者不一定都有。指令类型给出这条指令需要执行的功能。如果需要寄存器参加,一般来说会有寄存器提示符提示涉及的寄存器,在机器语言中寄存器一般用一个特定的数字表示,而不会像汇编代码中用%+字母的格式表示。如果需要常数,如表示立即数或者偏移指令中就会有常数字。
汇编语言和机器语言的映射关系:
机器语言能够直接被机器执行。但是直接使用机器语言存在不便于阅读、难以记忆的问题。汇编语言本质上也是直接对硬件操作,由于采用了助记符,相比机器语言更加方便书写与阅读。机器语言是比汇编语言更加低级的语言,能直接在机器上运行,与汇编语言可以相互转化。
4.5 本章小结
本章我们介绍了关于汇编的过程,Hello先生在这一步中不仅将自身的汇编指令翻译成了机器指令,而且将自己拥有的全局变量和函数以及向别人借来的全局变量和函数都进行了整理,并得出重定位信息,便于链接器将他真正的组装起来。他离出生又近了一步。

第5章 链接
5.1 链接的概念与作用
概念:连接时将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。
作用:通过符号解析和重定位,链接器将多个可重定位目标文件组合起来,生成可执行目标文件,以便于加载到内存中并执行程序。
5.2 在Ubuntu下链接的命令

图 5.1 链接命令
5.3 可执行目标文件hello的格式
可执行目标文件的格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式。它还包括程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址。

图 5.2 ELF头
用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息:

图 5.3 各节基本信息
.text…rodata和.data节与可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时内存地址以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位),所以它不再需要.rel节。
5.4 hello的虚拟地址空间
利用edb调试hello,我们发现hello的虚拟内存地址空间是从0x400000开始,到0x404ff0结束。如图所示了这段地址空间内存储着hello的全部信息。由于.rodata节中存储着printf函数的格式串,可读性相对于其他节更好,故本小节中将以.rodata节为例,介绍hello的虚拟地址空间。

图 5.4 内存地址空间
根据节头部表的表项可知,.rodata节起始于内存地址0x402000处,截止于内存地址0x40203b处。
使用edb查看内存地址0x402000处的内容,如下所示,我们可以内存地址的内容中看到两个以Hello开头的格式串的位置。

图 5.5 Hello开头格式串位置
5.5 链接的重定位过程分析
使用命令:objdump -d -r hello > hello_objdump.txt得到可执行目标文件hello的反汇编代码,与hello.o的反汇编代码进行比较,可以发现如下的不同:
1.hello的反汇编代码中已经有明确的虚拟地址,而hello.o的反汇编代码中的main函数的起始地址还是0,说明还没有经过重定位。说明链接的过程中需要为代码确定虚拟内存空间中的地址。

图 5.6 反汇编代码分析
2.hello的反汇编代码中多出了一些节,包括.init、.plt、.plt.sec、.fini,同时在代码节中也添加了如<_start>这样的内容。这些内容主要是有关程序初始化的一些需要执行的代码、动态链接相关的一些信息、程序正常终止需要执行的代码等。

图 5.7 反汇编代码分析
链接器完成符号解析后,代码中的每个符号引用都与一个符号定义唯一的关联起来,此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小,可以开始重定位步骤,重定位步骤分为以下两步:
重定位节和符号定义:在这一步中,链接器将所有同类型的节合并为同一类型的聚合节,然后将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及输入模块定义的每个符号。当这一步完成时,程序中每条指令和全局变量都有唯一的运行时内存地址。
重定位节中的符号引用:在这一步中,链接器将修改代码节和数据节中对每个符号的引用,使其指向正确的运行时地址。执行此步骤,要依靠我们上文中介绍的重定位表。
简化的重定位算法的伪代码如下图所示

图 5.8 重定位算法
5.6 hello的执行流程
载入:
_init 0x401000
开始:
_start 0x4010f0
__libc_start_main 0x7fbe71ac0bc0
执行:
main 0x401125
puts@plt 0x401030
exit@plt 0x401070
printf@plt 0x401040
sleep@plt 0x401080
getchar@plt 0x401050
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
1、PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
2、GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
根据hello ELF文件可知,GOT起始表位置为0x404000,如图:

图 5.9汇编代码分析
在调用dl_init之前0x404008后的16个字节均为0:

图 5.10 内存查看
调用_start之后发生改变,0x404008后的两个8个字节分别变为:0x7f659b90e1e0、0x7f659b8f6ef0其中GOT[O](对应0x403e10)和GOT[1](对应0x7f659b90e1e0)包含动态链接器在解析函数地址时会使用的信息。包含动态链接器在解析函数地址时会使用的信息。GOT[2](对应0x7f659b8f6ef0)是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,改变后的GOT表如下:
图 5.11 GOT表

GOT[2]对应部分是共享库模块的入口点,如下:

举例puts函数在调用puts函数前对应GOT条目指向其对应的PLT条目的第二条指令,如图puts@plt指令跳转的地址:

图 5.12 puts@指令跳转地址
可以看出其对应GOT条目初始时指向其PLT条目的第二条指令的地址。puts函数执行后在查看此处地址:

图 5.13 puts@指令跳转地址
可以看出其已经动态链接,GOT条目已经改变。
5.8 本章小结
链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理称为目标文件的二进制文件,它有3种不同的形式:可重定位的、可执行的和共享的。可重定位的目标文件由静态链接器合并成一个可执行的目标文件,它可以加载到内存中并执行。共享目标文件(共享库)是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序调用dlopen库的函数时。
链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到–个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。
静态链接器是由像GCC这样的编译驱动程序调用的。它们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,而链接器用来悄悄地解析这些多重定义的规则可能在用户程序中引入微妙的错误。
多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器通过从左到右的顺序扫描来解析符号引用,这是另一个引起令人迷惑的链接时错误的来源。
加载器将可执行文件的内容映射到内存,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有对定义在共享库中的例程和数据的未解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务。
被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载、链接和访问共享库的函数和数据,应用程序也可以在运行时使用动态链接器。

第6章 hello进程管理
6.1 进程的概念与作用
进程:一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用:进程提供了两个关键抽象:独立的逻辑控制流,他提供一个假象,好像我们的程序独占地使用处理器。私有的地址空间,他提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell是一个交互性的应用级程序,它代表用户运行其他程序。后面出现了一些变种,比如bash。shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
Shell本质是一个应用程序,
1、 打印一个命令行提示符,指示用户可以输入命令,如果用户输入了一个命令,那么Shell将会读取这个命令并解析这个命令,
2、 命令的第一个参数是Shell内置的命令(例如quit、jobs、bg或fg),Shell会直接执行此命令,
3、 命令的第一个参数是一个可执行程序的路径,Shell会创建(fork)出一个子进程。
4、 在这个子进程的上下文中加载(execve)这个程序。
5、 如果用户要求后台进行此程序,则Shell返回到命令行提示符,
6、 否则等待(waitpid)进程终止,并进行进程回收。
6.3 Hello的fork进程创建过程
终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的,在父进程中,fork 返回子进程的 PID,在子进程中,fork 返回 0。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。

图 6.1 execve函数
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。
参数列表是用图6.1中的数据结构表示的。argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv [0]是可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的, envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name=value”的名字-值对。
6.5 Hello的进程执行
进程上下文信息
系统中每个程序都运行在某个进程上下文中。上下文是由程序正确运行所需状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:
(1) 保存以前进程的上下文
(2) 恢复新恢复进程被保存的上下文
(3) 将控制传递给这 个新恢复的进程 ,来完成上下文切换。
逻辑控制流:一系列程序计数器 PC 的值的序列叫做逻辑控制流。由于进程是轮流使用处理器的,同一个处理器每个进程执行它的流的一部分后被抢占,然后轮到其他进程。
进程时间片
多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。多任务也叫时间分片。

图 6.2 逻辑控制流
进程调度的过程
操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。当进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中的调度器决定的。当内核选择了一个新的进程运行时,我们就说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来讲控制转移到新的进程。
hello初始运行在用户模式,随后,hello进程调用sleep,并进入内核模式。计时器开始计时。上下文切换使内核将当前进程的控制权交给其他进程。当sleep函数时间到达时发送一个中断信号,此时进入内核状态执行中断处理,然后内核将进程控制权交还给hello进程,hello进程继续执行自己的控制逻辑流。

图 6.3 进程调度的过程
用户态与核心态转换
运行hello程序的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制转移到异常处理程序,处理器将模式从用户模式变为异常模式。处理程序运行在内核模式中,当它返回到hello代码时,处理器就把模式从内核模式改回到用户模式。
6.6 hello的异常与信号处理
hello执行过程中会出现的异常有中断、陷阱、故障和终止,处理方式为暂停当前进程的执行,调用异常处理子程序,根据异常种类以及处理方式不同采取返回当前指令、返回下一条指令、不返回三种方式,具体处理方式如下表所示:

图 6.4 异常
由于hello程序较为简单,产生的信号种类较为单一,可能产生的信号种类、默认行为及相应事件在下表中列出:
ID 名称 默认行为 相应事件
2 SIGINT 终止 来自键盘的中断
9 SIGKILL 终止 杀死程序(该信号不能被捕获不能被忽略)
11 SIGSEGV 终止 无效的内存引用(段故障)
14 SIGALRM 终止 来自alarm函数的定时器信号
17 SIGCHLD 忽略 一个子进程停止或者终止
18 SIGCONT 忽略 继续进程如果该进程停止
19 SIGSTOP 停止 不是来自终端的停止信号
20 SIGTSTP 停止 来自终端的停止信号
以下将会介绍hello程序处理来自键盘的信号
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
1、正常输出时的状态如下

图 6.5 正常输出
2、不停乱按情况如下,可以发现,乱按只是将屏幕的输入缓存到 stdin,当 getchar 的时候读出一个’\n’结尾的字串(作为一次输入),其他字串会当做 shell 命令行输入。

图 6.6 不停乱按情况
3、Ctrl + Z情况如下,可以发现进程停止了运行。Ctrl + Z会导致内核发送一个SIGTSTP信号到前台进程组的每个进程,其默认情况时停止前台作业并挂起。

图 6.7 CTRL + Z
此时使用jobs命令查看,当前作业已停止

图 6.8 jobs命令
此时使用ps命令查看,当前进程并没有被回收,其实后台作业号为42955

图 6.9 ps命令 +
此时使用名来fg 1将此进程变成前台进程,此时shell继续执行hello进程直至完成

图 6.10 fg命令
或者使用kill命令,kill命令发送信号号码sig给进程pid。下图时kill命令发送SIGKILL信号给PID = 42965的进程。

图 6.11 kill命令
4、Ctrl + C情况如下,可以发现进程终止了运行。Ctrl + C会导致内核发送一个SIGNINT信号到前台进程组的每个进程,其默认情况时终止前台作业。
此时可以看到该进程已经被回收,进程表无hello进程。

图 6.12 CTRL + C
6.7本章小结
异常控制流发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制。
在硬件层,异常是由处理器中的事件触发的控制流中的突变。控制流传递给一个软件处理程序,该处理程序进行一些处理,然后返回控制给被中断的控制流。
有四种不同类型的异常:中断、故障、终止和陷阱。当一个外部1/О设备(例如定时器芯片或者磁盘控制器)设置了处理器芯片上的中断管脚时,(对于任意指令)中断会异步地发生。控制返回到故障指令后面的那条指令。一条指令的执行可能导致故障和终止同步发生。故障处理程序会重新启动故障指令,而终止处理程序从不将控制返回给被中断的流。最后,陷阱就像是用来实现向应用提供到操作系统代码的受控的入口点的系统调用的函数调用。
在操作系统层,内核用ECF提供进程的基本概念。进程提供给应用两个重要的抽象:1)逻辑控制流,它提供给每个程序一个假象,好像它是在独占地使用处理器,2)私有地址空间,它提供给每个程序一个假象,好像它是在独占地使用主存。
在操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或者终止,运行新的程序,以及捕获来自其他进程的信号。信号处理的语义是微妙的,并且随系统不同而不同。
(第6章1分)

第7章 hello的存储管理
7.1 hello的存储器地址空间
7.11逻辑地址
逻辑地址是CPU所生成的地址。逻辑地址是内部和编程使用的、并不唯一。例如,你在进行C语言指针编程中,可以读取指针变量本身值(&操作),实际上这个值就是逻辑地址,它是相对于你当前进程数据段的地址(偏移地址),不和绝对物理地址相干。是由段地址和偏移地址构成的,表示为[段标识符:段内偏移量]。
例如:23:8048000 段寄存器(CS等16位):偏移地址(16/32/64);
实模式下:逻辑地址CS:EA —>物理地址CS*16+EA;
保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址, 段地址+偏移地址=线性地址。
7.1.2线性地址
线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。通过逻辑地址变换就可以生成一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。
7.1.3虚拟地址
虚拟内存被组织成一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址,作为到数组的索引。空间为N = 2n 个虚拟地址的集合,例如{0,1,2,3,….,N-1}。
7.1.4物理地址
物理地址是加载到内存地址寄存器中的地址,内存单元的真正地址。在前端总线上传输的内存地址都是物理内存地址,编号从0开始一直到可用物理内存的最高端。这些数字被北桥(Nortbridge chip)映射到实际的内存条上。物理地址是明确的、最终用在总线上的编号,不必转换,不必分页,也没有特权级检查(no translation, no paging, no privilege checks)。空间为M = 2^m 个物理地址的集合,例如{0,1,2,3,….,M-1}。
Intel采用段页式存储管理(通过MMU)实现:
·段式管理:逻辑地址—>线性地址==虚拟地址;
·页式管理:虚拟地址—>物理地址。
以hello中的puts调用为例:mov $0x400714,%edi callq 4004a0,$0x400714为puts输出字符串逻辑地址中的偏移地址,需要经过段地址到线性地址的转换变为虚拟地址,然后通过MMU转换为物理地址,才能找到对应物理内存。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址的表示形式为[段标识符:段内偏移量],这个表示形式包含完成逻辑地址到虚拟地址(线性地址)映射的信息。
7.2.1 段标识符
段标识符又名段选择符,是一个16位的字段,如下图所示,其包括一个13位的索引字段,1位的TI字段和2位的RPL字段。

图 7.1 段标识符
索引字段用于确定当前使用的段描述符在描述符表中的位置,即段选。
RPL字段用于表示CPU的当前特权级,RPL=00为第0级,位于最高级的内核态;RPL=11为第3级,位于最低级的用户态。
TI字段用于选择描述符表,TI-0,选择全局描述符表;TI=1,选择局部描述符表。
7.2.2 段描述符与段描述符表
段描述符是一种数据结构,实际上就是段表项,分为两类:
①用户的代码段和数据段描述符
②系统控制段描述符,又分两种:特殊系统控制段描述符,包括:局部描述符表(LDT)描述符和任务状态段(TSS)描述符;控制转移类描述符,包括:调用门描述符、任务门描述符、中断门描述符和陷阱门描述符
描述符表实际上就是段表,由段描述符(段表项)组成。有三种类型
①全局描述符表GDT:只有一个,用来存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及TSS(任务状态段)等都属于GDT中描述的段
②局部描述符表LDT:存放某任务(即用户进程)专用的描述符
③中断描述符表IDT:包含256个中断门、陷阱门和任务门描述符
7.2.3 逻辑地址到线性地址的变换
如下图所示,逻辑地址到虚拟地址的转换主要分为以下步骤
①逻辑地址被分割为16位的段选择符与32位的段内偏移量
②根据段选择符的TI字段,选择全局描述符表或局部描述符表
③根据段选择符的索引字段,选择描述符表中的段描述符,其中包含被选段的基地址
④将被选段的基地址与段内偏移量相结合,得到32位虚拟地址

图 7.2 逻辑地址到线性地址的变换
7.3 Hello的线性地址到物理地址的变换-页式管理
形式上来说,地址翻译是一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)中元素之间的映射,

图 7.3 VAS和PAS的映射
其中

图 7.4 MAP
图7.3展示了MMU如何利用页表来实现这种映射。CPU中的一个控制寄存器,页表基址寄存器(Page Table Base Register,PTBR)指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(Virtual Page Offset,VPO)和一个(n一p)位的虚拟页号(Virtual Page Number,VPN)。MMU利用VPN来选择适当的PTE。例如,VPN 0选择PTE 0,VPN 1选择PTE1,以此类推。将页表条目中物理页号(Physical Page Number,PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是Р字节的,所以物理页面偏移(Physical Page Offset,PPO)和 VPO是相同的。

图 7.5 使用页表的地址翻译
图7.6-a展示了当页面命中时,CPU硬件执行的步骤。
第1步:处理器生成一个虚拟地址,并把它传送给MMU。
第2步:MMU生成PTE地址,并从高速缓存/主存请求得到它。
第3步:高速缓存/主存向MMU返回PTE。
第4步:MMU构造物理地址,并把它传送给高速缓存/主存。
第5步:高速缓存/主存返回所请求的数据字给处理器。
页面命中完全是由硬件来处理的,与之不同的是,处理缺页要求硬件和操作系统内核协作完成,如图7.6-b 所示。

图 7.6 页面
第1步到第3步:和图7.6-a中的第1步到第3步相同。
第4步:PTE中的有效位是零,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
第5步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。
第6步:缺页处理程序页面调人新的页面,并更新内存中的PTE。
第7步:缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,在 MMU执行了图7.6-b中的步骤之后,主存就会将所请求字返回给处理器。
7.4 TLB与四级页表支持下的VA到PA的变换
用来压缩页表的常用方法是使用层次结构的页表。用一个具体的示例是最容易理解这个思想的。假设32位虚拟地址空间被分为4KB的页,而每个页表条目都是4字节。还假设在这一时刻,虚拟地址空间有如下形式:内存的前2K个页面分配给了代码和数据,接下来的6K个页面还未分配,再接下来的1023个页面也未分配,接下来的1个页面分配给了用户栈。图9-17展示了我们如何为这个虚拟地址空间构造一个两级的页表层次结构。
一级页表中的每个PTE负责映射虚拟地址空间中一个4MB的片(chunk),这里每一片都是由1024个连续的页面组成的。比如,PTE0映射第一片,PTE1映射接下来的一片,以此类推。假设地址空间是4GB,1024个PTE已经足够覆盖整个空间了。
如果片i中的每个页面都未被分配,那么一级PTEi就为空。例如,图7-17中,片2~7是未被分配的。然而,如果在片i中至少有一个页是分配了的,那么一级PTEi就指向一个二级页表的基址。例如,在图7.7中,片0、1和8的所有或者部分已被分配,所以它们的一级PTE就指向二级页表。

图 7.7 一个两级页表层次结构。注意地址是从上往下增加的

二级页表中的每个PTE都负责映射一个4KB的虚拟内存页面,就像我们查看只有一级的页表一样。注意,使用4字节的PTE,每个一级和二级页表都是4KB字节,这刚好和一个页面的大小是一样的。
这种方法从两个方面减少了内存要求。第一,如果一级页表中的一个PTE是空的,那么相应的二级页表就根本不会存在。这代表着一种巨大的潜在节约,因为对于一个典型的程序,4GB的虚拟地址空间的大部分都会是未分配的。第二,只有一级页表才需要总是在主存中;虚拟内存系统可以在需要时创建、页面调人或调出二级页表,这就减少了主存的压力﹔只有最经常使用的二级页表才需要缓存在主存中。

图 7.8 使用k级页表的地址翻译
图7.8描述了使用k级页表层次结构的地址翻译。虚拟地址被划分成为k个VPN和1个 VPO。每个VPN 主都是一个到第i级页表的索引,其中 1≤i≤k。第j级页表中的每个PTE,1≤j≤k一1,都指向第j+1级的某个页表的基址。第k级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE。对于只有一级的页表结构,PPO和VPO是相同的。
7.5 三级Cache支持下的物理内存访问
此时我们已经得到了物理地址,首先我们利用其中的CI进行组索引,得到匹配的组后,按照标志位CT的内容进行匹配,如果匹配成功并且有效位为1,则命中,即可按照偏移量取出数据。如果不命中,就要向下一级的cache寻找数据,三级都没有,则要向内存中寻找。找到之后更换cache中的空闲块,若没有空闲块,则需要根据自己的策略来驱逐一个块来更新

图7.9 cache下的内存访问

7.6 hello进程fork时的内存映射
Linux通过将一个虚拟内存区域与一个磁盘上的对象(object)关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)。虚拟内存区域可以映射到两种类型的对象中的一种:

  1. Linux文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如–个可执行目标文件。文件区(section)被分成页大小的片,每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到CPU第一次引用到页面(即发射一个虚拟地址,落在地址空间这个页面的范围之内)。如果区域比文件区要大,那么就用零来填充这个区域的余下部分。
    2)匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在内存中的。注意在磁盘和内存之间并没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制零的页(demand-zero page)。
    无论在哪种情况中,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件(swap file)之间换来换去。交换文件也叫做交换空间(swap space)或者交换区域(swap area)。需要意识到的很重要的一点是,在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数。
    既然我们理解了虚拟内存和内存映射,那么我们可以清晰地知道fork函数是如何创建一个带有自己独立虚拟地址空间的新进程的。
    当fork 函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
    当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
    7.7 hello进程execve时的内存映射
    虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。既然已经理解了这些概念,我们就能够理解execve函数实际上是如何加载和执行程序的。假设运行在当前进程中的程序执行了如下的execve调用:
    execve( “a.out”,NULL,NULL) ;
    正如在第8章中学到的,execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地替代了当前程序。加载并运行a.out需要以下几个步骤:
    1、删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
    2、映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。图7.10概括了私有区域的不同映射。
    3、映射共享区域。如果 a.out程序与共享对象(或目标)链接,比如标准C库 libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
    4、设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的人口点。
    下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换人代码和数据页面。

图7.10 加载器是如何映射用户地址空间的区域的
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页( page fault)。图7.11展示了在缺页之前我们的示例页表的状态。CPU引用了VP3中的一个字,VP 3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果 VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。

图7.11 VM缺页。对VP3中的字的引用会不命中,从而触发了缺页

接下来,内核从磁盘复制VP3到内存中的PP 3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP 3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。图7.12展示了在缺页之后我们的示例页表的状态。

图7.12 VM缺页之后,缺页处理程序选择VP4作为牺牲页,并从磁盘上用VP3的副本取代它。在缺页处理程序重新启动导致缺页的指令之后,该指令从内存中正确的读取数字,而不会产生异常。

虚拟内存是在20世纪60年代早期发明的,远在CPU-内存之间差距的加大引发产生SRAM缓存之前。因此,虚拟内存系统使用了和 SRAM缓存不同的术语,即使它们的许多概念是相似的。在虚拟内存的习惯说法中,块被称为页。在磁盘和内存之间传送页的活动叫做交换(swapping)或者页面调度( paging)。页从磁盘换入(或者页面调入)DRAM和从DRAM换出(或者页面调出)磁盘。一直等待,直到最后时刻,也就是当有不命中发生时,才换人页面的这种策略称为按需页面调度(demand paging)。也可以采用其他的方法,例如尝试着预测不命中,在页面实际被引用之前就换入页面。然而,所有现代系统都使用的是按需页面调度的方式。
7.9动态存储分配管理
动态内存分配器动态内存分配器维护者一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护,每个块要么是已分配的,要么是空闲的。分配器主要分为显式分配器和隐式分配器。
下面将介绍一些基本方法与策略
1、显式空间链表的堆块结构:将空闲块组织成链表形式的数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。

图7.13显示空闲链表

2、带边界标记的隐式空闲链表:一个块是由一个字的头部、有效载荷、可能的一些额外的填充,以及在块的结尾处的一个字的脚部组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。

图7.14带边界标记的隐式空闲链表

3、适配块策略有以下三种
首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块:可以取总块数(包括已分配和空闲块)的线性时间,但是会在靠近链表起始处留下小空闲块的“碎片”。
下一次适配:和首次适配相似,只是从链表中上一次查询结束的地方开始,优点是比首次适应更快:避免重复扫描那些无用块。但是一些研究表明,下一次适配的内存利用率要比首次适配低得多。
最佳适配:查询链表,选择一个最好的空闲块适配,剩余最少空闲空间,优点是可以保证碎片最小——提高内存利用率,但是通常运行速度会慢于首次适配。
4、合并策略则分为4种
在情况1中,两个邻接的块都是已分配的,因此不可能进行合并。所以当前块的状态只是简单地从已分配变成空闲。在情况2中,当前块与后面的块合并。用当前块和后面块的大小的和来更新当前块的头部和后面块的脚部。在情况3中,前面的块和当前块合并。用两个块大小的和来更新前面块的头部和当前块的脚部。在情况4中,要合并所有的三个块形成一个单独的空闲块,用三个块大小的和来更新前面块的头部和后面块的脚部。
5、链表的维护方式则有两种,一是后进先出的顺序,二是按照地址顺序来维护
6、最终我们采取策略的首要目的就是保证吞吐率和内存使用率的最大化
7.10本章小结
虚拟内存是对主存的一个抽象。支持虚拟内存的处理器通过使用一种叫做虚拟寻址的间接形式来引用主存。处理器产生一个虚拟地址,在被发送到主存之前,这个地址被翻译成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件通过使用页表来翻译虚拟地址,而页表的内容是由操作系统提供的。
虚拟内存提供三个重要的功能。第一,它在主存中自动缓存最近使用的存放磁盘上的虚拟地址空间的内容。虚拟内存缓存中的块叫做页。对磁盘上页的引用会触发缺页,缺页将控制转移到操作系统中的一个缺页处理程序。缺页处理程序将页面从磁盘复制到主存缓存,如果必要,将写回被驱逐的页。第二,虚拟内存简化了内存管理,进而又简化了链接、在进程间共享数据、进程的内存分配以及程序加载。最后,虚拟内存通过在每条页表条目中加入保护位,从而了简化了内存保护。
地址翻译的过程必须和系统中所有的硬件缓存的操作集成在一起。大多数页表条目位于L1高速缓存中,但是–个称为TLB的页表条目的片上高速缓存,通常会消除访问在Ll上的页表条目的开销。
现代系统通过将虚拟内存片和磁盘上的文件片关联起来,来初始化虚拟内存片,这个过程称为内存映射。内存映射为共享数据、创建新的进程以及加载程序提供了一种高效的机制。应用可以使用mmap函数来手工地创建和删除虚拟地址空间的区域。然而,大多数程序依赖于动态内存分配器,例如 malloc,它管理虚拟地址空间区域内一个称为堆的区域。动态内存分配器是一个感觉像系统级程序的应用级程序,它直接操作内存,而无需类型系统的很多帮助。分配器有两种类型。显式分配器要求应用显式地释放它们的内存块。隐式分配器(垃圾收集器)自动释放任何未使用的和不可达的块。

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m字节的序列:
B0,B1,B2……Bm
所有的 IO 设备(如网路、磁盘、终端)都被模型化为文件,而所有的输入和输出都被 当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数

  1. 接口的操作
    (1)打开文件:程序要求内核打开文件,内核返回一个小的非负整数(描述符),用于标识这个文件。程序在只要记录这个描述符便能记录打开文件的所有信息。
    (2)shell在进程的开始为其打开三个文件:标准输入、标准输出和标准错误。
    (3)改变当前文件的位置:对于每个打开的文件,内核保存着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作显式地设置文件的当前位置为k。
    (4)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会出发一个称为EOF的条件,应用程序能检测到这个条件,在文件结尾处并没有明确的EOF符号。
    (5)关闭文件:内核释放打开文件时创建的数据结构以及占用的内存资源,并将描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
  2. 函数
    (1)打开文件:int open(char* filename,int flags,mode_t mode)
    flag: O_RDONLY(只读),O_WRONLY(只写),O_RDWR(可读写)
    mode: 指定新文件的访问权限位
    返回值:成功则为文件描述符,失败则返回-1
    (2)关闭文件:int close(fd)
    返回值:成功则返回0,失败则返回-1
    (3)读文件ssize_t read(int fd,void *buf,size_t n)
    read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。
    返回值:成功则返回读的字节数,出错则返回-1,EOF返回0
    (4)写文件ssize_t wirte(int fd,const void *buf,size_t n)
    write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
    返回值:成功则返回读的字节数,出错则返回-1
    8.3 printf的实现分析
  3. printf函数体

图8.1 printf源代码

观察可知首先arg将获得第二个不定长参数,即输出的时候格式化串对应的值。接着函数调用了vsprintf函数,我们查看其代码
2. vsprintf函数

图8.2 vsprintf函数

在printf中调用系统函数write(buf, i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,intINT_VECTOR_SYS_CALLA代表通过系统调用syscall。
3.write函数

图8.3 write函数
在printf中调用系统函数write(buf, i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
4.查看sys_call函数

图8.4 sys_call函数

syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。于是我们的打印字符串就显示在了屏幕上。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用int 0x80或syscall。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
经过上述步骤后,字符串“Hello 1190202420 孟子恒”便显示在了屏幕上。
8.4 getchar的实现分析
读取键盘缓冲区中的一个字符(读取上一次被读取的字符的下一个字符),若没有字符可读,则等待用户键入并回车后再执行下一步(回车也算一个字符,因而getchar也会读缓冲区里的回车)。
图8.5 getchar函数

异步异常-键盘中断的处理:read函数同样通过sys_call中断来调用内核中的系统函数。键盘中断处理子程序会接受按键扫描码并将其转换为ASCII码后保存在缓冲区。然后read函数调用的系统函数可以对缓冲区ASCII码进行读取,直到接受回车键返回。
8.5本章小结
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数的实现。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
一个简简单单的hello程序的一生如同一个婴儿的一生,看似平凡,但它代码生涯中的每一步都包含着程序员爸爸妈妈的呕心沥血。我们来回顾一下如同亲生子般的hello程序的一生吧。首先,hello诞生于身为程序员的我们使用的C语言代码。可是仅仅的C语言是一种高级语言,并不能被我们爱恨交加的计算机底层所能接受。因此小小的hello文件将经过它的第一个幼年期——预处理,成为了hello小朋友hello.i,虽然hello长大了一些,可是它和原来的hello还是区别不大,什么都做不了,hello需要经过它的青年期——编译器的磨练,成为青年hello:hello.s。此时,hello已经完全脱胎换骨了,从一个简简单单的高级语言慢慢转变为了由机械语言的文本模式——汇编语言了!hello渐渐被它的计算机底层爷爷所认可,可是毕竟还是存在代沟,hello.s身上仍然有一些年幼的品质没有消失,需要汇编器的进一步帮忙——帮助hello.o重定位,生成hello.s文件!此时的hello.o已经成长为hello先生了,需要结婚生子,计算机底层爷爷也想看看hello先生的妻子和孩子,hello先生请了链接器帮忙,帮助hello.o引用的符号进行解析和重定位,最终成为了可执行文件hello!!!
当hello从一个婴儿变成了hello先生,hello终于可以在底层运行啦!此时我们在终端运行hello的可执行文件,shell程序会调用fork为其创建新的进程,再用execve函数执行它。内核会为它加载映射虚拟内存,为hello创建新的堆栈段,cpu将为其分配时间片,在自己的时间片里,hello顺序执行自己的逻辑控制流。运行过程中,同时也会调用一些函数,例如printf函数,这些函数与linux I/O的设备模拟化密切相关,同时还会碰到一些拦路虎——异常与信号,计算机底层帮助hello准备了一系列处理方法,使得hello成功被执行!!!
花有空开日,人无在少年。hello的一生是如此丰富多彩,可终有老去的一天,随着一行行代码终究被执行,hello也即将迎来它代码生涯的重点——被shell回收,它所创建的一切都将被内核所删除,留下来的,只有我们这些程序员丢落的青丝几缕。
感悟:学习的知识越多,越能认识到自己的视野是由多么短浅,当我学完C语言,认为hello只是一个很普通不过的程序,现在看来,小小一个hello文件,就是我一个学期都无法琢磨透的庞然大物。这门课的学习,让我对计算机系统的底层有了更加深刻的认识,明白了早年程序员为了实现计算机底层的功能优化付出的心血与汗水。同时我也明白了实践的重要性,纸上得来终觉浅,绝知此事要躬行,实验和课堂完全是两码事,实验部分的设计,让我对课堂的知识有了更深入的理解,也提高了我的动手能力!

(结论0分,缺失 -1分,根据内容酌情加分)

附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.c 源文件,本实验的开始
hello.i 经预处理器预处理的源文件,用于研究预处理器功能
hello.s 汇编代码文件,用于研究编译器功能
hello.o 可重定位目标文件,用于研究汇编器功能
hello 可执行目标文件,用于研究链接器功能
hello_o_asm 反汇编文件,用于研究hello.o的反汇编
hello_asm 反汇编文件,用于研究hello的反汇编

参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
(参考文献0分,缺失 -1分)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值