hello的一生

摘 要
摘要:
通过hello程序从高级语言程序被编译、汇编、链接、运行,从外部存储设备,经过I/O桥,进入到内存,各级cache,最后在I/O中输出,最后被回收的过程描述,诠释了hello,简单却复杂的一生,描述了即使是最简单的程序,却在生命周期中有着同样复杂的经历,从而展现了程序从高级语言编写完成到最终变为机器语言在执行的过程。将课本知识和实际进行结合,同时也是对课程的一次回顾和复习。
关键词:
编译;汇编;链接;存储;进程;存储;I/O

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

目 录

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

第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
程序的周期从c语言开始,到最后被机器执行可以分为四个过程:
1.预处理。
2.编译。
3.汇编。
4. 链接。
预处理就是预处理器(cpp)根据以字符#开头的的命令,修改原始的c程序将一些库函数的内容插入,并且把一些宏替换带入。结果生成另一个c程序,以i为扩展名。编译器(ccl) 接着对.i文件进行编译,将高级语言转换为低一层次的机器语言(如:mov $1,%rax),形成hello.s汇编语言文本文件。汇编器as 将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o 中。hello.o 文件是一个二进制文件,它的字节编码是机器预言指令而不是字符。可以使用二进制阅读软件hexedit等查看。在链接阶段,链接器会把我们所调用的库函数单独预编译好了的重定位文件与hello.o进行链接,得到的结果就是可执行文件hello,这时就可以加载到内存并执行了。
以上是从.c到可执行文件的过程,当我们在命令行中输入./hello时,shell(壳)会对命令行进行解释,回车结束时程序已经开始执行。首先操作系统会使用fork函数形成一个子进程,分配相应的内存资源,包括CPU的使用权限和进程虚拟内存等。然后使用execve函数加载进程。至此完成了从程序到进程的转变P2P(From Program to Process)。这时这个fork的进程的上下文已经变成hello的信息。CPU会一步一步执行此程序,按照取指、译码、执行、访存、写回、更新PC等微操作,逐步执行目标文件中的程序。同时,CPU使用流水线、进程切换等工作方式实现多进程作业。
回看进程管理和储存管理。OS的进程管理调用fork函数产生子进程(process),调用execve函数,并进行虚拟内存映射(mmp),并为运行的hello分配时间片以执行取指译码流水线等操作;OS的储存管理以及MMU解决VA到PA的转换,cache、TLB、页表等加速访问过程,IO管理与信号处理综合软硬件对信号等进行处理;程序结束时,shell回收hello进程,内核将其所有痕迹从系统中清除。这时实现了020:From Zero-0 to Zero-0。

1.2 环境与工具
硬件工具:X64 AMD A10 CPU,8GRAM,512GHD DISK
软件工具:Windows10 64位,Vmware 14.1.3,Ubuntu18.04.1 LTS
开发者与调试工具:gcc,gdb,edb,Winhex,vim,ld,readelf,objdump,ldd等
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
中间结果文件 文件作用
hello.c 源代码
hello.i 预处理得到的文件
ASCII码的中间文件
hello.s ASCII汇编语言文件
hello.o as得到可重定位目标文件
hello.asm 反汇编得到的文本文件
hello.elf hello.o的elf文件
hello ld得到可执行目标文件
hello_elf hello的elf文件
hello_asm hello的反汇编文件

1.4 本章小结
本章我们分析了Hello展开从Program到Process以及0 to 0的大概过程。这是一个整体的大纲。我们将会从hello的出生开始,一步一步的讲述hello的一生中的故事。预处理、编译、汇编、链接。我们会从这四个时期出发,来讲述hello从出生到走向工作的过程。
(第1章0.5分)

第2章 预处理
2.1 预处理的概念与作用
预处理的概念
预处理是在编译之前进行的处理,一般指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程,预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive)。
预处理过程扫描源代码,对其进行初步的转换,产生新的源代码提供给编译器。预处理过程读入源代码,检查包含预处理指令的语句和宏定义,并对源代码进行响应的转换。预处理过程还会删除程序中的注释和多余的空白字符。
预处理的作用
扩展源代码,插入所有用#include命令指定的文件,扩展所有用#define声明指定的宏。
作用主要与三部分有关:宏定义,文件包含,条件编译

  1. 宏定义相关。预处理程序中的#define 标识符文本,预处理工作也叫做宏展开:将宏名替换为文本(这个文本可以是字符串、可以是代码等)。
  2. 文件包含相关。预处理程序中的#include,将头文件的内容插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件,这与复制粘贴的效果相同。
  3. 条件编译相关。根据#if以及#endif和#ifdef以及#ifndef来判断执行编译的条件。
    2.2在Ubuntu下预处理的命令
    指令:gcc -E hello.c -o hello.i 或
    2.3 Hello的预处理结果解析

图 2.3.1
打开.i文件们直观地可以发现,代码长度变得极长,由原来的十几行变为现在的所框出的3060行的ASCII码中间文本文件,而我们原来的hello.c也存在于其中,在文本的最末尾从3046行开始的位置。对其中的一函数进行简单的查看可以发现增加了许多带有extern的函数,这些外部函数便是预处理过程中对#include这样的头文件的展开的插入。

图2.3.2

图2.3.3
以上两图为一些预处理文件中的内容。
头文件一般包括以下几部分内容:
1.对类型/函数的声明
2.内置(inline)函数的定义
3.宏定义#definne以及以及一些其他的红定义
4.全局变量的定义
5.外部函数以及外部变量的定义
6.有需要的情况还可以包含其他头文件
总结地来说,预处理过程在main函数之前,预处理器(cpp)读取头文件stdio.h 、stdlib.h 、和unistd.h中的内容,三个系统头文件依次展开。比如stdio.h的展开,打开usr/include/stdio.h发现了其中还含有#开头的宏定义等,预处理器会对此继续递归展开,最终的.i程序中没有#define,以及#开头的语句。
2.4 本章小结
本章主要介绍了预处理的概念和应用功能,以及Ubuntu下预处理的两个指令,同时具体到我们的hello.c文件的预处理结果hello.i文本文件解析,详细了解了预处理的内涵。我们发现预处理主要由预处理器完成,这一阶段一共完成4件事:头文件的展开;宏替换;去掉注释;条件编译

(第2章0.5分)

第3章 编译
3.1 编译的概念与作用
编译的概念
编译就是将源语言经过词法分析、语法分析、语义分析以及经过一系列优化后生成汇编代码的过程。具体到我们实验,就是将预处理得到的ASCII码的中间文件hello.i翻译成ASCII汇编语言文件hello.s的过程。编译器ccl中包含一个将C程序按照一定的语法规则分方法翻译成汇编代码的程序,汇编语言相比高级语言更加贴近于机器,这种汇编语言的集合称为指令集,不同的CPU的指令集也不尽相同。一般包括mov赋值指令,jg条件跳转,comjg条件转移,逻辑运算指令等等。同时用编译器编译C程序的时候可以指定编译器的优化等级如-O1,-O2,以及编译选项如-no-pie等,不同的优化等级对程序处理不同,优化等级越高,程序越符合机器的思维方式,能够最大化利用CPU,但是不利于人的理解。
编译的作用
编译的目的是将高级语言程序转化为机器可直接识别处理执行的机器码的中间步骤。它包括以下几个部分。

  1. 词法分析。对输入的字符串进行分析和分割,形成所使用的源程序语言所允许的记号(token),同时标注不规范记号,产生错误提示信息。
  2. 语法分析。分析词法分析得到的记号序列,并按一定规则识别并生成中间表示形式,以及符号表。同时将不符合语法规则的记号识别出其位置并产生错误提示语句。
  3. 语义分析。即静态语法检查,分析语法分析过程中产生的中间表示形式和符号表,以检查源程序的语义是否与源语言的静态语义属性相符合。
  4. 代码优化。将中间表示形式进行分析并转换为功能等价但是运行时间更短或占用资源更少的等价中间代码。

3.2 在Ubuntu下编译的命令
指令:gcc -S hello.i -o hello.s

3.3 Hello的编译结果解析
3.3.1汇编文件的解析

图3.3.1
从图3.3.1可以看出,相比于.i文件,.s文件减少许多内容。可以看到已经变成汇编代码。注意到main之前有一段内容以.开始,这些内容本身不是汇编代码,而是对编译器来说起到了类似于标签的内容。
.file说明了文件的内容是 “hello.c”
.text代表代码段
.section .rodata 只读代码段
.global main指明标签main是一个可以在其它模块的代码中被访问的全局符号
.align 8 代表采用的对齐方式是8字节
.type main, @function 声明对象类型或函数类型
.string “Hello %s %s\n” 代表字符串类型

