本文以hello.c这一简单c语言文件研究了程序在Linux系统下的整个生命周期,以其原始程序开始,依次深入研究了编译、链接、加载、运行、终止、回收的过程,从而了解hello.c文件的“一生”。这是一个最简单的程序,却和最复杂的程序有着同样的生命周期经历。本文从一个程序员的视角展示了计算机系统的机制。
关键词:计算机系统;程序生命周期;预处理;进程
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
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的P2P是指hello.c文件从可执行程序(Program)变为运行时进程(Process)的过程。在Linux系统下,hello.c 文件依次经过cpp(C Pre-Processor,C预处理器)预处理、ccl(C Compiler,C编译器) 编译、as (Assembler,汇编器)汇编、ld (Linker,链接器)链接最终成为可执行目标程序hello(在Linux下该文件无固定后缀)。打开shell,输入命令./hello后,shell 通过fork产生子进程,hello 便从可执行程序(Program)变成为进程(Process)。
Hello的020是指hello.c文件“From 0 to 0”,初始时内存中并无hello文件的相关内容,这便是“From 0”。通过在Shell下调用execve函数,系统会将hello文件载入内存,执行相关代码,当程序运行结束后, hello进程被回收,并由内核删除hello相关数据,这即为“to 0”。
1.2 环境与工具
X64 CPU;2GHz;2G RAM;256GHD Disk 以上
Windows7/10 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位 以上;
Visual Studio 2010 64位以上;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
功能 | |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
hello2.asm a.out | 反汇编hello可执行文件得到的反汇编文件 可执行文件 |
1.4 本章小结
本章概述了hello 的P2P,020,同时列出了研究时采用的具体软硬件环境和中间结果。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理步骤是指程序开始运行时,预处理器(cpp,C Pre-Processor,C预处理器)根据以字符#开头的命令,修改原始的C程序的过程。例如,hello.c文件6到8行中的#include 命令会告诉预处理器读取系统头文件stdio.h,unistd.h,stdlib.h 的内容,并把这些内容直接插入到程序文本中。用实际值替换用#define定义的字符串。除此之外,预处理过程还会删除程序中的注释和多余的空白字符。预处理通常得到另一个以.i作为拓展名的C程序。
预处理过程将#include后继的头文件内容直接插入程序文本中,完成字符串的替换,方便后续处理。预处理过程中并未直接解析程序源代码的内容,而是对源代码进行相应的分割、处理和替换。简单来说,预处理是一个文本插入与替换的过程,生成的hello.i文件仍然是文本文件。
2.2在Ubuntu下预处理的命令
在Ubuntu系统下,进行预处理的命令为:
linux> gcc -E -o hello.i hello.c
运行结果如图1所示。
图1:对hello.c进行预处理
2.3 Hello的预处理结果解析
图2:hello.i在文本查看器下的内容
由图2所示,在预处理后文件增加到了3104行,主要是进行了宏替换和头文件的展开。以stdio.h头文件的展开为例,CPP先删除指令#include <stdio.h>,并到Ubuntu系统的默认的环境变量中寻找 stdio.h,最终打开路径/usr/include/stdio.h下的stdio.h文件。若stdio.h文件中使用了#define语句,则按照上述流程继续递归地展开,直到所有#define语句都被解释替换掉为止。除此之外,CPP还会进行删除程序中的注释和多余的空白字符等操作,并对一些值进行替换。
2.4 本章小结
本章主要介绍了预处理的概念及作用、并结合Ubuntu系统下hello.c文件实际预处理之后得到的hello.i程序对预处理结果进行了解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译是指C编译器ccl通过词法分析和语法分析,将合法指令翻译成等价汇编代码的过程。通过编译过程,编译器将文本文件 hello.i 翻译成汇编语言文件 hello.s,在hello.s中,以文本的形式描述了一条条低级机器语言指令。
编译可以将文本文件翻译成汇编语言程序,为后续将其转化为二进制机器码做准备
3.2 在Ubuntu下编译的命令
命令行:linux> gcc -S hello.i -o hello.s
编译如图3所示。
图3:将hello.i编译为hello.s文件
3.3 Hello的编译结果解析
3.3.1 数据类型
一.整数型
1.立即数
立即数在文件中以$+数字的形式出现,一个例子如图4所示。
图4:立即数示例
2.函数的局部变量
Int i作为函数的局部变量被编译器保存在栈当中,如图5所示。从图中可以看出其占用了四个字节。
图
5:局部变量i
3.main函数的参数argc
与局部变量相同都保存在堆栈当中。
二.字符串
hello.s中字符串如图6所示,均存储在.text代码段中。
图6:文件中的字符串
三.数组
程序中涉及的数组为char *argv[],即函数的第二个参数。在hello.s中,其首地址保存在栈中。访问时,通过寄存器寻址的方式访问。详细内容如图7、8所示
图7、8:*argv在文件中的位置和访问
赋值操作
int i
对局部变量的赋值在汇编代码中通过mov指令完成,而mov指令的后缀由传送的字节数确定,详细内容如表3所示。
表3 mov指令的后缀
后缀 | b | w | l | q |
大小(字节) | 1 | 2 | 3 | 4 |
算数操作
汇编语言中,算数操作的指令包括:
表格 4 算数操作指令
指令 | 效果 |
leaq s,d | d=&s |
inc d | d+=1 |
dec d | d-=1 |
neg d | d=-d |
add s,d | d=d+s |
sub s,d | d=d-s |
imulq s | r[%rdx]:r[%rax]=s*r[%rax](有符号) |
mulq s | r[%rdx]:r[%rax]=s*r[%rax](无符号) |
idivq s | r[%rdx]=r[%rdx]:r[%rax] mod s(有符号) r[%rax]=r[%rdx]:r[%rax] div s |
divq s | r[%rdx]=r[%rdx]:r[%rax] mod s(无符号) r[%rax]=r[%rdx]:r[%rax] div s |
在hello.s中,具体涉及的算数操作包括:
subq $32, %rsp:开辟栈帧
addq $16, %rax:修改地址偏移量
addl $1, -4(%rbp):实现i++的操作
图 9 算数操作示例
关系操作
在hello.s中,具体涉及的关系操作包括:
不等于
检查argc是否不等于4。在hello.s中,使用cmpl $4,-20(%rbp),比较 argc与4的大小并设置条件码,为下一步je利用条件码进行跳转作准备。
图 10 检查argc!=4
小于:
检查i是否小于10。在hello.s中,使用cmpl $9, -4(%rbp)比较i与9的大小,然后设置条件码,为下一步jle利用条件码进行跳转做准备。
图 11 检查i<10
数组操作
对数组的访问操作通过寄存器寻址方式实现。
图12:对数组的访问
控制转移
程序中控制转移的具体表现有两处:
控制转移:
当argc不等于3时,执行函数体内部的代码。在hello.s中,使用cmpl $3,-20(%rbp),比较 argc与3是否相等,若相等,则跳转至.L2,不执行后续部分内容;若不等则继续执行函数体内部对应的汇编代码。
图 13 控制转移
循环指令:
当i < 10时进行循环,每次循环i++。在hello.s中,使用cmpl $9,-4 (%rbp),比较 i与9是否相等,在i<=9时继续循环,进入.L4,i>9时跳出循环。
图 14 for循环的实现
函数操作
C语言中,调用函数时进行的操作如下:
传递控制:
进行过程 Q 的时候,程序计数器必须设置为 Q 的代码的起始地址,然后在返回时,要把程序计数器设置为 P 中调用 Q 后面那条指令的地址。
传递数据:
P 必须能够向 Q 提供一个或多个参数,Q 必须能够向 P 中返回一个值。
分配和释放内存:
在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
在hello.s中共调用了main, puts,printf,sleep,exit,getchar 等函数,对函数的调用都通过call指令进行。一个调用的实例如图12所示
图 12 call指令示例
3.4 本章小结
编译器通过词法分析和语法分析,在确认所有的代码都符合语法规则之后,对我们的代码做一些优化,将程序翻译成汇编代码,生成我们需要的hello.s文件。
对于一些操作(例如for循环和if判断),编译器会智能地简化过程,并将其转换为汇编语言的控制逻辑;编译器还可以证明函数的调用和跳转,并在堆栈框架和操作中平衡它们。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编是指汇编器(assembler)将以.s结尾的汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在.o 目标文件中的过程。其作用是将汇编语言翻译为机器语言,并将相关指令以可重定位目标程序格式保存在.o文件中。
4.2 在Ubuntu下汇编的命令
命令行:linux> gcc -c hello.s -o hello.o
汇编后结果如图13所示。
图13:汇编结果
4.3 可重定位目标elf格式
4.3.1获得elf文件
指令:readelf -a hello.o > elf.txt
4.3.2分析elf文件
一.ELF头
Elf头以一个16进制的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表的文件
偏移,以及节头部表中条目的大小和数量等信息。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。如图14所示。
图14:elf头
二. 节头部表。
节头部表包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。目标文件中每个节都有一个固定大小的条目。如图15所示。
图
15:节头部表
三.rela.text节
一个.text节中位置的列表,包含.text节中需要重定位的信息(例如hello.o中的getchar,exit等的重定位信息),需要使用链接器在组合时将这些位置链接。如图16所示。
图16:rela.text节
四.符号表
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。如图17所示。
图
17:符号表
4.4 Hello.o的结果解析
图
18:部分反汇编代码
反汇编内容如图18所示。可以看出反汇编代码与前文在分支转移、函数调用、全局变量访问中均有不同。
4.5 本章小结
本章介绍了汇编的概念与作用,在Ubuntu下通过实际操作将hello.s文件翻译为hello.o文件,并生成hello.o的ELF格式文件hello.elf,研究了ELF格式文件的具体结构。通过比较hello.o的反汇编代码(保存在hello.asm中)与hello.s中代码,
了解了汇编语言与机器语言的异同之处。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是指通过链接器,将程序编码与数据块收集并整理成为一个单一文件,生成完全链接的可执行的目标文件(windows系统下为.exe文件,Linux系统下一般省略后缀名)的过程。链接提供了一种模块化的方式,可以将程序编写为一个较小的源文件的集合,且实现了分开编译更改源文件,从而减少整体文件的复杂度与大小,增加容错性,也方便对某一模块进行针对性修改。
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
链接结果如图19所示。
图
19:链接后生成了可执行目标文件hello
5.3 可执行目标文件hello的格式
在Shell中输入命令 readelf -a hello > hello2.elf 生成 hello 程序的 ELF 格式文件,保存为hello2.elf(与第四章中的elf文件作区分)。详细的分析如下。
与可重定位文件结构类似。在ELF格式中,节头部表(Section Headers)对hello的各个节的信息做了说明,包括各段的起始地址(Address),大小(size),类型(Type),偏移(Offset)对齐要求(Align)等信息。头大小和节头数量增加,并且获得了入口地址。如图20所示。
图
20:hello2.elf中的ELF头。主要是类型发生改变、节头增加。
其他的部分与hello.elf类似,不再赘述。
5.4 hello的虚拟地址空间
打开edb,通过 data dump 查看加载到虚拟地址的程序代码。
图 21 加载到虚拟地址的程序代码
根据计算机系统的特性,程序被载入至地址0x400000~0x401000中。在该地址范围内,每个节的地址都与前一节中节对应的 Address 相同。根据edb查看的结果,在地址空间0x400000~0x400fff中存放着与地址空间0x400000~0x401000相同的程序,在0x400fff之后存放的是.dynamic到.shstrtab节的内容。
5.5 链接的重定位过程分析
在Shell中使用命令objdump -d -r hello > hello2.asm生成反汇编文件hello2.asm,与第四章中生成的hello.o.asm文件进行比较,其不同之处主要有①增加的节:hello中增加了.init和.plt节(以及一些节中定义的函数)②增加的函数:在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep等函数③函数调用:hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。④地址访问:hello.o中的相对偏移地址变成了hello中的虚拟内存地址。部分反汇编代码如图32所示。
图
22:部分反汇编代码
5.6 hello的执行流程
详细内容参见表4。
程序名称 | 程序地址 |
ld-2.27.so!_dl_start | 0x7fce8cc38ea0 |
ld-2.27.so!_dl_init | 0x7fce8cc47630 |
hello!_start | 0x400500 |
libc-2.27.so!__libc_start_main | 0x7fce8c867ab0 |
-libc-2.27.so!__cxa_atexit | 0x7fce8c889430 |
-libc-2.27.so!__libc_csu_init | 0x4005c0 |
hello!_init | 0x400488 |
libc-2.27.so!_setjmp | 0x7fce8c884c10 |
-libc-2.27.so!_sigsetjmp | 0x7fce8c884b70 |
--libc-2.27.so!__sigjmp_save | 0x7fce8c884bd0 |
hello!main | 0x400532 |
hello!puts@plt | 0x4004b0 |
hello!exit@plt | 0x4004e0 |
*hello!printf@plt | -- |
*hello!sleep@plt | -- |
*hello!getchar@plt | -- |
ld-2.27.so!_dl_runtime_resolve_xsave | 0x7fce8cc4e680 |
-ld-2.27.so!_dl_fixup | 0x7fce8cc46df0 |
--ld-2.27.so!_dl_lookup_symbol_x | 0x7fce8cc420b0 |
libc-2.27.so!exit | 0x7fce8c889128 |
5.7 Hello的动态链接分析
课堂中涉及到的动态链接部分不多,以下内容主要参考CSAPP和Hello的一生_啾星的小猫猫的博客-CSDN博客、https://blog.csdn.net/m0_51180992/article/details/124783702中的内容。
在edb调试之后我们发现原先0x00600a10开始的global_offset表是全0的状态,在执行过_dl_init之后被赋上了相应的偏移量的值。这说明dl_init操作是给程序赋上当前执行的内存地址偏移量,这是初始化hello程序的一步。
5.8 本章小结
本章中介绍了链接的概念与作用、并得到了链接后的hello可执行文件的ELF格式文本hello2.elf,据此分析了hello2.elf与hello.elf的异同;之后,根据反汇编文件hello2.asm与hello.asm的比较,加深了对重定位与动态链接的理解。(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个正在运行的程序的实例,系统中的每一个程序都运行在某个进程的上下文中。进程最大的作用就是给应用程序提供两个关键抽象:①一个独立的逻辑控制流,提供一个假象,好像程序独占地使用处理器②一个私有地址空间,提供一个假象,好像程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell 的作用:Shell 是一个用C语言编写的交互型应用程序,代表用户运行其他程序。Shell 应用程序提供了一个界面,用户可以通过这个界面进行系统的基本操作,访问操作系统内核的服务。
Shell的处理流程大致如下:
从Shell终端读入输入的命令。
切分输入字符串,获得并识别所有的参数
若输入参数为内置命令,则立即执行
若输入参数并非内置命令,则调用相应的程序为其分配子进程并运行
若输入参数非法,则返回错误信息
处理完当前参数后继续处理下一参数,直到处理完毕
6.3 Hello的fork进程创建过程
fork进程的创建过程如下:首先,带参执行当前目录下的可执行文件hello,父进程会通过fork函数创建一个新的运行的子进程hello。子进程获取了与父进程的上下文,包括栈、通用寄存器、程序计数器,环境变量和打开的文件相同的一份副本。子进程与父进程的最大区别是有着跟父进程不一样的PID,子进程可以读取父进程打开的任何文件。当子进程运行结束时,父进程如果仍然存在,则执行对子进程的回收,否则就由init进程回收子进程。
6.4 Hello的execve过程
调用函数fork创建新的子进程之后,子进程会调用execve函数,在当前进程的上下文中加载并运行一个新程序hello。execve 函数从不返回,它将删除该进程的代码和地址空间内的内容并将其初始化,然后通过跳转到程序的第一条指令或入口点来运行该程序。将私有的区域映射进来,例如打开的文件,代码、数据段,然后将公共的区域映射进来。后面加载器跳转到程序的入口点,即设置PC指向_start 地址。_start函数最终调用hello中的 main 函数,这样,便完成了在子进程中的加载。
6.5 Hello的进程执行
在程序运行时,Shell为hello fork了一个子进程,这个子进程与Shell有独立的逻辑控制流。在hello的运行过程中,若hello进程不被抢占,则正常执行;若被抢占,则进入内核模式,进行上下文切换,转入用户模式,调度其他进程。直到当hello调用sleep函数时,为了最大化利用处理器资源,sleep函数会向内核发送请求将hello挂起,并进行上下文切换,进入内核模式切换到其他进程,切换回用户模式运行抢占的进程。与此同时,将 hello 进程从运行队列加入等待队列,由用户模式变成内核模式,并开始计时。当计时结束时,sleep函数返回,触发一个中断,使得hello进程重新被调度,将其从等待队列中移出,并内核模式转为用户模式。此时 hello 进程就可以继续执行其逻辑控制流。
6.6 hello的异常与信号处理
6.6.1 正常运行
输入的参数依次为作者的学号、姓名、每次打印间隔时间,如图23所示。
图
23:正常运行。
6.6.2 在运行时按回车(或其他无意义字符)
可以看到,打印正常进行,只是会多出打进去的字符乱码,如图
24所示。
图24:在运行时按回车(或其他无意义字符)
在运行结束之后,这些字符会被当做下一条命令的输入。
6.6.3 在运行时按下Ctrl + C
Shell进程收到SIGINT信号,Shell结束并回收hello进程,如图25所示。
图
25:在运行时按下Ctrl + C
6.6.4 在运行时按下Ctrl + Z
Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程,如图26所示。
图
26:在运行时按下Ctrl + Z
6.6.5对进程的查看与回收
对hello进程的挂起可由ps和jobs命令查看,可以发现hello进程确实被挂起而非被回收,且其job代号为1。
图 27 用ps命令查看挂起进程
在Shell中输入pstree命令,可以将所有进程以树状图显示(此处仅展示部分):
图28:用pstree命令查看所有进程
输入kill命令,则可以杀死指定进程:
图29:使用Kill命令杀死指定进程
输入fg 1则命令将hello进程再次调到前台执行,可以发现Shell首先打印hello的命令行命令,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。
6.7本章小结
本章初步讲述了进程的概念和作用讲述了shell如何在用户程序运行时通过fork函数创建一个新的进程以及通过execve函数加载一个新的进程讲述了shell如何在用户和系统内核直接按通过上下文切换建立了一个交互运行的桥梁,还通过简单距离讲述了信号机制在程序运行中的作用,了解到了前端程序和后台程序。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:源程序里使用的地址,或者源代码经过编译以后编译器将一些标号,变量转换成的地址,或者相对于当前段的偏移地址。
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每个字节都有唯一的物理地址。
虚拟地址:虚拟地址就是逻辑地址,又叫虚地址。
线性地址:分段机制下CPU寻址是二维的地址即,段地址:偏移地址,CPU不可能认识二维地址,因此需要转化成一维地址即,段地址*16+偏移地址,这样得到的地址便是线性地址(在未开启分页机制的情况下也是物理地址)。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式内存管理方式就是直接将逻辑地址转换成物理地址,也就是CPU不支持分页机制。其地址的基本组成方式是段号+段内偏移地址。每个段选择符大小为16位,段描述符为8字节。GDT为全局描述表,LDT为局部描述符。段描述符存放在描述符表中,也就是GDT或LDT中,段首地址存放在段描述符中。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(VA)到物理地址(PA)之间的转换通过对虚拟地址内存空间进行分页的分页机制完成。
通过7.2节中的段式管理过程,可以得到了线性地址/虚拟地址,记为VA。虚拟地址可被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量),根据计算机系统的特性可以确定VPN与VPO的具体位数,由于虚拟内存与物理内存的页大小相同,因此VPO与PPO(物理页偏移量)一致。而PPN(物理页号)则需通过访问页表中的页表条目(PTE)获取。
若PTE的有效位为1,则发生页命中,可以直接获取到物理页号PPN,PPN与PPO共同组成物理地址。
若PTE的有效位为0,说明对应虚拟页没有缓存到物理内存中,产生缺页故障,调用操作系统的内核的缺页处理程序,确定牺牲页,并调入新的页面。再返回到原来的进程,再次调用导致缺页的指令。此时发生页命中,获取到PPN,与PPO共同组成物理地址。详细过程如图30所示。
图30:Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
TLB即翻译后备缓冲器,它是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块,TLB通常有高度的相连度。所以每次在翻译地址的过程时可以先在TLB中查询是否命中以直接获取物理地址。
多级页表:如果只用一个单独的页表进行地址翻译,会有一个占据内存大量空间的页表驻留在内存中,因此我们需要压缩页表,也就是使用了多级页表。一级页表中的每个PTE负责映射到虚拟地址空间中的一个片,这里每一个片都是由1024个连续的页面组成(也就是二级页表)再用这些二级页表的PTE覆盖整个空间。两级页表层次结构如下图所示:
当TLB与四级页表相结合其地址翻译过程如下:先将这个虚拟地址的VPN分为TLB标记部分和TLB索引部分检查是否再TLB命中如果命中直接取出物理地址,否则的化虚拟地址被划分为4个VPN和一个VPO每个VPN(i)对应了第i级页表的索引,通过这个索引最后对应了一个固定的PPN将这个PPN与VPO结合得到新的物理地址,并把这个物理地址的信息存入TLB缓存。
7.5 三级Cache支持下的物理内存访问
MMU发送物理地址PA给L1缓存,L1缓存从物理地址中抽取出缓存偏移CO、缓存组索引CI以及缓存标记CT。高速缓存根据CI找到缓存中的一组,并通过CT判断是否已经缓存地址对应的数据,若缓存命中,则根据偏移量直接从缓存中读取数据并返回;若缓存不命中,则继续从L2、L3缓存中查询,若仍未命中,则从主存中读取数据。详细结构如图31所示。
图31:三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
当fork函数被当前进程hello调用时,内核为新进程hello创建各种数据结构,并分配给它一个唯一的PID。为了给这个新的hello创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当着两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
删除已存在的用户区域
删除当前进程hello虚拟地址的用户部分中的已存在的区域结构。
映射私有区域
为新程序的代码、数据、bss和栈区域创建新的私有的、写时复制的区域结构。其中,代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
映射共享区域
若hello程序与共享对象或目标(如标准C库libc.so)链接,则将这些对象动态链接到hello程序,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器
最后,execve设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
发生一个缺页异常后,控制会转移到内核的缺页处理程序。判断虚拟地址是否合法,若不合法,则产生一个段错误,然后终止这个进程。
若操作合法,则缺页处理程序从物理内存中确定一个牺牲页,若该牺牲页被修改过,则将它换出到磁盘,换入新的页面并更新页表。当缺页处理程序返回时,CPU 再次执行引起缺页的指令,将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,主存将所请求字返回给处理器。
7.9动态存储分配管理
本学期课程并未涉及到动态内存部分,以下主要来自我的自学理解和csapp、【火炬】哈工大-CS-计算机系统-大作业: hello的一生 - 知乎等内容。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显示地保留为供应用程序使用。空闲块用来分配,空闲块保持空闲,直到它显示地被应用所分配。一个已分配地块保持已分配状态直到它被释放,这种释放要么是应用程序显式执行地,要么是内存分配器自身隐式执行地。
分配器分为两种基本风格:显式分配器、隐式分配器。显式分配器要求应用显式地释放任何已分配的块;隐式分配器要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
动态存储分配管理需要考虑地式分配速率和堆栈地利用绿,其中影响堆栈利用率低地主要原因是一种称为碎片地现象,非为外部碎片和内部碎片。其中内部碎片是在一个已分配块比有效荷载大时发生地。外部碎片是当空西安内存合计起来能够满足一个分配请求,但他们不是连续地时候发生地。
7.10本章小结
本章主要介绍了hello 的存储器地址空间、intel 的段式管理、hello 的页式管理, VA 到PA 的变换、物理内存访问,hello进程fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备管理:unix io接口
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前的文件位置、读写文件、关闭文件。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口:
打开文件,内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。
改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k
关闭文件。当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中
8.2.2 Unix IO函数:
read和write–最简单的读写函数;
readn和writen–原子性读写操作;
recvfrom和sendto–增加了目标地址和地址结构长度的参数;
recv和send–允许从进程到内核传递标志;
readv和writev–允许指定往其中输入数据或从其中输出数据的缓冲区;
recvmsg和sendmsg–结合了其他IO函数的所有特性,并具备接受和发送辅助数据的能力。
8.3 printf的实现分析
Windows下查看printf代码如图32所示。
图32: printf代码
(char*)(&fmt)+4)表示的是可变参数中的第一个参数的地址。vsprintf的作用是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。接着从vsprintf生成显示信息到write系统函数,直到陷阱系统调用int 0x80或syscall。Vsprintf函数代码如图33所示。
图
33:Vsprintf函数代码
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar调用系统函数read,发送一个中断信号,内核抢占这个进程,用户输入字符串,键入回车后(字符串和回车都保存在缓冲区内),再次发送信号,内核重新调度这个进程,getchar从缓冲区读入字符。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了linux的IO设备管理方法和及其接口和函数,对printf函数和getchar函数的底层实现有了基本了解。
(第8章1分)
结论
hello程序的一生经历了如下过程:
预处理
将hello.c中include的所有外部的头文件头文件内容直接插入程序文本中,完成字符串的替换,方便后续处理;
编译
通过词法分析和语法分析,将合法指令翻译成等价汇编代码。通过编译过程,编译器将hello.i 翻译成汇编语言文件 hello.s;
汇编
将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o 目标文件中;
链接
通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello;
加载运行
打开Shell,在其中键入 ./hello 1190200208 李旻翀,终端为其fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行;
执行指令
在该进程被调度时,CPU为hello其分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器一步一步地更新,CPU不断地取指,顺序执行自己的控制逻辑流;
访存
内存管理单元MMU将逻辑地址,一步步映射成物理地址,进而通过三级高速缓存系统访问物理内存/磁盘中的数据;
动态申请内存
printf 会调用malloc 向动态内存分配器申请堆中的内存;
信号处理
进程时刻等待着信号,如果运行途中键入ctr-c ctr-z 则调用shell 的信号处理函数分别进行停止、挂起等操作,对于其他信号也有相应的操作;
终止并被回收
Shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
我的感悟:做完了hello的一生,便是温习了整个《计算机系统》课程的所学。一路走来有困难,但终有所获。希望在以后的“码农”生涯中,能时刻谨记这一份收获。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
(附件0分,缺失 -1分)
文件名 | 功能 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
hello2.asm a.out | 反汇编hello可执行文件得到的反汇编文件 可执行文件 |
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2018.4
[2] [转]printf 函数实现的深入剖析https://www.cnblogs.com/pianist/p/3315801.html
[3] hello.c的前世今生https://zhuanlan.zhihu.com/p/401121362
[4] 梦想之家xiao_chen.ELF文件头更详细结构[EB/OL].2017[2021-6-10].
https://blog.csdn.net/qq_32014215/article/details/76618649.
[5] 操作系统——存储管理https://blog.csdn.net/hanpiyo/article/details/124936542
[6] C汇编Linux手册http://docs.huihoo.com/c/linux-c-programming
(参考文献0分,缺失 -1分)