Hello的一生

Hello的一生

第1章 概述

1.1 Hello简介

首先程序员通过键盘和编辑器编写hello.c的代码,保存为文本文件。之后我们的hello的生命就开始了!预处理cpp首先将其处理为hello.i,之后由编译器ccl处理为hello.s汇编语言文件,再由汇编器as处理得到hello.o可重定位二进制文件。之后链接器ld将hello.o与其他.o文件或者函数库进行连接,得到了一个可执行文目标程序hello,并将其安安稳稳放在了磁盘上。由此便是p2p的过程。

之后的阶段,将文件名输入到名为shell应用程序,shell会加载并运行我们的hello,具体为,利用fork得到一个与父进程几乎相同的子进程,并且分配相对应的内存资源,虚拟内存,CPU的使用权限,shell为他execve,调用加载器,并映射虚拟内存,之后跳转到程序入口,调用main函数,CPU通过取值、译码、执行操作来实现程序,并按照进程切换方式工作。当程序运行结束后,其处于终止状态,父进程对hello进行回收,删除相应的数据,hello从此消失,不带走一片云彩。这就是020。

1.2 环境与工具

1.2.1 硬件环境

X64CPU 2.80GHZ 8.0G RAM 1TB HD Disk 1.2.2

1.2.2 软件环境

Windows 10 X64位 Vmware14 Ubuntu16.04LTS64位
Ubuntu下GDB、edb 、readelf、vim

1.3 中间结果

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

文件 作用
hello.i hello.c文件预处理后的文件
hello.s hello.i文件经过编译之后得到的文件
hello.o hello.s汇编之后得到的可重定位目标文件
helloo.elf hello.o的elf格式文件
hello.c hello.c的C语言文件
hello.elf hello可执行目标文件的elf格式文件
hello hello.c经过编译之后得到的可执行目标文件
1.4 本章小结

本章主要描述了hello从生到死传奇的一段“程”生,尽管只是笼统的介绍,但也算对他的生平有了一定的了解。同时本章还说明了本次作业的环境和中间结果的作用。

第2章 预处理

2.1 预处理的概念与作用

预处理的概念:预处理器(cpp)根据以字符“#”开头的命令,修改原始的C程序。

作用:处理代码中以#开头的预编译指令、删除注释等
例如:预处理会对下面的带#的指令进行文字描述的操作

#include 包含一个源代码文件
#define 定义宏
#undef 取消已定义的宏
#if如果给定条件为真,则编译下面代码
#ifdef 如果宏已经定义,则编译下面代码
#ifndef如果宏没有定义,则编译下面代码
#elif 如果前#if条件不为真,当前条件为真,则编译下面代码,其实就是else if的简写
#endif结束一个#if……#else条件编译块
#error停止编译并显示错误信息

2.2在Ubuntu下预处理的命令

gcc -E hello.c -o hello.i

Hello的一生
上图是hello.c预处理之后的结果hello.i,可以看到hello.c没有了注释,已经被扩充到了3126行,,较原来的hello.c有了很多补充,也就是将一些头文件插入到了原来的代码之中,而且插入的文件可能也含有一些宏,又需要进行预处理,总之整个过程是递归进行的。整个.i文件仍然是C语言可读的。
2.4 本章小结

本章主要是对hello.c的生命开端的描述,介绍了其p2p最开始的步骤,预处理,描述了预处理的概念作用以及在linux下的指令,并将预处理之后的hello.i文件和hello.c进行了比较。

第3章 编译

3.1 编译的概念与作用

编译概念:编译的概念在这里是指编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。

编译的作用:编译的作用就是将一个.i文件通过一系列的分析优化,生成相应的汇编语言程序以进行后续的操作。

3.2 在Ubuntu下编译的命令

gcc -S hello.i -o hello.s

Hello的一生
可以看到他被定义为全局类型.globl 并且在.data节,且4字节对齐,类型为@object 大小是4字节并且类型是long(linux下long4字节)且值为2.

