Hello大作业p2p

摘  要

本文主要通过计算机系统课程所学知识对hello程序在Linux系统下的整个生命周期进行分析,从预处理到链接,再从加载进内存到终止、回收的过程,即P2P,020的过程。以此对计算机系统知识体系有整体的把握。

关键词:计算机系统;编译;存储管理;进程管理  

第1章 概述

1.1 Hello简介

P2P:Hello.c从文本文件,经过cpp 的预处理、ccl 的编译、as 的汇编、ld 的链接,变为可执行文件,存储在磁盘上,即程序Program;当用户通过向shell输入可执行文件的名字,运行程序时,shell就会创建一个新的进程,并在该进程的上下文中执行Hello,这是Process。这便是从Program到Process的过程。

020:shell创建新进程后,调用execve执行Hello程序,execve在当前进程的上下文中,通过加载器将Hello的代码和数据从磁盘复制到内存,从0到1,然后跳转到程序入口点运行Hello,CPU 为运行的Hello执行逻辑控制流。当程序运行结束后,shell 父进程负责回收Hello 进程,内核抛弃子进程Hello,删除掉其在系统中的所有痕迹,于是从1到0。这就是0 to 0 的过程。

1.2 环境与工具

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

硬件环境:CPU: Intel Pentium , RAM: 8G , SSD 128G

软件环境:Windows 10 , Ubuntu 20.04 , WSL2

开发及调试工具: gcc , vim , Visual Studio Code , readelf , objdump

1.3 中间结果

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

hello.c

hello程序C语言源文件

hello.i

hello.c经预处理后的中间文件

hello.s

hello.i编译得到的汇编语言文件

hello.o

hello.o 编译后得到的可重定位目标文件

1.4 本章小结

本章主要简单介绍了hello 的p2p,020 过程,列出了本次实验的环境、中间结果。

第2章 预处理

2.1 预处理的概念与作用

预处理指在编译前,由C预处理器cpp根据预处理指令,对源代码进行文本替换等处理,得到中间文件的过程。

      

2.2在Ubuntu下预处理的命令

命令:cpp 源文件名 中间文件名

2.3 Hello的预处理结果解析

打开hello.i文件可以发现,整个hello.i程序已经拓展为3061行。main函数出现在hello.c中的代码自3047行开始

在此之前是大量的外部函数、类型定义、stdio.h 等的递归展开,以stdio.h的展开为例,cpp到默认的环境变量下寻找stdio.h,打开/usr/include/stdio.h 发现其中依然使用了#define语句,cpp对此递归展开,所以最终.i程序中是没有#define的。

2.4 本章小结

本章主要介绍了预处理的定义与作用、并结合预处理之后的程序对预处理结果进行了解析。

第3章 编译

3.1 编译的概念与作用

编译是由编译器cc1根据一定的语法、优化规则,将高级语言C的中间文件翻译成ASCII汇编语言文件的过程。

3.2 在Ubuntu下编译的命令

命令: gcc 中间文件名 -Og -o 汇编文件名

3.3 Hello的编译结果解析

3.3.1 数据

      1. 整数

整数常量:

C代码中的常数在汇编中是以立即数存储于代码段中的。

局部整型变量:

可以看到用于循环的局部变量i存储于栈上

      1. 字符串

代码中有两个字符串,分别是提示信息与格式字符串,二者都存储于只读数据段中

以标号形式访问

      1. 数组

程序中的数组为main函数的参数数组,访问时先将数组首地址存入栈中。然后通过偏移得到数组中各字符串的首地址。

3.3.2 赋值

本代码涉及的赋值只有对循环变量i的初始化,汇编代码中通过mov指令完成。

mov指令的后缀是根据传送的数据的大小来确定的。

指令

b

w

l

q

大小

8b (1B)

16b (2B)

32b (4B)

64b (8B)

3.3.3 算术操作

程序中的算术操作只有对循环变量i的自增。在汇编代码中使用add指令完成。

3.3.4 关系操作

程序中涉及的关系运算有两处。

一处是判断argc是否为4,使用cmp指令和je条件跳转完成。cmp指令相当于sub指令,但是不改变目的寄存器,只改变条件码,je指令当ZF=0时便会跳转。故可实现C代码中的!=操作。

