南京大学 操作系统 (JYY) 学习笔记:访问操作系统的对象与“一切皆文件”
2026/7/27 13:56:05 网站建设 项目流程

写在前面:这是本系列的第七篇。

我们已经知道,进程从execve后的初始状态开始,可以通过mmap改变自己的地址空间,通过fork创建新的进程,再通过execve执行新的程序——我们慢慢已经开始理解“操作系统上的应用生态”并没有魔法了。

本讲内容:操作系统还必须给应用程序提供访问操作系统对象的机制。Windows 提供了具体的 Handle (句柄) 机制,而 UNIX 则贯彻了极致的浪漫主义:“Everything is a file”。这节课我们来领略这种设计带来的方便(和不便)。

课前反思:Testkit 与工程实现

关于上节课的 Testkit,其实我还不太会用。

多个.c文件交织在一起,执行顺序让人摸不着头脑,快照 (Snapshot) 机制也没完全听懂。有机会一定要自己琢磨一下怎么用 Test(或者让豆包/Kimi 详细拆解教一下)。

在系统编程中,测试框架是重中之重。


操作系统的对象

操作系统里到底有哪些“对象”供我们操作?

  • 进程 (Process):
    • 进程 = 状态机。
    • 进程管理 API:fork,execve,exit,waitpid
  • 连续的内存段 (Memory Segments):
    • 我们可以把“连续的内存段”看作一个对象。
    • 它可以在进程间共享,也可以直接映射到文件上。
    • 内存管理 API:mmap,munmap,mprotect,msync

文件描述符 (File Descriptor)

文件是操作系统中最常见的对象。

文件和设备

  • 文件:有“名字”的数据对象。
    • 字节流:终端 (Terminal)、随机数发生器 (random) 等,数据像水流一样,读过去就没了。
    • 字节序列:硬盘上的普通文件,可以通过指针随意前后移动定位。
  • 文件描述符 (fd):
    • 它是指向操作系统对象的“指针”。
    • UNIX 的核心哲学:Everything is a file
    • 只要掌握了这个“指针”,你就可以访问系统里的“一切”。
    • 对象的访问全靠这个指针:open(获取指针),close(销毁指针),read/write(解引用/读写数据),lseek(指针内偏移运算),dup(指针间赋值)。

文件描述符的底层逻辑

fd = open(...),后续所有的操作都会基于这个fd

进程里面有一个进程地址空间,内核为你维护了一个“文件描述符表”。fd本质上就是这个表里的一个整数索引(Index)。

