本报告结合本学期计算机系统课程所学知识,详细地阐述了hello程序精彩的“一生”。从其预处理、编译、汇编、链接、进程管理、存储管理与IO管理等方面分别对其“各阶段人生”进展进行了系统地分析。
hello的P2P,从输入hello.c文件的源代码,到cpp预处理成hello.i,再到ccl编译生成汇编语言文件hello.s,并组装为可重定位文件hello.o,ld将其与各个库链接,shell收到./hello的指令后,调用fork创建进程,execve加载到内存来完成这个程序。之后程序由cpu控制其逻辑流程的操作、中断、上下文切换来处理异常,最后结束,父进程回收资源。
关键词:计算机系统课程 hello程序 hello的一生
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:(From Program to Process)
hello程序的生命周期从一个高级c语言程序开始,首先预处理器cpp将.c文件处理为修改了的.i文件,然后ccl将.i文件处理为.s汇编程序,汇编器as处理.s文件为.o文件,最后链接器ld转为可执行程序。
在shell中输入字符串“./hello”。 shell 程序将字符串读入并解析。然后shell调用fork函数来创建新的子进程。子进程是父进程shell的副本,再通过execve函数调用启动加载器,然后使用mmap函数创建新的内存区域,创建一组新的代码、数据、堆栈段。通过将虚拟地址空间中的页面映射到可执行文件的页面大小块,新的代码和数据段被赋值为可执行文件的对应内容。最后,运行main函数。
020:程序在内存中From Zero to Zero,hello先执行P2P,然后在程序执行结束之后,hello对应的进程会保持在终止状态,直到被其父进程回收然后退出,shell会再次变成执行hello之前的状态,即0->...->0.
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;512GB SSD
软件环境:Windows10 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发工具:DEVC++ 64位;gcc等;Microsoft word,vi/vim/gedit+gcc
Readelf;gdb/edb;objdump
1.3 中间结果
作用 | |
hello.c | 源程序文件(c程序文件) |
hello.i | 预处理后生成的文本文件 |
hello.s | 编译后生成的汇编语言文件 |
hello.o | 汇编后生成的可重定位文件 |
hello | 链接后生成的可执行程序 |
hello.elf | hello.o的ELF格式文件 |
hello_.elf | hello的ELF格式文件 |
hello_obj | hello.o的反汇编文件 |
hello_objdump | hello的反汇编文件 |
1.4 本章小结
本章对hello进行了总体的概括,介绍了hello程序运行的大致过程(P2P与020)的意义和过程,硬件环境、软件环境和开发工具,最后简述了从.c文件到可执行文件的中间结果
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预编译器根据以字符#开头的预处理指令,对代码进行初始的处理,最终的得到一个以.i为扩展名的C文件。
1.将所有的#define删除,并展开所有的宏定义;
2.处理所有的预编译指令,例如:#if,#elif,#else,#endif;
3.处理#include预编译指令,将被包含的文件插入到预编译指令的位置;
4.添加行号信息文件名信息,便于调试;
5.删除所有的注释:// /**/;
6.保留所有的#pragma编译指令,因为在编写程序的时候,我们经常要用到#pragma指令来设定编译器的状态或者是指示编译器完成一些特定的动作。
7.生成.i文件。
作用:
1.复制引用的文件,例如#include <stdio.h>命令告诉预处理器读取头文件stdio.h的内容,并把它直接插入程序文本中。
2.替换宏定义在源代码中的所有显示。
3.规定限制编译某段代码的条件。
2.2在Ubuntu下预处理的命令
命令:gcc -E -o hello.i hello.c
2.3 Hello的预处理结果解析
1.首先预处理程序将#include <stdio.h>、#include <unistd.h>、#include <stdlib.h>三个文件的源码内容添加到预处理文件中;
2.后面的一部分则直接复制源文件的内容。
3.如果遇到头文件中包含预处理命令,则递归地将内容复制进去,直到预处理文件中不包含任何预处理命令为止。
预处理方便了编译器将程序翻译成汇编语言时的操作。
2.4 本章小结
第二章通过Hello的例子解析了预处理结果,预处理的相关概念及处理的类型,实现替换定义的宏符号、加入头文件的内容、根据指令进行选择性编译等。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:
编译是指将一个经过预处理的高级语言程序文本(.i文件)翻译成能执行相同操作的等价ASII码形式汇编语言文件(.s文件)的过程。
编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
作用:
将高级语言转换成更接近机器语言的汇编语言,简化将高级语言转换成计算机可执行的二进制文件时的操作。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.c
3.3 Hello的编译结果解析
3.3.1 数据
局部变量:作为函数中的局部变量i被储存在栈中,栈地址:%rbp-4
argc:传入的参数,存在栈里,位于%rbp-20。
argv[]:传入的数组,存在栈里,argv的地址位于%rbp-32,利用argv的地址加i*8,就能得到argv[i]。
立即数:储存在.data段中,在运行需要时加入寄存器中,如果无空闲寄存器则放入栈中。
表达式:存在代码段的.rodata中
3.3.2 算术操作
在循环操作中,使用了自加++操作符:
在每次循环执行的内容结束后,对i进行一次自加,栈上存储变量i的值加1
程序第14行中判断传入参数argc是否等于4,汇编代码为:
je用于判断指令cmpl产生的条件码,若比较结果不相等则跳转到.L2;
对于for循环中的循环执行条件(i = 0; i < 8; i++)汇编代码为:
jle用于判断指令cmpl产生的条件码,若-4(%rbp)的值小于等于7则跳转到.L4;
3.3.4数组/指针/结构操作
主函数main的参数中有指针数组char *argv[]
在argv数组中,argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别表示两个字符串。
据argv数组是传入的参数,储存在栈上。初始地址位于%rbp-32,利用argv的地址加i*8,就能得到argv[i]
对比原函数可知通过%rsi-8和%rax-16,分别得到argv[1]和argv[2]两个字符串
3.3.5函数操作
X86-64中,过程调用传递参数规则:第1~6个参数一次储存在%rdi、%rsi、%rdx、%rcx、%r8、%r9这六个寄存器中,剩下的参数保存在栈当中。
main函数:
参数传递:传入参数argc和argv[],分别用寄存器%rdi和%rsi存储。
函数调用:被系统启动函数调用。
函数返回:设置%eax为0并且返回,对应return 0 。
汇编代码如下:
可见argc存储在%rdi中,argv存储在%rsi中;
printf函数:
参数传递:call puts时只传入了字符串参数首地址;for循环中call printf时传入了 argv[1]和argc[2]的地址。
函数调用:if判断满足条件后调用,与for循环中被调用。
exit函数:
参数传递:传入的参数为1,再执行退出命令
函数调用:if判断条件满足后被调用.
sleep函数:
参数传递:传入参数atoi(argv[3]),
函数调用:for循环下被调用,call sleep
getchar函数:
函数调用:在main中被调用,call getchar
3.3.6 类型转换
atoi函数将字符型argv[3]转换为整型数。
3.4 本章小结
第三章讲述了编译阶段编译器如何把.i代码转换成.s汇编代码的,同时也分析c代码中的各数据与操作在汇编代码中是如何储存并实现的。编译程序所做的工作,就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码表示。包括之前对编译的结果进行解析,都令我更深刻地理解了C语言的数据与操作,对C语言翻译成汇编语言有了更好的掌握。因为汇编语言的通用性,这也相当于掌握了语言间的一些共性。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中的过程成为汇编
作用:
将汇编代码转换为计算机能够理解并执行的二进制机器代码,这个二进制机器代码是程序在本机器上的机器语言的表示
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
命令:gcc -c -o hello.o hello.s
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
先用readelf命令:
readelf -h hello.o
elf头从一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助连接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
readelf -a hello.o > hello.elf
ELF头:文件格式
.text:机器代码
.rodata:只读数据(跳转表等)
.data:已初始化的全局变量
.bss: 未初始化的全局变量
.symtab:符号表
.rel.text:可重定位代码
.rel.data:可重定位数据
.debug:调试符号表
重定位节:表述了各个段引用的外部符号等,在链接时,需要通过重定位节对这些位置的地址进行修改。链接器会通过重定位条目的类型判断该使用什么养的方法计算正确的地址值,通过偏移量等信息计算出正确的地址。
本程序需要重定位的信息有:.exit,printf等
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
使用objdump -d -r hello.o >hello.txt指令
数的表示:hello.s中的操作数时十进制,hello.o反汇编代码中的操作数是十六进制。
分支转移:跳转语句之后,hello.s中是.L2和.LC1等段名称,而反汇编代码中跳转指令之后是相对偏移的地址,也即间接地址。
函数调用:hello.s中,call指令使用的是函数名称,而反汇编代码中call指令使用的是main函数的相对偏移地址。因为函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
4.5 本章小结
第四章介绍汇编和汇编后生成的可重定位文件,分析可重定位文件的结构和各个组成部分,以及各个组成部分的内容和功能,比较了机器语言和汇编代码的区别和关系,生成hello.o可重定位文件
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行
作用:
把可重定位目标文件和命令行参数作为输入,产生一个完全链接的,可以加载运行的可执行目标文件。可重定位目标文件.o不能直接执行,需要链接过后形可执行目标文件.out计算机才能执行hello程序。链接器实现了分离编译(模块化)。
注意:这儿的链接是指从 hello.o 到hello生成过程。
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
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
5.3 可执行目标文件hello的格式
命令:readelf -a hello > hellold.elf
节头:描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
使用edb加载hello, Data Dump 窗口可以查看加载到虚拟地址中的 hello 程序。查看 ELF 格式文件中的 Program Headers,它告诉链接器运行时加载的内容,并提供动态链接的信息。
Edb中0x400000所储存的信息与表头所储存的信息一致,Init段存在于0x401000
rodata段在0x404048
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
1.链接增加新的函数:
在hello中链接加入了在hello.c中用到的库函数,如exit、printf、sleep、getchar等。
2.增加的节:
hello中增加了.init和.plt节等
3.函数调用:
hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
4.地址访问:hello.o中的相对偏移地址变成了hello中的虚拟内存地址。
一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义(即其一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道其输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:
·重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。
·重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
5.6 hello的执行流程
401000 <_init>
401020 <.plt>
401030 <puts@plt>
401040 <printf@plt>
401050 <getchar@plt>
401060 <atoi@plt>
401070 <exit@plt>
401080 <sleep@plt>
401090 <_start>
4010c0 <_dl_relocate_static_pie>
4010c1 <main>
401150 <__libc_csu_init>
4011b0 <__libc_csu_fini>
4011b4 <_fini>
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
5.7 Hello的动态链接分析
在elf文件中可以找到:
进入edb调试:
初始时got存的plt的第二条指令,链接器修改got,再调用plt时,指向正确的内存地址,plt跳转到正确区域。
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
介绍了链接的概念与作用,说明了hello.o与其他.o(.so)文件链接成为一个可执行目标文件的过程,展示了hello.o的ELF文件形式和各个节的含义,分析了hello的虚拟地址空间、重定位过程与动态链接过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:
进程的经典定义就是一个执行中程序的实例。在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行 我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
作用:
每次运行程序时,shell创建一新进程,在这个进程的上下文切换中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。
进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个交互型应用及程序,代表用户运行其他命令,基本作用是解释并运行用户的指令。
处理流程:
1.读取用户命令行输入。
2.解析命令行字符串,获取命令行参数并传递给execve函数argv向量。
3.检查第一个命令行参数是否是shell内置命令。是则直接执行,否则fork创建子进程
4.在子进程中,继续步骤(2)获取参数并调用excve()运行程序。
5.命令行末尾没有&,代表前台的工作,shell使用waitpid等待工作完成返回。
6.命令行末尾有&,代表后台工作,shell返回
6.3 Hello的fork进程创建过程
在终端中输入 ./hello ,shell 将解释命令行。 由于这不是一个内置的 shell 命令,所以调用fork来创建一个新的运行子进程并运行可执行程序 hello。 新创建的子进程几乎(但不完全)与父进程相同。 二者之间最大的不同在于PID的不同。Fork函数只会被调用一次,但会返回两次,在父进程中,fork返回子进程的PID,在子进程中,fork返回0。
6.4 Hello的execve过程
execve函数加载并运行可执行文件hello,且带参数列表argv和环境变量envp。
运行时,创建一个内存映像,在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段,接下来,加载器跳转到程序的入口,_start函数的地址,这个函数是在系统目标文件ctrl.o中定义的,对所有的c程序都一样。_start函数调用系统启动函数,_libc_start_main,该函数定义在libc.so里,初始化环境,调用用户层的main函数,处理main函数返回值,并且在需要的时候返回给内核。
只有当出现错误时, execve 才会返回到调用程序。所以execve 调用一次并从不返回。
6.5 Hello的进程执行
(以下格式自行编排,编辑时删除)
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
hello进程的执行依赖于进程的抽象:
- 逻辑控制流::程序计数器 PC 的一系列值序列,进程轮流使用处理器,在同一个处理器核心中,每个进程执行其一部分流后被暂时挂起(上下文转换机制),然后处理器运行其他进程。
- 并发流:一个逻辑控制流的执行时间与另一个控制流重叠,成为并发流,这两个流(宏观上)并发的运行。
- 时间片:一个进程执行其一部分控制流的一段时间叫做时间片。
- 用户模式和内核模式:处理器通常使用一个寄存器区分两种模式,该寄 存器记录着当前进程享有的权限,没有设置模式位时进程处于用户模式, 用户模式的进程不允许执行特权指令,也不允许直接引用内存空间中内核区内的代码和数据;设置模式位时进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问内存中的任何地址。
- 上下文信息:上下文就是内核重新启动一个挂起的进程所需要的资源,包括通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等。
- 上下文切换:在内核调用了一个新的进程并使其运行后,当前进程就会挂起,并使用上下文切换机制来将控制权转移到新的进程。
1)保存被挂起进程的上下文到内存
2)启用恢复进程保存的上下文,
3)将控制传递给新恢复的进程,来完成上下文切换。
在hello进程运行开始,shell已经fork后为hello程序分配了新的虚拟的地址空间,调用execve函数之后将hello的.txt和.data节分配到虚拟地址空间的代码区和数据区。当hello运行在用户模式下,输出hello 1190202001 杨永正,然后hello调用sleep函数后进入内核模式,它会处理sleep请求主动挂起当前进程,并将hello进程加入等待队列,sleep定时器开始计时,内核进行上下文切换并传递 控制当前进程到其他进程。当定时器倒计时结束,会发送一个中断信号。此时进入内核态进行中断处理,将hello进程从等待队列中移除并重新加入运行队列,hello进程继续其控制逻辑流。
6.6 hello的异常与信号处理
(以下格式自行编排,编辑时删除)
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
乱按:
会把回车之前的字符串当作命令,在hello结束之后尝试运行
Ctrl+C:
会让内核发送一个SIGINT信号给到前台进程组中的每个进程,结果是终止前台进程,通过ps命令发现这时hello进程已经被回收。
Ctrl+Z:
挂起进程,可以执行jobs等指令,在输入fg后继续执行
程序运行过程中不停乱按可以发现输入的内容只是将屏幕的输入缓存到 stdin,当getchar()的时候读取了回车前的一个字符,其他字符串只会作为 shell 命令行输入。
6.7本章小结
本章了解了hello进程的执行过程。主要讲hello的创建、加载和终止,通过键盘输入来了解hello的异常与信号处理。程序是指令、数据及其组织形式的描述,进程是程序的实体。可以说,进程是运行的程序。在hello运行过程中,内核有选择对其进行管理,决定何时进行上下文切换。
同样是在hello的运行过程中,当接受到不同的异常信号时,异常处理程序将对异常信号做出相应,执行相应的代码,每种信号都有不同的处理机制,对不同的异常信号,hello也有不同的处理结果。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:用于确定机器语言中的指令或操作数的地址。一个逻辑地址包含一个段和一个偏移量,偏移量是一个相对偏移量,段决定了偏移量从哪里开始,这样就可以通过段和偏移量来确定地址。即hello.o中的相对偏移地址
线性地址:是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:虚拟地址与线性地址相同。也就是hello中的虚拟内存地址
物理地址:装入内存地址寄存器的地址,内存单元的真实地址。通过地址转换器,可以将hello的虚拟地址转换为物理地址。
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成,段标识符,段内偏移量。段标识符是一个16位长的字段组成,称为段选择符,其中前13位是一个索引号。后面三位包含一些硬件细节。
索引号,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
这里面,我们只用关心Base字段,它描述了一个段的开始位置的线性地址。
全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
给定一个完整的逻辑地址段选择符+段内偏移地址,
看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
把Base + offset,就是要转换的线性地址了
7.3 Hello的线性地址到物理地址的变换-页式管理
VM系统通过将虚拟内存分割为称为虚拟页的大小固定的块,类似的,物理内存被分割为物理页。系统通过操作系统软件、mmu中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构来完成缓存各种操作。页表将虚拟页映射到物理页,每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。
页表就是一个页表条目的数组。虚拟地址空间中的每个页在页表中一个固定的偏移量处都有一个PTE。PTE包含了一个有效位和一个n位字段,有效位表明了该虚拟页当前是否被缓存在DRAM中。因为DRAM是全相联的,所以任意物理页都可包含任意虚拟页。
n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个n-p位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE.将页表条目中的物理页号和VPO串联起来就是相应的物理地址。因为物理和虚拟页面都是P字节的,所以物理页面偏移和虚拟页面偏移是相同的。
7.4 TLB与四级页表支持下的VA到PA的变换
36位VPN 被划分成四个9 位的片,每个片被用作到一级页表的偏移量。CR3 寄存器包含页目录表的物理地址。VPN1提供在Ll中的偏移量,这PTE包含L2页表的基地址。VPN2提供在Ll中的偏移量,以此类推即可得到物理页号,与虚拟地址VPO相连即可得到物理地址。
7.5 三级Cache支持下的物理内存访问
每一级Cache都分为S个组,每组都有E行,每一行有B个字节的块,以及一位有效位,64位的tag标记。
对物理地址值而言分为组索引,tag标记,以及块偏移。
首先对地址值的组索引找到相对应的Cache中的对应组。
然后根据tag标记找到符合tag标记的那一行。
如果有效值为1,则命中,在这一行中读或写(写回)取相对应块索引的数据
否则不命中,同理从下一级cache执行相同操作,未命中则再到下一级去找,如果三级cache都找不到则在内存中找到相对应的这一行,根据地址值对应的组加入缓存中,选择有效位为0的行优先放入,否则按照LRU算法替换缓存行。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一得PID。同时为这个新进程创建虚拟内存。
新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区的引导加载程序代码,在当前进程中加载并运行可执行目标文件 hello 中包含的程序,有效地将当前程序替换为 hello 程序。加载和运行 hello 需要以下步骤:
1)删除已有的用户区,删除当前进程虚拟地址的用户部分中已有的区结构。
2)映射私有区域,为新程序的代码、数据、bss和堆栈区域创建新的区域结构。所有这些新区域都是私有的,并在书面上复制。代码和数据区域映射到 hello 文件中的 .text 和 .data 区域。bss 区域用于二进制零并映射到匿名文件。它的大小包含在你好。堆栈和堆地址也用于二进制零,初始长度为零。
3)映射共享区,hello程序链接共享对象libc.so,libc.so动态链接到这个程序,然后映射到用户虚拟地址空间中的共享区。
4)设置程序计数器(PC),execve做的最后一件事是设置当前进程上下文的程序计数器,使其指向代码区的入口点。
7.8 缺页故障与缺页中断处理
DRAM缓存不命中称为缺页。假设某时刻CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发了一个缺页异常。
缺页中断处理:缺页异常调用内核的缺页异常处理程序。 程序选择内存中的一页作为牺牲页,如果该页被修改(修改位被设置),则将该页写回磁盘; 然后根据目标虚拟页的PTE的磁盘地址,取出磁盘的页放入内存,同时修改PTE。然后在程序中断时返回当前指令, 并继续请求访问虚拟地址。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。对每个进程,内核维护着一个变量brk,它指向堆的顶部。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式的保留为供应程序使用。空闲块保持空闲,直到它被应用所分配。已分配的块保持已分配,直到它被释放。
显示分配器要求应用显示地释放任何已分配的块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个快。
malloc函数返回一个指针,指向大小为至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。若malloc遇到问题,就返回NULL,并设置errno。malloc不初始化它返回的内存。
7.10本章小结
第七章主要讲述了虚拟内存的知识,虚拟内存地址和物理内存地址的转换,TLB和页表的结构和查找原理,以及如何使用物理地址通过三级缓存访问内存,以及 还学习了fork和execve内存映射,以及动态存储分配管理的原理和实现
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:
打开文件:
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux shell创建的每个进程开始时都有三个打开的文件: 标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可以用来代替显式的描述符值。
改变当前的文件位置: 对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
读写文件:
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的而文件,当k>=m时执行读操作会触发一个成为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似的,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
函数:
- 进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的:int open(char *filename, int flags, mode_t mode)。若成功返回新文件描述符,若出错则返回-1.open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件。mode参数指定了新文件的访问权限位。作为上下文的一部分,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置成mode&~umask。最后进程通过调用close函数关闭一个打开的文件。
- 通过read和write函数进行输入与输出:ssize_t read(int fd, void *buf, size_t n)若成功返回读的字节数,若EOF则为零,出错为-1;ssize_t write(int fd, const void *buf, size_t n)成功为写的字节数,出错为-1.read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。也可以通过RIO包处理不足值。它提供了两类不同的函数:无缓冲的输入输出函数;带缓冲的输入函数。
- 应用程序通过调用stat和fstat函数检索关于文件的信息。Int stat(const char*filename,struct stat *buf);int fstat(int fd,struct stat *buf);若成功返回0,出错返回-1.stat函数以一个文件名作为输入,并填写stat数据结构中的各个成员。fstat相似,只不过是以文件描述符而不是文件名作为输入。stat数据结构中的st_mode编码了文件访问许可位和文件类型。St_size则包含了文件的字节数大小。
- 通过readdir系列函数来读取目录的内容。DIR*opendir(const char *name)若成功返回处理的指针,若出错,返回NULL,它以路径名为参数,返回指向目录流的指针。Struct dirent *readdir(DTR *dirp)若成功返回指向下一个目录的指针,若没有更多目录或出错则返回NULL。每次对readdir的调用返回都是指向流dirp中下一个目录项的指针,或者没有更多目录项则返回NULL.每个目录项都是一个结构。若出错,readdir返回NULL并设置errno。函数closedir关闭流并释放所有找资源。成功返回0,失败返回-1.
8.3 printf的实现分析
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;
}
printf程序按照格式fmt结合参数args生成字符串,并返回串的长度。
然后是write函数:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在printf中调用系统函数write将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址。
int INT_VECTOR_SYS_CALLA表示通过调用系统syscall。
然后是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
sys_call函数通过总线将字符串中的字节从寄存器复制到显卡的显存。显存存储ASCII字符码,字符显示驱动子程序通过ASCII码在字体库中查找点阵信息,将点阵信息存储在vram中。
显示芯片根据刷新频率逐行读取vram。并通过信号线将每个点(RGB分量)发送到液晶显示器。所以我们的输入字符串出现在屏幕上。从vsprintf生成显示数据,写系统函数,int 0x80拦截系统调用,或者sys_call字符显示驱动子程序:从ASCII到字体库显示vram。 (采集每个点的RGB颜色数据)
显示芯片根据刷新频率逐行读取vram。并通过vsprintf的信号线将每个点(RGB分量)发送到液晶显示器,生成显示数据。编写write函数,然后到陷阱-系统调用int 0x80 或 sys_call 等。
字符显示驱动子程序:从ASCII到字体库显示vram(存储每个点的RGB颜色数据),显示芯片相应地逐行读取vram 刷新频率并通过信号线将每个点(RGB分量)发送到液晶显示器。
8.4 getchar的实现分析
getchar原函数:
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
bb=buf;
}
return (--n>=0)?(unsigned char)*bb++:EOF;
}
getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车为止(回车字符也放在缓冲区中)。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要阐述了Linux的IO设备管理办法以及IO接口实现与相应的函数实现。分析了getchar()和printf()函数的实现。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
编写:通过编辑器将代码输入hello.c。
预处理:将hello.c调用的所有外部库和宏替换预处理、扩展和合并到一个hello.i文件中。
编译:将hello.i编译成汇编文件hello.s。
汇编:把hello.s汇编成可重定位的目标文件hello.o。
链接:将hello.o与可重定位的目标文件和动态链接库链接成可执行的目标程序hello。
运行:在shell命令行中输入./hello 1190202001 杨永正 1。
创建子进程:shell进程调用fork为其创建子进程。
运行程序:shell调用execve,execve调用loader,添加映射的虚拟内存,程序进入程序入口后开始加载物理内存,然后进入main函数。
执行指令:CPU为其分配时间片,在一个时间片内,hello可以使用CPU,依次执行自己的控制逻辑流。
上下文切换:hello调用sleep函数之后进程进入内核模式,内核进行上下文切换将当前进程的控制权交给其他进程,当sleep函数调用完成时,内核执行上下文切换将控制返还给hello进程。
访问内存:MMU将程序中使用的虚拟内存地址通过页表映射到物理地址。
动态内存申请:printf调用malloc动态内存分配器在堆中申请内存。
信号:如果在运行时输入ctr-c ctr-z,会分别调用shell的信号处理函数终止和停止。
结束:shell父进程或ini养父进程回收子进程,内核删除为这个进程创建的所有数据结构。hello最终被shell父进程回收,内核回收为其创建的所有信息,hello的一生也随之结束了
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | |
源程序 | hello.c |
预处理后的文件 | hello.i |
编译之后的汇编文件 | hello.s |
汇编之后的可重定位目标文件 | hello.o |
链接之后的可执行目标文件 | hello |
Hello.o 的 ELF 格式 | helloelf.txt |
Hello.o 的反汇编代码 | dis_hello.s |
hello的ELF 格式 | hello.elf |
hello 的反汇编代码 | hello_objdump.s |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
- 兰德尔E.布兰恩特 大卫R.奥哈拉伦 深入理解计算机系统(第三版)
- [转]printf 函数实现的深入剖析 - Pianistx - 博客园