计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机
学 号
班 级
学 生
指 导 教 师 刘宏伟
计算机科学与技术学院
2021年6月
摘 要
本文就hello.c文件为研究对象,通过分析从hello.c到可执行文件hello,再到运行该可执行文件到该文件结束运行的一系列过程,了解计算机系统的相应知识。
关键词:计算机系统;程序一生;
目 录
第1章 概述................................................................................... - 4 -
1.1 Hello简介............................................................................ - 4 -
1.2 环境与工具........................................................................... - 4 -
1.3 中间结果............................................................................... - 4 -
1.4 本章小结............................................................................... - 4 -
第2章 预处理............................................................................... - 6 -
2.1 预处理的概念与作用........................................................... - 6 -
2.2在Ubuntu下预处理的命令................................................ - 6 -
2.3 Hello的预处理结果解析.................................................... - 6 -
2.4 本章小结............................................................................... - 7 -
第3章 编译................................................................................... - 8 -
3.1 编译的概念与作用............................................................... - 8 -
3.2 在Ubuntu下编译的命令.................................................... - 8 -
3.3 Hello的编译结果解析........................................................ - 8 -
3.4 本章小结............................................................................. - 11 -
第4章 汇编................................................................................. - 12 -
4.1 汇编的概念与作用............................................................. - 12 -
4.2 在Ubuntu下汇编的命令.................................................. - 12 -
4.3 可重定位目标elf格式...................................................... - 12 -
4.4 Hello.o的结果解析........................................................... - 14 -
4.5 本章小结............................................................................. - 14 -
第5章 链接................................................................................. - 15 -
5.1 链接的概念与作用............................................................. - 15 -
5.2 在Ubuntu下链接的命令.................................................. - 15 -
5.3 可执行目标文件hello的格式......................................... - 15 -
5.4 hello的虚拟地址空间....................................................... - 18 -
5.5 链接的重定位过程分析..................................................... - 20 -
5.6 hello的执行流程............................................................... - 21 -
5.7 Hello的动态链接分析...................................................... - 22 -
5.8 本章小结............................................................................. - 23 -
第6章 hello进程管理.......................................................... - 24 -
6.1 进程的概念与作用............................................................. - 24 -
6.2 简述壳Shell-bash的作用与处理流程........................... - 24 -
6.3 Hello的fork进程创建过程............................................ - 24 -
6.4 Hello的execve过程........................................................ - 25 -
6.5 Hello的进程执行.............................................................. - 25 -
6.6 hello的异常与信号处理................................................... - 26 -
6.7本章小结.............................................................................. - 27 -
第7章 hello的存储管理...................................................... - 28 -
7.1 hello的存储器地址空间................................................... - 28 -
7.2 Intel逻辑地址到线性地址的变换-段式管理.................. - 28 -
7.3 Hello的线性地址到物理地址的变换-页式管理............. - 28 -
7.4 TLB与四级页表支持下的VA到PA的变换................... - 29 -
7.5 三级Cache支持下的物理内存访问................................ - 31 -
7.6 hello进程fork时的内存映射......................................... - 32 -
7.7 hello进程execve时的内存映射..................................... - 32 -
7.8 缺页故障与缺页中断处理................................................. - 33 -
7.9动态存储分配管理.............................................................. - 36 -
7.10本章小结............................................................................ - 37 -
第8章 hello的IO管理....................................................... - 38 -
8.1 Linux的IO设备管理方法................................................. - 38 -
8.2 简述Unix IO接口及其函数.............................................. - 39 -
8.3 printf的实现分析.............................................................. - 39 -
8.4 getchar的实现分析.......................................................... - 39 -
8.5本章小结.............................................................................. - 40 -
结论............................................................................................... - 40 -
附件............................................................................................... - 41 -
参考文献....................................................................................... - 42 -
第1章 概述
1.1 Hello简介
P2P:用C语言写出hello.c文件(Program),经过预处理器(ccp),编译器(ccl),汇编器(as)的预处理编译汇编分别生成.i,.s,.o 文件,最后经过链接器链接可执行文件。在Bash中,进程管理(OS)利用fork生成子程序(Process)。
020:在输入hello的运行命令后,shell利用fork生成子进程,再利用execve加载。经历访存,内存分配等进程结束后被回收。
1.2 环境与工具
软件:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位。
硬件:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
开发及调试工具:Codeblocks gcc cpp edb
1.3 中间结果
文件名字 | 描述 |
hello.i | 修改了的源程序 (文本) |
hello.s | 汇编程序 (文本) |
hello.o | 可重定位 目标程序 (二进制) |
hello.txt | hello.o的ELF格式 |
objdump.txt | hello.o的反汇编文件 |
hello | 可执行目标文件 |
helloelf.txt | hello的ELF格式 |
diff.txt | 分析hello与hello.o的不同的文本文件。 |
1.4 本章小结
通过第一章的描述,使我们了解了在整个研究过程中,hello的From Program to Process与From Zero-0 to Zero-0的基本过程。还了解了本次研究所需要的硬件软件与开发调试工具和本次研究的中间结果,让我们对本次研究有了基本的了解。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理阶段。预处理器(CPP)根据以字符 #开头的命令,修改原始的 C 程序。比如 hello.c 中第 1 行的#include < stdio.h> 命令告诉预处理器读取系统头文件 stdio.h 的内容,并把它直接插入程序文本中。结果就得到了另一个 C 程序,通常是以 .i 作为文件扩展名。
2.1.2预处理的作用
扩展源代码,插人所有用include 命令指定的文件,并扩展所有声明指定 的宏。
2.2在Ubuntu下预处理的命令
预处理命令:cpp hello.c>hello.i 生成.i文件。
2.3 Hello的预处理结果解析
.i文件:
生成了三千余行的文本文件,将所有用include 命令指定的文件与所有声明指定的宏插入。
例如在.i文件中插入的stdio.h头文件中的一部分。
该部分在stdio.h头文件中也存在。
文件最后的部分与.c文件相同。
2.4 本章小结
本章就预处理操作,使我了解了预处理的概念与作用,预处理的命令与预处理结果。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译器(ccl)将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。
3.1.2编译的作用
编译是非常有用的,因为它为不同高级语言的不同编译器提供了通用的输出语言。例如,C 编译器和 Fortran 编译器产生的输出文件用的都是一样的汇编语言。
3.2 在Ubuntu下编译的命令
gcc -S hello.c -o hello.s 生成.s文件
3.3 Hello的编译结果解析
3.3.1 编译结果
.s文件:
3.3.2 数据
常量:
字符串常量存储在.text段中
例如代码中的printf中的两个字符串。
变量:
局部变量存储在堆栈段。
例如代码中的int i 存储在栈的-4(%rbp)中,大小为4个字节。
例如代码中的argc存储在edi中
初始化的全局变量存储在数据段(.data段)。
例如代码中的int sleepsecs=2.5。
可以看到sleepsecs在.data段中。
赋值:
mov 例如给i赋值为0。
3.3.3 类型转换
隐式:
例如代码中的int sleepsecs=2.5隐式类型转换。
3.3.4算术操作
例如hello中i+1表示为:
3.3.5关系操作
例如在比较i<10时:
比较i与9
例如比较argc!=3时:
比较3与argc
3.3.6数组操作
例如在访问数组argv[]时:
利用rax先后读取argv[1]与argv[2]
3.3.7控制转移
if(argc!=3) 若条件成立,则跳转到L2。
for(i=0;i<10;i++) 若条件不成立,则跳转到L4,否则i+1。
3.3.8函数操作
main函数:传递参数argc与argv[]存储在edi与rsi中。
printf函数:
printf("Usage: Hello 学号 姓名!\n");传递参数为打印的字符串的首地址。利用call调用函数。
printf("Hello %s %s\n",argv[1],argv[2]); 传递参数为打印的字符串的首地址。利用call调用函数。
getchar函数:
直接利用call调用。
exit函数:
利用call调用,传递参数为edi为1.
Sleep函数:
传递参数eax作为sleep的参数。利用call调用
3.4 本章小结
本章就编译操作的分析,使我了解了编译的概念与作用,编译的命令与Hello的编译结果。
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件 hello.o 中。hello.o 文件是一个二进制文件,它包含的17个字节是函数 main 的指令编码。如果我们在文本编辑器中打开 hello.o 文件,将看到一堆乱码。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o 产生.o文件
4.3 可重定位目标elf格式
Readelf -a hello.o>hello.txt生成elf格式文件
ELF 头(ELF header):以一个 16 字节的序列开始,这个序列描述了生成该文件 的系统的字的大小和字节顺序。ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括 ELF 头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如 X86-64) 节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。
节头部表:不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
重定位节:链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输人模块的.data 节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输人模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
offset 是需要被修改的引用的节偏移。symDol 标识被修改引用应该指向的符号。type 告知链接器如何修改新的引用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
符号表:在编译时,编译器向汇编器输出每个全局符号,或者是强(strong)或者是弱(weak), 汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局 变量是强符号,未初始化的全局变量是弱符号。
4.4 Hello.o的结果解析
objdump -d -r hello.o>objdump.txt
机器语言由机器指令集构成,能够直接被机器执行。在不同的设备中,汇编语言对应着不同的机器语言指令集 ,通过汇编过程转换成机器指令。 普遍地说,特定的汇编语言和特定的机器语言指令集是一一对应的,不同平台之间不可直接移植。简单来说,汇编语言是机器语言(二进制指令)的文本形式,与指令是一一对应的关系 。在机器语言中,调用函数时并没有像汇编语言一样使用函数的目标地址,而是调用目标函数相对地址的下一条地址。在汇编语言中程序对不同分支进行了分段,以便访问这些分支,而机器语言调用了明确的地址。
4.5 本章小结
本章就对汇编过程的分析,使我们了解了汇编的概念与作用,可重定位目标elf格式及机器语言与汇编语言的关系。
第5章 链接
5.1 链接的概念与作用
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
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.3 可执行目标文件hello的格式
readelf -a hello > helloelf.txt
ELF 头(ELF header):ELF 头描述文件的总体格式。它还包括程序的入口点(entry point), 也就是当程序运行时要执行的第一条指令的地址。.text .rodata 和.data 节与可重定位目标文件中的节是相似的。
节头部表:不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
重定位节:链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输人模块的.data 节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输人模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
offset 是需要被修改的引用的节偏移。symDol 标识被修改引用应该指向的符号。type 告知链接器如何修改新的引用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
符号表:在编译时,编译器向汇编器输出每个全局符号,或者是强(strong)或者是弱(weak), 汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局 变量是强符号,未初始化的全局变量是弱符号。
动态符号表:
动态节:存储着动态链接器使用的信息。
程序头部表:ELF 可执行文件被设计得很容易加载到内存,可执行文件的连续的片(chunk)被映射到连续的内存段。程序头部表(program header table)描述了这种映射关系。
5.4 hello的虚拟地址空间
从程序头部表,我们会看到根据可执行目标文件的内容初始化两个内存段。第 1 行和第 2 行告诉我们第一个段(代码段)有读/执行访问权限,开始于内存地址 0x400000 处,总共的内存大小是 0x76c 字节,并且被初始化为可执行目标文件的头 0x76c 个字节,其中包括 ELF 头、程序头部表以及 .init .text 和 .rodata 节。
PHDR段:开始于内存地址 0x400040 处,总共的内存大小是 0x1c0 字节,
与5.3对比可以明显看出这部分存储着程序头部表。
INTERP段:开始于内存地址 0x400200 处,总共的内存大小是 0x1c 字节
可以明显看出这部分存储解释器。
NOTE: 开始于内存地址 0x40021c 处,总共的内存大小是 0x20 字节
保存辅助信息。
第 3 行和第 4 行告诉我们第二个段(数据段)有读/写访问权限,开始于内存地址 0x600e50 处,总的内存大小为 0x1f8 字节,并用从目标文件中偏移 0xe50处开始的 .data节中的 0x1f8 个字节初始化。
DYNAMIC段:开始于内存地址 0x600e50 处,总共的内存大小是 0x1a0 字节
与5.3对比可以明显看出这部分存动态节的数据。
5.5 链接的重定位过程分析
Objdump -d -r hello>diff.txt生成分析hello与hello.o的不同的文本文件。
链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。
hello增加了.init节与.plt节与.fini节:
Hello还在增加的三个节中加入了hello所需要的_init, puts, printf, _libc_start_main, getchar, sleep, exit, .plt.got,_start, _libc_cus_init, libc_csu_fini, _fini 函数。
hello的地址较hello.s的地址发生了偏移。
重定位由两步组成:
重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输人模块的.data 节被全部合并成一个节,这个节成为输出的可执行目标文件的.data 节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输人模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
重定位举例:
从hello的符号表中,变量sleepsecs的ADDR(symbol) = ADDR(sleepsecs) = 0x600904;
从hello.o的重定位节中获取sleepsec的重定位信息type= R_X86_64_PC32,offset=0x5c, addend=-4。
在可执行文件的反汇编文件中得到ADDR(s) = ADDR(.text) = 0x4004fa。
refaddr = ADDR(s) + offset = 0x4004fa + 0x5c=0x400556
*refptr = (unsigned) (ADDR(symbol) + addend - refaddr) = (unsigned) (0x600904 – 0x4-0x400556) = (unsigned) (0x2003aa) 小端存储
在可执行文件的反汇编文件中对计算结果进行验证:发现计算正确
5.6 hello的执行流程
_init 0x0000000000400488
_start () 0x000000000040052a
__libc_start_main 0x000000000040052a
__libc_csu_init () 0x0000000000400624
_init () 0x0000000000400488
main () 0x00000000004005b2
_IO_puts 0x00000000004004b5
__GI_exit 0x00000000004004e5
_fini () 0x000000000040063c
_init 0x0000000000400488
_start 0x000000000040052a
__libc_csu_init 0x0000000000400624
_init 0x0000000000400488
main 0x00000000004005b2
__sleep 0x00000000004004f5
__printf 0x00000000004004b5
__sleep 0x00000000004004f5
__printf 0x00000000004004b5
__sleep 0x00000000004004f5
__printf 0x00000000004004b5
__sleep 0x00000000004004f5
__printf 0x00000000004004b5
__sleep 0x00000000004004f5
__printf 0x00000000004004b5
__sleep 0x00000000004004f5
__printf 0x00000000004004b5
__sleep 0x00000000004004f5
__printf 0x00000000004004b5
__sleep 0x00000000004004f5
__printf 0x00000000004004b5
__sleep 0x00000000004004f5
__printf 0x00000000004004b5
__sleep 0x00000000004004f5
__printf 0x00000000004004b5
getchar() 0x00000000004004d5
__GI_exit 0x00000000004004e5
_fini () 0x000000000040063c
5.7 Hello的动态链接分析
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接。
假设程序调用一个由共享库定义的函数。编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。不过,这种方法并不是 PIC,因为它需要链接器修改调用模块的代码段,GNU 编译系统使用了一种很有趣的技术来解决这个问题,称为延迟绑定(lazy binding) 将过程地址的绑定推迟到第一次调用该过程时。延迟绑定是通过两个数据结构之间简洁但又有些复杂的交互来实现的,这两个数据结构是:GOT 和过程链接表(Procedure Linkage Table, PLT),如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的 GOT 和 PLT。GOT 是数据段的一部分,而PLT 是代码段的一部分。
可以看到.got大小为0x10,地址为0x600ff0。
可以看到.got.plt大小为0x40,地址为0x601000。
在运行dl_start前.got与.got.plt的存储如下:
在运行dl_start后.got与.got.plt的存储如下:
可以很明显的发现这些段发生了变化。
5.8 本章小结
本章就链接的概念与作用,在Ubuntu下链接的命令,可执行目标文件hello的格式,hello的虚拟地址空间,链接的重定位过程分析,hello的执行流程,Hello的动态链接分析对链接进行了分析。
第6章 hello进程管理
6.1 进程的概念与作用
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。每次用户通过向 shell 输人一个可执行目标文件的名字,运行程序时,shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个交互型的应用级程序,它代表用户运行其他程序。最早的 shell 是 sh 程序,后面出现了一些变种,比如 csh,tcsh,ksh 和 bash,shell 执行一系列的读/求值(read/evaluate)步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
6.3 Hello的fork进程创建过程
父进程通过调用 fork 函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的 PID。
fork 函数只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork 返回子进程的PID在子进程中,fork 返回 0。因为子进程的 PID 总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。
hello创建进程
观察hello进程
6.4 Hello的execve过程
execve 函数在当前进程的上下文中加载并运行一个新程序。execve 函数加载并运行可执行目标文件 filename, 且带参数列表 argv 和环境变量列表 envp只有当出现错误时,例如找不到 filename才会返回到调用程序。所以,与 fork —次调用返回两次不同,execve调用一次并从不返回。
6.5 Hello的进程执行
多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片因此,多任务也叫做时间分片(time slicing)。
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。 一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction), 比如停止处理器、改变模式位,或者发起一个 I/O 操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷人系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling), 是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换 1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,hello中的 sleep 系统调用,它显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。中断也可能引发上下文切换。比如,所有的系统都有某种产生周期性定时器中断的机制,通常为每 1 毫秒或每 10 毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
6.6 hello的异常与信号处理
正常运行:
可以看到程序结束后无该进程,说明上述进程被回收。
在键盘上输入 Ctrl+C 会导致内核发送一个 SIGINT 信号到前台进程组中的每个进程。结果是终止前台作业。
可以看到Ctrl+C后无该进程,说明上述进程被回收。
输入 Ctrl+Z 会发送一个 SIGTSTP 信号到前台进程组中的每个进程。结果是停止(挂起)前台作业。
可以看到Ctrl+Z后有进程,说明上述进程未被回收。
发现为停止(挂起)前台作业
可继续运行该进程
也可终止该进程
不停乱按键盘
进程忽略该信号
6.7本章小结
本文就hello进程管理,使我了解了进程的概念与作用,壳Shell-bash的作用与处理流程,Hello的fork进程创建过程,Hello的execve过程,Hello的进程执行,hello的异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
物理地址:计算机系统的主存被组织成一个由 M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。第一个字节的地址为 0, 接下来的字节地址为 1,再下一个为 2, 依此类推。给定这种简单的结构,CPU 访问内存的最自然的方式就是使用物理地址。我们把这种方式称为物理寻址。
逻辑地址:这存储单元的地址就可以用段基址(段地址)和段内偏移量(偏移地址)来表示,段基址确定它所在的段居于整个存储空间的位置,偏移量确定它在段内的位置,这种地址表示方式称为逻辑地址,通常表示为段地址:偏移地址的形式。
线性地址:是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:虚拟地址是程序运行在保护模式下,这样程序访问存储器所使用的逻辑地址称为虚拟地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理(segmentation),是指把一个程序分成若干个段(segment)进行存储,每个段都是一个逻辑实体(logical entity),程序员需要知道并使用它。它的产生是与程序的模块化直接有关的。段式管理是通过段表进行的,它包括段号或段名、段起点、装入位、段的长度等。此外还需要主存占用区域表、主存可用区域表。
7.3 Hello的线性地址到物理地址的变换-页式管理
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在DRAM 中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到 DRAM 中,替换这个牺牲页。
这些功能是由软硬件联合提供的,包括操作系统软件、MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存中叫做页表(page table)的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作 系统负责维护页表的内容,以及在磁盘与 DRAM 之间来回传送页。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB 是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个 PTE 组成的块。TLB 通常有高度的相联度。如图 9-15 所示,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果 TLB 有:T=2t个组,那么 TLB 索引(TLBI)是由VPN 的 t 个最低位组成的,而 TLB 标记(TLBT)是由 VPN 中剩余的位组成的。
图 9-16a 展示了当 TLB 命中时(通常情况)所包括的步骤。这里的关键点是,所有的地址翻译步骤都是在芯片上的 MMU 中执行的,因此非常快。
•第 1 步:CPU 产生一个虚拟地址。
•第 2 步和第 3 步:MMU 从 TLB 中取出相应的 PTE
•第 4 步:MMU 将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
•第 5 步:高速缓存/主存将所请求的数据字返回给 CPU
当 TLB 不命中时,MMU 必须从 L1 缓存中取出相应的 PTE, 如图 9-16b 所示。新取出的 PTE 存放在 TLB 中,可能会覆盖一个已经存在的条目。
用来压缩页表的常用方法是使用层次结构的页表。用一个具体的示例是最容易理解这个思想的。假设 32 位虚拟地址空间被分为 4KB 的页,而每个页表条目都是 4 字节。还假设在这一时刻,虚拟地址空间有如下形式:内存的前 2K 个页面分配给了代码和数据,接下来的 6K 个页面还未分配,再接下来的 1023 个页面也未分配,接下来的 1 个页面分配给了用户栈。图 9-17 展示了我们如何为这个虚拟地址空间构造一个两级的页表层次结构。
一级页表中的每个 PTE 负责映射虚拟地址空间中一个 4MB 的片(chunk),这里每一片都是由 1024 个连续的页面组成的。比如,PTE0 映射第一片,PTE1 映射接下来的一片,以此类推。假设地址空间是 4GB,1024 个 PTE 已经足够覆盖整个空间了。
如果片 i 中的每个页面都未被分配,那么一级 PTEi 就为空。例如,图 9-17 中,片 2到7是未被分配的。然而,如果在片 i 中至少有一个页是分配了的,那么一级PTEi就指向一个二级页表的基址。例如,在图 9-17 中,片 0、1 和 8 的所有或者部分已被分配,所以它们的一级 PTE 就指向二级页表。
二级页表中的每个 PTE 都负责映射一个 4KB 的虚拟内存页面,就像我们查看只有一级的页表一样。注意,使用 4 字节的 PTE。每个一级和二级页表都是 4KB 字节,这刚好和一个页面的大小是一样的。
图 9-25 描述了使用4级页表层次结构的地址翻译。虚拟地址被划分成为4个 VPN 和1 个 VPO。每个 VPNi 都是一个到第 i 级页表的索引,其中1<=i<=4。 第j级页表中的每个 PTE,1<=j<=3,都指向第 j+1 级的某个页表的基址。第4级页表中的每个 PTE 包含某个物理页面的 PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定 PPN之前,MMU 必须访问4个 PTE。对于只有一级的页表结构,PPO 和 VPO 是相同的。
7.5 三级Cache支持下的物理内存访问
位于处理器芯片上的 L1 高速缓存的容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快。一个容量为数十万到数百万字节的更大的 L2 高速缓存通过一条特殊的总线连接到处理器。进程访问 L2 高速缓存的时间要比访问 L1 高速缓存的时间长 5 倍,但是这仍然比访问主存的时间快 5到10 倍。L1 和 L2 高速缓存是用一种叫做静态随机访问存储器(SRAM)的硬件技术实现的。比较新的、处理能力更强大的系统甚至有三级高速缓存:LI,L2 和L3系统可以获得一个很大的存储器,同时访问速度也很快,原因是利用了高速缓存的局部性原理,即程序具有访问局部区域里的数据和代码的趋势。通过让高速缓存里存放可能经常访问的数据,大部分的内存操作都能在快速的高速缓存中完成。
在处理器和一个较大较慢的设备(例如主存)之间插入一个更小更快的存储设备(例如高速缓存)的想法已经成为一个普遍的观念。实际上,每个计算机系统中的存储设备都被组织成了一个存储器层次结构,如图 1-9 所示。在这个层次结构中,从上至下,设备的访问速度越来越慢、容量越来越大,并且每字节的造价也越来越便宜。寄存器文件在层次结构中位于最顶部,也就是第 0 级或记为 L0,这里我们展示的是三层高速缓存 L1 到 L3,占据存储器层次结构的第 1 层到第 3 层。主存在第 4 层,以此类推。
存储器层次结构的主要思想是上一层的存储器作为低一层存储器的高速缓存。因此,寄存器文件就是 L1 的高速缓存,L1 是 L2 的高速缓存,L2 是 L3 的高速缓存,L3 是主存的高速缓存,而主存又是磁盘的高速缓存。在某些具有分布式文件系统的网络系统中,本地磁盘就是存储在其他系统中磁盘上的数据的高速缓存。
7.6 hello进程fork时的内存映射
当 fork 函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID为了给这个新进程创建虚拟内存,它创建了当前进程的构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用 fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
要运行可执行目标文件hello, 我们可以在 Linux shell 的命令行中输入它的名字
./hello 1190201619 惠羿
因为 hello 不是一个内置的 shell 命令,所以 shell 会认为 hello 是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器(loader)的操作系统代码来运行它。任何Linux 程序都可以通过调用execve函数来调用加载器。加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载。
虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。我们就能够理解 execve 函数实际上是如何加载和执行程序的。假设运行在当前进程中的程序执行了如下的 execve 调用:
execve(“hello”,NULL,NULL);
函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用 hello程序有效地替代了当前程序。加载并运行 hello 需要以下几个步骤:
删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。图 9-31 概括了私有区域的不同映射。
映射共享区域。如果 hello 程序与共享对象(或目标)链接,比如标准 C 库 libc.so。那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的人口点。下一次调度这个进程时,它将从这个入口点开始执行。Linux 将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
在有些情况中,被引用的存储器位置实际上是存储在磁盘存储器上的。此时,硬件会产生一个缺页(page fault)异常信号。同其他异常一样,这个异常会导致处理器调用操作系统的异常处理程序代码。然后这段代码会发起一个从磁盘到主存的传送操作。一旦完成,操作系统会返回到原来的程序,而导致缺页的指令会被重新执行。这次,存储器引用将成功,虽然可能会导致高速缓存不命中。让硬件调用操作系统例程,然后操作系统例程又会将控制返回给硬件,这就使得硬件和系统软件在处理缺页时能协同工作。因为访问磁盘需要数百万个时钟周期,0S 缺页中断处理程序执行的处理所需的几百个时钟周期对性能的影响可以忽略不计。
从处理器的角度来看,将用暂停来处理短时间的高速缓存不命中和用异常处理来处理长时间的缺页结合起来,能够顾及到存储器访问时由于存储器层次结构引起的所有不可预测性。
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的 abort 例程,abort 例程会终止引起故障的应用程序。图 8-7 概述了一个故障的处理。
一个经典的故障示例是缺页异常,当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。就像我们将在第 9 章中看到的那样,一个页面就是虚拟内存的一个连续的块(典型的是 4KB)缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中了,指令就可以没有故障地运行完成了。
在虚拟内存的习惯说法中,DRAM 缓存不命中称为缺页(page fault), 图 9-6 展示了在缺页之前我们的示例页表的状态。CPU 引用了 VP 3 中的一个字,VP 3 并未缓存在DRAM 中。地址翻译硬件从内存中读取 PTE 3, 从有效位推断出 VP 3 未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在 PP 3 中的 VP 4。如果 VP 4 已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改 VP 4 的页表条目,反映出 VP 4 不再缓存在主存中这一事实。
接下来,内核从磁盘复制 VP 3 到内存中的 PP 3, 更新 PTE 3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP 3 已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。图 9-7 展示了在缺页之后我们的示例页表的状态。
假设 MMU 在试图翻译某个虚拟地址 A 时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
1) 虚拟地址 A 是合法的吗?换句话说,A 在某个区域结构定义的区域内吗?为了回答这个问题,缺页处理程序搜索区域结构的链表,把 A 和每个区域结构中的 vm_start 和_end 做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。这个情况在图 9-28 中标识为 “1”。因为一个进程可以创建任意数量的新虚拟内存区域所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Linux 使用某些我们没有显示出来的字段,Linux 在链表中构建了一棵树,并在这棵树上进行査找。
2) 试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。这种情况在图 9-28 中标识为 “2”。
3) 此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU 重新启动引起缺页的指令,这条指令将再次发送 A 到 MMU。这次,MMU 就能正常地翻译 A而不会再产生缺页中断了。
7.9动态存储分配管理
虽然可以使用低级的 mmap 和 munmap 函数来创建和删除虚拟内存的区域,但是 C 程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器更方便,也有更好的可移植性。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)(见图 9-33)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。 对于每个进程,内核维护着一个变量 brk,它指向堆的顶部。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
显式分配器(explicit allocator), 要求应用显式地释放任何已分配的块。例如,C 标准库提供一种叫做 malloc 程序包的显式分配器。C 程序通过调用 malloc 函数来分配一个块,并通过调用 free 函数来释放一个块。
隐式分配器(implicit allocator), 另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器, 而自动释放未使用的已分配的块的过程叫做垃级收集例如,诸如 Lisp, ML 以及 Java 之类的高级语言就依赖垃圾收集来释放已分配的块。
7.10本章小结
本章就hello的存储管理,使我了解了hello的存储器地址空间,Intel逻辑地址到线性地址的变换-段式管理,Hello的线性地址到物理地址的变换-页式管理,TLB与四级页表支持下的VA到PA的变换,缺页故障与缺页中断处理,hello进程fork时的内存映射,hello进程execve时的内存映射,缺页故障与缺页中断处理,动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
一个 Linux 文件就是一个 m 个字节的序列,所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
设备管理:unix io接口
这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O, 这使得所有的输人和输出都能以一种统一且一致的方式来执行:
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)和标准错误(描述符为 2)。头文件< unistd.h> 定义了常量 STDIN_FILENO,STDOUT_FILENO 和 STDERR_FILENO,它们可用来代替显式的描述符值。
改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置 k,初始为0, 这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行 seek 操作,显式地设置文件的当前位置为是k。
读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k开始,然后将k增加到 k+n。给定一个大小为 m 字节的文件,当 时执行读操作会触发一个称为 end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的 “EOF 符号”。
类似地,写操作就是从内存复制 n>0 个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2 简述Unix IO接口及其函数
进程是通过调用 open 函数来打开一个已存在的文件或者创建一个新文件的:
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件。mode 参数指定了新文件的访问权限位。
进程通过调用 close 函数关闭一个打开的文件。
关闭一个已关闭的描述符会出错。
应用程序是通过分别调用 read 和 write 函数来执行输入和输出的。
read 函数从描述符为 fd 的当前文件位置复制最多 n 个字节到内存位置 buf。返回值-1,表示一个错误,而返回值 0 表示 EOF。否则,返回值表示的是实际传送的字节数量。
write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件位置。
8.3 printf的实现分析
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章就hello的IO管理,使我了解了Linux的IO设备管理方法,Unix IO接口及其函数,printf的实现,getchar的实现。
结论
Hello的一生:
- 源代码:用C语言编写hello.c的代码。
- 预处理:预处理器将hello.c处理成hello.i。
- 编译:编译器将hello.i 翻译成 hello.s。
- 汇编:汇编器将hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件 hello.o 中。
- 链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。
- 输入命令:在shell中输入./hello 1190201619 惠羿 shell识别并发送信号
- 创建子进程:进程管理shell通过fork()创建子进程。
- 运行:进程管理 shell利用execve加载进程。
- 执行:进程管理分配mmap,时间片。
- 访存:MMU将CPU给出的虚拟地址翻译为物理地址,再根据物理地址去内存中寻找数据。
- 动态申请内存:通过调用malloc,从堆中申请内存。
- 结束:shell父进程回收子进程。
这就是hello的一生,虽然它的一生在机器中很短暂,但它的一生基本上囊括了计算机系统的所有知识。虽然hello的一生看起来简单,但对hello的一生的研究对我们了解计算机系统有着重要的作用。
附件
文件名字 | 描述 |
hello.i | 修改了的源程序 (文本) |
hello.s | 汇编程序 (文本) |
hello.o | 可重定位 目标程序 (二进制) |
hello.txt | hello.o的ELF格式 |
objdump.txt | hello.o的反汇编文件 |
hello | 可执行目标文件 |
helloelf.txt | hello的ELF格式 |
diff.txt | 分析hello与hello.o的不同的文本文件。 |
参考文献
[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.