程序人生-Hello’s P2P

摘 要
计算机系统是由硬件和系统软件组成的,他们共同工作来运行应用程序。虽然系统的具体实现方式随着时间不断变化,但是系统内在的概念却没有改变。所有计算机系统都有相似的硬件和软件,它们又执行着相似的功能。
我们通过跟踪hello程序的生命周期来回顾我们对计算机系统的学习——从它被程序员创建开始,到系统上运行,输出简单的消息然后终止。我们将沿着这个程序的生命周期,探讨计算机系统中一些概念和原理。

关键词:预处理;编译;汇编;链接;进程管理;异常和信号;存储器体系结构,IO管理。

(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)

目 录

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

第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:编译器驱动程序(compiler driver)代表用户在需要时调用语言预处理器cpp预处理,编译器cc1编译,汇编器as汇编和链接器ld之后成为可执行程序hello,在shell中键入启动命令后,shell fork产生一个新的子进程,并在该子进程中调用execve,加载并执行hello程序 。
020:在shell中键入启动命令后,shell fork产生一个新的子进程,并在该子进程中调用execve,加载并执行hello程序 ,为其映射虚拟内存,把进程的上下文中的程序计数器指向代码区域的入口点。CPU为运行的hello进程分配时间片,执行逻辑控制流。当hello进程运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。
1.2 环境与工具
硬件环境:X64 CPU ,1.80GHz,16G RAM
软件环境:Windows 10 64位 ,Vmware 14 ,Ubuntu 16.04 LTS 64 位
开发工具:gcc,gdb,edb,readelf,objdump
1.3 中间结果
hello.c :hello源代码
hello.i :预处理后的文本文件
hello.s :hello.i编译后的汇编文件
hello.o :hello.s汇编后的可重定位目标文件
hello :链接后的可执行文件
hello.o_d-r:hello.o的反汇编代码(objdump -d -r hello.o得到)
hello.o_dxs:hello.o的反汇编代码(objdump -dxs hello.o得到)
hello.o_elf:hello.o的Readelf结果(readelf -a hello.o 得到)
hello_dxs:hello的反汇编代码(objdump -dxs hello得到)
hello_elf:hello.o的Readelf结果(readelf -a hello 得到)
1.4 本章小结
本章对hello进行了简单的介绍,分析了其P2P和020的过程。列出了本次任务的环境和工具,解释了过程中出现的中间产物及其作用。
(第1章0.5分)

第2章 预处理
2.1 预处理的概念与作用
预处理是指进行编译的第一遍扫描之前所做的工作,由预处理器cpp负责完成。C语言提供了多种预处理功能,包括宏定义,文件包含,条件编译。

  1. 宏定义
    在C语言源程序中允许一个标识符来表示一个字符串,称为“宏”;被定义为“宏”的标识符称为“宏名”。在编译程序中,对程序所有出现的“宏名”,都用宏定义中的字符取代换,又被称为“宏替代”或“宏代换”。宏定义是有源程序中的宏定义命令完成的。宏代换是由预处理程序自动完成的。在C语言中,宏分为有参数和无参数两种。
    (1)无参数宏
    无参数宏定义的一般形式为:#define 标示字符串
    其中:“#”代表是编译预处理命令,define是宏处理的关键词,标识符是宏名。字符串是宏名所代替的内容,也可以是常数,表达式等。宏定义是用宏名表示一个字符串,在宏展开时又以该字符串取代宏,是一种简单的替换。
    (2)带参数宏
    带参数宏定义中,宏名和形参之间不能有空格出现。在带参宏定义中,形参不分配内存单元,因此,不作为类型主义,宏调用参数的实参有具体的值。去代替形参,形参和实参是两个不同的量。在带参宏中,只是符号代换,不存在值传递。
  2. 文件包含
    文件包含命令行的一般形式是: #incude “文件名”
    文件包含命令功能是指定文件插入该位置取代该命令行,从而把指定文件和当前源文件程序连成一个源文件。
  3. 条件编译
    程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。条件编译指令将决定哪些代码被编译,而哪些不被编译的。可以根据表达式的值或者某个特定的宏是否被定义来确定编译条件。

2.2在Ubuntu下预处理的命令
gcc -E -o hello.i hello.c

图 1 预处理

2.3 Hello的预处理结果解析
经过预处理,29行的hello.c生成了3119行的hello.i,hello.c中的#include<stdio.h>命令预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。我们可以在hello.i的末尾部分找到hello.c
2.4 本章小结
本章我们探讨了预处理过程,了解了预处理提供的三大功能:宏定义,文件包含和条件编译。在这个阶段,我们将hello.c通过预处理转换成了hello.i文件。
hello.c的预处理阶段中做的工作就是处理
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
将三个头文件插入hello.c文件中生成hello.i文件。

(第2章0.5分)

第3章 编译
3.1 编译的概念与作用
这个阶段编译器主要做词法分析、语法分析、语义分析等,在检查无错误后后,编译器(ccl)把代码翻译成汇编语言。编译器将ASCII文本文件hello.i 翻译成ASCII文本文件hello.s,hello.s中的汇编语言语句以一种文本格式描述了低级机器语言指令
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s

图 2 编译
3.3 Hello的编译结果解析

图 3  Hello的编译结果解析

3.3.1 数据
(1)整型变量:

  1. int sleepsecs=2.5;在C程序中sleepsecs是已初始化的全局变量,存放在.data段。sleepsecs被定义为全局变量globl,在.data段中,sleepsecs被设置为对齐方式是4,类型是object,大小是4字节。
  2. int argc;argc作为局部变量存储在栈或者寄存器中,argc是函数传入的第一个int型参数,存储在%edi中。
    3.int i;i作为局部变量存储在栈或者寄存器中。i存放在-4(%rbp)的位置,movl $0, -4(%rbp)将i初始化为0。

