计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 7203610401
班 级 2036012
学 生 王元辰
指 导 教 师 刘宏伟
计算机科学与技术学院
2022年5月
摘 要
本文介绍了hello的整个生命周期。讨论了hello程序从高级语言hello.c经过预处理,编译,汇编,链接最终生成可执行文件的过程。并且根据CSAPP所学内容,分析了计算机对hello程序进行进程管理,存储管理,IO管理的过程。通过完整的过程分析,进一步深入的理解计算机系统。
关键词:hello;编译;汇编;进程;存储;IO
(摘要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 -
Hello程序从它的诞生到它的结束,整个过程可以用“P2P”和“020”来概括描述。
“P2P”,意为From Program to Process。Program是C语言程序源代码hello.c,Process即为操作系统运行该程序时创建的进程。Linux中,hello.c经过cpp的预处理、编译器ccl、汇编器as、链接器ld等过程,生成可执行文件hello。在bash中输入cmdline后,操作系统用进程管理调用fork函数为其生成一个child process,并调用execve函数在该子进程上下文中运行hello程序,调用mmap函数为该程序创建虚拟内存映射,还将该进程的执行分为若干个时间片调度运行,让其能够在CPU\RAM\IO上完成取指、译码、执行、访存、写回等流水线操作,hello便从程序变成了进程。
“020”,意为From Zero to Zero。第一个zero指编写代码之前,该程序还不存在。第二个zero指当程序运行结束后,shell父进程负责回收hello子进程,内核删除相关数据结构,系统完成程序运行,释放相应内存,hello这个程序结束,又回到“零”。
1.2.1硬件环境
X64 CPU;1.8GHz;16G RAM;1024GHD Disk
1.2.2软件环境
Windows10 64位;VirtualBox ;Ubuntu 16.04 LTS 64位;
1.2.3开发工具
CodeBlocks 64位;gcc;edb
列出生成的中间结果文件的名字,文件的作用等。
Hello.i |
预处理后的文本文件 |
Hello.s |
编译后的汇编文件 |
Hello.o |
汇编后的可重定位目标文件 |
Hello.out |
Hello反汇编后的可重定位目标文件 |
Hello |
链接后的可执行目标文件 |
本章主要介绍了 hello 的 p2p,020 过程,写了本次实验软硬件环境、中间结果,并简介了hello程序从hello.c到可执行目标文件hello的历程。
(第1章0.5分)
概念:程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
作用:ISO C和ISO C++都规定程序由源代码被翻译分为若干有序的阶段(phase) ,通常前几个阶段由预处理器实现。预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令) 。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。
预处理器在UNIX传统中通常缩写为PP,在自动构建脚本中C预处理器被缩写为CPP的宏指代。为了不造成歧义,C++(cee-plus-plus) 经常并不是缩写为CPP,而改成CXX。
下表列出了一些常见的预处理命令:
预处理名称 |
含义 |
#define |
宏定义 |
#under |
撤销已定义过的宏名 |
#include |
使编译程序将另一源文件嵌入到带有 #include的源文件中 |
#if |
#if的一般含义是如果#if后面的常量表达式为true,则编译它与#endif之间的代码,否则跳过这些代码。命令#endif标识一个时#if块的结束。#els命令的功能有点像C语言中的else ,#else建立另一选择(在#if失败的情况下)。#elif命令意义与 elseif相同,它形成一个 ifelse-if阶梯状语句,可进行多种编译选择 |
#else |
|
#elif |
|
#endif |
|
#ifdef |
用#ifdef与#ifndef命令分别表示“如果有定义”及“如果无定义”,是条件编译的另一种方法 |
#ifndef |
|
#line |
改变当前行数和文件名称,它们是在编译程序中预先定义的标识符命令的基本形式如下:#line number[“filename”] |
#error |
编译程序时,只要遇到#error就会生成一个编译错误提示消息,并停止编译 |
#pragma |
为实现时定义的命令,它允许向编译程序传送各种指令例如,编译程序可能有一种选择,它支持对程序执行的跟踪,可用#pragma语句指定一个跟踪选择 |
命令:gcc hello.c -E -o hello.i
预处理产生的hello.i文件中, 原先hello.c的代码没有任何改变,而原来前面的#include语句被替换成了大量的具体内容,包括函数的声明、结构体等数据结构的定义、数据类型的定义等内容。源程序开头的注释也被删除了。可以看出,预处理的结果仍然是可以打开查看的文本程序,预处理只是对源程序进行了一些文本性质的处理,生成的是扩展的C源程序。
本章主要介绍了预处理的概念作用及常见的预处理指令,例如实现将定义的宏进行符号替换、引入头文件的内容、条件编译等。
(第2章0.5分)
编译将预处理产生的hello.i翻译成一个汇编语言文件hello.s。编译会对预处理文件进行词法分析、语法分析、优化等操作,将C语言这种高级语言转换为成更低级、更底层、机器更好理解的汇编语言程序。
词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。执行词法分析的程序称为词法分析程序或扫描器。源程序中的单词符号经扫描器分析,一般产生二元式:单词种别;单词自身的值。单词种别通常用整数编码,如果一个种别只含一个单词符号,那么对这个单词符号,种别编码就完全代表它自身的值了。若一个种别含有许多个单词符号,那么,对于它的每个单词符号,除了给出种别编码以外,还应给出自身的值。词法分析器一般来说有两种方法构造:手工构造和自动生成。手工构造可使用状态图进行工作,自动生成使用确定的有限自动机来实现。
编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位。编译程序的语法规则可用上下文无关文法来刻画。语法分析的方法分为两种:自上而下分析法和自下而上分析法。自上而下就是从文法的开始符号出发,向下推导,推出句子。而自下而上分析法采用的是移进归约法,基本思想是:用一个寄存符号的先进后出栈,把输入符号一个一个地移进栈里,当栈顶形成某个产生式的一个候选式时,即把栈顶的这一部分归约成该产生式的左邻符号。
汇编语言程序比源程序的层次更低,但是与机器代码相比程序员更容易理解,汇编语言相当于高级语言和机器语言之间的过渡,是从源程序转换到机器代码的关键中间环节。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
命令: gcc -S hello.i -o hello.s
3.3.2.1常量
hello.s中有两个字符串常量,放在只读数据段(.rodata)中,8地址对齐。分别是.LC0:.string "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201" 和.LC1:.string "Hello %s %s\n" ,对应hello.c源程序中printf里的两个字符串。
同时可以看出,编译器将字符串中的中文按照UTF-8编码规则编码为每个汉字对应三个字节,例如“用”编码为”\347\224\250”。英文、数字、空格、换行等按照ASCII规则编码为一个字节。
3.3.2.2局部变量
局部变量有int i 、int argc 以及 char *argv[] ,均存放在栈上。
①用于循环计数的整形变量i放在%rbp-4的位置上
②main函数的参数int argc放在%rbp-20的位置上
③ main函数的参数char* argv[]放在%rbp-32的位置上
3.3.2.3全局函数
main函数为全局函数
Movb |
一个字节 |
Movw |
两个字节(字) |
Movl |
四个字节(双字) |
Movq |
八个字节(四字) |
本程序中,对循环变量i的赋值操作,i为int类型,采用movl
本程序中算数操作是i的自增运算
下表给出了常用算数运算指令:
指令 |
效果 |
描述 |
1eaq S,D |
D←&S |
加载有效地址 |
INC D DEC D NEG D NOT D |
D←D+1 D←D-1 D←-D D←~D |
加1 减l 取负 取补 |
ADD S,D SUB S,D IMUL S,D XOR S,D OR S,D AND S,D |
D←D+s D←D-S D←D*S D←D^S D←D|S D←D&S |
加 减 乘 异或 或 与 |
SAL k,D SHL k,D SAR k,D SHR k,D |
D←D< D←D< D←D>>A k D←D>>L k |
左移 左移(等同于SAL) 算术右移 逻辑右移 |
Hello程序中有两次比较操作。
①argc与4比较,设置条件码,相等则跳转到.L2。
②i与7比较,小于等于则跳转到.L4
hello程序中,main函数的参数char *argv[]是一个字符指针的数组。通过argv[1]和argv[2]访问了字符指针数组中的元素。
汇编代码中使用基地址+偏移的方式来访问数组元素。数组首地址存储在%rbp-32的位置,通过将首地址加8获得argv[1]的地址,将首地址加16获得argv[2]的地址。
另外,编译器会根据引用的数据类型的大小进行伸缩,由于这里的数组是指针数据,因此伸缩因子为8。
Hello程序中出现了两次控制转移。
①argc与4比较,设置条件码,相等则跳转到.L2。
②i与7比较,小于等于则跳转到.L4
①函数调用
Hello程序中一共出现了7次函数调用,除去main函数自己以外,其余6次函数调用均在main函数中发生,使用call指令。
②参数传递
正常情况下,参数传递通过寄存器实现,通过寄存器最多传递6个参数,按照顺序依次为%rdi、%rsi、%rdx、%rcx、%r8、%r9。多余的参数通过栈来传递。
在hello程序中:
▪对于第一个函数,参数只有1个,放在%rdi中
▪对于第二个函数,参数只有一个,放在%rdi中
▪对于第三个函数,参数有三个,放在%rdi、%rsi、%rdx中
▪对于第四个函数,参数只有一个,放在%rdi中
▪对于第五个函数,参数只有一个,放在%rdi中
▪对于第六个函数,没有参数
③函数返回
编译时,在函数的最后添加指令ret来实现函数的返回。在hello程序中,由于其余6个函数均为内置函数,所以只能看到main函数的返回。
Hello程序中涉及的类型转换是:atoi(argv[3]),将字符串类型转换为整数类型。其他的类型转换还有int、float、double、short、char之间的转换
本章主要介绍了编译的概念和作用,并对hello程序详细地分析了编译器如何处理C语言的各种数据类型以及各类操作。通过理解编译器编译的机制,我们可以很容易的将汇编语言翻译成c语言。
(第3章2分)
汇编的过程将编译生成的汇编语言文件hello.s翻译成一个可重定位目标文件hello.o。可重定位目标文件包含指令对应的二进制机器语言,这种二进制代码能够被计算机理解并执行。因此汇编是将汇编语言转换成最底层的、机器可理解的机器语言的过程。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
命令:gcc -c -m64 -no-pie -fno-PIC hello.s -o hello.o
利用命令:readelf -a hello.o > helloelf.txt查看hello.o的ELF格式,结果重定向到helloelf.txt便于查看分析。
ELF Header以一个16字节的目标序列开始,即第二行的Magic,这个序列描述了生成该文件的系统的字的大小和字节顺序。以hello程序为例,这个16字节序列为7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00,描述了系统的字的大小为8字节,字节顺序为小端序。
ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。以hello.o为例,ELF头中包含了ELF头的大小:64字节;目标文件的类型:REL(可重定位文件);机器类型:Advanced Micro Devices X86-64;节头部表的文件偏移:1240bytes;节头部表中条目的数量:14.
节头部表描述不同节的位置和大小,目标文件中的每个节都有一个固定大小的节头部表条目。以hello程序为例,节头部表一共描述了14个不同节的位置、大小等信息。依次为:
[1].text节:已编译程序的机器代码,大小为0x92字节,类型为PROGBITS,偏移量为0x40,标志为AX(表明该节的数据只读并且可执行)。
[2] .rela.text节:一个.text节中位置的列表,大小为0xc0字节,类型为RELA,偏移量为0x388,标志为I。
[3].data节:已初始化的全局和静态C变量,大小为0x0字节,类型为PROGBITS,偏移量为0xd2,标志为WA(表明该节的数据可读可写)。
[4].bss节:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。大小为0x0字节,类型为NOBITS,偏移量为0xd2,标志为WA(表明该节的数据可读可写)。
[5].rodata节:只读数据,大小为0x33字节,类型为PROGBITS,偏移量为0xd8,标志为A(表明该节的数据只读)。
[6].comment节:包含版本控制信息,大小为0x2c字节,类型为PROGBITS,偏移量为0x10b,标志为MS。
[7].note.GNU_stack节:标记可执行堆栈,大小为0x0字节,类型为PROGBITS,偏移量为0x137。
[ 8] .note.gnu.propert节:记录GNU的特有属性信息,大小为0x20字节,类型为NOTE,偏移量为0x138,标志为A(表明该节的数据只读)。
[9].eh_frame节:处理异常,大小为0x38字节,类型为PROGBITS,偏移量为0x128,标志为A(表明该节的数据只读)。
[10].rela.eh_frame节:.eh_frame节的重定位信息,大小为0x18字节,类型为RELA,偏移量为0x448,标志为I。
[11].symtab节:一个符号表,存放在程序中定义和引用的函数和全局变量的信息。大小为0x1b0字节,类型为SYMTAB,偏移量为0x190。
[12].strtab节:一个字符串表,包括.symtab和.debug节中的符号表,以及节头部中的节名字。大小为0x48字节,类型为STRTAB,偏移量为0x340。
[13].shstrtab节:包含节区名称,大小为0x74字节,类型为STRTAB,偏移量为0x460。
符号表存放程序中定义和调用的函数和全局变量的信息,每个符号表是一个条目的数组,每个条目包括value:距定义目标的节的起始位置的偏移;size:目标的大小;type:指明数据还是函数;bind:表示符号是局部的还是全局的等等。
以hello程序为例,符号表一共描述了18个符号。对于函数main,Ndx=1表明它在.text节,value=0表明它在.text节中偏移量为0的地方,size=146表明大小为146字节,bind=GLOBAL表明它是全局符号,type=FUNC:表明它是函数。其他的符号如puts、exit、printf、sleep、atoi和getchar都是外部的库函数,需要在链接后才能确定。
汇编器遇到对最终位置未知的目标引用,会产生一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位信息就放在重定位节.rel.text中,已初始化数据的重定位条目放在.rel.data中。
每个重定位条目包括offset:需要被修改的引用的节偏移;symbol:标识被修改引用应该指向的符号;type:重定位类型,告知链接器如何修改新的引用;attend:一些重定位要使用它对被修改引用的值做偏移调整。ELF定义了32种不同的重定位类型,两种最基本的重定位类型包括R_X86_64_PC32(重定位使用32位PC相对地址的引用)和R_X86_64_32(重定位使用32位绝对地址的引用)。
以hello.s为例,重定位节.rela.text一共描述了8个重定位条目。重定位节.rela.eh_frame描述了1个重定位条目。
命令:objdump -d -r hello.o 得到hello.o的反汇编代码如下:
观察hello.o的反汇编可以发现,机器语言是用二进制代码表示的一种机器指令的集合。一条指令就是机器语言的一个语句,它是一组有意义的二进制代码,指令的基本格式一般为操作码字段和地址码字段,其中操作码指明了指令的操作性质及功能,地址码则给出了操作数或操作数的地址。每一条机器指令都与一条汇编指令一一对应。
将hello.o的反汇编与第3章的 hello.s进行对照可以发现,hello.o反汇编结果包含与hello.s相同的汇编指令, 但与hello.s文件相比也有若干差异。二者具体差异如下:
①文件内容:
hello.s作为汇编文件,其内容中没有机器指令,但hello.o文件由hello.s文件汇编而来,内容反汇编后包含机器指令。除此之外,hello.o反汇编结果中不包含hello.s中含有的以.cfi_开头的汇编指示符(CFI 即 Call Frame Information,是 DWARF 2.0 定义的函数栈信息,DWARF 即 Debugging With Attributed Record Formats ,是一种调试信息格式)
②操作数表示:
立即数在hello.s文件中以十进制表示,在反汇编中以十六进制表示。这是因为在机器语言中,由于转换成了二进制代码,立即数都是用16进制数表示的。
③分支转移:
hello.s的无条件跳转语句和条件跳转语句后以代码段助记符为跳转目标:
但在hello.o反汇编中,汇编指令中的无条件跳转语句和条件跳转语句后的跳转目标改用目的代码段的相对.text偏移量来表达,机器指令中的跳转目标对应字节采用相对PC偏移量来表达:
第0x17字节开始的机器指令中,0x74为条件跳转语句je的编码,目标的地址0x2f与当前PC值0x19差值正好为机器指令中的0x16。
④函数调用:
hello.s汇编代码中的函数调用方式为call指令后直接加助记符作为调用目标:
call puts@PLT
而在hello.o反汇编中,汇编指令部分函数调用语句变为callq,且callq之后如果跳转目标函数地址未定,则在callq指令下方给出目标函数的重定向条目,函数调用指令根据重定向条目对链接器的指示进行函数跳转。且在该条汇编指令对应的机器指令部分,函数调用目标地址偏移量暂时为0(将由链接器根据重定向条目填充):
对sleep函数调用的地址偏移量由过程链接表延时绑定,机器指令中后四个字节(操作数地址)暂时为0。
本章主要介绍了汇编的概念和作用,对hello.s进行了汇编,生成了hello.o可重定位目标文件,并且分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,比较了hello.s和hello.o反汇编代码的不同之处,分析了从汇编语言到机器语言的一一映射关系。
(第4章1分)
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。
注意:这儿的链接是指从 hello.o 到hello生成过程。
使用ld的链接命令: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
readelf命令:readelf -a hello > hello_elf.txt查看可执行目标文件hello的ELF格式,并将结果重定向到hello_elf.txt便于查看分析。
ELF头以一个16字节的目标序列开始,Magic这个序列描述了生成该文件的系统的字的大小和字节顺序。以hello为例,这个16字节序列为7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00,描述了系统的字的大小为8字节,字节顺序为小端序。
ELF头剩下的部分包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。以hello为例,ELF头中包含了ELF头的大小:64字节;目标文件的类型:EXEC(可执行文件);机器类型:Advanced Micro Devices X86-64;节头部表的文件偏移:14208bytes;节头部表中条目的数量:27。同时,ELF头中还包括程序的入口点地址0x4010f0,即程序运行时要执行的第一条指令的地址。
与hello.o相比,hello的节头部表一共描述了27个不同节的位置、大小等信息,比hello.o多出13个节。各节的起始地址由偏移量给出,同时也给出了大小等信息。
其中比hello.o节头部表多出的部分如下(没有列出全部):
①.interp段:动态链接器在操作系统中的位置不是由系统配置决定,也不是由环境参数指定,而是由ELF文件中的 .interp段指定。该段里保存的是一个字符串,这个字符串就是可执行文件所需要的动态链接器的位置,常位于/lib/ld-linux.so.2。(通常是软链接)
②.dynamic段:该段中保存了动态链接器所需要的基本信息,是一个结构数组,可以看做动态链接下 ELF文件的“文件头”。存储了动态链接会用到的各个表的位置等信息。
③.dynsym段:该段与 “.symtab”段类似,但只保存了与动态链接相关的符号,很多时候,ELF文件同时拥有 .symtab 与 .synsym段,其中 .symtab 将包含 .synsym 中的符号。该符号表中记录了动态链接符号在动态符号字符串表中的偏移,与.symtab中记录对应。
④.dynstr段:该段是 .dynsym 段的辅助段,.dynstr 与 .dynsym 的关系,类比与 .symtab 与 .strtab的关系 hash段:在动态链接下,需要在程序运行时查找符号,为了加快符号查找过程,增加了辅助的符号哈希表,功能与 .dynstr 类似
⑤.rel.dyn段:对数据引用的修正,其所修正的位置位于 “.got”以及数据段(类似重定位段“rel.data”)。
⑥.rel.plt段:对函数引用的修正,其所修正的位置位于 “.got.plt”。
程序头部表描述了可执行文件的连续的片映射到连续的内存段的映射关系。包括目标文件的偏移、段的读写/执行权限、内存的开始地址、对齐要求、段的大小、内存中的段大小等。
以hello中的第一个LOAD为例,Offset说明段的偏移量为0;VirtAddr说明映射到的虚拟内存段的开始地址是0x400000;FileSiz说明段的大小为0x5c0字节;Memsiz说明内存中的段大小也是0x5c0字节;Flags为R ,标志段的权限为只读且不可执行;Align说明段的对齐要求为0x1000即8对齐。
在hello中,原来的.rela.text节已经没有了,说明链接的过程已经完成了对.rela.text的重定位操作。Hello中出现了6个新的重定位条目。这些重定位条目都和共享库中的函数有关,因为此时还没有进行动态链接,共享库中函数的确切地址仍是未知的,因此仍然需要重定位节,在动态链接后才能确定地址。
hello的符号表一共描述了50个符号,比hello.o多出33个符号。多出的符号都是链接后产生的库中的函数以及一些必要的启动函数。
此外。hello中还多出了一个动态符号表,表中的符号都是共享库中的函数,需要动态链接。
使用edb加载hello,可以看到本进程的虚拟地址空间各段信息。
可以看出,段的虚拟空间从0x400000开始,到0x400ff0结束。
由5.3的分析可知,.interp段从地址0x4002e0开始,相对于起始地址0x400000来说偏移量为0x2e0,大小为0x1c,对齐要求为1,查看虚拟地址0x4002e0处的值,发现共享库/lib64/ld-linux-x86-64.so.2信息。如下图所示:
.text段从地址0x4010d0开始,偏移量为0x10d0,大小为0x145,对齐要求为16,查看虚拟地址0x4010d0处的值,发现二进制序列与反汇编文件中代码符合,如下图所示:
.rodata段从地址0x402000开始,偏移量为0x2000,大小为0x3b,对齐要求为4,查看虚拟地址0x402000内容,发现其中就是两个字符串常量,如下图所示:
其他的节大同小异,不再展示。
命令:objdump -d -r hello > helloexe.txt对hello进行反汇编,结果保存到helloexe.txt中便于查看。
hello与hello.o的不同之处在于以下几个方面:
①hello中的汇编代码已经使用虚拟内存地址来标记了,从0x400000开始;而hello.o中的汇编代码是从0开始的,还没有涉及到虚拟内存地址。
②在hello.o中,只存在main函数的汇编指令;而在hello中,由于链接过程中发生重定位,引入了其他库的各种数据和函数,以及一些必需的启动/终止函数,因此hello中除了main函数的汇编指令外,还包括大量其他的指令。
③main函数中涉及重定位的指令的二进制代码被修改。在之前汇编的过程中,汇编器遇到对最终位置未知的目标引用,会产生一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。因此在链接的过程中,链接器会根据重定位条目以及已知的最终位置对修改指令的二进制码,这个过程就是重定位的过程。
下面以hello.o为例,说明hello如何进行重定位。
我们再来回顾一下hello.o中的重定位节:
重定位节给出了需要被修改的引用的节偏移、重定位类型、偏移调整等信息。
Hello.o中类型为R_X86_64_PC32和R_X86_64_PLT32,二者其实没有太大区别,都是32位PC相对地址的引用。
来看.rodata – 4,它的偏移为0x1c,在hello.o的反汇编中可以找到:
由于字符串常量的最终位置未知,因此产生了一个重定位条目。而重定位的目的就是修改这个数据,使得传入%rdi的是"Usage: Hello 学号 姓名!\n"的最终地址。同时,重定位类型为R_X86_64_PC32,因此地址为PC相对地址。由5.4可知,该字符串常量的地址为0x402008,因此重定位会将这条指令的最后四个字节改为c3 0e 00 00(小端形式的地址)。因为当前PC是0x401145, 0x402008-0x401145就是0xec3,所以偏移为0xec3。查看hello的反汇编结果,结果吻合。
|
据此我们也可以得到上述同样的结果。
名称 |
地址 |
ld-2.23.so!_dl_start |
0x7f7c8a4619b0 |
ld-2.23.so! dl_init |
0x7f7c8a470780 |
hello!_start |
0x4004d0 |
hello!__libc_start_main |
0x400480 |
libc-2.23.so!__libc_start_main |
0x7f7c8a0b6750 |
libc-2.23.so! cxa_atexit |
0x7f7c8a0d0290 |
hello!__libc_csu_init |
0x400580 |
hello!_init |
0x400430 |
libc-2.23.so!_setjmp |
0x00007faa21463cb0 |
libc-2.23.so!_sigsetjmp |
0x7f7c8a0cb1c0 |
hello!main |
0x401125 |
hello!puts@plt |
0x400460 |
hello!exit@plt |
0x4004a0 |
hello!printf@plt |
0x400470 |
hello!sleep@plt |
0x4004b0 |
hello!getchar@plt |
0x400490 |
ld-2.23.so!_dl_runtime_resolve_avx |
0x7f7c8a477870 |
libc-2.23.so!exit |
0x7f4ea0c8d5b0 |
当程序调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。但这需要链接器修改调用模块的代码段,GNU编译系统使用一种称为延迟绑定的技术将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过两个数据结构之间的交互来实现的,分别是GOT和PLT,GOT是数据段的一部分,而PLT是代码段的一部分。PLT与GOT的协作可以在运行时解析函数的地址,实现函数的动态链接。
过程链接表PLT是一个数组,每个条目是16字节代码。PLT[0]是一个特殊条目,跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。由5.3.2中的节头部表知,存储PLT的.plt节的开始地址为0x401020.
在hello的反汇编结果中可以查看到每个PLT条目。PLT[0]是一个特殊条目,跳转到动态链接器中。接下来每个条目对应一个调用的库函数,例如PLT[1]对应的是puts函数;PLT[2]对应的是printf函数;PLT[3]对应的是getchar函数等等。
全局偏移量表GOT是一个数组,每个条目为8字节地址,和PLT联合使用。GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息,GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。由5.3.2中的节头部表知,存储GOT的.got.plt节的开始地址为0x404000.
在edb中查看初始时的GOT条目,如下图所示。除了PLT[0]外,每个PLT对应的GOT条目初始时都指向这个PLT的第二条指令。例如:PLT[1]对应地址0x404018处的GOT[3],而0x404018处的值为0x401030(小端法),恰好指向PLT[1]的第二条指令。在函数第一次被调用时,动态链接器会修改相应的GOT条目。
同时也可以看到,GOT[1]和GOT[2]这两个条目初始时均为0。而GOT[1]应该包含动态链接器在解析函数地址时会使用的信息,GOT[2]应该为动态链接器在1d-linux.so模块中的入口点。使用edb调试,当dl_start函数返回后,发现这两个条目被修改为正确的值。
在函数第一次被调用时,动态链接器会修改相应的GOT条目。以puts函数为例,puts函数对应的是PLT[1],PLT[1]对应地址0x404018处的GOT[3],而GOT[3]的初始值为0x401030,指向PLT[1]的第二条指令。当第一次调用puts时,动态链接器确定puts的运行时位置,用这个地址重写GOT[3]。这时,puts函数才真正完成动态链接,后续对puts的调用就可以直接根据GOT[3]的值进行跳转。
在本章中主要介绍了链接的概念与作用,并且详细阐述了hello.o是怎么链接成为一个可执行目标文件的过程,详细介绍了hello.o的ELF格式和各个节的含义,并且分析了hello的虚拟地址空间、重定位过程、执行流程以及动态链接过程。
(第5章1分)
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。
作用:进程为用户提供了以下假象:
①我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存。
②处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
shell是指为使用者提供操作界面的软件,是一个交互型应用级程序,它接收用户命令,然后调用相应的应用程序。shell是系统的用户界面,提供了用户与内核进行交互操作的接口。
shell的作用:shell最重要的功能是命令解释,可以说shell是一个命令解释器。Linux系统上的所有可执行文件都可以作为shell命令来执行,同时它也提供一些内置命令。此外,shell还包括通配符、命令补全、命令历史、重定向、管道、命令替换等很多功能。
shell的处理流程:从终端读入输入的命令行->解析输入的命令行,获得命令行指定的参数->检查命令是否是内置命令,如果是内置命令则立即执行,否则在搜索路径里寻找相应的程序,找到该程序就执行它。
终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的 逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
以hello为例,当我们输入 ./hello 7203610401 王元辰 1 的时候,首先shell对我们输入的命令进行解析,由于我们输入的命令不是一个内置命令,因此shell会调用fork()创建一个子进程。
当创建了一个子进程之后,子进程调用execve函数在当前子进程的上下文加载并运行一个新的程序即hello程序,加载并运行需要以下几个步骤:
①删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
②映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
③映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
④设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到 CPU 引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
进程提供给应用程序的抽象:
一个独立的逻辑控制流,它提供一个假象,好像我们的进程独占的使用处理器;一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用CPU内存。
hello进程的执行是依赖于进程所提供的抽象的基础上,下面阐述操作系统所提供的的进程抽象:
①逻辑控制流::一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
②并发流:一个逻辑流的执行时间与另一个流重叠,成为并发流,这两个流成为并发的运行。多个流并发的执行的一般现象成为并发。
③时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
④私有地址空间:进程为每个流都提供一种假象,好像它是独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,在这个意义上,这个地址空间是私有的。
⑤用户模式和内核模式::处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
⑥上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
⑦上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:
▪保存以前进程的上下文,
▪恢复新进程被保存的上下文,
▪将控制传递给这个新恢复的进程,来完成上下文切换。
现在我们再来看一下hello进程执行,再进程调用execve函数之后,由上面分析可知,进程已经为hello程序分配了新的虚拟的地址空间,并且已经将hello的.txt和.data节分配虚拟地址空间的代码区和数据区。最初hello运行在用户模式下,输出hello 7203610401 王元辰,然后hello调用sleep函数之后进程陷入内核模式,内核不会选择什么都不做等待sleep函数调用结束,而是处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。如下图所示:
当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。
整个程序的执行效果如下:
异常和信号异常可以分为四类:中断、陷阱、故障、终止,各自的属性下表给出:
类别 |
原因 |
异步/同步 |
返回行为 |
中断 |
来自I/O设备的信号 |
异步 |
总是返回到下一条指令 |
陷阱 |
有意的异常 |
同步 |
总是返回到下一条指令 |
故障 |
潜在可恢复的错误 |
同步 |
可能返回到当前指令 |
终止 |
不可恢复的错误 |
同步 |
不会返回 |
hello执行过程中发生中断:如果其他进程使用了外部I/O设备,那么在hello进程运行时可能会出现外部I/O设备引起的中断。中断的处理:将控制传递给适当的中断处理程序,处理程序返回时,就将控制返回给下一条指令,程序继续执行,好像没有发生过中断一样。
hello执行过程中发生陷阱:hello中调用了系统调用sleep,产生陷阱。陷阱的处理:将控制传递给适当的异常处理程序,处理程序解析参数,调用适当的内核程序。处理程序返回时,将控制返回给下一条指令。
hello执行过程中发生故障:当hello进程刚从入口点开始执行时,会发生缺页故障。hello进程运行的过程中,也可能发生缺页故障。故障的处理:将控制传递给故障处理程序,如果处理程序能够修正这个错误情况,就将控制返回到引起故障的指令并重新执行它;否则终止引起故障的应用程序。
hello执行过程中发生终止:hello执行过程中,DRAM或者SRAM可能发生位损坏,产生奇偶错误。发生错误时会将控制传递给终止处理程序,终止引起错误的应用程序。
Hello程序在执行过程中,可能产生的信号如:SIGINT,SIGTSTP,SIGCHLD,SIGKILL,SIGALRM等等。进程接受到信号时,会触发控制传递到信号处理程序,信号处理程序运行,信号处理程序返回后,将控制返回给被中断的程序。每个信号类型有相关联的默认行为,使用signal函数可以修改和信号相关联的行为。
下面以hello的运行过程为例,简要说明异常与信号的处理。
①程序运行过程中不停乱按键盘,包括回车。如果乱按不包括回车,输入的字符串会缓存到缓冲区;如果输入的最后是回车,则getchar会读进回车,把回车前的字符串作为输入shell的命令:
②程序运行过程中键入Ctrl-Z。键入Ctrl-Z会发送SIGTSTP信号给前台进程组的每个进程,结果是暂停前台作业,也就是停止hello进程:
使用jobs命令可以查看当前的作业,可以看出当前的作业是hello进程,且状态是已停止:
使用ps命令可以查看当前所有进程以及它们的PID,进程包括bash,hello以及ps:
使用fg命令可以使停止的hello进程继续在前台运行(也可以再次键入Ctrl-Z使hello停止运行):
③使用kill命令可以给指定进程发送信号。比如 kill -9 9593 是指向PID为9593的进程(即hello)发送SIGKILL信号。这个命令会杀死hello进程,当再次使用ps时可以发现hello进程已经被杀死,使用jobs指令也看不到当前的作业了。
④程序运行过程中键入Ctrl-C。键入Ctrl-C会发送SIGINT信号给前台进程组的每个进程,结果是终止前台进程,即终止hello进程。使用ps命令也可以验证,hello进程已经终止并被回收,不再存在了。使用jobs指令也看不到当前的作业了。
本章主要介绍了进程的定义与作用,同时介绍了 Shell 的一般处理流程和作用,并且着重分析了调用 fork 创建新进程,调用 execve函数执行 hello,hello的进程执行,以及hello 的异常与信号处理。
(第6章1分)
逻辑地址:逻辑地址是指由程序产生的与段相关的偏移地址部分。例如,在进行C语言指针编程中,可以使用&操作读取指针变量的值,这个值就是逻辑地址,是相对于当前进程数据段的地址。一个逻辑地址由两部份组成:段标识符和段内偏移量。
线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址生成了一个线性地址。如果启用了页式管理,那么线性地址可以再变换产生物理地址。若没有启用页式管理,那么线性地址直接就是物理地址。
虚拟地址:因为虚拟内存空间的概念与逻辑地址类似,因此虚拟地址和逻辑地址实际上是一样的,都与实际物理内存容量无关。
物理地址:存储器中的每一个字节单元都给以一个唯一的存储器地址,用来正确地存放或取得信息,这个存储器地址称为物理地址,又叫实际地址或绝对地址。
逻辑地址由段标识符和段内偏移量两部分组成。段标识符由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号,是对段描述符表的索引,每个段描述符由8个字节组成,具体描述了一个段。后3位包含一些硬件细节,表示具体是代码段寄存器还是栈段寄存器还是数据段寄存器等。通过段标识符的前13位,可以直接在段描述符表中索引到具体的段描述符。每个段描述符中包含一个Base字段,它描述了一个段的开始位置的线性地址。将Base字段和逻辑地址中的段内偏移量连接起来就得到转换后的线性地址。
对于全局的段描述符,放在全局段描述符表中,局部的(每个进程自己的)段描述符,放在局部段描述符表中。全局段描述符表的地址和大小存放在gdtr控制寄存器中,而局部段描述符表存放在ldtr寄存器中。
给定逻辑地址,看段选择符的最后一位是0还是1,用于判断选择全局段描述符表还是局部段描述符表。再根据相应寄存器,得到其地址和大小。通过段标识符的前13位,可以在相应段描述符表中索引到具体的段描述符,得到Base字段,和段内偏移量连接起来最终得到转换后的线性地址。
页表是一个页表条目(PTE)的数组,虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。每个PTE由一个有效位和一个n位地址字段组成,有效位表明该虚拟页是否被缓存在DRAM中。如果设置了有效位,那么地址字段表示相应的物理页的起始位置;如果没有设置有效位,那么空地址表示虚拟页还未被分配,否则这个地址指向该虚拟页在磁盘的起始位置。
MMU利用页表实现从虚拟地址到物理地址的变换。CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含一个p位的虚拟页面偏移VPO和一个n-p位的虚拟页号VPN。MMU利用VPN选择适当的PTE,如果这个PTE设置了有效位,则页命中,将页表条目中的物理页号和虚拟地址中的VPO连接起来就得到相应的物理地址。否则会触发缺页异常,控制传递给内核中的缺页异常处理程序。缺页处理程序确定物理内存中的牺牲页,调入新的页面,并更新内存中相应PTE。处理程序返回到原来的进程,再次执行导致缺页的指令,MMU重新进行地址翻译,此时和页命中的情况一样。同时,也可以利用TLB缓存PTE加速地址的翻译。
TLB的支持:在MMU中包括一个关于PTE的缓存,称为翻译后备缓冲器(Translation Lookaside Buffer)。TLB是一个小的、虚拟寻址的缓存,每一行保存着一个由单个PTE组成的块。由于VA到PA的转换过程中,需要使用VPN确定相应的页表条目,因此TLB需要通过VPN来寻找PTE。和其他缓存一样,需要进行组索引和行匹配。如果TLB有2t个组,那么TLB的索引TLBI由VPN的t个最低位组成,TLB标记TLBT由VPN中剩余的位组成。
当MMU进行地址翻译时,会先将VPN传给TLB,看TLB中是否已经缓存了需要的PTE,如果TLB命中,可以直接从TLB中获取PTE,将PTE中的物理页号和虚拟地址中的VPO连接起来就得到相应的物理地址。这时所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。如果TLB不命中,那和7.3中描述的过程类似,需要从cache或者内存中取出相应的PTE。
四级页表的支持:多级页表可以用来压缩页表,对于k级页表层次结构,虚拟地址的VPN被分为k个,每个VPNi是一个到第i级页表的索引。当1≤j≤k-1时,第j级页表中的每个PTE指向某个第j+1级页表的基址。第k级页表中的每个PTE和未使用多级页表时一样,包含某个物理页面的PPN或者一个磁盘块的地址。对于Intel Core i7,使用了4级页表,每个VPNi有9位。当TLB未命中时,36位的VPN被分为VPN1、VPN2、VPN3、VPN4,每个VPNi被用作到一个页表的偏移量。CR3寄存器包含L1 页表的物理地址,VPN1提供到一个L1 PTE的偏移量,这个PTE包含某个L2页表的基址。VPN2提供到这个L2页表中某个PTE的偏移量,以此类推。最后得到的L4 PTE包含了需要的物理页号,和虚拟地址中的VPO连接起来就得到相应的物理地址。
当MMU完成了从虚拟地址到物理地址的转换后,就可以使用物理地址进行内存访问了。Intel Core i7使用了三级cache来加速物理内存访问,L1级cache作为L2级cache的缓存,L2级cache作为L3级cache的缓存,而L3级cache作为内存(DRAM)的缓存。
进行物理内存访问时,会首先将物理地址发送给L1级cache,看L1级cache中是否缓存了需要的数据。L1级cache共64组,每组8行,块大小64B。因此将物理地址分为三部分,块偏移6位,组索引6位,剩下的为标记位40位。首先利用组索引位找到相应的组;然后在组中进行行匹配,对于组中的8个行,分别查看有效位并将行的标记位与物理地址的标记位匹配,当标记位匹配且有效位是1时,缓存命中,根据块偏移位可以直接将cache中缓存的数据传送给CPU。如果缓存不命中,需要继续从存储层次结构中的下一层中取出被请求的块,将新块存储在相应组的某个行中,可能会替换某个缓存行。
L1级cache不命中时,会继续向L2级cache发送数据请求。和L1级cache的过程一样,需要进行组索引、行匹配和字选择,将数据传送给L1级cache。同样L2级cache不命中时,会继续向L3级cache发送数据请求。最后,L3级cache不命中时,只能从内存中请求数据了。
值得注意的是,三级cache不仅仅支持数据指令的访问,也支持页表条目的访问,在MMU进行虚拟地址到物理地址的翻译过程中,三级cache也会起作用。
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。内核给新进程创建虚拟内存,创建当前进程的mm_struct、区域结构和页表的原样副本,将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,为每个进程保持了私有地址空间的抽象概念。同时延迟私有对象中的副本直到最后可能的时刻,充分利用了稀有的物理内存。
exceve()函数在当前进程的上下文中加载并运行我们需要的hello程序。execve函数加载并运行可执行文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。
execve函数用hello程序有效替代当前程序,需要以下几个步骤:
①删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
②映射私有区域。为新程序(即hello)的代码、数据、bss和栈区域等创建新的区域结构。所有这些区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
③映射共享区域。如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
④设置程序计数器。最后设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
当内核调度这个进程时,它就将从这个入口点开始执行。Linux根据需要换入代码和数据页面。
缺页故障的产生:CPU产生一个虚拟地址给MMU,MMU经过一系列步骤获得了相应的PTE,当PTE的有效位未设置时,说明虚拟地址对应的内容还没有缓存在内存中,这时MMU会触发缺页故障。
缺页故障的处理:缺页异常导致控制转移到内核的缺页处理程序。处理程序随后执行以下步骤:(1)判断虚拟地址是否合法。缺页处理程序搜索区域结构的链表,把虚拟地址和每个区域结构中的vm_start和vm_end做比较。如果指令不合法,缺页处理程序会触发一个段错误,从而终止这个进程。(2)判断内存访问是否合法。比如缺页是否由一条试图对只读页面进行写操作的指令造成的。如果访问不合法,缺页处理程序会触发一个保护异常,从而终止这个进程。(3)这时,内核知道缺页是由合法的操作造成的。内核会选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。处理程序返回时,CPU重新执行引起缺页的指令,这条指令将再次发送给MMU。这次,MMU能正常地进行地址翻译,不会再产生缺页中断了。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但不失通用性,假设堆是一个请求二进制零的区域,紧接在未初始化数据区域后开始,向上生长。对每个进程,内核维护一个全局变量brk指向堆顶。分配器将堆视为一组不同大小的块的集合来维护。每个块是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留,供应用程序使用;空闲块可用来分配。空闲块保持空闲,直到空闲块显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的(即显式分配器),要么是内存分配器自身隐式执行的(即隐式分配器)。显式分配器和隐式分配器是动态内存分配器的两种基本风格。两种风格都要求应用显式地分配块,不同之处在于由哪个实体来负责释放已分配的块。显式分配器要求应用显式地释放任何已分配的块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。
显式分配器必须在一些约束条件下工作:处理任意请求序列;立即响应请求;只使用堆;对齐要求;不修改已分配的块。在这些限制条件下,分配器试图实现吞吐率最大化和内存使用率最大化,但这两个性能目标通常是相互冲突的。
分配器的具体操作过程以及相应策略:
①放置已分配块:当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。执行这种搜索的常见策略包括首次适配、下一次适配和最佳适配等。
②分割空闲块:一旦分配器找到了匹配的空闲块,需要决定分配这个空闲块中多少空间。可以选择用整个块,但会造成额外的内部碎片;也可以选择将空闲块分割为两部分,第一部分变成已分配块,剩下的变成新的空闲块。
③获取额外的堆内存:如果分配器不能为请求块找到空闲块,分配器通过调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插到空闲链表中,然后被请求的块放在这个新的空闲块中。
④合并空闲块:分配器释放一个已分配块时,要合并相邻的空闲块。分配器决定何时执行合并,可以选择立即合并或者推迟合并。合并时需要合并当前块和前面以及后面的空闲块。
组织空闲块的形式有很多,包括隐式空闲链表、显式空闲链表、分离的空闲链表等等。
带边界标签的隐式空闲链表分配器:一个块由一个字的头部、有效载荷、可能的一些额外的填充以及一个脚部。头部位于块的开始,编码了这个块的大小(包括头部、脚部和所有的填充)以及这个块是已分配的还是空闲的。由于对齐要求,头部的高位可以编码块的大小,而剩余的几位(取决于对齐要求)总是零,可以编码其他信息。使用最低位作为已分配位,指明这个块是已分配的还是空闲的。脚部位于每个块的结尾,是头部的一个副本,是为了方便释放块时的合并操作。头部后面就是调用分配器时请求的有效载荷,有效载荷后面是一片不使用的填充块,其大小可以是任意的。填充的原因取决于分配器的策略。如果块的格式是如上所述,就可以将堆组织成一个连续的已分配块和空闲块的序列,这种结构为隐式空闲链表。空闲块通过头部的大小字段隐含地连接,可以通过遍历堆中所有的块间接遍历整个空闲块的集合。同时,需要一个特殊标记的结束块(设置分配位而大小为零的头部),这种设置简化了空闲块合并。
显式空间链表:已分配块的块结构和隐式链表的相同,由一个字的头部、有效载荷、可能的一些额外的填充以及一个脚部组成。而在每个空闲块中,增加了一个前驱指针和后继指针。通过这些指针,可以将空闲块组织成一个双向链表。空闲链表中块的排序策略包括后进先出顺序、按照地址顺序维护、按照块的大小顺序维护等。显式空闲链表降低了放置已分配块的时间,但空闲块必须足够大,以包含所需要的指针、头部和脚部,这导致了更大的最小块大小,潜在提高内部碎片程度。
而malloc采用的是分离的空闲链表。分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小升序排列,当分配器需要一个大小为n的块时,就搜索相应大小类对应的空闲链表。如果不能找到合适的块,就搜索下一个链表,以此例推。
本章主要介绍了hello的存储器的地址空间,介绍了四种地址空间的差别和地址的相互转换。同时介绍了hello的四级页表的虚拟地址空间到物理地址的转换。阐述了三级cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
一个Linux文件就是一个m个字节的序列:
B0,B1 ,B2……Bm-1
所有的 IO 设备(例如网络、磁盘和终端)都被模型化为文件,所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux内核引出一个简单、低级的应用接口,称为 Unix I/O,使得所有的输入和输出都能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口
Unix IO 接口,使得所有的输入和输出都能以一种统一且一致的方式来执行:
①打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备。内核返回一个小的非负整数,即描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
②Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件中的常量可以代替显式的描述符值。
③改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置 k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
④读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发EOF条件,应用程序能检测到这个条件。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
⑤关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数:
①进程通过调用open函数打开一个存在的文件或者创建一个新文件。
int open(char* filename,int flags,mode_t mode);
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件;mode参数指定了新文件的访问权限位。
②进程通过调用close函数关闭一个打开的文件。
int close fd;
fd是需要关闭的文件描述符,成功返回0,错误返回-1。关闭一个已关闭的描述符会出错。
③应用程序通过分别调用read和write函数来执行输入和输出。
ssize_t read(int fd,void *buf,size_t n);
ssize_t wirte(int fd,const void *buf,size_t n);
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则返回值表示的是实际传送的字节数量。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
Printf函数主体如下:
Printf函数按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
write函数如下:
在printf中调用系统函数write(buf,i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,
int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
syscall函数体如下:
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是我们的打印字符串就显示在了屏幕上。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
getchar函数如下:
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成 ASCII 码,保存到系统的键盘缓冲区之中。
getchar 函数落实到底层调用了系统函数 read,通过系统调用 read 读取存储在键盘缓冲区中的 ASCII 码直到读到回车符然后返回整个字串,getchar 进行封装,大体逻辑是读取字符串的第一个字符然后返回。
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数的实现。
(第8章1分)
总结hello所经历的过程:
①hello.c:编写c程序,hello.c诞生,它是一个文本文件,hello.c中的每个字符都是用ascall编码表示。
②hello.i:hello.c经过预处理阶段变为hello.i。
③hello.s:hello.i经过编译阶段变为hello.s。
④hello.o:hello.s经过汇编阶段变为hello.o。
⑤hello:hello.o与可重定位目标文件和动态链接库链接成为可执行文件hello。至此可执行hello程序正式诞生。
⑥运行:在shell里输入./hello 7203610401 王元辰 1
⑦创建子进程:由于终端输入的不是一个内置的shell命令,因此shell调用fork函数创建一个子进程。
⑧加载::shell 调用 execve,execve 调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main 函数。
⑨上下文切换:hello调用sleep函数之后进程陷入内核模式,处理休眠请求主动释放当前进程,内核进行上下文切换将当前进程的控制权交给其他进程,当sleep函数调用完成时,内核执行上下文切换将控制传递给当前进程。
⑩动态申请内存:当hello程序执行printf函数时,会调用 malloc 向动态内存分配器申请堆中的内存。
11信号管理:当程序在运行的时候我们输入Ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
12终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法:
①计算机系统的设计思想和实现都是基于抽象实现的。从最底层的信息的表示用二进制表示抽象开始,到实现操作系统管理硬件的抽象:进程是对处理器、主存和I/O设备的抽象。虚拟内存是对主存和磁盘设备的抽象。文件是对I/O设备的抽象。
②计算机系统的设计精巧:为了解决快的设备存储小、存储大的设备慢的不平衡,设计了高速缓存来作为更底层的存储设备的缓存,大大提高了CPU访问主存的速度。
③计算机系统的设计考虑全面:计算机系统设计考虑一切可能的实际情况,设计出一系列的满足不同情况的策略。比如写回和直写,写分配和非写分配,直接映射高速缓存和组相连高速缓存等等。
(结论0分,缺失 -1分,根据内容酌情加分)
文件名称 |
文件作用 |
hello.c |
C语言源程序 |
hello.i |
hello.c预处理后的文件 |
hello.s |
hello.i编译后的文件 |
hello.o |
hello.s汇编后的文件 |
hello |
hello.o链接后的文件 |
helloelf.txt |
hello.o的ELF文件 |
hello_elf.txt |
hello的ELF文件 |
(附件0分,缺失 -1分)
为完成本次大作业你翻阅的书籍与网站等
[1]https://baike.baidu.com/item/%E9%A2%84%E5%A4%84%E7%90%86/7833652?fr=aladdin
[2] https://www.cnblogs.com/diaohaiwei/p/5094959.html
[3] https://www.cnblogs.com/pianist/p/3315801.html
[4] 兰德尔 E.布莱恩特,大卫 R.奥哈拉伦. 深入理解计算机系统. 机械工业出版社
[5] https://blog.csdn.net/rabbit_in_android/article/details/49976101
(参考文献0分,缺失 -1分)