Linux x86_64系统调用简介

如果要对Linux上的恶意软件进行细致地分析,或者想了解Linux内核,又或是想对Linux内核做进一步的定制,那么了解Linux的全部系统调用就是一个很好的帮助。在分析Linux上的恶意软件时,如果对Linux的系统调用不了解,那么往往会忽视某些重要的关键细节。因此,在充分了解Linux的系统调用之后,就能做到有的放矢,更好地达到我们的需求。

本仓库将详细记录每个Linux x86_64系统调用的功能、用法与实现细节。

环境

所有实现细节均基于v5.4版本的Linux内核,glibc-2.31版本的glibc。所记录的系统调用列表位于Linux内核源码的arch/x86/entry/syscalls/syscall_64.tbl。功能、用法基于其相应的manual page,可在man7.org中查看。涉及到验证代码的,则会在Ubuntu 20.04中进行验证。

如果仅想阅读本仓库的文章以及相应的测试代码,可以使用

git clone git@github.com:Evian-Zhang/introduction-to-linux-x86_64-syscall.git

如果想同时把相应版本的Linux内核源码与glibc源码一并下载,可以使用

git clone --recurse-submodules git@github.com:Evian-Zhang/introduction-to-linux-x86_64-syscall.git

对于在国内的网友,可以考虑使用清华大学开源软件镜像站:

git clone git@github.com:Evian-Zhang/introduction-to-linux-x86_64-syscall.git
cd introduction-to-linux-x86_64-syscall
git config submodule.linux.url https://mirrors.tuna.tsinghua.edu.cn/git/linux.git
git config submodule.glibc.url https://mirrors.tuna.tsinghua.edu.cn/git/glibc.git
git pull --recurse-submodules

构建

请安装mdBook后,在项目根目录下使用

mdbook build

请注意,如果您使用了--recurse-submodules命令克隆本仓库,从而在本地仓库中包含了全部的Linux和glibc源码,请不要使用上述命令构建。因为mdBook目前会将项目根目录中所有的文件都拷贝进输出的构建目录中。

系统调用对照表

每个系统调用名都超链接到了其在本仓库中对应的文章。

名称系统调用号头文件内核实现
read0unistd.hfs/read_write.c
write1unistd.hfs/read_write.c
open2fcntl.hfs/open.c
close3unistd.hfs/open.c
stat4sys/stat.hfs/stat.c
fstat5sys/stat.hfs/stat.c
lstat6sys/stat.hfs/stat.c
poll7poll.hfs/select.c
lseek8unistd.hfs/read_write.c
mmap9sys/mman.harch/x86/kernel/sys_x86_64.c
munmap11sys/mman.hmm/mmap.c
pread6417unistd.hfs/read_write.c
pwrite6418unistd.hfs/read_write.c
readv19sys/uio.hfs/read_write.c
writev20sys/uio.hfs/read_write.c
select23sys/select.hfs/select.c
mremap25sys/mman.hmm/mremap.c
msync26sys/mman.hmm/msync.c
epoll_create213sys/epoll.hfs/eventpoll.c
remap_file_pages216sys/mman.hmm/mmap.c
epoll_ctl232sys/epoll.hfs/eventpoll.c
epoll_wait233sys/epoll.hfs/eventpoll.c
openat257fcntl.hfs/open.c
newfstatat262sys/stat.hfs/stat.c
pselect6270sys/select.hfs/select.c
ppoll271poll.hfs/select.c
epoll_pwait281sys/epoll.hfs/eventpoll.c
eventfd284sys/eventfd.hfs/eventfd.c
eventfd2290sys/eventfd.hfs/eventfd.c
epoll_create1291sys/epoll.hfs/eventpoll.c
preadv295sys/uio.hfs/read_write.c
pwritev296sys/uio.hfs/read_write.c
name_to_handle_at303fcntl.hfs/fhandle.c
open_by_handle_at304fcntl.hfs/fhandle.c
preadv2327sys/uio.hfs/read_write.c
pwritev2328sys/uio.hfs/read_write.c
statx332linux/stat.hfs/stat.c
open_tree428fs/namespace.c

License

本仓库遵循CC-BY-4.0版权协议
作为copyleft的支持者之一,我由衷地欢迎大家积极热情地参与到开源社区中。Happy coding!

基础知识

在本仓库中,如无特殊说明,处理器指令集默认为x86_64指令集。

系统调用简介

在Linux中,内核提供一些操作的接口给用户态的程序使用,这就是系统调用。对于用户态的程序,其调用相应的接口的方式,是一条汇编指令syscall

比如说,创建子进程的操作,Linux内核提供了fork这个系统调用作为接口。那么,如果用户态程序想调用这个内核提供的接口,其对应的汇编语句为(部分)

movq $57, %rax
syscall

syscall这个指令会先查看此时RAX的值,然后找到系统调用号为那个值的系统调用,然后执行相应的系统调用。我们可以在系统调用列表中找到,fork这个系统调用的系统调用号是57。于是,我们把57放入rax寄存器中,然后使用了syscall指令。这就是让内核执行了fork

调用约定

提到这个,就不得不说Linux x86_64的调用约定。我们知道,系统调用往往会有许多参数,比如说open这个打开文件的系统操作,我们可以在include/linux/syscalls.h中找到其对应的C语言接口为

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

它接受三个参数。那么,参数传递是按照什么规定呢?事实上,当涉及到系统调用时,调用约定与用户态程序一般的调用约定并不相同。在System V Application Binary Interface AMD64 Architecture Processor Supplement的A.2.1节中我们可以看到:

  1. User-level applications use as integer registers for passing the sequence %rdi, %rsi, %rdx, %rcx, %r8 and %r9. The kernel interface uses %rdi, %rsi, %rdx, %r10, %r8 and %r9.
  2. A system-call is done via the syscall instruction. The kernel destroys registers %rcx and %r11.
  3. The number of the syscall has to be passed in register %rax.
  4. System-calls are limited to six arguments, no argument is passed directly on the stack.
  5. Returning from the syscall, register %rax contains the result of the system-call. A value in the range between -4095 and -1 indicates an error, it is -errno.
  6. Only values of class INTEGER or class MEMORY are passed to the kernel.

比较重要的是前五点。从这五点我们可以知道,如果要调用open系统调用,那么步骤是:

  1. pathname放入rdi寄存器
  2. flags放入rsi寄存器
  3. mode放入rdx寄存器
  4. open的系统调用号2放入rax寄存器
  5. 执行syscall指令
  6. 返回值位于rax寄存器

我们使用逆向工具查看汇编代码时,就是通过类似以上六步的方法,确定一个系统调用的相关信息的。

这个规范就称为内核接口的调用约定,可以从第一点就显著地看到,这个调用约定与用户态的程序是不同的。也就是说,如果我们用编译器直接编译

long sys_open(const char *pathname, int flags, mode_t mode);

那么,编译出来的可执行程序会认为,这个函数是用户态函数,其传参仍然是按 %rdi, %rsi, %rdx, %rcx, %r8, %r9的顺序,与内核接口不符。因此,gcc提供了一个标签asmlinkage来标记这个函数是内核接口的调用约定:

asmlinkage long sys_open(const char *pathname, int flags, mode_t mode);

当函数前面有这个标签时,编译器编译出的可执行程序就会认为是按内核接口的调用约定对这个函数进行调用的。详情可以看FAQ/asmlinkage

glibc封装

当然,我们平时写的代码中,99%不会直接用到上述的系统调用方法。当我们真的去写一个C程序时:

// syscall-wrapper-test.c
#include <unistd.h>

int main() {
    fork();
    return 0;
}

然后我们将其编译为汇编代码

gcc syscall-wrapper-test.c -S -o syscall-wrapper-test.S

只能看到这个指令:

callq fork

然后在整个汇编文件中都不会找到fork这个函数的实现。甚至我们如果将其编译为可执行程序

gcc syscall-wrapper-test.c -o syscall-wrapper-test

然后用逆向工具去反汇编,也会发现整个可执行程序中也不会有fork的实现,同时也不会找到任何对57这个系统调用号进行syscall的代码。

这是因为,我们在Linux上编写的程序,通常都会链接到glibc的动态链接库。我们用

ldd syscall-wrapper-list

查看其链接的动态链接库,就会看到

libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6

而glibc则提供了许多系统调用的封装。这使我们在编写程序的时候,并不需要直接和内核进行交互,而是借用glibc这层封装,更加安全、稳定地使用。关于glibc对系统调用的封装,详情请见官方文档SyscallWrappers

此外,glibc还提供一个特殊的封装——syscall:

#include <unistd.h>
long syscall(long number, ...);

这可以看作汇编指令syscall的封装。比如说,我们想自己实现一个open函数:

#include <unistd.h>
#include <sys/syscall.h>
#include <sys/types.h>

long my_open(const char *pathname, int flags, mode_t mode) {
    return syscall(SYS_open, pathname, flags, mode);
}

其中,SYS_open为一个宏,定义在sys/syscall.h头文件中,其值为2,也就是open系统调用的系统调用号。

当然,如果真的想在可执行程序中直接对内核进行系统调用,可以把glibc静态链接:

gcc syscall-wrapper-test.c -static -o syscall-wrapper-test

然后在syscall-wrapper-test这个可执行程序中就可以看到直接的syscall了。

对glibc的动态链接和静态链接各有利弊。对于恶意软件编写者来说,他们往往倾向于静态链接恶意软件。这是因为,分析者可以轻松地写一个动态链接库,将其使用的glibc中的API hook住,改变其行为,使它达不成目的。而如果静态链接,那么分析者只有通过修改内核等比较麻烦的方案才能改变其行为。而静态链接的坏处则在于,如果简单地使用-static选项进行静态链接,就是把整个库都链接进最终的可执行程序中。这会导致库中许多没有被用到的函数的代码也在可执行程序中,使可执行程序的体积增大。解决方案可以参考gcc的官方文档Compilation-options

内核接口

我们之前提到,在Linux内核中,可以在include/linux/syscalls.h文件中找到系统调用函数的声明(会加上sys_前缀)。而其实现则是使用SYSCALL_DEFINEn这个宏。比如说,我们在fs/open.c中可以看到

SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
	/* ... */
}

这代表:

  • 内核提供了一个接口,接受三个参数
  • 这个接口叫open
  • 第一个参数的类型是const char __user *,参数名为filename
  • 第二个参数的类型是int,参数名是flags
  • 第三个参数的类型是umode_t,参数名是mode

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标志位打开相应的文件。

close系统调用

系统调用号

3

函数签名

内核接口

asmlinkage long sys_close(unsigned int fd);

glibc封装

#include <unistd.h>
int close(int fd);

简介

close系统调用的作用是关闭一个文件描述符,使得其不再指向任何文件,同时该文件描述符也可供后续open等操作复用。

close系统调用在我们日常的文件操作中随处可见,功能也相对比较简单。但是,有几点需要注意:

首先,如果我们在open状态下删除(unlink)了某个文件,那对这个文件描述符的close操作的行为,则需要判断该文件被多少个文件描述符所引用。假设close的文件描述符是最后一个引用该文件的描述符,则close操作之后,该文件将被真正意义上的删除。

第二,close并不能确保我们在之前对文件写入的数据一定会写入到硬盘中。正如我们在write中提到的,如果要确保在close之前数据写入到硬盘,就使用fsync

第三,close如果失败,则返回-1,并且设置相应的errnoclose可能失败的原因比较重要,主要包括:

  • EBADF: fd不是一个有效的,处于打开状态的文件描述符
  • EINTR: close操作被信号中断
  • EIO: IO失败

虽然close失败后的行为和别的系统调用类似,但比较特殊的是,一般情况下,这种失败只能起到一种“告知”作用,我们不能再次使用close。这是因为在close的实现中,真正实施关闭文件的操作在整个过程的很前面,一般在文件真正关闭之前是不会产生错误的。因此,尽管close出错,但有可能这个文件描述符已经被关闭了。此时,再次调用close,如果该文件描述符没有被再次使用,则由于已经被关闭,所以会返回EBADF错误;否则,如果在再次调用close之前,该文件描述符被别的线程复用了,那就会不小心关闭了别的线程的有意义的文件描述符。

实现

close的实现位于fs/open.c中:

SYSCALL_DEFINE1(close, unsigned int, fd)
{
	int retval = __close_fd(current->files, fd);

	/* can't restart close syscall because file table entry was cleared */
	if (unlikely(retval == -ERESTARTSYS ||
		     retval == -ERESTARTNOINTR ||
		     retval == -ERESTARTNOHAND ||
		     retval == -ERESTART_RESTARTBLOCK))
		retval = -EINTR;

	return retval;
}

可见其核心为位于fs/file.c__close_fd函数:

int __close_fd(struct files_struct *files, unsigned fd)
{
	struct file *file;
	struct fdtable *fdt;

	spin_lock(&files->file_lock);
	fdt = files_fdtable(files);
	if (fd >= fdt->max_fds)
		goto out_unlock;
	file = fdt->fd[fd];
	if (!file)
		goto out_unlock;
	rcu_assign_pointer(fdt->fd[fd], NULL);
	__put_unused_fd(files, fd);
	spin_unlock(&files->file_lock);
	return filp_close(file, files);

out_unlock:
	spin_unlock(&files->file_lock);
	return -EBADF;
}

从第13行可以看到,最先进行的实际操作是在该进程的文件描述符列表删除该描述符,然后在第16行使用位于fs/open.cfilp_close函数对文件描述符对应的文件做扫尾工作:

int filp_close(struct file *filp, fl_owner_t id)
{
	int retval = 0;

	if (!file_count(filp)) {
		printk(KERN_ERR "VFS: Close: file count is 0\n");
		return 0;
	}

	if (filp->f_op->flush)
		retval = filp->f_op->flush(filp, id);

	if (likely(!(filp->f_mode & FMODE_PATH))) {
		dnotify_flush(filp, id);
		locks_remove_posix(filp, id);
	}
	fput(filp);
	return retval;
}

其核心也就是第11行的filp->f_op->flush,也就是如果文件系统有相应的flush操作,则对文件进行flush。在fs/ext4/file.c的512行就可以看到,我们在Linux中常用的EXT4系统,并没有定义相应的操作。也就是说,这里实际上也没做什么事。

从这个实现中我们可以验证两件事:

  • close整个过程的早期,文件描述符就已经失效了,不再表示一个有效的打开状态的文件描述符,且在其完成之前基本上不会产生失败。
  • 如果我们flush的操作失败了,是没有机会再次进行flush的,因为文件描述符先前已经失效了。

read, pread64, readv, preadv, preadv2系统调用

readpread64

系统调用号

read的系统调用号为0, pread64的系统调用号为17。

函数原型

内核接口

asmlinkage long sys_read(unsigned int fd, char __user *buf, size_t count);
asmlinkage long sys_pread64(unsigned int fd, char __user *buf, size_t count, loff_t pos);

glibc封装

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t pread(int fd, void *buf, size_t count, off_t offset);

简介

readpread是最基础的对文件读取的系统调用。read会从描述符为fd的文件中读取count个字节存入buf中,而pread则是从描述符为fd的文件中,从offset位置开始,读取count个字节存入buf中。如果读取成功,这两个系统调用都将返回读取的字节数。因此,这两个系统调用主要的区别就在于读取的位置,其它功能均类似。

有几点需要注意:

首先,是从哪开始读。pread64没有问题,就是从offset的位置开始读。而对于read,如果它读取的描述符对应的文件支持seek,那么它是从文件描述符中存储的文件偏移(file offset)处继续读。假设我们的文件对应的二进制数据为

1F 2E 3D 4C 5B 6A

我们先是用read读取了3个字节的内容,此时文件因为之前没有被读过,因此文件偏移为0,read将读到1F 2E 3D。然后,文件偏移就被更新为3。那么,我们接下来如果用read读取1个字节的内容,读到的将会是4C

因此,对于支持seek的文件来说,read是从文件偏移的位置继续读,pread是从offset的位置开始读。

我们知道,更改文件偏移有单独的系统调用lseek,因此,如果我们要从某个特定的位置读取数据,可以lseek+read,也可以pread。但是,系统调用实际上是一个复杂的耗时操作,所以pread就用一次系统调用解决了两个系统调用的问题。

第二,是读多少的问题。readpread读取的字节数一定不大于count,但有可能小于count。假设说我们的二进制文件只有上述6个字节。那么,如果我们read了8个字节,后2个字节自然是无法被读取的。因此,只能读取到6个字节,read也将返回6。除此之外,还有很多可能会让readpread读取的字节小于count。比如说,从一个终端读取(输入的字节小于其需求的字节),或者在读取时被某些信号中断。

此外,除了读的字节小于count之外,readpread还有可能读取失败。此时的返回值将是-1。我们可以用errno查看其错误。文件描述符不可读或无效(EBADF),buf不可使用(EFAULT),文件描述符是目录而非文件(EISDIR)等等,这些都有可能直接造成读取的错误。对于以非阻塞形式打开的文件,还可能返回EAGAINEWOULDBLOCK,详情请见open

第三,是读完当readpread读取结束后的工作。read会更新文件描述符中的文件偏移,它们读了多少字节,就向后移动多少字节。但是,值得注意的是,pread并不会更新文件偏移。pread不更新文件偏移这一点对于多线程的程序来说极其有用。我们知道,多条线程有可能共用同一个文件描述符,但文件偏移是存储在文件描述符中。如果我们在多线程中使用read,会导致文件偏移混乱;但是,如果我们使用pread,则会完满避免这个问题。

第四,是如何读。在Linux的哲学中,如何读并不是readpread能决定的,而是由文件描述符本身决定的。文件描述符在创建的时候,就决定了它将被如何读取,比如说是否阻塞等等。

用例

#include <unistd.h>
#include <fcntl.h>

int main() {
    int fd = open("./text.txt", O_RDONLY);
    if (fd < 0) {
        // open error
        exit(1);
    }
    char buf[64];
    ssize_t read_length = read(fd, buf, 64);
    if (read_length < 0) {
        // read error
        exit(1);
    }
    ssize_t pread_length = pread(fd, buf, 64, 233);
    if (pread_length < 0) {
        // pread error
        exit(1);
    }
    close(fd);
    return 0;
}

实现

readpread64的实现均位于fs/read_write.c。这两个系统调用的核心是__vfs_read函数,其实现为

ssize_t __vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
	if (file->f_op->read)
		return file->f_op->read(file, buf, count, pos);
	else if (file->f_op->read_iter)
		return new_sync_read(file, buf, count, pos);
	else
		return -EINVAL;
}

我们可以看到,它实际上是调用了file->f_op->read(file, buf, count, pos)函数。也就是我们上面讲到的,readpread怎么读,是由文件描述符决定的。而第二个条件分支的new_sync_read我们之后会提到。我们可以把这个操作看作是用C实现的C++的多态,file->f_op->read是一个函数指针,类似于一个虚函数。每一种文件类型都会定义自己的read的方法,而__vfs_read则是调用了这个虚函数的方法。

总的来说,完成了这个函数之后,就实现了文件读取功能。

readpread64的完整实现也很类似。read的实现主要是在ksys_read函数中,其实现为

ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
	struct fd f = fdget_pos(fd);
	ssize_t ret = -EBADF;

	if (f.file) {
		loff_t pos, *ppos = file_ppos(f.file);
		if (ppos) {
			pos = *ppos;
			ppos = &pos;
		}
		ret = vfs_read(f.file, buf, count, ppos);
		if (ret >= 0 && ppos)
			f.file->f_pos = pos;
		fdput_pos(f);
	}
	return ret;
}

可以看到,read会确定当前的文件偏移(第7行),然后从当前的文件偏移开始读取;读取完毕后,更新文件偏移(第14行)。

pread64的实现主要是在ksys_pread64函数中,其实现为

ssize_t ksys_pread64(unsigned int fd, char __user *buf, size_t count, loff_t pos)
{
	struct fd f;
	ssize_t ret = -EBADF;

	if (pos < 0)
		return -EINVAL;

	f = fdget(fd);
	if (f.file) {
		ret = -ESPIPE;
		if (f.file->f_mode & FMODE_PREAD)
			ret = vfs_read(f.file, buf, count, &pos);
		fdput(f);
	}

	return ret;
}

可以看到,它与read的实现主要的区别在于,它不需要读取当前的文件偏移,而是直接从pos处开始;读取完毕后,它也不会更新当前的文件偏移。

readv, preadvpreadv2

系统调用号

readv为19,preadv为295,preadv2为327。

函数原型

asmlinkage long sys_readv(unsigned long fd, const struct iovec __user *vec, unsigned long vlen);
asmlinkage long sys_preadv(unsigned long fd, const struct iovec __user *vec, unsigned long vlen, unsigned long pos_l, unsigned long pos_h);
asmlinkage long sys_preadv2(unsigned long fd, const struct iovec __user *vec, unsigned long vlen, unsigned long pos_l, unsigned long pos_h, rwf_t flags);

glibc封装后为

#include <sys/uio.h>
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t preadv(int fd, const struct iovec *iov, int iovcnt, off_t offset);
ssize_t preadv2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);

简介

我们在上面提到,pread除了在多线程中发挥大作用之外,也可以将两次系统调用lseek+read化为一次系统调用。而这一节所讲的系统调用,则是更进一步。readpread是将文件读取到一块连续内存中,那如果我们想要将文件读取到多块连续内存中(也就是说,有多块内存,内存内部连续,但内存之间不连续),就得多次使用这些系统调用,造成很大的开销。而readv, preadv, preadv2则是为了解决这样的问题。

首先,我们需要知道iovec的定义(位于include/uapi/linux/uio.h):

struct iovec
{
	void __user *iov_base;
	__kernel_size_t iov_len;
};

这实际上就是read的后两个参数,也就是内存中的目的地址,与需要读取的长度。

readv, preadv, preadv2的第二个参数iov是一个iovec结构体组成的数组,其元素个数由第三个参数iovcnt给出。这三个系统调用的作用就是“分散读”(scatter input),将一块连续的文件内容,按顺序读入多块连续区域中。

preadvpreadv2中,我们可以看到,其系统调用接口含有两个参数pos_lpos_h,但glibc封装后只有一个参数offset。这是因为,考虑到64位地址的问题,pos_lpos_h分别包含了offset的低32位和高32位。

此外,还需要注意,这里的读取虽然说是“向量化”,但实际上,缓冲区是按数组顺序处理的,也就是说,只有在iov[0]被填满之后,才会去填充iov[1]

同样类似readpread,这三个系统调用也是返回读取的字节数,同样可能会小于iov->iov_len之和。

readpread不同的是,这三个系统调用是原子性的,它们读取的文件内容永远是连续的,也就是说不会因为文件偏移被别的线程改变而混乱。比如说,我们想将文件中的内容读入三块缓冲区中。如果我们是使用三次read,但是在第一次read结束之后,第二次read开始之前,另外一个线程对这个文件描述符的文件偏移进行了改变,那么接下来的两次read读出的数据与第一次read读出的数据是不连续的。但是,如果我们用readv,读出的数据一定是连续的。

preadvpreadv2的区别,主要在于最后一个参数。它通过一些标志位来改变读取的行为。具体可以看其手册preadv2

关于文件偏移的更新,readvread一样,在结束之后会更新文件偏移;preadvpread一样,在结束之后不会更新文件偏移;对于preadv2来说,如果offset为-1,其会使用当前的文件偏移而不是前往指定的文件偏移,并且在结束后会更新文件偏移,但是如果其不为-1,则不会更新文件偏移。

用例

#include <sys/uio.h>
#include <fcntl.h>

int main() {
    int fd = open("./text.txt", O_RDONLY);
    if (fd < 0) {
        // open error
        exit(1);
    }
    char buf1[64], buf2[32], buf3[128];
    struct iovec iovecs[3];
    iovec[0] = (struct iovec){ .iov_base = buf1, .iov_len = 64 };
    iovec[1] = (struct iovec){ .iov_base = buf2, .iov_len = 32 };
    iovec[2] = (struct iovec){ .iov_base = buf3, .iov_len = 128 };
    ssize_t readv_length = readv(fd, iovecs, 3);
    if (readv_length < 0) {
        // readv error
        exit(1);
    }
    close(fd);
    return 0;
}

实现

这三者实现的核心为call_read_iter函数,位于include/linux/fs.h文件中:

static inline ssize_t call_read_iter(struct file *file, struct kiocb *kio, struct iov_iter *iter)
{
	return file->f_op->read_iter(kio, iter);
}

就像之前讲的一样,“怎么读”是由文件类型本身决定的,这里就是file->f_op->read_iter这个函数指针。

而其余结构则与readpread的实现类似。

同时,可以指出,之前readpread的实现中,第二个条件分支

if (file->f_op->read_iter)
		return new_sync_read(file, buf, count, pos);

就是防止文件类型只实现了read_iter而没有实现iter,因此用长度为1的数组调用file->f_op->read_iter

write, pwrite64, writev, pwritev, pwritev2系统调用

writepwrite64

系统调用号

write的系统调用号为1,pwrite64的系统调用号为18。

函数原型

内核接口

asmlinkage long sys_write(unsigned int fd, const char __user *buf, size_t count);
asmlinkage long sys_pwrite64(unsigned int fd, const char __user *buf, size_t count, loff_t pos);

glibc封装

#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);

简介

writepwrite是最基础的对文件写入的系统调用。write会将bufcount个字节写入描述符为fd的文件中,而pread则会将bufcount个字节写入描述符为fd的文件从offset开始的位置中。如果写入成功,这两个系统调用都将返回写入的字节数。因此,这两个系统调用主要的区别就在于写入的位置,其它功能均类似。

注意点:

首先,是文件偏移的问题:

  • 写入前位置

    显然,pwrite是从文件偏移为offset的位置开始写入,但是write的问题则比较特殊。一般来说,write开始写入时的文件偏移就是当前的文件偏移,但是,当文件描述符是通过open系统调用创建,且创建时使用了O_APPEND标志位的话,每次write开始写入前,都会默认将文件偏移移到文件末尾。

  • 写入后位置

    readpread类似,write在成功写入n个字节后,会将文件偏移更新n个字节;但pwrite则不会更新文件偏移,因此和pread一起常用于多线程的代码中。

我们可以通过一个简单的程序检测这个性质

#include <fcntl.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>

void test_file_offset(int fd) {
    char read_buf[16];
    char write_buf[] = "payload";
    printf("File offset is %zd.\n", lseek(fd, 0, SEEK_CUR));
    ssize_t read_length = read(fd, read_buf, 4);
    read_buf[read_length] = '\0';
    printf("Read %zd bytes: %s.\n", read_length, read_buf);
    printf("File offset is %zd.\n", lseek(fd, 0, SEEK_CUR));
    printf("Write %zd bytes.\n", write(fd, write_buf, 7));
    printf("File offset is %zd.\n", lseek(fd, 0, SEEK_CUR));
    read_length = read(fd, read_buf, 4);
    read_buf[read_length] = '\0';
    printf("Read %zd bytes: %s.\n", read_length, read_buf);
    printf("File offset is %zd.\n", lseek(fd, 0, SEEK_CUR));
}

