操作系统导论——CPU虚拟化


CPU虚拟化

目录

抽象:进程

进程非正式定义:进程就是运行中的程序

OS通过虚拟化CPU来提供这种假象:通过让一个进程只允许一个时间片,然后切换到其他进程,OS提供了存在多个虚拟化CPU的假象,这也是时分共享CPU技术

抽象:进程

进程的机器状态:

  1. 它的内存。指令存在内存中。正在进行的程序读取和写入的数据也在内存中。因此进程可以访问的内存是该进程的一部分。
  2. 机器状态的另一部分是寄存器
  3. 程序也经常访问持久存储设备。

进程API

OS接口包含的内容:

创建(create):OS必须包含一些创建新进程的方法。shell中输入命令或双击应用程序。

销毁(destroy):还提供了一个强制销毁进程的接口。可以用来停止失控进程。

等待(wait):程序休眠

其他控制(miscellaneous control):例如,大多数OS提供某种方法来暂停进程,然后恢复。

状态(status):获得有关进程的状态信息。例如运行了多长时间,处于什么状态。

进程创建:更多细节

OS如何启动并运行一个程序?

OS运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载到内存中加载到进程的地址空间。程序最初以某种可执行格式驻留在磁盘上。

image-20210918100301411

早期OS加载过程尽早完成,即在运行程序之前全部完成。现代OS惰性执行该程序,即仅在程序执行期间需要加载的代码或数据片段,才会加载。

加载到内存中后,OS在运行此进程之前还要执行一些其他操作。第二件事,必须为程序的运行时栈(run-time stack)分配一些内存。也可能为程序的堆分配一些内存。

OS还将执行一些其他初始化任务,特别是与输入/输出相关的任务

总的来说,将代码和静态数据加载到内存中,通过创建和初始化栈以及执行与I/O设置相关的其他工作。最后一项任务,启动程序,在入口处运行,即main()。通过跳转main()例程,OS将CPU的控制权转移到新创建的进程中,从而程序开始执行。

进程状态

进程的三种状态:

运行(running):在运行状态下,进程正在处理器上运行。这意味着,它正在执行指令。

就绪(ready):在就绪态下,进程已准备好运行,但由于某种原因,OS选择不在此时运行。

阻塞(block):在阻塞状态下,一个进程执行了某种操作,直到发生其他时间时才会准备运行。一个常见的例子是,当进程向磁盘发起I/O请求时,它会被阻塞,因此其他进程可以使用处理器。

image-20210918101903561

数据结构

为了跟踪每个进程的状态,OS可能会为所有就绪的进程保留某种进程列表,以及跟踪当前正在进行的进程的一些附加信息。OS还必须以某种方式跟踪被阻塞的进程。

对于停止的进程,寄存器上下文将保存其寄存器的内容。当一个进程停止时,它的寄存器将被保存到这个内存位置。通过恢复这些寄存器(将它们的值放回实际的物理寄存器中),OS可以恢复运行该进程。

插叙:进程API

关键问题:如何创建并控制接口

fork()系统调用

系统调用fork()用于创建新进程。新创建的进程几乎与调用进程完全一样,对OS来说,这时看起来有两个完全一样的p1程序在运行,并都从fork()系统调用中返回。

子进程不会从main()函数开始执行,而是直接从fork()系统调用返回。子进程并不是完全拷贝了父进程,它拥有自己的地址空间、寄存器、程序计数器。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int
main(int argc, char* argv[])
{
        printf("Hello world(pid:%d)\n", (int)getpid());
        int rc = fork();
        if (rc < 0) {
                fprintf(stderr, "fork failed\n");
                        exit(1);
        }
        else if (rc == 0) {
                printf("hello, I am child (pid:%d)\n", (int)getpid());
        }
        else
        {
                printf("hello, I am parent of %d (pid:%d)\n", rc, (int)getpid());
        }
        return 0;
}

有可能子进程会先运行

得到如下输出:

[root@centos OSChap5]# ./a.out 
Hello world(pid:2870)
hello, I am parent of 2871 (pid:2870)
hello, I am child (pid:2871)

wait()系统调用

父进程调用wait(),延迟自己的执行,直到子进程执行完毕。当子进程结束时,wait()才返回父进程。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int
main(int argc, char* argv[])
{
        printf("Hello world(pid:%d)\n", (int)getpid());
        int rc = fork();
        printf("rc:%d\n",rc);
        if (rc < 0) {
                fprintf(stderr, "fork failed\n");
                        exit(1);
        }
        else if (rc == 0) {
                printf("hello, I am child (pid:%d)\n", (int)getpid());
        }
        else
        {
                int wc = wait(NULL);
                printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", wc, rc, (int)getpid());
        }
        return 0;
}

得到如下输出:

[root@centos OSChap5]# ./a.out 
Hello world(pid:2912)
rc:2913
rc:0
hello, I am child (pid:2913)
hello, I am parent of 2913 (wc:2913) (pid:2912)

exec()系统调用

也是创建进程API的一个重要部分,这个系统调用可以让子进程执行与父进程不同的程序。

exec()给定可执行程序的名称及需要的参数后,exec()会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int
main(int argc, char* argv[])
{
        printf("Hello world(pid:%d)\n", (int)getpid());
        int rc = fork();
        if (rc < 0) {
                fprintf(stderr, "fork failed\n");
                        exit(1);
        }
        else if (rc == 0) {

                printf("hello, I am child (pid:%d)\n", (int)getpid());
                char *myargs[3];
                myargs[0]= strdup("wc");
                myargs[1]= strdup("p3.c");
                myargs[2]= NULL;
                execvp(myargs[0], myargs);
                printf("This shouldn't print out");
         }
        else
        {
                int wc = wait(NULL);
                printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", wc, rc, (int)getpid());
        }
        return 0;
}