3.3.2数据的解析
(1)字符串常量

此汇编代码中涉及到的字符串有两个 如图3.2.2-1
第一个.string “\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” 这个是printf传入的格式串,对应于我们的c语言程序就是 。值得注意的是,在Linux操作系统下字符串使用UTF-8的格式编码,一个汉字在UTF-8中占三个字节。正好与上面相对应。
第二个.string “Hello %s %s\n”,仍然是printf传入的格式串。对应的是 。同时作为格式串的字符串应该在rodata段。

图 3.3.2-1
(2)整型变量
a. int i
如图3.3.2-2。
这里的i是一个局部变量作为循环变量来进行计数,编译器将局部变量存放在栈和寄存器中。hello.s程序中i存放在-4(%rbp)处,占据4字节,对应于汇编语句movl $0, -4(%rbp)。在循环中重复执行指令便能实现i++。

图3.3.2-2
b. int argc
argc是main函数的参数记录。记录传入参数个数(函数名算参数),在这里参数用寄存器来传递。如下图3.3.2-3所示。argc 通过寄存器%edi传递并放在栈-20(%rbp)处,用于判断是否有足够的4个参数。通常情况下,%edi保存函数的第一个参数。

图 3.3.2-3
c. 立即数
在汇编代码中许多整数以立即数的形式出现,立即数以$开头,直接出现在代码段,如本代码中的subq $32, %rsp $4, -20(%rbp) 等,他们可能是本身就以立即数的形式出现在c语言代码中或者在预处理中由于宏替换直接被代换成了立即数。
d. int sleepsecs
sleepsecs是已被初始化的全局变量(初始化语句为int sleepsecs=2.5;)。由于.data 节存放已经初始化的全局和静态C变量,编译器首先将sleepsecs在.text代码段中声明为全局变量;其次在.data 段中,设置对齐方式(.align)为4字节对齐,设置类型(.type)为对象,设置大小(.size)为4字节,设置为long类型(.long),其值为2(如图3.3.2-4)。

图3.3.2-4
有趣的是,在这个过程中,我们发现全局变量sleepsecs被转化为long类型。这个问题是因为自动转换是内置规则,即隐式转换,而且int转化为long不会丢失数据。这与编译器缺省有关。

(3)字符串数组
此代码中涉及的变量类型只有整形与字符串,argv[]就是一个字符串数组。argv是一个二重指针,其中每一个数组元素都是一个指针指向相应的字符串的首地址。由于argv同argc一样是main的参数,因此argv同样使用寄存器来传递。如图3.3.3所示。注意两个划线语句,argv是由寄存器%rsi传递,存放在栈中的rbp-32的位置,对argv[]数组进行访问的时候就用到了这个二重指针,通过每次+16来实现对argv[1],argv[2]的首地址的得到,然后间接引用这个地址的内容就可以得到各个字符串的内容。

图 3.3.3
3.3.3赋值的解析
1.=赋值
在这里用赋值语句进行的赋值只有一句就是对i这个循环变量的赋初值,这个变量在声明的时候并无初值,在栈中分配地址空间,如划线语句:

在汇编语句中可以发现由于这个i是局部变量,保存在栈中-4(%rbp)中,因此这里的赋值采用movl指令对栈中的空间进行立即数赋值。
值得注意的是mov指令之后的后缀。b代表8位,w16位,l32位,q64位。

3.3.4类型转换的解析
1.main函数中调用了一个库函数atoi,作用是将字符串表示的数转化为实际的浮点数(double),这里出现了一次字符和浮点数的显式转换。属于显式转换。
2.由于转换之后的值作为sleep函数的参数进行调用,让我们来看一下sleep函数的定义,可以看到参数是无符号整型,这里出现了一次隐藏的转换,将浮点double数转换为unsigned int
头文件:#include <unistd.h>
函数:unsigned int sleep (unsigned int seconds);//n秒
此外:int usleep (useconds_t usec);//n微秒
一般来说c语言中的类型转换涉及到的情况比较复杂,不同的数据类型之间存在优先级关系,一般情况下类型的级别从高到低依次是 long double、double、float、unsigned long、long long、unsigned long、long、unsigned int、int,在赋值表达式语句中,计算的最终结果会被转换成赋值变量的类型,一般优先级,范围以及精度高的向低精度范围的类型转换时都能保证正确性,但是相反则很容易出现一些由于精度产生的很严重的错误。

3.3.5算术操作的解析
Linux中算术操作的指令有许多,在书中已经有详细地介绍与解释。下面用书上的两张图来具体看一下算术操作指令用法:

图 3.3.5
在hello.s中出现的算术操作指令有三:
a) 加法操作add:
在对计数器加一时addq 这里在对argv访问时的指针计数器每次+8

对循环变量i。在hello.s文件中是通过语句addl $1, -4(%rbp)实现i++的,因为-4(%rbp)继承自原来的i=0,因此通过addl(有意思的是,这里仍然要使用addl,因为是int操作)达到每次循环+1的目的

b)减法操作sub:
减法操作sub:为main函数开辟栈帧是将栈顶指针-0x32

c) 加载有效地址:将LC1的有效地址传送给%rdi。

3.3.6关系操作的解析
cpu中关系操作主要依靠cmp及test系列指令来完成,cpu维护着一组单个位的条件码,cmp指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目的寄存器之外,cmp指令与sub指令的行为是一样的。一般在此类指令之后都会跟着jx或检查条件码的操作,也就是通过这样的机制使得操作数的关系操作得以实现。

图3.3.6-1
Hello.c中涉及的条件有以下两个高级语句
if(argc!=4) 判断输入参数的个数是否正确
for(i=0;i<8;i++)循环判断
对应于hello.s中的语句如下:

3.3.7控制转移的解析
C语言中的语句不仅仅是顺序结构执行。某些结构,比如条件语句,循环语句,分支语句要求有条件的执行,根据数据测试的结果来决定操作执行的顺序。
机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。一般来说关系操作会和控制转移相关联。常用的条件码如下:

图3.3.7-1
Hello这个程序中涉及到控制转移的操作仍然有两个
a)
这个汇编代码对应于高级语言是if(argc!=4),用cmpl指令将argc(在栈中)与立即数4进行比较,如果不相等则设置条件码ZF标志位,如果相减结果得0,ZF会被设置,je指令根据ZF标志位确定是否需要跳转到L2还是顺序执行下面的指令,从而实现了控制的转移。
b)
这个汇编代码对应于高级语言是for(i=0;i<8;i++)将循环变量i(存在栈中)与立即数7进行比较,同时会设置条件码,检查条件码如果小于则跳转到循环体内容实现了控制的转移。

图 3.3.7-2整个循环过程的控制转移汇编代码
最后,给出AT&T汇编中跳转指令的规范。

3.3.8数组指针的解析
大致说明:对于汇编语句,有关的操作大多数是通过数据传送mov指令实现的。
1)首先是语句movl %edi, -20(%rbp)和movq %rsi, -32(%rbp)。分别是将寄存器%edi的内容赋值给-20(%rbp)指针指向的地址,将寄存器%rsi的内容赋值给-32(%rbp)指针指向的内容。这2句汇编语句对应源程序中main函数形参的传入部分。
2)对于源程序中的输出argv[1]和argv[2]部分。在编译器的处理下变成了截图所示部分。箭头标注的部分代表取出指针所指的内存中的内容。

图3.3.8 取出指针指向的内容

3.3.9函数的解析
C语言中的用函数这个功能使得一个大的工程或问题可以得到细化,分而治之,使得主函数变得精简而优雅。函数操作涉及到参数传递(地址/值)、函数调用()、函数返回 return等。在汇编代码中涉及到函数的调用与返回的指令是call与ret,函数的调用一般与运行时栈联系紧密。

源代码中的函数有main函数,printf函数(第一处被优化为puts函数),sleep函数,getchar函数和exit函数。
1)参数传递(地址/值)。
过程中的传递数据,P必须能够向Q提供一个或多个参数。对于一个64位的系统,前六个参数的传递是通过寄存器来传递分别是 rdi rsi rdx rcx r8 r9,如果再多的参数需要在通过压栈传递,先出现的参数后压栈,如图中所示,第七个参数离上一个栈帧最远。
对于main函数。函数形参有2个,在汇编代码中分别是用这两条语句达到传送参数的功能的(如图3.3.9-1)。
即函数原来将我们要传入的参数储存在%edi和%rsi中,然后在栈上保存。更具体一步,传入的两个参数分别是值和地址。

