目录
摘 要
本文主要通过分析hello.c的一生,从预处理、编译、汇编、链接、进程管理、存储管理和IO管理这几个方面来了解hello.c程序从生成到结束所经历的过程,利用相关工具在Ubuntu下实现hello.c的各种操作。
关键词:hello;计算机系统;P2P;020;预处理;编译;进程管理
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
第1章 概述
1.1 Hello简介
1、P2P
P2P,即From Program to Process。首先,用高级语言编写hello.c文件,经过预处理生成一个hello.i文件,再将hello.i输入编译器,对其编译将生成一个hello.s汇编语言文件,生成的hello.s文件将输入汇编器,翻译成机器语言,产生一个hello.o,也就是可重定位的目标文件,将其经过链接器的链接生成可执行目标程序hello,此时在Linux终端中调用相关指令,创建进程,运行该可执行文件。
2、020
020即From Zero-0 to Zero-0。输入相关指令,操作系统将调用fork函数创建进程,接着调用exceve在进程的上下文中加载并运行hello,然后将进程映射到虚拟内存,并载入物理内存。执行时,CPU在流水线上依次执行每一条指令。执行完成后,父进程回收hello进程,内核删除相关数据结构,hello进程重新回归0。
1.2 环境与工具
硬件环境:X64 CPU;2.10GHz;16.0 GB RAM
软件环境:Windows10 64位;Oracle VM VirtualBox;Ubuntu 20.04.4
开发与调试工具:gcc;gdb;edb;Visual Studio 2022 64位;objdump;
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
中间文件名 | 文件的作用 |
hello.i | 预处理后生成的文件 |
hello.s | 编译后生成的文件 |
hello.o | 汇编后生成的可重定位目标程序 |
hello | 链接之后生成的可执行程序 |
elf.txt | hello经过readelf分析得到的文本文件 |
hello.asm | hello.o经过objdump反汇编得到的文本文件 |
hello.elf.txt | hello经过readelf分析得到的文本文件 |
hello_obj.txt | hello经过objdump反汇编得到的文本文件 |
1.4 本章小结
本章主要对hello在执行的过程中的总体流程进行了简要的概述,简要介绍了实验中使用的软硬件条件以及实验中产生的文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
1、概念:预处理是C语言的一个重要功能,它由预处理程序负责完成。当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译,预处理器执行以#开头的命令、删除注释等来修改c程序生成.i文件
2、作用:
(1)文件包含:#include”文件名”命令的作用是把指定的文件模块内容插入到#include所在的位置,当程序编译链接时,系统会把所有#include指定的文件链接生成可执行代码。比如hello.c的第一行的#include<stdio.h>指示预处理器打开一个名字为stdio.h的文件,并将它的内容加到当前的程序中。
(2)宏定义:#define指令,用符号表示的实际值替换定义的符号
(3)条件编译:#if,#ifdef,#ifndef,,#elif,#else 和#endif 指令可以根据编译器可以测试的条件来将一段文本包含到程序中或排除在程序之外。
(4)特殊符号:例如#error等,预编译程序可以识别一些特殊的符号,并在后续过程中进行合适的替换。#error,#line和#pragma这些特殊指令较少用到。
(5)删除c语言源程序中的注释部分
2.2在Ubuntu下预处理的命令
指令cpp hello.c > hello.i(或gcc -E hello.c -o hello.i)之后生成预处理后的文件hello.i
2.3 Hello的预处理结果解析
1、头文件:
hello.i文件扩展至3000多行,可以看出前面绝大部分前面是hello引用的头文件stdio.h, unistd.h , stdlib.h内容的展开。除了这三个头文件的内容,还出现了其他的头文件,这是因为这三个头文件中同样使用#include命令引入了其他的头文件,这些头文件也出现在了hello.i文件中。
2、源代码:
从hello.i文件的3000多行之后,可以看到hello.c中的main函数:
3、在源代码头部出现的注释在预处理之后的源代码部分已经不可见,可以看出在预处理过程中预处理器会删除源代码中的注释部分。
2.4 本章小结
本章首先了解了预处理的概念和功能,以及Ubuntu下的预处理指令;并且对hello.c文件执行了预处理命令,得到了hello.i文件,对.i文件进行解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
1、概念:编译就是把预处理之后的文件进行一系列词法分析、语法分析、语义分析以及优化后生成的相应汇编代码文件,即从.i文件产生 .s文件的过程。
2、作用:
(1)词法分析:对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序;
(2)语法分析:语法分析器使用由词法分析器生成的各词法单元的第一个分量来创建树形的中间表示。该中间表示给出了词法分析产生的词法单元的语法结构。常用的表示方法是语法树;
(3) 语义分析与中间代码产生:语义分析器使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致 。它同时收集类型信息,并存放在语法树或符号表中,以便在中间代码生成过程使用;中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现;
(4)代码优化:代码优化试图改进中间代码,以便生成更好的目标代码;
(5)目标代码:目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
生成了hello.s文件
3.3 Hello的编译结果解析
3.3.1汇编初始部分
节名称 | 作用 |
.file | 声明源文件 |
.text | 代码节 |
.section.rodata | 只读数据段 |
.globl | 声明全局变量 |
.type | 声明一个符号是函数类型还是数据类型 |
.size | 声明大小 |
.string | 声明一个字符串 |
.align | 声明对指令或者数据的存放地址进行对齐的方式 |
3.3.2数据
- 常量:
①程序中有两个字符串常量,这两个字符串都在只读数据段中;
.LC0它存储的是我们第一条printf语句打印的字符串"用法: Hello 学号 姓名 秒数!\n"。另一个是.LC1,它是第二条printf语句所打印的字符串,其中的%s是占位符。
②立即数
(2)局部变量:局部变量可以储存在栈中的某一个位置,或是直接储存在寄存器中,hello.c里面的局部变量共有三个,一个是循环变量i,以及argc和argv,对于i,我们发现它储存在栈中地址为-4(%rbp)的位置:
对于argc,main函数的参数argc存在寄存器%edi中,并将其地址压入栈中,利用%rbp的偏移量来表示:
对于argv,数组char *argv[]是main函数的第二个参数,每个元素都是一个指向字符类型的指针。数组的起始地址存放在栈中-32(%rbp)的位置;
3.3.3操作
(1)赋值
mov指令根据操作数的字节大小分为:
movb:1字节
movw:2字节
movl:4字节
movq:8字节
将立即数0赋值给地址为%rbp-4的变量i,其占用4个字节:
(2)类型转换
调用atoi函数将字符型转为int整型变量
(3)算术操作
循环变量i的值在每一轮的循环中都要修改
(4)关系操作
代码中if、elif、else等条件函数,其汇编代码的呈现形式就是判别跳转,cmpl指令是将后面两个参数进行比较,je表示相等跳转,还有类似的如js、ja、jne、jbe等;
(5)数组、指针、结构操作
代码中出现了 argv数组,通过观察可以发现argv储存的两个值都存放在栈中,argv[1]的储存地址是-24(%rbp),而argv[1]的储存地址是-16(%rbp):
for循环体中sleep函数参数为atoi(argv[3])
(6)控制转移
if语句:判断argc是否为4,若为4,则执行if体的语句,若不是,则不执行:
for循环体:for(i=0;i<8;i++),每次循环前判断i是否小于8,若满足,则执行循环体,否则不执行:
循环体汇编代码:
(7)函数调用
①printf函数:
参数:第一次调用的时候只传入了字符串参数首地址;for循环中调用的时候传入了 argv[1]和argc[2]的地址。
第一次传入的是.LC0处的字符串:
第二次传入的是.LC1处的字符串以及argv[1]和argv[2]:
②main函数
参数:argc和argv,argc存储在%edi中,argv存储在%rsi中。返回值为int类型,存储在%eax中。
③atoi函数
参数:argv[3],将字符串转为整型
④exit函数:
参数:传入的参数为1,执行退出命令。
调用:当if条件满足的时候调用这一函数。
⑤sleep函数
参数:参数是atoi(argv[3]),储存在%edi中,这一函数在for循环的条件下被调用:
⑥getchar函数
无参数:
3.4 本章小结
本章主要介绍了编译阶段编译的基本概念和作用,函数从源代码变为等价的汇编代码;对hello.c程序的编译过程进行了截图展示,分别从C语言的数据,赋值语句,类型转换,算术操作,关系操作,控制转移与函数操作这几点进行分析, 加深了对编译结果的理解和运用。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:把汇编语言翻译成机器语言的过程称为汇编。汇编器将hello.s翻译成机器语言指令,把这些指令打包成一种叫可重定位目标程序的格式,并将结果保存在目标文件hello.o中。
作用:将汇编代码根据特定的转换规则转换为二进制代码,也就是机器代码,使其在链接后能被机器识别并执行。
4.2 在Ubuntu下汇编的命令
指令:gcc hello.s -c -o hello.o
生成了hello.o文件:
4.3 可重定位目标elf格式
命令:readelf -a hello.o > ./elf.txt,可以导出我们需要的elf文件;
4.3.1 ELF头
ELF header存储着一些机器和该ELF文件的基本信息,该ELF头以一个16字节的序列开始,该序列描述了生成文件系统下的字的大小以及一些其他信息。还包含了帮助链接器语法分析和解释目标文件的信息:ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
4.3.2节头部表
记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
4.3.3重定位表
包含了在代码中使用的一些外部变量等信息,在链接的时候需要根据重定位节的信息对这些变量符号进行修改。链接的时候链接器会根据重定位节的信息对外部变量符号决定选择何种方法计算正确的地址,通过偏移量等信息计算出正确的地址。
调用外部函数的指令需要重定位;引用全局变量的指令需要重定位;调用局部函数的指令不需要重定位;在可执行目标文件中不存在重定位信息。在本程序需要被重定位的是printf、puts、exit、sleepsecs、getchar、sleep和.rodata中的.L0和.L1。
4.3.4符号表
.symtab,存放程序中定义的全局变量和函数的信息。name记录目标名称,value记录符号地址,size记录目标大小,type记录目标类型,是函数还是数据,bind表示全局还是本地。
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o > hello.asm分析hello.o的反汇编
生成的hello.asm文件如下:
将hello.asm和hello.s对比,得到以下结论:
- 进制不同:
hello.asm:数字的表示是十六进制的
hello.s:数字的表示是十进制的
- 跳转指令不同:
hello.asm:
hello.s:
- 对函数的调用不同:
hello.asm:call指令使用的是main函数的相对偏移地址
hello.s:call指令后跟的是需要调用的函数的名称
4.5 本章小结
本章对hello.s进行了汇编,生成了hello.o可重定位目标文件,并且分析了该文件的ELF头、节头部表、符号表和可重定位节;又生成了比较了hello.o反汇编文件hello.asm,并且比较了hello.asm和hello.s代码的不同之处,对汇编知识有了更加深入的了解。
(第4章1分)
第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
生成了hello文件:
5.3 可执行目标文件hello的格式
命令:readelf -a hello > hello.elf.txt
生成了hello.elf.txt文件
5.3.1ELF头
打开hello.elf.txt文件,可以发现ELF头中的type为EXEC,即hello为可执行目标文件,同时hello的入口地址非零,说明重定位工作已经完成;hello.elf.txt文件中节头表的条目数量为27,比hello.o文件生成的elf.txt中节头表的条目数量多。
5.3.2节头部表
描述了各个节的大小、偏移量和其他属性。与elf.txt相比内容更为丰富详细:
5.3.3重定位表
5.3.4符号表
存放程序中定义的函数和局部变量的信息
5.4 hello的虚拟地址空间
使用edb打开hello可执行文件,可以在edb的Data Dump窗口看到hello的虚拟地址空间分配的情况,查看各段信息:
观察上图,可以看出:这一段程序的地址是从0x401000开始的,到0x400fff结束;可以通过edb找到各个节的信息;
从5.3.1的ELF头中可以看出程序的入口地址为0x4010f0,对应于节头表中.text节的起始地址:虚拟地址开始于0x4010f0,大小为0x145:
5.3.2的节头表中的.interp节的起始地址为0x4002e0。
根据5.3.2节头表中的各个节的位置信息可以找到各个节在内存中的位置。比如.rodata节的起始位置为0x402000。
我们也可以通过Symbols Viewer查看具体节点位置:
5.5 链接的重定位过程分析
命令:objdump -d -r hello > hello_obj.txt
生成了hello_obj.txt文件
打开该文件:
5.5.1 hello与hello.o的不同:
(1)hello.o的地址从0开始,而hello从0x401000开始。这说明hello.o还未实现重定位的过程,每个符号还没有确定的地址,而hello已经实现了重定位,每个符号都有其确定的地址;
hello:
hello.o:
(2)hello中链接加入了代码中调用的库函数,例如getchar、puts、printf、exit等,同时每一个函数都有了相应的虚拟地址;
(3)hello中增加了.init和.plt节,和一些节中定义的函数;
(4)在hello的main函数中,条件跳转指令和call指令后均为绝对地址,而hello.o中是相对于main函数的相对地址;
hello:
hello.o:
5.5.2链接的过程
链接主要分为两个过程:符号解析和重定位。
符号解析:目标文件定义和引用符号,符号解析将每个符号引用和一个符号定义关联起来。
重定位:编译器和汇编器生成从0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。
5.5.3重定位过程
当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目放在.rel.text节中,已初始化数据的重定位条目放在.rel.data中;
ELF中常用的R_X86_64_PC32重定位类型,该重定位方法:
例子:
通过查询hello的ELF文件内容得到main函数地址为0x401125,.rodata的地址为0x402008。将数据带入运算可得:*refptr = (unsigned)(ADDR(.rodata)+addend - ADDR(main)- Offset)=(unsigned)(0x402008 + 0x22 – 0x401125-0x54) =(unsigned)(0xeb1)
同理可以得到其他的重定位条目。
5.6 hello的执行流程
使用edb执行hello:
在main中顺序调用了一系列函数:
调用到libc-2.31.so!exit,退出程序。
程序名 | 地址 |
_init | 0x401000 |
_puts@plt | 0x401030 |
_printf@plt | 0x401040 |
_getchar@plt | 0x401050 |
_atoi@plt | 0x401060 |
_exit@plt | 0x401070 |
_sleep@plt | 0x401080 |
_start | 0x4010f0 |
main | 0x401125 |
_libc_csu_init | 0x4011c0 |
_libc_csc_fini | 0x4011c0 |
_fini | 0x401238 |
在终端打开edb,执行hello,右键点击analyze here,出现以下信息:
5.7 Hello的动态链接分析
动态链接的基本思想:把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序。当程序调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。编译系统提供了延迟绑定的方法。
延迟绑定是通过GOT和PLT实现的,将过程地址的绑定推迟到第一次调用该过程时。通过GOT和过程链接表PLT的协作来解析函数的地址。在加载时,动态链接器会重定位GOT中的每个条目,使它包含正确的绝对地址,而PLT中的每个函数负责调用不同函数。那么,通过观察edb,便可发现dl_init后.got.plt节发生的变化。
根据hello.elf.txt文件可知,GOT起始表位置为0x404000
链接前的.got.plt表中内容:
执行链接后:
从图中可以看出,在dl_init调用之后,该处的两个8字节的数据都发生了改变。
5.8 本章小结
本章主要介绍了链接的概念与作用,链接可分为符号定义和重定位,了解了可执行文件的ELF格式,利用edb、gdb、objdump等工具对链接的ELF文件、虚拟地址空间、重定位过程等进行详细分析,分析了hello的执行过程和动态连接过程,对链接有了更深的理解。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用:在现代系统上运行一个程序时,我们会得到一个假象,好像我们的程序是系统中唯一运行的程序一样。我们的程序好像独占处理器和内存。处理器好像无间断地一条接一条执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象是通过进程的概念提供的。进程提供给应用程序的关键抽象:一个独立的逻辑控制流,提供一个程序独占处理器的假象;一个私有的地址空间,提供一个程序独占地使用内存系统的假象。
6.2 简述壳Shell-bash的作用与处理流程
shell是是一个交互型应用级程序,它接收用户命令,然后调用相应的应用程序。shell是系统的用户界面,提供了用户与内核进行交互操作的接口。
作用:shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行,求值步骤解析命令行,并根据解析结果运行程序。
处理流程:
①从终端读入输入的命令。
②分析命令行字符串,获取命令行参数。
③检查第一个命令行参数是否是一个内置的shell命令,如果是则立即执行。
④如果不是内部命令,调用fork( )创建新进程/子进程执行指定程序。
⑤判断为前台程序还是后台程序,如果是前台程序则直接执行并等待执行结束,如果是后台程序则将其放入后台并返回。
⑥对键盘输入的信号和其他信号有特定的处理。
⑦回收僵死进程。
6.3 Hello的fork进程创建过程
当在shell上输入./hello命令时,命令行会首先判断该命令是否为内置命令,如果是内置命令则立即对其进行解释;否则将其看成一个可执行目标文件,再调用fork创建一个新进程并在其中执行。
如图,输入以下指令,hello不是内置命令,此时shell会fork一个子进程,子进程得到与父进程用户级虚拟地址空间相同的但是虚拟地址独立、PID也不相同的一份副本。
6.4 Hello的execve过程
exceve函数在当前进程的上下文中加载并运行一个新程序。exceve函数加载并运行可执行目标文件,并带参数列表和环境变量列表。只有当出现错误时,exceve才会返回到调用程序。与fork一次调用返回两次不同,在exceve调用一次并从不返回。加载并运行需要以下几个步骤:
- 删除已存在的用户区域;
- 映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构;
- 映射共享区域:将共享链接到程序的对象映射到用户虚拟地址空间中的共享区域;
- 设置程序计数器(PC):设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。
6.5 Hello的进程执行
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程:
1)保存当前进程上下文;
2)恢复某个先前被抢占的进程被保存的上下文
3)将控制转移给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换,这时就存在着用户态与核心态的转换
在一个程序被调运行开始到被另一个进程打断,中间的时间就是运行的时间片。
6.6 hello的异常与信号处理
6.6.1hello正常执行
程序完成之后被回收:(名字用首字母缩写代替)
6.6.2 Ctrl-Z
(1)键盘上按下Ctrl-Z,发出SIGTSTP信号,程序中断:
(2)输入ps,打印出各个进程的pid,可以看到之前挂起的进程hello:
(3)输入jobs
打印出被挂起进程组的jid,可以看到被挂起的hello,以及状态Stopped:
(4)输入pstree:
(5)运行fg 1:可以把之前挂起在后台的hello重新调到前台来执行,打印出剩余部分,然后输入hello回车,程序运行结束,进程被回收。
(6)运行kill
重新执行进程,按下Ctrl-Z后运行ps,查找hello的pid,通过kill -9 9788 来杀死hello进程:
6.6.3按下Ctrl-C
运行时按下Ctrl-C,进程收到 SIGINT 信号,进程停止,输入ps查看,可以看到 hello已经被彻底结束:
6.6.4乱按
将屏幕的输入缓存到缓冲区。乱码被认为是命令,不影响当前进程的执行。
在乱按的时候加上回车,会被认为是输入了一条指令,不影响程序执行:
6.7本章小结
本章介绍了进程的概念和作用,以及shell的作用和处理流程,分析了hello可执行文件的执行过程,包括进程创建、加载和终止,以及通过键盘输入等过程。从创建进程到进程并回收进程,在这个过程中,内核对其调度,异常处理程序为其将处理各种异常。每种信号都有不同的处理机制,对不同的shell命令,hello也有不同的响应结果。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址指由程序产生的与段相关的偏移地址部分,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。
线性地址:是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生逻辑地址,hello的反汇编文件中看到的地址(即逻辑地址)中的偏移量,加上相应段的基地址,便得到了对应的线性地址。
虚拟地址:有时我们也把逻辑地址称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。如下图,左侧的地址即虚拟地址:
物理地址:是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。虚拟地址经过地址翻译得到物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段选择符和段内偏移量组成,段标识符selector占16位,也称为段选择符。其中前13位是索引号,后三位表示是哪种类型的寄存器:代码段寄存器、数据段寄存器、栈寄存器,段偏移量offset占32位。可以通过索引号在段描述表中找到一个具体的段描述符,描述了一个段。给出逻辑地址后,通过段选择符中的T1字段确定是全局段描述表还是局部段描述表,之后通过索引号找到具体的段描述符,得到其基地址,再加上相对位置偏移,完成了从逻辑地址到线性地址的变换,即完成了段式管理。
其中,段首地址存放在段描述符中。而段描述符存放在描述符表中,也就是GDT或LDT中。
段式管理特点:
1.段式管理以段为单位分配内存,每段分配一个连续的内存区。
2.由于各段长度不等,所以这些存储区的大小不一。
3.同一进程包含的各段之间不要求连续。
4.段式管理的内存分配与释放在作业或进程的执行过程中动态进行。
7.3 Hello的线性地址到物理地址的变换-页式管理
通过分页机制实现线性地址(虚拟地址VA)到物理地址(PA)之间的转换。页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
n位的线性地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN);
首先,MMU利用VPN选择适当的PTE,根据PTE,我们知道虚拟页的信息。TLB从VPN中抽取出TLBI和TLBT,查找对应组中是否有匹配的条目。若命中,直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。若不命中,MMU需从页表中的PTE中取出PPN,若得到的PTE无效或标记不匹配,就触发一个缺页故障,调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。若有效,取出PPN,将线性地址中的VPO与PPN连接起来就得到了对应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1 利用TLB表加速地址翻译
每次 CPU 产生一个虚拟地址,MMU 就必须查阅一个PTE,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会要求从内存多取一次数据,代价是几十到几百个周期。如果 PTE 碰巧缓存在LI 中,那么开销就下降到1个或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在 MMU 中包括了一个关于PTE 的小的缓存,称为TLB。
TLB 缓存了虚拟页号到物理页号的映射关系;我们可以把 TLB 简化成存储着键对值的哈希表。MMU 会先把虚拟页号作为键去查询 TLB 中的缓存项,如果未命中则再去多级页表中查询,反之则直接返回。
7.4.2多级页表
将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。
7.4.3 TLB与四级页表支持下VA到PA的变换
从虚拟地址中的VPN 1开始在一级页表中获取二级页表基址,结合虚拟地址中VPN 2字段得到三级页表基址,结合虚拟地址中VPN 3字段得到四级页表基址,结合虚拟地址中VPN 4字段得到PPN,结合虚拟地址中VPO字段,组合成最终的物理地址,实现从虚拟地址VA到物理地址PA的地址翻译。
7.5 三级Cache支持下的物理内存访问
物理地址(52位)被分割为40位的缓存偏移CT,6位的缓存组索引CI,6位的缓存标记CO。
以L1高速缓存为例:
MMU将物理地址发给L1缓存,缓存从物理地址中去除CO、CI以及CT,根据CI在L1中寻找相应的组:若缓存中CI所指示的组有标记与CT匹配的条目且有效位已经设置时,则检测到一个缓存命中,读出在偏移量CO处的数据字节,并把它返回给MMU,随后MMU将它传递给CPU。缓存不命中,则需到低一级Cache(若L3 cache中找不到则到主存)中取出相应的块将其放入当前cache中,重新执行对应指令,访问要找的数据。
7.6 hello进程fork时的内存映射
(1) 当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
(2) 当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
加载并运行hello需要以下几个步骤:
(1)删除已存在的用户区域
删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有空间
为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零,下图概括了私有区域的不同映射。
(3)映射共享区域
如果hello程序与共享对象或目标链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器PC
execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
7.8.1缺页故障
缺页故障是一种常见的故障,当指令引用一个虚拟地址,在 MMU 中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。
7.8.2缺页中断处理
处理缺页是由硬件和操作系统内核协作完成的:
(1)处理器生成一个虚拟地址,并将它传送给MMU
(2)MMU生成PTE地址,并从高速缓存/主存请求得到它
(3)高速缓存/主存向MMU返回PTE
(4)PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
(5)缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它放入虚拟内存。
(6)缺页处理程序页面调入新的页面,并更新内存中的PTE
(7)缺页处理程序返回到原来的进程,重新执行导致缺页故障的命令,CPU将引起缺页的虚拟地址重新发送给MMU,重新在内存管理单元中查找页表。因为虚拟页面已经换存在物理内存中,所以就会命中。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
7.9.1动态内存分配器
(1)动态内存管理的基本方法与策略:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆heap,系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高地址)。对于每个进程,内核维护着一个变量brk,它指向对的顶部。
(2)分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个个连续的分配器将的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显示地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显示地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显示执行的。要么是内存分配器自身隐式执行的。
(3)分配器由两种基本风格,两种风格都要求应用显式地分配块。它们的不同之处在于由那个实体来负责释放已分配的块。
显示分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。
隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器。
7.9.2隐式空闲链表
一个块是由一个字的头部、有效载荷,以及可能的填充组成。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。块的头最后一位指明这个块是已分配的还是空闲的。
具体的隐式空闲链表形式如下:
放置已分配的块当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。分配器搜索方式的常见策略是首次适配、下一次适配和最佳适配。在释放一个已分配块的时候需要考虑是否能与前后空闲块合并,减少系统中碎片的出现。
①首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块
②下一次适配:从上一次查询结束的地方开始搜索空闲链表,选择第一个合适的空闲块
③最佳适配:检查每个空闲块,选择适合所需请求大小的最小空闲块
当分配器找到一个匹配的空闲块时,通常将空闲块分割为两部分。第一部分变为了已分配块,剩下的部分变为了空闲块。利用边界标记,可以允许在常数时间内进行对前面块的合并。
7.9.3显式空闲链表
根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里,例如堆可以组织成一个双向的空闲链表,在每个空闲块中,都包含一个前驱和后继指针。
使用双向链表使得首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的也可以是常数,这取决于我们所选择的空闲链表中块的排序策略:
一种方法是后进先出LIFO的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用的块,释放一个块可以在常数时间内完成。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常驻时间内完成;
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址,这种情况下释放一个块需要线性时间的搜索来定位合适的先驱。
7.10本章小结
本章主要介绍了以hello进程执行过程为例,通过对比分析阐述了逻辑地址、线性地址、虚拟地址和物理地址的概念,然后详细介绍了虚拟内存与物理内存之间的转换关系,同时介绍了在发生缺页异常的时候系统将会如何处理这一异常。最后介绍了动态内存分配的作用以及部分方法与策略。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m个字节的序列:00101010011110…
设备的模型化:文件。所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入输出都被当做对相应文件的读和写来执行。
设备管理:unix io接口。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口:
(1) 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符;
(2)Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量 STDIN_FILENO、STDOUT_FILENO 和 STDERR_FIIENO,它们可用来代替显式的描述符值;
(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) 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.1 Unix IO接口:
(1)打开文件
函数原型:int open(char *filename, int flags, mode_t mode)
函数作用:进程通过调用open函数来打开一个已存在的文件或创建一个新文件。
(2)关闭文件
函数原型:int close(int fd)
函数作用:调用close函数,通知内核结束访问一个文件,关闭打开的一个文件。成功返回0,出错返回-1。
(3)读文件read()
函数原型:ssize_t read(int fd, void *buf, size_t n)
函数作用:应用程序通过调用read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示错误,返回值0表示EOF,否则返回值表示的是实际传送的字节数量。
(4)写文件write()
函数原型:ssize_t write(int fd, const void *buf, size_t n)
函数作用:应用程序通过调用write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。返回值-1表示出错,否则,返回值表示内存向文件fd输出的字节的数量。
8.3 printf的实现分析
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
8.3.1printf函数
va_list的定义:typedef char *va_list ,这说明它是一个字符指针。
其中的: (char*)(&fmt) + 4) 表示的是...中的第一个参数。
可以发现printf的输入参数是fmt,但是后面是不定长的参数,同时在printf内存调用了两个函数,一个是vsprintf,一个是write。
8.3.2vsprintf(buf, fmt, arg)
vsprintf函数将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。vsprintf的作用就是格式化,它接受确定格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
8.3.3write函数
这里是给几个寄存器传递了几个参数,然后以一个int结束。
一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
8.3.4sys_call
call save,是为了保存中断前进程的状态。
ecx中是要打印出的元素个数;
ebx中的是要打印的buf字符数组中的第一个元素 ;
这个函数的功能就是不断的打印出字符,直到遇到:'\0' ;
[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
8.3.5字符显示驱动子程序
通过ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
8.4.1getchar函数:
介绍:getchar()是stdio.h中的库函数,是读入函数的一种。它从标准输入里读取下一个字符,相当于getc(stdin)。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。
实现:程序调用getchar后,等待用户按键,将输入的字符储存在电脑的缓冲区,等待键入回车。键入回车后,getchar从stdin流中每次读取一个字符,如果输入不止一个字符,则保存在缓冲区中,等待后需getchar调用,直到缓冲区清空后,等待用户按键。
8.4.2实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了 Linux的IO设备管理方法、UNIX I/O的接口及其函数的具体内容,分析了printf函数和getchar函数的内部底层实现。
(第8章1分)
结论
- hello程序经历的过程
(1)在编辑器上用C语言编写hello.c
(2)预处理:hello.c经过预处理器的预处理得到hello.i文本文件
(3)编译:hello.i经过编译器的编译得到hello.s汇编文件
(4)汇编:hello.s经过汇编器的汇编得到hello.o可重定位目标程序二进制形式文件
(5)链接:hello.o链接生成可执行目标程序hello,是二进制形式文件
(6)创建子进程:在shell中使用指令./hello运行hello时,shell会调用fork创建进程、调用execve加载hello程序
(7)加载程序:子进程中调用execve函数,加载hello程序;
(8)运行阶段:
- 信号管理:内核负责调度进程,并对可能产生的异常及信号进行处理;
- 访问内存及动态申请内存:计算机通过使用三级Cache架构提高数据访存速率,并通过对内存空间的段式管理和页式管理完成从逻辑地址到线性地址再到物理地址的转换,通过使用TLB和多级表结构加快由物理地址获取数据的速率。
- 交互:hello的输入输出与外界交互,与linux I/O息息相关;
(9)终止:当hello程序运行结束后,父进程shell会将其回收,内核也会清除在内存中为hello程序创建的各种数据结构和信息。
2、对计算机系统的设计与实现的感悟
本次大作业以hello.c为起点,从预处理、编译、汇编、链接、进程管理、存储管理和IO管理这几个方面来了解hello.c程序从生成到结束所经历的过程,分析了hello.c的一生,加深了我对程序实现过程的理解,同时也帮助我巩固了所学知识。
在这次大作业中我意识到了一个复杂的系统需要多方面的协作配合才能更好地实现功能,也理解了计算机系统设计与实现的基础就是准确,保证了准确的同时,进行各方面的优化使其执行的更高效。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
中间文件名 | 文件的作用 |
hello.i | 预处理后生成的文件 |
hello.s | 编译后生成的文件 |
hello.o | 汇编后生成的可重定位目标程序 |
hello | 链接之后生成的可执行程序 |
elf.txt | hello经过readelf分析得到的文本文件 |
hello.asm | hello.o经过objdump反汇编得到的文本文件 |
hello.elf.txt | hello经过readelf分析得到的文本文件 |
hello_obj.txt | hello经过objdump反汇编得到的文本文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[2] CSDN博客 ELF可重定位目标文件格式
[3] 深入理解计算机系统原书第3版
[4]read和write系统调用以及getchar的实现_Vincent's Blog的博客-CSDN博客_getchar实现
[5]预处理_百度百科
[6] Linux进程地址空间缺页异常的几种处理方法(含代码演示) - 知乎
[7] linux内核学习 虚拟内存- MMU 和 TLB - 多级页表 - 知乎
[8] 进程调度 - 知乎
(参考文献0分,缺失 -1分)