为什么这样设计API

这样可以在fork之后exec之前运行代码的机会。

UNIX的管道也是用类似的方式实现的,但用的是pipe()系统调用。在这种情况下,一个进程的输出被链接到了一个内核管道(pipe)上,另一个进程的输入也被链接到了同一个管道上。因此,前一个进程的输出无缝地作为后一个进程的输入,许多命令可以通过这种方式串联在一起。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>

int
main(int argc, char* argv[])
{
        int rc = fork();
        if (rc < 0) {
                fprintf(stderr, "fork failed\n");
                        exit(1);
        }
        else if (rc == 0) {
                close(STDOUT_FILENO);
                open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC,S_IRWXU);

                char *myargs[3];
                myargs[0]= strdup("wc");
                myargs[1]= strdup("p4.c");
                myargs[2]= NULL;
                execvp(myargs[0], myargs);
                printf("This shouldn't print out");
         }
        else
        {
                int wc = wait(NULL);

        }
        return 0;
}

输出如下:

[root@centos OSChap5]# cat p4.output 
 34  66 778 p4.c    # 34行,66个

机制:受限直接执行

通过时分共享CPU,实现了虚拟化。但是也有一些挑战。第一个是性能:如何在不增加系统开销的情况下实现虚拟化?第二个是控制权:如何有效地运行进程,同时保留对CPU的控制?

基本技巧:受限直接执行

image-20210919094711574

使用正常的调用并返回跳转到程序的main(),并在稍后回到内核。

问题1:受限制的操作

直接运行非常快,程序直接在硬件CPU上执行。但是进程希望执行某种受限操作怎么办(I/O请求或获得更多系统资源)?

采用受保护的控制权转移:硬件通过提供不同的执行模式来协助操作系统。在用户态,应用程序不能完全访问硬件资源。在内核态,OS可以访问机器的全部资源。还提供了 trap 内核和 return from trap返回到用户态程序的特别说明,以及一些指令,让OS告诉硬件陷阱表(trap table)在内存中的位置。

要执行系统调用,程序必须执行特殊的trap指令。返回时,必须确保存储足够的调用者寄存器。例如x86上,处理器会将程序计数器、标志和其他一些寄存器推送到每个进程的内核栈(kernel stack)上。

内核通过 trap table 知道在OS内运行什么代码。

image-20210919101706511

问题2:在进程之间切换

如何重新获得CPU控制权,以便可以在进程之间切换。

协作方式:等待系统调用

mac os像这样的系统通常包括一个显式的 yield 系统调用

协作调度系统中,OS通过等待系统调用,或某种非法操作发送,从而重新获得CPU的控制权。

非协作方式:操作系统进行控制

进程不协作,如何获得CPU控制权?

即使进程以非协作方式运行,添加时钟中断也让OS能够在CPU上重新运行。

时钟设备可以编程为每隔几毫米产生一次中断。产生中断时,当前进程停止,OS预先配置的中断程序会运行。此外,OS重新获得CPU控制权,可以停止当进程并启动另外一个进程。

保存和恢复上下文

是继续当前正在运行的进程还是切换到另一个进程,这个决定是由调度程序 scheduler 做出的,它是OS的一部分。

image-20210919105244490

有两种类型的寄存器保存/恢复

第一次是发生时钟中断的时候。在这种情况下,运行进程的用户寄存器由硬件隐式保存,使用该进程的内核栈。

第二种是当OS决定从A切换到B。在这种情况下,内核寄存器被软件(即OS)明确地保存,但这次被存储在该进程的进程结构的内存中,

进程调度:介绍

工作负载假设

确定工作负载是构建调度策略的关键部分。

对进程的假设:

1.每一个工作运行时间相同

2.所有的工作同时达到

3.一旦开始,每个工作保持运行直到完成

4.所有的工作知识用CPU(即不执行I/O操作)

5.每个工作的运行时间是已知的

调度指标

周转时间:任务的周转时间定义为任务完成时间减去任务到达系统的时间。

T周转时间 = T完成时间 - T到达时间

先进先出(FIFO)

先到先服务。

eg:A、B、C同时到达系统,A比B早一点,B比C早一点,每个工作运行时间为10s,则平均周转时间是?

A最先到达,完成时间为10s。接着B开始任务,完成时间是20s,接下来是C,完成时间为30s,平均周转时间为(10+20+30)/3=20s

如果放宽假设1,每个任务运行时间不再相同,A100s,B和C还是10s,则平均周转时间为(100s+110s+120s)/3=110s。

这个问题通常被称为护航效应一些耗时较少的潜在资源消费者被排在重量级的资源消费者之后

最短任务优先(SJF)Shortest Jobs First

