CSAPP大作业 2018 Hello's P2P

计算机科学与技术学院
2018年12月
摘 要
在计算机科学的发展中,大部分程序猿都是通过hello.c这一简单的程序来接触编程。然而正是因为hello的单纯与浅显没有让程序猿感到“至少40%”的神秘,它便遭遇冷落甚至无视。难道它真的如同它的表象,简单得不像是实力派吗?还真不是:仅仅这样一个简单的程序,就毫无漏洞地向我们展示了整个计算机系统的工作历程以及一个程序完整的生命周期,并形象地解释了计算机系统许多内在的概念。在本文中,我们以第三人称的口吻,通过跟踪hello程序的生命周期来漫游该程序的“一生”——从它出生(被创建)开始,到它艰苦奋斗(在系统上运行),达到人生巅峰(输出简单的消息),最后实现中国梦,归隐山林(终止)。

关键词:程序;系统;生命周期;创建;运行;终止

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

目 录

第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.1.1 hello的P2P
P2P: From Program to Process
hello的program是从#include <stdio.h>……到……return 0; }的代码集合;
hello.c依次经过:
(1) 预处理器cpp(将hello.c变为hello.i➡ascii码文本文件)
(2) 编译器gcc(将hello.i变为hello.s➡含有汇编代码的汇编语言程序)
(3) 汇编器as(将hello.s变为hello.o➡可重定位目标文件)
(4) 链接器ld(生成Linux下的a.out格式的可执行文件)
最后,在shell中,进程管理为这个可执行文件fork一个子进程(process)。

1.1.2 hello的020
020: From Zero-0 to Zero-0
创建子进程之后,系统调用execve函数启动新的程序;
(1) 内核为该进程“创建”了虚拟内存的布局,建立内存和磁盘文件之间的映射;
(2) 运行开始时,程序通过缺页异常,将数据拷贝到物理内存;
(3) 从main函数开始,执行目标代码;
(4) 运行结束后,父进程回收该子进程,内核释放子进程的内存。

1.2 环境与工具
1.2.1 硬件环境
x64 CPU; 2.7GHz; 8G RAM; 128GHD Disk
1.2.2 软件环境
macOS Mojave; Ubuntu 16.04
1.2.3 开发工具
Xcode; vim; gcc; edb; objdump; readelf

1.3 中间结果
hello.i:由预处理器翻译成的文本文件
hello.s:由编译器编译成的汇编语言文件
hello.o:由汇编器翻译成的可重定位目标文件
helloo.txt:hello.o文件由objdump形成的反汇编代码文件的输出重定向
helloaout.txt:hello文件由objdump形成的反汇编代码文件的输出重定向
hellooelf.txt:hello.o文件的elf全部信息的输出重定向
helloaoutelf.txt:hello文件的elf 全部信息的输出重定向。

1.4 本章小结
本章描述了hello程序的运行框架:预处理➡编译➡汇编➡链接➡运行➡回收,高度概括了hello的一生“做了什么”,“被做了什么”。

(第1章0.5分)

第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理:在编译之前进行的处理。C语言的预处理主要有三个方面的内容:
(1) 宏定义
(2) 文件包含
(3) 条件编译
预处理命令以符号“#”开头。[1]

2.1.2 预处理的作用
预处理过程读入源代码,检查包含预处理指令的语句和宏定义,并对源代码进行相应的转换。预处理过程还会删除程序中的注释和多余的空白字符。[2]

2.2在Ubuntu下预处理的命令
命令:cpp hello.c -o hello.i
在这里插入图片描述
图2.2-1 预处理命令及生成的hello.i

2.3 Hello的预处理结果解析
2.3.1 预编译程序读出源代码,对其中内嵌的指示字进行响应,产生源代码的修改版本,修改后的版本会被编译程序读入。(即产生的hello.i程序,总共3126行!)
在这里插入图片描述
图2.3-1 预处理结果文件hello.i

预编译过程主要处理那些源代码文件中以“#”开始的预编译命令。比如“#include”、“#define”等,主要处理规则如下:
(1) 将所有的“#define”删除,并且展开所有的宏定义;
(2) 处理所有条件预编译指令,比如“#if”、“#ifdef”、“#elif”、“#else”、“#endif”;
(3) 处理“#include”预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归执行的,也就是说被包含文件可能还包含其他文件;
(4) 删除所有的注释“//”和“/* */”;
(5) 添加行号和文件名标识,如下图,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号;
在这里插入图片描述
图2.3-2 添加行号和文件名标识

(6) 保留所有的#program编译器指令,因为编译器需要使用它们。

注:经过预编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中。所以当我们无法判断宏定义是否正确或头文件包含是否正确的时候,可以查看预编译后的文件来确定问题。[3]

2.4 本章小结
任何一个看似简单的程序的预处理都需要大量的工作!在完成本章的过程中,我对我们计算机初学者在编程中常用的预处理命令有了全新的理解。例如,#include后面头文件的文件名的表示形式:可以是尖括号或双引号。尖括号是到系统规定的路径去寻找该文件,双引号则是预处理用户的第三方文件。另外,我还认识到,还可以使用宏定义#define来定义一些简单函数(也就是说宏定义中可以包含变量),但是要注意运算符的优先级。比如,如果要定义一个函数func(a, b) = ab,#define func(a, b) ab和#define func(a, b) (a)(b)是不同的。宏定义只是简单地进行字符串替换。如果要计算func(1+2, 3),前者计算的是1+23,后者计算的是(1+2)*3!

(第2章0.5分)

第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
把C语言转换成汇编语言的过程。

3.1.2 编译的作用
把.i文件编译成.s文件(.s文件是汇编语言源程序)。

3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
在这里插入图片描述
图3.2-1 编译命令及生成的hello.s

3.3 Hello的编译结果解析
3.3.1 常量
本程序唯一的常量只有第18行的字符串:
在这里插入图片描述
图3.3-1 唯一的字符串常量

它对应于hello.s中的下列代码:
在这里插入图片描述
图3.3-2 hello.s中的字符串常量