(2)常量:常量以立即数的形式出现,如图4。

图 4 常量的出现形式
(3)字符串:argv[1]和argv[2]是指向从命令行读入的字符串。
printf函数的标准字符串存储在.rodate段中,如图5

图 5 字符串的出现形式

3.3.2 赋值
(1) 对全局变量sleepsecs的赋值,因为sleepsec是int类型,将2.5强制类型转换之后赋初值为2:

图 6 对全局变量sleepsecs的赋值
(2) 对局部变量i的赋值:使用movl语句,对应于C程序中i=0;

图 7 对局部变量i的赋值
3.3.3 类型转换
int sleepsecs=2.5;中2.5是float类型,sleepsecs是int类型,2.5隐式转换成int类型的2,sleepsecs被赋值为2,说明在赋值时发生了隐式类型转换。

3.3.4 算数操作

实现了i++.
3.3.5 关系操作

  1. argc存放在-20(%rbp)的位置,通过如下汇编代码实现if(argc!=3)

    1. i存放在-4(%rbp)的位置,通过如下汇编代码实现判断for(i=0;i<10;i++)中的i<10

3.3.6 数组/指针/结构操作
指针数组:char *argv[]:argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别指向另外两个用户输入的字符串。
当main开始执行时,用户栈的组织结构如图8所示:

图 8 用户栈

将argv[0]移入%rax

将argv[1]移入%rax和%rsi
3.3.7 控制转移
1.if(argc!=3)
{
printf(“Usage: Hello 学号 姓名!\n”);
exit(1);
}
if(argc!=3) 的转移控制由如下汇编代码实现

cmpl语句根据两个操作数之差来设置条件码,他们只设置条件码而不代编任何其他的寄存器。cmpl语句比较 -20(%rbp)(之前拷贝了一份argc在-20(%rbp)中)和3,设置条件码。je判断ZF标志位,如果cmpl操作使得ZF标志位为0,则跳到.L2中,否则顺序执行下一条语句。

2. for(i=0;i<10;i++)的转移控制由如下汇编代码实现

首先初始化控制变量i为0,然后跳转到.L3处的比较代码,使用 cmpl 进行比较,如果 i<=9,则跳入.L4 循环体执行,否则说明循环结束,顺序执行 for 之后的指令。
3.3.8 函数操作
1.main函数:
参数传递:传入参数argc和argv,分别用寄存器%rdi和%rsi存储。
函数调用:被系统启动函数调用。
函数返回:设置%eax为0并且返回,对应return 0 。
2.printf函数:
参数传递:调用printf之前传入要被打印字符串的首地址
if循环体中的printf函数对应call puts,调用时传入了字符串参数首地址;
for循环中调用printf时分别传入了 argv[1]和argc[2]的地址。
函数调用:在if体中可能被调用,在for循环中被调用
函数返回:返回一个int值,表示被打印的字符数。
3.exit函数:
参数传递:传入的参数为1
函数调用:if判断条件满足后被调用
4.sleep函数:
参数传递:传入参数sleepsecs,传递控制call sleep
函数调用:for循环下被调用
函数返回:返回一个int值,表示剩余的睡眠时间
5.getchar
函数调用:在main中被调用
函数返回:getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1。
3.4 本章小结
编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。
(第3章2分)

第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将hello.s翻译成机器语言指令,把这些二进制字节的集合以ELF格式打包成可重定位目标程序,并将结果保存在目标文件hello.o中。
典型的ELF可重定位目标文件的结构如图9所示:
图 9 ELF可重定位目标文件的结构
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o

图 10 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式
4.3.1 ELF头描述了生成该文件的系统的字的大小和字节顺序,并且包含帮助链接器语法分析和解释目标文件的信息

图 11 可重定位目标ELF头

4.3.2 节头部表描述了不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。具体的描述包括节的名称、类型、地址和偏移量等。

图 12 可重定位目标节头部表
4.3.3符号表
.symtab存放在程序中定义和引用的函数和全局变量的信息。用readelf查看hello.o的符号表如图13:

图 13 可重定位目标符号表

4.3.4重定位条目:
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行目标文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。
ELF重定位条目的数据结构如下:
typedef struct{
long offset; /需要被修改的引用的节偏移/
long type:32, /重定位类型/
symbol:32; /标识被修改引用应该指向的符号/
long attend; /符号常数,对修改引用的值做偏移调整/
}Elf64_Rela;

图 14 可重定位目标.rela.text
我们用objdump查看hello.o的反汇编(可重定位条目存放在rel.text和rel.data段,但是objdump为了方便查看将仓可重定位条目放在需要被重定位的引用后面),可以看到未重定位符号的重定位条目。

图15 objdump查看hello.o的反汇编

4.4 Hello.o的结果解析
hello.s文件中的汇编指令和和反汇编代码中的汇编指令主要有以下的差别:

  1. 操作数:hello.s中的操作数是十进制,hello.o反汇编代码中的操作数是十六进制。

图 16 hello.s中的操作数

图 17 hello.o反汇编的操作数

  1. 分支转移:跳转语句之后,hello.s中是标识符,而反汇编代码中跳转指令之后是相对偏移的地址。
    图 18 hello.s中的分支转移

图19 hello的反汇编中的分支转移
3.函数调用:hello.s中,call指令之后直接是函数名称,而反汇编代码中call指令之后是函数的相对偏移地址。因为函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目(可重定位条目存放在rel.text和rel.data段,但是objdump为了方便查看将仓可重定位条目放在需要被重定位的引用后面)。

图20 hello.s中的函数调用

图21 hello的反汇编中的函数调用

4.全局变量的访问:在hello.s文件中,对于.rodata和sleepsecs等全局变量的访问,是$.LC0和sleepsecs(%rip),而在反汇编代码中是$0x0和0(%rip),是因为它们的地址也是在连接后时确定的,因此引用需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。

