目录

一、为什么文件这么重要?

先看几个演示:

演示:linux缺省进程打开的3个文件描述符

先补充一下close命令:

补充:进程的正确看法

总结一下:

二、文件内部的io调用机制

总的来说,是一下几点:

1. 用户态与内核态的隔离

2. 系统调用:用户态访问内核的唯一合法入口

3. glibc:系统调用的用户态封装

4. 异常触发:从用户态到内核态的切换机制

逐层详解

一句话总结(记):


本文章内容均来自本人的个人笔记为个人学习总结,禁止他人转载,参考自B站课程:码农论坛《C++环境高级编程》,以及韦东山《嵌入式linux应用开发》。由于当时方便记笔记,笔记中少部分图片(仅涉及部分代码以及相关运行结果展示,不涉及重要笔记、资料等)来源于原课程视频截图,版权归原作者“码农论坛”,“韦东山”及相关权利人所有。

本笔记无任何商业用途(除开csdn官方操作),仅供个人学习交流。感谢原up主的课程分享!


一、为什么文件这么重要?

  1. 对linux来说,socket函数和文件操作没有区别。
  2. 在网络传输的过程中,可以使用文件i/o函数。

总之,在linux中,一切皆文件。

先看几个演示:

先看两个简单的演示代码:

创建文件并写入数据:

编译运行:

再演示:读取文件

在/porc/进程id/fd目录中,存放了每个进程打开的fd(文件描述符,又叫文件句柄)

这些数字都是系统的进程,全部的进程在这里:

用ps命令查看与test有关的进程:

就可以进入fd目录,查看具体的文件描述符了!

但是这是shell的。

修改test(demo3):

编译运行:

此时程序还在运行

回到另一个窗口,查看test进程:

显示0,1,2,3

我们只打开了一个文件是3,那012是哪来的???

原因;

Linux 进程默认打开 3 个文件描述符:

0:标准输入(对应设备:键盘,C++ 中对应 cin)

1:标准输出(对应设备:显示器,C++ 中对应 cout)

2:标准错误(对应设备:显示器,C++ 中对应 cerr)

所以在c++中才可以使用这些进行输入输出操作

演示:linux缺省进程打开的3个文件描述符

先补充一下close命令:

close(fd)是 Linux 系统调用,作用是释放指定的文件描述符,让进程和对应的文件 / 设备断开关联

简单说:close(fd) 就是 “关掉” 进程对某个文件 / 网络连接的访问权限,把 fd 这个 “编号车位” 空出来。

fd 值

名称

对应设备

C++ 标准 IO 绑定

0

标准输入 (stdin)

键盘

cin

1

标准输出 (stdout)

显示器

cout

2

标准错误 (stderr)

显示器

cerr

核心关联:C++ 的cin/cout/cerr本质是对底层 fd=0/1/2 的封装,fd 被关闭,上层 IO 就会失效。

cin/cout 绑定 “标准输入 / 输出通道” 是 C++ 标准规定的通用逻辑,Windows 也不例外;区别只在于:Linux 用「整数文件描述符(fd)」表示这个通道,Windows 用「句柄(HANDLE)」表示,但绑定关系本身是通用的。

Windows:

// 1. 获取标准输入/输出句柄(替代Linux的fd=0/1)

    HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE);

    HANDLE hStdout = GetStdHandle(STD_OUTPUT_HANDLE);

    // 2. 关闭标准输入/输出句柄(替代Linux的close(0)/close(1))

    CloseHandle(hStdin);

    CloseHandle(hStdout);

特性

Linux

Windows

IO 管理方式

整数文件描述符(fd)

句柄(HANDLE,结构体指针)

标准 IO 标识

fd=0 (输入)/1 (输出)/2 (错误)

STD_INPUT_HANDLE 等宏(非整数)

关闭 IO 的函数

close(fd)

CloseHandle(HANDLE)

核心头文件

unistd.h/fcntl.h

windows.h

正式演示:

编译运行:

没有任何问题。

若关闭标准输入:

直接显示0,根本没有机会让我们输入。

若也关闭标准输出:

只显示了标准错误

同理,把标准错误都关掉(close(2)),这几行代码都不会显示!

综上!!!

“Linux 一切皆文件描述符”+“C++ 标准 IO 绑定 fd”

把这几个都关了,并sleep(100s),查看进程:

补充:进程的正确看法

示例:

第 1 行:vi demo3.cpp

这是你用 vi 编辑器打开 demo3.cpp 源码的进程,不是运行的程序。

第 2 行(你要找的):./demo3

命令列是 ./demo3 → 这就是你编译后正在运行的可执行程序,也就是真正的 demo3 进程,PID=7979。

它运行在 pts/2 终端,说明你是在第二个终端窗口里执行的 ./demo3。

第 3 行:grep --color=auto demo3

这是你当前执行的 ps -ef | grep demo3 命令里的 grep 子进程,它只是在搜索包含 “demo3” 的进程,不是你写的程序。

它会把自己也搜出来,因为命令里包含 “demo3” 这几个字。

为什么是7979而不是7076???

7979 是 ./demo3 这个程序的进程 ID(PID),也就是你要找的目标进程。

7076 是它的父进程 ID(PPID),也就是启动它的那个 shell(终端)进程,不是 demo3 本身。

接下来编译运行,查看进程:

显示:啥也没有!

文件描述符的分配规则:找到最小的,没有被占用的文件描述符:

现在关闭了0,1,2,加上一个socket(套接字),注意加上头文件sys/socket.h

之后打开文件会发生啥?

编译运行:

查看进程:

执行ls,显示0 1(这里忘了执行截图)如上

详细解释为什么是0 1,而不是3 4:

之前打开文件,文件描述符是3,现在关闭了012:

详解步骤:

步骤 1:初始状态(进程启动)

系统默认分配 3 个 fd:0(stdin)、1(stdout)、2(stderr) --> 这 3 个编号都被占用。

步骤 2:执行 close(0); close(1); close(2);

手动释放了 0、1、2 这 3 个编号 --> 现在 0、1、2 都是“空车位”,3、4、5… 也都是空的。

步骤 3:执行 fd=open("data.txt",...)

内核开始找“最小的空编号”:

先看 0 --> 空的 --> 直接分配给文件 --> fd=0。

此时占用的 fd:0(文件),1、2、3… 仍空。

步骤 4:执行 sockfd=socket(...)

内核继续找"最小的空编号":

先看 0 --> 已被文件占用 --> 跳过;

再看 1 --> 空的 --> 分配给 Socket --> sockfd=1。

此时占用的 fd:0(文件)、1(Socket),2、3… 仍空。

步骤 5:执行 ls /proc/3776/fd

看到的就是当前被占用的 fd:0、1 --> 完全符合「找最小未占用」的规则。

把socket注释掉,再编译运行,查看进程:

显示0!!!!

系统默认分配 3 个 fd:0(stdin)、1(stdout)、2(stderr) --> 这 3 个编号都被占用。

执行 close(0); close(1); close(2);

手动释放了 0、1、2 这 3 个编号 --> 现在 0、1、2 都是「空车位」,3、4、5… 也都是空的。

执行 fd=open("data.txt",...)

内核开始找“最小的空编号”:

先看 0 --> 空的 --> 直接分配给文件 --> fd=0。

由此说明:文件描述符的分配规则:找到最小的,没有被占用的文件描述符。

运行完毕,上述两个程序连printf的内容都没有输出!

说明::

C 语言的标准输入输出也是绑定文件描述符的,而且和 C++ 的逻辑完全一致。

在linux是绑定fd,在windows是绑定句柄!

C 语言的标准输入输出也是绑定文件描述符的,而且和 C++ 的逻辑完全一致。

在linux是绑定fd,在windows是绑定句柄!

C 语言标准 IO

含义

绑定的 Linux 文件描述符

对应设备

stdin

标准输入

0

键盘

stdout

标准输出

1

显示器

stderr

标准错误

2

显示器

C 语言标准 IO

Linux 底层实现

Windows 底层实现

stdin

绑定 fd=0