(1) 斜杠“\”加三个数字表明:字符串被编码成UTF-8的格式,每个汉字占3个字节,每个“\”加3个数字表示1个字节,后面的3个数字均采用8进制数表示;
(2) 容易发现,这里总共有12个字节。因为在源代码中,三个汉字后的“!”为中文的“!”而不是英文的”!”,所以“!”也要占3个字节。

3.3.2 变量
(1) 全局变量
该程序唯一的全局变量定义如下:
在这里插入图片描述
图3.3-3 唯一的全局变量

它对应于hello.s中的下列代码:
在这里插入图片描述
图3.3-4 hello.s中的全局变量

a. 第2行,.globl声明sleepsecs为全局类型的变量;
b. 第3行,由于sleepsecs已经被赋初值,所以会在.data节中声明该变量;
c. 第4行,在.data节中,设置对齐方式为4;
d. 第5行,设置该变量类型为object(对象);
e. 第6行,设置该类型变量大小为4字节;
f. 第8行,本是int类型的sleepsecs被声明为long类型(在32位机器上,long与int同义),且值为2;(不是2.5是因为sleepsecs被声明为整型,却被赋值为小数,此时编译器会自动忽略掉该初值的小数部分,仅把整数部分的2赋值给sleepsecs)

(2) 局部变量
a. 变量i
变量i所在的for循环采用“跳转到中间”的跳转方式,如图所示:
在这里插入图片描述
图3.3-5 hello.s中的变量i

由图可知,变量i存放在主函数的栈帧%rbp-4的位置处。jle指令表明,当i<=9即i<10时,跳转到.L4继续执行循环,否则循环结束。

b. 变量argc
argc是由外部输入决定的变量,它代表命令行中字符串的个数,这里的所说的字符串中间以空白字符分隔。根据下图可知,argc也被存在main函数的栈帧中:
在这里插入图片描述
图3.3-6 hello.s中的变量argc

该汇编语句对应原C代码中对argc和3的比较。程序开始时,argc被保存在寄存器%edi中。

c. 变量argv[]
argv[]也是由外部输入决定的变量,它是一个二级指针变量。它对应如下汇编代码:
在这里插入图片描述
图3.3-7 hello.s中的argv

如图,打印字符串“Hello %s %s\n”, argv[1], argv[2]时,对应上面的语句。这里调用printf而不调用puts是因为该字符串含有两个%s控制符,而且它不是一个字符串常量。上图第41行和第44行分别表示把%rax中保存的内存地址处的值传递给%rdx和%rax,即argv[1]和argv[2]。第47行把0传递给%eax,这是main函数的返回值。

(3) 操作
a. 赋值运算
本程序中使用的赋值运算有:对sleepsecs赋值为2.5(实际得到的值为2)、对循环索引i进行从0到9的赋值、隐含地对argc和argv[]赋值(由外部输入决定)。
sleepsecs和i都属于整形变量,赋值操作均用mov指令来完成:
在这里插入图片描述
图3.3-8 简单的数据传送指令[4]

注:图中1个字代表2个字节

b. 关系运算
本程序中使用的关系运算有argc和3的“!=”判断、i和10的“<”判断。
关系运算表达式的值要么为真,要么为假。表达式为真时,这个表达式也对应一个整数值1,为假时这个表达式对应一个整数值0。在编程中使用条件语句和循环语句时,有时可以省略关系说明,直接利用表达式的真值作为循环终止条件。
以上C代码中的关系运算得到结果后,在汇编语言中都会设置相应的条件码,程序再根据“最近”设置的条件码进行跳转(通常跳转语句都紧贴在比较和测试语句的后一条语句):

在这里插入图片描述
图3.3-9 汇编中的比较和测试指令[4]

在这里插入图片描述
图3.3-10 汇编中的跳转指令[4]

c. 算术运算
本程序中使用的(整数)算术运算有循环中对循环索引i的增一操作。算数运算对应的汇编语言指令如下:
在这里插入图片描述
图3.3-11 汇编中的整数算术运算[4]

d. 类型转换
本程序中使用的类型转换只有将sleepsecs的2.5转换为2,为隐式类型转换(编译器自动忽略小数部分)。该操作在一开始声明全局变量的时候就完成了:
在这里插入图片描述
图3.3-12 hello.s中的类型转换

第8行直接将其赋值为2,而不是2.5。

e.数组和指针操作
本程序中使用的数组/指针操作只有二级指针(指针数组)argv[]。图3.3-7已经分析过,两次将%rax保存的内存地址处的值传到寄存器,作为字符串的一部分(%s格式控制的部分输出)。另外,正因为它是二级指针(指针数组),该数组中存的是相应字符串的“地址”,所以还要再进行一次寻址(可能是基址寻址,也可能是偏移量寻址。本程序中采用基址寻址(%rax))。

f. 控制转移
本程序中有两处控制转移:if语句和for语句,对应如下汇编代码:


在这里插入图片描述
图3.3-13 hello.s中的控制转移

if语句和for语句对应的控制转移语句均为条件跳转。

g. 函数操作
本程序中使用的函数有:main、printf、exit、sleep、getchar
函数参数均在相应函数的栈帧中保存。每次转移到相应的子函数栈帧时,都需要让旧的%rbp入栈,再让此时的%rbp去寻找此时的%rsp,这样就转移到了子函数的栈帧。以main函数为例:
在这里插入图片描述
图3.3-14 main函数栈帧起始操作

  1. 参数传递
    main函数的参数:argc和argv[]。由图3.3-7知,这两个参数存储在主函数的栈帧中。当调用main函数时,栈顶指针%rsp为main函数开辟一段新的栈空间,直到程序结束再释放这段栈空间。子函数类似。
  2. 函数调用
    函数调用指令为call+函数名,如下图所示:
    在这里插入图片描述
    图3.3-15 hello.s中的call指令

上图便是调用puts函数和exit函数的指令。当执行call指令时,原指令的下一条指令地址IP会压入栈,此时控制转移到相应的子函数。与call指令相对应的是ret指令。当执行ret指令时,会把原call指令的下一条指令的地址弹回IP,开始执行原call指令的下一条指令。

  1. 函数返回
    本程序中的函数返回只有主函数的“return 0”。
    大多数情况下,函数的返回值都保存在特定的寄存器%eax(%rax)中,本程序将返回值0传递给%eax作为返回值,如下图:
    在这里插入图片描述
    图3.3-16 hello.s中的函数返回

