HIT-2024CSAPP 程序人生-Hello‘s P2P大作业

摘要

        本文借助hello.c程序,结合本学期计算机系统课程所学习的大部分内容,分析了hello.c这个程序的一生,探讨了从源程序到可执行程序转变的全过程,包含预处理,编译,链接,生成等步骤,在Ubuntu系统下完成了从程序到进程的转变,也进一步加强了对于计算机系统这门课程的理解与认知。

关键词:计算机系统;源程序;可执行程序;Ubuntu;                           

目录

第一章 概述

1.1 Hello简介

        根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

        P2P是指Program to Process 首先hello是在磁盘上的一个程序文件即为“Program”,在使用运行该代码文件时,首先通过预处理器处理hello.c文件,此时会生成hello.i为预处理后的程序,然后通过编译器生成hello.s文件,之后汇编程序交给汇编器汇编得到一系列机器语言指令,产生可重定位程序hello.o,可重定位文件通过链接器经过链接生成可执行目标程序,也就是hello程序,此时在shell中输入./hello,shell会调用fork,exceve等函数和指令为其创建进程,也就是变为“Process”,实现了P2P的全过程。

        020是指程序从无到有及“from 0 to 0”,开始为“0”,意味着在内存中没有包含hello程序的相关内容,在shell中输入相关命令后,shell会调用fork函数为程序创建进程,然后通过exceve载入内存,执行相关代码,程序运行结束后,该进程被回收,内核清除这一进程的所有信息,此时再次归零,即“to 0”。

1.2 环境与工具

        列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。

        硬件环境:处理器:Intel® Core™ i7-1260P

        RAM:16.00GB

        系统:64位操作系统,基于x64处理器

        软件环境:Windows11 64位;Ubuntu20.04

        开发及调试工具: vscode,gcc as,ld,vim,edb,readelf,gedit,gdb

1.3 中间结果

hello.c

源程序

hello.i

预处理后的修改的c程序

hello.s

汇编程序

hello.o

可重定位目标文件

hello

可执行目标程序

obj_hello.s

hello.o的反汇编文件

hello1.elf

hello的elf文件

obj1_hello.s

hello的反汇编文件

elf.txt

hello.o的elf文件

1.4 本章小结

        在第一章中,简要介绍了Hello的P2P,020的整个过程,以及介绍了本论文所使用的软件和硬件环境和在实验过程中所产生的中间文件和结果。

第二章 预处理

2.1 预处理的概念与作用

        预处理的概念:预处理指程序在计算机编译之前所进行的处理,是计算机处理程序的第一步处理,预处理器根据以字符#开头的命令,对原始的c程序进行修改。例如#include,#define等等,然后将修改的文本保存。

        作用:预处理将例如#include后继头文件的内容插入到程序文本中,以及处理宏定义,条件编译,特殊符号等,还会删除c语言源程序中的注释程序,对源代码进行相应的分割,替换,最终生成.i为后缀的文本文件。

2.2在Ubuntu下预处理的命令

        在Ubuntu系统下,预处理的命令是:cpp hello.c > hello.i

        截图如下:

                                                          图2.1 预处理

2.3 Hello的预处理结果解析

        Hello.c源程序代码文件有29行,但经过预处理后的文件有3092行,原来的源代码部分在3079行到3092行,在这之前为hello所引用的所有的头文件内容的展开,如stdio.h unistd.h stdlib.h等,如果处理后仍然有以字符“#”开头的内容,则继续进行处理,直到hello.i文件中没有宏定义为止.

                                                          2.2 头文件信息

2.4 本章小结

        在第二章中介绍了预处理过程中预处理所进行的工作,包括头文件展开,宏替换,删除注释等,并且结合Ubuntu系统下对hello.c文件经过预处理后的结果进行了分析。

第三章 编译

3.1 编译的概念与作用

        编译的概念:编译器(ccl)将文本文件hello.i翻译为文本文件hello.s的过程,包含了一个汇编语言程序。

        编译的作用:将源代码输入扫描器进行词义分析,然后生成语法分析树进行语义分析,在编译的过程中,编译器还能起到优化代码的作用,最后实现代码生成,得到hello.s文件,为后续二进制机器码的转化做准备。

