计算机系统大作业程序人生-Hello’s P2P

目  录

第1章 概述

1.1 Hello简介

1.2 环境与工具

1.3 中间结果

1.4 本章小结

第2章 预处理

2.1 预处理的概念与作用

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

2.4 本章小结

第3章 编译

3.1 编译的概念与作用

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.4 本章小结

第4章 汇编

4.1 汇编的概念与作用

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

4.4 Hello.o的结果解析

4.5 本章小结

第5章 链接

5.1 链接的概念与作用

5.2 在Ubuntu下链接的命令

5.3 可执行目标文件hello的格式

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

5.6 hello的执行流程

5.7 Hello的动态链接分析

5.8 本章小结

第6章 hello进程管理

6.1 进程的概念与作用

6.2 简述壳Shell-bash的作用与处理流程

6.3 Hello的fork进程创建过程

6.4 Hello的execve过程

6.5 Hello的进程执行

6.6 hello的异常与信号处理

6.7本章小结

第7章 hello的存储管理

7.1 hello的存储器地址空间

7.2 Intel逻辑地址到线性地址的变换-段式管理

7.3 Hello的线性地址到物理地址的变换-页式管理

7.4 TLB与四级页表支持下的VA到PA的变换

7.5 三级Cache支持下的物理内存访问

7.6 hello进程fork时的内存映射

7.7 hello进程execve时的内存映射

7.8 缺页故障与缺页中断处理

7.9动态存储分配管理

7.10本章小结

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

8.2 简述Unix IO接口及其函数

8.3 printf的实现分析

8.4 getchar的实现分析

8.5本章小结

结论

附件

参考文献


第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

P2P(Program to Process)是指从一个源程序经过预处理器、编译器、汇编器、连接器处理后,生成可执行文件,并在操作系统中运行该可执行文件,将其转换为一个进程的过程。在该过程中,操作系统会为可执行程序fork出一个新的进程,然后使用execve函数加载并执行程序,将其映射到虚拟内存区域,并根据需要将其载入物理内存中。当程序在CPU上运行完毕后,内核会将其从系统中清除。

在Hello的P2P过程中,Hello程序经历了从源代码到可执行文件再到进程的转变。首先,Hello.c经过预处理器、编译器、汇编器和连接器处理,生成了可执行文件hello。接着,在Bash内输入运行hello的命令后,操作系统为其fork出一个新的进程,在该进程的上下文中使用execve函数加载并运行hello程序。程序被映射到相应的虚拟内存区域,并根据需要载入物理内存中。当hello程序在CPU上执行完毕后,内核将其从系统中清除,完成整个P2P过程。

020过程描述了程序的创建、加载和执行,以及在CPU下运行后被内核清除的过程。在该过程中,操作系统首先创建了一个新的进程,然后在该进程的上下文中使用execve函数加载并运行可执行程序hello,将其映射到对应的虚拟内存区域,并根据需要载入物理内存中。当hello程序在CPU下运行后,内核会将其从系统中清除,完成整个过程。

1.2 环境与工具

列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。

硬件环境:X64 CPU; 1.20GHz; 16G RAM; 512GHD Disk

软件环境:Win11; Ubuntu 20.04

开发工具:CodeBlocks 64位,vim,gdb,gcc

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

hello.c源程序

hello.i 预处理后生成的文本文件

hello.s 编译后生成的汇编语言文件

hello.o 汇编后生成的可重定位文件

hello 链接后生成的可执行文件

1.4 本章小结

这章内容概述了从源程序经过预处理、编译、汇编、链接的整个过程,最终生成可执行文件。同时介绍了实验中所用的硬件、软件环境和开发工具,以及实验过程中产生的中间结果。


第2章 预处理

2.1 预处理的概念与作用

预处理是指在编译过程中的第一个阶段,主要负责处理源代码中的预处理指令,将其转换为适合编译器处理的形式。预处理器会执行一系列指令,如#include、#define和#ifdef等,以及宏替换等操作。对源代码进行处理,使得代码更具有可读性、可维护性,同时为编译器提供更加清晰简洁的代码进行后续的编译处理。

预处理的主要作用包括:

  1. 头文件包含:将#include指令所包含的头文件内容插入到源文件中,方便程序员组织代码和复用代码。
  2. 宏替换:将#define定义的宏在源代码中进行替换,可以简化代码编写和提高可读性。
  3. 条件编译:根据#ifdef、#ifndef等条件指令来进行代码的选择性编译,使得能够根据不同的条件编译不同的代码段。
  4. 注释处理:去除注释、空白行等对编译无关紧要的内容,减小源文件大小,提高编译效率。

2.2在Ubuntu下预处理的命令

使用以上代码后产生hello.i文件

2.3 Hello的预处理结果解析

以下为hello.c源程序:

以下为hello.i的一部分:

可以观察到,与.c源文件相比,生成的.i文件包含了大量额外的内容,可以辨认出这些内容都是可读的C语言语句。预处理阶段展开了源文件中定义的宏,将头文件中的内容包含到了这个文件中。例如,可以看到getsubopt、getloadavg等函数的定义,以及一些结构体类型的声明。在hello.i文件中增加了许多头文件和结构体,只是因为在预处理阶段,会递归地处理程序中的#include指令,如将#include<stdio.h>替换为相应的头文件,如果头文件中还包含#include指令,这个过程会被递归地执行。所以产生了许多头文件和结构体。

