彭民德:《電子計算60年》(17)分析UNIX原始碼揭去作業系統神祕感

彭民德發表於2016-08-12

作業系統是個軟體,它也是由程式和資料結構組成的,在紙面上跟其它程式沒有什麼兩樣。開啟機器電門後它怎麼能夠讓計算機工作起來?作業系統執行起來後何以具備上面講的那許多神奇的特徵,能夠完成那麼多管理功能呢?它到底是個什麼樣子的呢?簡直有一種神祕感。教學生也好,自己理解也好,還只能停留在理論層面上,很抽象。當時高校教師普遍反映,作業系統這門課,難教難學。迫切需要閱讀一個實實在在的作業系統程式碼,至少作為教作業系統的老師有這種迫切的需求。雖然我們用的基於IBM OS/360的引進教材,不但寫得很好,它還有一個供教學用的作業系統例項SOS,但是那個例項是用匯編語言寫的,也不好讀。UNIX V6原始碼正好填補了這個空白,來得非常及時。

最先到達筆者手裡的UNIX原始碼,是由浙江大學1982年翻印的一個第六版版本。

enter image description here

UNIX原始碼從編號100的行到9099行,為閱讀方便,其間有些空行和純註釋行,實際只有8000多行。分為5節,即“程式初啟”、“自陷、中斷、系統呼叫、程式管理”、“程式對換、基本I/O、塊裝置”、“檔案和目錄、檔案系統、管道”、“字元裝置”。按照C源程式由檔案組成的架構,程式碼被分成44個檔案。其中行號100到1499是2個基於PDP-11組合語言檔案;有14個用來定義全程變數和重要資料結構的“.h”檔案;另有28個C檔案。在整個程式碼的前面,有閱讀的索引,主要是變數的交叉訪問表。它按變數的字母順序列出每個變數在程式碼中出現的行號。它對於閱讀大型程式很有用,可以說是一種不能缺少的工具。我們還是首次看到這樣的索引。常常看到有些輾轉引用資料講,UNIX核心程式碼中,組合語言程式佔10%,其實UNIX第六版組合語言程式碼行還不到7%。