3.2 在Ubuntu下编译的命令

        在Ubuntu下编译的命令为:gcc -S hello.i -o hello.s

        在Ubuntu下编译运行的结果如下:

                                                         3.1 编译

3.3 Hello的编译结果解析

3.3.1数据

(1)常量

        数字常量:在源代码中使用的数字常量都是储存在.text 段的,包括在比较的时候使用的数字变量等,在循环的时候使用的循环比较变量等,具体情况见如下截图:

                                                      3.2数字变量的储存情况

        字符串常量:在printf等函数中所打印出来的字符串常量通常是存储在.rotate段中,具体的情况见如下截图:

                                                        3.3字符串变量储存情况

(2)变量

        全局变量: 全局变量经过编译后放在了.data段,这一变量的初始化不需要其他的汇编语句,在刚开始的时候就被编译好了。

        局部变量:局部变量存储在栈中的某一个位置或者直接存储在寄存器中,对于源代码中的局部变量进行分析,局部变量有三个,循环变量i,argv和argc

        对于循环变量i存储在栈中地址为-4(%ebp)的位置,对于i的操作见下图:

                                                     图3.4 局部变量i的储存情况

        对于局部变量argc,标志的在程序运行时候输入变量的个数,存储在栈中地址为-20(%ebp)的位置,具体的汇编代码如下图所示:

                                                   图3.5 局部变量和argc的储存情况

                对于局部变量argv,保存输入变量,存储在栈中,具体的汇编代码如下图所示:

                                                       图3.6 局部变量argv的存储情况

3.3.2赋值

对于变量的赋值,可以看到对于循环变量i在循环的过程中不断进行赋值,在每次循环结束的时候都对齐进行+1的操作,具体的操作如下图所示:

                                                      图3.7 对局部变量i的赋值操作

3.3.3算数操作

        对于局部变量i,是循环变量,在每一轮都要对这个值进行修改,其对应的算术操作的汇编代码如下图所示:

                                                           图3.8算术操作

3.3.4关系操作

        第一处是对argc的判断,当是否等于5时进行条件跳转,如图所示:

                                                         图3.9关系操作源代码

        对应的汇编代码如下:

                                                         图3.10关系操作汇编代码

3.3.5数组/指针/结构操作

        在本程序中对于数组的操作是对于argv数组的操作,观察代码得argv的值都存放在栈中,argv[0]-argv[4]的存储地址依次递减8,对于数组操作的汇编代码如下:

                                                          图3.11 数组操作

3.3.6函数调用

        在x86系统中第一到六个参数一次存储在%rdi,%rsi,%rdx,%rcx,%r8,%r9六个寄存器中,其余的参数保存在栈的某个位置。

Main函数:

        Main函数传递采参数argc和argv,其中argv存储在栈中,而argc存储在%rdi寄存器中,函数返回语句是return 0,即在汇编代码中奖返回寄存器%eax设置成0并返回,其汇编代码如下图所示:

                                                             图3.12 main函数汇编代码

Printf函数:

        Printf函数在调用的时候传入字符串参数首地址和在循环中传入了argv[1],argv[2]等五个参数的地址,其汇编代码如下图所示:

                                                               图3.13 printf函数汇编

Sleep函数:

        在for循环中每一次循环都会调用一次Sleep函数,其汇编代码如下:

                                                              图3.14 sleep函数汇编

Atoi函数:

        同sleep函数,每次循环调用一次,汇编代码如下:

                                                              图3.15 atoi函数汇编

Exit函数:

        Exit函数传入的参数为1时会执行退出命令,在本程序中满足if条件是会进行调用,汇编代码如下:

                                                               图3.16 exit函数汇编

3.4 本章小结

        在第三章中介绍了编译的概念和作用,同时在Ubuntu系统下以编译好的hello.s文件为例,详细介绍了编译器在编译过程中是如何处理各种数据类型和各类操作,并且对这些操作的实现进行了验证。

第四章 汇编

4.1 汇编的概念与作用

        汇编的概念:汇编器as将hello.s翻译为机器语言指令,将这些指令打包形成一个可重定位目标程序的格式,将结果文件保留在目标二进制文件hello.o中。

        汇编的作用:将汇编代码根据一定的转换规则转换为二进制代码也就是机器可识别的机器码。