函数返回后,已执行完的函数栈帧会被释放,恢复栈帧为调用前的状态。上图leave指令执行该操作,leave与每个函数汇编指令的前两条恰好相反,它让%rsp去寻找%rbp,然后把栈顶值弹回%rbp(即之前压入栈的旧的%rbp)。

3.4 本章小结
本章我们深入到低级的汇编语言来漫游hello.c“编译”的过程。我们分别从hello程序的不同变量类型和操作类型,观察了汇编语言的执行过程。我们可以发现,各种各样的C代码写出的程序是有很大共性的:逻辑上近似相同的C代码,即使具体实现方式不同,编译成汇编代码的时候,它们的指令也会有很大一部分重叠。本章使我们深入探索“程序的机器级表示”,深入到寄存器,深入到系统内核,深入到内存。

(第3章2分)

第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是把编译阶段生成的“.s”文件转成“.o”格式的目标文件。

4.1.2 汇编的作用
输入汇编语言源程序,检查语法的正确性。如果正确,则将源程序翻译成等价的二进制或浮动二进制的机器语言程序,并根据用户的需要输出源程序和目标程序的对照清单;如果语法有错,则输出错误信息,指明错误的部位、类型和编号。最后,对已汇编出的目标程序进行善后处理。[5]

4.2 在Ubuntu下汇编的命令
命令:as hello.s -o hello.o
在这里插入图片描述
图4.2-1 汇编命令及生成的hello.o文件

4.3 可重定位目标elf格式
(1) 理论上典型的elf格式文件包含的内容:
在这里插入图片描述
图4.3-1 典型的ELF可重定位目标文件[4]

上图展示了一个典型的ELF可重定位目标文件的格式。ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:
.text:已编译程序的机器代码。
.rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表。
.data:已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。
.symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
.rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。
.rel.data:被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
.debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才会得到这张表。
.line:原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会得到这张表。
.strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串的序列。[4]

(2) hello.o的ELF格式
readelf命令:readelf -a readelf.o(-a表示查看所有信息)
a. ELF header
在这里插入图片描述
图4.3-2 hello.o的ELF头

该表的作用与上述(1)典型的ELF可重定位目标文件完全相同。

b. header sections
在这里插入图片描述
图4.3-3 hello.o的节头部表

hello.o的节头部表描述了不同节的类型、地址、偏移量等信息。

c. .rela.text
在这里插入图片描述
图4.3-4 hello.o的.rela.text节

offset是指相对可重定位text或data段的偏移量,当汇编器生成一个目标模块时,它并不知道数据和代码最终将存放在存储器中的什么位置,它也不知道这个模块引用其他模块的函数或者全局变量的位置。所以就生成了一个重定位表,告诉连接器在将目标文件合并成可执行文件时如何修改这个引用,这个重定位表就告诉链接器需要重定位的地方。 [6]
在这里插入图片描述
图4.3-5 生成重定位表的过程[6]

观察图4.3-4最右面一列,这一列出现了hello程序中的函数名及全局变量的名字。最后一列是hello中所有需要重定位的函数和变量的声明。其中两个.rodata表示两个字符串常量(因为是只读的);

d. .rela.eh_frame
在这里插入图片描述
图4.3-6 hello.o的.rela.eh_frame节

e. .symtab
在这里插入图片描述
图4.3-7 hello.o的.symtab节

该符号表的作用与上述(1)典型的可重定位目标文件ELF相同。

4.4 Hello.o的结果解析
命令:objdump -d -r hello.o
在这里插入图片描述
图4.4-1 用objdump查看hello.o的反汇编

在这里插入图片描述
图4.4-2 hello的汇编与反汇编的main函数比较

上图中左侧是hello.s汇编语言文件,右侧是objdump得到的hello.o的反汇编文件。比较以上两者可以看出它们的区别有如下几点:
a. (机器语言的操作数与汇编语言不一致).s文件中,立即数通常是用10进制表示的,而反汇编文件中,所有的立即数及机器码都是用十六进制表示的;
b. 观察划黑线的第一行,.s文件中$.LC0对应反汇编中的 0 x 0 c . 线 . s c a l l + . s . L 1 , . L 2 使 c a l l + 4.3 4 T y p e 4.3 4 S y m . N a m e + A d d e n d d . 线 . s 0x0; c. (分支转移函数调用)观察划黑线的第二行,.s文件中调用函数的命令是call+函数名,在.s文件的各个部分设置了如.L1, .L2等分支转移的标志。而反汇编中使用的是call+相对于该函数起始位置的偏移量来转移控制,同时,紧接着,在下面一条指令中声明即将调用的函数的类型(图4.3-4的Type)和函数的声明(图4.3-4的Sym.Name + Addend); d. 最后一处划黑线的地方,.s文件中 .LC1对应反汇编中的$0x0;
e. .s文件中不包含机器码,反汇编中汇编指令左面是每一条指令的机器码,且同样用十六进制表示。
f. 当用上述指令(objdump -d -r hello.o)反汇编hello.o得到的只有该文件中的.text节,且main函数的起始地址默认设为全0。

4.5 本章小结
本章描述了汇编器将hello.s转换成hello.o的过程,即汇编。汇编器向我们展示了它是如何将C程序中不同类型的变量及变量名字进行分类保存的,具体说就是分散保存在各个节中。同时,本章还看到了objdump工具得到的反汇编代码与汇编代码的区别:在我看来,汇编就是把.i文件(ascii码语言)翻译成汇编语言,再由as(汇编器)把汇编语言文件转换成.o文件(机器码)的过程。而反汇编,仍可以“望文生义”,“反”在将.o文件逆向转换成汇编语言文件的过程。总之,汇编和反汇编都是向着汇编语言转换。

(第4章1分)

第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被夹在(复制)到内存并执行。[4]

5.1.2 链接的作用
5.1.1链接的概念已经给出

