struct file 不是磁盘文件:一次打开实例和共享偏移

struct file 不是磁盘文件:一次打开实例和共享偏移

上一篇已经完成两件事:

  1. 沿着 current->files -> fdtable->fd[fd] 找到 struct file
  2. 通过完整实验确认 open()dup()、共享偏移、两类 flags 和 unlink() 的可见行为。

上一篇已经得到的关系不在本文重新证明:

操作 fdtable struct file f_pos / f_flags
再次 open() 同一路径 增加新槽位 创建新 file 独立
dup() 增加新槽位 指向原 file 共享
fork() 子进程复制 fdtable 对应槽位指向原 file 共享
线程 / CLONE_FILES 直接共享 fdtable 自然看到相同表项 共享

本文从这些现象继续向内核实现追问:

  • struct file 的完整字段分别负责什么?
  • 为什么 f_pos 可以共享,又怎样避免并发更新相互覆盖?
  • FD_CLOEXECO_APPEND 为什么存放在不同对象中?
  • dup()fork() 在源码中复制的究竟是哪一层引用?
  • close() 为什么先让 fd 失效,最后一个引用消失时才调用 release
  • unlink() 删除名字后,打开实例和 inode 为什么还能继续存在?

核心结论仍然只有一句:

struct file 描述一次打开实例,也就是 POSIX 所说的 open file description;它保存打开后的共享状态和操作入口,不是路径名,也不是 inode,更不是磁盘上的文件格式。

一、Linux v6.12.65 的完整 struct file

下面是 Linux v6.12.65struct file 的完整结构声明。它依赖内核头文件中的类型、配置宏和属性,不是可以脱离内核单独编译的用户态程序。

c 复制代码
struct file {
	atomic_long_t			f_count;
	spinlock_t			f_lock;
	fmode_t				f_mode;
	const struct file_operations	*f_op;
	struct address_space		*f_mapping;
	void				*private_data;
	struct inode			*f_inode;
	unsigned int			f_flags;
	unsigned int			f_iocb_flags;
	const struct cred		*f_cred;
	/* --- cacheline 1 boundary (64 bytes) --- */
	struct path			f_path;
	union {
		/* regular files (with FMODE_ATOMIC_POS) and directories */
		struct mutex		f_pos_lock;
		/* pipes */
		u64			f_pipe;
	};
	loff_t				f_pos;
#ifdef CONFIG_SECURITY
	void				*f_security;
#endif
	/* --- cacheline 2 boundary (128 bytes) --- */
	struct fown_struct		*f_owner;
	errseq_t			f_wb_err;
	errseq_t			f_sb_err;
#ifdef CONFIG_EPOLL
	struct hlist_head		*f_ep;
#endif
	union {
		struct callback_head	f_task_work;
		struct llist_node	f_llist;
		struct file_ra_state	f_ra;
		freeptr_t		f_freeptr;
	};
	/* --- cacheline 3 boundary (192 bytes) --- */
} __randomize_layout
  __attribute__((aligned(4)));	/* lest something weird decides that 2 is OK */

__randomize_layout 和条件编译意味着实际布局不是面向用户态的稳定 ABI。理解字段的语义和引用关系是必要的,但不能让用户程序依赖某个字段偏移。

1.1 按职责分组

这五组分别回答:

text 复制代码
目标身份:这次打开最终关联了谁?
操作能力:这个已打开对象允许执行什么、调用哪组实现?
打开状态:这一次打开当前进行到哪里、使用什么状态?
身份与错误:以什么凭据打开、怎样记录异步通知和错误?
生命周期:还有谁持有它、最后怎样释放?

1.2 每个字段的角色

