计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机类
学 号 xxxxxxxxxx
班 级 xxxxxxx
学 生 xx
指 导 教 师 吴锐
计算机科学与技术学院
2022年5月
本文主要介绍hello程序在Linux下由代码到程序,由运行到回收这样的一生。从预处理、编译、汇编、链接、进程管理、存储管理和I/O管理这些不同的方面,分析hello过程出现的各种现象,以此来理解整个过程中计算机系统各部分所做的工作。
关键词:P2P;020;Linux;预处理;编译;汇编;链接;进程管理;存储管理;I/O管理;
目 录
第1章 概述
1.1 Hello简介
P2P:在Linux系统下,hello.c源程序经过预处理(cpp)、编译(ccl)、汇编(as)、链接(ld)最终生成可执行目标文件hello。然后shell会fork一个新的子进程,再调用execve函数将hello程序加载到新创建的子进程中。由此便实现了由程序(Program)到进程(Process)的转变,即P2P。
020:Linux加载器execve将程序加载到新创建的进程中,之后通过虚拟内存映射将程序从磁盘载入物理内存中执行,这其中包括段式管理、页式管理等等。然后CPU为该进程分配时间片,执行该程序对应的逻辑控制流。当程序执行结束后,父进程回收hello进程,内核删除相关数据结构。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows10 64位;VirtualBox/Vmware 11以上;Ubuntu 20.04 LTS 64位/优麒麟 64位
开发工具:vi/vim/gedit+gcc/edb/readelf
1.3 中间结果
文件名称 | 作用 |
hello.c | 源代码 |
hello.i | 预处理生成的文本文件 |
hello.s | 编译生成的汇编文件 |
hello.o | 汇编生成的可重定位目标文件 |
hello | 链接得到的可执行目标文件 |
注:论文分析过程中还进行了反汇编和查看ELF等,但并没有将其形成文件,主要的内容作用均可在后文相应章节中找到。
1.4 本章小结
简要介绍了hello.c程序P2P、020的过程。列出了实验所需的环境和工具以及过程中所生成的中间结果。
第2章 预处理
2.1 预处理的概念与作用
C语言的源程序加工包括三步:预处理、编译和连接。所谓的预处理是指在进行正式编译(此法分析,代码生成,优化等)之前所做的工作。
预处理是C语言的一个重要功能,它由预处理程序负责完成,任何C语言程序都有一个预处理程序。当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。C语言提供多种预处理功能,大多数预处理器指令属于下面3种类型:
- 宏定义:#define 指令定义一个宏,#undef 指令删除一个宏定义。
- 文件包含:#include指令导致一个指定文件的内容被包含到程序中。
- 条件编译:#if,#ifdef,#ifndef,,#elif,#else 和#endif 指令可以根据编译器可以测试的条件来将一段文本包含到程序中或排除在程序之外。
C语言主要处理以上3种预编译指令,剩下的#error,#line和#prgma等更特殊的指令,较少用到。
2.2在Ubuntu下预处理的命令
预处理命令:gcc -E -o hello.i hello.c
图2-1 Ubuntu下预处理命令及预处理结果截图
2.3 Hello的预处理结果解析
图2-1 文本文件hello.i内容的部分截图
预处理后得到的hello.i将hello.c中的文件包含语句删除,并将这些头文件的内容插入到main函数之前。
2.4 本章小结
本章先简要概括了预处理的概念和作用,然后通过对hello.c预处理做进一步理解。预处理过程是之后所有操作的基础,是不可或缺的重要过程。合理使用预处理功能编写的程序便于阅读、修改、移植和调试,也有利于模块化程序设计。
第3章 编译
3.1 编译的概念与作用
编译是由 xxx.i(预处理文件)生成 xxx.s(汇编文件)即将高级语言翻译为汇编语言的过程,此过程由编译器(ccl)来完成。
在编译阶段,编译器将包含高级语言的 xxx.i 文本文件翻译成包含一个汇编语言程序的 xxx.s 文本文件,并逐行检查语法错误,这是gcc编译四个步骤当中最重要也是最耗时的。
3.2 在Ubuntu下编译的命令
命令:gcc -S -o hello.s hello.i
图3-1 Ubuntu下编译命令及编译结果截图
3.3 Hello的编译结果解析
图3-2 hello.s文件的开头结尾部分
解析: .file:源文件名
.globl:全局变量
.data:数据段
.align:对齐方式
.type:指定是对象类型或是函数类型
.size:大小
.long:长整型
.section .rodata:下面是 .rodata 节
.string:字符串
.text:代码段
3.3.1 数据
- 字符串(全局变量)
图3-3 字符串有关汇编代码
两个字符串"用法: Hello 学号 姓名 秒数!\n"和"Hello %s %s\n"被分别存储在标签.LC0和.LC1中。
- main函数参数
图3-4 参数int argc、char *argv[]有关汇编代码
第1条语句在main函数开始时将寄存器%rbp指向栈指针%rsp指向的地址处,第3条语句代表栈指针%rsp向下移动参数开辟32字节空间,第4条语句将第1个参数寄存器%edi中的int argc存放至-20(%rbp)地址处,第5条语句将第2个参数寄存器%rsi中的char *argv[]存放在-32(%rbp)地址处。
- 局部变量
图3-5 局部变量int i有关汇编代码(1)
在-4(%rbp)地址处放置一个初值为0的变量,代表局部变量int i。
3.3.2 赋值
for循环开始时的i=0操作在编译后与定义变量int i操作合并,都包含在图3-5语句中。
3.3.3 算术操作
图3-6 局部变量int i有关汇编代码(2)
图3-6是在每次循环结束时执行i++操作,存储变量i的地址不变。
3.3.4 关系操作
- “!=”
图3-7 !=关系操作有关汇编代码
前面已经说过,argc存放在-20(%rbp)处,第3、4代码把argc和立即数4进行比较,用跳转指令je(若相等则跳转,不相等则继续向下)来执行源代码中的不等操作argc!=4。
- “<”
图3-8 <关系操作有关汇编代码
由前文和源程序知变量i的初始值为0,其后的有关运算为i++,于是要执行i<8操作,汇编器进行了这样的处理:将i与立即数7进行比较,若i小于等于7,则i<8为真,反之为假。
3.3.5 数组、指针操作
图3-9 数组、指针操作有关汇编代码
main函数的第二个参数是char *argv[]数组的首地址,存放在-32(%rbp)处,源程序中用argv[1]来读取数组的第二个元素,对应图3-9第4、5行语句,即取出首地址偏移量为8处的值,因为char *类型占8个字节;同理,argv[2]读取数组第3个元素对应图3-9第1、2行语句,即取首地址偏移16字节处的值。
3.3.6 控制转移
- if条件控制
图3-10 if条件控制有关汇编代码
图3-10第3、4行将argc与立即数4进行比较,若不相等则说明if条件判断为真,继续向下执行printf()和exit()函数;反之则不执行源代码if中语句,直接跳转到.L2即进入for循环。
- for循环
图3-11 for循环有关汇编代码
由图3-11分析编译器对for循环操作的处理:首次进入循环将i置为0,跳转到.L3,将i与立即数7进行比较,若i小于等于7(i小于8)跳转到.L4执行循环体,.L4的最后一行代表每次循环结束执行i++,重复每次循环过程直到i大于7(不满足i<8)结束for循环。
3.3.7 函数操作(参数传递与返回值)
- main函数
图3-12 main函数返回值有关汇编代码
在3.3.1 (2)中我们分析了main函数的参数传递,这里略过;将返回值寄存器的值置为0后ret,即为main函数执行return 0。main函数返回后它的局部变量所占空间全部释放。
- 第一个printf()函数
图3-13 第一个printf()有关汇编代码
编译器将保存在.LC0(%rip)中的字符串读入第1个参数寄存器%rdi,然后调用puts打印该字符串;printf()函数不需要它的返回值。
- exit()函数
图3-14 exit()函数有关汇编代码
原理同(2),只是传入参数是立即数1。若执行到exit()函数,则整个程序直接终止,其后其他函数的参数传递和返回值都无意义。
- 第二个printf()函数
3.3.5中分析了该函数的第2和第3个参数,第1个参数同(2),只是从.LC1中读出;该函数也不需要它的返回值。
- atoi()和sleep()函数
图3-15 atoi()和sleep()函数有关汇编代码
函数atoi()传入参数原理同3.3.5,它的返回值存放在寄存器%rax中作为函数sleep()的传入参数,sleep()函数没有返回值。
- getchar()函数
该函数无参数传入,也不需要它的返回值,直接call调用即可。
3.4 本章小结
本章先简要概括了编译的概念和作用,然后通过对hello.i编译做进一步理解。并对编译器是如何处理C语言的各个数据类型以及各类操作进行了解析说明。
第4章 汇编
4.1 汇编的概念与作用
汇编是指从 xxx.s 到 xxx.o 即编译后生成的汇编语言文件到生成机器语言二进制程序的过程,此过程由汇编器(as)来完成。
在汇编阶段,汇编器将 xxx.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件 xxx.o 文件中 xxx.o 文件是一个二进制文件,它包含的内容是 xxx.c 程序的指令编码,如果在文本文件中打开将看到一堆乱码。
4.2 在Ubuntu下汇编的命令
指令:gcc -C -o hello.o hello.s
图4-1 Ubuntu下编译命令及编译结果截图
4.3 可重定位目标elf格式
图4-2 hello.o的ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(若可重定位、可执行或者共享的)、机器类型(若x86-64)、节头部表的文件偏移,以及节头部表条目的大小和数量。
图4-3 hello.o的的各节基本信息
夹在ELF头和节头部表之间的都是节。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。图4-2展示了ELF可重定位文件中各节的信息。偏移量代表对应的节在hello.o二进制文件中相对于起始地址偏移了多少,也就是每一节保存在hello.o中的什么位置。
图4-4 hello.o的重定位节
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行目标文件时如何修改这个引用。其中代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。
举例分析,偏移量代表这个重定位信息在当前重定位节中的偏移量,也就是在这个重定位信息的存储位置;信息包含了两个信息,前2个字节的信息代表这个代码在汇编代码中被引用的时候的地址相对于所有汇编代码的偏移量,也就是这个代码具体在哪个位置被调用了;后4个字节代表重定位类型,一个是绝对引用,另一个是相对引用;这也与其后的类型相对应;最后的符号值和符号名称保存了代码段中具体符号的信息。
图4-5 hello.o的符号表
.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。我们在图4-4中可以看到,符号名Name在最后一列;NDX值代表符号存在哪个section中;Value表示对其所在section起始位置的偏移量;size代表各个符号的大小,type代表符号类型,如main的类型是FUNC,也就是函数。
4.4 Hello.o的结果解析
图4-6 反汇编后汇编代码(左)与hello.s部分汇编代码(右)
机器语言指令是一种二进制代码,由操作码和操作数两部分组成。操作码规定了指令的操作,是指令中的关键字,不能缺省。操作数表示该指令的操作对象。 机器语言与汇编语言有映射关系:每一行机器代码对应一行汇编代码。机器代码中的操作数是能被机器直接理解的代码,而汇编代码是能使人理解CPU操作的低级语言代码。
反编译则是在反汇编的基础上,将低级语言还原回高级语言,在这个过程中由于一些因素可能会导致与源码不同。hello.o的反汇编与hello.s的主要区别有:
- 全局变量:在hello.s中,对于字符串等全局变量的访问是通过访问标签.LC0等,而在反汇编后,则是直接对偏移的相对地址进行访问。
- 操作数:hello.s中的操作数为十进制,而反汇编代码中的操作数是十六进制。
- 分支转移:执行跳转语句时,hello.s中是跳转到.L3和.L4等段代号,而反汇编代码中是直接跳转至主函数加段内偏移的地址。
- 函数调用:hello.s中,函数调用时使用call指令直接加函数名称,而反汇编代码中使用call指令加函数的相对偏移地址。
4.5 本章小结
本章先简要概括了汇编的概念和作用,然后通过对hello.s汇编做进一步理解;接着用readelf列出了hello.o的ELF格式的各类信息并加以分析;最后对hello.o进行反汇编,并与第3章的 hello.s进行了对照分析。
第5章 链接
5.1 链接的概念与作用
链接是由 xxx.o 生成 xxx ,即由可重定位目标文件到可执行目标文件或可执行文件的过程。
链接阶段链接器会进行引入如printf、system、scanf等函数的函数库、合并多目标文件、合并启动例程等操作,即将各种代码和数据片段收集合并成为一个单一文件,这个文件中存放二进制的机器语言(注意,虽然是二进制文件,但是打开之后不是101010这些,因为我们看到的101010是一系列字符串,而不是计算机识别的那个101010),可以被加载(复制)到内存中并由系统执行。
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-1 Ubuntu下链接指令及链接结果截图
5.3 可执行目标文件hello的格式
图5-2 hello的ELF头
hello的ELF头中指出hello的类型为EXEC类型,即hello为可执行目标文件。可执行目标文件hello的格式类似于可重定位目标文件的格式,ELF头描述文件的总体格式。它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。同时hello的入口地址非零,说明重定位工作已完成。注意到hello的ELF头中program headers的偏移量非零,说明hello文件中比hello.o文件中多了一个段头表。
图5-3 hello的各段基本信息
图5-3中保存了可执行文件hello中的各个节的信息。可以看到hello文件中的节的数目比hello.o中多了很多,说明在链接过后有很多文件有添加了进来。值得注意的是每一节都有了实际地址,而不是像在hello.o中那样地址值全为0。这说明重定位工作已完成。.text、.rodata和.data节与可重定位目标文件中的节是类似的,除了这些节已经被重定位到它们最终的运行时的内存地址外。.init节定义了一个小函数_init,程序初始化代码会调用它。因为可执行文件时完全链接的,所以无.rel节。同时多出的节是为了能够实现动态链接,如.interp这一节包含动态链接器的路径名,动态链接器通过执行一系列重定位工作完成链接任务。
图5-4 hello的重定位节
图5-5 hello的符号表
图5-6 hello的段头表
段头表描述了可执行目标文件的连续的片与连续的虚拟内存段之间的映射关系。从段头表中可以看到根据可执行目标文件的内容初始化为两个内存段,分别为只读内存段(代码段)和读写代码段(数据段)。
5.4 hello的虚拟地址空间
图5-7 edb中SymbolViewer
将hello加载到edb中后,打开SymbolViewer即可看到各节的虚拟地址(或各虚拟地址所对应的节)。
图5-8 在edb中查看hello起始地址
上图可以可以看出程序从0x400000开始。
图5-9 在edb中查看hello程序入口地址
从ELF头中看出程序的入口地址为0x4010f0,对应于节头表中.text节的起始地址。
图5-10 在edb中查看.interp节内容
可以看出0x4002e0位置处放的是动态链接器的路径名。
图5-11 在edb中查看.rodata节内容
可以看出printf语句中的格式串%s %s位于.rodata节,属于只读数据。
5.5 链接的重定位过程分析
图5-12 hello的反汇编代码
(hello.o的反汇编见图4-5左)
- hello与hello.o主要有以下的不同:
- 链接增加了新的函数:在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数。
- 增加了节:hello中增加了.init和.plt节,和一些节中定义的函数。
- 函数调用:hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
- 地址访问:hello.o中的相对偏移地址变成了hello中的虚拟内存地址。因为它们的地址是在运行时确定的,因此访问需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。
- 通过上面的对比可以看到,在链接的过程中,链接器会进行如下几个过程:
- 将代码、符号、变量、函数等进行重定位,使这些元素在可执行文件中可以有明确的虚拟内存地址。具体的执行方式就是用.o文件中的重定位条目,这个条目告诉链接器应该如何修改这个引用的地址。
- 将调用的函数都加载到可执行文件中,使其变成一个完整的文件,在文件中涉及到的任何符号或者函数等信息在文件中都有定义。
- 分析hello重定位过程,主要有以下两步:
- 重定位节和符号定义。在汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text节中,已初始化数据的重定位条目放在.rel.data中。
- 重定位节中的符号引用。链接器依赖于hello.o中的重定位条目,修改代码节和数据节中对每个符号的引用,使得它们指向正确运行时的地址。
总体来说,重定位的过程就是应用重定位文件中存储的信息,在对应的符号表和汇编代码中将要重定位的符号或者函数的位置准确的放到可执行文件中。
5.6 hello的执行流程
hello在执行的过程中一共要执行三个大的过程,分别是载入、执行和退出。载入过程的作用是将程序初始化,等初始化完成后,程序才能够开始正常的执行。如图5-7所示,由于hello程序只有一个main函数,所以在程序执行的时候主要都是在main函数中。又因为main函数中调用了很多其它的库函数,所以可以看到,在main函数执行的过程中,会出现很多其他的函数。
程序名称 | 程序地址 |
ld-2.31.so!_dl_start | 0x7f8e7cc34ed0 |
ld-2.31.so!_dl_init | 0x7f8e7cc486a0 |
hello!_start | 0x4010f0 |
libc-2.31.so!_libc_start_main | 0x7ff 825425fc0 |
libc-2.31.so!_cxa_atexit | 0x7ff 825448f60 |
hello!_libc_csu_int | 0x4011c0 |
libc-2.31.so!_setjmp | 0x7ff 82fdb2e00 |
libc-2.27.so!exit | 0x7ff 82fdc3bd0 |
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
图5-13 ELF文件部分内容
图5-14 调用dl_init前
图5-15 调用dl_init后
可以看出,在dl_init调用之后,该处的两个8字节的数据都发生了改变。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址, GOT[2]是动态链接器ld-linux.so模块中的入口点。
5.8 本章小结
本章先简要概括了链接的概念和作用,然后分析了hello的ELF格式、hello的虚拟地址空间、重定位过程、执行流程和动态链接过程。详细介绍了hello.o是如何实现动态链接生成一个可执行文件的。
第6章 hello进程管理
6.1 进程的概念与作用
进程就是一个执行中程序的实例,每一个进程都有它自己的地址空间,包括代码段、数据段、和堆栈区。
每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。
进程提供给应用程序的关键抽象:一个独立的逻辑控制流,好像我们的程序独占地使用处理器;一个私有的地址空间,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash是一个Linux中提供的交互型应用级程序,它在操作系统中为用户与内核之间提供了一个交互的界面,用户直接面对的不是计算机硬件而是shell,用户把指令告诉shell,由shell解释再传输给系统内核,接着内核再去支配计算机硬件去执行各种操作,并且处理各种各样的操作系统的输出结果。其处理流程如下:
- 终端进程读取用户由键盘输入的命令行;
- 分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量;
- 检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令;
- 如果不是内部命令,调用fork( )创建新进程/子进程;
- 在子进程中,用步骤2获取的参数,调用execve( )执行指定程序;
- 如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait)等待作业终止后返回;
- 如果用户要求后台运行(如果命令末尾有&号),则shell返回。
6.3 Hello的fork进程创建过程
当在shell上输入./hello命令时,命令行会首先判断该命令是否为内置命令,如果是内置命令则立即对其进行解释。否则将其看成一个可执行目标文件,再调用fork创建一个新进程并在其中执行。
当shell运行hello程序时,父进程会通过fork函数创建一个新的运行的子进程Hello。Hello进程几乎但不完全与父进程相同,Hello进程得到与父进程用户级虚拟空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库、以及用户栈。Hello进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,Hello进程可以读写父进程中打开的任何文件。父进程和Hello进程最大的区别在于它们有不同的PID。fork函数只被调用一次,却会返回两次。在父进程中,fork返回Hello进程的PID,在Hello进程中,fork返回0 。
6.4 Hello的execve过程
Shell在子进程中调用execve函数。Execve函数用hello的参数列表argv和环境变量列表envp运行hello程序。除非遇到错误,execve函数被调用后不再返回。调用完成后,子进程的地址空间会被hello函数覆盖。
6.5 Hello的进程执行
- 几个概念
- 上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
- 进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
- 用户态和内核态:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
- 上下文切换:当一个进程正在执行时,内核调度了另一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。在进行上下文切换时,需要保存以前进程的上下文,恢复新恢复进程被保存的上下文,将控制传递给这个新恢复的进程来完成上下文切换。
- 调度过程分析
hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,内核处理休眠请求主动释放当前进程以加载新的进程进行执行。同时将hello进程从运行队列中移入到等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程并执行,当定时器到时时发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。
6.6 hello的异常与信号处理
hello执行过程中会出现的异常:
中断:信号SIGTSTP,默认行为是 停止直到下一个SIGCONT
终止:信号SIGINT,默认行为是 终止
图6-1 正常运行
程序正常执行,最后需要输入一个字符回车结束程序。可以看到在执行ps命令之后,程序后台并没有hello进程正在执行了,说明进程正常结束,已经被回收了。
图6-2 不停乱按包括回车
如果乱按过程中没有按回车,则只会在屏幕上显示输入的内容。如果输入回车,则getchar读回车,并把回车前的字符串当作shell输入的命令。
图6-3 乱按包括ctrl-c
按空格、回车都不会打断程序进行,而输入ctrl-c后,shell父进程会收到一个SIGINT信号,这个信号的功能是直接将子进程结束。可以在ps中看到,已经没有了hello进程,说明ctrl-c会直接将进程结束并回收掉。
图6-4 按ctrl-z、ctrl-z后执行fg命令
在hello程序运行时,按下ctrl-z,shell父进程会收到一个SIGSTP信号,这个信号的功能是将程序挂起并且放到后台。通过ps命令我们可以看到,hello命令并没有结束。接下来我们用fg命令将刚刚挂起的hello进程放到前台,进程又继续执行直到8次循环结束。此时再查看进程发现hello已结束。
图6-5 按ctrl-z后执行pstree命令
pstree命令将所有的进程按照树状结构打印出来。这样我们就可以知道不同进程之间的关系。
图6-6 执行kill命令
使用kill -s 9 PID强制终结进程。
图6-7 执行jobs命令
jobs命令可以查看当前执行的关键操作是什么,比如我们执行了一个Ctrl+Z命令将进程挂起后,用jobs命令就能看到Ctrl+Z这条命令。
6.7本章小结
本章先简要介绍了Shell的作用和一般处理过程,然后分析了fork和execve函数的功能,还展示了hello进程的执行以及hello的异常和信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。
线性地址:也叫虚拟地址,和逻辑地址类似,也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件也是内存的转换前地址。
虚拟地址:一个带虚拟内存的系统中,CPU从一个有N=2^n个地址空间中生成虚拟地址。虚拟地址其实就是线性地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU链接的地址总线相对应。可以直接把物理地址理解成插在机器上那根内存本身,把内存看成一个从0字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址。
就hello而言,它是在物理地址上运行的,但是对于CPU而言,CPU看到的hello运行的地址是逻辑地址,在具体操作的过程中,CPU会将逻辑地址转换成线性地址再变成物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成:段标识符和段内偏移量。段标识符是一个16位长的字段组成,称为段选择符,其中前13位是一个索引号。后面三位包含一些硬件细节索引号就是段描述符的索引。
段描述符具体描述了一个段地址,这样,很多段描述符就组成段描述符表。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符。Base字段表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。
一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],看段选择符的T1等于0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
然后拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
最后把Base + offset,就是要转换的线性地址了。
7.3 Hello的线性地址到物理地址的变换-页式管理
图7-1 使用页表的地址翻译
线性地址到物理地址的转换是通过页的这个概念完成的。线性地址被分为以固定长度为单位的组,称为页。
首先 Linux 系统有自己的虚拟内存系统,Linux 将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd 指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct 的链表,一个链表条目对应一个段,所以链表相连指出了 hello 进程虚拟内存中的所有段。
CPU芯片上有一个专门的硬件叫做内存管理单元(MMU),这个硬件的功能就是动态的将虚拟地址翻译成物理地址的。这个表示如何工作的呢,如图7-1所示。N为的虚拟地址包含两个部分,一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE(页表条目)。接下来在对应的PTE中获得PPN(物理页号),将PPN与VPO串联起来,就得到了相应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单一PTE组成的块。TLB通常有高的相联度。
多级页表的使用从两个方面减少了内存要求。第一,如果一级页表中的一个PTE是空的,那么相应的二级页表就根本不会存在。第二,只有一级页表才需要总是在主存中。虚拟内存系统可以在需要时创建、页面调入或调出二级页表。
7.5 三级Cache支持下的物理内存访问
当 CPU 提出访存请求后给出物理地址,然后高速缓存根据 CI 找到缓存组, 在缓存组中根据 CT 与缓存行中的标记位进行匹配,如果匹配成功并且有效位为 1,为命中,则按照块偏移对数据块中的数据进行访问,否则为不命中,向下一级缓存中寻找数据,如果找到,则按照放置策略和替换策略替换该级缓存中的缓存块,否则继续向下一级缓存或主存中寻找数据。
7.6 hello进程fork时的内存映射
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效的替代了当前程序。加载并运行hello需要以下几个步骤:
- 删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。图7.7 概括了私有区域的不同映射。
- 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页现象的发生是由于页表只相当于磁盘的一个缓存,所以不可能保存磁盘中全部的信息,对于有些信息的查询就会出现查询失败的情况,也就是缺页。对于一个访问虚拟内存的指令来说,如果发生了缺页现象,会进行以下操作:
- 处理器生成一个虚拟地址,并把它传送给MMU;
- MMU生成PTE地址,并从高速缓存/主存请求得到它;
- 高速缓存/主存向MMU返回PTE;
- PTE中的有效位是零,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序;
- 缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘;
- 缺页处理程序页面调人新的页面,并更新内存中的PTE;
- 缺页处理程序返回到原来的进程,再次执行导致缺页的指令,CPU将地址重新发送给MMU。因为虚拟页面现在已经缓存在物理内存中,所以会命中,主存将所请求字返回给处理器。
7.9动态存储分配管理
- 动态内存分配器维护者一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种基本风格。两种风格都是要求显示的释放分配块:
- 显式分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。
- 隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器。
- 隐式空闲链表:一个块是由一个字的头部,有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小,以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
- 显式空闲链表:一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。
7.10本章小结
本章主要介绍了有关内存管理的知识。详细阐述了hello程序是如何存储,如何经过地址翻译得到最终的物理地址。介绍了hello的四级页表的虚拟地址空间到物理地址的转换。阐述了三级cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理和动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个linux文件就是一个m个字节的序列:B0, B1, … , Bk, … , Bm-1。所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。
8.2 简述Unix IO接口及其函数
- Unix I/O 接口:
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
- Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
- 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
- 读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件:内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
- Unix IO函数:
- int open(const char *pathname,int flags,int perms):用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。成功返回文件描述符,失败返回-1。
参数:pathname,被打开的文件名(可包括路径名如"dev/ttyS0");flags,文件打开方式。 - int close(int fd):用于关闭一个被打开的的文件。成功返回0,出错返回-1。
所需头文件:#include <unistd.h>
参数:fd文件描述符 - ssize_t read(int fd, void *buf, size_t count):从文件读取数据。返回所读取的字节数、0(读到EOF)或-1(出错)。
所需头文件: #include <unistd.h>
参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。 - ssize_t write(int fd, void *buf, size_t count):向文件写入数据。返回值写入文件的字节数(成功)或-1(出错)
所需头文件: #include <unistd.h> - off_t lseek(int fd, off_t offset,int whence):用于在指定的文件描述符中将将文件指针定位到相应位置。成功返回当前位移,失败返回-1。
所需头文件:#include <unistd.h>,#include <sys/types.h>
参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)
8.3 printf的实现分析
图8-1 printf函数体
printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。printf用了两个外部函数,一个是vsprintf,还有一个是write。
图8-2 函数vsprintf
vsprintf函数作用是接受确定输出格式的格式字符串fmt(输入)。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
图8-3 write
write函数的作用是将buf中的i个元素写到终端。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用int 0x80或syscall等。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
图8-4 getchar函数体
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章节主要介绍了Linux的IO设备管理方法和开、关、读、写、转移文件的Unix IO接口及其函数,深入分析了printf和getchar函数的实现。
结论
hello的一生经历:
- 首先我们编写一个简单的hello.c源程序。
- hello.c被预处理hello.i文件
- hello.i被编译为hello.s汇编文件
- hello.s被汇编成可重定位目标文件hello.o
- 链接器将hello.o和外部文件链接成可执行文件hello
- 在shell输入命令后,通过exceve加载并运行hello
- 在一个时间片中,hello有自己的CPU资源,顺序执行逻辑控制流
- hello的VA通过TLB和页表翻译为PA
- 三级cache 支持下的hello物理地址访问
- hello在运行过程中会有异常和信号等
- printf会调用malloc通过动态内存分配器申请堆中的内存
- shell父进程回收hello子进程,内核删除为hello创建的所有数据结构
深切感悟:
在学习深入理解计算系统之前,我们更多关注的是如何编写一个安全、高效的源程序。与hello共同走过它的一生,我感受到要使一个程序安全诞生直至平稳谢幕还有很长很长一段路要走,这其中需要软硬件等各工种的全力配合。分析hello的程序人生,可以让我们更加全面系统的了解计算机系统,掌握了这些最基本的原理,万丈高楼才能平地起。
附件
文件名称 | 作用 |
hello.c | 源代码 |
hello.i | 预处理生成的文本文件 |
hello.s | 编译生成的汇编文件 |
hello.o | 汇编生成的可重定位目标文件 |
hello | 链接得到的可执行目标文件 |
注:论文分析过程中还进行了反汇编和查看ELF等,但并没有将其形成文件,主要的内容作用均可在前文相应章节中找到。
参考文献
[1] 兰德尔 E 布莱恩特,大卫 R 奥哈拉伦. 深入理解计算机系统. 北京:机械工业出版社,第三版.
[2] Dylan_Cai. 编译过程及数据类型(一). CSDN:https://blog.csdn.net/Dylan_Cai/article/details/105034879
[3] 芮小谭. Linux中的readelf命令. CSDN:http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 请给一杯柠檬水. 学习笔记(1):编译的基本概念. CSDN:https://blog.csdn.net/weixin_44195971/article/details/103343836.
[5] Pianistx. [转]printf 函数实现的深入剖析. CSDN:https://www.cnblogs.com/pianist/p/3315801.html.