HIT2018计算机系统大作业 程序人生-Hello’s P2P

程序人生-Hello’s P2P

摘要

该报告从细节出发,讲述了hello.c从c文件一步步成功运行的绝大多数过程。预处理、编译、汇编、链接,每一步的操作如何对上一步的文件进行操作,形成新的文件。hello进程在shell执行的过程,存储管理的过程,IO处理的过程也有较为详细的介绍解释。
关键词:程序的生命周期;hello;从程序到进程;进程的管理;

目 录

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

第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
p2p(From program to process):hello刚开始由程序员通过外部输入设备键盘输入并保存为.c文件,这时hello成为了一个program。然后通过预处理器(preprocessor)处理#开始的预编译指令,如宏定义(#define)、文件包含(#include)、条件编译(#ifdef)等。接着进行编译,使用编译器(compiler)将C语言,翻译汇编代码。再通过汇编器(Assembler)将汇编代码翻译成二进制机器语言,最后使用连接器(Linker)将汇编器生成的目标文件外加库链接为一个可执行文件。
020:shell将fork一个子进程然后execve hello进程,映射虚拟内存,使用TLB,4级页表、3级Cache找到对应物理内存,然后内核调度分配时间片。结束时,shell父进程回收子进程。

1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件:inter core i5-7300HQ x86-64,8G RAM,128G SSD +1T Disk;
软件:Windows 10 64位;Vmware14 pro;ubuntu-18.04.1 64位;
开发与调试工具:GCC,GDB, ld, readelf, edb.

1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

文件名称作用
hello.i预处理之后的文件
hello.s编译之后的汇编文件
hello.o汇编之后的可重定位目标文件
hello链接之后的可执行目标文件
helloobjdump.txthello.o的反汇编文件
helloobjdump2.txthello的反汇编文件
hello.elfhello.o的ELF头
hello2.elfhello的ELF头
helloreadelf.txthello.o的ELF所有信息
helloreadelf2.txthello的ELF所有信息

1.4 本章小结
这一章只是这个实验的开头,简介一些实验基本信息,hello简介,环境,中间产物。

第2章 预处理