4.2 在Ubuntu下汇编的命令

        在Ubuntu下的汇编命令为:as hello.s -o hello.o

截图如下:

                                                              图4.1  汇编

4.3 可重定位目标elf格式

        分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。

4.3.1文件头,首先查看文件头,ELF头以一个16字节的序列开始,这个序列描述了生成该文件系统下的字的大小以及一些其他信息。ELF 头剩下的部分包含帮助链接器语法分析和解释目标 文件的信息:包括 ELF 头的大小、目标文件的类型、机器类型、节头部表的文件 偏移,以及节头部表中条目的大小和数量。在命令行输入命令:readelf -a hello.o > ./elf.txt。得到ELF头的代码如下:

                                                             图4.2 生成可重定位目标elf格式

                                                                 图4.3 ELF

4.3.2节头表

        该部分描述了.o文件中每一个节出现的位置和大小,集体内容如下所示:

                                                             4.4 节头表

4.3.3重定位节

        重定位节中包含了代码中所使用的外部变量等其他信息,在链接的时候链接器根据重定位的信息对外部变量符号进行修改,通过偏移量等信息计算出正确的地址。

        程序中需要重定位的信息由,rodata中的模式快,puts,exit,printf,atoi,sleep,getchar 这些符号需要进行重定位,重定位信息如下所示:

                                                              4.5 重定位节信息

4.3.4符号表

        在本程序中的getchar,puts,exit等函数名和其它定义和引用的全局变量的信息回存放在.symtab这个符号表中,具体信息如下所示:

                                                               4.6 符号表内容

4.4 Hello.o的结果解析

使用命令objdump -d -r hello.o > obj_hello.s

        得到的反汇编代码如下:

                                                               图4.7反汇编代码

分析 hello.o 的反汇编,并请与第 3 章的 hello.s 进行对照分析。

不同如下:

  1. hello.s经过反汇编后所得到的文件对数字的表示是十进制的,而用hello.o进行反汇编后得到的文件对数字的表示是十六进制的。
  2. hello.s反汇编中对于跳转给出段的名字,而hello.o反汇编跳转命令后紧跟的是需要跳转部分的目标地址。
  3. 函数代用也存在不同,在hello.s中指令call直接跟的是需要调用的函数名称,而hello.o反汇编中指令call使用的是main函数相对编译地址,并且函数的相对地址都为0。

4.5 本章小结

        第四章介绍了汇编的概念和作用,并且在Ubuntu下以hello.s文件翻译为hello.o文件为例,生成了hello的ELF格式文件,并研究了hello.elf的具体结构,比较了hello.o的反汇编代码与hello.s中的代码,并进行了分析。

第五章 链接

5.1 链接的概念与作用

        链接的概念:链接是指通过链接器,将程序编码与数据块收集整理成为一个单一文件并生成完全链接的的可执行目标文件的过程。

        链接的作用:提供了一个模块化的方式,将程序编写为一个较小的源文件的集合,实现了分开编译源文件,减少了整体文件的复杂度和大小,方便对某一模块进行单一针对性的修改。

5.2 在Ubuntu下链接的命令

        在Ubuntu下链接的命令为:ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux[1]gnu/crt1.o/usr/lib/x86_64-linux-gnu/crti.ohello.o/usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o

                                                图5.1 ubuntu下的链接命令

5.3 可执行目标文件hello的格式

使用命令:readelf -a hello > hello1.elf

5.3.1 ELF头

        ELF头中所含的内容与第四章中类似,具体情况如下:

                                                                图5.2 ElF

5.3.2节头

        描述了各个节的大小,偏移量和其他属性,具体内容如下:

                                                                5.3 节头表部分内容

5.4 hello的虚拟地址空间

        使用edb可以打开hello可执行文件,通过data dumo查看加载到虚拟地址的程序代码,查看ELF格式文件的程序头,会知道链接器运行时加载的内容,并且提供动态链接的信息,具体内容如下所示:

                                                               图5.4 edb中的data dump识图

        程序的地址是从0x401000开始的,并且该处有ELF的标识,可以判断从可执行文件时加载的信息。接下来可以分析其中的一些具体的内容:其中PHDR保存的是程序头表;INTERP保存了程序执行前需要调用的解释器;LOAD记录程序目标代码和常量信息;DYNAMIC 储存了动态链接器所使用的信息;NOTE记录的是一些辅助信息;GNU_EH_FRAME 保存异常信息;GNU_STACK使用系统栈所需要的权限信息;GNU_RELRO 保存在重定位之后只读信息的位置。