图22 hello.s中对全局变量的访问
图 23 hello.o反汇编中对全局变量的访问
图 24 hello.s和hello的反汇编文件对比
4.4 本章小结
汇编就是将hello.s汇编程序翻译成机器语言指令,把这些指令打包成ELF格式的可重定位目标程序hello.o的过程。在这章我们用READELF工具查看了hello.o的信息。通过hello.o获得反汇编代码,并且与hello.s文件进行了对比。发现反汇编后分支转移,函数调用,全局变量访问的方式有所不同,进而间接了解到从汇编语言映射到机器语言汇编器实现了的转换。
(第4章1分)

第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。
作用:链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。
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

图 25 Ubuntu下链接命令

5.3 可执行目标文件hello的格式
可执行目标文件hello的格式类似于可重定位目标文件的格式,ELF头描述文件的总体格式。它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。.text、.rodata和.data节与可重定位目标文件中的节是类似的,除了这些节已经被重定位到它们最终的运行时的内存地址外。.init节定义了一个小函数_init,程序初始化代码会调用它。因为可执行文件时完全连接的,所以无.rel节。
图26 hello ELF格式中的 Section Headers

5.4 hello的虚拟地址空间

图 27 hello ELF中的符号

图 28 运行时虚拟地址内的符号
对比发现,程序从0x400000开始载入,各个节的地址(虚拟地址)和5.3节中ELF中声明的地址相同。各个符号在程序运行时的地址(虚拟地址)也和ELF中声明的地址相同。
5.5链接的重定位过程分析
hello与hello.o的反汇编文件区别:

  1. 链接增加新的函数:
    在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数。
    2.增加的节:
    hello中增加了.interp节,.hash节,.got.plt节,.dynsym节,.dynstr节,.rela.dyn 节,.rela.plt .init节,.plt节, .fini节,.got节,和.got.plt节等节,这些节大部分是为动态链接而服务的。plt(过程链接表)配合got(全局偏移量表)使用,能够通过延迟绑定机制实现对共享库中函数动态的重定位。
    消失的节:hello中无hello.o中的rel.data和rel.text节,因为这些在重定位时已经使用过了,在hello可执行文件中也就没有存在的必要了。
    3.对符号的引用:
    3.1函数调用:
    hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
    跳转地址和函数调用的地址在hello中都变成了虚拟内存地址。
    3.2对全局变量的引用:
    hello.o文件中对于.rodata和sleepsecs等全局变量的引用,是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此引用需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。
    而在hello文件中,对全局变量的引用变成了进程运行时的虚拟地址。

重定位:
链接器在完成符号解析以后,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。然后就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时的地址。
在hello.o到hello中,首先是重定位节和符号定义,链接器将所有输入到hello中相同类型的节合并为同一类型的新的聚合节。例如,来自所有的输入模块的.data节被全部合并成一个节,这个节成为hello的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每一个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
然后是重定位节中的符号引用,链接器扫描重定位条目,对每个重定位条目执行如图29所示的重定位算法,修改hello中的代码节和数据节中对每一个符号的引用,使得他们指向正确的运行地址。在共享库中的函数,由于库的地址是随机加载的,在运行前无法知道其确切地址,只有通过动态链接延迟绑定机制实现。
图 29 重定位算法的伪代码描述
5.6 hello的执行流程
</lib/x86_64-linux-gnu/ld-2.27.so> 0x7f0be404a630
<ld-2.27.so!dl start+0> 0x7f0be403bea0
<ld-2.27.so!dl init+0> 0x7f0be404a630
<hello! start+0> 0x400500
<libc-2.27.so! libc start main+0> 0x7f879d416ab0
<hello!main+2b> 0x400612
hello!puts@plt 0x4004b0
hello!exit@plt 0x4004e0

5.7 Hello的动态链接分析
对于动态共享链接库中 PIC 函数,编译器没有办法预测函数的运行时地址,所 以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代 码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表 PLT+全局偏移量 表 GOT 实现函数的动态链接,GOT 中存放函数目标地址,PLT 使用 GOT 中地址跳转到目标函数。
下面是程序在run前后的(即执行dl_init函数前后)的GOT表变化情况:

图30 GOT表位置
图31 dl_init之前的GOT表

图32 dl_init之后的GOT表
5.8 本章小结
本章主要介绍了链接的概念与作用:是将各种代码和数据片段收集并组合成为一个单一文件的过程,探索了在连接前后可重定位目标文件hello.o与可执行目标文件hello ELF格式的差别,分别阅读hello.o和hello的反汇编代码,反向推理了在重定位时连接器进行的工作,着重研究了重定位的过程,比较了可执行文件ELF声明的地址了程序实际运行时的地址。通过edb的调试运行,了解了动态链接的一些细节。
(第5章1分)

