由mmap引发的SIGBUS

一直以来都觉得使用mmap读文件是非常高效、非常优雅的做法(参见《 从"read"看系统调用的耗时》)。mmap之后,就可以通过内存访问的方式访问到文件里的内容,省去了read这样的系统调用。
却不曾想过,mmap以后,如果读文件出错会发生什么……

今晚看到一篇介绍apache bug的文章,里面说到,apache使用mmap来实现对静态文件的访问。在读文件之前,apache使用stat系统调用得知了文件的长度,然后按照此长度读取已经被映射在某个内存区间上的文件。
然而如果在读静态文件(内存访问)的过程中,文件被外部势力修改了,导致文件长度被减小。则apache可能访问到映射文件之外的内存(本来这块内存是在映射文件之内的,但是现在文件减小了),导致进程收到SIGBUS信号,然后崩溃。

内核代码追踪

真的会存在这样的情况吗?在好奇心驱使下,看了看相关的内核代码。(以下,关于内存管理方面的细节请参阅《 linux内存管理浅析》。)

首先是mmap的调用过程,考虑最普遍的情况,一个vma会被分配,并且与对应的file建立联系。

mmap_region()
......
    vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
    ......
    if (file) {
        ......
        vma->vm_file = file;
        get_file(file);
        error = file->f_op->mmap(file, vma);
        ......
    } else if (vm_flags & VM_SHARED) {
    ......

这里是通过file->f_op->mmap函数来“建立联系”的,而一般情况下,这个函数等于generic_file_mmap。

generic_file_mmap()
    ......
    vma->vm_ops = &generic_file_vm_ops;
    vma->vm_flags |= VM_CAN_NONLINEAR;
    ......

其中:

struct vm_operations_struct generic_file_vm_ops = {
    .fault  = filemap_fault,
};


注意这里对vma->vm_ops的赋值,下面会用到。然后,mmap就完成了,仅仅是建立了vma,及其与file的对应关系。没有分配内存、更没有读文件。

接下来,当对应的虚拟内存被访问时,将触发访存异常。内核捕捉到异常,再完成内存分配和读文件的事情。(其中细节还是详见《 linux内存管理浅析》。)
do_page_fault就是内核用于捕捉访存异常的函数。其中内核会先确认引起异常的内存地址是合法的,并且找出它所对应的vma(如果找不到就是不合法)。然后分配内存、建立页表。对于本文中描述的mmap映射了某个文件的这种情况,内核还需要把文件对应位置上的数据读到新分配的内存上,这个工作主要是由vma->vm_ops->fault来完成的。前面我们看到vma->vm_ops是如何被赋值的了,而且这个vma->vm_ops->fault就等于filemap_fault。

filemap_fault()
    ......
    size = (i_size_read(inode) + PAGE_CACHE_SIZE - 1) >> PAGE_CACHE_SHIFT;
    if (vmf->pgoff >= size)
        return VM_FAULT_SIGBUS;
    ......

这个函数做的第一件事情就是检查要访问的地址偏移(相对于文件的)是否超过了文件大小,如果超过就返回VM_FAULT_SIGBUS,这将导致SIGBUS信号被发送给进程。

用户程序验证

虽然看到内核代码就是这么实现的了,写个用户程序来验证一下总会让人更信服。一个简单的测试程序如下:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/mman.h>
#include <unistd.h>
#define FILESIZE 8192
void handle_sigbus(int sig)
{
    printf("SIGBUS!\n");
    _exit(0);
}
void main()
{
    int i;
    char *p, tmp;
    int fd = open("tmp.ttt", O_RDWR);
    p = (char*)mmap(NULL,FILESIZE, PROT_READ|PROT_WRITE,MAP_SHARED, fd, 0);
    signal(SIGBUS, handle_sigbus);
    getchar();
    for (i=0; i<FILESIZE; i++) {
        tmp = p[i];
    }
    printf("ok\n");
}

在执行这个程序前:
kouu@kouu-one:~/test$ stat tmp.ttt
File: "tmp.ttt"
Size: 239104     Blocks: 480        IO Block: 4096   普通文件

把程序跑起来,显然8192大小的内存是可以映射的。然后程序会停在getchar()处。
kouu@kouu-one:~/test$ echo "" > tmp.ttt
kouu@kouu-one:~/test$ stat tmp.ttt
File: "tmp.ttt"
Size: 1          Blocks: 8          IO Block: 4096   普通文件


现在我们将 tmp.ttt弄成1字节的。然后给程序一个输入,让它从getchar()返回。
kouu@kouu-one:~/test$ ./a.out

SIGBUS!


立刻,程序就收到SIGBUS信号了。

解决办法

这样的问题在用户态有办法解决吗?我的理解是:没有!

或许你会说,为什么不在每次读之前都取一下文件大小,以确保不越界呢?读文件是通过读内存来进行的,那么应当每读一个字就检查一下吗?这样做的话效率将大打折扣,mmap还有什么意义?
即便如此,“检查通过”与“读操作”并不是原子的,这两个操作之间还是可能存在文件被缩小的问题(尽管可能性变小了)。

所以,目前使用mmap的程序是会存在这样的风险,而收到SIGBUS信号。

是否你异想天开,打算把SIGBUS给捕捉了,然后忽略掉呢?
可以试一下上面的程序,在handle_sigbus函数中把_exit一句注释掉,看看会有什么样的结果。
其结果就是,handle_sigbus会重复重复再重复地被调用,就像一个死循环。为什么会这样呢?因为如果在handle_sigbus函数中把收到SIGBUS的事情给忽略了,内核也就会从前面提到的访存异常中返回,回到用户态,然后CPU会重新执行引起异常的那条指令(这条指令因为异常而未被执行完,必须得重新执行)。
正常情况下,这个时候页面已经分配了、页表已经建立了、文件也读好了。重新执行引起异常的指令时,就不会再引起异常了。但是现在这些条件不满足,这条指令还会引起异常,于是又走到上面讲的那一套流程,然后又触发SIGBUS,然后又被忽略……于是就成了死循环。

所以,面对这种情况,用户程序是没招的。
或许在打开文件之后,mmap之前,先给文件加一个强制锁( 百度一下?),这是一种解决办法。但是使用强制锁的限制很多(文件系统要支持、mount时要特殊处理,还要给文件加SGID),并且锁本身很黄很暴力(确实可以阻止别人写入,但是如果程序BUG、句柄泄漏,别人就真没法改了),据说移植性又不好。这一招不到万不得已还是别使……

那么内核程序呢?

如果用户程序通过read来读文件,则每次读文件都是通过系统调用来进行的。当发生错误的时候,read系统调用可以尽可能地返回失败(而不必武断地发出SIGBUS信号),然后让程序决定下一步该怎么办。
但是像mmap这样,是以读内存的方式来读文件。有什么机制让你选择读内存失败了该怎么办吗?没有,只能是“不成功,便成仁”。(好比你写下“i=j;”这么一句,不可能还有办法检查读取j或者写i是否成功。)

然而,我不知道内核为什么要在判断访问文件越界时抛出SIGBUS,或许有些东西我没能理解透彻。
在这个地方(filemap_fault函数中),如果发现访问越界,是否可以返回一个0页面,让它给映射上呢(也就是说,如果读越界,读到的内容就是0)。(这个0页面在内核中其实也是存在的,页面的内容全是0,当程序去读没有被映射的页面时,这个0页面就映射给它,而并不用分配新的页面。因为页面都没映射,显然没被写过,也就是说这些内存没有初值,所以默认都填0了。)
并且,这里的访问越界,我觉得,应该没有什么危害。因为再怎么越界,都不会越过mmap时创建的那个vma,这些地址应该说都是合法的。

你可能感兴趣的:(apache,linux,cache,struct,File,Signal)