Linux内存管理之综合篇

来源:互联网 发布:2017淘宝卖家参加双12 编辑:程序博客网 时间:2024/05/21 10:05

2.6 Linux 内核使用了许多技术来改进对大量内存的使用,使得 Linux 比以往任何时候都更适用于企业。本文列出了一些更重要的改变,包括反向映射(reverse mapping)、使用更大的内存页、页表条目存储在高端内存中,以及更稳定的内存管理器。

随着 Linux 内核的发展和成熟,更多的用户期待着 Linux 可以运行非常大的系统来处理科学分析应用程序或者甚至海量数据库。这些企业级的应用程序通常需要大量的内存才能好好运行。2.4 Linux 内核有识别相当大数量的内存的功能,但是 2.5 内核发生了很多改变,使其有能力以更有效的方式处理更大量的内存。

反向映射

在 Linux 内存管理器中,页表保持对进程使用的内存物理页的追踪,它们将虚拟页映射到物理页。这些页中有一些可能不是长时间使用,它们应该被交换出去。不过,在它们可以被交换出去之前,必须找到映射那个页的每一个进程,这样那些进程中相应页的页表条目才可以被更新。在 Linux 2.4 内核中,这是一项令人生畏的任务,因为为了确定某个页是否被某个进程映射,必须遍历每个进程的页表。随着在系统中运行的进程数量的增加,将这些页交换出去的工作量也会增加。

反向映射,或者说是 RMAP,就是为解决此问题而在 2.5 内核中实现的。反向映射提供了一个发现哪些进程正在使用给定的内存物理页的机制。不再是遍历每个进程的页表,内存管理器现在为每一个物理页建立了一个链表,包含了指向当前映射那个页的每一个进程的页表条目(page-table entries, PTE)的指针。这个链表叫做 PTE 链。PTE 链极大地提高了找到那些映射某个页的进程的速度,如图 1 所示。

图 1. 2.6 中的反向映射
2.6 中的反向映射

当然,没有什么是免费的:用反向映射获得性能提高也要付出代价。反向映射最重要、明显的代价是,它带来了一些内存开销。不得不用一些内存来保持对所有那些反向映射的追踪。PTE 链的每一个条目使用 4 个字节来存储指向页表条目的指针,用另外 4 个字节来存储指向链的下一个条目的指针。这些内存必须使用低端内存,而这在 32 位硬件上有点不够用。有时这可以优化到只使用一个条目而不使用链表。这种方法叫做 p页直接方法(page-direct approach)。如果只有一个到这个页的映射,那么可以用一个叫做“direct”的指针来代替链表。只有在某个页只是由一个惟一的进程映射时才可以进行这种优化。如果稍后这个页被另一个进程所映射,它将不得不再去使用 PTE 链。一个标记设置用来告诉内存管理器什么时候这种优化对一个给定的页有效。

反向映射还带来了一些其他的复杂性。当页被一个进程映射时,必须为所有那些页建立反向映射。同样,当一个进程释放对页的映射时,相应的映射也必须都删除掉。这在退出时尤其常见。所有这些操作都必须在锁定情况下进行。对那些执行很多派生和退出的应用程序来说,这可能会非常浪费并且增加很多开销。

尽管有一些折衷,但可以证明反向映射是对 Linux 内存管理器的一个颇有价值的修改。通过这一途径,查找定位映射某个页的进程这一严重瓶颈被最小化为只需要一个简单的操作。当大型应用程序向内核请求大量内存和多个进程共享内存时,反向映射帮助系统继续有效地运行和扩展。当前还有更多对反向映射的改进正在研究中,可能会出现在未来的 Linux 内核版本中。

大内存页

典型地,内存管理器在 x86 系统上处理的内存页为 4 KB。实际的页大小是与体系结构相关的。对大部分用途来说,内存管理器以这样大小的页来管理内存是最有效的。不过,有一些应用程序要使用特别多的内存。大型数据库就是其中一个常见的例子。由于每个页都要由每个进程映射,必须创建页表条目来将虚拟地址映射到物理地址。如果您的一个进程要使用 4KB 的页来映射 1 GB 内存,这将用到 262,144 个页表条目来保持对那些页的追踪。如果每个页表条目消耗 8 个字节,那些每映射 1 GB 内存需要 2 MB 的开销。这本身就已经是非常可观的开销了,不过,如果有多个进程共享那些内存时,问题会变得更严重。在这种情况下,每个映射到同一块 1 GB 内存的进程将为页表条目付出自己 2 MB 的代价。如果有足够多的进程,内存在开销上的浪费可能会超过应用程序请求使用的内存数量。

