CSAPP大作业

{width=“2.5125in”
height=“0.46319444444444446in”}

计算机系统

大作业

题 目 [程序人生-Hello’s P2P]{.ul}

专 业 [计算学部]{.ul}

学   号 [119020610]{.ul}

班   级 [1903004]{.ul}

学 生 [张景阳]{.ul}

指 导 教 师 [史先俊 ]{.ul}

计算机科学与技术学院

2021年6月

摘 要

本文讲述了hello.c编写完成后在Linux下运行的完整生命历程,通过预处理、编译、汇编、链接、进程管理、储存管理、IO管理这些hello.c的生命历程进行详细,清楚的解释。通过一些常用的工具,如gdb、edb、readelf等,来详细的观察hello从开始到结束的执行历程。

关键词: 计算机系统, P2P, 020, hello的一生,大作业;

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

**
**

目 录 {#目-录 .TOC-Heading}

第1章 概述 - 3 -

1.1 Hello简介 - 3 -

1.2 环境与工具 - 3 -

1.3 中间结果 - 4 -

1.4 本章小结 - 4 -

第2章 预处理 - 5 -

2.1 预处理的概念与作用 - 5 -

2.2在Ubuntu下预处理的命令 - 6 -

2.3 Hello的预处理结果解析 - 6 -

2.4 本章小结 - 7 -

第3章 编译 - 8 -

3.1 编译的概念与作用 - 8 -

3.2 在Ubuntu下编译的命令 - 9 -

3.3 Hello的编译结果解析 - 9 -

3.4 本章小结 - 16 -

第4章 汇编 - 17 -

4.1 汇编的概念与作用 - 17 -

4.2 在Ubuntu下汇编的命令 - 17 -

4.3 可重定位目标elf格式 - 17 -

4.4 Hello.o的结果解析 - 21 -

4.5 本章小结 - 22 -

第5章 链接 - 23 -

5.1 链接的概念与作用 - 23 -

5.2 在Ubuntu下链接的命令 - 23 -

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

5.4 hello的虚拟地址空间 - 26 -

5.5 链接的重定位过程分析 - 28 -

5.6 hello的执行流程 - 30 -

5.7 Hello的动态链接分析 - 30 -

5.8 本章小结 - 33 -

第6章 hello进程管理 - 34 -

6.1 进程的概念与作用 - 34 -

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

6.3 Hello的fork进程创建过程 - 34 -

6.4 Hello的execve过程 - 35 -

6.5 Hello的进程执行 - 36 -

6.6 hello的异常与信号处理 - 38 -

6.7本章小结 - 44 -

第7章 hello的存储管理 - 45 -

7.1 hello的存储器地址空间 - 45 -

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

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

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

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

7.6 hello进程fork时的内存映射 - 52 -

7.7 hello进程execve时的内存映射 - 53 -

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

7.9动态存储分配管理 - 54 -

7.10本章小结 - 58 -

第8章 hello的IO管理 - 59 -

8.1 Linux的IO设备管理方法 - 59 -

8.2 简述Unix IO接口及其函数 - 59 -

8.3 printf的实现分析 - 60 -

8.4 getchar的实现分析 - 62 -

8.5本章小结 - 62 -

结论 - 63 -

附件 - 64 -

参考文献 - 65 -

第1章 概述

1.1 Hello简介

这里简述Hello,P2P与020的简单过程

P2P:From Program to Process

  1. 首先通过编译器,建立.c文件,输入一段代码,得到hello.c的源程序

  2. 再经过gcc编译器的预处理得到.i文件

  3. 进而对于编译得到.s汇编语言的文件

  4. 编译后的文件经过汇编器as处理转换为二进制.o可重定位文件

  5. 将.o文件交给链接器生成.o可执行文件

  6. 在shell中输入命令启动可执行文件,shell会调用fork创建一个独立进程,在调用execve,执行hello.o程序。

这就是hello.c P2P的整个过程下面用一张图来展现一下

{width=“5.902777777777778in”
height=“1.675in”}

(图1.1)

020:From Zero-0 to Zero-0

  1. 通过shell输入文件名,shell通过fork创建一个新的进程,shell为hello进程execve,映射出一块虚拟内存,虚拟内存通过mmap为hello规划了一块空间

  2. 之后CPU从.text节中不断取出代码,从.data节不断取出数据。调度器为hello规划进程执行的时间片。如果有异常的话会触发异常处理子程序。

  3. 当程序结束后,shell回收hello进程,操作系统会释放进程的虚拟空间和相关结构,内核中删除相关数据。

1.2 环境与工具

硬件环境:Inter® Core™ i7-8750H CPU @ 2.20GHz;32G RAM; 512GHD Disk

软件环境:Windows10 64位;Vmware 16 Pro;Ubuntu 20.04 LTS 64位

开发工具:Visual Studio 2019 64位;CodeBlocks 32位/64位;Vim; VI

Clion 2020.3.2;EDB Version1.3,GDB,readelf,hexedit,gcc,gedit

1.3 中间结果


文件名称 功能


hello.i 预处理文件

hello.s 汇编文件

hello.elf elf文件

hello.o 可重定位文件

hello.objdump hello.o的反汇编文件

hello.c C语言源代码

hello.o.elf hello.o的反汇编文本

1.4 本章小结

本章对P2P,020的概念进行了初步的介绍,说明了大作业需要的环境与工具,初步列出了所需要的中间的辅助文件并说明其作用。

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

**预处理的概念:**预处理所做的主要工作,就是在对一个C源文件进行编译操作之前,对其进行一些预先的处理,包括删除删除注释,处理宏定义,添加包含的头文件。

预处理的过程:
预处理过程时整个编译过程的第一步。预处理的输入文件时.c文件和.h文件,输出文件是.i文件。

{width=“5.625694444444444in”
height=“4.6097222222222225in”}

(图2.1)

过程可分为三步:

  1. 头文件替换

  2. 宏替换

  3. 条件编译

预编译的作用:

  1. 用实际值替换宏定义的字符串

  2. 将头文件的代码插入到程序中

  3. 条件编译,处理所有的预编译指令,如#if, #ifdef等

  4. 删除所有注释

  5. 添加行号和文件名标识,以便编译时编译器产生调试用的行号信息

  6. 保留所有的#pragma编译指令(编译器需要用)

2.2在Ubuntu下预处理的命令

预处理命令如下:

{width=“3.763888888888889in”
height=“0.3659722222222222in”}

(图2.2.1)

之后生成一个.i文件

{width=“2.0083333333333333in”
height=“0.9916666666666667in”}

(图2.2.2)

2.3 Hello的预处理结果解析

{width=“5.113888888888889in”
height=“2.6506944444444445in”}

(图2.3)

这里是对头文件的导入并且展开

{width=“5.902777777777778in”
height=“2.8375in”}

(图2.4)

这里发现注释已经没有了,对注释的清除。

通过上面我们可以发现其中对define宏定义的递归展开。最终的.i文件中是没有#define语句的,用大量的全局变量来进行操作。

经过编译预处理后,得到的是预处理文件,它被拓展了许多内容,还是一个可读的文件,但是不包含任何宏定义

2.4 本章小结

本章通过对于hello.c进行处理,获得hello.i文件,结合预处理的概念及作用分析了预处理对文本.c文件的相应处理,并对结果进行了相应的解析。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

**编译的概念:**编译就是利用编译程序从源语言编写的源程序产生目标程序的过程,即用编译程序产生目标程序的动作。其工作过程分为五个阶段:词法分析,语法分析,语义检查和中间代码生成,代码优化,目标代码生成。

  1. 词法分析

词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。

  1. 语法分析

编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位。

  1. 中间代码

中间代码是源程序的一种内部表示,或称中间语言。中间代码的作用是使编译程序的结构在逻辑上更为简单明确。特别是可使目标代码的优化比较容易实现中间代码,即为中间语言程序,中间语言的复杂性介于源程序语言和机器语言之间。

  1. 代码优化

代码优化是指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。

  1. 目标代码

目标代码生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。

**编译的作用:**编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。它主要进行词法分析和语法分析,分析过程中若发现有错误,编译程序会报告错误的性质和错误的发生地点,并且将错误所造成的影响限制在尽可能小的范围内,使得源程序的其余部分能继续被编译下去。

3.2 在Ubuntu下编译的命令

gcc -S hello.i -o hello.s

{width=“4.268055555555556in”
height=“0.4388888888888889in”}

(图3.1)

3.3 Hello的编译结果解析

3.3.1 总看编译结果

{width=“6.08125in”
height=“4.341666666666667in”}

(图3.2)

{width=“3.910416666666667in”
height=“5.723611111111111in”}

(图3.3)

  1. 开头部分

{width=“3.9347222222222222in”
height=“0.8048611111111111in”}

(图3.4)

.file源文件是hello.c文件

.text下面是代码段

.section .rodata下面是rodata节

.align声明对指令或者是数据存放地址进行对齐的方式 本程序为8

.long .string声明long、string类型

{width=“5.902777777777778in”
height=“1.1625in”}

(图3.5)

如图3.5,LCO与LC1是字符串类型,分别如图中所示

其中.globla声明全局变量main,.type用来指定是函数类型或者对象类型

  1. C语言中的数据

hello.s用到的C数据类型:整数、字符串、数组

  1. 整数

    a. 变量

int argc 即参数的个数

{width=“3.048611111111111in”
height=“0.20347222222222222in”}

(图3.6)在hello.c中的argc

{width=“2.365972222222222in”
height=“0.20347222222222222in”}

(图3.7)在hello.s中程序通过%rdi,将值传给argc

int i 程序中的变量,用于记录循环次数

{width=“4.422916666666667in”
height=“0.45555555555555555in”}

(图3.8)在hello.i中的i, 用于记录循环次数的变量

{width=“2.4145833333333333in”
height=“0.3173611111111111in”}

(图3.9)在hello.s中的i,i保存在栈帧中,为rbp-4的位置

b. 常量

4、0、8

在hello.i中以常数形式出现

{width=“3.0409722222222224in”
height=“1.4715277777777778in”}

(图3.10)常量在hello.s中以立即数的形式出现

  1. 字符串

在hello.i中一共出现了两次字符串

第一个字符串为:用法: Hello 学号 姓名
秒数!\n,里面有中文,所以用UTF-8编码,在hello.s中为如下展现

{width=“5.675in”
height=“0.5201388888888889in”}

(图3.11)字符串的UTF-8编码格式

第二个字符串为Hello %s %s\n,没有用UTF-8编码,在hello.s中为如下展现

{width=“2.870138888888889in”
height=“0.2847222222222222in”}

(图3.12)字符串在hello.s的展现,没有用UTF-8格式编码

  1. 数组

hello.i中出现了三次数组元素

main函数的char* argv[]

{width=“2.5770833333333334in”
height=“0.17083333333333334in”}

(图3.13)main函数的argv数组参数

作为循环数组元素printf字符串个sleep函数参数

{width=“3.2111111111111112in”
height=“0.3659722222222222in”}

(图3.14)第二次与第三次出现数组元素

分析:
argv每个元素均为char*,大小为8bytes指向内核中的字符串,由图3-14可知,argv首地址为-32(%rbp),argv[2]在-32(%rsp)+16的位置,保存在%rdx;argv[1]在-32(%rsp)+8的位置,保存在%rsi;而格式字符串作为printf第一个参数保存在%rdi中。argv[3]在-32(%rsp)+24的位置,保存在%rdi,作为系统函数atoi参数,将argv[3]指向的字符串转换为int,返回值传送给%rdi作为sleep函数的参数。

{width=“1.479861111111111in”
height=“1.601388888888889in”}

(图3.15)循环数组在hello.s中的体现

3.3.4 C语言中的赋值

1**.** 局部变量i的赋值

根据3.3.2中的变量可得,i的赋值在栈中操作,将立即数赋值给i

{width=“1.8777777777777778in”
height=“0.21944444444444444in”}

(图3.16)hello.s中对局部变量i的赋值

  1. C语言的类型转换
<!-- -->
  1. atoi函数

{width=“2.4229166666666666in”
height=“0.2111111111111111in”}

(图3.17)hello.i中的atoi函数

atoi函数自动检测类型,输入argv[3]作为参数,atoi将字符串被显式转换为int类型,
若在字符串输入浮点数,在atoi函数会发送强制(隐式)类型转换,发生舍入,如输入1.5,则向零舍入变为1。

  1. C语言的算数操作
<!-- -->
  1. i++

由图3.18可知,i保存在-4(%rbp),每次循环结束,将i addl
$1,后缀为l,表明i为4bytes大小

{width=“2.6256944444444446in”
height=“0.1951388888888889in”}

(图3.18)i++在hello.s中的体现

  1. C语言的关系操作
<!-- -->
  1. argc != 4

对于所传参数大小的判断,通过cmpl,argc保存在栈帧,由下图可知

{width=“1.9347222222222222in”
height=“0.14652777777777778in”}

(图3.19)argc!= 4在hello.s中的体现

  1. i < 8

由3.3.4可知,i被赋值为0。再由下图可知,将i与7比较。

{width=“3.032638888888889in”
height=“0.3173611111111111in”}

(图3.20)i< 8在hello.s中的体现

  1. 数组/指针/结构操作
<!-- -->
  1. 指针数组char* argv[]

由图3.15可知,argv[1]、argv[2]、argv[3]分别保存在栈中8(%rbp)、16(%rbp)、24(%rbp)位置,每个指针为char*类型,8个字节大小

{width=“2.3743055555555554in”
height=“2.5125in”}

(图3.21)三个参数 argv[1]、argv[2]、argv[3]的位置

3.3.9 控制转移

1**.** if(argc != 4)

if控制将argc(输入的参数个数)和4比较,若相等,则跳转到跳转表.L2的位置,如图3.19

  1. for(i = 0; i< 8; i++)

for循环控制,比较i与7的大小,若i <=
7则跳转到.L4循环体的位置,执行循环体,如下图3.21

{width=“3.276388888888889in”
height=“3.438888888888889in”}

(图3.22)for循环

  1. 函数操作
<!-- -->
  1. main函数

参数传递:argc、argv,分别通过寄存器%rdi和%rsi传参,传入main函数后分别保存在栈中-20(%rbp)和-32(%rbp)的位置,如图3-21

函数调用:被系统启动函数调用

函数返回:%rax寄存器保存返回值,设置%eax=0,return 0,如图3-22

{width=“3.292361111111111in”
height=“0.7638888888888888in”}

(图3.23)参数传递

{width=“2.609722222222222in”
height=“0.6180555555555556in”}

(图3.24)设置返回值

  1. puts函数

参数传递:"\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"字符串首地址保存在%rdi中传入puts函数

函数调用:if条件控制调用,若argc!=4 main函数调用puts函数

  1. exit函数

参数传递:参数为1

函数调用:if条件控制调用,若argc!=4 main直接调用exit函数,如图3.24

{width=“2.7805555555555554in”
height=“0.325in”}

(图3.25)exit函数调用

  1. printf函数

参数传递:"Hello %s
%s\n"格式串作为第一个参数,argv[1]指向的字符串为第二个参数,argv[2]指向的字符串为第三个参数,分别通过%rdi、%rsi和%rdx传入printf

函数调用:for循环控制,若i<8 则main调用printf函数,如图3-21

  1. atoi函数

参数传递:argv[3]指向的字符串为参数,通过%rdi传参

函数调用:for循环控制,main函数调用,如图3-20

函数返回:通过%rax保存返回值

  1. sleep函数

参数传递:atoi的返回值作为参数通过%rdi传入sleep函数

函数调用:for循环控制,main函数调用,如图3.25

{width=“2.7805555555555554in”
height=“0.5041666666666667in”}

(图3.26)sleep函数调用

  1. getchar函数

传递控制:call getchar

函数调用:main函数调用,如图3-26

函数返回:出错返回-1,否则返回输入字符串对应的ASCII码

{width=“2.24375in”
height=“0.20347222222222222in”}

(图3.27)getchar 函数调用

3.4 本章小结

本章通过对于hello.i进行编译,获得hello.s文件,结合编译的概念及作用分析了编译对文本.i文件的相应处理,详细地阐述了数据、赋值、类型转换、算术操作、逻辑/位操作、关系操作、数组/指针/结构操作、控制转移、函数操作的过程,并对结果进行了相应的解析。

(第3章2分)

第4章 汇编

4.1 汇编的概念与作用

**汇编的概念
:**汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable
object program)的格式,并将结果保存在目标文件hello.o中。

