HIT CSAPP 2018 计算机系统大作业

 

 

摘  要

 

本文通过合理运用这个学期在计算机系统课程上学习的知识,分析研究hello程序在Linux下从代码到程序,从出生到终止的过程,通过熟练使用各种工具,学习Linux框架下整个程序的声明周期,加深对课本知识的印象。

关键字:O2O,P2P,预处理,编译,链接…

 

 

 

 

 

 

目  录

 

第1章 概述......................................... - 4 -

1.1 Hello简介.................................. - 4 -

1.2 环境与工具................................. - 4 -

1.3 中间结果..................................... - 4 -

1.4 本章小结..................................... - 4 -

第2章 预处理..................................... - 5 -

2.1 预处理的概念与作用................. - 5 -

2.2在Ubuntu下预处理的命令...... - 5 -

2.3 Hello的预处理结果解析.......... - 5 -

2.4 本章小结..................................... - 5 -

第3章 编译......................................... - 6 -

3.1 编译的概念与作用..................... - 6 -

3.2 在Ubuntu下编译的命令.......... - 6 -

3.3 Hello的编译结果解析.............. - 6 -

3.4 本章小结..................................... - 6 -

第4章 汇编......................................... - 7 -

4.1 汇编的概念与作用..................... - 7 -

4.2 在Ubuntu下汇编的命令.......... - 7 -

4.3 可重定位目标elf格式.............. - 7 -

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

4.5 本章小结..................................... - 7 -

第5章 链接......................................... - 8 -

5.1 链接的概念与作用..................... - 8 -

5.2 在Ubuntu下链接的命令.......... - 8 -

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

5.4 hello的虚拟地址空间............... - 8 -

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

5.6 hello的执行流程....................... - 8 -

5.7 Hello的动态链接分析.............. - 8 -

5.8 本章小结..................................... - 9 -

第6章 hello进程管理................ - 10 -

6.1 进程的概念与作用................... - 10 -

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

6.3 Hello的fork进程创建过程.. - 10 -

6.4 Hello的execve过程.............. - 10 -

6.5 Hello的进程执行.................... - 10 -

6.6 hello的异常与信号处理......... - 10 -

6.7本章小结.................................... - 10 -

第7章 hello的存储管理............ - 11 -

7.1 hello的存储器地址空间......... - 11 -

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

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

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

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

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

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

7.8 缺页故障与缺页中断处理....... - 11 -

7.9动态存储分配管理.................... - 11 -

7.10本章小结.................................. - 12 -

第8章 hello的IO管理............. - 13 -

8.1 Linux的IO设备管理方法....... - 13 -

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

8.3 printf的实现分析.................... - 13 -

8.4 getchar的实现分析................ - 13 -

8.5本章小结.................................... - 13 -

结论..................................................... - 14 -

附件..................................................... - 15 -

参考文献............................................. - 16 -

 

 

第1章 概述

1.1 Hello简介

P2P:

一、编辑hello.c文本

二、预处理:处理”#’,将#define定义的宏作字符替换;处理条件编译指令;处理#include,将#include指向的文件插入;删除注释;添加行号和文件标示;保留#program编译器指令。

三、编译:语法分析;语义分析;优化后生成相应的汇编代码;从高级语言到汇编语言再到二进制的机器语言。

四、链接:将翻译生成的二进制文件绑定在一起。

五、在shell中启动,fork产生子进程,成为process。

 

O2O:

  1. shell为其execve,映射虚拟内存。
  2. 载入物理内存。
  3. 进入 main函数执行目标代码。
  4. CPU分配时间片执行逻辑控制流。
  5. 程序运行结束后,shell父进程回收hello进程。
  6. 内核删除相关数据结构,结束。

 

1.2 环境与工具

一、Windows10 家庭版 build1803

二、VMware14+Ubuntu18.04 64位

三、[email protected] 16GB ram ddr3

四、gcc   code blocks

1.3 中间结果

Hello.i                                       预处理之后文本文件

Hello.o                                     编译之后的汇编文件

Hello.s                                      汇编之后的可重定位目标执行

Hello.elf                                   Hello ELF

Hello_ans                                 Hello的反汇编代码

Helloa_ans                               Hello.o的反汇编代码

Helloa_elf                                Hello.o ELF

1.4 本章小结

本章主要简单总结了hello这个程序的P2P,O2O过程,简要分析了中间结果。

(第1章0.5分)

 

 

第2章 预处理

2.1 预处理的概念与作用

