哈工大2022计算机系统大作业:Hello‘s P2P

说明

本文为哈工大2022年计算机系统大作业。文中内容多为学习课程后的个人理解,如有谬误欢迎指出。

摘要

Hello World程序是许多程序员学习一门程序语言写出的第一个程序。在这个程序的背后,是操作系统和硬件的紧密配合,利用巧妙的抽象将一个复杂庞大的过程简化为一个最基础的程序。本文从计算机系统层面上在各个方面游览了Hello World程序的生命周期,揭开了它神秘的面纱。
关键词:计算机系统;linux

第1章 概述

1.1 Hello简介

1.1.1 文本输入到电脑

假设我们已经打开vim等文本编辑器,在我们敲击键盘的过程中,字符被读入寄存器,再被存入内存中。当我们保存文件并退出,程序文本被交换到磁盘。

1.1.2 用shell编译程序

我们打开shell,实际上也是运行shell这个程序,不过我们的关注点在于hello程序。我们输入命令gcc hello.c -o hello,这个过程相当于预处理→编译→汇编→链接四个过程。由于这四步的具体内容在后面都会解释,就不在这里详细说明了。这时,gcc已经帮我们产生了hello这个可执行目标文件。

1.1.3 用shell加载并运行程序

我们在shell中输入指令./hello,shell由于没有相应的内置命令,它将其识别为一个可执行目标文件,为这个程序创建一个子进程(fork),并利用execve(或,类似的函数)把hello的内容加载到子进程的地址空间。在这个过程中,涉及到虚拟内存:在fork时,操作系统仅仅给子进程复制各种数据结构,如页表等。仅有在子进程进行写时,才会将内容真正地写到内存的另一块地址(私有的写时复制)。

1.1.4 进程的回收

当子进程执行return语句后,它保持一种已经终止的状态,向shell发送SIGCHLD信号,等待shell对其进行回收。当shell调用waitpid指示操作系统将其回收后,hello的生命周期便结束了。

1.2 环境与工具

1.2.1 硬件环境(略)

1.2.2 软件环境

Windows 11;VMWare 16.2.2;Ubuntu 20.04

1.2.3 开发工具

VIM - Vi IMproved 8.1 (2018 May 18, compiled Feb 01 2022 09:16:32);
gcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0.
GNU gdb (Ubuntu 9.2-0ubuntu1~20.04.1) 9.2
edb 1.3.0

1.3 中间结果

文件名 作用
hello.c C语言源程序,文本文件
hello.i 经过预处理的hello源程序,文本文件
hello.s 编译后产生的汇编文件,文本文件
hello.o 汇编产生的可重定位目标文件,二进制文件
hello 链接产生的可执行目标文件,二进制文件

1.4 本章小结

Hello World程序是所有程序员在学习第一门程序语言时所写的第一个程序,最早在1972年Brian Kernighan的B语言教程A Tutorial Introduction to the Language B中出现,1978年也在Kernighan和Dennis Ritchie的经典C语言教材The C Programming Language沿用下来并闻名世界。接下来我们将探究Hello World程序的一生,从各个方面了解Hello World的执行细节。

第2章 预处理

2.1 预处理的概念与作用

预处理(preprocessing)指的是预处理器(cpp)根据以字符#开头的命令(如#include、#define、#pragma等),修改原始的C程序,最后生成.i文本文件的过程。预处理能够帮助程序员节省工作量、使程序更易读、便于维护。

2.2 在Ubuntu下预处理的命令

命令:

cpp hello.c hello.i

在这里插入图片描述

2.3 Hello的预处理结果解析

hello.c源程序包括注释仅有23行,但hello.i有3060行。其中包括大量的typedef,如typedef unsigned char __u_char;还包括600多行的枚举(enum)类型,以及标准C的函数原型,如extern int printf (const char *__restrict __format, …);标准输入输出和错误也在这里被定义(extern FILE *stdin;extern FILE *stdout;extern FILE *stderr;);源程序代码被放在了.i文件的最后。

2.4 本章小结

预处理能够大大简化程序员的工作,并提供一定的可移植性:通过宏定义,我们可以更简单明了地定义一些实用的“函数”;通过#if等编译命令,我们可以将代码根据不同的运行平台做调整,而非在不同平台上采用不同的代码版本,带来不必要的麻烦。

第3章 编译

3.1 编译的概念与作用

编译器(cc1)将预处理过的文本文件转换为汇编文件。汇编语言为不同种类的语言提供了相同的形式,其指令与处理器的指令集类似,更贴近底层,便于汇编器将其转换为机器码供机器执行。

3.2 在Ubuntu下编译的命令

编译命令:

/usr/lib/gcc/x86_64-linux-gnu/9/cc1 hello.i

在这里插入图片描述

3.3 Hello的编译结果解析

3.3.1 判断、函数调用

在这里插入图片描述
这一部分判断输入的命令行参数是否符合要求。若不符合要求,直接提示正确的输入格式并且直接退出程序。对应的汇编如下:
在这里插入图片描述
可以看到在cmpl处将4与-20(%rbp)比对,因此可以得出argc存放在-20(%rbp)处。若argc不等于4,进入下面的leaq语句,把.LC0(%rip)放入%rdi作为参数传入puts。.LC0的内容在最上边:
在这里插入图片描述
.string的内容就是printf中的格式串。由于有中文,在这里用数个字节表示。

