open, openat, name_to_handle_at, open_by_handle_at, open_tree系统调用

openopenat

系统调用号

open为2,openat为257。

函数原型

内核接口

asmlinkage long sys_open(const char __user *filename, int flags, umode_t mode);
asmlinkage long sys_openat(int dfd, const char __user *filename, int flags, umode_t mode);

glibc封装

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
int openat(int dirfd, const char *pathname, int flags);
int openat(int dirfd, const char *pathname, int flags, mode_t mode);

简介

我们知道,绝大多数文件相关的系统调用都是直接操作文件描述符(file descriptor),而openopenat这两个系统调用是一种创建文件描述符的方式。open系统调用将打开路径为filename的文件,而openat则将打开相对描述符为dirfd的目录,路径为filename的文件。

详细来说,openopenat的行为是

  • filename是绝对路径
    • open打开位于filename的文件
    • openat打开位于filename的文件,忽略dirfd
  • filename是相对路径
    • open打开相对于当前目录,路径为filename的文件
    • openat打开相对于dirfd对应的目录,路径为filename的文件;若dirfd是定义在fcntl.h中的宏AT_FDCWD,则打开相对于当前目录,路径为filename的文件。

接着,是“怎么打开”的问题。openopenat的参数flags, mode控制了打开文件的行为(mode详情请见creat系统调用。TODO:增加超链接)。

flags

用于打开文件的标志均定义在fcntl.h头文件中。

文件访问模式标志(file access mode flag)
  • O_RDONLY

    以只读方式打开。创建的文件描述符不可写。

  • O_WDONLY

    以只写方式打开。创建的文件描述符不可读。

  • O_RDWR

    以读写方式打开。创建的文件描述符既可读也可写。

  • O_EXEC

    以只执行方式打开。创建的文件描述符只可以被执行。只能用于非目录路径。

  • O_SEARCH

    以只搜索方式打开。创建的文件描述符值可以被用于搜索。只能用于目录路径。

POSIX标准要求在打开文件时,必须且只能使用上述标志位中的一个。而glibc的封装则要求在打开文件时,必须且只能使用前三个标志位(只读、只写、读写)中的一个。

文件创建标志(file creation flag)

文件创建标志控制的是openopenat在打开文件时的行为。部分比较常见的标志位有:

  • O_CLOEXEC
    • 文件描述符将在调用exec函数族时关闭。

    • 我们知道,当一个Linux进程使用fork创建子进程后,父进程原有的文件描述符也会复制给子进程。而常见的模式是在fork之后使用exec函数族替换当前进程空间。此时,由于替换前的所有变量都不会被继承,所以文件描述符将丢失,而丢失之后就无法关闭相应的文件描述符,造成泄露。如以下代码

      #include <fcntl.h>
      #include <unistd.h>
      
      int main() {
          int fd = open("./text.txt", O_RDONLY);
          if (fork() == 0) {
              // child process
              char *const argv[] = {"./child", NULL};
              execve("./child", argv, NULL); // fd left opened
          } else {
              // parent process
              sleep(30);
          }
      
          return 0;
      }
      

      其中./child在启动30秒后会自动退出。

      在启动这个程序之后,我们使用ps -aux | grep child找到child对应的进程ID,然后使用

      readlink /proc/xxx/fd/yyy
      

      查看,其中xxx为进程ID,yyyfd中的任意一个文件。我们调查fd中的所有文件,一定能发现一个文件描述符对应text.txt。也就是说,在执行execve之后,子进程始终保持着text.txt的描述符,且没有任何方法关闭它。

    • 解决这个问题的方法一般有两种:

      • fork之后,execve之前使用close关闭所有文件描述符。但是如果该进程在此之前创建了许多文件描述符,在这里就很容易漏掉,也不易于维护。
      • 在使用open创建文件描述符时,加入O_CLOEXEC标志位:
        int fd = open("./text.txt", O_RDONLY | O_CLOEXEC);
        
        通过这种方法,在子进程使用execve时,文件描述符会自动关闭。
  • O_CREAT
    • filename路径不存在时,创建相应的文件。
    • 使用此标志时,mode参数将作为创建的文件的文件模式标志位。详情请见creat系统调用。TODO: 增加超链接
  • O_EXCL
    • 该标志位一般会与O_CREAT搭配使用。
    • 如果filename路径存在相应的文件(包括符号链接),则open会失败。
  • O_DIRECTORY
    • 如果filename路径不是一个目录,则失败。
    • 这个标志位是用来替代opendir函数的。TODO: 解释其受拒绝服务攻击的原理。
  • O_TRUNC
    • 如果filename路径存在相应的文件,且以写的方式打开(即O_WDONLYO_RDWR),那么将文件内容清空。
