程序人生Hello‘s P2P
目录
- 第一章 概述
- 第二章 预处理
- 第三章 编译
- 第四章 汇编
- 第五章 链接
- 第六章 Hello 的进程管理
- 第七章 Hello 的存储管理
- 第八章 Hello 的 IO管理
第一章 概述
Hello简介
大家好,我是hello,下面由我来自我介绍一下我的一生,我的诞生过程十分曲折,就像十月怀胎一样,它有个名字叫做P2P(From Program to Process)。我的一生呢,全部奉献给伟大的计算机了,OS为我安排好了计划,完成我的使命——向所有程序员问好(Hello!)。说到这里我的任务完成了,我光荣的一生结束了,不过我还有很多兄弟姐妹啦,大家都有自己特定的工作,我们把我们奉献给计算机的一生叫做O2O。
P2P过程:首先程序的缔造者为我赋予了基本的使命,他写下了一份叫做hello.c的代码,可是计算机爸爸不认识我嘤嘤嘤,所有我要一点点变化,一点点长大,我经历了预处理编译汇编链接四个步骤终于编程了一个可执行的二进制文件,我好开心啊,我终于可以在计算机上面自由驰骋啦。听说电脑的主人想知道我长什么样子(害羞ing),他在shell中写下了./hello,父进程为我创造了子进程,他说加油小hello!相信你一定是最棒哒! O2O过程:之后shell调用了execve加载程序的数据并让我在后台等待(安排虚拟内存),他说让我们安静的等待”上场”,我的内心十分激动,希望能够大放异彩。终于轮到我啦,我变成了开始运行的进程,shell让我登台表演(为我分配了物理内存),在舞台上我表演了Hello向你问好,你微微一下,要知道那可是你的第一个程序啊,我好开心啊!不过这时候我的表演结束啦,父进程回收我叫我下场,内核删除了我,我挥一挥衣袖不带走一片云彩。程序变为0。1.2 环境与工具
硬件环境:Intel Core i7-8750HQ, 16GRAM, 512GSSD
软件信息:VMware WorkStation 14 Ubuntu 18.04.1 LTS 使用工具:edb , gdb , Objdump , readelf, gcc, ld 1.3 中间结果1.3 中间结果
hello.c:源程序文件
hello.i:预处理后的文本文件 hello.s:编译后的汇编文件 hello.o:汇编后的可重定位文件 hello:链接后的可执行文件1.4 本章小结
本节用生动形象的语言介绍了hello的P2P过程和O2O过程,介绍了实验的环境和使用的工具。
第二章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念:
预处理又称预编译,(对于c/c++来说)预处理指的是在程序编译之前,根据以字符#开头的命令(即头文件/define/ifdef之类的),修改原始的c程序,正如我们hello.c文件中的三个头文件,#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h中的内容,并把它插入到程序之中。经过预处理我们会得到一个.i结尾的预处理文件。2.1.2预处理的作用:
这样做的目的是方便编译器在对程序进行翻译的时候更加方便。2.2在Ubuntu下预处理的命令
- 在Ubuntu下的预处理命令为 gcc -E -o xxx.i xxx.c
- 下图为我预处理命令的截图
预处理文件的截图:
2.3 Hello的预处理结果解析
我们可以发现,经过预处理后,前面3个#include消失了,取而代之的是一长串以#开头的字符串,这个应该是我们原来的三个系统头文件,虽然长度变长了许多,但是总的来说并不影响程序的可读性
2.4 本章小结
本章节简单介绍了c语言在编译前的预处理过程,简单介绍了预处理过程的概念和作用,对预处理过程进行演示并解析预处理的过程
第三章 编译
3.1 编译的概念与作用
- 概念: 编译器把文本文件(xxx.i)翻译成含有汇编语言的文本文件(xxx.s) 编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
作用:
把高级语言翻译成机器更接近机器语言的汇编语言。3.2 在Ubuntu下编译的命令
- gcc -S -o xxx.s xxx.i
- 下图为我预处理命令的截图:
我的hello.s文件
3.3 Hello的编译结果解析
3.3.1 全局变量的解析
在hello.c之中存在初始化的一个全局变量sleepsecs,这个变量申明的是整型的变量类型但是程序里面却给他赋初值为2.5根据经验我们知道他的值应该被处理成为2,小数位丢失。我们现在看看这个已经赋初值的全局变量在.s是以什么姿态出现的吧。
我们看到全局变量在.s文件中是以一个符号表形式存在的,符号表中每一行的意义如下:
- .file 表示这个变量是在hello.c文件中申明的
- .text 是存储已编译的机器代码,在这里没有
- .globl 表示这个变量是一个全局变量
- .data 用于保存已经初始化的静态或者全局C变量,sleepsecs被存储在它里面
- .type 表示这个变量的类型是object(对象)
- .size 表示占用四个字节
- .long 在32位之中就是一个整形变量存储的值是2,与我们之前的分析一致
.rodata 表示在rodata节中有sleepsecs,它描述的是一个偏移量,表示的是这个变量在寄存器中的具体位置
3.3.2局部变量的解析
局部变量是在调用过程中在栈中声明的,我们看看在.s文件中什么地方他被声明的:
在.L2位置局部变量i被存储在 -4(%rbp)之中,并跳转到.L3位置开始一个循环
3.3.3 加减运算
加法:加法的实现利用的是add指令。 add a b 的意思是 b = b+a,即为加法运算。其中addb,addw,addl,addq分别为字节,字,双字和四字的加法。
减法:加法的实现利用的是sub指令。 sub a b 的意思是 b = b-a,即为减法运算。 其中subb,subw,subl,subq分别为字节,字,双字和四字的减法。3.3.4 赋值语句
赋值语句运用的是mov实现的,比如:mov a b就是把a的值赋给b ,如果a或者b是寄存器或地址,则就是取寄存器或者地址存储里面或者读取里面对应的值,a除了是寄存器还可以是立即数。
3.3.5 比较
比较通过cmp来实现,指令根据两个操作数之间的差值来设置条件码。如果两个操作数相等,则标记条件码ZF=1,表示两个数是相等的。如果第一个操作数比第二个小,则设置条件码SF=1,表示比较结果为负数,计算机会根据这些条件码来决定跳转。我们的程序中有如下代码来表示i<10这一条件:
我们在之前分析过-4(%rbp)里面存储的是局部变量i,这表示i与9比较当9-i小于等于0就跳转,这与我们的c代码相符.
3.3.6 函数的一些操作
- 函数传递参数 函数通过寄存器来传递参数,机器提供六个寄存器来传递参数,第一个参数放在%rdi,第二个放在%rsi,第三个%rdx,第四个%rcx,第五个%r8,第六个%r9,当函数的参数小于6个就存储在寄存器里面。如果需要传递的变量大于6个的时候就需要通过栈来传递变量。我们来看一下hello.s中的参数传递。
Main函数里面有两个参数int argc 和 char *argv[] ,对应的汇编代码里面他们分别被保存在%rdi和%rsi之中。
函数返回值
函数返回值是被一个寄存器%rax保存的。函数调用
如果我们进入了if语句,我们这时需要调用两个函数,函数调用的第一步就是把函数需要的参数放入相应的寄存器中,由前面解析我们已经知道,函数的第一个参数存放在%rdi中,所以这里首先把.LC0中数据放入edi,由图 知里面的数据就是我们的输出然后再call puts函数。call Q 指令会把地址A压入栈中,并将PC设置位Q的起始地址,压入栈中的地址称位返回地址,是call指令后面的那条指令的地址。同理可得exit(0)的调用过程。
3.3.7 for循环
汇编语言实现循环有两种方法分别是条件传送和条件跳转,但是一般的汇编器在优化程度不高的情况下会选择跳转跳转的方法,在这里我想用hello.s中的汇编代码来分析汇编语言实现条件跳转的方法。
上图c语言代码,这个循环要执行十次,i这个变量存储在-4(%rbp)之中,对应的.L3部分,有一个判断条件,如果i<=9就执行跳转,跳转到代码的主体部分,也就是上面的.L4,每一次执行完.L4都要让i+1。
3.4 本章小结
本节主要介绍的是编译器通过编译由.i文件生成汇编语言的.s文件的过程,并通过解析全局变量,局部变量在.s文件中的表示方法,以及各类c语言的基本语句的汇编表示,让读者更加理解高级语言的底层表示方法。汇编语言和高级语言不同,即使是高级语言中一个简单的条件语句或者循环语句在汇编语言中都需要涉及到更多步骤来实现。学习汇编语言与编译,使我们能够真正的理解计算机底层的一些执行方法,即使可能以后我们不会去自己写汇编代码,仍然能为我们以后思考问题中提供帮助。
第四章 汇编
4.1 汇编的概念与作用
- 概念: 把汇编语言翻译成机器语言的过程称为汇编,并将这些指令打包成一种叫做可重定位目标程序,并将这个结果保留在(xxx.o)中。这里的xxx.o是二进制文件。汇编过程的作用是将汇编指令转换成一条条机器可以直接读取分析的机器指令。
作用:
通过汇编这个过程,把汇编代码转化成了计算机完全能够理解的机器代码,这个代码也是我们程序在计算机中表示。4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.3.1 ELF 头
ELF头首先以一个16字节的序列开始,这个序列描述了生成该文件的系统的 字的大小和字节顺序。剩下部分就如下图所示,列出了包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小(64字节),目标文件的类型(REL可重定位文件),机器类型(AMD X86-64),节头部表的文件偏移,以及节头部表中条目的大小和数量。
4.3.2节
在ELF后的都是节,下图也列出了节和它的一些基本信息,这里在写出每个节里应该存放的东西。
.text: 已编译的机器代码,类型为PROBITS,意思是程序数据,旗标为AX,下面也给出了解释,意思是分配内存且可执行.rela.text 一个.text节中位置的列表(下一节会重点解释)
.data: 这个里面是已初始化的全局变量和静态c变量,类型也为PROBITS,旗标WA意思是分配内存且可修改
.bss: 这里面放的是未初始化的全局变量和静态c变量,类型NOBITS,意思是暂时没有存储空间,说明这个节在开始是不占据实际的空间
。 .rodata: 只读数据,如printf中的格式串和switch中的跳转表,我们hello程序中的printf中的格式串就存放在这里。.comment: 这个节中包含了版本控制信息
.note.GNU_stack: 用来标记executable stack(可执行堆栈)
.eh_frame: This section contains information necessary for frame unwinding during exception handling.(这个节的意思我没有找到中文的解释)主要就是用来处理异常 .rela.eh_frame:.eh_frame的重定位信息 .symtab:,装载符号信息 .strtab: 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部的节名字。 .shstrtab:该区域包含节区名称4.3.3 符号表
每个可重定位目标模块都有一个符号表,它包含m的定义和引用的符号的信息。符号表是由汇编器构造,使用编译器输出到汇编语言.s文件中的符号。每个符号表是一个条目的数组,每个条目包含下面部分
然后我们可以看到我们hello程序的符号表
举个例子,我们通过下图可以看到对全局符号sleepsecs的定义,它是一个位于.data段偏移量位0(value值)处一个大小为4个字节的变量,全局符号main是一个位于.text段,段偏移为0,大小125字节的函数4.3.4重定位节
在rela.text里面有我们的重定位条目,这个条目能告诉链接器目标文件合并成可执行文件时如何修改引用。一个重定位条目包含以下信息
我们利用objdump能够得到我们hello文件的重定位条目,这里最后有我们的符号名称,说明这是哪个的重定位条目,偏移量是指被修改的引用的节偏移x
类型这里有两种,R_X86_64_32意思是重定位时使用一个32位的绝对地址的引用,通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。R_X86_64_PC32意思是重定位时使用一个32位PC相对地址的引用。一个pc相对地址就是据程序计数器的当前运行值的偏移量。objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。4.4 Hello.o的结果解析
4.4.1 机器代码
通过objdump -d -r hello.o 分析hello.o的反汇编代码,我们发现与第三章hello.s的第一个不同是在汇编代码的左侧多了好多的数字,这些数字代表的是机器语言的二进制代码,机器语言就是由一系列的二进制数组成的,一般包括操作码字段和字节码字段。在指令集中每个指令都有自己对应的机器代码。
4.4.2 跳转
第二个不同就是跳转的方式不同,在汇编代码中,代码由不同的段组成,每次跳转会跳到其他段的位置如下图所示
而机器代码为指令分配了地址,跳转是依据地址跳转的,如下图所示:
0x74在机器指令中代表跳转的意思,跳转的位置要根据PC(下一条指令的地址)与74下一位地址的数字决定,比如上图中PC的值为0x15,0x74下一个数字为0x16,所有我们可以推算出要跳转到的位置就是0x15+0x16=0x2b
4.4.3 保留地址以便重定向
在hello.o的反汇编代码中我们会看到很多函数调用的地方,这些函数来自静态库或者其他的文件,需要进行链接才能调用这些函数,所以与汇编代码不同,机器代码为这些函数留下了一些地址以便链接后重定向,如下图所示:
在0x1c位置callq调用了printf函数,在这里为puts留下了空间,根据我们上一节知道的,printf的格式串存放在.rodata,根据后面的.rodata_0x0查询重定位节为0x1d,在链接的时候会把这部分代码加入相应地址的位置。
4.5 本章小结
当我们从汇编代码变为了机器代码,程序就真正变成了计算机可以理解的程序,我们也知道了我们的程序真正在计算机中是以什么存储的。机器代码与汇编代码会根据cpu的指令集,产生一个对应,我们也能通过objdump这样的反汇编工具查看机器码对应的汇编码,不过这里对代码已经与我们.s里的汇编代码有了些不同,已经在汇编过程中我们的代码变成了ELF格式,代码被放在代码段,全局变量放在.data段,通过重定位条目得到每个符号不同偏移量,去不同的段找到我们想要的信息。
(第4章1分)第五章 链接
5.1 链接的概念与作用
- 概念: 链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。
作用:
把可重定位目标文件和命令行参数作为输入,产生一个完全链接的,可以加载运行的可执行目标文件。5.2 在Ubuntu下链接的命令
链接命令的截图:
5.3 可执行目标文件hello的格式
5.3.1 hello的ELF头:
节头数量由之前的13个变成了24个。
5.3.2 hello的节头
这个时候我们能够发现,这里比原来hello.o要多出很多来,这是因为我们计算机进行了动态链接,这里也把多出来的一些段做一些解释
interp段:动态链接器在操作系统中的位置不是由系统配置决定,也不是由环境参数指定,而是由 ELF 文件中的 .interp 段指定。该段里保存的是一个字符串,这个字符串就是可执行文件所需要的动态链接器的位置,常位于 /lib/ld-linux.so.2。(通常是软链接)
dynamic段:该段中保存了动态链接器所需要的基本信息,是一个结构数组,可以看做动态链接下 ELF 文件的“文件头”。存储了动态链接会用到的各个表的位置等信息。
dynsym段:该段与 “.symtab”段类似,但只保存了与动态链接相关的符号,很多时候,ELF文件同时拥有 .symtab 与 .synsym段,其中 .symtab 将包含 .synsym 中的符号。该符号表中记录了动态链接符号在动态符号字符串表中的偏移,与.symtab中记录对应。
dynstr段:该段是 .dynsym 段的辅助段,.dynstr 与 .dynsym 的关系,类比与 .symtab 与 .strtab 的关系
hash段:在动态链接下,需要在程序运行时查找符号,为了加快符号查找过程,增加了辅助的符号哈希表,功能与 .dynstr 类似
rel.dyn段:对数据引用的修正,其所修正的位置位于 “.got”以及数据段(类似重定位段 “rel.data”)
rel.plt段:对函数引用的修正,其所修正的位置位于 “.got.plt
分析hello的ELF格式,用readelf等列出其各段的基本信息
5.4 hello的虚拟地址空间
使用edb打开hello程序,hello的虚拟地址从0x400000开始,在下面可以查看内存,具体情况如下图所示:
我们现在用段信息和edb的虚拟内存空间进行比较,比如.interp段的段偏移为0x200,虚拟内存开始的地方是0x4000000,所以从0x4000200开始记录的就是这个段内的信息我们来看看它都保存了哪些信息呢?
根据上一节的分析我们知道了,这个段内保存的是动态链接库的位置,在后面edb也翻译出了这个语句对应的ASCII码。
再比如我们知道,text保存的是机器指令,我们在段信息里面查找得知text的偏移量是0x500,于是我们用edb可以看到在0x4000500的位置保存了一系列二进制数,把这些数和objdump的机器代码比较可以完全对应上。
5.5 链接的重定位过程分析
5.5.1 反汇编hello和hello.o
使用objdump -d -r hello > hello获取hello的反汇编代码并与hello.o的反汇编代码进行比较:
通过比较可知hello的反汇编代码比hello.o的多出来init和plt这两个节,plt节是在链接动态库的时候使用的,init节是为了程序初始化需要执行的代码。
5.5.2 函数变多
通过链接,很多外部且被当前程序调用的函数被链接进入可执行文件以便程序运行时的调用,比如hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入。
5.5.3 重定位如何实现?
重定位的实现依靠.rodata这个段,这个段保留重定位所需的信息,叫做重定位条目,链接器解析重定条目时发现两个类型为R_X86_64_PC32的对.rodata的重定位(printf中的两个字符串),.rodata与.text节之间的相对距离确定,因此链接器直接修改call之后的值为目标地址与下一条指令的地址之差,指向相应的字符串。这一部分教材上有详细介绍。
5.6 hello的执行流程
名称 | 地址 |
---|---|
ld-2.27.so! dl_start | 0x7ffee5aca680 |
ld-2.27.so! dl_init | 0x7f9f48629630 |
hello!_start | 0x0x400500 |
ld-2.27.so!_libc_start_main | 0x7f9f48249ab0 |
libc-2.27.so! cxa_atexit | 0x7f4523fd6af7 |
libc-2.27.so! lll_look_wait_private | 0x7f4523ff8471 |
libc-2.27.so!_new_exitfn | 0x7f87ff534220 |
hello!_libc_csu_init | 0x7f87ff512b26 |
libc-2.27.so!_setjmp | 0x7f87ff512b4a |
libc-2.27.so!_sigsetjmp | 0x7f87ff52fc12 |
libc-2.27.so!__sigjmp_save | 0x7f87ff52fbc3 |
hello_main | 0x400532 |
hello!puts@plt | 0x4004b0 |
hello!exit@plt | 0x4004e0 |
hello!printf@plt | 0x400587 |
hello!sleep@plt | 0x400594 |
hello!getchar@plt | 0x4005a3 |
dl_runtime_resolve | 0x7f169ad84750 |
libc-2.27.so!exit | 0x7fce8c889128 |
5.7 Hello的动态链接分析
5.7.1 概念
GOT表:
概念:每一个外部定义的符号在全局偏移表(Global offset Table)中有相应的条目,GOT位于ELF的数据段中,叫做GOT段。 作用:把位置无关的地址计算重定位到一个绝对地址。程序首次调用某个库函数时,运行时连接编辑器(rtld)找到相应的符号,并将它重定位到GOT之后每次调用这个函数都会将控制权直接转向那个位置,而不再调用rtld。PLT表:
过程连接表(Procedure Linkage Table),一个PLT条目对应一个GOT条目 当main()函数开始,会请求plt中这个函数的对应GOT地址,如果第一次调用那么GOT会重定位到plt,并向栈中压入一个偏移,程序的执行回到_init()函数,rtld得以调用就可以定位printf的符号地址,第二次运行程序再次调用这个函数时程序跳入plt,对应的GOT入口点就是真实的函数入口地址。 动态连接器并不会把动态库函数在编译的时候就包含到ELF文件中,仅仅是在这个ELF被加载的时候,才会把那些动态函库数代码加载进来,之前系统只会在ELF文件中的GOT中保留一个调用地址.
5.7.2 分析
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。
在dl_init调用之后, 0x601008和0x601010处的两个8B数据分别发生改变为0x7fd9 d3925170和0x7fd9 d3713680,其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址, GOT[2]指向动态链接器ld-linux.so运行时地址。
在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问跳转时GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数。
因为在PLT中使用的jmp,所以执行完目标函数之后的返回地址为最近call指令下一条指令地址,即在main中的调用完成地址。
5.8 本章小结
本章主要介绍了链接器如何将hello.o可重定向文件与动态库函数链接起来,其中用到了rodata中的重定位条目,最终分析了程序如何实现的动态库链接。
第六章 hello进程管理
6.1 进程的概念与作用
进程的经典定义是一个执行中程序的实例,系统的每个程序都运行在某个进程的上下文。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存里的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。
通过进程,我们会得到一种假象,好像我们的程序是当前唯一运行的程序,我们的程序独占处理器和内存,我们程序的代码和数据好像是系统内存中唯一的对象。6.2 简述壳Shell-bash的作用与处理流程
shell俗称壳,它是指UNIX系统下的一个命令解析器;主要用于用户和系统的交互。UNIX系统上有很多种Shell。首个shell,即Bourne Shell,于1978年在V7(AT&T的第7版)UNIX上推出。后来,又演变出C shell、bash等不同版本的shell。
bash,全称为Bourne-Again Shell。它是一个为GNU项目编写的Unix shell。bash脚本功能非常强大,尤其是在处理自动循环或大的任务方面可节省大量的时间。bash是许多Linux平台的内定Shell。 处理流程: 1 新建文件test.sh $ touch test.sh 2 添加可执行权限 $ chmod +x test.sh 3 编辑test.sh,test.sh内容如下: !/bin/bash echo “hello bash” exit 0 说明: !/bin/bash : 它是bash文件声明语句,表示是以/bin/bash程序执行该文件。它必须写在文件的第一行 echo “hello bash” : 表示在终端输出“hello bash” exit 0 : 表示返回0。在bash中,0表示执行成功,其他表示失败。 4 执行bash脚本 $ ./bash 在终端输出“bash hello”6.3 Hello的fork进程创建过程
我们在shell上输入./hello,这个不是一个内置的shell命令,所以shell会认为hello是一个可执行目标文件,通过条用某个驻留在存储器中被称为加载器的操作系统代码来运行它。当shell运行一个程序时,父进程通过fork函数生成这个程序的进程。这个子进程几乎与父进程相同,子进程得到与父进程相同的虚拟地址空间(独立)的一个副本,包括代码,数据段,堆,共享库以及用户栈。唯一的不同是与父进程的PID不同。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序
execve函数加载并运行可执行文件filename(hello),且带参数列表argv和环境变量envp.当加载器运行时,它创建一个类似与图 6-2 的内存映像。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段,接下来,加载器跳转到程序的入口,_start函数的地址,这个函数是在系统目标文件ctrl.o中定义的,对所有的c程序都一样。_start函数调用系统启动函数,_libc_start_main,该函数定义在libc.so里,初始化环境,调用用户层的main函数,处理main函数返回值,并且在需要的时候返回给内核。6.5 Hello的进程执行
逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。 用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。 上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。 简单看hello sleep进程调度的过程:当调用sleep之前,如果hello程序不被抢占则顺序执行,假如发生被抢占的情况,则进行上下文切换,上下文切换是由内核中调度器完成的,当内核调度新的进程运行后,它就会抢占当前进程,并进行1)保存以前进程的上下文2)恢复新恢复进程被保存的上下文,3)将控制传递给这个新恢复的进程 ,来完成上下文切换。如上图所示,hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时(2.5secs)发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。
6.6 hello的异常与信号处理
6.6.1 乱按与回车
如果乱按过程中没有回车,这个时候只是把输入屏幕的字符串缓存起来,如果输入最后是回车,getchar把回车读入,并把回车前的字符串当作shell输入的命令
6.6.2Ctrl+C
如下图,如果在程序运行过程中输入Ctrl+C,会让内核发送一个SIGINT信号给到前台进程组中的每个进程,结果是终止前台进程,通过ps命令发现这时hello进程已经被回收。
6.6.3 Ctrl+Z
如下图,如果输入Ctrl+Z会发送一个SIGTSTP信号给前台进程组的每个进程,,结果是停止前台作业,即我们的hello程序
6.6.4 fg命令
fg 1 的意思是使第一个后台作业变为前台,第一个后台作业是我们的hello,所以输入fg 1 后hello程序又开始运行,并且是继续刚才的进程,输出剩下的7个字符串。
6.7本章小结
本章介绍了进程的概念和作用,描述了shell如何在用户和系统内核之间建起一个交互的桥梁。讲述了shell的基本操作以及各种内核信号和命令,还总结了shell是如何fork新建子进程、execve如何执行进程、hello进程如何在内核和前端中反复跳跃运行的。
第七章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:又称相对地址,是程序运行由CPU产生的与段相关的偏移地址部分。他是描述一个程序运行段的地址。
物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。他是在前端总线上传输的而且是唯一的。在hello程序中,他就表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。
线性地址:这个和虚拟地址是同一个东西,是经过段机制转化之后用于描述程序分页信息的地址。他是对程序运行区块的一个抽象映射。以hello做例子的话,他就是一个描述:“我这个hello程序应该在内存的哪些块上运行。”
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成,段标识符,段内偏移量。段标识符是一个16位长的字段组成,称为段选择符,其中前13位是一个索引号。后面三位包含一些硬件细节。
索引号,这里可以直接理解成数组下标,它对应的“数组”就是段描述符表,段描述符具体描述了一个段地址,这样,很多段描述符就组成段描述符表。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。 这里面,我们只用关心Base字段,它描述了一个段的开始位置的线性地址。 Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。 GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。 首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],- 看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
- 拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
把Base + offset,就是要转换的线性地址了。
7.3 Hello的线性地址到物理地址的变换-页式管理
计算机利用页表,通过MMU来完成从虚拟地址到物理地址的转换。
PTBR是cpu里的一个控制寄存器,指向当前页表,n位的虚拟地址包括p位的虚拟页面偏移VPO和n-p位的虚拟页号VPN。MMU通过VPN来选择适当的PTE,将页表条目中的PPN(物理页号)和虚拟地址的VPO串联起来,就得到相应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
每次cpu产生一个虚拟地址,MMU需要查询一个PTE,如果运气不好,需要从内存中取得,这需要花费很多时间,通过TLB(翻译后备缓冲器)能够消除这些开销。TLB是一个小的,虚拟寻址的缓存,在MMU里,其每一行都保存着一个单个PTE组成的块,TLB通常具有高度相联度
用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。
如果是32位系统,我们有一个32位地址空间,4KB的页面和一个4字节的PTE,我们总需要一个4MB的页表驻留在内存中,而对于64位系统,我们甚至需要8PB的空间来存放页表,这显然是不现实的。用来压缩页表的常见方式就是使用层次结构的页表。
如果是二级页表,第一级页表的每个PTE负责一个4MB的块,每个块由1024个连续的页面组成。二级页表每一个PTE负责一个4KB的虚拟地址页面。这样的好处在于,如果一级页表中有一个PTE是空,那么二级页表就不会存在,这样会有巨大的潜在节约,因为4GB的地址空间大部分都是未分配的。现在的64位计算机采用4级页表,36位的VPN被封为4个9位的片,每个片被用作一个页面的偏移,CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1PET的偏移量,这个PTE包含L2页表的基地址,VPN2提供一个到L2PTE的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
当我们通过MMU得到了物理地址后,我们就需要去内存里去相应的数据,当从内存直接取数据速度太慢,计算机利用cache(高度缓存)来加快访存速度。它位于CPU与内存之间,访问速度比内存块很多,需要从内存里取数据时,先考虑是否在cache里有缓存。图 是一个典型的cache结构。
那么我们又是如何确定物理地址所对应的是高速缓存中的哪个部分根据高速缓存的大小,我们把物理地址分割成这些部分,其中S = 2^s,B = 2^b,剩下的t位都是标记位,得到一个物理地址后,通过组索引部分可以确定在cache里的哪一组,通过标记位确定看是否与组里的某一行标记相同,如果有,通过块偏移位确定具体是哪个数据块,从而得到我们的数据。如果没有找到,则需要从内存里去数据,并找到cache里的一行替换,对于L1,L2这样的组相联cache,替换策略通常有LFU(最不常使用),LRU(最近最少使用)。
7.6 hello进程fork时的内存映射
Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域,这个过程称为内存映射。
我们知道Linux为每个进程维护了一个单独的虚拟地址空间,通过图 的数据结构来包含每个进程的信息当我们的hello程序被fork后,内存为我们的hello创建如图7-11的数据结构,并分配给一个唯一的PID,为了给我们的新进程hello创建虚拟内存,它创建了当前进程mm_struct,区域结构和页表的原样副本,将两个进程的每个页面标记为只读,并将两个进程中的每个区域结构都标记为写时复制。当fork在新进程里返回时,新进程现在的虚拟内存刚好和调用fork时相同。
7.7 hello进程execve时的内存映射
当我们用./hello运行可执行文件hello时,先fork创建虚拟内存区域,当这个区域和父进程还是完全一样的,然后会调用execve(“hello”,NULL,NULL),加载并运行可执行文件hello,用hello程序有效替换了当前程序,加载并运行hello的步骤如下:
- 删除已存在的用户区域
- 映射私有区域(为hello程序的代码,数据,bss,栈区域创建新的区域结构),都是私有,写时复制的
- 映射共享区域,如我们的hello需要与libc.so动态链接,那么这些对象动态链接到这些程序,然后再映射到用户虚拟地址空间中的共享区域。
7.8 缺页故障与缺页中断处理
虚拟内存中,DRAM缓存不命中称为缺页。如图7-13,CPU需要引用VP3中的一个字,通过读取PTE3,发现有效位为0,说明不在内存里,这时就发生了缺页异常。缺页异常发生时,通常会调用内核里的缺页异常处理程序,该程序会选择一个牺牲页,这里是存放在PP3的VP4,如果VP4已经被修改,内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在内存里。接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令。
缺页处理程序不是直接就替换,它会经过一系列的步骤:- 虚拟地址是合法的吗?如果不合法,它就会触发一个段错误
- 试图进行的内存访问是否合法?意思就是进程是否有读,写或者执行这个区域的权限
- 经过上述判断,这时才能确定这是个合法的虚拟地址,然后才会执行上述的替换。
7.9动态存储分配管理
当我们的C程序需要额外的虚拟内存区域,就会使用动态内存分配来得到,动态内存分配器维持着一个进程的虚拟内存区域,称为堆,分配器将堆视作一组大小不同的块,每个块就是一个连续的虚拟内存块,这些块要么是已分配的,要么是空闲的。
对于C程序,使用的是显式分配器,能够显式地释放任何已分配的块。C标准库通过malloc程序包的显示分配器。如上图,malloc函数能够返回一个至少包含size大小的内存块的指针,这里的size不一定是我们输入的大小,因为块大小需要数据对齐。32位中返回的地址总位8的倍数,64位中总是16的倍
free函数把当前地址对应的块给释放变成空闲块。
这个malloc和free又是怎么实现的呢 一个分配器的设计中,我们需要考虑以下问题:- 空闲块组织
- 放置,如何找到一个合适的空闲块来放置新的块
- 分割,分配后空闲块的剩余部分如何处理
- 合并,如何处理刚刚释放的块 现在主要有3种方式来构造分配器
7.9.1隐式空闲链表
它的数据结构如图所示
这种结构下块与块之间是通过头部的块大小隐式连接在一起,意思是我们要找到下一个块,只需要用当前块地址加上块大小即可。
这种结构的优点就是简单,当任何操作,如放置分配的块,都需要对整个链表搜索,全是线性级。 我们在搜索一个可以用户请求的空闲块时,一般有三种策略- 首次适配:从头搜索的第一个合适的块
- 下一次适配:从上一次分配结束的位置开始的首次适配
- 最佳适配:检查所有块,选择最适合的那个块
7.9.2显式空闲链表
这种结构比隐式多了两个指针,一个指针指向前一个空闲块,一个指针指向后一个空闲块,这样做的好处是在查找空闲块时,不需要进行很多无用的查找操作(隐式查找时需要看每一个块,这里只用看空闲块)
组织这种链表有两种方式,一种是LIFO,最新释放的空闲块放在链表最前面。一种是按地址大小来维护,链表中的块地址都小于它后继节点的地址。
7.9.3分离的空闲链表
这种是在显式空闲链表的基础上又进行优化,把所有的块大小分为一些等价类,如下图所示,然后每个等价类都有一个链表,我们寻找空闲块时就只需要在对应大小的链表里找就好了
但是这种链表也有一个问题,如果我们分割了一个空闲块,那么剩下的空闲块大小可能不应该在这个链表里,有两种方法来处理这个问题,一种是简单分离适配,每个空闲链表里的块大小都是这个大小类里最大元素的大小。然后处理时不会分割,直接取出链表的第一个块。另一种方法是分离适配,把分割后的块再插入适当的空闲链表。
合并策略: 在频繁的申请释放后,我们的堆中会有很多碎片,我们需要通过合并来减少碎片的产生。我们需要决定什么时候合并,一般来说有两种考虑,一是立即合并,每次释放一个块就合并,二时推迟合并,直到分配请求失败,再扫描整个堆进行合并。 如图 ,这种结构下,我们能够知道下一个块的位置,但不知道上一个块的位置,那么如果要与上一个块合并就会比较困难,这里有一个叫边界标记的技术可以解决这个问题,如图 ,这里多增加了一个脚部,脚部和头部内容完全一样,我们可以通过脚部,找到上一个块的脚部,从而知道上一个块的大小和是否已分配。7.10本章小结
在这一章中,主要介绍了程序的存储结构,通过段式管理在逻辑地址到虚拟地址,页式管理从虚拟地址到物理地址。程序访问过程中的cache结构和页表结构,进程如何加载自己的虚拟内存空间,内存映射和动态内存分配。
这些所有的结构,都是为了两个目标,加快访问的速度,为每个进程分配一个独立的虚拟内存空间而不不会受到其他进程影响第八章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
所有的输入输出以一种统一且一致的方式来执行:
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
- Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
- 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
- 读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
8.2.1打开文件
open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符数字是在当前进程中没有打开的最小描述符。Flags参数指明了该进程打算如何访问这个文件。
8.2.2关闭文件:
读文件:
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf,返回值-1,表示一个错误,返回0表示EOF.否则,返回值表示的是实际传送的字节数量。
8.2.3写文件
write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置
8.3 printf的实现分析
printf需要做的事情是:接受一个fmt的格式,然后将匹配到的参数按照fmt格式输出。printf用了两个外部函数,一个是vsprintf,还有一个是write。
vsprintf函数作用是将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。 write函数是将buf中的i个元素写到终端的函数。 Printf的运行过程: 从vsprintf生成显示信息,显示信息传送到write系统函数,write函数再陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序。从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。8.4 getchar的实现分析
键盘中断的处理过程:
当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求。键盘中断服务程序先从键盘接口取得按键的扫描码,然后根据其扫描码判断用户所按的键并作相应的处理,最后通知中断控制器本次中断结束并实现中断返回。 若用户按下双态键(如:Caps Lock、Num Lock和Scroll Lock等),则在键盘上相应LED指示灯的状态将发生改变; 若用户按下控制键(如:Ctrl、Alt和Shift等),则在键盘标志字中设置其标志位; 若用户按下功能键(如:F1、F2、…等),再根据当前是否又按下控制键来确定其系统扫描码,并把其系统扫描码和一个值为0的字节存入键盘缓冲区; 若用户按下字符键(如:A、1、+、…等),此时,再根据当前是否又按下控制键来确定其系统扫描码,并得到该按键所对应的ASCII码,然后把其系统扫描码和ASCII码一起存入键盘缓冲区; getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。8.5本章小结
本章主要介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数。通过这章,我们知道,就算是我们从键盘输入一串字符串,然后要在屏幕上显示出来,这中间的过程也是异常复杂的,需要经过很多的处理,也不得不叹服计算机设计者的伟大。
结论
Hello程序在计算机中从出生到死亡整个过程:
1 写程序:写下我们hello程序的代码- 预处理:对带#的指令解析,生成hello.i文件
- 编译:把我们的C语言程序编译成汇编语言程序,生成.s文件
- 汇编:把汇编语言转换成机器代码,生成重定位信息,生成.o文件。
- 链接:与动态库链接,生成可执行文件hello
- 创建进程:在shell利用./hello运行hello程序,父进程通过fork函数为hello创建进程
- 加载程序:通过加载器,调用execve函数,删除原来的进程内容,加载我们现在进程的代码,数据等到进程自己的虚拟内存空间。
- 执行指令:CPU取指令,顺序执行进程的逻辑控制流。这里CPU会给出一个虚拟地址,通过MMU从页表里得到物理地址, 在通过这个物理地址去cache或者内存里得到我们想要的信息
- 异常(信号):程序执行过程中,如果从键盘输入Ctrl-C等命令,会给进程发送一个信号,然后通过信号处理函数对信号进行处理。
- 结束:程序执行结束后,父进程回收子进程,内核删除为这个进程创建的所有数据结构。
到此,我们对hello程序的分析已经全部结束。其实一直以来,我都想要知道一个程序在计算机内部到底是怎么执行的,我们现在有了各种各样的IDE,我们只需要写好代码,按一下build键,然后我们的程序就编译好输出了,当这中间的所有过程,我们都一点也不清楚,如果以后是在这些过程中发生了错误,那么我们会不知所措,这次大作业算是初步了解整个过程,也算是对一个程序的执行有了大体的认识,当还有许许多多的地方,只能算是一笔带过,实现上的细节还远远不清楚,还需要以后的学习中继续努力。
附件
hello.c:源程序文件
hello.i:预处理后的文本文件 hello.s:编译后的汇编文件 hello.o:汇编后的可重定位文件 hello:链接后的可执行文件 (附件0分,缺失 -1分)参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Linux GCC 常用命令 https://www.cnblogs.com/ggjucheng/archive/2011/12/14/2287738.html [2] ELF中与动态链接相关的段 https://blog.csdn.net/virtual_func/article/details/48792087 [3] linux bash总结 http://www.cnblogs.com/skywang12345/archive/2013/05/30/3106570.html. [4] x86在逻辑地址,线性地址,理解虚拟地址和物理地址 https://www.cnblogs.com/bhlsheji/p/4868964.html [5] printf函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html [6] 键盘的中断处理 https://blog.csdn.net/xumingjie1658/article/details/6965176 [7]聊聊Linux动态链接中的PLT和GOT(3)——公共GOT表项 https://blog.csdn.net/linyt/article/details/51637832 [8] 深入理解计算机系统(第三版) Randal E.Bryant David R.O’Hallaron!