3.3.2 循环部分

在这里插入图片描述
该循环进行8次,每次输出argv[1]和argv[2],即学号和姓名,每次sleep argv[3]秒。对应的汇编如下:
在这里插入图片描述
.L3是判断循环条件部分,可以看到i保存在-4(%rbp)中,每次与7比较,若<=7则结束循环,call getchar之后结束程序。循环体内经过一系列处理分别将.LC1(%rip)(即Hello %s %s格式串)、-24(%rbp)和-16(%rbp)放入printf的参数;随后将-8(%rbp)传给atoi,再将返回值%rax传送给sleep,把i++,再次判断循环条件,直到不满足循环条件。

3.4 本章小节

汇编是C源程序经过预处理器、编译器处理后的结果,是最贴近机器底层的语言。即使在高级语言盛行的今天,我们仍然需要学习汇编。通过学习汇编,我们能够理解编译器的优化能力,并分析代码中隐含的低效率以更好地优化我们的程序(正如我们在Lab3中所做的那样)。此外,学习汇编还可以帮助我们分析程序为何崩溃(内存越界引用),如何遭受攻击(缓冲区溢出等),并根据其原理做相应的防护措施。

第4章 汇编

4.1 汇编的概念与作用

汇编器接受.s作为输入,以可重定位目标文件作为输出。可重定位目标文件包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件,从而被加载到内存并执行。

4.2 在Ubuntu下汇编的命令

命令:

as hello.s -o hello.o

在这里插入图片描述

4.3 可重定位目标文件elf格式

命令:

readelf -a hello.o

读取结果:

4.3.1 ELF头

在这里插入图片描述
ELF头描述生成该文件的系统的字的大小和字节顺序、帮助链接器语法分析和解释目标文件的信息。上图中包含的有效信息有:ELF64(ELF 64位的可执行程序);2’s complement,little endian即补码表示,小端法;REL(Relocatable file即可重定位目标文件);运行机器为AMD x86-64;节头开始为文件开始处1160字节偏移处。

4.3.2 节头

在这里插入图片描述
这一部分列出了节头。大致上与CSAPP P467一致。上面的属性分别是节名、类型、地址(此时暂时未被分配均为0)、偏移量(节相对于文件开始的偏移)、节大小、表项(Entry)大小、flags(节属性)、(与其他节的)关联、附加节信息、对齐(2的Align次方)。

4.3.3 符号表

在这里插入图片描述
其中puts、exit等C标准函数都是全局符号(GLOBAL).Value是该符号在对应节中的偏移量。

4.4 Hello.o的结果解析

命令:

objdump -d a.out > asm.txt

在这里插入图片描述
可以看到在反汇编中最大的区别就是调用函数没有填入有效的数字(如16行处第二个字节开始是四字节的0).这些要留到链接阶段进行重定位符号引用,才会填上相对偏移量。此外,由于在编译阶段没有保留符号的名字,函数调用都被写为了<main+offset>的形式。
机器语言用特定的字节表示各种操作。只要给定了文件的开始位置,就可以把合法的字节序列唯一地解释为有效的指令。汇编语言的操作数直接用人能够读懂的字符(%rax、$10)来表示;而机器代码的操作数会被映射为特定的字节(对于寄存器)或大/小端法表示的十六进制(直接数)。分支转移和函数调用由标签、符号(.L1,sum等)变为了相对偏移量,更适合被加载到内存中工作。

4.5 本章小结

汇编器接受汇编代码,产生可重定位目标文件。它可以和其他可重定位目标文件合并而产生一个可以直接加载被运行的可执行目标文件。正因为它并不包含最终程序的完整信息,它的符号尚未被确定运行时位置,并用0占位。在第五部分中将说明如何将多个可重定位目标文件合并,并确定最终符号的最终运行位置。

第5章 链接

5.1 链接的概念与作用

链接将多个重定位目标文件(或静态/动态库)整合到一起,并且修改符号引用,输出一个可执行目标文件。链接使得一个较大的程序可以被分解成许多模块来编写,并最终合并为一个可执行程序。

5.2 在Ubuntu下链接的命令

链接命令:

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/gcc/x86_64-linux-gnu/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro 

在这里插入图片描述

5.3 可执行目标文件hello的格式

读取命令与4.3节相同。
读取结果:

5.3.1 ELF头

在这里插入图片描述

变化的地方有:Type,由REL(可重定向目标文件)变为DYN(共享对象文件);Entry point address即程序入口点,由0x0(未确定)变为了0x1100;程序和节的起始位置和大小都有改变;节个数由13个变为了31个。

5.3.2 节头

以下是部分节头信息。
在这里插入图片描述
在这里插入图片描述
第一个特点就是节的数目有了显著增加,由13个变为31个。多出来的部分节作用如下1
-.plt,.plt.got,.plt.sec:与位置无关代码有关,PLT即Procedure Linkage Table(过程链接表),GOT即Global Offset Table(全局偏移量表),在CSAPP P490页提到。
-.dynamic,.dynsym,.dynstr:与动态链接符号相关。
-.gnu.hash:符号的哈希表,用于加速查找符号。
-.gnu.version,.gnu.version_r:与版本有关的信息。
-.init_array,.fini_array:存放函数指针,其中的函数分别在main函数之前之后调用,用于初始化和收尾。
-.init,.fini:存放上述初始化和收尾的代码。
-.eh_frame_hdr:与异常处理相关。
-.interp:包含了动态链接器在文件系统中的路径。
-.note.ABI-tag:ELF规范中记录的注释部分,包含一些版本信息。
-.note.gnu.build-id:描述该ELF文件的相关属性,保存的是GNU build ID,主要在debug时用于唯一识别一个可执行文件。

