哈尔滨工业大学
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 未来技术学院
学 号 2021111679
班 级 21W0311
学 生 李昊宸
指 导 教 师 史先俊
计算机科学与技术学院
2022年5月
本报告主要对计算机系统中最为基础的“Hello.c”程序进行编译输入的整个过程进行了细致的处理与分析,讲述计算机在IDE处理程序的幕后操作流程。整个过程中包括预处理,编译,汇编,执行以及执行过程中对进程的处理回收等。在整个流程完成后程序结束运行,如同它未曾来过。
关键词:Hello;编译;预处理;汇编;链接;进程管理;计算机系统
目 录
第1章 概述
1.1 Hello简介
通过文本编辑器或各种工具编写hello.c源文件
运行C预处理器(cpp),对hello.c进行预处理,得到hello.i文件
运行C编译器(ccl),翻译hello.i,得到汇编文件hello.s
运行汇编器(as),翻译汇编文件hello.s,生成一个可重定向的目标文件hello.o(binary)
运行链接器(ld),将hello.o与系统文件printf.o组合,得到可执行文件hello(binary)
执行hello(./hello),shell通过fork创建新的进程,用execve对虚拟内存映射,通过mmap开辟内存空间
CPU从虚拟内存中截取代码和数据,调度器为进程规划时间片区,在发生异常时能够触发异常处理
1.2 环境
硬件环境:AMD Ryzen 5 PRO 4650U with Radeon Graphics 2.10 GHz
软件环境:Windows 11 家庭中文版
工具: 文本编辑器;CODEBLOCKS;gcc;gdb;objdump;CPU-Z
1.3 中间结果
1.4 本章小结
简要介绍了hello一生的几个阶段,以及配置所需的软硬件操作环境、调试工具等基础信息。
第2章 预处理
2.1 预处理的概念与作用
预处理是为编译做的准备工作,能够对源程序 . c文件中出现的以字符“#”开头的命令进行处理,包括宏定义#define、文件包含#include、条件编译#ifdef等,最后将修改之后的文本进行保存,生成.i文件,预处理结束。
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
hello.c被拓展为3091行的hello.i代码,格式为txt,hello.i未改变hello.c文件中的主函数,只是将头文件加入代码,对宏进行了宏展开。
2.4 本章小结
介绍了文件预处理的结果,说明了文件预处理进行的操作。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
将源语言经过词法分析,语法分析,语义分析以及一系列优化后生成汇编代码的过程
3.1.2编译的作用
3.1.2.1词法分析:将源代码程序输入扫描器,将源代码的字符串序列分割成一系列记号。
3.1.2.2语法分析:基于词法分析得到一系列记号,生成语法树
3.1.2.3语义分析:有语义分析器完成,指示判断是否合法,不判断对错,又分为静态语义和动态语义。
3.1.2.4源代码优化(中间语言生成):中间代码使得编译器分为前端和后端前端产生与机器(或环境)无关的中间代码,编译器的后端将中间代码转换为目标机器代码,目的:一个前端对多个后端,适应不同平台。
3.1.2.5编译器后端主要包括:代码生成器(依赖于目标机器,依赖目标机器的不同字长,寄存器,数据类型等),目标代码优化器(选择合适的寻址方式,左移右移代替乘除,删除多余指令)。
3.2 在Ubuntu下编译的命令
指令:gcc -S -o hello.s hello.i
3.3 Hello的编译结果解析
3.3.1 声明
3.3.2数据部分
- .string字符串
汇编代码中有两个字符串,分别是指针数组和打印出的字符串。它们都在只读部分内。
- 局部变量
这里是在主函数内声明了一个局部变量 存储在栈的”-4(rbp)”中
- argc与char*argv[]
参数argc和数组char*argv[]是main函数的两个参数,argc是用户传入的参数;argv[]是main函数中存放指向char类型数据的指针的数组,它的头部也存放在栈中的%rbp中。
- 立即数
在汇编代码中直接体现,不以变量形式出现。
3.3.3算术操作
本程序中的算术运算只有循环增加i,在指令上体现为对-4(%rbp)+1之后检测是否小于等于7。
3.3.4关系操作
je是判断argc = 4;图3.3.4-2jle是判断局部变量i <= 7,若不满足则跳过条件判断的这一行,继续执行下面的程序。
3.3.5控制转移
关系操作一般伴随着控制转移,我们不难发现这段代码的循环块为L4,在L2中对循环变量i进行初始化,然后跳到L3中的判断语句,在L3中判断是否终止循环,若i <= 8,则进入L4循环,否则顺序执行。
3.3.6数组操作
程序中对数组的操作在L4循环块中,具体操作为从-32(%rbp)中取出数组的第一个元素argv[1]和第二个元素argv[2],放入rax和rdx中。之后对rax解引用,将argv[1]放入rsi中,再将字符串常量放入rdi中,使用printf输出
3.3.7
本程序中函数包括puts,exit,printf,atoi,sleep,getchar
3.3.7.1puts
由于printf中不包含需要引用的参数,只有一个字符串,所以这里使用了puts来打印输出
3.3.7.2exit
立即数1为exit函数唯一参数
3.3.7.3printf
其中rax和rdx是存储的两个引用参数argv[1]和argv[2]。然后调用printf函数打印。
3.3.7.4atoi与sleep
atoi是将一个char转化为int类型,用来作为sleep的参数;而atoi的参数是argv[3],也就是-8(%rbp)处的数据,第三个参数。
3.3.7.6getchar
无参数
3.4 本章小结
介绍了编译操作的过程,解读了将预处理后的hello.i文件编译为汇编代码文件的hello.s,在此过程中,编译器将会对源文件进行语法分析、词法分析,得到汇编文件hello.s。同时,本章中详细解析了变量、相关运算,以及各类C语言的基本语句的汇编表示,更便于理解高级语言的底层表示。
第4章 汇编
4.1 汇编的概念与作用
4.1.1概念:汇编器(as)将hello.s文件翻译成二进制机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存到目标文件hello.o中。hello.o是一个二进制文件。
4.1.2作用:在汇编过程中,文件格式将由面向阅读友好的文本文件转化为机器可执行的二进制文件,并且将文本文件中的常量转化为对应的二进制补码,同时,汇编过程也将生成可重定位目标文件的结构信息,Linux系统使用可执行可链接格式(ELF)对目标文件进行组织。
4.2 在Ubuntu下汇编的命令
命令as hello.s -0 hello.o
4.3 可重定位目标elf格式
4.3. 1ELF头
ELF头描述生成该文件的系统的字的大小和字节顺序、帮助链接器语法分析和解释目标文件的信息。上图中包含的有效信息有:ELF64(ELF 64位的可执行程序);2补码表示,小端法;REL(Relocatable file即可重定位目标文件);运行机器为AMD x86-64;节头开始为文件开始处1056偏移处。
4.3.2节头
这一段是elf中的节头部分,与CSAPP书中大致相同。表头部分是小节的名称、类型、地址、偏移量、节大小、项大小、(flags)旗标、链接、附加信息、对齐(2的align次方)。
4.3.3符号表
这其中,Num为某个符号的编号,Name是符号的名称。Size表示它是一个位于.text节中偏移量为0处的146字节函数。Bind表示这个符号是本地的还是全局的。
4.3.4重定位节
4.4 Hello.o的结果解析
(以下格式自行编排,编辑时删除)
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
hello.o反汇编代码和汇编代码hello.s差不多,所用指令都相同,不同的是
(1)分支转移:反汇编的跳转指令用的不是段名称比如.L3,二是用的确定的地址,因为,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
- 函数调用:在.s 文件中,函数调用之后直接跟着函数名称,而在反汇编程 序中,call的目标地址是当前下一条指令。这是因为 hello.c 中调用的函数 都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执 行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定。
- 立即数:在反汇编中被自动转换为16进制。
(4)反汇编中汇编指令被详细转换为机器代码。
4.5 本章小结
本章对应的主要是hello.s汇编到hello.o的过程。在本章中,我们查看了hello.o的可重定位目标文件的格式,使用反汇编查看hello.o经过反汇编过程生成的代码并且把它与hello.s进行比较,分析和阐述了从汇编语言进一步翻译成为机器语言的汇编过程。
第5章 链接
5.1 链接的概念与作用
链接的概念:将程序中调用的库函数合并到hello.o程序中,结果得到一个可执行目标文件,可以被加载到内存中,由系统执行。
链接的作用:链接使得分离编译成为可能,它将巨大的源文件分解成更小的模块,易于管理,可以通过独立地修改或编译这些模块,并重新链接应用,而不必再重新编译其他文件。
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.so /usr/lib/x86_64-linux-gnu/crtn.o
5.3 可执行目标文件hello的格式
ELF头
命令:readelf-h hello
hello的数据是2补码、小端序;文件类型是EXEC(可执行程序);节数为27
5.3.2节头
命令:readelf -S hello
hello中节头表的条目数多于hello.o中节头表的条目数。值得注意的是每一节都有了实际地址,而不是像在hello.o中那样地址值全为0。这说明了重定位工作已完成。同时,多出的节是为了能够实现动态链接,如.interp这一节包含动态链接器的路径名,动态链接器通过执行一系列重定位工作完成链接任务。
5.3.3符号表
命令:readelf -s hello
与hello.s相比,hello的elf符号表多出了很多内容,但是符号表的列没有变化,这说明在链接工作完成后,重定向文件与系统文件链接,在执行文件中加入了系统函数的机器语言符号。
5.4 hello的虚拟地址空间
在edb的Data Dump中,我们可以看到程序的虚拟地址空间从(0X00401000)到(0X00401ff0)
5.5 链接的重定位过程分析
命令:objdump -d -r hello > hello.txt
相比hello.o的反汇编中,hello的反汇编文件多出了很多节,以及很多系统自带的函数。 比如,在上图中的_init是程序初始化的代码;.plt是动态链接的过程链接表。
5.6 hello的执行流程
5.7 Hello的动态链接分析
动态共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个程序链接起来,这个过程就是动态链接。
把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
.plt:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
.got:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
hello在动态连接器加载前后的重定位是不一样的,在加载之后才进行重定位
5.8 本章小结
本章介绍了可重定向文件和系统静态函数库链接成为可执行文件的过程,链接为程序编写以及版本管理(利用动态链接)提供了一定的便利。利用静态库,计算机可以利用同一组标准库而不需要占用大量的磁盘空间;通过动态链接共享库,多个进程可以共享一个函数的多个副本而不需要花费多份内存空间,并且可以仅仅通过更新动态链接库而不必重新编译程序来更新版本。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念:
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
6.1.2 进程的作用:
在运行一个进程时,我们的这个程序好像是系统当中唯一一个运行的程序,进程的作用就是提供给程序两个关键的抽象。一分别是独立的逻辑控制流和私有的地址空间。
6.2 简述壳Shell-bash的作用与处理流程
shell是一种传统的用户界面,本质上也是一个程序。而bash是shell的一种,在1989年发布第一个正式版本。shell的工作原理是从系统输入读入指令,并检查其是否为程序内置命令。若是,则shell直接按用户指令执行;否则它会认为这是一个可执行程序,在文件系统中查找并为其fork一个子进程并执行(execve)。
6.3 Hello的fork进程创建过程
在程序运行时,Shell就会创建一个新的进程,并且新创建的进程更新上下文,在这个新建进程的上下文中便可以运行这个可执行目标文件。
fork()函数拥有一个int型的返回值。子进程中fork返回0,在父进程中fork返回子进程的Pid。新创建的进程与父进程几乎相同但有细微的差别。子进程得到与父进程虚拟地址空间相同的一份副本(代码、数据段、堆、共享库以及用户栈),并且子进程拥有与父进程不同的Pid。
6.4 Hello的execve过程
当Hellol的进程被创建之后,他会调用execve函数加载并调用程序。exevce函数在被调用时会在当前进程的上下文中加载并运行一个新程序。它被调用一次从不返回,执行过程如下:
删除已存在的用户区域
映射私有区:为 hello 的代码、数据、.bss 和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的
映射共享区:比如 hello 程序与共享库 libc.so 链接
设置 PC:exceve() 做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点
execve() 在调用成功的情况下不会返回,只有当出现错误时,例如找不到需要执行的程序时,execve() 才会返回到调用程序
6.5 Hello的进程执行
Hello在执行过程中涉及到几个十分重要的概念,如果不提前阐述就无法很好地理解Hello在运行中的状态。
6.5.1 逻辑控制流和时间片
进程的运行本质上是CPU不断从程序计数器 PC 指示的地址处取出指令并执行,值的序列叫做逻辑控制流。操作系统会对进程的运行进行调度,执行进程A->上下文切换->执行进程B->上下文切换->执行进程A->… 如此循环往复。 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。在一个程序被调运行开始到被另一个进程打断,中间的时间就是运行的时间片。
6.5.2用户模式和内核模式
Shell使得用户可以有机会修改内核,所以需要设置一些防护措施来保护内核,如限制指令的类型和可以作用的范围。
6.5.3上下文切换
如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程,上下文就是内核重新启动一个被抢占的进程所需要的状态,是一种比较高层次的异常控制流。
6.5.4调度
在对进程进行调度的过程,操作系统主要做了两件事:加载保存的寄存器,切换虚拟地址空间。
6.5.5用户态与核心态转换
为了能让处理器安全运行,需要限制应用程序可执行指令所能访问的地址范围。因此划分了用户态与核心态。核心态可以说是拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证了系统的安全性。
6.6 hello的异常与信号处理
在程序运行过程中乱打字。在打字的过程中,如果没有按回车,那么程序会正常一直运行,一旦输入回车(getchar读取),shell就会开始检测回车之前的行与内部命令匹配,若无匹配则会继续运行程序。
在程序运行过程中按下“Ctrl + Z”的组合键,可以使进程被挂起,但是hello并没有被回收,使用ps命令调出进程可以发现hello还在后台运行,使用fg 指令可以将hello进程恢复运行,输入字符串回车后结束程序,然后回收进程。
与Ctrl + Z不同,Ctrl + C将程序直接终止并且回收了进程,使用ps调用后台进程时发现已经没有hello,自然也无法使用fg 恢复进程。
6.7本章小结
本章介绍了hello运行的进程,进程是一个执行中程序的实例(Instance),即使操作系统中同时有多个程序执行,我们看到的也像是操作系统仅在运行前台程序一样,这是通过上下文切换实现的。操作系统根据某种特定的策略调度进程来在不同进程间快速地交错执行。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址:
程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。
7.1.2线性地址:
也叫虚拟地址,和逻辑地址类似,也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件也是内存的转换前地址。
7.1.3 虚拟地址:
也就是线性地址。
7.1.4 物理地址:
用于内存芯片级的单元寻址,与处理器和CPU链接的地址总线相对应。可以直接把物理地址理解成插在机器上那根内存本身,把内存看成一个从0字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在 Intel 平台下,逻辑地址(logical address)是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address。我们把这个过程称作段式内存管理。
一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址即虚拟地址(VA)到物理地址(PA)之间的转换通过分页机制完成,而分页机制是对虚拟地址内存空间进行分页。
系统将虚拟页作为进行数据传输的单元。Linux下每个虚拟页大小为4KB。物理内存也被分割为物理页, MMU(内存管理单元)负责地址翻译,MMU使用页表将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。
7.4 TLB与四级页表支持下的VA到PA的变换
页表是 PTE(页表条目)的数组,它将虚拟页映射到物理页,每个 PTE 都有一个有效位和一个 n 位地址字段,有效位表明该虚拟页是否被缓存在 DRAM 中。虚拟地址分为两个部分,虚拟页号(VPN)和虚拟页面偏移量(VPO)。其中VPN需要在PTE中查询对应,而VPO则直接对应物理地址偏移(PPO)。
7.4.1TLB加速地址翻译
既然要经常访问页表条目,不如直接将页表条目缓存到高速缓存中,这就是TLB的基本思想。TLB译为翻译后备缓冲器,也就是页表的缓存。TLB是一个具有较高相连度的缓存,如下图。根据VPN中的TLB索引找到缓存中相应的组,根据标记(tag)找到相应的缓存行,根据设置的有效位找到对应的位置。
7.4.2四级页表支持下缓存
下图为Core i7使用的四级页表地址翻译
同一级页表一样,若缓存页命中,则返回PPN,以VPO作为页便宜的到地址;若未命中,则经过四级页表查询,直到找到最终的PTE,查询,返回PPE。下图为4级页表目录格式:
7.5 三级Cache支持下的物理内存访问
获得物理地址之后,先取出组索引对应位,在L1中寻找对应组。如果存在,则比较标志位,相等后检查有效位是否为1.如果都满足则命中取出值传给CPU,否则按顺序对L2cache、L3cache、内存进行相同操作,直到出现命中。然后再一级一级向上传,如果有空闲块则将目标块放置到空闲块中,否则将缓存中的某个块驱逐,将目标块放到被驱逐块的位置。
7.6 hello进程fork时的内存映射
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。通过 fork 创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用 fork 时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:
删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存 在的区域结构。
映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结 构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名 文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长 度为零。
映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链 接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程 上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页(page fault)指虚拟内存中的DRAM缓存不命中。当CPU请求某个虚拟地址的数据而它恰好不在主存而在磁盘中时(通过检查有效位),就会引发缺页故障,调用内核中的缺页异常处理程序,它会选择一个牺牲页,用所请求的页替换该牺牲页。如果该主存中的牺牲页还被修改过,在替换之前内核还需要将其复制回磁盘。牺牲页的选择因系统而异,常见的替换算法有LRU(Least Recently Used)算法,它选择一个最近最久未使用的页面作为牺牲页。如果一个程序拥有良好的局部性,虚拟内存能够以较好的效率完成任务(典型的页面大小为4KB,这足够抵消从磁盘交换页面进入内存的时间)。但是,如果一个程序的工作集超出了物理内存的大小,就很可能引发抖动(thrashing)现象,这会导致页面从内存和磁盘之间频繁地换入换出,带来极大的时间开销,此时我们就应该设法减小工作集大小来提高程序速度。
7.9动态存储分配管理
在hello程序中使用的printf,而printf会使用由动态内存分配器动态内存分配机制。动态内存分配器维护进程虚拟地址空间中的的堆区域,它将堆视作一组不同大小的块的集合来维护,每个块是一段连续的虚拟内存碎片,要么是已分配的,要么是空闲的。空闲块保持空闲直至被应用程序分配,以已分配块保持已分配状态直至被释放。
分配器需要一些数据结构维护堆块来区分块边界以及区分已分配块和空闲块,这些可以被标识在块的头部,那么分配器可以将堆组织为一个连续的已分配块和未分配块的序列(称为隐式空闲链表),如下图所示:
这样的话,通过隐式空闲链表,分配器可以通过对于链表的操作以完成在堆上放置已分配的块、分割空闲块、获取额外内存、合并空闲块等操作。于是应用程序就可以动态地在堆上分配额外内存空间了。
7.10本章小结
本章主要介绍了有关内存管理的知识。详细阐述了hello程序是如何存储,如何经过地址翻译得到最终的物理地址。介绍了hello的四级页表的虚拟地址空间到物理地址的转换。阐述了三级cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
8.2.1函数open()和opennat()
int open(const char* path, int oflag, .../*mode_t mode*/);
int openat(int fd, const char* path, int oflag, .../*mode_t mode*/);
若文件打开失败则返回-1,失败原因可以通过errno查看;若成功将返回最小的未用的文件描述符的值。其中参数path为要打开的文件的文件路径,oflag为文件打开模式。打开模式如下:
8.3.2creat()函数
int create(const char *path, mode_t mode);
若文件创建失败返回-1;若创建成功返回当前创建文件的文件描述符。参数与open中对应的参数含义相同。create(path, mode)函数功能为创建新文件,与open(path, O_CREATE|O_TRUNC|O_WRONLY)功能相同。
8.3.3lseek()函数
int lseek(int fd, off_t offset, int whence);
成功则返回新的文件的偏移量;失败则返回-1。使用lseek()函数显式的为一个打开的文件设置偏移量。lseek仅将文件的偏移量记录在内核中,并不引起IO开销。
8.3.4read()函数
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);
若读取成功,读到文件末尾返回0,未读到文件末尾返回当前读的字节数。若读取失败,返回-1。fd为要读取文件的文件描述符。buf为读取文件数据缓冲区,nbytes为期待读取的字节数,通常为sizeof(buf)。
8.3.5write()函数
#include <unistd.h>
ssize_t write(int fd, const void* buf, size_t ntyes);
若写入成功则返回写入的字节数;失败返回-1。buf为写入内容的缓冲区,ntyes为期待写入的字节数,通常为sizeof(buf)。一般情况下返回值与ntypes相等,否则写入失败。
8.3 printf的实现分析
printf函数的具体实现如下:
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
首先,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;
}
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章简单介绍了Linux函数IO设备的管理方法,以及UnixIO函数的用法,参数含义和函数功能。
结论
综上所述 hello的“出生”到“死亡”由以下的步骤构成
①用文本编辑器或者其他能够写出字的东西编写hello.c的源代码。
②预处理器通过#include和#define等对hello.c的代码进行展开。
③编译器将C语言代码转换为汇编语言代码,程序开始从人类层面进入机器层面。
④汇编器将文本形式的汇编语言代码转换为二进制形式的可重定位目标文件hello.o,程序中使用的绝对地址将暂时保留为重定位条目,程序开始进入人类读不懂的二进制形态。
⑤链接器将hello.o与其它必要的库进行链接并进行重定位,得到可执行文件hello(也是人类读不懂的二进制文件)。
⑥shell通过fork创建进程,execve加载可执行文件hello及其所需的动态链接库,通过虚拟内存机制将可执行文件中的节映射到内存空间中。
⑦在hello进程运行时,会产生诸多的异常与信号,例如键盘中断、SIGTSTP、SIGINT等。
⑧在程序hello运行时,它将使用一个属于自己的虚拟地址空间,通过分段机制和分页机制进行内存访问。
⑨在程序hello运行时,要通过中断与IO端口等与外部硬件设备交互。
⑩最终,hello正常退出,或者进程收到信号后终止,都会使得操作系统结束并回收hello的进程。
总而言之,hello,没我们想的那么简单。看似easy的printf指令,它的执行牵扯到预处理、编译、重定向、shell中fork、execve等等步骤,这就是计算机系统的含金量。这是人类构建的一整套缜密的,独立于自然的语言系统,是人类智慧的结晶,也是计算思维的具象体现。
附件
参考文献
[1] 深入理解计算机系统原书第3版-文字版.pdf
[2]https://blog.csdn.net/qq_53603164/article/details/124885012
[3]https://blog.csdn.net/weixin_51744028/article/details/124716