全方位剖析 Linux 作業系統,太全了!!!

程式設計師cxuan發表於2020-10-10

Linux 簡介

UNIX 是一個互動式系統,用於同時處理多程式和多使用者同時線上。為什麼要說 UNIX,那是因為 Linux 是由 UNIX 發展而來的,UNIX 是由程式設計師設計,它的主要服務物件也是程式設計師。Linux 繼承了 UNIX 的設計目標。從智慧手機到汽車,超級計算機和家用電器,從家用桌上型電腦到企業伺服器,Linux 作業系統無處不在。

大多數程式設計師都喜歡讓系統儘量簡單,優雅並具有一致性。舉個例子,從最底層的角度來講,一個檔案應該只是一個位元組集合。為了實現順序存取、隨機存取、按鍵存取、遠端存取只能是妨礙你的工作。相同的,如果命令

ls A*

意味著只列出以 A 為開頭的所有檔案,那麼命令

rm A*

應該會移除所有以 A 為開頭的檔案而不是隻刪除檔名是 A* 的檔案。這個特性也是最小吃驚原則(principle of least surprise)

最小吃驚原則一半常用於使用者介面和軟體設計。它的原型是:該功能或者特徵應該符合使用者的預期,不應該使使用者感到驚訝和震驚。

一些有經驗的程式設計師通常希望系統具有較強的功能性和靈活性。設計 Linux 的一個基本目標是每個應用程式只做一件事情並把他做好。所以編譯器只負責編譯的工作,編譯器不會產生列表,因為有其他應用比編譯器做的更好。

很多人都不喜歡冗餘,為什麼在 cp 就能描述清楚你想幹什麼時候還使用 copy?這完全是在浪費寶貴的 hacking time。為了從檔案中提取所有包含字串 ard 的行,Linux 程式設計師應該輸入

grep ard f

Linux 介面

Linux 系統是一種金字塔模型的系統,如下所示

應用程式發起系統呼叫把引數放在暫存器中(有時候放在棧中),併發出 trap 系統陷入指令切換使用者態至核心態。因為不能直接在 C 中編寫 trap 指令,因此 C 提供了一個庫,庫中的函式對應著系統呼叫。有些函式是使用匯編編寫的,但是能夠從 C 中呼叫。每個函式首先把引數放在合適的位置然後執行系統呼叫指令。因此如果你想要執行 read 系統呼叫的話,C 程式會呼叫 read 函式庫來執行。這裡順便提一下,是由 POSIX 指定的庫介面而不是系統呼叫介面。也就是說,POSIX 會告訴一個標準系統應該提供哪些庫過程,它們的引數是什麼,它們必須做什麼以及它們必須返回什麼結果。

除了作業系統和系統呼叫庫外,Linux 作業系統還要提供一些標準程式,比如文字編輯器、編譯器、檔案操作工具等。直接和使用者打交道的是上面這些應用程式。因此我們可以說 Linux 具有三種不同的介面:系統呼叫介面、庫函式介面和應用程式介面

Linux 中的 GUI(Graphical User Interface) 和 UNIX 中的非常相似,這種 GUI 建立一個桌面環境,包括視窗、目標和資料夾、工具欄和檔案拖拽功能。一個完整的 GUI 還包括視窗管理器以及各種應用程式。

Linux 上的 GUI 由 X 視窗支援,主要組成部分是 X 伺服器、控制鍵盤、滑鼠、顯示器等。當在 Linux 上使用圖形介面時,使用者可以通過滑鼠點選執行程式或者開啟檔案,通過拖拽將檔案進行復制等。

Linux 組成部分

事實上,Linux 作業系統可以由下面這幾部分構成

  • 載入程式(Bootloader):載入程式是管理計算機啟動過程的軟體,對於大多數使用者而言,只是彈出一個螢幕,但其實內部作業系統做了很多事情
  • 核心(Kernel):核心是作業系統的核心,負責管理 CPU、記憶體和外圍裝置等。
  • 初始化系統(Init System):這是一個引導使用者空間並負責控制守護程式的子系統。一旦從引導載入程式移交了初始引導,它就是用於管理引導過程的初始化系統。
  • 後臺程式(Daemon):後臺程式顧名思義就是在後臺執行的程式,比如列印、聲音、排程等,它們可以在引導過程中啟動,也可以在登入桌面後啟動
  • 圖形伺服器(Graphical server):這是在監視器上顯示圖形的子系統。通常將其稱為 X 伺服器或 X。
  • 桌面環境(Desktop environment):這是使用者與之實際互動的部分,有很多桌面環境可供選擇,每個桌面環境都包含內建應用程式,比如檔案管理器、Web 瀏覽器、遊戲等
  • 應用程式(Applications):桌面環境不提供完整的應用程式,就像 Windows 和 macOS 一樣,Linux 提供了成千上萬個可以輕鬆找到並安裝的高質量軟體。

Shell

儘管 Linux 應用程式提供了 GUI ,但是大部分程式設計師仍偏好於使用命令列(command-line interface),稱為shell。使用者通常在 GUI 中啟動一個 shell 視窗然後就在 shell 視窗下進行工作。

shell 命令列使用速度快、功能更強大、而且易於擴充套件、並且不會帶來肢體重複性勞損(RSI)

下面會介紹一些最簡單的 bash shell。當 shell 啟動時,它首先進行初始化,在螢幕上輸出一個 提示符(prompt),通常是一個百分號或者美元符號,等待使用者輸入

等使用者輸入一個命令後,shell 提取其中的第一個詞,這裡的詞指的是被空格或製表符分隔開的一連串字元。假定這個詞是將要執行程式的程式名,那麼就會搜尋這個程式,如果找到了這個程式就會執行它。然後 shell 會將自己掛起直到程式執行完畢,之後再嘗試讀入下一條指令。shell 也是一個普通的使用者程式。它的主要功能就是讀取使用者的輸入和顯示計算的輸出。shell 命令中可以包含引數,它們作為字串傳遞給所呼叫的程式。比如

cp src dest

會呼叫 cp 應用程式幷包含兩個引數 srcdest。這個程式會解釋第一個引數是一個已經存在的檔名,然後建立一個該檔案的副本,名稱為 dest。

並不是所有的引數都是檔名,比如下面

head -20 file

第一個引數 -20,會告訴 head 應用程式列印檔案的前 20 行,而不是預設的 10 行。控制命令操作或者指定可選值的引數稱為標誌(flag),按照慣例標誌應該使用 - 來表示。這個符號是必要的,比如

head 20 file

是一個完全合法的命令,它會告訴 head 程式輸出檔名為 20 的檔案的前 10 行,然後輸出檔名為 file 檔案的前 10 行。Linux 作業系統可以接受一個或多個引數。

為了更容易的指定多個檔名,shell 支援 魔法字元(magic character),也被稱為萬用字元(wild cards)。比如,* 可以匹配一個或者多個可能的字串

ls *.c

告訴 ls 列舉出所有檔名以 .c 結束的檔案。如果同時存在多個檔案,則會在後面進行並列。

另一個萬用字元是問號,負責匹配任意一個字元。一組在中括號中的字元可以表示其中任意一個,因此

ls [abc]*

會列舉出所有以 ab 或者 c 開頭的檔案。

shell 應用程式不一定通過終端進行輸入和輸出。shell 啟動時,就會獲取 標準輸入、標準輸出、標準錯誤檔案進行訪問的能力。

標準輸出是從鍵盤輸入的,標準輸出或者標準錯誤是輸出到顯示器的。許多 Linux 程式預設是從標準輸入進行輸入並從標準輸出進行輸出。比如

sort	

會呼叫 sort 程式,會從終端讀取資料(直到使用者輸入 ctrl-d 結束),根據字母順序進行排序,然後將結果輸出到螢幕上。

通常還可以重定向標準輸入和標準輸出,重定向標準輸入使用 < 後面跟檔名。標準輸出可以通過一個大於號 > 進行重定向。允許一個命令中重定向標準輸入和輸出。例如命令

sort <in >out

會使 sort 從檔案 in 中得到輸入,並把結果輸出到 out 檔案中。由於標準錯誤沒有重定向,所以錯誤資訊會直接列印到螢幕上。從標準輸入讀入,對其進行處理並將其寫入到標準輸出的程式稱為 過濾器

考慮下面由三個分開的命令組成的指令

sort <in >temp;head -30 <temp;rm temp

首先會呼叫 sort 應用程式,從標準輸入 in 中進行讀取,並通過標準輸出到 temp。當程式執行完畢後,shell 會執行 head ,告訴它列印前 30 行,並在標準輸出(預設為終端)上列印。最後,temp 臨時檔案被刪除。輕輕的,你走了,你揮一揮衣袖,不帶走一片雲彩

命令列中的第一個程式通常會產生輸出,在上面的例子中,產生的輸出都不 temp 檔案接收。然而,Linux 還提供了一個簡單的命令來做這件事,例如下面

sort <in | head -30

上面 | 稱為豎線符號,它的意思是從 sort 應用程式產生的排序輸出會直接作為輸入顯示,無需建立、使用和移除臨時檔案。由管道符號連線的命令集合稱為管道(pipeline)。例如如下

grep cxuan *.c | sort | head -30 | tail -5 >f00

對任意以 .t 結尾的檔案中包含 cxuan 的行被寫到標準輸出中,然後進行排序。這些內容中的前 30 行被 head 出來並傳給 tail ,它又將最後 5 行傳遞給 foo。這個例子提供了一個管道將多個命令連線起來。

可以把一系列 shell 命令放在一個檔案中,然後將此檔案作為輸入來執行。shell 會按照順序對他們進行處理,就像在鍵盤上鍵入命令一樣。包含 shell 命令的檔案被稱為 shell 指令碼(shell scripts)

推薦一個 shell 命令的學習網站:https://www.shellscript.sh/

shell 指令碼其實也是一段程式,shell 指令碼中可以對變數進行賦值,也包含迴圈控制語句比如 if、for、while 等,shell 的設計目標是讓其看起來和 C 相似(There is no doubt that C is father)。由於 shell 也是一個使用者程式,所以使用者可以選擇不同的 shell。

Linux 應用程式

Linux 的命令列也就是 shell,它由大量標準應用程式組成。這些應用程式主要有下面六種

  • 檔案和目錄操作命令
  • 過濾器
  • 文字程式
  • 系統管理
  • 程式開發工具,例如編輯器和編譯器
  • 其他

除了這些標準應用程式外,還有其他應用程式比如 Web 瀏覽器、多媒體播放器、圖片瀏覽器、辦公軟體和遊戲程式等

我們在上面的例子中已經見過了幾個 Linux 的應用程式,比如 sort、cp、ls、head,下面我們再來認識一下其他 Linux 的應用程式。

我們先從幾個例子開始講起,比如

cp a b

是將 a 複製一個副本為 b ,而

mv a b

是將 a 移動到 b ,但是刪除原檔案。

上面這兩個命令有一些區別,cp 是將檔案進行復制,複製完成後會有兩個檔案 a 和 b;而 mv 相當於是檔案的移動,移動完成後就不再有 a 檔案。cat 命令可以把多個檔案內容進行連線。使用 rm 可以刪除檔案;使用 chmod 可以允許所有者改變訪問許可權;檔案目錄的的建立和刪除可以使用 mkdirrmdir 命令;使用 ls 可以檢視目錄檔案,ls 可以顯示很多屬性,比如大小、使用者、建立日期等;sort 決定檔案的顯示順序

Linux 應用程式還包括過濾器 grep,grep 從標準輸入或者一個或多個輸入檔案中提取特定模式的行;sort 將輸入進行排序並輸出到標準輸出;head 提取輸入的前幾行;tail 提取輸入的後面幾行;除此之外的過濾器還有 cutpaste,允許對文字行的剪下和複製;od 將輸入轉換為 ASCII ;tr 實現字元大小寫轉換;pr 為格式化列印輸出等。

程式編譯工具使用 gcc

make 命令用於自動編譯,這是一個很強大的命令,它用於維護一個大的程式,往往這類程式的原始碼由許多檔案構成。典型的,有一些是 header files 標頭檔案,原始檔通常使用 include 指令包含這些檔案,make 的作用就是跟蹤哪些檔案屬於標頭檔案,然後安排自動編譯的過程。

下面列出了 POSIX 的標準應用程式

程式 應用
ls 列出目錄
cp 複製檔案
head 顯示檔案的前幾行
make 編譯檔案生成二進位制檔案
cd 切換目錄
mkdir 建立目錄
chmod 修改檔案訪問許可權
ps 列出檔案程式
pr 格式化列印
rm 刪除一個檔案
rmdir 刪除檔案目錄
tail 提取檔案最後幾行
tr 字符集轉換
grep 分組
cat 將多個檔案連續標準輸出
od 以八進位制顯示檔案
cut 從檔案中剪下
paste 從檔案中貼上

Linux 核心結構

在上面我們看到了 Linux 的整體結構,下面我們從整體的角度來看一下 Linux 的核心結構

核心直接坐落在硬體上,核心的主要作用就是 I/O 互動、記憶體管理和控制 CPU 訪問。上圖中還包括了 中斷排程器,中斷是與裝置互動的主要方式。中斷出現時排程器就會發揮作用。這裡的低階程式碼停止正在執行的程式,將其狀態儲存在核心程式結構中,並啟動驅動程式。程式排程也會發生在核心完成一些操作並且啟動使用者程式的時候。圖中的排程器是 dispatcher。

注意這裡的排程器是 dispatcher 而不是 scheduler,這兩者是有區別的

scheduler 和 dispatcher 都是和程式排程相關的概念,不同的是 scheduler 會從幾個程式中隨意選取一個程式;而 dispatcher 會給 scheduler 選擇的程式分配 CPU。

然後,我們把核心系統分為三部分。

  • I/O 部分負責與裝置進行互動以及執行網路和儲存 I/O 操作的所有核心部分。

從圖中可以看出 I/O 層次的關係,最高層是一個虛擬檔案系統,也就是說不管檔案是來自記憶體還是磁碟中,都是經過虛擬檔案系統中的。從底層看,所有的驅動都是字元驅動或者塊裝置驅動。二者的主要區別就是是否允許隨機訪問。網路驅動裝置並不是一種獨立的驅動裝置,它實際上是一種字元裝置,不過網路裝置的處理方式和字元裝置不同。

上面的裝置驅動程式中,每個裝置型別的核心程式碼都不同。字元裝置有兩種使用方式,有一鍵式的比如 vi 或者 emacs ,需要每一個鍵盤輸入。其他的比如 shell ,是需要輸入一行按Enter鍵將字串傳送給程式進行編輯。

網路軟體通常是模組化的,由不同的裝置和協議來支援。大多數 Linux 系統在核心中包含一個完整的硬體路由器的功能,但是這個不能和外部路由器相比,路由器上面是協議棧,包括 TCP/IP 協議,協議棧上面是 socket 介面,socket 負責與外部進行通訊,充當了門的作用。

磁碟驅動上面是 I/O 排程器,它負責排序和分配磁碟讀寫操作,以儘可能減少磁頭的無用移動。

  • I/O 右邊的是記憶體部件,程式被裝載進記憶體,由 CPU 執行,這裡會涉及到虛擬記憶體的部件,頁面的換入和換出是如何進行的,壞頁面的替換和經常使用的頁面會進行快取。

  • 程式模組負責程式的建立和終止、程式的排程、Linux 把程式和執行緒看作是可執行的實體,並使用統一的排程策略來進行排程。

在核心最頂層的是系統呼叫介面,所有的系統呼叫都是經過這裡,系統呼叫會觸發一個 trap,將系統從使用者態轉換為核心態,然後將控制權移交給上面的核心部件。

Linux 程式和執行緒

下面我們就深入理解一下 Linux 核心來理解 Linux 的基本概念之程式和執行緒。系統呼叫是作業系統本身的介面,它對於建立程式和執行緒,記憶體分配,共享檔案和 I/O 來說都很重要。

我們將從各個版本的共性出發來進行探討。

基本概念

每個程式都會執行一段獨立的程式,並且在初始化的時候擁有一個獨立的控制執行緒。換句話說,每個程式都會有一個自己的程式計數器,這個程式計數器用來記錄下一個需要被執行的指令。Linux 允許程式在執行時建立額外的執行緒。

Linux 是一個多道程式設計系統,因此係統中存在彼此相互獨立的程式同時執行。此外,每個使用者都會同時有幾個活動的程式。因為如果是一個大型系統,可能有數百上千的程式在同時執行。

