计算机系统大作业

摘要

本文遍历了hello.c在Linux下生命周期,借助Linux下系列开发工具,通过对其预处理、编译、汇编等过程的分步解读及对比来学习各个过程在Linux下实现机制及原因。同时通过对hello在Shell中的动态链接、进程运行、内存管理、I/O管理等过程的探索来更深层次的理解Linux系统下的动态链接机制、存储层次结构、异常控制流、虚拟内存及UnixI/O等相关内容。旨在将课本知识与实例结合学习,更加深入地理解计算机系统的课程内容。

关键词
操作系统;编译;链接;虚拟内存;异常控制流;

目 录

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

第1章 概述
1.1 Hello简介

P2P:From Program to Process

gcc编译器驱动程序读取程序文件hello.c,然后由预处理器(cpp)根据以#字符开头的命令,修改该程序获得hello.i文件,通过编译器(cll)将文本文件hello.i翻译成文本文件形式的汇编程序hello.s,再通过汇编器(cs)将hello.s翻译成机器语言指令,将指令打包成可重定位的目标文件hello.o,然后通过链接器(ld)合并获得可执行目标文件hello。Linux系统中通过内置命令行解释器shell加载运行hello程序,为hello程序fork进程,至此,hello.c完成了P2P的过程。

O2O:From Zero-0 to Zero -0

Shell通过execve在fork产生的子进程中加载hello,先删除当前虚拟地址的用户部分已存在的数据结构,为hello的代码、数据、bss和栈区域创建新的区域结构,然后映射共享区域,设置程序计数器,使之指向代码区域的入口点,进入main函数,CPU为hello分配时间片执行逻辑控制流。hello通过Unix I/O管理来控制输出。hello执行完成后shell会回收hello进程,并且内核会从系统中删除hello所有痕迹,至此,hello完成O2O的 过程。

1.2环境与工具

硬件环境
Intel(R)Core™i5-7200U CPU 2.50GHx 2.70GHz 8G RAM X64 256GSSD + 1TGHD
软件环境
操作系统:Winsows 64
虚拟机:Vmware 14
Linux:16.04
开发工具
gcc ld edb readelf gedit hexedit objdump
1.3中间结果
hello.c:源代码
hello.i:hello.c预处理生成的文本文件。
hello.s:hello.i经过编译器翻译成的文本文件hello.s,含汇编语言程序。
hello.o:hello.s经汇编器翻译成机器语言指令打包成的可重定位目标文件
hello.elf:hello.o的ELF格式。
hello_o_asm.txt:hello.o反汇编生成的代码。
hello:经过hello.o链接生成的可执行目标文件。
hello_out.elf:hello的ELF格式。
hello_out_asm.txt:hello反汇编生成的代码。
1.4本章小结
本章主要是漫游式地了解hello在系统中生命周期,对每个部分需要有系统地了解;同时本章列出本次实验的基本信息。

(第1章0.5分)

第2章 预处理
2.1 预处理的概念与作用
概念:在编译之前进行的处理。 C语言的预处理主要有三个方面的内容: 1.宏定义; 2.文件包含; 3.条件编译4.删除注释。 预处理命令以符号“#”开头。
主要功能:

  1. 文件包含。通常,该文件是后缀名为"h"或"hpp"的头文件。文件包含命令把指定头文件插入该命令行位置取代该命令行,从而把指定的文件和当前的源程序文件连成一个源文件。例如hello.c中第一行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h文件的内容,并把它直接插入到程序文本中

  2. 删除注释;

  3. 执行宏替代。宏定义分为有参数和无参数两种例如#define MAX 2147483647在预处理中会把所有MAX替代为2147483647,#define MAX(x,y) ((x)>(y))?(x): (y)在预处理中会把所有MAX(x,y)替换为((x)>(y))?(x): (y)。

  4. 条件编译。
    一般情况下,源程序中所有的行都参加编译。但有时希望对其中一部分内容只在满足一定条件才进行编译,也就是对一部分内容指定编译的条件,这就是“条件编译”。有时,希望当满足某条件时对一组语句进行编译,而当条件不满足时则编译另一组语句。
    条件编译功能可按不同的条件去编译不同的程序部分,从而产生不同的目标代码文件。这对于程序的移植和调试是很有用的。
    条件编译有三种形式
    4.1 #ifdef形式
    #ifdef 标识符 (或#if defined标识符)
    程序段1
    #else
    程序段2
    #endif
    如果标识符已被#define命令定义过,则对程序段1进行编译;否则对程序段2进行编译。如果没有程序段2(它为空),#else可以没有,即可以写为:
    #ifdef 标识符 (或#if defined标识符)
    程序段
    #endif
    这里的“程序段”可以是语句组,也可以是命令行。这种条件编译可以提高C源程序的通用性。

#ifndef形式
#ifndef 标识符
程序段1
#else
程序段2
#endif
如果标识符未被#define命令定义过,则对程序段1进行编译,否则对程序段2进行编译。这与#ifdef形式的功能正相反。
#ifndef标识符”也可写为“#if !(defined 标识符)”。
4.3 #if形式
#if 常量表达式
程序段1
#else
程序段2
#endif
如果常量表达式的值为真(非0),则对程序段1 进行编译,否则对程序段2进行编译。因此可使程序在不同条件下,完成不同的功能。

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

经过预处理之后,hello.c文件转化为hello.i文件。原文件中的宏进行了宏展开,头文件中的内容被包含进该文件中。打开该文件可以发现,文件长度变为3125行。因为hello.c包含的头文件中还包含有其他头文件,因此系统会递归式的寻址和展开,直到文件中不含宏定义且相关的头文件均已被引入。同时引入了头文件中所有typedef关键字,结构体类型、枚举类型、通过extern关键字调用并声明外部的结构体及函数定义。文件的内容增加,且仍为可以阅读的C语言程序文本文件
2.4 本章小结
本阶段完成了对hello.c的预处理工作。使用Ubuntu下的预处理指令可以将其转换为.i文件。完成该阶段转换后,可以进行下一阶段的汇编处理。

(第2章0.5分)

第3章 编译
3.1 编译的概念与作用