2.4 本章小结

这章详细介绍了预处理的指令和作用,并以hello.c程序为例进行了说明。通过比较源程序和预处理后生成的文本文件,更加具体地展示了预处理的作用。预处理作为编译运行的关键第一步,在hello.c文件的预处理和结果展示中得到了充分阐述。查看.i文件可以让我们直观地感受到预处理前后源文件的变化,从而加深对预处理过程的理解。


第3章 编译

3.1 编译的概念与作用

编译的概念是指编译器对hello.i文件进行处理的过程。在这一阶段,编译器会对代码进行语法和语义的分析,然后生成相应的汇编代码,并将其保存在hello.s文件中。

编译的作用包括对源程序进行检查,判定是否存在语法上的错误,若有错误则提示改正,若无错误则自动将源程序转换为二进制形式的目标程序。因此,编译的作用在于将高级语言源代码转换为计算机能够理解和执行的机器代码,从而使得程序能够在计算机上运行。

3.2 在Ubuntu下编译的命令

使用以上代码后产生hello.s文件

3.3 Hello的编译结果解析

使用vim文本编辑器打开hello.s文件——在终端中输入代码:vim hello.s

3.3.1 数据

hello.c中定义了一个局部变量i,局部变量应该放在寄存器中。由下图可以看出,局部变量i被放在了寄存器-4(%rbp)中。而其他的数字常量都以立即数的形式出现如$1,$7。

字符串常量则被放入内存中的 .rodata节常量区中,hello.c中的两个字符串常量的形式如下:

打印字符串常量时,编译器将语句翻译为先将字符串存放的地址存入寄存器,再进行打印,如下图:

3.3.2 赋值

hello.c中的赋值操作有将i赋值为0,汇编语言为将0放到寄存器-4(%rbp)中,具体如下图:

3.3.3 类型转换

如下图所示,hello.c中有将字符型的argv[3]通过函数atoi()转换成整型变量的过程,编译器先是将argv[3]从栈中取出,再赋值给%rdi,通过调用atoi函数,将其转化为整型变量后再放回到%edi中。

3.3.4 算术操作

hello.c中的算数操作有i++,在编译器中通过addl操作实现加一操作,由3.3.1可知变量i被存放在寄存器-4(%rbp)中,如下图所示:

3.3.5 关系操作

hello.c中有将argc和4比较的操作,由上文可知,argc被存放在寄存器-20(%rbp)中,编译器将其翻译为将寄存器-20(%rbp)中与立即数$5作比较,如果相等则进行跳转,此处的跳转即为跳出if语句,如果不等,则进入if语句。进行接下来的操作。源程序中的两处比较都是采用了cmp语句+跳转语句。

3.3.6 数组操作

hello.c中的数组有*argv,编译器将数组的首地址存放在栈中-32(%rbp)的位置,通过加24,加1+操作来获得argv[1]和argv[2],如下图所示:

3.3.7 控制转移

由3.3.5可知hello.c中的if操作采用的比较+跳转的方式来实现。

for循环操作本质上也是比较+跳转,先是为i赋初值0,接下来比较i和9的大小,如果比9小或等于则跳到代码.L4段,如果大于则继续执行,在代码.L4段的结尾对i有一个加一操作,至此循环操作完成。

(1).L2段对i赋初值

(3).L3判断循环的进入条件

(4).L4段进入循环并加一

3.3.8 函数操作

源程序调用了六个函数,printf、atoi()、puts()、getchar()、sleep()、exit()。编译器均是用call来进行调用的,如下图所示:

3.4 本章小结

这章详细介绍了编译的定义和重要作用,同时针对编译生成的代码在数据处理、赋值操作、类型转换、算术运算、关系运算、数组操作、控制流转移、函数调用等方面进行了深入分析和阐述。


第4章 汇编

4.1 汇编的概念与作用

概念:汇编是将.s文件中的汇编语言代码翻译成机器语言指令,并将这些指令打包成可重定位目标程序的格式,最终保存到.o文件中的过程。

作用:汇编的作用是将汇编文本文件翻译成机器可以识别的二进制代码,为计算机程序的执行提供了基础,使得源代码能够被计算机理解和执行。

4.2 在Ubuntu下汇编的命令

使用以上代码后产生hello.o文件

使用上述代码查看hello.o

4.3 可重定位目标elf格式

一个典型的ELF可重定位目标文件的格式如图

4.3.1 ELF头

