计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L020302
班 级 2003010
学 生 lijiaying
指 导 教 师 郑贵滨
计算机科学与技术学院
2021年5月
本次大作业以hello程序为例,通过将源文件hello.c文件变为hello可执行文件的,再到最终结束的过程,来描述程序是如何进行预处理,编译,汇编和链接等方面的内容, 介绍了hello程序是如何加载到内存中并被计算机执行的,包括进程创建、环境创建、内存访问、异常处理、I/O系统交互等。通过hello程序的一生,我们研究了程序从源代码到运行的整个过程,了解现代计算机系统的结构与工作原理,使我们对计算机系统的理解更加深入了。
关键词:预处理;编译;汇编;链接;进程;存储; I/O;
目 录
5.3.4 Section to Segment mapping. - 26 -
6.2 简述壳Shell-bash的作用与处理流程... - 36 -
6.3 Hello的fork进程创建过程... - 36 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 45 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 45 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 46 -
7.5 三级Cache支持下的物理内存访问... - 47 -
7.6 hello进程fork时的内存映射... - 47 -
7.7 hello进程execve时的内存映射... - 48 -
第1章 概述
1.1 Hello简介
P2P(From Program to Process)过程:
- 使用高级语言编写得到hello.c(Program)
- 使用预处理器进行预处理可以得到hello.i文件
- 对其编译得到hello.s汇编程序
- 经过汇编器将hello.s文件翻译成机器语言指令,把这些指令打包成为可重定位的目标程序的格式,将结果存在目标文件hello.o中。
- 通过链接器与printf.o链接得到可执行文件hello
- 执行此文件hello,当在shell中输入./hello,shell调用fork为hello程序创建一个新进程(Process)。
020(From Zero-0 to Zero-0)过程(即程序从无到有最终又被清0的整个过程):
- 程序由最初的不存在到经历了上述P2P后,由shell为其execve
- 映射虚拟内存,并且载入物理内存。
- 再进入 main函数执行目标代码,并且CPU为hello分配时间片执行逻辑控制流。
- 当程序运行结束后,输出的内容会从主存复制到寄存器文件中,然后从寄存器复制到显示设备上,最终会在屏幕上呈现
- hello进程终止,由shell回收,删除相关数据(0)。
1.2 环境与工具
硬件环境:X64 CPU;2.40 GHz;8G RAM; 1.7THD Disk
软件环境:Windows10 64位;VirtualBox ;Ubuntu 20.04.2 LTS
开发与调试工具:Codeblocks;Objdump ,gcc,vim,edb,readelf,HexEdit
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 作用
hello.c 源程序
hello.i 预处理后的文件
hello.s 编译后生成的文件
hello.o 汇编后的可重定位目标执行文件
helloelf.txt 存放使用readelf工具查看hello.o文件的结果的文件
helloobjdump.txt hello.o的反汇编代码
hello 链接后的可执行文件
hello1.elf 存放使用readelf工具查看hello文件的结果的文件
hellobj.txt hello的反汇编代码
1.4 本章小结
本章介绍了hello的P2P,020过程,以及进行实验时的软硬件环境及开发与调试工具和在本论文中生成的中间结果文件。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器(cpp)根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,结果得到另一个C程序,通常以.i为文件扩展名。
作用:
1. 处理头文件,如#include。该指令将头文件中的定义统统都加入到它所产生的输出文件中,使编译程序对之进行处理
2. 处理条件编译指令如#ifdef,#ifndef,#else,#elif,#endif等。伪指令的引入使得可以通过不同定义的宏来决定编译程序处理的代码。预编译程序依据有关文件,过滤掉不必要的代码。
3. 处理宏定义指令预处理器根据#if和#ifdef等编译命令及其后的条件,将源程序中的某部分包含进来或排除在外。
4.处理特殊符号,预编译程序对于在源程序中出现的特殊符号用合适的值进行替换。
2.2在Ubuntu下预处理的命令
命令:gcc hello.c -E -o hello.i
2.3 Hello的预处理结果解析
原hello.c内容:
预处理后hello.i内容:
我们可以看出内容明显增多。
具体增加的具体内容如下
- hello.i文件对hello.c程序中的宏进行了宏展开
- 将头文件中的内容添加到进该文件中。例如声明的函数、定义的结构体、定义的变量、定义的宏等内容。
- 如果代码中有#define命令还会对相应符号进行替换
2.4 本章小结
本章主要介绍了预处理相关的概念、作用,对如何使用gcc对c程序文件进行预处理进行了示范,并结合预处理得到的文件对预处理过程进行了分析。
第3章 编译
3.1 编译的概念与作用
概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。编译的实质是把预处理文件进行语法检查、词法分析、语义分析、优化源程序、分配寄存器的使用等,使C语言等高级语言转换成机器更好理解的汇编语言程序,但转换后的文件仍为ASCII文本文件。
作用:编译后生成的.s汇编语言程序文本文件比预处理文件更容易让机器理解,也比.o可重定位目标文件更容易让程序员理解,并确保了每个模块中每个局部符号只有一个定义、唯一的名字,对无法解析的全局符号生成一个连接器符号表条目等,是对于程序向机器指令的关键一步。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1 数据类型
(1)对于C语言的常量,编译器按照相应类型进行编码:
整型数据:编译器将其转化为此数据的十六进制形式
字符串类型:编译器会根据字符的ASCII编码或是Unicode编码生成十六进制数
(2)对于局部变量:i;main函数的参数argc,argv
1.局部变量int类型的i:主要起计数作用,存储在运行时的栈中,地址为%rbp-4
如图,首先对i变量赋初值为0
在每次循环时对i增加1,cmpl比较i和7的大小来决定什么时候结束循环。因此局部变量i是存放在栈上的,并通过相对栈顶(%rsp)的偏移量来访问
2. main函数的参数:int型的argc和字符型指针数组的指针argv:
可以看到,argc开始存储在寄存器rdi中,比较过程中放在了栈中,帧指针为%rbp-20,argv开始存储在寄存器rsi中,使用时放在栈中,帧指针为%rbp-32
3.3.2 赋值
对编译器来说,赋值操做就是将数据从一个位置复制到另一个位置。进行这些操做的指令叫做数据传送指令。逗号操作符会将一行代码分割成几个独立的赋值操做。对于赋初始值的变量编译器会在首次出现这个变量时为其赋相应值。对于没有赋初始值的变量,编译器有两种处理方式:1)相当于赋初始值为0,即在首次出现这个变量时为其赋值0;2)不进行处理,直到为其赋值时再申请内存并赋值。
本程序中进行了对循环变量i的赋值,对于c语言中的i=0,使用的是movl指令进行赋值操作:
3.3.3 类型转换(隐式或显式)
对于整型与整型、整型与字符型之间的类型转换,直接按照位级表示截断或者扩展。对于整型与浮点类型之间的转换,由编译器根据表示的值来进行转换。
类型转换分为显式和隐式转换,其中显式转换为程序员显式地利用 () 在代码中展现了出来,而隐式转换由编译器编译时自行完成,不需要程序员的参与。
这里为显式类型转换。
3.3.4 sizeof
sizeof的结果由编译器据源代码的数据类型得出,编译器在编译阶段确认类型后直接将其表示为常数。
3.3.5 算数操作
算数操作有:+ - * / % ++ -- 取正/负+- 复合“+=”等
此程序中有i++,其在hello.s中为addl
3.3.6 关系操作
关系操作有:==,!= ,> ,< ,>= ,<=等
关系操作通过CMP或TEST指令设置条件码,例如CF、ZF、SF、OF。其中,关系操作并不会改变寄存器或内存中的值。
条件码不能直接读取,常用的使用方法有三种:
1.根据条件码的某种组合,将一个字节设置成0或者1,set类指令
2.可以条件跳转到程序的某个其他的部分,jmp类指令
3.可以有条件的传送数据,条件传送指令。
此程序中的argc!=4
还有i<8;
3.3.7 数组/指针/结构操作
数组/指针/结构的操作是通过使用基址加变址的方式访问操作,其实实质是对指针进行操作。指针的算数操作是以它们指向的对象的大小为单位进行操作的,而这种大小并不一定是一个字节。
此程序中编译器实现了对main函数的参数char *argv[ ]这一数组进行访问。
3.3.8 控制转移
控制转移有if/else, switch, for, while,do/while,?:,continue ,break等。
此程序中有
1.if(argc!=4)
当argc不等于4时,执行程序L2的第一行代码。cmpl执行后,设置一个条件执行码,je根据ZF判断程序段是否完全符合条件,若符合则对程序执行跳转,否则程序继续执行原来的语句。
2.for(i=0; i<8; i++)
i在-4(%rbp)中,每次循环+1,i≤7则继续跳到循环体的开头,否则跳出循环。
3.3.9 函数操作
(1)函数调用与传参:
此显示了printf函数调用时参数传递过程。(%rdi存放格式化用的字符串,%rsi与%rdx分别存放着两个要打印的字符串的首地址。)
显示了exit函数调用时参数传递过程。(%edi中存放的1了退出状态)
此显示了atoi函数调用时参数传递过程。(%rdi中存放字符串的首地址)
显示了sleep函数调用时参数传递过程。(%edi中存放所需要sleep的秒数)
调用getchar函数
(2)函数返回:
main函数的返回
3.4 本章小结
本章主要介绍编译的概念和作用,将预处理后的hello.i文件编译为汇编代码文件hello.s, 解析变量、相关运算,以及各类C语言的基本语句的汇编表示,更便于理解高级语言的底层表示。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器(as)将hello.s文件翻译成为二进制机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存到目标文件hello.o中。hello.o是一个二进制文件,包含着程序的指令编码,而如果用文本编辑器打开,会看到一堆乱码。
作用:在汇编过程中,文件格式将由面向阅读友好的文本文件转化为机器可执行的二进制文件,并且将文本文件中的常量转化为对应的二进制补码,还会进行构造符号表等工作。同时,汇编过程也将生成可重定位目标文件的结构信息,Linux系统使用可执行可链接格式(ELF)对目标文件进行组织。
4.2 在Ubuntu下汇编的命令
命令: gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
Linux系统使用可执行可链接格式(ELF)对目标文件进行组织,其具体结构及其内容如下:
使用readelf命令readelf -a hello.o > helloelf.txt查看hello.o的ELF格式,并将结果重定向到helloelf.txt便于查看分析。
4.3.1 ELF头
ELF头以一个16字节的序列Magic开始,该序列描述了生成该文件的系统的字的大小和字节顺序。
ELF头剩下的部分包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(可重定位/可执行/共享)、机器类型、节头部表的文件偏移,以及节头部表条目的大小和数量。
由hello.o文件的ELF头部分可知:字节顺序为小端序,此文件为REL(可重定位文件),机器类型为AMD X86-64,节头部表的文件偏移为0,节头大小为64字节,节头数量为14等
4.3.2 节头部表
节头部表描述不同节的位置和大小,目标文件中的每个节都有一个固定大小的节头部表条目。节头:记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
以hello.s为例,节头部表一共描述了13个不同节的位置、大小等信息。
4.3.3 重定位节
当汇编器遇到对最终位置未知的目标引用,就会产生一个重定位条目,告诉链接器在将目标文件合并成可执行文件时该如何修改这个引用。代码的重定位信息存放在重定位节.rel.text中,而已初始化数据的重定位条目放在.rel.data中。
每个重定位条目包括offset(需要被修改的引用的节偏移),symbol(标识被修改引用应该指向的符号),type(重定位类型,告知链接器如何修改新的引用),attend(一些重定位要使用它对被修改引用的值做偏移调整)。
ELF定义了32种不同的重定位类型。其中两种最基本的重定位类型包括R_X86_64_PC32(重定位使用32位PC相对地址的引用)和R_X86_64_32(重定位使用32位绝对地址的引用)。
以hello.s为例,hello程序中需要被重定位的有printf、puts、exit、sleep、sleepseces、getchar和.rodata中的.L0和.L1。.rela.eh_frame节是.eh_frame节的重定位信息。
重定位节保存的是.text节中需要被修正的信息,即任何调用外部函数或者引用全局变量的指令都需要被修正。调用外部函数的指令和引用全局变量的指令需要重定位,而调用局部函数的指令不需要重定位。
4.3.4 符号表
.symtab是一个符号表,它存放了程序中定义和引用的函数和全局变量的信息。
每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o > helloobjdump.txt
hello.s内容如下:
而反汇编内容如下:
将hello.s与反汇编文件比较,我们可以看出两者的指令并没有什么不同的地方,并且汇编语言(操作码和操作数)和二进制机器语言存在一一射的关系。
其中hello.s中的汇编指令被映射到二进制的机器语言时,机器语言完全是二进制代码构成的,机器可以直接根据二进制代码执行对应的操作。不同的汇编指令被映射到不同的二进制功能码,而汇编指令的操作数也被映射成二进制的操作数。因此每一条汇编语言的指令都可以映射到一条机器语言指令,而给出任何一条合法的机器语言指令也可以得知它对应的汇编指令。
但是也有不同之处:
(1)分支转移:在hello.s中,分支跳转是直接以.L0等助记符表示。而在反汇编代码中,分支转移表示为主函数+段内偏移量。
(2)函数调用:hello.s中函数调用时直接给函数名称。反汇编的文件中却为call加main+偏移量,即用具体的地址表示。在.rela.text节中为其添加重定位条目等待链接。
(3)访问全局变量:汇编代码中使用.LC0(%rip),反汇编代码中为0x0(%rip),由于访问时需要进行重定位,所以初始化为0并添加重定位条目。
(4)数据内容不同:hello.s立即数为10进制格式,而反汇编代码的立即数为16进制格式
(5)有无对应机器码不同:hello.s只有汇编代码,而反汇编代码有对应的机器码
4.5 本章小结
本章对hello.s进行了汇编,生成了hello.o可重定位目标文件,并且分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,比较了hello.s和hello.o反汇编代码的不同之处,分析了汇编语言与机器语言的关系。
第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.3 可执行目标文件hello的格式
命令:readelf -a hello > hello1.elf
5.3.1 ELF头
可执行目标文件的ELF头描述了文件的总体格式,它还包括程序的入口点(11行),即当前程序运行要执行的第一条指令的地址。hello是个可执行文件,有27个节。
5.3.2 节头部表
不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。通过节头部表,我们可以直到各节的名称、类型、地址、偏移量、地址、对齐信息等。
5.3.3 程序头部表
可执行文件或共享目标文件的程序头部表是一个结构数组。每种结构都描述了系统准备程序执行所需的段或其他信息。
5.3.4 Section to Segment mapping
5.3.5 Dynamic section
5.3.6 重定位节
见4.3
5.3.7 Symbol table
hello程序的符号表包含编号Num、Value、Size、Type、Bind、Vis、Ndx、Name字段。
可以看到,可执行目标文件的符号表表项数目明显多于可重定位目标文件的表项数目。一方面,可执行目标文件中加入了与调试、加载、动态链接相关的节,使得表示节的符号数增多;另一方面,由于链接器对可重定位目标文件中的符号进行了进一步解析,加入了若干系统调用。
5.3.8 其它节
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
分析程序头LOAD可加载的程序段的地址为0x400000
通过edb加载hello程序,打开Data Dump查看hello加载到虚拟地址的状况,可以看到进程的虚拟地址空间各段信息。可以看出,代码段的虚拟空间从0x400000开始。
根据5.3中的节头部表,可以通过edb找到各个节的信息,比如.interp节,由于在5.3中的节头部表中它的偏移为0x2e0,则虚拟地址开始于0x4002e0,大小为0x1c。
5.5 链接的重定位过程分析
使用命令objdump -d -r hello > hellobj.txt对hello进行反汇编,并将结果重定向到hellobj.txt中便于查看分析。
hello与hello.o的不同之处主要在于以下几个方面:
(1) hello中的汇编代码使用虚拟内存地址来标记了,从0x400000开始;而hello.o中的汇编代码是从0开始的,还没有涉及到虚拟内存地址。
(2)在hello.o中,只存在main函数的汇编指令;而在hello中,由于链接过程中发生重定位,引入了其他库的各种数据和函数,以及一些必需的启动/终止函数,因此hello中除了main函数的汇编指令外,还包括大量其他的指令。
(3)main函数中涉及重定位的指令的二进制代码被修改。在之前汇编的过程中,汇编器遇到对最终位置未知的目标引用,会产生一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。所以在链接的过程中,链接器会根据重定位条目以及已知的最终位置对修改指令的二进制码,这个过程就是重定位的过程。
接下来说明hello如何进行重定位:
(1)重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
(2)重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
(3)重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。代码的重定位条目放在.rel.txt。
(4)应用算法计算重定位过程的地址
5.6 hello的执行流程
(1) 加载:_dl_start、_dl_init
(2) 开始执行:_start、_libc_start_main
(3)执行main:_main、_printf、_exit、_atoi、_sleep、_getchar、_dl_runtime_resolve_xsave、_dl_fixup、_dl_lookup_symbol_x,
(4)退出:exit
函数名 函数地址
ld-2.31.so!_dl_start 0x7fbc946a1130
ld-2.31.so!_dl_init 0x7f9612138650
hello!_start 0x4010f0
hello!main 0x401125
lib-2.31.so!__libc_start_main 0x7f9611d58b30
hello!puts@plt 0x401030
hello!exit@plt 0x401070
hello!sleep@plt 0x401080
hello!atoi@plt 0x401060
hello!getchar@plt 0x401050
hello!printf @plt 0x401040
调用与跳转的各个子程序名如下:
._dl_start, ._dl_init, ._cax_atexit, ._new_exitfn, ._libc_start_main,
._libc_csu_init, ._main, ._printf, ._atoi, ._sleep, ._getchar,._exit
._dl_runtime_resolve_xsave, ._dl_fixup, ._dl_lookup_symbol_x, .exit
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。
延迟绑定是通过GOT和PLT实现的,根据hello ELF文件可知,GOT起始表位置为0x404000。
调用dl_init之前的GOT表如图所示:
0x404008后的16个字节均为0.
调用dl_init之后的GOT表如图所示:
从图中可以看到.got.plt的条目已经发生变化。
5.8 本章小结
本章主要介绍了链接的概念与作用,链接可分为符号定义和重定位。并且了解了可执行文件的ELF格式,分析了hello的虚拟地址空间,重定位过程,执行过程,动态链接过程。通过本章实验,我对链接有了更深的理解。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
作用:进程作为一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
6.2 简述壳Shell-bash的作用与处理流程
1.Shell-bash的作用:
Linux系统中,Shell是一个交互型应用级程序,为使用者提供操作界面,接收用户命令,然后调用相应的应用程序。
2.重复如下处理流程:
(1)终端进程读取用户由键盘输入的命令行
(2)分析命令行字符串,获取命令行参数,再构造传递给execve的argv向量
(3)检查第一个命令行参数是否是一个内置的shell命令
(4)如果不是内部命令,调用fork( )创建新进程/子进程
(5)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
(6)如果用户没要求后台运行,则前台运行,shell使用waitpid等待作业终止后返回
(7)如果用户要求后台运行,则shell直接返回;
6.3 Hello的fork进程创建过程
当在shell中输入命令学号和姓名的时候,shell会解析输入的命令行,获得命令行指定的参数。由于./hello不是shell内置的命令,因此shell将hello看作一个可执行目标文件,在相应路径里寻找hello程序,找到该程序就执行它。
shell会通过调用fork()函数创建一个子进程,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同但独立的一个副本,包括代码段、数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,子进程可以读写父进程中打开的任何文件。
父进程和子进程之间最大的区别在于它们的PID不同。hello程序之后就会运行在这个新创建的子进程的上下文中。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开所有文件描述符。
hello的execve过程可以总结为以下几个步骤:删除已存在的用户区域;映射私有区域;映射共享区域;设置程序计数器。
下图概括了私有区域的不同映射:
6.5 Hello的进程执行
逻辑控制流:如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称为逻辑流。
上下文切换:如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程,上下文就是内核重新启动一个被抢占的进程所需要的状态,是一种比较高层次的异常控制流。。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:shell使得用户可以有机会修改内核,所以需要设置一些防护措施来保护内核,如限制指令的类型和可以作用的范围。
开始Hello运行在用户模式,收到信号后进入内核模式,运行信号处理程序,之后再返回用户模式。运行过程中,cpu不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用cpu,实现进程的调度。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
hello执行过程中会出现4种异常:中断、陷阱、故障、终止。
(1)中断:外部的I/O设备造成的,这里是键盘。
(2)陷阱:系统调用,hello会执行系统调用函数read陷入到内核。
(3)故障:故障由错误情况引起,它可能被故障处理程序修正,hello在被加载器加载后,第一次执行取命令时,便会发生缺页故障,之后由缺页故障处理程序将虚拟页缓存到物理内存。
(4)终止:不可恢复的错误,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。
6.6.1.正常运行
最后输入回车后hello正常返回
6.6.2 输入回车
输入多个回车后结果如下:
输入回车后,shell把回车存入输入缓冲区。
hello在循环结束后从输入缓冲区中读取一个回车后正常返回,而shell把剩下的回车从输入缓冲区读出。
6.6.3 输入Ctrl-Z
输入Ctrl-Z后结果如图:
输入Ctrl-Z,内核发送一个SIGTSTP信号到前台进程组的每个进程,输入ctrl-z默认结果是挂起前台的作业,hello进程并没有回收,而是运行在后台下。
运行ps命令:
可以看到当前存在的进程仍有hello。
用ps命令可以看到,hello进程并没有被回收。
运行jobs命令:
可以看到hello已经停止。
运行pstree命令:
可以看到当前的进程树
输入命令fg:
hello程序继续执行。
他的后台 job 号是 1,调用 fg 1 将其调到前台,此时 shell 程序首先打印 hello 的命令行命令, hello 继续运行打印剩下的info,之后输入字串,程序结束,同时进程被回收。
使用kill命令:
Kill命令可以给指定进程发送信号。比如 kill -9 2621 是指向PID为2791的进程(即hello)发送SIGKILL信号。这个命令会杀死hello进程,当再次使用ps时可以发现hello进程已经被杀死。
6.3.4输入Ctrl-C
输入Ctrl-C会发送SIGINT信号给前台进程组的每个进程,结果是终止前台进程,即终止hello进程。
6.3.5输入乱码
程序运行过程中不停乱按键盘,包括回车。如果乱按不包括回车,输入的字符串会缓存到缓冲区;如果输入的最后是回车,则getchar会读进回车,把回车前的字符串作为输入shell的命令。
6.7本章小结
本章介绍了进程的概念和作用,shell-bash的处理过程与作用。着重分析了调用fork创建新进程,调用execve函数执行hello,及hello的进程执行过程。最终分析了 hello在运行时遇到的异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
(1)逻辑地址:程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 段标识符:段内偏移量。
(2)线性地址:逻辑地址向物理地址转化过程中的一步,逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式,分页机制中线性地址作为输入。
(3)虚拟地址:程序访问存储器所使用的逻辑地址称为虚拟地址。
(4)物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 Intel 平台下,逻辑地址是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到段基址然后加上段内偏移,这就得到了线性地址。我们把这个过程称作段式内存管理。
一个逻辑地址是由段标识符和段内偏移量组成。段标识符是一个16位长的字段。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。后面3位包含一些硬件细节,表示具体的是代码段寄存器还是栈段寄存器抑或是数据段寄存器。
全局的段描述符,放在全局段描述符表(GDT)中,一些局部的段描述符,放在局部段描述符表(LDT)中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。VM系统将虚拟内存分割,称为虚拟页,类似地,物理内存也被分割成物理页。
系统将虚拟页作为进行数据传输的单元。Linux下每个虚拟页大小为4KB。物理内存也被分割为物理页, MMU(内存管理单元)负责地址翻译,MMU使用页表将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
利用页表来管理虚拟页,页表就是一个页表条目(PTE)的数组,每个PTE由一个有效位和一个n位地址字段组成,有效位表明了该虚拟页当前是否被缓存在DRAM中,如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。如果发生缺页,则从磁盘读取。
内存管理单元利用页表来实现从虚拟地址到物理地址的翻译。
如图展示了MMU如何利用页表来实现这种映射。CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移和一个(n- p)位的虚拟页号。MMU利用VPN来选择适当的PTE。将页表条目中物理页号和虚拟地址中的VPO串联起来,就能得到相应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
单独页表具有局限性,所以我们使用层次结构的页表来对其空间进行压缩。主要思想为:将页表构建出层次结构,高级页表中存储低级页表的低质,最底层页表存储相应的物理内存地址。这种方法从两个方面减少了内存要求:如果一级页表中的一个PTE是空的,那么相应的二级页表就根本不会存在 ;只有一级页表才需要总是存储在主存中,虚拟内存系统可以在需要时创建、调入、调出二级页表。
TLB称为翻译后备缓冲器,是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。
虚拟地址中用以访问TLB的组成部分如图所示:
接下来介绍如何使用四级的页表来将虚拟地址翻译成物理地址。36位VPN被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含LI 页表的物理地址。VPN 1提供到一个LI PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
获得物理地址之后,先取出组索引对应位,在L1中寻找对应组。如果存在,则比较标志位,相等后检查有效位是否为1.如果都满足则命中取出值传给CPU,否则按顺序对L2cache、L3cache、内存进行相同操作,直到出现命中。然后再一级一级向上传,如果有空闲块则将目标块放置到空闲块中,否则将缓存中的某个块驱逐,将目标块放到被驱逐块的位置。
7.6 hello进程fork时的内存映射
在shell输入命令行后,内核会调用fork创建子进程,为hello程序的运行创建上下文,并分配一个与父进程不同的PID。
通过fork创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任意一个之后进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。
7.7 hello进程execve时的内存映射
参考6.4得到。
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
(1)删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
(3)映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
处理缺页是由硬件和操作系统内核协作完成的,在指令请求一个虚拟地址时,MMU中查找页表,如果这时对应得物理地址没有存在主存的内部,我们必须要从磁盘中读出数据。在虚拟内存的习惯说法中,DRAM缓存不命中成为缺页。在发生缺页后系统会调用内核中的一个缺页处理程序,选择一个页面作为牺牲页面
处理程序执行如下步骤:
(1)检查虚拟地址是否合法,如果不合法则触发一个段错误,程序终止。
(2)检查试图进行的内存访问是否合法,即检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。
(3)两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。
7.9动态存储分配管理
动态内存分配器的基本原理:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,两种风格都要求应用显式地分配块,它们的不同之处在于由哪个实体来负责释放已分配的块。
1.显式分配器:要求应用显式地释放任何已分配的块。例如,c标准库提供一种叫做malloc程序包的显式分配器。c程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。c++中的new和delete操作符与c中的malloc和free相当。
2.隐式分配器:另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集,例如Lisp,ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
堆中的块主要组织为两种形式:
(1)隐式空闲链表(带边界标记)
在块的首尾的四个字节分别添加header和footer,负责维护当前块的信息(大小和是否分配)。由于每个块是对齐的,所以每个块的地址低位总是0,可以用该位标注当前块是否已经分配。可以利用header和footer中存放的块大小寻找当前块两侧的邻接块,方便进行空闲块的合并操作。因为空闲块是通过头部隐含地连接着的,所以把这种结构成为隐式空闲链表。
2.显式空闲链表
也可以将空闲块组织成某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里。例如,堆可以组织成一个空闲链表,在每个空闲块中添加两个指针,分别指向前一个空闲块和后一个空闲块。采用该策略,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
使用边界标记的堆块的格式其中头部和脚部分别存放了当前内存块的大小与是否已分配的信息。通过这种结构,隐式动态内存分配器会对堆进行扫描,通过头部和脚部的结构实现查找。
而使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。维护链表的顺序有:后进先出(LIFO),将新释放的块放置在链表的开始处,使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章介绍了存储管理的有关内容。介绍了存储器的地址空间:物理地址、虚拟地址、逻辑地址、线性地址,然后对段式管理和页式管理进行了较为详细的描述,同时还讨论了VA到PA的变换、物理内存访问、fork和execve的内存映射、缺页故障和缺页处理、动态存储分配管理等内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件:所有IO设备都被模型化为文件,所有的输入和输出都能被当做相应文件的读和写来执行
设备管理:unix io接口:将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的接口,称为Unix I/O,这使得所有的输入和输出能以一种统一且一致的方式来执行。。
8.2 简述Unix IO接口及其函数
Unix IO接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux Shell创建的每个进程都有三个打开的文件:标准输入(描述符为0),标准输出(描述符为1),标准错误(描述符为2)。
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)
进程通过调用close函数关闭一个打开的文件,fd是需要关闭的文件的描述符。
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_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
参考[转]printf 函数实现的深入剖析 - Pianistx - 博客园
前提:printf和vsprintf代码是windows下的。
查看printf代码:
int printf(const char fmt, …)
{
int i;
char buf[256];
va_list arg = (va_list)((char)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
首先,arg得到第二个不定长参数,即输出的时候格式化串对应的值。
接下来,查看vsprintf代码:
int vsprintf(char *buf, const char fmt, va_list args)
{
char p;
char tmp[256];
va_list p_next_arg = args;
for (p = buf; *fmt; fmt++)
{
if (*fmt != ‘%’) //忽略无关字符
{
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt
{
case ‘x’: //只处理%x一种情况
itoa(tmp, ((int)p_next_arg)); //将输入参数值转化为字符串保存在tmp
strcpy(p, tmp); //将tmp字符串复制到p处
p_next_arg += 4; //下一个参数值地址
p += strlen(tmp); //放下一个参数值的地址
break;
case ‘s’:
break;
default:
break;
}
}
return (p - buf); //返回最后生成的字符串的长度
}
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。
write函数如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall,
然后,查看syscall的实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
syscall将字符串中的字节“Hello 120L020302 lijiaying”从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是我们的打印字符串“Hello 120L020302 lijiaying”就显示在了屏幕上。
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中
接下来查看getchar:
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
bb=buf;
}
return(–n>=0)?(unsigned char)*bb++:EOF;
}
在getchar函数中,首先声明了几个静态变量:buf表示缓冲区,BUFSIZ为缓冲区的最大长度,而bb指针指向缓冲区的首地址。
由于getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,返回字符串的第一个字符。
则当getchar调用read函数时,将缓冲区读入到buf中,并将长度送给n,再重新令bb指针指向buf。返回buf中的第一个字符(如果长度n < 0,则报EOF错误)。
8.5本章小结
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数的实现。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
用计算机系统的语言,总结hello的一生。
从源代码hello.c到可执行文件hello,再到结束需要经历的过程:
一. 编写:将代码键入hello.c
二.预处理:将hello.c调用的所有外部的库展开再合并到一个hello.i文件中
三. 编译:将hello.i编译成为汇编文件hello.s
四. 汇编:将hello.s汇编成为可重定位目标文件hello.o
五. 链接:将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello
六. 运行:在shell中输入./hello 120L020302 lijiaying
七. 创建子进程:在shell进程调用fork为其创建子进程
八. 运行程序:shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数。
九. 执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流
十. 访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
十一. 动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存。
十二. 信号:如果运行途中键入Ctrl-C,Ctrl-Z则调用shell的信号处理函数分别停止、挂起。
十三. 结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。
至此,hello经历完所有过程,完成了它的一生。
本次大作业以hello程序为示例,展现了在计算机系统中程序是如何一步步被执行的。通过这个过程,我们能清楚的明白计算机系统执行一个程序的基本流程与原理,并进一步深入理解了计算机软件、硬件的相互配合。hello虽小,却是一切代码的开始与映射。
本次大作业对hello执行的深入分析对我们深入了解计算机系统有很大帮助。通过学习这门课,我感受到了计算机系统的复杂和奥妙,并且对底层信息有了更多了解。
附件
文件名 作用
hello.c 源程序
hello.i 预处理后的文件
hello.s 编译后生成的文件
hello.o 汇编后的可重定位目标执行文件
helloelf.txt 存放使用readelf工具查看hello.o文件的结果的文件
helloobjdump.txt hello.o的反汇编代码
hello 链接后的可执行文件
hello1.elf 存放使用readelf工具查看hello文件的结果的文件
hellobj.txt hello的反汇编代码
参考文献
[1] Randal E.Bryant / David O’Hallaron. 深入理解计算机系统(原书第3版)[M]. 机械工业出版社,2016:1-87.
[2] 哈尔滨工业大学计算机系统大作业2022春_Drama_coder的博客-CSDN博客
[3] 哈工大深入理解计算机系统大作业_月华流的博客-CSDN博客_dl_init
[4] 哈工大计算机系统大作业——程序人生_恠丶的博客-CSDN博客
[5] 哈工大深入理解计算机系统大作业_Aleafy的博客-CSDN博客
[6] 哈工大计算机系统大作业 程序人生-Hello’s P2P_littleteresa的博客-CSDN博客
本文通过详解Hello程序从源代码到可执行文件的全程,探讨了预处理、编译、汇编、链接等步骤,揭示了程序加载、执行、内存管理、进程创建和I/O交互等核心概念,深入理解计算机系统工作原理。
1522

被折叠的 条评论
为什么被折叠?



