计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2022113408
班 级 2203101
学 生 李冰洋
指 导 教 师 史先俊
计算机科学与技术学院
2023年4月
通过逐步分析程序hello从编写代码到最终执行结束的过程,对所学的知识进行了系统的回顾与应用。本篇论文分析了hello生成可执行程序的过程,阐述了hello在执行过程中OS、以及各种硬件所发挥的作用,分析了在Linux系统下独特的I/O管理,对程序运行的底层原理逐渐有了更深的了解。
关键词:hello;编译;汇编;进程;地址;I/O管理
目 录
第1章 概述................................................................................... - 4 -
1.1 Hello简介............................................................................ - 4 -
1.2 环境与工具........................................................................... - 4 -
1.3 中间结果............................................................................... - 5 -
1.4 本章小结............................................................................... - 5 -
第2章 预处理............................................................................... - 6 -
2.1 预处理的概念与作用........................................................... - 6 -
2.2在Ubuntu下预处理的命令................................................ - 6 -
2.3 Hello的预处理结果解析.................................................... - 6 -
2.4 本章小结............................................................................... - 7 -
第3章 编译................................................................................... - 8 -
3.1 编译的概念与作用............................................................... - 8 -
3.2 在Ubuntu下编译的命令.................................................... - 8 -
3.3 Hello的编译结果解析........................................................ - 8 -
3.4 本章小结............................................................................. - 11 -
第4章 汇编................................................................................. - 12 -
4.1 汇编的概念与作用............................................................. - 12 -
4.2 在Ubuntu下汇编的命令.................................................. - 12 -
4.3 可重定位目标elf格式...................................................... - 12 -
4.4 Hello.o的结果解析........................................................... - 15 -
4.5 本章小结............................................................................. - 17 -
第5章 链接................................................................................. - 18 -
5.1 链接的概念与作用............................................................. - 18 -
5.2 在Ubuntu下链接的命令.................................................. - 18 -
5.3 可执行目标文件hello的格式......................................... - 18 -
5.4 hello的虚拟地址空间....................................................... - 22 -
5.5 链接的重定位过程分析..................................................... - 23 -
5.6 hello的执行流程............................................................... - 25 -
5.7 Hello的动态链接分析...................................................... - 25 -
5.8 本章小结............................................................................. - 26 -
第6章 hello进程管理.......................................................... - 27 -
6.1 进程的概念与作用............................................................. - 27 -
6.2 简述壳Shell-bash的作用与处理流程........................... - 27 -
6.3 Hello的fork进程创建过程............................................ - 27 -
6.4 Hello的execve过程........................................................ - 28 -
6.5 Hello的进程执行.............................................................. - 28 -
6.6 hello的异常与信号处理................................................... - 29 -
6.7本章小结.............................................................................. - 34 -
第7章 hello的存储管理...................................................... - 35 -
7.1 hello的存储器地址空间................................................... - 35 -
7.2 Intel逻辑地址到线性地址的变换-段式管理.................. - 35 -
7.3 Hello的线性地址到物理地址的变换-页式管理............. - 36 -
7.4 TLB与四级页表支持下的VA到PA的变换................... - 36 -
7.5 三级Cache支持下的物理内存访问................................ - 38 -
7.6 hello进程fork时的内存映射......................................... - 38 -
7.7 hello进程execve时的内存映射..................................... - 39 -
7.8 缺页故障与缺页中断处理................................................. - 40 -
7.9动态存储分配管理.............................................................. - 40 -
7.10本章小结............................................................................ - 41 -
第8章 hello的IO管理....................................................... - 42 -
8.1 Linux的IO设备管理方法................................................. - 42 -
8.2 简述Unix IO接口及其函数.............................................. - 42 -
8.3 printf的实现分析.............................................................. - 43 -
8.4 getchar的实现分析.......................................................... - 45 -
8.5本章小结.............................................................................. - 46 -
参考文献....................................................................................... - 49 -
第1章 概述
1.1 Hello简介
P2P:从程序到进程(From Program to Process),首先编写代码得到源程序hello.c(即program),在预处理阶段,经过预处理器(cpp)处理,对预处理指令做出处理,例如对 #define 的替换,插入 #include 的头文件等操作,得到hello.i文件;在编译阶段,编译器(ccl)将预处理后的代码文件“翻译”成汇编语言的文件,得到hello.s;在汇编阶段,汇编器(as)将汇编语言文件“翻译”成机器代码的二进制文件,得到可重定位目标文件hello.o;在链接阶段,链接器(ld)把源代码和库函数独立编译后的结果,按照要求将它们组装起来,得到可执行目标文件hello。
之后,在shell中,调用fork()为hello生成子进程,到此为止hello完成了从程序到进程的转化。
O2O: shell调用fork函数创建子进程,通过execve加载可执行文件hello,映射虚拟内存(mmap),进入程序之后载入物理内存。之后为hello分配时间片,在硬件上执行取指、译码、流水线等操作。在执行过程中,操作系统(OS)和内存管理单元(MMU)将内存虚拟地址(VA)转化为物理地址(PA),内存管理器和CPU通过三级Cache、多级页表、TLB等加速程序执行,IO管理与信号处理对进程的运行状态做出改变。当程序运行结束时,父进程将此进程收回,内核删除相关数据。到此为止,hello全部痕迹都没有了,可以说回到了0。
1.2 环境与工具
X64 CPU;2G RAM;Windows11; Vmware 11;Ubuntu 16.04 LTS 64位;vim,gcc
1.3 中间结果
预处理后的文本文件 | |
hello.s | 编译后产生汇编语言的文本文件 |
hello.o | 汇编后生成的可重定位目标文件 |
hello.txt | hello.o的ELF格式文件 |
hello_ass.txt | hello.o的反汇编代码文件,用于分析hello.o的结果 |
hello | 链接产生的可执行目标文件 |
hello_ld.txt | 可执行程序hello的ELF格式,用于hello的重定位分析 |
hello_asm.txt | hello的反汇编代码,用于链接的重定位过程分析 |
1.4 本章小结
介绍了hello的P2P、O2O的过程,对大作业的整体流程有了初步了解,并列出了大作业进行所需要的软件、硬件与环境,列出了大作业全程产生的中间文件。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理是在编译之前进行的处理,会对源代码进行文本替换和文件包含等操作,它基于预处理指令(以 # 开头的指令)来处理源代码,以生成最终的源代码供编译器使用。
作用:
(1)通过预处理命令,将其他文件的内容包含到当前源文件中,包含的文件内容被复制到指令所在位置。
(2)根据源代码中定义的宏(使用 #define 指令定义的标识符)来展开对应的宏定义,并将其替换为展开后的文本。
(3)条件编译,使用条件编译指令(如 #ifdef、#ifndef、#if、#else、#elif、#endif 等),根据条件的真假选择编译哪些代码块,过滤掉不必要的代码块。
(4)预处理器会删除源代码中的注释,以便在实际编译中不影响编译器对代码的解析。
2.2在Ubuntu下预处理的命令
预处理命令为:
gcc -E hello.c -o hello.i
-E选项让gcc在预处理结束后停止编译过程,-o将预处理结果保存到test.i中
2.3 Hello的预处理结果解析
hello.c的预处理结果被保存到hello.i中,发现该文件以文本文件的形式保存。打开后,发现代码量大大增加,hello.c文件中仅有24行,而hello.i中有3092行。
原文件中的注释全部被删除掉,并且插入了大量代码。hello.i中包括引用的各种.h文件的路径。一些未在hello.c中引用的文件,比如features.h,应该是在stdio.h或者stdlib.h这样已引用的文件中被嵌套引用,而这些文件都需要被展开。如图:
对头文件里宏定义和条件编译语句也进行相应的宏替换和条件编译处理。
2.4 本章小结
本章简单解释了预处理的概念、作用,在Ubuntu下执行了预处理的命令,分析了hello.i的内容,探究了经过预处理后文件与原文件的不同,了解了在预处理阶段所完成的宏替换、头文件展开等工作。
第3章 编译
3.1 编译的概念与作用
概念:编译是将高级语言代码转换为计算机可执行的机器语言的过程。编译器将源代码作为输入,产生以汇编语言表示的目标文件作为输出。
作用:将高级语言翻译为汇编语言,通过语法分析、词法分析、语义分析、代码优化以及代码生成等生成汇编语言程序。
3.2 在Ubuntu下编译的命令
编译命令为:
gcc -S hello.i -o hello.s
-S 选项,让编译程序在生成汇编语言输出之后停止。
3.3 Hello的编译结果解析
3.3.1数据
(1)常量
(一)字符串常量
在hello.c文件中,有两个字符串常量,他们分别被保存在.LC0和.LC1中。
(二)常数
代码中有三个常数,下面会对他们逐个说明。
对在汇编代码24行中,将立即数4与-20(%rbp)进行比较,相等则跳转到.L3中继续执行,不相等就会直接退出。
当argc与4不相等时,将1保存到edi中,以1来退出程序。
在代码的for循环中,如果i<8,程序就可以继续执行。在.L3中,没有将i(%rbp-4)与8比较,而是判断i是不是小于等于7,来决定程序是否继续执行。
(2)变量
传入的参数argc和argv,函数传参依次保存在rdi、rsi、rdx等寄存器,可知argc保存在rdi中,argv保存在rsi中。由下图可以知道,argc保存在-20(%rbp),argv保存在-32(%rbp)。
在argc等于4判断成功后,会跳转到.L2中,把0赋给了-4(%rbp),根据原始代码可以知道循环开始时将i赋值为0,所以推断出-4(%rbp)保存了i的值。
3.3.2赋值
hello.c中的赋值操作只有在for循环开始时对i的赋值,在3.3.1部分已经说明。
3.3.3类型转换
argv[3]是字符型变量,经过显式类型转化,用atoi函数转化为整型。
具体的实现方式为:将argv[3]的值(-32(%rbp))通过rax赋给%rdi,作为函数参数传入atoi中。之后call atoi@PLT,将返回值eax再赋值给edi,传入sleep函数中。
3.3.4算术操作
代码中的算术操作只有循环计数时的i++
在一个循环结束后,会对-4(%rbp)加1,由3.3.1可知,这个位置存放的就是i
3.3.5关系操作
argc != 4在汇编代码中是将argc与4进行比较,相等则会跳转到.L2继续运行。
i < 8是把i和7来比较,i<=7时,循环继续执行。
3.3.6数组/指针/结构操作
程序传入了char *argv[],数组中每个元素都是字符指针。采用基址加偏移量的方式来访问数组中的元素。开始时argv数组的首地址被保存在-32(%rbp)中。
对数组元素的访问都是先找到首地址,之后加上相对应的偏移量。由于字符指针占8个字节,所以偏移量分别加8、16、24。下面三张截图分别是访问argv[2]、argv[1]、argv[3]
3.3.7控制转移
代码中的控制转移共有两处:一处是判断argc和4是否相等;一处是将i和8比较。
如果argc和4相等,则跳转到.L2,否则执行后面的语句
如果i<=7(即i<8),则跳转到.L4,否则执行后面的语句。
3.3.8函数操作
main函数中传入了argc、argv,分别保存在%edi、%rsi中。执行过程中可能通过exit来退出程序,或者程序运行结束后通过return 0正常返回,将rax赋值为0后离开。
在代码中调用了printf(put@PLT)、exit(exit@PLT)、atoi(atoi@PLT)、sleep(sleep@PLT)、getchar(getchar@PLT)这些函数。
以atoi举例,传入的参数为argv[3],先将argv[3]读取到%rax中,之后传入%rdi,将其传入atoi中,并把返回值保存在eax中。
3.4 本章小结
这一章阐述了编译的概念以及作用,给出了在Ubuntu下编译的命令,并通过分析汇编代码,说明了编译阶段编译器是如何处理C语言的各个数据类型以及各类操作的。
通过探究编译阶段的各个操作,理解了编译的具体流程,对其有了更深的了解。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标文件的格式,生成二进制文件hello.o。
作用:将汇编代码转换为计算机可以直接执行的机器码。通过汇编,计算机可以理解和执行程序的指令,从而实现程序的功能。
4.2 在Ubuntu下汇编的命令
汇编命令为:
gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
将输出重定向到hello.txt
4.3.1 ELF头
ELF头开始是16字节的数字序列(Magic),这个序列描述了生成该文件的系统的字的大小和字节顺序。最开始的4个字节是所有ELF文件都必须相同的标识码,第一个字节对应ASCII字符里面的DEL控制符,后面3个字节刚好是ELF这3个字母的ASCII码。这4个字节称为ELF文件的魔数。接下来的一个字节是用来标识ELF的文件类的,0x01表示是32位的,0x02表示是64位的;第6个字是字节序,规定该ELF文件是大端的还是小端的。第7个字节规定ELF文件的主版本号,一般是1。后面的9个字节ELF标准没有定义,一般填0,也可用作扩展标志。
ELF头在Magic之后的有帮助链接器语法分析和解释目标文件的信息,可以知道目标文件类型为可重定位文件、机器类型为X86-64、节头部表的文件偏移为1056字节、节头部表中条目有13个。
4.3.2节头
对于不同节区的分析:
.text:用来存放已编译的程序执行代码
.rela.text:针对text段的重定位表,包括被模块引用或定义的所有全局变量的重定位信息。偏移量表示需要进行重定向的代码在.text或.data节中的偏移位置;信息包括symbol和type两个部分,symbol在前面四个字节,type在后面四个字节;类型指重定向的类型;符号名称是重定位目标的名字,加数表是一个有符号常数,是重定位过程需要加减的常量。
.data: 已初始化的全局变量和静态变量。局部变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。
.bss: 存放未初始化的全局变量和静态变量,以及所有被初始化为0的全局或静态变量。
.rodata: 存放的是只读数据,比如字符串常量,全局const变量和#define定义的常量,又称为常量区。
.symtab:符号表,存放函数和全局变量的名字、节名称位置。
根据Value都为0,可知符号相对于目标节的起始位置的偏移量都是0;size说明了目标字节的大小;根据type可以确定是字节还是函数;bind可以知道是局部变量还是全局变量;ndx表明所属节区。
.strtab: 一个字符串表,存储变量名、函数名等。
.shstrtab: bss、text、data等段名存储在这里。
4.4 Hello.o的结果解析
输出重定向到hello_ass.txt,便于阅读。反汇编代码为:
机器语言是计算机可以直接执行的一组二进制指令,它是由0和1组成的二进制代码。机器语言由操作码、操作数构成;汇编语言是机器语言的助记符表示形式,它使用助记符和操作数表示指令。汇编器将汇编语言代码翻译为机器语言,它将每个汇编指令转换为对应的机器指令,根据指令的助记符和操作数生成指令的二进制表示形式。机器语言和汇编语言之间存在一对一的映射关系。
与反汇编代码与hello.s的不同:
(1)条件分支转移语句不同:在hello.s中,跳转指令使用的是像.L2这样的段名称,在hello.o的反汇编代码中是直接跳转到确定的地址。
(2)数字使用进制不同:在hello.s中使用十进制,而在反汇编代码中是十六进制;
(3)函数调用不同:在hello.s中,通过call+函数名就可以直接调用函数。但在反汇编代码中,是call 28 <main+0x28>这种形式,因为hello.c中调用的函数都位于共享库,需要通过动态链接器来确定函数运行时的执行地址。在汇编时,这些地址是不确定的。因此将call后的地址全部设为0,在.rela.txt节中为其添加重定位条目,等待静态链接的进一步确定。
(4)全局变量表示不同:hello.s中,使用两个字符串常量时直接通过%rip加上所在段的名称,而在反汇编代码中是%rip+0,因为.rodata节中的数据也是在运行时确定的,需要重定位,先全部填为0,在.rela.text节中添加重定位条目。
4.5 本章小结
这一章讨论了从hello.s到hello.o的汇编过程,了解了汇编的概念和作用。使用readelf查看了可重定位目标格式(ELF),并进行了详细阐述,对ELF有了更深的了解。之后使用objdump对hello.o进行反汇编,得到了反汇编代码,与hello.s进行对比,解释了机器语言与汇编语言中不一致的地方。
第5章 链接
5.1 链接的概念与作用
概念:将多个目标文件或静态库中的符号解析并进行符号引用的过程。链接器将可重定位目标文件中的代码和数据组合在一起,解决符号间的引用,创建一个最终的可执行文件。
作用:将不同的目标文件和库文件组合成一个可执行文件,以便计算机可以理解和执行程序的指令。通过链接,可以方便地将不同的代码模块组合在一起,形成一个完整的程序。
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/11/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/11/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
5.3 可执行目标文件hello的格式
将输出重定位到hello_ld.txt
ELF头开始是16字节的Magic,描述了生成该文件的系统的字的大小和字节顺序。
之后是帮助链接器语法分析和解释目标文件的信息。与第四章中hello.o的ELF格式不同之处:
不同点 | hello.o的ELF格式 | Hello的ELF格式 |
类型 | REL(可重定位文件) | EXEC(可执行文件) |
入口点地址 | 0x0 | 0x4010f0 |
程序头起点 | 0 | 64 |
Start of section headers | 1056 | 14080 |
Size of program headers | 0 | 56 |
Number of program headers | 0 | 12 |
Number of section headers | 14 | 30 |
Section header string table index | 13 | 29 |
节头:由上表可知,节头数目从hello.o的13条变成了hello的29条。
节头表包含了程序的名称、地址、偏移量等信息,由于发生了错位,用箭头指示出了他们各自的位置。
程序头:
Type:表示段的类型,如代码段、数据段、动态链接段等。
Offset:指示段在文件中的起始位置的偏移量。通过文件偏移量,可以在文件中找到该段的具体数据。
Virtual Address:表示段在进程虚拟地址空间中的起始地址。加载器将段从文件映射到内存时,会将段的数据复制到虚拟地址指定的位置。
Physical Address:指示段在物理内存中的起始地址。
重定位节(.rela.text):
符号表(.symtab):
5.4 hello的虚拟地址空间
使用edb加载hello,进行查看
edb窗口如下:
在Symbols窗口,看到了各段的名称与对应的始末位置,与5.3中的节头表进行对比,各段的的起始位置可以对应的上。
查看datadump窗口,根据节头表可以知道,以.text为例,.text的起始地址为0x4010f0,与ELF头中的入口点地址一致,偏移量为0x10f0,大小为0x17e,所以.text的虚拟内存为从0x4010f0到0x40126e。其它段与.text段同理。
5.5 链接的重定位过程分析
得到hello的反汇编代码,重定向到hello_asm.txt中
(1)经过链接得到的hello程序中引入了其它库函数,因此经过objdump后会多出一部分代码,这些代码在hello.o的反汇编代码中是没有的,hello.o中只有<main>。如下图,左侧为hello.o的反汇编代码,右侧为hello的反汇编代码
(2)hello.o的反汇编代码都是偏移地址,而hello的反汇编代码则是虚拟地址。因为在链接前,位于共享库的函数的地址是不确定的,需要用偏移地址来表示;经过链接后,重定位已经完成,地址被确定下来,就可以使用虚拟地址表示。可参考上图。
对重定位的分析:
当汇编器生成模块时,数据和代码在内存中的最终位置、外部函数位置、全局变量的位置都不清楚。针对最终位置未知的目标引用,汇编器会生成一个重定位条目。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rela.data中。在把hello.o链接为可执行文件hello的过程中,链接器根据.rela_data和.rela_text节中保存的重定位信息对符号或者函数进行重定位。
下图是hello.o的重定位节,可以确定偏移量,在运行时指向对应的位置。
5.6 hello的执行流程
子程序名 | 地址 |
_dl_start | 0x7ffff7c28700 |
_dl_init | 0x7ffff7fc94d0 |
_start | 0x4010f0 |
main | 0x4011de |
puts | 0x7ffff7c606f0 |
printf | 0x7ffff7cea570 |
strtol | 0x7ffff7c474e0 |
sleep | 0x7ffff7cea570 |
getchar | 0x7ffff7c87ae0 |
5.7 Hello的动态链接分析
程序调用由共享库定义的函数时,编译器无法预测这个函数的运行地址在哪儿,一般会生成一个重定位条目,由链接器在程序加载时解析这条重定位记录。使用延迟绑定技术将过程地址绑定推迟到第一次调用该过程。
GOT是一个全局偏移表,用于存储程序中使用的外部符号的地址。PLT是一个过程链接表,当程序需要调用共享库或其他外部函数时,PLT提供了一组固定的跳转指令。初始时,这些跳转指令中存储的是一个特殊的地址,该地址位于GOT表中,并且会在第一次调用外部函数时被动态链接器替换。替换后,GOT表中的地址就会指向实际的外部函数。
GOT是一个数组,每个元素为8字节地址,GOT[0]和GOT[1]包含动态链接器解析函数地址时需要用到的信息,GOT[2]是动态链接器在1d-linux.so模块的入口点。
用readelf打开hello,找到.got.plt的地址,从0x404000开始,大小为0x48,结束位置为0x404048。
用edb打开hello,通过data dump窗口分别查看dl_init前后的变化。发现开始时GOT[1](0x404008) 和GOT[2](0x404010)中内容均为0,在dl_init之后,二者的内容恢复正常,变成重定位表和动态链接器ld-linux.so运行地址。
5.8 本章小结
主要介绍了链接的概念和作用,给出了如何通过ld将hello.o链接为一个可执行目标文件。并通过readelf分析了各段的信息。之后使用edb分析了hello的虚拟地址空间。通过比较hello.o和hello反汇编代码的不同之处,对重定位进行了分析。
在5.6跟踪了hello的执行流程,对其运行有了更透彻了解。最后对hello动态链接分析,对plt和got有了更深一步的了解。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程指正在执行中的程序实例。进程是操作系统中资源分配和调度的基本单位,每个进程都有自己的内存空间、指令流、堆栈以及其他与程序执行相关的资源。
作用:进程提供给应用程序两个关键抽象:
(1)逻辑控制流,通过OS内核的上下文切换机制来提供,使每个程序似乎独占地使用CPU。
(2)私有地址空间,通过OS内核的虚拟内存机制提供,使每个程序似乎独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一种命令行解释器,为用户提供操作界面,用户在这个界面中输入命令,然后shell解析和执行用户输入的命令,并运行程序。
处理流程:
(1)Shell首先读取用户输入的命令行,并进行解析,获取argc、argv。
(2)检查是否为内置命令,如果是shell的内置命令,就立即执行该命令。
(3)不是内置命令,Shell调用系统函数fork()创建一个新的进程。
(4)将argc、argv传递给execve,通过execve加载运行该可执行文件。
(5)使用waitpid等待执行结束后,回收该子进程。
(6)循环执行知道用户退出shell
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。fork在子进程中返回0,在父进程中返回子进程的PID。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程虚拟地址空间相同、但是相互独立的一份副本,包括代码、数据段、堆、共享库以及用户栈。
在shell中输入./hello 2022113408 李冰洋 0,shell调用fork来创建新的进程。
6.4 Hello的execve过程
创建子进程后,在子进程中调用execve函数,传入可执行文件名、命令行参数、环境变量,与fork返回两次不同,execve调用后不会返回(除非出现错误时)。
execve调用启动加载器,删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件的内容。最后,加载器设置PC指向_start地址,调用应用程序的main函数。
6.5 Hello的进程执行
进程上下文信息:是操作系统中用于保存和恢复进程执行状态的数据结构。它包含了进程的所有必要信息,使得进程可以在被调度之间进行切换,并从上次被中断的地方继续执行。
进程时间片是指操作系统给予一个进程占用CPU执行的时间段。在多任务处理系统中,CPU时间会被切分成一个个小的时间片,每个进程被分配一个时间片,当时间片用完后,操作系统将剥夺该进程的CPU执行权,将CPU分配给其他就绪状态的进程。
为了防止OS 本身及关键数据遭受到应用程序有意或无意的破坏,通常将 CPU 的执行状态分成核心态和用户态两种。
核心态:具有较高的特权,能执行一切指令,访问所有寄存器和存储区,传统的 OS都在核心态运行。
用户态:具有较低特权的执行状态,仅能执行规定的指令,访问指定的寄存器和存储区。一般情况下,应用程序只能在用户态运行,不能去执行OS指令及访问OS区域,这样可以防止应用程序对OS的破坏。
并发执行:把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。就是说一个CPU核心先运行一下进程1,再运行一下进程2,再运行一下进程3,只要微观上切换的足够快,宏观上看好像就是同时运行的。
并行执行:把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。
开始运行hello时,hello被分配时间片,系统中多个进程并发地执行,轮流使用CPU。在时间片结束后,在用户态下保存上下文信息,之后被hello被挂起,等待时间片到来后继续执行。hello开始运行在用户模式中,但执行系统调用函数sleep或者exit时,hello会保存上下文信息,然后切换到内核态。内核此时会将hello进程休眠一定时间,这段时间内去执行其他的进程。等到内核中的处理程序完成对系统函数的调用后,执行上下文切换,将控制返回给进程hello,此时切换为用户态。
6.6 hello的异常与信号处理
可能出现的异常:中断(Interrupts)、陷阱 (Traps)、故障 (Faults)、终止 (Aborts)。其中中断为异步异常,其它三个为同步异常。
中断:处理器外部I/O设备引起由的,处理器的中断引脚指示,中断处理程序返回到下一条指令处。
陷阱:是有意的,执行指令的结果,陷阱处理程序将控制返回到下一条指令。
故障:不是有意的,但可能被修复。处理程序要么重新执行引起故障的指令(已修复),要么终止。
终止:非故意,不可恢复的致命错误造成。需要中止当前程序。
(1)不停乱按,输入的字符串被保存在缓冲区中,在hello执行完后会将输入的乱码作为命令行输入
(2)Ctrl-C,会出发中断异常,操作系统向前台进程hello发送 SIGINT 信号,终止hello进程。
(3)Ctrl-Z,会向前台进程hello发送SIGSTP信号,hello进程被挂起,停止执行,等待在后台。
(4)ps,查看当前所有进程的PID
(5)jobs,显示当前终端会话中运行的作业列表
(6)pstree,以树形结构的方式显示当前系统中的进程,并按照父子进程关系进行缩进,展示进程之间的层次关系。
(7)fg,发送SIGCONT信号,使hello继续在前台运行。
(8)kill,在输入Ctrl-Z后,用ps查看hello的PID,然后用kill -9命令强制终止进程,发送SIGKILL杀死hello
6.7本章小结
介绍了进程的概念和作用,阐述了shell的作用和处理流程,分析了fork、execve的过程以及hello进程执行过程,然后分析了hello的执行过程中可能遇到的各种异常,并尝试了Ctrl-C、Ctrl-Z、fg等各种命令,查看了运行结果。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址,由一个段标识符加上指定段内相对地址的偏移量(hello.o)组成。
线性地址:是从逻辑地址变换到物理地址的中间层。程序hello的代码会产生逻辑地址,加上相应段的基地址就生成了一个线性地址。
虚拟地址:就是线性地址。在操作系统中,为hello进程分配了一块虚拟地址空间,进程hello在执行时使用虚拟地址来访问其分配的内存空间。虚拟地址在进程内是唯一的,并且通常是指令和数据的起始位置加上一个偏移量。虚拟地址空间的大小与物理内存的大小有关。
物理地址:物理地址是真正访问计算机内存时使用的地址,可以看作是内存模块上的硬件地址。它指的是存储器内存模块的物理位置,用于实际访问存储器中的数据。如果启用了分页机制,那么hello的线性地址能再变换成hello的物理地址。若没有启用分页机制,那么hello线性地址直接就是hello的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段选择符和段内偏移量构成。段选择符有16位,前13位是索引号,用来确定当前使用的段描述符在描述符表中的位置;后面3位表示TI和RPL,TI为0时选择全局描述符表(GDT),为1时选择局部描述符表(LDT),RPL来选择内核态与用户态,00(0)表示核心态,11(3)表示用户态。
根据段选择符,确定了采用全局描述符表还是局部描述符表之后,找到相对应的寄存器,得到地址和大小后,获得了段描述符表。根据段选择符的前13位,通过索引号在段描述符表中找到对应的段描述符,得到base,即得到了基地址。
将得到的基地址与段内偏移量相加,计算公式为:线性地址 = 基地址 << 4 + 段内偏移量。由此就可以得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址,即虚拟地址,由两部分组成,包括VPO(虚拟页面偏移量)、VPN(虚拟页号);物理地址组成部分有PPO(物理页面偏移量)、PPN(物理页号)。而且VPO与PP是相等的,因此只需要将VPN转化为PPN,找到对应的物理页号,就可以得到物理地址。这个操作可以通过MMU来实现。
hello虚拟地址的VPN可以作为页表的索引,通过读取页表基址寄存器(PTBR)的内容,可以获得页表中的PTE,其中包括有效位和物理页号(即PPN)。如果有效位是0,代表缺页,触发缺页异常,缺页异常处理程序选择一个牺牲页,并重新启动导致缺页的指令,之后可以得到hello的物理页号(PPN);如果有效位是0,则可以直接得到物理页号。
物理页号与物理页面偏移量共同组成了物理地址,线性地址被变化为物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
VA就是虚拟地址,PA就是物理地址,MMU 使用虚拟地址的 VPN 部分来访问TLB,TLB 命中减少内存访问,如果TLB不命中就会引发额外的内存访问。由于局部性原理,TLB 不命中很少发生。
通过VPN中的TLBT(TLB标记)在集合里匹配每一行的标记,用TLBI(TLB索引)选择集合,如果命中了TLB,就得到了相应的页表条目PTE,MMU相应得到了PPN。由于PPO和VPO相等,PPO和PPN构成了所需要的hello的物理地址。
如果hello虚拟地址的VPN没有命中TLB,MMU就会从页表中查询。四级页表对应的虚拟地址包括4个VPN和1个VPO,VPN k对应到第k级页表。对于1到3级页表,每一集页表的条目指向下一级页表的基址。在第4级页表中,根据VPN 4取出对应的PTE,从中找到hello的PPN,与PPO组合从而得到hello的物理地址。
7.5 三级Cache支持下的物理内存访问
由下图可知,在core i7中,物理地址由40位的CT、6位的CI、6位的CO构成。根据MMU发送的物理地址,使用CT对一级Cache的每一行进行匹配,使用CI进行组索引,如果匹配成功且块的有效位为1,则成功命中。之后根据CO来确定数据块中具体字节的位置。
如果在当前存储层次中没有命中,就从下一级中提取出来对应的块,如果Cache有空闲块,根据放置策略直接放置在空闲块中;如果没有空闲块,根据替换策略选取一个牺牲块,替换驱逐该牺牲块。
在上一级Cache没有找到,就会去下一级Cache中寻找。这与一级Cache的访问机制一样。按照L1-L2-L3的访问顺序,逐级寻找数据,最终返回给CPU。
7.6 hello进程fork时的内存映射
当fork函数被调用以创建hello进程时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。
为hello进程创建虚拟内存,创建当前进程的的mm_struct、 vm_area_struct和页表的原样副本,并且将两个进程中的每个页面都标记为只读,两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW)。在hello进程中返回时,新进程拥有与调用fork进程相同的虚拟内存。如果之后任意进程进行了写操作,故障处理程序就会在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新的副本,然后恢复这个页面的可写权限。
7.7 hello进程execve时的内存映射
调用execve函数在当前进程中加载并运行hello
会删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
为hello的代码、数据、bss和栈区域创建新的区域结构,这些区域是私有的、写时复制,代码和初始化数据映射到由目标文件提供的.text和.data区;.bss和栈堆是私有的、请求二进制0的,映射到匿名文件,栈堆的初始长度0。
共享对象由动态链接映射到本进程共享区域。例如hello与共享进程libc.so链接,libc.so动态链接到hello,然后映射到虚拟就中的共享区域。
设置PC(程序计数器),并使之指向代码区域的入口点。在进入这个进程时,将会从代码区域的入口点开始执行,Linux根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
一般情况下说缓存不命中为缺页。如下图示例,如果CPU引用了虚拟内存的VP3,但在PET3中有效位为0,VP3未被缓存,由此引发了缺页异常。缺页异常会导致调用缺页异常处理程序,它会选出一个牺牲页,在这里是存放在PET4中的VP4。缺页异常处理程序将VP3加载到物理内存中,并更新PTE。之后重新启动导致缺页的指令,成功命中。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(break),它指向堆的顶部。如下图。
分配器将内存视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留供应用程序使用,空闲块可用来分配。
分配器有两种基本风格,显式分配器要求应用显式地释放任何已分配的块。隐式分配器要求分配器检测一个已分配块,何时不再被程序使用,那么就释放这个块。
分配器的实现方法有:
(1)隐式空闲链表,通过空闲块头部的大小字段隐式的连接起来,比较简单,但任何操作都需要遍历整个空闲链表,导致搜索时间与堆中已分配块和空闲块的总数呈线性关系。它有三种放置策略:
①从头开始搜索空闲链表,当发现能够满足申请的空闲块时,就使用。
②之前使用了某一个空闲块,那么下一次适配就是从之前分配的那个空闲块的下一个地方开始寻找满足申请的空闲块。
③从头到尾遍历空闲链表,然后寻找最合适的空闲块用来满足申请。
(2)显式空闲链表,与隐式空闲链表相比,显式空闲链表把空闲内存块单独拿出来组成一个链表。
C标准库提供了一个称为malloc程序包的显式分配器,程序可以通过调用malloc从堆中分配一个块。
在hello中,printf调用malloc函数,它会开辟一个内存块,返回值为void*,这个指针指向size大小的内存块。如果开辟失败,就会返回NULL。当开辟的空间不再需要使用时,通过free来释放和回收内存。
7.10本章小结
本章介绍了hello的存储管理,介绍了逻辑地址、线性地址、虚拟地址、物理地址的各自概念,通过它们的转换关系说明了段式管理和页式管理。阐述了TLB与四级页表支持下的操作、三级Cache支持下的操作。解释了调用fork、execve时的内存映射,讲述了缺页故障的处理方法以及动态内存管理的方法。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
在Linux中,万物皆文件,设备也被模型化为文件,通过在文件系统中的设备文件(如/dev/sda)来访问和操作设备。
设备管理:unix io接口
"万物皆文件"的思想允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O,使得Linux系统具有统一的接口。读取设备数据可以使用类似的文件操作方式,如打开、读取、写入和关闭等。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O接口
接口就是连接CPU与外设之间的部件,它完成CPU与外界的信息传送。还包括辅助CPU工作的外围电路,如中断控制器、DMA控制器、定时器、高速CACHE
这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
(1)打开和关闭文件,通过open()、close()来实现。
open()函数用于打开一个设备文件,并返回一个非负整数的文件描述符用于后续对设备文件的读写操作。它接受两个参数:path,表示设备文件的路径和文件名;flags,用于指定打开设备文件的模式和选项,如只读、只写、非阻塞等,可以使用预定义的常量组合来指定所需的选项。
close()函数用于关闭指定的设备文件描述符。它接受一个整数参数fd,表示要关闭的设备文件描述符。close()函数返回整数值0表示成功关闭设备文件,返回-1表示关闭设备文件失败,失败时会设置errno错误号来指示错误类型。
(2)读写文件,通过read() 、write()实现。
read()函数从设备文件中读取数据。它接受三个参数:fd,表示设备文件的文件描述符;buf,用于存储读取的数据的缓冲区;count,表示要读取的字节数。read()函数返回实际读取的字节数,如果返回值为0表示已经到达文件末尾,返回值为-1表示读取失败,出错时errno会被设置。
write()函数向设备文件写入数据。它接受三个参数:fd,表示设备文件的文件描述符;buf,要写入的数据的缓冲区;count,表示要写入的字节数。write()函数返回实际写入的字节数,如果返回值为-1表示写入失败,出错时errno会被设置。
(3)改变当前的文件位置,可以通过lseek实现。
lseek()函数接受三个参数:fd,表示文件描述符,指定要操作的设备文件;offset,表示要设置的偏移量,可以是正数、负数或0;whence,表示相对于哪个位置进行偏移的模式。
whence可以是以下几种常量:
SEEK_SET:将文件指针移动到文件的开头,偏移量从文件开头开始计算。
SEEK_CUR:将文件指针移动到当前位置,偏移量从当前位置开始计算。
SEEK_END:将文件指针移动到文件的末尾,偏移量从文件末尾开始计算。
lseek()函数返回新的偏移量,如果返回值为-1表示操作失败,出错时errno会被设置。
8.3 printf的实现分析
printf的函数体为:
fmt指向的是要打印的字符串的首地址,形参中的…说明参数的数量不确定。C语言中,参数连续的从右往左压栈,为了确定参数数量,通过{ va_list arg = (va_list)((char*)(&fmt) + 4); }这句代码,可以找到…中第一个参数,也就可以找到后继所有参数。
下一行代码{ i = vsprintf(buf, fmt, arg); },vsprintf接受确定输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出。它返回的是要打印出来的字符串的长度,下面是一个示例函数体,实现了对16进制的格式化:
在printf函数中,下一行代码是{ write(buf, i); },write实现了写操作,写操作,把buf中的i个元素的值输出到终端。由于打印的最底层操作和硬件有关,为了保证安全性,需要限制程序的权限。所以操作系统将buf(需要打印的函数)传递给和硬件操作的函数来完成。
write的汇编代码为:
ecx存放要打印出的元素个数,ebx中的是要打印的buf字符数组中的第一个元素,int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call函数。
sys_call函数的汇编代码为:
sys_call函数把将要输出的字符串数据通过总线复制到显卡的显存中,在显存中以ASCII码的形式存储字符。字符显示驱动子程序读取ASCII码,之后在字模库中找到与ASCII码相对应的点阵信息,将点阵信息存储到vram中。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。sys_call就完成了对字符串的打印。
8.4 getchar的实现分析
getchar()是stdio.h中的库函数,代码为:
getchar调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
在成功调用时返回值是字符的ASCII码值,在读取结束或者失败的时候,会返回-1。它的作用是从stdin流中读入一个字符,如果stdin有数据的话不需要键盘输入可以直接读取。第一次getchar()时,需要人工的输入,可以不断输入直到输入回车。如果你输入了多个字符,后续的getchar()再执行时就会直接从缓冲区中读取,而不是等待键盘输入。
字符的传递顺序为:输入设备->内存缓冲区->getchar()
异步异常-键盘中断的处理:
当键盘上的一个按键按下时,键盘会发送一个中断信号给CPU,与此同时,键盘会在指定端口(0x60) 输出一个数值,这个数值对应按键的扫描码叫通码;当按键弹起时,键盘又给端口输出一个数值,这个数值叫断码。断码 = 通码 + 0x80。键盘扫描码如下所示:
键盘的输入到达60端口时,相关的芯片就会向CPU发出中断类型码为9的可屏蔽中断信息。CPU检测到改中断信息后,如果IF = 1,则响应中断,引发中断过程,转去执行int 9中断例程,来进行基本的键盘输入处理。
中断实际上是将CPU当前正在执行的任务给打断,让CPU先处理中断任务,然后再返回处理原先的任务。把键盘中断通码和断码数值缓存到键盘缓冲区,然后把控制权交还给原来任务,等到CPU空闲时处理键盘中断事件。
键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
8.5本章小结
本章介绍了Linux中的I/O管理,说明了Linux如何通过文件管理I/O设备,介绍了Unix I/O接口及其函数,从底层分析了printf和getchar的实现方法。
结论
hello从编写的hello.c代码开始,经过预处理,为编译器提供hello.i文件,这经历了复杂的操作,删除注释、宏展开、插入头文件代码……这仍是helllo整个人生中不起眼的一小部分。之后仍有很多的挑战。
之后hello要在编译器的帮助下,将hello.i中的高级语言翻译为汇编语言,保存在hello.s中。这时的hello已经符合了计算机的逻辑。对人而言,hello正在一步步变得难以理解,但对计算机来说,hello正在变得越来越清晰明了。
接下来,hello被送到汇编器这里,再次将汇编语言翻译为机器语言指令,并打包成可重定位目标文件的格式。这已经是计算机可以直接执行的机器码,这时的hello对人来说已经没有了可读性。通过汇编,hello变成了计算机可以理解和执行程序的指令。
随后要进行链接。hello看似孤单地走完了前面的路程,但hello的执行需要很多标准库函数的支撑。到这里,C标准库中的各种函数也被处理好,他们会和hello 链接在一起。他们经过合并会变成一个可执行文件,到这里,hello作为主角,加上库函数为它当配角,已经可以登台演出。
即便hello已经开始运行,但他的“演出”过程仍有很大的工作量。fork为hello创建进程,为子进程hello分配虚拟内存空间,可以说为hello的“演出”拉开了帷幕,之后execve加载hello的可执行程序,将hello引上舞台。分配CPU时间片、段页式管理、TLB、多级页表、三级Cache、内存映射、IO管理、信号处理、软硬结合等等……都是为了保证hello的正常执行所做的幕后工作。
“演出”过程中难免有意外,因此在hello进程执行的过程中,要应对各种异常并正确处理。中断、陷阱、终止等等,处理得当hello可以继续“表演”,发生无法处理的情况,例如终止,hello也不得不立刻停止。
这就是hello的一生,在计算机中有不计其数的其它程序,也像hello一样,计算机软硬件协同,使他们发挥了各自该有的作用。他们共同支撑起了计算机强大的功能。
附件
hello.i | 预处理后的文本文件 |
hello.s | 编译后产生汇编语言的文本文件 |
hello.o | 汇编后生成的可重定位目标文件 |
hello.txt | hello.o的ELF格式文件 |
hello_ass.txt | hello.o的反汇编代码文件,用于分析hello.o的结果 |
hello | 链接产生的可执行目标文件 |
hello_ld.txt | 可执行程序hello的ELF格式,用于hello的重定位分析 |
hello_asm.txt | hello的反汇编代码,用于链接的重定位过程分析 |
参考文献
[1] Randal E. Bryant, David R. O’Hallaon. 深入理解计算机系统. 第三版. 北京:机械工业出版社[M]. 2018:1-737.
[2] printf 函数实现的深入剖析https://www.cnblogs.com/pianist/p/3315801.html
[3]ELF文件详解—初步认识 ELF文件详解—初步认识-CSDN博客
[4]操作系统——进程调度
[5]段选择符 段寄存器段选择符 段寄存器_段选择符是段寄存器吗-CSDN博客
[6]虚拟地址与物理地址
[7]Linux系统调用原理
[8]PC机键盘的处理过程(外中断-键盘输入)