大作业
题 目 程序人生-Hello’s P2P
专 业 计算机
学 号
班 级
学 生
指 导 教 师
计算机科学与技术学院
2022年5月
本文分析了一个hello.c程序的完整处理过程:经过预处理、编译、汇编、链接,最终变为一个可执行文件hello。同时本文还分析了程序运行过程中的进程管理、存储管理、IO管理。从多个角度阐述了Hello这样一个最基本的程序的运行过程。虽然hello这个程序看起来简单,实际上却凝聚着众多前辈的心血才得以让计算机运行这样一个小小的程序。通过本文对hello程序的分析,可以举一反三地发现其他的程序运行过程。
关键词:预处理;编译;汇编;链接;进程管理;存储管理;IO管理;
目 录
第1章 概述........................................................... - 4 -
1.1 Hello简介.................................................... - 4 -
1.2 环境与工具................................................... - 4 -
1.3 中间结果....................................................... - 4 -
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 本章小结..................................................... - 15 -
第4章 汇编......................................................... - 16 -
4.1 汇编的概念与作用..................................... - 16 -
4.2 在Ubuntu下汇编的命令......................... - 16 -
4.3 可重定位目标elf格式............................. - 16 -
4.4 Hello.o的结果解析.................................. - 19 -
4.5 本章小结..................................................... - 20 -
第5章 链接......................................................... - 22 -
5.1 链接的概念与作用..................................... - 22 -
5.2 在Ubuntu下链接的命令......................... - 22 -
5.3 可执行目标文件hello的格式................. - 22 -
5.4 hello的虚拟地址空间.............................. - 26 -
5.5 链接的重定位过程分析............................. - 27 -
5.6 hello的执行流程...................................... - 28 -
5.7 Hello的动态链接分析.............................. - 28 -
5.8 本章小结..................................................... - 29 -
第6章 hello进程管理................................. - 30 -
6.1 进程的概念与作用..................................... - 30 -
6.2 简述壳Shell-bash的作用与处理流程... - 30 -
6.3 Hello的fork进程创建过程.................... - 31 -
6.4 Hello的execve过程............................... - 31 -
6.5 Hello的进程执行...................................... - 33 -
6.6 hello的异常与信号处理.......................... - 36 -
6.7本章小结..................................................... - 40 -
第7章 hello的存储管理............................. - 41 -
7.1 hello的存储器地址空间.......................... - 41 -
7.2 Intel逻辑地址到线性地址的变换-段式管理. - 42 -
7.4 TLB与四级页表支持下的VA到PA的变换.. - 44 -
7.5 三级Cache支持下的物理内存访问........ - 46 -
7.6 hello进程fork时的内存映射................ - 46 -
7.7 hello进程execve时的内存映射............ - 47 -
7.8 缺页故障与缺页中断处理......................... - 48 -
7.9动态存储分配管理..................................... - 49 -
7.10本章小结................................................... - 50 -
第8章 hello的IO管理............................... - 51 -
8.1 Linux的IO设备管理方法........................ - 51 -
8.2 简述Unix IO接口及其函数..................... - 51 -
8.3 printf的实现分析...................................... - 52 -
8.4 getchar的实现分析.................................. - 54 -
8.5本章小结..................................................... - 55 -
结论....................................................................... - 56 -
附件....................................................................... - 57 -
参考文献............................................................... - 58 -
第1章 概述
1.1 Hello简介
1.1.1 P2P(Program to Process)
Hello的P2P过程指的是hello.c从源程序到进程的过程。其中包括hello.c经过cpp预处理变为hello.i,再经过cc1编译器的处理变为hello.s汇编程序。之后经过as汇编器的处理变为hello.o的二进制目标程序,最后与printf.o一起经过链接器ld的处理比变为可执行目标程序hello。
图1-1 编译系统
1.1.2 020(From Zero-0 to Zero-0)
Hello的020过程指的是执行hello程序时,shell会fork出一个子进程并分配空间,将要执行的目标文件通过execve装载进内存。进程结束后,内核回收进程,删除内存中相关信息。
1.2 环境与工具
X64 CPU;2GHz;2G RAM;256GHD Disk 以上
Windows10 64位; Vmware 11;Ubuntu 16.04 LTS 64位/优麒麟 64位 以上;
Visual Studio 2019 64位;CodeBlocks 64位;vi/vim/gedit+gcc;edb ;gdb ;
1.3 中间结果
中间结果 | 名称 | 作用 |
Hello.c | 源程序 | 源代码 |
Hello.i | 预处理后的文本文件 | 便于程序的修改、阅读、移植和调试,也便于实现模块化程序设计 |
Hello.s | 编译后的汇编文件 | 将高级语言源程序翻译成目标程序编译程序,将预处理中的高级语言以文本文件格式转换为汇编语言 |
Hello.o | 汇编后的可重定位目标文件 | 将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式 |
Hello | 可执行目标程序 | 变成可执行程序,使用户可以直接执行而不必知晓原理 |
1.4 本章小结
Hello虽然是一个简单的程序但却需要计算机多个部件的完美配合才能运行出结果。通过对它的分析,我收获了很多相关的知识。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程(例如从hello.c生成hello.i文件)。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)[1]。
作用:宏定义,用一个标识符来表示一个字符串,在宏调用中将用该字符串代换宏名。文件包含,可以将多个源文件整合成一个源文件进行编译处理。条件编译,允许只编译源程序中满足条件的程序段,使生成的目标程序较短,从而减少了内存的开销并提高了程序的效率。使用预处理功能便于程序的修改、阅读、移植和调试,也便于实现模块化程序设计。
2.2在Ubuntu下预处理的命令
命令:cpp hello.c > hello.i(或gcc –E hello.c –o hello.i)
图2-1 预处理
图2-2 预处理
2.3 Hello的预处理结果解析
有预处理产生的hello.i文件可知,预处理是编译器对各种预处理命令的处理。预处理产生了一个文本文件,产生了三千多行代码,但是main函数中内容并没有被改变。只是原来出现在开头的头文件展开,注释消失,被其他文本代替。
图2-3 预处理结果开头
图2-4预处理结果结尾(含源程序)
2.4 本章小结
预处理是程序从源代码变成可执行程序的第一步,主要是编译器对各种预处理命令进行处理,包括头文件包含,宏定义扩展等等。
第3章 编译
3.1 编译的概念与作用
概念:编译是指用编译程序产生目标程序的动作。 编译就是把高级语言变成计算机可以识别的二进制语言。 编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
作用:编译程序的作用是将高级语言源程序翻译成目标程序编译程序,将预处理中的高级语言以文本文件格式转换为汇编语言。
3.2 在Ubuntu下编译的命令
gcc –S hello.c –o hello.s(或gcc hello.i –o hello.s)
图3-1 编译
图3-2 编译
3.3 Hello的编译结果解析
3.3.1数据
C语言数据包括常量、变量(全局/局部/静态)、表达式、类型、宏等等。
3.3.1.1 常量
两个字符串放在静态存储区,无法改变(只读数据)。
图3-3 字符常量存储的汇编语言
图3-4 字符常量在源程序的位置
3.3.1.2 变量
(1)局部变量i。局部变量i经过编译器处理,在初次使用时赋值,之后存储于栈中。每次循环加1,并与7比较。函数返回时栈复原。
图3-5 变量i在原函数中
图3-6 变量i在汇编语言中
(2)函数参数argc、argv。即为局部变量,存储于栈中。只在对应的函数局部起作用,使用时赋值于寄存器edi和rsi,函数调用完后释放相应栈空间。
图3-7 函数参数在原函数中的位置
图3-8 函数参数在汇编中
3.3.1.3表达式
C语言表达式主要包括五种:赋值表达式、算术表达式、关系表达式、条件表达式、逻辑表达式。
- 赋值表达式
通过mov指令将立即数传到相应地址中。
图3-9 对i赋值为0
图3-10 对i赋值为0的汇编语言
- 算术表达式
汇编语言中通过add实现加法操作(其他算术操作同理)
图3-11 i++
图3-12 i++的汇编语言
- 关系、条件表达式
可以通过cmp的汇编语言实现比较表达式,也可进一步查看条件是否满足。
图3-13 argc不等于4,i<8的原关系表达式
图3-14 相应的汇编语言
3.3.1.3类型
Hello函数中主要包括int型、char型
图3-15 原函数中类型
3.3.2 赋值
通过mov将一个立即数或一个地址的值传给另一个地址。(见3.3.1.3赋值表达式)
3.3.3算术操作
通过add,sub,and,xor,等操作实现+、-、&、|等操作。(见3.3.1.3算术表达式)
3.3.4关系操作、控制转移
通过cmp比较,之后通过jmp,jle,je,jl,je,jne,jge,jg进行跳转实现。可以完成包括==、!=、 >、 <、 >=、 <=的指令。(见3.3.1.3关系表达式)。
3.3.5函数操作
主要包括参数传递(地址/值)、函数调用()、局部变量、函数返回。
- 参数传递
调用函数时,事先将要传入函数中使用的参数存放于寄存器中。存放顺序依次是rdi,rsi,rdx,rcx,r8,r9。当参数超过六个时,将超出的参数放于栈中,待函数调用结束后再释放相应栈。
图3-16 main函数的参数
图3-17 参数传递的汇编语言
- 函数调用
通过call指令调用函数,并将返回地址压入栈中
图3-18 函数调用
- 函数返回
通过ret指令返回栈中记录的地址,并将栈恢复。寄存器rax记录返回值。
图3-19 函数返回
3.4 本章小结
编译是程序从源代码变成可执行程序的第二步,通过编译器将预处理中的高级语言以文本文件格式转换为汇编语言。不同数据和操作有不同的汇编语言,为方便机器识别。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o是一个二进制文件。
作用:采用汇编语言编写程序虽不如高级程序设计语言简便、直观,但是汇编出的目标程序占用内存较少、运行效率较高,且能直接引用计算机的各种设备资源。它通常用于编写系统的核心部分程序,或编写需要耗费大量运行时间和实时性要求较高的程序段。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
gcc –c hello.s –o hello.o(或as hello.s –o hello.o)
图4-1 汇编
图4-2 汇编
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
使用命令readelf -a hello.o查看ELF各节信息。
4.3.1 ELF Header
图4-3 ELF头
(1)Magic
7f 、45、4c、46分别对应ASCII码的Del(删除)、字母E、字母L、字母F。这四个字节被称为ELF文件的魔数,操作系统在加载可执行文件时会确认魔数是否正确,如果不正确则拒绝加载。 第五个字节标识ELF文件是32位(01)还是64位(02)的。 第六个字节标识该ELF文件字节序是小端(01)还是大端(02)的。 第七个字节指示ELF文件的版本号,一般是01。 后九个字节ELF标准未做定义。一般为00。
(2) Class
ELF64标识ELF文件是64位的
- Data
标识该ELF文件字节序是小端的、两位补码。
- Version
标识当前ELF文件版本号为1。
- OS/ABI
指出操作系统类型,ABI是Application Binary Interface 的缩写。
- ABI Version
标识ABI 版本号为0。
- Type
表示文件类型。ELF 文件有 3 种类型,一种是如上所示的REL(Relocatable file)可重定位目标文件,一种是可执行文件(Executable),另外一种是共享库(Shared Library) 。
- Machine
标识机器平台类型为Adcanced Micro Dwvices X86-64。
- Version
当前目标文件的版本号为1。
- Entry point address
程序的虚拟地址入口点。由于该文件还不是可运行的程序,故而这里为零。
- Start of program headers
程序头起点位置,由于该文件还不是可运行的程序,这个目标文件没有程序头,故而这里为零。
- Start of section headers
节头开始处是1240(十进制)。
- Flags
与处理器相关联的标志,x86 平台上该处为0。
- Size of this header
ELF 文件头的字节数,表示每个头字节数为64位。
- Size of program headers
程序头大小,由于该文件还不是可运行的程序,故而这里为零。
- Number of program headers
程序头数量,由于该文件还不是可运行的程序,故而这里为零。
- Size of section headers
节头的大小,表示每个节头字节数为64位。
- Number of section headers
节头的数量,这里是14个。
- Section header string table index
表示节头字符串表索引号。
4.3.2 Section Headers
对象文件中的重定位条目,会构成一个个单独的节。这些节的名字,常是".rel.XXX"或".rela.XXX"的形式。其中XXX表示的是这些重定位条目所作用到的节名。
图4-4 节头
.text:已编译程序的机器代码。
.rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。
.data:已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。
.bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。
.rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表。
.symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,,symtab符号表不包含局部变量的条目。
.strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以nu11结尾的字符串的序列。
4.3.3 重定位节
一个.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
图4-5 可重定位节
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
机器语言指令是一种二进制代码,由操作码和操作数两部分组成。操作码规定了指令的操作,是指令中的关键字,不能缺省。操作数表示该指令的操作对象。
由下图可以看出左边为十六进制的机器指令,右边为对应的汇编指令。通过与hello.o对比我们可以发现二者操作大体上相同,主要有几点区别:
- 分支转移:原来的跳转语句中的标记(.L1,.L2等)全部被替换为实际地址,对函数的调用也变为相对应的实际地址。.s文件可以使用.L1之类的标记帮助理解,而机器语言必须将其翻译成机器能够识别的语言。
- 函数调用:在.s文件中,采用call+函数名,反汇编代码中callq+下一条指令地址(偏移地址为0x0),并添加重定位条目,最终需要通过链接器完成目标地址的确定。
- 全局变量访问:在.s文件中,采用‘段名称+%rip’的寻址方式,反汇编代码中使用0x0(%rip),并添加重定位条目来寻址。
图4-6 hello.o的反汇编
4.5 本章小结
汇编是程序从源代码变成可执行程序的第三步,通过汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。
第5章 链接
5.1 链接的概念与作用
概念:链接是指在电子计算机程序的各模块之间传递参数和控制命令,并把它们组成一个可执行的整体的过程。
作用:在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
注意:这儿的链接是指从 hello.o 到hello生成过程。
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 链接结果
5.3 可执行目标文件hello的格式
与第四章的hello.o对比可以看出:
5.3.1 ELF header
- Type:变成了EXEC(可执行文件)。
- Entry point address:程序的虚拟地址入口点,被改为0x4010f0。
- Start of program headers:程序头起点,被改为64。
- Start of section headers:节头开始处,被改为14208。
- Size of program headers:程序头大小被改为56个字节。
- Number of program headers:程序头数量被改为12。
- Number of section headers:节头增加了13个变为27。
- Section header string table index:节头字符串表索引号,同样增加了13个变为26。
图5-2 ELF 头
5.3.2 Section headers
图5-3 Section headers
新增了一些节包括动态符号表等与链接有关的节。
5.3.3 Program Headers
该部分描述了Filesize(目标文件中的段大小)、Memsiz(内存中的段大小)、Offset(偏移量)、Virtaddr(虚拟空间地址)、Flags(运行时访问权限)、Align(对齐要求)。
图5-4 Program Headers
5.3.4 .symtab
图5-5 .symtab
.dynsym是动态符号表(Dynamic Symbol),由于我们在链接时链接了共享库(-dynamic-linker /lib64/ld-linux-x86-64.so.2),.dynsym节保存了动态链接相关的导入导出符号,不包括模块内部的符号。.dynsym节为.symtab节的子集,并且其内部的符号都没有初始化,这是因为动态链接将连接过程推迟到加载或运行时再加载进内存并链接,于是.dynsym节中的符号都没有初始化。
.symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,,symtab符号表不包含局部变量的条目。
.symtab节中除了来自.dynsym的多出来符号,还有一些符号,他们来自其他的可重定位目标文件(.o)。
5.3.5 重定位文件
图5-6 重定位文件
.rela.dyn,与动态链接有关,连接器在动态链接时在共享库/lib64/ld-linux-x86-64.so.2中找到函数定义,并且等待运行在加载或运行时再加载进内存并链接,故没有符号值。其他符号已经重定位完成,从重定位信息中删除。
5.4 hello的虚拟地址空间
用edb打开
图5-7 edb打开hello
0x00400000开始,程序被载入,其中节的排布与Address中声明的相同。
图5-8 程序起始
例如可以看到虚拟地址从0x400000开始到0x4005a0,这与分析的LOAD段(代码段)起始与大小相对应。
5.5 链接的重定位过程分析
Hello与hello.o的不同:
(1)main开始地址不同
(2)函数调用偏移量不同,hello反汇编里经过的重定位,偏移量为非零;hello.o反汇编未经过重定位,偏移量都为零。所以链接操作就是将各个.o里面的函数引用,全局变量引用进行重定位。
图5-9 hello的反汇编
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
_init
puts@plt
printf@plt
getchar@plt
atoi@plt
exit@plt
sleep@plt
_start
_dl_relocate_static_pie
Main
__libc_csu_init
__libc_csu_fini
_fini
5.7 Hello的动态链接分析
由于编译器无法确定函数运行时的地址,所以需要进行重定位。此时对每一条IC的调用过,目标地址都是PLT的代码逻辑,GOT存放PLT中函数调用的下一条地址。
图5-10 gdb查看puts@got.plt地址
首先用gdb找到查看puts@got.plt地址,之后在edb中找到对应地址,查看调用dl_init前后的变化。
图5-11 dl_init前
图5-12 dl_init后
可以看出,调用dl_init之后0x55557fa8变为对应地址0x00007ffff7e44450。
5.8 本章小结
链接是程序从源代码变成可执行程序的第四步,它在电子计算机程序的各模块之间传递参数和控制命令,并把它们组成一个可执行的整体。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
作用:系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。每次用户通过向shell输人一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash作用: Shell是一个命令行解释器,它是一个交互型的应用级程序,它代表用户运行其他程序。Shell输出一个提示字符,等待输入一个命令行,然后执行这个命令。如果该命令行的第一个单词不是一个内置的shell命令,那么shell就会假设这是一个可执行文件的名字,它将加载并运行这个文件。
处理流程:
(1) 读取从键盘输⼊的命令。
(2) 判断命令是否正确,且将命令⾏的参数改造为系统调⽤execve() 内部处理所要求的形式。
(3) 终端进程调⽤fork() 来创建⼦进程,⾃⾝则⽤系统调⽤wait() 来等待⼦进程完成。
(4)当⼦进程运⾏时,它调⽤execve() 根据命令的名字指定的⽂件到⽬录中查找可⾏性⽂件,调⼊内存并执⾏这个命令。
(6) 如果命令⾏末尾有后台命令符号& 终端进程不执⾏等待系统调⽤,⽽是⽴即发提⽰符,让⽤户输⼊下⼀条命令;如果命令末尾没有&则终端进程要⼀直等待。当⼦进程完成处理后,向⽗进程报告,此时终端进程被唤醒,做完必要的判别⼯作后,再发提⽰符,让⽤户输⼊新命令。
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。
图6-1 调用fork函数
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
fork函数是有趣的(也常常令人迷惑),因为它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork返回子进程的PID。在子进程中,fork返回0。因为子进程的PID总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。
图6-2 execve函数
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。
参数列表是用图6-3中的数据结构表示的。argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0]是可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的,如图6-3所示。envp变量指向一个以ull结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name=value”的名字-值对。
图6-3 环境变量列表的组织结构
在execve加载了filename之后,它调用7.9节中描述的启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型
int main(int argc,char **argv,char **envp);
或者等价的
int main(int argc,char *argv[],char *envp[]);
当main开始执行时,用户栈的组织结构如下图所示。让我们从栈底(高地址)往栈顶(低地址)依次看一看。首先是参数和环境字符串。栈往上紧随其后的是以ull结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串。全局变量environ指向这些指针中的第一个envp[0]。紧随环境变量数组之后的是以null结尾的argv[]数组,其中每个元素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数libc start main的栈帧。
图6-4 一个新程序开始时,用户栈的典型组织结构
6.5 Hello的进程执行
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后再这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.5.1 逻辑控制流
图6-5 逻辑控制流
考虑一个运行着三个进程的系统,如图6-5所示。处理器的一个物理控制流被分成了三个逻辑流,每个进程一个。每个竖直的条表示一个进程的逻辑流的一部分。在这个例子中,三个逻辑流的执行是交错的。进程A运行了一会儿,然后是进程B开始运行到完成。然后,进程C运行了一会儿,进程A接着运行直到完成。最后,进程C可以运行到结束了。
图6-5的关键点在于进程是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占(preempted)(暂时挂起),然后轮到其他进程。对于一个运行在这些进程之一的上下文中的程序,它看上去就像是在独占地使用处理器。唯一的反面例证是,如果我们精确地测量每条指令使用的时间,会发现在程序中一些指令的执行之间,CPU好像会周期性地停顿。然而,每次处理器停顿,它随后会继续执行我们的程序,并不改变程序内存位置或寄存器的内容。
6.5.2 并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行。更准确地说,流X和Y互相并发,当且仅当X在Y开始之后和Y结束之前开始,或者Y在X开始之后和X结束之前开始。例如,图6-5中,进程A和B并发地运行,A和C也一样。另一方面,B和C没有并发地运行,因为B的最后一条指令在C的第一条指令之前执行。
注意,并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流在时间上重叠,那么它们就是并发的,即使它们是运行在同一个处理器上。不过,有时我们会发现确认并行流是很有帮助的,它是并发流的一个真子集。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流(parallel flow),它们并行地运行(running in parallel),且并行地执行(parallel execution)。
6.5.3 私有地址空间
在一台位地址的机器上,地址空间是2”个可能地址的集合,0,1,…,2一1。进程为每个程序提供它自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上说,这个地址空间是私有的。
尽管和每个私有地址空间相关联的内存的内容一般是不同的,但是每个这样的空间都有相同的通用结构。比如,图6-6展示了一个x86-64Liux进程的地址空间的组织结构。
地址空间底部是保留给用户程序的,包括通常的代码、数据、堆和栈段。代码段总是从地址0x400000开始。地址空间顶部保留给内核(操作系统常驻内存的部分)。地址空间的这个部分包含内核在代表进程执行指令时(比如当应用程序执行系统调用时)使用的代码、数据和栈。
图6-6 进程地址空间
6.5.4 用户模式和内核模式
处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
Liux提供了一种聪明的机制,叫做/proc文件系统,它允许用户模式进程访问内核数据结构的内容。/⊙roc文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你可以使用/proc文件系统找出一般的系统属性,比如CPU类型(/proc/cpuinfo),或者某个特殊的进程使用的内存段(/proc/<process-id>/maps)。2.6版本的Liux内核引入/sys文件系统,它输出关于系统总线和设备的额外的低层信息。
6.5.5 上下文切换
内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是sleep系统调用,它显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
中断也可能引发上下文切换。比如,所有的系统都有某种产生周期性定时器中断的机制,通常为每1毫秒或每10毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
6.6 hello的异常与信号处理
6.6.1 异常的类别
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。图6-7中的表对这些类别的属性做了小结。
图6-7 异常的类别。异步异常是由处理器外部的1/O设备中的事件产生的。同步异常是执行一条指令的直接产物
6.6.1.1中断
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序(interrupt handler)。
图6-8概述了一个中断的处理。I/O设备,例如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断,这个异常号标识了引起中断的设备。
图6-8 中断处理。中断处理程序将控制返回给应用程序控制流中的下一条指令
在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,它就将控制返回给下一条指令(也即如果没有发生中断,在控制流中会在当前指令之后的那条指令)。结果是程序继续执行,就好像没有发生过中断一样。
6.6.1.2 陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve),或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的“sysca11 n”指令,当用户程序想要请求服务n时,可以执行这条指令。执行sysca11指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。图6-9概述了一个系统调用的处理。
图6-9 陷阱处理。陷阱处理程序将控制返回给应用程序控制流中的下一条指令
从程序员的角度来看,系统调用和普通的函数调用是一样的。然而,它们的实现非常不同。普通的函数运行在用户模式中,用户模式限制了函数可以执行的指令的类型,而且它们只能访问与调用函数相同的栈。系统调用运行在内核模式中,内核模式允许系统调用执行特权指令,并访问定义在内核中的栈。8.2.4节会更详细地讨论用户模式和内核模式。
6.6.1.3 故障
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。图6-10概述了一个故障的处理。
图6-10 故障处理。根据故障是否能够被修复,故障处理程序要么重新执行引起故障的指令,要么终止
一个经典的故障示例是缺页异常,当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。一个页面就是虚拟内存的一个连续的块(典型的是4KB)。缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中了,指令就可以没有故障地运行完成了。
6.6.1.4 终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。如图6-11所示,处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
图6-11 终止处理。终止处理程序将可知传递给一个内核abort例程,该例程会终止这个应用程序
6.6.2 运行实例
(1)回车符
图6-12 回车符
按下回车符,程序不会有响应直到程序结束,只会多几个空命令行。
(2)CTRL+C
图6-13 CTRL+C 程序终止
进程终止。由于我们从键盘输入Ctrl-C,bash向shell父进程发送信号SIGINT,信号处理函数的逻辑是结束hello,并回收hello进程。
(3)CTRL+Z
图6-14 CTRL+Z 程序暂停
Shell接受到ctrl-z后,向父进程发送SIGSTP信号,使进程挂起,但并未停止。
图6-15 ps和jobs命令
使用ps和jobs命令可以看到hello被挂起,仍在进程中。
图6-16 pstree
使用pstree命令可以看到hello仍在进程中。
6-17 fg和kill命令
使用fg命令,hello会继续放到前台来执行。使用kill -9 PID 向进程发送信号SIGKILL杀死进程hello。
6.7本章小结
进程管理是操作系统的职能之一,主要是对处理机进行管理。为了提高CPU的利用率而采用多道程序技术。通过进程管理来协调多道程序之间的关系,使CPU得到充分的利用。
第7章 hello的存储管理
7.1 hello的存储器地址空间
(1)逻辑地址(Logical Address):是指由程式产生的和段相关的偏移地址部分。例如,在进行C语言指针编程中,能读取指针变量本身值(&操作),实际上这个值就是逻辑地址,是相对于当前进程数据段的地址,不和绝对物理地址相干。只有在Intel实模式下,逻辑地址才和物理地址相等(因为实模式没有分段或分页机制,Cpu不进行自动地址转换);逻辑也就是在Intel保护模式下程式执行代码段限长内的偏移地址(假定代码段、数据段如果完全相同)。应用程式员仅需和逻辑地址打交道,而分段和分页机制对我们来说是完全透明的,仅由系统编程人员涉及。应用程式员虽然自己能直接操作内存,那也只能在操作系统给你分配的内存段操作。
(2)线性地址(Linear Address):是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。ntel80386的线性地址空间容量为4G(2的32次方即32根地址总线寻址)。
(3)物理地址(Physical Address):是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
(4)虚拟内存(Virtual Memory):是指计算机呈现出要比实际拥有的内存大得多的内存量。因此他允许程式员编制并运行比实际系统拥有的内存大得多的程式。这使得许多大型项目也能够在具有有限内存资源的系统上实现。
在Lux0.11内核中,给每个程式(进程)都划分了总容量为64B的虚拟内存空间。因此程式的逻辑地址范围是0x0000000到0x4000000。有时我们也把逻辑地址称为虚拟地址。因为和虚拟内存空间的概念类似,逻辑地址也是和实际物理内存容量无关的。逻辑地址和物理地址的“差距”是0xC0000000,是由于虚拟地址>线性地址>物理地址映射正好差这个值。这个值是由操作系统指定的。机理逻辑地址(或称为虚拟地址)到线性地址是由CPU的段机制自动转换的。如果没有开启分页管理,则线性地址就是物理地址。如果开启了分页管理,那么系统程式需要参和线性地业到物理地址的转换过程。具体是通过设置页目录表和页表项进行的。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符和段内偏移量。
段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。引号,可以理解为数组的下标——而它将会对应一个数组,也就是“段描述符(segment descriptor)”。很多个段描述符,就组了一个数组,叫“段描述符表”。
图7-1 段描述符
Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。那究竟什么时候该用GDT,什么时候该用LDT呢?这是由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT。
GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
首先,给定一个完整的逻辑地址 [段选择符:段内偏移地址],
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。
图7-1 转换地址
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
线性地址是一个32位无符号整数,可以用来表示高达4GB的地址,也就是,高达4294967296个内存单元。线性地址通常用十六进制数字表示,值的范围从0x00000000到0xffffffff)。 程序代码会产生逻辑地址,通过逻辑地址变换就可以生成一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。当采用4KB分页大小的时候,线性地址的高10位为页目录项在页目录表中的编号,中间10位为页表中的页号,其低12位则为偏移地址。如果是使用4MB分页机制,则高10位页号,低22位为偏移地址。如果没有启用分页机制,那么线性地址直接就是物理地址。
CPU的页式内存管理单元,负责把一个线性地址,最终翻译为一个物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。
图7-2 线性地址
如上图,分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。每一个进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中。每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位)。
依据以下步骤进行转换:
(1)从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
(2)根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
(3)根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
(4)将页的起始地址与线性地址中最后12位相加,得到最终我们想要的物理地址
7.4 TLB与四级页表支持下的VA到PA的变换
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。如图7-3所示,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
图7-3 虚拟地址中用以访问TLB的组成部分
图7-4a展示了当TLB命中时(通常情况)所包括的步骤。这里的关键点是,所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。
第1步:CPU产生一个虚拟地址。
第2步和第3步:MMU从TLB中取出相应的PTE。
第4步:MU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
第5步:高速缓存/主存将所请求的数据字返回给CPLU。
当TLB不命中时,MMU必须从L1缓存中取出相应的PTE,如图9-16b所示。新取出的PTE存放在TLB中,可能会覆盖一个已经存在的条目。
图7-4 TLB命中和不命中的操作图
图7-5描述了使用k级页表层次结构的地址翻译。虚拟地址被划分成为k个VPN和1个VPO。每个VPNi都是一个到第i级页表的索引,其中1≤i≤k。第j级页表中的每个PTE,1≤≤k一1,都指向第j十1级的某个页表的基址。第k级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE。对于只有一级的页表结构,PPO和VPO是相同的。
图7-5 使用k级页表的地址翻译
访问k个PTE,第一眼看上去昂贵而不切实际。然而,这里TLB能够起作用,正是通过将不同层次上页表的PTE缓存起来。实际上,带多级页表的地址翻译并不比单级页表慢很多。
7.5 三级Cache支持下的物理内存访问
高速缓存被组织成一个有S个高速缓存组的数组,每个组包含E个高速缓存行,每个行有B个字节的数据块。
对于地址参数S和B 将m个地址位分为了三个字段,如图7-7中b 所示。A 中5 个组索引位是一个到S 个组的数组的索引。第一个组是组0 , 第二个组是组1 , 依此类推。组索引位被解释为一个无符号整数,它告诉我们这个字必须存储在哪个组中。一旦我们知道了这个字必须放在哪个组中,A 中的t个标记位就告诉我们这个组中的哪一行包含这个字(如果有的话)。当且仅当设置了有效位并且该行的标记位与地址A 中的标记位相匹配时,组中的这一行才包含这个字。一旦我们在由组索引标识的组中定位了由标号所标识的行,那么b 个块偏移位给出了在B个字节的数据块中的字偏移。
图7-6 物理内存
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
假设运行在当前进程中的程序执行了如下的execve调用:
execve("a.out",NULL,NULL);
execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地替代了当前程序。加载并运行a.out需要以下几个步骤:
a.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
b.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。图7-7概括了私有区域的不同映射。
c.映射共享区域。如果a.out程序与共享对象(或目标)链接,比如标准C库1ibc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
d.设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的人口点。下一次调度这个进程时,它将从这个人口点开始执行。Liux将根据需要换人代码和数据页面。
图7-7 加载器是如何映射用户地址空间的区域的
7.8 缺页故障与缺页中断处理
假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
1)虚拟地址A是合法的吗?换句话说,A在某个区域结构定义的区域内吗?为了回答这个问题,缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm start和vm end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。这个情况在图9-28中标识为“1”。因为一个进程可以创建任意数量的新虚拟内存区域(使用在下一节中描述的mmap函数),所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Liux使用某些我们没有显示出来的字段,Liux在链表中构建了一棵树,并在这棵树上进行查找。
2)试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。这种情况在图9-28中标识为“2”。
3)此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。
图7-8 虚拟内存
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)(见图9-33)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(读做“break”),它指向堆的顶部。
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
图7-9 堆
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
a.显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用ma11oc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。
b.隐式分配器(implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbage collection)。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
7.10本章小结
本章介绍了HELLO的存储器地址空间、INTEL逻辑地址到线性地址的变换-段式管理、HELLO的线性地址到物理地址的变换-页式管理、TLB与四级页表支持下的VA到PA的变换、三级CACHE支持下的物理内存访问、FORK的内存映射、EXECVE的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
设备管理:unix io接口
这种将设备优雅地映射为文件的方式,允许Linux内核出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
(1)打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的。
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:O RDONLY:只读。O_WRONLY:只写。O_RDWR:可读可写。
(2)改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为。
通过调用1seek函数,应用程序能够显示地修改当前文件的位置。
(3)读写文件。一个读操作就是从文件复制>0个字节到内存,从当前文件位置k开始,然后将增加到k十n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似地,写操作就是从内存复制>0个字节到一个文件,从当前文件位置开始,然后更新k。
应用程序通过分别调用read和write函数来执行输入和输出的。
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值一l表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
(4)关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
进程通过调用close函数关闭一个打开的文件。
8.3 printf的实现分析
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
(1)研究printf的实现,首先来看看printf函数的函数体。
图8-1 printf函数体
看看i = vsprintf(buf, fmt, arg);
图8-2 vsprint函数
可知vsprintf返回的是要打印出来的字符串的长度。所以说:vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
(2)write(buf, i),把buf中的i个元素的值写到终端。
让我们追踪下write吧:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里是给几个寄存器传递了几个参数,然后一个int结束。
(3)一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
再来看看sys_call的实现:
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
如果只是理解printf的实现的话,我们完全可以这样写sys_call:
sys_call:
;ecx中是要打印出的元素个数
;ebx中的是要打印的buf字符数组中的第一个元素
;这个函数的功能就是不断的打印出字符,直到遇到:'\0'
;[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
xor si,si
mov ah,0Fh
mov al,[ebx+si]
cmp al,'\0'
je .end
mov [gs:edi],ax
inc si
loop:
sys_call
.end:
ret
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar()是stdio.h中的库函数,它的作用是从stdin流中读入一个字符,也就是说,如果stdin有数据的话不用输入它就可以直接读取了,第一次getchar()时,确实需要人工的输入,但是如果你输了多个字符,以后的getchar()再执行时就会直接从缓冲区中读取了。
键盘输入的字符都存到缓冲区内,一旦键入回车,getchar就进入缓冲区读取字符,一次只返回第一个字符作为getchar函数的值,如果有循环或足够多的getchar语句,就会依次读出缓冲区内的所有字符直到’\n’.要理解这一点,之所以你输入的一系列字符被依次读出来,是因为循环的作用使得反复利用getchar在缓冲区里读取字符,而不是getchar可以读取多个字符,事实上getchar每次只能读取一个字符.如果需要取消’\n’的影响,可以用getchar();来清除,这里getchar();只是取得了’\n’但是并没有赋给任何字符变量,所以不会有影响,相当于清除了这个字符.
作用1:从缓冲区读走一个字符,相当于清除缓冲区。
作用2:前面的scanf()在读取输入时会在缓冲区中留下一个字符’\n’(输入完按回车键所致),所以如果不在此加一个getchar()把这个回车符取走的话,接下来的scanf()就不会等待从键盘键入字符,而是会直接取走这个“无用的”回车符,从而导致读取有误。
8.5本章小结
输入/输出(I/O)是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从I/O设备复制数据到主存,而输出操作是从主存复制数据到I/O设备。
所有语言的运行时系统都提供执行I/O的较高级别的工具。例如,ANSI C提供标准I/O库,包含像printf和scanf这样执行带缓冲区的I/O函数。C++语言用它的重载操作符<<(输入)和>>(输出)提供了类似的功能。在Liux系统中,是通过使用由内核提供的系统级UixI/O函数来实现这些较高级别的I/O函数的。
结论
Hello主要包括两个过程P2P和020。包括hello.c经过cpp预处理变为hello.i,再经过cc1编译器的处理变为hello.s汇编程序。之后经过as汇编器的处理变为hello.o的二进制目标程序,最后与printf.o一起经过链接器ld的处理比变为可执行目标程序hello。同时,执行hello程序时,shell会fork出一个子进程并分配空间,将要执行的目标文件通过execve装载进内存。进程结束后,内核回收进程,删除内存中相关信息。
计算机系统设计精巧细致。虽然hello在运行时看着非常地简单,但在实际地操作过程中却需要计算机系统地精妙配合方得以实现。前辈们钻研了许久研究出来的计算机可谓是人类智慧的结晶。我在实现hello程序的过程中,也感触良多。Hello.c从一个源代码变为可被用户执行的程序hello经过了诸多步骤,而在计算机的运行上只是弹指一挥间。其中需要的存储、进程、IO管理用户也是无法看到的。只需要通过黑盒测试之后就可以被用户执行。相信在这个基础上,未来超大型计算机的研制将一步步实现。
附件
中间结果 | 名称 | 作用 |
Hello.c | 源程序 | 源代码 |
Hello.i | 预处理后的文本文件 | 便于程序的修改、阅读、移植和调试,也便于实现模块化程序设计 |
Hello.s | 编译后的汇编文件 | 将高级语言源程序翻译成目标程序编译程序,将预处理中的高级语言以文本文件格式转换为汇编语言 |
Hello.o | 汇编后的可重定位目标文件 | 将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式 |
Hello | 可执行目标程序 | 变成可执行程序,使用户可以直接执行而不必知晓原理 |
参考文献
[1]https://baike.baidu.com/item/%E9%A2%84%E5%A4%84%E7%90%86/7833652?fr=aladdin
[2] https://blog.csdn.net/LJH_Gemini/article/details/83119434
[3] https://blog.csdn.net/king_cpp_py/article/details/80334086
[4]https://baike.baidu.com/item/%E6%B1%87%E7%BC%96%E8%AF%AD%E8%A8%80/61826?fr=aladdin
[5] https://blog.csdn.net/qfanmingyiq/article/details/124295287
[8]进程管理_百度百科
[9] https://wenku.baidu.com/view/51913c4b59fb770bf78a6529647d27284a73371e.html
[10] https://blog.csdn.net/hfut_zhanghu/article/details/122340261