csapp大作业-Hello程序的一生

计算机系统

大作业

题     目  程序人生-Hellos P2P 

专       业        计算机                

学   号                     

班   级                     

学       生          王骞        

指 导 教 师          吴锐           

计算机科学与技术学院

2021年5月

摘  要

本文通过对一个简简单单的hello程序进行分析,围绕着其全生命流程,展开了分析,从预处理,到编译,汇编,链接成.o文件,再到被加载入内存,成为进程,从进程管理,存储管理,IO管理的角度,对这个程序进行了进一步的探讨。

通过对计算机系统的漫游,从最外面的文本文件,一步一步,到了最底层的硬件实现,和操作系统的配合,使得对计算机系统的理解,更加深入

关键词: Hello程序;预处理;编译;汇编;链接;进程;存储;虚拟内存;I/O ;                       

目  录

第1章 概述- 4 -

1.1 Hello简介 - 4 -

1.2 环境与工具 - 4 -

1.3 中间结果 - 4 -

1.4 本章小结 - 5 -

第2章 预处理- 6 -

2.1 预处理的概念与作用 - 6 -

2.2在Ubuntu下预处理的命令 - 6 -

2.3 Hello的预处理结果解析 - 7 -

2.4 本章小结 - 8 -

第3章 编译- 9 -

3.1 编译的概念与作用 - 9 -

3.2 在Ubuntu下编译的命令 - 9 -

3.3 Hello的编译结果解析 - 9 -

3.4 本章小结 - 13 -

第4章 汇编- 14 -

4.1 汇编的概念与作用 - 14 -

4.2 在Ubuntu下汇编的命令 - 14 -

4.3 可重定位目标elf格式 - 14 -

4.4 Hello.o的结果解析 - 17 -

4.5 本章小结 - 19 -

第5章 链接- 20 -

5.1 链接的概念与作用 - 20 -

5.2 在Ubuntu下链接的命令 - 20 -

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

5.4 hello的虚拟地址空间 - 24 -

5.5 链接的重定位过程分析 - 27 -

5.6 hello的执行流程 - 29 -

5.7 Hello的动态链接分析 - 29 -

5.8 本章小结 - 30 -

第6章 hello进程管理- 31 -

6.1 进程的概念与作用 - 31 -

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

6.3 Hello的fork进程创建过程 - 32 -

6.4 Hello的execve过程 - 32 -

6.5 Hello的进程执行 - 33 -

6.6 hello的异常与信号处理 - 33 -

6.7本章小结 - 37 -

第7章 hello的存储管理- 38 -

7.1 hello的存储器地址空间 - 38 -

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

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

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

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

7.6 hello进程fork时的内存映射 - 41 -

7.7 hello进程execve时的内存映射 - 41 -

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

7.9动态存储分配管理 - 42 -

7.10本章小结 - 42 -

第8章 hello的IO管理- 44 -

8.1 Linux的IO设备管理方法 - 44 -

8.2 简述Unix IO接口及其函数 - 44 -

8.3 printf的实现分析 - 45 -

8.4 getchar的实现分析 - 47 -

8.5本章小结 - 47 -

结论- 47 -

附件- 49 -

参考文献- 50 -


第1章概述

1.1Hello简介

P2P,指From Program to Process

一个程序员编写好.c文件的过程就是Program,编译器驱动程序代表用户在需要时调用语言预处理器cpp、编译器ccl、汇编器as和链接器ld,预处理器将原始代码处理生成.i文件,编译器将其编译得到.s文件,汇编器将.s文件进一步处理,翻译成机器语言,将指令打包为可重定位的.o文件,最后,连接器将它与库函数进行连接,得到可执行文件hello

在终端中./hello后,操作系统fork一个子进程hello来执行这个程序,execve加载这个程序。

O2O:From Zreo-O to Zero-O。

shell执行可执行目标文件,管理hello进程,对其进行存储管理,分配映射虚拟内存、分配物理内存,输出结果到显示器,最后结束hello进程,回收其内存空间。

1.2环境与工具

硬件环境:X64 CPU;2GHz;4GRAM.

软件环境:Windows10 64位;Ubuntu 20.04.

使用工具:Codeblocks; Objdump;Hexedit;Visual Studio 2022;

1.3 中间结果

 

1.4 本章小结