**汇编的作用:**将汇编代码转变为机器指令,生成目的二进制文件。

4.2 在Ubuntu下汇编的命令

gcc hello.s -c -o hello.o

{width=“5.902777777777778in”
height=“0.7479166666666667in”}

(图4.1)

4.3 可重定位目标elf格式

首先通过readelf -a hello.o > hello.elf来生成elf文本文件

{width=“5.910416666666666in”
height=“0.9347222222222222in”}

(图4.2)

进行分析

  1. ELF头:如图4.3,包含了程序有关的各种信息。以16B的Magic开始,Magic(幻数)描述了生该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。

{width=“5.902777777777778in”
height=“4.040972222222222in”}

(图4.3)

  1. 节头:不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。节头部表罗列了几个节,包括名称、类型、全体大小、地址、旗标、链接、偏移量、信息、对齐,如图4-4。夹在ELF头和节头部表之间的都是节。

{width=“3.6666666666666665in”
height=“3.1791666666666667in”}

(图4.4)

  1. 重定位节:

.rela.text,保存的是.text节中需要被修正的信息;任何调用外部函数或者引用全局变量的指令都需要被修正;调用外部函数的指令需要重定位;引用全局变量的指令需要重定位;
调用局部函数的指令不需要重定位;在可执行目标文件中不存在重定位信息。本程序需要被重定位的是printf、puts、exit、sleepsecs、getchar、sleep和.rodata中的.L0和.L1。