5.2 在Ubuntu下链接的命令
链接多个.o文件的命令如下:
ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/5/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o a.out
在这里插入图片描述
图5.2-1 Linux下链接生成可执行文件hello

5.3 可执行目标文件hello的格式
(1) 理论上典型的elf格式文件包含的内容:
在这里插入图片描述
图5.3-1 典型的ELF可执行目标文件

(2) hello程序的elf格式文件包含的内容:
使用命令:readelf -a hello

在这里插入图片描述
在这里插入图片描述
图5.3-2 hello的elf格式中各个节的基本信息

上图第一行的Size, Type, Address, Offset分别表示各个节的大小、类型、地址和偏移量。

5.4 hello的虚拟地址空间
在这里插入图片描述
图5.4-1 使用edb加载hello

在这里插入图片描述
图5.4-2 ELF文件中的Program Headers Table

(1) 通过左下角Data Dump可以查看hello的虚拟地址空间;
(2) Data Dump窗口处,虚拟地址的变化范围是0x400000到0x401000,说明该程序被映射到这段虚拟内存空间;
(3) 查看该hello程序的程序头表,如图5.4-2所示,该表列出了7个段,这些段组成了最终在内存中执行的程序。
PHDR保存程序头表;
INTERP指定在程序已经从可执行映射到内存之后,必须调用解释器。在这里解释器并不意味着二进制文件的内存必须由另一个程序解释。它指的是这样的一个程序:通过链接其他库,来满足未解决的引用;
LOAD表示一个从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串),程序的目标代码等等;
DYNAMIC段保存了其他动态链接器(即,INTERP中指定的解释器)使用的信息;
NOTE保存了专有信息;
虚拟地址空间的各个段,填充了来自ELF文件中特定段的数据;
readelf输出的第二部分制定了哪些节载入到哪些段。[7]

5.5 链接的重定位过程分析
在这里插入图片描述
图5.5-1 可执行文件hello的反汇编
在这里插入图片描述
图5.5-2 hello的反汇编对比图

(1) hello与hello.o反汇编的不同
a. hello的反汇编代码内容比hello.o的多出如下4个节:
.init:程序初始化需要执行的代码
.plt:动态链接-过程链接表
.got.plt:动态链接-全局偏移量表-存放函数
.fini:程序正常终止时需要执行的代码
b. hello中不再使用相对函数入口偏移的地址,而是使用虚拟内存地址.

(2) 链接的过程
a. 单独的hello.o文件无法独自变成可执行文件a.out,它需要和其他.o文件链接,其中包括crt1.o、crti.o、crtn.o。这三个文件中主要定义了程序入口_start和初始化函数_init。_start调用hello中的main函数,libc.so是动态链接共享库,其中定义了printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init、__libc.csu_fini、__libc_start_main;
b. 在hello的反汇编程序中,call指令调用函数时不再使用相对于函数起始位置偏移量的方式,而是使用虚拟地址内存的方式。在反汇编左侧机器码中,call后面函数前的地址为call的下一条指令的地址与call指令机器码后面值的补码运算和,即call的机器码(e8)后面的值为目标函数的地址与call的下一条语句指令地址的差值:
在这里插入图片描述
图5.5-3 hello反汇编中函数的调用变化

0x400445 + 0x7b == 0x4004c0,同理,jmp指令也如此。

(3) 重定位
重定位由两步组成:
a. 重定位节和符号定义。在这一步中,链接器将.o文件中所有类型相同的节合并为同一类型的新的hello可执行文件的聚合节;
b. 重定位节中的符号引用。在这一步中,链接器修改.o文件中代码节和数据节对每个符号的引用,使得他们指向正确的运行时地址。
当汇编器生成一个目标模块时,它并不知道数据和代码最终将存放在存储器中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。

5.6 hello的执行流程
(左侧为子程序名称,右侧为地址)
ld-2.23.so!_dl_start 00007f49:1359ac33
ld-2.23.so!_dl_init 00007f49:1359ac65
ld-2.23.so!_dl_lookup_symbol_x 00007f49:1359ace6
ld-2.23.so!_dl_open 00007f49:1359ad55
ld-2.23.so!_dl_catch_error 00007f49:1359ae3a
ld-2.23.so!_dl_dprintf 00007f49:1359ae74
ld-2.23.so!_dl_map_object 00007f49:1359aeb0
………………(以上是加载hello前的子程序,太多了,没有完全列出,题目要求列出hello!__libc_start_main@plt之后的)
hello!__libc_start_main@plt 00000000:004004f4
hello!puts@plt 00000000:00400514
hello!exit@plt 00000000:0040051e
hello!printf@plt 00000000:0040054f
hello!sleep@plt 00000000:0040055c
hello!getchar@plt 00000000:0040056b
hello!_init 00000000:004005ac
qword [r12+rbx*8] 00000000:004005c9

过程说明:hello程序首先进入main函数,判断argc是否等于3,然后调用puts打印字符串常量(没有%s格式控制符,可不用printf),此时调用exit,程序以1为返回值退出。否则调用printf函数打印for循环中的字符串,再调用sleep函数。循环结束后,调用getchar函数,最终返回0值。

5.7 Hello的动态链接分析
在这里插入图片描述
图5.7-1 调用dl_ini前

在这里插入图片描述
图5.7-2 调用dl_init后

如图,调用dl_init函数前后,发生改变的为.got.plt节的内容。该地址由图5.3-2确定,地址为0x6008c8。

5.8 本章小结
链接听起来简单,实际上,过程逻辑还是好理解的,但是需要的准备工作太多。简单地说,本章链接的内容就是把.o文件变成最终a.out的可执行文件。在这过程中,链接器需要在其他模块中寻找变量、把相同类型的段合并、把不同变量重定位。当程序运行起来的时候,链接还会起作用。这时链接器去定位动态链接库,并把这个库链接到进程的虚拟地址空间

(第5章1分)

第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。进程是线程的容器,是程序的实体。

6.1.2 进程的作用
进程为用户提供以下假象:我们的程序好像是系统中当前运行的唯一的程序一样,我们的程序好像是独占地使用处理器和内存,处理器好像是无间断地执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。

