封面

🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》C++
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

在这里插入图片描述


🏠博主简介
在这里插入图片描述

前言

本文主要整理 Linux 基础 I/O 的前半部分内容,从文件的基本概念讲起,依次回顾 C 语言文件接口、标准输入输出错误、文件打开方式,再进入系统调用层面的 open、write、read、close。最后结合实验解释为什么 fd 通常从 3 开始,以及文件描述符本质上为什么是进程文件描述符表的数组下标。


一、先理解什么是“文件”

在磁盘上新建一个空文件,文件大小显示为 0KB,那么这个文件到底占不占空间?

这里不能简单理解成“有内容才叫文件”。一个文件可以先粗略理解成:

文件 = 文件内容 + 文件属性

所谓文件属性,也可以叫元数据,比如文件名、权限、拥有者、修改时间、inode 信息等。

所以空文件虽然没有真正的文件内容,但是它仍然需要被文件系统记录和管理。从严格角度说,空文件一般不会分配存放内容的数据块,但是它需要 inode、目录项等元数据来描述。也就是说,它不是“什么都没有”。

这也是学习文件系统时第一个要转过来的点:

文件不只是内容,文件还有属性。

1.1 狭义理解文件

从最普通的角度看,文件一般存放在磁盘中。

磁盘属于永久性存储介质,断电后数据不会像内存一样丢失。同时,磁盘也是一种外设,程序想读磁盘内容,或者把数据写入磁盘,本质上都是在和外设进行输入输出操作,也就是 I/O。

所以我们平时说的文件读写,底层可以理解成:

进程和磁盘设备之间进行数据交换。

1.2 从系统角度理解文件操作

我们平时写 C 语言时,经常使用 fopenfclosefreadfwrite 这些函数。

但是要注意:

真正能访问硬件的不是 C 语言库函数,而是操作系统。

用户程序不能直接去操作磁盘、显示器、键盘这些硬件。硬件是由操作系统管理的,用户程序想访问硬件,只能通过操作系统提供的系统调用接口。

所以 C 语言文件接口的底层,一定会封装对应的系统调用。

还有一个问题:代码里写了 fopen,文件就已经打开了吗?

答案是没有。

代码躺在磁盘里时,它只是一个可执行文件,还没有真正运行。只有程序被加载到内存中运行起来,变成进程,并且执行到 fopenopen 那一行时,文件才真正被打开。

因此,对文件的操作,本质上是:

进程对文件进行操作。

从是否被打开的角度看,文件可以简单分成两类:

类型 含义
磁盘级文件 文件存在磁盘中,但当前没有被进程打开
内存级文件 文件已经被进程打开,操作系统需要在内核中维护它

二、回顾 C 语言文件接口

在进入系统调用之前,先回顾一下 C 语言文件接口。因为我们最开始接触文件操作时,通常都是从 fopen 这一套开始的。

2.1 以写的方式打开文件

fopen 可以使用不同方式打开文件,比如读、写、追加等。

如果打开失败,返回 NULL;如果打开成功,返回一个 FILE* 类型的文件指针。

fopen 接口说明:不同打开方式对应不同文件操作权限。

fopen 接口说明:不同打开方式对应不同文件操作权限。

fopen 打开成功后返回 FILE*,后续读写都围绕这个文件流进行。

fopen 打开成功后返回 FILE*,后续读写都围绕这个文件流进行。

下面是一段简单的写文件代码:

#include <stdio.h>
#include <string.h>

int main()
{
    // 不带路径时,文件会创建在当前进程的工作目录下
    FILE *fp = fopen("log.txt", "w");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    int cnt = 1;
    const char *msg = "hello world";

    while (cnt <= 10)
    {
        char buffer[1024];
        snprintf(buffer, sizeof(buffer), "%s%d\n", msg, cnt++);
        fwrite(buffer, strlen(buffer), 1, fp);
    }

    fclose(fp);
    return 0;
}

实验结果:以 w 方式写入后,log.txt 中生成了对应内容。

实验结果:以 w 方式写入后,log.txt 中生成了对应内容。

这里有一个细节:如果 fopen 只传文件名,不传路径,文件会创建在哪里?

每个进程都有自己的当前工作目录。可以使用下面的命令查看正在运行进程的信息:

ls /proc/[进程id] -l

其中比较常见的两个字段是:

字段 含义
cwd 当前进程运行目录的符号链接
exe 当前进程可执行程序完整路径的符号链接

通过 /proc 查看进程信息,可以看到 cwd 和 exe 这两个关键链接。

通过 /proc 查看进程信息,可以看到 cwd 和 exe 这两个关键链接。

所以,不带路径打开文件时,操作系统会根据当前进程的工作目录 cwd 来决定文件放在哪里。

2.2 以读的方式打开文件

下面写一个简单的读文件程序:

#include <stdio.h>
#include <string.h>

int main()
{
    FILE *fp = fopen("log.txt", "r");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    char buf[1024];
    while (1)
    {
        size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
        if (n > 0)
        {
            buf[n] = '\0';
            printf("%s", buf);
        }

        if (feof(fp))
        {
            break;
        }
    }

    fclose(fp);
    return 0;
}

这里要注意一点:如果我们想把读出来的内容当字符串打印,就要给数组预留一个位置放 \0

文件本身只是一堆字节,不会自动帮你加字符串结束符。

2.3 简单实现一个 cat 命令

稍微改一下,就能实现一个简单版 cat

#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        printf("Usage: %s <filename>\n", argv[0]);
        return 1;
    }

    FILE *fp = fopen(argv[1], "r");
    if (fp == NULL)
    {
        perror("fopen");
        return 2;
    }

    char buf[1024];
    memset(buf, 0, sizeof(buf));

    while (1)
    {
        size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
        if (n > 0)
        {
            buf[n] = '\0';
            printf("%s", buf);
        }

        if (feof(fp))
        {
            break;
        }
    }

    fclose(fp);
    return 0;
}

运行时直接传文件名:

./mycat log.txt

实验结果:传入文件名后,程序可以像简化版 cat 一样读取文件内容。

实验结果:传入文件名后,程序可以像简化版 cat 一样读取文件内容。

这样程序就不用每次修改源码了。想读哪个文件,运行时传不同参数即可。


三、stdin、stdout、stderr 到底是什么?

我们写程序时,经常直接使用:

printf("hello world\n");
scanf("%d", &x);
fprintf(stderr, "error\n");

这里就有一个问题:我们并没有手动打开键盘,也没有手动打开显示器,为什么可以直接输入输出?

因为一个程序运行起来时,通常已经拥有三个默认的标准文件描述符:

名称 含义 默认对应设备
stdin 标准输入 键盘
stdout 标准输出 显示器
stderr 标准错误 显示器

在 C 语言层面,它们是 FILE* 类型:

#include <stdio.h>

extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;

这就解释了为什么 fprintf(stdout, ...) 可以直接向屏幕打印。因为 stdout 本身就代表标准输出流。

3.1 输出到显示器的几种方法

输出到显示器不只有 printf 一种写法,下面这几种本质上都是把内容写到标准输出:

#include <stdio.h>
#include <string.h>

int main()
{
    const char *msg = "hello fwrite\n";

    fwrite(msg, strlen(msg), 1, stdout);
    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");

    return 0;
}

如果暂时不看 C 库函数,直接站在系统调用角度,也可以用 readwrite 操作默认的 0、1、2

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

int main()
{
    char buf[1024];
    ssize_t s = read(0, buf, sizeof(buf) - 1); // 从标准输入读取
    if (s > 0)
    {
        buf[s] = 0;
        write(1, buf, strlen(buf)); // 写到标准输出
        write(2, buf, strlen(buf)); // 写到标准错误
    }

    return 0;
}

这里就能看出:stdin/stdout/stderr 是 C 语言层面的文件流,而 0/1/2 是系统层面的文件描述符。

直接向标准输出打印,本质上就是向标准输出对应的 fd 写入内容。

直接向标准输出打印,本质上就是向标准输出对应的 fd 写入内容。

需要注意的是,严格来说,底层的 0、1、2 文件描述符通常是进程从父进程(比如 shell)继承来的,C 标准库再把它们包装成 stdinstdoutstderr 这三个 FILE* 流。

所以可以这样理解:

系统层只认 fd,C 语言层把 fd 包装成了 FILE*,让我们用起来更方便。


