六星经典CSAPP-笔记(10)系统IO
六星经典CSAPP-笔记(10)系统I/O
1.Unix I/O
所有语言的运行时系统都提供了高抽象层次的I/O操作函数。例如,ANSI C在标准I/O库中提供了诸如printf和scanf等I/O缓冲功能的函数;C++中则重载了<<和>>用来支持读写。在Unix系统中,这些高层次的函数基于Unix的系统I/O函数来实现,多数时候我们都无需直接使用底层的Unix I/O。但学习Unix系统I/O能更好地理解一些系统概念,而且当高层次的函数不适用时我们也能轻松地实现想要的功能,例如访问文件的元数据。
Unix/Linux将各种I/O设备统一而优雅地抽象为文件,在此基础上提供了一套非常简洁的低层次的API接口,也就是Unix I/O。这套API主要由以下五个函数组成:
- open():请求内核打开一个file,从而应用代码能够访问file对应的I/O设备。内核会返回一个非负整数,叫做描述符descriptor。内核会为打开的file维护一组数据结构(学习Linux内核时了解了一些),可以把描述符想成file在内核中的id,后续各种操作都需要它。
- lseek():修改当前file的操作位置k,即从file开端的字节偏移量。读写文件时会自动移动k,也可以显式调用lseek()移动k。
- read():从位置k开始,从file拷贝大于0个字节到内存中。当k超过文件的总字节大小时,会触发一个条件
end-of-file(EOF)
。 - write():类似地,从内存拷贝大于0个字节到file的位置k处,并更新k。
- close():通知内核释放file在内核中对应的数据结构,将描述符放回到资源池中。进程结束时,内核会自动关闭所有打开的file。
EOF到底是什么东西?
首先必须明确一点,没有EOF字符这种东西。EOF是一种状态或条件,由操作系统内核去检测是否达到这种条件。当应用程序从read()中读取到0时,就说明达到EOF条件了。对于磁盘file,EOF表示位置k超过了文件大小。对于网络连接file,EOF表示连接的一端关闭了连接,另一端就会检测到EOF。
2.打开关闭文件
下面详细学习一下open()和close()函数:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
/** Returns: new file descriptor if OK, −1 on error */
int open(char *filename, int flags, mode_t mode);
#include <unistd.h>
/** Returns: zero if OK, −1 on error */
int close(int fd);
flags参数指明想要怎样访问文件,它可以由几个模式组合而成:
- O_RDONLY:只读
- O_WRONLY:只写
- O_RDWR:读写
- O_CREAT:文件不存在则创建
- O_TRUNC:文件存在则truncate它
- O_APPEND:写文件前先将位置k置为末尾,即追加模式
mode参数指明新建文件的访问权限,此参数可以省略。要注意的是:每个进程都有一个由umask()函数设置的umask
,open()函数最终创建出的文件的访问权限是mode & ~umask
:
- S_IRUSR/S_IWUSR/S_IXUSR:Owner的读、写、执行权限
- S_IRGRP/S_IWGRP/S_IXGRP:Owner所在组中成员的读、写、执行权限
- S_IROTH/S_IWOTH/S_IXOTH:其他任何用户的读、写、执行权限
3.读写数据
read()和write()至多拷贝n个字节,返回值:-1表示错误,0表示EOF,大于0则表示实际拷贝的字节数。
#include <unistd.h>
/** Returns: number of bytes read if OK, 0 on EOF, −1 on error */
ssize_t read(int fd, void *buf, size_t n);
/** Returns: number of bytes written if OK, −1 on error */
ssize_t write(int fd, const void *buf, size_t n);
ssize_t和size_t有什么区别?
size_t被定义为unsigned int,而ssize_t被定义为int。因为read()和write()要返回-1表示发生错误,所以要使用ssize_t作为返回值。
#include <unistd.h>
int main(int argc, char const *argv[])
{
char c;
while(read(STDIN_FILENO, &c, 1) != 0) {
write(STDOUT_FILENO, &c, 1);
}
return 0;
}
在以下几种情况下,read()和write()可能会返回少于n个字节:
- 没有数据可读了:假如我们以50字节chunk读取,而只有20个字节可读,调用read()就会遇到EOF。
- 从终端读取:每次只能读取到终端上的一行文本。
- 读/写Socket:Socket内部的缓冲限制和网络延迟会导致只能读取一部分数据。同理,进程间的Unix管道pipe、IPC等读写的情况也类似。
而当你从硬盘读取数据时,除非读到末尾触发EOF,你永远也不会碰到返回小于n字节的情况。同样写数据时也从不会碰到这种情况。
4.文件元数据
可以通过文件名或描述符获得文件的元数据。
#include <unistd.h>
#include <sys/stat.h>
/** Returns: 0 if OK, −1 on error */
int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);
/* Metadata returned by the stat and fstat functions */
struct stat {
dev_t st_dev; /* Device */
ino_t st_ino; /* inode */
mode_t st_mode; /* Protection and file type */
nlink_t st_nlink; /* Number of hard links */
uid_t st_uid; /* User ID of owner */
gid_t st_gid; /* Group ID of owner */
dev_t st_rdev; /* Device type (if inode device) */
off_t st_size; /* Total size, in bytes */
unsigned long st_blksize; /* Blocksize for filesystem I/O */
unsigned long st_blocks; /* Number of blocks allocated */
time_t st_atime; /* Time of last access */
time_t st_mtime; /* Time of last modification */
time_t st_ctime; /* Time of last change */
};
例如,下面是一个结合了文件打开关闭和元数据读取的小例子,首先新建文件,然后利用fstat()函数查看新创建文件的访问权限,并以类似ls命令的格式打印出来:
// printf, getchar
#include <stdio.h>
// malloc
#include <stdlib.h>
// open, mode_t
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
// close
#include <unistd.h>
mode_t getumask();
char *mode2str(mode_t mode);
int main(int argc, char const *argv[])
{
int fd;
struct stat stat;
/*
* 1.Print default umask.
* The typical default value for the process umask is:
* S_IWGRP | S_IWOTH (octal 022)
*/
printf("umask: %s\n", mode2str(getumask()));
// 2.Create a new file
if ((fd = open("foo.txt",
O_WRONLY | O_CREAT,
S_IRUSR | S_IWUSR)) == -1) {
fprintf(stderr, "Create file failed\n");
exit(1);
}
// 3.Check if new file mode = mode & ~mask
if (fstat(fd, &stat) == 0)
printf("file mode: %s\n", mode2str(stat.st_mode));
else
printf("Get metadata failed\n");
// 4.Close file
close(fd);
return 0;
}
/**
* There is a getumask(), but only specified in glib_c
* , not portable.
* @return current umask
*/
mode_t getumask()
{
mode_t mode = umask(0);
umask(mode);
return mode;
}
/**
* Acted like 'ls -l'
* @param mode file mode
* @return human-read
*/
char *mode2str(mode_t mode)
{
char *str = malloc(10 * sizeof(char));
int i = 0;
str[i++] = S_ISREG(mode) ? 'F' : 'D';
str[i++] = mode & S_IRUSR ? 'R' : '-';
str[i++] = mode & S_IWUSR ? 'W' : '-';
str[i++] = mode & S_IXUSR ? 'X' : '-';
str[i++] = mode & S_IRGRP ? 'R' : '-';
str[i++] = mode & S_IWGRP ? 'W' : '-';
str[i++] = mode & S_IXGRP ? 'X' : '-';
str[i++] = mode & S_IROTH ? 'R' : '-';
str[i++] = mode & S_IRWXO ? 'W' : '-';
str[i++] = mode & S_IXOTH ? 'X' : '-';
return str;
}
5.文件共享
Unix有很多种共享文件的方式,在学习之前先要了解Unix内核为每个文件维护了哪些数据结构。正是有这些数据结构的存在,才产生了多种多样的共享方式:
- 描述符表(descriptor table):每个进程都有一张独立的描述符表,每个entry中都保存了打开file的描述符和指向文件表的指针。
- 文件表(file table):文件表是所有进程共享的,每个entry保存了当前位置,指向文件的描述符的个数(可能有多个)和指向v-node表的指针。当引用个数减少为0时,内核会删除掉该文件表项。
- v-node表(v-node table):v-node表也是所有进程共享的,每个entry保存了元数据stat的信息。
下面就看一下最常见的三种情况。
5.1 不共享
5.2 同一文件被打开多次
5.3 子进程继承父进程已打开文件
下面是一个小例子,open_twice()尝试重复打开一个文件两次,然后用两次得到的描述符从文件中读取一个字符,结果读到的是’f’,说明v-node表项相同,但file table未发生共享。而inherit_parent_fd()则是打开文件后,fork一个子进程,子进程先读取一个字符,父进程等子进程结束后再读取一个字符,结果读到的是’o’,说明file table发生共享,子进程的读取导致位置k发生偏移。
// printf
#include <stdio.h>
// exit
#include <stdlib.h>
// open, seek
#include <fcntl.h>
// read, write
#include <unistd.h>
// wait
#include <wait.h>
void open_twice(char *filename);
void inherit_parent_fd(char *filename);
int main(int argc, char const *argv[])
{
open_twice("foo.txt");
inherit_parent_fd("foo.txt");
return 0;
}
void open_twice(char *filename)
{
int fd1, fd2;
char c;
fd1 = open(filename, O_RDONLY);
fd2 = open(filename, O_RDONLY);
read(fd1, &c, 1);
read(fd2, &c, 1);
printf("c = %c\n", c);
}
void inherit_parent_fd(char *filename)
{
int fd;
char c;
fd = open(filename, O_RDONLY);
if (fork() == 0) {
read(fd, &c, 1);
exit(0);
}
wait(NULL);
read(fd, &c, 1);
printf("c = %c\n", c);
}
6.标准I/O
6.1 标准I/O库
ANSI C定义了一组更多层次的I/O函数,叫做标准I/O库(libc),提供了Unix C外的另一种选择:
- 打开关闭:fopen()和fclose()
- 读写字节:fread()和fwrite()
- 读写字符:fgets()和fputs()
- 格式化I/O:scanf()和printf()
标准I/O库将打开的file抽象为stream。对于程序员来说,stream就是指向FILE类型的指针。stream或者说FILE类型,其实= file描述符 + buffer,其目的就是在内部维护一块缓冲区,从而避免频繁调用开销很大的系统调用。此外,每个ANSI C程序启动时都会自动打开三个stream,对应标准输入、输出、错误流:
#include <stdio.h>
extern FILE *stdin; /* Standard input (descriptor 0) */
extern FILE *stdout; /* Standard output (descriptor 1) */
extern FILE *stderr; /* Standard error (descriptor 2) */
大多数C程序员在其整个职业生涯中都只使用标准I/O库,而不会直接使用底层的Unix I/O,这也是推荐的做法。但标准I/O库在处理网络全双工通信时会有些限制:
- 限制1:调用output函数后,要调用fflush,fseek,fsetpos或rewind后,才能调用input函数。因为对Socket使用lseek是非法的,所以可以在每次调用input前,先调用fflush重置读取位置能够解决此问题。
- 限制2:调用input函数后,要调用fseek,fsetpos或rewind,才能调用output函数,否则会遇到EOF。此问题只能为读和写分别打开两个FILE来解决。
6.2 I/O重定向
Unix Shell提供了I/O重定向操作符,例如unix> ls > foo.txt。这个功能是由dup2()函数来完成的。dup2会拷贝oldfd的描述符表entry覆盖到newfd的entry,假如之前newfd是打开状态的,那么dup2会先关闭newfd再开始拷贝。例如,默认stdout对应fd1,假设foo.txt对应fd4,那么dup2(4, 1)会将fd4的描述符表entry覆盖到fd1,所以最终stdout会和fd4一样,都指向foo.txt文件的file table表项。
#include <unistd.h>
/** Returns: nonnegative descriptor if OK, −1 on error */
int dup2(int oldfd, int newfd);