计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业
学 号
班 级
学 生
指 导 教 师
计算机科学与技术学院
2024年5月
摘要
HelloWorld程序象征着编程的起点,本文通过跟踪一个Hello程序的生命周期,来讲述它的发展过程。Hello程序的旅程始于C语言编写的源代码,首先经历预处理阶段,然后转化为.i文件,这是它成长的第一步。随后,它继续演变,从.i文件变为.s文件,即汇编语言文件,这使得它更接近机器语言。在经过编译、汇编和链接等步骤后,Hello程序最终成为一个可执行文件,这标志着它即将进入一个全新的生命周期阶段。
在下一阶段,Hello程序与操作系统进行交互,操作系统像一个发现者,为它创建进程,提供虚拟内存和独立的地址空间。操作系统还为它分配时间片和逻辑控制流,使其能够在系统上自由运行。当进程结束时,Hello程序的生命周期也随之结束,尽管短暂,但它的运行过程是辉煌的。
本文从hello.c源代码的编写开始,详细描绘了Hello程序在成长过程中的每一个变化。
关键词:计算机系统;计算机体系结构;程序生命周期;底层原理;Linux系统;Hello;
(摘要0分,缺失1分,根据内容精彩称都酌情加分0-1分)
自媒体发表截图
目录
2.2在Ubuntu下预处理的命令.............................................................................. 5
3.2在Ubuntu下编译的命令.................................................................................. 6
4.2在Ubuntu下汇编的命令.................................................................................. 7
5.2在Ubuntu下链接的命令.................................................................................. 8
5.3可执行目标文件hello的格式......................................................................... 8
6.2简述壳Shellbash的作用与处理流程........................................................... 10
6.3Hello的fork进程创建过程........................................................................... 10
6.6hello的异常与信号处理................................................................................. 10
7.2Intel逻辑地址到线性地址的变换段式管理................................................... 11
7.3Hello的线性地址到物理地址的变换页式管理............................................. 11
7.4TLB与四级页表支持下的VA到PA的变换.................................................. 11
7.5三级Cache支持下的物理内存访问............................................................... 11
7.6hello进程fork时的内存映射....................................................................... 11
7.7hello进程execve时的内存映射................................................................... 11
7.8缺页故障与缺页中断处理................................................................................ 11
8.1Linux的IO设备管理方法............................................................................... 13
8.2简述UnixIO接口及其函数............................................................................. 13
第1章 概述
1.1 Hello简介
首先简述Hello的P2P,020的整个过程。
P2P 即 “从程序到进程”,特指 hello.c 源代码经系列处理转化为运行时进程的全过程,在 Linux 系统中具体实现如下:
hello 程序的生命周期始于开发者编写的 hello.c 源代码文件,需依次经历预处理、编译、汇编、链接四个核心阶段,最终生成可执行程序。预处理阶段中,预处理器会解析代码中的头文件引用(如 #include)和宏定义,自动整合对应的系统头文件内容,将 hello.c 转换为纯 C 代码中间文件 hello.i;紧接着编译器介入,将 hello.i 中的高级语言代码翻译为汇编语言指令,生成汇编文件 hello.s;随后汇编器将 hello.s 中的汇编指令进一步转换为机器可直接识别的二进制指令集,并打包为可重定位的目标文件 hello.o;最后链接器发挥作用,将 hello.o 与程序依赖的库函数(如 printf 所在的系统库)对应的目标文件进行整合,解决符号引用冲突,生成完整的可执行文件 hello。
当用户在 Shell 中输入运行命令后,操作系统通过调用 fork 函数创建新进程,再通过 execve 函数将上述可执行文件加载至内存,完成从静态程序到动态进程的转变,即 P2P 流程的最终落地。
020全称为FromZerotoZero,初始时内存中并无hello文件的相关内容,这便是“From0”。通过在Shell下调用execve函数,系统会将hello文件载入内存,执行相关代码,实现了从无到有的过程,程序在运行过程中经历大量的异常和信号,对存储器进行读写访问等。当程序运行结束后,hello进程被回收,并由内核删除hello相关数据,这即为“to0”,释放虚拟地址空间,删除相关内容,实现从有到终的过程。
1.2 环境与工具
硬件环境:处理器:AMD Ryzen 7 7840H with Radeon 780M Graphics (3.80 GHz)
机带RAM:16.0GB
系统类型:64位操作系统,基于x64的处理器
软件环境:Windows11 64位,VMware,Ubuntu 64位
开发与调试工具:VSCode,vim,gidit ,objdump,edb,gcc,readelf等开发工具
1.3 中间结果
| hello.i | hello.c预处理后得到的文本文件 |
| hello.s | hello.i编译后得到的汇编语言文件 |
| hello.o | hello.s汇编后得到的可重定位目标文件 |
| hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
| hello2.elf | 由hello可执行文件生成的.elf文件 |
| hello.asm | 反汇编hello.o得到的反汇编文件 |
| hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
1.4 本章小结
本章首先对Hello的P2P(从程序到进程)和020(从无到无)流程进行了全面介绍,涵盖了流程的设计思路与实现方法。随后,详细阐述了本实验所需的硬件配置、软件平台以及开发工具,并对实验过程中生成的各个中间结果文件的名称及其功能进行了说明。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
从字面上理解,预处理就是在源程序被编译器正式处理之前,由预处理器(cpp)根据源文件中的宏定义、条件编译等指令对源文件进行预先的修改和处理。这一阶段会执行诸如宏替换、头文件包含、条件编译以及注释删除等预处理命令。需要特别强调的是,预处理过程发生在源代码被转换为二进制代码之前。
2.1.2 预处理作用
预处理的核心作用主要体现在三方面:
- 文本整合与替换:解析#include指令,将指定头文件(如系统头文件stdio.h)的内容完整嵌入当前源代码中,解决代码对外部声明的依赖;解析#define宏定义,将代码中所有宏标识符替换为对应文本或常量,简化代码编写并统一配置。
- 条件编译处理:依据#ifdef、#ifndef、#if等条件指令,选择性保留或剔除代码片段,可实现跨平台适配、调试代码开关等功能,减少最终可执行程序的冗余。
- 代码规范化:移除源代码中的注释、多余空白字符,规整代码格式,同时处理#line、#error等辅助指令,为后续编译阶段提供干净、统一的输入,避免编译过程因非核心语法问题出错。
2.2在Ubuntu下预处理的命令
在Ubuntu系统下,进行预处理的命令为:
cpphello.c>hello.i
运行截图如下:

图1输入预处理命令
2.3 Hello的预处理结果解析
在Linux环境下打开hello.i文件,我们能够发现hello.i程序已经拓展为3061行,相比于hello.c文件的行数有了大幅度的增加。其中,hello.c中的main函数相关代码在hello.i程序中对应着3048行到3061行。

图2hello.i的main函数相关代码
在程序进入 main 函数执行之前,代码中引入的多个头文件(例如 stdio.h、unistd.h、stdlib.h)会按照代码中声明的顺序依次完成展开操作。以 stdio.h 头文件的展开过程为例:预处理器(CPP)会先将源代码中的 #include <stdio.h> 包含指令移除,随后依据 Ubuntu 系统预先配置的环境变量所指定的路径,搜索 stdio.h 头文件的位置。当定位到位于 /usr/include/stdio.h 的目标文件后,预处理器会打开该文件并将其内容完整嵌入到原代码的对应位置。若在 stdio.h 文件内部包含了 #define 定义的宏指令,预处理器会以递归的方式持续展开这些宏,直至所有宏定义都完成正确替换。除此之外,预处理器还会执行一系列辅助处理任务,比如剔除源代码中的注释内容、清理多余的空白字符,同时对代码中需要替换的特定值完成相应的替换操作,为后续的编译阶段做好准备。
2.4 本章小结
本章主要介绍了高级语言程序在翻译称为可执行目标文件的四个阶段中的第一个阶段——预处理的概念及作用、并结合Ubuntu系统下hello.c文件实际预处理之后得到的hello.i程序对预处理结果进行了解析。
(第2章0.5分)
第3章编译
3.1编译的概念与作用
3.1.1 编译的概念
编译是 C 语言程序从源代码向可执行程序转换过程中的核心阶段,指编译器将预处理后无冗余的纯 C 语言代码(如 hello.i),按照目标 CPU 指令集规则,翻译为汇编语言代码(如 hello.s)的过程。这一过程并非简单的文本转换,而是先对代码进行语法校验、语义分析以确保逻辑合法,再将高级语言中抽象的语法结构(如循环、函数调用、条件分支)拆解为与机器指令一一对应的汇编指令序列,同时通过基础优化提升指令执行效率,最终生成贴近硬件执行逻辑、人类可读且机器可识别的汇编语言文件,为后续汇编阶段的二进制转换奠定基础。
3.1.2 编译的作用
编译器是连接 C 语言高级代码与底层机器指令的核心工具,核心作用是将预处理后的纯 C 代码转换为适配目标 CPU 架构的汇编语言代码,同时完成三项关键工作:
- 校验代码的语法和语义合法性,及时检出并提示语法错误、变量未定义等问题;
- 对代码进行优化,比如删除冗余指令、调整指令顺序,提升后续程序的运行效率;
- 针对不同 CPU 架构生成对应的汇编指令,保证指令与硬件平台兼容。
3.2在Ubuntu下编译的命令
在Ubuntu系统下,进行预处理的命令为:
gcc -S hello.c -o hello.s
运行截图如下:

图3输入编译命令
3.3Hello的编译结果解析
编译过程是整个过程构建的核心部分,编译成功,源代码会从文本形式转换为机器语言。下面是hello.s汇编文件内容:
3.3.1对文件信息的记录
首先是记录文件相关信息的汇编代码,为之后链接过程使用。其中.file表明了源文件,.text代码段,.section.radata存放只读变量,.align对齐方式为8字节对齐,.string字符串,.global全局变量,.type声明main是函数类型。

图4hello.s文件信息的记录
3.3.2对局部变量的操作

图5hello.c文件内容

图6局部变量的操作
局部变量存储在栈中,当进入函数main的时候,会根据局部变量的需求,在栈上请求一段空间供局部变量使用,正如图5中第12行inti,i为局部变量,在hello.s中可以看到,第32行jmp.L3跳到了.L3的位置,然后存储i这个局部变量,也就是将栈指针减4,然后再跳转到.L4进行之后的操作。
3.3.3对字符串常量的操作
在main函数前,在.rodata处的.LC0和.LC1已经存储了字符串常量,标记该位置是代码是只读的。在main函数中使用字符串时,得到字符串的首地址,如下第26行和第44行。

图7字符串常量的操作
3.3.4对立即数的操作
立即数直接用$加数字表示。

图8立即数操作
如第21行,立即数32就用$32表示。
3.3.5参数传递———对main函数的参数argv的传递
在main函数的开始部分,因为后面还会使用到%rbp数组,所以在都16行pushq先将%rbp压栈保存起来。21行将栈指针减少32位,然后分别将%rdi和%rsi的值存入栈中。
由此我们可以知道,%rbp20和%rbp32的位置分别存了argv数组和argc的值。

图9参数传递
3.3.6数组的操作
程序中涉及的数组为charargv[],即函数的第二个参数。在hello.s中,其首地址保存在栈中。如图10即为数组的存放,图11即为数组访问时通过寄存器寻址的方式来访问。

图10数组的存放

图11数组的访问
3.3.7函数操作
C语言中,调用函数时进行的操作如下:
- 传递控制:
进行过程Q的时候,程序计数器必须设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
- 传递数据:
P必须能够向Q提供一个或多个参数,Q必须能够向P中返回一个值。
- 分配和释放内存:
在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
另附64位系统下的参数传递顺序:
| 1 | 2 | 3 | 4 | 5 | 6 | >=7 |
| %rdi | %rsi | %rdx | %rcx | %r8 | %r9 | 栈空间 |
具体到hello.s中,例如程序入口处,调用了main函数,其在hello.s中标注为@function函数类型。之后又调用puts,printf,sleep,exit,getchar函数,对函数的调用都通过call指令进行。如printf函数的调用,取得argv数组的第二个和第三个元素放入寄存器%rsi和%rdx,然后第44行取得了字符串的地址,并存入了%rdi作为第一个参数,这样三个参数都准备好后,用call指令调用了printf函数。

图12函数操作
3.3.8for循环
for 循环是编程中应用广泛的循环结构,能够在指定条件成立时,重复执行某一段代码逻辑。该结构的实现流程包含多个关键环节,具体为循环变量初始化、循环条件校验、循环体代码执行以及循环变量更新,这些环节在汇编语言层面也有对应的实现方式,具体如下:
1.初始化循环变量:
在for循环开始之前,需要初始化循环变量。在汇编语言中,这通常涉及到将一个初始值加载到一个寄存器中。
2.执行循环体:
然后执行循环体中的代码。这部分代码可以是任何操作,比如计算、条件判断等。
3.更新循环变量:
每次循环结束后,需要更新循环变量。这通常涉及到使用add,sub等指令来增加寄存器中的值。
4.条件检查:
在每次循环之后,需要检查循环是否应该继续。这是通过将循环变量与某个条件进行比较来完成的,通常使用cmp指令。
5.判断是否继续循环:
cmp指令会设置处理器的状态标志,根据这些标志,可以使用条件跳转指令(如jle,表示“如果小于或等于则跳转”)来决定是否继续执行循环。
6.退出循环:
如果条件不满足,循环将退出,程序将继续执行循环之后的代码。
这个过程在汇编语言中非常常见,因为它提供了对硬件的直接控制,允许程序员以非常精确的方式编写代码。

图13for循环操作
3.3.9赋值操作
赋值操作很简单,用movq指令即可,将a寄存器的值赋值给b寄存器,用movqab(以8字节为例)。具体到hello.s中,例如第59行,将0赋值给%rax寄存器。

图14赋值操作
3.4本章小结
本章节围绕编译的概念与作用展开深入探究,明确指出编译的核心是将文本格式的源代码转换为汇编语言代码,而这一步骤正是生成可执行二进制文件的不可或缺的关键环节。本章以Ubuntu系统下的hello.s文件为分析对象,详细阐释了编译器对各类数据类型及操作的识别与处理方式,由此清晰呈现出数据与操作在汇编层面的具体实现逻辑。可以说,编译是高级语言程序向可执行文件转化过程中至关重要的一环。(第3章2分)
第4章汇编
4.1汇编的概念与作用
4.1.1汇编语言的基本概念
汇编语言是面向特定 CPU 架构的低级编程语言,是机器语言(二进制指令)的 “符号化表示”—— 它用人类可读的助记符(如mov表示数据移动、add表示加法、jmp表示跳转)替代机器语言的二进制编码,同时保留机器指令的核心逻辑,是连接高级语言与硬件底层指令的桥梁。与高级语言(如 C 语言)的抽象性不同,汇编语言直接映射目标硬件的指令集、寄存器、内存地址等底层资源,无语法糖和抽象封装,完全贴合硬件的执行规则。
4.1.2汇编语言的功能
- 精准映射机器指令:每条汇编指令都与一条机器语言指令一一对应,编译器将高级语言转换为汇编语言后,汇编器可直接将其翻译为机器能执行的二进制目标代码,是高级语言代码落地为硬件可执行指令的关键中间载体;
- 直接操控硬件资源:程序员可通过汇编指令直接操作 CPU 寄存器、内存地址、I/O 端口等底层资源,实现对硬件执行流程的精细化控制(如指定数据存储到某一寄存器、手动管理栈空间),这是高级语言难以实现的;
- 支撑程序性能优化:汇编语言剔除了高级语言的冗余封装,可针对特定场景精简指令序列、优化执行逻辑(如调整指令顺序适配 CPU 流水线),常用于对执行效率、资源占用要求极高的场景(如嵌入式开发、驱动程序编写)。
4.2在Ubuntu下汇编的命令
在Ubuntu下汇编的命令为:
gcc -S hello.s -o hello.o
汇编过程如下:

图15汇编的命令
4.3可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先,在shell中输入readelf -a hello.o>hello_elf.txt指令获得hello.o文件的ELF格式:

图16获得hello.o文件的elf格式的命令
在编写汇编代码之前,理解可重定位目标文件的ELF结构至关重要。
其结构分析如下:
1. ELF头(ELFHeader):ELF文件格式以一个16字节的序列"Magic"开头,这个序列不仅标识了文件类型,还包含了关于生成该文件的系统所使用的字大小和字节顺序的信息。在ELF头的其余部分,提供了辅助链接器进行语法分析和解释目标文件所需的关键信息。这些信息包括ELF头自身的大小、目标文件的类型、目标机器的类型、节头部表在文件中的偏移量,以及节头部表中条目的大小和数量等重要数据。这些信息对于链接器正确处理和链接目标文件至关重要。

图17ELF头
- 节头:
节头包含了文件中出现的各个节的意义,包括节的类型、位置和大小等信息。

图18节头
- 重定位节.rela.text
在可重定位目标文件中,.rela.text节扮演着至关重要的角色。它详细列出了.text节(代码节)中所有需要链接器在链接过程中进行地址调整的位置。这个节的存在是为了确保当多个目标文件被链接器合并时,所有相关的地址引用都能被正确地更新。
具体来说,在本例中,.rela.text节包含了8条重定位条目,它们分别对应于以下内容:
1..L0——指向第一个printf调用中使用的字符串字面量的位置。
2.puts函数——一个标准库函数,用于输出字符串到标准输出。
3.exit函数——用于终止程序的标准库函数。
4..L1——指向第二个printf调用中使用的字符串字面量的位置。
5.printf函数——一个用于格式化输出的标准库函数。
6.sleepsecs——这可能是一个变量或常量,用于指定sleep函数的休眠时间。
7.sleep函数——一个标准库函数,使程序暂停执行指定的时间。
8.getchar函数——一个用于从标准输入读取下一个可用字符的标准库函数。
这些重定位条目确保了在最终的可执行文件中,所有的函数调用和字符串引用都能被链接到正确的地址。
.rela.text节包含如下信息:
| 偏移量 | 代表需要进行重定向的代码在.text或.data节中的偏移位置 |
| 信息 | 包括symbol和type两部分,其中symbol占前半部分,type占后半部分,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型 |
| 类型 | 重定位到的目标的类型 |
| 加数 | 计算重定位位置的辅助信息 |

图19重定位节.rela.text
- 重定位节.rela.eh_frame

图20rela.eh_frame
- 符号表Symboltable
符号表是链接过程中的一个关键组成部分,它记录了程序中所有符号的定义和引用信息。这个表是链接器用来解决不同目标文件之间符号引用所必需的。它包含了所有需要进行地址重定位的符号,确保了在最终的可执行文件中,每个符号都被赋予了正确的地址。
简而言之,符号表是链接器用于定位和重定位的数据库,它声明了所有需要在程序中定位的符号,以及它们在内存中的位置。这样,当链接器处理多个目标文件时,它能够利用符号表来正确地调整和解决符号引用,使得最终的程序能够正确地运行。

图21符号表
4.4Hello.o的结果解析
在终端输入objdump -d -r hello.o分析hello.o的反汇编结果如下:
图22hello.o的反汇编结果
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
当我们查看hello.o的反汇编文件时,会发现它与原始的hello.s汇编文件中的汇编代码非常相似。然而,在反汇编文件中,我们也会看到一些不熟悉的部分,这些就是机器代码。
机器代码是二进制形式的机器指令集合,每条机器代码对应于一条具体的机器指令,这是计算机硬件能够直接识别和执行的语言。汇编语言通过一种映射关系转换成机器代码,其中汇编语言的操作码和操作数与机器语言中的二进制数据相对应,使得计算机能够理解并执行代码。
对比hello.s与生成的反汇编发现,机器代码与汇编代码的主要区别体现在以下几个方面:
1.分支跳转:
在汇编语言hello.s中,分支跳转语句(如je.L2)使用标签(标识符)来指定跳转的目标位置。而在反汇编的机器代码中,这些跳转语句被转换成具体的内存地址,从而实现跳转功能。
![]()
图23分支跳转
2.函数调用:
在汇编语言的.s文件中,调用函数时直接使用函数名。但在.o文件的反汇编中,call指令的目标地址是相对于当前指令的下一条指令地址。这是因为在hello.c中调用的函数可能来自共享库,它们的确切地址在编译时是未知的,需要在链接阶段确定。因此,在机器代码中,对于这些尚未确定地址的函数调用,会先将下一条指令的相对地址设置为0,然后在.rela.text节中为这些调用添加重定位条目,以便在链接时确定最终的函数地址。

图24函数调用
3.访问全局变量:汇编代码中用.LC0等符号。而反汇编中为0x0,需要重定位后才能显示具体地址。
![]()
图25访问全局变量
4.5本章小结
本章介绍了汇编的概念与作用。详细阐述了从汇编源文件hello.s到可重定位目标文件hello.o的转换过程。在这一部分中,在Ubuntu下对hello.o文件的ELF(ExecutableandLinkableFormat)头部、节(Section)头部以及符号表进行了深入分析。通过这些分析,我们可以洞察到Hello程序的内部结构和组织方式。
此外,本节还对hello.o文件进行了反汇编分析,展示了如何将汇编代码进一步转换为机器代码,从而使计算机硬件能够理解和执行。通过比较hello.s和hello.o的反汇编,我们可以了解到在编译过程中,汇编代码是如何被优化和转换的,以及.o文件是如何使机器更容易理解和执行代码的。
总的来说,本章节不仅提供了对hello.o文件结构的深入理解,还解释了编译和链接过程中的关键步骤。
(第4章1分)
第5章链接
5.1链接的概念与作用
5.1.1链接的基本概念
链接过程是使用链接器(Linker)将多个编译后的代码和数据片段整合成一个单一的文件,从而创建一个完全链接的可执行目标文件。在Windows操作系统中,这个文件通常以.exe作为文件扩展名,而在Linux系统中,通常不使用文件扩展名。
5.1.2链接的重要性
链接提供了一种模块化的编程方法,允许开发者将程序划分为多个较小的源文件。这种方法的优势在于:
模块化:程序被分解成多个模块,每个模块可以独立编译和链接。
简化复杂性:通过将大型程序拆分成更小的部分,降低了整体程序的复杂性。
减少编译时间:只有修改过的模块需要重新编译,而不是整个程序。
增强容错性:当程序中的一个模块出现问题时,可以仅针对该模块进行调试和修复,而不必重新编译整个程序。
便于维护和更新:模块化使得对特定模块的修改和更新更加方便,而不会影响到其他模块。
总的来说,链接是软件开发过程中的一个关键步骤,它不仅简化了程序的构建过程,还提高了程序的可维护性和可扩展性。
注意:这儿的链接是指从hello.o到hello生成过程。
5.2在Ubuntu下链接的命令
在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 -lc /usr/lib/x86_64-linux-gnu/crtn.o
链接过程如下:

图26链接指令
在终端中运行hello如图27所示:
![]()
图27hello运行指令
使用ld的链接命令,应截图,展示汇编过程!注意不只连接hello.o文件
5.3可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
在Shell中输入命令readelf -a hello>hello2.elf生成hello程序的ELF格式文件,保存为hello2.elf(与第四章中的elf文件作区分):
![]()
图28生成ELF文件
打开hello2.elf,分析hello的ELF格式如下:
1.ELF头(ELFHeader)
ELF头是可执行和可链接文件格式(ExecutableandLinkableFormat)的核心组成部分,它在hello2.elf和hello.elf中扮演着相似的角色。这个头部以一个16字节的序列Magic开始,这个序列不仅标识了文件类型,还包含了关于生成该文件的系统所使用的字大小和字节顺序的信息。
在hello2.elf的ELF头中,与hello.elf相比,一些基本信息保持不变,例如Magic和文件类别等。然而,也存在一些变化,例如:
类型变化:hello2.elf可能被指定为不同类型的文件,比如一个可执行文件而不是一个可重定位的目标文件。
程序头大小和节头数量增加:随着程序的扩展或功能的增加,程序头(ProgramHeader)的大小和节头(SectionHeader)的数量都可能增加,以适应更多的程序段或节。
入口地址的获得:hello2.elf可能包含了一个入口地址,这是程序执行的起始点,链接器会使用这个地址来确定程序的启动位置。
总的来说,尽管hello2.elf和hello.elf在ELF头的某些方面保持了一致性,但随着程序的不同需求和特性,hello2.elf在类型、程序头大小、节头数量以及入口地址等方面都有所调整,以满足特定的程序行为和执行要求。

图29ELF头
2.节头
hello2.elf中的节头包含了文件中出现的各个节的语义,包括节的类型、位置、偏移量和大小等信息。与hello.elf相比,其在链接之后的内容更加丰富详细。


图30节头
- 程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。

图31程序头
4.Dynamic section

图32Dynamicsection
5.重定位节
重定位节保存着代码的重定位条目。如图38,hello有两个重定位节,分别为.rela.dyn和.rela.plt。

图33重定位节
- Symboltable
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。

图34Symboltable
5.4hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
打开edb,通过datadump查看加载到虚拟地址的程序代码。
可以看到hello的虚拟地址空间起始地址为0x401000,如图35,结束地址为0x401ff0,如图41。

图35hello的起始地址

图36hello的结束地址
接着根据节头部表的地址信息,可以在edb中查看各节的位置。
5.5链接的重定位过程分析
objdumpdrhello分析hello与hello.o的不同,说明链接的过程。
5.5.1两者之间的不同之处
在Shell中使用命令objdump -d -r hello>hello2.asm生成反汇编文件hello2.asm,与第四章中生成的hello.o.asm文件进行比较,其不同之处如下:
![]()
图37生成.asm文件

图38对比两个文件内容不同
- 函数数量在链接后变多。链接后的反汇编文件hello2.asm中增加了.plt,.init,.fini节,且各个函数都有了其具体实现。例如多出了.plt,exit@plt,sleep@plt等函数的代码。这是由于动态链接器将共享库中hello.c用到的函数加入可执行文件中。

图39链接后的函数
- 在链接的过程中,函数调用指令call的参数会发生变化。在链接过程中,链接器解析了重定位条目,修改字节代码将call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。通过这种方式连接器确保了程序中的函数调用在最终的可执行文件中指向正确的目标使得程序能够正确的执行函数调用。

图40call指令的函数
- 在链接过程中,链接器会解析重定位条目,将跳转指令的参数从符号引用更新为实际的相对地址。对于动态链接的函数,这个地址是相对于.plt(过程链接表)中相应条目的地址。这样,生成的反汇编代码能够确保程序在执行跳转时,能够正确地跳转到目标代码段或动态库函数。

图41跳转指令的函数
5.5.2重定位分析
1.整合段和分配地址:链接器将所有输入模块中的同类段(例如.data段)合并到输出文件的相应段中,并为这些新段以及原模块中的段和符号分配唯一的运行时内存地址。
2.修改符号引用:链接器利用重定位实体(relocationentries)来更新代码和数据段中的符号引用,确保它们指向正确的运行时地址。这样,程序中的每条指令和全局变量都与正确的内存位置关联起来。

图42重定位实体

图43重定位算法伪代码
5.6hello的执行流程
使用gdb/edb执行hello,说明从加载hello到_start,到callmain,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
| 程序名称 | 程序地址 |
| ld2.29so!_dl_start | 0x7f810f43b048 |
| ld2.29so!_dl_init | 0x7f810f41ec10 |
| hello!_start | 0x4010f0 |
| libc2.29so!__libc_start_main | 0x7f810f224fc0 |
| libc2.29so!__cxa_atexit | 0x7f810f247f60 |
| hello!__libc_csu_init | 0x4011c0 |
| hello!_init | 0x401000 |
| libc2.29so!_setjmp | 0x7f810f243e00 |
| libc2.29so!__sigsetjmp | 0x7f810f243d30 |
| hello!main | 0x401125 |
| hello!printf@plt | 0x401040 |
| hello!atoi@plt | 0x401060 |
| hello!sleep@plt | 0x401080 |
| hello!getchar@plt | 0x401050 |
| hello!exit@plt | 0x401070 |
| hello!getchar@plt | 0x7f810f247bc0 |
| libc2.27so!exit | 0x7fce8cc4e680 |
5.7Hello的动态链接分析
分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。
在终端中通过readelfahello>hello2.elf生成hello.elf文件打开查找.got.plt表的初始地址。
![]()
图44生成hello2.elf文件

图45查找.got.elf表的初始地址
不运行的情况下,先进入edb查看由上面查询到的.got.elf的初始地址0x404000下的信息。

图46GOT表信息
可以发现起始数据0x403e50后的16个字节全是0。
然后再运行到main函数后,再来重新看表中的信息,如下图47所示

图47执行init后GOT表信息
不难发现0x403e50后面多出了0x7fdad4ca1190和0x7fdad4c8abb0两个地址,分别为GOT[1]和GOT[2]。首先GOT[1]是包含动态链接器在解析函数地址时会使用的信息。而GOT[2]则是动态链接器在1dlinux.so模块中的入口点。
最后我们进入0x7fdad4c8abb0地址,用edb来查看共享库模块,如图48所示

图48共享库模块
5.8本章小结
本章节深入探讨了链接的概念和它在软件开发中的重要性。通过实践,我们得到了链接后的hello可执行文件,其以ELF(ExecutableandLinkableFormat)格式存在,文件名为hello2.elf。通过对这个文件的分析,我们比较了它与原始的hello.elf文件之间的相似之处和不同之处。
此外,本章还通过比较两个反汇编文件hello2.asm和hello.asm,进一步加深了对重定位机制和动态链接过程的理解。这种比较分析帮助我们认识到链接器如何将分散的代码和数据段整合成单一的可执行文件,确保所有函数调用和变量引用都被正确解析和定位。通过这个过程,我们能够更清楚地看到链接对程序执行的重要性以及它如何影响程序的结构和性能。
(第5章1分)
第6章hello进程管理
6.1进程的概念与作用
6.1.1进程的定义
进程代表了程序执行的一个活动实例。在操作系统中,每个程序的执行都在一个独立的进程环境中进行。
6.1.2进程的功能
进程为应用程序提供了两个基础而重要的抽象概念:
1.独立的逻辑控制流:它使得每个程序都好像拥有自己的执行线程和对处理器的独占使用权,从而创建了一个程序能够独立运行的假象。
2.私有地址空间:每个进程拥有自己的内存空间,这使得程序能够安全地运行而不会干扰其他进程,同时也让每个程序都感觉像是它独自使用着整个内存系统。
6.2简述壳Shellbash的作用与处理流程
Shellbash的作用:
Shell是一个交互式的命令解释器,它是由C语言编写的,允许用户执行其他程序。这个应用程序充当用户与操作系统之间的桥梁,使得用户能够执行基本的系统操作并利用内核提供的功能。
Shell的处理流程:
1.Shell从终端接收用户输入的命令。
2.它解析输入的字符串,提取并识别命令中的各个参数。
3.如果识别出的是内置命令,Shell将直接执行该命令。
4.如果命令不是内置的,Shell会寻找并启动相应的外部程序,并为其创建一个子进程来执行。
5.如果输入的命令无效,Shell将提供错误信息反馈给用户。
6.Shell继续处理命令列表中的下一个命令,直到所有命令都被执行完毕。
6.3Hello的fork进程创建过程
在Linux操作系统中,用户可以利用./命令来运行一个可执行的程序文件。当用户执行这个命令时,Shell会启动一个新的进程,并为这个新进程设置一个新的执行上下文,使得该可执行文件能够在新的进程上下文中执行。
fork()是一个系统调用,它用于创建新进程。这个函数有一个整型(int)的返回值,其返回值在父进程和子进程中是不同的:
在子进程中,fork()返回0。在父进程中,fork()返回新创建的子进程的进程ID(PID)。新创建的子进程在很多方面与父进程相似,但也存在一些关键的区别:
子进程拥有与父进程相同的虚拟地址空间,但这个地址空间是独立的副本。这意味着子进程复制了父进程的代码段、数据段、堆、共享库以及用户栈。子进程拥有自己独立的进程ID(PID),这与父进程的PID不同。
这种机制使得子进程能够从父进程继承必要的资源,同时保持独立性,进行自己的执行流程。
6.4Hello的execve过程
1.执行新程序:execve函数用于在当前进程中启动一个新的程序。它接受三个参数:
1.1filename:指定要执行的新程序的文件路径,可以是绝对路径或相对路径。
1.2argv[]:一个字符串数组,包含传递给新程序的命令行参数。这个数组的格式与C语言main函数中的argv参数数组相同,其中argv[0]通常与filename中的文件名(basename)一致。
1.3envp[]:一个字符串数组,定义了新程序的环境变量列表,对应于新程序中的environ数组。
- 加载器的工作:加载器(Loader)负责删除子进程现有的虚拟内存段,并创建一组新的内存段,其中栈和堆被初始化为0。加载器将虚拟地址空间中的页映射到可执行文件的页大小的块(chunk),并初始化新的代码和数据段为可执行文件的内容。随后,加载器跳转到start标签,开始执行新程序。在实际读取文件内容之前,加载器并不读取文件,直到发生缺页中断。

图49加载器映射用户地址区域
3.覆盖现有进程:通过execve调用,当前进程的代码、数据和栈将被新程序的内容覆盖。
4.调用特性:由于execve调用会替换调用进程,因此它永远不会返回到调用它的进程。这意味着不需要检查execve的返回值,因为如果调用成功,它将不会返回;如果返回值为1,则表示发生了错误,通常会有一个错误码来指示具体的错误原因。
这个过程是Linux系统提供的一种机制,允许进程执行新的程序,而无需创建新的进程。这在某些情况下可以节省资源,因为它避免了创建新进程的开销。
6.5Hello的进程执行
(结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
6.5.1.上下文信息:
操作系统的内核为系统中的每个进程维护一个上下文。上下文包含了内核重新启动一个被中断的进程所需的所有状态信息。这包括但不限于:
通用目的寄存器和浮点寄存器的值,用于存储临时数据和指令执行结果。
程序计数器,指示下一条指令的地址。
用户栈,用于函数调用时存储参数和局部变量。
状态寄存器,记录处理器的状态。
内核栈,用于内核模式下的函数调用。
页表,描述进程的虚拟内存地址空间。
进程表,包含进程的相关信息。
文件表,记录进程打开的文件信息。
6.5.2.进程时间片:
进程时间片指的是操作系统分配给进程执行其控制流的时间单元。在多任务操作系统中,时间片允许多个进程轮流使用CPU资源。
6.5.3.进程调度过程:
操作系统内核通过上下文切换来管理进程的调度。在某些情况下,内核可以决定中断当前进程的执行,转而恢复另一个之前被中断的进程。这个过程称为调度,由内核中的调度器负责。当内核决定运行一个新进程时,会发生以下步骤:
1.保存当前进程的上下文,确保可以恢复其执行状态。
2.从之前被中断的进程中恢复其保存的上下文。
3.将CPU控制权转移给新恢复的进程,使其开始执行。
6.5.4用户态与核心态的转换:
用户态与核心态的转换是操作系统内核管理进程状态的重要机制。在用户态下,进程执行用户级别的代码,而在核心态下,进程执行需要更高权限的操作,如访问硬件资源或管理其他进程。转换通常发生在以下情况:
进程请求操作系统服务时,会触发系统调用,从用户态切换到核心态。
进程发生异常或中断时,操作系统会介入,从用户态切换到核心态进行处理。
进程完成操作系统服务请求后,会从核心态切换回用户态,继续执行用户级别的任务。
这种转换确保了系统的稳定性和安全性,防止用户程序直接访问或修改关键的系统资源。

图50用户态和内核态的转换
6.6hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,CtrlZ,CtrlC等,Ctrlz后可以运行psjobspstreefgkill等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
1. 在程序正常运行时,打印10次提示信息,以输入回车为标志结束程序,并回收进程。

2.如果在程序运行的时候按回车键,则相应的会多打印按回车键次数的几处空行,而且程序可以正常结束。

3.如果使用Ctrl + C,则Shell进程会收到SIGINT信号然后Shell会结束并回收hello进程。

4.如果按下Ctrl + Z,Shell进程会收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。

对hello进程的挂起可由ps和jobs命令查看,如下图所示我们可以发现hello进程的确被挂起而非被回收,并且其job代号为1。

如果在Shell中输入pstree命令,我们则可以将所有进程以树状图显示(由于树状图太长了这里仅展示部分):

如果输入kill命令,则可以杀死指定(进程组的)进程:

在hello进程被挂起时,如果输入fg 则命令,进程收到一个SIGCONT信号,hello继续执行

5.不停乱按
不停地乱按只会输出到终端界面相应按的东西,并不会影响进程正常进行。

6.7本章小结
进程的基本概念与作用,Shellbash的基本概念,进程控制:fork和execve函数,本章通过一个具体的示例——hello可执行文件,深入探讨了fork和execve这两个系统调用的原理和执行过程。fork用于创建一个新的进程,即子进程,它是当前进程的副本。execve则用于在当前进程的上下文中执行一个新的程序。最后,本章还讨论了在hello程序带参数执行时可能遇到的各种异常情况和信号处理机制。通过这一章的学习,我们可以对进程的工作原理、Shell的使用以及程序执行过程中的异常和信号处理有一个基本的了解。
(第6章1分)
第7章hello的存储管理
7.1hello的存储器地址空间
1.逻辑地址:这是程序在执行时产生的地址,它与程序的段有关,由两部分组成:段选择符和偏移量。在hello.asm程序中,逻辑地址指的是指令和数据的相对偏移量。
2.线性地址:逻辑地址经过操作系统的内存管理单元(MMU)中的段机制转换后得到的地址。这个地址是处理器可以寻址的整个内存空间中的地址,它用于映射程序的分页信息。对于hello程序来说,线性地址定义了它在内存中应该运行的具体位置。
3.虚拟地址:在现代操作系统中,虚拟地址通常指的是经过MMU转换前的地址,即线性地址。因此,虚拟地址和线性地址在这里是相同的概念。
4.物理地址:这是CPU通过地址总线访问实际物理内存时使用的地址。物理地址是内存单元在硬件上的实际位置,CPU通过这个地址来读取或写入数据。
这个地址空间的转换过程是现代操作系统内存管理的核心部分,它允许程序使用逻辑地址来访问内存,而操作系统则负责将这些逻辑地址转换为物理地址,同时处理内存的分配和保护。
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.2Intel逻辑地址到线性地址的变换段式管理
Intel处理器从逻辑地址到线性地址的变换通过段式管理的方式实现。以下是详细过程的描述:
1.段表:每个程序在系统中都有一个与之关联的段表。这个段表记录了程序各个段在主存中的信息,包括:段号或段名,段的起始地址,装入位(指示段是否已装入内存),段的长度,主存占用区域表,主存可用区域表。
2.段寄存器:段寄存器中存储的是段选择符,它用于访问段表中的特定段描述符。段选择符由以下三部分组成:
2.1索引:确定段描述符在描述符表(GDT或LDT)中的位置。
2.2TI(表指示器):决定是访问全局描述符表(GDT,当TI=0)还是局部描述符表(LDT,当TI=1)。
2.3RPL(请求特权级):表示当前访问的段的特权级别,范围从0(最高级别,即内核级别)到3(最低级别,即用户级别)。

图51段选择符的情况
3.段描述符:通过索引定位到段描述符后,可以获取段的基址。段描述符包含了段的起始物理地址和其他属性。
4.线性地址计算:将段基址与逻辑地址中的偏移量相加,得到线性地址(也称为虚拟地址)。公式为:线性地址=段基址+偏移量。
图52逻辑地址转化为线性地址
7.3Hello的线性地址到物理地址的变换页式管理
线性地址(VA)到物理地址(PA)之间的转换通过对虚拟地址内存空间进行分页的分页机制完成。
1.线性地址的获取:通过段式管理过程,我们得到了线性地址,也称为虚拟地址(VA)。虚拟地址由两部分组成:
VPN(虚拟页号):标识虚拟内存中的页位置。
VPO(虚拟页偏移量):标识页内偏移量,与物理页偏移量(PPO)大小相同。
2.分页机制:虚拟地址空间通过分页机制进行管理。每个虚拟页的大小与物理页大小相同,因此VPO可以直接映射到PPO。

图53Hello的线性地址到物理地址的变换页式管理
3.页表条目(PTE):为了将虚拟地址转换为物理地址,需要访问页表中的页表条目(PTE)。PTE包含了物理页号(PPN)和其他信息。
4.页命中与缺页:
页命中:如果PTE的有效位为1,表示对应的虚拟页已经加载到物理内存中,可以直接从PTE中获取PPN,然后将PPN与VPO组合得到完整的物理地址。
缺页故障:如果PTE的有效位为0,表示虚拟页不在物理内存中,此时会发生缺页故障。操作系统的内核将介入处理:
确定牺牲页(如果物理内存已满,需要替换掉一个现有的页)。
将新的页面调入物理内存。
返回到发生缺页的进程,并重新执行导致缺页的指令。
此时,由于新页面已经加载,页命中,可以获取PPN,并与VPO组合得到物理地址。
5.页访问示意图:虚拟地址访问某个页的过程通常在图54中有所展示,说明了虚拟地址到物理地址的转换流程。

图54根据虚拟地址访问页面
7.4TLB与四级页表支持下的VA到PA的变换
针对IntelCorei7CPU,从虚拟地址(VA)到物理地址(PA)的转换过程涉及多个步骤,具体如下:
1.虚拟地址空间:IntelCorei7CPU拥有48位的虚拟地址空间。
2.物理地址空间:物理地址空间为52位,允许CPU访问更大的物理内存。
3.TLB(TranslationLookasideBuffer):TLB是一个高速缓存,用于存储最近转换的虚拟地址到物理地址的映射。它采用四路十六组相连的替换策略。
4.缓存:L1和L2缓存大小为64字节,采用八路组相连的替换策略。L3缓存也采用十六路组相连。
5.页表结构:页表大小为4KB,页表条目(PTE)大小为8字节,这意味着每个页表可以包含512个PTE。
6.地址划分:由于页表大小和PTE大小固定,VPO和PPO共有12位,因此VPN为36位,PPN为40位。
7.TLB索引:TLB有16组,所以TLB索引(TLBI)需要4位,TLB标记(TLBT)需要364=32位。
8.地址转换过程:
CPU生成虚拟地址VA并发送至MMU(MemoryManagementUnit)。MMU使用前36位VPN作为TLBT和TLBI在TLB中进行匹配。如果TLB命中,MMU可以直接获取PPN和VPO,组合成52位的物理地址PA。如果TLB未命中,MMU将查询页表:CR3寄存器包含第一级页表的起始物理地址。通过VPN的前9位(VPN1)在第一级页表中索引PTE。如果PTE有效且权限匹配,MMU继续查询下一级页表,直至第四级页表。最终在第四级页表中找到PPN,与VPO结合形成PA。将新的转换条目添加到TLB以优化未来的访问。

图55TLB与四级页表支持下的VA到PA的变换
9.缺页和段错误:
如果在查询PTE时发现页面不在物理内存中,将引发缺页故障。如果PTE检查显示权限不足,将引发段错误。

图56多级页表的工作原理
7.5三级Cache支持下的物理内存访问
基于IntelCorei7CPU的L1Cache参数,以下是对三级Cache支持下的物理内存访问过程的描述:
1.L1Cache结构:L1Cache采用8路64组相连的替换策略,块大小为64字节。
2.地址划分:
块偏移(CO):由于块大小为64字节,即2^6字节,因此需要6位二进制来索引块内偏移,对应于物理地址中的最低6位。
组索引(CI):共有64组,因此需要6位二进制来索引组号。
标记位(CT):剩余的位数用于标记位,包括物理页号(PPN)和物理页偏移量(PPO),减去块偏移和组索引所需的位数,即40位。
3.物理地址PA的划分:物理地址PA可以划分为CT(40位)、CI(6位)和CO(6位)。
4.缓存访问过程:
使用CI进行组索引,确定访问的组。
在选定的组内,通过8路相连的方式,对每个块的CT进行比较,寻找匹配的物理地址。如果找到匹配的CT且块的valid标志位为1,则缓存命中,根据CO确定数据在块内的位置,取出数据并返回。如果没有匹配成功或块的valid标志位为0,则缓存未命中。
5.未命中处理:
缓存未命中时,请求数据从下一级缓存(L2Cache)开始向上一级缓存或主存请求。
请求顺序为L2Cache→L3Cache→主存。
6.数据加载与缓存替换:
查询到数据后,需要将其读入L1Cache。如果映射到的组内有空闲块,则直接放置在空闲块中。如果当前组内没有空闲块,则需要进行替换:采用LFU(LeastFrequentlyUsed,最少使用)策略选择替换块。

图57TLB与四级页表支持下的VA到PA的变换
7.6hello进程fork时的内存映射
当进程hello调用fork函数时,内核执行以下步骤来创建一个新的进程:
1.创建数据结构:内核为新进程创建必要的数据结构,包括mm_struct(内存管理结构),区域结构(用于内存映射),以及页表。
2.分配PID:新进程被分配一个唯一的进程标识符(PID)。
3.复制虚拟内存:内核创建了当前进程虚拟内存的精确副本,包括:
mm_struct的副本,它包含了虚拟内存的信息。区域结构的副本,用于跟踪内存区域。页表的副本,用于虚拟地址到物理地址的映射。
4.设置页面权限:初始时,两个进程中的每个页面都被标记为只读,以防止对共享页面的意外写入。
5.私有写:每个区域结构都被设置为私有的写时复制(CopyOnWrite,COW)。这意味着,当任一进程尝试写入某个页面时,内核将自动为该页面创建一个私有副本,从而确保每个进程都有自己的独立地址空间。
6.虚拟内存相同:当fork在新进程中返回时,新进程的虚拟内存与调用fork时的父进程的虚拟内存相同。
7.写操作触发COW:一旦父进程或子进程尝试写入共享页面,写时复制机制将被触发,内核会为写入操作的进程创建一个新的页面,从而保持两个进程的地址空间独立。
7.7hello进程execve时的内存映射
execve函数用于在当前进程中加载并运行新程序hello,这个过程涉及以下步骤:
1.删除现有用户区域:首先,内核删除当前进程hello虚拟地址空间用户部分中的所有现有区域结构,为新程序的载入做准备。
2.映射私有区域:
为新程序创建私有的、写时复制的区域结构,这些区域包括代码、数据、BSS(BlockStartedbySymbol,未初始化数据区)和栈。代码和数据区域被映射到hello程序文件的.text和.data段。BSS区域请求二进制零,映射到匿名文件,其大小在hello程序中指定。栈和堆区域也是请求二进制零,初始时长度为零,它们会在程序运行时动态增长。
3.映射共享区域:
如果hello程序与共享对象或库(例如标准C库libc.so)链接,内核将这些共享对象动态链接到hello程序中。这些共享对象随后被映射到用户虚拟地址空间的共享区域内,允许多个进程共享这些库的代码和数据。
4.设置程序计数器:
execve最后设置当前进程的程序计数器,指向新程序代码区域的入口点,即程序的起始执行位置。
通过这些步骤,execve函数成功替换当前进程的映像,使其执行新程序hello,而无需创建新的进程。这种方式在进程需要改变执行的程序时非常有用,例如在shell脚本中运行不同的命令。

图58加载器映射用户空间地址区域
7.8缺页故障与缺页中断处理
当发生一个缺页异常时,内核将执行以下缺页处理程序的步骤:
1.合法性检查:
控制权首先转移到内核的缺页处理程序。缺页处理程序会检查触发缺页异常的虚拟地址是否合法。如果虚拟地址不合法,内核将产生一个段错误(SegmentationFault)。段错误会导致该进程终止并退出。
2.选择牺牲页:
如果虚拟地址合法,缺页处理程序将从物理内存中选择一个牺牲页(即被替换的页)。这个选择基于某些页面置换算法,如最近最少使用(LRU)算法。
3.页面置换:
如果牺牲页在内存期间被修改过(即“脏”页),它需要被写回到磁盘中,这个过程称为“页面置换”。如果牺牲页未被修改,它可以直接被新的页面替换,无需写回磁盘。
4.加载新页面:
将新的页面从磁盘读取到物理内存中,更新页表以反映新的物理页面位置。新页面被加载到物理内存后,页表将被更新,以包含新的物理地址。
5.返回用户模式:
缺页处理程序完成其工作后,返回控制权给用户模式的进程。CPU重新执行之前因缺页而失败的指令。
6.地址转换:
引起缺页的虚拟地址再次被发送给内存管理单元(MMU)。由于现在该虚拟页面已经加载到物理内存中,MMU将能够成功地将虚拟地址转换为物理地址。
7.命中并返回数据:
由于页面已经缓存在物理内存中,MMU将命中页表条目,主存将请求的数据字返回给处理器。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存管理是操作系统和编程语言中用于分配和回收内存资源的重要机制。以下是一些基本方法和策略的介绍:
1.动态内存分配器:
分配器负责管理进程的堆(heap)区域,这是一块用于动态内存分配的虚拟内存区域。堆由不同大小的内存块组成,这些块要么是已分配的(被应用程序使用),要么是空闲的(等待被分配)。
2.分配器的两种风格:
显式分配器:要求应用程序在不再需要内存时显式释放已分配的内存块。
隐式分配器:具备垃圾收集功能,能够自动检测并释放不再使用的内存块。
3.重要概念:
隐式链表:空闲块通过内存头部的大小字段隐式连接,分配器通过遍历这些块来管理空闲内存。

图59隐式链表的结构
显式链表:每个空闲块包含指向前驱和后继块的指针,这样可以更快地搜索和适配合适的内存块。

图60显式链表的结构
带边界标记的合并:在内存块的末尾添加一个与头部相同的脚部,使得分配器可以通过检查前一个块的脚部来快速合并相邻的空闲块。
分离存储:维护多个空闲链表,每个链表包含大小相同的块,通过分离存储不同大小的块,可以更高效地管理内存。
动态内存管理的目标是有效地利用内存资源,减少内存碎片,并提供足够的灵活性以满足应用程序的内存需求。不同的分配策略和数据结构选择会影响内存分配器的性能和效率。例如,隐式链表和显式链表提供了不同的搜索和合并空闲内存块的方法,而分离存储则允许分配器更快地找到合适大小的内存块,减少搜索时间和内存碎片。垃圾收集是隐式分配器的一个关键特性,它自动处理内存释放,简化了程序设计,但也可能引入性能开销。
7.10本章小结
1.hello程序的存储器地址空间:介绍了hello程序如何使用逻辑地址、线性地址(虚拟地址)、物理地址等概念。
2.Intel的段式管理:解释了Intel处理器如何通过段式管理机制将逻辑地址转换为线性地址,包括段寄存器、段选择符、段描述符等概念。
3.hello的页式管理:讨论了页式管理的基本概念,以及hello程序是如何通过页表来进行虚拟地址到物理地址的映射。
4.VA到PA的变换:详细描述了虚拟地址(VA)到物理地址(PA)的转换过程,包括页表的使用和地址转换机制。
5.物理内存访问:介绍了物理内存访问的过程,包括Cache的工作方式和物理内存的访问策略。
6.进程fork和execve时的内存映射:探讨了当hello进程执行fork和execve系统调用时,操作系统如何管理内存映射,包括写时复制(CopyOnWrite)和内存的重新分配。
7.缺页故障与缺页中断处理:分析了缺页故障发生时的处理流程,包括缺页中断的触发、处理程序的执行、页面置换算法等。
8.动态存储分配管理:讨论了动态内存分配的策略和方法,包括显式和隐式分配器、隐式链表、显式链表、带边界标记的合并、分离存储等概念。
通过本章的学习,我们对存储器地址空间、内存管理机制、进程的内存映射、缺页处理以及动态内存分配有了深入的理解。这些知识点是操作系统内存管理的核心内容,对于理解程序如何与操作系统交互以及操作系统如何管理资源至关重要。
(第7章2分)
第8章hello的IO管理
8.1Linux的IO设备管理方法
设备的模型化:文件
设备管理:unixio接口
Unix和Linux系统中的所有I/O设备都通过文件系统接口被抽象成文件,这种方法称为UnixI/O。它为Linux内核提供了一个简单而基础的接口,允许对设备执行统一的操作,包括:打开设备对应的文件,建立与设备的连接。改变文件指针的位置,以便在设备上定位数据。读取或写入数据到设备文件,即执行输入或输出操作。关闭设备文件,结束与设备的连接。
这种模型化大大简化了I/O编程,因为开发者可以使用标准的文件操作API来与各种设备交互,而无需关心设备的硬件细节。
8.2简述UnixIO接口及其函数
UnixI/O接口和函数是操作系统提供给应用程序用于执行输入和输出操作的一套标准机制。以下是对UnixI/O接口和相关函数的概述:
UnixI/O接口:
1.打开文件:
应用程序请求内核打开一个文件,内核返回一个文件描述符,这是一个用于后续操作的唯一标识符。每个由Shell启动的进程都有三个标准文件描述符:标准输入(通常为0)、标准输出(通常为1)、标准错误(通常为2)。
2.改变文件位置:
内核为每个打开的文件维护一个文件位置指针,初始位于文件开头。应用程序可以使用seek函数显式地改变文件位置指针。
3.读写文件:
read操作从文件中读取数据到内存,从当前文件位置开始,并将位置指针向前移动。
write操作将数据从内存写入文件,同样从当前文件位置开始,并更新位置指针。读取操作在文件末尾返回EOF(文件结束标志),写操作则更新文件长度。
4.关闭文件:
内核释放与文件描述符关联的资源,并将该描述符标记为可用。
UnixI/O函数:
1.open函数:
原型:intopen(charfilename,intflags,mode_tmode)
用于打开一个已存在的文件或创建一个新文件。filename是要打开或创建的文件的路径。flags指定文件的访问方式,如只读、写入或追加。mode为新创建文件的访问权限。返回值是新的文件描述符。
2.close函数:
原型:intclose(intfd)
fd是要关闭的文件的文件描述符。函数执行成功返回0,失败返回1。
3.read函数:
原型:ssize_tread(intfd,voidbuf,size_tn)
从文件描述符fd处读取最多n个字节到缓冲区buf。返回值是实际读取的字节数,0表示EOF,1表示错误。
4.write函数:
原型:ssize_twrite(intfd,constvoidbuf,size_tn)
将最多n个字节的数据从缓冲区buf写入文件描述符fd。返回值是成功写入的字节数,1表示错误。
8.3printf的实现分析
首先查看printf的源码。

图61 printf源码
使用省略号...表示函数可以接受不定数量的参数。
va_list是可变参数列表的类型定义,通常定义为typedef char *va_list。
通过(char)(&fmt)+4可以获取...中的第一个参数,即arg表示的是...中的第一个参数。
再看vsprintf的源码:

图62 vsprintf的源码
不难发现,vsprintf函数用于根据格式化字符串fmt和可变参数列表args生成格式化后的字符串。它返回生成字符串的长度。
printf函数接受格式化命令,并将匹配的参数格式化输出。使用i=vsprintf(buf,fmt,arg)获取要打印字符串的长度。通过write(buf,i)将缓冲区buf中的i个字节写入到终端。
再进一步对write进行追踪:

图63 write的实现
write函数负责将字符串输出到屏幕上。内部通过系统调用sys_call实现。sys_call是操作系统提供的机制,用于执行特定的系统级功能。在write函数中,它将字符串从寄存器复制到显存。

图64 sys_call的实现
应用程序通过write系统调用请求操作系统将数据输出到屏幕。这个调用会触发内核态,操作系统将处理这个请求。操作系统将字符串中的字节从CPU寄存器中通过系统总线复制到显卡的显存中。显存是显卡用来存储即将显示在屏幕上的图像数据的内存区域。显存中存储的是字符的ASCII码。每个字符根据其ASCII值在显存中占据相应的空间。负责将ASCII码转换为屏幕上可见的字符。这涉及到字体渲染,即将ASCII码映射到字模库中的相应字模。字模信息被进一步转换成VRAM(视频随机存取存储器)中的数据。VRAM存储着屏幕上每个像素点的RGB颜色信息。显示芯片按照一定的刷新频率逐行读取VRAM中的数据,并将RGB信号通过信号线传输给液晶显示器,从而在屏幕上渲染出每个像素点的颜色。
8.4getchar的实现分析
getchar函数是C语言标准库中用于从标准输入(通常是键盘)读取单个字符的函数。当程序调用getchar函数时,它会等待用户输入。用户通过键盘输入的字符被存放在键盘缓冲区中。用户输入的字符会一直存放在缓冲区,直到按下回车键(Enter)。当用户按下回车键后,getchar开始从标准输入流(stdin)中读取字符,每次读取一个字符。如果用户在按下回车之前输入了多个字符,这些字符都会保留在键盘缓冲区中。后续的getchar调用将不会等待用户的进一步输入,而是直接从键盘缓冲区中读取字符。这个过程会持续进行,直到缓冲区中的字符被读取完毕。当缓冲区中的字符都已经被读取后,下一次getchar调用将再次等待用户的按键输入。
getchar函数的这种缓冲机制使得用户可以一次性输入多个字符,然后在程序中通过连续调用getchar来逐一读取这些字符,从而提高了输入效率。每次调用getchar都会返回读取的字符,直到遇到文件结束符(EOF),这时getchar会返回EOF,其值为1。
函数源代码如下:

图65 getchar源码
异步异常键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar调用系统函数read,发送一个中断信号,内核抢占这个进程,用户输入字符串,键入回车后(字符串和回车都保存在缓冲区内),再次发送信号,内核重新调度这个进程,getchar从缓冲区读入字符。
8.5本章小结
本章内容涵盖了Linux操作系统中的I/O设备管理机制,包括设备文件化、Unix I/O接口以及相关的标准库函数。我们了解到了如何通过文件描述符对设备进行操作,以及printf和getchar等常用输入输出函数的底层实现原理。这为我们在使用这些函数时提供了更深层次的认识,帮助我们更有效地进行编程和系统调用。
(第8章1分)
结论
在计算机系统的语境下,可将 hello 程序从源代码到最终执行完成的全过程逐条总结如下:
1 预处理阶段,编译器对 hello.c 源文件中的预处理指令进行解析,将头文件内容嵌入源代码,同时完成宏替换等预处理操作;
2 编译阶段,编译器对预处理后的代码开展词法与语法分析,最终生成汇编语言文件 hello.s;
3 汇编阶段,汇编器将 hello.s 中的汇编指令转换为机器语言,并生成可重定位的目标文件 hello.o;
4 链接阶段,链接器将 hello.o 与程序依赖的库文件进行关联整合,生成完整的可执行文件 hello;
5 加载运行阶段,用户在 Shell 中输入运行命令后,操作系统会为程序创建专属进程,并将程序的代码与数据加载至内存空间;
6 指令执行阶段,CPU 启动对程序机器指令的执行流程,依照程序的控制逻辑依次推进指令执行;
7 内存访问阶段,程序运行过程中,MMU(内存管理单元)负责将程序使用的逻辑地址转换为物理地址,并借助缓存系统完成数据的读取与写入;
8 内存动态分配阶段,若程序通过 malloc 等标准库函数申请动态内存,将由操作系统的内存管理器完成内存空间的分配与管理;
9 信号处理阶段,程序会响应操作系统发出的各类信号,例如用户通过键盘产生的中断信号,并执行对应的信号处理逻辑;
10 程序终止与资源回收阶段,当程序执行完毕后,操作系统会回收程序占用的各类资源,包括内存空间、进程控制块等核心资源。
透过 hello 程序的整个执行链路,我深切体会到计算机系统设计的精妙构思与复杂体系,从源代码到最终运行结果的每一个环节都经过了严谨的设计与优化,正是这种层层协同、环环相扣的架构设计,保障了系统高效稳定地运转。基于此,我的创新理念聚焦于探索更为高效、安全且智能的系统设计与实现路径,旨在通过优化各组件间的协同机制、强化资源管理的精细化程度、融入智能预判与动态适配能力等方式,进一步提升计算机系统的性能上限与安全等级,更好地适配复杂多变的应用场景需求。
(结论0分,缺失1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
| 文件名 | 功能 |
| hello.i | 预处理后得到的文本文件 |
| hello.s | 编译后得到的汇编语言文件 |
| hello.o | 汇编后得到的可重定位目标文件 |
| hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
| hello.asm | 反汇编hello.o得到的反汇编文件 |
| hello2.elf | 由hello可执行文件生成的.elf文件 |
| hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
(附件0分,缺失1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Randal E. Bryant, David R. O'Hallaron. 深入理解计算机系统(第 3 版)[M]. 龚奕利,贺莲,译。北京:机械工业出版社,2016.
[2] 唐朔飞。计算机组成原理(第 3 版)[M]. 北京:高等教育出版社,2017.
[3] 尼亚 ozuozuihao. 深入浅出计算机组成原理_计算机组成原理相关的书籍和文章博客[EB/OL]. https://blog.csdn.net/niyaozuozuihao/article/details/102736310, 2025-03-19.
[4] 腾讯云开发者社区。关于计算机组成原理,你不容错过的几本书,必有新的体会 [EB/OL]. https://cloud.tencent.com/developer/news/1328287, 2024-03-01.
转载自CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2402_87803024/article/details/156312374



