操作系统笔记(4)-内存虚拟化

概述

  • 用户程序生成的每个地址都是 虚拟地址

  • 操作系统只是为每个进程提供一个 假象,具体来说,就是它拥有自己的大量私有内存。在一些硬件的帮助下,操作系统会将这些假的 虚拟地址 变成真实的物理地址,从而能够找到想要的信息

地址空间

  • 地址空间(address space)是运行中的程序看到的系统中的内存,假设只有 代码 这三个部分:
#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;
    printf("location of stack : %p\n", &x);
    return 0;
}

/*
    output:
    location of code : 0x5605e3b046fa
    location of heap : 0x5605e4b00670
    location of stack : 0x7ffc71aaf944
*/
  • 根据输出结果我们可以看出,静态的 代码 部分在内存的顶部,然后依次是 ,为了满足动态增长的堆栈空间,一般规定堆空间 向下增长,而栈空间 向上增长,如下图

    地址空间

  • 在用户看来这些空间是连续的,但其实它们对应任意的 物理地址

目标

  • 之所以操作系统要 虚拟化内存 , 主要是为了让进程彼此 隔离 , 从而防止相互造成伤害

  • 另外还有三个重要的目标:

    1. 透明:程序不应该感知到内存被虚拟化的事实,它的行为就好像拥有自己的私有物理内存,这样在幕后,操作系统(和硬件)完成了所有的工作,让不同的工作 复用 内存, 从而实现这个假象

    2. 效率:操作系统应该追求虚拟化的同时保证程序时间和空间上的 效率, 不应该为了实现虚拟化而消耗太多资源

    3. 保护:操作系统应该保证当一个进程执行 加载存储指令提取 时,它不应该以任何方式访问或影响任何其它进程或操作系统本身的内存内容

内存操作 API

内存类型

  • 栈内存:它的申请和释放操作是编译器来 隐式管理 的,所以有时也称为 自动内存

  • 堆内存:它的所有操作和释放都有程序员 显示 地完成

分配堆内存

  • C语言主要提供了三种方式来分配堆内存
#include <stdlib.h>

void *malloc(size_t size);

void *calloc(size_t nmemb, size_t size);

void *realloc(void *ptr, size_t size);

练习:模拟动态扩容的向量

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

typedef struct vector {
    int size;              // 当前向量中的元素个数
    int capacity;          // 当前向量的容器大小
    int* elems;            // 指向容器的指针
} vector;

// 根据初始大小设置容器
vector* initVec(int cap) {
    vector* vec = (vector*)malloc(sizeof(vector));
    vec->size = 0;
    vec->capacity = cap;
    vec->elems = (int*)calloc(vec->capacity, sizeof(int));
    return vec;
}

// 扩容 mul 倍空间
void enlarge(vector *vec, int mul) {
    vec->capacity *= mul;
    vec->elems = (int*)realloc(vec->elems, vec->capacity * sizeof(int));
}

// 在向量尾部添加元素 x
void push_back(vector *vec, int x) {
    if (vec->size == vec->capacity) {   // 需要扩容
        enlarge(vec, 2);                // 两倍扩容
    }
    vec->elems[vec->size++] = x;
}

void freeVec(vector *vec) {
    free(vec->elems);
    free(vec);
}

int main(int argc, char* argv[]) {

    vector* vec = initVec(3);       // 初始大小为3的容器

    for (int i = 0; i < 100; i++) {
        push_back(vec, i);
    }

    for (int i = 0; i < 100; i++) {
        printf("%d\n", vec->elems[i]);
    }

    freeVec(vec);

    return 0;
}

释放内存

  • C语言主要通过 free() 来释放堆内存
#include <stdlib.h>

void free(void *ptr);

常见错误

  1. 忘记分配内存

  2. 没有分配足够的内存

  3. 忘记初始化分配的内存

  4. 忘记释放内存

  5. 在用完之前释放内存

  6. 反复释放内存

  7. 错误地调用 free()

valgrind 调试工具练习

  • 关于工具的安装和使用可以查看官方文档

  • 测试常见错误 7

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

int main(int argc, char* argv[]) {
    int* p;
    free(p);
    return 0;
}

调试结果如下:

  • 测试常见错误 4
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char* argv[]) {
    int* p = (int*)malloc(sizeof(int));
    
    return 0;
}

测试结果如下:

  • 测试常见错误 2
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char* argv[]) {
    int* data = (int*)malloc(100 * sizeof(int));
    data[100] = 0;
    
    return 0;
}

测试结果如下:

  • 测试常见错误 5
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char* argv[]) {
    int* data = (int*)malloc(100 * sizeof(int));
    data[2] = 100;
    free(data);
    printf("%d\n", data[2]);
    return 0;
}

测试结果如下:

地址转换

  • 操作系统为了实现 内存虚拟化,就需要一种方式将虚拟地址映射到真实的物理地址中去

  • 操作系统利用了一种基于硬件的地址转换,简称为 地址转换,它可以看成是受限直接执行这种一般方法的补充. 利用 地址转换 ,硬件对每次内存访问进行处理(即指令获取、数据读取或写入),将指令中的虚拟地址转换为数据实际存储的物理地址

假设

  • 假设用户的地址空间必须连续地存放在物理内存中

  • 假设用户的地址空间小于物理内存的大小

动态(基于硬件)重定位

  • 基址加界限(base and bound),有时又称为动态重定位(dynamic relocation)

    具体来说,每个 CPU 需要两个硬件寄存器:基址(base)寄存器界限(bound)寄存器 ,有时称为 限制(limit)寄存器,利用它们我们可以将虚拟地址空间映射到一块 连续 的物理地址

    1. 物理地址 = 虚拟地址 + 基址
    2. 然后根据界限寄存器的值检查是否在合法的地址访问

    举例来说:如果虚拟地址是 16KB ,那么界限寄存器的值就是 16KB ,如果要将这块虚拟地址空间映射到物理地址的 32~48KB 处,基址寄存器的值就应该是 32KB

  • 在进程切换时,可以将这两个寄存器的值保存在每个进程都有的结构中,如进程 控制块(PCB), 然后通过硬件的帮助进行保存和恢复即可

  • 缺点:使用这种方式分配空间存在浪费,因为在虚拟地址中处于 之间的未分配区域也映射到了物理地址中并且不能被利用, 这种浪费通常被称为 内部碎片

分段

  • 为了解决这个问题,分段(segmentation)的概念应运而生,它在一个 MMU 中引入不止一对界限和基址寄存器,而是给地址空间内的每个逻辑段一对,例如分给 代码 各自一对

  • 分段的机制使得操作系统能够将不同的段放到不同的物理内存区域,从而避免了虚拟地址空间中未使用部分占用物理内存

  • 使用了这种机制后,计算物理地址的方式变为 基址+偏移量 了,偏移量又根据地址的增长方向划分了正负

  • 段错误 指的是在支持分段的机器上发生了非法的内存访问