解决这一问题的一个方法是使用更大的页。大部分新的处理器都支持至少一个小的和一个大的内存页大小。在 x86 上,大内存页的大小是 4 MB,或者,在物理地址扩展(PAE)打开的系统上是 2 MB。假定在前面的中使用页大小为 4 MB 的大内存页,同样 1 GB 内存只用 256 个页表条目就可以映射,而不需要 262,144 个。这样开销从 2 MB 变为 2,048 个字节。

大内存页的使用还可以通过减少 变换索引缓冲(translation lookaside buffer, TLB)的失败次数来提高性能。TLB 是一种页表的高速缓存,让那些在表中列出的页可以更快地进行虚拟地址到物理地址的转换。大内存页可以用更少的实际页来提供更多的内存,相当于较小的页大小,使用的大内存页越多,就有越多的内存可以通过 TLB 引用。

在高端内存中存储页表条目

在 32 位机器上页表通常只可以存储在低端内存中。低端内存只限于物理内存的前 896 MB,同时还要满足内核其余的大部分要求。在应用程序使用了大量进程并映射了大量内存的情况下,低端内存可能很快就不够用了。

现在,在 2.6 内核中有一个配置选项叫做 Highmem PTE,让页表条目可以存放在高端内存中,释放出更多的低端内存区域给那些必须放在这里的其他内核数据结构。作为代价,使用这些页表条目的进程会稍微慢一些。不过,对于那些在大量进程在运行的系统来说,将页表存储到高端内存中可以在低端内存区域挤出更多的内存。

图 2. 内存区域
内存区域

稳定性

更好的稳定性是 2.6 内存管理器的另一个重要改进。当 2.4 内核发布时,用户几乎马上就开始遇到内存管理相关的稳定性问题。从内存管理对整个系统的影响来看,稳定性是至关重要的。问题大部分已经解决,但是解决方案必须从根本上推翻原来的内存管理器并重写一个简单的多的管理器来取代它。这为 Linux 的发行者改进自己特定发行版本的 Linux 的内存管理器留下了很大的空间。不过,那些改进的另一方面是,在 2.4 中的内存管理部件由于使用的发行版本不同而很不相同。为避免再发生这样的事情,内存管理成为 2.6 中内核开发的最细致的一部分。从很低端的桌面系统到大型的、企业级的、多处理器的系统,新的内存管理代码已经在它们上面都已经进行了测试和优化。

结束语

Linux 2.6 内核中内存管理的改进远远不只本文中提到的这些特性。很多变化是细微的,却相当重要。这些变化一起促生了 2.6 内核中的内存管理器,它的设计目标是更高的性能、效率和稳定性。有一些变化,比如 Highmem PTE 和大内存页,目的是减少内存管理带来的开销。其他变化,比如反向映射,提高了某些关键领域的性能。之所以选择这些特别的例子,是因为它们举例说明了 Linux 2.6 内核得到了怎样的调整和增强,以便更好地处理企业级的硬件和应用程序。

#####################################################

Linux 大页面使用与实现简介

为了减少 TLB Miss 对应用程序性能的影响,Linux 内核支持以 2MB 作为物理页面分页的基本单位。 Linux 内核采用基于 Hugetlb 伪文件系统的实现方式支持大页面,虽然较大地提升了应用程序的性能,但由于不能做到完全的透明性,对应用程序的移植带来了挑战。本文对 Linux 大页面机制的使用和实现进行了简要的介绍和分析。

随着计算需求规模的不断增大,应用程序对内存的需求也越来越大。为了实现虚拟内存管理机制,操作系统对内存实行分页管理。自内存“分页机制”提出之始,内存页面的默认大小便被设置为 4096 字节(4KB),虽然原则上内存页面大小是可配置的,但绝大多数的操作系统实现中仍然采用默认的 4KB 页面。 4KB 大小的页面在“分页机制”提出的时候是合理的,因为当时的内存大小不过几十兆字节,然而当物理内存容量增长到几 G 甚至几十 G 的时候,操作系统仍然以 4KB 大小为页面的基本单位,是否依然合理呢?