.rela.eh_frame节是.eh_frame节重定位信息。如图4.5

下图中Offset为需要重定位的地址与该段首地址的偏移;Info的高24位说明了所引用的符号索引,低8位为对应的重定位类型,Type即为对于类型的具体表示;重定位前值为0;Sym.Name即为绑定的符号名,Addend即为偏移。

这里可以看出我们的程序用PLT(过程链接表)对共享库函数进行了动态链接,而对常量字符串的引用采用了PC相对引用的方式。注意到由于PC相对引用和动态链接的特殊性(即运行这条指令时,PC的值其实为下一条指令的地址,因此PC真正的值和我们目前想进行重定位的值有4个字节的偏移),因此我们在Addend的位置都减去了4(其实第四行本来是+26),这样实现了抵消这四个字节所带来的偏差。因此我们心里应该明白,第一行其实指向了.rodata段的首地址,第四行其实指向了.rodata+0x26的地址,其他行都指向了共享库函数对应的PLT表项的首地址

{width=“5.902777777777778in”
height=“4.333333333333333in”}

(图4.5)

  1. 符号表
    :.symtab,一个符号表,它存放在程序中定义和引用的函数和全局变量的信息,一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。

{width=“5.902777777777778in”
height=“2.8618055555555557in”}

(图4.6)

  1. 其余的节

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

.rodata节:只读数据,如printf 格式串、switch 跳转表等。

.data节:已初始化的全局和静态C变量.局部C变量在运行时被保存在栈中,既不出先在.data节中,也不出现在.bss节中。

.bss节:未初始化全局变量,仅是占位符,不占据任何实际磁盘空间。区分初始化和非初始化是为了空间效率。

.rel.data节的重定位信息,用于对被模块使用或定义的全局变量进行重定位的信息,一般而言,任何已经初始化的全局变量,如果它的初始值为一个全局变量地址或者外部定义函数的地址,都需要被修改。

.debug节:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。

.line:原始C源程序中的行号和.text节中机器指令之间的映射.

.strtab:一个字符串表,其内容包括.symtab和.debug中的符号表,以及节头部中的节名字。

.rela.eh_frame:指明重定位类型

4.4 Hello.o的结果解析

objdump -d -r hello.o >
hello.objdump得到hello.o的反汇编文件,将其与hello.s文件进行对比。如图4.7

{width=“5.910416666666666in”
height=“0.8208333333333333in”}

(图4.7)

下图是反汇编与汇编的对比(图4.8)

{width=“5.902777777777778in”
height=“2.7152777777777777in”}

(图4.8)

其中hello.o的反汇编hello.objdump是按照一节一节进行翻译的,信息规整的许多,

也加入了对应的elf格式信息;同时每一条汇编代码都对应着一条由01序列所表示的机器代码(此处以16进制显示,为了看着方便),同时最左方也表明了相对地址。其中跳转指令和函数调用等指令,在汇编的注释中都表示为对应的偏移,而不是在.s中的跳转到机器自己生成的符号处(类似于goto语句)。

总结,区别如下:

1)条件分支变化:hello.s中的段名称L1,L2等被替换为确定的相对偏移地址,可知段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在了.

