计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能
学 号 2022111637
班 级 22wl022
学 生 张格玮
指 导 教 师 刘宏伟
计算机科学与技术学院
2024年5月
本文结合计算机系统所学知识,以Hello.c程序为例,完整探索程序在计算机系统下的每步实现过程,包括预处理、编译、汇编、链接、进程管理、存储管理、IO管理,展示了hello程序在计算机系统中的运行过程,hello程序一生的背后,是计算机系统处理程序和进程的本质。
关键词:P2P、预处理、编译、汇编、链接、操作系统、进程管理、存储管理;
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
1.1.1 P2P指的是Program to Process,即从源文件到可执行目标文件然后被分配进程执行的过程。生成可执行目标文件中间经历了预处理,编译,汇编,链接这四个具体的过程。
P2P主要有以下环节:
(1)编写源程序hello.c;
(2)hello.c文件经过预处理器预处理,将“#”开头的命令修改源程序,生成新的hello.i文件;
(3)hello.i文件通过编译器编译,生成hello.s的汇编程序文件;
(4)hello.s文件经过编译器将.s文件翻译成机器语言,生成可重定位目标程序hello.o,它是一个二进制文件;
(5)将标准C库中的printf.o函数和hello.o通过链接的方式合并,得到二进制的可执行文件hello;
(6)运行程序并按照提示输入信息;
(7)通过fork创建子程序hello。
经历以上的环节,即可完成hello的P2P过程。
图1-1 hello的编译过程(以gcc为例)
1.1.2 Hello的020过程可以描述为以下步骤:
- 程序加载:操作系统加载可执行文件到内存中,并为其分配资源。
- 进程创建:操作系统创建一个新的进程来运行Hello程序。
- 执行:CPU执行Hello程序的指令,按照程序的逻辑进行计算和操作。
- 程序结束:shell 父进程回收 hello 进程,释放内存并删除数据结构。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:11th Gen Intel(R) Core(TM) i5-1155G7 @ 2.50GHz 2.50 GHz
软件环境:Windows Subsystem for Linux 2 For Ubuntu20.04
Windows 11 家庭版
开发工具:EDB/GCC/GDB/OBJDUMP
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.i | hello.c预编译后的文件。 |
hello.s | hello.i编译后的文件,用于研究汇编语言以及编译器的汇编操作。 |
hello.o | hello.s汇编后的文件,可重定位目标程序。 |
hello | hello.o链接后生成的可执行文件,可以用来反汇编或者通过EDB、GDB等工具分析链接过程以及程序运行过程。 |
disasm_hello.s | hello.o经过反汇编生成的汇编语言文件 |
disasm.txt | 可执行文件的反汇编文件 |
1.4 本章小结
本章对hello进行了一个总体的概括,包括P2P、020的意义和过程,介绍了作业中的硬件环境、软件环境和开发工具,最后简述了从.c文件到可执行文件中间经历的过程。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理指的是程序在编译之前进行的处理,是计算机在处理一个程序时所进行的第一步处理,可以进行代码文本的替换工作,但是不做语法检查。
预处理是为编译做的准备工作,能够对源程序. c文件中出现的以字符“#”开头的命令进行处理,包括宏定义#define、文件包含#include、条件编译#ifdef等,最后将修改之后的文本进行保存,生成. i文件。
2.1.2预处理的作用:
(1) 宏展开:预处理程序中的“#define”标识符文本,用实际值(可以是字符 串、代码等)替换用“#define”定义的字符串。
(2) 文件包含复制:通过预处理指令#include,可以将其他文件的内容插入到源代码中。这样可以将共享的代码放在头文件中,通过包含头文件可以在多个源文件中共享这些代码。
(3) 条件编译处理:根据“#if”和“#endif”、“#ifdef”和“#ifndef”后面的 条件确定需要编译的源代码。
(4) 注释:删除C语言源程序中所有的注释;
(5) 符号定义:预处理器可以通过预处理指令#define进行符号定义,用于简化代码中的重复性操作,提高代码的可读性和维护性。
2.2在Ubuntu下预处理的命令
命令:gcc hello.c -E -o hello.i
图2-1 Ubuntu下预处理命令
2.3 Hello的预处理结果解析
经过预编译过程后,文件拓展至3092行。在其中,文件中所有的注释已经消失。完成了对头文件的展开,对宏定义的替换等内容,文件末尾为main函数内容。如2-2图所示。
图2-2
2.4 本章小结
本章介绍了预处理的概念和作用,并对hello的预处理结果进行了简要的分析,看到了预处理后的文件内容及宏定义的替换等功能。预处理过程对于代码的优化、复用和可配置性起到了重要的作用。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译是指将预处理后的文件.i生成到.s即汇编语言程序的过程。
3.1.2编译的作用
编译会对预处理文件进行语法检查、错误检测和报告、代码优化等操作,将C语言这种高级语言转换为成更低级、更底层、机器能理解的汇编语言程序。
3.2 在Ubuntu下编译的命令
命令:gcc hello.i -S -o hello.s
图3-1 Ubuntu下编译命令
3.3 Hello的编译结果解析
3.3.1数据
- 局部变量
i
int i是一个未初始化的局部变量,根据它的第一次使用赋值0,找到它存放的栈,位置是-4(%rbp)。
argc
argc由寄存器%edi保存,然后又被存入-20(%rbp)。
- 字符串
"Hello 学号 姓名 秒数!\n"和"Hello %s %s\n"存放在hello.s开头.rodata 节
3.3.2赋值
i=0:使用movl操作对i进行赋值操作,将立即数0传给i。
3.3.3算术操作
i++:for循环中的i++操作,用addl将-4(%rbp)中的值加1,再存入其中。
3.3.4逻辑操作
argc!=5:if语句中判断argc是否不等于5,用cmpl比较argc与立即数5,跳转指令je代表如果相等则跳转到.L2,也就是跳出循环;若不相等则执行je下一行程序。
3.3.5关系操作
i<10:在for循环中的条件为i<10,用cmpl比较-4(%rbp)与立即数9的大小,跳转指令jle表示若小于等于9(即小于10)则跳转到.L4(即for循环中的操作),若大于则执行jle下一行程序(即for循环之后的程序)。
3.3.6数组/指针/结构操作
数组argv:
char *argv[], main函数执行时输入的命令行,在用到argv[1]、argv[2]、argv[3]等,需要用到movq、addq等操作,因为argv作为一个指针数组,每个地址是8位。
3.3.7控制转移
1.if语句
3.3.4中,if语句的控制转移用到了cmpl和je操作。
2.for循环
3.3.5中,for循环的控制转移用到了cmpl和jle操作。
3.3.8函数操作
- main
main函数的调用是pushq寄存器%rbp,movq%rsp到%rbp,保存栈基地址,栈指针减32,开一个32的空间;
main函数的返回是将%eax设置为0,然后释放堆栈空间,返回%eax, 即 return 0。
main函数调用:
main函数返回:
- printf
调用printf函数是将字符串传到%rdi,再调用puts打印出来。
- exit
exit是将立即数1传入%edi,然后调用exit退出。
- atoi
将argv[3]传给%rdi,然后调用atoi函数得到整型数
- sleep
将得到的整型数给到%edi,然后调用sleep函数是计算机暂时休眠所给的时间。
- getchar
直接调用getchar
3.4 本章小结
本章介绍了编译的概念与作用,在Ubuntu下编译的指令,了解数据存放,数据运算,函数调用以及分支控制等的相关实现,加深了对汇编语言的了解。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念:
汇编是指将汇编语言程序.s经过编译器(as)转化为二进制的机器语言指令,并把这些指令打包成可重定位目标程序的格式,并保存在目标文件.o中。
4.1.2汇编的作用:
汇编的作用是把汇编语言翻译成机器语言,用二进制码0、1代替汇编语言中的符号,即让它成为机器可以直接识别的程序。
4.2 在Ubuntu下汇编的命令
命令:gcc hello.s -c -o hello.o
图4-1 Ubuntu下汇编命令
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
4.3.1 ELF可重定位目标文件格式
图4-2 典型的可重定位目标文件
4.3.2 ELF中各节的解析
1. ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。其中包括ELF头的大小,目标文件的类型,机器类型,节头部表的文件偏移,以及节头部表中条目的大小和数量。不同的节位置和大小都是由节头部表描述的。
2. .text节
已编译的机器代码。
3. .rodata
只读数据,比如printf语句中的格式串和开关语句的跳转表,立即数。
4. .data
已初始化的全局和静态C变量。
5. .bss
未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际空间,它仅仅是一个占位符。运行时,在内存中分配这些变量,初始值为0。(事实上,未初始化的全局变量分配至伪节COMMON中)
6. .symtab
一个符号表,存放在程序中定义和引用的函数和全局变量信息。(局部非静态变量在栈中,与上述节无关)
7. .rel.text
一个.text节中位置的列表,即链接时需要修改的位置。
8. .rel.data
被模块引用或定义的所有全局变量的重定位信息。
9. .debug
一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量以及原始的C源文件。(只有-g选项编译时才有)
10. .line
原始C源程序中的行号和.text节中机器指令之间的映射。(只有-g选项编译时才有)
11. .strtab
一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的接名字。
4.3.3 hello各节信息
1. ELF头
如图4-3所示,包括ELF头的大小,目标文件的类型(如可重定位、可执行或者共享的),机器类型(如 X86-64 AMD),节头部表的文件偏移,以及节头部表中条目的大小和数量。
图4-3 ELF头
2. hello.o各节
如图4-4所示,详细标识了每个节的名称、类型、地址、偏移量、大小、读取权限、对齐方式。
图4-4 hello.o中各节信息
3. hello.o符号表
如4-5图所示:
图4-5 hello.o符号表
4. hello.o可重定位节
如图4-6所示,在ELF表中有两个.rel节,分别是.rela.text和.rela.eh_frame。内容有偏移量、信息、类型、符号值、符号名称等等。
图4-6 可重定位节
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o > disasm_hello.s
图4-7 hello反汇编
hello.o反汇编和hello.s的对比:
1. 操作数进制不同:.o反汇编文件操作数是十六进制,.s文件操作数是十进制,如图4-7所示:
图4-8 操作数对比
2. 跳转指令不同:.o反汇编文件按照所在节起始地址+地址偏移量进行的,还给出了可重定位条目,.s的文件是按照.L2等进行跳转,如图4-8所示:
图4-9 跳转指令
3. 函数调用不同:.o文件call+函数起始地址,.s文件call+函数名,如图4-9所示:
图4-10 函数调用
4.5 本章小结
本章对hello.s进行了汇编,生成了hello.o可重定位目标文件,并且分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,比较了hello.s和hello.o反汇编代码的不同之处,通过对比我们发现:反汇编文件不包含注释和高级语言的符号信息,因为它是从二进制文件中提取的。此外,反汇编文件更接近于底层机器代码的表示形式,指令和操作数使用底层的寄存器和内存地址,以及机器码的十六进制表示。分析了从汇编语言到机器语言的一一映射关系,从而更深刻地理解汇编这一过程。
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念:
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
5.1.2链接的作用:
链接使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,我们可以独立的修改和编译那些更小的模块,这也更便于我们维护管理我们的代码。使代码变得简洁,可移植性强,模块化成都较高。
5.2 在Ubuntu下链接的命令
在终端输入以下指令对hello.o进行链接:
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.3 可执行目标文件hello的格式
指令:readelf -a hello > hello.elf
得到可执行目标文件hello的elf格式文件hellold.elf。
图5-2
5.3.1 ELF Header
图5-3
在该ELF HEADER中我们可以获得程序入口等信息,与hello.o文件的ELF节头部表相比,程序地址入口变为0x4010f0,节头部表的起始地址变为13560字节。
5.3.2 Section Header
图5-4
图5-5
这是一个ELF格式的可执行文件的节头部信息。每个节都由一个节头部描述,其中包含有关节的名称、类型、大小、偏移量、对齐方式等信息。
5.3.3 Relocation Header
图5-6
与hello.o的ELF格式相比,现在所有的value和addend都为0说明我们已经链接成功,实现了重定位效果。
5.3.4 Symbol table
图5-7
与hello.o的ELF格式不同,每个符号分配了Type,Ndx也发生了变化,说明了链接的作用。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
如图5-8所示,可知程序的地址为0x00401000-0x00402000段中,看出hello的虚拟地址空间开始于0x400000,结束与0x400ff0。我们还可以通过节头表的信息知道各节的起始地址,例如.text节的起始地址是0x4010f0。。
图 5-8 edb分析
5.5 链接的重定位过程分析
如图5-8所示,hello比hello.o多出了init、plt、fini这几节
图5-9
图5-10
5-11 额外节
以下是对其的分析:
init包含程序初始化时需要的代码。
plt节也称为过程链接表,其包含了动态链接器调用从共享库导入的函数所必需的相关代码。由于.plt节保存了代码,所以节类型为SHT_PROGBITS 。
fini包含进程终止时要执行的指令代码少了.rel.text 和.rel.data 节等重定位信息节。多了一个程序头表也叫作段头表。
链接的过程:链接就是链接器将各个目标文件组装在一起,文件中的各个函数段按照一定规则累积在一起。
hello重定位:有重定位PC相对引用和绝对引用,对于PC相对引用,将地址改为PC值-跳转目标位置地址。绝对引用则将地址改成该符号的第一个字节所在的地址。
5.6 hello的执行流程
_dl_start | 0x7f8f00953df0 |
_dl_init | 0x7f7988ad5c10 |
_start | 0x4010f0 |
_libc_start_main | 0x7f753a71bfc0 |
_libc_csu_init | 0x4011c0 |
_init | 0x401000 |
_setjmp | 0x7f753a73acb0 |
_sigsetjmp | 0x7f753a73abe0 |
main | 0x401125 |
5.7 Hello的动态链接分析
动态链接的内容为.got .got.plt
它们的地址分别是0x403ff0和0x404000,如下图:
图5-12
通过edb,找到内存所在位置:
.got(0x403ff0):
图5-13
.got.plt(0x404000):
图5-14
.got变化:
图5-15
.got.plt变化:
图5-16
5.8 本章小结
本章主要介绍了链接的概念及链接的作用,利用链接指令在Ubuntu下将hello.o文件经过链接器(ld)生成了可执行目标文件hello。然后分析了hello的ELF格式,并用readelf等列出了其各节的基本信息。同时了解了hello的虚拟地址空间知识;并通过反汇编 hello 文件,将其与 hello.o 反汇编文件对比,详细了解了重定位过程;遍历了整个 hello 的执行过程,在最后对hello进行了动态链接,通过edb调试,分析了在dl_init前后,.got .plt节的内容变化。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念:
狭义定义:进程是计算机科学中最深刻,最成功的概念之一。进程的经典定义就是一个执行中程序的实例,进程拥有一个独立的逻辑控制流和私有的地址空间。
广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
6.1.2进程的作用:
通过进程这个概念,我们在运行一个程序的过程中会得到一个假象,我们的程序运行时好像是系统中当前运行的唯一一个程序一样。我们的程序好像是独占的使用处理器和内存。处理器好像就是无间断的一条接一条的执行我们程序中的指令。最后我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 壳Shell-bash的作用
shell是用户和Linux(Linux内核)之间的接口程序。你在提示符下输入的每个命令都由shell先解释然后传给Linux内核。
shell 是一个命令语言解释器。拥有自己内建的shell命令集。此外,shell也能被系统中其他有效的Linux 实用程序和应用程序所调用。
6.2.2壳Shell-bash的处理流程:
(1)终端进程读取用户由键盘输入的命令行;
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量;
(3)检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令;
(4)如果不是内部命令,调用fork( )创建新进程/子进程;
(5)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序;
(6)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait等待作业终止后返回。
(7)如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程
1.首先在终端输入./hello。它不是shell的内置命令,shell会从文件系统中找到当前目录下的hello文件并执行。
2. Shell调用fork函数,创建一个子进程。子进程除了进程号之外与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,当父进程调用fork时,子进程可以读写父进程中的任何文件。
3. hello将在fork创建的子进程中执行。内核能够以任意方式交替执行父子进程的逻辑控制流的指令,父进程与子进程是并发运行而独立的。在子进程执行期间,父进程等待子进程的完成。
6.4 Hello的execve过程
在shell给hello进行fork()函数创建子进程之后,会调用execve函数,在进程的上下文中加载并运行hello,调用_start创建新的且被初始化为0的栈等,随后将控制给主函数main,并传入参数列表和环境变量列表。只有当出现错误时,execve才会返回到调用程序,否则,execve调用一次且不返回。在execve加载完毕可执行目标文件hello后,会调用启动代码,启动代码设置栈,将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序,由此将控制转移给新程序的主函数。
6.5 Hello的进程执行
6.5.1上下文信息:
内核在一个进程正在运行的时候,调度了另一个新的进程运行后,它就抢占当前进程,并使用上下文切换来控制转移到新的进程。我们需要保存以前进程的上下文,恢复新恢复进程被保存的上下文,将控制传递给这个新恢复的进程来完成上下文切换。
6.5.2进程时间片:
进程执行它的控制流的一部分的每一时间段。
6.5.3进程调度的过程,用户态与核心态转换:
当开始运行hello时,内存为hello分配时间片,如一个系统运行着多个进程,那么处理器的一个物理控制流就被分成了多个逻辑控制流,逻辑流的执行是交错的,它们轮流使用处理器,会存在并发执行的现象。其中,一个进程执行它的控制流的一部分的每一时间段叫做时间片。然后在用户态下执行并保存上下文。如果在此期间内发生了异常或系统中断,则内核会休眠该进程,并在核心态中进行上下文切换,控制将交付给其他进程。当hello执行到sleep时,hello会休眠,再次上下文切换,控制交付给其他进程,一段时间后再次上下文切换,恢复hello在休眠前的上下文信息,控制权回到hello继续执行。hello在循环后,程序调用 getchar,hello从用户态进入核心态,并再次上下文切换,控制交付给其他进程。最终,内核从其他进程回到hello进程,在return后进程结束。
6.6 hello的异常与信号处理
异常可以分为四类:中断、陷阱、故障、终止。
1.正常运行
在shell中输入如下命令./hello 2022111637 张格玮 1。hello每隔一秒打印一行“Hello 2021112925 张格玮”,进入循环,共打印10次。打印完毕后,调用getchar()函数,等待用户输入回车后程序终止。Shell回收hello子进程,继续等待用户输入指令。
图6-1 正常运行
2.不停乱按
在shell中输入如下命令./hello 2022111637 张格玮 1,并在程序执行过程中不停乱按时,按下的字符串会直接显示,但不会干扰程序的运行,由于在乱按过程中没有输入回车,所以在最后一行hello的字符串打印完毕后,需要敲一个回车才能退出程序。
图6-2 不停乱按
3.回车
在shell中输入如下命令./hello 2022111637 张格玮 1,并在hello执行过程中敲回车,会首先在打印的过程中显示换行,一个回车对应一个换行。在打印完毕最后一行字符串后,由于输入的回车依然存在于stdin中,所以在调用getchar()函数时,会读取stdin中的回车,因此无需再敲回车键,便能终止程序。程序终止后,发现shell中出现9个空行,这是因为在程序的执行过程中,敲了大于10下回车键,因此都留在stdin中,getchar()只接收了其中的第一个回车,由于在程序终止后没有清空stdin,剩余的回车保留在其中。当shell继续运行时,遇到回车便开始处理,但单独的回车相当于一个空行,被shell忽略,读入但不执行任何操作,因此留下了9个空行。
图6-3 不断敲回车
在shell中输入如下命令./hello 2022111637 张格玮 1,并在程序执行过程中按Ctrl-z,产生中断异常,发送信号SIGSTP,这时hello的父进程shell会接收到信号SIGSTP并运行信号处理程序。导致hello被挂起,并打印相关信息。
图6-4 hello运行时按Ctrl-z
此时在shell命令行中输入ps,会打印出各进程的pid,其中包括被挂起的hello。
图6-5 Ctrl-z后输入ps
在shell命令行中输入jobs,会打印出被挂起的hello的jid及标识。
图6-6 Ctrl-z后输入jobs
在shell命令行中输入pstree ,可以查看进程树。
图6-7 Ctrl-z后执行pstree
在按Ctrl-z之后,在shell命令行中输入fg,被挂起在后台的hello进程被重新调到前台执行,打印出剩余部分,按回车后终止程序。
图6-8 Ctrl-z后执行fg
Ctrl-z之后,输入ps,得到hello的pid为3603,因此,在shell中输入kill -9 3603,可以发送信号SIGKILL给进程,该进程被杀死。
图6-9 Ctrl-z后执行kill
- 按Ctrl-c
hello运行时按Ctrl-C,会导致异常,从而内核产生信号SIGINT,发送给hello的父进程,父进程收到它后,向子进程发生SIGKILL来强制终止子进程hello并回收它。再运行ps,可以发现并没有进程hello,可以说明他已经被终止并回收了。
图6-10 在hello执行过程中按Ctrl-c
6.7本章小结
本章介绍了进程的定义与作用,介绍了Shell的一般处理流程和作用,分析了调用fork和execve函数的过程。分析了执行hello过程中可能产生的各种异常和所发出的信号,在执行hello的过程中,使用shell的各种指令以测试分析异常和信号处理机制。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址:逻辑地址是相对于进程而言的,它是程序在运行时使用的地址,而不考虑这些地址在物理内存中的实际位置。在 hello 中,生成的 hello.o 文件中的地址即偏移量,都是逻辑地址。
2.线性地址:对现代计算机而言,线性地址等同于虚拟地址。
3.虚拟地址:虚拟地址是逻辑地址计算后的结果,通过页表和MMU翻译形成对物理地址的映射。每个进程都被视为有一个独立的虚拟地址空间,因此虚拟地址也就是线性的。通过对可执行文件的反汇编可得到基于虚拟地址寻址的代码。
4.物理地址:物理地址是指计算机主存(RAM)中实际存储数据的位置。物理地址是硬件直接处理和使用的地址,它指向计算机内存模块中的具体存储单元。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel x86 架构中的段式管理是一种内存管理机制,用于将逻辑地址(Logical Address)转换为线性地址(Linear Address)。这个机制包含两个主要步骤:段选择和偏移量。如图7-1所示。
段寄存器(16位),用于存放段选择符。CS(代码段):程序代码所在段。SS(栈段):栈区所在段。DS(数据段):全局静态数据区所在段。其他3个段寄存器ES、GS和FS可指向任意数据段。
段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。用GDT或是LDT由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT。
图7-1 逻辑地址到线性地址的变换
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种将线性地址转换为物理地址的内存管理方法。它将内存分为若干个固定大小的页,每个页都有一个物理地址。页式管理通过使用页表来将线性地址转换为物理地址。
在页式管理中,页是内存的基本单位。每个页都有一个唯一的物理地址,用于访问该页。CPU 通过查找页表来查找与给定线性地址相关的物理地址。页表是一个包含所有进程的页表项的表格,每个页表项都包含一个物理地址和线性地址之间的映射信息。
在 Intel x86 架构中,页的大小通常是 4KB,并且每个进程都有一个页表。页表由若干个页表项组成,每个页表项都包含一个物理地址和线性地址之间的映射信息。当CPU需要访问某个线性地址时,它会查找该进程的页表,查找与该线性地址相关的物理地址。如果找到了相应的物理地址,则 CPU 将使用该地址来访问内存。如果未找到相应的物理地址,则会发生页面错误异常,CPU 将启动页面调度程序来处理该异常。
7.4 TLB与四级页表支持下的VA到PA的变换
现代 CPU 都包含一张名为 TLB(Transfer Look-aside Table),叫做快表,或者高速地址变址缓存,以加速对于页表的访问。TLB通常有高度的相联度。用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2^t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
若TLB命中,会经历如下步骤:CPU产生一个虚拟地址;MMU从TLB中取出相应的PTE;MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存;高速缓存/主存将所请求的数据字返回给CPU。
若TLB不命中,对于四级页表来说虚拟地址被划分成4个VPN和1个VPO,VPN的每个片表示一个到第i级页表的索引,即偏移量,CR3寄存器包含L1页表的物理地址余下的页表中,第j级页表中的每个PTE,1≤j≤3,都指向j+1级的某个页表的基址。最后在L4页表中对应的PTE中取出PPN,与VPO连接,从而形成物理地址PA。
经过四级页表支持下的VA到PA的变换,虽然所经历的步骤更多,但如果一级页表的一个PTE是空的,对应的二级页表就不会存在,因此可以节省大量未被使用的空间。
7.5 三级Cache支持下的物理内存访问
在三级Cache支持下,物理内存访问的过程可以分为三个阶段:L1 Cache、L2 Cache和L3 Cache。
首先,当CPU访问物理内存时,它会首先在L1 Cache中查找对应的数据。如果L1 Cache中存在对应的数据,则CPU可以直接使用它来进行计算。否则,CPU需要访问L2 Cache。
其次,如果L1 Cache中不存在对应的数据,则CPU会在L2 Cache中查找对应的数据。如果L2 Cache中存在对应的数据,则CPU可以将它加载到L1 Cache中,并使用它来进行计算。否则,CPU需要访问L3 Cache。
最后,如果L2 Cache中不存在对应的数据,则CPU会在L3 Cache中查找对应的数据。如果L3 Cache中存在对应的数据,则CPU可以将它加载到L2 Cache中,并将它再加载到L1 Cache中,最终使用它来进行计算。否则,CPU需要访问主存。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的页面标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
exceve 函数加载和执行程序 Hello,需要以下几个步骤:
1.删除已存在的用户区域。
2.映射私有区域。为 Hello 的代码、数据、bss 和栈区域创建新的区域结构,所有这些区域都是私有的、写时复制的。
3.映射共享区域。比如 Hello 程序与标准 C 库 libc.so 链接,这些对象都是动态 链接到 Hello 的,然后再用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。exceve 做的最后一件事就是设置当前进程的上下文中。
7.8 缺页故障与缺页中断处理
当页表条目的有效位是0,MMU触发一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
处理程序执行下面的步骤:
(1)判断虚拟地址是否合法;缺页处理程序搜索区域结构的链表,把虚拟地址和每个区域的始端与末端作比较,如果这个指令是不合法的,那么缺页处理程序就处理一个段错误,从而终止这个进程。
(2)判断试图进行的内存访问是否合法;进程是否有读、写、执行这份区域页面的权限,如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。
(3)进行合法操作;选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页异常处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次将虚拟地址发送给MMU,这次将正常翻译。
7.9动态存储分配管理
动态存储分配管理是指在程序运行时,根据需要动态地分配和释放内存。这是为了灵活使用计算机内存而采取的一种策略,与静态存储分配相对,后者在程序编译时就已经确定了内存的分配和释放。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
- 显式分配器(Explicit Allocator):
在显式分配器中,程序员明确地指定何时分配内存以及何时释放内存。程序员需要手动调用相应的分配和释放函数,通常是类似于 malloc、free、calloc、realloc 等函数。
- 隐式分配器(Implicit Allocator):
在隐式分配器中,程序运行时系统自动管理内存的分配和释放,程序员无需显式地调用分配和释放函数。常见的实现包括垃圾回收机制,其中系统会自动检测并回收不再被引用的内存。
7.10本章小结
本章介绍了逻辑地址、线性地址、虚拟地址和物理地址的概念,分析了Intel逻辑地址到线性地址的变换、线性地址到物理地址的变换以及TLB与四级页表支持下的VA到PA的变换描述了三级Cache支持下的物理内存访问。列出了hello进程fork时、execve时的内存映射,以及缺页故障与缺页中断处理。概括了动态存储分配管理的步骤,从抽象到具体的阐述了hello的存储管理。
结论
C 语言编写 hello.c;
预处理 hello.c,得到 hello.i;
编译 hello.i 成为汇编文件 hello.s;
汇编 hello.s,将其变为可重定位目标文件 hello.o;
链接,将hello.o与可重定位目标文件和动态链接库链接成为可执行程序hello;
运行hello,在shell中输入./hello 2022111637 张格玮 1;
shell 调用 fork 创建子进程;
shell 调用 execve,映射虚拟内存;
进入程序,载入物理内存,进入 main 函数;
执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺
序执行自己的控制逻辑流;
访问内存:MMU 将程序中使用的虚拟内存地址通过页表映射成物理地址;
动态申请内存:printf 会调用 malloc 向动态内存分配器申请堆中的内存;
信号:运行途中键入不同指令,做出不同反应;
结束:shell 父进程回收子进程,内核删除为这个进程创建的所有数据结构。
附件
hello.c: C语言程序源代码
hello.i: hello.c预处理后生成的汇编文件
hello.s: hello.i编译后生成的汇编文件
hello.o: hello.s汇编后生成的二进制机器指令
hello: 链接后生成的可执行文件
hello.elf: hello的elf格式文件
disasm_hello.s: hello.s的反汇编文件
参考文献
[1] LINUX 逻辑地址、线性地址、物理地址和虚拟地 https://www.cnblogs.com/zengkefu/p/5452792.html
[2] 异常控制流处理https://blog.csdn.net/zy691357966/article/details/5137136
[3] (美)兰德尔E.布莱恩德等著;龚奕利,贺莲译. 深入理解计算机系统[M]. 北京:机械工业出版社,2016.7
[4] Pianistx.printf 函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html