文件状态标志(file status flag)

文件状态标志控制的是打开文件后的后续IO操作。

  • O_APPEND

    • 使用此标志位时,在后续每一次write操作前,会将文件偏移移至文件末尾。(详情请见write)。
  • O_ASYNC

    • 使用信号驱动的IO。后续的IO操作将立即返回,同时在IO操作完成时发出相应的信号。
    • 这种方式在异步IO模式中较少使用,主要由于这种基于中断的信号处理机制比系统调用的耗费更大,并且无法处理多个文件描述符同时完成IO操作。参考What's the difference between async and nonblocking in unix socket?
    • 对正常文件的描述符无效,对套接字等文件描述符有效。
  • O_NONBLOCK

    • 后续的IO操作立即返回,而不是等IO操作完成后返回。

    • 对正常文件的描述符无效,对套接字等文件描述符有效。

    • 对于以此种方式打开的文件,后续的readwrite操作可能会产生特殊的错误——EAGAIN(对于套接字文件还可能产生EWOULDBLOCK)。

      这种错误的含义是接下来的读取或写入会阻塞,常见的原因可能是已经读取完毕了,或者写满了。比如说,当客户端发送的数据被服务器端全部读取之后,再次对以非阻塞形式打开的套接字文件进行read操作,就会返回EAGAINEWOULDBLOCK错误。

  • O_SYNCO_DSYNC

    • 使用O_SYNC时,每一次write操作结束前,都会将文件内容和元信息写入相应的硬件。
    • 使用O_DSYNC时,每一次write操作结束前,都会将文件内容写入相应的硬件(不保证元信息)。
    • 这两种方法可以看作是在每一次write操作后使用fsync
  • O_PATH

    • 仅以文件描述符层次打开相应的文件。
    • 我们使用openopenat打开文件通常有两个目的:一是在文件系统中找到相应的文件,二是打开文件对其内容进行查看或修改。如果传入O_PATH标志位,则只执行第一个目的,不仅耗费更低,同时所需要的权限也更少。
    • 通过O_PATH打开的文件描述符可以传递给close, fchdir, fstat等只在文件层面进行的操作,而不能传递给read, write等需要对文件内容进行查看或修改的操作。

其他注意点

此外,还有一些需要注意的。

在新的Linux内核(版本不低于2.26)中,glibc的封装open底层调用的是openat系统调用而不是open系统调用(dirfdAT_FDCWD)。我们可以在glibc源码的sysdeps/unix/sysv/linux/open.c中看到:

int
__libc_open (const char *file, int oflag, ...)
{
  int mode = 0;

  if (__OPEN_NEEDS_MODE (oflag))
    {
      va_list arg;
      va_start (arg, oflag);
      mode = va_arg (arg, int);
      va_end (arg);
    }

  return SYSCALL_CANCEL (openat, AT_FDCWD, file, oflag, mode);
}

open的glibc封装实际上就是系统调用openat(AT_FDCWD, file, oflag, mode)

openopenat返回的是文件描述符(file descriptor),在Linux内核中,还有一个概念为文件描述(file description)。操作系统会维护一张全局的表,记录所有打开的文件的信息,如文件偏移、打开文件的状态标志等。这张全局的表的表项即为文件描述。而文件描述符则是对文件描述的引用。

