哈工大2021春计算机系统大作业 程序人生-Hello’s P2P

 

 

 

计算机系统

大作业

题     目  程序人生-Hello’s P2P 

专       业         计算机类        

学     号        1190200613       

班     级         1903004         

学       生          罗旭正     

指 导 教 师           史先俊       

计算机科学与技术学院

2021年5月

摘  要

本文介绍了hello.c文件编写完成后在Linux下运行的完整生命历程,对预处理、编译、汇编、链接、进程管理、存储管理、I/O管理这些hello程序的生命历程进行详细、清楚地解释。通过运用一些工具,如gdb、edb、readelf等,清晰地观察hello程序完整的周期,直观地表现了程序从开始到结束的生命历程。

关键词:Ubuntu;预处理;编译;汇编;链接;进程;存储;I/O                          

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

 

 

 

 

 

 

 

目  录

 

 

第1章 概述

1.1 Hello简介

P2P(From Program to Process):

hello.c是C语言文本文件,经过cpp预处理,进行宏替换,生成文本文件hello.i,编译器ccl生成汇编程序hello.s,然后通过汇编器as生成二进制可重定位文件hello.o,链接器ld将其与引用到的库函数连接起来生成二进制可执行文件hello。在shell中输入命令,shell为其fork一个子进程,再调用execve把程序加载到进程中,开始运行。

                      图1-1 P2P流程图