ELF头是一个16字节的序列,用于描述生成该文件的系统的字的大小和字节顺序。ELF头的其余部分包含有关目标文件的连接器语法分析和解释信息,包括ELF头的大小、目标文件类型(如可重定位、可执行、共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。

4.3.2 节头

(1).text

已编译程序的机器代码

(2).rodata

只读数据

(3).data

已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。

(4).bss

未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0.

(5).symtab 节
符号表

(6).rel.text 节
文本部分的重新定位信息在可执行文件中需要修改的指令的地址修改指令

(7).rel.data 节
数据段的重新定位信息

在合并的可执行文件中需要修改的指针数据的地址

(8).debug 节

符号调试信息(gcc -g)

(9).line

初始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会得到这张表。

(10).srttab

一个字符串表

4.3.3 重定位节

ELF文件格式中的重定位节包含两个部分:.rela.text节与.rela.eh_frame节。.rela.text节包含.text节中的位置的列表,含有该.text中所需要进行重定位操作的信息,当链接器(ld)将目标文件与其他文件由进行结合时,需要修改这些位置.rela.eh_frame节包含了对en_frame节的重定位信息。

4.3.4 符号表

ELF文件格式中的符号表中存放了程序中所定义和引用的的全局变量以及函数的信息。(不包含局部变量)

4.4 Hello.o的结果解析

利用 objdump -d -r hello.o  分析hello.o的反汇编

对比hello.o的反汇编代码和hello.s中的内容可以发现以下几处不同:

  1. 对字符串常量的引用不同

hello.s中是用的全局变量所在的那一段的名称加上%rip的值,而hello.o中用的是0加%rip的值,因为当前为可重定位目标文件,之后还需经过重定位方可确定其具体位置,所以这里都用0来代替。

  1. 函数的调用不同

   hello.s中的函数调用是直接call+函数的名字,而反汇编代码中函数调用是call+下一条指令的地址。具体如下:

  1. 数字进制不同

hello.s中立即数采用的是十进制,而hello.o的反汇编代码中采用的是十六进制。

在hello.o的反汇编文件中,我们会看到熟悉的汇编代码,与hello.s汇编文件的内容相同。然而在这些汇编代码中间,会夹杂一些我们不太熟悉的内容,即机器代码。

这些机器代码是二进制机器指令的集合,每条机器代码对应一条机器指令,是机器真正能识别的语言。每条汇编语句都可以用机器二进制数据来表示,汇编语言中的操作码和操作数以一种相对应的方式映射到机器语言,使得机器能够真正理解代码的含义并执行相应的功能。

机器代码与汇编代码的不同之处在于:

  1. 在分支跳转方面,汇编语言中的分支跳转语句使用标识符(例如je .L2)来指示应该跳转到哪里,而经过翻译后的机器语言则直接使用相应的地址来实现跳转。

  1. 在函数调用方面,汇编语言.s文件中直接使用函数名进行函数调用。然而,在.o反汇编文件中,call指令的目标地址是当前指令的下一条指令地址。这是因为hello.c中调用的函数通常是共享库中的函数,需要在链接之后才能确定相应函数的地址。因此,在机器语言中,对于这种不确定地址的调用,会先将下一条指令的相对地址设置为0,然后在.rela.text节中为其添加重定位条目,等待链接时确定地址。

4.5 本章小结

本节讨论了汇编语言的概念和作用,并对汇编指令进行了介绍。同时,通过反汇编详细比较了生成的机器代码和汇编代码之间的差异。


5链接

5.1 链接的概念与作用

链接的概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。在现代系统中,链接是由叫做链接器的程序自动执行的。

链接的作用:链接的作用是将预编译好了的一个目标文件(hello.o)或若干目标文件外加链接库合并成为一个可执行目标文件(hello)。使得分离编译称为可能,不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为可独立修改和编译的模块。当改变这些模块中的一个时,只需简单重新编译它并重新链接即可,不必重新编译其他文件。结合上述文字,写出链接的概念与作用

5.2 在Ubuntu下链接的命令

使用以上代码得到hello文件

5.3 可执行目标文件hello的格式

5.3.1 ELF头

使用readelf -h hello可以得到如下信息:

可以看到文件的Type发生了变化,从REL变成了EXEC(Executable file可执行文件),节头部数量也发生了变化,变为了27个

5.3.2 Section

使用readelf -S hello代码得到以下信息:

节头部表对hello中所有信息进行了声明,包括了大小(Size)、偏移量(Offset)、起始地址(Address)以及数据对齐方式(Align)等信息。根据始地址和大小,我们就可以计算节头部表中的每个节所在的区域。

5.3.3 符号表

使用readelf -s hello代码得到以下信息:

可以发现经过链接之后符号表的符号数量陡增,说明经过连接之后引入了许多其他库函数的符号,一并加入到了符号表中。

5.3.4 可重定位段信息

使用readelf -r hello代码得到以下信息:

5.4 hello的虚拟地址空间

使用edb --run hello代码打开edb

可以看到hello虚拟地址空间的起始地址为0x401000,结束地址为0x401ff0。

根据5.3.2中的Section头部表,可以找到对应的节的其实空间对应位置,例如.init初始化节,起始位置地址为0x401000在edb中有其对应位置

5.5 链接的重定位过程分析

利用objdump -d -r hello指令反汇编与hello.o比较

5.5.1 函数代码段

hello中的每个函数都有其自己的一段,而且每个函数和指令都有其虚拟地址,如下图所示

5.5.2 main函数的起始地址发生变化

hello.o中main函数的地址是0x0,而在hello中main函数有了虚拟地址,变化如下图所示:

5.5.3 函数的重定位

hello中的函数经过重定位后拥有了自己的虚拟地址。如下图所示:

5.5.4 跳转指令

同样跳转也变成了跳转到指令的虚拟地址:

5.6 hello的执行流程

程序名称

程序地址

_start

0x4010f0

_libc_start_main

0x7ffff7de2f90

__GI___cxa_atexit

0x7ffff7e05de0

__new_exitfn

0x7ffff7e05b80

__libc_csu_init

0x4011c0

_init

0x401000

_sigsetjump

0x7ffff7e01bb0

main

0x401125

do_lookup_x

0x7ffff7fda4c9

dl_runtime_resolve_xsavec

0x7ffff7fe7bc0

_dl_fixup

0x7ffff7fe00c0

_dl_lookup_symbol_x

0x7ffff7fdb0d0

check_match

0x7ffff7fda318

strcmp

0x7ffff7fee600

5.7 Hello的动态链接分析

动态共享库是一种现代创新产物,旨在解决静态库的缺陷。共享库是一个目标模块,在运行或加载时,可以被加载到任意内存地址,并与程序链接在一起,这个过程被称为动态链接。

将程序分割成相对独立的模块,只有在程序运行时才将它们链接在一起形成完整的程序,而不像静态链接那样将所有程序模块链接成一个单独的可执行文件。

.plt:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。

.got:GOT是一个数组,其中每个条目是8字节地址。与PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。

hello在动态连接器加载前后的重定位是不一样的,重定位是在加载之后才进行的。

5.8 本章小结

本章介绍了链接的概念和作用,分析了可执行目标文件hello的格式,详细分析了链接的重定位过程和hello的执行流程,最后对hello的动态链接进行了分析。


6hello进程管理

6.1 进程的概念与作用

概念:操作系统对一个正在运行的程序的一种抽象。

作用:一个程序在系统上运行时,操作系统会提供一种,程序在独占这个系统,包括处理器,主存,I/O设备的假象。处理器看上去在不间断地一条一条执行程序中的指令…这些假象都是通过进程的概念实现的。

6.2 简述壳Shell-bash的作用与处理流程

shell是一个在Linux操作系统中提供的应用程序,它为用户与内核之间提供了一个交互的界面,用户可以通过这个界面访问操作系统的内核服务。其处理流程如下:

1. 从界面中读取用户的输入。

2. 将输入的内容转化成对应的参数。

3. 如果是内核命令就直接执行,否则为其分配新的子进程继续运行。

4. 在运行期间,监控shell界面内是否有键盘输入的命令,如果有需要作出相应的反应。

6.3 Hello的fork进程创建过程

Fork函数在进程的当前位置创建一个新进程,新进程具有与原进程完全相同的状态(除PID)。创建过程如下:

1. 复制父进程的堆、栈等数据空间给新进程。

2. 创建新进程。

输入以下命令,fork函数会使程序创建一个子进程,子进程与父进程共享同一个虚拟地址空间,但拥有不同的PID。

6.4 Hello的execve过程

在子进程中调用execve函数,在进程的上下文中运行hello程序。这将覆盖当前进程的地址空间,但不会创建一个新的进程。execve函数调用一次,如果成功就不会返回,而如果发生错误则会返回-1。

在运行hello程序的过程中,虚拟空间会创建新的代码、数据、堆和栈。它将可执行代码和数据从磁盘复制到内存中,并映射共享区域。最后,它会跳转到程序的第一条指令开始执行。

6.5 Hello的进程执行

1. 时间片是指操作系统分配给每个程序或线程的运行时间段。在微观上,CPU在每个时间片内只能执行一个线程的指令,但由于CPU的轮转调度速度很快,因此在宏观上产生了多个程序并行执行的效果。这种机制使得每个程序都能够独占CPU的假象。

2. 上下文切换是操作系统内核实现多任务处理的一种方式。内核为每个进程维护着一个上下文,它包含了重启被抢占进程所需的全部状态信息。

3. 调度是指在进程执行过程中,内核可能会决定抢占当前进程并重新开始先前被抢占的进程的过程。内核调度一个新的进程运行后,通过上下文切换机制来将控制转移给新的进程:1)保存当前进程的上下文;2)恢复之前被抢占进程的保存的上下文;3)将控制转移给这个新恢复的进程。当内核代表用户执行系统调用时,也可能发生上下文切换,这时就会涉及用户态与核心态之间的转换。