第6章 hello进程管理
6.1 进程的概念与作用
进程的一个经典定义就是一个执行中程序的实例。
系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。在现代系统上运行一个程序时,我们会得到一个假象,我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell:shell是一个交互型的应用级程序,它代表用户运行其他程序,是用户和系统内核沟通的桥梁。用户可以通过shell向操作系统发出请求,操作系统选择执行命令。
处理流程:
(1)从终端读入输入的命令。
(2)调用parseline函数将输入字符串切分,构建argv参数数组,并判断是前台运行还是后台运行。
(3)如果是内置命令则立即执行
(4)否则调用相应的程序为其分配子进程并运行
(5)shell 应该接受键盘输入信号,并对这些信号进行相应处理
6.3 Hello的fork进程创建过程
在shell终端键入./hello 1180801003 李威东 ,shell读入我们从终端键入的命令行,调用parseline函数,构建argv数组,其中argv[0]指向./hello字符串,argv[1]指向1180801003字符串,argv[2]指向李威东字符串,parseline返回0,表明这是一个前台运行的进程。判断出这个命令不是内置命令之后,shell会认定这是当前目录文件下的可执行目标文件hello,终端调用fork函数创建一个子进程,新的子进程几乎但不完全和父进程相同,子进程可以得到父进程用户级虚拟地址空间相同(但独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈,子进程还获得与父进程任何打开文件描述符相同的副本,意味着子进程可以读写父进程中打开的任何文件。他们最大的不同是拥有不同PID。
我们从虚拟内存的角度再来看fork函数,当fork函数被当前进程调用时,内核为新的进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新的进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为写时私有复制。当fork在新的进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的寻你内存相同。当这两个进程中的任意一个后来进行写操作的时候,写时复制机制就会创建新的页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
父进程和子进程是并发运行的独立进程,内核可以任意方式交替执行他们的逻辑控制流中的指令,所以这会导致我们不能简单的凭直觉判断指令执行的顺序。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。在fork创建的子进程中调用execve函数加载运行我们的hello可执行文件。execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序。
我们从虚拟内存的角度来看execve函数,execve加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。
2.映射私有区域。为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时复制的。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。
3.映射共享区域。比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。execve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点,也就是_start 地址,_start 最终调用 hello 中的 main 函数。
除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到 CPU 引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
图33 用户进程的地址空间
6.5 Hello的进程执行
逻辑控制流:一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供这样一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常是用某个控制寄存器的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。用户程序必须通过系统调用接口间接地访问内核代码和数据。
上下文:系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

上下文切换的过程:
(1)保存当前进程的上下文
(2)恢复现在调度进程的上下文
(3)将控制传给新恢复进程
引起上下文切换的情况主要有:
(1)当内核代表用户执行系统调用时可能会发生系统调用,可能会发生上下文切换。如果系统调用因为某一个事件而发生阻塞,那么内核可以让当前进程休眠,切换另一个进程,一般来说,即使系统调用没有因为等待某个时间而阻塞,内核也可以决定执行上下文的切换,而不是将控制返回给调用进程。sleep函数会显示请求调用进程休眠,此时内核会把调用进程暂时挂起,切换到另一个进程。
(2)中断也可能引发上下文切换。比如,所有的系统都有某种产生特定周期性定时器终端的机制。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到另一个新的进程。

图34 由定时器引起的上下文切换
之前我们提到,execve设置好了hello进程的上下文,而且hello进程是运行在用户模式下的。hello中的sleep函数会显示请求调用进程休眠,此时内核会把hello进程暂时挂起,切换到另一个进程。hello 进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时(2.5secs)发送一个中断信号,此时进入内核状态执行中断处理,将 hello 进程从等待队列中移出重新加入到运行队列,hello进程就可以继续进行自己的控制逻辑流了。
当hello调用getchar的时候会调用系统调用read。在调用read之后从用户模式陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的 DMA 传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。磁盘取数据要用一段相对较长的时间,所以内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,磁盘一个中断信号,表示数据已经从磁盘传送到了内存,此时内核从其他进程进行上下文切换回 hello进程。
图35 由read引起的上下文切换
6.6 hello的异常与信号处理
hello执行过程中可能会出现的异常:
中断: 进程收到信号SIGTSTP(由Ctrl+Z发出),默认行为是停止直到下一个SIGCONT。
终止:进程收到信号SIGINT(由Ctrl+C发出),默认行为是终止。
(1)如图,是正常执行 hello 程序的结果,当程序执行完成之后,hello进程被回收。

图36 hello 正常运行

(2)如图,是在hello进程运行时按下Ctrl+Z,之后再调用fg命令的情况。
Ctrl+Z向shell进程发送一个SIGSTP信号。shell父进程收到 SIGSTP 信号,SIGSTP信号处理函数的逻辑是将接收到SIGSTP信号的进程信息打印屏幕、将前台进程hello挂起。
通过ps 命令,我们可以看出 hello 进程没有被回收,它的后台 job 号是1。
通过fg 命令,我们向hello进程所在进程组发送一个SIGCONT信号,并将其转化为前台进程运行。我们发现hello进程继续打印剩下的字符串,并在执行getchar后正常结束。hello进程结束后,向其父进程shell发送SIGCHLD信号,shell的SIGCHILD信号处理程序将其回收,删除记录。再次使用jobs和ps命令,我们发现不再存在hello进程。

图37 在hello进程运行时按下Ctrl+Z
(3)如图,是在hello进程运行时按下Ctrl+C的情况。Ctrl+C向shell发送一个SIGINT信号。shell进程收到 SIGINT 信号,shell的SIGINT信号处理函数的逻辑是向前台进程组发送SIGINT信号。因为我们的hello进程运行在前台,所以hello进程会收到shell发来的SIGINT信号,我们的hello进程并没有设置SIGINT的信号处理程序,默认行为是程序终止。hello进程终止之后向其父进程发送SIGCHLD信号,shell的SIGCHILD信号处理程序将其回收,删除记录。使用jobs和ps命令,我们发现不再存在hello进程。

图38 在hello进程运行时按下Ctrl+Z
(4)如图,是我们调用kill命令来向hello进程发送SIGCONT信号的情况。

图39 向挂起的hello进程发送SIGCONT
我们执行hello程序,在hello程序执行时按下Ctrl+Z将hello进程挂起,并将hello进程转化为后台进程。我们在shell中用ps指令获得hello进程的pid,调用kill指令向hello进程发送SIGCONT信号。我们发现hello进程接收到SIGCONT之后会继续向屏幕打印信息,但是在结束之后,我们用ps指令发现hello进程仍然在后台挂起,这是因为我们直接向hello进程发送SIGCONT不像shell 对fg命令的执行逻辑,fg命令不仅将后台进程转换到前台进程运行,而且向后台进程发送了SIGCONT信号,而我们用kill指令只是向hello进程发送了SIGCONT信号,shell仍然认为hello运行在后台。我们调用fg指令,将其转化到前台运行,发现其能正常结束。再次调用ps指令,我们发现hello进程已经被shell回收。
(5)如图,我们调用kill发送SIGINT信号hello进程的情况。通过kill -9 63083杀死pid为63083的进程。我们用ps指令查看,发现shell可以正确地回收被终止的hello进程。

图40 向挂起的hello进程发送SIGINT
(6)如图,是我们在hello进程运行时乱按的情况。我们发现hello进程仍然能够正确运行。在最后getchar时候会读入我们之前的输入,剩下的输入会被当做命令行处理。

图41 hello运行时乱按

(7)如图,是我们用Ctrl+Z挂起hello进程后调用pstree指令的部分截图。

图42 pstree
6.7本章小结
本章介绍了hello进程是如何在的计算机中运行的。通过对本章的探讨,我们进一步地认识了进程的概念,了解了shell对的作用和处理流程,在这过程中fork和execve函数起到了重要的作用。了解了操作系统对进程管理的一些简单机制,了解了对异常和信号的处理机制。
(第6章1分)

第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:包含在机器语言中用来指定一个操作数或一条指令的地址。每一个逻辑地址都由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离。
线性地址:逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。是hello中的虚拟内存地址。
虚拟地址:一个带虚拟内存的系统中,CPU从一个有N=2^n个地址空间中生成虚拟地址。虚拟地址其实就是线性地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。地址翻译会将hello的一个虚拟地址转化为物理地址。
图43 MMU把逻辑地址转换成物理地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
在8086 的实模式下,把某一段寄存器(段基址)左移4 位,然后与地址ADDR 相加后被直接送到内存总线上,这个相加后的地址(20位)就是内存单元的物理地址,而程序中的这个地址ADDR就叫逻辑地址(或叫虚拟地址)。
一个逻辑地址由两部分组成,段标识符、段内偏移量。
如果我们把段看成一个对象的话,那么对它的描述如下。
(1)段的基地址(Base Address):在线性地址空间中段的起始地址。
(2)段的界限(Limit):表示在逻辑地址中,段内可以使用的最大偏移量。
(3)段的属性(Attribute): 表示段的特性。例如,该段是否可被读出或写入,或者该段是否作为一个程序来执行,以及段的特权级等。

7.2.1用户段描述符(Descriptor)
所谓描述符(Descriptor),就是描述段的属性的一个8 字节存储单元。
一个段描述符指出了段的32 位基地址和20 位段界限(即段大小)。第6 个字节的G 位是粒度位,当G=0 时,段长表示段格式的字节长度,即一个段最长可达1M 字节。当G=1 时,段长表示段的以4K 字节为一页的页的数目,即一个段最长可达1M×4K=4G 字节。D 位表示缺省操作数的大小,如果D=0,操作数为16 位,如果D=1,操作数为32 位。

图44段描述符的一般格式

图45存取字节的一般格式
第7 位P 位(Present) 是存在位,表示段描述符描述的这个段是否在内存中,如果在内存中。P=1;如果不在内存中,P=0。
DPL(Descriptor Privilege Level),就是描述符特权级,它占两位,其值为0~3,用来确定这个段的特权级即保护等级。0为内核级别,3为用户级别。
S 位(System)表示这个段是系统段还是用户段。如果S=0,则为系统段,如果S=1,则为用户程序的代码段、数据段或堆栈段。
类型占3 位,第3 位为E 位,表示段是否可执行。当E=0 时,为数据段描述符,这时的第2 位ED 表示地址增长方向。第1 位(W)是可写位。当段为代码段时,第3 位E=1,这时第2 位为一致位(C)。当C=1 时,如果当前特权级低于描述符特权级,并且当前特权级保持不变,那么代码段只能执行。所谓当前特权级CPL(Current Privilege Level),就是当前正在执行的任务的特权级。第1 位为可读位R。
存取权字节的第0 位A 位是访问位,用于请求分段不分页的系统中,每当该段被访问时,将A 置1。对于分页系统,则A 被忽略未用。

7.2.2选择符、描述符表和描述符表寄存器
描述符表(即段表)定义了386 系统的所有段的情况。所有的描述符表本身都占据一个字节为8 的倍数的存储器空间,空间大小在8 个字节(至少含一个描述符)到64K 字节(至多含8K=8192)个描述符之间。
1.全局描述符表(GDT)
全局描述符表GDT(Global Descriptor Table),除了任务门,中断门和陷阱门描述符外,包含着系统中所有任务都共用的那些段的描述符。它的第一个8 字节位置没有使用。
2.中断描述符表(IDT)
中断描述符表IDT(Interrupt Descriptor Table),包含256 个门描述符。IDT 中只能包含任务门、中断门和陷阱门描述符,虽然IDT 表最长也可以为64K 字节,但只能存取2K字节以内的描述符,即256 个描述符,这个数字是为了和8086 保持兼容。
3.局部描述符表(LDT)
局部描述符表LDT(Local Descriptor Table),包含了与一个给定任务有关的描述符,每个任务各自有一个的LDT。有了LDT,就可以使给定任务的代码、数据与别的任务相隔离。每一个任务的局部描述符表LDT 本身也用一个描述符来表示,称为LDT 描述符,它包含了有关局部描述符表的信息,被放在全局描述符表GDT 中,使用LDTR进行索引。
在实模式下,段寄存器存储的是真实的段基址,在保护模式下,16 位的段寄存器无法放下32 位的段基址,因此,它们被称为选择符,即段寄存器的作用是用来选择描述符。选择符的结构如图43 所示。

图46 选择符的一般格式
可以看出,选择符有3 个域:第15~3 位这13 位是索引域,表示的数据为0~8129,用于指向全局描述符表中相应的描述符。第2 位为选择域,如果TI=1,就从局部描述符表中选择相应的描述符,如果TI=0,就从全局描述符表中选择描述符。第1、0 位是特权级,表示选择符的特权级,被称为请求者特权级RPL(Requestor Privilege Level)。只有请求者特权级RPL 高于(数字低于)或等于相应的描述符特权级DPL,描述符才能被存取,这就可以实现一定程度的保护。

7.2.3在没有分页操作时寻址一个存储器操作数
(1)在段选择符中装入16 位数,同时给出32 位地址偏移量(比如在ESI、EDI …等)。
(2)先根据相应描述符表寄存器中的段地址(确定描述符表的地址)和段界限(确定描述符表的大小),根据段选择符的TI决定从哪种描述符表中取,再根据段选择符的索引找到相应段描述符的位置,比较RPL与DPL,若该段无问题,就取出相应的段描述符放入段描述符高速缓冲寄存器中。
(3)将段描述符中的32 位段基地址和放在ESI、EDI 等中的32 位有效地址相加,就形成了32 位物理地址.

图47 寻址过程

7.2.4 linux中的段机制
从2.2 版开始,Linux 让所有的进程都使用相同的逻辑地址空间,因此就没有必要使用局部描述符表LDT。
Linux 在启动的过程中设置了段寄存器的值和全局描述符表GDT 的内容,段寄存器的定义在include/asm-i386/segment.h 中:
21
2
3
4

#define __KERNEL_CS 0x10 //内核代码段,index=2,TI=0,RPL=0
#define __KERNEL_DS 0x18 //内核数据段, index=3,TI=0,RPL=0
#define __USER_CS 0x23 //用户代码段, index=4,TI=0,RPL=3
#define __USER_DS 0x2B //用户数据段, index=5,TI=0,RPL=3
从定义看出,没有定义堆栈段,实际上,Linux 内核不区分数据段和堆栈段,这也体现了Linux 内核尽量减少段的使用。因为没有使用LDT,因此,TI=0,并把这4 个段描述符都放在GDT中, index 就是某个段描述符在GDT 表中的下标。内核代码段和数据段具有最高特权,因此其RPL为0,而用户代码段和数据段具有最低特权,因此其RPL 为3。
全局描述符表的定义在arch/i386/kernel/head.S 中:
11
2
3
4
5
6
7
8
9
10
ENTRY(gdt_table)
.quad 0x0000000000000000 /* NULL descriptor /
.quad 0x0000000000000000 /
not used /
.quad 0x00cf9a000000ffff /
0x10 kernel 4GB code at 0x00000000 /
.quad 0x00cf92000000ffff /
0x18 kernel 4GB data at 0x00000000 /
.quad 0x00cffa000000ffff /
0x23 user 4GB code at 0x00000000 /
.quad 0x00cff2000000ffff /
0x2b user 4GB data at 0x00000000 /
.quad 0x0000000000000000 /
not used /
.quad 0x0000000000000000 /
not used */
从代码可以看出,GDT 放在数组变量gdt_table 中。按Intel 规定,GDT 中的第一项为空,这是为了防止加电后段寄存器未经初始化就进入保护模式而使用GDT 的。第二项也没用。从下标2~5 共4 项对应于前面的4 种段描述符值。对照图2.10,从描述符的数值可以得出:
• 段的基地址全部为0x00000000;
• 段的上限全部为0xffff;
• 段的粒度G 为1,即段长单位为4KB;
• 段的D 位为1,即对这4 个段的访问都为32 位指令;
• 段的P 位为1,即4 个段都在内存。
由此可以得出,每个段的逻辑地址空间范围为0~4GB。每个段的基地址为0,因此,逻辑地址到线性地址映射保持不变,也就是说,偏移量就是线性地址,我们以后所提到的逻辑地址(或虚拟地址)和线性地址指的也就是同一地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
VM系统通过将虚拟内存分割为称为虚拟页的大小固定的块来处理这个问题。每个虚拟页的大小为P=2p字节。类似的,物理内存被分割为物理页,大小也为P字节(物理页也被成为页帧)。
在任意时刻,虚拟页面的集合都被分为三个不相交的集合:
(1) 未分配的:VM系统还没分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。
(2) 缓存的:当前已缓存在物理内存中的已分配页。
(3) 未缓存的:未缓存在物理页中的已分配页。
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在那个物理页中国。如果不命中,系统必须判断这个虚拟页存放在磁盘的那个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘中复制到DRAM中替换这个牺牲页。这些功能是由软硬件联合提供的,包括操作系统软件,MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存章的叫做页表的数据结构,页表将虚拟地址映射到物理页。每次MMU将一个虚拟地址转换成物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。
页表就是一个页表条目(PTE)的数组。虚拟地址空间的每个页在页表中的一个固定偏移量处都有一个PTE。每个PTE是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页面是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的其实地址,这个物理页中缓存了该虚拟页,如果没有设置有效位,那么一个空地址表示这个虚拟页还没由分配,否则,这个地址就指向该虚拟页在磁盘上的起始位置。
每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE。为了消除查阅PTE的时间代价,在MMU中包括了一个关于PTE的小缓存,成为翻译后备缓存器(TLB)。TLB通过VPN来寻址。TLB的每一行都保存着一个由单个PTE组成的块。
(1) 处理器产生一个虚拟地址VA。
(2) 处理器向MMU发送VA。
(3) MMU截取VPN,利用VPN寻址TLB,请求得到PTE。
(4) 如果TLB命中,那么向MMU返回PTE
(5) MMU截取PPN,与VPO串联得到物理地址。
(6) 如果TLB不命中,那么MMU触发一个故障。内核的故障处理程选择一个牺牲块(如果TLB已满)替换到主存,并将相应的PTE调入TLB,将控制传递回触发异常的指令,这时候TLB会命中,情况同(1)~(5)。
7.4 TLB与四级页表支持下的VA到PA的变换
为了解决不常用页表常驻内存造成的内存浪费,引入了多级页表来压缩页表。
虚拟地址被划分为4个VPN和一个VPO。48位虚拟地址的高36位被划分成四个VPN。CR3寄存器包含一级页表的物理地址。一级页表中的每个PTE负责映射虚拟地址空间中的一个4MB的片,这里的每一格片都是由1024个连续的页面组成的。如果片i中的每个页面都未分配,那么上一级PTEi就为空。如果在片i中至少有一个页是分配了的,那么一级PTEi就指向一个二级页表的基址。以此类推,直到最后一级页表,最后一级的页表中每一个PTE都负责映射一个4KB的虚拟页面。将最后一级页表中对应的PTE返回给MMU,MMU像7.3中所说的一样构建物理地址。
访问k个PTE第一眼看上去是昂贵且不切实际的,然而TLB可以起作用,正是TLB将不同层次的PTE缓存起来,带多级页表的地址翻译并不比单级页表慢很多。
7.5 三级Cache支持下的物理内存访问
因为CPU向内存请求数据时,如果需要对每一级Cache进行访问,用的都是同样的物理地址,因此我们可以递归地来看三级Cache:高一级存储结构向低一级存储结构发送一个物理地址PA请求数据,如果低一级存储结构命中,那么向上一级返回数据,否则向下一级存储结构请求,这可能会换出这一级中的某个块。最高级的存储结构是CPU中的寄存器,最低级可以是本地磁盘,甚至是远程二级存储。
在Cache得到一个m位PA的请求之后,Cache首先会把PA分成3个部分:CI(组索引)、CO(块内偏移)、CT(标志位),从PA低位到高位依次是CO,CI,CT。CI,CO,CT各有多少位是由Cache本身的参数决定的,CT有b位,B=2b,其中B是块的字节大小;CI有s位,S=2s,其中S是Cache的组数。如果是全相联Cache,那么整个Cache只有一组,没有组索引位。剩下的高位全部作为CT。
Cache根据PA得到CI,到CI组中寻找块,如果该组中存在一个块,它的有效位为1且标志位tag与虚拟地址一致,那么Cache命中,它会根据CO找到合适的字节返回到上一级存储结构。否则,Cache不命中,它会向下一级存储结构发送同样的PA请求PA所在的块,这可能会涉及到牺牲一个块。
7.6 hello进程fork时的内存映射
在shell终端键入./hello 1180801003 李威东 ,shell读入我们从终端键入的命令行,调用parseline函数,构建argv数组,其中argv[0]指向./hello字符串,argv[1]指向1180801003字符串,argv[2]指向李威东字符串,parseline返回0,表明这是一个前台运行的进程。判断出这个命令不是内置命令之后,shell会认定这是当前目录文件下的可执行目标文件hello,终端调用fork函数创建一个子进程,新的子进程几乎但不完全和父进程相同,子进程可以得到父进程用户级虚拟地址空间相同(但独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈,子进程还获得与父进程任何打开文件描述符相同的副本,意味着子进程可以读写父进程中打开的任何文件。他们最大的不同是拥有不同PID。
我们从虚拟内存的角度再来看fork函数,当fork函数被当前进程调用时,内核为新的进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新的进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为写时私有复制。当fork在新的进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任意一个后来进行写操作的时候,写时复制机制就会创建新的页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程的上下文中加载并运行一个新程序。在fork创建的子进程中调用execve函数加载运行我们的hello可执行文件。execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序。
我们从虚拟内存的角度来看execve函数,execve加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。
2.映射私有区域。为Hello的代码、数据、.bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时复制的。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。
3.映射共享区域。比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。execve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点,也就是_start 地址,_start 最终调用 hello 中的 main 函数。
除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到 CPU 引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
7.8 缺页故障与缺页中断处理
DRAM缓存不命中称为缺页。CPU如果引用了一个未缓存页面中的字,MMU在读取页表时候就会从有效位推断出VP3尚未缓存,并会触发一个缺页故障。
缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这次就会页命中。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) 。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) 。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
分配器将堆视为一组不同大小的块(block) 的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格:显式分配器和隐式分配器。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
1.显式分配器:要求应用显式地释放任何已分配的块。例如C程序通过调用malloc函数来分配一个块,通过调用free函数来释放一个块。其中malloc采用的总体策略是:先系统调用sbrk一次,会得到一段较大的并且是连续的空间。进程把系统内核分配给自己的这段空间留着慢慢用。之后调用malloc时就从这段空间中分配,free回收时就再还回来(而不是还给系统内核)。只有当这段空间全部被分配掉时还不够用时,才再次系统调用sbrk。当然,这一次调用sbrk后内核分配给进程的空间和刚才的那块空间一般不会是相邻的。
2.隐式分配器:也叫做垃圾收集器,例如,诸如Lisp、ML、以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
2.2 带边界标签的隐式空闲链表分配器原理