5.5 链接的重定位过程分析

使用命令:objdump -d -r hello > obj1_hello.s

                                                            图5.5 hello反汇编代码部分

hello与hello.o反汇编代码的不同:

  1. 在链接过程中,hello中加入了代码中调用的一些库函数,例如getchar,puts,printf等,同时每一个函数都有了相应的虚拟地址
  2. hello中增加了.init和.plt节,和一些节中定义的函数。
  3. hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。这是由于hello.o中对于函数还未进行定位,只是在.rel.text中添加了重定位条目,而hello进行定位之后自然不需要重定位条目。
  4. 地址访问:在链接完成之后,hello中的所有对于地址的访问或是引用都调用的是虚拟地址地址。

符号解析:目标文件定义和引用符号,符号解析将每个符号引用和一个符号定义关联起来。

重定位:编译器和汇编器生成从0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。

5.6 hello的执行流程

根据反汇编代码可以看出执行函数及虚拟内存地址如下:

401000<_init>

401020<.plt>

401090<puts@plt>

4010a0<printf@plt>

4010b0<getchar@plt>

4010c0<atoi@plt>

4010d0<exit@plt>

4010e0<sleep@plt>

4010f0<_start>

401120<_dl_relocate_static_pie>

401125<main>

4011c8<_fini>

                                                              图5.6 使用edb执行hello过程截图

5.7 Hello的动态链接分析

        当程序调用一个由共享库定义的函数时,由于编译器无法预测这时候函数的地址是什么,因此这时,编译系统提供了延迟绑定的方法,将过程地址的绑定推迟到第一次调用该过程时。通过GOT和过程链接表PLT的协作来解析函数的地址。在加载时,动态链接器会重定位GOT中的每个条目,使它包含正确的绝对地址,而PLT中的每个函数负责调用不同函数。那么,通过观察edb,便可发现dl_init后.got.plt节发生的变化。

        首先观察elf中.got.plt节的内容

                                                              图5.7 elf中的.got.plt.的内容

使用edb进行调试:

                                                          图5.8 执行init之前的地址

                                                            图5.9 执行init之后的地址

5.8 本章小结

        在第五章中介绍了链接的概念与作用,并且得到了链接后的hello可执行文件的ELF格式文本,并且分析了与hello.o的ELF格式文本的区别,通过两个反汇编文件的比较,加深了对于重定位和动态链接的理解。

第六章 HELLO进程管理

6.1 进程的概念与作用

        进程的概念:进程是一个正在运行的程序的实例,系统中的每一个程序都运行在某个进程的上下文中。

        进程的作用:给应用程序提供两个关键抽象:

  1. 一个独立的逻辑控制流,提供一个假象,好像程序独占地使用处理器
  2. 一个私有地址空间,提供一个假象,好像程序独占地使用内存系统

6.2 简述壳Shell-bash的作用与处理流程

作用:shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行,求值步骤解析命令行,并根据解析结果运行程序。

处理流程如下:

1.Shell首先从命令行中找出特殊字符(元字符),在将元字符翻译成间隔符号。元字符将命令行划分成小块 tokens。Shell 中的元字符如下所示:SPACE,TAB,NEWLINE,&,;,(,),<,>,|。

2.程序块tokens被处理,检查看他们是否是shell中所引用到的关键字。

3.当程序块tokens被确定以后,shell 根据aliases文件中的列表来检查命令的第一个单词。如果这个单词出现在aliases表中,执行替换操作并且处理过程回到第一步重新分割程序块tokens。

4.Shell对~符号进行替换。

5.Shell对所有前面带有$符号的变量进行替换。

6.Shell将命令行中的内嵌命令表达式替换成命令;他们一般都采用$(command)标记法。

7.Shell计算采用$(expression)标记的算术表达式。

8.Shell将命令字符串重新划分为新的块tokens。这次划分的依据是栏位分割符号,称为IFS。缺省的 IFS 变量包含有:SPACE,TAB和换行符号。

