歡迎光臨
每天分享高質量文章

系統呼叫,讓世界轉起來! | Linux 中國

我其實不想將它分解開給你看,使用者應用程式其實就是一個可憐的甕中大腦。
— Gustavo Duarte


致謝
編譯自 | https://manybutfinite.com/post/system-calls/ 
 作者 | Gustavo Duarte
 譯者 | qhwdw ? ? ? ? ? 共計翻譯:105 篇 貢獻時間:177 天

我其實不想將它分解開給你看,使用者應用程式其實就是一個可憐的甕中大腦brain in a vat

它與外部世界的每個交流都要在內核的幫助下透過系統呼叫才能完成。一個應用程式要想儲存一個檔案、寫到終端、或者開啟一個 TCP 連線,核心都要參與。應用程式是被核心高度懷疑的:認為它到處充斥著 bug,甚至是個充滿邪惡想法的腦子。

這些系統呼叫是從一個應用程式到內核的函式呼叫。出於安全考慮,它們使用了特定的機制,實際上你只是呼叫了內核的 API。“系統呼叫system call”這個術語指的是呼叫由核心提供的特定功能(比如,系統呼叫 open())或者是呼叫途徑。你也可以簡稱為:syscall

這篇文章講解系統呼叫,系統呼叫與呼叫一個庫有何區別,以及在作業系統/應用程式介面上的刺探工具。如果徹底瞭解了應用程式藉助作業系統發生的哪些事情,那麼就可以將一個不可能解決的問題轉變成一個快速而有趣的難題。

那麼,下圖是一個執行著的應用程式,一個使用者行程:

它有一個私有的 虛擬地址空間[1]—— 它自己的記憶體沙箱。整個系統都在它的地址空間中(即上面比喻的那個“甕”),程式的二進位制檔案加上它所使用的庫全部都 被對映到記憶體中[2]。核心自身也對映為地址空間的一部分。

下麵是我們程式 pid 的程式碼,它透過 getpid(2)[3] 直接獲取了其行程 id:

  1. #include <sys/types.h>

  2. #include <unistd.h>

  3. #include <stdio.h>

  4. int main()

  5. {

  6.    pid_t p = getpid();

  7.    printf("%d\n", p);

  8. }

pid.c download[4]

在 Linux 中,一個行程並不是一齣生就知道它的 PID。要想知道它的 PID,它必須去詢問核心,因此,這個詢問請求也是一個系統呼叫:

它的第一步是開始於呼叫 C 庫的 getpid()[5],它是系統呼叫的一個封裝。當你呼叫一些函式時,比如,open(2)read(2) 之類,你是在呼叫這些封裝。其實,對於大多數程式語言在這一塊的原生方法,最終都是在 libc 中完成的。

封裝為這些基本的作業系統 API 提供了方便,這樣可以保持內核的簡潔。所有的核心程式碼執行在特權樣式下,有 bug 的核心程式碼行將會產生致命的後果。能在使用者樣式下做的任何事情都應該在使用者樣式中完成。由庫來提供友好的方法和想要的引數處理,像 printf(3) 這樣。

我們拿一個 web API 進行比較,內核的封裝方式可以類比為構建一個盡可能簡單的 HTTP 介面去提供服務,然後提供特定語言的庫及輔助方法。或者也可能有一些快取,這就是 libc 的 getpid() 所做的:首次呼叫時,它真實地去執行了一個系統呼叫,然後,它快取了 PID,這樣就可以避免後續呼叫時的系統呼叫開銷。

一旦封裝完成,它做的第一件事就是進入了核心超空間hyperspace。這種轉換機制因處理器架構設計不同而不同。在 Intel 處理器中,引數和 系統呼叫號[6] 是 載入到暫存器中的[7],然後,執行一個 指令[8] 將 CPU 置於 特權樣式[9] 中,並立即將控制權轉移到核心中的全域性系統呼叫 入口[10]。如果你對這些細節感興趣,David Drysdale 在 LWN 上有兩篇非常好的文章(其一[11]其二[12])。

核心然後使用這個系統呼叫號作為進入 sys_call_table[13] 的一個 索引[14],它是一個函式指標到每個系統呼叫實現的陣列。在這裡,呼叫了 sys_getpid[15]

在 Linux 中,系統呼叫大多數都實現為架構無關的 C 函式,有時候這樣做 很瑣碎[16],但是透過核心優秀的設計,系統呼叫機制被嚴格隔離。它們是工作在一般資料結構中的普通程式碼。嗯,除了完全偏執的引數校驗以外。

一旦它們的工作完成,它們就會正常傳回,然後,架構特定的程式碼會接手轉回到使用者樣式,封裝將在那裡繼續做一些後續處理工作。在我們的例子中,getpid(2)[3] 現在快取了由核心傳回的 PID。如果核心傳回了一個錯誤,另外的封裝可以去設定全域性 errno 變數。這些細節可以讓你知道 GNU 是怎麼處理的。

