进程地址空间(Linux内核源码分析)

背景

之前写过关于内存管理源码分析的博客。大体介绍了什么是页、区、slab缓存,以及内核获取、释放页的接口,分配、释放slab缓存的接口。进程地址空间简单的说就是用户空间中进程的内存,我们叫这内存为进程地址空间。本篇博客借助linux源码大体分析进程地址空间的相关知识。

进程控制块

既然我们要聊一聊进程地址空间,那么不可避免的就要先聊一下进程控制块,进程控制块的概念想必大家不会陌生。一个进程是由一个进程控制块来描述的。所以,可想而知,进程控制块中一定包含有进程地址空间的描述结构体。这个描述进程地址空间的结构体就是mm_struct。先来看下task_struct结构体。(位于linux/sched.h中)
这里写图片描述
这是截取了task_struct中的一部分代码。

mm_struct

接下来我们就重点的看一下mm_struct结构体。此结构体是用于描述进程地址空间的,所以每个进程都有唯一的mm_struct结构体,即唯一的进程地址空间。我们来看一下此结构体的部分源码(位于linux/mm_types.h中)。
这里写图片描述
203行的mmap域与204行的mm_rb域描述的对象是相同的。为什么要用不同的方式来组织相同的对象呢?原因在于,用mmap作为链表可以方便遍历所有元素;而mm_rb作为红黑树,适合于搜索指定的元素。
215行的mm_users域与216行的mm_count域都是原子数,用于用户引用此进程地址空间(也就是此结构体)的计数。区别在于,mm_users表示的是有多少进程在引用此进程地址空间,若其为3,则表示有3个线程共享此进程地址空间。mm_count表示的是如果有用户使用此进程地址空间,那么此域值为1,否则域为0。当mm_count为0时,此结构体就会被撤销。
所有的mm_struct结构体都通过双向链表mmlist域连接在一起。双向链表的链表头是init进程的进程地址空间。
mm_struct结构体的源码就分析这么多。
我们都知道一个进程有唯一的一个mm_struct结构体,即一个进程有唯一的一个进程地址空间。那么,在创建一个新进程时,mm_struct是如何被创建的?
我之前有一篇博客讲过slab缓存的作用以及用法,提到过slab缓存的作用在于快速为常用的结构体分配空间。很显然mm_struct是一个常用的结构体,新进程的mm_struct结构体是通过allocate_mm()宏(位于linux/fork.h中)从mm_cachep slab缓存中分配得来的。前面提到过,一个进程有唯一的进程地址空间,而线程没有自己的进程地址空间,不同线程共享同一个进程地址空间。是否共享地址空间几乎是进程和Linux中线程间本质上的唯一区别。那么,内核线程与进程地址空间的关系是如何的呢?

内核线程与进程地址空间

内核线程没有进程地址空间,当然也就没有mm_struct结构体。所以内核线程对应的进程描述符中的mm域为NULL。请大家回想一下在一个进程被调度时进程地址空间是如何进行转换的?
实际上是该进程的mm域指向的进程地址空间被装载到内存中,进程描述符中的active_mm域被更新指向新的进程地址空间。
而内核线程是没有进程地址空间的,所以其mm域为NULL,当内核线程被调度时,内核发现它的mm域为NULL,就会保留前一个进程的进程地址空间,更新内核线程进程描述符中的active_mm域指向前一个进程的进程地址空间。因为内核线程不访问用户空间的内存,所以它们仅仅使用地址空间中和内核内存相关的信息,这些信息的含义和普通进程完全相同。
上面讲过的mm_struct结构体是描述进程地址空间的,而通过分析源码我们发现,mm_struct结构体并没有涉及到具体的虚拟内存区域,如内存区间何起何止。但在mm_struct结构体的第一个域mmap指向的vm_area_struct结构体中描述了虚拟内存区域。我们来看看这个结构体。

vm_area_struct

这里写图片描述
这里截取了部分vm_area_struct的源码。(位于linux/mm_types.h中)
其中136行的vm_start域是进程地址空间的起始地址,137行的vm_end域是结束地址。
135行的vm_mm域指向相关的mm_struct结构体。也就是此虚拟内存区域相关的进程地址空间。
聊完虚拟内存区域VMA,接下来就可以聊一下如何为一个进程地址空间创建一个VMA。

do_mmap