5.3.3 符号表

在这里插入图片描述
在这里插入图片描述
可执行文件的符号表中多了很多符号,而且额外有一张动态符号表(.dynsym)。printf、puts、atoi、exit、getchar等C标准库函数在动态符号表和符号表中都有表项。此外一个与可重定向目标文件的不同是,这些符号已经确定好了运行时位置。

5.4 hello的虚拟地址空间

虚拟地址的典型布局如下:
在这里插入图片描述
在这里插入图片描述
在hello的反汇编中,可以找到.text节的起始函数是_start,偏移为0x1100.用gdb在函数入口处打一个断点,运行程序:
在这里插入图片描述
可以知道.text节的虚拟地址从0x555555555100开始。理论上由于ASLR(Address-Space Layout Randomization,地址空间布局随机化),这个地址每次应该不同,但经过多次实验这个地址没有变化。查找资料得知gdb由于方便调试关闭了随机化,可以用set disable-randomization off指令来启用ASLR2。开启后,发现该地址每次是变化的。
在这里插入图片描述

5.5 链接的重定位过程分析

hello(可执行文件)的反汇编多了很多编译器加入的函数,如入口函数_start等。此外,由于ld链接器将可重定位目标文件内的符号进行了重定位,一些原来用0占位的操作数做了修改。由于调用C标准库中的函数都是动态链接的,hello中也没有别的函数,下举printf中的格式串进行分析:
在这里插入图片描述
这个格式串由于是一个硬编码的串,在3.3.1节我们也看到它存在汇编文件的最开始(.string)。在hello.o反汇编中,由于未进行重定位,它是用0占位的:
在这里插入图片描述

可以看到把%rdi赋值0x0(%rip),左边的机器码也是四字节0.
而在hello的反汇编中,这里已经被填上了操作数0xdff,说明这个编码字符串被存在%rip+0xdff处,并被赋给%rdi:
在这里插入图片描述
用gdb验证之,发现正是该字符串。
在这里插入图片描述
用objdump -s hello.o查看hello.o的完整内容:
在这里插入图片描述

可以发现.rodata的内容就是这个字符串。由于puts参数是由运行时PC加上某一固定值实现的,可以确定该重定位为重定位PC相对引用。由前面的分析,我们可以推断出链接器的重定位过程:
(1)链接器确定ADDR(.text) = 0x555555555100.
(2)链接器确定ADDR(str) = x,这应该是链接器根据某种算法安排的值,我们无法直接获得(str即该硬编码字符串)。
(3)str的重定位条目r属性如下:
-r.offset = 0x11e9+0x28 = 0x1211;见下图:lea指令在0x25处,其操作机器码为3字节,因此重定位偏移量在main偏移的0x28处。又由于main在text节偏移0x11e9处,因此总偏移量为0x11e9+0x28=0x1211.
在这里插入图片描述

-r.symbol = str;这个字符串程序中没有指定名字,这里用str指代,也没什么意义。
-r.type = R_X86_64_PC32;该条目说明重定位方式是PC相对引用。
-r.addend = -4,即该重定位位置与PC当前位置的差。
(4)由CSAPP P481的重定位算法,有
refaddr = ADDR(.text)+r.offset = 0x555555555100+0x1211 = 0x555555556311.
*refptr = (unsigned)(ADDR(str)+r.addend-refaddr)
= (unsigned)(x + (-4) - 0x555555556311)
= 0xdff(即最后填到lea操作数的值)
我们就可以算出x的值:x = 0x555555556311 + 0x4 + 0xdff = 0x555555557114.(虽然这个中间值没什么意义)
上述过程即链接器的重定位PC相对引用过程。

5.6 hello的执行流程

用edb的analyze功能(用终端运行edb,在edb中右键→analyze here,分别在ld-xxx.so和hello区域analyze一次,在终端会出现如下信息)可以发现动态链接库中依次出现了如下函数:
在这里插入图片描述
下面是hello模块内部依次出现的函数。
在这里插入图片描述

5.7 Hello的动态链接分析

在这里插入图片描述
在edb的plugin中找到symbol viewer,查找hello!.got表项:
在这里插入图片描述
可以发现一个hello!.got+0x70,我们把它的地址减去0x70,得到0x55f2fdfadf90。
用Ctrl+G快捷键定位到该地址,GOT表内容如下,正好也与我们反汇编结果相同(a0 3d开头,后接一堆0):
在这里插入图片描述
在这里插入图片描述
我们让程序开始运行(上面的Run),到hello部分。发现表的内容已经发生了变化。
在这里插入图片描述
根据书上P491内容,GOT[0]~GOT[2]是动态链接器相关的地址,GOT[3]开始为调用函数的地址,而每个表项为8字节,验证该结论,在symbol viewer中查找该地址,发现正好就是puts在内存中的位置。依次可以验证后面的几项是printf、getchar等。这说明在hello的_start开始前调用了动态链接的相关函数,修改GOT表的内容,将其指向库中函数的运行时位置。
在这里插入图片描述

