计算机系统大作业
题目:程序人生-Hello’s P2P
计算机科学与技术学院
大作业题为程序人生-Hello’s P2P,围绕一个较简单的hello.c函数的整个生命周期展开,介绍了该示例程序从编写到执行到结束的全过程。详细地分析了每个阶段的具体操作过程,原理以及可能发生的一些异常情况等内容。将计算机系统的各个组成部分统一起来,并将所有学过的内容进行了整理与贯通。通过Linux中的命令行解析器shell进行操作,并使用了gdb、edb等调试工具,理论与实际相结合,并最终对计算机的体系结构有了更加深刻的认识。
关键词:编译系统;进程管理;存储管理;I/O管理
1.1 Hello简介
Hello程序是许多程序员最早接触的程序,它的源程序如下:
上面的源程序是许多IDE是直接默认生成的,程序员只需进行编译,程序即可运行,输出显示hello, world。尽管hello程序非常简单,但是为了让它实现运行,系统的每个主要组成部分都需要协调工作。hello程序的生命周期可概括为被程序员创建,在系统上运行,输出简单的消息,终止。
hello程序的生命周期是从一个高级C语言程序开始的,因为这种形式能够被人读懂。然后编译器驱动程序通过预处理,编译,汇编,链接四个阶段,将hello.c(program)翻译成一个可执行目标文件hello。hello文件存放在磁盘中,可以被加载到内存中,有系统执行。hello叫做程序(Program),用户通过shell,调用一系列函数将hello运行在内存中。他是通过一种叫做进程(Process)的抽象来实现的。
execve函数将hello加载至内存,顺着逻辑控制流,hello在硬件中取指译码执行,最终显示在屏幕上。最后程序终止,shell将子进程回收。
1.2 环境与工具
硬件环境 | X64 CPU;2GHz;2G RAM;256GHD Disk 以上 |
---|---|
软件环境 | Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位 |
开发工具 | Visual Studio 2010 64位以上;Codeblocks |
调试工具 | gdb,edb,readelf |
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
中间结果文件名称 文件作用
hello.c | hello源程序(文本) |
---|---|
hello.i | 修改了的源程序(文本) |
hello.s | 汇编程序(文本) |
hello.o | 可重定位目标程序(二进制) |
hello | 可执行目标程序(二进制) |
hello_o.elf | 可重定位目标文件hello.o的ELF格式文件 |
hello.elf | 可执行目标文件hello的ELF格式文件 |
helloo_obj.txt | hello.o程序的反汇编文件 |
hello_obj.txt | hello程序的反汇编文件 |
1.4 本章小结
这一章总结了整个实验的环境,使用的工具以及所有的中间结果,并且概述了hello程序的p2p以及020过程。
2.1 预处理的概念与作用
预处理阶段:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中#include
预处理的处理规则: |
---|
1.将所有的 “#define” 删除,并展开所有的宏定义 |
2.处理所有的条件预编译指令,比如:" #if #ifdef #elif #else #endif " |
3.处理所有的 “#include” 预编译指令 |
4.删除所有的注释 “//”,“/* */” |
5.添加行号和文件名标识,以便编译时产生的行号信息以及用于编译错误或警告时能够显示行号 |
6.保留所有的“#pragma”编译器指令 |
2.2在Ubuntu下预处理的命令
预处理命令:
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
通过Gedit编辑器打开hello.c源程序:
通过Gedit编辑器打开hello.i程序,并截取最后部分,如下图:
发现修改后的hello.i程序从源程序hello.c中的23行增加到了3042行,main函数在hello.i的最后一部分。hello.i程序处理了所有的“#include”预编译指令,并且从最后的main函数可以看出,hello.i程序删除了所有的注释。
2.4 本章小结
这一章介绍了编译系统中的预处理阶段的概念,以及具体的操作,并且分析了预处理结果hello.i文件。预处理阶段即预处理器cpp将源程序文件hello.c与其相关的头文件(例如stdio.h)预处理成一个修改了的源文件(.i文件)。
3.1 编译的概念与作用
编译阶段:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。该程序包含函数main的定义,如下图所示(不完整):
定义中的每条语句都以一种文本格式描述了一条低级机器语言指令。汇编语言是非常有用的,因为它为不同高级语言的不同编译器提供了通用的输出语言。
3.2 在Ubuntu下编译的命令
编译命令:
gcc -S hello.i -o hello.s
若直接从源文件(hello.c)到生成汇编语言文件(hello.s),则编译命令:
gcc -S hello.c -o hello.s
3.3 Hello的编译结果解析
先分析hello.s文件的开头部分:
.file “hello.c” | 源文件名为hello.c |
---|---|
.text | 代码段起始 |
.section .rodata | 下面是.rodata节 |
.align 8 | 对齐方式为8字节 |
.string “\347\224\250\346\263\225: Hello \345……\201” | 数据段中的字符串常量 |
.string “Hello %s %s\n” | 数据段中的字符串常量 |
.text | 代码段起始 |
.globl main | 全局变量(标签main是一个可以在其它模块的代码中被访问的全局符号) |
.type main, @function | 指定是对象类型或者函数类型 |
再分析main函数部分:
关于main函数参数的问题:
C语言规定main函数的参数只能有两个,习惯上这两个参数写为argc和argv。并且还规定了argc(第一个形参)必须是整型变量,argv(第二个形参)必须是指向字符串的指针数组。加上形参说明后,main函数的函数头应写为:
main (int argc,char *argv[])
关于atoi函数的问题:
atoi()是把字符串转换成整形数的一个函数。该函数会扫描参数nptr字符串,跳过前面的空白字符,例如空格,tab缩进等。
函数原型:int atoi(const char* nptr);
参数nptr:要进行转换的字符串;
返回值:返回一个int值,此值由将输入字符作为数字解析而生成。如果输入无法转换为该类型的值,则atoi的返回值为0。
说明:如果字符存在(跳过空格,全是空格返回0),是数字或者正负号则开始做类型转换,当出现一个字符不能识别为数字时(包括结束符\0),函数将停止读入字符串,返回整数值。否则,返回0。
3.3.1数据:
3.3.2赋值操作:
对循环变量i赋值,采用如下movl语句:
3.3.3算术操作:
3.3.4关系操作:
3.3.5控制转移:
3.3.6函数操作:
函数调用通过call指令来实现。call Q会把地址A压入栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。
3.4 本章小结
这一章总结了编译阶段的概念及具体操作指令,最后分析了编译结果hello.s文件。该文件包含一个汇编语言程序,汇编语言中的每条语句都以一种文本格式描述了一条低级机器语言指令。
汇编语言是直接面向处理器的程序设计语言,它所操作的对象不是具体的数据,而是寄存器或者存储器,因此执行速度要比其他语言快,但同时也使得编程更加复杂。汇编语言总体特性为机器相关性、高速度与高效率以及编写和调试的复杂性。
4.1 汇编的概念与作用
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
汇编阶段:汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它包含程序的指令编码。如果在文本编辑器中打开hello.o文件,将看到一堆乱码。
4.2 在Ubuntu下汇编的命令
汇编命令:
gcc -c hello.s -o hello.o
若直接从源文件(hello.c)到生成可重定位目标文件(hello.o),则汇编命令:
gcc -S hello.c -o hello.o
此时若用Gedit文本编辑器打开hello.o文件,将看到一堆乱码,如下图:
4.3 可重定位目标elf格式
首先,用readelf -a hello.o > hello_o.elf命令生成可重定位目标文件hello.o的ELF格式文件hello_o.elf。-a选项表示显示全部信息,如下图:
再用文本编辑器打开hello_o.elf文件。
1.ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。
2.节头部表
描述不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。夹在ELF头和节头部表之间的都是节。
3…rel.text重定位节
一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。
该重定位节中有8条重定位信息,分别为对第一个字符串内容(.L0)、puts函数、exit函数、第二个字符串内容(.L1)、printf函数、atoi函数、sleep函数、getchar函数的重定位声明。
4. .symtab节
一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。每个可重定位目标文件在.symtab中都有一张符号表。
符号表:每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。汇编器使用编译器输出到汇编语言.s文件中的符号构造ELF符号表,这张符号表包含一个条目的数组。在编译时,编译器向汇编器输出每个全局符号,或者是强,或者是弱,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。
关于重定位条目:
无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。下图展示了ELF重定位条目的格式:
offset:需要被修改的引用的节偏移
symbol:标识被修改引用应该指向的符号;占前4个字节
type:告知链接器如何修改新的引用;占后4个字节
addend:一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
重定位使用PC相对寻址方式,计算方法如下:
分析链接器如何用这个算法来重定位hello.o中的引用。先分析main.o的反汇编代码:
main函数为每个引用,汇编器产生了一个重定位条目,显示在引用的后一行上。这些重定位条目告诉链接器对
4.4 Hello.o的结果解析
通过命令:
objdump -d -r hello.o > helloo_obj.txt
获得hello.o程序的反汇编代码,并放入文件helloo_obj.txt中:
反汇编文件helloo_obj.txt与汇编文件hello.s比较:
1分支转移:在汇编代码中,分支跳转是直接以.L0等助记符表示:
但是在反汇编代码中,分支转移表示为主函数+段内偏移量:
2函数调用:汇编代码中,函数调用时call后直接是函数名@PLT:
反汇编中call之后是main+段内偏移量:
3全局变量访问:汇编代码中,全局变量访问.LC0(%rip):
反汇编中,全局变量访问0x0(%rip)(此时尚未进行重定位,全局变量全都初始化为0+%rip)。
机器语言的构成以及与汇编语言的映射关系:
机器语言是二进制机器指令的集合。机器指令展开来讲就是一台机器可以正确执行的命令。机器指令由操作码和操作数组成。汇编语言是一种用于计算机或其他可编程器件的低级语言,亦称为符号语言。是通俗的比较容易理解的语言。在不同的设备中,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。机器语言与汇编语言具有一一对应的映射关系,一条机器语言程序对应一条汇编语言语句。
4.5 本章小结
这一章总结了汇编阶段的概念及具体操作指令;分析了可重定位目标elf格式,特别是重定位项目的分析;最后分析了汇编结果hello.o文件,并将hello.o的反汇编文件helloo_obj.txt文件与汇编文件hello.s进行了比较分析。
生成的可重定位目标文件是一个二进制文件,在编辑器中打开是乱码,无法直接分析,可通过objdump转换为反汇编语言。
5.1 链接的概念与作用
链接阶段:
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器(loader)加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
hello程序调用了printf函数,它是每个C编译器都提供的标准C库中的一个函数。
printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。链接器(ld)就负责处理这种合并。结果就得到hello文件,它是一个可执行目标文件(或者简称可执行文件),可以被加载到内存中,由系统执行。
5.2 在Ubuntu下链接的命令
gcc链接命令:
gcc hello.o -o hello
若直接从源文件(hello.c)到生成可执行目标文件(hello),则gcc命令:
gcc -m64 -no-pie -fno -PIC hello.c -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
此时若用Gedit文本编辑器打开hello文件,将看到一堆乱码,如下图:
5.3 可执行目标文件hello的格式
首先,用readelf -a hello > hello.elf命令生成可执行目标文件hello的ELF格式文件hello.elf。-a选项表示显示全部信息,如下图:
再用文本编辑器打开hello.elf文件。
ELF头:
可执行目标文件格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式。与可重定位目标文件hello.o的ELF格式文件hello_o.elf的ELF头相比,hello.elf文件的ELF头发生了一些改变:
(1)它包括了程序的入口点地址,即当程序运行时要执行的第一条指令的地址
(2).init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。
(3).text、.rodata与.data节与可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时内存地址以外
(4)程序头大小与节头数量增加了
(5)ELF可重定位目标文件没有了.rel.text与.rel.data节,因为可执行目标文件中并不需要重定位信息,它是完全链接的(已被重定位)。
. rel.text :一个. text节中位置的列表,当链接器吧这个目标文件和其他文件组合时,需要修改这些位置
. rel.data :被模块引用或定义的所有全局变量的重定位信息
节头部表:
节头部表描述了不同节的大小和数量,它对hello中所有节的信息进行了说明,包括名称、大小、类型、在程序中的偏移量等等。
由于hello为完全链接的程序,因此根据给出的信息即可确定程序实际加载到虚拟地址的地址位置。
5.4 hello的虚拟地址空间
使用edb打开hello程序,通过Data Dump窗口可以查看加载到虚拟地址中的hello程序。代码段的信息如下所示。代码段开始于0x401090处。
同观察可知每一个节的地址和节头部表的Address的说明相同。
5.5 链接的重定位过程分析
通过命令:
objdump -d -r hello > hello_obj.txt
获得hello程序的反汇编代码,并放入文件hello_obj.txt中:
可以发现hello反汇编结果与hello.o的反汇编结果有些不同之处。可执行文件hello的反汇编结果中给出了重定位结果,而hello.o的反汇编结果中各部分的开始地址均为0,以main函数为例,如下图所示:
hello反汇编结果:
hello.o反汇编结果:
链接的过程:
在链接过程中使用的命令指定了动态链接器为64位的:/lib64/ld-linux-x86-64.so.2,同时添加了crti.o,crt1.o,crtn.o等系统目标文件,将程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中的sleep,printf,exit,getchar等函数以及_start函数中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将这些函数从不同文件中链接生成一个可执行文件。同时链接器根据可重定位目标文件中的重定位表同符号表一一对应,修改重定位信息。
5.6 hello的执行流程
通过使用objdump查看反汇编代码,并使用edb单步运行,可以观察到main函数前后调用的函数名称与地址,如下表:
函数名 | 地址 |
---|---|
0x3ee7e70b | |
0x3ee7efb1 | |
0x3ee7fe6f | |
0x3ee8064a | |
0x3ee806b9 | |
0x3ee813bb | |
0x3ee82212 | |
0x3ee83ac2 | |
0x3ee86d70 | |
0x3ee87671 | |
0x3ee8768a | |
0x3ee8a921 | |
0x3ee8b7c9 | |
0x3ee8c389 | |
0x3ee8e24d | |
0x3ee8e2be | |
0x3ee8f720 | |
0x3ee93d5d | |
0x004010dd | |
0x004010e7 | |
0x0040111a | |
0x0040112d | |
0x00401134 | |
0x00401143 | |
0x00401178 |
5.7 Hello的动态链接分析
动态链接是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时,还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
5.8 本章小结
这一章围绕可重定位目标文件的链接问题,介绍了可重定位文件hello.o链接生成可执行目标文件hello的过程。使用Linux下的链接命令可将hello.o转换为可执行目标文件hello。并对可执行文件的执行流程、动态链接等问题进行了分析。
6.1 进程的概念与作用
进程(process)的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。我们将关注进程提供给应用程序的关键抽象:
1.一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
2.一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell的概念:
Linux实质上是一个操作系统内核,一般用户不能直接使用内核,而是通过外壳程序(shell)来与内核进行沟通。Shell(命令行解释器)是一个交互型的应用级程序,它代表用户运行其他程序,是用户和系统内核沟通的桥梁。用户可以通过shell向操作系统发出请求,操作系统选择执行命令。一般我们使用的shell是bash,在解释命令的时候,bash不会直接参与解释,而是创建新进程进行命令的解释,bash只用等待结果即可,这样可以保证bash进程的安全。
Shell的处理流程:
1.用户在命令行中键入命令
2.Shell通过parseline builtin函数将命令字符串分割填充到参数数组,传个main。
3.Shell检查命令是否是内置命令,若是则立即执行;若不是再检查是否是一个应用程序(这里的应用程序可以是Linux 本身的实用程序),然后shell在搜索路径里寻找这些应用程序,调用相应程序(execve和fork)为其分配子进程;如果输入的命令不是一个内部命令且在路径里没有找到这个可执行文件,将会显示一条错误信息。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。Hello的fork进程创建过程可分为如下几步:
1.在终端输入命令行:./hello 1183710212 刘星宇 1
2.shell处理命令行,判断其是不是内置命令,shell判断其为当前目录文件下的可执行目标文件hello后,终端会调用fork函数创建一个新的子进程。
3.父进程和子进程是并发运行的独立进程,内核可以任意方式交替执行他们的逻辑控制流中的指令,所以这会导致我们不能简单的凭直觉判断指令执行的顺序。
4.父进程会默认等待子进程执行完之后回收子进程,但是也会有产生僵死进程的情况,父进程可以调用waitpid函数等待其子进程终止或停止。
6.4 Hello的execve过程
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。
Hello的execve过程:
1.当在命令行中输入./hello 1183710212 刘星宇 1命令后,shell会进行命令行解析,shell认为hello是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器的操作系统代码来运行它。任何linux程序都可以通过调用execve函数来调用加载器,加载器将可执行目标文件的代码和数据从磁盘中复制到内存,然后跳转到入口点来运行程序。
2.实际上execve在加载了filename之后会调用启动代码,启动代码设置栈,并将控制传递给新程序的主函数hello,该主函数有如下形式的原型:
或者等价的
当main开始执行时,用户栈的组织结构如下图所示:
从栈底往栈顶,依次是参数和环境字符串,以null结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串,全局变量environ指向这些指针中的第一个envp[0],以null结尾的argv[ ]数组,其中每个元素都指向栈中的一个参数字符串,在栈的顶部是系统启动函数libc_start_main的栈帧。
6.5 Hello的进程执行
用户模式和内核模式:
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常是用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位后,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令。
上下文切换的概念:
操作系统内核使用一种称为上下文切换的较高层次形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一组对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
上下文切换的过程:
1.保存当前进程的上下文
2.恢复某个先前被抢占的进程被保存的上下文
3.将控制传递给这个新恢复的进程
6.6 hello的异常与信号处理
异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。异常就是控制流中的突变,用来相应处理器状态中的某些变化。
异常可以分为四类:中断、陷阱、故障和终止,如下图:
1.程序执行过程按crtl+C:通过ps指令可以看出后台没有hello程序,说明crtl+c指令终止了hello,如下图:
2.程序执行过程crtl+z:通过ps和jobs指令都可以看到后台存在被挂起的hello程序,可以通过fg命令将其调成前台继续执行,说明crtl+z是停止(挂起)前台作业,如下图:
3.在程序执行过程中乱按:可以看出在程序执行过程中乱按实际上是将屏幕输入缓存到stdin,当getchar读到\n字符时,其他字符当做命令输入。
4.使用kill命令杀死程序hello,如下图:
6.7本章小结
这一章围绕hello的进程管理,总结了hello程序是如何在计算机中运行。hello以进程的形式运行,每个进程都处在某个进程的上下文中,每个进程也都有属于自己的上下文,用于操作系统进行进程调度。用户通过shell和操作系统交互,向内核提出请求,shell通过fork函数和execve函数来运行可执行文件。操作系统中有一套异常控制的系统,用于保障程序运行。最后在hello运行过程中执行了各种操作,了解了hello的异常与信号处理。
7.1 hello的存储器地址空间
逻辑地址:
由程序产生的和段相关的偏移地址部分,是相对于当前进程数据段的地址,不和绝对物理地址相关。只有在Intel实模式下,逻辑地址才和物理地址相等(因为实模式没有分段或分页机制,CPU不进行自动地址转换)。应用程序员仅需和逻辑地址打交道,而分段和分页机制仅由系统编程人员涉及。
线性地址:
逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
虚拟地址:
是指计算机呈现出要比实际拥有的内存大得多的内存量。因此他允许程序员编写并运行比实际系统拥有的内存大得多的程序。这使得许多大型项目也能够在具有有限内存资源的系统上实现。
有时我们也把逻辑地址称为虚拟地址。因为和虚拟内存空间的概念类似,逻辑地址也是和实际物理内存容量无关的。逻辑地址和物理地址的“差距”是0xC0000000,是由于虚拟地址->线性地址->物理地址映射正好差这个值。这个值是由操作系统指定的。机理逻辑地址(或称为虚拟地址)到线性地址是由CPU的段机制自动转换的。如果没有开启分页管理,则线性地址就是物理地址。如果开启了分页管理,那么系统程式需要参和线性地址到物理地址的转换过程。具体是通过设置页目录表和页表项进行的。
物理地址:
是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Segmentation的内存管理方式可以支持这种思路。逻辑地址空间由一组段组成。每个段都有名字和长度。地址指定了段名称和段内偏移。因此用户通过两个量来指定地址:段名称和偏移。段是编号的,通过段号而非段名称来引用。因此逻辑地址由有序对构成:
段偏移d因该在0和段界限之间,如果合法,那么就与基地址相加而得到所需字节在物理内存中的地址。因此段表是一组基地址和界限寄存器对。
以hello为例,在保护模式下,逻辑地址48位,线性地址32位,物理地址32位。48位逻辑地址分为16位段寄存器+32位有效地址,有效地址就是汇编中8(%ebp,%edx,4),也是段内偏移地址。段寄存器的结构如下:(段寄存器16bit是指程序员可见)
段寄存器的最低两位(RPL)描述了特权等级,TI是用于选择描述符表(0为GDT,1为LDT)。前13位作为段选择符,不是段描述符,段描述符大小8字节,结构如下:
段描述符中64bit,其中32位作为段基址,剩下32位描述了段信息,包括limit(段界限,显示段大小),DPL(特权等级:环保护模式,11为内核态,00为用户模式)。
但是其实段式管理是比较复杂的,有实模式和保护模式的区别,还涉及到8086处理器的历史。总的来说,段式管理能够很方便的将程序分成各个不同的段,设置相应的执行权限,同时还能方便的通过相对关系寻址,但也有一定的缺陷。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理的基本原理:
将各进程的虚拟空间划分成若干个长度相等的页,页式管理把内存空间按页的大小划分成片或者页面,然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
页式管理分类:
1 静态页式管理。静态分页管理的第一步是为要求内存的作业或进程分配足够的页面。系统通过存储页面表、请求表以及页表来完成内存的分配工作。静态页式管理解决了分区管理时的碎片问题。但是,由于静态页式管理要求进程或作业在执行前全部装入内存,如果可用页面数小于用户要求时,该作业或进程只好等待。而且作业和进程的大小仍受内存可用页面数的限制。
2 动态页式管理。动态页式管理是在静态页式管理的基础上发展起来的。它分为请求页式管理和预调入页式管理。
页式管理优缺点:
优点: 没有外碎片,每个内碎片不超过页大小
缺点:程序全部装入内存。要求有相应的硬件支持。这增加了机器成本。增加了系统开销。
7.4 TLB与四级页表支持下的VA到PA的变换
我们一直假设系统只使用一个单独的页表来进行地址翻译。假设32位虚拟地址空间被分为4KB的页,而每个页表条目都是4字节,还假设内存的前2K个页面分配给了代码和数据,接下来的6K个页面还未分配,再接下来的1023个页面也未分配,接下来的一个页面分配给了用户栈。下图展示了我们如何为这个虚拟地址空间构造一个两级的页表层次结构。
二级页表将VPN分成VPN1与VPN2两部分,分别作为一二级页表的索引。首先根据VPN1在一级页表中找到对应二级页表的基址,然后通过VPN2找到物流页的地址。这里可以看到二级页表是动态存在的,如果一级页表项无效,那么后面二级页表也不会存在,节省了大量内存空间,如下图:
TLB俗称快表,是页表(PTE)的高速缓存。它是一个小的,虚拟地址缓存,其中每一行都存放着一个由单个PTE组成的块。TLB通常具有高度的相联度。仍然是利用虚拟地址的VPN项,将VPN分成TLBI和TLBT两部分。如果给定一个有2^t个组的TLB,那么VPN的最低t位即作为TLB的组索引。剩余为作为标记位,找到组中对应行。
利用TLB作为页表的高速缓存过程如下图:
下图总结了完整的Corei7地址翻译过程,从CPU产生虚拟地址的时刻一直到来自内存的数据字到达CPU。Core i7采用四级页表层次结构,每个进程有它自己私有的页表层次结构。当一个Linux进程在运行时,虽然Core i7体系结构允许页表换进换出,但是与已分配了的页相关联的页表都是驻留在内存中的。CR3的值是每个进程上下文的一部分,每次上下文切换时,CR3的值都会被恢复。
7.5 三级Cache支持下的物理内存访问
三级cache支持下的物理内存访问过程:
1.CPU产生一个虚拟地址VA
2.MMU利用虚拟地址(VA)中的VPN从TLB中取出相应的PTE。若命中,MMU将PTE中的PPN与虚拟地址(VA)中的VPO串联,得到物理地址(PA)。
若不命中,利用VPN多级页表机制到内存中找对应的PTE,MMU翻译进而得到物理地址(PA)。
3.MMU将翻译成的物理地址发送到高速缓存/主存;高速缓存采用LI,L2,L3三级cache。
4.高速缓存/主存将所请求的数据字返回给CPU。
物理地址(PA)分成PPN和PPO。可将PPO进一步分成CI和CO,CI作为cache组索引,CO作为块偏移,PPN作为标记,如下图:
7.6 hello进程fork时的内存映射
内存映射:
Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射。虚拟内存区域可以映射到两种类型的对象中的一种:
1.Linux文件系统中的普通文件
2.匿名文件
无论在哪种情况中,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件之间换来换去。交换文件也叫做交换空间或者交换区域。在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数。
fork函数内存映射:
理解了虚拟内存和内存映射,就可以清晰地知道fork函数时如何创建一个带有自己独立虚拟地址空间的新进程的。
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数加载和执行hello程序:
假设运行在当前进程中的程序执行了如下的execve调用:
Execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域:为新程序的代码、数据、.bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。.bss是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。下图概括了私有区域的不同映射:
3.映射共享区域:hello程序与共享目标链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC):设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
1.缺页故障处理:
当CPU发送一个虚拟地址(VA)时,如果该物理地址不在内存中,必须从磁盘中取出,就会发生缺页故障,缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。
2.缺页中断处理:
确定缺页是由于对合法虚拟地址进行合法的操作造成之后,系统选择一个牺牲页面,如果这个牺牲页面被修改过,将其交换出去,换入新页面并更新页表,当缺页处理程序返回后,CPU重启引起缺页的指令。
7.9动态存储分配管理
动态内存分配器(例如malloc)维护着一个进程的虚拟内存区域,称为堆。对于每个进程,内核维护着一个变量brk,指向堆的顶部。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配,要么是空闲的。
分配器有两种基本风格,两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
1.显示分配器:要求应用显示释放任何已分配的块。
2.隐式分配器:也叫垃圾收集器,会自动监测不再使用的块将其释放回收。
造成堆利用效率很低的主要原因是碎片现象,碎片分为两种:
1.内部碎片:在一个已分配块比有效载荷大时发生的。
2.外部碎片:当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。
一个实际的分配器要在吞吐率和利用率之间把握好平衡,要考虑空闲块组织、放置新分配的块、分割、合并等问题。我们需要设计合适的数据结构来减少碎片,可用以下两种方式:
1.隐式空闲链表
任何实际的分配器都需要一些数据结构,允许它来区别边界,以及区别已分配块和空闲块。大多数分配器将这些信息嵌入块本身。一个简单的方法如下图:
基于以上块的格式,可将堆组织为一个连续的已分配块和空闲块的序列,我们还需要某种特殊标记的结束块,在以下示例中,就是一个设置了已分配位而大小为零的终止头部,如下图:
隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。
2.显式空闲链表
隐式空闲链表魁岸分配与堆块的总数呈线性关系,一种更好的方法是将空闲块组织为某种形式的显式数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继,如下图:
使用双向链表而不是隐式空闲链表,是首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
7.10本章小结
这一章围绕hello的存储管理,介绍了虚拟内存是如何简化加载和共享的。总结了虚拟地址到物理地址的翻译过程及多级cache下的物理内存访问过程。最后总结了动态内存分配管理的原理与不同的实现方式。
8.1 Linux的IO设备管理方法
设备的模型化:文件
一个Linux文件就是一个m个字节的序列:
B0,B1,……Bk,……Bm-1
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件。
设备管理:unix io接口
所有的输入和输出都被当作对文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
所有的输入和输出都能以一种统一且一致的方式来执行:
(1)打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O 设备。内核返回一个小的非负整数,叫做描述符(fd),它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
(2)Linux shell创建的每个进程开始时都有三个打开的文件:
标准输入(描述符为0),标准输出(描述符为1),标准错误(描述符为2)
(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函数来打开一个已存在的文件或者创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。Flags参数指明了进程打算如何访问这个文件:
O_RDONLY:只读
O_WRONLY:只写
O_RDWR:可读可写
mode参数指定了新文件的访问权限位。
(2)关闭文件:
进程通过调用close函数关闭一个打开的文件。关闭一个已关闭的描述符会出错。
(3)读文件:
应用程序通过调用read函数来执行输入。read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
(4)写文件:
应用程序通过调用write函数来执行输出。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
8.3 printf的实现分析
分析printf函数的源代码:
printf函数是可变参数的格式化输出函数,一般用于向标准输出设备按规定格式输出信息。函数返回值为整型,若成功返回输出的字符数,出错则返回负值。我们需要一种方法,让函数体知道具体调用时参数的个数。
1.printf函数中有一条语句:
va_list的定义为:typedef char va_list,说明va_list是一个字符指针。
(char)(&fmt) + 4) 表示的是…中的第一个参数的地址。
fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素;fmt也是个变量,它的位置是在栈上分配的,它也有地址。
对于一个char *类型的变量,它入栈的是指针,而不是这个char *型变量。换句话说,sizeof§ (p是一个指针,假设p=&i,i为任何类型的变量都可以)得到的都是一个固定的值。
2.printf函数中下一条语句:
查看并分析vsprintf函数:
printf接下来接受一个格式化的命令,并把指定的匹配的参数格式化输出。i = vsprintf(buf, fmt, arg); vsprintf返回要打印出来的字符串的长度。
3.分析write函数:
write(写操作),把buf中的i个元素的值写到终端。
INT_VECTOR_SYS_CALL的实现:
init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call, PRIVILEGE_USER);
一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
再跟踪syscall的实现:
一个call save是为了保存中断前进程的状态。Sys_call将字符串中的字节以ASCII字符形式从寄存器复制到显卡的显存中。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
printf()函数不能确定参数…究竟在什么地方结束,也就是说,它不知道参数的个数。它只会根据format中的打印格式的数目依次打印堆栈中参数format后面地址的内容。
8.4 getchar的实现分析
1.getchar函数的定义:
getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓 冲区中)。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ASCII 码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
2.getchar的实现分析:
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
3.使用getchar函数应注意几个问题:
1>getchar函数只能接受单个字符,输入数字也按字符处理。输入多于一个字符时,只接收第一个字符。
2>使用本函数前必须包含文件“stdio.h”
3>getchar什么字符都会读,即便是空白符和换行符
8.5本章小结
这一章分析了hello的IO管理,主要分析了Linux的IO设备管理方法、Unix IO 接口及其函数,并且重点分析了printf函数和getchar函数的底层实现。
所有的语言的运行时系统都提供了执行I/O的较高级别的工具。例如,ASCII C提供标准I/O库,它包含像printf和scanf这样执行带缓冲区的I/O函数。C++语言用它的重载操作符<<(输入)和>>(输出)提供了类似的功能。在Linux系统中,是通过使用由内核提供的系统级Unix I/O函数来实现这些较高级别的I/O函数的。了解Unix I/O将帮助理解其他的系统概念;有时除了使用Unix I/O以外别无选择。
1.总结hello所经历的过程:
hello程序虽然简单,但是运行它需要经历多个阶段,并且需要系统的每个主要组成部分协调工作。hello程序的生命周期可概括为下面几个主要阶段:
预处理阶段:hello.c通过预处理器(cpp)转变成了hello.i
编译阶段:hello.i通过编译器(ccl)转变成了hello.s
汇编阶段:hello.s通过汇编器(as)转变成了hello.o
链接:hello.o在系统执行文件的帮助下,动态链接生成了可执行文件hello
运行:在shell中输入命令
创建子进程:shell调用fork函数,创建了新的子进程
加载:execve函数调用加载器将hello程序加载到了该子进程。这里还有虚拟内存机制的帮助,我们能够轻松地完成内存映射
指令执行:加载器将程序计数器预置在entry point,我们的逻辑控制流可以跑起来了
中断:内核会周期性调度进程
访存:CPU通过MMU来访问物理地址
动态内存分配:动态内存分配器能够动态满足进程对空间的需求
信号:内核通过信号系统来处理程序执行中的用户请求和异常
终止:父进程回收已终止的子进程;如果一个父进程终止了,内核安排init进程回收,init进程不会终止,是所有进程的祖先。
2.对计算机系统的设计与实现的深切感悟;创新理念:
计算机系统是一个庞大且相互协调工作的系统,有完整的硬件与软件设备。通过对hello程序的整个生命周期进行分析,体会到了看似简单的几行C代码,实现其运行却需要许多阶段,并且对各种可能发生的情况进行分析与处理。要充分理解计算机系统的各个部分是如何协调工作的,不仅要从宏观、抽象层上进行把握,还要理解计算机底层硬件细节是如何工作的,这需要投入大量的时间与精力,并在不断的学习之后融会贯通,从而更加清晰地认识hello程序的整个运行到结束过程。
计算机的发展速度很快,在不停的更新换代,一些软硬件设计原理也有了改变与更新,还有许多创造性的发展。这就要求我们跟上时代的步伐,提高创新意识与创新能力,不局限于已有的计算机系统实现方式,在学习的同时多思考。
中间结果文件名称 | 文件作用 |
---|---|
hello.c | hello源程序(文本) |
hello.i | 修改了的源程序(文本) |
hello.s | 汇编程序(文本) |
hello.o | 可重定位目标程序(二进制) |
hello | 可执行目标程序(二进制) |
hello_o.elf | 可重定位目标文件hello.o的ELF格式文件 |
hello.elf | 可执行目标文件hello的ELF格式文件 |
helloo_obj.txt | hello.o程序的反汇编文件 |
hello_obj.txt | hello程序的反汇编文件 |
[1] 文章:readelf命令。作者:lijun5635。时间:2013-05-02。出处:CSDN。链接: https://blog.csdn.net/lijun5635/article/details/8876040.
[2] 文章:命令行参数的使用(讲解main函数的参数)。作者:bilibili-无聊星期三。时间:2018-01-02。出处:CSDN。链接:https://blog.csdn.net/Boring_Wednesday/article/details/78954104
[3] 文章:sleep()函数-延迟函数。作者:iteye_2449。时间:2011-05-24。出处:CSDN。链接:https://blog.csdn.net/iteye_2449/article/details/82068339
[4] 文章:printf函数。作者:狂风吹我心。时间:2019-12-21。出处:知乎。链接:https://zhuanlan.zhihu.com/p/95510086
[5] 文章:使用GDB调试汇编语言。作者:whoami_l。时间:2018-09-13。出处:CSDN。链接:https://blog.csdn.net/whoami_I/article/details/82670846
[6] 文章:操作系统学习记录之十一:页式管理,段式管理和段页式管理。作者:小小柴。时间:2018-08-13。出处:CSDN。链接:https://blog.csdn.net/cxy19931018/article/details/81631410
[7] 文章:printf函数实现的深入分析。作者:Pianistx。出处:CSDN。链接:https://www.cnblogs.com/pianist/p/3315801.html