O2O(From Zero-0 to Zero-0:

子进程通过execve系统调用启动加载器。加载器创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它会调用应用程序的main函数。程序运行结束后,shell回收进程,释放虚拟地址空间,删除有关内容,所以进程从0开始,最后又回到了0。

1.2 环境与工具

硬件环境:Intel(R) Core(TM) i7-9750H CPU;2.60GHz;8G RAM;1T SSD

软件环境:Windows10 64位;Vmware 15.5 PRO;Ubuntu 18.04 LTS 64位

开发与调试工具:gcc,vim,edb,readelf,HexEdit

1.3 中间结果

文件名称

文件作用

hello.c

C程序代码

hello.i

hello.c预处理之后的文本文件

hello.s

hello.i编译后的汇编文件

hello.o

hello.s汇编之后的可重定位目标文件

hello

hello.o与其他可重定位目标文件链接之后的可执行目标文件

hello1.txt

hello.o反汇编生成的文本文件

hello2.txt

hello反汇编生成的文本文件

helloelf.txt

ELF格式下的hello

1.4 本章小结

    本章介绍了hello.c程序P2P和O2O的基本过程,介绍了实验的环境与工具以及实验生成的中间文件。

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

概念:

程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。

作用:

ISO C和ISO C++都规定程序由源代码被翻译分为若干有序的阶段(phase) ,通常前几个阶段由预处理器实现。预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,其中ISO C/C++要求支持的包括#if / #ifdef / #ifndef / #else / #elif / #endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。

2.2在Ubuntu下预处理的命令

命令:gcc -E hello.c -o hello.i

 图2-1 Ubuntu输入命令

生成文件:

图2-2 生成的预处理文件

2.3 Hello的预处理结果解析

结果:

图2-3 查看hello.i文件

解析:

预处理文件总共3105行,main函数在hello.i文件的最后,没有变化,前面的头文件#include <stdio.h>、#include <unistd.h> 、#include <stdlib.h>被扩展,文件加入了很多宏定义,如typedef、extern、sturct、enum等。

2.4 本章小结

本章介绍了预处理概念与作用,以及在Linux下预处理的操作,简要分析了生成的hello.i文件。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

概念:

编译器(ccl)将hello.i文件转换成汇编文件hello.s,里面是hello.c对应的汇编语言程序。

作用:

编译的过程实质上对预处理文件进行语法分析、语义分析、优化,将其转换为汇编程序,是高级语言向机器代码转换中重要的中间过程。

3.2 在Ubuntu下编译的命令

命令:gcc -S hello.i -o hello.s

图3-1 Ubuntu输入命令

生成文件:

图3-2 生成的汇编文件

3.3 Hello的编译结果解析

3.3.1 数据

1. 字符串

程序中有两个输出字符串,它们都存在.rodata节,作为printf的参数。

图3-3 字符串常量

图3-4 作为参数传递给printf

2. 数组

向main函数中传参int argc,char *argv[],可以看出argv首地址在栈中的位置为-32(%rbp),被多次调用传给printf。

图3-5 argv[]多次被调用

3. 局部变量

main函数声明了一个局部变量i,赋值为0,放在帧指针%rbp指向位置的下方4个字节中。

图3-6 声明局部变量

4. 立即数

立即数直接体现在汇编代码中。

3.3.2 全局函数

程序中声明了一个全局函数:主函数main,在汇编代码中体现出来。

图3-7 全局函数main

3.3.3 数据类型转换

程序中使用了一个类型转换操作atoi,把字符串转为int型数。

图3-8 atoi类型转换

3.3.4 算数操作

程序中有i的自增计算,在汇编文件中体现如下:

图3-9 i++

3.3.6 关系操作与控制转移

1. 判断argc!= 4,如果argv等于4,就将控制转移到L2处的指令,否则继续执行下一条指令。

图3-10 argc!= 4

2. 判断i <= 7,如果成立,就跳到L4继续执行循环内容,如果不成立,就继续执行下面的指令。

图3-11 i <= 7

3.3.7 函数操作

hello程序中包含着对puts,exit,printf,atoi,sleep,getchar函数的调用。

1.     main:

参数传递:%edi保存argc,%rsi保存argv

图3-12 main函数传参

   返回:0

图3-13 main函数返回0

2.     exit:

参数传递:movl       $1, %edi

3.     puts:

参数传递:leaq .LC0(%rip), %rdi

返回:向标准输出设备屏幕输出字符串并换行

4.     printf:

参数传递:leaq .LC1(%rip), %rdi

5.     atoi:

参数传递:把%rdi设置为argv[3]并传入

图3-14 argv[3]传给atoi

返回:返回值存储在%eax

6.     sleep:

参数传递:movl       %eax, %edi(atoi处理完的argv[3])

7.     getchar:

无参数传递,返回值是int类型。

3.4 本章小结

本章介绍了编译的概念与作用,对示例文件进行编译操作,并从各种数据、函数等多方面对汇编程序进行分析。

(第32分)

第4章 汇编

4.1 汇编的概念与作用

概念:

    汇编器(as)将汇编文件转换为二进制可重定位文件的过程。

作用:

    将汇编代码转换为机器指令,并将其打包为重定位目标程序的格式,生成.o文件,为程序的链接与运行做准备。

4.2 在Ubuntu下汇编的命令

命令:gcc -c hello.s -o hello.o

图4-1 Ubuntu输入命令

生成文件:

图4-2 生成的可重定位文件

4.3 可重定位目标elf格式

4.3.1 ELF头

    该部分包含了ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量,其中Magic描述了生成该文件的系统的字的大小和字节顺序。

图4-3 ELF头

4.3.2 节头表

该部分包含了各节的名称、大小、类型、地址、偏移量。每个节有不同的读写权限、对齐大小。因为这是用来重定位的文件,所以每个节的起始地址都是0。

    

图4-4 节头表

4.3.3 符号表

    符号表.symtab包含了程序中的函数、全局变量的名称、类型、大小、vis等信息。

图4-5 符号表

4.3.4 重定位节

重定位节.rela.text包含了对.text节进行重定位的信息。当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。

图4-6 重定位节

4.4 Hello.o的结果解析

反汇编指令:objdump -d -r hello.o > hello1.txt

图4-7 反汇编代码

与汇编语言进行对比,可以发现:

1.     二者指令基本上一样,反汇编的指令后面没有对字大小的表示,如w,l,q等,ret后面加上了q,与原先的ret一样;

2.     在分支转移上,汇编语言跳转使用的是段名称,如L1、L2,反汇编中直接跳转到每一行最前面的逻辑地址;

3.     在函数调用上,汇编语言直接call函数的名称,反汇编中call下一条指令,机器代码除了第一字节都是零,这是因为调用的函数在别的库中,目前还未链接,链接后,机器指令后面部分+下一条指令地址=被调用函数的地址;

4.     机器语言中包含了两种重定位条目,一种是PC相对引用,一种是绝对引用,它们帮助程序链接时找到需要的函数或数据的地址。

对于机器语言,可以看出它由操作码和操作数组成,长短不固定,但在一定范围内,与汇编语言是一一对应的映射关系。

4.5 本章小结

本章介绍了汇编的概念与作用,生成了二进制可重定位目标文件,通过指令查看了ELF文件的相关内容,然后将反汇编文件与汇编程序对比,发现二者的联系。

(第41分)

第5章 链接

5.1 链接的概念与作用

概念:

链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也将就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是有应用程序来执行。

作用:

链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。

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-1 Ubuntu输入命令

生成文件:

图5-2 生成的可执行文件

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

5.3.1 ELF头

具体内容和可重定位目标文件差不多,文件类型变成了:EXEC(可执行文件)。

    

图5-3 ELF头

5.3.2 节头部表

与hello.o相比,多了几个部分:

1.     interp段:动态链接器在操作系统中的位置不是由系统配置决定,也不是由环境参数指定,而是由ELF文件中的 .interp段指定。该段里保存的是一个字符串,这个字符串就是可执行文件所需要的动态链接器的位置,常位于/lib/ld-linux.so.2(通常是软链接);

2.     dynamic段:该段中保存了动态链接器所需要的基本信息,是一个结构数组,可以看做动态链接下 ELF文件的“文件头”。存储了动态链接会用到的各个表的位置等信息;

3.     dynsym段:该段与 “.symtab”段类似,但只保存了与动态链接相关的符号,很多时候,ELF文件同时拥有 .symtab 与 .synsym段,其中 .symtab 将包含 .synsym 中的符号。该符号表中记录了动态链接符号在动态符号字符串表中的偏移,与.symtab中记录对应;

4.     dynstr段:该段是 .dynsym 段的辅助段,.dynstr 与 .dynsym 的关系,类比与 .symtab 与 .strtab的关系 hash段:在动态链接下,需要在程序运行时查找符号,为了加快符号查找过程,增加了辅助的符号哈希表,功能与 .dynstr 类似;

5.     rel.dyn段:对数据引用的修正,其所修正的位置位于 “.got”以及数据段(类似重定位段 “rel.data”);

6.     rel.plt段:对函数引用的修正,其所修正的位置位于 “.got.plt”。

图5-4 节头部表(1)

图5-5 节头部表(2)

5.3.3 符号表

    符号表中存放了程序中引用的函数和全局变量信息。

图5-6 符号表(部分)

5.3.4 重定位节

列举出了函数中详细的重定位信息。

图5-7 重定位节

5.4 hello的虚拟地址空间

使用edb查看可执行目标文件hello,根据5.3.2的节头部表,可以看出:

1.     .init段地址是0x4004c0,偏移量是0x4c0,大小为17

图5-8 .init节

2.     .text段地址是0x400550,偏移量是0x550,大小为132

图5-9 .text节

3.     .data段地址是0x601048,偏移量是0x1048,大小为4

图5-10 .data节

5.5 链接的重定位过程分析

生成反汇编文件:

图5-11 反汇编文件

从这段汇编代码中可以看到:

hello.o中的je,call,jmp后面跟的操作数是全0,而hello中是已经计算出来的相应段或函数的地址。根据这个不同可以分析出hello.o链接成为hello的过程中需要对重定位条目进行重定位,对相应的条目进行计算得到地址。并且再hello的反汇编代码中除了main函数以外还有很多其他的函数,例如puts,printf,getchar,atoi,exit,sleep,_start等函数的汇编代码,从这个不同可以分析出链接会将共享库中函数的汇编代码加入hello.o中。

hello重定位的过程:

(1)重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。

(2)重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。

(3)重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。代码的重定位条目放在.rela.txt

重定位算法:

foreach section s{

foreach relocation entry r{

refptr = s + r.offset;/*ptr to reference to be relocated*/

/*Relocate a PC-relative reference*/

if(r.type == R_X86_64_PC32){//PC相对寻址的引用

refaddr = ADDR(s) + r.offset;/*ref's run-time address*/

*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr);

}

/*Relocate an absolute reference*/

if( r.type == R_X86_64_32)//使用32位绝对地址

*refptr = (unsigned)(ADDR(r.symbol) + r.addend);

}

}

