#计算机系统# HIT 2018 CS:APP Hello的一生 大作业

欢迎访问个人博客:https://lyp123.com

第1章 概述

1.1 Hello简介

1.1.1 From Program to Process

         首先hello.c通过I/O设备如键盘等经过总线存入主存。然后GCC编译器驱动程序读取源程序文件hello.c,通过预处理器cpp变成hello.i(修改了的源程序)然后通过编译器ccl变成hello.s(汇编程序),然后通过汇编器as变成hello.o(可重定位目标程序),这时的hello.o就不是之前的文本了,而是对机器友好的二进制代码了。最后再通过链接器ld与标准C库进行链接,最终变成hello(可执行的二进制目标程序)此时的hello就是一个Program了。然后在shell(Bash)里面输入字符串“./hello”后,shell程序将字符逐一读入寄存器,然后再放入到内存里面去,然后shell调用fork函数创建一个新运行的子进程,这个子进程是父进程shell的一个复制,然后子进程通过execve系统调用启动加载器。加载器删除子进程现有的虚拟内存段,然后使用mmap函数创建新的内存区域,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk), 新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main 函数。然后程序从内存读取指令字节,然后再从寄存器读入最多两个数,然后在执行阶段算术/逻辑单元要么执行指令指明的操作,计算内存引用的有效地址要么增加或者减少栈指针。然后在流水线化的系统中,待执行的程序被分解成几个阶段,每个阶段完成指令执行的一部分。最后变成一个Process运行在内存中。

1.1.2 From Zero-0 to Zero-0

        首先说明这里的020应该指的是程序(Process)在内存中From Zero to Zero。一开始hello先是执行了上面所述的过程,然后在程序执行结束以后,该进程会保持在一种已终止的状态中,直到该进程被其父进程也就是shell进程回收然后退出,shell会再次变成hello执行之前的状态,也就是说又变成Zero了。

 

1.2 环境与工具

1.2.1 硬件环境

X64 CPU;2.8GHz;8G RAM;1THD Disk;

1.2.2 软件环境

Windows10 64位;Vmware 14;Ubuntu 16.04 LTS 64位;

1.2.3 开发工具

Visual Studio 2015 64位;CodeBlocks;vi/vim/gpedit+gcc;

 

1.3 中间结果

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

hello.i(修改了的源程序):预处理阶段实现的功能主要有三个:

1.加载头文件2.进行宏替换3.条件编译

hello.s(汇编程序):包含汇编语言程序。

hello.o(可重定位目标程序):将汇编语言翻译成机器语言指令,并将指令打包成一种叫做可重定位目标程序的格式。

 

1.4 本章小结

        hello.c被编写出来,然后在编译器的作用下被编译成可执行文件,然后在系统的操作下被执行,然后被回收,看似简单的步骤却经历了一番伟大的路程。这不仅仅代表了一个程序,也代表了绝大多数程序的历程。

                                                                                          (第1章0.5分)

 

 

 

第2章 预处理

2.1 预处理的概念与作用

概念:预处理器(cpp) 根据以字符#开头的命令,修改原始的C 程序。

作用:预编译过程主要处理那些源代码中以#开始的预编译指令,主要处理规则如下:

1)将所有的#define删除,并且展开所有的宏定义;

2)处理所有条件编译指令,如#if,#ifdef等;

3)处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。该过程递归进行,及被包含的文件可能还包含其他文件。

4)删除所有的注释//和 /**/;

5)添加行号和文件标识,如#2 “hello.c” 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号信息;

6)保留所有的#pragma编译器指令,因为编译器须要使用它们;

 

2.2在Ubuntu下预处理的命令

gcc -E hello.c -o hello.i

                                                  图2-1 在Ubuntu下预处理的命令

 

2.3 Hello的预处理结果解析

         #include<stdio.h>、#include<unistd.h>、#include<stdlib.h>等头文件包含的文件被插入到该预编译指令的位置。该过程递归进行,及被包含的文件可能还包含其他文件。

                                                                                     图2-2 Hello的预处理结果解析

 

2.4 本章小结

        .c文件中包含有头文件也就是有外部文件的,还有一些程序员需要但是对于程序执行没有任何帮助的宏定义以注释,和一些程序员需要的条件编译和完善程序文本文件等操作都需要通过预处理来实现。预处理可以使得程序在后序的操作中不受阻碍,是非常重要的步骤。

                                                                                              (第2章0.5分)

 

