目 录
第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.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 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
1.P2P
在Windows或Linux环境下,编写C语言代码,得到最初的hello.c程序,这就是P2P的第一个P即Program。接下来的过程需要调用语言预处理器、编译器、汇编器和链接器。首先,hello.c文件经过预处理器cpp,生成hello.i文件;接着经过编译器ccl生成hello.s汇编语言文本文件;第三步,经过汇编器as生成可重定位目标程序hello.o;最后运行链接器(ld)创建一个可执行目标文件hello。在shell中输入./hello的命令,会调用fork来新建一个子进程并使用execve函数加载此进程进而实现hello的执行,此时的Hello已经转换为一个进程,就是P2P的第二个P,即Process。
2.020
在实现了程序到进程的转换之后,会为hello进行内存映射并分配独有的运行空间,设置PC,执行Hello。CPU以流水线形式读取并执行指令。执行过程中,操作系统适时实施进程调度,并利用虚拟内存简化内训管理,实现数据的快速访存并在必要时利用I/O系统进行输入输出。进程执行结束后,shell启动信号处理机制回收进程,清除其在执行时的内存占用和进程上下文等信息,进程就被完全删除而消失,又回归到0的状态。这就是020的过程。
1.2 环境与工具
1.2.1 硬件环境
X64CPU;1.80 GHz;16GB RAM;476.92G HD Disk
1.2.2 软件环境
Windows10 64 位;VMware 15;Ubuntu 20.04
1.2.3 开发工具
Win:Visual Studio Community 2022
Linux:CodeBlocks 20.03
gcc
1.3 中间结果
hello.c:源代码文件
hello.i:预处理后生成的文件
hello.s:编译后生成的文件
hello.o:汇编后生成的可重定位目标程序
hello:链接之后生成的可执行程序
1.4 本章小结
本章简述了Hello的P2P、020的过程并介绍了实验的基本信息:环境、工具以及实验的中间结果文件的名字和作用。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理在进行编译的第一遍扫描(词法扫描和语法分析)之前所作的工作。主要指对宏定义命令、文件包含命令和条件编译命令进行处理;然后再将处理的结果,和源程序一起进行编译,以得到目标代码。
预处理的作用:
1.程序的预处理过程就是将预处理指令(可以简单理解为#开头的正确指令)转换为实际代码中的内容(替换)
2.#include<stdio.h>,这里是预处理指令,包含头文件的操作,将所包含头文件的指令替代
3.如果头文件中包含了其他头文件,也需要将头文件展开包含
预处理会展开以#起始的行解释为预处理指令
2.2在Ubuntu下预处理的命令
预处理命令:gcc hello.c -E -o hello.i
2.3 Hello的预处理结果解析
预处理之后,查看hello.i文件,发现文件的内容得到了扩充。可以看到hello.i文件中, main函数和定义的全局变量没有变化,而预处理过程将原来前面的#include语句被替换成了很多的头文件中的内容,对原文件中的宏进行了宏展开。这些都使得hello.i文件变得冗长,但是还是可以看懂大概意思,与C语言程序有一定的相似之处。
2.4 本章小结
本章介绍了预处理的概念和作用,结合实际程序Hello分析了预处理的过程作出了哪些改变,包括宏展开、头文件引入、删除注释、条件编译等。
第3章 编译
3.1 编译的概念与作用
编译的概念:
将预处理完的文件进行一系列语法分析及优化后生成相应的汇编文件
作用:
将经过预处理得到的hello.i文件通过词法分析、语法分析和优化等操作之后将高级语言转换为成更低级、更底层、机器更好理解的汇编语言程序生成汇编文件hello.s。汇编语言程序比源程序的层次更低,但汇编语言更易于机器理解。
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.c -o hello.s
3.3 Hello的编译结果解析
3.3.0汇编指令
.file:声明源文件
.text:代码节
.section:
.rodata:只读代码段
.align:数据或者指令的地址对其方式
.string:声明一个字符串(.LC0,.LC1)
.global:声明全局变量(main)
.type:声明一个符号是数据类型还是函数类型
3.3.1数据
1.字符串常量: hello.c源程序中的两个printf的参数是字符串常量“用法:Hello 学号 姓名 秒数! \n”和“Hello %s %s\n”,其中汉字被编码成UTF-8格式,一个汉字在UTF-8编码中占三个字节。
在hello.s中这两个常量分别由.LC0和.LC1指示。
2. 局部变量
局部变量i:main函数声明了一个整型局部变量i,循环中i被初始化为0
编译器进行编译的时候将局部变量i会放在堆栈中。如图所示,局部变量i放在栈上-4(%rbp)的位置(4字节)。
局部变量argc和argv:main函数的参数argc和argv它们都存放在栈上的,并通过相对栈顶的偏移量来访问。
其中argc通过和4比较实现打印输入
argv中元素大小为8B,在main函数内访问数组元素argv[1],argv[2]时,利用寄存器%rax计算地址实现两次的取值。
3.3.2操作
1.赋值: mov语句
局部变量不初始化时,在汇编代码里不会有体现;
全局或静态变量不初始化时会被存放在.bss段;初始化的全局变量,会被存放在.data段。
2.算数:addl实现加法操作
算术操作指令还包括inc,dec,neg,sub,imul等等,如图所示
3.关系操作:cmp命令来进行关系操作。本质是做减法,设置标志位,利用对标志位的判断来完成后续操作。
(1)if中判断argc的取值是否不等于4.
(2)for循环判断结束条件i是否小于等于7
4.数组操作:argv中元素大小为8B,在main函数内访问数组元素argv[1],argv[2]时,利用寄存器%rax计算地址实现两次的取值。
5.控制转移:利用cmp指令和jxx条件跳转指令来进行转移控制。
(1)if判断argc的取值后的控制转移
(2)for循环结束时的控制转移
6.函数操作
(1)调用:call指令
Hello中一共6次函数调用
(2)参数传递:大部分是通过寄存器实现,但是通过寄存器最多可以传递6个参数,参数传递是按照顺序%rdi、%rsi、%rdx、%rcx、%r8、%r9。如果有多余的参数就会利用栈来传递。
函数printf:一个参数,通过寄存器%rdi传递
函数exit:一个参数,通过寄存器%edi传递
函数printf:三个参数,通过寄存器%rdi、%rsi、%rdx传递
函数atoi:一个参数,通过寄存器%rdi传递
函数sleep:一个参数,通过寄存器%edi传递
函数getchar:没有参数,不需要进行参数传递
3.4 本章小结
本章介绍了编译的概念和作用,并针对汇编程序hello.s,分析了编译器对各种数据以及各类操作的处理。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
汇编器(as)将.s汇编程序翻译成机器语言指令,然后把这些指令按照可重定位目标程序的格式进行打包,打包完成后保存在后缀为.o 目标文件中,该文件是一个二进制文件,它包含程序的指令编码。
汇编的作用:
将汇编语言转换成最底层的机器语言——真正机器可以读懂的二进制代码。
4.2 在Ubuntu下汇编的命令
汇编的命令:gcc hello.s -c -o hello.o
4.3 可重定位目标elf格式
4.3.1 ELF头
终端输入命令 : readelf - h hello.o
ELF头显示的信息显示了16字节的标识信息、ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
以hello.o为例, 16字节序列为7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00,描述了系统的字的大小为8字节,字节顺序为小端序。ELF头中包含了ELF头的大小:64字节;目标文件的类型:REL(可重定位文件);机器类型:Advanced Micro Devices X86-64;节头表的起始位置为1240;文件中共有14节;节头部表中条目的数量:13
4.3.2节头表
终端输入命令:readelf -S hello.o
节头表显示了文件中的每个节的语义,其中包括节的类型、位置和大小等信息。每个节都从0开始原因是文件为可重定位目标文件。在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小,同时可以观察到,代码是可执行的,但是不能写;数据段和只读数据段都不可执行,而且只读数据段也不可写。
以hello.o为例,节头表中一共有14项,其中第一项为空。剩下13项对应于可重定位文件中每一节的相关内容,其中包含每一节的名字,类型,地址(由于还未重定位所以每一节的地址都用0代替),在文件中的偏移量,节的大小,访问权限,对齐方式等等。
4.3.3符号表
终端输入命令:readelf -s hello.o
符号表中存储了程序中定义和使用的各种符号,包括函数名,全局变量名等等。其中每一个符号有其对应的value,size,type,name等等内容。Bind字段表明符号是本地的还是全局的。
4.4 Hello.o的结果解析
终端输入:objdump -d -r hello.o
查看hello.o的反汇编,并hello.s进行对照分析。
观察反汇编代码和hello.s代码可以发现,反汇编代码中除了汇编代码,还有其对应的机器语言代码。机器语言代码由二进制的机器指令集合构成,机器指令由操作码和操作数构成。其中机器语言的每一个操作码,寄存器编号等都与汇编语言一一对应。机器语言中,数据是以二进制小端存储来存储的,汇编语言中是采用的是顺序十六进制存储。通过分析可以建立机器语言与汇编语言的一一对应。
比较发现:
- 分支转移:反汇编代码跳转指令的操作数是确定的地址,而不再使用hello.s中的符号。hello.s中的.L1等符号只是便于编写的助记符,在反汇编代码中不再使用。
- 函数调用:反汇编代码在函数调用使用的是当前下一条指令地址,hello.s中使用的是的函数名称。在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将call 指令后的相对地址设置为全 0,所以才会出现目标地址都是下一条指令地址的情况。
- 全局变量访问:反汇编代码中对于全局变量的访问0x0(%rip),hello.s文件中为.LC0(%rip),和函数调用一样, rodata 中数据地址是在运行时确定,故访问需要重定位。
4.5 本章小结
本章介绍了汇编的概念和作用,使用Ubuntu下的汇编指令将将hello.s转换为.o可重定位目标文件,并分析了hello.o文件各部分,比较hello.s和hello.o的不同之处,了解了汇编代码和机器代码之间的区别和联系。
第5章 链接
5.1 链接的概念与作用
链接的概念:
链接是指将各种代码和数据片段收集并组合为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时、加载时、运行时。
链接工作大致包含两个步骤,一是符号解析,二是重定位。具体表现为链接器将每个符号引用与一个确定的符号定义关联起来。将符号从.o文件的相对位置重新定位到可执行文件的最终绝对内存位置并更新所有对这些符号的引用来反映它们的新位置。
链接的作用:
生成可执行文件
对于大的项目可以实现单独修改其中某一个部分再与其他部分重新整合,不用重新编译整个项目,节省了时间开销。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
终端输入:
ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
- ELF头
终端输入:readelf -h hello
hello的类型为EXEC类型;hello的入口地址非零,说明重定位工作已完成;hello文件中节头表的条目数量为27,比hello.o文件中的数目多。
2.节头表
终端输入:readelf -S hello
对比上一章hello.o的elf文件节头表,可以发现在hello中每一节都有了实际地址,说明重定位工作已完成。除此之外好多出了很多节,多出的节是为了后续链接器能够实现动态链接。
3.符号表
终端输入:readelf -s hello
每一个符号有其对应的value,size,type,name等等内容。Bind字段表明符号是本地的还是全局的。
5.4 hello的虚拟地址空间
Hello的起始地址为0x400000
从ELF头中看出程序入口为0x4010f0,从节头表中可以看出该地址对应.text段的起始地址
.rodata段:0x402000
.data段:0x404048
27个段均可通过节头表中的地址信息查看
5.5 链接的重定位过程分析
终端输入:objdump -d -r hello
下图节选了main函数的反汇编
- 分析hello与hello.o的不同
链接后hello的反汇编代码长了很多,观察发现链接过后加入了hello.c中调用的其他函数,比如printf,exit,sleep等等
hello.o中的相对偏移地址变成了hello中的虚拟内存地址,跳转指令jmp和函数调用指令call后的地址都变成了虚拟内存地址。
链接过程:
判断输入文件f是静态库文件还是目标文件,目标文件放入集合E中;解析目标文件中的符号,若出现未定义的符号则将其放入集合U,出现了定义但未使用的符号则放入集合D中;读入库文件,读库文件的每个可重定位文件对应U中的符号,如果有相同,就将该.o文件加入E,并更新U和D;链接器读入crt*库1中的目标文件;接入动态链接库libc.so
2.重定位
重定位条目:
其中offset是节偏移。Symbol为指向的符号。Type表示类型。ELF中定义了32中不同的重定位类型。其中比较常用的是R_X86_64_PC32和R_X86_64_32。前者重定位一个使用32位PC相对地址的引用,后者重定位一个使用32位绝对地址的引用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
重定位实现:
Hello中调用了puts函数,利用上述重定位方法可计算
refaddr=401125+21=401146
*refptr=unsigned(401090 – 4 – 401146)=ff ff ff 46
可到hello的反汇编代码中验证,发现计算结果正确
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
在hello中的动态链接如图
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
用edb查看对应地址,可以看到:动态链接前
动态链接后
地址中的内容发生了变化,是因为动态链接在可执行文件运行的时候根据elf文件对应段中信息启动了动态链接器,实现了hello和动态链接库的动态链接。
5.8 本章小结
本章介绍了程序在编程可执行文件之前的最后一步——链接。介绍了链接的概念和作用,对比了hello.o和hello的反汇编代码的区别,解释了链接过程和重定位的方法以及hello的动态链接。链接之后就意味着hello可以被执行了。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。程序是指令、数据及其组织形式的描述,进程是程序的实体,系统的每个程序都运行在某个进程的上下文。
作用:
提供一种假象:我们的程序是当前唯一运行的程序,我们的程序独占处理器和内存,我们程序的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(command interpreter,命令解析器)。它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令并定义了各种变量和参数,并提供了许多在高级语言中才具有的控制结构,包括循环和分支。
Shell的功能:
接收用户命令,然后调用相应的应用程序。用户通过访问这个界面访问操作系统内核的服务。
处理流程:
当shell从终端读入一个命令后,首先将输入字符串切分获得所有的参数。然后判断它是否为内置命令,如果是就通过Shell内部的解释器将其解释为系统功能调用并转交给内核执行;否则需要fork子进程并execve调用相应的程序执行。
6.3 Hello的fork进程创建过程
在终端输入./hello,由于这个不是一个内置的shell命令,所以shell会判断hello是当前目录下的一个可执行目标文件。首先会调用 fork 函数创建一个子进程,新创建的子进程会得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本。父进程和子进程是并发运行的独立进程,二者的PID不相同
6.4 Hello的execve过程
函数原型: int execve(const char *filename,const char argv[],const char envp[])
execve函数在当前进程的上下文中加载并运行一个新程序。
具体表现为加载并运行可执行文件filename(hello),且带参数列表argv和环境变量envp。步骤如下:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
hello一开始运行在用户模式,hello进程调用sleep函数后进入内核模式,内核处理休眠请求主动释放当前进程并加在新的进程运行。内核将hello进程从运行队列中移入到等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程并执行,这时我们说内核调度了一个新的进程,在内核调度了一个新的进程后,它就抢占了当前进程。计时结束后定时器发送中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,恢复上下文,回到用户模式继续运行。
当hello调用getchar的时候,当读操作被调用之后陷入内核,在内核请求数据输入。内核执行上下文切换,调度一个新的进程。切换到其他进程。当完成数据传输后,发出中断信号,恢复上下文回到用户模式继续运行。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
信号:
1.中断: 运行 Hello 程序时, 外部 I/O 设备可能出现异常 。
2.陷阱:陷阱是故意的,是执行的。命令结果表明,哈洛在执行sleep函数时出现上述异常。
3.故障:在运行 hello 程序时,可能会发生页面缺陷的错误操作。
4.退出: 退出时不可恢复, hello 运行中可能出现 DRAM 节点损坏的编码错误 。
处理:
1.正常运行
2.Ctrl-Z
Crtl+z向进程发送了一个sigtstp信号让进程暂时挂起
接着输入ps发现进程未关闭
输入jobs发现进程已停止
输入fg继续运行
输入kill杀死进程
3.Ctrl-C
Crtl+c指令向进程发送sigint信号让进程直接结束
4.乱按键盘
hello 运行过程中乱按键盘会在屏幕上显示出按的内容,但不会影响 hello 的 输出。
6.7本章小结
本章介绍了进程的概念和作用、Shell的一般处理过程和作用。分析了fork和execve函数的功能,展示了hello进程的执行以及hello的异常和信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
线性地址:地址空间是一个非负整数地址的有序集合,而如果此时地址空间中的整数是连续的,则我们称这个地址空间为线性地址空间。CPU在保护模式下,“段基址+段内偏移地址”为线性地址,线性地址通常用十六进制数字表示,值得范围从0x00000000到0xfffffff)程序代码会产生逻辑地址,通过逻辑地址变换就可以生成一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。如果没有启用分页机制,那么线性地址直接就是物理地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU链接的地址总线相对应。计算机系统的主存被组织成一个由 M 个连续的字节大小的单元组成的数组,其每一个字节都被给予一个唯一的地址,这个地址称为物理地址。物理地址也是计算机的硬件中的电路进行操作的地址。
逻辑地址:程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。
虚拟地址:CPU 启动保护模式后,程序运行在虚拟地址空间中。与物理地址相似,虚拟内存被组织为一个存放在磁盘上的 N 个连续的字节大小的单元组成的数 组,其每个字节对应的地址成为虚拟地址。虚拟地址包括 VPO(虚拟页面偏移量)、 VPN(虚拟页号)、TLBI(TLB 索引)、TLBT(TLB 标记)。
比如:hello 反汇编代码中的0000000000401125 <main>: 这里的 0x401125 是逻辑地址的偏移量部分,偏移量再加上代码段的段地址就得到 了 main 的虚拟地址(线性地址),虚拟地址是现代系统的一个抽象概念,再经过 MMU 的处理后将得到实际存储在计算机存储设备上的地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理:指的是把一个程序分成若干个段进行存储,每个段都是一个逻辑实体。段式管理是通过段表进行的,包括段号(段名),段起点,装入位,段的长度等。程序通过分段划分为多个块,如代码段,数据段,共享段等。
逻辑地址是程序源码编译后所形成的跟实际内存没有直接联系的地址,即在不同的机器上使用相同的编译器来编译同一个源程序则其逻辑地址是相同的,但在不同机器上生成的线性地址是不相同的。
在x86保护模式下,段的信息(段基线性地址、长度、权限等)即段描述符占8个字节,段信息无法直接存放在段寄存器中(段寄存器只有2字节)。Intel的设计是段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值(index)。
一个逻辑地址是两部分组成的,包括段标识符和段内偏移量。段标识符是由一个16位长的字段组成的,称为段选择符。前13位是一个索引号即是“段描述符”的索引,后3位为一些硬件细节。通过段标识符的前13位可以直接在段描述符表中找到一个具体的段描述符。
逻辑地址转化为线性地址的过程:
1.先检查段选择符的T1字段,该字段决定了段描述符保存在哪一个描述符表中,(比如转换的是GDT还是LDT中的段)。如果是GDT中的段(T1=0),分段单元从gdtr寄存器中得到GDT中的线性基地址。如果是LDT中的段(T1=1),分段单元从ldtr寄存器中得到GDT的线性基地址。之后再根据相应寄存器得到地址和大小。
2.由于一个段描述符字长为8bits,其在GDT或LDT中的相对地址是段选择符的最高13位的值×8。此时我们就得知了其偏移地址。
3.Base+offset得到线性地址。
4.逻辑地址转化为线性地址的公式:线性地址 = 段基址*16+偏移的逻辑地址
7.3 Hello的线性地址到物理地址的变换-页式管理
页:线性地址被划分为固定长度单位的数组
物理页:也称页框,页桢,是分页单元将所有物理内存划分成固定大小的单元为管理单位,通常情况下其大小与内存页大小一致。
为了节约维护进程的内存空间,cpu在页式内存管理方式中引入了二级页表结构,第一级页表称作页目录,用于存放页表的基地址,第二级页表用于存放物理内存中页框的基地址。
线性地址VA被分为虚拟页号(VPN)与虚拟页偏移量(VPO),CPU取出虚拟页号,MMU通过页表基址寄存器来定位页表条目PTE,PTE的有效位为1时,从页表条目中取出信息物理页号(PPN),通过将物理页号与虚拟页偏移量(VPO)结合,得到由物理地址(PPN)和物理页偏移量(PPO)组合的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
VA被分成VPN和VPO两部分,VPN也被分为TLBT和TLBI两部分。其中TLBI是用于确定TLB中的组索引, TLBT用于判断PPN是否已被缓存到TLB中,如果TLB命中,则直接取出PPN,否则取出VPN到页表中查询PPN。在页表中查询PPN时,VPN会被分为四个部分,分别用作一二三四级页表的索引,而前三级页表的查询结果为下一级页表的基地址,第四级页表的查询结果为PPN。将查询到的PPN与VPO组合,得到物理地址PA。
7.5 三级Cache支持下的物理内存访问
得到物理地址后,将物理地址划分为CO、CI、CT三部分,CO由块大小决定,CI和CT分别对应cache的组索引和行标记从cache中寻找。如果L1命中,则直接将数据取出传给CPU,不命中则紧接着寻找下一级cache L2,接着L3,如果L3也不命中,则需要从内存中将对应的块取出放入cache中。
7.6 hello进程fork时的内存映射
Fork函数为新进程创建虚拟内存。创建当前进程的的mm_struct, vm_area_struct和页表的原样副本,两个进程中的每个页面都标记为只读,两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW)。在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存,随后的写操作通过写时复制机制创建新页面。
7.7 hello进程execve时的内存映射
Execve函数在当前进程中加载并运行新程序的步骤:
1.删除已存在的用户区域。
2.映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。代码和数据区域被映射为hello文件的.txt和.data区,.bss区域是请求二进制零的,映射匿名文件,栈和堆区域也是请求二进制零的,初始长度为零。
3. 映射共享区域:共享对象有动态链接映射到本进程共享区域。
4.设置PC,指向代码区域的入口点(Linux将根据需要换入代码和数据页面)。
7.8 缺页故障与缺页中断处理
缺页故障:虚拟内存中的字不在物理内存中(DRAM缓存不命中)
如图所示,VP3没有被缓存到物理内存中,此时VP3的引用会引发缺页故障
处理缺页故障:触发缺页异常程序选择一个牺牲页(如图VP4),将VP4从内存交换到磁盘,并从磁盘读取VP3交换到物理内存。此时导致缺页的指令重新启动,不会出现缺页异常。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护一个进程的虚拟内存区域,称为堆。如图所示,分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种基本风格。两种风格都是要求显示的释放分配块。
(1) 显式分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。
(2) 隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器。
隐式空闲链表:
隐式空闲链表的一个块是由一个字的头部,有效荷载,以及一些额外的填充组织组成。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。在隐式空闲链表中,空闲块是通过头部中的大小字段隐含地链接着的。分配器可通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。
隐式空闲链表分配内存的步骤:
1.放置已分配的块
2.分割空闲块
3.获取额外的堆内存
4.合并空闲块
显式空闲链表:
显式空闲链表是将空闲块组织为某种形式的显式数据结构。在每个空闲块中都包含一个pred和succ指针。使用双向链表能使得首次适配的分配时间从块总数的线性时间减少到空闲块数量的线性时间。
显式空闲链表中块的排序策略:
1.后进先出(LIFO):将新释放的块放置在链表开始处,使用LIFO的顺序和首次适配的放置策略,此时分配器会最先检查最近使用的块,释放一个块可以在常熟时间内完成.
2.按照地址顺序维护空闲链表:这种情况下释放一个块需要线性时间搜索定位合适的前驱。按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率
7.10本章小结
本章分析了虚拟地址、线性地址和虚拟物理线性地址之间的相互转换,简单介绍了段式管理和页式管理。分析了物理内存访问、缺页故障、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.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
2.Shell 创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
3.改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行 seek,显式地将改变当前文件位置 k。
4.读写文件:一个读操作就是从文件复制 n>0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k+n,给定一个大小为 m 字节的而文件,当 k>=m 时,触发EOF。类似一个写操作就是从内存中复制 n>0 个字节到一个文件,从当前文件位置 k 开始,然后更新 k。
5.关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O函数:
- int open(char* filename,int flags,mode_t mode) ,用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。其中pathname:被打开的文件名flags:文件打开方式,成功执行打开操作则返回文件描述符,失败返回-1
2.int close(fd),用于关闭一个被打开的的文件,其中fd 是需要关闭的文件的描述符,close 返回操作结果0或1。
3.ssize_t read(int fd,void *buf,size_t n),用于从文件中读取数据。其中fd是将要读取数据的文件描述词;buf为指缓冲区,即读取的数据会被放到这个缓冲区中去;count 表示调用一次read操作,应该读多少数量的字符。返回值-1 表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
4.ssize_t wirte(int fd,const void *buf,size_t n),用于向文件中写数据。write 函数从内存位置 buf 复制至多 n个字节到描述符为 fd的当前文件位置。
8.3 printf的实现分析
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
函数原型:
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;
}
其中va_list是一个字符指针:typedef char *va_list;(char*)(&fmt) + 4) 表示的是...中的第一个参数。fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。接着从vsprintf生成显示信息,到write系统函数,直到陷阱系统调用 int 0x80或syscall。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
(以下格式自行编排,编辑时删除)
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar函数原型:
int getchar(void)
{
char c;
return (read(0,&c,1)==1)?(unsigned char)c:EOF
}
当程序调用getchar时,程序就等着用户按键,当用户按键时,键盘接口会产生一个键盘扫描码和一个中断请求,中断处理程序会从键盘接口取得按键扫描码并把它转换成ASCII码,保存到系统的键盘缓冲区。紧接着getchar调用read函数来读取字符,read执行一个系统调用,按照系统调用从键盘缓冲区读取按键ASCII码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux的IO设备管理方法,简述了Unix IO接口及其函数,并且从底层介绍了printf和getchar函数是如何实现的。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
1.键盘输入编写程序:用C语言编写hello.c代码。
2.预处理:预处理器处理hello.c进行注释删除。宏展开等操作后生成一个ASCII码文件 hello.i。
3.编译:编译器完成对hello.i的进一步处理,使之变得更接近机器语言,生成汇编文件hello.s。
4.汇编:汇编器将hello.s文件中的汇编语言语句转化为机器语言指令,并将这些指令合并成hello.o的二进制文件。
5.链接:将 hello.o 与可重定位目标文件静态库和动态链接库链接成为可执行目标程序 hello。
6.终端运行:在终端中输入./hello的指令。
7.创建子进程:shell 调用 fork()创建新的子进程,子进程和父进程共享虚拟地址空间。
8.运行程序:shell 调用 execve,execve 函数启动加载器,进行虚拟内存映射(mmp) CPU 为其分配时间片, hello获得进程控制权,顺序执行自己的控制逻辑流。
9.进程切换:执行过程中由于系统调用或者计时器中断,会导致上下文切换,内核会选择另一个进程进行调度,并抢占当前的hello进程
10.对异常和信号进行处理:收到信号。或者发生异常,hello会调用异常处理子程序和信号处理子程序来处理。
11.运行结束:hello结束会发送信号给父进程,shell 父进程回收子进程,内核删除这个进程的所有数据。
我的感悟:计算机系统是一门非常有难度的课程,虽然可能目前看起来计算机系统并没有让我们看到有太多的应用,但是我个人认为相比于学习一门程序语言,计算机系统介绍了机器的底层逻辑,告诉我们如何从机器触发来理解程序的执行。理解了程序究竟是怎样实现运行的,我们就可以从底层出发解释各种结果的出现、来优化程序提高运行的性能等等。计算机系统就像是地基一样,学习计算机,只是会编写程序还远远不够,重要的是能理解“是什么”和“为什么”,才能让“怎么做”达到最好的效果。
附件
hello.c C语言源文件
hello.i 经过预处理得到的C语言文件
hello.s 经过编译得到的汇编语言文件
hello.o 经过汇编得到的机器语言文件
hello 经过链接得到的可执行文件
参考文献
[1] https://blog.csdn.net/qq_45656248/article/details/117898467
[2]https://xc-throne.blog.csdn.net/article/details/117898044?spm=1001.2101.3001.6650.7&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7ERate-7-117898044-blog-117898467.pc_relevant_paycolumn_v3&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7ERate-7-117898044-blog-117898467.pc_relevant_paycolumn_v3&utm_relevant_index=9
[3] https://blog.csdn.net/weixin_45750972/article/details/123533683
[4] https://blog.csdn.net/m0_46332820/article/details/121862825
[5] https://www.csdn.net/tags/NtzacgysOTUwNDYtYmxvZwO0O0OO0O0O.html
[6] Computer Systems:A Programmer's Perspective Bryant,R.E.