内核使用do_mmap函数创建一个新的线性地址空间,由于新创建的地址空间若是与原有的地址空间相邻内核会自动进行合并,所以更严格的来说,do_mmap不一定总是创建新的VMA,而可能是扩展已有的VMA。来看看do_mmap的源码(位于linux/mm.h中)
这里写图片描述
函数参数中的file、offset、len域分别指要映射的文件、要映射的起始偏移地址、映射的长度。
其中file域可以为空,这时指此次映射与文件无关,这叫做匿名映射,如果是与文件有关的,叫做文件映射。
addr是可选参数,它指定搜索空闲区域的起始位置。prot参数指定内存区域中页面的访问权限。flag参数指定了VMA标志。
有do_mmap来创建新的地址空间,当然就有do_munmap来删除地址空间。该函数定义在mm/mmap.c中。这里就不赘述了。

总结

本篇博客先介绍了什么是进程地址空间,然后介绍了其描述符mm_struct,一个进程对应一个进程地址空间,也就是说task_struct对应一个mm_struct。内存描述符mm_struct介绍完后聊了一下其与内核线程的关系,因为我们知道,内核线程是没有进程地址空间的。所以内核线程使用前一个进程的mm_struct。接下来介绍了一下vm_area_struct结构体,它描述了虚拟内存区域,也就是进程地址空间中的内存区域。最后简要说了一下创建线性地址空间的函数do_mmap。

内存管理(Linux内核源码分析)

背景

本篇博客试图通过linux内核源码分析linux的内存管理机制,并且对比内核提供的几个分配内存的接口函数。然后聊下slab层的用法以及接口函数。

内核分配内存与用户态分配内存

内核分配内存与用户态分配内存显然是不同的,内核不可以像用户态那样奢侈的使用内存,内核使用内存一定是谨小慎微的。并且,在用户态如果出现内存溢出因为有内存保护机制,可能只是一个报错或警告,而在内核态若出现内存溢出后果就会严重的多(毕竟再没有管理者了)。

我们知道处理器处理数据的基本单位是字。而内核把页作为内存管理的基本单位。那么,页在内存中是如何描述的?
内核用struct page结构体表示系统中的每一个物理页:
这里写图片描述
flags存放页的状态,如该页是不是脏页。
_count域表示该页的使用计数,如果该页未被使用,就可以在新的分配中使用它。
要注意的是,page结构体描述的是物理页而非逻辑页,描述的是内存页的信息而不是页中数据。
实际上每个物理页面都由一个page结构体来描述,有的人可能会惊讶说那这得需要多少内存呢?我们可以来算一下,若一个struct page占用40字节内存,一个页有8KB,内存大小为4G的话,共有524288个页面,需要刚好20MB的大小来存放结构体。这相对于4G的内存根本九牛一毛。

有些页是有特定用途的。比如内存中有些页是专门用于DMA的。
内核使用区的概念将具有相似特性的页进行分组。区是一种逻辑上的分组的概念,而没有物理上的意义。
区的实际使用和分布是与体系结构相关的。在x86体系结构中主要分为3个区:ZONE_DMA,ZONE_NORMAL,ZONE_HIGHMEM。
ZONE_DMA区中的页用来进行DMA时使用。ZONE_HIGHMEM是高端内存,其中的页不能永久的映射到内核地址空间,也就是说,没有虚拟地址。剩余的内存就属于ZONE_NORMAL区。
我们可以看一下描述区的结构体struct zone(在linux/mmzone.h中定义)。
这里写图片描述
这个结构体比较长,我只截取了一部分出来。
实际上不是所有的体系结构都定义了全部区,有些64位的体系结构,比如Intel的x86-64体系结构可以映射和处理64位的内存空间,所以其没有ZONE_HIGHMEM区。而有些体系结构中的所有地址都可用于DMA,所以这些体系结构就没有ZONE_DMA区。

内核中内存分配接口

我们现在已经大体了解了内核中的页与区的概念及描述。接下来我们就可以来看看内核中有哪些内存分配与释放的接口。在内核中,我们正是通过这些接口来分配与释放内存的。首先我们来看看以页为单位进行分配的接口函数。

获得页与释放页

获得页

