前言
内存管理是操作系统的一项核心功能。
从操作系统启动到创建0号进程(也就是idle进程)的过程中,大部分执行的代码都涉及到内存管理。
操作系统的内存管理大致可以分为以下几个层次:
物理内存管理
物理内存指的是电脑中实际存在的内存容量,这个信息可以通过BIOS获取。
在内存分页后,物理内存的管理结构变成了一个数组,其中每个元素表示一个物理内存页,每页的大小为4096字节
如下图:
图片
在一个简单的内核示例中,物理内存页的管理结构可以仅包含一项:
atomic_t refs;
这表示物理内存页的引用计数:如果计数为0,表示该页空闲;若计数大于0,则表示该页正在被使用,具体数值表示共享此页的进程数量。
简单的内核示例通常不支持SMP架构,因此自旋锁(spinlock)并不需要。
然而,在对称多处理器(SMP)系统中,由于全局数据结构可能会被多个CPU同时访问,因此需要引入自旋锁。
在这种情况下,物理内存页的管理结构至少需要包含以下两项:
atomic_t spinlock;
atomic_t refs;
自旋锁的作用类似于应用程序中的互斥锁(mutex),不同之处在于,当自旋锁获取失败时,它会反复尝试直到成功。
void spin_lock(atomic_t* lock)
{
while (spin_trylock(lock) == 0);
}
以上代码展示了为自旋锁加锁的函数。while循环会不断尝试加锁,直到成功;如果未成功,它将持续自旋尝试,因此称之为自旋锁。
在SMP环境中,自旋锁用于保护共享的数据结构:当一个CPU持有自旋锁时,其他CPU无法访问该共享数据。
对于单处理器系统,没有必要使用自旋锁,直接关闭中断即可。
在单处理器环境中,关闭中断可以防止内核的并发执行,从而避免对共享数据的竞争。
但是,在多处理器系统中,必须使用自旋锁,因为关闭中断只能影响当前CPU,而无法影响其他CPU;此时,自旋锁用于保护共享数据。
物理内存的管理数组是最关键的全局共享数据。
当需要为某个进程分配内存时,判断哪个内存页空闲、哪个已被使用,都依赖于这个数组。
在加自旋锁时,一定要先关闭中断,因为如果在加锁后、关闭中断前,刚好有中断发生,并在中断处理函数中再次请求加同一个锁,就会导致递归死锁。
在Linux内核中,关闭中断并加锁的函数是:spin_lock_irqsave()。
分配物理内存页的函数是:get_free_pages(),它可以分配1页或连续多页的内存。
如果分配多页内存,起始地址需按页数对齐。
虚拟内存管理
虚拟内存的管理是通过进程的页表来实现的。
为了节约物理内存,当一个新进程创建时,它会与父进程共享同一套物理内存页。
只有当新进程需要对某个内存页进行写操作时,系统才会为它创建一个新的物理内存页副本,并取消该页与父进程的共享,这个过程称为写时复制(Copy-On-Write)
图片
写时复制的过程可以描述为:
- 申请一个新的内存页,
- 将旧内存页的内容复制到新的内存页中,
- 将新内存页的地址填入子进程的页表中,
- 将旧内存页的引用计数减1。
因此,在新进程刚创建时,它的用户空间并没有专属的物理内存页。只有在需要写操作时,系统才会通过写时复制机制逐步分配内存页,从而尽可能地保持物理内存的空闲状态。
另一种保持物理内存尽量空闲的机制是“按需加载”:
- 当通过mmap映射一个文件时,操作系统不会立即为该文件分配内存或将其内容加载到内存中,
- 只有在进程实际读取文件的某一部分时,操作系统才会分配物理内存页,并将该部分内容从磁盘读入内存。
这就是“写时复制”和“按需加载”的过程:只有在真正需要时,Linux系统才会将物理内存分配给进程。
用户态的内存函数
以上这些机制都是操作系统内核的一部分,应用程序的代码无需关注这些细节。
应用程序分配内存的最底层操作通过brk()系统调用完成。
图片
brk()是一个系统调用,用于修改应用程序数据段的末尾位置,以便分配或释放应用程序的堆空间。
图片
在C标准库中,brk() 被封装成了 sbrk() 和 brk() 两个函数,以便于程序员使用:
- sbrk() 用于申请内存:void* sbrk(int increment);
- brk() 用于回收内存:int brk(void* addr);
实际上,Linux系统中只有一个 brk() 系统调用,它负责设置进程数据段的末尾,并将这个值返回给应用程序。
图片
在Linux内核的头文件中,brk() 系统调用的处理函数 sys_brk() 如图所示。
如果想直接使用系统调用,可以通过 Linux 的 syscall() 函数来实现。该函数接受调用号和参数列表,能够帮助区分实际的系统调用和C库的封装。syscall() 函数的声明为:long syscall(long number, ...);,它的参数是可变的,最多支持6个参数,因为寄存器的数量有限。
基于 sbrk() 和 brk(),常用的内存管理函数 malloc() 和 free() 被封装出来。malloc() 分配的内存块可以按需释放,不必按顺序。而 brk() 和 sbrk() 分配的内存必须按顺序释放,因为它们会调整进程数据段的结尾。
数据段结尾(brk)之外的堆空间如果被使用,会导致段错误。因此,Linux man 手册建议应用程序不要直接使用 sbrk() 和 brk() 来申请和释放内存。
brk() 的作用仅仅是通知 Linux 内核哪个范围的堆内存是可用的。实际的物理内存页是在进程实际读写内存时由内核根据写时复制和按需加载机制自动申请的,应用程序并不会感知到这些细节。
此外,Linux 还会将不常用的物理内存页交换到磁盘上的交换分区(swap),以释放更多内存。因此,当内存不足时,磁盘的读写频率也会增加。