4. 用户态与核心态转换是处理器根据控制寄存器中的模式位来限制应用程序可以执行的指令和访问的地址空间范围的过程。当模式位未设置时,进程运行在用户模式下,只能执行非特权指令;而当模式位设置时,进程运行在内核模式下,可以执行指令集中的任何指令,并访问系统内存中的任何位置。当发生异常时,控制会传递到异常处理程序,从用户模式切换到内核模式;而当返回到应用程序代码时,又会从内核模式切换回用户模式。

6.6 hello的异常与信号处理

 hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。

 程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps  jobs  pstree  fg  kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。

6.6.1 不停乱按

按下的字符串会直接显示并且不干扰程序的运行

6.6.2 正常运行

正常运行就是每隔一秒输出一个“hello 2022112300 zhangwenbo 13313629850 0\n”,总共输出8次后,调用getchar()函数,等待用户按下回车终止程序

6.6.3 按回车

会首先在打印的过程中换行。在打印完毕最后一行字符串后,由于输入的回车依然存在于stdin中,所以在调用getchar()函数时,会读取stdin中的回车,因此无需再敲回车键,便能终止程序。

程序终止后,发现terminal中出现n个空行,这是因为在程序的执行过程中,敲了n+1下回车键,因此都留在stdin中,getchar()只接收了其中的第一个回车,由于在程序终止后没有清空stdin,剩余的回车保留在其中。当terminal继续运行时,遇到回车便开始处理,但单独的回车相当于一个空行,被terminal忽略,读入但不执行任何操作,因此留下了n个空行。