概念: 编译是利用编译程序从预处理文本文件产生汇编程序(文本)的过程。主要包含五个阶段:词法分析;语法分析;语义检查、中间代码生成、目标代码生成。

作用:编译作用主要是将文本文件hello.i翻译成文本文件hello.s,并在出现语法错误时给出提示信息,执行过程主要从其中三个阶段进行分析:

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

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

  3. 目标代码生成。目标代码生成器把语法分析后或优化后的中间代码经汇编程序汇编生成汇编语言代码,成为可执行的机器语言代码。
    3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.3.1指令解释
声明 含义
.file 可重定位目标文件
.text 已编译程序的汇编代码
.section .rodata 只读数据
.globl 全局变量
.type 函数或对象类型
.size 规模大小
.align 对指令或者数据的存放地址进行对齐的方式
3.3.2 数据类型

  1. 字符串型

汇编语言中,输出字符串作为全局变量保存,因此存储于.rodata节中。汇编文件hello.s中,共有两个字符串,均作为printf参数,分别为:

原本字符串为“Usage: Hello 1170500116 张婉茹!”,对应中文字符可以看出对中文字符进行了utf-8编码,中文汉字以‘\345’开头,占三个字符,而全角字符‘!’占用两个字符。

该字符串为printf格式化输出的字符串,正常保存。

  1. 整型

针对汇编文件中出现的整型数:

sleepsecs被定义为整型全局变量,因此对2.5进行向下取整,sleepsecs为2,根据对齐方式.align为4,sleepsecs的size大小定义为了4Byte。hello.c中定义sleepsecs为整型,但是在hello.s中可以看出其类型为.long。是因为编译器ccl会将int表示为long但对齐方式仍为int型的4字节,long类型表示为双字quad,对齐方式仍为8字节。

此外,还有main函数内部的整型数,例如main函数参数argc,其为函数第一个参数,保存于栈空间中-0x20(%rbp),占4个字节大小;局部变量i位于栈空间-0x4(%rbp)位置,占4个字节大小;还有立即数,如判断argc!=3、i<10等直接作为立即数存放于指令中。

  1. 数组

在hello.c中有对数组的应用,如下:

数组为main函数参数,循环代码为:

图3.3.2.8 hello.s循环输出部分

argv[2]作为printf函数的第三个参数,应当存于寄存器%rdx中,因此可推断argv[2]地址为-0x16(%rbp);argv[1]作为printf第二个参数,应当存于寄存器%rsi中,因此可推断argv[1]地址为-0x2A(%rbp)中,数组首地址位于-0x32(%rbp) ,以上所占字节数为8。

3.3.3 汇编语言操作

  1. 数据传送指令
    a) MOV类
    MOV类主要由4条指令组成:movb、movw、movl、movq。主要区别在于他们操作的数据大小不同分别为1,2,4,8字节。

指令 效果 描述
MOV S,D D<—S 传送
movb R<—I 传送字节
movw R<—I 传送字
movl R<—I 传送双字
movq R<—I 传送四字
movabsq I ,R R<—I 传送绝对的四字
在hello.s中也存在着大量的MOV数据传送,例如:

  1. 算数和逻辑操作

获取栈空间:

根据argv首地址获得argv[1]和argv[2]:

循环中i++:

3.3.4 控制转移

a) 跳转指令

比较argc与3如果相等则执行循环,不等则打印提示信息退出程序:

argc与3不等的情况下:

循环体中(.L4为循环体内部语句),i<=9则执行.L4:

a) 转移控制
指令 描述
call Label 过程调用
call *Operand 过程调用
ret 从过程调用中返回

call函数过程调用并将下一条指令压栈,hello.s中用到转移控制的部分:

如果argc不等于3,将输出提示信息并执行exit函数退出程序:

循环体调用printf函数和sleep函数:

循环结束后执行getchar函数:

3.3.5 函数操作

hello.c中的函数:

a) int main(int argc, char *argv[])

参数传递与函数调用:内核执行c程序时调用特殊的启动例程,并将启动例程作为程序的起始地址,从内核中获取命令行参数和环境变量地址,执行main函数。

函数退出:hello.c中main函数有两个出口,第一个是当命令行参数数量不为3时输出提示信息并调用exit(1)退出main函数;第二个是命令行参数数量为3执行循环和getchar函数后return 0的方式退出函数。

函数栈帧结构的分配与释放:main函数通过pushq %rbp、movq %rsp, %rbp、subq $32, %rsp 为函数分配栈空间,如果是通过exit函数结束main函数则不会释放内存,会造成内存泄露,但是程序如果通过return正常返回则是由指令leave即mov %rbp,%rsp,pop %rbp恢复栈空间。

b) exit():

参数传递与函数调用:在hello.c中设置%edi值为0表示赋给exit函数第一个变量,然后通过call函数调用exit()。

c) printf()

参数传递与函数调用:printf参数根据字符串中的输出占位符数量来决定的。

hello.s中调用printf函数函数如下图:

如果printf函数有其他参数时,main函数按照寄存器表示参数的顺序为printf构造参数然后通过calll指令调用printf:
参数 1 2 3 4 5 6 其余
地址 %rdi %rsi %rdx %rcx %r8 %r9 被调用函数的栈帧

如果printf只有字符串作为参数则main函数设置%rdi(%edi)为格式化字符串地址,通过call函数调用puts函数。

函数返回:printf()函数返回值为printf打印的字符数。puts()函数执行成功返回非负数,执行失败返回EOF。

d) sleep()

图3.3.5.3 sleep函数调用

参数传递与函数调用:main函数设置第一个参数%edi为sleepsecs。通过call指令调用sleep()函数。

函数返回:若进程/线程挂起到参数所指定的时间则返回0,若有信号中断则返回剩余秒数。

e) getchar()

图3.3.5.4 getchar函数调用

参数传递与函数调用:main函数通过call指令调用getchar()函数,且getchar()函数无参数。

函数返回:getchar()函数返回值类型为int,如果成功返回用户输入的ASCII码,出错返回-1。

3.3.6 类型转换

类型转换分为显示和隐式。