图48 一个简单的堆块格式
对于带边界标签的隐式空闲链表分配器,一个块是由一个字的头部、有效载荷、可能的一些额外的填充,以及在块的结尾处的一个字的脚部组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。
我们称这种结构称为隐式空闲链表,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意:此时我们需要某种特殊标记的结束块,可以是一个设置了已分配位而大小为零的终止头部。
Knuth提出了一种边界标记技术,允许在常数时间内进行对前面快的合并。这种思想是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。

图49 用隐式空闲链表来组织堆
简单的放置策略:
(1)首次适配:从头搜索,遇到第一个合适的块就停止;
(2)下次适配:从头搜索,遇到下一个合适的块停止;
(3)最佳适配:全部搜索,选择合适的块停止。
分割空闲块:
适配到合适的空闲块,分配器将空闲块分割成两个部分,一个是分配块,一个是新的空闲块
获取额外的堆内存:
分配器通过调用sbrk函数,申请额外的存储器空间,插入到空闲链表中。
合并空闲块:
为了减少外部碎片,需要进行相邻空闲块的合并。
显式空闲链表的基本原理:
根据定义,程序不需要一个空闲块的主体,所以实现空闲链表数据结构的指针可以存放在这些空闲块的主体里面。
显式空闲链表结构将堆组织成一个双向空闲链表,在每个空闲块的主体中,都包含一个pred(前驱)和succ(后继)指针。