5.8 本章小节

链接为程序编写以及版本管理(利用动态链接)提供了一定的便利。程序员不必将所有函数同时写在一个文件中,而是可以分别工作,最后将可重定位目标文件链接在一起;利用静态库,计算机可以利用同一组标准库而不需要占用大量的磁盘空间;通过动态链接共享库,多个进程可以共享一个函数的多个副本而不需要花费多份内存空间,并且可以仅仅通过更新动态链接库而不必重新编译程序来更新版本。

第6章 hello进程管理

6.1 进程的概念与作用

“进程是执行的程序,这是一种非正式的说法。进程不只是程序代码,还包括当前活动,如PC、寄存器的内容、堆栈、数据段,还可能包含堆。”3
进程是操作系统对一个正在运行的程序的一种抽象。一个系统在同一时间好像同时运行多个程序,这是通过进程指令的交错执行实现的。

6.2 简述壳Shell-bash的作用与处理流程

shell是一种传统的用户界面,本质上也是一个程序。而bash是shell的一种,在1989年发布第一个正式版本,现在许多Linux发行版都把它作为默认shell。shell每次读取用户输入的指令,并检查其是否为内置命令。若是,则shell直接按用户指令执行;否则它会认为这是一个可执行程序,在文件系统中查找并为其fork一个子进程并执行(execve)。

6.3 Hello的fork进程创建过程

当我们输入./hello时,shell发现它不是一个内置命令,于是将其判定为可执行程序。shell为它fork一个子进程:内核为新进程创建各种数据结构,并分配给它一个唯一的PID。它创建当前进程的mm_struct、区域结构和页表的原样副本。(CSAPP P584,mm_struct结构见P581)

6.4 Hello的execve过程

在shell为hello创建子进程后,shell调用execve函数。execve获取参数中的filename,将命令行作为新进程的argv,并传入环境变量。execve的具体行为在7.7节介绍,这里不重复说明。

6.5 Hello的进程执行

hello并不是操作系统中运行的唯一进程。为了最大化CPU利用率,需要进行进程调度(process scheduling)。当一个进程等待时,操作系统从该进程接管CPU控制,并将CPU交给另一进程。这种切换CPU到另一进程的行为被称为上下文切换(context switch)。进行上下文切换时,陷入内核态,内核会将旧进程状态保存在其PCB(Process Control Block,进程管理块)中,然后加载经调度而要执行的新进程的上下文,并将控制重新转移给新进程(返回用户态)。上下文切换的典型速度为几毫秒。
进程调度分为抢占和非抢占调度:在非抢占调度下,一旦某个进程被分配到CPU,它就会一直使用CPU直到它终止或切换到等待状态;Windows 95引入抢占调度,这类算法允许抢占当前运行进程的CPU时间。
进程调度有几种典型算法3:先到先服务、最短作业优先调度、优先级调度、轮转调度等。
(1)先到先服务。最简单的CPU调度算法就是先到先服务(First-Come First-Served,FCFS)算法。先请求CPU的进程首先分配到CPU,直到它运行结束。FCFS的缺点是平均等待时间往往很长。如果一个耗时较长的进程先到达,那么后来的所有无论执行时间多短的进程都需要等待其结束。
(2)最短作业优先调度(Shortest-Job-First,SJF)。当CPU空闲时,它会被赋给具有最短CPU执行的进程。可以证明SJF算法的平均等待时间最小(反证法即可)。但由于难以知道下次CPU执行的长度,往往试图近似SJF调度:将下一次CPU的执行长度近似为以前CPU测量长度的指数平均。SJF算法可以是抢占的或是非抢占的。抢占的SJF又被称为最短剩余时间优先,如果新进程到达而当前CPU正忙,需要比较当前进程的剩余时间与新进程的执行时间。如果新进程时间较短,可以抢占当前运行进程。
(3)优先级调度(priority-scheduling)。SJF算法是优先级调度算法的一个特例。在优先级调度下,每个进程都有一个优先级,具有最高优先级的进程会被分配到CPU。若优先级相同,则按FCFS调度。这种算法的一个主要问题是无穷阻塞(indefinite blocking)或饥饿(starvation)。优先级调度算法可能会让一个低优先级进程无穷等待CPU。或者在系统轻负荷时该进程被执行,或者系统最终崩溃,并失去所有未完成的低优先级进程。
(4)轮转调度(Round-Robin,这个词讹传自法语ruban rond,17世纪法国农民请愿抗议时将名字写成环状以避免找出带头的人4)。轮转调度算法专门为分时系统设计,它将一个较小时间单元定义为时间片(time slice),大小通常为10~100ms。CPU调度程序循环整个等待进程的队列,为每个进程分配不超过一个时间片的CPU时间。轮转调度的性能很大程度上取决于时间片的大小。在极限情况下,如果时间片选取过大,那么RR算法与FCFS算法没有区别;另一方面,如果时间片选取过小,会导致大量时间被花费在上下文切换上,降低CPU利用率。

6.6 hello的异常与信号处理

执行过程中可能收到SIGINT、SIGTSTP等(如Lab4中);在hello结束或停止时会发出SIGCHLD信号。

6.6.1 异常处理

