摘 要
本文根据《深入理解计算机系统》的各个章节,描述了hello.c从编译到运行,再到进程回收的整个过程,并对执行中的每个步骤都分为一章详细阐述,从而加深对计算机系统的理解。
关键词:深入理解计算机系统;进程;操作系统;虚拟内存
目 录
文章目录
- 第1章 概述
- 第2章 预处理
- 第3章 编译
- 第4章 汇编
- 第5章 链接
- 第6章 hello进程管理
- 第7章 hello的存储管理
- 7.1 hello的存储器地址空间
- 7.2 Intel逻辑地址到线性地址的变换-段式管理
- 7.3 Hello的线性地址到物理地址的变换-页式管理
- 7.4 TLB与四级页表支持下的VA到PA的变换
- 每次 CPU 产生一个虚拟地址后,通过它的 VPN 部分看 TLB 中是否缓存,如果命中,直接得到 PPN,将虚拟地址中的 VPO 作为物理页偏移,这样就能得到物理地址;如果 TLB 不命中,则经过四级页表的查找得到最终的PTE,从而得到 PPN,进而得到物理地址。
- 7.5 三级Cache支持下的物理内存访问
- 7.6 hello进程fork时的内存映射
- 7.7 hello进程execve时的内存映射
- 7.8 缺页故障与缺页中断处理
- 7.9动态存储分配管理
- 7.10本章小结
- 第8章 hello的IO管理
- 结论
- 附件
- 参考文献
**
**
第1章 概述
1.1 Hello简介
Hello程序的声明周期是从一个高级C语言程序开始的,为了在系统上运行hello.c程序,每条C语句都必须被其它程序转化为一些列的机器语言指令,最终获得可执行目标文件hello,然后在shell中输入“./hello”,加载并运行可执行目标文件hello,最后回收hello进程。
Hello的P2P过程是从程序文件hello.c开始(Program),由gcc调用cpp,ccl,as,ld,经历了预处理,编译,汇编,链接这一过程(Process),最终获得可执行目标文件hello并保存在磁盘中。
Hello的020是指hello可执行目标文件从运行到最后被回收的过程。当在shell中输入“./hello”时,shell调用fork函数创建一个子进程,然后子进程调用操作系统内核提供的execve函数,将可执行目标文件hello加载到子进程的地址空间,并设置进程上下文。加载器使用mmap函数进行内存映射,将磁盘上的hello文件的内容映射到虚拟内存页中。
1.2 环境与工具
硬件环境:CPU:Intel® Core™ i7-10875H CPU @ 2.30GHz 2.30 GHz,RAM:16.0 GB
操作系统:Windows Subsystem for Linux2(Ubuntu20.04 LTS)
开发工具:vscode、vim、gcc、gdb、edb
1.3 中间结果
列出
文件 | 作用 |
---|---|
Hello.c | 源代码 |
Hello.i | 预处理后的代码 |
Hello.s | 汇编代码 |
Hello.o | 可重定位目标文件 |
Hello.o.elf.txt | Hello.o的ELF |
Hello.o.s | Hello.o反汇编后的代码 |
Hello | 链接后的可执行目标文件 |
Hello.elf.txt | Hello的ELF |
Obj_hello.s | Hello的反汇编代码 |
1.4 本章小结
本章介绍了hello的P2P和020过程,描述了使用的环境与工具,列出了生成的中间文件以及它们的作用。
(第1章0.5分)
**
**
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理是编译阶段之前对源代码进行的一系列文本替换和宏扩展操作。这一过程由预处理器cpp完成。
2.1.2 预处理的作用
预处理扫描带有#的命令,并对#语句进行替换。
(1)宏定义和替换
使用#define语句定义宏,然后在代码中使用这些宏,预处理器会将这些宏替换为其定义的内容。比如:
#define PI 3.14
float area = PIradiusradius
在预处理时,PI会被替换为3.14
(2)文件包含
使用#include指令包含其他文件的内容,如头文件。预处理器会将这些文件的内容插入到包含指令的位置。比如:
#include<stdio.h>
在预处理时,stdio.h文件的内容会被插入到代码中。
(3)条件编译
使用 #ifdef、#ifndef、#if、#else、#elif 和 #endif 指令控制代码的编译条件。预处理器会根据这些条件指令决定哪些代码块会被编译。比如:
#ifdef DEBUG
xxxx
#endif
在预处理阶段,如果定义了DEBUG,那么#ifdef 与 #endif之间的语句会被保留;否则,它会被忽略。
(4)宏函数
定义带参数的宏,预处理器会进行相应的替换和展开
#define SQUARE(x) ((x) * (x))
int y = SQUARE(5);
在预处理阶段,SQUARE(5)会被替换为((5) * (5))。
2.2在Ubuntu下预处理的命令
输入命令:gcc -E hello.c -o hello.i
-E表示只激活预处理阶段,最终生成预处理后的文件hello.i
图2-1 hello.i部分内容
2.3 Hello的预处理结果解析
源文件hello.c:
预处理结果hello.i:
(1)外部库文件
首先,开始部分有一系列的外部库.h文件路径
(2)数据类型名称替换
接下来是一些typedef,前面是我们编写代码时使用的标准数据类型,后面的别名就是引入的头文件中使用的类型定义。
(3)内部函数声明
中间部分是很多内部函数的声明,包括系统内核提供的接口的封装:
(4)main函数
最后是我们的main函数代码部分:
2.4 本章小结
本章介绍了hello.c的预处理过程,分析了预处理结果文件hello.i的各部分内容。
可以看到,hello.i文件共有3061行,并且hello.c中的main函数被放在最后。hello.c中的#include<stdio.h>经过预处理后被替换为hello.i中3000多行的stdio.h头文件内容,这体现了预处理简化代码,增加代码可读性和可维护性的作用。可以说,hello.c中还有很多残缺不全的部分,需要经过预处理,将宏定义替换和引用的头文件内容插入到代码中,补全代码,最终得到完整的代码文件hello.i
(第2章0.5分)
**
**
第3章 编译
3.1 编译的概念与作用
编译阶段将预处理的结果文件hello.i转换为汇编代码,汇编器作为这一阶段的核心,对hello.i进行了语法检验,语义分析和优化,最终生成了汇编代码文件hello.s
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
-S表示只激活到编译过程。
3.3 Hello的编译结果解析
接下来对C语言中数据类型及各种操作如何编译到汇编代码进行解析。
3.3.1 常量
(1)字符型常量
hello.c中的printf打印了一个字符串,这个字符串常量存放在.LC0中
(2)其他常量
还有一些常量以立即数出现在汇编代码中,比如if判断中有一个整数5:
它对应的汇编代码:
cmpl 比较argc与5是否相等,如果相等,则跳至.L2,;否则,将调用exit函数退出程序。
3.3.2 变量与运算
(1)局部变量
局部变量存储在寄存器或者栈中。
hello.c中有一个局部变量:
在汇编代码中,int i存放在栈中-4(%rbp)处:
(2)算术运算
在for循环中,局部变量i的值每次加1:
在汇编代码中,这个运算由addl指令完成:
3.3.3 数组/指针操作
main函数的参数中,有一个字符串数组argv:
根据代码:
在汇编代码中,%rdi向sleep函数传递参数,栈中%rbp指向的栈空间存放的内容是argv[4]的地址。
3.3.4 控制转移
for循环中,每次都需要比较i<10,对应的汇编代码是cmpl指令,判断i是否小于等于9,如果是,则继续执行循环体;否则,跳出循环:
3.3.5 函数调用与返回
(1)main函数
传入参数为argc、argv,参数从shell中传入,返回值设置为0
(2)printf函数
设置%rdi和%rsi的值来传入参数:
(3)exit函数
设置%rdi和%rsi的值来传入参数:
(4)atoi函数
将一个字符串的首地址给%rdi调用,函数返回这个字符串转成的整数值,存放在%eax中:
(5)sleep函数
由%rdi传入参数:
3.4 本章小结
本章介绍了从hello.i文件编译成hello.s文件的过程,分析了hello.i中的变量、常量、算术运算、控制转移以及函数调用在汇编语言中对应的转换。
现在hello.s只需再向前一步,即可被转化为由0和1组成的机器指令,被机器识别,这一步就是汇编过程。
(第3章2分)
**
**
第4章 汇编
4.1 汇编的概念与作用
汇编阶段是将高级语言编写的源代码转换为机器可以直接执行的指令序列的过程,这个过程中,汇编代码被逐行转换为01组成的机器代码。
汇编过程的主要作用有:
1.生成机器指令
2.优化:在汇编过程中,可以进行一些简单的优化,例如减少不必要的指令、重排指令以提高执行效率等。
3直接硬件控制:汇编过程使得程序员可以直接控制处理器、内存和其他硬件资源,这对于一些系统级编程、驱动程序开发以及对性能要求极高的应用非常重要。
4.2 在Ubuntu下汇编的命令
gcc -C hello.s -o hello.o
-C表示只激活到汇编过程。生成了可重定位目标文件hello.o。
4.3 可重定位目标elf格式
hello.o文件采用ELF格式编辑,典型的ELF可重定位目标文件格式如下:
4.3.1 ELF头
使用readelf -h hello.o查看ELF文件头:
ELF头以16字节的魔数开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表的偏移值,以及节头部表中条目的大小和数量等信息。
ELF头中还包括程序的入口点,也就是程序运行时要执行的第一条指令的地址,为0x0
4.3.2 节头部表
使用readelf -S hello.o查看节头部表:
节头部表描述了hello.o中文件中各个节的语义,包括节的类型、位置和大小等信息。Flags描述了各个节的读写权限:
4.3.3 符号表
使用readelf -s hello.o命令查看.symtab节中的ELF符号表:
.symtab节存放了程序中定义和引用的函数和全局变量的信息,包括main函数、atoi函数、exit函数的信息:
4.3.4 重定位条目
使用readelf -r hello.o命令查看hello.o的重定位条目:
由于数据和代码在内存中的地址没有被分配(链接过程中进行重定向),所以汇编器生成hello.o文件时,对最终地址未知的目标引用会生成一个重定位条目,告诉链接器在将目标文件合并成可执行目标文件时如何修改这个引用的位置。代码重定位条目放在.rela.plt节中,已初始化数据的重定位条目放在.rela.dyn节中。
4.4 Hello.o的结果解析
使用objdump -d -r hello.o分析hello.o的反汇编,并与第3章的 hello.s进行对照分析:
与hello.s不同点:
(1)函数调用,在hello.s中,call后面的目标函数是函数名,而在hello.o.s中,call后面是目标函数的相对偏移地址。
(2)分支跳转:在hello.s中,分支跳转的目标位置是通过.L1、.L2这样的助记符来实现的,而hello.o.s中,跳转的目标位置是指令的地址。
4.5 本章小结
本章介绍了汇编的过程,并对可重定位目标文件hello.o的内容进行分析,分析了ELF头、节头部表、重定位节以及符号表。比较了hello.s与hello.o反汇编之后的代码区别。
(第4章1分)
**
**
第5章 链接
5.1 链接的概念与作用
链接时将多个目标文件和库文件结合成一个单独的可执行文件或者库文件的过程。这个文件可被加载到内存并执行。
链接器使分离编译成为可能,在对软件项目进行维护时,我们不用将这个项目组织成一个巨大的源文件,而是将它分解为更小的、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需要简单地重新编译它,并重新链接应用,而不必编译其他文件。
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.s /usr/lib/x86_64-linux-gnu/crtn.o
5.3 可执行目标文件hello的格式
5.3.1 ELF头
查看hello的ELF头:
5.3.2 节头部表
链接器将各个文件对应的段都合并了,并且重新分配并计算了相应节的类型、位置和大小等信息。
各个节的地址页从0开始进行了分配。
5.4 hello的虚拟地址空间
使用edb打开hello:
5.5 链接的重定位过程分析
使用objdump -d -r hello命令反汇编代码,查看一下:
比较与编译阶段中生成的hello.s之间的区别:
(1)新增函数
链接后,加入了很多库函数,比如printf、getchar、atoi、exit、sleep、puts等:
这是链接时符号解析找到的函数定义。
(2)新增节
新增了.init和.plt节:
(3)函数调用与跳转
由于hello文件已经是重定位后的可执行目标文件,所以每一个call或jmp指令的目标地址就是确切的虚拟地址:
5.6 hello的执行流程
在shell中运行hello时,shell会调用驻留在存储器中的加载器来运行hello。当加载器运行时,它会创建内存映像,如图所示:
在ELF头部表的引导下,加载器将可执行目标文件的片复制到代码段和数据段,然后加载器跳转到程序的入口点,_start函数的地址处。这个函数是在系统目标文件ctrl.o中定义的,_start函数调用系统启动函数_libc_start_main,该函数定义在libc.so中。它初始化执行环境,调用用户层的main函数,处理mian函数的返回值。
使用gdb调试,单步运行程序:
hello文件的起始地址为0x1100,则在0x1100处打断点:
单步运行:
接下来会调用main函数,程序并不是直接调用main函数,而是先来到__libc_start_main,这用来向main函数传递参数。
5.7 Hello的动态链接分析
Hello程序在加载可执行文件时,自动加载了动态链接库ld-2.31.so。hello程序的一些地方可能会引用这个动态链接库里的符号(比如函数调用),这一机制是通过PLT表和GOT表实现的,它们的每一个条目都对应动态链接库中的符号引用。在readelf中我们可以在节表里看到关于PLT和GOT的信息(它们对应两个节):
在程序一开始,先执行_dl_start和_dl_init,_dl_init能够修改PLT和GOT,这一过程相当于“注册”动态链接库的符号,使得hello在后面的正常运行中能够引用它们(实现诸如间接跳转等行为)。
查看 _GLOBAL_OFFSET_TABLE 的内容:
运行前:
运行dl_init之后:
5.8 本章小结
本章介绍了 hello 的链接过程,比对链接后的 hello 与 hello.o 的不同,并拓展讲解了 endbr64 的作用,最后使用 gdb 工具逐行查看 hello 的运行过程。
(第5章1分)
**
**
第6章 hello进程管理
6.1 进程的概念与作用
进程(process),是操作系统对一个正在运行的程序的一种抽象。进程是程序的基本执行实体;在面向线程设计的系统(如当代多数操作系统、Linux 2.6及更新的版本)中,进程本身不是基本执行单位,而是线程的容器。程序本身只是指令、数据及其组织形式的描述,相当于一个名词,进程才是程序(那些指令和数据)的真正执行实例,hello 在运行时,操作系统会提供一种假象,就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器、主存和 I/O 设备。处理器看上去就像在不间断地一条接一条地执行程序中的指令,即该程序的代码和数据是系统内存中唯一的对象。这些假象就是通过进程来实现的。
6.2 简述壳Shell-bash的作用与处理流程
Shell 是一种交互型的应用级程序,用户能够通过 Shell 与操作系统内核进行交互。
Shell的处理流程:
(1)在 Shell 中输入 hello 程序的路径
(2)Shell 判断用户输入的是否为内置命令,如果不是,就认为它是一个可执行目标文件
(3)Shell 构造 argv 和 envp
(4)Shell 使用 fork() 创建子进程,调用 execve() 函数在新创建的子基础南横的上下文中加载并运行 hello 程序。将 hello 中的 .text 节、.data 节、.bss 节等内容加载到当前进程的虚拟地址空间
(5)execve() 函数调用加载器,跳转到程序的入口点,开始执行 _start 函数,我们的 hello 程序便正式开始执行了
6.3 Hello的fork进程创建过程
在shell接受到./hello这个命令行后,它会对其进行解析,发现是加载并运行一个可执行文件的命令,于是它会先创建一个对应./hello的作业,再用fork()创建一个子进程,这个子进程与父进程几乎与父进程完全相同,它们有着相同的代码段、数据段、堆、共享库以及栈段,但它们的pid与fork的返回值是不同的,因此可以进行区分。然后,父进程(即shell主进程)会将新创建的子进程用setpgid()放在一个新的进程组中,这样这个进程组就对应./hello这个作业,shell可以通过向进程组中的所有进程发信号的方式管理作业。
6.4 Hello的execve过程
execve() 函数加载并运行可执行目标文件,且带参数列表 argv 和环境变量列表 envp,execve() 函数调用一次从不返回。它的执行过程如下:
删除已存在的用户区域
映射私有区:为 hello 的代码、数据、.bss 和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的
映射共享区:比如 hello 程序与共享库 libc.so 链接
设置 PC:exceve() 做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
execve() 在调用成功的情况下不会返回,只有当出现错误时,例如找不到需要执行的程序时,execve() 才会返回到调用程序。
6.5 Hello的进程执行
进程正常运行是依赖于其上下文的,上下文是由程序正确运行的状态组成的,这些状态包括存放在内存里的程序的代码、数据、栈、寄存器、所占用的资源等,总之,在程序正常运行的时候,这些上下文状态绝不能被异常破坏。
然而,进程是需要不断进行切换的。当前运行在CPU的进程每隔一段时间就需要切换至其它进程。假设hello进程现在正在运行,突然发生了由主板上的时钟芯片引发的时钟中断(属于异步异常),然后处理器会从用户态立刻转入到内核态(拥有最高的管理特权级),控制流转入操作系统内核程序,内核会将hello进程目前的上下文暂时保存起来,然后通过进程调度程序找到要切换的进程B,加载B的被保存的上下文,将控制流交给进程B,处理器重新转入到用户态。
并且,操作系统会给每个进程分配时间片,它决定了当前进程能够执行它的控制流的连续一段时间。
在hello程序被执行的时候,初始时正常运行,然后hello调用sleep函数,这时sleep通过syscall引发异常(陷阱),转入内核态,内核保存hello的上下文,然后将hello进程置于休眠态,切换到其它进程。等到休眠时间到了的时候,此时时钟中断使得控制流从其它进程跳到内核,内核发现hello进程的休眠时间到了,就把hello解除休眠状态。之后在应当进行进程切换的时候,恢复hello的上下文,控制流转入hello进程,处理器切换到用户态。
6.6 hello的异常与信号处理
异常可以分为四类:中断、陷阱、故障和终止。
中断:来自处理器外部的IO设备的信号的结果。
陷阱:陷阱是有意的异常,是执行一条指令的结果,就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
故障:故障是由错误情况引起,它可能被故障处理程序修正;若无法修正这个错误,则处理程序返回到内核中的abort历程,终止引起故障的应用程序。
终止:终止是不可修改的致命错误造成的结果,通常是一些硬件错误。处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
信号:一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的时间。Linux系统支持30种不同类型的信号:
接下来在hello运行时,测试其中的部分信号。
SIGSTP:按下^Z会向hello进程发送SIGSTP信号,这个进程就会挂起来,可以使用fg %命令,让它在前台继续执行:
SIGINT:按下^C,向hello进程发送SIGINT信号,这个进程会被终止。
也可以在执行hello程序的命中加上&,这样它会在后台运行,分别使用ps和jobs来查看hello进程。
ps 和 jobs 的区别在于,ps 会打印系统中的所有进程,包括正在使用的终端 Shell,而 job 只会打印 Shell 正在维护的进程,它不会包括自己。
kill:当进程在后台运行时,可以使用kill命令来终止它:
6.7本章小结
本章介绍了hello进程的创建和运行过程,介绍异常和信号相关的概念,并向hello进程分别发送了信号SIGSTP、SIGINT,使用命令ps、jobs、kill,研究了hello进程接受信号的情况。
(第6章1分)
**
**
第7章 hello的存储管理
7.1 hello的存储器地址空间
hello进程与其他进程共享CPU和主存资源的,要使每个进程产生了一种错觉:进程占有所有的CPU和存储资源,就要引入对主存的抽象——虚拟内存。虚拟内存为每个进程提供了一个大的、一致的和私有的进程空间。虚拟内存上建立了两对映射关系:内存映射,这建立了磁盘上的对象与虚拟页之间的映射,即磁盘上的对象被分配到哪个虚拟页面上了;虚拟页与物理页之间的映射,这针对于已缓存的虚拟页而言,已缓存的虚拟页在主存中有一个对应的物理页,当需要从磁盘将虚拟页调入到主存时,就依赖于虚拟页与物理页的映射关系,将虚拟页存储到对应的物理页上,这个映射关系由页表记录。
数据可以在多种地址空间中拥有自己的地址,比如同一个函数,它在虚拟地址空间中拥有自己的虚拟地址,在物理地址空间中拥有自己的物理地址。接下来介绍一些地址空间:
1.逻辑地址:格式为“段地址:偏移地址”,是 CPU 生成的地址,在内部和编程使用,并不唯一。
2.线性地址:逻逻辑地址到物理地址变换之间的中间层,逻辑地址经过段机制后转化为线性地址。
3.虚拟地址:保护模式下,hello 运行在虚拟地址空间中,它访问存储器所用的逻辑地址。
4.物理地址:加载到内存地址寄存器中的地址,内存单元的真正地址。CPU 通过地址总线的寻址,找到真实的物理内存对应地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在实模式下,逻辑地址为:CS:EA,CS使段寄存器,将CS里的值左移四位,再加上EA就是线性地址。
在保护模式下,要用段描述符作为下标,到GDT或LAT中查表获得段地址,段地址+偏移地址就是线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟地址空间会被分为若干页,即分页机制。CPU对于一个线性地址会取它的高若干位,通过它们去存储在内存中的页表里查询对应的页表条目,得到这个线性地址对应的物理页起始地址,然后与线性地址的低位(页中的偏移)相加就是物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
页表是PTE的数组,它将虚拟页映射到物理页,每个PTE都有一个有效位和一个n位的地址字段,有效位表明该虚拟页是否被缓存在DRAM中,地址字段表明DRAM中相应的物理页的起始位置,它分为两个部分:VPO和VPN:
7.4.1 TLB加速地址翻译
为了优化 CPU 产生一个虚拟地址后,MMU 查阅 PTE的过程,在 MMU 中设置一个关于 PTE 的小缓存,称为 TLB(翻译后备缓冲器)。像普通的缓存一样,TLB 的索引和标记是从 PTE 中的 VPN 提取出来的,如图:
7.4.2 四级页表翻译
Core i7采用四级页表层次结构,如图:
每次 CPU 产生一个虚拟地址后,通过它的 VPN 部分看 TLB 中是否缓存,如果命中,直接得到 PPN,将虚拟地址中的 VPO 作为物理页偏移,这样就能得到物理地址;如果 TLB 不命中,则经过四级页表的查找得到最终的PTE,从而得到 PPN,进而得到物理地址。
7.5 三级Cache支持下的物理内存访问
得到物理地址后,将物理地址分为 CT(标记位)、CI(组索引) 和 CO(块偏移)。根据 CI 查找 L1 缓存中的组,依次与组中每一行的数据比较,有效位有效且标记位一致则命中。如果命中,直接返回想要的数据。如果不命中,就依次去 L2、L3 缓存判断是否命中,命中时将数据传给 CPU 同时更新各级缓存。
7.6 hello进程fork时的内存映射
在 Shell 输入命令行后,内核调用fork创建子进程,为 hello 程序的运行创建上下文,并分配一个与父进程不同的PID。通过 fork 创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用 fork 时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。
7.7 hello进程execve时的内存映射
execve() 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。加载并运行 hello 需要以下几个步骤:
删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的 .text 和 .data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC),execv() 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
hello程序在运行时,很有可能会发生缺页故障,当CPU产生的一个虚拟地址并不在DRAM缓存中时,即发生缺页,此时会调用缺页异常处理程序,缺页异常处理程序会选择一个牺牲页,用要读取的地址的内容替换它,然后内核重新启动导致缺页的指令。
7.9动态存储分配管理
在hello程序中使用的printf,而printf会使用由动态内存分配器动态内存分配机制。动态内存分配器维护进程虚拟地址空间中的的堆区域,它将堆视作一组不同大小的块的集合来维护,每个块是一段连续的虚拟内存碎片,要么是已分配的,要么是空闲的。空闲块保持空闲直至被应用程序分配,以已分配块保持已分配状态直至被释放。
分配器需要一些数据结构维护堆块来区分块边界以及区分已分配块和空闲块,这些可以被标识在块的头部,那么分配器可以将堆组织为一个连续的已分配块和未分配块的序列(称为隐式空闲链表),如下图所示:
这样的话,通过隐式空闲链表,分配器可以通过对于链表的操作以完成在堆上放置已分配的块、分割空闲块、获取额外内存、合并空闲块等操作。于是应用程序就可以动态地在堆上分配额外内存空间了。
7.10本章小结
本章分析了hello的内存访问机制,尤其阐明了基于页式管理的虚拟内存机制,对于某个地址处的数据访问,要涉及到基于段描述符的逻辑地址到线性地址的转换、基于分页机制的线性地址到物理地址的转换、TLB与Cache、缺页故障等机制,而虚拟内存空间能够使得程序在表面上独占整个内存。
(第7章 2分)
**
**
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
在Linux中,所有的IO设备(网络、磁盘、终端等)都被模型化为文件,所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix IO接口可以实现4种操作:
(1)打开文件,应用程序要求内核打开相应的文件,表示它要访问一个IO设别,内核放回这个文件的描述符以唯一标识这个文件。shell创建的每个进程开始都有三个打开的文件:标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。
(2)改变当前的文件位置,应用程序通过调用seek操作,显示地设置文件的当前位置。
(3)读写文件,读操作read就是从当前位置开始,从文件复制n个字节到内存,然后文件位置增加n;写操作write则是从内存复制n个字节到一个文件,从当前文件k开始,然后对k加n,文件末尾有EOF标识,如果读写文件时遇到了这个标识符,意味着到达了文件的末尾。
(4)关闭文件,当应用完成了对文件的访问之后,它就通知内核关闭这个文件,内核释放文件打开时创建的数据结构和内存资源。
8.3 printf的实现分析
printf函数实现的简单逻辑:
int printf(const char *format, ...) {
va_list args;
int done;
va_start(args, format);
done = vfprintf(stdout, format, args);
va_end(args);
return done;
}
首先,printf开辟一块输出缓冲区,然后用vsprintf在输出缓冲区中生成要输出的字符串。之后通过write将这个字符串输出到屏幕上。而write会通过syscall陷阱跳到内核,内核的显示驱动程序会通过这些字符串及其字体生成要显示的像素数据,将它们传到屏幕上对应区域的显示vram中。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar函数的简单实现逻辑:
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
bb=buf;
}
return(--n>=0)?(unsigned char)*bb++:EOF;
}
首先,getchar会开辟一块静态的输入缓冲区,若输入缓冲区为空,则调用read向输入缓冲区中读入一行字符串。而read会通过syscall陷阱跳到内核,内核会使得调用方不断等待。当按下键盘后,键盘中断处理程序执行,向输入缓冲区中放入由键盘端口读入的扫描码转换成的字符,直到按下回车后调用方不再等待。那么getchar所做的事情其实就是不断地从输入缓冲区中取下一个字符,如果没有则等待输入。
8.5本章小结
本章分析了hello的printf函数和getchar函数,从IO的机制揭示了hello是如何在屏幕上打印出信息的,又是如何从键盘输入的,它们背后的机制是软件通过底层IO端口或中断与外部硬件的交互。
(第8章1分)
结论
通过以上八章的分析,hello终于走完了它的一生,一切都在电光火石之间,在你按下Enter键的一瞬间。hello的一生虽然短暂,但是无比精彩,计算机系统中的所有技术,所有高超的设计和无与伦比的思想,为hello奏响软硬件的交响曲。让我们做一个小结吧,就像对英雄的赞歌一样,回顾hello的一生
(1)出生伊始,程序员用文本编辑器编写hello.c
(2)预处理通过#include和#define等对hello.c的代码进行预处理展开。
(3)编译器将C语言转换为汇编代码,程序开始进入机器层面。
(4)汇编器将文本形式的汇编语言代码转换为二进制形式,即机器指令,绝对地址暂时保留为重定位条目。
(5)链接器将hello.o与其他库进行链接并进行重定位,得到可执行目标文件hello
(6)shell通过fork创建进程,使用execve函数加载hello,通过加载器为磁盘上的hello文件分配虚拟页
(7)在运行hello进程时,会根据虚拟内存机制执行缺页处理程序,将hello程序逐步调入到内存中,使hello得到执行;同时hello进程运行时可能会接受多种异常和信号,并对每种信号进行相应。hello进程运行时,虚拟内存为它提供了一个独立的,私有的虚拟地址空间,通过分段机制和分页机制进行内存访问。
(8)在hello运行时,要通过中断与IO端口等与外部硬件设备进行交互,最终打印出字符。
(9)最终,hello正常退出,shell中的父进程会回收hello进程。hello挥一挥衣袖,不带走一片云彩。
ICS为我打开了计算机的“黑盒子”,让我看到了简单的hello.c程序需要如此复杂且巧妙的机制去运行,那颗在初学C语言时的好奇心总算得到了一点满足。运行hello的每个步骤无不凝结着计算机科学家们的伟大智慧,他们为程序员与机器搭建起一个巧妙而坚固的桥梁,让我们与机器对话,与智能为友。
(结论0分,缺失 -1分,根据内容酌情加分)
**
**
附件
文件 | 作用 |
---|---|
Hello.c | 源代码 |
Hello.i | 预处理后的代码 |
Hello.s | 汇编代码 |
Hello.o | 可重定位目标文件 |
Hello.o.elf.txt | Hello.o的ELF |
Hello.o.s | Hello.o反汇编后的代码 |
Hello | 链接后的可执行目标文件 |
Hello.elf.txt | Hello的ELF |
Obj_hello.s | Hello的反汇编代码 |
(附件0分,缺失 -1****分)
**
**
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Bryant, R.E. and O’Hallaron, D.R., 2011. Computer Systems: A Programmer’s Perspective. Prentice Hall.
[2] S. Tang, 计算机组成原理. 清华大学出版社, 2019.
(参考文献0分,缺失 -1分)