四、文件打开方式的细节

不同的打开方式,会直接影响文件原来的内容。

4.1 w 方式:清空写入

如果用 w 方式打开一个已经存在的文件,但是不写任何内容:

#include <stdio.h>

int main()
{
    FILE *fp = fopen("log.txt", "w");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    fclose(fp);
    return 0;
}

运行后会发现,log.txt 原来的内容被清空了。

实验结果:w 方式打开已有文件,即使不写入内容,也会清空原文件。

实验结果:w 方式打开已有文件,即使不写入内容,也会清空原文件。

所以 w 的特点是:

文件不存在就创建;文件存在就先清空,再从开头写入。

这也能解释为什么输出重定向 > 会清空原文件。因为它打开目标文件时,使用的就是类似“清空写入”的逻辑。

输出重定向 > 会先清空目标文件,再写入新的输出内容。

输出重定向  会先清空目标文件,再写入新的输出内容。

4.2 a 方式:追加写入

如果使用 a 方式打开文件:

a 方式表示 append,也就是追加写入。

a 方式表示 append,也就是追加写入。

FILE *fp = fopen("log.txt", "a");
if (fp == NULL)
{
    perror("fopen");
    return 1;
}

fprintf(fp, "hello append\n");
fclose(fp);

它不会清空原文件,而是把新内容追加到文件末尾。

实验结果:a 方式会把新内容继续追加到原文件末尾。

实验结果:a 方式会把新内容继续追加到原文件末尾。

这对应 Linux 中的追加重定向:

./a.out >> log.txt

追加重定向 >> 的底层逻辑,也可以理解成追加方式打开文件。

追加重定向  的底层逻辑,也可以理解成追加方式打开文件。

4.3 r+、w+、a+ 的区别

这几个模式都带 +,表示以读写方式打开,但是它们的细节不一样:

打开方式 文件不存在时 文件存在时 特点
r+ 打开失败 保留原内容 从开头读写
w+ 创建新文件 先清空原内容 从开头读写
a+ 创建新文件 保留原内容 写入永远追加到末尾

还有一个容易踩坑的地方:

w+ 这种读写方式打开文件后,刚写进去的内容,不能马上直接读出来。

原因是写入数据后,文件的读写位置已经移动到了内容末尾。此时直接读,读到的就是文件末尾,自然读不出刚才写入的内容。

如果想重新从头读,可以使用:

rewind(fp);

相关接口还有:

接口 作用
fseek 调整文件读写位置
ftell 获取当前文件读写位置
rewind 将文件读写位置移动到开头

读写指针相关接口:fseek、ftell、rewind。

读写指针相关接口:fseek、ftell、rewind。

4.4 写字符串时要不要写入 \0?

答案是:不需要。

\0 是 C 语言用来标识字符串结尾的规则,和文件系统本身没有关系。

文件只关心字节数据。如果把 \0 也强行写入文本文件,用某些工具打开时可能会看到奇怪的显示效果。


五、系统文件 I/O:open、write、read、close

C 语言接口用起来比较方便,但它不是最底层。Linux 系统层提供的是系统调用接口。

5.1 open:系统层面的文件打开

open 的常见形式如下:

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

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

open 系统调用接口:返回值是文件描述符 fd。

open 系统调用接口:返回值是文件描述符 fd。

open 的 flags 用来描述打开方式,mode 用来描述新文件权限。

open 的 flags 用来描述打开方式,mode 用来描述新文件权限。

返回值:

返回值 含义
>= 0 打开成功,返回文件描述符 fd
-1 打开失败,并设置错误码

比如创建并以写入方式打开文件:

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

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

    close(fd);
    return 0;
}

实验结果:open 创建文件后,如果没有正确设置权限,显示效果可能不符合预期。

实验结果:open 创建文件后,如果没有正确设置权限,显示效果可能不符合预期。

这里的 O_CREAT | O_WRONLY 就是通过位图传递多个标志位。

5.2 库函数和系统调用的关系

前面用到的 fopenfclosefreadfwrite 属于 C 标准库函数;这一节用到的 openclosereadwritelseek 属于系统调用接口。

可以先这样理解:

层次 常见接口 特点
C 标准库 fopen/fread/fwrite/fclose 用起来方便,内部封装了更多细节
系统调用 open/read/write/close/lseek 操作系统直接提供的接口,更贴近底层

所以,fopen 这一类函数不是直接操作硬件,而是对系统调用又封装了一层。这样做的好处是让我们写代码更方便,也能屏蔽一部分系统差异。

这也是为什么我们后面一定要把 FILE*fd 放在一起理解:

FILE* 是 C 库层的文件流,fd 是系统层真正用来定位打开文件的整数。

补充图:C 库函数和系统调用的关系。

补充图:C 库函数和系统调用的关系。

5.3 为什么系统调用用位图传标志位?

如果一个函数有很多选项,难道每个选项都传一个参数吗?

这样函数参数会非常多,不好维护。

Linux 中常用位图的方式传递标志位。也就是用一个整数的不同二进制位,表示不同选项是否打开。

举个简单例子:

#include <stdio.h>

#define ONE_FLAG   (1 << 0)
#define TWO_FLAG   (1 << 1)
#define THREE_FLAG (1 << 2)
#define FOUR_FLAG  (1 << 3)

void Print(int flag)
{
    if (flag & ONE_FLAG)
        printf("One!\n");

    if (flag & TWO_FLAG)
        printf("Two!\n");

    if (flag & THREE_FLAG)
        printf("Three!\n");

    if (flag & FOUR_FLAG)
        printf("Four!\n");
}

int main()
{
    Print(ONE_FLAG);
    Print(ONE_FLAG | TWO_FLAG);
    Print(ONE_FLAG | TWO_FLAG | THREE_FLAG);
    return 0;
}

openflags 也是类似思想。

常见标志位如下:

标志位 含义
O_RDONLY 只读打开
O_WRONLY 只写打开
O_RDWR 读写打开
O_CREAT 文件不存在则创建
O_TRUNC 打开时清空原文件内容
O_APPEND 追加写入

5.4 mode 和 umask

如果使用 O_CREAT 创建新文件,一定要传第三个参数 mode,比如:

int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);

这里的 0666 表示普通文件的默认权限:拥有者、所属组、其他用户都可以读写。

但是最终看到的权限可能不是 666,而是 644。这是因为系统还有一个权限掩码 umask

实际权限大致可以理解为:

最终权限 = mode & (~umask)

如果临时把 umask 设置为 0:

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

int main()
{
    umask(0);
    return 0;
}

那么创建文件时就不会被默认掩码屏蔽权限。

设置 mode 为 0666 后,最终权限还会受到 umask 影响。

设置 mode 为 0666 后,最终权限还会受到 umask 影响。

5.5 close:关闭文件

系统调用层关闭文件使用 close

#include <unistd.h>

close(fd);

它的参数就是 open 返回的文件描述符。

close 使用 fd 关闭对应的打开文件。

close 使用 fd 关闭对应的打开文件。

5.6 write:系统层面的写入

write 的常见形式如下:

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

参数含义:

参数 含义
fd 要写入的文件描述符
buf 要写入的数据起始地址
count 想写入的字节数

返回值:

返回值 含义
> 0 实际写入的字节数
-1 写入失败

write 接口说明:第一个参数是 fd,返回值是实际写入的字节数。

write 接口说明:第一个参数是 fd,返回值是实际写入的字节数。

示例:

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

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

    const char *msg = "hello world\n";
    write(fd, msg, strlen(msg));

    close(fd);
    return 0;
}

这里写入字符串时,仍然不需要把 \0 写入文件,所以使用 strlen(msg),不要写成 strlen(msg) + 1

5.7 O_TRUNC 与 O_APPEND

如果打开一个已经有内容的文件,只使用:

open("log.txt", O_CREAT | O_WRONLY, 0666);

它不会自动清空原文件。

实验现象:只用 O_WRONLY 打开时,不会自动清空文件内容。

实验现象:只用 O_WRONLY 打开时,不会自动清空文件内容。

如果想打开时清空文件,必须加 O_TRUNC

int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);

如果想追加写入,使用 O_APPEND

int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);

所以 C 语言的打开方式和系统调用大致可以对应为:

C 语言方式 系统调用标志位
"w" `O_WRONLY
"a" `O_WRONLY
"r" O_RDONLY

