输入/输出(I/O)是在主存和外部设备之间复制数据的过程。
在 Linux 系统中,通过使用由内核提供的系统级 Unix I/O 函数来实现高级别的 I/O 函数。
Unix I/O
一个 Linux 文件就是一个 $m$ 个字节的序列: $$ B_0, B_1,\dots,B_k,\dots,B_{m-1} $$ 所有的 I/O 设备被模型化为文件,而所有的输入和输出都被当作对应文件的读写来执行,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这样使所有的输入和输出能用统一的方式来执行:
- 打开文件:程序要求内核打开文件,内核会返回一个小的非负整数的描述符,它在后续对此文件的所有操作中标识这个文件。
- 每个进程开始时打开三个文件:标准输入(描述符0)、标准输出(描述符1)和标准错误(描述符2)。头文件
<unistd.h>
定义了常量STDIN_FILENO
、STDOUT_FILENO
和STDERR_FILENO
。 - 改变当前文件位置:对于每个打开的文件,内核保持一个文件的位置
k
,初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek
操作,设置文件的位置。 - 读写文件:一个读操作就是从文件复制
n > 0
个字节到内存,从当前文件位置k
开始增加到k+n
。给定一个大小为m
字节的文件,当k >= m
时执行读操作会触发 end-of-file(EOF)的条件。那么写操作就是从内存复制n > 0
个字节到文件。 - 关闭文件:程序通知内核关闭这个文件,内核会释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
文件
文件类型如下:
- 普通文件(regular file)包含任意数据,通常区分文本文件和二进制文件。只是文本文件中有 ASCII 或 Unicode 字符。
- 目录(directory)是包含一组链接(link)的文件,每个链接都将一个文件名映射到一个文件,每个目录至少有
.
和..
,代表当前目录的链接和到父目录的链接。 - 套接字(socket)用来与另一个进程进行跨网络通信的文件。
打开和关闭文件
进程通过 open
函数来打开或创建文件的:
# include <sys/types.h>
# include <sys/stat.h>
# include <fcntl.h>
// 成功返回文件描述符,出错返回 -1
int open(char *filename, int flags, mode_t mode);
flags
参数可选:
- O_RDONLY:只读。
- O_WRONLY:只写。
- O_RDWR:可读可写。
fd = open("foo.txt", O_RDONLY, 0);
flags
参数是多位掩码的或:
- O_CRETE:如果文件不存在就创建一个空文件。
- O_TRUNC:如果文件存在就截断。
- O_APPEND:写操作时设置文件位置到文件结尾。
fd = open("foo.txt", O_WRONLY|O_APPEND, 0);
mode
参数指定了新文件的访问权限。
umask
在 Linux 中,umsak 用于设置新建文件和目录的默认权限。它由3位八进制数表示,分别表示用户、组和其他用户的权限掩码。
# define DEF_MODE S_IRUSR|S_IWUSR|S_IROTH|S_IWOTH
# define DEF_UMASK S_IWGRP|S_IWOTH
umask(DEF_UMASK);
fd = open("foo.txt", O_CREAT|O_TRUNC|O_WRONLY, DEF_MODE);
最后进程通过 close
函数关闭文件。
# include <unisted.h>
// 成功返回 0, 否则返回 -1
int close(int fd);
读和写文件
通过调用 read 和 write 函数来执行读写:
# include <unistd.h>
// 成功返回读的字节数;EOF 为 0;出错 -1
ssize_t read(int fd, void *buf, size_t n);
// 成功返回写的字节数,错为 -1.
ssize_t write(int fd, const void *buf, size_t n);
size_t 和 ssize_t
size_t
一般用来表示一种计数,比如有多少东西被拷贝等。它在数组下标和内存管理函数等地方广泛使用。ssize_t
用来表示可以被执行读写操作的数据块的大小,它表示的是有符号的 size_t 类型。
char c;
while (read(STDIN_FILENO, &c, 1) != 0)
write(STDOU_FILENO, &c, 1);
exit(0);
RIO 健壮地读写
RIO(Robust I/O)提供了两种函数处理不足值(short count)。
无缓冲输入输出:直接在内存和文件之间传送数据,没有应用级缓冲。
rio_readn
函数:
- 此函数尝试从文件描述符
fd
中读取n
个字符到用户缓冲区usrbuf
中。 - 如果被信号处理函数中断,它会再次尝试读取,确保读取到
n
个字节。 - 与普通的
read
函数不同,rio_readn
解决了在网络编程中可能读取到的字节数少于请求数量的问题。
// 成功返回读的字节数;EOF 为 0;出错 -1
ssize_t rio_readn(int fd, void *usrbuf, size_t n) {
size_t nleft = n;
ssize_t nread;
char *bufp = usrbuf;
while (nleft > 0) {
if ((nread = read(fd, bufp, nleft)) < 0) {
if (errno == EINTR)
nread = 0; // 被信号处理函数中断,再次尝试读取
else
return -1; // 出错,errno 由 read 设置
} else if (nread == 0) // 读取到 EOF
break;
nleft -= nread; // 剩下的字符数减去本次读到的字符数
bufp += nread; // 缓冲区指针向右移动
}
return (n - nleft); // 返回实际读取的字符数
}
rio_writen
函数:
- 此函数类似于
rio_readn
,但用于写出数据。 - 它保证写出
n
字节,不会返回不足值。
// 成功返回写的字节数,错为 -1.
ssize_t rio_writen(int fd, void *usrbuf, size_t n) {
size_t nleft = n;
ssize_t nwritten;
char *bufp = usrbuf;
while (nleft > 0) {
if ((nwritten = write(fd, bufp, nleft)) <= 0) {
if (errno == EINTR)
nwritten = 0;
else
return -1;
}
nleft -= nwritten;
bufp += nwritten;
}
return n;
}
带缓冲带输入:
rio_readlineb
从内部读缓冲区复制一个文本行,当缓冲区变空时,会自动地调用 read
重新填满缓冲区,如果文件既包含 文本行也二进制数据文件,就用 rio_readn
的缓冲版本 rio_readnb
,它从和 rio_readlineb
一样的缓冲区中传送原始字节。
void rio_readinitb(rio_t *rp, int fd);
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);
ssize_t rio_readnb(rio_t *rp, void *userbuf, size_t n);
每打开一个描述符,都会调用一次 rio_readinitb
函数,它将描述符 fd
和 地址 rp
处的一个类型为 rio_t
的缓冲区联系起来。
# define RIO_BUFSIZE 8192
typedef struct{
int rio_fd;
int rio_cnt;
char *rio_bufptr;
char rio_buf[RIO_BUFSIZE];
}
void rio_readinitb(rio_t *rp, int fd){
rp->rio_fd = fd;
rp->fio_cnt = 0;
rp->rio_bufptr = rp->rio_buf;
}
rio_readlineb
函数从文件 rp
读出下一个文本行(包括结尾的换行符),复制到 usrbuf
,并用 NULL 字符来结束这个文本行。超过 maxlen-1
的文本行会被截断,并用一个 NULL 字符结束。
注意:带缓冲的函数调用不应该和无缓冲的 rio_readn
交叉调用。
int main(int argc, char **argv){
int n;
rio_t rio;
char buf[MAXLINE];
// 先初始化将数据读取到内置的缓冲区中
Rio_readinitb(&rio, STDIN_FILENO);
while ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0)
Rio_write(STDOUT_FILENO, buf, n);
}
我们一起看一下 rio_read
函数的实现:
static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n){
int cnt;
// 处理缓冲区
while (rp->rio_cnt <= 0){
// 用 read 读取到缓冲区中
rp -> rio_cnt = read(rp->rio_fd, rp->rio_buf, sizeof(rp->rio_buf));
if (rp->rio_cnt < 0){
if (errno != EINTR)
return -1
}
else if (rp -> rio_cnt == 0)
return 0;
else
// 加载成功后重置 buffer 地址
rp->rio_bufptr = rp->rio_buf;
}
// 将数据从缓冲区复制到用户缓冲区
cnt = n;
if (rp->rio_cnt < n)
cnt = rp -> rio_cnt;
memcpy(usrbuf, rp->rio_bufptr, cnt);
rp->rio_bufptr += n;
rp->rio_cnt -= cnt;
return cnt;
}
errno
errno 是一个全局变量,用于表示最近一次发生的错误代码,它被定义在 errno.h 头文件中。
接着,我们再看到 rio_readlineb
函数和 rio_readnb
函数:
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen){
int n, rc;
char c, *bufp = usrbuf;
for (n = 1; n < maxlen; n++){
if ((rc = rio_read(rp, &c, 1)) == 1){
*bufp++ = c;
if (c == '\n'){
n++;
break;
}
}else if (rc == 0){
if (n == 1)
return 0;
else
break;
} else
return -1;
}
*bufp = 0;
return n-1;
}
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n){
size_t nleft = n;
ssize_t nread;
char *bufp = usrbuf;
while (nleft > 0){
if ((nread = rio_read(rp, bufp, nleft)) < 0)
return -1;
else if(nread == 0)
break;
nleft -= nread;
bufp += nread;
}
return (n - nleft);
}
读取文件元数据
检索关于文件的信息,也就是元数据(meta data):
# include <unistd.h>
# include <sys/stata.h>
int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat*buf);
st_size
成员包含了文件的字节数大小。st_mode
成员编码了文件访问许可位和文件类型,它的低9位标识了文件所有者、组用户和其他用户的读写执行权限。
这些权限位的含义如下:
- 文件类型:
S_IFIFO
:管道或 FIFO 文件。S_IFCHR
:字符特殊文件。S_IFDIR
:目录文件。S_IFBLK
:块特殊文件。S_IFREG
:普通文件。S_IFLNK
:链接文件。S_IFSOCK
:套接字。
- 文件访问权限控制:
S_ISVTX
:粘住位。S_ISGID
:将进程的有效组 ID 设置为文件的组所有者 ID。S_ISUID
:将进程的有效用户 ID 设置为文件的用户 ID。
- 文件访问权限:
S_IXOTH
:其他用户可执行。S_IWOTH
:其他用户可写。S_IROTH
:其他用户可读。S_IXGRP
:组用户可执行。S_IWGRP
:组用户可写。S_IRGRP
:组用户可读。S_IXUSR
:文件所有者可执行。S_IWUSR
:文件所有者可写。S_IRUSR
:文件所有者可读。
粘住位(也称为黏滞位)是 Unix 文件系统权限的一个特殊标志。它在文件或目录的权限中起到重要作用。
- 文件的粘住位:
- 如果一个可执行程序文件设置了粘住位,那么在该程序第一次执行并结束时,该程序正文的一部分会被保存在交换区(swap space)中。
- 正文部分指的是机器指令部分,这使得下次执行该程序时能更快地将其装入内存区。
- 在交换区中,文件是连续存放的,而在一般的 Unix 文件系统中,文件的各数据块很可能是随机存放的。
- 常用的应用程序,例如文本编辑程序和编译程序,通常设置了它们所在文件的粘住位。
- 现今较新的 Unix 系统大多数都具有虚存系统以及快速文件系统,所以不再需要使用这种技术。
- 目录的粘住位:
- 在目录上设置粘住位,只有对该目录具有写许可权的用户并且满足以下条件之一,才能删除或更名该目录下的文件:
- 拥有此文件。
- 拥有此目录。
- 是超级用户。
- 例如,目录
/tmp
和/var/spool/uucp/public
是设置粘住位的候选者,这两个目录是任何用户都可以在其中创建文件的目录。- 这两个目录对任一用户(用户、组和其他)的许可权通常都是读、写和执行。但是用户不应能删除或更名属于其他人的文件,因此在这两个目录的文件上都设置了粘住位。
Linux 在 sys/stat.h
中定义了宏来去定 st_mode
成员的文件类型:
- S_ISREG(m)。这是一个普通文件吗?
- S_ISDIR(m)。这是一个目录文件吗?
- S_ISSOCK(m)。这是一个网络套接字吗?
int main(int argc, char **argv){
struct stat stat;
char *type, *readok;
Stat(argv[1], &stat);
if (S_ISREG(stat.st_mode))
type = "regular";
else if (S_ISDIR(stat.st_mode))
type = "directory";
else
type = "other";
if ((stat.st_mode & S_IRUSR))
redok = "yes";
else
readok = "no";
printf("type: %s, read: %s\n", type, readok);
exit(0);
}
读取目录内容
用 readdir
系列函数来读取目录的内容:
# include <sys/types.h>
# include <dirent.h>
// 成功返回处理的指针;出错为NULL
DIR *opendir(const char *name);
函数 opendir
以路径名为参数,返回指向目录流(directory stream)的指针,流是对条目有序列表的抽象,在这里指目录项的列表。
# include <dirent.h>
// 成功则为指向下一个目录项的指针没有更多目录或出错就为NULL并设置errno
struct dirent *readdir(DIR *dirp);
每次对 readdir
的调用返回到都是指向流 drip
中下一个目录项的指针,每个目录项都是一个结构:
struct dirent{
ino_t d_ino; // inode number
char d_name[256]; // filename
}
- 什么是 inode?
- 理解 inode 需要从文件存储说起。
- 文件存储在硬盘上,硬盘的最小存储单位叫做“扇区”(Sector)。
- 每个扇区储存512字节(相当于0.5KB)。
- 操作系统读取硬盘时,不会一个个扇区地读取,而是一次性连续读取多个扇区,即一次性读取一个“块”(block)。
- 这种由多个扇区组成的“块”是文件存取的最小单位。
- “块”的大小通常是4KB,即连续八个扇区组成一个 block。
- 文件数据存储在“块”中,但我们还需要找到一个地方来存储文件的元信息,例如文件的创建者、创建日期、大小等等。
- 这个存储文件元信息的区域就叫做 inode,中文译名为“索引节点”。
- 每个文件都有对应的 inode,里面包含了与该文件有关的一些信息。
- inode 的内容
- inode 包含文件的元信息,具体包括:
- 文件的字节数
- 文件拥有者的 User ID
- 文件的 Group ID
- 文件的读、写、执行权限
- 文件的时间戳(ctime、mtime、atime)
- 链接数(有多少文件名指向这个 inode)
- 文件数据 block 的位置
- 除了文件名以外的所有文件信息都存在 inode 中。
- inode 的大小
- inode 也会消耗硬盘空间,因此硬盘格式化时会自动将硬盘分成两个区域:数据区和 inode 区(inode table)。
- 每个 inode 节点的大小一般是128字节或256字节。
- inode 节点的总数在格式化时给定,通常是每1KB或每2KB设置一个 inode。
- 如果 inode 节点用尽,但硬盘未满,就无法在硬盘上创建新文件。
- inode 号码
- 每个 inode 都有一个号码,操作系统用 inode 号码来识别不同的文件。
- Unix/Linux 系统内部不使用文件名,而使用 inode 号码来识别文件。
- 文件名只是 inode 号码便于识别的别称。
- 使用
ls -i
命令可以查看文件名对应的 inode 号码。- 目录文件
- 目录(directory)也是一种文件,实际上就是目录文件。
- 目录文件的结构是一系列目录项(dirent)的列表,每个目录项包含文件名和对应的 inode 号码。
- 使用
ls -l
命令可以列出文件的详细信息。- 硬链接
- Unix/Linux 系统允许多个文件名指向同一个 inode 号码,这称为“硬链接”(hard link)。
- 硬链接可以创建相同 inode 号码的文件,删除一个文件名不影响其他文件名的访问。
- 软链接
- 文件 A 的内容是文件 B 的路径,这称为“软链接”(soft link)或“符号链接”(symbolic link)。
- 文件 A 依赖于文件 B 存在,删除文件 B 会导致打开文件 A 报错。
我们通过 closedir
关闭流并释放所有的资源。
# include <dirent.h>
int closedir(DIR *dirp);
接下来,我们看一看实例:
int main(int argc, char **argv){
DIR *streamp;
struct dirent *dep;
streamp = opendir(argv[1]);
errno = 0;
while ((dep = readdir(streamp)) != NULL){
printf("Found file: %s \n", dep->d_name);
}
if (errno != 0)
unix_error("readdir error");
closedir(streamp);
eixt(0);
}
共享文件
内核用三个相关的数据结构来表示打开的文件:
- 描述符表(descriptor table)。每个进程都有它独立的描述表,它的表项是由进程打开的文件描述符来索引的。
- 文件表(file table)。打开文件集合是由一张文件表来表示的,所有进程共享这张表。每个表项包括:
- 当前的文件位置:表示下一次读或写的位置。
- 引用计数:表示有多少个描述符指向该文件表表项。
- 指向
v-node
表中对应表项的指针。
v-node
表(v-node table)。同文件表一样,所有的进程共享这账v-node
表。每个v-node
表表项对应一个文件,记录文件的元数据信息,如权限、大小、类型等。
多个描述符也可以通过不同的文件表项来引用同一个文件。当用同一个 filename 调用 open 函数两次就会出现这种情况。
在调用 fork 之后,子进程会有父进程的一个描述符表的副本。父子进程共享相同的文件打开集合,因此共享相同的文件位置。
fork、wait 和 waitpid
fork():
fork()
函数用于创建新进程。当父进程调用fork()
时,会生成一个子进程。子进程与父进程共享相同的代码段、数据段、堆栈段等。- 在子进程中,
fork()
返回0;在父进程中,返回子进程的进程ID。#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <unistd.h> int main() { pid_t pid = fork(); if (pid == 0) { printf("我是子进程,进程ID:%d\n", getpid()); } else if (pid > 0) { printf("我是父进程,进程ID:%d,子进程ID:%d\n", getpid(), pid); } else { printf("fork出错!\n"); } return 0; }
wait():
wait()
函数用于等待子进程结束。当子进程终止时,父进程通过wait()
获取子进程的退出状态。#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> int main() { pid_t pid = fork(); if (pid == 0) { printf("我是子进程,进程ID:%d\n", getpid()); exit(0); } else if (pid > 0) { printf("我是父进程,进程ID:%d,子进程ID:%d\n", getpid(), pid); int status; pid_t wpid = wait(&status); if (wpid == -1) { perror("wait出错"); exit(1); } if (WIFEXITED(status)) { printf("子进程以状态 %d 退出\n", WEXITSTATUS(status)); } } else { printf("fork出错!\n"); } return 0; }
waitpid():
waitpid()
函数类似于wait()
,但提供更灵活的等待方式。您可以指定等待特定的子进程,以及控制等待的行为。#include <stdio.h> #include <sys/wait.h> #include <unistd.h> int main() { int status; pid_t pid = fork(); if (pid < 0) { printf("fork出错\n"); } else if (pid == 0) { printf("这是子进程,进程ID:%d\n", getpid()); sleep(1); return 1; } else { printf("这是父进程,进程ID:%d\n", getpid()); pid_t wait_pid = waitpid(pid, &status, 0); printf("子进程 %d 以状态 %d 退出\n", wait_pid, WEXITSTATUS(status)); } return 0; }
I/O 重定向
Linux shell 提供的重定向就是,允许用户将磁盘文件和标准输入输出联系起来。
当 Web 服务器代表客户运行 CGI 程序的时候,它就执行一种相似类型的重定向。
# include <unistd>
int dup2(int oldfd, int newfd);
dup2
函数复制 oldfd
文件内容到 newfd
文件,覆盖 newfd
。如果 newfd
是打开了,则会在复制之前被关闭。那么实际上,我们并不需要对 newfd
的内容进行覆盖,我们只需要将描述符指向 oldfd
就可以了。
通过调用 dup2(4,1)
重定向后,文件A被关闭,原本的描述符会指向文件 B。
# include <stdio.h>
# include <unistd.h>
int main(){
int fd1, fd2;
char c;
fd1 = open("foo.txt", &c, 1);
fd2 = open("foo.txt", &c, 1);
read(fd2, &c, 1);
dup2(fd2, fd1);
read(fd1, &c, 1);
printf("c = %c \n", c);
}
标准 I/O
标准 I/O 库将一个打开都文件模型化为 流。一个流就是一个指向 FILE 类型的结构的指针。
每个 ANSI C 程序开始都有三个打开的流 stdin、stdout 和 stderr。
# include <stdio.h>
extern FILE *stdin; // 0
extern FILE *stdout; // 1
extern FILE *stderr; // 2
类型为 FILE 的流是对文件描述符和流缓冲区的抽象。
流缓冲区的目的和 RIO 读缓冲区的一样:就是让开销高的 Linux I/O 系统调用的数量尽可能小。
I/O 函数综合
以下为使用规则:
- 只要有可能就尽量用标准 I/O。
- 不要用 scanf 或 rio_readlineb 来读二进制文件。
- 对网络套接字的 I/O 使用 RIO 函数。