9.Shell执行通配符*?[]的替换。

10.shell把所有从处理的结果中用到的注释删除,并且按照下面的顺序实行命令的检查:

A.内建的命令

B. shell函数(由用户自己定义的)

C.可执行的脚本文件(需要寻找文件和PATH路径)

11.在执行前的最后一步是初始化所有的输入输出重定向。

12.最后,执行命令。

6.3 Hello的fork进程创建过程

打开shell,输入命令./hello 2022110570 xzh 18648556480 0带参数生成可执行文件。

        Fork函数创建进程过程如下:首先,带参数执行当前目录下的可执行文件hello,父进程会用过fork函数创建一个新的子进程hello,子进程获取了父进程的上下文,包括,栈,寄存器,程序计数器,环境变量等等,子进程和父进程的区别在于子进程和父进程有着不一样的PID。当子进程运行结束时,如果父进程仍然存在,执行对子进程的回收,否则有init进程回收子进程。程序执行结果如下:

                                                             图6.1 程序正常执行状态

6.4 Hello的execve过程

        exceve函数在当前进程的上下文中加载并运行一个新程序。exceve函数加载并运行可执行目标文件,并带参数列表和环境变量列表。只有当出现错误时,exceve才会返回到调用程序。所以,与fork一次调用返回两次不同,在exceve调用一次并从不返回。当加载可执行目标文件后,exceve调用启动代码,启动代码设置栈,将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序,由此将控制传递给新程序的主函数。

6.5 Hello的进程执行

        在程序运行时,shell为hello创建了一个子进程,这个子进程和shell有对的控制流,在hello运行过程中如果hello进程被抢占则进入内核模式,进行上下文切换,转入用户模式,调度其他进程,直到当hello调用sleep函数是,sleep函数会向内核发送请求将hello挂起,进行上下文的切换,进入内核模式切换到其他进程。

        进程执行到某些时刻,内核可决定抢占该进程,并重新开启一个先前被抢占了的进程,这种决策称为调度。内核调度一个新的进程运行后,通过上下文切换机制来转移控制到新的进程:1)保存当前进程上下文;2)恢复某个先前被抢占的进程被保存的上下文3)将控制转移给这个新恢复的进程。当内核代表用户执行系统调用时,可能会发生上下文切换,这时就存在着用户态与核心态的转换。

6.6 hello的异常与信号处理

异常类型:

异常类别

原因

异步/同步

返回行为

中断

来自I/O设备的信号

同步

总是返回到下一条指令

陷阱

有意的异常

同步

总是返回到下一条指令

故障

潜在可恢复的错误

同步

可能返回到当前指令

终止

不可恢复的错误

同步

不会返回

处理方式如下图:

                                                              图6.2中断处理方式

                                                               图6.3陷阱处理方式

                                                              图6.4故障处理方式

                                                              图6.5终止处理方式

不停乱按:将屏幕的输入缓存到缓冲区,乱码被认为是命令,不影响当前进程的执行。

                                                              图6.6运行时不断乱按

按下ctrl-z:程序在运行时按ctrl-z,这时会产生中断异常,它的父进程会接收到信号SIGSTP并运行信号处理程序,然后发现程序被挂起,打印相关挂起信息。结果如下图:

6.7运行时按下Ctrl-Z

Ctrl-Z后运行ps,打印出了各进程的pid,可以看到之前挂起的进程hello。

                                                             图6.8挂起后执行ps

Ctrl-Z后运行jobs,打印出了被挂起进程组的pid,可以看到之前被挂起的hello,以被挂起的标识Stopped。

                                                              图6.9 输入jobs

Ctrl-Z后运行pstree,可看到它打印出的信息:

                                                             图6.10 ptree打印的信息

Ctrl-Z后运行fg:因为之前运行jobs是得知hello的jid为1,那么运行fg1可以把之前挂起在后台的hello重新调到前台来执行,打印出剩余部分,然后输入hello回车,程序运行结束,进程被回收。

                                                               图6.11 执行pg

Ctrl-Z后运行Kill:重新执行进程,可以发现hello的进程号为7625,那么便可通过kill -9 7625发送信号SIGKILL给进程7625,它会导致该进程被杀死。然后再运行ps,可发现已被杀死的进程hello。

                                                             图6.12 执行kill命令

