计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能(未来技术模块)
学 号 7203610725
班 级 2036014
学 生 黄鸿睿
指 导 教 师 刘宏伟
计算机科学与技术学院
2021年5月
摘 要
hello.c这样一个简单的C语言文件在Linux下是怎样编译执行的?在开发工具封装下的一键编译执行背后隐藏着多少过程与中间产物?本文就是带着这样的疑问,一步一步深入研究了hello.c文件的P2P和020,在Ubuntu下探究了hello程序的整个生命周期,将计算机系统整个体系串联到一起,真正将计算机知识形成体系,融会贯通.
**关键词:**计算机系统;计算机体系结构;程序生命周期;底层原理;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
**
**
目 录
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 11 -
7.7 hello进程execve时的内存映射 - 11 -
第1章 概述
1.1 Hello简介
Hello的P2P指的是hello.c文件从C语言程序(Program)转换为进程(Process)的过程。Linux系统下,hello.c经过cpp(C Pre-Processor)C预处理器预处理,ccl(C Compiler)C编译器编译,as(Assembler)汇编器汇编,ld(Linker,链接器)链接,最后成为可执行目标程序hello。在shell内输入命令./hello通过fork产生子进程,hello就运行成为进程。
020是指hello文件从0到0的过程(Form 0 to 0)。从0开始是指初始时内存中没有hello文件的内容。其运行时从Shell下调用execve函数后,系统把hello文件载入内存,执行hello程序。运行结束后hello进程回收,内核相关数据也被删除,恢复到与hello无关,这是到0结束。
1.2 环境与工具
硬件环境:AMD Ryzen 5 4600U with Radeon Graphics 2.10 GHz
16G RAM
512G SSD
软件环境:Windows 11 64位
VMware Workstation Pro/Ubuntu 20.04 LTS 64位
开发调试工具:gedit, gcc, Visual Studio Code, edb
1.3 中间结果
文件名 | 功能 |
---|---|
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
hello | 链接hello.o得到的可执行文件 |
1.4 本章小结
本章概述了Hello程序的P2P和020,同时介绍了大作业过程中应用的软硬件环境和开发工具,列出了中间结果文件
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
C语言标准规定,预处理是指前4个编译阶段(phases of translation)。
(1)三字符组与双字符组的替换;(2)行拼接(Line splicing): 把物理源码行(Physical source line)中的换行符转义字符处理为普通的换行符,从而把源程序处理为逻辑行的顺序集合;(3)单词化(Tokenization): 处理每行的空白、注释等,使每行成为token的顺序集。(4)扩展宏与预处理指令(directive)处理.[2]
具体而言,hello.c文件中6到8行的#include命令会使预处理器读取系统头文件stdio.h、unistd.h、stdlib.h中的内容并插入程序文本,#define命令定义的字符串则会被直接替换为实际值。此外,预处理还会删除程序中的注释和多余空白字符。预处理会得到一个.i拓展名的文件。
预处理只是进行简单的文本分割、插入和替换,方便后续的处理,而并未对源代码内容进行任何直接的解析。
2.2在Ubuntu下预处理的命令
Ubuntu系统中预处理命令为
cpp hello.c > hello.i
2.3 Hello的预处理结果解析
用gedit打开hello.i文件,发现其行数拓展至3060行。如下图所示,hello.c文件中的main函数保留在了3047到3060行。
main函数前的部分是头文件stdio.h unistd.h stdlib.h的展开。展开流程为,删除#include命令,到环境变量中寻找include的文件并打开替换,将#define语句定义的字符串解释替换,删除注释和多余空白字符等。
2.4 本章小结
本章主要介绍了预处理的概念和作用,并结合Ubuntu系统下hello.c文件实际的预处理过程和预处理结果进行解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译是指C编译器(C Compiler)通过词法和语法分析,将合法指令翻译成对应的汇编代码的过程。
通过编译,编译器将文本文件hello.i翻译成汇编语言文件hello.s。hello.s中,以文本形式记录了hello.c对应的机器语言汇编代码,以方便后续转换为二进制机器码。
3.2 在Ubuntu下编译的命令
Ubuntu系统下,编译命令为:
gcc –m64 –no-pie –fno-PIC -S -o hello.i hello
截图如下:
3.3 Hello的编译结果解析
3.3.1 文件结构分析
hello.s文件的结构如下表所示
表格 2 hello.s文件结构
内容 | 作用 |
---|---|
.file | 源文件 |
.text | 代码段 |
.global | 全局变量 |
.data | 存放已经初始化的全局和静态C 变量 |
.section .rodata | 存放只读变量 |
.align | 对齐方式 |
.type | 表示是函数类型/对象类型 |
.size | 表示大小 |
.long .string | 表示是long类型/string类型 |
3.3.2 数据类型
hello.s中有有三种数据类型:整数,字符串,数组。
(1)整数
hello.s中的整数有:
① int i
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-k7zwYjoO-1653021981876)(media/1f0e0974a5ed94e0b7030c3029f06f15.png)]编译器将局部变量存储在寄存器或者栈空间中。i作为函数内部的局部变量,并不占用文件实际节的空间,只存在于运行时栈中。对于i的操作就是直接对寄存器或栈进行操作。
从hello.s的情况来看,i使用了4字节的地址空间
②int argc
argc是main函数的参数之一,64位编译下,由寄存器传入,进而保存在堆栈中。
③立即数4,0,1,2,3等
在汇编语句中直接以$3的形式出现。
(2)字符串
程序中有两个字符串常量,如下图所示
可以看出均为字符串常量,储存于.text数据段中。\XXX是UTF-8编码,一个汉字占用3个字节
(3)数组
程序中的数组只有char *argv[],main函数的第二个参数。hello.s中,其存储于栈内(21-23行),用寄存器寻址方式访问(36行)。数组操作在下一部分不再赘述。
3.3.3 数据操作
(1)赋值操作
局部变量的赋值操作使用mov指令完成,其后缀b,w,l,q分别对应1,2,3,4大小的字节
对i的赋值就如图6的31行所示
(2)算数操作
hello.s中涉及的算数操作有:
21 subq $32, %rsp //开辟栈帧
35 addq $16, %rax //修改地址偏移量
51 addl $1, -4(%rbp)//i++
(3)关系操作
hello.s中涉及的关系操作有:
① argc!=4
检查argc是否不等于3。对应的汇编代码在24行使用cmpl指令比较3和argc的大小(见图6),并设置条件码为下一步je指令进行跳转做准备。
②i<8:
检查i是否小于8。在hello.s的第53行,使用cmpl指令比较7和i的大小,设置条件码为下一步jle的跳转做准备。
3.3.4 控制转移
程序中的控制转移有两处:
13 if(argc!=4)
当argc不等于4时,执行代码块内部的内容。hello.s中为使用24行的cmpl指令比较argc与4是否相等。相等则跳转至.L2,不执行后续代码块内部的内容;不等则继续执行。
17 for(i=0;i<8;i++)
当i<8时进行循环,每次循环i自增1。在hello.s中为使用53行的cmpl指令比较i与7是否相等,在i<=9时继续循环,进入.L4,否则跳出循环。
3.3.5 函数操作
hello.c中的函数有main(),printf(),exit(),sleep(),atoi(),getchar()等。
在hello.s中,main()函数在程序入口处被调用,被标注为@function类型。之后对其他函数的调用都经过call指令进行。如下表所示
表格 3 hello.s中各函数对应的代码
函数 | 对应代码 |
---|---|
14 printf() | 26-27行(由于仅输出一个字符串被优化为puts函数) |
15 exit() | 28-29行,传入参数1 |
18 printf() | 34-43行,传入三个参数 |
19 atoi() | 44-48行,将argv[3]转换为int类型整数 |
19 sleep() | 49-50行,传入一个参数,为atoi()返回值 |
21 getchar() | 55行,无参数 |
3.4 本章小结
本章介绍了编译的概念和作用,描述了从hello.i转换为hello.s的过程。同时以hello.s为例,介绍了编译器对各种数据类型和操作的的处理方式,验证了大部分数据和操作在汇编中的实现。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编是指汇编器(assembler)将汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在.o 目标文件中的过程。
汇编能将汇编语言译为机器语言,并将相关指令以可重定位目标程序的格式保存在.o文件中。
4.2 在Ubuntu下汇编的命令
Ubuntu下汇编命令为:
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
汇编过程如下:
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先获得hello.o的ELF格式,输入以下命令:
readelf -a hello.o > hello.elf
过程如下图所示:
其结构分析如下:
1. ELF头(ELF Header):
ELF头从一个16字节序列magic开始,描述了生成该文件的系统的字的大小和字节顺序,剩下部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
2. 节头:
包含文件中各节的意义,有节的类型、位置和大小等信息。
3.重定位节.rela.text
这一节中有8条重定位信息,分别是对.L0(hello.c中14行printf()中的字符串),puts()(优化后的14行printf()),exit(),.L1(18行printf()中的字符串),printf()(18行),sleep(),getchar()进行重定向声明。
4.重定位节.rela.eh_frame
5. 符号表(Symbol table)
符号表中保存有定位、重定位程序中符号定义和引用信息。所有重定位需要引用的符号都在符号表中声明。
4.4 Hello.o的结果解析
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lCnHOHam-1653021981884)(media/a2a2c8b1998387de583781633f175e02.png)]使用objdump -d -r hello.o > hello.asm 命令分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。过程如下:
对比hello.asm和hello.s,发现两者在以下三个部分存在差异
(1)分支转移
hello.s中,跳转指令目标地址直接记为段名称(.L2,.L4等),在反汇编得到的hello.asm中则变为目标地址与当前地址下一条指令的地址差。
(2)函数调用
hello.s中,call指令直接接函数名称,但在反汇编代码中,call目标地址为当前指令的下一条指令,这是因为调用的函数为共享库函数,最终要动态链接器作用才能确定函数在运行时的执行地址。
(3)全局变量访问
与函数调用类似,rodata中的数据地址在运行时才能确定,访问时需要重定位,故存储于rodata段的全局变量(hello.c中表现为printf()中的字符串)需要在汇编为机器语言时将操作数置为0并添加重定向条目。
除此之外,二者几乎完全相同,说明汇编过程不会改变代码逻辑。
4.5 本章小结
本章介绍了汇编的概念和作用。展示了在Ubuntu下将hello.s文件汇编为hello.o文件,又由hello.o文件生成其ELF格式的hello.elf文件和反汇编得到的hello.asm文件。研究了ELF格式文件的结构,通过比较hello.asm和hello.s了解了汇编语言与机器语言的异同。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是指通过链接器(Linker)将程序编码和数据块收集并整理成为一个单一文件,生成完全链接的可执行的目标文件的过程(Windows系统下为.exe,Linux系统下一般省略后缀名)
链接提供了一种模块化的方式,使得程序可以被编写为一个较小的源文件的集合,可以分开编译更改源文件,从而减小整体文件的复杂度与大小,增加容错性,也方便对模块进行针对性修改。
5.2 在Ubuntu下链接的命令
Ubuntu下链接的命令如下:
ld -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 /usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o hello.o -lc -z relro -o hello
5.3 可执行目标文件hello的格式
输入命令生成hello程序的ELF格式文件hello2.elf(与hello.o的做区别):
readelf -a hello > hello1.elf
对hello的ELF格式的的分析如下(主要介绍与hello.elf的不同之处):
1. ELF头
hello2.elf中ELF头与hello.elf的基本相同。不同之处在于类型发生改变,程序头大小和节头数量增加,并获得了入口地址。
2.节头
hello2.elf的节头在链接后丰富了内容。
3. 程序头
程序头部分为结构数组,描述了系统准备程序执行所需的段和其他信息。本部分为hello2.elf新增。
4.segment to section mapping[4]
这个部分是在hello2.elf中新增的,它是在链接过程被确定的。
4. Dynamic section
5. Symbol table
符号表中有定位和重定位程序中符号定义与引用信息。
5.4 hello的虚拟地址空间
使用edb加载hello,通过Data Dump查看程序代码与其虚拟地址。
可以看出,程序是从0x4010f0的位置开始的,对照5.3中图19ELF头中入口点位置,发现两者匹配。
5.5 链接的重定位过程分析
使用命令:
objdump -d -r hello > hello2.asm
将生成的hello2.asm反汇编文件与hello.asm文件比较,有以下不同之处:
1.函数数量增加
hello2.asm中增加了_init,.plt等等若干函数的代码。这是动态链接器将hello.c使用的共享库函数也加入了hello中。
2.call指令参数发生变化
链接器解析了所有重定位条目。call之后的字节地址被链接器修改为调用函数代码所在的目标地址,指向对应的代码段。下图为getchar()的例子。
3.跳转指令参数发生变化
je等指令在链接过程中,其重定向条目被链接器解析并计算完成了跳转的相对距离,指令后的代码变为跳转目标位置的字节代码。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的有过程,其调用与跳转的各个子程序名或程序地址如下表所示。
表格 4 程序名称与程序地址
程序名称 | 程序地址 |
---|---|
ld-2.27.so!_dl_start | 0x7ffe68c3a148 |
ld-2.27.so!_dl_init | 0x7fce8cc47630 |
hello!_start | 0x400500 |
libc-2.27.so!__libc_start_main | 0x7fce8c867ab0 |
-libc-2.27.so!__cxa_atexit | 0x7fce8c889430 |
-libc-2.27.so!__libc_csu_init | 0x4005c0 |
hello!_init | 0x400488 |
libc-2.27.so!_setjmp | 0x7fce8c884c10 |
-libc-2.27.so!_sigsetjmp | 0x7fce8c884b70 |
–libc-2.27.so!__sigjmp_save | 0x7fce8c884bd0 |
hello!main | 0x400532 |
hello!puts@plt | 0x4004b0 |
hello!exit@plt | 0x4004e0 |
*hello!printf@plt | – |
*hello!sleep@plt | – |
*hello!getchar@plt | – |
ld-2.27.so!_dl_runtime_resolve_xsave | 0x7fce8cc4e680 |
-ld-2.27.so!_dl_fixup | 0x7fce8cc46df0 |
–ld-2.27.so!_dl_lookup_symbol_x | 0x7fce8cc420b0 |
libc-2.27.so!exit | 0x7fce8c889128 |
5.7 Hello的动态链接分析
由于动态链接器对共享库函数的调用进行了延迟绑定,而延迟绑定是同通过GOT(全局偏移量表)和PLT(过程链接表)实现的。查询hello2.elf,找到GOT起始位置在0x404000
查看edb的Data Dump,在调用dl_init前,其内容如下
调用后内容变为:
比较其中变化可以发现,GOT中的内容发生了变化。在执行完dl_init后程序就可以从PLT和GOT进行动态链接了。
5.8 本章小结
本章先介绍了链接的概念与作用,并比较了将hello.o进行连接后得到的hello程序的ELF格式文件和反汇编文件与链接前的相同与不同之处,加深了对重定位和动态链接的理解。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个正在运行的程序实例,系统中的每个程序都运行在某个进程的上下文中。
进程能给应用程序提供两个关键抽象:
①一个独立的逻辑控制流,提供一个程序独占使用处理器的假象。
②一个私有地址空间,提供一个程序独占使用内存的假象。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个由C语言编写的交互型应用程序,它代表用户运行程序。同时Shell提供一个界面,使得用户能够对系统进行基本操作,访问操作系统内核的服务。
Shell处理流程如下图所示:
6.3 Hello的fork进程创建过程
fork进程的创建过程如下[6]:
(1)给新进程分配一个标识符
(2)在内核中分配一个PCB,将其挂在PCB表上
(3)复制它的父进程的环境(PCB中大部分的内容)
(4)为其分配资源(程序、数据、栈等)
(5)复制父进程地址空间里的内容(代码共享,数据写时拷贝)
(6)将进程置成就绪状态,并将其放入就绪队列,等待CPU调度。
打开Shell,输入命令./hello 7203610725 黄鸿睿 5,带参数执行之前生成的可执行文件。
6.4 Hello的execve过程
execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main()开始运行。同时,进程的 ID 将保持不变。在hello这里,这个过程表现为用fork()创建新的子进程之后,子进程调用execve(),在当前进程的上下文中加载并运行一个新程序hello。execve ()没有返回值,它将删除该进程的代码和地址空间内的内容并将其初始化,然后通过跳转到程序的第一条指令或入口点来运行该程序。将私有的区域映射进来,例如打开的文件,代码、数据段,然后将公共的区域映射进来。后面加载器跳转到程序的入口点,即设置PC指向_start 地址。_start函数最终调用hello中的 main (),这样,便完成了在子进程中的加载。[7]
6.5 Hello的进程执行
hello运行时,Shell为hello程序fork了一个子进程,这个子进程与Shell的逻辑控制流是独立的。若hello进程不被抢占,其正常运行;若被抢占则进入内核模式,进行上下文切换转入用户模式调度其他进程。当hello调用sleep函数时,为最大化利用处理器资源,此时sleep函数会像内核发送请求将hello挂起并进行上下文切换,进入内核模式切换其他进程,切换回用户模式运行抢占的进程。同时,将hello进程从运行队列加入等待队列,由用户模式变为内核模式,开始计时。计时结束后返回,触发中断,使hello进程被重新调度,从等待队列中移除,由内核模式转为用户模式,hello进程可以继续执行其逻辑控制流。
6.6 hello的异常与信号处理
6.6.1 程序正常运行
输入参数不足4个时,程序会打印提示信息并直接结束程序。输入参数符合要求时,程序会打印8次提示信息,并以输入回车结束程序回收进程。
6.6.2 程序运行时按回车
程序会在运行中打印空行,并在输入完成后直接结束。这是因为运行时按的回车也加载进了stdin流,作为最后结束程序的标志被程序接受。
6.6.3 程序运行时按Ctrl+C
此时shell收到SIGINT信号,结束并回收hello进程。
6.6.4 程序运行时按Ctrl+Z
此时Shell收到SIGSTP信号,显示屏幕提示信息并挂起hello进程。
对hello进程的挂起可以用ps和jobs命令查看,发现其确为挂起而非被回收,且其job代号为1
可用pstree用树状图显示所有进程。
输入kill命令可杀死指定进程
输入fg 1则可以将hello进程再次调到前台执行,hell首先打印命令行命令,hello再从挂起处继续运行,打印剩下的语句.程序可以正常结束并回收.
6.6.5 不停乱按
程序运行时的输入均缓存到stdin流中,getchar时读入出一个’\n’结尾的字符串作为一次输入,hello结束后,stdin流中其他字符会当作Shell 的命令读入.
6.7本章小结
本章介绍了进程的概念与作用,也介绍了Shell的基本概念和作用.在本章中,以hello为例研究了fork,execve函数的原理与执行过程,并给出了hello带参执行情况下各种异常与信号处理的结果.
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是指由程序产生的与段相关的偏移地址部分,逻辑地址由选择符和偏移量两部分组成。具体而言,其为hello.asm中的相对偏移地址。逻辑地址经过段机制转化后为线性地址,其为处理器可寻址空间的地址,用于描述程序分页信息的地址.具体以hello而言,线性地址标志着hello应在内存上哪些具体数据块上运行.虚拟地址即为上述线性地址.物理地址则是CPU通过地址总线寻址找到的真实物理内存对应地址.
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel处理器从逻辑地址到线性地址的变换通过段式管理的方式实现。每个程序在系统中都保存着一个段表,段表保存着该程序各段装入主存的状况信息,包括段号或段名、段起点、装入位、段的长度、主存占用区域表、主存可用区域表等,从而方便进行段式管理。
在段寄存器中,存放着段选择符,可以通过段选择符来得到对应段首地址。段选择符的结构如下:
其包含三部分:索引,TI,RPL
索引:用来确定当前使用的段描述符在描述符表中的位置;
TI:根据TI的值判断选择全局描述符表(TI=0,GDT)或选择局部描述符表(TI=1,LDT);
RPL:判断重要等级。RPL=00,为第0级,位于最高级的内核,RPL=11,为第3级,位于最低级的用户状态;
通过一个索引,可以定位到段描述符,进而通过段描述符得到段基址。段基址与偏移量结合就得到了线性地址,虚拟地址。[8]
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(VA)到物理地址(PA)之间的转换通过对虚拟地址内存空间进行分页的分页机制完成。
通过7.2节中的段式管理过程,可以得到了线性地址/虚拟地址,记为VA。虚拟地址可被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量),根据计算机系统的特性可以确定VPN与VPO的具体位数,由于虚拟内存与物理内存的页大小相同,因此VPO与PPO(物理页偏移量)一致。而PPN(物理页号)则需通过访问页表中的页表条目(PTE)获取,如下图所示。
若PTE的有效位为1,则发生页命中,可以直接获取到物理页号PPN,PPN与PPO共同组成物理地址。
若PTE的有效位为0,说明对应虚拟页没有缓存到物理内存中,产生缺页故障,调用操作系统的内核的缺页处理程序,确定牺牲页,并调入新的页面。再返回到原来的进程,再次调用导致缺页的指令。此时发生页命中,获取到PPN,与PPO共同组成物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
编写本文设备的CPU的基本参数如下:
- 虚拟地址空间48位(n=48)
- 物理地址空间52位(m=52)
- TLB四路十六组相连
- L1,L2,L3块大小为64字节
- L1,L2八路组相连
- L3十六路组相连
- 页表大小4KB(P=4x1024=2^12),四级页表,页表条目(PTE)大小8字节
由上述信息可以得知,VPO与PPO有p=12位,故VPN为36位,PPN为40位。单个页表大小4KB,PTE大小8字节,则单个页表有512个页表条目,需要9位二进制进行索引,而四级页表则需要36位二进制进行索引,对应着36位的VPN。TLB有16组,故TLBI有t=4位,TLBT有36-4=32位。
如图所示, CPU产生虚拟地址VA,并将其传送至MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)在TLB中进行匹配,若命中,则得到PPN(40bit)与VPO(12bit)组合成物理地址PA(52bit)。若TLB没有命中,则MMU向页表中查询,由CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,则执行下一步确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并向TLB中添加条目。多级页表的工作原理展示如下:
若查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
7.5 三级Cache支持下的物理内存访问
因为三级Cache的工作原理基本相同,所以在这里以L1 Cache为例,介绍三级Cache支持下的物理内存访问。
L1 Cache的基本参数如下:
- 8路64组相连
- 块大小64字节
由L1 Cache的基本参数,可以分析知:
块大小64字节→需要6位二进制索引→块偏移6位
共64组→需要6位二进制索引→组索引6位
余下标记位→需要PPN+PPO-6-6=40位
故L1 Cache可被划分如下(从左到右):
CT(40bit)CI(6bit)CO(6bit)
在7.4中我们已经由虚拟地址VA转换得到了物理地址PA,首先使用CI进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO取出相应的数据后返回。
若没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中请求数据(请求顺序为L2 Cache→L3 Cache→主存,若仍不命中才继续向下一级请求)。查询到数据之后,需要对数据进行读入,一种简单的放置策略如下:若映射到的组内有空闲块,则直接放置在空闲块中,若当前组内没有空闲块,则产生冲突(evict),采用LFU策略进行替换。
7.6 hello进程fork时的内存映射
当fork函数被当前进程hello调用时,内核为新进程hello创建各种数据结构,并分配唯一PID。为给hello创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当两个进程中的任一个进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数加载并运行hello需要以下几个步骤:
-
删除已存在的用户区域
删除当前进程hello虚拟地址的用户部分中的已存在的区域结构。
-
映射私有区域
为新程序的代码、数据、bss和栈区域创建新的私有的、写时复制的区域结构。其中,代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
-
映射共享区域
若hello程序与共享对象或目标(如标准C库libc.so)链接,则将这些对象动态链接到hello程序,然后再映射到用户虚拟地址空间中的共享区域内。
-
设置程序计数器
最后,execve设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
(1)段错误:首先判断这个缺页的虚拟地址是否合法,遍历所有的合法区域结构,如果这个虚拟地址对所有的区域结构都无法匹配,就返回一个段错误。
(2)非法访问:查看地址的权限,判断一下进程是否有读写改这个地址的权限。
(3)如果不是上面两种情况那就是正常缺页,就选择一个页面换入新的页面并更新到页表
7.9动态存储分配管理
动态内存管理的基本方法与策略介绍如下:
动态内存分配器维护着一个称为堆的进程的虚拟内存区域。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放可以由应用程序显式执行或内存分配器自身隐式执行。
具体而言,分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
下面介绍动态存储分配管理中较为重要的概念:
-
隐式链表
堆中的空闲块通过头部中的大小字段隐含地连接,分配器通过遍历堆中所有的块,从而间接遍历整个空闲块的集合。
对于隐式链表,其结构如下:
-
显式链表
在每个空闲块中,都包含一个前驱(pred)与后继(succ)指针,从而减少了搜索与适配的时间。
显式链表的结构如下:
-
带边界标记的合并
采取使用边界标记的堆块的格式,在堆块的末尾为其添加一个脚部,其为头部的副本。添加脚部之后,分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态。从而实现快速合并,减小性能消耗。
-
分离存储
维护多个空闲链表,其中,每个链表的块具有相同的大小。将所有可能的块大小分成一些等价类,从而进行分离存储。
7.10本章小结
本章主要介绍了hello 的存储器地址空间、intel 的段式管理、hello 的页式管理, VA 到PA 的变换、物理内存访问,hello进程fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理.全面加深了对计算机系统存储管理的了解.
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
IO设备均被模型化为文件,输入输出便成为对应文件的读写.这种方式使得Linux能简单地管理设备
8.2 简述Unix IO接口及其函数
-
Unix I/O接口:
-
打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。对于Shell创建的每个进程,其都有三个打开的文件:标准输入,标准输出,标准错误。
-
改变当前的文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
-
读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
-
关闭文件
内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去
-
Unix I/O函数:
-
int open(char* filename,int flags,mode_t mode)
进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
-
int close(fd)
fd是需要关闭的文件的描述符,close返回操作结果。
-
ssize_t read(int fd,void *buf,size_t n)
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
-
ssize_t wirte(int fd,const void *buf,size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
查看windows系统下的printf函数体:
形参列表中的…是可变形参的一种写法,当传递参数的个数不确定时,用这种方式来表示。
va_list的定义:typedef char *va_list,说明它是一个字符指针,其中 (char*)(&fmt) + 4) 即arg表示的是…中的第一个参数。
再进一步查看windows系统下的vsprintf函数体:
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
printf函数的功能为接受一个格式化命令,并按指定的匹配的参数格式化输出,故i = vsprintf(buf, fmt, arg)是得到打印出来的字符串长度,其后的write(buf, i)是将buf中的i个元素写到终端。
因此,vsprintf的作用为接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,进而产生格式化输出。
再进一步对write进行追踪:
这里给几个寄存器传递了参数,然后以一个int INT_VECTOR_SYS_CALL结束。INT_VECTOR_SYS_CALL代表通过系统调用syscall,查看syscall的实现:
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar调用系统函数read,发送一个中断信号,内核抢占这个进程,用户输入字符串,键入回车后(字符串和回车都保存在缓冲区内),再次发送信号,内核重新调度这个进程,getchar从缓冲区读入字符。
8.5本章小结
本章主要介绍了Linux系统的IO设备管理方法及其接口和函数,也了解了printf()和getchar()的底层实现.
(第8章1分)
**
**
结论
hello程序的一生经历了如下过程:
-
预处理
将hello.c中include的所有外部的头文件头文件内容直接插入程序文本中,完成字符串的替换,并删除多余空白字符得到hello.i供后续处理;
-
编译
通过词法分析和语法分析,将合法指令翻译成等价汇编代码。通过编译过程,编译器将hello.i 翻译成汇编语言文件 hello.s;
-
汇编
将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o 目标文件中;
-
链接
通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello;
-
加载运行
打开Shell,在其中键入 ./hello 7203610725 黄鸿睿,终端为其fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行;
-
执行指令
在该进程被调度时,CPU为hello其分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器一步一步地更新,CPU不断地取指,顺序执行自己的控制逻辑流;
-
访存
内存管理单元MMU将逻辑地址,一步步映射成物理地址,进而通过三级高速缓存系统访问物理内存/磁盘中的数据;
-
动态申请内存
printf 会调用malloc 向动态内存分配器申请堆中的内存;
-
信号处理
进程时刻等待着信号,如果运行途中键入Ctrl+C/Ctrl+Z 则调用shell 的信号处理函数分别进行停止、挂起等操作,对于其他信号也有相应的操作;
-
终止并被回收
Shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 功能 |
---|---|
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
hello | 链接hello.o得到的可执行文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 兰德尔 E.布莱恩特. 深入理解计算机系统[M]. 北京:机械工业出版社,2018.
[2] C预处理器 - 维基百科,自由的百科全书 (wikipedia.org)
[3] ELF-64 Object File Format (uclibc.org)
[4] From .rodata to .rwdata – introduction to memory mapping and LD scripts – Guy on BITS
[5] bash处理的12个步骤流程图_AstrayLinux的博客-CSDN博客
[6] 进程的创建过程(fork函数)_lyl194458的博客-CSDN博客_fork创建进程
[7] fork和execve和Linux内核的一般执行过程 - 知乎 (zhihu.com)
[8] 段页式访存——逻辑地址到线性地址的转换 - 简书 (jianshu.com)
(参考文献0分,缺失 -1分)
文件 |
| hello2.elf | 由hello可执行文件生成的.elf文件 |
| hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
| hello | 链接hello.o得到的可执行文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 兰德尔 E.布莱恩特. 深入理解计算机系统[M]. 北京:机械工业出版社,2018.
[2] C预处理器 - 维基百科,自由的百科全书 (wikipedia.org)
[3] ELF-64 Object File Format (uclibc.org)
[4] From .rodata to .rwdata – introduction to memory mapping and LD scripts – Guy on BITS
[5] bash处理的12个步骤流程图_AstrayLinux的博客-CSDN博客
[6] 进程的创建过程(fork函数)_lyl194458的博客-CSDN博客_fork创建进程
[7] fork和execve和Linux内核的一般执行过程 - 知乎 (zhihu.com)
[8] 段页式访存——逻辑地址到线性地址的转换 - 简书 (jianshu.com)
(参考文献0分,缺失 -1分)