隐式转换:隐式转换就是系统默认的、不需要加以声明就可以进行的转换数据类型自动提升,数据类型提升顺序为:

byte,short,char -->int -->long -->float -->double

显示转换:程序通过强制类型转换运算符将某类型数据转换为另一种类型。

hello中存在隐式转换。即:

int类型变量赋值float或double类型常量会发生隐式转换,根据向下取整的原则,系统会将整数部分赋值给变量。因此sleepsecs值为2。

3.4 本章小结

本章系统阐述了编译器将预处理文本文件hello.i翻译为文本文件hello.s的具体操作,主要就汇编语言伪指令、数据类型、汇编语言操作、控制转移,函数操作、类型转换六方面针对hello.s中各部分做出相应的解释说明。

(第3章2分)

第4章 汇编
4.1 汇编的概念与作用

概念:把汇编语言翻译成机器语言的过程称为汇编。
作用:汇编器(as)将hello.s翻译成机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在二进制目标文件hello.o中。
4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

使用readelf –h hello.o查看文件头信息。

根据文件头的信息,可以知道该文件是可重定位目标文件,有13个节。

使用readelf –S hello.o查看节头表。

从而得知各节的大小,以及他们可以进行的操作
可以得到各节的基本信息。由于是可重定位目标文件,所以每个节都从0开始,用于重定位。在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小。同时可以观察到,代码段是可执行的,但是不能写;数据段和只读数据段都不可执行,而且只读数据段也不可写。

使用readelf –s hello.o可以查看符号表的信息。

hello.o文件的ELF格式组成如下:
(1)ELF头
Magic序列:大小为16B,描述了生成该文件的系统的字的大小和字节顺序。
其余内容:包含帮助链接器解析和解释目标文件的信息,包括ELF头的大小,目标文件的类型,机器类型,节头表的文件偏移量以及节标头信息,例如表中的条目大小和数量。

(2)Section Headers
节头部表,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。
(3)rela.text
重定位节是在.text节中表示位置的一个列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
(4)rela.eh_frame
eh_frame节的重定位信息。
(5)symtab
符号表,用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明。

ELF定义了32种不同的重定位类型,针对hello.o中出现的两个:

R X86_ 64 PC32。 重定位-一个使用32位PC相对地址的引用。一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址(如call指令的目标),PC值通常是下一条指令在内存中的地址。

R X86_ 64 _32。 重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
对hello.o中的puts函数进行重定位PC的相对引用分析:

定义puts的重定位条目为r,则其信息为:

  1. {
  2.  r.offset = 0x1b  
    
  3.  r.symbol = puts  
    
  4.  r.type = R_X86_64_PC32  
    
  5.  r.addend = -0x4  
    
  6. }
    利用objdump获得hello.o的反汇编代码,可得出其占位符位置为:

链接过程中链接器可以确定ADDR(s) 和 ADRR(r.symble),利用以下公式:

  1. refptr = s + r.offset;
  2. refaddr = ADDR(s) + r.offset;
  3. *refptr = (unsigned)(ADDR(r.symbol) + r.addend - refaddr);

可以得出*refptr为0x**,执行这条指令时,CPU会执行以下的步骤:

1将PC压入栈中;
PC <— PC+0x**即可获得puts函数的地址,成功执行。

对hello.o中的.rodata进行重定位PC的绝对引用分析:

定义其重定位条目为r,则其信息为:

  1. {
  2.  r.offset = 0x16  
    
  3.  r.symbol = .rodata  
    
  4.  r.type = R_X86_64_32  
    
  5.  r.addend = 0x0  
    
  6. }
    这些字段告诉链接器要修改从偏移量0x16开始的绝对引用,这样会在运行时指向.rodata+0x0的位置。利用以下公式:
  7. refptr = s + r.offset;
  8. *refptr = (unsigned)(ADDR(r.symbol) + r.addend);

可以链接器链接时获得refptr,修改偏移量0x16处的占位符为refptr即可绝对引用,获得printf格式串的地址。

以上分别举了重定位PC相对引用的实例和重定位绝对引用的实例,其他重定位条目情况均相似。

4.4 Hello.o的结果解析
objdump -d -r hello.o

和编译后的文件进行比较,可以观察到如下差别:
a) 文件内容构成

编译后的文件中只有对文件最简单的描述,记录了文件格式和.text代码段;而有对文件的描述,全局变量的完整描述(包括.type .size .align 大小及数据类型)以及.rodata只读数据段。

两者均包含main函数的汇编代码,但是区别在于hello.s的汇编代码是由一段段的语句构成,同时声明了程序起始位置及其基本信息等;而hello_o_asm.txt则是由一整块的代码构成,除需要链接后才能确定的地址(此处用空白占位符填充),代码包含有完整的跳转逻辑和函数调用等。

b) 分支转移

hello.o反汇编后的包含了由操作数和操作码构成的机器语言,跳转指令中地址为已确定的实际指令地址(因为函数内部跳转无需通过链接确定);hello.s主要使用通过使用例如.L0、.L1等的助记符表示的段完成内部跳转及函数条用的逻辑。
a) 函数调用

hello.o反汇编后的文件中,call地址后为占位符(4个字节的0),指向的是下一条地址的位置,原因是库函数调用需要通过链接时重定位才能确定地址;而hello.s中的函数调用直接是call+函数名表示。

a) 数据访问方式

hello.o反汇编后的文件中,对.rodata中printf的格式串的访问需要通过链接时重定位的绝对引用确定地址,因此在汇编代码相应位置仍为占位符表示,对.data中已初始化的全局变量sleepsecs为0x0+%rip的方式访问;hello.s中访问方式为sleepsecs+%rip(此处的sleepsecs表示的是段名称而不是变量本身),格式串则需要通过助记符.LC0、.LC1等。两者访问参数的方式均相同,通过栈帧结构及%rbp相对寻址访问。

4.5 本章小结
本阶段完成了对hello.s的汇编工作。使用Ubuntu下的汇编指令可以将其转换为.o可重定位目标文件。此外,本章通过将.o文件反汇编结果与.s汇编程序代码进行比较,了解了二者之间的差别。完成该阶段转换后,可以进行下一阶段的链接工作。
(第4章1分)

