计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能
学 号 2022111376
班 级 2203602
学 生 武靖涛
指 导 教 师 吴锐
计算机科学与技术学院
2024年5月
本文通过追踪hello小程序在Linux系统的一生,探讨hello程序从hello.c经过预处理、编译、汇编、链接生成可执行文件,并由操作系统进行进程管理、存储管理和I/O管理的全过程。以此将CSAPP课程中的内容进行全面地总结和梳理,加深对计算机系统的理解。
关键词: 计算机系统;Hello程序;Ubuntu;Linux;预处理;编译;汇编,链接;进程;shell;存储;虚拟内存;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的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:首先,Hello的开始是一段储存在磁盘上的程序文本(Program),在需要使用这一个代码文件的时候,用预处理器处理hello.c文件,生成一个hello.i文件,也就是修改了的源程序,之后,hello.i输入编译器,编译器将生成一个hello.s文件,在这一步之前所有的文件都还是文本形式,还没有转换为二进制机器码格式。生成的hello.s文件将输入汇编器,产生一个hello.o,也就是可重定位程序,可重定位文件经过链接器的链接将生成可执行目标程序hello,此时在shell中调用相关命令将为其创建进程(Process),执行程序。
O2O:在shell中输入相关命令后,shell将调用fork函数为这一程序创建进程,之后将通过exceve在进程的上下文中加载并运行hello,将进程映射到虚拟内存空间,并加载需要的物理内存。执行时,在CPU的分配下,指令进入CPU流水线执行。当执行结束后父进程将回收这一进程,内核将清除这一进程的相关信息,这一进程就结束了。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows7 64 位以上;VirtualBox/Vmware 11 以上;Ubuntu 16.04 LTS 64 位/ 优麒麟 64位 以上
开发工具: gcc,as,ld,vim,edb,readelf,gedit,gdb
1.3 中间结果
中间结果文件 | 文件作用 |
hello.i | hello.c预处理得到的文本文件 |
hello.s | hello.i编译后的汇编文件 |
hello.o | hello.s汇编得到的可重定位目标文件 |
hello | 链接得到的可执行目标文件 |
hello.out | hello反汇编之后的可重定位文件 |
1.4 本章小结
这一章主要对hello在执行的过程中的总体流程进行了简要的概述,以及在实验中使用的软硬件条件以及实验中产生的文件。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器(cpp)根据以字符#开头的命令,修改原始的c程序。
作用:
1.处理头文件:比如hello.c的第一行的#include<stdio.h>命令告诉预处理器读取系统有文件stdio.h的内容,并把它直接插入程序文本中。
2.处理宏定义:对于#define指令,进行宏替换,对于代码中所有使用宏定义的地方使用符号表示的实际值替换定义的符号
3.处理条件编译:根据可能存在的#ifdef来确定程序需要执行的代码段。
4.处理特殊符号:例如#error等,预编译程序可以识别一些特殊的符号,并在后续过程中进行合适的替换。
5.删除c语言源程序中的注释部分。
2.2在Ubuntu下预处理的命令
cpp hello.c > hello.i
2.3 Hello的预处理结果解析
相对于源程序,hello.i中的代码量增加。原来的C代码在文本的最末端。在这之前是hello引用的所有的头文件stdio.h, unistd.h , stdlib.h内容的展开。我们可以发现插入的部分不止有这三个头文件的内容,还出现了其他的头文件,这是因为这三个头文件中同样使用#include命令引入了其他的头文件,这些头文件同样出现在了hello.i文件中。插入的库文件的具体信息如下图所示:
2.4 本章小结
这一部分介绍了在预处理过程中预处理器的工作(头文件展开,宏替换,删除注释,条件替换等),同时使用ubuntu系统展示了对于hello.c文件的预处理过程与预处理结果。
第3章 编译
3.1 编译的概念与作用
概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.i,它包含一个汇编语言程序。
作用:编译的主要作用可以分为如下几个部分:
- 扫描(词义分析):将源代码程序输入扫描器,将源代码中的字符序列分割为一系列c语言中的符合语法要求的字符单元,这一部分可以分为自上而下的分析和自下而上的分析两种方式。
- 语法分析:基于词法分析得到的字符单元生成语法分析树。
- 语义分析:在语法分析完成之后由语义分析妻进行语义分析,主要就是为了判断指令是否是合法的c语言指令,这一部分也可以叫做静态语义分析,并不判断一些在执行时可能出现的错误,例如如果不存在IDE优化,这一步对于1/0这种只有在动态类型检查的时候才会发现的错误,代码将不会报错。
- 中间代码:中间代码的作用是可使使得编译程序的逻辑更加明确,主要是为了下一步代码优化的时候优化的效果更好。
- 代码优化:根据用户指定的不同优化等级对代码进行安全的、等价的优化,这一行为的目的主要是为了提升代码在执行时的性能。
- 生成代码:生成是编译的最后一个阶段。在经过上面的所有过程后,在这一过程中将会生成一个汇编语言代码文件,也就是我们最后得到的hello.s文件,这一文件中的源代码将以汇编语言的格式呈现。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1汇编指令的介绍
.file:声明源文件
.text:代码节
.section:
.rodata:只读代码段
.align:数据或者指令的地址对其方式
.string:声明一个字符串(.LC0,.LC1)
.global:声明全局变量(main)
.type:声明一个符号是数据类型还是函数类型
3.3.2数据
1.字符串常量:
可以发现在printf等函数中使用的字符串常量是储存在.rotate段的,具体储存情况可以见如下截图:
2.变量(全局/局部/静态):
无全局与静态变量。
局部变量:
局部变量通常使用栈指针(%rsp)和基址指针(%rbp)进行访问。在此段汇编代码中,可以看到通过基址指针(%rbp)来访问局部变量。
-20(%rbp):此偏移量用于存储从main函数参数argc传入的值。汇编代码中通过movl %edi, -20(%rbp)存储参数argc的值。-32(%rbp):此偏移量用于存储从main函数参数argv传入的值。汇编代码中通过movq %rsi, -32(%rbp)存储参数argv的值。
-4(%rbp):存储局部变量i,用于在循环中计数。汇编代码中通过movl $0, -4(%rbp)初始化该变量。
通过addl $1, -4(%rbp)递增该变量。
3.main函数
参数 argc 作为用户传给main的参数。也是被放到了堆栈中。
4.各种立即数
立即数直接体现在汇编代码中
5.数组:char *argv[]
hello.c中唯一的数组是作为main函数的第二个参数,数组的每个元素都是一个指向字符类型的指针。数组的起始地址存放在栈中-32(%rbp)的位置,被两次调用找参数传给printf
3.3.3全局函数
由hello.c可知,hello.c声明了一个全局函数int main(int argc,char *argv[]),经过编译之后,main函数中使用的字符串常量也被存放在数据区。
3.3.4赋值操作
程序中的赋值操作主要有:i=0这条赋值操作在汇编代码主要使用mov指令来实现,而根据数据的类型又有好几种不一样的后缀
movb:一个字节
movw:两个字节
movl:四个字节
movq:八个字节
3.3.5算数操作
hello.c中的算数操作有:i++,由于是i是int类型的,因此汇编代码只用addl就能实现
3.3.6控制转移
若i不满足小于等于9,则跳出循环(leave)
3.3.7函数操作
调用函数时有以下操作:(假设函数P调用函数Q)
(1)传递控制:进行过程 Q 的时候,程序计数器必须设置为 Q 的代码的起始 地址,然后在返回时,要把程序计数器设置为 P 中调用 Q 后面那条指令的 地址。
(2)传递数据:P 必须能够向 Q 提供一个或多个参数,Q 必须能够向 P 中返回 一个值。
(3) 分配和释放内存:在开始时,Q 可能需要为局部变量分配空间,而在返回 前,又必须释放这些空间。
hello.C涉及的函数操作有:
main函数,printf,exit,sleep ,getchar函数
main函数的参数是argc和argv;两次printf函数的参数恰好是那两个字符串
exit参数是1,sleep函数参数是atoi(argv[3])
函数的返回值存储在%eax寄存器中。
3.4 本章小结
本章hello.i -> hello.s,直观地看到了编译的结果,并将起与C源程序的代码结合起来,理解汇编语言发挥的作用,以过往的实验经历,也可以很熟练地将汇编代码与对应的C语言代码对照。
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
gcc hello.s -c -o hello.o
4.3 可重定位目标elf格式
(1) ELF Header:用命令:readelf -h hello.o,如图4.3.1ELF Header
ELF Header:以 16B 的序列 Magic 开始,Magic 描述了生成该文件的系统 的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解 ## 标题释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、 字节头部表(section header table)的文件偏移,以及节头部表中条目的大 小和数量等信息。根据头文件的信息,可以知道该文件是可重定位目标文件,有14个节。
(2) Section Headers:命令:readelf -S hello.o
Section Headers:节头部表,包含了文件中出现的各个节的语义,包括节 的类型、位置和大小等信息。 由于是可重定位目标文件,所以每个节都从0开始,用于重定位。在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小,同时可以观察到,代码是可执行的,但是不能写;数据段和只读数据段都不可执行,而且只读数据段也不可写。
(3)查看符号表.symtab :命令readelf -s hello.o
.symtab: 存放程序中定义和引用的函数和全局变量的信息。name是符号名称,对于可冲定位目标模块,value是符号相对于目标节的起始位置偏移,对于可执行目标文件,该值是一个绝对运行的地址。size是目标的大小,type要么是数据要么是函数。Bind字段表明符号是本地的还是全局的。
(4)重定位节.rela.text
重定位节:一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
重定位节.rela.text中各项符号的信息:
Offset:需要被修改的引用节的偏移Info:包括symbol和type两个部分,symbol在前面四个字节,type在后面四个字节,
symbol:标识被修改引用应该指向的符号,
type:重定位的类型
Type:告知链接器应该如何修改新的应用
Attend:一个有符号常数,一些重定位要使用它对被修改引用的值做偏移调整Name:重定向到的目标的名称。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
每行代码末尾指令基本相同,但在每条指令前面都会有一串十六进制的编码。hello.s是由汇编语言组成的,相对于计算机能识别的机器级指令,汇编代码仍是抽象语言;而反汇编得到的代码不仅仅有汇编代码,还有机器语言代码。机器语言代码是计算机可识别执行的,是一种纯粹的二进制编码。机器指令由操作码和操作数构成,汇编语言是人们比较熟悉的词句直接表述CPU动作形成的语言,是最接近CPU运行原理的语言。每一条汇编语言操作码都可以用机器二进制数据来表示,进而可以将所有的汇编语言(操作码和操作数)和二进制机器语言建立一一映射的关系,因此可以将汇编语言转化为机器语言,通过对机器代码的分析可以看出一下不同的地方。
(1)分支转移:反汇编的跳转指令用的不是段名称比如.L3,二是用的确定的地址,因为,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
(2)函数调用:在.s 文件中,函数调用之后直接跟着函数名称,而在反汇编程 序中,call的目标地址是当前下一条指令。这是因为 hello.c 中调用的函数 都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执 行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定。
4.5 本章小结
本章对hello.s进行了汇编,生成了hello.o可重定位目标文件,并且分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,比较了hello.s和hello.o反汇编代码的不同之处,分析了从汇编语言到机器语言的一一映射关系。
第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.so /usr/lib/x86_64-linux-gnu/crtn.o
5.3 可执行目标文件hello的格式
(1)ELF Header:hello的文件头和hello.o文件头的不同之处如下图标记所示,Type类型为EXEC表明hello是一个可执行目标文件,有27个节
(2)节头部表Section Headers:Section Headers 对 hello中所有的节信息进行了声明,其 中包括大小 Size 以及在程序中的偏移量 Offset,因此根据 Section Headers 中的信息我们就可以用 HexEdit 定位各个节所占的区间(起始位置,大小)。其中 Address 是程序被载入到虚拟地址的起始地址。
(3)重定位节.rela.text:
(4) 符号表.symtab
5.4 hello的虚拟地址空间
通过查看edb,看出hello的虚拟地址空间开始于0x401000,结束与0x402000
每一个表项提供了各段在虚拟地址空间和物理地址空间的各方面的信息。在下面可以看出,程序包含PHDR,INTERP,LOAD ,DYNAMIC,NOTE ,GNU_STACK几个部分,PHDR 保存程序头表。INTERP 指定在程序已经从可执行文件映射到内存之后,必须调用的解释器。LOAD 表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等。DYNAMIC 保存了由动态链接器使用的信息。NOTE 保存辅助信息。GNU_STACK:权限标志,用于标志栈是否是可执行。与5.3对照,我们可以根据5.3中每一节对应的起始地址在edb中找到响应信息。
5.5 链接的重定位过程分析
使用命令:objdump -d -r hello >hello.out,获得hello的反汇编代码.
通过分析hello与hello.o的不同,说明链接的过程。可以发现以下不同的地方:(1)hello反汇编的代码有确定的虚拟地址,也就是说已经完成了重定位,而hello.o反汇编代码中代码的虚拟地址均为0,未完成可重定位的过程.
(2)hello反汇编的代码中多了很多的节以及很多函数的汇编代码,这些节都具有一定的功能和含义。
hello重定位的过程:
(1)重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
(2)重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
(3)重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。代码的重定位条目放在.rel.txt.
5.6 hello的执行流程
在edb中找加载hello可执行文件,列出子程序名及地址:
子程序名 | 程序地址 |
ld -2.33.so!_dl_start | 0x7f641a388df0 |
ld-2.33.so!_dl_init | 0x7f641a398c10 |
hello!_start | 0x4010b0 |
libc-2.33so!__libc_start_main | 0x7f6fe58bd550 |
hello!printf@plt | 0x4010c0 |
hello!sleep@plt | 0x4010f0 |
hello!getchar@plt | 0x4010d0 |
libc-2.33.so!exit | 0x7f6fe58b40d0 |
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
5.8 本章小结
在本章中主要介绍了链接的概念与作用,并且详细阐述了hello.o是怎么链接成为一个可执行目标文件的过程,详细介绍了hello.o的ELF格式和各个节的含义,并且分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情 况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数 据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动 过程调用的指令和本地变量
作用:进程为用户提供了以下假象:
(1) 我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存。
(2) 处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Linux系统中,Shell是一个交互型应用级程序,代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程)。
其基本功能是解释并运行用户的指令,重复如下处理过程:
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
(3)检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令
(4)如果不是内部命令,调用fork( )创建新进程/子进程
(5)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
(6)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait…等待作业终止后返回。
(7)如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程
终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的 逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
以我们的hello为例,当我们输入 ./hello 2022111376 武靖涛 100000 的时候,首先shell对我们输入的命令进行解析,由于我们输入的命令不是一个内置的shell命令,因此shell会调用fork()创建一个子进程。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程虚拟地址空间相同的但是独立的一份副本,子进程获得与父进程任何打开文件描述符相同的副本,最大区别是子进程有不同于父进程的PID;
当我们运行hello程序时,在shell中输入./hello,此时OS就会fork创建一个子进程来运行这一程序。
6.4 Hello的execve过程
当创建了一个子进程之后,子进程调用exceve函数在当前子进程的上下文加载并运行一个新的程序即hello程序,加载并运行需要以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
(2)映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。如图6.4
(3)映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
(4)设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据 复制。直到 CPU 引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用 它的页面调度机制自动将页面从磁盘传送到内存。
6.5 Hello的进程执行
进程提供给应用程序的抽象:
(1) 一个独立的逻辑控制流,它提供一个假象,好像我们的进程独占的使用处理器
(2) 一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用CPU内存。
hello进程的执行是依赖于进程所提供的抽象的基础上,下面阐述操作系统所提供的的进程抽象:
①逻辑控制流::一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流 使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占 (暂时挂起),然后轮到其他进程
②并发流:一个逻辑流的执行时间与另一个流重叠,成为并发流,这两个流成为并发的运行。多个流并发的执行的一般现象成为并发
③时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
④私有地址空间:进程为每个流都提供一种假象,好像它是独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,在这个意义上,这个地址空间是私有的。
⑤用户模式和内核模式::处理器通常使用一个寄存器提供两种模式的区分,该寄 存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的 代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任 何命令,并且可以访问系统中的任何内存位置。
⑥上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由 通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内 核数据结构等对象的值构成。
⑦上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:
1) 保存以前进程的上下文
2)恢复新恢复进程被保存的上下文,
3)将控制传递给这 个新恢复的进程 ,来完成上下文切换。
现在我们再来看一下hello进程执行,再进程调用execve函数之后,由上面分析可知,进程已经为hello程序分配了新的虚拟的地址空间,并且已经将hello的.txt和.data节分配虚拟地址空间的代码区和数据区。最初hello运行在用户模式下,输出hello 2022111376 武靖涛,然后hello调用sleep函数之后进程陷入内核模式,内核不会选择什么都不做等待sleep函数调用结束,而是处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。
6.6 hello的异常与信号处理
异常和信号异常可以分为四类:中断、陷阱、故障、终止
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
在发生异常时会发出信号,比如缺页故障会导致OS发生SIGSEGV信号给用户进程,而用户进程以段错误退出。
按下Crtl+Z,进程收到SIGSTP信号,hello进程挂起并向父进程发送SIGCHLD。
运行ps命令查看进程运行状态。
运行jobs命令:
可以看到停止的作业。
运行pstree命令:
运行kill命令:
Ctrl+C发送SIGINT信号,Hello进程被终止。
6.7本章小结
本章介绍了进程的概念和作用,观察了hello进程的创建,执行,终止以及各个命令的执行,如进程树,ps等。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。
线性地址:也叫虚拟地址,和逻辑地址类似,也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件也是内存的转换前地址。
虚拟地址:也就是线性地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU链接的地址总线相对应。可以直接把物理地址理解成插在机器上那根内存本身,把内存看成一个从0字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址,但是事实上,这只是一个硬件提供给软件的抽像,内存的寻址方式并不是这样。所以,说它是“与地址总线相对应”,是更贴切一些,不过抛开对物理内存寻址方式的考虑,直接把物理地址与物理的内存一一对应,也是可以接受的。也许错误的理解更利于形而上的抽像。
7.2 Intel逻辑地址到线性地址的变换-段式管理
实模式下:逻辑地址CS:EA到物理地址CS*16+EA
保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址,段地址+偏移地址=线性地址。
段选择符各字段含义:
15 14 | 32 | 10 |
索引 | TI | RPL |
TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)
RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级。高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置,被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
7.4 TLB与四级页表支持下的VA到PA的变换
在现代操作系统中,地址转换过程中除了页表机制外,还使用了转换后备缓冲(Translation Lookaside Buffer, TLB)来加速虚拟地址(VA)到物理地址(PA)的转换。下面我们详细说明TLB和四级页表机制下虚拟地址到物理地址的转换过程。
7.4.1 TLB
TLB是一种高速缓存,用于存储最近使用的虚拟地址到物理地址的映射。通过TLB,可以避免每次地址转换都进行多级页表查找,从而加速地址转换过程。
7.4.2 四级页表机制
四级页表机制将虚拟地址转换为物理地址时,通过四级页表结构进行映射。假设我们使用64位地址空间,地址分解如下:
Page Map Level 4 (PML4):PML4是四级页表的顶层,每个进程有一个PML4表。
Page Directory Pointer Table (PDPT):PDPT是第二级页表,它的每个条目指向一个Page Directory (PD)。
Page Directory (PD):PD是第三级页表,它的每个条目指向一个Page Table (PT)。
Page Table (PT)::PT是第四级页表,它的每个条目指向一个物理页框。
每一级页表大小为512项,每项指向下一级页表或物理页。
64位虚拟地址分为以下几部分:PML4 索引高9位,PDPT 索引下一个9位,PD 索引再下一个9位,PT 索引最后一个9位,页内偏移12位
7.4.3 TLB和四级页表结合的地址转换过程
(1)从虚拟地址(VA)提取各级索引和页内偏移
假设虚拟地址为 VA,PML4 索引位于高9位,PDPT 索引位于下一个9位,PD 索引位于再下一个9位,PT 索引为最后一个9位,页内偏移& 0xFFF为低12位。
(2)CPU首先在TLB中查找虚拟地址的映射。如果命中(TLB hit),则直接使用缓存的物理地址。如果未命中(TLB miss),则需要进行页表查找。
(3)四级页表查找(在TLB miss的情况下):
使用PML4索引在PML4表中查找,找到对应的PDPT表地址。使用PDPT索引在PDPT表中查找,找到对应的PD表地址。使用PD索引在PD表中查找,找到对应的PT表地址。使用PT索引在PT表中查找,找到物理页框地址。
(4)计算物理地址:
物理地址 = 物理页框地址 + 页内偏移
7.5 三级Cache支持下的物理内存访问
在现代计算机系统中,为了提高内存访问的速度,通常会使用多级缓存(Cache)。
7.5.1 缓存层级结构
(1)一级缓存(L1 Cache)
位置:最接近CPU核心,通常分为两个部分:指令缓存(L1i)和数据缓存(L1d)。
大小:通常较小(几KB到几十KB)。
速度:非常快,延迟通常在1到3个时钟周期。
(2)二级缓存(L2 Cache)
位置:紧接L1缓存,可能是每个CPU核心独有,也可能是每两个核心共享。
大小:比L1大(几百KB到几MB)。
速度:稍慢于L1缓存,延迟通常在10到20个时钟周期。
(3)三级缓存(L3 Cache)
位置:通常为整个处理器共享,所有核心都可以访问。
大小:较大(几MB到几十MB)。
速度:慢于L2缓存,延迟通常在几十到上百个时钟周期。
7.5.2 缓存访问过程
当CPU需要访问某个物理地址时,三级缓存架构的访问过程如下:
(1)CPU发出内存访问请求
CPU生成一个物理地址来访问数据(假设地址为PA)。
(2)L1缓存查找
CPU首先在L1缓存中查找PA。如果命中(hit),L1缓存返回数据给CPU,访问结束。如果未命中(miss),请求发送到L2缓存。
(3)L2缓存查找
在L2缓存中查找PA。如果命中(hit),L2缓存返回数据给CPU,并且可能将数据复制到L1缓存。如果未命中(miss),请求发送到L3缓存。
(3)L3缓存查找
在L3缓存中查找PA。如果命中(hit),L3缓存返回数据给CPU,并且可能将数据复制到L2和L1缓存。如果未命中(miss),请求发送到主内存(DRAM)。
(4)内存访问
在L3缓存未命中的情况下,访问请求发送到主内存。主内存返回数据给L3缓存,并且可能复制到L2和L1缓存。最终,数据从L1缓存返回给CPU。
7.5.3 缓存一致性
为了确保多核处理器中所有核心对内存的一致视图,通常采用缓存一致性协议(如MESI、MOESI)。这些协议管理缓存之间的数据一致性,确保当一个核心修改缓存中的数据时,其他核心能够看到最新的数据。
7.6 hello进程fork时的内存映射
当 fork 函数被 shell 进程调用时,内核为新进程创建各种数据结构,并分配给 它一个唯一的 PID,为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只 读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
int execve(const char *pathname, char *const argv[], char *const envp[]);
pathname新程序的路径。
argv[]:传递给新程序的命令行参数。
envp[]:传递给新程序的环境变量。
1、 输入 ./hello lmy 2022113064 3
2、 execve加载hello程序后,设置栈,将控制传递给hello程序的主函数。
3、 删除已存在的用户区域
4、 映射新的私有区域。代码和初始化数据映射到.text和.data区(执行可执行文件提供),.bss映射到匿名文件,共享对象由动态链接映射到本进程共享区域,设置PC,指向代码区域的入口点。栈中从栈底到栈顶是参数和环境字符串,再往上是指针数组,每个指针指向刚才的环境变量和参数字符串。栈顶是系统启动函数libc_start_main的栈帧和预留的未来函数的栈帧。
当hello进程调用execve()时,整个进程的内存映射发生了彻底的变化。旧的地址空间被清除,新的程序被加载到地址空间中。通过这种方式,execve()可以在当前进程的上下文中运行一个新的程序,而不需要创建新的进程。这样不仅节省了资源,还允许新程序继承当前进程的许多属性,如进程ID、环境变量等。该函数成功运行正确运行时不返回。逻辑控制流交给要运行的程序
7.8 缺页故障与缺页中断处理
7.8.1 缺页故障(Page Fault)
虚拟内存在DRAM缓存不命中即为缺页故障。
7.8.2 缺页中断处理
缺页中断处理:触发缺页异常时启动缺页处理程序
1、缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘
2、缺页处理程序页面调入新的页面,并更新内存中的PTE
3、缺页处理程序返回到原来的进程,再次执行导致缺页的命令。
7.9动态存储分配管理
7.9.1 动态内存管理的基本方
虽然可以使用低级的mmap和munmap函数来创建和删除虚拟内存区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器更方便,也有更好的可移植性。
(1)显式分配器:要求应用显式地释放任何已分配的块。例如,c标准库提供一种叫做malloc程序包的显式分配器。c程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。c++中的new和delete操作符与c中的malloc和free相当。
(2)隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集,例如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
7.9.2 动态内存管理的策略
(1)带边界标签的隐式空闲链
带边界标签的隐式空闲链表使用边界标签(boundary tags)来管理内存块,内存块之间没有显式的指针链接。每个内存块包含头部和尾部的边界标签,这些标签存储块的大小和状态(分配或空闲)。
(2)显示空间链表
显式空闲链表使用链表来维护所有空闲块,链表中的每个节点都包含指向下一个和上一个空闲块的指针。这种方式提供了更高效的空闲块管理。
7.10本章小结
本章主要介绍了hello的存储器的地址空间,介绍了四种地址空间的差别和地址的相互转换。同时介绍了hello的四级页表的虚拟地址空间到物理地址的转换。阐述了三级cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m字节的序列:
B0,B1,B2……Bm
所有的 IO 设备(如网路、磁盘、终端)都被模型化为文件,而所有的输入和输出都被 当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都被当做相应文件的读和写来执行:
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix I/O 接口:
(1)打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想 要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在 后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文 件的所有信息。
(2)Shell 创建的每个进程都有三个打开的文件:标准输入,标准输出,标 准错误。 (3)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位 置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用 程序能够通过执行 seek,显式地将改变当前文件位置 k。
(4)读写文件:一个读操作就是从文件复制 n>0 个字节到内存,从当前文 件位置 k 开始,然后将 k 增加到 k+n,给定一个大小为 m 字节的而文 件,当 k>=m 时,触发 EOF。类似一个写操作就是从内存中复制 n>0 个字节到一个文件,从当前文件位置 k 开始,然后更新 k。
(5)关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢 复到可用的描述符池中去。
Unix I/O 函数:
(1)int open(char* filename,int flags,mode_t mode) ,进程通过调用 open 函 数来打开一个存在的文件或是创建一个新文件的。 open函数将filename 转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在 进程中当前没有打开的最小描述符,flags 参数指明了进程打算如何访 问这个文件,mode 参数指定了新文件的访问权限位。
(2)int close(fd),fd 是需要关闭的文件的描述符,close 返回操作结果。
(3) ssize_t read(int fd,void *buf,size_t n),read 函数从描述符为 fd 的当前文 件位置赋值最多 n 个字节到内存位置 buf。返回值-1 表示一个错误,0 表示 EOF,否则返回值表示的是实际传送的字节数量。
4) ssize_t wirte(int fd,const void *buf,size_t n),write 函数从内存位置 buf 复制至多 n 个字节到描述符为 fd 的当前文件位置。
8.3 printf的实现分析
分析首先查看printf函数的函数体:
`1. static int printf(const char *fmt, ...)
2. {
3. va_list args;
4. int i;
5. va_start(args, fmt);
6. write(1,printbuf,i=vsprintf(printbuf, fmt, args));
7. va_end(args);
8. return i;
9.}
printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
接下来是write函数:
1.write:
2. mov eax, _NR_write
3. mov ebx, [esp + 4]
4. mov ecx, [esp + 8]
5. int INT_VECTOR_SYS_CALL
在printf中调用系统函数write(buf,i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,
int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
查看syscall函数体:
1.sys_call:
2.call save
3.
4. push dword [p_proc_ready]
5.
6. sti
7.
8. push ecx
9. push ebx
10. call [sys_call_table + eax * 4]
11. add esp, 4 * 3
12.
13. mov [esi + EAXREG - P_STACKBASE], eax
14. cli
15. ret
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是我们的打印字符串就显示在了屏幕上。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。getchar 调用 fgetc(stdin),fgetc 尝试从 stdin 的缓存区读取一个字符。如果缓存区为空,则调用 _uflow。__uflow 调用 _underflow 来填充缓存区。
__underflow 调用 read 系统调用,从标准输入读取数据到缓存区。数据被读入缓存区后,返回给 fgetc,然后 getchar 返回读取的字符。
8.5本章小结
本章简述IO设备管理方法及Unix I/O函数。同时分析了printf和getchar两个函数的实现,对Linux环境下函数的执行进行了详细叙述。
结论
1.编写源代码:
hello.c:编写C程序 hello.c,它是一个包含C源代码的文本文件,每个字符都用ASCII编码表示。
2.预处理:
hello.i: hello.c 经过预处理器处理,插入头文件的内容,展开宏,删除注释,得到预处理后的文件 hello.i。
3.编译:
hello.s:编译器将 hello.i 转换为汇编语言文件 hello.s,这是对应于机器指令的低级表示。
4.汇编:
hello.o:汇编器将 hello.s 转换为机器语言指令,生成可重定位目标文件 hello.o,它是一个二进制文件。
5.链接:
hello:链接器将 hello.o 与库函数(如 printf)的目标文件链接,解决所有符号引用,生成可执行文件 hello。
6.加载与运行:
创建子进程:在终端输入 ./hello lmy 2022113064 3,Shell 调用 fork() 创建一个子进程来运行 hello。
加载:子进程调用 execve 加载 hello 可执行文件,操作系统将 hello 映射到虚拟内存,并设置程序入口点。
虚拟内存到物理内存:虚拟地址经过页表映射为物理地址,程序代码和数据段加载到物理内存中。
7.执行:
_start:程序从入口点 _start 开始执行,进行初始设置后调用 __libc_start_main。
__libc_start_main:负责C运行时环境的初始化,最终调用用户的 main 函数。
main: main 函数执行用户定义的程序逻辑,如调用 printf 输出结果。
I/O操作:程序通过系统调用与I/O设备交互(如文件管理),执行输入输出操作。
8.终止:
exit: main 函数返回后,调用 exit 函数,进行清理工作(如调用 atexit 注册的函数),并返回退出状态码。
回收子进程:Shell 父进程通过 wait 系统调用回收终止的子进程,并获取其退出状态。
对计算机系统的设计与实现的深切感悟:
深切感悟:从编写到执行一个简单的 hello 程序,深入理解计算机系统的每个环节,可以看到背后复杂而精巧的设计。简单的一个程序一条输出需要经过如此复杂而精妙的步骤,才能出现在我们的屏幕上,不禁让人感叹。
创新理念:引入更多的动态优化机制,根据运行时信息实时调整资源分配和管理策略,提高系统的响应速度和资源利用率。
通过不断的学习和实践,深入理解计算机系统的设计与实现,探索新的设计和实现方法,推动计算机科学的发展。
附件
中间结果文件 | 文件作用 |
hello.i | hello.c预处理得到的文本文件 |
hello.s | hello.i编译后的汇编文件 |
hello.o | hello.s汇编得到的可重定位目标文件 |
hello | 链接得到的可执行目标文件 |
hello.out | hello反汇编之后的可重定位文件 |
参考文献
[1] Bryant, Randal E., and David R. O'Hallaron. "Computer Systems: A Programmer's
Perspective." Pearson, 2016.
[2] https://github.com.
[3] file:///C:/Users/m1777/Desktop/深入理解计算机系统原书第3版-文字版.pdf