目录
摘 要
HelloWorld是众多程序员是编写的第一个程序,但不是所有人都清楚hello程序运行所经历的,它从P2P到020,从程序到进程,由编写好.c的程序->预处理->编译->汇编->链接形成可执行文件->bash中运行时为其fork进程,hello便完美的诞生;从fork新进程->execve加载运行程序->映射虚拟内存载入物理内存->CPU为hello分配时间片执行逻辑控制流->bash回收进程,又赤条条的离开,不带走一片云彩;让我们来一起回顾hello这精彩的一生。
关键词:计算机系统;进程;linux;操作系统;
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:From Program to Process从程序到进程,是指编写好.c的程序->预处理->编译->汇编->链接形成可执行文件->bash中运行时为其fork进程;
020:From 0 to 0,是指bash为其fork新进程->execve加载运行程序->映射虚拟内存载入物理内存->CPU为hello分配时间片执行逻辑控制流->bash回收进程;
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows10 64位以上;Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发与调试工具:Visual Studio 2010 64位以上;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
hello.i:源程序hello.c经预处理后生成的修改了的源程序文本;
hello.s: hello.i经过编译后生成的汇编程序;
hello.o: hello.s经过汇编生成的可重定位目标程序;
hello: .o文件经过链接后产生的可执行目标程序;
hello.out: hello反汇编后形成的可重定位目标程序;
1.4 本章小结
本章总梳理了一下程序运行的各个阶段,回顾了简单程序hello.c的运行过程P2P和020,也是对计算机系统这门课的学框架。
第2章 预处理
2.1 预处理的概念与作用
预处理器(cpp)根据以#开头的命令,修改原始的C程序。比如hello.c中第6行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件拓展名。
2.2在Ubuntu下预处理的命令
命令:gcc hello.c -E -o hello.i
2.3 Hello的预处理结果解析
可以看到hello.i文件中的头文件被展开插入到程序文本中了,注释被删除掉了,main函数仍然保持不变,生成了新的C程序hello.i;
2.4 本章小结
本章梳理了程序的预处理过程,并对处理过程做了分析;
第3章 编译
3.1 编译的概念与作用
编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。该程序包含函数main的定义,其中每条语句都以一种文本格式描述了一条低级机器语言指令。汇编语言为不同高级语言的不同编译器提供了通用的输出语言。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
所有‘.’开头的行都是指导汇编器和链接器工作的伪指令,我们通常可以忽略这些行。另一方面,也没有关于指令的用途以及它们与源代码之间关系的解释说明。
3.3.1数据类型
①字符串
字符串一般储存于数据段,加载时需用leaq指令取地址;
②局部变量
局部变量会放在堆栈中,汇编代码中将i放在了-4(%rbp)上,并且i被初始化成了0;
③常量
如各种立即数,可以在汇编代码中直接处理;
3.3.2赋值操作
汇编用mov指令来实现赋值操作,mov Src,Dst表示将Src寄存器所存的值传送给Dst,mov后缀的不同表示传送不同的字节数。
movb:一个字节,movw:两个字节,movl:四个字节,movq:八个字节。
3.3.3算术操作
由于i为int类型,所以hello.c中的i++在汇编代码中用addl指令实现。
add Src,Dst:Dst=Dst+Src,sub Src,Dst:Dst=Dst-Src.
3.3.4关系操作与控制转移操作
hello.c中的if(argc!=4)在汇编中用cmp和je指令来实现,for循环中的条件判断语句在汇编中用cmp和jle指令来实现;
cmp Src,Dst:基于Dst-Src设置条件码;je D:相等时跳转到D;jle D:(有符号数)小于等于时跳转到D;
可以判断出26-29就对应这if语句条件下的操作,跳转的.L2对应着if语句后面的操作;同样.L3的汇编代码对应着源代码for循环中的条件判断语句.L4的代码为for中的循环语句,.L3后的代码为跳出循环后的语句。
3.3.5函数操作
①函数调用
在汇编中用call指令来调用函数;
②函数返回
函数的返回值会储存在寄存器%rax中;
③参数引用
引用函数参数时,从寄存器%rdi中取第一个参数,从%rsi取第二个参数;如hello.c中main函数的第一、二个参数分别存于寄存器%edi和%rsi中;
④参数传递
给函数传参时,在调用函数前应先向寄存器%rdi、%rsi分别传送第一、二个参数的值;如果参数大于2,参数再依次传递给寄存器%rdx,%rcx,%r8,%r9;
如调用第一个printf时,将数据段.LC0中的字符串传给了%rdi;
调用第二个printf时,先将arg[2]的值传给了%rdx,再将arg[1]的值传给了%rsi,最后将数据段.LC1字符串传递给%rdi;
调用exit函数时将立即数1传递给%rdi;
调用atoi函数时,将arg[3]的值传给%rdi;
调用sleep函数时,将atoi函数的返回值传递给%rdi;
3.3.6数组操作
数组argv[]的起始地址被存放在栈-32(%rbp)的位置,由两次addq的操作移动了栈帧指针,将arg[2]存在了%rdx里,arg[1]存在了%rsi里;
3.3.7类型转换
hello.c中调用了函数atoi()来将字符串arg[3]转换为整型数,汇编时也是用call指令调用atoi函数,将参数传递给寄存器%rdi;
3.4 本章小结
本章回顾了编译阶段的流程,结合源代码详细分析了hello.s的汇编代码,再一次巩固了编译器是如何对各种数据类型以及操作进行处理的。
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它包含函数main的指令编码。如果我们在文本编辑器中打开hello.o文件,将看到一堆乱码。
4.2 在Ubuntu下汇编的命令
gcc hello.s -c -o hello.o
4.3 可重定位目标elf格式
上图为一个典型的ELF可重定位目标文件。
4.3.1 ELF头
使用命令readelf -h hello.o查看
ELF头描述了生成该文件的系统的字的大小和字节顺序,也包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小(64bytes)、目标文件的类型(是可重定位的)、机器类型X86-64等等。
4.3.2 节头部表
使用命令readelf -S hello.o查看
节部头表包含了13个节,以及每个节的名称、类型、大小、偏移量等信息;
4.3.3 .symtab符号表
使用命令readelf -s hello.o查看。
.symtab: 一个符号表,用于存放程序中定义和引用的函数和全局变量的信息。每个可重定位文件在.symtab中都有一张符号表(除非特意用STRIP命令去掉它)。和编译器的符号表不同,.symtab符号表不包含局部变量的条目。
name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字;
value是符号的地址,对于可重定位的模块来说,value是距定义目标节的起始位置的偏移,对于可执行目标文件来说,该值是一个绝对运行的地址;
size是目标的大小(以字节为单位);
type要么是数据要么是函数;
Bind字段表明符号是本地的还是全局的。
4.3.4 .rel.text
使用命令readelf -a hello.o查看。
.rel.text:一个.text 节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示连接器包含这些信息。
offset偏移量 是需要被修改的引用的节偏移;
symbol标识被修改引用应该指向的符号;
type类型 告知链接器应该如何修改新的应用;
attend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整;
4.4 Hello.o的结果解析
4.4.1反汇编结果与hello.s文件的区别:
①反汇编中除了汇编代码,左侧还有机器代码;
②反汇编与hello.s文件的立即数采用的进制不同,反汇编使用的是十六进制,而hello.s采用的是十进制;
③反汇编后不具有伪指令了;
④反汇编后jmp指令跳转变成了确定的地址;
⑤反汇编后的函数调用所用的call指令后也接的不是函数名而是下一条指令地址值了;
4.4.2机器语言的构成:
机器语言指令是一种二进制代码,由操作码和操作数两部分组成。
4.4.3机器语言与汇编语言的关系:
汇编语言,只是符号形式的机器语言,但用它来编写程序或阅读已经编写好的程序比起机器语言来要简单和方便多了。这就是计算机语言发展中的第二代语言—汇编语言。人们使用这种助记符编写程序后,要是计算机能够接受,还必须把编好的程序逐条翻译成二进制编码的机器语言。当然,这个工作并不是有程序员来完成,而是有称为“汇编程序”的程序自动完成的。汇编程序的功能就是把由汇编语言编写的程序翻译成机器语言程序,计算机才能执行该程序。这个翻译过程称为汇编。
所以每一条汇编语言操作码都可以用机器二进制数据来表示,进而可以将所有的汇编语言(操作码和操作数)和二进制机器语言建立一一映射的关系。
4.4.4区别分析:
①操作数:机器代码采用的是二进制,故反汇编时转换为16进制更方便;
②分支转移:hello.s中的段名称如.L2、.L3,只是汇编语言中的助记符,本质表示的还是地址,故在反汇编后直接以地址的形式显示出来了;
③函数调用:反汇编后的call指令后不再接函数名了,而是接下一条指令的地址,这是由于调用的函数在共享库中,需要通过动态链接器才能确定函数的运行时的地址。所以在汇编成机器语言时,对于这些不确定地址的函数调用,call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定。
4.5 本章小结
本章回顾了汇编阶段的流程,温故而知新,将流程整理一遍发现了很多不懂的新东西,不仅以elf格式查看了可重定位目标,还比较了反汇编后的代码和原汇编代码的区别,加深了对重定位概念的理解。
第5章 链接
5.1 链接的概念与作用
当hello程序调用函数库中的函数时,函数所存于的另外单独预编译好了的目标文件必须用链接器来合并到hello.o的程序中,结果得到可执行目标文件hello文件,它可以被加载到内存中,由系统执行。
如hello程序调用的printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个函数必须通过链接器(ld)将这个文件合并到hello.o程序中,结果得到hello文件。
5.2 在Ubuntu下链接的命令
命令: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
5.3 可执行目标文件hello的格式
4.3.1 ELF头
使用命令readelf -h hello查看
变化:类型由可重定位文件变为了可执行文件,入口点地址与程序头起点也不再为0了,节头起始位置和数量发生了变化,program header的大小和数量也发生了变化。
4.3.2 节头部表
使用命令readelf -S hello查看
节部头表增加到了30个,以及每个节的名称、类型、大小、地址、偏移量等信息;
4.3.3 符号表
使用命令readelf -s hello查看。
可以看到,除了.symtab增加了一些符号外,还多了另一个表.dynsym动态符号表。
.dynsym:动态符号表,用来保存与动态链接相关的导入导出符号,不包括模块内部的符号。而.symtab则保存所有符号,包括 .dynsym 中的符号。
so 库中就是只有.dynsym就可以了,用于动态链接时的符号查找和地址重定位。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
ELF头:
.text:已编译程序的机器代码
.rodata:可以看到字符串Hello存储在堆栈中,
.data:因为没有定义全局变量和静态变量,所以该地址空间没有存储数据;
.bss:因为没有定义全局变量和静态变量,所以该地址空间没有存储数据;
5.5 链接的重定位过程分析
(objdump -d -r hello)分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
左图为hello.o的反汇编代码,右图为hello的反汇编代码。
区别:
之前的.text中只有main的汇编的代码,但链接之后增加了puts、printf、getchar、atoi、exit、sleep等函数的汇编代码;
①增加了新的节:之前的反汇编代码只有.text节,但链接后增加了.init、.pit、.pit.sec、.fini节的代码;
②函数调用地址更新:之前的代码call指令后接的为下一指令的地址,更新后的call指令后接了调用函数的确切地址;
③删除了重定位条目:因为已经更新的地址,所以原标记的重定位条目被删除了;
④函数参数传递值更新:之前用lea指令取址进行参数传递时,传的为0x0(%rip),重定位后相对寻址的值得到了更新;
⑤main函数地址及跳转地址更新:之前的main函数地址为0,更新后main函数有了确切的地址;之前代码的跳转指令后接的地址为main函数地址的相对值,更新后为确切的地址值;
重定位过程分析:
下图为重定位条目结构与算法
以下面重定位条目为例:
main的运行时地址 ADDR(S) = 0x4011d6
lea指令的运行时地址 ADDR(r.symbol) = 0x402008
引用的运行时地址 refaddr = ADDR(S) + r.offset = 0x4011f2(12的位置)
lea下一指令运行时地址 = 0x4011f6= refaddr - r.addend
根据重定位算法,我们将修改0x0为0x4,这正是.rodata引用处的最终值
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
借助edb的analyzer可列出从加载hello到程序终止的所有过程,其调用的各个子程序名和程序地址如上图所示(<>中为程序地址)。
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
edb对动态链接库的分析:
5.8 本章小结
本章回顾了链接阶段的流程,又学到了新东西,比较了链接前后的汇编代码的区别,又亲自国立一遍重定位的算法,更深刻的理解了重定位的过程;还巩固了动态链接的相关概念。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
进程的作用:清晰地刻画动态系统的内在规律,有效管理和调度进入计算机系统主存储器运行的程序。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell-bash在操作系统的最外层,负责直接与用户进行对话,把用户的输入解释给操作系统,并处理各种各样的操作系统的输出结果,输出到屏幕反馈给用户。这种对话方式可以是交互式也可以是非交互式的;
处理流程:
1.读取用户由键盘输入的命令;
2.对命令进行分析,以命令名为文件名,并将其他参数改造为系统调用execve()参数处理所要求的格式;
3.终端进程(shell)调用fork()或者vfork()建立一个子进程;
4.子进程根据文件名(命令名)到目录中查找有关文件,将他调入内存,并创建新的文本段,并根据写时拷贝的方式创建相应的数据段、堆栈段;
5.当子进程完成处理或者出现异常后,通过exit()或_exit()函数向父进程报告;
6.终端进程调用wait函数来等待子进程完成,并对子进程进行回收;
6.3 Hello的fork进程创建过程
父进程通过调用fork()函数创建一个新的运行的子进程时,子进程得到与父进程用户及虚拟地址空间相同但是独立的一个副本,包括代码段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的PID。
过程:
①内核为新进程建立内核数据结构,并分配给它惟一一个PID。
②为了给新进程建立虚拟存储器[建立页目录]。
③建立了当前进程的mm_struct,区域结构和页表的原样拷贝。
④将两个进程的每一个页面都标记为[只读]。并给两个区域进程的每一个区域结构都标记为[私有的写时拷贝]。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。它先加载运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序,否则调用一次便从不返回。
过程:
①删除已存在的用户区域:删除当前进程虚拟地址的用户部分中已存在的区域结构。
②映射私有区域:为新程序的文本,数据,bss和栈区域建立新的区域结构。全部新的区域结构都是私有的,写时拷贝的。文本和数据区域被映射到a.out文件中的文件和数据区。bss区域是请求二进制零,映射到匿名文件。
③映射共享区域
④设置程序计数器
⑤设置PC指向文本区域的入口点。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
6.5.1 进程时间片:
由于系统不止存在运行hello的一个进程,所以多个进程的逻辑控制流交错执行。每个进程执行它的控制流的一部分的每一段时间叫做时间片,因此,hello所在的进程与其他进程轮流运行时被分成了多个时间片。
6.5.2 上下文切换:
上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,它由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。操作系统的内核使用上下文切换的较高层形式的异常控制流来实现多任务。
重新启动hello所在进程的过程和hello进程被其他进程抢占时都会发生上下文切换;
过程为:(1)保存当前进程的上下文,(2)恢复某个先前被抢占的进程被保存的上下文,(3)将控制传递给这个新恢复的进程;
6.5.3 调度的过程:
在hello所在进程执行的某种时刻,内核可以通过调度来抢占该进程,并重新开始一个先前被抢占了的进程;也可以通过调度用hello所在的进程抢占其他正在执行的进程;
6.5.4 用户态与核心态转换:
运行hello的进程初始时是在用户模式中的。当程序发生中断、故障或者陷入系统调用这样的异常时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
Hello执行过程中可能产生中断和终止异常;
6.6.1 中断:来自I/O设备的信号
当进程在前台运行时,键入Ctrl+C或者时乱按并回车,会产生SIGINT信号,内核发送该信号给给这个前台进程组中的每个进程,并终止前台进程;
当进程在前台运行时,键入Ctrl+Z,会产生SIGTSTP信号,内核发送该信号给这个前台进程组中的每个进程,停止前台进程;
6.6.2 终止:不可恢复的错误
当kill程序向hello所在进程组发送SIGKILL信号(信号9)时,该进程组就会将信号发送给组中每个进程,并杀死进程;
6.7本章小结
本章回顾了进程的概念,以及梳理了hello运行时它所在的进程的执行过程,并分析了运行中可能碰到的异常和信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.1.1逻辑地址:
是由hello程序产生的由段选择符和段内偏移地址组成的地址。这2部分组成的地址并不能直接访问物理内存,而是要通过分段地址的变化处理后才会对应到相应的物理内存地址。
7.1.2虚拟地址:
指由hello程序产生的段内偏移地址。逻辑地址与虚拟地址二者之间没有明确的界限。
7.1.3线性地址:
指虚拟地址到物理地址变换的中间层,是处理器可寻址的内存空间(称为线性地址空间)中的地址。hello程序代码会产生逻辑地址,或者说段中的偏移地址,加上相应段基址就成了一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换产生物理地址。若是没有采用分页机制,那么线性地址就是物理地址。
7.1.4物理地址:
指内存中物理单元的集合,他是地址转换的最终地址,进程在运行时执行指令和访问数据最后都要通过物理地址来存取主存。
逻辑(虚拟)地址经过分段(查询段表)转化为线性地址。线性地址经过分页(查询页表)转为物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址;
7.3 Hello的线性地址到物理地址的变换-页式管理
将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面,然后把页式虚拟地址与物理地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1当页面命中时:
(1)处理器生成虚拟地址 VA,传给 MMU;
(2) MMU 生成 PTE 地址,通过高速缓存或主存请求得到它;
(3)高速缓存或主存返回PTE;
(4)MMU 构造物理地址,并传送给高速缓存或主存;
(5)高速缓存或主存返回请求的数据字给处理器。
7.4.2当页面不命中时:
(1)处理器生成虚拟地址 VA,传给 MMU;
(2)MMU 生成 PTE 地址,通过高速缓存或主存请求得到它;
(3)高速缓存或主存返回PTE;
(4)PTE 有效位为 0,传递 CPU 的控制,让操作系统内核执行缺页异常处理程序;
(5)确定物理内存的牺牲页,如果该牺牲页已被修改则换出磁盘;
(6)缺页处理程序页面调入新的页面,并更新内存中的 PTE;
(7)返回到原来的进程,再次执行缺页指令,此时会命中,MMU 将返回请求的数据字给处理器。
7.5 三级Cache支持下的物理内存访问
由于对于每个k,位于k层的更快更小的存储设备作为位于k+1层的更大更慢的存储设备的缓存。
当程序需要第K+1层的某个数据对象d时,它首先在当前存储在第k层的一个块中查找d,如果d刚好缓存在第k层中,那么缓存命中;另一方面,第k层中没有缓存数据d。此时第k层的缓存从第k+1层缓存中,取出d的块,如果k满了,会通过一定的策略,选出一个k中的块进行替换。第k层中没有缓存数据d。此时第k层的缓存从第k+1层缓存中,取出d的块,如果k满了,会通过一定的策略,选出一个k中的块进行替换。(其中k可取1、2)
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存,创建当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
假设运行在当前进程中的程序执行了execve调用:execve("hello",NULL,NULL)。加载运行hello需要以下几个步骤:
(1)删除已存在的用户区域;
(2)映射私有区域;
(3)映射共享区域;
(4)设置程序计数器(PC),设置hello进程上下文中的程序计数器,使之指向代码区域的入口点;
下一次调用hello进程时,它将从这个入口点开始执行。
7.8 缺页故障与缺页中断处理
假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
· 虚拟地址 A 是合法的吗?换句话说,A 在某个区域结构定义的区域内吗?为了回答这个问题,缺页处理程序搜索区域结构的链表,把 A 和每个区域结构中的 vm_start 和 vm_end 做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。这个情况在下图中标识为 “1”。因为一个进程可以创建任意数量的新虚拟内存区域(使用在下一节中描述的 mmap 函数),所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Linux 使用某些我们没有显示出来的字段,Linux 在链表中构建了一棵树,并在这棵树上进行查找。
· 试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。这种情况在下图中标识为 “2”。
· 此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU 重新启动引起缺页的指令,这条指令将再次发送 A 到 MMU。这次,MMU 就能正常地翻译 A,而不会再产生缺页中断了。
7.9动态存储分配管理
printf会调用malloc,而mallocc为显式分配器,调用时会从堆中分配块:void *malloc(size_t size)(返回:若成功则为已分配块的指针,若出错则为 NULL);
malloc 函数返回一个指针,指向大小为至少 size 字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。实际中,对齐依赖于编译代码在 32 位模式(gcc -m32)还是 64 位模式(默认的)中运行。在 32 位模式中,malloc 返回的块的地址总是 8 的倍数。在 64 位模式中,该地址总是 16 的倍数。
如果 malloc 遇到问题(例如,程序要求的内存块比可用的虚拟内存还要大),那么它就返回 NULL,并设置 errno。malloc 不初始化它返回的内存。
7.10本章小结
本章回顾了hello程序运行时的存储管理与内存分配机制,包括它的储存器的地址空间,以及地址之间的变换过程与缺页异常处理,又从内存映射的角度重新审视进程的fork与execve函数。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个 Linux 文件就是一个 m 个字节的序列:B0,B1,⋯,Bk,⋯,Bm−1
所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:
(1)打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
(2)Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)和标准错误(描述符为 2)。头文件 <unistd.h> 定义了常量 STDIN_FILENO、STDOUT_FILENO 和 STDERR_FILENO,它们可用来代替显式的描述符值。
(3)改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置 k,初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行 seek 操作,显式地设置文件的当前位置为 k。
(4)读写文件。一个读操作就是从文件复制 n > 0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k+n。给定一个大小为 m 字节的文件,当k⩾m时执行读操作会触发一个称为 end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的 “EOF 符号”。
类似地,写操作就是从内存复制 n > 0 个字节到一个文件,从当前文件位置 k 开始,然后更新 k。
(5)关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2 简述Unix IO接口及其函数
Unix I/O接口:一个 Linux 文件就是一个 m 个字节的序列:B0,B1,⋯,Bk,⋯,Bm−1,所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O。
函数:
(1)open 函数:int open(char *filename, int flags, mode_t mode),打开一个已存在的文件或者创建一个新文件;
(2)close 函数:int close(int fd),关闭一个打开的文件;
(3)read 函数:ssize_t read(int fd, void *buf, size_t n),读文件,从描述符为 fd 的当前文件位置复制最多 n 个字节到内存位置 buf。返回值 -1 表示一个错误,而返回值 0 表示 EOF。否则,返回值表示的是实际传送的字节数量;
(4)write 函数:ssize_t write(int fd, const void *buf, size_t n),写文件,从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件位置;
(5)rio_readn 函数:ssize_t rio_readn(int fd, void *usrbuf, size_t n),
无缓冲的读文件,从描述符 fd 的当前文件位置最多传送 n 个字节到内存位置 usrbuf。在遇到 EOF 时只能返回一个不足值;
(6)rio_writen 函数:ssize_t rio_writen(int fd, void *usrbuf, size_t n),无缓冲的写文件,从位置 usrbuf 传送 n 个字节到描述符 fd。决不会返回不足值;
(7)rio_readlineb函数:ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen),从文件叩读出下一个文本行(包括结尾的换行符),将它复制到内存位置 usrbuf,并且用 NULL(零)字符来结束这个文本行。rio_readlineb 函数最多读 maxlen-1 个字节,余下的一个字符留给结尾的 NULL 字符。超过 maxlen-1 字节的文本行被截断,并用一个 NULL 字符结束。
(8)rio_readnb函数:ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n),从文件 rp 最多读 n 个字节到内存位置 usrbuf。对同一描述符,对 rio_readlineb 和 rio_readnb 的调用可以任意交叉进行。然而,对这些带缓冲的函数的调用却不应和无缓冲的 rio_readn 函数交叉使用。
8.3 printf的实现分析
下代码为printf函数的定义:
static int printf(const char *fmt, ...)- {
- va_list args;
- int i;
- va_start(args, fmt);
- write(1,printbuf,i=vsprintf(printbuf, fmt, args));
- va_end(args);
- return i;
- }
参数中采用了可变参数的定义,可变参数的一系列实现函数va函数如下:
va_list arg_ptr;
void va_start( va_list arg_ptr, prev_param );
type va_arg( va_list arg_ptr, type );
void va_end( va_list arg_ptr );
首先在函数里定义一个va_list型的变量,这里是arg_ptr,这个变量是指向参数的指针。然后使用va_start使arg_ptr指针指向prev_param的下一位,然后使用va_args取出从arg_ptr开始的type类型长度的数据,并返回这个数据,最后使用va_end结束可变参数的获取。
可变参数调用原理:
C语言中,参数压栈的方向是从右往左。也就是说,当调用printf函数的适合,先是最右边的参数入栈。fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。对于一个char *类型的变量,它入栈的是指针,而不是这个char *型变量。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar()是标准I/O标准库里的库函数,其原型是int getchar(void);
它的作用是从stdin流中读入一个字符,也就是说,如果stdin有数据的话不用输入它就可以直接读取了,第一次getchar()时,确实需要人工的输入,但是如果你输了多个字符,以后的getchar()再执行时就会直接从缓冲区中读取了。
实际上是 输入设备->内存缓冲区->程序getchar ;
键盘输入的字符都存到缓冲区内,一旦键入回车,就会发送信号到内核,进行异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数进入缓冲区读取字符,通过系统调用读取按键ascii码,一次只返回第一个字符作为getchar函数的值,直到接受到回车键才返回。
8.5本章小结
本章回顾了IO设备的基本概念与管理方法,总结了一下与IO接口有关的函数,并且从IO接口的角度,深入分析了两个基本函数printf ()和getchar ()的实现。
结论
hello所经历的过程:
- 由程序员编写的hello.c源程序经过预处理生成hello.i文本文件;
- hello.i经过编译器生成hello.s汇编文件;
- hello.s再经过汇编生成hello.o可重定位文件;
- hello.o再经过链接器生成hello可执行文件;
- hello可执行程序运行时,bash为其fork创建新进程;
- hello所在的进程与其他进程轮流运行时被分成了多个时间片,调用execve函数启动新的程序,内核调度使hello程序抢占其他正在执行的进程;
- 在hello运行时通过mmap进行内存映射,提高文件读取效率;
- hello运行时还可以通过IO接口与用户进行输入输出交互;
- 直到程序发生异常或被终止后,bash终止其进程并进行回收。
对计算机系统的设计与实现的深切感悟:
经过这个学期计算机系统的学习与实验,与这次大作业对知识点的梳理,我再一次感叹于计算机的设计精妙,它的设计远不像我上大学前理解的键盘输入屏幕输出那么简单,也不仅仅是由电路板的设计实现,而是由硬件到操作系统再到应用一层一层的实现。在之前的实验中仅IEEE浮点表示和指令集的体系结构就让我叹为观止了,更别谈后来又新学到的储存器的层次结构与进程这一伟大概念了!了解到这些,我将以更严谨更充满敬意地去对待这门课、对待这门专业,向伟大的工程师们学习、致敬!!!
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.i:预处理后的文本文件;
hello.s:编译后的汇编程序文本;
hello.o:汇编后二进制的可重定位目标程序;
hello:链接后的可执行目标程序;
dump_hello_o.txt;hello.o的反汇编程序文本;
dump_hello.txt;hello的反汇编程序文本;
elf_hello_o.txt:ELF格式的hello.o文本;
elf_hello.txt:ELF格式的hello文本;
参考文献
[1] c语言编译过程详解,预处理,编译,汇编,链接(干货满满)_木槿花better的博客-CSDN博客_c语言编译过程
[2] 深刻理解计算机系统 第九章 虚拟存储器 - JavaShuo
[3] C语言中getchar()和putchar()的实现细节_紫荆的传说的博客-CSDN博客_putchar的实现
[4] https://www.cnblogs.com/pianist/p/3315801.html
[5] 9.7 案例研究:Intel Core i7 / Linux 内存系统 - 深入理解计算机系统(CSAPP) (gitbook.io)