6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用
shell的一项主要功能是在交互方式下解释从命令行输入的命令。shell解析命令行,将其分解为词(也称为token),词之间由空白分隔,空白由制表符、空格键或换行组成。如果词中有特别的元字符,shell会对其进行替换。shell处理文件I/O和后台进程。对命令行的处理结束后,shell搜索命令并开始运行他。
shell的另一项重要功能是制定用户环境,这通常在shell的初始化文件中完成。初始化文件中有非常多定义,包括设置终端键和窗口属性,设置用来定义搜索路径、权限、提示符和终端类型的变量,设置特定应用程式所需的变量,如窗口、字处理程式和编程语言的库等。Korn/Bash shell和C/TC shell还提供了更多的制定功能:历史添加、别名、设置内置变量防止用户破坏文件或无意中退出,通知用户作业完成。
shell还能用作解释性的编程语言。shell程式(也称为shell脚本)由文件中的一列命令组成。shell程式用编辑器生成(也能在命令行上直接输入脚本)。他们由UNIX命令组成,命令之间插入了一些基本的程式结构,如变量赋值、条件测试和循环。shell脚本不必编译。shell会逐行解释脚本,就似乎他是从键盘输入相同。shell负责解释命令,因此,用户需要了解可用的命令有哪些。附录A中列出了一些有用的命令。
6.2.2 Shell-bash的处理流程
(1) 读取输入并解析命令行;
(2) 替换特别字符,比如通配符和历史命令符;
(3) 设置管道、重定向和后台处理;
(4) 处理信号;
(5) 程式执行相关设置。
6.3 Hello的fork进程创建过程
(1) 在Linux终端输入./hello 1171910407 郭奕含,shell对输入的命令行进行解析:如果字符串不是系统内置命令,那么shell会把它自动当成文件名来处理;
(2) 系统调用fork函数,为hello程序创建一个子进程;

注:新创建的子进程与父进程的用户级虚拟地址空间相同,子进程得到的是父进程虚拟地址空间的一个副本,但父进程与子进程有着不同的pid。
至此,创建完毕。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。
execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到hello,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。
在终端输入./hello时,由于hello不是一个内置的命令,所以系统会认为hello是一个可执行目标文件,然后调用某个驻留在存储器中称为加载器(loader)的操作系统代码来运行它。加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序拷贝到存储器并运行的过程叫做加载(loading);[8]
execve加载了hello之后,它调用启动代码,启动代码设置栈,并将控制传递给新程序的主函数。[4]

当加载器运行时,它创建如下图所示的存储器映像。在可执行文件中段头部表的指导下,加载器将可执行文件的相关内容拷贝到代码和数据段。接下来,加载器跳转到程序的入口点,也就是符号_start的地址。在_start地址处的启动代码(startup code)是在目标文件ctrl.o中定义的,对所有的C程序都是一样的。
在这里插入图片描述
图6.4-1 Linux运行时存储器映像

6.5 Hello的进程执行
6.5.1 进程调度的过程
(1) 逻辑流和时间片
考虑一个运行着三个进程的系统,如下图。处理器的一个屋里控制流被分成了三个逻辑控制流,每个进程一个。每个竖直的条表示一个进程的逻辑流的一部分。在这个例子中,三个逻辑流的执行是交错的。进程A运行了一会儿,然后是进程B开始运行到完成。然后,进程C运行了一会儿,进程A接着运行直到完成。最后,进程C可以运行到结束了。
多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)。[4]
在这里插入图片描述
图6.5-1 逻辑控制流和时间片

(2) 上下文和调度
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。上下文切换机制是建立在较低层异常机制上的。
内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中成为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前的进程,并使用一种成为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是sleep系统调用,它显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
下图展示了一对进程A和B之间上下文切换的示例。在这个例子中,进程A初始运行在用户模式中,直到它通过执行系统调用read陷入到内核。内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘终端处理器。[4]
在这里插入图片描述
图6.5-2 进程上下文切换的剖析

6.5.2 用户态与核心态的转换
处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变位模式,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如终端、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。[4]

6.6 hello的异常与信号处理
6.6.1 hello执行后按4次空格
在这里插入图片描述
图6.6-1 hello执行后按4次空格

该异常种类为“中断”,因为换行符可以被解析成来自键盘的信号。如果不按空格,程序正常输出10行字符串后退出。现在在程序每次sleep的2秒内,我按了4次空格,屏幕会立即显示换行。“换行符”并不会导致程序结束,而且不影响最终输出的字符串个数及内容。然而,main函数返回前有一个getchar(),可以读走1个换行符,这样只剩下3个空格。再加上程序默认执行结束后会换行并提示输出新的命令行,所以最后有4行提示。

6.6.2 hello执行后按ctrl-c
在这里插入图片描述
图6.6-2 hello执行后按ctrl-c

按下ctrl-c,相当于发送SIGINT信号,终止该进程。

6.6.3 hello执行后按ctrl-z
(1) 按下ctrl-z
在这里插入图片描述
图6.6-3 hello执行后按ctrl-z

按下ctrl-z相当于发送SIGSTP信号,它是来自中断的停止信号,使得进程停止,持续到下一个SIGCONT。

(2) 按下ps和jobs
在这里插入图片描述
图6.6-4 按下ctrl-z后按下ps和jobs

通过ps命令,我们可以发现hello进程还没有被回收,而是转移到后台,此时它的PID为16073;
通过jobs命令,我们可以发现hello的job号为1。

(3) 按下pstree
在这里插入图片描述
(中间还有很多内容没有截图)
在这里插入图片描述
图6.6-5 按下ctrl-z后按下pstree

该命令以树状图显示进程间的关系。

在这里插入图片描述
图6.6-6 按下ctrl-z后按下kill

该命令会发送信号SIGKILL,强行杀死该进程。

6.7本章小结
本章我们终于遇到了计算机发展历史上最成功的概念——进程。仍然说一下,进程提供给应用两个重要的抽象:1)逻辑控制流,它提供给每个程序一个假象,好像它是在独占地使用处理器,2)私有地址空间,它提供给每个程序一个假象,好像它是在独占地使用内存。此外,我们主要还谈到了信号,它是一种更高层的软件形式的异常,允许进程和内核中断其他进程。