图3.3.9-1 main函数传递形式参数
对于printf函数。printf函数在具体的汇编代码中被优化为puts函数。我们发现第一次在汇编代码中(图3.3.9-2)首先将%rdi赋值为字符串“Usage: Hello 学号 姓名! \n”字符串的首地址(leaq .LC0(%rip), %rdi)。然后调用了puts函数,即将第一处字符串参数传入。
对于第二处,类似的,我们发现在汇编代码中(图3.3.9-3)首先将%rdi赋值为字符串"Hello %s %s\n"的首地址。这里没有被优化为puts函数,而是直接调用printf函数。同时设置%rsi为argv[1],%rdx为argv[2]。这样就可以根据控制字符串,直接输出终端键入的命令行。

图3.3.9-2,第一次调用printf函数

图3.3.9-3
对于sleep函数。根据图3.3.9-4我们可以发现,传入参数的过程为movl sleepsecs(%rip), %eax和movl %eax, %edi,对应原来函数的形式参数为全局变量sleepsecs。

图3.3.9-4
对于getchar函数。如图。

对于exit函数(如图)。通过汇编语句movl $1, %edi将%edi寄存器内容设置为1。

2)函数调用
对于main函数。main函数被调用即call才能执行(被系统启动函数 __libc_start_main调用)。对于call指令,它将下一条指令的地址dest压栈, 然后跳转到main 函数,即完成对main函数的调用。
对于printf函数。在main函数内部,通过汇编语句call puts@PLT调用(第一次),通过汇编语句call printf@PLT调用(第二次)。

对于sleep函数。在main函数内部,被多次调用(在for循环内部),调用了10次,通过汇编语句call sleep@PLT除法此调用。
对于getchar函数。在main函数内部,最后被调用,调用它的汇编语句是:call getchar@PLT
对于exit函数。在main函数内部被调用,调用它的汇编语句是call exit@PLT。
3)函数返回
对于main函数。程序结束时,调用leave指令(leave相当于mov %rbp,%rsp和pop %rbp),恢复栈空间为调用之前的状态,然后 ret 返回(ret 相当 pop IP,将下一条要执行指令的地址设置为dest)。

此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.4 本章小结
编译的作用主要就是将高级语言转换为更低一级的汇编语言,可以将不同的高级语言根据编译器的不同类型转换成一些通用的汇编代码,它更加贴近于机器的思维与指令,同时编译器还可以检查高级语言代码的语法正确性,是我们编写代码到执行这个过程中最为重要的一环。
从高级语言转化为汇编语言这个过程是比较复杂的,编译器需要作出许多我们看不到的调整与优化,而且会根据用户所输入的选项不同,如:gcc -m64 -Og -no-pie -fno-PIC作出不同的编译,同时对c语言中的数据、赋值、类型转换、算术操作、逻辑/位操作、关系操作、数组/指针/结构操作、控制转移、函数操作在汇编语言中都会找到对应的处理与映射。
(第3章2分)

第4章 汇编
4.1 汇编的概念与作用
汇编是汇编器(as)将汇编语言书写的程序翻译成机器语言程序的过程。把这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,.o文件是一个二进制文件,它包含程序的指令编码。
这时程序已经由高级语言转换为机器可以明白的二进制代码,这个二进制文件是一个可重定位文件。它的结构与经典的elf文件的结构类似。
汇编算法采用的基本策略是简单的。通常采用两遍扫描源程序的算法。第一遍扫描源程序根据符号的定义和使用,收集符号的有关信息到符号表中,形成符号表节;第二遍利用第一遍收集的符号信息,将源程序中的符号化指令逐条翻译为相应的机器指令。

4.2 在Ubuntu下汇编的命令
汇编命令:as hello.s -o hello.o 或 gcc -c hello.s -o hello.o

4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
典型的ELF可重定位目标文件的表格如下:

ELF头 节
.text
.rodata
.data
.bss
.symtab
.rel.text
.rel.data
.debug
.line
.strtab
节头部表 描述目标
文件的节
下面逐一列出ELF文件的节的内容。
ELF 头:以 16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。

根据文件头的信息,可以知道该文件是可重定位目标文件,有13个节,入口地址为0x0,数据采用小端序x86-64等信息。
.text:已编译程序的机器代码。
.rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表。
.data:已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss中。
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态C变量。
.symtab:一个符号表,他存放在程序中定义和引用的函数和全局变量的信息。如下图所示。可以看到不同的符号的标码,他们的值(如果值有意义),所占空间大小(如main占146字节) 所属类型(全局还是局部),以及最后的符号的名称。
节头部表:节头表包括节名称,节的类型,节的属性(读写权限),节在ELF文件中所占的长度以及节的对齐方式和偏移量。我们可以使用终端指令readelf -S hello.o来查看节头表。这个表中包含了这个重定位文件中的各个节的名称大小,其中的数据类型,地址,相对于头的偏移量,以及对齐方式以及可以对各个节进行的操作:write 等
.rela重定位节。该节包括的内容是:偏移量,信息,类型,符号值,符名称和加数。我们可以看到截图中有8条重定位信息,分别对应对.L0(第一个 printf 中的字符串),puts 函数,exit 函数,.L1(第二个 printf 中的字符串)、printf 函数、 sleepsecs、sleep 函数、getchar 函数。
关于.rela的结构声明如下。
typedef struct {
Elf64_Addr r_offset;
Elf64_Xword r_info;
Elf64_Sxword r_addend;
} Elf64_Rela;
a)r_offset
此成员指定应用重定位操作的位置。不同的目标文件对于此成员的解释会稍有不同。但对于可重定位文件,该值表示节偏移。重定位节说明如何修改文件中的其他节。重定位偏移会在第二节中指定一个存储单元。
b)r_info
此成员指定必须对其进行重定位的符号表索引以及要应用的重定位类型。重定位类型特定于处理器。重定位项的重定位类型或符号表索引是ELF32_R_TYPE 或ELF32_R_SYM分别应用于项的r_info成员所得的结果。对于 64 位 SPARC Elf64_Rela 结构,r_info 字段可进一步细分为 8 位类型标识符和 24 位类型相关数据字段。对于现有的重定位类型,数据字段为零。但是,新的重定位类型可能会使用数据位。
c)r_addend
此成员指定常量加数,用于计算将存储在可重定位字段中的值,Rela 项包含显式加数。64 位 x86 仅使用 Elf64_Rela 重定位项。因此,r_addend 成员用作重定位加数。
这里具体讲一下重定位类型中的R_X86_64_PC32和R_X86_64_32

这里的重定位节在之后的链接中会成为对这些函数的映射的具体位置计算的关键
同时书中也给出了重定位的计算方法:

4.4 Hello.o的结果解析
输入命令objdump -d -r hello.o >hello.asm,打开hello.asm。
分析hello.o的反汇编代码(即分析hello.asm文本文件)与hello.s文件的区别(总体大致相同,只有小部分区别)

  1. 分支转移:hello.s文件中分支转移是使用段名称进行跳转的,而hello.o文件中分支转移是通过地址进行跳转的。

hello.s分支跳转

hello.o分支转移
2. 函数调用:hello.s文件中,函数调用call后跟的是函数名称;而在我们的hello.o文件中,call后跟的是下一条指令。而同时因为这些函数都是共享库函数,这时候地址是不确定的,因此call指令将相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待链接的进一步确定。

hello.s调用函数

hello.o调用函数
3. 全局变量:hello.s文件中,全局变量是通过语句:段地址+%rip完成的;对于hello.o的反汇编来说,则是:0+%rip,因为.rodata节中的数据是在运行时确定的,也需要重定位,现在填0占位,并为其在.rela.text节中添加重定位条目。

hello.s访问变量

hello.o访问变量
说明机器语言:
机器语言:二进制的机器指令的集合;
机器指令:由操作码和操作数构成的;
机器语言:灵活、直接执行和速度快。
汇编语言:主体是汇编指令,是机器指令便于记忆的表示形式,为了方便程序员读懂和记忆的语言指令。
汇编指令和机器指令在指令的表示方法上有所不同。
4.5 本章小结
本章对汇编结果进行了详尽的介绍。与我们的hello.o文件相结合,介绍了汇编的概念与作用,以及在Ubuntu下汇编的命令。同时本章主要部分在于对可重定位目标elf格式进行了详细的分析,侧重点在重定位项目上。同时对hello.o文件进行反汇编,将hello.asm与之前生成的hello.s文件进行了对比。使得我们对该内容有了更加深入地理解。
(第4章1分)

