〖Linux文件系统〗:彻底打通文件 IO 全链路

🌌 文件系统真正难的,从来不是记住几个 open/read/write****接口。

真正困难的是把下面这一整条链路串起来:

进程 → fd → struct file → dentry / inode → address_space → Page Cache → 磁盘文件系统

如果这些东西在脑子里还是一个个孤立名词,那么学到最后一定会乱。

这一篇,我们不把知识点拆碎背,而是沿着一次真实的文件访问过程,一路走到底。


📖 本文关键词

open · read · write · close · fd · dup2 · 重定向 · 用户级缓冲区 · fork · 软硬链接 · inode · dentry · VFS · Page Cache · task_struct

@TOC


1. 文件到底是什么?先把最基础的认知对齐

在开始研究接口之前,先统一几个非常重要的认知。

1.1 文件不仅仅是"磁盘上的东西"

我们平时提到文件,第一反应一般是:

  • .txt

  • .cpp

  • .jpg

  • .mp4

这些确实是文件,但这是站在普通用户角度看到的磁盘文件。

从 Linux 系统角度看,文件的范围远比这个大。

复制代码
文件
├── 磁盘文件
│   ├── 普通文本
│   ├── 程序
│   ├── 图片
│   └── ...
│
└── 内存中的文件对象
    └── 进程打开文件后,内核为其建立相应的数据结构

所以学习 Linux 文件系统时,我们其实同时在研究两个世界:

复制代码
磁盘上的文件系统
        ↓
内核中的文件对象
        ↓
进程如何操作这些文件对象

后面所有内容,基本都围绕这三层展开。


1.2 文件 = 内容 + 属性

一个文件不是只有里面保存的数据。

比如:

复制代码
ls -l test.txt

除了文件内容,我们还能看到:

  • 文件大小

  • 权限

  • 所有者

  • 所属组

  • 修改时间

  • 链接数

所以可以先建立一个最基本的认识:

复制代码
文件 = 文件内容 + 文件属性

在 Ext 系列文件系统中,可以粗略理解为:

复制代码
文件属性
   ↓
 inode

文件内容
   ↓
Data Blocks

这也是为什么:

一个文件即使大小为 0,也不意味着它完全不消耗文件系统资源。

它可能没有真正的数据块,但仍然需要 inode、目录项等元数据来描述它。


1.3 Linux 为什么总说"一切皆文件"?

Linux 最经典的一句话:

Everything is a file ------ 一切皆文件。

它并不是说键盘、显示器、网卡真的都变成了磁盘上的 .txt。

真正的意思是:

Linux 尽量把不同设备、不同资源抽象成统一的文件接口。

比如:

复制代码
普通磁盘文件
键盘
显示器
管道
设备
Socket
...

上层都可以通过类似:

复制代码
read();
write();

这样的统一接口去操作。

于是应用程序不需要针对每一种硬件重新设计一套完全不同的操作方式。

这背后依赖的一个重要机制就是:

复制代码
struct file
    ↓
  f_op
    ↓
file_operations

不同类型的文件提供不同的具体实现,但上层看到的仍然可以是统一的:

复制代码
read(fd, ...);
write(fd, ...);

这其实就是 C 语言环境下一种非常经典的"多态"思想。


1.4 文件操作为什么一定和进程有关?

一个磁盘文件可以独立存在,但文件操作一定由进程发起。

比如:

复制代码
open();
read();
write();
close();

这些函数最终都是当前进程去调用的。

因此,进程必须保存一个重要的信息:

我现在到底打开了哪些文件?

Linux 在进程对应的数据结构 task_struct 中,会关联一个:

复制代码
files_struct

用于管理当前进程已经打开的文件。

先简单建立这个关系:

复制代码
task_struct
     │
     └── files
           ↓
      files_struct
           ↓
        fdtable
           ↓
      struct file*

后面讲到文件描述符时,这条链会正式串起来。


2. 文件操作:open、read、write、close 到底发生了什么?

Linux 下最基础的一组文件操作接口就是:

复制代码
open
read
write
close

看起来只有四个函数,但真正值得学的并不是"函数怎么调用",而是:

调用这些函数之后,操作系统到底做了什么?


2.1 open:从路径找到文件,再给进程一个 fd

常见函数原型:

复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags, ...);

当需要创建文件时,第三个参数通常是:

复制代码
mode_t mode

例如:

复制代码
int fd = open("test.txt", O_WRONLY | O_CREAT, 0666);

返回值:

复制代码
成功 → 文件描述符 fd
失败 → -1

2.1.1 fd 到底是什么?

很多人在刚学这里时会产生一个错误理解:

fd 是不是文件的编号?

不准确。

更加准确的说法是:

fd 是当前进程文件描述符表中的一个下标。

可以先把结构想象成:

复制代码
task_struct
     │
     └── files
           ↓
      files_struct
           ↓
       fd_array / fdtable
           │
           ├── [0] ──→ struct file
           ├── [1] ──→ struct file
           ├── [2] ──→ struct file
           ├── [3] ──→ struct file
           └── ...

所以:

复制代码
fd = 数组下标

系统默认通常已经打开:

复制代码
0 → stdin  → 标准输入
1 → stdout → 标准输出
2 → stderr → 标准错误

因此我们第一次主动打开普通文件时,经常得到:

复制代码
fd = 3

但要注意:

fd 并不是永远从 3 开始递增。

它遵循的是:

复制代码
当前进程中最小的、尚未被使用的文件描述符

例如:

复制代码
close(1);

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

此时 1 已经空出来了,因此 open 完全可能返回:

复制代码
fd = 1

这个机制后面正是重定向能够实现的基础。


2.1.2 一个 fd 最终指向什么?

fd 本身只是数组下标。

真正描述"当前这一次打开行为"的,是内核里的:

复制代码
struct file

因此关系是:

复制代码
fd
 ↓
文件描述符表
 ↓
struct file

struct file 中有几个非常重要的成员。

f_pos:当前文件读写位置

例如:

复制代码
文件内容:

ABCDEFG
   ↑
 f_pos

连续调用:

复制代码
read(fd, buf, 3);

第一次读:

复制代码
ABC

f_pos 向后移动。

第二次再读时,会从新的位置继续读取。


f_flags:文件打开状态

例如:

复制代码
O_RDONLY
O_WRONLY
O_RDWR
O_APPEND
O_NONBLOCK
...