绑定 GetStdHandle(STD_INPUT_HANDLE)

stdout

绑定 fd=1

绑定 GetStdHandle(STD_OUTPUT_HANDLE)

stderr

绑定 fd=2

绑定 GetStdHandle(STD_ERROR_HANDLE)

接下来创建一个socket:

编译运行:

查看进程,进入目录并ls:

说明,socket本质就是文件的操作。

解释:统一的 I/O 操作

在网络传输数据的过程中,可以直接使用文件的 I/O 函数(如read()/write())来收发数据,对socket函数也是如此:

来到之前的网络编程客户端(在我上一篇博客)程序:

在服务端程序的send和recv都可以改成文件操作的write和read,效果是一样的:

编译运行服务端程序,再编译运行客户端,效果一样:

同样类比:

C/C++ 标准库的文件操作(比如 C 的 fopen/fread/fwrite、C++ 的 fstream)本质上都是对底层文件描述符(fd)的封装—— 上层的「文件指针(FILE*)」「流对象(fstream)」最终都会绑定到内核的文件描述符,所有读写操作最终都会通过 fd 与内核交互。

简单说:fd 是 Linux 内核给文件的 “底层身份证”,C/C++ 的文件操作只是给这个身份证套了一层 “易用的上层接口”。

这里就不演示了,大家自己测试一下。

总结一下:

核心设计思想:一切皆文件

对 Linux 而言,socket 操作与普通文件操作没有本质区别:

普通文件、网络 socket、管道、设备等,在 Linux 内核中都被抽象为「文件」,统一用 ** 文件描述符(fd)** 来管理。

这是 Linux “一切皆文件” 设计哲学的直接体现。

统一的 I/O 操作

在网络传输数据的过程中,可以直接使用文件的 I/O 函数(如read()/write())来收发数据:

对文件:read(fd, buf, count) / write(fd, buf, count)

对 socket:read(sockfd, buf, count) / write(sockfd, buf, count)

接口完全一致,上层代码无需关心底层是文件还是网络连接。

文件描述符(fd)的本质

文件描述符是 Linux 分配给文件或 socket 的整数标识:

它是进程与内核之间的 “凭证”,进程通过 fd 来访问对应的资源(文件、网络、设备等)。

分配规则:内核会选择当前最小的未被占用的整数作为新的 fd。

进程启动时默认占用:0(标准输入)、1(标准输出)、2(标准错误)。

✨ 一句话总结

Linux 把网络 socket 和普通文件统一抽象为「文件」,用 ** 文件描述符(fd)** 来标识,并用同一套read/write接口完成 I/O,这就是 “一切皆文件” 的核心魅力。

但注意区分:不是 “所有东西”,而是:进程能访问的几乎所有 I/O 资源,本质都是用文件描述符(fd)来表示。

文件描述符只是进程视角的 “句柄 / 编号”,不是硬件本身,也不是内核对象本身。

相当于:

你手里的门卡编号,不是门本身,也不是房间。

哪些东西会被分配 fd?

几乎所有I/O 资源(所有能 “进数据” 和 “出数据” 的东西)都会用 fd 表示:

普通文件

目录(某些打开方式)

管道(pipe)

有名管道(fifo)

socket(TCP/UDP/ 原始套接字)

终端设备(/dev/tty、/dev/pts/0)

键盘、显示器

声卡、显卡(部分设备)

大部分设备文件(/dev/xxx)

哪些东西不是文件描述符?

进程本身(PID 不是 fd)

线程

内存地址

信号

内核对象本身(inode、file 结构体)

管道、socket 是内核对象,fd 只是指向它的编号

最经典的一句 Linux 设计哲学

Everything is a file, not everything is a file descriptor.

一切皆文件(抽象)

不是一切皆文件描述符(整数编号)

对应你代码里的

open() 打开文件 → I/O 资源

socket()/accept() 网络 → I/O 资源

close() 关闭 I/O 资源

所有 I/O 资源,Linux 都用 fd 来代表示。

二、文件内部的io调用机制

总的来说,是一下几点:

1. 用户态与内核态的隔离

Linux 将虚拟地址空间划分为用户空间和内核空间,两者通过硬件内存保护机制(MMU)严格隔离。用户进程运行在用户态,无法直接访问内核数据或代码,这是操作系统安全与稳定性的基石。

2. 系统调用:用户态访问内核的唯一合法入口

当应用程序需要执行文件读写、进程创建、网络通信等特权操作时,必须通过系统调用向内核请求服务。系统调用是内核暴露给用户空间的唯一编程接口。

3. glibc:系统调用的用户态封装

系统调用的原始形式(如 syscall 指令直接传递系统调用号)使用不便,glibc(GNU C Library) 提供了符合 POSIX 标准的封装函数(如 open、read、write)。这些封装函数负责:

将用户参数整理为内核期望的格式

设置系统调用号

触发异常进入内核态

处理返回值并设置 errno

补充:

glibc(GNU C Library)是 Linux 系统的核心库,为操作系统和应用程序提供了关键的 API 支持,包括 ISO C11、POSIX.1-2008、BSD 等标准接口。这些接口涵盖了文件操作(如 open、read、write)、内存管理(如 malloc)、线程支持(如 pthread_create)、动态加载(如 dlopen)等功能。

4. 异常触发:从用户态到内核态的切换机制

glibc 封装的系统调用函数内部,最终通过一条软中断指令(x86 上是 syscall,ARM 上是 svc)触发 CPU 异常。CPU 响应异常后:

硬件自动切换到内核模式

根据异常向量表跳转到内核的系统调用分发例程

内核根据系统调用号查表,调用对应的内核函数(如 sys_open、sys_read)

完成后通过特定指令返回用户态,恢复进程上下文

一句话总结

用户程序 → glibc 封装函数 → syscall/svc 触发异常 → CPU 切换特权级 → 内核系统调用分发器 → 内核函数执行 → 结果返回用户态。

这个是完整流程(图片来源于韦东山)

完整链路示例:

用户态                                                                 内核态

───────                                                        ──────

app 调用 open/read

    │

    ▼

glibc 封装函数

    │

    ▼

设置系统调用号到寄存器

    │

    ▼

触发异常(swi/svc)──────────► CPU切换特权级

                                                                          │

                                                                          ▼

                                                              异常处理入口

                                                                          │

                                                                          ▼

                                                              从寄存器取出系统调用号

                                                                          │

                                                                          ▼

                                                              查 sys_call_table

                                                                          │

                                                                          ▼

                                                              执行对应内核函数

                                                              sys_open / sys_read

逐层详解

第一层:APP 调用

// 示例代码

int fd = open("1.txt", O_RDWR);

int len = read(fd, buf, 1024);

这些都是 POSIX 标准函数,你直接调用,不用关心底层。

第二层:glibc 封装

// glibc 内部(简化示意)

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

{

    // ... 参数整理 ...

    // 调用真正的系统调用

    return _libc_open(pathname, flags, mode);

}

图中的 weak_alias 是 glibc 的一个机制:

weak_alias(_libc_open, open)

意思是:open 这个符号,默认指向 _libc_open。用户如果自己定义了一个 open 函数,可以覆盖它。这是 glibc 为了灵活性做的设计,你可以暂时不管它,知道 open 最终调用的是 _libc_open 就行。

第三层:设置系统调用号 + 触发异常

这是最关键的一步。_libc_open 内部做的事情:

Assembly:

; ARM EABI 方式(如 i.MX6ULL 的 Cortex-A7)

mov r7, #NR_open    ; 把 open 的系统调用号放进 r7 寄存器

swi 0               ; 触发软件中断,CPU 进入内核态

不同 ARM 架构传参方式不同:

架构

传参寄存器

触发指令

old ABI(老 ARM)

swi 指令编码里

swi #NR_open

EABI(ARMv7,i.MX6ULL)

r7

swi

ARM64(新板子)

r8

svc

old ABI:系统调用号藏在 swi 指令本身

EABI:系统调用号在 r7 寄存器里

