前面两章说明了UNIX系统体系和标准及其实现,本章具体讨论UNIX系统I/O实现,包括打开文件、读文件、写文件等。
UNIX系统中的大多数文件I/O只需要用到5个函数:open、read、write、lseek、close。它们是不带缓冲的I/O。
只要涉及多个进程间共享资源,原子操作的概念就变得很重要,本章通过open( )函数来讨论此概念。
文件描述符
文件描述符是一个非负整数,它是内核对打开文件的一个抽象。每当打开或者创建一个文件时,内核会向进程返回一个文件描述符,随后可以利用该描述符来进行文件的读或写。一个进程默认的文件描述符范围是有限的,可以通过调用sysconf( _SC_OPEN_MAX )函数来查看限制,也可以通过shell命令ulimit -n来查看。例如,在我的Ubuntu Server上,其限制为65536,如下图所示:
而在我的Mac OS X上,则默认最大为256:
函数open和openat
函数open和openat用于打开或创建一个文件。其头文件及函数原型如下:
#include <fcntl.h> int open(const char *path, int oflag, ... /* mode_t mode */ ); int openat(int fd, const char *path, int oflag, ... /* mode_t mode */ );
这两个函数成功时,返回非负的文件描述符,出错时返回-1。由open和openat函数返回的文件描述符一定是最小的未用的描述符数值。
函数create
函数create用于创建文件。其头文件及函数原型如下:
#include <fcntl.h> int creat (const char* file, mode_t mode);
此函数存在致命缺点,即创建和写不是原子操作,因此已经成为一个鸡肋接口。
函数close
函数close用于关闭一个已经打开的文件。其头文件及函数原型如下:
#include <unistd.h> int close (int fd);
关闭一个文件时会释放该进程加在其上的文件记录锁。当一个进程终止时,内核为自动关闭该进程打开的所有文件。
函数lseek
每个文件都有一个与其相关联的“当前文件偏移量”,它通常是一个非负整数,用来度量从文件开始处计算的字节数。读写操作通常从当前文件偏移量处开始,并使偏移量增加读写的字节数。打开一个文件时默认文件偏移量为0,若指定了O_APPEND选项,则偏移量设置为末尾字节。我们可以使用lseek来手动设置文件偏移量。其头文件及函数原型如下:
#include <unistd.h> off_t lseek (int fd, off_t offset, int whence)
其中的whence指的是偏移量设置方式,其值有如下三种:
- SEEK_SET:将文件偏移量从开始处开始偏移,offset只能正值
- SEEK_CUR:将文件偏移量从当前处开始偏移,offset可正可负
- SEEK_END:将文件偏移量从文件尾开始偏移,offset可正可负
如果lseek执行成功,则返回新的文件偏移量。lseek也可以用来测试目标文件是否支持设置偏移量。
对于SEEK_CUR和SEEK_END,当文件偏移量设置为负数并且lseek成功执行,则返回的文件偏移量是实际偏移量,而不是设置的offset值,例如:
#include <unistd.h> #include <fcntl.h> #include <iostream> using std::cout; using std::endl; int main() { auto fd = open("/file",O_RDONLY); cout << fd << endl; cout << lseek(fd,-2,SEEK_END); close(fd); return 0; }
假定/file是一个文本文件,其内容为“abcde”,则当上面代码中leesk执行成功后,lseek返回值为4,而不是-2,因为我们指定从文件末尾处(SEEK_END)开始进行偏移,偏移量向前(-2),则实际偏移量移动到“d”,被移动经过的第二个是“e”,而第一个是Linux系统上文本末尾的结束标记字符“$”。
文件偏移量的设置可以大于文件的长度,在这种情况下,下一次对文件的读写会加长文件,并在文件中间构成一个空洞,空洞部分被读取为0,空洞部分并不占用硬盘空间。
函数read
函数read用于从打开的文件读取数据。其头文件及函数原型如下:
#include <unistd.h> ssize_t read (int fd, void *buf, size_t nbytes)
ssize_t在Linux系统上是一个long int类型。fd是待读取的源文件,buf是待写入的目标缓冲,而nbytes则是想要读取的最大字节数。read函数成功之后返回读取的实际字节数。
- 返回的字节数和想要读取的最大字节数可能不一致,原因有如下几个:
- 即将到达文件尾部,而剩余的字节数小于要读取的字节数;
- 从终端设备读取时,是以换行为准,指定的字节数大于一行的总字节数时;
- 从网络读时,缓冲导致小于想要读取的字节数;
- 从面向记录的设备读时,一次最多返回一个记录;
- 信号中断导致只读取部分的返回。
函数write
函数write用于向打开的文件写入数据。其头文件及函数原型如下:
#include <unistd.h> ssize_t write (int fd, const void* buf, size_t n);
write函数返回值通常等于n,也即指定写入的数量,否则返回-1表示出错。
对于read和write函数,一定要注意其操作的是内存中的字节数,比如要用read和write去读写int类型变量,则一次性要读写32位,也即4字节。因此其是二进制还是文本模式取决于对字节的解释。
I/O的效率
由于read和write是不带缓冲的,因此每一次的调用都会进行一次内核调用,这会对I/O的效率造成很大的影响。
原子操作
原子操作指的是一个活一系列操作是密不可分的,要么完成全部,要么一个都没完成,是不可能只执行了其中的一部分的。
函数dup和dup2
函数dup和dup2用来复制一个现有的文件描述符。其头文件及函数原型如下:
#include <unistd.h> int dup (int fd); int dup2(int fd1, int fd2);
这两个在成功执行时返回新的描述符,当失败时,它们返回-1。对于dup2( )来说,如果fd2已经被占用,其会先关闭旧的fd2,然后返回与fd2相等的描述符值,当fd1和fd2相等时,其什么也不做,仅仅返回fd2。
函数sync、fsync、fdatasync
UNIX系统通常会实现一个磁盘缓冲的功能,当程序向硬盘写入内容时,并不会每次都去写硬盘,而是将待写入的东西缓存buffer中,在稍后将多次缓存的数据一次性写入硬盘,这种方式称为延迟写。通常内核会在缓冲区满了或者需要重用缓冲区时进行刷新写入。UNIX提供了三个这样的函数。其头文件及函数原型如下:
#include <unistd.h> void sync(void); int fsync(int fd); int fdatasync(int fd);
其中,fdatasync( )函数在FreeBSD及其衍生版(比如MacOS)中不受支持。
sync( )函数是对整个缓冲区作用生效,并且不等待实际磁盘操作的结束就返回;fsync( )函数是只对指定的文件描述符作用生效,它等待磁盘操作结束才返回。fdatasync( )函数和fsync( )函数类似,区别是它只刷新文件的数据部分,不刷新文件的属性部分。
函数fcntl
函数fcntl( )可以用来设置文件描述符的属性。其头文件及函数原型如下:
#include <fcntl.h> int fcntl (int fd, int cmd, ...);
fcntl( )函数成功时返回对应的值,失败时返回-1。它具有以下5种功能:
- 1.复制一个已有的描述符;
- 2.获取或设置文件描述符标志;
- 3.获取或设置文件状态标志;
- 4.获取或设置异步I/O所有权;
- 5.获取或设置记录锁。
利用fcntl( )函数修改文件描述符标志或者文件状态标志时,必须先获取当前的标志状态,然后再追加更新,最后将新的状态标志设置写入回去,如果直接设置会导致旧的标志被复位。
函数ioctl
ioctl( )函数是一个功能比较混杂的函数。通常用于终端I/O,其头文件及函数原型如下:
#include <sys/ioctl.h> int ioctl (int fd, unsigned long int request, ...);
习题
3.1 当读/写磁盘文件时,本章中描述的函数确实是不带缓冲机制的吗?请说明原因。
最终的硬盘I/O是带缓冲的,因为内核会提供一个缓冲区用来存储向硬件设备中写入的数据。对于普通概念上的缓冲,通常是指非内核提供的用户级缓冲。
3.2 编写一个与3.12节中dup2功能相同的函数,要求不调用fcntl函数,并且要有正确的出错处理。
int dup2_self(int fd, int fd2) { if (fd < 0 || fd2 < 0 || fd2 > OPEN_MAX) //判断文件描述符的合法性 { return -1; } if (fd == fd2) { return fd2; } close(fd2); //如果已打开fd2,则关闭。未打开也不会有影响。 if (fd2 == 0) { return dup(fd); //dup()总是返回最小的,如果是0,则close()关闭后,一定返回0 } int *fdp = new int[(sizeof(int) * fd2)]{}; //全部默认初始化为0,执行到此处时fd2一定大于0 int tempfd = -1, i = 0; while ((tempfd = dup(fd)) != fd2 && tempfd != -1) { fdp[i] = tempfd; ++i; } while (i+1) { close(fdp[i--]); } delete[] fdp; return tempfd; }
3.3 假设一个进程执行下面3个函数调用:
fd1 = open(pathname, oflags); fd2 = dup(fd1); fd3 = open(pathname, oflags);
画出类似于图3-9的结果图。对fcntl作用于fd1来说,F_SETFD命令会影响哪一个文件描述符?F_SETFL呢?
3.4 在许多程序中都包含下面一段代码?
dup2(fd, 0); dup2(fd, 1); dup2(fd, 2); if (fd > 2) close(fd);
为了说明if语句的必要性,假设fd是1, 画出每次调用dup2时3个描述符项及相应的文件表项的变化情况。然后再画出fd为3的情况。
3.5 在Bourne shell、Bourne-again shell和Korn shell中,digit1>&digit2表示要将描述符digit1重定向至描述符digit2的同一文件。请说明下面两条命令的区别。
./a.out > outfile 2>&1
./a.out 2>&1 > outfile
(提示:shell从左到右处理命令行。)
3.6 如果使用添加标志打开一个文件以便读、写,能否使用lseek在任一位置开始读?能否用lseek更新文件中任一部分的数据?请编写一段程序以验证之。