这些打开文件时指定的状态,需要被保存下来。


f_op:这个文件应该怎么操作?

也就是:

复制代码
file_operations

普通文件、设备文件、管道等对象的底层实现可以完全不同。

但对于应用层来说:

复制代码
read();
write();

接口仍然保持统一。

这就是前面"一切皆文件"能够成立的重要原因之一。


f_inode:这个打开文件属于哪个 inode?

复制代码
struct file
    │
    └── f_inode
          ↓
      struct inode

struct file 描述的是:

一次打开行为。

而 inode 描述的是:

文件本身。

因此:

复制代码
open("a.txt", ...);
open("a.txt", ...);

完全可能产生:

复制代码
struct file A
struct file B

但是:

复制代码
它们最终对应同一个 inode

也就是说:

复制代码
同一个文件
   ↓
可以被 open 多次
   ↓
产生多个 struct file
   ↓
但最终指向同一个 inode

这个关系非常重要。


2.2 open 不仅是"打开文件":它还必须完成路径解析

我们调用:

复制代码
open("/home/user/test.txt", O_RDONLY);

传进去的是一个:

复制代码
字符串路径

但是磁盘和内核显然不能直接靠字符串完成文件操作。

操作系统必须先回答:

/home/user/test.txt 到底对应哪个文件?

这个过程就是:

路径解析。


2.2.1 相对路径从哪里开始找?

例如:

复制代码
open("./test.txt", O_RDONLY);

这是相对路径。

Linux 会从当前进程的:

复制代码
fs_struct->pwd

开始寻找。

也就是:

复制代码
task_struct
     │
     └── fs
          ↓
      fs_struct
          ↓
         pwd

pwd 保存当前工作目录。


2.2.2 绝对路径从哪里开始?

例如:

复制代码
open("/home/user/test.txt", O_RDONLY);

路径以 / 开头。

此时从:

复制代码
fs_struct->root

开始解析。

所以可以记住:

复制代码
相对路径 → pwd
绝对路径 → root

2.2.3 pwd 和 root 保存的并不只是一个字符串

它们最终对应:

复制代码
struct path

其中非常重要的两个部分是:

复制代码
struct path
├── mnt
└── dentry

其中:

mnt

表示:

当前路径位于哪个挂载实例中。

Linux 并不像 Windows 那样简单使用:

复制代码
C:
D:
E:

而是把不同文件系统挂载到统一目录树的不同位置。

所以解析一个路径时,不仅要知道:

复制代码
当前目录是谁

还要知道:

复制代码
它属于哪个挂载文件系统

dentry

dentry 可以理解为:

目录项在内存中的表示。

里面有:

复制代码
d_name
d_parent
d_inode

于是可以形成:

复制代码
文件名
  ↓
dentry
  ↓
inode

这里开始出现 Linux 文件系统一个非常重要的设计:

名字和文件本体并不是一回事。

文件名字主要通过目录项组织。

真正描述文件属性的是:

复制代码
inode

2.2.4 路径解析为什么需要 dcache?

假设我们每次访问:

复制代码
/home/user/project/src/main.cpp

都从磁盘上的根目录开始一层一层找:

复制代码
/
↓
home
↓
user
↓
project
↓
src
↓
main.cpp

那性能会非常差。

所以 Linux 会缓存已经解析过的目录项。

这就是:

复制代码
dcache

也就是:

dentry cache。

路径解析时,可以先尝试利用已经存在的 dentry 缓存。

如果对应目录项没有缓存,则需要进入具体文件系统,通过 inode 的相关操作,例如:

复制代码
i_op->lookup()

继续完成查找。

于是整体思路可以理解成:

复制代码
open(path)
    ↓
确定路径起点
    ↓
绝对路径 → root
相对路径 → pwd
    ↓
逐级解析路径
    ↓
优先利用 dcache
    ↓
缓存不存在
    ↓
进入具体文件系统查找
    ↓
得到 dentry / inode

2.2.5 chdir 为什么会影响相对路径?

例如:

复制代码
chdir("/home/user");

之后:

复制代码
open("./test.txt", O_RDONLY);

路径解析的起点已经发生变化。

本质上:

复制代码
chdir
  ↓
修改当前进程 fs_struct 中的 pwd

目录树本身并没有被修改。

变的是:

当前进程站在目录树的哪个位置。

可以把它想象成:

复制代码
目录树没动
人换地方了

所以如果当前工作目录发生变化,再继续使用相对路径,就可能访问完全不同的位置。


2.3 flags:文件到底准备怎么打开?

open 第二个参数用于描述打开方式。

最常见的几个:

复制代码
O_RDONLY

只读。

复制代码
O_WRONLY

只写。

复制代码
O_RDWR

可读可写。


O_CREAT

文件不存在时创建。

复制代码
open("test.txt", O_WRONLY | O_CREAT, 0666);

O_TRUNC

如果文件已经存在,将原内容截断为 0。

所以:

复制代码
O_WRONLY | O_CREAT | O_TRUNC

经常用于覆盖写。


O_APPEND

追加写。

每次写数据时都追加到文件尾部。


O_NONBLOCK

非阻塞方式。

这个标志在:

复制代码
管道
Socket
设备

等场景中非常常见。


O_CLOEXEC

表示:

在成功执行 exec 后自动关闭这个 fd。

它可以避免文件描述符意外泄漏给新程序。


2.4 创建文件时 mode 和 umask 是怎么配合的?

例如:

复制代码
open("test.txt", O_WRONLY | O_CREAT, 0666);

我们传入:

复制代码
0666

但最终创建出来的文件权限不一定真的是:

复制代码
0666

还需要经过:

复制代码
umask

过滤。

可以粗略理解成:

复制代码
最终权限 = mode & ~umask

例如:

复制代码
mode  = 0666
umask = 0002

则:

复制代码
0666 & ~0002
=
0664

目录为什么一定要注意 x 权限?

对于普通文件:

复制代码
r → 读
w → 写
x → 执行

而对于目录:

复制代码
x

还有一个非常重要的含义:

允许穿过、进入这个目录进行路径解析。

因此即使你知道某个文件完整路径,如果中间目录缺少相应的执行权限,路径解析仍然可能失败。

这也是:

复制代码
目录 x 权限

经常被忽略的地方。


2.5 creat:专门用来创建文件

函数:

复制代码
#include <fcntl.h>

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

它本质上相当于:

复制代码
open(pathname,
     O_WRONLY | O_CREAT | O_TRUNC,
     mode);

所以现代代码中很多时候直接使用:

复制代码
open()

就足够了。


2.6 write:把用户数据写向文件

函数原型:

复制代码
#include <unistd.h>

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

参数:

复制代码
fd
→ 写哪个文件

buf
→ 数据从哪里来

count
→ 最多写多少字节

返回值:

复制代码
> 0  → 实际写入的字节数
-1   → 发生错误

要注意:

返回值不一定永远等于 count。

系统调用存在"短写"的可能,可靠程序应根据返回值判断实际写入了多少。


2.6.1 为什么写字符串时经常用 strlen?

例如:

复制代码
const char *msg = "hello Linux\n";

write(fd, msg, strlen(msg));

而不是:

复制代码
write(fd, msg, 100);

原因很简单:

write 根本不知道 buf 里面装的是不是 C 字符串。

它只知道:

复制代码
从 buf 开始
向后取 count 个字节

所以:

复制代码
strlen(msg)

用于告诉系统:

我真正需要写的是这个字符串实际有效的字节数。

而字符串末尾的:

复制代码
'\0'

通常只是 C 字符串在用户态的结束标志,本身没有必要写入普通文本文件。


2.7 write 成功 ≠ 数据已经真正落盘

这是文件 IO 非常重要的一点。

很多人会下意识认为:

复制代码
write(fd, buf, size);

只要返回成功:

复制代码
数据就已经进入硬盘

实际上并不是。

Linux 为了解决:

复制代码
CPU / 内存非常快
磁盘非常慢

这个巨大速度差距,引入了:

复制代码
Page Cache

普通 buffered I/O 的典型写入过程更接近:

复制代码
用户空间 buf
       ↓
write
       ↓
内核 Page Cache
       ↓
页面标记 dirty
       ↓
write 返回
       ↓
后台 writeback
       ↓
磁盘

所以:

write 返回成功,通常只代表数据已经成功进入内核管理的文件缓存体系,并不等于磁盘介质已经完成持久化。

后面 Page Cache 部分会完整解释这条链路。


2.8 用户级缓冲区:FILE* 和 fd 不要混为一谈

C 标准库中还有:

复制代码
FILE *

比如:

复制代码
FILE *fp = fopen("test.txt", "w");

这里要纠正一个非常容易出现的说法:

FILE* 并不"等于" fd。

更加准确的是:

FILE* 指向用户态标准 IO 库维护的流对象,它内部会封装文件描述符,并维护自己的缓冲区、错误状态等信息。

可以理解成:

复制代码
FILE*
 │
 ├── fd
 ├── 用户态缓冲区
 ├── 当前状态
 └── ...

所以:

复制代码
stdio
    ↓
FILE*
    ↓
用户级缓冲区
    ↓
write 系统调用
    ↓
内核

为什么还要多加一层缓冲区?

因为如果:

复制代码
printf("a");
printf("b");
printf("c");

每次都立刻执行一次系统调用:

复制代码
write()

系统调用次数会非常多。

于是 libc 可以先把数据积攒在用户级缓冲区里:

复制代码
a
ab
abc
...

等满足刷新条件以后,再一次性调用:

复制代码
write()

减少用户态与内核态之间频繁切换。


2.8.1 三种缓冲模式

标准 IO 常见三种缓冲方式。

全缓冲

缓冲区达到一定程度时再刷新。

普通文件经常采用这种方式。


行缓冲

遇到:

复制代码
'\n'

时可以触发刷新。

终端上的 stdout 通常表现为行缓冲。


无缓冲

数据尽量直接提交给底层 IO。

例如 stderr 通常采用无缓冲策略,以便错误信息及时输出。


2.8.2 fflush 到底刷新到哪?

复制代码
fflush(FILE *stream);

它做的是:

复制代码
用户态 FILE 缓冲区
        ↓
      fflush
        ↓
     内核空间

注意:

fflush 并不等于"强制磁盘落盘"。

它解决的是:

复制代码
用户级缓冲区
        ↓
内核

而真正涉及内核缓存向存储设备同步,则是另外一层问题。

这个区分非常重要:

复制代码
fflush
→ libc 用户缓冲区 → 内核

write
→ 用户内存 → 内核文件缓存体系

fsync / O_SYNC
→ 进一步要求同步到持久化存储语义

2.8.3 fclose 和 exit 为什么会刷新缓冲区?

正常执行:

复制代码
fclose(fp);

标准库会负责处理尚未刷新的用户态数据。

正常:

复制代码
exit();

退出时同样会执行用户态清理过程,其中包括刷新标准 IO 缓冲区。

这件事一旦遇到:

复制代码
fork()

就非常有意思了。


2.9 fork 为什么会把 printf 打印两遍?

看下面的程序:

复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    printf("hello");

    fork();

    return 0;
}

如果:

复制代码
hello

在调用 fork() 之前还停留在用户态缓冲区里,那么 fork 创建子进程时,整个用户空间状态会按写时拷贝语义被继承。

于是:

复制代码
fork 前:

父进程缓冲区
[hello]

fork 后:

父进程缓冲区
[hello]

子进程缓冲区
[hello]

最终两个进程都:

复制代码
return 0;

正常退出时都会刷新自己的缓冲区。

于是可能看到:

复制代码
hellohello

加入:

复制代码
printf("hello\n");

如果标准输出连接终端,通常是行缓冲。

\n 导致在 fork() 之前就完成刷新:

复制代码
printf("hello\n")
      ↓
终端行缓冲刷新
      ↓
fork 时缓冲区已经为空

因此不会重复。


但是如果:

复制代码
./a.out > test.txt

事情又变了。

stdout 不再连接终端,而是连接普通文件,往往转为:

复制代码
全缓冲

因此即使存在:

复制代码
'\n'

也不一定在 fork() 前刷新。

于是缓冲区仍然可能被复制,最终文件中出现两份输出。


exec 失败后为什么经常推荐 _exit?

典型场景:

复制代码
pid_t id = fork();

if (id == 0)
{
    execl(...);

    // exec执行到这里说明失败
    _exit(1);
}

为什么不是:

复制代码
exit(1);

原因之一就是:

exit 会执行用户态标准库清理,其中可能再次刷新从父进程继承而来的缓冲区。