每一次成功调用openopenat,都会在文件描述表中创建一个新的表项,返回的文件描述符即是对该表项的引用。而我们常见的dup, fork等复制的文件描述符,则会指向同一个文件描述。

文件描述创建之后,不会随着文件路径的修改而修改。也就是说,当我们通过open打开了某个特定路径下的文件,然后我们将该文件移动到别的路径上,我们后续的read, write等操作仍能成功。

用法

我们在使用openopenat时,可以有如下的思考顺序:

  1. 打开文件的路径是绝对路径还是相对路径
    • 绝对路径使用openopenat都可以
    • 对于相对路径而言,如果相对于当前目录,则可以使用open,但大部分情况而言还是openat适用性更广(相对于当前目录可以传递AT_FDCWDdirfd参数)
  2. 打开文件是否需要读、写
    • 只需要读,flags加入标志位O_RDONLY
    • 只需要写,flags加入标志位O_WDONLY
    • 既需要读,又需要写,flags加入标志位O_RDWR
  3. 对于可能会产生子进程并使用exec函数族的程序,flags加入标志位O_CLOEXEC
  4. 如果需要对文件进行写入:
    • 如果需要在写之前清空文件内容,flags加入标志位O_TRUNC
    • 如果需要在文件末尾追加,flags加入标志位O_APPEND
    • 如果文件不存在的时候需要创建文件,flags加入标志位O_CREAT,并且传递相应的文件模式标志位给mode

以下几种都是合理的使用方式:

int fd1 = open(filename, O_RDONLY);
int fd2 = open(filename, O_RDWR | O_APPEND);
int fd3 = open(filename, O_WDONLY | O_CLOEXEC | O_TRUNC);

实现

openopenat的实现均位于fs/open.c文件中,与其相关的函数是do_sys_open:

long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
{
	struct open_flags op;
	int fd = build_open_flags(flags, mode, &op);
	struct filename *tmp;

	if (fd)
		return fd;

	tmp = getname(filename);
	if (IS_ERR(tmp))
		return PTR_ERR(tmp);

	fd = get_unused_fd_flags(flags);
	if (fd >= 0) {
		struct file *f = do_filp_open(dfd, tmp, &op);
		if (IS_ERR(f)) {
			put_unused_fd(fd);
			fd = PTR_ERR(f);
		} else {
			fsnotify_open(f);
			fd_install(fd, f);
		}
	}
	putname(tmp);
	return fd;
}

由其实现可知,无论路径是否一样,flag是否相同,open总会使用新的文件描述符。也就是说:

int a = open("./text.txt", O_RDONLY);
int b = open("./text.txt", O_RDONLY);

尽管调用参数一样,ab依然是不同的。

此外,这个函数调用了do_filp_open函数作为真正的操作,而其核心是实现在fs/namei.c的函数path_openat:

static struct file *path_openat(struct nameidata *nd, const struct open_flags *op, unsigned flags)
{
	struct file *file;
	int error;

	file = alloc_empty_file(op->open_flag, current_cred());
	if (IS_ERR(file))
		return file;

	if (unlikely(file->f_flags & __O_TMPFILE)) {
		error = do_tmpfile(nd, flags, op, file);
	} else if (unlikely(file->f_flags & O_PATH)) {
		error = do_o_path(nd, flags, file);
	} else {
		const char *s = path_init(nd, flags);
		while (!(error = link_path_walk(s, nd)) &&
			(error = do_last(nd, file, op)) > 0) {
			nd->flags &= ~(LOOKUP_OPEN|LOOKUP_CREATE|LOOKUP_EXCL);
			s = trailing_symlink(nd);
		}
		terminate_walk(nd);
	}
	if (likely(!error)) {
		if (likely(file->f_mode & FMODE_OPENED))
			return file;
		WARN_ON(1);
		error = -EINVAL;
	}
	fput(file);
	if (error == -EOPENSTALE) {
		if (flags & LOOKUP_RCU)
			error = -ECHILD;
		else
			error = -ESTALE;
	}
	return ERR_PTR(error);
}