第5章 链接
5.1 链接的概念与作用
(1)概念
由汇编程序生成的目标文件并不能立即就被执行,其中可能还有许多没有解决的问题。
例如,某个源文件中的函数可能引用了另一个源文件中定义的某个符号(如变量或者函数调用等);在程序中可能调用了某个库文件中的函数,等等。所有的这些问题,都需要经链接程序的处理方能得以解决。
链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。
(2)功能
根据开发人员指定的同库函数的链接方式的不同,链接处理可分为两种:
1.静态链接
在这种链接方式下,函数的代码将从其所在的静态链接库中被拷贝到最终的可执行程序中。这样该程序在被执行时这些代码将被装入到该进程的虚拟地址空间中。静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码。
2.动态链接
在此种方式下,函数的代码被放到称作是动态链接库或共享对象的某个目标文件中。链接程序此时所作的只是在最终的可执行程序中记录下共享对象的名字以及其它少量的登记信息。在此可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码。对于可执行文件中的函数调用,可分别采用动态链接或静态链接的方法。使用动态链接能够使最终的可执行文件比较短小,并且当共享对象被多个进程使用时能节约一些内存,因为在内存中只需要保存一份此共享对象的代码。但并不是使用动态链接就一定比使用静态链接要优越。在某些情况下动态链接可能带来一些性能上损害。
5.2 在Ubuntu下链接的命令

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

在 ELF 格式文件中,Section Headers 声明hello 中的所有section 信息,包 括程序中的 size size 和 offset Offset,因此根据 Section Headers 中的信息, 我们可以使用 HexEdit 来定位每个的间隔(起始位置和大小)。地址是程序加载 到虚拟地址的起始地址。

5.4 hello的虚拟地址空间
使用edb加载hello查看虚拟地址空间信息
通过 ELF 格式文件中的程序头表可以看到动态链接的信息。

每一个表项提供 了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方 面的信息。在下面可以看出,程序包含7 个段。 ( 1 ) PHDR : 保存程序头表。 ( 2 ) INTERP : 指定在程序已经从可执行文件映射到内存之后,必须调用的解释器(如动 态链接器)。 ( 3 )LOAD 表 示 一 个 需 要 从 二 进 制 文 件 映 射 到 虚 拟 地 址 空 间 的 段 。其 中 保 存 了 常 量 数 据( 如 字符串)、程序的目标代码等 , 并且 分为 读写 两个部分 。 ( 4 ) DYNAMIC 保存了由动态链接器使用的信息。 ( 5 ) NOTE 保存辅助信息。 ( 6 ) GNU_STACK :权限标志,标志栈是否是可执行的。 ( 7 ) GNU_RELRO :指定在重定位结束之后那些 内存区域是需要设 置只读。
edb加载hello可以从Data Dump中查看虚拟地址空间,程序的虚拟地址空间为0x00000000004000000-0x0000000000401000,

首先查看hello_out.elf中的程序头部分

对应Data Dump中各段映射关系分别为:

图5.4.4 PHDR部分

此处PHDR表示该段具有读/执行权限,表示自身所在的程序头部表在内存中的位置为内存起始位置0x400000偏移0x40字节处、大小为0x1c0字节。

图5.4.5 INTERP部分

此处INTERP表示该段具有读权限,位于内存起始位置0x400000偏移0x200字节处,大小为0x1c个字节,记录了程序所用ELF解析器(动态链接器)的位置位于: /lib64/ld-linux-x86-64.so.2。

图5.4.6 LOAD代码段

此处LOAD表示第一个段(代码段)有读/执行访问权限,开始于内存地址0x400000处,总共内存大小是0x838个字节,并且被初始化为可执行目标文件的头0x838字节,其中其中包括ELF头、程序头部表以及.init、.text、.rodata字节。

图5.4.7 LOAD(代码段)

此处的LOAD表示第二个段(数据段)有读写权限,开始于内存地址0x600e10地址处,总的内存大小为0x250个字节,并用从目标文件中偏移0xe10处开始的.data中的0x24c个字节初始化。该段中剩下的4个字节对应于初始时将被初始化为0的.bss数据。

图5.4.8 NOTE部分

此处NOTE表示该段位于内存起始位置0x400000偏移0x21c字节处,大小为0x20个字节,该段是以‘\0’结尾的字符串,包含一些附加信息。

图5.4.9 GUN_RELOAD部分

此处为GNU_RELRO表示该段在重定位后设置为只读属性。

图5.4.10 DYNAMIC部分

该段描述动态链接信息。

5.5 链接的重定位过程分析
(1)节内容增加:

(2)函数增加:在使用 ld 命令链接的时候,指定了动态链接器为 64 的 /lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o 中主要定义了程序入口_start、初 始化函数_init,_start 程序调用 hello.c 中的 main函数,libc.so 是动态链接共享库, 其中定义了 hello.c 中用到的 printf、sleep、getchar、exit 函数和_start 中调用的 __libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入。
(3)函数调用:链接器在完成符号解析以后,就把代码中的每个符号引用和 正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。 此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。然后就 可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运 行时的地址。在 hello 到 hello.o 中,首先是重定位节和符号定义,链接器将所有输 入到 hello 中相同类型的节合并为同一类型的新的聚合节。例如,来自所有的输入 模块的.data节被全部合并成一个节,这个节成为 hello 的.data节。然后,链接器将 运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模 块定义的每一个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一 的运行时内存地址了。然后是重定位节中的符号引用,链接器会修改 hello中的代 码节和数据节中对每一个符号的引用,使得他们指向正确的运行地址。
计算机系统课程报告
以puts为例,简述其链接过程:

puts第一次被调用时程序从过程链接表PLT中进入其对应的条目;
第一条PLT指令通过全局偏移量表GOT中对应条目进行间接跳转,初始时每个GOT条目都指向它对应的PLT条目的第二条指令,这个简单跳转把控制传送回对应PLT条目的下一条指令;
把puts函数压入栈中之后,对应PLT条目调回PLT[0];
PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]间接跳转进动态链接器中。动态链接器使用两个栈条目确定puts的运行时位置,用这个地址重写puts对应的GOT条目,再把控制传回给puts。