当进程收到一个信号且该信号未被阻塞,该进程就会调用异常处理程序来处理该信号。如果未用signal函数指定信号处理程序,该进程会执行默认行为。否则,进程的控制会被内核转移到该信号处理程序处。

6.6.2 SIGINT/SIGTSTP

当hello执行过程中用户在键盘上按下Ctrl+C/Z,会使得操作系统给所有前台进程(此时仅有shell)发送一个SIGINT/SIGTSTP信号。当shell捕获到这个SIGINT/SIGTSTP信号,它检查前台进程组并给hello进程发送(通过kill函数)一个SIGINT/SIGTSTP,由于hello没有设置对于这两种信号的处理程序,它执行默认操作:终止/停止,并向父进程shell发送一个SIGCHLD信号。

6.6.3 SIGCHLD

当hello向shell发送一个SIGCHLD,shell会调用自己的SIGCHLD处理程序来回收子进程(通过waitpid函数)。shell需要获取子进程的退出状态(用waitpid中修改过的status参数),并且根据其退出状态做不同的处理:当子进程停止,shell仅仅是将它的运行状态改为停止;否则,shell直接将其回收,并视情况输出提示信息。

以下是一些对信号的测试及其截屏:
对正在运行的hello用Ctrl+Z发送SIGTSTP信号:
在这里插入图片描述
用fg命令使hello恢复前台运行,使用jobs命令查看运行中作业:
在这里插入图片描述
对正在运行的hello用Ctrl+C发送SIGINT信号:
在这里插入图片描述
用pstree命令列出进程树:
在这里插入图片描述
用kill命令向进程发送SIGKILL信号:
在这里插入图片描述

6.7 本章小结

进程是一个执行中程序的实例(Instance),它是计算机科学中最深刻、最成功的概念之一。即使操作系统中同时有多个程序执行,我们看到的也像是操作系统仅在运行前台程序一样,这是通过上下文切换实现的。操作系统根据某种特定的策略调度进程(在不同操作系统中可能是不同的,这也是为什么我们不能假定父子进程的执行先后)来在不同进程间快速地交错执行。

第7章 hello的存储管理

7.1 hello的存储器地址空间

在这里插入图片描述

7.1.1 物理地址

物理地址对应的是计算机的主存,每个地址对应着主存的一个字节。早期计算机和数字信号处理器等系统使用物理地址直接进行寻址。这一点在IA 32和IA 64中没有区别。物理地址是由线性地址转换而来的。

7.1.2 逻辑地址

在IA 32时期,逻辑地址由[段选择符: 段内偏移量]组成(即上图中的Segment Selector和Offset)。在IA 64中,进程的地址不再分段,相当于段选择符代表的段基址以0开始,因此逻辑地址只由段内偏移一个量决定。

7.1.3 线性地址

在IA 32中,线性地址由逻辑地址经过转换得到。先用段选择符到全局描述符表(GDT)中取得段基址,再加上段内偏移量,即得到线性地址。IA 64中,由于不存在段,偏移量也就不需要转换才能得到线性地址了,因此逻辑地址==线性地址。

7.1.4 虚拟地址

虚拟地址强调程序拿到的地址并不是真实的物理地址,而是一个虚拟的地址(由逻辑地址表示),需要经过到线性地址再到物理地址的变换。IA 64中,虚拟地址==逻辑地址==线性地址。
下面一张表格给出了这四种地址的示例:

地址 IA-32 IA-64
逻辑地址 [08F1:0100](省略段描述符) 0x123456
线性地址 0x08F1*0x10+0x0100=0x9010 0x123456
虚拟地址 [08F1:0100](省略段描述符) 0x123456
物理地址 0x9010查询页表获得 0x123456查询页表获得

7.2 Intel逻辑地址到线性地址的变换-段式管理

段式管理是应用在IA32架构上的管理模式。一组寄存器(CS,DS,SS等)保存着当前进程各段(如代码段、数据段、堆栈段)在描述符表中的索引,可以用来查询每段的逻辑地址。当获取了形如[aaaa:bbbb]的逻辑地址,可以通过简单的运算来取得线性地址(段基址*0x10H+段内偏移)。

7.3 Hello的线性地址到物理地址的变换-页式管理

无论是IA32的段式管理还是页式管理,都需要查询页表将线性地址转换为物理地址。线性地址被分为数个部分(在Linux内存系统中为2部分,但思想是相同的)。MMU(Memory Management Unit,内存管理单元)取得线性地址的前面一部分并以其为索引(虚拟页号)查询页表的表项,得到物理页号,再将物理页号与线性地址的后面一部分拼接到一起,得到物理地址,CPU就可以通过这个物理地址访问到内存。

7.4 TLB与四级页表支持下的VA到PA的变换