int main() {
    int fd_with_append = open("./text.txt", O_RDWR | O_APPEND);
    printf("File opened with O_APPEND:\n");
    test_file_offset(fd_with_append);
    close(fd_with_append);
    int fd_without_append = open("./text.txt", O_RDWR);
    printf("File opened without O_APPEND:\n");
    test_file_offset(fd_without_append);
    close(fd_without_append);
    return 0;
}

输出为

File opened with O_APPEND:
File offset is 0.
Read 4 bytes: 1234.
File offset is 4.
Write 7 bytes.
File offset is 13.
Read 0 bytes: .
File offset is 13.
File opened without O_APPEND:
File offset is 0.
Read 4 bytes: 1234.
File offset is 4.
Write 7 bytes.
File offset is 11.
Read 2 bytes: ad.
File offset is 13.

我们在同目录中有一个文本文件text.txt,它的内容为六字节长的字符串"123456"。

  • 首先,我们使用O_APPEND标志位创建文件描述符fd_with_append
    1. 使用read读入4字节。在读入前文件偏移为0,读入成功4字节,文件偏移为4,且读入的字符串为"1234"。
    2. 使用write写入7字节长度字符串"payload"。在写入前,文件偏移被移至文件末尾6,因此成功写入7字节后,文件偏移为13。
    3. 使用read读入4字节。在读入前,文件偏移为13,处于文件末尾,无内容读入,所以读入字节为0,读入后文件偏移依然为13。
    4. 最终,text.txt的内容为"123456payload"。
  • 接着,我们不使用O_APPEND标志位创建文件描述符fd_without_append
    1. 使用read读入4字节。在读入前文件偏移为0,读入成功4字节,文件偏移为4,且读入的字符串为"1234"。
    2. 使用write写入7字节长度字符串"payload"。写入前文件偏移为4,写入成功7字节,文件偏移为11。此时text.txt的内容为"1234payloadad"。
    3. 使用read读入4字节。在读入前,文件偏移为11,文件总长度为13字节,所以只能读入成功2字节,读入后文件偏移为13,读入的字符串为"ad"。
    4. 最终,text.txt的内容为"1234payload"。

根据我们这个例子,很好地解释了文件偏移与read, write的关系。此外,还有一些需要注意的:

第一,在不使用O_APPEND标志位创建文件的例子中,为什么写入后文件的内容为1234payloadad。在写入前,由于上一轮的修改,文件的内容为"123456payload"。此时文件偏移为4,接下来将从"56..."的位置开始写入。而write如果写入的位置之后还有数据,是直接覆盖的,因此覆盖了7个字节,就变成了"1234payloadad"。这在POSIX标准中有提及:

After a write() to a regular file has successfully returned:

  • Any subsequent successful write() to the same byte position in the file shall overwrite that file data.

第二,和read类似,writepwrite返回的成功写入的字节数,可能会小于传入的参数count。这可能是由多种原因引起,比如说此硬盘分区的容量已满,或者超过了当前文件系统允许的单个文件的最大体积,此时,只能尽可能多地写入字节。比如说此硬盘分区还有2K字节就满了,我们企图写入5K字节,那么只能写入成功2K字节,所以write将返回2K。

但是,尽管如此,writepwrite并不能保证在成功返回后,数据一定已经被写入硬盘。在某些情况下,甚至写入的错误也并不一定立刻出现。因此当我们再一次调用文件修改的操作,如write, fsync, close等时,就有可能出现错误。我们可以通过在写入数据后调用fsync,或是在open创建文件时使用O_SYNCO_DSYNC标志位来解决这一问题。

虽然不能保证数据一定写入硬盘,POSIX标准同样规定了一件事:

After a write() to a regular file has successfully returned:

  • Any successful read() from each byte position in the file that was modified by that write shall return the data specified by the write() for that position until such byte positions are again modified.

也就是说,即使不保证写入硬盘,read读入的数据一定是write成功之后的数据。

实现

writepwrite的实现均位于fs/read_write.c中,其核心为__vfs_write函数:

static ssize_t __vfs_write(struct file *file, const char __user *p, size_t count, loff_t *pos)
{
	if (file->f_op->write)
		return file->f_op->write(file, p, count, pos);
	else if (file->f_op->write_iter)
		return new_sync_write(file, p, count, pos);
	else
		return -EINVAL;
}

readpread类似,writepwrite将调用文件类型对应的write函数指针,如果不存在,则调用其用于writev, pwritevwrite_iter函数指针。

TODO: 对于常用的EXT4文件系统,找到『当文件描述符创建时使用O_APPEND标志位时,write系统调用会从文件末尾开始写入』这个特性的实现。

writev, pwritevpwritev2

系统调用号

writev为20,pwritev为296,pwritev2为328。

函数原型

内核接口

asmlinkage long sys_writev(unsigned long fd, const struct iovec __user *vec, unsigned long vlen);
asmlinkage long sys_pwritev(unsigned long fd, const struct iovec __user *vec, unsigned long vlen, unsigned long pos_l, unsigned long pos_h);
asmlinkage long sys_pwritev2(unsigned long fd, const struct iovec __user *vec, unsigned long vlen, unsigned long pos_l, unsigned long pos_h, rwf_t flags);

glibc封装

#include <sys/uio.h>
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt, off_t offset);
ssize_t pwritev2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);

简介

readv, preadv, preadv2类似,这三个系统调用是为了解决一次性从多个连续内存向一个文件描述符写入的问题,这三个系统调用被称为“聚合写”(gather output)。

这三个系统调用的特性与readv, preadv, preadv2十分类似,这里不再赘述。

lseek系统调用

系统调用号

8

函数签名

内核接口

asmlinkage long sys_lseek(unsigned int fd, off_t offset, unsigned int whence);

glibc封装

#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);

简介

对于支持seek的文件类型,在内核的文件描述中,会有一个“文件偏移”(file offset)。这个的作用是标记后续的readwrite的起点。而lseek系统调用的作用就是操作这个文件偏移。

对于fd对应的文件,lseek的功能根据whence的不同而不同。whence可能的值包括:

  • SEEK_SET

    将文件偏移置于offset

  • SEEK_CUR

    将文件偏移置于距当前偏移的offset字节。

    offset为正则为当前偏移之后,offset为负则为当前偏移之前

  • SEEK_END

    将文件偏移置于距文件末尾的offset字节。

    offset为负则为文件末尾之前。offset可以为正,表示在文件末尾之后的offset个字节。如果在此处写入,那么使用read读取之前文件末尾至当前偏移,将会得到被\0填充的区域(被称为洞)。

    假设当前文件长度为5字节,我们使用lseek,从第8字节的位置开始写入。然后使用read读取6到7字节的内容,则内容为\0填充的2字节区域。

  • SEEK_DATA

    将文件偏移置于从offset开始,第一个包含数据的字节。

    如果我们使用SEEK_END制造了洞,且当前文件偏移在洞中,则SEEK_DATA将会将我们的文件偏移移动到之后第一个有数据的区域。

  • SEEK_HOLE

    将文件偏移置于从offset开始,第一个洞的开始字节。

    如果该文件没有洞,则将置于文件末尾。

lseek的返回值为进行修改文件偏移的操作之后,当前的文件偏移。

我们可以用这个示例来简单了解一下lseek的功能:

#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>

int main() {
    int fd = open("./text.txt", O_RDWR);
    int end_offset = lseek(fd, 0, SEEK_END);
    lseek(fd, 4, SEEK_END);
    write(fd, "123", 3);
    char buf[4];
    lseek(fd, end_offset, SEEK_SET);
    read(fd, buf, 4);
    for (int i = 0; i < 4; i++) {
        printf("%d", buf[i]);
    }
    printf("\n");

    int at_hole = lseek(fd, end_offset + 2, SEEK_SET);
    int next_data = lseek(fd, at_hole, SEEK_DATA);
    printf("Current offset %d at hole, move to %d with SEEK_DATA\n", at_hole, next_data);

    int at_data = lseek(fd, end_offset - 2, SEEK_SET);
    int next_hole = lseek(fd, at_data, SEEK_HOLE);
    printf("Current offset %d at data, move to %d with SEEK_HOLE\n", at_data, next_hole);

    return 0;
}

其中text.txt的内容长度为6字节。

因此,我们的步骤是

  1. 将文件偏移移动到末尾之后4字节,当前文件偏移为10
  2. 写入数据
  3. 将文件偏移移动到6
  4. 读入4字节数据。这4字节由于在“洞“中,所以均为0
  5. 将文件偏移移动到8
  6. 使用SEEK_DATA找到下一个有数据的区域,文件偏移为10
  7. 将文件偏移移动到4
  8. 使用SEEK_HOLE找到下一个洞的区域,文件偏移为6

但是,由于不同的文件系统对SEEK_DATASEEK_HOLE的支持不同,上述行为在不同文件系统下可能会不同。最简单的实现就是,SEEK_DATA不改变文件偏移,SEEK_HOLE移动到文件末尾,也就是把“洞”也看作正常的数据。

实现

lseek的实现位于fs/read_write.c,其最终调用的是vfs_llseek

loff_t vfs_llseek(struct file *file, loff_t offset, int whence)
{
	loff_t (*fn)(struct file *, loff_t, int);

	fn = no_llseek;
	if (file->f_mode & FMODE_LSEEK) {
		if (file->f_op->llseek)
			fn = file->f_op->llseek;
	}
	return fn(file, offset, whence);
}

而我们来看看比较常见的EXT4文件系统的实现,其位于fs/ext4/file.c:

loff_t ext4_llseek(struct file *file, loff_t offset, int whence)
{
	struct inode *inode = file->f_mapping->host;
	loff_t maxbytes;

	if (!(ext4_test_inode_flag(inode, EXT4_INODE_EXTENTS)))
		maxbytes = EXT4_SB(inode->i_sb)->s_bitmap_maxbytes;
	else
		maxbytes = inode->i_sb->s_maxbytes;

	switch (whence) {
	default:
		return generic_file_llseek_size(file, offset, whence,
						maxbytes, i_size_read(inode));
	case SEEK_HOLE:
		inode_lock_shared(inode);
		offset = iomap_seek_hole(inode, offset, &ext4_iomap_ops);
		inode_unlock_shared(inode);
		break;
	case SEEK_DATA:
		inode_lock_shared(inode);
		offset = iomap_seek_data(inode, offset, &ext4_iomap_ops);
		inode_unlock_shared(inode);
		break;
	}

	if (offset < 0)
		return offset;
	return vfs_setpos(file, offset, maxbytes);
}

对于SEEK_HOLESEEK_DATA,EXT4特殊考虑了,实现了正确的行为。对于一般的操作,则是使用fs/read_write里的generic_file_llseek_size函数:

loff_t
generic_file_llseek_size(struct file *file, loff_t offset, int whence,
		loff_t maxsize, loff_t eof)
{
	switch (whence) {
	case SEEK_END:
		offset += eof;
		break;
	case SEEK_CUR:
		/*
		 * Here we special-case the lseek(fd, 0, SEEK_CUR)
		 * position-querying operation.  Avoid rewriting the "same"
		 * f_pos value back to the file because a concurrent read(),
		 * write() or lseek() might have altered it
		 */
		if (offset == 0)
			return file->f_pos;
		/*
		 * f_lock protects against read/modify/write race with other
		 * SEEK_CURs. Note that parallel writes and reads behave
		 * like SEEK_SET.
		 */
		spin_lock(&file->f_lock);
		offset = vfs_setpos(file, file->f_pos + offset, maxsize);
		spin_unlock(&file->f_lock);
		return offset;
	case SEEK_DATA:
		/*
		 * In the generic case the entire file is data, so as long as
		 * offset isn't at the end of the file then the offset is data.
		 */
		if ((unsigned long long)offset >= eof)
			return -ENXIO;
		break;
	case SEEK_HOLE:
		/*
		 * There is a virtual hole at the end of the file, so as long as
		 * offset isn't i_size or larger, return i_size.
		 */
		if ((unsigned long long)offset >= eof)
			return -ENXIO;
		offset = eof;
		break;
	}

	return vfs_setpos(file, offset, maxsize);
}

这是默认实现的行为,对于SEEK_SET, SEEK_CURSEEK_END,清楚地实现了它们的功能;对于SEEK_DATA,则默认不改变文件偏移,SEEK_HOLE则会移动到文件末尾。

poll, select, pselect6, ppoll系统调用

selectpoll

系统调用号

poll为7,select为23。

函数原型

内核接口

asmlinkage long sys_poll(struct pollfd __user *ufds, unsigned int nfds, int timeout);
asmlinkage long sys_select(int n, fd_set __user *inp, fd_set __user *outp, fd_set __user *exp, struct timeval __user *tvp);

glibc封装

poll

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

select

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

简介

selectpoll都是为了实现IO多路复用的功能。

一般来说,对硬盘上的文件的读取都不会阻塞。但是,对管道、套接字、伪终端等文件的读取,是可能产生阻塞的。举个例子来说,如果我们读取stdin

int fd = STDIN_FILENO; // stdin
char buf[64];
read(fd, buf, 64); // blocks here
process_read_content(buf);