在某些使用者空間中,即使使用者退出登入,仍然會有一些後臺程式在執行,這些程式被稱為 守護程式(daemon)

Linux 中有一種特殊的守護程式被稱為 計劃守護程式(Cron daemon) ,計劃守護程式可以每分鐘醒來一次檢查是否有工作要做,做完會繼續回到睡眠狀態等待下一次喚醒。

Cron 是一個守護程式,可以做任何你想做的事情,比如說你可以定期進行系統維護、定期進行系統備份等。在其他作業系統上也有類似的程式,比如 Mac OS X 上 Cron 守護程式被稱為 launchd 的守護程式。在 Windows 上可以被稱為 計劃任務(Task Scheduler)

在 Linux 系統中,程式通過非常簡單的方式來建立,fork 系統呼叫會建立一個源程式的拷貝(副本)。呼叫 fork 函式的程式被稱為 父程式(parent process),使用 fork 函式建立出來的程式被稱為 子程式(child process)。父程式和子程式都有自己的記憶體映像。如果在子程式建立出來後,父程式修改了一些變數等,那麼子程式是看不到這些變化的,也就是 fork 後,父程式和子程式相互獨立。

雖然父程式和子程式保持相互獨立,但是它們卻能夠共享相同的檔案,如果在 fork 之前,父程式已經開啟了某個檔案,那麼 fork 後,父程式和子程式仍然共享這個開啟的檔案。對共享檔案的修改會對父程式和子程式同時可見。

那麼該如何區分父程式和子程式呢?子程式只是父程式的拷貝,所以它們幾乎所有的情況都一樣,包括記憶體映像、變數、暫存器等。區分的關鍵在於 fork 函式呼叫後的返回值,如果 fork 後返回一個非零值,這個非零值即是子程式的 程式識別符號(Process Identiier, PID),而會給子程式返回一個零值,可以用下面程式碼來進行表示

pid = fork();    // 呼叫 fork 函式建立程式
if(pid < 0){
  error()				 // pid < 0,建立失敗
}
else if(pid > 0){
  parent_handle() // 父程式程式碼
}
else {
  child_handle()  // 子程式程式碼
}

父程式在 fork 後會得到子程式的 PID,這個 PID 即能代表這個子程式的唯一識別符號也就是 PID。如果子程式想要知道自己的 PID,可以呼叫 getpid 方法。當子程式結束執行時,父程式會得到子程式的 PID,因為一個程式會 fork 很多子程式,子程式也會 fork 子程式,所以 PID 是非常重要的。我們把第一次呼叫 fork 後的程式稱為 原始程式,一個原始程式可以生成一顆繼承樹

Linux 程式間通訊

Linux 程式間的通訊機制通常被稱為 Internel-Process communication,IPC 下面我們來說一說 Linux 程式間通訊的機制,大致來說,Linux 程式間的通訊機制可以分為 6 種

下面我們分別對其進行概述

訊號 signal

訊號是 UNIX 系統最先開始使用的程式間通訊機制,因為 Linux 是繼承於 UNIX 的,所以 Linux 也支援訊號機制,通過向一個或多個程式傳送非同步事件訊號來實現,訊號可以從鍵盤或者訪問不存在的位置等地方產生;訊號通過 shell 將任務傳送給子程式。

你可以在 Linux 系統上輸入 kill -l 來列出系統使用的訊號,下面是我提供的一些訊號

程式可以選擇忽略傳送過來的訊號,但是有兩個是不能忽略的:SIGSTOPSIGKILL 訊號。SIGSTOP 訊號會通知當前正在執行的程式執行關閉操作,SIGKILL 訊號會通知當前程式應該被殺死。除此之外,程式可以選擇它想要處理的訊號,程式也可以選擇阻止訊號,如果不阻止,可以選擇自行處理,也可以選擇進行核心處理。如果選擇交給核心進行處理,那麼就執行預設處理。

作業系統會中斷目標程式的程式來向其傳送訊號、在任何非原子指令中,執行都可以中斷,如果程式已經註冊了新號處理程式,那麼就執行程式,如果沒有註冊,將採用預設處理的方式。

例如:當程式收到 SIGFPE 浮點異常的訊號後,預設操作是對其進行 dump(轉儲)和退出。訊號沒有優先順序的說法。如果同時為某個程式產生了兩個訊號,則可以將它們呈現給程式或者以任意的順序進行處理。

下面我們就來看一下這些訊號是幹什麼用的

  • SIGABRT 和 SIGIOT

SIGABRT 和 SIGIOT 訊號傳送給程式,告訴其進行終止,這個 訊號通常在呼叫 C標準庫的abort()函式時由程式本身啟動

  • SIGALRM 、 SIGVTALRM、SIGPROF

當設定的時鐘功能超時時會將 SIGALRM 、 SIGVTALRM、SIGPROF 傳送給程式。當實際時間或時鐘時間超時時,傳送 SIGALRM。 當程式使用的 CPU 時間超時時,將傳送 SIGVTALRM。 當程式和系統代表程式使用的CPU 時間超時時,將傳送 SIGPROF。

  • SIGBUS

SIGBUS 將造成匯流排中斷錯誤時傳送給程式

  • SIGCHLD

當子程式終止、被中斷或者被中斷恢復,將 SIGCHLD 傳送給程式。此訊號的一種常見用法是指示作業系統在子程式終止後清除其使用的資源。

  • SIGCONT

SIGCONT 訊號指示作業系統繼續執行先前由 SIGSTOP 或 SIGTSTP 訊號暫停的程式。該訊號的一個重要用途是在 Unix shell 中的作業控制中。

  • SIGFPE

SIGFPE 訊號在執行錯誤的算術運算(例如除以零)時將被髮送到程式。

  • SIGUP

當 SIGUP 訊號控制的終端關閉時,會傳送給程式。許多守護程式將重新載入其配置檔案並重新開啟其日誌檔案,而不是在收到此訊號時退出。

  • SIGILL

SIGILL 訊號在嘗試執行非法、格式錯誤、未知或者特權指令時發出

  • SIGINT

當使用者希望中斷程式時,作業系統會向程式傳送 SIGINT 訊號。使用者輸入 ctrl - c 就是希望中斷程式。

  • SIGKILL

SIGKILL 訊號傳送到程式以使其馬上進行終止。 與 SIGTERM 和 SIGINT 相比,這個訊號無法捕獲和忽略執行,並且程式在接收到此訊號後無法執行任何清理操作,下面是一些例外情況

殭屍程式無法殺死,因為殭屍程式已經死了,它在等待父程式對其進行捕獲

處於阻塞狀態的程式只有再次喚醒後才會被 kill 掉

init 程式是 Linux 的初始化程式,這個程式會忽略任何訊號。

SIGKILL 通常是作為最後殺死程式的訊號、它通常作用於 SIGTERM 沒有響應時傳送給程式。

  • SIGPIPE

SIGPIPE 嘗試寫入程式管道時發現管道未連線無法寫入時傳送到程式

  • SIGPOLL

當在明確監視的檔案描述符上發生事件時,將傳送 SIGPOLL 訊號。

  • SIGRTMIN 至 SIGRTMAX

SIGRTMIN 至 SIGRTMAX 是實時訊號

  • SIGQUIT

當使用者請求退出程式並執行核心轉儲時,SIGQUIT 訊號將由其控制終端傳送給程式。

  • SIGSEGV

當 SIGSEGV 訊號做出無效的虛擬記憶體引用或分段錯誤時,即在執行分段違規時,將其傳送到程式。

  • SIGSTOP

SIGSTOP 指示作業系統終止以便以後進行恢復時

  • SIGSYS

當 SIGSYS 訊號將錯誤引數傳遞給系統呼叫時,該訊號將傳送到程式。

  • SYSTERM

我們上面簡單提到過了 SYSTERM 這個名詞,這個訊號傳送給程式以請求終止。與 SIGKILL 訊號不同,該訊號可以被過程捕獲或忽略。這允許程式執行良好的終止,從而釋放資源並在適當時儲存狀態。 SIGINT 與SIGTERM 幾乎相同。

  • SIGTSIP

SIGTSTP 訊號由其控制終端傳送到程式,以請求終端停止。

  • SIGTTIN 和 SIGTTOU

當 SIGTTIN 和SIGTTOU 訊號分別在後臺嘗試從 tty 讀取或寫入時,訊號將傳送到該程式。

  • SIGTRAP

在發生異常或者 trap 時,將 SIGTRAP 訊號傳送到程式

  • SIGURG

當套接字具有可讀取的緊急或帶外資料時,將 SIGURG 訊號傳送到程式。

  • SIGUSR1 和 SIGUSR2

SIGUSR1 和 SIGUSR2 訊號被髮送到程式以指示使用者定義的條件。

  • SIGXCPU

當 SIGXCPU 訊號耗盡 CPU 的時間超過某個使用者可設定的預定值時,將其傳送到程式

  • SIGXFSZ

當 SIGXFSZ 訊號增長超過最大允許大小的檔案時,該訊號將傳送到該程式。

  • SIGWINCH

SIGWINCH 訊號在其控制終端更改其大小(視窗更改)時傳送給程式。

管道 pipe

Linux 系統中的程式可以通過建立管道 pipe 進行通訊。

在兩個程式之間,可以建立一個通道,一個程式向這個通道里寫入位元組流,另一個程式從這個管道中讀取位元組流。管道是同步的,當程式嘗試從空管道讀取資料時,該程式會被阻塞,直到有可用資料為止。shell 中的管線 pipelines 就是用管道實現的,當 shell 發現輸出

sort <f | head

它會建立兩個程式,一個是 sort,一個是 head,sort,會在這兩個應用程式之間建立一個管道使得 sort 程式的標準輸出作為 head 程式的標準輸入。sort 程式產生的輸出就不用寫到檔案中了,如果管道滿了系統會停止 sort 以等待 head 讀出資料

管道實際上就是 |,兩個應用程式不知道有管道的存在,一切都是由 shell 管理和控制的。

共享記憶體 shared memory

兩個程式之間還可以通過共享記憶體進行程式間通訊,其中兩個或者多個程式可以訪問公共記憶體空間。兩個程式的共享工作是通過共享記憶體完成的,一個程式所作的修改可以對另一個程式可見(很像執行緒間的通訊)。

在使用共享記憶體前,需要經過一系列的呼叫流程,流程如下

  • 建立共享記憶體段或者使用已建立的共享記憶體段(shmget())
  • 將程式附加到已經建立的記憶體段中(shmat())
  • 從已連線的共享記憶體段分離程式(shmdt())
  • 對共享記憶體段執行控制操作(shmctl())

先入先出佇列 FIFO

先入先出佇列 FIFO 通常被稱為 命名管道(Named Pipes),命名管道的工作方式與常規管道非常相似,但是確實有一些明顯的區別。未命名的管道沒有備份檔案:作業系統負責維護記憶體中的緩衝區,用來將位元組從寫入器傳輸到讀取器。一旦寫入或者輸出終止的話,緩衝區將被回收,傳輸的資料會丟失。相比之下,命名管道具有支援檔案和獨特 API ,命名管道在檔案系統中作為裝置的專用檔案存在。當所有的程式通訊完成後,命名管道將保留在檔案系統中以備後用。命名管道具有嚴格的 FIFO 行為

寫入的第一個位元組是讀取的第一個位元組,寫入的第二個位元組是讀取的第二個位元組,依此類推。

訊息佇列 Message Queue

一聽到訊息佇列這個名詞你可能不知道是什麼意思,訊息佇列是用來描述核心定址空間內的內部連結列表。可以按幾種不同的方式將訊息按順序傳送到佇列並從佇列中檢索訊息。每個訊息佇列由 IPC 識別符號唯一標識。訊息佇列有兩種模式,一種是嚴格模式, 嚴格模式就像是 FIFO 先入先出佇列似的,訊息順序傳送,順序讀取。還有一種模式是 非嚴格模式,訊息的順序性不是非常重要。

套接字 Socket

還有一種管理兩個程式間通訊的是使用 socket,socket 提供端到端的雙相通訊。一個套接字可以與一個或多個程式關聯。就像管道有命令管道和未命名管道一樣,套接字也有兩種模式,套接字一般用於兩個程式之間的網路通訊,網路套接字需要來自諸如TCP(傳輸控制協議)或較低階別UDP(使用者資料包協議)等基礎協議的支援。

套接字有以下幾種分類

  • 順序包套接字(Sequential Packet Socket): 此類套接字為最大長度固定的資料包提供可靠的連線。此連線是雙向的並且是順序的。
  • 資料包套接字(Datagram Socket):資料包套接字支援雙向資料流。資料包套接字接受訊息的順序與傳送者可能不同。
  • 流式套接字(Stream Socket):流套接字的工作方式類似於電話對話,提供雙向可靠的資料流。
  • 原始套接字(Raw Socket): 可以使用原始套接字訪問基礎通訊協議。

Linux 中程式管理系統呼叫

現在關注一下 Linux 系統中與程式管理相關的系統呼叫。在瞭解之前你需要先知道一下什麼是系統呼叫。

作業系統為我們遮蔽了硬體和軟體的差異,它的最主要功能就是為使用者提供一種抽象,隱藏內部實現,讓使用者只關心在 GUI 圖形介面下如何使用即可。作業系統可以分為兩種模式

  • 核心態:作業系統核心使用的模式
  • 使用者態:使用者應用程式所使用的模式

我們常說的上下文切換 指的就是核心態模式和使用者態模式的頻繁切換。而系統呼叫指的就是引起核心態和使用者態切換的一種方式,系統呼叫通常在後臺靜默執行,表示計算機程式向其作業系統核心請求服務。

系統呼叫指令有很多,下面是一些與程式管理相關的最主要的系統呼叫

fork

fork 呼叫用於建立一個與父程式相同的子程式,建立完程式後的子程式擁有和父程式一樣的程式計數器、相同的 CPU 暫存器、相同的開啟檔案。

exec

exec 系統呼叫用於執行駐留在活動程式中的檔案,呼叫 exec 後,新的可執行檔案會替換先前的可執行檔案並獲得執行。也就是說,呼叫 exec 後,會將舊檔案或程式替換為新檔案或執行,然後執行檔案或程式。新的執行程式被載入到相同的執行空間中,因此程式的 PID 不會修改,因為我們沒有建立新程式,只是替換舊程式。但是程式的資料、程式碼、堆疊都已經被修改。如果當前要被替換的程式包含多個執行緒,那麼所有的執行緒將被終止,新的程式映像被載入執行。

這裡需要解釋一下程式映像(Process image) 的概念

什麼是程式映像呢?程式映像是執行程式時所需要的可執行檔案,通常會包括下面這些東西

  • 程式碼段(codesegment/textsegment)

又稱文字段,用來存放指令,執行程式碼的一塊記憶體空間

此空間大小在程式碼執行前就已經確定

記憶體空間一般屬於只讀,某些架構的程式碼也允許可寫

在程式碼段中,也有可能包含一些只讀的常數變數,例如字串常量等。

  • 資料段(datasegment)

可讀可寫

儲存初始化的全域性變數和初始化的 static 變數

資料段中資料的生存期是隨程式持續性(隨程式持續性)
隨程式持續性:程式建立就存在,程式死亡就消失

  • bss 段(bsssegment):

可讀可寫

儲存未初始化的全域性變數和未初始化的 static 變數

bss 段中的資料一般預設為 0

  • Data 段

是可讀寫的,因為變數的值可以在執行時更改。此段的大小也固定。

  • 棧(stack):

可讀可寫

儲存的是函式或程式碼中的區域性變數(非 static 變數)

棧的生存期隨程式碼塊持續性,程式碼塊執行就給你分配空間,程式碼塊結束,就自動回收空間

  • 堆(heap):

可讀可寫

儲存的是程式執行期間動態分配的 malloc/realloc 的空間

堆的生存期隨程式持續性,從 malloc/realloc 到 free 一直存在

下面是這些區域的構成圖

exec 系統呼叫是一些函式的集合,這些函式是

  • execl
  • execle
  • execlp
  • execv
  • execve
  • execvp

下面來看一下 exec 的工作原理

  1. 當前程式映像被替換為新的程式映像
  2. 新的程式映像是你做為 exec 傳遞的燦睡
  3. 結束當前正在執行的程式
  4. 新的程式映像有 PID,相同的環境和一些檔案描述符(因為未替換程式,只是替換了程式映像)
  5. CPU 狀態和虛擬記憶體受到影響,當前程式映像的虛擬記憶體對映被新程式映像的虛擬記憶體代替。