如果你想要原生的呼叫,glibc 提供了 syscall(2)[17] 函式,它可以不透過封裝來產生一個系統呼叫。你也可以透過它來做一個你自己的封裝。這對一個 C 庫來說,既不神奇,也不特殊。

這種系統呼叫的設計影響是很深遠的。我們從一個非常有用的 strace(1)[18] 開始,這個工具可以用來監視 Linux 行程的系統呼叫(在 Mac 上,參見 dtruss(1m)[19] 和神奇的 dtrace[20];在 Windows 中,參見 sysinternals[21])。這是對 pid 程式的跟蹤:

  1. ~/code/x86-os$ strace ./pid

  2. execve("./pid", ["./pid"], [/* 20 vars */]) = 0

  3. brk(0)                                  = 0x9aa0000

  4. access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)

  5. mmap2(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7767000

  6. access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)

  7. open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3

  8. fstat64(3, {st_mode=S_IFREG|0644, st_size=18056, ...}) = 0

  9. mmap2(NULL, 18056, PROT_READ, MAP_PRIVATE, 3, 0) = 0xb7762000

  10. close(3)                                = 0

  11. [...snip...]

  12. getpid()                                = 14678

  13. fstat64(1, {st_mode=S_IFCHR|0600, st_rdev=makedev(136, 1), ...}) = 0

  14. mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7766000

  15. write(1, "14678\n", 614678

  16. )                  = 6

  17. exit_group(6)                           = ?

輸出的每一行都顯示了一個系統呼叫、它的引數,以及傳回值。如果你在一個迴圈中將 getpid(2) 執行 1000 次,你就會發現始終只有一個 getpid() 系統呼叫,因為,它的 PID 已經被快取了。我們也可以看到在格式化輸出字串之後,printf(3) 呼叫了 write(2)