第5章 链接
5.1 链接的概念与作用
概念:
链接器(1d)就负责处理将printf.o等c语言库函数的已经单独编译好的目标文件与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.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
与可重定位文件结构类似。ELF头中给出了一个16字节序列,描述了生成该文件系统字的大小和字节顺序。其余的为帮助链接器进行语法分析和解释目标文件的信息,包括ELF文件的大小,节头部的起始位置,程序的入口地点,目标文件的类型,机器类型,节头部表的文件偏移,以及节头部表中条目的大小与数量,
使用readelf -a hello >hello_elf生成hello的ELF格式文件。
1.ELF头

对比之前用hello.o形成的elf文件发现文件头发生了一些变化:
1)类型发生改变汇编之后是可重定位文件,而现在变成了可执行文件。
2)程序头大小改变,原本没有程序头现在程序头56个字节。
3)程序的入口地址发生改变,原本程序头与程序入口的地址均为0,现在都已经有了确切的地址,指明了程序第一条语句的地址。
4)节头数量与字符串表索引节头改变,elf节的数量改变
2.查看文件头信息

现在增至27个节,而且已经没有重定位即rel节。因为行成可执行文件之后已经完成重定位。多出许多节与我们连接的库函数有关。而且其中包括size大小和在程序中的偏移量offset,因为是已链接的程序,所以根据标出的信息就可以确定程序实际被加载到虚拟地址的地址。
3.符号表

符号表中的条目变为现在的51条,在链接过程中加入了许多新的符号。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
通过分析典型的ELF可执行文件目标文件结构可以知道这些节最后加载到虚拟内存空间为两个段——代码段和数据段,而且这两个段的起始地址可以通过5.3的分析得出。

查看ELF文件中的程序头,这是在程序被执行的时候告诉链接器运行时需要加载的内容并提供动态链接信息。每个表项提供了各段在虚拟地址空间中的大小,位置,访问权限和对齐方式。

第一个有R/E(读/执行)权限,开始于内存0x400000处,总共大小是0x890字节,这其中包括ELF头,程序头部表,以及.init、.text和.rodata节。
第二个有RW(读写)权限,开始于内存0x600e00处,总共大小为0x254字节,占内存0x258字节(这里多的字节对应于运行时被初始化为0的.bss节)。
下面使用edb打开hello程序,通过Data Dump窗口可以查看加载到虚拟地址中的hello程序。

可以对这个datadump的开始的字节与elf头的magic进行对比可以发现,二者相同,说明程序正是从0x400000开始加载,而这段数据也正描述了整个elf文件的一些总体信息。并且代码段也开始于0x400000。其次我们观察0x600e10开始的地址空间代表了数据段。

5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
使用objdump反汇编可执行文件:

使用objdump反汇编目标文件:

二者的差别在于以下的几处:
1.链接增加新的函数:链接过程中,会从我们所引用的共享库中寻找连接器需要解析的符号(库函数),如puts,exits这类函数会加入到可执行文件的内容中。
2.增加的节:hello中增加了.init和.plt节,和一些节中定义的函数。
3.函数调用:hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
4.地址访问:hello.o中的相对偏移地址变成了hello中的虚拟内存地址。而hello.o文件中对于.rodata等全局变量的访问,是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。
下面具体对一些点进行分析:
通过对比可以发现hello相比于hello.o增加了许多内容,而且这些内容是链接时所加入的,因此hello的但汇编是综合了hello.o与一些目标文件,下面分析这两个反汇编代码的具体不同。
首先来看一下有什么不同,在.o文件中main段子执行其他函数的调用时,在注释中的仅仅是对main函数的一个偏移量,而且在代码中相应的偏移值也都是0,如图中所框以及一些类似的指令,这样并不能确定那个函数的位置在哪,只能知道是一个函数以及重定位方式,具体要在链接之后才能确定。

再看链接之后得到的hello的反汇编中main,如下图

链接之后的main段,首先各个指令都有了自己的地址,而这个地址使他们的真实地址,其次在对函数的调用也讲这些偏移进行了填充,我们来具体计算一个例子。
地址在0x401182调用了一个call,调用printf的虚拟地址是0x4010a0。我们用这条指令的下一条指令地址0x401187作为PC。因此函数跳转量应该为4010a0-401187,用有符号整数表示为0Xffffff19。小端模式下为19 ff ff ff。正好对应401182后机器代码e8后的值。
接下来的call指令也是一样,在hello中准确的指明了具体调用的函数,而hello.o文件中也只有一个main函数的偏移量。这里充分说明了重定位进行的工作结果,而连接器进行重定位的依据也就是那些.o中的注释。
下面我们看一下二者之间相差的那些节是什么,main.o只有一个main但是可执行文件却有很多其他的节,这些内容很明显是链接时加入的,这就要对链接的过程进行简要的分析。为了构造可执行文件,链接器先后完成两个主要任务:符号解析和重定位。
1 对各个目标模块中没有定义的变量,在其它目标文件中找到相关的定义
2 把不同目标文件中生成的相同类型的段进行合并
3 把不同目标文件中的变量进行地址重定位
动态链接库:程序在运行的时候才去定位这个库,并且把这个库链接到进程的虚拟地址空间。对于某一个动态链接库而言,所有使用这个库的可执行文件都共享同一块物理地址空间,该物理地址空间在当前动态链接库第一次被链接时加载到内存中。
静态链接库:将不同的可重定位模块打包成一个文件,在链接的时候会自动从这个文件中抽取用到的模块。
其中重定位的过程很重要,通过重定位才能完成在虚拟内存中的映射。

这是重定位条目,根据这个条目如何计算出来地址在第四章已经进行了讲解,下面举一个具体的例子:

以exit函数为例,我们查看它的重定位信息,偏移量为0x2b,我们查看main.o中的这个代码的地址为2a,而call指令e8占有一个字节,因此offest正好是0x2b对应上,下面我们看refptr如何计算,也就是e8之后的值,r.offest=0x2b,r.symbol=exit,r.type=4,r.addent=4, 可以计算出
refaddr=addr(main)+offest=0x401125+02b=0x401150, addr(symbol)=0x4010d0,
根据书上所讲公式
refptr=4010d0-4-401150=ffffff7c正好与hello反汇编所得到的值对应。

5.6 hello的执行流程
使用edb单步调试运行程序,观察其调用的函数,这里可以发现在调用main之前主要进行了初始化的工作调用了_init,在这个函数之后动态链接的重定位工作已经完成,我们可以看到在这个函数的调用之后是一系列在这个程序中所用到的库函数(printf,exit,atoi等等)这些函数实际上在代码段并不占用实际的空间只是一个占位的符号,实际上他们的内容在共享区(高地址)处。之后调用了_start这个就是起始的地址,准备开始执行main的内容,main函数内部所调用的函数在第三章已经进行了充分的分析这里略过main内部的函数,在执行main之后还会执行__libc_csu_init 、__libc_csu_fini 、_fini 最终这个程序才结束。
下面列出了各个函数的名称与地址

  1. _init <0x0000000000401000>
  2. puts@plt <0x0000000000401090>
  3. printf@plt <0x00000000004010a0>
  4. getchar@plt <0x00000000004010b0>
  5. atoi@plt <0x00000000004010c0>
  6. exit@plt <0x00000000004010d0>
  7. sleep@plt <0x00000000004010e0>
  8. _start <0x00000000004010f0>
  9. _dl_relocate_static_pie <0x0000000000401120>
  10. main <0x0000000000401125>
  11. __libc_csu_init <0x00000000004011c0>
  12. __libc_csu_fini <0x0000000000401230>
  13. _fini <0x0000000000401238>
    5.7 Hello的动态链接分析
    动态链接是为了减少共享库函数的代码频繁出现在各个程序中从而占用宝贵而昂贵的内存空间从而提出的一个有效的方法,共享库是一个.so目标模块(elf文件)在加载时由动态链接器程序加载到内存的任意位置并和一个内存中的程序(如当前的可执行文件)动态完全连接为一个可执行程序,使用这个技术可以节省内存与磁盘空间方便库函数的更新与升级。
    这里涉及到有关位置无关代码的知识,程序调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定的技术解决这个问题,将过程地址的延迟绑定推迟到第一次调用该过程时。