第3章 编译

3.1 编译的概念与作用

概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。

作用:编译过程就是把预处理完的文件进行一系列词法分析,语法分析,语义分析及优化后生成相应的汇编代码文件。

注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序

 

3.2 在Ubuntu下编译的命令

gcc -S hello.i -o hello.s

                                                        图3-1 在Ubuntu下编译的命令

 

3.3 Hello的编译结果解析

3.3.1 数据

有变量int sleepsecs,编译器将其编译成

       .type      sleepsecs, @object
       .size       sleepsecs, 4

3.3.2 赋值

赋值语句sleepsecs=2.5编译器将其编译成

sleepsecs:
      .long      2
      .section  .rodata

赋值语句i=0编译器将其编译成

      movl     $0, -4(%rbp)

3.3.3 类型转换(显示或隐式)

由于sleepsecs是int型的而2.5是float类型的,这就有一个隐式的类型转换,编译器将2.5隐式地转换成了2存入sleepsecs。

3.3.4 算术操作

编译器将i++编译成

      addl $1, -4(%rbp)

3.3.5 关系操作

编译器将i<10编译成

      cmpl      $9, -4(%rbp)
      jle       .L4

将argc!=3编译成     

      cmpl      $3, -20(%rbp)
      je       .L2

3.3.6 数组/指针/结构操作

printf函数里面的一系列对指针和对数组的操作编译器编译为:     

      movq     -32(%rbp), %rax
      addq      $16, %rax
      movq     (%rax), %rdx
      movq     -32(%rbp), %rax
      addq      $8, %rax
      movq     (%rax), %rax
      movq     %rax, %rsi

3.3.7 控制转移

编译器将if,for等控制转移语句都使用了cmp来比较然后使用了条件跳转指令来跳转。编译器将if(argc!=3)编译成:

      cmpl     $3, -20(%rbp)
      je       .L2

将for循环里面的比较和转移编译成:

      cmpl     $9, -4(%rbp)
      jle      .L4

 

3.3.8 函数操作

编译器将printf("Usage: Hello 学号 姓名!\n");编译为:

      movl  $.LC0, %edi
      call  puts

将printf("Hello %s %s\n",argv[1],argv[2]);编译为:     

      movq     -32(%rbp), %rax
      addq      $16, %rax
      movq     (%rax), %rdx
      movq     -32(%rbp), %rax
      addq      $8, %rax
      movq     (%rax), %rax
      movq     %rax, %rsi
      movl      $.LC1, %edi
      movl      $0, %eax
      call      printf

将sleep(sleepsecs);编译为:

movl     sleepsecs(%rip), %eax
movl     %eax, %edi
call sleep

                                                                             图3-2 Ubuntu下编译结果对比图示

 

3.4 本章小结

        在编译阶段,编译器将高级语言编译成汇编语言。汇编语言是直接面向处理器(Processor)的程序设计语言。处理器是在指令的控制下工作的,处理器可以识别的每一条指令称为机器指令。每一种处理器都有自己可以识别的一整套指令,称为指令集。处理器执行指令时,根据不同的指令采取不同的动作,完成不同的功能,既可以改变自己内部的工作状态,也能控制其它外围电路的工作状态。

        汇编语言的另一个特点就是它所操作的对象不是具体的数据,而是寄存器或者存储器,也就是说它是直接和寄存器和存储器打交道,这也是为什么汇编语言的执行速度要比其它语言快,但同时这也使编程更加复杂,因为既然数据是存放在寄存器或存储器中,那么必然就存在着寻址方式,也就是用什么方法找到所需要的数据。例如上面的例子,我们就不能像高级语言一样直接使用数据,而是先要从相应的寄存器AX、BX 中把数据取出。这也就增加了编程的复杂性,因为在高级语言中寻址这部分工作是由编译系统来完成的,而在汇编语言中是由程序员自己来完成的,这无异增加了编程的复杂程度和程序的可读性。

        再者,汇编语言指令是机器指令的一种符号表示,而不同类型的CPU 有不同的机器指令系统,也就有不同的汇编语言,所以,汇编语言程序与机器有着密切的关系。所以,除了同系列、不同型号CPU 之间的汇编语言程序有一定程度的可移植性之外,其它不同类型(如:小型机和微机等)CPU 之间的汇编语言程序是无法移植的,也就是说,汇编语言程序的通用性和可移植性要比高级语言程序低。

       总结起来就是三个特点:机器相关性、高速度和高效率、编写和调试复杂(相对于高级语言)。

                                                                                            (第32分)

 

