计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 信息安全
学 号 2022112864
班 级 2203201
学 生 xxx
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
本论文旨在详细探讨计算机系统中程序从源码到可执行文件的整个转换过程,具体分析了C语言程序`Hello.c`的编译、链接、运行等各个阶段。通过使用GCC编译器以及Ubuntu环境下的一系列工具,本文对预处理、编译、汇编、链接、加载和运行等环节进行了详细的实验和解析。主要方法包括对每个阶段生成的中间文件进行逐一分析,结合具体的命令和调试过程,深入理解程序的转换机制。实验结果展示了各个中间文件的内容和它们在程序转换中的作用。通过这些分析,揭示了程序在执行过程中涉及的存储管理、进程管理和输入输出管理等重要方面,具有重要的理论与实践意义。论文不仅强调了对每一步的理解和掌握,还在实验中总结了许多感悟和创新理念,对进一步学习和研究计算机系统提供了参考和指导。
关键词:计算机系统;编译;链接;汇编;进程管理;存储管理;IO管理
目 录
5.5.1 hello.asm和hello.o.asm文件对比
第1章 概述
1.1 Hello简介
1.1.1 P2P(Program to Process):
1. 预处理(Preprocessing):
`gcc -E hello.c -o hello.i`:预处理阶段,处理宏定义、头文件包含和条件编译等,生成预处理后的文件`hello.i`。
2. 编译(Compilation):
`gcc -S hello.i -o hello.s`:将预处理后的C代码转化为汇编代码,生成汇编文件`hello.s`。
3. 汇编(Assembly):
`gcc -c hello.s -o hello.o`:将汇编代码转化为目标代码(机器语言),生成目标文件`hello.o`。
4. 链接(Linking):
`gcc hello.o -o hello`:链接阶段,将目标文件与所需的库文件链接,生成可执行文件`hello`。
5. 加载(Loading):
当执行`./hello`时,操作系统将可执行文件加载到内存中,并为其分配地址空间。
6. 进程创建(Process Creation):
通过fork系统创建进程;新进程通过execve系统调用将其地址空间替换为新程序的地址空间。
1.1.2 O2O(Object to Output):
1. 运行可执行文件:在Shell中输入`./hello`,创建子进程。
2. 进程创建:Shell使用`fork()`创建子进程。
3. 加载程序:子进程使用`execve()`加载`hello`程序,将子进程地址空间替换为`hello`程序的地址空间。
4. 执行程序:系统为`hello`分配虚拟内存,CPU开始执行`hello`的指令,程序通过系统调用进行输入输出操作。
5. 程序执行完成:`hello`程序运行结束,调用`exit()`退出。
6. 回收资源:父进程使用`wait()`或`waitpid()`等待子进程结束并回收资源,子进程终止。
1.2 环境与工具
硬件环境:
处理器 12th Gen Intel(R) Core(TM) i7-12700H 2.30 GHz |
系统 64 位操作系统, 基于 x64 的处理器 |
RAM 16.0 GB |
软件环境:Windows 11,VMware Workstation 17 pro,Ubuntu 22.04.4
开发调试工具:gdb,edb,vim,gcc,Visual Studio 2022,CodeBlocks
1.3 中间结果
hello.c:源代码文件
hello.i:预处理后的文本文件
hello.s:编译后的汇编文件
hello.o:汇编后的可重定位目标文件
hello:可执行文件
hello.asm:hello的反汇编代码
hello.o.asm:hello.o的反汇编代码
hello.elf:hello的ELF信息
hello.o.elf: hello.o的ELF信息
1.4 本章小结
本章对hello的P2P和O2O过程,实验使用的环境与工具和实验中产生的文件进行了简要的概述说明。
第2章 预处理
2.1 预处理的概念与作用
2.1.1概念:
预处理是编译过程中的第一个阶段,主要用于处理源代码中的预处理指令和宏定义。预处理器会扫描源代码,识别并处理以`#`开头的预处理指令,然后生成一个新的预处理后的源文件,这个文件将被用于后续的编译阶段。预处理不生成可执行代码,但它会对源代码进行一系列文本替换和操作。
2.1.2作用:
预处理的主要作用包括以下几个方面:
1. 宏定义和替换:
通过`#define`指令,程序员可以定义常量或宏。预处理器在源代码中遇到宏名时,会将其替换为宏定义的内容。这简化了代码的编写和维护,因为可以使用简短的宏名代替复杂或多次重复的表达式。
2. 文件包含:
使用`#include`指令,可以将其他文件的内容包含到当前文件中。`#include`有两种形式:`#include <filename>`用于包含系统头文件,`#include "filename"`用于包含用户定义的文件。这使得代码可以被模块化,头文件可以定义常用的函数和变量声明,从而促进代码的重用。
3. 条件编译:
条件编译指令如`#if`、`#ifdef`、`#ifndef`、`#else`、`#elif`和`#endif`允许程序员根据特定条件有选择地编译代码段。这在编写跨平台代码时特别有用,因为可以针对不同的平台或环境编译不同的代码部分。
4. 行控制:
预处理指令`#line`可以改变预处理器报告的行号和文件名,主要用于调试目的或者处理自动生成的代码。这样,程序员可以在错误信息中看到自定义的行号和文件名,便于跟踪源代码。
5. 错误提示:
使用`#error`和`#warning`指令,程序员可以生成自定义的错误或警告信息。如果在预处理过程中遇到这些指令,编译器将输出相应的错误或警告消息并停止编译。这有助于在代码不满足某些条件时提供即时反馈。
6. 注释删除:
在预处理阶段,所有的注释都会被删除。这减少了源代码的大小,并使得后续的编译阶段只处理纯代码内容,而不受注释的干扰。
2.2在Ubuntu下预处理的命令
预处理命令:gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC -E hello.c -o hello.i
图2.2-1 预处理命令
图2.2-2 文件显示
图2.2-3 hello.i文件
2.3 Hello的预处理结果解析
hello.i文件一共有3902行,大致可以分为四个部分(前三部分是头文件或其间接引用的内容)。
首先是头文件中间接引用的文件路径,如下图所示:
图2.3-1 hello.i中外部库路径(部分)
然后是一些类型的重命名和结构体的定义,如下图所示:
图2.3-2 hello.i文件中类型的重命名(部分)
图2.3-3 hello.i文件中结构体的定义(部分)
其次是一些外部函数的引入,如下图所示:
图2.3-4 hello.i文件中外部函数的引入(部分)
最后是hello的C语言程序,如下图所示:
图2.3-5 hello.i文件中的C语言程序
2.4 本章小结
本章对预处理阶段详细分析,先介绍了预处理的概念和作用,再说明预处理的指令,然后对于处理结果进行解析并对hello.i文件的内容进行介绍。hello.i文件与hello.c文件相比,内容复杂很多,这体现了预处理的重要性——可以为后续程序编写节省大量时间,使hello程序可以在机器中成功运行。
第3章 编译
3.1 编译的概念与作用
3.1.1概念:
编译阶段,编译器(ccl)会将hello.i翻译成汇编语言程序hello.s。汇编代码以文本格式描述低级机器语言指令。编译过程可以分为6个阶段,词法分析,语法分析,语义分析,中间代码生成,代码优化和目标代码生成。
3.1.2作用:
编译阶段,编译器将高级程序语言翻译为统一的,接近机器语言,对机器友好的汇编代码,使机器更容易理解,为汇编做准备。
3.2 在Ubuntu下编译的命令
编译命令:gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC -S hello.i -o hello.s
图3.2-1 编译命令
图3.2-2 文件显示
图3.2-3 hello.s文件
3.3 Hello的编译结果解析
3.3.1文件信息
图3.3.1-1 文件信息
首先是记录文件相关信息的汇编代码,为之后链接过程使用。其中.file表明了源文件,.text代码段,.section .radata只读代码段,.align对齐方式为8字节对齐,.string字符串,.global全局变量,.type声明main是函数类型。
3.3.2数据
3.3.2.1常量
(1)程序中用到了字符串常量“用法: Hello 学号 姓名 手机号 秒数!\n”和“Hello %s %s %s\n”,它们分别存储在.LC0,.LC1所标记的位置,如图3.3.2.1-1所示。第一个字符串输出时会到.LC0所标记的位置读取,考虑到printf中不含其他的格式化参数,编译器会直接将其优化为puts函数,如图3.3.2.1-2所示。同理,第二个字符串输出时会到.LC1标记的位置读取,调用printf函数进行输出,如图3.3.2.1-3所示。
图3.3.2.1-1 字符串常量
图3.3.2.1-2 第一个字符串输出
图3.3.2.1-3 第二个字符串输出
(2)程序中还是用到了数字常量5和10,如图3.3.2.1-4所示。它们在汇编代码中的表示如图3.3.2.1-5所示。
图3.3.2.1-4 源程序中的整型常量
图3.3.2.1-5 汇编中的整型常量
3.3.2.2变量
程序中使用了局部变量i,它的存储位置为%ebp,如下图所示。
图3.3.2.2-1 汇编中的变量
3.3.3赋值
hello程序中使用了i=0赋值操作,它在汇编代码中的表示如下图。
图3.3.3-1 汇编中的赋值操作
3.3.4算数操作
hello程序中使用了i++算数运算操作,它在汇编代码中的表示如下图。
图3.3.4-1 汇编中的算数操作
3.3.5关系操作
hello程序中使用了多次关系操作,如图3.3.5-1所示。它们在汇编中的表示如图3.3.5-2。
图3.3.5-1 源程序中的关系操作
图3.3.5-2 汇编中的关系操作
3.3.6数组/指针操作
hello中使用了argv数组,其中存放的是字符串的首地址(存放的是指针)。其在汇编中的表示如图3.3.6-1。从中可以看出,由于%rbx中存储的是argv数组的首地址,从而(%rbx)就相当于对存储地址的解引用,进而通过加减8的整数倍可以得到各个字符串的首地址(64位系统)。
这里可以观察到多出来一个参数传递,经过查找得知由于调用的函数为__printf_chk,第一个参数是为了防止栈溢出。
图3.3.6-1 argv数组在汇编中的表示
3.3.7控制转移
(1)程序中使用了if/else语句,相关源代码和汇编代码如下图所示。
图3.3.7-1 if/else语句
图3.3.7-2 if/else语句的汇编代码
(2)程序中还使用了for循环,相关源代码和汇编代码如下图所示。
图3.3.7-3 for循环语句
图3.3.7-4 for循环汇编代码
3.3.8函数调用
程序调用了printf函数,exit函数,atoi函数,sleep函数和getchar函数,下面在源代码和汇编代码中对它们进行了标注。由于编译器会对代码进行优化,所以汇编代码中出现的函数与源程序有出入。具体如下图所示。
图3.3.8-1 源代码中的函数调用
图3.3.8-2 汇编代码中的函数调用
3.3.9类型转换
(1)程序中使用了atoi函数。编译阶段,编译器对程序进行了优化,将atoi函数替换为例strtol函数。对于该函数的参数传递,查找资料得知atoi()与使用strtol(nptr,(char**)NULL,10)结果相同,从而得到了合理的解释。
图3.3.9-1 atoi函数
图3.3.9-2 strtol函数的参数传递
(2)隐式类型转换。atoi函数的返回值类型为int,而sleep函数要求参数类型为unsigned int,从而这里存在隐式类型转换。但汇编代码中找不到体现。原因也很好理解,只是数据二进制的解释形式发生了变化。
图3.3.9-3 隐式类型转换
3.4 本章小结
本章介绍了编译的概念和作用。然后,使用编译命令,在Ubuntu下对hello.i进行编译,得到了hello.s文件。
最后,根据hello.s文件中出现的文件信息、数据、赋值、算术操作、关系操作、数组/指针操作、控制转移、函数调用和类型转换,对比分析了源程序和汇编代码。
第4章 汇编
4.1 汇编的概念与作用
4.1.1概念
汇编阶段,汇编器(as)将汇编代码翻译为了二进制的机器语言,并将这些机器语言打包为可执行可链接格式(ELF),存储在可重定位目标文件中。
4.1.2作用
在汇编阶段,hello实现了从汇编程序到二进制机器语言的转换,使机器可以真正理解hello程序。
4.2 在Ubuntu下汇编的命令
编译命令:gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC -c hello.s -o hello.o
图4.2-1 汇编命令
图4.2-2 文件显示
4.3 可重定位目标elf格式
4.3.1整体介绍
在详细分析hello.o的ELF格式前,先整体介绍一下ELF目标文件格式,如图4.3-1所示(截取自课件)。ELF在解析时可以分为两种视图:链接视图和执行视图。链接视图以节为单位,段头表(程序头表)是可选的,节区头部表必须有;而执行视图以段为单位,段头表(程序头表)必须有,节区头部表可选。
图4.3.1-1 ELF文件格式
4.3.2 ELF头
使用readelf -h hello.o命令查看hello.o的ELF头,得到如下结果。ELF头以Magic开始,Magic的前四个字节表示这是一个ELF文件(0x7f是固定开头,0x45 0x4c 0x46是ELF的ASCII码值),后面的0x02表示这个文件是64位架构,第一个0x01表示文件为小端序,第二个0x01表示版本号,当然这里只显示了hello.o中部分字节,我们可以使用hexdump工具显示之后的内容,如图4.3.2-2所示。
ELF头之后都是一些基本信息,比如字节顺序,机器类型,文件类型等等。我们可以在/usr/include/elf.h文件中找到ELF结构体的定义,如图4.3.2-3所示。
图4.3.2-1 hello.o的ELF头
图4.3.2-2 hexdump查看hello.o
图4.3.2-3 elf.h文件
4.3.3节头表
使用readelf -S --wide hello.o命令查看节头表中的内容。由于此时为链接视图,从而我们可以从节头表中看到ELF中所有节的信息。图中对Type进行标注。
图4.3.3-1 hello.o的节头表
下面对节头表中的信息进行详细解读(elf.h中也有节头表的结构体定义)。
Name | 本节的名称 |
Type | 根据本节的内容和语义对节的种类进行划分 |
Address | 这个节被加载后的虚拟地址 |
Off | 本节在文件中的偏移量 |
Size | 本节的大小 |
ES | 指明该节对应的每一个表项的大小 |
Flg | 本节的访问权限(可读/可写/可执行等) |
Lk | 指向节头表中本节所对应的位置 |
Inf | 指明该节的附加信息 |
Al | 本节的对齐方式 |
表4.3.3-1 节头表中信息解读
图4.3.3-2 elf.h文件
4.3.4重定位节
当汇编器生成 hello.o 后,并不知道数据和代码最终将放在内存中的什么位置,也不知道这个模块引用的外部符号的位置。所以,当汇编器遇到对最终位置未知的目标引用,就会生成一个重定位条目。代码的重定位条目放在.rel.text中,而已初始化数据的重定位条目放在.rel.text中。
使用readelf -r hello.o命令查看hello.o的重定位节,发现它有两个重定位节。
图4.3.4-1 hello.o的重定位节
首先介绍偏移量和加数这两个概念。偏移量指的是需要被修改引用的节(section)的起始位置,而加数则是由汇编器根据重定位类型等信息预先设定的一个值,它将与偏移量结合,共同决定了最终的地址。
接下来,我们探讨重定位条目的类型。在X86_64架构中,重定位条目的类型主要有三种:
R_X86_64_32:这种类型的重定位是针对一个32位绝对地址的引用。在这种情况下,CPU将直接使用在指令中编码的32位值作为有效的地址,无需进行任何额外的修改或计算。
R_X86_64_PC32:这种类型涉及32位的程序计数器(PC)相对地址。它基于程序计数器的当前值与目标地址之间的差值来进行寻址。这种方式常用于实现程序中的相对跳转。
R_X86_64_PLT32:这是过程链接表(Procedure Linkage Table,PLT)的延迟绑定重定位。R_X86_64_PLT32重定位类型特别用于将程序中对动态链接库中函数的调用地址重定位到PLT中的相应条目。这种机制是动态链接中的关键部分,它允许程序在运行时解析外部函数的地址。
4.3.5符号表
符号表中包含hello中定义和引用的符号的信息。使用readelf -s hello.o命令查看。
图4.3.5-1 hello.o的符号表
Value是距定义目标的节的起始位置的偏移量;Size为目标的大小;Type是指符号的类型,通常是数据或函数;Bind表示符号是本地或全局;Vis表示符号的可见性;Ndx表示节,对应readelf用一个整数索引来标识每个节,比如Ndx=1表示.text节;Name表示名称。
4.4 Hello.o的结果解析
使用objdump -d -r hello.o > hello.o.asm命令得到hello.o的反汇编如下(这里为了方便查看,将反汇编代码输出到了hello.o.asm文件中)。发现使用objdump自动把重定位条目加入了汇编代码中。
图4.4-1 hello.o的反汇编代码
将hello.o.asm与第3章的 hello.s进行对比。发现在以下几方面不同。
(1)分支转移不同。hello.s中使用了.L2,.L3和.L6作为跳转标记;而在hello.o.asm中使用地址(不是真实的内存地址)来跳转,如下图所示。
图4.4-2 分支转移的不同
(2)函数调用不同。在hello.s中直接使用函数的名字来进行调用;在hello.o.asm中使用偏移量来进行调用(调用函数距下一条指令的距离),只不过这里还没有进行重定位,所以用0来进行占位。
图4.4-3 函数调用的不同
(3)操作数的进制不同。在hello.s中操作数为十进制;在hello.o.asm中操作数的进制为十六进制。
图4.4-4 操作数进制不同
(4)有无汇编指示符。在hello.s中有汇编指示符;在hello.o.asm中没有汇编指示符。
图4.4-5 汇编指示符
4.5 本章小结
本章介绍了汇编的概念和作用,然后,使用汇编命令,在Ubuntu下对hello.s进行编译,得到了hello.o文件。然后通过gcc命令得到了hello.o可重定位目标文件,并利用readelf命令对其各个部分进行了详细的解读。最后借助objdump反汇编得hello.o.asm文件与hello.s文件进行比较分析。
第5章 链接
5.1 链接的概念与作用
5.1.1概念
链接是将各种代码和数据片段收集并组合为单一文件的过程,由链接器来自动完成,这个单一文件是可以被加载到内存执行的。链接可以执行于编译时,也可以执行于加载时,甚至可以执行于运行时。
5.1.2作用
链接过程主要包含两个关键步骤:符号解析和重定位。首先,符号解析负责将程序中的每个引用与输入的可重定位目标文件中的符号表进行匹配。接着,重定位环节在合并节区后,链接器会调整每个符号的引用,确保它们指向正确的运行时地址。链接器的存在极大地促进了分离编译的实现,使得程序员能够采用模块化设计和编程方法,从而提高团队协作的效率。当需要对软件进行修改或调试时,开发者仅需对特定模块进行修改,然后重新编译并链接该模块,而无需重新编译整个项目,这大大简化了开发和维护的过程。
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
图5.2-1 链接命令
图5.2-2 文件显示
5.3 可执行目标文件hello的格式
使用readelf -a hello > hello.elf和readelf -a hello.o > hello.o.elf命令得到hello和hello.o的ELF描述。对比发现hello.o.elf中多了程序头表,下面将对其简要介绍,其余部分与hello.o类似不再赘述。从节头表中可以看到各段的基本信息,包括各段的起始地址,大小等信息。
图5.3-1 hello和hello.o的ELF头
图5.3-2 hello和hello.o的节头表
图5.3-3 hello和hello.o的重定位节
图5.3-4 hello和hello.o符号表
程序头表中存储着页面大小,虚拟地址等信息。观察程序头表,可以发现有九种程序头。Offset指偏移量,Flags指访问权限,Align指对齐方式,即每个段的起始地址%align要等于偏移量%align。
图5.3-5 hello的程序头表
5.4 hello的虚拟地址空间
使用edb加载hello,如下图所示:
图5.4-1 edb加载hello
在edb中打开Memory Regions,查看每块内存的读写权限。
图5.4-2 edb中的Memory Regions
这里选取.interp和.rodata进行对照分析。从节头中我们可以得到.interp的起始地址为0x4002e0,大小为0x1c;.rodata的起始地址为0x402000,大小为0x48,如下图所示。
图5.4-3 节头表
在edb中找到对应内存,查看内容如下。图中框选的内容就是对应段的内容。
图5.4-4 .interp内容
图5.4-5 .rodata内容
5.5 链接的重定位过程分析
5.5.1 hello.asm和hello.o.asm文件对比
为方便比较分析,使用命令objdump -d -r hello > hello.asm,将反汇编代码存入hello.asm文件中,如下图所示。
图5.5.1-1 hello可执行程序的反汇编代码
经过对比发现,hello.asm和hello.o.asm主要在以下几方面存在差异。
(1)可重定位信息被修改。hello.asm中已经没有可重定位条目,如下图所示。这些可重定位条目包括分支转移,函数调用,字符串常量的使用等。
图5.5.1-2 可重定位条目
(2)两个文件的大小不同。hello.asm有147行,hello.o.asm只有52行。hello.asm中插入了共享库函数的指令,如下图所示。
图5.5.1-3 共享库函数
5.5.2链接过程
正如先前所述,链接过程包含两个核心步骤:符号解析和重定位。
在符号解析阶段,链接器负责将程序中出现的每个符号与输入的可重定位目标文件中的符号表进行匹配。如果在符号表中未能找到对应的符号,链接器将会报错,以确保程序的完整性和正确性。
重定位过程则进一步细分为两个子步骤。首先,链接器执行节和符号定义的重定位,这一阶段中,链接器会将所有可重定位目标文件中的相同类型的节合并为一个统一的聚合节,从而优化内存使用并简化程序结构。其次,链接器进行符号引用的重定位,调整每个符号的引用,确保它们指向正确的运行时地址。这一阶段主要包括两种重定位方式:PC相对引用和绝对引用的重定位。
5.5.3重定位过程分析
(1)先介绍重定位绝对引用。以hello.o.asm中的一个可重定位条目为例。如下图所示。此例的重定位类型为R_X86_64_32,.rodata.str1.8位于0x402008,所以将占位的0直接替换为0x402008,机器为小端序。
图5.5.3-1 重定位绝对引用
(2)再介绍重定位PC相对引用。同样以hello.o.asm中的一个可重定位条目为例。如下图所示。此例中的重定位类型为R_X86_64_PLT32,观察hello.asm,我们可以知道puts函数的绝对地址(r.symbol)为0x401090,refaddr为0x401144,r.addend为-4,从而得出替换值为r.symbol+r.addend-refaddr=0xffffff48(补码)。
图5.5.3-2 重定位PC相对引用
5.6 hello的执行流程
在edb中打开hello程序,设置edb从Application Entry Point处进入调试。
图5.6-1 设置Application Entry Point
然后运行hello程序,程序会在0x4010f0处停止,这与ELF头中的程序入口地址相吻合,此时观察edb寄存器栏,从中我们可以得到当前所处的函数。
图5.6-2 运行时查看当前函数
不断单步运行hello程序,得到各主要函数的地址,如下所示。
函数名 | 地址 | 函数名 | 地址 |
_start | 0x4010f0 | __libc_start_main | 0x00007f9a8cb9bf90 |
__cxa_atexit | 0x00007f9a8cbbede0 | __libc_csu_init | 0x00000000004011b0 |
_setjmp | 0x00007f9a8cbbac80 | main | 0x0000000000401125 |
__printf_chk | 0x4010b0 | strtol | 0x4010a0 |
sleep | 0x4010d0 | getc | 0x4010e0 |
exit | 0x00007f521a771a40 | puts | 0x401090 |
hello!exit@plt | 0x4010c0 |
表5.6-1 各函数地址
根据执行流程绘制如下图。
图5.6-3 流程图
5.7 Hello的动态链接分析
当程序需要调用一个由共享库定义的函数时,编译器在编译阶段无法预知该函数在运行时的具体地址,因为共享库在运行时可以被加载到内存中的任意位置。为了解决这一问题,编译系统采用延迟绑定的技术,它将函数地址的解析推迟到程序首次调用该函数时进行。在这个机制中,全局偏移表(GOT)和过程链接表(PLT)发挥着关键作用。GOT用于存储函数的地址,而PLT则包含了用于调用不同函数的跳转指令。在程序加载时,动态链接器介入,它负责重定位GOT中的每个条目,将它们更新为正确的绝对地址。同时,PLT中的跳转指令确保了对函数的正确调用。通过观察程序的".got.plt"节的变化,我们可以直观地看到动态链接的过程。
通过观察节头表可以得到.got.plt的位置和大小。.got.plt节的起始位置为0x404000,大小为0x48。
图5.7-1 .got.plt节的位置和大小
在edb中查看对应位置。发现有两个字节发生了变动。第一个字节存储的是动态链接器在解析函数地址时会使用的信息;第二个字节是动态链接器ld-linux.so模块中的入口点。
图5.7-2 .got.plt对比
5.8 本章小结
本章先介绍了链接的概念及其作用。通过在虚拟机上进行链接操作,我们成功生成了 hello 可执行程序。接着,本章对 hello 可执行文件和其对应的目标文件 hello.o 的 ELF(可执行和可链接格式)进行了细致的对比分析,特别介绍了程序头表的新特性,并利用 edb 工具深入查看了虚拟地址空间中各个段的具体信息。随后,本章进一步比较了 hello 和 hello.o 的反汇编代码,并结合 hello.o 的重定位项,对 hello 的可重定位过程进行了详尽的分析。这包括了对 PC 相对寻址和绝对寻址重定位机制的探讨,帮助读者理解链接器如何将代码和数据正确地放置到内存中。最后,通过使用 edb 工具对 hello 程序的执行流程和动态链接过程进行分析,本章进一步加深了对 hello 程序工作原理的理解。这一分析不仅展示了动态链接的全过程,也揭示了操作系统如何管理程序的加载和执行。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1概念
进程的定义是一个正在运行的程序实例,系统中的每个程序都运行在某个进程的上下文中。
6.1.2作用
进程提供给应用程序两个关键抽象。第一个是独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;第二个是私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell的作用
Shell 是一个重要的应用程序,它扮演着用户与 Linux 内核之间的桥梁。它不仅提高了用户与操作系统交互的效率,还增强了安全性,并降低了使用成本。Shell 的核心作用在于命令解释:它能够识别并解析用户输入的指令,然后将这些指令传递给内核执行。通过 Shell,用户能够轻松访问操作系统提供的丰富服务。Shell 的存在简化了用户的操作,使得即使是复杂的任务也能通过简单的命令来完成。它提供了一个强大的接口,使用户能够充分利用 Linux 内核的强大功能,实现高效、灵活的系统管理和应用程序开发。
6.2.2 Shell的处理流程
当用户在终端中输入一个命令时,Shell 会首先判断这个命令是内置命令还是外部命令。如果是内置命令,Shell 会利用其内部解释器直接将命令转换为系统功能调用,并提交给内核执行。如果用户输入的是外部命令或实用程序,Shell 则会在硬盘上搜索相应的可执行文件,将其加载到内存中,然后执行。
在执行外部命令的过程中,如果该命令被指定为前台进程,Shell 会通过 fork() 函数创建一个新的子进程。在这个子进程中,Shell 将调用 execve() 函数来加载并执行外部命令。在前台进程运行期间,Shell 会阻塞,等待该进程完成。一旦进程结束,Shell 会在其信号处理程序中使用 waitpid() 函数来回收子进程,确保资源得到正确释放。如果用户指定命令在后台执行,Shell 的行为则有所不同。在这种情况下,Shell 不会等待子进程结束,而是立即返回命令提示符,允许用户继续输入其他命令。这样,后台进程可以独立于 Shell 运行,不会阻塞用户的其他操作。
6.3 Hello的fork进程创建过程
当我们在 Shell 中输入命令 `./hello` 时,Shell 首先会检查这个命令是否为内置命令。如果不是,Shell 会将其视为一个可执行程序的名称,并在当前目录中搜索名为 `hello` 的文件。一旦找到,Shell 就会开始执行这个程序。
在执行过程中,Shell 会调用 `fork()` 函数来创建一个新的子进程。这个子进程将在这个新创建的进程中执行 `hello` 程序。关于 `fork()` 函数,它是一个在 Unix 和类 Unix 系统中非常重要的系统调用。当 `fork()` 被调用时,它会返回两次:在父进程中返回新创建的子进程的进程标识符(PID),而在子进程中返回 `0`。这个特性经常被用来区分父进程和子进程。
`fork()` 函数创建的子进程几乎与当前进程完全相同,但又不完全相同。子进程会获得当前进程虚拟地址空间的一份独立副本,这包括代码段、数据段、堆、共享库以及用户栈,甚至父进程打开的文件描述符。这意味着子进程拥有与父进程相同的环境,但它们是完全独立的执行实体。最大的不同在于,子进程拥有自己的 PID,这是操作系统用来唯一标识进程的数字。这个 PID 的不同是区分父进程和子进程的关键因素。
6.4 Hello的execve过程
确实,仅仅让子进程运行与父进程相同的内容可能有些单调。如果我们希望子进程执行全新的程序,那么 `execve()` 函数就派上用场了。`execve()` 函数的功能是加载并运行指定的可执行目标文件。一旦成功加载并运行,`execve()` 函数本身不会返回,因为它将控制权完全交给了新的程序。
`execve()` 函数调用时,会启动一段特殊的启动代码,这段代码负责设置新的栈环境,并将执行流程传递给新程序的主函数。值得注意的是,尽管子进程将开始执行全新的程序,但 `execve()` 函数并不会改变子进程的 PID,子进程的身份标识保持不变。
以 `hello` 可执行程序为例,假设我们通过 `fork()` 函数创建了一个子进程。在子进程中,我们可以使用 `execve()` 函数来加载并运行 `hello` 程序。此时,内核将介入,它将替换子进程的原始上下文为 `hello` 程序的上下文,并将控制权传递给 `hello` 程序的主函数。
6.5 Hello的进程执行
在操作系统中,进程是一个关键的抽象概念,它代表了独立的逻辑控制流。当操作系统同时运行多个进程时,单个物理处理器的控制流被虚拟化为多个逻辑控制流,以便这些进程可以交替执行,从而给每个程序一种仿佛独占处理器的错觉。每个进程执行其控制流的一段特定时间被称为时间片。例如,当 hello 程序开始执行时,操作系统会为其分配一个时间片,允许它在这段时间内运行。
图6.5-1 逻辑控制流(教材图)
操作系统内核通过一种称为上下文切换的技术来实现多任务处理(多进程)。上下文是内核重新启动一个被抢占的进程所需的状态信息,这包括寄存器、程序计数器等关键数据。假设 CPU 正在执行 hello 程序中的某条指令,如果操作系统认为 hello 进程已经运行了足够的时间(或者执行了 sleep 函数),这时操作系统会将程序从用户模式转换到内核模式。内核中的调度器随后执行上下文切换,保存当前进程的状态信息,然后恢复另一个之前被暂停的进程所保存的状态,接着再次从内核模式转换回用户模式,将控制权交给这个之前被抢占的进程。
图6.5-2 进程上下文切换(教材图)
6.6 hello的异常与信号处理
异常控制流可以分为四种:异常,进程,信号和非本地跳转。异常位于硬件与操作系统交界的部分,可分为中断,陷阱,故障和终止;信号位于应用和操作系统的交界之处。由于异常和信号数量很多,这里选取三个进行说明。
6.6.1正常运行
在shell中输入命令./hello 2022112864 锁千棋 17756861582 2,程序执行结果如下。程序运行后每隔2秒输出一个Hello 2022112864 锁千棋 17756861582。在输出10个之后,程序会等待用户输入一个字符,读取到字符后程序终止。
图6.6.1-1 程序正常运行结果
6.6.2乱按键盘
在程序执行时乱按键盘,程序不会受任何影响。当在shell中输入命令./hello 2022112864 锁千棋 17756861582 2后,shell会将hello作为前台进程开始执行。在此期间,shell对乱按键盘输入的东西不会做任何处理,直到hello进程结束,shell才会对输入的字符串进行解析(如果输入回车),如下图所示。
图6.6.2-1 乱按键盘
6.6.3 Crtl-Z
当程序运行时输入Crtl-Z的结果如下所示。当键盘输入Crtl-Z时,shell会接收到SIGTSTP信号,并且shell会将这个信号转发给前台进程组中的所有进程,挂起前台进程组。
图6.6.3-1 输入Crtl-Z后程序运行结果
使用ps和jobs命令进行查看,得到如下结果。
图6.6.3-2 jobs和ps命令执行结果
再使用pstree命令查看。pstree是一个Linux下的命令,通过它可以列出当前的进程,以及它他们的树状结构。
图6.6.3-3 pstree命令执行结果
为让停止的前台进程组继续运行,我们可以输入fg %1或fg 1(%可有可无)让停止的hello继续运行,如下所示。
图6.6.3-4 fg命令执行结果
在hello进程停止后,可以使用kill命令向其发送信号,比如:终止信号(SIGINT),发送SIGINT信号后,ps中就看不到hello进程了。每种信号都对应一个序号,9对应着SIGINT,9291是hello进程的PID。补充:若在PID前加上负号,信号会发送到进程组PID中的每个进程。
图6.6.3-5 kill内置命令
6.6.4 Crtl-C
当程序运行时从键盘输入Crtl-C,程序运行结果如下。此时shell会接收到SIGINT信号,并且shell会将这个信号转发给前台进程组中的所有进程,终止前台进程组。之后使用ps和jobs命令进行查看。
图6.6.4-1 Crtl-C
6.7本章小结
本章内容首先深入探讨了进程的基本概念及其在操作系统中的核心作用,随后详细介绍了 Shell(以 bash 为例)的功能和它的处理流程。通过具体分析 `hello` 程序的 `fork` 和 `execve` 过程,进一步阐释了 Shell 是如何处理命令以及操作系统如何进行上下文切换和逻辑控制流的管理。在深入理解了 Shell 和进程的工作原理后,本章通过对比 `hello` 程序在正常运行时与用户在执行过程中按下 `Ctrl-Z`、`Ctrl-C` 或者随意按键时的不同反应,简要介绍了异常控制流的概念。这种对比不仅展示了进程在面对不同用户输入时的行为差异,也揭示了操作系统如何处理异常情况,确保系统的稳定性和响应性。
第7章 hello的存储管理
7.1 hello的存储器地址空间
在Linux系统中,内存管理采用了一种相对简化的分段机制,这导致逻辑地址、线性地址和虚拟地址在数值上是一致的。
7.1.1 逻辑地址
逻辑地址是编程时开发者使用的地址,它由段地址和段内偏移两部分组成,格式通常表示为 `[段地址:段内偏移]`。在调试工具如 `edb` 中,地址通常以逻辑地址的形式呈现。在Linux系统中,由于所有段的起始地址都被设置为 `0x0`,因此逻辑地址直接等同于线性地址。例如,在分析 `hello` 程序的反汇编代码时,所看到的地址实际上就是逻辑地址。
7.1.2 线性地址
线性地址是一个非负整数地址的有序集合,它构成了逻辑地址到物理地址转换的中间层。线性地址是处理器能够直接寻址的内存空间(线性地址空间)中的地址。在 `hello` 程序的反汇编代码中,地址同样可以被理解为线性地址。
7.1.3 虚拟地址
虚拟地址是现代操作系统提供的一种内存抽象,它允许程序在执行时使用抽象的地址而不是直接映射到物理内存的地址。在Linux中,虚拟地址在数值上与线性地址相同,它们会通过内存管理单元(MMU)的处理转换为物理地址。
7.1.4 物理地址
物理地址是主存中的实际地址,主存被组织成一个由M个连续的字节大小的单元组成的数组,每个字节单元都被赋予了一个唯一的物理地址。无论是指令的读取还是数据的访问,最终都需要通过物理地址来实现。
7.2 Intel逻辑地址到线性地址的变换-段式管理
下面的讲述都是基于Intel 32位情况下的。
由上文可知,逻辑地址由两部分构成,段地址和段内偏移。逻辑地址一共有48位,其前16位被称为段选择子,它被用来得到段地址,这16位的格式如下。其中索引为描述符表的索引(注意在找位置的时候要乘8);TI用于区分全局描述符表(GDT)和局部描述符表(LDT)。若TI为0,描述符表为GDT,若TI是1,则描述符表为LDT;请求特权级(RPL)代表选择子的特权级,共有4个特权级(0级、1级、2级、3级),0级最高,CPU只能访问同一特权级或级别较低特权级的段。
图7.2-1 段选择子格式
例如:给出逻辑地址:0x21:0x12345678,需要将其转换为线性地址。段选择子0x21=0000000000100 0 01b,它代表的意思是:段选择子的索引为4,选择GDT中的第4个描述符;最右边的01b代表特权级RPL为1级。段内偏移为0x12345678,若此时GDT第四个描述符中描述的段基址(Base)为0x11111111,则线性地址=0x11111111+0x12345678=0x23456789。
7.3 Hello的线性地址到物理地址的变换-页式管理
在现代操作系统中,线性地址空间(通常也被称作虚拟地址空间)被划分为多个固定大小的单元,称为“页”。一个线性地址由两部分组成:虚拟页号(VPN)和虚拟页偏移(VPO)。在此假设中,VPO占据p位,而整个虚拟地址空间共有n位。
将线性地址转换为物理地址的过程遵循以下步骤:
1. 页表基址寄存器(PTBR):首先,系统从页表基址寄存器PTBR中提取出当前进程(例如,"hello"进程)的页表首地址。
- 确定VPN:接着,系统取线性地址的前n-p位作为VPN。
3. 页表查找:利用VPN,在页表中进行查找,确定对应的页表项(PTE)。页表项的位置可以通过计算 `VPN * PTE大小 + 页表首地址` 得到。
4. 有效性检查:系统检查找到的页表项PTE是否有效。如果有效,PTE中将包含物理页号(PPN)。
5. 处理无效页表项:如果页表项无效,可能有两种情况:
- 未缓存:这意味着对应的虚拟页尚未缓存到物理页中。这时,系统需要将虚拟页加载到物理页,这个过程会引发一个缺页异常(Page Fault)。
- 未分配:如果虚拟页未被分配,系统将无法找到对应的物理页,通常会报错。
6. 组合PPN和VPO:最后,系统将有效的物理页号PPN与虚拟页偏移VPO组合起来,形成完整的物理地址。
图7.3-1 使用页表的地址翻译(教材图)
下面用书上的图进一步说明页面命中和缺页两种情况。
图7.3-2 两种情况(教材图)
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1 TLB
为进一步节省时间开销,MMU中包括了一个关于PTE的小的缓存,成为快表(Translation Lookaside Buffer)。其中每一行存储着一个由单个PTE组成的块,TLB的组成形式与高速缓存很相近。用于组选择和行匹配的索引和标记字段都是从虚拟地址的虚拟页号中提取出来的。
图7.4.1-1 虚拟地址中用以访问TLB的组成部分(教材图)
它的具体操作方法同样用书上的图加以说明。若TLB不命中,MMU会从L1高速缓存中取出相对应的PTE,更新TLB,此过程可能会导致一个原来存在的条目被驱逐。去到对应的PTE后,将PPN和VPO相组合得到物理地址。
图7.4.1-2 TLB命中和不命中操作图(教材图)
7.4.2四级页表
在一级页表的设计中,系统为每个虚拟地址都分配一个页表项(PTE),即便应用程序只使用了一小部分的虚拟内存空间,整个页表仍然会占用大量的连续物理内存。这种做法在内存资源有限的情况下显得非常低效。
为了提高内存利用率,多级页表技术应运而生。它通过引入间接层级来减少所需的内存空间。在32位系统中,通常采用两级页表结构;而在64位系统中,则可能采用四级页表结构。这种设计允许系统只为当前正在使用的虚拟地址空间分配页表项,从而大幅减少了内存的占用。
在多级页表结构中,除了最后一级页表直接存储物理页号(PPN)外,其他各级页表存储的都是它们下一级页表的起始地址。这样,只有当访问到具体的虚拟地址时,才会逐级查询各级页表,直到找到对应的PPN。
第一级页表的地址通常存储在页表基址寄存器(PTBR)中,它为内存管理单元(MMU)提供了快速访问第一级页表的入口。
下面以书上的图进一步进行说明。
图7.4.2-1 多级页表(教材图)
7.4.3 Core i7地址翻译情况
此过程将多级页表和TLB相结合,如下图所示。其中CR3是一个寄存器,存储第一级页表的地址。
图Core i7地址翻译的概况(教材图)
7.5 三级Cache支持下的物理内存访问
上文我们分析了从虚拟地址到物理地址的变换。在得到物理地址后,将其送给L1缓存,从物理地址中取出标记、组索引信息进行匹配。如果对应组中有一路的标记与物理地址相匹配,且该路的有效位为1,则Cache命中,根据块偏移取出一定数量的数据返回给CPU。如果Cache不命中,继续去下一级存储中查询。若在下一级查找成功,则将数据加载入上一级缓存(此时还要看上一级缓存有没有满,若上一级缓存已经存满,则要驱逐某个路),进一步传递给CPU。
图7.5-1 高速缓存读取(PPT)
7.6 hello进程fork时的内存映射
当我们在操作系统中调用`fork`函数来创建一个新的`hello`进程时,内核执行了一系列精心设计的步骤来确保新进程的创建既高效又安全。首先,内核为`hello`进程创建了必要的数据结构,并赋予它一个独一无二的进程标识符(PID),标志着`hello`进程独立生命周期的开始。
为了构建`hello`进程的虚拟内存空间,内核采取了高效的方法:它创建了当前进程的内存管理结构`mm_struct`、区域结构和页表的精确副本。这些副本确保了`hello`进程拥有了自己的内存布局信息,包括堆、栈、代码段等。在复制过程中,所有页面初始被设置为只读,以防止立即发生写操作导致的数据不一致。同时,每个区域结构都被标记为私有的写时复制(Copy-On-Write, COW),这意味着只有当任一进程尝试写入数据时,相应的页面才会转变为私有的可写状态,并创建一个新的私有副本。
当`fork`函数在`hello`进程中返回时,它拥有的虚拟内存镜像与`fork`调用时完全相同,确保了父子进程在内存使用上的隔离。这种机制不仅优化了内存的使用,还保持了进程间清晰的界限。写时复制策略在进程需要写操作时自动触发,为每个进程生成新的页面,从而维持了私有地址空间的概念,确保了进程间的数据隔离和安全。
图7.6-1 写时复制机制(教材图)
7.7 hello进程execve时的内存映射
`execve`函数是Linux系统中用于执行一个新程序的关键函数。当调用`execve`函数运行`hello`程序时,内核会经历一系列精细的操作来加载并启动新程序,同时替换当前进程的映像。以下是`execve`函数执行的主要步骤:
1. 清除用户区域:首先,内核会移除当前进程用户空间中的现有区域结构,这包括删除虚拟地址空间用户部分中的所有已存在的区域。
2. 创建私有区域:接下来,内核为新程序的代码、数据、BSS(Block Started by Symbol,未初始化数据区)和栈创建新的区域结构。所有这些新创建的区域都是私有的,并且采用写时复制(Copy-On-Write)策略。其中,代码和数据区域直接映射自`hello`程序文件中的`.text`和`.data`段;BSS区域请求二进制零初始化,映射到匿名文件,其大小由`hello`程序定义;栈和堆则请求二进制零初始化,初始长度为零。
3. 映射共享区域:由于`hello`程序与共享对象`libc.so`(C标准库)链接,内核需要将`libc.so`动态链接到程序中,并映射到用户虚拟地址空间的共享区域内。这样,`hello`程序就可以共享并重用`libc.so`中的代码,而无需在每个程序中都包含一份库的副本。
4. 设置程序计数器:`execve`函数执行的最后步骤是设置当前进程的程序计数器(PC)。程序计数器被设置为指向新程序代码区域的入口点,这样当控制权交回给用户空间时,程序将从正确的起始位置开始执行。
图7.7-1 加载器是如何映射用户地址空间的区域的
7.8 缺页故障与缺页中断处理
当处理器尝试访问一个虚拟地址,而该地址在内存管理单元(MMU)的页表中尚未建立映射时,就会触发缺页故障(Page Fault)。这一事件标志着需要从辅助存储(如硬盘)中调入数据到物理内存。面对缺页故障,系统会生成一个异常信号,这时操作系统的内核迅速响应,将控制权移交给专门的缺页异常处理程序。
该处理程序首先检查物理内存,确定是否有足够的空间来加载请求的数据页。如果物理内存中存在空闲区域,操作系统便将虚拟页的内容直接复制到物理内存的这一空闲位置,同时更新页表项以反映新的映射关系,确保虚拟地址到物理地址的正确转换。
然而,如果物理内存已被占满,操作系统则必须采取行动,采用预定义的内存管理算法(例如最近最少使用LRU算法)来选择一个当前不活跃的页进行驱逐。这个被选中的页可能需要先写回到辅助存储设备,以便为新的数据页腾出空间。一旦这样做了,操作系统便将缺失的虚拟页加载到物理内存中,并在页表中创建或更新相应的条目。完成这些步骤后,操作系统将恢复执行引起缺页故障的指令。此时,因为所需的数据已经加载到物理内存中,指令能够顺利执行,程序继续向前推进,而用户对这整个复杂的处理过程毫无察觉。
图7.8-1 缺页故障与处理机制
下面再举一个书上的例子进一步说明。当要从内存中读取VP3时,从有效位判断VP3未被缓存,触发缺页故障。这时缺页故障会调用缺页异常处理程序选择一个牺牲页,在此例中就是VP4。如果VP4被修改过,则内核就会将它复制回磁盘。之后修改VP4的页表条目,将有效位变为0。接下来内核从磁盘复制VP3到内存中的PP3,更新PTE3。异常处理程序返回后重新启动导致缺页的指令,进行正常处理。
图7.8-2 缺页前
图7.8-3 缺页处理后
7.9动态存储分配管理
7.9.1动态内存分配基本概念
当C程序运行时额外需要虚拟内存时,使用动态内存分配器来分配。动态内存分配器维护着一个进程中称为堆(heap)的虚拟内存区域。堆是一个请求二进制零的区域,它紧接在未初始化的数据区域(.bss)后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
图7.9.1-1 堆(教材图)
分配器有两种基本风格,两种风格都要求应用显式的分配块,它们的不同之处在于哪个实体来负责释放已分配的块。
(1)显式分配器,要求应用显式地释放任何已分配的块,例如C语言提供一种叫做malloc程序包的显示分配器。
(2)隐式分配器,要求分配器检测一个已分配块何时不再被程序所使用,其又被称为垃圾收集器。
7.9.2隐式空闲链表
大多数分配器将一些基本信息(块大小,已分配/空闲等)存入堆块中,格式如下。
图7.9.2-1 一个简单的堆块格式(教材图)
隐式空闲链表的结构如下。因为空闲块是通过头部中的大小字段隐含的链接起来的。下图中的阴影部分为已分配块,没有阴影的部分是空闲块,头部标记为(块大小/已分配位)。
图7.9.2-2 用隐式空间链表组织堆(教材图)
7.9.3显示空闲链表
由于隐式空闲链表上块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不合适的,一种更好的方法是将空闲块组织为某种形式的显式数据结构,例如堆可以组织成一个双向空闲链表,如下图所示。
图7.9.3-1 使用双向链表的堆块格式(教材图)
7.10本章小结
在本章中,我们通过一个简单的"hello"程序,深入探讨了计算机系统中的内存地址概念,包括逻辑地址、线性地址、虚拟地址和物理地址。我们首先解释了这些地址如何在Intel 32位体系结构中,通过段式管理从逻辑地址转换为线性地址(也就是虚拟地址)。随后,我们转向了Linux系统的页式管理,详细阐述了线性地址到物理地址的转换过程。
本章还探讨了TLB(Translation Lookaside Buffer,快表)和多级页表的概念及其重要性。我们解释了TLB如何作为页表的高速缓存,加快地址转换的速度,并讨论了在四级页表结构下,虚拟地址到物理地址的转换是如何得到TLB支持的。
进一步地,我们介绍了三级Cache系统如何支持物理内存的访问流程,并以"hello"进程为例,分析了在`fork`和`execve`系统调用期间内存映射的变化。这不仅展示了进程创建和程序执行的内存管理细节,也揭示了操作系统如何高效地利用有限的内存资源。
此外,本章还引用了教材中的例子,详细说明了缺页故障和缺页中断的处理机制。这包括了当进程访问未加载到物理内存中的页面时,操作系统如何响应并加载所需的数据。
最后,我们深入分析了动态存储分配管理。从动态内存管理的基本方法到管理策略,我们全面介绍了操作系统如何动态地分配和回收内存资源,以及这些策略如何影响系统的性能和效率。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:一个Linux文件可以被视为一个由m字节组成的序列,这种抽象使得所有的I/O设备都能够以文件的形式被模型化,甚至内核本身也可以映射为文件。
设备管理:这种优雅的设备映射方式为Linux内核带来了显著的优势。它使得内核能够提供一个简单而低级的应用程序接口,这个接口遵循Unix I/O的标准。Unix I/O以其简洁性和一致性而闻名,它允许开发者通过统一的接口与各种设备进行交互,无论是磁盘、网络接口还是其他硬件设备。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口
(1)打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
(2)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。通过执行seek操作,能够显式地设置文件的当前位置为k。
(3)读写文件:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k。
(4)关闭文件:内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
8.2.2函数
在Unix系统中,文件操作的精髓可以归结为五个基本函数:`open`、`read`、`write`、`lseek` 和 `close`。这些函数构成了Unix I/O操作的核心,使得对文件及设备的操作变得简洁而高效。下面是对这些函数的逐一介绍:
1. `open` 函数:`int open(char* filename, int flags, mode_t mode);` 此函数用于打开一个已存在的文件或创建一个新文件。它将文件名 `filename` 转换为一个文件描述符,并返回这个描述符。返回的描述符通常是当前进程中尚未使用的最小整数。`flags` 参数定义了进程对文件的访问方式,而 `mode` 参数则为新创建的文件设置了访问权限。
2. `read` 函数:`ssize_t read(int fd, void *buf, size_t n);` 此函数从文件描述符 `fd` 指向的文件中读取最多 `n` 个字节到缓冲区 `buf`。成功读取时返回实际读取的字节数;返回 `-1` 表示出现错误;返回 `0` 表示已到达文件末尾(EOF)。
3. `write` 函数:`ssize_t write(int fd, const void *buf, size_t n);` 此函数将最多 `n` 个字节的数据从缓冲区 `buf` 写入文件描述符 `fd` 指向的文件中。它与 `read` 函数相对应,用于将数据输出到文件。
4. `lseek` 函数:`off_t lseek(int fildes, off_t offset, int whence);` 这个函数允许应用程序显式地修改文件的当前读写位置。`fildes` 是一个已打开的文件描述符,`offset` 是要移动的字节数,`whence` 指定了移动的基准点(如文件开头、当前位置或文件末尾)。
5. `close` 函数:`int close(int fd);` 此函数用于关闭文件描述符 `fd` 所指向的文件。关闭文件是一个重要的操作,它释放了与文件描述符相关联的资源,并确保所有挂起的数据都已正确写入。
通过这五个函数,Unix系统提供了一种统一的接口来处理文件和设备,极大地简化了编程模型,同时也提高了代码的可移植性和可维护性。这种设计哲学是Unix哲学的一个缩影,即“一切皆文件”的理念,它将复杂的设备操作抽象为简单的文件读写操作,为开发者提供了极大的便利。
8.3 printf的实现分析
先来看printf的函数体,如下图所示。
printf函数的参数为可变形参(出现了...),当传递参数不确定时就可以用这种形式来表示。接下来看蓝框框住的内容,va_list使用char *定义的,因此这句话的意思就是让arg指向fmt后第一个参数的位置。然后看下面一句,调用了vsprintf函数,下面结合图8.3-2(这里的代码只显示了对十六进制的格式化)分析vsprintf函数。若格式化串当前指针处不是%,则直接复制到buf中,反之则根据%后的内容进行格式化(不同的字母代表不同的含义,d代表十进制数字等等)。返回值为输出字符串的长度(printf函数体中i的值)。返回printf函数中后,再调用write函数进行屏幕输出。
图8.3-1 printf函数体(网站截图)
图8.3-2 vsprintf函数(网站截图)
再来详细看看write的反汇编代码,如下所示。在write中最后进行了系统调用,显示格式化了的字符串。
图8.3-3 wirte函数反汇编代码(网站截图)
图8.3-4 syscall(网站截图)
接下来,系统已经确定了所要显示在屏幕上的符号。根据每个符号所对应的ASCII码,系统会从字模库中提取出每个符号的VRAM信息(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。进入getchar函数之后,进程会进入阻塞状态,等待外界的输入。系统开始检测键盘的输入。此时如果按下一个键,就会产生一个异步中断,这个中断会使系统回到当前的getchar进程,然后根据按下的按键,转化成对应的ASCII码,保存到系统的键盘缓冲区。
getchar 函数实际上是通过调用 read 系统函数来实现的,它从标准输入读取下一个字符。getchar 与 getc(stdin) 等价,用于从标准输入流中读取单个字符。当 getchar 被调用时,它会将读取的字符保存在一个静态缓冲区中,并返回该缓冲区的第一个字符。在后续的 getchar 调用中,函数将直接从静态缓冲区中提供字符,而无需再次调用 read 进行系统调用,直到缓冲区中的字符被完全读取。只有当缓冲区为空时,getchar 才会再次调用 read 来填充缓冲区。
8.5本章小结
本章介绍了linux系统下的IO设备的管理方法,讨论了Linux系统中Unix I/O的形式以及实现的模式函数。最后,介绍了printf和getchar两个函数的底层实现分析。printf函数的讨论涵盖了它如何将数据格式化并输出到标准输出设备,而getchar函数的分析则揭示了其如何从标准输入设备读取单个字符。
结论
程序员的旅程始于一行行精心编写的 C 语言代码,它们被保存在一个以 `.c` 结尾的文件中——`hello.c`,这标志着 `hello` 程序生命周期的开始。随后,预处理器、编译器、汇编器和连接器相继登场,将 `hello.c` 转换为一个可执行文件 `hello`,准备被加载器载入内存并执行。
当程序员在 Shell 中输入命令 `./hello 2022112864 锁千棋 17756861582 2` 时,Shell 确定这不是一个内置指令后,通过 `fork()` 函数创建一个新的进程,并为其分配虚拟内存。在这个新进程中,`execve()` 函数被调用,将 `hello` 程序加载到内存中。操作系统随后为这个进程分配时间片,允许 CPU 开始执行程序。
在执行过程中,CPU 从内存中逐条取出指令进行执行,而内存管理单元(MMU)、转换后备缓冲(TLB)和三级缓存等硬件组件忙碌地工作,确保指令的快速加载和执行;I/O 设备、异常处理程序和信号处理器则时刻监控着程序的运行状态。在 `hello` 程序中,`printf` 函数底层触发了一个陷阱(trap),执行 `write` 函数,将预定的内容输出到屏幕上。
程序运行结束后,Shell 对 `hello` 进程进行回收,内核将其从系统中清除,标志着 `hello` 程序生命周期的结束。
通过了解 `hello` 程序的一生,我获得了深刻的认识,对计算机系统的设计与实现有了更加深入的体会。`hello` 程序不仅为我打开了编程的大门,更引领我探索计算机底层系统的奥秘。计算机领域还有许多未知等待我去探索,这些谜题将激励我在未来的计算机学习之旅中不断前行,去揭开它们的神秘面纱。。
附件
hello.c:源代码文件
hello.i:预处理后的文本文件
hello.s:编译后的汇编文件
hello.o:汇编后的可重定位目标文件
hello:可执行文件
hello.asm:hello的反汇编代码
hello.o.asm:hello.o的反汇编代码
hello.elf:hello的ELF信息
hello.o.elf: hello.o的ELF信息
参考文献
- Randal E.Bryant等.深入理解计算机系统(原书第3版)[M]. 北京:机械工业出版社,2016.7:2.
- 计算机系统春季课程PPT
- atoi函数和strtol函数:
atoi,atol,strtod,strtol,strtoul详解_strtol atol-CSDN博客
- readelf命令
Linux命令学习手册-readelf - 知乎 (zhihu.com)
- hexdump命令
Linux命令学习总结:hexdump - 潇湘隐者 - 博客园 (cnblogs.com)
- pstree命令
学习一个Linux命令-pstree - 知乎 (zhihu.com)
- Linux下ELF解读
ELF格式解读-(1) elf头部与节头_elf文件头-CSDN博客
ELF文件结构描述 - yooooooo - 博客园 (cnblogs.com)
- 逻辑地址(分段管理)
Linux 线性地址,逻辑地址和虚拟地址的关系? - 知乎 (zhihu.com)
【构建操作系统】全局描述符表GDT - 知乎 (zhihu.com)
- Unix接口IO及其函数
unix环境下的文件操作的一些函数open()、close()、read()、write()、dup()、fsync()sync()函数_read、write和fsync-CSDN博客
- printf函数