内存虚拟化-地址空间
2023-1-10
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property
 
早期系统
从内存来看,早期的机器并没有提供多少抽象给用户,基本上机器的物理内存看起来如下:
notion image
操作系统曾经是一组函数(实际上是一个库),在内存中(从物理地址开始),然后有一个正在运行的程序(进程),目前在物理内存中(从物理地址 开始),并使用剩余的内存。这里几乎没有抽象,用户对操作系统的要求也不多。
 
 
多道程序和时分共享
由于机器昂贵,人们开始更有效地共享机器,多道程序系统时代开启,多个进程在给定时间准备运行,比如当有一个进程在等待I/O 操作的时候,操作系统会切换这些进程,这样增加了CPU 的有效利用率。
但很快,人们开始对机器要求更多,分时系统的时代诞生了。一种实现时分共享的方法,是让一个进程单独占用全部内存运行一小段时间,然后停止它,并将它所有的状态信息保存在磁盘上(包含所有的物理内存),加载其他进程的状态信息,再运行一段时间,这就实现了某种比较粗糙的机器共享。
遗憾的是,这种方法有一个问题:太慢了,特别是当内存增长的时候。虽然保存和恢复寄存器级的状态信息(程序计数器、通用寄存器等)相对较快,但将全部的内存信息保存到磁盘就太慢了。因此,在进程切换的时候,我们仍然将进程信息放在内存中,这样操作系统可以更有效率地实现时分共享。
notion image
随着时分共享变得更流行,人们对操作系统又有了新的要求。特别是多个程序同时驻留在内存中,使保护成为重要问题。人们不希望一个进程可以读取和修改其他进程的内存。
 
 

地址空间

为了解决这些需求,操作系统需要提供一个易用的物理内存抽象,这个抽象叫作地址空间(address space),是运行的程序看到 的系统中的内存。
一个进程的地址空间包含运行的程序的所有内存状态:
  • 程序的代码(code,指令)必须在内存中,因此它们在地址空间里
  • 程序运行时,利用栈(stack)来保存当前的函数调用信息,分配空间给局部变量,传递参数和函数返回值
  • 堆(heap)用于管理动态分配的、用户管理的内存,就像C语言调用malloc()或面向对象语言(如C++Java)调用new获得内存。
当然,还有其他的东西(例如,静态初始化的变量),现在假设只有这3 个部分:代码、栈和堆。
notion image
程序代码位于地址空间的顶部(在本例中从0 开始,并且装入到地址空间的前1KB)。代码是静态的(因此很容易放在内 存中),所以可以将它放在地址空间的顶部。
程序运行时,地址空间有两个区域可能增长(或者收缩)。它们就是堆(在顶部)和栈(在底部)。把它们放在那里,是因为它们都希望能够增长。通过将它们放在地址空间的两端,可以允许这样的增长:它们只需要在相反的方向增长。因此堆在代码(1KB) 之下开始并向下增长(当用户通过malloc()请求更多内存时),栈从16KB 开始并向上增长(当用户进行程序调用时)。然而,堆栈和堆的这种放置方法只是一种约定,如果你愿意,可以用不同的方式安排地址空间。
 
当然,当描述地址空间时,所描述的是操作系统提供给运行程序的抽象(abstract)。程序不在物理地址0~16KB的内存中,而是加载在任意的物理地址。
进程A 尝试在地址0(虚拟地址,virtual address)执行加载操作时,然而操作系统在硬件的支持下,出于某种原因,必须确保不是加载到物理地址0,而是物理地址320KB(这是A 载入内存的地址)。
 

虚拟化内存目标

  • 透明:操作系统实现虚拟内存的方式,应该让运行的程序看不见。因此,程序不应该感知到内存被虚拟化的事实,相反,程序的行为就好像它拥有自己的私有物理内存。在幕后,操作系统(和硬件)完成了所有的工作,让不同的工作复用内存,从而实现这个假象。
  • 效率:操作系统应该追求虚拟化尽可能高效,包括时间上(即不会使程序运行得更慢)和空间上(即不需要太多额外的内存来支持虚拟化)。在实现高效率虚拟化时,操作系统将不得不依靠硬件支持,包括TLB 这样的硬件功能
  • 保护:操作系统应确保进程受到保护,不会受其他进程影响,操作系统本身也不会受进程影响。当一个进程执行加载、存储或指令提取时,它不应该以任何方式访问或影响任何其他进程或操作系统本身的内存内容(即在它的地址空间之外的任何内容)。保护让我们能够在进程之间提供隔离(isolation)的特性。
 
 
你看到的所有地址都不是真的
作为用户级程序的程序员,可以看到的任何地址都是虚拟地址。只有操作系统,通过精妙的虚拟化内存技术,知道这些指令和数据所在的物理内存的位置。如果在一个程序中打印出一个地址,那就是一个虚拟的地址,提供地址如何在内存中分布的假象
 
 
 

内存操作API

运行一个C 程序的时候,会分配两种类型的内存
  • 第一种称为栈内存,它的申请和释放操作是编译器来隐式管理的,所以有时也称为自动(automatic)内存。
    • 申请栈内存很容易。比如,假设需要在func()函数中为一个整形变量x申请空间。为了声明这样的一块内存,只需要这样做:
      编译器完成剩下的事情,确保在进入 func() 函数的时候,在栈上开辟空间。从该函数退出时,编译器释放内存。因此,如果希望某些信息存在于函数调用之外,建议不要将它们放在栈上。
  • 出于对长期内存的需求,所以需要第二种类型的内存,即堆(heap)内存,所有的申请和释放操作都由程序员显式地完成:
    • 要释放不再使用的堆内存,程序员只需调用free()
       
      使用malloc()free()时会出现一些常见的错误:
    • 忘记分配内存
    • 没有分配足够的内存
    • 忘记初始化分配的内存
    • 忘记释放内存
    • 在用完之前释放内存,这种错误称为悬挂指针(dangling pointer)
    • 反复释放内存
    • 错误地调用free()
    •  
      为什么在你的进程退出时没有内存泄露
      系统中实际存在两级内存管理。
      第一级是由操作系统执行的内存管理,操作系统在进程运行时将内存交给进程,并在进程退出(或以其他方式结束)时将其回收。第二级管理在每个进程中,例如在调用malloc()free()时,在堆内管理。即使没有调用free()(泄露了堆中的内存),操作系统也会在程序结束运行时,收回进程的所有内存(包括用于代码、栈,以及相关堆的内存页)。无论地址空间中堆的状态如何,操作系统都会在进程终止时收回所有这些页面,从而确保即使没有释放内存,也不会丢失内存。
       
      底层操作系统支持
      malloc 库管理虚拟地址空间内的空间,但是它本身是建立在一些系统调用之上的,这些系统调用会进入操作系统,来请求本多内存或者将一些内容释放回系统。
      内存分配库的底层使用的实际是如下几个系统调用(以Linux为例):
    • brk():调整程序间断点(program break)到指定地址
    • sbrk():将程序间断点增加或减少指定字节数
    • 由于program break是当前堆结束的位置(堆与数据段、代码段相连,但与栈之间有大量空白内存,因此将堆结束位置称为程序间断点),brksbrk在移动program break的时候实际上是在增加或减少程序内存。例如program break向高地址移动,进程会访问更高的地址,操作系统则需要提供更高地址对应的地址转换,也就是增加了内存。
       
      另外还有一些与内存相关的系统调用,这与MMIO(内存映射IO)机制相关:
    • mmap():将文件(Linux认为设备、socket等都是文件)的指定部分映射到内存
    • munmap():取消文件映射到内存
    •  
 
  • 计算机基础
  • 操作系统
  • CPU虚拟化-进程调度内存虚拟化-机制:地址转换
    目录