在 Linux 操作系统上运行内存需求量较大的应用程序时,由于其采用的默认页面大小为 4KB,因而将会产生较多 TLB Miss 和缺页中断,从而大大影响应用程序的性能。当操作系统以 2MB 甚至更大作为分页的单位时,将会大大减少 TLB Miss 和缺页中断的数量,显著提高应用程序的性能。这也正是 Linux 内核引入大页面支持的直接原因。好处是很明显的,假设应用程序需要 2MB 的内存,如果操作系统以 4KB 作为分页的单位,则需要 512 个页面,进而在 TLB 中需要 512 个表项,同时也需要 512 个页表项,操作系统需要经历至少 512 次 TLB Miss 和 512 次缺页中断才能将 2MB 应用程序空间全部映射到物理内存;然而,当操作系统采用 2MB 作为分页的基本单位时,只需要一次 TLB Miss 和一次缺页中断,就可以为 2MB 的应用程序空间建立虚实映射,并在运行过程中无需再经历 TLB Miss 和缺页中断(假设未发生 TLB 项替换和 Swap)。

为了能以最小的代价实现大页面支持,Linux 操作系统采用了基于 hugetlbfs 特殊文件系统 2M 字节大页面支持。这种采用特殊文件系统形式支持大页面的方式,使得应用程序可以根据需要灵活地选择虚存页面大小,而不会被强制使用 2MB 大页面。本文将针对 hugetlb 大页面的应用和内核实现两个方面进行简单的介绍,以期起到抛砖引玉的作用。

Hugetlb FileSystem 的应用

本文的例子摘自 Linux 内核源码中提供的有关说明文档 (Documentation/vm/hugetlbpage.txt) 。使用 hugetlbfs 之前,首先需要在编译内核 (make menuconfig) 时配置CONFIG_HUGETLB_PAGECONFIG_HUGETLBFS选项,这两个选项均可在 File systems 内核配置菜单中找到。

内核编译完成并成功启动内核之后,将 hugetlbfs 特殊文件系统挂载到根文件系统的某个目录上去,以使得 hugetlbfs 可以访问。命令如下:

mount none /mnt/huge -t hugetlbfs

此后,只要是在 /mnt/huge/ 目录下创建的文件,将其映射到内存中时都会使用 2MB 作为分页的基本单位。值得一提的是,hugetlbfs 中的文件是不支持读 / 写系统调用 ( 如read()write()等 ) 的,一般对它的访问都是以内存映射的形式进行的。为了更好地介绍大页面的应用,接下来将给出一个大页面应用的例子,该例子同样也是摘自于上述提到的内核文档,只是略有简化。

清单 1. Linux 大页面应用示例
#include <fcntl.h>  #include <sys/mman.h>  #include <errno.h>  #define MAP_LENGTH      (10*1024*1024)  int main()  {     int fd;     void * addr;     /* create a file in hugetlb fs */     fd = open("/mnt/huge/test", O_CREAT | O_RDWR);     if(fd < 0){         perror("Err: ");         return -1;     }       /* map the file into address space of current application process */     addr = mmap(0, MAP_LENGTH, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);     if(addr == MAP_FAILED){         perror("Err: ");         close(fd);         unlink("/mnt/huge/test");         return -1;     }       /* from now on, you can store application data on huage pages via addr */     munmap(addr, MAP_LENGTH);     close(fd);     unlink("/mnt/huge/test");     return 0;  }

对于系统中大页面的统计信息可以在 Proc 特殊文件系统(/proc)中查到,如/proc/sys/vm/nr_hugepages给出了当前内核中配置的大页面的数目,也可以通过该文件配置大页面的数目,如:

echo 20 > /proc/sys/vm/nr_hugepages

调整系统中的大页面的数目为 20 。 例子中给出的大页面应用是简单的,而且如果仅仅是这样的应用,对应用程序来说也是没有任何用处的。在实际应用中,为了使用大页面,还需要将应用程序与库libhugetlb链接在一起。libhugetlb库对malloc()/free()等常用的内存相关的库函数进行了重载,以使得应用程序的数据可以放置在采用大页面的内存区域中,以提高内存性能。

Hugetlb 特殊文件系统的内核实现