strace 可以開始一個新行程,也可以附加到一個已經執行的行程上。你可以透過不同程式的系統呼叫學到很多的東西。例如,sshd 守護行程一天都在乾什麼?

  1. ~/code/x86-os$ ps ax | grep sshd

  2. 12218 ?        Ss     0:00 /usr/sbin/sshd -D

  3. ~/code/x86-os$ sudo strace -p 12218

  4. Process 12218 attached - interrupt to quit

  5. select(7, [3 4], NULL, NULL, NULL

  6. [

  7.  ... nothing happens ...

  8.  No fun, it's just waiting for a connection using select(2)

  9.  If we wait long enough, we might see new keys being generated and so on, but

  10.  let's attach again, tell strace to follow forks (-f), and connect via SSH

  11. ]

  12. ~/code/x86-os$ sudo strace -p 12218 -f

  13. [lots of calls happen during an SSH login, only a few shown]

  14. [pid 14692] read(3, "-----BEGIN RSA PRIVATE KEY-----\n"..., 1024) = 1024

  15. [pid 14692] open("/usr/share/ssh/blacklist.RSA-2048", O_RDONLY|O_LARGEFILE) = -1 ENOENT (No such file or directory)

  16. [pid 14692] open("/etc/ssh/blacklist.RSA-2048", O_RDONLY|O_LARGEFILE) = -1 ENOENT (No such file or directory)

  17. [pid 14692] open("/etc/ssh/ssh_host_dsa_key", O_RDONLY|O_LARGEFILE) = 3

  18. [pid 14692] open("/etc/protocols", O_RDONLY|O_CLOEXEC) = 4

  19. [pid 14692] read(4, "# Internet (IP) protocols\n#\n# Up"..., 4096) = 2933

  20. [pid 14692] open("/etc/hosts.allow", O_RDONLY) = 4

  21. [pid 14692] open("/lib/i386-linux-gnu/libnss_dns.so.2", O_RDONLY|O_CLOEXEC) = 4

  22. [pid 14692] stat64("/etc/pam.d", {st_mode=S_IFDIR|0755, st_size=4096, ...}) = 0

  23. [pid 14692] open("/etc/pam.d/common-password", O_RDONLY|O_LARGEFILE) = 8

  24. [pid 14692] open("/etc/pam.d/other", O_RDONLY|O_LARGEFILE) = 4

看懂 SSH 的呼叫是塊難啃的骨頭,但是,如果搞懂它你就學會了跟蹤。能夠看到應用程式開啟的是哪個檔案是有用的(“這個配置是從哪裡來的?”)。如果你有一個出現錯誤的行程,你可以 strace 它,然後去看它透過系統呼叫做了什麼?當一些應用程式意外退出而沒有提供適當的錯誤資訊時,你可以去檢查它是否有系統呼叫失敗。你也可以使用過濾器,檢視每個呼叫的次數,等等:

  1. ~/code/x86-os$ strace -T -e trace=recv curl -silent www.google.com. > /dev/null

  2. recv(3, "HTTP/1.1 200 OK\r\nDate: Wed, 05 N"..., 16384, 0) = 4164 <0.000007>

  3. recv(3, "fl a{color:#36c}a:visited{color:"..., 16384, 0) = 2776 <0.000005>

  4. recv(3, "adient(top,#4d90fe,#4787ed);filt"..., 16384, 0) = 4164 <0.000007>

  5. recv(3, "gbar.up.spd(b,d,1,!0);break;case"..., 16384, 0) = 2776 <0.000006>

  6. recv(3, "$),a.i.G(!0)),window.gbar.up.sl("..., 16384, 0) = 1388 <0.000004>

  7. recv(3, "margin:0;padding:5px 8px 0 6px;v"..., 16384, 0) = 1388 <0.000007>

  8. recv(3, "){window.setTimeout(function(){v"..., 16384, 0) = 1484 <0.000006>

我鼓勵你在你的作業系統中的試驗這些工具。把它們用好會讓你覺得自己有超能力。

但是,足夠有用的東西,往往要讓我們深入到它的設計中。我們可以看到那些使用者空間中的應用程式是被嚴格限制在它自己的虛擬地址空間裡,執行在 Ring 3(非特權樣式)中。一般來說,只涉及到計算和記憶體訪問的任務是不需要請求系統呼叫的。例如,像 strlen(3)[22] 和 memcpy(3)[23] 這樣的 C 庫函式並不需要核心去做什麼。這些都是在應用程式內部發生的事。

C 庫函式的 man 頁面所在的節(即圓括號裡的 2 和 3)也提供了線索。節 2 是用於系統呼叫封裝,而節 3 包含了其它 C 庫函式。但是,正如我們在 printf(3) 中所看到的,庫函式最終可以產生一個或者多個系統呼叫。

如果你對此感到好奇,這裡是 Linux[24] (也有 Filippo 的串列[25])和 Windows[26]的全部系統呼叫串列。它們各自有大約 310 和 460 個系統呼叫。看這些系統呼叫是非常有趣的,因為,它們代表了軟體在現代的計算機上能夠做什麼。另外,你還可能在這裡找到與行程間通訊和效能相關的“寶藏”。這是一個“不懂 Unix 的人註定最終還要重新發明一個蹩腳的 Unix ” 的地方。(LCTT 譯註:原文 “Those who do not understand Unix are condemned to reinvent it,poorly。” 這句話是 Henry Spencer[27] 的名言,反映了 Unix 的設計哲學,它的一些理念和文化是一種技術發展的必須結果,看似糟糕卻無法超越。)

與 CPU 週期相比,許多系統呼叫花很長的時間[28]去執行任務,例如,從一個硬碟驅動器中讀取內容。在這種情況下,呼叫行程在底層的工作完成之前一直處於休眠狀態。因為,CPU 執行的非常快,一般的程式都因為 I/O 的限制在它的生命週期的大部分時間處於休眠狀態,等待系統呼叫傳回。相反,如果你跟蹤一個計算密集型任務,你經常會看到沒有任何的系統呼叫參與其中。在這種情況下,top(1)[29] 將顯示大量的 CPU 使用。

在一個系統呼叫中的開銷可能會是一個問題。例如,固態硬碟比普通硬碟要快很多,但是,作業系統的開銷可能比 I/O 操作本身的開銷 更加昂貴[30]。執行大量讀寫操作的程式可能就是作業系統開銷的瓶頸所在。向量化 I/O[31] 對此有一些幫助。因此要做 檔案的記憶體對映[2],它允許一個程式僅訪問記憶體就可以讀或寫磁碟檔案。類似的對映也存在於像影片卡這樣的地方。最終,雲端計算的經濟性可能導致核心消除或最小化使用者樣式/核心樣式的切換。

最終,系統呼叫還有益於系統安全。一是,無論如何來歷不明的一個二進製程式,你都可以透過觀察它的系統呼叫來檢查它的行為。這種方式可能用於去檢測惡意程式。例如,我們可以記錄一個未知程式的系統呼叫的策略,並對它的異常行為進行報警,或者對程式呼叫指定一個白名單,這樣就可以讓漏洞利用變得更加困難。在這個領域,我們有大量的研究,和許多工具,但是沒有“殺手級”的解決方案。

這就是系統呼叫。很抱歉這篇文章有點長,我希望它對你有用。接下來的時間,我將寫更多(短的)文章,也可以在 RSS[32] 和 Twitter[33] 關註我。這篇文章獻給 glorious Clube Atlético Mineiro。


via:https://manybutfinite.com/post/system-calls/

作者:Gustavo Duarte[35] 譯者:qhwdw 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

贊(0)

分享創造快樂

© 2024 知識星球   網站地圖