6.6.4 按ctrl+z

停止进程,发送SIGSTP信号,父进程收到信号后进行处理

在shell命令行中输入ps,打印出各进程的pid,其中包括被挂起的hello。

输入fg程序继续运行

输入jobs,查看hello程序当前状态

输入pstree查看当前进程树

输入kill杀死hello进程,利用ps查看后发现进程已被杀死

Ctrl-C发送信号2(SIGINT),终止进程,程序直接退出并被回收。

6.7本章小结

本节内容阐述了进程的概念和作用,简要介绍了Shell-Bash的功能及其处理流程,以及Hello程序中fork进程的创建、execve过程和执行过程。此外,还详细描述了在Hello程序执行过程中可能发生的各种异常情况,所产生的信号以及相应的处理方式。


7hello的存储管理

7.1 hello的存储器地址空间

1. 逻辑地址:逻辑地址是由段基址和段偏移量组成的相对寻址方式,在Hello程序的反汇编代码中,使用的地址都是逻辑地址,采用相对寻址方式。

2. 线性地址:如果地址空间中的整数是连续的,则称其为线性地址空间。Hello程序的反汇编代码中,通过偏移量可知,地址是连续的,因此可以被视为线性地址。

3. 虚拟地址:虚拟地址是由CPU生成的一种中间地址,用于访问主存。MMU硬件负责将虚拟地址翻译成物理地址。在Hello程序中,代码段的地址例如0x400000就是虚拟地址,需要通过映射转换成物理地址。

4. 物理地址:计算机系统的主存被组织成一个由M个字节大小的单元组成的数组,每个单元都有唯一的物理地址。在Hello程序中,MMU负责将虚拟地址翻译成对应的物理地址。

7.2 Intel逻辑地址到线性地址的变换-段式管理

一个逻辑地址由两部分组成,即段标识符和段内偏移量。段标识符是由一个16位长的字段组成,也称为段选择符,其中前13位是索引号,而后面的3位则包含了一些硬件细节,如下图所示:

最后两位涉及权限检查。

索引号,或者直接理解成数组下标,就是“段描述符表(segment descriptor)”的索引。段描述符表中存放着一系列的段描述符,每个段描述符具体描述了一个段(将“段”比喻成虚拟内存中的一段连续区域)。通过逻辑地址中的段标识符的前13位,可以直接在段描述符表中找到对应的段描述符。每个段描述符由8个字节组成,如下图所示:

Base字段描述了一个段的起始位置的线性地址。

根据Intel的设计,一些全局的段描述符存放在“全局段描述符表(GDT)”中,而一些局部的描述符,例如每个进程自己的,会存放在所谓的“局部段描述符表(LDT)”中。何时应该使用GDT,何时应该使用LDT取决于段选择符中的T1字段,当T1=0时表示使用GDT,而T1=1时表示使用LDT。

GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。

首先,给定一个完整的逻辑地址[段选择符:段内偏移地址]:

1. 首先查看段选择符中的T1位,确定当前要转换的是GDT中的段还是LDT中的段,并根据相应的寄存器获取其地址和大小,从而建立一个数组。

2. 提取段选择符中的前13位,通过在数组中查找对应的段描述符,获取到该段描述符的Base字段,即基地址。

3. 将基地址与段内偏移地址相加,得到要转换的线性地址。

7.3 Hello的线性地址到物理地址的变换-页式管理

在Linux中,逻辑地址等同于线性地址(因为各段的基址都是0x0)。线性地址通过分页机制映射到物理地址。具体来说,分页是CPU提供的一种机制,Linux根据这种机制的规则,利用它来实现内存管理。

分页的基本原理是将线性地址划分为固定长度的单元,称为页(page)。页内部的连续线性地址映射到连续的物理地址。在x86架构中,每页大小为4KB。为了将线性地址转换为物理地址,CPU需要访问当前任务的线性地址到物理地址的映射表,即页表(page table),该表存放在内存中。然而,由于页表过长,直接访问效率低下,因此采用了分级或分段的方式。

在保护模式下,控制寄存器CR0的最高位PG位控制分页管理机制的生效。如果PG=1,分页机制生效,必须通过页表查找将线性地址转换为物理地址。如果PG=0,则分页机制无效,线性地址直接被视为物理地址。

