计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L021711
班 级 2003005
学 生 李泽华
指 导 教 师 吴 锐
计算机科学与技术学院
2021年5月
本文目的是对一个计算机程序从无到有,再到可执行的过程进行深入剖析,以全面展示一个计算机的工作过程。通过对一个c程序“hello.c”到最终其可执行文件被放入内存中运行的所有过程进行详细梳理,解释了一个c程序是如何通过预处理、编译、汇编、链接,生成执行程序的,又如何通过进程管理、存储管理、IO管理对可执行文件进行加载、执行。
关键词:计算机系统;编译;汇编;流水线;链接;进程;存储;I/O管理
目 录
第1章 概述................................................................................... - 4 -
1.1 Hello简介............................................................................ - 4 -
1.2 环境与工具........................................................................... - 4 -
1.3 中间结果............................................................................... - 4 -
1.4 本章小结............................................................................... - 4 -
第2章 预处理............................................................................... - 5 -
2.1 预处理的概念与作用........................................................... - 5 -
2.2在Ubuntu下预处理的命令................................................ - 5 -
2.3 Hello的预处理结果解析.................................................... - 5 -
2.4 本章小结............................................................................... - 5 -
第3章 编译................................................................................... - 6 -
3.1 编译的概念与作用............................................................... - 6 -
3.2 在Ubuntu下编译的命令.................................................... - 6 -
3.3 Hello的编译结果解析........................................................ - 6 -
3.4 本章小结............................................................................... - 6 -
第4章 汇编................................................................................... - 7 -
4.1 汇编的概念与作用............................................................... - 7 -
4.2 在Ubuntu下汇编的命令.................................................... - 7 -
4.3 可重定位目标elf格式........................................................ - 7 -
4.4 Hello.o的结果解析............................................................. - 7 -
4.5 本章小结............................................................................... - 7 -
第5章 链接................................................................................... - 8 -
5.1 链接的概念与作用............................................................... - 8 -
5.2 在Ubuntu下链接的命令.................................................... - 8 -
5.3 可执行目标文件hello的格式........................................... - 8 -
5.4 hello的虚拟地址空间......................................................... - 8 -
5.5 链接的重定位过程分析....................................................... - 8 -
5.6 hello的执行流程................................................................. - 8 -
5.7 Hello的动态链接分析........................................................ - 8 -
5.8 本章小结............................................................................... - 9 -
第6章 hello进程管理.......................................................... - 10 -
6.1 进程的概念与作用............................................................. - 10 -
6.2 简述壳Shell-bash的作用与处理流程........................... - 10 -
6.3 Hello的fork进程创建过程............................................ - 10 -
6.4 Hello的execve过程........................................................ - 10 -
6.5 Hello的进程执行.............................................................. - 10 -
6.6 hello的异常与信号处理................................................... - 10 -
6.7本章小结.............................................................................. - 10 -
第7章 hello的存储管理...................................................... - 11 -
7.1 hello的存储器地址空间................................................... - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理................... - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理............. - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换.................... - 11 -
7.5 三级Cache支持下的物理内存访问................................ - 11 -
7.6 hello进程fork时的内存映射......................................... - 11 -
7.7 hello进程execve时的内存映射..................................... - 11 -
7.8 缺页故障与缺页中断处理................................................. - 11 -
7.9动态存储分配管理.............................................................. - 11 -
7.10本章小结............................................................................ - 12 -
第8章 hello的IO管理....................................................... - 13 -
8.1 Linux的IO设备管理方法................................................. - 13 -
8.2 简述Unix IO接口及其函数.............................................. - 13 -
8.3 printf的实现分析.............................................................. - 13 -
8.4 getchar的实现分析.......................................................... - 13 -
8.5本章小结.............................................................................. - 13 -
参考文献....................................................................................... - 16 -
第1章 概述
1.1 Hello简介
-
- hello.c通过文本编辑器创建,按照c格式书写,其main()函数中仅输出一个“Hello world”。
- hello.c经过预处理器会进行一些文本替换,最终生成一份经过修改的文本,称为hello.i。
- hello.i经过编译器处理,其中的c语言语句会被转化成汇编语言语句以实现其功能,最终生成以汇编语句描述的文本文件hello.s。
- hello.s经过汇编器处理,会生成一个可重定位目标文件hello.o。
- hello.o和许多原本就存于系统中的目标文件xxx.o经过链接器处理生成最终的可执行文件hello(hello.out)。
- 通过命令行输入执行该程序后,shell会fork出一个子进程,再在子进程中调用execve,将hello文件映射到进程虚拟地址空间,或者用户自行调用mmap为hello进行空间映射,其中.text和.data会映射到相应的代码段。获得这些信息后进程便可自动执行。
- 程序运行结束时,父进程回收hello进程和它创建的子进程,内核删除相关数据结构。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;1.7GHz;16G RAM;512G SSD
1.2.2 软件环境
Windows10 64位; Vmware 11;Ubuntu 16.04 LTS 64位/优麒麟 64位 以上;
1.2.3 开发工具
Visual Studio 2019;CodeBlocks 64位;vi/vim/gedit+gcc;objdump;edb/gdb
1.3 中间结果
- hello.c 源程序
- hello.i 预处理后文件
- hello.s 编译后的汇编文件
- hello.o 汇编后的可重定位目标执行文件
- hello.out 链接后的可执行文件
- hello_o_disassembling hello.o的反汇编
- hello_disassembling hello的反汇编代码
1.4 本章小结
本章总体介绍了hello程序“一生”的过程,以及进行接下来会用到了软硬件工具,同时罗列了所有中间过程涉及到的文件。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。包括#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的)以及单独的 #(空指令)。
预处理的作用:根据源代码中的预处理指令修改源代码,预处理从系统的头文件包中将头文件的源码插入到目标文件中,宏和常量标识符已全部被相应的代码和值替换,最终生成xxx.i文件。
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
结果分析:经过预处理之后,hello.c变为hello.i文件,仍为文本文件,但内容变为3000多行。
根据之前的叙述,这一步是对源程序中#后面的内容进行宏展开、内容替换,因此许多原本属于别的位置的内容被大量添加进来。
2.4 本章小结
本章介绍了预处理的相关概念和作用,进行实际操作查看了hello.i文件,它通过如将定义的宏进行符号替换、引入头文件的内容、根据指令进行选择性编译等操作,对源程序进行补充和替换。
第3章 编译
3.1 编译的概念与作用
概念:编辑器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
作用:1.扫描(词法分析),2.语法分析,3.语义分析,4.源代码优化(中间语言生成),5.代码生成,目标代码优化。
- 扫描:将源代码程序输入扫描器,将源代码的字符序列分割成一系列记号。例array[index] = (index + 4) * (2 + 6);
- 语法分析:基于词法分析得到的一系列记号,生成语法树。
- 语义分析:由语义分析器完成,指示判断是否合法,并不判断对错。又分静态语义:隐含浮点型到整形的转换,会报warning,动态语义:在运行时才能确定:例1除以3
- 源代码优化:中间代码(语言)使得编译器分为前端和后端,前端产生与机器(或环境)无关的中间代码,编译器的后端将中间代码转换为目标机器代码,目的:一个前端对多个后端,适应不同平台。
- 代码生成:编译器后端主要包括:代码生成器:依赖于目标机器,依赖目标机器的不同字长,寄存器,数据类型等。目标代码优化器:选择合适的寻址方式,左移右移代替乘除,删除多余指令。
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
代码分节:在文件头部位置说明该文件的源文件是hello.c,.text说明代码节,.section.rodata说明只读数据段,.align 8说明地址对齐方式
对数据的处理:hello.c中涉及到的数据类型有整数、字符串、数组
字符串会被放置在.rodata数据段,在hello.s文件中的表示如下,文件共使用了两个字符串,由于汉字编码问题,未能清楚显示出字符串内容。
字符指针数组argv[],通过如下方式存储在栈中,三条语句的位置揭示了它是如何存储的。它每个元素是一个指向一个字符串首地址的指针,作为main函数的第二 个参数 ,argv[]开始被保存在寄存器%rsi 中,然后又被保存到栈中32 (%rbp)的位置。
整数则单纯通过保存在寄存器中进行使用,下图中变量i保存在%eax中,作为循环索引。
函数操作:call指令、函数传参、栈空间分配、ret指令
call指令引发一个指定函数的调用,从而使程序接下来执行的是call指令之后函数内的指令,在call指令调用函数之前,需要按照寄存器默认的传参顺序,将函数所需要的数据保存在相应寄存器中,超过的参数则需放入栈中进行保存。
ret指令标志函数返回,返回的参数保存在%rax寄存器中。%rbp为栈帧的底部,函数在%rbp上分配空间,而leave指令相当于mov %rbp,%rsp加上pop %rbp,恢复栈空间为调用main函数之前的状态。
函数传参有默认使用的寄存器顺序,当调用函数时,若需要传参,参数会按照函数签名中参数顺序,将需要的数据依次传入(%rdi_%rsi_%rdx_%rcx_%r8_%r9),超出六个的参数将存放在调用过程的栈帧上。
常见操作:算数运算、赋值操作、条件判断、
赋值操作相当于程序编写中的“=”,用于将等号右边的值赋值给等号左边的值,其在汇编语句中对应的是MOV指令,根据操作对象的字节大小不同,具体又分为movb、movw、movl、movq。
条件判断是程序编写常用的操作,其在hello.s的表现如下:如下语句对应的是hello.c中的i<8,通过比较存在栈中的一个4bytes的数据和7的大小,来决定是否发生跳转,如果栈中数据≤7则发生跳转。
算数操作也是程序编写常用的操作,数学上的四则运算都有汇编语句与之相对应,下图展示了hello.c中索引i的+1操作。
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.4 本章小结
本节对应于书上与汇编语言相关的章节,要介绍了编译器处理c语言程序的基本过程,分析了编译器如何处理c语言的各个数据类型和各类操作,从而将函数从源代码变为等价的汇编代码,其中几乎包含了大部分的编译机制,涵盖了高级程序语言到汇编语言的转化思想。经过该步骤 hello.s已经是更加接近机器层面的汇编代码。
第4章 汇编
4.1 汇编的概念与作用
利用汇编器将xxx.s文件内容翻译成机器语言指令,并把这些指令打包成可重定位目标程序的格式,并将结果保存在xxx.o 目标文件中,xxx.o 文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
通过如下命令可以得到.s文件
4.3 可重定位目标elf格式
可重定位文件里有许多东西可以查看,这里查看最重要的四个东西:ELF Header、Section Header Table、.symtab、.rel_text
ELF Header:程序头表
ELF Header(程序头)从大小为16Bytes的序列 Magic 开始,它描述了该文件生成所在系统的字大小和字节顺序,ELF 头剩下的部分包含帮助链接器进行语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、 字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
Section Header Table:节头部表
节头部表包含了关于如何解释文件中所有出现节的信息,包括节的类型、位置和大小。 其中,代码段属于可执行区域,但不可写;数据段和只读数据段都不可执行,只读数据段不可写,但数据段可写。
在链接时,链接器通过在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小,从而最终生成可执行文件。
.symtab:符号表
.symtab节(符号表)中存放程序中定义和引用的函数和全局变量的符号信息。其中name是符号名称;value是符号相对于目标节起始位置的偏移量,对于可执行目标文件,该值是一个绝对运行的地址;size是目标的大小;type要么是数据要么是函数;Bind字段表明符号是本地的还是全局的。
.rel_text:.text节中重定位需要的信息
重定位节包括了所有.text 节中需要进行重定位的信息,在链接器试图将该重定位文件链接成可执行目标文件的时候,会使用其中的信息对.text节中的数据进行更改,而在可执行目标文件中它因使命终结不再存在。其中各信息意义如下:
Offset:需要被修改的引用节的偏移Info:包括symbol和type两个部分,symbol在前面四个字节,type在后面四个字节,
symbol:标识被修改引用应该指向的符号,
type:重定位的类型
Type:告知链接器应该如何修改新的应用
Attend:一个有符号常数,一些重定位要使用它对被修改引用的值做偏移调整Name:重定向到的目标的名称。
4.4 Hello.o的结果解析
hello.0反汇编之后的文件hello_o_diassembling.txt同hello.s相比汇编语言的指令并没有太多不同,最大的不同在于反汇编代码所显示的除了汇编代码还有机器代码,即以二进制形式表示的机器指令,而二进制才是电脑可以真正识别的语言。
机器指令由操作码和操作数构成,而汇编语言是人们比较熟悉的词句直接表述CPU动作形成的语言,最接近CPU运行原理。所有的汇编语言都与一段二进制机器语言有着一对一的映射关系,因此可以将汇编语言转化为机器语言,其中最明显的不同的地方如下:
分支转移:反汇编的跳转指令用的是确定的地址,而非段名称,段名称仅在汇编语言中使用,是便于编写的助记符,而在汇编成机器语言之后它没有必要存在,而是替换成了对于机器识别真正有帮助的确定的地址。
函数调用:在xxx.s文件中,函数调用call指令之后紧跟着函数名,而在反汇编程序中,call的目标地址是当前下一条指令。原因是 hello.c 中调用的函数存在共享库,函数最终的地址只有在之后由链接器链接不同文件的时候才能确定,而在汇编的时候,这些函数调用即call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定。
4.5 本章小结
本章通过对hello.s汇编生成了hello.o可重定位目标文件进行了解读,展示并分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,比较了hello.s和hello.o反汇编代码的不同之处,分析了从汇编语言到机器语言的映射关系。
第5章 链接
5.1 链接的概念与作用
链接是将存于.o或.so文件的代码和数据片段整合后形成最终可执行文件的过程,这个文件可被加载(复制)到内存并执行。
链接可发生在不同的时间段。使用最基本的静态链接时,链接发生于编译时,也就是在源代码被编译成机器代码时;使用动态链接时,链接执行于加载时,也就是在程序被加载器加载到内存并执行时;使用<dlfcn.h>中的函数时,链接执行于运行时,由应用程序来进行操作。
链接的作用在于模块化和提高效率。链接的操作由一个称为链接器的程序执行的,链接器使得分离编译成为可能,因此不同的代码段可以以不同的.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的格式
1.ELF头:如下图所示hello的ELF头信息指出hello的入口地址非零,说明重定位工作已完成;此时hello的类型为EXEC,即可执行目标文件;hello ELF头中program headers的偏移量非零,它代表的是段头表的偏移量。
2.节头表:不同于hello.o,hello中每一节节头表都有了地址,这是因为经过重定向之后,它们已经获得了确定的地址,并随时可加载进内存;同时hello中节头表条目数多于hello.o中节头表的条目数,多出的节是为了实现动态链接如.interp这一节包含动态链接器的路径名,动态链接器通过执行一系列重定位工作完成链接任务。
3. 符号表:hello程序的符号表包含编号Num、Value、Size、Type、Bind、Vis、Ndx、Name字段,其功能相比于hello.o并无太大变化。
4.段头表:段头表描述了可执行目标文件的连续的片与连续的虚拟内存段之间的映射关系。从段头表中可以看到根据可执行目标文件的内容初始化为两个内存段,分别为只读内存段(代码段)和读写代码段(数据段)。
5.4 hello的虚拟地址空间
通过查看0x401000~0x402000段中,可知程序从此开始运行,,到0x401ff0结束。
通过对hello的反汇编,发现该401000应该是.init节用于初始化这一个可执行文件的地方。
5.5 链接的重定位过程分析
通过命令objdump -d -r hello > hello_disassembling.txt得到hello文件的反汇编代码。
通过对比hello_o_disassembling.txt和hello_disassembling.txt中两个文件反汇编代码段不同可以发现:
1.虚拟地址不同:hello.o的反汇编代码的虚拟地址从0开始,而hello的反汇编代码从0x401000开始。这是因为hello.o还未实现重定位的过程,每个符号还没有确定的地址,而hello已经实现了重定位,每个符号都有其确定的地址,因此反汇编之后将其虚拟内存地址显示了出来。
2.函数数目不同:hello中除了main函数的汇编代码,还有很多其它函数的汇编代码。这是因为经过链接之后,一些其它.o文件或者动态链接库文件的东西被加入到可执行程序中。
3.函数跳转指令后地址不同:
从如下两幅图中显示的图片可以看出,callq指令后的机器指令不同,这是因为在经过重定位之后,可执行程序已经可以获得确定的跳转位置了,根据相关的地址计算算法,可以获得一个偏移量以跳转到函数相应位置,因此机器码不再全为0而是一个和目标函数位置密切相关的偏移量。
hello的重定位过程:
在汇编器生成一个目标模块时,由于并不知道数据和代码最终将放在内存中的什么位置。所以,当汇编器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。具体过程如下:
(1)重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
(2)重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
(3)重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。代码的重定位条目放在.rel.txt和.rel.data中
5.6 hello的执行流程
使用edb执行hello程序,同时参数输入为120L021711 李泽华 1
通过edb检测执行过程,获得所执行程序名称与地址,具体信息如下:
5.7 Hello的动态链接分析
动态链接的基本思想:
把程序按照模块拆分成各个相对独立部分,链接在在程序运行时进行。
与静态链接把所有程序模块都链接成一个单独的可执行文件不同,动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时仍需要用到动态链接库。如在对静态库进行链接形成可执行程序时,对一个符号进行判断时,通过对动态链接库的检查发现这个符号是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,采用的方法是为该引用生成一条重定位记录,之后动态链接器在程序加载的时候再对其进行解析。
延迟绑定:GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。它是通过GOT和PLT实现的,其中GOT是数据段的一部分,而PLT是代码段的一部分。两表内容如下:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其elf表示如下图所示,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
如下图所示,通过比对dl_init调用前后的内容变化可以看出,第二、三行的两个8字节的数据都发生了改变。
和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址, GOT[2]是动态链接器ld-linux.so模块中的入口点。
5.8 本章小结
本章主要介绍了链接的概念和作用,详细介绍了hello.o是如何链接生成一个可执行文件的。同时展示了可执行文件中不同节的内容。最后分析了程序是如何实现的动态链接的。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个执行中程序的实例,每一个进程都有它自己的地址空间,一般情况下,它的空间包括文本区域、数据区域、和堆栈。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
作用:它提供了一个假象,当前正在调用的程序好像是系统中当前运行的唯一程序,仿佛当前的程序是在独占地使用处理器和内存。处理器像是在无间断地执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell是一个交互型的应用级程序,它为使用者提供了操作界面的功能(命令解析器)。它接受用户命令,然后调用相应的应用程序。Linux系统中所有的可执行文件都可以作为Shell命令来执行。
处理流程:首先对用户输入的命令进行解析,判断命令是否为内置命令,如果为内置命令,调用内置命令处理函数;如果不是内置命令,就创建一个子进程,将程序在该子进程的上下文中运行。判断为前台程序还是后台程序,如果是前台程序则直接执行并等待执行结束,如果是后台程序则将其放入后台并返回。同时Shell对键盘输入的信号和其他信号有特定的处理。
- 终端进程读取用户由键盘输入的命令行
- 处理函数分析命令行字符串,将命令行分解成若干命令行参数,并构造传递给execve的argv向量
- 检查第一个命令行参数argv[0]是否是一个内置的shell命令
- 如果不是内部命令,调用fork( )创建新进程/子进程
- 在子进程中,通过使用之前获得的参数,调用execve( )执行指定程序
- 如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait…等待作业终止后返回。
- 若用户要求后台运行(如果命令末尾有&号),则shell返回
6.3 Hello的fork进程创建过程
在6.2中提到使用fork创建进程,其具体创建过程如下:
- 首先判断hello不是一个内置的shell指令,所以调用应用程序,找到当前所在目录下的可执行文件hello
- 接下来Shell会调用fork函数为父进程创建一个新的子进程,子进程会得到与父进程虚拟地址空间相同的一段数据结构的副本(包括代码和数据段,堆,共享库和用户栈)。父进程与子进程最大的不同在于他们分别拥有不同的PID,在子进程中程序运行的这个过程中,父进程在原位置等待着程序的运行完毕。
6.4 Hello的execve过程
在执行fork得到子进程后随即使用解析后的命令行参数调用execve函数,execve调用启动加载器来执行hello程序。
加载器执行的操作是,删除子进程现有的虚拟内存段,并创建新的代码、数据、堆和栈段。代码和数据段被初始化为hello的代码和数据。堆和栈被置空。然后加载器将PC指向hello程序的起始位置,即从下条指令开始执行hello程序。
6.5 Hello的进程执行
在hello进程的执行过程的描述中,涉及到许多术语,具体如下:
逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:shell使得用户可以有机会修改内核,所以需要设置一些防护措施来保护内核,如限制指令的类型和可以作用的范围。
上下文切换:上下文就是内核重新启动一个被抢占的进程所需要的状态,是一种比较高层次的异常控制流。
hello运行过程:在hello进程执行时,先调用execve函数之后,进程会为hello分配虚拟地址空间,为.text节和.data节分配代码区和数据区。一开始hello运行在用户模式下,根据指令进行屏幕显示输出,然后hello就调用了sleep函数,进程进入内核模式,内核会请求释放当前进程,将hello进程移出运行队列加入等待队列,这时计时器开始计时,内核也进行上下文切换将当前进程的控制权交给其他进程。根据我们选择的时间,时间到后,会发送一个中断信号,此时又进入内核状态执行中断处理,将hello进程重新加入运行队列,hello就继续执行自己的控制逻辑流。hello的运行过程实际上被切分成时间片,与其他进程交替占用cpu,以实现进程的调度。
6.6 hello的异常与信号处理
在hello执行的过程中,可能经历下列异常:
中断:中断是异步发生的,是来自处理器外部的I/O设备的信号导致的异常。
陷阱:陷阱是有意的异常,是执行一条指令的结果。
故障:故障是由错误情况引起,可能可以被故障处理程序修复的异常,比如第一次访问内存时一定会发生缺页,这是后就调用缺页处理程序。
终止:终止是不可恢复的错误导致的,通常是一些硬件错误,比如DRAM或者SRAM位损坏的奇偶错误。
当出现异常之后,系统会根据异常种类发出信号,每个信号有其对应序列
而在hello程序运行过程中,各种异常可以通过下列方式导致:按键盘,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令。
1. 正常运行:
如果乱按的过程中包括回车,那么乱按的内容将会在该程序结束之后作为命令输入。
2. Ctrl+Z终止:
在键盘下按下Ctrl-Z之后,会导致内核发送一个SIGTSTP信号到前台进程组的每个进程,默认情况下结果是停止(挂起)前台作业。
3. Ctrl+C暂停,在此可以输入各种指令查看当前进程:
在键盘上按下Ctrl-C之后,会导致内核发送一个SIGINT信号到前台进程组中的每个进程,默认情况下结果是终止前台作业。
6.7本章小结
本章介绍了有关进程管理的多个概念。介绍了Shell的作用和处理流程,以及利用fork创建子进程、利用execve加载进程的方法。展示hello程序执行的具体过程,以及异常信号的处理机制。
第7章 hello的存储管理
7.1 hello的存储器地址空间
虚拟地址→线性地址→物理地址
- 逻辑地址:一个相对于程序入口的地址偏移量。
程序员视角:在编写代码时,进行内存引用或开辟空间总要为变量分配一个实际的内存地址,但这个地址在还未执行时根本无法确定,但地址总是会分配的,而分配之后相对于整个代码块的首地址偏移量则是可以确定地,因此这个偏移量则可理解成程序员视角的逻辑地址。
CPU视角:编写程序最终目的是生成一个可执行文件,交由计算机执行,计算机执行文件会有一个入口,所有的数据在完全加载进内存之前无法确定地址,但入口是确定的,因此逻辑地址相当于一个对于程序入口偏移量。
可执行文件在执行时,相当于由内核调用了一个fork()函数打开一个新的进程,硬件上来看该可执行文件的内容会从磁盘加载到内存(有可能并没完全加载进去,但肯定还有别的东西管着它),其内存管理由系统进行,此时CPU视角该文件的起始地址不为0,使用之前的逻辑地址才能在硬件层面获得数据的虚拟地址(物理地址)。
- 线性地址:程序入口地址+地址偏移量。
线性地址伴随着Intel的X86体系结构的发展而来。当32位CPU出现的时,寻址范围达到4GB,相对于内存大小来说,这是一个相当巨大的数字,当时一般不会用到这么大的内存。那么这个时候CPU可见的(可识别的)4GB空间和内存的实际容量产生了差距。而线性地址就是用于描述CPU可见的这4GB空间。
线性地址可看作一个非负整数集合,其整数是连续的,其中的每一个整数对应着CPU可识别的1Byte大小的空间。
- 虚拟地址:一种线性地址空间,和物理地址存在一一映射关系,但无直接联系。
虚拟内存的可寻址范围一般远大于它的实际内存大小,不同的进程具有不同的虚拟内存,每个虚拟内存通过某种方法与物理地址一一对应。
它是为实现系统对各程序进行形式统一内存管理的一种机制,其主要功能有缓存、内存管理、内存保护。
- 物理地址:
在CPU外部地址总线上最终寻址时使用的地址,对应某一具体硬件。虚拟内存通过多级页表可最终寻找到其对应的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
Linux系统下,线性地址到物理地址的变换通过分页机制完成。其抽象机制主要有分页机制、地址翻译,涉及的主要硬件有CPU、MMU、内存。
分页机制:分页机制下,CPU将物理内存以4KB为大小看作一页,寻址和数据传输都以页为单位进行,在页表下,一个页表项映射到的物理内存为1页,其字节寻址由偏移量完成。
地址翻译:CPU视角下的地址是一个虚拟地址,一个虚拟地址(VA)=VPN|VPO
在页表机制下,一个虚拟地址翻译成物理地址需要先由VPN获得其物理地址PPN,然后和原有的VPO拼接而成,即VA→PA:VPN|VPO→f(VPN)|VPO
7.4 TLB与四级页表支持下的VA到PA的变换
TLB产生原因:每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。翻译后备缓存器(TLB)因此而生。而且由于内存变大,即使是一个常见的512G的内存,若仅以单页PTE组织其大小也超过1G。
TLB分级机制:其组织结构类似于树状结构,在四级页表结构中,(1~3级)一页大小的页表对应着512个次级页表,(4级)最后层的页表对应着512个PPN(用来和VPO组成最终物理地址的东西)。
处理器生成一个虚拟地址,并将其传送给MMU,MMU用VPN向TLB请求对应的PTE,如果命中,则跳过之后的几步。MMU生成PTE地址(PTEA).,并从高速缓存/主存请求得到PTE。如果请求不成功,MMU向主存请求PTE,高速缓存/主存向MMU返回PTE。PTE的有效位为零, 因此 MMU触发缺页异常,缺页处理程序确定物理内存中的牺牲页 (若页面被修改,则换出到磁盘——写回策略)。缺页处理程序调入新的页面,并更新内存中的PTE。缺页处理程序返回到原来进程,再次执行导致缺页的指令。
在多级页表的情况下,无非就是不断通过索引-地址-索引-地址重复四次进行寻找,当当某一步发生不命中就会根据一定策略将某一页表以及之后的所有页表替换掉
7.5 三级Cache支持下的物理内存访问
物理地址(PA) = CT|CI|CO,其中CT为标记,CI为组索引,CO为偏移量。
获得物理地址之后,进行如下匹配操作:
- 取出组索引(CI),在L1中寻找对应组
- 若存在,则比较标志位
- 若相等则检查有效位是否为
若在上面三步均未命中:
- 按顺序对L2cache、L3cache、内存进行相同操作,直到出现命中
- 一级级向上传,如果有空闲块则将目标块放置到空闲块中,否则将缓存中的某个块驱逐,将目标块放到被驱逐块的位置。
7.6 hello进程fork时的内存映射
我们知道计算机在启动时,可以理解成进程0,fork出了一个子进程1,而其余所有的进程都为进程1的子进程。
进程虚拟地址空间
进程由内核管理,而虚拟内存以及页表也由内核管理,一个进程虚拟地址空间中,可划分出内核虚拟内存和进程虚拟内存,下图是一个重要的数据结构,它保存于内核虚拟内存(对应的物理内存)中,对于内核管理进程虚拟内存有重大作用。
mm是task_struct块的一个指针,指向mm_struct块,其中pgd为一级页表基址,mmap指向一个区域链表vm_area_structs,其中链表的每一个Node标记着一个代码块的起点、重点、读写许可权限、页面状态(共享or私有)、下一区域地址。
父进程fork子进程的过程:
父进程在fork子进程的时候,首先会将整个进程的虚拟内存复制一份,包括内核虚拟内存和进程虚拟内存,也就包括了mm_struct、区域结构和页表的原样副本,它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任意一个后来进行写操作时,写时复制机制就会创建新页面。
7.7 hello进程execve时的内存映射
execve函数加载过程中发生的内存映射的步骤如下:
- 在fork一个新的子进程之后,execve加载新程序之后会先删除已有的页表和vm_area_struct链表,重新创建vm_area_struct链表
- 其中代码和初始化数据通过文件映射,映射到.text和.data区
- .bss和栈会映射到匿名文件,即只分配虚拟地址,页表中并不会最终找到与之对应的物理地址。
- 由于程序编写难免使用共享库,因此会对使用的内容进行动态链接,共享文件映射到进程的共享库内存部分
- 最终设置程序计数器(PC),将当前进程上下文中的PC计数器内容,指向代码区域入口点,以便在内核切换进程时,通过该内容启动该进程。
7.8 缺页故障与缺页中断处理
缺页异常分为三类:
- 虚拟地址不合法:缺页处理程序就会触发一个段错误,进而终止这个进程
- 进程进行非法操作:通过内核维护的一个结构体,我们可以很容易地判断一个虚拟地址是否合法,其非法操作由内核判定,缺页处理程序触发一个保护异常,终止进程
- 合法地址进行合法操作造成缺页:其步骤如下
- 处理器生成一个虚拟地址,并将其传送给MMU
- MMU生成PTE地址(PTEA),并从高速缓存/主存请求得到PTE3)高速缓存/主存向MMU返回PTE
- PTE的有效位为零,因此 MMU触发缺页异常(此时判断除了缺页)
- 缺页处理程序确定物理内存中的牺牲页(若页面被修改,则换出到磁盘――写回策略)
- 缺页处理程序调入新的页面,并更新内存中的PTE
- 缺页处理程序返回到原来进程,再次执行导致缺页的指令
7.9动态存储分配管理
堆是动态内存分配器维护着一个进程的虚拟内存区域。堆在各系统上的具体实现不同,但都可抽象的看作是一个东西,它是一个请求二进制零的区域,紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量 brk指向堆的顶部。
动态内存分配器将堆视为一组大小不同的块的集合进行维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块
显式分配器(explicit allocator):
C标准库提供一种叫做malloc程序包的显式分配器,它要求应用显式地释放任何已分配的块。在C程序中,通过调用malloc函数来分配一个块,通过调用free函数来释放一个块。
隐式分配器(implicit allocator):
它要求分配器检测一个已分配块何时不再被程序所使用,当检测该块之后便将其回收。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。
块由一个字的头部、有效载荷以及可能的一些额外的填充组成。头部编码了这个块的大小,并标识了这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
显示空闲链表:
一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和 succ(后继)指针。
7.10本章小结
本小节讨论了存储空间的管理,首先解释了四种地址概念:逻辑地址、线性地址、虚拟地址、物理地址,接着描述了逻辑地址到线性地址再到物理地址的转换过程。再用Intel中的TLB及四级页表实例,描述了VA到PA的转换过程,同时说明了如何使用物理内存访问cache。最后将进程使用到的fork和execve的内存映射情况加以描述。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
Linux系统对所有的I/O设备都进行了一个抽象,将其模型化为一个文件,包括内核也被映射为一个文件。
设备管理:unix io接口
这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
我们可以对文件的操作有:
1.打开关闭操作open()和close()
2.读写操作read()和write()
3.改变当前文件位置lseek()——指示文件要读写位置的偏移量
8.2 简述Unix IO接口及其函数
Unix IO:一个简单、低级的应用接口,用于应用和文件之间的输入输出。由于Linux将所有设备都模型化为了文件,因此所有的输入和输出都被当作对相应文件的读和写来执行。
打开文件:来自应用程序的请求,通过内核要求打开相应文件。内核会返回一个非负整数的文件描述符,来对该文件进行标识。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准错误(描述符为2)。头文件<unistd.h>定义了宏常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,可用来代替显式的描述符值。
改变当前的文件位置:对于每个打开的文件,内核需要有一个量来记录其打开位置,通过文件偏移量来表示,应用程序通过seek操作,可设置文件的当前位置为k。
读写文件:读操作会从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作会从内存复制n个字节到文件,当前文件位置为k,然后更新k
关闭文件:当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中。
8.3 printf的实现分析
printf函数声明传入参数是一个字符串,之后的token…表明参数未知。
vsprintf函数返回值代表字符串长度,它接受确定输出格式的格式字符串fmt。用格式字符串对个数不确定的参数进行格式化,产生格式化输出。
系统函数write(buf, i),将buf中将长度为i的字节输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址。在函数实现最后的int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数
syscall函数通过总线将字符串的数据传入到显存,以字符的ASCII码表示,接着驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。于是我们的打印字符串得以打印读取了输入的字符串。
8.4 getchar的实现分析
1.等待用户按键:在调用getchar函数后,进程会将控制权移交给OS,待用户按下按键,输入的内容便会显示在屏幕上。按下回车键表示输入完成,这时控制权被交还给进程。
2.异步异常-键盘中断:来自键盘的终端会引起一个异步异常,内核调用键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
3.系统函数调用:getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux的IO设备管理方法,Unix IO接口及其函数,分析了如何实现printf和getchar函数,对文件的操作有了更深入的理解。
(第8章1分)
结论
hello程序简单的一生波澜不惊却能让我们透过它看见计算机系统深层的运作方式,它的一生总结如下:
hello的诞生:
- 通过文本编辑器/IDE按照C语言规则编写得到hello.c源程序
- hello.c源程序经过预处理得到hello.i
- hello.i经过编译得到汇编文件hello.s
- hello.s经过汇编得到可重定位目标文件hello.o
- hello.o经过链接得到可执行文件hello
hello的人生经历:
- 通过shell输入命令 ./hello 120L021711 李泽华 1 运行hello程序;shell调用fork函数创建子进程,并调用execve函数加载运行hello程序,在它的人生中会经历不同的操作:
- 逻辑控制流:cpu为其分配时间片,在其时间片里,hello顺序执行自己的逻辑控制流
- 内存访问:hello执行过程中,当然少不了访问内存,当进程请求一个虚拟地址时,由MMU转化为物理地址,通过cache来访问
- 函数调用:运行过程中,同时也会调用一些函数,例如printf函数,这些函数与linux I/O的设备模块化成文件密切相关
- 信号处理:运行过程中,还会遇到各种各样的信号,shell为其准备了各种的信号处理程序
hello的谢幕:hello程序以被父进程回收而结束,内核会收回它的所有信息,从此hello短暂的一生迎来短暂谢幕,当然如果程序还需要,它又会卷土重来。
感悟:本文仅仅是对hello程序简简单单的一生进行简单梳理便所耗篇幅尤甚,它的背后更是设计者庞大而缜密的设计实现和深刻思考。它的顺利执行背后一个个的步骤是环环相扣的设计和缜密的规则,也正是通过这一视角,在梳理的同时,我也通过这一视角加深了对计算机系统的理解。
附件
- hello.c 源程序
- hello.i 预处理后文件
- hello.s 编译后的汇编文件
- hello.o 汇编后的可重定位目标执行文件
- hello.out 链接后的可执行文件
- hello_o_disassembling hello.o的反汇编代码
- hello_disassembling hello的反汇编代码
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1](46条消息) C语言的预处理详解_绘夜的博客-CSDN博客_c预处理
[2]认真分析mmap:是什么 为什么 怎么用 - 胡潇 - 博客园 (cnblogs.com)
[3](46条消息) gcc--编译的四大过程及作用_shiyongraow的博客-CSDN博客_gcc编译的作用
[4](46条消息) linux 文件描述符表 打开文件表 inode vnode_辉仔的博客-CSDN博客
[5] [转]printf 函数实现的深入剖析 - Pianistx - 博客园 (cnblogs.com)
[6] Randal E. Bryant, David R. O'Hallaon. 深入理解计算机系统. 第三版. 北京市:机械工业出版社[M]. 2018-1-737
[7] Stallings.计算机组成与体系结构:性能设计(原书第8版). 北京:机械工业出版社,2011.