这也说明:

C 语言的文件接口,本质上是对系统调用的封装。

5.8 文本写入和二进制写入

看下面这段代码:

int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
int a = 1234567;
write(fd, &a, sizeof(a));
close(fd);

实验结果:直接写入 int 的二进制数据,文本方式打开会看到不可读内容。

实验结果:直接写入 int 的二进制数据,文本方式打开会看到不可读内容。

如果用普通文本方式打开 log.txt,可能会看到乱码。

原因是这里写入的不是字符串 "1234567",而是变量 a 在内存中的二进制数据。int 通常占 4 个字节,所以文件中写入的是这 4 个字节的二进制形式。

如果想让文件中看到 1234567,需要先把整数格式化成字符串:

char buf[32];
snprintf(buf, sizeof(buf), "%d", a);
write(fd, buf, strlen(buf));

从系统调用角度看,write 并不关心你写的是文本还是二进制。它只认一段地址和一段字节数。

所谓文本写入、二进制写入,更多是语言层和用户理解上的区分。

语言层可以使用 fputs 进行文本写入。

语言层可以使用 fputs 进行文本写入。

fwrite 更偏向按字节块写入,也常用于二进制数据写入。

fwrite 更偏向按字节块写入,也常用于二进制数据写入。

5.9 read:系统层面的读取

read 的常见形式如下:

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

参数含义:

参数 含义
fd 从哪个文件描述符读取
buf 读取到的数据放在哪里
count 最多读取多少字节

返回值:

返回值 含义
> 0 实际读到的字节数
0 读到文件末尾
-1 读取失败

read 接口说明:从指定 fd 读取数据到用户提供的空间。

read 接口说明:从指定 fd 读取数据到用户提供的空间。

read 的返回值需要重点关注:大于 0、等于 0、小于 0 含义不同。

read 的返回值需要重点关注:大于 0、等于 0、小于 0 含义不同。

示例代码:

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

int main()
{
    int fd = open("log.txt", O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        return 1;
    }

    while (1)
    {
        char buffer[64];
        ssize_t n = read(fd, buffer, sizeof(buffer) - 1);

        if (n > 0)
        {
            buffer[n] = '\0';
            printf("%s", buffer);
        }
        else if (n == 0)
        {
            break;
        }
        else
        {
            perror("read");
            break;
        }
    }

    close(fd);
    return 0;
}

这里同样要注意,如果把读出来的内容当字符串打印,就要手动加 \0


六、fd 到底是什么?为什么从 3 开始?

现在问题来了:

open 返回的只是一个整数,为什么拿着这个整数就能读写文件?

我们先做个实验:

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

