南京大学 操作系统 (JYY) 学习笔记:访问操作系统的对象与“一切皆文件”
写在前面这是本系列的第七篇。我们已经知道进程从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;的指针赋值。文件描述符的分配策略规则总是分配当前最小的未使用描述符。012 是系统默认保留的标准输入 (stdin)标准输出 (stdout)标准错误 (stderr)。新打开的文件总是从3开始分配。关闭文件后该描述符号可以被立即重新分配。进程能打开多少文件使用ulimit -n查看进程级限制。使用sysctl fs.file-max查看系统级总限制。rootLAPTOP-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。rootLAPTOP-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()出子进程。子进程继承了这两个管口。然后父进程关闭读口只写子进程关闭写口只读。一根单向的跨进程数据管道就此打通rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec7/pipe# ./anonymous-pipe[1760]Write:Hello, world![1761]Got:Hello, world![1760]Done.之前在计网里听得一脸懵逼的“管道”其实在命令行里天天用比如ls | grep txt。它的底层就是pipeforkdup2把标准输入/输出重定向到管口上。“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 全都找到然后在底层用自己的内核逻辑替换并实现它。这就是现代操作系统的“移花接木”之术