第4章 汇编

4.1 汇编的概念与作用

概念:汇编器(as) 将hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program) 的格式,并将结果保存在目标文件hello.o 中。

作用:汇编器是将汇编代码转变成机器可以执行的命令,每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,根据汇编指令和机器指令的对照表一一翻译即可。

4.2 在Ubuntu下汇编的命令

gcc –c hello.s –o hello.o

                                                         图4-1 Ubuntu下汇编的命令

 

4.3 可重定位目标elf格式

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

节头信息如下:

4-2  Ubuntureadelf列出的节头信息

其中重定位节有.rela.text以及.rela.eh_frame详细信息如下:

                                                图4-3  Ubuntu下重定位节的详细信息

 

4.4 Hello.o的结果解析

        机器语言指的是二进制的机器指令集合,而机器指令是由操作码和操作数构成的。汇编语言的主体是汇编指令。汇编指令和机器指令的差别在于指令的表示方法上,汇编指令是机器指令便于记忆的书写格式。

在对比两个文件后,汇编器在汇编hello.s时:

  1. 为每条语句加上了具体的地址,全局变量和常量都被安排到了具体的地址里面。
  2. 操作数在hello.s里面都是十进制,在到hello.o里面的机器级程序时都是十六进制。
  3. 跳转语句jx&jxx原来对应的符号都变成了相对偏移地址。
  4. 函数调用时原来的函数名字也被替换成了函数的相对偏移地址。

4.5 本章小结

        汇编器将汇编语言转化成机器语言,机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指令的集合。它是计算机的设计者通过计算机的硬件结构赋予计算机的操作功能。机器语言具有灵活、直接执行和速度快等特点。  不同型号的计算机其机器语言是不相通的,按着一种计算机的机器指令编制的程序,不能在另一种计算机上执行。

        一条指令就是机器语言的一个语句,它是一组有意义的二进制代码,指令的基本格式如,操作码字段和地址码字段,其中操作码指明了指令的操作性质及功能,地址码则给出了操作数或操作数的地址。

        用机器语言编写程序,编程人员要首先熟记所用计算机的全部指令代码和代码的涵义。手编程序时,程序员得自己处理每条指令和每一数据的存储分配和输入输出,还得记住编程过程中每步所使用的工作单元处在何种状态。这是一件十分繁琐的工作。编写程序花费的时间往往是实际运行时间的几十倍或几百倍。而且,编出的程序全是些0和1的指令代码,直观性差,还容易出错。除了计算机生产厂家的专业人员外,绝大多数的程序员已经不再去学习机器语言了。但是作为最基础的语言我们还是要稍作了解,目的是对计算机系统的执行方式进行了解,有助于我们编写出质量更高的代码。

                                                                                            (第41分)

 

第5章 链接

5.1 链接的概念与作用

链接本质:合并相同的“节”

作用:目标代码不能直接执行,要想将目标代码变成可执行程序,还需要进行链接操作。才会生成真正可以执行的可执行程序。链接操作最重要的步骤就是将函数库中相应的代码组合到目标文件中。

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/5/crtbeginT.o -L/usr/lib/gcc/x86_64-linux-gnu/5 hello.o -lc -lgcc -dynamic-linker /lib64/ld-linux-x86-64.so.2  /usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -o hello

                                                          图5-1  Ubuntu下链接的命令

 

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

ELF各段信息如下:

                                                               图5-2  helloELF各段基本信息

 

5.4 hello的虚拟地址空间

使用edb加载hello,下图是在edb中能获取到的数据,和5.3中的对应关系:

                                                                          5-3  hello的虚拟地址空间在edb中的展示

 

5.5 链接的重定位过程分析

hello相对于hello.o有如下不同:

  1. hello.o中的相对偏移地址到了hello中变成了虚拟内存地址
  2. hello中相对hello.o增加了许多的外部链接来的函数。
  3. hello相对hello.o多了很多的节类似于.init,.plt等
  4. hello.o中跳转以及函数调用的地址在hello中都被更换成了虚拟内存地址。

