进程地址空间(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调度程序。

ext4日志系统分析(二)

一、背景

上篇博客介绍了jbd2模块的安装与卸载。同时也讲了安装时初始化函数会在/proc/fs下创建一个jbd2目录。这篇博客打算介绍一下在jbd2模块被安装后内核是如何对日志系统进行初始化的,以及jbd2目录下的文件是从哪里来的。这里所涉及的东西较多,所以我只挑重点功能函数来讲。

二、jbd2目录

不知道大家还记得吗,上一篇博客我提到在安装jbd2模块时,内核会在/proc/fs下创建一个jbd2目录。这个目录具体做什么用到我并没有讲到。现在我们来看一下这个目录中有什么东西。
这里写图片描述
这个sda1-8目录表示日志存储在这个目录中,它位于分区sda1的inode号为8的文件中。进到该目录中。
这里写图片描述
这里有个info文件。打开看看内容。
这里写图片描述
这个文件中都是关于日志的基本信息。我们现在就通过源码来分析下这些信息从何而来。

三、源码分析

我们首先来看一下ext4_load_journal函数:
这里写图片描述
这里写图片描述
这个函数是在ext4_fill_super函数中被调用的。这两个函数都位于fs/ext4/super.c文件中。我们知道,这个文件中都是与超级块相关的函数,ext4_fill_super是文件系统为每个分区初始化super_block时调用的。而ext4_load_journal函数则是用来装载日志系统的。这个函数的重点我用红线标出。分别是初始化日志目录与初始化日志分区。
这是由于ext4文件系统可以使用目录作为日志,也可以使用一个分区来作为目录。一般的个人电脑linux都是用目录作为日志的。比如我的linux:
这里写图片描述
jbd2目录下有一个目录sda1-8。指的是日志存储于sda1分区的inode为8的文件中。如果没有inode号,指的是该文件系统用一个分区来作为日志。
由于我的linux中的日志是由在目录中记录的,那么我就来看看第一个函数(函数定义在fs/ext4/super.c中):
这里写图片描述
这里大部分的代码都是在检查错误,比如inode是否在磁盘上,是否可用。重点在于被红线标出的部分,此函数用于初始化日志inode。我们来看看这个函数(函数被定义在fs/jbd2/journal.c中):
这里写图片描述
我看到了一个熟悉的函数jbd2_stats_proc_init,顾名思义,这个函数应该就是用来初始化jbd2目录中的文件的。进去看看(该函数被定义在fs/jbd2/journal.c中):
这里写图片描述
可以看到,这里info被创建,且是一个只读文件。我们再进被标出的结构体。
这里写图片描述
可以看到,这里定义了对文件的操作函数。由于我们主要用的是打开和读取文件操作。所以我们可以进我们感兴趣的函数中一窥。这里看一下jbd2_seq_info_open函数。
这里写图片描述
然后看一下打开操作的结构体。
这里写图片描述
这里重点在被红线标出的函数。进去看一下。
这里写图片描述
此函数把日志的统计信息写到打开文件的private_data指向的seq_file里。
上面的这些结构体和函数都是在fs/jbd2/journal.c中。
看完了打开操作,大家可以自己再看一下读操作。我这里就不展示了。
上面我在各函数间跳的比较多,可能大家看的眼花缭乱的。我这里制作一张图来展示一下上面介绍的这些函数之间的调用关系。
这里写图片描述
我这篇博客讲的是左边的这条线。右边的是分区作为日志的路线。当然我这里讲的比较粗线条,只是大概讲了一下info中的信息是从哪里来的。更细的东西还得继续深入分析源码才行。

四、总结

本篇博客主要分析了jbd2目录下的文件信息的来源。

ext4日志系统分析(一)

一、背景

ext4文件系统用的是jbd2日志系统。本篇博客从内核源码着手,大致分析jbd2日志系统。

二、源码分析

我这里用的是source insight工具来分析linux内核的源码。
内核版本为3.10.0。
首先我们搜索一下jbd2关键字。可以看到如下图所示:
这里写图片描述
可以看到,在fs/jbd2中存在6个文件。这个fs/jbd2中存的就是jbd2日志系统的代码。
一步一步来,先看看插入jbd2模块时系统做了什么工作。
插入模块的函数在Journal.c中被定义。位于2611-2640行。
(这个图片下面经常要用到,用到时记得往回翻看!)
这里写图片描述
插入模块时运行的是journal_init函数,退出模块时运行的是journal_exit函数。我们先来看看插入模块时初始化函数journal_init做了什么。

第2615行:

宏定义检查journal_superblock_s结构体的大小是否等于1024字节。
若不等于1024字节,就会报错。
可以进入这个结构体看看,
这里写图片描述
0x0400的大小表示这个结构体就是1024字节。如果这个结构体不是1024字节难道不该报错么?
这个结构体应该表示的是日志系统的固有属性。

第2617行:

journal_init_caches()函数
初始化日志所要用到的缓冲区,看看这个函数。
这里写图片描述
这个函数中调用了四个函数,都是用来初始化cache的。
分别为:
revoke_cache
head_cache
handle_cache
transaction_cache
以revoke_cache为例,我们看看它的函数
这里写图片描述

208-209:

断言jbd2_revoke_record_cache和jbd2_revoke_table_cache一定是空的。非空则报错。

211:

给revoke_record创建cache,使用SLAB分配机制。

213:

创建cache失败则跳到222销毁revoke_cache。

216:

给revoke_table创建cache,使用SLAB分配机制。

218:

创建cache失败则跳到224返回错误信息。
接下来我们回到journal_init函数继续来讲插入journal模块时运行的函数
这个时候假设cache已经创建成功了。会给ret返回0
接下来运行的就是jbd2_create_jbd_stats_proc_entry函数。

2619(往上翻我刚才说的那个图)

这个函数用来在/proc/fs下创建jbd2目录。jbd2用来存储日志的状态信息。
至于这些状态信息从哪里来的以后再说。这里只是创建了这个目录。
我们可以具体的看一下这些源码:
这里写图片描述
我们可以看到有两个jbd2_create_jbd_stats_proc_entry函数
一个是在/pro/fs下创建jbd2,一个是空循环函数。看2535、2550、2555可以知道这是内核中常用到的条件编译。#ifdef CONFIG_PROC_FS表示如果proc文件系统则编译下面的这段代码,否则,没有/proc目录,那么肯定也就无法创建jbd2,则执行空循环函数。
好了,接着讲journal_init函数。
之前我们说到,如果返回的ret等于0则表示初始化cache成功,接着会创建目录,那如果ret不等于0,也就是说cache的初始化失败了呢?(忘了ret是干吗的可以翻看前面第2个图片的代码)
这时就会执行jbd2_journal_destroy_caches函数

2621(往上翻我刚才说的那个图)

很明显,这个函数是用来销毁cache的,也就是说,cache创建失败了,那么严谨起见,不能就这么退出了,要先将cache销毁掉再报错。
看下该函数:
这里写图片描述
之前创建的四个cache在这里都要被销毁。那么,最后一个destroy_slabs函数是干什么的呢?我们可以进入这个函数看一下究竟。
这里写图片描述
为什么我连着上面一大段解释也要截图出来呢?是因为仅仅看此函数是看不出来究竟的。大家可以阅读上面的解释,那么此函数的作用就一目了然。
2171行可以看到,这些cache是在挂载时开辟出来的,用于复制缓冲区的数据。而在jbd2卸载时这些cache将被释放。也就是这个函数用来释放这些cache。
journal_init函数解释到这里先告一段落。
接下来我们看一下卸载模块时运行的函数journal_exit。
这里写图片描述
这里有一个条件编译,如果是JBD2的调试模式运行2629-2631,否则只运行其余部分。endif后面的代码就不用过多解释了,也就是删除目录与刚才讲过的cache的销毁。

2628

这段代码表示编译时是JBD2的调试模式。

2629

这是对nr_journal_heads的一个读原子操作。
我们看看这个变量。
这里写图片描述
这里写图片描述
2289行定义了这个变量是原子变量,初值为0
2326行表明,如果分配了一次journal_head,那么就会对它进行原子操作加1
2343行表明,如果释放了一次journal_head,那么就会对它进行原子操作减1
之前提过journal_head_cache,以后再细讲。

2631

这段代码表明,JBD2日志系统泄漏了n个journal_head。n是上面从nr_journal_heads中读出的数值。

三、总结

到这里jbd2模块装载与卸载时做了什么也就大致分析完了。接下来的博客会继续分析ext4的日志系统。

内核中的同步

一、内核中同步的问题

假设我们把内核比作一个服务器(我是说假设),那么正在CPU上运行的进程、发出中断请求的外部设备就相当于一个客户端,客户端不断访问服务器,时间也不一定。就像是服务器会相应客户端的请求一样,内核也会响应进程与外设的请求。但同样,时间也不一定,请求的顺序也不一定。内核中有许多的共享资源,这些资源可以被不同进程所访问。这时候就会出现一个“管理”的问题。就像是大学中的班级占用教室上课一样也需要管理,如果不进行妥善管理,那么就有可能出现一个班级正在上课而另一个班级的学生也要用这个教室,这个时候就会出现冲突。而教务处就起着管理的作用,由教务处安排教室在什么时间被什么班级占用。这里的教务处就像是内核,班级就像是访问共享资源的进程,教室就像是内核中的共享资源。

二、临界区

临界区就是访问和操作共享数据的代码段。
要注意临界区指的是代码段。
我这里举个具体的例子来说:
假设有内核任务a与内核任务b,这两个任务都访问同一个共享资源变量i,
执行i++操作。
学过微机原理的各位应该知道这个i++大体上是分为三步进行的:
1)得到当前变量i的值并且拷贝到一个寄存器中。
2)将寄存器中的值加1。
3)把i的新值写回到内存中。
假设i的初值为1。当任务a、b并发执行i++操作时,期望的结果应该是一个任务完成1)2)3)之后,另一个任务再进行步骤1)2)3)。这样得出的结果应该是i=3。可实际上,有可能是这样的情况:
任务a执行1)->任务b执行1)->任务a执行2)->任务b执行2)->任务a执行3)->任务b执行3)
这样执行下来的结果是i=2。
为什么会产生这样的结果呢?因为任务a加好的结果还没有放回内存中,任务b从内存中取出的i值还是初值1。
这里的i++实际上就是一个最简单的临界区的例子。它是一个代码段,是一个访问和操作共享数据i的代码段。
任务并发的操作共享数据可能导致意想不到的结果。甚者,我们都知道内核中有各种各样的链表结构,如果并发的操作链表则可能会破坏链表的结构,结果更是可怕。所以,我们应该思考如何来保护这些对象呢?如何来管理并发任务对共享数据的访问呢?