ARM64:系统调用号在 r8 寄存器里,触发指令叫 svc 而不是 swi

第四层:异常处理入口(进入内核)

swi(或 svc)触发后,CPU 硬件自动做:

切换到内核模式(特权级)

跳转到异常向量表里预定的入口地址

内核的异常处理代码开始执行

第五层:取出系统调用号,查表分发

内核异常处理代码做的事:

// 内核里的逻辑(简化)

if (是 swi/svc 异常) {

    // 从寄存器取出系统调用号

    int nr = r7;  // EABI 方式

    

    // 查系统调用表,执行对应函数

    sys_call_table[nr](参数...);

}

图中的 sys_call_table 是一个函数指针数组,每个系统调用号对应一个数组下标,指向内核里的实现函数:

sys_call_table[NR_open]  → sys_open

sys_call_table[NR_read]  → sys_read

sys_call_table[NR_write] → sys_write

...

第六层:内核函数执行

// 内核源码 fs/open.c

SYSCALL_DEFINE3(open, const char __user *, pathname, int, flags, umode_t, mode)

{

    // 拷贝用户空间路径名到内核空间

    // 分配文件描述符

    // 调用 VFS 层打开文件

    // 返回 fd

}

// 内核源码 fs/read_write.c

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)

{

    // 根据 fd 找到文件结构体

    // 调用 VFS 层读取数据

    // 把数据拷贝到用户空间

    // 返回读取字节数

}

SYSCALL_DEFINE3 是一个宏,3 表示这个系统调用有 3 个参数。展开后就是定义了 sys_open 和 sys_read 函数。

完整链路:

我的代码:

  open("1.txt", O_RDWR)

      │

      ▼

glibc:

  _libc_open("1.txt", O_RDWR, 0)

      │

      ▼

汇编:

  mov r7, #NR_open

  swi                    ← 触发异常,进入内核

      │

      ▼

内核异常处理:

  从 r7 取出 NR_open

      │

      ▼

查表:

  sys_call_table[NR_open]

      │

      ▼

内核函数:

  sys_open()             ← 真正干活的地方

      │

      ▼

返回:

  fd = 3  →  回到用户态,你的代码拿到 fd

几个缩写的解释:

缩写

全称

含义

ABI

Application Binary Interface

应用程序二进制接口,定义函数调用时参数怎么传、寄存器怎么用

EABI

Embedded ABI

嵌入式 ARM 的标准 ABI

swi

Software Interrupt

ARMv7 的软中断指令,用于触发系统调用

svc

Supervisor Call

ARM64 的系统调用指令,和 swi 作用一样,换个名字

NR_open

Number of open

open 系统调用的编号(每个系统调用都有一个唯一编号)

一句话总结(记):

app → glibc 封装 → 触发异常 → 内核系统调用分发 → 内核函数干活 → 返回结果

接下来测试:

一个最简单的程序:

编译,后台运行,查看进程:

这里0,1,2:

这三个数字是 Linux 为每个进程默认打开的标准文件描述符。它们在你启动程序时由系统自动分配,分别负责输入、输出和报错。

就是我们熟知的:

0 → 标准输入 (stdin)

程序读取数据的地方。它指向 /dev/pts/0,也就是你敲命令的终端窗口。如果程序等着你键盘打字输入,就是从这个通道读取的。

1 → 标准输出 (stdout)

程序打印结果的地方。同样指向你的终端,所以你用 printf 或 std::cout 输出的内容会显示在屏幕上。

2 → 标准错误 (stderr)

程序输出错误信息的地方。也指向终端,所以错误信息默认也会打印在屏幕上。之所以和标准输出分开,是为了能单独把错误信息重定向到文件里去。

因为程序是在终端前台或者后台 (&) 启动的,系统会自动把它的输入、输出都“连接”到这个终端设备上,所以你看到它们都指向 /dev/pts/0。

若在代码里再打开一个文件:

重新查看进程:

可见多了一个文件句柄,也不需要解释。

再修改代码:

编译运行,分别打开两个文件(运行文件和源文件)(来源于韦东山):