另一处是判断循环变量是否小于8,与上面类似,使用jle指令进行条件跳转,即当i小于等于7跳转。

3.3.5 数组操作

本程序只涉及访问数组,具体为访问argv参数字符串数组。argv是字符串首地址为元素的数组。

首先将数组的首元素地址存入栈中,并存入寄存器以方便寻址。

然后通过add指令调整寄存器存储的指针,并通过间接寻址的方式访问数组元素。

3.3.6 控制转移

      1. if(argc!=4)

使用cmp指令和je条件跳转完成。cmp指令相当于sub指令,但是不改变目的寄存器,只改变条件码,je指令当ZF=0时便会跳转到.L2处,查看.L2处代码,初始化循环变量,开始循环,对应于程序中的循环体。若不等,调用exit直接退出。

      1. for(i=0;i<8;i++)

循环结构如下,先在.L2中初始化,然后通过cmp与jle指令检查循环终止条件,在.L3与.L4间循环。

3.3.7 函数操作

传递控制:准备进行过程Q的时候,先将PC压入栈中,以便之后能够返回。同时PC设置为Q的代码的起始地址,然后在返回时, call Q后面那条指令的地址出栈,控制回到P过程。

传递数据:P必须能够向Q提供一个或多个参数,Q必须能够向P中返回一个值。64位编译系统采用寄存器传参数。一般使用%rax保存返回值。

64位编译系统的传参次序:

分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间,这些通过增减rsp的值实现。

      

程序中涉及的函数操作:

main 函数:

传递控制:main 函数因为被调用call 才能执行(被系统启动函数__libc_start_main 调用),call 指令将下一条指令的地址dest 压栈,然后跳转到main 函数。

传递数据:外部调用过程向main 函数传递参数argc 和argv,分别使用%rdi 和%rsi 存储,函数正常出口为return 0,将%eax 设置0返回。

分配和释放内存:使用%rbp 记录栈帧的底,函数分配栈帧空间在%rbp 之上,程序结束时,调用leave 指令,恢复栈空间为调用之前的状态,然后ret返回,ret 相当pop IP,将下一条要执行指令的地址设置为dest。

printf 函数:

传递数据:第一次printf 将%rdi 设置为“用法: Hello 学号 姓名 秒数!\n”字符串的首地址。第二次printf 设置%rdi 为“Hello %s %s\n”的首地址,设置%rsi 为argv[1],%rdx 为argv[2]。

控制传递:第一次printf 因为只有一个格式字符串参数,所以call puts优化;第二次printf 使用call printf。

exit 函数:

传递数据:将main返回值 %edi 设置为1。

控制传递:call exit。

atoi函数

传递数据:将%edi 设置为argv[3]。

控制传递:call atoi。

sleep 函数:

传递数据:将%edi 设置为atoi的返回值eax。

控制传递:call sleep。

getchar 函数:

控制传递:没有参数,直接call gethcar

3.4 本章小结

编译器将.i 的中间文件编译为.s 的汇编代码。经过编译之后,hello 从C 语言被翻译为更加低级的汇编语言。

本章主要阐述了编译器是如何处理各个数据类型以及各类操作,并结合 hello.c C 程序到 hello.s 汇编代码之间的映射关系给出合理分析。

第4章 汇编

4.1 汇编的概念与作用

汇编器(as)将.s 汇编程序翻译成机器语言,同时把机器语言指令连同数据打包成可重定位目标文件的格式,并将结果保存在.o 文件中,.o 文件是一个可重定位目标文件,包含程序的指令编码、数据以及其他程序运行所需的必要信息。

注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。

4.2 在Ubuntu下汇编的命令

as 汇编语言文件路径 -o 可重定位目标文件路径

4.3 可重定位目标elf格式

分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。

  1. ELF 头

描述了生成该文件的系统的字大小、字节顺序(大/小)、ELF头的大小、目标文件的类型、机器类型(如x86-64)、节头部表的偏移、节头部表中条目的大小和数量等信息

  1. 节头表

从左到右依次为节名、类型、起始地址、偏移、大小等信息

例如.text节存储程序的机器语言代码,从里文件开头0x40偏移处开始,大小为0x6d

  1. 重定位节

重定位节.rela.text:存放着代码的重定位条目。当链接器将这个目标文件和其他目标文件链接时,会根据这个节,修改.text节中相应位置的信息。