第二次调用的过程:
puts被调用时程序从过程链表PLT中进入对应的条目;
通过对应GOT条目的间接跳转直接会将控制转移到puts。
5.6 hello的执行流程

5.7 Hello的动态链接分析
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。

延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:

PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。

GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。

根据hello ELF文件可知,GOT起始表位置为0x601000,如图:

在调用dl_init之前0x601008后的16个字节均为0:

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

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

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

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

可以看出其已经动态链接,GOT条目已经改变。

5.8 本章小结
本章了解了链接的概念作用,分析可执行文件hello的ELF格式及其虚拟地址空间,同时通过实例分析了重定位过程、加载以及运行时函数调用顺序以及动态链接过程,深入理解链接和重定位的过程。
(第5章1分)

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

作用:进程给应用程序提供的关键抽象有两种:
a) 一个独立的逻辑控制流,提供一个假象,程序独占地使用处理器。
b) 一个私有的地址空间,提供一个假象,程序在独占地使用系统内存。
6.2 简述壳Shell-bash的作用与处理流程
shell作为UNIX的一个重要组成部分,是它的外壳,也是用户于UNIX系统交互作用界面。Shell是一个命令解释程序,也是一种程序设计语言。

  1. 读入命令行、注册相应的信号处理程序、初始化进程组。
  2. 通过paraseline函数解释命令行,如果是内置命令则直接执行,否则阻塞信号后创建相应子进程,在子进程中解除阻塞,将子进程单独设置为一个进程组,在新的进程组中执行子进程。父进程中增加作业后解除阻塞。如果是前台作业则等待其变为非前台程序,如果是后台程序则打印作业信息。
    6.3 Hello的fork进程创建过程
    首先了解进程的创建过程:父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时。子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的id。

fork后调用一次返回两次,在父进程中fork会返回子进程的PID,在子进程中fork会返回0;父进程与子进程是并发运行的独立进程。内核能够以任何方式交替执行他们逻辑控制流中的指令。

hello的fork进程创建过程为:系统进程创建hello子进程然后调用waitpid()函数知道hello子进程结束,程序进程图如下:

6.4 Hello的execve过程
系统为hello fork子进程之后,子进程调用execve函数加载并运行可执行目标文件hello,并且带参数列表argv和环境变量列表envp,并将控制传递给main函数,以下是其详细过程:
加载器将hello中的代码和数据从磁盘复制到内存中,它创建类似于Linux x86-64运行的虚拟内存映像,如下图所示:

加载器在程序头部表的引导下将hello的片复制到代码段和数据段。接下来加载器跳转到程序的入口点——_start函数的地址,_start函数调用系统启动函数__libc_start_main(定义在libc.so中),该启动函数初始化执行环境,处理main函数返回值,在需要的时候把控制传回给内核。

6.5 Hello的进程执行
首先了解进程执行中逻辑控制流、并发流、用户模式和内核模式、上下文切换等概念:
a) 逻辑控制流
在调试器单步执行程序时,会发现一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC的值的序列叫做逻辑控制流。

b) 并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地执行。
多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念被称为多任务。
一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫时间分片。
c) 用户模式和内核模式
处理器通过某个控制寄存器中的一个模式位来提供限制一个应用可以执行的指令以及它可以访问的地址空间范围的功能。该寄存器描述了当前进程享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存为止;没有设置模式位时,进程就运行在用户模式中。用户模式的进程不允许和执行特权指令、也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。

d) 上下文切换
内核为每个进程维持一个上下文,上下文就是内核重新启动的一个被强占的进程所需的状态。由包括通用目的寄存器、浮点寄存器、程序计数器、用户站、状态寄存器、内核栈和各种内核数据结构。
上下文切换的机制:
保存当前进程的上下文;
恢复某个先前被抢占的进程被保存的上下文;
将控制传递给这个新恢复的进程。
接下来阐述进程调度的概念及过程、用户态和核心态的转换:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这个决策就叫做调度,是由内核中称为调度器的代码处理的。在内和调度了一个新的进程运行后,它就抢占当前进程,并使用上文所述的上下文切换的机制将控制转移到新的进程。内核代表的用户执行系统调用时,可能会发生上下文切换;中断也有可能引发上下文切换。

通过上图所示的内核模式用户模式的切换描述用户态核心态转换的过程,在切换的第一部分中,内核代表进程A在内核模式下执行指令。然后在某一时刻,它开始代表进程B(仍然是内核模式下)执行指令。在切换之后,内核代表进程B在用户模式下执行指令。随后,进程B在用户模式下运行一会儿,直到磁盘发出一个中断信号,表示数据已经从磁盘传送到了内存。内核判定进程B已经运行了足够长的时间,就执行一个从进程B到进程A的上下文切换,将控制返回给进程A中紧随在系统调用read之后的那条指令。进程A继续运行,直到下一次异常发生,依此类推
6.6 hello的异常与信号处理
hello执行过程中出现的异常种类可能会有:中断、陷阱、故障、终止。

中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断的异常处理程序被称为中断处理程序。

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

故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。

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

信号允许进程和内核中断其他进程。每种信号都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程是不可见的。信号提供一种机制,通知用户进程发生了这些异常。例如在hello运行过程中键入回车,Ctrl-Z,Ctrl-C等
在程序运行过程中键入Ctrl-Z,会导致内核发送SIGTSTP信号给hello,同时发送SIGCHLD给hello的父进程。

键入Ctrl-Z后执行ps显示当前进程数量及内容,其中包含被暂停的hello进程,进程PID为2538

键入Ctrl-Z后执行jobs显示当前暂停的进程,其中包含hello进程。

执行pstree显示当前进程树。

执行fg命令恢复前台作业hello。
执行kill命令杀死进程。

在hello执行过程中键入Ctrl-C终止hello进程。

在hello执行过程中键入回车键。
6.7本章小结