重定位:链接器在完成符号解析以后,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。然后就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时的地址。在hello到hello.o中,首先是重定位节和符号定义,链接器将所有输入到hello中相同类型的节合并为同一类型的新的聚合节。例如,来自所有的输入模块的.data节被全部合并成一个节,这个节成为hello的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每一个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。然后是重定位节中的符号引用,链接器会修改hello中的代码节和数据节中对每一个符号的引用,使得他们指向正确的运行地址。

                                                                           图5-4  hello.ohello的对比图

 

5.6 hello的执行流程

  1. 先是加载程序_init (argc=1, argv=0x7fffffffde38, envp=0x7fffffffde48)
  2. 0x00000000004004d0 in _start ()
  3. 0x0000000000400480 in __libc_start_main@plt ()
  4. 0x0000000000400670 in __libc_csu_init ()
  5. 0x0000000000400430 in _init ()
  6. 0x00000000004005b0 in frame_dummy ()
  7. 0x0000000000400540 in register_tm_clones ()
  8. 0x00000000004005f2 in main ()
  9. 0x0000000000400460 in puts@plt ()
  10. 0x00000000004004a0 in exit@plt ()
  11. 0x0000000000400580 in __do_global_dtors_aux ()
  12. 0x0000000000400500 in deregister_tm_clones ()
  13. 0x00000000004006e4 in _fini ()

注:使用edb由于动态链接库的原因单步调试太久了,所以以上内容是使用gdb得出,以下列出我从edb获取到的一些动态链接的一些内容:

                             图5-5  hello运行过程中执行动态链接库过程

 

5.7 Hello的动态链接分析

在dl_init前后

  1. _GLOBAL_OFFSET_TABLE_的变化

                                 图5-6 _GLOBAL_OFFSET_TABLE_的变化对比图

 

5.8 本章小结

        链接(linking) 是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行千编译时(compile time), 也就是在源代码被翻译成机器代码时;也可以执行千加栽时Cload time), 也就是在程序被加栽器(loader)加载到内存并执行时;甚至执行于运行时(run time), 也就是由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker) 的程序自动执行的。

       链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。

       本章我们对链接的步骤和过程进行了详细的分解和解析,相信在以后处理相关的问题的时候能够不会手忙脚乱。

                                                                                    (第51分)

 

第6章 hello进程管理

6.1 进程的概念与作用

概念:一个执行中程序的实例。

作用:每次用户通过向shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。

 

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

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

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

 

6.3 Hello的fork进程创建过程

        Shell通过调用fork 函数创建一个新的运行的子进程。也就是Hello程序,Hello进程几乎但不完全与Shell相同。Hello进程得到与Shell用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。Hello进程还获得与Shell任何打开文件描述符相同的副本,这就意味着当Shell调用fork 时,Hello可以读写Shell中打开的任何文件。Sehll和Hello进程之间最大的区别在于它们有不同的PID。

                                                  图6-1  Hellofork进程创建进程图

 

6.4 Hello的execve过程

      execve 函数加载并运行可执行目标文件filename, 且带参数列表argv 和环境变量列表envp 。只有当出现错误时,例如找不到filename, execve 才会返回到调用程序。所以,与fork 一次调用返回两次不同, execve 调用一次并从不返回。

                                               图6-2  Helloexecve进程创建进程图

 

6.5 Hello的进程执行

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

 

6.6 hello的异常与信号处理

6.6.1 异常种类

hello执行过程中会出现的异常种类有:

  1. 中断:SIGSTP:挂起程序
  2. 终止:SIGINT:终止程序

6.6.2 命令的运行

1.ps:

           图6-3  ps命令运行截屏

2.jobs:

                            图6-4  jobs命令运行截屏

3.pstree:

                                                图6-5  pstree命令运行截屏

 

4.fg:发送SIGCONT信号继续执行停止的进程

                               图6-6  fg命令运行截屏

 

5.kill -9 pid:发送SIGKILL信号给指定的pid杀死进程

                                图6-6  kill命令运行截屏

 

