Back
Featured image of post 系统级IO

系统级IO

输入/输出(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_FILENOSTDOUT_FILENOSTDERR_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_tssize_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 文件系统权限的一个特殊标志。它在文件或目录的权限中起到重要作用。

  1. 文件的粘住位
    • 如果一个可执行程序文件设置了粘住位,那么在该程序第一次执行并结束时,该程序正文的一部分会被保存在交换区(swap space)中。
    • 正文部分指的是机器指令部分,这使得下次执行该程序时能更快地将其装入内存区。
    • 在交换区中,文件是连续存放的,而在一般的 Unix 文件系统中,文件的各数据块很可能是随机存放的。
    • 常用的应用程序,例如文本编辑程序和编译程序,通常设置了它们所在文件的粘住位。
    • 现今较新的 Unix 系统大多数都具有虚存系统以及快速文件系统,所以不再需要使用这种技术。
  2. 目录的粘住位
    • 在目录上设置粘住位,只有对该目录具有写许可权的用户并且满足以下条件之一,才能删除或更名该目录下的文件:
      • 拥有此文件。
      • 拥有此目录。
      • 是超级用户。
    • 例如,目录 /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
}
  1. 什么是 inode?
    • 理解 inode 需要从文件存储说起。
    • 文件存储在硬盘上,硬盘的最小存储单位叫做“扇区”(Sector)。
    • 每个扇区储存512字节(相当于0.5KB)。
    • 操作系统读取硬盘时,不会一个个扇区地读取,而是一次性连续读取多个扇区,即一次性读取一个“块”(block)。
    • 这种由多个扇区组成的“块”是文件存取的最小单位。
    • “块”的大小通常是4KB,即连续八个扇区组成一个 block。
    • 文件数据存储在“块”中,但我们还需要找到一个地方来存储文件的元信息,例如文件的创建者、创建日期、大小等等。
    • 这个存储文件元信息的区域就叫做 inode,中文译名为“索引节点”。
    • 每个文件都有对应的 inode,里面包含了与该文件有关的一些信息。
  2. inode 的内容
    • inode 包含文件的元信息,具体包括:
      • 文件的字节数
      • 文件拥有者的 User ID
      • 文件的 Group ID
      • 文件的读、写、执行权限
      • 文件的时间戳(ctime、mtime、atime)
      • 链接数(有多少文件名指向这个 inode)
      • 文件数据 block 的位置
    • 除了文件名以外的所有文件信息都存在 inode 中。
  3. inode 的大小
    • inode 也会消耗硬盘空间,因此硬盘格式化时会自动将硬盘分成两个区域:数据区和 inode 区(inode table)。
    • 每个 inode 节点的大小一般是128字节或256字节。
    • inode 节点的总数在格式化时给定,通常是每1KB或每2KB设置一个 inode。
    • 如果 inode 节点用尽,但硬盘未满,就无法在硬盘上创建新文件。
  4. inode 号码
    • 每个 inode 都有一个号码,操作系统用 inode 号码来识别不同的文件。
    • Unix/Linux 系统内部不使用文件名,而使用 inode 号码来识别文件。
    • 文件名只是 inode 号码便于识别的别称。
    • 使用 ls -i 命令可以查看文件名对应的 inode 号码。
  5. 目录文件
    • 目录(directory)也是一种文件,实际上就是目录文件。
    • 目录文件的结构是一系列目录项(dirent)的列表,每个目录项包含文件名和对应的 inode 号码。
    • 使用 ls -l 命令可以列出文件的详细信息。
  6. 硬链接
    • Unix/Linux 系统允许多个文件名指向同一个 inode 号码,这称为“硬链接”(hard link)。
    • 硬链接可以创建相同 inode 号码的文件,删除一个文件名不影响其他文件名的访问。
  7. 软链接
    • 文件 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 函数。
Built with Hugo
Theme Stack designed by Jimmy
© Licensed Under CC BY-NC-SA 4.0