为了节省页表占用的内存空间,x86架构采用了两级查找的方式,通过页目录表和页表来将线性地址转换为物理地址。每个任务都有自己的页目录表(Page Global Directory,简称PGD)和页表(Page Table,简称PT)。在x86架构中,32位线性地址被分为三个部分:最高的10位作为页目录表的偏移量,中间的10位作为页表的偏移量,最低的12位作为物理页内的字节偏移量。

页目录表的大小为4KB,刚好是一个页的大小,包含1024项,每个项占据4字节(32位)。页目录表中的每个表项存储着对应页表的物理地址(由于物理页地址是4KB对齐的,因此物理地址的最低12位总是0,表项中的最低12位留作其他信息,这里进行简化分析)。如果页目录表中的页表尚未分配,则对应的物理地址填为0。

页表的大小也为4KB,同样包含1024项,每个项占据4字节。页表中的每个表项存储着对应物理页的物理内存起始地址。

7.4 TLB与四级页表支持下的VA到PA的变换

TLB(Translation Lookaside Buffer)和四级页表是现代计算机系统中用于虚拟地址到物理地址转换的重要机制。这种 TLB 和分级页表结合的机制,使得虚拟地址到物理地址的转换可以更加高效。同时,通过替换 TLB 中不常用或最近未使用的页面,可以有效地管理有限的缓存资源。

以下是通过 TLB 进行虚拟地址(VA)到物理地址(PA)的具体过程:

1. 虚拟地址到一级页表的索引转换:

   当进程尝试访问内存时,它提供一个虚拟地址。首先,这个虚拟地址被用来查询 TLB,以确认是否有与之相关联的页表项(PTE,Page Table Entry)。如果 TLB 中未找到匹配的 PTE,处理器将会查找页目录(通常是四级页表的第一级),并使用虚拟地址的某一部分作为索引来定位页目录中的相应条目。

2. 一级页表到二级页表的索引转换:

   一旦获取了页目录中的相应 PTE,处理器会检查该 PTE 是否在 TLB 中。如果在 TLB 中找到了相应的条目,则直接使用 TLB 条目进行物理地址的查找。如果 PTE 不在 TLB 中,处理器将使用 PTE 中的指针作为索引来访问二级页表(通常是四级页表的第二级)。

3. 二级页表到三级页表的索引转换:

   类似地,处理器会检查从二级页表获取的 PTE 是否在 TLB 中。如果在 TLB 中找到了相应的条目,则可以直接使用 TLB 条目来查找物理地址。如果 PTE 不在 TLB 中,处理器将使用 PTE 中的指针作为索引来访问三级页表(通常是四级页表的第三级)。

4. 三级页表到物理地址的转换:

   一旦获取了三级页表中的相应 PTE,处理器会检查该 PTE 是否已经在 TLB 中。如果在 TLB 中找到了相应的条目,则可以直接使用 TLB 条目来获取物理地址。如果 PTE 不在 TLB 中,处理器将使用 PTE 中的指针直接从硬盘上读取相应的数据页,并将其加载到内存中。然后,该 PTE 将被插入到 TLB 中,以便将来更快地访问该数据。

7.5 三级Cache支持下的物理内存访问

计算机系统中,三级缓存(L1、L2 和 L3 缓存)和物理内存(RAM)是存储和检索数据的关键组件。以下是在三级缓存支持下,如何从物理内存中访问数据的过程:

1. CPU 指令:当 CPU 需要访问数据时,首先检查该数据是否在 L1 缓存中。L1 缓存位于 CPU 内部,具有非常快的访问速度。

2. L1 缓存未命中:如果数据不在 L1 缓存中,CPU 将转向 L2 缓存进行查找。L2 缓存通常比 L1 缓存大,但访问速度较慢。

3. L2 缓存未命中:如果数据在 L2 缓存中未被找到,CPU 进一步检查 L3 缓存。L3 缓存是所有 CPU 核心共享的,比 L1 和 L2 缓存更大,但访问速度更慢。

4. L3 缓存未命中:如果数据在 L3 缓存中仍未被找到,CPU 发出请求到主存(通常是 RAM)。主存是计算机中最大的存储区域,但访问速度相比缓存要慢得多。

5. 从 RAM 中检索数据:当 CPU 从 RAM 中检索数据时,这个数据首先会被加载到 L3 缓存中,然后可以被 CPU 快速访问。如果这个数据经常被访问,它可能会被进一步加载到 L2 和 L1 缓存中,以便更快地访问。

写回策略:当 CPU 修改一个数据项时,这个改变最初只会反映在缓存中。为了保持数据的一致性,有一个写回策略,它会将修改过的数据写回到更低级别的缓存或主存中。

7.6 hello进程fork时的内存映射

当当前进程调用fork函数时,内核会为新的进程创建各种数据结构,并为其分配唯一的PID。这包括创建当前进程的mm_struct、区域结构和页表的原样副本,以便为新进程创建虚拟内存。同时,内核将两个进程中的每个页面标记为只读,并将两个进程中的每个区域都标记为私有的写时复制。