2.1 预处理的概念与作用
概念:预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位,预处理记号(preprocessing token)用来支持语言特性。
作用:根据源代码中的预处理指令修改你的源代码。预处理指令是一种命令语句(如#define),它指示预处理程序如何修改源代码。在对程序进行通常的编译处理之前,编译程序会自动运行预处理程序,对程序进行编译预处理,这部分工作对程序员来说是不可见的。
预处理程序读入所有包含的文件以及待编译的源代码,然后生成源代码的预处理版本。在预处理版本中,宏和常量标识符已全部被相应的代码和值替换掉了。如果源代码中包含条件预处理指令(如#if),那么预处理程序将先判断条件,再相应地修改源代码。

2.2在Ubuntu下预处理的命令
命令:gcc -E hello.c -o hello.i
截图:
图2.2 预处理命令
图2.2 预处理命令
2.3 Hello的预处理结果解析
结果得到一个hello.i文件,我们将其打开观察。
 hello.i部分截图
图2.3.2  原.c程序截图
图2.3.1和图2.3.2 hello.i部分截图和原.c程序截图
由于我们的c程序中没有宏定义 (#define)、条件编译(#ifdef)所以在预处理之后,我们在预处理后的文件的3100行发现自己的程序主题部分与原.c文件无任何变化,但是我的原.c文件的头文件(#include)已经在被处理了,所以在此处找不到原头文件。
2.4 本章小结
本章简要的介绍了预处理的概念与作用,预处理就是在正式编译之前做了一些简单的工作(替换宏定义等),然后使用#gcc -E hello.c -o hello.i将.c文件处理为.i文件,观察.i文件我们知道了预处理后的程序主体会在.i文件的最后出现。

第3章 编译

3.1 编译的概念与作用
概念:将某一种程序设计语言写的程序(这里是指预处理之后.s的程序)翻译成等价的另一种语言的程序的程序(汇编之前.s文件的程序), 称之为编译
作用:1.词法分析,2.语法分析,3.语义分析,4.源代码优化(中间语言生成),5.代码生成,目标代码优化。

  1. 词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。
  2. 基于词法分析得到的一系列记号,生成语法树。
  3. 由语义分析器完成,指示判断是否合法,并不判断对错。
  4. 中间代码(语言)使得编译器分为前端和后端,前端产生与机器(或环境)无关的中间代码,编译器的后端将中间代码转换为目标机器代码
  5. 编译器后端主要包括:代码生成器:依赖于目标机器,依赖目标机器的不同字长,寄存器,数据类型等
    注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序

3.2 在Ubuntu下编译的命令
命令: gcc -s hello.c -o hello.s
截图:
图3.2  编译命令
3.3 Hello的编译结果解析
3.3.1数据
a.全局变量sleepsecs:
图3.3.1.a  sleepsecs的声明
图3.3.1.a sleepsecs的声明
全局变量sleepsecs在第8行声明,而其在.c中为被声明为初始化的全局变量,所以编译之后将其存储在.data里。
b.局部变量i:
图3.3.1.b 局部变量i
图3.3.1.b 局部变量i
局部变量i第一次在程序中出现在37行处,因为在.c源文件中i被定义为局部变量,将其存储在用户栈中或直接存储在寄存器中。但是37行的操作告知我们i变量在-4(%rbp)位置中,即在用户栈里。
c.常量:
直接用立即数表示。
d. 字符串常量:
图3.3.1.d 字符串常量
图3.3.1.d 字符串常量
在两个printf中的字符.LC0和.LC1在12-15行声明,字符串存储在.rodata节中。
3.3.2 赋值
a.sleepsecs
图3.3.2.a  sleepsecs的声明
图3.3.2.a sleepsecs的声明
sleepsecs为已初始化的全局变量,在声明时直接赋值为2。
b.i
图3.3.2.b  局部变量i
图3.3.2.b 局部变量i
i为未初始化的局部变量,其存储在用户栈中,赋值直接使用mov指令即可。
3.3.3类型转换
a.隐式转换
图3.3.4.a  i++
图3.3.3.a sleepsecs的声明
.c源程序中sleepsecs的声明为 int sleepsecs=2.5;由于sleepsecs的类型定义为int,所以将其赋值为浮点型的2.5会被隐式转换(Round to 0)为int型的2,所以在声明是直接将其值赋位2。
3.3.4 算数操作
a.++

图3.3.4.a i++
++算数操作在.s文件的53行被翻译为add操作(源操作数为1)
3.3.5 关系操作
a.!=
图3.3.5.a  !=操作
图3.3.5.a !=操作
.c源程序中的i!=3,在编译时被翻译为cmp指令,cmp $3, -20(%rbp),
将-20(%rbp)中存储的i与3比较,并设置条件码,je指令根据条件码决定下一步操作。
b.<
图3.3.5.b   <操作
图3.3.5.b <操作
.c源文件中的语句i<10,在编译时使用cmp指令设置条件码,并设置条件码,jle指令根据条件码决定下一步操作,实现<关系操作。
3.3.6 数组/指针/结构操作
a.数组操作
图3.3.6.a1  源文件中数组操作
图3.3.6.a1 源文件中数组操作
图3.3.6.a2  数组准备
图3.3.6.a2 数组准备
图3.3.6.a3  数组操作
图3.3.6.a3 数组操作
.c源程序中的数组操作出现在循环体for循环中,每次循环中都要访问argv[1],argv[2]这两个内存。在翻译时,argv[]先是被存在用户栈中(29行),再使用基址加偏移量寻址访问argv[1],argv[2]。
argv[1]: 数组首地址存放于-32(%rbp)(29行),现将其存储到%rax中(43行),在加上偏移量$8(44行),再将该位置内容放在%rsi中(46行)成为下一个函数的第二个参数。
argv[2]: 数组首地址存放于-32(%rbp)(29行),现将其存储到%rax中(40行),在加上偏移量$16(41行),再将该位置内容放在%rdi中(42行)成为下一个函数的第一个参数。
3.3.7 控制转移
a.if语句
图3.3.7.a1 源文件里的if语句
图3.3.7.a1 源文件里的if语句
图3.3.7.a2   if语句
图3.3.7.a2 if语句
源文件里的的if语句判断条件是argc!=3,在翻译后变为cmp语句,
cmp $3,(%rbp), cmp语句只是设置了条件码,而控制转移则还需接下来的je语句一同执行,当相等时跳转到.L2处,不相等时顺序执行,实现控制转移。
b.for语句
图3.3.7.b1 源文件里的for语句
图3.3.7.b1 源文件里的for语句
图3.3.7.b2   for语句
图3.3.7.b2 for语句
源程序的for语句主要三部分:初始化、循环条件、循环变量改变,首先初始化在翻译时对应了在37行的mov操作,将i=0;接着38行使用了for循环翻译的一种常见方法跳转到中间;循环条件在55行处翻译为cmp语句,将i与9比较并设置条件码,56行根据条件码来实现控制转移,39-52行时循环体内部的指令暂且不谈,循环变量改变在53行翻译为add操作,每次使i+1;
3.3.8 函数操作
函数操作包含传递参数,函数调用,函数返回(return)
a. main函数
传递参数:在程序被执行时,_start从由内核设置的栈中获取参数和环境变量信息,然后调用 main函数将参数argc和avgv[]传递到main函数。
函数调用: 可执行文件的开始地址是_start的地址,控制传递给_start以后,然后调用__libc_start_main(__libc_start_main被认为main)。
函数返回:main函数使用return返回0,翻译为movl $0 , %eax; ret; 先将%eax置成0,在使用ret返回。
b.printf函数
传递参数:第一次调用将.L0放置在%rdi传递给函数puts;第二次调用将.L1放置在%rdi中传递,将argv[1]放在%rsi中传递,将argv[2]放在%rdx中传递。
函数调用:第一次在main函数中直接call puts调用,第二次main在函数中调用
函数返回:返回打印的字符数量(在本程序中没有使用)
c.exit函数
传递参数:使用mov,将%edi置成一,传入exit函数中。
函数调用:在main函数里直接call exit函数。
d.sleep函数
传递参数:由于数据sleepsecs是已初始化的全局变量,在传递参数时将sleepsecs传递给%edi即可。
函数调用:在main函数里直接call sleep函数。
e.getchar函数
函数调用:在main函数里直接call getchar 函数。
3.4 本章小结
本章显示介绍了有关编译的概念作用,然后使用#gcc -s hello.c -o hello.s生成了
编译后的文件。对于生成的.s文件,我们讲解了C语言的数据与操作在机器之中如何被处理翻译的,这为我们的下一步汇编打下了继续的基础。

第4章 汇编

4.1 汇编的概念与作用
概念:把汇编语言翻译成机器语言的过程称为汇编。在汇编语言中,用助记符(Memoni)代替操作码,用地址符号(Symbol)或标号(Label)代替地址码。这样用符号代替机器语言的二进制码,就把机器语言变成了汇编语言。
作用: 用汇编语言编写的程序,机器不能直接识别,要由一种程序将汇编语言翻译成机器语言,汇编程序起这种翻译作用。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
命令 gcc -c hello.s -o hello.o
截图:
图4.2  汇编截图
图4.2 汇编截图
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
图4.3.1 ELF头
图4.3.1 ELF头
图4.3.2 节头部表
图4.3.2 节头部表
图4.3.3 重定位信息
图4.3.3 重定位信息
重定位是将EFL文件中的未定义符号关联到有效值的处理过程。在main.o中,这意味着对printf,exit等函数的未定义的引用和全局变量(sleepsecs)必须替换为该进程的虚拟地址空间中适当的机器代码所在的地址。在目标中用到的相关符号之处,都必须替换。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
hello的反汇编与hello.s的汇编不同之处:

  1. 操作数
    图 4.4 .1.1  .s文件的一部分
    图 4.4 .1.1 .s文件的一部分
    图4.4 .1.2  反汇编文件的部分
    图4.4 .1.2 反汇编文件的部分
    在部分文件截图中可以看到,在.s文件中出现的立即数都是10进制的,而在反汇编文件中同样出现时则是16进制。
  2. 分支转移
    图4.4 .2.1  .s文件中部分分支转移图4.4 .2.1  .s文件中部分分支转移
    图4.4 .2.1 .s文件中部分分支转移
    图 4.4.2.2 反汇编文件中部分分支转移图 4.4.2.2 反汇编文件中部分分支转移
    图 4.4.2.2 反汇编文件中部分分支转移
    观察两个文件中相同语句翻译的条件转移,会发现在.s文件中jmp语句之后都是紧接着助记符,而在反汇编文件中jmp语句之后则是一个具体的地址。所以在汇编语句翻译成机器语言时,会将助记符映射成一个具体的地址。
  3. 函数调用
    图4.4.3.1  .s文件中printf函数与sleep函数
    图4.4.3.1 .s文件中printf函数与sleep函数
    图4.4.3.2 反汇编文件中的printf函数与sleep函数
    图4.4.3.2 反汇编文件中的printf函数与sleep函数
    对比两个文件的函数调用很容易发现在.s文件中的函数调用都只是call加函数名称,而在反汇编的文件中函数调用则call之后是下一条指令的地址,但是并不是函数实际的地址。在这里我们调用的函数是共享库里的函数,需要动态链接才能确定函数的真正执行地址,而在生成机器语言时将函数的地址都设置为了下一条指令的地址。如何找到这些函数的实际地址,我们将这些没有实际地址的函数在重定位项目中添加条目,在link阶段会进行重定位。
  4. 映射关系
    图4.4.4   .s与反汇编的映射关系图
    图4.4.4 .s与反汇编的映射关系图
    4.5 本章小结
    这一章我们介绍了汇编概念与作用,使用# gcc -c hello.s -o hello.o得到.o文件并使用readelf工具分析可重定位目标elf格式,重点介绍了重定位项目,在objdump操作进行反汇编比较与原汇编语句的不同之处说明机器语言的构成,与汇编语言的映射关系。

第5章 链接

5.1 链接的概念与作用
概念:将一个或多个由编译器或汇编器生成的目标文件外加库链接为一个可执行文件。目标文件是包括机器码和链接器可用信息的程序模块。
作用:链接器的工作就是解析未定义的符号引用,将目标文件中的占位符替换为符号的地址。链接器还要完成程序中各目标文件的地址空间的组织,这可能涉及重定位工作。
注意:这儿的链接是指从 hello.o 到hello生成过程。
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
图5.2  ld链接命令
图5.2 ld链接命令
5.3 可执行目标文件hello的格式
图5.3.1  hello的ELF头
图5.3.1 hello的ELF头
图5.3.2  hello的节头表
图5.3.2 hello的节头表
hello的ELF头和hello.o的ELF头大体一致,但是类型REL (可重定位文件)变为了EXEC (可执行文件),程序头从无到有,节头和字符串表索引节头的数量变多。
节头表中给出了hello各段的基本信息,括各段的起始地址,大小等
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图5.4  hello节头表与虚拟地址空间各段
图5.4 hello节头表与虚拟地址空间各段
5.5 链接的重定位过程分析
hello的反汇编与hello.o的反汇编的不同:
1. 多出若干节
原hello.o反汇编只有一个.text节,而hello的反汇编则多出了.init(初始化时调用其中的_init函数),.plt(动态链接-过程链接表),.fini(程序正常终止时执行) 节。
2. 调用函数的地址确定
图5.5   hello的反汇编的exit函数
图5.5   hello的反汇编的exit函数
图5.5 hello的反汇编的exit函数
上一章我们提到hello.o反汇编之后call 后面的是下一条指令的地址,不是实际的地址。现在在hello反汇编的call 之后是函数的地址。链接过程中,动态链接器(ld-linux.so)链接程序在运行过程中根据记录的共享对象的符号定义来动态加载共享库,然后完成重定位。在此可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码。
3. 重定位节,符号定义与符号引用
a. 重定位节和符号定义
链接器将所有类型相同的节合并为同一类型的新的聚合节。
b.符号引用
链接器修改.data和.text节中对每个符号的引用,使得他们正确的指向运行时地址。执行这一步链接器依赖于可重定位节的重定位条目。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。

程序名程序地址
ld-2.27.so!_dl_start0x7fce 8cc38ea0
ld-2.27.so!_dl_init0x7fce 8cc47630
hello!_start0x400500
libc-2.27.so!__libc_start_main0x7fce 8c867ab0
-libc-2.27.so!__cxa_atexit0x7fce 8c889430
-libc-2.27.so!__libc_csu_init0x4005c0
hello!_init0x400488
libc-2.27.so!_setjmp0x7fce 8c884c10
hello!main0x400532
hello!puts@plt0x4004b0
hello!exit@plt0x4004e0
*hello!printf@plt
*hello!sleep@plt
*hello!getchar@plt
ld-2.27.so!_dl_runtime_resolve_xsave0x7fce 8cc4e680
-ld-2.27.so!_dl_fixup0x7fce 8cc46df0
–ld-2.27.so!_dl_lookup_symbol_x0x7fce 8cc420b0
libc-2.27.so!exit0x7fce 8c889128

5.7 Hello的动态链接分析
动态链接使我们在调用一个共享库定义的函数可以在运行时找到函数的地址。但是在调用时编译器没办法预测这个函数(共享库定义)的运行时地址,因为定义它的共享模块可以在运行时加载到任何位置。但是GNU编译系统通过延迟绑定技术来解决这个问题,将过程地址的绑定推迟到第一次调用该过程中。
延迟绑定通过:GOT和PLT实现,如果一个目标模块调用定义在共享库中的任何函数,那么他就有自己的GOT和PLT。
第一次调用共享库函数时,不调用共享库函数,直接进入函数对应的PLT中,接着PLT指令通过对应的GOT指令进行间接跳转,由于每个GOT指令初始时都指向他对应的PLT条目的第二条指令,所以这个间接跳转只是简单的把控制传回PLT条目的下一条指令。接着把函数的ID入栈PLT跳转到PLT[0],PLT[0]再将动态链接器的一个参数入栈,然后间接跳转到动态链接器中。动态链接器依据两个栈条目确定函数的运行位置,重写对应的GOT条目,再把控制传给函数。
所以,在运行dl_init前,GOT表中存放的都是对应PLT条目的第二条指令,在运行dl_init后,GOT表中存放的就是对应的函数的地址。
(GOT位置由节头表偏移量和大小即可得知601000-601040)
图5.7.2  dl_init前后的GOT
图5.7.1 dl_init前后的GOT(从10-30,共四行)
5.8 本章小结
这一章我们主要介绍了链接的概念与作用,又使用ld进行链接,分析了hello的格式,节头表,各段等信息。发现偏移量与进程的虚拟地址空间各段位置一一对应。比较hello与hello.o反汇编的不同处,发现共享库函数的地址变为了实际地址,又寻找了hello从头到尾的运行的函数。最后分析了hello的动态链接终于找到了共享库函数使用延迟绑定的方法,利用PLT和GOT帮助最终找到函数的地址。

第6章 hello进程管理

6.1 进程的概念与作用
概念:(经典定义)一个正在执行的程序的示例,是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
作用:提供给应用进程两个关键抽象1.一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。2.一个私有的地址空间,它提供一个假象,好像我们程序在独占整个内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell俗称壳,是指“为使用者提供操作界面”的软件(命令解析器),它接收用户命令,然后调用相应的应用程序。
处理流程:
1.打印提示信息
2.等待用户输入
3.接受命令
4.解释命令
5.找到该命令,执行命令,如果命令含有参数,输入的命令解释它
6.执行完成,返回第一步
6.3 Hello的fork进程创建过程
********* **********——>hello进程 ————>exit
*******************|……………………………… |
*******************| ………………………………|
—终端———fork———————————waitpid———exit—>
图 . Hello的fork进程创建过程
在shell中键入命令./hello,shell分析输入的命令并解释执行hello程序,就使用fork创建子进程,子进程几乎与父进程完全相同,子进程得到了父进程的一个相同且独立的一份副本,但是二者之间的最大不同在于他们拥有不同的PID。

6.4 Hello的execve过程
fork创建子进程后,使用execve在当前进程中载入并运行一个新程序即hello。
execve函数加载hello并带参数列表argv和环境变量envp。它会覆盖当前进程的代码、数据、栈,但是保留PID,继承已打开的文件描述符和信号上下文。
6.5 Hello的进程执行
在hello执行之前,内核被其他进程调用,接着在某些时刻,内核可以决定抢占当前进程,并开始一个先前被抢占了的进程(如hello),hello就可以被执行。这种决策就叫做调度,是由内核中的调度器代理的。在内核调度hello后,它就抢占当前进程并使用上下文切换转移控制到hello进程。
上下文是内核重新启动一个被抢占的进程所需要的状态。它由一些对像的值组成,如寄存器,用户栈,内核栈,内核数据结构。而上下文切换就是先保存当前进程的上下文,再恢复hello进程的上下文,最后将控制转移到hello进程。
图6.5.1 sleep导致的上下文切换(A为hello)
图6.5.1 sleep导致的上下文切换(A为hello)
当hello进程每次执行到sleep时,程序陷入休眠状态,内核调度其他进程,而sleep函数结束后,hello进程重新进入待执行进程队列中等待内核调度。
图6.5.2 getchar导致的上下文切换(A为hello)
图6.5.2 getchar导致的上下文切换(A为hello)
内核调度后,执行到getchar函数时,调用read函数,陷入到内核。内核中的陷阱处理程序请求来自磁盘处理器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。内核调度其他进程。之后当磁盘发出一个中断信号,表示数据已经从磁盘传送到了内存。内核判断其他进程已经运行了它的足够长时间,就执行一个从其他进程到进程hello的上下文切换,将控制传递到read之后的指令,进程hello继续执行。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
异常种类 产生信号 处理方法
中断 SIGSTP 停止直到下一个SIGCONT
终止 SIGINT 终止程序
图6.6.0 程序正常运行(最后键入换行符)
图6.6.0 程序正常运行(最后键入换行符)

  1. 程序正常运行时直接正常运行,到getchar处停止等待输入,输入完成后继续运行,直到最后程序被回收。
    图6.6.1 运行过程中按Ctrl-Z
    图6.6.1 运行过程中按Ctrl-Z
  2. 程序在打印三行信息之后,按下Ctrl-Z,内核发出SIGSTP信号给前台进程组中的每个进程,hello进程被停止直到CIA一个SIGCONT再次运行。

图6.6.2 运行过程中按下Ctrl-C
3. 程序在打印三行信息后,按下Ctrl-C,内核发出SIGINT信号给前台进程组中的每个进程,hello进程就被终止,且被回收。
图6.6.3 运行过程中按下若干回车
图6.6.3 运行过程中按下若干回车
4. 在程序运行过程中,我们随意的按下若干回车,发现回车在按下时只是简单地在输出中换行而在hello进程结束后所按的回车使得shell读入几个换行。
图6.6.4 运行过程中随便乱按
图6.6.4 运行过程中随便乱按
5.在运行过程中我们的随便乱按没有影响程序的正常运行,在运行到getchar时,键入的一个换行结束的字符串,进程结束。
图6.6.5.1  Ctrl-Z之后运行ps命令
图6.6.5.1 Ctrl-Z之后运行ps命令
图 6.6.5.2  Ctrl-Z之后运行jobs命令
图 6.6.5.2 Ctrl-Z之后运行jobs命令
图6.6.5.3  Ctrl-Z之后运行pstree命令
图6.6.5.3  Ctrl-Z之后运行pstree命令图6.6.5.3  Ctrl-Z之后运行pstree命令
图6.6.5.3 Ctrl-Z之后运行pstree命令
图6.6.5.4  Ctrl-Z之后运行fg命令
图6.6.5.4 Ctrl-Z之后运行fg命令
图6.6.5.5  Ctrl-Z之后运行kill命令
图6.6.5.5 Ctrl-Z之后运行kill命令
6.7本章小结
本章简述了进程管理的一些简要信息,比如进程的概念作用,shell的基本原理,shell如何fork和execve我们的hello进程,我们的hello进程在执行时会遇到什么样的情况它是怎么被执行的。又介绍了一些常见异常和其信号处理方法。

第7章 hello的存储管理

7.1 hello的存储器地址空间
图7.1 几个地址的关系
图7.1 几个地址的关系
逻辑地址:是指由程序产生的与段相关的偏移地址部分。一个逻辑地址由两部份组成,段标识符和段内偏移量。
线性地址:是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:虚拟地址是程序保护模式下,这样程序访问存储器所使用的逻辑地址称为虚拟地址,与实地址模式下的分段地址类似,虚拟地址也可以写为“段:偏移量”的形式,这里的段是指段选择器。
物理地址:在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address),又叫实际地址或绝对地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式存储管理要求每个作业的地址空间按照程序自身的逻辑划分为若干段,每个段都有一个唯一的内部段号。
对于intel 的逻辑地址由段标志符和段内偏移量组成。段标志符有16位,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。
————————————————
| **** 段号S********| ***段内偏移量W|
31——————16 15——————0
———————————————
| *****索引号(13位) | 硬件细节|
15————————3 2————0
索引号就使我们的逻辑地址对应着段描述符表中一个具体的段描述符。
图7.2.1 段描述符
图7.2.1 段描述符
而段描述符又包括全局和局部,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。当段选择符中的T1字段=0,表示用GDT,=1表示用LDT。
图7.2.2 段式管理
图7.2.2 段式管理
总之对于一个逻辑地址如何转化为线性地址分以下几步:

  1. 看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。
  2. 出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
  3. 把Base + offset,就是要转换的线性地址了。
    7.3 Hello的线性地址到物理地址的变换-页式管理
    页式存储管理将程序逻辑地址空间划分为固定大小的页(page),而物理内存划分为同样大小的页框(page frame)。每一个作业有一个页表,用来记录各个页在内存中所对应的块。
    在linux下线性地址(linear address)(也称虚拟地址 virtual address),所以从从虚拟地址如何变换到物理地址主要是通过MMU进行地址翻译。
    图7.3.1 基于页表的地址翻译
    图7.3.1 基于页表的地址翻译
    首先读入一个虚拟地址,将其划分为VPN和VPO,使用VPN在页表里查找对应的页表项。若其有效位为0,那么页面不存在,执行缺页处理操作。若其有效位为1,则将其的PPN加上PPO就得到了物理地址(PPO=VPO)。
    为了加速地址变换的过程,,在地址变换机构中增设了一个具有并行查找能力的高速缓冲存储器——快表,又称联想寄存器(TLB),用来存放当前访问的若干页表项。
    图7.3.2  TLB表的转化机制
    图7.3.2 TLB表的转化机制
    7.4 TLB与四级页表支持下的VA到PA的变换
    图7.4.1  VA到PA的变换
    图7.4.1 VA到PA的变换
    图7.4.2 四级页表下的VA到PA的变换
    图7.4.2 四级页表下的VA到PA的变换
    VA到PA的变换主要以下几步组成
  4. 将需要转化的虚拟地址(VA)将其划分为VPN和VPO,再将VPN分为前32位TLBT,后四位TLBI,依据TLBI找到TLB的组,TLBT进行索引位匹配,匹配成功找到PPN,加上PPO就得到了物理地址(PPO=VPO)。如果匹配不成功,则向页表查询。
  5. 虚拟地址前36位被分为4个VPN每个9位对应一到四级页表的偏移量。先从CR3中找到一级页表的位置,根据VPN1找到L2页表的位置,重复以上操作最后在四级页表中找到页的PPN,加上PPO就得到了物理地址(PPO=VPO)。
    注:TLBI: TLB索引,TLBT: TLB 标记 , VPO: 虚拟页面偏移量(字节) ,VPN: 虚拟页号,PPO:物理页面偏移量 ,PPN:物理页号
    7.5 三级Cache支持下的物理内存访问
    图7.5 三级Cache支持下的物理内存访问
    图7.5 三级Cache支持下的物理内存访问
    在我们得到PA之后,怎么访问PA的内容现在是个问题,在现在计算机中通常使用高速缓存来实现物理内存访问,多级缓存能大幅提高访存的速度。
  6. 将PA分解成CT,CI,CO三部分。
  7. 根据CI找到合适的组,在组内寻找和CT相同的标志位,找到后查看其有效位,若为1,则hit。
  8. hit之后根据CO将数据返回给CPU
  9. 如果没找到与CT相同的标志位或者找到了但是有效位为0,则向下一级缓存中查找(L2),若L2中同样miss,继续向下寻找L3,最后寻找主存(概率极小)。
  10. 在下一级中hit之后,在上一级缓存中找到一个空闲块或驱逐一个其他块来放置hit的新块,重置块内信息。
    7.6 hello进程fork时的内存映射
    为新进程创建虚拟内存创建当前进程的的mm_struct, vm_area_struct和页表的原样副本。两个进程中的每个页面都标记为只读,两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW)。
     在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存,随后的写操作通过写时复制机制创建新页面。
    7.7 hello进程execve时的内存映射
    图7.7  hello进程execve时的内存映射
    图7.7 hello进程execve时的内存映射
    在子进程中加载hello程序,加载hello需要以下几个步骤:
  11. 删除已存在的用户区域,删除子进程的已存在的区域结构。
  12. 映射私有区域。为新进程hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零
  13. 映射共享区域,hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
  14. 设置PC,设置上下文的程序计数器,使其指向代码区域的入口点。
    7.8 缺页故障与缺页中断处理
    图7.8.1  缺页故障示例
    图7.8.1 缺页故障示例
    Page fault缺页: 虚拟内存中的字不在物理内存中 (DRAM缓存不命中)。如上图访问VP3时,VP3在页表中无缓存,则发生缓存不命中,引发缺页。
    图7.8.2  缺页中断处理
    图7.8.2 缺页中断处理
    当发生缺页时,缺页异常调用内核中的缺页异常处理程序,该程序选择一个牺牲页,如果牺牲页被修改了,内核会将它复制回磁盘,然后修改页表条目说明页表修改事实。然后将在内存中找到的页复制回PTE中,重新启动引发缺页的命令。
    7.9动态存储分配管理
    在程序运行时程序员使用动态内存分配器 (比如 malloc) 获得虚拟内存。
    图7.9.1 动态内存分配器
    图7.9.1 动态内存分配器
    动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) 。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) 。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
    图7.9.2 堆示例
    图7.9.2 堆示例
    分配器将堆视为一组不同大小的块(block) 的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
    分配器有两种基本风格。两种风格都要求应用显式地分配块,但不同之处的在于是由哪个实体负责释放已分配的块:显式分配器:要求应用显式地释放任何已分配的块。隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块。
    动态存储分配管理大部分的内容相似只是由于采用的不同基本方法和策略不同有几种区分。
    基本方法与策略:
  15. 如何区分空闲块:
    隐式空闲链表:隐式空闲链表通过头部中的大小字段隐含地连接空闲块。
    显式空闲链表:显式空闲链表在空闲块中使用指针连接空闲块。
    分离的空闲链表:每个大小类的空闲链表包含大小相等的块
    按照大小排序的块:可以使用平衡树(例如红黑树),在每个空闲块中有指针,大小作为键。
  16. 如何放置已分配的块
  17. 首次适配 (First fit): 从头开始搜索空闲链表,选择第一个合适的空闲块。
  18. 下一次适配 (Next fit): 和首次适配相似,只是从链表中上一次查询结束的地方开始。
  19. 最佳适配 (Best fit): 查询链表,选择一个最好的空闲块适配。
  20. 合并空闲块(使用带头和脚部引脚的块)
    图7.9.3四种情况
    图7.9.3 四种情况
    7.10本章小结
    本章介绍了hello的存储管理,hello的存储到底是怎么进行的呢。首先我们先了解了一些地址概念,在介绍了内存管理的两种方法,段式和页式管理。这样我们从理论上可以从逻辑地址找到它的物理地址。然后我们介绍了TLB与四级页表支持下的VA到PA的变换,得到了PA。经过三级Cache支持下的物理内存访问,我们访问到了实际的物理地址。又介绍了hello在fork和execve时的内存映射。简介缺页故障与缺页中断处理,最后谈及了动态内存分配。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O:
8.2 简述Unix IO接口及其函数
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)的条件,应用程序能检测到这个条件。类似地,写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5)关闭文件:当应用完成了对文件的访问之后,它就会通知内核关闭这个文件。作为相应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。无论一个进程因何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数:

  1. 进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件的:
    int open(char *filename, int flags, mode_t mode);
    open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
  2. 进程通过调用close 函数关闭一个打开的文件。
    int close(int fd);
    返回0,成功;返回-1,失败。
    3.应用程序是通过分别调用read 和write 函数来执行输入和输出的。
    ssize_t read(int fd, void *buf, size_t n);
    ssize_t write(int fd, const void *buf, size_t n);
    read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
    write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。图10-3 展示了一个程序使用read 和write 调用一次一个字节地从标准输入复制到标准输出。
    8.3 printf的实现分析
  3. printf函数
    图 8.3.1 printf函数的函数体
    图 8.3.1 printf函数的函数体
    va_list的定义: typedef char va_list 这说明它是一个字符指针。
    其中的: (char
    )(&fmt) + 4) 表示的是…中的第一个参数。
    然后在观察函数vsprintf(buf,fmt, arg)
    图 8.3.2 vsprintf函数的函数体
    图 8.3.2 vsprintf函数的函数体
    看i = vsprintf(buf, fmt, arg),vsprintf返回的是一个长度。而printf中后面的一句:write(buf, i),则是将buf中的i个元素写到终端。
    write的汇编语句是