7.3节中没有考虑TLB与页表层次结构。TLB(Translation Lookaside Buffer,翻译后备缓冲器)用于加速地址翻译。每次CPU产生一个虚拟地址,就必须查询PTE(页表条目),在最差情况下它在内存中读取PTE,耗费几十到几百个周期。TLB是一个对PTE的缓存,每当查询PTE时,MMU先询问TLB中是否存有该条目,若有,它可以很快地得到结果;否则,MMU需要按照正常流程到高速缓存/内存中查询PTE,把结果保存到TLB中,最后在TLB中取得结果。
多级页表用于减少常驻于内存中的页表大小。由于在同一时间并非所有虚拟内存都被分配,那么操作系统可以只记录那些已被分配的页来减小内存开销,这通过多级页表实现。对于一个k(k>1)级页表,虚拟地址被分为k个虚拟页号和一个虚拟页偏移量。为了将虚拟地址转换为物理地址,MMU首先用第一段虚拟页号查询常驻于内存的一级页表,获取其二级页表的基址,再用第二段虚拟页号查询三级页表的基址,直到对第k级页表返回物理地址偏移,MMU就得到了该虚拟地址对应的物理地址。对于那些没有分配的虚拟地址,对应的多级页表根本不存在,只有当分配到它们时才会创建这些页表。因此,多级页表能够减少内存需求。

7.5 三级Cache支持下的物理内存访问

在MMU获得物理地址后,它请求对应内存单元的数据,使高速缓存/内存将它返回给CPU。如果在寄存器和主存之间没有任何其他存储设备,那么每次内存读都需要花费几十个到几百个时钟周期。为了加速对数据的访问,现在几乎所有的计算机都配备多级的高速缓存。高速缓存不同于基于DRAM的主存,它采用基于SRAM的更小而快速的存储方式。存储器层次结构的主要思想是上一层的存储器作为低一层存储器的高速缓存。每当MMU发起一次对内存的取数据请求,L1缓存需要检查是否缓存了该内存处的数据。若有,CPU可以很快地取得该数据(仅花费几个时钟周期);否则,L1缓存需要从L2处请求数据并将L2返回的数据缓存在L1中。以此类推,最终一层层地将数据返回给CPU。
此外,缓存以块为基本单元进行,也就是对于一个单元的请求会把它附近的一个特定大小的块也缓存到更高层的存储器。这依赖于程序拥有的空间局部性和时间局部性。空间局部性指一个被引用的内存单元附近的内存很有可能在不久后被引用;时间局部性指一个被引用的内存单元很有可能在不久后被多次引用。因此,一个具有良好局部性的程序会减少花费在访问内存上的时间:一次缓存可以供后来的多次内存访问使用。因此,程序员应该根据具体问题对程序的写法进行适当的调整,以利用缓存提供的性能提升。

7.6 hello进程fork时的内存映射

当shell调用fork时,内核为新进程创建包括页表、区域结构、mm_struct等数据结构,并将新进程与父进程映射到同一块虚拟内存,并标记这些页为只读,将两个进程中的每个区域结构都标记为私有的写时复制。通过这种“懒惰”的方法,如果父子进程仅仅是读某一块内存,它们不需要花费额外时间来创建一块多余的副本;当其中某个进程需要写某区域时,这个写操作会触发一个保护故障,从而导致故障处理程序在物理内存中创建这个页面的一个新副本,并将页表条目指向这个新副本,恢复这个页面的可写权限。私有的写时复制节省了创建页面的时间,并充分利用了物理内存。

7.7 hello进程execve时的内存映射

shell在fork子进程后要立即调用execve加载并运行hello程序。这需要以下几个步骤:
(1)删除已经存在的用户区域(即除了内核以外的部分,包括代码、数据、bss、堆、栈、共享库内存映射区域);
(2)映射私有区域:为了用新进程替代原有进程,execve需要为新程序的用户区域创建新的区域结构,且它们均为私有写时复制的。其中代码和数据区域被映射为hello中的.text和.data段,bss区域是请求二进制零的,其长度包含在hello文件中;栈和堆也是请求二进制零的,其初始长度均为0.
(3)映射共享区域:如果一个程序与共享对象(目标)链接,(在hello中libc.so就是其中一个),那么这些对象需要被映射到虚拟地址中的共享区域内。
(4)设置PC。对于hello进程,execve设置rip寄存器到代码区域的入口点,程序可以开始执行了。

7.8 缺页故障与缺页中断处理

缺页(page fault)指虚拟内存中的DRAM缓存不命中。当CPU请求某个虚拟地址的数据而它恰好不在主存而在磁盘中时(通过检查有效位),就会引发缺页故障,调用内核中的缺页异常处理程序,它会选择一个牺牲页,用所请求的页替换该牺牲页。如果该主存中的牺牲页还被修改过,在替换之前内核还需要将其复制回磁盘。牺牲页的选择因系统而异,常见的替换算法有LRU(Least Recently Used)算法,它选择一个最近最久未使用的页面作为牺牲页。如果一个程序拥有良好的局部性,虚拟内存能够以较好的效率完成任务(典型的页面大小为4KB,这足够抵消从磁盘交换页面进入内存的时间)。但是,如果一个程序的工作集超出了物理内存的大小,就很可能引发抖动(thrashing)现象,这会导致页面从内存和磁盘之间频繁地换入换出,带来极大的时间开销,此时我们就应该设法减小工作集大小来提高程序速度。

7.9 动态存储分配管理

