本文以源文件hello.c为起点,经历预处理、编译、汇编、链接,经历hello进程从创建、运行到回收,在这个过程中,深入学习与实践与之相关的各模块的知识。本文主要是以CSAPP的知识为基础,以Ubuntu系统为实践平台,将课本知识与手动实践结合起来。这样既有利于贯通繁杂的知识,形成知识架构,也有利于将课本中抽象的知识实例化,从而更具象地理解它们。
关键词:计算机系统;Ubuntu;程序的生命周期。
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 25 -
6.3 Hello的fork进程创建过程... - 25 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 29 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 30 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 30 -
7.5 三级Cache支持下的物理内存访问... - 32 -
7.6 hello进程fork时的内存映射... - 32 -
7.7 hello进程execve时的内存映射... - 33 -
第1章 概述
1.1 Hello简介
P2P:From Program to Process
从hello.c到hello进程。
(1)hello.c被预处理器解析头文件,生成hello.i文件。
(2)hello.i被编译器翻译成汇编语言程序hello.s。
(3)hello.s被汇编器处理与打包成可重定位目标程序hello.o。
(4)链接器将多个可重定位目标文件链接成可执行文件hello。
(5)运行命令行:./hello 120L020104 范翰林 秒数,Shell即创建hello进程。
020:From Zero-0 to Zero-0
hello进程从创建到回收。
(1)Shell创建子进程,并调用加载器在子进程中加载与运行hello程序。
(2)Shell等待hello进程结束并回收。hello进程所占用的内存资源最终会被删除。
1.2 环境与工具
软件环境:
Ubuntu 16.04 LTS 64位,Windows10 64位,Vmware 11以上。
硬件环境:
X64 CPU;8G RAM。
开发与调试工具:
edb,objdump,gcc,readelf,Visual Studio。
1.3 中间结果
名称 | 作用 |
hello.c | 源文件 |
hello.i | 预处理器处理hello.c后得到的文件 |
hello.s | 汇编程序 |
hello.o | 可重定位目标文件 |
hello.elf | hello.o的ELF格式 |
asm.txt | hello.o的反汇编文件 |
hello | 可执行目标文件 |
hello2.elf | hello的ELF格式 |
asm2.txt | hello的反汇编代码 |
表1.3 中间结果文件。
1.4 本章小结
本章简要概述了P2P和020的过程,列出了本次作业写用到的软硬件环境、开发与调试工具以及中间文件。
第2章 预处理
2.1 预处理的概念与作用
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第1行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到另一个C程序,通常是以.i作为文件扩展名。
2.2在Ubuntu下预处理的命令
预处理命令:gcc -m64 -no-pie -fno-PIC -E -o hello.i hello.c。
结果如下:
图2.2 Ubuntu下预处理命令
2.3 Hello的预处理结果解析
打开生成的.i文件。发现主函数没有发生变化,如图:
图2.3 hello.i文件内容(局部)
但是.c文件的3条预处理命令#include <stdio.h>,#include <unistd.h> 和#include <stdlib.h>被替换成对应的头文件中的内容。这是因为cpp会根据以#开头的命令,读取对应的头文件的内容,并把它直接插入程序文本中。
2.4 本章小结
根据课本知识讲解了编译器在预处理阶段所做的操作,并结合具体的gcc命令生成.i文件,查看预处理结果并反过来验证了课本的知识。
第3章 编译
3.1 编译的概念与作用
编译是指编译器(ccl)基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,将文本文件hello.i翻译成文本文件hello.s。hello.s包含一个汇编语言程序。汇编语言是机器代码和文本表示,给出程序中的每一条指令。
也就是说,编译器把用C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。
3.2 在Ubuntu下编译的命令
应截图,展示编译过程!
编译命令:gcc -m64 -no-pie -fno-PIC -S -o hello.s hello.i
结果如下:
图3.2 Ubuntu下编译命令
3.3 Hello的编译结果解析
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.3.1数据
hello.s中存在的数据有立即数、局部变量、数组、字符串。
1.立即数
在ATT格式的汇编代码中,立即数的书写方式是$后面跟一个用标准C表示法表示的整数。hello.s中涉及的立即数如图3.3.1-1所示。
图3.3.1-1 hello.s中的立即数
2.局部变量
局部变量有int argc,即main的第一个参数,以及int i。具体解释如图3.3.1-2。
图3.3.1-3 hello.s中的argv数组
4.字符串
如图3.3.1-4,有2个字符串,第1个字符串即“用法: Hello 学号 姓名 秒数!”,第2个字符串是“Hello %s %s\n”。
图3.3.1-4 hello.s中的字符串
3.3.2 赋值
for循环开始处,有i=0。在hello.s中实现如图3.3.2。
图3.3.2 i=0在hello.s中的实现
3.3.3 算术操作
hello.s中出现的算术操作及其含义如图3.3.3。
图3.3.3 hello.s中的算术操作及其含义
3.3.4 关系操作
有两个关系操作,如图3.3.4。
图3.3.4 hello.s中的关系操作
1.比较4和argc。
2.比较7和i。
3.3.5 数组操作
数组argv在第2位存放学号,第3位存放姓名。hello.s中,通过修改数组偏移量,读取argv[1]和argv[2]的字符串,将它们作为printf的其中两个参数。如图3.3.5。
图3.3.5 hello.s中的数组操作
3.3.6 控制转移
hello.s中有3处控制转移,如图3.3.6所示。
图3.3.6 hello.s中的控制转移
结合关系操作,做如下分析:
1:je命令的跳转条件是ZF,即==成立。cmpl指令将4和argc比较,若相等,将ZF设为1,跳到L2,在C语句表现为跳出if分支;否则,跳过je指令,在C语句表现为执行if分支。
2:jmp指令,直接跳转。在L2中,i被赋给0,然后跳到L3。
3:jle指令的跳转条件是(SF^OF) | ZF,即<=成立。在C语句中表现为i<=7,即i<8。如果成立,跳转到L4(L4执行完成后,顺序地再次执行L3);否则,不再执行L4,继续执行下面的指令。
L2,L3以及L4组成for循环在汇编语言中的一种实现方式。
3.3.7 函数操作
参数传送:在x86-64中,大部分过程间的数据传送是通过寄存器实现的,并且,通过寄存器最多传递6个整形参数,超出6个的部分通过栈来传递。6个寄存器有固定的顺序:%rdi, %rsi, %rdx, %rcs, %r8, %r9。查看hello.s,它确实遵循这个规定。
函数调用:用call指令过程调用。它把PC设置为函数代码的起始位置,并把返回地址压入栈中。
局部变量:函数的局部变量存放在寄存器或栈帧中。
函数返回:ret指令。将被调用函数的栈帧弹出,PC设置为返回地址。
在hello.s中,main函数调用了6个子函数。如图3.3.7。
图3.3.7 hello.s中的函数调用
3.4 本章小结
在编译这个阶段,首先复习并阐述了编译的概念与作用,然后在Ubuntu中执行gcc命令生成.s汇编程序,最后阅读汇编程序并分模块地做汇编语言的指令与语法分析。实践了汇编语言的指令、控制转移与函数调用等知识。
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位的目标程序的格式,并将结果保存在目标文件hello.o中。
hello.o文件是一个二进制文件,它包含的是指令编码。
4.2 在Ubuntu下汇编的命令
汇编命令:gcc -m64 -no-pie -fno-PIC -c -o hello.o hello.s
结果如下:
图4.2 汇编命令
4.3 可重定位目标elf格式
获得ELF格式:readelf -a hello.o > hello.elf。
1. ELF header
在shell中输入readelf -h hello.o查看ELF头。
ELF头的第1行是一个16字节的序列,描述了生成该文件系统的字的大小和字顺序。
剩下的部分包含帮助链接器语法分析和解释目标文件的信息,如图4.3.1所示。
图4.3.1 ELF头的内容
2. 节头
运行readelf -S main.o查看节头。
由图4.3.2可知,ELF文件包含14个节。“偏移量”表示每个节的起始位置,“大小”表示节的大小,由这两个参数,可以确定每个节在ELF中的具体位置。
图4.3.2 ELF中的节头
3. 符号表
输入readelf -s hello.o命令,查看ELF中的.symlab,即符号表。
由图4.3.3,符号表包含17个符号。对符号表分析如下:
Type:表示符号的种类。
Bind:表示符号是全局符号还是局部符号。
Ndx:如果是具体数字,表示到节头部表的索引,比如main函数的Ndx为1,表示main被分配到.text节中。如果是UND,代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号(红框)。如果是ABS,代表不该被重定位的符号。
图4.3.3 ELF中的符号表
4. 重定位节
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。
输入readelf -r hello.o,查看hello.o的重定位节。结果如图4.3.4。
每个重定位条目的构成如下:
偏移量:需要被修改的引用的节偏移。
信息:前32位标志被修改引用应该指向的符号,后32位告知链接器如何修改新的引用。
类型:有两种重定位类型,R_X86_64_PLT32和R_X86_64_32。前者表示重定位一个使用32位PLT相对地址的引用,后者表示重定位一个使用32位绝对地址的引用。
加数:一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
图4.3.4 ELF中的重定位节
4.4 Hello.o的结果解析
输入objdump -d -r hello.o > asm.txt,将hello.o反汇编,(如图4.4-1)将结果保存在asm.txt文件中。
图4.4-1 运行反汇编命令
与hello.s进行对照分析。主要的不一致体现在:
1. 分支转移
在hello.s中,跳转指令的目标是一个标志位,如.L2,.L3等。而在反汇编代码中,跳转指令的目标是具体的地址。如图4.4-2所示。
图4.4-2 hello.s和asm.txt的分支转移对比
2. 函数调用
汇编器不知道函数调用的在内存中的最终位置,所以就生成一个重定位条目,告诉链接器在链接时如何修改这个引用。
在hello.s文件中,call命令后面跟着的是一个标签,而在反汇编代码中,call命令的后面是重定位条目,如图4.4-3所示。
图4.4-3 hello.s和asm.txt的函数调用对比
4.5 本章小结
在汇编这个阶段,我们主要是通过查看可重定位目标文件来学习,涉及符号表与重定位条目这两大块知识。本章引导我们利用readelf和objdump工具探索可重定位目标文件的具体内容,并将hello.o的反汇编文件与汇编程序进行比较,从而实践了汇编的知识,也对汇编器所做的工作有了更具体的认识。
第5章 链接
5.1 链接的概念与作用
链接是指链接器(ld)将预编译好了的目标文件合并到我们的hello.o程序中,生成一个可执行目标文件。它可以被加载到内存中,由系统执行。
为了构造可执行文件,链接器必须完成两个主要任务。一是符号解析,将每个符号引用正好和一个符号定义关联起来。二是重定义,编译器和汇编器生成从地址0开始的代码和数据节,而链接器把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
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.2。
图5.2 链接生成可执行程序
5.3 可执行目标文件hello的格式
1. readelf -h hello查看ELF头
如图5.3-1。结构和hello.o的ELF头基本相同,特别的,它包括了程序的入口点,也就是当程序运行时要执行的第一条指令的地址。
图5.3-1 hello的ELF头
2. readelf -S hello查看节头
节头展示了hello的各节的大小、偏移量和起始地址等信息。
图5.3-2 hello的节头(局部)
3. readelf -l hello查看程序头
程序头部表描述了可执行文件的连续的片到连续的内存段的映射关系。题目要求的各段的起始地址,大小信息可以在这里查看。
图5.3-3 hello的程序头
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息。
查看edb的Data Dump栏,发现虚拟地址空间从0x401000地址开始,如图5.4。和图5.3-3对比,发现第四段(红框)的起始地址也是0x401000,这说明在这个Ubuntu系统中,代码段从地址0x401000处开始。后面是数据段。运行时堆在数据段之后,通过调用malloc库往上增长。
图5.4 虚拟地址空间的各段信息(局部)
5.5 链接的重定位过程分析
使用命令objdump -d -r hello > asm2.txt将反汇编代码输入asm2.txt中,如图5.5-1。
图5.5-1 hello反汇编
与hello.o的反汇编文件进行比较,其不同之处有:
1. 每条指令有唯一的运行时内存地址&篇幅增加
链接后的反汇编文件中,每条指令有唯一的运行时内存地址,并且多出了puts,printf,sleep,exit,atoi等函数的代码。这是因为链接器将所有可重定位文件的相同类型的节合并为同一类型的新的聚合节,并给每条指令和全局变量唯一的运行时内存地址。对比如图5.5-2所示。
图5.5-2 链接前后反汇编文件的对比
2. call指令重定位
链接器根据重定位条目,修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
以main函数中调用puts函数为例。
如图5.5-3所示,hello.o中的call指令开始于节偏移0xe的地方,0xe8为call操作码,后面跟着的重定位条目告诉ld:修改开始于1f的32位PLT相对引用,这样在运行时它会指向puts例程。
然后更新该引用:*refptr = puts的地址 – call指令的下一条指令的地址 + 重定位条目的修正量。得到*refptr = 0x16f28,如图5.5-3。
图5.5-3 链接前后调用puts函数的对比
验证:call指令下一条指令地址 + *refptr = puts函数地址,即0x401db8 + 0x16f28 = 0x418ce0,如图5.5-4,和图5.5-3吻合。
图5.5-4 计算puts的地址
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
用edb的debug窗口查看hello程序,一步一步运行,可以查看所调用与跳转的各个子程序名和程序地址,如图5.6-1。
图5.6-1 edb运行窗口
列出其调用与跳转的各个子程序名,得到的子程序顺序如下:
ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
libc-2.27.so!__libc_start_main
_lib_start_main
hello!main
hello!puts@plt
hello!exit@plt
hello!printf@plt
hello!atoi@plt
hello!sleep@plt
hello!getchar@plt
libc-2.27.so!exit
5.7 Hello的动态链接分析
在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件hello。此时共享库中的代码和数据没有被合并到hello中。加载hello时,动态链接器对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。
动态链接采用了延迟加载的策略,即在调用函数时才进行符号的映射。使用偏移量表GOT+过程链接表PLT实现函数的动态链接。GOT中存放函数目标地址,为每个全局函数创建一个副本函数,并将对函数的调用转换成对副本函数调用。
通过edb查看,在dl_init调用前,如图5.7-1。
图5.7-1 dl_init调用前
调用后,如图 5.7-2。
图5.7-2 dl_init调用后
5.8 本章小结
在链接阶段,主要涉及重定位、加载可执行文件与动态链接的知识。
本章引导我们利用构造静态库手动地链接可重定位目标文件,并用readelf工具查看可执行文件的ELF格式,再与hello.o的ELF格式进行对比,从而对链接器所做的工作有更深刻的理解与记忆。
本章还引导我们用edb查看加载可执行文件的过程,从而对可执行的文件的内存映射、加载器的工作方式以及动态链接有了更深入的学习。
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中程序的实例。
进程提供给应用程序2个关键抽象:1.一个独立的连加控制流,它提供一个假象,好像程序独占地使用处理器;2.一个私有的地址空间,它提供一个假象,好你程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
1. 作用:
Shell是一种交互型的应用级程序,代表用户运行其他程序。
2. 处理流程:
Shell执行一系列的读/求值步骤。
读步骤:读取用户输入的命令行,解析以空格分隔的命令行参数,将命令行的参数改造为系统调用的内部处理所要求的形式。
求值步骤:如果第一个词是内置的Shell命令,马上解释这个命令。如果是一个可执行文件,Shell用fork()创建一个子进程,并在子进程的上下文中加载并运行这个程序。如果最后一个参数为&,说明程序在后台运行,父进程不会等待子进程结束,可以继续读下一个命令行;否则,子进程在前台运行,而在当前进程中,要调用wait(),等待子进程结束。所以同一时间,最多有一个程序在前台运行,而可以有多个程序在后台运行。
6.3 Hello的fork进程创建过程
运行命令./hello 120L020104 范翰林 秒数。hello可执行文件正常运行,如图6.3。
fork创建进程:父进程判断输入的不是内置命令,就通过fork函数创建子进程。子进程与父进程几乎但不完全相同。子进程得到与父进程用户级虚拟空间相同(但是独立)的一份副本,包括数据段、代码、共享库、堆和用户栈。子进程可以读写父进程中打开的任何文件。二者之间最大的不同在于有不同的PID。
图6.3 执行hello
6.4 Hello的execve过程
Shell用fork()创建一个子进程后,在子进程的上下文中加载并运行一个新的hello程序,也就是调用了execve函数。execve 调用一次并从不返回。调用execve需要3个参数,第1个参数filename表示可执行目标文件名,第2个参数argv表示参数列表,第3个参数envp表示环境变量列表。execve在加载了filename之后,调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数main。新程序会覆盖当前子进程的地址空间,但并没有创建一个新的进程。
6.5 Hello的进程执行
在程序运行时,Shell为hello fork了一个子进程,这个子进程与Shell有独立的逻辑控制流。这些逻辑流是交错运行的。每个进程执行它的控制流的一部分的每一时间段叫做时间片。
内核为每个进程维持一个上下文。上下文是内核重启一个被抢占的进程所需的状态。内核可以抢占当前的进程,并使用上下文切换将控制转移到新的进程。
在hello的运行过程中,当hello调用sleep函数时,为了最大化利用处理器资源,它显示地请求内核调用hello进程休眠,并进行上下文切换,将控制转移到其他的进程。与此同时,将 hello 进程由用户模式变成内核模式,并开始计时。当计时结束时,sleep函数触发一个中断,使得内核重新进行调度,切换回hello进程。hello进程从内核模式转换为用户模式。
6.6 hello的异常与信号处理
1. 不停乱按
运行结果截屏如图6.6-1。输入的命令暂时缓存,hello先处理第1个‘/n’之前的命令,由于输入非法而结束进程。缓存区中未执行的输入则继续被终端处理。
图6.6-1 hello处理不停乱按的结果
2. 回车
如图6.6-2,进程结束。和1中相同,多余的‘/n’存在缓存区中,被终端处理。
图6.6-2 hello处理回车的结果
3. Ctrl-Z
输入Ctrl -Z,进程收到SIGSTP信号,停止运行。用ps列出当前进程信息,jobs查看当前shell中已启动的作业项。发现hello进程只是被挂起,而没有终止。
图6.6-3 hello处理Ctrl-Z并运行ps和jobs
运行pstree命令,将当前进程以树状图形式展示。如图6.6-4。
图6.6-4 运行pstree结果
运行fg命令,被挂起的进程重新在前台运行;运行kill命令,彻底杀死该进程。如图6.6-5。
图6.6-5 执行fg和kill命令
4. Ctrl-C
输入Ctrl-C,Shell进程收到SIGINT信号,进程终止并回收子进程。如图6.6-6。
图6.6-6 hello处理Ctrl-C
6.7本章小结
本章引导我们分析hello程序的进程管理方式,学习并回答Shell如何创建一个子进程,如何加载可执行程序,如何并发地运行多个进程,如何调度上下文切换来处理sleep函数等等。
本章还引导我们在Ubuntu下运行hello并在键盘输入各种命令,并用Linux中ps和jobs等方法来查看当前进程列表,从而对信号处理方面的知识有更深入的理解。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1. 逻辑地址
逻辑地址是指由程序产生的与段相关的偏移地址部分,逻辑地址由选择符和偏移量两部分组成。对于hello来说,就是hello.o的反汇编文件中的相对偏移地址。
2. 线性地址
是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。对于hello来说,程序hello的代码会产生逻辑地址,或者说是(即hello程序)段中的偏移地址,它加上相应段的基地址就生成了一个线性地址。
3. 虚拟地址
概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节有唯一的虚拟地址,作为到数组的索引。edb窗口中Data Dump显示的就是虚拟地址。
4. 物理地址
存储器中的每一个字节单元都给以一个唯一的存储器地址,用来正确地存放或取得信息,这个存储器地址称为物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
内存被分为了不同的段,段寄存器有一个栈、一个代码寄存器和两个数据寄存器。
所有的段由段选择符描述,如图7.2。
图7.2 段选择符
为了得到一个段的开始位置的线性地址,首先先利用索引号从GDT(全局段描述表)或LDT(局部段描述符表)中得到段描述符。选择GDT还是LDT取决于段选择符中的T1,若T1等于0则选择GDT,反之选择LDT。RPL用于判断重要等级。RPL=00,为第0级,位于最高级的内核,RPL=11,为第3级,位于最低级的用户状态。
这样我们就得到了段基址。最后段基址加上段偏移量就得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
图7.3展示了使用页表的地址翻译方式。CPU中的PTBR指向当前页表。n位虚拟地址(线性地址)包含VPN和VPO两部分。MMU利用VPN来选择适当的PTE。
如果PTE的有效位是1,那么MMU将该PTE的PPN和虚拟地址中的VPO连接起来,就得到相应的物理地址,并把它传送给高速缓存/主存。高速缓存/主存返回所请求的数据字给处理器。
如果PTE的有效位是0,MMU就会触发一次异常,传递控制到CPU中的控制到内核中的缺页异常处理程序。执行下面的步骤:
(1)判断该虚拟地址是否在某个区域结构定义的区域内,如果不在,就会终止进程。
(2)判断该进程是否有读、写或者执行这个区域内页的权限,如果没有,就会触发保护异常,终止进程。
(3)缺页异常处理程序确定没有(1)(2)的错误,就会选出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。然后,调入新的页面,更新内存中的PTE。最后,返回到原来的进程,也就是将原来缺页的虚拟地址再次传给MMU。
图7.3 使用页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
图7.4-1展示了TLB与四级页表支持下的地址翻译的全过程。
图7.4-1 TLB与四级页表支持下的地址翻译概况
1. 利用TLB进行地址翻译
首先CPU产生一个虚拟地址。MMU从TLB中取出相应的PTE。
如果TLB命中,MMU从该PTE中得到PPN,和虚拟地址中的VPO连接起来,就得到相应的物理地址。
如果TLB不命中,就要启动页表翻译。
2. 四级页表地址翻译
图7.4-2给出了4级页表地址翻译的过程。
虚拟地址被划分为5部分,前4片(VPN 1~VPN 4)中的每个片都被用作到一个页表的偏移量。逐级检索,终于在L4找到相应的PTE,从该PTE中得到PPN,和虚拟地址中的VPO连接起来,就得到相应的物理地址。
特别的,新取出的PTE要存放在TLB中,可能会覆盖一个已经存在的条目。
图7.4-2 4级页表的地址翻译
3. 高速缓存的工作
接下来,MMU发送物理地址给高速缓存,缓存从物理地址中抽出CI,确定组号;抽出CT,确定行数;最后根据CO确定偏移量(如图7.4-1)。
将结果返回给MMU,随后MMU将它传递回CPU。
4. 缺页与缓存不命中
第3步可能产生其他工作路径:
(1)如果得到的PTE是无效的,就会导致缺页,具体的已经在7.3中讲过。特别的,在第四级页表的条目中有一个D位(图7.4-3),如果对应的页进行了写,D会被MMU设置。MMU可以根据D位来判断是否必须写回牺牲页。
(2)如果PTE有效,但是缓存不命中,则从下一级缓存取出包含数据对象的那个块。如果当前缓存已经满了,可能会覆盖现存的一个块。7.5中也涉及这方面知识。
图7.4-3 第4级页表的条目
7.5 三级Cache支持下的物理内存访问
高速缓存将MMU传来的物理地址中抽取出缓存偏移CO、缓存组索引CI以及缓存标记CT。访问过程如下:
1. 组选择、行匹配和字选择
假设PTE是有效的,那么一级Cache就会根据CI,确定组号。再查看该组的每一行,如果存在一行的标记位等于CT,就读出该行在偏移量CO处的数据字节。如果,不存在等于CT的标记位,即缓存不命中。
2. 处理缓存不命中
一级Cache不命中,那么就从二级Cache取出被请求的块,然后将新的块存储在CI和CT指示的组中的一个高速缓存行。如果组中有一个空行,可以选择它进行替换,如果没有,可以根据LFU、LRU等策略来选择要替换的行。
如果二级Cache仍然不命中,则向三级Cache寻找被请求的块,依此类推。也就是说,位于k层的存储设备保存着从k+1层的存储设备中取出的字,而不能越级访问。
7.6 hello进程fork时的内存映射
当fork函数被Shell调用时,内核为新进程hello创建各种数据结构,并分配给它一个唯一的PID。为了给这个新的进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行文件hello中的程序,用hello程序替代了当前程序。加载并运行hello需要以下几个步骤:
1. 删除已存在的用户区域
删除当前进程hello虚拟地址的用户部分中的已存在的区域结构。
2. 映射私有区域
为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。如图7.7。
图7.7 加载器是如何映射用户地址空间的区域的
3. 映射共享区域
如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4. 设置程序计数器
execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
已经在7.3中讲过。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存管理的基本方法与策略介绍如下:
C标准库提供了一个称为malloc程序包的显示分配器。程序通过调用malloc函数从堆中分配块。malloc函数返回一个指针,指向大小至少为size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。如果malloc遇到问题,就返回NULL,并设置errno。此外还有calloc,realloc等。
程序通过调用free函数来释放已分配的堆块。
7.10本章小结
本章基于存储器层次结构和虚拟内存的知识,以hello、Intel Core i7等为具体实例,介绍了存储空间、段式管理、页式管理、TLB、多级页表、多级缓存、内存映射、缺页故障与处理、动态分配内存等知识。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
1. 设备的模型化:文件
所有的IO设备(如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。
2. 设备管理:unix io接口
这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前的文件位置、读写文件、关闭文件。
8.2 简述Unix IO接口及其函数
Unix I/O接口:
1. 打开文件:
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。
Linux Shell创建的每个进程开始时都有三个打开的文件:标准输入,标准输出,标准错误。
2. 改变当前的文件位置:
对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
3. 读写文件:
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。
类似地,写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
4. 关闭文件:
当应用完成了对文件的访问后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O函数:
1. 打开文件
int open(char* filename, int flags, mode_t mode)
进程是通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符;flags参数指明了进程打算如何访问这个文件;mode参数指定了新文件的访问权限位。
2. 关闭文件
int close(fd)
fd是open函数返回的一个文件描述符。若成功,close返回0,出错则返回-1。
3. 读文件
ssize_t read(int fd, void *buf, size_t n)
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,返回值0表示EOF。否则返回值表示的是实际传送的字节数量。
4. 写文件
ssize_t wirte(int fd, const void *buf, size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
查看windows下的printf函数体,如图8.3-1。
图8.3-1 printf的函数体
1. 参数
形参列表中的…是可变形参的一种写法,当传递参数的个数不确定时,就可以用这种方式来表示。
2. va_list的定义:
typedef char *va_list
这说明它是一个字符指针。其中的: (char*)(&fmt) + 4) 表示的是...中的第一个参数。
3. vsprintf的作用
首先查看vsprintf(buf, fmt, arg),如图8.3-2。
图8.3-2 vsprintf的函数体
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。返回的是要打印出来的字符串的长度。
4. write系统函数
追踪write函数,如图8.3-3。int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
图8.3-3 追踪write
sys_call的原型很复杂,可以用汇编语言来描述它,如图8.3-4。它所做的工作就是显示格式化了的字符串。
图8.3-4 sys_call的功能描述
syscall将字符串复制到显卡的显存中,显存中存储的是字符的ASCII码。相应的字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
查看getchar代码,如图8.4。
图8.4 getchar函数
异步异常-键盘中断的处理:用户按键时,触发键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
如果键盘输入多个字符,再次调用getchar时,可以直接从缓存区中读取一个字符。
8.5本章小结
本章介绍了IO设备管理方法,简述了Unix IO的接口与方法,并以系统级IO的知识为基础,分析了printf和getchar的实现方式。
结论
hello程序的人生:
1.预处理
预处理器解析以字符#开头的命令,读取系统头文件stdio.h,并把它们直接插入程序文本中,生成后缀名为.i的文件。
2.编译
编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,将文本文件hello.i翻译成文本文件hello.s,为不同的高级语言的不同编译器提供了通用的输出语言。
3.汇编
汇编器将hello.s翻译成机器语言指令,把这些指令打包成可重定位的目标程序的格式,并将结果保存在目标文件hello.o中。
4.链接
链接器(ld)将预编译好了的目标文件合并到我们的hello.o程序中,生成一个可执行目标文件hello。hello可以被加载到内存中,由系统执行。
5.加载与运行
Shell为可执行程序hello创建一个子进程,并用execve函数调用加载器。加载器将hello的代码和数据从磁盘复制到内存,控制跳转到程序的入口点,于是,hello开始运行。
6.执行命令行
由于sleep显示地请求内核调度hello进程休眠,hello在用户模式与内核模式中来回切换。在用户模式下,hello可以解析命令行参数,
7.异常与信号处理
Ctrl-z、Ctrl-c等输入会发送信号给处理器,处理器检测到信号,就会挂起当前进程,将控制转移到相应的信号处理程序。
8.访存
处理器为hello进程分配一个虚拟内存空间,并为hello进程将工作集合调度到内存。每当处理器发出一条虚拟地址,MMU将它翻译成物理地址,高速缓存、主存和磁盘返回目标数据或指令。
9.进程回收
Shell会等待并回收hello进程。内置的quit命令可以终止hello进程,Ctrl-c也可以立即终止进程,Ctrl-z可以挂起hello进程。最终,内核会回收所有僵死子进程。
感悟:
在这份大作业中,我切实体会到了“从程序员的角度看计算机系统”的内涵。一个平常的hello程序,就可以综合这本书所体现的大部分内容,就可以关系到整个计算机系统的硬件与内核。透过hello这个窗口,我看到课本看似繁杂的知识被模块化地串联起来,厚厚的课本仿佛有了一个框架的支撑。
也不得不感叹软硬件工程师的细心、耐心、博学与创新思维——他们给计算机系统这个复杂的大工厂安排了一条条井然有序、高效作业的流水线。
附件
名称 | 作用 |
hello.c | 源文件 |
hello.i | 预处理器处理hello.c后得到的文件 |
hello.s | 汇编程序 |
hello.o | 可重定位目标文件 |
hello.elf | hello.o的ELF格式 |
asm.txt | hello.o的反汇编文件 |
hello | 可执行目标文件 |
hello2.elf | hello的ELF格式 |
asm2.txt | hello的反汇编代码 |
表2 中间结果文件
参考文献
[1] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2018.4
[2] 《printf 函数实现的深入剖析》https://www.cnblogs.com/pianist/p/3315801.html
[3] 《物理地址、虚拟地址(线性地址)、逻辑地址以及MMU的知识》 https://blog.csdn.net/macrossdzh/article/details/5954763
[4] 《readelf命令使用说明》https://blog.csdn.net/yfldyxl/article/details/81566279