问题描述
考虑下面的示例。
char* p = (char*)malloc(4096);
p[0] = 'a';
p[1] = 'b';
4KB 内存是通过调用 malloc()
分配的。操作系统在用户空间处理用户程序的内存请求。首先,OS 向 RAM 请求内存分配,然后 RAM 将物理内存地址提供给 OS。操作系统收到物理地址后,将物理地址映射到虚拟地址,然后操作系统将虚拟地址即p
的地址返回给用户程序。
我在虚拟地址中写入了一些值(a 和 b),它们确实被写入了主内存(RAM)。我很困惑,我在虚拟地址而不是物理地址中写入了一些值,但即使我不关心它们,它也确实写入了主内存(RAM)。
后面会发生什么?操作系统对我有什么作用? 我在某些书籍(操作系统、系统编程)中找不到相关资料。 你能给一些解释吗? (为了便于理解,请省略缓存的内容)
解决方法
你要明白虚拟内存是虚拟的,它可以比物理内存RAM更广泛,所以它的映射方式不同。虽然它们实际上是一样的。
您的程序使用虚拟内存地址,而决定在 RAM 中保存的是您的操作系统。如果它填满了,那么它将使用硬盘驱动器上的一些空间来继续工作。
但是硬盘驱动器比 RAM 慢,这就是为什么您的操作系统使用一种算法(可能是循环)在硬盘驱动器和 RAM 之间交换内存页,这取决于正在完成的工作,确保最有可能使用的数据在快速内存中。来回交换页面,操作系统不需要修改虚拟内存地址。
总结忽略了很多东西
,您想了解虚拟内存的工作原理。有很多关于这方面的在线资源,我发现这里有一个似乎可以很好地解释它,而不会在技术细节上过于疯狂,但也没有掩盖重要的术语。
https://searchstorage.techtarget.com/definition/virtual-memory
,对于 x86 平台上的 Linux,与请求内存等效的程序集基本上是使用 int 0x80
调用内核,并将调用的一些参数设置到某些寄存器中。操作系统在启动时设置中断,以便能够响应请求。它在 IDT 中设置。
32 位系统的 IDT 描述符如下所示:
struct IDTDescr {
uint16_t offset_1; // offset bits 0..15
uint16_t selector; // a code segment selector in GDT or LDT
uint8_t zero; // unused,set to 0
uint8_t type_attr; // type and attributes,see below
uint16_t offset_2; // offset bits 16..31
};
偏移量是该中断的处理程序入口点的地址。所以中断 0x80 在 IDT 中有一个条目。此条目指向处理程序的地址(也称为 ISR)。当您调用 malloc() 时,编译器会将此代码编译为系统调用。系统调用在某个寄存器中返回已分配内存的地址。我也很确定这个系统调用实际上会使用 sysenter x86 指令来切换到内核模式。该指令与 MSR 寄存器一起使用,以在 MSR(模型特定寄存器)中指定的地址处安全地从用户模式跳转到内核模式。
一旦进入内核模式,所有指令都可以执行,并且可以解锁对所有硬件的访问。为了提供请求,操作系统不会“向 RAM 请求内存”。 RAM 不知道操作系统使用什么内存。 RAM 只是盲目地响应其 DIMM 上的断言引脚并存储信息。操作系统只是在启动时使用由 BIOS 构建的 ACPI 表进行检查,以确定有多少 RAM 以及连接到计算机的不同设备是什么,以避免写入某些 MMIO(内存映射 IO)。一旦操作系统知道有多少 RAM 可用(以及哪些部分可用),它将使用算法来确定每个进程应该获得哪些可用 RAM 部分。
当您编译 C 代码时,编译器(和链接器)将在编译时正确确定所有内容的地址。当您启动该可执行文件时,操作系统知道该进程将使用的所有内存。因此,它将相应地为该进程设置页表。当您使用 malloc() 动态请求内存时,操作系统会确定您的进程应该获取物理内存的哪一部分并相应地更改(在运行时)页表。
至于分页本身,你可以随时阅读一些文章。一个简短的版本是 32 位分页。在 32 位分页中,每个 CPU 内核都有一个 CR3 寄存器。该寄存器包含页全局目录底部的物理地址。 PGD 包含几个页表底部的物理地址,这些页表本身包含几个物理页面底部的物理地址(https://wiki.osdev.org/Paging)。一个虚拟地址被分成 3 部分。右边的 12 位 (LSB) 是物理页中的偏移量。中间的10位是页表中的偏移量,10位MSB是PGD中的偏移量。
所以当你写
char* p = (char*)malloc(4096);
p[0] = 'a';
p[1] = 'b';
您创建了一个 char* 类型的指针并进行系统调用以请求 4096 字节的内存。操作系统将该内存块的首地址放入某个常规寄存器(取决于系统和操作系统)。您不应忘记 C 语言只是一种约定。操作系统通过编写兼容的编译器来实现该约定。这意味着编译器知道要使用的寄存器和中断号(用于系统调用),因为它是专门为该操作系统编写的。因此,编译器将在运行时将存储在该特定寄存器中的地址存储到这个 char* 类型的指针中。在第二行中,您告诉编译器您想在第一个地址处获取字符并将其设为“a”。在第三行,您将第二个字符设为“b”。最后,你可以写一个等价的:
char* p = (char*)malloc(4096);
*p = 'a';
*(p + 1) = 'b';
p 是一个包含地址的变量。指针上的 + 操作将这个地址增加该指针中存储的内容的大小。在这种情况下,指针指向一个字符,因此 + 操作将指针增加一个字符(一个字节)。如果它指向一个 int,那么它将增加 4 个字节(32 位)。实际指针的大小取决于系统。如果你有一个 32 位系统,那么指针是 32 位宽(因为它包含一个地址)。在 64 位系统上,指针为 64 位宽。相当于你所做的静态内存是
char p[4096];
p[0] = 'a';
p[1] = 'b';
现在编译器会在编译时知道这个表将获得多少内存。它是静态内存。即使这样,p 也代表指向该数组第一个字符的指针。这意味着你可以写
char p[4096];
*p = 'a';
*(p + 1) = 'b';
结果相同。
,首先,操作系统向 RAM 请求内存分配,...
操作系统不必请求内存。它在启动时可以访问所有内存。它保留自己的数据库,记录该内存的哪些部分用于什么目的。当它想为用户进程提供内存时,它使用自己的数据库来查找一些可用的内存(或者停止将内存用于其他目的,然后使其可用)。一旦它选择了要使用的内存,它就会更新它的数据库以记录它正在使用中。
... 然后 RAM 为操作系统提供物理内存地址。
RAM 不会向操作系统提供地址,只是在启动时,操作系统可能必须询问硬件以查看系统中可用的物理内存。
一旦操作系统收到物理地址,操作系统将物理地址映射到虚拟地址......
虚拟内存映射通常被描述为将虚拟地址映射到物理地址。操作系统有一个用户进程中的虚拟内存地址的数据库,它有一个物理内存的数据库。当它满足进程的请求以提供虚拟内存并决定用物理内存支持该虚拟内存时,操作系统将通知硬件它选择的映射。这取决于硬件,但一种典型的方法是操作系统更新一些页表条目,这些条目描述了哪些虚拟地址被转换为哪些物理地址。
我在虚拟地址中写入了一些值(a 和 b),它们确实写入了主内存(RAM)。
当你的进程写入映射到物理内存的虚拟内存时,处理器会取虚拟内存地址,在页表条目或其他数据库中查找映射信息,并将虚拟内存地址替换为物理内存地址。然后它将数据写入该物理内存。
,对您的问题的详细回答会很长——而且太长,无法在 StackOverflow 上展示。
这是对您问题的一小部分的非常简化的答案。
你写道:
我很困惑,我在虚拟地址中写入了一些值,而不是物理地址,但它确实写入了主内存
看来你在这里有一个非常根本的误解。
虚拟地址“后面”没有内存。每当您访问程序中的虚拟地址时,它都会自动转换为物理地址,然后使用该物理地址在主内存中进行访问。
转换发生在硬件中,即在处理器内部称为“MMU - 内存管理单元”的块中(参见 https://en.wikipedia.org/wiki/Memory_management_unit)。
MMU 拥有一个很小但非常快速的查找表,它告诉我们如何将虚拟地址转换为物理地址。操作系统配置这个表,但之后,转换发生而不涉及任何软件 - 只是重复 - 每当您访问虚拟内存地址时都会发生。
MMU 还需要某种进程 ID 作为输入以进行转换。这是必要的,因为两个不同的进程可能使用相同的虚拟地址,但它们需要转换为两个不同的物理地址。
如上所述,MMU 查找表 (TLB) 很小,因此 MMU 无法保存完整系统的所有翻译。当 MMU 不能做翻译时,它可以做某种例外,以便可以触发某些 OS 软件。然后操作系统将重新编程 MMU,以便丢失的转换进入 MMU,进程可以继续执行。注意:某些处理器可以在硬件中完成此操作,即不涉及操作系统。