在简要介绍了大页面的使用之后,本文接下来将重点介绍 hugetlbfs 在内核中的实现。本文的源代码分析是基于 2.6.18.8 版本的 Linux 内核进行的。涉及到的文件主要包括mm/hugetlb.cinclude/linux/hugetlb.h以及fs/hugetlbfs/inode.c三个文件。为了能够更好地理解 hugetlbfs 的工作原理,将按照上述程序示例中给出的流程,介绍 hugetlb 特殊文件系统的初始化过程、在 hugetlbfs 伪文件系统中创建文件的内核处理流程,以及将 hugetlb 文件映射到用户地址空间的内核实现过程。

hugetlbfs 初始化

Hugetlbfs 的初始化是通过函数hugetlb_init()完成的,该函数在系统初始化时执行。用于 hugetlbfs 的大页面是在这一初始化过程就分配好了的,并且在系统运行过程中不会被回收。初始化的过程大致如下:

  1. 在 NUMA 机器上,每个 NUMA 节点 (node) 都有一个空闲 huge page 的链表,用数组hugepage_freelists[]记录这些链表的头结构 (list_head),每个 NUMA 节点对应数组的一个元素,在 hugetlb 伪文件系统初始化时,首先要初始化这些链表头结构。
  2. 接下来,调用alloc_fresh_huge_page()在每个 NUMA node 中预分配大页面,并加入到上述的空闲页面链表中去,全局变量max_huge_pages记录了系统中支持的大页面的最大数目。在alloc_fresh_huge_page()中以__GFP_COMP为标志调用通用底层的物理页面分配函数alloc_page_node()以分配一个 2MB 的大页面,由于声明了__GFP_COMP标志,使得连续的 512 个 4KB 的页面作为一个“混合”页面,它们的 page 结构一起被分配给 hugetlbfs 。分配成功后,将这 512 个连续的 page 结构中的第二个 page 结构的lru.next指针指向一个特定的“析构函数”——free_huge_page()
  3. 最后调用put_page()将大页面放到相应的空闲链表中去,由于这个大页面被设置了PG_compound标志,进而会调用该析构函数,将页面放到该大页面所在 NUMA node 的hugepage_freelists[]链表中去。最后将max_huge_pagesfree_huge_pages(系统中空闲的大页面数目)以及nr_huge_pages(系统中实际的大页面数目)等作为统计信息的全局变量初始化为成功预分配的大页面数目。

在 hugetlbfs 中创建文件

在示例代码中,首先调用open()系统调用在 hugetlbfs(/mnt/huge)创建了一个文件,相应地,在内核中由sys_open()函数最终调用hugetlbfs_create()为该文件分配内存索引节点(inode)结构,并进行基本的初始化工作。其中值得一提的是,在 inode 初始化时将文件操作表指针inode->i_fop指向 hugetlbfs 特有的函数跳转表hugetlbfs_file_operations,而此表中就包含了实现文件映射的 mmap 方法hugetlbfs_file_mmap(),这个函数在系统调用sys_mmap()的实现中极为关键。

由于 hugetlbfs 是一个伪文件系统,在磁盘上没有相应的副本,因此在该文件系统中创建一个文件的过程也仅仅是分配虚拟文件系统(VFS)层的 inode、dentry 等结构。甚至连物理内存页面都不会分配,而是在对该文件映射后并访问时,才通过缺页中断进入内核分配大页面并建立虚实映射,这一过程将在 2.4 详细介绍。

为 Hugetlb 文件建立映射

在成功创建了 hugetlbfs 文件之后,就可以将其映射到应用进程的地址空间了,这是通过系统调用mmap()实现的。在内核中,sys_mmap()调用 2.2 节中提到的函数跳转表中的hugetlbfs_file_mmap()方法为应用进程建立映射。

函数hugetlbfs_file_mmap()对虚存区域 (vma) 的偏移、边界等进行检查,并设置该 vma 的VM_HUGETLBVM_RESERVED等标志,以区别于 4KB 映射的虚存区域,并且在进程运行过程中,该虚存区域映射的物理页面不会被回收。在这个过程中,虚拟地址并没有真正映射到物理地址空间,而这一工作则推迟到应用程序访问该内存区域并引发缺页中断(即 Page Fault)的时候进行,见 2.4 节。

分配大页面、建立虚实映射