概念:一般指在程序原代码被翻译为目标代码的过程中,生成二进制代码之前的过程。

作用:预处理器对程序源代码文本进行处理,得到的结果再由编译器进一步编译。预处理不对源代码进行解析,但他将会把源代码分割或处理成特定的单位,并处理符号来支持语言的特性(如C语言的宏)。

2.2在Ubuntu下预处理的命令

 gcc hello.c -E -o hello.i

2.3 Hello的预处理结果解析

       展开了stdio.h unistd.h stdlib.h等头文件,将头文件中的#ifdef #ifndef等条件判断语句处理,只保留了需要的语句

 

  hello.i已经由hello.c 的29行拓展到3000+行,原来的代码在文本的最底端。

2.4 本章小结

       C程序需要大量的帮助才能从代码成为真正的程序,预处理则将找到了他需要的帮手们,并将他们和程序员写下的代码安排在需要的地方。本章介绍了预处理的概念和作用,并结合hello.i 简单分析了预处理的工作。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

概念:将某一种程序设计语言写的程序翻译成等价的另一种语言的程序的程序。

包括:

  • 词法分析:对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。
  • 语法分析:以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位。
  • 生成目标代码:目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。

3.2 在Ubuntu下编译的命令

gcc -S hello.i -o hello.s

 

3.3 Hello的编译结果解析

一、指令:

.file                     声明源文件       

.text                    代码段

.section .rodata   rodata节,存放字符串和const

.globl                  声明一个全局变量

.type                   用来指定是函数类型或是对象类型

.size                    声明大小           

.long、.string     声明一个long、string类型

.align                   声明对指令或者数据的存放地址进行对齐的方式

二、数据:

.rodata 存放字符串和const,这里放的是”usage: hello 学号 姓名”。和”hello %s %s\n” 第一个s是参数学号,第二个是姓名。

       .text 声明下面的main是代码 .globl 声明main是全局的 .type 和 @function声明main的类型是函数

       sleepsecs是全局的object 大小为4bit .align 4说明应该是一个int类型的数据

       .data主要存放初始值是0以外的全局变量和初始值为0以外的静态局部变量

但在这里sleepsecs的类型被声明为long,值为2,C语言代码中赋值为2.5,依照了C语言向下取整的特性。

这里开了长度为32的栈,用来存放传入的学号和姓名,即char[]。

 

  • 计算:

Leaq .LC0(%rip),%rdi 计算LC1的段地址并传递给%rdi

 

  • 控制转移

cmpl    $3, -20(%rbp)

    je      .L2

-20(%rbp)与3比较,相等跳转.L2 由C语言代码知-20(%rbp)即argv

cmpl    $9, -4(%rbp)

jle     .L4

      即C语言中的for(i=0;i<10;i++)

  • 函数:

main:

printf:

puts@PLT:

sleep

getchar

 

3.4 本章小结

解释了编译器是如何处理C语言的各个数据类型以及各类操作的,基本都是先给出原理然后结合hello.c C程序到hello.s汇编代码之间的映射关系作出合理解释。

(第32分)

第4章 汇编

4.1 汇编的概念与作用

       概念:把汇编语言翻译成机器语言的过程称为汇编。

       作用:汇编器将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,.o文件是一个二进制文件,它包含程序的指令编码。这个过程称为汇编,亦即汇编的作用。

4.2 在Ubuntu下汇编的命令

as hello.s -o hello.o

4.3 可重定位目标elf格式

  

Magic描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括节头大小、目标文件的类型、系统及硬件类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。

 

重定位节.rela.text ,一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。如图4.4,图中8条重定位信息分别是对.L0(第一个printf中的字符串)、puts函数、exit函数、.L1(第二个printf中的字符串)、printf函数、sleepsecs、sleep函数、getchar函数进行重定位声明。

 

4.4 Hello.o的结果解析

分支转移:反汇编代码跳转指令的操作数由段名称变成了确定的地址。

操作数:由.s中的数变成了具体的准确数字,如call 0 变为准确地址。

函数调用:在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址。

全局变量:在.s文件中,全局变量使用段名称+%rip,在反汇编代码中0+%rip,因为rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。

4.5 本章小结

介绍了hello从hello.s到hello.o的汇编过程,比较了前后的的结果,了解了从汇编语言映射到机器语言汇编器需要实现的转换。

(第41分)

5章 链接

5.1 链接的概念与作用