本章从进程的角度分别描述了hello子进程fork和execve过程,并针对execve过程中虚拟内存映像以及栈组织结构等作出说明。同时了解了逻辑控制流中内核的调度及上下文切换等机制。阐述了Shell和Bash运行的处理流程以及hello执行过程中可能引发的异常和信号处理。
(第6章1分)

第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址空间是由段地址和偏移地址构成的。

例如:23:8048000 段寄存器(CS等16位):偏移地址(16/32/64);

实模式下:逻辑地址CS:EA —>物理地址CS*16+EA;

保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址, 段地址+偏移地址=线性地址。

线性空间地址为非负整数地址的有序集合,例如{0,1,2,3…}。

虚拟地址空间为N = 2n 个虚拟地址的集合,例如{0,1,2,3,….,N-1}。

物理地址空间为M = 2m 个物理地址的集合,例如{0,1,2,3,….,M-1}。物理地址是真实的物理内存的地址。

Intel采用段页式存储管理(通过MMU)实现:

•段式管理:逻辑地址—>线性地址==虚拟地址;

•页式管理:虚拟地址—>物理地址。

以hello中的puts调用为例:mov $0x400714,%edi callq 4004a0,$0x400714为puts输出字符串逻辑地址中的偏移地址,需要经过段地址到线性地址的转换变为虚拟地址,然后通过MMU转换为物理地址,才能找到对应物理内存。
7.2 Intel逻辑地址到线性地址的变换-段式管理
图7.2.1段式寄存器的含义

段式寄存器(16位)用于存放段选择符:CS(代码段)是指程序代码所在段;SS(栈段)是指栈区所在段;DS(数据段)是指全局静态数据所在段;其他三个段寄存器ES、GS和FS可指向任意数据段。

段选择符中各字段含义为:

图7.2.2 段选择符

其中TI表示描述符表,TI=0则为全局描述符表;TI=1则为局部描述符表。RPL表示环保护,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,中间环留给中间软件使用。高13位表示用来确定当前使用的段描述符在描述符表中的位置。

逻辑地址向线性地址转换的过程中被选中的描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址,过程如下图(注意:GDT首址或LDT首址都在用户不可见寄存器中

图7.2.3 逻辑地址向线性地址转换过程

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

虚拟内存概念:虚拟内存是系统对主存的抽象概念,是硬件异常、硬件地址翻译、主存、磁盘文件和内存文件的完美交互。为每个进程提供了一个大的、一致的和私有的地址空间。
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。虚拟页则是虚拟内存被分割为固定大小的块。物理内存被分割为物理页,大小与虚拟页大小相同

图7.3.1虚拟页物理页缓存关系

页表是一个页表条目的数组,将虚拟页地址映射到物理页地址。

图7.3.2 虚拟页映射物理页

地址翻译中需要了解虚拟地址、物理地址的组成部分及其他基本参数,如下:

图7.3.3 虚拟地址物理地址组成部分及参数

地址翻译可简化为以下流程:

图7.3.4 地址翻译过程

形式上来说,地址翻译是一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)中元素的映射。CPU中存在一个控制寄存器为页表基址寄存器指向当前页表。n位的虚拟地址包含着p位的虚拟页面偏移和(n-p)位的虚拟页号。MMU利用VPN来选择适当的PTE,将页表条目中的物理页号和虚拟地址中的VPO串联起来就得到相对应的物理地址。

页命中时CPU硬件执行的步骤为:

处理器生成虚拟地址,并传给MMU。
MMU生成PTE地址,并从高速缓存/主存中请求得到它。
高速缓存/主存向MMU返回PTE。
MMU构造物理地址并把它传送给高速缓存/主存。
高速缓存/主存泛会所请求的数据字给处理器。

图7.3.5页命中时CPU硬件执行步骤

缺页时CPU硬件执行步骤为:

与页命中1)到3)相同。
PTE中有效位为0,MMU触发一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
却也处理程序确定出物理内存的牺牲页,如果这个页面已经被修改了,则把他换出物理磁盘。
缺页处理程序页面调入新的页面,并更新内存中的PTE。
缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中。主存将所请求的字返回给处理器。

图7.3.6 页不命中时CPU硬件执行步骤

7.4TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。其组成部分如下图:

图7.4.1 TLB组成

用于组选择和行匹配的索引和标记字段是从虚拟地址的虚拟页号中提取出来的。如果TLB有T=2^t个组,那么TLB索引是由VPN的t个最低位组成的,而TLB标记是由VPN中剩余的位组成的。

TLB中所有的地址翻译步骤都是在芯片上的MMU执行的,因此非常快。当TLB命中时,其执行步骤为:

CPU上产生一个虚拟地址。
(2) 和 3))MMU从TLB中取出相应的PTE。
MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
高速缓存/主存将所请求的数据字返回给CPU。
其对应的操作图为:

图7.4.2 TLB命中

TLB不命中时,MMU需要从L1缓存取出相应的PTE,存于TLB之中,可能会覆盖已存在条目。其操作图为:

图7.4.3 TLB不命中

使用四级页表的地址翻译,虚拟地址被划分为4个VPN和1个VPO。每个VPNi都是一个到i级页表的索引,其中1<=i<=4.第j级页表中的每个PTE,1<=j<=3,都是指向第j+1级的每个页表的基址。第4级也表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问4个PTE。下图为使用Core i7的4级页表的地址翻译:

图7.4.5 多级页表地址翻译

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

如图为Core i7的内存系统:

图7.5.1 Core i7内存系统

针对物理内存访问,主要对各类高速缓存存储器的读写策略做出说明:

当CPU执行一条读内存字w的指令,它向L1高速缓存请求这个字。如果L1高速缓存由w的一个缓存的副本,那么就得到L1的高速缓存命中,高速缓存会很快抽取出w并返回给CPU。否则就是缓存不命中,当L1高速缓存向主存请求包含w的块的一个副本时,CPU必须等待。当被请求的块最终从内存到达时,L1高速缓存将这个快存放在他的一个高速缓存行里,从被缓存的块中抽取字w,然后返回给CPU。总体来看,高速缓存确定一个请求是否命中,然后抽取出被请求的字的过程,分为三步,(1)组选择、(2)行匹配、(3)字抽取。

