- 摘要
- 第1章 概述
- 1.1 Hello简介
- 1.2 环境与工具
- 1.3 中间结果
- 1.4 本章小结
- 第2章 预处理
- 2.1 预处理的概念与作用
- 2.2 Ubuntu下预处理的命令
- 2.3 Hello的预处理结果解析
- 2.4 本章小结
- 第3章 编译
- 3.1 编译的概念与作用
- 3.2 在Ubuntu下编译的命令
- 3.3 Hello的编译结果解析
- 3.3.1 首先,我们先关注编译器对字符串常量的处理:
- 3.3.2 main函数参数的储存。
- 3.3.3 关系 *** 作
- 3.3.4 算术 *** 作
- 3.3.5 数组 *** 作
- 3.3.6 条件控制
- 3.3.7 循环控制
- 3.3.8 函数调用
- 3.4 本章小结
- 第4章 汇编
- 4.1 汇编的概念与作用
- 4.2 在Ubuntu下汇编的命令
- 4.3 可重定位目标elf格式
- 4.3.1 ELF头
- 4.3.2 节头部表
- 4.3.3重定位条目
- 4.3.4符号表
- 4.4 Hello.o的结果解析
- 4.5 本章小结
- 第5章 链接
- 5.1 链接的概念与作用
- 5.2 在Ubuntu下链接的命令
- 5.3 可执行目标文件hello的格式
- 5.4 hello的虚拟地址空间
- 5.5 链接的重定位过程分析
- 5.5.1 函数代码段
- 5.5.2 main函数的变化
- 5.5.3 全局变量和函数重定位
- 5.5.4 跳转指令
- 5.6 hello的执行流程
- 5.7 Hello的动态链接分析
- 5.8 本章小结
- 第6章 hello进程管理
- 6.1 进程的概念与作用
- 6.2 简述壳Shell-bash的作用与处理流程
- 6.3 Hello的fork进程创建过程
- 6.4 Hello的execve过程
- 6.5 Hello的进程执行
- 6.6 hello的异常与信号处理
- 6.7本章小结
- 第7章 hello的存储管理
- 7.1 hello的存储器地址空间
- 7.2 Intel逻辑地址到线性地址的变换-段式管理
- 7.3 Hello的线性地址到物理地址的变换-页式管理
- 7.4 TLB与四级页表支持下的VA到PA的变换
- 7.5 三级Cache支持下的物理内存访问
- 7.6 hello进程fork时的内存映射
- 7.7 hello进程execve时的内存映射
- 7.8 缺页故障与缺页中断处理
- 7.9动态存储分配管理
- 7.9.1 隐式空闲链表
- 7.9.2 显式空闲链表
- 7.10本章小结
- 第8章 hello的IO管理
- 8.1 Linux的IO设备管理方法
- 8.2 简述Unix IO接口及其函数
- 8.3 printf的实现分析
- 8.4 getchar的实现分析
- 8.5本章小结
- 结论
- 附件
- 参考文献
摘要
计算机世界由硬件和软件构成。本论文以一个简单的程序hello.c为载体,探究探究了计算机软件程序从编写产生,到编译转化为可执行程序,再到运行,最后结束回收的过程。一分为二地看,c程序的一生分为两个阶段。构造阶段发生了程序的编写、预处理、编译、汇编、链接。运行阶段程序需要依赖各种计算机底层原理:创建任务需要进程机制、申请栈帧、多任务并行需要CPU分时机制、访问物理内存需要虚拟内存机制、异常中断与恢复需要信号机制、程序输入输入需要IO机制…
我们一一探索这些内容,陪hello.c走完了一生。
关键词:C语言;深入理解计算机系统;预处理;编译;汇编;链接;进程管理;信号机制;虚拟内存;系统级IO。
第1章 概述 1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程:
P2P:首先从一个写好的源程序程序文件(Program)开始,hello.c经过预处理器、编译器、汇编器、连接器的处理,执行预处理->编译->汇编->链接,最后生成可执行程序。我们在Bash内输入运行hello.c的指令,OS为它fork进程(Process),这就是P2P的过程。
020:经过“P2P”过程,我们编写了一个hello.c程序、生成了可执行文件、并创建了运行它的进程。接下来通过exceve在进程的上下文中加载并运行hello,把它映射到对应虚拟内存区域,并依需求载入物理内存。hello.c的指令在CUP下执行,实现它拥有的功能,在程序运行结束后,内核把它从系统中清除,hello完成了它的一生,这便是020的过程。
1.2 环境与工具列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:X64 CPU; 1.20GHz; 16G RAM; 512GHD Disk
软件环境:Win11; Ubuntu 20.04
开发工具:Visual Studio 2022, CodeBlocks 64位,vim,gdb,gcc
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.c 原始c程序(源程序)
hello.i 预处理 *** 作后生成的文本文件
hello.s 编译之后生成的汇编语言文件
hello.o 汇编之后生成的可重定位文件
hello 链接之后生成的可执行程序
在第一章,我们简单介绍了hello.c文件P2P和O2O的过程,对其在我们电脑上的运行情况有了大致的了解。在本章,我们列出了实验所用的软硬件环境、开发工具和中间结果文件。
第2章 预处理 2.1 预处理的概念与作用
程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。预处理的作用主要有以下几点:
(1)展开“宏”,也就是将#define后的宏替换成相应部分。
(2)递归地处理程序中的#include指令,如将#include
(3)删除注释。注释对于机器而言是无用的,会在预处理阶段删除。
(4)处理条件编译指令,如#if, #endif等。编译器将先判断条件,再修改源代码。
在Ubuntu终端中输入下列命令对hello.c预处理,生成hello.i文件到hello.c同一文件夹下。
gcc -E hello.c -o hello
预处理指令执行后,同一文件夹下产生了新的文件
先看源程序,方便与后续对比
下面看一看hello.i的预处理结果
从这张截图我们可以看到,源程序中的注释已经删除。同时#include被替换成了相应的头文件。另外,hello.i中还包含了不在hello.c中显式包含的头文件,说明#include是一个递归的过程。
来到main函数部分,我们发现,宏N已经被替换为4了
在本章,我们和hello.c一起经历了预处理过程,在这里,hello.c经历了宏的替换,脱掉了无用的注释,并且把藏着的(#include)内容显示了出来。这是hello.c成长为可执行程序的第一步。
第3章 编译 3.1 编译的概念与作用
概念:编译器ccl将文本文件hello.i翻译成文本文件hello.s。它包含一个汇编语言程序。
作用:编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
使用如下指令对hello.i进行编译 *** 作
gcc -S hello.i > hello.s
3.3 Hello的编译结果解析
3.3.1 首先,我们先关注编译器对字符串常量的处理:
在hello.s中以上字符串储存在主程序之外的区域:
在hello.c中main接受两个参数,分别是四字节的int类型argc,和char*数组argv。
在hello.s中,他们分别储存在%edi和%rsi中。
hello.c中涉及了两个关系 *** 作:
这两个表达式分别转换为一下语句:
转化为
hello.c在for语句中取了数组argv中的值.
这些 *** 作使用如下语句实现:
取argv[1]
取argv[2]
取argv[3]
hello.c中使用了一次if条件语句。这个if语句使用了argc和立即数4的比较结果,以此约定是否进入分支。
在hello.s中分支结构使用跳转指令实现:
如果不满足条件判断就跳转到.L2否则继续执行后续语句。
hello.c中使用了一次for循环语句。
在hello.s中,以上功能使用如下实现方式:
.L2进行循环变量初始化
.L4检查循环是否结束
.L3是循环体,同时对循环变量进行递增
hello.c中在main函数里进行了多次函数调用(printf,exit,atoi,sleep,getchar)
其中,getchar()函数不接受参数,直接调用即可。
剩下四个函数需要在调用时传入参数,我们利用寄存器%rdi和%rsi
调用exit(),参数1在寄存器%edi中。
下面一部分汇编代码实现了两次函数调用。注意到调用atoi函数产生的返回值储存在%rax中。然后传递给了sleep。
下图为printf调用,这里需要三个参数,所以我们额外借助了%rdx寄存器。
在编译的过程中,hello实现了从高级语言到汇编语言的转变,它比原来更加接近于底层,更加接近于机器。只有通过这一步骤,他才能被之后的汇编器所理解,从而翻译出真正的机器语言,在计算机上自如地运转。它离拥有程序的生命更近了一步。
第4章 汇编 4.1 汇编的概念与作用
概念:汇编器as将hello.s翻译成机器语言指令,把这些指令打包成一种叫可重定位目标程序的格式,并将结果保存在目标文件hello.o中。(hello.o是一个二进制文件)。
作用:把汇编语言翻译成机器语言,用二进制码代替汇编语言中的符号,即让它成为机器可以直接识别的程序。
as hello.s -o hello.o
4.3 可重定位目标elf格式
*使用命令 readelf -a hello.o 可以重定位目标文件hello.o的ELF格式。
4.3.1 ELF头如图所示,ELF可重定位目标文件中首先出现的是ELF头,ELF头以一个16字节的序列开始,这个序列描述了生成文件的系统的字大小和字节顺序。ELF头的剩下部分帮助链接器语法分析和解释目标文件的信息:包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
节头部表描述了不同节的位置和大小其中目标文件中的每个届都有一个固定的大小条目。
4.3.3重定位条目.rela.text节是一个.text节中位置的列表。当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者应用全局变量的指令都需要修改。
4.3.4符号表其中,“偏移量”是需要被修改的引用的字节偏移(在代码节或数据节的偏移),“信息”指示了重定位目标在.symtab中的偏移量和重定位类型,“类型”表示不同的重定位类型,例如图中R_X86_64_PC32就表示重定位一个使用32位PC相对地址的引用。“符号名称”表示被修改引用应该指向的符号,“加数”是一个有符号常数,一些类型的重定位要用他对被修改引用的值做偏移调整。
符号表中存放了程序中定义和引用的函数和全局变量。在hello.c中我们没有定义全局变量,所以符号表中没有他们的身影,只能看到函数。
使用objdump指令可以查看hello.o的反汇编代码。这里将反汇编结果保存到hello.o.objdump.txt中。
objdump -d hello.o -o hello.o.objdump.txt
我们总览反汇编代码,将其与hello.s比较,可以发现主要有这些差异:
-
从main函数开始(起始地址为0),每一条机器指令获得了地址。我们把文件看成两部分,左边是16进制的机器指令,右边是对应的汇编代码。
-
见下图(左为hello.s,右为hello.o反汇编代码),可以看到,hello.s中所有10进制表示的立即数,在hello.o中转为使用16进制表示。
-
使用重定向条目替换全局变量和外部函数。因为变量还有函数的真正地址需要经过重定位才能确定,所以这里都用0替代。
-
分支转移函数
在hello.s中,分支转移函数通过Lable实现表明跳转地址,如下图:
而在hello.o的反汇编文件中,因为我们给每一条指令赋予了一个地址,所以直接使用<函数名 + 偏移量>的形式表明跳转地址,如下图:
第一行条件跳转指令跳转到的位置,也就是最后一行的高亮部分,这正是main函数内相对地址2f所在地。 -
函数调用
依然使用call指令调用函数,call后跟着的是下一条指令的地址。因为函数的具体地址也要等到重定位后才能确定,所以这里也用0表示,后面跟上重定位条目信息。
在汇编这个 *** 作中,我们的hello有向它真正在及其上运行迈出了一步。在这里汇编代码转化为了机器指令,从main函数开始,每条机器指令有了自己的地址。所有可重定向的全局变量和外部函数被替换成了可重定向条目,我们的调用地址为0,这将会在重定向是计算好。代码中的跳转指令使用了刚刚获得的地址,所有十进制立即数写成了十六进制的模式。hello越来越贴近机器执行了。
第5章 链接 5.1 链接的概念与作用
概念:链接(linking)是将各种代码和数据片段收集并组合成一个大一文件的过程,这个这个文件可被家在(复制)到内存并执行。
作用:链接使分离编译成为了可能。我们不用将一个大型的应用程序组织值为一个巨大的源文件,而是看到一把他分解为更小,更好管理的模块,可以俄uli地修改和编译这些模块。
使用如下指令进行链接,产生链接可执行文件,试运行,打印提示信息。说明生成可执行文件成功。
注意 x86_64-linux-gun 文件夹下究竟是5还是6,7,8,9和版本有关,需要根据实际情况修改。我这里是9,不同电脑可能不一样
ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello.out
5.3 可执行目标文件hello的格式
在终端中输入指令readelf -a hello.out 打印可执行文件的ELF格式。
readelf -a hello.out
各段基本信息如下:
在hello.out的ELF文件程序头可以看到各段的虚拟地址
使用edb加载hello,查看本进程的虚拟地址空间各段信息.
在命令行输入指令 objdump -d hello > hello.txt 生成可执行文件年的反汇编代码,通过将其与hello.o的反汇编代码比较,可以分析了解重定位过程。
objdump -d hello > hello.txt
5.5.1 函数代码段
在hello.c中,我们只能看到main函数的代码段,但是在可知向文件hello.out中,还出现了其他代码段,并且每个函数以及每条指令都有了虚拟地址,如下图所示:
在hello.o中main函数的起始地址是0x0000000000000000但是在hello中,main函数有了起始地址变成了虚拟地址。而且每一条指令的地址也随之偏移。
在hello.o中,用0+%rip的值来表示全局变量的位置,同时使用重定位条目表明后续得到真正的值的方法。而在hello中,所有全局变量都得到了自己的虚拟地址。同样被重定位的还有在main中调用的函数,如下图所示:
在hello中跳转指令的 *** 作数为跳转目的地的虚拟地址:
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
子程序名 | 地址 |
---|---|
_start | 0x4010f0 |
libc-2.31.so!__libc_start_main | 0x7f6d972d6fc0 |
libc-2.31.so!__cxa_atexit | 0x7f6d972f9e10 |
__libc_csu_init | 0x4011c0 |
_init | 0x401000 |
__libc_csu_fini | 0x401230 |
Libc-2.31.so!_setjmp | 0x7f6d972f5cb0 |
Libc-2.31.so!_sigsetjmp | 0x7f6d972f5be0 |
main | 0x401125 |
exit | 0x401070 |
当程序调用一个由共享库定义的函数时,编译器无法预测这个函数运行时的地址,因为定义它的共享模块在运行时可以加载到任何位置。GUN编译系统使用一种很有趣的技术来解决这个问题,称为延迟绑定——将过程地址的绑定推迟到第一次调用该过程时。延迟绑定通过全局偏移量表GOT和过程链接表PLT的协作来解析函数的地址。如果一个目标模块调用定义在共享库中的任何函数,那么他就有自己的GOT和PLT。GOT是数据段的一部分,而PLT是代码段的一部分。
在加载时,动态链接器会重定位GOT中的每个条目,使它包含正确的绝对地址,而PLT中的每个函数负责调用不同函数。那么,通过观察edb,便可发现dl_init后.got.plt节发生的变化。
查看elf文件得到.got.plt的起始位置是0x404000,结束于0x404048。
在执行dl_init前,这一节的内容是:
在执行dl_init之后这一节的内容是:
注意到,.got.plt发生变化的部分是第8到第15字节。它们对应GOT[1]和GOT[2]的位置。其中,这时GOT[1]包含动态链接器在解析函数地址时使用的信息,而GOT[2]是动态链接器ld-linux.so模块中的入口点。
链接过程中,链接器将各种代码和数据文件合并成一个可执行文件。正如本章开头提到的:链接使分离编译成为了可能。我们不用将一个大型的应用程序组织值为一个巨大的源文件,而是看到一把他分解为更小,更好管理的模块,可以俄uli地修改和编译这些模块。
经过链接,hello完成了从代码到可执行文件的最后一次飞跃。接下来hello终于可以在shell中运行,开启他的程序人生了。这一路走来,我们见证了hello的成长,也对链接过程有了更多,更深刻的理解。
第6章 hello进程管理 6.1 进程的概念与作用
概念:广义上说,进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是 *** 作系统动态执行的基本单元,在传统的 *** 作系统中,进程既是基本的分配单元,也是基本的执行单元。
作用:在现代系统上运行一个程序时,我们会得到一个假象,好像我们的程序是系统中唯一运行的程序一样。我们的程序好像独占处理器和内存。处理器好像无间断地一条接一条执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程作用:shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行,求值步骤解析命令行,并代表用户运行程序。
处理流程:
- shell打印一个命令行提示符,等待用户在stdin上输入命令行,然后以空格为分隔解析命令行,得到命令行参数,第一个参数被假设为要么是一个内置的shell命令名,马上就会解释这个命令并执行相应 *** 作;要么是一个可执行目标文件,会通过fork创建一个新的子进程,并在新的子进程的上下文中通过execve加载并运行这个文件。
- 如果用户要求在后台运行该程序,那么shell返回到循环的顶部,等待下一个命令行。否则,shell使用waitpid函数等待作业终止并回收(也可以使用CtrlZ或CtrlC挂起或终止)。当作业终止时,shell开始下一轮的迭代。
- 父进程通过调用fork函数创建一个新的运行的子进程。
- 调用fork函数后,新创建的子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本(包括代码、数据段、堆、共享库以及用户栈),子进程获得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。
- fork被调用一次,却返回两次,子进程返回0,父进程返回子进程的PID。子进程有不同于父进程的PID。
- exceve函数在当前进程的上下文中加载并运行一个新程序。
- exceve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,exceve才会返回到调用程序,否则,exceve调用依一次且从不返回。
- 在exceve加载了可执行目标文件后,调用加载器,加载器代码设置栈,将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
- 时间片:时间片是指CPU分配给每个程序的运行时间,每个线程被分配一个时间段,作为他的时间片。微观上一个CPU统一时间只能执行一个时间片,但是因为CPU轮转调度很快,所以在宏观上产生了多个程序并行不悖的效果。这个机制使得程序都产生了自己独占CPU的假象。
- 上下文切换: *** 作系统内核使用一种称为“上下文切换”的亦称控制流来实现多任务。内核为每个进程维持一个上下文,上下文就是内核重启被抢占的进程所需的状态。
- 调度:进程执行到某些时刻,内核可决定抢占该进程,并重新开启一个先前被抢占了的进程,这种决策称为调度。内核调度一个新的进程运行后,通过上下文切换机制来转移控制到新的进程:1)保存当前进程上下文;2)恢复某个先前被抢占的进程被保存的上下文3)将控制转移给这个新恢复的进程。当内核代表用户执行系统调用时,可能会发生上下文切换,这时就存在着用户态与核心态的转换。
- 用户态与核心态转换:处理器用某个控制寄存器中的一个模式位来限制一个应用可以执行的指令以及它可以访问的地址空间范围。没有设置模式位时,进程运行在用户模式中,不允许执行特权指令;而设置模式位时,它运行在内核模式中,可以执行指令集中的任何指令,访问系统内存的任何位置。异常发生时,控制传递到异常处理程序,由用户模式转变到内核模式,返回至应用程序代码时,又从内核模式转变到用户模式。
hello执行过程中可能出现的异常有:
- 中断:中断由处理器外部的I/O设备的信号引起(如Ctrl-Z,Ctrl-C),可能产生信号SIGSTP,它会将程序挂起,直到有下一个SIGCONT信号;也可能产生信号SIGINT,它会将进程终止。
- 陷阱:陷阱是有意的异常,是执行一条指令的结果。如hello调用exit终止当前进程。
下面展示在各种可能情况下hello的运行结果
-
程序正常运行:打印4次序号姓名,中间间隔1秒。之后,程序调用exit(1)引发陷阱异常,结束进程。
-
在程序执行时乱敲键盘
可以看到,我们在键盘的输入会显示在命令行中,输入回车会换行。我们的输入与hello的输出杂揉在一起,但是不会阻塞当前进程的执行。当hello执行完毕后,shell读取我们的输入,提示并没有这些指令。
-
Ctrl^Z
程序运行时按Ctrl^Z这会产生中断异常。这是hello被挂起了,我们可以看到提示信息。
在Ctrl^Z后输入ps,显示当前进程状态,打印出各进程的pid,其中就有刚刚被挂起的hello。
在Ctrl^Z后输入jobs,查看当前终端放入后台的指令。可以找到hello。
在Ctrl^Z后输入pstree,以树形结构显示程序和进程间的关系。
Ctrl^Z后运行fg。fg指令将挂起的对象转到前台运行。之前从jobs得知,hello的jid是1,可以看到hello恢复运行。
在Ctrl^Z后输入kill杀死进程,打开ps发现进程已经被结束。
-
运行时按CTRL^C,这会导致一个中断异常。从而内核产生信号SIGINT,父进程受到它后,向子进程发生SIGKILL来强制终止子进程hello并回收它。这时在运行ps,可以发现并没有进程hello,可以说明他已经被终止并回收了。
hello开始真正在系统上运行时,离不开shell给它提供的平台,也离不开进程机制和信号机制的支持。从创建进程,到在进程中加载程序,信号以及上下文切换使其可以自如的运行在计算机中,就好像独占了整个CPU。
hello在运行时,可能会产生各种异常:中断,陷阱等等。而更复杂的程序在运行时可能会产生各种各样的异常。
而当hello的进程生命结束,同样需要各种信号与系统的配合来对它进行终止,回收。程序的高效运行离不开异常、信号、进程等概念,正是这些机制支持hello能够顺利地在计算机上运行。其中,进程时计算机科学中最深刻,最成功的概念之一。
第7章 hello的存储管理 7.1 hello的存储器地址空间
- 逻辑地址:逻辑地址指由程序产生的与段相关的偏移地址部分。在有地址变换功能的计算机中,访内指令给出的地址 ( *** 作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。从hello的反汇编代码中看到的地址,它们需要通过计算,即加上对应段的基地址才能得到真正的地址,这些便是hello中的逻辑地址。
- 线性地址:逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。hello的反汇编文件中看到的地址(即逻辑地址)中的偏移量,加上对应段的基地址,便得到了hello中内容对应的线性地址。
- 虚拟地址:使用虚拟寻址时,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送至内存前先转换成适当的物理地址。虚拟地址转化成物理地址的过程叫做地址翻译。在linux中,虚拟地址数值树等于线性地址,即hello中看到的地址加上对应段基地址的值。
- 物理地址:指出现CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。在hello的运行中,在访问内存时需要通过CPU产生虚拟地址,然后通过地址翻译得到一个物理地址,并通过物理地址访问内存中的位置。[7]
逻辑地址由【段选择符:偏移量】组成,线性地址为段首地址与逻辑地址中的偏移量组成。其中,段首地址存放在段描述符中。而段描述符存放在描述符表中,也就是GDT(全局描述符表)或LDT(局部描述符表)中。
段选择符(16位)由三部分组成。高13位是索引号,可以在描述符表中找到对应的描述符;紧接着TI指示段描述符是在GDT还是LDT中;最低两位表示请求者特权级别。由此,便可以通过段选择符的指示在段描述符表中找到对应的段描述符,然后便可从段描述符中获得段首地址,将其与逻辑地址中的偏移量相加,就得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理线性地址(虚拟地址)由虚拟页号VPN和虚拟页偏移VPO组成。
首先,MMU从线性地址中抽取出VPN,这时MMU把VPN解析为两部分(TLB tag和TLB index),使用index访问TLB组,使用tag判断是否命中。若命中,将缓存的PPN返回给MMU。若不命中,MMU需从将VPN与寄存在页表基址寄存器(PTBR)中的页表基地址相加,得到目标PTE的物理地址,去内存中取出PTE。MMU从PTE中取出PPN,若得到的PTE无效或标记不匹配,就产生缺页,内核需调入所需页面,重新运行加载指令,若有效,则取出PPN。最后将线性地址中的VPO与PPN连接起来就得到了对应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换虚拟地址VA由虚拟页号VPN和虚拟页偏移VPO组成。若TLB命中时,所做 *** 作与7.3中相同;若TLB不命中时,VPN被划分为四个片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。只有一级页表常驻内存,二三四级页表按需储存。第一二三级页表的PTE指向一个页表,而第四级页表的PTE指向虚拟内存的一个页。VPN1提供到一个L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,依次类推。最后在L4页表中对应的PTE中取出PPN,与VPO连接,形成物理地址PA。
MMU将物理地址发给L1缓存,物理地址自高为到低位是缓存标记CT、缓存组索引CI以及缓存偏移CO。若缓存中CI所指示的组有标记与CT匹配的条目且有效位为1,则检测到一个命中,读出在偏移量CO处的数据字节,并把它返回给MMU,随后MMU将它传递给CPU。若不命中,则需到低一级Cache(若L3 cache中找不到则到主存)中取出相应的块将其放入当前cache中,重新执行对应指令,访问要找的数据。
- 当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的pid。
- 为了给这个新进程创建虚拟内存,他创建了当前进程的mm_struct、区域结构和页表的原样副本,它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制。
- 当fork从新进程返回,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写 *** 作时,写时复制机制就会创建新页面,也就为每个进程保持了私有地址空间的抽象概念。
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要:
- 删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域:为新程序hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
- 映射共享区域:如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。
对虚拟内存来说,DRAM缓存不命中称为缺页。假设地址翻译硬件从内存中读取某PTE,若此PTE有效为为0且物理页号不为0,即该页不再物理内存中,而在虚拟内存(磁盘)上,则引发了缺页异常。
内核中的缺页异常处理程序会选择一个牺牲页(设牺牲页为A,待载入页为A)。若A页被修改了,那么内核会将它写回磁盘,内核会修改A的PTE,置有效位为0,反映出A以不在主存中。然后,内核从磁盘复制B到内存中PTE指示的位置,然后处理程序返回,重新启动导致缺页的指令。这时,页B已存在主存中,不会在导致缺页,可以正常读取。
7.9动态存储分配管理Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护这一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块来处理。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。
分配器主要有两种风格:显式分配器和隐式分配器。显式分配器要求程序显式释放任何一分配的块;隐式分配器可以自动检查一个已分配块何时不再被程序使用,然后自动释放。动态内存管理需要考虑如下几个问题:空闲块组织,放置,分割和合并。
7.9.1 隐式空闲链表任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配的块和空闲块。
7.9.2 显式空闲链表一种简单的方法是,一个块是有一个字的头部、有效载荷、以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有填充),以及这个块是已分配的还是空闲的。我们称这种结构为隐式空闲链表
隐式空闲链表的优点是简单,显著的缺点是任何 *** 作的开销,都要对空闲链表进行搜索,该搜索锁需时间与对中已分配块和空闲块的总是呈显性关系。
7.10本章小结一种更好的方法是将空闲块组织为某种形式的显式数据结构.因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面.例如,堆可以组织成一一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针.
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从从块总数的显性时间减少到了空闲块的线性时间。不过,释放一个块的时间可以使线性的,也可能是个常数,这取决于我们锁选择的空闲链表中块的排序策略。
在hello 的运行中,它需要与其他进程共享CPU和主存资源。然而,如果太多的进程需要太多的内存,那么它们中可能有一些根本无法运行。而虚拟内存的概念很好地解决了这一点。虚拟内存是计算机最重要的概念之一,是硬件异常,地址翻译,主存,磁盘和内核软件的完美交互,它为每个进程提供了大的私有的地址空间。可以说,虚拟内存及其管理机制保障了hello的运行。
第8章 hello的IO管理 8.1 Linux的IO设备管理方法
一个Linux文件就是一个m字节的序列。所有的I/O设备都被模型化为文件,而所有的输入和输出都被当作对相应的文件的读和写来执行。这种方法允许Linux内核引出一个简单,低级的应用接口,成为Unix I/O,使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数Unix IO一共实现了五个接口:打开文件,改变当前的文件位置,读文件,写文件和关闭文件。
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有 *** 作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
打开文件可以使用函数open实现,open的原型如下:
#include
#include
#include
/* filename是文件名,
* flags指明了进程打算以何种方式打开文件;
* mode指定了新文件的访问权限位。
* */
int open(char *filename, int flags, mode_t mode);
// 返回:新文件描述符,失败为-1
-
改变当前文件位置。改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过调用函数seek,显式地设置文件的当前位置为k 。
-
读写文件。一个读 *** 作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k>=m 时执行读 *** 作会触发一个称为EOF 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。
类似地,写 *** 作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。读写文件的函数是read和write,原型如下:
#include
ssize_t read(int fd, void *buf, size_t n);
// 返回:若成功则为读的字节数,EOF则为0,出错为-1
ssize_t write(int fd, const void *buf, size_t n);
// 返回:若成功则为写的字节数,失败为-1
- 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
我们可以使用函数close关闭文件,close原型如下:
#include
int close(int fd);
//返回:若成功返回0,失败返回-1
8.3 printf的实现分析
首先来观察一下printf的实现代码。参数列表后的…表示printf在执行前并不知道有多少个参数,printf声明了256字节的缓冲区buf,实际上buf被当作字符串对待。后面我们可以看到,printf通过调用vsprintf将格式化的字符串放入buf中。而在C libaray中,vsprintf的含义就是将格式化的字符串复制给一个字符串变量。
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 8);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
注意代码第6行: va_list arg = (va_list)((char)(&fmt) + 8);
首先,va_list被定义为char *。那么下面分析该类型的变量arg代表着什么。C语言中,向函数传递参数时,需要把参数按从右至左的顺序依次压入栈中,因为栈是从高地址向低地址增长,所以第一个参数fmt地址最低。那么&fmt便是fmt(char *类型)存在栈中的地址,地址占8字节,再加上8即为下一个参数的地址。所以arg指向了…中的第一个参数。
代码中的第7行调用了C libary中定义的函数vsprintf
#include
int vsprintf(char *str, const char *format, va_list ap);
//返回:格式化后字符串str的长度
vsprintf的实现很长,这里我们简要讲解一下他的功能。vsprintf依次检查format中的每一个字符,如果不是%就原样复制到str中。一旦找到一个%,vsfprintf就会开始检查之后的字符,对包括但不限于’.’,’-’,’+’,’0’,’#’的字符解释为格式,根据’d’,’’c’,’s’等符号解析ap指针,并让ap指向下一个变量。
最后把在str末尾加上’\0’,返回长度。
printf中第8行代码调用了write函数。write使用了vsprintf的返回值,前面解释过,这是输出字符串的长度。下面来看write的汇编代码。
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
write函数的作用便是将字符数组buf中的i个元素打印到终端。而这个步骤与硬件有关,这就需要限制程序执行的权限。而write中的最后一行int INT_VECTOR_SYS_CALL便是通过系统来调用函数sys_call来执行下一步 *** 作。
函数sys_call的实现
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
sys_call的功能,便是显示格式化了的字符串。(其中第2行call save用来保存中断前进程状态) 然后通过字符显示驱动子程序,从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析getchar由宏实现:#define getchar() getc(stdin)它有一个int型的返回值。当程序调用getchar()时,它会等待用户按键来输入字符。用户输入的字符被存放在缓冲区中,直到用户按了回车键,这时getchar()才会从stdio流中读入一个字符。
getchar函数的返回值是用户输入的字符的ASCII码,若出错返回-1,且将用户输入的字符回显到屏幕.若用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
这可以看做一个异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar函数调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结程序的运行离不开输入和输出,Linux巧妙地把所有I/O模型化为文件,这种方法使得所有的输入和输出都能以一种统一且一致的方式来执行。在这一章,我们站在Unix I/O 的角度解读了hello在命令行输出结果的过程。到了这一步,hello不仅有了在CPU上执行的运算逻辑,还能够聆听外界的声音(输入)和表达自己的想法(输出),真正的有了灵魂。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
在这次实验中,我们见证了hello的产生,也见证了hello的消亡,纵观hello的一生,大致分为两个阶段:生成阶段和运行阶段。
-
在生成阶段中,hello经历了一下步骤:
1、在文本中编写hello.c文件。
2、hello.c经过预处理生成hello.i。这一步会对hello.c进行简单的装饰。包括库的引用,宏变量的替换,和注释的脱除。
3、hello.i经过编译生成hello.s。在这一步,我们分析了C语言是如何转化为汇编代码的,全局变量是如何储存的等等。
4、hello.s经过汇编生成可重定向文件hello.o。这一步,把汇编语言翻译成机器语言,用二进制码代替汇编语言中的符号,即让它成为机器可以直接识别的程序。同时我们从0开始,为main函数的每一条指令赋予了地址。
5、链接。hello.o通过连接各个静态库和动态库最终生成了可执行文件hello。在hello的elf文件中,各个段都有了自己的虚拟地址,而且在hello.o中为确定位置的全局变量和函数,都得到了重定位。 -
hello的执行过程,同样涉及复杂的系统机制。考虑下面这些问题,hello运行时进程是如何产生的?hello要如何使用虚拟内存机制?hello的IO如何实现?
6、在shell中输入./hello 并跟上三个参数200111028 伍文浩 3.
7、子进程与执行:shell进程调用fork创建子进程,shell调用execve。execve调用启动加载器,加映射虚拟内存,创建新的内存区域,并创建一组新的代码、数据、堆和栈段.程序开始运行。
8、执行指令:CPU为其分配时间片,在一个时间片中,hello有对CPU的控制权,顺序执行自己的代码
9、动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存。hello访问内存时使用虚拟内存机制,MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
10、结束:程序结束后,shell父进程会回收子进程。
11、另外,hello在运行时还涉及信号机制。hello接受键盘输入Ctrl Z 引发中断异常挂起;hello调用exit函数引发陷阱;hello也可能被Ctrl C 终止。
附件
hello.c : hello.c 源文件
hello.i : hello.c 经过预处理生成的文件
hello.s : hello.i 经过编译生成的文件
hello.o : hello.s 经过汇编产生的可重定向文件
hello.o.objdump.txt : 使用objdump查看hello.o的反汇编代码
hello(hello.out) : 最终生成的可执行为文件
hello.elf.txt : hello的elf文件
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1]Randal E.Bryant, Dazke R.O’Hallaron 深入理解计算机系统. 北京:机械工业出版社,2016.7 : 489-492.
[2] HITCS-Lab1.ppt Ld的正确连接方法
[3] Linux常用命令ld怎么用
https://www.yisu.com/zixun/674914.html
[4] CSDN gcc 编译过程
https://blog.csdn.net/qq_33273956/article/details/88652921
[5] CSDN ELF文件格式解析
https://blog.csdn.net/feglass/article/details/51469511
[6] 百度百科 进程。
[7] 百度百科 逻辑地址
[8] 博客园 《printf 函数实现的深入剖析》
https://www.cnblogs.com/pianist/p/3315801.html
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)