hello是绝大多数程序员所编写的第一段代码,是编程学习中最为简单最为基础的一个程序。而从计算机系统的角度看,一个hello程序从编写完成经过预处理、编译、汇编、链接等过程形成可执行文件,再将可执行文件加载进内存并运行的过程融汇了计算机系统设计的方方面面。本文从一个简单的hello.c程序开始,介绍了hello程序再Linux系统下运行的完整生命周期,包括了P2P(From program to process)和020(From zero to zero)两个过程,通过hello.c的完整周期,可以进而窥探其它程序的生命历程,进而领略计算机系统设计的巧妙。
关键词:Linux系统;计算机系统;P2P;020;程序的生命历程
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
Hello的P2P过程:P2P,即From Program to Process。Hello的生命周期是从一个C语言程序的源文件开始的,首先预处理器cpp对源文件进行预处理,生成文本文件Hello.i,然后编译器ccl编译Hello.i生成汇编程序Hello.s,接着汇编器as对Hello.s进行汇编,生成可重定位文件Hello.o,最后链接器ld将Hello.o与它引用到的库链接,生成可执行文件Hello,但到此只是得到了可执行程序。当用shell输入命令行执行这个可执行程序时,系统会创建一个新的进程,接着删除已存在的用户区域,然后映射私有区域,为Hello程序的代码段/只读内存段和数据段/读写内存段以及堆和栈创建新的区域结构,再将控制权转移给动态链接器(如果程序连接了动态库),映射共享区域,符号重定位,最后控制跳转到程序的入口点。自此一个进程创建完成,完成了From Program to Process。
Hello的020过程,即From Zero to Zero,指的是一个程序在运行过程中从无到终得过程。在这个过程中,shell首先调用fork函数为程序创建进程,随后调用execve函数在进程上下文中加载运行hello程序,从而得到了虚拟地址空间,存储了通用目的寄存器、程序计数器、用户栈、内核栈及各种内核数据结构等信息,至此实现了从无到有的过程。程序在运行过程中经历大量异常和信号,对存储器进行读写访问,与外设进行交互,程序运行结束后,父进程回收子进程,释放虚拟地址空间,删除相关内容,实现从有到终的过程。
1.2 环境与工具
软件环境:Windows10-64bit,WMware Workstation Pro,Ubuntu 22.04.3 LTS-64bit
开发与调试工具:vim,,gedit,Visual Studio Code,gcc,gdb
1.3 中间结果
hello.i 预处理产生的文本文件,是编译的输入
hello.s 编译产生的汇编代码文本文件,是汇编的输入
hello.o 汇编产生的二级制的可重定位目标文件,是链接的输入
hello 用ld链接hello.o以及需要用到的动态库和.o文件生成的可执行目标文件,用于运行程序,观察动态链接过程。
hello1 用命令gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC hello.c -o hello1生成的可执行目标文件
1.4 本章小结
本章根据hello的自白,简要介绍了hello的P2P和020过程,并罗列出了实验所需要的软硬件开发环境和开发调试工具,并罗列了实验中所得到的中间结果文件及其作用。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理是在编译之前使用预处理器cpp,根据源代码中的预处理命令对代码进行插入和修改,得到一个适合编译的文本文件(默认以.i为后缀)的过程,除此之外,一些预处理指令还会影响之后编译器的行为。
预处理的作用很大,以下列举4个:
1.通过#include命令省去了手动复制头文件的过程, 预处理器会自动把这条命令从文本中删除,在并在该位置插入命令要求的头文件文本。
2.方便代码切换(使用#define和#if等,实现代码的快速切换)
3.利用宏定义,使用适合本系统的代码。在标准库的源文件中,有很多根据宏定义选择适合该系统的代码的文本。
4.让预处理时有报错。#error,当预处理器到#error,就会报错。可以用于一些宏变量的检查
2.2在Ubuntu下预处理的命令
命令行:gcc hello.c -E -o hello.i
2.3 Hello的预处理结果解析
用vim打开Hello.i,Hello.i总共有3092行,如图。可以发现,注释已经消失,#include也已经消失,而main函数之前的文本被替换成3000多行的内容,即include的头文件中的内容被添加到Hello.i中。
2.4 本章小结
本章首先介绍了预处理的定义和作用。然后以Hello.c为例,演示了在Ubuntu下如何进行程序预处理,并对结果进行了分析。预处理对程序员来说非常有用,可以省去手动复制粘贴的麻烦。此外,通过宏变量和预处理的条件判断语句,程序能够自动适配环境,同时提供了更细致地与编译器交流的方式。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
C语言的编译是编译器将预处理得到的.i文件翻译成汇编代码.s文件的过程。
C语言的编译通常包括词法、语法、语义分析、中间代码生成、代码优化、目标代码生成等步骤[1]。
编译的作用有很多,以下列举三条:
1.编译器将用高级语言书写的源程序转换为汇编代码。
2.现在的编译器大多有优化功能,可以提高程序的性能。
3.编译器在编译的过程中会发现一些问题,可以帮助程序员找到bug。
3.2 在Ubuntu下编译的命令
命令行:gcc hello.c -S -o hello.s
3.3 Hello的编译结果解析
1.源文件.file
2.代码段.text
3.只读代码段.section & .rodata
4.字节对齐方式.align
5.字符串.string
6.全局变量.global
7.main函数类型.type
3.3.2操作局部变量
1.当进入main函数时,根据局部变量的需求,为原本存储在栈中的局部变量申请一段栈上的空间以供使用。
2.在hello.c中设定的局部变量即为i,而在hello.s中可以看到,其跳转到了L3的位置后又将栈指针减少4,表明存储了局部变量i。一般来说,过程通过减小%rsp的值为局部变量申请空间。汇编代码中,%rsp被一次性减32,根据代码的上下文可知,从地址R[rrbp]-4到地址R[rrbp]的这段4Byte空间被用来存放int局部变量i。由此可知在本段汇编代码中,通过基于%rbp计算有效地址的方式实现对int类型的局部的引用。
3.3.3 赋值操作
C语言代码中的一个显式的赋值操作(i=0)在汇编代码中通过mov指令完成。还有一些操作,除了使用mov之外,可以使用leaq实现赋值操作。
3.3.4类型转换
sleep的参数类型时unsigned int,而atoi的返回值是int,在此处会进行隐式类型转换。而由于只是从signed的变成unsigned,位数并没有改变,所以在C语言中,只是会变成用有符号数的方式解释这些位。而解释这些位在sleep函数内,故汇编代码中只是将数传给了%edi,没有做额外的操作,如图。
3.3.5算术运算
常见的算术运算指令有
ADD、ADC、AAA、DAA: 加法
INC:加“1”
SUB、SBB、AAS、DAS: 减法
DEC:减“1”
CMP:比较
NEG:求补指令
MUL、IMUL、AAM:乘法
DIV、IDIV、AAD:除法
在该汇编代码中,只有一个算术操作:add,如图。
3.3.6关系操作
本程序中仅涉及到CMP,即设置条件码,控制后续条件跳转语句,如图。
3.3.7数组操作
数组操作的主要操作是通过下标索引需要元素的值,下标索引操作离不开汇编中的内存引用操作。
3.3.8控制转移
该代码涉及了分支结构和for循环结构。
C语言的分支结构依靠if,else和switch等。本代码中使用了if,在汇编代码层面,if通常由cmp指令和条件跳转指令配合完成。如图,执行cmpl时,如果-20(%rbp)==4,ZF会被set,即设置为1,,否则ZF被reset,即设置为0。执行je时,如果ZF==1,就跳转到.L2处的代码,否则,不跳转,执行下一条指令。
C语言的for循环结构的实现也离不开跳转指令,也离不开关系操作。如图所示,这个for循环首先由一个无条件跳转,在刚开始for循环时跳转到条件判断处.L3,然后如果满足条件,就跳转到.L4。当循环体的代码执行完之后,又顺序执行到条件跳转。
3.3.9函数操作
函数是过程的一种,过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。
程序中涉及的函数操作列举如下:
main函数:
传递控制:系统启动函数__libc_start_main使用call指令调用main函数,call指令将下一条指令的地址压栈,然后将%rip的值设置为main函数指令的起始地址。
传递数据:__libc_start_main向main函数传递参数argc和argv,分别使用%edi(argc的类型是int)和%rsi存储,main函数的return 0对应于汇编中的三条指令,将%eax设置0,然后ret(夹在中间的leave稍后分析),其中,ret从栈中弹出返回地址,将这个地址赋给%rip。
分配和释放内存:使用%rbp记录对应栈帧的最高地址-8的值,通过减小%rsp的值为函数在栈中分配空间,程序结束时,调用leave指令,leave将%rbp的值赋给%rsp(释放局部变量占用的空间),然后从栈中弹出一个4字长的值给%rbp(这个值其实就是__libc_start_main%rbp的值),恢复栈空间为调用main函数之前的状态。
printf函数:
传递数据:第一次printf将%rdi设置为"用法: Hello 学号 姓名 秒数!\n"字符串的首地址。第二次printf设置%rdi为"Hello %s %s\n"的首地址,设置%rsi为argv[1],%rdx为argv[2]。
控制传递:第一次printf因为只有一个字符串参数,所以call puts@PLT;第二次printf使用call printf@PLT。
exit函数:
传递数据:将%edi设置为1。
控制传递:call exit@PLT。
atoi函数
传递数据:将%rdi设置为argv[3]。
控制传递:call atoi@PLT。
sleep函数:
传递数据:将%edi设置为&eax(即atoi函数返回的值)。
控制传递:call sleep@PLT。
getchar函数:
控制传递:call gethcar@PLT
3.4 本章小结
本章简要介绍了编译过程中,hello通过编译器从而被翻译为汇编语言的过程,并详细的依照C语言的各种类型和操作,对汇编语言程序进行解析,为接下来的汇编和链接阶段做好准备。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
汇编是指把汇编语言编写的程序转换为相匹配的机器语言程序的过程。汇编程序所输入的是用汇编语言编写的源程序,而输出的是用机器语言编写的目标程序。
汇编的作用:
1.将程序转写为机器语言,让机器能够理解程序
2.汇编过程中可从汇编程序得到一个可重定位目标文件,便于后续的链接操作。
4.2 在Ubuntu下汇编的命令
命令行:gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
4.3.1 可重定位目标文件[2]
ELF头以16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型(如可执行、可重定位或者共享的)、机器类型、节头部表的文件偏移以及节头部表中条目的大小和数量。
.text:已编译程序的机器代码。
.rodata:只读数据。
.data:已初始化的非0的全局变量和静态变量。
.bss:未初始化的全局变量和局部静态变量,以及所有被初始化为0的全局变量和静态变量(仅是占位符,该节在目标文件中不占据实际的空间)。
.symtab:符号表(与编译器中的符号表不同),存放存放程序中定义和引用的函数和全局变量信息,不包括局部变量。
.rel.text:代码的重定位条目的列表,用于在重定位的时候,重新修改代码段的指令中的地址信息。
.rel.data:已初始化数据的重定位条目的列表(比如,有个全局变量a,它的初值在汇编时无法确定,.rel.data就会有一个与a有关的重定位条目)
.debug:调试符号表,只有以-g方式调用编译器驱动程序时,才会得到这张表。
.line:原始C源程序中的行号和.text节中机器指令之间的映射。
.strtab节:字符串表,包括.symtab和.debug节中的符号表以及节头部表的节名。
节头部表:节头部表描述了这个elf文件中每个节的位置与大小。
4.3.2hello的分析
分析节头部表:使用readelf -S Hello.o查看节头部表,如下图。
分析符号表:用readelf -s Hello.o查看符号表。如下图,Name为符号名称,Value是符号相对于目标节的起始位置偏移,Size为目标大小,Type是类型,数据或函数,Bind表示是本地还是全局。
分析文件头:用readelf -h指令可以查看Hello.o的ELF头信息,如下图21.
Class:64位ELF文件格式
Data:数据表示形式是二进制补码,且是小端字节序(低位字节存储在低内存地址)。
Version:版本为1
OS/ABI:操作系统为UNIX SYSTEM V
TYPE:REL表明这是一个可重定位文件
Machine:这个ELF文件是为AMD的64位x86架构(X86-64)而编译的。
Entry point address:程序的入口地址为0x0
Start of program headers:0表示没有程序头表
Start of section headers:节头部表的起始位置为1056字节处
Size of section headers:每个表项64个字节
Number of section headers:该可重定位文件共14个表
Section header string table index:13为.strtab在节头表中的索引
分析重定位条目:使用readelf -r Hello.o查看重定位条目。
.rela.data / .rel.data 节在本程序中没有。
.rela.eh_frame节通常是与异常处理框架(Exception Handling Frame)相关的重定位条目。
.rela.text中,Offset表示要修改的引用的地址与.text节的首地址的差。Info的前部分表示在符号表中的索引,后部分表示重定位的类型(经观察,Info显示的并不是显示出所有的8字节,符号表中的索引只有低2字节被显示出来)。Type只是将重定位的类型编码翻译成对程序员比较友好的字符串。Sym.Value和Sym.Name是用在符号表中查找得到的。Addend是为了修正PC的误差。
4.4 Hello.o的结果解析
使用objdump -d -r Hello.o反汇编(如下图)。与Hello.s相比,原来的伪指令消失了。
经过对比可以得到,汇编指令代码几乎是相同的,反汇编代码再汇编代码的基础之上,还有左侧的机器代码,说明机器代码和汇编指令是一一对应的。
跳转指令中,反汇编代码的跳转是对应的地址的跳转,而hello.s中的跳转则是以.Lx命名的代码块为单位的跳转;对于常量的操作数,反汇编代码中主要以十六进制呈现,而hello.s中则主要是十进制的数字。除此之外,helllo.s中有大量的以“.”开头的伪指令,而反汇编代码中则没有。机器指令主要由操作指令和操作数操作数组成,在映射关系上与汇编指令呈现一一对应的映射关系。
4.5 本章小结
本章简要的介绍了hello.s经过汇编器得到可重定位目标文件hello.o的过程,首先简要的介绍了可重定位目标文件的格式,在此基础上使用readelf命令添加不同选项得到了关于hello.o中ELF的节头表、符号表、可重定位条目等信息,最后通过反汇编程序将hello.o反汇编生成得到汇编语言程序,并于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
5.3 可执行目标文件hello的格式
可执行目标文件的格式类似于可重定位目标文件的格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式。它还包括程序的入口点,也就是程序运行时要执行的第一条指令的地址。.text、.rodata和.data节与可重定位目标文件的节是相似的,除了这些节已经被重定位到它们最终的运行内存izhi以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位),所以不再需要rel节。
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
- 使用readelf -h hello命令查看hello的ELF头
2.使用readelf -S查看hello的节头部表
3.调用readelf -l hello查看hello的程序头表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
由下图可以得到,虚拟地址空间的起始位置是0x400000
由上图可以得知,.interp段的起始地址为0x4002e0,在edb中查找地址得到如下图的结果:
由上图可以得到,.text的起始地址为0x4010f0,在edb中查询地址可以得到如下图的结果:
由上图可以得到,.rodata的起始地址为0x402000,在edb中查询地址可以得到如下图的结果:
5.5 链接的重定位过程分析
调用objdump -d -r hello命令,得到hello的反汇编程序,如下图结果所示
从上图5.5-1到5.5-6并结合图4.4-1到图4.4-2可以得到,hello与hello.o在以下几点由一定差异:
1.虚拟地址不同,hello.o的反汇编代码虚拟地址从0开始,而hello的反汇编代码虚拟地址从0x400000开始。这是因为hello.o在链接之前只能给出相对地址,而hello在链接之后得到的是绝对地址。
2.反汇编节数不同,hello.o只有.text节,里面只有main函数的反汇编代码。而hello在main函数之前加上了链接过程中重定位而加入的各种在hello中被调用的函数、数据,增加了.init,.plt,.plt.sec等节的反汇编代码。
3.跳转指令不同,hello.o中的跳转指令后加的主要是汇编代码块前的标号,而hello中的跳转指令后加的则是具体的地址,但相对地址没有发生变化。
5.6 hello的执行流程
由于先前在编译时没有加上-g选项,则生成的可执行文件中不含调试信息,对分析工作产生阻碍,因此重新用下图的指令进行编译,产生新的可执行文件hello1。之后调用edb运行可执行程序hello1,右键点击analyze here可以得到hello1的函数列表信息,如下图所示.
5.7 Hello的动态链接分析
程序调用一个有共享库定义的函数时,编译器无法预测函数在运行时的地址,因为定义这个函数的共享模块可能可以被加载到任何位置。因此,编译系统采用延迟绑定,将过程地址的绑定推迟到第一次调用该过程的时候。
延迟绑定需要用到两个数据结构GOT和过程链接表(PLT)。PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
在第一次调用某个函数时,程序不直接调用,而是调用进入函数所在的PLT条目,第一条PTL指令通过GOT进行间接跳转,每个GOT条目初始时都指向其对应的PLT条目的第二条指令,这个间接跳转只是简单将控制传送回函数所在的PLT条目的下一条指令。之后将函数的ID压入栈中之后,函数所在的PLT条目跳转到PLT[0],最后PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]简介跳转进入动态链接器。动态链接器通过使用两个栈条目来确定函数的运行时位置,在将控制传递给函数。
后续调用时,则可以不用通过GOT[4]的跳转将控制给到函数。
由图查看hello的ELF文件,得到GOT运行时的地址0x403ff0和PLT运行时的地址0x404000。之后在程序调用dl_init之前,先查看0x404000位置的内容:
在dl_init调用前,对于每一条PIC函数调用,初始时每个GOT条目都指向了PLT条目的第二条指令。在调用dl_init后,可以看到对应内容发生了变化。
5.8 本章小结
本章节简要介绍了链接的相关过程,首先简要阐述了链接的概念和作用,给出了链接在Ubuntu系统下的指令。之后研究了可执行目标文件hello的ELF格式,并通过edb调试工具查看了虚拟地址空间和几个节的内容,之后依据重定位条目分析了重定位的过程,并借助edb调试工具,研究了程序中各个子程序的执行流程,最后则借助edb调试工具通过对虚拟内存的查取,分析研究了动态链接的过程[3]。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中程序的实例。进程简化了用户的内存操作的工作,提高了程序的通用性,是多个过程并发执行的基础,是计算机科学中最深刻,最成功的概念。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个交互型应用级程序,代表用户运行其他程序。如Windows下的命令行解释器,cmd、powershell,图形界面的资源管理器。Linux下的Terminal/tcsh、bash等等,也包括图形化的GNOME桌面环境。Shell是信号处理的代表,负责各进程创建与程序加载运行及前后台控制,作业调用,信号发送与管理等。Shell是人在操作系统中的代表。
处理流程:shell执行一系列的读/求值步骤。读步骤读取用户的命令行,求值步骤解析命令,代表用户运行。shell在求值一个命令行时会创建一个进程组。一个进程组对应一个作业,一个作业是为了求这个命令行的值而创建的进程。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同,子进程得到父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,也就意味着父进程调用fork函数时,子进程能够读写父进程打开的任何文件。父进程和新创建的子进程之间最大的区别在于其不同的PID。
fork函数只被调用一次,但是会返回两次;依次在父进程中,返回子进程的PID,另一次是在子进程中,直接返回0,通过fork函数的返回值可以判断程序是在父进程还是子进程中执行。父进程和子进程是并发运行的独立进程。内核以任意方式交替执行其逻辑控制流的指令。
6.4 Hello的execve过程
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。execve函数一次调用而从不返回。
6.5 Hello的进程执行
1.逻辑控制流是程序计数器(PC)值的序列,这些值对应于可执行目标文件或运行时动态链接到程序的共享对象中的指令。每个PC值代表着程序执行时的不同步骤或位置。
2.时间分片是一种处理器调度策略,其中多个进程交替使用处理器。每个进程执行一小部分代码,然后被暂停(抢占),轮到下一个进程执行。当多个进程的执行在时间上重叠时,称为并发执行。这种并发执行的概念被称为并发性,而多个进程轮流执行的概念称为多任务。每个进程在执行其控制流的一部分时称为一个时间片,因此多任务也被称为时间分片。
3.用户模式和内核模式用于保护操作系统内核免受恶意或错误行为的影响。处理器通过控制寄存器中的模式位来区分这两种模式。当模式位被设置时,进程处于内核模式;否则,进程处于用户模式。初始情况下,程序代码在用户模式下执行。当发生中断、故障或系统调用时,进程从用户模式切换到内核模式。处理器将控制传递给内核处理程序,在内核模式下执行。处理程序完成后,处理器将控制返回给应用程序,并将模式从内核模式切换回用户模式。
4.进程上下文切换是操作系统在执行多任务时进行的重要操作。在某些时刻,内核可能会决定抢占当前执行的进程,并重新开始先前被抢占的进程。这个决策由内核中的调度器处理。在进行抢占时,需要进行上下文切换。上下文切换涉及保存当前进程的状态(上下文),恢复先前被抢占的进程的状态,并将控制传递给新恢复的进程。
6.6 hello的异常与信号处理
1.乱打字
不影响运行,但会留在程序中
- 回车
- ctrl-z
挂起程序
- ctrl-c
- Ps
- Jobs
- Pstree
- Fg
- Kill
6.7本章小结
本章概述了hello进程大致的执行过程,阐述了进程、shell、fork、execve等相关概念,之后从不同角度详细分析了进程的执行过程。并在运行时尝试了不同形式的命令和异常,每种信号都有不同处理机制,针对不同的shell命令,hello会产生不同响应。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址
程序经过编译后出现在汇编代码中的地址,逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为段标识符:段内偏移量。
- 物理地址
在存储器中以字节为单位存储信息,每一个字节单元与一个唯一的存储器地址一一对应,称为物理地址,又叫实际地址或者是绝对地址。物理地址对应了系统中实际的内存字节。
3.虚拟地址
CPU启动保护模式之后,程序运行在虚拟地址空间中,虚拟地址空间是所有可能地址的集合,对于一个64位的机器而言,则集合中共有2^64种可能。
4.线性地址
逻辑地址到物理地址之间变换的中间层,在分段不见中逻辑地址就是段总的偏移地址,加上段的及地址就可以得到线性地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 Intel 平台下,逻辑地址由 selector:offset 组成,其中 selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。要将逻辑地址转换为线性地址,首先需要通过 selector 在全局描述符表 (GDT) 中找到对应的段描述符,获取段基址,然后加上 offset(段内偏移)即可得到线性地址。这个过程称为段式内存管理。
逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符),可以直接在段描述符表中找到一个具体的段描述符,该描述符描述了一个段。
全局的段描述符存放在全局段描述符表 (GDT) 中,而一些局部的段描述符存放在局部段描述符表 (LDT) 中。
给定一个完整的逻辑地址段选择符+段内偏移地址,可以根据段选择符的T1位来确定当前要转换的是GDT中的段,还是LDT中的段,然后根据相应的寄存器,获取其地址和大小。通过查找段选择符中的前13位,可以在段描述符表中找到对应的段描述符,从而得到其基地址。将基地址与偏移相加即可得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址到物理地址的转换是通过分页机制来实现的。分页机制将虚拟内存划分为固定大小的虚拟页。
为了确定虚拟页是否缓存在DRAM中,并确定虚拟页所在的物理页位置,需要使用页表。页表是一个页表条目(PTE)的数组,每个PTE包含一个有效位和一个n位地址字段,有效位的设置与否表示虚拟页是否被缓存在DRAM中。在地址翻译过程中,需要使用一个页表基址寄存器,指向当前页表。一个n位虚拟地址包含两部分,一部分是p位的虚拟页面偏移,另一部分是一个n-p位的虚拟页号。如果虚拟页已被缓存,即页面命中,MMU利用虚拟页号(VPN)选择合适的PTE,然后将页表条目中的物理页号和虚拟地址中的虚拟页面偏移结合起来,就可以得到一个物理地址。
如果虚拟页没有被缓存,就会触发缺页异常,控制会传递到缺页异常处理程序。缺页处理程序会确定物理内存中的牺牲页,然后判断是否需要调出内存,完成牺牲页的替换。接着,新的页面会被调入,并更新内存中的PTE。最后,控制会返回到原来的进程,重新发出引起缺页的虚拟地址,这次就会变成命中的情况。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是MMU中的一个缓存,用于缓存PTE,也被称为快表。它是一个小型的虚拟寻址缓存,每一行保存了一个单个PTE组成的块,并具有高度的相联性。
多级页表是一种用于压缩页表的结构,主要目的是减少页表的大小。在地址翻译过程中,虚拟地址的页号VPN被分为k个部分,每个VPNi都是指向第i级页表的索引。当1 <= j <= k-1时,VPNi指向第j+1级的某个页表。第k级页表中的每个PTE包含某个物理页面的PPN,或者是一个磁盘块的地址。为了构造物理地址,MMU需要访问k个PTE,然后才能确定PPN。Intel Core i7采用的是一个四级页表层次结构,每个VPNi有9位。当未命中时,36位的VPN被分为VPN1、VPN2、VPN3、VPN4,每个VPNi被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址,VPN1提供到一个L1 PTE的偏移量,这个PTE包含某个L2页表的基址。VPN2提供到这个L2页表中某个PTE的偏移量,以此类推。最后得到的L4 PTE包含了需要的物理页号,和虚拟地址中的VPO连接起来就得到相应的物理地址。
TLB能够加速地址翻译,而多级页表能够对页表进行压缩,便于存储大量的页表。
在从虚拟地址(VA)翻译得到物理地址(PA)的过程中,MMU首先使用VPN向TLB请求对应的PTE。如果命中,就直接跳过后续步骤。否则,MMU生成PTE地址,并从高速主存中请求PTE。高速缓存或主存会向MMU返回PTE。如果PTE的有效位为0,表示缺页,MMU会触发缺页异常,缺页处理程序确定物理内存中的牺牲页(如果页面已被修改,则换出到磁盘)。然后缺页处理程序调入新的页面,并更新PTE。最后,缺页处理程序返回到原进程,并重新执行导致缺页的指令。
7.5 三级Cache支持下的物理内存访问
通过内存地址的组索引获得值,如果对应的值是data则像L1 d-cache对应组中查找,如果是指令,则向L1 i-cache对应组中查找。将L1对应组中的每一行的标记位进行对比,如果相同并且有效位为1则命中,获得偏移量,取出相应字节,否则不命中,向下一级cache寻找,直到向内存中寻找。
7.6 hello进程fork时的内存映射
当调用fork函数时,内核会为新进程创建各种数据结构,并分配给它一个唯一的PID。为了创建新进程的虚拟内存,内核会创建当前进程的mm_struct、区域结构和页表的副本,并将两个进程中的每个页面标记为只读,将每个区域结构标记为私有的写时复制。
当fork在新进程中返回时,新进程的虚拟内存布局与调用fork时存在的虚拟内存布局相同。当这两个进程中的任意一个进行写操作时,写时复制机制会创建新的页面,因此为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
Execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效替代了当前程序。加载并运行a.out需要以下几个步骤:
删除已存在的用户区域:删除进程虚拟地址中已存在的用户区域;
映射私有区域:为新程序的代码、数据、bsss和栈区域创建新的区域结构。这些区域都是私有的、写时复制的。代码和数据区域被映射成a.out文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。
映射共享区域:如果a.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器:execve做的最后一件事就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障是指一个虚拟页没有被缓存在DRAM中,即DRAM缓存未命中,这种情况被称为缺页。当CPU引用一个页表条目中的一个字时,而该页表条目并未被缓存在DRAM中,地址翻译硬件会从内存中读取该页表条目,通过检查有效位是否为0来判断该页是否已被缓存,如果没有被缓存,则会触发缺页异常。
缺页中断处理程序会被调用来处理缺页异常。该程序首先会选择一个牺牲页,如果这个牺牲页在DRAM中已被修改,那么就会将其写回磁盘。然后将引用的虚拟页复制到内存中原来牺牲页所在的位置,并更新页表条目。处理程序完成后会返回,重新执行引起缺页的指令。此时,所需的虚拟页已经被缓存在主存中,因此地址翻译硬件可以正常处理页命中。
7.9动态存储分配管理
printf函数会调用malloc,以下简述动态内存管理的基本方法与策略:
动态内存分配器维护着一个进程的虚拟内存区域中的堆。分配器将堆视为一组不同大小的块的集合来维护。每个块是一个连续的虚拟内存片,可以是已分配的,也可以是空闲的。已分配的块明确保留供应用程序使用。空闲块可用于分配。空闲块保持为空闲状态,直到被应用程序显式分配。已分配的块保持已分配状态,直到被释放,这种释放可以是应用程序显式执行的,也可以是内存分配器自身隐式执行的。
内存分配器分为两种基本风格:显式分配器和隐式分配器。 显式分配器要求应用程序显式释放任何已分配的块,例如C语言中的free,C++中的delete。 隐式分配器要求分配器检测已分配块何时不再被使用,然后释放这些块。自动释放未使用的已分配块的过程称为垃圾收集。Java、ML等高级程序设计语言依赖垃圾收集来释放已分配的块。
显式分配器需要一些数据结构来记录块的边界、位置和状态(空闲或已分配)。一种简单的方法是使用隐式空闲链表,一种更通用的方法是使用显式空闲链表。显式空闲链表对块的排序策略有两种:一种是按照后进先出的顺序维护链表,另一种是按照地址顺序维护链表。
隐式分配器的垃圾收集器将内存视为一个有向可达图,图中的节点分为根节点和堆节点。堆节点对应堆中的已分配块,根节点包含指向堆的指针。根节点的位置可以是寄存器、栈中的变量,或者虚拟内存中读写数据区域的全局变量。堆被视为垃圾当且仅当从任意根节点到该堆节点都不可达。
7.10本章小结
本节介绍了几种地址空间的概念;介绍了逻辑地址到线性地址的转化,介绍了线性地址与物理地址之间的转化(分页机制);介绍了分页机制的原理和硬件优化;介绍了fork和execve中有关虚拟地址的操作;介绍了缺页故障及其处理,最后简述了动态存储分配管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个字节的序列,所有的I/O设备都被模型化为文件,所有的输入输出都被当作是文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,被称为是Unix I/O,这使得所有的输入和输出都能够以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
1.Unix接口:
(1)打开文件:一个应用程序通过内核打开文件,来宣告它想访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
(2)I/O设备:内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
(3)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
(4)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
(5)关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件,作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放他们的内存资源。
2.Unix I/O函数:
(1)int open(char *filename, int flags, mode_t mode);
进程通过调用open函数打开一个已存在的文件或者创建一个新文件:
Open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:O_RDONLY:只读、O_WRONLY:只写和O_RDWR可读可写。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 write(int fd, const void *buf, size_t n);
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
Printf的函数体:
va_list是一个字符指针的重定义,(char*)((&fmt) + 4 )是第一个参数,这与栈的结构有关,*fmt存放在栈中,后续的字符型指针也都存在栈中,而指针大小位四个字节,所以+4得到第一个参数。之后调用了vsprintf函数,其函数体如下图所示:
vsprintf主要用于格式化,接受输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
函数体如下:
getchar函数调用了read函数,通过系统调用read读取存储在键盘缓冲区的ASCII码,直到读到回车符才返回。不过read函数每次会把所有内容读进缓冲区,如果缓冲区本来非空,则不会调用read函数,而是简单的返回缓冲区最前面的元素。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了hello的IO管理机制,先简述了IO设备被抽象为文件的现象,随后介绍了IO的设备管理方法——unix IO接口,随后对unixIO接口做了介绍之后,给出了Unix IO接口的相关函数,并在此基础上,对printf和getchar的视线分析做了介绍。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
1.编写hello.c的源程序
2.预处理,将c文件调用的库与原本的c文件进行合并,得到hello.i文本文件
3.编译:hello.i编译生成汇编语言文件hello.s
4.汇编:hello.s汇编得到二进制可重定位目标文件hello.o
5.链接:hello.o与其它调用库函数所在的可重定位目标文件和动态链接库链接生成可执行文件hello,hello至此可以被加载入内存并运行。
6.创建进程:终端shell调用fork函数,创建一个子进程,为程序的加载运行提供虚拟内存空间等上下文
7.加载程序:shell调用execve函数,启动加载器映射虚拟内存,之后开始载入物理内存,在进入main函数
8.访问内存:通过TLB和多级页表,实现虚拟内存和物理内存的翻译,进而访问计算机的存储结构,访问内存。
9.IO:hello输入输出与外界进行交互,与Linux IO的抽象有关
10.终止:hello被父进程回收,内核收回为其创建的所有信息。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
计算机系统的设计与实现涉及的复杂性远超我们的想象。在日常生活中,我们使用计算机的各种功能时往往感觉轻松简单,然而背后的实现却是极其复杂的。Hello的一生向我们展示了计算机科学领域的复杂性,没有任何功能是理所当然的,一切都是建立在前人深刻的思考和创新之上。学习计算机领域需要投入大量的时间和精力,深入研究其中的原理和技术,不断追求至善。这个领域的学习需要不断地探索和实践,以理解并应对日益复杂的挑战。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.i 预处理产生的文本文件,是编译的输入
hello.s 编译产生的汇编代码文本文件,是汇编的输入
hello.o 汇编产生的二级制的可重定位目标文件,是链接的输入
hello 用ld链接hello.o以及需要用到的动态库和.o文件生成的可执行目标文件,用于运行程序,观察动态链接过程。
hello1 用命令gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC hello.c -o hello1生成的可执行目标文件
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 兰德尔·E,布莱恩特等. 深入理解计算机系统[M]. 北京:机械工业出版社,2016.7(2022.1重印)
[2] 月光下的麦克 readelf指令使用.2023-0201 http://t.csdnimg.cn/mpcVG[3] 编译和链接的过程_douguailove的博客-CSDN博客_编译过程,编译和链接的过程_编译链接-CSDN博客。
(参考文献0分,缺失 -1分)