计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 未来技术模块
学 号 2022113507
班 级 WL027
学 生 朱江
指 导 教 师 郑贵滨
计算机科学与技术学院
2024年5月
hello.c作为一个适合程序语言初学者阅读和执行的编程语言,表面看起来简单,实际上包含了丰富的内涵。本论文结合《深入理解计算机系统》一书,全面探讨了hello.c从预处理到IO管理的整个执行过程。论文详细介绍了hello.c涉及的计算机系统知识和内容,对其中的各种机制进行了深入的分析和剖析。
具体来说,论文首先解释了hello.c的预处理阶段,包括编译器如何处理代码以准备执行。然后,论文讨论了hello.c的编译过程,详细分析了编译器如何将源代码转换成可执行的机器代码。接着,论文探讨了hello.c的运行时环境,包括内存管理和程序的执行流程。此外,论文还深入讨论了hello.c中涉及的输入输出(IO)管理机制,包括文件操作和标准输入输出流的处理方式。
通过结合hello.c的具体实现和《深入理解计算机系统》提供的理论知识,论文不仅帮助读者理解hello.c本身,还帮助读者深入理解计算机系统底层的工作原理和机制。这样的分析有助于程序语言初学者更好地理解编程语言的运行方式和底层系统如何支持和执行这些语言。
总之,本论文通过对hello.c从预处理到IO管理的全面剖析,结合了实际和理论的双重视角,为读者提供了深入理解计算机系统的宝贵机会,为进一步的学术研究和实践探索奠定了坚实的基础。
关键词:hello.c;计算机系统;
目 录
第1章 概述
1.1 Hello简介
我们通过键盘输入一行行代码,这些代码组成了一个C源文件,即我们的主角 hello.c。接下来,hello.c 经过预处理器 cpp,编译器 cc1,汇编器 as,链接器 ld 等一系列工具的处理,最终生成一个可加载到内存执行的可执行目标文件 hello。
然后,我们在Shell中执行命令 "./hello"。Shell 通过 fork 函数创建一个新的进程,然后在子进程中通过 execve 函数将 hello 程序加载到内存中。虚拟内存机制通过 mmap 为 hello 进程分配了一片虚拟空间,调度器为 hello 进程分配了执行时间片,使其能够与其他进程一同合理地利用 CPU 和内存资源。这标志着 hello 完成了其从程序到进程(P2P,Program to Process)的转变过程。
随后,CPU 逐条从 hello 的 .text 段取指令,寄存器的值随程序执行而不断变化,异常处理程序监视着键盘的输入。hello 中的系统调用 syscall 会导致进程陷入内核,内核接管进程执行 write 函数,将一串字符传递给屏幕 IO 的映射文件。映射文件对输入数据进行解析,读取 VRAM,然后在屏幕上显示出一行行字符串。
最后,hello 程序执行完毕,Shell 通过 waitpid 函数通知内核回收 hello 进程,hello 进程终止。至此,hello 完成了其程序执行的一生,如同零到零(O2O,From Zero to Zero),既不带来也不带走。
这个过程中,操作系统的各个组成部分如预处理器、编译器、汇编器、链接器、虚拟内存机制、调度器、异常处理程序、系统调用以及 IO 映射文件,共同参与并完成了 hello 程序的运行与输出过程。
1.2 环境与工具
硬件环境:
处理器 AMD Ryzen 7 5800H with Radeon Graphics 3.20 GHz
机带 RAM 16.0 GB (15.4 GB 可用)
NVIDIA GeForce RTX 3060 Laptop GPU
软件环境:
Windows 11 家庭中文版 23H2
VMware Workstation 17.5.x Player
Ubuntu 22.04.1
gcc version 11.4.0
1.3 中间结果
hello.c | C源文件 |
hello.i | C预处理文件,由hello.c预处理得到 |
hello.s | 汇编语言文件,由hello.i编译得到 |
hello.o | 可重定位目标文件,由hello.s汇编得到 |
hello_elf.txt | 由readelf生成的关于hello.o的ELF信息 |
hello_asm.txt | 由objdump生成的关于hello.o的反汇编信息 |
hello | 可执行文件,由hello.o链接得到 |
hello_exe_elf.txt | 由readelf生成的关于hello的ELF信息 |
hello_exe_asm.txt | 由objdump生成的关于hello的反汇编信息 |
1.4 本章小结
本章描述了hello.c的P2P以及020过程,以及本人在撰写论文时所使用的软硬件环境,测试使用的工具,以及产生的中间文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理(Preprocessing)是指在编译过程中对源代码进行预处理的一系列操作。预处理器负责根据源代码中的预处理指令进行处理,生成经过宏展开、条件编辑处理后的中间代码。预处理器通常是编译器的一部分,负责在实际编译之前进行一些准备工作。
2.1.2预处理的作用
- 宏替换(Macro Replacement)
在C语言中,通过’#define’定义的宏可以在源代码中多次使用,预处理器会将宏名替换为宏定义的内容。
- 文件包含(File Inclusion)
通过’#include’指令可以将其他文件的内容包含到当前文件中,使得源代码可以模块化的组织。
- 条件编译(Conditional Compilation)
使用’#ifdef’,’#ifndef’,’#if’,’#else’,’#endif’等预处理指令来根据条件编译特定的代码段。
- 注释移除(Comment Removal)
预处理器可以移除源代码中的注释,以减少编译后的文件大小。
- 其他
还有一些其他的预处理指令和功能,如’#undef’,’#error’,’#pragma’等,用于定义宏、显示错误信息、控制编译行为等。
2.2在Ubuntu下预处理的命令
可以用以下命令进行预处理:
gcc -m64 hello.c -E -o hello.i
预处理过程如图:
图2.2-1 在Ubuntu下预处理的过程
2.3 Hello的预处理结果解析
打开hello.c和hello.i进行对比不难发现,hello.i将hello.c的文件内容进行了大量扩充,从原本的24行扩充到3061行。
通过内容对比,可以发现,hello.c与hello.i文件中的main函数内容相同,但是hello.i文件中将注释全部删除了。
图2.3-1 main函数内容对比
其次是头文件部分,hello.i中将hello.c的#include部分替换成了相应的头文件的内容。并且在此基础上增添了许多hello.c中没有包含的头文件,是由hello.c中引用的头文件stdio.h,unistd.h与stdlib.h所间接应用的,均被递归地插入到了hello.i之中。
图2.3-2头文件对比
2.4 本章小结
本章介绍了预处理的概念及主要功能:宏替换、文件包含、条件编译、注释移除等。然后,使用gcc -m64 hello.c -E -o hello.i命令,在Ubuntu下对hello.c进行了预处理,得到了hello.i文件。最后,将hello.i与hello.c两者的内容进行了对比,发现了预处理对注释进行丢弃以及对头文件进行修改与扩充。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译是指利用编译器将预处理后的文本文件.i编译成汇编语言程序文件.s的过程。
3.1.2编译的作用
- 词法分析(Lexical Analysis)
编译器的词法分析器(lexer)将源代码拆分成一系列的标记(token),这些标记是源代码的基本组成单元,如关键字、变量名、操作符和标点符号。
- 语法分析(Syntax Analysis)
编译器的语法分析器(parser)根据词法分析生成的标记,构建出源代码的语法结构(通常是抽象语法树,AST),并检查语法是否正确。
- 语义分析(Semantic Analysis)
编译器在语义分析阶段检查源代码是否符合语言的语义规则,如类型检查、变量是否已声明、函数调用是否正确等。
- 中间代码形成(Intermediate Code Generation)
编译器将抽象语法树或其他中间表示(IR)转换成中间代码。中间代码是一种介于源代码和目标代码之间的代码形式,通常独立于具体的机器架构,即汇编语言。
- 优化(Optimization)
编译器对中间代码进行优化,以提高生成代码的执行效率和减少资源消耗。优化可能包括循环优化、常量折叠、死代码消除等。
3.2 在Ubuntu下编译的命令
可用以下命令进行编译:
gcc -m64 hello.i -S -o hello.s
编译过程如图:
图3.2-1 在Ubuntu下编译的过程
3.3 Hello的编译结果解析
3.3.1数据
- 整型常量
在hello.c中出现的整形常量在hello.s中被编译器编译为立即数。如argc!=4中的整型5以立即数$5出现(图3.3-1),exit(1)中的整型常量1以立即数$1的形式出现(图3.3-2),i=0中的整型常量0以立即数$0的形式出现(如3.3-3)。
图3.3-1
图3.3-2
图3.3-3
- 字符串常量
在hello.c中出现的字符串常量在hello.s中都有对应出现,编译器将字符串常量存入了 .note节之中。(图3.3-4)在使用字符串常量的时候,编译器将字符串常量的地址根据传参规则赋值至对应寄存器中。(图3.3-5,图3.3-6)
图3.3-4
图3.3-5
图3.3-6
- 局部变量
在hello.c中仅有一个循环变量i,在编译器中被编译成了对寄存器%rbp的相应操作。(图3.3-7)
图3.3-7
3.3.2赋值
在hello.c中仅出现了一次赋值,即i=0。(图3.3-8)
图3.3-8
3.3.3类型转换
在hello.c中有语句:sleep(atoi(argv[3])),atoi返回类型为int,sleep接收类型为unsigned int的参数,发生了隐式类型转换。但分析其对应汇编语句后发现,atoi将返回值保存在%eax中,随后直接将%eax作为参数赋值给%edi,便开始调用sleep,隐式转换并没有在.s文件中展现出来。(图3.3-9)
图3.3-9
3.3.4算术操作
hello.c中仅出现了一次整型变量i自增的算术运算i++,编译为了对应寄存器 %rbp的加1操作。(图3.3-10)
图3.3-10
3.3.5关系操作
对于关系操作,编译器一般会将其编译为cmp操作,随后通过je、jne、ja、jb、jg、jl等跳转命令实现分支结构。在hello.c中,整型变量argc与整型常量5的不等比较,编译为cmp + jne(图3.3-11);整型变量i与整型常量10的小于比较,编译为cmp + jle(图3.3-12)。
图3.3-11
图3.3-12
3.3.6数组操作
编译器对数组操作编译为对地址的加减操作,取值时使用mov,取地址时使用lea。在hello.c中使用argv[]对数组进行访问,但是在hello.s中使用movq进行访问。如argv[1],argv[2],argv[3],argv[4]对应的编译操作是:
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rcx——————argv[1]
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx——————argv[2]
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax——————argv[3]
movq %rax, %rsi——————argv[4]
在64位寄存器中,每一个字符占8字节,所以增量为8。
3.3.7控制转移
hello.c文件中使用了两次控制转移,分别是if的分支语句,在.s文件中被编译为了cmp+je(图3.3-13);for循环语句,在.s文件中被编译为了cmpl+jle(图3.3-14)。
图3.3-13
图3.3-14
3.3.8函数操作
.c文件中一共使用了6次函数调用,分别是printf 2次、exit 1次、sleep 1次、atoi 1次、getchar 1次。对应的.s文件中均使用call指令进行函数调用。如下:
Call printf
Call exit
Call sleep
Call atoi
Call getchar
3.4 本章小结
本章主要介绍了编译的概念及其作用,其主要作用为对高级程序语言编译为汇编语言,并进行优化。然后使用gcc对hello.c文件进行编译,得到hello.s文件。最后,将.c文件和.s文件在数据、赋值、类型转化、算数操作、关系操作、数组操作、控制转移、函数操作等方面进行对比,并得知编译器是如何工作的。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
汇编指使用汇编器(as)将.s文件翻译成二进制的机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,将结果保存在目标文件.o中的过程。
4.1.2汇编的作用
- 低级硬件控制
- 性能优化
- 代码精简
- 嵌入式系统编程
- 编写操作系统和驱动程序
4.2 在Ubuntu下汇编的命令
可用以下命令进行汇编:
as hello.s -o hello.o
汇编过程如图:
图4.2-1 汇编过程
4.3 可重定位目标elf格式
使用readelf -a hello.o命令,可在终端查看hello.o的ELF格式。也可使用readelf -a hello.o > hello_elf.txt将结果重定向至文本文件hello_elf.txt中,便于查看。
4.3.1ELF头部(ELF Header)
ELF头部包含有关文件布局的基本信息,如文件类型、目标架构、入口点地址等(图4.3-1)。
图4.3-1
ELF头部各成员含义可见下表(表4.3-1):
成员 | 作用 |
e_type | 表明本目标文件属于哪种类型 1:重定位文件;值为2:可执行文件;值为3:动态链接库文件 |
e_machine | 指定该文件适用的处理器体系结构 |
e_version | 指明目标文件的版本 |
e_entry | 指明程序入口的虚拟地址 |
e_phoff | 指明程序头表开始处在文件中相对于 ELF 文件初始位置的偏移量 |
e_shoff | 指明节头表开始处在文件中的偏移量 |
e_flags | 处理器特定的标志位 |
e_ehsize | 表明ELF文件头的大小,以字节为单位 |
e_phentsize | 表明在程序头表中表项的大小,以字节为单位 |
e_phnum | 表明程序头表中的表项数 |
e_shentsize | 表明在节头表中表项的大小,以字节为单位 |
e_shnum | 表明节头表中的表项数 |
e_shstrndx | 表明节头表中与节名字表相对应的表项的索引,存放着节的名字 |
e_ident | Elf文件识别标志,包含Magic、Class、Data、Version、OS/ABI等信息 |
表4.3-1 ELF头部各成员说明
4.3.2 ELF节头表
节头表描述了文件的节布局,链接器和调试器使用这些信息。包含节的名称、类型、地址、偏移量、大小、链接信息、对齐信息等(图4.3-2)。
图4.3-2
其中各个成员信息请见下表(表4.3-2):
成员 | 含义 |
sh_name | 一个偏移量,指向本节的名字 |
sh_type | 指明本节的类型 |
sh_flags | 指明本节的属性 |
sh_offset | 指明本节的位置 |
sh_size | 指明该节的大小,以字节为单位 |
sh_link | 指向节头表中本节所对应的位置 |
sh_info | 指明该节的附加信息 |
sh_addralign | 指明该节内容对齐字节的数量 |
sh_entsize | 指明该节对应的每一个表项的大小 |
表4.3-2 节头表各成员含义
4.3.3重定位节
重定位是连接符号引用与符号定义的过程。例如,程序调用函数时,关联的调用指令必须在执行时将控制权转移到正确的目标地址。可重定位文件必须包含说明如何修改其节内容的信息。重定位节即包含了这些用于重定位的数据信息。
在.o文件中,重定位节有两个.rela.text和.rela.eh_frame(图4.3-3)。
图4.3-3重定位节
4.3.4 符号表
符号表保存了程序实现或使用的所有全局变量和函数,如果程序引用一个自身代码未定义的符号,则称之为未定义符号。这类引用必须在静态链接期间用其他目标模块或库解决,或在加载时通过动态链接解决。
图4.3-4 hello.o的符号表
4.4 Hello.o的结果解析
使用objdump -d -r hello.o > hello_asm.txt将结果重定向至文本文件hello_asm.txt中,便于查看。
通过比较hello.s与hello_asm.txt,可以发现以下5点不同:
- 汇编指示符消失
在hello.s中时常出现的汇编指示符.cfi_***没有在hello_asm.txt中出现。
图4.4-1汇编指示符消失
- 操作数的进制不同
hello.s中操作数是十进制的,而在hello_asm.txt中,操作数以十六进制表示。
图4.4-2操作数进制不同
- 分支转移不同
hello.s中为汇编语言代码的一些行增加了标签(如 .L3,.L6),分支转移时,在跳转指令后用对应标签名称表示跳转位置。而在hello_asm.txt中,每一行反汇编都有着明确的地址,跳转指令后用相应的地址表示跳转位置。
图4.4-3 分支转移不同
- 字符串常量的引用形式不同
hello.s中用标签对字符串常量进行引用,而在hello_asm.txt中,使用字符串常量的虚拟地址进行引用。
图4.4-4字符串常量引用形式不同
- 函数调用方式不同
hello.s中直接使用函数名对函数进行引用,而在hello_asm.txt中,应使用相对下一行指令的地址的偏移值对函数进行引用。
图4.4-5 函数调用方式不同
4.5 本章小结
本章主要介绍了汇编的概念与作用。汇编语言经过as转化为机器语言,并把这些指令打包成可重定位目标程序的格式,并保存在.o文件中,成为及其可直接识别的程序。然后,使用as hello.s -o hello.o命令,在Ubuntu下对hello.s进行汇编,得到了hello.o文件。最后使用objdump -d -r hello.o > hello_asm.txt命令,得到了hello.o的反汇编文件,并将其与hello.s进行对比,从汇编指示符、操作数进制、分支转移、字符串常量、函数调用五个角度进行了不同之处的分析。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
链接是将各种代码和数据片段收集并组合为一个单一文件的过程,所得到的文件可以被加载到内存之中并执行。链接由链接器(ld)程序执行,链接执行的时机可以是编译时,即源代码被翻译成机器码的时候;以及加载时,即程序被加载器加载到内存并执行的时候;甚至是运行时,即在应用程序执行链接命令的时候。
5.1.2链接的作用
链接的两个主要任务是符号解析和重定位。符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。链接的存在也使得分离式编译成为了可能。
5.2 在Ubuntu下链接的命令
在Ubuntu下,使用ld进行链接的命令如下:
ld -plugin/usr/lib/gcc/x86_64-linux-gnu/9/collect2 \ -plugin /usr/lib/gcc/x86_64-linux-gnu/9/liblto_plugin.so \ -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/9/lto-wrapper \ -plugin-opt=-fresolution=/tmp/cclcKquz.res \ -plugin-opt=-pass-through=-lgcc \ -plugin-opt=-pass-through=-lgcc_s \ -plugin-opt=-pass-through=-lc \ -plugin-opt=-pass-through=-lgcc \ -plugin-opt=-pass-through=-lgcc_s \ --build-id \ --eh-frame-hdr \ -m elf_x86_64 \ --hash-style=gnu \ --as-needed \ -dynamic-linker /lib64/ld-linux-x86-64.so.2 \ -pie \ -z now \ -z relro \ -o hello \ /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o \ /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/crti.o \ /usr/lib/gcc/x86_64-linux-gnu/9/crtbeginS.o \ -L/usr/lib/gcc/x86_64-linux-gnu/9 \ -L/usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu \ -L/usr/lib/gcc/x86_64-linux-gnu/9/../../../../lib \ -L/lib/x86_64-linux-gnu \ -L/lib/../lib \ -L/usr/lib/x86_64-linux-gnu \ -L/usr/lib/../lib \ -L/usr/lib/gcc/x86_64-linux-gnu/9/../../.. \ hello.o \ -lgcc \ --push-state \ --as-needed \ -lgcc_s \ --pop-state \ -lc \ -lgcc \ --push-state \ --as-needed \ -lgcc_s \ --pop-state \ /usr/lib/gcc/x86_64-linux-gnu/9/crtendS.o \ /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/crtn.o -o hello
链接过程如图:
图5.2-1 链接过程
5.3 可执行目标文件hello的格式
使用readelf -a hello > hello_exe_elf.txt将结果重定向至文本文hello_exe_elf.txt中,便于查看。
下图为hello的ELF头(左)与hello.o的ELF头(右):
图5.3-1 hello与hello.o的ELF头对比
通过比较可知,hello的ELF头部中节头部分被大大扩充。下图给出hello的节头部分(图5.3-2)。
图5.3-2 helllo的节头表
5.4 hello的虚拟地址空间
使用edb --run hello命令在edb中加载hello。
图5.4-1 在edb中运行hello
根据5.3中的节头表的内容,我们可以在edb的Data Dump中跳转至对应地址,从而查看某段的原始数据。例如,下图在edb中对 .dynstr段进行了查看:
图5.4-2 在edb中查看.dynstr段
5.5 链接的重定位过程分析
使用objdump -d -r hello > hello_exe_asm.txt将结果重定向至文本文件hello_exe_asm.txt中,便于查看。
5.5.1 不同点
通过比较两个文件可以得出以下几点不同:
- 文件大小不同
hello_asm.txt中仅有main函数的相关内容,而hello_exe_asm.txt多出了许多内容,文件大小差异显著。
- 指令地址不同
hello_asm.txt中的指令地址是从0开始的,而hello_exe_asm.txt中,每行指令都被分配了相应的虚拟地址。
- 调用共享库中函数指令不同
hello_exe_asm.txt中插入了许多共享库中的函数指令。
- 字符串常量地址不同
在hello_asm.txt中,尚未分配虚拟地址,对字符串常量的引用都用0进行代替。而在hello_exe_asm.txt中,由于虚拟地址已经分配,对字符串常量的引用直接使用字符串常量虚拟地址。
- 控制转移不同
在hello_asm.txt中,尚未分配虚拟地址,对指令地址的引用都使用相对偏移进行代替。而在hello_exe_asm.txt中,由于虚拟地址已经分配,对指令地址的引用直接使用字符串常量虚拟地址。表现在控制转移中就是跳转指令之后的参数变为了虚拟地址。(机器码中为相对寻址)
- 函数调用不同
在hello_asm.txt中,尚未分配虚拟地址,对函数的引用都使用0进行代替。而在hello_exe_asm.txt中,由于虚拟地址已经分配,使用函数对应的虚拟地址对函数进行引用。(机器码中为相对寻址)
5.5.2 链接过程
1. 符号解析
链接器解析符号引用的方法就是将每个引用与它对应的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对于局部符号及静态局部变量解析比较简单:只需要保证每个模块中的每个局部符号只有一个定义。对于全局符号的解析:当编译器遇到一个不是在当前模块定义的符号时,会假设该符号时在其他某个模块中定义的,生成一个链接器符号表条目,并交给链接器处理;如果链接器在任何输入的模块中都找不到该定义就报错并且终止。
- 重定位
重定位将每个符号引用和符号定义关联起来,并且为每个符号分配运行时地址。重定位包括:重定位节和符号定义:链接器将所有相同类型的数据节合并为同一类型的聚合节,并且将运行时的内存地址赋值给新的聚合节及每个模块定义的符号;重定位节中的符号引用:链接器修改代码节和数据节中对每个符号的引用,使得其指向正确的运行地址。
5.5.3 重定位过程分析
在hello.s文件中有两种重定位,分别是:R_X86_64_PC32、R_X86_64_PLT32
先看R_X86_64_PLT32重定位过程:
R_X86_64_PLT32 puts-0x4
25: bf 01 00 00 00 mov $0x1,%edi
2a: e8 00 00 00 00 callq 2f <main+0x2f>
120e: bf 01 00 00 00 mov $0x1,%edi
1213: e8 c8 fe ff ff callq 10e0 <exit@plt>
由上述两代码对比可知,重定位既是将虚拟地址赋给函数,把之前由0占位的地方补全。
再看R_X86_64_PC32重定位过程:
5f: R_X86_64_PC32 .rodata+0x2c
63: b8 00 00 00 00 mov $0x0,%eax
68: e8 00 00 00 00 callq 6d <main+0x6d>
124c: b8 00 00 00 00 mov $0x0,%eax
1251: e8 5a fe ff ff callq 10b0 <printf@plt>
5.6 hello的执行流程
Temporary breakpoint 1 at 0x11e9: file hello.c, line 11.
Starting program: /home/nepenthe123/big/hello
Temporary breakpoint 1, main (argc=21845, argv=0x0) at hello.c:11
11 int main(int argc,char *argv[]){
14 if(argc!=5){
15 printf("用法: Hello 2022113507 朱江 15123860311 1!\n");
__GI__IO_puts (
str=0x555555556008 "用法: Hello 2022113507 朱江 15123860311 1!")
at ioputs.c:33
33 ioputs.c: No such file or directory.
35 in ioputs.c
36 in ioputs.c
__lll_cas_lock (futex=0x7ffff7fb37e0 <_IO_stdfile_1_lock>)
at ../sysdeps/unix/sysv/linux/x86/lowlevellock.h:47
47 ../sysdeps/unix/sysv/linux/x86/lowlevellock.h: No such file or directory.
__GI__IO_puts (
str=0x555555556008 "用法: Hello 2022113507 朱江 15123860311 1!")
at ioputs.c:36
36 ioputs.c: No such file or directory.
39 in ioputs.c
40 in ioputs.c
IO_validate_vtable (vtable=0x7ffff7fae4a0 <_IO_file_jumps>) at libioP.h:944
944 libioP.h: No such file or directory.
__GI__IO_puts (
str=0x555555556008 "用法: Hello 2022113507 朱江 15123860311 1!")
at libioP.h:948
948 in libioP.h
_IO_new_file_xsputn (f=0x7ffff7fb26a0 <_IO_2_1_stdout_>, data=0x555555556008,
n=48) at fileops.c:1198
1198 fileops.c: No such file or directory.
1204 in fileops.c
1197 in fileops.c
_IO_new_file_xsputn (n=48, data=0x555555556008, f=<optimized out>)
at fileops.c:1211
1211 in fileops.c
1228 in fileops.c
1244 in fileops.c
IO_validate_vtable (vtable=0x7ffff7fae4a0 <_IO_file_jumps>) at libioP.h:941
941 libioP.h: No such file or directory.
944 in libioP.h
_IO_new_file_xsputn (n=48, data=<optimized out>, f=<optimized out>)
at libioP.h:948
948 in libioP.h
_IO_new_file_overflow (f=0x7ffff7fb26a0 <_IO_2_1_stdout_>, ch=-1)
at fileops.c:732
732 fileops.c: No such file or directory.
733 in fileops.c
740 in fileops.c
743 in fileops.c
745 in fileops.c
__GI__IO_doallocbuf (fp=fp@entry=0x7ffff7fb26a0 <_IO_2_1_stdout_>)
at genops.c:343
343 genops.c: No such file or directory.
344 in genops.c
346 in genops.c
347 in genops.c
IO_validate_vtable (vtable=0x7ffff7fae4a0 <_IO_file_jumps>) at libioP.h:944
944 libioP.h: No such file or directory.
__GI__IO_doallocbuf (fp=fp@entry=0x7ffff7fb26a0 <_IO_2_1_stdout_>)
at libioP.h:948
948 in libioP.h
__GI__IO_file_doallocate (fp=0x7ffff7fb26a0 <_IO_2_1_stdout_>)
at filedoalloc.c:78
78 filedoalloc.c: No such file or directory.
84 in filedoalloc.c
IO_validate_vtable (vtable=0x7ffff7fae4a0 <_IO_file_jumps>) at libioP.h:944
944 libioP.h: No such file or directory.
__GI__IO_file_doallocate (fp=0x7ffff7fb26a0 <_IO_2_1_stdout_>) at libioP.h:948
948 in libioP.h
__GI__IO_file_stat (fp=0x7ffff7fb26a0 <_IO_2_1_stdout_>, st=0x7fffffffde80)
at fileops.c:1147
1147 fileops.c: No such file or directory.
1148 in fileops.c
__GI___fxstat (vers=1, fd=1, buf=0x7fffffffde80)
at ../sysdeps/unix/sysv/linux/wordsize-64/fxstat.c:33
33 ../sysdeps/unix/sysv/linux/wordsize-64/fxstat.c: No such file or directory.
34 in ../sysdeps/unix/sysv/linux/wordsize-64/fxstat.c
35 in ../sysdeps/unix/sysv/linux/wordsize-64/fxstat.c
__GI__IO_file_doallocate (fp=0x7ffff7fb26a0 <_IO_2_1_stdout_>)
at filedoalloc.c:86
86 filedoalloc.c: No such file or directory.
91 in filedoalloc.c
__gnu_dev_major (__dev=34816) at ../include/sys/sysmacros.h:47
47 ../include/sys/sysmacros.h: No such file or directory.
91 filedoalloc.c: No such file or directory.
__GI__IO_file_doallocate (fp=0x7ffff7fb26a0 <_IO_2_1_stdout_>)
at filedoalloc.c:94
94 in filedoalloc.c
97 in filedoalloc.c
101 in filedoalloc.c
用法: Hello 2022113507 朱江 15123860311 1!
[Inferior 1 (process 2866) exited with code 01]
5.7 Hello的动态链接分析
通过观察.got.plt节的变化,我们可以得到动态链接的过程。通过readelf找到.got.plt节在地址为0x404000的地方开始,大小为0x48。因此,结束地址为0x40400047,这两个地址之间部分便是.got.plt的内容。
图5.7-1 .got.plt节信息
在edb的Data Dump中找到该地址的内容,观察发现,在dl_init前后.got.plt节发生了变化。这些变化的内容分别对应.got[1]和.got[2]的位置。其中,.got[1]包括动态链接器在解析函数地址时使用的信息,而.got[2]则是动态链接器ld-linux.so模块中的入口点。
图5.7-2 变化的.got.plt节内容
当程序需要调用一个动态链接库内定义的函数时(例如printf) ,call指令并没有让控制流直接跳转到对应的函数中去,由于延迟绑定的机制,还不知道printf的确切位置。取而代之的是,控制流会跳转到该函数对应的plt表中,然后通过plt表将当前将要调用的函数的序号压入栈中。接下来,调用动态链接器。动态链接器会根据栈中的信息执行重定位,将真实的printf的运行时地址写入got表,取代了got原先用来跳转到plt的地址,变为了真正的函数地址。
5.8 本章小结
本章主要介绍了链接的概念及作用,其主要指是将各种代码和数据片段收集并组合为一个单一文件的过程。然后,在Ubuntu下将hello.o文件经由链接器生成了可执行目标文件hello。然后,通过readelf列出了其各节的基本信息,包括起始位置、大小等信息。然后,用edb查看了hello的虚拟地址空间,同时查看了各节的起始位置与大小。然后,通过objdump对hello进行反汇编,得到了其反汇编程序hello_exe_asm.txt,并与hello.o的反汇编程序hello _asm.txt进行了多方面的比较。然后,分析了hello的重定位过程与执行过程。最后,通过edb,分析了hello程序的动态链接项目在dl_init前后的内容变化。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程是计算机中运行程序的一个实例,是操作系统进行资源分配和调度的基本单位。一个进程包含了程序的可执行代码、程序计数器、堆栈、寄存器集合、以及程序运行所需的所有资源。进程可以被看作是一个活动的实体,它不仅仅是程序本身,还包括程序执行时的所有上下文环境。
6.1.2 进程的作用
进程的作用是作为操作系统进行资源管理和任务调度的基本单位,它确保多个程序能够在同一系统中并发执行。通过进程,操作系统可以有效地分配和管理CPU、内存、文件和其他资源,提供一个独立的执行环境,使得每个程序可以运行而不干扰其他程序。此外,进程的隔离性和保护机制确保了系统的稳定性和安全性,防止程序崩溃或恶意代码影响整个系统。
6.2 简述壳Shell-bash的作用与处理流程
6.1.1 Shell的作用
Shell是一个交互型应用级程序,可代表用户运行其他程序。Shell最重要的功能是命令解释,从这种意义上说,Shell是一个命令解释器。Linux系统上的所有可执行文件都可以作为Shell命令来执行。
6.1.2 Shell的处理流程
当用户提交了一个命令后,Shell首先判断它是否为内置命令,如果是就通过Shell内部的解释器将其解释为系统功能调用并转交给内核执行;若是外部命令或应用程序就试图在硬盘中查找该命令并将其调入内存,再将其解释为系统功能调用并转交给内核执行。
执行外部命令时,Shell创建会通过fork创建一个子进程,并通过execve加载并运行该外部命令(可执行目标文件),当该进程执行结束时在信号处理子程序中用waitpid命令对其进行回收,从内核中将其删除。
当执行的进程为前台进程时,Shell会阻塞命令的输入直到前台进程终止运行。
6.3 Hello的fork进程创建过程
当我们在终端中输入命令 ./hello时,Shell会先判断发现这个参数并不是内置的命令,从而把这条命令当作一个可执行程序的名字尝试执行。
接下来,Shell会执行fork函数,创建一个子进程。我们的hello将会在这个进程中执行。fork函数的作用是创建一个与当前进程平行运行的子进程。内核会将父进程的上下文,包括代码,数据段,堆,共享库以及用户栈,甚至于父进程打开的文件的描述符,都创建一份副本。然后利用这个副本执行子进程。从这个角度上来说,子进程与父进程直到执行完fork的瞬间都是完全相同的。
6.4 Hello的execve过程
在父进程执行fork函数后,父进程将继续运行Shell的程序,而子进程将通过execve加载用户输入的程序,即我们的hello。由于hello是前台运行的,所以Shell会阻塞命令输入,等待hello运行结束。execve函数加载并运行可执行目标文件。只有当出现错误时,execve才会返回到调用程序,否则execve调用一次而从不返回。在execve加载了hello之后,它会调用内核提供的启动代码。内核会将原上下文替换为hello的上下文,然后将控制传递给新程序的程序入口。
6.5 Hello的进程执行
当hello进程创建之时,操作系统会为hello进程分配时间片,让hello进程得以运行。若一个操作系统中运行着多个进程,处理器的一个物理控制流就被分成了多个逻辑控制流,分别交替执行这几个进程。逻辑流的执行是交错的,它们轮流使用处理器,会存在并发执行的现象。其中,一个进程执行它的控制流的一部分的每一时间段叫做时间片。
hello进程在内存中执行的过程中,并不是一直占用着CPU的资源。因为当内核代表用户执行系统调用时,可能会发生上下文切换,如执行hello中的sleep函数时,或者当操作系统认为hello进程了运行足够久的时候。在这时候,程序将由用户态转换至核心态,内核中的调度器执行上下文切换,将当前的上下文信息保存到内核中,恢复某个先前被抢占的进程的上下文,然后再由核心态转换至用户态,将控制传递给这个先前被抢占的进程。
学号姓名秒数手机号
6.6 hello的异常与信号处理
6.6.1正常运行
在终端执行./hello 2022113507 zhujiang 13123860311 1命令,不干扰程序执行,即可完成一次hello的正常运行。
根据上述输入的命令行参数,hello正常运行时,每隔1秒将在屏幕上打印“Hello 2022113507 zhujiang 13123860311”字样,一共会打印10次。打印结束后,调用getchar()函数阻塞程序执行,等待用户输入。在用户输入回车之后,hello程序终止,Shell回收hello进程,由于不在有前台作业,Shell将等待用户输入下一条命令。
图6.6.1 正常运行的hello
6.6.2随意输入(不包括Ctrl-Z,Ctrl-C)
在hello程序执行时,在键盘进行随意的输入(不包括Ctrl-Z,Ctrl-C),按下的字符串会直接显示,但不会干扰程序的运行。
图6.6.2 随意乱按时的hello
6.6.3 Ctrl-C
在hello程序执行时,输入Ctrl-C,会中断hello的执行。输入Ctrl-C会发送 SIGINT 信号给Shell,再由Shell将信号转发给前台进程组中的所有进程,终止前台进程组。用ps命令进行查看,找不到hello进程。
图6.6.3 输入Ctrl+C时的hello
6.6.4 Ctrl-Z
在hello程序执行时,输入Ctrl-Z,会将hello进程挂起。输入Ctrl-Z会发送 SIGTSTP 信号给Shell,再由Shell将信号转发给前台进程组中的所有进程,挂起前台进程组。用ps命令进行查看,会找到被挂起的hello进程。
图6.6.4-1 输入Ctrl+Z时的hello
在将hello进程挂起后,使用jobs命令可以看到被挂起的hello进程的jid及状态标识。
图6.6.4-2 jobs查看hello进程
在将hello进程挂起后,使用pstree命令可以查看hello进程的继承关系。在这里,hello进程的继承路径为systemd→systemd→gnome-terminal-→bash→hello
图6.6.4-2 pstree查看进程树
在将hello进程挂起后,使用fg + hello进程对应的jid即可将挂起的hello进程重新回到前台执行,打印剩余内容,并进行正常的程序退出。
图6.6.4-3 fg命令恢复hello进程的执行
在将hello进程挂起后,使用kill命令可对hello进程发送信号。通过ps查看hello的PID为10683,使用kill -9 10683向hello进程发送SIGKILL信号,将其杀死。之后,再使用ps命令就看不到hello进程了。
图6.6.4-4 kill命令向hello进程发送SIGKILL信号
6.7本章小结
本章介绍了进程的概念和作用,同时简述了Shell的作用及执行流程。然后,解析了hello的fork过程与execve过程,通过调用fork()函数与execve()来实现。同时,结合了进程上下文信息、进程时间片、用户态与核心态转换等内容,介绍了hello的进程执行流程。最后,通过分析了在hello执行过程中不停乱按,Ctrl-Z,Ctrl-C,在Ctrl-Z后运行ps、jobs、pstree、fg、kill等命令所造成的现象,说明了hello中异常与信号的处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是用户编程时使用的与段有关的偏移地址,分为段基址和段偏移量两部分,这是程序员可以见到的地址。
例如,在hello_exe_asm.txt中出现的mov $0x402008, %edi中的地址$0x402008即为逻辑地址,需要加上相应的DS数据段基址才能得到对应的线性地址。
7.1.2 线性地址
指虚拟地址到物理地址变换的中间层,是处理器可寻址的内存空间(称为线性地址空间)中的地址。hello_exe_asm.txt中出现的地址是逻辑地址,加上相应段基址就成了一个线性地址。
在Linux中,所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从0开始,长度为4G,这样 线性地址 = 逻辑地址 + 0,也就是说,逻辑地址在数值上等同于线性地址了。
7.1.3 虚拟地址
虚拟地址是指由程序产生的由段选择符和段内偏移地址组成的地址。经过CPU页部件转换成具体的物理地址,进而通过地址总线访问内存。在Linux中,虚拟地址在数值上等同于线性地址。
7.1.4 物理地址
主存被组织成一个由M个连续的字节大小的单元组成的数组,其中每个字节都被赋予了一个唯一的物理地址。进程在运行时指令的执行和数据的访问最后都要通过将虚拟地址转换为物理地址来对主存进行存取。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址分为段选择符 / 段基址和段偏移量两部分。在保护模式下,段选择符并不直接指向段基址段选择符,而是指向段描述符表中定义段的段描述符。
段选择符的3个字段分别是:请求特权级RPL(Requested Privilege Level),表指示标志TI(Table Index),与索引值(Index)。
图7.2-1 段选择符的结构
根据段选择符,首先根据TI判断应该选择全局描述符表还是局部描述符表,从GDT与LDT所对应的寄存器GTDR和LDTR获取GDT与LDT的首地址,将段选择符的索引字段的值乘8,加上GDT或LDT的首地址,就能得到当前段描述符的地址。
得到段描述符的地址后,可以通过段描述符中BASE字段获得段的基地址。将其与段偏移量相加,即可得到线性地址。
图7.2-2 线性地址的求解流程
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(虚拟地址)由虚拟页号VPN和虚拟页偏移VPO组成。
由线性地址到物理地址的变换通过以下步骤进行:首先从页表基址寄存器PTBR中,得到hello进程的页表地址。同时根据线性地址前n-p位,即虚拟页号,在页表中找到与之对应的索引项,得到物理页号PPN。最后将物理页号与线性地址中最后p位,即偏移量,将它们相加,就可以得到物理地址。
图7.3-1 虚拟地址到物理地址的转换
当引用内容时,首先内存管理单元从线性地址中抽取出虚拟页号,检查高速缓存/主存,看它是否缓存于高速缓存/主存中。若命中,将缓存的内容返回给处理器。若不命中,即需要的内容不在物理内存中,则产生缺页中断,需要从虚拟内存所给出对应的磁盘的内容重新加载到物理内存中。
图7.3-2 页面命中的地址翻译流程
图7.3-3 页面不命中的地址翻译流程
7.4 TLB与四级页表支持下的VA到PA的变换
为了节约页表的内存存储空间,我们会使用多级页表。虽然多级页表节约了我们的存储空间,但是却存在问题:原本我们对于只需要进行一次地址转换,只需要访问一次内存就能找到对应的物理页号,算出物理地址。现在我们需要多次访问内存,才能找到对应的物理页号。最终,虽然节约了空间,却带来了时间上的额外开销,变成了一个“以时间换空间”的策略,极大地限制了内存访问性能。
为了解决这种问题导致处理器性能下降的问题,现代 CPU 中都包含了一块缓存芯片TLB,全称为地址变换高速缓冲(Translation Lookaside Buffer),简称为“快表”,用于加速对于页表的访问。简单来说,TLB就是页表的Cache,属于MMU的一部分,其中存储了当前最可能被访问到的页表项。
图7.4-1 MMU中访问TLB
当CPU处理虚拟地址时,首先去TLB中根据标志Tag寻找页表数据,假如TLB中正好存放所需的页表,说明TLB命中,直接从TLB中获取该虚拟页号对应的物理页号。如果TLB不命中,需要从L1缓存中根据VA取出相应的PTE,计算出PA,并将该PTE存放在TLB中,可能会覆盖原先的条目。
图7.4-2 加入TLB后,通过虚拟内存访问数据的流程
在四级页表的参与之下,当TLB不命中时,将根据VPN1、VPN2…一层层的计算出下一级页表的索引,最后在L4页表中找到相应的PTE,计算出对应的PA,并将其添加至TLB之中。
图7.4-2 Core i7的四级页表示意图
7.5 三级Cache支持下的物理内存访问
在MMU计算出物理地址PA之后,将其发送至L1缓存,缓存从PA中取出标记、组索引信息进行匹配。如果匹配成功,且有效位为1,则Cache命中,根据块偏移取出数据返回给CPU。如果Cache不命中,继续向下一级缓存或主存查询,按照L1-L2-L3-主存的顺序。查找成功后,将数据返回CPU,并将相应的块根据替换策略缓存在当前的Cache中。
图7.5 读Cache示意图
7.6 hello进程fork时的内存映射
当Shell调用fork函数创建hello进程时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。
为了给hello进程创建虚拟内存,内核创建了当前进程的mm_struct、区域结构和页表的原样副本,将两个进程中的每个页面都标记为只读,并且把两个进程中的每个区域结构都标记为私有的写时复制。
当hello进程中fork返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。这两个进程的任一个后来进行写操作时,写时复制机制就会创建新页面,为每个进程保持了私有地址空间的抽象概念。
图7.6 写时复制示意图
7.7 hello进程execve时的内存映射
当Shell在fork出的hello进程中使用execve,在hello进程中加载并运行包含在可执行目标文件hello中的程序时,需要执行以下步骤:
1、删除已存在的用户区域
删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2、映射私有区域
为hello程序的代码、数据、bss和栈区创建新的区域结构。所有这些新的区域都是私有的、写时复制的。
代码和数据区域被映射为 hello文件中的.text和.data区。
bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。
栈和堆区域也是请求二进制零的,初始长度为零。
3、映射共享区域
如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器
execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
图7.7 execve执行后的内存映射
7.8 缺页故障与缺页中断处理
当DRAM 缓存不命中时,就发生了缺页。
以下是一个缺页处理的例子。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。
缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。
图7.8-1 缺页处理之前
接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。
图7.8-2 缺页处理之后
7.9动态存储分配管理
动态内存分配器维护着一个进程中称为堆(heap)的虚拟内存区域。堆是一个请求二进制零的区域,它紧接在未初始化的数据区域(.bss)后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用,空闲块则可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,显式分配器与隐式分配器。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
显式分配器要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来,分配一个块,并通过调用free函数来释放一个块。C++中的new和delete运算符与C中的malloc和free相当。
与之相反,隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
7.9.2隐式空闲链表分配器原理
在隐式空闲链表的情况中,一个块是由一个字的头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。
有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。
图7.9-1 简单的堆块格式
堆被组织为一个连续的已分配块和空闲块的序列。
图7.9-2 用隐式空闲链表来组织堆
其中,阴影部分是已分配块。没有阴影的部分是空闲块。头部标记为(大小(字节)/ 已分配位)。
称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意,我们需要某种特殊标记的结束块,在这个示例中,就是一个设置了已分配位而大小为零的终止头部。
隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。
7.9.3显式空闲链表分配器原理
相比于隐式空闲链表,一种更好的方法是将空闲块组织为某种形式的显式数据结构。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继指针。在使用双向空闲链表的情况下,有两种维护链表的方式:
图7.9-3 显式空闲链表中的堆块格式
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。
7.10本章小结
本章介绍了hello的存储器地址空间。结合了hello,说明了逻辑地址、线性地址、虚拟地址、物理地址的概念,以及它们的区别与联系,互相转化的方法。叙述了在段式管理之下,逻辑地址到线性地址(虚拟地址)的变换是如何完成的。叙述了在页式管理之下,线性地址到物理地址的变换是如何完成的。分析了TLB与四级页表支持下的VA到PA的变换。以四级页表为例,介绍了多级页表的层次、工作流程以及节省空间的优点。而为了弥补页表速度上的缺点,引入了高速地址变址缓存TLB。介绍了三级Cache支持下的物理内存访问的流程,之后以hello进程为例,分析了fork与execve时的内存映射。介绍了缺页故障与缺页中断的处理,并使用一个简单例子,描述了缺页中断的处理流程。最后,分析了动态存储分配管理。从动态内存管理的基本方法与动态内存管理的策略两个方面对动态内存管理进行介绍。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
在Linux中,所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1 简述Unix I/O接口
通过Unix I/O接口,所有的输入和输出都能以统一且一致的方式来执行:
1、打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2、每个进程开始时都打开的三个文件
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)
3、改变当前的文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。通过执行seek操作,能够显式地设置文件的当前位置为k。
4、读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时,执行读操作会触发一个EOF条件。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5、关闭文件
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
8.2.2 Unix I/O函数
int open(char *filename, int flags, mode_t mode);
应用程序通过调用open函数来打开一个已存在的文件或者创建一个新文件。
int close(int fd);
应用程序通过调用 close 函数关闭一个打开的文件。
ssize_t read(int fd, void *buf, size_t n);
应用程序通过调用read函数来执行文件的输入。
ssize_t write(int fd, const void *buf, size_t n);
应用程序通过调用write函数来执行文件的输出。
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的形参列表中,可以看到,const char *fmt之后的参数都用了“...”来代替。这个是可变形参的一种写法,当传递参数的个数不确定时,就可以用这种方式来表示。
在printf的函数体中,有句:
- va_list arg = (va_list)((char *)(&fmt) + 4);
其中,va_list定义为:
typedef char *va_list;
(char*)(&fmt) + 4) 表示的是“…”中的第一个参数的地址。这是因为,在C语言中,参数压栈的方向是从右往左的。第一个参数fmt将在栈顶的位置,而栈顶是往地址减小的方向增加的。在32位中,第一个参数const char *fmt的大小为4字节,将fmt的地址加上4后,指针向栈底方向移动,指向“…”中的第一个参数。
之后的下一句:
- i = vsprintf(buf, fmt, arg);
中,调用了vsprintf函数,其简单实现为:
- int vsprintf(char *buf, const char *fmt, va_list args)
- {
- char *p;
- char tmp[256];
- va_list p_next_arg = args;
- for (p = buf; *fmt; fmt++)
- {
- if (*fmt != '%')
- {
- *p++ = *fmt;
- continue;
- }
- fmt++;
- switch (*fmt)
- {
- case 'x':
- itoa(tmp, *((int *)p_next_arg));
- strcpy(p, tmp);
- p_next_arg += 4;
- p += strlen(tmp);
- break;
- case 's':
- break;
- default:
- break;
- }
- }
- return (p - buf);
- }
其执行流程为,扫描格式串fmt,如果没有遇到%(格式占位符),则将字符原封不动的输出至buf中。如果遇到了%,根据其后面接着的字母来判断需要进行格式化输出的类型,从而解读出p_next_arg的真实数据类型,再调用对应的具体函数进行格式化字符串的生成。
例如,格式占位符是%d,则将p_next_arg(char*)解读为int*类型(强转类型转换),解引用得到实际的参数int,再调用itoa等函数将int格式化为字符串,输出至buf中。输出完这个参数之后,让p_next_arg加上这个参数的大小,使之指向下一个参数。
在获得格式化字符串buf后,printf调用write进行输出:
- write(buf, i);
其中的i是buf中格式化字符串的长度,由vsprintf返回。
我们看一下write的实现:
- write:
- mov eax, __NR_write
- mov ebx, [esp + 4]
- mov ecx, [esp + 8]
- int INT_VECTOR_SYS_CALL
在write中,给寄存器传递了参数,之后int INT_VECTOR_SYS_CALL,通过系统来调用sys_call这个函数。
最后,我们看一下sys_call的实现:
- sys_call:
- call save
- push dword [p_proc_ready]
- sti
- push ecx
- push ebx
- call [sys_call_table + eax * 4]
- add esp, 4 * 3
- mov [esi + EAXREG - P_STACKBASE], eax
- cli
- ret
这里的call [sys_call_table + eax*4](调用的是sys_call_table[eax])中, sys_call_table是一个函数指针数组,每一个成员都指向一个函数,用以处理相应的系统调用。在这个实例中,此时的eax为4(即__NR_write的系统调用号),从而对内核中的write进行调用。
接下来,系统已经确定了所要显示在屏幕上的符号。根据每个符号所对应的ASCII码,系统会从字模库中提取出每个符号的VRAM信息。
显卡使用的内存分为两部分,一部分是显卡自带的显存称为VRAM内存,另外一部分是系统主存称为GTT内存。在嵌入式系统或者集成显卡上,显卡通常是不自带显存的,而是完全使用系统内存。通常显卡上的显存访存速度数倍于系统内存,因而许多数据如果是放在显卡自带显存上,其速度将明显高于使用系统内存的情况。
显示芯片按照刷新频率逐行读取VRAM,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
进入getchar函数之后,进程会进入阻塞状态,等待外界的输入。系统开始检测键盘的输入。此时如果按下一个键,就会产生一个异步中断,这个中断会使系统回到当前的getchar进程,然后根据按下的按键,转化成对应的ASCII码,保存到系统的键盘缓冲区。
接下来,getchar调用了read函数。read函数会产生一个陷阱,通过系统调用,读取键盘缓冲区中存储的刚刚按下的按键信息,然后返回指定大小的字符串。
最后,getchar会将这个字符串保存在一个静态的缓冲区中,并返回其第一个字符。在下次调用getchar时,将直接从静态的缓冲区中取出字符并返回,而不是通过read再次进行读取,直到静态缓冲区为空,才再调用read进行读取。
8.5本章小结
本章介绍了linux系统下的IO的基本知识,讨论了Linux系统中Unix I/O的形式以及实现的模式函数。最后,对printf和getchar两个函数的实现进行了深入的探究。
(第8章1分)
结论
我们通过键盘向计算机输入一行行代码,这些代码组合成了一个C源文件——我们的主角hello.c。 接下来,hello.c经过预处理器cpp、编译器cc1、汇编器as、链接器ld的处理,最终生成了一个可以加载到内存执行的可执行目标文件hello。
然后,我们在Shell中执行命令“./hello 2022113573 zhujiang 13123860311 1”。Shell通过fork函数创建一个新的进程,在子进程里通过execve函数将hello程序加载到内存。虚拟内存机制通过mmap为hello进程规划了一片虚拟空间,调度器为hello进程分配执行的时间片,使其能够与其他进程一起合理利用CPU和内存的资源。hello完成了从程序到进程的P2P(Program to Process)转换。
之后,CPU逐条从hello的.text段取指令,寄存器的值随着程序的执行不断变化,异常处理程序监视着键盘的输入。hello中的syscall系统调用会触发陷阱,让内核接手进程,执行write函数,将一串字符传递给屏幕IO的映射文件。映射文件对传入数据进行分析,读取VRAM,然后在屏幕上显示出“Hello 2022113573 zhujiang 13123860311”这一行字符串。
最后,hello程序运行结束,Shell通过waitpid函数通知内核回收hello进程,hello进程消失。至此,hello完成了其程序执行的一生,从无到有,再回归无,是真正的O2O(Zero to Zero)。
hello的一生结束了,而我们的计算机之路才刚刚开始。了解完hello的一生之后,我们学到了许多知识,也产生了更多疑惑。虽然hello的一生结束了,但执行它的CPU还在不停运转,内存中的比特海洋仍在波涛汹涌。让我们用一生去解开这些疑惑吧!
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello.c | C源文件 |
hello.i | C预处理文件,由hello.c预处理得到 |
hello.s | 汇编语言文件,由hello.i编译得到 |
hello.o | 可重定位目标文件,由hello.s汇编得到 |
hello_elf.txt | 由readelf生成的关于hello.o的ELF信息 |
hello_asm.txt | 由objdump生成的关于hello.o的反汇编信息 |
hello | 可执行文件,由hello.o链接得到 |
hello_exe_elf.txt | 由readelf生成的关于hello的ELF信息 |
hello_exe_asm.txt | 由objdump生成的关于hello的反汇编信息 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
- 《深入理解计算机系统》第3版
- 程序详细编译过程(预处理、编译、汇编、链接)
https://zhuanlan.zhihu.com/p/476697014
- bss、data和rodata区别与联系
bss、data和rodata区别与联系_link中的srodata-CSDN博客
- 64位ELF文件头格式介绍
- 程序的链接
https://www.cnblogs.com/shuqin/p/12012906.html
- 虚拟地址、逻辑地址、线性地址、物理地址的区别
虚拟地址、逻辑地址、线性地址、物理地址的区别_线性地址和虚拟地址的区别-CSDN博客
- 一文读懂内存管理中TLB:地址转换后援缓冲器
https://zhuanlan.zhihu.com/p/480808324
- 256-Linux虚拟内存映射和fork的写时拷贝
256-Linux虚拟内存映射和fork的写时拷贝_当fork时,内核并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。-CSDN博客
(参考文献0分,缺失 -1分)