(第6章1分)

第7章 hello的存储管理
7.1 hello的存储器地址空间
(1) 物理地址
计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,每字节都有一个唯一的物理地址(Physical Address, PA)。第一个字节的地址为0,接下来的字节地址为1,再下一个为2,以此类推。给定这种简单的结构,CPU访问内存的最自然的方式就是使用物理地址。我们把这种方式称为物理寻址(physical addressing)。[4]
hello程序的物理地址即hello在内存中对应的字节位置。

(2) 虚拟地址
早期的PC使用物理寻址,而且诸如数字信号处理器、嵌入式微控制器以及Cray超级计算机这样的系统仍然继续使用这种寻址方式。然而,现代处理器使用的是一种称为虚拟寻址(virtual addressing)的寻址形式,如下图:
在这里插入图片描述
图7.1-1 一个使用虚拟寻址的系统

使用虚拟寻址,CPU通过生成一个虚拟地址(Virtual Address, VA)来访问主存,这个虚拟地址在被送到内存之前先转换成适当的物理地址。[4]
hello程序的虚拟地址即它的字节在内存中存储的另一种表达方式,对应于物理地址但是不同于物理地址,它表达了二者之间的映射关系。

(3) 逻辑地址
逻辑地址(Logical Address)指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。

(4) 线性地址
跟逻辑地址类似,它也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址(Linear Address)则对应了硬件页式内存的转换前地址。[9]

7.2 Intel逻辑地址到线性地址的变换-段式管理
机器语言指令中出现的内存地址,都是逻辑地址,需要转换成线性地址,再经过MMU(CPU中的内存管理单元)转换成物理地址才能够被访问到。
在x86保护模式下,段的信息(段基线性地址、长度、权限等)即段描述符占8个字节,段信息无法直接存放在段寄存器中(段寄存器只有2字节)。Intel的设计是段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值(index)。
Linux中逻辑地址等于线性地址。为什么这么说呢?因为Linux所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从0x00000000开始,长度4G,这样,线性地址=逻辑地址+0x00000000,也就是说逻辑地址等于线性地址。
这样的情况下Linux只用到了GDT,不论是用户任务还是内核任务,都没有用到LDT。GDT的第12和13项段描述符是__KERNEL_CS和__KERNEL_DS,第14和15项段描述符是__USER_CS和__USER_DS。内核任务使用__KERNEL_CS和__KERNEL_DS,所有的用户任务共用__USER_CS 和__USER_DS,也就是说不需要给每个任务再单独分配段描述符。内核段描述符和用户段描述符虽然起始线性地址和长度都一样,但DPL(描述符特权级)是不一样的。__KERNEL_CS 和__KERNEL_DS 的DPL值为0(最高特权),__USER_CS 和__USER_DS的DPL值为3。[10]
在这里插入图片描述
图7.2-1 逻辑地址转线性地址

7.3 Hello的线性地址到物理地址的变换-页式管理
分页是CPU提供的一种机制,Linux只是根据这种机制的规则,利用它实现了内存管理。
分页的基本原理是把线性地址分成固定长度的单元,称为页(page)。页内部连续的线性地址映射到连续的物理地址中。x86每页为4KB(为简化分析,我们不考虑扩展分页的情况)。为了能转换成物理地址,我们需要给CPU提供当前任务的线性地址转物理地址的查找表,即页表(page table),页表存放在内存中宏。
在保护模式下,控制寄存器CR0的最高位PG位控制着分页管理机制是否生效,如果PG=1,分页机制生效,需通过页表查找才能把线性地址转换成物理地址。如果PG=0,则分页机制无效,线性地址就直接作为物理地址。
为了实现每个任务的平摊的虚拟内存和相互隔离,每个任务都有自己的页目录和页表。
为了节约页表占用的内存空间,x86将线性地址通过页目录表和页表两级查找转换成物理地址。
32位的线性地址被分为3个部分:
最高10位Directory页目录表偏移量,中间10位Table是页表偏移量,最低12位Offset是物理页内的字节偏移量。
页目录表的大小为4KB(刚好是一个页的大小),包含1024项,每个项4字节(32位),表项里存储的内容就是页表的物理地址(因为物理页地址4K字节对齐,物理地址低12位总是0,所以表项里的最低12字节记录了一些其他信息,这里做简化分析)。如果页目录表中的页表尚未分配,则物理地址填0.
页表的大小也是4K,同样包含1024项,每个项4字节,内容为最终物理页的内存起始地址。[10]
在这里插入图片描述
图7.3-1 线性地址转物理地址

7.4 TLB与四级页表支持下的VA到PA的变换
下图总结了完整的Core i7地址翻译过程,从CPU产生虚拟地址的时刻一直到来自内存的数据字到达CPU。Core i7采用四级页表层次结构。每个进程有它自己私有的页表层次结构。当一个Linux进程在运行时,虽然Core i7体系结构允许页表换进换出,但是与已分配了的页相关联的页表都是驻留在内存中的。CR3控制寄存器指向第一级页表(L1)的起始位置。CR3的值是每个进程上下文的一部分,每次上下文切换时,CR3的值都会被恢复。
在这里插入图片描述
图7.4-1 Core i7的内存系统

在这里插入图片描述
图7.4-2 Core i7地址翻译的概况。为了简化,没有显示i-cache、i-TLB和L2统一TLB

下图给出了第一级、第二级或第三级页表中条目的格式。当P=1时(Linux中就总是如此),地址字段包含一个40位物理页号(PPN),它指向适当的页表的开始处。注意,这强加了一个要求,要求物理页表4KB对齐。
在这里插入图片描述
图7.4-3 第一级、第二级和第三级页表条目格式,每个条目引用一个4KB子页表

下图给出了第四级页表中条目的格式。当P=1,地址字段包括一个40位PPN,它指向物理内存中某一页的基地址。这又强加了一个要求,要求物理页4KB对齐。
在这里插入图片描述
图7.4-4 第四级页表条目的格式。每个条目引用一个4KB子页