直接映射高速缓存读策略:
直接映射高速缓存E=1,即每组只有一行。组选择是通过组索引位标识组。高速缓存从w的地址中间抽取出s个组索引位,这些位被解释为一个对应于一个组号的无符号整数,来进行组索引。行匹配中,确定了某个组i,接下来需要确定是否有字w的一个副本存储在组i包含的一个高速缓存行里,因为直接映射高速缓存只有一行,如果有效位为1且标志位相同则缓存命中,根据块偏移位即可查找到对应字的地址并取出;若有效位为1但标志位不同则冲突不命中,有效位为0则为冷不命中,此时都需要从存储器层次结构下一层取出被请求的块,然后将新的块存储在组索引位指示的组中的一个高速缓存行中。

组相联高速缓存读策略:
组相联高速缓存每个组都会保存多余一个的高速缓存行,组选择与直接映射高速缓存的组选择一样,通过组索引位标识组。行匹配时需要找遍组中所有行,找到标记位有效位均相同的一行则缓存命中;如果CPU请求的字不在组的任何一行中,则缓存不命中,选择替换时如果存在空行选择空行,如果不存在空行则通过替换策略替换其中一行。

全相联高速缓存读策略:
全相联高速缓存只包含一个组,其行匹配和字选择与组相联高速缓存中一样,区别主要是规模大小的问题。

写策略:分为两种,直写和写回。

直写是立即将w的高速缓存块写回到紧接着的低一层中。虽然简单,但是只写的缺点是每次写都会引起总线流量。

写回尽可能的推迟更新,只有当替换算法要驱逐这个更新过的块时,才把它写回到紧接着的第一次层中,由于局部性,写回能显著减少总线流量,但增加了复杂性。处理写不命中有两种方法一种为写分配,加载相应的的低一层的块到高速缓存中,然后更新这个高速缓存块。另一种方法为非写分配,避开高速缓存,直接把这个字写到低一层中,直写高速缓存通常是非写分配的,写回高速缓存通常是写分配的。

7.6hello进程fork时的内存映射

首先了解共享对象在虚拟内存中的应用:

一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟内存的其他进程而言,也是可见的。而且,这些变化也会反映在磁盘上的原始对象中。

另一方面,对于一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。一个映射到共享对象的虚拟内存区域叫做共享区域。类似地,也有私有区域。下图为共享对象使用实例:

图7.6.1 共享对象

其次,关注写时复制这一概念:

私有对象使用一种叫写时复制来映射至虚拟内存中,多个进程可将一个私有对象映射到其内存不同区域,共享该对象同一物理副本对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时复制。只要没有进程试图写它自己的私有区域,它们就可以继续共享物理内存中对象的一个单独副本。然而,只要有一个进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。

当故障处理程序注意到保护异常是由于进程试图写私有的写时复制区域中的一个页面而引起的,它就会在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新的副本,然后恢复这个页面的可写权限。下图为写时复制的示例:

图7.6.2 写时复制

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

7.7hello进程execve时的内存映射

execve函数在hello进程中加载并运行hello,主要步骤如下:

删除已存在的用户区域。
映射hello私有区域。
映射共享区域。
设置程序计数器PC。
下一次调度hello,将从入口点开始执行。Linux根据需要换入代码和数据页面。

加载器映射用户地址空间区域如下:

图7.7.1 加载器映射用户地址空间区域

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

下图为Linux组织虚拟内存的结构:

图7.8.1 Linux组织虚拟内存的结构

缺页异常及其处理过程:如果DRAM缓存不命中称为缺页。当地址翻译硬件从内存中读对应PTE时有效位为0则表明该页未被缓存,触发缺页异常。缺页异常调用内核中的缺页异常处理程序。

缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较,如果指令不合法,缺页处理程序就触发一个段错误、终止进程。

缺页处理程序检查试图访问的内存是否合法,如果不合法则触发保护异常终止此进程。

缺页处理程序确认引起异常的是合法的虚拟地址和操作,则选择一个牺牲页,如果牺牲页中内容被修改,内核会将其先复制回磁盘。无论是否被修改,牺牲页的页表条目均会被内核修改。接下来内核从磁盘复制需要的虚拟页到DRAM中,更新对应的页表条目,重新执行导致缺页的指令。以下为Linux缺页处理简图:

图7.8.2 Linux缺页处理

7.9动态存储分配管理

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

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

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

隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。而自动释放未使用的已分配的块的过程叫做垃圾收集。

隐式空闲链表的堆块格式及其组织格式如下图:

图7.9.1 隐式链表堆块格式

图7.9.2 隐式链表组织格式

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

显式空闲链表的堆块格式如下图:

图7.9.3 显示链表组织格式

显式空闲链表有两种方式来维护一种是先进后出,另一种是地址顺序。此处不详细展开。

放置空闲块的策略有三种,分别是首次适配、下一次适配、最佳适配。

首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一.次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。

7.10本章小结

本章从Linux存储器的地址空间起,阐述了Intel的段式管理和页式管理机制,以及TLB与多级页表支持下的VA到PA的转换,同时对cache支持下的物理内存访问做了说明。针对内存映射及管理,简述了hello的fork和execve内存映射,了解了缺页故障与缺页中断处理程序,对动态分配管理做了系统阐述。

第8章hello的IO管理
8.1Linux的IO设备管理方法

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

打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。
Linux Shell创建的每个进程开始时都有三个打开的文件:标准输入、标准输出、标准错误。
改变当前文件的位置。对于每个打开的文件,内核保持着一个文件位置k、初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显示地设置文件的当前位置为k。
读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。
8.2简述Unix IO接口及其函数

open()
函数原型:int open(char * filename, int flags, mode_t mode);

解析:open函数将file那么转换为一个文件描述符并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。

close()
函数原型:int close(int fd);

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

解析:read函数从描述符为fd 的当前文件位置复制最多n个字节到内存位置buf。返回值一1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。

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

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

8.3printf的实现分析

