# 「一切皆為檔案」的理念與解讀 > 資料整理: [jserv](https://wiki.csie.ncku.edu.tw/User/jserv) UNIX 的成功因素很多,但其 "Everything is a file" 理念無疑功不可沒。儘管該理念並未在 UNIX、後繼的 BSD 家族及 Linux 核心中完全落實,仍奠定 I/O 模型的基礎,展現簡潔與優雅。Linux 進一步擴充該理念,透過檔案描述子建構出更廣泛的系統抽象,並將其應用於 Linux 核心的 signalfd (訊號)、timerfd (計時器)、eventfd (事件通知)、pidfd (行程,搭配專屬的 pidfs 虛擬檔案系統)、userfaultfd (分頁錯誤)、inotify (檔案系統事件)、perf_event_open (效能計數器) 等機制,將原本各自獨立的核心物件統一包裝為可透過 `read()`/`poll()`/`epoll` 操作的檔案描述子。 ## 貫串作業系統的理念 ### 先有實作,後有口號 "Everything is a file" 這句口號並未出現在任何 UNIX 奠基文獻中。它的出現是典型的 UNIX 實用主義:先有可運作的實作,很久之後才有人回頭把它概括成原則。 | 年份 | 事件 | 是否提及 "Everything is a file"? | |---|---|---| | 1969 | Ken Thompson 在 PDP-7 上開發 UNIX 原型 | 磁碟、DECtape、終端機已透過同一套 `read`/`write` 存取,但無任何書面闡述 | | 1971 | [First Edition UNIX Manual](https://www.bell-labs.com/usr/dmr/www/1stEdman.html) | Section 4 記錄特殊檔案 (special files),`read(2)`/`write(2)` 統一適用於裝置與一般檔案,純粹是機制文件 | | 1973 | 第四屆 [SOSP](https://dl.acm.org/doi/10.1145/800009.808045) 首次公開發表 (摘要形式) | 將「裝置視為檔案」列為系統特徵之一,但未賦予任何口號 | | 1974 | 〈[The UNIX Time-Sharing System](https://dl.acm.org/doi/10.1145/361011.361061)〉刊於 CACM | 列出六項檔案系統特徵,第六項為 "the treatment of peripheral devices as files",仍是描述性語句 | | 1978 | [BSTJ 專刊](https://archive.org/details/bstj57-6-1899):McIlroy 前言 + Ritchie/Thompson 修訂版 | McIlroy 首次明確闡述 UNIX 設計哲學,但聚焦於管線與工具組合,並未將檔案抽象列為獨立原則 | | 1979 | Ritchie〈[The Evolution of the Unix Time-sharing System](https://www.bell-labs.com/usr/dmr/www/hist.html)〉 | 回顧 PDP-7 時期即已具備的統一介面,屬事後追溯 | | 1984 | Kernighan & Pike《[The UNIX Programming Environment](https://en.wikipedia.org/wiki/The_Unix_Programming_Environment)》 | 以 "a sequence of bytes" 描述檔案模型,但仍視為系統特性而非命名原則 | | 1990 年代 | Plan 9 開發與推廣、大學教學口耳相傳 | "Everything is a file" 作為口號逐漸成形。Plan 9 需要為 UNIX 的做法命名,才能說明自己做得更徹底 | | 2003 | Eric S. Raymond《[The Art of UNIX Programming](http://www.catb.org/esr/writings/taoup/)》 | 將此口號視為既成共識加以引用,未追溯出處 | 從 1969 年 PDP-7 原型到口號廣為流傳,中間相隔約二十年。在這段期間,UNIX 工程師只是把「裝置當檔案操作」視為理所當然的設計選擇,而非哲學宣言。這正是 UNIX 文化的特色:用可運作的程式碼定義正確性,而非用規格書。 > 延伸閱讀: [Linux 核心設計: 作業系統術語及概念](https://hackmd.io/@sysprog/linux-concepts) ### UNIX 論文中的實際表述 1974 年論文〈[The UNIX time-sharing system](https://dl.acm.org/doi/10.1145/361011.361061)〉(1978 年於 BSTJ 修訂) 中,Ritchie 和 Thompson 將裝置視為特殊檔案,以下段落描述掛載機制: > When the system is initialized, only one file system device is known (the "root device"); its name is built into the system. More storage is attached by "mounting" other devices, each of which contains its own directory structure. When a device is mounted, its root is attached to a leaf of the already-accessible hierarchy. UNIX 將普通檔案與裝置,藉由目錄結構一統於遞迴的樹狀結構之中,形成統一的命名空間。UNIX 檔案系統是掛載於根目錄 (root) 的樹狀目錄結構,每個目錄節點皆可掛載一棵子樹。 「一切皆為檔案」的概念使如此的樹狀結構得以掛載各種資源,例如 [Network File System](https://en.wikipedia.org/wiki/Network_File_System) (NFS) 允許將遠端主機的檔案系統掛載至本機目錄樹。亦即,本機檔案系統中的某個檔案實際上可能存放於遠端機器,這在當時來說是相當創新的設計。 Linux 延續 UNIX 作業系統的哲學 "Everything is a file"。在《[The Linux Programming Interface](https://man7.org/tlpi/)》(TLPI)中,這個理念被描述為 "Universality of I/O": > One of the distinguishing features of the UNIX I/O model is the concept of universality of I/O. This means that the same four system calls—open(), read(), write(), and close()—are used to perform I/O on all types of files, including devices such as terminals. > > (UNIX I/O 模型的一個顯著特徵是 I/O 通用性,這意味著相同的四個系統呼叫 `open()`、`read()`、`write()` 和 `close()`,可用於所有類型的檔案,包括終端機等裝置。) 這種統一抽象層使不同類型的 I/O (如終端輸出與檔案讀寫) 都能視為檔案操作。裝置驅動程式的核心目的,便是實作這樣的抽象層,使作業系統得以透過一致介面存取各類裝置。TLPI 在第 14 章進一步說明: > A device special file corresponds to a device on the system. Within the kernel, each device type has a corresponding device driver, which handles all I/O requests for the device. A device driver is a unit of kernel code that implements a set of operations that (normally) correspond to input and output actions on an associated piece of hardware. The API provided by device drivers is fixed, and includes operations corresponding to the system calls `open()`, `close()`, `read()`, `write()`, `mmap()`, and `ioctl()`. > > (裝置專用檔案對應於系統中的裝置,每種裝置類型皆對應一個裝置驅動程式,負責處理該裝置的所有 I/O 請求。) 這些操作皆基於檔案描述子 (file descriptor, fd),例如 `open()`、`read()`、`write()` 和 `lseek()`。在 UNIX 中,檔案被視為位元組序列,多數 I/O 操作也都透過這些標準介面進行。 > 延伸閱讀: [Linux 核心設計: 檔案系統概念及實作手法](https://hackmd.io/@sysprog/linux-file-system) "file descriptor" (簡稱 fd) 為何譯作「檔案描述子」呢?查閱《重編國語辭典修訂本》對於「舟子」的[解釋](https://dict.revised.moe.edu.tw/dictView.jsp?ID=115700)是「船夫、艄公、水手」,意即操舟的人。換言之,「[子](https://dict.revised.moe.edu.tw/dictView.jsp?ID=9426)」在漢字中本有指「從事某種職業的人」之義。由此推論,「描述子」強調 fd 透過整數來「描述」檔案,並在作業系統核心與應用程式之間傳遞溝通。這樣的譯法既保留 descriptor 的「描述」之意,也符合傳統漢語詞彙結構風格。 > 延伸閱讀: [資訊科技詞彙翻譯](https://hackmd.io/@sysprog/it-vocabulary) 在核心實作中,每個行程的 `task_struct` 直接持有指向 [`files_struct`](https://github.com/torvalds/linux/blob/master/include/linux/fdtable.h) 的指標,`files_struct` 再透過 `fdtable` 維護目前的 fd 表。為了效能,核心預設使用內嵌的小型 `fd_array[]` (大小為 `NR_OPEN_DEFAULT`,在 64 位元系統上為 64),當開啟的 fd 超過此上限時才動態配置更大的表。`fdtable` 中的指標陣列以 `struct file*` 為元素,陣列索引即為使用者空間看到的 fd 整數。當行程呼叫 `open()` 時,核心在此陣列中找到最小的未使用索引,將新配置的 `struct file` 掛上去,再把該索引值回傳給使用者空間作為 fd。POSIX 規範要求 fd 分配遵循「最小可用編號」原則,因此行程啟動時,標準輸入 ([stdin](https://man7.org/linux/man-pages/man3/stdin.3.html))、標準輸出 (stdout) 與標準錯誤 (stderr) 固定佔用 fd 0、1、2。C 標準函式庫的 `FILE*` (如 `fopen()` 回傳的指標) 則是在 fd 之上再包裝一層緩衝區管理,其底層仍透過 fd 與核心溝通。 不過,fd 只是行程私有的一個整數索引,真正承載檔案位移、狀態旗標與存取方法的是核心中的 open file description,也就是 Linux 內部的 `struct file`。因此,[dup(2)](https://man7.org/linux/man-pages/man2/dup.2.html) 複製出的二個 fd 會共享同一份檔案位移,而 [fork(2)](https://man7.org/linux/man-pages/man2/fork.2.html) 之後父子行程也會繼承指向同一 open file description 的 fd。這也是為何 `lseek()`、`O_APPEND` 與部分 `fcntl()` 語意,常要區分「fd 本身」與「fd 指向的 open file description」。 fd 的生命週期還牽涉到另一個常被忽略的旗標:[FD_CLOEXEC](https://man7.org/linux/man-pages/man2/fcntl.2.html)。若某個 fd 未設置 close-on-exec,行程在 [execve(2)](https://man7.org/linux/man-pages/man2/execve.2.html) 之後,新程式映像仍會繼承該 fd;這既可能是有意為之的管線設計,也可能造成檔案描述子洩漏。正因如此,現代 Linux API 常直接提供 `O_CLOEXEC`、`SOCK_CLOEXEC`、`EPOLL_CLOEXEC` 等旗標,盡量在建立 fd 的當下就把繼承語意說清楚。 ## 一切皆為檔案描述子 UNIX 宣稱的「一切皆為檔案」並未徹底落實,考慮以下二個反例: 1. [ioctl](https://man7.org/linux/man-pages/man2/ioctl.2.html) 2. [BSD socket](https://en.wikipedia.org/wiki/Berkeley_sockets) 「一切皆為檔案」的概念背後,是希望所有 I/O 操作都可抽象為 `open()`, `read()`, `write()`, `close()` 等系統呼叫,但 [ioctl](https://man7.org/linux/man-pages/man2/ioctl.2.html) 的出現打破這種簡潔與優雅。依據 [ioctl(2)](https://man7.org/linux/man-pages/man2/ioctl.2.html) 手冊頁: > The ioctl() system call manipulates the underlying device parameters of special files. In particular, many operating characteristics of character special files (e.g., terminals) may be controlled with ioctl() operations. 有些行為難以僅用 `read()` 和 `write()` 來描述,例如在播放光碟時執行快轉,[ioctl](https://man7.org/linux/man-pages/man2/ioctl.2.html) 就是為了彌補 `read()` 與 `write()` 的不足而產生,但也帶來一些問題: - 無法直接從檔案描述子判斷可用的 [ioctl](https://man7.org/linux/man-pages/man2/ioctl.2.html) 操作,僅能藉由錯誤碼推斷 - [ioctl](https://man7.org/linux/man-pages/man2/ioctl.2.html) 與裝置驅動程式緊密耦合,意味著只要有不同的裝置驅動程式,就伴隨著運用 ioctl 系統呼叫打造的工具進行控制 接著來看 [socket](https://man7.org/linux/man-pages/man2/socket.2.html)。依據 [socket(2)](https://man7.org/linux/man-pages/man2/socket.2.html) 手冊頁: > socket() creates an endpoint for communication and returns a file descriptor that refers to that endpoint. 儘管 socket 也透過檔案描述子表示,但它的操作介面與標準檔案並不相同: - 建立 socket 必須使用 [socket](https://man7.org/linux/man-pages/man2/socket.2.html) 系統呼叫,而非 `open()`,也就是說,建立網路連線前,socket 不存在於檔案系統 - [bind](https://man7.org/linux/man-pages/man2/bind.2.html), [connect](https://man7.org/linux/man-pages/man2/connect.2.html) 和 [accept](https://man7.org/linux/man-pages/man2/accept.2.html) 等系統呼叫缺乏對應的標準檔案操作 BSD socket 起初是為網路通訊而設計,4.2BSD 首先以它承載 TCP/IP 堆疊;從一開始,這套介面就不是沿著 pathname 與 `open()` 的檔案模型長出來,而是獨立定義連線建立、位址綁定與連線接受等語意,自然使得 socket 與標準檔案介面無法完全對齊。 ![image](https://hackmd.io/_uploads/rJjEQPMT1e.png =60%x) socket 並非「不是檔案」,而是只被部分納入檔案抽象,因此難以與一般檔案共用完全一致的操作模型。 UNIX 所謂的「一切皆為檔案」的理念,在實務層面,不得不退化為「一切皆為檔案描述子」: * 一切皆為「檔案」:納入 UNIX/Linux 目錄樹狀結構並統一命名 * 一切皆為「檔案描述子」:檔案描述子存在於行程的開啟檔案表中,僅對行程可見 若從 pathname、fd 與可輪詢性來對照,差異會更清楚: | 對象 | 是否存在於目錄樹 | 是否以 fd 操作 | 是否可交給 `poll()`/`select()` | |---|---|---|---| | 普通檔案 | 是 | 是 | 形式上可以,但通常永遠顯示為就緒 | | 裝置節點 (`/dev/null`, `/dev/kvm`) | 是 | 是 | 視裝置類型而定 | | pipe | 否 | 是 | 是 | | socket | 否 | 是 | 是 | | `pidfd` / `signalfd` / `eventfd` | 否 | 是 | 是 | | `/proc`、`/sys` 中的虛擬檔案 | 是 | 是 | 多數不具一般串流語意,是否可輪詢視實作而定 | 此外,這層抽象在多數虛擬檔案系統中偏向單向:系統資源可以透過檔案介面呈現,例如 `/proc` 中的行程資訊與 `/dev` 中的裝置節點,但反向操作未必成立。我們無法藉由在 `/dev` 下建立新檔案來產生一個磁碟裝置,也無法透過 `mkdir /proc/99999` 來建立行程。不過 [cgroup](https://man7.org/linux/man-pages/man7/cgroups.7.html) 和 [configfs](https://docs.kernel.org/filesystems/configfs.html) 則允許透過 `mkdir` 建立對應的核心資源 (如控制群組),這是少數「反向成立」的例外。Linux 核心的 [VFS](https://docs.kernel.org/filesystems/vfs.html) (Virtual File System) 正是實現此抽象的關鍵中介層:它在使用者空間的系統呼叫 (`open`, `read`, `write`) 與底層的實體檔案系統或虛擬檔案系統 (procfs, sysfs, devtmpfs) 之間,提供統一的檔案操作介面,使得不同性質的資源得以共用同一套存取語意。 在實務上,透過 `/proc/[pid]/fd` 目錄即可觀察任一行程目前開啟的檔案描述子: ```shell $ ls -l /proc/$(pidof nginx)/fd lrwx------ 1 root root 64 ... 0 -> /dev/null l-wx------ 1 root root 64 ... 2 -> /var/log/nginx/error.log lrwx------ 1 root root 64 ... 3 -> socket:[26527] lrwx------ 1 root root 64 ... 6 -> socket:[27700] ``` 在 `/proc/[pid]/fd` 中,每個項目都是指向實際資源的符號連結,而 `socket:[26527]` 這類標記則說明該 fd 對應的是一個不存在於目錄樹中的 socket 物件。[lsof](https://man7.org/linux/man-pages/man8/lsof.8.html) 命令可進一步列出 fd 的 TYPE 欄位 (REG, DIR, CHR, IPv4, IPv6, FIFO, PIPE 等),反映出「一切皆為檔案描述子」下各種異質資源共存於同一張 fd 表中的現實。依據 [proc(5)](https://man7.org/linux/man-pages/man5/proc.5.html) 手冊頁: > The proc file system is a pseudo-file system which is used as an interface to kernel data structures. It is commonly mounted at /proc. Most of it is read-only, but some files allow kernel variables to be changed. [pipe](https://man7.org/linux/man-pages/man2/pipe.2.html) 同樣回傳檔案描述子而非路徑。依據 [pipe(2)](https://man7.org/linux/man-pages/man2/pipe.2.html) 手冊頁: > pipe() creates a pipe, a unidirectional data channel that can be used for interprocess communication. The array pipefd is used to return two file descriptors referring to the ends of the pipe. 這對 fd 不在 UNIX 風格的樹狀目錄結構中出現。「一切皆為檔案」和「組合小程式」等 UNIX 原則相輔相成,若無法將所有資源皆視為檔案,就無法簡單地透過管線串接程式來達成複雜操作。以 [socket](https://man7.org/linux/man-pages/man2/socket.2.html) 為例,它雖然可用 `read()` 與 `write()` 進行資料交換,但建立與連線管理仍需仰賴 [socket](https://man7.org/linux/man-pages/man2/socket.2.html)、[bind](https://man7.org/linux/man-pages/man2/bind.2.html)、[connect](https://man7.org/linux/man-pages/man2/connect.2.html)、[accept](https://man7.org/linux/man-pages/man2/accept.2.html) 等專屬系統呼叫。 倘若網路連線也能納入目錄樹中的檔案節點,就可直接沿用 `open()` 與 shell 重新導向,例如 `fd = open("/net/udp/1.1.1.1/53", ...)`。現實中 Linux 並沒有這種介面,但使用者空間一直在嘗試近似的包裝。[GNU bash](https://www.gnu.org/software/bash/) 內建 `/dev/tcp/host/port` 與 `/dev/udp/host/port` 虛擬路徑 (源自 [Korn Shell](https://en.wikipedia.org/wiki/KornShell)),可在 shell 中做最低限度的 TCP 互動: ```shell $ exec 6<>/dev/tcp/www.google.com/80 $ printf 'GET / HTTP/1.1\r\nHost: www.google.com\r\nConnection: close\r\n\r\n' >&6 $ cat <&6 ``` 不過 `/dev/tcp/...` 並非真正的檔案系統節點,而是 bash 內部攔截的虛擬路徑。[socat](https://man7.org/linux/man-pages/man1/socat.1.html) 和 [ncat](https://man7.org/linux/man-pages/man1/ncat.1.html) 一類工具同樣只是把 socket 介面封裝成較接近檔案 I/O 的操作。更徹底的設計思路可見於 [GNU Hurd](https://www.gnu.org/software/hurd/) 的 [translator](https://www.gnu.org/software/hurd/hurd/documentation/translators.html) 及後文的 Plan 9,但這樣的願景並未在 Linux 核心中完整落實。 > 延伸閱讀: [淺談 Microkernel 設計和真實世界中的應用](https://hackmd.io/@sysprog/microkernel-design) ## 可輪詢性:fd 化的驅動力 在早期 UNIX 的互動模式中,shell 與 tty 的主流程較接近單純的 request/response 迴圈:使用者先輸入命令,shell 讀取後建立子行程執行,再等待結果輸出到終端機。此處的重點不是檔案描述子天生具有半雙工語意,而是早期命令列互動大多依循「先讀命令,再等結果」的控制流程,因此: 1. 使用者輸入命令,shell 從終端機讀取 2. 系統執行命令,並將結果寫回終端機 此模式適用命令列環境,但於通訊系統情境顯得不足。例如,一個監聽多個終端的中繼伺服器 ([relay server](https://en.wikipedia.org/wiki/Relay_network)): - 若終端機無輸入資料,`read()` 將阻塞 - 若程式選擇需等待 2 分鐘才有輸入的終端機,則其他輸入將無法即時處理 當時的 shell 只需依序讀取命令、`fork` + `exec` 執行、`wait` 等待結束,如此循環即可。但若通訊的彼端是個人 (例如 [cu](https://man.openbsd.org/cu.1) 電話通訊),雙方可能同時發聲,需要全雙工 (full-duplex) 操作。UNIX 的做法是透過 [fork](https://man7.org/linux/man-pages/man2/fork.2.html) 讓父子行程各自負責讀與寫同一個 fd: ```c void phone() { int fd = dial(...); if (fork() == 0) { while (1) { word = read(fd, ...); /* Receive incoming message */ /* Process the received content */ } } else { while (1) { /* Prepare outgoing content */ talk = write(fd, ...); /* Send it out */ } } } ``` 若進一步考慮中繼場景,也就是同時監聽 N 個終端機,並將訊息從某一終端機轉送到另一端,就會遇到阻塞問題。傳統 UNIX 檔案操作若遇到「無資料可讀取」的終端機,讀取呼叫會被阻塞,造成系統卡住,無法偵測其他終端機是否有新資料。假設終端機 1 在 2 分鐘後才有資料,但程式正好被阻塞在該終端機,而終端機 2 在 10 秒後送出資料卻得不到及時處理,延遲勢必增加。中繼接線員需要某種機制,以判斷「哪個終端機有資料可讀」。 4.2BSD 引入 [select](https://man7.org/linux/man-pages/man2/select.2.html),以解決上述問題。依據 [select(2)](https://man7.org/linux/man-pages/man2/select.2.html) 手冊頁: > select() allows a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become "ready" for some class of I/O operation (e.g., input possible). 亦即,`select()` 會同時檢查多個檔案描述子: - 若皆無資料可讀取,便阻塞 - 只要有任一檔案描述子出現可讀取狀態,[select](https://man7.org/linux/man-pages/man2/select.2.html) 立即返回 1983 年 TCP/IP 隨 4.2BSD 釋出,BSD socket 成為標準化的全雙工檔案描述子。後續 BSD socket 在與 [UNIX System V](https://en.wikipedia.org/wiki/UNIX_System_V) [STREAMS](https://en.wikipedia.org/wiki/STREAMS) (1986 年隨 SVR3 推出) 的競爭中勝出,由於 socket 不像 STREAMS 那樣嚴重依賴 ioctl 系統呼叫,[select](https://man7.org/linux/man-pages/man2/select.2.html) 的使用也得以普及。 運用 [select](https://man7.org/linux/man-pages/man2/select.2.html) 來管理多個檔案描述子,即可在單一行程中處理多道通訊要求,作為 TCP 伺服器。後續 [poll](https://man7.org/linux/man-pages/man2/poll.2.html) 和 [epoll](https://man7.org/linux/man-pages/man7/epoll.7.html) 都可視為 `select()` 的延伸與改進。 依據手冊頁,[poll(2)](https://man7.org/linux/man-pages/man2/poll.2.html) "performs a similar task to select(2): it waits for one of a set of file descriptors to become ready to perform I/O",而 [epoll(7)](https://man7.org/linux/man-pages/man7/epoll.7.html) 則 "performs a similar task to poll(2): monitoring multiple file descriptors to see if I/O is possible on any of them",並支援 edge-triggered 模式且能良好擴展至大量 fd。三者皆建立在「一切皆為檔案描述子」的前提上,統一以 fd 為監控對象。 > 延伸閱讀: [Linux 核心設計: 針對事件驅動的 I/O 模型演化](https://hackmd.io/@sysprog/linux-io-model) 上述演進揭示 Linux 為何不斷將更多核心物件「fd 化」:唯有成為 fd,資源才能進入統一的事件等待集合。UNIX 的原始承諾是透過路徑名稱統一命名;Linux 的實際演化則把重心從「pathname 統一」轉向「fd 的可傳遞性與可輪詢性」。理解這一背景後,下列例外及其後續修補就不再零散,而是同一條演進脈絡的體現: 至此,我們知道,"Everything is a file" 沒有充分落實於 UNIX 作業系統,作為 UNIX 血緣延續的 BSD 和異軍突起的 Linux 也同樣無法貫徹這理念,存在若干例外及不一致的狀況,如下: 1. Linux 引入 [signalfd](https://man7.org/linux/man-pages/man2/signalfd.2.html) 之前,[signal](https://man7.org/linux/man-pages/man7/signal.7.html) 與檔案無關,無法藉由 [poll](https://man7.org/linux/man-pages/man2/poll.2.html) 監控和追蹤 2. FreeBSD 推出 [pdfork](https://man.freebsd.org/cgi/man.cgi?query=pdfork) 之前,子行程亦非檔案,無法藉由 [poll](https://man7.org/linux/man-pages/man2/poll.2.html) 監控和追蹤,只能用 [wait](https://man7.org/linux/man-pages/man2/wait.2.html),且難以在多執行緒的環境處理 3. Linux v5.3 引入 [pidfd_open](https://man7.org/linux/man-pages/man2/pidfd_open.2.html),使行程可看作檔案,v5.4 再透過 `P_PIDFD` 旗標完善 [waitid](https://man7.org/linux/man-pages/man2/waitid.2.html) 的支援,這樣就可運用 [poll](https://man7.org/linux/man-pages/man2/poll.2.html) 監控和追蹤 4. 執行緒至今仍非檔案描述子,無法藉由 [poll](https://man7.org/linux/man-pages/man2/poll.2.html) 等待執行緒結束,只能透過 [pthread_join](https://man7.org/linux/man-pages/man3/pthread_join.3.html) 阻塞等待 (Linux v6.9 引入 `PIDFD_THREAD` 旗標,允許 `pidfd_open()` 針對特定 TID 取得 pidfd,但尚未廣泛採用) 5. 一般磁碟檔案亦非真正可輪詢的對象:對普通檔案呼叫 `poll()` 永遠回傳「就緒」,即使該檔案位於 NFS 且實際讀取會阻塞。嚴格來說,只有 pipe 和 socket 才是真正可輪詢的「好檔案」;[select](https://man7.org/linux/man-pages/man2/select.2.html) 和 [socket](https://man7.org/linux/man-pages/man2/socket.2.html) 同樣在 4.2BSD 時期一併引入 在 Linux 核心中,較為精確的描述應為 "Everything is a file descriptor" (一切皆為檔案描述子)。Linux 核心秉持此理念,自 v2.6.22 起陸續將原本不屬於檔案的核心事件包裝為 fd:[signalfd(2)](https://man7.org/linux/man-pages/man2/signalfd.2.html) 讓訊號可透過 `read()` 接收而非 signal handler;[eventfd(2)](https://man7.org/linux/man-pages/man2/eventfd.2.html) 將事件通知轉為可讀寫的 fd;[timerfd_create(2)](https://man7.org/linux/man-pages/man2/timerfd_create.2.html) (v2.6.25 重新設計 API) 使計時器到期成為可讀取的 fd。三者的共通點在於:將原本需要專屬機制 (signal handler、futex、`setitimer`) 處理的事件,統一轉換為可交給 [poll](https://man7.org/linux/man-pages/man2/poll.2.html)/[epoll](https://man7.org/linux/man-pages/man7/epoll.7.html) 監控的檔案描述子。後續 Linux 核心將此模式擴充至更多子系統: 以 `eventfd` 為例:它是一個匿名的 64 位元計數器 fd,沒有對應的 pathname,卻能加入 `epoll` 事件迴圈、經由 `SCM_RIGHTS` 傳給其他行程,甚至跨容器傳遞通知。這正是「不是一般檔案,但仍是 fd」的代表性設計。 這裡的「可傳遞」尤其關鍵。透過 [unix(7)](https://man7.org/linux/man-pages/man7/unix.7.html) 的 ancillary data 機制,行程可用 `sendmsg()` 搭配 `SCM_RIGHTS` 把 fd 傳給另一個行程,而不必重新以 pathname 開啟同一資源。換言之,fd 不只是本地索引,也是一種可在行程間移交能力 (capability) 的載體。這也是 `pidfd_getfd()`、容器執行階段、圖形顯示伺服器及多媒體框架大量依賴 fd 的原因。 除了事件來源之外,Linux 也把「暫時存在於記憶體中的檔案」納入 fd 模型。依據 [memfd_create(2)](https://man7.org/linux/man-pages/man2/memfd_create.2.html) 手冊頁: > memfd_create() creates an anonymous file and returns a file descriptor that refers to it. 這類檔案沒有持久化 pathname,卻可搭配 `mmap()`、`ftruncate()`、`sendmsg()` 與密封 (sealing) 機制使用,常見於共享記憶體、JIT compiler 暫存物件及沙箱內部資料交換。這再次說明:Linux 真正統一的對象不是「看得見的路徑名稱」,而是可操作、可傳遞的 fd。 | 機制 | 核心版本 | 用途 | |---|---|---| | [pidfd_open](https://man7.org/linux/man-pages/man2/pidfd_open.2.html) | v5.3 | 行程變成 fd:"creates a file descriptor that refers to the process whose PID is specified" | | [pidfd_getfd](https://man7.org/linux/man-pages/man2/pidfd_getfd.2.html) | v5.6 | 經由 pidfd 複製其他行程的 fd:"allocates a new file descriptor...a duplicate of an existing file descriptor...in the process referred to by the PID file descriptor" | | [userfaultfd](https://man7.org/linux/man-pages/man2/userfaultfd.2.html) | v4.3 | 分頁錯誤變成 fd:"creates a new userfaultfd object that can be used for delegation of page-fault handling to a user-space application, and returns a file descriptor" | | [memfd_create](https://man7.org/linux/man-pages/man2/memfd_create.2.html) | v3.17 | 建立匿名記憶體檔案:"creates an anonymous file and returns a file descriptor that refers to it" | | [Landlock](https://docs.kernel.org/userspace-api/landlock.html) | v5.13 | 以 fd 為基礎的沙箱安全模組,`landlock_create_ruleset()` 回傳規則集 fd | | [inotify](https://man7.org/linux/man-pages/man7/inotify.7.html) | v2.6.13 | 檔案系統事件變成 fd:監控檔案/目錄的建立、刪除、修改,可交給 `poll()`/`epoll` | | [perf_event_open](https://man7.org/linux/man-pages/man2/perf_event_open.2.html) | v2.6.31 | 效能計數器變成 fd:可 `read()` 讀取硬體/軟體事件計數,可 `poll()` 等待溢位通知 | | fd-based mount API | v5.2+ | `fsopen()` → `fsconfig()` → `fsmount()` → `move_mount()` 流程取代傳統 `mount()` | pidfd 的演進尤為值得留意:v5.6 加入 `pidfd_getfd()`,v5.8 支援 `setns()` 經由 pidfd 進入命名空間,v6.5 引入 `SO_PEERPIDFD` 使 socket 的身分傳遞也能與 pidfd 串接。至於 Linux v6.9,主要是核心內部改以 pidfs 機制支撐 pidfd 的實作,重點在於內部表示與後續擴充,而非讓使用者空間多出一個可直接操作的掛載檔案系統。 此外,[io_uring](https://man7.org/linux/man-pages/man7/io_uring.7.html) 是 Linux 專屬的非同步 I/O 介面。依據 [io_uring(7)](https://man7.org/linux/man-pages/man7/io_uring.7.html) 手冊頁: > io_uring is a Linux-specific API for asynchronous I/O. It allows the user to submit one or more I/O requests, which are processed asynchronously without blocking the calling process. 自 Linux v5.1,[io_uring](https://hackmd.io/@sysprog/iouring) 發展出「固定檔案」(fixed file) 機制,在 ring 內部維護獨立於行程 fd 表的檔案參照,甚至允許檔案從未出現在行程的 fd 表中,形成「無描述子的檔案」(descriptorless files),這是對傳統 fd 模型的延伸。 Linux 選擇保留既有的 BSD socket 與 ioctl API,同時逐步把新的核心物件包裝成 fd,讓新舊模型共存。Plan 9 則採取另一條路:從零開始重新設計命名空間與控制介面,讓 socket 和 ioctl 類的需求也回歸檔案樹操作。 ## 「一切皆為檔案」落實在 Plan 9 作業系統 [Plan 9](https://en.wikipedia.org/wiki/Plan_9_from_Bell_Labs) 是 UNIX 故鄉 Bell Labs 在 1980 年代中期開始開發的分散式作業系統,目標是成為 UNIX 的後繼者,延伸 UNIX 的「一切皆為檔案」理念,不僅將檔案視為磁碟儲存單位,更視所有計算資源皆為檔案,藉由讀寫操作與之互動。 ![image](https://hackmd.io/_uploads/rybJ3PM6kx.png) Plan 9 的階層式檔案系統與 UNIX 相似,但由於計算資源皆被視為檔案,遠端與本地資源僅在實作細節上有別,並透過 [9P](https://en.wikipedia.org/wiki/9P_(protocol)) 通訊協定統一存取。也因此,即使是 CPU 也能以檔案方式共享。各行程各有自己的命名空間,可自由掛載或合併本機與遠端資源。例如,將遠端的 `/bin` 合併至本地檔案系統,即可直接使用遠端程式。一台機器上的可執行檔案可以直接在另一台機器的 CPU 上執行,而且對使用者而言一切都是透明的。這背後仰賴 9P 協定隱藏底層通訊細節,並充分發揮「一切皆檔案」的理念。 Plan 9 的關鍵創舉在於,它將分散於多台電腦的硬體與軟體元件 (而非整台電腦) 視為獨立資源,不論這些元件是透過內部匯流排或是網路互相連結。 ### 以檔案操作取代 socket API 在 Plan 9 中,網路連線透過 `/net` 目錄樹完成,完全不需要 BSD socket API。以 TCP 連線為例: ``` /net/tcp/ clone ← 開啟此檔案以建立新連線 stats 0/ ← 連線 0 的目錄 ctl ← 寫入控制命令 (connect, announce) data ← 讀寫實際資料 local ← 本端位址 (可讀取) remote ← 遠端位址 (可讀取) status ← 連線狀態 (可讀取) listen ← 伺服端等待連線 (開啟時阻塞) ``` 建立 TCP 用戶端連線的步驟: ```c /* 1. 開啟 clone 檔案,核心分配連線目錄 (如 /net/tcp/7) */ int fd = open("/net/tcp/clone", ORDWR); read(fd, buf, ...); /* buf = "7" */ /* 2. 向 ctl 寫入連線要求 */ write(fd, "connect 93.184.216.34!80", ...); /* 3. 開啟 data 檔案進行 I/O */ int datafd = open("/net/tcp/7/data", ORDWR); write(datafd, "GET / HTTP/1.0\r\n\r\n", ...); read(datafd, response, ...); ``` 伺服端則透過 `announce` 宣告監聽、再開啟 `listen` 等待連線: ```c int fd = open("/net/tcp/clone", ORDWR); read(fd, buf, ...); /* "12" */ write(fd, "announce 80", ...); /* 宣告監聽 port 80 */ /* 開啟 listen 檔案,阻塞直到用戶端連入 */ int listenfd = open("/net/tcp/12/listen", ORDWR); /* listenfd 即為新連線的 ctl fd,對應新目錄 (如 /net/tcp/13) */ int datafd = open("/net/tcp/13/data", ORDWR); /* 透過 datafd 與用戶端通訊 */ ``` 查詢連線狀態只需讀取文字檔案: ```shell % cat /net/tcp/7/remote 93.184.216.34!80 % cat /net/tcp/7/status Established ``` DNS 解析同樣是檔案操作,由使用者空間的 `ndb/dns` 檔案伺服器在 `/net/dns` 提供服務,無需 `getaddrinfo()` 函式庫。 以下展現在 Plan 9 中,「一切皆為檔案」對 TCP 連線的操作: ![edited_plan9](https://hackmd.io/_uploads/H1iNyOGpye.png =70%x) 了解此簡化範例之後,可研讀〈[The Organization of Networks in Plan 9](https://9p.io/sys/doc/net/net.html)〉及〈[The Use of Name Spaces in Plan 9](https://9p.io/sys/doc/names.html)〉以體會 Plan 9 系統的原貌與精神。 ### 消除 ioctl UNIX 下的硬體控制需 [ioctl](https://man7.org/linux/man-pages/man2/ioctl.2.html) 系統呼叫,或如 X window system 以函式呼叫存取。Plan 9 徹底消除 `ioctl`:每個裝置目錄包含 `ctl` 檔案,接受文字命令: ```shell echo 'connect 10.0.0.1' > /net/tcp/7/ctl # 網路控制 echo 'volume 70' > /dev/audioctl # 音訊控制 ``` 文字命令的優勢在於可自我描述、可透過 shell 操作、可經 9P 遠端存取,不像 ioctl 的二進位結構需要對應的標頭檔才能解讀。 ### 行程命名空間 Plan 9 的另一創舉是行程命名空間 (per-process namespace):每個行程各有自己的檔案系統檢視,可透過 `bind` 和 `mount` 自由組合本機與遠端資源。例如: ```shell bind -b /usr/glenda/bin /bin # 將個人工具目錄疊加至 /bin 之前 import remotehost /net /net/remote # 掛載遠端機器的網路堆疊 ``` 執行 `import` 後,透過 `/net/remote/tcp/clone` 建立的 TCP 連線將從遠端機器發起,不需要任何 VPN 軟體。這種 namespace 組合能力使得 Plan 9 毋須 `$PATH` 環境變數,命名空間本身即為搜尋路徑。 ### 9P 通訊協定在現代系統的延續 [9P](https://en.wikipedia.org/wiki/9P_(protocol)) 以 `Tversion`/`Rversion` 協商版本、`Tattach`/`Rattach` 建立存取,再以 `Twalk`, `Topen`, `Tread`, `Twrite`, `Tclunk` 等訊息操作檔案樹,構成完整的資源存取語意。此協定在現代系統中仍有實際應用: - WSL2 以 9P 將 Windows 檔案系統掛載至 Linux VM 內的 `/mnt/c/` - QEMU 透過 [virtio-9p](https://wiki.qemu.org/Documentation/9psetup) 實作主機與虛擬機器間的檔案共享 - Linux 核心的 v9fs 用戶端 (`fs/9p/`) 支援 virtio, TCP, RDMA 等多種傳輸層 ### Plan 9 的影響與局限 正如 Eric S. Raymond 在《[The Art of UNIX Programming](http://www.catb.org/esr/writings/taoup/)》(2003 年) 中所言,Plan 9 作為產品未能取代現有 UNIX 系統,因為「現有的程式碼基礎已夠好」(the most dangerous enemy of a better solution is an existing codebase that is just good enough)。Linux 核心汲取 Plan 9 的若干想法,但選擇性地移植而非全盤採納: | Plan 9 概念 | Linux 對應實作 | 差異 | |---|---|---| | `/proc` (行程資訊以檔案呈現) | procfs (v0.97 起即存在) | Linux `/proc` 額外承載大量非行程資訊 (`/proc/cpuinfo`, `/proc/meminfo`),已偏離原始設計 | | 裝置控制以 `ctl` 文字檔取代 ioctl | sysfs (v2.6.0) | sysfs 提供唯讀屬性居多,裝置控制仍大量依賴 ioctl | | 行程命名空間 (per-process namespace) | mount namespace (v2.4.19),後擴充為 pid/net/uts/ipc/user/cgroup/time 共 8 種 | Plan 9 的命名空間是日常操作的基礎;Linux 的命名空間主要用於容器隔離,並非一般使用者的預設互動模式 | | 9P 通訊協定 | v9fs 核心模組 (`fs/9p/`) | 用於 WSL2、QEMU virtio-9p 等特定場景,並非 Linux 的通用檔案共享協定 | | union mount (`bind -b`, `bind -a`) | overlayfs (v3.18) | overlayfs 是獨立的檔案系統類型,非命名空間層級的操作 | | cgroup 概念 (資源以檔案樹控制) | cgroup (v2.6.24) | 概念相近但實作獨立,cgroup 允許 `mkdir` 建立新控制群組 | 然而 Linux 仍保留 BSD socket 和 ioctl,未能如 Plan 9 般將網路通訊與裝置控制完全納入檔案操作。 這背後的原因並不神秘。首先,UNIX 與 POSIX 軟體生態極為龐大,`open()`/`read()`/`write()` 之外還有大量程式直接依賴 BSD socket、`ioctl` 與既有命令列工具,全面改寫成本過高。其次,BSD socket 很早就成為跨 UNIX 系統的事實標準,應用程式、教科書與網路函式庫都圍繞它建立。Plan 9 那種以命名空間重塑網路 API 的方式雖然更一致,卻難以無痛移植。Linux 因此走上較務實的路線:保留既有 API,同時逐步把新能力包裝為 fd,讓新舊模型得以共存。 Linux 落實「一切皆為檔案」的程度超越傳統 UNIX,以 CPU 運算資源的管理為例,可用以下命令進行 [CPU 熱插拔](https://docs.kernel.org/core-api/cpu_hotplug.html) (hotplug): ```shell $ echo 0 >/sys/devices/system/cpu/cpu1/online $ echo 0 >/sys/devices/system/cpu/cpu2/online $ cat /proc/cpuinfo | grep processor ``` 注意:`cpu0` 在多數平台上負責處理特殊中斷,預設不支援離線操作,需於核心啟動參數指定 `cpu0_hotplug` 方可控制。 於是,關於 CPU 的管理就不用依賴 [ioctl](https://man7.org/linux/man-pages/man2/ioctl.2.html) 系統呼叫。同樣的思路也延伸至更多子系統:[sysfs](https://man7.org/linux/man-pages/man5/sysfs.5.html) 將裝置屬性以檔案呈現,例如 `cat /sys/class/net/eth0/statistics/rx_bytes` 可讀取網路卡收到的位元組數,而 [cgroup](https://man7.org/linux/man-pages/man7/cgroups.7.html) 則將資源配額控制檔案化: ```shell # 限制控制群組的記憶體上限 (cgroup v1) $ echo 536870912 > /sys/fs/cgroup/memory/<group>/memory.limit_in_bytes # cgroup v2 對應介面為 memory.max $ echo 512M > /sys/fs/cgroup/<group>/memory.max # 查看 GPU 裝置節點 (DRM subsystem,視驅動程式而定) $ ls /dev/dri/ card0 renderD128 # KVM 虛擬化裝置 (裝置號 10,232,權限依發行版設定) $ ls -l /dev/kvm ``` 這些範例呈現一致的模式:無論是硬體屬性、資源配額還是虛擬化裝置,都可透過標準的檔案操作 (`cat`, `echo`, `open`/`read`/`write`) 存取,而不必額外學習一套完全不同的控制介面。權限管理同樣受惠於此設計:既然資源皆以檔案方式呈現,UNIX 的擁有者、群組與存取模式 (owner/group/mode) 機制便自然適用於裝置、行程資訊與核心參數的存取控制。 ## 結語 回顧「一切皆為檔案」的演進,可以辨識出三種層次的「統一」: * UNIX 的路徑統一: 透過目錄樹將磁碟檔案、裝置與掛載點納入同一個命名空間,以 `open()`/`read()`/`write()`/`close()` 存取 * Linux 的 fd 統一: 不再執著於路徑名稱,轉而將訊號、計時器、行程、事件通知甚至安全規則都包裝為可傳遞、可輪詢的檔案描述子。行為模式從「它叫什麼名字」轉向「拿這個 fd 做什麼」 * Plan 9 的命名空間統一: 徹底消除 ioctl 與 socket,所有資源皆透過 9P 協定以檔案樹呈現,配合行程命名空間實現分散式透明存取 Linux 採取務實路線:不破壞既有生態系統,但每輪新的子系統設計 (pidfd, Landlock, [io_uring](https://hackmd.io/@sysprog/iouring), fd-based mount API) 都朝向「檔案描述子優先」靠攏。這條路線或許永遠不會抵達 Plan 9 的純粹,卻正因為能與數十年的 POSIX 生態系統共存,反而成為實際運作中最廣泛的「一切皆為檔案描述子」實踐。