获得页使用的接口是alloc_pages函数,我们来看下它的源码(位于linux/gfp.h中)
这里写图片描述
可以看到,该函数返回值是指向page结构体的指针,参数gfp_mask是一个标志,简单来讲就是获得页所使用的行为方式。order参数规定分配多少页面,该函数分配2的order次方个连续的物理页面。返回的指针指向的是第一page页面。
获得页的方式不只一种,我们还可以使用__get_free_pages函数来获得页,该函数和alloc_pages的参数一样,然而它会返回一个虚拟地址。源码如下:
这里写图片描述
可以看到,这个函数其实也是调用了alloc_pages函数,只不过在获得了struct page结构体后使用page_address函数获得了虚拟地址。
另外还有alloc_page函数与__get_free_page函数,都是获得一个页,其实就是将前面两个函数的order分别置为了0而已。这里不赘述了。

我们在使用这些接口获取页的时候可能会面对一个问题,我们获得的这些页若是给用户态用,虽然这些页中的数据都是随机产生的垃圾数据,不过,虽然概率很低,但是也有可能会包含某些敏感信息。所以,更谨慎些,我们可以将获得的页都填充为0。这会用到get_zeroed_page函数。看下它的源码:
这里写图片描述
这个函数也用到了__get_free_pages函数。只是加了一种叫做__GFP_ZERO的gfp_mask方式。所以,这些获得页的函数最终调用的都是alloc_pages函数。alloc_pages函数是获得页的核心函数。

释放页

当我们不再需要某些页时可以使用下面的函数释放它们:
__free_pages(struct page *page, unsigned int order)
__free_page
free_pages
free_page(unsigned long addr, unsigned int order)
这些接口都在linux/gfp.h中。
释放页的时候一定要小心谨慎,内核中操作不同于在用户态,若是将地址写错,或是order写错,那么都可能会导致系统的崩溃。若是在用户态进行非法操作,内核作为管理者还会阻止并发出警告,而内核是完全信赖自己的,若是在内核态中有非法操作,那么内核可能会挂掉的。

kmalloc与vmalloc

前面讲的那些接口都是以页为单位进行内存分配与释放的。而在实际中内核需要的内存不一定是整个页,可能只是以字节为单位的一片区域。这两个函数就是实现这样的目的。不同之处在于,kmalloc分配的是虚拟地址连续,物理地址也连续的一片区域,vmalloc分配的是虚拟地址连续,物理地址不一定连续的一片区域。这里依然需要特别注意的就是使用释放内存的函数kfree与vfree时一定要注意准确释放,否则会发生不可预测的严重后果。

slab层

分配和释放数据结构是内核中的基本操作。有些多次会用到的数据结构如果频繁分配内存必然导致效率低下。slab层就是用于解决频繁分配和释放数据结构的问题。为便于理解slab层的层次结构,请看下图
这里写图片描述
简单的说,物理内存中有多个高速缓存,每个高速缓存都是一个结构体类型,一个高速缓存中会有一个或多个slab,slab通常为一页,其中存放着数据结构类型的实例化对象。
分配高速缓存的接口是struct kmem_cache *kmem_cache_create (const char *name, size_t size, size_t align,unsigned long flags, void (*ctor)(void *))。
它返回的是kmem_cache结构体。第一个参数是缓存的名字,第二个参数是高速缓存中每个对象的大小,第三个参数是slab内第一个对象的偏移量。剩下的就不细说。
总之,这个接口函数为一个结构体分配了高速缓存,那么高速缓存有了,是不是就要为缓存中分配实例化的对象呢?这个接口是
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags)
参数是kmem_cache结构体,也就是分配好的高速缓存,flags是标志位。
抽象的介绍看着不直观, 我们看个具体的例子。之前我写过一个关于jbd2日志系统的博客,介绍过jbd2的模块初始化过程。其中就提到过jbd2在进行模块初始化的时候是会创建几个高速缓冲区的。如下:
这里写图片描述
我们看看第一个创建缓冲区的函数。
这里写图片描述
首先是断言缓冲区一定为空的。然后用kmem_cache_create创建了两个缓冲区。两个高速缓冲区就这么创建好了。看下图
这里写图片描述
这里用kmem_cache结构体,也就是jbd2_revoke_record_cache高速缓存实例化了一个对象。

总结

内存管理的linux内核源码我只分析了一小部分,主要是总结了一下内核分配与回收内存的接口函数及其用法。

块IO层(Linux内核源码分析)

背景

