編寫屬於你的第一個Linux核心模組

發表於2014-06-26

曾經多少次想要在核心遊蕩?曾經多少次茫然不知方向?你不要再對著它迷惘,讓我們指引你走向前方……

核心程式設計常常看起來像是黑魔法,而在亞瑟 C 克拉克的眼中,它八成就是了。Linux核心和它的使用者空間是大不相同的:拋開漫不經心,你必須小心翼翼,因為你程式設計中的一個bug就會影響到整個系統。浮點運算做起來可不容易,堆疊固定而狹小,而你寫的程式碼總是非同步的,因此你需要想想併發會導致什麼。而除了所有這一切之外,Linux核心只是一個很大的、很複雜的C程式,它對每個人開放,任何人都去讀它、學習它並改進它,而你也可以是其中之一。

學習核心程式設計的最簡單的方式也許就是寫個核心模組:一段可以動態載入進核心的程式碼。模組所能做的事是有限的——例如,他們不能在類似程式描述符這樣的公共資料結構中增減欄位(LCTT譯註:可能會破壞整個核心及系統的功能)。但是,在其它方面,他們是成熟的核心級的程式碼,可以在需要時隨時編譯進核心(這樣就可以摒棄所有的限制了)。完全可以在Linux原始碼樹以外來開發並編譯一個模組(這並不奇怪,它稱為樹外開發),如果你只是想稍微玩玩,而並不想提交修改以包含到主線核心中去,這樣的方式是很方便的。

在本教程中,我們將開發一個簡單的核心模組用以建立一個/dev/reverse裝置。寫入該裝置的字串將以相反字序的方式讀回(“Hello World”讀成“World Hello”)。這是一個很受歡迎的程式設計師面試難題,當你利用自己的能力在核心級別實現這個功能時,可以使你得到一些加分。在開始前,有一句忠告:你的模組中的一個bug就會導致系統崩潰(雖然可能性不大,但還是有可能的)和資料丟失。在開始前,請確保你已經將重要資料備份,或者,採用一種更好的方式,在虛擬機器中進行試驗。

儘可能不要用root身份

預設情況下,/dev/reverse只有root可以使用,因此你只能使用sudo來執行你的測試程式。要解決該限制,可以建立一個包含以下內容的/lib/udev/rules.d/99-reverse.rules檔案:

別忘了重新插入模組。讓非root使用者訪問裝置節點往往不是一個好主意,但是在開發其間卻是十分有用的。這並不是說以root身份執行二進位制測試檔案也不是個好主意。

模組的構造

由於大多數的Linux核心模組是用C寫的(除了底層的特定於體系結構的部分),所以推薦你將你的模組以單一檔案形式儲存(例如,reverse.c)。我們已經把完整的原始碼放在GitHub上——這裡我們將看其中的一些片段。開始時,我們先要包含一些常見的檔案頭,並用預定義的巨集來描述模組:

這裡一切都直接明瞭,除了MODULE_LICENSE():它不僅僅是一個標記。核心堅定地支援GPL相容程式碼,因此如果你把許可證設定為其它非GPL相容的(如,“Proprietary”[專利]),某些特定的核心功能將在你的模組中不可用。

什麼時候不該寫核心模組

核心程式設計很有趣,但是在現實專案中寫(尤其是除錯)核心程式碼要求特定的技巧。通常來講,在沒有其它方式可以解決你的問題時,你才應該在核心級別解決它。以下情形中,可能你在使用者空間中解決它更好:

  • 你要開發一個USB驅動 —— 請檢視libusb
  • 你要開發一個檔案系統 —— 試試FUSE
  • 你在擴充套件Netfilter —— 那麼libnetfilter_queue對你有所幫助。

通常,核心裡面程式碼的效能會更好,但是對於許多專案而言,這點效能丟失並不嚴重。

由於核心程式設計總是非同步的,沒有一個main()函式來讓Linux順序執行你的模組。取而代之的是,你要為各種事件提供回撥函式,像這個:

這裡,我們定義的函式被稱為模組的插入和刪除。只有第一個的插入函式是必要的。目前,它們只是列印訊息到核心環緩衝區(可以在使用者空間通過dmesg命令訪問);KERN_INFO是日誌級別(注意,沒有逗號)。__init__exit是屬性 —— 聯結到函式(或者變數)的後設資料片。屬性在使用者空間的C程式碼中是很罕見的,但是核心中卻很普遍。所有標記為__init的,會在初始化後釋放記憶體以供重用(還記得那條過去核心的那條“Freeing unused kernel memory…[釋放未使用的核心記憶體……]”資訊嗎?)。__exit表明,當程式碼被靜態構建進核心時,該函式可以安全地優化了,不需要清理收尾。最後,module_init()module_exit()這兩個巨集將reverse_init()reverse_exit()函式設定成為我們模組的生命週期回撥函式。實際的函式名稱並不重要,你可以稱它們為init()exit(),或者start()stop(),你想叫什麼就叫什麼吧。他們都是靜態宣告,你在外部模組是看不到的。事實上,核心中的任何函式都是不可見的,除非明確地被匯出。然而,在核心程式設計師中,給你的函式加上模組名字首是約定俗成的。

這些都是些基本概念 – 讓我們來做更多有趣的事情吧。模組可以接收引數,就像這樣:

modinfo命令顯示了模組接受的所有引數,而這些也可以在/sys/module//parameters下作為檔案使用。我們的模組需要一個緩衝區來儲存引數 —— 讓我們把這大小設定為使用者可配置。在MODULE_DESCRIPTION()下新增如下三行:

這兒,我們定義了一個變數來儲存該值,封裝成一個引數,並通過sysfs來讓所有人可讀。這個引數的描述(最後一行)出現在modinfo的輸出中。

由於使用者可以直接設定buffer_size,我們需要在reverse_init()來清除無效取值。你總該檢查來自核心之外的資料 —— 如果你不這麼做,你就是將自己置身於核心異常或安全漏洞之中。

來自模組初始化函式的非0返回值意味著模組執行失敗。

導航

但你開發模組時,Linux核心就是你所需一切的源頭。然而,它相當大,你可能在查詢你所要的內容時會有困難。幸運的是,在龐大的程式碼庫面前,有許多工具使這個過程變得簡單。首先,是Cscope —— 在終端中執行的一個比較經典的工具。你所要做的,就是在核心原始碼的頂級目錄中執行make cscope && cscope。Cscope和Vim以及Emacs整合得很好,因此你可以在你最喜愛的編輯器中使用它。

如果基於終端的工具不是你的最愛,那麼就訪問http://lxr.free-electrons.com吧。它是一個基於web的核心導航工具,即使它的功能沒有Cscope來得多(例如,你不能方便地找到函式的用法),但它仍然提供了足夠多的快速查詢功能。

現在是時候來編譯模組了。你需要你正在執行的核心版本標頭檔案(linux-headers,或者等同的軟體包)和build-essential(或者類似的包)。接下來,該建立一個標準的Makefile模板:

現在,呼叫make來構建你的第一個模組。如果你輸入的都正確,在當前目錄內會找到reverse.ko檔案。使用sudo insmod reverse.ko插入核心模組,然後執行如下命令:

恭喜了!然而,目前這一行還只是假象而已 —— 還沒有裝置節點呢。讓我們來搞定它。

混雜裝置

在Linux中,有一種特殊的字元裝置型別,叫做“混雜裝置”(或者簡稱為“misc”)。它是專為單一接入點的小型裝置驅動而設計的,而這正是我們所需要的。所有混雜裝置共享同一個主裝置號(10),因此一個驅動(drivers/char/misc.c)就可以檢視它們所有裝置了,而這些裝置用次裝置號來區分。從其他意義來說,它們只是普通字元裝置。

要為該裝置註冊一個次裝置號(以及一個接入點),你需要宣告struct misc_device,填上所有欄位(注意語法),然後使用指向該結構的指標作為引數來呼叫misc_register()。為此,你也需要包含linux/miscdevice.h標頭檔案:

這兒,我們為名為“reverse”的裝置請求一個第一個可用的(動態的)次裝置號;省略號表明我們之前已經見過的省略的程式碼。別忘了在模組卸下後登出掉該裝置。

‘fops’欄位儲存了一個指標,指向一個file_operations結構(在Linux/fs.h中宣告),而這正是我們模組的接入點。reverse_fops定義如下:

另外,reverse_fops包含了一系列回撥函式(也稱之為方法),當使用者空間程式碼開啟一個裝置,讀寫或者關閉檔案描述符時,就會執行。如果你要忽略這些回撥,可以指定一個明確的回撥函式來替代。這就是為什麼我們將llseek設定為noop_llseek(),(顧名思義)它什麼都不幹。這個預設實現改變了一個檔案指標,而且我們現在並不需要我們的裝置可以定址(這是今天留給你們的家庭作業)。

關閉和開啟

讓我們來實現該方法。我們將給每個開啟的檔案描述符分配一個新的緩衝區,並在它關閉時釋放。這實際上並不安全:如果一個使用者空間應用程式洩漏了描述符(也許是故意的),它就會霸佔RAM,並導致系統不可用。在現實世界中,你總得考慮到這些可能性。但在本教程中,這種方法不要緊。

我們需要一個結構函式來描述緩衝區。核心提供了許多常規的資料結構:連結列表(雙聯的),雜湊表,樹等等之類。不過,緩衝區常常從頭設計。我們將呼叫我們的“struct buffer”:

data是該緩衝區儲存的一個指向字串的指標,而end指向字串結尾後的第一個位元組。read_ptrread()開始讀取資料的地方。緩衝區的size是為了保證完整性而儲存的 —— 目前,我們還沒有使用該區域。你不能假設使用你結構體的使用者會正確地初始化所有這些東西,所以最好在函式中封裝緩衝區的分配和收回。它們通常命名為buffer_alloc()buffer_free()

static struct buffer buffer_alloc(unsigned long size) { struct buffer *buf; buf = kzalloc(sizeof(buf), GFP_KERNEL); if (unlikely(!buf)) goto out; … out: return buf; }

核心記憶體使用kmalloc()來分配,並使用kfree()來釋放;kzalloc()的風格是將記憶體設定為全零。不同於標準的malloc(),它的核心對應部分收到的標誌指定了第二個引數中請求的記憶體型別。這裡,GFP_KERNEL是說我們需要一個普通的核心記憶體(不是在DMA或高記憶體區中)以及如果需要的話函式可以睡眠(重新排程程式)。sizeof(*buf)是一種常見的方式,它用來獲取可通過指標訪問的結構體的大小。

你應該隨時檢查kmalloc()的返回值:訪問NULL指標將導致核心異常。同時也需要注意unlikely()巨集的使用。它(及其相對巨集likely())被廣泛用於核心中,用於表明條件幾乎總是真的(或假的)。它不會影響到控制流程,但是能幫助現代處理器通過分支預測技術來提升效能。

最後,注意goto語句。它們常常為認為是邪惡的,但是,Linux核心(以及一些其它系統軟體)採用它們來實施集中式的函式退出。這樣的結果是減少巢狀深度,使程式碼更具可讀性,而且非常像更高階語言中的try-catch區塊。

有了buffer_alloc()buffer_free()openclose方法就變得很簡單了。

struct file是一個標準的核心資料結構,用以儲存開啟的檔案的資訊,如當前檔案位置(file->f_pos)、標誌(file->f_flags),或者開啟模式(file->f_mode)等。另外一個欄位file->privatedata用於關聯檔案到一些專有資料,它的型別是void *,而且它在檔案擁有者以外,對核心不透明。我們將一個緩衝區儲存在那裡。

如果緩衝區分配失敗,我們通過返回否定值(-ENOMEM)來為呼叫的使用者空間程式碼標明。一個C庫中呼叫的open(2)系統呼叫(如glibc)將會檢測這個並適當地設定errno 。

學習如何讀和寫

“read”和“write”方法是真正完成工作的地方。當資料寫入到緩衝區時,我們放棄之前的內容和反向地儲存該欄位,不需要任何臨時儲存。read方法僅僅是從核心緩衝區複製資料到使用者空間。但是如果緩衝區還沒有資料,revers_eread()會做什麼呢?在使用者空間中,read()呼叫會在有可用資料前阻塞它。在核心中,你就必須等待。幸運的是,有一項機制用於處理這種情況,就是‘wait queues’。