三、内核同步措施

我这里只简要的介绍三种内核同步措施。

1 原子操作

Linux内核提供了一些专门的函数和宏,这些函数和宏作用于atomic_t类型的变量。具体的可以查阅相关资料。

2 自旋锁

自旋锁是专为防止多处理器并行而引入的一种锁。
自旋锁最多只能被一个内核任务持有,如果一个内核任务试图请求一个已被持有的自旋锁,那么这个任务就会一直进行忙循环,也就是旋转,等待锁的重新可用。如果锁没有被持有,那么请求它的内核便立即得到它并且继续执行。自旋锁可以在任何时刻防止多于一个的内核任务同时进入临界区。因此这种锁可有效的避免多处理器上并行运行的内核任务竞争共享资源。具体自旋锁的使用可以查阅相关资料。

3 信号量

信号量是一种睡眠锁,也就是说,它也是一种锁机制,不同于自旋锁,当等待资源的任务没有持有锁时选择睡眠等待而不是忙循环的访问。于是,对比于自旋锁,信号量更适合用于当需等待的时间较长时。因为这样就不会降低CPU的效率来不断循环。关于信号量的使用我下面会做一个实例。

四、生产者-消费者并发实例

对于并发访问与信号量的使用我这里举一个具体的例子:
想必大家都喝过牛奶,那么,生产牛奶的厂家和经销商之间是如何进行分工合作的呢?简单来说,牛奶厂家和经销商最希望的就是厂家生产一批牛奶,经销商刚好需要的就是这么多的一批牛奶。厂家第二天再生产一批牛奶,经销商需要的还是这么多的一批牛奶。以后的日子每天如此。当然,这是理想的情况,现实不会如此。现实中厂家生产的多了,经销商不需要这么多这就是供大于求。会造成浪费。而厂家生产的少了,经销商卖完了没东西卖,这就是供不应求,钱就赚的少了,效益没有最大化。总之都不是理想情况。
这里我们将此抽象为生产者-消费者模型,厂家(producer)将牛奶放入仓库(缓冲区),经销商(consumer)将牛奶从仓库(缓冲区)中取走去售卖。
话不多说,代码如下:

#include<linux/kthread.h>
#include<linux/init.h>
#include<linux/module.h>
#include<linux/semaphore.h>
#include<linux/sched.h>
#include<asm/atomic.h>
#include<linux/delay.h>
#define PRODUCT_NUMS 10

static struct semaphore sem_producer;
static struct semaphore sem_consumer;

static char product[12];
static atomic_t num;
static int producer(void *product);
static int consumer(void *product);
static int id = 1;
static int consume_num = 1;

static int producer(void *p)
{
  char *product = (char *)p;
  int i;

  atomic_inc(&num);
  printk("producer[%d] start...\n",current->pid);
  for(i = 0;i<PRODUCT_NUMS;i++){
    down(&sem_producer);
    snprintf(product,12,"2017-04-%d",id++);
    printk("producer[%d]produce %s\n",current->pid,product);
    up(&sem_consumer);
    if(id == 10)
      msleep(400);
  }
      msleep(400);
  printk("producer[%d] exit...\n",current->pid);
  return 0;
}

static int consumer(void *p)
{
  char *product = (char *)p;

  printk("consumer[%d] start...\n",current->pid);
  for(;;){
    down_interruptible(&sem_consumer);
    if(consume_num >= PRODUCT_NUMS * atomic_read(&num))
      break;
    printk("consumer[%d] consume %s\n",current->pid,product);
    consume_num++;
    memset(product,'\0',12);
    up(&sem_producer);
  }
  printk("consumer[%d] consume %s\n",current->pid,product);
  printk("consumer[%d] exit...\n",current->pid);
  return 0;
}

static int procon_init(void)
{
  printk(KERN_INFO"show producer and consumer\n");
  sema_init(&sem_producer,1);
  sema_init(&sem_consumer,0);
  atomic_set(&num,0);
  kthread_run(producer,product,"conpro_1");
  kthread_run(consumer,product,"compro_1");
  return 0;
}

static void procon_exit(void)
{
  printk(KERN_INFO"exit producer and consumer\n");
}

module_init(procon_init);
module_exit(procon_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("producer and consumer Module");
MODULE_ALIAS("a simplest module");

这是一个内核模块,所以需要用Makefile文件进行编译模块。我这里把Makefile文件内容也写在这里:
obj-m := procon.o
CURRENT_PATH := $(shell pwd)
LINUX_PATH := $(shell uname -r)
LINUX_KERNEL_PATH := /usr/src/kernels/$(LINUX_PATH)

all:
make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) modules
clean:
make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) clean

写好这两个程序后,使用make编译模块,
insmod procon.ko加载模块,然后用dmesg来观察结果。
我的结果是这样的:
这里写图片描述
噢,忘了说了,我的内核版本是3.10.0的。版本不同可能某些内核函数是不同的。但如果版本比我高的内核基本上是没问题的。