** HIT 2018 CS:APP程序人生-Hello’s P2P**
摘 要
本论文旨在通过研究hello.c在linux系统下的整个生命周期。通过对hello.c的概述;hello.c的预处理;编译;汇编;链接;一直到hello.c的进程;存储;IO管理。了解hello的一生。通过使用linux操作系统,对hello.c进行执行,用gcc等工具进行实验。对深入理解计算机系统这本书进行总结与贯通。让程序员了解整个程序的周期,使学生对于课程的理解以及知识的升华有很大帮助。
关键词:hello.c;生命周期;计算机系统;csapp;os;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
第1章 概述
1.1 Hello简介
P2P:在linux中hello.c 经过 cpp 的预处理、ccl 的编译、as 的汇编、ld 的链接最终 成为可执行目标程序 hello。在shell中输入./hello启动命令后,shell为其fork,产生子进程,于是hello从 Program 变成 Process。
020:hello变成process后,shell为其execve,更新代码段,数据段,堆栈段。映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main 函数执行目标代码,CPU 为运行的 hello 分配时间片执行逻辑控制流。当程序运行结束后,shell 父进程负责回收 hello 进程,内核删除相关数据结构。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk
软件环境:Ubuntu 16.04 LTS 64位/优麒麟 64位
开发与调试工具:vim,gcc,as,ld,edb,readelf,HexEdit
1.3 中间结果
1.4 本章小结
本章主要介绍了 hello 的 p2p,020 过程,列出了本次实验信息:硬件环境、软件环境,开发与调试工具,以及生成的中间结果文件的名字,文件的作用等。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器 cpp 根据以字符#开头的命令(宏定义、条件编译),修改原始的 C 程序,将引用的所有库展开合并成为一个完整的文本文件。
主要功能如下:
1、 将源文件中用#include 形式声明的文件复制到新的程序中。比如 hello.c 第 6-8 行中的#include
2、 用实际值替换用#define 定义的字符串
3、 根据#if 后面的条件决定需要编译的代码
2.2在Ubuntu下预处理的命令
命令:cpp hello.c > hello.i
图 2.1 使用 cpp 命令生成 hello.i 文件
2.3 Hello的预处理结果解析
打开 hello.i 之后发现,整个 hello.i 程序已经拓展为 3126 行,main 函数出现在 hello.c 中的代码自 3110 行开始。如下
图 2.2 hello.i 中 main 函数的位置
在这之前出现的是 stdio.h unistd.h stdlib.h 的依次展开,以 stdio.h 的展开为例, cpp 到默认的环境变量下寻找 stdio.h,
图 2.3 读取系统头文件 stdio.h等
打开/usr/include/stdio.h 发现其中依然使用了 #define 语句,cpp 对此递归展开,所以最终.i 程序中是没有#define 的。而且发现其中使用了大量的#ifdef #ifndef 的语句,cpp 会对条件值进行判断来决定是否执行包含其中的逻辑。其他类似。
2.4 本章小结
Hello.c: 操作系统为我操碎了心,将我用到的工具与我建立起了联系
本章主要介绍了预处理的概念与作用、将hello.c进行了预处理,生成hello.i文件,并结合hello.c预处理之后的程序对预处理结果进行了解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译器将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。 这个过程称为编译,同时也是编译的作用。
编译器的构建流程主要分为 3 个步骤:
程序中涉及的关系运算为:
1) argc!=3:判断 argc 不等于 3。hello.s 中使用 cmpl $3,-20(%rbp),计算 argc-3 然后设置条件码,为下一步 je 利用条件码进行跳转作准备。
2) i<10:判断 i 小于 10。hello.s 中使用 计算 i-9 然后设置条件码,为下一步 jle 利用条件码进行跳转做准备。
3.3.6 控制转移
程序中涉及的控制转移有:
1) if (argv!=3):当 argv不等于3 的时候执行程序段中的代码。对于 if 判断,编译器使用跳转指令实现,首先 cmpl 比较 argv 和 3,设置条件码,使用 je 判断 ZF 标志位,如果为 0,说明 argv-3=0 argv==3,则不执行 if 中的代码直接跳转到.L2,
否则顺序执行下一条语句,即执行 if 中的代码。
2) for(i=0;i<10;i++):使用计数变量 i 循环 10 次。编译器的编译逻辑是,首先无条件跳转到位于循环体.L4 之后的比较代码,使用 cmpl 进行比较,如果 i<=9,则跳入.L4 for 循环体执行,否则说明循环结束,顺序执行 for 之后的逻辑。
图 3.5 for 循环的编译
3.3.7 函数操作
函数是一种过程,过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。P 中调用函数 Q 包含以下动作:
1) 传递控制:进行过程 Q 的时候,程序计数器必须设置为 Q 的代码的起始地址,然后在返回时,要把程序计数器设置为 P 中调用 Q 后面那条指令的地址。
2) 传递数据:P 必须能够向 Q 提供一个或多个参数,Q 必须能够向 P 中返回一个值。
3) 分配和释放内存:在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
64 位程序参数存储顺序(浮点数使用 xmm,不包含):
1 2 3 4 5 6 7
%rdi %rsi %rdx %rcx %r8 %r9 栈空间
程序中涉及函数操作的有:
1) main 函数:
a) 传递控制,main 函数因为被调用 call 才能执行(被系统启动函数 __libc_start_main 调用),call 指令将下一条指令的地址 dest 压栈, 然后跳转到 main 函数。
b) 传递数据,外部调用过程向 main 函数传递参数 argc 和 argv,分别 使用%rdi 和%rsi 存储,函数正常出口为 return 0,将%eax 设置 0 返回。
c) 分配和释放内存,使用%rbp 记录栈帧的底,函数分配栈帧空间 在%rbp 之上,程序结束时,调用 leave 指令,leave 相当于 mov %rbp,%rsp,pop %rbp,恢复栈空间为调用之前的状态,然后 ret 返回,ret 相当 pop IP,将下一条要执行指令的地址设置为 dest。
2) printf 函数:
a) 传递数据:第一次 printf 将%rdi 设置为“Usage: Hello 学号 姓名! \n”字符串的首地址。第二次 printf 设置%rdi 为“Hello %s %s\n” 的首地址,设置%rsi 为 argv[1],%rdx 为 argv[2]。
b) 控制传递:第一次 printf 因为只有一个字符串参数,所以 call puts@PLT;第二次 printf 使用 call printf@PLT。
3) exit 函数:
a) 传递数据:将%edi 设置为 1。
b) 控制传递:call exit@PLT。
4) sleep 函数:
a) 传递数据:将%edi 设置为 sleepsecs。
b) 控制传递:call sleep@PLT。
5) getchar 函数:
a) 控制传递:call gethcar@PLT
3.4 本章小结
Hello.c:操作系统又将我拆分,转换成另一种存在形式
编译器将.i 的拓展程序编译为.s 的汇编代码。经过编译之后,我们的 hello 自 C 语言解构为更加低级的汇编语言。
本章介绍了编译的概念与作用,通过对hello.s的解析,使学生能够读懂汇编语句,了解汇编语句下:数据:常量、变量(全局/局部/静态)、表达式、类型、宏,赋值语句,类型转换,算数操作,逻辑/位操作,关系操作,控制转移以及函数操作的基本知识。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。这个过程称为汇编,亦即汇编的作用。
4.2 在Ubuntu下汇编的命令
指令:as hello.s -o hello.o
图 4.1 使用 as 指令生成 hello.o 文件
4.3 可重定位目标elf格式
使用 readelf -a hello.o > helloelf 指令获得 hello.o 文件的 ELF 格式。其组成如下:
图 4.2 使用readelf生成hello.o 文件的 ELF格式
1) ELF Header:以 16B 的序列 Magic 开始,Magic 描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解 释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、 字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
图 4.3 ELF Header
2) Section Headers:节头部表,包含了文件中出现的各个节的语义,包括节 的类型、位置和大小等信息。
图 4.4节头部表 Section Headers
3) 重定位节.rela.text ,一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。如图 4.4,图中 8 条重定位信息分别是对.L0(第一个 printf 中的字符 串)、puts 函数、exit 函数、.L1(第二个 printf 中的字符串)、printf 函数、 sleepsecs、sleep 函数、getchar 函数进行重定位声明。
图4.5重定位节.rela.text
.rela节的包含的信息有:
offset 需要进行重定向的代码在.text 或.dat节中的偏移位置,8 个字节。
Info 包括 symbol 和 type 两部分, 其中 symbol 占前 4 个字节, type 占后 4 个字节,symbol 代表重定位到的目标在.symtab 中的偏移量,type 代表重定位 的类型
Addend 计算重定位位置的辅助信息, 共占 8 个字节
Type 重定位到的目标的类型
Name 重定向到的目标的名称
4)重定位节 ‘.rela.eh_frame’ :eh_frame 节的重定位信息
图4.6 重定位节. rela.eh_frame
4.4 Hello.o的结果解析
使用objdump -d -r hello.o > helloobj获得反汇编代码。
图 4.7 使用objdump对hello.o操作获得反汇编代码
图4.8 反汇编代码 图4.9 hello.s代码
1)分支转移:反汇编代码跳转指令的操作数使用的不是段名称如.L3,因为段 名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显 然不存在,而是确定的地址。
2)函数调用:在.s 文件中,函数调用之后直接跟着函数名称,而在反汇编程 序中,call 的目标地址是当前下一条指令。这是因为 hello.c 中调用的函数 都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其 call 指令后的相对地址设置为全 0(目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定。
3)全局变量访问:在.s文件中,访问 rodata(printf 中的字符串),使用段名称+%rip,在反汇编代码中0+%rip,因为 rodata 中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全 0 并添加重定位条目。
4.5 本章小结
Hello.c:什么?要将我转换成二进制格式才能让计算机认识我?
本章介绍了通过汇编器(as)将hello.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式的过程。最终生成文件为hello.o。并且生成了hello.o的ELF文件格式,介绍了其中的内容。通过hello.o获得反汇编代码,并且与hello.s文件进行了对比。发现反汇编后分支转移,函数调用,全局变量访问的方式有所不同。间接了解到从汇编语 言映射到机器语言汇编器需要实现的转换。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。
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.1 使用 ld 命令链接生成可执行程序 hello
5.3 可执行目标文件hello的格式
使用 readelf -a hello > helloelf2命令生成 hello 程序的 ELF 格式文件。
图 5.2 hello ELF 格式中的 Section Headers (节头)
5.4 hello的虚拟地址空间
图5.3 edb加载hello
使用edb加载hello,查看本进程的虚拟地址空间各段信息。
在 0x400000~0x401000 段中,程序被载入,自虚拟地址 0x400000 开始,自 0x400fff 结束,这之间每个节(开始 ~ .eh_frame 节)的排列即开始结束同图 5.2 中 Address 中声明。
5.5 链接的重定位过程分析
使用objdump -d -r hello > hellobj获得 hello 的反汇编代码。
图5.4 hello 的反汇编代码
hello.o中的相对偏移地址到了hello中变成了虚拟内存地址
hello中相对hello.o增加了许多的外部链接来的函数。
hello相对hello.o多了很多的节类似于.init,.plt等
hello.o中跳转以及函数调用的地址在hello中都被更换成了虚拟内存地址。
重定位:链接器在完成符号解析以后,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。然后就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时的地址。在hello.o到hello中,首先是重定位节和符号定义,链接器将所有输入到hello中相同类型的节合并为同一类型的新的聚合节。例如,来自所有的输入模块的.data节被全部合并成一个节,这个节成为hello的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每一个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。然后是重定位节中的符号引用,链接器会修改hello中的代码节和数据节中对每一个符号的引用,使得他们指向正确的运行地址。
5.6 hello的执行流程
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。 进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
作用:每次用户通过向shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell 是一个交互型的应用级程序,它代表用户运行其他程序。
处理流程:
1)从终端读入输入的命令。
2)将输入字符串切分获得所有的参数
3)如果是内置命令则立即执行
4)否则调用相应的程序为其分配子进程并运行
5)shell 应该接受键盘输入信号,并对这些信号进行相应处理
6.3 Hello的fork进程创建过程
在终端Terminal 中键入 ./hello 1173710119 guanwei,运行的终端程序会对输入的命令行进行解析,因为 hello 不是一个内置的 shell 命令所以解析之后终端 程序判断./hello 的语义为执行当前目录下的可执行目标文件 hello,之后终端程序 首先会调用 fork 函数创建一个新的运行的子进程,新创建的子进程几乎但不完全 与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的) 一份副本,这就意味着,当父进程调用 fork 时,子进程可以读写父进程中打开的 任何文件。父进程与子进程之间最大的区别在于它们拥有不同的 PID。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。简单进程图如下:
图6-1 Hello的fork进程创建进程图
6.4 Hello的execve过程
execve 函数加载并运行可执行目标文件filename, 且带参数列表argv 和环境变量列表envp 。只有当出现错误时,例如找不到filename, execve 才会返回到调用程序。所以,与fork 一次调用返回两次不同, execve 调用一次并从不返回。
当 fork 之后,子进程调用 execve 函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即 hello 程序,execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置 PC 指向_start 地址,_start 最终调用 hello 中的 main 函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到 CPU 引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。加载器创建的内存映像如下:
图 6.2 启动加载器创建的系统映像
6.5 Hello的进程执行
逻辑控制流:一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
简单看 hello sleep 进程调度的过程:当调用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执行过程中会出现的异常种类有:
中断:SIGSTP:挂起程序
终止:SIGINT:终止程序
1)如图 6.3,是正常执行 hello 程序的结果,当程序执行完成之后,进程被回收。
图 6.3 正常运行 hello 程序
2)如图 6.4,是在程序输出时按下ctrl-z的结果,当按下ctrl-z之后,shell父进程收到 SIGSTP 信号,信号处理函数的逻辑是打印屏幕回显、将 hello 进程挂起,通过ps 命令我们可以看出 hello 进程没有被回收,此时他的后台 job 号是1
图6.4运行中途按下 ctrl-z
调用 fg 1 将其调到前台,此时 shell 程序首先打印 hello 的命令行命令, hello 继续运行打印剩下的字符串,此时再次按下ctrl-z。
图6.5运行中途第二次按下 ctrl-z
此次我们使用kill,以及将hello程序调到前台时将进程终止
当你执行一个"kill"命令,你实际上发送了一个信号给系统,让它去终结不正常的应用。总共有60个你可以使用的信号,但是基本上你只需要知道SIGTERM(15)和SIGKILL(9)。
kill命令的工作原理是,向Linux系统的内核发送一个系统操作信号和某个程序的进程标识号,然后系统内核就可以对进程标识号指定的进程进行操作。比如在top命令中,我们看到系统运行许多进程,有时就需要使用kill中止某些进程来提高系统资源。kill是大多数Shell内部命令可以直接调用的。
此时再次用ps,和jobs时发现没有hello程序了。
图6.6 将hello进程kill后状态
3)如图 6.7是在程序运行时按下 ctrl-c 的结果,当按下 ctrl-c 之后,shell 父进程收到 SIGINT 信号,信号处理函数的逻辑是结束 hello,并回收 hello 进程。
图 6.7运行中途按下 ctrl-c
4)如图 6.8是在程序运行中途乱按的结果,可以发现,乱按只是将屏幕的输
入缓存到 stdin,当 getchar 的时候读出一个’\n’结尾的字串(作为一次输入),其他字串会当做 shell 命令行输入。
图 6.8运行中途乱按
5)图6.9是在程序运行时按下ctrl-z后输入pstree的截图
图6.9 pstree命令运行截屏
6.7本章小结
Hello:操作系统内核将我加载,为我分配了一部分活动空间,在计算机这个大都市中,为我分配的那片区域是属于我的,我能够自由活动。
本章主要介绍了进程的概念与作用,了解了fork ,execve函数的功能,并且通过对hello程序的执行了解了hello的异常与信号处理。通过本章我们可以编写异常处理函数处理异常和为程序丰富功能,也可以使得我们在以后编写程序的时候尽量减少异常的发生。
(第6章1分)
第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 概念关系说明
下面是转换的具体步骤:
给定一个完整的逻辑地址[段选择符:段内偏移地址]。
看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。可以得到一个数组。
取出段选择符中前13位,在数组中查找到对应的段描述符,得到Base,也就是基地址。
线性地址 = Base + offset。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[220]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。这里注意到,这个total_page数组有220个成员,每个成员是一个地址(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,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 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
在上一步中我们已经获得了物理地址 VA,如图 7.6,使用 CI(后六位再后六位)进行组索引,每组8路,对8路的块分别匹配 CT(前40位)如果匹配成功且块valid 标志位为 1,则命中(hit),根据数据偏移量 CO(后六位)取出数 据返回。如果没有匹配成功或者匹配成功但是标志位是 1,则不命中(miss),向下一 级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的 放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。
图7.6三级Cache支持下的物理内存访问示意图
7.6 hello进程fork时的内存映射
当 fork 函数被 shell 进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID,为了给这个新进程创建虚拟内存,它创建了当前进程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 中。栈和堆区域也是请求二进制零的,初始长度为零。
3) 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4) 设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障是一种常见的故障,当指令引用一个虚拟地址,在 MMU 中查找页表 时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就 会发生故障。
缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU 重新启动引起缺页的指令,这条指令再次发送 VA 到 MMU,这次 MMU 就能正常翻译 VA 了。
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:在linux这个大都市中,除了我自己那部分空间,还有一部分公共区域可以让我自由驰骋
本章主要介绍了hello的存储地址空间de内容,了解了从逻辑地址到线性地址的变换以及从线性地址到物理地址的变换的知识。以及四级页表下的VA到PA的变换,了解了三级cache下的物理内存访问。能够解决缺页故障以及缺页中断的处理。了解了c语言中的malloc的具体执行规则,对malloc的使用有了更深入的了解。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有的 IO 设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux内核引出一个简单低级的应用接口,称为 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 和 vsprintf 代码是 windows 下的。
查看 printf 代码:
int printf(const char fmt, …) {
int i;
char buf[256];
va_list arg = (va_list)((char)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
首先 arg 获得第二个不定长参数,即输出的时候格式化串对应的值。 查看 vsprintf 代码:
int vsprintf(char *buf, const char fmt, va_list args) {
char p;
char tmp[256];
_list p_next_arg = args;
for (p = buf; *fmt; fmt++) {
if (*fmt != ‘%’) //忽略无关字符
{
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt)
{
case ‘x’: //只处理%x一种情况
itoa(tmp, ((int)p_next_arg)); //将输入参数值转化为字符串保存在tmp strcpy(p, tmp); //将tmp字符串复制到p处
p_next_arg += 4; //下一个参数值地址
p += strlen(tmp); //放下一个参数值的地址
break;
case ‘s’:
break;
default:
break;
}
}
return (p - buf); //返回最后生成的字符串的长度
}
则知道 vsprintf 程序按照格式 fmt 结合参数 args 生成格式化之后的字符串,并 返回字串的长度。 在 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 代表通过系统调用 syscall,查看 syscall 的实现:
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
syscall 将字符串中的字节“Hello ”从寄存器中通过总线复
制到显卡的显存中,显存中存储的是字符的 ASCII 码。字符显示驱动子程序将通过 ASCII 码在字模库中找到点阵信息将点阵信息存 储到 vram 中。显示芯片会按照一定的刷新频率逐行读取 vram,并通过信号线向液晶显示器 传输每一个点(RGB 分量)。于是我们的打印字符串“Hello i”就显示在了屏幕上。
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成 ASCII 码,保存到系统的键盘缓冲区之中。
getchar 函数落实到底层调用了系统函数 read,通过系统调用 read 读取存储在 键盘缓冲区中的 ASCII 码直到读到回车符然后返回整个字串,getchar 进行封装, 大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
Hello:不止linux操作系统能够控制我,就连外部的人类也能够通过IO设备对我进行控制,真的是好难过
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了printf 函数和 getchar 函数。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
1) 编写hello程序:通过编辑器将hello的代码输入到计算机中
2) 预处理:通过命令cpp hello.c > hello.i将 hello.c 调用的所有外部的库展开合并到一个 hello.i 文件中
3) 编译:通过命令gcc -S hello.i -o hello.s将 hello.i 编译成为汇编文件 hello.s
4) 汇编:通过命令as hello.s -o hello.o将 hello.s 会变成为可重定位目标文件 hello.o
5) 链接:通过一系列命令: 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将 hello.o 与可重定位目标文件和动态链接库链接成为可执行目标程序 hello
6) 运行:在 shell 中输入./hello
7) 创建子进程:shell 进程调用 fork 为其创建子进程
8) 加载程序:shell 调用 execve,execve 调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main 函数。
9) 程序的运行过程:如果运行途中键入 ctr-c ctr-z 则调用 shell 的信号处理函数分别停止、挂起。
10)结束:shell 父进程回收子进程,内核删除为这个进程创建的所有数据结构。
我对计算机系统的设计实现的深切感悟就是计算机系统的实现是一个复杂的过程,程序被程序员用高级语言编辑出来后需要进行一系列的操作才能展现在人们面前,程序的代码内容也会对程序的运行时间产生影响,好的程序能够尽量减少访存过程中的缺页,减少程序运行时间。
我的创新理念就是可以提高cache的级数,增加访存速度。
(结论0分,缺少 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 虚拟地址、 逻辑地址、线性地址、物理地址: https://blog.csdn.net/rabbit_in_android/article/details/49976101
[2] printf 函数实现的深入剖析: https://blog.csdn.net/zhengqijun_/article/details/72454714
[3] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[4] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[5] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[6] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[7] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[8] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
(参考文献0分,确实 -1分)