系统级I/O
输入/输出(I/O)是在主存和外部设备(磁盘驱动器、终端和网络)之间复制数据的过程。 输入操作就是从I/O设备复制数据到主存, 输出操作就是将主存数据复制到I/O设备。
一、Unix IO
一个Linux文件就是一个m
个字节的序列,所有的I/O设备(磁盘、终端和网络)都被模型化为文件,所有的输入和输出都被当做对相应文件的读和写来执行。
- 打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它对此文件的后续操作起到标识作用,内核会记录打开文件的相关信息,应用程序只需记住描述符。
- 修改当前文件位置:对于每个打开的文件,内核保持着一个文件位置
k
(初始值为0
),记录文件当前位置距文件开头起始位置的字节偏移量。应用程序能够通过执行seek
操作,显式地设置文件的当前位置为k
。 - 读写文件:一个读操作就是从文件复制
n>0
个字节到内存,从当前文件k
位置开始,然后将k
增加到k+n
。给定一个大小为m
字节的文件,当k>=m
时执行读操作,会出发EOF(end-of-file)条件——能被应用程序检测到,文件末尾没有明确的EOF符号。
而写文件就是从内存复制n
个字节到文件位置k
处,然后更新k=k-n
。 - 关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止,内核都会关闭所有打开的文件并释放它们的内存资源。
【I/O总结】通过将I/O设备抽象为文件的形式,使得对I/O设备的输入输出统一为对文件的读写操作,而应用程序可以通过文件描述符来对指定的I/O设备进行操作。在创建进程时,内核会默认打开三个文件,标准输入(描述符为0
)、标准输出(描述符为1
)和标准错误(描述符为2
),可通过STDIN_FILENO
、STDOUT_FILENO
和STDERR_FILENO
代替描述符来访问这三个文件。当进程终止时,内核会关闭所有打开的文件,并释放内存资源。
二、文件
在Linux中,每个文件都有一个类型,如下:
- 普通文件(regular file):包含任意数据,应用程序常常要区分文本文件(text file)和二进制文件(binary file):
文本文件是只含有ASCII
或Unicode
字符的普通文件;
二进制文件是其他的所有文件。对内核而言,文本文件和二进制文件没有区别。 - 目录(directory):包含一组链接(link)的文件,其中每个链接都将一个文件名(filename)映射到一个文件,这个文件可能是另一个目录。每个目录至少含有两个条目。
- 套接字(socket):用来与另一个进程进行跨网络通信的文件。
Linux将所有文件组织成目录层次结构(Directory Hierarchy),其中/
表示根目录,其他所有文件都是根目录的直接或间接的后代。 而目录层次结构中的位置可以用路径名(Pathname)来指定,具有两种形式:
- 绝对路径名(Absolute Pathname):从根目录开始的路径,
/home/xcloud/
。 - 相对路径名(Relative Pathname):以文件名开始,表示从当前工作目录开始的路径,
./xcloud
。
三、打开和关闭文件
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(char *filename, int flags, mode_t mode);
进程通过open
函数可以打开一个已存在的文件或创建一个新的文件,将filename
会转换为一个文件描述符,并返回描述符数字,该数字是当前进程没有打开的最小数字(下面有例子说明)。
flags
参数用来表明访问的方式,可通过|
来合并多个掩码。
mode
参数用来指定新文件的访问权限,而每个进程可通过umask
函数来设置用户默认权限的补码,则文件最终的权限是通过mode & ~umask
确定的。
最后,可以将文件描述符编号传入close
函数来关闭文件。如果尝试关闭一个已关闭的文件,则会报错返回-1
。
#include <unistd.h>
int close(int fd);
练习题:下列程序的输出是什么?
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main() {
int fd1, fd2;
fd1 = open("foo.txt", O_RDONLY, 0);
close(fd1);
fd2 = open("baz.txt", O_RDONLY, 0);
printf("fd2 = %d\n", fd2);
exit(0);
}
在创建进程时,内核会默认打开三个文件,标准输入(描述符为0
)、标准输出(描述符为1
)和标准错误(描述符为2
)。而为打开的文件分配最小的描述符,当打开fd1
时,分配了描述符3
给fd1
,而当关闭fd1
时,就将描述符3
回收了,所以当打开fd2
时,又将描述符3
赋给第二个文件了。所以这里输出fd2 = 3
。
四、读和写文件
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, const void *buf, size_t n);
read
函数从文件描述符fd
的文件位置复制最多n
个字节到内存位置buf
中,如果发生错误就返回-1
,如果遇到EOF
就返回0
,否则返回实际传送的字节数目。write
函数是将内存位置buf
的n
个字节复制到文件描述符fd
的文件位置,如果成功完成写操作,就返回字节数目。
【注】在x86-64中,size_t
为unsigned long
,而ssize_t
为long
。由于read
和write
函数会返回有符号数,所以这里需要设置为ssize_t
。
#include <unistd.h>
int main(){
char c;
while(read(STDIN_FILENO, &c, 1) != 0){
write(STDOUT_FILENO, &c, 1);
}
return 0;
}
以上代码就是将你通过标准输入的字符一个个输出到标准输出中。由于这些都是系统调用,所以速度会特别慢。有些时候read
和write
函数传送的字节数比应用程序要求的少,称为不足值(Short Count),主要因为:
read
函数遇到EOF
时,比如文件中中剩下20
个字节,而read
函数想要读取50
个字节,则第一次会读取20
个字节并返回20
,而下一次读取时会返回0
表示遇到EOF
了。- 如果打开的文件与终端相关联,则每个read函数将依次传送一个文本行,返回的就是文本行的字节数,
- 如果打开的文件与套接字相关联,则由于内部缓冲和网络延迟,就会造成这个现象。
五、用RIO包健壮地读写
RIO(Robust I/O)包可以处理不足值的问题,它提供了两类不同的函数。
5.1 无缓冲的输入输出函数
这些函数直接在内存和文件之间传送数据,没有应用级缓冲。它们对将二进制数据读写到网络和从网络读写二进制数据尤其有用。
rio_readn
函数从文件描述符fd
的文件位置传送最多n
个字节到usrbuf
中,如果遇到EOF
就返回一个不足值:ssize_t rio_readn(int fd, void *usrbuf, size_t n) { size_t nleft = n; ssize_t nread; char *bufp = usrbuf; while (nleft > 0) { //不断循环,直到读取到n个字节 if ((nread = read(fd, bufp, nleft)) < 0) { if (errno == EINTR) /* Interrupted by sig handler return */ nread = 0; /* and call read() again */ else return -1; /* errno set by read() */ } else if (nread == 0) break; /* EOF */ nleft -= nread; bufp += nread; } return (n - nleft); /* Return >= 0 */ }
rio_writen
函数是将usrbuf
中的n
个字节传送到文件描述符fd
的文件位置,不会出现不足值。ssize_t rio_writen(int fd, void *usrbuf, size_t n) { size_t nleft = n; ssize_t nwritten; char *bufp = usrbuf; while (nleft > 0) { //不断循环,直到写了n个节 if ((nwritten = write(fd, bufp, nleft)) <= 0) { if (errno == EINTR) /* Interrupted by sig handler return */ nwritten = 0; /* and call write() again */ else return -1; /* errno set by write() */ } nleft -= nwritten; bufp += nwritten; } return n; }
5.2 带缓冲的输入函数
六、读取文件元数据
应用程序能够通过调用stat
和fstat
函数,检索到关于文件的信息,有时也称为文件的元数据(metadata)。
#include <unistd.h>
#include <sys/stat.h>
int stat(const *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);
stat
函数与fstat
函数的功能一样,都是获取文件的元数据信息,存放到buf
中,只不过stat
以文件名作为参数,fstat
以文件描述符作为参数。结构体struct stat *buf
的定义如下:
其中st_size
包含了文件的字节数大小,st_mode
编码了文件访问许可位,我们可以通过sys/stat.h
中定义的宏来确定该部分的信息:
S_ISREG(st_mode)
: 是否为普通文件S_ISDIR(st_mode)
:是否为目录文件S_ISSOCK(st_mode)
:是否为套接字
七、读取目录内容
对于目录的操作,我们将目录项定义为以下数据结构。
struct dirent{
int_t d_ino; //文件位置
char d_name[256]; //文件名
}
可以通过传递一个路径名给以下函数来获得目录项的列表。
#include <sys/types.h>
#include <dirent.h>
DIR *opendir(const char *name);
然后可通过循环调用以下函数来不断获得列表中的下一个目录项,并根据目录项的数据结构来获得目录的信息。readdir
函数若成功,返回下一个目录项的指针,没有更多的目录项或者出错,返回NULL
。
#include <dirent.h>
struct dirent *readdir(DIR *dirp);
函数closedir关闭流并释放其所有资源。
#include <dirent.h>
int closedir(DIR *dirp);
以下是读取目录的代码示例:
八、共享文件
内核有三种数据结构来表示打开的文件:
- 描述符表(Descriptor Table):每个进程有自己独立的描述符表,进程打开的所有文件描述符都包含在该表中,每个文件描述符指向文件表中的一个表项。(每次打开一个文件,就会在文件表中分配一个表项)
- 文件表(File Table):所有进程共享文件表,包含所有打开文件的文件位置和指向的v-node表项,由于可能在不同进程中共享同一个文件表表项,所以会有一个引用次数表示有多少个描述符指向当前文件表表项,只有当表项的引用次数为0才会删除该表项。该文件表描述了指向对应文件表象的描述符的信息,有操作系统维护。
- v-node表:所有进程共享v-node表,每个表项包含了stat结构中的大多数信息,用来描述文件的信息。在系统中的每个文件无论是否打开,都在v-node表汇总有一个对应的表项。
总的来说,我们在进程中每次打开文件获得的文件描述符都是描述符表中的一个表项,然后指向对应的文件表表项,描述了该文件描述符的信息,而对应文件的信息可通过文件表表项指向的v-node表项来获得。
每个描述符都有自己的文件位置,表明了当前读取文件的位置,如果对同一文件用不同的open打开,由于分配了不同的文件描述符,使其指向不同的文件表表项,而获得不同的文件位置,但是由于是相同文件,所以会指向同一个v-node表项,这就使得这两个文件描述符可以对同一个文件的不同文件位置进行读写,将不同的描述符操作独立开来。
在父进程使用fork函数创建一个子进程时,子进程会复制父进程的描述符表,由于描述符表中包含指向文件表中的指针,所以子进程中相同的描述符也指向了相同的文件表表项,所以父子进程对文件位置的修改是共享的。
【注】描述符指向的文件表项决定了它的文件位置,判断是否共享文件表项。