重定位条目的结构如下

offset

需要进行重定向的代码在.text或.data 节中的偏移位置,8 个字节。

info

包括symbol 和type 两部分,其中symbol 占前4 个字节, type 占后4 个字节,symbol 代表重定位到的目标在.symtab中的偏移量,type 代表重定位的类型

type

重定位到的目标的类型,4个字节

addend

重定位地址修正量

此节中有两种重定位类型,R_X86_64_32和R_X86_64_PLT32

R_X86_64_32使用绝对地址重定位,R_X86_64_PLT32使用程序链接表进行重定位。

  1. 符号表

用来存放程序中包含的所有符号的信息,包括外部符号、局部符号、全局符号。对应函数、全局变量、过程静态变量。

从左到右依次为符号的索引、值、大小、类型、作用域等,最后是符号的名字,存储形式为名称字符串相对于.strtab节的偏移。

4.4 Hello.o的结果解析

objdump -d -r hello.o  分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。

说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。

反汇编内容如下

  1. 机器语言的构成

机器语言指令是一种二进制代码,由操作码和操作数两部分组成。操作码规定了指令的操作,是指令中的关键字,不能缺省。操作数表示该指令的操作对象。

  1. 机器语言与汇编语言的映射关系

汇编语言与机器语言往往是一一对应的关系,汇编语言是机器语言的助记符。

  1. hello.s与反汇编代码的区别

与hello.s进行对比后发现,反汇编结果与汇编语言文件大部分相同,只有跳转指令、函数调用、字符串常量不一样,hello.s中,这三者都是用符号直接表示,而反汇编代码中与此不同:

跳转指令:反汇编中,跳转目标在机器代码是PC相对的,即用目标指令地址与当前指令下一条指令的地址之差的补码表示。

函数调用:由于hello程序中调用的都是共享库中的函数,其地址还需重定位。因此暂时定为缺省值0,后面附有其对应的重定位条目。

字符串常量:使用0作为占位符,重定位条目中是引用.rodata节的符号。

4.5 本章小结

本章介绍了hello 从hello.s 到hello.o 的汇编过程,查看了hello.o 的elf 内容、使用objdump 得到反汇编代码与hello.s 进行比较,介绍了从汇编语言转换到机器语言大致内容。同时在这个过程中,生成了链接中的重定位过程所需的重定位条目及符号表。

5章 链接

5.1 链接的概念与作用

链接是将各种代码和数据片段收集并组合成为一个单一文件的过程。

得益于链接,分离编译成为可能,我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。

5.2 在Ubuntu下链接的命令

ld -o 输出可执行目标文件 系统目标文件 其他目标文件

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

使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件

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

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

ELF头

各段基本信息

5.4 hello的虚拟地址空间

用edb查看程序hello,发现程序在地址0x400000~0x401000中被载入,每个节排列都同5.3中的地址声明,在文件0~fff空间中,与0x400000~0x401000段的存放的程序相同,在fff之后存放的是.dynamic~.shstrtab节

查看ELF文件的程序头,程序头在为链接器提供运行时的加载内容和提供动态链接的信息,每一个表项提供了各段在虚拟地址空间大小和物理地址空间大小,位置,标志,访问权限和对齐方式,红框表示虚拟地址空间信息

程序头段说明如下

PHDR   保存程序头表

INTERP      指定在程序已经从可执行文件映射到内存之后,必须调用的解释器(如动态链接器)。

LOAD 表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串)、程序的目标代码等。

DYNAMIC 保存了由动态链接器使用的信息

NOTE  保存辅助信息

GNU_STACK    权限标志,标志栈是否是可执行的

GNU_RELRO   指定在重定位结束之后那些内存区域是需要设置只读

5.5 链接的重定位过程分析

以下格式自行编排,编辑时删除

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。

结合hello.o的重定位项目,分析hello中对其怎么重定位的。

比较二者的节头表,hello比 hello.o 多出来的这些节:

.interp:保存 ld.so 的路径

.note.ABI-tag

.note.gnu.build-id:编译信息表

.gnu.hash:gnu 的扩展符号 hash 表

.dynsym:动态符号表

.dynstr:动态符号表中的符号名称