首先查看printf函数的函数体:

  1. static int printf(const char *fmt, …)
  2. {
  3.  va_list args;  
    
  4.  int i;  
    
  5.  va_start(args, fmt);  
    
  6.  write(1,printbuf,i=vsprintf(printbuf, fmt, args));  
    
  7.  va_end(args);  
    
  8.  return i;  
    
  9. }
    va_list的定义是:typedef char * va_list,因此通过调用va_start函数,获得的arg为第一个参数的地址。

vsprintf的作用是格式化。接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出,例如hello中:

printf(“Hello %s %s\n”,argv[1],argv[2]);

命令行参数为./hello 1173710217 hpy,则对应格式化后的字符串为:Hello 1173710217 hpy\n,并且i为返回的字符串长度

接下来是write函数:

  1. write:
  2.   mov eax, _NR_write   
    
  3.   mov ebx, [esp + 4]   
    
  4.   mov ecx, [esp + 8]   
    
  5.   int INT_VECTOR_SYS_CALL  
    

根据代码可知内核向寄存器传递几个参数后,中断调用syscall函数。对应ebx打印输出的buf数组中第一个元素的地址,ecx是要打印输出的个数。查看syscall函数体:

  1. sys_call:
  2.  call save   
    
  3.  push dword [p_proc_ready]   
    
  4.  sti   
    
  5.  push ecx   
    
  6.  push ebx   
    
  7. call [sys_call_table + eax * 4]   
    
  8. add esp, 4 * 3   
    
  9. mov [esi + EAXREG - P_STACKBASE], eax   
    
  10. cli  
    
  11. ret
    

在syscall函数中字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),实现printf格式化输出。

8.4getchar的实现分析

getchar源代码为:

  1. int getchar(void)
  2. {
  3. static char buf[BUFSIZ];
  4. static char *bb = buf;
  5. static int n = 0;
  6. if(n == 0)
  7. {
  8. n = read(0, buf, BUFSIZ);
  9. bb = buf;
  10. }
  11. return(–n >= 0)?(unsigned char) *bb++ : EOF;
  12. }
    异步异常-键盘中断的处理:键盘中断处理是底层的硬件异常,当用户按下键盘时,内核会调用异常键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar函数read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。实现读取一个字符的功能。

8.5本章小结

本章系统的了解了Unix I/O,通过LinuxI/O设备管理方法以及Unix I/O接口及函数了解系统级I/O的底层实现机制。通过对printf和getchar函数的底层解析加深对Unix I/O以及异常中断等的了解。

结论

hello生命周期的大事件:

hello.c首先被经过预处理器处理,得到预处理文本文件hello.i。
hello.i经过编译器处理生成文本文件hello.s,包含一个汇编程序。
hello.s经过汇编器翻译为机器语言指令,打包为可重定位目标程序hello.o。
hello经过链接生成可执行目标文件hello。
在Linux下键入./hello 1173710217 hpy运行hello,内核为hello fork出新进程,并在新进程中execve hello程序。
execve 通过加载器将hello中的代码和数据从磁盘复制到内存,为其创建虚拟内存映像,加载器在程序头部表的引导下将hello的片复制到代码段和数据段,执行_start函数。
MMU通过页表将虚拟地址映射到对应的物理地址完成访存。
内核通过GOT和PLT协同工作完成共享库函数的调用。
hello调用函数(eg:printf),内核通过动态内存分配器为其分配内存。
内核通过调度完成hello和其他所有进程的上下文切换,成功运行hello。
shell父进程回收hello,内核删除hello进程的所有痕迹。hello——卒。
回顾hello短暂的一生,可以看出hello虽然很简单,但是却凝结着人类的智慧。自1946年第一台电子计算机问世以来,计算机技术在元件器件、硬件系统结构、软件系统、应用等方面,均有惊人进步。从两个足球场大的计算机到如今我们面前的小小的笔记本,不得不令人感叹现代计算机系统设计的精巧。

计算机系统高效有序的运行离不开底层硬件的完美契合,计算机多级存储结构、内核对进程的调度策略、动态链接的执行方式、cache替换策略、页表替换策略、异常与信号等的处理……这些无一不体现计算机底层实现的完备与优雅。同时,作为程序员,了解与学习计算机底层实现也有助于我们充分利用计算机,编写出计算机底层友好的代码,提高计算、工作的效率。

总之,hello的故事,其实才刚刚开始……

附件

hello.c:源代码

hello.i:hello.c预处理生成的文本文件。

hello.s:hello.i经过编译器翻译成的文本文件hello.s,含汇编语言程序。

hello.o:hello.s经汇编器翻译成机器语言指令打包成的可重定位目标文件

hello.elf:hello.o的ELF格式。

hello_o_asm.txt:hello.o反汇编生成的代码。

hello:经过hello.o链接生成的可执行目标文件。

hello_out.elf:hello的ELF格式。

hello_out_asm.txt:hello反汇编生成的代码。

参考文献
为完成本次大作业你翻阅的书籍与网站等
1]兰德尔E.布莱恩特 大卫R.奥哈拉伦. 深入理解计算机系统(第3版).机械工业出版社. 2018.4.

[2]条件编译#ifdef的妙用详解_透彻:https://blog.csdn.net/qq_33658067/article/details/79443014

[3]typedef 百度百科:https://baike.baidu.com/item/typedef/9558154?fr=aladdin

[4]extern 百度百科:https://baike.baidu.com/item/extern/4443005?fr=aladdin

[5]编译 百度百科:https://baike.baidu.com/item/编译/1258343?fr=aladdin

[6]printf函数详细讲解:https://www.cnblogs.com/windpiaoxue/p/9183506.html

[7] 关于Linux 中sleep()函数说明:https://blog.csdn.net/fly__chen/article/details/53175301

[8] getchar()函数的返回值以及单个字符输出函数putchar:https://blog.csdn.net/cup160828/article/details/58067647?utm_source=blogxgz9

[9]gcc常用命令选项:https://blog.csdn.net/Crazy_Tengt/article/details/71699029

[10]ELF文件-段和程序头:https://blog.csdn.net/u011210147/article/details/54092405

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

[12]getchar()函数详解:http://www.aspku.com/kaifa/c/332134.html

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值