資料整理: jserv
UNIX 的成功因素很多,但其 "Everything is a file" 理念無疑功不可沒。儘管該理念並未在 UNIX 與後繼的 BSD 家族及 Linux 核心中完全落實,仍奠定 I/O 模型的基礎,展現簡潔和優雅。Linux 進一步擴充此理念,透過檔案描述子建構出更廣泛的系統抽象,並將其應用至行程管理、訊號、虛擬化等議題。
UNIX 早期論文〈The UNIX Time-Sharing System〉中,Dennis Ritchie 和 Ken Thompson 提出 "Everthing is a file" (一切皆為檔案) 的主體思想:
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 (NFS) 允許將遠端主機的檔案系統掛載至本機目錄樹。亦即,本機檔案系統中的某個檔案實際上可能存放於遠端機器,這在當時來說是相當創新的設計。
Linux 延續 UNIX 作業系統的哲學 "Everything is a file",該理念在《The Linux Programming Interface》(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()
, andioctl()
.(裝置專用檔案對應於系統中的裝置,每種裝置類型皆對應一個裝置驅動程式,負責處理該裝置的所有 I/O 請求。)
這些操作皆基於檔案描述子(file descriptor),如 open()
、read()
、write()
和 lseek()
。在 UNIX 中,檔案被視為位元組序列,而所有 I/O 操作均透過這些標準介面進行。
"file descriptor" (簡稱 fd) 為何譯作「檔案描述子」呢?查閱《重編國語辭典修訂本》對於「舟子」的解釋是「船夫、梢公、水手」,意即操舟的人,也就是說,「子」在漢字本有指「從事某種職業的人」的意思。由此推論,「描述子」強調 fd 透過整數來「描述」檔案,並於作業系統核心與應用程式之間傳遞溝通。這樣的譯法既保留 descriptor 作為「描述」之意,也符合傳統漢語詞彙結構風格。
延伸閱讀: 資訊科技詞彙翻譯
UNIX 宣稱的「一切皆為檔案」並未徹底落實,考慮以下二個反例:
「一切皆為檔案」的概念背後,是希望所有 I/O 操作都可抽象為 open()
, read()
, write()
, close()
等系統呼叫,但 ioctl 的出現打破這種簡潔與優雅。有些行為難以僅用 read()
和 write()
來描述,例如在播放光碟時執行快轉,ioctl 就是為了彌補 read()
與 write()
的不足而產生,但也帶來一些問題:
接著來看 socket,儘管 socket 也透過檔案描述子表示,但它的操作介面與標準檔案並不相同:
open()
,也就是說,建立網路連線前,socket 不存在於檔案系統BSD socket 起初是為封裝 TCP/IP 網路通訊而設計,而 TCP/IP 一開始就沒被抽象成檔案的一部分,自然使得 socket 與標準檔案介面格格不入。
可見,socket 幾乎就是為 TCP/IP 量身打造的介面,可與 TCP 狀態機一一對應,換言之,socket
並非嚴格意義上的檔案,因此難以用統一方式處理 socket 與一般檔案的 I/O 串流 (stream)。
UNIX 所謂的「一切皆為檔案」的理念,在實務層面,不得不退化為「一切皆為檔案描述子」:
值得留意的是,pipe 雖然會產生一對檔案描述子,但它們不在 UNIX 風格的樹狀目錄結構中出現。「一切皆為檔案」和「組合小程式」等 UNIX 原則相輔相成,若無法將所有資源皆視為檔案,就無法簡單地透過管線串接程式來達成複雜操作。以 socket 為例,沒有支援標準檔案的 open
與 close
,無法直接對 socket 使用 cat。基於上述議題,socat 和 ncat 一類的輕量網路程式實際上只能包裝 socket 的 I/O 介面,使之類似檔案操作。
然而,倘若一個網路連線也能被視為目錄樹上的檔案,就能直接用 open
打開連線,例如:
此時就不再需要 socat 和 ncat,可在 shell 中便可完成所有操作,例如:
甚至可以像下列方式進行複製檔案描述子:
其實 socat 和 ncat 一類工具只是將標準檔案 I/O 介面封裝成適用於 socket 的操作。而在 GNU bash 中,隱含類似的「socket 檔案」機制,摘錄自 GNU bash 手冊:
/dev/tcp/host/port
: If host is a valid hostname or Internet address, and port is an integer port number or service name, Bash attempts to open the corresponding TCP socket./dev/udp/host/port
: If host is a valid hostname or Internet address, and port is an integer port number or service name, Bash attempts to open the corresponding UDP socket.利用上述的 /dev/udp/host/port
,我們可在 bash 裡頭存取 Google 主頁:
參考輸出:
如此一來,不僅用不到 telnet,甚至 wget 和 curl 都變成多餘,也就是說,只要系統提供 cat
命令(或 read
系統呼叫)和 echo
(或 write
系統呼叫)等基本工具,我們就能像操作普通檔案,去處理 socket。不過,/dev/tcp/www.google.com/80
其實不存在,這只是 GNU bash 為使用者創造的假象,部分為了相容於 GNU Hurd 作業系統。
socat 尚有其他用法,例如用 socat
實作簡易 TCP 伺服器:
隨後在另一個終端機用 telnet 127.0.0.1 1234
連線,即可進入這個臨時 shell,參考的執行過程:
倘若網路連線能夠直接視為目錄樹狀結構裡頭的檔案,甚至連 socat 和 telnet 都不用,只要透過 read 與 write 就能完成所有操作,這也是在 GNU Hurd 作業系統的 translator 概念。不過前述美好的願景不能在 Linux 核心充分落實。
至此,我們知道,"Everything is a file" 沒有充分落實於 UNIX 作業系統,作為 UNIX 血緣延續的 BSD 和異軍突起的 Linux 也同樣無法貫徹這理念,存在若干例外及不一致的狀況,如下:
在 Linux 核心中,較為精確的描述應為 "Everything is a file descriptor" (一切皆為檔案描述子)。Linux 核心秉持此理念,提出 eventfd, timerfd 和 signalfd 等機制。
延伸閱讀:
Plan 9 是 UNIX 故鄉 Bell Labs 在 1980 年代中期開始開發的分散式作業系統,目標是成為 UNIX 的後繼者,延伸 UNIX 的「一切皆為檔案」理念,不僅將檔案視為磁碟儲存單位,更視所有計算資源皆為檔案,藉由讀寫操作與之互動。
Plan 9 的階層式檔案系統與 UNIX 相似,但由於計算資源皆是檔案,遠端與本地資源僅在實作細節上有別,透過名為 9P 的通訊協定統一存取。也因此,即使是 CPU 亦能以檔案方式共享。各行程各有自己的命名空間,可自由掛載或合併本機與遠端資源。例如,將遠端的 /bin
合併至本地檔案系統,即可直接使用遠端程式。一台機器上的可執行檔案可以直接在另一台機器的 CPU 上執行,且對使用者而言一切都是透明的。這背後仰賴 9P 協定來隱藏底層通訊細節,並充分發揮「一切皆檔案」的理念。
Plan 9 的關鍵創舉在於,它將分散於多台電腦的硬體與軟體元件(而非整台電腦)視為獨立資源,不論這些元件是透過內部匯流排或是網路互相連結。以下展現在 Plan 9 中,「一切皆為檔案」對 TCP 連線的操作:
了解此簡化範例之後,可研讀〈The Organization of Networks in Plan 9〉以體會 Plan 9 系統的原貌與精神。
UNIX 下的硬體控制需 ioctl 系統呼叫,或如 X window system 以函式呼叫存取,但在 Plan 9 中,CPU、周邊設備、網路、乃至圖形界面都以檔案形式抽象呈現。既然 Plan 9 允許 TCP 連線、裝置、甚至行程間通訊都可透過 9P 通訊協定掛載於檔案系統,於是我們可這樣操作:
不過,正如 Eric S. Raymond 所言,Plan 9 作為產品未能取代現有 UNIX 系統,因為「現有的系統已夠好」。Linux 核心汲取 Plan 9 的若干想法並予以發揚光大,例如 procfs, sysfs, cgroup, debugfs。
Linux 落實「一切皆為檔案」的程度超越傳統 UNIX,以 CPU 運算資源的管理為例,可用以下命令進行 CPU 熱插拔 (hotplug):
於是,關於 CPU 的管理就不用依賴 ioctl 系統呼叫。
延伸閱讀:
在早期 UNIX(1969 年),shell 和 tty 皆為半雙工 (half-duplex) I/O,亦即允許二個裝置進行雙向資料傳輸,但同一時間僅能由其中一方傳送。若另一方欲傳送,則必須等待正在傳送的裝置完成後才能開始,於是:
此模式適用命令列環境,但於通訊系統場景顯得不足。例如,一個監聽多個終端的中繼伺服器 (relay server):
read()
將阻塞當時機器主要被動等使用者的指示,也就是說,一旦使用者輸入命令並寫入檔案描述子後,系統才會執行並回傳結果,如此的設計對於當時的 shell 應用場景已足夠,其主體程式碼如下:
但如果通訊的彼端是個人 (例如電話通訊),就必須同時處理「你講的話」和「對方講的話」,也就是全雙工 (full-duplex) 通訊。cu 命令的出現正好代表這種需求:在通話過程中,雙方可能同時發聲 (如吵架場景)。既然 UNIX 已幾乎具備「一切皆為檔案」及 fork 系統呼叫,允許二個行程各自讀取、寫入同一個檔案描述子,達成全雙工操作。以下是概念程式碼:
若進一步考慮中繼場景,也就是同時監聽 N 個終端機,並將訊息從某一終端機轉送到另一端,就會遇到阻塞問題。傳統 UNIX 檔案操作若遇到「無資料可讀取」的終端機,讀取呼叫會被阻塞,造成系統卡住,無法偵測其他終端機是否有新資料。假設終端機 1 在 2 分鐘後才有資料,但程式正好被阻塞在該終端機,而終端機 2 在 10 秒後送出資料卻得不到及時處理,這顯然會衝擊到效率。中繼接線員需要某種機制,以判斷「哪個終端機有資料可讀」。
4.2BSD 引入 select,以解決上述問題,它會同時檢查多個檔案描述子:
1983 年 TCP/IP 加入 BSD,並在與 UNIX System V Streams 的競爭中勝出,BSD socket 成為標準化的全雙工檔案描述子。由於 socket 不像 Streams 那樣嚴重依賴 ioctl 系統呼叫,select 的使用也得以普及。
運用 select 來管理多個檔案描述子,就可在單一行程中即能處理多道通訊要求,作為 TCP 伺服器。後續 poll 和 epoll 皆是 select 系統呼叫的改進。
延伸閱讀: