曾經多少次想要在核心遊蕩?曾經多少次茫然不知方向?你不要再對着它迷惘,讓我們指引你走向前方……
核心程式設計常常看起來像是黑魔法,而在亞瑟 c 克拉克的眼中,它八成就是了。linux核心和它的使用者空間是大不相同的:抛開漫不經心,你必須小心翼翼,因為你程式設計中的一個bug就會影響到整個系統。浮點運算做起來可不容易,堆棧固定而狹小,而你寫的代碼總是異步的,是以你需要想想并發會導緻什麼。而除了所有這一切之外,linux核心隻是一個很大的、很複雜的c程式,它對每個人開放,任何人都去讀它、學習它并改進它,而你也可以是其中之一。

學習核心程式設計的最簡單的方式也許就是寫個核心子產品:一段可以動态加載進核心的代碼。子產品所能做的事是有限的——例如,他們不能在類似程序描述符這樣的公共資料結構中增減字段(lctt譯注:可能會破壞整個核心及系統的功能)。但是,在其它方面,他們是成熟的核心級的代碼,可以在需要時随時編譯進核心(這樣就可以摒棄所有的限制了)。完全可以在linux源代碼樹以外來開發并編譯一個子產品(這并不奇怪,它稱為樹外開發),如果你隻是想稍微玩玩,而并不想送出修改以包含到主線核心中去,這樣的方式是很友善的。
在本教程中,我們将開發一個簡單的核心子產品用以建立一個/dev/reverse裝置。寫入該裝置的字元串将以相反字序的方式讀回(“hello world”讀成“world hello”)。這是一個很受歡迎的程式員面試難題,當你利用自己的能力在核心級别實作這個功能時,可以使你得到一些加分。在開始前,有一句忠告:你的子產品中的一個bug就會導緻系統崩潰(雖然可能性不大,但還是有可能的)和資料丢失。在開始前,請確定你已經将重要資料備份,或者,采用一種更好的方式,在虛拟機中進行試驗。
<a target="_blank"></a>
預設情況下,/dev/reverse隻有root可以使用,是以你隻能使用sudo來運作你的測試程式。要解決該限制,可以建立一個包含以下内容的/lib/udev/rules.d/99-reverse.rules檔案:
别忘了重新插入子產品。讓非root使用者通路裝置節點往往不是一個好主意,但是在開發其間卻是十分有用的。這并不是說以root身份運作二進制測試檔案也不是個好主意。
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
module_license("gpl");
module_author("valentine sinitsyn <[email protected]>");
module_description("in-kernel phrase reverser");
這裡一切都直接明了,除了module_license():它不僅僅是一個标記。核心堅定地支援gpl相容代碼,是以如果你把許可證設定為其它非gpl相容的(如,“proprietary”[專利]),某些特定的核心功能将在你的子產品中不可用。
核心程式設計很有趣,但是在現實項目中寫(尤其是調試)核心代碼要求特定的技巧。通常來講,在沒有其它方式可以解決你的問題時,你才應該在核心級别解決它。以下情形中,可能你在使用者空間中解決它更好: 通常,核心裡面代碼的性能會更好,但是對于許多項目而言,這點性能丢失并不嚴重。
由于核心程式設計總是異步的,沒有一個main()函數來讓linux順序執行你的子產品。取而代之的是,你要為各種事件提供回調函數,像這個:
static int __init reverse_init(void)
{
printk(kern_info "reverse device has been registered\n");
return 0;
}
static void __exit reverse_exit(void)
printk(kern_info "reverse device has been unregistered\n");
module_init(reverse_init);
module_exit(reverse_exit);
這裡,我們定義的函數被稱為子產品的插入和删除。隻有第一個的插入函數是必要的。目前,它們隻是列印消息到核心環緩沖區(可以在使用者空間通過dmesg指令通路);kern_info是日志級别(注意,沒有逗号)。__init和__exit是屬性 —— 聯結到函數(或者變量)的中繼資料片。屬性在使用者空間的c代碼中是很罕見的,但是核心中卻很普遍。所有标記為__init的,會在初始化後釋放記憶體以供重用(還記得那條過去核心的那條“freeing unused kernel memory…[釋放未使用的核心記憶體……]”資訊嗎?)。__exit表明,當代碼被靜态建構進核心時,該函數可以安全地優化了,不需要清理收尾。最後,module_init()和module_exit()這兩個宏将reverse_init()和reverse_exit()函數設定成為我們子產品的生命周期回調函數。實際的函數名稱并不重要,你可以稱它們為init()和exit(),或者start()和stop(),你想叫什麼就叫什麼吧。他們都是靜态聲明,你在外部子產品是看不到的。事實上,核心中的任何函數都是不可見的,除非明确地被導出。然而,在核心程式員中,給你的函數加上子產品名字首是約定俗成的。
這些都是些基本概念 - 讓我們來做更多有趣的事情吧。子產品可以接收參數,就像這樣:
# modprobe foo bar=1
modinfo指令顯示了子產品接受的所有參數,而這些也可以在/sys/module//parameters下作為檔案使用。我們的子產品需要一個緩沖區來存儲參數 —— 讓我們把這大小設定為使用者可配置。在module_description()下添加如下三行:
static unsigned long buffer_size = 8192;
module_param(buffer_size, ulong, (s_irusr | s_irgrp | s_iroth));
module_parm_desc(buffer_size, "internal buffer size");
這兒,我們定義了一個變量來存儲該值,封裝成一個參數,并通過sysfs來讓所有人可讀。這個參數的描述(最後一行)出現在modinfo的輸出中。
由于使用者可以直接設定buffer_size,我們需要在reverse_init()來清除無效取值。你總該檢查來自核心之外的資料 —— 如果你不這麼做,你就是将自己置身于核心異常或安全漏洞之中。
static int __init reverse_init()
if (!buffer_size)
return -1;
printk(kern_info
"reverse device has been registered, buffer size is %lu bytes\n",
buffer_size);
來自子產品初始化函數的非0傳回值意味着子產品執行失敗。
但你開發子產品時,linux核心就是你所需一切的源頭。然而,它相當大,你可能在查找你所要的内容時會有困難。幸運的是,在龐大的代碼庫面前,有許多工具使這個過程變得簡單。首先,是cscope —— 在終端中運作的一個比較經典的工具。你所要做的,就是在核心源代碼的頂級目錄中運作make cscope && cscope。cscope和vim以及emacs整合得很好,是以你可以在你最喜愛的編輯器中使用它。
現在是時候來編譯子產品了。你需要你正在運作的核心版本頭檔案(linux-headers,或者等同的軟體包)和build-essential(或者類似的包)。接下來,該建立一個标準的makefile模闆:
obj-m += reverse.o
all:
make -c /lib/modules/$(shell uname -r)/build m=$(pwd) modules
clean:
make -c /lib/modules/$(shell uname -r)/build m=$(pwd) clean
現在,調用make來建構你的第一個子產品。如果你輸入的都正确,在目前目錄内會找到reverse.ko檔案。使用sudo insmod reverse.ko插入核心子產品,然後運作如下指令:
$ dmesg | tail -1
[ 5905.042081] reverse device has been registered, buffer size is 8192 bytes
恭喜了!然而,目前這一行還隻是假象而已 —— 還沒有裝置節點呢。讓我們來搞定它。
在linux中,有一種特殊的字元裝置類型,叫做“混雜裝置”(或者簡稱為“misc”)。它是專為單一接入點的小型裝置驅動而設計的,而這正是我們所需要的。所有混雜裝置共享同一個主裝置号(10),是以一個驅動(drivers/char/misc.c)就可以檢視它們所有裝置了,而這些裝置用次裝置号來區分。從其他意義來說,它們隻是普通字元裝置。
要為該裝置注冊一個次裝置号(以及一個接入點),你需要聲明struct misc_device,填上所有字段(注意文法),然後使用指向該結構的指針作為參數來調用misc_register()。為此,你也需要包含linux/miscdevice.h頭檔案:
static struct miscdevice reverse_misc_device = {
.minor = misc_dynamic_minor,
.name = "reverse",
.fops = &reverse_fops
};
...
misc_register(&reverse_misc_device);
printk(kern_info ...
這兒,我們為名為“reverse”的裝置請求一個第一個可用的(動态的)次裝置号;省略号表明我們之前已經見過的省略的代碼。别忘了在子產品卸下後登出掉該裝置。
misc_deregister(&reverse_misc_device);
‘fops’字段存儲了一個指針,指向一個file_operations結構(在linux/fs.h中聲明),而這正是我們子產品的接入點。reverse_fops定義如下:
static struct file_operations reverse_fops = {
.owner = this_module,
.open = reverse_open,
.llseek = noop_llseek
另外,reverse_fops包含了一系列回調函數(也稱之為方法),當使用者空間代碼打開一個裝置,讀寫或者關閉檔案描述符時,就會執行。如果你要忽略這些回調,可以指定一個明确的回調函數來替代。這就是為什麼我們将llseek設定為noop_llseek(),(顧名思義)它什麼都不幹。這個預設實作改變了一個檔案指針,而且我們現在并不需要我們的裝置可以尋址(這是今天留給你們的家庭作業)。
讓我們來實作該方法。我們将給每個打開的檔案描述符配置設定一個新的緩沖區,并在它關閉時釋放。這實際上并不安全:如果一個使用者空間應用程式洩漏了描述符(也許是故意的),它就會霸占ram,并導緻系統不可用。在現實世界中,你總得考慮到這些可能性。但在本教程中,這種方法不要緊。
我們需要一個結構函數來描述緩沖區。核心提供了許多正常的資料結構:連結清單(雙聯的),哈希表,樹等等之類。不過,緩沖區常常從頭設計。我們将調用我們的“struct buffer”:
struct buffer {
char *data, *end, *read_ptr;
unsigned long size;
data是該緩沖區存儲的一個指向字元串的指針,而end指向字元串結尾後的第一個位元組。read_ptr是read()開始讀取資料的地方。緩沖區的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(),open和close方法就變得很簡單了。
static int reverse_open(struct inode *inode, struct file *file)
int err = 0;
file->private_data = buffer_alloc(buffer_size);
return err;
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():
init_waitqueue_head(&buf->read_queue);
我們等待可用資料;或者等待read_ptr != end條件成立。我們也想要讓等待操作可以被中斷(如,通過ctrl+c)。是以,“read”方法應該像這樣開始:
static ssize_t reverse_read(struct file *file, char __user * out,
size_t size, loff_t * off)
struct buffer *buf = file->private_data;
ssize_t result;
while (buf->read_ptr == buf->end) {
if (file->f_flags & o_nonblock) {
result = -eagain;
goto out;
if (wait_event_interruptible
(buf->read_queue, buf->read_ptr != buf->end)) {
result = -erestartsys;
我們讓它循環,直到有可用資料,如果沒有則使用wait_event_interruptible()(它是一個宏,不是函數,這就是為什麼要通過值的方式給隊列傳遞)來等待。好吧,如果wait_event_interruptible()被中斷,它傳回一個非0值,這個值代表-erestartsys。這段代碼意味着系統調用應該重新啟動。file->f_flags檢查以非阻塞模式打開的檔案數:如果沒有資料,傳回-eagain。
我們不能使用if()來替代while(),因為可能有許多程序正等待資料。當write方法喚醒它們時,排程程式以不可預知的方式選擇一個來運作,是以,在這段代碼有機會執行的時候,緩沖區可能再次空出。現在,我們需要将資料從buf->data 複制到使用者空間。copy_to_user()核心函數就幹了此事:
size = min(size, (size_t) (buf->end - buf->read_ptr));
if (copy_to_user(out, buf->read_ptr, size)) {
result = -efault;
如果使用者空間指針錯誤,那麼調用可能會失敗;如果發生了此事,我們就傳回-efault。記住,不要相信任何來自核心外的事物!
buf->read_ptr += size;
result = size;
out:
return result;
為了使資料在任意塊可讀,需要進行簡單運算。該方法傳回讀入的位元組數,或者一個錯誤代碼。
寫方法更簡短。首先,我們檢查緩沖區是否有足夠的空間,然後我們使用copy_from_userspace()函數來擷取資料。再然後read_ptr和結束指針會被重置,并且反轉存儲緩沖區内容:
buf->end = buf->data + size;
buf->read_ptr = buf->data;
if (buf->end > buf->data)
reverse_phrase(buf->data, buf->end - 1);
這裡, reverse_phrase()幹了所有吃力的工作。它依賴于reverse_word()函數,該函數相當簡短并且标記為内聯。這是另外一個常見的優化;但是,你不能過度使用。因為過多的内聯會導緻核心映像徒然增大。
最後,我們需要喚醒read_queue中等待資料的程序,就跟先前講過的那樣。wake_up_interruptible()就是用來幹此事的:
wake_up_interruptible(&buf->read_queue);
耶!你現在已經有了一個核心子產品,它至少已經編譯成功了。現在,是時候來測試了。
或許,核心中最常見的調試方法就是列印。如果你願意,你可以使用普通的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環境下進行的原因。
編譯子產品,然後加載進核心:
$ make
$ sudo insmod reverse.ko buffer_size=2048
$ lsmod
reverse 2419 0
$ ls -l /dev/reverse
crw-rw-rw- 1 root root 10, 58 feb 22 15:53 /dev/reverse
一切似乎就位。現在,要測試子產品是否正常工作,我們将寫一段小程式來翻轉它的第一個指令行參數。main()(再三檢查錯誤)可能看上去像這樣:
int fd = open("/dev/reverse", o_rdwr);
write(fd, argv[1], strlen(argv[1]));
read(fd, argv[1], strlen(argv[1]));
printf("read: %s\n", argv[1]);
像這樣運作:
$ ./test 'a quick brown fox jumped over the lazy dog'
read: dog lazy the over jumped fox brown quick a
它工作正常!玩得更逗一點:試試傳遞單個單詞或者單個字母的短語,空的字元串或者是非英語字元串(如果你有這樣的鍵盤布局設定),以及其它任何東西。
現在,讓我們讓事情變得更好玩一點。我們将建立兩個程序,它們共享一個檔案描述符(及其核心緩沖區)。其中一個會持續寫入字元串到裝置,而另一個将讀取這些字元串。在下例中,我們使用了fork(2)系統調用,而pthreads也很好用。我也省略打開和關閉裝置的代碼,并在此檢查代碼錯誤(又來了):
char *phrase = "a quick brown fox jumped over the lazy dog";
if (fork())
/* parent is the writer */
while (1)
write(fd, phrase, len);
else
/* child is the reader */
while (1) {
read(fd, buf, len);
printf("read: %s\n", buf);
你希望這個程式會輸出什麼呢?下面就是在我的筆記本上得到的東西:
read: a kcicq brown fox jumped over the lazy dog
read: a kciuq nworb xor jumped fox brown quick a
這裡發生了什麼呢?就像舉行了一場比賽。我們認為read和write是原子操作,或者從頭到尾一次執行一個指令。然而,核心确實無序并發的,随便就重新排程了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的工作原理相同:
if (mutex_lock_interruptible(&buf->lock)) {
我們在函數一開始就擷取鎖。mutex_lock_interruptible()要麼得到互斥鎖然後傳回,要麼讓程序睡眠,直到有可用的互斥鎖。就像前面一樣,_interruptible字尾意味着睡眠可以由信号來中斷。
mutex_unlock(&buf->lock);
/* ... wait_event_interruptible() here ... */
下面是我們的“等待資料”循環。當擷取互斥鎖時,或者發生稱之為“死鎖”的情境時,不應該讓程序睡眠。是以,如果沒有資料,我們釋放互斥鎖并調用wait_event_interruptible()。當它傳回時,我們重新擷取互斥鎖并像往常一樣繼續:
goto out_unlock;
out_unlock:
最後,當函數結束,或者在互斥鎖被擷取過程中發生錯誤時,互斥鎖被解鎖。重新編譯子產品(别忘了重新加載),然後再次進行測試。現在你應該沒發現毀壞的資料了。
現在你已經嘗試了一次核心黑客。我們剛剛為你揭開了這個話題的外衣,裡面還有更多東西供你探索。我們的第一個子產品有意識地寫得簡單一點,在從中學到的概念在更複雜的環境中也一樣。并發、方法表、注冊回調函數、使程序睡眠以及喚醒程序,這些都是核心黑客們耳熟能詳的東西,而現在你已經看過了它們的運作。或許某天,你的核心代碼也将被加入到主線linux源代碼樹中 —— 如果真這樣,請聯系我們!
原文釋出時間:2014-06-24
本文來自雲栖合作夥伴“linux中國”