近萬行UNIX程式碼跟所有其它C語言程式一樣,由一個main函式為入口統領。龐大的UNIX由眾多小程式組成,其中有一些難以想象地簡單。最簡單的程式是6577行的函式null(),程式體是個空語句,它什麼都不做。6566行的nodev()函式僅有一個賦值語句。6326行max(a,b)求a與b中較大的那個,只將二者大小做個比較,就返回a或者b,也只有兩行。有的函式只包含一個返回語句,返回值由某個表示式決定,比如7679行schar()的“return(*u.u_dirp++&0377);”以及另一處1771行“return((n+127>>7);”。這些表示式讓我們看到C語言引進的資料型別和運算子的簡潔和必要性。

UNIX的複雜性不在它的一個個程式,更不在某一些C語句,其複雜性主要體現在總體結構,程式間的關聯上。要整體把握就不是一件容易的事了。當時讓我想起我校資深教授侯振廷曾經打趣地說過,對於許多人來說,最高階學報上的文章,每一個符號都能看懂,總起來就看不懂了。

閱讀原始碼掌握作業系統的技術,是僅僅閱讀其它書籍資料不可替代的。讀程式碼時才能接觸一些技術細節,一些平時無法理解或被忽略或未發現的地方,它們對於理解作業系統的工作很重要。特別是有些程式技術是僅僅在作業系統程式碼裡才有的,比如說只在核心才有的特權指令,讀到並理解了,用現在的話說,感覺特別爽。

比如,系統為了管理併發活動,為每個程式設立控制用的proc結構和user結構;為了管理檔案,要為每個檔案建一個目錄,就好比學校各個處室建立一些表格,記載學生和教師資訊,校方據以管理學生和教師。作業系統各個部分的資料結構,靠編制使用者程式是看不見的,只能在作業系統程式碼中才能實際地看到,並能看到許多相關程式讀寫它們。

又比如,系統程式在對各個程式的proc結構進行操作,以便挑選一個符合某種條件的程式時,必須保證操作的完整性,要暫時遮蔽各種中斷的發生。而“遮蔽一切中斷”是一條“特權指令”,這種指令唯有作業系統能用,唯有作業系統程式碼中才能見到。我們平時寫程式用的都是使用者級別的指令,系統不向你提供特權指令。在UNIX核心相關程式中,通過呼叫一小段PDP-11組合語言程式碼,把CPU的中斷優先順序提高到最高階,達到遮蔽一切中斷的目的。待所需要的操作完整地做完之後,再恢復原先的中斷優先順序。而設定處理器執行某段程式碼的中斷優先順序的指令就是特權指令。

UNIX中施行程式排程的程式swtch,功能是選擇一個程式,把處理機使用權交給這個程式讓它執行。如果當前程式甲在執行,它呼叫swtch將可能選擇乙,而甲不可能繼續執行,形成如圖(左圖)所示的局面。

enter image description here

swtch的呼叫讓CPU轉道了,進去的是甲,出來的卻是乙。其它任何程式都沒有這個特徵,絕不會因為呼叫一個什麼別的程式,導致自己放棄CPU讓別人佔用,把執行權利也轉讓了。唯有作業系統中這個排程程式有這樣的特徵,讀了覺得新鮮。這也是學習其它課程無法接觸到的技術。

還有一個程式fork,功能是生成一個子程式。比如甲呼叫它可以生成乙程式,呼叫後甲還在,乙就是甲的子程式,反過來,甲就是乙的父程式(右圖)。問題是其結構不同於程式語言中的分支結構,一般的分支結構,提供非甲即乙或非乙即甲的一次選擇。哪怕多個分支,也只能多選一。現在這裡fork之後,甲乙兩支都要走,要不然建立乙就沒有意義。UNIX設計者讓fork先後有兩次返回並區別返回值,使得呼叫fork後的後續兩支程式,即分別為父子程式的程式都有機會執行,巧妙地解決了這個問題。這種程式讀了不得不為之稱奇。

程式通訊用的睡眠程式sleep也很特殊,呼叫它的程式,也要放棄CPU,在設定好睡眠原因(比如等待某種訊息,或者睡眠時長),在程式管理資訊proc結構中標註程式狀態為睡眠後,將呼叫程式排程程式swtch,去排程別的程式執行。自己則不從sleep出來,安安心心睡一會兒。此後所屬的計算機世界不管發生什麼,它都不管。到它等待的條件出現的時候,總會被一個不知道是什麼程式喚醒,把它再次排到被排程的佇列中去。

還有很多有特定功能的程式,只有讀到它,才知道它幹什麼。比如,每個程式的執行都要作業系統支援,對應程式的每個程式既包括使用者地址空間,也有一個包括整個作業系統程式碼的核心地址空間,在程式執行之前,estabur會為它裝配這兩個邏輯空間。繼而填寫各由8對暫存器組成的頁表,才能保證訪問地址的正確定位。

學習作業系統原理時講,使用者程式使用外設必需經由系統呼叫向作業系統申請,外設最終只能由作業系統啟動。但是在閱讀裝置管理部分的程式時,好久都找不到究竟在哪裡啟動外設工作。可是我堅信外設只能由作業系統啟動,作業系統程式碼中一定有啟動的地方。有一天終於找出來了,某個與外設相關的函式中有個賦值語句,被賦值的變數此前已經取得某個記憶體地址的值,它代表某個裝置的記憶體地址(裝置與記憶體統一編址技術),對應的一個給該變數賦值的語句其實就是往這個裝置地址寫入一個預定的值,其中包括向某個或某幾個位寫“1”,根據裝置特性,裝置硬體就應該被驅動了。啊,原來是這樣的,祕密就隱藏在一條看似普通的賦值語句之中。眾裡尋他千百度,原來就在燈火闌珊處,一種學習和工作中的樂趣油然而生,愉悅回報了艱苦的付出。

系統初啟程式又是怎麼回事?在計算機面前,開機後好幾分鐘內,只見硬碟指示燈一陣陣地閃爍,系統內部都在幹啥?潛心地讀讀這部分程式碼就會有所獲益。興趣是最好的老師,我願意下功夫去讀它,還要做筆記。讀到其中一些經典的程式段,簡直有學習文學時讀唐詩般的愜意。有時右手拿著鍋鏟在炒菜,左手還拿著那本程式碼在琢磨,一分心把菜炒糊了也是有的。

分析UNIX原始碼過程中還有一個收穫,看到UNIX原始碼檔案前面有一個變數名和函式名的交叉索引表。UNIX給我們做了表率,原始檔每一行都有行號,許多行都有適當的註釋,說明該函式或者語句的作用。檔案前面的交叉訪問表,給出程式中每一個變數名和函式名,在原始檔中出現的全部行號。變數名和函式名依字典順序排列,每個名字出現的行號也依行號順序排列。這一措施大大方便了程式的閱讀。後來我們還自己嘗試編寫列交叉訪問表的程式。程式有兩個引數,第一個是關鍵字表檔名,這個檔案的內容是不需要檢索的關鍵字,比如,for,case等,屬於程式語言固有的關鍵字,可以單獨編輯。第二個引數是被檢索的檔名。程式以命令列引數作為入口引數。程式中採用了樹形資料結構,用到了二叉樹遍歷等技術。可以說這也是獨一無二的收穫,不分析UNIX,不接觸大程式,就看不到交叉索引表,並深入地做實現探討。

分析UNIX原始碼時,如果把它的程式程式碼從靜態向動態運轉進行昇華,對於計算機世界的內部工作的認識就會有一個質的飛躍。我們注意到UNIX初啟完成後,通常在使用者態下執行,即在使用者態執行使用者程式。只當發生中斷或者捕獲時,系統才從使用者態轉向核心態。在核心態下進行中斷與捕獲處理都被看作是原使用者程式的繼續。如果原程式不呼叫swtch放棄CPU的話,不會發生程式轉道。程式轉道通常發生在中斷與捕獲處理結束後。在返回使用者態斷點之前,要檢查一個叫做重排程的標誌runrun,如果因為某種原因被重置過了,即其值不為0,才進行重新排程。也就是說系統把大部分時間都留給使用者程式執行,它只是在幫你做中斷與捕獲處理。系統設計者設計了各種各樣時鐘中斷和裝置中斷,還有多種“軟”中斷,為每種中斷都編寫了對應的處理程式。設計者拿捏著系統中中斷與捕獲發生的頻度,一旦發生便立即處理。在中斷與捕獲處理之後可能進行排程,保證系統中所有的使用者程式都能夠巨集觀上同時執行。系統內部各個使用者程式雖然萬馬奔騰,排程卻是高效有序地進行,當然也是依照排程策略公平地進行的。只要你不關機,作業系統將不停息地自主執行,處理和等待處理系統中各種各樣的事件。

近萬行C語言的UNIX核心可執行程式碼同駐記憶體併發執行,系統內部微觀世界熱鬧極了。它們要保證作業系統本身的完整性,定時與不定時地備份資料。它們緊緊盯住整個系統硬體,保證機器正常工作,比如寫/讀記憶體,以檢驗和保證記憶體正常工作。你開啟/關閉印表機,或者隨便按下鍵盤上一個按鍵,系統都會作出相應的反應。許多事件都在發生並被中斷處理。以時鐘中斷而論,有每20ms一次的中斷,每秒一次的中斷,還有每4秒一次的中斷。每20ms一次的中斷,用於排程計時,比如記錄一個使用者程式在計算機上本次用了多長CPU時間了。而計算機上實際的計時,記錄時分秒、年月日就是靠每秒一次時鐘中斷處理累計的。至於每4秒一次的中斷處理,將呼叫swtch, 強行計算機進行一次排程,以優化UNIX的排程策略。只要使用者啟動某個程式,UNIX都要立馬為它建立一個叫做程式的活動實體。為程式分配記憶體,裝入記憶體,滿足其資源需求,並排程執行。讓程式處在就緒、執行、I/O阻塞等不同狀態的變化之中。系統會為每個程式生成映象,並及時保留和恢復執行現場,支援其走走停停,直到執行結束。還有一些附帶程式,像螢幕保護程式,遊戲程式,則在後臺執行。當計算中心後半夜沒有人上機時,可以啟動它們執行,以保持CPU正常工作。因此即使沒有人啟動應用程式,作業系統內部也在不停地執行,維持一個精彩的內部世界。

1983年起,我們立即著手把對於UNIX的分析成果用於教學。當時給學生開一個學年作業系統課,第一個學期上作業系統原理,第二個學期閱讀UNIX原始碼。學生們反映,學的知識新而具體,學的紮實。通過分析程式碼也得到了一些做研究寫論文的訓練。

分析UNIX原始碼除了提高對UNIX的認識,對於理解和掌握運用C語言,學習資料結構都有很大幫助。UNIX中,有很多資料型別。其中頻繁使用的有管理程式的proc結構、usr結構,記憶體頁表結構、管理檔案目錄的i節點結構、管理檔案磁碟塊的多重索引結構、管理空閒磁碟塊的成組連線結構、管理字元裝置和塊裝置的裝置表結構。每個結構中往往都包含整型、實型、字元型、陣列、指標和結構型別。相同結構又會組成佇列、陣列、單連結串列或者雙向連結串列、樹等更復雜的資料型別。看到在各個資料物件上的檢索、排序、插入、刪除等操作。在UNIX程式碼中,還看到平常很少用的資料型別,比如暫存器型別變數、靜態變數、聯合。通過閱讀UNIX這樣的,我們還可以讀得懂的大型程式,更可以學到如何將一個大的任務劃分成模組,怎樣設計模組間的輸入、輸出,劃分模組的呼叫關係。可以說我們當時分析的UNIX是基於高階語言的,程式導向的程式設計方法的典範,對於我們這個行當的人來說,分析它的意義怎麼強調都不過分

時至今日,還時不時看到有人提出是否要做原始碼分析工作,比如是否要分析LINUX,而且存在爭議。依我看,分析原始碼的意義是不必爭議的,問題在於你是否有時間,這要花去很多時間的。好比唐詩宋詞以及閱讀它們的意義不言自明,問題是自己是否想並且可以拿出多少時間用在這種閱讀上。在經典的計算機程式面前,要是你其它任務忙不過來,只好卻步。要是時間和精力允許,而且有興趣的話,不妨讀一讀,或深或淺,總會有好處的。

(與本文相關的更多內容,可參看 彭民德《電子計算60年》第4章 三代計算併發共享 電子工業出版社)

相關文章