按下Ctrl-C:进程收到SIGINT信号,结束hello。在ps中查询不到其PID,在job中也没有显示,可以看出hello已经被彻底结束。

6.7本章小结

        在第六章中主要介绍了hello可执行文件的实际执行过程,包括进程创建,加载和终止,还通过键盘输入的过程,演示了出现异常时的情况,显示了各种各样的异常和中断信息,这些异常,信号等机制支持hello能顺利在计算机上运行。

第七章 HELLO的存储管理

7.1 hello的存储器地址空间

逻辑地址:逻辑地址指由程序产生的与段相关的偏移地址部分,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。从hello的反汇编代码中看到的地址,它们需要通过计算,通过加上对应段的基地址才能得到真正的地址,这些便是hello中的逻辑地址。

线性地址:是逻辑地址到物理地址变换之间的中间层。hello的代码产生的段中的偏移地址,加上相应段的基地址构成一个线性地址。Hello.o反汇编中每个函数可见这种表示方式。

虚拟地址:是线性地址经过分页机制转换后得到的地址。操作系统使用页表将线性地址映射到虚拟地址。虚拟地址空间允许每个进程有自己独立的地址空间,从而提高了安全性和稳定性。当hello程序运行时,操作系统为其分配一个虚拟地址空间。Hello反汇编中可以看到都有固定的地址。

物理地址:是指出现在CPU外部地址总线上寻址物理内存的地址信号,是地址变换的最终结果地址。

7.2 Intel逻辑地址到线性地址的变换-段式管理

        Intel处理器从逻辑地址到线性地址的变换是通过段式管理的方式实现,每个程序在系统中保存着一个段表,段表保存着该程序各段装入主存的状况和信息。段首地址存放在段描述符中,段描述符存放在描述符表中。

        段式管理以段为单位分配内存,每段分配一个连续的内存区。在同一进程中包含的各段之间不要求连续。其内存分配与释放在作业或进程的执行过程中动态进行。段式管理示意图如下:

        31                                                                16  15                                                                   0

段号S

段内地址W

7.3 Hello的线性地址到物理地址的变换-页式管理

        线性地址到物理地址的变换是现代计算机系统中重要的一部分,尤其在支持多任务的操作系统中。页式管理是用于实现虚拟内存的主要技术之一。以下是页式管理中线性地址到物理地址变换的过程:

1. 页式管理概述

        页式管理将虚拟地址空间(线性地址)划分为固定大小的块,称为“页”(Page),而物理内存划分为同样大小的块,称为“页框”(Frame)。页表(Page Table)用于存储页到页框的映射关系。

 2. 线性地址结构

        线性地址通常分为以下几个部分,具体取决于硬件架构。例如,在32位的x86架构中,线性地址可以分为三级:目录索引,也表索引和页内偏移。

3.变换过程

        变换过程可分为五个步骤,首先获取页目录基址,然后对线性地址进行分割,再查找页目录项,页表项,最后计算物理地址。

       在上节的段式管理过程中,得到了线性地址记为VA,虚拟地址可分为两部分,VPN和VPO,根据具体的特性确定VPN和VPO的具体位数,PPN需要通过访问PTE获取。若PTE的有效位为1,则发生页命中,可以直接获取到物理页号PPN,PPN与PPO共同组成的物理地址。若PTE的有效位为0,说明对应虚拟页没有缓存到物理内存中产生缺页故障,调用操作系统的内核的缺页处理程序,确定牺牲页,并调入新的页面,再返回到原来的进程,再次调用导致缺页的指令。

7.4 TLB与四级页表支持下的VA到PA的变换

在现代x86-64架构中,使用四级页表进行地址转换。这四级页表依次是:

  1. PML4(Page Map Level 4)
  2. PDPT(Page Directory Pointer Table)
  3. PD(Page Directory)
  4. PT(Page Table)