字段 作用 需要避免的误解
f_count struct file 的内核引用计数 不是用户态 fd 数量;临时内核引用也会计入
f_lock 保护 f_flags、epoll hooks 等内部状态 不负责保护共享位置,位置使用 f_pos_lock
f_mode 内核计算出的 FMODE_* 能力与内部模式 不等同于用户传给 open()O_* flags
f_op 本次打开最终使用的 file_operations 后续 read/write/poll/ioctl 从这里分派
f_mapping 可缓存对象对应的 address_space 不是进程虚拟地址空间
private_data 文件系统、驱动或伪文件的每次打开私有状态 同一个 inode 的不同 file 可以拥有不同值
f_inode 缓存的目标 inode 指针 inode 描述对象,不保存这次打开独有的偏移
f_flags file status flags O_APPENDO_NONBLOCK 等共享状态位于这里
f_iocb_flags 构造 I/O control block 时使用的内部 flags 不是 fd flags
f_cred 创建或打开该 file 时保存的凭据 后续操作不一定只看调用线程此刻的凭据
f_path mount + dentry 组成的路径引用 unlink 名字不会直接清除已打开 file 的这份引用
f_pos_lock 必要时串行化共享文件位置操作 内核按条件使用,不是所有 I/O 都无条件加锁
f_pipe pipe 对同一个 union 的专用解释 pipe 没有普通文件那样的 seek 位置
f_pos 当前文件位置 属于打开实例,不属于 fd 槽位
f_security LSM 保存的 file 安全上下文 只有相应配置启用时存在
f_owner 异步通知等机制使用的 owner 状态 不是 inode 的 uid/gid 所有者
f_wb_err 该 file 对 mapping writeback 错误的观察状态 服务于异步回写错误报告
f_sb_err superblock 级错误的观察状态 同样不是普通系统调用返回值缓存
f_ep 与该 file 关联的 epoll hooks 只有启用 epoll 配置时存在
最后一个 union 在 readahead、task work、延迟 fput 和 slab 释放阶段复用存储 不同成员在不同生命周期阶段解释

1.3 f_modef_flags 解决不同问题

f_mode 表示内核已经建立的能力和内部状态:

text 复制代码
FMODE_READ        允许通过这个 file 读
FMODE_WRITE       允许通过这个 file 写
FMODE_LSEEK       支持定位
FMODE_ATOMIC_POS  位置操作需要满足原子性要求
FMODE_STREAM      按没有普通文件位置的数据流处理
FMODE_PATH        这是 O_PATH 得到的路径句柄

f_flags 保存本次打开使用的 file status flags:

text 复制代码
O_APPEND      追加写语义
O_NONBLOCK    非阻塞语义
O_DIRECT      direct I/O 请求
O_SYNC        同步写语义

用户传给 open() 的 flags 会参与构造两者,但两者不是同一份原始参数。F_SETFL 也只能修改规定的那部分 file status flags,不能在打开后随意重写访问模式或所有打开选项。

1.4 与用户态 FILE * 的边界

text 复制代码
用户态 FILE *  -> libc 缓冲、EOF、错误状态
       │ fileno()
       ▼
整数 fd        -> fdtable 槽位
       │ 系统调用
       ▼
struct file    -> 内核打开实例

fwrite() 可能只修改 libc 缓冲;内核 struct file 不知道这个缓冲中还有多少数据。三个名字相似,但处于三个不同层次。

二、共享 f_pos 的实现与串行化

上一篇已经用 AB/CD/AB 的读取结果证明:dup() 得到的两个 fd 共享位置,第二次 open() 得到的 file 拥有独立位置。

从内核对象看,原因不是"两个偏移被同步",而是根本只有一个值:

text 复制代码
fd[original]  ─┐
               ├─> struct file A -> f_pos
fd[duplicate] ─┘

fd[independent] ──> struct file B -> 另一个 f_pos

2.1 隐式位置与显式位置

read()write()lseek() 使用打开实例的当前位置,因此共享同一 file 的调用者会互相观察到位置变化。

pread()pwrite() 由调用者显式传入 offset,不以 f_pos 作为本次位置,也不在成功后推进它。它们仍然经过 VFS、权限检查、具体文件操作和其他 flags 语义,不能理解为"绕过 struct file"。

2.2 为什么共享位置需要锁

如果两个执行流同时对同一个 f_pos 做普通读取,真正需要保护的不是最后那次赋值,而是整个读改写过程:

text 复制代码
读取旧 f_pos
    ↓
以旧位置执行 I/O
    ↓
根据实际传输量计算新位置
    ↓
写回 f_pos

没有串行化时,两个执行流可能读取相同旧值,对同一范围执行 I/O,再互相覆盖更新结果。

Linux v6.12.65 中,决定是否锁位置以及取得位置锁的两个完整函数如下:

c 复制代码
static inline bool file_needs_f_pos_lock(struct file *file)
{
	return (file->f_mode & FMODE_ATOMIC_POS) &&
		(file_count(file) > 1 || file->f_op->iterate_shared);
}

struct fd fdget_pos(unsigned int fd)
{
	struct fd f = fdget(fd);
	struct file *file = fd_file(f);

	if (file && file_needs_f_pos_lock(file)) {
		f.word |= FDPUT_POS_UNLOCK;
		mutex_lock(&file->f_pos_lock);
	}
	return f;
}

判断条件分成两部分:

  1. file 带有 FMODE_ATOMIC_POS,位置操作需要满足相应的原子性语义。
  2. file 可能经由多个引用访问,或者操作表提供共享目录迭代。

需要加锁时,fdget_pos() 锁住 f_pos_lock,并在临时 struct fd 中记录 FDPUT_POS_UNLOCK。配对的 fdput_pos() 才能知道退出时还要解锁。

file_count(file) > 1 不能被反推成"用户一定拥有两个 fd"。f_count 是内核引用计数,系统调用的临时引用、fork 复制和其他内核持有者都可能影响它。

三、fd flags 与 file status flags 的内部边界

3.1 存储位置决定共享规则

用户操作 内核状态位置 dup() 后是否共享
F_GETFD / F_SETFD 操作的 FD_CLOEXEC fdtable 的 close_on_exec 位图
F_GETFL / F_SETFL 操作的可变状态 flags struct file->f_flags
read/write/lseek 使用的当前位置 struct file->f_pos

因此,判断某个状态是否共享时,不要从系统调用名字猜,要看它最终存在哪个对象中。

3.2 O_CLOEXEC 为什么不成为共享的 f_flags

O_CLOEXEC 是创建 fd 时原子设置该槽位 FD_CLOEXEC 的请求。open 参数整理阶段会把它从真正进入 file->f_flags 的 flags 中剥离,fd 分配路径则用它设置 close_on_exec 位。

它解决多线程程序中的竞态:

text 复制代码
有窗口的两步:
open() 得到 fd
fcntl(F_SETFD, FD_CLOEXEC)

原子的一步:
open(..., O_CLOEXEC)

如果另一个线程在两步之间执行 fork()+execve(),新程序可能意外继承这个 fd。O_CLOEXEC 让"创建槽位"和"标记 exec 时关闭"成为同一次 fd 创建操作。

操作 新槽位的 FD_CLOEXEC
open() 不带 O_CLOEXEC 默认关闭
open()O_CLOEXEC 开启
dup(oldfd) 默认关闭,即使 oldfd 开启
dup3(oldfd, newfd, O_CLOEXEC) 开启
fork() 子 fdtable 复制父表当时的各个位
execve() 关闭标记了 FD_CLOEXEC 的槽位

四、dup()fork() 和线程在哪一层建立共享

4.1 dup() 移交的是同一个 file 引用

Linux v6.12.65dup() 系统调用的完整函数如下:

c 复制代码
SYSCALL_DEFINE1(dup, unsigned int, fildes)
{
	int ret = -EBADF;
	struct file *file = fget_raw(fildes);

	if (file) {
		ret = get_unused_fd_flags(0);
		if (ret >= 0)
			fd_install(ret, file);
		else
			fput(file);
	}
	return ret;
}

引用所有权是理解这段代码的关键:

  1. fget_raw() 找到原 file 并取得一份引用,没有 pathname walk,也没有分配新 file。
  2. get_unused_fd_flags(0) 分配新槽位;参数为 0,所以新槽位不设置 FD_CLOEXEC
  3. fd_install() 把同一个指针和刚取得的引用移交给新槽位。
  4. 如果槽位分配失败,fput() 归还刚才取得的引用。

dup2() / dup3() 还要处理指定目标、原子替换、oldfd == newfdO_CLOEXEC 和并发预留槽位,但最终安装的仍然是源 file 的引用。

4.2 fork 复制表,CLONE_FILES 共享表

这两种共享不能混为一谈:

场景 files_struct / fdtable file 引用
dup() 同一张表增加槽位 新旧槽位各持有同一个 file
fork() 子进程复制出另一张表 复制的槽位增加并持有原 file 引用
CLONE_FILES task 共享同一个 files_struct 共同使用同一批槽位
再次 open() 分配新槽位 槽位持有新 file