2)函数调用变化:
在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待静态链接的进一步确定。

3)数据访问变化:
全局变量访问,在hello.objdump中对.rodata中printf的格式串的访问需要通过链接时重定位的绝对引用确定地址,在hello.s中相应位置仍为占位符表示。hello.objdump中对.data中已初始化的全局变量sleepsecs为0x0+%rip的方式访问hello.s中访问方式为sleepsecs+%rip。

4.5 本章小结

本章对hello.s进行了汇编,生成了hello.o可重定位目标文件,并且分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,比较了hello.s和hello.o反汇编代码的不同之处,分析了从汇编语言到机器语言的一一映射关系。对汇编过程即作用,重定位的方式以及典型的ELF可重定位目标文件有了深入的理解。

(第4章1分)

第5章 链接

5.1 链接的概念与作用

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

**链接的作用:**链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。

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

{width=“5.910416666666666in”
height=“0.8131944444444444in”}

(图5.1)

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

通过readelf的操作,来观察可执行文件hello的格式

{width=“5.910416666666666in”
height=“0.6340277777777777in”}

(图5.2)

我们首先展示以下典型的ELF文件的格式

{width=“3.6909722222222223in”
height=“5.658333333333333in”}

(图5.3)

首先我们根据上图对于和可重定位目标文件的节进行一些简单的对比

1.ELF头中字段入口点给出执行程序时第一条指令的地址,而在可重定位文中,此字段为0

2.多一个段头部表(程序头表)(segment header table),是一个结构数组

3.多一个.init节,用于定义_init函数,该函数用来进行可执行目标文件开始执行时的初始化工作

4.少两个.rel节(无需重定位),因为已经重定位好了

进下来我们进行进一步的分析:

  1. ELF的对比

{width=“5.4875in”
height=“3.845833333333333in”}

(图5.4)

幻数这一栏是没有变化的

节头部表的开始比可重定位文件大得多,在可重定位目标文件中,每个可装入节的起始地址总是0,但在可执行目标文件中,转入节地址确定,被填充进了正确的地址可重定位文件,文件类型是REL,可执行目标文件的文件类型会变成EXECStart
of program headers: 64 (bytes into file)段头部表的开始
可执行目标文件中有了具体的值,入口点有了具体的地址

  1. 其他的变化

没有了rel.text节,符号表,用来存放程序中定义和引用的函数和全局变量的信息。新的符号表多了很多符号,Value符号对应字符串在strtab节中的偏移量。

5.4 hello的虚拟地址空间

{width=“5.902777777777778in”
height=“4.853472222222222in”}

**(图5.5)**hello进程的虚拟地址空间

{width=“5.902777777777778in”
height=“4.0159722222222225in”}

**(图5.6)**hello ELF中的程序头文件

通过对比我们可以发现,在EDB中可以寻找这些段,根据偏移量以及大小,可以在这里找到它们的位置。其中Load起始于400000到403e50保存着程序头,段头部表,init,text,.radata的内容。从图5.4-3可以看出
Init开始于401000.

使用edb工具可以看出.daatdump开始于401000,到402000也就是从init开始。从图5.4-4可以看出/rodata开始于402000,保存着程序的只读数据,如字符串常量等等。

DYNAMIC起始于403e50,保存了其他动态链接器(即,INTERP中指定的解释器)使用的信息。

PHDR也存在在 load 中,开始于400040保存着程序头

INTERP存在在LOAD中,开始于400270,指定在程序已经从可执行文件映射到内存之后,必须调用的解释器(如动态链接器>

NOTE保存辅助信息

GNU_STACK:权限标志,标志栈是否是可执行的。

GNU_RELRO:指定在重定位结束之后那些内存区域是需要设置只读。

5.5 链接的重定位过程分析

Hello.o的反汇编比.o文件多了很多的重定位节

{width=“4.91875in”
height=“3.3819444444444446in”}

(图5.7)可执行文件

我们可以看到在hello的反汇编中多出了.init节、plt节、text节

{width=“5.902777777777778in”
height=“1.3173611111111112in”}

(图5.8).init节

{width=“4.642361111111111in”
height=“2.4715277777777778in”}

(图5.9).plt节

{width=“5.902777777777778in”
height=“1.91875in”}

(图5.10).text节

主要区别是多了这些函数

<_init> <.plt> <_start> <_dl_relocate_static_pie>
<__libc_csu_init> <__libc_csu_fini> <_fini>

其中重要是_start调用的,链接器链接之后生成的用于寻找文件头的标志。

而且重定位条目有两种类型:R_X86_64_PC32和R_X86_64_32

r.offset = Oxf

r.symbol = sum

r.type = R_x86_64_PC32r.addend = -4

这些字段告诉链接器修改开始于偏移量0xf处的32位PC相对引用,这样在\

运行时它会指向sum例程。现在,假设链接器已经确定

ADDR(s) = ADDR( .text) = Ox4004d0和ADDR(r.symbol) = ADDR(sum) =

0x4004e8。

链接器首先计算出引用的运行时地址(第7行):refaddr = ADDR(s)+ r.offset

0x4004d0 + 0xf = 0x4004df

然后,更新该引用,使得它在运行时指向sum程序

*refptr = (unsigned)(ADDR(r.symbol) + r.addend - refaddr)

= (unsigned)(Ox4004e8+(-4)- Ox4004df)=(unsigned)(Ox5)

在得到的可执行目标文件中,call指令有如下的重定位的形式:4004de:e8 05 00
oo o0 callq 4004e8 <sum>

5.6 hello的执行流程

{width=“5.902777777777778in”
height=“2.8208333333333333in”}

(图5.11)通过edb与hello反汇编的对照,来观察hello的执行流程

  1. 载入:_dl_start,_dl_init

  2. 开始执行:__stat,_cax_atexit,_new_exitfn,_libc_start_main,_libc_csu_init

  3. 运行:_main,_printf,_exit,_sleep,_getchar,_dl_runtime_resolve_xsave,_dl_fixup,_dl_lookup_symbol_x

4. 退出:exit

5.7 Hello的动态链接分析

动态链接的定义:共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。共享库也称为共享目标,在Linux系统中通常用.so后缀来表示。微软的操作系统大量地使用了共享库,它们称为DLL(动态链接库)。

对于动态共享链接库中PIC函数,编译器没有办法计算出函数的地址,因此动态链接器使用PLT+全局偏移量表GOT实现函数的动态链接。

(GOT中存放目标函数的地址 PLT使用GOT中的地址跳转到目标函数)

{width=“5.008333333333334in”
height=“3.6256944444444446in”} (图5.12)PLTGOT的位置

{width=“5.910416666666666in”
height=“4.820833333333334in”}

(图5.13)GOT表的内容

由于PLTGOT由0x40400开始,所以我们咋edb的Data Dump中从0x40400开始分析。

对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,GOT中存放的是PLT中函数调用指令的下一条地址指令

在init被调用之前,GOT表中0x404008和0x404010处两个数据为空
{width=“5.910416666666666in”
height=“4.804861111111111in”}

(图5.14)内容为空

在调用init之后,即程序开始之后,可以得到GOT表中的内容

根据图5.15可得

GOT[1]为0x7f50840b21e0

GOT[2]为0x7f508409aef0

{width=“5.910416666666666in”
height=“4.813194444444444in”}

(图5.15)

因此由GOT[1]与GOT[2]地址可知

GOT[1] = 0x7f50840b21e0

GOT[2] = 0x7f508409aef0

其中GOT[1]指向重定位表,GOT[2]指向动态链接器

5.8 本章小结

介绍了链接的概念和作用,并分析了可执行文件的ELF格式,虚拟地址空间的分配,重定位和执行过程还有动态链接的过程。

(第5章1分)

第6章 hello进程管理

6.1 进程的概念与作用

**进程的概念:**进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。

**进程的作用:**提供给应用程序的关键抽象

  1. 一个独立的逻辑控制流,他提供一个假象,好像我们的程序独占地使用处理器

  2. 一个私有的地址空间,他提供一个假象,好像我们的程序独占地使用内存系统

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

**Shell的作用:**在计算机科学中,Shell俗称壳(用来区别于核),是指"为使用者提供操作界面"的软件(命令解析器)。它类似于DOS下的command.com和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。同时它又是一种程序设计语言。作为命令语言,它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令;作为程序设计语言,它定义了各种变量和参数,并提供了许多在高级语言中才具有的控制结构,包括循环和分支。

Shell的处理流程:

  1. 从终端读入用户输入的命令

  2. 分割字符串,转化为所需要的参数

  3. 判断命令格式,如果是内置命令则立即执行

  4. 如果不是内置命令则调用相应的程序

  5. 寻找命令文件,需要在PATH环境变量中寻找命令所需的可执行文件

  6. 最后通过fork创建子进程来完成命令,或者对于具体的命令用execv函数执行即可

6.3 Hello的fork进程创建过程

**fork的创建过程:**父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。

fork函数是有趣的(也常常令人迷惑),因为它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork返回子进程的PID。在子进程中,fork返回0。因为子进程的PID总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。

**实例:**我们在命令行输入./hello 1190200610 张景阳
3,我们通过这个过程来分析fork的过程

  1. 首先shell会判断这个命令是否是一个内置命令,发现不是,则./后面的应该是一个可执行文件。之后对hello后面的字符串进行解析,存在argv中,作为hello的参数。

  2. 之后终端会调用系统函数fork,fork为hello创建了一个新的子进程

  3. 之后hello在子进程中执行,最后结束

{width=“5.902777777777778in”
height=“1.4875in”}

(图6.1)fork的运行实例

6.4 Hello的execve过程

**execve的概念:**execve函数在当前进程的上下文中加载并运行一个新程序。

{width=“4.820833333333334in”
height=“0.7722222222222223in”}

(图6.2)execve的函数定义

execve的过程:
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。

{width=“3.8777777777777778in”
height=“0.9104166666666667in”}

(图6.3)参数列表的组织结构

{width=“4.08125in”
height=“1.0409722222222222in”}

(图6.4)环境变量列表的组织结构

在execve加载了filename之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型

int main(int argc, char **argv,char **envp) ;

或者等价的

int main(int argc, char *argv], char *envp[]);

