HIT CSAPP 2019数据科学辅修大作业程序人生-Hello’s P2P From Program to Process

计算机系统基础

大作业

题 目 程序人生-Hello’s
P2P

专 业 数据科学与大数据技术辅修

计算机科学与技术学院

2019年3月

摘 要

hello.c只是一个短短十几行的程序文件,所谓麻雀虽小五脏俱全,hello.c文件包含了头文件,各个函数,各个参数,各个变量。我们可以从预处理到编译,到汇编到链接的hello.i,hello.s,hello.o,hello可执行目标文件以及一些重定位文件中找到这些内容出现的身影。尽管小并且简单,它在电脑的内存中也占有一席之地。还要有进程管理和设备管理为它安排……

关键词:hello;预处理;编译;链接;内存;进程;管理

(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)

目 录

第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:From Program to Process

用高级语言编写得到.c文件,再经过编译器预处理得到.i文件,进而对其进行编译得到.s汇编语言文件。此后通过汇编器将.s文件翻译成机器语言,将指令打包成可重定位的.o目标文件,再通过链接器与库函数链接得到可执行文件hello,执行此文件,操作系统会为其fork产生子进程,再调用execve函数加载进程。至此,P2P结束。

020:From Zero-0 to Zero-0

操作系统调用execve后映射虚拟内存,先删除当前虚拟地址的数据结构并未hello创建新的区域结构,进入程序入口后载入物理内存,再进入main函数执行代码。执行完成后,父进程回收hello进程,内核删除相关数据结构。

1.2 环境与工具

Intel i7

Ubuntu64位

Edb

Window10家庭版

VMware Workstation Pro

gcc ld readelf gedit objdump
edb hexedit

1.3 中间结果

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

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

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

Hello:.o经过链接生成的可执行目标文件

1.4 本章小结

本章主要介绍了P2P、020的过程并列出实验基本信息

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

预处理又称预编译,预处理指的是在程序编译之前,根据以字符#开头的命令(即头文件/define/ifdef之类的),例如本程序就有三个头文件:stdio.h,unistd.h,stdlib.h

预处理的作用:

  1. 执行源文件包含。#include 指令告诉预处理器(cpp)读取源程序所引用 的系统源文件,并把源文件直接插入程序文本中。

  2. 执行宏替换。用实际值替换用#define 定义的字符串

  3. 条件编译。根据#if 后面的条件决定需要编译的代码 , #endif 是#if, #ifdef, #ifndef 这些条件命令的结束标志。

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

打开hello.i之后发现,整个hello.i程序已经拓展为3188行,main函数前被插入了大量代码,这些都是头文件文件

2.4 本章小结

本阶段完成了对hello.c的预处理工作。介绍了预处理的定义与作用、并结合预处理之后的程序对预处理结果进行了解析。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

概念:把预处理完的文件进行一系列语法分析及优化后生成相应的汇编文件

作用:将main.i翻译成一个汇编语言文件main.s

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.3.0文件声明解析

声明 含义

.file 源文件

.text 代码段

.data 数据段(存储已初始化的全局和静态C变量)

.align 对齐格式

.type 符号类型

.size 数据空间大小

.section.rodata 只读代码段

.global 全局变量

.string 字符串类型数据

.long 长整型数据

3.3.1数据

hello.s 中用到的 C 数据类型:整数类型、字符串、数组.

整数类型:

hello.c中的整数类型有全局变量int sleepsecs,main的参数int argc,局部变量int i。

hello.c 在 main 函数之前先定义了一个整型全局变量 sleepsecs,并赋予了初值:

经过编译器处理, hello.s文件先声明了 sleepsecs 这个全局变量,然 后将 sleepsecs 存放在.data 节,可以看到第7行为其分配大小4字节,第9行为其赋初值2。

参数int argc,局部变量i出现在main的栈帧中,它们没有标识符,也不需要被声明,而是直接使用。int i 是循环中用来计数的局部变量,,argc 是从终端输入的参数的个数,也是 main 函数的第一个参数。编译器一般会将局部变量存储在寄存器或者程序栈中,可以看出 hello.s 将 i 存储在-4(%rbp)中,初始值为 0,每次循环加一,退出循环条件是 i 大于 7

argc
作为第一个参数,由寄存器%edi
保存,然后又被存入-20(%rbp)

字符串:

两个printf语句中的格式字符串出现在.rodata段。

第一个字符串.LC0 包含汉字,每 个汉字在 utf-8 编码中被编码为三个字节,第二个字符串的两个%s 为用户 在终端输入的两个参数。

数组:

作为main参数的char
*argv[]则出现在栈帧中。

3.3.2运算与操作

赋值操作

hello.c 的赋值操作有两处,一是将全局变量 sleepsecs 赋值为 2.5,二是在循 环开始时将 i 赋值为 0。源程序对i赋值为零的操作使用mov语句实现的。

比较操作

数组操作

argv[1]:首先从-32(%rbp)读取argv地址存入rax,接下来rax增加8个字节,此时rax中存放的是&(argv[1]),读取此地址指向的argv[1]放入rax,最后存入rsi。

argv[2]:首先从-32(%rbp)读取argv地址存入rax,接下来rax增加16个字节,此时rax中存放的是&(argv[2]),读取此地址指向的argv[2]放入rdx。

3.3.3控制转移

控制转移往往与关系操作配合进行,如果满足某个条件,则跳转至某个位置。

对于 hello.c 中的 if(argc!=3),编译器处理为了先判断 argc 和 3 是否相等,若 相等,则跳转至.L2,否则继续执行之后的内容。而对于 for 循环中的 i<10, 编译器则会判断 i 是否小于等于 7,若是,则继续执行循环体内的内容,否则

3.3.4函数调用

main 函数

main 函数开始时被存在.text 节,标记类型为函数,程序运行时,将由系统 启动函数调用,因此 main 函数是 hello.c 的起点。main 函数的两个参数分 别为 argc 和 argv[],由命令行输入,存储在%rdi 和%rsi 中。

printf 函数

printf的调用,参数被存放在寄存器传递。以printf(“Hello %s
%s\n”,argv[1],argv[2]);为例,格式化字符串被存放在edi传递,argv[1]被放在rsi,argv[2]被放在rdx。使用call来调用printf,而printf的返回值则会被存入eax返回。

exit 函数

exit函数的调用,参数被存放在edi传递,然后使用call调用exit。

sleep 函数

对sleep的调用,参数被存放在edi传递,然后使用call调用sleep。

getchar 函数

对getchar的调用直接使用了call。

main函数的返回值放在eax传递

3.4 本章小结

在本章中,编译器将高级语言编译成汇编语言,在以上的分析过程中,详细的分析了编译器是怎么处理C语言的各个数据类型以及各类操作的,按照不同的数据类型和操作格式,解释了hello.c文件与hello.s文件间的映射关系。在此阶段,编译器将hello.i文件编译成更抽象更低级的hello.s汇编语言文件,为汇编阶段产生机器可识别的机器语言指令打下基础。

(第3章2分)

第4章 汇编

4.1 汇编的概念与作用

概念:通过汇编器,把汇编语言翻译成机器语言

作用:通过汇编这个过程,把汇编代码转化成了计算机完全能够理解的机器代码,这个代码也是我们程序在计算机中表示。

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

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

名称

作用

ELF头

描述了生成该文件的系统的大小和字节顺序以及帮助链接器语法分析和解释目标文件的信息

.text

已编译的程序的机器代码

.rodata

只读数据

.data

已初始化的全局和静态C变量

.bss

未初始化的全局和静态C变量

.symtab

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

.rel.text

.text节的重定位记录表

.rel.data

被模块引用或定义的所有全局变量的重定位信息

.debug

一个调试符号表

.line

原始C源程序的行号和.text节中机器指令之间的映射

.strtab

一个字符串表

节头部表

每个节的偏移量大小

查看文件头信息

查看节头表

查看符号表信息

4.4 Hello.o的结果解析

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

主要区别及原因:


分支转移:反汇编代码跳转指令的操作数使用的不是段名称如.L3,而段名称只是在汇编语言中便于编写的助记符,在汇编成机器语言之后变成了确定的地址。


函数调用:在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待静态链接的进一步确定。


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

4.5 本章小结

本章通过对汇编后产生的 hello.o 的可重定位的 ELF 格式的考察、对重定位项目的举例分析以及对反汇编文件与 hello.s 的对比,从原理层次了解了汇编这一过程实现的变化。

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

(第4章1分)

第5章 链接

5.1 链接的概念与作用

