摘 要
本文主要对hello程序从代码部分到程序生成、执行、结束回收整个过程进行了详细的介绍,可执行文件hello能够读取命令行参数,每间隔一段时间打印命令行输入的数据。
从hello的文本开始叙述,经过预处理、编译、汇编和链接生成一个可执行文件,同时对进程调用、内存开销、子进程和父进程、进程回收和信号处理进行了描述,并介绍文件存储以及CPU调用内存数据的原理(包括高速缓存、RAM、页表、快页表的知识点)。在最后介绍了系统级IO,简述了文件的输入和输出。
Hello的一生经历了很多过程,本文结合所学知识逐步解析了各个过程在Linux系统中的实现及背后机制,完整探讨了hello.c从编写完成到最终执行的整个生命周期。
关键词:计算机系统;hello程序;编译;汇编;链接;进程
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
P2P(From Program to Process)过程:
对于一个C源文件到目标文件的转化分为四个步骤:预处理、编译、汇编和链接:首先经过预处理器cpp进行预处理,生成文本文件hello.i,然后经过编译器ccl生成hello.s汇编程序,接着经过汇编器as生成hello.o文件,最后经过链接器ld将其与引用到的库函数链接,生成可执行文件hello。之后shell系统会利用fork、execve等函数创建新进程并且把程序内容加载,实现程序到进程的转化。
020(From Zero-0 to Zero-0)过程:
程序运行前,shell调用execve函数加载hello程序后,将程序内容载入物理内存。在程序运行结束后,shell父进程回收进程,同时内核将控制权转移回shell,之后释放虚拟内存空间并清除相关数据。
图一、编译系统
1.2 环境与工具
1.2.1 硬件环境:
x64 CPU;3.20GHz;16GRAM;
1.2.2 软件环境:
windows10 64位;VMware Workstation Pro16.2.2;Ubuntu 23.04
1.2.3 开发工具:
gcc;vim;edb;objdump;CodeBlocks
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 文件含义 |
hello.c | 源文件、文本文件 |
hello.i | 预处理文件 |
hello.s | 汇编文件 |
hello.o | 可重定位目标文件 |
hello | 目标文件 |
hello.elf | 可重定位的elf格式文件 |
hello2.elf | hello的elf格式文件 |
hello.asm | hello的反汇编文件 |
hello2.asm | hello.o的反汇编文件 |
1.4 本章小结
本章主要介绍了hello.c P2P和020的过程。写出了本次实验所需的环境和工具以及过程中所生成的中间结果文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
在编译过程中,预处理是编译器的第一个阶段之一,其主要目的是对源代码进行一些预处理操作,以便为后续的编译阶段做准备。预处理器通常执行以下几项任务:
- 移除注释: 将源代码中的注释(如单行注释 // 或多行注释 /* */)去除,以便编译器不会考虑这些注释内容。
- 展开宏定义: 处理源代码中的宏定义,将宏替换为其定义的内容。例如,将#define PI 3.14159替换为代码中的3.14159。
- 包含头文件: 处理#include指令,将头文件的内容插入到源文件中。这样,可以在源文件中使用其他文件中定义的函数、变量等。
- 条件编译: 处理条件编译指令(如#if、#ifdef、#ifndef、#else、#elif和#endif),根据条件判断编译哪些代码块,排除不需要的部分。
- 删除空行和空格: 去除源代码中的多余空格、空行等,以便提高编译效率和减小生成的目标文件大小。
2.2在Ubuntu下预处理的命令
应截图,展示预处理过程!
2.3 Hello的预处理结果解析
可以发现代码中对源文件中定义的宏进行了展开,将头文件中的内容包含到这个文件中。
2.4 本章小结
本章主要介绍了hello.c程序的预处理,包括预处理的概念和作用,进行的hello.c文件的预处理展示。预处理作为编译运行的第一步,.i文件可以更加直观的感受到预处理前后源文件的变化。
第3章 编译
3.1 编译的概念与作用
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
概念:编译(Compiling)是指将高级程序代码转换为低级机器代码或其他可执行形式的过程。编译的过程包括了从预处理后的文件(通常以 .i 结尾,表示经过预处理的源文件)到汇编语言程序(通常以 .s 结尾)的转换。编译的主要目标是将高级语言的代码转换为汇编语言代码,以便后续可以通过汇编器和链接器生成可执行程序。
作用:
语法检查和错误提示:编译器能够对源代码进行语法分析,帮助开发者在编码阶段发现并修正问题。
代码优化:编译器可以对源代码进行各种优化,以提高程序的性能、减少资源消耗或改善代码结构。常见的优化包括常量折叠、循环展开、函数内联等。
目标代码生成:编译器将经过语法分析和优化后的源代码转换为目标平台上的机器代码或其他可执行形式,以便在计算机上运行。
内存管理:编译器可能会负责分配和管理程序运行时所需的内存空间,包括静态内存分配和动态内存分配。
调试支持:一些编译器提供与调试器的集成,能够生成调试信息或支持调试信息的查看,帮助开发者在程序出现问题时进行调试。
3.2 在Ubuntu下编译的命令
应截图,展示编译过程!
3.3 Hello的编译结果解析
3.3.1 数据
本程序中只有常量和局部变量。
常量(字符串常量)
c程序里有两处:"用法: Hello 学号 姓名 秒数!\n"和"Hello %s %s\n"
数字常量
局部常量:在汇编语言中被放在寄存器或栈上
3.3.2 赋值
movq %rax, %rax将%rax中的值赋给%rax
3.3.3 算数操作
subq $32, %rsp减去栈指针的值
3.3.4 关系操作
cmpl $5, -20(%rbp)比较-20(%rbp)中的值和5的大小
3.3.5 控制转移
je用于判断cmpl比较的结果,若两个操作数的值不相等则跳转到指定地址。
3.3.6 逻辑/位操作:
根据比较结果跳转到.L2
3.3.7 函数操作
参数传递:传入参数argc和*rgv[],分别用寄存器%rdi和%rsi存储。
函数调用:被系统启动函数调用。
函数返回:设置%eax为0并且返回,对应return 0
3.4 本章小结
本章介绍了编译的概念和作用,尝试对预处理后的代码进行了编译得到hello.s文件,分析了编译的结果,对编译过程和汇编语言有了更加深入的认识。
第4章 汇编
4.1 汇编的概念与作用
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
汇编是指汇编器将以.s结尾的汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在.o目标文件中的过程,这些文件可以进一步链接成最终的可执行程序。
汇编语言的主要作用包括:
直接控制硬件:汇编语言允许程序员直接控制计算机的硬件资源,这使得编写底层系统程序和设备驱动程序成为可能。
优化性能:由于汇编语言可以更精确地控制计算机的底层操作,因此程序员可以编写更高效的代码,以优化程序的性能和资源利用率。
理解底层原理:通过学习汇编语言,程序员可以更深入地理解计算机的底层工作原理,包括指令执行、内存管理和寄存器操作等方面。
嵌入式系统开发:在嵌入式系统开发中,通常需要对硬件进行直接控制,而汇编语言是实现这一目标的重要工具之一。
4.2 在Ubuntu下汇编的命令
(以下格式自行编排,编辑时删除)
应截图,展示汇编过程!
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
ELF文件的标识符:7f 45 4c 46。02 01表示ELF文件的种类,01表示32位,02表示x86-64系统,01表示字节序,后面为保留字段,无含义。
节头信息:
变量类型:
.rodata--只读数据段,对应C语言类型中的const类型
printf中的字符串--常量类型
.string--字符串类型
.glabal以及.main--局部变量
循环操作:
jmp指令--根据c语言源代码我们可以推得这是循环开始的一个操作,在L3中会与循环条件比对并判断是否继续执行。不难发现这里的jmp(jle)后跳转的地址都是一个参数,而不是具体的地址,其根本原因是还需要后续编译器提供实际的地址。
数据操作:
cmp--将两个数据进行比较,并根据比较结果改变条件码的数据。
mov--将一个寄存器的数据移动到另一个寄存器
add--加法
函数调用:
call printf@PLT,call getchar@PLT--因为printf和getchar都还未重定位,所以这里用一个指代完成对这两个函数的调用。
返回值:
ret--退出当前指令并返回返回值,一般返回值默认保存在rax寄存器中
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,与第3章的 hello.s进行对照分析。
左侧为机器语言反汇编得到的结果,右为汇编程序。
可以看到机器语言程序是由机器指令组成的,反汇编中每一行的1到5个字节不等的16进制数表示就是一条机器指令,对应汇编语言中的一行。机器指令可以是变长的,常用的,操作数少的指令字节数少;不常用的,操作数多的指令字节数多。
机器语言中所有的操作数都是16进制形式的,而汇编语言里操作数可以有10进制形式的。机器语言中call后面跟的是该函数的地址,而汇编语言中call后面跟的是函数名。机器语言中跳转的目标是地址,而汇编语言中跳转的目标是标号。
4.5 本章小结
本章中我们列出的汇编的概念作用,使用汇编和反汇编指令得到了hello.o和hello.elf文件,并对结果进行了说明,与第3章的 hello.s进行对照分析。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接(Linking)是将多个目标文件(object files)或库文件(library files)合并成一个可执行程序或共享库的过程。在编程中,通常会将一个项目分成多个文件来组织代码,每个文件编译后生成一个目标文件。链接器(Linker)负责将这些目标文件中的符号(symbols)解析并连接起来,以创建一个完整的可执行程序或库文件。
链接的主要作用包括:
符号解析:链接器负责解析每个目标文件中引用的符号,包括函数、变量和其他命名对象。它将这些符号解析为内存地址,以便程序可以正确地访问和调用它们。
符号重定位:当程序中的某些符号引用的地址在编译时是未知的(例如,外部函数或变量),链接器负责将这些符号的地址映射到正确的位置,以确保程序在运行时能够正确地访问它们。
库链接:链接器还可以将目标文件与库文件(如静态库或动态库)进行链接,以便在程序中使用库中提供的函数和资源。
生成可执行文件或共享库:链接器最终将所有的目标文件和库文件组合在一起,生成一个完整的可执行程序或共享库,供用户执行或其他程序使用。
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
头信息:
节头部表信息(包含了每一个节的名称、大小、类型、地址、偏移量等信息)
程序头
段节相关信息
重定位信息
地址信息
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息
Data Dump查看虚拟地址:
5.5 链接的重定位过程分析
可以发现,通过反汇编代码中多了很多指令,原因是在编译的时候动态链接将系统中其他的标准库函数引入。同时hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
在链接过程中,链接器会根据hello.o中的重定位项目,将其中引用的符号与其他目标文件或库文件中的定义进行匹配,并进行相应的地址替换和调整。这样,最终生成的可执行文件hello中的代码和数据就可以正确地与其他模块进行连接和执行。
5.6 hello的执行流程
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数),调用与跳转的各个子程序名或程序地址如下:
<_init>:401000
<.plt>:401020
<puts@plt> :401090
<printf@plt>:4010a0
<getchar@plt>:4010b0
<atoi@plt>:4010c0
<exit@plt>:4010d0
<sleep@plt>:4010e0
<_start>:4010f0
<_dl_relocate_static_pie>:401120
<main> :401125
<_libc_scu_init>:4011c0
<_libc_csu_fini>:401230
<_fini>: 401238
5.7 Hello的动态链接分析
可以发现,动态链接之前0x401000地址存的是INIT的虚拟地址,而动态链接后变成了实际地址。
5.8 本章小结
本章介绍了链接的概念与作用,也通过在Ubuntu下的命令获得了可执行目标文件hello的格式。在edb中查看了hello的虚拟地址空间,分析反汇编与hello.o的不同后,也对hello的动态链接进行了分析,为下章的操作做了准备。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
在计算机系统中,进程是指正在运行的程序的实例。每个进程都有自己的内存空间、代码、数据和运行时的状态。进程是操作系统进行资源分配和管理的基本单位,它可以独立运行并与其他进程隔离。
进程的作用:
现代计算机中给每个进程一个独立的PID,这使得程序员可以更好的调度某个正在运行程序的资源和数据。同时,每个程序独占一个进程可以更好的保护程序内部资源。CPU的一个核只能处理一个进程,这使得计算机的硬件资源能够得到更好的使用。进程为程序提供了两个抽象:逻辑控制流和私有地址空间。逻辑控制流使得每个进程都好像独立占用cpu,而私有地址使得每个程序好像独立占有系统内存。
6.2 简述壳Shell-bash的作用与处理流程
Shell 是一种命令行解释器,用于与操作系统进行交互,它接收用户输入的命令并将其转换成操作系统能够理解的指令。而 bash(Bourne Again Shell)则是其中最常见和流行的一种 Unix Shell。
Shell 的作用:
提供了用户与操作系统交互的界面,用户可以通过 Shell 输入命令执行各种操作,如创建文件、管理进程、设置环境变量等。
解释并执行用户输入的命令,将其转换成系统调用或其他合适的指令,从而实现用户的需求。
支持脚本编程,用户可以编写 Shell 脚本来自动化执行一系列操作,提高工作效率。
处理流程:
用户在命令行界面输入命令,按下回车键。
操作系统将输入的命令传递给当前正在运行的 Shell 进程。
Shell 解析命令,检查命令是否存在、语法是否正确,并根据需要执行相应的操作。
如果命令是内置命令(如 cd、echo 等),则 Shell 直接执行对应的功能。
如果命令是外部命令(如 ls、grep 等),则 Shell 会在系统路径中查找可执行文件,并调用相应的程序执行命令。
执行完命令后,Shell 将结果输出到标准输出(通常是显示在命令行界面上)。
Shell 进入等待用户下一次输入的状态,重复以上流程。
总的来说,Shell 是用户与操作系统之间的桥梁,通过解释和执行用户输入的命令,实现了用户对计算机系统的控制和操作。
6.3 Hello的fork进程创建过程
首先,系统级父进程init调用fork()函数能够创建子进程,子进程能够共享父进程的文件资源,在init中由于init是整个系统进程,所以可以看成创建的子进程为单独一个进程,且占用整个内存。Fork()会返回两个值,在父进程中返回子进程pid,在子进程中返回0。
6.4 Hello的execve过程
execve()系统调用用于在当前进程中执行一个新的程序。它实际上是替换了当前进程的映像,而不是像fork()那样创建一个新的进程。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
- 加载程序:
首先,操作系统会根据指定的路径加载 "Hello" 程序的可执行文件。这个过程涉及文件系统的操作,操作系统会读取程序文件并将其加载到内存中。
- 创建进程:
在加载程序后,操作系统会为 "Hello" 程序创建一个新的进程。这个新进程将执行 "Hello" 程序的代码,并拥有自己的进程上下文,包括进程 ID、内存空间等。
- 执行程序:
一旦进程创建完成,操作系统会将程序的执行权限交给这个新的进程。 "Hello" 程序的代码会被加载到进程的内存空间中,并且程序的执行会从程序的入口点开始。
- 程序运行:
"Hello" 程序开始在新的进程中运行。它可能会执行一系列操作,这取决于程序的实现。在本例中,程序会打印 "Hello"。
- 程序结束:
一旦程序完成了它的任务,它会通知操作系统,并退出执行。这个时候,操作系统会清理相关资源,包括释放进程所占用的内存空间等。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的:
正常情况
不停乱按(不包括回车)
(回车)
Ctrl-Z 使用SIGTSTP信号停止前台作业
Ctrl-C 使用SIGINT信号终止前台进程
Ctrl-Z 后运行ps jobs pstree fg kill
6.7本章小结
在本章中了解的hello进程的执行流程,也展示了 hello执行过程中会出现哪几类异常,会产生哪些信号,以及他们的处理方式。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
在计算机系统中,程序在执行时需要访问内存中的数据和指令,因此需要用到地址的概念,不同的地址代表了不同的抽象层次。
逻辑地址是指进程代码访问内存的地址空间,是由程序生成的地址,由段基址和偏移量组成,与实际物理内存无关。
线性地址是指逻辑地址经过分段、分页机制转换后得到的地址,由描述符和偏移量组成,地址空间中整数连续,它是虚拟地址空间的地址。
虚拟地址是指应用程序对内存的地址请求,即程序所看到的地址,也就是逻辑地址和线性地址的总和。
物理地址是指实际存在于内存中的地址,通过页表机制由线性地址转换而来。用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
执行hello程序时,程序的指令和数据首先被加载到逻辑地址空间,经过分段、分页转换后得到线性地址,最终通过页表机制转换为物理地址,才能被实际执行。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel处理器使用分段来管理内存。在这种模式下,逻辑地址由段选择器和偏移量组成。段选择器用于选择一个段,偏移量表示在选定的段中的偏移。
段选择器和偏移量的组合:首先,CPU将逻辑地址中的段选择器和偏移量组合成一个48位的线性地址。这个过程是通过使用段选择器中的索引乘以一个固定值(段描述符的大小)来计算段描述符的起始地址,然后加上偏移量得到的。
段描述符的获取:接下来,CPU使用计算得到的线性地址来访问描述符表,通常是全局描述符表或局部描述符表。每个描述符都包含了段的基地址和大小等信息。
段的基地址和大小:CPU从描述符中提取出段的基地址和大小信息。
线性地址的计算:CPU将逻辑地址中的偏移量与段的基地址相加,得到线性地址。
访问内存:最后,CPU将线性地址发送到内存管理单元,MMU根据页表或段表将线性地址转换为物理地址,然后用于访问内存。
7.3 Hello的线性地址到物理地址的变换-页式管理
将程序的逻辑地址空间和物理内存划分为等长的页面,这种分配方式便于维护,且不容易产生碎块。
虚拟页面的集合分为三个不相交的子集:
未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。
缓存的:当前已缓存在物理内存中的已分配页。
未缓存的:未缓存在物理内存中的已分配页。
7.4 TLB与四级页表支持下的VA到PA的变换
(
在计算机系统中,虚拟地址到物理地址的转换是通过页表来实现的。TLB是一个缓存,用于存储最近的一些虚拟地址到物理地址的映射,以提高地址转换的速度。四级页表是一种页表结构,将虚拟地址空间分割成多个层级,以便有效地管理大量的内存页。
使用TLB和四级页表进行VA到PA转换的步骤:
当CPU执行一个指令时,它会生成一个虚拟地址。
虚拟地址被送入TLB进行查找。如果TLB中有这个虚拟地址的映射,则TLB会返回相应的物理地址。
如果TLB中没有找到对应的映射,CPU会使用虚拟地址的高位来索引四级页表的根节点。
四级页表的根节点会指向下一级页表(或者页表页),依此类推,直到找到最底层的页表项。
最底层的页表项包含了物理页的地址,通过将虚拟地址的偏移部分与页表项中的偏移部分相加,得到物理地址。
整个过程中,TLB的作用是加速常用的地址映射,减少了对页表的访问次数,从而提高了地址转换的速度。如果TLB中没有找到对应的映射,则需要通过访问页表来完成地址转换,这会增加一定的延迟。
7.5 三级Cache支持下的物理内存访问
在拥有三级缓存的体系结构中,物理内存的访问通常会涉及到多个层级的缓存和内存控制器。
一个基本的物理内存访问过程:
- CPU访问缓存:当CPU需要访问内存中的数据时,首先会检查最高级别的缓存,即L1缓存。如果所需的数据已经在L1缓存中,则CPU可以直接从缓存中读取数据,这样可以极大地提高访问速度。如果数据不在L1缓存中,CPU会继续检查更低级别的缓存,如L2缓存和L3缓存,直到找到所需的数据或者在所有缓存中都没有找到。
- 缓存未命中:如果数据在缓存中未命中(不在缓存中),CPU就需要从更慢的主存中读取数据。在这种情况下,CPU会发送一个请求给内存控制器,请求所需的数据。
- 内存控制器访问内存:内存控制器负责管理系统中的内存模块,并响应来自CPU的读写请求。一旦内存控制器收到请求,它会在物理内存中定位所需的数据,并将其传输到CPU的缓存层级中。如果所需的数据已经存在于主存的缓存行中,则内存控制器可以直接将数据传输到CPU的缓存中。
数据返回到缓存:一旦数据从主存传输到CPU的缓存中,CPU就可以在缓
- 存中访问所需的数据。同时,内存控制器也会更新相关的缓存行,以确保缓存中的数据与主存中的数据保持一致。
7.6 hello进程fork时的内存映射
当一个进程调用fork()系统调用来创建一个新的进程时,新进程将会复制父进程的地址空间。这意味着,新进程将会有与父进程相同的内存映射,包括代码段、数据段、堆、栈等。
具体来说,在fork()调用后,操作系统会执行以下操作来完成内存映射:
- 复制页表:操作系统会复制父进程的页表到新进程的页表中。页表是一种数据结构,用于将虚拟地址映射到物理地址。
- 写时复制:在大多数现代操作系统中,fork()系统调用采用了写时复制(copy-on-write)的技术。这意味着在新进程被创建时,并不会立即复制父进程的内存页,而是将这些内存页标记为只读,并且它们与父进程共享相同的物理内存页。只有在新进程或父进程尝试修改其中一个内存页时,操作系统才会将该内存页复制一份,以确保新进程和父进程的内存空间是相互独立的。
- 更新页表:如果发生写时复制,操作系统会更新新进程的页表,使其指向新分配的物理内存页,而不是父进程的共享内存页。
- 返回新进程:最后,操作系统将控制权交给新进程,并开始执行新进程的代码。
7.7 hello进程execve时的内存映射
在执行execve系统调用时,当前进程的内存映射会被清除,并被新的可执行文件所指定的内存映射所替代。这是因为execve系统调用用于加载并执行一个新的程序,因此旧程序的内存映射不再有效。
当调用execve时,操作系统会执行以下步骤来创建新的内存映射:
- 清除旧的内存映射:当前进程的所有内存映射将被清除。这包括程序代码、数据、堆栈等区域的映射。
- 加载新的可执行文件:操作系统会将新的可执行文件加载到内存中,创建新的程序代码、数据和其他段的内存映射。这通常包括程序的代码段、数据段、堆、栈等。
- 设置程序入口点:操作系统会设置新程序的入口点,即程序开始执行的位置。
- 初始化堆栈和参数:操作系统会初始化一个新的堆栈,用于新程序的函数调用和局部变量等。将传递给execve的参数传递给新程序。这些参数通常包括命令行参数、环境变量等。
- 执行新程序:一旦所有准备工作完成,操作系统会开始执行新程序,从其入口点开始执行。
7.8 缺页故障与缺页中断处理
当程序试图访问虚拟内存中的某个页面,而该页面并未装入物理内存时,就会发生缺页故障。这时,操作系统需要进行相应的缺页中断处理,以下是缺页中断处理的基本流程:
- 触发缺页中断:当程序访问的页面不在物理内存中时,CPU会产生缺页中断,将控制权交给操作系统内核。
- 操作系统的响应:操作系统内核捕获缺页中断,并开始处理该异常情况。
- 确定缺页位置:操作系统内核首先需要确定引起缺页中断的虚拟页面的位置,包括页面号、进程标识符等信息。
- 查找页面:操作系统内核会查找页面是否在辅存(通常是硬盘或者固态硬盘)中,如果页面已经在辅存中,则需要将其调入到物理内存中。
- 更新页表:一旦页面被调入物理内存,内核需要更新页表,建立虚拟地址到物理地址的映射关系。
- 恢复执行:最后内核会返回到引起缺页中断的指令,并重新执行该指令,这时由于页面已经在物理内存中,程序可以正常访问所需的数据。
缺页中断处理是虚拟内存管理的核心部分,它允许操作系统将程序的部分数据存储在辅存中,从而有效地扩展了可用的物理内存空间。通过将不常用的页面置换到辅存中,并在需要时再次调入到物理内存中,操作系统能够更加高效地利用有限的内存资源。
7.9动态存储分配管理
Printf会调用malloc
动态存储分配管理是指操作系统或运行时环境动态地分配和释放内存空间,以满足程序在运行过程中对内存的需求。涉及以下几个方面:
- 内存分配算法:动态存储分配管理需要使用一定的算法来决定如何分配可用的内存空间。常见的内存分配算法包括首次适应、最佳适应和最坏适应等。这些算法根据空闲内存块的大小、位置等条件来选择合适的内存块进行分配。
- 内存分配数据结构:为了有效地管理内存分配,需要使用适当的数据结构来跟踪已分配和未分配的内存块。常见的数据结构包括空闲块列表、内存映射表、位图等。
- 内存碎片整理:动态存储分配容易导致内存碎片的产生,造成内存资源的浪费。因此,管理器通常需要实现内存碎片整理机制,通过合并相邻的空闲块来减少内存碎片,以提高内存利用率。
- 内存回收:动态存储分配管理也需要负责回收不再使用的内存空间,以便将其重新分配给其他程序使用。内存回收通常通过释放操作或者垃圾回收机制来实现。
- 内存保护:在多任务操作系统中,动态存储分配管理还需要考虑内存保护的问题,确保每个程序只能访问其分配的内存空间,防止程序之间相互干扰。
7.10本章小结
本章主要介绍了存储器地址空间中各种类型地址的含义,分析了通过段式管理实现逻辑地址到线性地址的变换、页式管理实现线性地址到物理地址变换的过程。还介绍了TLB与四级页表及三级Cache下的地址翻译及访问过程,进程调用fork、execve时的内存映射、缺页故障和处理等相关内容。进一步加深了我对程序的存储管理的理解。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
UNIX I/O接口是UNIX操作系统提供的一套用于进行输入和输出操作的接口,它提供了一系列函数来进行文件操作、设备访问等。这些函数通常包含在不同的头文件中,例如 <stdio.h>、<fcntl.h> 和 <unistd.h> 等。
常用的UNIX I/O函数及其功能:
open():用于打开文件,并返回文件描述符,如果文件打开失败,则返回-1。函数原型为:int open(const char *path, int flags);
close():关闭一个打开的文件描述符。函数原型为:int close(int fd);
read():从文件描述符中读取数据,并将其存储到缓冲区中。函数原型为:ssize_t read(int fd, void *buf, size_t count);
write():将数据从缓冲区写入到文件描述符中。函数原型为:ssize_t write(int fd, const void *buf, size_t count);
lseek():设置文件描述符的读/写位置。函数原型为:off_t lseek(int fd, off_t offset, int whence);
fcntl():用于对文件描述符进行各种控制操作,如设置文件描述符的属性。函数原型为:int fcntl(int fd, int cmd, ... /* arg */);
ioctl():对设备进行控制操作,如设置设备参数、发送控制命令等。函数原型为:int ioctl(int fd, unsigned long request, ...);
dup() / dup2():复制文件描述符,使多个文件描述符指向同一个文件表项。函数原型为:int dup(int oldfd); int dup2(int oldfd, int newfd);
pipe():创建管道,用于在两个进程之间进行通信。函数原型为:int pipe(int pipefd[2]);
select() / poll() / epoll():用于多路复用I/O操作,以便同时监视多个文件描述符的I/O状态。select() 和 poll() 是传统的方法,而 epoll() 是Linux特有的高性能机制。
8.3 printf的实现分析
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
这段代码是一个简化版的 vsprintf() 函数实现,它接收一个格式化字符串 fmt 和一个 va_list 类型的参数列表 args,将格式化后的字符串存储到缓冲区 buf 中。这个函数实现了对 %x 类型的参数的处理,即将整数参数以十六进制格式输出到缓冲区。
工作原理:
char* p;:定义一个指针 p,用于指向缓冲区 buf。
char tmp[256];:定义一个临时数组 tmp,用于临时存储格式化后的字符串。
va_list p_next_arg = args;:将传入的参数列表 args 复制一份给 p_next_arg。
for (p=buf; *fmt; fmt++) { ... }:遍历格式化字符串 fmt,直到遇到字符串结束符 \0。
if (*fmt != '%') { ... }:如果当前字符不是格式化字符 %,直接将其复制到缓冲区 buf 中。
fmt++;:移动指针,跳过 % 符号。
switch (*fmt) { ... }:根据格式化字符的类型进行处理。
case 'x'::处理十六进制整数的情况。
itoa(tmp, *((int*)p_next_arg));:将整数参数转换为字符串并存储到临时数组 tmp 中。
strcpy(p, tmp);:将临时数组 tmp 中的字符串复制到缓冲区 buf 中。
p_next_arg += 4;:移动参数列表指针到下一个参数。
p += strlen(tmp);:移动缓冲区指针到新添加的字符串的末尾。
case 's'::处理字符串的情况,这部分代码没有实现。
default::处理其他格式化字符的情况,这部分代码也没有实现。
return (p - buf);:计算格式化后的字符串的长度,并返回。
8.4 getchar的实现分析
getchar() 函数通常用于从标准输入流中读取一个字符。它的实现方式可以根据操作系统和编程语言的不同而异,但一般而言,它会调用底层的系统函数来实现字符的获取。在UNIX或类UNIX系统中,getchar() 可能会使用 read() 系统调用来从标准输入中读取字符。这个过程中可能会涉及到键盘中断的处理。
异步异常 - 键盘中断的处理:
当用户按下键盘时,键盘会发送一个中断信号给计算机,通知它有按键事件发生了。
操作系统会响应这个中断,并执行相应的中断处理程序。
中断处理程序会读取键盘控制器中的数据,将按键的扫描码转换成相应的 ASCII 码,然后将这个 ASCII 码放入系统的键盘缓冲区中。
getchar() 的实现:
当程序调用 getchar() 时,它会调用底层的系统函数来获取字符。
在UNIX系统中,通常会调用 read() 系统调用来从标准输入流中读取字符。
read() 函数会检查标准输入流中是否有字符可读。如果没有,它可能会阻塞程序的执行,直到有字符可读或者发生了错误。
当键盘中有字符可读时,read() 函数会从键盘缓冲区中读取一个字符,然后将这个字符返回给调用 getchar() 的程序。
等待回车键的输入:
在大多数情况下,getchar() 函数会一直读取字符,直到接收到回车键才返回。
这意味着用户在输入字符时,可以在按下回车键之前输入任意数量的字符。
一旦用户按下了回车键,getchar() 就会返回刚刚输入的第一个字符,而其余的字符则会留在输入缓冲区中等待后续的读取。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf和getchar函数的实现。
(第8章1分)
结论
hello 程序“艰辛”的一生包含以下阶段:
- 预处理:预处理器cpp将头文件内容插入程序文本中,完成字符串替换和删除多余空白字符,生成包含完整程序代码的预处理文件hello.i。
- 编译:通过词法分析和语法分析等,编译器ccl将hello.i翻译成具备在指令级别上控制硬件资源能力的汇编语言文件hello.s。
- 汇编:汇编器as将汇编程序翻译成机器语言指令,而后打包成可重定位目标程序hello.o。
- 链接:链接器ld将hello.o与动态链接库链接整合为单一文件,生成完全链接的可执行目标文件hello。
- 进程载入:通过Bash键入命令,操作系统为程序fork新进程并通过execve加载代码和数据到为其提供的私有虚拟内存空间,程序开始执行。
- 进程控制:由进程调度器对进程进行时间片调度,并通过上下文切换实现hello的执行,程序计数器(PC)更新,CPU按顺序取指,执行程序控制逻辑。
- 内存访问:内存管理单元MMU将逻辑地址逐步转换成物理地址,通过三级Cache访问物理内存/磁盘中的数据。
- 信号处理:进程接收信号,调用相应的信号处理函数对信号进行终止、停止、前/后台运行等处理。
- 进程回收:Shell等待并回收子进程,内核删除为进程创建的所有资源。
计算机系统的设计与实现涉及多个领域,包括硬件、操作系统、编译器、网络等。即使是一个简单的 hello.c也需要操作系统综合其他部分进行许多复杂的操作,并且每一步都经过了设计者的深思熟虑,需要在有限的硬件资源下尽可能地提高了程序的时间和空间性能。
在计算机系统这门课上的学习和实操让我深刻认识到计算机系统的复杂性和重要性,它们是现代科技和生活的基石。深入理解计算机系统的设计原理和实现细节有助于我们提高代码编写和性能优化的能力,更好的提升作为新医工专业学生的专业素养,服务健康中国战略。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
共九个
hello.c:hello程序的源代码,文本文件
hello.i:经过预处理修改了的源程序,文本文件
hello.s:编译器翻译成的汇编程序,文本文件
hello.o:汇编器翻译汇编程序为二进制机器语言指令,可重定位的目标程序
hello:调用printf.o函数,经过链接器后得到的可执行文件
hello.elf:hello.o的elf格式文件
hello2.elf:hello的elf格式文件
hello.asm:hello的反汇编文件
hello2.asm:hello.o的反汇编文件
(附件0分,缺失 -1分)
参考文献
- 《深入理解计算机系统》Randal E.Bryant David R.O’Hallaron 机械工业出版社
- 段页式访存-逻辑地址到线性地址转换https://www.jianshu.com/p/fd2611cc808e
(参考文献0分,缺失 -1分)