重定位地址计算公式为:

refaddr = ADDR(s) + r.offset

*refptr = (unsigned) (ADDR(r.symbol) + r.addend - reffaddr)

5.6 hello的执行流程

函数名

函数地址

ld-2.31.so!_dl_start

0x7ffff7a03b00

ld-2.31.so!_dl_init

0x7ffff7de37d0

hello!_start

0x400550

libc-2.31.so!_libc_start_main

0x7ffffc827ab0

-libc-2.31.so!_cxa_atexit

0x7ffffc849430

-libc-2.31.so!_libc_csu_init

0x400620

libc-2.31.so!_setjmp

0x7ffffc844c10

libc-2.31.so!exit

0x7ffffc849128

图5-12 gdb打断点看地址

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条目。

通过节头表找到GOT起始位置0x601000:

图5-13 GOT起始位置

GOT表在调用dl_init前的情况:

    

图5-14 调用dl_init之前的GOT

调用dl_init之后的情况:

图5-15 调用dl_init之后的GOT

0x601008后面的16个字节分别变为0x7f9a36e60170、0x7f9a36c4c8f0, 其中GOT[0](0x600e50)和GOT[1]( 0x7f9a36e60170)包含了动态链接器在解析函数地址时会使用的信息。GOT[2](0x7f9a36c4c8f0)是动态链接器在ld-linux,so模式中的入口点。

5.8 本章小结

本章介绍了链接的概念与作用,对比hello.o和hello文件的格式信息,查看hello的虚拟地址空间,分析了其重定位原理与过程、执行流程与动态链接过程。

 

(第51分)

第6章 hello进程管理

6.1 进程的概念与作用

概念:

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

作用:

进程提供给应用程序两种关键抽象:

1.     一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;

2.     一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。

6.2 简述壳Shell-bash的作用与处理流程

作用:

Shell是一个用C语言编写的程序,他是用户使用Linux的桥梁。Shell 是一个交互型应用级程序,代表用户运行其它程序(命令行解释器,以用户态方式运行的终端进程)。

处理流程:

1.     终端进程读取用户由键盘输入的命令行;

2.     分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量;

3.     检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令;

4.     如果不是内部命令,调用fork( )创建新进程/子进程;

5.     在子进程中,用步骤2获取的参数,调用execve( )执行指定程序;

6.     如果用户没要求后台运行(命令末尾没有&号)则shell使用waitpid(或wait...)等待作业终止后返回;

7.     如果用户要求后台运行(如果命令末尾有&号),则shell返回。

6.3 Hello的fork进程创建过程

    在终端输入./hello 1190200613 lxz,shell判断它不是内置命令,于是调用fork创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同分PID。

fork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0。父进程与子进程是并发运行的独立进程,在子进程执行期间,父进程默认等待子进程完成。

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序。

图6-1 execve函数

execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到hello,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。

argv变量指向一个以null结尾的指针数据,其中每个指针都指向一个参数字符串,按照惯例,argv[0]是可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的envp也指向一个以null结尾的指针数组,其中每个指针都指向一个环境变量字符串,每个串都是形如“name=value”的名字-值对。

图6-2 参数与环境变量列表组织结构

在execve加载了hello之后,调用启动代码。启动代码设置栈,并将控制转递给新程序的主函数main。

6.5 Hello的进程执行

上下文信息:

上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内 核数据结构等对象的值构成。

上下文切换:

当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:

1.     保存以前进程的上下文;

2.     恢复新恢复进程被保存的上下文;

3.     将控制传递给这 个新恢复的进程 ,来完成上下文切换。

进程时间片:

一个进程执行它的控制流的一部分的每一时间段叫做时间片。

用户模式和内核模式:

处理器通常是用某个控制寄存器中的一个模式位提供两种模式的区分功能,该寄存器描述了进程当前享有的特权。

1.     当设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置;

2.     当没有设置模式位时,进程就处于用户模式中, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的 代码和数据。

hello程序的运行:

进程调用execve函数,为hello分配好了虚拟地址空间,将代码段和数据段映射为可执行文件hello中的相应内容。hello首先在用户模式下输出hello 1190200613 罗旭正,然后系统调用sleep,显式地请求让调用进程休眠,此时内核会处理休眠请求,内核保存hello进程的上下文,主动释放hello进程,把hello进程从运行队列转移到等待队列。计时器开始计时,内核进行上下文切换,把控制权交给其他进程。定时器到时,发送中断信号,内核恢复hello进程的上下文,将hello进程从等待队列移回运行队列,继续运行hello程序。

图6-3 进程上下文切换

之后hello程序调用getchar,实际上是系统调用read,hello从用户模式陷入内核模式,异常处理程序处理来自键盘缓冲区的传输,并且安排在完成数据传输后中断处理器。此时内核进行上下文切换到其他进程。当完成数据传输后,引发中断信号,此时进程再进行上下文切换到hello。

图6-4 hello程序演示

6.6 hello的异常与信号处理

hello运行过程中可能会遇到以下几种异常:

图6-5 异常的类别

异常时产生的信号:

图6-6 Linux信号

正常运行的程序结果:

    命令行输入了程序名,参数,循环结束后输入了生日0315。

图6-7 程序的正常运行

在键盘上不停乱按:

在程序运行过程中,我乱按键盘(字母和回车),发现输入的混乱字符只是连续出现在屏幕上,与程序本来的输出连接在一起。期间由于我输入了三次回车,getchar读回车,把回车前面的混乱字符串当做shell指令去解析,最终结果是未找到。

图6-8 乱按键盘的输出结果

输入Ctrl-Z:

    如果在程序运行中输入Ctrl-Z,会发送一个SIGTSTP信号给前台进程组的每个进程,停止前台作业,hello就被停止了(挂起)。

图6-9 输入Ctrl-Z的结果

输入Ctrl-Z之后运行ps、jobs、pstree、fg、kill等命令:

1.     ps

运行hello前没有hello进程,运行hello时输入Ctrl-Z,再通过ps查看,发现hello在进程列表里,说明hello只是被暂停,没有被终止和回收。

图6-10  ps操作

2.     jobs

使用jobs查看当前暂停的进程,列出hello和它目前的状态(已停止)。

图6-11  jobs操作

3.     fg

使用fg,恢复第一个后台作业到前台,hello又继续在前台运行,输出剩下5个字符串。

图6-12  fg操作

输入Ctrl-C:

如果在程序运行中输入Ctrl-C,会发送一个SIGINT信号给前台进程组的每个进程,终止前台进程,hello进程就会被回收。

图6-13 输入Ctrl-C的结果

6.7本章小结

    本章介绍了进程的概念与作用,简要说明shell的作用和处理流程,介绍了hello程序的进程创建、加载、进程执行以及各种异常与信号处理的相关内容。

(第61分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:

是指由程序产生的和段相关的偏移地址部分,程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。

线性地址:

线性地址是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。是hello中的虚拟内存地址。

虚拟地址:

虚拟地址就是线性地址。每个进程都有独立且结构相同的虚拟地址空间,从0x400000开始。

物理地址:

CPU通过地址总线的寻址,找到真实的物理内存对应地址。 CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。

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

一个逻辑地址由两部分组成,段选择符和段内偏移量。

段选择符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。TI:0为GDT,1为LDT。Index指出选择描述符表中的哪个条目,RPL请求特权级。如图所示:

图7-1 段选择符字段

1.     TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT);