以 4KB 为基本分页单位的 64 位 Linux 操作系统来采用四级页表管理虚实映射。如图 1 所示。每个页表项占据 64 位(8Bytes),因此每个作为页表的物理页面可以存放 512 个页表项,从而最末级页表所映射的物理内存大小为 512*4KB = 2MB,依此类推,在上一级页表(PMD)中,每一个 PMD 表项可映射 2MB 的物理内存。当采用 2MB 作为分页的基本单位时,内核中则设置了三级页表,如图 2 所示。在三级页表中,最末一级页表为 PMD 表,同样地,每一个 PMD 表项指出了一个 2MB 的大页面,也即虚拟地址的低 21 位作为大页面的页内偏移,而高位则作为大页面的页面编号(pfn)。为了能让 MMU 正确地进行虚实地址转换,必须告知 MMU 哪个页表项映射的是 4KB 的物理页面,哪个页表项映射的是 2MB 的大页面,这是通过页表项中的标志位 _PAGE_PSE 来区分的,这一般是通过内联函数pte_mkhuge()设置的。

图 1. 64 位 Linux 操作系统四级页表示意图
64位Linux操作系统四级页表示意图
图 2. 64 位 Linux 操作系统三级页表示意图
64 位 Linux 操作系统三级页表示意图

简单介绍了采用大页面映射的页表组织后,下面将描述进程在设置为大页面的虚存区域产生 Page Fault 时的缺页中断处理流程,如图 3 所示:

图 3. 大页面缺页中断处理函数调用流程
大页面缺页中断处理函数调用流程

在进程访问到尚未建立虚实映射的大页面内存区域时,就会产生缺页中断,缺页中断的处理函数是大名鼎鼎的do_page_fault()函数。从do_page_fault()到函数__handle_mm_fault()是缺页中断处理的公共流程,不是我们关注的重点,在此不作介绍。在函数__handle_mm_fault()中首先会检查产生缺页中断的内存区域是否是大页面区域,即VM_HUGETLB标志是否设置,如果是,则调用函数hugetlb_fault()进行大页面的映射,这是大页面缺页中断处理的入口函数,其处理过程大致如下:

  • hugetlb_fault()
    1. 根据产生 Page Fault 的虚拟地址查找或分配相应的 PMD 表项;
    2. 调用hugetlb_no_page()以分配物理内存、建立虚实映射;
    3. 如果引发缺页中断的内存操作是写操作,且该大页面被设置为只读,则预先做一次 Copy on Write 操作,以避免因“违规操作”再次产生 Page Fault 而影响性能。
  • hugetlb_no_page()
    1. 在产生 Page Fault 的虚存区域所映射的 hugetlb 特殊文件的页面缓存(PageCache)中查找引发中断的虚拟地址所在的文件页面,如果找到则跳转到 3) ;
    2. 分配大页面,这是通过函数alloc_huge_page()完成的。分配成功后,将该页面加入到该 hugetlb 文件对应的 Page Cache 中,以便可以与其它进程共享该大页面。
    3. 设置相应的 PMD 表项,需要强调的是,为了区分大页面与 4KB 页面需要设置页表项的_PAGE_PSE标志位,使得 MMU 在进行虚实地址转换时能将此 PMD 表项作为最后一级映射,得到大页面的物理地址。
  • alloc_huge_page()

    在 2.1 节提到,系统初始化时为每个 NUMA node 都初始化了相应的空闲大页面链表——hugepage_freelists[],并分配了全部的大页面,因此,在系统运行过程中分配大页面的操作即为从该链表中获取空闲大页面的过程。至于大页面的解除映射以及释放,与分配与建立映射的过程相反,在此不再赘述。

小结

Linux 基于 hugetlb 特殊文件系统的大页面支持为应用程序的灵活性和性能优化提供了方便。为了测试大页面对应用程序性能的影响,我们使用 Linpack 进行了一个简单的实验,实验结果表明,采用 hugetlb 大页面的情况下,Linpack 的性能相对于采用 4KB 页面时提升了 1 到 2 个百分点,这对于大规模的科学计算应用来说性能的提升是较为显著的。除了性能的显著提高外,简单的文件操作接口如open()mmap()等也使得大页面机制简单易用。从总体上讲,通过 hugetlbfs 实现对大页面的支持是成功的。