waitpid

等待子程式結束或終止

exit

在許多計算機作業系統上,計算機程式的終止是通過執行 exit 系統呼叫命令執行的。0 表示程式能夠正常結束,其他值表示程式以非正常的行為結束。

其他一些常見的系統呼叫如下

系統呼叫指令 描述
pause 掛起訊號
nice 改變分時程式的優先順序
ptrace 程式跟蹤
kill 向程式傳送訊號
pipe 建立管道
mkfifo 建立 fifo 的特殊檔案(命名管道)
sigaction 設定對指定訊號的處理方法
msgctl 訊息控制操作
semctl 訊號量控制

Linux 程式和執行緒的實現

Linux 程式

在 Linux 核心結構中,程式會被表示為 任務,通過結構體 structure 來建立。不像其他的作業系統會區分程式、輕量級程式和執行緒,Linux 統一使用任務結構來代表執行上下文。因此,對於每個單執行緒程式來說,單執行緒程式將用一個任務結構表示,對於多執行緒程式來說,將為每一個使用者級執行緒分配一個任務結構。Linux 核心是多執行緒的,並且核心級執行緒不與任何使用者級執行緒相關聯。

對於每個程式來說,在記憶體中都會有一個 task_struct 程式描述符與之對應。程式描述符包含了核心管理程式所有有用的資訊,包括 排程引數、開啟檔案描述符等等。程式描述符從程式建立開始就一直存在於核心堆疊中。

Linux 和 Unix 一樣,都是通過 PID 來區分不同的程式,核心會將所有程式的任務結構組成為一個雙向連結串列。PID 能夠直接被對映稱為程式的任務結構所在的地址,從而不需要遍歷雙向連結串列直接訪問。

我們上面提到了程式描述符,這是一個非常重要的概念,我們上面還提到了程式描述符是位於記憶體中的,這裡我們省略了一句話,那就是程式描述符是存在使用者的任務結構中,當程式位於記憶體並開始執行時,程式描述符才會被調入記憶體。

程式位於記憶體被稱為 PIM(Process In Memory) ,這是馮諾伊曼體系架構的一種體現,載入到記憶體中並執行的程式稱為程式。簡單來說,一個程式就是正在執行的程式。

程式描述符可以歸為下面這幾類

  • 排程引數(scheduling parameters):程式優先順序、最近消耗 CPU 的時間、最近睡眠時間一起決定了下一個需要執行的程式
  • 記憶體映像(memory image):我們上面說到,程式映像是執行程式時所需要的可執行檔案,它由資料和程式碼組成。
  • 訊號(signals):顯示哪些訊號被捕獲、哪些訊號被執行
  • 暫存器:當發生核心陷入 (trap) 時,暫存器的內容會被儲存下來。
  • 系統呼叫狀態(system call state):當前系統呼叫的資訊,包括引數和結果
  • 檔案描述符表(file descriptor table):有關檔案描述符的系統被呼叫時,檔案描述符作為索引在檔案描述符表中定位相關檔案的 i-node 資料結構
  • 統計資料(accounting):記錄使用者、程式佔用系統 CPU 時間表的指標,一些作業系統還儲存程式最多佔用的 CPU 時間、程式擁有的最大堆疊空間、程式可以消耗的頁面數等。
  • 核心堆疊(kernel stack):程式的核心部分可以使用的固定堆疊
  • 其他: 當前程式狀態、事件等待時間、距離警報的超時時間、PID、父程式的 PID 以及使用者識別符號等

有了上面這些資訊,現在就很容易描述在 Linux 中是如何建立這些程式的了,建立新流程實際上非常簡單。為子程式開闢一塊新的使用者空間的程式描述符,然後從父程式複製大量的內容。為這個子程式分配一個 PID,設定其記憶體對映,賦予它訪問父程式檔案的許可權,註冊並啟動

當執行 fork 系統呼叫時,呼叫程式會陷入核心並建立一些和任務相關的資料結構,比如核心堆疊(kernel stack)thread_info 結構。

關於 thread_info 結構可以參考

https://docs.huihoo.com/doxygen/linux/kernel/3.7/arch_2avr32_2include_2asm_2thread__info_8h_source.html

這個結構中包含程式描述符,程式描述符位於固定的位置,使得 Linux 系統只需要很小的開銷就可以定位到一個執行中程式的資料結構。

程式描述符的主要內容是根據父程式的描述符來填充。Linux 作業系統會尋找一個可用的 PID,並且此 PID 沒有被任何程式使用,更新程式標示符使其指向一個新的資料結構即可。為了減少 hash table 的碰撞,程式描述符會形成連結串列。它還將 task_struct 的欄位設定為指向任務陣列上相應的上一個/下一個程式。

task_struct : Linux 程式描述符,內部涉及到眾多 C++ 原始碼,我們會在後面進行講解。

從原則上來說,為子程式開闢記憶體區域併為子程式分配資料段、堆疊段,並且對父程式的內容進行復制,但是實際上 fork 完成後,子程式和父程式沒有共享記憶體,所以需要複製技術來實現同步,但是複製開銷比較大,因此 Linux 作業系統使用了一種 欺騙 方式。即為子程式分配頁表,然後新分配的頁表指向父程式的頁面,同時這些頁面是隻讀的。當程式向這些頁面進行寫入的時候,會開啟保護錯誤。核心發現寫入操作後,會為程式分配一個副本,使得寫入時把資料複製到這個副本上,這個副本是共享的,這種方式稱為 寫入時複製(copy on write),這種方式避免了在同一塊記憶體區域維護兩個副本的必要,節省記憶體空間。

在子程式開始執行後,作業系統會呼叫 exec 系統呼叫,核心會進行查詢驗證可執行檔案,把引數和環境變數複製到核心,釋放舊的地址空間。

現在新的地址空間需要被建立和填充。如果系統支援對映檔案,就像 Unix 系統一樣,那麼新的頁表就會建立,表明記憶體中沒有任何頁,除非所使用的頁面是堆疊頁,其地址空間由磁碟上的可執行檔案支援。新程式開始執行時,立刻會收到一個缺頁異常(page fault),這會使具有程式碼的頁面載入進入記憶體。最後,引數和環境變數被複制到新的堆疊中,重置訊號,暫存器全部清零。新的命令開始執行。

下面是一個示例,使用者輸出 ls,shell 會呼叫 fork 函式複製一個新程式,shell 程式會呼叫 exec 函式用可執行檔案 ls 的內容覆蓋它的記憶體。

Linux 執行緒

現在我們來討論一下 Linux 中的執行緒,執行緒是輕量級的程式,想必這句話你已經聽過很多次了,輕量級體現在所有的程式切換都需要清除所有的表、程式間的共享資訊也比較麻煩,一般來說通過管道或者共享記憶體,如果是 fork 函式後的父子程式則使用共享檔案,然而執行緒切換不需要像程式一樣具有昂貴的開銷,而且執行緒通訊起來也更方便。執行緒分為兩種:使用者級執行緒和核心級執行緒

使用者級執行緒

使用者級執行緒避免使用核心,通常,每個執行緒會顯示呼叫開關,傳送訊號或者執行某種切換操作來放棄 CPU,同樣,計時器可以強制進行開關,使用者執行緒的切換速度通常比核心執行緒快很多。在使用者級別實現執行緒會有一個問題,即單個執行緒可能會壟斷 CPU 時間片,導致其他執行緒無法執行從而 餓死。如果執行一個 I/O 操作,那麼 I/O 會阻塞,其他執行緒也無法執行。

一種解決方案是,一些使用者級的執行緒包解決了這個問題。可以使用時鐘週期的監視器來控制第一時間時間片獨佔。然後,一些庫通過特殊的包裝來解決系統呼叫的 I/O 阻塞問題,或者可以為非阻塞 I/O 編寫任務。

核心級執行緒

核心級執行緒通常使用幾個程式表在核心中實現,每個任務都會對應一個程式表。在這種情況下,核心會在每個程式的時間片內排程每個執行緒。

所有能夠阻塞的呼叫都會通過系統呼叫的方式來實現,當一個執行緒阻塞時,核心可以進行選擇,是執行在同一個程式中的另一個執行緒(如果有就緒執行緒的話)還是執行一個另一個程式中的執行緒。

從使用者空間 -> 核心空間 -> 使用者空間的開銷比較大,但是執行緒初始化的時間損耗可以忽略不計。這種實現的好處是由時鐘決定執行緒切換時間,因此不太可能將時間片與任務中的其他執行緒佔用時間繫結到一起。同樣,I/O 阻塞也不是問題。

混合實現

結合使用者空間和核心空間的優點,設計人員採用了一種核心級執行緒的方式,然後將使用者級執行緒與某些或者全部核心執行緒多路複用起來

在這種模型中,程式設計人員可以自由控制使用者執行緒和核心執行緒的數量,具有很大的靈活度。採用這種方法,核心只識別核心級執行緒,並對其進行排程。其中一些核心級執行緒會被多個使用者級執行緒多路複用。

Linux 排程

下面我們來關注一下 Linux 系統的排程演算法,首先需要認識到,Linux 系統的執行緒是核心執行緒,所以 Linux 系統是基於執行緒的,而不是基於程式的。

為了進行排程,Linux 系統將執行緒分為三類

  • 實時先入先出
  • 實時輪詢
  • 分時

實時先入先出執行緒具有最高優先順序,它不會被其他執行緒所搶佔,除非那是一個剛剛準備好的,擁有更高優先順序的執行緒進入。實時輪轉執行緒與實時先入先出執行緒基本相同,只是每個實時輪轉執行緒都有一個時間量,時間到了之後就可以被搶佔。如果多個實時執行緒準備完畢,那麼每個執行緒執行它時間量所規定的時間,然後插入到實時輪轉執行緒末尾。

注意這個實時只是相對的,無法做到絕對的實時,因為執行緒的執行時間無法確定。它們相對分時系統來說,更加具有實時性

Linux 系統會給每個執行緒分配一個 nice 值,這個值代表了優先順序的概念。nice 值預設值是 0 ,但是可以通過系統呼叫 nice 值來修改。修改值的範圍從 -20 - +19。nice 值決定了執行緒的靜態優先順序。一般系統管理員的 nice 值會比一般執行緒的優先順序高,它的範圍是 -20 - -1。

下面我們更詳細的討論一下 Linux 系統的兩個排程演算法,它們的內部與排程佇列(runqueue) 的設計很相似。執行佇列有一個資料結構用來監視系統中所有可執行的任務並選擇下一個可以執行的任務。每個執行佇列和系統中的每個 CPU 有關。

Linux O(1) 排程器是歷史上很流行的一個排程器。這個名字的由來是因為它能夠在常數時間內執行任務排程。在 O(1) 排程器裡,排程佇列被組織成兩個陣列,一個是任務正在活動的陣列,一個是任務過期失效的陣列。如下圖所示,每個陣列都包含了 140 個連結串列頭,每個連結串列頭具有不同的優先順序。

大致流程如下:

排程器從正在活動陣列中選擇一個優先順序最高的任務。如果這個任務的時間片過期失效了,就把它移動到過期失效陣列中。如果這個任務阻塞了,比如說正在等待 I/O 事件,那麼在它的時間片過期失效之前,一旦 I/O 操作完成,那麼這個任務將會繼續執行,它將被放回到之前正在活動的陣列中,因為這個任務之前已經消耗一部分 CPU 時間片,所以它將執行剩下的時間片。當這個任務執行完它的時間片後,它就會被放到過期失效陣列中。一旦正在活動的任務陣列中沒有其他任務後,排程器將會交換指標,使得正在活動的陣列變為過期失效陣列,過期失效陣列變為正在活動的陣列。使用這種方式可以保證每個優先順序的任務都能夠得到執行,不會導致執行緒飢餓。

在這種排程方式中,不同優先順序的任務所得到 CPU 分配的時間片也是不同的,高優先順序程式往往能得到較長的時間片,低優先順序的任務得到較少的時間片。

這種方式為了保證能夠更好的提供服務,通常會為 互動式程式 賦予較高的優先順序,互動式程式就是使用者程式

Linux 系統不知道一個任務究竟是 I/O 密集型的還是 CPU 密集型的,它只是依賴於互動式的方式,Linux 系統會區分是靜態優先順序 還是 動態優先順序。動態優先順序是採用一種獎勵機制來實現的。獎勵機制有兩種方式:獎勵互動式執行緒、懲罰佔用 CPU 的執行緒。在 Linux O(1) 排程器中,最高的優先順序獎勵是 -5,注意這個優先順序越低越容易被執行緒排程器接受,所以最高懲罰的優先順序是 +5。具體體現就是作業系統維護一個名為 sleep_avg 的變數,任務喚醒會增加 sleep_avg 變數的值,當任務被搶佔或者時間量過期會減少這個變數的值,反映在獎勵機制上。

O(1) 排程演算法是 2.6 核心版本的排程器,最初引入這個排程演算法的是不穩定的 2.5 版本。早期的排程演算法在多處理器環境中說明了通過訪問正在活動陣列就可以做出排程的決定。使排程可以在固定的時間 O(1) 完成。

O(1) 排程器使用了一種 啟發式 的方式,這是什麼意思?

在電腦科學中,啟發式是一種當傳統方式解決問題很慢時用來快速解決問題的方式,或者找到一個在傳統方法無法找到任何精確解的情況下找到近似解。

O(1) 使用啟發式的這種方式,會使任務的優先順序變得複雜並且不完善,從而導致在處理互動任務時效能很糟糕。

為了改進這個缺點,O(1) 排程器的開發者又提出了一個新的方案,即 公平排程器(Completely Fair Scheduler, CFS)。 CFS 的主要思想是使用一顆紅黑樹作為排程佇列。

資料結構太重要了。

CFS 會根據任務在 CPU 上的執行時間長短而將其有序地排列在樹中,時間精確到納秒級。下面是 CFS 的構造模型

CFS 的排程過程如下:

CFS 演算法總是優先排程哪些使用 CPU 時間最少的任務。最小的任務一般都是在最左邊的位置。當有一個新的任務需要執行時,CFS 會把這個任務和最左邊的數值進行對比,如果此任務具有最小時間值,那麼它將進行執行,否則它會進行比較,找到合適的位置進行插入。然後 CPU 執行紅黑樹上當前比較的最左邊的任務。

在紅黑樹中選擇一個節點來執行的時間可以是常數時間,但是插入一個任務的時間是 O(loog(N)),其中 N 是系統中的任務數。考慮到當前系統的負載水平,這是可以接受的。

排程器只需要考慮可執行的任務即可。這些任務被放在適當的排程佇列中。不可執行的任務和正在等待的各種 I/O 操作或核心事件的任務被放入一個等待佇列中。等待佇列頭包含一個指向任務連結串列的指標和一個自旋鎖。自旋鎖對於併發處理場景下用處很大。

Linux 系統中的同步

下面來聊一下 Linux 中的同步機制。早期的 Linux 核心只有一個 大核心鎖(Big Kernel Lock,BKL) 。它阻止了不同處理器併發處理的能力。因此,需要引入一些粒度更細的鎖機制。

Linux 提供了若干不同型別的同步變數,這些變數既能夠在核心中使用,也能夠在使用者應用程式中使用。在地層中,Linux 通過使用 atomic_setatomic_read 這樣的操作為硬體支援的原子指令提供封裝。硬體提供記憶體重排序,這是 Linux 屏障的機制。

具有高階別的同步像是自旋鎖的描述是這樣的,當兩個程式同時對資源進行訪問,在一個程式獲得資源後,另一個程式不想被阻塞,所以它就會自旋,等待一會兒再對資源進行訪問。Linux 也提供互斥量或訊號量這樣的機制,也支援像是 mutex_tryLockmutex_tryWait 這樣的非阻塞呼叫。也支援中斷處理事務,也可以通過動態禁用和啟用相應的中斷來實現。

Linux 啟動

下面來聊一聊 Linux 是如何啟動的。

當計算機電源通電後,BIOS會進行開機自檢(Power-On-Self-Test, POST),對硬體進行檢測和初始化。因為作業系統的啟動會使用到磁碟、螢幕、鍵盤、滑鼠等裝置。下一步,磁碟中的第一個分割槽,也被稱為 MBR(Master Boot Record) 主開機記錄,被讀入到一個固定的記憶體區域並執行。這個分割槽中有一個非常小的,只有 512 位元組的程式。程式從磁碟中調入 boot 獨立程式,boot 程式將自身複製到高位地址的記憶體從而為作業系統釋放低位地址的記憶體。