6.7本章小结

        异常控制流发生在计算机系统的各个层次。比如, 在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。

       本章对异常控制流和信号的了解和认知可以使得我们更加了解异常的相关机制,是我们可以编写异常处理函数处理异常和为程序丰富功能,也可以使得我们在以后编写程序的时候尽量减少异常的发生。

                                                                                                (第61分)

 

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:逻辑地址(LogicalAddress)是指由程序产生的与段相关的偏移地址部分。就是hello.o里面的相对偏移地址。

线性地址:地址空间(address space) 是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间(linear address space) 。就是hello里面的虚拟内存地址。

虚拟地址:CPU 通过生成一个虚拟地址(Virtual Address, VA) 。就是hello里面的虚拟内存地址。

物理地址:用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。计算机系统的主存被组织成一个由M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。就是hello在运行时虚拟内存地址对应的物理地址。

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

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

                                               图7-1  段选择符说明

       索引号是“段描述符(segment descriptor)”的索引,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,每一个段描述符由8个字节组成,如下图:

                                                 图7-2  段选择符说明

        其中Base字段,它描述了一个段的开始位置的线性地址,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中,由段选择符中的T1字段表示选择使用哪个,=0,表示用GDT,=1表示用LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。如下图:

                                                  图7-3  概念关系说明

 

下面是转换的具体步骤:

  1. 给定一个完整的逻辑地址[段选择符:段内偏移地址]。
  2. 看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。可以得到一个数组。
  3. 取出段选择符中前13位,在数组中查找到对应的段描述符,得到Base,也就是基地址。
  4. 线性地址 = Base + offset。

 

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

        线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。这里注意到,这个total_page数组有2^20个成员,每个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。如图:

                                                  图7-4  二级管理模式图

由上图可得:

1.分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。

2.每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中

3.每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位)

依据以下步骤进行转换:

1.从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器)。

2.根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。

3.根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址。

4.将页的起始地址与线性地址中最后12位相加,得到最终我们想要的物理地址。

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

        图7-5给出了Core i7 MMU 如何使用四级的页表来将虚拟地址翻译成物理地址。36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1 提供到一个Ll PET 的偏移量,这个PTE 包含L2 页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推。

                                                图7-5  TLB与四级页表支持下的VA到PA的变换

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

       首先我们要先将高速缓存与地址翻译结合起来,首先是CPU发出一个虚拟地址给TLB里面搜索,如果命中的话就直接先发送到L1cache里面,没有命中的话就先在页表里面找到以后再发送过去,到了L1里面以后,寻找物理地址又要检测是否命中,这里就是使用到我们的CPU的高速缓存机制了,通过这种机制再搭配上TLB就可以使得机器在翻译地址的时候的性能得以充分发挥。

 

                                  图7-6a  三级Cache支持下的物理内存访问示意图

                                       图7-6(b)  三级Cache支持下的物理内存访问示意图

7.6 hello进程fork时的内存映射

      fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID 。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork 在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

 

7.7 hello进程execve时的内存映射

       execve 函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:

  1. 删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
  2. 映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。图7.7 概括了私有区域的不同映射。
  3. 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
  4. 设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

 

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

         在虚拟内存的习惯说法中, DRAM 缓存不命中称为缺页(page fault) 。图7-7 展示了在缺页之前我们的示例页表的状态。CPU 引用了VP 3 中的一个字, VP 3 并未缓存在DRAM 中。地址翻译硬件从内存中读取PTE 3, 从有效位推断出VP 3 未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4 。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4 的页表条目,反映出VP 4 不再缓存在主存中这一事实。

                                                                                      7-7 缺页异常示意图

 

7.9动态存储分配管理

       动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) 。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) 。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。

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

基本方法:这里指的基本方法应该是在合并块的时候使用到的方法,有最佳适配和第二次适配还有首次适配方法,首次适配就是指的是第一次遇到的就直接适配分配,第二次顾名思义就是第二次适配上的,最佳适配就是搜索完以后最佳的方案,当然这种的会在搜索速度上大有降低。

