计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L020203
班 级 2003003
学 生 陈美娜
指 导 教 师 史先俊
计算机科学与技术学院
2022年5月
本文主要论述了hello.c程序在编写之后,在linux下的生命周期。将通过以程序hello.c进行预处理、编译、汇编、链接、执行的过程为例,分析了这些过程中产生的文件的相应信息和作用。同时介绍内存管理、IO管理、进程管理等相关知识,虚拟内存、异常信号处理等相关内容。
关键词:编译;汇编;链接;内存管理;IO管理;虚拟内存;
目 录
第1章 概述
1.1 Hello简介
P2P:(1)首先在vim,codeblocks等编辑器下编辑好hello.c源文件,然后经过cpp预处理可得到修改了的源程序hello.i(文本),经过cc1编译可得到汇编程序hello.s,经过as汇编可得到可重定位目标程序hello.o,最后经ld链接可得到可执行目标程序hello,存放在磁盘上。
(2)然后在shell中输入运行该程序的命令./hello后,shell会通过fork创建一个子进程,此时会对hello文件进行加载并运行。
020:(1)运行中:fork一个子进程后,调用execve加载并运行hello程序,将其映射到虚拟内存,加载完成后处理器便开始执行该程序的指令,进入程序入口后程序开始载入物理内存,然后进入main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。
(2)运行结束后,父进程shell负责回收终止的hello子进程,已终止的子进程被回收后,内核会从系统中删掉它的所有痕迹。
1.2 环境与工具
硬件环境:Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz 2.30 GHz;16.0G RAM;
软件环境:Windows10 64位;Vmware 11以上;Ubuntu 20.04 LTS 64位
开发与调试工具:Visual Studio 2022 64位;CodeBlocks 64位;vi/vim/gedit+gcc;edb;gdb等
1.3 中间结果
hello.i | cpp预处理得到的修改了的源程序hello.i |
hello.s | cc1编译可得到汇编程序hello.s |
hello.o | 经过as汇编得到的可重定位目标程序 |
hello | 经ld链接可得到可执行目标程序hello |
hello.c | 源文件 |
hello_objdump.txt | 反汇编结果文本 |
1.4 本章小结
本章主要对hello.c程序的生命历程进行了总体的概述,简要的概括了hello漫游的大致过程,交代了所需的环境和中间结果。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器根据字符#开头的命令,修改原始的C程序。
作用:预处理是从源程序变成可执行程序的第一步,c预处理程序为cpp(即C Preprocessor) ,主要用于C语言编译器对各种预处理命令进行处理,包括对头文件的包含、宏定义的扩展、条件编译的选择等,例如,对于#include 指示的处理结果,就是将相应 .h 文件的内容插人到源程序文件中。
2.2在Ubuntu下预处理的命令
指令:Linux中hello.c文件进行预处理的命令是:gcc -E -o hello.i hello.c(或cpp hello.c>hello.i)
截图如下:
图2-2-1
2.3 Hello的预处理结果解析
预处理结果解析:打开hello.i文件,可以看到经过预处理的代码量变成了3000多行,代码量大大增加了。预处理处理了源文件中以“#”开头的编译指令,可以看到hello.i把头文件插到“#include”处,可以以递归的方式进行处理。文件也删除了所有的注释,看不到“.c”文件中的注释了。添加了行号和文件名标识,以便于编译时产生调试用的行号信息。如果代码中有#define命令还会进行删除并展开定义的宏。
.i文件结果截图:
图2-3-1
2.4 本章小结
本章介绍了预处理的相关概念和作用,查看了hello.i文件,并对.i文件进行了分析。
第3章 编译
3.1 编译的概念与作用
概念:编辑器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
作用:c 编译器在进行具体的程序翻译之前,会先对源程序进行词法分析、语法分析和语义分析, 然后根据分析的结果进行代码优化和存储分配,分析过程中发现有语法错误,给出提示信息,最终把C语言源程序翻译成汇编语言程序。
3.2 在Ubuntu下编译的命令
指令:Linux中hello.c文件进行预处理的命令是:gcc -S -o hello.s hello.c(或cc1 hello.c)
截图如下:
图3-2-1
3.3 Hello的编译结果解析
3.3.1汇编文件内初始部分
(1)相应节的作用:
.file "hello.c" //声明源文件“hello.c”
.text //代码节,存储程序的机器代码
.section .rodata //只读数据段
.align 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"//声明一个字符串
.text //代码节
.globl main //声明全局变量
.type main, @function//声明一个符号是函数类型还是数据类型
(2)原代码对应部分截图:
图3-3-1-1
3.3.2数据部分
- 常量
(1)字符串常量:程序中有两个字符串,也就是hello.c中打印语句中的字符串。
他们存在只读数据区中作为printf函数的参数,如下图部分;
图3-3-2-1
(2)整型常量:以立即数的形式直接体现在汇编代码中。例如下图:
图3-3-2-2
2.变量
(1)局部变量
main函数声明了一个局部变量i,i作为一个int的类型占4个单字节。编译器进行编译时将局部变量i会放在堆栈中。如图所示,局部变量i放在栈上-4(%rbp)的位置。
图3-3-2-3
(2)全局变量
参数 argc:命令行参数的总个数,包括可执行程序名。作为用户传给main的参数。也是被放到了堆栈中。
数组:char *argv[]是作为main函数的第二个参数,argv存储了所有的命令行参数,argv[i]存储第 i 个参数,argv[0]存储可执行程序名。argv[1]存储的是学号;argv[2]存储的是姓名;argv[3]存储的是秒数;数组的起始地址存放在栈中-32(%rbp)的位置如下图:
图3-3-2-4
3.3.3数据操作
1.赋值操作
赋值操作也就是hello.c中for循环中的i=0,在汇编代码中使用mov指令来实现,如下图:
图3-3-3-1
2.算术操作
算术操作也就是hello.c中for循环中的i++,在汇编代码中使用addl指令来实现,其中-4(%rbp)中存储的就是i的值,如下图:
图3-3-3-2
3.关系操作
hello.c中的if语句中的argc!=4,在汇编代码中使用je指令来实现,当不相等时就跳到.L2代码段,如下图:
图3-3-3-3
hello.c中的for循环中的i<8,作为判断循环条件指令被编译为cmpl $7,-4(%rbp),如下图:
图3-3-3-4
4.控制转移
编译器将if,for等控制转移语句都使用了cmp来比较然后使用了条件跳转指令来跳转
(1)if/else
If语句首先判断argc是否等于4,如果argc等于4,则不执行if语句,否则执行if语句,在汇编代码中使用je指令来实现,当不相等时就跳到.L2代码段,对应的汇编代码为:
图3-3-3-5
(2)for循环
每次判断需要i是否小于8来决定是否继续循环,在汇编代码中使用是否等于7来实现。.L4为循环的主体内容,对应的汇编代码为:
图3-3-3-6
5.函数操作
汇编代码中利用call指令来调用函数,将函数需要的参数存放在寄存器中或栈中来进行参数传递,函数调用结束之后,返回值保存在寄存器%rax中。比如如下的两次调用,分别调用了printf函数和atoi函数。
图3-3-3-7
3.4 本章小结
本章对hello.c程序的汇编阶段进行了分析,分析的汇编代码的数据与操作,分析了代码中的数据与操作,分别介绍了C语言中数据类型,赋值操作、关系操作、、控制转移和函数操作等对应的汇编语言的指令。
第4章 汇编
4.1 汇编的概念与作用
概念:将编译生成的汇编语言代码转换为机器语言代码的过程。
作用:将汇编代码转换成真正机器可以读懂的二进制代码。通常把汇编生成的机器语言目标代码文件称为可重定位目标文件。
4.2 在Ubuntu下汇编的命令
指令:Linux中hello.c文件进行预处理的命令是:gcc -C -o hello.o hello.c(或as hello.s -o hello.o)
截图如下:
图4-2-1
4.3 可重定位目标elf格式
1.使用指令readelf -a hello.o>hello_readelf.txt
图4-3-1
2.ELF头表
ELF头是以16字节的序列开始,这个序列描述了生成该文件的系统的字大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件信息。其中包括ELF头的大小、目标文件的类型,机器类型,节头部表的文件偏移,以及节头部表中条目的大小和数量。hello程序的ELF表信息如下图:
图4-3-2
3.节头部表
包含了文件中出现的各个节的含义,记录了各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐等信息。
图4-3-3
4.重定位节
重定位表中的每个表项都包含了 如何修改某个目标项的信息,一般,同一个重定位表中的表项都是描述同一个节区中符号的修改信息。.rela.data保存的是.data节中需要被修正的信息,rela.text保存的是.text节中需要被修正的信息;任何调用外部函数或者引用全局变量的指令都需要被修正;调用外部函数的指令需要重定位;引用全局变量的指令需要重定位; 调用局部函数的指令不需要重定位。
图4-3-4
5.符号表
.symtab : 一个符号表, 它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过一g 选项来编译一个程序, 才能得到符号表信息。实际上,每个可重定位目标文件在.symtab 中都有一张符号表(除非程序员特意用STRIP 命令去掉。
符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。以下为每个条目的格式。符号表由一个个符合表项构成,每个符号表项用来定位 、重定位程序中符号定义和引用 的信息。
图4-3-5
以下为hello程序相应的符号表:
图4-3-6
4.4 Hello.o的结果解析
1.命令:objdump -d -r hello.o
命令截图:(objdump -d -r hello.o>hello_objdump.txt,已经将反汇编的结果存储的hello_objdump.txt中。
图4-4-1
2.与hello.s进行对照分析
将反汇编代码和hello.s对照分析可看出,反汇编代码大体内容没有发生改变。反汇编代码增加的指令的机器代码表示内容。机器语言(machine language)是一种指令集的体系。这种指令集,称机器码(machine code),是电脑的CPU可直接解读的数据。机器代码是纯粹的二进制语言,由操作码和操作数构成,是电脑可以真正识别的语言。
机器码和汇编语言之间是一一对应的关系。汇编程序中存有汇编指令(助记符)和机器码(机器指令)之间一一对应关系的对照表。
反汇编代码和hello.s不同之处:
(1)分支转移:hello.s中,分支转移表示为主函数+段内偏移量。反汇编得到的代码中,跳转指令的操作数不再是.L2之类的代码段名称,而是具体的地址,操作数会发生相应的变化,如下图所示:
hello.s中:
图4-4-2
反汇编后:
图4-4-3
(2)函数调用:在hello.s中,函数调用是call加调用的函数名。如下图:
图4-4-4
而反汇编文件是call加下一条指令的地址,反汇编得到的是call 相对地址。在机器语言中全部初始化为0,等待重定位条目被解释,如下图:
图4-4-5
(3)访问字符串常量,在hello.s中,使用.L0(%rip)的形式访问,而在反汇编文件中以0x0(%rip)的形式访问,等待重定位条目被解释。
(4)操作数进制表示
hello.s文件采用10进制,而反汇编的采用16进制。
4.5 本章小结
本章对汇编过程进行了分析,介绍了hello 从hello.s 到hello.o 的汇编过程。生成了hello.o可重定位目标文件,并且分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,并使用objdump 得到反汇编代码与hello.s 进行比较,了解从汇编语言映射到机器语言汇编器需要实现的转换。
第5章 链接
5.1 链接的概念与作用
概念:是将各种代码和数据片段收集并组合成为一个单一文件的过程。
作用:链接的功能是将所有关联的可重定位目标文件组合起来, 以生成一个可执行文件。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。
5.2 在Ubuntu下链接的命令
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
截图:
图5-2-1
5.3 可执行目标文件hello的格式
1.ELF Header:命令:readelf -h hello
ELF 头中字段e-entry 给出系统将控制权转移到的起始虚拟地址(入口点), 即执行程序时第一条指令的地址。而在可重定位文件中,此字段为0。
图5-3-1
2.节头部表:命令:readelf -S hello
对Hello中所有节的信息进行了声明,包括大小size和偏移量offset,根据里面的信息可以定位各个节所占的区间,地址为被加载到虚拟地址的初始地址。
图5-3-2
图5-3-3
3.重定位节
图5-3-4
4.符号表.symtab
图5-3-5
5.4 hello的虚拟地址空间
虚拟地址空间起始地址为0x400000,程序头LOAD可加载的程序段的地址为0x400000
图5-4-1
图5-4-2
由elf文件可看出.inerp偏移量为0x2e0,在edb对应位置找到。
图5-4-3
5.5 链接的重定位过程分析
objdump -d -r hello结果截图:
图5-5-1
重定位过程如下:
(1)重定位节和符号定义:链接器将所有类型的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,这一步完成时,程序中每个指令和全局变量都有唯一的运行时地址;
(2)重定位节中的符号引用:链接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。链接器依赖于可重定位目标模块中重定位条目这个数据结构;
(3)重定位条目:当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。重定位条目放在.rel.text节。
经过对hello和hello.o的比较分析,可以看出hello反汇编的代码有明确的虚拟地址,即完成了重定位,而hello.o中虚拟地址为0,未完成重定位。
两者的汇编代码完全相同,但虚拟地址不同:hello.o中都是相对偏移,而在hello.t文件中全部变成了CPU直接寻址的绝对地址。链接器将hello.o中的偏移量加上虚拟内存起始地址和.text节的偏移量就得到了hello.out中的地址。控制转移指令后的地址由偏移量变成了偏移量加上函数的起始地址,call后的地址重定位后计算出实际运行时地址;
hello.out文件中多了一些节,这些节有其特定的含义,如.init是程序初始化需要执行的代码,.plt是动态链接的过程链接表,.fini是程序正常终止需要执行的代码。
5.6 hello的执行流程
执行流程:
(1) 载入:_dl_start、_dl_init
(2) 开始执行:_start、_libc_start_main
(3) 执行main:_main、_printf、_exit、_sleep、
_getchar、_dl_runtime_resolve_xsave、_dl_fixup、_dl_lookup_symbol_x
(4) 退出:exit
程序名称 | 地址 |
ld-2.31.so!_dl_start | 0x7fecc59c29a0 |
ld-2.31.so!_dl_init | 0x7fecc59c3130 |
hello!_start | 0x4010f0 |
hello!_main | 0x401125 |
libc-2.31.so!_cxa_atexit | 0x7fecc5989430 |
libc-2.31.so!_libc_csu_init | 0x4014c0 |
libc-2.31.so!_setjmp | 0x7fecc5984c10 |
libc-2.31.so!exit | 0x7fecc5989128 |
hello!puts@plt | 0x401030 |
hello!printf@plt | 0x401040 |
hello!getchar@plt | 0x401050 |
hello!atoi@plt | 0x401060 |
hello!exit@plt | 0x401070 |
hello!sleep@plt | 0x401080 |
5.7 Hello的动态链接分析
共享链接库代码是动态的目标模块,在程序开始运行或者调用程序加载时,可以自动加载该代码到任意的一个内存地址,并和一个在目标模块内存中的应用程序链接起来,这个过程就是对动态链接的重定位过程。
共享库是以两种不同的方式来“共享”的。首先,在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。其次,在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。
edb调试过程:
首先在elf文件中找到.got的地址0x403ff0
图5-7-1
在edb找到相应地址处,并且在dl_init处设置断点。
dl_init前:
图5-7-2
dl_init后:
图5-7-3
可以观察到,0404000到0404010之间的一段数据发生了变化。此变化便是GOT表中加载了共享库的内容。
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,跳转到动态链接器中。每个条目都负责调用一个具体的函数。PLT[[1]]调用系统启动函数 (__libc_start_main)。从PLT[[2]]开始的条目调用用户代码调用的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[[1]]包含动态链接器在解析函数地址时会使用的信息。GOT[[2]]是动态链接器在ld-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。
5.8 本章小结
本章主要介绍了链接的过程,利用edb、gdb、objdump等工具对链接的ELF文件、虚拟地址空间、重定位过程等进行详细分析,同时对hello程序的执行流程以及动态链接也做了简要介绍。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
作用:进程为程序提供了一种假象,程序好像是独占的使用处理器和内存。处理器好像是无间断地一条接一条地执行我们程序中的指令。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell是指为使用者提供操作界面的软件(命令解析器)。它接受用户命令,然后调用相应的应用程序。Linux系统中所有的可执行文件都可以作为Shell命令来执行。
处理流程:首先对用户输入的命令进行解析,判断命令是否为内置命令,如果为内置命令,调用内置命令处理函数;如果不是内置命令,就创建一个子进程,将程序在该子进程的上下文中运行。判断为前台程序还是后台程序,如果是前台程序则直接执行并等待执行结束,如果是后台程序则将其放入后台并返回。同时Shell对键盘输入的信号和其他信号有特定的处理。
6.3 Hello的fork进程创建过程
(1)打开terminal输入:Linux>./hello 120L020203 陈美娜 2
(2)然后shell就会分析这条指令,发现不是内置命令,于是判断./hello是执行当前目录下的可执行目标文件hello
(3)调用fork函数创建一个新的运行子进程 ,子进程得到与父进程用户级虚拟地址空间相同(但独立)的一份副本(包括代码段、数据段、共享库以及用户栈),意味着父进程调用fork时,子进程可以读写父进程打开的任何文件,而在子进程执行期间,父进程一直等待子进程完成。父进程与子进程的区别在于他们拥有不同的PID。
6.4 Hello的execve过程
当fork之后,子进程调用execve 函数在当前进程的上下文中加载并运行一个新程序即hello 程序,execve 调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello 程序,加载器删除子进程现有的用户虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC 指向_start 地址,_start 最终调用hello中的main 函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
6.5 Hello的进程执行
系统中每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,他是栈、通用目的寄存器的内容、PC、环境变量以及打开文件描述符的集合。
一个进程执行他的控制流的一部分的每一时间段叫做时间片。处理器通常用某个控制寄存器的一个模式位来提供用户模式和内核模式的功能。设置了模式位时,进程就运行在内核模式中,该进程可以执行指令集中的任何指令,可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中,不允许执行特权指令。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程的决定叫做调度。上下文切换的流程是:保存当前进程的上下文,恢复某个先前被抢占的进程被保存的上下文,最后将控制传递给这个新恢复的进程。过程如图所示:
图6-5-1
对于hello程序的运行过程:
(1)hello在刚运行时,内核为其保存一个上下文,进程在用户模式下运行,没有异常或中断信号的产生,hello就将一直正常的执行。如果产生了异常或者系统中断时,内核将启用调度器休眠当前进程,并在内核模式中完成上下文切换,将控制传递给其他进程。
(2)当程序在执行sleep函数时,系统调用显式的请求让调用进程休眠,调度器抢占当前进程,并产生上下文切换,将控制转移到新的进程。计数器到时后,产生一个中断信号,中断当前正在运行的进程,进行上下文切换恢复hello的上下文信息,控制会回到hello进程中。
(3)当循环结束后,程序调用getchar函数,hello之前运行在用户模式,进行read调用后掉入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且完成缓冲区到内存的数据传输后,引发一个中断信号,此时内核从其他进程上下文切换回hello进程。
如下程序运行截图:
图6-5-2
6.6 hello的异常与信号处理
异常可以分为四类: 中断(interrupt) 、陷阱(trap) 、故障(fault) 和终止(abort)如下图的几种异常种类:
图6-6-1
常见信号:
图6-6-2
几种异常运行结果截图:
(1)不停乱按:
图6-6-3
如果乱按的过程中包括回车,那么乱按的内容将会在该程序结束之后作为命令输入。
(2)Ctrl-C:
图6-6-4
在键盘上按下Ctrl-C之后,会导致内核发送一个SIGINT信号到前台进程组中的每个进程,默认情况下结果是终止前台作业。
(3)Ctrl-Z:
图6-6-5
输入ctrl-z默认结果是挂起前台的作业,hello进程并没有回收,而是运行在后台下,用ps命令可以看到,hello进程并没有被回收。此时他的后台 job 号是 1,调用 fg 1 将其调到前台,此时 shell 程序首先打印 hello 的命令行命令, hello 继续运行打印剩下的 8 条 info,之后输入字串,程序结束,同时进程被回收,如下图:
图6-6-6
(4)ps命令
图6-6-7
(5)jobs命令:
图6-6-8
(6)pstree命令:
图6-6-9
(7)fg命令:
图6-6-10
(8)kill命令:
图6-6-11
(9)正常运行hello程序
图6-6-11
6.7本章小结
本章简述了进程、shell的概念与作用,分析了hello程序使用fork创建子进程的过程以及使用execve加载并运行用户程序的过程,运用上下文切换、用户模式、内核模式、内核调度等知识,分析了hello进程的执行过程,最后分析了hello对于异常以及信号的处理并进行了实际操作。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或指令的地址。是由一个段标识符加指定段内相对地址的偏移量,表示为[段标识符:段内偏移量]。
线性地址:又叫虚拟地址。和逻辑地址类似,也是一个不真实的地址,线性地址对应内存的转换前地址。
虚拟地址:同线性地址。
物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线完成的。在前端总线上传输的内存地址就是物理内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理是从逻辑地址到线性地址的变换:
一个逻辑地址由两部分组成,段标识符、段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号,后面3位包含一些硬件细节,索引号,是“段描述符”,段描述符具体地址描述了一个段。这样,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,由8个字节组成。
给定一个逻辑地址[段选择符:段内偏移地址],转换过程如下:
1、首先根据段选择符判断当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。这样就得到了一个数组。
2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
3、Base + offset就是要转换的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
在分页的机制下地址转化管理机制主要实现了虚拟地址(它也即非非线性内存地址)向虚拟地址物理页或内存地址的非线性分页转化。vm内存系统将虚拟内存的块大小分割成作为一个被我们称为基于虚拟页的内存大小固定的块块用来进行处理这个固定大小的内存问题,每个称为虚拟页的内存大小可以固定为2p=2p个单位字节。类似的,物理块和虚拟内存被再一次细分为一个物理页(也被通常称为页帧),大小与每一个虚拟页的地址大小与其对应的值相等。例如:一个32位的虚拟机器,线性的地址可以最大达到4gb,用4kb为一个页来进行划分,也可以分为1m个页,通过页表处理和查找这些虚拟页的数据,方便对线性地址的大小进行管理。
之后计算机会进行一个翻译操作,把一个n元素的虚拟地址空间中的虚拟元素与一个m元素的另一个物理虚拟地址空间相互进行映射,这个翻译操作被我们称为地址翻译。
虚拟地址由对应的虚拟物理页号(vpn)和虚拟页偏移量(vpo)共同组成,类似的,物理地址由虚拟物理页偏移号(ppn)和对应的物理页偏移量(ppo)共同分配组成(这里没有特别考虑tlb快表的物理地址结构)。页表中物理地址存在三种常见的情况:未分配:没有在虚拟内存的空间中分配该条目的内存。未分配缓存:在虚拟内存的空间中已经分配了但是没有被直接缓存到对应物理地址的内存中。已分配已缓存:内存已经缓存在了对应物理地址的内存中。页表的基址寄存器paptbr+vpn在页表中可以获得条目pte,通过对比条目对应的有效位判断物理地址是上述哪一种的情况,如果有效则通过提取得出对应物理地址的页号寄存器ppn,与对应的虚拟页偏移量共同分配构成了物理地址寄存器pa。
当页面命中时CPU硬件执行的步骤:
第1步:处理器会产生一个虚拟地址,并且将它传送给地址管理单元MMU。
第2步: MMU生成PTE地址,并从高速缓存/主存请求得到它。
第3步:高速缓存或者主存向MMU返回PTE。
第4步:MMU构造物理地址,并把它传送给高速缓存/主存。
第5步:高速缓存或者主存会返回所请求的数据字给处理器。
7.4 TLB与四级页表支持下的VA到PA的变换
为了消除每次产生一个虚拟地址MMU就查阅一个PTE带来的时间开销,现代许多系统都在MMU中包括了一个关于PTE的小缓存,称为翻译后备缓冲器(TLB),TLB的速度快于一级cache。
TLB通过虚拟地址的VPN部分进行索引,分为索引(TLBI)与标记(TLBT)两个部分,这样,MMU在读取PTE时会通过TLB,不命中再从内存将PTE复制到TLB。同时为了减少页表太大造成的空间损失,可以使用层次结构的页表页压缩页表大小。
在四级页表层次结构的地址翻译中,CPU产生虚拟地址VA,VA传送给MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)向TLB中匹配。如果命中,得到PPN与VPO组合成PA。否则,MMU向页表中查询,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,如果在物理内存且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并且向TLB中添加条目。如果查询PTE发现不在物理内存中,则引发缺页故障。如果权限不足,则引发段错误。过程如图所示:
图7-4-1
7.5 三级Cache支持下的物理内存访问
以一级Cache为例。L1Cache有64组,组索引位s=6,每组有8行,由于每块大小为64B,故块偏移为6,标记位为40位。
具体过程如下:
1.组选择:取出虚拟地址的组索引位,将二进制组索引转化为一个无符号整数,找到相应的族;
2.行匹配:将虚拟地址的标记位与相应组中所有行的标记位进行比较,当虚拟地址标记位和高速缓存标记位匹配,且该行有效位为1,则高速缓存命中;
3.字选择:一旦高速缓存命中,我们就知道要找的字节在这个块的某个地方,块偏移位提供了第一个字节的偏移;
4.若不命中,则需要从L2中取出被请求的块,将新的块存储在组索引位指示的组中的一个高速缓存行中。放置策略如下:有空闲块直接放置,否则采用LRU策略进行替换。
7.6 hello进程fork时的内存映射
在shell输入命令行后,内核调用fork创建子进程,为hello程序的运行创建上下文,并分配一个与父进程不同的PID。通过fork创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。
7.7 hello进程execve时的内存映射
fork创建hello子进程后,在子进程中调用execve函数,加载并运行可执行程序hello,主要步骤如下:
1. 删除已存在的用户区域,也就是将shell与hello都有的区域结构删除。
2. 然后映射私有区域,即为新程序的代码、数据、bss和栈区域创建新的区域结构,均为私有的、写时复制的。映射共享区域,将一些动态链接库映射到hello的虚拟地址空间。
3. 设置PC,使之指向hello程序的代码入口。
经过这个内存映射的过程,在下一次调度hello进程时,就能够从hello的入口点开始执行了。
7.8 缺页故障与缺页中断处理
缺页故障:当指令引用一个相应的虚拟地址,而与该地址相应的物理页面不再内存中,会触发缺页故障。通过查询页表PTE可以知道虚拟页在磁盘的位置。缺页处理程序从指定的位置加载页面到物理内存中,并更新PTE。然后控制返回给引起缺页故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中,因此指令可以没有故障的运行完成。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。分配器将堆视为一组不同大小的块( block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,它们都要求应用显式地分配块,而不同之处在于由哪个实体来负责释放已分配的块:
显式分配器,要求应用显式地释放任何已分配的块;而隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。因此隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。
7.10本章小结
本章主要通过对hello程序运行时虚拟地址的变化进行分析,解析了适用于hello应用程序的虚拟存储地址空间,分析了虚拟地址,线性地址和虚拟物理线性地址之间的互相转换,页表的命中与不页表的命中,使用动态快表缓存作为页表的高速缓存以及如何加速页表,动态内存管理的操作,fork时的动态内存中断与映射、execve时的动态内存中断与映射、缺页的中断与缺页映射和中断的处理。这些缓存方面的基础知识有助于我们能够编写出更加对高速缓存友好的程序代码以及一些对高速缓存的优化解决手段,加速我们的程序运行。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的接口,称为Unix I/O,这使得所有的输入和输出能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix I/O接口的几种操作:
1.打开文件:程序要求内核打开文件,内核返回一个小的非负整数(描述符),用于标识这个文件。程序只要记录这个描述符便能记录打开文件的所有信息;
2.shell在进程的开始为其打开三个文件:标准输入、标准输出和标准错误;
3.改变当前文件的位置:对于每个打开的文件,内核保存着一个文件位置k,初始为0.这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作显式地设置文件的当前位置为k;
4.读写文件:一个读操作就是从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会触发一个称为EOF的条件,应用程序能够检测到这个条件,在文件结尾处没有明确的EOF符号;
5.关闭文件:内核释放打开文件时创建的数据结构以及其占用的内存资源,并将描述符恢复到可用的描述符池中。无论一个进程因为何种原因停止时,内核都会关闭所有打开的文件并释放他们的内存资源。
Unix I/O函数:
1.int open(char* filename, int flags, mode_t mode),open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位;
2.int close(int fd),关闭一个打开的文件,返回操作结果;
3.ssize_t read(int fd, void *buf, size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的实际传送的字节数量;
4.ssize_t write(int fd, const void *buf, size_t n),write函数从内存位置buf赋值至多n个字节到描述符fd的当前文件位置。
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分量)发送到液晶显示器。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从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函数中,首先声明了几个静态变量:buf表示缓冲区,BUFSIZ为缓冲区的最大长度,而bb指针指向缓冲区的首地址。
getchar调用read函数,将缓冲区读入到buf中,并将长度送给n,再重新令bb指针指向buf。最后返回buf中的第一个字符(如果长度n < 0,则报EOF错误)。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux系统中I/O设备的管理方法,Unix I/O接口和函数,并且分析了printf和getchar函数是如何通过Unix I/O函数来实现的。
结论
hello所经历的过程:
(1)首先在vim,记事本等编辑器下编辑好hello.c源文件。
(2)hello.c经过cpp预处理可得到hello.i,cpp进行了向源程序中插入包含的外部库、宏替换等操作。
(3)cc1将hello.i编译成汇编程序hello.s。
(4)hello.s经过as汇编可得到可重定位目标程序hello.o。
(5)hello.o最后经ld链接可得到可执行目标程序hello。
(6)在shell中输入运行hello的命令后,shell会通过fork创建一个子进程,至此hello就从一个program变成了一个process。
(7)fork一个子进程后,hello进程便有了独立的虚拟地址空间。
(8)调用execve加载并运行hello。
(9)CPU为运行着的hello程序分配时间片执行逻辑控制流。
(10)执行期间需要的指令或数据被从磁盘载入物理内存,还有可能被缓存在cache中。
(11)运行结束后,父进程shell负责回收终止的hello子进程,之后操作系统内核会从系统中删除hello的所有痕迹。
附件
hello.i | cpp预处理得到的修改了的源程序hello.i |
hello.s | cc1编译可得到汇编程序hello.s |
hello.o | 经过as汇编得到的可重定位目标程序 |
hello | 经ld链接可得到可执行目标程序hello |
hello.c | 源文件 |
hello_objdump.txt | 反汇编结果文本 |
参考文献
[1] Randal E. Bryant David R. O’Hallaron. Computer Systems A Progammer’s Perspective Third Edition
[2] https://www.cnblogs.com/diaohaiwei/p/5094959.html
[3] Randal E.Bryant David R.O’Hallaron. 深入理解计算机系统(第三版). 机械工业出版社,2016.
[4] https://blog.csdn.net/drshenlei/article/details/4261909