複製完成後,boot 程式讀取啟動裝置的根目錄。boot 程式要理解檔案系統和目錄格式。然後 boot 程式被調入核心,把控制權移交給核心。直到這裡,boot 完成了它的工作。系統核心開始執行。

核心啟動程式碼是使用組合語言完成的,主要包括建立核心堆疊、識別 CPU 型別、計算記憶體、禁用中斷、啟動記憶體管理單元等,然後呼叫 C 語言的 main 函式執行作業系統部分。

這部分也會做很多事情,首先會分配一個訊息緩衝區來存放除錯出現的問題,除錯資訊會寫入緩衝區。如果除錯出現錯誤,這些資訊可以通過診斷程式調出來。

然後作業系統會進行自動配置,檢測裝置,載入配置檔案,被檢測裝置如果做出響應,就會被新增到已連結的裝置表中,如果沒有相應,就歸為未連線直接忽略。

配置完所有硬體後,接下來要做的就是仔細手工處理程式0,設定其堆疊,然後執行它,執行初始化、配置時鐘、掛載檔案系統。建立 init 程式(程式 1 )守護程式(程式 2)

init 程式會檢測它的標誌以確定它是否為單使用者還是多使用者服務。在前一種情況中,它會呼叫 fork 函式建立一個 shell 程式,並且等待這個程式結束。後一種情況呼叫 fork 函式建立一個執行系統初始化的 shell 指令碼(即 /etc/rc)的程式,這個程式可以進行檔案系統一致性檢測、掛載檔案系統、開啟守護程式等。

然後 /etc/rc 這個程式會從 /etc/ttys 中讀取資料,/etc/ttys 列出了所有的終端和屬性。對於每一個啟用的終端,這個程式呼叫 fork 函式建立一個自身的副本,進行內部處理並執行一個名為 getty 的程式。

getty 程式會在終端上輸入

login:

等待使用者輸入使用者名稱,在輸入使用者名稱後,getty 程式結束,登陸程式 /bin/login 開始執行。login 程式需要輸入密碼,並與儲存在 /etc/passwd 中的密碼進行對比,如果輸入正確,login 程式以使用者 shell 程式替換自身,等待第一個命令。如果不正確,login 程式要求輸入另一個使用者名稱。

整個系統啟動過程如下

Linux 記憶體管理

Linux 記憶體管理模型非常直接明瞭,因為 Linux 的這種機制使其具有可移植性並且能夠在記憶體管理單元相差不大的機器下實現 Linux,下面我們就來認識一下 Linux 記憶體管理是如何實現的。

基本概念

每個 Linux 程式都會有地址空間,這些地址空間由三個段區域組成:text 段、data 段、stack 段。下面是程式地址空間的示例。

資料段(data segment) 包含了程式的變數、字串、陣列和其他資料的儲存。資料段分為兩部分,已經初始化的資料和尚未初始化的資料。其中尚未初始化的資料就是我們說的 BSS。資料段部分的初始化需要編譯就期確定的常量以及程式啟動就需要一個初始值的變數。所有 BSS 部分中的變數在載入後被初始化為 0 。

程式碼段(Text segment) 不一樣,data segment 資料段可以改變。程式總是修改它的變數。而且,許多程式需要在執行時動態分配空間。Linux 允許資料段隨著記憶體的分配和回收從而增大或者減小。為了分配記憶體,程式可以增加資料段的大小。在 C 語言中有一套標準庫 malloc 經常用於分配記憶體。程式地址空間描述符包含動態分配的記憶體區域稱為 堆(heap)

第三部分段是 棧段(stack segment)。在大部分機器上,棧段會在虛擬記憶體地址頂部地址位置處,並向低位置處(向地址空間為 0 處)擴充。舉個例子來說,在 32 位 x86 架構的機器上,棧開始於 0xC0000000,這是使用者模式下程式允許可見的 3GB 虛擬地址限制。如果棧一直增大到超過棧段後,就會發生硬體故障並把頁面下降一個頁面。

當程式啟動時,棧區域並不是空的,相反,它會包含所有的 shell 環境變數以及為了呼叫它而向 shell 輸入的命令列。舉個例子,當你輸入

cp cxuan lx

時,cp 程式會執行並在棧中帶著字串 cp cxuan lx ,這樣就能夠找出原始檔和目標檔案的名稱。

當兩個使用者執行在相同程式中,例如編輯器(editor),那麼就會在記憶體中保持編輯器程式程式碼的兩個副本,但是這種方式並不高效。Linux 系統支援共享文字段作為替代。下面圖中我們會看到 A 和 B 兩個程式,它們有著相同的文字區域。

資料段和棧段只有在 fork 之後才會共享,共享也是共享未修改過的頁面。如果任何一個都需要變大但是沒有相鄰空間容納的話,也不會有問題,因為相鄰的虛擬頁面不必對映到相鄰的物理頁面上。

除了動態分配更多的記憶體,Linux 中的程式可以通過記憶體對映檔案來訪問檔案資料。這個特性可以使我們把一個檔案對映到程式空間的一部分而該檔案就可以像位於記憶體中的位元組陣列一樣被讀寫。把一個檔案對映進來使得隨機讀寫比使用 read 和 write 之類的 I/O 系統呼叫要容易得多。共享庫的訪問就是使用了這種機制。如下所示

我們可以看到兩個相同檔案會被對映到相同的實體地址上,但是它們屬於不同的地址空間。

對映檔案的優點是,兩個或多個程式可以同時對映到同一檔案中,任意一個程式對檔案的寫操作對其他檔案可見。通過使用對映臨時檔案的方式,可以為多執行緒共享記憶體提供高頻寬,臨時檔案在程式退出後消失。但是實際上,並沒有兩個相同的地址空間,因為每個程式維護的開啟檔案和訊號不同。

Linux 記憶體管理系統呼叫

下面我們探討一下關於記憶體管理的系統呼叫方式。事實上,POSIX 並沒有給記憶體管理指定任何的系統呼叫。然而,Linux 卻有自己的記憶體系統呼叫,主要系統呼叫如下

系統呼叫 描述
s = brk(addr) 改變資料段大小
a = mmap(addr,len,prot,flags,fd,offset) 進行對映
s = unmap(addr,len) 取消對映

如果遇到錯誤,那麼 s 的返回值是 -1,a 和 addr 是記憶體地址,len 表示的是長度,prot 表示的是控制保護位,flags 是其他標誌位,fd 是檔案描述符,offset 是檔案偏移量。

brk 通過給出超過資料段之外的第一個位元組地址來指定資料段的大小。如果新的值要比原來的大,那麼資料區會變得越來越大,反之會越來越小。

mmapunmap 系統呼叫會控制對映檔案。mmp 的第一個引數 addr 決定了檔案對映的地址。它必須是頁面大小的倍數。如果引數是 0,系統會分配地址並返回 a。第二個引數是長度,它告訴了需要對映多少位元組。它也是頁面大小的倍數。prot 決定了對映檔案的保護位,保護位可以標記為 可讀、可寫、可執行或者這些的結合。第四個引數 flags 能夠控制檔案是私有的還是可讀的以及 addr 是必須的還是隻是進行提示。第五個引數 fd 是要對映的檔案描述符。只有開啟的檔案是可以被對映的,因此如果想要進行檔案對映,必須開啟檔案;最後一個引數 offset 會指示檔案從什麼時候開始,並不一定每次都要從零開始。

Linux 記憶體管理實現

記憶體管理系統是作業系統最重要的部分之一。從計算機早期開始,我們實際使用的記憶體都要比系統中實際存在的記憶體多。記憶體分配策略克服了這一限制,並且其中最有名的就是 虛擬記憶體(virtual memory)。通過在多個競爭的程式之間共享虛擬記憶體,虛擬記憶體得以讓系統有更多的記憶體。虛擬記憶體子系統主要包括下面這些概念。

大地址空間

作業系統使系統使用起來好像比實際的實體記憶體要大很多,那是因為虛擬記憶體要比實體記憶體大很多倍。

保護

系統中的每個程式都會有自己的虛擬地址空間。這些虛擬地址空間彼此完全分開,因此執行一個應用程式的程式不會影響另一個。並且,硬體虛擬記憶體機制允許記憶體保護關鍵記憶體區域。

記憶體對映

記憶體對映用來向程式地址空間對映影像和資料檔案。在記憶體對映中,檔案的內容直接對映到程式的虛擬空間中。

公平的實體記憶體分配

記憶體管理子系統允許系統中的每個正在執行的程式公平分配系統的實體記憶體。

共享虛擬記憶體

儘管虛擬記憶體讓程式有自己的記憶體空間,但是有的時候你是需要共享記憶體的。例如幾個程式同時在 shell 中執行,這會涉及到 IPC 的程式間通訊問題,這個時候你需要的是共享記憶體來進行資訊傳遞而不是通過拷貝每個程式的副本獨立執行。

下面我們就正式探討一下什麼是 虛擬記憶體

虛擬記憶體的抽象模型

在考慮 Linux 用於支援虛擬記憶體的方法之前,考慮一個不會被太多細節困擾的抽象模型是很有用的。

處理器在執行指令時,會從記憶體中讀取指令並將其解碼(decode),在指令解碼時會獲取某個位置的內容並將他存到記憶體中。然後處理器繼續執行下一條指令。這樣,處理器總是在訪問儲存器以獲取指令和儲存資料。

在虛擬記憶體系統中,所有的地址空間都是虛擬的而不是物理的。但是實際儲存和提取指令的是實體地址,所以需要讓處理器根據作業系統維護的一張表將虛擬地址轉換為實體地址。

為了簡單的完成轉換,虛擬地址和實體地址會被分為固定大小的塊,稱為 頁(page)。這些頁有相同大小,如果頁面大小不一樣的話,那麼作業系統將很難管理。Alpha AXP系統上的 Linux 使用 8 KB 頁面,而 Intel x86 系統上的 Linux 使用 4 KB 頁面。每個頁面都有一個唯一的編號,即頁面框架號(PFN)

上面就是 Linux 記憶體對映模型了,在這個頁模型中,虛擬地址由兩部分組成:偏移量和虛擬頁框號。每次處理器遇到虛擬地址時都會提取偏移量和虛擬頁框號。處理器必須將虛擬頁框號轉換為物理頁號,然後以正確的偏移量的位置訪問物理頁。

上圖中展示了兩個程式 A 和 B 的虛擬地址空間,每個程式都有自己的頁表。這些頁表將程式中的虛擬頁對映到記憶體中的物理頁中。頁表中每一項均包含

  • 有效標誌(valid flag): 表明此頁表條目是否有效
  • 該條目描述的物理頁框號
  • 訪問控制資訊,頁面使用方式,是否可寫以及是否可以執行程式碼

要將處理器的虛擬地址對映為記憶體的實體地址,首先需要計算虛擬地址的頁框號和偏移量。頁面大小為 2 的次冪,可以通過移位完成操作。

如果當前程式嘗試訪問虛擬地址,但是訪問不到的話,這種情況稱為 缺頁異常,此時虛擬作業系統的錯誤地址和頁面錯誤的原因將通知作業系統。

通過以這種方式將虛擬地址對映到實體地址,虛擬記憶體可以以任何順序對映到系統的物理頁面。

按需分頁

由於實體記憶體要比虛擬記憶體少很多,因此作業系統需要注意儘量避免直接使用低效的實體記憶體。節省實體記憶體的一種方式是僅載入執行程式當前使用的頁面(這何嘗不是一種懶載入的思想呢?)。例如,可以執行資料庫來查詢資料庫,在這種情況下,不是所有的資料都裝入記憶體,只裝載需要檢查的資料。這種僅僅在需要時才將虛擬頁面載入進內中的技術稱為按需分頁。

交換

如果某個程式需要將虛擬頁面傳入記憶體,但是此時沒有可用的物理頁面,那麼作業系統必須丟棄實體記憶體中的另一個頁面來為該頁面騰出空間。

如果頁面已經修改過,那麼作業系統必須保留該頁面的內容,以便以後可以訪問它。這種型別的頁面被稱為髒頁,當將其從記憶體中移除時,它會儲存在稱為交換檔案的特殊檔案中。相對於處理器和實體記憶體的速度,對交換檔案的訪問非常慢,並且作業系統需要兼顧將頁面寫到磁碟的以及將它們保留在記憶體中以便再次使用。

Linux 使用最近最少使用(LRU)頁面老化技術來公平的選擇可能會從系統中刪除的頁面,這個方案涉及系統中的每個頁面,頁面的年齡隨著訪問次數的變化而變化,如果某個頁面訪問次數多,那麼該頁就表示越 年輕,如果某個呃頁面訪問次數太少,那麼該頁越容易被換出

物理和虛擬定址模式

大多數多功能處理器都支援 實體地址模式和虛擬地址模式的概念。物理定址模式不需要頁表,並且處理器不會在此模式下嘗試執行任何地址轉換。 Linux 核心被連結在實體地址空間中執行。

Alpha AXP 處理器沒有物理定址模式。相反,它將記憶體空間劃分為幾個區域,並將其中兩個指定為物理對映的地址。此核心地址空間稱為 KSEG 地址空間,它包含從 0xfffffc0000000000 向上的所有地址。為了從 KSEG 中連結的程式碼(按照定義,核心程式碼)執行或訪問其中的資料,該程式碼必須在核心模式下執行。連結到 Alpha 上的 Linux核心以從地址 0xfffffc0000310000 執行。

訪問控制

頁面表的每一項還包含訪問控制資訊,訪問控制資訊主要檢查程式是否應該訪問記憶體。

必要時需要對記憶體進行訪問限制。 例如包含可執行程式碼的記憶體,自然是隻讀記憶體; 作業系統不應允許程式通過其可執行程式碼寫入資料。 相比之下,包含資料的頁面可以被寫入,但是嘗試執行該記憶體的指令將失敗。 大多數處理器至少具有兩種執行模式:核心態和使用者態。 你不希望訪問使用者執行核心程式碼或核心資料結構,除非處理器以核心模式執行。

訪問控制資訊被儲存在上面的 Page Table Entry ,頁表項中,上面這幅圖是 Alpha AXP的 PTE。位欄位具有以下含義

  • V

表示 valid ,是否有效位

  • FOR

讀取時故障,在嘗試讀取此頁面時出現故障

  • FOW

寫入時錯誤,在嘗試寫入時發生錯誤

  • FOE

執行時發生錯誤,在嘗試執行此頁面中的指令時,處理器都會報告頁面錯誤並將控制權傳遞給作業系統,

  • ASM

地址空間匹配,當作業系統希望清除轉換緩衝區中的某些條目時,將使用此選項。

  • GH

當在使用單個轉換緩衝區條目而不是多個轉換緩衝區條目對映整個塊時使用的提示。

  • KRE

核心模式執行下的程式碼可以讀取頁面

  • URE

使用者模式下的程式碼可以讀取頁面

  • KWE

以核心模式執行的程式碼可以寫入頁面

  • UWE

以使用者模式執行的程式碼可以寫入頁面

  • 頁框號

對於設定了 V 位的 PTE,此欄位包含此 PTE 的物理頁面幀號(頁面幀號)。對於無效的 PTE,如果此欄位不為零,則包含有關頁面在交換檔案中的位置的資訊。

除此之外,Linux 還使用了兩個位

  • _PAGE_DIRTY

如果已設定,則需要將頁面寫出到交換檔案中

  • _PAGE_ACCESSED

Linux 用來將頁面標記為已訪問。

快取

上面的虛擬記憶體抽象模型可以用來實施,但是效率不會太高。作業系統和處理器設計人員都嘗試提高效能。 但是除了提高處理器,記憶體等的速度之外,最好的方法就是維護有用資訊和資料的快取記憶體,從而使某些操作更快。在 Linux 中,使用很多和記憶體管理有關的緩衝區,使用緩衝區來提高效率。

緩衝區快取

緩衝區快取記憶體包含塊裝置驅動程式使用的資料緩衝區。

還記得什麼是塊裝置麼?這裡回顧下