每个进程的文件句柄3,都对应着本进程打开的新文件。

说明:每个进程,都有自己的文件句柄空间。

一般来讲,想要打开fd=3,的文件,实际上是说打开某个进程fd=3的文件。

提取的代码:

long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
{
    struct open_flags op;
    int fd = build_open_flags(flags, mode, &op);

    struct filename *tmp;

    if (fd)
    {
        return fd;
    }

    tmp = getname(filename);
    if (IS_ERR(tmp))
    {
        return PTR_ERR(tmp);
    }

    fd = get_unused_fd_flags(flags);   // ① 获得未用的文件句柄(描述符)
    if (fd >= 0) {
        struct file *f = do_filp_open(dfd, tmp, &op);  // ② 打开文件,得到 file 结构体
        if (IS_ERR(f)) {
            put_unused_fd(fd);
            fd = PTR_ERR(f);
        } else {
            fsnotify_open(f);
            fd_install(fd, f);       // ③ 在当前进程里记录下来(绑定 fd 和 file)
        }
    }

    putname(tmp);
    return fd;
}

这个file结构体非常的复杂:

其中有一个重要的变量:

f_pos 是 struct file 结构体中的一个字段,用来记录当前文件操作的读写位置(文件偏移量)。

通俗理解

它就像一个书签,标记着“下次读/写从这个位置开始”。你之前问过 write 那张图里 POS 的自动更新,那个 POS 在内核里就是 f_pos 在管理。

它在 file 结构体里的样子

struct file {

    // ...

    loff_t f_pos;   // loff_t 是 long long 类型,即 64 位有符号整数

    // ...

};

它是怎么工作的

1. 打开文件时

fd = open("1.txt", O_RDONLY);

内核创建 struct file,f_pos 初始值 = 0(从文件开头开始)。

2. 读/写时自动更新

read(fd, buf, 100);  // 读了 100 字节,内核自动把 f_pos += 100

read(fd, buf, 100);  // 接着从 100 的位置继续读,读完 f_pos = 200

write(fd, buf, 50);  // 从 200 的位置写,写完 f_pos = 250

每一次 read 或 write 成功后,内核都会把实际读写的字节数加到 f_pos 上。

3. 手动改位置用 lseek

lseek(fd, 0, SEEK_SET);  // 把 f_pos 设回 0,回到文件开头

lseek(fd, 100, SEEK_CUR); // f_pos 往后跳 100 字节

4. 打开时加特殊标志也会影响它

fd = open("1.txt", O_RDWR | O_APPEND);  // 每次写之前 f_pos 自动跳到文件末尾

fd = open("1.txt", O_RDWR | O_TRUNC);   // 清空文件,f_pos 从 0 开始

一个细节:为什么 f_pos 要单独加锁(f_pos_lock)

在多线程场景下:

// 线程 A 和 B 同时用同一个 fd 读写

lseek(fd, 100, SEEK_SET);  // A 线程想从 100 开始读

lseek(fd, 200, SEEK_SET);  // B 线程同时把 f_pos 改成了 200

f_pos_lock 就是为了防止这种竞争,保证 lseek + read/write 是一个原子操作。

总结:f_pos 就是你文件操作的“当前位置指针”,内核用它来跟踪每次读写到哪了,保证顺序读写的连贯性。

流程:(图片来源于韦东山)

2. fd_install 的实现

void fd_install(unsigned int fd, struct file *file)

{

    __fd_install(current->files, fd, file);

}

拆解:

代码

含义

current

指向当前进程的 task_struct(Linux 内核用 current 宏获取当前正在运行的进程)

current->files

当前进程的文件描述符表struct files_struct),这个表记录了该进程打开的所有文件

__fd_install(...)

把 struct file * 存到 current->files 这个表里,索引用 fd,即 current->files->fd_array[fd] = file

 用人话说

fd_install 就是往当前进程的“文件打开记录本”里写一行:

文件描述符 fd=3  →  指向内核里的 struct file(代表"1.txt"这个已打开的文件)