链接的过程就是指,经过编译后将会生成一个目标文件,这个目标文件可能会调用printf等函数,对于printf函数,它的目标代码在系统的函数库中,链接所要做的就是将这些函数库中相应的代码组合到目标文件中去。

5.2 在Ubuntu下链接的命令

ld -o hello -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 hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o

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

 

 

记录了各节的大小,偏移量,地址(程序被载入到虚拟地址的起始地址)

5.4 hello的虚拟地址空间

使用edb打开hello程序,通过edb的Data Dump窗口查看加载到虚拟地址中的hello程序。

   在0x400000~0x401000段中,程序被载入,自虚拟地址0x400000开始,自0x400fff结束,这之间每个节(开始 ~ .eh_frame节)的排列即开始结束同下图中Address中声明。

   如上图,查看ELF格式文件中的Program Headers,程序头表在执行的时候被使用,它告诉链接器运行时加载的内容并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方面的信息。在下面可以看出,程序包含8个段:

 

PHDR保存程序头表。

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

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

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

       NOTE保存辅助信息。

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

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

5.5 链接的重定位过程分析

.interp                                                     保存ld.so的路径

.note.ABI-tag                                         Linux下特有的section

.hash                                                       符号的哈希表

.gnu.hash                                                GNU拓展的符号的哈希表

.dynsym                                                 运行时/动态符号表

.dynstr                                                    存放.dynsym节中的符号名称

.gnu.version                                           符号版本

.gnu.version_r                                        符号引用版本

.rela.dyn                                                        运行时/动态重定位表

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

.init                                                         程序初始化需要执行的代码

.plt                                                          动态链接-过程链接表

.fini                                                         当程序正常终止时需要执行的代码

.eh_frame     contains exception unwinding and source language information.

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

.got                                                         动态链接-全局偏移量表-存放变量

.got.plt                                                    动态链接-全局偏移量表-存放函数

.data                                                       初始化了的数据

.comment                                               一串包含编译器的NULL-terminated字符串

5.6 hello的执行流程

通过比较hello.objdump和helloo.objdump了解链接器。

   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中用到的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_PC32的对.rodata的重定位(printf中的两个字符串),.rodata与.text节之间的相对距离确定,因此链接器直接修改call之后的值为目标地址与下一条指令的地址之差,指向相应的字符串。这里以计算第一条字符串相对地址为例说明计算相对地址的算法(算法说明同4.3节):

refptr = s + r.offset = Pointer to 0x40054A

refaddr = ADDR(s) + r.offset= ADDR(main)+r.offset=0x400532+0x18=0x40054A

*refptr=(unsigned)(ADDR(r.symbol)+r.addend-refaddr)=ADDR(str1)+r.addend-refaddr=0x400644+(-0x4)-0x40054A=(unsigned) 0xF6,

 

观察反汇编验证计算:

5.7 Hello的动态链接分析

 

5.8 本章小结

介绍了链接的概念与作用、hello的ELF格式,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。

(第51分)

 

6章 hello进程管理

6.1 进程的概念与作用

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

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

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

作用:shell 是一个交互型的应用级程序,它代表用户运行其他程序。

 

处理流程:shell 执行一系列的读/求值(read /evaluate ) 步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。

6.3 Hello的fork进程创建过程

一个进程被创建了,这个进程(hello)父进程一样,拥有一套与父进程相同的变量,相同的一套代码,这里可以粗浅的理解为子进程又复制了一份main函数。这里返回一个子进程的进程号,大于0。

 

6.4 Hello的execve过程

1.使用execve就是一次系统调用,首先要做的将新的可执行文件的绝对路径从调用者(用户空间)拷贝到系统空间中。

2.在得到可执行文件路径后,就找到可执行文件打开,由于操作系统已经为可执行文件设置了一个数据结构,就初始化这个数据结构,保存一个可执行文件必要的信息。

3.可执行文件不是真正上能够自己运行的,需要有代理人来代理。在系统内核中有一个formats队列,循环遍历这个队列,看看现在被初始化的这个数据结构是哪个代理人可以代理的。如果没有就继续查看数据结构中的信息。按照系统配置了是否可以动态加载模块,加载一次模块,再循环遍历看是否有代理人前来认领。

4.找到正确的代理人后,代理人首先要做的就是放弃以前从父进程继承来的资源(虽然代理程序各不相同,但这是一个共性的操作)。主要是对信号处理表,用户空间和文件3大资源的处理。

6.5 Hello的进程执行

