本文简单阐述逻辑地址、线性地址、物理地址和虚拟地址。
编写程序时使用的地址空间叫符号名空间,在符号名空间中,通过符号(即变量名)引用内存。
编译后的程序使用的地址空间是逻辑地址空间,逻辑地址是相对于段基地址的偏移。
CPU 将整个地址空间划分为若干个分段,比如内核代码段、内核数据段、用户代码段、用户数据段等。每个段都有权限位,使不同的程序可以访问不同的段。
段描述符用于描述每个段。段描述符由 8 个字节组成,其中最重要的是段的基地址。多个段描述符组成段描述符表,段描述符表分为 GDT(全局段描述符表,每个 CPU 一个)和 LDT(局部段描述符表,每个进程一个)。
逻辑地址是由两部分组成 - [段选择符:偏移]。段选择符的组成如下所示:
其中:
TI 标识使用 LDT 还是 GDT
索引号是段描述符表中的索引
整体转换流程如下:
根据段选择符中的 TI 位确定使用 GDT 还是 LDT,再根据寄存器获取要使用的段描述符表的地址
段选择符的前 13 位是段描述符表中的索引,据此得到段描述符,即可得到段的基地址 Base
Base + Offset 即为要转换的线性地址
以下内容来自《深入理解 Linux 内核》第三版
Linux 以非常有限的方式使用分段。实际上,分段和分页在某种程度上有点多余,因为它们都可以划分进程的物理地址空间:分段可以给每一个进程分配不同的线性地址空间,而分页把同一线性地址空间映射到不同的物理空间。与分段相比,Linux 更喜欢分页的方式。
运行在用户态的所有 Linux 进程都使用一对相同的段来对指令和数据寻址。这两个段就是所谓的用户代码段和用户数据段。类似地,运行在内核态的所有 Linux 进程都使用一对相同的段来对指令和数据寻址:它们分别叫做内核代码段和内核数据段。表 2-3 显示了这四个重要段的段描述符字段的值。
相应的段选择符由宏 __USER_CS、__USER_DS、__KERNEL_CS 和 __KERNEL_DS 分别定义。例如,为了对内核代码段寻址,内核只需要把 __KERNEL_CS 宏产生的值装进 cs 段寄存器即可。
注意,与段相关的线性地址从 0 开始,达到 232 - 1的寻址限长。这就意味着用户态或内核态下的所有进程可以使用相同的逻辑地址。
所有段都从 0x00000000 开始,这可以得出另一个重要结论,那就是 Linux 下逻辑地址与线性地址是一致的,即逻辑地址的偏移量字段的值与相应的线性地址的值总是一致的。
线性地址是逻辑地址转换为物理地址的中间层,CPU 的 MMU(内存管理单元)负责将线性地址转换为物理地址。
为提升效率,CPU 进行内存管理的基本单位不是字节,而是页,每个页 4KB。因此在 32 位机器上,线性地址被划分为 2^32 / 4K = 2^20 = 1MB 个页。
同样地,CPU 也将物理地址空间划分为页,称每个页为物理页。
理论上,每个页都应该对应一个物理页。但是一个地址是 32 位,4 个字节。那么存储该对应关系需要 4MB 的内存。
为节省空间,引入页目录和页表的概念,每个进程都有自己的页目录,它的地址存储在 CPU 的寄存器(比如 CR3)中。
以 32 位机器为例,一个 32 位的线性地址分为 3 部分:
页目录索引(10 位)
页表索引(10 位)
偏移(12 位)
整个转换过程如下所示:
从 CPU 寄存器 CR3 中获取页目录地址(操作系统在调度进程时,负责将页目录地址放入寄存器)
根据线性地址的页目录索引部分,去页目录中找到页表
根据线性地址的页表索引部分,找到页的起始地址
起始地址 + 偏移,即可得到物理地址
说明:
物理内存通常小于线性地址空间,并且许多进程在同时运行,因此不可能提前为进程建立页表
Linux 利用 CPU 的缺页异常解决该问题。进程创建后,给页目录的表项值都填 0,CPU 在查找页表时,如果表项的内容为 0,则引发缺页异常,进程暂停执行,Linux 内核通过一系列复杂的算法分配一个物理页,并且将物理页的地址填到表项中,进程再恢复执行
可以形而上地将物理地址理解为:将物理内存的存储单元从 0 开始编号,每个编号就是对应存储单元的物理地址。
综上,整个转换过程是 - 符号名 -> 逻辑地址 -> 线性地址 -> 物理地址。
以下内容来自《深入理解计算机系统》第三版
虚拟存储器是一个抽象概念,它为每个进程提供了一个假象,好像每个进程都在独占地使用主存。每个进程看到的存储器都是一致的,称之为虚拟地址空间。图 1.13 所示的是 Linux 进程的虚拟地址空间(其它 Unix 系统的设计也与此类似)。在 Linux 中,最上面的四分之一的地址空间是预留给操作系统中的代码和数据使用的,这对所有进程都一样。底部的四分之三的地址空间用来存放用户进程定义的代码和数据。请注意,图中的地址是从下往上增大的。
每个进程看到的虚拟地址空间由大量准确定义的区域(area)组成,每个区都有专门的功能。
虚拟存储器的运作需要硬件和操作系统软件间的精密复杂的互相合作,包括对处理器生成的每个地址的硬件翻译。基本思想是把一个进程虚拟存储器的内容存储在磁盘上,然后用主存作为磁盘的高速缓存。
为通用计算设计的现代处理器使用的是虚拟寻址,参见图 10.2。
根据虚拟寻址,CPU 通过生成一个虚拟地址(Virtual Address,VA)来访问主存,这个虚拟地址在被送到存储器之前先转换成适当的物理地址。将一个虚拟地址转换为物理地址的任务叫做地址翻译(Address Translation)。就像异常处理一样,地址翻译需要 CPU 硬件和操作系统之间的紧密合作。CPU 上的芯片叫做 MMU(Memory Management Unit,存储器管理单元)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操作系统管理。
地址空间(Address Space)是一个非负整数的地址的有序集合:
{0, 1, 2, ...}
如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间(Linear Address Space)。为了简化我们的讨论,我们总是假设使用的是线性地址空间。在一个带虚拟存储器的系统中,CPU 从一个有 N = 2n 个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间:
{0, 1, ..., N-1}
一个地址空间的大小是由表示最大地址所需的位数来描述的。例如,一个包含 N = 2n 个地址的虚拟地址空间就叫做一个 n 位地址空间。现代操作系统典型地都支持 32 位或者 64 位地址空间。
一个系统还有一个物理地址空间(Physical Address Space),它与系统中物理存储器的 M 个字节相对应:
{0, 1, 2, ..., M-1}
M 不要求是 2 的幂,但是为了简化讨论,我们假设 M = 2m。
地址空间的概念是很重要的,因为它清楚地区分了数据对象(字节)和它们的属性(地址)。一旦我们认识到了这种区别,我们就可以概括总结,允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间。这就是虚拟存储器的基本思想。主存的中每个字节都有一个选自虚拟地址空间的虚拟地址,和一个选自物理地址空间的物理地址。
以下内容来自《深入理解 Linux 内核》第三版
所有新近的 Unix 系统都提供了一种有用的抽象,叫虚拟内存(Virtual Memory)。虚拟内存作为一种逻辑层,处于应用程序的内存请求与硬件内存管理单元(MMU)之间。
虚拟内存子系统的主要成分是虚拟地址空间(Virtual Memory Space)的概念。进程所用的一组内存地址不同于物理内存地址。当进程使用一个虚拟地址时,内核和 MMU 协同定位其在内存中的实际物理位置。
进程的虚拟地址空间包括了进程可以引用的所有虚拟内存地址。内核通常用一组内存区域描述符描述进程虚拟地址空间。例如,当进程通过 exec() 类系统调用开始某个程序的执行时,内核分配给进程的虚拟地址空间由以下内存区组成:
程序的可执行代码
程序的初始化数据
程序的未初始化数据
初始程序栈(即用户态栈)
所需共享库的可执行代码和数据
堆(由程序动态请求的内存)
所有现代 Unix 操作系统都采用了所谓请求调页(demand paging)的内存分配策略。有了请求调页,进程可以在它的页还没有在内存时就开始执行。当进程访问一个不存在的页时,MMU 产生一个异常,异常处理程序找到受影响的内存区,分配一个空闲的页,并用适当的数据把它初始化。同理,当进程通过调用 malloc() 或 brk()(有 malloc() 在内部调用)系统调用动态地请求内存时,内核仅仅修改进程的堆内存区的大小。只有试图引用进程的虚拟内存地址而产生异常时,才给进程分配页框(Page Frame)。
虚拟地址空间也采用其他更有效的策略,如前面提到的写时复制策略。例如,当一个新进程被创建时,内核仅仅把父进程的页框赋予给子进程的地址空间,但是把这些页框标记为只读。一旦父进程或子进程试图修改页中的内容时,一个异常就会产生。异常处理程序把新页框赋给受影响的进程,并用原来页中的内容初始化新页框。
[package]
name = "mmaptest"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
libc = "0.2"
use std::ptr;
use std::thread;
use std::time::Duration;
fn main() {
let pid = unsafe { libc::getpid() };
println!("PID: {}", pid);
// 打开匿名映射
let map_size = 500 * 1024 * 1024;
let addr = ptr::null_mut();
let prot = libc::PROT_READ | libc::PROT_WRITE;
let flags = libc::MAP_ANONYMOUS | libc::MAP_PRIVATE;
let fd = -1;
let offset = 0;
let ptr = unsafe { libc::mmap(addr, map_size, prot, flags, fd, offset) };
if ptr == libc::MAP_FAILED {
panic!("Failed to mmap");
}
// 循环写入 1024 个字节,直到满足退出条件
let mut count = 0;
loop {
thread::sleep(Duration::from_millis(1));
let data: [u8; 1024] = [count as u8; 1024];
unsafe {
ptr::copy(data.as_ptr(), (ptr as *mut u8).add(count * 1024), 1024);
}
count += 1;
// 设置退出条件
if count == map_size / 1024 {
break;
}
}
// 解除映射
unsafe {
libc::munmap(ptr, map_size);
}
}
libc::mmap
libc::mmap
用于进行内存映射(Memory Mapping)。它可以将文件或者匿名映射(anonymous mapping)映射到进程的内存空间,使得进程可以直接访问这段内存。
函数签名如下:
pub unsafe extern "C" fn mmap(
addr: *mut c_void,
len: size_t,
prot: c_int,
flags: c_int,
fd: c_int,
offset: off_t,
) -> *mut c_void
参数解释如下:
addr
:映射的起始地址。如果传入 NULL
,则由系统选择合适的地址。
len
:映射的大小,以字节为单位。
prot
:映射的保护模式(protection mode),用于指定内存的访问权限。可以使用 libc
库中定义的常量,比如 libc::PROT_READ
表示只读权限,libc::PROT_WRITE
表示可写权限,libc::PROT_EXEC
表示可执行权限,可以使用按位或(bitwise OR)操作符组合多个保护模式。如果不需要特定的保护模式,可以传入 0。
flags
:映射的标记位,用于指定映射类型和映射属性。常用的标记位有 libc::MAP_SHARED
表示映射是共享的,多个进程可以共享同一份映射的内存;libc::MAP_PRIVATE
表示映射是私有的,每个进程都有自己独立的映射副本;libc::MAP_ANONYMOUS
表示创建匿名映射,没有对应的文件,用于临时存储或者共享内存等场景。还有其它标记位,可以根据需求进行选择。
fd
:要映射的文件描述符(file descriptor)。如果创建匿名映射,则传入 -1。
offset
:映射的偏移量。对于文件映射来说,表示从文件的哪个位置开始映射;对于匿名映射来说,通常传入 0。
函数返回值是映射的起始地址,如果映射失败,那么返回 MAP_FAILED
,即 -1。
[package]
name = "malloctest"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
libc = "0.2"
use libc::malloc;
use std::thread;
use std::time::Duration;
fn main() {
let pid = unsafe { libc::getpid() };
println!("PID: {}", pid);
let mut count = 0;
loop {
count += 1;
// 分配内存
let mem_ptr = unsafe { malloc(100 * 1024) };
if mem_ptr.is_null() {
panic!("Failed to allocate memory");
}
// 不释放内存
// unsafe { libc::free(mem_ptr) };
if count >= 1024 * 1024 {
break;
}
// 等待 1ms
thread::sleep(Duration::from_millis(1));
}
thread::park();
}