系统级I/O
概述
输入/输出(Input/Output,I/O)是在内存和外部设备(如磁盘驱动器、终端、网络适配器)之间拷贝数据的过程。输入操作是从I/O设备拷贝数据到内存,输出操作是从内存拷贝数据到I/O设备。
Linux将一切设备抽象为文件,常见的I/O操作包括打开文件、读文件、写文件等。Linux中的大多数文件I/O只需要用到5个函数:open、read、write、lseek和close。
文件描述符
对于内核而言,所有打开的文件都通过文件描述符引用。文件描述符是一个非负整数(int类型),当打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。当读写一个文件时,将文件描述符作为参数传递给read或write。文件描述符的变化范围是0~OPEN_MAX - 1,默认情况下,OPEN_MAX的值为1024,也即文件描述符的变化范围是[0, 1024)。
对于Linux 3.2.0及以后的版本,文件描述符的变化范围几乎是无限的,它只受到系统配置的内存总量、整型的字长以及系统管理员所配置的软限制和硬限制的约束。
按照惯例,每个进程默认打开三个文件描述符:
文件描述符 | magic number | 含义 |
---|---|---|
STDIN_FILENO | 0 | 标准输入 |
STDOUT_FILENO | 1 | 标准输出 |
STDERR_FILENO | 2 | 标准错误 |
文件在内核中的表现形式
内核用三个相关的数据结构来表示打开的文件:
- 描述符表(descriptor table)。每个进程都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表的一个表项。
- 文件表(file table)。打开文件的集合是由一张文件表来表示的,所有的进程共享这张表。每个文件表的表项包含:
当前文件位置
(current file offset)、引用计数
(reference count,当前指向该表项的描述符表项数)、指向i-node表的中对应表项的指针
。关闭一个描述符会减少相应的文件表表项中的引用计数。当引用计数为零时,内核才将这个文件表表项删除。 - i-node表(i-node table)。所有进程共享i-node表,每个表项包含stat结构中的大部分信息,包括st_mode和st_size成员。
下面列举三种打开文件在内核中的表现状态:
-
每个描述符对应一个不同的文件,如下图所示,这是最典型的情况,没有共享文件:
-
多个文件描述符也可以通过
不同的打开文件表表项
来引用同一个文件。如下图所示,以同一个pathname调用open函数两次,就会发生这种情况。在这种情况下,每个文件描述符都有它自己的文件位置,所以对不同描述符的读操作可以从文件的不同位置获取数据:
-
父子进程共享相同的打开文件表集合,因此共享相同的文件位置,下图是在调用fork之后,父子进程打开文件的状态,可以看到文件A和文件B所对应的文件表表项的引用计数变为2,值得注意的是,在内核删除相应文件表表项之前,父子进程必须关闭它们的文件描述符:
open
读写文件之前,必须打开文件,获得对应的文件描述符。可以使用open函数打开文件:
#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);
open函数有两个原型,均可用来打开pathname指定的文件。函数参数的含义如下:
- pathname :要打开或创建文件的名字
- flags :指明打开文件的方式
- mode :指定新文件的访问权限位
- return :若成功返回新文件描述符,出错返回-1
flags指明打开文件的方式
访问模式 | 描述 |
---|---|
O_RDONLY | 只读 |
O_WRONLY | 只写 |
O_RDWR | 可读可写 |
上面三种访问模式每次能选且只能选一个。另外,flags也可以是一个或者更多位掩码,为写操作
提供额外的提示:
打开方式 | 描述 |
---|---|
O_APPEND | 每次写时都追加到文件的尾端 |
O_CREAT | 如果文件不存在则创建它,并且用用mode指定该新文件的访问权限位 |
O_EXCL | 如果同时指定了O_CREAT,并且文件已经存在,则出错。使用O_CREAT | O_EXCL可以测试一个文件是否存在,如果不存在,则创建此文件,这使测试和创建两者成为一个原子操作。 |
O_NONBLOCK | 如果pathname引用的是一个FIFO、块文件或者字符文件,则为文件的本次打开操作和后续I/O操作设置非阻塞模式。 |
O_SYNC | 使每次write等待物理I/O操作完成,包括有该write操作引起的文件属性更新所需的I/O |
O_TRUNC | 如果文件存在,并且以O_WRONLY或O_RDWR成功打开,则将其长度截断为0,即清空文件内容 |
mode指定新文件的访问权限位
mode参数在创建文件是发挥作用,它指定新文件的访问权限位,这些位定义在sys/stat.h中 :
掩码 | 对应位 | 描述 |
---|---|---|
S_IRWXU | 00700 | 拥有者能够读、写、执行这个文件 |
S_IRUSR | 00400 | 拥有者能够读这个文件 |
S_WRUSR | 00200 | 拥有者能够写这个文件 |
S_IXUSR | 00100 | 拥有者能够执行这个文件 |
S_IRWXG | 00070 | 拥有者所在组的成员能够读、写、执行这个文件 |
S_IRGRP | 00040 | 拥有者所在组的成员能够读这个文件 |
S_WRGRP | 00020 | 拥有者所在组的成员能够写这个文件 |
S_IXGRP | 00010 | 拥有者所在组的成员能够执行这个文件 |
S_IRWXO | 00007 | 其他人能够读、写、执行这个文件 |
S_IROTH | 00004 | 其他人能够读这个文件 |
S_WROTH | 00002 | 其他人能够写这个文件 |
S_IXOTH | 00001 | 其他人能够执行这个文件 |
一个示例
需要注意几点:
- open每次返回的文件描述符总是当前进程中没有打开的最小描述符
- 每个进程都有一个umask,可以通过掉umask函数来设置。当进程通过带mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置为
mode & ~umask
。
下面使用umask函数设置进程中的umask值为0022,并且使用open函数创建一个新文件:
// open_test.c
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
int main()
{
int fd;
/* 修改umask值为 00022 */
umask(S_IWGRP | S_IWOTH);
/* 以读写的方式打开文件 */
if((fd = open("test.txt", O_RDWR | O_CREAT | O_TRUNC, 0777)) < 0) {
fprintf(stderr, "open error : %s", strerror(errno));
exit(0);
}
/* 成功创建新文件 */
printf("create file successfully! fd = %d\n\n", fd);
close(fd);
exit(0);
}
如果open成功执行,那么将在程序的工作目录下创建一个新文件test.txt,该文件的访问权限为:0777 & (~0022) = 0755,即-rwxr-xr-x。我们在程序工作目录下运行ls -lh test.txt
,结果如下:
$ ./open_test
create file successfully! fd = 3
$ ls -lh test.txt
-rwxr-xr-x 1 hwg hwg 0 4月 28 20:10 test.txt
从上面的输出结果可以得知,open返回的文件描述符为3,因为进程的0、1、2文件描述符均被打开,分别之前标准输入、标准输出、标准错误,此时进程最小的未使用文件描述符就是3,因此成功调用open返回3
creat
除了使用open,指定O_CREAT访问模式来创建一个新文件,还可以使用creat函数创建新文件:
#include <fcntl.h>
// @pathname :文件名
// @mode :新文件访问权限
// return :若成功则返回只写打开的文件描述符,出错返回-1
int creat(const char *pathname, mode_t mode);
这个函数与open(path, O_WRONLY | O_CREAT | O_TRUNC, mode)等价。creat的一个不足是它只能以只写方式打开所创建的文件。
close
进程可以通过调用close函数关闭一个打开的文件:
#include <unistd.h>
// @fd : 要关闭文件的文件描述符
// return : 若成功则为0,出错则为-1
int close(int fd);
值得注意的有三点:
- 关闭一个已关闭的文件描述符会出错
- 关闭一个文件时,会释放该进程加在该文件上的所有记录锁
- 当一个进程关闭时,内核会自动关闭它所有打开的文件
read
使用read函数从打开的文件中读数据:
#include <unistd.h>
// @fd : 文件描述符
// @buf : 缓冲区
// @nbytes : 期望读的字节数
// return : 若成功则为读到的字节数,若EOF则为0,若出错则为-1(并设置errno)
ssize_t read(int fd, void *buf, size_t nbytes);
read函数从描述符为fd的当前文件位置拷贝最多nbytes个字节到内存位置buf,注意这里返回值最多是nbytes,因为在某些情况下,read读到的字节数少于要求读的字节数:
- 读普通文件时遇到EOF,在读到要求字节数之前已到达文件尾端,下一次调用read时将返回0。
- 从终端读文本行,一次最多读一行,返回值等于文本行大小
- 从网络套接字读数据时,网络中的缓存机制可能造成返回值小于所要求读的字节数
- 从管道或FIFO读时,如果管道包含的字节数少于要求读的字节数,则read只返回实际可用的字节数
- 在读取任何数据之前被信号中断,返回-1,并设置errno为EINTR
- 如果文件以
O_NONBLOCK
模式打开,则文件为非阻塞模式,当文件没有数据可读时,read返回-1,并设置errno为EAGAIN
可见,read函数的使用比较复杂,我们可以封装一个Read函数尽可能减少调用的复杂性,下面给出Read的一个示例:
#include <unistd.h>
#include <errno.h>
ssize_t Read(int fd, void *buf, size_t n)
{
size_t nleft = n;
size_t nread;
char *pbuf = buf;
while(nleft > 0) {
if((nread = read(fd, pbuf, nleft)) < 0) {
/* 被信号中断 */
if(errno == EINTR)
continue;
/* 没有数据可读 */
else if(errno == EAGAIN)
continue;
/* 出错 */
else
return -1;
}
else if(nread == 0) /* EOF */
break;
else {
nleft -= nread;
pbuf += nread;
}
}
return n-left;
}
write
使用write函数向打开的文件写数据:
#include <unistd.h>
// @fd :文件描述符
// @buf :写入的内容
// @nbytes :写入的字节数
// return : 如果成功则为写入的字节数,出错则为-1(并设置errno)
ssize_t write(int fd, const void *buf, size_t nbytes);
write的返回值通常与nbytes的值相同,但是有时候返回值也有可能比nbytes的值小:
- 写普通文件时,如果磁盘已满或者超过了进程的文件长度限制,write返回-1
- 如果文件以
O_NONBLOCK
模式打开,调用write时会返回-1,并设置errno为EAGAIN - 当write在写数据的时候,被信号中断,返回-1,并设置errno为EINTR
- 写网络套接字时,网络中的缓存机制可能造成返回值小于所要求写的字节数
同样,为了降低write的调用复杂性,我们可以封装一个Write函数,下面给出Write的一个示例:
#include <unistd.h>
#include <errno.h>
ssize_t Write(int fd, void *buf, size_t n)
{
size_t nleft = n;
ssize_t nwritten;
char *pbuf = buf;
while(nleft > 0) {
if((nwritten = (fd, pbuf, nleft)) <= 0) {
if(errno == EINTR || errno == EAGAIN)
continue;
else
return -1;
}
nleft -= nwritten;
pbuf += nwritten;
}
return n;
}
lseek
每个打开都有一个与其相关联的当前文件偏移量(current file offset),用以度量从文件开始处计算的字节数。当打开一个文件时,除非指定O_APPEND选项,否则偏移量被设置为0。
可以调用lseek函数显式地为一个打开文件设置偏移量:
#include <unistd.h>
// @fd : 文件描述符
// @offset : 偏移量,解释与whence有关
// @whence : 偏移量的位置
// return : 若成功则为新的文件偏移量,出错则为-1(并设置errno)
off_t lseek(int fd, off_t offset, int whence);
offset的解释与whence的值相关:
whence | 含义 | offset的解释 |
---|---|---|
SEEK_SET | 绝对偏移量 | 将该文件的偏移量设置为距文件开始处offset个字节 |
SEEK_CUR | 相对于当前位置的偏移量 | 将该文件的偏移量设置为其当前值加offset,offset可正可负 |
SEEK_END | 相对于文件尾端的偏移量 | 将该文件的偏移量设置为文件长度加offset,offset可正可负 |
值得注意的几点:
- 如果文件描述符指向的是一个管道、FIFO或网络套接字,则lseek返回-1,并将errno设置为ESPIPE
- lseek仅将当前的文件偏移量记录在内核的文件表表项中,并不引起任何I/O操作
- 文件偏移量可以大于文件的当前长度,在这种情况下,对该文件的下一次写操作,将加长该文件,并在文件中构成一个空洞,这个空洞的所有字节被读为0。在Linux中,文件中的空洞并不要求在磁盘上作用存储区。
下面用一个例子来验证空洞文件:
// filehole.c
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
char buf1[] = "abcdefghij";
char buf2[] = "ABCDEFGHIJ";
void sys_err(const char *msg);
int main()
{
int fd;
/* 创建一个文件file.hole */
if((fd = open("file.hole", O_WRONLY | O_CREAT | O_TRUNC)) < 0)
sys_err("open error");
/* 写入10个字节后,当前文件位置为10 */
if(write(fd, buf1, 10) != 10)
sys_err("buf1 write error");
/* 将当前文件位置设置为16384 */
if(lseek(fd, 16384, SEEK_SET) == -1)
sys_err("lseek error");
/* 再写入10个字节后,当前文件位置为16384 */
if(write(fd, buf2, 10) != 10)
err_sys("buf2 write error");
exit(0);
}
void sys_err(const char *msg)
{
fprintf(stderr, "%s : %s", msg, strerr(errno));
exit(0);
}
编译运行程序:
$ gcc -Wall filehole.c -o filehole
$ ./filehole # 运行程序
$ ls -l file.hole # 检查含有空洞的文件大小
8 ---x-wx--T 1 hwg hwg 16394 4月 28 22:22 file.hole
$ od -c file.hole # 观察实际内容
0000000 a b c d e f g h i j \0 \0 \0 \0 \0 \0
0000020 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0
*
0040000 A B C D E F G H I J
0040012
使用od命令观察file.hole文件的实际内容,命令行中的-c
标志表示以字符方式打印文件内容。从中可以看到,文件中间的30个未写入直接都被读成0。
为了证明该文件中确实有一个空洞,我在这里创建一个包含与file.hole一样大的文件——file.nohole(以16394个a字符填充),将file.hole与file.nohole进行比较:
$ ls -ls file.hole file.nohole
8 ---x-wx--T 1 hwg hwg 16394 4月 28 22:22 file.hole
20 -r----x--t 1 hwg hwg 16394 4月 28 22:29 file.nohole
虽然两个文件的长度均为16394,但是无空洞的文件占用了20个磁盘块,而具有空洞的文件只占用8个磁盘块。
dup2
Linux的外壳提供了I/O重定向
操作符,允许用户将磁盘文件和标准输入输出关联起来,实现I/O重定向可以使用dup2函数
#include <unistd.h>
// @oldfd : 要拷贝的描述符表项
// @newfd : 目的描述符表项
// return : 若成功则为非负的描述符,若出错则为-1
int dup2(int oldfd, int newfd);
dup2函数拷贝描述符表项oldfd到描述符表项newfd,覆盖描述符表项newfd以前的内容,如果newfd以及打开,dup2会在拷贝oldfd之前关闭newfd。
假设一个进程当前打开的文件描述符如下图所示,文件描述符1指向文件A(引用计数为1),文件描述符4指向文件B(引用计数为1)。
接着我们调用dup2(4,1)后,现在两个描述符都指向文件B,如下图所示。文件A已经被关闭了,并且它的文件表和i-node表表项也被删除;文件B的引用计数增加1,此后,写到标准输出的数据都被重定向到文件B。
参考资料
- Randal E. Bryant, David R. O’Hallaron, 布赖恩特,等. 深入理解计算机系统[M]. 机械工业出版社, 2011.
- W. Richard Stevens, Stephen A. Rago, 史蒂文斯, 等. UNIX 环境高级编程[M]. 人民邮电出版社, 2014.