Linux 系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。当shell 运行一个程序时,父shell 进程生成一个子进程,它是父进程的一个复制。子进程通过execve 系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk), 新的代码和数据段袚初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main 函数。

6.6 hello的异常与信号处理

正常执行hello,执行完成之后,进程被回收。

当按下ctrl-z之后,shell父进程收到SIGSTP信号,信号处理函数的逻辑是打印屏幕回显、将hello进程挂起,通过ps命令我们可以看出hello进程没有被回收,此时他的后台job号是1,调用fg 1将其调到前台,此时shell程序首先打印hello的命令行命令,hello继续运行打印剩下的10条info,之后按下回车,程序结束,同时进程被回收。

当按下ctrl-c之后,shell父进程收到SIGINT信号,信号处理函数的逻辑是结束hello,并回收hello进程。

程序运行中途乱按,只是将屏幕的输入缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),其他字串会当做shell命令行输入。

6.7本章小结

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

(第61分)

7章 hello的存储管理

7.1 hello的存储器地址空间

物理地址(physical address)

用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。

——这个概念应该是这几个概念中最好理解的一个,但是值得一提的是,虽然可以直接把物理地址理解成插在机器上那根内存本身,把内存看成一个从0字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址,但是事实上,这只是一个硬件提供给软件的抽像,内存的寻址方式并不是这样。所以,说它是“与地址总线相对应”,是更贴切一些,不过抛开对物理内存寻址方式的考虑,直接把物理地址与物理的内存一一对应,也是可以接受的。也许错误的理解更利于形而上的抽像。

 

虚拟内存(virtual memory)

这是对整个内存(不要与机器上插那条对上号)的抽像描述。它是相对于物理内存来讲的,可以直接理解成“不直实的”,“假的”内存,例如,一个0x08000000内存地址,它并不对就物理地址上那个大数组中0x08000000 - 1那个地址元素;

之所以是这样,是因为现代操作系统都提供了一种内存管理的抽像,即虚拟内存(virtual memory)。进程使用虚拟内存中的地址,由操作系统协助相关硬件,把它“转换”成真正的物理地址。这个“转换”,是所有问题讨论的关键。

有了这样的抽像,一个程序,就可以使用比真实物理地址大得多的地址空间。(拆东墙,补西墙,银行也是这样子做的),甚至多个进程可以使用相同的地址。不奇怪,因为转换后的物理地址并非相同的。

——可以把连接后的程序反编译看一下,发现连接器已经为程序分配了一个地址,例如,要调用某个函数A,代码不是call A,而是call 0x0811111111 ,也就是说,函数A的地址已经被定下来了。没有这样的“转换”,没有虚拟地址的概念,这样做是根本行不通的。

打住了,这个问题再说下去,就收不住了。

 

逻辑地址(logical address)

Intel为了兼容,将远古时代的段式内存管理方式保留了下来。逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。以上例,我们说的连接器为A分配的0x08111111这个地址就是逻辑地址。

——不过不好意思,这样说,好像又违背了Intel中段式管理中,对逻辑地址要求,“一个逻辑地址,是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量],也就是说,上例中那个0x08111111,应该表示为[A的代码段标识符: 0x08111111],这样,才完整一些”

 

线性地址(linear address)或也叫虚拟地址(virtual address)

跟逻辑地址类似,它也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件页式内存的转换前地址。

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

段式内存管理方式就是直接将逻辑地址转换成物理地址,也就是CPU不支持分页机制。其地址的基本组成方式是段号+段内偏移地址。       在介绍段式内存管理方式之前首先介绍逻辑空间。逻辑空间分为若干个段,其中每一个段都定义了一组具有完整意义的信息,逻辑地址对应于逻辑空间,如(主程序的main())函数,如下图所示。   

段是对程序逻辑意义上的一种划分,一组完整逻辑意义的程序被划分成一段,所以段的长度是不确定的。

    

 如上图所示,段式内存管理方式经过段表映射到内存空间。先说明一下段表的概念,可以将段表抽象成一个大的数组集合,数组中的元素是什么呢?就是“段描述符”----用于描述一个段的详细信息的结构。段描述符一般是由8个字节组成,也就是64位。操作系统使用的不同的段描述符如下图所示。

将逻辑地址转换成下一个环节的地址(物理地址,不适用分页或者使用分页的线性地址)需要使用段表,而获得段表中一个特定的段描述符需要使用段选择符,这里需要区分的概念就是“段选择符"和“段描述符"。段描述符描述了一个段的详细信息,例如,起始地址(BASE的32位,长度20位),适用于转换下一个环节的地址所需要的详细信息;而段选择符是用于找到对应的段描述符的。      