当main开始执行时,用户栈的组织结构如图6.5所示。让我们从栈底(高地址)往栈顶(低地址)依次看一看。首先是参数和环境字符串。栈往上紧随其后的是以null结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串。全局变量environ指向这些指针中的第一个envp[0]。紧随环境变量数组之后的是以null结尾的argv[
]数组,其中每个元素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数libc_start_main的栈帧。

{width=“3.4875in”
height=“3.2111111111111112in”}

(图6.5)一个新程序开始时,用户栈的典型组织结构

Hello的进程执行

**逻辑控制流:**逻辑控制流。进程为每个程序提供了一种假象,好像程序在独占地使用处理器。每个竖直的条表示一个进程的逻辑控制流的一部分

**时间片:**一个进程执行它的控制流的一部分的每一时间段叫做时间片。

**用户模式与内核模式:**处理器通常是用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。

没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个1О操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。

运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。

**进程上下文:**操作系统内核使用一种称为上下文切换( context
switch)的较高层形式的异常控制流来实现多任务。上下文切换机制是建立在8.1节中已经讨论过的那些较低层异常机制之上的。

内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。

图6.6展示了一对进程A和B之间上下文切换的示例。在这个例子中,进程A初始运行在用户模式中,直到它通过执行系统调用read陷入到内核。内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。

{width=“5.129861111111111in”
height=“1.520138888888889in”}

(图6.6)进程上下文切换的剖析

**总结:进程调度的过程:**当调用Sleep进程之前,如果
hello进程不被抢占,则按顺序执行,加入发生被抢占的情况就进行上下文切换。

Hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,内核处理休眠请求主动释放当前进程。

计时器开始计时,内核进行如图6.6的上下文切换,将当前进程的控制权交给其他进程,当进程达到你输入的停止时间后,发送一个中断信号

触发中断异常处理子程序,将
hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。

6.6 hello的异常与信号处理

**异常的概念:**异常就是控制流中的突变,用来相应处理器状态中的变化。

**异常的种类:**异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。图6.7中的表对这些类别的属性做了小结。

{width=“4.975694444444445in”
height=“1.0326388888888889in”}

(图6.7)异常的种类

异常的信号与处理:

  1. 中断

中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序(interrupt
handler)。

图6.8概述了一个中断的处理。I/O设备,例如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断,这个异常号标识了引起中断的设备。

{width=“5.2034722222222225in”
height=“1.3416666666666666in”}

(图6.8)中断处理

在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,它就将控制返回给下一条指令(也即如果没有发生中断,在控制流中会在当前指令之后的那条指令)。结果是程序继续执行,就好像没有发生过中断一样。

剩下的异常类型(陷阱、故障和终止)是同步发生的,是执行当前指令的结果。我们把这类指令叫做故障指令(
faulting instruction)。

  1. 陷阱与系统调用

陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。

{width=“5.048611111111111in”
height=“1.325in”}

(图6.9)陷阱处理

  1. 故障

故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障

处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的
abort例程,abort例程会终止引起故障的应用程序。图6.10概述了一个故障的处理。{width=“5.317361111111111in”
height=“1.2277777777777779in”}

(图6.10)故障处理

  1. 终止

终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。如图6.11所示,处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。

{width=“5.219444444444444in”
height=“1.398611111111111in”}

(图6.11)终止处理

异常处理的实例:

  1. 输出回车

正常hello执行,程序执行完成后,输出回车,程序回收。

{width=“5.317361111111111in”
height=“1.8944444444444444in”}

(图6.12)

  1. 不停乱按

仅仅是向命令行的缓冲区中输入无效的字符串,每次输出的信息的时候会将缓冲区的字符串都输出出去并清空。

{width=“4.739583333333333in”
height=“1.9430555555555555in”}

(图6.13)

  1. Ctrl-C

会给程序发一个终止信号,结束进程。

{width=“4.991666666666666in”
height=“1.398611111111111in”}

(图6.14)

  1. Ctrl-Z