2.     RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级;

3.     高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置。

段描述符是一种数据结构,实际上就是段表项,分两类:

1.       用户的代码段和数据段描述符;

2.       系统控制段描述符,又分两种:

                                 a.            特殊系统控制段描述符,包括:局部描述符表(LDT)描述符和任务状态段(TSS)描述符;

                                 b.            控制转移类描述符,包括:调用门描述符、任务门描述符、中断门描述符和陷阱门描述符。

描述符表实际上就是段表,由段描述符(段表项)组成。有三种类型:

1.     全局描述符表GDT:只有一个,用来存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及TSS(任务状态段)等都属于GDT中描述的段;

2.     局部描述符表LDT:存放某任务(即用户进程)专用的描述符;

3.     中断描述符表IDT:包含256个中断门、陷阱门和任务门描述符。

图7-2 存储器寻址

逻辑地址向线性地址转换:

被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址,其中GDT首址或LDT首址都在用户不可见寄存器中。

图7-3 逻辑地址转换为线性地址

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

n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,例如VPN 0选择PTE 0。根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。这里的VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。

具体过程为:

1.     处理器生成一个虚拟地址,并把它传送给MMU;

2.     MMU生成PTE地址,并从高速缓存中请求得到它;

3.     高速缓存向MMU返回PTE;

4.     MMU构造物理地址,并把它传送给高速缓存;

5.     高速缓存返回锁清秋的数据字给处理器;

图7-4 虚拟地址转换为物理地址

7.4 TLB与四级页表支持下的VA到PA的变换

Intel i7的地址翻译用到了TLB与四级页表的技术,其中虚拟地址48位,物理地址52位,页表大小为4KB,页表为4级。

由于页表大小是4KB,所以VPO为12位,VPN就是36位。每一个PTE条目是8字节,所以每一个页表就有512个条目,那么VPN中每一级页表对应的索引就有9位。对于TLB,因为有16组,所以TLBI就是4位,TLBT就是32位。

CPU产生虚拟地址VA,传给MMU,MMU使用前36位作为VPN,在TLB中搜索,如果命中,就得到其中的40位物理页号PPN,与12位VPO合并成52位的物理地址PA。

如果TLB没有命中,MMU就向内存中的页表请求PTE。CR3是一级页表的起始地址,VPN1是第一级页表中的偏移量,用来确定二级页表的起始地址,VPN2又作为第二级页表的偏移量,以此类推,最后在第四级页表中找到对应的PPN,与VPO组合成物理地址PA。

图7-5 四级页表下VA到PA的转换

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

在虚拟地址翻译成物理地址之后,CPU向内存传递物理地址,物理地址分割成标记CT、组索引CI和块偏移CO,先到L1,按照CI、CT、CO的顺序搜索,命中就返回数据,不命中就在下一级缓存L2中寻找,取出CPU要寻找的块,以此类推到L3。

图7-6 三级cache

7.6 hello进程fork时的内存映射

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

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念,

图7-7 私有写时复制

7.7 hello进程execve时的内存映射

execve函数在当前进程中加载并运行包含在可执行目标文件hello中的文件。加载并运行hello需要以下几个步骤:

1.     删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构;

2.     映射私有区域。为新的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零;

3.     映射共享区域。hello与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内;

4.     设置程序计数器。execve设置当前进程上下文的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,将从这个入口点开始执行。

图7-8 通过execve进行内存映射

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

当指令引用一个相应的虚拟地址,而与改地址相应的物理页面不在内存中,就会引发缺页故障。

假设MMU在试图翻译某个地址时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:

1.     检查虚拟地址是否合法。该步骤主要检查虚拟地址是否是指向某个区域结构定义的区域内,即是否在访问范围内。如果不在区域内,访问的页是不存在的,则会报错,并终止进程;