链接的概念:

链接是指在电子计算机程序的各模块之间传递参数和控制命令,并把它们组成一个可执行的整体的过程。总之是把多个文件拼接合并成一个可执行文件。

链接的作用:

链接可以在编译、汇编、加载和运行时执行。链接方便了模块化编程。

5.2 在Ubuntu下链接的命令

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

在 ELF 格式文件中,Section Headers 对 hello 中所有的节信息进行了声明,其 中包括大小 Size 以及在程序中的偏移量
Offset,因此根据 Section
Headers 中的信 息我们就可以用 HexEdit 定位各个节所占的区间(起始位置,大小)。其中 Address 是程序被载入到虚拟地址的起始地址。

5.4 hello的虚拟地址空间

用 edb 打开 hello,可以在 Data Dump 窗口看见 hello 加载到虚拟地址中的状况,程序的虚拟地址空间为
0x00000000004000000-0x0000000000401000,如下图:

查看 .elf 中的程序头部分

elf 里面的 Program Headers:

PHDR:程序头表

INTERP:程序执行前需要调用的解释器

LOAD:程序目标代码和常量信息

DYNAMIC:动态链接器所使用的信息

NOTE::辅助信息

GNU_EH_FRAME:保存异常信息

GNU_STACK:使用系统栈所需要的权限信息

GNU_RELRO:保存在重定位之后只读信息的位置

5.5 链接的重定位过程分析

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

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

与 hello.o 反汇编文本 hello.objdump 相比,在 hello.objdump 中多了许多节

节名称 描述

.interp 保存ld.so的路径

.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. Each entry

in this
section is represented by single

CFI

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

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

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

.data 初始化了的数据

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

符串

函数调用

hello.o 反汇编文件中,call 地址后为占位符(4 个字节的 0);而 hello 在生成过程中使用了动态链接共享库,函数调用时用到了延时绑定机制。以 puts 为例,简述其链接过程:

puts第一次被调用时程序从过程链接表PLT中进入其对应的条目;

第一条PLT指令通过全局偏移量表GOT中对应条目进行间接跳转,初始时每个GOT条目都指向它对应的PLT条目的第二条指令,这个简单跳转把控制传送回对应PLT条目的下一条指令

把puts函数压入栈中之后,对应PLT条目调回PLT[0];

PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]间接跳转进动态链接器中。动态链接器使用两个栈条目确定puts的运行时位置,用这个地址重写puts对应的GOT条目,再把控制传回给puts。

第二次调用的过程:

puts被调用时程序从过程链表PLT中进入对应的条目;

通过对应GOT条目的间接跳转直接会将控制转移到puts。

数据访问 hello.o 反汇编文件中,对. rodata 中 printf 的格式串的访问需要通过链接时重定位的绝对引用确定地址,因此在汇编代码相应位置仍为占位符表示,对. data 中已初始化的全局变量 sleepsecs 为 0x0+%rip 的方式访问;而 hello 反汇编文件中对应全局变量已通过重定位绝对引用被替换为固定地址。

5.6 hello的执行流程

加载程序 ld-2.27.so!_dl_start

ld-2.27.so!_dl_init

加载hello hello!_start

libc-2.27.so!__libc_start_main

-libc-2.27.so!__cxa_atexit

-libc-2.27.so!__libc_csu_init

Hello初始化 hello!_init

libc-2.27.so!_setjmp

-libc-2.27.so!_sigsetjmp

–libc-2.27.so!__sigjmp_save

调用main函数(运行) hello!main

调用打印函数 hello!puts@plt

调用退出函数 hello!exit@plt

ld-2.27.so!_dl_runtime_resolve_xsave

-ld-2.27.so!_dl_fixup

–ld-2.27.so!_dl_lookup_symbol_x

退出程序 libc-2.27.so!exit

5.7 Hello的动态链接分析

无论在内存中的何处加载一个目标模块(包括共享目标模块),数据段与代码段的距离总是保持不变。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。

而要生成对全局变量PIC引用的编译器利用了这个事实,它在数据段开始的地方创建了一个表,叫做全局偏移量表(GOT)。在GOT中,每个被这个目标模块引用的全局数据目标(过程或全局变量)都有一个8字节条目。编译器还为GOT中每个条目生成 一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。

hello中对.got的初始化是由_dl_start函数执行的。下面的四张图片反应了这一过程:

.got
_dl_start执行前

.got.plt
_dl_start执行前

.got
_dl_start执行后

.got.plt
_dl_start执行后

hello要调取由共享库定义的函数puts,printf,而程序调用一个由共享库定义的函数,编译器没有办法预测这个函数的运行地址,因为定义它的共享模块在运行时可以加载到任何位置。为了解决这个问题,GNU编译系统使用了延迟绑定技术:

当hello尝试调用puts时,不直接调用puts,而是调用进入puts对应的PLT条目。这个条目会尝试利用GOT项进行间接跳转。

第一次被调用时,GOT项的值为PLT条目中的下一条指令地址,因而接下来会跳回PLT条目,在把puts的ID 0压入栈后,会转到PLT[0]的位置,PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]跳转进动态链接器中。动态链接器使用两个栈条目来确定puts的运行时位置,用这个地址重写puts的GOT项,再把控制传递给puts

在下一次执行到puts对应的PLT条目时,GOT项已经被修改,因此利用GOT项进行的间接跳转会直接跳转到puts函数

5.8 本章小结

概述了链接的概念和作用,hello程序的ELF格式,重点分析了hello程序的虚拟地址空间、重定位和执行过程。简述了动态链接的原理。

(第5章1分)

第6章 hello进程管理

6.1 进程的概念与作用

概念:进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量、以及打开文件描述符的集合。

作用:进程给应用程序提供的关键抽象有两种:

a) 一个独立的逻辑控制流,提供一个假象,程序独占地使用处理器。

b) 一个私有的地址空间,提供一个假象,程序在独占地使用系统内存。

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

Shell的作用:Shell是一个用C语言编写的程序,他是用户使用 Linux 的桥梁。Shell既是一种命令语言,又是一种程序设计语言,Shell 是指一种应用程序。Shell

应用程序提供了一个界面,用户通过这个界面访问操作系统内核的服务。

处理流程:

1)从终端读入输入的命令。

2)将输入字符串切分获得所有的参数

3)如果是内置命令则立即执行

4)否则调用相应的程序执行

5)shell应该接受键盘输入信号,并对这些信号进行相应处理

6.3 Hello的fork进程创建过程

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

fork 后调用一次返回两次,在父进程中 fork 会返回子进程的 PID,在子进程中 fork 会返回 0;父进程与子进程是并发运行的独立进程。内核能够以任何方式交替执行他们逻辑控制流中的指令。

hello 的 fork 进程创建过程为:系统进程创建
hello 子进程然后调用
waitpid() 函数知道 hello 子进程结束

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序。函数声明如下:

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

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

6.5 Hello的进程执行

逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。

用户模式和内核模式:处理器通过用某个控制寄存器中的一个模式位来限制一个应用可以执行的指令和它可以访问的地址空间范围。当设置了模式位时,进程就运行着在内核模式,可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。

用户模式与内核模式之间的转换方法:运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过中断,故障或陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将用户模式变为内核模式,处理程序运行在内核模式中,当它返回到应用程序代码时,处理前就把模式从内核模式改为用户模式。

上下文信息:内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。并通过上下文切换的机制来将控制转移到新的进程。

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

Hello的进程调度过程:hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,计时器开始计时,内核通过上下文切换将当前进程将当前进程的控制权交给其他进程。当sleep函数时间到达时发送一个中断信号,此时进入内核状态执行中断处理,然后内核将进程控制权交还给hello进程,hello进程继续执行自己的控制逻辑流。

6.6 hello的异常与信号处理

Ctrl-Z:

按下Ctrl-Z之后,shell收到SIGTSTP信号,将hello进程暂停,通过ps命令可以看到hello进程没有被回收,此时他的后台job号是1,调用fg 1将其调到前台,此时shell程序首先打印hello的命令行命令,hello继续运行

Ctrl-C:

按下Ctrl-C之后,shell收到信号SIGINT,然后结束进程hello。进程hello结束并且被回收。

乱按:

再hello执行过程中键入的信息,都会在hello的结尾处被getchar()函数读入,然后输出到终端。当键入的命令中有\n()回车时,前一段字符会作为命令行内容被执行。

6.7本章小结

进程就是一个执行中的程序。他有独立的逻辑控制流和私有的地址空间。

在Shell中输入命令之后,shell就解析命令。