本章简单的介绍了Hello程序的“一生”,即Hello的P2P和O2O的过程。以一个最基础的程序为示例,代表性地揭示了计算机运行一个程序的整个过程。


第2章预处理

2.1 预处理的概念与作用

概念:

程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,预处理阶段是指预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,得到的结果再由编译器核心进一步编译。

作用:

1、将#include后面的头文件内容直接插入程序文本;

2、将#define定义的宏用实际值替换;

3、预编译程序将根据有关的文件,将那些不必要的代码(如条件编译指令,如#ifdef,#ifndef,#else,#elif,#endif等)过滤掉;

4、识别与替换特殊符号。例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。

预处理所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。这个文件的含义同没有经过预处理的源文件是相同的,但内容有所不同。简单来说,预处理是一个文本插入与替换的过程,生成的hello.i文件仍然是文本文件。

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

经过预处理之后,hello.c转化为hello.i文件,打开该文件可以发现文件仍为可以阅读的C语言程序文本文件。

可以看到,hello.i的行数相比hello.c大大增加,达到了3060行。而且main函数后面的内容没有改变,只是头文件被插入,前面增加的行是stdio.h,unistd.h,stdlib.h的依次展开。

以stdio.h为例。由于stdio.h是用<>括起来的,所以在系统指定目录下寻找(usr/include),而双引号则是在当前文件夹查找,找不到再系统指定目录下查找。预处理包括:1.将所有的#define删除,并展开所有的宏定义;2.处理所有的预编译指令,例如:#if,#elif,#else,#endif;3.处理#include预编译指令,将被包含的文件插入到预编译指令的位置;4.添加行号信息文件名信息,便于调试;5.删除所有的注释:// /**/;6.保留所有的#pragma编译指令,因为在编写程序的时候,我们经常要用到#pragma指令来设定编译器的状态或者是指示编译器完成一些特定的动作。7.生成.i文件。

2.4 本章小结

本章介绍了预处理的概念和作用,将hello.c预处理为hello.i,并对预处理结果进行了分析。


第3章编译

3.1 编译的概念与作用

概念:

编译器将预处理文本文件hello.i翻译成汇编语言文本文件hello.s,该文件用汇编语言描述了程序的内容

作用:

经过词法分析,语法分析,语义分析,和一定的优化来生成可以实现对应程序功能的汇编代码文件,与高级语言相比,汇编语言更接近计算机能够理解的语言,进一步有利于二进制机器语言的生成。

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.3.1数据处理

(1)常量处理

.LC0和.LC1分别对应了hello.c中的两个printf'函数中的字符串常量

.string声明了字符串型常量

第三行的.rodata表示只读数据段

  1. 局部变量

Hello.c中只有一个局部变量 int i;以及for循环中初始化i=0;

可以看到30-31行对应了对i的处理

  1. 函数参数

如图21-23行 函数参数被保存在栈空间上,通过栈指针加偏移量进行访问

3.3.2数据操作

(1)数据赋值

for循环中i=0对i进行赋值,由于i为int型,占四个字节,所以用movl进行传送

  1. 数据加操作

51行对应了hello.c中的i++操作

另外还有一些寻址时用到的加操作,如下

%rax寄存器中存栈指针,再利用与立即数的addq操作进行相对寻址,把参数放到%rax寄存器中,然后进行函数的调用

  1. 数据比较操作

判断argc是否等于4,判断输入参数的数量是否正确

这里是cmpl是判断for循环中i<8

3.3.3函数操作

(1)调用函数

call调用puts,exit,printf,atoi,sleep,getchar函数

(2)数据准备

第一个printf通过编译转换成了puts函数,第26行是调用puts前进行的数据准备,.LC0与第一个字符串有关

第28行把1赋给了%edi,随后调用exit函数,进行了exit(1)的操作

调用第二个printf前,利用rax相对寻址,把参数放到了rdx和rsi中,把.LC1对应的字符串放到了rdi中,随后进行了printf函数调用。

后面先调用了atoi求出赋给sleep的参数,然后调用了sleep

(3)函数返回

在这个程序中,只能看到main函数的返回,ret

(4)控制转移

cmpl和jle的组合实现了控制转移,这里是for循环中i<8的控制转移,i大于等于8就跳出了for循环。

另一个控制转移的例子,参数数量argc等于4,则会跳转到L2执行。

3.4 本章小结

这一章通过对编译后的hello.s代码进行分析,看到了c语言程序经过预处理后变为更低级的汇编语言的一些转换。看到了对应的c语言转换为相应的汇编语言以及编译中做的编译器做的一些处理(例如第一个printf变为puts)。对比c语言与汇编代码,让我们更清晰地看到编译器做了些什么。


第4章汇编

4.1 汇编的概念与作用

概念:汇编是将汇编代码转化成机器可以执行的命令,每一条汇编语句都对应一条机器指令,并生成可重定位目标程序的.o文件,该文件为二进制文件,字节编码时机器指令。在这里,汇编器(as)将hello.s翻译成机器语言指令,并将指令打包成可重定位目标程序hello.o。

作用:编译器根据根据汇编指令表和机器指令表一一进行翻译,把编译阶段生成的汇编文件转化成机器代码,生成目标文件(.o).

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

    

4.3.1 ELF头

ELF头以一个16字节的序列开始(magic那一行),这个序列描述了生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器语法分析和解释目标文件的信息。包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。

4.3.2 节头部表

不同节的位置和大小是由节头部表描述的,其中目标文件每个节都有一个固定大小的条目

 

4.3.3重定位节

一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。

在这里,8 条重定位信息分别是对.L0(第一个 printf 中的字符串)、puts 函数、exit 函数、.L1(第二个 printf 中的字符串)、printf 函数、atoi函数、sleep 函数、getchar 函数进行重定位声明。

76行开始同.rel.text一样属于重定位信息的section,只不过它包含的是eh_frame的重定位信息,而eh_frame生成描述如何unwind 堆栈的表。

 

4.3.4 符号表

.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。

 

4.4 Hello.o的结果解析

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

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

 

  1. hello.s使用10进制,Dhello.s使用16进制

 

 

                                                                      Hello.s图

 

 

Dhello.s图

  1. 分支跳转形式不同

 

hello.s中跳转的为段名称

 

Dhello.s中跳转为相对寻址

  1. 函数调用

 

hello.s中函数调用使用函数名称

 

Dhello.s中使用main函数相对寻址,并且在下一行还有重定位信息

(4)字符串访问

 

hello.s中用段名称+%rip访问

 

Dhello.s中使用0+%rip访问,但在下一行有重定位的信息

4.5 本章小结

汇编器接受汇编代码,产生可重定位目标文件。它可以和其他可重定位目标文件合并而产生一个可以直接加载被运行的可执行目标文件。正因为它并不包含最终程序的完整信息,它的符号尚未被确定运行时位置,并用0占位。在第五部分中将说明如何将多个可重定位目标文件合并,并确定最终符号的最终运行位置。

汇编的过程相当于重定位,将汇编语言和机器语言以一一映射的关系对应起来翻译。使得程序进一步转化为可识别与可执行。


5链接

5.1 链接的概念与作用

概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。

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

5.2 在Ubuntu下链接的命令

 

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

    

 

各段信息如下:

1.ELF头:

与前一部分ELF头基本一致,但类型发生改变,程序头大小和节头数量增加,并且获得了入口地址

 

  1. 节部头表

与hello.elf相比,其在链接之后的内容更加丰富详细。记录了其各段的基本信息,包括各段的起始地址,大小等信息

 

 

 

  1. 程序头

描述了系统准备程序执行所需的段或其他信息。

 

5.4 hello的虚拟地址空间

使用edb加载hello

   

 

data dump由401000开始,对照helloout.elf中的节头表

 

发现401000对应的是.init段,main函数由init段里的启动代码调用,因此对照关系正确。

 

查看400000起始部分 可以看到与ELF头对应

 

.rodata段中的两个字符串内容也是对应的

 

 

edb中symbolsviewer与helloout.elf一一对应

5.5 链接的重定位过程分析

objdump -d -r hello 分析hello与hello.o的不同


1.链接之后函数的数量增加

 

 

 

链接后的反汇编文件Dhelloout.s中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。

  1. 增加了一些节,如.init,.plt
  2. 函数调用:hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
  3. 跳转指令参数发生变化。与函数调用类似,跳转地址变味了虚拟地址。在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。

以下面的条目分析hello重定位

 

首先,由重定位条目可以得知以下信息:

r.offset=0x21

r.symbol=puts

r.type=R_X86_64_PLT32

r.addend=-0x4

CSAPP上讲的是R_X86_64_PC32,说的是利用与PC的相对地址计算重定位,这里利用的是R_X86_64_PLT32,其实计算过程是一样的。

ADDR(s)=0x4011d6

refaddr=ADDR(s)+r.offset=0x4011f7

*refaddr=(unsigned)(ADDR(r.symbol)+r.addend-refaddr)=(unsigned)(-0x16b)

       =0xfffffe95

在运行时,call指令调用PC值为0x4011fb,PC+0xfffffe95截断后得到0x401090,可以发现,正是hello运行时call的地址。

所以,一般重定位分为两步:

1、重定位节和符号定义

2、重定位节的符号引用,依靠重定位条目选择合适方式计算(R_X86_64_PC32或R_X86_64_32)

5.6 hello的执行流程

 

 

5.7 Hello的动态链接分析

   在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件hello。此时共享库中的代码和数据没有被合并到hello中。加载hello时,动态链接器对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。

动态链接采用了延迟加载的策略,即在调用函数时才进行符号的映射。使用偏移量表GOT+过程链接表PLT实现函数的动态链接。GOT中存放函数目标地址,为每个全局函数创建一个副本函数,并将对函数的调用转换成对副本函数调用。

 

dl_init前


dl_init后

 

5.8 本章小结

本章概括了链接的概念与作用,并且详细分析了hello.o是怎么链接成为一个可执行目标文件的过程。介绍了hello.o的ELF格式和各个节的含义,并且分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。通过可执行文件的程序头来分析重定位的过程,并解析了一个程序运行的全过程。最后简单介绍了动态链接这一现代计算机中极为重要的部分是怎么运作的。


6hello进程管理

6.1 进程的概念与作用

概念:

进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。

作用:

进程作为一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令。

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

作用:

Shell是信号处理的代表,负责各进程创建与程序加载运行及前后台控制,作业调用,信号发送与管理等。

Shell执行一系列的读/求值步骤然后终止。该步骤读取来自用户的一个命令行。求值步骤读取来自用户的一个命令行,求值步骤解析该命令行,并代表用户执行程序。在解析命令后,如果是内置命令,则解析指令并执行,否则就根据相关文件路径执行可执行目标文件。

处理流程:

命令行是一串 ASCII 字符由空格分隔。字符串的第一个单词是一个可执行程序,或者是 shell 的内置命令。命令行的其余部分是命令的参数。如果第一个单词是内置命令,shell 会立即在当前进程中执行。否则,shell 会新建一个子进程,然后再子进程中执行程序。新建的子进程又叫做作业。通常,作业可以由 Unix 管道连接的多个子进程组成。如果命令行以 &符号结尾,那么作业将在后台运行,这意味着在打印提示符并等待下一个命令之前,shell 不会等待作业终止。否则,作业在前台运行,这意味着 shell 在作业终止前不会执行下一条命令行。 因此,在任何时候,最多可以在一个作业中运行在前台。 但是,任意数量的作业可以在后台运行。

Unix shell 支持作业控制的概念,允许用户在前台和后台之间来回移动作业,并更改进程的状态(运行,停止或终止)。在作业运行时,键入 ctrl-c会将 SIGINT 信号传递到前台作业中的每个进程。SIGINT 的默认动作是终止进程。类似地,键入 ctrl-z 会导致 SIGTSTP 信号传递给所有前台进程。SIGTSTP 的默认操作是停止进程,直到它被 SIGCONT 信号唤醒为止。Unixshell 还提供支持作业控制的各种内置命令。

6.3 Hello的fork进程创建过程

 

如图,输入以下指令,由于hello不是内部命令,这时候shell会后fork一个子进程,父进程会给子进程复制一份相同的虚拟地址空间副本,子进程可以读取父进程中打开的任何文件。

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序(hello),原型为:

int execve(const char *filename, const char *argv[], const char *envp[])

execve加载并运行可执行文件filename(hello),且带参数列表argv和环境变量列表envp。与fork不同,execve只有在找不到hello时才会返回。

在execve加载了hello之后,它利用启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下的原型:

int main(int argc , char *argv[] , char *envp[]);

execve函数的执行过程会覆盖当前进程的地址空间(映射私有区与共享区),但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。并且将PC指向代码区入口。

6.5 Hello的进程执行

上下文的概念: 上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

上下文切换: 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。

开始Hello运行在用户模式,收到信号后进入内核模式,运行信号处理程序,之后再返回用户模式。运行过程中,cpu不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用cpu,实现进程的调度。

6.6 hello的异常与信号处理

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

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

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

  1. 正常情况  打印8次信息 输入任意字符结束

 

  1. 运行时按回车

 

异步中断,采用处理程序解决后继续执行

  1. 乱按

 

乱按的字符会被输入到屏幕上,如果输入回车,则会解析为命令,但都是无效命令

  1. ctrl z

 

输入ctrl-z 进程停止

5.jobs ps

 

输入jobs与ps确认hello进程挂起

6.fg

 

输入fg恢复hello,完成剩余的输出

  1. ctrl-c

 

可以看出ctrl c让hello进程彻底结束

  1. kill

 

用kill杀死hello进程

9.pstree

 

以树形式查看父子进程关系

6.7本章小结

本章介绍了计算机中最重要的概念之一——进程。可以说现代计算机离不开进程的概念,只有充分了解进程的创建与异常流控制,才可能成为一个优秀的程序员。本章介绍了Shell的一般处理流程,调用 fork 创建新进程,调用 execve 执行 hello,hello的进程执行,hello 的异常与信号处理。


7hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:逻辑地址是指由程序产生的与段相关的偏移地址部分。例如,在进行C语言指针编程中,可以使用&操作读取指针变量的值,这个值就是逻辑地址,是相对于当前进程数据段的地址。一个逻辑地址由两部份组成:段标识符和段内偏移量。

线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址生成了一个线性地址。如果启用了页式管理,那么线性地址可以再变换产生物理地址。若没有启用页式管理,那么线性地址直接就是物理地址。

虚拟地址:因为虚拟内存空间的概念与逻辑地址类似,因此虚拟地址和逻辑地址实际上是一样的,都与实际物理内存容量无关。

物理地址:存储器中的每一个字节单元都给以一个唯一的存储器地址,用来正确地存放或取得信息,这个存储器地址称为物理地址,又叫实际地址或绝对地址。

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

8086处理器的寄存器是16位的,后来引入了段寄存器,可以访问更多的地址空间但不改变寄存器和指令的位宽。8086共设计了20位宽的地址总线,逻辑地址为段寄存器左移4位加上偏移地址得到20位地址。

内存被分为了不同的段,段寄存器有一个栈、一个代码寄存器和两个数据寄存器,在实模式下,逻辑地址、线性地址、实际的物理地址是完全一样的,保护模式下线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到

所有的段由段描述符描述,而多个段描述符能组成一个数组,我们称成功数组为段描述表。段描述符中的BASE字段对我们翻译线性地址至关重要的。

BASE字段表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。

为了得到BASE字段,我们利用索引号从GDT(全局段描述表)或LDT(局部段描述符表)中得到段描述符。选择GDT还是LDT取决于段选择符中的T1,若T1等于0则选择GDT,反之选择LDT。

这样我们就得到了BASE。最后通过BASE加上段偏移量就得到了线性地址

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

通过分页机制实现线性地址(书里的虚拟地址VA)到物理地址(PA)之间的转换。分页机制是指对虚拟地址内存空间进行分页。首先Linux系统有自己的虚拟内存系统,Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录,当hello运行时内核为hello进程维护一个段的任务结构(task_struct)。

虚拟页是指系统将每个段分为大小固定的块,linux下一页为4KB

物理页和虚拟页一样,他是物理内存的分割,MMU使用页表来实现二者之间的映射

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

 

首先是TLB,TLB也就是翻译后备缓冲器是一个包含在MMU中的小缓存,其每一行都由一个PTE组成。TLB将一个n-p位VPN分为t位的组索引和n-t-p位的标记。

在访问时与cache几乎一致,先通过组索引找到所在组,在通过标记位判断是否是我们要访问的虚拟地址,如果命中则从中读取物理页号,并通与VPO组合成物理地址访问数据并将数据返回给CPU。如果不命中则必须从下一级TLB或者内存中寻找。

查询过程大概如下:

CPU 产生虚拟地址 VA,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支持下的物理内存访问

 

现代计算机高速缓存Intel core i7层次结构:每个CPU芯片有四个核,每个核有自己私有的L1 i-cache、L1 d-cache和L2统一的高速缓存。所有的核共享片上的L3统一的高速缓存。

通过内存地址的组索引获得值,如果对应的值是data则像L1 d-cache对应组中查找,如果是指令,则向L1 i-cache对应组中查找。将L1对应组中的每一行的标记位进行对比,如果相同并且有效位为1则命中,获得偏移量,取出相应字节,否则不命中,向下一级cache寻找,直到向内存中寻找。

7.6 hello进程fork时的内存映射

当fork函数被shell调用时,内核为新进程(hello的前身)创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。正如第六章提到的,只有当这两个进程中的任一个后来进行写操作时,写时复制机制才就会创建新页面,才会在物理内存上有实际区别。因此,也就为每个进程保持了私有空间地址的抽象概念。

7.7 hello进程execve时的内存映射

加载并运行hello需要以下结构步骤:

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

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

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

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

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

页面命中完全由硬件处理,处理缺页则需要硬件和操作系统内核协作完成:

1. 处理器生成一个虚拟地址,并将它传送给MMU

2. MMU生成PTE地址,并从高速缓存/主存请求得到它

3. 高速缓存/主存向MMU返回PTE

4. PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。

5. 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。

6. 缺页处理程序页面调入新的页面,并更新内存中的PTE

7. 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但不失通用性,假设堆是一个请求二进制零的区域,紧接在未初始化数据区域后开始,向上生长。对每个进程,内核维护一个全局变量brk指向堆顶。分配器将堆视为一组不同大小的块的集合来维护。

分配器的具体操作过程以及相应策略:

放置已分配块:当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。执行这种搜索的常见策略包括首次适配、下一次适配和最佳适配等。

分割空闲块:一旦分配器找到了匹配的空闲块,需要决定分配这个空闲块中多少空间。可以选择用整个块,但会造成额外的内部碎片;也可以选择将空闲块分割为两部分,第一部分变成已分配块,剩下的变成新的空闲块。

获取额外的堆内存:如果分配器不能为请求块找到空闲块,分配器通过调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插到空闲链表中,然后被请求的块放在这个新的空闲块中。

合并空闲块:分配器释放一个已分配块时,要合并相邻的空闲块。分配器决定何时执行合并,可以选择立即合并或者推迟合并。合并时需要合并当前块和前面以及后面的空闲块。

7.10本章小结

本章介绍了程序是如何组织储存器的。先从程序所使用的不同地址开始,分别介绍了逻辑地址、虚拟地址(线性地址)以及物理地址。并介绍了计算机是怎么一步步将地址从逻辑地址变化到虚拟地址再从虚拟地址变化到物理地址的。其中着重介绍了虚拟地址和物理地址之间的映射,以及进程是怎么映射到虚拟地址空间的。之后还介绍程序是怎么利用Cache来获取物理地址中所存放的数据的。最后简单介绍了虚拟地址中极为重要的概念——缺页异常,以及简单介绍了动态内存分配机制。


8hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前的文件位置、读写文件、关闭文件、每个进程开始时都有三个打开的文件:stdin,stdout,stderr。

8.2 简述Unix IO接口及其函数

Unix IO 接口,使得所有的输入和输出都能以一种统一且一致的方式来执行,它有以下几个功能:

打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备

Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。

改变当前文件的位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。

读写文件。一个读操作就是从文件复制n > 0个字符到内存,从当前文件位置k开始,然后k += n。对给定一个大小为m字节的文件,当k>=m时执行读操作会出发一个称为EOF的条件。

关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。

Unix I/O 函数:

进程通过调用open函数打开一个存在的文件或者创建一个新文件。

int open(char* filename,int flags,mode_t mode);

open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件;mode参数指定了新文件的访问权限位。

int close(fd),fd是需要关闭的文件的描述符(C中表现为指针),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的实现分析

先看printf函数的是实现


其中va_list是char *类型,c语言参数压栈顺序是从右到左。

 

fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。
      fmt也是个变量,它的位置,是在栈上分配的,它也有地址。
    对于一个char *类型的变量,它入栈的是指针,而不是这个char *型变量。

所以char型数据占1字节,32位机器指针占4字节,因此是+4找到第一个参数的位置。

 

接下来看vsprintf,Fmt是format的缩写,当没有碰到%时,就将fmt的字符复制到buf中;一旦碰到,则将args参数指向的内存空间的值赋值给buf,就是我们认知中将%x,%s换成对应数值或串。然后返回的值就是写指针相对初始的偏移,故返回的是写的长度。

在 printf 中调用系统函数 write(buf,i)将长度为i的buf输出。write 函数如下:

write:

mov eax, _NR_write

mov ebx, [esp + 4]

mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

在 write 函数中,将栈中参数放入寄存器,ecx 是字符个数,ebx 存放第一个字符地址,int INT_VECTOR_SYS_CALLA 代表通过系统调用 sys_call,查看 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

8.4 getchar的实现分析

getchar()函数实际上是int getchar(void),所以它返回的是ASCII码,所以只要是ASCII码表里有的字符它都能读取出来。在调用getchar()函数时,编译器会依次读取用户键入缓存区的一个字符(注意这里只读取一个字符,如果缓存区有多个

字符,那么将会读取上一次被读取字符的下一个字符),如果缓存区没有用户键入

的字符,那么编译器会等待用户键入并回车后再执行下一步 (注意键入后的回车键也算一个字符,输出时直接换行)。

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

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

8.5本章小结

本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数。。在我看来Unix I/O是一个非常有趣且成功的抽象,因为它把一切输入输出都归结为对文件的操作,而且这一抽象是十分成功的,因为I/O的过程本质上就是一个对信息交换的过程,因此把所有与程序进行信息交换的主体,比如网络设备当作文件是完全没问题的。这种抽象不仅可以简化计算机的设计,还能更好的帮助我们理解学习系统级I/O。

结论

hello所经历的过程:

源程序:在文本编辑器或IDE中编写C语言代码,得到最初的hello.c源程序。

预处理:预处理器解析宏定义、文件包含、条件编译等,生成ASCII码的中间文件hello.i。

编译:编译器将C语言代码翻译成汇编指令,生成一个ASCII汇编语言文件hello.s。

汇编:汇编器将汇编指令翻译成机器语言,并生成重定位信息,生成可重定位目标文件hello.o。

链接:链接器进行符号解析、重定位、动态链接等创建一个可执行目标文件

hello。此时,hello才真正地可以被执行。

fork创建进程:在shell中运行hello程序时,shell会调用fork函数创建子进程,供之后hello程序的运行。

execve加载程序:子进程中调用execve函数,加载hello程序,进入hello的程序入口点,hello终于要开始运行了。

运行阶段:内核负责调度进程,并对可能产生的异常及信号进行处理。MMU、TLB、多级页表、cache、DRAM内存、动态内存分配器相互协作,共同完成内存的管理。Unix I/O使得程序与文件进行交互。

终止:hello进程运行结束,shell负责回收终止的hello进程,内核删除为hello进程创建的所有数据结构。hello的一生到此结束,没有留下一丝痕迹。

对计算机系统的设计与实现的深切感悟:

hello从诞生到结束,经历了千辛万苦,在硬件、操作系统、软件的相互协作配合下,终于完美地完成了它的使命。这让我认识到,一个复杂的系统需要多方面的协作配合才能更好地实现功能。同时,计算机系统提供的一系列抽象使得实际应用与具体实现相互分离,可以很好地隐藏实现的复杂性,降低了程序员的负担,使得程序更加容易地编写、分析、运行。这让我认识到抽象是十分重要的,是计算机科学中最为重要的概念之一。


附件

列出所有的中间产物的文件名,并予以说明起作用。

 


参考文献

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

[1]  RANDALE.BRYANT, DAVIDR.O‘HALLARON. 深入理解计算机系统[M]. 机械工业出版社, 2011.

[2]《gcc--编译的四大过程及作用》https://blog.csdn.net/shiyongraow/article/details/81454995

[3]  《深入理解ELF文件》https://blog.51cto.com/u_12444109/3026869

[4]  《段页式访存——逻辑地址到线性地址的转换》https://www.jianshu.com/p/fd2611cc808e

[5]《C语言lseek()函数:移动文件的读写位置》http://c.biancheng.net/cpp/html/236.html

[6]  《段页式访存——线性地址到物理地址的转换》https://www.cnblogs.com/pipci/p/12404023.html

[7] 《深入了解GOT,PLT和动态链接》https://www.cnblogs.com/pannengzhi/p/2018-04-09-about-got-plt.html

[8] 《printf 函数实现的深入剖析》https://www.cnblogs.com/pianist/p/3315801.html

[9]  深入解析Linux内核I/O剖析(open,write实现) - 笨拙的菜鸟 - 博客园


版权声明:本文为m0_54114436原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。