下面说一下段选择符。段选择符是一个由16位长的字段组成的,其中前13位是一个索引号,后面三位包含一些硬件细节,如下图所示。       

根据段选择符可以获取段描述符,虽然段描述符比较复杂,但是对于寻址而言,我们只关注Base的32位,它 描述了一个段的开始位置的线性地址。Intel设计的本意是,一些全局的段描述符,就放在"全局段描述符(GDT)"中,一些局部的,例如每个进程自己的,就放在所谓的"局部段描述符表(LDT)中"。那么,究竟什么时候该用GDT,什么时候该用LDT呢?这是由段选择描述符中的T1字段表示的,=0标识使用GDT,=1表示使用LDT。GDT在内存中的大小和地址存放在CPU的gdtr寄存器中,而LDT则在ldtr寄存器中。如下图所示。

首先给定一个完整的逻辑地址[段选择符:段内偏移地址],      1.看段选择描述符中的T1字段是0还是1,可以知道当前要转换的是GDT中的段,还是LDT中的段,再根据指定的相应的寄存器,得到其地址和大小,我们就有了一个数组了。      2.拿出段选择符中的前13位,可以在这个数组中查找到对应的段描述符,这样就有了Base,即基地址就知道了。      3.把基地址Base+Offset,就是要转换的下一个阶段的物理地址。

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