本篇博客重点分析块IO层,试图了解在linux源码中是如何实现块IO的。

基本知识

块设备与字符设备

块设备与字符设备都是物理外设。简单来说,块设备与字符设备的最大区别在于块设备都随机对数据片段进行读写的,而字符设备都以顺序对数据片段进行读写的。
比如磁盘、CD-ROM盘、闪存就属于块设备。键盘、串口属于字符设备。

扇区与块

扇区是块设备的最小寻址单元,也就是说,是物理上的最小单元。而块则不同,块是文件系统进行IO的最小单元,就是说,块是逻辑上的最小单元。所以,对于编程者来说,可能更为在意的是块的概念。
由此看来,扇区与块的关系就很明了了,块一定是扇区整数倍,而且一定要小于内存中一个页的长度。通过扇区的大小是512字节。

页与块

页是内存中的一个基本管理单元。
块是文件系统的一种抽象,内核执行的所有磁盘操作都是按照块进行的。

源码分析

我们可以先思考一下这个问题,从磁盘这样的块设备中要读取数据到内存中,从整体来看内核需要干什么?
首先是不是需要知道磁盘哪块的数据要放入到内存中的哪个位置?
然后需要知道是如何将数据放入内存中,也就是以怎样的方式?
知道了前面的之后,就需要考虑一个问题,内核中需要处理的块IO一定不只一个,如何在一小段时间内来了许多需要进行块IO的请求,这个时候该怎么处理呢?难道不需要管理随便的找块IO请求进行处理吗?
接下来我们就通过源码逐个分析这些问题。

buffer和buffer_head

从磁盘到内存,我们首先需要知道它们的对应关系是什么,前面基础里提到,内存的基本管理单元是页,磁盘的基本管理单元是块(逻辑上来说)。所以说的具体点就是,哪个块对应哪个页。
当一个块的数据被调入内存中,会有一个缓冲区与之对应,这个缓冲区就相当于块在内存中的表示。一个缓冲区就是buffer。buffer_head结构体就是用来描述它们之间对应关系的。
接下来我们就直接看看这个结构体(位于include/linux/buffer_head中)
这里写图片描述
70的b_bdev指向具体的块设备。
66中的b_blocknr指向逻辑块号。我们就知道了块是哪个块。
64的b_page指向缓冲区位于哪个页面之中。
68的b_data指向页面中的缓冲区开始的位置。
67的b_size显示了缓冲区的大小是多少。
从这几个域我们就知道了磁盘与内存的映射关系了。在内存页中,数据起始于b_data,到b_data+b_size。
其它还有一些域,如b_state显示了buffer的状态。b_count表示了这个buffer_head的被用次数,若被用了就需要给它进行原子加1操作,这样其它地方就不能再重复使用了。
显然,只知道内存与磁盘数据的对应关系还不行,我们还需要知道在内存中的具体的区域,也就是需要知道数据的容器。这个容器就是结构体bio。

bio结构体

我们先来直接看看bio结构体的源码。
这里写图片描述
95行bi_io_vec指向了一个bio_vec结构体数组。这个bio_vec结构体是一个比较关键的东西。我们看一下这个结构体。
这里写图片描述
此结构体中的page大家应该比较熟悉,这个内存中的一个页面。bv_offset表示页中数据的偏移量,bv_len表示这个数据的长度。这下我们应该明白了。bio结构体拥有bio_vec结构体数组,每个结构体包含了一个页面中的一些“片段”。而这“片段”就是之前buffer_head描述的磁盘与内存中对应的块的连续数据。换句话说,“片段”由连续的内存缓冲区组成。
现在再回过头来看bio结构体。
既然知道了bi_io_vec指向bio_vec结构体数组的首地址。那么肯定就得知道数组的长度与当前正操作的bio_vec的索引。
这就是72行bi_vcnt域和73行bi_idx域。bi_vcnt记录数组的数量,bi_idx记录当前bi_vec结构体的索引数。
至此,磁盘与内存的映射关系的结构体我们了解了,块在内存中的数据容器的结构体我们也了解了。现在有个问题,若内核中有多个块IO请求,那么这些IO请求就按照先来先处理的方式来进行吗?很显然这样不合适,我们需要用一些IO调度程序来管理这些IO请求。具体的IO调度程序这里就暂时先不讲了。以后有时间再分析具体的IO调度程序。