操作系统对象
文件描述符
在 Linux/Unix 操作系统中,文件描述符是进行所有输入/输出(I/O)操作的基础。它是一个非负整数,作为程序与内核之间进行文件操作的“凭证”。理解文件描述符的关键在于掌握其背后的三层内核数据结构:进程文件描述符表、系统级打开文件表和 i-node 表。
回到定义, 文件描述符 (File Descriptor, fd) 本质上是一个索引,指向一个进程内核区域中的特定数据结构。它的形式是一个非负整数(0, 1, 2, 3, …)。当进程成功打开一个文件或创建一个管道/套接字时,内核会返回一个文件描述符。此后,程序所有对该文件的读、写、关闭等操作,都通过这个整数来标识,而无需再使用文件名。
回忆“一切皆文件”:在 Unix 哲学中,设备(如键盘、显示器)、网络连接(套接字)等都被抽象为文件,因此也都可以通过文件描述符来访问。
需要注意的是, 当一个进程启动时,默认会拥有三个标准的文件描述符: - 0 : 标准输入 (stdin) - 键盘 - 1 : 标准输出 (stdout) - 屏幕 - 2 : 标准错误 (stderr) - 屏幕
因此,程序中第一次成功 open() 一个新文件,通常返回的文件描述符是 3。
三层核心结构
要彻底理解文件描述符的行为,必须了解其背后的三层内核结构。这三层结构清晰地分离了“哪个进程”、“哪次打开”和“哪个文件”的概念。
- 第一层:进程文件描述符表 (Per-Process File Descriptor Table)
归属:每个进程独立拥有一张。
功能:它是一个简单的数组,索引就是文件描述符的整数值。数组的每个元素是一个指针,指向第二层中的一个“文件表项”。这张表将一个简单的整数(文件描述符)与一个具体的“文件打开实例”(文件表项)关联起来。它只告诉进程:“你的文件描述符 3 对应的是那个文件打开实例”。
- 第二层:系统级打开文件表 (System-wide Open File Table)
归属:整个操作系统内核共享一张。
功能:表中的每一个条目被称为 文件表项 (File Table Entry),它代表了一次独立的文件打开操作。这是整个机制的核心,包含了:
- 文件状态标志:文件是如何被打开的(如 O_RDONLY 只读, O_APPEND 追加模式等)。
- 当前文件偏移量 (offset):这是最重要的属性。它记录了下一次读/写操作在文件中的位置,即读写指针。
- 引用计数:记录了有多少个来自第一层的指针指向此表项。
- 指向 i-node 的指针:指向第三层,关联到文件的具体物理信息。
这张表抽象了“打开的文件”这个概念。offset 存放在这里,意味着 offset 是跟“某次文件打开”这个行为绑定的,而不是跟进程或者文件本身绑定。
- 第三层:i-node 表 (i-node Table)
归属:系统内核级,代表物理存储上的文件。
功能:每个文件或目录在文件系统中都有一个唯一的 i-node。它存储了文件的元数据 (metadata),如文件大小、所有者、权限、创建时间以及数据在磁盘上的实际位置。i-node 是文件的静态描述。无论一个文件被打开多少次,它在磁盘上都只有一个 i-node。