2.     要进行的内存访问是否合法。主要判断是否满足读、写或者执行这个区域内页面的权限。例如,如果是因为试图对一个只读页面进行写操作而引起的缺页中断,那么缺页处理程序会触发一个保护异常,并终止这个程序;

3.     在完成上面两个步骤成功到达第三个步骤时,已经确定了缺页是由于正常的不命中原因引起的,此时便可以从磁盘中将缺失的页读入内存。首先选择一个牺牲页面,如果牺牲页面有被修改过,则将牺牲页面写回磁盘,否则直接还如新的页。

这样,当却也处理程序返回时,CPU会重启引起缺页的指令,而这时就没有了缺页的情况。

图7-9 Linux缺页处理

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址),对于每个进程,内核维护着一个变量brk,它指向堆的顶部。

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

图7-10 堆

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

1. 显式分配器:要求应用显式地释放任何已分配的块。例如,C程序提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++的new和delete操作符与C中的malloc和free相当。

2. 隐式分配器:另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。

显示分配器必须在一些相当严格的约束条件下工作:

1.     处理任意请求序列;

2.     立即响应请求;

3.     只使用堆;

4.     对齐块(对齐要求);

5.     不修改已分配的块。

一个实际的分配器要在吞吐率和利用率之间把握好平衡,就必须考虑一下几个问题:

1.     空闲块组织:如何记录空闲块;

2.     放置:我们如何选择一个合适的空闲块来放置一个新分配的块;

3.     分割:分配完后,如何处理这个空闲块中的剩余部分;

4.     合并:如何处理一个刚刚被释放的块。

有两种比较简单的空闲块组织方式,分别是隐式空闲链表和显式空闲链表。

显式空闲链表的一个块是由一个字的头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小,以及这个块是已分配的还是空闲的。

图7-11 隐式链表

 

图7-12 隐式链表组织示例

当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的。一些常见的策略是首次适配、下一次适配和最佳适配。

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

当分配器释放一个已分配块时,可能产生假碎片, 因此必须合并相邻的空闲块。有两种策略,立即合并和推迟合并。为了使合并块操作简单高效,Knuth提出一种技术,叫做边界标记。在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态。

图7-13 带边界标记的块

考虑当分配器释放当前块时所有可能存在的情况:

1.     前面的块和后面的块都是已分配的;

2.     前面的块是已分配的,后面的块空闲;

3.     前面的块空闲,后面的块已分配;

4.     前面的块和后面的块均已分配。

图7-14 使用边界标记的合并

除了隐式空闲链表以外,一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。

图7-15 显式链表块结构

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。空闲链表中块的排序策略有两种:后进先出(LIFO)和地址顺序。使用后进先出策略,释放和合并一个块可以在常数时间内完成,使用地址顺序维护链表,首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。

7.10本章小结

    本章介绍了hello的存储器的地址空间,介绍了四种地址空间的差别以及地址的相互转换过程。阐述了四级页表的地址翻译和三级cache的物理内存访问原理。介绍了进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理相关内容。

