[Linux](10)系统级I/O,文件描述符,重定向,缓冲区,文件操作模拟实现

前言

所有语言的运行时系统都提供执行 I/O 的较高级别的工具。例如,ANSI C 提供标准 I/O 库,包含像 printfscanf 这样执行带缓冲区的 I/O 函数。C++ 语言用它的重载操作符 <<(输入)和 >>(输出)提供了类似的功能。在 Linux 系统中,是通过使用由内核提供的系统级 Unix I/O 函数来实现这些较高级别的 I/O 函数的。大多数时候,高级别 I/O 函数工作来良好,没有必要直接使用 Unix I/O。那么为什么还要麻烦地学习 Unix I/O 呢?

  • 了解 Unix I/O 将帮助你理解其他的系统概念。I/O 是系统操作不可或缺的一部分,因此,我们经常遇到 I/O 和其他系统概念之间的循环依赖。例如,I/O 在进程的创建和执行中扮演着关键的角色。反过来,进程创建又在不同进程间的文件共享中扮演着关键角色。因此,要真正理解 I/O,你必须理解进程,反之亦然。
  • 有时你除了使用 Unix I/O 以外别无选择。在某些重要的情况中,使用高级 I/O 函数不太可能,或者不太合适。例如,标准 I/O 库没有提供读取文件元数据的方式,例如文件大小或文件创建时间。另外,I/O 库还存在一些问题,使得用它来进行网络编程非常冒险。

本文将介绍 Unix I/O 和标准 I/O 的一般概念,并且向你展示在 C 程序中如何可靠地使用它们。这部分的学习将为我们随后学习网络编程和并发性奠定坚实的基础。

文件是什么

“Linux 下一切皆文件”

一个 Linux 文件就是一个 m 个字节的序列。

所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行

Linux 文件操作

打开文件open

NAME
       open, creat - open and possibly create a file or device

SYNOPSIS
       #include <sys/types.h>
       #include <sys/stat.h>
       #include <fcntl.h>

       int open(const char *pathname, int flags);
       int open(const char *pathname, int flags, mode_t mode);

       int creat(const char *pathname, mode_t mode);

参数 flags 必须包括以下访问模式之一:

  • O_RDONLY:只读
  • O_WRONLY:只写
  • O_RDWR:读写

flags 参数也可以是一个或者更多位掩码的或,为写提供给一些额外的指示:

  • O_CREAT:如果文件不存在,就创建它的一个截断的(truncate)(空)文件。
  • O_TRUNC:如果文件已经存在,就截断它。
  • O_APPEND:在每次写操作前,设置文件位置到文件的结尾处。

mode 参数指定了新文件的访问权限位。这些位的符号名字如下表:

掩码描述
S_IRUSR
S_IWUSR
S_IXUSR
使用者(拥有者)能够读这个文件
使用者(拥有者)能够写这个文件
使用者(拥有者)能够执行这个文件
S_IRGRP
S_IWGRP
S_IXGRP
拥有者所在组的成员能够读这个文件
拥有者所在组的成员能够写这个文件
拥有者所在组的成员能够执行这个文件
S_IROTH
S_IWOTH
S_IXOTH
其他人(任何人)能够读这个文件
其他人(任何人)能够写这个文件
其他人(任何人)能够执行这个文件

作为上下文的一部分,每个进程都有一个 umask,它是通过调用 umask 函数来设置的。当进程通过带某个 mode 参数的 open 函数调用来创建一个新文件时,文件的访问权限被设置为 mode & ~ umask

关于文件权限,也可以使用八进制表示,文件权限复习👉[Linux](3)Linux权限你了解吗?_世真的博客-CSDN博客

例子