图50 使用双向链表的堆块格式
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
在本章中我们对存储结构有了一个较为全面的探讨:从线性地址到虚拟地址再到物理地址,从CPU直到磁盘。为了利用程序的局部性原理并且降低硬件成本,存储器系统按照存储器层次结构的方法来组织。存储器体系结构的核心思想是对于每个k,位于k层的更小更快的存储设备作为k+1层的更大更慢的存储设备的缓存。虚拟内存是一种对主存的抽象。虚拟内存提供了三个重要的能力:(1)它将主存看成一个存储在磁盘上的地址空间的高速缓存(2)它为每个进程提供了一致的地址空间(3)它保护了每个进程的地址空间不被其他进程破坏。虚拟内存是由硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的,一致的和私有的地址空间。我们探究了fork hello进程时的内存映射和execve hello进程时的内存映射。在缺页故障与缺页中断处理中我们看到了操作系统和硬件的完美交互。最后我们讨论了动态存储分配管理。
(第7章 2分)

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个linux文件就是一个m个字节的序列:
B0 , B1 , … , Bk , … , Bm-1
所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix IO接口:
打开文件,内核返回一个非负整数的文件描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需要记住这个描述符。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。

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

读写文件。(1)读操作:从当前文件位置k开始,从文件复制n个字节到内存,然后将k增加到k+n;(2)写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k。