(第7 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

一个 Linux 文件就是一个m字节的序列:B0 , B1 , .... , Bk , .... , Bm-1。

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

8.2 简述Unix IO接口及其函数

Unix I/O接口:

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

2.     Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误;

3.     改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k;

4.     读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k;

5.     关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。

Unix I/O函数:

1.     int open(char* filename,int flags,mode_t mode),进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位;

2.     int close(fd),fd是需要关闭的文件的描述符,close返回操作结果;

3.     ssize_t read(int fd,void* buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量;

4.     ssize_t wirte(int fd,const void *buf,size_tn),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。

8.3 printf的实现分析

查看printf源码:

图8-1 printf源码

printf函数调用了vsprintf函数和write函数,按照参数fmt的格式将匹配到的参数输出,返回字符串的长度。

查看vsprintf函数:

图8-2 vsprintf源码

vsprintf函数的作用是将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。

查看write函数:

图8-3 write源码

write(buf,i)将长度为i的buf输出,期间调用了syscall。

查看syscall函数:

图8-4 syscall函数体

syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),最后结果就显示在屏幕上。

8.4 getchar的实现分析

查看getchar函数:

图8-5 getchar函数源码

getchar函数通过调用read函数返回字符。其中read函数的第一个参数是描述符fd,0代表标准输入。第二个参数输入内容的指针,这里也就是字符c的地址,最后一个参数是1,代表读入一个字符,符号getchar函数读一个字符的设定。read函数的返回值是读入的字符数,如果为1说明读入成功,那么直接返回字符,否则说明读到了buf的最后。

read函数同样通过sys_call中断来调用内核中的系统函数。键盘中断处理子程序会接受按键扫描码并将其转换为ASCII码后保存在缓冲区。然后read函数调用的系统函数可以对缓冲区ASCII码进行读取,直到接受回车键返回。

这样,getchar函数通过read函数返回字符,实现了读取一个字符的功能。

8.5本章小结

本章介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数。

(第81分)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

结论

hello所经历的过程:

1.     用C语言编写hello.c文件。

2.     预处理:对#开头的指令进行解析,通过预处理器(cpp)解释预处理指令,生成hello.i文件。

3.     编译:编译器(ccl)将解释完预处理指令的hello.i文件进行编译,得到汇编程序(文本)hello.s文件。

4.     汇编:汇编器(as)将汇编程序hello.s中的汇编语言转换成机器语言,生成重定位信息,将这些代码和信息生成为一个可重定位目标程序(二进制)hello.o。

5.     链接:由于hello程序调用了printf函数,其存在于一个名为printf.o的单独的预编译好了的目标文件中,所以链接器(ld)将hello.o和printf.o链接,处理合并为hello文件,这就是我们需要的可执行目标文件。

6.     创建进程:在shell命令行输入./hello,shell解释命令并调用fork函数创建一个子进程。

7.     加载程序:加载器调用execve函数,在当前进程(新创建的子进程)的上下文中运行hello程序。

8.     内存管理:运行hello时,内存管理单元MMU、翻译后备缓冲器TLB、多级页表机制、三级cache等计算机中的各个组成部件共同运转和配合,完成对内存地址的解析、请求、返回、访问。

9.     异常处理:如果产生缺页异常,则缺页处理程序选择合适的牺牲页替换,并重新加载相应命令。

10.  结束:当hello运行完毕,向父进程shell发送SIGCHLD信号,提示进程已终止,然后父进程回收hello,内核删除为这个进程创建的所有数据结构。

感悟:

经过计算机系统这门课的学习,思考,实践,我对于计算机系统的运行机制,程序的编译、链接机制;操作系统的相关知识,Linux的相关操作与系统调用函数等。作为一个计算机专业的学生,了解这样的底层知识是必不可少的,了解了这些知识才会对程序有一个正确的认识,才能够写出高质量,高性能的代码。

 

(结论0分,缺失 -1分,根据内容酌情加分)

附件

文件名称

文件作用

hello.c

C程序代码

hello.i

hello.c预处理之后的文本文件

hello.s

hello.i编译后的汇编文件

hello.o

hello.s汇编之后的可重定位目标文件

hello

hello.o与其他可重定位目标文件链接之后的可执行目标文件

hello1.txt

hello.o反汇编生成的文本文件

hello2.txt

hello反汇编生成的文本文件

helloelf.txt

ELF格式下的hello

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

参考文献

为完成本次大作业你翻阅的书籍与网站等

[1]  林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.

[2]  辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.

[3]  赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).

[4]  谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.

[5]  KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.

[6]  CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.

[7]  [转]printf 函数实现的深入剖析 - Pianistx - 博客园 (cnblogs.com).

[8]  (1条消息) 虚拟内存与物理内存的联系与区别_pigff的博客-CSDN博客_虚拟内存和物理内存.

[8]  兰德尔E.布莱恩特,大卫R.奥哈拉伦. 深入理解计算机系统. 北京:机械工业出版社,2016年11月第1版.

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值