hello.c中还有一个局部变量i 其应该存放在栈中在hello.s中如下描述

Hello的一生
接着我们来看字符串,在hello.c中有两个字符串,分别作为printf函数的传入,在hello.s中是如下定义的
Hello的一生
3.3.2赋值

1、将sleepsecs赋值为2.5,直接在hello.s开头进行如下图操作

Hello的一生
3.3.3 类型转换

在hello.c中隐式的将float类型的2.5转换为整型,遵从向0舍入,如下图

Hello的一生
3.3.5关系操作

在hello.c的for循环中存在一个i与10的比较 判断i

Hello的一生
3.3.6数组操作

在hello.c中有对第二个参数即数组的操作,在hello.s中通过如下进行访问,红线是获取地址,黑线是获取值

Hello的一生
2、在for循环时也有控制转移,具体操作如下
Hello的一生

在调用puts放在edi中

Hello的一生
调用函数:

调用pus函数的指令

Hello的一生

调用sleep函数

Hello的一生
4.3 可重定位目标elf格式

Hello的一生

节头表包含了文件中出现的节的类型位置和大小。

其中对于重定位.rela.text我们重点分析,他是一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,修改这些位置。

Hello的一生
4.4 Hello.o的结果解析

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

Hello的一生
在节头中记录了每个节的信息,包括节的名称,和大小,类型、对齐以及偏移量,在虚拟地址中的起始地址,通过这些信息就可以确定每个节的地址段。
Hello的一生
之后我们将得到的虚拟地址与上一节中的信息进行对比,对比如下图 Hello的一生
可以看到两者之间的不同主要在于
1、 hello中多了一些节如.init .plt节
2、 hello中引入了一些外部函数
3、 hello.o中跳转和函数的调用在hello中被具体确定,是一个明确的虚拟内存地址
接着我们来说明链接过程,整个链接过程,首先是对符号进行解析,这个过程需要链接器从左到右按照在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。在完成解析的任务后,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。开始重定位,将合并输入模块,并为每个符号分配运行时的地址。首先进行重定位节和符号定义,链接器将所有相同类型的节合并为同一类型的新的聚合节。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址。然后是重定位节中的符号引用,链接器会修改中的代码节和数据节中对每一个符号的引用,使得他们指向正确的运行地址。

我们以hello.o中的一个需要重定位的内容进行举例。

Hello的一生
我们来分析一个实例,在调用addvec时,
1、程序调用进入PLT[2]
2、第一条PLT指令通过GOT[4]间接跳转,跳转到PLT[2]第二条指令
3、把addvecID压入栈,PLT[2]跳转到PLT[0]
4、PLT[0]通过GOT[1]把动态链接器参数压入栈中,通过GOT[2]间接跳转进动态链接器中。动态链接器确定addvec运行位置,重写got[4]

对于我们的hello我们通过5.3小节,知道它的.got地址是0x6008b8,查看他的信息发现全是0

Hello的一生
看到got[2]有了内容,进入这个地址
Hello的一生
6.4 Hello的execve过程

在shell fork了一个子进程之后,这个子进程会调用execve函数在当前进程的上下文中加载并运行hello程序。execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到hello,execve才会返回到调用程序。所有与fork一次调用返回两次不一样,execve调用一次从不返回。在execve加载了hello,他调用启动代码,启动代码设置栈,并将控制传递给新程序主函数。在main开始执行时,用户栈的组织结构如下图

Hello的一生
6.5 Hello的进程执行

操作系统内核使用一种称为上下文切换的异常控制流来实现多任务,所谓上下文就是内核重新启动一个被抢占的进程所需的状态。当hello没有被别的进程抢占时,也就是他处于自己的进程时间片,此时按照hello程序顺序执行hello,当有别的进程进行抢占时,就会发生上下文切换,内核进行调度,保存hello进程上下文恢复一个之前被抢占的进程上下文,并将控制转给这个进程。具体如下图