关闭文件。当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中。无论一个进程因为何种原因终止,内核都会关闭所有打开的文件并释放他们的内存资源。

Unix IO函数:

  1. open()函数
    功能描述:用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。
    函数原型:int open(char *filename,int flags,mode_t mode)
    参数:filename:被打开的文件名(可包括路径名如"dev/ttyS0")flags:文件打开方式,mode参数指定了新文件的访问权限位。
    返回值:成功:返回文件描述符;失败:返回-1

  2. close()函数
    功能描述:用于关闭一个被打开的的文件
    所需头文件: #include <unistd.h>
    函数原型:int close(int fd)
    参数:fd文件描述符
    函数返回值:0成功,-1出错

  3. read()函数
    功能描述: 从文件读取数据。
    所需头文件: #include <unistd.h>
    函数原型:ssize_t read(int fd, void *buf, size_t count);
    参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。
    返回值:返回所读取的字节数;0(读到EOF);-1(出错)。

  4. write()函数
    功能描述: 向文件写入数据。
    所需头文件: #include <unistd.h>
    函数原型:ssize_t write(int fd, void *buf, size_t count);
    返回值:写入文件的字节数(成功);-1(出错)

  5. lseek()函数
    功能描述: 用于在指定的文件描述符中将将文件指针定位到相应位置。
    所需头文件:#include <unistd.h>,#include <sys/types.h>
    函数原型:off_t lseek(int fd, off_t offset,int whence);
    参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)
    返回值:成功:返回当前位移;失败:返回-1
    8.3 printf的实现分析
    https://www.cnblogs.com/pianist/p/3315801.html
    从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
    字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
    显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
    8.4 getchar的实现分析
    异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
    getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
    8.5本章小结
    本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了printf 函数和 getchar 函数。
    (第8章1分)
    结论
    用计算机系统的语言,逐条总结hello所经历的过程。
    你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
    (结论0分,缺失 -1分,根据内容酌情加分)