在fork函数返回时,新进程的虚拟内存与调用fork时的虚拟内存完全相同。然而,当这两个进程中的任何一个后续执行写操作时,写时复制机制会创建新的页面。因此,每个进程都保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

1. 删除已存在的用户区域。在shell虚拟地址的用户部分中,删除已存在的区域结构。

2. 映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。

3. 映射共享区域。如果hello程序与共享对象(或目标)链接,例如标准C 库libc.so,那么这些对象会动态链接到该程序,然后映射到用户虚拟地址空间中的共享区域内。

4. 设置程序计数器(PC) 。execve 完成后的最后一步是设置当前进程上下文中的程序计数器,使其指向代码区域的入口点。

7.8 缺页故障与缺页中断处理

缺页故障是指当软件尝试访问已映射在虚拟地址空间中但目前未加载到物理内存中的一个页面时,由中央处理器的内存管理单元所引发的中断。

缺页中断处理的过程如下:

1. 当操作系统检测到缺页中断时,会确定需要哪个虚拟页面。

2. 选择一个牺牲页面。如果该页面已被修改,则将其交换出去,并将新页面换入,并更新页表。

3. 缺页处理程序返回后,CPU重新执行引发缺页的指令。这条指令再次发送虚拟地址到内存管理单元(MMU),这次MMU能够成功翻译虚拟地址。

7.9动态存储分配管理

7.9.1 内存管理策略:

1. 首次适应:从起始位置开始搜索并分配第一个足够大的空闲块。

2. 最佳适应:搜索并分配最小的足够大的空闲块。

3. 最差适应:搜索并分配最大的足够大的空闲块。

7.9.2 内存管理的基本方法:

动态内存管理的主要方法是使用malloc函数和free函数。这是最常用的动态内存管理方法之一。malloc函数用于在运行时动态分配内存,而free函数用于释放之前分配的内存。通过使用malloc和free,可以实现灵活的内存管理。然而,程序员需要小心地管理内存的申请和释放,以避免内存泄漏或内存碎片等问题。

7.10本章小结

本章详细讨论了hello程序的存储器地址空间、段式管理、页式管理以及物理内存访问。同时也介绍了在调用fork和execve函数时的内存映射过程,并对TLB与四级页表支持下的虚拟地址到物理地址的转换进行了详细说明。此外,对缺页故障和缺页中断处理以及动态存储分配管理进行了概要概述。


8hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备的模型化是指将所有的I/O设备(如网络、磁盘和终端)抽象成文件的形式,使得所有的输入和输出操作都可以通过对应文件的读写来完成。

设备管理:unix io接口

设备管理方面,将设备优雅地映射为文件,使得Linux内核能够提供一个简单、低级的应用接口,即Unix I/O。这样一来,所有的输入和输出都可以以一种统一且一致的方式进行处理。

8.2 简述Unix IO接口及其函数

Unix I/O接口是Unix操作系统提供的用于进行输入/输出操作的一套函数集合。它包括以下几个函数:

1. open函数:用于打开文件或设备,并返回一个文件描述符。

2. read函数:从已打开的文件或设备中读取数据,并返回实际读取的字节数。

3. write函数:向已打开的文件或设备中写入数据,并返回实际写入的字节数。

4. close函数:关闭已打开的文件或设备,并释放相关资源。

5. lseek函数:改变文件指针的位置,实现对文件的随机访问。

6. fsync函数:将缓冲区的数据强制写入磁盘,确保数据持久化。

7. fdatasync函数:类似于fsync,但专门用于数据文件。

8. sync函数:将所有未写入磁盘的数据写入磁盘中。

9. ioctl函数:对设备执行控制操作,例如设置串口参数等。

8.3 printf的实现分析

