计算机系统
大作业
题 目 程序人生-Hello’s P2P
本文详细探究了hello.c这个对于程序员再简单不过而又熟悉万分的程序,在Linux下不平凡的生命周期。利用Linux下一系列的开发工具,从对其预处理、编译、汇编等过程的详细解读来学习各个过程在Linux系统下得以实现的机制及原因,到对hello在Shell(壳)中的动态链接、进程运行、内存管理、I/O管理等过程的探索来更深层次的理解Linux系统下的动态链接、存储层次结构、异常控制流、虚拟内存及UnixI/O等相关内容。将书本内容与实例实践相结合,见微知著地更加深入地了解计算机系统的课程内容。
关键词:编译;链接;虚拟内存;异常控制流;
(根据内容精彩程度加分1分)
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
From Program to Process
首先hello.c通过I/O设备如键盘等经过内存总线存入主存。然后GCC编译器读取源程序文件hello.c,经过预处理操作变成hello.i(处理后的源程序)然后通过ccl变成hello.s(汇编程序),然后通过汇编器as变成hello.o(可重定位目标程序),此时hello.o已经变为二进制文件。最后再通过链接器ld与C标准库链接,最终变成hello(可执行目标程序)。然后在shell里面输入字符串“./hello”后,shell程序将字符逐一读入寄存器,然后再放入到内存里面,接着shell调用fork函数创建一个新的子进程(上下文环境),这个子进程是父进程shell的一个复制,然后子进程通过execve系统调用启动加载器。加载器删除子进程现有的虚拟内存段,然后使用mmap函数创建新的内存区域,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk), 新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main 函数。然后程序从内存读取指令字节,然后再从寄存器读入最多两个数,然后在执行阶段算术/逻辑单元要么执行指令指明的操作,计算内存引用的有效地址要么增加或者减少栈指针。然后在流水线化的系统中,待执行的程序被分解成几个阶段,每个阶段完成指令执行的一部分。最后变成一个Process运行在内存中。
From Zero-0 to Zero -0
Shell通过execve在fork产生的子进程中加载hello,先删除当前虚拟地址的用户部分已存在的数据结构,为hello的代码、数据、bss和栈区域创建新的区域结构,然后映射共享区域,设置程序计数器,使之指向代码区域的入口点,进入main函数,CPU为hello分配时间片执行逻辑控制流。hello通过Unix I/O管理来控制输出。hello执行完成后shell会回收hello进程,并且内核会从系统中删除hello所有痕迹,至此,hello完成O2O的 过程。
1.2 环境与工具
硬件环境:Intel Core i7-6700HQ x64CPU,16G RAM
软件环境:Ubuntu18.04.1 LTS;
开发与调试工具:gcc ld edb readelf gedit hexedit objdump vim
1.3 中间结果
hello.c:源代码
hello.i:hello.c经预处理生成的文本文件。
hello.s:hello.i经过编译器翻译成的文本文件hello.s,含汇编语言程序。
hello.o:hello.s经汇编器翻译成机器语言指令打包成的可重定位目标文件
hello.elf:hello.o的ELF格式。
hello_o_asm.txt:hello.o反汇编生成的代码。
hello:经过hello.o链接生成的可执行目标文件。
hello_out.elf:hello的ELF格式。
hello_out_asm.txt:hello反汇编生成的代码。
1.4 本章小结
本章漫游式地概述hello在系统中生命周期,对每个部分需要有系统地了解,并且本章列出进行本次实验的本机信息。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理是编译器在编译开始之前调用预处理器来执行以#开头的命令(读取头文件、执行宏替代等)、删除注释、包含其他文件、执行条件编译、布局控制等修改原始的C程序,生成以.i结尾的文本文件。
主要功能如下:
1、将源文件中用#include形式声明的文件复制到新的程序中。例如hello.c中第一行的#include命令告诉预处理器读取系统头文件stdio.h文件的内容,并把它直接插入到程序文本中。
2、删除注释。
3、根据#if后面的条件决定需要编译的代码。
4、执行宏替代。宏是对一段重复文字的简短描写,例如#define MAX 1024在预处理中会把所有MAX替代为1024,#define MAX(x,y) ((x)<(y))?(x): (y)在预处理中会把所有MAX(x,y)替换为((x)<(y))?(x): (y)。
5、布局控制。布局控制的主要功能是为编译程序提供非常规的控制流信息。
2.2在Ubuntu下预处理的命令
进入hello.c所在文件,打开终端,输入gcc -E -o hello.i hello.c 即可。
图2.2.1生成预处理文本文件hello.i
2.3 Hello的预处理结果解析
在预处理文本文件hello.i中,首先是对文件hello.c中系统头文件的寻址和解析:
图2.3.1 hello.i内容
由于hello.c包含的头文件中还有其他头文件,因此系统会递归地的寻址和展开,直到文件中不含宏定义且相关的头文件均已被引入。同时引入了头文件中所有typedef关键字,结构体类型、通过extern关键字调用并声明外部的结构体及函数定义,部分例子如下:
图2.3.2 hello.i内容(2)
图2.3.3 hello.i内容(3)
图2.3.4 hello.i内容(4)
最后在3047行引入main函数,如图:
图2.3.5 hello.i中main函数
2.4 本章小结
.c文件中包含有头文件也就是有外部文件的,还有一些程序员需要但是对于程序执行没有任何帮助的宏定义以注释,和一些程序员需要的条件编译和完善程序文本文件等操作都需要通过预处理来实现。预处理可以使得程序在后序的操作中不受阻碍,是非常重要的步骤。本章了解了预处理的概念及作用,以及Ubuntu下预处理对应的指令,更直观的展现预处理的结果。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念: 编译是利用编译程序从预处理文本文件产生汇编程序(文本)的过程。主要包含五个阶段:词法分析;语法分析;语义检查、中间代码生成、目标代码生成。
作用:编译作用主要是将文本文件hello.i翻译成文本文件hello.s,执行过程主要从其中三个阶段进行分析:
1、词法分析。词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序;
2、语法分析。语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位;
3、目标代码生成。目标代码生成器把语法分析后或优化后的中间代码经汇编程序汇编生成汇编语言代码,成为可执行的机器语言代码。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
gcc -S -o hello.s hello.i
图3.2.1生成hello.s
3.3 Hello的编译结果解析
3.3.1汇编文件伪指令
指令 | 对应内容 |
.file | 声明源文件 |
.text | 声明代码段 |
.data | 声明数据段 |
.align | 声明指令及数据存放地址的对齐方式 |
.type | 指定类型 |
.size | 声明大小 |
.section .rodata | 只读数据 |
.global | 全局变量 |
3.3.2数据类型
- 字符串型:
汇编语言中,输出字符串作为全局变量保存,因此存储于.rodata节中。汇编文件hello.s中,共有两个字符串,均作为printf参数,分别是:
原本字符串为“Usage: Hello 学号 姓名!”,对应中文字符可以看出对中文字符进行了utf-8编码,中文汉字以‘\345’开头,占三个字符,而全角字符‘!’占用两个字符。
图3.3.2.1 输出字符串(1)
图3.3.2.2 输出字符串(2)
- 数组:
在hello.c中有对数组的应用,如下:
图3.3.2.3 hello.c循环输出部分
数组为main函数参数,循环代码为:
图3.3.2.4 hello.s循环输出部分
argv[2]作为printf函数的第三个参数,应当存于寄存器%rdx中,因此可推断argv[2]地址为-0x16(%rbp);argv[1]作为printf第二个参数,应当存于寄存器%rsi中,因此可推断argv[1]地址为-0x2A(%rbp)中,数组首地址位于-0x32(%rbp) ,以上所占字节数为8。
3.3.3汇编语言操作
数据传送类指令,mov类
MOV类主要由4条指令组成:movb、movw、movl、movq。主要区别在于他们操作的数据大小不同分别为1,2,4,8字节。
指令 | 效果 | 描述 |
MOV S,D | D<—S | 传送 |
movb | R<—I | 传送字节 |
movw | R<—I | 传送字 |
movl | R<—I | 传送双字 |
movq | R<—I | 传送四字 |
movabsq I ,R | R<—I | 传送绝对的四字 |
例如:
图3.3.3.1 数据传送指令(1)
3.3.4控制转移
指令 | 同义名 | 跳转条件 | 描述 |
jmp Label | 1 | 直接跳转 | |
jmp *Operand | 1 | 间接跳转 | |
je Label | jz | ZF | 相等/零 |
jne Label | jnz | ~ZF | 不相等/非零 |
js Label | SF | 负数 | |
jns Label | ~SF | 非负数 | |
jg Label | jnle | (SF^OF)&ZF | 大于(有符号>) |
jge Label | jnl | ~(SF^OF) | 大于或等于(有符号>=) |
jl Label | jnge | SF^OF | 小于(有符号<) |
jle Label | jng | (SF^OF)|ZF | 小于或等于(有符号<=) |
ja Label | jnbe | CF&ZF | 超过(无符号>) |
jae Label | jnb | ~CF | 超过或相等(无符号>=) |
jb Label | jnae | CF | 低于(无符号<) |
jbe Label | jna | CF&ZF | 低于或相等(无符号<=) |
例如:
图3.3.4.1 跳转指令(1)
转移控制:
指令 | 描述 |
call Label | 过程调用 |
call *Operand | 过程调用 |
ret | 从过程调用中返回 |
call函数过程调用并将下一条指令压栈,hello.s中用到转移控制的部分:
图3.3.4.2控制转移(1)
3.3.5函数操作
hello.c中的函数:
1、 int main(int argc, char *argv[])
参数传递与函数调用:内核执行c程序时调用特殊的启动例程,并将启动例程作为程序的起始地址,从内核中获取命令行参数和环境变量地址,执行main函数。
函数退出:hello.c中main函数有两个出口,第一个是当命令行参数数量不为4时输出提示信息并调用exit(1)退出main函数;第二个是命令行参数数量为4执行循环和getchar函数后return 0的方式退出函数。
函数栈帧结构的分配与释放:main函数通过pushq %rbp、movq %rsp, %rbp、subq $32, %rsp 为函数分配栈空间,如果是通过exit函数结束main函数则不会释放内存,会造成内存泄露,但是程序如果通过return正常返回则是由指令leave即mov %rbp,%rsp,pop %rbp恢复栈空间。
2、 exit():
参数传递与函数调用:在hello.c中设置%edi值为0表示赋给exit函数第一个变量,然后通过call函数调用exit()。
3、 printf()
参数传递与函数调用:printf参数根据字符串中的输出占位符数量来决定的。
hello.s中调用printf函数函数如下图:.
图3.3.5.1 printf函数调用
图3.3.5.2 puts函数调用
4、sleep()
函数返回:若进程/线程挂起到参数所指定的时间则返回0,若有信号中断则返回剩余秒数。
- getchar()
图3.3.5.3 getchar函数调用
函数返回:getchar()函数返回值类型为int,如果成功返回用户输入的ASCII码,出错返回-1。
3.3.6类型转换
类型转换分为显示和隐式。
隐式转换:隐式转换就是系统默认的、不需要加以声明就可以进行的转换数据类型自动提升,数据类型提升顺序为:
byte,short,char -->int -->long -->float -->double
3.4 本章小结
本章系统阐述了编译器将预处理文本文件hello.i翻译为文本文件hello.s的具体操作,主要就汇编语言伪指令、数据类型、汇编语言操作、控制转移,函数操作、类型转换六方面针对hello.s中各部分做出相应的解释说明。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:把汇编语言翻译成机器语言的过程称为汇编。
作用:汇编器(as)将hello.s翻译成机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在二进制目标文件hello.o中。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
Linux终端下进入hello.c所在文件夹,输入指令as hello.s -o hello.o即可。
图4.2.1 生成hello.o
4.3 可重定位目标elf格式
使用readelf –a hello.o > hello.elf 生成hello.o文件的ELF格式。
- ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。
Elf头如下:
图4.3.1 ELF头
- 节头部表
节头部表记录了各节名称、类型、地址、偏移量、大小、全体大小、旗标、连接等等。
图4.3.2 节头部表
- 重定位节
.rela.text记录了一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。
在hello.o的重定位节中包含了main函数调用的puts、exit、printf、sleep、getchar函数,还有.rodata节(包含prnitf的格式串)的偏移量、信息、类型、符号值、符号名称及加数。rela.eh_frame记录了.text的信息。hello.elf的重定位节如下:
图4.3.3 重定位节
- 符号表
.symtab存放着程序中定义和引用函数和全局变量的信息。且不包含局部变量的条目。
图4.3.6 .symtab表
4.4 Hello.o的结果解析
利用objdump -d -r hello.o > hello_o_asm.txt生成hello.o对应的反汇编文件,并与hello.s比较,差别体现在以下几个方面:
1、 文件内容构成
hello_o_asm.txt中只有对文件最简单的描述,记录了文件格式和.text代码段;而hello.s中有对文件的描述,全局变量的完整描述(包括.type .size .align 大小及数据类型)以及.rodata只读数据段。
两者均包含main函数的汇编代码,但是区别在于hello.s的汇编代码是由一段段的语句构成,同时声明了程序起始位置及其基本信息等;而hello_o_asm.txt则是由一整块的代码构成,代码包含有完整的跳转逻辑和函数调用等。
图4.4.1 文件内容构成不同举例
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
2、分支转移
hello_o_asm.txt包含了由操作数和操作码构成的机器语言,跳转指令中地址为已确定的实际指令地址,hello.s主要使用通过使用例如.L0、.L1等的助记符表示的段完成内部跳转及函数条用的逻辑。
hello_o_asm.txt:
Hello.s:
图4.4.2 分支转移不同
- 函数调用
hello_o_asm.txt文件中,call地址后为占位符(4个字节的0),指向的是下一条地址的位置,原因是库函数调用需要通过链接时重定位才能确定地址;而hello.s中的函数调用直接是call+函数名表示。
hello_o_asm.txt:
Hello.s
图4.4.3 函数调用不同
4.5 本章小结
本章通过对汇编后产生的hello.o的可重定位的ELF格式的考察、对重定位项目的举例分析以及对反汇编文件与hello.s的对比,从原理层次了解了汇编这一过程实现的变化。
、(第4章1分)
第5章 链接
5.1 链接的概念与作用
(以下格式自行编排,编辑时删除)
概念:链接是将各种代码和数据片段收集并组合称为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。本质即合并相同的“节”。
作用:链接可以执行于编译时(源代码到机器代码);也可以执行于加载时(程序被加载到内存);甚至执行于运行时。链接操作由链接器执行,链接器使得分离编译成为可能。开发过程中无需将大型的应用程序组织委员一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。链接操作最重要的步骤就是将函数库中相应的代码(也就是模块)组合到目标文件中。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
终端进入hello.o所在文件,输入指令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/5/crtbegin.o
/usr/lib/gcc/x86_64-linux-gnu/5/crtend.o
/usr/lib/x86_64-linux-gnu/crtn.o
hello.o -lc -z relro -o hello
图5.2.1 链接生成可执行目标文件
5.3 可执行目标文件hello的格式
使用readelf -a hello > hello_out.elf执行获得包含hello的ELF格式的文件。
1、节头部表中包含了各段的基本信息,包括名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐等信息:
图5.3.1 可执行目标文件ELF(1)
图5.3.2 可执行目标文件ELF(2)
2、重定位节.rela.text:
图5.3.2重定位节(2)
5.4 hello的虚拟地址空间
Hello2是典型的ELF可执行文件:
图5.4.1 可执行文件ELF信息
通过查看edb,看出hello的虚拟地址空间开始于0x400000,结束与0x400ff0
图5.4.2 hello2虚拟地址
图5.4.3hello2地址
查看ELF格式文件中的Program Headers,程序头表在执行的时候被使用,它告诉链接器运行时加载的内容并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方面的信息。在下面可以看出,程序包含8个段:
1、PHDR保存程序头表。
2、INTERP指定在程序已经从可执行文件映射到内存之后,必须调用的解释器(如动态链接器)。
3、LOAD表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量4、数据(如字符串)、程序的目标代码等。
5、DYNAMIC保存了由动态链接器使用的信息。
6、NOTE保存辅助信息。
7、GNU_STACK:权限标志,标志栈是否是可执行的。
8、GNU_RELRO:指定在重定位结束之后那些内存区域是需要设置只读。
举例:
查看.txt节:
图5.4.4hello2.txt节
.txt节,虚拟地址开始于0x400550,大小为0x132。
5.5 链接的重定位过程分析
(以下格式自行编排,编辑时删除)
hello相对于hello.o有如下不同:
1、hello.o中的相对偏移地址到了hello中变成了虚拟内存地址。
2、hello中相对hello.o增加了许多的外部链接来的函数。
3、hello相对hello.o多了很多的节类似于.init,.plt等。
4、hello.o中跳转以及函数调用的地址在hello中都被更换成了虚拟内存地址。
重定位:链接器在完成符号解析以后,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。然后就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时的地址。在hello到hello.o中,首先是重定位节和符号定义,链接器将所有输入到hello中相同类型的节合并为同一类型的新的聚合节。例如,来自所有的输入模块的.data节被全部合并成一个节,这个节成为hello的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每一个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。然后是重定位节中的符号引用,链接器会修改hello中的代码节和数据节中对每一个符号的引用,使得他们指向正确的运行地址。
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
5.6 hello的执行流程
(以下格式自行编排,编辑时删除)
加载程序 | ld-2.23.so!_dl_start |
ld-2.23.so!_dl_init | |
LinkAddress!_start | |
ld-2.23.so!_libc_start_main | |
ld-2.23.so!_cxa_atexit | |
LinkAddress!_libc_csu.init | |
ld-2.23.so!_setjmp | |
运行 | LinkAddress!main |
程序终止 | ld-2.23.so!exit |
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条目。
根据hello ELF文件可知(图5.7.1),GOT起始表位置为0x601000,如图:
图5.7.1
GOT表位置在调用dl_init之前0x601008后的16个字节均为0:
图5.7.2GOT表初始前内容
调用_start之后发生改变,0x601008后的两个8个字节分别变为:0x7f6f8dc46170、0x7f6f8da34750,其中GOT[O](对应0x600e28)和GOT[1](对应0x7fb06087e168)包含动态链接器在解析函数地址时会使用的信息。GOT[2](对应0x7fb06066e870)是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,改变后的GOT表如下:
图5.7.3 GOT表初始后内容
GOT[2]对应部分是共享库模块的入口点,如下:
图5.7.4共享库模块入口点
举例puts函数在调用puts函数前对应GOT条目指向其对应的PLT条目的第二条指令,如图puts@plt指令跳转的地址
图5.7.5puts&plt函数
图5.7.6调用puts函数前(链接前)PLT函数
可以看出其对应GOT条目初始时指向其PLT条目的第二条指令的地址。puts函数执行后在查看此处地址:
图5.7.7
可以看出其已经动态链接,GOT条目已经改变。
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
本章了解了链接的概念作用,分析可执行文件hello的ELF格式及其虚拟地址空间,同时通过实例分析了重定位过程、加载以及运行时函数调用顺序以及动态链接过程,深入理解链接和重定位的过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量、以及打开文件描述符的集合。
作用:进程给应用程序提供的关键抽象有两种:
a) 一个独立的逻辑控制流,提供一个假象,程序独占地使用处理器。
b) 一个私有的地址空间,提供一个假象,程序在独占地使用系统内存。
6.2 简述壳Shell-bash的作用与处理流程
shell作为UNIX的一个重要组成部分,是它的外壳,也是用户于UNIX系统交互作用界面。Shell是一个命令解释程序,也是一种程序设计语言。
1.读入命令行、注册相应的信号处理程序、初始化进程组。
2. 通过paraseline函数解释命令行,如果是内置命令则直接执行,否则阻塞信号后创建相应子进程,在子进程中解除阻塞,将子进程单独设置为一个进程组,在新的进程组中执行子进程。父进程中增加作业后解除阻塞。如果是前台作业则等待其变为非前台程序,如果是后台程序则打印作业信息。
6.3 Hello的fork进程创建过程
首先了解进程的创建过程:父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时。子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的id。
fork后调用一次返回两次,在父进程中fork会返回子进程的PID,在子进程中fork会返回0;父进程与子进程是并发运行的独立进程。内核能够以任何方式交替执行他们逻辑控制流中的指令。
hello的fork进程创建过程为:系统进程创建hello子进程然后调用waitpid()函数知道hello子进程结束,程序进程图如下:
图6.3.1 hello 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将根据需要换入代码和数据页面。
图6.4
6.5 Hello的进程执行
首先了解进程执行中逻辑控制流、并发流、用户模式和内核模式、上下文切换等概念:
a) 逻辑控制流
在调试器单步执行程序时,会发现一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC的值的序列叫做逻辑控制流。
b) 并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地执行。
多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念被称为多任务。
一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫时间分片。
c) 用户模式和内核模式
处理器通过某个控制寄存器中的一个模式位来提供限制一个应用可以执行的指令以及它可以访问的地址空间范围的功能。该寄存器描述了当前进程享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存为止;没有设置模式位时,进程就运行在用户模式中。用户模式的进程不允许和执行特权指令、也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。
d) 上下文切换
内核为每个进程维持一个上下文,上下文就是内核重新启动的一个被强占的进程所需的状态。由包括通用目的寄存器、浮点寄存器、程序计数器、用户站、状态寄存器、内核栈和各种内核数据结构。
上下文切换的机制:
1、保存当前进程的上下文;
2、恢复某个先前被抢占的进程被保存的上下文;
3、将控制传递给这个新恢复的进程。
接下来阐述进程调度的概念及过程、用户态和核心态的转换:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这个决策就叫做调度,是由内核中称为调度器的代码处理的。在内和调度了一个新的进程运行后,它就抢占当前进程,并使用上文所述的上下文切换的机制将控制转移到新的进程。内核代表的用户执行系统调用时,可能会发生上下文切换;中断也有可能引发上下文切换。
图6.5.1上下文切换
通过上图所示的内核模式用户模式的切换描述用户态核心态转换的过程,在切换的第一部分中,内核代表进程A在内核模式下执行指令。然后在某一时刻,它开始代表进程B(仍然是内核模式下)执行指令。在切换之后,内核代表进程B在用户模式下执行指令。随后,进程B在用户模式下运行一会儿,直到磁盘发出一个中断信号,表示数据已经从磁盘传送到了内存。内核判定进程B已经运行了足够长的时间,就执行一个从进程B到进程A的上下文切换,将控制返回给进程A中紧随在系统调用read之后的那条指令。进程A继续运行,直到下一次异常发生,依此类推。
6.6 hello的异常与信号处理
(以下格式自行编排,编辑时删除)
- 异常和信号异常可以分为四类:中断、陷阱、故障、终止,各自的属性
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 同步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
hello程序出现的异常可能有:
中断:在hello程序执行的过程中可能会出现外部I/O设备引起的异常。
陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep函数的时候会出现这个异常。
故障:在执行hello程序的时候,可能会发生缺页故障。
终止:终止时不可恢复的错误,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。
在发生异常时会发出信号,比如缺页故障会导致OS发生SIGSEGV信号给用户进程,而用户进程以段错误退出。常见信号种类如下表所示。
2.键盘上各种操作导致的异常
1.图6.6.1是正常执行hello程序的结果
图6.6.1
- 图6.6.2按下 ctrl-z 的结果,输入ctrl-z默认结果是挂起前台的作业,hello进程并没有回收,而是运行在后台下,如图6.6.3所示用ps命令可以看到,hello进程并没有被回收。此时他的后台 job 号是 1,调用 fg 1 将其调到前台,此时 shell 程序首先打印 hello 的命令行命令, hello 继续运行打印剩下的 8 条 info,之后输入字串,程序结束,同时进程被回收。如图6.6.4.
图6.6.2
图6.6.3
转存失败重新上传取消
图6.6.4
- 图6.6.5是按下Ctrl+c的结果,在键盘上输入Ctrl+c会导致内核发送一个SIGINT信号到前台进程组的每个进程,默认情况是终止前台作业,如图6.6.6,用ps查看前台进程组发现没有hello进程。
图6.6.5
转存失败重新上传取消
图6.6.6
4.程序运行过程中按键盘,不停乱按,结果如图6.6.7,可以发现,乱按只是将屏幕的输入缓存到 stdin,当 getchar 的时候读出一个’\n’结尾的字串(作为一次输入),其他字串会当做 shell 命令行输入。
转存失败重新上传取消
图6.6.7
6.7本章小结
在本章中,阐述进程的定义与作用,同时介绍了 Shell 的一般处理流程和作用,并且着重分析了调用 fork 创建新进程,调用 execve函数 执行 hello,hello的进程执行,以及hello 的异常与信号处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址空间是由段地址和偏移地址构成的。
例如:23:8048000 段寄存器(CS等16位):偏移地址(16/32/64);
实模式下:逻辑地址CS:EA —>物理地址CS*16+EA;
保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址, 段地址+偏移地址=线性地址。
线性空间地址为非负整数地址的有序集合,例如{0,1,2,3…}。
虚拟地址空间为N = 2n 个虚拟地址的集合,例如{0,1,2,3,….,N-1}。
物理地址空间为M = 2m 个物理地址的集合,例如{0,1,2,3,….,M-1}。物理地址是真实的物理内存的地址。
Intel采用段页式存储管理(通过MMU)实现:
·段式管理:逻辑地址—>线性地址==虚拟地址;
·页式管理:虚拟地址—>物理地址。
以hello中的puts调用为例:mov $0x400714,%edi callq 4004a0,$0x400714为puts输出字符串逻辑地址中的偏移地址,需要经过段地址到线性地址的转换变为虚拟地址,然后通过MMU转换为物理地址,才能找到对应物理内存。
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.2 Intel逻辑地址到线性地址的变换-段式管理
最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。
分段功能在实模式和保护模式下有所不同。
实模式,即不设防,也就是说逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。
在保护模式下,线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。段寄存器无法放下32位段基址,所以它们被称作选择符,用于引用段描述符表中的表项来获得描述符。描述符表中的一个条目描述一个段。
Base:基地址,32位线性地址指向段的开始。Limit:段界限,段的大小。 DPL:描述符的特权级0(内核模式)-3(用户模式)。
所有的段描述符被保存在两个表中:全局描述符表GDT和局部描述符表LDT。gdtr寄存器指向GDT表基址。
在保护模式下,分段机制就可以描述为:通过解析段寄存器中的段选择符在段描述符表中根据Index选择目标描述符条目Segment Descriptor,从目标描述符中提取出目标段的基地址Base address,最后加上偏移量offset共同构成线性地址Linear Address。
当CPU位于32位模式时,内存4GB,寄存器和指令都可以寻址整个线性地址空间,所以这时候不再需要使用基地址,将基地址设置为0,此时逻辑地址=描述符=线性地址,Intel的文档中将其称为扁平模型(flat model),现代的x86系统内核使用的是基本扁平模型,等价于转换地址时关闭了分段功能。在CPU 64位模式中强制使用扁平的线性空间。逻辑地址与线性地址就合二为一了。
7.3 Hello的线性地址到物理地址的变换-页式管理
(以下格式自行编排,编辑时删除)
虚拟内存概念:虚拟内存是系统对主存的抽象概念,是硬件异常、硬件地址翻译、主存、磁盘文件和内存文件的完美交互。为每个进程提供了一个大的、一致的和私有的地址空间。
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。虚拟页则是虚拟内存被分割为固定大小的块。物理内存被分割为物理页,大小与虚拟页大小相同。
转存失败重新上传取消
图7.3.1虚拟页物理页缓存关系
页表是一个页表条目的数组,将虚拟页地址映射到物理页地址。
转存失败重新上传取消
图7.3.2 虚拟页映射物理页
地址翻译中需要了解虚拟地址、物理地址的组成部分及其他基本参数,如下:
转存失败重新上传取消转存失败重新上传取消
图7.3.3 虚拟地址物理地址组成部分及参数
地址翻译可简化为以下流程:
转存失败重新上传取消
图7.3.4 地址翻译过程
形式上来说,地址翻译是一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)中元素的映射。CPU中存在一个控制寄存器为页表基址寄存器指向当前页表。n位的虚拟地址包含着p位的虚拟页面偏移和(n-p)位的虚拟页号。MMU利用VPN来选择适当的PTE,将页表条目中的物理页号和虚拟地址中的VPO串联起来就得到相对应的物理地址。
页命中时CPU硬件执行的步骤为:
处理器生成虚拟地址,并传给MMU。
MMU生成PTE地址,并从高速缓存/主存中请求得到它。
高速缓存/主存向MMU返回PTE。
MMU构造物理地址并把它传送给高速缓存/主存。
高速缓存/主存泛会所请求的数据字给处理器。
转存失败重新上传取消
图7.3.5页命中时CPU硬件执行步骤
缺页时CPU硬件执行步骤为:
与页命中1)到3)相同。
PTE中有效位为0,MMU触发一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
缺页处理程序确定出物理内存的牺牲页,如果这个页面已经被修改了,则把他换出物理磁盘。
缺页处理程序页面调入新的页面,并更新内存中的PTE。
缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中。主存会将所请求的字返回给处理器。
转存失败重新上传取消转存失败重新上传取消
图7.3.6 页不命中时CPU硬件执行步骤
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存,类似与高速缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。其组成部分如下图:
转存失败重新上传取消
图7.4.1 TLB组成
用于组选择和行匹配的索引和标记字段在VPN虚拟页号中。若TLB有T=2^t个组,那么TLB索引是由VPN的t个最低位组成的,而TLB标记是由VPN中剩余的位组成的,有n-p-t位。
TLB中所有的地址翻译步骤都是在芯片上的MMU执行的,因此非常快。当TLB命中时,其执行步骤为:
1、CPU上产生一个虚拟地址。
2、(2) 和 3))MMU从TLB中取出相应的PTE。
3、MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
4、高速缓存/主存将所请求的数据字返回给CPU。
转存失败重新上传取消
图7.4.2 TLB命中
TLB不命中时,MMU需要从L1缓存取出相应的PTE,存于TLB之中,可能会覆盖已存在条目。其操作图为:
转存失败重新上传取消
图7.4.3 TLB不命中
使用四级页表的地址翻译,虚拟地址被划分为4个VPN和1个VPO。每个VPNi都是一个到i级页表的索引,其中1<=i<=4.第j级页表中的每个PTE,1<=j<=3,都是指向第j+1级的每个页表的基址。第4级也表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问4个PTE。下图为使用Core i7的4级页表的地址翻译:
转存失败重新上传取消
图7.4.5 多级页表地址翻译
7.5 三级Cache支持下的物理内存访问
前提:只讨论L1 Cache的寻址细节,L2与L3Cache原理相同。L1 Cache是8路64组相联。块大小为64B。
解析前提条件:因为共64组,所以需要6bit CI进行组寻址,因为共有8路,因为块大小为64B所以需要6bit CO表示数据偏移位置,因为VA共52bit,所以CT共40bit。
在上一步中我们已经获得了物理地址VA,使用CI(后六位再后六位)进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO(后六位)取出数据返回。
如果没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。
7.6 hello进程fork时的内存映射
当 fork 函数被 shell 进程调用时,内核为新进程创建各种数据结构,并分配给 它一个唯一的 PID,为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只 读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障:当指令引用一个相应的虚拟地址,而与改地址相应的物理页面不再内存中,会触发缺页故障。通过查询页表PTE可以知道虚拟页在磁盘的位置。缺页处理程序从指定的位置加载页面 到物理内存中,并更新PTE。然后控制返回给引起缺页故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中,因此指令可以没有故障的运行完成。故障处理具体流程如图7.8所示
转存失败重新上传取消
图7.8.1缺页故障处理流程
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) 。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) 。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
分配器将堆视为一组不同大小的块(block) 的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
基本方法:这里指的基本方法应该是在合并块的时候使用到的方法,有最佳适配和第二次适配还有首次适配方法,首次适配就是指的是第一次遇到的就直接适配分配,第二次顾名思义就是第二次适配上的,最佳适配就是搜索完以后最佳的方案,当然这种的会在搜索速度上大有降低。
策略:这里的策略指的就是显式的链表的方式分配还是隐式的标签引脚的方式分配还是分离适配,带边界标签的隐式空闲链表分配器允许在常数时间内进行对前面块的合并。这种思想是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。显式空间链表就是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继指针,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果找到了一个,那么就(可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。
7.10本章小结
本章从Linux存储器的地址空间起,阐述了Intel的段式管理和页式管理机制,以及TLB与多级页表支持下的VA到PA的转换,同时对cache支持下的物理内存访问做了说明。针对内存映射及管理,简述了hello的fork和execve内存映射,了解了缺页故障与缺页中断处理程序,对动态分配管理做了系统阐述。
(第7章 2分)
第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接口统一操作:
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O函数:
int open(char* filename,int flags,mode_t mode) ,进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
int close(fd),fd是需要关闭的文件的描述符,close返回操作结果。
ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
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;
}
arg首先获得第二个不定长参数,即输出的时候格式化串对应的值。
再查看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': //只处理%x一种情况
itoa(tmp, *((int*)p_next_arg)); //将输入参数值转化为字符串保存在tmp
strcpy(p, tmp); //将tmp字符串复制到p处
p_next_arg += 4; //下一个参数值地址
p += strlen(tmp); //放下一个参数值的地址
break;
case 's': break;
default: break;
}
}
return (p - buf); //返回最后生成的字符串的长度
}
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall,查看syscall的实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
syscall将字符串中的字节“Hello 1170300825 lidaxin”从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是我们的打印字符串“Hello 1170300825 lidaxin”就显示在了屏幕上。
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章系统的了解了Unix I/O,通过LinuxI/O设备管理方法以及Unix I/O接口及函数了解系统级I/O的底层实现机制。通过对printf和getchar函数的底层解析加深对Unix I/O以及异常中断等的了解。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
hello.c:编写c程序,hello.c诞生,它是一个二进制文本文件,hello.c中的每个字符都是用ascall编码表示。
hello.i:hello.c经过预处理阶段变为hello.i。
hello.s:hello.i经过编译阶段变为hello.s。
hello.o:hello.s经过汇编阶段变为hello.o。
hello:hello.o与可重定位目标文件和动态链接库链接成为可执行文件hello。至此可执行hello程序正式诞生。
运行:在终端输入1190200401 王泽园 1。
创建子进程:由于终端输入的不是一个内置的shell命令,因此shell调用fork()函数创建一个子进程。
加载::shell 调用 execve,execve 调用启动加载器,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main 函数。
上下文切换:hello调用sleep函数之后进程陷入内核模式,处理休眠请求主动释放当前进程,内核进行上下文切换将当前进程的控制权交给其他进程,当sleep函数调用完成时,内核执行上下文切换将控制传递给当前进程。
动态申请内存:当hello程序执行printf函数是, 会调用 malloc 向动态内存分配器申请堆中的内存。
信号管理:当程序在运行的时候我们输入Ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有 数据结构。
对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
计算机系统的设计思想和实现都是基于抽象实现的。从最底层的信息的表示用二进制表示抽象开始,到实现操作系统管理硬件的抽象:进程是对处理器、主存和I/O设备的抽象。虚拟内存是对主存和磁盘设备的抽象。文件是对I/O设备的抽象。
计算机系统的设计精巧:为了解决快的设备存储小、存储大的设备慢的不平衡,设计了高速缓存来作为更底层的存储设备的缓存,大大提高了CPU访问主存的速度!
计算机系统的设计考虑全面:计算机系统设计考虑一切可能的实际情况,设计出一系列的满足不同情况的策略。比如写回和直写,写分配和非写分配,直接映射高速缓存和组相连高速缓存等等。
文件名称 | 文件作用 |
hello.i | 预处理之后文本文件 |
hello.s | 编译之后的汇编文件 |
hello.o | 编译之后的可重定位目标执行 |
hello | 链接之后的可执行目标文件 |
hello2.c | 测试程序代码 |
hello2 | 测试程序 |
hello.objdmp | Hello的反汇编代码 |
hello.elf | Hellode ELF格式 |
hmp.txt | 存放临时数据 |
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
(参考文献0分,缺失 -1分)