这样以后你用 read(3, buf, 100) 时,内核根据 fd=3 就能立刻找到对应的 struct file,知道要读哪个文件、当前读到哪了(f_pos)、用什么权限打开的(f_mode)。

为什么必须绑定到 current->files?

因为文件描述符是进程私有的。每个进程有自己的 files_struct 表:

进程

fd=3 指向

你的 test 进程

1.txt 的 struct file

另一个进程

可能是 config.json,也可能根本没打开 fd=3

总结(图片来源于韦东山):

Linux 内核中每创建一个进程,都会为它创建一个独立的 task_struct 结构体。

整个链条是这样的:

创建进程:当你运行 ./test 程序,内核做的第一件事就是分配一个新的 task_struct。

绑定文件管理模块:在这个 task_struct 里,有一个指针 files,指向一个专门管文件的 files_struct 结构体。

记录打开的文件:files_struct 里有个指针 fdt,最终指向一个存有 struct file * 指针的数组。你用 open 打开 1.txt 时,返回的 fd (如3) 就是这个数组的索引,而数组该位置里存放的,就是指向一个全新 struct file 的指针。

所以,整个流程是单行道:一个进程 → 一个 task_struct → 一个 files_struct → 一张文件表。

你反复打开同一个文件,只是在这同一张表里新增了不同的条目,分别指向不同的 struct file 而已。

这个“一个进程一个 task_struct”的模型,是整个 Linux 系统编程的基础。

进程 task_struct

  └── files (struct files_struct *)

        └── fd_array[]

              ├── [0] → struct file (指向 /dev/pts/0)   ← 标准输入

              ├── [1] → struct file (指向 /dev/pts/0)   ← 标准输出

              ├── [2] → struct file (指向 /dev/pts/0)   ← 标准错误

               └── [3] → struct file (指向 1.txt)        ← 你刚 open 的

                            │

                            ├── f_pos = 0

                            ├── f_mode = O_RDWR

                            └── ...

task_struct 是 Linux 内核中代表一个进程的核心数据结构,用 C 语言的结构体实现。它常被称为进程控制块(PCB)

简单来说,内核不认“进程名”,只认 task_struct。操作系统通过它来管理和调度进程,一个 task_struct 就是一个进程的“档案袋”。

这个结构体非常庞大,包含了一个进程的所有关键信息:

进程身份信息:进程ID (PID)、父进程ID (PPID)、所属用户等。

内存管理信息:进程的代码段、数据段、堆栈在内存的哪里,即内存描述符 mm_struct

文件系统信息:就是我们之前讨论的 files_struct,记录进程打开了哪些文件。

调度信息:进程的优先级、时间片、运行状态等,用于 CPU 调度。

信号信息:哪些信号被阻塞、哪些信号在等待处理等。

current 是内核中的一个,在任何内核代码中,都可以通过 current 直接获取当前正在执行的进程的 task_struct。所以之前 fd_install 函数里,就是通过 current->files,找到当前进程的文件描述符表,然后把 struct file 指针存进去的。

fd_install 就是在这个 fd_array[] 里填上新的一格。

内部结构->从 current 到 struct file 的完整路径:

current                     ← 当前进程的 task_struct

  └── files                 ← struct files_struct *(文件描述符管理器)

        └── fdt             ← struct fdtable *(文件描述符表指针,真正的表本体)

              └── fd[]      ← struct file * 数组(文件描述符数组)

                    ├── [0] → struct file (stdin)

                    ├── [1] → struct file (stdout)

                    ├── [2] → struct file (stderr)

                    └── [3] → struct file (你刚 open 的文件)

fdtable 是一个可以动态扩容的结构。当一个进程打开的文件超过初始容量时,内核会分配一个更大的 fd[] 数组,然后让 fdt 指向新的表。files_struct 本身不用动,只换 fdt 指针就行。

调用链:

current->files          // struct files_struct *

current->files->fdt     // struct fdtable *

current->files->fdt->fd // struct file **  即 fd 数组

__fd_install(current->files, fd, file) 内部实际做的是:

current->files->fdt->fd[fd] = file;

把 struct file * 存进这个数组的对应位置。

更多推荐