int main()
{
    int fd1 = open("log1.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
    int fd2 = open("log2.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
    int fd3 = open("log3.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
    int fd4 = open("log4.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);

    printf("fd1: %d\n", fd1);
    printf("fd2: %d\n", fd2);
    printf("fd3: %d\n", fd3);
    printf("fd4: %d\n", fd4);

    close(fd1);
    close(fd2);
    close(fd3);
    close(fd4);

    return 0;
}

正常情况下,你会看到类似结果:

fd1: 3
fd2: 4
fd3: 5
fd4: 6

实验结果:连续打开普通文件,fd 一般从 3、4、5、6 开始分配。

实验结果:连续打开普通文件,fd 一般从 3、4、5、6 开始分配。

0、1、2 去哪里了?

它们已经被标准输入、标准输出、标准错误占用了:

fd 含义 默认设备
0 标准输入 键盘
1 标准输出 显示器
2 标准错误 显示器

可以用 fileno 验证:

#include <stdio.h>

int main()
{
    printf("stdin fd: %d\n", fileno(stdin));
    printf("stdout fd: %d\n", fileno(stdout));
    printf("stderr fd: %d\n", fileno(stderr));
    return 0;
}

输出一般是:

stdin fd: 0
stdout fd: 1
stderr fd: 2

实验结果:stdin、stdout、stderr 底层分别对应 fd 0、1、2。

实验结果:stdin、stdout、stderr 底层分别对应 fd 0、1、2。

所以新打开的普通文件,一般就从 3 开始分配。

这就能得出一个很重要的结论:

C 语言的 FILE* 结构体中,底层一定封装了对应的文件描述符 fd。

语言层用 FILE*,系统层用 fd。不同语言的文件接口可能不一样,但到了操作系统层面,最终都要落到文件描述符上。


七、操作系统如何管理被打开的文件?

为什么 fd 是一个整数?

因为它本质上可以理解成数组下标。

7.1 先描述,再组织

操作系统管理任何东西,一般都有一个思想:

先描述,再组织。

进程要被管理,操作系统会用 task_struct 描述进程。

文件被打开后,也需要被管理,操作系统会在内核中创建对应的文件对象,比如 struct file

一个进程可以打开多个文件,那么进程怎么知道自己打开了哪些文件?

在内核中,每个进程都有自己的文件描述符表。简单理解,它里面有一个数组:

struct file *fd_array[];

这个数组中的每个元素,存放的是一个已经打开文件对象的地址。

当进程调用 open 打开新文件时,内核大致做这些事:

  1. 在内核中创建一个 struct file 对象,描述这个被打开的文件。
  2. 在当前进程的文件描述符表中,找到一个最小的空闲位置。
  3. struct file 的地址填入这个位置。
  4. 把这个位置的数组下标返回给用户。

进程、文件描述符表和 struct file 之间的关系,可以先按这个结构理解。

进程、文件描述符表和 struct file 之间的关系,可以先按这个结构理解。

这个数组下标,就是 fd。

所以:

文件描述符 fd 的本质,就是当前进程文件描述符表的数组下标。

如果想从内核源码里验证这条线,也可以顺着下面几个结构去看:

结构体 大致作用 常见位置
task_struct 描述进程,里面会关联文件描述符表 include/linux/sched.h
files_struct 描述当前进程打开文件的表 include/linux/fdtable.h
struct file 描述一个已经打开的文件对象 include/linux/fs.h

不同系统的内核版本路径可能不一样,可以先用 uname -a 看自己的内核版本,再到对应源码目录中查找。这里不需要把源码全部背下来,只要抓住这条关系即可:

task_struct -> files_struct -> fd_array[] -> struct file

补充图:fd 本质是当前进程文件描述符表的数组下标。

补充图:fd 本质是当前进程文件描述符表的数组下标。

也就是说,进程不是直接“拿着文件名”去读写,而是先通过自己的 fd 表找到对应的打开文件对象。

7.2 read/write 的基本过程

当用户调用:

read(fd, buffer, size);

操作系统会根据当前进程找到它的文件描述符表,再用 fd 作为下标找到对应的 struct file,然后把读到的数据放到用户传入的 buffer 中。

当用户调用:

write(fd, buffer, size);

操作系统同样会先根据 fd 找到对应的文件对象,再把用户传入的数据写入目标文件。

所以 read/write 的主线可以先这样理解:

进程传入 fd,内核根据 fd 找到文件对象,再完成对应读写操作。


八、全文总结

本篇主要整理了 Linux 基础 I/O 的前半部分内容。我们先从文件的概念讲起,知道了文件不仅有内容,还有权限、时间、inode 等属性信息。接着回顾了 C 语言中的 fopen、fread、fwrite、fclose,并理解了 stdin、stdout、stderr 这三个默认打开的标准流。

后面进入系统调用层,学习了 open、write、read、close 等接口。和 C 库函数不同,open 成功后返回的是文件描述符 fd。通过实验可以发现,普通文件的 fd 一般从 3 开始,因为 0、1、2 已经分别被标准输入、标准输出、标准错误占用了。

最后要记住一句话:

FILE* 是 C 语言层的文件流,fd 是系统层定位文件的整数,本质上就是进程文件描述符表的数组下标。

理解了 fd 的本质,后面学习重定向、dup2 和 MiniShell 重定向实现就会轻松很多。

文件描述符表。

只要抓住这条线,Linux 文件 I/O 的底层逻辑就清楚了。

资源分享
【Linux进程控制】从exec程序替换到手写简易Shell:fork、execvp、环境变量与内建命令
【Linux系统篇】从 fork 到 WNOHANG:进程创建与等待机制详解
【Linux进程】程序地址空间详解:虚拟地址、页表、写时拷贝与mm_struct

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