塊裝置是一個能儲存固定大小塊資訊的裝置,它支援以固定大小的塊,扇區或群集讀取和(可選)寫入資料。每個塊都有自己的實體地址。通常塊的大小在 512 - 65536 之間。所有傳輸的資訊都會以連續的塊為單位。塊裝置的基本特徵是每個塊都較為對立,能夠獨立的進行讀寫。常見的塊裝置有 硬碟、藍光光碟、USB 盤

與字元裝置相比,塊裝置通常需要較少的引腳。

緩衝區快取記憶體通過裝置識別符號和塊編號用於快速查詢資料塊。 如果可以在緩衝區快取記憶體中找到資料,則無需從物理塊裝置中讀取資料,這種訪問方式要快得多。

頁快取

頁快取用於加快對磁碟上影像和資料的訪問

它用於一次一頁地快取檔案中的內容,並且可以通過檔案和檔案中的偏移量進行訪問。當頁面從磁碟讀入記憶體時,它們被快取在頁面快取中。

交換區快取

僅僅已修改(髒頁)被儲存在交換檔案中

只要這些頁面在寫入交換檔案後沒有修改,則下次交換該頁面時,無需將其寫入交換檔案,因為該頁面已在交換檔案中。 可以直接丟棄。 在大量交換的系統中,這節省了許多不必要的和昂貴的磁碟操作。

硬體快取

處理器中通常使用一種硬體快取。頁表條目的快取。在這種情況下,處理器並不總是直接讀取頁表,而是根據需要快取頁的翻譯。 這些是轉換後備緩衝區 也被稱為 TLB,包含來自系統中一個或多個程式的頁表項的快取副本。

引用虛擬地址後,處理器將嘗試查詢匹配的 TLB 條目。 如果找到,則可以將虛擬地址直接轉換為實體地址,並對資料執行正確的操作。 如果處理器找不到匹配的 TLB 條目, 它通過向作業系統發訊號通知已發生 TLB 丟失獲得作業系統的支援和幫助。系統特定的機制用於將該異常傳遞給可以修復問題的作業系統程式碼。 作業系統為地址對映生成一個新的 TLB 條目。 清除異常後,處理器將再次嘗試轉換虛擬地址。這次能夠執行成功。

使用快取也存在缺點,為了節省精力,Linux 必須使用更多的時間和空間來維護這些快取,並且如果快取損壞,系統將會崩潰。

Linux 頁表

Linux 假定頁表分為三個級別。訪問的每個頁表都包含下一級頁表

圖中的 PDG 表示全域性頁表,當建立一個新的程式時,都要為新程式建立一個新的頁面目錄,即 PGD。

要將虛擬地址轉換為實體地址,處理器必須獲取每個級別欄位的內容,將其轉換為包含頁表的物理頁的偏移量,並讀取下一級頁表的頁框號。這樣重複三次,直到找到包含虛擬地址的物理頁面的頁框號為止。

Linux 執行的每個平臺都必須提供翻譯巨集,這些巨集允許核心遍歷特定程式的頁表。這樣,核心無需知道頁表條目的格式或它們的排列方式。

頁分配和取消分配

對系統中物理頁面有很多需求。例如,當影像載入到記憶體中時,作業系統需要分配頁面。

系統中所有物理頁面均由 mem_map 資料結構描述,這個資料結構是 mem_map_t 的列表。它包括一些重要的屬性

  • count :這是頁面的使用者數計數,當頁面在多個程式之間共享時,計數大於 1
  • age:這是描述頁面的年齡,用於確定頁面是否適合丟棄或交換
  • map_nr :這是此mem_map_t描述的物理頁框號。

頁面分配程式碼使用 free_area向量查詢和釋放頁面,free_area 的每個元素都包含有關頁面塊的資訊。

頁面分配

Linux 的頁面分配使用一種著名的夥伴演算法來進行頁面的分配和取消分配。頁面以 2 的冪為單位進行塊分配。這就意味著它可以分配 1頁、2 頁、4頁等等,只要系統中有足夠可用的頁面來滿足需求就可以。判斷的標準是nr_free_pages> min_free_pages,如果滿足,就會在 free_area 中搜尋所需大小的頁面塊完成分配。free_area 的每個元素都有該大小的塊的已分配頁面和空閒頁面塊的對映。

分配演算法會搜尋請求大小的頁面塊。如果沒有任何請求大小的頁面塊可用的話,會搜尋一個是請求大小二倍的頁面塊,然後重複,直到一直搜尋完 free_area 找到一個頁面塊為止。如果找到的頁面塊要比請求的頁面塊大,就會對找到的頁面塊進行細分,直到找到合適的大小塊為止。

因為每個塊都是 2 的次冪,所以拆分過程很容易,因為你只需將塊分成兩半即可。空閒塊在適當的佇列中排隊,分配的頁面塊返回給呼叫者。

如果請求一個 2 個頁的塊,則 4 頁的第一個塊(從第 4 頁的框架開始)將被分成兩個 2 頁的塊。第一個頁面(從第 4 頁的幀開始)將作為分配的頁面返回給呼叫方,第二個塊(從第 6 頁的頁面開始)將作為 2 頁的空閒塊排隊到 free_area 陣列的元素 1 上。

頁面取消分配

上面的這種記憶體方式最造成一種後果,那就是記憶體的碎片化,會將較大的空閒頁面分成較小的頁面。頁面解除分配程式碼會盡可能將頁面重新組合成為更大的空閒塊。每釋放一個頁面,都會檢查相同大小的相鄰的塊,以檢視是否空閒。如果是,則將其與新釋放的頁面塊組合以形成下一個頁面大小塊的新的自由頁面塊。 每次將兩個頁面塊重新組合為更大的空閒頁面塊時,頁面釋放程式碼就會嘗試將該頁面塊重新組合為更大的空閒頁面。 通過這種方式,可用頁面的塊將盡可能多地使用記憶體。

例如上圖,如果要釋放第 1 頁的頁面,則將其與已經空閒的第 0 頁頁面框架組合在一起,並作為大小為 2頁的空閒塊排隊到 free_area 的元素 1 中

記憶體對映

核心有兩種型別的記憶體對映:共享型(shared)私有型(private)。私有型是當程式為了只讀檔案,而不寫檔案時使用,這時,私有對映更加高效。 但是,任何對私有對映頁的寫操作都會導致核心停止對映該檔案中的頁。所以,寫操作既不會改變磁碟上的檔案,對訪問該檔案的其它程式也是不可見的。

按需分頁

一旦可執行映像被記憶體對映到虛擬記憶體後,它就可以被執行了。因為只將映像的開頭部分物理的拉入到記憶體中,因此它將很快訪問實體記憶體尚未存在的虛擬記憶體區域。當程式訪問沒有有效頁表的虛擬地址時,作業系統會報告這項錯誤。

頁面錯誤描述頁面出錯的虛擬地址和引起的記憶體訪問(RAM)型別。

Linux 必須找到代表發生頁面錯誤的記憶體區域的 vm_area_struct 結構。由於搜尋 vm_area_struct 資料結構對於有效處理頁面錯誤至關重要,因此它們以 AVL(Adelson-Velskii和Landis)樹結構連結在一起。如果引起故障的虛擬地址沒有 vm_area_struct 結構,則此程式已經訪問了非法地址,Linux 會向程式發出 SIGSEGV 訊號,如果程式沒有用於該訊號的處理程式,那麼程式將會終止。

然後,Linux 會針對此虛擬記憶體區域所允許的訪問型別,檢查發生的頁面錯誤型別。 如果該程式以非法方式訪問記憶體,例如寫入僅允許讀的區域,則還會發出記憶體訪問錯誤訊號。

現在,Linux 已確定頁面錯誤是合法的,因此必須對其進行處理。

檔案系統

在 Linux 中,最直觀、最可見的部分就是 檔案系統(file system)。下面我們就來一起探討一下關於 Linux 中國的檔案系統,系統呼叫以及檔案系統實現背後的原理和思想。這些思想中有一些來源於 MULTICS,現在已經被 Windows 等其他作業系統使用。Linux 的設計理念就是 小的就是好的(Small is Beautiful) 。雖然 Linux 只是使用了最簡單的機制和少量的系統呼叫,但是 Linux 卻提供了強大而優雅的檔案系統。

Linux 檔案系統基本概念

Linux 在最初的設計是 MINIX1 檔案系統,它只支援 14 位元組的檔名,它的最大檔案只支援到 64 MB。在 MINIX 1 之後的檔案系統是 ext 檔案系統。ext 系統相較於 MINIX 1 來說,在支援位元組大小和檔案大小上均有很大提升,但是 ext 的速度仍沒有 MINIX 1 快,於是,ext 2 被開發出來,它能夠支援長檔名和大檔案,而且具有比 MINIX 1 更好的效能。這使他成為 Linux 的主要檔案系統。只不過 Linux 會使用 VFS 曾支援多種檔案系統。在 Linux 連結時,使用者可以動態的將不同的檔案系統掛載倒 VFS 上。

Linux 中的檔案是一個任意長度的位元組序列,Linux 中的檔案可以包含任意資訊,比如 ASCII 碼、二進位制檔案和其他型別的檔案是不加區分的。

為了方便起見,檔案可以被組織在一個目錄中,目錄儲存成檔案的形式在很大程度上可以作為檔案處理。目錄可以有子目錄,這樣形成有層次的檔案系統,Linux 系統下面的根目錄是 / ,它通常包含了多個子目錄。字元 / 還用於對目錄名進行區分,例如 /usr/cxuan 表示的就是根目錄下面的 usr 目錄,其中有一個叫做 cxuan 的子目錄。

下面我們介紹一下 Linux 系統根目錄下面的目錄名

  • /bin,它是重要的二進位制應用程式,包含二進位制檔案,系統的所有使用者使用的命令都在這裡
  • /boot,啟動包含引導載入程式的相關檔案
  • /dev,包含裝置檔案,終端檔案,USB 或者連線到系統的任何裝置
  • /etc,配置檔案,啟動指令碼等,包含所有程式所需要的配置檔案,也包含了啟動/停止單個應用程式的啟動和關閉 shell 指令碼
  • /home,本地主要路徑,所有使用者用 home 目錄儲存個人資訊
  • /lib,系統庫檔案,包含支援位於 /bin 和 /sbin 下的二進位制庫檔案
  • /lost+found,在根目錄下提供一個遺失+查詢系統,必須在 root 使用者下才能檢視當前目錄下的內容
  • /media,掛載可移動介質
  • /mnt,掛載檔案系統
  • /opt,提供一個可選的應用程式安裝目錄
  • /proc,特殊的動態目錄,用於維護系統資訊和狀態,包括當前執行中程式資訊
  • /root,root 使用者的主要目錄資料夾
  • /sbin,重要的二進位制系統檔案
  • /tmp, 系統和使用者建立的臨時檔案,系統重啟時,這個目錄下的檔案都會被刪除
  • /usr,包含絕大多數使用者都能訪問的應用程式和檔案
  • /var,經常變化的檔案,諸如日誌檔案或資料庫等

在 Linux 中,有兩種路徑,一種是 絕對路徑(absolute path) ,絕對路徑告訴你從根目錄下查詢檔案,絕對路徑的缺點是太長而且不太方便。還有一種是 相對路徑(relative path) ,相對路徑所在的目錄也叫做工作目錄(working directory)

如果 /usr/local/books 是工作目錄,那麼 shell 命令

cp books books-replica 

就表示的是相對路徑,而

cp /usr/local/books/books /usr/local/books/books-replica

則表示的是絕對路徑。

在 Linux 中經常出現一個使用者使用另一個使用者的檔案或者使用檔案樹結構中的檔案。兩個使用者共享同一個檔案,這個檔案位於某個使用者的目錄結構中,另一個使用者需要使用這個檔案時,必須通過絕對路徑才能引用到他。如果絕對路徑很長,那麼每次輸入起來會變的非常麻煩,所以 Linux 提供了一種 連結(link) 機制。

舉個例子,下面是一個使用連結之前的圖

以上所示,比如有兩個工作賬戶 jianshe 和 cxuan,jianshe 想要使用 cxuan 賬戶下的 A 目錄,那麼它可能會輸入 /usr/cxuan/A ,這是一種未使用連結之後的圖。

使用連結後的示意如下

現在,jianshe 可以建立一個連結來使用 cxuan 下面的目錄了。‘

當一個目錄被建立出來後,有兩個目錄項也同時被建立出來,它們就是 ... ,前者代表工作目錄自身,後者代表該目錄的父目錄,也就是該目錄所在的目錄。這樣一來,在 /usr/jianshe 中訪問 cxuan 中的目錄就是 ../cxuan/xxx

Linux 檔案系統不區分磁碟的,這是什麼意思呢?一般來說,一個磁碟中的檔案系統相互之間保持獨立,如果一個檔案系統目錄想要訪問另一個磁碟中的檔案系統,在 Windows 中你可以像下面這樣。

兩個檔案系統分別在不同的磁碟中,彼此保持獨立。

而在 Linux 中,是支援掛載的,它允許一個磁碟掛在到另外一個磁碟上,那麼上面的關係會變成下面這樣

掛在之後,兩個檔案系統就不再需要關心檔案系統在哪個磁碟上了,兩個檔案系統彼此可見。

Linux 檔案系統的另外一個特性是支援 加鎖(locking)。在一些應用中會出現兩個或者更多的程式同時使用同一個檔案的情況,這樣很可能會導致競爭條件(race condition)。一種解決方法是對其進行加不同粒度的鎖,就是為了防止某一個程式只修改某一行記錄從而導致整個檔案都不能使用的情況。

POSIX 提供了一種靈活的、不同粒度級別的鎖機制,允許一個程式使用一個不可分割的操作對一個位元組或者整個檔案進行加鎖。加鎖機制要求嘗試加鎖的程式指定其 要加鎖的檔案,開始位置以及要加鎖的位元組

Linux 系統提供了兩種鎖:共享鎖和互斥鎖。如果檔案的一部分已經加上了共享鎖,那麼再加排他鎖是不會成功的;如果檔案系統的一部分已經被加了互斥鎖,那麼在互斥鎖解除之前的任何加鎖都不會成功。為了成功加鎖、請求加鎖的部分的所有位元組都必須是可用的。

在加鎖階段,程式需要設計好加鎖失敗後的情況,也就是判斷加鎖失敗後是否選擇阻塞,如果選擇阻塞式,那麼當已經加鎖的程式中的鎖被刪除時,這個程式會解除阻塞並替換鎖。如果程式選擇非阻塞式的,那麼就不會替換這個鎖,會立刻從系統呼叫中返回,標記狀態碼錶示是否加鎖成功,然後程式會選擇下一個時間再次嘗試。

加鎖區域是可以重疊的。下面我們演示了三種不同條件的加鎖區域。

如上圖所示,A 的共享鎖在第四位元組到第八位元組進行加鎖

如上圖所示,程式在 A 和 B 上同時加了共享鎖,其中 6 - 8 位元組是重疊鎖

如上圖所示,程式 A 和 B 和 C 同時加了共享鎖,那麼第六位元組和第七位元組是共享鎖。

如果此時一個程式嘗試在第 6 個位元組處加鎖,此時會設定失敗並阻塞,由於該區域被 A B C 同時加鎖,那麼只有等到 A B C 都釋放鎖後,程式才能加鎖成功。

Linux 檔案系統呼叫

許多系統呼叫都會和檔案與檔案系統有關。我們首先先看一下對單個檔案的系統呼叫,然後再來看一下對整個目錄和檔案的系統呼叫。

為了建立一個新的檔案,會使用到 creat 方法,注意沒有 e

這裡說一個小插曲,曾經有人問 UNIX 創始人 Ken Thompson,如果有機會重新寫 UNIX ,你會怎麼辦,他回答自己要把 creat 改成 create ,哈哈哈哈。

這個系統呼叫的兩個引數是檔名和保護模式

fd = creat("aaa",mode);

這段命令會建立一個名為 aaa 的檔案,並根據 mode 設定檔案的保護位。這些位決定了哪個使用者可能訪問檔案、如何訪問。

creat 系統呼叫不僅僅建立了一個名為 aaa 的檔案,還會開啟這個檔案。為了允許後續的系統呼叫訪問這個檔案,這個 creat 系統呼叫會返回一個 非負整數, 這個就叫做 檔案描述符(file descriptor),也就是上面的 fd。

如果在已經存在的檔案上呼叫了 creat 系統呼叫,那麼該檔案中的內容會被清除,從 0 開始。通過設定合適的引數,open 系統呼叫也能夠建立檔案。