CPU的页式内存管理单元负责把一个线性地址转换为物理地址。从管理和效率的角度出发,线性地址被划分成固定长度单位的数组,称为页(page)。例如,一个32位的机器,线性地址可以达到4G,用4KB为一个页来划分,这样,整个线性地址就被划分为一个2^20次方的的大数组,共有2的20次方个页,也就是1M个页,我们称之为页表,改页表中每一项存储的都是物理页的基地址。       这里不得不说的是另一个“页”,我们称之为物理页,或者页框、页桢。是分页单元将所有的物理内存都划分成了固定大小的单元为管理单位,其大小一般与内存页大小一致。       如果内存页按照这种方式进行管理,管理内存页需要2^20次方的数组,其中每个数组都是32bit,也就是4B(其中前20位存储物理内存页的基地址,后面的12位留空,用于与给定的线性地址的后12位拼接起来一起组成一个真实的物理地址,寻找数据的所在。这样就需要为每个进程维护4B*2^20=4MB的内存空间,极大地消耗了内存。为了能够尽可能的节约内存,CPU在页式内存管理方式中引入了两级的页表结构,如下图所示。

      

如上图所示,这种页式管理方式中,第一级的页表称之为“页目录”,用于存放页表的基地址;第二级才是真正的“页表”用于存放物理内存中页框的基地址。       1、二级页目录的页式内存管理方式中,第一级的页目录的基址存放在CPU寄存器CR3中,这也是转换的开始点;       2、每一个活动的进程,都有其对应的独立虚拟内存(页目录也是唯一的),那么它对应一个独立的页目录地址。--运行一个进程,需要将它的页目录地址放到CR3寄存器中,将别的页目录的基址暂时换到内存中;       3、每个32位的线性地址被划分成三部分,页目录索引(10位),页表索引(10位),偏移量(12位)。线性地址转换成物理地址的过程如下:       1、从CR3中取出进程的页目录的地址(操作系统在负责进程的调度的时候,将这个地址装入对应的CR3地址寄存器),取出其前20位,这是页目录的基地址;       2、根据取出来的页目录的基地址以及线性地址的前十位,进行组合得到线性地址的前十位的索引对应的项在页目录中地址,根据该地址可以取到该地址上的值,该值就是二级页表项的基址;当然你说地址是32位,这里只有30位,其实当取出线性地址的前十位之后还会该该前十位左移2位,也就是乘以4,一共32位;之所以这么做是因为每个地址都是4B的大小,因此其地址肯定是4字节对齐的,因此左移两位之后的32位的值恰好就是该前十位的索引项的所对应值的起始地址,只要从该地址开始向后读四个字节就得到了该十位数字对应的页目录中的项的地址,取该地址的值就是对应的页表项的基址;       3、根据第二步取到的页表项的基址,取其前20位,将线性地址的10-19位左移2位(原因和第2步相同),按照和第2步相同的方式进行组合就可以得到线性地址对应的物理页框在内存中的地址在二级页表中的地址的起始地址,根据该地址向后读四个字节就得到了线性地址对应的物理页框在内存中的地址在二级页表中的地址,然后取该地址上的值就得到线性地址对应的物理页框在内存中的基地址;(这一步的地址比较绕,还请仔细琢磨,反复推敲)       4、根据第3步取到的基地址,取其前20位得到物理页框在内存中的基址,再根据线性地址最后的12位的偏移量得到具体的物理地址,取该地址上的值就是最后要得到值;       其实,对比一级页表机制和二级页表机制可以发现,二级页表机制中同样需要1024个二级页表,每个页表都有1024项,每项的大小都是4B,因此一个二级页表需要4KB,1024个二级页表需要4MB的空间,再加上页目录,好像比只有一级页表机制占用了更多的内存,而且寻址方式变得更复杂了,似乎是在自己给自己找麻烦。从CPU的开销中可以看到,如果是一级页表机制,那么CPU取到一个数需要访问内存两次,而使用二级页表机制之后,CPU想要取得一个操作数,需要访问内存三次。其实,使用二级页表机制还是有很多优点的,如下:      1、二级表结构也许页表分散在内存的各个页面中,而不需要保存在连续的4M的内存块中;      2、不需要位不存在的线性地址空间分配二级页表,虽然目录表页面总是必须存在于物理内存中,但是二级页表可以在需要的时候再分配,这使得页表结构的大小对应于实际使用的线性地址空间的大小;      3、页目录和页表中的每个表项都有一个存在属性,页目录中的存在属性指明对应的页表结构是否存在。如果页目录指明对应的二级页表存在,那么通过访问二级表,表查找过程就像上面的查找过程一样进行下去;如果存在标志表明对应的二级表项不存在,那么处理器就会产生一个缺页异常来通知操作系统。页目录表项的存在属性使得操作系统可以根据实际使用的线性地址范围来分配二级页表页面;当然,页目录表项中的存在位还可以在虚拟内存中存放二级页表,这意味着在任何的时候只有部分二级页表需要存放在物理内存中,其余部分可以保存在磁盘中。处于物理内存中的页表对应的页目录项可以被标注为存在,以表明可用它们进行分页转换。处于磁盘上的页表对应的页目录项被标注为不存在。由于二级页表不存在而引发的异常会通知操作系统将缺少的页表从磁盘上加载进物理内存。将页表存储在虚拟内存中减少了保存页表所需要的物理内存;

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

VA到PA的映射过程

首先将CPU内核发送过来的32位VA[31:0]分成三段,前两段VA[31:20]和VA[19:12]作为两次查表的索引,第三段VA[11:0]作为页内的偏移,查表的步骤如下:

⑴从协处理器CP15的寄存器2(TTB寄存器,translation table base register)中取出保存在其中的第一级页表(translation table)的基地址,这个基地址指的是PA,也就是说页表是直接按照这个地址保存在物理内存中的。

 

⑵以TTB中的内容为基地址,以VA[31:20]为索引值在一级页表中查找出一项(2^12=4096项),这个页表项(也称为一个描述符,descriptor)保存着第二级页表(coarse page table)的基地址,这同样是物理地址,也就是说第二级页表也是直接按这个地址存储在物理内存中的。

⑶以VA[19:12]为索引值在第二级页表中查出一项(2^8=256),这个表项中就保存着物理页面的基地址,我们知道虚拟内存管理是以页为单位的,一个虚拟内存的页映射到一个物理内存的页框,从这里就可以得到印证,因为查表是以页为单位来查的。

⑷有了物理页面的基地址之后,加上VA[11:0]这个偏移量(2^12=4KB)就可以取出相应地址上的数据了。

这个过程称为Translation Table Walk,Walk这个词用得非常形象。从TTB走到一级页表,又走到二级页表,又走到物理页面,一次寻址其实是三次访问物理内存。注意这个“走”的过程完全是硬件做的,每次CPU寻址时MMU就自动完成以上四步,不需要编写指令指示MMU去做,前提是操作系统要维护页表项的正确性,每次分配内存时填写相应的页表项,每次释放内存时清除相应的页表项,在必要的时候分配或释放整个页表。7.5 三级Cache支持下的物理内存访问

7.6 hello进程fork时的内存映射

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

7.7 hello进程execve时的内存映射

1.使用execve就是一次系统调用,首先要做的将新的可执行文件的绝对路径从调用者(用户空间)拷贝到系统空间中。

2.在得到可执行文件路径后,就找到可执行文件打开,由于操作系统已经为可执行文件设置了一个数据结构,就初始化这个数据结构,保存一个可执行文件必要的信息。

3.可执行文件不是真正上能够自己运行的,需要有代理人来代理。在系统内核中有一个formats队列,循环遍历这个队列,看看现在被初始化的这个数据结构是哪个代理人可以代理的。如果没有就继续查看数据结构中的信息。按照系统配置了是否可以动态加载模块,加载一次模块,再循环遍历看是否有代理人前来认领。

4.找到正确的代理人后,代理人首先要做的就是放弃以前从父进程继承来的资源(虽然代理程序各不相同,但这是一个共性的操作)。主要是对信号处理表,用户空间和文件3大资源的处理。

  a.信号处理表:将父进程的信号处理表复制过来(可能已经复制了,也可能没有复制,通过检查信号处理表的共享参数就可以知道)。信号处理分3种情况:一对该信号不理睬,二对信号采取默认动作,三对信号采取指定动作。前面2种可以直接复制,最后一种,所谓的默认动作就是用户指定了程序处理,这段程序的代码必然是父进程自己拥有的,他的位置就存在用户空间中,下面我们会放弃继承到的父进程用户空间,对第3种情况的处理就是将其改成默认处理。所以,在新建的子进程中,对信号如果子进程没有采取指定处理,那么一律都会是默认处理,当然如果父进程对某个信号采取了不理睬,子进程也会不理睬,除非子进程后来又做了修改。

  b.用户空间,放弃原来的用户空间(子进程可能有自己的页面,或者就是通过指针共享了父进程的页面)这些一律放弃,将进程控制块task_struct中对用户空间的描述的数据结构mm_struct的下属结构vma全部置0.简而言之就是现在子进程的用户空间是个空架子,一个页面也没有,父进程空间被放弃。

   c.进程控制块task_struct中有file的指针记录了进程打开的文件信息,子进程对继承到的文件采取关闭应当关闭的信息。file的数据结构中有位图记录了应当关闭的文件,子进程放弃这些文件。一般来说,执行的效果是除了标准输入文件,标准输出文件,标准错误输出文件。其它的文件都不会被子进程继承。(标准输入一般就是键盘,标准输出就是显示器。因此如果子进程有打印语句的话,那么他的打印出来的字符会打印到父进程打印的地方,前面写文章有点错误,我已经改掉了)。

5.至此我们已经做了的实际动作就是信号处理表,用户空间和文件。但用户空间是个空架子,真正的程序代码没载入,数据段也没载入,堆栈没有开辟,执行参数和环境变量也没有被印射。但可以知道,每个可执行文件的载入是不同的,比如linux下shell文件和a.out文件2个有很明显的不同,你可以对他们采用同样的载入办法吗。下面就是各个代理人自己开始为自己的代理方申请空间,准备用户内存。最后调用 start_thread 开始启动进程。

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

缺页中断处理一般流程:

1.硬件陷入内核,在堆栈中保存程序计数器,大多数当前指令的各种状态信息保存在特殊的cpu寄存器中。

2.启动一个汇编例程保存通用寄存器和其他易丢失信息,以免被操作系统破坏。

3.当操作系统发现缺页中断时,尝试发现需要哪个虚拟页面。通常一个硬件寄存器包含了这些信息,如果没有的话操作系统必须检索程序计数器,取出当前指令,分析当前指令正在做什么。

4.一旦知道了发生缺页中断的虚拟地址,操作系统会检查地址是否有效,并检查读写是否与保护权限一致,不过不一致,则向进程发一个信号或者杀死该进程。如果是有效地址并且没有保护错误发生则系统检查是否有空闲页框。如果没有,则执行页面置换算法淘汰页面。

5.如果选择的页框脏了,则将该页写回磁盘,并发生一次上下文切换,挂起产生缺页中断的进程让其他进程运行直到写入磁盘结束。且回写的页框必须标记为忙,以免其他原因被其他进程占用。

6.一旦页框干净后,操作系统查找所需页面在磁盘上的地址,通过磁盘操作将其装入,当页面被装入后,产生缺页中断的进程仍然被挂起,并且如果有其他可运行的用户进程,则选择另一用户进程运行。

7.当磁盘中断发生时,表明该页已经被装入,页表已经更新可以反映他的位置,页框也标记位正常状态。

8.恢复发生缺页中断指令以前的状态,程序计数器重新指向这条指令。

9.调度引发缺页中断的进程,操作系统返回调用他的汇编例程

10.该例程恢复寄存器和其他状态信息,返回到用户空间继续执行,就好像缺页中断没有发生过。

7.9动态存储分配管理

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

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

   分配器分为两种基本风格:显式分配器、隐式分配器。

   显式分配器:要求应用显式地释放任何已分配的块。

   隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。

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

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

在内存块中增加4B的Header和4B的Footer,其中Header用于寻找下一个blcok,Footer用于寻找上一个block。Footer的设计是专门为了合并空闲块方便的。因为Header和Footer大小已知,所以我们利用Header和Footer中存放的块大小就可以寻找上下block。

2)隐式链表

   所谓隐式空闲链表,对比于显式空闲链表,代表并不直接对空闲块进行链接,而是将对内存空间中的所有块组织成一个大链表,其中Header和Footer中的block大小间接起到了前驱、后继指针的作用。

