在本章之前的内容部分概述了系统调用的实现机制,现在我们将试着详细讲解Linux内核中不同系统调用的实现。本章之前的部分和本书其他章节描述的Linux内核机制大部分对用户空间是隐约可见或完全不可见。但是Linux内核代码不仅仅是有关内核的,大量的内核代码为我们的应用代码提供了支持。通过Linux内核,我们的程序可以在不知道扇区、磁道和磁盘的其他结构的情况下对文件进行读写操作,我们也不需要手动去构造和封装网络数据包就可以通过网络发送数据。
我们的程序通过系统调用这个特定的机制和内核进行交互。因此,我决定去写一些系统调用的实现及其行为,比如我们每天会用到的 read, write, open, close, dup 等等。
我决定从open系统调用开始。如果你对C程序有了解,你应该知道在我们能对一个文件进行读写或执行其他操作前,我们需要使用 open 函数打开这个文件:
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
int main(int argc, char *argv) {
int fd = open("test", O_RDONLY);
if fd < 0 {
perror("Opening of the file is failed\n");
}
else {
printf("file sucessfully opened\n");
}
close(fd);
return 0;
}在这样的情况下,open 仅是来自标准库中的函数,而不是系统调用。标准库将为我们调用相关的系统调用。open调用将返回一个文件描述符,这个文件描述符是一个独一无二的数值,和被打开的文件息息相关。现在我们使用open调用打开了一个文件并且得到了文件描述符,我们可以和这个文件交互了。我们可以写入,读取等等操作。程序中已打开的文件列表可通过proc 文件系统获取:
$ sudo ls /proc/1/fd/
0 13 2 27 31 36 40 45 50 55 6 64 69 73 78 82 87 91 98
1 14 20 28 32 37 41 46 51 56 60 65 7 74 79 83 88 92 99
10 15 22 29 33 38 42 47 52 57 61 66 70 75 8 84 89 93
11 16 23 3 34 39 43 48 53 58 62 67 71 76 80 85 9 94
12 19 26 30 35 4 44 5 54 59 63 68 72 77 81 86 90 95我并不打算在这篇文章中以用户空间的视角来描述open函数的细节,会更多地从内核的角度来分析。如果你不是很熟悉open函数,你可以在 man 手册获取更多信息。
如果你阅读过上一节,你应该知道系统调用通过SYSCALL_DEFINE宏定义实现。open系统调用位于fs/open.c源文件中,如下:
SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
if (force_o_largefile())
flags |= O_LARGEFILE;
return do_sys_open(AT_FDCWD, filename, flags, mode);
}可以看到,该函数调用了同一个源文件中的do_sys_open函数。但是在这个函数被调用前,我们来看看open系统调用定义的实现代码中 if 分支语句
if (force_o_largefile())
flags |= O_LARGEFILE;这里可以看到如果force_o_largefile()返回true,传递给open系统调用的flags参数会加上了 O_LARGEFILE 标志。O_LARGEFILE是什么?阅读open(2)man 手册可以了解到:
O_LARGEFILE
(LFS) Allow files whose sizes cannot be represented in an off_t
(but can be represented in an off64_t) to be opened. The
_LARGEFILE64_SOURCE macro must be defined (before including any
header files) in order to obtain this definition. Setting the
_FILE_OFFSET_BITS feature test macro to 64 (rather than using
O_LARGEFILE) is the preferred method of accessing large files on
32-bit systems (see feature_test_macros(7)).
在GNU C 标准库参考手册中可以获取更多信息:
Data Type: off_t This is a signed integer type used to represent file sizes. In the GNU C Library, this type is no narrower than int. If the source is compiled with _FILE_OFFSET_BITS == 64 this type is transparently replaced by off64_t.
Data Type: off64_t This type is used similar to off_t. The difference is that even on 32 bit machines, where the off_t type would have 32 bits, off64_t has 64 bits and so is able to address files up to 2^63 bytes in length. When compiling with _FILE_OFFSET_BITS == 64 this type is available under the name off_t.
因此不难猜到off_t, off64_t和 O_LARGEFILE是关于文件大小的。就Linux内核而言,在32位系统中如果调用者没有指定 O_LARGEFILE标志,就不允许打开大文件。在64位系统上,需要强制加上了这个标志。force_o_largefile宏在include/linux/fcntl.h头文件中定义,如下:
#ifndef force_o_largefile
#define force_o_largefile() (!IS_ENABLED(CONFIG_ARCH_32BIT_OFF_T))
#endif因此,force_o_largefile在我们当前的x86_64架构下展开为true,因此O_LARGEFILE标志将被添加到 open系统调用的flags参数中。
现在我们了解O_LARGEFILE标志和force_o_largefile宏的意义,我们可以继续讨论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);
trace_do_sys_open(tmp->name, flags, mode);
}
}
putname(tmp);
return fd;
}让我们试着一步一步理解 do_sys_open是如何工作。
根据函数定义,我们可以知道open系统调用的第二个参数flags控制文件打开的方式,第三个参数mode规定文件的权限。do_sys_open函数首先调用build_open_flags函数来检查给定的flags参数是否有效,并处理不同的flags和mode条件。
build_open_flags函数同样在fs/open.c中实现,需要三个参数:flags -- 控制打开文件的方式;mode -- 打开文件的权限;op -- open_flags结构体。
open_flags结构体在fs/internal.h文件中定义,该结构体保存了flags和权限模式信息。如下:
struct open_flags {
int open_flag;
umode_t mode;
int acc_mode;
int intent;
int lookup_flags;
};build_open_flags函数的主要目的就是生成一个open_flags结构体实例。
首先,定义了一个局部变量:
int acc_mode = ACC_MODE(flags);这个局部变量表示访问模式,它的初始值会等于 ACC_MODE 宏展开的值,这个宏在include/linux/fs.h中定义,如下:
#define ACC_MODE(x) ("\004\002\006\006"[(x)&O_ACCMODE])"\004\002\006\006" 是一个四字符的数组,如下:
"\004\002\006\006" == {'\004', '\002', '\006', '\006'}因此,ACC_MODE宏展开后就是数组中[(x) & O_ACCMODE]索引的值。我们可以看到,O_ACCMODE的值为00000003。通过x & O_ACCMODE后,我们获取最后两个bit位的值,分别表示 read, write 和 read/weite 访问模式。如下:
#define O_ACCMODE 00000003
#define O_RDONLY 00000000
#define O_WRONLY 00000001
#define O_RDWR 00000002再从数组中计算索引得到值后,ACC_MODE 会展开一个文件的访问模式,包含 MAY_WRITE, MAY_READ和其他信息。
在我们计算得到初始访问模式后,我们会看到以下条件判断语句:
if (flags & (O_CREAT | __O_TMPFILE))
op->mode = (mode & S_IALLUGO) | S_IFREG;
else
op->mode = 0;如果打开文件时,不是新建文件或者临时文件,我们忽略mode,其他情况下传递。这是因为:
This argument must be supplied when O_CREAT or O_TMPFILE is specified in flags; if neither O_CREAT nor O_TMPFILE is specified, then mode is ignored
- 确保不会泄漏文件描述符
在接下来的步骤,我们检查一个文件是否被fanotify打开过并且没有O_CLOSEXEC标志,来确保不会在用户空间泄漏文件描述符。如下:
flags &= ~FMODE_NONOTIFY & ~O_CLOEXEC;通过execve系统调用,新的文件描述符默认设置为保持打开状态,但open系统调用支持O_CLOSEXEC标志,这样可以被用来改变默认的操作行为。这样即使在一个线程中打开文件并设置O_CLOSEXEC标志,同时第二个程序中进行fork + execve操作时不会泄露文件描述符。你应该还记得子程序会有一份父程序文件描述符的副本。
- 检查同步标志
接下来检查flags参数是否包含__O_SYNC 标志,如果包含,则外加O_DSYNC标志:
if (flags & __O_SYNC)
flags |= O_DSYNC;O_SYNC标志确保在所有的数据写入到磁盘前,任何关于写的调用不会返回。O_DSYNC和O_SYNC类似,但O_DSYNC不要求等待写入的元数据(如:atime, mtime等)。
- 检查临时文件标志
接下来,检查是否创建临时文件,用户在创建一个临时文件,必须确认flags参数应该包含O_TMPFILE_MASK,并且确保文件可写。
if (flags & __O_TMPFILE) {
if ((flags & O_TMPFILE_MASK) != O_TMPFILE)
return -EINVAL;
if (!(acc_mode & MAY_WRITE))
return -EINVAL;
}因为在 man 手册中有提及:
O_TMPFILE must be specified with one of O_RDWR or O_WRONLY
- 检查文件路径标志
检查是否存在文件路径标志,如下:
else if (flags & O_PATH) {
flags &= O_DIRECTORY | O_NOFOLLOW | O_PATH;
acc_mode = 0;
}O_PATH标志允许我们通过在文件系统目录树中的位置获取文件描述符,只允许在文件描述符层面执行操作。在这种情况下文件自身是没有被打开的,只能使用dup, fcntl 等操作。因此,使用所有与文件内容相关的操作,像 read, write 等,就必须使用 O_DIRECTORY | O_NOFOLLOW | O_PATH 标志。
现在我们已经分析完成了这些标志,将其设置到open_flags:
op->open_flag = flags;我们在函数的开始获取了初始的访问模式,现在根据标记修改访问模式,如下:
if (flags & O_TRUNC)
acc_mode |= MAY_WRITE;
if (flags & O_APPEND)
acc_mode |= MAY_APPEND;
op->acc_mode = acc_mode;O_TRUNC标志表示将文件长度删减到0,O_APPEND标志允许以追加模式打开文件。
open_flags中接下来的设置的字段是intent,确定我们真正的操作。换句话说就是我们真正想对文件做什么,打开,新建,重命名等等操作。如果flags参数包含O_PATH标志,即我们不能对文件内容做任何事情,intent设置为0,否则设置为LOOKUP_OPEN。如果需要新建文件,设置LOOKUP_CREATE,O_EXEC标志确认文件之前不存在。如下:
op->intent = flags & O_PATH ? 0 : LOOKUP_OPEN;
if (flags & O_CREAT) {
op->intent |= LOOKUP_CREATE;
if (flags & O_EXCL)
op->intent |= LOOKUP_EXCL;
}open_flags结构体里最后的标志是lookup_flags,确定路径查找方式。如下:
if (flags & O_DIRECTORY)
lookup_flags |= LOOKUP_DIRECTORY;
if (!(flags & O_NOFOLLOW))
lookup_flags |= LOOKUP_FOLLOW;
op->lookup_flags = lookup_flags;O_DIRECTORY表示目录,我们使用LOOKUP_DIRECTORY;如果想要遍历但不使用软链接,使用LOOKUP_FOLLOW。
在build_open_flags函数完成后,我们建立了flags和modes。
接下来调用getname函数获取filename结构体,得到系统调用所需的文件名:
tmp = getname(filename);
if (IS_ERR(tmp))
return PTR_ERR(tmp);getname函数在fs/namei.c文件中定义,如下:
struct filename *
getname(const char __user * filename)
{
return getname_flags(filename, 0, NULL);
}getname函数仅仅调用getname_flags函数然后返回它的结果。getname_flags函数的主要目的是调用strncpy_from_user从用户空间复制文件路径到内核空间。
filename结构体在include/linux/fs.h头文件中定义,如下:
struct filename {
const char *name; /* pointer to actual string */
const __user char *uptr; /* original userland pointer */
int refcnt;
struct audit_names *aname;
const char iname[];
};字段说明如下:
name-- 指向内核空间的文件路径指针;uptr-- 用户空间的原始指针;aname-- 来自审计上下文的文件名;refcnt-- 引用计数;iname-- 文件名,长度小于PATH_MAX;
接下来就是获取新的空闲文件描述符,如下:
fd = get_unused_fd_flags(flags);get_unused_fd_flags函数获取当前程序打开文件的文件描述符表,根据最小值(0)、最大值(RLIMIT_NOFILE)和flags标志计算分配的文件描述符,并根据flags参数设置或清除O_CLOEXEC标志。
do_sys_open最后主要的步骤就是获取file结构,如下:
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);
trace_do_sys_open(tmp->name, flags, mode);
}
}do_filp_open()函数主要功能是转换文件路径到file结构体,file结构体描述程序里已打开的文件。如果参数有误,则do_filp_open执行失败,使用put_unused_fd函数释放文件描述符;否则,返回file结构体,并在当前程序的文件描述符表中存储这个file结构体。
现在让我们来简短看下do_filp_open()函数的实现。这个函数在fs/namei.c中实现。如下:
struct file *do_filp_open(int dfd, struct filename *pathname,
const struct open_flags *op)
{
struct nameidata nd;
int flags = op->lookup_flags;
struct file *filp;
set_nameidata(&nd, dfd, pathname);
filp = path_openat(&nd, op, flags | LOOKUP_RCU);
if (unlikely(filp == ERR_PTR(-ECHILD)))
filp = path_openat(&nd, op, flags);
if (unlikely(filp == ERR_PTR(-ESTALE)))
filp = path_openat(&nd, op, flags | LOOKUP_REVAL);
restore_nameidata();
return filp;
}首先,调用set_nameidata函数初始化nameidata结构体,该结构体提供指向文件inode的链接。这是do_filp_open()函数的主要功能之一,这个函数通过传递到open系统调用的的文件名获取inode。在 nameidata结构体被初始化后,调用path_openat函数获取file结构,如下:
filp = path_openat(&nd, op, flags | LOOKUP_RCU);
if (unlikely(filp == ERR_PTR(-ECHILD)))
filp = path_openat(&nd, op, flags);
if (unlikely(filp == ERR_PTR(-ESTALE)))
filp = path_openat(&nd, op, flags | LOOKUP_REVAL);注意path_openat会被调用了三次,通过不同的方式打开文件。首先,Linux内核以RCU模式打开文件,这是打开文件有效的方式。如果打开失败,以正常模式打开文件。第三种方式相对较少,仅在nfs文件系统中使用。path_openat函数查找路径,尝试寻找与路径相符合的dentry(目录数据结构,Linux内核用来追踪记录文件在目录里层次结构)。
path_openat函数在fs/namei.c中定义。如下:
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);
}
...
...
}path_openat从调用alloc_empty_file()函数开始。alloc_empty_file()分配一个新file结构体并做一些额外的检查,例如:是否打开超出了系统中能打开的文件的数量等。在我们获得已分配的新file结构体后,根据flags标志进行不同的处理,如:O_TMPFILE标志调用do_tmpfile;O_PATH标志调用do_o_path;其他情况下调用path_init函数。
path_init函数在进行真正的路径寻找前执行一些预备工作,从路径中的开始位置遍历路径和元数据,如:路径中的inode ,dentry inode等。路径的开始位置可能是根目录(/)或者当前目录,因为我们使用AT_CWD作为起点。
path_init之后是个循环,循环执行 link_path_walk 和 do_last 。link_path_walk函数进行名称解析,沿着给定路径行走的过程,这个程序逐步理除了最后一个组件部分的文件路径。处理过程包括检查权限和获得文件组件,当获取到一个文件的组件后,传递给walk_component函数,这个函数从dcache更新当前的目录入口或询问底层文件系统。重复这个处理直到完成所有的路径。link_path_walk执行后,do_last函数会基于link_path_walk 返回的结果填充file结构体。当我们完成文件路径中的最后一个组成部分时,do_last中的vfs_open 函数将会被调用。
vfs_open函数在fs/open.c中实现,主要功能是调用底层文件系统的打开操作。
现在,我们已经实现了open的功能,剩余的工作出现错误时返回和释放分配的资源。现在,我们的讨论就结束了,我们没有分析open系统调用全部的实现。我们跳过了一些内容,例如:从不同挂载点的文件系统打开文件,解析软链接等,但去查阅这些处理特征应该不会很难。这些要素不包括在通用的 open 系统调用实现中,具体特征取决于底层文件系统。如果你对此感兴趣,可查阅特定filesystem的file_operations.open回调函数。
本文详细分析了open系统调用的实现过程。
本系列文章翻译自linux-insides,如果你有任何问题或者建议,请联系0xAX或者创建 issue。