APUE第三章总结:文件IO

这是APUE(Advanced Programming in the UNIX Environment,UINX环境高级编程)阅读笔记系列文章

首先参考一个简单的类UINX操作系统实现——xv6,了解操作系统是如何组织文件资源的

xv6的File IO实现

在xv6中,file结构体是对管道,inode(表示常规文件),设备的抽象。xv6没有内核内存分配器,只是静态分配了一个固定大小的file数组,用于存放内核打开的所有file

file descriptor(简称fd)在xv6中,是通过PCB(进程控制块)中的一个固定大小的数组存储的,这表明了两点:

  1. fd是process local的,表示进程对内核资源的引用
  2. 一个进程能够打开的文件是有最大数量限制的

fd和windows的handler很相似,它是为了避免直接使用指针引用内核数据,优点如下

  1. 避免内核数据结构暴露在进程中 如果直接将file结构体的指针交给进程使用,进程就能知道file的内存地址,虽然进程一般无法读写这块地址,但不代表这是足够安全的
  2. 防止恶意进程提供错误指针误导内核 假设进程提供的指针是非法指针,并不指向file结构体,这种情况在xv6中能够通过判断指针是否在数组中越界来确定
    然而,如果进程提供的指针指向其他进程打开的文件,就有机会滥用其他进程的资源
    换而言之,指针是全局的,而fd是process local的
  3. 向进程屏蔽file结构体的实现细节 众所周知随着内核版本的迭代,内核使用的某些数据结构会发生改变,例如添加新功能,需要在原有的结构体上添加新的字段
    这样的细节对进程是无用的,还会导致编译后的二进制依赖于某个特定版本的内核实现

通常一个程序开始运行时,已经有三个文件打开了:0,1,2,分别表示stdin, stdout, stderror
在xv6的实现中,内核并没有对0,1,2特殊处理,因为它只是shell的约定。xv6在用户态下提供了一个shell程序,片段如下

c

  if(open("console", O_RDWR) < 0){
    mknod("console", CONSOLE, 0);
    open("console", O_RDWR);
  }
  dup(0);  // stdout
  dup(0);  // stderr

可以看出shell打开了终端设备文件,并通过dup使得0,1,2指向同一个终端设备文件。而在shell中运行的程序fork自shell程序,所以继承了shell程序的0,1,2文件描述符
换而言之,一个程序运行时,0,1,2不一定是已经打开的文件!!!

文件打开,关闭

open函数打开一个文件,返回fd

c

int open (const char *__file, int __oflag, ...)
参数名说明
__file文件路径
__oflag打开方式,通过比特掩码传递flags
mode只有在创建文件时才会使用,用于设置创建的文件的权限,类型为mode_t
返回文件描述符,-1表示打开失败,并设置erron,其他情况返回文件描述符

返回的fd总是未使用的fd中最小的一个,利用这个特性可以重定向标准输入,标准输出,标准错误

常用oflag

说明
O_RDONLY只读
O_WRONLY只写
O_RDWD可读可写
O_EXEC执行
O_APPNED写入内容附加到末尾
O_CLOEXEC执行exec系列函数时自动关闭,防止子进程继承到父进程的该文件
O_CREATE如果文件不存在,自动创建
O_EXCL和O_CREATE配合使用,如果文件存在,则返回错误
O_TRUNC如果文件能够以可读方式打开,则将文件大小设置为0

c

int close (int __fd)

进程结束时,操作系统会自动关闭进程打开的文件,所以在一些场合下,可以不调用close关闭文件

考虑creat函数

c

int creat (const char *__file, mode_t __mode)

相比open,只能创建文件 打开文件时,如果需要自动创建文件,有两种方式

  1. 使用openO_CREATE
  2. 使用openO_CREATE|O_EXCL判断文件是否存在,如果不存在则使用creat创建。之后再打开文件

这两种方式的不同在于,在并发环境下,先判断一个条件是否成立再执行某个操作,这样的流程是线程不安全的,有可能在判断条件成立后,内核剥夺CPU,重新被调度时条件已经不成立。 使用openO_CREATE,判断文件是否存在和创建文件两个步骤是原子操作,保证不会出现竞态条件

文件操作