可见对于大部分情况而言,就是按照符号链接的路径找到最终的文件,然后用do_last打开文件。

name_to_handle_atopen_by_handle_at

系统调用号

name_to_handle_at为303,open_by_handle_at为304。

函数原型

内核接口

asmlinkage long sys_name_to_handle_at(int dfd, const char __user *name, struct file_handle __user *handle, int __user *mnt_id, int flag);
asmlinkage long sys_open_by_handle_at(int mountdirfd, struct file_handle __user *handle, int flags);

glibc封装

#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int name_to_handle_at(int dirfd, const char *pathname, struct file_handle *handle, int *mount_id, int flags);
int open_by_handle_at(int mount_fd, struct file_handle *handle, int flags);

简介

name_to_handle_atopen_by_handle_atopenat的功能解耦成两部分:

filename ----openat----> file descriptor
filename ----name_to_handle_at----> file handle + mount id ----open_by_handle_at----> file descriptor

openat将输入的文件路径转化为文件描述符输出,而name_to_handle_at将输入的文件路径转化为文件句柄和挂载ID输出,而open_by_handle_at将输入的文件句柄和挂载ID转化为文件描述符输出。

这样做看似多此一举,但是在构造无状态的文件服务器时很有用。假设我们的文件服务器是使用openat作为打开文件的方式:

  1. 方案一
    • 步骤
      1. 服务器告诉客户端文件路径
      2. 客户端告诉服务器,想要修改的文件路径,和修改的内容
      3. 服务器使用openat打开相应文件路径,然后对文件进行修改
    • 假设服务器告诉客户端我们的文件路径为path/text.txt,在客户端发送修改指令之前,又有另一个客户端将这个文件移动到了path2/text.txt,那么客户端的修改就失败了,因为openat无法打开相应的文件
  2. 方案二
    • 步骤
      1. 服务器用openat打开文件路径,告诉客户端文件描述符
      2. 客户端告诉服务器,想要修改的文件描述符,和修改的内容
      3. 服务器对相应的文件描述符作出相应的修改
    • 由于文件描述符在文件移动后依然有效,所以这么做确实可以避免方案一的问题。但是,我们的服务器就成了一个有状态的服务器。因为程序一旦挂掉,那么所有文件描述符都会失效

因此,用openat并不能完美解决无状态文件服务器的问题。但是,我们用name_to_handle_atopen_by_handle_at的方案为:

  1. 服务器用name_to_handle_at打开文件路径,告诉客户端文件句柄和挂载ID
  2. 客户端告诉服务器,想要修改的文件句柄和挂载ID,和修改的内容
  3. 服务器使用open_by_handle_at打开相应的文件,获得文件描述符,进行进一步的修改

这一方案和方案二看起来很类似,但实际上有一点不同:文件句柄和挂载ID是由操作系统维护的,而不需要服务器维护。也就是说,只要是在同一操作系统中,所有的进程通过文件句柄和挂载ID打开的文件一定相同。

name_to_handle_at的具体行为,由dirfd, pathnameflags共同控制:

  • 如果pathname是绝对路径,则返回对应的文件句柄和挂载ID,忽略dirfd
  • 如果pathname是相对路径,则返回该路径相对于dirfd(若值为AT_FDCWD则是当前目录)对应的文件的文件句柄和挂载ID
  • 如果pathname解析出来是符号链接,且flags包含标志位AT_SYMLINK_FOLLOW,则继续找该符号链接引用的真实文件,并返回真实文件的文件句柄和挂载ID
  • 如果pathname为空字符串,且flags包含控制位AT_EMPTY_PATH,则返回对应于文件描述符dirfd的文件句柄和挂载ID(此时dirfd可以为任意文件类型的描述符,不一定是目录的文件描述符)

open_by_handle_atflags则和openatflags含义相同,为打开文件的方式。

用法

struct file_handle的定义为

struct file_handle {
	unsigned int  handle_bytes;
	int           handle_type;
	unsigned char f_handle[0];
};