但是,从本质上讲 hugetlbfs 的实现方式仅仅是一个通过“打补丁”的手段来支持灵活的内存页面大小,这主要是受限于 Linux 内核“模块化”的特征,为了尽可能少地影响到其它内核模块,hugetlbfs 无疑是一个很明智的选择,同时也注定了其无法实现对应用程序的透明性。

随着芯片制造工艺的不断进步,物理内存的容量会越来越大,因此 Linux 内核的内存分页基本单位的增大是一个必然的趋势。但如何做到对传统应用程序的完全透明性和与其它内核模块的兼容性,是实现上的难点。笔者在写作本文之前曾试图通过修改 Linux 内核中定义页面大小的宏(PAGE_SIZE)来实现透明的大页面支持,但内核中某些部分的代码仅仅支持 4KB 的页面大小,使得内核编译都无法通过,即使经过适当的修改勉强编译通过,内核也无法正常启动。因此可以预见的是,实现 Linux 内核透明的大页面支持将是一项繁杂的工作。

虚拟内存机制提出的动机就是解决内存容量限制的问题,而在今天内存容量已经渐渐地不再是一个限制,并且随着多核的发展,内存带宽明显地成为性能的瓶颈。在这样的发展趋势下,操作系统是否应该考虑新的内存管理方式?灵活的或者可配置的内存页面大小是否能够满足应用程序日益增长的内存需求?这些都是操作系统研发人员应该关注的问题。

#####################################################

Linux 内存和用户空间 API 简介

因为内核和用户空间存在于不同的虚拟地址空间中,在它们之间移动数据需要特别注意。研究虚拟地址空间和内核 API 理念,用于将数据移入或移出用户空间,并学习其他的一些用于映射内存的映射技术。

Linux 内存

在 Linux 中,用户内存和内核内存是独立的,在各自的地址空间实现。地址空间是虚拟的,就是说地址是从物理内存中抽象出来的(通过一个简短描述的过程)。由于地址空间是虚拟的,所以可以存在很多。事实上,内核本身驻留在一个地址空间中,每个进程驻留在自己的地址空间。这些地址空间由虚拟内存地址组成,允许一些带有独立地址空间的进程指向一个相对较小的物理地址空间(在机器的物理内存中)。不仅仅是方便,而且更安全。因为每个地址空间是独立且隔离的,因此很安全。

但是与安全性相关联的成本很高。因为每个进程(和内核)会有相同地址指向不同的物理内存区域,不可能立即共享内存。幸运的是,有一些解决方案。用户进程可以通过 Portable Operating System Interface for UNIX® (POSIX) 共享的内存机制(shmem)共享内存,但有一点要说明,每个进程可能有一个指向相同物理内存区域的不同虚拟地址。

虚拟内存到物理内存的映射通过页表完成,这是在底层软件中实现的(见图 1)。硬件本身提供映射,但是内核管理表及其配置。注意这里的显示,进程可能有一个大的地址空间,但是很少见,就是说小的地址空间的区域(页面)通过页表指向物理内存。这允许进程仅为随时需要的网页指定大的地址空间。

图 1. 页表提供从虚拟地址到物理地址的映射
页表提供从虚拟地址到物理地址的映射

由于缺乏为进程定义内存的能力,底层物理内存被过度使用。通过一个称为 paging(然而,在 Linux 中通常称为 swap)的进程,很少使用的页面将自动移到一个速度较慢的存储设备(比如磁盘),来容纳需要被访问的其它页面(见图 2 )。这一行为允许,在将很少使用的页面迁移到磁盘来提高物理内存使用的同时,计算机中的物理内存为应用程序更容易需要的页面提供服务。注意,一些页面可以指向文件,在这种情况下,如果页面是脏(dirty)的,数据将被冲洗,如果页面是干净的 (clean),直接丢掉。

图 2. 通过将很少使用的页面迁移到速度慢且便宜的存储器,交换使物理内存空间得到了更好的利用
通过将很少使用的页面迁移到速度慢且便宜的存储器,交换使物理内存空间得到了更好的利用

MMU-less 架构

不是所有的处理器都有 MMU。因此,uClinux 发行版(微控制器 Linux)支持操作的一个地址空间。该架构缺乏 MMU 提供的保护,但是允许 Linux 运行另一类处理器。关于 uClinux 的详细信息见 参考资料。