那么,在执行read时,如果我们一直不向终端输入,那么这里会始终阻塞着,程序永远不会执行到之后的process_read_context(buf)。在这种情况下,这种行为是符合逻辑的,因为我们之后的语句是依赖读入的内容buf的。所以除非我们收到了buf的内容,否则就不应该执行之后的指令。

但是,如果有多个文件需要读入,就产生了问题。假设我们有一个nfd个元素的文件描述符数组fds,我们需要对他们读入,并彼此独立地分别处理每个读入的内容。

  • 方案一

    void process_fds(int *fds, int nfd) {
        for (int i = 0; i < nfd; i++) {
            char buf[64];
            read(fds[i], buf, 64);
            process_read_content(buf);
        }
    }
    

    这个方案能完成我们的需求,但是效率实在是太低了。由于是按顺序依次处理读入的内容,如果fds[0]始终没有输入,但是fds[1]早就有了输入。我们明明可以先处理fds[1]的输入的,但是由于进程阻塞在了fds[0]read操作中,我们的时间就这样被白白浪费了。

  • 方案二

    既然每个文件描述符处理读入是互相独立的,我们就可以创建nfd个线程,每个线程中处理其读入。

    这种方案确实可以解决我们方案一中的问题,但是线程的创建、线程之间的切换是非常耗费时间的。

为了更高效地解决这个问题,我们可以增加一个新的操作——判断某个文件描述符是否可以读入。我们可以遍历文件描述符,判断是否有已经可以读入的,如果有的话就直接处理,如果没有的话就再次遍历。这样几乎没有耗费的时间。

我们甚至可以想出更高效的方案,在主线程中查询是否有可以读取的文件描述符,然后把可以读取的文件描述符给别的线程执行。

select就为我们提供了一个类似的解决方案。我们给select传入需要检测IO状态(可以读入、可以写入等)的文件描述符集合,select立即返回,告诉我们哪些文件描述符的IO已经准备就绪。

poll的功能和select类似,但解决了一些select的缺点。具体请见下面的用法一节。

用法

select

select的函数签名为

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

readfds, writefdsexceptfds是文件描述符集合,分别用于:

  • readfds

    已经准备好供读取的文件描述符集合,即read操作不会阻塞。

  • writefds

    已经准备好供写入的文件描述符集合,即write操作不会阻塞。

  • exceptfds

    其余条件的文件描述符集合。包括:

    • TCP套接字有带外数据
    • 处于包模式下的伪终端的主端检测到从端的状态变化
    • cgroup.events文件被修改

如果对相应的状态变化不感兴趣,在对应的参数中传递NULL即可。

我们可以用以下几个函数操作fd_set类型的变量:

void FD_CLR(int fd, fd_set *set);
int  FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
  • FD_ZERO

    set清空

  • FD_SET

    fd放入set

  • FD_CLR

    fdset中移除

  • FD_ISSET

    判断fd是否处于set

nfdsreadfds, writefds, exceptfds中,数值最大的文件描述符加1。如readfds包含文件描述符4, 6, 7,writefds包含文件描述符5,exceptfds为空,则nfds为8。

timeout为超时参数,其结构为

struct timeval {
    time_t      tv_sec;         /* seconds */
    suseconds_t tv_usec;        /* microseconds */
};

如果timeout指针为NULL,则select将一直等待,直到有一个文件描述符准备好。如果tv_sectv_usec均为0,则select将立即返回。否则,select如果等待达到timeout的时间,还没有任何文件描述符准备好,就返回。

当函数返回之后,会有如下变化:

  • 返回值为readfds, writefds, exceptfds中准备好的文件描述符的总数
  • readfds, writefds, exceptfds中会只保留已经处于准备好状态的文件描述符。我们可以通过FD_ISSET去查看哪些文件描述符准备好。(正因如此,如果我们在一个循环中使用select,那在每次使用之前,需要复制一遍各集合,或用FD_CLR清空后重新添加)
  • select可能会更新timeout参数。

综上,如果我们要用select,按第三个方案来实现我们的功能,其写法为

void process_fds(int *fds, int nfd) {
    fd_set rset;
    FD_ZERO(&rset);
    int maxfd = -1;
    for (int i = 0; i < nfd; i++) {
        FD_SET(fds[i], &rset);
        if (fds[i] > maxfd) {
            maxfd = fds[i];
        }
    }
    while (1) {
        fd_set tmp_rset;
        memcpy(&tmp_rset, &rset, sizeof(fd_set));
        if (select(maxfd + 1, &tmp_rset, NULL, NULL, NULL) <= 0) {
            break;
        }
        for (int i = 0; i < nfd; i++) {
            if (FD_ISSET(fds[i], &tmp_rset)) {
                FD_CLR(fds[i], &rset);
                char buf[64];
                read(fds[i], buf, 64);
                process_read_content(buf);
            }
        }
    }
}

此外,值得注意的是,glibc的封装要求我们的文件描述符的值不能超过FD_SETSIZE,也就是1024。

poll

poll的函数签名为

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

select不同的是,它并不是把文件描述符放在fd_set结构体中,而是放在一个struct pollfd类型组成的数组中,nfds为该数组的长度。

struct pollfd的定义为

struct pollfd {
    int   fd;         /* file descriptor */
    short events;     /* requested events */
    short revents;    /* returned events */
};

fd是文件描述符,events是用户感兴趣的事件(类似于select中的readfds, writefdsexceptfds),由用户填写;revents是实际发生的事件,由内核填写。