其中f_handle字段是变长数组。当我们使用name_to_handle_at时,首先需要将handlehandle_bytes字段置0,传入后,name_to_handle_at将返回-1,同时errno会被置为EOVERFLOW,同时handlehandle_bytes字段会被置为其需要的大小,然后我们再根据这个大小分配相应的空间给handle,再次传入即可:

char filename[] = "./text.txt";
struct file_handle tmp_handle;
tmp_handle.handle_bytes = 0;
if (name_to_handle_at(filename, AT_FDCWD, &tmp_handle, NULL, 0) != -1 || errno != EOVERFLOW) {
	exit(-1); // Unexpected behavior
}
struct file_handle *handle = (struct file_handle *)malloc(tmp_handle.handle_bytes);
handle->handle_bytes = tmp_handle.handle_bytes;
int mount_id;
name_to_handle_at(filename, AT_FDCWD, handle, &mount_id, 0);

open_by_handle_at则相对比较简单:

int fd = open_by_handle_at(mount_id, handle, O_RDONLY);

实现

name_to_handle_atopen_by_handle_at的实现均位于fs/fhandle.c中。name_to_handle_at的核心为位于fs/exportfs/expfs.c中的exportfs_encode_inode_fh:

int exportfs_encode_inode_fh(struct inode *inode, struct fid *fid, int *max_len, struct inode *parent)
{
	const struct export_operations *nop = inode->i_sb->s_export_op;

	if (nop && nop->encode_fh)
		return nop->encode_fh(inode, fid->raw, max_len, parent);

	return export_encode_fh(inode, fid, max_len, parent);
}

open_by_handle_at的核心为位于fs/exportfs/expfs.c中的exportfs_decode_fh:

struct dentry *exportfs_decode_fh(struct vfsmount *mnt, struct fid *fid, int fh_len, int fileid_type, int (*acceptable)(void *, struct dentry *), void *context)
{
	const struct export_operations *nop = mnt->mnt_sb->s_export_op;
	// ...
	result = nop->fh_to_dentry(mnt->mnt_sb, fid, fh_len, fileid_type);
	// ...
}

由此可见,其关键在于export_operations这个结构体。通过位于内核源码Documentation/filesystems/nfs/exporting.rst的文档我们可以知道:

encode_fh (optional)

Takes a dentry and creates a filehandle fragment which can later be used to find or create a dentry for the same object. The default implementation creates a filehandle fragment that encodes a 32bit inode and generation number for the inode encoded, and if necessary the same information for the parent.

fh_to_dentry (mandatory)

Given a filehandle fragment, this should find the implied object and create a dentry for it (possibly with d_obtain_alias).

用于产生文件句柄的encode_fh函数指针,其默认实现是和inode直接相关的,所以确实可以看作是由操作系统来维护这个文件句柄的。

open_tree

系统调用号

428

函数原型

内核接口

asmlinkage long sys_open_tree(int dfd, const char __user *path, unsigned flags);

glibc封装

无glibc封装,需要手动调用syscall

简介

该系统调用的介绍在网络上较少,可以参考lkml.org

我们可以将该系统调用看作包含O_PATH标志位,用openopenat打开。也就是说,只在文件系统中标记该位置,而不打开相应的内容,产生的文件描述符只能供少部分在文件层次进行的操作使用。

flags包含标志位OPEN_TREE_CLONE时,情况会稍微复杂一些。此时,open_tree会产生一个游离的(detached)挂载树,该挂载树对应的就是dfdpath决定的路径上的子树。如果还包含AT_RECURSIVE标志位,则整个子树都将被复制,否则就只复制目标挂载点内的部分。OPEN_TREE_CLONE标志位可以看作mount --bind,而OPEN_TREE_CLONE | AT_RECURSIVE标志位可以看作mount --rbind

实现

位于fs/namespace.c文件中,其核心语句为

file = dentry_open(&path, O_PATH, current_cred());

也就是通过O_PATH标志位打开相应的文件。