下面两个图所示的就是在执行函数dl_init前后,数据段的一些变化

通过对两个图的对比,注意到0x404000开始的地方内发生了变化,由前面的分析可以知道GOT表的位置就在0x404000处,这里执行ld_init之后填入了GOT的信息。
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
这一章主要介绍的是如何从可重定位文件hello.o生成可执行文件hello的过程。从ld链接器将hello.o的链接命令,然后到可执行文件ELF的查看,分析可执行文件的相关信息,hello的重定位过程,执行流程,hello的动态链接分析,进一步加深了对链接以及动态链接过程细节的理解。
(第5章1分)

第6章 hello进程管理
6.1 进程的概念与作用
进程概念
狭义定义:进程是正在运行的程序的实例(an instance of a computer program that is being executed)。
广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
 进程作用
1)在现代计算机中,进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
2)每次用户通过向shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
3)进程提供给应用程序两个关键抽象:一个独立的逻辑控制流;一个私有的地址空间。
6.2 简述壳Shell-bash的作用与处理流程
1.shell定义
Linux系统中,Shell是一个交互型应用级程序,提供了用户与内核进行交互操作的一种接口。代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程。
Shell有许多形式1)sh 最早的shell 2)csh/tcsh 变种 3)bash变种、缺省的Linux shell
2.shell的功能
Shell是一种命令解释器,它解释由用户输入的命令并且把它们送到内核去执行。Shell可以解析命令行将其划分为各个字符串存在main的参数argv[]中,对于各个命令,shell负责判断命令的正确性以及分类(内置还是可执行文件等),并execve加载运行这个可执行程序,同时shell还可以处理各种信号,负责回收终止的子程序,防止僵死进程的出现。
3.shell的处理流程
(a)终端进程读取用户由键盘输入的命令行。
(b)分析命令行字符串,将命令行以空格为间隙分解,获取命令行参数,并构造传递给execve的argv向量
©检查第一个命令行参数是否是一个内置的shell命令,如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。
(d)如果是一个路径下的可执行文件,调用fork( )创建新进程/子进程
(e)在子进程中,用步骤b获取的参数,调用execve( )执行指定程序。
(f)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait…)等待作业终止后返回。
(g)如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程
fork()函数:
父进程通过调用fork函数创建一个新的运行的子进程,子进程几乎与父进程完全一样,得到与父进程相同的用户级虚拟地址空间,代码、数据段、共享库以及用户栈,同时子进程可以读写父进程中打开的任何文件,他们的不同就是有着不同的PID。例如,要是父进程打开了五个文件,那么子进程也有五个打开的文件,而且这些文件的当前读写指针也停在相同的地方。所以,这一步所做的是复制。这样得到的子进程独立于父进程,具有良好的并发性。同时fork函数还会返回两次,在子进程中返回0,而在父进程中返回子进程的PID,如下面的例子:
pid_t pid;
pid=fork();//fork不需要传递参数。
if(pid==0){ //返回值为0,说明在子进程,下面是子进程中需要做的事
printf(“%d%d”,getpid(),getppid());
}
else{ //返回值不为0 ,说明在父进程中,下面写父进程中需要做的事
printf(“%d”,pid);
}
总结来说fork();函数有如下特点:
1).调用一次,返回两次
2).父进程与子进程并发执行
3).相同但是彼此独立的地址空间
4).共享文件
Hello的fork过程:
在终端输入./hello 120L022419 lmh 1
因为./hello不是内置命令,而是当前目录下的可执行文件,于是终端调用fork函数在当前进程中创建一个新的子进程,该进程与父进程几乎完全相同。子进程得到与父进程用户级虚拟地址空间相同的副本,包括代码、数据、堆、共享库和用户栈。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行他们的逻辑控制流中的指令。这样一个子进程就创建好了,之后会将可执行程序hello加载到内存中,开始执行了。
6.4 Hello的execve过程
execve函数加载并运行一个可执行目标文件,这里是hello,而且带参数列表argv[]和环境变量列表envp[],只有当找不到可执行目标文件时才会回到调用程序,否则execve函数会将控制转移到新的可执行文件去执行,与fork函数调用一次返回两次不同,execve函数调用一次从不返回。如下图:

fork与execve不同,fork函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品,而execve函数在当前的进程上下文中加载并运行一个新程序。他会覆盖当前进程的地址空间,将其替换为这个可执行文件固有的一些信息,而且新的进程仍然有相同的PID。因此一般需要fork和execve配合使用才能加载并运行一个程序。主要进行的操作有:删除已经存在的用户区域、映射私有区域、映射共享区域、设置程序计数器PC。
当 fork之后,子进程调用 execve 函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即 hello 程序。execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置 PC 指向_start 地址,_start 最终调用 hello 中的 main 函数。
main开始时,用户栈的组织结构如下图所示:

6.5 Hello的进程执行
1.进程在执行时的顺序
在操作系统中,每一个时刻通常有许多程序在进行,但是我们通常会认为每一个进程都独立占用CPU内存以一些其他资源,如果单步调试我们的程序可以发现在执行时一系列的(PC)程序计数器的值,这个PC值的序列就是逻辑控制流。事实上,多个程序在计算机内部执行时,采用并行的方式,他们的执行是交错的,像下图每个程序都交错运行一小会儿,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。

2.进程的时间片
正是由于进程执行的并发性,在宏观上,我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。但是在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,一个进程执行它的控制流的一部分的每一时间段叫做时间片。
3.用户模式与内核模式
当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3级)用户代码中运行。
用户程序调用系统 API 函数称为系统调用(System Call);发生系统调用时会暂停用户程序,转而执行内核代码(内核也是程序),访问内核空间,这称为内核模式(Kernel Mode)。
用户空间保存的是应用程序的代码和数据,是程序私有的,其他程序一般无法访问。当执行应用程序自己的代码时,称为用户模式(User Mode)。
4.上下文与上下文切换
上下文:
上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象比如描述地址空间的页表,包含当前进程有关信息的进程表,以及包含进程以打开文件的信息的文件表构成。
在进程执行到某些时刻,内核可以决定抢占当前进程,并开始一个之前被抢占的进程开始执行,这种决策就叫做调度,由调度器来决定,在内核调度了一个新的进程后就会发生上下文切换的操作。
上下文切换的过程:
1.保存当前进程的上下文
2.恢复现在调度进程的上下文
3.将控制传给新恢复进程
在本次大作业中涉及到的一个很明显的上下文切换是sleep的调用,hello初始运行在用户模式,在 hello 进程调用 sleep 之后陷入内核模式,内核处理休眠请求主动挂起当前进程,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时(我们输入的指定时间)发送一个中断信号,此时进入内核状态执行中断处理,将 hello 进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
还有一个上下文切换是在getchar处,这个函数需要从标准输入读取字符,但是其实是调用了系统函数来执行,调用这个函数之后同样会陷入到内核中,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回 hello进程。
6.6 hello的异常与信号处理
1)正常运行
程序正常执行,总共循环8次每次输出提示信息之后等待我们从命令行输入的秒数,最后需要输入一个字符回车结束程序。
2)中途按下ctrl-Z
内核向前台进程发送一个SIGSTP信号,前台进程被挂起,直到通知它继续的信号到来,继续执行。当按下fg 1 后,输出命令行后,被挂起的进程从暂停处,继续执行。
3)中途按下ctrl-C
内核向前台进程发送一个SIGINT信号,前台进程终止,内核再向父进程发送一个SIGCHLD信号,通知父进程回收子进程,此时子进程不再存在。
4)运行途中乱按
运行途中乱按后,只是将乱按的内容输出,程序继续执行,但是我们所输入的内容到第一个回车之前会当做getchar缓冲掉,后面的输入会简单的当做我们即将要执行的命令出现在shell的命令行处。
5)输入ps打印前台进程组
ps打印当前进程的状态
6)打印进程树
详细显示从开机开始的各个进程父子关系,以一颗树的形式展现。
7)列出jobs当前的任务
jobs,打印进程状态信息
8)输入fg 1,继续执行前台进程1
9)输入kill
Kill之后会根据不同的发送信号的值,以及要发送的进程的pid发送相应的信号,这里我们将hello杀死。
6.7本章小结
本章介绍了hello程序在计算中的加载与运行的过程。计算机为每个程序抽象出一个概念为进程。Hello是以进程的形式运行,每个进程都处在某个进程的上下文中,每个进程也都有属于自己的上下文,用于操作系统通过上下文切换进行进程调度。用户通过shell和操作系统交互,向内核提出请求,shell通过fork函数和execve函数来运行可执行文件。操作系统中有一套异常控制的系统,用于保障程序运行。异常的种类分为较低级的中断,终止,陷阱和故障,还有较高级的上下文切换和信号机制。通过对hello执行过程中对其发送各种信号对各个信号的处理以形式有了更深的理解。
(第6章1分)

