计算机系统
大作业
计算机科学与技术学院
2024年5月
本文利用计算机系统知识对hello的一生进行了漫游,从预处理、编译、汇编、链接、进程管理、存储管理与IO管理几个方面,对hello程序以及相关计算机系统知识进行了分析与阐述。进一步加深对编译系统中各部分功能的理解,并且梳理了关于异常与进程、虚拟内存、系统级I/O等部分的知识点。
关键词:计算机系统;编译系统;进程管理;存储管理;I/O管理
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
(1)P2P的过程:
P2P,即From Program to Process,指的是从程序员利用编辑器编写好程序代码后,将程序转化为进程的过程。这一过程通常是由编译系统和进程管理共同实现的。
编译系统包括预处理器、编译器、汇编器和链接器等组成部分,并提供编译驱动程序,能够代表用户在需要时调用预处理器、编译器、汇编器和链接器。接下来,以GNU编译系统为例,描述GCC驱动程序将程序员利用ASCII码文本编辑的程序代码(Program)一步步转化为二进制文本的可执行程序的过程。
预处理器根据原始C程序中的预处理指令,向源文件中插入系统头文件等文本,产生修改后的ASCII文本hello.i。编译器将hello.i翻译成汇编语言程序hello.s,再由汇编器将其翻译成机器语言指令,生成可重定位目标程序hello.o。链接器以一组可重定位目标程序和命令行参数作为输入,输出完全链接的、可以加载和运行的可执行目标文件hello。通过编译系统完成了了从程序到可执行目标文件的转化。
可执行目标文件hello以二进制文件的形式存储在磁盘中,运行hello时,我们需要在命令行中输入./hello。由于这并不是内置指令,进程管理利用fork()函数为执行hello创建了新的子进程(Process),并利用exceve()函数加载可执行的hello程序,shell调用加载器loader,从磁盘中读取hello并将可执行文件hello的代码和数据复制到子进程的内存中,将控制转移到程序的开头,即完成了从Program到Process的转变。
(2)020的过程:
020,即 From Zero to Zero,是指hello程序在运行前不占据内存,运行后也会清除内存的过程,即从无到有再到无。
在准备过程中,进程管理首先会为hello程序fork一个新的子进程,并将hello程序从磁盘读取到内存中,将子进程的当前进程影像替换为hello程序的进程映像。操作系统会为其分配虚拟内存,并映射到物理内存,实现了从无到有的过程。在执行过程中,CPU会利用三级cache和TLB等进行指令或数据的读取,并执行这些指令,并调用I/O设备进行相应的输出,有了更多资源。当程序运行完毕后,子进程发送信号给父进程,并由父进程回收子进程的资源,操作系统把子进程移除,释放其占用的内存等资源,实现了从有到无的过程。
因此,hello程序在运行过程中就是进程、资源等020的过程。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2GRAM;256GB SSD
软件环境:Windows11 64位;Vmware 16;Ubuntu 20.04 64位;
开发与调试工具:gcc;edb;gdb;readelf;objdump等
1.3 中间结果
文件名 | 文件作用 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | hello.o输出的ELF格式文件 |
hello.asm | hello.o反汇编得到的汇编语言文件 |
hello | 链接后得到的二进制可执行文件 |
helloout.elf | hello输出的ELF格式文件 |
Helloout.asm | hello反汇编得到的汇编语言文件 |
1.4 本章小结
本章首先介绍了对hello的一生有高度概括性的P2P与020的概念,同时介绍了开发软硬件环境、工具以及过程中产生的中间文件结果。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理是指预处理器根据以#字符开头的命令,修改原始的C程序得到修改后的C程序的过程,通常是以.i为文件扩展名。通过GCC编译器调用预处理器cpp,将hello.c文件处理后生成hello.i文件,cpp对hello.c开头中读取系统头文件的指令部分做了处理,将系统头文件直接插入程序文本中,得到修改后的C程序,hello.i也是ASCII文本,可以用文本编辑器打开查看。
作用:
(1)处理#include指令,将系统头文件直接插入到文本中
(2)删除所有注释
(3)删除#define指令,展开所有的宏定义
(4)处理条件预编译指令,比如#if、#ifdef、#elif、#else、#endif
(5)添加原程序中对应的行号和文件标识
(6)保留#pragma编译器指令
2.2在Ubuntu下预处理的命令
由于要求使用gcc –m64 –no-pie –fno-PIC作为编译指令,因此预处理生成hello.i的指令为:gcc –m64 –no-pie –fno-PIC -E hello.c -o hello.i,文件夹中生成了预处理器输出的结果hello.i。
图1 预处理指令及输出结果
2.3 Hello的预处理结果解析
由于hello.c与hello.i都是ASCII文本,所以都可以用文本编辑器来打开查看。
Hello.c的内容如下图所示:
图2 hello.c文本内容
hello.i的内容如下图所示:
图3 hello.i文本内容
从图中可以看出,预处理删除了源程序中1-5行的所有注释,并且处理了所有#include预编译指令,将对应的系统头文件直接插入了文本中(如stdio.h),导致文本文件由23行增加到了3061行。将源程序中的main函数原样保留并修改了缩进,放在文件的最末尾,并且预处理器还为程序添加了行号(如main函数前的#11,表示处于源程序的11行)。
2.4 本章小结
本章重点介绍了在编译hello.c文件过程中,预处理指令的概念及作用,展示了预处理指令及输出的hello.i文件,并对原C程序和修改后的C程序的内容进行了比对,分析了其中各要素的关系及预处理器所作出的处理,进一步体现了预处理器的作用。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译是指编译器将C语言编写的文本文件hello.i翻译成汇编语言文件hello.s的过程,依旧可以使用文本编辑器来查看。其汇编语言程序中,每条语句都以一种文本格式描述了一条或多条低级机器语言指令,为不同高级语言的不同编译器提供了通用的输出语言。
作用:
(1)将更接近语言的高级语言程序翻译为更接近机器语言的汇编语言程序
(2)为不同高级语言的不同编译器提供了通用的输出语言
(3)每条语句都描述了低级机器语言指令,便于程序员查看低层机器实现的逻辑
(4)编译器会对程序进行优化,从而提升程序运行效率。
3.2 在Ubuntu下编译的命令
在预处理完的基础上,利用命令gcc –m64 –no-pie –fno-PIC -S hello.i -o hello.s进行编译,并在文件中生成编译后的汇编语言文件hello.s。
图4 编译指令及生成的文件
3.3 Hello的编译结果解析
由于hello.s也可以用文本编辑器打开,查看并分析编译器对C语言中各种数据类型与操作的处理方式。hello.s的内容如下图所示:
图5 hello.s的内容
3.3.1字符串常量
图6 .LC0中的字符串常量
.LC0中在“.string”后面声明了一个字符串常量,数据存储在.rodata后面,说明这是只读数据存储区。
3.3.2整型常量
图7 整型常量
以“$ + 整型数字”的形式将整型变量存储为立即数,例如C程序中的语句:
- if(argc!=5){
其中的整型常量5,在.LFB6中以立即数$5的形式表示。
3.3.3参数
hello.c中只有main一个函数,其两个参数分别为int argc和char *argv[],即整型变量argc和字符指针数组argv。这两个参数分别应存储在寄存器ebi与寄存器rsi中。在C语言中,这是两个特殊的参数,他们用于从命令行接受参数。argc是一个整数,存储了从命令行传递给程序的参数的数量,其中程序名被认为是第一个参数。argv是一个字符指针数组,每个字符串表示一个命令行参数,argv[0]中存储的是程序名。程序通过对参数argc来进行判断相当于对命令行参数的个数进行判断。
图8 参数
从图中可以看出,main函数中对参数进行了进一步处理,将这两个参数都压入栈,分别存在-20(%rbp)与-32(%rbp)的位置。cmpl指令将-20(%rbp)与常量5进行比较,正好对应原C程序中的语句:
- if(argc!=5){
进一步验证了,edi寄存器中存储的值就是第一个参数argc,并且这条语句是为了判断传入命令行参数是否为5。
3.3.4局部变量
原C程序中的局部变量只有int类型的变量i,分析会汇编代码得出,汇编代码对局部变量声明的顺序与C程序不同。C程序在main函数一开始就声明了变量i,但是没有对其赋初值,而是直到进入循环才第一次赋初值:
图9 hello.c中的局部变量
而汇编代码对局部变量的声明进行了修改,只有argc==5即命令行参数为5,跳转到.L2后,才声明变量i,将其存储在栈中-4(%rbp)的位置,并将其值初始化为0,,随后在.L3与.L4中对其进行各种操作。
图10 .L2中声明变量i
图11 .L3与.L4中对变量i进行操作
3.3.5非静态函数
hello.c程序只有main一个函数,并且为非静态函数,hello.s利用.globl将非静态函数main声明为了全局符号,表示其可以被其他模块引用。
图12 非静态函数
3.3.6赋值操作符
main函数中只有一处赋值操作,是在循环中对变量i进行初始化,C程序中的语句为:
- for(i=0;i<10;i++){
汇编程序hello.s中对应的赋值语句是:
图13 赋值操作
由局部变量分析可知,-4(%rbp)位置中存储着整型局部变量i,因此这条语句实现了将i赋值为0,通过mov操作,将立即数0放入对应内存位置中实现。i是int类型数据,占4B,因此使用movl操作,表示目的内存中存储的是双字变量。
3.3.7类型转换操作
程序中调用atoi函数将字符指针类型的参数argv[4]强制转换为int类型,作为sleep函数的参数。
- sleep(atoi(argv[4]));
在汇编语言中可以找到相应的操作:
图14 类型转换操作
首先将参数的值传入rdi寄存器,作为参数调用atoi函数,并且在eax寄存器中获得atoi函数返回的int类型数据,自此实现了强制类型转换,得到了可以作为sleep函数参数的整型数据。
3.3.8算数操作
程序中的算数操作主要是在循环中对变量i进行自增操作,原语句为:
- for(i=0;i<10;i++){
hello.s中是通过add指令实现的上述操作:
图15 算数操作
该指令的意思是,从内存-4(%rbp)的位置取出数据(i),将其+1,并将结果写回该内存位置中,由于该位置存储的是整型变量,因此使用addl指令,实现了自增运算操作。
3.3.9关系操作
程序中有两处关系操作,原语句分别如下:
- if(argc!=5){
- for(i=0;i<10;i++){
hello.s文件中对应的汇编代码分别如下:
图16 关系操作
cmpl指令将栈中-20(%rbp)位置的数据与5作比较,由参数分析可知,此处存放的是参数argc,因此相当于将argc与5进行比较。je指令实现如下功能——如果argc与5相等即命令行参数为5,则跳转到.L2中,否则继续执行下面的代码。通过进一步分析可知,.L2中对应的是argc==5情况下的C语言代码,下文中有对第五个参数argv[4]的引用。而继续执行的代码对应的是argc!=5情况下的代码,因此实现了关系操作,并实现了if语句的分支。
类似,将-4(%rbp)处的数据,即局部变量i与9进行比较,如果i<=9,则跳转到.L4中,否则继续执行下面的操作。
3.3.10数组操作
程序在调用printf时访问了三个数组元素,分别为argv[1]-argv[3],即执行程序时命令行输入的第2-4个参数。对应的汇编代码如下:
图17 数组操作
由参数分析可知,汇编程序将指向数组argv的指针存储在-32(%rbp)位置中,首先从该位置中读出数组指针,指向数组的第一个元素,将其值存在%rax中,分别访问rax+24、rax+16、rax+8这三个指针,分别是指向argv[3]-argv[1]的指针,读取指针指向的内存位置,相当于分别读取了argv[3]-argv[1]。
3.3.11控制转移
程序中有两处控制转移操作,分别为if语句与for循环,原代码如下:
- if(argc!=5){
- for(i=0;i<10;i++){
hello.s文件中对应的汇编代码分别如下:
图18 控制转移
由关系操作可知,cmpl与je共同完成了对于argc是否等于5的判断。je指令实现如下功能——如果argc与5相等,则跳转到.L2中,否则继续执行下面的代码。通过进一步分析可知,.L2中对应的是argc==5情况下的C语言代码,而继续执行的代码对应的是argc!=5情况下的代码,实现了if语句的控制转移。
类似,cmpl与jle共同完成了对i 是否小于等于9的操作,如果i<=9,则跳转到.L4中,否则继续执行下面的操作,实现了for循环的控制转移。
3.3.12函数操作
程序中共有main、printf、exit、atoi、sleep、getchar六个函数。
- main函数
main函数的两个参数分别是argc与argv,见参数分析。通过使用call内置指令来调用,并且在main函数内部调用了另外五个函数。
main函数中声明了一个局部变量i,见局部变量分析。
(2)printf函数
Main函数中两次调用printf函数,第一次只传了一个字符串常量做参数,汇编代码调用puts来实现:
图19 printf函数1
第二次调用printf函数,分别传入了argv[3]-argv[1]三个数组元素,将其值分别存储在rcx、rdx、rsi寄存器中,相当于访问参数argv[3]-argv[1]的值,并将其倒序传入寄存器中作为参数调用printf函数。并传入了.LC1中的字符串常量作为参数,具体实现如下:
图20 printf函数2
- exit函数
exit函数只有一个常量1作为参数,将立即数1存储到寄存器edi中,作为参数调用exit函数:
图21 exit函数
- atoi和sleep函数
首先将参数的值传入rdi寄存器,作为参数调用atoi函数,并且在eax寄存器中获得atoi函数返回的int类型数据,作为sleep函数参数的整型数据再次传入edi寄存器中,并且调用sleep函数。
图22 atoi函数与sleep函数
- getchar函数
由于getchar函数没有参数,因此当判断for循环结束后,直接调用getchar函数,不需要进行参数传递。
图23 getchar函数
3.4 本章小结
本章介绍了将hello.i文件编译为hello.s文件过程中,编译的具体概念及作用,并且展示了编译的命令和输出的文件。
详细展示了hello.s文件中各部分的内容,并且从字符串常量、整型常量、参数、局部变量、非静态函数、赋值操作、类型转换、算数操作、关系操作、数组操作、控制转移和函数操作12个方面来详细比较了源代码与汇编代码之间的关系和区别,解释了汇编代码是如何实现源代码的功能的,详细分析了编译结果。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编是指汇编器as将汇编语言文件hello.s翻译成机器语言指令,并将其打包成为可重定位目标文件的格式,并将结果保存在目标文件hello.o中。由于hello.o是一个二进制的机器语言文件,因此无法用文本编辑器打开。
作用:
将汇编语言翻译成机器可以直接识别并执行的二进制机器语言,并生成二进制的可重定位目标程序。
4.2 在Ubuntu下汇编的命令
汇编的指令为gcc –m64 –no-pie –fno-PIC -c hello.s -o hello.o,文件夹中生成了预处理器输出的结果hello.o,并且显示是不可用文本编辑器打开的格式。
图24 汇编指令
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
通过readelf -a hello.o > hello.elf指令将hello.o输出到hello.elf文件中。
图25 生成elf文件指令
ELF文件是目标文件的标准二进制格式,广泛应用于现代linux和unix操作系统中,包含程序的可执行代码、数据和其他信息,其格式如下图所示:
图26 elf文件格式
打开hello.elf文件,可以看到elf文件中标注出了各部分,分别对其进行分析。
- ELF头
图27 elf头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
例如,hello.elf文件的ELF头部分描述了如下信息:系统字的大小为2B、采用补码和小端序表示、目标文件类型为REL可重定位文件、ELF文件的版本号为当前版本、节头部表的文件偏移是1224B、ELF头的长度为64B、节头部表的大小为64B、节头部表条目的大小为14。
- 节头
图28 节头
节头部表描述了ELF文件中各节的名称、类型、大小、地址、全体大小、偏移、权限、旗标、链接、信息、对齐等信息。
(3)重定位节
图29 重定位节
重定位节包括rela.txt和rela.eh_frame两个节,rela.text节是一个.text节中位置的列表,当链接器将这个目标文件与其他文件组合时,需要修改这些位置,.rela.eh_frame节包含了对eh_frame节的重定位信息,重定位节中存储了符号的偏移量、信息、类型、符号值和符号名称。
(4)符号表
图30 符号表
符号表中列举出了程序中所有定义和引用符号的信息,包括三种符号:全局符号(非静态的C函数和全局变量)、外部符号(引用的其他模块定义的全局符号)、局部符号(静态的C函数和全局变量),并且不包含对应于本地非静态程序变量的任何符号,这些符号在运行中由栈进行管理,链接器并不进行处理。
.symtab节中的符号表中含有17个条目,列出了文件、各节、和各种函数的信息,由于程序中只有一个局部变量i没有全局变量,因此符号表中并没有变量对应的符号。
4.4 Hello.o的结果解析
使用指令:objdump -d -r hello.o 来对hello.o执行反汇编,并将其得出的结果与编译生成的hello.s进行对照,分析其中的区别和关系。
图31 反汇编指令
图32 hello.s内容1
图33 反汇编内容
- 最首要的区别是,反汇编生成的文件中,在每一行汇编指令的前面都加上了对应的机器语言指令,并且在机器语言指令前加上了指令的起始地址(偏移量)。
-
数制发生了变化,汇编代码中的立即数和操作数都以十进制的形式表示,而反汇编得到的代码中,立即数和操作数都以十六进制的形式表示。
图34 数制变化
- 分支转移时,目标地址的位置表示发生了变化。在原汇编代码中,采用段名称表示目标跳转地址,而在反汇编代码中,直接使用地址+段内偏移给出目的跳转地址。
图35 目的跳转地址变化
-
常量读取与函数调用发生了变化。汇编代码调用字符串常量时,直接调用.LC0,而反汇编代码中对应.rodata部分的重定位条目R_X86_64_32,在进行参数调用时要常量的地址进行修改。读取常量的方式发生了变化,但是传参的方式没有改变,都是使用寄存器edi。进行函数调用时,汇编代码中的函数调用在反汇编代码中要通过PLT进行重定位,因此标注了R_X86_64_PLT32条目,并且在callq语句中增加了返回地址。
图36 常量读取与函数调用
(5)在汇编代码中,有.cfi_*开头的指令,这些指令是调试信息,指示编译器如何处理栈帧,因此在反汇编代码中直接删掉了这些语句。
图37 调试信息
4.5 本章小结
本章介绍了汇编的概念和作用,并利用汇编指令将hello.s生成了可重定位目标文件hello.o文件。通过readelf读出hello.elf文件,对ELF文件格式进行了详细的阐述,并且解释了hello.elf文件中各部分中所包含的信息及其内容,包括ELF头、节头部表、重定位节和符号表,来阐述每个节的性质、信息和内容。
利用objdump对hello.o进行反汇编,将其内容与原始汇编程序hello.s中的内容进行详细的对比分析,并分析其对照关系,阐述了其中的区别和原因,对于汇编的作用有了进一步的了解。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是指链接器将多个已经预编译好的.o可重定位目标程序合并成一个可执行目标文件的过程,这个文件可以被加载到内存中由系统执行。
作用:
- 分离编译:链接是由链接器自动执行的,链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将大型的应用组织为一个巨大的源文件,而是可以把它们分解成更小、更好管理的模块,可以独立地进行修改和编译这些模块,我们改变这些中的一个时,只需要重新编译这一个模块并重新进行链接,而不必重新编译其他文件。
- 符号解析:编译过程中可能需要将多个可重定位目标文件进行链接,链接器负责解析变量和函数名符号的引用,使符号在多个目标文件之间进行正确的链接和执行。
- 重定位:可重定位目标文件中的代码段和数据段采用的是各自的相对地址(相对于段的起始地址),链接器将这些地址转换为绝对地址,确保在将多个目标文件链接为一个程序后能够正确的执行每一个文件中的功能。
(4)消除冗余代码:链接器将多个目标文件合并成为一个可执行文件,包含所有的代码和数据信息,同时进行一定的优化,移除冗余的操作和代码,保证程序能够正确高效运行。
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
在文件夹中生成了链接后生成的可执行目标文件hello。
图38 链接指令
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
通过readelf指令来输出elf文件helloout.elf,用文本编辑器查看并分析其各段的基本信息。
图39 生成helloout.elf文件
- ELF头
图40 elf头
与hello.elf相同,helloout.elf的ELF头同样以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
helloout.elf文件的ELF头部分描述了如下信息:系统字的大小为2B、采用补码和小端序表示、目标文件类型为EXEC可执行文件、ELF文件的版本号为当前版本、节头部表的文件偏移是14208B、ELF头的长度为64B、程序头的大小为56B,包含的条目的大小为12、节头部表的大小为64B,节头部表条目的大小为27且索引为26。
- 节头部表
图41 节头部表
节头部表描述了ELF文件中各节的名称、类型、大小、地址、全体大小、偏移、权限、旗标、链接、信息、对齐等信息。
- 程序头表
图42 程序头表
hello.elf文件中没有程序头,helloout.elf的程序头表包含多个程序头,每个程序头描述了文件中的一个段。段是可执行文件中的逻辑区块,可能包含代码、数据或其他信息。程序头表中包含的信息有段的类型、偏移量、虚拟地址、物理地址、文件大小、占用内存大小、权限标志和对齐方式等信息。
(4)段节表
图43 段节表
(5)动态段
图44 动态段
动态段中包含了与动态链接有关的信息,包括共享库、符号表和重定位表等,确保程序在运行时能够正确加载和解析所需的共享库和符号,从而实现动态链接的功能。
(6)重定位节
图45 重定位节表
(7)符号表
图46 符号表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,加载后edb的界面如下图所示:
图47 edb加载hello
通过ELF文件,可以查看各段的地址。
图48 虚拟地址空间分析
打开edb通过bookmarks查看虚拟地址各段信息,并将其与elf文件中的虚拟地址进行比对。通过比对发现,edb中显示的各段的虚拟地址,与elf文件中标注的虚拟地址是一一对应的。
5.5 链接的重定位过程分析
5.5.1反汇编分析
执行指令objdump -d -r hello得到hello反汇编的代码,并与hello.o反汇编的到的代码进行对比,对比结果如下:
- 分配虚拟内存地址
图49 分配虚拟内存地址
hello.o对应的反汇编程序中,指令前的地址都是相对于函数main开始的相对地址,经过链接后,指令前的地址修改为CPU可以寻址的虚拟内存地址,说明链接为每个指令都分配了新的虚拟内存地址。
(2)增加函数
图50 链接增加函数
经过链接,反汇编代码中多了许多原来没有的函数,包括init、.plt等系统函数,也增加了在main函数中调用的puts、atoi、printf、getchar等函数,并且也为这些函数分配了相应的虚拟内存地址,确保在执行程序的过程中,能够正常的调用这些函数。
(3)符号解析和函数调用
图51 符号解析和函数调用
hello.o中包含了未解析的符号(如外部函数调用),因此这些符号需要进行重定位,因此在反汇编程序中标注了R_X86_64_PLT32条目。经过链接,hello中的所有符号已经被解析并且重定位完成,链接器将目标文件中未解析的符号替换函数对应的虚拟内存地址和偏移量,保证程序在运行过程中可以正确地找到这些符号并执行函数内部的操作。
- 内存地址访问
图52 内存地址访问
对某些常量的访问,在链接后变成了对应的虚拟地址,通过gdb调试可知,0x402008内存地址中存储的就是0x0。
(5)目标跳转地址
图53 目标跳转地址
经过链接后,跳转指令的目标跳转地址也由相对main的相对指令地址变为可以由CPU直接寻址的虚拟内存地址。
5.5.2重定位的过程
(1)重定位节和符号定义。链接器将所有相同类型的节合并为同一类型的新的聚合节。不同的可重定位目标文件中的.data有不同的内容和位置,链接器将这些节进行重定位,得到hello中的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。
(2)重定位节中的符号引用。链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确运行时的地址。这个过程中链接器依赖于hello.o中的重定位条目。
5.6 hello的执行流程
使用gdb执行hello,将主要函数都打上断点,来观察程序中函数执行的顺序。以argc!=5为例,执行程序时不给出除了程序名外的其他参数,因此argc=1。下面是利用gdb调试得到的函数执行顺序信息:
图54 gdb调试信息
通过上面的调试信息可以得知,程序首先执行_init,随后调用_start系统函数,在系统函数中会进入__libc_start_main函数中,然后进入__libc_csu_init函数中,函数会再一次调用_init,然后进入main函数,由于输入的argc!=5,因此会调用puts函数,随后调用exit函数,最后执行_dl_fini和_fini函数退出程序。
当执行程序时一共输入五个参数即argc==5时,main函数会调用printf函数、atoi函数、sleep函数、getchar函数,并最终调用_dl_fini和_fini结束程序。
子程序名与程序地址:
程序名 | 程序地址 |
_init() | 0x401000 |
_start() | 0x4010f0 |
libc_start_main() | 0x7ffff7c29dc0 |
__libc_csu_init() | 0x4011c0 |
main() | 0x401125 |
puts() | 0x401090 |
exit() | 0x4010d0 |
printf() | 0x4010a0 |
atoi() | 0x4010c0 |
sleep() | 0x4010e0 |
getchar() | 0x4010b0 |
_dl_fini() | - |
_fini() | 0x401238 |
表1 程序名与地址
5.7 Hello的动态链接分析
在调用共享库函数时,编译器不能预测这个函数的运行时的地址,因此要为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。延迟绑定是通过GOT和PLT实现的。在hello输出的ELF格式文件中,可以在节头部表中找到.got段。
图53 got段
GOT是数据段的一部分,PLT是代码段的一部分。GOT是由地址构成的数组,每个元素为8个字节,在和PLT使用进行动态链接时,GOT[0]和GOT[1]包含动态链接器在解析函数地址会使用的各种信息。GOT[2]是动态链接器在ld-linux.so模块的入口点,其每个条目对应一个被调用的一个函数,地址在函数使用时被解析。
在动态链接前后,GOT段的内容如下图所示。
- 动态链接前 (b)动态链接后
图54 动态链接前后对比
5.8 本章小结
本章介绍了连接的概念和作用,并通过链接指令将多个可重定位目标文件作为输入,得到了最终的可执行文件hello。
利用readelf工具生成ELF文件helloout.elf,并分析了ELF文件中各部分的主要内容及作用。用edb查看了hello的虚拟地址空间,并发现ELF文件中各节的地址与edb中显示的虚拟地址一一对应。
对hello进行反汇编,得到反汇编程序并与hello.o得到的反汇编程序进行比较,分析其区别以及链接产生这些区别的原因,并对链接中符号解析和重定位进行了分析,着重叙述了重定位的过程。
通过gdb调试了解了hello程序执行过程中调用函数的流程及各函数的地址,并利用edb对hello执行过程中的动态链接进行了分析。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存里的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。
进程是操作系统对正在运行的程序的一种抽象,是对处理器、主存和I/O设备的抽象表示。在一个系统上可以同时运行多个进程,而每个进程都好像在独占硬件。
作用:
(1)独占运行:在现代系统上运行hello时,我们会得到一个假象,就好像系统上只有这个程序在运行,程序看上去是独占地使用处理器、主存和I/O设备。处理器看上去就像在不间断地一条接一条地执行hello程序中的指令。
(2)资源管理:操作系统通过进程来管理、分配和回收相应的资源,使进程之间的资源相对独立,在并发执行程序的过程中不会相互干扰。
(3)并发运行:无论是单核还是多核系统处理器都可以通过上下文切换来交错执行进程的指令,从而展现出CPU看上去是在并发地执行多个进程的假象。
(4)提供抽象:进程是操作系统的提供的三个基本抽象之一,是对处理器、主存和I/O设备的抽象表示。
6.2 简述壳Shell-bash的作用与处理流程
shell 是一个交互型应用级程序,代表用户运行其他程序。Shell是信号处理的代表,负责各进程创建与程序加载运行及前后台控制,作业调用,信号发送与管理等,是一个交互型应用级程序,代表用户运行其他程序。
Bash是许多linux系统的默认shell程序,是GNU项目的一部分,同时比sh提供了更多的功能。
shell的处理流程:
- 读取用户的命令行,解析命令行参数,如果是空命令行则直接跳过
- 判断命令是否是内置指令,如果是则立即执行
- 如果命令并非内置指令,则利用fork创建一个新的子进程,利用execve将程序的代码和数据等信息映射到子进程中,在子进程中执行新任务
- 如果命令是前台任务则等待其执行完毕并返回,如果是后台任务则将其放在后台执行。
- 在任务执行过程中,还可以对异常与信号进行处理,并且做出相应的反应。
6.3 Hello的fork进程创建过程
在执行hello时,输入的正确命令行参数应为
- ./Hello 学号 姓名 手机号 秒数
因此shell会读取并解析命令行,发现这并非一个内置指令,并通过fork创建新的子进程。父进程通过调用fork函数创建一个新的运行的子进程,fork函数被调用一次,却返回两次,子进程返回0,父进程返回子进程的PID。
新创建的子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本(代码、数据段、堆、共享库以及用户栈),但子进程有不同于父进程的PID。
6.4 Hello的execve过程
Execve 是一个系统调用,用于在当前进程的地址空间中执行一个新的程序。与execve不同,execve不创建新的进程,而是用新的程序替换当前进程的代码段、数据段、堆栈段,但保留进程 ID 和一些属性。首先进程调用execve函数,传递程序的路径、参数列表和环境变量列表信息。内核根据路径查找可执行文件,将其从磁盘复制到内存中,并加载到进程的地址空间中。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。
运行时,创建一个内存映像,在程序头部表的引导下,加载器将可执行文件复制到代码段和数据段并跳转到程序的入口,_start函数调用系统启动函数,_libc_start_main,初始化环境,调用用户层的main函数,执行main函数中的内容。
6.5 Hello的进程执行
上下文信息:是内核重新启动一个被抢占的进程所需的状态,由一些对象的值组成,包括寄存器、PC、栈和各种内核数据结构。
进程时间片:是操作系统分配给每个进程的CPU时间段。在时间片轮转调度中,每个进程被分配一个固定长度的时间片来运行。如果时间片结束时进程还未结束,则该进程会被暂停,CPU先执行下一个进程,实现进程之间的切换。
用户态:进程处于用户模式中,此时进程不允许执行特权指令,也不允许直接饮用地址空间中内核区的代码和数据。
核心态:进程处于内核模式中,可执行指令集中的任何指令,可以访问系统中任何内存位置。
用户态与核心态的转换:当异常发生时,控制传递到异常处理程序,进程从用户模式转化为内核模式;当它返回到应用程序代码时,再转换到用户模式。
图55 进程调度
通过上面这些处理,系统可以正常的调用并执行hello程序,以图55为例解释hello的进程调度过程。
Hello进程创建后等待执行,当hello被CPU选中执行后,进入hello的时间片并执行程序,时间片结束后,如果hello还未执行完,则保存上下文信息,CPU先执行下一个进程,再次轮到hello进程时再恢复上下文信息。正常执行过程中,hello进程处于用户态,当异常发生时,控制传递到异常处理程序,由用户态转换为核心态。
6.6 hello的异常与信号处理
正常运行程序的情况:
图56 正常运行
- 乱按
Shell会将乱按的内容都会被缓存,在执行完hello后将缓存中的每一行当成命令行来执行。
图57 乱按
- ctrl+z
按下ctrl+z会显示进程已停止,hello进程被挂起。由于hello是前台进程,按下ctrl+z后会向hello进程发送SIGTSTP信号,暂停进程的执行,在CPU的进程调度中暂停hello的时间片。
输入ps可以查看当前进程,发现hello进程在列,说明hello只是被挂起、暂停执行,而非被结束并删除。
执行jobs指令输出作业列表中的所有作业信息,可以看到hello进程也在其中,并且状态信息为已停止。
执行fg,向前台进程hello发送SIGCONT信号,继续执行前台进程hello。可以看到输入fg后hello继续执行。暂停之前hello输出了2次,继续之后hello输出了8次,一共10次,由此可以判断ctrl+z是让前台进程暂停,并且输入fg之后是继续进行而非重新执行。
图58 ctrl+z
在输入ctrl+z后,还可以执行pstree和kill指令。
执行pstree后将所有进程以树状图的形式显示:
图59 pstree指令
执行kill指令可以将hello进程杀死,但是子进程还在等待父进程回收:
图60 kill指令
- ctrl+c
与输入ctrl+z时的情况进行对比,输入ctrl+c后,向前台进程hello发送SIGINT信息,结束进程的执行。
输入ps发现当前进程中没有hello,而jobs和fg的执行结果都显示当前没有前台进程,由此可以判断输入ctrl+c后直接结束了当前进程,并且回收了子进程。
图61 ctrl+c
6.7本章小结
本章首先介绍了进程的概念与作用,并且分析了shell的作用与处理流程,给出了shell从取命令行到执行命令行的过程。随后具体分析了hello执行过程中,利用fork创建子进程的过程与利用execve加载进程,简述hello的创建、加载、执行、调度和终止。
在hello执行过程中,通过键盘的不同操作向进程发送不同的信号,进程产生了不同的反应。对于异常产生的信号以及进程对应的行为进行了分析。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
(1)逻辑地址:
在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。
将hello.o进行反汇编得到的文件中,其中调用函数以及做目的跳转地址时使用的是逻辑地址:
图62 逻辑地址
(2)线性地址:
是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,段中偏移地址加上段的基地址就是线性地址。
(3)虚拟地址:
虚拟地址是Windows程序时运行在386保护模式下,程序访问存储器所使用的逻辑地址称为虚拟地址,在执行过程中由操作系统将其转换为真正的物理地址。
将hello进行反汇编得到的文件中,调用函数以及做目的跳转地址时使用的是虚拟地址:
图63 虚拟地址
(4)物理地址:
是在地址总线上,以电子形式存在的,使得数据总线可以访问主存的某个特定存储单元的内存地址。在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个独立的存储器地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在段氏管理中,一个程序被分为多个段,每个段都用段名来标识,段由基地址,内部的地址可以通过偏移量来表示。一个逻辑地址由两部分组成——段描述符和段内偏移量。段描述符的结构如下图所示:
图64 段描述符
段描述符的高13位是索引号;TI位用来进行描述符表的选择,TI=0时表示选择GDT,从GCT中取出一个描述符,TI=1时表示选择LDT;RPL字段表示请求的特权级别。
系统中有一个全局描述符表GDT,存储系统全局的段描述符,还有一个局部描述符表LDT,存储特定进程的段描述符。被选中的段描述符会先被送至描述符cache,当TI=0时,从全局描述符表中取出相应的段描述符,并获取32位段基址。利用段基址和逻辑地址中的段内偏移量相加就可以获得计算后的线性地址。当TI=1时,则从LDT中选择匹配的段描述符进行计算。
图65 获取段基址
7.3 Hello的线性地址到物理地址的变换-页式管理
页表就是一个页表条目的数组,将虚拟地址页映射到物理地址页,每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。
虚拟内存的管理是通过页表来完成的,虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE,每个PTE包含一个有效位和一个物理页号。有效位指示了该虚拟页是否当前被缓存在DRAM中,如果有效位是1,物理页号就表示了DRAM中相应物理页的起始位置。发生缺页时,系统需要从磁盘中读取相应的数据。
n位的虚拟地址包含两部分:p位的虚拟页面偏移VPO和虚拟页号VPN。MMU利用VPN来选择适当的PTE,将页表条目中的物理页号和VPO串联起来就是相应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是翻译后备缓冲器,用于加速地址的翻译。MMU使用虚拟地址的VPN部分来访问TLB,查找虚拟地址的条目。
图65 TLB
如果TLB命中,则直接得到物理地址,减少内存访问;TLB不命中时会产生额外的内存访问,通过多级页表查找。
- TLB命中 (b)TLB不命中
图66 访问TLB
多级页表用于减少常驻于内存中的页表大小,将VPN划分为相同大小的不同部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。TLB未命中时,使用多级页表来查找,步骤如下:
(1)从一级页表PML4中使用虚拟地址的最高级部分索引,获取指向下一级页表的物理地址。
(2)从二级页表PDP中使用虚拟地址的下一级部分索引,获取指向下一级页表的物理地址。
(3)从三级页表PD中使用虚拟地址的次级部分索引,获取指向下一级页表的物理地址。
(4)从四级页表PT中使用虚拟地址的最低级部分索引,获取最终的物理页框地址。
(5)将物理页框地址与虚拟地址中的页内偏移组合,形成完整的物理地址。
(6)将新的虚拟地址到物理地址的映射更新到TLB中,以便下次访问加快速度。
图67 多级页表
当MMU翻译每一个虚拟地址时,它还会更新另外两个内核缺页处理程序会用到的位。每次访问一个页时,MMU都会设置A位,称为引用位。内核可以用这个引用位来实现它的页替换算法。每次对一个页进行了写之后,MMU都会设置D位,又称修改位或脏位。修改位告诉内核在复制替换页之前是否必须写回牺牲页。内核可以通过调用一条特殊的内核模式指令来清除引用位或修改位。
core i7采用四级页表层次结构,每个进程有它自己私有的层次结构。下图给出了Core i7 MMU如何使用四级的页表来将虚拟地址翻译成物理地址。36位VPN被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供到一个Ll PTE的偏移量,这个PTE包含L2页表的基地址。VPN 2提供到一个L2 PTE的偏移量,以此类推。
图68 core i7页表翻译
7.5 三级Cache支持下的物理内存访问
得到物理地址后,将物理地址分为CT(缓存标记)、CI(缓存索引)和CO(块偏移)。根据CI查找对应的组并判断是否命中,从而返回对应物理地址中的数据。
具体过程如下:
(1)CPU发出访存请求。
(2)检查L1缓存,利用CI找到对应的组,再比较CT,若命中,即CT相同且有效位是1,则返回数据;如果未命中,则继续L2。
(3)检查L2缓存,如果命中,需要将其缓存在L1并返回,若有空闲块则存放在空闲块中,否则根据替换策略选择牺牲块。若未命中,则继续L3。
(4)检查L3缓存,找到后需要缓存在L1、L2并返回,替换策略同上。
(5)若数据不在L1、L2、L3中,则需要从主存中读取数据,加载到缓存中,再数据返回给CPU。
7.6 hello进程fork时的内存映射
调用fork函数时,创建一个新的运行的子进程,fork函数被调用一次,却返回两次,子进程返回0,父进程返回子进程的PID。子进程最初的内存空间是与父进程虚拟地址空间相同但是独立的一份副本,包括代码、数据段、堆、共享库以及用户栈,但子进程有不同于父进程的PID。
为了给这个新的进程创建虚拟内存,fork创建了当前进程的mm_struct、区域结构和页表的原样副本,将两个进程的每个页面都标记为只读,并将两个进程的每个区域结构都标记为私有的写时复制。当这两个进程中的任一个进行写操作时,写时复制机制就会创建新页面,从而为每个进程保持了私有空间地址。
7.7 hello进程execve时的内存映射
调用execve函数时启动加载器,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用可执行目标文件hello程序替换当前程序的地址空间。
execve内存映射的步骤:
(1)清空现有地址空间,删除当前进程虚拟地址中已存在的用户部分。
(2)映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,如7.6中所分析的,这些采用写时复制机制新写入的区域都是私有的。
(3)映射共享区域,hello程序与共享库libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
图68 execve函数
7.8 缺页故障与缺页中断处理
7.8.1缺页故障:
DRAM缓存不命中称为缺页。当MMU试图翻译某个虚拟地址A时,如果在TLB中找不到相应的页表项,说明发生了一个缺页故障异常,进而调用内核中的缺页异常处理程序。
7.8.2缺页中断处理:
缺页故障会导致CPU产生一个中断请求,这就是缺页中断。缺页中断触发操作系统的中断处理程序,处理缺页故障。
缺页中断处理的流程如下:
(1)检查虚拟地址是否合法,如果没有匹配到任何结果,说明地址A是不合法的,于是报出段错误并终止这个进程。
(2)检查内存访问是否合法,即对内存进行的读、写操作是否有权限,如果进行了非法的访问则触发保护异常并终止这个进程。
两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。恢复进程上下文,就可以重新执行引发缺页故障的指令,并对虚拟地址进行正常的翻译。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。对每个进程,内核维护着一个变量brk,它指向堆的顶部。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式的保留为供应程序使用。空闲块保持空闲,直到它被应用所分配。已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显示执行的,要么是内存分配器自身隐式执行的。
显示分配器要求应用显示地释放任何已分配的块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。
隐式空闲链表:空闲块通过头部中的大小字段隐含地链接着,分配器可以可以通过遍历堆中所有的块,从而间接的遍历整个空闲块的集合。
放置已分配的块:当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个大小足够放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的。一些常见的策略是首次适配、下一次适配和最佳适配。
(3)分割空闲块:一旦分配器找到一个匹配的空闲块,它就必须做另外一个决策决定,那就是分配这个空闲块中多少的空间。一个选择是用整个空闲块,当放置策略倾向于产生好的匹配,这一决策产生的额外内部碎片也是可接受的;当匹配不太好,分配器通常会选择将这个空闲块分割为两部分,第一部分变成分配块,剩下的变成一个新的空闲块。
(4)若分配器找不到合适的空闲块,一个选择是合并那些在内存中物理上相邻的空闲块来创建一些更大的空闲块。如果还是不能生成足够大的块,就会通过sbrk函数,向内核请求额外的堆内存。至于合并策略,分配器可以选择立即合并或推迟合并。
(5)显式空闲链表:根据定义,程序不需要一个空闲块的主体,所以实现显示空闲链表的指针可以存放在这些空闲块的主体里面。
(6)分离的空闲链表:维护多个空闲链表,其中每个链表中的块有大致相等的大小,从而减少分配时间。分离存储方法包括简单分离存储、分离适配、伙伴系统等方法。
7.10本章小结
本章首先介绍了hello的存储地址空间,随后以hello为例分别分析了intel的段式管理及逻辑地址到线性地址的变换、页式管理及线性地址到物理地址的变换。分别分析了指定情况下虚拟地址到物理地址的转换和物理内存访问及其具体流程。
分析了hello进程fork和execve时的内存映射,缺页故障、缺页中断处理和流程,最后介绍了动态存储分配管理,包括显示分配器、隐式分配器与各种分配方式。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
Linux的设备管理的主要任务是控制设备完成输入输出操作,把各种设备硬件的复杂物理特性的细节屏蔽起来,提供一个对各种不同设备使用统一方式进行操作的接口。Linux把设备看作是特殊的文件,系统通过处理文件的接口—虚拟文件系统VFS来管理和控制各种设备。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
8.2.1 IO接口及操作
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
(1)打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
(2)改变当前文件的位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
(3)读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的而文件,当k>=m时执行读操作会触发一个成为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。
类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
(4)关闭文件
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.1.2 IO接口及函数
(1)打开和关闭文件
进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的,open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件,也可以是一个或更多位掩码的或,为写提供一些额外的指示。mode参数指定了新文件的访问权限位。
作为上下文的一部分,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置成mode&~umask。
最后进程通过调用close函数关闭一个打开的文件。关闭一个已关闭的描述符会出错。
- 读和写文件
应用程序是通过分别调用read和write函数来执行输入与输出的。
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误、返回值0表示EOF,其他返回值表示的是实际传送的字节数量。
write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
- 健壮读写
RIO会自动处理不足值,在像网络程序这样容易出现不足值的应用中,RIO包提供了方便、健壮和高效的I/O。RIO提供了两类不同的函数:无缓冲的输入输出函数(rio_readn和rio_writen)和带缓冲的输入函数(rio_readnb)。
无缓冲的输入输出函数直接在内存和文件之间传送数据,没有应用级缓冲。
带缓冲的输入函数高效地从文件中读取文本行和二进制数据,这些文件的内容缓存在应用级缓冲区内,带缓冲的RIO输人函数是线程安全的。
- 读取文件元数据
应用程序通过调用stat和fstat函数检索关于文件的信息。stat函数以一个文件名作为输入,并填写stat数据结构中的各个成员。fstat相似,只不过是以文件描述符而不是文件名作为输入。
当讨论web服务器时,stat数据结构中有两个成员需要注意。st_size成员包含了文件的字节数大小,st_mode成员编码了文件访问许可位和文件类型。、
- 读取目录内容
应用程序用readdir系列函数来读取目录的内容。
函数opendir以路径名为参数,返回指向目录流的指针。每次对readdir的调用返回都是指向流dirp中下一个目录项的指针,如果没有更多目录项则返回NULL。若出错,readdir返回NULL并设置errno。函数closedir关闭流并释放所有找资源。
8.3 printf的实现分析
Printf函数的原函数如下:
- int printf(const char *fmt, ...)
- {
- int i;
- char buf[256];
- va_list arg = (va_list)((char*)(&fmt) + 4);
- i = vsprintf(buf, fmt, arg);
- write(buf, i);
- return i;
- }
printf调用的vsprintf原函数如下:
- int vsprintf(char *buf, const char *fmt, va_list args)
- {
- char* p;
- char tmp[256];
- va_list p_next_arg = args;
- for (p=buf;*fmt;fmt++) {
- if (*fmt != '%') {
- *p++ = *fmt;
- continue;
- }
- fmt++;
- switch (*fmt) {
- case 'x':
- itoa(tmp, *((int*)p_next_arg));
- strcpy(p, tmp);
- p_next_arg += 4;
- p += strlen(tmp);
- break;
- case 's':
- break;
- default:
- break;
- }
- }
- return (p - buf);
- }
vsprintf函数将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
vsprintf函数将参数按照格式字符串将内容格式化后存入缓冲区buf。格式化完成后,printf通过调用write系统函数,将格式化的字符串输出。
调用write输出触发了从用户态到核心态的转换,这通过int 0x80或者syscall陷阱指令实现。系统调用处理程序接收系统调用请求,并调用sys_write来处理标准输出操作。
字符显示驱动子程序通过字模库将ASCII字符转换为像素点的矩阵表示,这些像素点被写入显示内存vram存储每一个点的RGB颜色信息。显示芯片按照刷新频率读取vram逐行读取数据,并通过信号线向液晶显示器传输每一个点,最终实现printf的功能将数据输出到屏幕上。
8.4 getchar的实现分析
- int getchar(void)
- {
- static char buf[BUFSIZ];
- static char* bb=buf;
- static int n=0;
- if(n==0){
- n=read(0,buf,BUFSIZ);
- bb=buf;
- }
- return (--n>=0)?(unsigned char)*bb++:EOF;
- }
当程序调用getchar时,程序等待直到用户输入,当用户按下回车键之后,getchar才开始从标准输入中每次读取一个字符。
getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
getchar调用read系统函数,以从标准输入设备读取数据。调用read陷阱指令导致hello进程在用户态和内核态之间进行转换。在内核中,sys_read系统函数处理请求,从内核的键盘缓冲区中读取数据。sys_read系统调用完成后,内核返回用户态,继续执行用户程序。getchar函数将read函数返回的字符返回给调用getchar的程序进行进一步处理。
当用户按下键盘上的一个键时,产生中断信号,通知CPU发生了按键事件。键盘中断触发后,CPU暂停当前执行的指令,并跳转到内核中专门处理键盘中断的异常处理程序。每次按键都会产生扫描码,表示按键的物理位置。中断处理程序读取这些扫描码,通过键盘映射表将其转换为ASCII码,最后将转换后的字符存储在系统的键盘缓冲区。
8.5本章小结
本章主要介绍了linux系统的I/O接口与设备管理方法,并详细阐述了I/O接口操作和函数部分。最后分析了printf和getchar函数的实现,以及其实现过程的具体流程和异常处理。
(第8章1分)
结论
(1)程序员了解C语言程序基本输入输出知识后,编写C语言文件hello.c。
(2)预处理器对hello.c进行预处理,根据以#字符开头的命令修改原始的C程序得到修改后的hello.i文件。
(3)编译器将C语言编写的hello.i翻译成更接近机器语言的汇编语言文件hello.s。其中每条语句都以一种文本格式描述了一条或多条低级机器语言指令,为不同高级语言的不同编译器提供了通用的输出语言。
(4)汇编器将汇编语言文件hello.s翻译成机器语言指令,并将其打包成为可重定位目标文件hello.o。
(5)链接器将多个已经预编译好的可重定位目标程序合并成一个可执行目标文件hello,hello可以被加载到内存中由系统执行。
(6)执行hello时,shell会读取并解析命令行发现这并非内置指令,通过fork创建新的子进程,并通过execve加载程序的内存。
(7)hello进程执行过程中,遵守CPU的进程调度和上下文切换机制,同时对异常做出正确的响应。
(8)程序执行完毕后,由父进程回收子进程,hello进程从作业列表中消失。
(9)hello的一生中,有地址之间的转换、内存从无到有再到无、各种工具之间协同合作、各种输出设备之间调度共赢。
如果程序员不了解以上计算机系统知识,也可以通过调用GCC编译器调用编译系统中的各部分,利用hello.c成功的生成可执行程序hello,并且执行hello得到正确的输出结果。
hello的一生结束了,但是它并没有彻底消失。由hello.c生成的hello仍然保留在磁盘中,伴随程序员走向更广阔、更深奥的计算机系统天地。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 文件作用 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | hello.o输出的ELF格式文件 |
hello.asm | hello.o反汇编得到的汇编语言文件 |
hello | 链接后得到的二进制可执行文件 |
helloout.elf | hello输出的ELF格式文件 |
Helloout.asm | hello反汇编得到的汇编语言文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
- Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2018.4
- printf 函数实现的深入剖析https://www.cnblogs.com/pianist/p/3315801.html
- 线性地址_百度百科
- 二进制地址_百度百科
- 逻辑地址_百度百科
- 逻辑地址 线性地址 虚拟地址 物理地址关系https://blog.csdn.net/icandoit_2014/article/details/87897495
(参考文献0分,缺失 -1分)
转载自CSDN-专业IT技术社区
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/m0_73685810/article/details/139398692