.gnu.version:符号版本

.gnu.version_r:符号引用版本

.rela.dyn:动态重定位表

.rela.plt:.plt 节的重定位条目

.init:程序初始化

.plt:动态链接表

.fini:程序终止时需要的执行的指令

.eh_frame:程序执行错误时的指令

.dynamic:存放被 ld.so 使用的动态链接信息

.got:存放程序中变量全局偏移量

.got.plt:存放程序中函数的全局偏移量

.data:初始化过的全局变量或者声明过的函数

       再比较 hello.o 的反汇编代码与 hello 的反汇编代码

1)函数个数:在使用 ld 命令链接的时候,指定了动态链接器为 64 的/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o 中主要定义了程序入口_start、初始化函数_init,_start 程序调用 hello.c 中的 main 函数,libc.so 是动态链接共享库,其中定义了 hello.c 中用到的 atoi、printf、sleep、getchar、exit 函数和_start 中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入。

2)函数调用:链接器解析重定条目时发现对外部函数调用的类型为

R_X86_64_PLT32 的重定位,此时动态链接库中的函数已经加入到了 PLT

中,.text 与.plt 节相对距离已经确定,链接器计算相对距离,将对动态链接库中函数的调用值改为 PLT 中相应函数与下条指令的相对地址,指向对应函数。对于此类重定位链接器为其构造.plt 与.got.plt。

3).rodata 引用:链接器解析重定条目时发现两个类型为R_X86_64_PLT32 的对.rodata 的重定位(printf 中的两个字符串),通过(2)中的方法对其重定位。

5.6 hello的执行流程

使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。

程序名称

程序地址

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

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的动态链接分析

动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数,在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。

在 dl_init 调用之前,对于每一条 PIC 函数调用,调用的目标地址都实际指向PLT 中的代码逻辑,GOT 存放的是 PLT 中函数调用指令的下一条指令地址。

.got.plt 的起始地址是 0x404000,执行dl_init前如图。

调用 dl_init 后 0x404008 和 0x404010 处的两个 8 字节的数据发生改变,出现了两个地址 0x7f85442c2190 和 0x7f85442ad200。这就是GOT[1]和 GOT[2]。

      

其中 GOT[1]指向重定位表(依次为.plt 节需要重定位的函数的运行时地址)用来确定调用的函数地址。

GOT[2]指向的目标程序是动态链接器 ld-linux.so 运行时地址。

5.8 本章小结

本章介绍了链接过程中对 hello 的处理以及对链接后生成的可执行文件hello 的分析,包括 hello 的 elf 格式、hello 的虚拟地址空间、hello 的重定位以及动态链接的过程。链接完成后,hello 成为了一个可以运行的程序。

6章 hello进程管理

6.1 进程的概念与作用

进程是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。

进程为用户提供了这样的假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。

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

shell是一个交互型的应用级程序,它代表用户运行其他程序,是连接用户与系统内核的桥梁。

处理流程:

  1. 从终端读入输入的命令。
  2. 将输入字符串切分获得所有的参数。
  3. 如果是内置命令则立即执行。
  4. 否则调用相应的程序为其分配子进程并运行。
  5. shell 应该接受键盘输入信号,并对这些信号进行相应处理。

6.3 Hello的fork进程创建过程

在终端运行hello程序时,shell会调用fork创建出一个新的子进程。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,这就意味着,当父进程调用fork 时,子进程可以读写父进程中打开的任何文件。父进程与子进程之间最大的区别在于它们拥有不同的PID。

从fork函数返回后,shell程序会根据fork的返回值判断在子进程还是父进程中,若是子进程,则调用execve函数执行hello。

6.4 Hello的execve过程

当fork 之后,子进程调用execve 函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即hello 程序,execve 调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello 程序,加载器删除子进程现有的用户虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC 指向_start 地址,_start 最终调用hello中的main 函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。

加载器创建的内存映像如下:

6.5 Hello的进程执行

以下格式自行编排,编辑时删除

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

逻辑控制流:一系列程序计数器PC 的值的序列叫做逻辑控制流。

上下文:内核重新启动一个被抢占的进程所需的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。

时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。