第7章 hello的存储管理
7.1 hello的存储器地址空间
物理地址(physical address):
用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相应。是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
逻辑地址(Logical Address):
由程式产生的和段相关的偏移地址部分。例如,你在进行C语言指针编程中,能读取指针变量本身值(&操作),实际上这个值就是逻辑地址,他是相对于你当前进程数据段的地址,不和绝对物理地址相干。只有在Intel实模式下,逻辑地址才和物理地址相等。表示为 [段标识符:段内偏移量],举一个具体的例子:DS:0x21785代表的就是代码段中偏移为0x21785的地址。
实模式下:逻辑地址CS:EA =物理地址:CS*16+EA
保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址, 段地址+偏移地址=线性地址。
线性地址(Linear Address):
逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
虚拟地址(Virtual Address):
现在计算机系统提供了一种对主存的抽象概念,叫做虚拟内存。虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美结合,他为每个程序提供了一个大的、一致的和私有的地址空间。通过一个很清楚的机制提供了三个重要的能力:1)他将主存看成是一个存储在存盘上的地址空间的高速缓存。2)他为每个进程提供了一致的地址空间,从而简化了内存的管理。3)它保护了每个进程的地址空间不被其他进程破坏。
虚拟地址就是建立在这样的环境下的概念,进程加载到内存中(以64位为例)都是从0x400000开始。这个地址就是虚拟地址。存在专门的虚拟地址到物理地址的映射的部件:MMU(地址翻译),这样虚拟地址通过MMU,页表机制,完成对物理地址的映射。
在一个程序执行时,CPU对一个物理地址的得到经历了:逻辑地址,通过段式管理得到线性地址(虚拟地址),在通过地址翻译,即页式管理翻译成物理地址,从物理地址中得到想要访问的数据。这些地址之间的关系如下图所示:

7.2 Intel逻辑地址到线性地址的变换-段式管理
段寄存器(16位):用于存放段选择符。
CS(代码段):程序代码所在段
SS(栈段):栈区所在段
DS(数据段):全局静态数据区所在段
其他3个段寄存器ES、GS和FS可指向任意数据段
一个逻辑地址由两个部分组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:

CS寄存器中的RPL字段表示CPU的当前特权级(Current Privilege Level,CPL)
TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)
RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置
索引号是“段描述符(segment descriptor)”的索引,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,每一个段描述符由8个字节组成。如图所示

逻辑地址向线性地址转换:
被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址。

7.3 Hello的线性地址到物理地址的变换-页式管理
Linux下,虚拟地址到物理地址的转化与翻译是依靠页式管理来实现的,虚拟内存作为内存管理的工具。概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组. 磁盘上数组的内容被缓存在物理内存中(DRAM cache)。这些内存块被称为页 (每个页面的大小为P = 2p字节)。

分页机制的作用就是通过将虚拟和物理内存分页,并且通过MMU建立起相应的映射关系,可以充分利用内存资源,便于管理。一般来说一个页面的标准大小是4KB,有时可以达到 4 MB。而且虚拟页面作为磁盘内容的缓存,有以下的特点:DRAM缓存为全相联,任何虚拟页都可以放置在任何物理页中需要一个更大的映射函数——不同于硬件对SRAM缓存更复杂精密的替换算法太复杂且无限制以致无法在硬件上实现DRAM缓存总是使用写回,而不是直写。
页表:
实现从虚拟页到物理页的映射,依靠的是页表,页表就是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址。这个页表是常驻与主存中的,给定一个虚拟地址通过对页表的查询,有可能得到相对应的物理地址。

地址翻译:
从虚拟地址转化到物理地址的过程叫做翻译。
以32位的虚拟地址为例。

32位的虚拟地址分成VPN+VPO,VPN是虚拟页号,这个值用来查询页表,VPO表示页内偏移。一个页面大小是4KB,所以VPO需要20位,VPN需要32-20=12位。CPU中的一个控制寄存器PTBR(页表基址寄存器)指向当前页表,MMU通过VPN来选择PTE(Page Table Entry),PTE(假设有效)中存放的即物理页号(PPN)。VPO和PPO是相同的。所以通过MMU,我们得到了线性地址相应的物理地址。如果我们找到的PTE中有效位为0,MMU触发一次异常,调用缺页异常处理程序,程序更新PTE,控制交回原进程,再次执行触发缺页的指令。这就是地址翻译的一个最基本的过程,之后的快表(TLB),多级页表都是在这个原理上对效率进行提高。

页面的命中与缺页
7.4 TLB与四级页表支持下的VA到PA的变换
多级页表:
如果在计算机内部页表是将所有的项都全部表示出来并且都存在主存中,那么会出现很多问题。
假设:4KB (212) 页面, 48位地址空间, 8字节 PTE
问题:将需要一个大小为 512 GB 的页表!这512GB的页表如果存放在主存中,这笔开销是十分巨大的。但是事实是由于程序良好的局部性和程序的每一个段并不是连续的,如下图所示,中间会有大量的页表的映射是用不上的,即每次我们访问的页面大概率只有几个,有没有什么办法可以加速这个过程呢?为了解决这个问题,我们可以采用页表分级的策略减少常驻内存的页表的开销,依照多级cacahe的原理,将页表进行一级一级缓存。

下图是k级页表的地址翻译方式:

TLB快表机制:
每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE,以便得到PA(物理地址)。最糟糕的情况,这会要求从内存多取一次数据,代价是几十个到几百个周期,如果对于页表的查找也采取缓存的方式,可能一次就命中这时会大大减小对页表的访问时间。
TLB是一个小的,虚拟地址缓存,其中每一行都存放着一个由单个PTE组成的块。TLB通常具有高度的相联度。仍然是利用虚拟地址的VPN项,将VPN分成TLBI和TLBT两部分。如果给定一个有2t个组的TLB,那么VPN的最低t位即作为TLB的index组索引。剩余为作为tag位,找到组中对应行。

           图 7.4.3

TLB与四级页表支持下的VA到PA的变换:
下面给出结合TLB与多级页表支持下的地址翻译的过程,我们以Corei7下的地址翻译——一些简化的示意来进行分析:

上图描述了corei7的内存系统的结构,包括三级cache,四级页表以及二级TLB翻译后备缓冲器。在下图中我们可以很清晰地看出一个虚拟地址是如何经过地址翻译得到对应的实际物理地址,然后访问数据的。
首先一个虚拟地址被分割成几个部分VPN与VPO,与上面所讲的地址翻译内容重复。这里我们更关注的是TLB以及多级页表机制。VPN不是直接当做索引去搜索页表,而是分割成4个部分,对应于四个页表,只有一级页表时常住在内存中,其余的依靠上级的页表得到下一级也表的首地址,在通过VPN的对应位,在这个第k级页表中搜索内容,再结合TLB找到对应的页表项就可以翻译得到PPA,这个就是物理地址的前40位,VPO=PPO,现在利用cache的知识就可以对物理地址进行一级一级的分析与访问了。

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

多级Cache访问策略:
当我们将一个虚拟地址翻译成了一个物理地址之后,下面要做的就是找到这个物理地址的数据并取出给CPU。如下图所示,一个物理地址可以分成两部分,PPO与PPN。由于内存的组织方式采用多级cache的方式,PPO还可以继续分割成CO与CI,CO是块偏移,即得到一个cache的一个命中行之后从哪个字节开始取出数据,CI作为组索引,PPN作为tag标记串。

因为L1L2L3这几级cache都是通过S组,E行,B位这种方式组织,先根据CI,CO找到对应的行,如果本级cache不命中找到会从更下一级cache中找到合适的行,通过某些替换策略进行替换与对应的牺牲,这样一级一级的访问极大的提高了对内存访问的效率,找到这个行之后根据PPO就可以找到所需要的数据,传给CPU了,同时Cache的读写命中\不命中都有对应的处理流程。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的页面标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。