探索printf的实现,我们从vsprintf生成显示信息开始,再到write系统函数,最后是陷阱-系统调用(如int 0x80或syscall)。字符显示驱动子程序负责从ASCII字符到字模库,然后将其显示在VRAM中(其中存储每个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取VRAM,并通过信号线向液晶显示器传输每个点的RGB分量。现在我们来研究printf函数的实现,首先关注printf函数的函数体。

int printf(const char *fmt, ...)

{

int i;

char buf[256];

   

     va_list arg = (va_list)((char*)(&fmt) + 4);

     i = vsprintf(buf, fmt, arg);

     write(buf, i);

   

     return i;

    }   

在形参列表中,存在一个token:...,这是一种表示可变参数的写法。当传递参数的个数不确定时,可以使用这种方式来表示。显然,我们需要一种方法让函数体能够知道具体调用时参数的个数。

我们先来看一下printf函数的内容,特别是这句:“va_list arg = (va_list)((char*)(&fmt) + 4);”。va_list的定义是:typedef char *va_list,这说明它是一个字符指针。而 (char*)(&fmt) + 4 表示...中的第一个参数。

在调用printf函数时,最右边的参数先入栈。fmt是一个指针,它指向第一个const参数(const char *fmt)中的第一个元素。fmt也是一个变量,它在栈上分配位置,也有自己的地址。对于一个`char *`类型的变量,它入栈的是指针,而不是这个char *类型变量本身。

vsprintf(buf, fmt, arg)的函数如下:

    int vsprintf(char *buf, const char *fmt, va_list args)

   {

    char* p;

    char tmp[256];

    va_list p_next_arg = args;

   

    for (p=buf;*fmt;fmt++) {

    if (*fmt != '%') {

    *p++ = *fmt;

    continue;

    }

   

    fmt++;

   

    switch (*fmt) {

    case 'x':

    itoa(tmp, *((int*)p_next_arg));

    strcpy(p, tmp);

    p_next_arg += 4;

    p += strlen(tmp);

    break;

    case 's':

    break;

    default:

    break;

    }

    }

   

    return (p - buf);

   }

vsprintf函数接受一个格式化的命令,并将指定的参数按照格式化输出。它返回的是一个长度,因此vsprintf的主要作用是格式化。该函数接受一个确定输出格式的格式字符串fmt,并使用该格式字符串对参数进行格式化,从而生成格式化输出。其中sys_call的实现:

 sys_call:

     call save

   

     push dword [p_proc_ready]

   

     sti

   

     push ecx

     push ebx

     call [sys_call_table + eax * 4]

     add esp, 4 * 3

   

     mov [esi + EAXREG - P_STACKBASE], eax

   

     cli

   

     Ret

如果只是理解printf的实现的话,我们完全可以这样写sys_call:

    sys_call:

    

     ;ecx中是要打印出的元素个数

     ;ebx中的是要打印的buf字符数组中的第一个元素

     ;这个函数的功能就是不断的打印出字符,直到遇到:'\0'

     ;[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串

     xor si,si

     mov ah,0Fh

     mov al,[ebx+si]

     cmp al,'\0'

     je .end

     mov [gs:edi],ax

     inc si

    loop:

     sys_call

   

    .end:

     ret

8.4 getchar的实现分析

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

处理键盘中断的子程序负责将接收到的按键扫描码转换成ASCII码,并将其保存到系统的键盘缓冲区中。

当调用`getchar`等函数时,它们会调用操作系统的系统调用来从标准输入读取数据,通常是通过调用`read`系统调用来实现的。

大多数输入/输出操作都是缓冲的,这意味着数据不会立即从输入设备传输到应用程序,而是先存储在缓冲区中。数据会在适当的时机(例如,当缓冲区满或者调用特定的刷新函数时)一次性传输。

系统调用可以是阻塞的或非阻塞的。在阻塞模式下,如果缓冲区中没有可用数据,调用将挂起,直到有数据可用。在非阻塞模式下,如果缓冲区中没有数据,调用会立即返回一个错误。

由于各种原因,系统调用可能会失败,例如输入/输出设备故障或者无效的参数。因此,`getchar`需要能够处理这些错误情况。

当`getchar`成功读取一个字符时,它会返回该字符的ASCII值。当达到文件结束符(EOF)时,通常返回EOF(通常是一个负值)。

在多线程环境中,需要特殊处理`getchar`的行为,以确保线程安全。

8.5本章小结

本章详细介绍了IO管理的基本概念,包括IO接口及其相关函数。其中,着重分析了printf`和getchar的实现原理。

在介绍IO管理时,首先从概念入手,阐述了IO管理的基本概念和重要性。然后,对IO接口进行了详细讲解,包括常用的IO函数以及它们的功能和用法。

针对printf函数,进行了深入的实现分析,从如何处理格式化命令到最终的输出过程,逐步解析了其内部机制。

对于getchar函数,同样进行了详细的实现分析,从如何处理键盘中断到如何将按键扫描码转换为ASCII码,并保存到系统的键盘缓冲区中,最终解释了其工作原理。

通过本章的介绍,读者可以更加深入地理解IO管理的核心概念,以及printf和getchar等常用IO函数的实现原理。

结论

这是一个很好的实验,通过以上的所有步骤可以深入了解计算机系统对源程序的处理过程。从预处理器的宏展开和头文件包含,到编译器的语法分析和生成汇编代码,再到汇编器将汇编代码转换为目标文件,以及链接器的符号解析和地址重定位,最终形成可执行文件。同时,理解了操作系统如何创建进程、加载程序、管理内存、处理信号以及进行进程回收等过程,对于理解计算机系统的运行原理和底层机制非常有帮助。总的来说,整个过程涉及了预处理、编译、汇编、链接、进程管理、内存管理、信号处理等多个方面,是计算机系统设计与实现中的重要环节。对于提升系统性能、提高开发效率和保证系统稳定性具有重要意义。

这样的实验不仅加深了对知识点的理解,也为以后深入学习系统编程和操作系统提供了良好的基础。通过不断探索和创新,可以为计算机系统的设计与实现带来新的思路和方法,推动整个行业的发展和进步。


附件

hello.c源程序

hello.i 预处理后生成的文本文件

hello.s 编译后生成的汇编语言文件

hello.o 汇编后生成的可重定位文件

hello 链接后生成的可执行文件


参考文献

为完成本次大作业你翻阅的书籍与网站等

[1]  林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.

[2]  辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.

[3]  赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).

[4]  谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.

[5]  KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.

[6]  CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值