计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能领域2+X
学 号 2021110511
班 级 2136001
学 生 寿天承
指 导 教 师 史先俊
计算机科学与技术学院
2023年4月
本文从最基本的hello.c作为切入点,对一个程序的生命周期展开介绍;主要内容包括七个板块:预处理、编译、汇编、链接、进程管理、存储管理、I/O管理。本文将以hello.c程序在Linux系统下的运行状态为参考基础,一步步阐释程序从键盘输入、保存到磁盘、程序运行结束、程序变为僵尸进程的全过程。
关键词:hello.c;Linux系统;生命周期;
目 录
第1章 概述
1.1 Hello简介
1.1.1 P2P(From program to process):
hello程序的生命周期是从一个高级C语言程序开始的,将hello.c翻译成可执行目标文件分为四个阶段:预处理、编译、汇编、链接。这四个阶段分别由cpp(预处理器)、cc1(编译器)、as(汇编器)、ld(链接器)完成。系统创建一个新进程并且把程序加载,从而实现程序向进程的转化。
1.1.2 O2O(From zero to zero):
当运行程序时,shell程序执行一系列指令(调用execve函数)加载可执行文件hello,这些指令将hello目标文件中代码的数据从磁盘复制到主存。程序结束运行时,父进程回收进程,释放虚拟内存空间。
1.2 环境与工具
1)硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
2)软件环境:Windows10 64位;VMware Workstation 16 player;Ubuntu 64位
3)开发和调试工具:gdb;edb;readelf;objdump;Code::Blocks20.03
1.3 中间结果
hello.i:hello.c预处理后的文件。
hello.s:hello.i编译后的文件。
hello.o:hello.s汇编后的文件。
hello:hello.o链接后的文件。
hello_o_disasm.txt:hello.o的objdump结果。
hello_disasm.txt:hello的objdump结果。
1.4 本章小结
本章根据hello的自白对hello程序进行了简要介绍,此外还介绍了该报告实验时的环境和工具并列举了hello程序编译过程中所有可能产生的中间文件。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理是源文件编译前所做的预备工作,预处理需要借助预处理程序。在源文件编译时,编译器会自动启动预处理程序进行预处理指令的解析。处理完成后才会进入编译阶段。
作用:1、宏定义 2、文件包含 3、条件编译
2.2在Ubuntu下预处理的命令
预处理命令:gcc -E -o hello.i
图一:预处理命令及生成文件
2.3 Hello的预处理结果解析
图二:hello.i内容
由上图可见,hello.i文件共有3100行,其中从3087行到3100行和hello.c主程序内容相同,而hello.i程序的前大部分代码为被加载到程序中的头文件。
2.4 本章小结
本章介绍了预处理的概念与作用,接着以hello.c为例,演示了在Ubuntu下如何使用Linux指令预处理程序,hello.i内容进行分析。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念:
编译器将预处理之后生成的文本文件hello.i翻译成文本文件hello.s,即生成汇编语言程序。
3.1.2编译的作用:
将高级语言源程序翻译转换为机器指令,机器指令和汇编指令一一对应,使机器更容易理解,为下一阶段汇编奠定基础。
3.2 在Ubuntu下编译的命令
图三:编译命令及其生成文件
3.3 Hello的编译结果解析
3.3.1 数据
1、常量:
(1)数字常量:在汇编语言中大部分数字常量是以立即数的形式表示,具体示例如下:
1)
上图中的立即数4对映hello.c中的if(argc!=4)中的argc判断条件。
上图中的立即数0对映hello.c中的for(i=0;i<9;i++)为i赋的值0。
由于编译器将i<9作为i<=8处理,所以上图立即数8对映循环中的判断条件。
(2)字符串常量:在printf等函数中出现的字符串常量存储在..rodata段,示例如下:
以上内容为记录文件相关信息的汇编代码,在后阶段的链接过程会时用,其中.file表明了源文件,.text代码段,.section .rodata只读代码段,.align对齐方式为8字节对齐,.string字符串,.global全局变量,.type声明main是函数类型,而其中的字符串Hello则对映hello.c中的字符串常量。
- 变量:
(1)全局变量、静态变量:该程序没有全局变量和静态变量,所以在hello.s的指令中没有.data和.bss节,但是存在.rodata只读数据节,这是用来存放printf的字符串的,上文已涉及,此处便不再赘述。
(2)局部变量:该程序中共三个局部变量,分别是argv数组、argc、i。
上图中寄存器%edi和%rsi分别传入的第一个参数和第二个参数存储在栈中。局部变量argv是保存着程序输入变量的数组;局部变量argc表示程序输入变量的个数,存储在栈中-20(%rbp)位置。
局部变量i存储在-4(%rbp)位置。
- 表达式:
赋值表达式i=0如下:
关系表达式argc!=4如下:
关系表达式i<9如下:
3.3.2 赋值
赋值在汇编语言中一般采用mov语句:i=0
3.3.3 算数操作
此程序中的算数操作为自加操作:i++
3.3.4 关系操作
此程序中的关系操作采用cmp语句实现:argc!=4和i<9
argc!=4
i<9
3.3.5 数组操作
汇编代码argv数组中的两个值都存储在栈中,printf("Hello %s %s\n",argv[1],argv[2])其中argv[1]存储在-32(%rbp)位置,argv[2]存储在-16(%rbp)位置,相应的汇编代码为:
3.3.6 控制转移
汇编代码中控制转移一般会采用jmp语句(及其同类型变体)
比较argc和4的大小,然后用je指令判断argc与4是否相等,若相等,则跳转到.L2;若不相等,则继续运行紧挨着的下一条指令,此处控制转移用于是否执行printf语句判断。
判断i是否大于等于8,若是则跳转到.L4,此处控制转移用于是否执行循环体的条件判断。
直接跳转,用于循环体的循环过程实现。
3.3.7 函数操作
该主程序调用了五个函数分别是:printf函数、sleep函数、getchar函数、exit函数和atoi函数。函数返回值保存在寄存器%rax中,函数的调用一般采用call语句。
1)调用printf函数:此处为printf("用法: Hello 学号 姓名 秒数!\n");
2)调用atoi函数:atoi(argv[3])
3)调用sleep函数:sleep(atoi(argv[3]));
4)调用getchar函数:getchar();
5)调用exit函数:exit(1);
6)调用printf函数:此处为printf("Hello %s %s\n",argv[1],argv[2]);
3.4 本章小结
本章首先介绍了编译的概念及作用,展示了hello.s文件结果,并从数据、赋值、算术操作、关系操作、数组操作、控制转移、函数操作方面对编译结果进行了详细分析。
第4章 汇编
4.1 汇编的概念与作用
汇编程序是指把汇编语言书写的程序翻译成与之等价的机器语言程序的翻译程序。汇编程序输入的是用汇编语言书写的源程序,输出的是用机器语言表示的目标程序。汇编器会把输入的汇编指令文件重新打包成可重定位目标文件,并将结果保存成.o文件。它是一个二进制文件,包含程序的指令编码。
汇编的作用:
完成从汇编语言文件到可重定位目标文件的转化。
4.2 在Ubuntu下汇编的命令
图四:汇编命令及其生成文件
4.3 可重定位目标elf格式
4.3.1 ELF头
使用命令readelf -h hello.o查看ELF头,结果如下图:
图五:ElF头
ELF头以一个16字节的序列(上图中的Magic)开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下部分的信息包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。例如上图中,数据部分显示系统采用小端法,文件类型为REL(可重定位文件),节头数量Number of section headers为14个等信息。
4.3.2 节头
节头描述了.o文件中出现的各个节的类型、位置、所占空间大小等信息。使用命令readelf -S hello.o查看节头,结果如下图:
图六:节头
夹在ELF头和节头部表之间的为节,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。
4.3.3 重定位节
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到最终未知未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并到可执行文件时如何修改新的引用。
使用readelf -r hello.o查看可重定位段信息,结果如下图:
图七:重定位节
上图中:偏移量是需要被修改的引用的节偏移;信息标识被修改引用应该指向的符号;类型告诉连接器如何修改新的引用;加数是一个有符号常数,一些类型的重定位要使用它对被修改的引用的值做偏移调整。
4.3.4符号表
符号表存放在程序中定义和引用的函数和全局变量的信息。使用命令readelf -s hello.o查看符号表,结果如下图:
图八:节头部表
上图中,Num为某个符号的编号,Name是符号的名称。第五行(Num为4)Size表示main函数是一个位于.text节中偏移量为0处的152字节函数,Type表示类型:例如main是函数。Bind表示这个符号是本地的还是全局的(local本地、global全局),由上图可知main函数名称这个符号变量是本地的。
4.4 Hello.o的结果解析
使用objdump -d -r hello.o命令对hello.o可重定位文件进行反汇编,得到结果如下图:
图九:反汇编结果
将其与hello.s进行对照分析可发现:
1)操作数:hello.s中操作数为十进制,反汇编代码中为十六进制;
2)分支转移:hello.s中地址使用段名称如 je .L2,而反汇编代码中则使用相对偏移地址,如 je 32<main+0x32>;
3)函数调用:hello.s中,call指令使用的是函数名称,反汇编代码中call指令使用的是相对偏移地址。原因是hello.s中调用的函数都是共享库中的函数,故需要通过等待调用动态链接将重定位的函数目标地址链接到共享库程序中,最终需通过动态链接器确定函数的运行时地址。
综上所述:二者除上述三点之外没有其他明显不同,说明汇编语言能与机器代码建立一一对应的映射关系。
4.5 本章小结
本章介绍了汇编,示例文件hello.s汇编得到hello.o后,进行ELF文件分析。然后通过hello.o反汇编得到的代码与hello.s进行比较,发现机器语言与汇编语言的映射关系,以及它与汇编语言相比较的异同之处。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
链接的作用:
将程序调用的各种静态链接库和动态连接库整合到一起,完善重定位目录,使之成为一个可运行的程序。链接器使得分离编译成为可能。
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头,节头部表,符号表:
5.3.1 ELF头
使用指令readelf -h hello查看hello的ELF头
5.3.2 节头
使用指令readelf -S hello查看节头信息
节头部表中包含了hello中所有节的信息,其中包括名称、类型、大小、地址和偏移量等信息,其中地址为程序被载入到虚拟地址的起始地址,偏移量为各个节在程序中的偏移量。根据节头部表的信息可以定位各个节的起始位置及大小。
5.3.3可重定位段
使用指令readelf -r hello查看可重定位段信息
5.3.4 符号表
使用指令readelf -s hello查看符号表信息
可以发现经过链接之后符号数量明显增大,说明经过链接之后引入了许多其他库函数的符号,它们加入到了符号表中。
5.4 hello的虚拟地址空间
在edb中打开可执行文件hello,可见hello虚拟地址空间的起始地址为0x401000,结束地址为0x401ff0。根据节头部表,我们可以找到对应的节的其实空间对应位置。
5.5 链接的重定位过程分析
5.5.1使用指令objdump -d -r hello查看hello可执行文件的反汇编条目
hello与hello.o的不同点:
1、在hello.o中,main函数地址从0开始,hello.o中保存的是相对偏移地址;而在hello中main函数0x401125开始,即hello中保存虚拟内存地址,对hello.o中的地址进行了重定位。
2、ELF描述文件总体格式,发现它包括了程序的入口点,即程序运行时执行的第一条指令的地址。由于可执行文件是完全链接的,因此没有rel节。
3、hello中多了.init节和.plt段。.init节定义函数_init,用于程序的初始化代码,还有初始化程序执行环境;.plt段为程序执行时的动态链接。所有重定位条目都被修改为确定的运行时内存地址。
4、在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数。
5.5.2 链接过程:
经以上分析可知,链接就是将多个可重定位目标文件合并到一起,生成可执行文件。链接需要进行符号解析、重定位及计算符号引用地址三个步骤。
5.5.3重定位:
重定位将合并输入模块。并为每个符号分配运行地址。重定位由两个步骤组成:重定位节与符号定义、重定位节中的符号引用。
1)重定位节与符号定义:链接器将相同类型的节合并为同一类型的新的聚合节,此后链接器将运行时内存地址赋值新的聚合节、输入模块定义的每个节,还有输入模块定义的每个符号。
2)重定位节中的符号引用:链接器修改代码节与数据节中对每个符号的引用,使他们指向正确的运行地址,这一步依赖hello.o中的重定位条目。
5.6 hello的执行流程
通过edb逐步调试,得到hello执行流程如下:
得到调用函数顺序如下:
ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
libc-2.27.so!__libc_start_main
-libc-2.27.so!__cxa_atexit
-libc-2.27.so!__libc_csu_init
hello!_init
libc-2.27.so!_setjmp
-libc-2.27.so!_sigsetjmp
–libc-2.27.so!__sigjmp_save
hello!main
hello!puts@plt
hello!exit@plt
hello!printf@plt
hello!atoi@plt
hello!sleep@plt
hello!getchar@plt
ld-2.27.so!_dl_runtime_resolve_xsave -ld-2.27.so!_dl_fixup
–ld-2.27.so!_dl_lookup_symbol_x
libc-2.27.so!exit
5.7 Hello的动态链接分析
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个程序链接起来,这个过程就是动态链接。
把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
.plt:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
.got:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
hello在动态连接器加载前后的重定位是不一样的,在加载之后才进行重定位
图十七:重定位后.init
5.8 本章小结
本章介绍了链接的过程。详细阐述了程序是如何进行重定位的操作,同时也说明了链接的工作原理。
第6章 hello进程管理
6.1 进程的概念与作用
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
6.1.2 进程的作用:
在运行一个进程时,我们的这个程序好像是系统当中唯一一个运行的程序,进程的作用就是提供给程序两个关键的抽象。一分别是独立的逻辑控制流和私有的地址空间。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 作用:shell是系统的用户界面,它接收并解释用户输入的命令,再将其送入内核执行。
6.2.2 处理流程:
读取从键盘输入的命令;判断命令是否正确,并判断命令是否为内置命令:若为内置命令则立即执行;否则将命令行的参数改造为系统调用execve()内部处理所要求的形式终端进程调用fork()来创建子进程,自身则用系统调用wait()来等待子进程完成;
当子进程运行时,它调用execve()根据命令的名字指定的文件到目录中查找可行性文件,调入内存并执行这个命令;如果命令行未尾有后台命令符号&终端进程不执行等待系统调用,而是立即发提示符,让用户输入下一条命令;如果命令末尾没有&则终端进程要一直等待;当子进程完成处理后,向父进程报告,此时终端进程被唤醒,做完必要的判别工作后,再发提示符,让用户输入新命令。
6.3 Hello的fork进程创建过程
父进程在读取命令后,首先判断该命令是否为内置命令,若非内置命令,则会调用fork命令创建子进程。子进程除PID外,与父进程完全一致,获得与父进程虚拟地址空间相同但独立的副本,其用户栈、寄存器、代码段等也与父进程一致,子进程可以读写父进程打开的任何文件。Fork函数在父进程中返回子进程PID,在子进程中则返回0。
6.4 Hello的execve过程
当创建了一个新运行的子进程后,子进程调用execve函数在当前子进程的上下文中加载并运行hello程序。execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到hello,execve才会返回到调用程序。所以execve调用一次且从不返回。
argv是一个参数字符串指针数组,argv[0]是可执行目标文件的名字,而envp的元素则指向环境变量。
6.5 Hello的进程执行
Hello在执行过程中涉及到几个十分重要的概念,如果不提前阐述就无法很好地理解Hello在运行中的状态。
6.5.1逻辑控制流
逻辑控制流是进程给运行程序的第一个假象,它让程序看起来独占整个CPU在运行,但实际上的情况当然不会是这样的,如下图。这三个进程的运行时间不是连续的,也就是说每个进程会交替的轮流使用处理器来进行处理。每个进程执行它的流的一部分,之后可能就会被抢占,如果被抢占了的话就会被挂起进行其他流的处理。
6.5.2并发流与时间片
两个流如果在执行的时间上有所重叠,那么我们就说这两个流是并发流,每个流执行一部分的时间就叫做时间分片。
6.5.3内核模式和用户模式
1)用户模式:
在用户模式中,进程不允许执行特权指令,例如发起一个I/O操作等,更重要的是不允许直接引用地址空间中内核区内的代码和数据。如果在用户模式下进行这样的非法命令执行,会引发致命的保护故障。
2)内核模式:
在内核模式下,进程的指令执行相对没有限制,这有点类似于在Linux操作系统中,是否使用sudo作为指令的前缀一样。而在内核模式下运行的进程相当于获得了超级管理员的许可。
6.5.4上下文切换
进程在运行时会依赖一些信息和数据,包括通用目的寄存器、浮点寄存器等的状态,这些进程运行时的依赖信息成为进程的上下文。而在进程进行的某些时刻,操作系统内核可以决定抢占当前的进程,并重新开始一个新的或者之前被抢占过的进程,这一过程成为调度。而抢占进程前后由于进程发生改变依赖信息也变得不同,这个过程就是上下文切换。
6.5.5Hello的执行
从Shell执行hello程序时,会先处于用户模式运行。在运行过程中,由内核不断进行上下文切换,配合调度器,与其他进程交替运行。如果在运行过程中收到了信号,那么就会陷入到内核中进入内核模式运行信号处理程序,之后再进行返回。
6.6 hello的异常与信号处理
异常可以分为四类:中断、陷阱、故障和终止。
信号可以被理解为一条小消息,他通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件,它提供了一种机制,通知用户进程发生了这些异常。
6.7本章小结
本章对进程展开介绍,hello在处理器中可能与其他进程并发执行,这就会涉及进程调度与上下文维护。进程运行过程中可能会发生各种异常,针对不同的异常有着不同的处理方式,但都是在内核态完成对异常的处理。进程同样会对信号做出响应。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1 逻辑地址:逻辑地址即程序中的段地址,逻辑地址由两部份组成,段标识符和段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。
7.2.2 线性地址:线性地址是逻辑地址到物理地址之间的一个中间层变换,程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么MMU内存管理单元会在内存映射表里寻找与线性地址对应的物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
7.2.3 虚拟地址:虚拟地址是CPU保护模式下的一个概念,保护模式是80286系列和之后的x86兼容CPU操作模式,在CPU引导完操作系统内核后,操作系统内核会进入一种CPU保护模式,也叫虚拟内存管理,在这之后的程序在运行时都处于虚拟内存当中,虚拟内存里的所有地址都是不直接的,所以你有时候可以看到一个虚拟地址对应不同的物理地址,比如hello进程里的call函数入口虚拟地址是0x001,而另一个进程也是,但是它俩对应的物理地址却是不同的,操作系统采用这种内存管理方法。
7.2.4 物理地址:物理地址就是内存中每个内存单元的编号,这个编号是顺序排好的,物理地址的大小决定了内存中有多少个内存单元,物理地址的大小由地址总线的位宽决定。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段偏移量加上基地址的和,构成线性地址。其中,段偏移量为逻辑地址的组成部分;基地址存储在段描述符表中,该表存储有多个描述符,每个描述符都描述了某个段的起始位置与大小等信息;而逻辑地址中的另一部分:段标识符的高13位为段选择符,段选择符能对应上段描述表中的一个描述符。
综上,逻辑地址到线性地址的变换过程为,取逻辑地址的段标识符中的段选择符,到段描述表中找到对应的描述符,描述符中存有段开始的线性地址,即段基址;段基址加上逻辑地址中的段偏移量就是线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址即hello程序虚拟地址空间中的虚拟地址,虚拟内存空间与物理内存空间都被划分为页,与页号相对应。虚拟地址由虚拟页号 + 虚拟页偏移量组成。页表是建立虚拟页号与物理页号映射关系的表结构,页表项包含有效位、物理页号、磁盘地址等信息。虚拟页号 + 页表起始地址能找到相对应的页表项,页表起始地址存储在页表基址寄存器中,页表项存储的页表状态有三种:未分配,已缓存,未缓存。当对应状态为已缓存时,说明虚拟页所对应的物理页已经存储在内存中,此时页表项存储的物理页号 + 物理页偏移量即为物理地址,而物理页偏移量与虚拟页偏移量相同,可以从虚拟地址中直接得出。当页表项中状态为未缓存时,若要读取该页,会引发缺页中断异常,缺页异常处理程序根据页置换算法,选择出一个牺牲页,如果这个页面已经被修改了,则写出到磁盘上,最后将这个牺牲页的页表项有效位设置为0,存入磁盘地址。缺页异常程序处理程序调入新的页面,如果该虚拟页尚未分配磁盘空间,则分配磁盘空间,然后磁盘空间的页数据拷贝到空闲的物理页上,并更新页表项状态为已缓存,更新物理页号,缺页异常处理程序返回后,再回到发生缺页中断的指令处,重新按照页表项命中的步骤执行。
7.4 TLB与四级页表支持下的VA到PA的变换
多级页表可以减小翻译地址时的时间开销。多级页表中,页表基址寄存器存储一级页表的地址,1到3的页表的每一项存储的下一级页表的起始地址,4级页表的每一项存储的是物理页号或磁盘地址。解析VA时,其前m位vpn1寻找一级页表中的页表项,接着一次重复k次,在第k级页表获得了页表条目,将PPN与VPO组合获得物理地址PA。
7.5 三级Cache支持下的物理内存访问
以组相联高速缓存为例,判断缓存是否命中,然后取出字的过程分为三步:
1)组选择
高速缓存从w的地址中抽取出s个组索引位,这些位被解释为一个对应于一个组号的无符号整数,用于在缓存中进行组选择。
2)行匹配
确定了缓存中的组i后,缓存将搜索组中的每一行,直到某行标记位与地址中的标记位一致,如果能找到这样的一行,那么即为命中。如果w不在组中的任何一行,那么就是缓存不命中,缓存会从下一级存储空间(例如L1的下一级为L2)中取出包含这个字的块,并依照特定的行替换策略将该行放入缓存中,行替换策略保证被替换行的被引用概率最低。
3)字选择
在命中的行中,使用块偏移选中字w返回给cpu。
7.6 hello进程fork时的内存映射
当fork函数被父进程调用时,内核为子进程创建各种数据结构,并分配它唯一的一个PID。为给这个新进程创建虚拟内存,它创建当前进程的mm_struct、区域结构与页表的原样副本;它将两个进程中的每个页面都标记为只读,并把两个进程中的每个区域结构都标记为私有的写时复制。
当fork在子进程中返回时,其现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程的任一者进行后续写操作时,写时复制机制就会创建新页面,也就为每个进程保持私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
在bash中的进程中执行了如下的execve调用:
execve("hello",NULL,NULL)
execve函数在当前进程中加载并运行包含在可执行文件hello中的程序,用hello替代了当前bash中的程序。
加载并运行hello需要以下几个步骤:
1、删除已存在的用户区域。删除当前进程hello虚拟地址的用户部分中的已存在的区域结构。
2、映射私有区域。为新程序的代码、数据、bss和栈区域创建新的私有的、写时复制的区域结构。其中,代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
3、映射共享区域。若hello程序与共享对象或目标(如标准C库libc.so)链接,则将这些对象动态链接到hello程序,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器(PC)。exceve做的最后一件事是设置当前进程的上下文中的程序计数器,是指指向代码区域的入口点。
下一次调度这个进程时,他将从这个入口点开始执行。Linux将根据需要换入代码和数据页面[1]。加载器映射用户地址空间区域图示如下(hello地位等同于a.out):
7.8 缺页故障与缺页中断处理
当指令引用一个地址,而与该地址相应的物理页面不在内存中,即PTE中的有效位是0,所以MMU出发了一次异常,会触发缺页故障,内核调用缺页处理程序。通过查询页表PTE可以知该页在磁盘的位置。缺页处理程序从指定的地址加载页面到物理内存中,然后更新PTE。再将控制返回给引起缺页故障的指令。当该指令再次执行时,相应的物理页面已加载在内存中,因此能够命中。
7.9动态存储分配管理
所有动态申请的内存都存在堆上面,用户通过保存在栈上面的一个指针来使用该内存空间。动态内存分配器维护着堆,堆顶指针是brk。有两种风格,一种叫显式分配器,使用两个函数,malloc和free,分别用于执行动态内存分配和释放。
malloc的作用是向系统申请分配堆中指定size个字节的内存空间。也就是说函数返回的指针为指向堆里的一块内存。并且,操作系统中有一个记录空闲内存地址的链表。当操作系统收到程序申请时,就会遍历该链表,然后寻找第一个空间大于所申请空间的堆结点,将该结点从空闲结点链表中删除后,将该结点的空间分配给到程序。在使用malloc()分配内存空间后,需释放内存空间,否则就会出现内存泄漏。
free()释放的是指针指向的内存,而不是指针。指针并没有被释放,它仍然指向原来的存储空间。因此指针需要手动释放,指针是一个变量,只有当程序结束时才被销毁。释放了内存空间后,原本指向这块空间的指针仍然存在。但此时指针指向的内容为垃圾,是未定义的。因此,释放内存后要把指针指向NULL,防止该指针后续被解引用。
7.10本章小结
本章详细阐述了hello在运行时内存空间的使用情况。首先对四个地址概念做了介绍。然后对段页式管理做了简单介绍,用hello这个实例,具有普遍性的介绍了cache与虚拟内存的实际使用。以及在虚拟内存视角下对fork和execve函数的映射关系有了全新理解,对于hello的诞生有了更清楚认识。也介绍了缺页故障与缺页中断处理、动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
在Linux中,所有的IO设备(网络、磁盘、终端等)都被模型化为文件,所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
①打开文件,应用程序要求内核打开相应的文件,来宣告它想要访问一个IO设备,内核返回这个文件的描述符以标识这个文件。Shell创建的每个进程开始时都有3个打开的文件:标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。
②改变当前的文件位置,应用程序通过执行seek操作,显式地设置文件的当前位置为k。
③读写文件,读操作就是从当前位置k开始,从文件复制n个字节到内存,然后将k增加到k+n,当k超出文件长度时应用程序能够通过EOF检测到。而写操作则是从内存复制n个字节到一个文件,从当前文件位置k开始,然后更新k。
④关闭文件,当应用完成了对文件的访问之后,它就通知内核关闭这个文件,内核释放文件打开时创建的数据结构和内存资源。
8.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;
}
首先,printf开辟一块输出缓冲区,然后用vsprintf在输出缓冲区中生成要输出的字符串。之后通过write将这个字符串输出到屏幕上。而write会通过syscall陷阱跳到内核,内核的显示驱动程序会通过这些字符串及其字体生成要显示的像素数据,将它们传到屏幕上对应区域的显示vram中。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
{
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会开辟一块静态的输入缓冲区,若输入缓冲区为空,则调用read向输入缓冲区中读入一行字符串。而read会通过syscall陷阱跳到内核,内核会使得调用方不断等待。当按下键盘后,键盘中断处理程序执行,向输入缓冲区中放入由键盘端口读入的扫描码转换成的字符,直到按下回车后调用方不再等待。那么getchar所做的事情其实就是不断地从输入缓冲区中取下一个字符,如果没有则等待输入。
8.5本章小结
通过以上分析,我们从底层的输入输出机理揭示了hello是如何在屏幕上打印出信息的,又是如何接受键盘输入的,它们背后的机制是软件通过底层IO端口或中断与外部硬件的交互。
结论
Hello程序在程序员通过键盘输入保存在磁盘上以.c文件存储,之后经过预处理,编译,汇编,链接等一系列过程,它从人能看懂的文本文件变成了机器能够看懂的二进制文件。
之后,在shell加载hello,shell根据输入判断,不是内置指令,于是先执行fork,变成了两个进程,此时复制了一份虚拟内存并且都映射到物理内存中相同的地址空间中,并把他们标记成为写复制,之后在子进程中调用execve装载hello的程序,此时会把虚拟内存中原有的区域结构删除,建立新的区域结构,至此hello成为了独立的进程。hello加载进入内存之后,首先会进行动态链接,动态链接器会根据hello的需要构建一个查表函数,而hello通过这个查表函数来进行对共享库函数的调用,在hello执行完毕之后,如果函数内部没有调用exit,__lib_start_main函数会帮我们调用exit退出,在退出后会给shell发送一个SIGCHLD信号,shell收到这个信息后会释放之前用于存储hello信息的一些内存空间,这就是hello从Zero到Zero的一生。
CSAPP作为一门十分经典的课程它教会了我许多:在知识层面,我对计算机有了更深刻的理解,从最开始的数据类型到后期的内存管理模式,这个过程一点点丰满了我对计算机的认知,如果说计算机在此课程前对我来说是一个黑箱,那么这个课程便是一缕烛光,使我能对黑箱的内容有了了解。此外,面对计算机这个精妙的仪器,我不得不惊叹于前人的智慧,我也不禁感慨在计算机这个知识领域还有太多的东西等待着我去探索和发掘。在思想层面,我认为自己需要学习前人在科技研发的路上追求卓越、用于创新的精神,前人的这种精神将在我未来的道路上激励我前进。