图8.3.3 write的汇编语句
write前面构建了一些参数,ecx中是要打印出的元素个数 ;ebx中的是要打印的buf字符数组中的第一个元素,最后一句调用了syscall函数。我们再来看看syscall的实现。

sys_call:

     call save
     push dword [p_proc_ready] 
     sti
     push ecx
     push eb
     call [sys_call_table + eax * 4]
     add esp, 4 * 3
     mov [esi + EAXREG - P_STACKBASE], eax
     cli
     ret

其他涉及太多不提,这个函数的功能就是不断的打印出字符,直到遇到:’\0’ 。
ecx中是要打印出的元素个数 ,ebx中的是要打印的buf字符数组中的第一个元素 。

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
(以下格式自行编排,编辑时删除)
异步异常-键盘中断的处理:键盘中断处理子程序。,第一次getchar()时,确实需要人工的输入,但是如果你输了多个字符,以后的getchar()再执行时就会直接从缓冲区中读取了。实际上是 输入设备->内存缓冲区->程序getchar
getchar等调用read系统函数,通过系统调用读取按键ascii码,,一旦键入回车,getchar就进入缓冲区读取字符,一次只返回第一个字符作为getchar函数的值,如果有循环或足够多的getchar语句,就会依次读出缓冲区内的所有字符直到’\n’
8.5本章小结
本章到了hello实现的最后一步,临门一脚。当我们把前面的内在要求完成之后,最后面对了出结果的时候,我们要实现最后的输出,将hello程序的结果展现在大家面前。
先是介绍了Linux的IO设备管理方法和Unix IO接口及其函数,再分析printf和getchar的实现,最终了解了如何输入输出。