策略:这里的策略指的就是显式的链表的方式分配还是隐式的标签引脚的方式分配还是分离适配,带边界标签的隐式空闲链表分配器允许在常数时间内进行对前面块的合并。这种思想是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。显式空间链表就是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继指针,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果找到了一个,那么就(可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。

 

7.10本章小结

         存储器系统(memory system) 是一个具有不同容量、成本和访问时间的存储设备的层次结构。CPU 寄存器保存着最常用的数据。靠近CPU 的小的、快速的高速缓存存储器(cache memory) 作为一部分存储在相对慢速的主存储器(ma in memory) 中数据和指令的缓冲区域。主存缓存存储在容量较大的、慢速磁盘上的数据,而这些磁盘常常又作为存储在通过网络连接的其他机器的磁盘或磁带上的数据的缓冲区域。存储器层次结构是可行的,这是因为与下一个更低层次的存储设备相比来说,一个编写良好的程序倾向千更频繁地访问某一个层次上的存储设备。所以,下一层的存储设备可以更慢速一点,也因此可以更大,每个比特位更便宜。整体效果是一个大的存储器池,其成本与层次结构底层最便宜的存储设备相当,但是却以接近于层次结构顶部存储设备的高速率向程序提供数据。作为一个程序员,你需要理解存储器层次结构,因为它对应用程序的性能有着巨大的影响。如果你的程序需要的数据是存储在CPU 寄存器中的,那么在指令的执行期间,在0个周期内就能访问到它们。如果存储在高速缓存中,需要4~7 5 个周期。如果存储在主存中,需要上百个周期。而如果存储在磁盘上,需要大约几千万个周期。

       这里就是计算机系统中一个基本而持久的思想:如果理解了系统是如何将数据在存储器层次结构中上上下下移动的,那么就可以编写自己的应用程序,使得它们的数据项存储在层次结构中较高的地方,在那里CPU 能更快地访问到它们。

        本章通过对hello在储存结构,高速缓存,虚拟内存涉及到的方面进行了详细的探索,通过对这些结构的了解我们可以以后编写一些对高速缓存友好的代码,或者说运行速度更快的代码,对我们来说都是受益匪浅。

                                                                                     (第7 2分)

 

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

         所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。

 

8.2 简述Unix IO接口及其函数

Unix I/O接口:

1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。

2.Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。

3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为K 。

4.读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。

5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

Unix I/O函数:

1.进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件的:

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

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

返回:若成功则为新文件描述符,若出错为-1。

2.进程通过调用close 函数关闭一个打开的文件。

int close(int fd);

返回:若成功则为0, 若出错则为-1。

3.应用程序是通过分别调用read 和write 函数来执行输入和输出的。

ssize_t read(int fd, void *buf, size_t n);

read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。

返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。

ssize_t write(int fd, const void *buf, size_t n);

write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。图10-3 展示了一个程序使用read 和write 调用一次一个字节地从标准输入复制到标准输出。

返回:若成功则为写的字节数,若出错则为-1。

 

8.3 printf的实现分析

先来分析一下printf函数:

static int printf(const char *fmt, ...)
{
     va_list args;
     int i;
   
     va_start(args, fmt);
     write(1,printbuf,i=vsprintf(printbuf, fmt, args));
     va_end(args);
     return i;
}

参数中明显采用了可变参数的定义,可以看到*fmt是一个char 类型的指针,指向字符串的起始位置。

再看一系列的va函数:

va_list arg_ptr;

void va_start( va_list arg_ptr, prev_param );
type va_arg( va_list arg_ptr, type );
void va_end( va_list arg_ptr );

       首先在函数里定义一个va_list型的变量,这里是arg_ptr,这个变量是指向参数的指针。然后使用va_start使arg_ptr指针指向prev_param的下一位,然后使用va_args取出从arg_ptr开始的type类型长度的数据,并返回这个数据,最后使用va_end结束可变参数的获取。

再来看vsprintf函数:

int vsprintf(char *buf, const char *fmt, va_list args)
{
    int len;
    int i;
    char * str;
    char *s;
    int *ip;
    int flags;            /* flags to number() */
    int field_width;      /* width of output field */
    int precision;        /* min. # of digits for integers; max number of chars for from string */
    int qualifier;        /* 'h', 'l', or 'L' for integer fields */

    for (str=buf ; *fmt ; ++fmt) {
        if (*fmt != '%') {                       
            *str++ = *fmt;            
            continue;
        }

        /* process flags */
        flags = 0;

        repeat:
            ++fmt;        /* this also skips first '%' */                         
            switch (*fmt) {
                case '-': flags |= LEFT; goto repeat;
                case '+': flags |= PLUS; goto repeat;
                case ' ': flags |= SPACE; goto repeat;                      
                case '#': flags |= SPECIAL; goto repeat;
                case '0': flags |= ZEROPAD; goto repeat;
                }

        /* get field width */
        field_width = -1;

        if (is_digit(*fmt))
            field_width = skip_atoi(&fmt);
        else if (*fmt == '*') {
            /* it's the next argument */
            field_width = va_arg(args, int);
            if (field_width < 0) {
                field_width = -field_width;
                flags |= LEFT;
            }
        }

        /* get the precision */
        precision = -1;

        if (*fmt == '.') {
            ++fmt;  
            if (is_digit(*fmt))
                precision = skip_atoi(&fmt);
            else if (*fmt == '*') {
                /* it's the next argument */
                precision = va_arg(args, int);
            }
            if (precision < 0)
                precision = 0;
        }

        /* get the conversion qualifier */
        qualifier = -1;

        if (*fmt == 'h' || *fmt == 'l' || *fmt == 'L') {
            qualifier = *fmt;
            ++fmt;
        }

        switch (*fmt) {   
            case 'c':
            if (!(flags & LEFT))
                while (--field_width > 0)
                    *str++ = ' ';
            *str++ = (unsigned char) va_arg(args, int);
            while (--field_width > 0)
                *str++ = ' ';
            break;

            case 's':
            s = va_arg(args, char *);
            len = strlen(s);
            if (precision < 0)
                precision = len;
            else if (len > precision)
                len = precision;
            if (!(flags & LEFT))
                while (len < field_width--)
                    *str++ = ' ';
            for (i = 0; i < len; ++i)
                *str++ = *s++;
            while (len < field_width--)
                *str++ = ' ';
            break;

            case 'o':
            str = number(str, va_arg(args, unsigned long), 8,
                field_width, precision, flags);
            break;
                 case 'p':
                     if (field_width == -1) {
                         field_width = 8;
                         flags |= ZEROPAD;
                     }
                     str = number(str,
                         (unsigned long) va_arg(args, void *), 16,
                         field_width, precision, flags);
                     break;

                 case 'x':
                     flags |= SMALL;

                 case 'X':
                     str = number(str, va_arg(args, unsigned long), 16,
                         field_width, precision, flags);
                     break;

            case 'd':                                  
                 case 'i':
                     flags |= SIGN;

                 case 'u':
                     str = number(str, va_arg(args, unsigned long), 10,
                         field_width, precision, flags);
                     break;

                 case 'n':
                     ip = va_arg(args, int *);
                     *ip = (str - buf);
                     break;

                 default:
                     if (*fmt != '%')
                         *str++ = '%';
                     if (*fmt)
                         *str++ = *fmt;
                     else
                         --fmt;
                     break;
                 }
             }

             *str = '\0';

             return str-buf;

}

  从代码中不难看出这个函数的作用是把后面的参数加到字符串里面然后输出字符串的长度。

  最后就是write函数了,我们先来看一下write的汇编代码:

write:
     mov eax, _NR_write
     mov ebx, [esp + 4]
     mov ecx, [esp + 8]
     int INT_VECTOR_SYS_CALL

      通过查询资料可以知道int INT_VECTOR_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

       可以看出代码里面的call是访问字库模板并且获取每一个点的RGB信息最后放入到eax也就是输出返回的应该是显示vram的值,然后系统显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

 

8.4 getchar的实现分析

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

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

 

8.5本章小结

       输入/输出(I/O) 是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从I/O 设备复制数据到主存,而输出操作是从主存复制数据到I/O 设备。所有语言的运行时系统都提供执行I/O 的较高级别的工具。例如, ANSI C 提供标准I/O 库,包含像printf 和scanf 这样执行带缓冲区的I/O函数。C++ 语言用它的重载操作符<<(输入)和>>(输出)提供了类似的功能。在Linux 系统中,是通过使用由内核提供的系统级Unix I/O 函数来实现这些较高级别的I/O 函数的。

      了解Unix I/O 将帮助我们理解其他的系统概念。I/O 是系统操作不可或缺的一部分,因此,我们经常遇到I/O 和其他系统概念之间的循环依赖。例如, I/O 在进程的创建和执行中扮演着关键的角色。反过来,进程创建又在不同进程间的文件共享中扮演着关键角色。因此,要真正理解I/O, 你必须理解进程,反之亦然。在对存储器层次结构、链接和加载、进程以及虚拟内存的讨论中,我们已经接触了I/O 的某些方面。既然你对这些概念有了比较好的理解,我们就能闭合这个循环,更加深入地研究I/O 。有时你除了使用Unix I/O 以外别无选择。在某些重要的情况中,使用高级I/O 函数不太可能,或者不太合适。例如,标准I/O 库没有提供读取文件元数据的方式,例如文件大小或文件创建时间。

      本章我们就hello里面的函数对应unix的I/O来细致地分析了一下I/O对接口以及操作方法,这有助于我们以后在写函数的时候在标准I/O 库没有的时候我们可以编写自己的I/O函数。

                                                                                 (第81分)

 

结论

用计算机系统的语言,逐条总结hello所经历的过程。

  1. hello.c诞生在键盘鼠标等I/O设备下,像宝宝一样通过文件的方式储存在主存里面。
  2. 预处理器将宝宝hello.c预处理成为幼年文本文件hello.i
  3. 编译器将幼年hello.i翻译成青少年汇编语言hello.s
  4. 汇编器将hello.s汇编成青年可重定位二进制代码hello.o
  5. 链接器将外部文件和hello.o合体成为成年可执行二进制文件hello
  6. shell通过execve创建了一个岗位(新进程)并把hello完好无损地安排进去
  7. hello在新岗位时,shell帮hello把hello的想要的东西都配置好(创建新的内存区域,并创建一组新的代码、数据、堆和栈段。并安排好内容),就等着hello在这个岗位上面大展身手
  8. 和我们一样,hello在执行的过程中一样也会遇到异常和信号以及命令,因为hello很听话,所以都会认真执行
  9. 在工作的过程中hello总会发出一些技巧听不懂的语言(VA),不过机器请了翻译员翻译了过来,hello做的越来越好
  10. hello不辞辛苦终于做完了工作,要退休了,shell有始有终的帮hello安排好养老计划,把hello从公司名单里面删除掉,然后hello就消失了。

 

       我对计算机系统的设计实现的深切感悟就是,计算机系统是一个平衡求稳的一个系统,考虑到了很多的现实问题,在硬件条件有限的情况下尽可能地发挥出来计算机最大的性能,在CS:APP里面让我印象很深的一句话就是内存和厨房里的垃圾桶一样,不管你用不用都是稀缺资源,从中足以可见计算机系统设计人员在其中投入了多少的精力和脑力才能设计出如此精妙完善的一个系统来。我的创新理念就是我觉得在设计计算机系统的时候很多东西其实是没有理论可以说明的,可以往概率的方向发展,利用概率来设置一些策略,比如说malloc里面的适配,有首次适配,有第二次适配,会不会有更多次的适配会有更好的结果呢,我想通过超级计算机加上软件方面的实现可以实现这一方面的设想。

                                                            (结论0分,缺少 -1分,根据内容酌情加分)

 

附件

hello.c

hello的源代码

hello.i

预处理后的hello.c的代码

hello.s

hello.i编译后的代码

hello.o

hello.s汇编后的代码

hello

hello.o链接后的代码

asm.txt

hello.o的反汇编文件

asmelf.txt

hello的反汇编文件

                                                                                 (附件0分,缺失 -1分)

 

参考文献

[1]  C语言再学习 -- GCC编译过程:https://blog.csdn.net/qq_29350001/article/details/53339861

[2] 深入理解计算机系统(1.1)------Hello World 是如何运行的:http://www.cnblogs.com/ysocean/p/7497468.html

[3]  深入理解计算机系统(3.1)------汇编语言和机器语言:https://www.cnblogs.com/ysocean/p/7580162.html

[4]   百度百科:机器语言:https://baike.baidu.com/item/%E6%9C%BA%E5%99%A8%E8%AF%AD%E8%A8%80/2019225?fr=aladdin

[5]  LINUX 逻辑地址、线性地址、物理地址和虚拟地址 转:https://www.cnblogs.com/zengkefu/p/5452792.html

[6]   Linux内核中的printf实现https://blog.csdn.net/u012158332/article/details/78675427

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

                                                                        (参考文献0分,缺失 -1分)

 

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值