Hello的一生
2、在执行过程中按下Ctrl-C

Hello的一生
可以看到将乱按的一部分当作了一个命令输入
4、 在执行过程中按下Ctrl-Z,发送一个停止信号将hello挂起

Hello的一生
看到hello只是被挂起

运行fg指令,发送SIGCONT信号继续执行程序

Hello的一生
运行pstree指令
Hello的一生
6.7本章小结

本章我们主要介绍了已经出生了的hello是如何被执行的,shell是如何为他fork一个子进程,子进程是如何为他execve。我们还介绍了进程之间是如何切换的,最后我们还测试了一些信号和异常情况,通过在命令行输入指令来实现了对一个进程的控制。

第7章 hello的存储管理

7.1 hello的存储器地址空间

物理地址:计算机系统的主存被组织成一个由M个连续字节大小的单元组成的数组。每字节都有一个唯一的物理地址。CPU通过地址总线寻址就是物理地址。

线性地址:地址空间是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么我们就说他是一个线性地址空间,在这里是和hello的虚拟地址空间相同

虚拟地址:在一个带虚拟内存的系统,cpu从一个有N个地址的地址空间中生成虚拟地址,也就是虚拟内存中的地址。

逻辑地址:是在有地址变换功能的计算机中,访内指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址,也就是是机器语言指令中,用来指定一个操作数或是一条指令的地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址即物理地址。一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是个索引号,后面3位包含一些硬件细节 。

7.2 Intel逻辑地址到线性地址的变换-段式管理

一个逻辑地址由两部分组成,段标识符:段内偏移量。段标识符是由一个16位子长字段组成,称为段选择符,前13位是一个索引号,后3位包含硬件细节,如下图。

Hello的一生
可以看到在其中有一个BASE着代表该段开始的基地址。

在intel设计,一些全局段描述符放在“全局段描述符表中(GDT)”,局部的,例如每个进程自己的,放在局部段描述符表LDT,通过段选择符中的T1字段=0表示GDT=1表示LDT。
具体变化过程为:

看段选择符T1是0还是1,知道是GDT还是LDT中的段,根据相应寄存器得到地址和大小。
通过段选择符前13位找到相应段描述符,查找BASE得到基地址
把BASE+OFFSET得到线性地址

Hello的一生
页表是一个页表条目的数组,虚拟地址空间中的每个页在页表中一个固定偏移量处有一个PTE,为了我们的目的,我们将假设每个PTE是由一个有效位和地址段组成,设置有效位,地址字段表示相应物理页起始位置。没有设置,空地址代表虚拟页未分配,否则指向虚拟页在磁盘起始位置。

而翻译地址过程是由MMU来进行的。CPU中一个控制寄存器,页表基址寄存器指向当前页表。一个n位虚拟地址包含两个部分,1个p位的虚拟页面偏移,一个N-p位的虚拟页号。MMU利用VPN也就是虚拟页号来选择适当的PTE,将页表条目中的物理页号和虚拟地址中的VPO串联起来,就得到了相应的物理地址。下图描述了整个过程。

Hello的一生
CPU生成一个48位的虚拟地址,根据TLBI向TLB中进行匹配,如果命中则直接将PPN和VPO组合形成物理地址。如果匹配不命中,则向页面中进行查询,CR3确定了第一级页表的起始地址,VPN1确定了第一级页表的偏移量,如此找到了PTE,如果在物理内存中符合权限,则就已经确定了第二级页表的起始地址,再利用VPN2确定第二级页表中的偏移量,找到一个PTE重复上述操作,最后会在第四级页表中找到PPN,将PPN和VPO结合,找到了物理地址。
Hello的一生
7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。

Hello的一生
7.8 缺页故障与缺页中断处理