而:

复制代码
_exit()

直接交给内核结束当前进程,不执行 stdio 用户态清理。

因此在:

复制代码
fork → exec

模型中,子进程 exec 失败后使用 _exit(),可以避免一些重复刷新导致的诡异问题。


2.10 read:从文件中读取数据

函数:

复制代码
#include <unistd.h>

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

返回值非常重要:

复制代码
n > 0
→ 成功读取 n 个字节

n == 0
→ EOF,读取结束

n == -1
→ 读取失败

同时:

read 也不保证一次一定能读满 count 个字节。

因此应该始终以返回值 n 为准。


读取字符串时为什么要给 '\0' 留位置?

例如:

复制代码
char buf[1024];

ssize_t n = read(fd, buf, sizeof(buf) - 1);

if (n > 0)
{
    buf[n] = '\0';
}

为什么是:

复制代码
sizeof(buf) - 1

因为:

复制代码
read()

读取的是裸字节。

它不会自动帮我们补:

复制代码
'\0'

如果后面准备:

复制代码
printf("%s", buf);

那么就需要人为保证它是合法 C 字符串。

所以最后留下一个位置:

复制代码
有效数据 | \0

需要注意:

如果读取的是二进制文件,就没有必要把数据强行当 C 字符串处理。


2.11 close:关闭的到底是什么?

函数:

复制代码
#include <unistd.h>

int close(int fd);

返回值:

复制代码
成功 → 0
失败 → -1

close(fd) 的核心作用是:

关闭当前进程文件描述符表中的这个 fd。

可以理解成:

复制代码
fdtable[fd]
    │
    X

并使对应打开文件对象的引用计数减少。

如果已经没有任何引用,那么相关打开文件对象才会被真正释放。


close 和"删除文件"不是一回事

文件真正什么时候被删除,是 Linux 中非常经典的问题。

假设:

复制代码
rm test.txt

本质上首先删除的是:

复制代码
目录项 / 文件名到 inode 的链接

使 inode 的硬链接计数减少。

真正能够回收文件存储空间,通常需要满足:

复制代码
硬链接数 == 0
&&
没有进程继续持有这个打开文件

所以会出现一种非常经典的现象:

复制代码
进程已经 open 一个文件
        ↓
另一个进程 rm 文件
        ↓
文件名消失
        ↓
原来的进程仍然可以继续 read/write

因为:

复制代码
目录项虽然没有了
但打开文件仍然引用 inode

直到最后的文件引用也被释放,文件数据才真正具备被回收的条件。


2.12 最小系统 IO 完整示例

把最基本的:

复制代码
open → write → close → open → read → close

串起来:

复制代码
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
    const char *filename = "test.txt";
    const char *msg = "hello Linux file system\n";

    // 1. 打开/创建文件
    int fd = open(filename,
                  O_WRONLY | O_CREAT | O_TRUNC,
                  0666);

    if (fd == -1)
    {
        perror("open");
        return 1;
    }

    // 2. 写文件
    ssize_t ret = write(fd, msg, strlen(msg));

    if (ret == -1)
    {
        perror("write");
        close(fd);
        return 1;
    }

    // 3. 关闭
    close(fd);

    // 4. 重新以只读方式打开
    fd = open(filename, O_RDONLY);

    if (fd == -1)
    {
        perror("open");
        return 1;
    }

    char buf[1024];

    // 给 '\0' 留一个位置
    ssize_t n = read(fd, buf, sizeof(buf) - 1);

    if (n == -1)
    {
        perror("read");
        close(fd);
        return 1;
    }

    if (n > 0)
    {
        buf[n] = '\0';
        printf("%s", buf);
    }

    close(fd);

    return 0;
}

到这里为止,我们已经拥有:

复制代码
路径
 ↓
open
 ↓
fd
 ↓
struct file
 ↓
read / write
 ↓
close

但 fd 的能力远不止"标识文件"。

接下来就是它非常经典的应用:

重定向。


3. 重定向:改变 fd 与文件之间的指向关系

平时执行:

复制代码
printf("hello\n");

为什么内容会打印到显示器?

因为:

复制代码
printf
 ↓
stdout
 ↓
fd = 1
 ↓
终端设备

所以真正决定数据去哪里的,并不是:

复制代码
printf

而是:

fd = 1 最终指向谁。

只要把 1 的指向修改掉:

复制代码
1 ──→ 显示器

变成:

复制代码
1 ──→ log.txt

程序完全不需要修改:

复制代码
printf("hello\n");

输出就会自动进入文件。

这就是重定向的本质。


3.1 dup2

函数:

复制代码
#include <unistd.h>

int dup2(int oldfd, int newfd);

可以理解为:

让 newfd 指向 oldfd 当前指向的那个打开文件对象。

例如:

复制代码
dup2(fd, 1);

原来:

复制代码
1  ──→ 终端

fd ──→ log.txt

调用后:

复制代码
1 ──┐
    ├──→ log.txt
fd ─┘

于是:

复制代码
printf("hello\n");

最终就写进了:

复制代码
log.txt

3.2 输出重定向:覆盖写

完整示例:

复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

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

    if (fd == -1)
    {
        perror("open");
        return 1;
    }

    dup2(fd, STDOUT_FILENO);

    printf("hello Linux\n");
    printf("stdout has been redirected\n");

    close(fd);

    return 0;
}

这里:

复制代码
STDOUT_FILENO

就是:

复制代码
1

3.3 输出重定向:追加写

只需要把:

复制代码
O_TRUNC

换成:

复制代码
O_APPEND

例如:

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

dup2(fd, STDOUT_FILENO);

于是新的输出不会清空旧文件,而是不断追加。


3.4 输入重定向

输入同理。

复制代码
int fd = open("input.txt", O_RDONLY);

dup2(fd, STDIN_FILENO);

之后:

复制代码
scanf();
getchar();

等原本从键盘读取数据的函数,就会转而从:

复制代码
input.txt

读取。

本质仍然没有变化:

复制代码
修改的不是 scanf
而是 fd = 0 指向谁

3.5 2>&1 到底是什么意思?

Shell 中经常看到:

复制代码
./server > server.log 2>&1

先看:

复制代码
> server.log

它相当于把:

复制代码
1 → server.log

然后:

复制代码
2>&1

表示:

让 fd = 2 做和 fd = 1 一样的指向。

于是最后:

复制代码
1 ──┐
    ├──→ server.log
2 ──┘

因此:

复制代码
stdout
stderr

都会进入同一个文件。


顺序非常重要

下面两个命令不是一回事:

复制代码
./server > server.log 2>&1

和:

复制代码
./server 2>&1 > server.log

Shell 会从左向右处理。

第一种:

复制代码
1 → 文件
2 → 当前的 1 → 文件

所以二者都进入文件。

而第二种首先让:

复制代码
2 → 当前的 1

此时 1 还指向终端。

然后才:

复制代码
1 → 文件

所以最终:

复制代码
1 → 文件
2 → 终端

这就是重定向顺序为什么不能乱。


4. 文件名和文件本体为什么能够分开?软硬链接与磁盘文件系统

前面路径解析时已经遇到了:

复制代码
dentry
inode

这里终于可以回答一个非常关键的问题:

Linux 中,文件名到底是不是文件本身的一部分?

答案是:

不是。

目录中维护的核心关系更接近:

复制代码
文件名 → inode number

inode 再去描述真正的文件属性以及文件数据的位置。

理解这一点以后,软链接和硬链接就会非常自然。


4.1 硬链接:多个文件名指向同一个 inode

创建硬链接:

复制代码
ln a.txt b.txt

此时:

复制代码
a.txt ──┐
        ├──→ inode
b.txt ──┘

也就是说:

a.txt 和 b.txt 是两个目录项名称,但最终指向同一个 inode。

可以通过:

复制代码
ls -li

观察 inode number。

例如:

复制代码
12345 a.txt
12345 b.txt

两个名字的 inode 编号相同。


删除一个硬链接为什么文件还在?

例如:

复制代码
rm a.txt

删除的是:

复制代码
a.txt → inode

这一条名字链接。

但是:

复制代码
b.txt → inode

仍然存在。

所以文件本体依然可以通过:

复制代码
b.txt

访问。

因此:

复制代码
删除文件名
≠
立刻删除文件数据

只有当:

复制代码
inode 硬链接计数 == 0

并且:

复制代码
没有进程继续持有打开引用

文件才真正具备释放条件。


4.2 为什么硬链接不能跨文件系统?

硬链接最终依赖:

复制代码
目录项 → inode number

而 inode number 是:

某个具体文件系统内部管理 inode 时使用的编号。

两个不同文件系统:

复制代码
文件系统 A
文件系统 B

各自管理自己的 inode。

所以不能简单让 A 文件系统中的目录项直接指向 B 文件系统内部的 inode。

因此:

硬链接不能跨文件系统。


4.3 为什么普通用户不能随便给目录创建硬链接?

如果目录可以随便互相建立硬链接:

复制代码
A → B
B → A

就可能产生:

复制代码
环

而目录本身还涉及:

复制代码
.
..

等特殊关系。

这会破坏目录树应有的结构,让:

复制代码
遍历
父子关系
引用管理

全部变得异常复杂。

因此 Linux 对目录硬链接有严格限制。


4.4 软链接:保存的是"路径"

创建:

复制代码
ln -s a.txt b.txt

注意:

复制代码
后面的 b.txt

才是新创建的软链接文件。

它的关系不是:

复制代码
b.txt → a.txt 的 inode

而更像:

复制代码
b.txt
 ↓
自己的 inode
 ↓
文件内容中保存 "a.txt" 这个路径

访问:

复制代码
b.txt

时,系统读出:

复制代码
a.txt

然后:

再做一次路径解析。

所以软链接可以理解成:

文件系统里的"快捷方式"。


4.5 为什么删掉目标文件后软链接会悬空?

因为软链接保存的是:

复制代码
路径

例如:

复制代码
a.txt

当:

复制代码
rm a.txt

之后,再通过软链接访问时:

复制代码
读取软链接
   ↓
得到路径 a.txt
   ↓
重新做路径解析
   ↓
找不到

于是形成:

悬空链接 / dangling symbolic link


4.6 为什么软链接可以跨文件系统?

因为它保存的是:

复制代码
路径字符串

而不是:

复制代码
直接引用另一个文件系统的 inode

只要这个路径能够经过正常路径解析找到目标,就可以跨文件系统。


4.7 为什么软链接不能无限套娃?

例如:

复制代码
A → B
B → A

如果内核毫无限制地继续解析:

复制代码
A
↓
B
↓
A
↓
B
↓
...

就会陷入死循环。

因此 Linux 对符号链接解析深度存在限制。

这也是为什么循环软链接最终会报:

复制代码
Too many levels of symbolic links

4.8 磁盘到底怎么保存文件?

上面的 inode、目录、文件内容,最终都必须落在真实磁盘上。

传统机械硬盘曾经可以通过:

复制代码
Cylinder
Head
Sector

进行物理位置描述,也就是经典的:

复制代码
CHS

但现代软件层面更倾向使用:

复制代码
LBA

将底层存储抽象成线性逻辑块:

复制代码
0
1
2
3
4
...

文件系统再建立在这些逻辑块之上。

因此可以把层次理解成:

复制代码
物理存储设备
     ↓
LBA 逻辑块
     ↓
文件系统
     ↓
inode / block / directory
     ↓
文件

4.9 Ext 文件系统为什么采用 Block Group?

如果整个磁盘只用一套全局结构来管理:

复制代码
几百万个 inode
几千万个 block

管理成本会非常高。

Ext 系列文件系统采用:

复制代码
Block Group

分组管理磁盘区域。

一个块组中,可以看到几个非常经典的部分:

复制代码
Block Group
│
├── Super Block
├── Group Descriptor Table
├── Block Bitmap
├── Inode Bitmap
├── Inode Table
└── Data Blocks

不用死记名字,可以从"文件系统到底需要管理什么"出发理解。


Super Block

可以把它理解成:

整个文件系统的说明书。

记录文件系统整体的重要元数据,例如:

复制代码
文件系统规模
block 大小
inode 情况
整体状态
...

GDT:Group Descriptor Table

用于描述各个:

复制代码
Block Group

的管理信息。


Block Bitmap

文件系统必须知道:

复制代码
哪个数据块已经使用?
哪个数据块还是空闲?

因此使用:

复制代码
Block Bitmap

进行管理。

可以粗略想象:

复制代码
0 → 空闲
1 → 已使用