相关 API 的内存视角:

  • open: 进程空间(内核态)里开辟一个新内存,用于放置文件状态信息,并返回一个索引fd给用户态。(类似p = malloc(sizeof(FileDescriptor));
  • close: 删去这个文件表项的引用。(类似delete(p);
  • read/write: 顺着指针读写数据,并自动推进偏移量。(类似*(p.data++);
  • lseek: 手动修改偏移量。(类似p.data += offset;
  • dup (duplicate): 复制一个已存在的文件描述符。创建一个新的fd整数,但它指向内核中同一个底层文件资源。(类似q = p;的指针赋值)。

文件描述符的分配策略

规则:总是分配当前最小的未使用描述符。

  • 0,1,2 是系统默认保留的:标准输入 (stdin),标准输出 (stdout),标准错误 (stderr)。
  • 新打开的文件,总是从3开始分配。
  • 关闭文件后,该描述符号可以被立即重新分配。
  • 进程能打开多少文件?
    • 使用ulimit -n查看进程级限制。
    • 使用sysctl fs.file-max查看系统级总限制。
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec7/fd# ./fd-alloc# 程序分配了 8 个文件描述符,编号从 3 到 10。Allocatedfiledescriptor fds[0]:3Allocatedfiledescriptor fds[1]:4... Allocatedfiledescriptor fds[7]:10# 程序关闭了编号为 1、3、5、7 (即 fd 4, 6, 8, 10) 的文件描述符Closedfiledescriptor fds[1]:4...# 程序重新分配了 4 个文件描述符。由于 4,6,8,10 被释放,系统优先复用它们!Reallocatedfiledescriptor fds[1]:4Reallocatedfiledescriptor fds[3]:6...

避坑指南:文件描述符的 Offset (偏移量)

如果希望在文件里面追加写入内容,偏移量offset就非常关键。

文件描述符不仅是一个编号,它在操作系统内核中对应着一个“打开文件表项 (Open File Table Entry)”,里面记录了非常关键的offset(当前读写到了哪里)。每次独立的open调用,都会产生一个拥有独立offset的表项。

🤖** 极客勘误:fork()dup()之后的 Offset 共享问题**
原笔记中 AI 对dup()的解释有误。在 Linux 内核底层,真实情况是这样的:

  • fork():创建子进程时,子进程完整复制了父进程的fd数组。父子进程的fd指向内核中同一个“打开文件表项”。因此,父子进程共享 Offset!(这就是为什么父子进程同时printf输出到文件时,内容不会互相覆盖,而是顺着往下写)。
  • dup():复制当前进程的一个fd(比如将fd 3复制为fd 4)。这两个fd同样指向内核中同一个“打开文件表项”。因此,dup()** 生成的两个 fd 也共享 Offset!** 在fd 3上写入内容,fd 4的偏移量也会跟着往前走。
  • 只有当你对同一个文件调用两次open()时,才会生成两个完全独立的 Offset。
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec7/fd# ./fd-offset# 程序使用 dup 复制文件描述符,分别写入 "A" 和 "B"。# 输出结果为 "AB",说明 dup 复制的文件描述符共享 offset,写入操作是连续追加的。Content of sample.txt: AB# 程序使用 fork 创建子进程,子进程写入 "D",父进程写入 "C"。# 输出结果为 "DC" (或 "CD")。它们依然共享 offset,不会互相覆盖同一位置。Content of sample.txt after fork: DC

Windows 中的文件描述符:Handle (句柄)

  • Handle (把手;握把;把柄):比 File Descriptor 更像“指针”。
  • 这是一个非常形象的翻译。你在操作一个黑盒,操作系统递给你一个“把手”,你抓着这个把手就能控制黑盒。
  • 进程创建的面向工程设计:Windows 默认情况下,Handle 是不继承给子进程的(这与 UNIX 默认继承 fd 恰恰相反,各有优劣,Windows 的设计在工程上更容易避免资源泄漏)。

操作系统里都有什么文件?

  • FHS (Filesystem Hierarchy Standard):
    文件系统层次结构标准。它使得软件和用户能够预测安装文件和目录的位置(比如/etc放配置,/bin放程序,/var放日志)。有趣的是,macOS 就不严格遵循 FHS。

冷知识:一个 U 盘就是一个操作系统?

只要拷对了文件,操作系统就能正常执行!

  1. 创建 UEFI 分区,并复制正确的 Loader (引导程序)。
  2. 创建文件系统 (mkfs格式化)。
  3. 使用cp -ar把 Linux 根目录的文件正确复制过去(保留权限)。注意修改fstab里的磁盘 UUID。
  4. 你就得到了一个可以正常启动的系统盘!
  5. 运行时挂载必要的其他文件系统:磁盘上的/dev,/proc其实都是空目录,是在系统启动后通过mount -t proc proc /proc动态在内存里“凭空捏造”出来的!

任何“可读写”的东西都可以是文件

  • 真实的设备:
    /dev/sda(硬盘),/dev/tty(终端显示器)。
  • 虚拟的设备 (文件):
    /dev/urandom(随机数生成器),/dev/null(数据黑洞)。
    它们在硬盘上并没有实际的“文件”,是操作系统内核为它们实现了特别的readwrite函数指针。甚至你可以通过往/sys/class/backlight里面write数字来直接控制屏幕亮度!

匿名管道 (Anonymous Pipe):一个特殊的“文件流”

这是实现进程间通信的基础。

intpipe(intpipefd[2]);
  • 返回两个文件描述符:pipefd[0]是读口,pipefd[1]是写口。
  • 看起来单个进程自己写自己读没啥用?
  • 精髓在于配合fork()
    父进程pipe()后,fork()出子进程。子进程继承了这两个管口。然后父进程关闭读口(只写),子进程关闭写口(只读)。一根单向的跨进程数据管道就此打通!
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec7/pipe# ./anonymous-pipe[1760]Write:'Hello, world!'[1761]Got:'Hello, world!'[1760]Done.

之前在计网里听得一脸懵逼的“管道”,其实在命令行里天天用(比如ls | grep txt)。它的底层就是pipe+fork+dup2(把标准输入/输出重定向到管口上)。


“Everything is a file” 的哲学反思

实现一切的基础架构:

  • 进程管理:fork,execve,waitpid,exit
  • 内存管理:mmap,munmap,mprotect,msync
  • 文件管理:open,close,read,write,lseek,dup

优点:优雅,文本接口,就是好用

  • 一套 API 访问所有对象。一切都可以用grep去搜索!
  • Unix Shell 的语法虽然经常被诟病太老旧(复杂的逻辑应该用 Python/Rust),但是:We all love quick & dirty!
# 查看所有进程的 fd,纯字符串文本处理的狂欢ls-l/proc/*/fd/*2>/dev/null|awk'{print $(NF-2), $(NF-1),$NF}'# 统计所有进程的内存占用grep-sVmRSS /proc/*[0-9]/status|awk'{sum += $2} END {print sum " kB"}'

所有一切都是基于字符串的替换,很接近自然语言,非常极客。

缺点:对高速设备不够友好

  • 万物皆文件,意味着你要到处lseek()然后再read/write,这会产生额外的系统调用延迟和内存拷贝。
  • 传统的 fd 是单线程同步 I/O。对于现代每秒百万次读写的高速 NVMe 固态硬盘和万兆网卡,这种 API 成了瓶颈。(这也是为什么现代引入了io_uring等零拷贝的高级机制)。

终局:API 与封装兼容 (Another Level of Indirection)

“Any problem in computer science can be solved with another level of indirection.” (Butler Lampson)
(计算机科学中的任何问题,都可以通过增加一个间接层来解决。)

不同的操作系统底座不同,但可以通过兼容层(套壳)来互相运行对方的程序:

  • Windows NT:Win32 API → POSIX 子系统 (早期的 WSL1 就是把 Linux 系统调用实时翻译成 Windows 系统调用,极其优雅但维护难度极大,最终暴毙,换成了现在跑真虚拟机的 WSL2)。
  • macOS:Cocoa API → 极其坚固的 BSD Unix 子系统。
  • Fuchsia:Zircon 微内核 → POSIX 兼容层。

OpenHarmony 的野望

理论上,只要你用代码实现并伪装出 Linux 所有的 API(提供同样的系统调用编号和行为),你就能在这个新的系统上毫无缝隙地运行所有的 Android/Linux 应用!

只需要把原应用需要调用的 API 全都找到,然后在底层用自己的内核逻辑替换并实现它。这就是现代操作系统的“移花接木”之术!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询