PTE有三个权限位,控制对页的访问。R/W位确定页的内容是可以读写的还是只读的。U/S位确定是否能够在用户模式中访问该页,从而保护操作系统内核中的代码和数据不被用户程序访问。XD(禁止执行)位是在64位系统中引入的,可以用来禁止从某些内存页取指令。这是一个重要的新特性,通过限制只能执行只读代码段,使得操作系统内核降低了缓冲区溢出攻击的风险。
当MMU翻译每一个虚拟地址时,它还会更新另外两个内核缺页处理程序会用到的位。每次访问一个页时,MMU都会设置A位,称为引用位(reference bit)。内核可以用这个引用位来实现它的页替换算法。每次对一个页进行了写之后,MMU都会设置D位,又称修改位或脏位(dirty bit)。修改位告诉内核在复制替换页之前是否必须写回牺牲页。内核可以通过调用一条特殊的内核模式指令来清除引用位或修改位。
下图给出了Core i7 MMU如何使用四级的页表来将虚拟地址翻译成物理地址。36位VPN被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN 1提供到一个L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN 2提供到一个L2 PTE的偏移量,以此类推。[4]
在这里插入图片描述
图7.4-5 Core i7页表翻译(PT:页表,PTE:页表条目,VPN:虚拟页号,VPO:虚拟页偏移,PPN:物理页号,PPO:物理页偏移量。图中还给出了这四级页表的Linux名字)

7.5 三级Cache支持下的物理内存访问
在任何既使用虚拟内存又使用SRAM高速缓存的系统中,都有应该使用虚拟地址还是使用物理地址来访问SRAM高速缓存的问题。尽管关于这个这种的详细讨论已经超出了我们的讨论范围,但是大多数系统是选择物理寻址的。使用物理寻址,多个进程同时在高速缓存中有存储块和共享来自相同虚拟页面的块成为很简单的事情。而且,高速缓存无需处理保护问题,因为访问权限的检查是地址翻译过程的一部分。
下图展示了一个物理寻址的高速缓存如何和虚拟内存结合起来。主要的思路是地址翻译发生在高速缓存查找之前。注意,页表条目可以缓存,就像其他的数据字一样。[4]
在这里插入图片描述
图7.5-1 将VM与物理寻址的高速缓存结合起来(VA:虚拟地址。PTEA:页表条目地址。PTE:页表条目。PA:物理地址)

7.6 hello进程fork时的内存映射
当fork函数被当前hello进程调用时,内核为新的hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新的hello进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新的hello进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。[4]
在这里插入图片描述
图7.6-1 Linux是如何组织虚拟内存的

7.7 hello进程execve时的内存映射
虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。既然已经理解了这些概念,我们就能够理解execve函数实际上是如何加载和执行程序的。假设运行在当前进程中的hello程序执行了如下的execve调用:
execve(“hello”, NULL, NULL);
execve函数在当前的hello进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
(1) 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2) 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。下图概括了私有区域的不同映射。
在这里插入图片描述
图7.7-1 加载器是如何映射用户地址空间的区域的

(3) 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
(4) 设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个程序时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。[4]

7.8 缺页故障与缺页中断处理
假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
(1) 虚拟地址A是合法的吗?换句话说,A在某个区域结构定义的区域内吗?为了回答这个问题,缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法,那么缺页处理程序就触发一个段错误,从而终止这个进程。这个情况在下图中标识为“1”。
因为一个进程可以创建任意数量的新虚拟内存区域,所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Linux使用某些我们没有显示出来的字段,Linux在链表中构建了一棵树,并在这棵树上进行查找。
(2) 试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。这种情况标识为“2”。
(3) 此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。[4]
在这里插入图片描述
图7.8-1 Linux缺页处理

7.9动态存储分配管理
虽然可以使用低级的mmap和munmap函数来创建和删除虚拟内存的区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器(dynamic memory allocator)更方便,也有更好的可移植性。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
(1) 显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。
(2) 隐式分配器(implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbage collection)。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。[4]
在这里插入图片描述
图7.9-1 堆

7.10本章小结
本章终于开启了对内存、地址等概念的分析。内存这东西,无论给多少都觉得不够用,可是越大的内存当然就需要越高的成本。本章还介绍了几种地址之间的转换(逻辑→线性,线性→物理),这是教材上没有提及或没有详细解释的部分。此外,本章最重点的部分是虚拟内存。第一,它在主存中自动缓存最近使用的存放磁盘上的虚拟地址空间的内容。虚拟内存缓存中的块叫做页。对磁盘上页的引用会触发缺页,缺页将控制转移到操作系统中的一个缺页处理程序。缺页处理程序将页面从磁盘复制到主存缓存,如果必要,将写会被驱逐的页。第二,虚拟内存简化了内存管理,进而又简化了链接、在进程间共享数据、进程的内存分配以及程序加载。最后,虚拟内存通过在每条页表条目中加入保护位,从而简化了内存保护。

(第7章 2分)

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m个字节的序列:
B[0], B[1], …, B[k], …, B[m-1]
所有的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增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会触发一个成为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。
类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
(5) 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。[4]

8.2 简述Unix IO接口及其函数
进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(char *filename, int flags, mode_t mode);
返回:若成功则为新文件描述符,若出错则为-1。
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件。
(1) O_RDONLY:只读。
(2) O_WRONLY:只写。
(3) O_RDWR:可读可写。
flags参数也可以是一个或者更多位掩码的或,为写提供给一些额外的指示:
(1) O_CREAT:如果文件不存在,就创建它的一个截断(truncated)(空)文件。
(2) O_TRUNC:如果文件已经存在,就截断它。
(3) O_APPEND:在每次写操作前,设置文件位置到文件的结尾处。
mode参数制定了新文件的访问权限位。这些位的符号名字如下图所示:
在这里插入图片描述
图8.2-1 访问权限位。在sys/stat.h中定义