Inode Bitmap

同理:

复制代码
哪个 inode 可以分配?
哪个 inode 已经使用?

由:

复制代码
Inode Bitmap

管理。


Inode Table

真正存放 inode。

前面说过:

复制代码
文件属性 → inode

因此 inode table 就是大量 inode 的集中存放区域。


Data Blocks

真正保存文件内容。

所以磁盘上一个文件最重要的两部分最终就是:

复制代码
属性
 ↓
inode

内容
 ↓
Data Blocks

4.10 目录本身到底保存什么?

目录当然也是文件。

但是目录数据的意义非常特殊。

其核心之一就是保存类似:

复制代码
文件名 → inode number

这样的目录项信息。

例如一个目录中有:

复制代码
a.txt
b.txt
hello

可以粗略理解成内部存在:

复制代码
a.txt  → inode 100
b.txt  → inode 204
hello  → inode 810

所以:

inode 本身并不依赖"文件名"来确定文件身份。

文件名属于目录组织体系的一部分。

这也是硬链接能够成立的根本原因:

复制代码
多个名字
   ↓
同一个 inode

5. Page Cache:为什么 read/write 不会每次都直接碰磁盘?

磁盘非常慢。

如果每调用一次:

复制代码
read()

都必须真正访问磁盘:

复制代码
CPU
 ↓
内存
 ↓
磁盘

整个系统性能会非常糟糕。

所以 Linux 会把已经读取过的文件数据缓存在内存中。

这就是:

Page Cache。


5.1 Page Cache 为什么不能挂在 struct file 上?

先回忆:

复制代码
struct file

表示:

一次打开行为。

比如:

复制代码
int fd1 = open("a.txt", O_RDONLY);
int fd2 = open("a.txt", O_RDONLY);

完全可能产生:

复制代码
fd1 → struct file A
fd2 → struct file B

但是它们打开的明明是:

复制代码
同一个文件

如果 Page Cache 挂在 struct file 上:

复制代码
struct file A → 一份缓存
struct file B → 又一份缓存

那么同一个磁盘文件就会被反复缓存,非常浪费,而且一致性管理也会变得复杂。

所以 Page Cache 更合理的归属应该是:

文件本身。

也就是:

复制代码
inode

5.2 inode、address_space 与 Page Cache

Linux 中可以建立这样一条链:

复制代码
struct inode
      ↓
struct address_space
      ↓
Page Cache

address_space 可以理解为:

管理某个文件内容在内存中缓存映射关系的重要结构。

Page Cache 中的数据按照:

复制代码
文件偏移 / 文件页索引

进行组织。

历史内核实现中经常提到:

复制代码
radix tree

现代 Linux 内核则使用:

复制代码
XArray

并逐渐大量使用:

复制代码
folio

来管理页缓存中的内存数据。

我们这里不继续深挖内核版本实现,只需要抓住核心关系:

复制代码
inode
  ↓
address_space
  ↓
某个文件在内存中的 Page Cache

5.3 struct file 怎么找到 Page Cache?

前面:

复制代码
fd
 ↓
struct file

而 struct file 中又可以通过:

复制代码
f_mapping

访问对应的:

复制代码
address_space

对于普通文件:

复制代码
struct file
    │
    ├── f_pos
    ├── f_flags
    ├── f_op
    ├── f_inode
    └── f_mapping
            ↓
       address_space
            ↓
        Page Cache

于是整个文件读取过程逐渐能够串起来了。


5.4 read 的完整路径

调用:

复制代码
read(fd, buf, size);

首先:

复制代码
fd
 ↓
当前进程 files_struct
 ↓
struct file

从 struct file 中得到:

复制代码
f_pos

确定当前从文件哪个位置读取。

然后找到:

复制代码
f_mapping
 ↓
address_space
 ↓
Page Cache

接下来出现两个情况。


情况一:Page Cache 命中

需要的数据已经在内存。

复制代码
read
 ↓
fd
 ↓
struct file
 ↓
address_space
 ↓
Page Cache
 ↓
命中
 ↓
拷贝给用户

完全不需要再次读取磁盘。

速度非常快。


情况二:Page Cache 未命中

如果所需文件页不在缓存中:

复制代码
Page Cache miss
        ↓
访问磁盘文件系统
        ↓
读取对应数据块
        ↓
装入 Page Cache
        ↓
再把数据返回用户

于是:

复制代码
第一次读取
→ 可能较慢

后续再次读取
→ 很可能直接命中内存

这就是文件缓存存在的重要意义。


5.5 write 的完整路径

普通 buffered write 的逻辑同样可以串起来。

复制代码
write(fd, buf, size);

先通过:

复制代码
fd
 ↓
struct file
 ↓
f_pos

确定写入位置。

然后:

复制代码
f_mapping
 ↓
address_space
 ↓
Page Cache

把用户缓冲区中的数据复制进相应的缓存页。

于是:

复制代码
用户 buf
    ↓
Page Cache

被修改的页面会被标记:

复制代码
dirty

也就是:

内存中的数据已经修改,但磁盘版本还没有同步更新。

之后:

复制代码
write()

便可能先返回。

后台再通过:

复制代码
writeback

机制把脏页写回磁盘。

完整关系:

复制代码
用户 buf
   ↓
write
   ↓
Page Cache
   ↓
dirty page
   ↓
write 返回
   ↓
后台 writeback
   ↓
文件系统
   ↓
磁盘

现在就能真正理解前面的那句话:

write 成功,不代表数据此刻已经物理落盘。


5.6 为什么要延迟写?

因为:

复制代码
内存速度 >>> 磁盘速度

如果每次:

复制代码
write()

都必须等待磁盘真正完成:

复制代码
4KB
4KB
4KB
4KB

大量碎片化 IO 会严重影响性能。

有了 Page Cache 后,可以:

复制代码
先写内存
    ↓
继续运行程序
    ↓
积累脏页
    ↓
系统统一 writeback

这样可以:

  • 减少频繁磁盘访问

  • 合并多个写操作

  • 提高整体吞吐量

这就是典型的:

用内存换 IO 性能。


5.7 O_SYNC 是干什么的?

如果打开文件时使用:

复制代码
O_SYNC

则要求写操作具有更强的同步语义。

也就是:

write 不再只是简单把数据扔进缓存就立即结束,而是需要等待相应同步要求完成后才能返回。