先运行最短的任务,然后是次短的任务。(非抢占式调度程序

eg:如果沿用上述例子,平均周转时间为(10s+20s+120s)/3=50s

如果放宽假设2,每个任务不是同时到达。B和C在A不久后到达,仍然被迫等到A完成。则平均周转时间为(100s+(110-10)+(120-10))/3=103.33s

最短完成时间优先(STCF)Shortest Time-to-Completion First

向SJF添加抢占,每当新工作进入系统时,它就会确定剩余工作和新工作中,谁的剩余时间最少,然后调度该工作。

沿用上述例子的平均周转时间为50s。

新度量指标:响应时间

响应时间:从任务到达系统到首次运行的时间

T响应时间=T首次运行 - T到达时间

eg:假设A在0时到达,B和C在10时到达,则平均响应时间为3.33

STCF在响应时间上并不好,如果3个工作同时到达,第三个工作必须等待前两个工作全部运行后才能运行。

轮转(Round-Robin)

时间片轮转算法:在一个时间片运行一个工作,然后切换到运行队列中的下一个任务,而不是运行一个任务直到结束,反复执行,直到所有任务完成。时间片长度必须是时钟中断周期的倍数

时间片长度对轮转算法至关重要。太短导致频繁上下文切换影响整体性能

摊销技术:通过减少成本的频度(即执行较少次的操作)系统的总成本就会降低。例如,时间片为10ms,上下文切换为1ms,这样大约10%的时间用于上下文切换,如果将时间片设为100ms,则只有1%的时间用于上下文切换,因此时间片带来的成本就被摊销了。

轮转的平均周转时间会上升。任何公平的政策,即在小规模的时间内将CPU均匀分配到活动进程之间,在周转时间这类指标上表现不佳。响应时间减少、周转时间上升。

结合I/O

调度程序必须要在工作发起I/O时做出决定,因为当前执行的作业在I/O期间不会使用CPU,它被阻塞等待I/O完成。

eg:A和B各执行50ms,但是A会每执行10ms发起I/O请求,每次I/O请求为10ms。则完成A时间为90ms,再执行B,平均周转时间为70ms。但如果把A划分为5个子工作,会在A执行I/O时,B抢占执行。这样做可以实现重叠,一个进程在等待另一个进程的I/O完成时使用CPU,系统因此得到更好的利用。

无法预知

OS没法预知每个工作的长度。

调度:多级反馈队列

多级反馈队列(Multi-level Feedback Queue)解决两方面问题:首先,它要优化周转时间。其次,给交互用户很好的交互体验,因此需要降低响应时间

MLFQ:基本规则

MLFQ中有许多独立的队列,每个队列有不同的优先级。任何时刻,一个工作只能存在于一个队列中。MLFQ总是优先执行较高优先级的工作

例如:如果一个工作不断放弃CPU去等待键盘输入,这是交互型进程的可能行为,MLFQ因此会让它保持高优先级。相反,如果一个任务较长时间占用CPU,则会降低其优先级。

两条基本规则:

  • 规则1:如果A的优先级>B的优先级,运行A
  • 规则2:如果A的优先级=B的优先级,轮转运行AB

image-20210920104736432

如何改变优先级

  • 规则3:工作进入系统时,放在最高优先级(最上层队列)

  • 规则4a:工作用完整个时间片后,降低其优先级(移入下一个队列)

  • 规则4b:如果工作在其时间片内主动释放CPU,则优先级不变

实例1:单个长工作

该工作首先进入最高级队列,执行10ms时间片后,调度程序将其优先级减1,因此进入Q1。在Q1执行完一个时间片后,进入最低优先级队列。

image-20210920105312194

实例2:来了短工作

A在最低优先级队列,B在100时到达,加入最高级队列,经过两个时间片运行,B执行完毕,然后继续A执行

如果不知道工作是短工作还是长工作,那么就在开始的时候假设其是短工作,并赋予最高优先级。如果确实是短工作,则很快会执行完毕,否则将被慢慢移入优先级队列,这时该工作也被认为是长工作。

实例3:如果有I/O

A为长工作,B为交互型工作,每工作1ms执行IO。

image-20210920105921114

MLFQ的问题

1:如果有许多交互型工作,则会长时间占用CPU,而CPU密集型长工作则得不到服务,会造成饥饿

2:有的程序会使用手段欺骗调度程序,以获得远超公平的资源,如果在占用99%的时间片后释放CPU保持较高优先级队列

3:一个程序可能在不同时间表现不同。计算密集型进程可能在某段时间表现为一个交互性进程。

提升优先级

周期性地提升所有工作的优先级,简单的:将所有工作扔到最高优先级队列

  • 规则5:经过一段时间S,就将系统中所有工作重新加入到最高级队列

image-20210920110959314

另外的问题是:S的值该如何设置?

更好的计时方式

如何防止调度程序被愚弄?调度程序应该记录一个进程在某一层中消耗的总时间,而不是在调度时重新计时。只要进程用完了自己的配额,就将其降到低一优先级队列中去。

  • 规则4:一旦工作用完了其在某一层中的事件配额(无论中间主动放弃多少次CPU),就降低其优先级(移入低一级队列)

image-20210920123341755

MLFQ调优及其他问题

配置多少队列?每一层队列的时间片配置多大?应该多久提升一次进程的优先级?

Solaris默认60层队列,时间片长度从20ms到几百ms,每1秒左右提升一次进程的优先级

image-20210920124034773

调度:比例份额

比例份额调度程序有时也称为公平份额调度程序。调度程序的最终目标是确保每个工作获得一定比例的CPU时间,而不是优化周转时间和响应时间。

基本概念:彩票数表示份额

一个进程拥有的彩票数占总彩票数的百分比,就是它占有资源的份额。

假设A75张,B有25张,定时地抽取,以决定运行A或B。

这种随机方法相对于传统决策有几种优势:

1.不需要记录任何状态。传统的需要记录每个进程已经获得了多少CPU时间。

2.快速。

利用随机性,进程的运行时间从概率上满足期望的比例

步长调度

系统中的每个工作都有自己的步长,这个值与票数成反比。

eg: A、B、C的票数分别是100、50、250,接下来用一个大数分别除以票数来获得步长,比如用10000,得到的步长分别为100、200、40.每次进程运行后,会让计数器增加它的步长,记录它的总体进展。

image-20210921111441996

问题:同样需要记录,而且记录的是全局状态。并且如果新进入一个程序,状态设为0,会独占CPU。

这两类调度程序不经常使用。

多处理器调度(高级)

内存虚拟化

抽象:地址空间

早期系统

早期的机器并没有提供多少抽象给用户。OS从物理地址0开始 ,进程则是使用剩余的内存。

多道程序和时分共享

多个进程在给定时间准备运行,比如当一个进程在等待I/O操作时,OS会切换这些进程,增加CPU利用效率。人们意识到批量计算的局限性,每个人都在等待它们执行的任务及时响应。

最开始的机器共享:让一个进程单独占用全部内存运行一小段时间,然后停止,将它所有的状态信息保存在磁盘上(包含所有的物理内存),加载其他进程的状态信息,再运行一段时间。这种方法保存和恢复寄存器级的状态信息相对较快,但将全部的内存信息保存到磁盘就太慢了。

因此,在进程切换的时候,仍然将进程信息放在内存中,这样OS可以更有效率地实现时分共享。

随着时分共享流行,进程的保护变得十分重要,不希望一个进程可以读取其他进程的内存。

image-20210921123334490

地址空间

地址空间是OS提供的一个易用的物理内存抽象,是运行的程序看到的系统中的内存。

一个进程的地址空间包含运行的程序的所有内存状态。包含代码、栈和堆。

image-20210921122958682

当多个线程在地址空间中共存时,没有像这样分配的好办法。

我们描述地址空间时,所描述的时操作系统提供给运行程序的抽象程序不在物理地址0~16kb的内存中,而是加载到任意的物理地址。如上节图所示,A、B和C加载到内存中的不同地址。

当OS在单一的物理内存为多个运行的进程(所有进程共享内存)构建一个私有的、可能很大的地址空间的抽象,就说OS在虚拟化内存

eg:上节图的进行A在地址0(将其称为虚拟地址, virtual address)执行加载操作时,OS确保不是加载到物理地址0,而是物理地址320KB(A载入内存的地址)。

目标

虚拟内存(VM)系统的目标:

  1. 透明。实现虚拟化内存的方式应该让运行的程序看不见,程序不应该感知到内存被虚拟化的事实。
  2. 效率。OS追求虚拟化尽可能高效,包括时间上(不会使程序运行得更慢)和空间上(不需要太多额外的内存来支持虚拟化)。
  3. 保护。确保进程受到保护,不会受其他进程影响,OS本身也不会受进程影响。

代码、堆、栈的地址

#include <stdio.h>
#include <stdlib.h>

int main (int argc, char *argv[]){
        printf("location of code : %p\n", (void *) main);
        printf("location of heap : %p\n", (void *) malloc(1));
        int x = 3;
        printf("location of stack : %p\n", (void *) &x);
        return x;
}

运行如下

[root@centos OSChap13]# ./a.out 
location of code : 0x40057d
location of heap : 0xf8e010
location of stack : 0x7ffda1ee1dac

如果你在一个程序中打印出一个地址,那就是一个虚拟地址。虚拟地址只是提供地址如何在内存中分布的假象(抽象),只有操作系统(和硬件)才直到物理地址。

插叙:内存操纵API

如何分配和管理内存?

内存类型

C程序在运行中,会分配两种类型的内存。第一种为栈内存,它的申请和释放操作时编译器来隐式管理的,所以有时也称为自动内存。

eg:比如在func()函数中定义一个整数x

void func(){
	int x; // 在栈内存中声明一个整数
}

编译器完成剩下的事,确保进入func()函数的时候,在栈上开辟空间。函数退出时,编译器释放内存。如果须在信息存在于函数调用之外,就不要放在栈中。

对于长期内存的需求,第二种称为堆内存,其中所有的申请和释放操作都由程序员显式地完成。

eg:在堆上分配一个整数,得到指向它的指针

void func(){
	int *x = (int *) malloc(sizeof(int));
}

malloc()调用

函数输入:传入要申请的堆空间大小。输出:成果就返回一个指向新申请空间的指针,失败就返回NULL。

只要包含头文件 <stdlib.h> 就可以使用malloc()了。sizeof()通常被认为是编译时操作符意味着这个大小是在编译时就已知道,sizeof被认为是一个操作符,而不是一个函数调用(函数调用在运行时发生)

#include <stdio.h>
#include <stdlib.h>

main(){
        int *x = malloc(10 * sizeof(int)); // sizeof认为我们只是问一个整数的指针多大
        printf("%d\n", sizeof(x));
        int y[10];
        printf("%d\n", sizeof(y));
}

输出:

[root@centos OSChap14]# ./a.out 
8
40

注意:如果为一个字符串声明空间,使用malloc(strlen(s)+1),使用strlen获取字符串的长度,并加上1,以便为字符串结束符留出空间

free()调用

释放不再使用的堆内存,使用free()函数,接收一个由malloc()返回的指针参数。

常见错误

对于支持自动内存管理的语言,当你调用类似malloc()的机制来分类内存时(通常用new或类似的东西来分配一个新对象),永远不需要调用其他来释放内存垃圾收集器会运行,找出不再引用的内存替你释放

忘了分配内存

错误代码如下

char *src = "hello";
char *dst; 
strcpy(dst, src); // 段错误

上述代码不会报错,可以正常运行

正确代码如下

char *src = "hello";
char *dst = (char *) malloc(strlen(src) + 1); 
strcpy(dst, src);  // 或者使用strdup()

没有分配足够的内存

有时也称缓冲区溢出(buffer overflow)

比如字符串拷贝时没有加1。在拷贝时,他会在超过分配空间的末尾处写入一个字节,有些情况下无害,可能会覆盖不再使用的变量溢出是系统中许多安全漏洞的来源

忘记初始化分配的内存

程序会遇到未初始化的读取。

忘记释放内存

另一个常见的错误为内存泄漏,如果忘记释放,就会发生。长时间运行的应用程序或者系统中,是一个巨大的问题,因为缓慢泄露的内存会导致内存不足,此时需要重新启动。有GC机制下,如果你仍然拥有对某块内存的引用,那么垃圾收集器就不会释放

在用完之前释放内存

这种错误称为悬挂指针。

反复释放内存

称为重复释放,结果是未定义的

错误地调用free()

free需要传入一个指针,传入其他的会得到无效释放

为什么进程退出时没有内存泄漏?

编写一个短时间运行的程序时,使用malloc分配内存。退出前是否需要free?

真正意义上,没有任何内存会“丢失”。因为系统中实际存在两级内存管理。

第一级是操作系统执行的内存管理,OS在进程运行时将内存交给进程,退出时回收。

第二级管理在每个进程中,例如在调用malloc()和free()时,在堆内管理。即使没有调用free,OS也会在程序结束运行时,收回进程的所有内存,无论地址空间中堆的状态如何。

底层操作系统支持

malloc和free不是系统调用而是库调用。但是malloc本身是建立在一些系统调用之上的,这些系统调用会进入操作系统,来请求更多内存或者将一些内容释放回系统

一个这样的系统调用叫brk,被用来改变程序分断(break)的位置:堆结束的位置。它需要一个参数(新分断的地址),从而根据新分断是大于还是小于当前分断,来增加或减小堆的大小。另一个调用sbrk要求传入一个增量,但目的是类似的。

机制:地址转换

如何高效、灵活地虚拟化内存?

利用地址转换,硬件对每次内存访问进行处理(即指令获取、数据读取或写入),将指令中的虚拟地址转换为数据实际存储的物理地址。因此,在每次内存引用时硬件都会进行地址转换,将应用程序的内存引用重定位到内存中实际的位置。

一个例子

void func(){
	int x;
	x = x + 3;
}

x的初始值为3000,该进程如图所示

image-20210922114023711

动态(基于硬件)重定位

每个CPU需要两个硬件寄存器:基址寄存器和界限寄存器。使得能将地址空间放在物理内存的任何位置,同时又能确保进程只能访问自己的地址空间。

OS决定程序在内存的中的实际加载地址,并将起始地址记录在基址寄存器中。上述程序运行时,OS决定加载到32KB物理内存,因此基址寄存器的值就为32KB。该进程产生的所有内存引用,都会被处理器通过以下方式转为物理地址:

physical address = virtual address + base

eg:上述访问int x

物理地址 = 32KB + 15KB = 47KB,进程中使用的内存引用都是虚拟地址,硬件计算出物理地址,并将其发回给内存系统。

上述界限寄存器被设置为16KB,因为只开辟的16KB的内存空间。如果进程需要访问超过这个界限或者为负数的虚拟地址,CPU会触发异常,进程最终可能被终止。

有时我们将CPU的这个负责地址转换的部分统称为内存管理单元 MMU。

空闲列表

OS用列表记录哪些空闲内存没有使用,以便能够为进程分配内存。

image-20210922120412195

重定向会造成内部碎片,所以需要更复杂的机制,以便更好地利用物理内存,接下来尝试将基址加界限的概念稍稍泛化,得到分段的概念。

分段

如果将整个地址空间放入物理内存,那么栈和堆之间的空间并没有被进程使用,缺依然占用了实际的物理内存。如何支持大地址空间?

分段:泛化的基址/界限

不止一个基址界限对,而是给地址空间内的每个逻辑段一对。一个段只是地址空间里的一个连续定长的区域,在典型的地址空间里有3个逻辑不同的段:代码、栈和堆。

image-20210923093507572

引用哪个段

硬件如何知道段内的偏移量?以及地址引用了哪几个段?

显式的方式:如果用14位虚拟地址的前两位来标识:00表示代码,01表示堆,10表示栈

会引起一个段的地址空间被浪费,所以有些系统将栈和堆当作同一个段,用一位来做标识。

反向增长

上图中栈从28KB增长到26KB,相应虚拟地址从16KB到14KB。于是除了基址和界限外,硬件还需要知道段的增长方向(用一位区分,1代表自小而大增长,0反之)

支持共享

为了节省内存,在地址空间之间共享某些内存段是有用的,因此需要硬件支持。

给每个段增加了几个位,标识程序是否能够读写该段,或执行其中的代码。

image-20210923095426976

有了保护位,除了检查虚拟地址是否越界,硬件还需要检查特定访问是否允许

细粒度与粗粒度的分段

分段少-粗粒度

分段多-细粒度,在内存中保存段表

操作系统支持

一个基址界限对和分段的区别:每个地址空间只有一个基址界限对会使得在运行程序的同时,需要把进程所需的代码块,栈和堆需要用的空间放到内存当中去,而这些空间是没有使用的,这样容易造成内部碎片,许多内存被浪费掉了。而分段则无需给进程分配一整块的内存,通过给每个代码段一对基址界限,这样通过逻辑地址来寻址,可以节省很多内存。

分段带来的新问题:

OS在上下文切换时应该做什么?每个段寄存器中的内容必须保存和恢复。每个进程都有自己独立的虚拟地址空间,OS必须在进程运行之前,确保这些寄存器被正确地赋值。

管理物理内存的空闲空间。物理内存很快充满了许多空闲空间的小洞,很难分配给新的段或扩大已有的段。这种问题称外部碎片

image-20210923100930669

问题的解决方案

1.紧凑物理内存,重新安排原有的段。例如,OS先终止运行的进程,将它们的数据复制到连续的内存区域中去,改变它们的段寄存器中的值,指向新的物理地址,从而得到了足够大的连续空间。但是,内存紧凑成本很高,会占用大量的处理器时间,而且应当在什么时候移动?

2.利用空闲列表管理算法,试图保留大的内存块用于分配。如最优适配、最坏匹配、首次匹配等等。

小结

分段可以更高效地虚拟内存,避免潜在的内存浪费,更好地支持稀疏地址空间,还可以代码共享。但是还是不足以支持更一般化的稀疏地址空间。例如,一个很大但是很稀疏的堆,都在一个逻辑段中,整个堆仍然需要完全加载到内存当中去。

空闲空间管理

如何管理空闲空间?

假设

在堆上管理空闲空间的数据结构称为空闲列表。假设不能进行紧凑,分配程序管理的是连续的一块字节区域。

底层机制

首先,看看空间分割与合并,其次如何快速并相对轻松地追踪已分配的空间。最后,讨论如何利用空闲区域的内部空间维护一个简单的列表,来追踪空闲和已分配的空间。

分割和合并

image-20210923105322453

任何大于10字节的请求都会失败(返回NULL)

如果申请小于10字节的内存,分配程序会执行分割(splitting)动作:找到一块可以满足的空闲空间,将其分割,第一块返回给用户,第二块留在空闲列表中。

分配程序还有另一种机制,合并(coalescing)。如果内存被free,只是简单地将空闲空间加入到空闲列表中,这样仍为几个10字节的空闲空闲,申请20字节的空间依然失败。所以分配程序在释放一块内存时合并可用空间。

追踪已分配空间的大小

大多数分配程序都会在头块(header)中保存一点额外的信息。如果用户调用了malloc(),并将结果保存在ptr中:ptr = malloc(20)

image-20210923110545494

如果用户请求N字节的内存,库不是寻找大小为N的空闲块,而是寻找N加上头块大小的空闲块

让堆增长:大多数的分配程序会从很小的堆开始,空间耗尽时,再向OS申请更大的空间.OS再执行sbrk系统调用时,会找到空闲的物理内存页,将它们映射到请求进程的地址空间中去,并返回新的堆的末尾地址。

基本策略

最优匹配

首先遍历整个空闲列表,找到和请求大小一样或更大的空闲块,然后返回这组候选者中最小的一块。

选择最接近用户请求大小的块,从而避免了浪费空间,然而,在遍历查找正确的空闲块时,要付出较高的代价

最差适配

尝试找到最大的空闲块,分割并满足用户需求后,将剩余的块加入空闲列表。

表现非常差,导致过量的碎片,同时还有很高的开销。

首次匹配

找到第一个足够大的块,将请求的空间返回给用户,同样,剩余的空闲空间留给后续请求。

有速度优势,不需要遍历整个列表,但有时列表开头有很多小块。因此,分配程序如何管理空闲列表的顺序就变得很重要。一种是基于地址排序。通过保持空闲块按内存地址有序,合并操作会很容易,从而减少内存碎片

下次匹配

这个算法多维护一个指针,指向上一次查找结束的位置。想法是将对空闲空间的查找操作扩展到整个列表中去,避免对列表开头频繁的分割。同样避免了遍历列表

分页:介绍

如何通过页来实现虚拟内存?

将空间分割成固定长度的分片。把物理内存看成是定长槽块的阵列,叫做页帧,每个这样的页帧包含一个虚拟内存页。

例子

image-20210924094110192

图1是64字节的地址空间,有4个16字节的页(虚拟页),左边0~64KB是物理内存地址。图2是有8个页帧,128字节物理内存,右边是OS将物理内存划分的页帧号。

虚拟页和页帧注意区别

物理地址非划分成大小相等的页帧,页帧是程序使用的虚拟地址,程序的虚拟地址空间被划分成大小相等的页。页内偏移大小=帧内偏移大小,页号大小(size)和帧号大小(size)可能不一样

为了记录地址空间的每个虚拟页放在物理内存中的位置,OS为每个进程保存一个页表。主要作用是为地址空间的每个虚拟页面保存地址转换,从而知道每个页在物理内存中的位置。如上图:Virtual Page0 -> Page Frame 3; Virtual Page1 -> Page Frame7...

eg:将虚拟地址到寄存器eax的数据显式加载

movl 21, %eax

image-20210924100735014

image-20210924113816505

  • 页映射帧
  • 页是连续的虚拟内存
  • 帧是非连续的物理内存
  • 不是所有的页都有对应的帧

通过页帧来计算物理地址

image-20210924112245998

image-20210924112800118

页表

页表用于将虚拟地址(虚拟页号)映射到物理地址(物理帧号)。

页表中的有效位(valid bit)用于指示特定地址转换是否有效。保护位(protection bit)表明页是否可以读取、写入或执行。存在位(present bit)表示该页是在物理存储器还是在磁盘上。脏位(dirty bit)表示页面被带入内存后是否被修改过。参考位(reference bit,也称访问位 access bit)用于追踪页是否被访问。

eg:x86的页表项

image-20210924102233878

存在位P;是否允许读写位(R/W);确定用户模式进程是否可以访问该页面的用户/超级用户位(U/S);确定硬件缓存如何为这些页面工作(PWT/PCD/PAT/G);一个访问位A;脏位D;页帧号PFN。

image-20210924115356277

页表的地址转换实例

image-20210924120755993

(4,1023)对应物理地址: 2^10*4+1023=5119

分页:快速地址转换(TLB)

分页机制的性能问题:

  • 访问一个内存单元需要2次内存访问:一次用于获取页表项,一次用于访问数据
  • 页表可能很大

因为要使用分页,就要将内存地址空间切分成大量固定大小的单元页,并且需要记录这些单元的地址映射信息。这些映射信息一半存储在物理内存中,所以在转换虚拟地址时,分页逻辑上需要一次额外的内存访问。每次指令获取、显式加载或保存,都要额外读取一次内存以得到转换信息,会非常慢。

如何加速地址转换?

缓存或间接访问。需要增加地址转换旁缓冲存储器(translation-lookaside buffer)TLB(位于CPU内部),就是频繁发生的虚拟到物理地址转换的硬件缓存。对每次内存访问,硬件先检查TLB,看其中是否有期望的转换映射,如果有就不用访问页表。

image-20210924121656901

TLB的基本算法

1 VPN = (VirtualAddress & VPN_MASK) >> SHIFT  			// 虚拟地址中提取页号
2 (Success, TlbEntry) = TLB_Lookup(VPN)					// 检查TLB是否有VPN的映射
3 if (Success == True)   									
4 	 if (CanAccess(TlbEntry.ProtectBits) == True)
5 		 Offset = VirtualAddress & OFFSET_MASK			
6 		 PhysAddr = (TlbEntry.PFN << SHIFT) | Offset  // 通过VPN取出PFN与Offset求得物理地址
7 		 AccessMemory(PhysAddr)
8 	 else
9 		 RaiseException(PROTECTION_FAULT)				// 被保护不能访问
10 else  	// PTBR页表基址寄存器						  // TLB Miss
11 	 PTEAddr = PTBR + (VPN * sizeof(PTE))				
12	 PTE = AccessMemory(PTEAddr)
13	 if (PTE.Valid == False)								// 不存在对应页帧
14		 RaiseException(SEGMENTATION_FAULT)
15	 else if (CanAccess(PTE.ProtectBits) == False)		// 被保护不能访问
16		 RaiseException(PROTECTION_FAULT)
17	 else  // page number, page frame number, 保护位
18		 TLB_Insert(VPN, PTE.PFN, PTE.ProtectBits)		// 更新TLB
19		 RetryInstruction()

img

访问a[0]时,未命中,a[0]的VPN以键值对的形式存入TLB,访问a[1]时,VPN存在于TLB中,所以命中。共计3次Miss,7次Hit,命中率为70%

处理TLB Miss

硬件处理

硬件必须知道页表在内存中的确切位置(通过页表基址寄存器),以及页表确切格式。发生Miss时,硬件会”遍历“页表,找到正确的页表项,取出想要的转换映射,用它更新TLB,并重试该指令。如上面的代码。

x86采用固定的多级页表,当前页表由CR3寄存器指出。

软件处理

Mips(如RISC-V)由操作系统管理,有所谓的软件管理TLB。发生Miss时,硬件系统会抛出异常(11行),这回暂停当前的指令流,将特权级提升至内核模式,跳转至陷阱处理程序。如下面的代码。

1    VPN = (VirtualAddress & VPN_MASK) >> SHIFT
2    (Success, TlbEntry) = TLB_Lookup(VPN)
3    if (Success == True)    // TLB Hit
4        if (CanAccess(TlbEntry.ProtectBits) == True)
5            Offset    = VirtualAddress & OFFSET_MASK
6            PhysAddr = (TlbEntry.PFN << SHIFT) | Offset
7            Register = AccessMemory(PhysAddr)
8        else
9            RaiseException(PROTECTION_FAULT)
10   else                    // TLB Miss
11       RaiseException(TLB_MISS)

这里的陷阱返回指令不同于服务于系统调用的从陷阱返回。后者返回后继续执行陷入操作系统之后那条指令,而前者是在TLB未命中的陷阱返回后,硬件必须从导致陷阱的指令继续执行。

TLB的内容

为了实现上下文切换的TLB共享,添加了一位地址空间标识符(Address Space Indentifier , ASID)。可以把ASID看做是PID(Process Identifier),但通常比PID少(PID32位,ASID8位)

img

也可以使得不同的VPN使用相同的PFN,共享同一物理页

分页:较小的表

如何让页表更小?

分页和分段结合

结合的方法不是为进程的整个地址空间提供单个页表,而是为每个逻辑分段提供一个

多级页表

基本思想:

首先,将页表分成页大小的单元。然后,如果整页的页表项无效,就完全不分配该页的页表。为了追踪页表的页是否有效(以及如果有效,它在内存中的位置),使用了名为页目录的新结构。页目录因此可以告诉你页表的页在哪里,或者页表的整个页不包含有效页。

image-20210925092043446

线性页表,即使地址空间的大部分中间区域无效,我们仍然需要为这些区域分配页表空间。

页目录项(Page Directory Entries)至少拥有有效位和页帧号。但这个有效位是指PDE指向的页表至少有一项是有效的。很好地支持稀疏的地址空间。

image-20210925095945106

例子展示

一个大小为16KB的小地址空间,包含64个字节的页。

image-20210925101813094

因此,有一个14位的虚拟地址空间,VPN有8位,偏移量有6位,所以线性表有2^8=256个项

image-20210925102938626

假设每个PTE为4字节,则Page Table为 256*4 Byte=1KB,每页大小为64 Byte,因此Page Table可以分为16个64字节的页,每个页可以容纳16个PTE。

总体步骤:获取VPN,用它索引到页目录,然后再索引到页表的页中。

16个页需要4位来表示,使用VPN的前四位。称为 Page Directory Index,根据下式来计算页目录项(PDE)的地址

PDEAddr = PageDirBase + (PDIndex * sizeof(PDE))

如果页目录项标记为无效(valid = 0 )则访问无效引发异常。如果有效,则需要从页目录项指向的页表的页中获取页表项PTE,页表索引(Page-Table Index)使用VPN剩下四位,公式如下

PTEAddr = (PDE.PFN << SHIFT) + (PDIndex * sizeof(PDE))

image-20210925104819557

从页目录项获得的页帧号必须左移

完整的页目录如下:

image-20210925104354680

一共256个PTE,实际只用存储16个页表项目录加上2个页(128 Bytes),远小于线性表的1 KB

不止两级

假设30位地址空间,每个页512字节,所以需要9位(2^9=512)offset,和21位 Virtual Page Number

假设每个PTE 4字节,所以每页有128个PTE(512/4=128),因此页表索引PTI 需要7位(2^7=128)

剩下14位(21-7=14)的页目录索引PDI,不是128。所以分成2个7位。

image-20210925112000562

不是要把所有地址都存在表里

image-20210925100137448

地址转换过程

展示了每个内存引用在硬件中发生的情况(假设硬件管理的TLB)

在TLB未命中时,硬件才需要执行完整的多级查找。这条路劲上,可以看到传统的两级页表的成本,两次额外的内存访问来查找有效的转换映射

image-20210925112523748

反向页表

用页帧号作为Index来查找逻辑页号

image-20210925114115927

超越物理内存:机制

如何用硬盘透明地提供巨大虚拟地址空间的假象?

交换空间

需要在硬盘上开辟一部分空间用于物理页的移入和移出,一般称这样的空间为交换空间。

image-20210925122128679

存在位

硬件判断页是否在内存中的方法,时通过页表项中的存在位。如果为1,则表示该页存在于物理内存中,并且所有内容都如上述进行。如果存在位位0,则页不在内存中,而是硬盘上。访问不在物理内存中的页,这种行为称为页错误(page fault)

页错误

OS如何知道所需要的页在哪?

许多系统中,页表是存储这些信息最好的地方。因此,OS可以用PTE中的某些位来存储硬盘地址,这些位通常用来存储PFN。当操作系统接收到页错误时,会在PTE中查找地址,并将请求发送到硬盘,将页读取到内存中。

硬盘I/O完成时,操作系统会更新页表,将此页标记为内存,更新页表项(PTE)的PFN字段已记录获取页的内存位置,并重试指令。

I/O时,进程处于阻塞状态。

交换什么时候发生

为保证有少量的空闲内存,大多数OS会设置高水位线(High Watermark,HW)和低水位线(Low Watermark,LW)来帮助决定何时从内存中清除页。

原理:当OS发现有少于LW个页可用时,后台负责释放内存的线程会开始运行,知道有HW个可用的物理页。这个后台线程称为守护进程(swap daemon),然后进入休眠状态。

通过同时执行多个交换进程,可以进行一些性能优化。交换算法需要先检查是否有空闲也,而不是直接执行替换。如果没有空闲页,会通知后台分页线程按需要释放页。当线程释放一定数目的页时,它会重新唤醒原来的线程,然后就可以把需要的页交换进内存,继续工作。

页面置换算法

如何决定换出哪个页?

缓存管理

内存只包含系统中所有页的自己,可以将其视为系统中虚拟内存页的缓存。进行页面置换时,希望缓存命中多(内存中找到待访问页面),缓存未命中少(磁盘获取页的次数)。

平均内存访问时间 AMAT=(PHit * TM)+(PMiss * TD

TM访问内存的成本,TD访问磁盘的成本,各自的概率相加为1

例子

10次命中9次,未命中1次。访问内存为100ns,访问磁盘为10ms,则

AMAT = (0.9*100ns) + (0.1 *10ms)=1ms

在线代OS中,访问磁盘的成本很高,即使很小的概率未命中页会拉低正在运行程序的总体AMAT

最优替换策略

替换内存中在最远将来才会被访问到的页。

前几次未命中,因为缓存开始是空的,这种称为冷启动未命中,或强制未命中。

FIFO

Belady线性

随机

10000次尝试后大约40%的概率命中

LRU

使用的一个历史信息是频率,如果一个页被访问了很多次,那么不应该被置换。

工作负载

workload不存在局部性时,置换算法差别不大

image-20210925133220348

为了实现LRU,需要做很多工作。在每次页访问(即每次内存访问,不管是取指令还是加载指令还是存储指令)时,我们都必须更新一些数据,从而将该页移动到列表的前面。

时钟置换算法

页面需要进行替换时,OS检查当前指向的页P的使用位是0还是1。如果是0,则意味着最近被使用,不适合被替换,然后P的使用为设为0,时钟指针递增到下一页(P+1)


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM