虛擬檔案系統是一種神奇的抽象,它使得 “一切皆檔案” 哲學在 Linux 中成為了可能。
什麼是檔案系統?根據早期的 Linux 貢獻者和作家 Robert Love 所說,“檔案系統是一個遵循特定結構的資料的分層儲存。” 不過,這種描述也同樣適用於 VFAT(虛擬檔案分配表)、Git 和Cassandra(一種 NoSQL 資料庫)。那麼如何區別檔案系統呢?
Linux 核心要求檔案系統必須是實體,它還必須在持久物件上實現 open()
、read()
和 write()
方法,並且這些實體需要有與之關聯的名字。從 物件導向程式設計 的角度來看,核心將通用檔案系統視為一個抽象介面,這三大函數是“虛擬”的,沒有預設定義。因此,核心的預設檔案系統實現被稱為虛擬檔案系統(VFS)。
如果我們能夠 open()
、read()
和 write()
,它就是一個檔案,如這個主控台對談所示。
VFS 是著名的類 Unix 系統中 “一切皆檔案” 概念的基礎。讓我們看一下它有多奇怪,上面的小小演示體現了字元裝置 /dev/console
實際的工作。該圖顯示了一個在虛擬電傳打字控制台(tty)上的互動式 Bash 對談。將一個字串傳送到虛擬控制台裝置會使其顯示在虛擬螢幕上。而 VFS 甚至還有其它更奇怪的屬性。例如,它可以在其中定址。
我們熟悉的檔案系統如 ext4、NFS 和 /proc 都在名為 file_operations 的 C 語言資料結構中提供了三大函數的定義。此外,個別的檔案系統會以熟悉的物件導向的方式擴充套件和覆蓋了 VFS 功能。正如 Robert Love 指出的那樣,VFS 的抽象使 Linux 使用者可以輕鬆地將檔案複製到(或複製自)外部作業系統或抽象實體(如管道),而無需擔心其內部資料格式。在使用者空間這一側,通過系統呼叫,進程可以使用檔案系統方法之一 read()
從檔案複製到核心的資料結構中,然後使用另一種檔案系統的方法 write()
輸出資料。
屬於 VFS 基本型別的函數定義本身可以在核心原始碼的 fs/*.c 檔案 中找到,而 fs/
的子目錄中包含了特定的檔案系統。核心還包含了類似檔案系統的實體,例如 cgroup、/dev
和 tmpfs,在引導過程的早期需要它們,因此定義在核心的 init/
子目錄中。請注意,cgroup、/dev
和 tmpfs 不會呼叫 file_operations
的三大函數,而是直接讀取和寫入記憶體。
下圖大致說明了使用者空間如何存取通常掛載在 Linux 系統上的各種型別檔案系統。像管道、dmesg 和 POSIX 時鐘這樣的結構在此圖中未顯示,它們也實現了 struct file_operations
,而且其存取也要通過 VFS 層。
VFS 是個“墊片層”,位於系統呼叫和特定 file_operations
的實現(如 ext4 和 procfs)之間。然後,file_operations
函數可以與特定於裝置的驅動程式或記憶體存取器進行通訊。tmpfs、devtmpfs 和 cgroup 不使用 file_operations
而是直接存取記憶體。
VFS 的存在促進了程式碼重用,因為與檔案系統相關的基本方法不需要由每種檔案系統型別重新實現。程式碼重用是一種被廣泛接受的軟體工程最佳實踐!唉,但是如果重用的程式碼引入了嚴重的錯誤,那麼繼承常用方法的所有實現都會受到影響。
找出系統中存在的 VFS 的簡單方法是鍵入 mount | grep -v sd | grep -v :/
,在大多數計算機上,它將列出所有未駐留在磁碟上,同時也不是 NFS 的已掛載檔案系統。其中一個列出的 VFS 掛載肯定是 /tmp
,對吧?
誰都知道把 /tmp 放在物理儲存裝置上簡直是瘋了!圖片:https://tinyurl.com/ybomxyfo
為什麼把 /tmp
留在儲存裝置上是不可取的?因為 /tmp
中的檔案是臨時的(!),並且儲存裝置比記憶體慢,所以建立了 tmpfs 這種檔案系統。此外,比起記憶體,物理裝置頻繁寫入更容易磨損。最後,/tmp
中的檔案可能包含敏感資訊,因此在每次重新啟動時讓它們消失是一項功能。
不幸的是,預設情況下,某些 Linux 發行版的安裝指令碼仍會在儲存裝置上建立 /tmp。如果你的系統出現這種情況,請不要絕望。按照一直優秀的 Arch Wiki 上的簡單說明來解決問題就行,記住分配給 tmpfs 的記憶體就不能用於其他目的了。換句話說,包含了大檔案的龐大的 tmpfs 可能會讓系統耗盡記憶體並崩潰。
另一個提示:編輯 /etc/fstab
檔案時,請務必以換行符結束,否則系統將無法啟動。(猜猜我怎麼知道。)
除了 /tmp
之外,大多數 Linux 使用者最熟悉的 VFS 是 /proc
和 /sys
。(/dev
依賴於共用記憶體,而沒有 file_operations
結構)。為什麼有兩種呢?讓我們來看看更多細節。
procfs 為使用者空間提供了核心及其控制的進程的瞬時狀態的快照。在 /proc
中,核心發布有關其提供的設施的資訊,如中斷、虛擬記憶體和排程程式。此外,/proc/sys
是存放可以通過 sysctl 命令設定的設定的地方,可供使用者空間存取。單個進程的狀態和統計資訊在 /proc/<PID>
目錄中報告。
/proc/meminfo 是一個空檔案,但仍包含有價值的資訊。
/proc
檔案的行為說明了 VFS 可以與磁碟上的檔案系統不同。一方面,/proc/meminfo
包含了可由命令 free
展現出來的資訊。另一方面,它還是空的!怎麼會這樣?這種情況讓人聯想起康奈爾大學物理學家 N. David Mermin 在 1985 年寫的一篇名為《沒有人看見月亮的情況嗎?現實和量子理論》。事實是當進程從 /proc
請求資料時核心再收集有關記憶體的統計資訊,而且當沒有人檢視它時,/proc
中的檔案實際上沒有任何內容。正如 Mermin 所說,“這是一個基本的量子學說,一般來說,測量不會揭示被測屬性的預先存在的價值。”(關於月球的問題的答案留作練習。)
當沒有進程存取它們時,/proc 中的檔案為空。(來源)
procfs 的空檔案是有道理的,因為那裡可用的資訊是動態的。sysfs 的情況則不同。讓我們比較一下 /proc
與 /sys
中不為空的檔案數量。
procfs 只有一個不為空的檔案,即匯出的核心設定,這是一個例外,因為每次啟動只需要生成一次。另一方面,/sys
有許多更大一些的檔案,其中大多數由一頁記憶體組成。通常,sysfs 檔案只包含一個數位或字串,與通過讀取 /proc/meminfo
等檔案生成的資訊表格形成鮮明對比。
sysfs 的目的是將核心稱為 “kobject” 的可讀寫屬性公開給使用者空間。kobject 的唯一目的是參照計數:當刪除對 kobject 的最後一個參照時,系統將回收與之關聯的資源。然而,/sys
構成了核心著名的“到使用者空間的穩定 ABI”,它的大部分內容在任何情況下都沒有人能“破壞”。但這並不意味著 sysfs 中的檔案是靜態,這與易失性物件的參照計數相反。
核心的穩定 ABI 限制了 /sys
中可能出現的內容,而不是任何給定時刻實際存在的內容。列出 sysfs 中檔案的許可權可以了解如何設定或讀取裝置、模組、檔案系統等的可設定、可調引數。邏輯上強調 procfs 也是核心穩定 ABI 的一部分的結論,儘管核心的文件沒有明確說明。
sysfs 中的檔案確切地描述了實體的每個屬性,並且可以是可讀的、可寫的,或兩者兼而有之。檔案中的“0”表示 SSD 不可移動的儲存裝置。
了解核心如何管理 sysfs 檔案的最簡單方法是觀察它的執行情況,在 ARM64 或 x86_64 上觀看的最簡單方法是使用 eBPF。eBPF(擴充套件的伯克利封包過濾器)由在核心中執行的虛擬機器組成,特權使用者可以從命令列進行查詢。核心原始碼告訴讀者核心可以做什麼;而在一個啟動的系統上執行 eBPF 工具會顯示核心實際上做了什麼。
令人高興的是,通過 bcc 工具入門使用 eBPF 非常容易,這些工具在主要 Linux 發行版的軟體包 中都有,並且已經由 Brendan Gregg 給出了充分的文件說明。bcc 工具是帶有小段嵌入式 C 語言片段的 Python 指令碼,這意味著任何對這兩種語言熟悉的人都可以輕鬆修改它們。據當前統計,bcc/tools 中有 80 個 Python 指令碼,使得系統管理員或開發人員很有可能能夠找到與她/他的需求相關的已有指令碼。
要了解 VFS 在正在執行中的系統上的工作情況,請嘗試使用簡單的 vfscount 或 vfsstat 指令碼,這可以看到每秒都會發生數十次對 vfs_open()
及其相關的呼叫。
vfsstat.py 是一個帶有嵌入式 C 片段的 Python 指令碼,它只是計數 VFS 函數呼叫。
作為一個不太重要的例子,讓我們看一下在執行的系統上插入 USB 記憶棒時 sysfs 中會發生什麼。
用 eBPF 觀察插入 USB 記憶棒時 /sys 中會發生什麼,簡單的和複雜的例子。
在上面的第一個簡單範例中,只要 sysfs_create_files()
命令執行,trace.py bcc 工具指令碼就會列印出一條訊息。我們看到 sysfs_create_files()
由一個 kworker 執行緒啟動,以響應 USB 棒的插入事件,但是它建立了什麼檔案?第二個例子說明了 eBPF 的強大能力。這裡,trace.py
正在列印核心回溯(-K
選項)以及 sysfs_create_files()
建立的檔案的名稱。單引號內的程式碼段是一些 C 原始碼,包括一個易於識別的格式字串,所提供的 Python 指令碼引入 LLVM 即時編譯器(JIT) 來在核心虛擬機器內編譯和執行它。必須在第二個命令中重現完整的 sysfs_create_files()
函數簽名,以便格式字串可以參照其中一個引數。在此 C 片段中出錯會導致可識別的 C 編譯器錯誤。例如,如果省略 -I
引數,則結果為“無法編譯 BPF 文字”。熟悉 C 或 Python 的開發人員會發現 bcc 工具易於擴充套件和修改。
插入 USB 記憶棒後,核心回溯顯示 PID 7711 是一個 kworker 執行緒,它在 sysfs 中建立了一個名為 events
的檔案。使用 sysfs_remove_files()
進行相應的呼叫表明,刪除 USB 記憶棒會導致刪除該 events
檔案,這與參照計數的想法保持一致。在 USB 棒插入期間(未顯示)在 eBPF 中觀察 sysfs_create_link()
表明建立了不少於 48 個符號連結。
無論如何,events
檔案的目的是什麼?使用 cscope 查詢函數 __device_add_disk()
顯示它呼叫 disk_add_events()
,並且可以將 “mediachange” 或 “ejectrequest” 寫入到該檔案。這裡,核心的塊層通知使用者空間該 “磁碟” 的出現和消失。考慮一下這種檢查 USB 棒的插入的工作原理的方法與試圖僅從源頭中找出該過程的速度有多快。
確實,沒有人通過拔出電源插頭來關閉伺服器或桌面系統。為什麼?因為物理儲存裝置上掛載的檔案系統可能有掛起的(未完成的)寫入,並且記錄其狀態的資料結構可能與寫入記憶體的內容不同步。當發生這種情況時,系統所有者將不得不在下次啟動時等待 fsck 檔案系統恢復工具 執行完成,在最壞的情況下,實際上會丟失資料。
然而,狂熱愛好者會聽說許多物聯網和嵌入式裝置,如路由器、恆溫器和汽車現在都執行著 Linux。許多這些裝置幾乎完全沒有使用者介面,並且沒有辦法乾淨地讓它們“解除啟動”。想一想啟動電池耗盡的汽車,其中執行 Linux 的主機裝置 的電源會不斷加電斷電。當引擎最終開始執行時,系統如何在沒有長時間 fsck 的情況下啟動呢?答案是嵌入式裝置依賴於唯讀根檔案系統(簡稱 ro-rootfs)。
ro-rootfs 是嵌入式系統不經常需要 fsck 的原因。 來源:https://tinyurl.com/yxoauoub
ro-rootfs 提供了許多優點,雖然這些優點不如耐用性那麼顯然。一個是,如果 Linux 進程不可以寫入,那麼惡意軟體也無法寫入 /usr
或 /lib
。另一個是,基本上不可變的檔案系統對於遠端裝置的現場支援至關重要,因為支援人員擁有理論上與現場相同的本地系統。也許最重要(但也是最微妙)的優勢是 ro-rootfs 迫使開發人員在專案的設計階段就決定好哪些系統物件是不可變的。處理 ro-rootfs 可能經常是不方便甚至是痛苦的,程式語言中的常數變數經常就是這樣,但帶來的好處很容易償還這種額外的開銷。
對於嵌入式開發人員,建立唯讀根檔案系統確實需要做一些額外的工作,而這正是 VFS 的用武之地。Linux 需要 /var
中的檔案可寫,此外,嵌入式系統執行的許多流行應用程式會嘗試在 $HOME
中建立設定的點檔案。放在家目錄中的組態檔的一種解決方案通常是預生成它們並將它們構建到 rootfs 中。對於 /var
,一種方法是將其掛載在單獨的可寫分割區上,而 /
本身以唯讀方式掛載。使用系結或疊加掛載是另一種流行的替代方案。
執行 man mount 是了解係結掛載和疊加掛載的最好辦法,這種方法使得嵌入式開發人員和系統管理員能夠在一個路徑位置建立檔案系統,然後以另外一個路徑將其提供給應用程式。對於嵌入式系統,這代表著可以將檔案儲存在 /var
中的不可寫快閃記憶體裝置上,但是在啟動時將 tmpfs 中的路徑疊加掛載或系結掛載到 /var
路徑上,這樣應用程式就可以在那裡隨意寫它們的內容了。下次加電時,/var
中的變化將會消失。疊加掛載為 tmpfs 和底層檔案系統提供了聯合,允許對 ro-rootfs 中的現有檔案進行直接修改,而系結掛載可以使新的空 tmpfs 目錄在 ro-rootfs 路徑中顯示為可寫。雖然疊加檔案系統是一種適當的檔案系統型別,而系結掛載由 VFS 名稱空間工具 實現的。
根據疊加掛載和系結掛載的描述,沒有人會對 Linux 容器 中大量使用它們感到驚訝。讓我們通過執行 bcc 的 mountsnoop
工具監視當使用 systemd-nspawn 啟動容器時會發生什麼:
在 mountsnoop.py 執行的同時,system-nspawn 呼叫啟動容器。
讓我們看看發生了什麼:
在容器 “啟動” 期間執行 mountsnoop
可以看到容器執行時很大程度上依賴於系結掛載。(僅顯示冗長輸出的開頭)
這裡,systemd-nspawn
將主機的 procfs 和 sysfs 中的選定檔案按其 rootfs 中的路徑提供給容器。除了設定系結掛載時的 MS_BIND
標誌之外,mount
系統呼叫的一些其它標誌用於確定主機名稱空間和容器中的更改之間的關係。例如,係結掛載可以將 /proc
和 /sys
中的更改傳播到容器,也可以隱藏它們,具體取決於呼叫。
理解 Linux 內部結構看似是一項不可能完成的任務,因為除了 Linux 使用者空間應用程式和 glibc 這樣的 C 庫中的系統呼叫介面,核心本身也包含大量程式碼。取得進展的一種方法是閱讀一個核心子系統的原始碼,重點是理解面向使用者空間的系統呼叫和標頭檔案以及主要的核心內部介面,這裡以 file_operations
表為例。file_operations
使得“一切都是檔案”得以可以實際工作,因此掌握它們收穫特別大。頂級 fs/
目錄中的核心 C 原始檔構成了虛擬檔案系統的實現,虛擬檔案??系統是支援流行的檔案系統和儲存裝置的廣泛且相對簡單的互操作性的墊片層。通過 Linux 名稱空間進行系結掛載和覆蓋掛載是 VFS 魔術,它使容器和唯讀根檔案系統成為可能。結合對原始碼的研究,eBPF 核心工具及其 bcc 介面使得探測核心比以往任何時候都更簡單。
非常感謝 Akkana Peck 和 Michael Eager 的評論和指正。
Alison Chaiken 也於 3 月 7 日至 10 日在加利福尼亞州帕薩迪納舉行的第 17 屆南加州 Linux 博覽會(SCaLE 17x)上演講了本主題。