eventsrevents是位掩码,其可以包含的标志位有

  • POLLIN:存在数据可以读入(相当于select中的readfds

  • POLLPRI:存在其他条件满足(相当于select中的exceptfds

  • POLLOUT:存在数据可以写入(相当于select中的writefds

  • POLLRDHUP

    流套接字对端关闭连接。

    需定义_GNU_SOURCE宏。

  • POLLERR

    出错。

    只可由revents包含,不可由events包含

  • POLLHUP

    挂起。

    只可由revents包含,不可由events包含

  • POLLNVAL

    由于fd未打开,请求无效。

    只可由revents包含,不可由events包含

events为0时,revents只可返回POLLERR, POLLHUPPOLLNVAL(将events置为0类似于select中的FD_CLR)。若其不为0,则可以返回events中包含的事件,以及POLLERR, POLLHUPPOLLNVAL。如果返回的revents为0,则表示什么都没发生,可能超时了,或者别的文件描述符中发生了用户感兴趣的事。

timeout参数表示其最多等待时间(以毫秒为单位)。如果其为负,则表示poll无限等待;如果其为0,则表示poll立即返回。

如果在超时范围内,任何一个用户感兴趣的事件发生了,poll将会返回,返回值为产生用户感兴趣事件的文件描述符个数;如果超时了,没有任何一个用户感兴趣的事件发生,则poll将会返回0。

综上,如果我们要用poll,按第三个方案来实现我们的功能,其写法为

void process_fds(int *fds, int nfd) {
    struct pollfd *pollfds = (struct pollfd *)malloc(nfd * sizeof(struct pollfd));
    for (int i = 0; i < nfd; i++) {
        pollfds[i].fd = fds[i];
        pollfds[i].events = POLLIN;
    }
    while (1) {
        if (poll(pollfds, nfd, -1) <= 0) {
            break;
        }
        for (int i = 0; i < nfd; i++) {
            if (pollfds[i].revents & POLLIN) {
                pollfds[i].events = 0;
                char buf[64];
                read(fds[i], buf, 64);
                process_read_content(buf);
            }
        }
    }
}

select不同的是,其可以包含的文件描述符个数无上限。

根据上述的讨论,pollselect的区别在于

  • poll文件描述符个数无上限,select文件描述符其值上限为FD_SETSIZE
  • poll感兴趣的事件种类更多
  • poll不需要在每次调用前都复制一遍fd_set,也就是poll不会改变传入的fds
  • poll超时参数精度为毫秒,select超时参数精度为微秒,select更精确。

实现

select

首先我们来看看fd_set与和其相关的函数是怎么实现的。在Linux内核的include/linux/types.h中可以看到

typedef __kernel_fd_set		fd_set;

而在include/uapi/linux/posix_types.h中可以看到

#define __FD_SETSIZE	1024

typedef struct {
	unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;

所以,这其实就是一个长度为1024字节的位数组。同时我们也明白了,为什么select要求文件描述符的值不能超过FD_SETSIZE

同时,我们也可以在glibc的源码misc/sys/select.h中看到和其相关的函数的定义

#define	FD_SET(fd, fdsetp)	__FD_SET (fd, fdsetp)
#define	FD_CLR(fd, fdsetp)	__FD_CLR (fd, fdsetp)
#define	FD_ISSET(fd, fdsetp)	__FD_ISSET (fd, fdsetp)
#define	FD_ZERO(fdsetp)		__FD_ZERO (fdsetp)

其实现则位于bits/select.h

#define __FD_ZERO(s) \
  do {									      \
    unsigned int __i;							      \
    fd_set *__arr = (s);						      \
    for (__i = 0; __i < sizeof (fd_set) / sizeof (__fd_mask); ++__i)	      \
      __FDS_BITS (__arr)[__i] = 0;					      \
  } while (0)
#define __FD_SET(d, s) \
  ((void) (__FDS_BITS (s)[__FD_ELT(d)] |= __FD_MASK(d)))
#define __FD_CLR(d, s) \
  ((void) (__FDS_BITS (s)[__FD_ELT(d)] &= ~__FD_MASK(d)))
#define __FD_ISSET(d, s) \
  ((__FDS_BITS (s)[__FD_ELT (d)] & __FD_MASK (d)) != 0)

简单来说,就是:

  • FD_ZERO将整个位数组清0(不用memset的原因是,这可能需要在之前声明memset的原型,并且这个数组其实并不大)
  • FD_SET将该文件描述符对应的比特位置1
  • FD_CLR将该文件描述符对应的比特位置0
  • FD_ISSET判断该文件描述符对应的比特位是否为1

接着,我们来看看select内部的实现。其实现均位于Linux内核源码的fs/select.c文件中。

首先,在core_sys_select函数里,使用了一个fd_set_bits的结构体,其定义为:

typedef struct {
	unsigned long *in, *out, *ex;
	unsigned long *res_in, *res_out, *res_ex;
} fd_set_bits;

一共六个位数组,前三个是存储我们传入的参数的,后三个存储结果,在最终再复制进前三个中。

select的实现,最主要的就是do_select函数,其内容非常长,但也十分重要:

static int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
{
	ktime_t expire, *to = NULL;
	struct poll_wqueues table;
	poll_table *wait;
	int retval, i, timed_out = 0;
	u64 slack = 0;
	__poll_t busy_flag = net_busy_loop_on() ? POLL_BUSY_LOOP : 0;
	unsigned long busy_start = 0;

	rcu_read_lock();
	retval = max_select_fd(n, fds);
	rcu_read_unlock();

	if (retval < 0)
		return retval;
	n = retval;

	poll_initwait(&table);
	wait = &table.pt;
	if (end_time && !end_time->tv_sec && !end_time->tv_nsec) {
		wait->_qproc = NULL;
		timed_out = 1;
	}

	if (end_time && !timed_out)
		slack = select_estimate_accuracy(end_time);

	retval = 0;
	for (;;) {
		unsigned long *rinp, *routp, *rexp, *inp, *outp, *exp;
		bool can_busy_loop = false;

		inp = fds->in; outp = fds->out; exp = fds->ex;
		rinp = fds->res_in; routp = fds->res_out; rexp = fds->res_ex;

		for (i = 0; i < n; ++rinp, ++routp, ++rexp) {
			unsigned long in, out, ex, all_bits, bit = 1, j;
			unsigned long res_in = 0, res_out = 0, res_ex = 0;
			__poll_t mask;

			in = *inp++; out = *outp++; ex = *exp++;
			all_bits = in | out | ex;
			if (all_bits == 0) {
				i += BITS_PER_LONG;
				continue;
			}

			for (j = 0; j < BITS_PER_LONG; ++j, ++i, bit <<= 1) {
				struct fd f;
				if (i >= n)
					break;
				if (!(bit & all_bits))
					continue;
				f = fdget(i);
				if (f.file) {
					wait_key_set(wait, in, out, bit,
						     busy_flag);
					mask = vfs_poll(f.file, wait);

					fdput(f);
					if ((mask & POLLIN_SET) && (in & bit)) {
						res_in |= bit;
						retval++;
						wait->_qproc = NULL;
					}
					if ((mask & POLLOUT_SET) && (out & bit)) {
						res_out |= bit;
						retval++;
						wait->_qproc = NULL;
					}
					if ((mask & POLLEX_SET) && (ex & bit)) {
						res_ex |= bit;
						retval++;
						wait->_qproc = NULL;
					}
					/* got something, stop busy polling */
					if (retval) {
						can_busy_loop = false;
						busy_flag = 0;

					/*
					 * only remember a returned
					 * POLL_BUSY_LOOP if we asked for it
					 */
					} else if (busy_flag & mask)
						can_busy_loop = true;

				}
			}
			if (res_in)
				*rinp = res_in;
			if (res_out)
				*routp = res_out;
			if (res_ex)
				*rexp = res_ex;
			cond_resched();
		}
		wait->_qproc = NULL;
		if (retval || timed_out || signal_pending(current))
			break;
		if (table.error) {
			retval = table.error;
			break;
		}

		/* only if found POLL_BUSY_LOOP sockets && not out of time */
		if (can_busy_loop && !need_resched()) {
			if (!busy_start) {
				busy_start = busy_loop_current_time();
				continue;
			}
			if (!busy_loop_timeout(busy_start))
				continue;
		}
		busy_flag = 0;

		/*
		 * If this is the first loop and we have a timeout
		 * given, then we convert to ktime_t and set the to
		 * pointer to the expiry value.
		 */
		if (end_time && !to) {
			expire = timespec64_to_ktime(*end_time);
			to = &expire;
		}

		if (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE,
					   to, slack))
			timed_out = 1;
	}

	poll_freewait(&table);

	return retval;
}

fd_set_bits类型的fds,其表示输入的字段复制于系统调用的输入,其表示输出的字段在调用前被清空。

其主体部分为两层嵌套的循环。在最外层循环中,每一轮循环处理BITS_PER_LONG,也就是一般来说64个文件描述符。这是因为我们的数据是用long的位数组来存储的,所以这样分批次效率更高。在内循环中,我们遍历这个long整型的每个字节,其每个字节对应一个文件描述符。

也就是说,我们从0开始,一直到我们传入系统调用的参数n,也就是最大的文件描述符的值,遍历每个文件描述符,在在53行看到,通过bit & all_bits,判断当前的文件描述符是否在我们之前传入的in, outex集合中。对于存在集合中的,最终调用了vfs_poll来查询单个文件的状态,其实现位于fs/poll.h中:

static inline __poll_t vfs_poll(struct file *file, struct poll_table_struct *pt)
{
	if (unlikely(!file->f_op->poll))
		return DEFAULT_POLLMASK;
	return file->f_op->poll(file, pt);
}

依旧是函数指针模拟多态。

最终,再把res_in, res_out, res_ex复制回原本的in, out, ex即可。

总的来说,select的步骤是,对于输入的参数nfds,把值从0到nfds - 1的所有相关的文件描述符都查询一遍,对于每个文件描述符,调用vfs_poll查询状态。

poll

poll的实现位于fs/select.c,其核心do_poll依然很长,但也十分重要:

static int do_poll(struct poll_list *list, struct poll_wqueues *wait, struct timespec64 *end_time)
{
	poll_table* pt = &wait->pt;
	ktime_t expire, *to = NULL;
	int timed_out = 0, count = 0;
	u64 slack = 0;
	__poll_t busy_flag = net_busy_loop_on() ? POLL_BUSY_LOOP : 0;
	unsigned long busy_start = 0;

	/* Optimise the no-wait case */
	if (end_time && !end_time->tv_sec && !end_time->tv_nsec) {
		pt->_qproc = NULL;
		timed_out = 1;
	}

	if (end_time && !timed_out)
		slack = select_estimate_accuracy(end_time);

	for (;;) {
		struct poll_list *walk;
		bool can_busy_loop = false;

		for (walk = list; walk != NULL; walk = walk->next) {
			struct pollfd * pfd, * pfd_end;

			pfd = walk->entries;
			pfd_end = pfd + walk->len;
			for (; pfd != pfd_end; pfd++) {
				/*
				 * Fish for events. If we found one, record it
				 * and kill poll_table->_qproc, so we don't
				 * needlessly register any other waiters after
				 * this. They'll get immediately deregistered
				 * when we break out and return.
				 */
				if (do_pollfd(pfd, pt, &can_busy_loop,
					      busy_flag)) {
					count++;
					pt->_qproc = NULL;
					/* found something, stop busy polling */
					busy_flag = 0;
					can_busy_loop = false;
				}
			}
		}
		/*
		 * All waiters have already been registered, so don't provide
		 * a poll_table->_qproc to them on the next loop iteration.
		 */
		pt->_qproc = NULL;
		if (!count) {
			count = wait->error;
			if (signal_pending(current))
				count = -ERESTARTNOHAND;
		}
		if (count || timed_out)
			break;

		/* only if found POLL_BUSY_LOOP sockets && not out of time */
		if (can_busy_loop && !need_resched()) {
			if (!busy_start) {
				busy_start = busy_loop_current_time();
				continue;
			}
			if (!busy_loop_timeout(busy_start))
				continue;
		}
		busy_flag = 0;

		/*
		 * If this is the first loop and we have a timeout
		 * given, then we convert to ktime_t and set the to
		 * pointer to the expiry value.
		 */
		if (end_time && !to) {
			expire = timespec64_to_ktime(*end_time);
			to = &expire;
		}

		if (!poll_schedule_timeout(wait, TASK_INTERRUPTIBLE, to, slack))
			timed_out = 1;
	}
	return count;
}

传入的参数list的类型是struct poll_list的指针:

struct poll_list {
	struct poll_list *next;
	int len;
	struct pollfd entries[0];
};

也就是说,这里是一个链表。其entries字段是一个变长数组。我们传入do_polllist参数是将传入系统调用的fds,也就是由nfdsstruct pollfd类型的实例组成的数组,在之前的do_sys_poll函数中,被分成长度为POLLFD_PER_PAGE的若干个部分,然后再将每个部分用链表串联起来。这样做的原因应该就是保证每次处理的一批不会超过页大小,尽量减少换页。

do_poll函数中我们可以看到,对每个链表项,遍历了其entries数组,也就相当于对我们传入的fds进行遍历。对每一个文件描述符,使用do_pollfd(pfd, pt, &can_busy_loop, busy_flag)来进行真正的poll操作,而do_pollfd,实际上就是调用的vfs_poll

pselect6ppoll

系统调用号

pselect6为270,ppoll为271。

函数签名

内核接口

asmlinkage long sys_pselect6(int, fd_set __user *, fd_set __user *, fd_set __user *, struct __kernel_timespec __user *, void __user *);
asmlinkage long sys_ppoll(struct pollfd __user *, unsigned int, struct __kernel_timespec __user *, const sigset_t __user *, size_t);

glibc封装

pselect6

#include <sys/select.h>
int pselect(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, const struct timespec *timeout,  const sigset_t *sigmask);

ppoll

#define _GNU_SOURCE
#include <signal.h>
#include <poll.h>
int ppoll(struct pollfd *fds, nfds_t nfds, const struct timespec *tmo_p, const sigset_t *sigmask);

简介

pselectselect的区别主要在于:

  • 超时精度
    • select使用的是struct timeval结构体,精确到微秒
    • pselect使用的是struct timespec结构体,精确到纳秒
  • 超时参数
    • select可能会更新其超时参数timeout
    • pselect6系统调用可能会更新其超时参数,glibc的封装pselect不会更新其超时参数
  • 信号掩码
    • pselect可以设置信号掩码,若其为NULL,则行为与select相同

ppollpoll的区别主要在于:

  • 超时精度
    • poll使用的超时精度为毫秒
    • ppoll使用的是struct timespec结构体,精确到纳秒
  • 信号掩码
    • ppoll可以设置信号掩码,若其为NULL,则行为与poll相同

pselectppoll可以看作执行selectpoll前后设置信号掩码。之所以需要这两个单独的系统调用,是因为如果我们的需求是,要么接收到特定的信号,要么某个文件描述符准备好,然后执行后续的操作。如果我们分为两步操作,但是接受信号实际上是在判断是否接收到信号之后,也就是判断结果为没接收到信号,而且在调用select之前,那么就可能陷入无限等待。pselectppoll进行信号判断的时候则是使用原子操作,所以不会产生这样的竞争条件。

实现

pselect6ppoll在Linux内核中的实现与selectpoll类似。有一点需要说明的是,pselect6接受的最后一个参数是void *类型的,这是因为它实际上需要的类型为

struct {
    const kernel_sigset_t *ss;   /* Pointer to signal set */
    size_t ss_len;               /* Size (in bytes) of object
                                    pointed to by 'ss' */
};

本来其实可以把这两个字段变成两个函数的参数的,但由于Linux x86_64的ABI要求系统调用至多只能接受6个参数,所以最后一个参数只能是这样的结构体了。

值得注意的是,在glibc的封装中,以pselect为例:

我们在glibc源码的sysdeps/unix/sysv/linux/pselect.c中可以看到:

int
__pselect (int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
	   const struct timespec *timeout, const sigset_t *sigmask)
{
  /* The Linux kernel can in some situations update the timeout value.
     We do not want that so use a local variable.  */
  struct timespec tval;
  if (timeout != NULL)
    {
      tval = *timeout;
      timeout = &tval;
    }

  /* Note: the system call expects 7 values but on most architectures
     we can only pass in 6 directly.  If there is an architecture with
     support for more parameters a new version of this file needs to
     be created.  */
  struct
  {
    __syscall_ulong_t ss;
    __syscall_ulong_t ss_len;
  } data;

  data.ss = (__syscall_ulong_t) (uintptr_t) sigmask;
  data.ss_len = _NSIG / 8;

  return SYSCALL_CANCEL (pselect6, nfds, readfds, writefds, exceptfds,
                         timeout, &data);
}

通过设置一个局部变量tval,使得传入的参数timeout不会被内核修改。ppoll也进行了类似的操作。

epoll_create, epoll_wait, epoll_ctl, epoll_pwait, epoll_create1系统调用

系统调用号

  • epoll_create: 213
  • epoll_wait: 232
  • epoll_ctl: 233
  • epoll_pwait: 281
  • epoll_create1: 291

函数原型

内核接口

asmlinkage long sys_epoll_create(int size);
asmlinkage long sys_epoll_wait(int epfd, struct epoll_event __user *events, int maxevents, int timeout);
asmlinkage long sys_epoll_ctl(int epfd, int op, int fd, struct epoll_event __user *event);
asmlinkage long sys_epoll_pwait(int epfd, struct epoll_event __user *events, int maxevents, int timeout, const sigset_t __user *sigmask, size_t sigsetsize);
asmlinkage long sys_epoll_create1(int flags);

glibc封装

#include <sys/epoll.h>
int epoll_create(int size);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_pwait(int epfd, struct epoll_event *events, int maxevents, int timeout, const sigset_t *sigmask);
int epoll_create1(int flags);

简介

selectpoll一样,epoll机制也是为了实现IO多路复用。其使用方法更先进,内部实现也更高效。

我们可以理解为,Linux内核为了实现epoll机制,在内核空间维护了一个数据结构,称为epoll实例。其包含两个集合,一个是由用户感兴趣的文件描述符与相应的事件组成,另一个是由触发了相应事件的文件描述符与相应的事件组成。

我们的整体步骤是

  1. 创建一个epoll实例
  2. 对于epoll实例,将我们感兴趣的文件描述符与相应的事件添加到集合中
  3. 从触发事件的集合中提取相应的文件描述符

使用

epoll_createepoll_create1

其函数签名为

int epoll_create(int size);
int epoll_create1(int flags);

这两个系统调用就是在内核空间创建一个epoll实例,返回该实例的文件描述符。

对于epoll_createsize参数是被忽略的,但其必须大于0。

epoll_create1epoll_create的加强版。如果flags为0,则其行为与epoll_create一致。此外,flags还可以加入EPOLL_CLOEXEC标志位,和open中的O_CLOEXEC标志位功能一致,具体请看相应的描述

epoll_createepoll_create1创建的文件描述符,也就是epoll实例对应的文件描述符也应在程序结束前使用close关闭。但我们应当注意,正如open中描述的,如果使用了dup或者fork等会复制文件描述符的操作,我们将会有多个文件描述符指向Linux内核空间中的epoll实例。只有所有的指向该epoll实例的文件描述符都被关闭,其内核空间中的资源才会被释放。

epoll_ctl

其函数签名为

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

其可以看作epoll机制中较为核心的系统调用。

总的来说,该系统调用接受的四个参数的意义为:

  • epfd

    epoll实例的文件描述符

  • op

    希望进行的操作

  • fd

    感兴趣的文件描述符

  • events

    对应于该文件描述符,感兴趣的事件

首先,epfd的意义很简单,就是我们调用epoll_createepoll_create1返回的epoll实例的文件描述符。

op是我们希望进行的操作,包括:

  • EPOLL_CTL_ADD

    向epoll实例的用户感兴趣的集合中增添元素,文件描述符由fd给出,感兴趣的事件由event给出

  • EPOLL_CTL_MOD

    修改epoll实例的用户感兴趣的集合中的元素。希望修改的元素的文件描述符由fd给出,修改后的事件由event给出

  • EPOLL_CTL_DEL

    删除epoll实例的用户感兴趣的集合中的元素。希望删除的元素的文件描述符由fd给出,event变量将被忽略,可以为NULL

fdevents就是这个系统调用的核心参数。

fd可以看作epoll_ctl阶段,在epoll实例中,用于区分用户感兴趣的集合中的不同元素的方法。这是因为,我们的增加、修改、删除操作,都是基于fd来选择相应的元素的。

这里有一点需要我们考虑。我们考虑以下情况:

我们通过dupfork等复制文件描述符的操作,创造了fd1fd2这两个文件描述符,但是其都指向同一个文件描述。如果我们将fd1fd2都加入epoll实例的用户感兴趣的集合,同时其对应的用户感兴趣的事件是不同的。然后,我们使用close关闭fd1。但由于文件描述没有被释放,在我们使用epoll_wait获取触发了的事件时,仍然会有fd1对应的事件报告出来。

因此,只有指向同一文件描述的所有文件描述符都被关闭,在epoll实例的用户感兴趣的集合中才会删除所有相应的元素。所以,我们在使用close关闭某个被加入epoll实例的文件描述符之前,记得要使用EPOLL_CTL_DEL操作先删除相应的元素。

event参数的类型是struct epoll_event的指针。结合我们之后将到的epoll_wait,这个参数的作用是标记相应的事件。当我们把这个参数传递给epoll_ctl时,这个参数表明我们关心的事件。随后我们使用epoll_wait同样会获得这个类型的实例,其表明触发的事件。

struct epoll_event的定义为

struct epoll_event {
    uint32_t     events;      /* Epoll events */
    epoll_data_t data;        /* User data variable */
};

首先来讲讲data字段。epoll_data_t的定义为

typedef union epoll_data {
    void        *ptr;
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;

这个字段对epoll实例来说并不会起到实际的用途。当我们把data作为一个字段,放在某个用户感兴趣的epoll_event事件中,传入epoll_ctl函数,那么内核就会记录这个data。当相应的事件触发之后,用户使用epoll_wait等获得相应的事件,此时data会不经修改地出现在返回的事件中。

这个data常见的用途是,由于epoll_wait等方法获得事件时,无法直接获得该事件对应的文件描述符,所以我们在使用epoll_ctl时,将文件描述符作为data即可。随后在epoll_wait获得的事件中,取其data字段,获得相应的fd

events字段是epoll的核心。events字段是一个位掩码,其主要包含以下几种标志位:

  • EPOLLET

    epoll处理的事件有两种模式:边沿触发(edge-triggered)与水平触发(level-triggered)。

    考虑以下情形:

    1. 我们向epoll实例注册一个文件描述符rfd,其代表某个管道的读端。我们关心其是否已经可读
    2. 从写端往管道里写了2KB数据
    3. 我们使用epoll_wait获得触发了的事件,其中包括我们事先注册的rfd
    4. 我们从rfd读取了1KB数据
    5. 我们再次使用epoll_wait

    如果是边沿触发模式,那么epoll只会在第3步的epoll_wait中给出rfd被触发的事件,第5步则不会给出相应的事件;如果是水平触发,那么epoll在第3步和第5步都会给出相应的事件。

    也就是说,只有在相应的文件描述符状态发生变化,从别的状态变成我们感兴趣的状态时,“边沿触发”才会给出我们相应的事件;只要相应的文件描述符处于感兴趣的状态时,“水平触发”就会给出我们相应的事件。

    因此,当我们使用边沿触发模式时,我们的readwrite操作不能只使用一次,因为之后相关的事件就不会被触发,也就不能读取或写入完整的数据了。我们应当在循环中使用readwrite,直到其返回错误EAGAIN(详见open)。同时由于EAGAIN错误只有在使用O_NONBLOCK标志位打开文件时才会出现,所以我们在使用边沿触发时要注意两点:

    • 使用非阻塞的文件描述符
    • readwrite要一直读取或写入到返回EAGAIN错误

    如果我们的events包含标志位EPOLLET,则该事件是边沿触发模式;如果不包含该标志位,则该事件是水平触发模式。

    该标志位只可包含在传递给epoll_ctlevents中,不会出现在epoll_wait等返回的events中。

  • EPOLLIN

    文件描述符已经可以被读取

  • EPOLLOUT

    文件描述符已经可以被写入

  • EPOLLRDHUP

    流套接字的对端关闭连接

  • EPOLLPRI

    select中的其他条件,poll中的POLLPRI

  • EPOLLERR

    出现错误。

    该标志位可能出现在epoll_wait等返回的events中,epoll默认关注这样的状态,因此并没有必要包含在传递给epoll_ctlevents

  • EPOLLHUP

    相关的文件描述符处于挂起状态。

    该标志位可能出现在epoll_wait等返回的events中,epoll默认关注这样的状态,因此并没有必要包含在传递给epoll_ctlevents

  • EPOLLONESHOT

    如果包含该标志位,该事件被触发,被epoll_wait等返回,那么该事件对应的文件描述符将不再会有别的事件被epoll实例关注。也就是说,如果该文件描述符别的事件出现了,epoll实例并不会返回相应的结果。

    如果要再次接受相应的事件,就应在epoll_ctl中使用EPOLL_CTL_MOD,给该事件新的事件掩码。

    在一个多线程程序中,如果我们在一个循环中调用epoll,每次获得一个触发的事件,就开启一个新的线程去处理,那么有可能某个状态没有改变,但是导致某个事件被多次触发,从而使得我们有多个线程去处理同一个文件描述符的状态。因此我们可以使用EPOLLONESHOT标志位来避免这种事。

    该标志位只可包含在传递给epoll_ctlevents中,不会出现在epoll_wait等返回的events中。

epoll_waitepoll_pwait

这两个系统调用的函数签名为

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events, int maxevents, int timeout, const sigset_t *sigmask);

epoll_waitepoll_pwait就是从epoll实例的准备好的集合中,获取相应的事件。其参数包括:

  • epfd为epoll实例的文件描述符。
  • events为一个数组,其元素类型为struct epoll_event,长度为maxevents
  • timeout为超时参数。

对于events参数,epoll实例会从准备好的集合中,选取至多maxevents个事件放入该数组中。

对于timeout参数,其精度为毫秒,如果其为-1,则epoll_wait将无限等待;如果其为0,则epoll_wait将立即返回。

epoll_wait的返回值为准备好的文件描述符的个数。

pselectppoll类似,epoll_pwait就是加上了信号掩码的epoll_wait

因此,如果使用epoll来实现我们在selectpoll中提出的方案三,其方法为:

void process_fds(int *fds, int nfd) {
    int epfd = epoll_create1(0);
    for (int i = 0; i < nfd; i++) {
        struct epoll_event ev;
        ev.events = EPOLLIN;
        ev.data.fd = fds[i];
        epoll_ctl(epfd, EPOLL_CTL_ADD, fds[i], &ev);
    }
    struct epoll_event *events = (struct epoll_event *)malloc(nfd * sizeof(struct epoll_event));
    while (1) {
        int ready_nfd = epoll_wait(epfd, events, nfd, -1);
        if (ready_nfd <= 0) {
            break;
        }
        for (int i = 0; i < ready_nfd; i++) {
            if (events[i].events & EPOLLIN) {
                char buf[64];
                read(events[i].data.fd, buf, 64);
                process_read_content(buf);
            }
        }
    }
}

实现

epoll的实现位于Linux内核的fs/eventpoll.c文件中。

在epoll的实现中,有两个结构体最为关键:struct eventpollstruct epitem

struct eventpoll就是内核中的epoll实例的结构体,而struct epitem就是一个文件描述符与它相关的事件组成的结构体,也就是epoll实例的两个集合的元素。

它们的部分字段如下:

struct eventpoll:

struct eventpoll {
	/* Wait queue used by sys_epoll_wait() */
	wait_queue_head_t wq;

	/* Wait queue used by file->poll() */
	wait_queue_head_t poll_wait;

	/* List of ready file descriptors */
	struct list_head rdllist;

	/* RB tree root used to store monitored fd structs */
	struct rb_root_cached rbr;

	/*
	 * This is a single linked list that chains all the "struct epitem" that
	 * happened while transferring ready events to userspace w/out
	 * holding ->lock.
	 */
	struct epitem *ovflist;

	/* wakeup_source used when ep_scan_ready_list is running */
	struct wakeup_source *ws;

	struct list_head visited_list_link;
};

struct epitem:

struct epitem {
	union {
		/* RB tree node links this structure to the eventpoll RB tree */
		struct rb_node rbn;
		/* Used to free the struct epitem */
		struct rcu_head rcu;
	};

	/* List header used to link this structure to the eventpoll ready list */
	struct list_head rdllink;

	/*
	 * Works together "struct eventpoll"->ovflist in keeping the
	 * single linked chain of items.
	 */
	struct epitem *next;

	/* The file descriptor information this item refers to */
	struct epoll_filefd ffd;

	/* Number of active wait queue attached to poll operations */
	int nwait;

	/* List containing poll wait queues */
	struct list_head pwqlist;

	/* The "container" of this item */
	struct eventpoll *ep;

	/* List header used to link this item to the "struct file" items list */
	struct list_head fllink;

	/* wakeup_source used when EPOLLWAKEUP is set */
	struct wakeup_source __rcu *ws;

	/* The structure that describe the interested events and the source fd */
	struct epoll_event event;
};

首先是struct rb_root_cached rbr这个字段。这就是epoll实例中用于存储用户感兴趣的事件的结构。它是一个红黑树,其包含的元素可以看作我们的struct epitem(其字段rbn就是表示在这棵红黑树的节点)。当我们使用epoll_create创建一个epoll实例时,这棵红黑树被初始化。当我们使用epoll_ctl去操作感兴趣的集合时,我们实际上就是增添、修改、删除这棵红黑树的元素。

这里值得注意的是,我们之前提到,在epoll_ctl阶段,可以把文件描述符看作集合的键,在我们操作这个集合的时候,通过这个键来区分不同的元素。但实际并不是这样。

以这棵红黑树的插入为例,其实现为ep_rbtree_insert:

static void ep_rbtree_insert(struct eventpoll *ep, struct epitem *epi)
{
	int kcmp;
	struct rb_node **p = &ep->rbr.rb_root.rb_node, *parent = NULL;
	struct epitem *epic;
	bool leftmost = true;

	while (*p) {
		parent = *p;
		epic = rb_entry(parent, struct epitem, rbn);
		kcmp = ep_cmp_ffd(&epi->ffd, &epic->ffd);
		if (kcmp > 0) {
			p = &parent->rb_right;
			leftmost = false;
		} else
			p = &parent->rb_left;
	}
	rb_link_node(&epi->rbn, parent, p);
	rb_insert_color_cached(&epi->rbn, &ep->rbr, leftmost);
}

可以看到在11行,通过调用ep_cmp_ffd来判断是否两个元素相同。首先我们来看看struct epitemffd字段,其类型为struct epoll_filefd:

struct epoll_filefd {
	struct file *file;
	int fd;
} __packed;

ep_cmp_ffd的实现为

static inline int ep_cmp_ffd(struct epoll_filefd *p1, struct epoll_filefd *p2)
{
	return (p1->file > p2->file ? +1:
	        (p1->file < p2->file ? -1 : p1->fd - p2->fd));
}

因此我们可以看到,内核是同时使用文件描述与文件描述符作为这棵红黑树的键的。

如果我们想通过epoll_ctl增加一个我们感兴趣的元素,我们做的核心实际上是增加了一个回调函数。首先我们需要知道,在include/linux/poll.h中,我们对某个文件的poll操作,其最终是这样的情形:

/* 
 * structures and helpers for f_op->poll implementations
 */
typedef void (*poll_queue_proc)(struct file *, wait_queue_head_t *, struct poll_table_struct *);

/*
 * Do not touch the structure directly, use the access functions
 * poll_does_not_wait() and poll_requested_events() instead.
 */
typedef struct poll_table_struct {
	poll_queue_proc _qproc;
	__poll_t _key;
} poll_table;

static inline __poll_t vfs_poll(struct file *file, struct poll_table_struct *pt)
{
	if (unlikely(!file->f_op->poll))
		return DEFAULT_POLLMASK;
	return file->f_op->poll(file, pt);
}

我们使用vfs_poll之后,会把filept传入其对应的实现中。而ptstruct poll_table_struct的指针,其中,_key字段是一个掩码,表明哪些事件是用户关注的;poll_queue_proc _qproc是一个函数指针。当出现了_key中的事件时,会自动触发这个回调函数。

当我们使用epoll_ctl去创建新的红黑树节点时,有一步为

/* Initialize the poll table using the queue callback */
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);

/*
 * Attach the item to the poll hooks and get current event bits.
 * We can safely use the file* here because its usage count has
 * been increased by the caller of this function. Note that after
 * this operation completes, the poll callback can start hitting
 * the new item.
 */
revents = ep_item_poll(epi, &epq.pt, 1);

这就是我们设置相应回调函数的地方。epq的类型是struct ep_pqueue,其定义为

struct ep_pqueue {
	poll_table pt;
	struct epitem *epi;
};

也就是说把poll_table封装了一层。我们通过init_poll_funcptr设置了epqpoll_table,然后通过ep_item_poll把这个poll_table传入了最终的vfs_poll函数中。

在epoll中,poll_table_key字段,也就是用户感兴趣的事件是全部事件,epoll会从触发的事件中筛选出用户感兴趣的事件。回调函数则是ep_ptable_queue_proc,其设置了回调函数ep_poll_callback

TODO:增加更多的描述。参考资料:

stat, fstat, lstat, newfstatat, statx系统调用

stat, fstat, lstatnewfstatat

系统调用号

stat为4,fstat为5,lstat为6,newfstatat为262。

函数签名

内核接口

asmlinkage long sys_newstat(const char __user *filename, struct stat __user *statbuf);
asmlinkage long sys_newfstat(unsigned int fd, struct stat __user *statbuf);
asmlinkage long sys_newlstat(const char __user *filename, struct stat __user *statbuf);
asmlinkage long sys_newfstatat(int dfd, const char __user *filename, struct stat __user *statbuf, int flag);

glibc封装

stat, fstat, lstat:

#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
int stat(const char *pathname, struct stat *statbuf);
int fstat(int fd, struct stat *statbuf);
int lstat(const char *pathname, struct stat *statbuf);

newfstatat:

#include <fcntl.h>
#include <sys/stat.h>
int fstatat(int dirfd, const char *pathname, struct stat *statbuf, int flags);

简介

这四个系统调用都和文件的状态信息有关,也就是和struct stat这个结构体密切相关。首先,我们来看看其定义(该结构体在不同的指令集、内核版本中都不尽相同,该结构体为当前Linux内核版本下的x86_64版本):

struct stat {
    dev_t st_dev;
    ino_t st_ino;
    nlink_t st_nlink;
    mode_t st_mode;
    uid_t st_uid;
    gid_t st_gid;
    dev_t st_rdev;
    off_t st_size;
    blksize_t st_blksize;
    blkcnt_t st_blocks;
#ifdef __USE_XOPEN2K8
    struct timespec st_atim;
    struct timespec st_mtim;
    struct timespec st_ctim;
#define st_atime st_atim.tv_sec
#define st_mtime st_mtim.tv_sec
#define st_ctime st_ctim.tv_sec
#else
    time_t st_atime;
    unsigned long st_atimensec;
    time_t st_mtime;
    unsigned long st_mtimensec;
    time_t st_ctime;
    unsigned long st_ctimensec;
#endif
};

其中每个字段的含义如下:

  • st_dev: 包含该文件的设备ID

  • st_ino: inode数

  • st_nlink: 该文件的硬链接数

  • st_mode: 文件类型和模式。

    • 文件类型

      通过与位掩码S_IFMT相与可得到相应的文件类型:

      • S_IFSOCK: 套接字文件
      • S_IFLINK: 符号链接
      • S_IFREG: 常规文件
      • S_IFBLK: 块设备
      • S_IFDIR: 目录
      • S_IFCHR: 字符设备
      • S_IFOFO: FIFO(管道)
    • 文件模式

      通过与下列掩码相与可以得到相应的数据:

      • 特殊权限

        • S_ISUID: SUID位

          在大部分情况下,如果将一个可执行的二进制程序的该位设为1,则运行该二进制程序产生的进程的euid与该文件的uid相同。该进程拥有该文件属主的权限。

        • S_ISGID: SGID位

          在大部分情况下,如果将一个可执行的二进制程序的该位设为1,则运行该二进制程序产生的进程的egid与该文件的gid相同。该进程拥有该文件属组的权限。

          如果将一个目录的该位设为1,则表明在其中创建的所有文件的gid均与该目录相同,而不与创建该文件的进程的gid相同。

        • S_ISVTX: Sticky位

          如果将一个目录的该位设为1,则该目录中所有文件只能被该文件的属主、该目录的属主以及特权进程重命名或删除。

      • 所有者权限

        • S_IRWXU: 属主拥有读、写、执行权限
        • S_IRUSR: 属主拥有读权限
        • S_IWUSR: 属主拥有写权限
        • S_IXUSR: 属主拥有执行权限
      • 用户组权限

        • S_IRWXG: 属组拥有读、写、执行权限
        • S_IRGRP: 属组拥有读权限
        • S_IWGRP: 属组拥有写权限
        • S_IXGRP: 属组拥有执行权限
      • 其它用户权限

        • S_IRWXO: 他人拥有读、写、执行权限
        • S_IROTH: 他人拥有读权限
        • S_IWOTH: 他人拥有写权限
        • S_IXOTH: 他人拥有执行权限
  • st_uid: 该文件的uid

  • st_gid: 该文件的gid

  • st_rdev: 如果该文件表示一个设备,则为该文件所表示的设备ID

  • st_size: 文件大小(以字节计)

    如果该文件是一个符号链接,则表示其链接的源路径对应的字符串长度

  • st_blksize: 使用高效文件IO时,推荐使用的块大小

  • st_blocks: 分配给该文件的块数目(以512字节为一个单位)

  • 时间戳

    • 首先,各个名称的意义:

      • atime: 最后访问时间
      • mtime: 最后修改时间
      • ctime: 文件状态最后修改时间
      • mtimectime的区别在于,前者是文件内容的修改,而后者则是文件对应inode的修改。如只修改st_atime字段,而不修改文件内容,则mtime不变,ctime发生改变。
    • 如果:

      • _POSIX_C_SOURCE宏定义为不小于200809L的值
      • _XOPEN_SOURCE定义为不小于700的值
      • _BSD_SOURCE被定义
      • _SVID_SOURCE被定义

      上述条件只需要满足任意一个,我们就可以通过st_atimest_atim.tv_sec访问UNIX时间戳(以秒为单位),st_atime.tv_nsec访问其纳秒部分(不是总时长,而是除去秒部分,剩下的纳秒部分的大小)。其余几个时间也可以用类似的方式访问秒和纳秒部分。

    • 如果上面的条件都不满足,我们则可以通过st_atime访问UNIX时间戳(以秒为单位),st_atimensec访问其纳秒部分。

    • 也就是说,无论何种情况,我们都可以用st_atime访问以秒为单位的UNIX时间戳。

stat是根据文件路径获得相应的文件状态信息,但如果文件是一个符号链接,则会获得其指向的实际文件的信息。

lstat也是根据文件路径获得相应的文件信息,但如果文件是一个符号链接,则会获得链接本身的状态信息。

fstat是根据文件描述符获得相应的文件信息,行为和stat相同。

fstatat则是一个更普遍的接口,可以提供stat, lstat, fstat的功能。首先,dirfdpathname的用法与openat相同,既可以用于绝对路径,也可以用于相对特定目录的相对路径,也可以用于相对当前目录的相对路径。其次,对于flags,其主要的标志位的功能有:

  • 如果包含AT_EMPTY_PATH标志位,且filename是空字符串,则获得dirfd句柄对应的文件的状态信息。此时dirfd可以是任何文件类型,而不一定是目录。这种行为类似fstat
  • 如果包含AT_SYMLINK_NOFOLLOW标志位,且dirfdfilename组成的路径是一个符号链接,则获得链接本身的状态信息(相当于lstat);如果不包含该标志位,则获得链接指向的实际文件的信息(相当于stat)。

实现

这四个系统调用的实现均位于fs/stat.c中。其实现的核心为位于fs/stat.c中的vfs_getattr_nosec函数:

int vfs_getattr_nosec(const struct path *path, struct kstat *stat, u32 request_mask, unsigned int query_flags)
{
	struct inode *inode = d_backing_inode(path->dentry);

	memset(stat, 0, sizeof(*stat));
	stat->result_mask |= STATX_BASIC_STATS;
	request_mask &= STATX_ALL;
	query_flags &= KSTAT_QUERY_FLAGS;

	/* allow the fs to override these if it really wants to */
	if (IS_NOATIME(inode))
		stat->result_mask &= ~STATX_ATIME;
	if (IS_AUTOMOUNT(inode))
		stat->attributes |= STATX_ATTR_AUTOMOUNT;

	if (inode->i_op->getattr)
		return inode->i_op->getattr(path, stat, request_mask,
					    query_flags);

	generic_fillattr(inode, stat);
	return 0;
}

我们可以看到,其最关键的就在于inode->i_op->getattr。因此,其和read, write类似,也是通过函数指针实现多态的方式,获得相应的状态。

statx

系统调用号

332

函数签名

内核接口

asmlinkage long sys_statx(int dfd, const char __user *path, unsigned flags, unsigned mask, struct statx __user *buffer);

glibc封装

#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
int statx(int dirfd, const char *pathname, int flags, unsigned int mask, struct statx *statxbuf);

上述头文件是在statx的man手册里写的,但实际使用时似乎还应该包含linux/stat.h头文件。

简介

该系统调用的功能类似与newfstatat,但是,其提供的信息的类型是struct statx而不是struct stat,其包含的信息更多。

使用该函数的方式与fstatat类似,其中mask表示的是用户感兴趣的字段。也就是说,我们可以通过mask让返回的statxbuf只有部分字段有意义,其余不感兴趣的字段不需要内核来填充。其提供的标志位包括:

  • STATX_TYPE: 得到stx_mode & S_IFMT
  • STATX_MODE: 得到stx_mode & ~S_IFMT
  • STATX_NLINK: 得到stx_nlink
  • STATX_UID: 得到stx_uid
  • STATX_GID: 得到stx_gid
  • STATX_ATIME: 得到stx_atime
  • STATX_MTIME: 得到stx_mtime
  • STATX_CTIME: 得到stx_ctime
  • STATX_INO: 得到stx_ino
  • STATX_SIZE: 得到stx_size
  • STATX_BLOCKS: 得到stx_blocks
  • STATX_BASIC_STATS: 得到上述全部
  • STATX_BTIME: 得到stx_btime
  • STATX_ALL: 得到所有的字段

该结构体的内容是:

struct statx {
	__u32	stx_mask;	/* What results were written [uncond] */
	__u32	stx_blksize;	/* Preferred general I/O size [uncond] */
	__u64	stx_attributes;	/* Flags conveying information about the file [uncond] */
	__u32	stx_nlink;	/* Number of hard links */
	__u32	stx_uid;	/* User ID of owner */
	__u32	stx_gid;	/* Group ID of owner */
	__u16	stx_mode;	/* File mode */
	__u16	__spare0[1];
	__u64	stx_ino;	/* Inode number */
	__u64	stx_size;	/* File size */
	__u64	stx_blocks;	/* Number of 512-byte blocks allocated */
	__u64	stx_attributes_mask; /* Mask to show what's supported in stx_attributes */
	struct statx_timestamp	stx_atime;	/* Last access time */
	struct statx_timestamp	stx_btime;	/* File creation time */
	struct statx_timestamp	stx_ctime;	/* Last attribute change time */
	struct statx_timestamp	stx_mtime;	/* Last data modification time */
	__u32	stx_rdev_major;	/* Device ID of special file [if bdev/cdev] */
	__u32	stx_rdev_minor;
	__u32	stx_dev_major;	/* ID of device containing file [uncond] */
	__u32	stx_dev_minor;
	__u64	__spare2[14];	/* Spare space for future expansion */
};

其与struct stat的主要区别在于:

  • stx_mask: 即传入的mask参数,表示哪些字段有意义
  • stx_attributes: 该文件更多的状态信息。其主要包含如下标志位:
    • STATX_ATTR_IMMUTABLE: 该文件不能被修改
    • STATX_ATTR_APPEND: 该文件只能以append模式写入,也就是说每次写入都必须在文件最后
    • STATX_ATTR_DAX: 该文件处于DAX状态(CPU直接访问)。TODO:进一步解释
  • stx_attributes_mask: 掩码,用于表示stx_attributes的哪些标志位被设置了(有可能有的标志位没有被操作系统设置,而不是没有包含该标志位)

实现

其实现与stat等系统调用相同。内核会根据系统调用的类型,确定一个内部的掩码,根据掩码获得相应的文件信息。

eventfd, eventfd2系统调用

系统调用号

  • eventfd: 284
  • eventfd2: 290

函数原型

内核接口

asmlinkage long sys_eventfd(unsigned int count);
asmlinkage long sys_eventfd2(unsigned int count, int flags);

glibc封装

#include <sys/eventfd.h>

int eventfd(unsigned int initval, int flags);

简介

自内核 2.6.22 起,Linux 通过 eventfd() 系统调用额外提供了一种非标准的同步机制。这个系统调用创建了一个 eventfd 对象,该对象拥有一个相关的由内核维护的 8 字节无符号整数。通知机制就建立在这个无符号整数的数值变化上。

这个系统调用返回一个指向该 eventfd 的文件描述符。用户可以对这个文件描述符使用 readwrite 系统调用,来操作由内核维护的数值。

此外,eventfd 可以和 epoll 等多路复用的系统调用一同使用:我们可以使用多路复用的系统调用测试对象值是否为非零,如果是非零的话就表示文件描述符可读。

在 linux 2.6.22 之后,eventfd 可用。在 linux 2.6.27 之后,eventfd2 可用。他们二者的区别是,eventfd 系统调用没有 flags 参数。而 glibc 从2.9开始,提供的 eventfd 底层则会调用 eventfd2 来进行实现(如果 eventfd2 被内核支持的话)。

因此如果内核版本不支持,您务必要将 flags 设置为0,除此之外这两个系统调用没有差别。

使用

函数签名

int eventfd(unsigned int initval, int flags);
  • unsigned int initval: 内核中维护的无符号整数的初始值,我们一般叫它 counter

  • int flags:

    • 2.6.26 及之前,这个flags还不支持,必须设置成0
    • flags 设置后,会影响对 eventfd 对象操作(如 read write)时的行为,有三种:
      • EFD_CLOEXEC
      • EFD_NONBLOCK
      • EFD_SEMAPHORE
  • return val int: 返回一个指向该对象的文件描述符

配合 readwrite 使用

  • read(2)
    • EFD_SEMAPHORE 如果没有被设置,从 eventfd read,会得到 counter,并将它归0
    • EFD_SEMAPHORE 如果被设置,从 eventfd read,会得到值 1,并将 counter - 1
    • counter 为 0 时,对它进行 read
      • EFD_NONBLOCK 如果被设置,那么会以 EAGAIN 的错失败
      • 否则 read 会被阻塞,直到为非0。
  • write(2)
    • 会把一个 8 字节的int值写入到 counter 里
    • 最大值是 2^64 - 1
    • 如果写入时会发生溢出,则write会被阻塞
      • 如果 EFD_NONBLOCK 被设置,那么以 EAGAIN 失败
    • 以不合法的值写入时,会以 EINVAL 失败
      • 比如 0xffffffffffffffff 不合法
      • 比如 写入的值 size 小于8字节

配合多路复用使用

poll(2), select(2), epoll(7)

  • 作为被监听的 fd 用于多路复用API
  • 如果 counter 的值大于 0 ,那么 fd 的状态就是「可读的」
    • selectreadfds 生效
    • pollPOLLIN 生效
  • 如果能无阻塞地写入一个至少为 1 的值,那么 fd 的状态就是「可写的」
    • selectwritefds 生效
    • pollPOLLOUT 生效

作用

  • 所有通过 pipe(2) 来进行**通知(而非数据传输)**操作的,都可以用 eventfd(2) 来代替
    • 节省文件描述符资源:pipe需要两个文件描述符,eventfd只需要一个
    • 内存开销小:内核管理层面,后者开销比前者低
      • 后者只需要一个counter,8字节大小
      • 前者在内核和用户态之间会来回拷贝多次,还会分配额外的虚拟内存页
  • 提供了内核台与用户态之间沟通的桥梁
    • 比如kernel AIO,内核在事件完成后可以通过eventfd,通知用户态结构
  • eventfd 既可以监听传统的文件,也可以监听内核提供的类似epollselect实例文件
  • eventfd 是并发安全的

例子

使用效果:

$ ./a.out 1 2 4 7 14
Child writing 1 to efd
Child writing 2 to efd
Child writing 4 to efd
Child writing 7 to efd
Child writing 14 to efd
Child completed write loop
Parent about to read
Parent read 28 (0x1c) from efd

源代码:

#include <sys/eventfd.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>             /* Definition of uint64_t */

#define handle_error(msg) \
    do { perror(msg); exit(EXIT_FAILURE); } while (0)
// 这个例子展示了 eventfd 在进程间通知的作用
int
main(int argc, char *argv[])
{
    int efd, j;
    uint64_t u;
    ssize_t s;
   // 接受至少一个数字
   if (argc < 2) {
        fprintf(stderr, "Usage: %s <num>...\n", argv[0]);
        exit(EXIT_FAILURE);
    }
   // flags 为 0
   // 初始值为 0
   efd = eventfd(0, 0);
   if (efd == -1)
        handle_error("eventfd");
   // fork 出子进程
   switch (fork()) {
    case 0: // 子进程下执行
        for (j = 1; j < argc; j++) {
            // 子进程负责把传入的参数写到 counter 里
            printf("Child writing %s to efd\n", argv[j]);
            u = strtoull(argv[j], NULL, 0);
                    /* strtoull() allows various bases */
            s = write(efd, &u, sizeof(uint64_t));
            if (s != sizeof(uint64_t))
                handle_error("write");
        }
        printf("Child completed write loop\n");

       exit(EXIT_SUCCESS);

   default: // 父进程下
        sleep(2); // 父进程暂时阻塞

       printf("Parent about to read\n");
       	// 阻塞结束后,父进程应当读到子进程写入的值之和
        s = read(efd, &u, sizeof(uint64_t));
        if (s != sizeof(uint64_t))
            handle_error("read");
        printf("Parent read %llu (0x%llx) from efd\n",
                (unsigned long long) u, (unsigned long long) u);
        exit(EXIT_SUCCESS);

   case -1:
        handle_error("fork");
    }
}

你可以修改一下代码,体验一下它如何通过 read write 进行通知作用的。

实现

eventfd 的实现位于Linux内核的fs/eventfd.c文件中。其中 struct eventfd_ctx 即是我们上文所说的 counter

struct eventfd_ctx {
	struct kref kref;
	wait_queue_head_t wqh;
	/*
	 * Every time that a write(2) is performed on an eventfd, the
	 * value of the __u64 being written is added to "count" and a
	 * wakeup is performed on "wqh". A read(2) will return the "count"
	 * value to userspace, and will reset "count" to zero. The kernel
	 * side eventfd_signal() also, adds to the "count" counter and
	 * issue a wakeup.
	 */
	__u64 count;
	unsigned int flags;
	int id;
};

可以看到,eventfd 的实现其实就是通过内核维护的一个等待队列来控制进程的唤醒和阻塞。

TODO:

  • 详细的 eventfd 的分析
  • eventfd 和多路复用系统调用的配合使用例子。

mmap, munmap, mremap, msync, remap_file_pages系统调用

mmap

系统调用号

9

函数签名

内核接口

asmlinkage long sys_mmap(unsigned long addr, unsigned long len, unsigned long prot, unsigned long flags, unsigned long fd, off_t pgoff);

glibc封装

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

简介

mmap做了一个特别神奇的事:把硬盘上的文件与内存之间建立映射。首先,我们来看看最终效果。假设我们有一个二进制文件data.bin,其内容为(16进制):

11 45 14 19 19 81 00

我们通过mmap,将这个文件映射到了内存中从0x10000开始的区域。接下来,如果我们的程序从内存0x10002读取2字节的内容,将得到0x1914这个数字(小端序)。

也就是说,我们没有借助read系统调用,而是直接对某个内存区域进行读取,就能读取到硬盘上的文件的内容。

接着我们来看看其参数。

总得来说,mmap的行为是,在flags的控制下,将描述符为fd的文件中,从offset位置开始,length个字节映射到地址为addr的内存空间中,并设置其内存保护为prot

在一般情况下,地址和长度应遵守这样的限制:

  • 如果addrNULL,则内核自己选择适当的内存地址进行映射。如果其不为NULL,则内核以addr的值为参考,选择一个适当的内存地址进行映射(一般是之后最近的页边界)。
  • offset应为页大小的倍数。
  • length应大于0。如果被映射的大小不是页大小的整数倍,则剩下的页的部分会被0填充。

控制mmap行为的核心为flags。其可以包含以下标志位:

  • 核心标志位

    以下三个标志位必须且只能包含一个。

    • MAP_SHARED

      建立一个共享的映射。

      如果在该进程中,对该映射后的内存区域进行修改,那么在别的进程中,如果其使用了mmap,将同一个文件也进行了映射,那么可以同步看见该修改。同时被映射的文件也会被修改。

    • MAP_SHARED_VALIDATE

      行为和MAP_SHARED类似。但是会核验flags,如果其包含了未知的标志位,将报错EOPNOTSUPP

    • MAP_PRIVATE

      建立一个私有的写时复制(copy-on-write)的映射。

      对该映射的内存区域进行的修改不会同步到其他进程中,也不会修改硬盘里相应的文件。

  • 附加标志位

    除了三个必要的标志位之外,还有一些标志位也可以被包含。其主要包括

    • MAP_ANONYMOUS

      忽略fd,被映射的内存区域将被初始化为0。

      此时fd应为-1,offset应为0。

    • MAP_FIXED

      addr看作确切的地址,而非一个参考。

      内核将准确地将文件映射到从addr开始的内存区域。如果这个映射与之前已经存在的内存映射有重合,则重合的部分将被新的映射覆盖。

    • MAP_FIXED_NOREPLACE

      行为和MAP_FIXED类似,但不会覆盖已有的内存映射。如果与已有的内存映射有重合,那么将直接返回错误EEXIST

prot参数则是控制映射的内存区域的内存保护,其可能的值包括

  • PROC_EXEC

    页可执行

  • PROC_READ

    页可读

  • PROC_WRITE

    页可写

  • PROC_NONE

    页不可访问

使用mmap读取文件的好处在于:使用read读取文件时,会先将文件的内容从硬盘上复制到内核的内存空间中,然后再由内核将数据复制到用户的内存空间中。但使用mmap时,文件的内容是可以直接复制到用户的内存空间中的。

实现

mmap的实现位于Linux内核源码的arch/x86/kernel/sys_x86_64.c,其直接调用了位于mm/mmap.cksys_mmap_pgoff函数。在经过了复杂的函数链之后,我们可以发现,其最终是调用的位于include/linux/fs.hcall_mmap函数:

static inline int call_mmap(struct file *file, struct vm_area_struct *vma)
{
	return file->f_op->mmap(file, vma);
}

其中struct vm_area_struct这个结构体,表示一块虚拟内存,其包含一个类型为struct vm_operations_struct的字段vm_ops,表示对虚拟内存的操作,其定义在include/linux/mm.h:

/*
 * These are the virtual MM functions - opening of an area, closing and
 * unmapping it (needed to keep files on disk up-to-date etc), pointer
 * to the functions called when a no-page or a wp-page exception occurs.
 */
struct vm_operations_struct {
	void (*open)(struct vm_area_struct * area);
	void (*close)(struct vm_area_struct * area);
	int (*split)(struct vm_area_struct * area, unsigned long addr);
	int (*mremap)(struct vm_area_struct * area);
	vm_fault_t (*fault)(struct vm_fault *vmf);
	vm_fault_t (*huge_fault)(struct vm_fault *vmf,
			enum page_entry_size pe_size);
	void (*map_pages)(struct vm_fault *vmf,
			pgoff_t start_pgoff, pgoff_t end_pgoff);
	unsigned long (*pagesize)(struct vm_area_struct * area);

	/* notification that a previously read-only page is about to become
	 * writable, if an error is returned it will cause a SIGBUS */
	vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);

	/* same as page_mkwrite when using VM_PFNMAP|VM_MIXEDMAP */
	vm_fault_t (*pfn_mkwrite)(struct vm_fault *vmf);

	/* called by access_process_vm when get_user_pages() fails, typically
	 * for use by special VMAs that can switch between memory and hardware
	 */
	int (*access)(struct vm_area_struct *vma, unsigned long addr,
		      void *buf, int len, int write);

	/* Called by the /proc/PID/maps code to ask the vma whether it
	 * has a special name.  Returning non-NULL will also cause this
	 * vma to be dumped unconditionally. */
	const char *(*name)(struct vm_area_struct *vma);

#ifdef CONFIG_NUMA
	/*
	 * set_policy() op must add a reference to any non-NULL @new mempolicy
	 * to hold the policy upon return.  Caller should pass NULL @new to
	 * remove a policy and fall back to surrounding context--i.e. do not
	 * install a MPOL_DEFAULT policy, nor the task or system default
	 * mempolicy.
	 */
	int (*set_policy)(struct vm_area_struct *vma, struct mempolicy *new);

	/*
	 * get_policy() op must add reference [mpol_get()] to any policy at
	 * (vma,addr) marked as MPOL_SHARED.  The shared policy infrastructure
	 * in mm/mempolicy.c will do this automatically.
	 * get_policy() must NOT add a ref if the policy at (vma,addr) is not
	 * marked as MPOL_SHARED. vma policies are protected by the mmap_sem.
	 * If no [shared/vma] mempolicy exists at the addr, get_policy() op
	 * must return NULL--i.e., do not "fallback" to task or system default
	 * policy.
	 */
	struct mempolicy *(*get_policy)(struct vm_area_struct *vma,
					unsigned long addr);
#endif
	/*
	 * Called by vm_normal_page() for special PTEs to find the
	 * page for @addr.  This is useful if the default behavior
	 * (using pte_page()) would not find the correct page.
	 */
	struct page *(*find_special_page)(struct vm_area_struct *vma,
					  unsigned long addr);
};

和我们的mmap有关的字段是

vm_fault_t (*fault)(struct vm_fault *vmf);

这个函数指针将在我们出现页错误的时候调用。

我们上面看到,mmap最终落实到了各个文件类型自己定义的mmap操作中。我们常见的EXT4文件系统中,这个操作为函数ext4_file_mmap:

static const struct vm_operations_struct ext4_file_vm_ops = {
	.fault		= ext4_filemap_fault,
	.map_pages	= filemap_map_pages,
	.page_mkwrite   = ext4_page_mkwrite,
};

static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
	struct inode *inode = file->f_mapping->host;
	struct ext4_sb_info *sbi = EXT4_SB(inode->i_sb);
	struct dax_device *dax_dev = sbi->s_daxdev;

	if (unlikely(ext4_forced_shutdown(sbi)))
		return -EIO;

	/*
	 * We don't support synchronous mappings for non-DAX files and
	 * for DAX files if underneath dax_device is not synchronous.
	 */
	if (!daxdev_mapping_supported(vma, dax_dev))
		return -EOPNOTSUPP;

	file_accessed(file);
	if (IS_DAX(file_inode(file))) {
		vma->vm_ops = &ext4_dax_vm_ops;
		vma->vm_flags |= VM_HUGEPAGE;
	} else {
		vma->vm_ops = &ext4_file_vm_ops;
	}
	return 0;
}