fork 后,子进程 close(3) 只清除子 fdtable 的第 3 项;父槽位仍然存在。但两边槽位都存在时,它们指向同一个 file,所以共享 f_posf_flags

线程共享 fdtable 时则只有一个 fd[3]。线程 T1 清除这个槽位后,T2 之后重新使用整数 3 发起的新调用也看不到旧 file。

这里必须区分"已经取得 file 引用的 I/O"和"之后重新用 fd 查表":

text 复制代码
T2 已经 fdget() 并开始 I/O
T1 随后 close(fd)

T2 正在进行的 I/O:
依靠临时 file 引用继续

T2 之后的新系统调用:
重新查 fdtable,可能得到 EBADF
如果槽位已复用,甚至可能命中另一个对象

五、close():先结束槽位,最后才结束 file

5.1 完整的 close 系统调用入口

Linux v6.12.65close 的完整系统调用函数如下:

c 复制代码
SYSCALL_DEFINE1(close, unsigned int, fd)
{
	int retval;
	struct file *file;

	file = file_close_fd(fd);
	if (!file)
		return -EBADF;

	retval = filp_flush(file, current->files);

	/*
	 * We're returning to user space. Don't bother
	 * with any delayed fput() cases.
	 */
	__fput_sync(file);

	/* can't restart close syscall because file table entry was cleared */
	if (unlikely(retval == -ERESTARTSYS ||
		     retval == -ERESTARTNOINTR ||
		     retval == -ERESTARTNOHAND ||
		     retval == -ERESTART_RESTARTBLOCK))
		retval = -EINTR;

	return retval;
}

入口按顺序处理三个不同层次:

  1. file_close_fd(fd) 从 fdtable 取出 file,并先清空这个 fd 槽位。
  2. filp_flush() 执行这一次 close 所需的 flush、锁清理等工作。
  3. __fput_sync() 归还槽位持有的 file 引用;如果这是最后一份引用,就进入最终释放。

fd 槽位在 flush 和最终释放之前已经清空,所以即使后续工作报告错误,也不能简单重试 close(fd)。另一个线程可能已经把相同的 fd 数字分配给新对象。

5.2 引用释放的完整主线

假设两个槽位来自 dup()

text 复制代码
初始:
fd[3] ─┐
       ├─> struct file A
fd[7] ─┘

close(3):
fd[3] -> 空
fd[7] -> struct file A

close(7) 且没有其他引用:
struct file A 才进入最终 __fput

5.3 flushrelease 不是同一个时机

回调 生命周期位置 file 是否可能仍有其他引用
flush(file, id) 一个 fd 的 close 路径 可能
release(inode, file) file 最后一份引用释放时 不再有普通持有者继续使用该打开实例

因此,同一个 file 被 dup() 后,关闭其中一个 fd 可以进入一次 close/flush 处理,但不能因此调用最终 release 并销毁 file。

具体文件系统或驱动可以不实现其中一个或两个回调。VFS 定义生命周期位置,具体实现决定是否需要动作。

f_count 也不能被理解成"每个用户 fd 固定贡献 1,除此之外没有别的引用"。fork 复制、正在执行的系统调用、内核子系统之间传递 file 等都可能持有引用。可靠边界只有一个:

最后一份内核引用消失之前,struct file 不能被释放。

六、unlink()close() 解除不同层次的引用

上一篇已经用 /proc/self/fd/N 中的 (deleted) 和仍可读取的内容验证了 unlink 后继续访问。本节只解释为什么。

6.1 名字引用与打开引用

两类操作改变的对象不同:

text 复制代码
unlink:
解除"父目录中的名字 -> inode"链接
减少 inode 的 i_nlink

close/fput:
解除"fd 或内核持有者 -> struct file"引用
最后再释放 file 持有的 path、inode 相关引用

最后一个硬链接消失,只说明对象不再能通过原目录名字找到;已打开 file 仍持有访问所需的路径和 inode 相关引用。

6.2 什么时候才具备回收条件

对典型本地文件系统,可以先使用下面的普通心智模型:

text 复制代码
i_nlink > 0:
至少还有目录名字引用 inode

i_nlink == 0,但仍有打开 file:
没有名字,旧 fd 仍可使用

i_nlink == 0,打开引用也结束:
文件系统具备最终回收 inode 和数据的条件