想法很簡單。如果當前程式需要等待某個事件,它的描述符(struct task_struct儲存‘current’資訊)被放進非可執行(睡眠中)狀態,並新增到一個佇列中。然後schedule()就被呼叫來選擇另一個程式執行。生成事件的程式碼通過使用佇列將等待程式放回TASK_RUNNING狀態來喚醒它們。排程程式將在以後在某個地方選擇它們之一。Linux有多種非可執行狀態,最值得注意的是TASK_INTERRUPTIBLE(一個可以通過訊號中斷的睡眠)和TASK_KILLABLE(一個可被殺死的睡眠中的程式)。所有這些都應該正確處理,並等待佇列為你做這些事。

一個用以儲存讀取等待佇列頭的天然場所就是結構緩衝區,所以從為它新增wait_queue_headt read\queue欄位開始。你也應該包含linux/sched.h標頭檔案。可以使用DECLARE_WAITQUEUE()巨集來靜態宣告一個等待佇列。在我們的情況下,需要動態初始化,因此新增下面這行到buffer_alloc()

我們等待可用資料;或者等待read_ptr != end條件成立。我們也想要讓等待操作可以被中斷(如,通過Ctrl+C)。因此,“read”方法應該像這樣開始:

我們讓它迴圈,直到有可用資料,如果沒有則使用wait_event_interruptible()(它是一個巨集,不是函式,這就是為什麼要通過值的方式給佇列傳遞)來等待。好吧,如果wait_event_interruptible()被中斷,它返回一個非0值,這個值代表-ERESTARTSYS。這段程式碼意味著系統呼叫應該重新啟動。file->f_flags檢查以非阻塞模式開啟的檔案數:如果沒有資料,返回-EAGAIN

我們不能使用if()來替代while(),因為可能有許多程式正等待資料。當write方法喚醒它們時,排程程式以不可預知的方式選擇一個來執行,因此,在這段程式碼有機會執行的時候,緩衝區可能再次空出。現在,我們需要將資料從buf->data 複製到使用者空間。copy_to_user()核心函式就幹了此事:

如果使用者空間指標錯誤,那麼呼叫可能會失敗;如果發生了此事,我們就返回-EFAULT。記住,不要相信任何來自核心外的事物!

為了使資料在任意塊可讀,需要進行簡單運算。該方法返回讀入的位元組數,或者一個錯誤程式碼。

寫方法更簡短。首先,我們檢查緩衝區是否有足夠的空間,然後我們使用copy_from_userspace()函式來獲取資料。再然後read_ptr和結束指標會被重置,並且反轉儲存緩衝區內容:

這裡, reverse_phrase()幹了所有吃力的工作。它依賴於reverse_word()函式,該函式相當簡短並且標記為內聯。這是另外一個常見的優化;但是,你不能過度使用。因為過多的內聯會導致核心映像徒然增大。

最後,我們需要喚醒read_queue中等待資料的程式,就跟先前講過的那樣。wake_up_interruptible()就是用來幹此事的:

耶!你現在已經有了一個核心模組,它至少已經編譯成功了。現在,是時候來測試了。

除錯核心程式碼

或許,核心中最常見的除錯方法就是列印。如果你願意,你可以使用普通的printk() (假定使用KERN_DEBUG日誌等級)。然而,那兒還有更好的辦法。如果你正在寫一個裝置驅動,這個裝置驅動有它自己的“struct device”,可以使用pr_debug()或者dev_dbg():它們支援動態除錯(dyndbg)特性,並可以根據需要啟用或者禁用(請查閱Documentation/dynamic-debug-howto.txt)。對於單純的開發訊息,使用pr_devel(),除非設定了DEBUG,否則什麼都不會做。要為我們的模組啟用DEBUG,請新增以下行到Makefile中:

完了之後,使用dmesg來檢視pr_debug()pr_devel()生成的除錯資訊。 或者,你可以直接傳送除錯資訊到控制檯。要想這麼幹,你可以設定console_loglevel核心變數為8或者更大的值(echo 8 /proc/sys/kernel/printk),或者在高日誌等級,如KERN_ERR,來臨時列印要查詢的除錯資訊。很自然,在釋出程式碼前,你應該移除這樣的除錯宣告。

注意核心訊息出現在控制檯,不要在Xterm這樣的終端模擬器視窗中去檢視;這也是在核心開發時,建議你不在X環境下進行的原因。

驚喜,驚喜!

編譯模組,然後載入進核心:

一切似乎就位。現在,要測試模組是否正常工作,我們將寫一段小程式來翻轉它的第一個命令列引數。main()(再三檢查錯誤)可能看上去像這樣:

像這樣執行:

它工作正常!玩得更逗一點:試試傳遞單個單詞或者單個字母的短語,空的字串或者是非英語字串(如果你有這樣的鍵盤佈局設定),以及其它任何東西。

現在,讓我們讓事情變得更好玩一點。我們將建立兩個程式,它們共享一個檔案描述符(及其核心緩衝區)。其中一個會持續寫入字串到裝置,而另一個將讀取這些字串。在下例中,我們使用了fork(2)系統呼叫,而pthreads也很好用。我也省略開啟和關閉裝置的程式碼,並在此檢查程式碼錯誤(又來了):

你希望這個程式會輸出什麼呢?下面就是在我的筆記本上得到的東西:

這裡發生了什麼呢?就像舉行了一場比賽。我們認為readwrite是原子操作,或者從頭到尾一次執行一個指令。然而,核心確實無序併發的,隨便就重新排程了reverse_phrase()函式內部某個地方執行著的寫入操作的核心部分。如果在寫入操作結束前就排程了read()操作呢?就會產生資料不完整的狀態。這樣的bug非常難以找到。但是,怎樣來處理這個問題呢?

基本上,我們需要確保在寫方法返回前沒有read方法能被執行。如果你曾經編寫過一個多執行緒的應用程式,你可能見過同步原語(鎖),如互斥鎖或者訊號。Linux也有這些,但有些細微的差別。核心程式碼可以執行程式上下文(使用者空間程式碼的“代表”工作,就像我們使用的方法)和終端上下文(例如,一個IRQ處理執行緒)。如果你已經在程式上下文中和並且你已經得到了所需的鎖,你只需要簡單地睡眠和重試直到成功為止。在中斷上下文時你不能處於休眠狀態,因此程式碼會在一個迴圈中執行直到鎖可用。關聯原語被稱為自旋鎖,但在我們的環境中,一個簡單的互斥鎖 —— 在特定時間內只有唯一一個程式能“佔有”的物件 —— 就足夠了。處於效能方面的考慮,現實的程式碼可能也會使用讀-寫訊號。

鎖總是保護某些資料(在我們的環境中,是一個“struct buffer”例項),而且也常常會把它們嵌入到它們所保護的結構體中。因此,我們新增一個互斥鎖(‘struct mutex lock’)到“struct buffer”中。我們也必須用mutex_init()來初始化互斥鎖;buffer_alloc是用來處理這件事的好地方。使用互斥鎖的程式碼也必須包含linux/mutex.h

互斥鎖很像交通訊號燈 —— 要是司機不看它和不聽它的,它就沒什麼用。因此,在對緩衝區做操作並在操作完成時釋放它之前,我們需要更新reverse_read()reverse_write()來獲取互斥鎖。讓我們來看看read方法 —— write的工作原理相同:

我們在函式一開始就獲取鎖。mutex_lock_interruptible()要麼得到互斥鎖然後返回,要麼讓程式睡眠,直到有可用的互斥鎖。就像前面一樣,_interruptible字尾意味著睡眠可以由訊號來中斷。

下面是我們的“等待資料”迴圈。當獲取互斥鎖時,或者發生稱之為“死鎖”的情境時,不應該讓程式睡眠。因此,如果沒有資料,我們釋放互斥鎖並呼叫wait_event_interruptible()。當它返回時,我們重新獲取互斥鎖並像往常一樣繼續:

最後,當函式結束,或者在互斥鎖被獲取過程中發生錯誤時,互斥鎖被解鎖。重新編譯模組(別忘了重新載入),然後再次進行測試。現在你應該沒發現毀壞的資料了。

接下來是什麼?

現在你已經嘗試了一次核心黑客。我們剛剛為你揭開了這個話題的外衣,裡面還有更多東西供你探索。我們的第一個模組有意識地寫得簡單一點,在從中學到的概念在更復雜的環境中也一樣。併發、方法表、註冊回撥函式、使程式睡眠以及喚醒程式,這些都是核心黑客們耳熟能詳的東西,而現在你已經看過了它們的運作。或許某天,你的核心程式碼也將被加入到主線Linux原始碼樹中 —— 如果真這樣,請聯絡我們!

相關文章