下面讓我們看一看主要的系統呼叫,如下表所示

系統呼叫 描述
fd = creat(name,mode) 一種建立一個新檔案的方式
fd = open(file, ...) 開啟檔案讀、寫或者讀寫
s = close(fd) 關閉一個開啟的檔案
n = read(fd, buffer, nbytes) 從檔案中向快取中讀入資料
n = write(fd, buffer, nbytes) 從快取中向檔案中寫入資料
position = lseek(fd, offset, whence) 移動檔案指標
s = stat(name, &buf) 獲取檔案資訊
s = fstat(fd, &buf) 獲取檔案資訊
s = pipe(&fd[0]) 建立一個管道
s = fcntl(fd,...) 檔案加鎖等其他操作

為了對一個檔案進行讀寫的前提是先需要開啟檔案,必須使用 creat 或者 open 開啟,引數是開啟檔案的方式,是隻讀、可讀寫還是隻寫。open 系統呼叫也會返回檔案描述符。開啟檔案後,需要使用 close 系統呼叫進行關閉。close 和 open 返回的 fd 總是未被使用的最小數量。

什麼是檔案描述符?檔案描述符就是一個數字,這個數字標示了計算機作業系統中開啟的檔案。它描述了資料資源,以及訪問資源的方式。

當程式要求開啟一個檔案時,核心會進行如下操作

  • 授予訪問許可權
  • 全域性檔案表(global file table)中建立一個條目(entry)
  • 向軟體提供條目的位置

檔案描述符由唯一的非負整陣列成,系統上每個開啟的檔案至少存在一個檔案描述符。檔案描述符最初在 Unix 中使用,並且被包括 Linux,macOS 和 BSD 在內的現代作業系統所使用。

當一個程式成功訪問一個開啟的檔案時,核心會返回一個檔案描述符,這個檔案描述符指向全域性檔案表的 entry 項。這個檔案表項包含檔案的 inode 資訊,位元組位移,訪問限制等。例如下圖所示

預設情況下,前三個檔案描述符為 STDIN(標準輸入)STDOUT(標準輸出)STDERR(標準錯誤)

標準輸入的檔案描述符是 0 ,在終端中,預設為使用者的鍵盤輸入

標準輸出的檔案描述符是 1 ,在終端中,預設為使用者的螢幕

與錯誤有關的預設資料流是 2,在終端中,預設為使用者的螢幕。

在簡單聊了一下檔案描述符後,我們繼續回到檔案系統呼叫的探討。

在檔案系統呼叫中,開銷最大的就是 read 和 write 了。read 和 write 都有三個引數

  • 檔案描述符:告訴需要對哪一個開啟檔案進行讀取和寫入
  • 緩衝區地址:告訴資料需要從哪裡讀取和寫入哪裡
  • 統計:告訴需要傳輸多少位元組

這就是所有的引數了,這個設計非常簡單輕巧。

雖然幾乎所有程式都按順序讀取和寫入檔案,但是某些程式需要能夠隨機訪問檔案的任何部分。與每個檔案相關聯的是一個指標,該指標指示檔案中的當前位置。順序讀取(或寫入)時,它通常指向要讀取(寫入)的下一個位元組。如果指標在讀取 1024 個位元組之前位於 4096 的位置,則它將在成功讀取系統呼叫後自動移至 5120 的位置。

Lseek 系統呼叫會更改指標位置的值,以便後續對 read 或 write 的呼叫可以在檔案中的任何位置開始,甚至可以超出檔案末尾。

lseek = Lseek ,段首大寫。

lseek 避免叫做 seek 的原因就是 seek 已經在之前 16 位的計算機上用於搜素功能了。

Lseek 有三個引數:第一個是檔案的檔案描述符,第二個是檔案的位置;第三個告訴檔案位置是相對於檔案的開頭,當前位置還是檔案的結尾

lseek(int fildes, off_t offset, int whence);

lseek 的返回值是更改檔案指標後檔案中的絕對位置。lseek 是唯一從來不會造成真正磁碟查詢的系統呼叫,它只是更新當前的檔案位置,這個檔案位置就是記憶體中的數字。

對於每個檔案,Linux 都會跟蹤檔案模式(常規,目錄,特殊檔案),大小,最後修改時間以及其他資訊。程式能夠通過 stat 系統呼叫看到這些資訊。第一個引數就是檔名,第二個是指向要放置請求資訊結構的指標。這些結構的屬性如下圖所示。

儲存檔案的裝置
儲存檔案的裝置
i-node 編號
檔案模式(包括保護位資訊)
檔案連結的數量
檔案所有者標識
檔案所屬的組
檔案大小(位元組)
建立時間
最後一個修改/訪問時間

fstat 呼叫和 stat 相同,只有一點區別,fstat 可以對開啟檔案進行操作,而 stat 只能對路徑進行操作。

pipe 檔案系統呼叫被用來建立 shell 管道。它會建立一系列的偽檔案,來緩衝和管道元件之間的資料,並且返回讀取或者寫入緩衝區的檔案描述符。在管道中,像是如下操作

sort <in | head –40

sort 程式將會輸出到檔案描述符1,也就是標準輸出,寫入管道中,而 head 程式將從管道中讀入。在這種方式中,sort 只是從檔案描述符 0 中讀取並寫入到檔案描述符 1 (管道)中,甚至不知道它們已經被重定向了。如果沒有重定向的話,sort 會自動的從鍵盤讀入並輸出到螢幕中。

最後一個系統呼叫是 fcntl,它用來鎖定和解鎖檔案,應用共享鎖和互斥鎖,或者是執行一些檔案相關的其他操作。

現在我們來關心一下和整體目錄和檔案系統相關的系統呼叫,而不是把精力放在單個的檔案上,下面列出了這些系統呼叫,我們一起來看一下。

系統呼叫 描述
s = mkdir(path,mode) 建立一個新的目錄
s = rmdir(path) 移除一個目錄
s = link(oldpath,newpath) 建立指向已有檔案的連結
s = unlink(path) 取消檔案的連結
s = chdir(path) 改變工作目錄
dir = opendir(path) 開啟一個目錄讀取
s = closedir(dir) 關閉一個目錄
dirent = readdir(dir) 讀取一個目錄項
rewinddir(dir) 迴轉目錄使其在此使用

可以使用 mkdir 和 rmdir 建立和刪除目錄。但是需要注意,只有目錄為空時才可以刪除。

建立一個指向已有檔案的連結時會建立一個目錄項(directory entry)。系統呼叫 link 來建立連結,oldpath 代表已有的路徑,newpath 代表需要連結的路徑,使用 unlink 可以刪除目錄項。當檔案的最後一個連結被刪除時,這個檔案會被自動刪除。

使用 chdir 系統呼叫可以改變工作目錄。

最後四個系統呼叫是用於讀取目錄的。和普通檔案類似,他們可以被開啟、關閉和讀取。每次呼叫 readdir 都會以固定的格式返回一個目錄項。使用者不能對目錄執行寫操作,但是可以使用 creat 或者 link 在資料夾中建立一個目錄,或使用 unlink 刪除一個目錄。使用者不能在目錄中查詢某個特定檔案,但是可以使用 rewindir 作用於一個開啟的目錄,使他能在此從頭開始讀取。

Linux 檔案系統的實現

下面我們主要討論一下 虛擬檔案系統(Virtual File System)。 VFS 對高層程式和應用程式隱藏了 Linux 支援的所有檔案系統的區別,以及檔案系統是儲存在本地裝置,還是需要通過網路訪問遠端裝置。裝置和其他特殊檔案和 VFS 層相關聯。接下來,我們就會探討一下第一個 Linux 廣泛傳播的檔案系統: ext2。隨後,我們就會探討 ext4 檔案系統所做的改進。各種各樣的其他檔案系統也正在使用中。 所有 Linux 系統都可以處理多個磁碟分割槽,每個磁碟分割槽上都有不同的檔案系統。

Linux 虛擬檔案系統

為了能夠使應用程式能夠在不同型別的本地或者遠端裝置上的檔案系統進行互動,因為在 Linux 當中檔案系統千奇百種,比較常見的有 EXT3、EXT4,還有基於記憶體的 ramfs、tmpfs 和基於網路的 nfs,和基於使用者態的 fuse,當然 fuse 應該不能完全的檔案系統,只能算是一個能把檔案系統實現放到使用者態的模組,滿足了核心檔案系統的介面,他們都是檔案系統的一種實現。對於這些檔案系統,Linux 做了一層抽象就是 VFS 虛擬檔案系統,

下表總結了 VFS 支援的四個主要的檔案系統結構。

物件 描述
超級塊 特定的檔案系統
Dentry 目錄項,路徑的一個組成部分
I-node 特定的檔案
File 跟一個程式相關聯的開啟檔案

超級塊(superblock) 包含了有關檔案系統佈局的重要資訊,超級塊如果遭到破壞那麼就會導致整個檔案系統不可讀。

i-node 索引節點,包含了每一個檔案的描述符。

在 Linux 中,目錄和裝置也表示為檔案,因為它們具有對應的 i-node

超級塊和索引塊所在的檔案系統都在磁碟上有對應的結構。

為了便於某些目錄操作和路徑遍歷,比如 /usr/local/cxuan,VFS 支援一個 dentry 資料結構,該資料結構代表著目錄項。這個 dentry 資料結構有很多東西(http://books.gigatux.nl/mirror/kerneldevelopment/0672327201/ch12lev1sec7.html)這個資料結構由檔案系統動態建立。

目錄項被快取在 dentry_cache 快取中。例如,快取條目會快取 /usr 、 /usr/local 等條目。如果多個程式通過硬連線訪問相同的檔案,他們的檔案物件將指向此快取中的相同條目。

最後,檔案資料結構是代表著開啟的檔案,也代表著記憶體表示,它根據 open 系統呼叫建立。它支援 read、write、sendfile、lock 和其他在我們之前描述的系統呼叫中。

在 VFS 下實現的實際檔案系統不需要在內部使用完全相同的抽象和操作。 但是,它們必須在語義上實現與 VFS 物件指定的檔案系統操作相同的檔案系統操作。 四個 VFS 物件中每個物件的運算元據結構的元素都是指向基礎檔案系統中功能的指標。

Linux Ext2 檔案系統

現在我們一起看一下 Linux 中最流行的一個磁碟檔案系統,那就是 ext2 。Linux 的第一個版本用於 MINIX1 檔案系統,它的檔名大小被限制為最大 64 MB。MINIX 1 檔案系統被永遠的被它的擴充套件系統 ext 取代,因為 ext 允許更長的檔名和檔案大小。由於 ext 的效能低下,ext 被其替代者 ext2 取代,ext2 目前仍在廣泛使用。

一個 ext2 Linux 磁碟分割槽包含了一個檔案系統,這個檔案系統的佈局如下所示

Boot 塊也就是第 0 塊不是讓 Linux 使用的,而是用來載入和引導計算機啟動程式碼的。在塊 0 之後,磁碟分割槽被分成多個組,這些組與磁碟柱面邊界所處的位置無關。

第一個塊是 超級塊(superblock)。它包含有關檔案系統佈局的資訊,包括 i-node、磁碟塊數量和以及空閒磁碟塊列表的開始。下一個是 組描述符(group descriptor),其中包含有關點陣圖的位置,組中空閒塊和 i-node 的數量以及組中的目錄數量的資訊。這些資訊很重要,因為 ext2 會在磁碟上均勻分佈目錄。

圖中的兩個點陣圖用來記錄空閒塊和空閒 i-node,這是從 MINIX 1檔案系統繼承的選擇,大多數 UNIX 檔案系統使用點陣圖而不是空閒列表。每個點陣圖的大小是一個塊。如果一個塊的大小是 1 KB,那麼就限制了塊組的數量是 8192 個塊和 8192 個 i-node。塊的大小是一個嚴格的限制,塊組的數量不固定,在 4KB 的塊中,塊組的數量增大四倍。

在超級塊之後分佈的是 i-node 它們自己,i-node 取值範圍是 1 - 某些最大值。每個 i-node 是 128 位元組的 long ,這些位元組恰好能夠描述一個檔案。i-node 包含了統計資訊(包含了 stat 系統呼叫能獲得的所有者資訊,實際上 stat 就是從 i-node 中讀取資訊的),以及足夠的資訊來查詢儲存檔案資料的所有磁碟塊。

在 i-node 之後的是 資料塊(data blocks)。所有的檔案和目錄都儲存在這。如果一個檔案或者目錄包含多個塊,那麼這些塊在磁碟中的分佈不一定是連續的,也有可能不連續。事實上,大檔案塊可能會被拆分成很多小塊散佈在整個磁碟上。

對應於目錄的 i-node 分散在整個磁碟組上。如果有足夠的空間,ext2 會把普通檔案組織到與父目錄相同的塊組中,而把同一塊上的資料檔案組織成初始 i-node 節點。點陣圖用來快速確定新檔案系統資料的分配位置。在分配新的檔案塊時,ext2 也會給該檔案預分配許多額外的資料塊,這樣可以減少將來向檔案寫入資料時產生的檔案碎片。這種策略在整個磁碟上實現了檔案系統的 負載,後續還有對檔案碎片的排列和整理,而且效能也比較好。

為了達到訪問的目的,需要首先使用 Linux 系統呼叫,例如 open,這個系統呼叫會確定開啟檔案的路徑。路徑分為兩種,相對路徑絕對路徑。如果使用相對路徑,那麼就會從當前目錄開始查詢,否則就會從根目錄進行查詢。

目錄檔案的檔名最高不能超過 255 個字元,它的分配如下圖所示

每一個目錄都由整數個磁碟塊組成,這樣目錄就可以整體的寫入磁碟。在一個目錄中,檔案和子目錄的目錄項都是未經排序的,並且一個挨著一個。目錄項不能跨越磁碟塊,所以通常在每個磁碟塊的尾部會有部分未使用的位元組。

上圖中每個目錄項都由四個固定長度的屬性和一個長度可變的屬性組成。第一個屬性是 i-node 節點數量,檔案 first 的 i-node 編號是 19 ,檔案 second 的編號是 42,目錄 third 的 i-node 編號是 88。緊隨其後的是 rec_len 域,表明目錄項大小是多少位元組,名稱後面會有一些擴充套件,當名字以未知長度填充時,這個域被用來尋找下一個目錄項,直至最後的未使用。這也是圖中箭頭的含義。緊隨其後的是 型別域:F 表示的是檔案,D 表示的是目錄,最後是固定長度的檔名,上面的檔名的長度依次是 5、6、5,最後以檔名結束。

rec_len 域是如何擴充套件的呢?如下圖所示

我們可以看到,中間的 second 被移除了,所以將其所在的域變為第一個目錄項的填充。當然,這個填充可以作為後續的目錄項。

由於目錄是按照線性的順序進行查詢的,因此可能需要很長時間才能在大檔案末尾找到目錄項。因此,系統會為近期的訪問目錄維護一個快取。這個快取用檔名來查詢,如果快取命中,那麼就會避免執行緒搜尋這樣昂貴的開銷。組成路徑的每個部分都在目錄快取中儲存一個 dentry 物件,並且通過 i-node 找到後續的路徑元素的目錄項,直到找到真正的檔案 i - node。

比如說要使用絕對路徑來尋找一個檔案,我們暫定這個路徑是 /usr/local/file,那麼需要經過如下幾個步驟:

  • 首先,系統會確定根目錄,它通常使用 2 號 i -node ,也就是索引 2 節點,因為索引節點 1 是 ext2 /3/4 檔案系統上的壞塊索引節點。系統會將一項放在 dentry 快取中,以應對將來對根目錄的查詢。
  • 然後,在根目錄中查詢字串 usr,得到 /usr 目錄的 i - node 節點號。/usr 的 i - node 同樣也進入 dentry 快取。然後節點被取出,並從中解析出磁碟塊,這樣就可以讀取 /usr 目錄並查詢字串 local 了。一旦找到這個目錄項,目錄 /usr/local 的 i - node 節點就可以從中獲得。有了 /usr/local 的 i - node 節點號,就可以讀取 i - node 並確定目錄所在的磁碟塊。最後,從 /usr/local 目錄查詢 file 並確定其 i - node 節點呢號。

如果檔案存在,那麼系統會提取 i - node 節點號並把它作為索引在 i - node 節點表中定位相應的 i - node 節點並裝入記憶體。i - node 被存放在 i - node 節點表(i-node table) 中,節點表是一個核心資料結構,它會持有當前開啟檔案和目錄的 i - node 節點號。下面是一些 Linux 檔案系統支援的 i - node 資料結構。

屬性 位元組 描述
Mode 2 檔案屬性、保護位、setuid 和 setgid 位
Nlinks 2 指向 i - node 節點目錄項的數目
Uid 2 檔案所有者的 UID
Gid 2 檔案所有者的 GID
Size 4 檔案位元組大小
Addr 60 12 個磁碟塊以及後面 3 個間接塊的地址
Gen 1 每次重複使用 i - node 時增加的代號
Atime 4 最近訪問檔案的時間
Mtime 4 最近修改檔案的時間
Ctime 4 最近更改 i - node 的時間

現在我們來一起探討一下檔案讀取過程,還記得 read 函式是如何呼叫的嗎?

n = read(fd,buffer,nbytes);

當核心接管後,它會從這三個引數以及內部表與使用者有關的資訊開始。內部表的其中一項是檔案描述符陣列。檔案描述符陣列用檔案描述符 作為索引併為每一個開啟檔案儲存一個表項。

檔案是和 i - node 節點號相關的。那麼如何通過一個檔案描述符找到檔案對應的 i - node 節點呢?

這裡使用的一種設計思想是在檔案描述符表和 i - node 節點表之間插入一個新的表,叫做 開啟檔案描述符(open-file-description table)。檔案的讀寫位置會在開啟檔案描述符表中存在,如下圖所示

我們使用 shell 、P1 和 P2 來描述一下父程式、子程式、子程式的關係。Shell 首先生成 P1,P1 的資料結構就是 Shell 的一個副本,因此兩者都指向相同的開啟檔案描述符的表項。當 P1 執行完成後,Shell 的檔案描述符仍會指向 P1 檔案位置的開啟檔案描述。然後 Shell 生成了 P2,新的子程式自動繼承檔案的讀寫位置,甚至 P2 和 Shell 都不知道檔案具體的讀寫位置。

上面描述的是父程式和子程式這兩個 相關 程式,如果是一個不相關程式開啟檔案時,它將得到自己的開啟檔案描述符表項,以及自己的檔案讀寫位置,這是我們需要的。

因此,開啟檔案描述符相當於是給相關程式提供同一個讀寫位置,而給不相關程式提供各自私有的位置。

i - node 包含三個間接塊的磁碟地址,它們每個指向磁碟塊的地址所能夠儲存的大小不一樣。

Linux Ext4 檔案系統

為了防止由於系統崩潰和電源故障造成的資料丟失,ext2 系統必須在每個資料塊建立之後立即將其寫入到磁碟上,磁碟磁頭尋道操作導致的延遲是無法讓人忍受的。為了增強檔案系統的健壯性,Linux 依靠日誌檔案系統,ext3 是一個日誌檔案系統,它在 ext2 檔案系統的基礎之上做了改進,ext4 也是 ext3 的改進,ext4 也是一個日誌檔案系統。ext4 改變了 ext3 的塊定址方案,從而支援更大的檔案和更大的檔案系統大小。下面我們就來描述一下 ext4 檔案系統的特性。

具有記錄的檔案系統最基本的功能就是記錄日誌,這個日誌記錄了按照順序描述所有檔案系統的操作。通過順序寫出檔案系統資料或後設資料的更改,操作不受磁碟訪問期間磁碟頭移動的開銷。最終,這個變更會寫入並提交到合適的磁碟位置上。如果這個變更在提交到磁碟前檔案系統當機了,那麼在重啟期間,系統會檢測到檔案系統未正確解除安裝,那麼就會遍歷日誌並應用日誌的記錄來對檔案系統進行更改。

Ext4 檔案系統被設計用來高度匹配 ext2 和 ext3 檔案系統的,儘管 ext4 檔案系統在核心資料結構和磁碟佈局上都做了變更。儘管如此,一個檔案系統能夠從 ext2 檔案系統上解除安裝後成功的掛載到 ext4 檔案系統上,並提供合適的日誌記錄。

日誌是作為迴圈緩衝區管理的檔案。日誌可以儲存在與主檔案系統相同或者不同的裝置上。日誌記錄的讀寫操作會由單獨的 JBD(Journaling Block Device) 來扮演。

JBD 中有三個主要的資料結構,分別是 log record(日誌記錄)、原子操作和事務。一個日誌記錄描述了一個低階別的檔案系統操作,這個操作通常導致塊內的變化。因為像是 write 這種系統呼叫會包含多個地方的改動 --- i - node 節點,現有的檔案塊,新的檔案塊和空閒列表等。相關的日誌記錄會以原子性的方式分組。ext4 會通知系統呼叫程式的開始和結束,以此使 JBD 能夠確保原子操作的記錄都能被應用,或者一個也不被應用。最後,主要從效率方面考慮,JBD 會視原子操作的集合為事務。一個事務中的日誌記錄是連續儲存的。只有在所有的變更一起應用到磁碟後,日誌記錄才能夠被丟棄。

由於為每個磁碟寫出日誌的開銷會很大,所以 ext4 可以配置為保留所有磁碟更改的日誌,或者僅僅保留與檔案系統後設資料相關的日誌更改。僅僅記錄後設資料可以減少系統開銷,提升效能,但不能保證不會損壞檔案資料。其他的幾個日誌系統維護著一系列後設資料操作的日誌,例如 SGI 的 XFS。

/proc 檔案系統

另外一個 Linux 檔案系統是 /proc (process) 檔案系統

它的主要思想來源於貝爾實驗室開發的第 8 版的 UNIX,後來被 BSD 和 System V 採用。

然而,Linux 在一些方面上對這個想法進行了擴充。它的基本概念是為系統中的每個程式在 /proc 中建立一個目錄。目錄的名字就是程式 PID,以十進位制數進行表示。例如,/proc/1024 就是一個程式號為 1024 的目錄。在該目錄下是程式資訊相關的檔案,比如程式的命令列、環境變數和訊號掩碼等。事實上,這些檔案在磁碟上並不存在磁碟中。當需要這些資訊的時候,系統會按需從程式中讀取,並以標準格式返回給使用者。

許多 Linux 擴充套件與 /proc 中的其他檔案和目錄有關。它們包含各種各樣的關於 CPU、磁碟分割槽、裝置、中斷向量、核心計數器、檔案系統、已載入模組等資訊。非特權使用者可以讀取很多這樣的資訊,於是就可以通過一種安全的方式瞭解系統情況。

NFS 網路檔案系統

從一開始,網路就在 Linux 中扮演了很重要的作用。下面我們會探討一下 NFS(Network File System) 網路檔案系統,它在現代 Linux 作業系統的作用是將不同計算機上的不同檔案系統連結成一個邏輯整體。

NFS 架構

NFS 最基本的思想是允許任意選定的一些客戶端伺服器共享一個公共檔案系統。在許多情況下,所有的客戶端和伺服器都會在同一個 LAN(Local Area Network) 區域網內共享,但是這並不是必須的。也可能是下面這樣的情況:如果客戶端和伺服器距離較遠,那麼它們也可以在廣域網上執行。客戶端可以是伺服器,伺服器可以是客戶端,但是為了簡單起見,我們說的客戶端就是消費服務,而伺服器就是提供服務的角度來聊。

每一個 NFS 服務都會匯出一個或者多個目錄供遠端客戶端訪問。當一個目錄可用時,它的所有子目錄也可用。因此,通常整個目錄樹都會作為一個整體匯出。伺服器匯出的目錄列表會用一個檔案來維護,這個檔案是 /etc/exports,當伺服器啟動後,這些目錄可以自動的被匯出。客戶端通過掛載這些匯出的目錄來訪問它們。當一個客戶端掛載了一個遠端目錄,這個目錄就成為客戶端目錄層次的一部分,如下圖所示。

在這個示例中,一號客戶機掛載到伺服器的 bin 目錄下,因此它現在可以使用 shell 訪問 /bin/cat 或者其他任何一個目錄。同樣,客戶機 1 也可以掛載到 二號伺服器上從而訪問 /usr/local/projects/proj1 或者其他目錄。二號客戶機同樣可以掛載到二號伺服器上,訪問路徑是 /mnt/projects/proj2。

從上面可以看到,由於不同的客戶端將檔案掛載到各自目錄樹的不同位置,同一個檔案在不同的客戶端有不同的訪問路徑和不同的名字。掛載點一般通常在客戶端本地,伺服器不知道任何一個掛載點的存在。

NFS 協議

由於 NFS 的協議之一是支援 異構 系統,客戶端和伺服器可能在不同的硬體上執行不同的作業系統,因此有必要在伺服器和客戶端之間進行介面定義。這樣才能讓任何寫一個新客戶端能夠和現有的伺服器一起正常工作,反之亦然。

NFS 就通過定義兩個客戶端 - 伺服器協議從而實現了這個目標。協議就是客戶端傳送給伺服器的一連串的請求,以及伺服器傳送回客戶端的相應答覆。

第一個 NFS 協議是處理掛載。客戶端可以向伺服器傳送路徑名並且請求伺服器是否能夠將伺服器的目錄掛載到自己目錄層次上。因為伺服器不關心掛載到哪裡,因此請求不會包含掛載地址。如果路徑名是合法的並且指定的目錄已經被匯出,那麼伺服器會將檔案 控制程式碼 返回給客戶端。

檔案控制程式碼包含唯一標識檔案系統型別,磁碟,目錄的i節點號和安全性資訊的欄位。

隨後呼叫讀取和寫入已安裝目錄或其任何子目錄中的檔案,都將使用檔案控制程式碼。

當 Linux 啟動時會在多使用者之前執行 shell 指令碼 /etc/rc 。可以將掛載遠端檔案系統的命令寫入該指令碼中,這樣就可以在允許使用者登陸之前自動掛載必要的遠端檔案系統。大部分 Linux 版本是支援自動掛載的。這個特性會支援將遠端目錄和本地目錄進行關聯。

相對於手動掛載到 /etc/rc 目錄下,自動掛載具有以下優勢

  • 如果列出的 /etc/rc 目錄下出現了某種故障,那麼客戶端將無法啟動,或者啟動會很困難、延遲或者伴隨一些出錯資訊,如果客戶根本不需要這個伺服器,那麼手動做了這些工作就白費了。
  • 允許客戶端並行的嘗試一組伺服器,可以實現一定程度的容錯率,並且效能也可以得到提高。

另一方面,我們預設在自動掛載時所有可選的檔案系統都是相同的。由於 NFS 不提供對檔案或目錄複製的支援,使用者需要自己確保這些所有的檔案系統都是相同的。因此,大部分的自動掛載都只應用於二進位制檔案和很少改動的只讀的檔案系統。

第二個 NFS 協議是為檔案和目錄的訪問而設計的。客戶端能夠通過向伺服器傳送訊息來操作目錄和讀寫檔案。客戶端也可以訪問檔案屬性,比如檔案模式、大小、上次修改時間。NFS 支援大多數的 Linux 系統呼叫,但是 open 和 close 系統呼叫卻不支援。

不支援 open 和 close 並不是一種疏忽,而是一種刻意的設計,完全沒有必要在讀一個檔案之前對其進行開啟,也沒有必要在讀完時對其進行關閉。

NFS 使用了標準的 UNIX 保護機制,使用 rwx 位來標示所有者(owner)組(groups)其他使用者 。最初,每個請求訊息都會攜帶呼叫者的 groupId 和 userId,NFS 會對其進行驗證。事實上,它會信任客戶端不會發生欺騙行為。可以使用公鑰密碼來建立一個安全金鑰,在每次請求和應答中使用它驗證客戶端和伺服器。

NFS 實現

即使客戶端和伺服器的程式碼實現是獨立於 NFS 協議的,大部分的 Linux 系統會使用一個下圖的三層實現,頂層是系統呼叫層,系統呼叫層能夠處理 open 、 read 、 close 這類的系統呼叫。在解析和引數檢查結束後呼叫第二層,虛擬檔案系統 (VFS) 層。

VFS 層的任務是維護一個表,每個已經開啟的檔案都在表中有一個表項。VFS 層為每一個開啟的檔案維護著一個虛擬i節點 ,簡稱為 v - node。v 節點用來說明檔案是本地檔案還是遠端檔案。如果是遠端檔案的話,那麼 v - node 會提供足夠的資訊使客戶端能夠訪問它們。對於本地檔案,會記錄其所在的檔案系統和檔案的 i-node ,因為現代作業系統能夠支援多檔案系統。雖然 VFS 是為了支援 NFS 而設計的,但是現代作業系統都會使用 VFS,而不管有沒有 NFS。

Linux IO

我們之前瞭解過了 Linux 的程式和執行緒、Linux 記憶體管理,那麼下面我們就來認識一下 Linux 中的 I/O 管理。

Linux 系統和其他 UNIX 系統一樣,IO 管理比較直接和簡潔。所有 IO 裝置都被當作檔案,通過在系統內部使用相同的 read 和 write 一樣進行讀寫。

Linux IO 基本概念

Linux 中也有磁碟、印表機、網路等 I/O 裝置,Linux 把這些裝置當作一種 特殊檔案 整合到檔案系統中,一般通常位於 /dev 目錄下。可以使用與普通檔案相同的方式來對待這些特殊檔案。

特殊檔案一般分為兩種:

塊特殊檔案是一個能儲存固定大小塊資訊的裝置,它支援以固定大小的塊,扇區或群集讀取和(可選)寫入資料。每個塊都有自己的實體地址。通常塊的大小在 512 - 65536 之間。所有傳輸的資訊都會以連續的塊為單位。塊裝置的基本特徵是每個塊都較為對立,能夠獨立的進行讀寫。常見的塊裝置有 硬碟、藍光光碟、USB 盤與字元裝置相比,塊裝置通常需要較少的引腳。

塊特殊檔案的缺點基於給定固態儲存器的塊裝置比基於相同型別的儲存器的位元組定址要慢一些,因為必須在塊的開頭開始讀取或寫入。所以,要讀取該塊的任何部分,必須尋找到該塊的開始,讀取整個塊,如果不使用該塊,則將其丟棄。要寫入塊的一部分,必須尋找到塊的開始,將整個塊讀入記憶體,修改資料,再次尋找到塊的開頭處,然後將整個塊寫回裝置。

另一類 I/O 裝置是字元特殊檔案。字元裝置以字元為單位傳送或接收一個字元流,而不考慮任何塊結構。字元裝置是不可定址的,也沒有任何尋道操作。常見的字元裝置有 印表機、網路裝置、滑鼠、以及大多數與磁碟不同的裝置

每個裝置特殊檔案都會和 裝置驅動 相關聯。每個驅動程式都通過一個 主裝置號 來標識。如果一個驅動支援多個裝置的話,此時會在主裝置的後面新加一個 次裝置號 來標識。主裝置號和次裝置號共同確定了唯一的驅動裝置。

我們知道,在計算機系統中,CPU 並不直接和裝置打交道,它們中間有一個叫作 裝置控制器(Device Control Unit)的元件,例如硬碟有磁碟控制器、USB 有 USB 控制器、顯示器有視訊控制器等。這些控制器就像代理商一樣,它們知道如何應對硬碟、滑鼠、鍵盤、顯示器的行為。

絕大多數字符特殊檔案都不能隨機訪問,因為他們需要使用和塊特殊檔案不同的方式來控制。比如,你在鍵盤上輸入了一些字元,但是你發現輸錯了一個,這時有一些人喜歡使用 backspace 來刪除,有人喜歡用 del 來刪除。為了中斷正在執行的裝置,一些系統使用 ctrl-u 來結束,但是現在一般使用 ctrl-c 來結束。

網路

I/O 的另外一個概念是網路, 也是由 UNIX 引入,網路中一個很關鍵的概念就是 套接字(socket)。套接字允許使用者連線到網路,正如郵筒允許使用者連線到郵政系統,套接字的示意圖如下

套接字的位置如上圖所示,套接字可以動態建立和銷燬。成功建立一個套接字後,系統會返回一個檔案描述符(file descriptor),在後面的建立連結、讀資料、寫資料、解除連線時都需要使用到這個檔案描述符。每個套接字都支援一種特定型別的網路型別,在建立時指定。一般最常用的幾種

  • 可靠的面向連線的位元組流
  • 可靠的面向連線的資料包
  • 不可靠的資料包傳輸

可靠的面向連線的位元組流會使用管道 在兩臺機器之間建立連線。能夠保證位元組從一臺機器按照順序到達另一臺機器,系統能夠保證所有位元組都能到達。

除了資料包之間的分界之外,第二種型別和第一種型別是類似的。如果傳送了 3 次寫操作,那麼使用第一種方式的接受者會直接接收到所有位元組;第二種方式的接受者會分 3 次接受所有位元組。除此之外,使用者還可以使用第三種即不可靠的資料包來傳輸,使用這種傳輸方式的優點在於高效能,有的時候它比可靠性更加重要,比如在流媒體中,效能就尤其重要。

以上涉及兩種形式的傳輸協議,即 TCPUDP,TCP 是 傳輸控制協議,它能夠傳輸可靠的位元組流。UDP使用者資料包協議,它只能夠傳輸不可靠的位元組流。它們都屬於 TCP/IP 協議簇中的協議,下面是網路協議分層

可以看到,TCP 、UDP 都位於網路層上,可見它們都把 IP 協議 即 網際網路協議 作為基礎。

一旦套接字在源計算機和目的計算機建立成功,那麼兩個計算機之間就可以建立一個連結。通訊一方在本地套接字上使用 listen 系統呼叫,它就會建立一個緩衝區,然後阻塞直到資料到來。另一方使用 connect 系統呼叫,如果另一方接受 connect 系統呼叫後,則系統會在兩個套接字之間建立連線。

socket 連線建立成功後就像是一個管道,一個程式可以使用本地套接字的檔案描述符從中讀寫資料,當連線不再需要的時候使用 close 系統呼叫來關閉。

Linux I/O 系統呼叫

Linux 系統中的每個 I/O 裝置都有一個特殊檔案(special file)與之關聯,什麼是特殊檔案呢?

在作業系統中,特殊檔案是一種在檔案系統中與硬體裝置相關聯的檔案。特殊檔案也被稱為 裝置檔案(device file)。特殊檔案的目的是將裝置作為檔案系統中的檔案進行公開。特殊檔案為硬體裝置提供了藉口,用於檔案 I/O 的工具可以進行訪問。因為裝置有兩種型別,同樣特殊檔案也有兩種,即字元特殊檔案和塊特殊檔案

對於大部分 I/O 操作來說,只用合適的檔案就可以完成,並不需要特殊的系統呼叫。然後,有時需要一些裝置專用的處理。在 POSIX 之前,大多數 UNIX 系統會有一個叫做 ioctl 的系統呼叫,它用於執行大量的系統呼叫。隨著時間的發展,POSIX 對其進行了整理,把 ioctl 的功能劃分為面向終端裝置的獨立功能呼叫,現在已經變成獨立的系統呼叫了。

下面是幾個管理終端的系統呼叫

系統呼叫 描述
tcgetattr 獲取屬性
tcsetattr 設定屬性
cfgetispeed 獲取輸入速率
cfgetospeed 獲取輸出速率
cfsetispeed 設定輸入速率
cfsetospeed 設定輸出速率

Linux IO 實現

Linux 中的 IO 是通過一系列裝置驅動實現的,每個裝置型別對應一個裝置驅動。裝置驅動為作業系統和硬體分別預留介面,通過裝置驅動來遮蔽作業系統和硬體的差異。

當使用者訪問一個特殊的檔案時,由檔案系統提供此特殊檔案的主裝置號和次裝置號,並判斷它是一個塊特殊檔案還是字元特殊檔案。主裝置號用於標識字元裝置還是塊裝置,次裝置號用於引數傳遞。

每個驅動程式 都有兩部分:這兩部分都是屬於 Linux 核心,也都執行在核心態下。上半部分執行在呼叫者上下文並且與 Linux 其他部分互動。下半部分執行在核心上下文並且與裝置進行互動。驅動程式可以呼叫記憶體分配、定時器管理、DMA 控制等核心過程。可被呼叫的核心功能都位於 驅動程式 - 核心介面 的文件中。

I/O 實現指的就是對字元裝置和塊裝置的實現

塊裝置實現

系統中處理塊特殊檔案 I/O 部分的目標是為了使傳輸次數儘可能的小。為了實現這個目標,Linux 系統在磁碟驅動程式和檔案系統之間設定了一個 快取記憶體(cache) ,如下圖所示

在 Linux 核心 2.2 之前,Linux 系統維護著兩個快取:頁面快取(page cache)緩衝區快取(buffer cache),因此,儲存在一個磁碟塊中的檔案可能會在兩個快取中。2.2 版本以後 Linux 核心只有一個統一的快取一個 通用資料塊層(generic block layer) 把這些融合在一起,實現了磁碟、資料塊、緩衝區和資料頁之間必要的轉換。那麼什麼是通用資料塊層?

通用資料塊層是一個核心的組成部分,用於處理對系統中所有塊裝置的請求。通用資料塊主要有以下幾個功能

將資料緩衝區放在記憶體高位處,當 CPU 訪問資料時,頁面才會對映到核心線性地址中,並且此後取消對映

實現 零拷貝機制,磁碟資料可以直接放入使用者模式的地址空間,而無需先複製到核心記憶體中

管理磁碟卷,會把不同塊裝置上的多個磁碟分割槽視為一個分割槽。

利用最新的磁碟控制器的高階功能,例如 DMA 等。

cache 是提升效能的利器,不管以什麼樣的目的需要一個資料塊,都會先從 cache 中查詢,如果找到直接返回,避免一次磁碟訪問,能夠極大的提升系統效能。

如果頁面 cache 中沒有這個塊,作業系統就會把頁面從磁碟中調入記憶體,然後讀入 cache 進行快取。

cache 除了支援讀操作外,也支援寫操作,一個程式要寫回一個塊,首先把它寫到 cache 中,而不是直接寫入到磁碟中,等到磁碟中快取達到一定數量值時再被寫入到 cache 中。

Linux 系統中使用 IO 排程器 來保證減少磁頭的反覆移動從而減少損失。I/O 排程器的作用是對塊裝置的讀寫操作進行排序,對讀寫請求進行合併。Linux 有許多排程器的變體,從而滿足不同的工作需要。最基本的 Linux 排程器是基於傳統的 Linux 電梯排程器(Linux elevator scheduler)。Linux 電梯排程器的主要工作流程就是按照磁碟扇區的地址排序並儲存在一個雙向連結串列 中。新的請求將會以連結串列的形式插入。這種方法可以有效的防止磁頭重複移動。因為電梯排程器會容易產生飢餓現象。因此,Linux 在原基礎上進行了修改,維護了兩個連結串列,在 最後日期(deadline) 內維護了排序後的讀寫操作。預設的讀操作耗時 0.5s,預設寫操作耗時 5s。如果在最後期限內等待時間最長的連結串列沒有獲得服務,那麼它將優先獲得服務。

字元裝置實現

和字元裝置的互動是比較簡單的。由於字元裝置會產生並使用字元流、位元組資料,因此對隨機訪問的支援意義不大。一個例外是使用 行規則(line disciplines)。一個行規可以和終端裝置相關聯,使用 tty_struct 結構來表示,它表示與終端裝置交換資料的直譯器,當然這也屬於核心的一部分。例如:行規可以對行進行編輯,對映回車為換行等一系列其他操作。

什麼是行規則?

行規是某些類 UNIX 系統中的一層,終端子系統通常由三層組成:上層提供字元裝置介面,下層硬體驅動程式與硬體或偽終端進行互動,中層規則用於實現終端裝置共有的行為。

網路裝置實現

網路裝置的互動是不一樣的,雖然 網路裝置(network devices) 也會產生字元流,因為它們的非同步(asynchronous) 特性是他們不易與其他字元裝置在同一介面下整合。網路裝置驅動程式會產生很多資料包,經由網路協議到達使用者應用程式中。

Linux 中的模組

UNIX 裝置驅動程式是被靜態載入到核心中的。因此,只要系統啟動後,裝置驅動程式都會被載入到記憶體中。隨著個人電腦 Linux 的出現,這種靜態連結完成後會使用一段時間的模式被打破。相對於小型機上的 I/O 裝置,PC 上可用的 I/O 裝置有了數量級的增長。絕大多數使用者沒有能力去新增一個新的應用程式、更新裝置驅動、重新連線核心,然後進行安裝。

Linux 為了解決這個問題,引入了 可載入(loadable module) 機制。可載入是在系統執行時新增到核心中的程式碼塊。

當一個模組被載入到核心時,會發生下面幾件事情:第一,在載入的過程中,模組會被動態的重新部署。第二,系統會檢查程式程式所需的資源是否可用。如果可用,則把這些資源標記為正在使用。第三步,設定所需的中斷向量。第四,更新驅動轉換表使其能夠處理新的主裝置型別。最後再來執行裝置驅動程式。

在完成上述工作後,驅動程式就會安裝完成,其他現代 UNIX 系統也支援可載入機制。

Linux 安全

Linux 作為 MINIX 和 UNIX 的衍生作業系統,從一開始就是一個多使用者系統。這意味著 Linux 從早期開始就建立了安全和資訊訪問控制機制。下面我們主要探討的就是 Linux 安全性的一些內容

Linux 安全基本概念

一個 Linux 系統的使用者群裡由一系列註冊使用者組成,他們每一個都有一個唯一的 UID (User ID)。一個 UID 是一個位於 0 到 65535 之間的整數。檔案(程式或者是其他資源)都標記了它的所有者的 UID。預設情況下,檔案的所有者是建立檔案的人,檔案的所有者是建立檔案的使用者。

使用者可以被分成許多組,每個組都會由一個 16 位的整數標記,這個組叫做 GID(組 ID)。給使用者分組是手動完成的,它由系統管理員執行,分組就是在資料庫中新增一條記錄指明哪個使用者屬於哪個組。一個使用者可以屬於不同組。

Linux 中的基本安全機制比較容易理解,每個程式都會記錄它所有者的 UID 和 GID。當檔案建立後,它會獲取建立程式的 UID 和 GID。當一個檔案被建立時,它的 UID 和 GID 就會被標記為程式的 UID 和 GID。這個檔案同時會獲取由該程式決定的一些許可權。這些許可權會指定所有者、所有者所在組的其他使用者及其他使用者對檔案具有什麼樣的訪問許可權。對於這三類使用者而言,潛在的訪問許可權是 讀、寫和執行,分別由 r、w 和 x 標記。當然,執行檔案的許可權僅當檔案時可逆二進位制程式時才有意義。試圖執行一個擁有執行許可權的非可執行檔案,系統會報錯。

Linux 使用者分為三種

  • root(超級管理員),它的 UID 為 0,這個使用者有極大的許可權,可以直接無視很多的限制 ,包括讀寫執行的許可權。
  • 系統使用者,UID 為 1~499。
  • 普通使用者,UID 範圍一般是 500~65534。這類使用者的許可權會受到基本許可權的限制,也會受到來自管理員的限制。不過要注意 nobody 這個特殊的帳號,UID 為 65534,這個使用者的許可權會進一步的受到限制,一般用於實現來賓帳號。

Linux 中的每類使用者由 3 個位元為來標記,所以 9 個位元位就能夠表示所有的許可權。

下面來看一下一些基本的使用者和許可權例子

二進位制 標記 准許的檔案訪問許可權
111000000 rwx------ 所有者可讀、寫和執行
111111000 rwxrwx--- 所有者和組可以讀、寫和執行
111111111 rwxrwxrwx 所有人可以讀、寫和執行
000000000 --------- 任何人不擁有任何許可權
000000111 ------rwx 只有組以外的其他使用者擁有所有權
110100100 rw-r--r-- 所有者可以讀和寫,其他人可以讀
110100100 rw-r----- 所有者可以讀和寫,組可以讀

我們上面提到,UID 為 0 的是一個特殊使用者,稱為 超級使用者(或者根使用者)。超級使用者能夠讀和寫系統中的任何檔案,不管這個檔案由誰所有,也不管這個檔案的保護模式如何。 UID 為 0 的程式還具有少數呼叫受保護系統呼叫的許可權,而普通使用者是不可能有這些功能的。通常情況下,只有系統管理員知道超級使用者的密碼。

在 Linux 系統下,目錄也是一種檔案,並且具有和普通檔案一樣的保護模式。不同的是,目錄的 x 位元位表示查詢許可權而不是執行許可權。因此,如果一個目錄的保護模式是 rwxr-xr-x,那麼它允許所有者讀、寫和查詢目錄,而其他人只可以讀和查詢,而不允許從中新增或者刪除目錄中的檔案。

與 I/O 有關的特殊檔案擁有和普通檔案一樣的保護位。這種機制可以用來限制對 I/O 裝置的訪問許可權。舉個例子,印表機是特殊檔案,它的目錄是 /dev/lp,它可以被根使用者或者一個叫守護程式的特殊使用者擁有,具有保護模式 rw-------,從而阻止其他所有人對印表機的訪問。畢竟每個人都使用印表機的話會發生混亂。

當然,如果 /dev/lp 的保護模式是 rw-------,那就意味著其他任何人都不能使用印表機。

這個問題通過增加一個保護位 SETUID 到之前的 9 個位元位來解決。當一個程式的 SETUID 位開啟,它的 有效 UID 將變成相應可執行檔案的所有者 UID,而不是當前使用該程式的使用者的 UID。將訪問印表機的程式設定為守護程式所有,同時開啟 SETUID 位,這樣任何使用者都可以執行此程式,而且擁有守護程式的許可權。

除了 SETUID 之外,還有一個 SETGID 位,SETGID 的工作原理和 SETUID 類似。但是這個位一般很不常用。

Linux 安全相關的系統呼叫

Linux 中關於安全的系統呼叫不是很多,只有幾個,如下列表所示

系統呼叫 描述
chmod 改變檔案的保護模式
access 使用真實的 UID 和 GID 測試訪問許可權
chown 改變所有者和組
setuid 設定 UID
setgid 設定 GID
getuid 獲取真實的 UID
getgid 獲取真實的 GID
geteuid 獲取有效的 UID
getegid 獲取有效的 GID

我們在日常開發中用到最多的就是 chmod了,沒想到我們日常開發過程中也能用到系統呼叫啊,chmod 之前我們一直認為是改變許可權,現在專業一點是改變檔案的保護模式。它的具體函式如下

s = chmod("路徑名","值");

例如

s = chmod("/usr/local/cxuan",777);

他就是會把 /usr/local/cxuan 這個路徑的保護模式改為 rwxrwxrwx,任何組和人都可以操作這個路徑。只有該檔案的所有者和超級使用者才有權利更改保護模式。

access 系統呼叫用來檢驗實際的 UID 和 GID 對某檔案是否擁有特定的許可權。下面就是四個 getxxx 的系統呼叫,這些用來獲取 uid 和 gid 的。

注意:其中的 chown、setuid 和 setgid 是超級使用者才能使用,用來改變所有者程式的 UID 和 GID。

Linux 安全實現

當使用者登入時,登入程式,也被稱為 login,會要求輸入使用者名稱和密碼。它會對密碼進行雜湊處理,然後在 /etc/passwd 中進行查詢,看看是否有匹配的項。使用雜湊的原因是防止密碼在系統中以非加密的方式存在。如果密碼正確,登入程式會在 /etc/passwd 中讀取使用者選擇的 shell 程式的名稱,有可能是 bash,有可能是 shell 或者其他的 cshksh。然後登入程式使用 setuid 和 setgid 這兩個系統呼叫來把自己的 UID 和 GID 變為使用者的 UID 和 GID,然後它開啟鍵盤作為標準輸入、標準輸入的檔案描述符是 0 ,螢幕作為標準輸出,檔案描述符是 1 ,螢幕也作為標準錯誤輸出,檔案描述符為 2。最後,執行使用者選擇的 shell 程式,終止。

當任何程式想要開啟一個檔案,系統首先將檔案的 i - node 所記錄的保護位與使用者有效 UID 和 有效 GID 進行對比,來檢查訪問是否允許。如果訪問允許,就開啟檔案並返回檔案描述符;否則不開啟檔案,返回 - 1。

Linux 安全模型和實現在本質上與大多數傳統的 UNIX 系統相同。

關注公眾號 程式設計師cxuan 回覆 cxuan 領取優質資料。

我自己寫了六本 PDF ,非常硬核,連結如下

我自己寫了六本 PDF ,非常硬核,連結如下

我自己寫了六本 PDF ,非常硬核,連結如下

cxuan 嘔心瀝血肝了四本 PDF。

cxuan 又肝了兩本 PDF。

相關文章