计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 未来技术
学 号 2022111443
班 级 wl023
学 生 黄维霖
指 导 教 师
计算机科学与技术学院
2024年5月
HelloWorld,作为无数编程梦想的起点,这篇文章将深入探索其背后的编程旅程。从最初的C语言源代码开始,Hello踏上了它的编程之路。首先,它经历了编程生涯的第一次转变——预处理阶段,这是它成长的第一步。随后,它逐渐蜕变,从一个原始的.i文件进化为更加机器友好的.s汇编文件。随着Hello的不断成长,它经历了汇编、链接等一系列复杂的过程,最终蜕变为一个可执行文件,这标志着它即将开启在计算机世界中的新征程。
在接下来的阶段中,Hello将与操作系统建立联系,就像一位才华横溢的选手遇到了他的伯乐。操作系统为它分配进程,提供虚拟内存和独立的地址空间,让它拥有了自己的舞台。同时,操作系统还为其分配时间片和逻辑控制流,让Hello能够在系统上自由驰骋。然而,就像所有的生命都有终点一样,随着进程的结束,Hello也将结束它短暂而辉煌的计算机生涯。
本文将从hello.c源代码开始,一路跟随Hello的足迹,详细描绘出它编程旅程的每一步变化,带您领略编程世界的奇妙与魅力。
关键词:预处理,编译,汇编,链接,进程管理,虚拟地址,存储管理,IO操作
目 录
第1章 概述
1.1 Hello简介
P2P: 全称为From Program to Progress,从程序到进程。这个看似简单的过程需要经过预处理、编译、汇编、链接等一系列的复杂动作才可以生成一个可执行目标文件。在运行时,我们打开Shell,等待我们输入指令。通过输入./hello,使Shell创建新的进程用来执行hello。 操作系统会使用fork产生子进程,然后通过execve将其加载,不断进行访存、内存申请等操作。最后,在程序结束返回后,由父进程或祖先进程进行回收,程序结束。
020:全称为From 0 to 0,从无到终。Hello的出生是由操作系统进行存储管理、地址翻译、内存访问,通过按需页面调度来开始这段生命。父进程或祖先进程的回收也标志着它生命的结束。
1.2 环境与工具
1.2.1硬件环境
处理器:AMD Ryzen 7 5800H with Radeon Graphics 3.20 GHz
系统类型:X64 CPU; 2GHz; 16G RAM; 256G HD Disk
1.2.2软件环境
VMware Workstation pro
Ubuntu22.04
1.2.3开发与调试工具
gedit+gcc;edb
————————————————
1.3 中间结果
文件名称 | 作用 |
hello.c | 储存hello程序源代码 |
hello.i | 源代码经过预处理产生的文件 |
hello.s | hello程序对应的汇编语言文件 |
hello.o | 可重定位目标文件 |
hello_o.s | hello.o的反汇编语言文件 |
Hello_o.elf | hello.o的ELF文件格式 |
hello | 二进制可执行文件 |
hello.elf | 可执行文件的ELF文件格式 |
hello.s | 可执行文件的汇编语言文件 |
1.4 本章小结
本章详细回顾了Hello程序从诞生到执行的完整生命周期,特别是它如何从源代码状态逐步转变为一个可执行的程序。通过这个过程,我们可以发现Hello程序的成长轨迹与整个计算机系统的学习和理解过程紧密相连。此外,本章还简要介绍了进行Hello程序编译和运行所需的软、硬件环境以及所依赖的编译处理工具,提供了清晰的整体文章布局脉络。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理是指在程序代码被翻译为目标代码的过程中,生成二进制文件之前的过程。这个过程一般包括包含头文件等工作。
预处理的作用:
预处理为编译做准备工作,主要进行代码的文本替换工作,它会根据预处理指令来修改源代码。在源代码中,以#开头的代码段即为预处理工作的对象。有以下几个功能:
头文件包含:例如 #include <stdio.h>,即为包含标准输入输出头文件。
条件编译指令:相当于一个选择装置,可以让程序员通过定义不同的宏(宏定义)来决定对哪些代码进行处理,而那些代码要被忽略。以下为一些条件编译指令简要介绍:
指令名称 | 功能 |
#if | 如果判断条件为真,则编译下面的代码 |
#ifdef | 判断是否宏定义,若是,则编译下面的代码 |
#ifndef | 判断是否宏定义,妥否,则编译下面的代码 |
#elif | else语句,若前置条件判断为假此条为真,则编译下面的代码 |
#endif | 结束一段if…else的条件编译指令判断 |
特殊符号处理:预编译程序可以识别一些特殊的符号。 例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。
2.2在Ubuntu下预处理的命令
gcc hello.c -E -o hello.i
图1.预处理
2.3 Hello的预处理结果解析
.i文件可以作为一个文本文档被打开,我选择使用记事本查看这个生成文件
图2 .i文件内容
可以看到.i文件相比于.c源文件多了超级多的内容,这是因为.i文件对源文件中定义的宏进行了展开,将头文件中的内容包含到这个文件中。例如上图中getsubopt、getloadavg等函数的定义,以及一些结构体类型的声明。
2.4 本章小结
本章详细介绍了hello.c程序预处理方面的知识和应用。预处理是编译运行过程中的第一步,扮演着至关重要的角色。它涉及对源代码文件的初始处理,包括宏定义的展开、头文件的包含以及条件编译等操作。通过对hello.c文件进行预处理,我们可以得到一个预处理后的文件(通常具有.i扩展名),这个文件直接展示了预处理阶段对源代码所做的修改和扩展。
查看.i文件为我们提供了一个直观的方式来理解预处理前后源代码的变化。在这个文件中,我们可以清晰地看到宏是如何被替换的,头文件内容是如何被直接插入的,以及条件编译指令是如何影响代码结构的。这种可见性有助于我们更深入地理解编译过程,并在需要时调试与预处理相关的问题。
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译是一个过程,通过编译程序(编译器)将用高级程序设计语言编写的源程序转换成机器能够理解的目标程序。这个过程包括将源代码转换成汇编语言代码,并最终生成可执行文件或库文件。编译不仅是一个动作,更是一种技术,它使得开发者能够使用高级语言编写程序,而无需直接操作机器语言。
编译的功能:
编译器的核心功能是将高级程序设计语言编写的源程序转换为机器语言代码,以便计算机能够执行。除此之外,编译器还具备以下功能:
语法检查:编译器会检查源代码是否符合编程语言的语法规则,如果发现有语法错误,会给出相应的错误提示。
语义检查:编译器还会对源代码进行语义分析,确保代码中的变量、函数、类型等的使用是符合语言规定的,并且逻辑上是正确的。
优化:编译器在生成目标代码的过程中,会进行一系列的优化操作,以提高代码的执行效率或减小代码的体积。
生成目标文件:编译器将源代码转换成汇编语言代码后,会进一步将其转换成机器语言代码,并生成可执行文件或库文件。这些文件是计算机可以直接执行的。综上所述,编译是一个复杂的过程,编译器不仅实现了从高级程序设计语言到机器语言的转换,还提供了多种辅助功能,以确保源代码的正确性和可执行性。
3.2 在Ubuntu下编译的命令
gcc hello.i -S -o hello.s
图3.hello.s文件
3.3 Hello的编译结果解析
图2 hello.s文件内容
3.3.1工作伪指令
我们阅读hello.s文件,发现第一部分的汇编代码有一部分是以.作为开头的代码段。这些代码段是指导汇编器和连接器工作的伪指令。这段代码对我们来说没有什么意义,通常可以忽略这些代码,但对汇编器和连接器缺是十分重要的。这些指导伪代码的含义如下表
图3 伪代码段
伪指令 | 含义 |
.file | 声明源文件(此处为hello.c) |
.text | 声明代码节 |
.section | 文件代码段 |
.rodata | Read-only只读文件 |
.align | 数据指令地址对齐方式(此处为8对齐) |
.string | 声明字符串(此处声明了LC0和LC1) |
.globl | 声明全局变量 |
.type | 声明变量类型(此处声明为函数类型) |
3.3.2数据格式和寄存器结构
在解析下面的汇编代码之前,我们需要先了解数据存储的格式以及寄存器的存储结构,Intel数据类型令16bytes为字,21bytes为双字,各种数据类型的大小一级寄存器的结构如下所示:
变量类型 | Intel 数据类型 | 汇编代码后缀 | 大小(字节) |
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l | 4 |
long | 四字 | q | 8 |
char * | 四字 | q | 8 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
图4 寄存器结构
3.3.3数据
立即数:
立即数在汇编代码中的呈现形式最为简单易辨认。是直接显式使用的一类数据,在汇编代码中通常以$作为标识。例如下图中的例子,表示的含义是比较(cmp compare)寄存器中的值和5,根据结果设置条件码(OF,SF,ZF等)
寄存器变量:
在汇编代码中,指令后面出现过许许多多的形如-20(%rbp)形式的代码声明,其实这些就是寄存器存储的变量,通过特定的寻址方式进行引用。例如下图中的例子,表示的就是将寄存器%edi中存储的值,加载到以现在栈指针%rbp指向的位置基础上,减去20所对应的地址中去。
字符串:
.LC0和.LC1作为两个字符串变量被声明。而在.LC0中出现的\347\224等是因为中文字符没有对应的ASCII码值无法直接显式显示,所以这样的字符方式显示。而且这两个字符串都在.rodata下面,所以是只读数据。随后有两句leaq指令,这个指令为加载有效地址,相当于转移操作。
3.3.4数据传送指令
数据传送指令无疑是整个程序运行过程中使用的最频繁的指令。汇编代码中数据移动使用MOV类,这些指令把数据从源位置复制到目的位置,不做任何变化。MOV类中最常用的有四条指令:movb、movw、movl、movq,这些指令执行相同的操作,区别在于他们所移动的数据大小不同,如下表所示。指令的最后一个限制字符必须和寄存器所对应的数据大小保持一致。
指令 | 效果 | 描述 |
MOV S,D | D←S | 传送 |
movb | 传送字节 | |
movw | 传送字 | |
movl | 传送双字 | |
movq | 传送四字 |
例如下图中第一句汇编代码,意为将寄存器%rax中存储的值传送到寄存器%rcx中。
除此之外,还有一些指令会先将数据进行零扩展或者符号扩展之后再进行传送。典型实例就是MOVZ(零扩展)和MOVS(符号扩展)
3.3.5压入和弹出栈数据
压入数据使用指令pushq,弹出数据使用指令popq,他理解起来其实可以看做一个由两句指令组成的结合体。我们拿popq指令作为例子来说明。
popq %rax 等价于 addq $8 %rsp + movq %rbp, (%rsp)
指令 | 效果 | 描述 |
pushq S | R[%rsp]←R[%rsp] - 8;M[R[%rsp]]←S | 将四字压入栈 |
popq D | D←M[R[%rsp]];R[%rsp]←R[%rsp] + 8 | 将四字弹出栈 |
3.3.6算术操作
算术运算也是十分常用的一些指令类,同样的,每种算术运算指令的末尾也有b、w、l、q(例如addb)来限制数据的大小。
指令 | 效果 | 描述 |
INC D DEC D NEG D NOT D | D←D + 1 D←D - 1 D← -D D← ~D | 加1 减1 取负 取补 |
ADD S, D SUB S, D IMUL S, D | D←D + S D←D - S D←D * S | 加 减 乘 |
例如:
3.3.7逻辑操作
逻辑操作常见的有两类,一类是加载有效地址,一类是位移操作。加载有效地址指令类似于MOV类指令,它的作用是将有效地址写入到目的操作数,相当于C语言中的取址操作,可以为以后的内存引用产生指针。位移操作顾名思义就是将二进制数进行整体左移或者右移。
3.3.8条件控制
汇编语言中,一些指令会改变条件码,例如cmpl指令和setl指令。这种指令一般不会单独使用,会根据比较结果进行后续操作。例如下图,将寄存器中存储的值和立即数5进行比较,设置条件码,然后进行跳转或者其他操作。
3.3.9跳转语句
跳转指令会根据条件码当前的值来进行相应的跳转。比较常见的是直接跳转,在hello.s中也有体现,如下图所示。cmpl指令判断寄存器中的值和立即数5的大小关系,设置条件码,再进行je。je的含义是jump if equal,也就是说,如果此时的条件码所表示含义为相等,则会跳转到相应的.L2指令行。因此,跳转指令用来实现条件分支。
3.3.10函数调用
call指令用来进行函数的调用。如下图所示的示例,call调用了getchar函数。它会先将函数的返回地址压入运行时栈中,之后跳转到相应的函数代码段进行执行。执行结束通过ret指令返回
3.4 本章小结
本章对汇编指令进行了基础介绍,并通过分析Hello World程序的机器级实现,展示了汇编指令与C语言代码语句之间的对应关系。通过简单的思考,我们可以发现汇编指令是如何与C语言中的操作相对应的。反过来,如果我们拥有一个程序的汇编代码,也可以大致推断出相应的C语言程序的结构和功能。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编程序是一种翻译程序,其主要功能是将由汇编语言编写的源代码转换成与之等效的机器语言代码(目标程序)。这个过程涉及读取汇编语言书写的源程序,然后将其转换成计算机能够直接执行的机器语言表示的目标程序。在这个过程中,汇编器会将输入的汇编指令文件转换成可重定位目标文件,通常保存为.o文件或.obj文件。这些二进制文件包含了程序的指令编码,是编译链接过程中的一个重要组成部分。
汇编的作用:汇编器在软件开发中起到了桥梁的作用,它完成了从人类可读、可写的汇编语言到计算机可执行的机器语言的转换。通过这个过程,开发者可以使用相对高级的汇编语言编写程序,而无需直接面对底层的机器语言,从而提高了开发效率和代码的可读性。同时,生成的可重定位目标文件可以与其他目标文件一起进行链接,生成最终的可执行文件。
4.2 在Ubuntu下汇编的命令
gcc hello.s -c -o hello.o
图1 hello.o文件产生
4.3 可重定位目标elf格式
4.3.1ELF头
.o文件为目标文件,相当于Windows中的.obj后缀文件,因此直接使用Vim或者其他文本编辑器查看会出现一大堆乱码。那么我们选择查看可重定位目标文件的elf形式,使用命令readelf -h hello.o查看ELF头,结果如下
图2 ELF头信息
ELF头以一个16字节的序列(Magic,魔数)开始,这个序列描述了生成文件的系统的字的大小和字节顺序。ELF头剩下部分的信息包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型和机器类型等。例如上图中,Data表示了系统采用小端法,文件类型Type为REL(可重定位文件),节头数量Number of section headers为13个等信息。
4.3.2Section头
使用命令readelf -S hello.o查看节头,结果如下
图3 Section头信息
夹在ELF头和节头部表之间的都为节,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。各部分含义如下:
名称 | 包含内容含义 |
.text | 已编译程序的机器代码 |
.rodata | 只读数据 |
.data | 已初始化的全局和静态C变量 |
.bss | 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量 |
.symtab | 一个符号表,存放一些在程序中定义和引用的函数和全局变量的信息 |
.rel.text | 一个.tex节中位置的列表 |
.rel.data | 被模块引用或定义的所有全局变量的重定位信息 |
.debug | 一个调试符号表 |
.line | 原始C源程序中的行号和.text节中机器指令之间的映射 |
.strtab | 一个字符串表(包括.symtab和.debug节中的符号表) |
4.3.3符号表
使用命令readelf -s hello.o查看符号表,结果如下
图4 符号表信息
这其中,Num为某个符号的编号,Name是符号的名称。Size表示他是一个位于.text节中偏移量为0处的146字节函数。Bind表示这个符号是本地的还是全局的,由上图可知main函数名称这个符号变量是全局的。
4.3.4可重定位段信息
使用readelf -r hello.o查看可重定位段信息,结果如下
图5 可重定位段信息
offset是需要被修改的引用的节偏移,Sym.标识被修改引用应该指向的符号。Type告知连接器如何修改新的引用,Addend是一个有符号常数,一些类型的重定位要使用它对被修改的引用的值做偏移调整。
4.4 Hello.o的结果解析
使用objdump -d -r hello.o命令对hello.o可重定位文件进行反汇编,得到的反汇编结果如下图
图6 hello.o的反汇编结果
当我们查看hello.o的反汇编文件时,我们会发现它与hello.s汇编文件的汇编代码非常相似,但其中也混杂了一些我们不太熟悉的元素,即机器代码。这些机器代码实际上是由二进制指令构成的集合,它们每条都对应着一条机器指令,这是计算机硬件真正能够理解和执行的语言。
汇编语言中的指令和数据都以一种类似于映射的方式与机器语言中的指令和操作数相对应,使得机器能够按照这些指令来执行特定的操作。然而,机器代码与汇编代码在某些方面有所不同。
在分支跳转方面,汇编语言中的跳转语句(如je .L2)使用了易于人类理解的标识符来指定跳转的目标位置。然而,在机器代码中,这些跳转指令会直接使用目标地址的二进制表示,从而确保机器能够准确地跳转到正确的位置。
在函数调用方面,汇编语言文件(如.s文件)中的函数调用直接使用了函数名作为标识符。然而,在.o反汇编文件中,由于此时函数调用所指向的目标地址尚未确定(特别是当函数位于共享库中时),call指令的目标地址会被设置为一个占位符(通常是下一条指令的地址),然后在重定位表中(如.rela.text节)为这个call指令添加重定位条目。这样,在链接阶段,链接器会根据这些重定位条目来确定函数的实际地址,并将其填充到call指令的目标地址字段中。
通过这种方式,汇编器和链接器共同协作,将人类可读的汇编代码转换为机器可执行的机器代码,并确保程序能够正确地执行其预期的功能。
4.5 本章小结
本章深入探讨了Hello程序从hello.s(汇编语言源代码)到hello.o(目标文件)的转换过程。在这一节中,我们详细分析了hello.o的ELF(可执行与可链接格式)头、Section头以及符号表,揭示了Hello程序更底层的结构和信息。此外,我们还对hello.o的反汇编文件进行了详细解析,对比了它与hello.s文件的不同之处,展示了目标文件是如何让机器更加直接地理解和执行代码的。
通过对ELF头和Section头的分析,我们了解了目标文件的基本结构和组成,包括各个段的类型、大小、位置等信息。这些信息对于链接器和加载器来说至关重要,它们需要依赖这些信息来正确地将目标文件组合成可执行文件,并在内存中加载和执行。
同时,我们还深入探讨了符号表在目标文件中的作用。符号表记录了程序中定义和引用的函数、变量等符号的信息,包括它们的名称、类型、地址等。这些信息在链接阶段被用来解析符号引用,确保函数调用和数据访问的正确性。
在解析hello.o的反汇编文件时,我们发现它与hello.s文件在语法和结构上非常相似,但也有一些重要的区别。特别是在分支跳转和函数调用方面,目标文件中的指令更加直接地指向了内存中的地址,而不是使用符号名称。这是因为在目标文件中,所有的符号引用都已经被解析为具体的内存地址,从而提高了机器执行代码的效率。
总之,本章通过对Hello程序的hello.o目标文件的深入分析,让我们更加了解了程序在编译过程中的底层细节,以及目标文件是如何让机器更加直接地理解和执行代码的。
第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
图1 hello文件产生
5.3 可执行目标文件hello的格式
5.3.1 ELF头
使用命令readelf -h hello查看hello的ELF头
图2 ELF头
可以看到文件的Type发生了变化,从REL变成了EXEC(Executable file可执行文件),节头部数量也发生了变化,变为了27个。
5.3.2 Section头
使用命令readelf -S hello查看节头部表信息
图3 节头部表信息
节头部表对hello中所有信息进行了声明,包括了大小(Size)、偏移量(Offset)、起始地址(Address)以及数据对齐方式(Align)等信息。根据始地址和大小,我们就可以计算节头部表中的每个节所在的区域。
5.3.3符号表
使用命令readelf -s hello查看符号表信息
图4 符号表信息
可以发现经过链接之后符号表的符号数量陡增,说明经过连接之后引入了许多其他库函数的符号,一并加入到了符号表中。
5.3.4可重定位段信息
使用命令readelf -r hello查看可重定位段信息
图5 可重定位段信息
5.4 hello的虚拟地址空间
在edb中打开可执行文件hello,可以看到hello虚拟地址空间的起始地址为0x401000,结束地址为0x401ff0。
图6 hello起止虚拟地址
根据5.3.2节里面的Section头部表,我们可以找到对应的节的其实空间对应位置,例如.init初始化节,起始位置地址为0x401000在edb中有其对应位置
图7 .init和其对应
5.5 链接的重定位过程分析
使用命令objdump -d -r hello查看hello可执行文件的反汇编条目
图8 hello反汇编
当你比较hello.s(汇编代码文件)和hello(可执行文件的反汇编输出)时,尽管两者在语法上可能非常相似,因为它们都是基于机器指令的,但你可以观察到以下主要区别:
- 地址空间的重定位:在hello.s中,函数调用通常使用相对地址或符号名称(如call function_name)。然而,在hello的反汇编输出中,这些调用已经被链接器重定位到了程序在虚拟地址空间中的实际地址。因此,你会看到明确的内存地址,如call 0x401234。
- 新加入的节(Sections):在链接过程中,链接器会加入许多新的节到可执行文件中,这些节在汇编代码阶段是不存在的。例如,.init节包含了程序初始化时需要执行的代码;.dynamic节包含了动态链接器(如ld.so)在运行时需要使用的信息,如符号表和重定位表;.text节包含了程序的执行代码;.data和.bss节分别包含了已初始化和未初始化的全局变量。
3.额外的符号和元数据:除了实际的机器指令外,可执行文件还包含了大量的符号和元数据,这些信息对于调试、动态链接以及程序的其他运行时行为至关重要。这些信息在汇编代码文件中通常是不可见的。
4.其他优化和填充:链接器还可能进行各种优化,如函数内联、死代码消除等,这些都会改变最终可执行文件的反汇编输出。此外,为了对齐或其他目的,链接器可能会在节之间或指令之间插入额外的填充字节。
5.启动文件和库代码:在链接过程中,链接器还会将启动文件(如crt0.o或crt1.o)和其他必要的库代码(如C运行时库)添加到可执行文件中。这些代码在程序开始执行时运行,并负责设置运行时环境、调用main函数等任务。在hello.s中,这些代码是不存在的。
重定位的过程分为两大步:
1.重定位节和符号定义:在这一步中,链接器将所有相同类型的节合并成为同一类型的新的聚合节。例如,来自所有输入模块的.data节全部被合并成一个节,这个节成为输出的可执行目标文件的.data节。
2.重定位节中的符号引用:在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位条目,及5.3节中分析的那些数据。
5.6 hello的执行流程
程序名称 | 程序地址 |
_start | 0x4010f0 |
_libc_start_main | 0x7ffff7de2f90 |
__GI___cxa_atexit | 0x7ffff7e05de0 |
__new_exitfn | 0x7ffff7e05b80 |
__libc_csu_init | 0x4011c0 |
_init | 0x401000 |
_sigsetjump | 0x7ffff7e01bb0 |
main | 0x401125 |
do_lookup_x | 0x7ffff7fda4c9 |
dl_runtime_resolve_xsavec | 0x7ffff7fe7bc0 |
_dl_fixup | 0x7ffff7fe00c0 |
_dl_lookup_symbol_x | 0x7ffff7fdb0d0 |
check_match | 0x7ffff7fda318 |
strcmp | 0x7ffff7fee600 |
5.7 Hello的动态链接分析
动态共享库(也称为动态链接库或DSO,Dynamic Shared Object)是现代软件开发中一个重要的创新,旨在解决静态库的一些局限性。动态共享库是一个包含代码和数据的目标模块,在程序运行时才被加载到内存中,并与程序进行链接,这个过程称为动态链接。
通过将程序拆分成各个相对独立的模块,并以动态共享库的形式提供,程序可以在运行时才将这些模块链接在一起形成一个完整的程序。这种方式与静态链接不同,静态链接是将所有程序模块都链接成一个单独的可执行文件。动态链接允许多个程序共享相同的库代码,从而减少了内存使用和磁盘存储需求。
在动态链接的实现中,有两个关键的数据结构:程序链接表(PLT, Procedure Linkage Table)和全局偏移表(GOT, Global Offset Table)。
PLT:PLT是一个数组,其中每个条目通常包含一小段跳转代码。PLT的第一个条目(PLT[0])是一个特殊条目,它负责跳转到动态链接器中,以处理首次调用某个函数时的地址解析。每个被可执行程序调用的库函数都有一个与之关联的PLT条目。当程序尝试调用一个库函数时,它首先跳转到相应的PLT条目,然后这个条目会跳转到动态链接器中进行地址解析(如果需要),并最终跳转到库函数的实际地址。
GOT:GOT是另一个数组,其中每个条目通常存储一个地址。GOT与PLT协同工作。GOT的前几个条目(如GOT[0]和GOT[1])包含动态链接器在解析函数地址时所使用的特殊信息。GOT的其他条目(从某个特定索引开始)则与库函数相关联,存储这些函数在内存中的实际地址。当动态链接器解析了一个函数的地址后,它会将该地址写入相应的GOT条目中,以便后续的调用可以直接跳转到这个地址,而无需再次通过动态链接器。
通过动态链接和使用PLT与GOT这样的数据结构,程序可以在运行时动态地加载和链接库代码,从而实现了代码的共享和复用,提高了程序的灵活性和效率。
hello在动态连接器加载前后的重定位是不一样的,在加载之后才进行重定位
图9 重定位后.init
5.8 本章小结
本章深入探讨了程序的连接(链接)过程。首先,我们解释了重定位操作的原理,即如何调整程序中的地址引用,以确保当代码和数据被加载到内存中时,它们能够正确地指向彼此。接着,我们描述了如何将相同类型的数据(如全局变量、代码段等)组织并放置在程序的相应节中,以提高内存访问效率和程序的可读性。最后,我们详细说明了链接器的工作原理,它负责将编译后的目标文件(包括代码、数据和引用)组合成一个可执行文件或共享库,同时解决它们之间的符号依赖关系。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:
进程是计算机中执行特定任务的一个实体,它代表了一个程序在给定数据集上的动态执行过程。进程是操作系统进行资源分配和调度的基本单位,也是操作系统结构中的核心组成部分。
进程的作用:
当一个进程被运行时,它提供了程序独立运行的环境,使得该程序在运行过程中仿佛是整个系统中唯一运行的程序。进程的主要作用是为程序提供两个关键的抽象:首先是独立的逻辑控制流,这意味着每个进程都拥有自己独立的执行路径和逻辑流程;其次是私有的地址空间,每个进程都拥有自己独立的内存区域,使得不同进程之间的数据相互隔离,保证了系统的稳定性和安全性。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一种命令解释器,其本质是一个应用程序,它充当了用户与Linux内核之间的桥梁,使用户能够高效、安全、经济地利用Linux内核的功能。位于操作系统的最外层,Shell负责直接与用户进行交互,解释用户的输入并转化为系统可以理解的指令,同时处理来自操作系统的各种输出结果,并呈现给用户。这类似于我们在Windows下常用的cmd命令行工具,也包括Bash等Linux中的Shell。
Shell在处理用户命令时遵循以下流程:
当用户输入一个命令后,Shell会首先检查这个命令是否为Shell的内置命令。如果是内置命令,Shell将直接利用内部机制将其转化为系统功能的调用,并转交给内核执行。如果该命令是外部命令或应用程序,Shell则会在文件系统中查找该命令对应的可执行文件,并将其加载到内存中。随后,Shell会将该命令解释为系统功能的调用,并同样转交给内核执行。
6.3 Hello的fork进程创建过程
在Linux系统中,用户可以通过 ./ 指令来执行一个可执行目标文件。在程序运行时,Shell就会创建一个新的进程,并且新创建的进程更新上下文,在这个新建进程的上下文中便可以运行这个可执行目标文件。fork()函数拥有一个int型的返回值。子进程中fork返回0,在父进程中fork返回子进程的Pid。新创建的进程与父进程几乎相同但有细微的差别。子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本(代码、数据段、堆、共享库以及用户栈),并且子进程拥有与父进程不同的Pid。
6.4 Hello的execve过程
当hello进程被创建后,它会调用execve系统调用来加载并执行一个新的程序。execve函数在被调用时会在当前进程的上下文中替换掉原有的程序映像,并开始执行新的程序。这个过程是单向的,一旦execve成功执行,原进程映像将被新程序完全替代,并且execve函数不会返回给原程序。以下是详细的执行过程:
1.删除已存在的用户空间:execve首先会释放当前进程已经加载的用户空间内存区域,包括代码、数据、堆和栈等。
2.映射私有区域:为新程序(如hello)的代码段、数据段、未初始化的数据段(.bss)和栈创建新的内存区域。这些新区域都是私有的,即每个进程都有其独立的副本,且这些区域采用写时复制(Copy-On-Write, COW)技术,以便在需要修改时创建独立的副本。
3.映射共享库:如果新程序(如hello)依赖于共享库(如libc.so),execve还会映射这些共享库到进程的地址空间中。共享库允许多个进程共享相同的代码和数据,从而节省内存空间。
4.设置程序计数器:execve在完成所有必要的内存映射和设置后,会修改当前进程的上下文信息,特别是程序计数器(Program Counter, PC)。程序计数器被设置为新程序代码段的入口点,即程序开始执行的第一条指令的地址。
5.执行新程序:一旦设置好程序计数器,控制流就会转移到新程序的入口点,开始执行新程序。从这一刻起,原进程映像已不存在,取而代之的是新程序的映像。
6.不返回调用者:如果execve调用成功,那么原进程映像将被完全替换,并且execve函数不会返回到原程序。只有当execve调用失败时(例如,找不到指定的程序文件),它才会返回错误码给调用者。
6.5 Hello的进程执行
6.5.1 逻辑控制流
逻辑控制流为运行中的程序提供了独占CPU的假象。实际上,CPU会在多个进程之间交替执行,使得每个进程都执行其逻辑控制流的一部分,随后可能被挂起以便让其他进程得以执行。![](https://img-blog.csdnimg.cn/direct/3009fb65fdc046d6b55b265f7c0abfa8.png)
图1 逻辑控制流
6.5.2 并发流与时间分片
当两个或多个进程的执行时间发生重叠时,我们称这些进程是并发的。每个进程获得执行权的时间段被称为时间分片或时间片。例如,在图中,进程A和进程B在时间上是重叠的,因此它们是并发的。同样,进程A和进程C也是并发的,但进程B和进程C(如果它们没有重叠的时间段)则不是并发的。
6.5.3 内核模式与用户模式
用户模式:在此模式下,进程只能执行非特权指令,不能直接发起I/O操作或访问地址空间中受保护的内核区域。任何试图执行这些操作的尝试都会导致保护故障或异常。
内核模式:当进程运行在内核模式下时,它可以执行包括特权指令在内的任何指令,并且可以访问整个地址空间,包括内核区域。这种模式下运行的进程拥有高级别的权限,类似于Linux操作系统中使用了sudo前缀的命令。
6.5.4 上下文切换
进程的上下文包含了其执行时依赖的所有状态信息,如通用寄存器、浮点寄存器和其他系统资源。当操作系统决定从一个进程切换到另一个进程时,它会保存当前进程的上下文(称为上下文保存),然后加载新进程的上下文(称为上下文恢复)。这个过程称为上下文切换,它允许操作系统实现进程间的多任务并发执行。
6.5.5 Hello程序的执行
当从Shell执行hello程序时,该程序首先以用户模式运行。在执行过程中,操作系统会根据调度策略进行上下文切换,使得hello程序与其他进程交替执行。如果hello程序在运行过程中接收到一个信号(如中断),操作系统会将其切换到内核模式,执行相应的信号处理程序。信号处理程序执行完毕后,hello程序会返回到用户模式并继续其执行,直到完成或再次被调度器挂起。
6.6 hello的异常与信号处理
6.6.1异常
异常可以分为四类:中断、陷阱、故障和终止,各类异常产生原因和一些行为总结成下表:
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
中断:
在进程运行的过程中,我们施加一些I/O输入,比如说敲键盘,就会触发中断。系统会陷入内核,调用中断处理程序,然后返回。
陷阱:
陷阱和系统调用是一回事,用户模式无法进行的内核程序,便通过引发一个陷阱,进入内核模式下再执行相应的系统调用。
故障:
常见的故障就是缺页故障。在hello中如果我们使用的虚拟地址相对应的虚拟页面不在内存中,就会发生此类缺页故障。故障是可能会被修复的,例如缺页故障触发的故障处理程序,会按需调动页面,再返回到原指令位置重新执行。但对于无法恢复的故障则直接报错退出。
终止:
如果遇到一个硬件错误,那对于幼小的hello来说是相当致命的,导致结果就是触发致命错误,终止hello的运行。
6.2.2信号
信号可以被理解为一条小消息,他通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件,它提供了一种机制,通知用户进程发生了这些异常。Linux中信号有如下种类:
图1. Linux信号及其种类
当hello在运行时我们在键盘上按下Ctrl-z,这个操作就会向进程发送SIGSTP信号,进入信号处理程序。可以从后台工作信息看到hello被暂时挂起,进程号为1。如果想继续这个进程可以使用命令fg 1。
SIGINT:输入Ctrl-z进程就会被终止。
通过jobs和ps查看当前所有进程和后台运行的进程,可以看到当hello挂起后抓为后台运行。
6.7本章小结
本章深入探讨了Hello进程的运行机制,并阐述了与进程相关的一系列核心知识和概念。通过学习,我们得以更清晰地认识到进程为程序运行所提供的两大关键假象:逻辑控制流和私有地址空间。这些假象使得程序能够并发执行,并且每个进程都拥有自己独立的运行环境,互不干扰。
第7章 hello的存储管理
7.1 hello的存储器地址空间
在CPU中,当然不会仅有单个的Hello进程在运行,实际上,多个进程会共享CPU和主存资源。为了更高效地管理内存,操作系统引入了一种关键的抽象概念,即虚拟内存。虚拟内存带来了多重优点:首先,它使得主存的使用更加高效,因为通过虚拟内存技术,物理内存可以被多个进程共享和复用,从而提高了内存的利用率;其次,虚拟内存简化了内存管理的复杂性,操作系统可以为每个进程提供一个统一的内存视图,而无需关心物理内存的实际布局;最后,它为每个进程提供了独立的地址空间,确保了进程之间的内存隔离,增强了系统的安全性和稳定性。
逻辑地址:逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元、存储单元、网络主机的地址。逻辑地址往往不同于物理地址,通过地址翻译器或映射函数可以把逻辑地址转化为物理地址。
线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:CPU启动保护模式后,程序运行在虚拟地址空间中。保护模式下,hello 运行在虚拟地址空间中,它访问存储器所用的逻辑地址。
物理地址:它是在地址总线上,以电子形式存在的,使得数据总线可以访问 主存的某个特定存储单元的内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel平台下,逻辑地址的转换依赖于分段机制,其格式为段选择符(也称为段标识符)和段内偏移量。段选择符是一个16位长的字段,其中前13位是一个索引号,用于在全局描述符表(GDT)或局部描述符表(LDT)中定位相应的段描述符。而后3位则包含了保护位和其他硬件相关的细节。
将逻辑地址转化为线性地址的步骤如下:
1.使用段选择符中的索引号在GDT或LDT中查找对应的段描述符。
2.检查段选择符中的权限位和其他标志,以确保当前进程有权访问该段,并且段的范围能够包含所请求的偏移量。
3.从找到的段描述符中提取段基地址(Base Address),这是一个物理内存中的起始地址。
4.将段内偏移量加到段基地址上,形成一个线性地址。这个线性地址是物理内存中的实际地址的虚拟表示,随后可能会通过分页机制进一步转换为物理地址。![](https://img-blog.csdnimg.cn/direct/592153e5c7454c5c997412f452a76ae5.png)
图1 段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
从概念上讲,虚拟内存被组织成一个连续的字节数组,这些字节存储在磁盘上,并且其大小用N来表示。为了更有效地管理这个巨大的字节数组,虚拟内存被分割成固定大小的块,这些块被称为虚拟页(或页帧)。这些虚拟页根据其在物理内存中的存在状态和映射关系,可以被划分为以下三种状态:
未分配:这部分虚拟页在虚拟内存中尚未被分配给任何进程或程序使用。
未缓存:这些虚拟页已经被分配给某个进程或程序,但当前它们的内容还没有被加载到物理内存中。当进程尝试访问这些页时,会发生页面错误,随后操作系统会将这些页的内容从磁盘加载到物理内存中。
已缓存:这些虚拟页不仅被分配给某个进程或程序,而且它们的内容当前正存储在物理内存中。因此,当进程尝试访问这些页时,可以立即从物理内存中获取所需数据,无需等待从磁盘加载。![](https://img-blog.csdnimg.cn/direct/591c460fe3d144a082ad80582f677138.png)
图2 页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
页表是一个由页表条目(PTE)组成的数组,用于将虚拟内存中的虚拟页映射到物理内存中的物理页。每个PTE包含一个有效位和一个n位地址字段。有效位用于指示相应的虚拟页当前是否已被缓存在DRAM(动态随机存取存储器)中。
一个虚拟地址由两部分组成:虚拟页号(VPN)和虚拟页面偏移量(VPO)。VPN用于在页表中查找对应的PTE,以确定虚拟页在物理内存中的位置。一旦找到对应的PTE,并且其有效位被设置为有效(即该虚拟页已被缓存),则可以使用PTE中的物理页号(PPN)与VPO结合,形成物理地址。在这个物理地址中,PPN被用作物理内存的页基地址,而VPO则直接对应于物理地址中的页面偏移量(PPO)。
图3 PTE寻页
7.4.1TLB加速地址翻译
既然要经常访问页表条目,不如直接将页表条目缓存到高速缓存中,这就是TLB的基本思想。TLB译为翻译后备缓冲器,也就是页表的缓存。TLB是一个具有较高相连度的缓存,如下图。根据VPN中的TLB索引找到缓存中相应的组,根据标记(tag)找到相应的缓存行,根据设置的有效位找到对应的位置。
图4 TLB加速地址翻译
7.4.2四级页表支持下缓存
下图为Core i7使用的四级页表地址翻译
图5 Core i7四级页表
同一级页表一样,若缓存页命中,则返回PPN,以VPO作为页便宜的到地址;若未命中,则经过四级页表查询,直到找到最终的PTE,查询,返回PPE。下图为4级页表目录格式:
图6 四级页表目录格式
7.5 三级Cache支持下的物理内存访问
在支持三级缓存(Cache)的物理内存访问过程中,当CPU尝试访问一个虚拟地址时,会首先检查翻译后备缓冲器(TLB)以查看该虚拟页号(VPN)是否已经被缓存。如果TLB命中,即找到了相应的VPN,则可以直接从TLB中获取对应的物理页号(PPN),从而快速访问物理内存中的数据。
如果TLB未命中,即没有找到相应的VPN,CPU将不得不查询多级页表来获取物理地址(PA)。通过解析页表条目(PTE),CPU可以得到完整的物理地址。接下来,CPU会将这个物理地址(PA)进行分解,通常分解为三个部分:缓存标记(CT,Cache Tag)、组索引(CI,Cache Index)和块偏移(CO,Cache Offset)。
完成分解后,CPU会检查物理地址对应的数据块是否在L1缓存中命中。如果L1缓存命中,CPU将直接从L1缓存中取出数据返回给请求者。如果L1缓存未命中,CPU会继续检查L2缓存,然后是L3缓存。在每一次缓存检查中,都会使用物理地址的组索引(CI)来定位缓存中的组,然后使用缓存标记(CT)来检查该组中的条目是否与物理地址匹配。
如果在三级缓存中都没有找到对应的数据块(即缓存未命中),CPU将不得不从主存(DRAM)中加载所需的数据块到L3缓存(如果空间允许),然后再逐级加载到L2和L1缓存中,以供未来可能的访问。整个过程中,从TLB检查到各级缓存的访问,直至主存的访问,形成了一个层次化的内存访问结构,旨在提高数据访问的速度和效率。
过程如下:
图7 TLBcache地址翻译
7.6 hello进程fork时的内存映射
当fork函数被shell或其他进程调用以创建新进程(例如为“hello”的进程)时,内核会为新进程执行一系列初始化步骤。这些步骤包括为新进程创建必要的数据结构,并为其分配一个唯一的进程标识符(PID)。
为了设置新进程的虚拟内存环境,内核会创建当前进程(父进程)的mm_struct(内存管理结构)、区域结构(例如vm_area_struct)和页表的完整副本。这些副本在初始状态下几乎与父进程中的相应结构相同,但内核会采取一些特定的预防措施来确保子进程和父进程的独立性。
具体来说,内核会将两个进程中的每一个页面都标记为只读,并将两个进程中的每个区域结构都设置为私有的写时复制(Copy-On-Write, COW)属性。这种设置意味着,尽管子进程和父进程在开始时共享相同的页面和区域结构,但任何尝试修改这些页面的写操作都会触发写时复制机制。
当写时复制机制被触发时,内核会为修改的页面创建一个新的物理页面副本,并更新子进程(或触发写操作的进程)的页表以指向这个新页面。通过这种方式,内核确保了两个进程各自拥有私有的地址空间,即使它们最初共享了相同的页面和区域结构。
因此,当fork函数在新进程中返回时,新进程(子进程)的虚拟内存布局与调用fork时父进程的虚拟内存布局相同。但是,由于写时复制机制的存在,这两个进程在后续执行中进行的任何写操作都不会相互影响,从而保持了它们各自地址空间的独立性。此外,新进程(子进程)也继承了父进程的文件描述符表,因此它可以访问父进程已经打开的任何文件。
7.7 hello进程execve时的内存映射
execve系统调用负责在当前进程上下文中加载并执行一个新的程序,具体地说是加载并运行位于可执行目标文件(例如hello)中的程序。这个过程会有效地用新程序替换当前进程的映像。以下是加载并运行hello程序的几个关键步骤:
1.清除现有用户区域:
在加载新程序之前,内核首先会清除当前进程虚拟地址空间中的用户部分已存在的区域结构。这包括代码、数据、栈和其他可能存在的区域。
2.映射新程序的私有区域:
为了加载新程序,内核会创建新的区域结构(如 vm_area_struct),用于存放新程序的代码、数据、未初始化的数据(bss)和栈。这些新的区域是私有的,并且具有写时复制(Copy-On-Write, COW)属性。代码和数据区域将被映射到 hello 文件的 .text 和 .data 段。bss 区域将被请求为二进制零,并映射到匿名文件,其大小由 hello 的可执行文件指定。栈和可能的堆区域也会作为请求二进制零的内存区域被创建,初始长度为零。
3.映射共享库区域:
如果 hello 程序依赖于共享对象(如 libc.so),内核也会将这些共享库映射到用户虚拟地址空间的共享区域内。共享库允许多个进程共享相同的代码和数据,从而节省内存空间。
4.设置程序计数器:
最后,execve 系统调用会设置当前进程上下文的程序计数器(Program Counter, PC),使之指向新程序代码区域的入口点。这通常是 hello 文件的 .text 段的开始位置,即程序的入口点。
一旦程序计数器被设置,当前进程将开始执行新加载的程序,而原来的程序映像(包括代码、数据和栈)将被丢弃。这个过程实现了进程映像的完全替换,使得新程序可以在当前进程上下文中运行。
7.8 缺页故障与缺页中断处理
页命中的概念:虚拟内存中的一个字存在于物理内存中,即缓存命中。那不难理解,缺页故障就是虚拟内存中的字不在物理内存中,发生页不命中。例如下图中,对VP3的访问即发生缺页故障![](https://img-blog.csdnimg.cn/direct/653364fe8f164e31963ed3f01df8d74f.png)
图8 缺页故障
7.8.2缺页故障处理
如果发生了缺页故障,则触发缺页故障处理程序,这个程序会选择一个牺牲页,例如上图中的P4,将其在物理内存中删除,加入所需要访问的VP3。随后返回重新执行原指令,则页命中。这种策略称为按需页面调度。
图9 缺页故障处理
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。
图10 堆
在内存管理中,当执行了内存碎片整理和垃圾回收之后,通常会释放出一些不再使用的内存空间,这些空间被称为空闲块。尽管单个空闲块可能相对较小,但随着时间的推移,这些小块空闲内存累积起来,如果不加以有效利用,就会造成内存资源的浪费。
为了有效地管理这些空闲块,操作系统通常采用两种主要方法:隐式空闲链表和显式空闲链表。
隐式空闲链表:这种方法并不显式地构建一个链表来跟踪空闲块。相反,它依赖于某种数据结构(如位图或空闲列表数组)来记录哪些内存块是空闲的。当需要分配内存时,系统会搜索这些数据结构以找到足够大的连续空闲块。隐式空闲链表的优势在于它通常更简单、更高效,特别是对于小型内存系统或嵌入式系统。
显式空闲链表:这种方法通过构建一个链表来显式地跟踪所有的空闲内存块。链表中的每个节点通常包含关于空闲块的大小、起始地址以及指向下一个空闲块的指针的信息。当需要分配内存时,系统会遍历链表以找到一个足够大的空闲块,然后将其从链表中移除并分配给请求者。当内存被释放时,系统会检查是否可以将释放的块与链表中的现有块合并,以形成一个更大的空闲块。显式空闲链表提供了更高的灵活性和更好的内存利用率,但通常需要更多的内存开销和更复杂的算法来维护链表。
通过这两种方法,操作系统可以有效地管理内存中的空闲块,避免浪费,并在需要时高效地分配和回收内存。
7.9.2隐式空闲链表
首次适配: 从头开始搜索空闲链表,选择第一个合适的空闲块: 搜索时间与总块数(包括已分配和空闲块)成线性关系。在靠近链表起始处留下小空闲块的“碎片”。
下一次适配 (Next fit): 和首次适配相似,只是从链表中上一次查询结束的地方开始。比首次适应更快: 避免重复扫描那些无用块。一些研究表明,下一次适配的内存利用率要比首次适配低得多。
最佳适配: 查询链表,选择一个最好的空闲块;适配,剩余最少空闲空间。保证碎片最小——提高内存利用率,通常运行速度会慢于首次适配。
在隐式空闲链表工作时,如果分配块比空闲块小,可以把空闲块分为两部分,一部分用来承装分配块,这样可以减少空闲部分无法使用而造成的浪费。隐式链表采用边界标记的方法进行双向合并。脚部与头部是相同的,均为 4 个字节,用来存储块的大小,以及表明这个块是已分配还是空闲块。同时定位头部和尾部,是为了能够以常数时间来进行块的合并。无论是与下一块还是与上一块合并,都可以通过他们的头部或尾部得知块大小,从而定位整个块,避免了从头遍历链表。但与此同时也显著的增加了额外的内存开销。他会根据每一个内存块的脚部边界标记来选择合并方式,如下图:
图11 隐式空闲链表合并方法
7.9.2显式空闲链表
显式空闲链表策略采用了一种分块的方法,将内存划分为多个大小类(或称为“桶”),并为每个大小类维护一个独立的空闲链表。这些链表中的节点代表可用的空闲内存块,并且每个节点的大小都与其所属的大小类相匹配。分配器维护着一个空闲链表数组,该数组中的每个元素对应一个大小类的空闲链表。当需要分配内存块时,分配器会根据所需块的大小,在对应的空闲链表中搜索可用的空闲块。
7.10本章小结
本章深入探讨了Hello程序与操作系统之间的交互机制,特别强调了计算机系统中一个至关重要的概念——虚拟内存。我们详细阐述了虚拟内存的内容,并解释了Hello程序如何通过地址翻译机制找到其所需数据的最终物理地址。进一步地,本章介绍了如何通过TLB(Translation Lookaside Buffer)来加速地址翻译过程,以及多级缓存在提升内存访问效率方面的作用。此外,我们还讨论了动态内存管理在操作系统中如何帮助有效地分配和回收内存资源。这些概念共同构成了现代计算机系统中内存管理的基础。
结论
Hello程序的一生经历了从诞生到消亡的完整过程。以下是改写后的总结:
Hello的旅程始于一份名为hello.c的C语言源代码文件的编写,这是Hello的起点。
首先,通过gcc -E命令,Hello经历了预处理阶段,从hello.c蜕变为hello.i,这一阶段处理了宏定义、条件编译等预处理指令。
接着,使用gcc -S命令,Hello进一步成长,经过编译阶段,从hello.i转化为汇编代码文件hello.s。
随后,通过gcc -c命令,Hello完成了汇编阶段,从hello.s汇编成机器语言的目标文件hello.o。此时,Hello已经是一个包含二进制代码的对象文件了。
之后,对Hello进行链接,将它与其它需要的库和对象文件合并,形成一个可在操作系统上直接运行的二进制可执行文件。
在Shell环境中,用户通过输入命令./hello 来启动Hello程序。
Shell首先检查该命令是否为内置命令,发现不是后,会将其视为外部程序来执行。
Shell通过调用fork()函数为Hello创建一个新的进程,随后调用execve()函数来加载Hello程序。execve()会替换新进程的内存映像,将其替换为Hello程序的虚拟内存布局,并设置程序计数器,使其指向Hello程序的入口点。
在运行Hello时,内存管理单元(MMU)、TLB(Translation Lookaside Buffer)、多级页表机制以及多级缓存(如三级缓存)协同工作,以完成虚拟地址到物理地址的翻译,并高效地处理内存请求。
当Hello执行到printf函数调用时,操作系统可能会调用malloc()等函数从堆中动态分配内存。
Hello在运行过程中,可以接受外部信号。例如,当用户从键盘输入Ctrl-C时,操作系统会发送一个SIGINT信号,导致Hello程序中断。用户可以使用Shell命令如jobs来查看当前运行的作业,或使用fg %<pid>来恢复被暂停的进程。
当Hello程序执行完毕后,其父进程会负责回收其占用的资源,从而结束Hello的一生。
纵观Hello的一生,虽然短暂,但充满了从源代码到可执行程序的奇妙转变,以及操作系统中复杂而精细的内存管理和进程管理过程。正如Hello的一生,我们也应追求短暂而精彩的人生,不断学习和成长,为社会做出贡献。
附件
文件名称 | 作用 |
hello.c | 储存hello程序源代码 |
hello.i | 源代码经过预处理产生的文件 |
hello.s | hello程序对应的汇编语言文件 |
hello.o | 可重定位目标文件 |
hello | 二进制可执行文件 |
参考文献
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.