计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 1190202026
班 级 1936601
学 生 周俣生
指 导 教 师 刘宏伟
计算机科学与技术学院
2021年6月
本文详细分析分析了C语言程序hello.c在Ubuntu下的执行过程,主要介绍了hello.c的预处理、编译、汇编、链接、进程管理、存储管理以及I/O管理。
通过跟踪hello程序的执行过程,带我们认识了计算机系统的工作方式。
关键词:预处理;编译;汇编;链接;进程;I/O;虚拟内存
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 5 -
3.2 在Ubuntu下编译的命令............................................................................. - 6 -
4.2 在Ubuntu下汇编的命令............................................................................. - 7 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
7.1 hello的存储器地址空间............................................................................ - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理....................................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 11 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 11 -
7.6 hello进程fork时的内存映射.................................................................. - 11 -
7.7 hello进程execve时的内存映射.............................................................. - 11 -
7.8 缺页故障与缺页中断处理........................................................................... - 11 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
第1章 概述
1.1 Hello简介
程序员利用C语言编写了C程序hello.c,经过预处理、编译、汇编、链接,生成了可执行文件:hello。
在Bash中输入命令,OS进程管理为hello 程序fork进程,调用execve加载,为进程mmap,分时间片,让hello程序得以在Hardware(CPU/RAM/IO)上执行(取指译码执行/流水线等)。
OS(存储管理)与MMU为hello程序翻译从VA到PA;TLB、4级页表、3级Cache,Pagefile等等为hello程序加速;IO管理与信号处理,软硬结合,才使hello程序最终在屏幕上显示。
Bash在运行结束后回收了进程。
O2O: From Zero-0 to Zero-0。
P2P: From Program to Process
1.2 环境与工具
1.硬件环境:
Win10_64位
处理器:Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz 2.59 GHz
机带RAM:8.00GB
2.软件环境:
Windows10、Ubuntu
3.开发工具
EDB、GDB、VIM、Objdump、readelf
1.3 中间结果
hello.c:C语言程序
hello.i:C语言程序hello.c预处理后得到的新的C程序
hello.s:将hello.c编译后得到汇编语言文本文件
hello.o:将hello.c汇编后得到的机器语言指令的可重定位目标程序
hello:将hello.o链接后得到的可执行文件
elf.txt:hello.o的ELF文件
elf1.txt:hello的ELF文件
hello.txt:hello.o反汇编后得到的汇编语言代码
hello1.txt:hello反汇编后得到的汇编语言代码
1.4 本章小结
本章系统的介绍了hello程序P2P,020的过程。给出了本次实验的系统环境以及所使用的工具,并列出了实验过程中所产生的中间文件。
第2章 预处理
2.1 预处理的概念与作用
概念:
预处理是指在程序源代码被编译之前,由预处理器对程序源代码进行的处理。这个过程并不对程序的源代码进行解释,但它把源代码分割或处理成为特定的符号用来支持宏调调用。在C语言源程序中可以加入一些预处理命令,以改进程序设计环境,提高编程效率,这些预处理伪字符是统一规定的,但是它们不是C语言本身的组成部分,不能直接对它们进行编译。
C提供的预处理命令功能主要有以下3种:
(1)宏定义
(2)文件包含
(3)条件编译
分别用宏定义伪命令,文件包含命令,条件编译命令来实现,为了与一般C语句相区别,这些命令以符号#开头。
2.2在Ubuntu下预处理的命令
cpp hello.c hello.i
gcc -E hello.c -o hello.i
图2.1 Ubuntu下预处理命令
2.3 Hello的预处理结果解析
使用命令cpp -E hello.c,在终端查看预处理结果。
或者打开hello.i,直接在文本中查看。
图2.2 Ubuntu下预处理结果
预处理命令的功能主要有三种(1)宏定义(2)文件包含(3)条件编译。
hello.c文件*有三条预处理指令:
图2.3 hello.c头文件
预处理器读取系统头文件的内容,然后将它们直接插入程序文本中。
从hello.i文本中可以看到stdio.h的位置在/usr/include中,我们在/usr/include中利用ls命令查看文件。
图2.4 /usr/include下的文件
2.4 本章小结
在C语言程序中加入一些预处理命令,以改进程序设计环境,提高编程效率,这此预处理伪字符是统一规定的,但它不是C语言本身的组成部分,不能直接对它们进行编译。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。结果就得到了另一个C程序,通常是以.i作为文件扩展名。
第3章 编译
3.1 编译的概念与作用
编译是编译器将预处理文本翻译为汇编文本的过程。
编译器是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的程序。编译器(ccl)将文本文件hello.i翻译成hello.s,它包含一个汇编语言程序。该程序包含函数main的定义。
3.2 在Ubuntu下编译的命令
gcc -S hello.c -o hello.s
图3.1 Ubuntu下编译命令
3.3 Hello的编译结果解析
3.3.1
在hello.c中定义了int型全局变量sleepsecs。
图3.2 hello.c中定义的全局变量
在程序中被声明为long类型的全局变量,并为其分配四个字节的空间。
图3.3 编译结果
将sleepsecs赋值为2。
图3.4 编译结果
在复制过程中有一次隐式类型转换。
从float或者double转换成int,值将会向零舍人。例如, 1.999将被转换成1,而-1.999将被转换成-1。进一步来说,值可能会溢出。C语言标准没有对这种情况指定固定的结果。与Intel兼容的微处理器指定位模式[10⋯00]为整数不确定值。一个从浮点数到整数的转换,如果不能为该浮点数找到一个合理的整数近似值,就会产生这样一个值。因此,表达式(int)+1e10会得到-21483648,即从一个正值变成了一个负值。
3.3.2
在hello.c中定义了int型局部变量i。
编译器将局部变量存储在寄存器或栈空间中。在hello.s中编译器将局部变量i存放在栈空间%rbx中。
图3.5 编译结果
将i赋值为0。
图3.6 编译结果
3.3.3
在hello.c*有两条print语句。
图3.7 hello.c中的print语句
在hello.s中分别将两条字符串存储在.LC0中和.LC1中。
图3.8 编译结果
3.3.4
在hello.c中有一条条件语句。
图3.9 hello.c中的if语句
编译器将argc放在寄存器%edi中。如果%edi中的值不等于3,则跳转到.L6。
图3.10 编译结果
加载字符串.LC0的地址。调用printf函数打印字符串。
将立即数1写入寄存器%edi,调用exit()函数。
图3.11 编译结果
3.3.5
在hello.c中有for循环语句。
图3.12 hello.c中的for语句
用寄存器%ebx保存局部变量i,赋值为0,然后跳转到.L2。
图3.13 编译结果
如果寄存器%ebx中的值(i)小于等于9,则跳转到.L3。进入循环内部。
图3.14 编译结果
将16(%rbp)中的参数argv[1]写入%rcx,将8(%rbp)中的参数argv[2]写入%rdx,加载字符串.LC1的地址到%rsi。调用printf函数。将sleepsecs赋值给%edi,作为参数传递给sleep函数。将%edi(i)加1。
图3.15 编译结果
3.3.6
汇编语言中的寻址模式。
图3.16 汇编语言中的寻址模式
参数argv[1],argv[2]存放的地址分别16(%rbp)和8(%rbp)。
图3.17 编译结果
3.3.7
C语言的算术操作对应的汇编语言。
图3.18 C语言的算术操作对应的汇编语言
3.4 本章小结
本章节主要结合了C语言中的各种数据类型,各种运算以及各种函数操作,逐个分析了编译器产生的汇编代码,介绍了编译器是如何处理C语言程序的。
在编译阶段,编译器将高级语言编译成汇编语言。汇编语言不具有可移植性,是直接面向处理器的语言,是机器指令的一种符号表示,不同类型的计算机系统有不同的机器指令系统,也就有不同的汇编语言。
第4章 汇编
4.1 汇编的概念与作用
汇编是将汇编语言翻译为机器语言的过程。
汇编器(as)将hello.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,如果我们在文本编辑器中打开hello.o文件,将看到一堆乱码。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o
4.1 Ubuntu下汇编命令
4.3 可重定位目标elf格式
用readelf -a hello.o > elf.txt生成hello.o的ELF文件。
4.2 Ubuntu下生成ELF文件命令
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节序列。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
图4.3 ELF头
共13个节头。
图4.4 节头
图4.5 重定位节信息
可知,需要重定位的信息有.LC0,puts,exit,.LC1,__printf_chk,sleepsecs,sleep,stdin,和_IO_getc。
4.4 Hello.o的结果解析
输入命令objdump -d -r hello.o > hello.txt,获得hello.o的反汇编代码,保存在hello.txt文件中。
图4.6 反汇编hello.o命令
打开hello.txt,查看反汇编代码。
图4.7 反汇编代码hello.txt
图4.8 hello.s
机器语言完全由二进制0、1构成,在这里的反汇编代码中,以16进制格式显示。两个16进制数组成一个字节编码,表示一个运算符或者操作数。
将hello.o反汇编后得到的代码与hello.s大体相同,存在部分差异:
- 在反汇编得到的代码中,跳转指令后跟着的是地址,而在hello.s中,跳转指令后跟着的是.L2、.L3这样的代码段标签。
- 在反汇编得到的代码中,call后面跟随的地址是相对地址,而在hello.s中,call后面跟随的是函数名。
4.5 本章小结
本章节介绍了汇编过程,汇编器将hello.s汇编得到hello.o,查看了hello.o的ELF。使用objdump工具对hello.o进行了反汇编,得到了反汇编代码,并与之前得到的hello.s进行比较。通过观察二者之间的异同,了解分析汇编器将汇编语言翻译到机器语言的这一过程。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序自动执行的。
5.2 在Ubuntu下链接的命令
在终端中输入链接命令:ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
图5.1 Ubuntu下链接命令
5.3 可执行目标文件hello的格式
用readelf工具,将hello的ELF文件保存在elf1.txt中。
输入命令:readelf -a hello > elf1.txt
查看ELF头信息:
图5.2 ELF头信息
节头部表包含了hello中的每一个节,包含了名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
节头:
图5.3 节头信息
5.4 hello的虚拟地址空间
使用edb加载hello。
图5.4 edb下打开hello
图5.5 在节头部表中的信息
图5.6 在edb中查看的节头部表的信息
5.5 链接的重定位过程分析
objdump -d -r hello > hello1.txt
图5.7 反汇编命令
打开hello1.txt,查看hello的反汇编代码。
图5.8 hello反汇编代码
在使用ld命令链接的时候,指定了动态链接器为/lib64/ld-linux-x86-64.so.2,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入。
链接器将所有R_X86_64_PC32和R_X86_64_PLT32换成计算好的地址。
以sleepsecs为例:
图5.9 反汇编代码
相应的重定位条目r由4个字段组成:
r.offset = 0x4b;
r.symbol = sleepsecs;
r.type = R_X86_64_PC32;
r.addend = -4;
R_X86_64_PC32重定位算法如下:
图5.10 R_X86_64_PC32重定位算法
refaddr=0x4005a2+0x4b=0x4005ed
*refptr=(unsigned)(ADDR(r.sleepsecs)+r.addend-refaddr)=0x601044+(-0x4)-0x4005ed=(unsigned) 0x200a53
图5.11 反汇编代码
5.6 hello的执行流程
0x7f43 55798061 ld -2.27 .so!oom
0x7f43 55798090 ld -2.27 .so!_start
0x7f43 55798098 ld -2.27 .so!_dl_start_user
0x7f43 557980e0 ld -2.27 .so!rtld_lock_default_lock_recursive
0x7f43 557980f0 ld -2.27 . so!rtld_lock_default_unlock_recursive
0x7f43 55798100 ld -2.27 .so!lookup_doit
0x7f43 55798160 ld -2.27 .so!dlmopen_doit
0x7f43 557981b0 ld -2.27 .so!print_unresolved
0x7f43 557981f0 ld -2.27 .so!print_missing_version
0x7f43 55798230 ld -2.27 .so!do_preload
0x7f43 557982e0 ld -2.27 .so!map_doit
0x7f43 55798ea0 ld -2.27 .so!_dl_start
0x4004f0 hello!_init
0x400520 hello!puts@plt
0x400530 hello!_IO_getc@plt
0x400540 hello!__printf_chk@plt
0x400550 hello!exit@plt
0x400560 hello!sleep@plt
0x400570 hello!_start
0x4005a0 hello!_dl_relocate_static_pie
0x4005a2 hello!main
0x400620 hello!__libc_csu_init
0x400690 hello!__libc_csu_fini
0x400694 hello!_fini
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接器使用过程链接表 PLT+全局偏移量 表 GOT 实现函数的动态链接,GOT 中存放函数目标地址,PLT 使用 GOT 中地址 跳转到目标函数。下面是程序在run前后的(即执行dl_init函数前后)的GOT表变化情况。
图5.12 节头部表
0x600000处偏移0x1000个字节,起始位置为0x601000。
图5.13 edb中查看
运行完成dl_init之后。
图5.14 edb中查看
5.8 本章小结
本章介绍了链接的概念、作用、可执行目标文件hello的格式、hello的虚拟地址空间、链接的重定位过程分析、hello的执行流程以及hello的动态链接分析。
链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程给应用程序提供两个关键的抽象:
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
- 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个交互型应用级程序,代表用户运行其他程序。是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。
其基本功能是解释并运行用户的指令,重复如下处理过程:
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
(3)检查第一个命令行参数是否是一个内置的shell命令
(4)如果不是内部命令,调用fork( )创建子进程
(5)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
(6)如果用户没要求后台运行(命令末尾没有&号)则shell使用waitpid(或wait)等待作业终止后返回。
(7)如果用户要求后台运行(如果命令末尾有&号),则shell返回。
6.3 Hello的fork进程创建过程
在终端中输入命令./hello 1190202026 zys。
shell首先对命令进行解析,判断它是否是一个内置的shell命令。然后发现这并不是一个内置命令,而是当前目录下的一个可执行目标文件。然后调用fork函数为hello创建一个子进程。
新创建的子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本,子进程获得与父进程任何打开文件描述符相同的副本,子进程有不同于父进程的PID。
图6.1 hello进程图
6.4 Hello的execve过程
execve函数加载并运行可执行目标文件filename,且带参数列表和环境变量列表envp。只有当出现错误时,例如找不到filename时,execve才会返回到调用程序。与fork一次调用返回两次不同,execve调用一次并从不返回。
图6.2 execve函数
当shell为hello创建了一个子进程之后,得到的子进程与父进程几乎相同。fork函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。此时调用execve函数,在当前进程的上下文中加载并运行hello。它会覆盖当前进程的地址空间,但并没有创建一个新的进程。此时的新的进程仍有与原先相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。
6.5 Hello的进程执行
处理器提供了一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常用某个控制寄存器中的一个模式位来提供这种功能,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式中的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中成为调度器的代码处理的。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种成为上下文切换的机制来控制转移到新的进程:
1)保存当前进程的上下文
2)恢复某个先前被抢占的进程被保存的上下文
3)将控制传递给这个新恢复发进程
当hello调用sleep函数时,它显示地请求了让进程休眠。此时,hello进程被挂起,从运行队列加入等待队列,定时器开始计时2s。调度器通过上下文切换,重新开始一个之前被抢占了的进程。当sleep调用完毕的时候,会发送一个中断信号,此时内核将当前进程挂起(或终止)并返回到hello进程。
图6.3 上下文切换
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
图6.4 hello正常运行过程
图6.5 运行过程中按下回车
在运行过程中按下回车不影响进程输出,不会让进程终止或挂起。
图6.6 运行过程中按下Ctrl-Z
运行过程中按下Ctrl-Z后,显示进程已停止(挂起)。
图6.7 按下Ctrl-Z后执行ps
按下Ctrl-Z后执行ps命令,可以看到hello进程并没有结束。
图6.8 按下Ctrl-Z后执行jobs
按下Ctrl-Z后执行jobs命令,查看hello和jid。
图6.9(1) 按下Ctrl-Z后执行pstree
图6.9(2) 按下Ctrl-Z后执行pstree
按下Ctrl-Z后执行pstree命令,以树状图结构显示进程之间的关系。输入pstree -p 21808(bash进程的PID),以bash进程为根节点查看。
图6.10 按下Ctrl-Z后执行fg
按下Ctrl-Z后执行fg命令,继续执行hello程序,继续输出没有输出的内容。
图6.11 按下Ctrl-Z后执行kill
按下Ctrl-Z后执行kill命令,杀死hello进程。输入ps命令查看,可以看到hello进程显示已杀死。
图6.12 按下Ctrl-C
按下Ctrl-C后,发现进程立即结束了。输入ps命令,可以看到hello进程已经终止。
进程运行时按下Ctrl-Z,对应的是shell接收到SIGTSTP信号,默认情况是停止(挂起)前台作业,而Ctrl-C会导致内核发送一个SIGINT信号,默认情况是终止前台作业。
图6.13 部分信号及相应事件
6.7本章小结
本章节介绍了进程的概念与作用,介绍了shell的作用与处理流程,分析了shell如何调用fork创建进程以及如何调用execve执行hello进程。结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等,分析了hello的进程执行过程。通过在shell中执行各种命令操作,分析了hello的异常与信号处理。
异常控制流发生在计算机系统的各个层次。在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。一个一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:又称相对地址。由段地址加上偏移地址构成,它是描述一个程序运行段的地址。
线性地址:是经过段机制转化之后用于描述程序分页信息的地址。它是对程序运行区块的一个抽象映射。
虚拟地址:虚拟地址跟线性地址相同,都是对程序运行区块的相对映射。
物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。它是在前端总线上传输的而且是唯一的。物理地址中很大一部分是留给内存条中的内存的,但也常被映射到其他存储器上。
在没有使用虚拟存储器的机器上,虚拟地址被直接送到内存总线上,使具有相同地址的物理存储器被读写;而在使用了虚拟存储器的情况下,虚拟地址不是被直接送到内存地址总线上,而是送到存储器管理单元MMU,把虚拟地址映射为物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分构成,段标识符和段内偏移量。段标识符是由一个16位长的字段组成,成为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。
一些全局的段描述符,就放在“全局段描述符表(GDT)”中;一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。什么时候使用GDT,什么时候使用LDT中是由段选择符中的TI字段表示的。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU的页式内存管理单元负责把一个线性地址转换为物理地址。从管理和效率的角度出发,线性地址被划分成固定长度单位的数组,称为页。例如,一个32位的机器,线性地址可以达到4G,用4KB为一个页来划分,这样,整个线性地址就被划分为一个2^20次方的的大数组,共有2的20次方个页,也就是1M个页,我们称之为页表,该页表中每一项存储的都是物理页的基地址。
类似地,物理内存也被分割为物理页(PP),是分页单元将所有的物理内存都划分成了固定大小的单元为管理单位,其大小一般与内存页大小一致。
图7.1 地址翻译符合小结
CPU中的一个控制寄存器,页表基址寄存器(PTBR)指向当前页表。N位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE。当页表条目中的物理页号(PPN)和虚拟地址中的VPO串联起来,就得到相印的物理地址。注意,因为物理和虚拟页面都是P字节的,所以物理页面偏移(PPO)和VPO是相同的。
图7.2 使用页表的地址翻译
当页面命中时,CPU的执行步骤:
1.处理器生成一个虚拟地址,并把它传送给MMU。
2.MMU生成PTE地址,并从高速缓存/主存请求得到它。
3.高速缓存/主存向MMU返回PTE。
4.MMU构造物理地址,并把它传送给高速缓存/主存。
5.高速缓存/主存返回所请求的数据字给处理器。
图7.3 页面命中的操作图
页面命中完全是由硬件来处理的,与之不同的是,处理缺页要求硬件和操作系统内核协作完成。
1~3.和页面命中的第1步到第3步相同。
4.PTE中的有效位是0,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
5.缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。
6.缺页处理程序页面调入新的页面,并更新内存中的PTE。
7.缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中。
图7.4 缺页的操作图
7.4 TLB与四级页表支持下的VA到PA的变换
翻译后备缓冲器(TLB)是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单一PTE组成的块。TLB通常有高度的相联度。用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2^t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
36位VPN被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN 1提供到一个L1 PET的偏移量,这个PTE包含L2页表的基地址。VPN 2提供到一个L2 PTE的偏移量,以此类推。
图7.5 虚拟地址中用以访问TLB的组成部分
图7.6 Core i7页表翻译
优化地址翻译:
在对地址翻译的讨论中,我们描述了一个顺序的两个步骤的过程
1)MMU将虚拟地址翻译成物理地址
2)将物理地址传送到L1高速缓存
然而,实际的硬件实现使用了一个灵活的技巧,允许这些步骤部分重叠,因此也就加速了对L1高速缓存的访问。例如,页面大小为4KB的Core i7系统上的一个虚拟地址有12位的VPO,并且这些位和相应物理地址中的PPO的12位是相同的。因为八路组相联的、物理寻址的L1高速缓存有64个组和大小为64字节的缓存块,每个物理地址有6个(log264)缓存偏移位和6个(log264)索引位。这12位恰好符合虚拟地址的VPO部分,这绝不是偶然!当CPU需要翻译一个虚拟地址时,它就发送VPN到MMU,发送VPO到高速L1缓存。当MMU向TLB请求一个页表条目时,L1 高速缓存正忙着利用VPO位查找相应的组,并读出这个组里的个标记和相应的数据字。当MMU从TLB得到PPN时,缓存已经准备好试着把这个PPN与这8个标记中的一个进行匹配了。
7.5 三级Cache支持下的物理内存访问
获得了物理地址VA之后,使用CI进行组索引,每组8路,对8路的块分别匹配CT,如果匹配成功且块的valid标志位为1,则命中,根据数据偏移量CO取出数据返回。
如果没有匹配成功或者匹配成功但是标志位不是1,则不命中,向下一级缓存中查询数据(L2 Cache、L3 Cache、主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突,则采用最近最少使用策略LFU进行替换。也就是替换掉最不经常访问的一次数据。
7.6 hello进程fork时的内存映射
当shell调用fork函数时,内核为新进程hello创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在进程hello中返回时,进程hello现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域。为新的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
- 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器。execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
图7.7 加载器是如何映射用户地址空间的区域的
7.8 缺页故障与缺页中断处理
处理缺页要求硬件和操作系统内核协作完成:
- 处理器生成一个虚拟地址,并把它传送给MMU。
- MMU生成PTE地址,并从高速缓存/主存请求得到它。
- 高速缓存/主存向MMU返回PTE。
- PTE中的有效位是0,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
- 缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。
- 缺页处理程序页面调入新的页面,并更新内存中的PTE。
- 缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
显式分配器: 要求应用显式地释放任何已分配的快。例如,C语言中的malloc 和free。
隐式分配器: 应用检测到已分配块不再被程序所使用,就释放这个块。比如Java,ML和Lisp等高级语言中的垃圾收集。
隐式空闲链表:
图7.8 一个简单的堆块的格式
一个块是由一个字的头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小,以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
图7.9 用隐式空闲链表来组织堆
、
图7.10 使用边界标记的堆块的格式
在隐式空闲链表堆块的基础上,在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离,这样就允许在常数时间内进行对前面块的合并。
释放当前块的所有可能的情况:
- 前面的块和后面的块都是已分配的
- 前面的块是已分配的,后面的块是空闲的
- 前面的块是空闲的,后面的块是已分配的
- 前面的和后面的块都是空闲的
显示空闲链表
一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继指针。
图7.11 使用双向空闲链表的堆块的格式
释放一个块的时间可以是线性的,也可能是个常数。
一种方法是用先进后出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址顺序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章主要介绍了hello的存储器地址空间、Intel的段式管理与页式管理、TLB与四级页表支持下的VA到PA的变换、三级Cache支持下的物理内存访问、hello进程fork时的内存映射、hello进程execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容。
理解虚拟内存将帮助程序员更好地理解系统是如何工作的,理解虚拟内存将帮助程序员利用虚拟内存的强大功能在应用程序中添加动力,理解虚拟内存以及诸如malloc之类的管理虚拟内存的分配程序,可以帮助程序员避免很多错误。
理解存储器层次结构,因为它对应用程序的性能有着巨大的影响。如果我们的程序需要的数据是存储在CPU 寄存器中的,那么在指令的执行期间,在0个周期内就能访问到它们。如果存储在高速缓存中,需要4~75个周期。如果存储在主存中,需要上百个周期。而如果存储在磁盘上,需要大约几千万个周期。理解存储器层次结构,对于我们以后编写一些运行速度更快的代码十分重要。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个字节的序列:
B0,B1,…,Bk,…,Bm-1
所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这种方式称为Unix I/O接口。
8.2 简述Unix IO接口及其函数
Unix I/O接口:
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
- Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
- 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为k。
- 读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号”。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数:
- 打开和关闭文件:
- 打开文件
进程是通过调用open函数来打开一个已经存在的文件或者创建一个新文件的。
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:
O_RDONLY:只读
O_WRONLY:只写
O_RDWR:读写
图8.1 open函数
- 关闭文件
进程通过调用close函数关闭一个打开的文件。
图8.2 close函数
- 读和写文件
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。下图展示了一个程序使用read 和write 调用一次一个字节地从标准输入复制到标准输出。返回:若成功则为写的字节数。若出错则为-1。
图8.3 read函数、write函数
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
研究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函数的内容 :
va_list arg = (va_list)((char*)(&fmt) + 4);
va_list的定义:typedef char *va_list
这说明它是一个字符指针。其中的: (char*)(&fmt) + 4) 表示的是“...”中的第一个参数。C语言中,参数压栈的方向是从右往左,也就是说,当调用printf函数的适合,先是最右边的参数入栈。fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。对于一个char *类型的变量,它入栈的是指针,而不是这个char *型变量。
图8.4 vsprintf(buf,fmt,arg)函数
它接受一个格式化的命令,并把指定的匹配的参数格式化输出。vsprintf返回的是一个长度。
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
write(buf, i):
write,顾名思义:写操作,把buf中的i个元素的值写到终端。
图8.5 write函数结构体
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章讲述了Linux的I/O设备管理方法、Unix I/O接口及其函数、printf的实现分析、getchar的实现分析。
了解Unix I/O将帮助程序员理解其他的系统概念,有时候除了使用Unix I/O以外别无选择。
Linux提供了少量的基于Unix I/O模型的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行I/O重定向。Linux的读和写操作会出现不足值,应用程序必须能正确的预计和处理这种情况。应用程序不应直接调用Unix I/O函数,而应该使用RIO包,RIO包通过反复执行读写操作,直到传送完所有的请求数据,自动处理不足值。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
- 编写C语言程序。
- 预处理:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。结果就得到了另一个C程序,通常是以.i作为文件扩展名。
- 编译:在编译阶段,编译器将高级语言编译成汇编语言。汇编语言不具有可移植性,是直接面向处理器的语言,是机器指令的一种符号表示,不同类型的计算机系统有不同的机器指令系统,也就有不同的汇编语言。
- 汇编:在汇编阶段,汇编器将汇编语言指令翻译成机器可执行的机器语言指令。
- 链接:链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
- 进程管理:进程是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。进程给应用程序提供两个关键的抽象:一个独立的逻辑控制流,一个私有的地址空间。
- 存储管理:CPU上的内存管理单元MMU根据页表将CPU生成的虚拟地址翻译成物理地址,进行相应的页面调度。在这个过程中,TLB,三级cache结构的使用加快了访存速度。
- I/O管理:Linux提供了少量的基于Unix I/O模型的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行I/O重定向。Linux的读和写操作会出现不足值,应用程序必须能正确的预计和处理这种情况。应用程序不应直接调用Unix I/O函数,而应该使用RIO包,RIO包通过反复执行读写操作,直到传送完所有的请求数据,自动处理不足值。
附件
hello.c:C语言程序
hello.i:C语言程序hello.c预处理后得到的新的C程序
hello.s:将hello.c编译后得到汇编语言文本文件
hello.o:将hello.c汇编后得到的机器语言指令的可重定位目标程序
hello:将hello.o链接后得到的可执行文件
elf.txt:hello.o的ELF文件
elf1.txt:hello的ELF文件
hello.txt:hello.o反汇编后得到的汇编语言代码
hello1.txt:hello反汇编后得到的汇编语言代码
参考文献
[1] Randal E.Bryant ,David O'Hallaron. Computer Systems: A Programmer's Perspective.中国电力出版社.