计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能方向(2+X)
学 号
班 级 21WL0XX
学 生
指 导 教 师
计算机科学与技术学院
2023年5月
众所周知,hello程序是最基本的c语言程序,但从它的代码的编写,到能够让系统执行,需要的过程并不是那么短暂。预处理、汇编、编译、链接——每一步都将这个c语言程序推向不同的阶段,而每经历一个阶段,hello更接近能被计算机看懂的样子。Hello执行后,它的内存管理、异常处理又是一个有条不紊的过程。于是,本文根据hello程序从产生到执行的过程,逐步具体分析。
关键词:计算机系统;预处理;汇编;编译;链接;内存管理;异常处理
目 录
2.1 预处理的概念与作用............................................................................. - 5 -
2.2在Ubuntu下预处理的命令.................................................................. - 5 -
2.3 Hello的预处理结果解析...................................................................... - 5 -
3.1 编译的概念与作用................................................................................. - 6 -
3.2 在Ubuntu下编译的命令..................................................................... - 6 -
3.3 Hello的编译结果解析.......................................................................... - 6 -
4.1 汇编的概念与作用................................................................................. - 7 -
4.2 在Ubuntu下汇编的命令..................................................................... - 7 -
4.3 可重定位目标elf格式......................................................................... - 7 -
4.4 Hello.o的结果解析.............................................................................. - 7 -
5.1 链接的概念与作用................................................................................. - 8 -
5.2 在Ubuntu下链接的命令..................................................................... - 8 -
5.3 可执行目标文件hello的格式............................................................ - 8 -
5.4 hello的虚拟地址空间.......................................................................... - 8 -
5.5 链接的重定位过程分析......................................................................... - 8 -
5.7 Hello的动态链接分析.......................................................................... - 8 -
第6章 hello进程管理........................................................................... - 10 -
6.1 进程的概念与作用............................................................................... - 10 -
6.2 简述壳Shell-bash的作用与处理流程............................................. - 10 -
6.3 Hello的fork进程创建过程............................................................. - 10 -
6.4 Hello的execve过程......................................................................... - 10 -
6.6 hello的异常与信号处理.................................................................... - 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 -
第8章 hello的IO管理......................................................................... - 13 -
8.1 Linux的IO设备管理方法.................................................................. - 13 -
8.2 简述Unix IO接口及其函数............................................................... - 13 -
8.4 getchar的实现分析............................................................................ - 13 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P过程:包含4个阶段,分别为预处理、编译、汇编、连接。普通的c文件(这里用hello.c来表示)经过预处理,可以得到中间文件hello.i,该过程需要预处理器,相关代码为:cpp +参数+hello.c hello.i;运行c语言编译器,输入命令行:cc1 hello.i -Og+参数+ -o hello.s, hello.i可以转化成ascii码汇编文件hello.s。驱动c语言汇编器(as +参数+ -o hello.o hello.s)后,汇编文件转化为二进制的可重定位目标文件hello.o,运用链接器输入ld -o hello hello.o,将可重定位的目标文件转化成可执行文件。最后在命令行中输入./ hello,即可执行文件,这时shell将hello程序加载到了内存并开始执行。
O2O过程:从操作系统(Operation System)到IO的过程。Linux操作系统加载main程序需要从它fork的子进程中执行execve,这个过程中将hello可执行文件映射到虚拟内存空间上,其中包含了代码段、数据段、堆、栈、共享库的文件映射区等。此时操作系统进程中有了hello的唯一标识符pid。Shell将hello文件进程覆盖在其子进程的上下文中,操作系统为其分配CPU的执行空间,设置程序计数器PC的值对hello的指令逐步执行。可执行文件hello内置的字符串从文件加载,经过CPU的处理输出的对于的I/O之上得到输出的结果。Hello结束后,操作系统回收子进程,删除hello,此时hello结束了它的一生。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
工具:笔记本电脑
处理器: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz (16 CPUs), ~2.3GHz
内存: 16GB RAM
软件环境:
Windows 11 家庭版 64-bit(版本:21B1) 、Ubuntu20.04 LTS 64位
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.c c语言源文件
hello.i 预处理后的文本文件
hello.s 编译之后产生的汇编文件
hello.o 可重定位目标文件
hello 可执行文件
1.4 本章小结
展示了hello从诞生到执行到消亡的P2P和O2O过程,梳理全文脉络,同时介绍了整个过程中所使用的环境和工具。
第2章 预处理
2.1 预处理的概念与作用
预处理是进行源文件编译的第一个阶段(不是所有语言都有预处理过程),不对源文件进行分析,而是对源文件进行文本操作,如删除源文件中的注释,在源文件中插入包含文件的内容(#include),定义符号并替换源文件中的符号等(#define),通过这些处理,将会得到编译器实际进行分析的文本。预处理器执行预处理的功能,而编译器往往将预处理器作为编译的第一个步骤,但是用户也可以单独调用预处理器,我们将C预处理器(C preprocessor)简写为 CPP。
预处理阶段主要发生:
1.头文件的包含
2.清除注释
3.宏的替换
4.处理所有的条件编译指令,如#ifdef #ifndef #endif等,也就是带#那些
5.保留#pargma指令
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i,可获得hello.i文件
图2-1 预处理命令
2.3 Hello的预处理结果解析
如图2-2,预处理之前的c文件只有短短20行代码,而预处理后的hello.i文件扩展到了3000行,并且删除了#include项。结果表明,#include的项被包含到了hello.c中,如#include <stdio.h>,在前面的多行函数定义包含了很多extern的外部函数和变量。预处理之后的文件仍然时文本文件,需要进一步的编译才可执行。
图2-2(a) 预处理前的源文件
图2-2(b) 预处理后的hello.i文件
2.4 本章小结
介绍了预处理的基本概念,预处理的方法以及分析预处理的结果,并于源文件进行了对比。
第3章 编译
3.1 编译的概念与作用
编译指的是利用编译程序,将预处理完的文件进行一系列语法分析及优化后,把用源语言程序表示的代码转化成计算机可以识别的二进制语言生成相应的汇编文件的过程。
作用:对预处理文件进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。同时将文本文件hello.i翻译成文本文件hello.s。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
图3-1 编译的命令
3.3 Hello的编译结果解析
Hello.s中主要数据类型有:全局变量,局部变量,指针数组,字符串。
图3-2给出了hello.s的代码,其中.LC0和.LC1中的数据类型是字符串,前一个字符串.LC0对应源文件中的“用法:Hello 学号 姓名 秒数!\n”,该字符串被编码为UTF-8格式后一个字符串为“Hello %s %s\n”,均存放在了.rodata只读数据段中。
图3-2 汇编代码中的LC0和LC1
图3-3 汇编得到的hello.s文件
图3-3是.L2部分的汇编代码,声明了局部变量int i,并将其初始化为0。该变量没有保存在寄存器中,而是保存在了栈上即-4(%rbp)处。
图3-3 汇编代码.L2和.L3部分
图3-4中的.L4部分展示了一部分的算术操作,如第53行addl $1, -4(%rbp)将-4(%rbp)的内容加一,根据上面的分析,这个操作是将int i的内容加一,然后和立即数7进行比较,小于等于则跳转,对应的操作为for循环中的i的增加。
图3-5中的22行代码movl %edi, -20(%rbp),将main函数的第一个参数argc存在了栈上,位置为-20(%rbp),紧接着,在24行cmpl $4, -20(%rbp)展示了条件分支语句,对于着源文件的if(argc!=4)的代码。
图3-4 汇编代码L4部分
图3-5 汇编代码LFB6部分
图3-6中的.L4部分还展示了数组操作。字符指针数组 char *argv[]:存储用户输入的命令行信息地址。该数组中每个元素大小为8个字节,argv既是数组名也是数组的首地址。图3-6(a)为argv[1],argv[2]的实现,一开始,main函数的第二个参数rsi被存在了栈上-32(%rbp)之处,该地址为argv[0]的地址,而推测argv[1]在-32(%rbp)+8的部分,而-32(%rbp)+16储存者argv[2]。
图3-6(a) 指针数组argv操作
图3-6(b) printf函数
图3-6(c) sleep函数和atoi函数
除此之外,源hello.c文件中的函数操作也在汇编文件中有相应的体现。图3-6(b)中%rdi传递的是printf的函数,在调用call之前,%rdi装载了.LC1(%rip)的值,而根据上面的分析.LC1(%rip)正是字符串Hello %s %s\n的首地址。于是输出的是该字符串的内容。图3-6(c)为sleep函数,该函数之前需要使用atoi来操作argv[3]。根据上面的指针数组的分析,argv[3]储存在-32(%rbp)+24栈上,而图3-6(c)的46和47行体现了这一点。然后将argv[3]参数转移到%edi中,随后结果得到%eax为sleep的操作数,再次转移到%edi作为sleep的参数传递,在52行处调用sleep。
3.4 本章小结
本章介绍了从hello程序从文本代码到汇编代码的过程,并对hello.s的相关数据、函数和指令进行了分析。通过观察汇编语言可以得到hello程序的底层实现。
第4章 汇编
4.1 汇编的概念与作用
汇编是指把汇编语言翻译成机器语言的过程。在汇编文件hello.s中,用助记符(Memoni)代替操作码,用地址符号(Symbol)或标号(Label)代替地址码。这样用符号代替机器语言的二进制码,就把机器语言变成了汇编语言。于是汇编语言亦称为符号语言。用汇编语言编写的程序,机器不能直接识别,要由一种程序将汇编语言翻译成机器语言,这种起翻译作用的程序叫汇编程序,汇编程序是系统软件中语言处理的系统软件。
汇编的作用:产生机器能读懂的代码,使得程序能被机器执行。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
图4-1 生成可重定位目标文件
4.3 可重定位目标elf格式
Hello.o的elf格式为可重定位目标文件的格式,包括了ELF头,程序头部表和节头表。
图4-2 ELF头信息
图4-2是ELF头的信息。从中可以得到的信息有16字节的标识信息(Magic)、文件和数据类型、机器类型,节头部表偏移、程序入口点(第一条指令的地址)以及 头表偏移,节头表的表项大小,表项个数,生成该文件的系统字大小和字节顺序。
图4-3展示了节头部表的内容,描述了不同的节的位置和大小。目标文件中的每一个节都有一个固定的大小条目,相关信息包括节的名称,类型,地址,偏移量,对齐,旗标等。
图4-4展示了rel.text节对应的重定位条目的信息,包含8个条目:printf、atoi、getchar等函数,以及.rodata(L0和L1),这些信息在链接的时候链接器将会对其进行重定位。表中还包含了两种基本的重定位类型R_X86_64_PC32和R_X86_64_PLT32。PC32使用的是32位PC相对引用而PLT32使用的是32位过程连接表。
图4-3 节头部表信息
图4-4 重定位条目信息
4.4 Hello.o的结果解析
机器语言与汇编语言是一一映射的关系。图4-5左使hello.s而右是使用objdump对hello.o操作之后得到的机器指令及其对应的汇编语言。从图中可以看出,机器语言是有0和1组成的机器指令的集合,而汇编语言是对于表示机器语言的文本描述。汇编语言是最解决机器语言的底层语言。从操作数的表示来看,机器语言表示为16进值(2进制),而汇编语言用10进制来表示。从跳转语句看,汇编语言用符号来表示跳转的地址,而机器语言用PC的相对偏移或者绝对地址来表示。如一条指令汇编语言表示成je .L2。同样的指令,机器语言则用74 19来表示。74表示je指令而19表示相对的偏移,翻译成汇编这条指令就是je 32 <main+0x32>。全局变量的访问:hello.s文件中对于全局变量的访问为.LC1(%rip,而在反汇编代码中是$0x0(%rip),原因与函数调用一样,全局变量的地址也是在运行时才确定,访问也需要经过重定位。
图4-5 汇编语言(图左)和机器语言(图右)
4.5 本章小结
在汇编过程中,hello.s被汇编器变为hello.o文件,此时hello.o已经是可以被机器读懂的二进制文件了。Hello.o可重定位目标文件也为后面进行链接做好了准备。此时的hello仍然不能“上岗工作”,还需要进行最后一步链接才能变为可以被系统执行的可执行文件。同时通过反汇编hello.o,能够了解到汇编代码和机器代码之间的区别和联系。
第5章 链接
5.1 链接的概念与作用
链接是指将各种代码和数据片段收集并组合为一个单一文件的过程,这个文件可被加载到内存并执行。在实际开发中,我们是多文件编程的,所有文件在编译后,需要合在一起,合在一起的过程就是链接的过程。
链接的作用:
(1)地址重定位: .o文件被整合的时候,每个目标文件的数据区、指令区等被整合到一起。在执行时,CPU会通过这个重定位的地址进行寻址,取指令和数据进行运行。
(2) 符号解析:两个.c文件的命名变量重复了,它们编译后得到了不同的.o文件,在链接到同一个可执行文件时,链接器会根据规则对两个符号进行统一,函数名也有同样的操作。
5.2 在Ubuntu下链接的命令
ld -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 /usr/lib/gcc/x86_64-linux-gnu/11/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/11/crtend.o /usr/lib/x86_64-linux-gnu/crln.o -z relro -o hello.out
图5-1 链接命令输入
5.3 可执行目标文件hello的格式
如图5-2,hello的ELF格式和hello.o分别差不多。ELF头包括了程序的入口点为0x4010f0,以及有12个程序头和28个节头部。节头部的偏移为13560 (bytes into file)而程序头的起始位置为64 (bytes into file)。ELF头展示了各个头部项所占用的字节:ELF头和节头是64B,程序头为56B。
图5-2 hello的ELF头信息
程序头表如图5-3所示,描述了程序各段的虚拟地址以及大小以及对齐方式、访问权限(R/W)等。其中的类型有:PHDR,用来保存程序头表;INTERP,程序映射到内存后的解释器如动态链接器等;LOAD表示保存了常量和目标代码的段,需要从二进制文件映射到地址空间,不同的LOAD分只读代码段和读写段。下方的DYNAMIC 保存了动态链接器使用的信息,而NOTE 保存辅助信息
图5-2 hello的程序头信息
下面图5-3展示了hello文件的节头信息,包括了大小、各节的类型以及地址、读写的权限和对齐的方式。包括了.init段、.text段、.rodata段等等。
图5-3(a) 节头部表前13项
图5-3(b) 节头部表后14项
Hello的节头如下图5-4所示,包含了各段的起始位置,大小等信息。
图5-4(a) 各节和段的映射关系
图5-4(b) .dynamic节信息
下图5-5(a)和(b)给出了Hello的符号表。.symtab部分往往包含了全部的符号表信息,但不是其中所有符号信息都会参与动态链接,所以ELF还专门定义一个.dynsym部分,这个部分存储的仅是动态链接需要的符号信息。
图5-5(a) 动态链接符号表
图5-5(b) 符号表
5.4 hello的虚拟地址空间
如下图5-6中edb的datadump部分中可以看出,加载hello的运行程序地址代码段在0x401000~0x401260部分,对应了上面的节头部表中的[12]项.init的开始,对应的offset是0x1000,所以hello被加载到了0x400000处而只读代码段的开始地址是0x401000。
图5-6 hello的.init段虚拟地址
5.5 链接的重定位过程分析
使用objdump -d -r hello获得的反汇编代码可见图5-6,和hello.o的反汇编代码进行比较,hello的反汇编代码加入了.init节和.plt节以及其他的函数,并且hello.o中的相对地址偏移变成了hello的虚拟地址。同时,由于hello完成了重定位,所以hello.o的重定位条目在hello中消失了。
图5-6(a) .init段反汇编代码
图5-6(b) .plt段反汇编代码
图5-6(c) .plt.sec段反汇编代码
图5-6(d) .text段部分反汇编代码
根据二者的不同可以得到hello的重定位过程,首先判断输入文件是否为库文件,如果不是,则放入目标文件集合E中。接着,链接器解析目标文件中的符号,如果出现未定义的则放入未定义的集合U中。随后链接器读入crt*库1中的目标文件,接入动态链接库libc.so。
重定位的步骤为:合并相同的节,将所有文件的.data节合并到新的.data节中;确定地址,之后链接器再分配内存地址赋给新的聚合的节以及输入模块定义的节和符号。地址确定后全局变量、指令等均具有唯一的运行时地址。
5.6 hello的执行流程
如图5-7,使用edb加载hello时,并不是从虚拟地址0x400000处开始的。原因是加载器需要创建一段内存映像,在程序头部表的引导下,加载器将可执行文件复制到内存中。接着进入程序入口_start函数处,启动系统函数_libc_start_main初始化执行环境然后调用用户层的main函数。
图5-7 加载内存映像过程的反汇编
子程序名 | 子程序地址 |
ld-2.27.so!_dl_start | 0x00007fdcb4899050 |
ld-2.27.so!_dl_init | 0x00007f7c09b3f4d0 |
hello!_start | 0x00000000004010f0 |
libc-2.27.so!__libc_start_main | 0x00007f8006829dc0 |
-libc-2.27.so!__cxa_atexit | 0x00007f8dec226280 |
-libc-2.27.so!__libc_csu_init | 0x00000000004011c0 |
hello!_init | 0x0000000000401000 |
libc-2.27.so!_setjmp | 0x00007f8dec221250 |
-libc-2.27.so!_sigsetjmp | 0x00007f8dec221240 |
--libc-2.27.so!__sigjmp_save | 0x00007f8dec221210 |
hello!main | 0x00000000004011d6 |
hello!puts@plt | 0x0000000000401030 |
hello!exit@plt | 0x00000000004010c0 |
*hello!printf@plt | 0x00000000004010b0 |
*hello!sleep@plt | 0x00000000004010d0 |
*hello!getchar@plt | 0x00000000004010e0 |
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
使用readelf可以确定全局偏移表的位置,从中可以看出GOT位于0x400ff0。
图5-9(a) ELF文件中的全局偏移信息
下图展示了全局偏移表(GOT)0x400ff0的内容,从中可以看出在dl_init后表项发生了变化,从0x400ff0和0x404008开始的8位的地址分别由0变成了0x00007fab6f829dc0和0x00007fab6f4cf2e0为动态链接表的地址。
图5-9(a) 执行dl_init前
图5-9(b) 执行dl_init后
函数调用一个由共享库定义的函数时,编译器无法预先判断出函数的地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定的方式解决该问题,在运行时动态载入。延迟绑定通过两个数据结构之间简洁但又有些复杂的交互来实现,即过程链接表(PLT)和全局偏移量表(GOT)。
PLT是一个数组,其中每个条目是16字节代码。PLT [0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。每个条目都负责调用一个具体的函数。
GOT也是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT [0]和GOT [1]包含动态链接器在解析函数地址时会使用的信息。GOT [2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
5.8 本章小结
本章简介了linux系统下链接的过程。链接是程序变成可执行文件的最后一步,经过链接将各种代码段和数据段整合到一起后,hello已经准备好接受系统的安排,可以进行工作了。同时,通过查看hello的虚拟地址空间,对比hello.o和hello的反汇编代码等等一系列的分析过程,对重定位,执行流程和动态链接过程进行了大致的概括。链接看似只是一个简单的合并过程,实际上却发挥着很大的作用,在软件开发中扮演着关键的角色。
第6章 hello进程管理
6.1 进程的概念与作用
系统中的每个程序都运行在某个进程的上下文中,在系统上常常有多个程序在执行,而程序的进程仿佛是每个运行的程序在单独使用处理器和内存,而处理器好像在无间断地一条一条地执行我们程序中的指令。
6.2 简述壳Shell-bash的作用与处理流程
Shell是Unix操作系统的用户接口,shell程序从用户的输入中得到信息,并将其翻译成操作系统内核能够识别的指令来执行相关的程序并把结果在shell中呈现。
Shell是连接Linux内核和应用程序的桥梁,可以合并编程语言并控制进程和文件、加载和运行应用程序。
处理流程如下:解析命令行、判断是否为内置命令行、判断是否为后台运行、解析后运行输入的命令行,把得到的结果在shell程序界面输出。
1. 打印一个命令行提示符,等待用户在stdin上输入命令行,对该命令行求值。
2. 判断命令是否正确以及是否为内置命令,且将命令行使用parseline函数的参数改造为系统调用execve()内部处理所要求的形式。
3. 终端进程调用fork()来创建子进程,自身则用系统调用wait()来等待子进程完成。
4. 当子进程运行时,它调用execve()根据命令的名字指定的文件到目录中查找可行性文件,调入内存并执行这个命令。
5. 如果命令行末尾有后台命令符号&,终端进程不执行等待系统调用,而是立即发提示符,让用户输⼊下一条命令;如果命令末尾没有&,则终端进程要一直等待。当子进程完成处理后,向父进程报告,此时终端进程被唤醒,做完必要的判别工作后,再发提示符。
6.3 Hello的fork进程创建过程
1. 输入./hello后,shell读入命令
2. 父进程通过fork函数创建新的运行子进程hello
3. 父进程用waitpid函数等待子进程终止或停止
4. 子进程停止之前,父进程执行的操作和子进程的操作在时间顺序满足拓扑排序,这段时间内父子进程的逻辑控制流指令交替进行
6.4 Hello的execve过程
shell通过fork命令创建子进程后,并通过fork的返回值来确定该进程是子进程时,调用execve函数来在当前进程的上下文中加载并执行hello程序,且将下一条指令指向hello程序的入口地址,同时将父进程的运行环境、共享库等内容复制一份给加载了的hello程序。
Execve()的函数原型如下:
int execve(const char *filename, const charargv[], const char envp[])
函数中的const char *filename用来执行参数filename字符串所代表的文件路径,第二个参数利用指针数组来传递给执行文件,并且需要以空指针NULL结束,最后一个参数为传递给执行文件的新环境变量数组,其中每个指针指向一个环境变量字符串,每个串都是形如name=value的名字-值对。当execve加载了filename之后,它调用启动代码,启动代码设置栈,将控制传递给新程序的主函数main。当出现例如找不到filename的错误,execve将返回调用程序,与fork调用一次返回两次不一样,execve调用一次并从不返回。
6.5 Hello的进程执行
用户模式和内核模式:处理器通过用某个控制寄存器的模式位实现限制一个应用可以执行的指令以及它可以访问的地址空间范围的功能,该寄存器描述了进程当前享有的特权,当设置模式位时,进程运行在内核模式中。一个运行在内核模式中的进程可以执行指令集的任何指令,并且可以访问系统中的任何内存位置。
当没有设置模式位时,进程运行在用户模式中,用户模式中的进程不允许执行特权指令,例如停止处理器,改变模式位,不允许进程直接引用地址空间中内核区的代码段和数据,此时用户程序必须通过系统调用接口间接访问内核代码和数据。
表6-1 内核模式和用户模式
用户模式到内核模式 | 内核模式到用户模式 | |
原因 | 由中断/异常/系统调用中断用户进程执行而触发 | OS执行中断返回指令将控制权交还用户进程而触发。 |
过程 | 1.处理器模式转为内核模式 2.保存当前进程的PC/PSW值到核心栈 3.转向中断/异常系统调用处理程序 | 1.从待运行进程核心栈中弹出PC/PSW值 2.处理器模式转为用户模式 |
时间片:指的是一个进程执行它的控制流的一部分的每一时间段,多任务也称为时间分片。
上下文切换:操作系统内核使用一种称为上下文切换的异常控制流来实现多任务。内核为每个进程维持一个上下文,上下文由一些对象的值组成,这些对象包括通用目的寄存器,浮点寄存器,程序计数器,用户栈,状态寄存器,内核栈和内核数据结构。引发上下文切换的因素有异常,系统调度等。处理过程如下:
- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程
根据任务的不同,可以分为以下三种类型:进程上下文切换、线程上下文切换和中断上下文切换。
6.6 hello的异常与信号处理
Hello进程中的异常可有中断、陷阱、故障、终止这4类。如果正常运行,hello程序的异常有中断和陷阱这两种。
Hello的进程异常分析1——中断:
中断在程序执行中参数,信号的来源有键盘的输入、子程序的退出等。常见的中断时从键盘中输入Ctrl+C和Ctrl+Z,hello进程受到中断的信号后,切换到内核模式,由中断子程序进行处理。下面展示了暂停信号和终止信号对hello产生的影响。
根据源文件,输入参数为“Hello 学号 姓名 秒数”即可运行hello程序,随后输入Crtl+C指令,向进程发送sigint信号让进程直接结束,此时再输入ps后无进程,所以不能恢复它的运行(图6-1)。
图6-1 输入Crtl+C运行
正常运行后输入Ctrl+Z,hello程序暂停运行,随后输入ps,hello的PID为5251并没有停止,所以输入kill才能把它结束。再shell命令行输入kill -9 5251可得到终止进程的目的。
图6-2 运行后输入Crtl+Z再输入kill杀死进程
如果需要将Ctrl+Z暂停了的程序重新执行,那么可以输入fg(图6-3)。输入jobs可以看到哪些是正在暂停的程序(图6-4),而pstree以进程图的方式列出了进程之间的调用关系(父子进程关系)。
图6-4(a) 使用fg指令将挂起的hello进程重新执行
图6-4(b) jobs和pstree指令
Hello的进程异常分析2——陷阱:
陷阱是内置程序产生的中断,是程序中某条指令的执行结果。陷阱可以由像exit函数、sleep函数产生,当执行这些函数程序时,会产生相应的异常信号,从而产生相应的异常状况。比如执行exit后,相当于在程序末尾由认为设定了SIGINT信号,进而终止程序,和键盘输入Ctrl+C有一样的结果。
6.7本章小结
这一章介绍了hello运行时的进程管理,分析了不同的异常以及对应的处理方法,对应了在shell命令行输入./hello后的创建、加载、执行到终止的全过程。本章还对键盘输入产生的中断异常和程序函数的陷阱进行了分析,通过ps、pstree、jobs、kill等命令可以查看并改变进程的状态。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念:
物理地址:物理地址是内存单元的绝对地址,与地址总线具有对应关系。无论CPU如何处理地址,最终访问的都是物理地址。CPU实模式下段地址+段内偏移地址即为物理地址,CPU可以使用此地址直接访问内存。物理地址的大小决定了内存中有多少个内存单元,物理地址的大小由地址总线位宽决定。
线性地址(虚拟地址):CPU在保护模式下,“段基址+段内偏移地址”为线性地址,如果CPU在保护模式下未开启分页功能,线性地址将被当成物理地址使用。若开启了虚拟分页功能,线性地址等同于虚拟地址,此时虚拟地址需要通过页部件电路转化为最终的物理地址。虚拟地址是CPU由N=2n个地址空间中生成的,虚拟地址即为虚拟空间中的地址
逻辑地址:无论cpu在什么模式下,段内偏移地址又称为有效地址/逻辑地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理:指的是把一个程序分成若干个段进行存储,每个段都是一个逻辑实体。段式管理是通过段表进行的,包括段号,段起点,装入位,段的长度等。程序通过分段划分为多个块,如代码段,数据段,共享段等。
逻辑地址是程序源码编译后所形成的跟实际内存没有直接联系的地址,即在不同的机器上使用相同的编译器来编译同一个源程序则其逻辑地址是相同的,但在不同机器上生成的线性地址是不相同的。
一个逻辑地址是两部分组成的,包括段标识符和段内偏移量。段标识符是由一个16位长的字段组成的,称为段选择符。前13位是一个索引号,后3位为一些硬件细节。如图,索引号即是“段描述符”的索引,段描述符具体地址描述了一个段,很多个段描述符就组成了段描述符表。通过段标识符的前13位直接在段描述符表中找到一个具体的段描述符。
图7-1 段和页关系示意图
7.3 Hello的线性地址到物理地址的变换-页式管理
页:线性地址被划分为固定长度单位的数组,例如32位的机器线性地址为4G,用4KB为一个页来划分。整个线性地址被划分为一个220的数组,共有2的20次方个页,称为页表。
物理页:也称页框,页桢,是分页单元将所有物理内存划分成固定大小的单元为管理单位,通常情况下其大小与内存页大小一致。
线性地址转化为物理地址的过程如下:
1.从cpu寄存器CR3中取出进程的页目录地址,操作系统负责调度进程时将该地址装入对应寄存器,取出其前20位,为页目录的基地址。
2.根据线性地址前10位(面目录索引),在数组中找到对应的索引项
3.根据线性地址的中间10位(页表索引),在页表中找到页的起始地址
4.将页的起始地址与线性地址中最后12位偏移相加即得到物理地址
利用页表实现虚拟地址到物理地址的映射过程如下:
1.CPU中的页表基址寄存器指向当前页表。N位虚拟地址包括一个p位的虚拟页面偏移VPO和一个n-p位的虚拟页号VPN
2.内存管理单元MMU利用VPN选择适当的PTE
3.将页表条目中的物理页号(PPN)和虚拟地址中(VPO)串联起来即得到物理地址
7.4 TLB与四级页表支持下的VA到PA的变换
Core i7采用四级页表层次结构,CR3控制寄存器指向第一级页表L1的起始位置,CR3的值是每个进程上下文的一部分,每次上下文切换时CR3的值都会被恢复,P = 1时地址字段包含一个40位的物理页号PPN,它指向适当的页表开始处。
下图7-1给出了core i7MMU如何使用四级页表来将虚拟地址翻译成物理地址,36位的VPN被划分为四个9位的片,每个片被用作到一个页表的偏移量,CR3寄存器包含L1页表的物理地址,VPN1提供到一个L1 PET 的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个 L2 PTE 的偏移量,以此类推。
图7-2 core i7的四级页表结构
7.5 三级Cache支持下的物理内存访问
Intel core i7处理器的高速缓存层次结构如图7-3左所示,每个cpu芯片有四个核,每个核有自己私有的L1 i-cache,所有的SRAM高速缓存存储器都在CPU芯片上。高速缓存cache是一个高速缓存组的数组,共有S个组(图7-2右),每组包含E行,每行有一个有效位,t个标记位和log2B位的数据块。
物理内存访问基于MMU将虚拟地址翻译成物理地址以后向cache中访问的,在cache中进行物理寻址有三个步骤:
1.组选择:cache按照物理地址的s个索引位来定位该地址映射的组
2.行匹配:选择组以后遍历组中的每一行,比较行的标记和地址标记,当二者相同且有效位为1时,该行包含地址的一个副本,此时缓存命中,否则缓存不命中,需要从下一层cache中取出请求块,将新的块存入高速缓存中,如果发生冲突不命中则需进行行替换,通常采用最近最少使用策略LFU算法进行替换
3.字选择:定位了行以后根据地址的块偏移量在行的数据块中进行寻址,得到的字即为地址指示的字
图7-3 core i7的cache结构与寻址方式
7.6 hello进程fork时的内存映射
进程能为每个进程提供自己私有的虚拟地址空间,免受其他进程的错误读写。但许多进程有相同的代码区域,比如printf函数这种来自于标准语言库的函数,假如每个进程中都保存一份同样的副本,将会是对内存的一种极大的浪费,而内存映射为我们提供了共享对象这种机制来节约内存开销。
图7-4 写时复制示意图
基于共享对象的概念,我们可以理解在使用fork函数加载hello时是如何创建一个带有自己独立虚拟地址空间的新进程:
1.fork被当前进程调用时,内核为新进程创建数据结构,分配唯一的PID
2.fork创建了当前进程的mm_struct(内存描述符,描述一个进程的整个虚拟内存空间),区域结构和页表的原样副本用来给新的进程创建虚拟内存
3. fork将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制
4. fork在新进程中返回时,新进程现在的虚拟内存与调用fork时存在的虚拟内存相同,这两个进程任意一个进行写操作时,写时复制机制将创建新页面,为每个进程保持了私有地址空间的概念
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行可执行对象文件hello.out中包含的程序,并用hello.out程序有效地替换当前程序。加载和运行hello.out需要以下步骤:
1.删除现有用户区域:删除当前进程虚拟地址的用户部分中的现有区域结构。
2.绘制私有区域:为程序代码、数据、BSS和堆栈区域创建新的区域结构。所有这些新领域都是私有的,并且是在写的时候复制的。代码和数据区域映射到hello.out文件文本和数据区域中的字段。BSS请求以匿名方式映射到二进制区域。
3.映射共享区域:如果hello.out程序链接到共享对象或目标,如标准C库libc.so,则这些对象将动态链接到程序,然后映射到用户虚拟地址空间中的共享区域。
4.设置程序计数器PC。execve所做的最后一件事是将当前进程上下文中的程序计数器设置为指向代码区域的入口点。
图7-5 Linux是如何组织虚拟内存的
7.8 缺页故障与缺页中断处理
MMU在试图翻译某个虚拟地址A时触发了一个缺页,这个异常导致控制转移到内核的缺页处理程序,程序将进行以下几个判断:
1.虚拟地址A是否合法:缺页处理程序搜索区域结构的链表,将A和每个区域结构中的vm_start和vm_end比较,如果指令不合法,程序触发一个段错误,终止进程,标识为1
2.试图进行的内存访问是否合法?如果试图进行的访问不合法,程序会触发一个保护异常,终止进程,标识为2
3.如果不是由于以上两个原因,那么造成缺页异常的访问是合法的,接下来程序选择一个牺牲页面,如果这个页面被修改过则将其交换出去,换入新页面并更新页表,当缺页处理程序返回时CPU重新启动引起缺页的指令,再次发送A到MMU,之后A将会被正常翻译。
图7-6 缺页处理流程图
7.9动态存储分配管理
隐式空闲链表的一个块是由一个字的头部,有效荷载,以及一些额外的填充组织组成。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果加上一个双字的对齐约束条件,块的大小就总是8的倍数,且块大小的最低3位总是0。头部后面就是应用调用malloc时请求的有效荷载,有效荷载后面是一片不使用的填充块,大小任意。分配步骤如下:
1.放置已分配的块:应用请求块后分配器搜索空闲链表,查找足够大的可以放置所请求块的空闲块。执行搜索的方式有首次适配,下一次适配,最佳适配
2. 分割空闲块:找到空闲块后决定占用多大的空闲块,大小不合适时将其分割
3. 获取额外的堆内存:当找不到合适的空闲块时将向内存请求额外的堆内存
4. 合并空闲块:将碎片化的空闲块进行合并提高内存利用率,策略有使用标记的边界合并。我们可以将前面块已分配/空闲位存放在当前块中多出来的低位中,那么已分配的块就不再需要脚部,这块空间可以用作有效荷载
合并空闲块的情况如下图7-5所示。情况1:前面和后面的块都已经分配;情况2:前面的块已分配,后面的块空闲;情况3:前面的块空闲,后面的块已分配;最后是前后的块都空闲。
图7-6 隐式空闲链表合并情况
显示空闲链表是比隐式空闲链表更好的方式是将空闲块组织为某种形式的显示数据结构,根据定义,程序不需要一个空闲块的主体,实现这个数据结构的指针可以存放在这些空闲块的主体里。堆可以组织成一个双向空闲链表,在每个空闲块中都包含一个pred和succ指针。使用双向链表能使得首次适配的分配时间从块总数的线性时间减少到空闲块数量的线性时间。
7.10本章小结
本章主要介绍了hello的内存管理,内存的组织形式以及虚拟寻址、物理寻址的方法。分析了实际过程中core i7的四级页表结构以及从cache到主存之间的映射方式,从原理上可以理解hello程序在执行过程中是如何从内存获取信息的。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
(以下格式自行编排,编辑时删除)
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
(以下格式自行编排,编辑时删除)
8.3 printf的实现分析
(以下格式自行编排,编辑时删除)
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
(以下格式自行编排,编辑时删除)
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
(以下格式自行编排,编辑时删除)
结论
hello的一生至此已经走完,让我们回顾在hello的一生中都发生了什么:
1.源代码输入:hello的源代码hello.c通过键盘等外部设备输入系统并存储
2.预处理:预处理器对hello.c进行预处理得到hello.i,增加了一些附件让hello能更好的进行下一步
3.编译;将预处理后的文本文件hello.i进行语法分析优化等生成汇编文件hello.s
4.汇编:将汇编文件hello.s翻译成机器能读懂的机器代码hello.o
5.链接:链接器将hello.o与其他所需的库文件进行链接合并,生成可执行文件hello
6.进程管理和存储管理:系统提供给hello一个工作岗位,安排工作时间,即私有的地址空间和进程执行的时间,最后hello执行完以后被回收,结束了它的一生
7.I/O管理:hello与用户的接触都是在这一阶段,在键盘键入,屏幕输出hello的执行
计算机系统的实现实际上是从底层硬件的实现,相应的数据结构都要以机器的储存类型为机器。能够从c语言到可执行文件,虽然产生了许多转化,但这种基于底层构建起来的方式能够有助于程序员从需求出发进行设计而忽略底层的运行。理解了计算机系统,能够从原理上明白为什么高级语言需要按照某种特定的方法进行编写。同时也让我明白了,虽然同是01字符串,不同的数据结构和表示能够让这原本枯燥的计算机变得生动。学习了计算机系统后才发现计算机对异常处理如此巧妙,让我对有些时候系统上产生的错误有了重新的看法。
附件
hello.c ——hello的c语言代码
hello.i ——预处理之后的文本文件
hello.s ——编译之后产生的汇编文件
hello.o ——可重定位的目标文件
hello ——可执行文件hello
relasm.txt ——hello.o的反汇编文件
asm.txt ——hello的反汇编文件
elf.txt ——hello的ELF格式文件信息
参考文献
[1] (美)兰德尔E.布莱恩特,大卫R.奥哈拉伦著;龚奕利,贺莲译.深入理解计算机系统[M].北京:机械工业出版社.2016.
[2] https://blog.csdn.net/
[3] https://zhuanlan.zhihu.com/
[4] 灰信网(软件开发博客聚合)