暂时挂起,将前台程序转入后台,但并没有结束。

{width=“5.902777777777778in”
height=“1.9270833333333333in”}

(图6.15)

{width=“5.910416666666666in”
height=“2.3819444444444446in”}

(图6.16)

  1. Ctrl-Z + ps

暂时挂起前台程序,将ps设为前台程序并执行。

{width=“5.910416666666666in”
height=“2.4875in”}

(图6.17)

  1. Ctrl-Z + jobs

暂时挂起前台程序,jobs设为前台程序并执行。

{width=“5.910416666666666in”
height=“2.317361111111111in”}

(图6.18)

  1. Ctrl-Z + pstree

暂时挂起前台程序,pstree设为前台程序并执行。

{width=“4.520138888888889in”
height=“4.845833333333333in”}

(图6.19)

  1. Ctrl-Z + fg

将后台程序恢复为前台程序

{width=“5.910416666666666in”
height=“2.325in”}

(图6.20)

6.7本章小结

本章节主要关注Shell如何运行 hello程序,以及对
hello进程执行过程的讨论。本章介绍了进程的概念与作用、
Shell及其工作流程,展示了fork函数如何创建新进程。之后展示了
execve加载运hello程序的过程,以及
hello的进程执行过程,介绍进程时间片的概念、进程调度的过程、用户态与核心态的转换等。最后介绍了hello的异常与信号处理。