作为上下文的一部分,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置为mode & ~ umask。
最后,进程通过调用close函数关闭一个文件的打开。
#include <unistd.h>
int close(int fd);
返回:若成功则为0,若出错则为-1.
8.3 printf的实现分析
我们首先看一下printf函数的函数体
int printf(const char *fmt, …)
{
int i;
char buf[256];

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

 return i; 

}
va_list的定义:
typedef char va_list
这说明它是一个字符指针。
其中的:(char
)(&fmt) + 4) 表示的是…中的第一个参数。

然后再来看一下vsprintf的函数体
int vsprintf(char *buf, const char fmt, va_list args)
{
char
p;
char tmp[256];
va_list p_next_arg = args;

for (p=buf;*fmt;fmt++) { 
if (*fmt != '%') { 
*p++ = *fmt; 
continue; 
} 

fmt++; 

switch (*fmt) { 
case 'x': 
itoa(tmp, *((int*)p_next_arg)); 
strcpy(p, tmp); 
p_next_arg += 4; 
p += strlen(tmp); 
break; 
case 's': 
break; 
default: 
break; 
} 
} 

return (p - buf); 

}
它接受一个格式化的命令,并把指定的匹配的参数格式化输出。
观察i = vsprintf(buf, fmt, arg);可知,该函数的返回值是要打印出来的字符串长度。
write,顾名思义:写操作,把buf中的i个元素的值写到终端。
所以说:vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。

下面再来分析write函数:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
只需知道一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
再来看一下sys_call的实现:
sys_call:
call save

 push dword [p_proc_ready] 

 sti 

 push ecx 
 push ebx 
 call [sys_call_table + eax * 4] 
 add esp, 4 * 3 

 mov [esi + EAXREG - P_STACKBASE], eax 

 cli 

 ret

一个call save,是为了保存中断前进程的状态。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。[11]

8.4 getchar的实现分析
getchar由宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
注:可以利用getchar()函数让程序调试运行结束后等待编程者按下键盘才返回编辑界面,用法:在主函数结尾,return 0;之前加上getchar();当你getchar()前没有使用回车的话可以用这个函数让程序调试运行结束后等待编程者按下键盘才返回编辑界面,否则将直接返回编辑界面。[12]
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
从第一天学编程开始,第一次接触hello, world起,我们就学会了调用printf、getchar、scanf等输入输出函数,可是并不知道它的机器级执行过程。Linux提供了少量的基于Unix I/O模型的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据。Linux内核使用三个相关的数据结构来表示打开的文件。描述符表中的表项指向打开文件表中的表项,而打开文件表中的表项又指向v-node表中的表项。每个进程都有它自己单独的描述符表,而所有的进程共享同一个打开文件表和v-node表。

(第8章1分)
结论
hello的一生会经历如下阶段:
(1) 预处理
预处理器cpp将.c文件翻译成.i的文件,将所有预处理命令(包括#include,#define等)全都递归替换成最原始的代码,扩展后的文件约3000多行;
(2) 编译
gcc编译器将.i文件翻译成.s格式的汇编语言文件,并同时进行隐式的类型转换等操作;
(3) 汇编
as汇编器将.s文件转换成全是十六机制机器码的.o文件;
(4) 链接
ld链接器链接我们的hello.o文件,以及到静态函数库找寻所需要的printf等函数的.o文件,将这些.o文件链接起来形成最终的可执行文件hello,并将.o的elf可重定位目标文件中相同类型的变量重新聚合,放入到新的hello的elf可重定位目标文件相应的节中;
(5) 进程创建
开始执行a.out格式的文件hello。这时,shell为hello程序fork一个子进程;
(6) 程序运行
shell调用execve函数,execve函数调用加载器,映射虚拟内存,进入程序入口后开始载入物理内存,然后进入main函数;
(7) 指令执行
hello和其他进程并发地运行,CPU为其分配时间片。进程给用户两个假象:好像独占地使用内存、好像独占地使用处理器;
(8) 内存访问
MMU将虚拟内存地址翻译成物理地址,然后访问,这其中必然会发生缺页异常;
(9) 进程回收
shell回收子进程,系统释放该进程的数据所占的内存空间。

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

附件
hello.i:由预处理器翻译成的文本文件
hello.s:由编译器编译成的汇编语言文件
hello.o:由汇编器翻译成的可重定位目标文件
helloo.txt:hello.o文件由objdump形成的反汇编代码文件的输出重定向
helloaout.txt:hello文件由objdump形成的反汇编代码文件的输出重定向
hellooelf.txt:hello.o文件的elf全部信息的输出重定向
helloaoutelf.txt:hello文件的elf 全部信息的输出重定向

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

参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 百度百科.预处理命令
https://baike.baidu.com/item/预处理命令/10204389?fr=aladdin
[2] 百度知道.C语言里面的预处理是什么意思?
https://zhidao.baidu.com/question/1759223752314319228.html
[3] GCC编译器原理(三) ------编译原理三:编译过程—预处理
https://www.cnblogs.com/kele-dad/p/9490640.html
[4] Randal E.Bryant, David R.O’Hallaron, 龚奕利, 贺莲.CS: APP深入理解计算机系统(第三版)
[5] 百度百科.汇编程序
https://baike.baidu.com/item/汇编程序/298210 - 4
[6] Linux 链接详解(1)
https://www.cnblogs.com/MaAce/p/7832067.html
[7] ELF(三)程序头表
https://blog.csdn.net/ylcangel/article/details/18145155
[8] 20135323符运锦---- 《深入理解计算机系统》第七章知识点总结
https://www.cnblogs.com/20135323fuyunjin/p/5361461.html
[9] LINUX 逻辑地址、线性地址、物理地址和虚拟地址 转
https://www.cnblogs.com/zengkefu/p/5452792.html
[10] Linux下逻辑地址、线性地址、物理地址详细总结
https://blog.csdn.net/FreeeLinux/article/details/54136688
[11] [转]printf函数实现的深入剖析
https://www.cnblogs.com/pianist/p/3315801.html
[12] 百度百科.getchar
https://baike.baidu.com/item/getchar/919709?fr=aladdin

(参考文献0分,缺失 -1分)

猜你喜欢

转载自blog.csdn.net/weixin_43915689/article/details/85567553
今日推荐