当CPU引用虚拟内存中的一个页的某个字时,,地址翻译硬件从内存中读取相应的PTE,从有效位推断这个页是否被缓存,如果未被缓存,则会触发一个缺页异常。缺页异常调用内核中的缺页处理程序,该程序会选择牺牲一个页,并修改牺牲页的页表条目。接下来,内核从磁盘复制这个读取页到内存中,更新相应的PTE随后返回。
当异常处理程序返回后,他会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。
具体过程如下图所示

Hello的一生
在这种情况下,一个块是由一个字的头部、有效载荷、以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。块大小最低三位总是0,只用内存大小的29个高位表示大小,其余3位用来编码其他信息。用最低位来指明这个块是已分配的还是空闲的。空闲块通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有块,遍历整个空闲块集合。我们需要某种特殊标记结束块。
寻找合适块的策略包括首次适配、下一次适配和最佳适配。首次适配会从头开始搜索空闲链表,选择第一个合适的空闲块。搜索时间与总块数(包括已分配和空闲块)成线性关系。会在靠近链表起始处留下小空闲块的“碎片”。下一次适配和首次适配相似,只是从链表中上一次查询结束的地方开始。比首次适应更快,避免重复扫描那些无用块。最佳适配会查询链表,选择一个最好的空闲块,满足适配,且剩余最少空闲空间。它可以保证碎片最小,提高内存利用率。

隐式链表的优点是简单,显著缺点是任何操作开销要对空闲链表搜索,搜索时间与堆中已分配块和空闲块总数呈线性关系。

在合并时,有四种情况,根据不同情况修改相应的头和脚就可以实现合并。

显示空闲链表:

将堆组织成一个双向空闲链表,在每个空闲块中,都包含一个pred前驱和succ后继指针。

Hello的一生
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是现行的,也可能是个常数。
一种方法是后进先出的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次配适的放置策略,分配器会最先检查最近使用过的块。
另一种是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
一般而言,显示链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片程度
7.10本章小结

本章是十分重要且复杂的,主要分析了hello的存储,对此我们介绍了一些基本地址空间的知识,同时也对一些地址间的转换方法做了介绍,分析了在得到地址后如何进行数据的读取,以及fork和execve如何进行内存映射,以及动态分配内存的方法。可以看到我们的hello真的是一个重要的角色,这么多规则都要围绕他转。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

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

8.2 简述Unix IO接口及其函数
Unix I/O接口:

1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。

2.Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件 定义了常量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函数将文件转为文件描述符,返回描述符数字。返回的描述符总是进程中当前没有打开最小。Flag参数,指明如何访问文件也可是一个或多为掩码的或。
O_RDONLY:只读
O_WRONLY:只写
O_RDWR:可读可写
Mode参数指定了新文件的访问权限位。若返回-1则出错。
2、ssize_t read(int fd, void*buf ,size_t n)
Read函数从描述符为fd的当前文件位置复制最多n个字节到内存buf。返回值为-1表示一个错误,返回0表示EOF,否则返回值表示的是实际传送字节数。
3、ssize_t write(int fd, const void *buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。若成功则返回写的字节数,若出错则为-1。

8.3 printf的实现分析

首先来看printf的代码

可以看到在参数部分采用了一个可变形参的写法,并且在下面这条语句va_list arg = (va_list)((char*)(&fmt) + 4); 获取了…中的第一个参数。接下来调用了vsprintf函数
我们查看这个函数的代码

int vsprintf(char *buf, const char *fmt, va_list args){	int len;	int i;	char * str;	char *s;	int *ip;	int flags;            /* flags to number() */	int field_width;      /* width of output field */	int precision;        /* min. # of digits for integers; max number of chars for from string */	int qualifier;        /* 'h', 'l', or 'L' for integer fields */	for (str = buf; *fmt; ++fmt) {		if (*fmt != '%') {			*str++

来源:佟岩

声明:本站部分文章及图片转载于互联网,内容版权归原作者所有,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

上一篇 2018年11月26日
下一篇 2018年11月26日

相关推荐