(第6章1分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

**逻辑地址:**程序代码经过编译后出现在汇编程序中地址。逻辑地址由选择符(在实模式下是描述符,在保护模式下是用来选择描述符的选择符)和偏移量(偏移部分)组成

线性地址:逻辑地址经过段基址后转化为线性地址,为描述符:偏移量的组合形式。分页机制中线性地址作为输入。

**虚拟地址:**实际上就是这里的线性地址

**物理地址:**CPU通过地址总线的寻址,找到真实的物理内存对应地址。CPU对内存的访问时通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。

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

**段式管理的概念:**段式管理就是把虚拟地址空间中的虚拟内存组织成一些长度可变的称为段的内存单元。段是虚拟地址到线性地址转化的基础,每个段由三个参数定义:

  1. 段基地址:指定段在线性地址空间中的开始地址。基地址是线性地址对应于段中的偏移0处。

  2. 段限长:是虚拟地址空间中段内最大科用偏移地址,定义了段的长度。

  3. 段属性:指定段的特性。如该段是否可读、可写或者可作为一个程序执行,段的特权级等等。

这三个参数存储在一个成为段描述符的结构项中。在逻辑地址到线性地址的转换映射过程中,会使用这个段描述符。段描述符保存在内存中的段描述符表中。如图7.1

{width=“5.902777777777778in”
height=“1.5770833333333334in”}

(图7.1)段描述符

段选择符是段的一个16位标志符,如下图所示。段选择符并不直接指向段,而是指向段描述符表中定义段的段描述符。段选择符包括3个字段的内容:

请求特权级RPL,RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级。这是一种环保护机制:内核工作在0环,用户工作在3环,中间环留给中间软件用。Linux仅用第0和第3环。

表指引标志TI,TI=0,表示描述符在GDT中,TI=1,表示描述符在LDT中。

{width=“5.910416666666666in”
height=“0.8458333333333333in”}

(图7.2)段选择符

转换过程:

  1. 先确定要访问的段,然后决定使用的段寄存器。

  2. 使用段选择符中的索引值在GDT或LDT中定位相应的段描述符,他们的首地址则通过GDTR寄存器和LDTR寄存器来获得

  3. 将段选择符的索引值*8,然后加上GDT或LDT的首地址,就能得到当前
    段描述符的地址。

  4. 利用段描述符校验段的访问权限和范围,以确保该段是可以访问的并且偏移量位于段界限内。

  5. 利用段描述符中取得的段基地址加上偏移量,形成一个线性地址。

{width=“5.309027777777778in”
height=“3.9590277777777776in”}

(图7.3)转换流程

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

页式管理的概念:概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。VM系统通过将虚拟内存分割为称为虚拟页的大小固定的块来处理这个问题。每个虚拟页的大小为P=2*字节。类似地,物理内存被分割为物理页,大小也为P字节。

在任意时刻,虚拟页面的集合都分为三个不相交的子集:

  1. 未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。

  2. 缓存的:当前已缓存在物理内存中的已分配页

  3. 未缓存的:为缓存在屋里内存中的已分配页

{width=“2.4715277777777778in”
height=“0.9513888888888888in”}(图7.4)

**页表的组织结构:**系统判定一个虚拟页是否缓存在一个DRAM的某个地方,这个功能是软硬件联合提供的,包括操作系统软件,MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及再磁盘与DRAM之间来回传送页。

图7.5展示了一个页表的基本组织结构。页表就是一个页表条目(Page Table
Entry,PTE)的数组。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。为了我们的目的,我们将假设每个PTE是有一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位﹐那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。

{width=“2.8375in” height=“2.08125in”}

(图7.5)页表

**地址翻译:**通过页表基址寄存器PTBR+VPN在页表中获得条目PTE,一条PTE中包含有效位,权限信息,物理页号。

  1. 如果有效位时0 + NULL,则代表没有在虚拟内存空间中分配该内存

  2. 如果是有效位0 +非
    NULL,则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中

  3. 如果有效位是1则代表该内存已经缓存在了物理内存中,可以得到其物理页号PPN,与虚拟页偏移量共同构成物理地址
    PA。

简单过程如图7.6

{width=“5.422916666666667in”
height=“2.357638888888889in”}

(图7.6)

转换过程:

页面命中的情况:

  1. 处理器生成一个虚拟地址,并把它传给MMU

  2. MMU生成PTE地址,并从高速缓存/主存请求得到它

  3. 高速缓存/主存向MMU返回PTE

  4. MMU构造物理地址,并把它传送给高速缓存/主存

  5. 高速缓存/主存返回所请求的数据字给处理器

过程如图7.7

{width=“4.032638888888889in”
height=“1.6909722222222223in”}

(图7.7)

缺页的情况:

  1. 处理器生成一个虚拟地址,并把它传给MMU

  2. MMU生成PTE地址,并从高速缓存/主存请求得到它

  3. 高速缓存/主存向MMU返回PTE

  4. 返回的PTE有效位是0,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序

  5. 缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘

  6. 缺页处理程序页面调入新的页面,更新内存中的PTE

  7. 缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为这次的虚拟页面会缓存在高速缓存中,所以会页命中。

如图7.8

{width=“5.902777777777778in”
height=“1.9756944444444444in”}

(图7.8)

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

**TLB:**图7.9展示了小内存系统的一个快照,包括TLB、页表的一部分和Ll高速缓存。在TLB和高速缓存的图上面,我们还展示了访问这些设备时硬件是如何划分虚拟地址和物理地址的位的。

TLB是利用VPN的位进行虚拟寻址的。因为TLB有4个组,所以VPN的低2位就作为组索引(TLBI)。VPN中剩下的高6位作为标记(TLBT),用来区别可能映射到同一个TLB组的不同的VPN。

{width=“5.105555555555555in”
height=“1.6826388888888888in”}

(图7.9)TLB:四组,16个条目,四路组相联

**四级页表下的VA到PA的转换:**图7.10给出了第一级、第二级或第三级页表中条目的格式。当P=1时(Linux中就总是如此),地址字段包含一个40位物理页号(PPN),它指向适当的页表的开始处。注意,这强加了一个要求,要求物理页表4KB对齐。

{width=“5.113888888888889in”
height=“3.0in”}

(图7.10)

图7.11给出了第四级页表中条目的格式。当P=1,地址字段包括一个40位PPN,它指向物理内存中某一页的基地址。这又强加了一个要求,要求物理页4KB对齐。

{width=“5.105555555555555in”
height=“3.203472222222222in”}

(图7.11)

PTE有三个权限位,控制对页的访问。R/W位确定页的内容是可以读写的还是只读的。U/S位确定是否能够在用户模式中访问该页,从而保护操作系统内核中的代码和数据不被用户程序访问。XD(禁止执行)位是在64位系统中引入的,可以用来禁止从某些内存页取指令。这是一个重要的新特性,通过限制只能执行只读代码段,使得操作系统内核降低了缓冲区溢出攻击的风险。

当MMU翻译每一个虚拟地址时,它还会更新另外两个内核缺页处理程序会用到的位。每次访问一个页时,MMU都会设置A位,称为引用位(reference
bit)。内核可以用这个引用位来实现它的页替换算法。每次对一个页进行了写之后,MMU都会设置D位,又称修改位或脏位(dirty
bit)。修改位告诉内核在复制替换页之前是否必须写回牺牲页。内核可以通过调用一条特殊的内核模式指令来清除引用位或修改位。

图7.12给出了MMU如何使用四级的页表来将虚拟地址翻译成物理地址。36位VPN被划分成四个9位的片,每个片被用作到–个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN
1提供到一个Ll PET的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2
PTE的偏移量,以此类推。

{width=“5.902777777777778in”
height=“3.203472222222222in”}

(图7.12)

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

同7.4中的过程,其中就包含了三级Cache支持的物理内存访问

细节如下:L1是8路64组相连的,每块大小为64B,所以说CI占6位,块偏移CO占6位,一共VA52位,因此CT为40位。

由得到的物理地址VA,截断出CI,Tag,CO位,之后进行组索引,每一组8路,比较标志位,如果匹配陈工并且有效位为0,则不命中,向下一级缓存中查询数据。根据常见的策略,查询到数据后,如果映射到的组内有空闲块,则直接防止;如果没有空闲块,则进行替换原则。L2,L3同L1。

{width=“5.910416666666666in”
height=“3.8944444444444444in”}

(图7.13)

7.6 hello进程fork时的内存映射

既然我们理解了虚拟内存和内存映射,那么我们可以清晰地知道fork函数是如何创建一个带有自己独立虚拟地址空间的新进程的。

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

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

{width=“3.8375in”
height=“1.5041666666666667in”}

(图7.14)

7.7 hello进程execve时的内存映射

虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。既然已经理解了这些概念,我们就能够理解execve
函数实际上是如何加载和执行程序的。假设运行当前进程中的程序执行了如下的execve调用:

Exceve(“a.out”, NULL, NULL);

正如在第8章中学到的,execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地替代了当前程序。加载并运行a.out需要以下几个步骤:

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

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

3. 映射共享区域。如果
a.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

4.
设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

{width=“4.438888888888889in”
height=“3.24375in”}

(图7.15)

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

**缺页故障:**假如目标内存页在物理内存中没有对应的页帧或者存在但无对应权限,CPU
就无法获取数据,这种情况下CPU就会报告一个缺页错误。

由于CPU没有数据就无法进行计算,CPU罢工了用户进程也就出现了缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的
Page Fault Handler 处理。

在访问内核地址空间时,缺页异常可能被各种条件触发。
(1)内核中的程序设计错误导致访问不正确的地址,这是真正的程序错误。
(2)内核通过用户空间传递的系统调用参数,访问了无效地址
(3)访问使用vmalloc分配的区域,触发缺页异常。
在向火从用户空间复制数据时,如果访问的地址在虚拟地址空间中不予物理内存也关联,则会发生缺页异常。
每次发生缺页异常时,将输出异常的原因和当前执行代码的地址

**缺页中断处理:**程序开始执行后,
Linux将根据需要换入代码和数据页面,MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后执行下面的步骤:

1.
虚拟地址A是合法的吗?缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm
start和vmend做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。

2.
试图进行的内存访问是否合法?比如这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图访问的操作是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。

3.
内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常的翻译A,而不会再产生缺页中断了。

7.9动态存储分配管理

**动态内存分配概念:**动态内存分配器维护着一个进程的虚拟内存区域,称为堆
(见图7.16)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。

分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。

{width=“2.236111111111111in”
height=“3.08125in”}

(图7.16)堆

1.
显式分配器,要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和
delete操作符与C中的malloc和 free相当。

2.
隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。

**分配器的要求和目标:**显式分配器必须在一些相当严格的约束条件下工作:

  1. 处理任意请求序列。一个应用可以有任意的分配请求和释放请求序列,只要满足约束条件:每个释放请求必须对应于一个当前已分配块,这个块是由一个以前的分配请求获得的。因此,分配器不可以假设分配和释放请求的顺序。例如,分配器不能假设所有的分配请求都有相匹配的释放请求,或者有相匹配的分配和空闲请求是嵌套的。

  2. 立即响应请求。分配器必须立即响应分配请求。因此,不允许分配器为了提高性能重新排列或者缓冲请求。

  3. 只使用堆。为了使分配器是可扩展的,分配器使用的任何非标量数据结构都必须保存在堆里。

  4. 对齐块(对齐要求)。分配器必须对齐块,使得它们可以保存任何类型的数据对象。

  5. 不修改已分配的块。分配器只能操作或者改变空闲块。特别是,一旦块被分配了,就不允许修改或者移动它了。因此,诸如压缩已分配块这样的技术是不允许使用的。

**隐式空闲链表:**在这种情况中,一个块是由一个字的头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。例如,假设我们有一个已分配的块,大小为24(0x18)字节。那么它的头部将是

0x00000018 | 0x1 = 0x00000019

类似地,一个块大小为40(0x28)字节的空闲块有如下的头部:0x00000028 | 0x0
= 0x00000028

头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。

假设块的格式如图7.17所示,我们可以将堆组织为一个连续的已分配块和空闲块的序列,如图7.18所示。

{width=“5.195138888888889in”
height=“1.7722222222222221in”}

(图7.17)一个简单的堆块的格式

{width=“5.902777777777778in”
height=“0.8291666666666667in”}

(图7.18)用隐式空闲链表来组织堆

我们称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意,我们需要某种特殊标记的结束块,在这个示例中,就是一个设置了已分配位而大小为零的终止头部。

隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。

很重要的一点就是意识到系统对齐要求和分配器对块格式的选择会对分配器上的最小块大小有强制的要求。没有已分配块或者空闲块可以比这个最小值还小。例如,如果我们假设一个双字的对齐要求,那么每个块的大小都必须是双字(8字节)的倍数。因此,图7.17的块格式就导致最小的块大小为两个字:一个字作头,另一个字维持对齐要求。即使应用只请求一字节,分配器也仍然需要创建一个两字的块。

分配策略:

放置已分配的块:

  1. 首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块

  2. 下一次适配:和首次适配跟相似,只不过是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。

  3. 最佳适配:检查每一个空闲块没选择适合所需请求大小的最小空闲块

**分割空闲块:**一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间。一个选择是用整个空闲块。虽然这种方式简单而快捷,但是主要的缺点就是它会造成内部碎片。如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可以接受的。

然而,如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块,而剩下的变成一个新的空闲块。图7.19展示了分配器如何分割图7.18中8个字的空闲块,来满足一个应用的对堆内存3个字的请求。

{width=“5.910416666666666in”
height=“0.7965277777777777in”}

(图7.19)

**获得额外的堆内存:**使用sbrk函数即可

合并空闲块:

  1. 立即合并

  2. 推迟合并

  3. 带边界标记的合并

    a. 前面的块和后面的块都是已分配的

    b. 前面的块是已分配的,后面的块是空闲的

    c. 前面的块是空闲的,后面的块是已分配的

    d. 前面的块和后面的块都是空闲的

**显式空闲链表:**隐式空闲链表为我们提供了一种介绍一些基本分配器概念的简单方法。然而,因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不适合的(尽管对于堆块数量预先就知道是很小的特殊的分配器来说它是可以的)。

一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如图7.20所示。

{width=“4.820833333333334in”
height=“2.252083333333333in”}

(图7.20)双向空闲链表的堆块格式

分离的空闲链表:

  1. 简单分离存储

  2. 分离适配

  3. 伙伴系统

7.10本章小结

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

(第7章 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:所有的 IO 设备都被模型化为文件,而所有的输入和输出都被

当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许

Linux 内核引出一个简单低级的应用接口,称为 Unix I/O。

8.2 简述Unix IO接口及其函数

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

  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十n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的"EOF符号"。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。

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

Unix IO函数:

1. int open(char* filename,int flags,mode_t mode) ,进程通过调用
open 函数来打开一个存在的文件或是创建一个新文件的。open 函数将
filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags
参数指明了进程打算如何访问这个文件,mode
参数指定了新文件的访问权限位。

2. int close(fd),fd 是需要关闭的文件的描述符,close 返回操作结果。

3. ssize_t read(int fd,void *buf,size_t n),read 函数从描述符为 fd
的当前文件位置赋值最多 n 个字节到内存位置 buf。返回值-1
表示一个错误,0表示 EOF,否则返回值表示的是实际传送的字节数量。

4. ssize_t wirte(int fd,const void *buf,size_t n),write
函数从内存位置 buf复制至多 n 个字节到描述符为 fd 的当前文件位置。

8.3 printf的实现分析

printf的函数体

int printf(const char *fmt, …){
int i;
char buf[256];

va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);

return i;

}

我们先通过printf的源代码来探究printf是如何实现格式化输出的。

首先我们看到va_list arg = (va_list)((char*)(&fmt) +
4)这条语句,其中va_list的定义为 typedef char
*va_list,这说明va_list是一个字符指针,其中(char*)(&fmt) +
4表示的是这个字符数组中的第一个元素。(注意:这是在32位的环境下,64位需要将4改为8,因为sizeof(void*)
= 8)

之后我们继续看 i = vsprintf(buf, fmt,
arg);这条语句引出我们下一个要研究的函数,vsprintf(buf, fmt,
arg)函数,看看这个函数是什么功能

vsprintf函数的功能

下面是vsprintf函数的源码

int vsprintf(char *buf, const *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的功能是对我们要输出的内容进行格式化。但是在prinf中核心的输出功能是由write(buf,
i)来实现的,因此,引出我们要讨论的第二个函数:write函数

write函数的实现

write是一个系统函数,我们需要到OS中看write是如何实现的,我们得到write函数的汇编实现

write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL

紧接着我们需要知道INT_VECTOR_SYS_CALL的实现

init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call,
PRIVILEGE_USER);

这里是一个陷阱,让我们进行系统调用

我们发现,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

通过阅读sys_call段得代码,我们可以发现,ecx是要打印出的元素个数,ebx中是要打印的buf字符数组的第一个元素,这个函数的功能就是不断打印buf中的字符,直到遇到’\0’。

8.4 getchar的实现分析

getchar代码如下:

int getchar(void)

{

char c;

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

}

getchar函数通过调用read函数返回字符。其中read函数的第一个参数是描述符fd,0代表标准输入。第二个参数输入内容的指针,这里也就是字符c的地址,最后一个参数是1,代表读入一个字符,符号getchar函数读一个字符的设定。read函数的返回值是读入的字符数,如果为1说明读入成功,那么直接返回字符,否则说明读到了buf的最后。

read函数同样通过sys_call中断来调用内核中的系统函数。键盘中断处理子程序会接受按键扫描码并将其转换为ASCII码后保存在缓冲区。然后read函数调用的系统函数可以对缓冲区ASCII码进行读取,直到接受回车键返回。

这样,getchar函数通过read函数返回字符,实现了读取一个字符的功能

8.5本章小结

本章介绍了Unix是如何将I/O设备模型化文件并统一处理的,同时详细介绍了Unix
I/O函数,并对标准I/O函数printf和getchar的实现进行了分析:标准I/O函数的实现都调用了系统的I/O函数,通过中断指令将程序控制权交给系统内核,

进行相应的中断操作,在对硬件进行相应的操作。

(第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:运行:在shell中输入./hello 1170300825 lidaxin

7:创建子进程:shell进程调用fork为其创建子进程

8:运行程序:shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入
main函数。

9:执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流

10:访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。

11:动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存。

12: 信号:如果运行途中键入ctr-c
ctr-z则调用shell的信号处理函数分别停止、挂起。

12:结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。

感悟:

虽然每个程序员刚开始的时候,都是在命令行中打出的第一行代码就是hello
world。只是我们在键盘或鼠标的一下按动,在计算机中就会有如此的复杂的过程来让他输出到屏幕上。这让我感觉到了,对于计算机科学来说,只有看着简单的事情,只要深入研究,每个点都有很深的知识在里面。通过计算机系统这门课让我了解到了,我们对于计算机底层知识的匮乏以及对我们之前观念的缺失。计算机底层的技术确实值得我们去认真学习,这样我们才能更好的去深入的研究关于计算机方面的知识。

用计算机系统的语言,逐条总结hello所经历的过程。

你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。

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

附件


文件名称 功能


hello.i 预处理文件

hello.s 汇编文件

hello.elf elf文件

hello.o 可重定位文件

hello.objdump hello.o的反汇编文件

hello.c C语言源代码

hello.o.elf hello.o的反汇编文本

(附件0分,缺失 -1分)

参考文献

为完成本次大作业你翻阅的书籍与网站等

[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分)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Mr.Youtiao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值