代价很明显:

复制代码
安全性 / 持久化保证增强
        ↑
性能下降

因此:

复制代码
普通高吞吐写入

和:

复制代码
强持久化要求

之间始终存在取舍。

数据库、日志系统等场景之所以经常研究:

复制代码
fsync
fdatasync
O_SYNC

本质上就是在研究:

数据什么时候才真正算"可靠保存"。


6. 最后回到 task_struct:把整个文件系统真正串成一张图

学到这里,再看:

复制代码
task_struct

就不再是一个孤零零的 PCB 结构体了。

它实际上把:

复制代码
进程
文件描述符
当前目录
根目录
umask
打开文件
VFS
inode
Page Cache

全部连接了起来。


6.1 files_struct:进程已经打开了哪些文件?

第一条链:

复制代码
task_struct
     ↓
   files
     ↓
files_struct
     ↓
fd_array / fdtable
     ↓
struct file*

所以:

复制代码
read(fd, ...);

第一步并不是"去磁盘找 fd"。

而是:

复制代码
当前进程
   ↓
files_struct
   ↓
根据 fd 下标
   ↓
找到 struct file

fd 的作用,到这里就非常清晰了:

它是当前进程访问打开文件对象的索引入口。


6.2 struct file:描述一次打开行为

复制代码
struct file
│
├── f_pos
│     └── 当前读写偏移
│
├── f_flags
│     └── O_RDONLY / O_APPEND / ...
│
├── f_op
│     └── 这个文件支持哪些具体操作
│
├── f_inode
│     └── 文件本身对应的 inode
│
└── f_mapping
      └── address_space
              ↓
          Page Cache

这里必须牢牢记住:

同一个文件多次 open,可以有多个不同的 struct file。

因此:

复制代码
struct file

更偏向:

打开状态。

而:

复制代码
inode

更偏向:

文件本体。


6.3 fs_struct:当前进程站在文件系统的哪里?

另一条重要链:

复制代码
task_struct
     ↓
     fs
     ↓
 fs_struct

其中包括:

复制代码
pwd
root
umask

pwd

表示:

复制代码
当前工作目录

用于相对路径解析。


root

表示:

复制代码
当前进程看到的根路径

用于绝对路径解析。


umask

创建文件时参与最终权限计算。


6.4 pwd / root 为什么最终都是 struct path?

因为路径不能只知道:

复制代码
某个目录是谁

还需要知道:

复制代码
它属于哪个挂载实例

所以:

复制代码
pwd
 ↓
struct path
│
├── mnt
└── dentry

同理:

复制代码
root
 ↓
struct path
│
├── mnt
└── dentry

6.5 dentry 最核心的几个关系

可以抓住:

复制代码
dentry
│
├── d_name
│     └── 当前名字
│
├── d_parent
│     └── 父目录
│
└── d_inode
      └── 文件 inode

于是:

复制代码
名字
 ↓
dentry
 ↓
inode

如果对应 dentry 已经存在于:

复制代码
dcache

路径查找可以直接利用缓存。

否则需要通过具体文件系统的:

复制代码
inode_operations

继续寻找。

例如目录查找时常会涉及:

复制代码
lookup()

创建目录项或文件时,则可能涉及:

复制代码
create()

等操作。

注意:

dcache 是整个 VFS 对 dentry 的缓存机制,并不是简单理解成 dentry 中某一个叫 dcache 的字段。


6.6 从 open 开始,把整条链完整走一遍

现在假设程序执行:

复制代码
int fd = open("/home/user/a.txt", O_RDWR);

到底发生什么?

可以沿着这条主线理解。


第一步:确定路径解析起点

因为:

复制代码
/home/user/a.txt

是绝对路径。

所以从:

复制代码
task_struct
 ↓
fs_struct
 ↓
root

开始。

如果是:

复制代码
./a.txt

则从:

复制代码
pwd

开始。


第二步:逐级解析路径

复制代码
/
↓
home
↓
user
↓
a.txt

解析过程中会使用:

复制代码
dentry
dcache
inode
mount

等 VFS / 文件系统对象。

最终找到目标文件对应的:

复制代码
inode

第三步:建立打开文件对象

内核为这一次打开行为建立:

复制代码
struct file

其中记录:

复制代码
f_pos
f_flags
f_op
f_inode
f_mapping
...

第四步:分配 fd

找到当前进程:

复制代码
fdtable

中最小的空闲位置。

例如:

复制代码
3

然后:

复制代码
fdtable[3]
    ↓
struct file

最后:

复制代码
open()

把:

复制代码
3

返回给应用程序。


6.7 再走一次 read

程序:

复制代码
read(fd, buf, size);

完整路径:

复制代码
fd
 ↓
files_struct
 ↓
fdtable
 ↓
struct file
 ↓
f_pos
 ↓
f_mapping
 ↓
address_space
 ↓
Page Cache

如果命中:

复制代码
Page Cache
 ↓
直接拷贝到用户 buf

如果未命中:

复制代码
Page Cache
 ↓
磁盘文件系统
 ↓
Data Blocks
 ↓
加载进 Page Cache
 ↓
返回用户

6.8 再走一次 write

复制代码
write(fd, buf, size);

典型路径:

复制代码
fd
 ↓
struct file
 ↓
f_pos
 ↓
f_mapping
 ↓
address_space
 ↓
Page Cache
 ↓
修改缓存页
 ↓
dirty
 ↓
write 返回
 ↓
writeback
 ↓
磁盘

至此:

复制代码
open
read
write
fd
struct file
inode
Page Cache
文件系统

已经不是七八个独立知识点,而是一条完整链路。


6.9 一张图收掉整篇文章

最后把整篇最重要的关系压成一张图。

复制代码
                          task_struct
                         /           \
                        /             \
                       ↓               ↓
                files_struct       fs_struct
                     │            /    |    \
                     │           /     |     \
                  fdtable      pwd    root   umask
                     │          │      │
                     │          └──┬───┘
                     │             ↓
                     │        struct path
                     │        /         \
                     │       ↓           ↓
                     │      mnt        dentry
                     │                  │
                     │          ┌───────┼────────┐
                     │          ↓       ↓        ↓
                     │       d_name  d_parent  d_inode
                     │                           │
                     │                           ↓
fd ──────────────────┘                       struct inode
 ↓                                             │