fork() 和 dup() 对于offset的影响
fork() 的情况:
- 父进程打开一个文件,我们假设它得到 fd = 3。此时,三层结构是:
父进程的 fd 表:fd[3] -> 指向一个文件表项 A
系统文件表:文件表项 A (offset = 0, 引用计数 = 1) -> 指向 i-node X
i-node 表:i-node X (代表实际文件)
- 父进程调用 fork() 创建子进程。
子进程复制了父进程的文件描述符表。这意味着:
- 子进程的 fd 表:fd[3] -> 也指向同一个文件表项 A
此时,文件表项 A 的状态变为:文件表项 A (offset = 0, 引用计数 = 2) -> 指向 i-node X
结论:因为父子进程的 fd=3 都指向了同一个文件表项 A,所以它们共享该表项中的所有信息,其中最重要的就是共享同一个 offset。任何一方读取文件导致 offset 变化,另一方都会受到影响。
dup() 的情况:
- 进程打开一个文件,得到 fd = 3。
进程 fd 表:fd[3] -> 指向文件表项 A
系统文件表:文件表项 A (offset = 0, 引用计数 = 1)
- 进程调用 dup(3),假设返回了新的文件描述符 fd = 4。
dup() 的作用是在进程的 fd 表中创建一个新条目,让它指向与原 fd 相同的那个文件表项。
进程 fd 表:fd[3] -> 指向文件表项 A; fd[4] -> 也指向文件表项 A
结论:同样地,因为 fd=3 和 fd=4 指向了同一个文件表项 A,所以它们也共享同一个 offset。
mount (挂载)
mount 是一个将独立的“文件系统”集成到主文件系统树中的过程。简单来说,就是将一个存储设备(如硬盘分区、U盘)的内容,附加到主文件系统的一个目录下,让我们可以像访问普通目录一样访问该设备里的文件。
例如, 执行 mount /dev/sdb1 /mnt/data 之后, 便可以通过访问/mnt/data来直接访问挂载的存储文件系统的物理或虚拟设备,如 /dev/sda1 (第一个硬盘的第一个分区)。
umount (卸载): 与 mount 相对,umount 命令(注意没有 ‘n’)则是将这个挂载文件系统从主文件系统树中移除。在拔掉 U 盘等设备前,执行卸载是非常重要的安全操作。
pipe (管道)
管道是 Unix 系统中历史最悠久也最强大的进程间通信 (Inter-Process Communication, IPC) 机制之一。它的作用是在两个进程之间建立一个单向的数据流。
可以将管道想象成一条单向的传送带:
一个进程(Write方)在传送带的一端放上物品(数据)。
另一个进程(Read方)在传送带的另一端取走物品。
传送带本身有容量限制,如果放满了,Write方就必须等待。
如果传送带空了,Read方就必须等待。
物品遵循“先进先出”(First-In, First-Out, FIFO) 的原则。
管道的内部工作机制
首先, 管道不是普通的磁盘文件,它存在于内核内存中的缓冲区 (Buffer), 而且需要通过文件描述符访问.
当一个进程调用 pipe() 系统调用时:
1 | int fd[2]; |
内核会执行以下操作:
在内核中创建一个管道对象,这个对象包含一个内存缓冲区和相关的管理信息。
在系统级打开文件表(第二级)中创建一个文件表项,这个表项指向新创建的管道对象。
在调用进程的文件描述符表(第一级)中,分配两个连续的文件描述符,并让它们都指向同一个文件表项。
fd[0] 被设置为只读端。
fd[1] 被设置为只写端。
管道的经典使用模式
管道本身在一个进程中没有意义(自己写自己读),它的威力体现在与 fork() 结合,用于父子进程间的通信。经典四步法如下:
父进程创建管道:父进程调用 pipe(fd),获得一个读描述符 fd[0] 和一个写描述符 fd[1]。
父进程创建子进程:父进程调用 fork()。现在,子进程继承了父进程的文件描述符表,因此子进程也拥有了这对指向同一个管道的 fd[0] 和 fd[1]。
关闭不用的描述符:这是至关重要的一步,为了建立单向数据流。假设我们想让父进程写,子进程读:
父进程:它只负责写,所以应该在父进程中使用下列指令关闭它的读端:close(fd[0]);
子进程:它只负责读,所以应该在子进程中使用下列指令关闭它的写端:close(fd[1]);
关闭不需要的一端不仅使得功能清晰, 而且也明确了结束信号 (EOF):读操作在管道为空时会阻塞。只有当所有指向管道写端的描述符都关闭后,读端再次读取时才会收到 EOF (End-Of-File,返回值 0),从而知道数据已经全部发送完毕。如果子进程不关闭写端 fd[1],那么它在读完所有数据后会永远阻塞,因为它自己还持有一个“可能写入”的端口。
- 通信:
父进程通过 write(fd[1], …) 写入数据。
子进程通过 read(fd[0], …) 读取数据。
这个模型正是 Shell 中 | 操作符的实现原理。例如 ls -l | grep .txt,ls 进程的标准输出被重定向到管道的写端,grep 进程的标准输入被重定向到管道的读端。
管道的类型
匿名管道 (Anonymous Pipe):由 pipe() 系统调用创建。
特点:没有文件名,只能用于有亲缘关系(通常是父子)的进程间通信。它们随着进程的结束而消失。
应用:Shell 中的 |。
命名管道 (Named Pipe / FIFO):由 mkfifo() 函数创建。
特点:在文件系统中拥有一个可见的、特殊的文件名。这使得任何两个不相关的进程都可以通过打开这个特殊文件来进行通信。
应用:用于一些需要在不同服务程序之间稳定交换数据的场景。
终端和Shell
终端和伪终端
在计算机早期(上世纪 60-70 年代),计算机主机非常昂贵且巨大,通常锁在专门的机房里。人们不是一人一台电脑,而是通过一种硬件设备来连接和使用主机。这种设备就是终端 (Terminal)。
它的本质就是一个纯粹的输入/输出设备, 通常就是一个键盘和一个屏幕(早期甚至是类似打字机的纸张打印输出设备)。它本身几乎没有计算能力。它的唯一工作就是把你从键盘敲入的字符,通过一根串行电缆发送给计算机主机; 同时接收从主机返回的字符,然后把它们显示在屏幕上。
你可以把它想象成一个纯粹的“传话筒”和“显示板”。它不理解你输入的 ls 是什么意思,它只负责把这两个字母传过去,再把主机返回的文件名列表显示出来。这种硬件设备也被称为 TTY (Teletypewriter, 电传打字机) 的衍生品。
随着技术发展,个人计算机普及了,我们不再需要一个独立的物理终端硬件。我们现在用的是功能强大的个人电脑,有图形用户界面 (GUI)。因此当需要与计算机系统交互时, 我们通常会使用一个终端模拟器 (Terminal Emulator)或者说伪终端(Pseudo-Terminal, PTY), 也就是双击打开的黑色命令行窗口。它的工作就是用软件来模拟过去那种硬件的行为。
所以,“伪”就伪在:
它不是硬件,而是软件。
它在软件层面模拟了一个物理终端的行为和接口。
从 Shell的角度看,它感觉自己连接的从设备 (/dev/pts/N) 和过去连接一个物理终端设备 (/dev/ttyS0) 没什么两样。正是这种成功的“伪装”,让所有为传统终端设计的命令行程序(几乎是所有)都能在新时代的图形化窗口中无缝运行。
Shell
而Shell, 字面意思是“外壳”。这个名字非常形象,因为它就是包裹在内核这个核心之外的一层,为用户提供了一个与内核交互的界面。
更详细一点, Shell 是一个运行在用户空间的、作为命令解释器的应用程序。它是用户通过命令行与操作系统内核进行交互的主要接口。它接收你的文本命令,将其翻译成内核可以理解的请求(系统调用),然后将内核返回的结果显示给你。它既是强大的交互工具,也是一个功能完备的脚本编程环境。
类比python(.py), 是一门解释性语言, 通过python.exe这个解释器来解释执行; 同样, shell(.sh)也是一门解释性语言, 通过shell解释器(例如bash, fish等)来解释执行, 将文本命令翻译为系统调用
作为编程语言的定位与特点
虽然 Shell 是一种编程语言,但它的设计哲学和应用领域与 C++、Java 或 Python 等通用编程语言(General-Purpose Language)有很大不同。
领域特定语言(Domain-Specific Language): Shell 的主要设计目标是自动化系统管理任务和与操作系统交互。它的“领域”就是操作系统的命令行环境。
胶水语言(Glue Language): Shell 最强大的能力是作为“胶水”,将操作系统中成百上千个小而专的命令行工具(如 ls, grep, awk, sed, curl)粘合起来,通过管道(|)和重定向(>)组合成强大的工作流。它的编程模型不是从零开始构建所有逻辑,而是编排和调度其他程序。
解释型语言(Interpreted Language): Shell 脚本由解释器(如 bash, zsh)逐行读取并执行,无需预先编译。这使得开发和测试周期非常快,非常适合快速编写自动化脚本。
不过, 其显著缺点则是数据结构有限和不擅长计算. 原生POSIX标准仅支持字符串和一维数组,处理复杂的数据结构(如哈希表、树、对象)非常笨拙。且对于数学运算,尤其是浮点数运算,支持非常薄弱,通常需要借助 bc 或 awk 等外部工具。
区别cmd, pwsh与bash
正如前面所说, Shell 是一个核心概念,它指代命令行解释器(Command Line Interpreter),即为用户提供与操作系统交互接口的程序。基于这一理念,业界诞生了多种具体的 Shell 实现。CMD (Command Prompt)是 Windows 的早期命令解释器, 只是一个简单的、基于文本行的命令接口, 现在已经被更现代化的命令行框架PowerShell替代。bash 则是 GNU 项目的一部分,当下被广泛应用于Unix系统。
Bash (Bourne Again Shell) - 定位:类 Unix 系统(Linux, macOS)的事实标准 Shell, 是 Unix-like 世界的通用语言。
- 哲学:一切皆文件,一切皆文本流。Bash 的核心优势在于处理纯文本。它通过管道(|)将一个个小而专的命令行工具(grep, awk, sed 等)组合起来,形成强大的文本处理工作流。它是典型的“胶水语言”,用于粘合不同的程序。
PowerShell - 定位:Windows 的现代化、面向对象的自动化框架。(目前已实现Windows, Linux 和 macOS 的跨平台)
哲学:一切皆对象(Object)。这是 PowerShell 与前两者最根本的区别。PowerShell 不在命令之间传递无格式的文本流,而是传递结构化的 .NET 对象。这使得它在进行复杂的系统管理和数据操作时,无需进行繁琐的文本解析,可以直接访问对象的属性和方法,更为精准和强大。
其简单的语法习惯等兼容Unix/Linux Shell
可执行文件
可执行文件(Executable File)是计算机中的一种特殊文件,它包含了一系列可以直接被操作系统加载并由CPU执行的机器指令。简单来说,它是程序的最终形态,可以独立运行,不需要其他辅助工具(如编译器或解释器)的帮助。
可执行文件是一个描述状态机初始状态的数据结构 (字节序列)
例如,在 Windows 系统上,你双击打开一个 .exe 文件,在 Linux 系统上,你在终端输入 ls 或 ./a.out,你所操作的这些文件都是可执行文件。
生成过程
一个可执行文件是从人类可读的源代码到机器可读的二进制指令的复杂转换过程的终点。这个过程通常包括以下几个关键步骤:
编写源代码:程序员用 C、C++、Java 等高级语言编写程序代码。
编译(Compilation):编译器将源代码文件(如 .c 文件)翻译成目标文件(Object File),通常是二进制格式。这个阶段会进行语法检查,并将高级代码转换为汇编代码,再转换为机器指令,但此时的指令地址还不是最终地址,且无法独立运行。
链接(Linking):链接器将一个或多个目标文件,以及程序所需的库文件(如标准库),组合成一个完整的可执行文件。