操作系统笔记(4)-内存虚拟化
操作系统笔记(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
*/
根据输出结果我们可以看出,静态的
代码
部分在内存的顶部,然后依次是堆
和栈
,为了满足动态增长的堆栈空间,一般规定堆空间向下增长
,而栈空间向上增长
,如下图在用户看来这些空间是连续的,但其实它们对应任意的
物理地址
目标
之所以操作系统要
虚拟化内存
, 主要是为了让进程彼此隔离
, 从而防止相互造成伤害另外还有三个重要的目标:
透明:程序不应该感知到内存被虚拟化的事实,它的行为就好像拥有自己的私有物理内存,这样在幕后,操作系统(和硬件)完成了所有的工作,让不同的工作
复用
内存, 从而实现这个假象效率:操作系统应该追求虚拟化的同时保证程序时间和空间上的
效率
, 不应该为了实现虚拟化而消耗太多资源保护:操作系统应该保证当一个进程执行
加载
、存储
或指令提取
时,它不应该以任何方式访问或影响任何其它进程或操作系统本身的内存内容
内存操作 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);
常见错误
忘记分配内存
没有分配足够的内存
忘记初始化分配的内存
忘记释放内存
在用完之前释放内存
反复释放内存
错误地调用
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)寄存器
,利用它们我们可以将虚拟地址空间映射到一块连续
的物理地址- 物理地址 = 虚拟地址 + 基址
- 然后根据界限寄存器的值检查是否在合法的地址访问
举例来说:如果虚拟地址是
16KB
,那么界限寄存器的值就是16KB
,如果要将这块虚拟地址空间映射到物理地址的32~48KB
处,基址寄存器的值就应该是32KB
在进程切换时,可以将这两个寄存器的值保存在每个进程都有的结构中,如进程
控制块(PCB)
, 然后通过硬件的帮助进行保存和恢复即可缺点:使用这种方式分配空间存在浪费,因为在虚拟地址中处于
堆
和栈
之间的未分配区域也映射到了物理地址中并且不能被利用, 这种浪费通常被称为内部碎片
分段
为了解决这个问题,
分段(segmentation)
的概念应运而生,它在一个MMU
中引入不止一对界限和基址寄存器,而是给地址空间内的每个逻辑段一对,例如分给代码
、堆
和栈
各自一对分段的机制使得操作系统能够将不同的段放到不同的物理内存区域,从而避免了虚拟地址空间中未使用部分占用物理内存
使用了这种机制后,计算物理地址的方式变为
基址+偏移量
了,偏移量又根据地址的增长方向划分了正负段错误
指的是在支持分段的机器上发生了非法的内存访问