操作系统-内存映射[半原创]
文章大部分来自微信公众号 bin的技术小屋 ,非原创 ,小部分是自己的学习批注
前言
我认为的映射, 就是大的方向说有两部分 ,我们先看转化的是什么 : `文件 -- > (进程) --> 虚拟内存 --> 物理内存` , 所以
1. 映射1 : 文件 -- > (进程) --> 虚拟内存 , 这部分就是主要体现在虚拟内存上
2. 映射2 : 虚拟内存 --> 物理内存 , 这部分就是之前写过的关于虚拟地址通过分段分页机制的映射内容
关于虚拟地址通过分段分页机制的映射内容, 可参考 : 操作系统-分页管理存储的实现 和 操作系统-IA32的地址转换
让我们思考一下 ,计算机启动后 ,操作系统接管计算机 ,然后我们就会运行我们的业务系统, 计算机最小的单位运行单位是进程 , 我们知道在计算机的世界操作的都是一个虚拟地址 ,而不是物理地址 , 也就是说 文件 -- > (进程) --> 虚拟内存 --> 物理内存
, 那么第一步就是进程是如何在虚拟内存中存在的,以一个什么样的形式 , 下面是一个进程的虚拟空间分布
32位虚拟内存空间分布
64位虚拟内存空间分布
内存映射原理
Linux通过将一个虚拟内存区域与一个磁盘上的对象(object)关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)。虚拟内存区域可以映射到两种类型的对象中的一种:
(1) Linux文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行的目标文件。文件区(section)被分成页大小的片,每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到CPU第一次引用到页面(即发射一个虚拟地址,落在地址空间这个页面的范围之内)。如果区域比文件区要大,那么就用零来填充这个区域的余下部分。
(2) 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页面,将标记为是驻留在内存中的。(这不就是将进程相关的东西 swap 到外部的文件去了吗 ) 注意在磁盘和内存之间并没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制零的页(demand-zero page)。
无论哪种情况下,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件(swap file)之间换来换去。交换文件也叫作交换空间(swap space)或者交换区域(swap area)。需要意识到的很重要的一点是,在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数。
第一种对象实际上的应用不就是我们 elf 文件加载到内存虚拟空间吗 , 第二种则是进程的切换 ,进程A 被换到了内存之外的空间 ,例如下图Linux 的交换空间就是匿名文件来的
下面 swap 空间就属于匿名文件.
内存映射-共享库的读写
之前学习共享库只知道共享库可以映射到任意的虚拟地址空间上 ,那么共享的位置位于哪里呢?? 位于下图标出的地方 :
那么具体是如何共享的呢 ?
上面的图例可以很清晰地看到两个进程共享同一个代码库 ,读是没问题了,因为都映射到各自的虚拟空间上 , 那写呢?
私有对象使用了一种叫做写时复制(copy-on-write)的巧妙技术被映射到虚拟内存中. 只要没有进程试图写它自己的私有区域, 他们就可以继续共享物理内存中对象的一个单独副本. 然后只要有一个进程试图写私有区域内的某个页面, 那么就会出发一个保护故障 ,它就会在物理内存中创建一个页面的副本, 更新页表条目指向新的副本,当故障处理程序返回时 ,CPU 重新执行这个写操作. 现在在新创建的页面上这个写操作就可以正常执行了.
可以看到这种方式除了费内存就是速度快了,用空间换时间 , 还有一个问题,写的页面是什么时候flush 回文件的 ?
内存映射-匿名映射
匿名映射的动机是什么呢 ?
进程虚拟内存空间的管理
用户进程内存空间表示
主要是进程在代码中的表示
ELF 中各个 section 是以 VMA 的结构组织起来的 , 各个section 以双链表的形式组织起来, 同时作为 task_struct 的一个红黑树节点 . 我们下面来看一下linux 中的源码实现
下面的内容来自参考文章, 非原创 , 参考文章中未提到使用到的 linux 版本 ,于是我参考了下面(参考资料-在线linux)中 v5.0.21 的代码
task_struct
struct task_struct {
// 进程id
pid_t pid;
// 用于标识线程所属的进程 pid
pid_t tgid;
// 进程打开的文件信息
struct files_struct *files;
// (重要)内存描述符表示进程虚拟地址空间
struct mm_struct *mm;
.......... 省略 .......
}
当我们调用 fork() 函数创建进程的时候,表示进程地址空间的 mm_struct 结构会随着进程描述符 task_struct 的创建而创建。
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
......... 省略 ..........
struct pid *pid;
struct task_struct *p;
......... 省略 ..........
// 为进程创建 task_struct 结构,用父进程的资源填充 task_struct 信息
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
......... 省略 ..........
}
随后会在 copy_process 函数中创建 task_struct 结构,并拷贝父进程的相关资源到新进程的 task_struct 结构里,其中就包括拷贝父进程的虚拟内存空间 mm_struct 结构。这里可以看出子进程在新创建出来之后它的虚拟内存空间是和父进程的虚拟内存空间一模一样的,直接拷贝过来
static __latent_entropy struct task_struct *copy_process(
unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace,
unsigned long tls,
int node)
{
struct task_struct *p;
// 创建 task_struct 结构
p = dup_task_struct(current, node);
....... 初始化子进程 ...........
....... 开始继承拷贝父进程资源 .......
// 继承父进程打开的文件描述符
retval = copy_files(clone_flags, p);
// 继承父进程所属的文件系统
retval = copy_fs(clone_flags, p);
// 继承父进程注册的信号以及信号处理函数
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
// 继承父进程的虚拟内存空间
retval = copy_mm(clone_flags, p);
// 继承父进程的 namespaces
retval = copy_namespaces(clone_flags, p);
// 继承父进程的 IO 信息
retval = copy_io(clone_flags, p);
...........省略.........
// 分配 CPU
retval = sched_fork(clone_flags, p);
// 分配 pid
pid = alloc_pid(p->nsproxy->pid_ns_for_children);
..........省略.........
}
这里我们重点关注 copy_mm 函数,正是在这里完成了子进程虚拟内存空间 mm_struct 结构的的创建以及初始化。
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
// 子进程虚拟内存空间,父进程虚拟内存空间
struct mm_struct *mm, *oldmm;
int retval;
...... 省略 ......
tsk->mm = NULL;
tsk->active_mm = NULL;
// 获取父进程虚拟内存空间
oldmm = current->mm;
if (!oldmm)
return 0;
...... 省略 ......
// 通过 vfork 或者 clone 系统调用创建出的子进程(线程)和父进程共享虚拟内存空间
if (clone_flags & CLONE_VM) {
// 增加父进程虚拟地址空间的引用计数
mmget(oldmm);
// 直接将父进程的虚拟内存空间赋值给子进程(线程)
// 线程共享其所属进程的虚拟内存空间
mm = oldmm;
goto good_mm;
}
retval = -ENOMEM;
// 如果是 fork 系统调用创建出的子进程,则将父进程的虚拟内存空间以及相关页表拷贝到子进程中的 mm_struct 结构中。
mm = dup_mm(tsk);
if (!mm)
goto fail_nomem;
good_mm:
// 将拷贝出来的父进程虚拟内存空间 mm_struct 赋值给子进程
tsk->mm = mm;
tsk->active_mm = mm;
return 0;
...... 省略 ......
由于本小节中我们举的示例是通过 fork() 函数创建子进程的情形,所以这里大家先占时忽略 if (clone_flags & CLONE_VM) 这个条件判断逻辑,我们先跳过往后看~~
copy_mm 函数首先会将父进程的虚拟内存空间 current->mm 赋值给指针 oldmm。然后通过 dup_mm 函数将父进程的虚拟内存空间以及相关页表拷贝到子进程的 mm_struct 结构中。最后将拷贝出来的 mm_struct 赋值给子进程的 task_struct 结构。
通过 fork() 函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝,直接从父进程中拷贝到子进程中。
而当我们通过 vfork 或者 clone 系统调用创建出的子进程,首先会设置 CLONE_VM 标识,这样来到 copy_mm 函数中就会进入 if (clone_flags & CLONE_VM) 条件中,在这个分支中会将父进程的虚拟内存空间以及相关页表直接赋值给子进程。这样一来父进程和子进程的虚拟内存空间就变成共享的了。也就是说父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。
子进程共享了父进程的虚拟内存空间,这样子进程就变成了我们熟悉的线程,是否共享地址空间几乎是进程和线程之间的本质区别。Linux 内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已。
内核线程和用户态线程的区别就是内核线程没有相关的内存描述符 mm_struct ,内核线程对应的 task_struct 结构中的 mm 域指向 Null,所以内核线程之间调度是不涉及地址空间切换的。
当一个内核线程被调度时,它会发现自己的虚拟地址空间为 Null,虽然它不会访问用户态的内存,但是它会访问内核内存,聪明的内核会将调度之前的上一个用户态进程的虚拟内存空间 mm_struct 直接赋值给内核线程,因为内核线程不会访问用户空间的内存,它仅仅只会访问内核空间的内存,所以直接复用上一个用户态进程的虚拟地址空间就可以避免为内核线程分配 mm_struct 和相关页表的开销,以及避免内核线程之间调度时地址空间的切换开销。
父进程与子进程的区别,进程与线程的区别,以及内核线程与用户态线程的区别其实都是围绕着这个 mm_struct 展开的。
现在我们知道了表示进程虚拟内存空间的 mm_struct 结构是如何被创建出来的相关背景,那么接下来笔者就带大家深入 mm_struct 结构内部,来看一下内核如何通过这么一个 mm_struct 结构体来管理进程的虚拟内存空间的。
mm_struct
struct mm_struct {
unsigned long task_size; /* size of task vm space */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
unsigned long mmap_base; /* base of mmap area */
unsigned long total_vm; /* Total pages mapped */
unsigned long locked_vm; /* Pages that have PG_mlocked set */
unsigned long pinned_vm; /* Refcount permanently increased */
unsigned long data_vm; /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
unsigned long exec_vm; /* VM_EXEC & ~VM_WRITE & ~VM_STACK */
unsigned long stack_vm; /* VM_STACK */
// (重要)页表目录信息
pgd_t * pgd;
// (重要) 各个虚拟地址的映射
struct vm_area_struct *mmap; /* list of VMAs */
// (重要) 红黑树 节点 ,也就是说一个进程作为一个节点
struct rb_root mm_rb;
...... 省略 ........
}
VMA
内存区域 vm_area_struct 会有两种组织形式,一种是双向链表用于高效的遍历,另一种就是红黑树用于高效的查找。
struct vm_area_struct {
// 这应该就是虚拟地址了!!!!
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
/*
* Access permissions of this VMA.
*/
//(重要) 权限相关
pgprot_t vm_page_prot;
unsigned long vm_flags;
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
struct file * vm_file; /* File we map to (can be NULL). */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units */
void * vm_private_data; /* was vm_pte (shared mem) */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
}
内核空间表示
提示几点需要注意的是
- 并不是说只要进入了内核态就开始使用物理地址了,这就大错特错了,千万不要这样理解,进入内核态之后使用的仍然是虚拟内存地址(言外之意就是依旧要走页表 , 依旧要MMU 的地址转化),只不过在内核中使用的虚拟内存地址被限制在了内核态虚拟内存空间范围中
- 内核空间里面的东西各个进程都是一样的 , 并不是进程私有
而对应映射到物理内存中的内容分区如下图 :
直接映射区
注意图片上的比例实际不是那样, 实际比例用户空间:内核空间 = 3:1
在这段 896M 大小的物理内存中,前 1M 已经在系统启动的时候被系统占用,1M 之后的物理内存存放的是内核代码段,数据段,BSS 段(这些信息起初存放在 ELF格式的二进制文件中,在系统启动的时候被加载进内存)。
当我们使用 fork 系统调用创建进程的时候,内核会创建一系列进程相关的描述符,比如之前提到的进程的核心数据结构 task_struct
,进程的内存空间描述符 mm_struct
,以及虚拟内存区域描述符 vm_area_struct
等。
这些进程相关的数据结构
也会存放在物理内存前 896M 的这段区域中,当然也会被直接映射至内核态虚拟内存空间中的 3G -- 3G + 896m 这段直接映射区域中。
这部分的虚拟内存区域映射到物理内存上去就是 ZONE_DMA
和 ZONE_NORMAL
这两块区域, 其中 ZONE_DMA
占 16M , 其他的给到 ZONE_NORMAL
ZONE_DMA 和 ZONE_NORMAL 的内容我们放到了其他章节进一步详细的介绍
vmallo 动态映射区
这张图我们也可以看到, 虚拟内存空间 , 内存出去 896M 后实际最多只有 128M 了, 而对应物理内存 , 还有3.2G 的空间, 如果内核要全部映射完 ,那怎么办呢 ?
于是就出现了 vmallo 动态映射区 , 最大的时候只有 128M , 映射对面的物理内存 128M 以后给A进程使用 ,然后B再进行映射另外的 128M , 相当于多个房间共享一个厕所的道理一样.
和用户态进程使用 malloc 申请内存一样,在这块动态映射区内核是使用 vmalloc 进行内存分配。由于之前介绍的动态映射的原因,vmalloc 分配的内存在虚拟内存上是连续的,但是物理内存是不连续的。通过页表来建立物理内存与虚拟内存之间的映射关系,从而可以将不连续的物理内存映射到连续的虚拟内存上。(当需要使用另外的 128M 空间的时候 , 直接修改页表就可以了)
由于 vmalloc 获得的物理内存页是不连续的,因此它只能将这些物理内存页一个一个地进行映射,在性能开销上会比直接映射大得多。
永久映射区
在内核的这段虚拟地址空间中允许建立与物理高端内存(ZONE_HIGHMEM)的长期映射关系。比如内核通过 alloc_pages() 函数
在物理内存的高端内存中申请获取到的物理内存页,这些物理内存页可以通过调用 kmap 映射到永久映射区中。
永久映射区和动态映射区有点像
alloc_pages() 函数 在后面会介绍 , 而且这个 alloc_pages() 可重要了!!!! 分配物理内存页的重要方法 !!
这里留个坑位 , 为什么要映射呢?? 肯定是为了方便内核操作
固定映射区
在固定映射区中的虚拟内存地址可以自由映射到物理内存的高端地址上,但是与动态映射区以及永久映射区不同的是,在固定映射区中虚拟地址是固定的,而被映射的物理地址是可以改变的。也就是说,有些虚拟地址在编译的时候就固定下来了,是在内核启动过程中被确定的,而这些虚拟地址对应的物理地址不是固定的。
采用固定虚拟地址的好处是它相当于一个指针常量(常量的值在编译时确定),指向物理地址,如果虚拟地址不固定,则相当于一个指针变量。
那为什么会有固定映射这个概念呢 ? 比如:在内核的启动过程中,有些模块需要使用虚拟内存并映射到指定的物理地址上,而且这些模块也没有办法等待完整的内存管理模块初始化之后再进行地址映射。因此,内核固定分配了一些虚拟地址,这些地址有固定的用途,使用该地址的模块在初始化的时候,将这些固定分配的虚拟地址映射到指定的物理地址上去。
这样的话 ,也就是这部分虚拟空间是内核特定的功能空间区域咯 ,不能随便给其他用途的
临时映射区
笔者在之前文章 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 的 “ 12.3 iov_iter_copy_from_user_atomic ” 小节中介绍在 Buffered IO 模式下进行文件写入的时候,在下图中的第四步,内核会调用 iov_iter_copy_from_user_atomic 函数将用户空间缓冲区 DirectByteBuffer 中的待写入数据拷贝到 page cache 中。
背景就是 : IO 为了快速地写入(write
)到内存中 , 传统的 write
需要多次拷贝 , 于是就出现了一个临时映射的区域
但是内核又不能直接进行拷贝,因为此时从 page cache 中取出的缓存页 page 是物理地址,而在内核中是不能够直接操作物理地址的,只能操作虚拟地址。
那怎么办呢?所以就需要使用 kmap_atomic 将缓存页临时映射到内核空间的一段虚拟地址上,这段虚拟地址就位于内核虚拟内存空间中的临时映射区上,然后将用户空间缓存区 DirectByteBuffer 中的待写入数据通过这段映射的虚拟地址拷贝到 page cache 中的相应缓存页中。 (也就是页表直接将 物理内存即缓存页 page
映射到虚拟地址即临时映射区
, 那么读写 临时映射区
就是读写 缓存页 page
) 这时文件的写入操作就已经完成了
由于是临时映射,所以在拷贝完成之后,调用 kunmap_atomic 将这段映射再解除掉
size_t iov_iter_copy_from_user_atomic(struct page *page,
struct iov_iter *i, unsigned long offset, size_t bytes)
{
// 将缓存页临时映射到内核虚拟地址空间的临时映射区中
char *kaddr = kmap_atomic(page),
*p = kaddr + offset;
// 将用户缓存区 DirectByteBuffer 中的待写入数据拷贝到文件缓存页中
iterate_all_kinds(i, bytes, v,
copyin((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len),
memcpy_from_page((p += v.bv_len) - v.bv_len, v.bv_page,
v.bv_offset, v.bv_len),
memcpy((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len)
)
// 解除内核虚拟地址空间与缓存页之间的临时映射,这里映射只是为了临时拷贝数据用
kunmap_atomic(kaddr);
return bytes;
}
虚拟内存到物理内存的映射
虚拟内存通过页表, 映射到物理内存中去
地址翻译
地址翻译就是虚拟地址是如何映射到对应的物理地址上去的, 这个建议参看袁春风老师的课程 , 这里就不赘述了.
其他
物理内存区域划分
ZONE_DMA
范围 : 16M
在 X86 体系结构下,ISA 总线的 DMA (直接内存存取)控制器,只能对内存的前16M 进行寻址,这就导致了 ISA 设备不能在整个 32 位地址空间中执行 DMA,只能使用物理内存的前 16M 进行 DMA 操作。
该区域的物理页面专门供I/O设备的DMA使用。之所以需要单独管理DMA的物理页面,是因为DMA使用物理地址访问内存,不经过MMU,并且需要连续的缓冲区,所以为了能够提供物理上连续的缓冲区,必须从物理地址空间专门划分一段区域用于DMA。
因此直接映射区的前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE_DMA。
ZONE_NORMAL
范围 : 16M~896M
该区域存放kernel代码、GDT、IDT、PGD、mem_map数组
ZONE_HIGHMEM
范围 : 896M 以上区域
用户数据(业务数据,例如一个进程里面的堆栈等等 ,就是业务数据)、页表(PT)等不常用数据 , 只在要访问这些数据时才建立映射关系(kmap())。比如,当内核要访问I/O设备存储空间时,就使用ioremap()将位于物理地址高端的mmio区内存映射到内核空间的vmalloc area中,在使用完之后便断开映射关系。
64位虚拟内存空间分布
64 位体系内核虚拟内存空间布局
64 位体系下的内核虚拟内存空间与物理内存的映射就变得非常简单,由于虚拟内存空间足够的大,即便是内核要访问全部的物理内存,直接映射就可以了,不在需要用到 ZONE_HIGHMEM 高端内存介绍的高端内存那种动态映射方式.