虚拟地址被划分为多个部分,每部分用于索引相应的页表级别。具体步骤如下:

  1. 分解虚拟地址:将虚拟地址分为五个部分:

        第一级(PML4)索引:最高9位

        第二级(PDPT)索引:接下来的9位

        第三级(PD)索引:再接下来的9位

        第四级(PT)索引:再接下来的9位

        页内偏移:最低12位

  1. 访问PML4表:使用CR3寄存器的值作为PML4表的物理基地址,加上PML4索引找到对应的PML4项。如果此项有效,则指向PDPT表。
  2. 访问PDPT表:PML4项中获取PDPT表的物理地址,加上PDPT索引找到对应的PDPT项。如果此项有效,则指向PD表。
  3. 访问PD表:从PDPT项中获取PD表的物理地址,加上PD索引找到对应PD项。如果此项有效,则指向PT表。
  4. 访问PT表:从 PD 项中获取PT表的物理地址,加上PT索引找到对应的PT项。如果此项有效,则指向物理页框。
  5. 计算物理地址:从PT项中获取物理页框基址,加上页内偏移得到最终的物理地址。

        TLB和四级页表结合使用提高了虚拟地址到物理地址转换的效率。TLB提供快速缓存,避免频繁访问页表。若TLB未命中,系统通过四级页表逐步解析虚拟地址,最终找到对应的物理地址。这种多级页表结构提高了内存管理的灵活性和地址空间的利用效率。

7.5 三级Cache支持下的物理内存访问

        MMU将物理地址发给L1缓存,缓存从物理地址中取出缓存偏移CO、缓存组索引CI以及缓存标记CT。若缓存中 CI 所指示的组有标记与 CT 匹配的条目且 有效位为1,则检测到一个命中条目,读出在偏移量CO处的数据字节,并把它返 回给MMU,随后MMU将它传递给CPU。若不命中,则在下一级cache或是主存 中寻找需要的内容,储存到上一级 cache 后再一次请求读取。

存储器三级结构如下图所示:

                                                              图7.1 存储器三级结构图

7.6 hello进程fork时的内存映射

        当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的pid。为了给这个新进程创建虚拟内存,系统创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制。当fork从新进程返回,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

Hello进程execve的内存映射步骤如下:

1.加载可执行文件头

        `execve` 会首先读取可执行文件的头部信息。对于ELF(Executable and Linkable Format)格式文件,这包括 ELF 头、程序头表(Program Header Table),以及段表(Section Header Table)。

2.映射各个段

        根据程序头表,将文件中的各个段(如代码段、数据段等)映射到进程的地址空间。主要段包括:

        代码段(.text):包含程序的机器代码。

        数据段(.data):包含初始化的全局变量。

        BSS段(.bss):包含未初始化的全局变量,系统将其初始化为零。

        堆(Heap):动态内存分配区域。

        堆栈(Stack):函数调用时使用的栈空间。

3.设置堆栈

        在内存映射完成后,`execve` 会初始化堆栈,包括命令行参数(argv)和环境变量(envp)

4 设置入口点

        最后,`execve`会根据ELF头中的入口点信息,将程序计数器(PC)设置为入口点地址,然后跳转到入口点执行。

7.8 缺页故障与缺页中断处理

页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的:

        1.处理器生成一个虚拟地址,并将它传送给MMU

        2.MMU生成PTE地址,并从高速缓存/主存请求得到它

        3.高速缓存/主存向MMU返回PTE

        4.PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。

        5. 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。

        6.缺页处理程序页面调入新的页面,并更新内存中的PTE

        7.缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。

7.9动态存储分配管理

基本方法:动态内存管理是操作系统和运行时库的重要功能,涉及在程序运行期间的内存分配和释放。基本方法包括堆分配,栈分配和静态分配。堆分配允许在运行时动态分配内存,适用于需要灵活内存管理的数据结构,如链表和树。栈分配用于函数调用中的局部变量分配,快速且不会产生碎片,但仅适用于生命周期短的内存。静态分配在编译时确定内存大小,适用于大小固定的数据。

分配策略:常见的分配策略包括首次适配(First-Fit)、最佳适配(Best-Fit)、最差适配(Worst-Fit)和快速适配(Quick-Fit)。首次适配从头开始查找第一个足够大的空闲块,简单高效但易造成低地址碎片。最佳适配寻找最接近需求大小的块,减少内存浪费但查找速度慢。最差适配分配最大的空闲块,减少大块碎片但可能浪费内存。快速适配通过多个链表管理不同大小的块,分配速度快但维护复杂。高级方法包括边界标记法(Boundary Tag Method),通过前后标记合并相邻空闲块,减少碎片但管理复杂;伙伴系统(Buddy System),将内存划分为大小为2的幂的块,快速分配和合并但利用率低;分级分配器(Slab Allocator),管理固定大小块,减少碎片提高效率,但灵活性差。合理选择和组合这些方法和策略可以提高内存利用率和系统性能。