7.7 hello进程execve时的内存映射
在当前进程中的程序执行了execve(”a.out”,NULL, NULL)调用时,execve函数在当前程序中加载并运行包含在可执行文件a.out中的程序,用a.out代替了当前程序。加载并运行a.out主要分为一下几个步骤:
1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构;
2.映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,映射到匿名文件,初始长度为零;
3.映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内;
4.设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。

7.8 缺页故障与缺页中断处理
缺页故障:
进程线性地址空间里的页面不必常驻内存,在执行一条指令时,如果发现他要访问的页没有在内存中(即存在位为0),那么停止该指令的执行,并产生一个页不存在的异常,对应的故障处理程序可通过从外存加载该页的方法来排除故障,之后,原先引起的异常的指令就可以继续执行,而不再产生异常。
会出现缺页异常的情况:
1.线性地址不在虚拟地址空间中
2.线性地址在虚拟地址空间中,但没有访问权限
3.没有与物理地址建立映射关系fork等系统调用时并没有映射物理页,写数据->缺页异常->写时拷贝
4.映射关系建立了,但在交换分区中,页面访问权限不足

缺页中断处理:
一个较为简单的表述为:确定缺页是由于对合法虚拟地址进行合法的操作造成之后,系统选择一个牺牲页面,如果这个牺牲页面被修改过,将其交换出去,换入新页面并更新页表,当缺页处理程序返回后,CPU重启引起缺页指令。
事实上缺页中断的处理具体复杂得多,见下图:

7.9动态存储分配管理
动态内存分配器的基本原理:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。不同系统有微小的差别但是一般来说堆存在于未初始化的数据后面并想高地址生长。对于每个进程,内核负责维护一个变量叫brk,它指向堆的顶部。分配器将堆视为一组不同大小的块的集合来维护。每个块都是一个连续的虚拟内存片有两种状态(已分配或空闲)。顾名思义,已分配的块已经被进程所使用,而空闲块可以用来分配,这时他就变成了一个已分配块,而已分配的块只有在被释放才能重新在使用。

根据分配器的风格来分类可以分为显式分配器与隐式分配器:
显式分配器:
要求应用显式地释放任何已分配的块。例如C程序通过调用malloc函数来分配一个块,通过调用free函数来释放一个块。必须用户来主动来调用某些函数来进行内存的申请与释放。注意:malloc不初始化它返回的内存,那些需要已经初始化的动态内存的应用程序可以使用calloc,它将分配的内存初始化为0。二进制零覆盖牺牲页面是发生在CPU第一次引用这样一个区域内的虚拟页面的时候的。
隐式分配器:
这时需要分配器检测一个已分配块何时不再被程序所使用,那么久释放这个块,也叫做垃圾收集器,例如,诸如Lisp、ML、以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
碎片:
造成堆利用效率低的原因是碎片现象,碎片分为两种:
内部碎片:当一个已分配块比有效载荷大的,或由对齐要求产生
外部碎片:空闲内存合起来足够满足分配请求,但是处于不连续的内存片中

动态内存分配器首先需要实现的问题有:
1.空闲块如何组织
2.如何选择一个合适的空闲块来放置一个新分配的块
3.在空闲块中放置了一块已分配块之后如何处理空闲块的余下的位置呢
4.如何将刚刚释放的块与相邻的块进行合并
5.当前使用的堆的空间不够需要再分配
同时分配器在工作时有着严格的要求:
1.分配器必须可以处理任意请求序列,对于用户来说执行的malloc与free的数量未必对等,只要释放的块当前是已被分配的有应该响应。
2.为了提高分配的效率,分配器对于申请请求必须迅速响应,不能重新排列或者缓冲请求。
3.分配器必须对齐块,使得他们可以保存任何的数据类型。
4.分配器只能操作修改空闲块对于已分配的块,一旦一个块被分配,不允许改变大小及一些块的固有信息,即使块中有剩余也不能压缩。
分配器的设计可以有多种方式针对空闲块的组织方法有以下三种:
a.隐式空闲链表(implicit free list)
隐含链表方式即在每一块空闲或被分配的内存块中使用一个字的空间来保存此块大小信息和标记是否被占用。根据内存块大小信息可以索引到下一个内存块,这样就形成了一个隐含链表,通过查表进行内存分配。优点是简单,缺点就是慢,需要遍历所有。

b.显式空闲链表(explicit free list)
显示空闲链表的方法,和隐含链表方式相似,唯一不同就是在空闲内存块中增加两个指针,指向前后的空闲内存块。相比显示链表,就是分配时只需要顺序遍历空闲块,虽然在空间上的开销有所增大,但是放置以及合并操作所用到的时间会有所减少。

