UNIX环境高级编程 第3章 文件I/O

时间:2021-07-11 00:11:44

前面两章说明了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,如下图所示:

UNIX环境高级编程 第3章 文件I/O

而在我的Mac OS X上,则默认最大为256:

UNIX环境高级编程 第3章 文件I/O

函数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,-,SEEK_END);
close(fd);
return ;
}

假定/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 < || fd2 < || fd2 > OPEN_MAX) //判断文件描述符的合法性
{
return -;
}
if (fd == fd2)
{
return fd2;
} close(fd2); //如果已打开fd2,则关闭。未打开也不会有影响。 if (fd2 == )
{
return dup(fd); //dup()总是返回最小的,如果是0,则close()关闭后,一定返回0
} int *fdp = new int[(sizeof(int) * fd2)]{}; //全部默认初始化为0,执行到此处时fd2一定大于0
int tempfd = -1, i = ;
while ((tempfd = dup(fd)) != fd2 && tempfd != -1)
{
fdp[i] = tempfd;
++i;
}
while (i+)
{
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, );
dup2(fd, );
dup2(fd, );
if (fd > )
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更新文件中任一部分的数据?请编写一段程序以验证之。