xv6实现中,file字段有一个属性,用于记录文件偏移

c

ssize_t read (int __fd, void *__buf, size_t __nbytes)
参数说明
fd必须是可读的文件描述符,可以是常规文件,终端设备,网络套接字等
__buf进程准备的缓冲数组,内核会将读取到的文件内容存放到这里
__nbytes进程期望一次最多读取的字节数量
返回实际读取的字节数,-1表示遇到错误,0表示遇到EOF

如果实际读取的字节数小于__nbytes,表明发生了如下情况

  • 如果读取常规文件,则表示读取__nbytes个字节时遇到了EOF
  • 如果从终端设备中读取,通常一次读取一行,返回这一行的字节数
  • 如果从网络套接字中读取,网络缓冲可能导致小于__nbytes个字节读取
  • 如果从管道中读取,而管道中剩余字节数小于__nbytes
  • etc…

读取后,文件偏移向前移动实际读取的字节数

c

ssize_t write (int __fd, const void *__buf, size_t __n)
参数说明
fdread
__buf进程准备的缓冲数组,内核会读取这个缓冲数组中的字节并写入文件
__nbytes进程期望一次最多写入的字节数量
返回大部分情况下等于__nbytes

如果返回值不等于__nbytes,表明错误发生,可能是磁盘已满,或者用户磁盘使用超过配额
读取后,文件偏移向前移动实际写入的字节数
为了提高IO性能,内核不会立刻将数据写入磁盘,而是先写入内存中的IO缓冲区,随后(保证一定时间内)写入磁盘 如果打开文件时使用了O_SYNC,会阻塞直到数据落盘
如果打开文件时使用了O_APPEND,会先将文件偏移移动到文件末尾,再写入

lseek函数用于修改文件偏移

c

__off_t lseek (int __fd, __off_t __offset, int __whence)
参数说明
__fd文件描述符
__offset新的文件偏移
__whence宏,表示相对位置
返回lseek调用后的文件偏移

__whence使用的宏

  1. SEEK_SET,相对文件开头寻址
  2. SEEK_CUR,相对当前位置寻址
  3. SEEK_END,相对文件末尾寻址

lseek中的l表示long integer,在引入__off_t前,lseek返回类型是long

off_t是有符号数,在极特殊情况下可能是负数,而返回-1又代表错误,所以应该使用==-1判断是否出现错误而不是<0

不是所有类型的文件都能使用lseek,对管道,套接字,FIFO使用lseek会返回-1表示错误

  • 判断文件是否可寻址

    c

    if (lseek(fd, 0 ,SEEK_CUR) != -1) {
      // ...
    }
  • 获取当前文件偏移

    c

    off_t pos = lseek(fd, 0 ,SEEK_CUR);
  • 获取文件大小

    c

    off_t size = lseek(fd, 0 ,SEEK_END);
    lseek只修改文件偏移,不会产生IO操作,可以使用lseek获取大文件的大小

lseek允许将文件偏移设置到EOF之后,随后调用write,就能在文件中间打一个洞
文件打洞后,空洞范围内使用read读取到的值是0'\0'),空洞不会增加文件大小,部分文件系统不会存储空洞中重复的数据

dup系列函数用于实现file对象的共享,也就是两个fd指向同一个资源,因此,也会共享file对象的属性,比如file标志位,文件偏移等等

c

int dup (int __fd)
参数说明
__fd文件描述符
返回一个新的文件描述符,和__fd指向相同的资源

dup返回的fd总是未使用的fd中最小的一个

c

int dup2 (int __fd, int __fd2)
参数说明
__fd文件描述符
__fd2期望dup文件描述符的位置

如果__fd2是已经打开的文件描述符,则自动关闭,然后再执行dup操作
如果__fd == __fd2,则直接返回__fd
如果__fd2未使用,则dup2执行完毕后,__fd2FD_CLOEXEC标志位被清除,方便与fork配合实现管道通信

重定向标准输入,也就是在0处打开其他文件,有两种做法 1.

c

close(0);
open("path/to/file", O_RDONLY);

c

int fd = open("path/to/file", O_RDONLY);
dup2(fd, 0);

区别是,使用dup2可以明显地表达意图,可读性更好

/dev下的文件