选择一个页面来交换存储的过程被称为一个页面置换算法,可以通过使用许多算法(至少是最近使用的)来实现。该进程在请求存储位置时发生,存储位置的页面不在存储器中(在存储器管理单元 [MMU] 中无映射)。这个事件被称为一个页面错误 并被硬件(MMU)删除,出现页面错误中断后该事件由防火墙管理。该栈的详细说明见 图 3。

Linux 提供一个有趣的交换实现,该实现提供许多有用的特性。Linux 交换系统允许创建和使用多个交换分区和优先权,这支持存储设备上的交换层次结构,这些存储设备提供不同的性能参数(例如,固态磁盘 [SSD] 上的一级交换和速度较慢的存储设备上的较大的二级交换)。为 SSD 交换附加一个更高的优先级使其可以使用直至耗尽;直到那时,页面才能被写入优先级较低的交换分区。

图 3. 地址空间和虚拟 - 物理地址映射的元素
虚拟 - 物理地址映射

并不是所有的页面都适合交换。考虑到响应中断的内核代码或者管理页表和交换逻辑的代码,显然,这些页面决不能被换出,因此它们是固定的,或者是永久地驻留在内存中。尽管内核页面不需要进行交换,然而用户页面需要,但是它们可以被固定,通过 mlock(或 mlockall)函数来锁定页面。这就是用户空间内存访问函数的目的。如果内核假设一个用户传递的地址是有效的且是可访问的,最终可能会出现内核严重错误(kernel panic)(例如,因为用户页面被换出,而导致内核中的页面错误)。该应用程序编程接口(API)确保这些边界情况被妥善处理。

内核 API

现在,让我们来研究一下用户操作用户内存的内核 API。请注意,这涉及内核和用户空间接口,而下一部分将研究其他的一些内存 API。用户空间内存访问函数在表 1 中列出。

表 1. 用户空间内存访问 API
函数描述access_ok检查用户空间内存指针的有效性get_user从用户空间获取一个简单变量put_user输入一个简单变量到用户空间clear_user清除用户空间中的一个块,或者将其归零。copy_to_user将一个数据块从内核复制到用户空间copy_from_user将一个数据块从用户空间复制到内核strnlen_user获取内存空间中字符串缓冲区的大小strncpy_from_user从用户空间复制一个字符串到内核

正如您所期望的,这些函数的实现架构是独立的。例如在 x86 架构中,您可以使用 ./linux/arch/x86/lib/usercopy_32.c 和 usercopy_64.c 中的源代码找到这些函数以及在 ./linux/arch/x86/include/asm/uaccess.h 中定义的字符串。

当数据移动函数的规则涉及到复制调用的类型时(简单 VS. 聚集),这些函数的作用如图 4 所示。

图 4. 使用 User Space Memory Access API 进行数据移动
使用 User Space Memory Access API 进行数据移动

access_ok 函数

您可以使用 access_ok 函数在您想要访问的用户空间检查指针的有效性。调用函数提供指向数据块的开始的指针、块大小和访问类型(无论这个区域是用来读还是写的)。函数原型定义如下:

access_ok( type, addr, size );

type 参数可以被指定为 VERIFY_READ 或 VERIFY_WRITEVERIFY_WRITE 也可以识别内存区域是否可读以及可写(尽管访问仍然会生成 -EFAULT)。该函数简单检查地址可能是在用户空间,而不是内核。

get_user 函数

要从用户空间读取一个简单变量,可以使用 get_user 函数,该函数适用于简单数据类型,比如,char 和 int,但是像结构体这类较大的数据类型,必须使用 copy_from_user 函数。该原型接受一个变量(存储数据)和一个用户空间地址来进行 Read 操作:

get_user( x, ptr );

get_user 函数将映射到两个内部函数其中的一个。在系统内部,这个函数决定被访问变量的大小(根据提供的变量存储结果)并通过__get_user_x 形成一个内部调用。成功时该函数返回 0,一般情况下,get_user 和 put_user 函数比它们的块复制副本 要快一些,如果是小类型被移动的话,应该用它们。

put_user 函数

您可以使用 put_user 函数来将一个简单变量从内核写入用户空间。和 get_user 一样,它接受一个变量(包含要写的值)和一个用户空间地址作为写目标:

put_user( x, ptr );

