计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 工科试验班(医类1)
学 号 2022112276
班 级 2252001
学 生 蒋一冉
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
本文主要介绍hello程序在linux下是如何从一个.c文件在经历了预处理、编译、汇编、链接一步步变成可执行文件的。对于在运行的过程中的进程、信号、异常处理、存储处理、IO管理等操作进行探究的探索。用以概括程序从书写完成到编译到执行的全过程,结合课内外内容进行完整的阐述
关键词:计算机系统;预处理;编译;汇编;链接;进程;存储管理;IO管理;
目 录
第1章 概述
1.1 Hello简介
P2P过程:对hello.c文本(program)进行预处理(gcc -E)生成hello.i文件,编译(gcc -S)生成汇编代码hello.s文件,再通过汇编操作(gcc -c)生成目标文件hello.o文件,最后链接操作生成二进制可执行文件。然后shell将程序fork,产生子进程,即为process。
020过程:然后execve,映射虚拟内存,载入物理内存,执行目标代码,CUP分配时间片执行逻辑控制流。运行结束之后,父进程回首子进程,内核执行删除操作,最后实现Zeor->Zero。
1.2 环境与工具
硬件环境:
设备名称 LAPTOP-LR4FDSS7
处理器 Intel® Core™ i7-9750H CPU @ 2.60GHz 2.59 GHz
机带 RAM 16.0 GB
系统类型 64 位操作系统, 基于 x64 的处理器
软件环境
版本 Windows 10 家庭中文版
VMware Workstation 15 Pro
Ubuntu 20.04
1.3 中间结果
文件名称 | 文件作用 |
hello.c | 源代码 |
hello.i | 预处理之后的文件 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编之后的目标文件 |
hello | 链接之后的可执行文件 |
objdump.txt | 对hello.o的反汇编文件 |
Objdump2.txt | 对hello的反汇编文件 |
ELF.elf | hello.o的elf文件 |
ELF2.elf | hello的elf文件 |
1.4 本章小结
这部分主要介绍了hello的P2P、020过程,同时给出了实验所需要的硬件、软件环境,以及需要用到的工具,给出了实验中产生的中间文件 。
第2章 预处理
2.1 预处理的概念与作用
概念:
预处理是 C 语言程序从源代码变成可执行程序的第一步,主要是 C 语言编译器对各种预处理命令进行处理,包括头文件的包含、宏定义的扩展、条件编译的选择等。
作用:
预处理的作用主要是让编译器在随后的文本进行编译的过程中更方便,便于编译器执行操作,因为以下会影响编译器的操作都在预处理阶段完成了。
1. 头文件展开:将#include包含的文件插入到该指令位置
2. 宏展开:展开所有的宏定义,并删除#define
3. 条件编译:处理所有的条件预编译指令: #if、 #ifdef、 #else
4. 删除注释行的内容
5. 添加行号和文件名标识,同时在编译调试的时候显示行号信息
6. 保留#pragma指令
简单分析:
文件包含:根据以字符#开头的命令,修改原始的C程序.比如hello.c中第1行的#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中, 该过程递归进行,及被包含的文件可能还包含其他文件.
宏定义:将宏名替换为文本,即在对相关命令或语句的含义和功能作具体分析之前就要换,例如
#define IMAX 100,把程序中全部的标识符IMAX换成某个具体数值100
条件编译:有些语句希望在条件满足时才编译.例如
#ifdef
标识符
程序段1
#else
程序段2
#endif
当标识符已经定义时,程序段1才参加编译.
2.2在Ubuntu下预处理的命令
图2.2.1 预处理截图及预处理文件
原文件大小为592字节,预处理为64756字节。
2.3 Hello的预处理结果解析
查看预处理结果文件,发现预处理只是对#命令进行处理,将用到的库的地址及库中的函数加入其中,同时去掉注释部分的内容,见图2.3.1,但是对原代码定义的函数或变量并未进行修改,见图2.3.2。
图2.3.1 预处理对头文件、宏注释的展开以及对注释的删除
图2.3.2 预处理对源代码中函数保持不变
2.4 本章小结
本章介绍了预处理的概念和功能,并在Ubuntu下的预处理指令将hello.c转化为hello.i文件,并对预处理生成的文件和源代码进行了对比分析。
预处理过程是计算机对程序操作的起始过程,在这个过程中预处理器对hello.c文件进行初步的解释,对头文件、宏注释进行展开扩展到程序之中,同时删除对程序没有作用的注释部分,对函数不进行更改,最后将处理完的文本保存到hello.i文件中。
第3章 编译
3.1 编译的概念与作用
概念:编译是在预处理之后的阶段,对源代码进行预处理后获得了hello.i文件,编译则是使用编译器(ccl)对hello.i文件进行处理的过程。此阶段编译器会完成对代码的语法和语义的分析,生成汇编代码,并将代码保存在hello.s文件中。汇编结果是语言无关的。
作用:
1. 对代码进行语法检查,出现错误则反馈,编译失败。
2. 生成汇编码
3. 覆盖处理
4. 目标程序优化
3.2 在Ubuntu下编译的命令
编译操作命令:gcc -S hello.i -o hello.s
图3.2.1 编译命令及结果
3.3 Hello的编译结果解析
一些基础的汇编指令含义
指令 | 含义 |
.file | 声明源文件 |
.text | 以下是代码段 |
.section.rodata | 以下是rodata节 |
.globl | 声明一个全局变量 |
.type | 指定函数类型和对象类型 |
.size | 声明大小 |
.string | 声明一个string类型 |
.align | 声明对指令或者数据的存放地址进行对齐的方式 |
3.3.1 数据
1. 字符串常量
源代码中定义的字符串在.rodata 段中定义保存,使用时候直接使用。
图3.3.1 字符串常量定义
图3.3.2 字符串常量的使用
2. 局部变量
局部变量基本都存放在栈中。
可以发现,将argc存放在-20(%rbp)的栈位置,将*arg[]存放在-32(%rbp)的栈位置,而int i则存放在-4(%rbp)的栈位置。
无论是int还是int[],局部变量在都存储在栈中,区别在于需要在栈中分配的大小时不同的。数据的大小需要根据数组
中元素个数进行确定。
图3.3.3 栈中局部变量赋值
3.3.1 操作
1. 赋值
赋值操作使用movb、movw、movl、movq进行,具体区分需要分别传送1、2、4、8字节大小的数据,如图3.3.3所示。
2.关系操作
第一处关系操作:比较argc是否等于5使用的cmpl指令,更新条件码
图3.3.4 argc是否为5操作
第二处关系操作:比较i是否小于10,这段在.L3中,被优化为和9比较。
图3.3.5 i是否小于10
- 控制转移
之前每一个cmpl的操作之后,都紧跟着一个j的操作,这个j的含义就是jmp,起到控制函数跳转的作用,j后面跟的参数,就对应了在满足什么条件的时候进行跳转。除了cmpl后的控制转移外,还有直接使用jmp的控制转移。
源代码编译后有多次控制转移,下面将配合图片进行说明。
图3.3.6 此处是判断argc是否为5,为5跳转到.L2
图3.3.7 此处无条件跳转到.L3
图3.3.8 此处判断i是否小于等于9,如果是跳转到.L4
- 算术运算操作
源代码编译后不是栈相关的运算只有i++运算,编译完成之后在.L4的末尾,进行加法操作。具体见图3.3.5。
图3.3.9 加法运算操作
- 数组操作
需要访问的数组为argv,放在栈中,按地址进行访问。
源代码中操作为:
图3.3.10 源代码中对数组访问
在编译后的文件中,访问方式如下:
图3.3.11 编译后文件对数组的访问
- 函数操作
源代码中所使用的函数有printf、exit、atoi、sleep、getchar,下面将结合截图依次介绍。
printf:
源代码中有两个printf。
- 首先介绍输入个数不正确的printf:首先将要打印的字符放到edi/rdi中,因为只有一个字符串,所以优化为puts函数。
图3.3.12 只有一个参数的printf会被编译器优化为puts函数
- 然后介绍多个参数的printf:在.L4中,同样要将参数加入到对应的寄存器中,然后调用printf函数
图3.3.13 多个参数的printf会被直接调用
exit:
在.LFB6中被直接调用。
图3.3.14 exit
atoi、sleep:参数被放入寄存器中,在.L4中被直接调用
图3.3.15 atoi、sleep
getchar:在.L3中被直接调用
图3.3.16 getchar
- 函数返回
所有函数的返回值都是将返回值放入寄存器rax之中,然后ret即可
3.4 本章小结
本章主要讲述了编译器如何将预处理之后的文本编译为汇编代码的。
分别介绍了:数据、关系操作、控制转移、算数操作、数组操作、函数调用、函数返回。
在编译阶段,编译器将高级语言编译成汇编语言。汇编语言是直接面向处理器的语言。汇编语言指令是机器指令的一种符号表示,而不同类型的CPU 有不同的机器指令系统,也就有不同的汇编语言,所以,汇编语言程序与机器有着密切的关系。
同时,编译器在编译代码的时候,会进行一些隐式的优化,函数顺序也并不完全按照代码的构思来,最终生成hello.s文件。
第4章 汇编
4.1 汇编的概念与作用
概念:
汇编(as),就是把汇编指令转为机器码的过程,机器码可以被CPU直接执行。汇编程序输入的是用汇编语言书写的源程序,输出的是用机器语言表示的目标程序。
作用:
汇编代码只是人可以看懂的代码,但机器并不能读懂,真正机器可以读懂并执行的是机器代码,也就是二进制代码。汇编的作用就是将之前在hello.s中保存的汇编代码翻译成可以供机器执行的二进制代码,这样机器就可以根据01代码,真正的开始执行所写的程序了。
4.2 在Ubuntu下汇编的命令
操作命令:gcc -c hello.s -o hello.o
图 4.2.1 汇编命令及文件
4.3 可重定位目标elf格式
使用 readelf -a hello.o > ELF.elf 命令获得 hello.o 文件的 ELF 格式。
图4.3.1 命令及生成文件
ELF的组成:
- ELF Header
以Magic开始,Magic 描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包括ELF头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
图4.3.2 ELF Header内容
- Section Headers:节头部表,包含了文件中各个节的语义,包括节的类型、位置和大小等。
图4.3.3 Section Header内容
- 重定位节.rela.text ,包含.text节中需要进行重定位的信息,目标文件和其他文件组合时,需要修改这些位置。如图 4.4,图中 8 条重定位信息分别是对.L0、puts 函数、exit 函数、.L1、printf 函数、atoi函数、sleep函数、getchar 函数进行重定位声明。
图4.3.4 重定位节内容
4.4 Hello.o的结果解析
操作命令:objdump -d -r hello.o >objdump.txt
图4.4.1 反汇编命令与结果
机器语言指的是二进制的机器指令集合,由操作码和操作数构成的。汇编语言的主体是汇编指令。汇编指令和机器指令的差别在于指令的表示方法上,汇编指令是机器指令便于记忆的书写格式。
图4.4.2 反汇编文件汇编文件对比
分支转移:反汇编代码跳转指令的操作数使用的不是段名称如.L0之类,段名称是汇编语言中便于编写的助记符,但是在反汇编中就要是具体的地址。
函数调用:在.s 文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。因为 hello.c 中调用的函数需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言时,将其call指令后的相对地址设置为全0,然 后在.rela.text 节中为其添加重定位条目。
全局变量访问:在.s文件中,访问.rodata,使用段名称+%rip,在反汇编代码中 0+%rip。
4.5 本章小结
本章探究了汇编操作将汇编代码转化为机器码的过程。也就是hello.s到可重定位目标文件hello.o的过程,同时也对比了汇编代码与机器码生成的反汇编代码之间的区别。
第5章 链接
5.1 链接的概念与作用
概念:
链接是通过链接器(ld)将各种代码和数据片断收集并组合成一个单一可执行文件的过程。这个文件可以被加载(复制)到内存并执行。
作用:
符号解析:将每个符号引用与一个确定的符号定义关联起来
重定位:将单独的代码节和数据节合并为单个节,将独好从.o文件的相对位置重新定位到可执行文件的最终内存位置。
5.2 在Ubuntu下链接的命令
链接命令:
ld /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/8/crtbeginT.o -L/usr/lib/gcc/x86_64-linux-gnu/8 hello.o -lc -lgcc -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/gcc/x86_64-linux-gnu/8/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -o hello
图5.2.1 链接命令及生成文件
5.3 可执行目标文件hello的格式
操作命令:readelf -a hello > ELF2.elf
ELF Header几乎没有变化,只是type从REL变为EXEC
图5.3.1 ELF Header
Section Header、和重定位节等几乎没有变化
5.4 hello的虚拟地址空间
仔细观察edb显示的hello文件的ELF信息和5.3中的文件ELF信息,打现ELF头的信息是完全相同的,但是再第17项开始的时候,地址发生较大变化,edb中没有这些节的信息,因为这些节是共享库的信息,再edb中想要获得这些信息要单独查看。这里简单使用readelf查看
图5.4.1 elf头
5.5 链接的重定位过程分析
举个简单的例子,看连接前的反汇编,其中je的参数是2f,其中的注释只是对main的一个偏移量,显然对hello.o来说,这段代码是不具体的。
但是对于之后的反汇编,很显然我们就能发现,代码的具体注释变成了系统库里的函数,偏移量也是正确的偏移量了。所以链接过程中,链接器会将我们链接的库函数与其他文件在可执行文件中准确定位出来。
所以重定位的具体流程大抵如下:
重定位节和符号定义。将所有相同类型的节合并为同一类型的新的聚合节。然后将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输人模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
重定位节中的符号引用。修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
关于重定位的计算方法,这里我们简单给出一个例子:
相对地址重定位:
重定位条目r由四个字段组成:
r.offset=0x25
r.symbol=exit
r.type=R_X86_64_PC32
r.addend=-4,
R_X86_64_PC32重定位算法摘抄书本如下:
Refaddr=0x400532+0x25=0x400557
*refptr=(unsigned)(ADDR(r.exit)+r.addend-refaddr)
=0x 600ab8+(-0x4)-0x400557
=(unsigned) 0x20055d
决定地址重定位:
r.offset=0x16
r.symbol=.rodata
r.type=R_X86_64_PC32
r.addend=0,
*refptr=(unsigned)(ADDR(r.rodata)
=0x400758
5.6 hello的执行流程
hello在执行的过程中一共要执行三个大的过程,分别是载入、执行和退出。载入过程的作用是将程序初始化。
ld-2.27.so! _dl_start 0x7fce 8cc38ea0
ld-2.27.so! _dl_init 0x7fce 8cc47630
hello! _start 0x400500
libc-2.27.so! __libc_start_main 0x7fce 8c867ab0
-libc-2.27.so! __cxa_atexit 0x7fce 8c889430
-libc-2.27.so! __libc_csu_init 0x4005c0
hello! _init 0x400488
libc-2.27.so! _setjmp 0x7fce 8c884c10
-libc-2.27.so! _sigsetjmp 0x7fce 8c884b70
–libc-2.27.so! __sigjmp_save 0x7fce 8c884bd0
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 0x7fce 8cc4e680
-ld-2.27.so! _dl_fixup 0x7fce 8cc46df0
–ld-2.27.so! _dl_lookup_symbol_x 0x7fce 8cc420b0
libc-2.27.so! exit 0x7fce 8c889128
5.7 Hello的动态链接分析
对于动态共享链接库中 PIC 函数,编译器没有办法预测函数的运行时地址,所 以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代 码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表 PLT+全局偏移量 表 GOT 实现函数的动态链接,GOT 中存放函数目标地址,PLT 使用 GOT 中地址 跳转到目标函数。 在 dl_init 调用之前,对于每一条 PIC 函数调用,调用的目标地址都实际指向 PLT 中的代码逻辑,GOT 存放的是 PLT 中函数调用指令的下一条指令地址。
在函数调用时,首先跳转到PLT执行.plt中操作,第一次访问跳转时GOT地址为下一条指令,将函数序号入栈,然后跳转到PLT[0],之后将重定位表地址入栈,访问动态链接器,在动态链接器中使用在栈里保存的函数序号和重定位表计算函数运行时的地址,重写GOT,返回调用函数.之后如果还有对该函数的访问,就不用执行第二次跳转,直接参看GOT信息。
5.8 本章小结
本章简述了链接的过程,重点阐述了连接过程中对文件的处理,着重介绍了hello的虚拟地址空间,重定位过程,执行流程,动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程的经典定义是一个执行中程序的实例。更详细来说是操作系统对一个正在运行的程序中对处理器、主存、I/O设备的抽象表示。
作用:每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个进程的上下文中运行这个可执行目标文件。应用程序也能创建新的进程,并且在这个进程上下文中运行自己的代码和程序。进程提供给应用程序关键的抽象:
1. 一个独立的逻辑控制流,提供一个假象,好像我们的程序独占使用处理器。
2. 一个私有的地址空间,提供一个假象,好像我们的程序独占使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell是一个交互性的应用级程序,代表用户运行其他程序。Shell执行一系列的读/求职步骤,然后终止。读步骤读取来自用户的一个命令行,求值步骤解析命令行,并代表用户运行程序。
处理流程:
1. 读取来自用户的一个命令行
2. 分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
3. 检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令
4. 如果是,立即运行
5. 如果不是内部命令,调用fork( )创建新进程/子进程
6. 在子进程中用步骤2获取的参数,调用execve( )执行指定程序
7. 如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait...)等待作业终止后返回。
8. 如果用户要求后台运行(如果命令末尾有&号),则shell返回。
6.3 Hello的fork进程创建过程
系统调用fork函数,创建一个子进程,该子进程拥有和父进程完全一致的代码、数据和资源,利用条件语句if(fork()==0)后加子进程内容即可。这个为后面的execve过程。 子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork 时,子进程可以读写父进程中打开的任何文件。
6.4 Hello的execve过程
在之前创建的子进程中通过if(pid==0),在其中调用execve加载可执行文件,调用启用代码,启动代码设置栈,将栈和堆初始化为0,代码段与数据段初始化为可执行文件中的内容,最后将PC指向_start的地址,将控制传递给hello程序的主函数。
只有当出现错误时,例如找不到filename, execve 才会返回到调用程序。所以,与fork 一次调用返回两次不同, execve 调用一次并从不返回。
6.5 Hello的进程执行
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
时间进程片:执行进程的控制流的一部分的每一时间段。
用户态与核心态:
处理器同非常使一个寄存器提供两种模式的区分,该寄存器描述进程当前的权限,在没有设置模式位的时候,进程处于用户态,此时进程不允许执行特权指令,同时也不允许直接引用地址空间中内核区域的代码和数据;但是一旦设置了模式位,该进程就处于核心态,可以执行指令集中任何指令,也可以访问系统中任何内存位置。
进程调度的含义就是,在执行过程中,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这个过程称为调度。内存收到中断信号之后,将当前进程加入等待序列,进行上下文切换将当前的进程控制权交给其他进程,当再次收到中断信号时将hello从等待队列加入运行队列。
6.6 hello的异常与信号处理
异常种类:
1. 异步异常:中断
2. 同步异常:陷阱、故障、终止
中断是来自I/O设备的信号,异步发生,中断处理程序对其进行处理,返回后执行下一条指令
陷阱是有意的异常,执行一条指令的结果,调用后返回下一条指令
故障是无意的异常,能够被修正。若被修正则返回到当前指令再执行一次,否则终止。
终止是不可修复的故障引起的。
信号是易中通知用户异常发送的机制,例如较为底层的硬件异常以及较高层的软件事件,比如Ctrl-Z和Ctrl-C,分别触发SIGCHLD和SIGINT信号。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
图6.6.1 乱按截图
图6.6.2 Ctrl+C截图
图6.6.3 Ctrl+Z截图
图6.6.4 Ctrl+Z后ps
显然,在Ctrl-Z之后,进程只是被挂起放到了后台,通过ps指令我们可以看到hello没有结束,接下来还可以继续刚刚挂起的进程。也可以直接kill掉进程。
但是在Ctrl-C之后,进程已经被结束了,已经被直接回收掉了。
图6.6.5 Ctrl+Z后kill
图6.6.6 Ctrl+Z后pstree
图6.6.6 Ctrl+Z后jobs
6.7本章小结
本章简单阐述了进程的概念和shell的原理以及简单的使用操作,同时介绍了信号和异常的相关信息。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:
又称相对地址,是程序运行由CPU产生的与段相关的偏移地址部分。
描述了程序在运行时的地址。
物理地址:
CPU地址总线传来的地址,由硬件电路控制(现在这些硬件是可编程的了)其具体含义。物理地址中很大一部分是留给内存条中的内存的,但也常被映射到其他存储器上(如显存、BIOS等)。在没有使用虚拟存储器的机器上,虚拟地址被直接送到内存总线上,使具有相同地址的物理存储器被读写;而在使用了虚拟存储器的情况下,虚拟地址不是被直接送到内存地址总线上,而是送到存储器管理单元MMU,把虚拟地址映射为物理地址。
线性地址:
是一个32位无符号整数,可以用来表示高达4GB的地址,也就是,高达4294967296个内存单元。线性地址通常用十六进制数字表示,值得范围从0x00000000到0xfffffff)程序代码会产生逻辑地址,通过逻辑地址变换就可以生成一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。如果没有启用分页机制,那么线性地址直接就是物理地址。
虚拟地址:与线性地址类似,同样是对程序运行区块的相对映射。
Hello运行于物理地址,对于CPU而言, hello的运行地址是逻辑地址,在具体操作的过程中,CPU会将逻辑地址转换成线性地址再变成物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
概念阐述:
1. 段寄存器:用于存放段选择符,用来确定对应段的首地址。
2. 段选择符:分为三个部分,索引,TI(全局描述符or局部描述符),RPL(CPU特权级)
知识背景:
1、逻辑地址=段选择符+偏移量
2、每个段选择符大小为16位,段描述符为8字节(注意单位).
3、GDT为全局描述符表,LDT为局部描述符表.
4、段描述符存放在描述符表中,也就是GDT或LDT中.
5、段首地址存放在段描述符中.
每个段的首地址都存放在自己的段描述符中,而所有的段描述符都存放在一个描述符表中(描述符表分为全局描述符表GDT和局部描述符表LDT)。而要想找到某个段的描述符必须通过段选择符才能找到。
操作流程:
1. 根据逻辑地址判断出段选择符和段内偏移地址。
2. 根据段选择符判断当前转换段属于GDT还是LDT,再根据相应寄存器得到地址和大小。
3. 根据段选择符查找出对临汾的段描述服,得到基地址
4. 线性地址=基地址+偏移地址
7.3 Hello的线性地址到物理地址的变换-页式管理
概念阐述:
1. 页表条目(PTE):页表将虚拟页映射到物理页,其每一项称为页表条目,由有效位和一个n位的地址字段组成。
2. 内存管理单元(MMU):CPU芯片上有一个专门的硬件,功能是动态的将虚拟地址翻译成物理地址。
操作流程:
1. 处理器生成一个虚拟地址,并把它传送给MMU
2. MMU生成PTE地址,并从高速缓存/主存请求得到它
3. 高速缓存/主存向MMU返回PTE
4. MMU构造物理地址,并把它传送给高速缓存/主存
5. 高速缓存/主存返回所请求的数据字给处理器.
7.4 TLB与四级页表支持下的VA到PA的变换
概念阐述:
1. TLB:PTE的缓存。
2. 多级页表:将完整的页表分组,分别对应到低一级的一节页表的一个PTE中,在执行程序的过程中,如果我们用到了一个特定的页表,那么我们就在一级页表后面动态的开出来。
操作流程:
1. CPU产生虚拟地址VA,VA传送给MMU,MMU使用前36位VPN作为TLBT(前32位+TLBI(后4位)向TLB中匹配
2. 如果命中,则得到 PPN (40bit)与VPO(12bit)组合成 PA(52bit)
3. 如果没有命中,MMU 向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出 PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到 PPN,与VPO组合成PA,并且向TLB 中添加条目。
7.5 三级Cache支持下的物理内存访问
先在TLB中找,若不命中,则在页表中找到PTE,构造出物理地址PA,然后去L1中利用物理地址分为CT,CI,CO,若没命中,依次向低级访存,最后返回结果。
具体流程:
获得了物理地址VA之后,使用CI(倒数7-12位)进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中,根据数据偏移量CO(后6位)取出数据返回。
如果没有匹配成功或者匹配成功但是标志位是1,则不命中,向下一级缓存中查询数据(L2 Cache->L3 Cache->主存),查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU(Least frequently used)进行替换。也就是替换掉最不经常访问的一次数据。
7.6 hello进程fork时的内存映射
概念阐述:
1. mm_struct(内存描述符):描述了一个进程的整个虚拟内存空间。
2. vm_area_struct(区域结构描述符):描述了进程的虚拟内存空间的一个区间。
操作流程:
1. 创建当前进程的mm_struct,vm_area_struct和页表的原样副本。
2. 两个进程的每个页面都标记为只读页面。
3. 两个进程的每个vm_area_struct都标记为私有,只能在写入时复制。
7.7 hello进程execve时的内存映射
操作流程:
1. 删除已存在用户区域
2. 映射私有区域
3. 映射共享区域
4. 程序计数器PC指向代码入口点
7.8 缺页故障与缺页中断处理
页表相当于磁盘缓存,所以会存在缓存不命中现象。
对于一个访问内存的指令的现象,如果发生了缺页故障,就会调用异常处理程序,从磁盘中寻找需要访问的页存,选择牺牲页,将PTE信息更新。
然后故障修正成功,返回当前程序,再次执行,就不会发生缺页故障了。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域-堆,分配器将堆视为一组不同大小的块来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。用户调用相应的申请和释放函数,动态内存分配器就会改变相应的块来完成要求,或检查相应的块,或遍历寻找空闲块。
隐式空闲链表分配器:
隐式空闲链表分配器的实现涉及到特殊的数据结构。其所使用的堆块是由一个子的头部、有效载荷,以及可能的一些额外的填充组成的。头部含有块的大小以及是否分配的信息。有效载荷用来存储数据,而填充块则是用来对付外部碎片以及对齐要求。
基于这样的基本单元,便可以组成隐式空闲链表。
隐式空闲链表:
一个块是由一个字的头部,有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小,以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息.在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
7.10本章小结
本章主要介绍了hello的存储地址空间、段式管理、页式管理、物理内存访问,还介绍了 hello 进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对响应文件的读和写来进行。
设备管理:unix io接口
使得所有的输入和输出都能以一种统一且一致的方式来进行,包括打开文件,改变当前的文件位置,读写文件和关闭文件等操作。
8.2 简述Unix IO接口及其函数
. 打开或创建文件:
一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息,应用程序只需记住这个描述符。
int open(char *filename, int flags, int perms);
int creat(char *name, int perms);
2. 关闭文件与删除文件:
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
int close(int fd);
int unlink(char *filename);
3. 读取文件与写入文件:
一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k ,直到k+n。
int read(int fd, char *buf, int n );
int write(int fd, char *buf, int n);
4. 游标移动
对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为K。
int lseek(int fd, long offset, int origion);
8.3 printf的实现分析
要实现printf函数,简单来说就是接受fmt格式,将匹配到的参数按照格式输出即可。
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章讲述了I/O设备管理机制,简单阐述了开、关、读、写、转移文件的接口及相关函数,简单分析了printf和getchar函数的实现方法。
结论
下面简单描述以下hello的一生,没有ppt中足够艺术,但是我尽力尝试描述。
1. 首先我们通过IDE使用C语言编写了hello.c文件,这是hello的胚胎。
2. 然后通过预处理将hello.c文件经过初步修改,头文件、宏定义的展开变为更容易被编译器理解的hello.i文件
3. 接着编译器将hello.i文件变为了更为基础的语言:汇编语言,并将代码保存在了hello.s文件中
4. 随后,汇编器将hello.s中的汇编语言处理成了能够被机器理解的机器语言,生成了可重定位的目标文件hello.o
5. 链接器将hello.o与外部文件(库)进行链接,这时候的hello.o终于变为了一个可执行文件hello
6. 随后在shell中输入运行指令./hello 2022112276 jyr 18846752508 3,内核分配好需要的空间
7. 同时此时的我们还能在外部给相应信号对进程进行操作,比如Ctrl-Z。
8. 在hello需要访问磁盘信息的时候,CPU通过MMU帮助hello寻址
9. 最终,hello进程执行结束之后,shell对它进行回收
这次大作业帮助我更加全面系统地了解了计算机系统这门课程,同时帮助我完成了对书本更详细翻阅工作,在考前替我梳理了计算机系统的相关知识。让我感受到了计算机底层系统的复杂性和严密性,同时也告诉我如何写一个对计算机底层系统友好的代码。
附件
文件名称 文件作用
hello.c 源代码
hello.i 预处理之后的文件
hello.s 编译后的汇编文件
hello.o 汇编之后的目标文件
hello 链接之后的可执行文件
objdump.txt 对hello.o的反汇编文件
Objdump2.txt 对hello的反汇编文件
ELF.elf hello.o的elf文件
ELF2.elf hello的elf文件
参考文献
[1] Bryant,R.E. 深入理解计算机系统
[2] Printf函数的深入剖析
https://www.cnblogs.com/pianist/p/3315801.html
[3] EDB的安装与使用
https://blog.csdn.net/Franklins_Fan/article/details/103643965
[4] 逻辑地址到线性地址的转换
https://blog.csdn.net/xuwq2015/article/details/48572421