当调用 sleep 之前,如果 hello 程序不被抢占则顺序执行,假如发生被抢占的情况,则进行上下文切换,上下文切换是由内核中调度器完成的,当内核调度新的进程运行后,它就会抢占当前进程,并进行 1)保存以前进程的上下文 2)恢复新恢复进程被保存的上下文,3)将控制传递给这个新恢复的进程 ,来完成上下文切换。

hello 初始运行在用户模式,在 hello 进程调用 sleep 之后陷入内核模式,内核处理休眠请求主动释放当前进程,并将 hello 进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时(2.5secs)发送一个中断信号,此时进入内核状态执行中断处理,将 hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello 进程就可以继续进行自己的控制逻辑流了。

当 hello 调用 getchar 的时候,实际落脚到执行输入流是 stdin 的系统调用 read,hello 之前运行在用户模式,在进行 read 调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的 DMA 传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回 hello 进程。

6.6 hello的异常与信号处理

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

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

异常:主要是中断异常,包括缺页中断、时钟中断、I/O中断。

时钟中断:不处理

I/O中断:将键盘输入字符读入缓冲区

缺页中断:缺页处理程序

  1. Ctrl+Z

向进程发送SIGSTP信号,进程挂起

ps命令

jobs命令

pstree命令

fg命令

kill命令

向进程发送SIGKILL信号,进程终止。

  1. Ctrl+C

向进程发送SIGINT信号,进程终止。

  1. 回车

对进程运行无影响,只是从输出缓冲区多输出几个空行

  1. 乱按

从键盘输入的字符都从缓冲区输出到屏幕上

6.7本章小结

本章阐明了进程的定义与作用,介绍了Shell 的一般处理流程,调用fork创建新进程,调用execve 执行hello,hello 的进程执行,hello 的异常与信号处理。

7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:又称相对地址,是程序运行由 CPU 产生的与段相关的偏移地址部分。描述一个程序运行段的地址。

物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。他是在前端总线上传输的而且是唯一的。在 hello 程序中,表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。

线性地址:即虚拟地址,是经过段机制转化之后用于描述程序分页信息的地址。是对程序运行区块的一个抽象映射。以 hello 为例子,是一个描述:“hello 程序应该在内存的哪些块上运行。”

逻辑(虚拟)地址经过分段(查询段表)转化为线性地址。线性地址经过分页(查询页表)转为物理地址。

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

根据7.1节的描述,逻辑地址=段选择符 : 偏移地址,而线性地址 = 段基址+ 偏移地址。因此,段式管理的关。键在于:段选择符段基址。

处理器有两种寻址模式:实模式与保护模式,下面分别对这两种模式,解释段式管理过程。

I.    实模式

实模式下,段选择符段基址的转换极为简单,段基址 = 段选择符 * 16。也就是说:线性地址 = 段选择符 * 16 + 偏移地址。甚至,不存在线性地址的中间概念,物理地址 = 线性地址 = 段选择符 * 16 + 偏移地址。

II.  保护模式

保护模式才是现代计算机常用的寻址模式。保护模式下,段选择符并不是通过直接计算得到段基址,而是作为一个索引,到一个称为描述符表的数据结构中读取段基址。此时,16位的段选择符也被划分成了几个部分,予以不同的解释。

TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)。

RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态。

高13位(8K个索引):用来确定当前使用的段描述符在描述符表中的位置。

计算机维护一系列表,称为描述符表。描述符表分为三种:全局描述符表(GDT)、局部描述符表(LDT)、中断描述符表(IDT)。描述符表的每个表项,大小为8个字节,称为段描述符。

于是整个过程如下图所示:

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

通过段式管理,我们得到了线性地址(虚拟地址VA)。下虚拟地址被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量),由于虚拟内存与物理内存的页大小相同,因此VPO与PPO(物理页偏移量)一致。而PPN(物理页号)则需通过访问页表中的页表条目(PTE)获取。

若PTE的有效位为1,页命中,则获取到PPN,与PPO组成物理地址。

若PTE的有效位为0,说明对应虚拟页没有缓存到物理内存中,引发一个缺页故障,调用操作系统的内核的缺页处理程序,确定牺牲页,并调入新的页面。再返回到原来的进程,再次调用导致缺页的指令。这次页命中,则获取到PPN,与PPO组成物理地址。

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