最后一句是"具备条件",不是对物理回收时刻的绝对承诺。mmap、page cache、日志、延迟删除和具体文件系统内部状态都可能让实际回收晚于最后一次用户态 close()

dentry、inode、mount 和 superblock 的详细对象关系由下一篇继续展开;本文只需要确认名字生命周期与打开实例生命周期相互独立。

七、完整 fork 实验:表独立,file 共享

上一篇的完整实验已经验证独立 open()dup()、两类 flags 和 unlink。本篇换成一个不重复的 fork 专项实验。

实验同时验证:

  1. 子进程先读取两个字节,父进程随后从共享位置继续读取。
  2. 子进程修改 O_APPEND,父进程从共享 file 观察到变化。
  3. 子进程关闭自己的 fd 后,父 fdtable 中的槽位仍然有效。

完整源码位于 assets/fork-file-sharing-demo.c,正文贴出全部头文件、错误处理、同步、断言和资源释放:

c 复制代码
#define _GNU_SOURCE

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

static void die(const char *what)
{
    perror(what);
    exit(EXIT_FAILURE);
}

static void write_all(int fd, const void *buffer, size_t length)
{
    const char *p = buffer;

    while (length > 0) {
        ssize_t n = write(fd, p, length);

        if (n < 0) {
            if (errno == EINTR)
                continue;
            die("write");
        }
        if (n == 0) {
            fprintf(stderr, "write returned zero\n");
            exit(EXIT_FAILURE);
        }
        p += n;
        length -= (size_t)n;
    }
}

static void read_exact(int fd, char *buffer, size_t length)
{
    size_t done = 0;

    while (done < length) {
        ssize_t n = read(fd, buffer + done, length - done);

        if (n < 0) {
            if (errno == EINTR)
                continue;
            die("read");
        }
        if (n == 0) {
            fprintf(stderr, "unexpected EOF\n");
            exit(EXIT_FAILURE);
        }
        done += (size_t)n;
    }
    buffer[length] = '\0';
}

static void wait_for_success(pid_t child)
{
    int status;
    pid_t result;

    do {
        result = waitpid(child, &status, 0);
    } while (result < 0 && errno == EINTR);

    if (result < 0)
        die("waitpid");
    if (!WIFEXITED(status) || WEXITSTATUS(status) != EXIT_SUCCESS) {
        fprintf(stderr, "child failed: status=0x%x\n", status);
        exit(EXIT_FAILURE);
    }
}

int main(void)
{
    char path[] = "/tmp/fork-file-sharing-XXXXXX";
    char child_bytes[3];
    char parent_bytes[3];
    int report_pipe[2];
    int shared_fd;
    int status_flags;
    off_t position;
    pid_t child;

    shared_fd = mkstemp(path);
    if (shared_fd < 0)
        die("mkstemp");

    write_all(shared_fd, "ABCDEFGHIJ", 10);
    if (lseek(shared_fd, 0, SEEK_SET) < 0)
        die("lseek start");

    if (pipe(report_pipe) < 0)
        die("pipe");

    child = fork();
    if (child < 0)
        die("fork");

    if (child == 0) {
        if (close(report_pipe[0]) < 0)
            die("child close read end");

        read_exact(shared_fd, child_bytes, 2);
        write_all(report_pipe[1], child_bytes, 2);

        status_flags = fcntl(shared_fd, F_GETFL);
        if (status_flags < 0)
            die("child F_GETFL");
        if (fcntl(shared_fd, F_SETFL, status_flags | O_APPEND) < 0)
            die("child F_SETFL");

        if (close(shared_fd) < 0)
            die("child close shared fd");
        if (close(report_pipe[1]) < 0)
            die("child close write end");
        _exit(EXIT_SUCCESS);
    }

    if (close(report_pipe[1]) < 0)
        die("parent close write end");

    wait_for_success(child);
    read_exact(report_pipe[0], child_bytes, 2);
    if (close(report_pipe[0]) < 0)
        die("parent close read end");

    read_exact(shared_fd, parent_bytes, 2);
    position = lseek(shared_fd, 0, SEEK_CUR);
    if (position < 0)
        die("lseek current");

    status_flags = fcntl(shared_fd, F_GETFL);
    if (status_flags < 0)
        die("parent F_GETFL");

    printf("child read before close       = %s\n", child_bytes);
    printf("parent read after child close = %s\n", parent_bytes);
    printf("shared file position          = %lld\n",
           (long long)position);
    printf("parent observes O_APPEND      = %d\n",
           !!(status_flags & O_APPEND));
    printf("parent fd remained valid      = yes\n");

    if (strcmp(child_bytes, "AB") != 0 ||
        strcmp(parent_bytes, "CD") != 0 ||
        position != 4 ||
        !(status_flags & O_APPEND)) {
        fprintf(stderr, "unexpected sharing semantics\n");
        exit(EXIT_FAILURE);
    }

    if (close(shared_fd) < 0)
        die("parent close shared fd");
    if (unlink(path) < 0)
        die("unlink");

    return EXIT_SUCCESS;
}

