计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能
学 号 2021110497
班 级 2103601
学 生 张雨婷
指 导 教 师 郑贵滨
计算机科学与技术学院
2023年5月
相信每一个程序员的学习之旅都是从hello开始的。本文将探索hello的一生:hello.c在linux系统中从源代码到经过预处理、编译、汇编、链接,在CPU、RAM、Cache、OS等的帮助下,最终生成可执行目标文件hello。之后通过在shell 中输入启动命令,shell 为其创建子进程,内核为新的子进程创建数据结构,hello从可执行程序变成为进程。子进程通过execve加载并执行该程序时,映射虚拟内存,程序在开始时载入物理内存,进入CPU处理。程序运行结束后,shell父进程会回收僵死子进程,内核负责清除它。
关键词:预处理;编译;汇编;链接;进程
目 录
第1章 概述
(0.5分)
1.1 Hello简介
- P2P
P2P(From Program to Process):用户用高级语言编写hello.c程序(program)。hello.c在Linux系统中运行shell程序,输入命令gcc hello.c -o hello,可以得到可执行目标文件hello。从hello.c到可执行目标文件hello的转变中,经历了高级语言源程序hello.c通过预处理器(cpp)得到一个修改了的高级语言程序(文本文件hello.i);然后hello.i通过编译器(ccl)得到汇编语言程序(文本文件hello.s;之后,hello.s再通过汇编器(as)生成一个机器语言程序(二进制文件hello.o);接下来,hello.o通过链接器(ld),将调用的标准C库中的函数(如printf等)对应的预编译好了的可重定位目标文件链接到hello.o中,得到可执行目标文件hello。可执行目标文件可以加载到内存中,由系统执行,即成为一个进程(process)。
- 020
020(From Zero-0 to Zero-0):用户在shell中输入“./hello”后,shell调用fork()函数创建子进程,子进程通过execve加载并执行该程序时,映射虚拟内存,程序在开始时载入物理内存,进入CPU处理。CPU为执行文件hello分配时间片,进行取指、译码、执行等流水线操作。程序运行结束后,shell父进程会回收僵死子进程,内核负责清除它。自此hello由0转换到0,实现了020。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2.30GHz;16G RAM;512G HD Disk
1.2.2 软件环境
Windows10 64位;Vmware 17;Ubuntu 22.04 LTS 64位
1.2.3 开发工具
Visual Studio 2022 64位;CodeBlocks 64位;vim+gcc
1.3 中间结果
hello.c:源代码
hello.i:预处理后的文本文件
hello.s:编译后的汇编文件
hello.o:汇编后的可重定位目标执行文件
hello:链接之后的可执行目标文件
elf.txt:hello.o的ELF格式文本文件
helloelf.txt:hello的ELF格式文本文件
hello.txt:hello.o的反汇编代码
hello-asm.txt:hello的反汇编代码
1.4 本章小结
本章介绍了Hello的P2P,020的整个过程,说明了进行实验的软硬件环境和开发工具。
第2章 预处理
(0.5分)
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理是预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,最后生成.i文本文件的过程。预处理中首先会展开以#起始的行,试图解释为预处理指令(preprocessing directive) 。预处理的结果是得到一个修改过的高级语言程序(文本文件hello.i)。
2.1.2预处理的作用
作用:主要作用有宏定义、文件包含、条件编译。预处理还可以帮助程序员节省工作量,提高程序可读性,便于维护。
宏定义:展开所有的宏定义,并且将所有的#define删除,将宏名替换为文本。.
文件包含:例如预处理程序中的#include,将头文件的内容插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件。
条件编译:处理所有条件预编译指令,根据#if以及#endif和#ifdef以及#ifndef来判断执行编译的条件。
2.2在Ubuntu下预处理的命令
预处理命令:gcc -E hello.c -o hello.i
图2.1 在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
执行预处理命令后,生成了hello.i文件。
图2.2 预处理后,生成的hello.i文件
hello.i有3091行,源程序代码保留,但是其中大部分内容都是预处理阶段新增加的。
图 2.3 hello.i中和hello.c相同部分
图 2.4 hello.i
2.4 本章小结
本章主要介绍了预处理的概念和作用,Ubuntu下预处理的命令,以及生成的hello.i与原始的hello.c的对比。
第3章 编译
(2分)
3.1 编译的概念与作用
3.1.1编译的概念
编译是由hello.i生成hello.s的过程。编译器cc1对hello.i经过一系列的语法分析、语义分析,并根据优化等级(-O)进行优化之后生成了汇编语言程序,hello.s是一个文本文件。
3.1.2编译的作用
编译可以将高级语言程序翻译成为优化后的汇编语言程序,在这个阶段还可以对高级语言程序进行语法检查、调试、修改、覆盖、目标程序优化等操作,以及一些嵌入式汇编的处理。
3.2 在Ubuntu下编译的命令
编译的命令:gcc -S hello.i -o hello.s
图3.1 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
执行编译命令后,生成了hello.s文件。
图3.2 编译后,生成的hello.s文件
3.3.1 伪指令
所有以‘.’开头的行都是指导汇编器和链接器工作的伪指令。
.file:声明源文件
.text:代码节
.rodata:只读代码段
.align 8:数据或者指令的地址对齐方式
.string:声明一个字符串(.LC0,.LC1)
.global:声明全局变量(main)
.type:声明一个符号是数据类型还是函数类型
图3.3 hello.s中伪指令
3.3.2 数据
3.3.2.1 常量
在if语句if (argc != 4)中,常量4的值保存的位置在.text中,作为指令的一部分:
图3.4常量
printf()中的字符串则被存储在.rodata中:
图3.5常量
3.3.1.2 变量
全局变量:
初始化全局变量储存在.data中,其初始化不需要汇编语句,可以直接完成。
局部变量:
局部变量存储在寄存器或栈中,程序中局部变量定义为int i。
在汇编代码中:
图3.6 变量
此处是循环初值i=0的操作,i被保存在栈当中的%rsp-4位置上。
3.3.3 赋值操作
置循环初值i = 0,是通过mov指令实现的。
赋值操作主要有mov指令实现,有以下几种:
movb:一字节
movw:两字节
movl:四字节
movq:八字节
因为int占四字节,所以i=0这条赋值操作利用movl指令来实现。
图3.7 赋值操作
3.3.4 算术操作
汇编语言中有add,sub,imul,xor等算术操作,对应着c语言中的+、-、*、异或等操作。在hello源程序中,每次循环结束,循环变量i都要加1,在汇编程序中,由于i是int型占四字节,i加1是通过addl指令完成的。
图3.8 算术操作
3.3.5 关系操作
cmpl比较立即数7与-4(%rbp)中值(i)的大小并设置条件码。根据条件码判断,当i中的值小于等于7时,程序会跳转到.L4处,即C程序中的i<8时执行for循环。
图3.9 关系操作
3.3.6 数组/指针/结构操作
数组和指针都是通过头指针加上偏移量来处理。结构体通过结构体内部的偏移量来访问。
由movl %edi, -20(%rbp)可知,argc存储在%edi
由movq %rsi, -32(%rbp)可知,argv存储在%rsi
图3.10 数组/指针/结构操作
3.3.7 控制转移
在汇编程序中,条件分支语句if - else以及各种循环都是通过条件跳转来实现的。例如if(argc!=4),就是通过条件跳转实现的。
图3.11 控制转移
图3.12 控制转移
3.3.8 函数操作
汇编程序中的函数调用通常是使用call指令 + 函数名,在调用函数之前,汇编代码都会更新程序计数器%rip的值,确定函数调用结束后应该执行的指令。函数调用的时候前6个参数依次存放在%rdi,%rsi,%rdx,%rcx,%r8,%r9这6个通用寄存器中,其余的参数存放在栈。函数的局部变量存放在栈中或者寄存器中。函数的返回值存放在寄存器%rax中。
图3.13 函数操作
3.3.9 类型转换
argv存放着参数字符串,但是sleep函数的参数类型是int型的,直接把参数传入是不可以的,所以程序调用了标准库中的函数atoi(),把字符串数字转换成int型的变量再传递给sleep使用,在汇编代码中就是call调用了atoi()函数进行类型转换的操作。
图3.14 类型转换
3.4 本章小结
本章主要介绍了编译的概念和作用,Ubuntu下编译的命令,以及生成的hello.s。还介绍了汇编代码是如何实现变量、常量、传递参数、赋值操作、算术操作、关系操作、数组/指针/结构操作、控制转移、函数操作和类型转换。
第4章 汇编
(2分)
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是汇编器(as)将汇编语言(hello.s)翻译成机器语言(hello.o)的过程,得到的hello.o是可重定位目标文件。
4.1.2 汇编的作用
汇编可以生成可重定位目标文件,为下一步与其他程序链接,和动态库,静态库链接做准备。
4.2 在Ubuntu下汇编的命令
汇编的命令:gcc -c hello.s -o hello.o
图4.1 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
导出elf的命令:readelf -a hello.o > ./elf.txt
图4.2 导出elf
图4.3 生成的elf.txt
4.3.1 hello.o的ELF头
ELF首先是一个16B的magic数,该数描述了生成该文件的系统的字的大小和字节顺序,当魔数出现异常时,操作系统会停止加载该程序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
图4.4 hello.o的ELF头
4.3.2 ELF节头部表
不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。节头记录每个节的名称、偏移量、大小、位置等信息。
.text节:已编译程序的机器代码以编译的机器代码。
.rela.text节:一个.text节中的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
.data节:已初始化的静态和全局C变量。
.bss节:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量,在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。
.rodata节:存放只读数据。
.comment节:包含版本控制信息。
.symtab:一个符号表,存放在程序中定义和引用的函数和全局变量的信息。
.strtab节:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部的节名字。
.shstrtab节:该区域包含节的名称。
图4.5 ELF节头部表
4.3.3 重定位节
代码的重定位条目存放在.rela.text中。当链接器把这个目标文件和其他文件进行链接时,会结合这个节,修改.text节中相应位置的信息。
2种常见的重定位类型:
R_X86_64_32:重定位一个使用32位绝对地址的引用.通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
R_X86_64_PC32:重定位一个使用32位PC相对地址的引用。一个PC相对地址就是据程序计数器的当前运行值的偏移量。
重定位节包括偏移量,信息,类型,符号值,符号名称和加数。
图4.6 重定位节
4.3.4 符号表
.symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息,不包含局部变量的条目,在进行符号解析的时候需要用到符号表。
图4.7 符号表
4.4 Hello.o的结果解析
执行汇编命令后,生成了hello.o文件。
图4.8 生成了hello.o文件
反汇编的命令:objdump -d -r hello.o>hello.txt
图4.9 hello.o的反汇编代码
4.4.1机器语言的构成
机器语言是由一系列的0、1代码构成的,是二进制。反汇编文件中使用16进制数来描述。
4.4.2与汇编语言的映射关系
每一个机器指令序列都包含的操作码、操作数等信息,以此一一对应着每一种汇编指令,把汇编语言转换成为机器语言。
4.4.3机器语言与汇编语言不一致分析
(1)操作数:hello.s中操作数是十进制的,而在反汇编代码中操作数是以十六进制表示的,这表明在机器中以二进制的形式存在。
(2)函数调用:hello.s中函数调用的call指令使用的是函数名称,反汇编代码中的call指令使用的是main函数相对偏移地址。
(3)分支转移:hello.s中列出了每个段的段名,分支转移时,跳转指令后用对应的段的名称表示跳转位置;而在hello.o的反汇编代码中每个段都有明确的地址,跳转指令后用相应的地址表示跳转位置。
4.5 本章小结
本章主要介绍了汇编的概念和作用,Ubuntu下汇编的命令,以及生成的hello.o。并分析了可重定位目标elf格式,主要分析了ELF头、节头部表、重定位节和符号表这几个节。还通过objdump得到hello.o的反汇编代码,并与hello.s进行对比,发现了它们大致相同,但有些许差别。
第5章 链接
(1分)
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并运行。链接可以执行于编译时、加载时、运行时。
5.5.2链接的作用
使分块编程成为可能,不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。链接方便了程序的维护,更新和优化。
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.3 可执行目标文件hello的格式
5.3.1 ELF头
文件头中类型由REL(可重定向文件)变为EXEC (可执行文件);入口点地址由0x0(未确定)变为了0x4010f0(确定);程序和节的起始位置和大小都有变化;节个数由14个变为了27个。
图5.2 ELF头
5.3.2 节头
节的数目有显著增加变化,由14变为27。增加的节的作用如下:
.interp: 包含了动态链接器在文件系统中的路径;
.note.ABI-tag: ELF规范中记录的注释部分,包含一些版本信息;
.gnu.hash: 符号的哈希表,用于加速查找符号;
.dynamic,.dynsym,.dynstr: 与动态链接符号相关;
.gnu.version,.gnu.version_r: 与版本有关的信息;
.init_array,.fini_array: 存放函数指针,其中的函数分别在main函数之前之后调用,用于初始化和收尾;
.init,.fini: 存放上述初始化和收尾的代码。
.eh_frame_hdr: 与异常处理相关。
图5.3 节头
5.3.3符号表
可执行文件中的符号表多出很多符号,并且额外又多出一个动态符号表(.dynsym),其中printf()、puts()、atoi()、exit()、getchar()等C标准库函数在动态符号表和符号表中都有表项。除此此外这些符号已确定好了运行时所处的位置,这和可重定向文件不同。
图5.4 符号表
5.3.4 重定位节
相比于hello.o的重定位节,hello的重定位节被分成了两部分,包含动态链接的重定位内容,同时每一个部分都有了准确的偏移量,因而所有的加数都变成了0。
图5.5 重定位节
5.3.5 段节
图5.6 段节
5.4 hello的虚拟地址空间
查看edb中的data dump可以得到hello的虚拟空间地址,从0x401000开始载入程序,一直到0x402000,对应查看段头可以发现,0x401000正好是程序载入地址,查看节头表可以发现,0x401000正好是.init节载入的地址。
图5.7 虚拟地址空间信息
5.5 链接的重定位过程分析
反汇编的命令:objdump -d -r hello > hello-asm.txt
图5.8 反汇编hello的命令
5.5.1 hello与hello.o的不同
(1)链接增加了新的函数:在hello中链接加入了在hello.c中用到的库函数,如exit、printf、sleep、getchar等函数。
(2)链接增加了节:hello中增加了.init节和.plt节和一些节中定义的函数。
(3)函数调用:hello中没有hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存空间地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,所以在.rela.text节中为其添加了重定位条目。
(4)跳转指令参数发生变化。
图5.9 hello的反汇编代码
5.5.2 链接的过程
符号解析和重定位
5.6 hello的执行流程
调用的子程序名和程序地址:
_start 0x4010f0
_init 0x401000
main 0x40112d
puts@plt 0x401090
exit@plt 0x4010d0
_fini 0x4011c0
sleep@plt 0x4010e0
atoi@plt 0x4010c0
printf@plt 0x4010a0
getchar@plt 0x4010b0
5.7 Hello的动态链接分析
动态链接采用了延迟加载的策略,只有在调用函数的时候才会进行符号的映射。动态链接通过使用偏移量表GOT和过程链表PLT的协同工作来实现。
GOT表中存放着函数的目标地址,PLT表则使用GOT中的地址来跳转到目标函数,在程序执行的过程中,dl_init负责修改PLT和GOT。
打开ELF文件,找到节头表,找到.got节的首地址,是0x403ff0,在执行链接前,如图:
图5.10 链接前
链接后如图:
图5.11 链接后
5.8 本章小结
本章主要介绍了链接的概念和作用,Ubuntu下链接的命令,以及生成的可执行目标文件hello。并分析了可执行目标文件hello的elf格式,与可重定位目标文本hello.o的elf格式进行对比。还通过objdump得到hello的反汇编代码,与hello.o进行对比。除此之外,还简单分析了hello的执行过程,最后对hello进行了动态链接分析。
第6章 hello进程管理
(1分)
6.1 进程的概念与作用
6.1.1 进程的概念
进程就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
6.1.2 进程的作用
(1)我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存。
(2)处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Linux系统中,Shell是一个交互型应用级程序,代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程)。
基本功能是解释并运行用户的指令。
重复如下处理过程:
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。
(3)检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令。
(4)如果不是内部命令,调用fork( )创建新进程/子进程。
(5)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
(6)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait...)等待作业终止后返回。
(7)如果用户要求后台运行(如果命令末尾有&号),则shell返回。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
6.4 Hello的execve过程
execve()函数在当前进程的上下文中加载并运行一个新程序。execve()函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量envp。只有当出现错误时,例如:找不到文件名,调用发生错误,execve()才会返回到调用程序,调用成功不会返回。
与fork()不同,fork()一次调用两次返回,execve()一次调用从不返回(必须是成功调用)。
6.5 Hello的进程执行
(1)上下文信息
上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成(不包含代码段和数据段)。
(2)进程的时间片
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
(3)用户态和核心态的转换
处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。上下文切换的时候,进程就处于内核模式。
- 进程调度的过程
在进程执行的某些时刻,内核可以决定抢占当前进程,并且可以重新开始一个先前被抢占了的进程,这种决策便称为调度。调度是由内核中的调度器代码处理的。当内核选择一个新进程运行时,即内核调度了这个进程。在内核调度一个新的进程运行之后,它就可以称之为抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。例如执行sleep()函数,sleep()函数请求调用休眠进程,sleep()将内核抢占,进入倒计时,当倒计时结束后,hello程序重新抢占内核,继续执行。
6.6 hello的异常与信号处理
6.6.1异常类型
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
6.6.2处理方式
(1)异常处理
图6.1 异常处理
- 陷阱处理
图6.2 陷阱处理
- 故障处理
图6.3 故障处理
- 终止处理
图6.4 终止处理
6.6.3 运行时乱按
(1)乱按:乱按不会影响程序的运行,但是会在程序运行结束后对shell发送许多无效指令。乱按的内容被放入缓冲区,等待程序执行结束被shell当作命令读走。
图6.5 乱按
- 输入Ctrl-Z后程序被放入后台并暂停运行
图6.6 输入Ctrl-Z
- Ctrl-C后程序直接结束运行,回到shell等待输入下一条指令
图6.7 输入Ctrl-C
- Ctrl-Z后输入ps,可以查看当前所有进程的相关信息,可以发现此时hello程序仍然存在
图6.8 Ctrl-Z后输入ps
- Ctrl-Z后输入jobs可以查看前台作业号
图6.9 Ctrl-Z后输入jobs
- Ctrl-Z后输入pstree,以树形结构显示了程序和进程间的关系
图6.10 Ctrl-Z后输入pstree
- Ctrl-Z后输入fg,能使被挂起的hello程序变成前台程序继续运行
图6.11 Ctrl-Z后输入fg
- Ctrl-Z后输入kill,对比kill前后两次ps显示的进程信息,可以知道,输入kill -9 4702会将SIGKILL信号发送给进程4702,使得它被终止。
图6.12 Ctrl-Z后输入kill
6.7本章小结
本章主要介绍了进程的概念与作用,壳Shell-bash作用与处理流程,明确了hello的fork进程创建过程与execve过程,通过调用fork()函数与execve()来实现。同时结合了进程上下文信息、进程时间片、用户态与核心态转换等,介绍了hello是如何在shell中作为一个子进程执行的。还分析了hello执行过程中按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等出现的异常、产生的信号,在Ctrl-z后运行ps、jobs、pstree、fg、kill等命令,查看这些指令对应的内容,进程的状态等,说明了异常与信号的处理。
第7章 hello的存储管理
( 2分)
7.1 hello的存储器地址空间
(2)线性地址:指虚拟地址到物理地址变换的中间层,是处理器可寻址的内存空间(称为线性地址空间)中的地址。程序代码产生的逻辑地址加上相应段基址就成了一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换产生物理地址。若是没有采用分页机制,那么线性地址就是物理地址。
(3)虚拟地址:是由程序产生的由段选择符和段内偏移地址组成的地址。
(4)物理地址:指内存中物理单元的集合。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址=段选择符+偏移量
每个段选择符大小为16位,段描述符为8字节。每个段的首地址都存放在自己的段描述符中,而所有的段描述符都存放在一个描述符表中。而要想找到某个段的描述符必须通过段选择符才能找到。通过段选择符可以找到想要的段描述符,从而获取某个段的首地址,然后再将从段描述符中获取到的首地址与逻辑地址的偏移量相加就得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
分页机制把线性地址空间和物理地址空间分别划分为大小相同的块。这样的块称之为页。通过在线性地址空间的页与物理地址空间的页之间建立的映射,分页机制实现线性地址到物理地址的转换。
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。利用页表来管理虚拟页,页表就是一个页表条目(PTE)的数组,每个PTE由一个有效位和一个n位地址字段组成。CPU首先会生成一个虚拟地址,传递给MMU,MMU利用VPN获取PPN与保持和VPO不变的PPO组成物理地址。
图7.1 线性地址到物理地址的变换
7.4 TLB与四级页表支持下的VA到PA的变换
图7.2 四级页表的地址翻译
图7.3 1-3级页表条目格式
图7.4 4级页表条目格式
7.5 三级Cache支持下的物理内存访问
图 7.5 三级Cache支持下的物理内存访问
物理地址PA被分成3块,CT(标记)、CI(索引)、CO(偏移),在L1中寻找,若命中,则返回对应块偏移的数据。否则,L1不命中,需要前往L2,L3甚至是主存中得到对应的数据。
7.6 hello进程fork时的内存映射
图 7.6 execve时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并且分配给它一个唯一的pid,同时为这个新进程创建虚拟内存。
它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并且将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中被返回时,新进程现有的虚拟内存刚好与调用fork时所存储的虚拟内存相同。当这两个进程中的任意一个后来进行写操作时,写时复制机制就会创建新页面。因此也为每个进程保持了私有空间地址的抽象概念。
7.7 hello进程execve时的内存映射
在bash中的进程中执行了如下的execve()函数调用:execve("hello",NULL,NULL);
execve()函数在当前进程中加载并运行包含在可执行文件hello中的程序,用hello替代了当前bash中的程序。
加载并运行hello的几个步骤:
(1)删除已存在的用户区域;
(2)映射私有区域;
(3)映射共享区域;
(4)设置程序计数器(PC)。
exceve()函数最后设置当前进程的上下文中的程序计数器,指向代码区域的入口点。而下一次调度该进程时,他将从这个入口点开始执行。Linux系统将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
页面的正确命中完全是由硬件所完成的,而处理缺页故障是由硬件和操作系统内核所协作完成的,如下图缺页中断处理所示:
图 7.6 缺页中断处理
处理流程:
(1)处理器生成一个虚拟地址,并将它传送给MMU;
(2)MMU生成PTE地址,并从高速缓存/主存请求得到它;
(3)高速缓存/主存向MMU返回PTE;
(4)PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序;
(5)缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改,那么就把它换到磁盘上;
(6)缺页处理程序页面调入新的页面,并更新内存中的PTE;
(7)缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以会正确命中。
7.9本章小结
本章主要介绍了hello的存储管理,介绍了逻辑地址、线性地址、物理地址、虚拟地址的区别和转化。还分析了段式管理、页式管理,介绍了程序的虚拟地址是如何翻译成物理地址,包括多级页表和三级cache。同时分析程序运行过程中fork,execve函数进行的内存映射以及缺页故障与缺页中断处理。
结论
hello程序的一生经历丰富,一生中的重要时刻有:
(1)hello.c经过预处理,得到了修改了的源程序hello.i(文本文件);
(2)hello.i经过编译处理,得到了汇编程序hello.s(文本文件);
(3)hello.s经过汇编处理,得到了可重定位目标程序hello.o(二进制文件);
(4)hello.o经过链接处理,生成可执行目标程序hello(二进制文件);
(5)shell调用fork()函数,创建子进程,再由execve()函数加载并且运行当前进程的上下文,在其中加载并运行hello程序;
(6)hello的变化过程中会在硬件与操作系统的帮助下,将虚拟地址翻译为物理地址;
(7)hello最终被shell父进程所回收,内核会清除它存在过的痕迹。
我参与了hello的一生,见证了它生命中每一个重要时刻。我深刻地认识到,即使是一个看似简单的程序,想要在计算机上运行并得到正确的结果,需要各部分有条不紊地工作。我惊叹于计算机系统设计的复杂却有序、合理且巧妙。陪伴Hello走过它的一生,我对在计算机系统课程中学到的知识理解更深入了。
附件
hello.c:源代码
hello.i:预处理后的文本文件
hello.s:编译后的汇编文件
hello.o:汇编后的可重定位目标执行文件
hello:链接之后的可执行目标文件
elf.txt:hello.o的ELF格式文本文件
helloelf.txt:hello的ELF格式文本文件
hello.txt:hello.o的反汇编代码
hello-asm.txt:hello的反汇编代码
参考文献
[1] 兰德尔.E.大卫.R. 深入理解计算机系统[M]. 北京:机械工业出版社,2016:7