C标准库的malloc和free函数提供了使程序员能够动态分配和释放内存的方法,在我们预先不知道输入数据的大小时这是很有用的。此外,动态分配内存有时也很危险:如果C(C++)程序员没有释放申请的内存,就很可能导致内存泄露,引起程序崩溃。
堆被组织为一个字节数组。每当程序请求malloc一块内存时,malloc通过某种策略返回一块空闲的指定大小的块的首地址。该策略的选择至关重要:选择算法不能太慢,块应该对齐。分配过程中还会产生内部碎片(对齐或最小块限制导致的未利用空间)和外部碎片(没有满足要求的连续空间),降低空间利用率。此外,该策略还应该能够处理free请求,也就会产生如何高效合并空闲块的问题。CSAPP提供了基于隐式空闲链表的实现。
隐式空闲链表包含头部、有效载荷和可选的填充。头部指明了块大小,并标志该块是否被分配。由于我们默认块是对齐的(如双字-一个字这里指4字节,下同),那么块的大小就总是8的倍数,低3位的0可以让我们保存该块是否被分配的信息。我们可以只通过头部的块大小找到下一个块的起始位置,而并不显式地保存一个指向下一个块的指针,因此这种数据结构被称为隐式空闲链表。
当一个应用请求一个块时,分配器搜索空闲链表,找到一个足够大的空闲块并返回其首地址。典型的放置策略包括首次适配(first fit)、下一次适配(next fit)和最佳适配(best fit)。首次适配返回(从头开始)第一个符合要求的空闲块;下一次适配是Knuth提出的对首次适配的一种改进,它从上一次返回的位置继续搜索:上次发现的匹配位置很有可能仍剩余一些空间,可以满足这次的匹配。最佳匹配搜索满足要求的最小的空闲块,以减少碎片,提高空间利用率。在分配器找到一个空闲块后,它可能选择返回整个空闲块或将其分割为两个块。
当应用要求释放一个块时,可能会有其他空闲块与之相邻。如果仅仅简单地将被释放块标记为空闲,有可能在接下来的操作中引起假碎片(fault fragmentation):许多空闲块挨在一起,但分配器未将它们合并起来满足一个大内存块的请求。为了应对这种情况,分配器还需要一种合并(coalescing)算法。合并也有许多策略:典型的有立即合并和推迟合并(deferred coalescing)。立即合并即在释放后立即检查两端的块,将它们合并;推迟合并在某次请求大内存块失败后见检查整个堆并合并所有的空闲块。
合并一个空闲块后面的块是很简单的,隐式空闲链表的信息足够分配器向后找到所有空闲块;但它无法找到前面块的起始位置。Knuth提出了边界标记(boundary tag)以解决这个问题。边界标记对一个块附加脚部(footer)标记,相当于此块头部的一个副本。因此,为了找到一个块前面一个块,可以访问该块首地址前一个字节的内容(即上一个块的脚部),通过上一个块的大小找到上一个块的首地址。但是这种方法会产生额外的内存开销。当分配的块很小如两个字时,头部和脚部也会消耗两个字的内存,仅有一半的空间存放有效载荷。可以对这种方法进行优化:在前面提到,由于对齐要求,头部有3个位(双字对齐)默认为0,其中一个位已经保存了分配情况;我们可以再用一个位来保存前一个块是否空闲,如果它不是空闲的,合并时也就不需要访问它,它也就不需要保存脚部以供当前块寻址了。反之,空闲块仍需要脚部。

7.10 本章小结

存储管理是操作系统中软件与硬件结合的典型示例。虚拟内存提供了比可用内存更大的地址空间,为进程之间独立运行提供了可能,并保障了内存的安全(我们写出的产生Segmentation Fault的程序就是虚拟内存完美工作的最好证明)。高速缓存加速了程序的运行,使程序员可以从更多的方面来优化他们的程序。存储管理也是C/C++比其他语言更自由之处(在出错时也更让人摸不着头脑),需要我们深入学习来写出更加安全高效的程序。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

Linux把所有IO设备都模型化为字节序列,即文件。所有的输入和输出都被当作对相应文件的读和写来执行。Linux提供了Unix IO接口函数,使得所有的输入和输出以统一的方式进行。

8.2 简述Unix IO接口及其函数

8.2.1 open

进程通过调用open函数来打开一个已存在的文件或创建一个新文件。函数原型如下:

int open(char *filename, int flags, mode_t mode);

open函数将filename转换为一个文件描述符(用数字表示)。返回的数字总是进程中没有打开的最小描述符。flags指明了打开该文件的方式,它有如下选项:
-O_RDONLY:只读;
-O_WRONLY:只写;
-O_RDWR:可读可写。
或者以多个掩码的或提供对文件的更多选项:
-O_CREAT:若文件不存在,创建它的一个截断的空文件;
-O_TRUNC:如果文件已经存在,直接截断它;
-O_APPEND:在每次写操作前,设置文件位置到文件的结尾处。
mode给该文件赋予更多的权限选项,同样可以通过多个掩码的或进行组合:

掩码 描述
S_IRUSR 使用者能读该文件
S_IWUSR 使用者能写该文件
S_IXUSR 使用者能执行该文件
S_IRGRP 拥有者所在组能读该文件
S_IWGRP 拥有者所在组能写该文件
S_IXGRP 拥有者所在组能执行该文件
S_IROTH 任何人能读该文件
S_IWOTH 任何人能写该文件
S_IXOTH 任何人能执行该文件

8.2.2 close

进程通过调用close函数关闭一个打开的文件,参数为open返回的文件描述符。

int close(int fd);

8.3.3 read和write

进程通过调用read和write函数来执行输入输出。

ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, void *buf, size_t n);