7.10本章小结

        在第七章中主要介绍了hello的存储地址空间,intel的段式管理,hello的页式管理,查看了存储器从逻辑地址到线性地址到物理地址的变换,了解了cache,动态存储分配管理的机制。

第八章 HELLO的IO管理

8.1 Linux的IO设备管理方法

        设备的模型化:文件.所有的I/O设备(网络,磁盘,终端)被模型化为文件,所有输入输出被当做对相应文件读和写来执行。

        设备管理:unix io接口。将设备映射为文件的方式,允许Linux内核引出一个简单,低级的应用接口,叫做Unix I/O

8.2 简述Unix IO接口及其函数

Unix IO接口:

  1. 打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有 操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
  2. Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0),标准输出(描述符为1),标准出错(描述符为2)。头文件定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
  3. 读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k。
  4. 关闭文件:当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放 文件打开时创建的数据结构,将描述符恢复到描述符池中。

Unix IO函数:

(1).打开文件:int open(char *filename, int flags, mode_t mode);

      Open 函数将 filename 转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程当中没有打开的最小描述符。Flags参数指明了进程打算如何访问这个文件,同时也可以是一个或者更多为掩码的或,为写提供给一些额外的指示。Mode参数指定了新文件的访问权限位。

(2).关闭文件:int close(int fd);

        调用close函数,通知内核结束访问一个文件,关闭打开的一个文件。成功返回0,出错返回-1。

(3).读文件:ssize_t read(int fd, void *buf, size_t n);

        调用 ead函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示错误,返回值0表示EOF,否则返回值表示的是实际传送的字节数量。

(4).写文件:ssize_t write(int fd, const void *buf, size_t n);

        调用从内存位置buf复制至多n个字节到描述符fd的当前文件位置。返回值-1表示出错,否则,返回值表示内存向文件fd输出的字节的数量。

8.3 printf的实现分析

以下是printf函数的具体实现:

int printf(const char *fmt, ...)

{

        int i;

        va_list arg = (va_list)((char *)(&fmt) + 4);

        i = vsprintf(buf, fmt, arg);

        write(buf, i);

        return i;

}

       在printf里输入参数是fmt,后面是不定长的参数,同时printf内存中调用了两个函数,vsprintf和write函数。

下面是vsprintf的具体实现:

int vsprintf(char *buf, const char *fmt, va_list args)

{

        char *p;

        chartmp[256];

        va_listp_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函数将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。write函数将buf中的i个元素写到终端。

        从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.

        字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

        显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

        getchar 是读入函数的一种。它从标准输入里读取下一个字符,相当于 getc(stdin)。返回类型为 nt型,为用户输入的ASCII码或EOF。getchar可用宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若 文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。

        异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

        getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

        在第八章中介绍了在linux系统下I/O设备的基本概念和处理方法,以及研究了printf函数的具体实现。

结论

        你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。

        Hello的一生所需要经历大概一下九个过程,这些过程包含着每个c语言程序在运行时所要经历的必经之路。

  1. 预处理,hello.c经过cpp的预处理,得到了经过大量扩展的源程序文件hello.i
  2. 编译,hello.i经过编译器处理得到汇编程序hello.s
  3. 汇编,通过汇编器as的处理,hello.s生成了可重定位文件hello.o
  4. 链接,链接器将重定位目标文件链接为可执行文件hello
  5. 生成子进程,在shell中输入./shell后会调用fork函数为hello生成进程
  6. 加载,execve函数加载并运行hello程序,将它映射到对应的虚拟内存的区域,并载入物理内存.
  7. I/O设备,在程序中有输出和输出语句,这些部分与printf,getchar等函数有关,涉及到内容的输入与输出,与linux系统下的I/O设备相关。
  8. 执行指令。
  9. 回收,在程序运行结束后,会进行对子进程的回收,同时内核将其从系统中删除。
  • 14
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值