计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 数据科学与大数据技术
学 号 2022111750
班 级 2203501
学 生 于博丞
指导教师 吴锐
计算机科学与技术学院
2024年5月
这篇论文追溯并详细分析了一个名为hello.c的源代码文件的完整生命周期,从其创建到最终作为可执行程序在操作系统中运行的整个过程。通过深入研究计算机在这一过程中与操作系统、处理器、内存以及I/O设备等方面的交互,旨在更全面地理解计算机系统底层原理。
在研究方法上,我们选取了实际的hello程序作为案例,在Ubuntu环境下运行相应的命令,跟踪程序从源代码到可执行程序的转化过程,并对每个阶段的结果进行深入分析解读。
本文详细探讨了计算机系统在编译并运行hello程序时的内部交互过程,包括预处理的影响、编译过程的关键步骤、汇编的输出结果、链接的作用以及最终程序执行的过程。通过研究进程管理、存储管理和I/O管理等方面,揭示了在操作系统层面的关键机制。
关键词:生命周期;预处理;编译;汇编;链接;生命周期;预处理;编译;汇编;链接。
目 录
第1章 概述
目录
1.1.2 Hello的020过程
1.1 Hello简介
1.1.1Hello的P2P过程
P2P(Program to Process)是一个精确的描述,指的是将一个程序从源代码转换为可执行进程的过程。在这个过程中,通常经历了预处理、编译、汇编和链接四个主要阶段
预处理:通过预处理器处理hello.c,包括宏展开和头文件包含等操作,生成预编译文件hello.i,其中已经包含了所有预处理的结果。
编译:编译器将hello.i编译成汇编代码hello.o。
汇编:汇编器将汇编代码hello.o转换为机器可执行的目标代码hello.s。
链接:链接器将目标代码文件与系统库以及其他必要的库文件链接在一起,生成最终的可执行文件hello。
1.1.2Hello的020过程
020指可执行文件Hello产生的进程从进入内存到从内存被回收的过程。
这包括了Bash中的fork和execve,进程管理、内存映射、存储管理、TLB和Cache技术的应用,以及IO管理和信号处理等步骤。
当可执行文件Hello被执行时,它会生成一个进程,该进程会经历从进入内存到被回收的完整生命周期。首先,在Bash中,通过fork系统调用创建新进程,然后通过execve系统调用加载Hello的可执行文件,实现程序的执行。这个新的进程被添加到操作系统的进程表中,并分配所需的资源。进程管理方面,操作系统会通过分配时间片的方式,允许Hello在CPU上执行,实现多任务处理。同时,内存映射会使用mmap系统调用为程序提供所需的内存空间,而存储管理与MMU协同工作,将虚拟地址(VA)翻译为物理地址(PA),实现存储管理。加速技术包括利用TLB、多级页表和Cache等技术,以提高内存访问效率。在程序执行过程中,操作系统通过IO管理和信号处理机制实现程序与外部设备的交互,包括键盘、主板、显卡、屏幕等。最终,当程序执行完成或由于某种原因终止时,父进程或养父进程将回收该进程,将其从系统中完全删除,消除所有痕迹。
1.2 环境与工具
1.2.1 硬件环境
Intel x64CPU;16G RAM
1.2.2 软件环境
1.2.3 开发工具
Vmware 17;Ubuntu 20.04 LTS 64位;Visual Studio 2022 64位;CodeBlocks 20.03 64位;vi/vim/gedit+gcc
1.3 中间结果
hello.c | hello 的C源文件 |
hello.i | hello.c经过预处理后的预编译文件 |
hello-p.i | hello.c经过加上-P参数的预处理的预编译文件 |
hello.s | hello.i经过编译得到的汇编语言文本文件 |
hello.o | hello.s经过汇编得到的可重定位目标文件 |
helloelf.txt | 通过readelf导出的hello.o的elf信息文本 |
asm.txt | hello.o反汇编导出得到的文本 |
hello | hello.o经过链接得到可执行文件 |
helloelf2.txt | 通过readelf导出的hello的elf信息文本 |
asm2.txt | hello反汇编导出得到的文本 |
1.4 本章小结
在本章节中介绍了Hello的P2P过程,020过程,以及在实验中要用到了软硬件环境及开发工具。同时,我还展示了在实验过程中会产生的中间结果文件。
之后我将在后续章节中对hello的一生进行更加详细的展示。
第2章 预处理
2.1 预处理的概念与作用
预处理是在编译过程中的一个阶段,它是在源代码被编译之前进行的一些处理。预处理器通常执行一系列的文本替换操作,其主要作用包括宏展开和头文件包含。
1. 宏展开:预处理阶段会处理源代码中的宏定义,并将宏展开为其对应的代码。这样可以提高代码的可读性和灵活性,同时减少代码的重复编写。例如,可以使用`#define`指令定义一个宏,然后在代码中使用该宏,预处理器会在编译之前将宏展开为相应的代码。
2. 头文件包含:在大型项目中,通常会将一些共享的代码放在头文件中,然后在需要使用这些代码的文件中包含头文件。预处理阶段会处理这些包含指令,将头文件的内容插入到包含指令所在的位置。这样可以避免在多个文件中重复编写相同的代码,提高代码的可维护性和重用性。
预处理的主要作用是准备源代码以供编译器进一步处理。通过预处理,可以对代码进行一些常见的操作,例如宏替换和头文件包含,从而简化代码编写,提高代码的可读性和可维护性。
2.2在Ubuntu下预处理的命令
以对hello.c进行预处理命令为例,输入命令:
gcc -E hello.c -o -hello.i
此外,可以添加-P参数,删除无用的信息:
gcc -E -P hello.c -o -hello_p.i
图2-1预处理命令
2.3 Hello的预处理结果解析
文件夹中出现hello.i文件以及hello-p.i文件。
之后我们将hello.i打开
图2-2 hello.i内容
我们可以发现,经过预处理之后所产生的文件仍然是c语言文件。
在文件开头有着hello.c使用各种库的文件信息,通过注释的方式给出。
在文件中间,我们可以看到hello.i对stdio.h等头文件进行了展开,如图2-3。这样虽然我们的程序中没有写系统文件的内容,我们仍然能在预处理后经过预处理器的替换而得到这些函数,在编译后能够为我们所用,而我们不需要在源程序中写这些大量的复杂的系统函数。
图2-3 hello.i展开stdio.h的部分截图
在文件结尾是我们原本程序的主函数中的代码,我们发现在这个过程已经没有了注释如图2-4。
图2-4 hello.i结尾部分截图
之后我们打开hello-p.i文件,如图2-5。我们发现开始的注释内容已经消失,同时我们发现文件的行数大大减少,从hello.i的3000多行缩短到1226行,使代码表现的更加紧凑。
图2-5hello-p.i内容截图
2.4 本章小结
在这一章中,我们深入研究了预处理的概念、作用以及在Ubuntu下的具体操作。预处理是编译过程中的关键步骤,通过它我们可以对源代码进行各种处理,为后续的编译步骤做好准备。
第3章 编译
3.1 编译的概念与作用
编译(Compiling)是计算机科学中的一个重要概念,指的是将高级语言代码(如C、Java等)转换成低级机器语言(如机器代码)的过程。编译器(Compiler)是执行编译过程的软件工具,负责将高级语言代码转换成机器语言,使得计算机可以理解和执行。
编译的主要作用包括:
1. 将高级语言转换成机器语言:计算机只能理解机器语言,而高级语言更易于人类理解和编写。编译器的主要作用是将高级语言代码转换成机器语言,以便计算机能够执行。
2. 优化代码:编译器在将高级语言代码转换成机器语言的过程中,可以进行一些优化操作,使得生成的机器代码更加高效、执行速度更快。
3. 错误检查:编译器还可以进行语法和语义错误的检查,帮助程序员及时发现并修复代码中的错误,提高代码的质量和可靠性。
4. 跨平台支持:一些编译器可以将高级语言代码编译成针对不同平台的机器语言,实现跨平台的支持,使得同一份代码可以在不同的计算机上运行。
3.2 在Ubuntu下编译的命令
图3-1编译指令截图
根据所给ppt的要求,在编译语句上加入-m64 -no-pie -fno-PIC的参数,如图3-1所示。
3.3 Hello的编译结果解析
这里我按照数据、赋值、类型转换、sizeof、算术操作、逻辑/位操作、关系操作、数组/指针/结构操作、控制转移、函数操作这些方面来对hello.s进行解析。
下面我会先给出程序的源代码,如图3-1,要用到的汇编代码将在后面各个部分的解析中给出截图。
图3-1hello.c内容截图
3.3.1数据
3.3.1.1常量
(1)源程序中的字符串常量有"用法: Hello 学号 姓名 手机号 秒数!\n"和"Hello %s %s %s\n"。在hello.s中,可以找到LC0和LC1,如图3-2,分别是这两个字符串常量的标识符,.string后面即为字符串的内容。
图3-2LC0,LC1内容截图
(2)源程序中还有整型常量,比如14行if(argc!=5)中的5,这些常量被直接保存在.text代码段中,如图3-3。
图3-3整型常量内容截图
3.3.1.2变量
int i为局部变量,如图3-4可以看出其被储存在-4(%rbp)的位置。int argc和char *argv[]也是局部变量,如图3-5可以看出这两个局部变量分别在 -20(%rbp) 和 -32(%rbp) 处分配空间。事实上,argc和argv被一开始储存在寄存器edi和rsi中,这两个寄存器在进入main函数前被设置为正确的值,随后它们在主函数被调用后存储在栈中。局部变量所在的函数(主函数)返回时,其所占的栈空间也会被释放。
图3-4局部变量i的地址
图3-5argc和argv的地址
3.3.1.3表达式
源程序中出现的表达式有算数表达式、赋值表达式、关系表达式。
(1)算数表达式
在源程序for循环中有着i++这个语句,其中让变量i在每次循环中自增1。hello.s中的汇编语句如下图3-6所示。
图3-6i++的汇编语句
(2)赋值表达式
在源程序for循环中,将i赋值成为0,在hello.s中通过movl语句进行赋值,如图3-7。
图3-7赋值语句
(3)关系表达式
在源程序for循环中,通过对i与常量10进行比较来决定是否进行跳转,如图3-8展示hello.s中对i与常量进行比较的过程。
图3-8关系表达式
3.3.1.4类型
(1)基本数据类型:
int 对应于寄存器 %edi 和 %eax,这些寄存器用于存储整数值。在x86-64架构中,%edi 通常用于函数参数和返回值,而 %eax 是通用寄存器之一。整数类型在寄存器中占据4个字节。
(2)指针类型:
char * 对应寄存器 %rdi、%rsi、%rdx 等,这些寄存器用于存储指针和地址。在x86-64架构中,这些寄存器用于传递指针参数和存储指针变量。指针类型的大小通常是8个字节,因为64位系统的地址空间是64位的。
char *argv[] 对应汇编中的 -32(%rbp),在这里存储了指向命令行参数的数组的指针。这个指针占据8个字节,因为它是64位指针。
(3)函数类型:
int main(int argc, char *argv[]) 对应汇编语言中的 main 标签。
可以看出,在汇编语言中,类型不再是C语言中的抽象概念,而是直接映射到底层硬件架构中的寄存器和内存位置。
3.3.2赋值
编译器使用数据传送指令)MOV类指令将值赋给地址,如图3-9所示。
图3-9mov类指令
3.3.2.1变量的初值赋值
主函数的形参被赋初值。
movl %edi, -20(%rbp):将 %edi 中的值(argc,函数参数)移动到堆栈中的 -20(%rbp),这是一个局部变量的位置。
movq %rsi, -32(%rbp):将 %rsi 中的值(argv,函数参数)移动到堆栈中的 -32(%rbp),也是一个局部变量的位置。
3.3.2.2不赋初值操作
见3.3.1.3表达式部分(2)
3.3.2.3逗号操作符的使用
在汇编语言中,逗号操作符并不是像C语言中那样表示序列点,而是将多个操作放在一行中执行。例如,在 addq $16, %rax 和 movq (%rax), %rdx 中,逗号分隔两个操作,表示它们是一个序列。
3.3.3算数操作
hello.c中出现的算数操作只有自增操作i++,使用add指令实现。
3.3.3.1 自增操作
见3.3.1.3表达式部分(1)
3.3.3.2 加法操作
虽然源程序中没有直接体现,但编译结果hello.s中出现了加法操作,同样使用add指令,如图3-10。
图3-10add指令
addq $16, %rax:加法操作,将16添加到 %rax 寄存器的值,用于对指针数组(argv[])的偏移以访问。
addq $8, %rax:加法操作,将8添加到 %rax 寄存器的值。
3.3.3.3 减法操作
hello.s中同样出现了减法操作,使用SUB指令。
subq $32, %rsp:这是一个减法操作,将%rsp寄存器的值减去32,用于在栈上分配32字节的空间。
3.3.4关系操作
3.3.4.1 关系操作!=
源程序中if(argc!=5)出现了关系操作!=,在hello.s中体现为
图3-11关系操作!=
使用CMP对立即数5和-20(%rbp)地址储存的值进行比较。
其中,CMP指令不会改变寄存器的值,而是根据比较结果设置标志寄存器的相应位。具体来说,它会设置零标志(ZF)、符号标志(SF)和溢出标志(OF)等。在这里,je 指令是"jump if equal"的缩写,它检查零标志(ZF),如果为真(表示相等),则跳转到.L2标签处。
3.3.4.2 关系操作<
在for循环中for(i=0;i<10;i++)出现了关系操作<,在hello.s中体现为
图3-12关系操作<
jle 是 "jump if less than or equal" 的缩写,检查零标志(ZF)和符号标志(SF),如果它们表示小于或等于关系,则跳转到.L4标签处。
3.3.5数组/指针操作
我们寻找数组内元素的地址,由于argv被存储在-32(%rbp),而64位编译下指针大小为8个字节,于是每个参数字符串地址相差8个字节。编译器以数组起始-32(%rbp)为基地址,以偏移量为索引,寻找各个字符串指针的地址。即-32(%rbp)+8,-32(%rbp)+16等,如图3-13。
图3-13数组操作
3.3.6控制转移
程序中出现的控制转移有if、for。编译器使用jump指令进行跳转转移,一般为判断或循环进行分支操作时,由于不同的逻辑表达式结果导致程序执行不同的代码,编译器使用CMP指令更新条件码寄存器后,使用相应的jump指令跳转到存放对应代码的地址。
访问条件码:
图3-14访问条件码
3.3.6.1 if判断
程序中if判断为if(argc!=5),对应的汇编语言:
图3-15if对应的汇编语言
使用CMPL对立即数5和-20(%rbp)地址储存的值进行比较。CMPL指令不会改变寄存器的值,而是根据比较结果设置标志寄存器的相应位。它会设置零标志(ZF)、符号标志(SF)和溢出标志(OF)等。在这里,je 指令是"jump if equal"的缩写,其检查的标志如上图所示,如果相等那么跳转。
3.3.6.2 for循环
在for循环中for(i=0;i<10;i++)代码对应为
图3-16for对应的汇编语言
将立即数9和存储在 -4(%rbp) 地址的值进行比较。jle 是 "jump if less than or equal" 的缩写,检查零标志(ZF)和符号标志(SF),如果它们表示小于或等于关系,则跳转到.L4标签处。
3.3.7函数操作
函数调用的完整过程包括栈帧的创建、参数传递、函数执行、返回值传递以及栈帧的销毁。当程序执行到一个函数调用指令(通常是 `call` 指令)时,当前函数的返回地址(即下一条指令的地址)会被压入栈中,从而保证被调用函数执行完毕后,程序可以通过从栈中弹出返回地址回到调用函数的正确位置。接下来,被调用函数开始执行时,需要为其创建一个栈帧。栈帧是一个包含函数参数、局部变量以及用于保存寄存器状态的逻辑单元。参数可以通过寄存器或者被推入栈中传递给被调用函数。同时,被调用函数通常会保存调用前的寄存器状态,以确保函数执行完毕后能够正确恢复调用前的状态。在栈帧中分配空间来存储局部变量,这一过程通过调整栈指针 %rsp 来进行,用于动态分配和释放栈空间。栈帧设置完成后,程序开始执行被调用函数的逻辑,包括对局部变量的操作和其他计算过程。当函数执行完毕时,函数的返回值通常存储在 %rax 寄存器中,调用函数可以通过寄存器或者其他方式获取返回值。随后,程序通过 `ret` 指令从栈中弹出返回地址,跳回到调用函数的执行点。函数返回后,栈帧被销毁,包括释放局部变量占用的栈空间和还原调用前的寄存器状态。
下面将给出内存之中栈帧结构:
图3-17栈帧结构
3.3.7.1参数传递
64位Linux系统中,当参数个数不大于6个时,按照优先级顺序使用寄存器传递参数:rdi, rsi, rdx, rcx, r8, r9。当参数个数大于6个时,前六个参数使用寄存器存放,而其他参数则被压入栈中进行存储。
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rax
movq %rax, %rsi
movl $.LC1, %edi
movl $0, %eax
call printf
分别把字符串常量的地址$.LC1,argv[1]和argv[2]存进寄存器rdi(edi),rsi和rdx中,之后向printf函数进行传参。
3.3.7.2函数调用
程序使用汇编指令call+函数地址调用函数,在hello.o中使用函数名作为助记符代替由于没有重定位而无法得知的函数地址。call将返回地址压入栈中,为局部变量和函数参数建立栈帧,然后转移到调用函数地址。
3.3.7.3局部变量
当函数开始执行时,会在栈上分配空间来存储局部变量。这些变量通常包括函数内部声明的临时变量和对象。通过调整栈指针%rsp来分配和释放这些局部变量所需的内存空间。
subq $32, %rsp
3.3.7.4函数返回
程序使用汇编指令 ret 从调用的函数中返回。这个过程会还原栈帧,也就是将栈中保存的返回地址等信息恢复到正确的位置,以便程序能够继续执行。
返回值通常被存储在 %rax 寄存器中。
3.4 本章小结
在本章中,我们深入探讨了编译过程的各个方面,其中编译器的主要任务是将高级语言转化为汇编代码,这是为了为后续的机器语言转换做准备。编译器通过一系列复杂的步骤,将源代码转换为等效的汇编代码。这个过程涵盖了数据类型、控制结构、函数调用等多个方面,确保生成的汇编代码能够正确且高效地表达原始程序的逻辑。编译器的工作是将高级抽象的程序转换为底层的机器指令,这样计算机可以直接执行这些指令,从而实现程序的功能。因此,编译器在软件开发中起着至关重要的作用,它为程序员提供了更高效、更灵活的开发方式,同时也为计算机提供了更有效地执行程序的方式。
第4章 汇编
4.1 汇编的概念与作用
汇编是将汇编语言代码(通常保存在以 `.s` 结尾的文件中)转换为机器语言二进制程序(通常保存在以 `.o` 或 `.obj` 结尾的文件中)的过程。在这个过程中,汇编器将汇编代码中的每条指令转换为相应的机器指令,同时处理符号、地址和数据等信息,以生成可执行的目标文件。这个过程是编译过程中的一个重要环节,是将高级语言代码转换为机器语言执行的关键步骤之一。
汇编的作用体现在将高级语言代码转换为底层的机器指令,从而使得计算机能够直接执行程序。通过汇编,程序员可以更深入地理解程序在计算机上的执行过程,并进行底层的性能优化和调试工作。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
图4-1汇编命令
4.3 可重定位目标elf格式
hello.o是可重定位目标文件(Relocatable Object File),这种类型的目标文件包含了编译器生成的二进制代码,但其内部地址(如函数和变量的地址)仍然是相对的,而非绝对的。这代表其可以被链接到其他目标文件或者库中,从而形成可执行文件,但是他们单独不能形成可执行程序。
ELF头(ELF header)以一个16字节的序列开始。这个序列描述了字的大小和生成该文件的字节顺序。ELF头剩下的部分包含帮助链接器解析和解释目标文件的信息。
典型的ELF可重定位目标文件如下图4-2所示。
图4-2典型的ELF可重定位目标文件
之后通过使用readelf -a hello.o>./helloelf.txt命令输出包含ELF所有可用的信息并将其保存在当前文件夹的helloelf.txt文本文件中。
图4-3ELF读取截图
4.3.1 ELF头
图4-4ELF头截图
上图包含了hello.o的重要信息。通过其ELF头,我们可以看出,文件是按照小端序,是一个64位的可重定位文件(REL)。使用AMDx86-64的系统架构,运行在UNIX - System V操作系统中。节头开始处是第1080字节,文件头占据64字节。程序的虚拟地址入口点为0,也没有程序头表。他有14个节头,字符串表索引为13。
4.3.2节头部表
图4-4节头部表截图
节头部表描绘了hello.o目标文件中各个节的[号]名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对其等信息。其中,.rela.text和.rela.data则是与重定位相关的节,用于指导连接器修正代码和数据的地址。在这个程序中只有.rela.text没有rela.data节,证明此时.data不需要进行重定位,而.text需要进行重定位。
在文件中还有着对标记的解释,其中W (Write) 表示节内容在运行时是可写的;A (Alloc) 表示节在进程的虚拟地址空间中是可分配的;X (Execute) 表示节内容在运行时是可执行的;M (Merge) 表示节包含可以被合并的可重复数据;S (Strings) 表示节包含以 null 结尾的字符串;I (Info) 表示节包含的信息;L (Large) 表示节含有大量的数据,超过某个预定义的限制;G (Group) 表示节是一个节组的一部分;T (TLS) 表示节包含线程局部存储 (Thread-Local Storage);E (Exclude) 表示节在进程映像中是排除的;O (OS specific) 表示节是特定于操作系统的;p (Processor specific) 表示节是特定于处理器的。
4.3.3重定位节
图4-4重定位节截图
在重定位节中,.rodata 中的模式串:如果 .rodata 包含了对其他模块或库中定义的常量字符串的引用,链接器需要通过重定位来调整这些引用,以便正确地定位这些字符串的地址。
在第68到74行中,我们可以发现有着puts、exit、printf、sleep、getchar等外部符号,但是在当前文件中未定义,需要通过重定位来确定这些符号在可执行文件或者共享库中的位置。
4.3.4符号表
图4-4符号表截图
符号表(.symtab)包含了二进制文件中所有符号的信息。第0条目通常是未定义的占位符。第1条目表示文件名hello.c,这是一个本地符号,类型是FILE,索引为ABS表示这个符号是绝对的,不在任何节中。第2和第3条目表示节.text和.rodata,它们是本地符号,类型是SECTION。第4条目表示main函数,这是一个全局符号,类型是FUNC,在.text节中。第5到第10条目表示外部函数如puts、exit、printf、atol、sleep和getchar,它们是全局符号,类型是NOTYPE,索引为UND表示未定义的符号,需要在链接时解析。这些符号表条目提供了文件中各种符号的详细信息,对于调试和链接过程非常重要。
4.4 Hello.o的结果解析
使用objdump -d -r hello.o>asm.txt对hello.o进行反汇编。
图4-5反汇编命令
所获得的反汇编文本如下图所示:
图4-6hello.o反汇编文本
hello.o的反汇编和和hello.s的结构和内容基本一致,而在数据、控制转移以及函数调用上有着区别。
在数据上,hello.s使用的是十进制的立即数,但是在反汇编文件中,使用的是十六进制数。例如在反汇编文件中的第11行中,sub $0x20,%rsp指令在hello.s中为subq $32,%rsp。
在控制转移中,hello.s使用助记符,例如.L2等的符号。在反汇编文件中,使用的是目标代码的虚拟地址,例如2d<main+0x2d>等,这种地址在链接之后可以变成正确的地址。
在函数调用上,hello.s文件中的函数调用使用的是函数的名称。在反汇编文件中,通常使用的是与被调用函数相关的重定位类型,比如在第19行的1f: R_X86_64_PLT32 puts-0x4。这个重定位类型在上述的.rela.text文件中可以找到,可以告诉编译器在链接时如何处理在调用时的地址修正。
4.5 本章小结
在本章中,我们介绍了如何将一个汇编文件转化成为一个可重定位目标文件。之后我们对可重定位目标文件的ELF格式的elf头、节头部表、重定位节和符号表进行了详细的介绍。之后我们使用了objdump指令对hello.o文件进行了反汇编,并与hello.s进行了比较,从而得到了从汇编语言转化为机器语言的过程。之后通过链接,就可以得到可执行程序。
第5章 链接
5.1 链接的概念与作用
链接(Linking)是将一个或多个目标文件(如hello.o)组合成一个可执行文件(如hello)的过程。链接是编译过程中的最后一步,负责将编译器生成的独立目标文件结合在一起,生成最终的可执行程序。
链接的作用包括符号解析、地址分配、重定位以及合并和优化。在符号解析过程中,链接器解析目标文件中引用的符号,确保所有符号都有定义,避免未定义符号导致的链接错误。地址分配则是将目标文件中的代码和数据节合并到一个统一的地址空间中,为每个符号分配最终的内存地址。重定位是链接器调整代码中的地址引用,使其指向正确的内存地址,例如修改函数调用指令中的地址,使其指向被调用函数的实际地址。此外,链接器还可以对多个目标文件进行合并和优化,去除冗余代码,优化内存布局,从而提高执行效率。
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 /usr/lib/x86_64-linux-gnu/crtn.o /usr/lib/x86_64-linux-gnu/libc.so hello.o
图5-1链接的命令
5.3 可执行目标文件hello的格式
使用命令readelf -a hello>./helloelf2.txt将hello的elf信息导出到文本文件helloelf2.txt中。
之后查看helloelf2.txt,截图如下:
图5-2elf头截图
这个ELF文件是一个64位的可执行文件(ELF64),使用小端序(little endian)编码。文件的版本为当前版本(1 (current)),目标操作系统ABI是UNIX - System V。ABI版本为0。文件类型是可执行文件(EXEC),系统架构是Advanced Micro Devices X86-64。入口点地址为0x4010f0,这表示程序开始执行的内存地址。程序头从文件中的第64字节开始,节头从文件中的第13560字节开始。文件标志为0,表示没有特别的标志。ELF头部的大小为64字节,程序头的大小为56字节,共有12个程序头。节头的大小也是64字节,共有27个节头。节头字符串表的索引为26。
之后查看程序头部表,可以看到各段的基本信息。
图5-3程序头部表截图
我们以第97行的LOAD段为例子:
偏移量为0x001000,虚拟地址:0x401000,物理地址为:0x401000,文件大小为:0x1cd,内存大小为:0x1cd,标志为可读可执行。
5.4 hello的虚拟地址空间
使用edb加载hello,使用edb查看空间各段信息如下:
图5-4edb各段截图截图
之后在edb中查看5.3示例中的load段,截图如下:
图5-5edbload段截图
起始处为0x401000,大小为0x1cd,因此查询在0x401000到0x4011cd中有信息,后面全为0。
5.5 链接的重定位过程分析
使用objdump -d -r hello>asm2.txt将hello的反汇编输入到文本文件中。
图5-6反汇编指令截图
之后查看反汇编文件asm2,截图如下:
图5-7反汇编文本截图
图5-8反汇编文本截图
通过对比hello的反汇编与hello.o的反汇编,可以发现这两个反汇编文本的不同主要体现在以下两方面:
- 额外的代码
在hello的反汇编代码中,包含了完整的程序入口、库函数调用以及相关的启动和退出代码,而这些是链接之前的hello.o文件所不具备的。在这两个文件中,我们也可以发现,程序入口的地址不同,这是因为在链接过程中,启动代码等额外的代码被添加到可执行文件中。
- 重定位信息
在hello.o的反汇编文件中包含着许多重定位信息,如R_X86_64_32等,这些信息是指示链接器在链接时需要进行的地址修正。而在hello中,地址已经被修正完成。
通过以上两个不同点,我们可以总结出链接和重定位的过程。
首先由链接器对hello.o中的重定位项目进行扫描,来识别需要解析的符号引用。对于每一个符号引用,链接器会在其他的目标文件中查找相应的定义。如果在别的地方找到了定义,那么链接器将符号引用与这些定义关联,确保每个符号的定义是唯一的。
在所有的符号都得到了解析和关联之后,链接器将会对hello.o的数据段和代码段进行地址重定位。
在这些操作执行之后,链接器生成一个可执行文件(hello),其中包含重定位的代码和数据。
5.6 hello的执行流程
首先使用gdb对hello进行调试,使用info function指令查看在程序运行过程中所调用的所有函数。
图5-9调用函数截图
之后使用edb对hello的运行过程进行追踪,发现hello的执行流程包含三个主要阶段:载入、执行和退出。
执行过程:
一旦载入完成,程序开始执行,控制权转移到程序的入口点 _start。_start 可能会调用一些初始化函数,如 _init,用于执行一些全局变量的初始化工作。
接着,控制流程跳转到程序的 main 函数,这是程序的主要逻辑入口。在 main 函数中,程序执行具体的业务逻辑,可能会调用其他函数,包括库函数和自定义函数。在调用其他函数时,控制权会转移到相应函数的入口点,执行该函数的代码。这个阶段涉及到函数调用、栈的使用、寄存器的保存和恢复等操作。
退出过程:
当 main 函数执行完毕或通过 exit 函数退出时,程序开始进行退出过程。在退出过程中,可能会调用一些终止函数,如 _fini,用于执行一些清理工作。最终,程序将控制权交还给操作系统,操作系统负责回收程序占用的资源,并结束程序的执行。
5.7 Hello的动态链接分析
程序的动态链接机制,特别是涉及到了 Global Offset Table(GOT)和 Procedure Linkage Table(PLT)。这是一种在程序运行时进行函数动态链接的机制,通常用于共享库的调用。GOT 存放的是全局偏移表,包含函数的地址或者指向 PLT 的入口。PLT 存放的是一系列跳转指令,每个指令对应一个函数的调用。
PLT 的初始条目通常包含两条指令:一条用于懒加载,另一条用于调用链接器。调用函数时,程序首先执行 PLT 表的第一条指令,该指令跳转到 PLT 的懒加载部分。懒加载部分负责调用链接器,链接器会更新 GOT 表的对应条目,使其指向正确的函数地址。下一次调用同一函数时,执行 PLT 表的第二条指令,该指令直接跳转到函数的地址。
GOT 表的初始值通常是 PLT 表的第二条指令,即进行懒加载的指令。链接器在懒加载时会更新 GOT 表的对应条目,使其指向正确的函数地址。
在程序运行时,动态链接器根据需要加载共享库,并更新 GOT 表中的条目。动态链接器通过重定位信息,找到函数的真正地址,并更新相应的 GOT 表项。
之后我们在之前生成的helloelf2.txt中寻找.got.plt的地址,发现在0x404000,如图5-10。
图5-10.got.ple地址截图
之后在edb中观测这个地址的内容,发现在dl_init之后,GOT表的内容已经被更改,如图5-11和5-12。
图5-11dl_init前GOT表内容截图
图5-10dl_init前GOT表内容截图
5.8 本章小结
在这一章中,通过研究链接的过程,我们发现链接是将代码和数据片段组合成一个可执行文件,该文件可以加载到内存并执行。
第6章 hello进程管理
6.1 进程的概念与作用
进程是计算机系统中正在执行的程序的实例,它包含程序代码、数据、进程控制块(PCB)等。每个进程都有自己克隆地址空间,意味着它拥有克隆内存区域,堆栈和数据段,确保进程之间的相互隔离。这种隔离保护了各个进程的独立性和安全性,防止一个进程意外影响另一个进程的执行。进程控制块(PCB)是操作系统用来管理进程的关键数据结构,包含了进程的标识、状态、寄存器内容、调度信息和其他
进程的主要作用在于资源管理和任务调度,通过进程的分配和管理CPU时间、内存空间、I/O设备等资源,确保进程的独立性保证了系统的安全性,同时通过进程调度机制,使得系统资源能够得到最大化利用,进程间通信(如管道、信号量、缓冲区等)允许进程进行数据交换和协调,增强系统的灵活性。
6.2 简述壳Shell-bash的作用与处理流程
Shell 是用户与操作系统之间的接口,而 Bash(Bourne Again Shell)是一种广泛使用的 Unix/Linux Shell。它的主要作用包括为用户提供与操作系统交互的界面,用户通过 Shell 输入命令,Shell 会解释这些命令并将其添加到指向该操作系统的执行中。Bash 还支持脚本编写,用户可以将多个命令组合成脚本文件,以便进行批量处理。同时,它也能配置用户环境,包括设置环境变量和创建别名等,使用户的工作环境更加个性化和高效。
Shell 的流程一般如下:首先,用户在Shell中输入命令。随后,Shell解析输入的命令,了解其结构和含义。如果命令是内置的(如cd、echo等),Shell会直接执行这些命令,无需调用外部程序,因此不会创建子进程。对于非内置命令,Shell 会在系统路径中寻找相应的可执行文件。如果找到,Shell会创建一个子进程,并在该子进程中运行该外部程序。此外,Shell 还需要处理来自用户键盘信号,例如Ctrl+C(中断)、Ctrl+Z(暂停)和Ctrl+D(结束输入)。这些信号会触发Shell执行不同的操作,如中断当前命令、暂停程序或退出Shell,传输用户能够更灵活地控制系统。
6.3 Hello的fork进程创建过程
当在 Shell 中输入 ./hello 并按下回车时,Shell 首先会解析这个命令。如果 hello 可执行文件存在,Shell 将调用 fork() 来创建一个新的子进程。在子进程中,fork() 返回值为 0,表示这是子进程。子进程接着执行 ./hello 可执行文件。在父进程中,fork() 返回新创建子进程的 PID。父进程可能会选择等待子进程完成,以确保在继续执行自己的任务之前,子进程已经执行完毕。这可以通过调用 wait() 或 waitpid() 来实现。在等待期间,父进程可能会阻塞自己的执行。当子进程完成执行 ./hello 后,它会终止,然后父进程(如果在等待子进程完成)会继续执行。
6.4 Hello的execve过程
当你在Shell中输入./hello并按下回车时,Shell首先会解析这个命令。接着,Shell将调用execve()来执行./hello可执行文件。这个系统调用的过程涉及以下步骤:Shell解析命令,确定要执行的文件路径,并准备好命令及其参数的参数数组。然后,execve()加载并执行指定的可执行文件(即./hello),并将当前进程的内存映像替换为新程序的内存映像。这意味着原始的Shell进程被./hello取代,而./hello成为了新的进程并开始执行。
6.5 Hello的进程执行
当你在Shell中输入./hello并按下回车时,操作系统会启动一系列的过程来执行该进程。这个过程涉及进程调度、用户态与核心态之间的转换以及进程时间片的管理等方面。
首先,操作系统会创建一个新的进程来执行./hello。在创建这个进程的过程中,操作系统会为其分配一个进程控制块(PCB),其中包含了进程的上下文信息,如程序计数器、寄存器状态等。然后,操作系统会将./hello可执行文件加载到新进程的内存空间中。
接着,操作系统将新进程放入就绪队列中等待执行。进程调度器会根据一定的调度算法选择一个进程执行。一旦./hello进程被选中,操作系统会将CPU的控制权转移到该进程,并开始执行其代码。
在执行过程中,进程处于用户态。在用户态下,进程可以执行一般的用户程序,如读写文件、分配内存等。然而,如果进程需要执行特权操作,如进行系统调用或访问受保护的内存区域,它必须切换到核心态。这个转换是通过操作系统来实现的。
进程执行过程中,操作系统会为每个进程分配一个时间片,即一段CPU执行时间。一旦时间片用尽,操作系统会中断当前进程的执行,并将CPU分配给其他进程。这样做是为了确保所有进程都有机会执行,并防止某个进程长时间占用CPU。
当./hello进程执行完毕或者发生异常时,操作系统会终止该进程。在进程终止时,操作系统会释放进程所占用的资源,并清理其占用的内存空间。
综上所述,./hello的进程执行过程涉及进程调度、用户态与核心态之间的转换以及进程时间片的管理等多个方面,这些都是由操作系统负责管理和协调的。
6.6 hello的异常与信号处理
6.6.1 异常
hello执行过程中会出现的异常可分类为中断、陷阱、故障、终止。
(1)中断(Interrupts)
中断是由硬件产生的信号,用于通知处理器发生了需要立即处理的事件。它通常是异步的,与程序的当前执行无关。
例子:
外部中断:如键盘输入(例如用户按下Ctrl-C生成的SIGINT信号)。
处理:操作系统接收到中断信号后,会暂停当前程序的执行,处理中断请求(如调用信号处理函数),然后再恢复程序的执行。
(2)陷阱(Traps)
陷阱是一种由程序指令显式生成的同步中断,用于调试、系统调用等。
例子:
系统调用:如程序请求操作系统服务(比如读写文件操作)。
调试陷阱:程序执行到一个断点时。
处理:程序执行到陷阱指令时,控制权转移到操作系统,完成相应的处理后返回程序执行。
(3)故障(Faults)
故障是由于程序错误或异常情况而产生的同步中断。它们通常是可恢复的,程序可以在处理故障后继续执行。
例子:
页错误:当程序访问的内存页面不在物理内存中时发生。
浮点异常:执行非法的浮点运算时发生。
处理:操作系统试图纠正故障(如加载所需的内存页)。如果可以纠正,程序继续执行;如果不可恢复,则程序终止。
(4)终止(Aborts)
终止是一种严重的故障,表示程序遇到了无法恢复的错误,无法继续执行。
例子:
段错误:程序试图访问无权限的内存区域。
非法指令:程序尝试执行无效或未定义的机器指令。
处理:操作系统通常会终止程序,有时会产生核心转储文件以便进行调试。
6.6.2 信号
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了一些异常。
信号的种类有:
图6-1信号种类截图
6.6.3 异常和信号处理
(1)Ctrl-Z
图6-2ctrl-z截图
运行hello后按下Ctrl-Z,使得SIGTSTP信号被发送给(?),进程挂起。
(2)Ctrl-C
进程收到 SIGINT 信号,结束 hello。在这里通过再创建一个进程[2]+,之后使用fg %1指令将挂起的一号作业转换为前台运行,然后传递ctrl-c信号,通过jobs查看进程,发现进程1已经被彻底结束。
图6-3ctrl-c截图
(3)jobs
使用jobs命令查看当前终端的作业信息,显示进程状态及对应编号。
(4)ps
通过ps t命令查看终端进程状态,T表示已停止,也可显示进程的PID。
图6-4ps t截图
(5)pstree
可以查看所有进程树状图显示,在这里对hello的进程进行截图。
图6-5ptree截图
(6)fg
通过fg %1(或者hello的pid)把被挂起的一号作业(hello)转为前台运行,在ctrl-c模块已经做过展示。
(7)kill
使用kill -9 %1给1号作业发送SIGKILL信号杀死程序;
使用kill -18 %1给1号作业发送SIGCONT使其继续执行。
(8)乱按
回车和空格被忽略,乱按输入的字母被认为是命令,但是没有找到相应的命令,没有执行。这种输入方式(如果不是乱按到Ctrl-C这些)不会触发系统响应。
图6-6乱按命令截图
6.7本章小结
本章介绍了进程管理的基本概念和实现,包括进程的独立地址空间和进程控制块(PCB)的作用。分析了Shell(特别是Bash)的功能及命令处理流程,通过./hello示例,探讨了fork()和execve()系统调用、用户态与核心态转换、以及CPU时间片管理。最后,简述了进程异常和信号处理机制,展示了操作系统如何处理进程中的各种事件。综上所述,本章帮助理解了操作系统在进程管理中的关键角色和实现方法。
第7章 hello的存储管理
7.1 hello的存储器地址空间
在计算机系统中,进程执行过程中涉及到不同类型的地址:逻辑地址、线性地址、虚拟地址和物理地址。每种地址在内存管理中有其特定的作用和转换关系。
逻辑地址是由CPU生成的地址,又称虚拟地址。在执行./hello程序时,程序生成的所有内存地址都是逻辑地址。这些地址是程序认为的内存位置,例如变量和函数的存储位置。逻辑地址空间是进程独有的,独立于实际的物理内存,保证了进程间的隔离性。
线性地址是经过分段机制转换后的地址。在使用分段机制的系统中,逻辑地址通过段寄存器和段偏移量转换为线性地址。假设系统使用分段机制,./hello进程的逻辑地址首先通过段寄存器转换为线性地址,这一步是操作系统在进行地址转换过程中所做的中间步骤。然而,在现代操作系统中,线性地址的概念可能不明显,因为分页机制已经取代了分段机制。
虚拟地址通常与逻辑地址同义,但更具体地指的是内存管理单元(MMU)处理后的地址。在分页机制中,./hello进程的逻辑地址通过页表映射为虚拟地址,这些地址被MMU映射到物理地址。虚拟地址空间让./hello认为它有一个连续的内存空间,即使实际的物理内存是分散的。
物理地址是内存单元的实际地址,是CPU通过内存总线访问的地址。当./hello进程需要访问内存中的数据时,MMU将虚拟地址转换为物理地址。这些物理地址对应于内存芯片上的实际位置,CPU通过这些地址进行数据的读写操作。物理地址空间是操作系统管理的真实内存布局,用于实际的数据存储和访问。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在Intel架构的计算机系统中,逻辑地址到线性地址的转换过程通过段式管理机制实现。段式管理使用段寄存器和段描述符来定义每个内存段的基址和边界。当在Shell中输入./hello并执行时,程序中的每个内存访问指令会产生一个逻辑地址。这个逻辑地址由段选择子和段内偏移量组成。段选择子指向一个段描述符,该描述符包含段的基地址、段界限和访问权限信息。
首先,程序代码在运行时会产生逻辑地址,例如访问变量或调用函数时。逻辑地址由段选择子和段内偏移量构成。段选择子存储在段寄存器中,如代码段寄存器(CS)、数据段寄存器(DS)、堆栈段寄存器(SS)等。段选择子是一个索引,用于指向段描述符表中的一个条目。
段描述符表是一个数据结构,包含了所有段的段描述符。每个段描述符存储了段的基地址、段界限和段属性(如权限位)。当段选择子指向段描述符表中的一个条目时,系统从段描述符表中读取对应段的基地址和其他相关信息。
接下来,逻辑地址中的段内偏移量与段描述符中的基地址相加,得到线性地址。线性地址是逻辑地址转换后的中间地址,经过这一过程,逻辑地址从段式内存管理的视角变换为一个统一的线性地址空间。这个线性地址随后可能进一步通过分页机制转换为物理地址,完成整个地址转换过程。
综上所述,逻辑地址到线性地址的转换涉及段选择子、段描述符表和基地址的计算。这一过程确保了程序能够正确访问内存中的各个段,同时提供了内存访问的保护和隔离机制。
7.3 Hello的线性地址到物理地址的变换-页式管理
在Intel架构的计算机系统中,线性地址到物理地址的转换通过页式管理机制实现。页式管理将内存分成固定大小的页(通常为4KB),并通过页表将线性地址映射到物理地址。下面结合./hello程序的执行过程,详细说明线性地址到物理地址的变换过程。
当在Shell中输入./hello并执行时,程序的每个内存访问指令会产生一个线性地址。这个线性地址需要通过页表转换为物理地址,以便实际访问内存。
首先,线性地址由两个部分组成:页号和页内偏移量。线性地址的高位部分是页号,低位部分是页内偏移量。例如,在一个32位的地址空间中,假设页大小为4KB,则线性地址的高20位表示页号,低12位表示页内偏移量。
接下来,处理器使用线性地址的页号查找页表。页表是一种数据结构,存储了线性地址与物理地址的映射关系。页表的基地址存储在控制寄存器CR3中。处理器通过页号在页表中查找对应的页表项(PTE)。每个页表项包含一个物理页框号(PFN)和一些控制位(如有效位、读写位等)。
一旦找到对应的页表项,处理器将页表项中的物理页框号与线性地址的页内偏移量组合,形成物理地址。具体来说,物理页框号作为物理地址的高位,线性地址的页内偏移量作为物理地址的低位。这样,线性地址被转换为物理地址,处理器可以使用这个物理地址访问实际内存。
在./hello程序的执行过程中,这个地址转换过程是透明的。程序认为自己在一个连续的线性地址空间中运行,而实际内存可能是分散的,甚至可能部分存在于磁盘的交换空间中。这种机制允许操作系统灵活管理内存,提供虚拟内存的支持,确保程序的内存访问安全和隔离。
7.4 TLB与四级页表支持下的VA到PA的变换
在现代计算机系统中,虚拟地址(VA)到物理地址(PA)的转换过程通常由多级页表和TLB协同完成。在Intel x86-64架构中,常采用四级页表来管理虚拟内存。
TLB(Translation Lookaside Buffer)是一个高速缓存,存储了最近使用的虚拟地址到物理地址的映射。TLB的作用是提高地址转换的速度,因为它可以直接从缓存中获取地址映射信息,而不需要每次都访问内存中的页表。当CPU需要访问内存时,首先会在TLB中查找对应的虚拟地址映射,如果找到了,就会直接从TLB中获取物理地址,这样的情况称为TLB命中。如果未在TLB中找到,就会进行多级页表的访问,并将新的地址映射信息加载到TLB中,这时候称为TLB未命中。
四级页表是一种分层的数据结构,用于将大型的虚拟地址空间映射到物理内存。它包括PML4表、PDPT表、PD表和PT表,每个表都存储着相应级别的页表项。当CPU接收到一个虚拟地址时,它首先从虚拟地址中提取出索引各级页表的字段,然后依次访问这些页表,直到找到对应的物理地址。这个过程中,每一级页表的页表项都包含了下一级页表的基地址,通过这种方式逐级查找,最终得到物理地址。
7.5 三级Cache支持下的物理内存访问
在现代计算机系统中,三级缓存(L3 Cache)是CPU内部的高速缓存层级之一,用于存储经常访问的数据和指令。在物理内存访问过程中,三级缓存通过其层级结构和缓存替换策略对内存访问进行优化,以提高内存访问速度和效率。
当CPU需要访问物理内存中的数据时,首先会检查L1缓存(一级缓存)和L2缓存(二级缓存)中是否存在所需的数据。如果所需数据未在L1和L2缓存中找到(即缓存未命中),CPU将会访问L3缓存。L3缓存通常具有更大的容量,更长的访问延迟,但仍然比主存(RAM)要快得多。
在L3缓存中,CPU会进行类似的查找操作,以确定所需的数据是否存在于其中。如果数据在L3缓存中命中,CPU会直接从L3缓存中获取数据,这样可以避免访问主存带来的较高延迟。如果数据未在L3缓存中找到(即L3缓存未命中),CPU将会继续访问更慢的主存来获取数据。
在物理内存访问过程中,L3缓存起到了缓解内存访问压力的作用。通过在CPU内部存储大量的数据,L3缓存可以减少对主存的访问次数,从而降低了内存访问的总体延迟。此外,L3缓存还可以通过其高速的访问速度和并行性,提高了数据访问的吞吐量,从而加速了程序的执行速度。
7.6 hello进程fork时的内存映射
在执行./hello时,如果涉及到fork()系统调用,将会创建一个新的子进程。在fork()时,子进程会复制父进程的内存映像,但是这种复制是通过写时复制(Copy-on-Write,COW)技术来实现的,即只有在子进程或父进程尝试修改内存内容时,才会进行实际的内存复制。
具体地说,当fork()调用发生时,子进程的内存映像会和父进程的一样,包括代码段、数据段、堆和栈等。但是这些内存页并不会被实际复制,而是被标记为只读,并与父进程共享。当子进程或父进程尝试修改这些共享的内存页时,操作系统会检测到这种修改尝试,并为子进程分配新的物理内存页,并将修改后的内容写入其中,从而保证了父子进程之间的内存隔离。
因此,在fork()时,子进程的内存映射与父进程的相同,但是实际的物理内存页并没有立即复制,而是在发生写操作时才进行复制,这样可以节省内存空间,并且在大部分情况下,不会带来额外的性能开销。
7.7 hello进程execve时的内存映射
当./hello进程调用execve()系统调用时,它会加载新的可执行文件,并且替换自己的内存映像。这个过程会导致原来的内存映射被清除,然后根据新的可执行文件重新建立内存映射。
具体来说,在execve()调用时,操作系统会首先关闭原有进程的文件描述符,然后释放原有的内存映射,并且根据新的可执行文件重新设置进程的内存映射。新的内存映射包括了可执行文件的代码段、数据段、堆和栈等。
一般情况下,execve()调用会清除原有的内存映射,并且建立一个新的内存映射,以加载新的程序。这样做可以确保新程序的内存布局是符合预期的,并且保证了内存的安全性和隔离性。
7.8 缺页故障与缺页中断处理
缺页故障是在程序访问虚拟内存中的某个页面时,发现该页面尚未加载到物理内存中而引发的错误。当发生缺页故障时,CPU会暂停当前程序的执行,并向操作系统发送一个缺页中断信号,以通知操作系统发生了页面访问错误。
在操作系统接收到缺页中断后,会执行相应的缺页中断处理程序。首先,操作系统会分析缺页的原因,可能是页面尚未加载到内存、被换出到磁盘或访问权限不正确等。然后,根据具体情况采取相应的措施来解决缺页问题。
如果页面未加载到内存中,操作系统会从磁盘上的页面文件或者交换空间中加载该页面到内存中。如果页面被换出到磁盘,操作系统可能会将其他页面换出,以腾出空间来加载缺页。另外,如果页面访问权限不正确,操作系统可能会更新页表项以重新设置页面权限。
处理完缺页后,操作系统会更新页表,并恢复程序的执行。这个过程保证了程序能够正常地访问所需的页面,并确保了虚拟内存的有效管理和使用。
7.9动态存储分配管理
动态内存分配器是操作系统中负责管理进程虚拟内存区域中堆的重要组件。堆是操作系统为进程动态分配内存而预留的一部分虚拟内存空间,其大小根据程序的需求动态增长或减少。每个进程都有一个指向堆顶部的变量。
堆被划分为一组不同大小的块,每个块要么是已分配的,要么是空闲的。已分配的块保留给应用程序使用,而空闲块可用于分配。动态内存分配器负责维护这些块,并执行分配和释放操作。
当应用程序请求一个大小为k字节的块时,分配器搜索空闲链表,选择适合大小的空闲块进行放置。放置策略由分配器的设计决定,常见的有首次适配、下一次适配和最佳适配。
一旦找到匹配的空闲块,分配器将其分割为两部分,一部分分配给应用程序,另一部分成为新的空闲块。
如果无法找到足够大的空闲块,分配器会合并相邻的空闲块或通过调用sbrk函数向内核请求额外的内存。分配器将这块额外的内存转换为一个大的空闲块,并将其插入空闲链表。
动态内存分配管理通常采用带边界标签的隐式空闲链表分配器管理或显式空间链表管理两种基本方法。带边界标签的分配器利用头部和尾部标签来连接空闲块,并支持合并操作。显式空间链表则将空闲块组织成双向链表,放置和释放操作根据链表进行操作。此外,还有分离链表等方法,如简单分离链表、分离适配和伙伴系统,用于提高内存利用率和减少碎片化。
7.10本章小结
本章详细介绍了操作系统中与内存管理相关的重要概念和机制,包括存储器地址空间、段式管理、页式管理、虚拟地址到物理地址的变换、物理内存访问,以及进程创建过程中的内存映射、缺页故障与缺页中断处理,还包括动态存储分配管理中的隐式空闲链表和显式空闲链表。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
在Linux系统中,一切皆文件的思想贯穿了整个系统。这意味着硬件设备被抽象为文件,可以通过文件系统进行访问和管理。每个设备都被视为文件,其状态和控制信息通过读写文件的方式进行访问。例如,硬盘、串口、打印机等设备都被映射到文件系统的某个位置,用户可以通过读写这些文件来进行设备的操作。
Linux提供了一套丰富的Unix IO接口,用于管理和操作IO设备。这些接口包括但不限于以下几种:文件描述符(File Descriptors)、系统调用(System Calls)、设备文件和特殊文件。通过这些接口,用户可以打开设备文件、读取数据、写入数据以及关闭文件,从而实现对IO设备的控制和管理。设备文件位于/dev目录下,代表了系统中的各种设备,用户可以直接操作这些文件来进行设备管理。因此,Linux的IO设备管理方法基于文件模型和Unix IO接口,为用户提供了灵活且强大的设备管理能力。
8.2 简述Unix IO接口及其函数
Unix IO接口是一组用于管理输入输出操作的函数集合,它们提供了一种统一的方式来进行设备和文件的读取、写入以及其他IO操作。这些函数通常被称为系统调用,因为它们直接与操作系统内核进行交互,实现了底层的IO操作。其中一些常见的Unix IO函数包括:
open():用于打开文件或设备,并返回一个文件描述符,该描述符可以用于后续的IO操作。
close():用于关闭先前打开的文件描述符,释放资源并终止对文件的访问。
read():用于从文件或设备中读取数据到指定的缓冲区。
write():用于将数据从指定的缓冲区写入到文件或设备中。
lseek():用于在文件中移动当前文件指针的位置。
ioctl():用于进行设备特定的控制操作,例如设置设备参数或获取设备状态。
fcntl():用于对文件描述符进行各种控制操作,例如设置文件状态标志或获取文件状态标志。
这些函数提供了一种标准的接口,使得应用程序可以跨平台地进行IO操作,而无需考虑底层操作系统的具体实现细节。通过调用这些函数,应用程序可以实现对文件和设备的读写、控制以及管理等操作。
8.3 printf的实现分析
printf函数的实现可以分为以下几个步骤:
从vsprintf生成显示信息。
通过write系统函数将信息写入缓冲区。
通过陷阱-系统调用 int 0x80或syscall等,将缓冲区中的信息传递给操作系统内核。
(4)操作系统内核将信息传递给字符显示驱动子程序。
(5)字符显示驱动子程序将ASCII码转换为字模库中对应的点阵信息。
(6)字符显示驱动子程序将点阵信息存储到显示vram中。
(7)显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar()函数是一个标准C库函数,用于从标准输入流(通常是键盘)获取一个字符。下面是getchar()函数的简要实现分析:
异步异常-键盘中断的处理:
当用户按下键盘时,会产生一个键盘中断。
操作系统中有一个与键盘中断相关的中断处理程序,也称为键盘中断处理子程序。这个处理程序负责从键盘控制器读取键盘扫描码,并将其转换为相应的ASCII码。
ASCII码通常被保存到系统的键盘缓冲区中,等待被应用程序读取。
getchar()函数调用read()系统函数:
当应用程序调用getchar()函数时,它会在底层调用read()系统调用从标准输入流(通常是键盘)读取数据。
read()系统调用会阻塞进程,直到有数据可读或者发生错误。
当有数据可读时,read()函数将读取键盘缓冲区中的一个字符,并将其返回给调用者。
等待回车键的处理:
通常情况下,getchar()函数会一直阻塞直到接收到回车键。
这是因为标准输入通常是行缓冲的,意味着输入的字符会在用户按下回车键后才被传递给程序。
因此,getchar()函数会一直读取字符,直到接收到回车键为止,然后才返回读取的字符。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法、Unix IO接口及其相关函数、以及printf和getchar函数的实现分析。这些深入的分析有助于理解操作系统中的IO机制和函数实现。
结论
从hello.c到hello的生成是一项复杂而精密的过程,它涉及了编译、链接和程序执行等多个阶段的协同工作。在编译阶段,hello.c被翻译成汇编语言,然后通过汇编阶段转换为机器语言指令,最终生成可重定位目标文件。在链接阶段,各个目标文件和库文件被有机地结合,形成一个完整的可执行目标程序。程序的执行不仅仅是简单地执行一系列指令,它是一个动态的过程,在操作系统的调度下,进程被抽象为一个独立实体,拥有自己的内存空间、代码段、数据段、堆和栈。程序执行过程中,CPU为程序分配时间片,程序依次执行指令,实现了代码的有序运行。此外,程序与外界的交互通过输入输出实现,而这也与操作系统中的I/O机制密切相关。最终,通过信号的处理和资源的回收,程序在操作系统中结束其生命周期,留下一段在计算机系统中的痕迹。整个过程突显了计算机系统设计与实现的复杂性和精密性,需要在性能、安全性和可维护性之间找到平衡,以确保系统能够高效、安全地运行。
附件
hello.c | hello 的C源文件 |
hello.i | hello.c经过预处理后的预编译文件 |
hello-p.i | hello.c经过加上-P参数的预处理的预编译文件 |
hello.s | hello.i经过编译得到的汇编语言文本文件 |
hello.o | hello.s经过汇编得到的可重定位目标文件 |
helloelf.txt | 通过readelf导出的hello.o的elf信息文本 |
asm.txt | hello.o反汇编导出得到的文本 |
hello | hello.o经过链接得到可执行文件 |
helloelf2.txt | 通过readelf导出的hello的elf信息文本 |
asm2.txt | hello反汇编导出得到的文本 |
参考文献
[1] Randal E. Bryant, David O’Hallaron. Computer Systems: A Programmer’s Perspective[M]. Beijing: Mechanical Industry Press, 2021:10-1.
[2] GeanQin. 认识各种内存地址[EB/OL]. https://blog.csdn.net/angjia7206/article/details/106546203.