read函数从描述符为fd的文件复制最多n个字节到内存位置buf。函数返回-1说明遇到错误,返回0表示EOF。否则,返回读取的字节数量。
write函数从内存位置buf处至多复制n个字节到文件描述符为fd的文件中,若函数返回-1说明遇到错误,否则返回实际写入的字符数量。

8.3 printf的实现分析

下面内容基于5.
printf的(类似)源码:(首先要知道的是,printf在不同平台上实现并不一样,原博客分析的似乎是一个叫做funny os的操作系统中的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;
}

va_list是typedef定义的类型,其实质为char指针。arg指printf的第一个参数,如printf(“%d,%d”, a, b);中的a,原因是:C语言参数从右向左入栈,而栈指针随着栈增长变小。那么栈地址从大到小就是b->a->fmt(即,格式串)。那么在32位系统下,(char*)&fmt+4就应该指向a的地址。随后printf调用vsprintf,其参数为缓冲区buf,fmt格式串以及arg即参数列表。vsprintf内部实现较长,在原博客中有源码,不再分析(但其给出的实现,似乎只实现了%s和%x的格式化)。其作用是返回printf中有效参数的个数,并将格式化的字符串写入到缓冲区buf中。我们回到printf的实现,紧接着它调用我们讲过的linux系统调用write(buf, i)(这与我们上面的文件操作版本的write有差异)。将buf中的i个char型元素写入终端。计算机系统这门课程提供的抽象到此结束,write内部的实现应当更底层。因此,我保留了下面的提示内容,它提示了write更底层的细节:
(以下为原文档中的提示)
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

当程序调用getchar(),程序等待用户输入。用户输入的每个字符实际上是一个中断,其触发事件为键盘按下,行为是将按下的对应字符保存到输入缓冲区。当按下的字符为回车时,中断处理程序将结束getchar并返回读入的第一个字符。

8.5 本章小结

Linux的文件系统将许多概念都抽象成文件:网络、磁盘或是终端。这种抽象提供了一种一致的对不同设备的处理方式。在调用Linux提供的接口时,我们应当注意如read和write函数返回的不足值,对程序做出正确的处理。

结论

Hello程序的生命周期开始于程序员把其内容输入到文本编辑器中:字符数据经过总线最终被传输到寄存器,并在文件被关闭后保存到磁盘。

在程序员于shell调用gcc指令编译hello.c后,它经历预处理、编译、汇编和链接后成为一个可执行目标文件。这是”P2P”中的第一个”P”:Program(程序)。程序可以作为目标文件存在于磁盘上,它仅仅是一堆代码和数据。紧随其后,程序员输入./hello让shell加载并运行hello。shell调用fork函数创建一个新的子进程,操作系统为这个新进程创建各种数据结构,将shell和hello的虚拟内存映射到同一个位置。当shell调用execve,它将子进程的用户区域清除,并用Hello这个“Program”的内容取而代之。

当PC被设置为hello的代码区域入口点后,hello便成为“P2P”中的第二个“P”:Process(进程)。进程是执行中程序的一个具体实例,它包括程序运行的各种状态,如堆、栈、寄存器等。操作系统提供的完美抽象提供了hello是唯一运行程序的假象,这是通过它不断地在许多进程之间实施上下文切换实现的。在hello运行的过程当中,CPU需要访问内存,这也是通过硬件和软件的配合实现的。CPU产生的地址是虚拟地址,它需要经过MMU的翻译,最终从内存的物理地址处取得数据。为了加速这一过程,现代计算机利用高速缓存的思想,在CPU和主存之间设置多级高速缓存(包括D-Cache数据高速缓存和I-Cache指令高速缓存)、在MMU处设置TLB以加速地址翻译,提高访存效率。

当hello的控制走向leave,它也离开(leave)了前台进程这个舞台。它最终向它的父进程发送一个SIGCHLD信号,等待着操作系统将其释放。shell收到SIGCHLD信号后,得知hello的控制正常终止,于是调用waitpid将通知操作系统将各种数据结构和内存释放。这就是hello的020:从0(execve将其堆和栈长度设置为0,并且属性为请求二进制零)到0(操作系统将其回收,其不再占用内存)。这样一个朴实而简单的程序标志着一个程序员生涯的开始;但它又与其之后写下的无数更复杂的程序是如此地相似。

经过一学期对计算机系统的学习,我认识到计算机系统是一个经过无数人的思考,设计出来的一个无比精妙的整体。这门课让我们认识到计算机隐藏在抽象下的运作方式,指导我们写出更有效率、更加安全的代码;告诉我们遇到计算机相关的问题时应该如何思考并解决问题。无论计算机在未来有怎样的变化,这门课程也不会失去其价值。

附件

文件名 作用
hello.c C语言源程序,文本文件
hello.i 经过预处理的hello源程序,文本文件
hello.s 编译后产生的汇编文件,文本文件
hello.o 汇编产生的可重定位目标文件,二进制文件
hello 链接产生的可执行目标文件,二进制文件

参考文献


  1. elf 文件格式-2 - section 和 segment 解析 ↩︎

  2. Enable ASLR in GDB ↩︎

  3. Abraham Silberschatz.等,操作系统概念,机械工业出版社 ↩︎ ↩︎

  4. Round-robin_(document) ↩︎

  5. [转]printf 函数实现的深入剖析 ↩︎

猜你喜欢

转载自blog.csdn.net/wyn1564464568/article/details/124780447