以只写方式打开log.txt,如果没有这个文件,就创建一个,以此创建的文件权限为666:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
    umask(0); // 如果省略此句,则默认使用系统掩码
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
    if (fd < 0)
    {
        perror("open");
        return 1;
    }

    printf("fd: %d\n", fd);
    close(fd);
    return 0;
}
[CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile
fd: 3
[CegghnnoR@VM-4-13-centos 2022_10_19]$ ll
total 28
-rw-rw-rw- 1 CegghnnoR CegghnnoR   55 Oct 19 13:23 log.txt

最后,进程会通过调用 close 函数关闭一个打开的文件

#include <unistd.h>

int close(int fd);
// 返回:若成功则为 0,若出错则为 -1

关闭一个已关闭的描述符会出错。

读写文件resdwrite

NAME
       read - read from a file descriptor

SYNOPSIS
       #include <unistd.h>

       ssize_t read(int fd, void *buf, size_t count);
// 返回:若成功则为读的字节数,若 EOF 则为 0,若出错则为 -1
NAME
       write - write to a file descriptor

SYNOPSIS
       #include <unistd.h>

       ssize_t write(int fd, const void *buf, size_t count);
// 返回:若成功则为写的字节数,若出错则为 -1
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main()
{
    umask(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
    if (fd < 0)
    {
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    
    int cnt = 0;
    const char* str = "hello file\n";
    while (cnt < 5)
    {
       write(fd, str, strlen(str));
       ++cnt;
    }
    
    close(fd);
    return 0;
}

C语言以只写方式打开文件会自动清空文件原有内容,而系统调用不会,默认会将新的内容直接覆盖式地写在原数据上,如果使用系统调用也想在打开文件时清空原有数据,那么可以加一个标志 O_TRUNC。另外,添加标志O_APPEND 表示追加,即将文件位置定义到最后,新的内容写在文件末尾。

ssize_tsize_t 的区别

你可能已经注意到了,read 函数有一个 size_t 的输入参数和一个 ssize_t 的返回值。那么这两种类型之间有什么区别呢?在 x86-64 系统中,size_t 被定义为 unsigned long,而 ssize_t (有符号的大小)被定义为 longread 函数返回一个有符号的大小,而不是一个无符号大小,这是因为出错时它必须返回 -1,有趣的是,返回一个 -1 的可能性使得 read 的最大值减小了一半。

文件描述符

上述代码我们都打印了 fd,也就是 open 的返回值,结果都是 3。那么 fd 究竟是什么呢?

open 返回值

RETURN VALUE
       open() and creat() return the new file descriptor, or -1 if an error occurred (in which case, errno is set appropriately).

从 man 手册中的描述可以发现,如果 fd < 0,说明打开失败,fd >= 0 则打开成功。

open 函数返回的一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。

  1. 为什么从 3 开始,0, 1, 2 在哪?

    Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)、标准错误(描述符为2)

    如下,我们可以查看它们的用来记录文件描述符的结构体成员 _fileno

    int main()
    {
        printf("stdin: %d\n", stdin->_fileno);
        printf("stdout: %d\n", stdout->_fileno);
        printf("stderr: %d\n", stderr->_fileno);
        return 0;
    }
    
    [CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile
    stdin: 0
    stdout: 1
    stderr: 2
    

    例子

    read 从文件描述符读取:

    NAME
           read - read from a file descriptor
    
    SYNOPSIS
           #include <unistd.h>
    
           ssize_t read(int fd, void *buf, size_t count);
    

    如下,从文件描述符0,也就是标准输入读取到字符数组buffer,然后打印:

    int main()
    {
        char buffer[1024];
        ssize_t s = read(0, buffer, sizeof(buffer) - 1);
        if (s > 0)
        {
            buffer[s] = '\0';
            printf("echo: %s", buffer);
        }
        return 0;
    }
    

    结果:

    [CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile
    hello   
    echo: hello
    

I/O 重定向

如下图,每一个进程都有一个指针files,指向一个顺序表,顺序表中存放的就是文件指针,其下标就是对应的描述符,这个表叫作描述符表

描述符

下列代码输出的是什么

int main()
{
    int fd1, fd2;

    fd1 = open("foo.txt", O_RDONLY, 0);
    close(fd1);
    fd2 = open("baz.txt", O_RDONLY, 0);
    printf("fd2 = %d\n", fd2);
    return(0);
}

答案

第一次调用 open 会返回描述符 3,比在下标为 3 的位置创建文件指针,调用 close 函数会释放描述符 3,最后对 open 的调用还是返回描述符 3,因此程序输出的是 “fd2 = 3”。

在上述知识的基础上,这里引出一个新的函数,dup

NAME
       dup, dup2, dup3 - duplicate a file descriptor

SYNOPSIS
       #include <unistd.h>

       int dup(int oldfd);
       int dup2(int oldfd, int newfd);

我们重点学习 dup2,它有两个参数,代表的含义为:将描述符 oldfdfile* 拷贝给 newfdfile*

例子

int main()
{
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0)
    {
        perror("open");
        return 0;
    }

    dup2(fd, 1); //将刚刚打开的文件log.txt里的file*复制到1,也就是标准输出的位置。

    fprintf(stdout, "打开文件成功,fd: %d\n", fd); // 向标准输出写文件

    fflush(stdout);
    close(fd);
    return 0;
}

打开文件 log.txt,其描述符为 3,将它的文件指针复制给描述符 1(标准输出)对应的文件指针,此时描述符 1 和 3 对应的文件都是 log.txt,标准输出文件由于引用计数为 0 会被自动释放。

运行结果

[CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile
[CegghnnoR@VM-4-13-centos 2022_10_19]$ cat log.txt
打开文件成功,fd: 3

可以看到,我们写的内容本来应该通过标准输出打印出来,现在却写到了 log.txt 文件里面。像这样的行为,我们就称 将标准输出重定向到磁盘文件 log.txt

此外还有追加重定向,输入重定向,操作类似,这里不再赘述。


关于标准输出和标准错误的重定向:

#include <stdio.h>

int main()
{
    printf("hello printf\n");
    perror("hello perror\n");
    return 0;
}

如上程序,直接运行会打印这两行信息,这说明无论是标准输出还是标准错误默认都是往屏幕上输出信息

[CegghnnoR@VM-4-13-centos 2022_9_30]$ ./a.out
hello printf
hello perror
: Success

而如果使用 > 重定向到一个文件,则只会打印标准错误的信息,标准输出信息被写入磁盘文件

[CegghnnoR@VM-4-13-centos 2022_9_30]$ ./a.out > log.txt
hello perror
: Success
[CegghnnoR@VM-4-13-centos 2022_9_30]$ cat log.txt
hello printf

所以这里的重定向仅仅是把标准输出重定向了,./a.out > log.txt 的完整写法应该是 ./a.out 1 > log.txt 即将文件描述符1重定向到 log.txt,如果要把标准错误也重定向到磁盘文件,可以像下面这样写

[CegghnnoR@VM-4-13-centos 2022_9_30]$ ./a.out > stdout.txt 2>stderr.txt
[CegghnnoR@VM-4-13-centos 2022_9_30]$ cat stdout.txt
hello printf
[CegghnnoR@VM-4-13-centos 2022_9_30]$ cat stderr.txt
hello perror
: Success

我们建议把标准输出和标准错误信息放到两个文件。

如果你想把它们混合到一个文件,可以像下面这样想写

[CegghnnoR@VM-4-13-centos 2022_9_30]$ ./a.out > all.txt 2>&1
[CegghnnoR@VM-4-13-centos 2022_9_30]$ cat all.txt
hello perror
: Success
hello printf

缓冲区

在写进度条的时候我们认识到了缓冲区的存在👉[Linux](5)gcc编译、gdb调试、make/makefile项目自动化构建工具、进度条小程序_世真的博客-CSDN博客

使用 printf 函数打印字符,如果没有 \n 的话其内容并不会马上被打印出来。

而系统接口 write 并非如此,

int main()
{
    printf("hello printf");
    
    const char* msg = "hello write";
    write(1, msg, strlen(msg));
    
    sleep(5);
    return 0;
}

如上代码,hello write 会被立刻打印出来,而 hello printf 在5秒后才被打印出来。

那么缓冲区在哪呢?

首先排除 write,其次,printf 只是个函数,无法留存缓冲区。另外可以注意到我们 C 语言的 fprintffputs 如果要向标准输出打印数据,就需要传参数 stdoutprintf 其实也隐含了这个参数,而 write 没有,那么缓冲区只可能在 *stdout 里了,stdoutFILE* 类型,FILE 是个结构体,其内部可以声明一个数组作为缓冲区。

如果在 *stdout 缓冲区有数据的情况下,将文件描述符 1 关掉,那么 stdout 会被直接释放,缓冲区内的数据再也不会刷新出来了。

int main()
{
    printf("hello printf");
    sleep(3);
    close(1);
    return 0;
}

以上程序 3 秒后什么也没有打印出来


刷新策略问题:

  • 常规:
    1. 无缓冲(立即刷新)
    2. 行缓冲(逐行刷新)
    3. 全缓冲(缓冲区写满刷新)
  • 特殊:
    1. 进程退出
    2. 用户强制刷新

一般会根据文件类型确定刷新策略,比如对显示器文件采用行刷新策略,对块设备对应的文件,磁盘文件采用全缓冲刷新。

奇怪的现象

int main()
{
    const char* str1 = "hello printf\n";
    const char* str2 = "hello fprintf\n";
    const char* str3 = "hello fputs\n";
    const char* str4 = "hello write\n";
    
    // C库函数
    printf(str1);
    fprintf(stdout, str2);
    fputs(str3, stdout);
    
    // 系统接口
    write(1, str4, strlen(str4));

    fork();
    return 0;
}
[CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile
hello printf
hello fprintf
hello fputs
hello write
[CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile > log.txt
[CegghnnoR@VM-4-13-centos 2022_10_19]$ cat log.txt
hello write
hello printf
hello fprintf
hello fputs
hello printf
hello fprintf
hello fputs

为什么直接打印出来是 4 条信息,而重定向却是 7 条信息?

答案

直接打印是 4 条信息很好理解,因为是代码一共 4 条信息,并且是行刷新策略。

重定向到磁盘文件却打印了 7 条信息,注意到除了 write,其他信息都打印了两次,明显和缓冲区有关,这里重定向到了磁盘文件,那么刷新策略会变成全缓冲刷新,所以在父进程中后三条信息都会被保存在缓存区里,当 fork 时,子进程继承父进程的数据,最后父子进程退出,两个进程的缓冲区都会刷新,发生写时拷贝,所以后三条信息会输出两次,最后一共是 7 条信息。

文件操作简易模拟实现

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <assert.h>
#include <stdlib.h>

#define NUM 1024
#define NONE_FLUSH 0x0
#define LINE_FLUSH 0x1
#define FULL_FLUSH 0x2

typedef struct MyFILE
{
    int _fileno;		// 描述符
    char _buffer[NUM]; 	// 缓冲区
    int _end;			// 缓冲区末尾
    int _flags;			// 打开文件的模式
}MyFILE;

MyFILE* my_fopen(const char* filename, const char* method)
{
    assert(filename);
    assert(method);

    int flags = O_RDONLY;

    // 此处详细实现“w”和“a”模式,其余类似
    if (strcmp(method, "r") == 0)
    {}
    else if (strcmp(method, "r+") == 0)
    {}
    else if (strcmp(method, "w") == 0)
    {
        flags = O_WRONLY | O_CREAT | O_TRUNC;
    }
    else if (strcmp(method, "w+") == 0)
    {}
    else if (strcmp(method, "a") == 0)
    {
        flags = O_WRONLY | O_CREAT | O_APPEND;
    }
    else if (strcmp(method, "a+") == 0)
    {}


    int fileno = open(filename, flags, 0666);
    if (fileno < 0) return NULL;
    
    MyFILE* fp = (MyFILE*)malloc(sizeof(MyFILE));
    if (fp == NULL) return fp;
    memset(fp, 0, sizeof(MyFILE));
    fp->_fileno = fileno;
    fp->_flags |= LINE_FLUSH;
    fp->_end = 0;

    return fp;
}

void my_fflush(MyFILE* fp)
{
    assert(fp);

    if (fp->_end > 0)
    {
        write(fp->_fileno, fp->_buffer, fp->_end);
        fp->_end = 0;
        syncfs(fp->_fileno);
    }
}

void my_fwrite(MyFILE* fp, const char* start, int len)
{
    assert(fp);
    assert(start);
    assert(len > 0);

    strncpy(fp->_buffer + fp->_end, start, len); // 将数据写到缓存区
    fp->_end += len;

    // 此处重点实现行刷新
    if (fp->_flags & NONE_FLUSH)
    {

    }
    else if (fp->_flags & LINE_FLUSH)
    {
        if (fp->_end > 0 && fp->_buffer[fp->_end - 1] == '\n')
        {
            write(fp->_fileno, fp->_buffer, fp->_end);
            fp->_end = 0;
            syncfs(fp->_fileno);
        }
    }
    else if (fp->_flags & FULL_FLUSH)
    {

    }
}

void my_fclose(MyFILE* fp)
{
    my_fflush(fp);
    close(fp->_fileno);
    free(fp);
}

int main()
{
    MyFILE* fp = my_fopen("log.txt", "w");
    if (fp == NULL)
    {
        printf("my_fopen error\n");
        return 1;
    }

    const char* s = "hello my file\n";
    my_fwrite(fp, s, strlen(s));
    
    printf("消息立即刷新");
    sleep(3);

    const char* ss = "hello my file";
    my_fwrite(fp, ss, strlen(ss));
    printf("写入了一个不满足刷新条件的字符串\n");
    sleep(3);
    
    const char* sss = "hello my file";
    my_fwrite(fp, sss, strlen(sss));
    printf("写入了一个不满足刷新条件的字符串\n");
    sleep(3);

    const char* ssss = "end\n";
    my_fwrite(fp, ssss, strlen(ssss));
    printf("写入了一个满足刷新条件的字符串\n");
    sleep(3);

    const char* sssss = "aaaaaaaa";
    my_fwrite(fp, sssss, strlen(sssss));
    printf("写入了一个不满足刷新条件的字符串\n");
    sleep(1);
    my_fflush(fp);
    sleep(3);

    my_fclose(fp);
}

shell 实现的补充(增加重定向功能)

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <assert.h>
#include <ctype.h>

#define SEP " "
#define NUM 1024
#define SIZE 128

#define DROP_SPACE(s) do { while (isspace(*s)) s++; }while(0)

char command_line[NUM];
char* command_args[SIZE];

char env_buffer[NUM];

#define NONE_REDIR -1
#define INPUT_REDIR 0
#define OUTPUT_REDIR 1
#define APPEND_REDIR 2

int g_redir_flag = NONE_REDIR;
char* g_redir_filename = NULL;

int ChangeDir(const char* new_path)
{
    chdir(new_path);
    return 0;
}

void PutEnvInMyShell(char* new_env)
{
    putenv(new_env);
}

void CheckDir(char* commands)
{
    assert(commands);
    //[start, end)
    char* start = commands;
    char* end = commands + strlen(commands);
    while (start < end)
    {
        if (*start == '>')
        {
            if (*(start + 1) == '>')
            {
                // 追加重定向
                *start = '\0';
                start += 2;
                g_redir_flag = APPEND_REDIR;
                DROP_SPACE(start);
                g_redir_filename = start;
                break;
            }
            else
            {
                // 输出重定向
                *start = '\0';
                start++;
                DROP_SPACE(start);
                g_redir_flag = OUTPUT_REDIR;
                g_redir_filename = start;
                break;
            }
        }
        else if (*start == '<')
        {
            // 输入重定向
            *start = '\0';
            start++;
            DROP_SPACE(start);
            g_redir_flag = INPUT_REDIR;
            g_redir_filename = start;
            break;
        }
        else
        {
            start++;
        }
    }
}

int main()
{
    g_redir_flag = NONE_REDIR;
    g_redir_filename = NULL;
    while (1)
    {
        // 1.显示提示符
        printf("[张三@我的主机名 当前目录]# ");
        fflush(stdout);
        // 2.获取用户输入
        memset(command_line, '\0', sizeof(command_line)*sizeof(char));
        fgets(command_line, NUM, stdin);
        command_line[strlen(command_line) - 1] = '\0'; // 去除\n
        
        CheckDir(command_line);
        
        
        // 3.字符切分 "ls -a -l -i" -> "ls" "-a" "-l" "-i"
        command_args[0] = strtok(command_line, SEP);
        int index = 1;
        if (strcmp(command_args[0], "ls") == 0)
            command_args[index++] = (char*)"--color=auto";
        while (command_args[index++] = strtok(NULL, SEP));
        if (strcmp(command_args[0], "cd") == 0 && command_args[1] != NULL)
        {
            ChangeDir(command_args[1]);
            continue;
        }
        if (strcmp(command_args[0], "export") == 0 && command_args[1] != NULL)
        {
            strcpy(env_buffer, command_args[1]);
            PutEnvInMyShell(env_buffer);
            continue;
        }
        // 创建进程,执行
        pid_t id = fork();
        if (id == 0)
        {
            int fd = -1;
            switch(g_redir_flag)
            {
            case NONE_REDIR:
                break;
            case INPUT_REDIR:
                fd = open(g_redir_filename, O_RDONLY);
                dup2(fd, 0);
                break;
            case OUTPUT_REDIR:
                fd = open(g_redir_filename, O_WRONLY | O_CREAT | O_TRUNC);
                dup2(fd, 1);
                break;
            case APPEND_REDIR:
                fd = open(g_redir_filename, O_WRONLY | O_CREAT | O_APPEND);
                dup2(fd, 1);
                break;
            default:
                printf("error\n");
                break;
            }
            // child
            // 程序替换
            execvp(command_args[0], command_args);
            exit(1);
        }
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);
        if (ret > 0)
        {
            printf("等待子进程成功:sig: %d, code: %d\n", status & 0x7F, (status >> 8) & 0xFF);
        }
    }
    return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

世真

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值