计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机类
*指 导 教 师 吴锐 *
计算机科学与技术学院
2019年12月
摘 要
当hello
world这个句子显示在电脑上时,标志着世界上第一个程序的诞生。然而很多人都认为让hello
world显示在屏幕上是一件很简单的事。殊不知,这个程序的所有执行周期却经历了很多阶段,当然,计算机科学家们在这个程序执行之前也做了大量的铺垫。所以,当我们研究hello
world程序的整个生命周期时,我们发现这个程序是不平凡的,我们也理解了计算机科学家们的执着与智慧。从开始对这个程序的编码,再到预处理,编译的高级语言阶段。再到汇编成机器语言,转换成机器码。再进行链接生成可执行文件,再执行。在此过程中,CPU,操作系统,内存,磁盘等计算机的重要组成部分都有条不紊的相互协调工作,其中又产生了进程的创建与回收、异常处理、内存管理等后台。这些后台过程都默默地支持着程序的执行。本篇论文来告诉你hello
world被打印在电脑屏幕里所要经历的所有过程。
**关键词:**程序 编码 执行 进程管理 储存管理
目 录
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 11
-
7.3 Hello的线性地址到物理地址的变换-页式管理 - 11
-
7.4 TLB与四级页表支持下的VA到PA的变换 - 11
-
7.7 hello进程execve时的内存映射 - 11 -
第1章 概述
1.1 Hello简介
Hello.c文件是通过vim
Code:Blocks,文档编辑器,Dev-cpp等文档编辑软件创建并编写相关hello.c代码来得到的
Hello.c文件在被创建后,经过预处理器的预处理(cpp),生成hello.i文件,之后经过编译器的编译(ccl)生成hello.s文本文件,在进行汇编器的汇编(as)翻译成机器语言指令生成可重定位目标程序,在通过连接器链接(ld)生成可执行目标文件之后便生成了hello的可执行文件,让计算机运行。
在计算机执行这个文件的过程中,通过fork()创建子程序,这样也将我从program变成了一个进程。
然后再execve(),再执行过程中hello程序映射虚拟内存,然后载入物理内存,进入CPU处理,进入main函数执行目标代码,CPU为文件hello分配时间片,执行;执行逻辑控制流,根据有之前生成的机器指令进行取值、译码、执行、更新等操作。与此同时,内存管理器(MMU)和CPU通过L1、L2、L3三级缓存与TLB多级页表在物理内存中取得相关数据,通过I/O管理与信号处理根据代码指令进行输出。知道进程结束,CPU收到信号,回收进程,删除内存,并执行回收的相关程序,这样就结束了hello的生命,就是hello的O2O过程。
1.2 环境与工具
硬件:i5-8250U,MX-150,8G内存,256G+1TSSD
软件:Windows 10,乌班图18.04,vim,readelf,gcc,gdb,objdump,edb,hexedit。
1.3 中间结果
-
hello.c 给的源文件
-
hello.i hello-1.i通过预处理器处理的hello.c文件
-
hello.s hello1.s通过编译器处理的hello.i文件
-
hello.o通过汇编器处理的hello.s文件
-
hello.out 通过ld(链接器)处理的hello.o文件
-
hello.out.objdump通过反汇编处理的hello.out文件
1.4 本章小结
本章从hello的整个生命周期的角度分析了hello.c文件从编写到被执行并输出相关结果的过程。宏观地介绍了hello的生命周期,以及介绍了与程序执行相关的术语。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理就是对程序进行第一次扫描编译之前程序应该做的工作。即根据字符以#开头的命令,修改原始的C程序
预处理所做的工作[1]:
-
对于类似诸如#include<stdio.h>的包含头文件的形式(也就是文件包含),预处理器将stdio.h文件里面的内容替换掉这一行,如果在这个头文件里面还有文件包含的指令,预处理器就会递归的将相关文件内容复制并替换其中的那一行,知道没有文件包含的指令。
-
预处理器将#define 字符串
数字进行预处理,将.c文件中所有这样的字符串用该数字进行替换。 -
如果#define还有参数代换诸如:#define 宏名(参数表)
字符串的形式。例如:#define S(a,b)
a*b。area=S(3,2);//第一步被换为area=a*b; ,第二步被换为area=3*2; -
条件编译#if-#else-#endif, #ifndef-#define-#endif, #ifdef-#endif
#if 后只接真假值(0,1), 为1则编译 #if 下面的程序一直到
#else(如果有的话)或者到 #endif -
预处理还有头文件保护功能,即有一个头文件保护符,头文件保护符依赖于预处理变量。[3]
-
预处理器还可以添加行号,为后面的其他阶段的进行提供便利。
2.2在Ubuntu下预处理的命令
图2-2-1
预处理可以使用gcc -E、也可以使用cpp 来进行对目标文件的预处理
2.3 Hello的预处理结果解析
图2-3-1
这是预处理器进行文件替换的时候替换的文件的路径
这是替换文件时替换后相关文件的定义
图2-3-2
定义的外部函数
图2-3-3
结尾才出现程序本体
2.4 本章小结
本章对预处理进行了解释,并简单地介绍了预处理的相关规则。通过实际操作了解了如何进行预处理,解析进行预处理后的文件。
第3章 编译
3.1 编译的概念与作用
编译:将人们可读的高级语言通过编译器翻译成机器指令。编译器编译一个程序通过词法分析、语法分析、语义分析等,在检查无错误后后,把代码翻译成汇编语言。
编译的作用:通过编译,通过预处理得到的.i文件就会被翻译成.s文件。
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
首先展示源程序里面的内容
图3-3-1
3.3.1汇编指令
在介绍汇编指令之前,先看看hello.s的部分汇编语言
图3-3-2
这是hello.s文件前半部分的内容,下面通过这个表格来解释这个汇编语言
指令 | 含义 |
---|---|
.file | 文件名(声明源文件) |
.text | 代码段 |
.globl | 声明全局变量 |
.data | 声明数据段 |
.align | 声明对指令或数据存放地址的对齐方式 |
.type | 指定类型 |
.size | 指定大小 |
.string | 声明strings类型 |
.long | 声明long类型 |
.section .rodata | 声明只读数据段 |
3.3.2数据类型
-
整型
-
int sleepsecs=2.5;这里的sleepsecs是指全局变量并且已经赋初值为2.5(应该是2)
图3-3-3 全局变量的声明
-
由图3-3-2可以知道编译器在.data节中将sleepsecs设置成全局变量4字节对齐,在.data节中。在sleepsecs中,编译器为其设置成.long型变量,并且向下取整,将sleepsecs设置成2。
-
int
i:在hello.c的开头定义了一个临时变量i,而实际上这个临时变量则被储存在栈帧中。
图3-3-4
这个图行号28则是将i储存在-4(%rbp)中。
-
int
argc:是main函数的第一个参数,表示命令行中输入了几个字符串进去了。从后面的代码进行对比这来看,我们可以知道这个argc是被储存在-20(%rbp)中。 -
立即数
这里的3与10作为立即数,是直接被拿出来比较的,而像34和41行代码的立即数则用$作为标志来表示。 -
字符串在这个程序中有两个字符串
图3-3-5
这两个个字符串中不同的地方在于一个有汉字,汉字则用多位数字来编码像上图中的\350\214\203\…而如果是纯字母与数字的话,则用ASSCI保存。遇到汉字时,有一个标志码,而标识码因不同编码格式而不同。
- 数组,源程序中有两次出现数组。第一次是在main函数的形参中出现的,第二是在循环中出现的
图3-3-6
根据汇编代码,我们可以知道,这个数组是字符串型指针数组,每个指针大小为8个字节,而且指向字符串。从汇编代码(图3-3-7)可以知道,这个数组应该分别在连续的栈中(从第41行到第44行可以看出来*argv[0]应该在-32(%rbp)里面)。
图3-3-7
- 类型转换:在这里由于我们的sleepsecs是整型的全局变量,但是我们给它赋值2.5在编译过程中,可能编译器会警告可能强制转换中会使数据失真。而实际上编译器根据sleepsecs定义的类型对2.5做出取整处理。从而得到2,将2赋值给sleepsecs。而在其他情况中,强制转换可以说是无处不在,有显式的强制转换(程序员规定的强制转换),也有隐式的强制转换。而我们在进行强制转换的过程中切记,一般情况下将精度小的数转换成精度大的数,将取值范围小的类转换成取值范围大的类,当然也要注意符号,
3.3.4 汇编语言操作
- 数据传送
图3-3-8 数据传送
由于数据传送是每个程序的必要指令,所以就不必列举hello.s里面的数据了
- 压栈与弹栈指令
在第22行出现pushl指令,第59行出现leave指令
图3-3-9压栈与弹栈
leave指令:leave指令将EBP寄存器的内容复制到ESP寄存器中,以释放分配给该过程的所有堆栈空间。然后,它从堆栈恢复EBP寄存器的旧值。
- 算数与逻辑操作指令
图3-3-10
如下图所示,可以看出在hello.s中有多处使用算数与逻辑操作指令,所以这也是基本的指令操作-0x32(%rbp)
图3-3-11
- 控制转移指令:控制转移有两种形式,一种是条件转移。另一种是比较转移指令。下面将所有的控制转移指令都展示在下图中。在hello.s中体现控制转移的语句也有很多,例如图3-3-11的第31行的跳转令,根据30行的比较指令设置的条件码来确定是否跳转,以及第38行的无条件跳转指令。现在我以30,31行为例说明比较指令与跳转指令,30行通过比较将3与argc进行比较,如果相等(31行)则跳转到L2处。而第38行则为无条件跳转。
图3-3-12
- 控制转移指令
图3-3-13
将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码的起始位置。不过,当稍后从Q返回的时候,处理器必须记录好它需要继续P的执行的代码位置。在x86-64机器中,这个信息是用指令call Q调用过程Q来记录的。该指令会把地址A压人栈中,并将PC设置为Q的起始地址。压人的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。
图3-3-14
如上图,第49和52行,将跳转到printf函数与sleep函数。而33与35行则跳转到puts函数与exit函数去。
-
函数
-
main(int argc,char*
argv[])函数:主函数,第一个参数表示命令行输入了几个字符串,第二个参数的每一个指针各自指向了每一个字符串。在本例中,argc被储存在-0x20(%rbp)中,而argv则储存在-0x32(%rbp)中。 -
exit()函数:退出函数。与return功能上大致相同。不过要设置%edi值为1。
-
printf()函数:打印函数,打印相应字符串.两次printf函数在被调用前都各自将LCD0与LCD1的首地址传入%rdi中。
-
getchar()函数:输入函数,输入字符(串)。并将输入的字符串转换为ASCII码。若出错则返回为-1.
-
sleep()函数:将%edi设置为sleepsecs的值.,通过call指令调用sleep函数,期间,若信号中断,则返回剩余时间,否则,返回0.
-
3.4 本章小结
本章介绍了hello.i文件编译的基本情况,以及编译后的结果。并对编译结果中的每条指令进行一一分析。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
汇编:就是用汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在hello.o中,而hello.o是二进制文件。
4.2 在Ubuntu下汇编的命令
图4-1
4.3 可重定位目标elf格式
用readelf -a
-hello.o可以解析到程序所有的ELF信息,如果想只看一部分的话可以用readelf
-h来看看自己需要哪个部分,然后进行命令行输入。
ELF头可以分为以下几个部分。下面来一一介绍从程序头表可以读出的信息。
- ELF头
ELF头定义了ELF魔数(可以读出文件类型,加载或读取文件的时候,可以用魔数确认文件类型是否正确)、版本、小端/大端、操作系统平台、目标文件的类型、机器结构的类型、程序执行的入口地址、程序头表(段头表)的起始位置与长度、节头表的起始位置和长度等。
图4-2
在hello.o文件的程序头表中(图4-2)红框告诉我们ELF头的基本信息,黄框告诉我们节头表的信息。最后一行告诉我们.strtab在节头表中的索引。
- 节头表
图4-3
除了ELF头之外,节头表是ELF可重定位目标文件中最重要的部分内容,节头表描述每个节的节名、在文件中的偏移、大小、访问属性、对齐方式等,每个表项占40B,从上图中我们可以知道节头表的所有信息。注意:可重定位目标文件中,每个可装入节的起始地址总是0.
- 重定位节
图4-4
这里是rel.txt与rel.data节的基本信息,上面基本介绍了需要重定位的符号在文件中的位置(偏移量)、信息、类型、符号值、符号名称与加数。也就是说,在进行链接过程中连接器需要在这些地方修改相应编码。
重定位多种重定位方式,下面我们介绍最基本的重定位方式。首先我们介绍重定位节的基本信息。
首先是重定位条目:
-
typedef struct{
-
long offset; //节内偏移
-
long symbol:24, //所绑定符号
-
type:8 //重定位的类型 R.offset=0x18;
-
long addend //重定位位置的辅助信息
-
}Elf64_Rel;
然后是最基本的重定位类型:R_x86_64_PC32 :绝对地址;
R_x86_64_32_PLT32:PC的相对地址。
首先是R_x86_64_PC32(这里用.rodata来举例分析,其他的可以类比)
R.offset=0x18;
R.symbol=.rodata;
R.type=R_x86_64_PLT32;
R.addend=-0x4;
ADDR(R.symbol)=ADDR(.rodata)
*refptr=(unsigned)(ADDR(R.symbol)+R.addend)计算.rodata的地址了;
其次用exit()来对R_x86_64_32_PC32类型进行重定位。
由上图知
R.offset=0x27;
R.symbol=.exit;
R.type=R_x86_64_PC32;
R.addend=-0x4;
ADDR(e)=ADDR(.text);
ADDR(R.symbol)=ADDR(sum);
Refaddr=ADDR(s)+R.offset;
*refptr=(unsigned)(ADDR(r.symbol)+R.addend-refaddr)
可以求出exit函数的相对调用exit那一行的的下一条指令的偏移量。
- 符号节信息
图4-5
符号表信息主要包含:程序中定义和引用函数和全局变量的信息,并声明重定位需要引用的符号。
4.4 Hello.o的结果解析
图4-6
得到的文件内容分析如下(对hello.s与hello1.s进行比较)
现在说明这两个文件里面内容的异同
- 从机器语言构成来看
hello.s是通过伪指令来实现跳转(比如jmp
.L3),而hello1.s通过按照地址偏移量直接进行跳转,当然形成hello1.s的也是从hello.s进行汇编而来,它将伪指令替换成相应的地址。
- 在函数以及对全局变量调用方面
hello.s是直接call相应的函数(当然在call之前也要将相关的寄存器值进行修改),而hello1.s则是后面跟着00
00 00
00这个需要在重定位(链接)过程中通过链接器计算相应的偏移量,然后再进行赋值。
- 在其他方面
hello.s显式的表示出了各个节的内容,但是在hello1.s里面则隐藏了.data节与.rodata节等。
图4-7 hello1.s的内容
图4-8 hello.s
4.5 本章小结
主要分析hello函数的汇编,以及将其与hello的编译进行对比。
第5章 链接
链接的概念与作用
-
链接的概念:链接过程将多个可重定位目标文件合并以生成可执行目标文件。
-
链接的作用:
-
链接可以简化我们写程序的步骤,这样我们就不用担心程序内指令的执行顺序比如跳转指令跳到哪里,这些事我们计算机都帮我们做好了。
-
模块化:一个程序可以分成很多源程序文件;可构建公共函数库,如数学库,标准C库等。以便代码重用,提高开发效率。
-
提高效率:正如(1)所言通过链接可以减少程序员的工作量。还有
时间上,可分开编译:只需要重新编译修改的源程序文件,然后重新链接;
空间上,无需包含共享库所有代码:源文件中无需包含共享库函数的源码,只要直接调用即可(如,只要直接调用printf()
函数,无需包含其源码),另外,可执行文件和运行时的内存中只需包含所调用函数的代码,而不需要包含整个共享库。
在Ubuntu下链接的命令
图5-1
5.3 可执行目标文件hello的格式
图5-2 hello.out的ELF头表
图5-3 hello.ou的节头表
图5-4 hello.out的程序头表
如图从hello.out的程序头表可以知道相关的部分的读写权限,大小,对齐要求,虚拟地址以及物理地址和偏移量,同样的在INTERP部分还记录了动态库的位置。
hello的虚拟地址空间
图5-5 代码段的内容(在0x3293f660处)大小0x202
5.5 链接的重定位过程分析
将hello.out进行反汇编(反汇编的内容在hello.out.objdump中)
图5-6
与hello.o有所不同,hello.out是从.init节开始的。由于进行了重定位,所以这些地址都是虚拟地址,而且跳转与引用都是对应一个地址或者一个偏移量。所以在链接阶段,这些需要的重定位条目都需要用地址或者偏移量来代替。然后与动态库进行动态链接(如果不是在运行时链接的话)并将其映射到一个虚拟空间上。在hello.out的反汇编代码中多出了.init
.plt .got(这是在.text节之前出现的)
.finit节(在.text节之后)这与对hello.o的反汇编代码有所不同。.init是程序的初始化代码
.pit是过程链表
.got是全局偏移量表(进行重定位所需要的通过位置无关代码确定模块外函数与数据的相对偏移量),.finit是程序终止代码。
在动态链接中可以通过我们之前介绍的方式来进行重定位,也可以用PIC来进行重定位,简单来说如果是模块外的数据引用与函数调用,我们可以用.pit的代码与.got(的相关数据)与动态链接器,将模块外调用的地址填写在.got的相应位置上。从而实现链接(重定位)
5.6 hello的执行流程
加载的程序 | ld-2.27.so!_dl_start |
---|---|
ld-2.27.so!_dl_init | |
LinkAddress!_start | |
ld-2.27.so!_libc_start_main | |
ld-2.27.so!_cxa_atexit | |
LinkAddress!_libc_csu.init | |
ld-2.27.so!_setjmp | |
程序执行 | LinkAddress!main |
程序终止 | ld-2.27.so!exit |
5.7 Hello的动态链接分析
图5-7
上图就是在执行动态链接时起作用的两个节。这是从节头表读出的。
正如前文所言,在程序正式执行之前先要进行动态链接,也就是说在执行dl_init之后动态链接器就将计算好了的地址写到GOT表中(在数据段中)
图5-8
在GOT表中给出了跳转地址,这样就直接跳转到正确的函数中。
5.8 本章小结
本章浅显地介绍了动态链接,以及在链接过程中的相关问题。
第6章 hello进程管理
6.1 进程的概念与作用
1.进程:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
2.进程的作用:清晰地刻画动态系统的内在规律,有效管理和调度进入计算机系统主存储器运行的程序。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用:Shell是一个用C语言编写的程序,通过Shell用户可以访问操作系统内核服务,类似于DOS下的command和后来的cmd.exe。Shell既是一种命令语言,又是一种程序设计语言。作为命令语言,它交互式地解释和执行用户输入的命令;作为程序设计语言,它定义了各种变量、参数、函数、流程控制等等。它调用了系统核心的大部分功能来执行程序、建立文件并以并行的方式协调各个程序的运行。
Shell的处理流程:
-
终端进程读取用户由键盘输入的命令行。
-
分析命令行字符串,获取命令行参数,并构造传递给execve和argv向量
-
检查第一个(首个,第0个(命令行参数是否是一个内置命令
-
如果不是内部命令,调用fork()创建子进程
-
在子进程中,用步骤二获取的参数,调用execve()执行指定程序
-
如果用户没要求后台运行,否则shell使用waitpaid等待作业终止后返回
-
如果用户要求后台运行,则shell返回。
6.3 Hello的fork进程创建过程
创建过程:
(1)给新进程分配一个标识符
(2)在内核中分配一个PCB,将其挂在PCB表上
(3)复制它的父进程的环境(PCB中大部分的内容)
(4)为其分配资源(程序、数据、栈等)
(5)复制父进程地址空间里的内容(代码共享,数据写时拷贝)
(6)将进程置成就绪状态,并将其放入就绪队列,等待CPU调度。
注意:fork()调用一次返回两次,在父进程中返回子进程的PID,在子进程中返回0.父子进程在此时的内容是完全一样的。只是PID不同。
6.4 Hello的execve过程
execve(执行文件)在父进程中fork一个子进程,在子进程中调用exec函数启动新的程序。exec函数一共有六个,其中execve为内核级系统调用,其他(execl,execle,execlp,execv,execvp)都是调用execve的库函数。
当对hello进行execve时,由于在shell中输入命令行参数并构造了argv与envp向量。所以execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。有几个步骤:删除已存在的用户区域、映射私有区域、映射共享区域、设置程序计数器
6.5 Hello的进程执行
hello.out在被执行执行首先得在shell中输入命令行,进行命令行解析。解析之后进行上下文切换,在内核态中调用fork函数、为hello创建一个子进程,之后调用execve函数。进入hello进程,在hello函数中由于调用sleep函数,再次进行内核态,进程调度使之进入其他进程并置零计时器。当计时器为2秒的时候。上下文切换进入hello函数并继续执行hello的逻辑流。这里介绍几个概念:
上下文:内核重新启动一个被抢占的进程所需的状态。它由一些对象的值构成,这些对象包括目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
调度:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫调度。
CPU调度是操作系统的基本功能。每当CPU空闲的时候,操作系统就会从就绪队列中选择一个程序来执行。进程选择由短期调度程序执行。
CPU调度决策一般发生在如下四种情形。
当一个进程从运行状态切换到等待状态。
当一个进程终止。
当一个进程从运行状态切换到就绪状态。
当一个进程从等待状态切换到就绪状态。
时间片:是分时操作系统分配给每个正在运行的进程微观上的一段CPU时间。
6.6 hello的异常与信号处理
1.键盘键入Ctrl+z
图6-1
2.键盘键入回车键
图6-2
3键盘输入ctrl+c
图6-3
图6-4
还尝试过其他命令行(比如输入 kill -11 -1)有惊喜。
可以看到运行hello时键盘中断有某种优先级,在输入回车时反而会阻塞这个信号,在输入ctrl+z时停止hello程序但是jobs里面还存在该进程,所以用kill
-9就杀死该进程了。ctrl+c则结束进程。
6.7本章小结
本章简要地介绍在shell里执行hello的过程以及进程管理。了解fork与execve系统调用函数
第7章 hello的存储管理
hello的存储器地址空间
逻辑地址:是指由程序产生的与段相关的偏移地址部分;
线性地址:是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:CPU启动保护模式后,程序运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。
物理地址:在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical
Address),又叫实际地址或绝对地址;放在寻址总线上的地址。放在寻址总线上,如果是读,电路根据这个地址每位的值就将相应地址的物理内存中的数据放到数据总线中传输。如果是写,电路根据这个地址每位的值就在相应地址的物理内存中放入数据总线上的内容。物理内存是以字节(8位)为单位编址的。
Intel逻辑地址到线性地址的变换-段式管理
图7-1
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
-
看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
-
拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
-
把Base +offset,就是要转换的线性地址了。 还是挺简单的,对于软件来讲,原则上就需要把硬件转换所需的信息准备好,就可以让硬件来完成这个转换了。
- Hello的线性地址到物理地址的变换-页式管理
PU的页式内存管理单元,负责把一个线性地址,最终翻译为一个物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。
另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。
这里注意到,这个total_page数组有2^20个成员,每个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。
图7-2
1、分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。
2、每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中,将别个的保存下来。
3、每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位)
4、依据以下步骤进行转换:
①从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
②根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
③ 根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
④ 将页的起始地址与线性地址中最后12位相加,得到最终我们想要的物理地址;
7.4 TLB与四级页表支持下的VA到PA的变换
●第1步:CPU产生一个虚拟地址。
●第2步和第3步: MMU从TLB中取出相应的PTE。.
●第4步:MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
●第5步:高速缓存/主存将所请求的数据字返回给CPU。
当TLB不命中时,MMU必须从L1缓存中取出相应的PTE,如图7-3b)所示。新取出的PTE存放在TLB中,可能会覆盖一个已经存在的条目。
图7-3
图7-3给出了Core17MMU如何使用四级的贝表来将虚拟地址翻译成物理地址。36位VPN被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1PET的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2PTE的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
三级缓存结构的物理内存如图所示,当L1不命中时会访问L2,以此类推。其中L1的指令缓存与数据缓存是分开的。
图7-4
7.6 hello进程fork时的内存映射
fork()函数被调用时内核为新进程创建一个新的数据结构,并分配一个唯一的PID。创建了当前进程的mm_struct,区域结构和页表原样的副本。
图7-5
7.7 hello进程execve时的内存映射
●删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
●映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些 新的区域都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的. text和.data区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello.out中。栈和堆区域也是请求=进制零的,初始长度为零。图7-6概括了私有区域的不同映射。
●映射共享区域。如果hello.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
●设置程序计数器(PC)。 execve做的最后- -件事情就是设置当前进程,上下文中的程序计数器,使之指向代码区域的入口点。
图7-6
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页(page fault)。图7-7展示了在缺页之前我们的示例页表的状态。CPU引用了VP 3中的一个字,VP 3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE 3,从有效位推断出VP 3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择- -个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP 4不再缓存在主存中这一事实。
接下来,内核从磁盘复制VP 3到内存中的PP 3,更新PTE3,随后返回。当异常处.理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。图7-7展示了在缺页之后我们的示例页表的状态。
图 7-7
7.9动态存储分配管理
内存管理需要分配器,分配器有以下几个要求
-
处理任意请求序列
-
立即响应请求
-
只使用堆
-
对齐块
-
不修改已分配的块
内存分配时可以使用显式空闲链表,从而在分配内存的时候查找适合的块的时间是对空闲块成线性变化的,空闲链表中前驱与后继分别指向上一个空闲链表与下一个空闲链表。表头有相应信息,表示链表状态与链表大小。如下图所示
图7-8
在分配空闲链表的时候,可以采用分离适配原则。这样可以大幅减少查找适合的空闲块的时间与提高空间利用率。当然使用红黑树是最佳答案。
当然可以采用隐式链表,使用首次适配原则,可以简单的实现一个分配器。
7.10本章小结
介绍了hello程序的内存管理。以及相应的物理地址,线性地址,逻辑地址及其它们之间的转换。还介绍了地址翻译的过程以及内存管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
类型:
普通文件
目录
套接字
设备管理:unix io接口
所有的I/O设备都被模型化为文件,而所有的输入输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix
I/O。
8.2 简述Unix IO接口及其函数
IO接口及其函数
Unix IO接口:
-
打开文件:一个应用程序通过要求内核打开相应文件来宣告他想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件内核记录有关这个文件的所有信息。应用程序只需记住这个描述符。
-
shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。
-
改变当前文件的位置。对于每个打开的文件,内核保持这一个文件位置k,初始为0。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
-
读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
-
关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
函数:
1.打开文件
int open(char *filename , int flags , mode_t mode);
open函数将filename转换成一个文件描述符,并且返回描述符数字,出错则返回-1
参数flags指明了进程打算如何访问这个文件,也可以是一个或者更多位掩码的或,为写提供一些额外的指示
参数mode指定了新文件的访问权限位
2.关闭文件
int close(int fd);
成功返回0,出错返回-1
3.读文件
ssize_t read(int fd , void *buf , size_t n);
返回:成功则为读的字节数,若EOF则为0,若出错为-1
4.写文件
ssize_t write(int fd , const void *buf ,size_t n);
8.3 printf的实现分析
函数声明
int printf(char *format…);
调用格式
printf("<格式化字符串>", <参量表>);
格式化字符串包含三种对象,分别为:
(1)字符串常量;
(2)格式控制字符串;
(3)转义字符。
字符串常量原样输出,在显示中起提示作用。输出表列中给出了各个输出项,要求格式控制字符串和各输出项在数量和类型上应该一一对应。其中格式控制字符串是以%开头的字符串,在%后面跟有各种格式控制符,以说明输出数据的类型、宽度、精度等。
首先看看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;
}
函数的定义过程中使用了可变参数。
其中,fmt是指向字符的指针,指向第一个参数,这个参数是固定的,可以通过这个参数的位置及C语言函数参数入栈的特点来引用其他可变参数。
vsprintf函数返回打印字符串的长度,它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
之后调用write(buf,i),查看write汇编代码
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
Linux系统下,所有的I/O设备都被模型化为文件,而所有的输入输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,即Unix
I/O,这使得所有的输入输出都能以一种统一且一致的方式来执行
结论
-
首先分别用预处理器将hello.c变成hello.i,之后分别用编译器,汇编器,链接器将其变成hello.s
hello.i hello.out文件。 -
在shell中输入相应命令行,构造相应的argv向量
-
shell为hello调用fork创建一个子进程,之后调用execve执行程序,利用加载器将hello.out文件里面的内容与物理地址建立一个映射关系
-
执行程序,屏幕出现hello
-
调用sleep系统函数,然后进行上下文切换,进程转入其他进程中。置计时器为0,当时间片用完后,调用内核函数,将进程转入hello中,继续执行hello.out文件的逻辑流。
-
程序结束,随之进程也结束,然后向shell进程发送SIGCHLD信号,shell回收进程,释放相应的内存空间。
感悟:hello.c从文件到执行,我们见证了计算机发展的历史过程,我们也接触了科学界大佬的智慧结晶。这样以来激发了我们的学习兴趣,通过梳理hello程序的整个生命周期,我从硬件到软件层次上全方位的了解了计算机,虽然比较浅显。但是我也知道了自己的不足。这样以来,更加深入的学习相关知识就是我的当务之急。当然我们也从中知道了,一个体系的设计就是从简单慢慢的升级到复杂的层次。这是计算机发展的规律。
附件
列出所有的中间产物的文件名,并予以说明起作用。
-
hello.c 给的源文件
-
hello.i hello-1.i通过预处理器处理的hello.c文件
-
hello.s hello1.s通过编译器处理的hello.i文件
-
hello.o通过汇编器处理的hello.s文件
-
hello.out 通过ld(链接器)处理的hello.o文件
-
hello.out.objdump通过反汇编处理的hello,out文件
参考文献 -
https://blog.csdn.net/DLUTBruceZhang/article/details/8753765--C语言中预处理详解
-
深入理解计算机系统 第三版
-
C++ primer 第五版
-
https://baike.baidu.com/item/虚拟地址/1329947?fr=aladdin——虚拟地址与物理地址的百度百科
-
http://bbs.chinaunix.net/thread-2083672-1-1.html—— LINUX
逻辑地址、线性地址、物理地址和虚拟地址 -
https://baike.baidu.com/item/printf
知道了自己的不足。这样以来,更加深入的学习相关知识就是我的当务之急。当然我们也从中知道了,一个体系的设计就是从简单慢慢的升级到复杂的层次。这是计算机发展的规律。
附件
列出所有的中间产物的文件名,并予以说明起作用。
-
hello.c 给的源文件
-
hello.i hello-1.i通过预处理器处理的hello.c文件
-
hello.s hello1.s通过编译器处理的hello.i文件
-
hello.o通过汇编器处理的hello.s文件
-
hello.out 通过ld(链接器)处理的hello.o文件
-
hello.out.objdump通过反汇编处理的hello,out文件
参考文献 -
https://blog.csdn.net/DLUTBruceZhang/article/details/8753765--C语言中预处理详解
-
深入理解计算机系统 第三版
-
C++ primer 第五版
-
https://baike.baidu.com/item/虚拟地址/1329947?fr=aladdin——虚拟地址与物理地址的百度百科
-
http://bbs.chinaunix.net/thread-2083672-1-1.html—— LINUX
逻辑地址、线性地址、物理地址和虚拟地址 -
https://baike.baidu.com/item/printf
来源:CSDN
作者:weixin_45748510
链接:https://blog.csdn.net/weixin_45748510/article/details/103753000