页表位于内存中,CPU中生成虚拟地址,根据虚拟内存中的VPN访问页表,为了提升访问速度,在这两者之间设置一个缓存,即为TLB。CPU进行地址翻译时,首先把VPN解释为TLBT(TLB标记)和TLBI(TLB索引)。

由TLBI,访问TLB中的某一组。遍历该组中的所有行,若找到一行的tag等于TLBT,且有效位valid为1,,则缓存命中,该行存储的即为PPN;若未找到一行的tag等于TLBT,或找到但该行的valid为0,则缓存不命中。进而需要到页表中找到被请求的块,用以替换原TLB表项中的数据。

下面考虑在TLB中缓存不命中的情况,用以说明对四级页表的访问:

缓存不命中后,VPN被解释成从低位到高位的4段,从高地址开始,第一段VPN作为第一级页表的索引,用以确定第二级页表的基址;第二段VPN作为第二级页表的索引,用以确定第三级页表的基址;第三段VPN作为第三级页表的索引,用以确定第四级页表的基址;第四段VPN作为第四级页表的索引,若该位置的有效位为1,则第四级页表中的该表项存储的是所需要的PPN的值。值得注意的是,在上述过程中,只要有一级页表条目的有效位为0,下一级页表就不存在,也就是产生缺页故障了。缺页故障的处理与上节所述相同。

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

物理地址得到之后,CPU尝试访问物理内存,此时,物理地址被解释为三部分,分别是CT(缓存标记),CI(组索引),CO(块偏移)。

首先根据组索引,找到缓存中对应的组,遍历该组中的所有行,若找到一行的tag等于CT,且标志位valid为1,则缓存命中(hit),根据CO(块偏移)读取块中对应的字;若未找到tag为CT的行,或该行的valid值为0,则缓存不命中。

若缓存不命中,则向下一级缓存中查找数据(L2 Cache->L3 Cache->主存)。找到数据之后,开始进行行替换。若该组中有一个空行,那就将数据缓存至这个空行,并置好tag和valid位;若该组中没有空行,选择一个牺牲行进行替换。

7.6 hello进程fork时的内存映射

当fork 函数被shell 进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的

mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

7.7 hello进程execve时的内存映射

execve函数调用驻留在内核区域的加载器加载程序代码,在当前进程中加载并运行可执行对象文件hello中包含的程序,并用hello程序有效地替换当前程序。加载和运行Hello需要以下步骤:

1)删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。

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

3)映射共享区域,hello 程序与共享对象libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。

4)设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。

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

当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生缺页故障。一个页面就是虚拟内存的一个连续的块(典型的是4KB)。缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中了,指令就可以没有故障地运行完成了。

7.9动态存储分配管理

printf 函数会调用 malloc,下面简述动态内存管理的基本方法与策略:

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。分配器分为两种基本风格:显式分配器:要求应用显式地释放任何已分配的块。隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。

显式分配器的设计与实现:

1)带边界标签的隐式空闲链表

堆及堆中内存块的组织结构:

在内存块中增加 4B 的头部和 4B 的脚部,其中头部用于寻找下一个块,脚部用于寻找上一个块。脚部的设计是专门为了合并空闲块方便的。因为头部和脚部大小已知,所以我们利用头部和脚部中存放的块大小就可以寻找上下内存块,其中头部和脚部中的块大小间接起到了前驱、后继指针的作用,故为隐式链表。

因为有了脚部,所以我们可以方便的对前面的空闲块进行合并。合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变头部和脚部中的值就可以完成这一操作。

2)显式空闲链表

空闲块被组织成链表形式的数据结构。堆可以组织成一个双向空闲链表,

在每个空闲块中,都包含一个 pred(前驱)和 succ(后继)指针,如下图:

   

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。维护链表的顺序有:后进先出(LIFO),将新释放的块放置在链表的开始处,使用 LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO 排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。

3)分离的空闲链表(以分离适配为例)

使用这种方法,分配器维护着一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员。有许多种不同的分离适配分配器这里我们描述了一种简单的版本。

