在Linux下,一切皆文件,而文件无非是被打开的文件或未被打开的文件。冯·诺依曼体系决定了,若要对磁盘上的文件进行读写,必须首先将其加载到内存中,所以被打开与未被打开文件之间最大的区别即为是否在内存中有一块属于自己的内存空间与相关的内存数据结构。在这篇文章中,我们讨论的即是被打开的文件,即是被加载到内存中的文件,文件被加载到内存中,往往是要进行一个工作,那便是文件的IO。
本文会从之前讨论过的C语言的文件IO函数讲起,然后过渡到Linux有关文件IO的系统调用,以及相关的内核数据结构,讨论C语言IO与Linux文件IO的联系,并结合缓冲区概念大致梳理出文件IO的过程。
C的标准IO库
C接口
C语言文件操作的接口有很多,包括对文件本身的操作与对文件指针的操作,在这里只讨论最关键的几个接口,更详细的C语言文件操作请参考之前的文章。C语言主要的文件IO接口有以下几个:
#include <stdio.h>
/*1.*/ FILE *fopen(const char *path, const char *mode);
#include <stdio.h>
/*2.*/ size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
/*3.*/ size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
#include <stdio.h>
/*4.*/ int fscanf(FILE *stream, const char *format, ...);
/*5.*/ int fprintf(FILE *stream, const char *format, ...);
/*6.*/ int scanf(const char *format, ...);
/*7.*/ int printf(const char *format, ...);
其中 1 号接口可以以指定方式打开指定文件,2 号到 5 号接口可以读写指定文件,6 号、7 号接口可以读写显示器文件。
在C语言中,文件的抽象是用FILE
结构描述的,这个结构体记录了对文件的读、写指针以及一些基准位置。较为关键的是,FILE 包含了一个_fileno
成员,这个成员的意义会在后续的 Linux IO 部分进行讨论。
//path: /usr/include/stdio.h
typedef struct _IO_FILE __FILE;
//path: /usr/include/libio.h
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
/*…………*/
};
标准输入、标准输出和标准错误
在语言层面,操作系统默认打开了 3 个文件:标准输入stdin、标准输出stdout和标准错误stderr:
/* Standard streams. */
extern struct _IO_FILE *stdin; /* Standard input stream. */
extern struct _IO_FILE *stdout; /* Standard output stream. */
extern struct _IO_FILE *stderr; /* Standard error output stream. */
/* C89/C99 say they're macros. Make them happy. */
#define stdin stdin
#define stdout stdout
#define stderr stderr
可以直接使用C语言的IO接口对这三个文件进行操作。下文会看到,这三个文件分别对应了 Linux 中的键盘文件和显示器文件(stdout与stderr)。关于这三个文件的实质,会在后序的 Linux 部分进行讨论。
文件缓冲区
在C语言的文件IO中,还有一个文件缓冲区的概念,我们之前只是简单提了一下这个概念,关于缓冲区的具体位置和在IO中的作用,会在下文进行详谈。
Linux的文件IO
系统调用接口
访问文件,本质是访问磁盘,而用户无法绕过操作系统直接访问硬件,所以在系统层面,用户一定是通过系统调用对文件进行操作。
以下是 linux 提供的最主要的 IO 系统调用接口:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
/*1.*/ int open(const char *pathname, int flags);
/*2.*/ int open(const char *pathname, int flags, mode_t mode);
/*3.*/ int close(int fd);
#include <unistd.h>
/*4.*/ ssize_t read(int fd, void *buf, size_t count);
/*5.*/ ssize_t write(int fd, const void *buf, size_t count);
其中 1 号、2 号接口可以以指定方式打开某个文件,其中 pathname 为文件所在路径,flags 可以指定该函数的多个选项,使用下面的一个或多个宏常量进行或运算以构成 flags 参数:
- O_RDONLY 只读打开。
- O_WRONLY 只写打开。
- O_RDWR 读、写打开。
- O_APPEND 追加写。
- O_TRUNC 成功打开文件时,清空这个文件。
- O_CREAT 如果文件不存在则创建它。此时要说明 open 的第 4 个参数 mode 以指明新生成文件的权限。
除此之外还有其他 flags 标志位,这里不再讨论。当使用 open 系统调用成功打开一个文件时,会返回这个文件的文件描述符(file descriptor),文件描述符是一个非负整数,在一个进程中,文件描述符唯一地标识了一个被打开的文件。关于文件描述符的本质和意义,会在下文进行详谈,这里只需要先认识到,文件描述符唯一地代表了某个文件即可。
使用 3 号接口以关闭一个文件。使用 4 号和 5 号接口以读写文件。在这些系统调用接口中,均以文件描述符标识操作的文件对象。
除了上述的这些系统调用接口之外,还有一些有关当前文件偏移量的接口,这里不再讨论。
内核中的文件组织
在从语言层面与系统层面了解了对文件的操作方法后,这里开始讨论 linux 内核与进程中对这些被打开的文件的管理,讨论这个话题的过程,其实也是探讨上述系统调用接口的原理的过程。
在 Linux 中,文件终归是被进程打开的,所以这里其实是讨论进程与该进程打开的文件之间的关系。
在讨论进程与文件的关系之前,我们先着眼于被打开的文件本身。在系统中,可能同时存在多个被打开的文件,所以操作系统必然要对这些被打开的文件进行管理:先对这些文件进行数据抽象,再以某种数据结构将这些数据抽象组织起来。即是一个先描述,再组织的过程。这些文件被数据抽象的结构即为struct file
结构体,这个结构体中记录了文件部分属性和内存映射信息等,同时,一个文件可以被多个进程打开,所以 struct file 中也记录了一个引用计数(f_count)。
//path: linux-3.10.1\include\linux\fs.h
struct file {
/*…………*/
struct path f_path;
#define f_dentry f_path.dentry
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;
/*…………*/
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
loff_t f_pos;
/*…………*/
struct address_space *f_mapping;
/*…………*/
};
至此,我们再来讨论进程与文件之间的关系。一个进程可以同时打开多个文件,所以进程必须要将这些文件组织起来,即是将一个个 struct file 组织起来,组织这些文件的结构是一个数组,即为 struct file *fd_array[]
。fd_array 是一个存储file*
类型的数组,这个数组被维护在 files_struct 结构体中,而 files_struct 被进程的 pcb 维护。
//path: linux-3.10.1\include\linux\sched.h
struct task_struct {
/*…………*/
/* open file information */
struct files_struct *files;
/*…………*/
}
//path: linux-3.10.1\include\linux\fdtable.h
struct files_struct {
/*…………*/
struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};
当进程 open 一个文件时,操作系统会创建这个文件对应的 struct file 结构体,然后在进程的 files_struct 中的 fd_array 数组中从 0 下标开始向后依次寻找第一个空闲位置,将文件的 struct file 的地址填到 fd_array 的这个空闲位置,并返回 fd_array 的下标,这个下标即为这个文件的文件描述符(fd)。至此,进程就可以通过 task_struct 找到 files_struct,并以 fd_array 索引到对应的 struct file,对文件进行操作。
在 Linux 中,在开机时,系统会默认打开 3 个文件:标准输入,标准输出和标准错误,其中标准输入对应硬件键盘,标准输出和标准错误对应硬件显示器,这三个文件是被最先打开的,分别占据 fd_array 的前 3 个位置,fd 分别为 0、1、2,所以,用户打开的文件总是以 fd 为 3 开始的。C语言中的 stdin、stdout 与 stderr 本质就是对这三个文件的封装。将标准输出和标准错误分开,便于提取错误信息。
int main()
{
char buff[1024]; buff[0] = '\0';
ssize_t s_r = read(0, buff, sizeof(buff)); //从0号键盘文件读
assert(s_r >= 0);
ssize_t s_w = write(1, buff, strlen(buff)); //写入到1号显示器文件
assert(s_w >= 0);
return 0;
}
/*
运行结果:
hello linux!
hello linux!
*/
文件描述符是进程认识打开文件的唯一方式,在操作系统层面,Linux 只认识文件描述符。语言是运行在操作系统之上的,结合之前讨论的 C 语言文件IO,可以想到,其中的描述文件的 FILE 结构体一定封装了对应文件的文件描述符,这个字段即为_fileno
。
//path: /usr/include/stdio.h
typedef struct _IO_FILE __FILE;
//path: /usr/include/libio.h
struct _IO_FILE {
/*…………*/
int _fileno;
/*…………*/
};
当进程关闭一个文件时,files_struct 中的对应位置的内容会被标记为可覆盖。承上,一个文件可以同时被多个进程打开,所以当一个 struct file 的引用计数减到 0 时,才会真正释放这个 file 结构体。
重定向
简单来说,重定向就是将原本要对 a 文件的读写操作转至 b 文件。在操作方式上,依然是对 a 文件进行操作,而被修改的文件则是 b 文件。这里所说的 “操作方式”,即是以 a 文件的文件描述符进行文件操作。
在命令层面,我们有时会使用重定向功能进行文件的清空或者简单的文件写入等操作:
[@shr Wed Nov 29 12:22:22 ~]$ echo hello linux >> hello.txt
[@shr Wed Nov 29 12:23:17 ~]$ cat hello.txt
hello linux
[@shr Wed Nov 29 12:23:24 ~]$ > hello.txt
[@shr Wed Nov 29 12:23:31 ~]$ cat hello.txt
[@shr Wed Nov 29 12:23:32 ~]$
现在,已经可以在文件层面理解重定向的原理和实现方式。
上文说到,当进程打开一个文件时,会在 ad_array 中从 0 依次向后寻找第一个空闲的位置,将 file 的指针填入这个位置。如果先关闭一个文件 a,再紧接着打开一个文件 b,那么文件 b 的 file 信息就会被放到原本文件 a 所在的 fd_array 的位置上,即文件 b 的 fd 即为原本文件 a 的 fd。由此,我们可以简单的地实现一个以 txt 文件为目标的重定向:
int main()
{
close(1); //关闭 1 号文件
//紧接着打开文件txt,此时txt的fd为 1
int fd = open("txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
char buff[1024] = "hello\n";
//向1号显示器文件写入时,本质是向文件txt中写入
printf("hello linux!\n");
return 0;
}
/*
运行结果:
[@shr Wed Nov 29 13:09:48 11.27_file_test]$ ./Test
[@shr Wed Nov 29 13:09:49 11.27_file_test]$ cat txt
hello linux!
[@shr Wed Nov 29 13:09:54 11.27_file_test]$
*/
上例只是为了最大程度体现重定向的原理设计的代码用例,在实际操作中,往往使用dup2(2)
系统调用接口进行重定向操作。
#include <unistd.h>
int dup2(int oldfd, int newfd);
dup2 系统调用“makes newfd be the copy of oldfd” ,也就是将 oldfd 位置对应的 file* 指针内容拷贝到 newfd 位置,使 newfd 对应的内容成为 oldfd 对应内容的拷贝,最后保留的是原本 oldfd 对应的 file* 指针。若重定向成功,dup2 返回 newfd,否则返回 -1。在重定向成功之后,可以选择性地close(oldfd)
。
int main()
{
int fd = open("txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
dup2(fd, 1); //将1重定向至fd
printf("hello linux!\n");
return 0;
}
通过选择 read(2) 和 write(2),结合 dup(2) 可以实现输入/输出重定向;通过 open(2) 的方式O_APPEND
或O_TRUNC
,结合 dup2(2) 可以实现追加/覆盖重定向。
通过 dup2(2),可以在之前讨论进程控制中所示的 mini_shell 的基础上增加简单的重定向功能:
#define LEFT "["
#define RIGHT "]"
#define COMMAND_SIZE 1024
#define ARG_MAX 50
#define PATH_LENGTH 100
#define ENV_LENGTH 100
#define DELIM_STR " \t"
#define NO_REDIRECT -1 //没有重定向
#define IN_REDIRECT 0 //输入重定向
#define OUT_REDIRECT 1 //输出重定向
#define APPEND_REDIRECT 2 //追加重定向
int quit = 0; //shell是否退出
int last_code = 0; //最近一次命令的退出码
char command_line[COMMAND_SIZE]; //存储输入命令
char* command_vector[ARG_MAX]; //存储解析后的输入命令
int is_redirect = NO_REDIRECT;
const char* filename;
const char* get_user_name()
{
return getenv("USER");
}
const char* get_host_name()
{
return getenv("HOSTNAME");
}
const char* get_pwd()
{
return getenv("PWD");
}
//普通命令执行
int normalCommand(int argCunt)
{
//fork一个子进程,并将子进程替换为欲执行命令
pid_t id = fork();
if(id < 0) { perror("fork err"); exit(1); }
else if(id == 0)
{
if(!strcmp(command_vector[0], "ls") || !strcmp(command_vector[0], "ll"))
{
command_vector[argCunt++] = "--color";
command_vector[argCunt] = NULL;
}
if(!strcmp(command_vector[0], "ll"))
{
command_vector[0] = "ls";
command_vector[argCunt++] = "-l";
command_vector[argCunt] = NULL;
}
//进行重定向,改变的是子进程pcb维护的fd_array[]的元素,程序替换不影响
if(is_redirect == IN_REDIRECT)
{
int fd = open(filename, O_RDONLY);
dup2(fd, 0);
}
else if(is_redirect == OUT_REDIRECT)
{
int fd = open(filename, O_WRONLY | O_TRUNC | O_CREAT, 0666);
dup2(fd, 1);
}
else if(is_redirect == APPEND_REDIRECT)
{
int fd = open(filename, O_WRONLY | O_APPEND | O_CREAT, 0666);
dup2(fd, 1);
}
else {}
int ret = execvp(command_vector[0], command_vector);
if(ret == -1) { exit(1); }
}
else
{ //等待子进程和获取退出信息,更新最近一次的退出信息
int status = 0;
waitpid(id, &status, 0);
last_code = WEXITSTATUS(status);
}
return 1;
}
//检查是否存在重定向
void checkRedirect()
{
for(unsigned i = 0; i < sizeof(command_line); ++i)
{
if(command_line[i] == '>')
{
command_line[i++] = '\0';
if(command_line[i] == '>') { is_redirect = APPEND_REDIRECT; command_line[i++] = '\0'; }
else { is_redirect = OUT_REDIRECT; }
while(command_line[i] == ' ') { ++i; }
filename = command_line + i;
break;
}
else if(command_line[i] == '<')
{
command_line[i++] = '\0';
is_redirect = IN_REDIRECT;
while(command_line[i] == ' ') { ++i; }
filename = command_line + i;
break;
}
else {}
}
}
//命令解析
int stringSplit()
{
checkRedirect();//检查和判断重定向操作
//printf("check complete, is_redirect:%d, filename:%s\n", is_redirect, filename);
int index = 0;
command_vector[index++] = strtok(command_line, DELIM_STR);
if(command_vector[index - 1] != NULL) while(command_vector[index++] = strtok(NULL, DELIM_STR));
return index - 1;
}
//用户交互
void interAct()
{
printf(LEFT"%s@%s %s"RIGHT" ", get_user_name(), get_host_name(), get_pwd());
fgets(command_line, COMMAND_SIZE, stdin);
command_line[strlen(command_line) - 1] = '\0';
}
void mini_shell_init()
{
is_redirect = NO_REDIRECT;
filename = NULL;
}
int main()
{
while(!quit)
{
mini_shell_init();
interAct(); //用户交互(输入命令)
int argCount = stringSplit(); //命令解析
if(argCount == 0) { continue; }
normalCommand(argCount);
}
return 0;
}
上述子进程进行重定向操作时,改变的只是子进程的 fd_array 内容,由于进程具有独立性,所以子进程的重定向操作不影响父进程。
缓冲区
在讨论C标准 IO 库时,最后提到了文件缓冲区的概念,当提到这个文件缓冲区时,指的其实是用户缓冲区。这里先讨论缓冲区的原理,由此便可解释一些实例代码中的现象。
缓冲区(buffer),即是一个临时的内存区域,用来临时存放输入/输出的数据。在C/C++语言层面,语言本身会给提供一个用户级的缓冲区,这个用户缓冲区是语言层面的,被称为用户缓冲区。在 linux 内核中,存在一个内核级的文件缓冲区,这个缓冲区被称为内核缓冲区。内核缓冲区是由操作系统维护的,用户无法干预内核缓冲区,内核缓冲区也无法感知用户缓冲区。下文当提到缓冲区时,默认是用户缓冲区。
//C语言层面维护的所打开的文件的用户级缓冲区
//path: /usr/include/stdio.h
typedef struct _IO_FILE __FILE;
//path: /usr/include/libio.h
struct _IO_FILE {
/*…………*/
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/*…………*/
};
在合适的时机,用户缓冲区会进行刷新,调用 write(2) 将内容写入到内核缓冲区中,并最终写入到文件。这个 “合适的时机” 主要包括以下几种情况:
- 调用 fflush(3) 主动刷新用户缓冲区。
- 符合缓冲区的刷新条件。用户缓冲区有如下几个刷新策略:
- 无缓冲 直接刷新;
- 行缓冲 不刷新,直到遇到换行符;
- 全缓冲 不刷新,直到缓冲区填满
- 显示器文件一般是行刷新,普通文件一般是全刷新
- 主动关闭文件/流。
- 进程退出。
刷新缓冲区,本质是调用 write(2) 将缓冲区的内容写入到内核。
当用户写入文件时,首先将数据写入到了用户缓冲区,当符合缓冲区的刷新条件时,调用 write(2) 接口将缓冲区中的数据写入到内核缓冲区,操作系统会对内核缓冲区进行检查,定期将脏数据最终写入到磁盘中,由此便完成了一个文件写操作。读操作与此同理。
缓冲区的存在有以下几个意义:
- 当用户输入时,只需要输入到语言级别的缓冲区即可,不必频繁地将数据写入到内核,可以缩短IO的行程,提高用户的IO效率。
- 在数据流层面,数据本身是以字符串的形式进行流动的,所以存在将用户输入的各种类型的数据与字符串相互格式化的需求,用户缓冲区可以辅助这个数据格式化的过程。