c.分离空闲链表(segregated free list)
分配器维护多个空闲链表,其中每个链表中的块大小大致相等,即讲这些空闲块分成一些等价类,先按照大小进行索引找到相应的空闲链表再,在链表内部搜索合适的块,这样相比于显式空闲链表时间效率更高。
针对查找空闲块的三个方法:
a.首次适应(first fit)
b.最佳适配(best fit)
c.下一次适配(next fit)
对于不同的空闲块组织方式,巧妙地设置头部和脚部,用指针可以有效地提升空闲块合并的效率
7.10本章小结
本章介绍了本章首先讲述了虚拟地址、线性地址、物理地址的概念与区别,从现代linux的内存系统出发,重点介绍了虚拟内存与物理内存之间的翻译转换通过虚拟地址到物理地址的转换,进一步加深了对虚拟地址空间的理解运作及其强大作用,虚拟内存的意义以及作用都是十分重大的,进一步介绍了一些从虚拟地址转换为物理地址的一些加速的方法(TLB,多级页表),和物理地址如何寻到数据的过程(cache的访问机制)。同时介绍了虚拟内存机制下的fork以及execve如何运作的,使进程的看似私有的地址空间变成了现实。接着,介绍了了动态内存管理时,如何组织空闲区域、空间的申请、分割、合并、回收等具体过程,对动态分配函数malloc系列函数有了更深的认识。
(第7章 2分)

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个字节的序列:B0,B1,B2…….所有的I/O设备(如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对文件的读写操作。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为UnixI/O,这使得所有的输入输出都能以一种统一的方式且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix I/O接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。
头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_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.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O接口函数:
1.进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件:
int open(char *filename, int flags, mode_t mode);

open 函数将filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,通过一些宏值的设置可以修改打开文件的方式,mode 参数指定了新文件的访问权限位。
返回:若成功则为新文件描述符,若出错为-1。

2.进程通过调用close 函数关闭一个打开的文件。
int close(int fd);
返回:若成功则为0, 若出错则为-1。
3.应用程序是通过分别调用read 和write 函数来执行输入和输出的。
ssize_t read(int fd, void *buf, size_t n);
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。
ssize_t write(int fd, const void *buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。展示了一个程序使用read 和write 调用一次一个字节地从标准输入复制到标准输出。
返回:若成功则为写的字节数,若出错则为-1。

8.3 printf的实现分析
实现:从vsprintf生成显示信息,到write系统函数,到陷阱产生系统调用 int 0x80或syscall.
首先我们通过查找得到printf的c语言代码,如下图所示:

在形参列表里有这么一个token:… 这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。很显然,我们需要一种方法,来让函数体可以知道具体调用时参数的个数。
下面我们一步一步进行分析:
va_list arg = (va_list)((char*)(&fmt) + 4);
va_list的定义:
typedef char va_list
这说明它是一个字符指针。
其中的: (char
)(&fmt) + 4表示的是…中的第一个参数。这个的理解和栈息息相关,由于参数的压栈方式是最后一个参数最先压栈,因此离fmt的地址也就是栈的最上方的指针最近的就是第一个参数。
下面printf函数又调用了另一个函数i = vsprintf(buf, fmt, arg);

由此可知,vsprint的功能为把指定的匹配的参数格式化,并返回字符串的长度。观察到printf中后面的一句:write(buf, i);我们可以分析出这是一个IO函数执行写操作,把buf中的i个元素的值写到终端。vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
下面我们反汇编追踪一下write:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里的int表示要执行系统调用,执行某项特定的操作。
再来看看sys_call的实现:
sys_call:
call save //保存中断前进程的状态
push dword [p_proc_ready]
sti
push ecx //ecx中是要打印出的元素个数
push ebx //ebx中的是要打印的buf字符数组中的第一个元素
call [sys_call_table + eax * 4] //不断的打印出字符,直到遇到:’\0’
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
//[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
cli
ret
Syscall将字符串中的字节从寄存器复制到显卡的显存中,以ASCII字符形式。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
首先我们看一下getchar()函数的具体实现代码,主要通过调用read函数 以及一些控制的代码来实现。
#include “sys/syscall.h”
#include <stdio.h>
int getchar(void)
{
char c;
return (read(0,&c,1)==1)?(unsigned char)c:EOF
//EOF定义在stdio.h文件中
}
异步异常——键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中。
getchar 函数落实到底层调用了系统函数 read,通过系统调用 read 读取存储在键盘缓冲区中的 ASCII 码直到读到回车符然后返回整个字串,getchar 进行封装,大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
本章主要讲述了Linux的IO设备管理方法,Unix I/O接口及其函数,以及printf函数实现的分析和getchar函数的实现。通过对系统函数的分析,我们对系统I/O函数和Linux中将设备统一为文件式来管理的方式有了进一步的认识。通过对一些库函数(输入输出)的分析,我们看到了printf函数的底层实现的分析过程中将看似简单的printf函数进行一些深入分析,发现就像我们的大作业hello一样,我们经常使用的或许我们注意到但是其背后蕴含的底层硬件软件的结合是十分庞大的,正是有了这些IO函数使得计算机能与用户良好的交互,也让我们进一步认识了底层的工作过程。

(第8章1分)

结论
本次大作业通过分析hello简单却不平凡的一生,对整个CSAPP课程也进行了总结,也逐渐熟悉了现代计算机的工作原理。整个大作业设计十分巧妙,知识点覆盖面也十分广泛,以hello为线索穿起了整个这门课程的一个回顾,从数据的运算处理,到汇编、链接、信号异常的处理、内存管理、再到后面的I/O管理。下面对整个实验的各个章节进行一个总结。
程序的编写:一个c语言的程序我们使用高级语言进行编写,当我们刚刚接触高级语言的时候第一个程序就是hello,当时懵懂的我们惊叹于神奇的几行代码可以将文字转换到电脑屏幕上,后来随着我们不断的学习我们了解到各种各样的数据结构,控制结构,指针、链表,他们的功能十分强大给我们带来了极大的便利但是我们却还是没有明白,如何使一个c语言所写的程序到机器内部去执行的?机器可以理解我们写的代码吗?计算机系统这门课程让我们对这个过程有了一个相当的理解。
预处理:根据预处理命令得到了修改后的hello.i文本,并且对hello.i程序进行了预处理结果分析与理解,理解了预处理器读取系统头文件中内容,并把它插入程序文本中的过程。
编译:编译的作用主要就是将高级语言转换为更低一级的汇编语言,.i->.o这种汇编语言不是机器语言(二进制),但是可以将不同的高级语言(c c++ java等形式完全不同的)根据编译器的不同类型转换成一些通用的汇编代码,它更加贴近于机器的思维与指令,同时编译器还可以检查高级语言代码的语法正确性,是我们编写代码到执行这个过程中最为重要的一环,
汇编:将hello.s转化为二进制的机器代码,生成hello.o的可重定位目标程序。
链接:对hello.o中引用的外部函数、全局变量等进行符号解析,并重定位为可执行文件hello。
加载与运行:在终端输入./hello 120L022419 lmh 1以执行程序。通过fork创建子程序。通过execve加载器载入,建立虚拟内存映射,设置当前进程的上下文中的程序计数器,使之指向程序入口处。此时这个程序有了自己的生命(独立的物理内存空间)。
访存与缺页:CPU上的内存管理单元MMU根据页表将CPU生成的虚拟地址翻译成物理地址,将相应的页面调度。同时在访问代码、数据、堆栈、时会产生缺页现象,经过处理程序的处理回复运行。
接收信号与处理:中途接受ctrl+z挂起,ctrl+c终止;
动态内存申请:在程序中若需要即时地分配一些空间来存储数据需要用到动态内存分配器,其中分配器如何组织空闲区域、空间的申请、分割、合并、回收等具体过程,对动态分配函数malloc系列函数有了更深的认识。
系统的I/O管理:IO就是计算机与外界设备进行交互的基础。一个Linux文件就是一个m个字节的序列,所有的I/O设备(如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对文件的读写操作。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为UnixI/O,这使得所有的输入输出都能以一种统一的方式且一致的方式来执行。有了IO我们可以向计算机内部通过鼠标键盘写入内容,我们可以听到各种音频,借助显卡我们可以看到各种各样的视觉特效,有了IO计算机真正的活了起来。
一些理解与体会:
历时一周这次大作业最终认真地完成了,首先对我来说这次大作业是一次复习与查缺补漏,由于作业设计到的内容十分全面,在完成的时候需要翻阅相关的书籍与资料,最重要的是需要仔细思考与理解书上的内容,在之前课堂上听完没有理解的地方在这次作业中都有不同的思考。
其次在这个过程中有了很多新的收获。原以为很简单的事情实际操作起来并不是那么简单,对于一些概念只知道大概,而通过这次大作业,使得我更加清晰地知道了计算机的运行原理,对一些操作有了更深层次的理解。
最后要感谢CSAPP这门课与老师的辛勤努力与付出,通过这门课真的增长了许多有关计算机的一些原理性的知识,虽然在短短一学期之内无法像课本题目那样深入理解计算机系统,但是这样更像是一个导论课的一个课程给了我们这样接触计算机不久的学生一个蓝图意义的指导。最重要的是八次实验,也算得上是这门课的核心。通过八次不同的实验,我们将课上的知识与实践结合,缓冲溢出攻击的试验我们切实地成为了一个“黑客”,拆炸弹在不失趣味性的同时深入探讨汇编,shell独立编写为我们带来了极大的成就感等等……
希望未来的一天,当我们在工作中或者其他场合遇到类似的问题,能够做出一名合格程序员的举动。
(结论0分,缺失 -1分,根据内容酌情加分)

附件
中间结果文件 文件作用
hello.c 源代码
hello.i 预处理得到的文件
ASCII码的中间文件
hello.s ASCII汇编语言文件
hello.o as得到可重定位目标文件
hello.asm 反汇编得到的文本文件
hello.elf hello.o的elf文件
hello ld得到可执行目标文件
hello_elf hello的elf文件
hello_asm hello的反汇编文件

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

参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 兰德尔E.布莱恩特 大卫R.奥哈拉伦 深入理解计算机系统(第三版)
[2] https://blog.csdn.net/u012491514/article/details/24590467
乐于其中. CSDN. 编译器工作流程详解. 2014:04-27.
[3] https://www.cnblogs.com/pianist/p/3315801.html printf函数的深入剖析
[4] https://www.cnblogs.com/MaAce/p/7999795.html Linux 链接详解----动态链接库
[5] https://blog.csdn.net/rikeyone/article/details/84778466 深入浅出内存管理–页表的创建
[6] https://blog.csdn.net/dan15188387481/article/details/49536317 操作系统中的虚拟内存详解
[7] https://blog.csdn.net/shijunwang/article/details/80217255 《深入理解计算机系统》笔记(三)链接知识【附图】
[8] http://blog.chinaunix.net/uid-24669930-id-4294759.html 动态链接之重定位

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
提供的源码资源涵盖了安卓应用、小程序、Python应用和Java应用等多个领域,每个领域都包含了丰富的实例和项目。这些源码都是基于各自平台的最新技术和标准编写,确保了在对应环境下能够无缝运行。同时,源码中配备了详细的注释和文档,帮助用户快速理解代码结构和实现逻辑。 适用人群: 这些源码资源特别适合大学生群体。无论你是计算机相关专业的学生,还是对其他领域编程感兴趣的学生,这些资源都能为你提供宝贵的学习和实践机会。通过学习和运行这些源码,你可以掌握各平台开发的基础知识,提升编程能力和项目实战经验。 使用场景及目标: 在学习阶段,你可以利用这些源码资源进行课程实践、课外项目或毕业设计。通过分析和运行源码,你将深入了解各平台开发的技术细节和最佳实践,逐步培养起自己的项目开发和问题解决能力。此外,在求职或创业过程中,具备跨平台开发能力的大学生将更具竞争力。 其他说明: 为了确保源码资源的可运行性和易用性,特别注意了以下几点:首先,每份源码都提供了详细的运行环境和依赖说明,确保用户能够轻松搭建起开发环境;其次,源码中的注释和文档都非常完善,方便用户快速上手和理解代码;最后,我会定期更新这些源码资源,以适应各平台技术的最新发展和市场需求。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值