为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果找到了一个,那么就分割它,并将剩余的部分插人到适当的空闲链表中。如果找不到合适的块那么就搜索下个更大的大小类的空闲链表。如此重复直到找到一个合适的块。如果空闲链表中没有合适的块那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。分离适配方法是一种常见的选择,C标准库中提供的GNU malloc包就是采用的这种方法,因为这种方法既快速,对内存的使用也很有效率。搜索时间减少了,因为搜索被限制在堆的某个部分而不是整个堆。内存利用率得到了改善,因为对分离空闭链表的简单的首次适配搜索,其内存利用率近似于对整个堆的最佳适配搜索的内存利用率。

7.10本章小结

本章通过对 hello 在储存结构,高速缓存,虚拟内存涉及到的方面进行了详细的探索,理解了系统是如何将数据在存储器层次结构中上上下下移动的,那么就可以编写自己的应用程序,使得它们的数据项存储在层次结构中较高的地方,在那里 CPU 能更快地访问到它们,复习了与内存管理相关的重要的概念和方法。加深了对动态内存分配的认识和了解。

8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:所有的IO设备都被模型化为文件

设备管理:所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备映射为文件的方式,允许Linux内核引出一个简单,低级的应用接口,称为Unix I/O

8.2 简述Unix IO接口及其函数

接口:

  1. 打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息
  2. Linux shell创建的每个进程开始时都有三个打开的文件:标准输入,标准输出,标准错误
  3. 改变当前文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k
  4. 读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时执行读操作时触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k
  5. 关闭文件:当应用完成了访问,它就通知内核关闭这个文件,并释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去

函数:

  1. int open(char* filename,int flags,mode_t mode) :进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位
  2. int close(fd),fd是需要关闭文件的描述符
  3. ssize_t read(int fd,void *buf,size_t n),该函数从描述符为fd的当前位置最多赋值n个字节到内存buf的位置,返回值为实际传送的字节数量
  4. ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置

8.3 printf的实现分析

首先将缓冲区、格式串及参数地址传递给vsprintf,然后vsprintf解析格式串,根据格式串中的格式符号解析参数,并将解析后的结果写入缓冲区,返回新串的长度。

然后,调用系统函数write(buf,i)将长度为 i 的 buf 输出。查看 write 函数如图。在 write 函数中,将栈中参数放入寄存器,ecx 是字符个数,ebx 存放第一个字符地址,int INT_VECTOR_SYS_CALL代表通过系统调用 syscall。

      

查看 syscall 的实现如图。syscall 将字符串中的字节从寄存器中通过总线复制到显卡的显存。

显存中存储的是字符的 ASCII 码。字符显示驱动子程序将通过 ASCII 码在字模库中找到点阵信息将点阵信息存储到 vram 中。显示芯片会按照一定的刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点(RGB 分量)。

8.4 getchar的实现分析

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

getchar等调用read系统函数,通过系统调用 read 读取存储在键盘缓冲区中的 ASCII 码直到读到回车符然后返回整个字串,直到接受到回车键才返回。

8.5本章小结

了解了printf的函数和getchar函数的底层实现,主要介绍了linux的IO设备管理方法和及其接口和函数。

结论

Hello的一生经历了如下的过程:

(1)编写,通过 editor 将代码键入 hello.c

(2)预处理,经过预处理器 cpp 的预处理,处理以#开头的行,得到 hello.i

(3)编译,编译器 ccl 将得到的 hello.i 编译成汇编文件 hello.s

(4)汇编,汇编器 as 又将 hello.s 翻译成机器语言指令得到可重定位目标文件 hello.o

(5)链接,链接器 ld 将 hello.o 与动态链接库链接生成可执行目标文件 hello,至此,hello 成为了一个可以运行的程序。

(6)运行,在 shell 中输入./hello,

(7)创建子进程,shell 进程调用 fork 为其创建子进程

(8)加载,shell 调用 execve,execve 调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main 函数。

(9)执行,CPU 为其分配时间片,在一个时间片中,hello 享有 CPU 资源,

顺序执行自己的控制逻辑流

(10)访存,当 CPU 访问 hello 时,请求一个虚拟地址,MMU 把虚拟地址转换成物理地址并通过三级 cache 访存。

(11)动态申请内存,printf 会调用 malloc 向动态内存分配器申请堆中的内存。

(12)信号,hello 运行过程中可能遇到各种信号,shell 为其提供了各种信号

处理程序。

(13)结束,shell 父进程回收子进程,内核删除为这个进程创建的所有数据

结构,hello 结束了它的一生。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值