3)空闲块合并

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

显示空间链表基本原理

将空闲块组织成链表形式的数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如下图:

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。

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

7.10本章小结

       主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,介绍了VA到PA在一定条件下的变换、物理内存访问,还介绍了hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。

(第7 2分)

8章 hello的IO管理

8.1 Linux的IO设备管理方法

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

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的实现分析

printf的声明

    int _cdecl printf(const char* format, …);

    _cdecl是C和C++程序的缺省调用方式

 

_CDEDL调用约定:

    1.参数从右到左依次入栈

    2.调用者负责清理堆栈

    3.参数的数量类型不会导致编译阶段的错误

 

 

对于x86而言,栈向下生长,函数参数从右向左入栈,因此从第一个固定参数(format)地址向前(向上)移动就可得到其他变参的地址。

 

va_list相关宏(VC++中stdarg.h里x86平台的宏定义)

 

typedef char *  va_list;

//_INTSIZEOF(n)宏:将sizeof(n)按sizeof(int)对齐。

#define _INTSIZEOF(n)   ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )

 

//取format参数之后的第一个变参地址,4字节对齐

#define va_start(va_list  ap, format) ( ap = (va_list)&format+ _INTSIZEOF(format) )

 

//对type类型数据,先取到其四字节对齐地址,再取其值