和 get_user 一样,put_user 函数被内部映射到 put_user_x 函数 ,成功时,返回 0,出现错误时,返回 -EFAULT

clear_user 函数

clear_user 函数被用于将用户空间的内存块清零。该函数采用一个指针(用户空间中)和一个型号进行清零,这是以字节定义的:

clear_user( ptr, n );

在内部,clear_user 函数首先检查用户空间指针是否可写(通过 access_ok),然后调用内部函数(通过内联组装方式编码)来执行 Clear 操作。使用带有 repeat 前缀的字符串指令将该函数优化成一个非常紧密的循环。它将返回不可清除的字节数,如果操作成功,则返回 0。

copy_to_user 函数

copy_to_user 函数将数据块从内核复制到用户空间。该函数接受一个指向用户空间缓冲区的指针、一个指向内存缓冲区的指针、以及一个以字节定义的长度。该函数在成功时,返回 0,否则返回一个非零数,指出不能发送的字节数。

copy_to_user( to, from, n );

检查了向用户缓冲区写入的功能之后(通过 access_ok),内部函数 __copy_to_user 被调用,它反过来调用__copy_from_user_inatomic(在 ./linux/arch/x86/include/asm/uaccess_XX.h 中。其中 XX 是 32 或者 64 ,具体取决于架构。)在确定了是否执行 1、2 或 4 字节复制之后,该函数调用 __copy_to_user_ll,这就是实际工作进行的地方。在损坏的硬件中(在 i486 之前,WP 位在管理模式下不可用),页表可以随时替换,需要将想要的页面固定到内存,使它们在处理时不被换出。i486 之后,该过程只不过是一个优化的副本。

copy_from_user 函数

copy_from_user 函数将数据块从用户空间复制到内核缓冲区。它接受一个目的缓冲区(在内核空间)、一个源缓冲区(从用户空间)和一个以字节定义的长度。和 copy_to_user 一样,该函数在成功时,返回 0 ,否则返回一个非零数,指出不能复制的字节数。

copy_from_user( to, from, n );

该函数首先检查从用户空间源缓冲区读取的能力(通过 access_ok),然后调用 __copy_from_user,最后调用 __copy_from_user_ll。从此开始,根据构架,为执行从用户缓冲区到内核缓冲区的零拷贝(不可用字节)而进行一个调用。优化组装函数包含管理功能。

strnlen_user 函数

strnlen_user 函数也能像 strnlen 那样使用,但前提是缓冲区在用户空间可用。strnlen_user 函数带有两个参数:用户空间缓冲区地址和要检查的最大长度。

strnlen_user( src, n );

strnlen_user 函数首先通过调用 access_ok 检查用户缓冲区是否可读。如果是 strlen 函数被调用,max length 参数则被忽略。

strncpy_from_user 函数

strncpy_from_user 函数将一个字符串从用户空间复制到一个内核缓冲区,给定一个用户空间源地址和最大长度。

strncpy_from_user( dest, src, n );

由于从用户空间复制,该函数首先使用 access_ok 检查缓冲区是否可读。和 copy_from_user 一样,该函数作为一个优化组装函数(在 ./linux/arch/x86/lib/usercopy_XX.c 中)实现。

内存映射的其他模式

上面部分探讨了在内核和用户空间之间移动数据的方法(使用内核初始化操作)。Linux 还提供一些其他的方法,用于在内核和用户空间中移动数据。尽管这些方法未必能够提供与用户空间内存访问函数相同的功能,但是它们在地址空间之间映射内存的功能是相似的。

在用户空间,注意,由于用户进程出现在单独的地址空间,在它们之间移动数据必须经过某种进程间通信机制。Linux 提供各种模式(比如,消息队列),但是最著名的是 POSIX 共享内存(shmem)。该机制允许进程创建一个内存区域,然后同一个或多个进程共享该区域。注意,每个进程可能在其各自的地址空间中映射共享内存区域到不同地址。因此需要相对的寻址偏移(offset addressing)。

mmap 函数允许一个用户空间应用程序在虚拟地址空间中创建一个映射,该功能在某个设备驱动程序类中是常见的,允许将物理设备内存映射到进程的虚拟地址空间。在一个驱动程序中,mmap 函数通过 remap_pfn_range 内核函数实现,它提供设备内存到用户地址空间的线性映射。



0 0