由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,这些地址应该说都是合法的。

SIGBUS的必要性

为什么内核在上述情况下要抛出SIGBUS信号呢?
原来这是POSIX的规定,引用一段:

The mmap() function can be used to map a region of memory that is larger than the current size of the object. Memory access within the mapping but beyond the current end of the underlying objects may result in SIGBUS signals being sent to the process. The reason for this is that the size of the object can be manipulated by other processes and can change at any moment. The implementation should tell the application that a memory reference is outside the object where this can be detected; otherwise, written data may be lost and read data may not reflect actual data in the object.

参阅mmap文档:http://www.opengroup.org/onlinepubs/000095399/functions/mmap.html


捕获异常的绝招

这个问题用户程序还有什么招吗?
发现还有一招,那就是使用异常处理的方法将这个错误catch住。在C下,我们可以使用sigsetjmp - siglongjmp来实现。(关于setjmp/longjmp,可参阅:http://www.yuanma.org/data/2007/0110/article_2084.htm

把之前的代码改造如下(类似的方法也可以用来捕获内存访问越界段错误等问题):

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/mman.h>
#include <unistd.h>
#include <setjmp.h>
#define FILESIZE 8192
sigjmp_buf env;
void handle_sigbus(int sig)
{
    printf("SIGBUS!\n");
    siglongjmp(env, 1);
}
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();
    if (!sigsetjmp(env, 1)) {
        for (i=0; i<FILESIZE; i++) {
            tmp = p[i];
        }
    }
    else {
        printf("fault cached when i=%d\n", i);
    }
    printf("ok\n");
}

注意env参数的传递,看起来似乎有些奇怪。env是一个保存执行上下文(栈指针、指令指针、等)的结构,setjmp函数会在这个结构中填入当前的上下文信息。然而,调用setjmp时传递的居然不是&env,而是env!这是怎么回事呢?C可不支持引用传递的喔~
在libc里面,jmp_buf(env其类型)有个很奇怪的定义:typedef struct __jmp_buf_tag jmp_buf[1];
知道原因了吧,原来env是一个数组的名字呀~

按照同样的流程,先执行程序、再将文件缩小、再进行内存访问。得到的输出结果如下:

kouu@kouu-one:~/test$ ./a.out 

SIGBUS!
fault cached when i=4096
ok

参阅一段邮件列表:http://lkml.indiana.edu/hypermail/linux/kernel/0205.1/0525.html


像可执行文件那样“text busy”?

在CU论坛上与网友讨论中(见:http://linux.chinaunix.net/bbs/thread-1162037-1-1.html),又引出一个问题:进程所执行的可执行文件也是通过mmap进行映射的(可以通过cat /proc/$pid/maps来看到这些映射)。那么如果我们在进程的执行期间将文件改小,是不是进程也会收到SIGBUS而崩溃呢?

如果你有办法将文件改小的话,的确会这样。但是你会发现,当你重写或者拷贝覆盖一个正在执行的文件时,控制台会给出“text busy”的提示。linux内核保证了这个文件不可写。

那么这是怎么做到的呢?mmap映射普通文件时是否可以借鉴?
这是通过建立映射时的MAP_DENYWRITE选项来实现的。这个选项在mmap的过程中会被处理:

mmap_region()
    ......
    if (file) {
        ......
        if (vm_flags & VM_DENYWRITE) {
            error = deny_write_access(file);
            ......
        }
    ......

MAP_DENYWRITE选项会被转成vma上的标记VM_DENYWRITE。mmap时遇到这个标记会调用deny_write_access。

deny_write_access()
int deny_write_access(struct file * file)
{
    struct inode *inode = file->f_path.dentry->d_inode;

    spin_lock(&inode->i_lock);
    if (atomic_read(&inode->i_writecount) > 0) {
        spin_unlock(&inode->i_lock);
        return -ETXTBSY;
    }
    atomic_dec(&inode->i_writecount);
    spin_unlock(&inode->i_lock);

    return 0;
}

如果文件正在被写(inode->i_writecount大于0,可能存在多个写者),则映射失败。因为现在要做的是禁止别人写,但是别人先到一步,这就没办法了。
否则(inode->i_writecount小于等于0),让inode->i_writecount自减1。inode->i_writecount的值小于0时表示文件已被“deny write”。而inode->i_writecount还可能小于-1,因为有多个进程同时让它“deny write”。只有等它们都解除禁止时,文件才能够被写。

当一个文件被“deny write”之后,其他进程若想修改它,则在open这个文件的时候就会因为无法通过“deny write”的检查,而得到相应的错误码。
检查函数get_write_access跟deny_write_access正好是反过来的:

get_write_access()
int get_write_access(struct inode * inode)
{
    spin_lock(&inode->i_lock);
    if (atomic_read(&inode->i_writecount) < 0) {
        spin_unlock(&inode->i_lock);
        return -ETXTBSY;
    }
    atomic_inc(&inode->i_writecount);
    spin_unlock(&inode->i_lock);

    return 0;
}

这样,试图以写模式打开一个已经被“deny write”的文件,就将会被阻止。文件既然不能被打开,也就不能被写了。

然而,不幸的是,MAP_DENYWRITE选项在mmap系统调用里面是会被忽略的,只有在内核内部使用do_mmap时才能被使用(比如exec系列的系统调用中,在加载可执行文件时,就会调用do_mmap,并使用MAP_DENYWRITE选项)。
就连动态链接库也没法幸免(它们也是由库函数通过系统调用mmap来映射的。奇怪的是,为什么不用uselib系统调用呢?),搜到一篇康神的文章在说这个事情:http://blog.kangkang.org/index.php/archives/49

那么为什么要忽略mmap系统调用时传递的MAP_DENYWRITE选项呢?man mmap,可以看到这么一段:
MAP_DENYWRITE
    This  flag  is ignored.  (Long ago, it signaled that attempts to write to the underlying file should fail with ETXTBUSY. But this was a source of denial-of-service attacks.)

指定MAP_DENYWRITE选项可能引起一些Dos,这里指的是:一个普通用户可以使整个系统在某些方面拒绝服务。典型的做法是:用户以MAP_DENYWRITE选项mmap某个日志文件,于是需要写这个日志文件的应用程序将无法正常工作。
比如,login程序在用户登录时会写utmp日志(一般在/var/run/utmp),如果这个文件被某个用户“deny write”,那么其他用户就没法登录了。

参阅下面一篇文章的讨论:http://lwn.net/2001/1011/kernel.php3
原文地址:https://www.cnblogs.com/hehehaha/p/6332790.html