#define va_arg(va_list  ap,type) 

              ( *(type*)((ap += _INTSIZEOF(type)) -_INTSIZEOF(type)) )

 

#define va_end(va_list  ap)  ( ap = (va_list)0 )

 

 

如何得到参数个数?

其实printf并不知道参数个数,它只是逐个解析format字符串。对于特定类型%,使用va_arg去取相应参数的值,直到遍历字符串结束。类似于如下代码:

    #include <stdio.h>

    #include <stdarg.h>

    void myprintf(const char *format, ...)

    {

                va_list ap;

                char ch;

                va_start(ap, format);

                while(ch = *format++)

                {

                        switch(c)

                        {

                                    case 'c':

                                        {

                                                char ch1 = va_arg(ap, char);

                                              putchar(ch1);

                                               break;

                                        }

8.4 getchar的实现分析

异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。

 

getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。

8.5本章小结

       要介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数。

(第81分)

结论

       到这里我们的hello就算寿终正寝啦,他从简简单单的十几行语句,被编译器加上许许多多神奇的操作,就像学习了计算机系统的我们一样,从懵懵懂懂变成一个理解了计算机系统内涵的程序(我要挂了- -)。在做这个大作业的过程中,我们回顾了一整个学期的计算机系统的课程,得到了一些复习的机会,感谢老师!

附件

Hello.i                                       预处理之后文本文件

Hello.o                                     编译之后的汇编文件

Hello.s                                      汇编之后的可重定位目标执行

Hello.elf                                   Hello ELF

Hello_ans                                 Hello的反汇编代码

Helloa_ans                               Hello.o的反汇编代码

Helloa_elf                                Hello.o ELF

 

 

参考文献

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

[1] C语言编译链接https://www.cnblogs.com/winifred-tang94/p/5833831.html

[2] 逻辑地址、线性地址https://blog.csdn.net/gdj0001/article/details/80135196

[3] TLB的作用及工作过程https://www.cnblogs.com/alantu2018/p/9000777.html

[4] linux下execve实现的过程 https://memorymyann.iteye.com/blog/240619

 

猜你喜欢

转载自blog.csdn.net/weixin_42010279/article/details/85460161