struct file                                    │
│                                              │
├── f_pos                                      │
├── f_flags                                    │
├── f_op                                       │
├── f_inode ───────────────────────────────────┘
└── f_mapping
       ↓
  address_space
       ↓
    Page Cache
       ↓
   文件系统
       ↓
inode table / data blocks
       ↓
      磁盘

如果还要再压缩,可以记成:

复制代码
进程如何找到已打开文件?

task_struct
→ files_struct
→ fdtable
→ fd
→ struct file

路径如何找到文件?

task_struct
→ fs_struct
→ root / pwd
→ struct path
→ mnt + dentry
→ inode

文件内容如何进入内存?

inode
→ address_space
→ Page Cache

最终如何落到磁盘?

Page Cache
→ 文件系统
→ inode / Data Blocks
→ 磁盘

这四条链如果真正能够在脑子里连起来,Linux 文件系统最重要的骨架基本就建立起来了。


7. 几个非常值得记住的面试结论

最后不重新复读整篇,只把真正容易被问、也容易答错的点压一下。

① fd 的本质是什么?

当前进程文件描述符表中的下标,通过它能够找到对应的 struct file。


② 为什么第一次 open 经常返回 3?

因为:

复制代码
0 stdin
1 stdout
2 stderr

通常已经被占用。

而 fd 按:

复制代码
当前最小未使用描述符

分配。


③ struct file 和 inode 有什么区别?

复制代码
struct file
→ 描述一次打开行为

inode
→ 描述文件本身

因此同一个文件多次 open:

复制代码
可以产生多个 struct file

但它们:

复制代码
可以共同指向同一个 inode

④ 重定向的本质是什么?

改变文件描述符与打开文件对象之间的对应关系。

dup2 的核心不是修改 printf,而是修改:

复制代码
stdout(fd=1)

到底指向谁。


⑤ write 返回成功是否意味着已经写入磁盘?

不一定。

普通 buffered I/O 通常先写入:

复制代码
Page Cache

之后再通过:

复制代码
writeback

真正落盘。


⑥ fflush 和 fsync 是一回事吗?

不是。

复制代码
fflush
→ 刷 libc 用户级缓冲区

fsync
→ 请求同步内核中的文件数据到持久化存储

这是非常典型的混淆点。


⑦ Page Cache 为什么不挂在 struct file 上?

因为:

复制代码
同一个文件

可能:

复制代码
被 open 多次

产生多个:

复制代码
struct file

但文件缓存应该属于:

复制代码
文件本身

因此通过:

复制代码
inode
→ address_space
→ Page Cache

统一组织。


⑧ 硬链接和软链接最本质的区别?

硬链接:

复制代码
不同文件名
   ↓
同一个 inode

软链接:

复制代码
独立 inode
   ↓
文件内容保存目标路径
   ↓
访问时再次路径解析

⑨ 文件什么时候才真正被删除?

典型情况下需要:

复制代码
硬链接计数 == 0
&&
没有进程继续持有打开引用

所以:

复制代码
rm

不一定意味着某个正在使用该文件的进程立即失去访问能力。


⑩ 相对路径和绝对路径分别从哪开始?

复制代码
相对路径
→ fs_struct->pwd

绝对路径
→ fs_struct->root

🌙 写在最后

文件系统这一块最容易掉进一个坑:

复制代码
open 背一个
read 背一个
write 背一个
inode 背一个
dentry 背一个
Page Cache 再背一个

背到最后,每一个词都见过,但真正让你解释:

执行一次 read(fd, buf, size)****,数据到底是怎么从文件来到进程里的?

脑子里还是一片散的。

真正有价值的理解应该是:

复制代码
fd
↓
struct file
↓
inode / address_space
↓
Page Cache
↓
文件系统
↓
磁盘

以及另一条:

复制代码
pathname
↓
root / pwd
↓
dentry
↓
inode
↓
struct file
↓
fd

当这两条链真正接起来以后:

open 不再只是一个函数,

fd 不再只是一个整数,

inode 不再只是一个八股名词,

Page Cache 也不再是一句"为了提高效率"。

它们终于变成了同一个 Linux 文件系统故事里的不同角色。


🧭 Linux 的魅力,就在这种"看起来只是一个接口,背后却连着整个操作系统"的地方。

今天这把文件系统的剑,就练到这里。

👀 关注 :后续继续沿着 Linux 系统与网络这棵大树向下挖

❤️ 点赞 :如果这篇文章真的帮你把文件系统串起来了

⭐ 收藏 :面试前重新顺一遍 fd → file → inode → Page Cache

💬 评论:有哪一段仍然感觉断,我也很想知道大家最容易卡在哪

代码不是背出来的,系统也不是八股拼出来的。把关系真正串起来,很多东西自然就记住了。


🐾 今日份 Linux 练级完成

复制代码
文件基础认知        ✅
open/read/write      ✅
fd / struct file     ✅
用户级缓冲区         ✅
fork 缓冲问题        ✅
重定向               ✅
软硬链接             ✅
Ext 文件系统         ✅
inode / dentry       ✅
Page Cache           ✅
task_struct 总串联   ✅

下一次再看到"Linux 一切皆文件",脑子里应该已经不只剩这一句话了。

相关推荐
大侠归来1 小时前
cJSON 源码解析:parse_string 函数深入剖析
linux·网络·算法
小小、码农1 小时前
〖Linux进程间通信〗IPC全家桶:匿名管道、FIFO、共享内存、消息队列与信号量一篇打通
linux·运维·服务器
阳光九叶草LXGZXJ1 小时前
达梦数据库-学习-67-SSL加密认证
linux·运维·数据库·sql·学习·ssl
弹简特1 小时前
【Java项目-企悦抽】13-奖品管理模块-奖品列表和创建奖品的实现
java·开发语言·网络·springboot
Rosanci1 小时前
从零到合入主线:我的 DeepSeek Harness 开源贡献实战手记
开发语言·前端·开源
東隅已逝,桑榆非晚1 小时前
priority_queue的介绍和使用
c++·经验分享·笔记·学习
弹简特1 小时前
【Java项目-企悦抽】14-活动管理模块01-创建活动的实现
java·开发语言·springboot
迅猛龙办公室1 小时前
Python实现求两个数字之和
java·开发语言·python
茉莉玫瑰花茶1 小时前
OpenGL [ 纹理 ]
开发语言·c++·opengl