可以看到,在通常情况下,是使用ext4_filemap_fault作为我们之前讲的vm_operations_struct中的fault字段。这个函数最终会被落实到mm/filemap.cfilemap_fault函数。在这个函数中,如果这个文件在内核的页缓存中,则直接去找那个页即可。如果没有,则调用pagecache_get_page,最终使用__add_to_page_cache_locked创建相应的页。

munmap

系统调用号

11

函数签名

内核接口

asmlinkage long sys_munmap(unsigned long addr, size_t len);

glibc封装

#include <sys/mman.h>
int munmap(void *addr, size_t length);

简介

在使用mmap将文件映射到内存空间之后,即使我们使用close关闭被映射的文件的描述符,该映射依然存在。如果需要取消相应的映射,我们可以使用munmap

munmap接受两个参数,表示需要取消映射的内存范围,其中,addr需要是页大小的整数倍。对于从addr开始,长度为length的内存区域,只要某个通过mmap建立的内存映射与该区域有交集,那么相应的内存映射就将被取消。

实现

munmap的实现位于Linux内核源码的mm/mmap.c文件中,其核心代码为__do_munmap函数:

mremap

系统调用号

25

函数签名

内核接口

asmlinkage long sys_mremap(unsigned long addr, unsigned long old_len, unsigned long new_len, unsigned long flags, unsigned long new_addr);

glibc封装

#define _GNU_SOURCE
#include <sys/mman.h>
void *mremap(void *old_address, size_t old_size, size_t new_size, int flags, ... /* void *new_address */);

简介