csapp大作业程序人生
计算机系统
大作业
计算机科学与技术学院
2024年5月
摘 要
本文围绕hello.c的完整实现,深入分析了hello.c从源代码文件经过预处理、编译、汇编、链接再到执行阶段,以及程序终止和回收的过程,全方面的了解了hello从编写完代码到进程结束的生命周期全过程。了解了hello.c的“一生”。
关键词:计算机系统;预处理;编译;汇编;链接;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
1.2 环境与工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1 预处理的概念与作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.4 Hello.o的结果解析
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.6 hello的执行流程
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.6 hello的异常与信号处理
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
7.10本章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
附件
参考文献
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
Hello的P2P(From Program to Process):当我们编写好一个原始的c语言代码,并将其储存成hello.c文件,hello.c先经过预处理得到hello.i文件,编译器对hello.i进行汇编,得到汇编文件 hello.s,hello.s被汇编器翻译成二进制机械指令并打包成可重定位目标程序保存在hello.o中,因为hello.c中调用了外部库中的printf函数,所以需要使用链接器ld将printf.o和hello.o进行链接生成可执行程序文件hello,它可被加载到内存中由系统执行。在Bash(shell)中,通过输入命令./hello,操作系统(OS)的进程管理通过调用fork函数,为其创建进程 (process),从而实现From Program to Process。
Hello的O2O(From Zero-0 to Zero-0):程序从无到有,从最初的零开始,经过编写、编译、链接等步骤,最终生成一个可以执行的程序。Shell 子进程调用 execve 函数,将程序进行虚拟内存映射、物理内存加载,加载完成后,控制权转移到 main 函数,程序开始执行,在执行过程中,程序调用各种系统函数,如 printf,通过系统调用与硬件交互,实现屏幕输出等功能。程序运行结束后,shell 父进程回收子进程,释放子进程所占用的虚拟内存空间和相关数据结构,进程的资源全部被释放,系统状态回到进程未执行前的状态,仿佛程序从未运行过,实现归零。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2.3GHz;16G RAM;512GHD Disk
1.2.2 软件环境
Windows 11 64位;VMware 17 ;Ubuntu 16.04 LTS 64位
1.2.3 开发工具
Visual Studio 2022 64位;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
| 文件名 | 作用 |
| hello.c | 源C语言程序 |
| hello.i | 预处理生成的文本文件 |
| hello.s | 编译生成的汇编文件 |
| hello.o | 汇编生成的可重定位目标文件(二进制) |
| hello | 经链接生成的可执行目标文件(二进制) |
| hello1.elf | hello.o经readelf得到的.elf文件 |
| hello1.asm | hello.o反汇编得到的文本文件 |
| hello2.elf | hello经readelf得到的.elf文件 |
| hello2.asm | hello反汇编得到的文本文件 |
1.4 本章小结
本章主要介绍了hello程序的P2P(From Program to Process) 及O2O(From Zero-0 to Zero-0)的过程。同时介绍了此次大作业完成的硬件环境、软件环境以及开发工具的相关信息。并列出为完成本次大作业,生成的中间结果文件的名字以及文件的作用。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
处理器(cpp)根据以字符#开头的命令,修改原始的C程序。通常包括系统自动检查包含预处理指令的语句和宏定义并进行相应的替换、删除注释及多余空白字符等,最终将调整后的源代码提供给编译器。比如hello.c中第一行中的 #include 命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果是得到了另一个C程序,通常以.i作为文件扩展名。
2.1.2预处理的作用
预处理可以处理C语言中以#开头的语句,大致包括以下几类:
#define:进行宏替换,用实际的常量或字符串常量来替换它的符号;
#include:处理文件包含,将包含的文件插入到程序文本中从而把包含的文件和当前源文件连接成一个新的源文件;
#if、#elif、#else等:条件编译,选择符合条件的代码送至编译器编译,实现有选择地执行相关操作
注释:删除C语言源程序中所有的注释;
#error等:特殊控制指令。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i 或cpp -o hello.i hello.c
图1 预处理命令运行截图
2.3 Hello的预处理结果解析
原本的24行hello.c源程序文件经过预处理生成了3061行的hello.i预处理文本文件。代码量显著增加。hello.i中的main函数(除注释与头文件)部分位于代码的最后,与源程序中的main函数基本相同,没有改变。
图2 hello.c文件与hello.i文件对比
对比发现,首先是注释部分被删除了,并且插入了大量的代码。其中在源程序中被引用的stdio.h,unistd.h,以及stdlib.h的代码,都被直接插入了程序文本中。
图3插入的stdio.h
图4 插入的unistd.h
图5 插入的stdlib.h
图6 文件所包含信息
2.4 本章小结
本章介绍了预处理的概念和具体的作用,并执行了hello.c的预处理过程,生成了hello.i文件,通过对比生成的hello.i文件与hello.c文件,加深了对预处理过程的理解。更好的理解了预处理的概念和作用。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.1.1 编译的概念
编译是指对经过预处理之后得到的.i文本文件通过编译器cc1进行词法分析和语法分析,确保所有语句符合语法规则,并将其转换为等价的中间代码或汇编代码的过程。
3.1.2 编码的作用
在编译阶段中,gcc首先要检查代码的规范性、是否有语法错误等,在检查无误后,gcc把代码翻译成汇编语言。在编译阶段,编译器还可以起到优化的作用。编译器经过词法分析、语法分析、中间代码生成、代码优化、目标代码生成几个过程将高级语言编写的源代码转换为机器可以执行的目标代码,从而确保程序的正确性和高效性。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s 或cc -S -o hello.s hello.i
图8编译过程截图
3.3 Hello的编译结果解析
3.3.1数据
- 常量数据
- 数字常量
图9-1 源程序中出现的数字常量
图9-2 hello.s 中编译器将数字常量处理为立即数
- 字符串常量
图10 源程序中出现的字符串常量
图10 hello.s中将字符串常量存入只读数据段.rodata 中
图11 打印字符串常量
- 变量数据
- 局部变量
图12 源程序中局部变量
图13 编译后的局部变量
3.3.2 赋值
图14 源程序中对局部变量i赋值
图15 汇编文件中对局部变量i赋值
3.3.3类型转换
图16 源程序中的类型转换
经过编译器编译后,argv[3]将字符型转化为整型数,存放在%eax中。
图17 编译后文件中的类型转换
3.3.4 算术操作
图18 源程序中的算术操作
图19 编译后的算术操作
3.3.5 关系操作
图20 源程序中的比较操作
图21编译后的比较操作
3.3.6 控制转移
第一个比较argc和5是否相等
第二个i初始化为0后,无条件跳转进入循环。
第三个比较i和9,小于等于9继续循环,大于9跳出循环。
图22编译后文件中的三次控制转移
3.3.7数组/指针/结构操作
图23 源程序中的数组
图24 编译后文件中的数组
3.3.8 函数调用
main()函数
参数传递:源程序中:int argc,char *argv[]
编译后:movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
局部变量:int i
函数返回:
编译前:exit(1);
return 0;
编译后:movl $1, %edi
call exit@PLT
movl $0, %eax
Leave
函数调用
1.printf()函数:
调用puts()函数进行字符串打印
还有正常调用printf()函数进行打印
2.exit()函数:参数为1
3.atoi()函数:参数为argc[4]
4.sleep()函数:参数为atoi(argv[3])的返回值
5.getchar()函数,没有参数
图25 源程序中的函数调用
图26 编译后文件中的函数调用
3.4 本章小结
本章主要介绍了编译的的概念以及编译的作用与功能,并在利用命令在Ubuntu下将hello.i文件编译生成了hello.s文件。而且通过比较源程序和编译后的文件,逐步分析了编译器是怎样处理C语言的各个数据类型以及各类操作的细致入微的分析过程加深了我对编译部分的理解。
(第3章2分)
4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
汇编是指汇编器将.s文件翻译成机器语言指令,把这些指令打包成可重定位目标文件的格式,并且将结果保存在.o文件中的过程。汇编后的文件是二进制文件。
4.1.2汇编的作用
将高级语言转换为机器代码,提供对硬件的低级控制,并且可以优化性能关键的代码段。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o或as -o hello.o hello.s
图27 汇编命令的截图
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
readelf -a hello.o > hello1.elf
图28 将hello.o汇编成.elf格式
4.3.1 文件头
图29 文件头
分析:Magic即魔数,用于标识ELF文件,其描述了生成该文件的系统的字的大小和字节顺序,ELF头的其余部分包含帮助链接器解析和解释目标文件的信息:包括ELF头的大小、目标文件的类型、处理器架构、节头部表的文件偏移,以及节头部表中条目的大小和数量等。
4.3.2 程序头
本程序中程序头为空
图30 文件头
4.3.3节表
节表描述了ELF文件中各个节的信息,包括节的名称、类型、偏移、大小等以及可以对各部分进行的操作权限。
- 节头
图31 节头
- 重定位节
用readelf -a hello.o探查ELF文件中能探查的其他节,其中.rela.text 包含 8 个条目。重定位节记录了各段中引用符号的相关信息。在链接过程中,链接器需要对这些位置的地址进行重定位。通过重定位条目的类型,链接器将判断如何计算地址值,并利用偏移量等信息计算出正确的地址。
图32 重定位节
| 偏移量 | 需重定向的代码在.text或.data节中的偏移位置 |
| 信息 | 包括symbol和type两部分,symbol占前半部分,type占后半部分,symbol表示重定位到的目标在.symtab中的偏移量,type表示重定位的类型 |
| 类型 | 重定位目标的类型 |
| 加数 | 计算重定位位置的辅助信息 |
表2 hello1.elf重定位节中的相关信息
- 符号表
符号表中列出了所有定位、重定位过程中需要引用的符号和信息,包括函数、变量和节。所有需要的符号均在其中声明。
图33 符号表
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
图34 hello.o对应的反汇编代码
4.4.1 数字进制不同
hello.s中的数字常量用十进制表示,hello.o的反汇编代码中的操作数用十六进制表示。
图35 hello.o反汇编代码和hello.s中不同的数字进制表示
4.4.2 函数调用不同
在hello.s中调用函数时,call指令后直接引用函数名称,hello.o的反汇编代码中,在call指令后加上下一条指令的地址来表示函数调用。在汇编之后、链接之前,hello.o文件包含机器语言代码,但还需要链接其他调用函数的.o文件,才能生成最终的可执行文件。因此,在call指令后面会留下链接用的空间,以便链接器在下一步进行重定位和链接。
图36 hello.o反汇编代码和hello.s中不同的函数调用
4.4.3 分支转移不同
在hello.s中,每个段都有其段名,分支跳转时,跳转指令后用相应段名表示跳转位置。而在hello.o的反汇编代码中,跳转指令后使用相应的地址表示跳转位置。
图37 hello.s和hello.o反汇编代码中不同的分支转移
4.5 本章小结
本章通过将hello .s 文件汇编成 hello.o 文件,深入理解了从汇编程序到可重定位目标程序(二进制文件)的过程。同时,查看ELF表,分析了其中的各项内容。接着将 hello.o 文件进行反汇编,通过与hello.s的对比,直观理解了它们的区别,并深入了解了机器代码的逻辑。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.1.1 链接的概念
链接是将各种代码和数据部分收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以在编译时执行,也可以在加载时执行,或者在运行时执行。
5.1.2 链接的作用
链接在软件开发中至关重要,因为它使分离编译成为可能。通过链接,我们可以将一个程序分割成若干独立的模块,为每个模块编写不同的源代码,并分别将其编译为目标文件或库,最后将这些模块链接起来形成一个完整的程序。
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/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
图38 生成可执行文件截图
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
命令readelf -a hello > hello2.elf
5.3.1 ELF头
与 hello1.elf 相比,hello2.elf 的基本信息(如 Magic 字段和类别等)未发生改变,但文件类型有所不同,程序头的大小和节头的数量有所增加,并且获得了入口地址。
图39 ELF头
5.3.2 节头
与hello1.elf相比,hello2.elf在链接之后的数量更多,从14个节变为30个
图40 节头
5.3.3程序头
和hello1.elf相比hello2.elf中增加了程序头部分,它包含了如何将文件的各个段加载到内存的指示信息,是执行程序时参考的关键部分。
图41 程序头
5.5.4 分段映射节
图42分段映射节
5.5.5动态节
图43 动态节
5.5.6重定位节
图44 重定位节
5.5.7符号表
图45 符号表
5.4 hello的虚拟地址空间
Data Dump从地址0x400000开始与程序头处读取的相关信息相同。与5.3对照可以发现各段均一一对应,可直观发现各段的虚拟地址与节头存在对应关系。
图46 进程的虚拟地址空间各段信息
5.5 链接的重定位过程分析
将hello.o与hello文件的反汇编代码进行对比分析,发现代码量显著增加。扩充的代码包括程序加载后在执行main之前进行的一些准备工作,以及 hello 需要使用的一些库函数的定义等。
在hello的反汇编文件中,每行指令都有唯一的虚拟地址,而hello.o的反汇编文件中,指令只有相对于代码段的偏移地址。这是因为目标文件只是一个中间产物,还没有被链接到最终的内存地址空间。hello是经过链接后,已经完成了重定位的可执行文件。每条指令都被分配了唯一的虚拟地址,指令的地址关系已经确定。
图47 hello.o与hello的反汇编代码对比
链接过程会分析所有相关的可重定位目标文件,并进行符号解析,将每个符号引用与符号定义关联起来。接下来,链接器根据汇编器产生的重定位条目指令,进行重定位,将每个符号定义与一个具体的内存位置关联。最终,链接器将程序运行所需的各个部分组装在一起,生成一个可执行目标文件。
5.6 hello的执行流程
| 程序名 | 程序地址 |
| main | 0x4011d6 |
| _init | 0x401000 |
| _start | 0x4010f0 |
| printf@plt | 0x4010a0 |
| puts@plt | 0x401090 |
| sleep@plt | 0x4010e0 |
| getchar@plt | 0x4010b0 |
| exit@plt | 0x4010d0 |
| _fini | 0x4012f8 |
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
5.7 Hello的动态链接分析
当程序调用共享库中的函数时,编译器无法预测该函数的确切地址,因为定义该函数的模块可以在运行时加载到任何内存位置。为了解决这个问题,编译器采用了延迟绑定的策略,将函数地址的加载推迟到第一次调用该函数时。动态链接器使用全局偏移量表(GOT)和过程链接表(PLT)来实现函数的动态链接。GOT是数据段的一部分,而PLT是代码段的一部分。
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
通过hello的ELF文件,可以看到GOT和PLT节的起始地址分别为0x403ff0和0x404000
在main处打断点观察变量前后变化:
分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
本章主要介绍了链接的概念及其作用。在Ubuntu环境下完成了链接过程,并分析了生成的hello可执行文件的ELF格式信息。使用EDB查看了“hello”程序的虚拟地址空间,使用objdump对可执行目标文件hello进行反汇编,并与之前的“hello.o”的反汇编程序进行比较。此外,还了解了链接的过程并分析了符号解析和重定位。最后,简要介绍了分析了“hello”程序动态链接的过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程是计算机操作系统中一个基本的执行单位,是正在运行的程序的实例。它不仅包含程序代码,还包含程序计数器、寄存器内容、变量和程序所需的资源。
6.1.2进程的作用
进程提供了独立的逻辑控制流,使得我们的程序好像独占地使用处理器。同时,它还提供了一个私有的地址空间,使得我们的程序好像独占地使用内存系统。此外,进程使CPU能够被科学有效地划分成多个部分,以便并行地运行多个进程。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash是系统的用户界面,提供了用户与内核进行交互操作的一种接口。可以接收用户输入的命令并将其送入内核去执行。
作用:shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。这些命令可以是系统内置的命令,也可以是用户自定义的脚本或程序。
处理流程:当Shell准备好接受用户输入时,会显示一个提示符,通常是一个特殊字符或者是包含主机名、当前目录等信息的字符串。Shell接收到用户输入后,会对其进行解释,并根据解释的结果执行相应的操作。如果是系统内置命令,Shell会直接执行;如果是外部程序或脚本,Shell会调用系统的执行程序来运行它们。如果有涉及,Shell会负责调用相应的系统功能来完成创建新的进程、分配资源、执行系统调用等操作。执行完命令后,Shell会将命令的输出结果显示给用户,并等待用户输入下一个命令。
6.3 Hello的fork进程创建过程
父进程通过fork函数创建一个新的运行的子进程,子进程中,fork返回0,父进程中,返回子进程的PID;子进程会几乎但不完全地复制父进程的状态。子进程会获得与父进程相同的虚拟地址空间的副本,但是独立的一份副本,子进程与父进程的最大区别在于子进程有不同于父进程的PID。当我们在shell中运行一个程序,比如./hello时,操作系统就会创建一个子进程来运行这个程序。
6.4 Hello的execve过程
在shell调用fork()函数创建子进程后,会调用execve函数,在进程的上下文中加载并运行“hello”程序。在调用execve函数时,操作系统会将当前进程的内存空间覆盖为新程序的代码和数据,并设置新的栈。然后,启动代码会被执行,它会初始化栈,并将可执行目标文件中的代码和数据从磁盘复制到内存中。最后,程序会通过跳转到入口点或第一条指令来运行,从而将控制转移给新程序的主函数。在正常情况下,execve函数不会返回到调用程序,只有当出现错误时才会返回。
6.5 Hello的进程执行
进程的正常运行依赖于其上下文状态,这些状态包括存储在内存中的程序代码、数据、栈、寄存器以及所占用的资源等。在程序正常运行期间,这些上下文状态必须保持完好,不能被异常破坏。
然而,由于CPU时间片的限制和多任务环境下的需求,进程需要不断进行切换。当一个进程正在运行时,可能会突然发生由主板上的时钟芯片引发的时钟中断,导致处理器立即从用户态转换到内核态。在内核态,操作系统会暂时保存当前进程的上下文,并通过进程调度程序选择下一个要执行的进程。内核会加载所选进程的保存的上下文,并将控制流交给该进程,然后处理器重新转回到用户态。
同时,操作系统会给每个进程分配一个时间片,该时间片决定了当前进程能够连续执行其控制流的一段时间。当进程的时间片用尽或者发生需要切换的事件时,如调用sleep函数导致系统调用引发异常,进程将被置于休眠态,控制流切换到其他进程。直到休眠时间结束或者其他条件满足时,操作系统会恢复被休眠进程的上下文,并将控制流重新交给该进程,处理器切换回用户态。
6.6 hello的异常与信号处理
6.6.1不停乱按,包括回车
在hello进程正常运行时,如果不断乱按键盘上的字符包括回车,这些字符可能会被打印在屏幕上,但没有实际作用。这是因为乱按按键会触发键盘中断(异步异常),导致处理器立即从用户态切换到内核态。在内核态,内核会识别到按下了某个字符,并可能会将其输出到屏幕上。然而,由于hello进程正在执行,所以它并不会对键盘输入做出任何响应,因此这些输入对进程本身没有影响。
6.6.2 Ctrl-C
程序执行时时按Ctrl-C,会导致断异常,使内核产生信号SIGINT,发送给父进程,父进程接收后,向子进程发生SIGKILL来强制终止子进程并回收。
6.6.3 Ctrl-Z
在程序执行过程中按Ctrl-Z,会产生中断异常,发送信号SIGSTP,这时hello的父进程shell会接收到信号SIGSTP并运行信号处理程序。最终hello被挂起,并打印相关信息。
输入ps将打印各进程的pid。
输入jobs将打印出被挂起的hello的jid及标识。
输入pstree 以树状图显示所有进程。
输入fg 将hello进程再次调到前台执行。
输入kill将杀死指定进程组的进程。
6.7本章小结
本章主要介绍了hello可执行文件的执行过程,包括进程的创建、加载和终止,以及处理键盘输入等环节。从进程的创建到其终止和回收,整个过程依赖于各种异常和中断的处理。程序的高效运行离不开异常、信号和进程管理等机制,正是这些机制确保了hello程序能够顺利地在计算机上执行。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1 逻辑地址
逻辑地址是由程序生成的段内偏移地址,也称为相对地址。它需要经过计算或转换才能得到内存中的实际有效地址,即物理地址。在hello程序的反汇编代码中看到的地址,通过加上对应段的基地址后才能得到实际的内存地址,这些就是hello中的逻辑地址。
7.1.2 线性地址
线性地址是逻辑地址到物理地址转换之间的中间层。hello程序的代码会生成逻辑地址,通过加上对应段的基地址,这些逻辑地址便转换成了hello程序中内容对应的线性地址。
7.1.3 虚拟地址
逻辑地址有时也被称为虚拟地址。因为它们与虚拟内存空间的概念类似,逻辑地址独立于实际物理内存容量,这些地址在hello程序中就是虚拟地址。
7.1.4 物理地址
物理地址是CPU外部地址总线上出现的寻址物理内存的地址信号,是地址转换的最终结果。在hello程序运行时,访问内存时需要通过CPU生成虚拟地址,然后通过地址翻译得到物理地址,并通过物理地址访问内存中的位置。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理的全称是段页式内存管理,这种方式将逻辑地址转换为线性地址,然后将线性地址转换为物理地址。逻辑地址与逻辑空间对应,逻辑空间被划分为不同长度的各个段。首先,通过段选择符找到对应的段描述符,段描述符中存储着该段的详细信息,然后根据段描述符获取到基地址。将基地址与偏移量相加即可得到线性地址。需要注意的是,线性地址和逻辑地址在本质上是相同的。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址到物理地址的转换是通过页式管理实现的。分页机制将虚拟内存空间划分成若干页,然后通过页表将这些虚拟页地址与物理内存地址建立一一对应关系。硬件地址转换机构(MMU)负责解决离散地址的转换问题。页式管理采用请求调页或预调页技术,实现内外存储器的统一管理。页表是由页表项(PTE)组成的数组,存储在内存中,用于将虚拟页地址映射到物理页地址。
如图所示,一个虚拟地址(VA)包含两个部分:虚拟页号(VPN)和虚拟页偏移量(VPO),其中VPO和物理页偏移量(PPO)是相同的。MMU利用VPN查找适当的PTE。如果PTE的有效位为1,即PTE命中,则直接将PTE中存储的物理页号(PPN)和虚拟地址中的虚拟页偏移量(VPO)结合,得到相应的物理地址。如果PTE不命中,则会触发缺页异常,调用缺页处理子程序进行处理。
图49 虚拟地址到物理地址的变换
7.4 TLB与四级页表支持下的VA到PA的变换
为节省页表空间,CPU通常采用多级页表机制,即上一级页表中的条目指向下一级页表。在常见的x86-64模式下,CPU使用四级页表。线性地址按位划分为五部分,前四部分分别作为每一级页表的索引,最低的12位作为页的偏移地址。CPU逐级查找对应物理页的PTE,最终得到物理地址。
为了优化页表查找效率,CPU还提供了用于页表的专用缓存TLB(翻译后备缓冲区)。TLB缓存页表的PTE,从而减少对内存的访问次数。
7.5 三级Cache支持下的物理内存访问
得到物理地址后,将其分解为标记位(CT)、组索引(CI)和块偏移(CO)。根据CI在L1缓存中查找对应的组,然后依次与组中每一行的数据比较,如果有效位为有效且标记位一致,则命中。如果命中,直接返回所需数据。如果不命中,则依次查找L2、L3缓存,直到找到数据为止,命中时将数据传给CPU,并更新各级缓存。不命中时,若有空闲块,则放置在空闲块中,否则根据替换策略选择牺牲块。
7.6 hello进程fork时的内存映射
在 Shell 输入命令后,内核通过执行 fork 来生成子进程,为 hello 程序的执行准备上下文,并赋予一个与父进程不同的PID。通过 fork 生成的子进程继承了父进程的内存区域结构、页表等副本,同时子进程也能访问父进程已打开的文件。当 fork 在新进程中完成后,新进程的虚拟内存与触发 fork 时父进程的虚拟内存保持一致。之后,当任一进程执行写操作时,写时复制机制将触发新页面的创建,以此维护每个进程的独立私有地址空间。
7.7 hello进程execve时的内存映射
执行 execve 函数以在当前进程中加载并执行可执行文件 hello,需要如下步骤
1.删除已存在的用户区域:这里指在fork后创建于此进程用户区域中的shell父进程用户区域副本。
2.设置私有区域:为 hello 程序的代码、数据、BSS段和栈区域创建新的区域结构。这些区域都设定为私有并支持写时复制。代码和数据区域映射自 hello 文件的 .text 和 .data 段。BSS段初始化为零,对应匿名映射,其大小根据 hello 文件定义。栈和堆区域也是请求二进制零的,初始长度为零。
3.配置共享区域:hello 程序需要与共享库 libc.so 进行动态链接。将 libc.so 映射到进程的用户虚拟地址空间内的共享区域。
4.初始化程序计数器:将程序计数器(PC)设置到hello代码区域的起始点。当进程下次被调度运行时,将从这个位置开始执行。
7.8 缺页故障与缺页中断处理
虚拟内存在DRAM缓存不命中即为缺页故障。
处理缺页中断的步骤:缺页中断处理程序首先确定要换出的牺牲页。如果该页已被修改,它将被写回到磁盘。然后,处理程序调入缺失的页面,并在内存的页表项中进行更新。最后,缺页中断处理程序将控制权返回给之前的进程,重新执行触发缺页异常的指令。
7.9动态存储分配管理
7.9.1动态内存管理的基本方法
动态内存分配器管理进程的一个虚拟内存区域,称为堆。这个分配器把堆视为由多种大小的内存块组成的集合。这些内存块,每一块都代表了一段连续的虚拟内存空间,可以是已被分配的,也可以是未被分配的。
已分配的内存块被明确地保留给应用程序使用。而未分配的块则保留在空闲状态,等待被应用程序分配使用。空闲块直到显式地被应用所分配前保持保持空闲。已分配的块将在释放前一直保持已分配状态,这种释放可以由应用程序显式执行或内存分配器自身隐式执行。
7.9.2 两种分配器
显示分配器:要求应用显示地释放任何已分配的块。如C语言中调用malloc函数来分配一个块,然后调用free函数来释放一个块。
隐式分配器:要求分配器到检测一个已分配块不再被程序所使用时,释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。
7.9.3 两种组织内存块的方法
1.显示链表
每个空闲块中,都包含一个前驱(pred)指针和一个后继(succ)指针,来减少搜索与适配的时间。
图50 分配块与空闲块
2.隐式链表
在堆中,空闲块通过它们头部的字段被隐式链接起来。分配器通过遍历堆的所有块来间接地遍历整个空闲块集合。
图51 简单的堆块的格式
7.10本章小结
本章深入讨论了hello程序的存储器地址空间,涵盖了逻辑地址、线性地址、虚拟地址和物理地址的定义,它们之间的关系及转换方法。分析了段式管理如何将逻辑地址转换为线性地址(即虚拟地址),探讨了页式管理如何实现从线性地址到物理地址的转换。详细介绍了在TLB和四级页表支持下,虚拟地址(VA)到物理地址(PA)的转换过程。强调了高速地址变址缓存(TLB)加速页表访问的功能。此外,讨论了在三级Cache支持下的物理内存访问流程,包括缓存命中和未命中的情况。分析了hello进程在执行fork与execve操作时的内存映射变化,还介绍了处理缺页故障和缺页中断的方法,详细说明了缺页中断的处理流程。最后,探讨了动态存储分配的管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.1.1设备的模型化:文件
在Linux中,一个文件被视为一个字节序列,通常有着特定的大小,用字节表示。所有的I/O设备,如网络、磁盘和终端,都被抽象为文件。因此,对于这些设备的输入输出操作都可以通过文件的读取和写入来完成。
8.1.2设备管理:unix io接口
通过将设备映射为文件,Linux内核提供了一种简单而底层的应用接口—UNIX I/O,使得对设备的访问与对普通文件的操作方式类似。
8.2 简述Unix IO接口及其函数
8.2.1打开文件
应用程序请求内核打开特定文件以访问I/O设备。内核会返回一个非负整数,称为文件描述符,用于标识该文件,并在后续操作中使用。内核会记录有关打开文件的所有信息,而应用程序只需记住该描述符即可。
8.2.2改变文件位置
对于每个打开的文件,内核都会维护一个文件位置,初始值为0。这个文件位置表示从文件开头开始的字节偏移量,应用程序可以通过执行seek操作来显式设置文件的当前位置。
8.2.3读写文件
读操作从文件的当前位置开始,将n个字节复制到内存中,从文件位置k到k+n的范围。写操作则将内存中的n个字节从当前位置k开始写入文件,然后更新文件的位置为k。
8.2.4 关闭文件
当不再需要打开的文件时,内核会释放相关的数据结构,并将文件描述符返回到可用的描述符池中。无论进程因何种原因终止,内核都会关闭所有已打开的文件,并释放它们的内存资源。
8.2.5 Unix I/O函数
1)open()函数:
int open(char *filename, int flags, mode_t mode);
open 函数将指定的文件 filename 转换为一个文件描述符,并返回该描述符的数字。flags参数指明进程如何访问文件,mode 参数指定了新文件的访问权限位。
- close()函数:
int close(int fd);
用于关闭一个打开的文件。
- read()函数:
ssize_t read(int fd, void *buf, size_t n);
从描述符为 fd 的当前文件位置复制最多n个字节到内存位置buf。如果发生错误,函数返回值为-1,返回值为 0表示EOF。否则,返回值表示实际传输的字节数量。
4)write()函数:
ssize_t write(int fd, const void *buf, size_t n);
从内存位置buf复制最多n个字节到描述符fd的当前文件位置。
5)lseek()函数:
使应用程序能够显式地修改当前文件的位置。
8.3 printf的实现分析
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系统调用会触发一个系统调用陷阱,将控制权转移到内核态。在内核中,显示驱动程序会将这些字符串转换为相应的像素数据,并将它们传输到屏幕对应区域的视频内存(VRAM)中。显示芯片会按照设定的刷新频率逐行读取VRAM,并通过信号线向液晶显示器传输每一个像素的RGB分量,最终在屏幕上显示出对应的内容。
8.4 getchar的实现分析
int getchar(void)
{
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()时,程序会等待用户按键。用户输入的字符被存放在键盘缓冲区中,直到用户按下回车为止。当用户按下回车后,getchar()开始从标准输入(stdin)流中每次读取一个字符。getchar()函数的返回值是用户输入的第一个字符的ASCII码,如果出错则返回-1,并将用户输入的字符回显到屏幕上。如果用户在按下回车之前输入了多个字符,那么这些字符会保留在键盘缓冲区中,等待后续的getchar()调用来读取。因此,后续的getchar()调用不会等待用户按键,而是直接读取键盘缓冲区中的字符,直到缓冲区中的字符被读取完毕后,才会再次等待用户的输入。可以将其看作是一种异步事件处理,即键盘中断处理。键盘中断处理程序接收按键的扫描码并将其转换成ASCII码,然后将ASCII码保存到系统的键盘缓冲区。当getchar()函数调用read()系统函数时,通过系统调用读取键盘缓冲区中的按键ASCII码,直到接收到回车键才返回。
8.5本章小结
本章主要介绍了Linux中的IO设备管理方法、Unix I/O接口以及相关函数,以及printf和getchar函数的实现原理。通过本章的学习,深入了解了Unix I/O接口在Linux系统中的作用,以及键盘中断作为异步异常的处理过程。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
1.hello.c源文件经过预处理生成hello.i。
2.编译器(cpp)编译hello.i生成hello.s汇编文本文件。
3.将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o目标文件中。
4.通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello。
5. 在Shell中输入命令./hello 王均怡 2022112282 13212801667 2,生成子进程。
6.子进程使用execve函数加载并运行名为hello的程序,并通过参数传递给该程序。
7. 操作系统为子进程分配虚拟内存空间,并将hello程序加载到该空间中。
8. 内核使用异常控制流调度hello进程,使其开始执行。在执行过程中,hello进程调用printf函数和getchar等函数进行输入输出操作。
9. 当hello进程执行完毕后,Shell父进程等待并回收子进程资源,内核删除为hello进程创建的所有数据结构。
深切感悟:通过计算机系统的学习,我了解了在学习C语言的时候一个最为简单的hello程序实现的背后,原来是如此复杂的过程。在没有学习计算机系统时,我觉得hello是最为简单的程序,但是经过本次大作业,我深入的了解了hello需要经历的每一个步骤,以及每个步骤是如何实现的有什么作用。对于计算机的学习,我还是一个初学者,但本次大作业也让我看到了计算机学习的精妙。在以后的学习生活中,我也将继续深入的学习计算机系统相关知识,并利用这些知识进行更为深入的学习与创新。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
| 文件名 | 作用 |
| hello.c | 源C语言程序 |
| hello.i | 预处理生成的文本文件 |
| hello.s | 编译生成的汇编文件 |
| hello.o | 汇编生成的可重定位目标文件(二进制) |
| hello | 经链接生成的可执行目标文件(二进制) |
| hello1.elf | hello.o经readelf得到的.elf文件 |
| hello1.asm | hello.o反汇编得到的文本文件 |
| hello2.elf | hello经readelf得到的.elf文件 |
| hello2.asm | hello反汇编得到的文本文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
- 伍之昂. Linux Shell编程从初学到精通 [M]. 北京:电子工业出版社
[2] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统(原书第三版)[M]. 机械工业出版社.2016.
[3] http://www.elecfans.com/emb/20190402898901.html
[4] https://www.cnblogs.com/knife-king/p/11090029.html
(参考文献0分,缺失 -1分)













































































