计算机科学与技术学院
2021年6月
摘 要
一个简简单单的hello程序,从一个.c的文本文件经过预处理,编译,汇编,再链接最终成为可执行程序,在运行时通过Shell,OS,MMU,IO等,从硬件到软件,从磁盘,到总线,主存,高速缓存,CPU,最终被OS和Bash回收,虽然在我们眼里只是短短的几秒,简单的一行,在计算机内部却是一个复杂且精妙的过程,本文将在此过程各个环节展开,阐述hello的详细经过。
关键词:汇编, 链接, CPU, 高速缓存, 进程, 虚拟地址空间;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述............................................................................................................. - 4 -
1.1 Hello简介...................................................................................................... - 4 -
1.2 环境与工具..................................................................................................... - 4 -
1.3 中间结果......................................................................................................... - 4 -
1.4 本章小结......................................................................................................... - 4 -
第2章 预处理......................................................................................................... - 5 -
2.1 预处理的概念与作用..................................................................................... - 5 -
2.2在Ubuntu下预处理的命令.......................................................................... - 5 -
2.3 Hello的预处理结果解析.............................................................................. - 5 -
2.4 本章小结......................................................................................................... - 5 -
第3章 编译............................................................................................................. - 6 -
3.1 编译的概念与作用......................................................................................... - 6 -
3.2 在Ubuntu下编译的命令............................................................................. - 6 -
3.3 Hello的编译结果解析.................................................................................. - 6 -
3.4 本章小结......................................................................................................... - 6 -
第4章 汇编............................................................................................................. - 7 -
4.1 汇编的概念与作用......................................................................................... - 7 -
4.2 在Ubuntu下汇编的命令............................................................................. - 7 -
4.3 可重定位目标elf格式................................................................................. - 7 -
4.4 Hello.o的结果解析...................................................................................... - 7 -
4.5 本章小结......................................................................................................... - 7 -
第5章 链接............................................................................................................. - 8 -
5.1 链接的概念与作用......................................................................................... - 8 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
5.4 hello的虚拟地址空间.................................................................................. - 8 -
5.5 链接的重定位过程分析................................................................................. - 8 -
5.6 hello的执行流程.......................................................................................... - 8 -
5.7 Hello的动态链接分析.................................................................................. - 8 -
5.8 本章小结......................................................................................................... - 9 -
第6章 hello进程管理................................................................................... - 10 -
6.1 进程的概念与作用....................................................................................... - 10 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.4 Hello的execve过程................................................................................. - 10 -
6.5 Hello的进程执行........................................................................................ - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
6.7本章小结....................................................................................................... - 10 -
第7章 hello的存储管理................................................................................ - 11 -
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 -
7.9动态存储分配管理....................................................................................... - 11 -
7.10本章小结..................................................................................................... - 12 -
第8章 hello的IO管理................................................................................. - 13 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
8.3 printf的实现分析........................................................................................ - 13 -
8.4 getchar的实现分析.................................................................................... - 13 -
8.5本章小结....................................................................................................... - 13 -
结论......................................................................................................................... - 14 -
附件......................................................................................................................... - 15 -
参考文献................................................................................................................. - 16 -
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
1." P2P"过程
(1) 预处理阶段
预处理器(CPP)根据以字符#开头的命令,修改原始的C程序。比如 hello.c中第1行的#include
(2) 编译阶段
编译器(ccl)将文本文件 hello.i 翻译成文本文件 hello.s 个汇编语言程序。该程序包含函数 main的定义言。例如,C编译器和 Fortran 编译器产生的输出文件用的都是一样的汇编语言。
(3) 汇编阶段
接下来,汇编器(as)将 hello.s 翻译成机器语言指令,把这些指令打包成 一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o的指令编码。如果我们在文本编辑器中打开 hello.o, 将是一堆乱码。
(4) 链接阶段
hello 程序调用了printf 函数,它是每个C编译器都提供的标准C库中的一个函数。printf 函数存在于一个名为 printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。链接器(Id)就负责处理这种合并。结果就得到 hello文件,它是一个可执行目标文件 (或者简称为可执行文件),可以被加载到内存中,由系统执行。
图1-1-1 hello的"P2P"过程
2. "020"过程
(1) 读取命令
初始时,外壳程序执行它的指令,等待我们输人一个命令。当我们在键盘上输人字符串“./hello”后,外壳程序将字符逐一读人寄存器,再把它存放到存储器中,如下所示。(顺序是键盘——寄存器——主存储器)
(2) 复制数据
当我们在键盘上敲回车键时,外壳程序就知道我们已经结束了命令的输人。然后外壳执行一系列指令来加载可执行的hello文件,将hello目标文件中的代码和数据从磁盘复制到主存。 数据包括最终会被输出的字符串"hello,world\n”。(顺序:磁盘——主存储器)。利用直接存储器存取(DMA)的技术,数据可以不通过处理器而直接从磁盘到达主存。
(3) 执行并显示
处理器就开始执行hello程序的main程序中的机器语言指令。这些指令将"hello,world\n”字符串中的字节从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。
图1-1-2 hello的"020"过程
硬件: X64 CPU;2GHz;2G RAM;256GHD Disk
软件环境: Windows10, ubuntu20.04
开发与调试工具: Clion 2021.1, gdb
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 |
作用 |
hello.c |
文本文件,用高级语言记录了程序的基本功能,用来预处理 |
hello.i |
预处理文本文件,可用于编译 |
hello.s |
编译文本文件,记录了高级语言对应的编译指令 |
hello.o |
可重定位二进制文本文件,可与其他文件链接成可执行文件 |
hello |
可执行文件,可以被计算机直接识别 |
hello.s1 |
hello.o的反汇编文件,可用于推测可重定位文件的结构 |
hello.s2 |
hello的反汇编文件, 可用于推测可执行文件的结构 |
hello.elf |
hello文件的readelf结果文件,记录了hello各段信息 |
hello.oelf |
hello.o文件的readelf结果文件,记录了hello.o各段信息 |
表1-3 编写本论文,生成的中间结果文件的名字,文件的作用
计算机系统是由硬件和系统软件组成的,它们共同协作以运行应用程序。计算机内部的信息被表示为一组组的位,它们依据上下文有不同的解释方式。程序被其他程序翻译成不同的形式,开始时是ASCLL文本,然后被编译器和链接器翻译成二进制可执行文件。
(第1章0.5分)
预处理器执行宏替换、条件编译以及包含指定的文件。以#开头的命令行(#前可以有空格)就是预处理的对象,这些命令行的语法独立于语言的其他部分,它们可以出现在任何地方,作用范围是从出现的位置到文件末尾,除非使用显式的取消操作的预处理。这是一个独立的过程,与之后的编译,汇编和链接相当。预处理是指在进行编译的第一遍扫描(词法扫描和语法分析)之前所作的工作。预处理是C语言的一个重要功能, 它由预处理程序负责完成。当对一个源文件进行编译时, 系统将自动引用预处理程序对源程序中的预处理部分作处理, 处理完毕自动进入对源程序的编译。
命令:gcc -E hello.c -o hello.i
图2-2预处理的命令
(1) 预处理会删除所有注释、添加行号以及文件标识。
(2) 预处理会处理所有条件编译指令, 如#if, #end等。
(3) 预处理会将#include中包含的文件内容复制到.i文件中,其中可能会有循环嵌套。导致预处理完成后的.i文件大小远大于.c文件。
(4) 预处理会将代码中的在宏定义中定义过的符号进行替换,包括无参宏和有参宏。
图2-3 能够预处理的命令
本章讲述了预处理步骤的概念, 功能, 命令和具体实现,预处理为下一步编译做准备。
(第2章0.5分)
此阶段完成语法和语义分析,然后生成中间代码,此中间代码是汇编代码,但是还不可执行,gcc编译的中间文件是.s文件。在此阶段会出现各种语法和语义错误,特别要小心未定义的行为,这往往是致命的错误。第一个阶段预处理和第二个阶段编译均由编译器完成。经过预编译得到的输出文件中,将只有常量,如数字、字符串、变量的定义,以及C语言的关键字,如main, if, else, for, while, {, }, +, -, *, \, 等等。预编译程序所要做的工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。
命令: gcc -S hello.i -o hello.s
1.伪指令
所有以‘.’开头的行都是指导汇编器和链接器工作的伪指令。我们通常可以忽略这些行。另一方面,也没有关于指令的用途以及它们与源代码之间关系的解释说明。如开始的.file "hello.c" .text, main函数的.cfi_startproc等。
2.寄存器
64位系统中的参数和变量都是用寄存器传递的,每个寄存器都有其特殊的用法, 一个 X86-64 的中央处理单元(CPU)包含一组 16 个存储 64 位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。图 3-2 显示了这 16 个寄存器。它们的名字都以%r开头,不过后面还跟着一些不同的命名规则的名字,这是由于指令集历史演化造成的。最初的x86-64中有8个16 位的寄存器, 每个寄存器都有特殊的用途,它们的名字就反映了这些不同的用途。扩展到 IA32架构时,这些寄存器也扩展成 32 位寄存器,标号从%eax到%ebp。扩展到 x86-64 后,原来的 8 个寄存器扩展成 64 位,标号从%rax 到%rbp 除此之外,还增加了 8 个新的寄存器,它们的标号是按照新的命名规则制定的:从%r8到%r15。
图3-3-1 x86-64系统16个寄存器的名称和用途
3.数据存储
x86-64用不同的寄存器存储不同的数据,寄存器有16中每个寄存器有不同的功能,还可以使用不同的位数,如hello.s中25行中的%rip用来存储下一条指令的地址,%rsp用来存储当前栈顶指针。
4.数据传送指令
操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好几条不同指令才能完成的功能。有多种不同的数据传送指令,它们或者源和目的类型不同,或者执行的转换不同,或者具有的一些副作用不同。根据讲述,把许多不同的指令划分成指令类,每一类中的指令执行相同的操作,只不过操作数大小不同。
mov指令把数据从一个位置复制到目的位置,不做任何变化。mov类由四条指令组成: movb, movw, movl, movq这些指令都执行同样的操作;主要区别在于它们操作的数据大小不同:分别是1、2、4 和8字节。
源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个寄存器,要么是一个内存地址。X86-64 加了一条限制,传送指令的 两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令----第一条指令将源值加载到寄存器中,第二条将该寄存器值写人目的位置。这些指令的寄存器操作数可以是 16 个寄存器有标号部分中的任意一个,寄存器部分的大小必须与指令最后一个字符(b, w, l, q)指定的大小匹配。大多数情况中,mov指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一的例外是 movl指令以寄存器作为目的时,它会把该寄存器的高位 4 字节设置为0。造成这个例外的原因是 X86-64 采用的惯例,即任何为寄存器生成 32位值的指令都会把该寄存器的高位部分置成 0。如在hello.s编译文件25行的movq %rsp, %rbp指令就是将%rsp存储的值传给%rbp。
leaq指令用来加载有效地址,操作数必须是内存引用, 目的数必须是一个寄存器, 它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。它的第一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读人数据,而是将有效地址写人到目的操作数。如hello.s编译文件31行的指令leaq .LC0(%rip), %rdi就是将地址%rip + .L0中的只传递给%rdi。
运算指令很多,如add, sub, imul, div等,这四个运算指令的操作都是将后一个操作数对前一个操作数作相应的运算,把结果传递给后一个操作数,所以后一个操作数必须是寄存器,前一个操作数可以是寄存器,内存引用或立即数, 如44行的指令addq $8, %rax 就是把%rax的值加8。
5.压栈和弹栈指令
(1) push指令
push的操作数是一个寄存器或者立即数,将一个值压入栈中,并把栈指针减一个内存的大小,如hello.s文件22行pushq %rbp,就是将%rbp中的值压入栈,相当于两条指令: ①subq $8, %rsp ②movq %rbp, (%rsp)
(2) pop指令
pop指令与push指令正好相反,它把栈指针指向的值赋值给目的寄存器,然后加一个内存的大小。
6.取址和返回指令
(1) call指令
call的操作数是一个地址,它的作用是进入该地址所指向的函数或指令位置,并执行push (下一条指令的首地址),把下一条指令的首地址压入栈,作为call结束后的返回地址。如33行的call puts@PLT 指令就是调用了printf函数。
(2) ret指令
ret指令和call对应,是当函数结束时,离开函数并pop出返回地址,并返回call时的下一条指令。每个函数结尾必有ret, 如61行的ret指令就是退出main函数。
7.条件跳转指令
(1) 条件码
除了整数寄存器,CPU 还维护着一组单个位的条件码(condition code)寄存器,它们
描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用的条件码有
CF: 进位标志。最近的操作使最高位产生了进位。可用来检査无符号操作的溢出。 ZF: 零标志。最近的操作得出的结果为0。
SF: 符号标志。最近的操作得到的结果为负数。
OF: 溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出
比如说,假设我们用一条 ADD指令完成等价于 C 表达式 t=a+b的功能,这里变量a,b和t都是整型的。然后,根据下面的 C表达式来设置条件码:
图3-3-2 条件码
(2) 跳转指令
正常执行的情况下,指令按照它们出现的顺序一条一条地执行。跳转(jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(label)指明。跳转指令有下面多种:
图3-3-3 jump跳转指令
(3) 条件跳转指令
条件跳转指令只设置条件码而不改变任何其他寄存器;CMP 指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目的寄存器之外,CMP 指令与SUB指令的行为是一样的。在ATT格式中,列出操作数的顺序是相反的,这使代码有点难读。如果两个操作数相等,这些指令会将零标志设置为1,而其他的标志可以用来确定两个操作数之间的大小关系。TEST 指令的行为与AND指令一样,除了它们只设置条件码而不改变目的寄存器的值。典型的用法是,两个操作数是一样的(例如,testq %rax,%rax 用来检查%rax是负数、零,还是正数),或其中的一个操作数是一个掩码,用来指示哪些位应该被测试。
第31,32页有两条连续的指令①cmpl $3, -20(%rbp) ② je .L2 表示如果%rbp寄存器中存储的值减0x20的地址处存储的值等于3,则跳转到.L2所在的指令处;
第37,38页有两条连续的指令①cmpl $9, -4(%rbp) ② jle .L4 表示如果%rbp寄存器中存储的值减0x4的地址处存储的值小于9,则跳转到.L4所在的指令处。
本章讲述了编译步骤的概念和作用,以及汇编语言的特点,解析了hello.s文件中的汇编代码详细描述了一些常见汇编指令的用法和细节。
(以下格式自行编排,编辑时删除)
(第3章2分)
汇编是指将从 .s的汇编语言文本文件生成到 .o的二进制可重定位文件,即编译后的文件到生成机器语言二进制程序的过程。
命令: gcc -c hello.s -o hello.o
图4-2 用cat查看hello.o文件是一堆乱码
1.可重定位文件的elf格式
下图展示了一个典型的 ELF 可重定位目标文件的格式。ELF 头(ELF header)以一个 16 字节的序列开始,这个序列描述了生成该文件 的系统的字的大小和字节顺序。ELF 头剩下的部分 包含帮助链接器语法分析和解释目标文件的信息。其 中包括 ELF 头的大小、目标文件的类型(如可重定 位、可执行或者共享的)、机器类型(如X86-64)节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由 节头部表描述的,其中目标文件中每个节都有一个固 定大小的条目(entry)夹在 ELF头和节头部表之间的都是节。
2.hello.o文件中各段的信息
(1) Elf 头(ELF Header)
以一个16字节序列开始,描述了生成该文件的系统的字的大小和字节顺序,剩下的信息帮助链接器语法分析和解释目标文件的信息。包括ELF头大小,目标文件类型,机器类型,节头部表的文件偏移以及节头部表中条目的大小和数量。
(2) 各节点的头部(Section Headers)
节头部表记录了各节名称、类型、地址、偏移量、大小、全体大小、旗标、连接、信息、对齐信息
图4-3-3 节头表
(3) 重定位节(section '.rela.text')
.rela.text,一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。调用本地函数的指令则不需要修改。链接器会依据重定向节的信息对可重定向的目标文件进行链接得到可执行文件。
偏移量:指需要进行重定向的代码在.text或.data节中的偏移位置,8个字节。
信息:包括symbol和type两部分,其中symbol占前4个字节,type占后4个字节,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型
类型:重定位到的目标的类型
符号名称:重定向到的目标的名称
加数:计算重定位位置的辅助信息,共占8个字节
图4-3-4 可重定位信息
(4) 符号表(Symbol table)
出现的符号,包括函数名,变量名的大小,类型,所在的段,以及是否是全局的
图4-3-5 符号表
3.反汇编操作
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
图4-3-6 反汇编命令
4.反汇编的文件和编译文件的不同
(1).反汇编文件没有了以‘.’开头的伪指令
(2).反汇编文件中MOV, PUSH, POP, SUB, ADD, LEA等类的指令均省去了不必要的后缀,简化了汇编代码。
(3).反汇编文件为每条指令增加了从零开始的相对地址(此地址既不是物理地址也不是虚拟地址, 因为文件还没有正式被执行), 并给每条指令表明了操作码,但涉及地址的操作码部分都被初始化为0。
(4).反汇编文件每个跳转和取值指令后面都列出了跳到的函数的所在具体位置,因为每条指令在函数中的位置已经开始确定。并交代了函数地址的寻址方式是R_X86_64_PC32或R_X86_64_PLT32 (相对寻址)还是R_X86_64_32(绝对寻址)。
5.机器语言的构成
机器语言是机器能直接识别的程序语言或指令代码,无需经过翻译,每一操作码在计算机内部都有相应的电路来完成它,或指不经翻译即可为机器直接理解和接受的程序语言或指令代码。机器语言使用绝对地址和绝对操作码。不同的计算机都有各自的机器语言,即指令系统。从使用的角度看,机器语言是最低级的语言。每条机器指令由以下部分组成:
(1).操作码。它具体说明了操作的性质及功能。一台计算机可能有几十条至几百条指令,每一条指令都有一个相应的操作码,计算机通过识别该操作码来完成不同的操作。
(2).操作数的地址。CPU通过该地址就可以取得所需的操作数。
(3).操作结果的存储地址。把对操作数的处理所产生的结果保存在该地址中,以便再次使用。
(3).下条指令的地址。执行程序时,大多数指令按顺序依次从主存中取出执行,只有在遇到转移指令时,程序的执行顺序才会改变。为了压缩指令的长度,可以用一个程序计数器(PC)存放指令地址。每执行一条指令,PC的指令地址就自动+1, (设该指令只占一个主存单元),指出将要执行的下一条指令的地址。当遇到执行转移指令时,则用转移地址修改PC的内容。由于使用了PC,指令中就不必明显地给出下一条将要执行指令的地址。
6.机器语言与汇编语言的映射关系
一个典型的 ELF 可重定位目标文件的节和汇编语言的关系:
.text :已编译程序的机器代码。
.rodata : 只读数据,比如printf语句中的格式串与switch语句中的跳转表
.data : 已初始化的全局和静态 C 变量。局部 C 变量在运行时被保存在栈中,既不出现在.data 节中,也不出现在.bss 节中。
.-bss : 未初始化的全局和静态 C 变量,以及所有被初始化为 0 的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化 和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁 盘空间。运行时,在内存中分配这些变量,初始值为0。
.symtab : 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g 选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在.symtab 中都有一张符号表(除非程序员特意用 STRIP 命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
.rel.text : —个.text 节中位置的列表,当链接器把这个目标文件和其他文件组合
时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修 改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重 定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。
.rel.data : 被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初
始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要 被修改。
.debug : 个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定 义和引用的全局变量,以及原始的 C 源文件。只有以-g 选项调用编译器驱动程序时,才会得到这张表。
.line : 原始 C 源程序中的行号和.text节中机器指令之间的映射。只有以-g 选项调用编译器驱动程序时,才会得到这张表。
.strtab : —个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的令名字。字符串表就是以 null 结尾的字符串的序列。
本章讲述了汇编的概念,作用和命令,可重定位文件的elf格式,可重定位文件各节的信息,机器语言的1构成以及与汇编语言的关系。
(第4章1分)
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时也就是在源代 码被翻译成机器代码时;也可以执行于加载时也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。在早 期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器的程序自动执行的, 在这里是指从可重定位文件hello.o 到可执行文件hello的生成过程。
图5-1 可重定位文件与静态库链接时的过程
命令: ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o a.out
图5-2 hello的链接和执行结果
hello的ELF格式:
图5-3-1 可执行文件的elf格式
用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息:
(1) Elf 头(ELF Header)
以一个16字节序列开始,描述了生成该文件的系统的字的大小和字节顺序,剩下的信息帮助链接器语法分析和解释目标文件的信息。包括ELF头大小,目标文件类型,机器类型,节头部表的文件偏移以及节头部表中条目的大小和数量。
图5-3-2 elf头
(2) 各节点的头部(Section Headers)
节头部表记录了各节名称、类型、地址、偏移量、大小、全体大小、旗标、连接、信息、对齐信息
图5-3-3节头表
(3) 程序头部表(Program Headers)
可执行文件中连续的片和连续的内存块的映射关系
图5-3-4 程序头部表
(4) 动态内存段(Dynamic section): 它们的地址,类型和变量名
图5-3-5 动态内存段
(5) 已经重定位的段(Relocation section)
它们的偏移量,索引,类型,变量名和附加值等
图5-3-6 已经重定位的段
(6) 动态符号表(Symbol table '.dynsym')
出现的保存与动态链接相关的导入导出符号,包括函数名,变量名的大小,类型,所在的段,以及是否是全局的
图5-3-7 动态符号表
(7) 普通符号表(Symbol table '.symtab')
出现的模块函数或者变量,其他内容同上
图5-3-8 普通符号表
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图5-4-1 虚拟内存空间信息
PHDR:保存程序头表
INTERP:动态链接器的路径
LOAD:可加载的程序段
DYNAMIN:保存了由动态链接器使用的信息
NOTE保存辅助信息
GNU_STACK:标志栈是否可执行
GNU_RELRO:指定重定位后需被设置成只读的内存区域
图5-4-2 hello的虚拟内存空间
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。结合hello.o的重定位项目,分析hello中对其怎么重定位的。
(1) hello文件中ELF头部修改了Type, 和Entry point address,原因是可执行程序的地址是已经确定的虚拟地址(虽然不是真实的物理地址), 同时修改了了Start of program headers和Start of section headers, 确定了程序头的大小和节头表的数量,索引等。
(2) 节头表中修改了偏移量的大小,并确定了各段的首地址。
(3) 因为是可执行文件,不能被重定位,所以没有重定位条目,但是有Relocation section '.rela.plt'的已经重定位好的部分。
(4) 增加了动态链接符号表,因为链接过程中.o文件和其他动态链表库链接生成了可执行文件。
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
1.程序经过的函数:
(1) 载入:_dl_start、_dl_init
(2) 开始执行:_start、_libc_start_main
(3) 执行main:_main、_printf、_exit、_sleep、
_getchar、_dl_runtime_resolve_xsave、_dl_fixup、_dl_lookup_symbol_x
(4) 退出:exit
2.子程序名称和地址:
程序名称 |
地址 |
ld-2.27.so!_dl_start |
0x7fb85a93aea0 |
ld-2.27.so!_dl_init |
0x7f9612138630 |
hello!_start |
0x400582 |
lib-2.27.so!__libc_start_main |
0x7f9611d58ab0 |
hello!puts@plt |
0x4004f0 |
hello!exit@plt |
0x400530 |
表5-4-1 子程序名称和地址
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
图5-7 加载动态链接时的过程
本章介绍了链接的概念与作用,以及链接时的虚拟内存空间结构以及重定位的和动态链接的分析。
(第5章1分)
异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是 无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系 统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及 打开文件描述符的集合。 每次用户通过向 shell 输人一个可执行目标文件的名字,运行程序时,shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够 创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
1.Shell-Bash的概念和作用
Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(command interpreter,命令解析器)。它类似于DOS下的COMMAND.COM和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。Bourne Again shell,它是Linux操作系统缺省的shell,是Bourne shell的扩展,简称Bash,与Bourne shell完全向后兼容,并且在Bourne shell的基础上增加、增强了很多特性。Bash放在/bin/bash中,它有许多特色,可以提供如命令补全、命令编辑和命令历史表等功能,它还包含了很多C shell和Korn shell中的优点,有灵活和强大的编程接口,同时又有很友好的用户界面。
2.处理流程
(1) 将命令行分成由元字符(meta character) 分隔的记号(token):元字符包括 SPACE, TAB, NEWLINE, ; , (, ), <, >, |, &记号的类型包括单词,关键字,I/O重定向符和分号。
(2) 检测每个命令的第一个记号,看是否为不带引号或反斜线的关键字。如果是一个开放的关键字,如if和其他控制结构起始字符串,function,{或(,则命令实际上为一复合命令。shell在内部对复合命令进行处理,读取下一个命令,并重复这一过程。如果关键字不是复合命令起始字符串,而是如then等一个控制结构中间出现的关键字,则给出语法错误信号。
(3) 依据别名列表检查每个命令的第一个关键字。如果找到相应匹配,则替换其别名定义,并退回第一步;否则进入第4步。
(4) 执行大括号扩展,例如a{b,c}变成ab ac。
(5) 如果~位于单词开头,用$HOME替换~。使用usr的主目录替换~user。
(6) 对任何以符号$开头的表达式执行参数(变量)替换。
(7) 对形如$(string)或者`string` 的表达式进行命令替换这里是嵌套的命令行处理。
(8) 计算形式为$((string))的算术表达式。
(9) 把行的参数替换,命令替换和算术替换 的结果部分再次分成单词,这次它使用$IFS中的字符做分割符而不是步骤1的元字符集。
(10) 对出现*, ?, [ ]对执行路径名扩展,也称为通配符扩展。
(11) 按命令优先级表(跳过别名),进行命令查寻。先作为一个特殊的内建命令,接着是作为函数,然后作为一般的内建命令,最后作为查找$PATH找到的第一个文件。
(12) 设置完I/O重定向和其他操作后执行该命令。
图6-3-1 fork函数的定义
父进程通过调用fork函数创建一个新的运行的子进程新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用 fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。fork有以下特点:
(1) 调用一次,返回两次
fork只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork 返回子进程的PID 在子进程中,fork返回0。因为子进程的PID 总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。
(2) 并发执行
父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行 它们的逻辑控制流中的指令。在我们的系统上运行这个程序时,父进程先完成它的 printf语句,然后是子进程。然而,在另一个系统上可能正好相反。
(3) 相同但是独立的地址空间
如果能够在 fork 函数在父进程和子进程中返回后立即 暂停这两个进程,我们会看到两个进程的地址空间都是相同的。每个进程有相同的 用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。因 此,在我们的示例程序中,当 fork 函数在第 6 行返回时,本地变量 x 在父进程和 子进程中都为 1。然而,因为父进程和子进程是独立的进程,它们都有自己的私有 地址空间。后面,父进程和子进程对 x 所做的任何改变都是独立的,不会反映在另 一个进程的内存中。这就是为什么当父进程和子进程调用它们各自的 printf 语句 时,它们中的变量 x 会有不同的值。
(4) 共享文件
当运行这个示例程序时,我们注意到父进程和子进程都把它们的输出显 示在屏幕上。原因是子进程继承了父进程所有的打开文件。当父进程调用fork时, stdout 文件是打开的,并指向屏幕。子进程继承了这个文件,因此它的输出也是指向屏幕的。
图6-3-2 用fork函数创建子进程的一个例子
执行hello文件时,CPU就用fork函数创建了一个用来运行hello的新进程,这个进程结束之后又会被它的父进程回收。
图6-4-1 execve函数的定义
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。
参数列表是用下图中的数据结构表示的。argv变量指向一个以null 结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0]是可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的,如图8-21所示。envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name=value”的名字-值对。
在execve加载了filename之后,它调用7.9节中描述的启动代码。启动代码设置栈,并将控制传递给新程序的主函数。
hello可执行程序会寻找它的参数列表,并把他们加载到数组argv[]中,然后加载环境变量到envp[]数组中,然后可以在main函数中调用这两个数组。
图6-4-2 参数列表和环境变量
当 main 开始执行时,用户栈的组织结构下图所示。让我们从栈底(高地址)往栈顶 (低地址)依次看一看。首先是参数和环境字符串。栈往上紧随其后的是以 null 结尾的指针 数组,其中每个指针都指向找中的一个环境变量字符串。全局变量 environ 指向这些指 针中的第一个 envp[] 紧随环境变量数组之后的是以 null 结尾的 argv[]数组,其中每 个兀素都指向钱中的一个参数字符串。在找的顶部是系统启动函数 libc_start_main的栈帧。
图6-4-3 用户栈的典型组织结构
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与内核态转换等等。
处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫 做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以 访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction), 比如停止处理器、改变模式位,或者发起一个 I/O操作。也不允许 用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致 命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷人系统调用这样的异常。当异常发生时,控制传递 到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程 序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页 表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling), 是由内核中称为调度器(scheduler)的代码处 理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个 新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换: ①保存当前进程的上下文,②恢复某个先前被抢占的进程被保存的上下文,③将控制传递给这个新恢复的进程。
图6-5 上下文切换
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
1.信号的接收和处理机制
当内核把进程p从内核模式切换到用户模式时(例如,从系统调用返回或是完成了一次上下文切换),它会检査进程 的未被阻塞的待处理信号的集合(pending &~blocked) 如果这个集合为空(通常情况下),那么内核将控制传递到 p 的逻辑控制流中的下一条指令,然而,如果集合是非空的,那么内核选择集合中的某个信号 /K通常是最小的是),并且强制p接收信号k收到这个信号会触发进程采取某种行为。一旦进程完成了这个行 为,那么控制就传递回p的逻辑控制流中的下一条指令每个信号类型都有一个预定义的默认行为,是下面中的一种:
信号的种类如下:
图6-6 linux信号
2.hello产生的信号及处理
(1) SIGINT: 用键盘数输入Ctrl-C或者等待进程结束时会发送SIGINT信号给内核,
终止当前进程。
(2) SIGTSTP: 用键盘输入Ctrl-Z时,会发送SIGTSTP信号给内核,然后暂停前台进程。
(3) SIGKILL: 在命令行中使用kill -9 pid或者kill all命令时,会产生SIGKILL命令,杀死对应进程。
(4) SIGCHLD: 在子进程结束时,进程向内核发送SIGCHLD信号,提示父进程去回收子进程,不需要处理。
(5) SIGCONT: sleep结束时或其他情况,进程被重新启动时,会发送SIGCONT信号,不需要处理。
(6) SIGSTOP: 使用命令sleep时,会发送SIGSTOP信号,停止程序直到下一个SIGCONT信号来临。
本章介绍了hello的进程管理,shell壳,以及各种信号和进程管理函数。
(第6章1分)
(1) 逻辑地址
逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元(memory cell)、存储单元(storage element)、网络主机(network host)的地址。 逻辑地址往往不同于物理地址(physical address),通过地址翻译器(address translator)或映射函数可以把逻辑地址转化为物理地址。
(2) 线性地址
地址空间(address space)是一个非负整数地址的有序集合{0, 1, 2……} 如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间(linear address),如hello中的虚拟地址一般都是连续的,可以称为线性地址。
(3) 虚拟地址
CPU 从一个有 N=2n个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space) ,反汇编文件hello.S中的地址都是虚拟地址。
(4) 物理地址
系统中也有物理地址空间(physical address space),对应计算机硬件上真实的物理内存。
段式管理(segmentation),是指把一个程序分成若干个段(segment)进行存储,每个段都是一个逻辑实体,程序员需要知道并使用它。它的产生是与程序的模块化直接有关的。段式管理是通过段表进行的,它包括段号或段名、段起点、装入位、段的长度等。此外还需要主存占用区域表、主存可用区域表。
图7-2 段式管理基本思想
虚拟内存被组织为一个由存放在磁盘上的 JV 个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被 缓存在主存中。和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块, 这些块作为磁盘和主存(较高层)之间的传输单元。VM 系统通过将虚拟内存分割为称为虚 拟页(Virtual Page, VP)的大小固定的块来处理这个问题。每个虚拟页的大小为 字 节。类似地,物理内存被分割为物理页(Physical Page, PP) 大小也为 P 字节(物理页也 被称为页帧(page frame))。页表就是一个页表条目(Page Table Entry (PTE))的数组。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个 PTE, 每个 PTE 是由一个有效位(valid bit)和一个 n 位地址字段组成 的。有效位表明了该虚拟页当前是否被 缓存在DRAM中。如果设置了有效位, 那么地址字段就表示 DRAM 中相应的 物理页的起始位置,这个物理页中缓存 了该虚拟页。如果没有设置有效位,那 么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。
图7-3 页面的基本结构
36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1 提供到一个Ll PET 的偏移量,这个PTE 包含L2 页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推。
图7-3 四级页表形式的物理内存访问
每一级Cache都分为S=2s个的组索引,每组都有E行,每一行有B=2b个字节的块,以及一位有效位,64位的tag标记对地址值而言分为组索引,tag标记,以及块索引。首先对地址值的组索引找到相对应的缓存中的对应组,然后根据tag标记找到符合tag标记的那一行,如果有效值为1,则命中,在这一行中读取相对应块索引的数据。否则,为不命中,再从下一级cache执行类似操作,再找不到则再到下一级去找,如果三级cache都找不到则在内存中找到相对应的这一行,根据地址值对应的组放入缓存中,如果有没有使用的行即有效位为0的行先放入,否则会把使用最久远的那一个缓存行换去,存入现在的行,如果找到同样由上述原则进行替换。
图7-5 三级cache支持下的intel Core i7内存系统
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
图7-6 私有写时复制
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用 a.out 程序有效地替代了当前程序。加载并运行 a.out需要以下几个步骤:
(1) 除已存在的用户区域。
删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2) 映射私有区域。
为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些 新的区域都是私有的、写时复制的。代码和数据区域被映射为 a.out 文件中的.text 和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。下图概括了私有区域的不同映射。
(3) 映射共享区域。
如果 a.out 程序与共享对象(或目标)链接,比如标准 C 库 libc. 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
(4) 设置程序计数器(PC)。
execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的人口点。下一次调度这个进程时,它将从这个人口点开始执行。Linux将根据需要换入代码和数据页面。
图7-7 虚拟内存映射
图7-8-1 页命中
在虚拟内存的习惯说法中,DRAM 缓存不命中称为缺页(page fault) ,下图展示了在缺页之前我们的示例页表的状态。CPU 引用了 VP 3 中的一个字,VP 3 并未缓存在 DRAM 中。地址翻译硬件从内存中读取 PTE 3, 从有效位推断出 VP 3 未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在 PP 3 中的 VP 4。如果 VP 4 已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改 VP 4 的页表条目,反映出 VP 4 不再缓存在主存中这一事实。
图7-8-2 缺页之前
接下来,内核从磁盘复制 VP 3 到内存中的 PP 3, 更新 PTE 3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到 地址翻译硬件。但是现在,VP 3 已经缓存在主存中了,那么页命中也能由地址翻译硬件 正常处理了。下图展示了在缺页之后我们的示例页表的状态。
图7-8-3 缺页
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)(见下图)系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk), 要么是已分配的,要么是空闲的。已分配的块显式地 保留为供应用程序使用。空闲块可用来分配。空闲块 保持空闲,直到它显式地被应用所分配。一个已分配 的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
图7-9-1 动态分配示意图
1.两种分配器结构
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责 释放已分配的块。
(1) 显式分配器(explicit allocator)
要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做 malloc 程序包的显式分配器。C 程序通过调用 malloc 函数来 new 和 delete 操作分配一个块,并通过调用 free 函数来释放一个块。C++ 中的 符与 C中的 malloc 和 free 相当。
(2) 隐式分配器(implicit allocator)
另一方面,要求分配器检测一个已分配块何时不再 被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector), 而自动释放未使用的已分配的块的过程叫做垃级收集(garbage collection) 例如,诸如 Lisp, ML以及 Java 之类的高级语言就依赖垃圾收集来释放已分配的块。
显示分配器有两种简单用来分配(malloc)和释放(free)内存的形式:
(3) 隐式空闲链表
隐式空闲链表的空闲块是通过头部中的大小字段隐含地连接着的。在这种情况中,一个块是由一个字的头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
图7-9-2 隐式空闲链表的块
分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意,我们需要 某种特殊标记的结束块,在这个示例中,就是一个设置了已分配位而大小为零的终止头部(terminating header) 隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。 很重要的一点就是意识到系统对齐要求和分配器对块格式的选择会对分配器上的最小块大小有强制的要求。没有已分配块或者空闲块可以比这个最小值还小。例如,如果我们 假设一个双字的对齐要求,那么每个块的大小都必须是双字(8 字节)的倍数。因此,图 935 中的块格式就导致最小的块大小为两个字:一个字作头,另一个字维持对齐要求。即 使应用只请求一字节,分配器也仍然需要创建一个两字的块。
图7-9-3 隐式空闲链表
(4) 显式空闲链表
隐式空闲链表为我们提供了一种介绍一些基本分配器概念的简单方法。然而,因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不适合的(尽管 对于堆块数量预先就知道是很小的特殊的分配器来说它是可以的)。
一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个 pred(前驱)和 succ(后继)指针。
图7-9-4 显式空闲链表的块
显示空闲链表相对于隐式空闲链表的最大特点就是, 它仅仅是维护空闲块链表, 而不是所有块,"下一个"空闲块可以在任何地方,块的顺序是任意的,因此需要存储前/后指针,而不仅仅是大小(size)。
图7-9-5 显式空闲链表
2.垃圾收集
已经malloc的块如果不再使用,就会变成垃圾,程序需要这个块所以在 garbage 返回前应该释放,不幸的是,有些垃圾没有释放,在程序的生命周期内都保持为已分配状态,毫无必要地占用着本来可以用来满足后面分配请求的堆空间。 垃圾收集器(garbage collector)是一种动态内存分配器,它自动释放程序不再需要的已分配块。这些块被称为垃圾(garbage)(因此术语就称之为垃圾收集器)。自动回收堆存 储的过程叫做垃圾收集(garbage collection)在一个支持垃圾收集的系统中,应用显式分配堆块,但是从不显示地释放它们。在 C 程序的上下文中,应用调用 malloc 但是从不调用 free。反之,垃圾收集器定期识别垃圾块,并相应地调用 free, 将这些块放回到空闲链表中。
垃圾收集器将内存视为一张有向可达图(reachability graph), 其形式如下图所示。该图的节点被分成一组根节点(root node)和一组堆节点(heap node) 每个堆节点对应于 堆中的一个已分配块。有向边 意味着块f 中的某个位置指向块9 中的某个位置。根 节点对应于这样一种不在堆中的位置,它们中包含指向堆中的指针。这些位置可以是寄存 器、栈里的变量,或者是虚拟内存中读写数据区域内的全局变量。
当存在一条从任意根节点出发并到达P的有向路径时,我们说节点 P 是可达的(reachable)在任何时刻,不可达节点对应于垃圾,是不能被应用再次使用的。垃圾收集 器的角色是维护可达图的某种表示,并通过释放不可达节点且将它们返回给空闲链表,来定期地回收它们。
图7-9-6 处理垃圾算法原理
经典垃圾处理算法是保守的算法,它可能会把垃圾看成正常内存(但是不会把正常内存看成垃圾),所以它通常不能处理全部的垃圾。
Mark&Sweep 垃圾收集器由标记(mark)阶段和清除(sweep)阶段组成,标记阶段标记出根节点的所有可达的和已分配的后继,而后面的清除阶段释放每个未被标记的已分配 块。块头部中空闲的低位中的一位通常用来表示这个块是否被标记了。 我们对 Mark&Sweep的描述将假设使用下列函数,其中 ptr定义为 typedef void *ptr:
① ptr isPtr (ptr p) 如果 p指向一个已分配块中的某个字,那么就返回一个指向 这个块的起始位置的指针 b, 否则返回 NULL。
②int blockMarked(ptr b)。如果块 b是已标记的,那么就返回 true
③int blockAllocated(ptr b) 如果块 b是已分配的,那么就返回 true
④void markBlock(ptr b) 标记块 b
⑤int length(b) 返回块 b的以字为单位的长度(不包括头部)。
⑥void unmarkBlock(ptr b)。将块 b的状态由已标记的改为未标记的。
⑦ptr nextBlock(ptr b) 返回堆中块 b的后继。
图7-9-7 mark函数
标记阶段为每个根节点调用一次mark 函数。如果 p 不指向一个已分配并且未标记的堆块,mark 函数就立即返回。否则,它就标记这个块,并对块中的每个 字递归地调用它自己。每次对 mark 函数的调用都标记某个根节点的所有未标记并且可达 的后继节点。在标记阶段的末尾,任何未标记的已分配块都被认定为是不可达的,是垃圾,可以在清除阶段回收。
图7-9-8 sweep函数
清除阶段是 sweep 函数的一次调用。sweep 函数在堆中每个块上反复循环,释放它所遇到的所有未标记的已分配块(也就是垃圾)。
图7-9-9 处理垃圾算法流程
C程序的 Mark & Sweep 收集器必须是保守的,其根本原因是C语言不会用类型信息来标记内存位置。因此,像 int 或者 float 这样的标量可以伪装成指针。例如,假设某 个可达的已分配块在它的有效载荷中包含一个 int 其值碰巧对应于某个其他已分配块 b 的有效载荷中的一个地址。对收集器而言,是没有办法推断出这个数据实际上是 int而不 是指针。因此,分配器必须保守地将块 b标记为可达,尽管事实上它可能是不可达的。因此它可能不会释放某些垃圾。虽然这并不影响应用程序的正确性,但是这可能导致不必要的外部碎片。
本章介绍了hello的内存管理,包括存储器地址空间,逻辑地址,线性地址和虚拟地址到物理地址的转化,四级页表,三级Cache的物理内存访问,以及动态内存分配等过程,详细地介绍了虚拟地址在程序中的应用。
(第7章 2分)
设备的模型化:文件
设备管理:unix io接口
1.I/O接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输人和输出都能以一种统一且一致的方式来执行:
(1) 打开文件。
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符1)和标准错误(描述符为2)。头文件< unistd.h>定义了常量STDINMFILENO、STDoUT_FILENO和 STDERR_FILENO,它们可用来代替显式的描述符值。
(2) 改变当前的文件位置。
对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
(3) 读写文件。
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k十n。给定一个大小为m字节的文件,当k≥m 时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。
类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
(4) 关闭文件。
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
2.I/O接口函数
(1) 打开和关闭文件
图8-2-1 open函数
进程是通过调用 open 函数来打开一个已存在的文件或者创建一个新文件的, open 函数将 filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总 是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:
0_RD0NLY: 只读。
0_WR0NLY: 只写。
0_RDWR: 可读可写。
例如,下面的代码说明如何以读的方式打开一个已存在的文件: fd = OpenCfoo.txt", 0_RD0NLY, 0);
flags 参数也可以是一个或者更多位掩码的或,为写提供给一些额外的指示:
0_CREAT: 如果文件不存在,就创建它的一个截断的(truncated)(空)文件。
0_TRUNC: 如果文件已经存在,就截断它。
0_APPEND: 在每次写操作前,设置文件位置到文件的结尾处。
图8-2-2 close函数
关闭文件函数是close,不能关闭已经关闭的文件。
(2)读和写文件
图8-2-3 read和write函数
read函数从描述符为 fd的当前文件位置复制最多 n个字节到内存位置 buf 返回值-1表示一个错误,而返回值 0 表示 EOF, 否则,返回值表示的是实际传送的字节数量。 write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd的当前文件位置。
(3) RIO 的无缓冲的输入输出函数
图8-2-4 RIO无缓冲输入输出函数
rio_readn函数从描述符fd的当前文件位置最多传送n个字节到内存位置usrbuf。类似地,rio_writen函数从位置usrbuf传送n个字节到描述符fd。rio_read函数在遇到EOF时只能返回一个不足值。rio_writen函数决不会返回不足值。对同一个描述符,可以任意交错地调用rio readn和 rio_writen。
(4) RIO的带缓冲的输入函数
图8-2-5 RIO带缓冲输入输出函数
包装函数(rio_readlineb)从一个内部读缓冲区复制一个文本行,当缓冲区变空时,会自动地调用read重新填满缓冲区。对于既包含文本行也包含二进制数据的文件(例如HTTP响应),我们也提供了一个rio_readn带缓冲区的版本,叫做rio_readnb,它从和rio_readlineb一样的读缓冲区中传送原始字节。
图8-2-6 带缓冲函数处理过程
每打开一个描述符,都会调用一次 rio__readinitb 函数。它将描述符 fd 和地址 rp处的一个类型为 rio_t 的读缓冲区联系起来。 rio_readlineb函数从文件 rp读出下一个文本行(包括结尾的换行符),将它复制到内存位置 usrbuf并且用 NULL(零)字符来结束这个文本行。rio_readlineb 函数最多 读 maxlen-1个字节,余下的一个字符留给结尾的 NULL 字符。超过 maxlen-1字节的文本行被截断,并用一个 NULL字符结束。 rio_readnb 函数从文件 rp最多读 w 个字节到内存位置 usrbuf 对同一描述符,对 rio_readlineb 和 rio_readnb的调用可以任意交叉进行。然而,对这些带缓冲的函数的 调用却不应和无缓冲的 rio_readn 函数交叉使用。
(5) 读取文件元数据
图8-2-7 stat和fstat函数
应用程序能够通过调用 stat 和 fstat 函数,检索到关于文件的信息(有时也称为文件的元数据(metadata))。stat 函数以一个文件名作为输入,并填写如图 10-9 所示的一个 stat 数据结构中的各个成员。fstat 函数是相似的,只不过是以文件描述符而不是文件名作为输入。
图8-2-8 stat数据结构
(6) 读取目录内容
图8-2-9 opendir和readdir函数
每次对 readdir 的调用返回的都是指向流 dirp中下一个目录项的指针,或者,如果没有更多目录项则返回NULL每个目录项都是一个结构,其形式如下: struct dirent {
ino_t d_ino; /* inode number */
char d_name [256] ; /* Filename */
}
虽然有些 Linux 版本包含了其他的结构成员,但是只有这两个对所有系统来说都是标准的。成员d_name是文件名,d_ino是文件位置。
如果出错,则readdir返回NULL,并设置errno。可惜的是,唯一能区分错误和流结束情况的方法是检查自调用readdir以来errno是否被修改过。
函数 closedir关闭流并释放其所有的资源
图8-2-10 closedir函数
(7) 文件共享
每个进程都有一个描述符表,记录了每个描述符整数对应的文件,包括标准输入stdin(fd=0),标准输出stdout(fd=1)和标准错误stderr(fd=2)以及其他文件(从fd=3开始)。每个描述符对应一个文件表,文件表记录了一个文件的引用数和文件位置(当前读写指针的位置),每个文件表又对应一个v-node表,记录文件的类型,权限,大小等其他信息。文件表和v-node表是所有进程共享的。
有时候每个描述符都会有自己的文件,没有共享关系,这也是一般情况。
图8-2-11 文件之间没有共享关系
但是也有时候,会有多个符号描述符共享同一个文件表和v-node表。例如,如果以同一个 filename调用open函数两次,就会发生这种情况。关键思想是每个描述符都有它自己的文件位置,所以对不同描述符的读操作可以从文件的不同位置获取数据。
图8-2-12 多个符号描述符共享同一个文件表和v-node表
(8) I/O重定向
我们可以讲一种操作得到的文件重定位到另一个文件中,或者把键盘输入的数据重定向到指令的文件中去,在Linux中实现这个操作的一种方法是使用dup2函数。dup2函数复制描述符表表项 oldfd到描述符表表项 newfd 覆盖描述符表表项 newfd以前的内容。如果 newfd已经打开了,dup2会在复制 oldfd之前关闭 newfd。dup2函数的作用就是把newfd 描述符的文件重定向到oldfd描述符的文件。
图8-2-13 dup2函数
图8-2-14 调用dup函数重定向之前
图8-2-14 调用函数dup2(4, 1), 使原本指向stdout的fd=1,指向了fd=4所指向的磁盘文件
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
Linux 提供了少量的基于 Unixl/O 模型的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行 I/O 重定向。Linux 的读和写操作会出现不足值,应用程序必须能正确地 预计和处理这种情况。应用程序不应直接调用 Unix I/O 函数,而应该使用 RIO包,RIO包通过反复执行 读写操作,直到传送完所有的请求数据,自动处理不足值。
Linux内核使用三个相关的数据结构来表示打开的文件。描述符表中的表项指向打开文件表中的表项,而打开文件表中的表项又指向 v-node 表中的表项,每个进程都有它自己单独的描述符表,而所有的 进程共享同一个打开文件表和 v-node 表。理解这些结构的一般组成就能使我们清楚地理解文件共享和 I/O重定向。
标准 I/O 库是基于 Unix I/O实现的,并提供了一组强大的高级 I/O 例程。对于大多数应用程序而言,标准 I/O更简单,是优于 Unix I/O的选择。然而,因为对标准 I/O 和网络文件的一些相互不兼容的 限制,Unix I/O比之标准 I/O 更该适用于网络应用程序。
(第8章1分)
(1) 用计算机系统的语言,逐条总结hello所经历的过程
hello.c文本文件先被预处理,处理了宏和条件编译语句,然后把每条高级语言翻译成汇编指令,再汇编成可重定位的二进制文件,最后链接成计算机可识别的可执行二进制文件。执行时hello可执行程序最开始是在磁盘中,使用命令./hello时,磁盘控制器将hello文件的二进制字节通过I/O总线读到主存储器,用户可以用鼠标或键盘输入信息,并通过USB控制器输进shell,shell就会将命令输出到主存, 这时会在CPU中创建一个运行hello的子进程,某一时刻由hello文件生成的子进程抢到了CPU的执行权,被主存储器通过主线接口读进CPU,并在CPU中的寄存器进行hello文件的运行 , 将main函数中的指令通过存储总线I/O桥,再通过系统总线以及PC寄存器和各种寄存器帮助下传到我们ALU进行命令的执行,然后将可能的输出通过图形适配器输出到显示器中。运行结束后会有相应的进程回收hello这个子进程。
(2) 你对计算机系统的设计与实现的深切感悟,创新理念,如新的设计与实现方法。
计算机系统这门课,尤其是csapp(《深入理解计算机系统》)这本书,让我很大程度上改变了对计算机这门学科的认识,知道计算机不仅仅是简单的写代码,还要有整体的系统观念,要对计算机硬件结构有深入的理解,对内部实现加以深究,才算真正的入门计算机,成为一名合格的科班人。
我还仅仅停留在理解层面上,创新理念可能以后会有……
感谢csapp这本地狱级教材和永远做不明白的labs陪我度过的这一学期难忘的时光,也感谢最好的郑老师为我们的辛勤付出,转眼间CS这门课也结束了,无疑这是我上大学以来收获最大一门课。
(结论0分,缺失 -1分,根据内容酌情加分)
列出所有的中间产物的文件名,并予以说明起作用。
文件名 |
作用 |
文件 |
hello.c |
文本文件,用高级语言记录了程序的基本功能,用来预处理 |
|
hello.i |
预处理文本文件,可用于编译 |
|
hello.s |
编译文本文件,记录了高级语言对应的编译指令 |
|
hello.o |
可重定位二进制文本文件,可与其他文件链接成可执行文件 |
|
hello |
可执行文件,可以被计算机直接识别 |
|
hello.s1 |
hello.o的反汇编文件,可用于推测可重定位文件的结构 |
|
hello.s2 |
hello的反汇编文件, 可用于推测可执行文件的结构 |
|
hello.elf |
hello文件的readelf结果文件,记录了hello各段信息 |
|
hello.oelf |
hello.o文件的readelf结果文件,记录了hello.o各段信息 |
图9 所有的中间产物的文件及其作用
具体产物文件放在了CSDN的资源处
CS大作业-中间产物.zip_-讲义文档类资源-CSDN下载
(附件0分,缺失 -1分)
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
(参考文献0分,缺失 -1分)