写在前面:这是本系列的第七篇。
我们已经知道,进程从
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: DCWindows 中的文件描述符:Handle (句柄)
- Handle (把手;握把;把柄):比 File Descriptor 更像“指针”。
- 这是一个非常形象的翻译。你在操作一个黑盒,操作系统递给你一个“把手”,你抓着这个把手就能控制黑盒。
- 进程创建的面向工程设计:Windows 默认情况下,Handle 是不继承给子进程的(这与 UNIX 默认继承 fd 恰恰相反,各有优劣,Windows 的设计在工程上更容易避免资源泄漏)。
操作系统里都有什么文件?
- FHS (Filesystem Hierarchy Standard):
文件系统层次结构标准。它使得软件和用户能够预测安装文件和目录的位置(比如/etc放配置,/bin放程序,/var放日志)。有趣的是,macOS 就不严格遵循 FHS。
冷知识:一个 U 盘就是一个操作系统?
只要拷对了文件,操作系统就能正常执行!
- 创建 UEFI 分区,并复制正确的 Loader (引导程序)。
- 创建文件系统 (
mkfs格式化)。 - 使用
cp -ar把 Linux 根目录的文件正确复制过去(保留权限)。注意修改fstab里的磁盘 UUID。 - 你就得到了一个可以正常启动的系统盘!
- 运行时挂载必要的其他文件系统:磁盘上的
/dev,/proc其实都是空目录,是在系统启动后通过mount -t proc proc /proc动态在内存里“凭空捏造”出来的!
任何“可读写”的东西都可以是文件
- 真实的设备:
/dev/sda(硬盘),/dev/tty(终端显示器)。 - 虚拟的设备 (文件):
/dev/urandom(随机数生成器),/dev/null(数据黑洞)。
它们在硬盘上并没有实际的“文件”,是操作系统内核为它们实现了特别的read和write函数指针。甚至你可以通过往/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 全都找到,然后在底层用自己的内核逻辑替换并实现它。这就是现代操作系统的“移花接木”之术!