附件
hello.c :hello源代码
hello.i :预处理后的文本文件
hello.s :hello.i编译后的汇编文件
hello.o :hello.s汇编后的可重定位目标文件
hello :链接后的可执行文件
hello.o_d-r:hello.o的反汇编代码(objdump -d -r hello.o得到)
hello.o_dxs:hello.o的反汇编代码(objdump -dxs hello.o得到)
hello.o_elf:hello.o的Readelf结果(readelf -a hello.o 得到)
hello_dxs:hello的反汇编代码(objdump -dxs hello得到)
hello_elf:hello.o的Readelf结果(readelf -a hello 得到)
(附件0分,缺失 -1分)

参考文献
[1].大规模计算机系统并行仿真技术研究.被引次数:3作者:朱小东.计算机体系结构中国科学技术大学2013(学位年度)
[2].计算机系统课程多元异构教学模式的研究与应用.被引次数:1作者:张丹丹.教育技术学辽宁师范大学2013(学位年度)
[3].高端容错计算机系统监测平台的设计与实现.被引次数:2作者:王力.计算机技术哈尔滨工业大学2011(学位年度)
[4].新型计算机系统信息泄漏分析及其防泄漏研究.作者:钱冰华.仪器仪表工程西安电子科技大学2013(学位年度)
[5].基于嵌入式Linux操作系统的导航计算机系统设计.被引次数:2作者:黄正仙.电气工程哈尔滨工业大学2008(学位年度)
[6].南极科考智能支撑装置超级计算机系统的设计与实现.作者:汤刘杰.控制理论与控制工程东南大学2012(学位年度)
[7].集群计算机系统中基于协同设计的并行I/O模拟器研究.作者:李李.计算机技术湖南大学2011(学位年度)
[8].无人机非相似余度飞控计算机系统设计与研究.作者:周小超.测试计量技术及仪器南京航空航天大学2013(学位年度)
[9].轨道交通安全计算机系统及安全控制机制关键技术研究.被引次数:4作者:陈光武.交通信息工程及控制兰州交通大学2014(学位年度)
[10].邮政储蓄计算机系统业务数据安全管理平台需求分析与验收测试.作者:巫建刚.软件工程北京邮电大学2010(学位年度)

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值