7.1 编译与运行

在仓库根目录执行:

sh 复制代码
docker run --rm --platform linux/amd64 \
  -v "$PWD/os/filesystem/assets:/src:ro" \
  gcc:13 \
  sh -c 'gcc -O2 -Wall -Wextra -Werror -std=c11 \
    /src/fork-file-sharing-demo.c -o /tmp/fork-demo &&
    /tmp/fork-demo'

实际输出:

text 复制代码
child read before close       = AB
parent read after child close = CD
shared file position          = 4
parent observes O_APPEND      = 1
parent fd remained valid      = yes

7.2 为什么使用 pipe 和 waitpid()

实验要证明共享位置,必须确定子进程先读、父进程后读。仅仅在 fork 后让父子各自调用 read(),调度顺序不确定,父进程也可能先读到 AB

程序用两种机制建立确定顺序:

  • 子进程把自己读到的两个字节写入 report_pipe,父进程因此可以取得子进程的真实结果。
  • 父进程用 waitpid() 等到子进程完成读取、设置 flags、关闭 fd 并退出,之后才读取共享 file。

pipe 在这里只负责报告和同步,不是被测试的文件内容。

7.3 输出怎样对应两层共享关系

fork 完成后的初始状态:

text 复制代码
父 fdtable:shared_fd -> struct file A
子 fdtable:shared_fd -> struct file A

两张 fdtable 相互独立;
两个槽位持有同一个 file A。

子进程先读取 AB,使 file A 的 f_pos 从 0 变成 2;它再设置 file A 的 O_APPEND,最后只清除子 fdtable 中的槽位。

父进程等待子进程退出后:

  • 仍能使用父 fdtable 中的 shared_fd,说明子 close 没有清除父槽位。
  • 读取到 CD 而不是 AB,说明父子共享 file A 的 f_pos
  • 查询到 O_APPEND=1,说明父子共享 file A 的 f_flags
  • 最终位置为 4,符合两次顺序读取各推进 2 字节。

因此,这个实验同时展示:

text 复制代码
fdtable 的修改边界:父子独立
struct file 的状态边界:父子共享

八、把打开实例的生命周期压成一张图

这张图把本文的边界固定下来:

  1. fdtable 槽位决定"当前 task 用哪个整数找到 file"。
  2. struct file 决定"这一次打开共享哪些状态、调用哪些操作"。
  3. inode 和路径对象决定"打开实例最终关联谁",由下一篇继续展开。
  4. close 解除 fd/file 引用,unlink 解除名字/inode 链接,两者不是同一种生命周期操作。

参考资料与源码

相关推荐
liang_jy15 小时前
文件管理(六)—— 文件共享和保护
面试·操作系统
liang_jy15 小时前
文件管理(五)—— 文件的基本操作
面试·操作系统
小宇子2B1 天前
fd 只是数组下标:`ksys_write()` 怎么找到 `struct file`
操作系统
liang_jy2 天前
文件管理(四)—— 文件存储空间管理
面试·操作系统
liang_jy2 天前
文件管理(三)—— 文件目录
面试·操作系统
小宇子2B2 天前
shrinker:页 LRU 之外的 VFS(虚拟文件系统)缓存怎么回收
操作系统
liang_jy3 天前
文件管理(一)—— 初识文件管理
面试·操作系统
liang_jy3 天前
文件管理(二)—— 文件结构
面试·操作系统
liang_jy4 天前
内存管理(七)—— 内存映射
面试·操作系统