当其解析到输入的命令是需要执行程序hello时,shell调用fork函数创建一个子进程,并且在子进程中调用execve函数加载程序hello。

Hello加载完毕之后开始执行,在hello执行的过程中,内核会同时执行其他的进程,即hello与许多进程并发执行。Hello执行一定时间之后,内核调度其他进程执行。Hello也会调用sleep函数显式地要求内核将其挂起一段时间。

在hello执行时收到信号会触发相应地异常处理代码。

(第6章1分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:程序代码经过编译后出现在汇编程序中地址。

线性地址:逻辑地址经过段机制转化后为线性地址,用于描述程序分页信息
的地址。以 hello 为例,线性地址就是 hello 应该在内存的哪些块上运行。

虚拟地址:同线性地址。

物理地址:处理器通过地址总线的寻址,找到真实的物理内存对应地址。是
内存单元的真实地址。以 hello 为例,物理地址就是 hello 真正应该在内存的哪些 地址上运行。

Hello的地址转换:程序hello中将一个虚拟内存空间中的地址转换为物理地址,需要进行两步:首先将给定一个逻辑地址,CPU要利用其段式内存管理单元,先将为个逻辑地址转换成一个线程地址,再利用其页式内存管理单元,转换为最终物理地址。

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

分段功能在实模式和保护模式下有所不同。

实模式:逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。

保护模式:线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。

段寄存器用于存放段选择符,通过段选择符可以得到对应段的首地址。处理器在通过段式管理寻址时,首先通过段描述符得到段基址,然后与偏移量结合得到线性地址,从而得到了虚拟地址。

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

将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。

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

为减少时间开销,MMU中存在一个关于PTE的缓存,成为翻译后备缓冲器TLB。其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。

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

1、CPU给出VA

2、MMU用VPN到TLB中找寻PTE,若命中,得到PA;若不命中,利用VPN(多级页表机制)到内存中找到对应的物理页面,得到PA。

3、PA分成PPN和PPO两部分。利用其中的PPO,将其分成CI和CO,CI作为cache组索引,CO作为块偏移,PPN作为tag。

先访问一级缓存,不命中时访问二级缓存,再不命中访问三级缓存,再不命中访问主存,如果主存缺页则访问硬盘

7.6 hello进程fork时的内存映射

Fork函数被调用是内核为hello新进程创建虚拟内存和各种数据结构并为其分配唯一的PID。为进行以上操作,还创建了当前进程的mm_struct、区域结构和样表的原样副本。Shell将两个进程中每个页面都标为只读,并将每个进程中的每个区域结构标记为写时复制。

7.7 hello进程execve时的内存映射

删除已存在的用户区域。

映射私有区域:为新程序的代码、数据、.bss和栈区域创建新的区域结构。

映射共享区:hello与系统执行文件链接映射到共享区域。

设置程序计数器PC:设置当前进程上下文中的PC,指向代码区域的入口点。

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

缺页故障是一种常见的故障,要访问的主页不在主存,需要操作系统调入才能访问。缺页中断处理函数为do_page_fault函数。

7.9动态存储分配管理

printf会调用malloc,接下来提一下动态内存分配的基本原理。

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。分配器将堆视为一组不同大小的块的集合来维护,每个块就是一个连续的需内存片,要么是已分配的,要么是空闲的。已分配的块显示地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显示地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显示执行的,要么是内存分配器自身隐式执行的。

两种堆的数据结构组织形式:

带标签的隐式空闲链表

带标签的隐式空闲链表的数据组织方式如下图:

空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。

显式空闲链表

显式空闲链表将链表的指针存放在空闲块的主体里面。堆被组织成一个双向空闲链表,在每个空闲块中,都包含一个pred和succ指针,如下图所示:

7.10本章小结

现代操作系统多采用虚拟内存系统,访存时地址需要从逻辑地址翻译到虚拟地址并进一步翻译成物理地址。

操作系统通过地址的页式管理来实现对磁盘的缓存、内存管理、内存保护等功能。

虚拟内存为便捷的加载、进程管理提供了可能。

程序运行过程中往往涉及动态内存分配,动态内存分配通过动态内存分配器完成。

(第7章 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

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

设备管理:unix io接口

8.2 简述Unix
IO接口及其函数

Linux以文件的方式对I/O设备进行读写,将设备均映射为文件。对文件的操作,内核提供了一种简单、低级的应用接口,即Unix I/O接口。

打开文件:int open(char *filename, int flags, mode_t mode);

关闭文件:int close(int fd);

读文件:ssize_t read(int fd, void *buf, size_t n);

写文件:ssize_t write(int fd, const void *buf, size_t n);

8.3 printf的实现分析

printf()函数的源代码如下:(来源:Linux libc6)

int

__printf (const char
*format, …)

{

va_list arg;

int done;

va_start (arg, format);

done = vfprintf (stdout, format, arg);

va_end (arg);

return done;

}

可以看到,printf()函数将变长参数的指针arg作为参数,传给vfprintf函数。然后vfprintf函数解析格式化字符串,调用write()函数。

下面用一个函数说明格式化的过程。

Vsprintf的示意函数(仅格式化16进制字符)如下

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); 

}

