关于之前的《由mmap引发的SIGBUS》问题,这几天又做了一些较深入的探索,整理如下。
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”,那么其他用户就没法登录了。