结论

用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello经历的过程

  1. hello先是使用编程软件一行行敲入计算机中保存为.c文件。
  2. 预处理,将hello.c中的外部库,宏定义,条件编译等展开得到hello.i
  3. 编译,hello.i翻译为汇编文件hello.o
  4. 汇编,将汇编语言hello.o翻译成可重定向文件hello.s
  5. 链接,将hello.s与可重定位目标文件和动态库链接成为可执行目标程序hello
  6. 运行,在shell下,经fork,execve得到进程hello。
  7. 访问内存, 将VA地址利用快表和四级页表变换为PA,再在三级cache下进行内存访问。
  8. I/O,通过使用Unix IO函数实现输入和输出。

感悟:
一个简单的hello.c,一次漫长的大作业,二者交织之后带给我一份难忘的回忆。
初始hello.c,不过是一个简单的程序,却不知其中又别有洞天,就好像王国维先生的“昨夜西风凋碧树,独上高楼,望尽天涯路”。我们在IDE下的一个简单的Build操作,却对应hello的预处理,编译,汇编,链接,一系列过程,每个过程都精准而优雅。在这次实验中,我们亲自进行了这一步步的过程,分析一个个的中间产物和他们的信息,这一部分也是我认为这个实验最难的一部分。枯燥无聊的一步步查找,分析,这种滋味真的不太是种享受。但是,我还是一点点的做了下来,可真是,“衣带渐宽终不悔,为伊消得人憔悴”。然后就是进程管理,存储管理,IO管理。乍一看,和我们的hello进程实现无太大关联,但是不仅hello对这些必不可缺,其他的进程也根本离不开这些管理。只有实现这些我们才能是hello进程成功的运行。经过这之后又有一种“众里寻他千百度,蓦然回首,那人却在灯火阑珊处”的滋味涌上心头。
一次漫长的大作业,一个并不简单的hello.c,一份难忘的回忆。

附件

列出所有的中间产物的文件名,并予以说明起作用。

文件名称作用
hello.i预处理之后的文件
hello.s编译之后的汇编文件
hello.o汇编之后的可重定位目标文件
hello链接之后的可执行目标文件
helloobjdump.txthello.o的反汇编文件
helloobjdump2.txthello的反汇编文件
hello.elfhello.o的ELF头
hello2.elfhello的ELF头
helloreadelf.txthello.o的ELF所有信息
helloreadelf2.txthello的ELF所有信息

参考文献

[1] Randal E.Bryant, David R.O’Hallaron.深入理解计算机系统(原书第三版). 北京:机械工业出版社,2016.7.
[2] printf函数实现:https://www.cnblogs.com/pianist/p/3315801.html
[3] Robert Love. Linux内核设计与实现(原书第3版). 北京:机械工业出版社,2011-4-30.
[4] 博韦,西斯特(美). 深入理解LINUX内核(第三版). 北京:中国电力出版社,2007-10-01.
[5] 内存管理:http://www.cnblogs.com/edisonchou/p/5115242.html
[6] sleep函数: https://www.ibm.com/support/knowledgecenter/zh/SSMKHH_10.0.0
/com.ibm.etools.mft.doc/bk52030_.htm

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值