从代码中可以得知,函数将格式化字符串与待填入字符相结合,从而产生真正的输出字符。库中真正的输出包含更多功能。

得到输出字符之后调用系统函数wirte():

write:

 mov eax, _NR_write 

 mov ebx, [esp + 4] 

 mov ecx, [esp + 8] 

 int INT_VECTOR_SYS_CALL 

这里制造了几个参数,然后进行sys_call。

syscall将字符串中的字节“Hello 1170801219 yangjin”从寄存器中通过总线复制到显卡的显存中,显存中存储着字符的ASCII码。

字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。

显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。然后字符就显示在了屏幕上。

8.4 getchar的实现分析

getchar有一个int型的返回值.当程序调用getchar时.程序就等着用户按键.用户输入的字符被存放在键盘缓冲区中.直到用户按回车为止(回车字符也放在缓冲区中).当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符.getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕.如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。

主要的就是与缓冲区的联系。

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

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

8.5本章小结

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

同时,本章中我们就hello里面的函数对应unix的I/O细致地分析了一下I/O对接口以及操作方法,prinf与getchar函数是Unix I/O的封装,而真正调用的是write和read这样的系统调用函数,而它们又都是由内核完成的,之所以键盘能输入是因为引发了异步异常,在屏幕上会有显示是因为字符串被复制到了屏幕赖以显示的显存当中。

(第8章1分)

结论

Hello的一生就此结束,以下为hello一生中的节点:

1预处理:gcc执行hello.c中的预处理命令,合并库,宏展开、

2编译,将hello.i编译成为汇编文件hello.s

3汇编,将hello.s会变成为可重定位目标文件hello.o

4链接,将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello

5运行:在shell中输入./hello1170801219 yangjin开始运行程序

6创建子进程:shell进程调用fork为其创建子进程,分配pid。

7运行程序:子进程shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数。

8执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流

9访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。

10动态申请内存:调用malloc向动态内存分配器申请堆中的内存。

11信号:运行途中键入ctr-c ctr-z则调用shell的信号处理函数分别停止、挂起。

12结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。

计算机中所有的程序的运行都会经历hello经历的一部分。在hello的一生中,我们可以看到计算机内部工作的严谨与精密。所有的函数、指令都一环扣一环,任何一个环节出错都将导致程序运行出错。

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

附件

Hello 可执行文件

hello.c 源文件

hello.i 预处理.i文件

hello.o hello.s经翻译成机器指令后打包成的可重定位目标文件

hello.objdump 反汇编文件

hello.s 汇编文件

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

参考文献

[1] Randal E.Bryant, David R.O’Hallaron.深入理解计算机系统(原书第三版). 北京:机械工业出版社,2016.7.

[2] printf函数实现:https://www.cnblogs.com/pianist/p/3315801.html

[3] Robert Love. Linux内核设计与实现(原书第3版). 北京:机械工业出版社,2011-4-30.

[4] 博韦,西斯特(美). 深入理解LINUX内核(第三版). 北京:中国电力出版社,2007-10-01.

[5] 内存管理:http://www.cnblogs.com/edisonchou/p/5115242.html

[6] sleep函数: https://www.ibm.com/support/knowledgecenter/zh/SSMKHH_10.0.0

/com.ibm.etools.mft.doc/bk52030_.htm

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

你可能感兴趣的:(HIT CSAPP 2019数据科学辅修大作业程序人生-Hello’s P2P From Program to Process)