天天看點

核心子產品調試

對 于任何一位核心代碼的編寫者來說,最急迫的問題之一就是如何完成調試。由于核心是一個不與特定程序相關的功能集合,是以核心代碼無法輕易地放在調試器中執 行,而且也很難跟蹤。同樣,要想複現核心代碼中的錯誤也是相當困難的,因為這種錯誤可能導緻整個系統崩潰,這樣也就破壞了可以用來跟蹤它們的現場。

本章将介紹在這種令人痛苦的環境下監視核心代碼并跟蹤錯誤的技術。

4.1  通過列印調試

最普通的調試技術就是監視,即在應用程式程式設計中,在一些适當的地點調用printf 顯示監視資訊。調試核心代碼的時候,則可以用 printk 來完成相同的工作。

4.1.1  printk

在前面的章節中,我們隻是簡單假設 printk 工作起來和 printf 很類似。現在則是介紹它們之間一些不同點的時候了。

其 中一個差别就是,通過附加不同日志級别(loglevel),或者說消息優先級,可讓 printk根據這些級别所标示的嚴重程度,對消息進行分類。一般采用宏來訓示日志級别,例如,KERN_INFO,我們在前面已經看到它被添加在一些打 印語句的前面,它就是一個可以使用的消息日志級别。日志級别宏展開為一個字元串,在編譯時由預處理器将它和消息文本拼接在一起;這也就是為什麼下面的例子 中優先級和格式字串間沒有逗号的原因。下面有兩個 printk 的例子,一個是調試資訊,一個是臨界資訊:

printk(KERN_DEBUG "Here I am: %s:%i/n", _ _FILE_ _, _ _LINE_ _);

printk(KERN_CRIT "I'm trashed; giving up on %p/n", ptr);

在頭檔案 <linux/kernel.h> 中定義了 8 種可用的日志級别字元串。

KERN_EMERG

用于緊急事件消息,它們一般是系統崩潰之前提示的消息。

KERN_ALERT

用于需要立即采取動作的情況。

KERN_CRIT

臨界狀态,通常涉及嚴重的硬體或軟體操作失敗。

KERN_ERR

用于報告錯誤狀态;裝置驅動程式會經常使用 KERN_ERR 來報告來自硬體的問題。

KERN_WARNING

對可能出現問題的情況進行警告,這類情況通常不會對系統造成嚴重問題。

KERN_NOTICE

有必要進行提示的正常情形。許多與安全相關的狀況用這個級别進行彙報。

KERN_INFO

提示性資訊。很多驅動程式在啟動的時候,以這個級别列印出它們找到的硬體資訊。

KERN_DEBUG

用于調試資訊。

每個字元串(以宏的形式展開)代表一個尖括号中的整數。整數值的範圍從0到7,數值越小,優先級就越高。

沒 有指定優先級的 printk 語句預設采用的級别是 DEFAULT_MESSAGE_LOGLEVEL,這個宏在檔案 kernel/printk.c 中指定為一個整數值。在 Linux 的開發過程中,這個預設的級别值已經有過好幾次變化,是以我們建議讀者始終指定一個明确的級别。

根 據日志級别,核心可能會把消息列印到目前控制台上,這個控制台可以是一個字元模式的終端、一個序列槽列印機或是一個并口列印機。如果優先級小于 console_loglevel 這個整數值的話,消息才能顯示出來。如果系統同時運作了 klogd  和 syslogd,則無論 console_loglevel 為何值,核心消息都将追加到 /var/log/messages 中(否則的話,除此之外的處理方式就依賴于對 syslogd 的設定)。如果 klogd 沒有運作,這些消息就不會傳遞到使用者空間,這種情況下,就隻好檢視 /proc/kmsg 了。

變 量 console_loglevel 的初始值是 DEFAULT_CONSOLE_LOGLEVEL,而且還可以通過sys_syslog 系統調用進行修改。調用 klogd 時可以指定 -c 開關選項來修改這個變量, klogd 的 man 手冊頁對此有詳細說明。注意,要修改它的目前值,必須先殺掉 klogd,再加 -c選項重新啟動它。此外,還可以編寫程式來改變控制台日志級别。讀者可以在 O’Reilly 的 FTP 站點提供的源檔案 miscprogs/setlevel.c 裡找到這樣的一段程式。新優先級被指定為一個 1 到 8 之間的整數值。如果值被設為 1,則隻有級别為 0(KERN_EMERG) 的消息才能到達控制台;如果設為 8,則包括調試資訊在内的所有消息都能顯示出來。

如 果在控制台上工作,而且常常遇到核心錯誤(參見本章後面的“調試系統故障”一節)的話,就有必要降低日志級别,因為出錯處理代碼會把 console_loglevel 增為它的最大數值,導緻随後的所有消息都顯示在控制台上。如果需要檢視調試資訊,就有必要提高日志級别;這在遠端調試核心,并且在互動會話未使用文本控制 台的情況下,是很有幫助的。

從2.1.31這個版本起,可以通過文本檔案 /proc/sys/kernel/printk 來讀取和修改控制台的日志級别。這個檔案容納了 4 個整數值。讀者可能會對前面兩個感興趣:控制台的目前日志級别和預設日志級别。例如,在最近的這些核心版本中,可以通過簡單地輸入下面的指令使所有的核心 消息得到顯示:

# echo 8 > /proc/sys/kernel/printk

不過,如果仍在 2.0 版本下的話,就需要使用 setlevel 這樣的工具了。

現在大家應該清楚為什麼在 hello.c範例中使用 <1> 這些标記了,它們用來確定這些消息能在控制台上顯示出來。

對 于控制台日志政策,Linux考慮到了某些靈活性,也就是說,可以發送消息到一個指定的虛拟控制台(假如控制台是文本螢幕的話)。預設情況下,“控制台” 就是目前地虛拟終端。可以在任何一個控制台裝置上調用 ioctl(TIOCLINUX),來指定接收消息的虛拟終端。下面的 setconsole  程式,可選擇專門用來接收核心消息的控制台;這個程式必須由超級使用者運作,在 misc-progs 目錄裡可以找到它。下面是程式的代碼:

int main(int argc, char **argv)

{

  char bytes[2] = {11,0};

  if (argc==2) bytes[1] = atoi(argv[1]);

  else {

      fprintf(stderr, "%s: need a single arg/n",argv[0]); exit(1);

  }

  if (ioctl(STDIN_FILENO, TIOCLINUX, bytes)<0) {    

      fprintf(stderr,"%s: ioctl(stdin, TIOCLINUX): %s/n",

              argv[0], strerror(errno));

      exit(1);

  }

  exit(0);

}

setconsole 使用了特殊的ioctl指令:TIOCLINUX ,這個指令可以完成一些特定的 Linux 功能。使用 TIOCLINUX 時,需要傳給它一個指向位元組數組的指針參數。數組的第一個位元組指定所請求子指令的數字,接下去的位元組所具有的功能則由這個子指令決定。在 setconsole 中,使用的子指令是 11,後面那個位元組(存于bytes[1]中)辨別虛拟控制台。關于 TIOCLINUX 的詳盡描述可以在核心源碼中的 drivers/char/tty_io.c 檔案得到。

4.1.2  消息如何被記錄

printk 函數将消息寫到一個長度為 LOG_BUF_LEN(定義在 kernel/printk.c 中)位元組的循環緩沖區中,然後喚醒任何正在等待消息的程序,即那些睡眠在 syslog 系統調用上的程序,或者讀取 /proc/kmesg 的程序。這兩個通路日志引擎的接口幾乎是等價的,不過請注意,對 /proc/kmesg 進行讀操作時,日志緩沖區中被讀取的資料就不再保留,而 syslog 系統調用卻能随意地傳回日志資料,并保留這些資料以便其它程序也能使用。一般而言,讀 /proc 檔案要容易些,這使它成為 klogd 的預設方法。

手工讀取核心消息時,在停止klogd之後,可以發現 /proc 檔案很象一個FIFO,讀程序會阻塞在裡面以等待更多的資料。顯然,如果已經有 klogd 或其它的程序正在讀取相同的資料,就不能采用這種方法進行消息讀取,因為會與這些程序發生競争。

如 果循環緩沖區填滿了,printk就繞回緩沖區的開始處填寫新資料,覆寫最陳舊的資料,于是記錄程序就會丢失最早的資料。但與使用循環緩沖區所帶來的好處 相比,這個問題可以忽略不計。例如,循環緩沖區可以使系統在沒有記錄程序的情況下照樣運作,同時覆寫那些不再會有人去讀的舊資料,進而使記憶體的浪費減到最 少。Linux消息處理方法的另一個特點是,可以在任何地方調用printk,甚至在中斷處理函數裡也可以調用,而且對資料量的大小沒有限制。而這個方法 的唯一缺點就是可能丢失某些資料。

klogd 運作時,會讀取核心消息并将它們分發到 syslogd,syslogd 随後檢視 /etc/syslog.conf ,找出處理這些資料的方法。syslogd 根據設施和優先級對消息進行區分;這兩者的允許值均定義在 <sys/syslog.h> 中。核心消息由 LOG_KERN 設施記錄,并以 printk 中使用的優先級記錄(例如,printk 中使用的 KERN_ERR對應于syslogd 中的 LOG_ERR)。如果沒有運作 klogd,資料将保留在循環緩沖區中,直到某個程序讀取或緩沖區溢出為止。

如 果想避免因為來自驅動程式的大量監視資訊而擾亂系統日志,則可以為 klogd 指定 -f (file) 選項,訓示 klogd 将消息儲存到某個特定的檔案,或者修改 /etc/syslog.conf 來适應自己的需求。另一種可能的辦法是采取強硬措施:殺掉klogd,而将消息詳細地列印到空閑的虛拟終端上。*

注: 例如,使用下面的指令可設定 10 号終端用于消息的顯示:

setlevel 8

setconsole 10

或者在一個未使用的 xterm 上執行cat /proc/kmesg來顯示消息。

4.1.3  開啟及關閉消息

在 驅動程式開發的初期階段,printk 對于調試和測試新代碼是相當有幫助的。不過,當正式釋出驅動程式時,就得删除這些列印語句,或至少讓它們失效。不幸的是,你可能會發現這樣的情況,在删除 了那些已被認為不再需要的提示消息後,又需要實作一個新的功能(或是有人發現了一個 bug),這時,又希望至少把一部分消息重新開啟。這兩個問題可以通過幾個辦法解決,以便全局地開啟或禁止消息,并能對個别消息進行開關控制。

我們在這裡給出了一個編寫 printk 調用的方法,可個别或全局地對它們進行開關;這個技巧是定義一個宏,在需要時,這個宏展開為一個printk(或printf)調用。

可以通過在宏名字中删減或增加一個字母,打開或關閉每一條列印語句。

編譯前修改 CFLAGS 變量,則可以一次關閉所有消息。

同樣的列印語句既可以用在核心态也可以用在使用者态,是以,關于這些額外的資訊,驅動和測試程式可以用同樣的方法來進行管理。

下面這些來自 scull.h 的代碼,就實作了這些功能。

#undef PDEBUG            

#ifdef SCULL_DEBUG

#  ifdef _ _KERNEL_ _

#    define PDEBUG(fmt, args...) printk( KERN_DEBUG "scull: " fmt,

                                       ## args)

#  else

#    define PDEBUG(fmt, args...) fprintf(stderr, fmt, ## args)

#  endif

#else

#  define PDEBUG(fmt, args...)

#endif

#undef PDEBUGG

#define PDEBUGG(fmt, args...)

符 号 PDEBUG 依賴于是否定義了SCULL_DEBUG,它能根據代碼所運作的環境選擇合适的方式顯示資訊:核心态運作時使用printk系統調用;使用者态下則使用 libc調用fprintf,向标準錯誤裝置進行輸出。符号PDEBUGG則什麼也不做;它可以用來将列印語句注釋掉,而不必把它們完全删除。

為了進一步簡化這個過程,可以在 Makefile加上下面幾行:

# Comment/uncomment the following line to disable/enable debugging

DEBUG = y

# Add your debugging flag (or not) to CFLAGS

ifeq ($(DEBUG),y)

DEBFLAGS = -O -g -DSCULL_DEBUG # "-O" is needed to expand inlines

else

DEBFLAGS = -O2

endif

CFLAGS += $(DEBFLAGS)

本 節所給出的宏依賴于gcc 對ANSI C預編譯器的擴充,這種擴充支援了帶可變數目參數的宏。對 gcc 的這種依賴并不是什麼問題,因為核心對 gcc 特性的依賴更強。此外,Makefile依賴于 GNU 的make 版本;基于同樣的道理,這也不是什麼問題。

如果讀者熟悉 C 預編譯器,可以将上面的定義進行擴充,實作“調試級别”的概念,這需要定義一組不同的級别,并為每個級别賦一個整數(或位掩碼),用以決定各個級别消息的詳細程度。

但 是每一個驅動程式都會有自身的功能和監視需求。良好的程式設計技術在于選擇靈活性和效率的最佳折衷點,對讀者來說,我們無法預知最合适的點在哪裡。記住,預處 理程式的條件語句(以及代碼中的常量表達式)隻在編譯時執行,要再次打開或關閉消息必須重新編譯。另一種方法就是使用C條件語句,它在運作時執行,是以可 以在程式運作期間打開或關閉消息。這是個很好的功能,但每次代碼執行時系統都要進行額外的處理,甚至在消息關閉後仍然會影響性能。有時這種性能損失是無法 接受的。

在很多情況下,本節提到的這些宏都已被證明是很有用的,僅有的缺點是每次開啟和關閉消息顯示時都要重新編譯子產品。

4.2  通過查詢調試

上一節講述了 printk 是如何工作的以及如何使用它,但還沒談到它的缺點。

由 于 syslogd 會一直保持對其輸出檔案的同步重新整理,每列印一行都會引起一次磁盤操作,是以大量使用 printk 會嚴重降低系統性能。從 syslogd 的角度來看,這樣的處理是正确的。它試圖把每件事情都記錄到磁盤上,以防系統萬一崩潰時,最後的記錄資訊能反應崩潰前的狀況;然而,因處理調試資訊而使系 統性能減慢,是大家所不希望的。這個問題可以通過在 /etc/syslogd.conf 中日志檔案的名字前面,字首一個減号符解決。*

注: 這個減号是個“特殊”标記,避免 syslogd 在每次出現新資訊時都去重新整理磁盤檔案,這些内容記述在 syslog.conf(5) 中,這個手冊頁很值得一讀。

修 改配置檔案帶來的問題在于,在完成調試之後改動将依舊保留;即使在一般的系統操作中,當希望盡快把資訊重新整理到磁盤時,也是如此。如果不願作這種持久性修改 的話,另一個選擇是運作一個非 klogd 程式(如前面介紹的cat /proc/kmesg),但這樣并不能為通常的系統操作提供一個合适的環境。

多數情況中,擷取相關資訊的最好方法是在需要的時候才去查詢系統資訊,而不是持續不斷地産生資料。實際上,每個 Unix 系統都提供了很多工具,用于擷取系統資訊,如:ps、netstat、vmstat等等。

驅動程式開發人員對系統進行查詢時,可以采用兩種主要的技術:在 /proc 檔案系統中建立檔案,或者使用驅動程式的 ioctl 方法。/proc 方式的另一個選擇是使用 devfs,不過用于資訊查找時,/proc 更為簡單一些。

4.2.1  使用 /proc 檔案系統

/proc 檔案系統是一種特殊的、由程式建立的檔案系統,核心使用它向外界輸出資訊。/proc 下面的每個檔案都綁定于一個核心函數,這個函數在檔案被讀取時,動态地生成檔案的“内容”。我們已經見到過這類檔案的一些輸出情況,例如, /proc/modules 列出的是目前載入子產品的清單。

Linux系 統對/proc的使用很頻繁。現代Linux系統中的很多工具都是通過 /proc 來擷取它們的資訊,例如 ps、top 和 uptime。有些裝置驅動程式也通過 /proc 輸出資訊,你的驅動程式當然也可以這麼做。因為 /proc 檔案系統是動态的,是以驅動程式子產品可以在任何時候添加或删除其中的檔案項。

特 征完全的 /proc 檔案項相當複雜;在所有的這些特征當中,有一點要指出的是,這些 /proc 檔案不僅可以用于讀出資料,也可以用于寫入資料。不過,大多數時候,/proc 檔案項是隻讀檔案。本節将隻涉及簡單的隻讀情形。如果有興趣實作更為複雜的事情,讀者可以先在這裡了解基礎知識,然後參考核心源碼來建立完整的認識。

所有使用 /proc 的子產品必須包含 <linux/proc_fs.h>,通過這個頭檔案定義正确的函數。

為 建立一個隻讀 /proc 檔案,驅動程式必須實作一個函數,用于在檔案讀取時生成資料。當某個程序讀這個檔案時(使用 read 系統調用),請求會通過兩個不同接口的其中之一發送到驅動程式子產品,使用哪個接口取決于注冊情況。我們先把注冊放到本節後面,先直接講述讀接口。

無論采用哪個接口,在這兩種情況下,核心都會配置設定一頁記憶體(也就是 PAGE_SIZE 個位元組),驅動程式向這片記憶體寫入将傳回給使用者空間的資料。

推薦的接口是 read_proc,不過還有一個名為 get_info 的老一點的接口。

int (*read_proc)(char *page, char **start, off_t offset, int count, int *eof, void *data);

參 數表中的 page 指針指向将寫入資料的緩沖區;start 被函數用來說明有意義的資料寫在頁面的什麼位置(對此後面還将進一步談到);offset 和 count 這兩個參數與在 read 實作中的用法相同。eof 參數指向一個整型數,當沒有資料可傳回時,驅動程式必須設定這個參數;data 參數是一個驅動程式特有的資料指針,可用于内部記錄。*

注: 縱覽全書,我們還會發現這樣的一些指針;它們表示了這類進行中有關的“對象”,與C++ 中的同類處理有些相似。

這個函數可以在2.4核心中使用,如果使用我們的 sysdep.h 頭檔案,那麼在2.2核心中也可以用這個函數。

int (*get_info)(char *page, char **start, off_t offset, int count);  

get_info 是一個用來讀取 /proc 檔案的較老接口。所有的參數與 read_proc 中的對應參數用法相同。缺少的是報告到達檔案尾的指針和由data 指針帶來的面向對象風格。這個函數可以用在所有我們感興趣的核心版本中(盡管在它 2.0 版本的實作中有一個額外未用的參數)。

這兩個函數的傳回值都是實際放入頁面緩沖區的資料的位元組數,這一點與 read 函數對其它類型檔案的處理相同。另外還有 *eof 和 *start 這兩個輸出值。eof 隻是一個簡單的标記,而 start 的用法就有點複雜了。

對于 /proc 檔案系統的使用者擴充,其最初實作中的主要問題在于,資料傳輸隻使用單個記憶體頁面。這樣就把使用者檔案的總體尺寸限制在了 4KB 以内(或者是适合于主機平台的其它值)。start 參數在這裡就是用來實作大資料檔案的,不過該參數可以被忽略。

如 果 proc_read 函數不對 *start 指針進行設定(它最初為 NULL),核心就會假定 offset 參數被忽略,并且資料頁包含了傳回給使用者空間的整個檔案。反之,如果需要通過多個片段建立一個更大的檔案,則可以把 *start 指派為頁面指針,是以調用者也就知道了新資料放在緩沖區的開始位置。當然,應該跳過前 offset 個位元組的資料,因為這些資料已經在前面的調用中傳回。

長久以來,關于 /proc 檔案還有另一個主要問題,這也是 start 意圖解決的一個問題。有時,在連續的 read 調用之間,核心資料結構的 ASCII 表述會發生變化,以至于讀程序發現前後兩次調用所獲得的資料不一緻。如果把 *start 設為一個小的整數值,調用程式可以利用它來增加 filp->f_pos 的值,而不依賴于傳回的資料量,是以也就使 f_pos 成為read_proc 或 get_info 程式中的一個内部記錄值。例如,如果 read_proc 函數從一個大的結構數組傳回資料,并且這些結構的前 5 個已經在第一次調用中傳回,那麼可将 *start 設定為 5。下次調用中這個值将被作為偏移量;驅動程式也就知道應該從數組的第六個結構開始傳回資料。這種方法被它的作者稱作“hack”,可以在 /fs/proc/generic.c 中看到。

現在我們來看個例子。下面是scull 裝置 read_proc 函數的簡單實作:

int scull_read_procmem(char *buf, char **start, off_t offset,

                 int count, int *eof, void *data)

{

  int i, j, len = 0;

  int limit = count - 80;

  for (i = 0; i < scull_nr_devs && len <= limit; i++) {

      Scull_Dev *d = &scull_devices[ i];

      if (down_interruptible(&d->sem))

              return -ERESTARTSYS;

      len += sprintf(buf+len,"/nDevice %i: qset %i, q %i, sz %li/n",

                     i, d->qset, d->quantum, d->size);

      for (; d && len <= limit; d = d->next) {

          len += sprintf(buf+len, "  item at %p, qset at %p/n", d,

                                  d->data);

          if (d->data && !d->next)

              for (j = 0; j < d->qset; j++) {

                  if (d->data[j])

                      len += sprintf(buf+len,"    % 4i: %8p/n",

                                                  j,d->data[j]);

              }

      }

      up(&scull_devices[ i].sem);

  }

  *eof = 1;

  return len;

}

這是一個相當典型的 read_proc 實作。它假定決不會有這樣的需求,即生成多于一頁的資料,是以忽略了 start 和 offset 值。但是,小心不要超出緩沖區,以防萬一。

使用 get_info 接口的 /proc 函數與上面說明的 read_proc 非常相似,除了沒有最後的那兩個參數。既然這樣,則通過傳回少于調用者預期的資料(也就是少于 count 參數),來提示已到達檔案尾。

一 旦定義好了一個 read_proc 函數,就需要把它與一個 /proc 檔案項連接配接起來。依賴于将要支援的核心版本,有兩種方法可以建立這樣的連接配接。最容易的方法是簡單地調用 create_proc_read_entry,但這隻能用于2.4核心(如果使用我們的 sysdep.h 頭檔案,則也可用于 2.2 核心)。下面就是 scull 使用的調用,以 /proc/scullmem 的形式來提供 /proc 功能。

create_proc_read_entry("scullmem",

                     0    ,

                     NULL ,

                     scull_read_procmem,

                     NULL );

這 個函數的參數表包括:/proc 檔案項的名稱、應用于該檔案項的檔案許可權限(0是個特殊值,會被轉換為一個預設的、完全可讀模式的掩碼)、檔案父目錄的 proc_dir_entry 指針(我們使用 NULL 值使該檔案項直接定位在 /proc 下)、指向 read_proc 的函數指針,以及将傳遞給 read_proc 函數的資料指針。

目錄項 指針(proc_dir_entry)可用來在 /proc 下建立完整的目錄層次結構。不過請注意,将檔案項置于 /proc 的子目錄中有更為簡單的方法,即把目錄名稱作為檔案項名稱的一部分――隻要目錄本身已經存在。例如,有個新的約定,要求裝置驅動程式對應的 /proc 檔案項應轉移到子目錄 driver/ 中;scull 可以簡單地指定它的檔案項名稱為 driver/scullmem,進而把它的 /proc 檔案放到這個子目錄中。

當然,在子產品解除安裝時,/proc 中的檔案項也應被删除。 remove_proc_entry 就是用來撤消 create_proc_read_entry 所做工作的函數。

remove_proc_entry("scullmem", NULL );

另 一個建立 /proc 檔案項的方法是,建立并初始化一個 proc_dir_entry 結構,并将該結構傳遞給函數 proc_register_dynamic (2.0 版本)或 proc_register(2.2 版本,如果結構中的索引節點号為0,該函數即認為是動态檔案)。作為一個例子,當在2.0核心的頭檔案下進行編譯時,考慮下面 scull 所使用的這些代碼:

static int scull_get_info(char *buf, char **start, off_t offset,

              int len, int unused)

{

  int eof = 0;

  return scull_read_procmem (buf, start, offset, len, &eof, NULL);

}

struct proc_dir_entry scull_proc_entry = {

      namelen:    8,

      name:       "scullmem",

      mode:       S_IFREG | S_IRUGO,

      nlink:      1,

      get_info:   scull_get_info,

};

static void scull_create_proc()

{

  proc_register_dynamic(&proc_root, &scull_proc_entry);

}

static void scull_remove_proc()

{

  proc_unregister(&proc_root, scull_proc_entry.low_ino);

}

代碼聲明了一個使用 get_info 接口的函數,并填寫了一個 proc_dir_entry 結構,用于對檔案系統進行注冊。

這 段代碼借助sysdep.h 中宏定義的支援,提供了 2.0 和 2.4 核心之間的相容性。因為 2.0 核心不支援 read_proc,它使用了 get_info 接口。如果對 #ifdef 作一些更多的處理,可以使這段代碼在 2.2 核心中使用 read_proc,不過這樣收益并不大。

4.2.2  ioctl 方法

ioctl是作用于檔案描述符之上的一個系統調用,我們會在下一章介紹它的用法;它接收一個“指令”号,用以辨別将執行的指令;以及另一個(可選的)參數,通常是個指針。

做為替代 /proc檔案系統的方法,可以為調試設計若幹ioctl指令。這些指令從驅動程式複制相關資料到使用者空間,在使用者空間中可以檢視這些資料。

使用ioctl 擷取資訊比起 /proc 來要困難一些,因為需要另一個程式調用 ioctl 并顯示結果。這個程式是必須編寫并編譯的,而且要和測試子產品配合一緻。從另一方面來說,相對實作 /proc 檔案所需的工作,驅動程式的編碼則更為容易些。

有時 ioctl 是擷取資訊的最好方法,因為它比起讀 /proc 要快得多。如果在資料寫到螢幕之前要完成某些處理工作,以二進制擷取資料要比讀取文本檔案有效得多。此外,ioctl 并不要求把資料分割成不超過一個記憶體頁面的片斷。

ioctl 方法的一個優點是,在結束調試之後,用來取得資訊的這些指令仍可以保留在驅動程式中。/proc檔案對任何檢視這個目錄的人都是可見的(很多人可能會納悶 “這些奇怪的檔案是用來做什麼的”),然而與 /proc檔案不同,未公開的 ioctl 指令通常都不會被注意到。此外,萬一驅動程式有什麼異常,這些指令仍然可以用來調試。唯一的缺點就是子產品會稍微大一些。

4.3  通過監視調試

有時,通過監視使用者空間中應用程式的運作情況,可以捕捉到一些小問題。監視程式同樣也有助于确認驅動程式工作是否正常。例如,看到 scull 的 read 實作如何響應不同資料量的 read 請求後,我們就可以判斷它是否工作正常。

有許多方法可監視使用者空間程式的工作情況。可以用調試器一步步跟蹤它的函數,插入列印語句,或者在 strace 狀态下運作程式。在檢查核心代碼時,最後一項技術最值得關注,我們将在此對它進行讨論。

strace 指令是一個功能非常強大的工具,它可以顯示程式所調用的所有系統調用。它不僅可以顯示調用,而且還能顯示調用參數,以及用符号方式表示的傳回值。當系統調 用失敗時,錯誤的符号值(如 ENOMEM)和對應的字元串(如Out of memory)都能被顯示出來。strace 有許多指令行選項;最為有用的是 -t,用來顯示調用發生的時間;-T,顯示調用所花費的時間; -e,限定被跟蹤的調用類型;-o,将輸出重定向到一個檔案中。預設情況下,strace将跟蹤資訊列印到 stderr 上。

strace從核心中接收資訊。這意味着一個程式無論是否按調試方式編譯(用 gcc 的 -g選項)或是被去掉了符号資訊都可以被跟蹤。與調試器可以連接配接到一個運作程序并控制它一樣,strace 也可以跟蹤一個正在運作的程序。

跟蹤資訊通常用于生成錯誤報告,然後發給應用開發人員,但是它對核心程式設計人員來說也同樣非常有用。我們已經看到驅動程式是如何通過響應系統調用得到執行的;strace 允許我們檢查每次調用中輸入和輸出資料的一緻性。

例如,下面的螢幕資訊顯示了 strace ls /dev > /dev/scull0 指令的最後幾行:

[...]

open("/dev", O_RDONLY|O_NONBLOCK)     = 4

fcntl(4, F_SETFD, FD_CLOEXEC)         = 0

brk(0x8055000)                        = 0x8055000

lseek(4, 0, SEEK_CUR)                 = 0

getdents(4, , 3933)   = 1260

[...]

getdents(4, , 3933)    = 0

close(4)                              = 0

fstat(1, {st_mode=S_IFCHR|0664, st_rdev=makedev(253, 0), ...}) = 0

ioctl(1, TCGETS, 0xbffffa5c)          = -1 ENOTTY (Inappropriate ioctl

                                                   for device)

write(1, "MAKEDEV/natibm/naudio/naudio1/na"..., 4096) = 4000

write(1, "d2/nsdd3/nsdd4/nsdd5/nsdd6/nsdd7"..., 96) = 96

write(1, "4/nsde5/nsde6/nsde7/nsde8/nsde9/n"..., 3325) = 3325

close(1)                              = 0

_exit(0)                              = ?

很 明顯,ls 完成對目标目錄的檢索後,在首次對 write 的調用中,它試圖寫入 4KB 資料。很奇怪(對于 ls 來說),實際隻寫了4000個位元組,接着它重試這一操作。然而,我們知道scull的 write 實作每次最多隻寫一個量子(scull 中設定的量子大小為4000個位元組),是以我們所預期的就是這樣的部分寫入。經過幾個步驟之後,每件工作都順利通過,程式正常退出。

另一個例子,讓我們來對 scull 裝置進行讀操作(使用 wc 指令):

[...]

open("/dev/scull0", O_RDONLY)           = 4

fstat(4, {st_mode=S_IFCHR|0664, st_rdev=makedev(253, 0), ...}) = 0

read(4, "MAKEDEV/natibm/naudio/naudio1/na"..., 16384) = 4000

read(4, "d2/nsdd3/nsdd4/nsdd5/nsdd6/nsdd7"..., 16384) = 3421

read(4, "", 16384)                      = 0

fstat(1, {st_mode=S_IFCHR|0600, st_rdev=makedev(3, 7), ...}) = 0

ioctl(1, TCGETS, {B38400 opost isig icanon echo ...}) = 0

write(1, "   7421 /dev/scull0/n", 20)   = 20

close(4)                                = 0

_exit(0)                                = ?

正 如所料,read 每次隻能讀取4000個位元組,但資料總量與前面例子中寫入的數量是相同的。與上面的寫跟蹤相對比,請讀者注意本例中重試工作是如何組織的。為了快速讀取數 據,wc 已被優化了,因而它繞過了标準庫,試圖通過一次系統調用讀取更多的資料。可以從跟蹤的 read 行中看到 wc 每次均試圖讀取 16KB 資料。

Linux行家可以在 strace 的輸出中發現很多有用資訊。如果覺得這些符号過于拖累的話,則可以僅限于監視檔案方法(open,read 等)是如何工作的。

就個人觀點而言,我們發現 strace 對于查找系統調用運作時的細微錯誤最為有用。通常應用或示範程式中的 perror 調用在用于調試時資訊還不夠詳細,而 strace 能夠确切查明系統調用的哪個參數引發了錯誤,這一點對調試是大有幫助的。

4.4  調試系統故障

即使采用了所有這些監視和調試技術,有時驅動程式中依然會有錯誤,這樣的驅動程式在執行時就會産生系統故障。在出現這種情況時,擷取盡可能多的資訊對解決問題是至關重要的。

注 意,“故障”不意味着“panic”。Linux 代碼非常健壯(用術語講即為魯棒,robust),可以很好地響應大部分錯誤:故障通常會導緻目前程序崩潰,而系統仍會繼續運作。如果在程序上下文之外發 生故障,或是系統的重要組成被損害時,系統才有可能 panic。但如果問題出在驅動程式中時,通常隻會導緻正在使用驅動程式的那個程序突然終止。唯一不可恢複的損失就是程序被終止時,為程序上下文配置設定的一 些記憶體可能會丢失;例如,由驅動程式通過 kmalloc 配置設定的動态連結清單可能丢失。然而,由于核心在程序中止時會對已打開的裝置調用 close 操作,驅動程式仍可以釋放由 open 方法配置設定的資源。

我們已經說過,當核心行為異常時,會在控制台上列印出提示資訊。下一節将解釋如何解碼并使用這些消息。盡管它們對于初學者來說相當晦澀,不過處理器在出錯時轉儲出的這些資料包含了許多值得關注的資訊,通常足以查明程式錯誤,而無需額外的測試。

4.4.1  oops消息

大部分錯誤都在于 NULL指針的使用或其他不正确的指針值的使用上。這些錯誤通常會導緻一個 oops 消息。

由 處理器使用的位址都是虛拟位址,而且通過一個複雜的稱為頁表(見第 13 章中的“頁表”一節)的結構映射為實體位址。當引用一個非法指針時,頁面映射機制就不能将位址映射到實體位址,此時處理器就會向作業系統發出一個“頁面失 效”的信号。如果位址非法,核心就無法“換頁”到并不存在的位址上;如果此時處理器處于超級使用者模式,系統就會産生一個“oops”。

值得注意的是,2.0 版本之後引入的第一個增強是,當向使用者空間移動資料或者移出時,無效位址錯誤會被自動處理。Linus 選擇了讓硬體來捕捉錯誤的記憶體引用,是以正常情況(位址都正确時)就可以更有效地得到處理。

oops 顯示發生錯誤時處理器的狀态,包括 CPU 寄存器的内容、頁描述符表的位置,以及其它看上去無法了解的資訊。這些消息由失效處理函數(arch

  *(int *)0 = 0;

  return 0;

}

正如讀者所見,我們這使用了一個 NULL 指針。因為 0 決不會是個合法的指針值,是以錯誤發生,核心進入上面的 oops 消息狀态。這個調用程序接着就被殺掉了。在 read 實作中,faulty 子產品還有更多有意思的錯誤狀态。

char faulty_buf[1024];

ssize_t faulty_read (struct file *filp, char *buf, size_t count,

                   loff_t *pos)

{

  int ret, ret2;

  char stack_buf[4];

  printk(KERN_DEBUG "read: buf %p, count %li/n", buf, (long)count);

  ret = copy_to_user(buf, faulty_buf, count);

  if (!ret) return count;

  printk(KERN_DEBUG "didn't fail: retry/n");

  sprintf(stack_buf, "1234567/n");

  if (count > 8) count = 8;

  ret2 = copy_to_user(buf, stack_buf, count);

  if (!ret2) return count;

  return ret2;

}

這 段程式首先從一個全局緩沖區讀取資料,但并不檢查資料的長度,然後通過對一個局部緩沖區進行寫入操作,制造一次緩沖區溢出。第一個操作僅在 2.0 核心會導緻 oops 的發生,因為後期版本能自動地處理使用者拷貝函數。緩沖區溢出則會在所有版本的核心中造成 oops;然而,由于 return 指令把指令指針帶到了不知道的地方,是以這種錯誤很難跟蹤,所能獲得的僅是如下的資訊:

EIP:    0010:[<00000000>]

[...]

Call Trace: [<c010b860>]

Code:  Bad EIP value.

用 戶處理 oops 消息的主要問題在于,我們很難從十六進制數值中看出什麼内在的意義;為了使這些資料對程式員更有意義,需要把它們解析為符号。有兩個工具可用來為開發人員 完成這樣的解析:klogd 和 ksymoops。前者隻要運作就會自行進行符号解碼;後者則需要使用者有目的地調用。下面的讨論,使用了在我們第一個 oops 例子中通過使用NULL 指針而産生的出錯資訊。

使用 klogd

klogd 守護程序能在 oops 消息到達記錄檔案之前對它們解碼。很多情況下,klogd 可以為開發者提供所有必要的資訊用于捕捉問題的所在,可是有時開發者必須給它一定的幫助。

當 faulty 的一個oops 輸出送達系統日志時,轉儲資訊看上去會是下面的情況(注意 EIP 行和 stack 跟蹤記錄中已經解碼的符号):

Unable to handle kernel NULL pointer dereference at virtual address /

   00000000

printing eip:

c48370c3

*pde = 00000000

Oops: 0002

CPU:    0

EIP:    0010:[faulty:faulty_write+3/576]

EFLAGS: 00010286

eax: ffffffea   ebx: c2c55ae0   ecx: c48370c0   edx: c2c55b00

esi: 0804d038   edi: 0804d038   ebp: c2337f8c   esp: c2337f8c

ds: 0018   es: 0018   ss: 0018

Process cat (pid: 23413, stackpage=c2337000)

Stack: 00000001 c01356e6 c2c55ae0 0804d038 00000001 c2c55b00 c2336000 /

          00000001

     0804d038 bffffbd4 00000000 00000000 bffffbd4 c010b860 00000001 /

          0804d038

     00000001 00000001 0804d038 bffffbd4 00000004 0000002b 0000002b /

          00000004

Call Trace: [sys_write+214/256] [system_call+52/56]  

Code: c7 05 00 00 00 00 00 00 00 00 31 c0 89 ec 5d c3 8d b6 00 00  

klogd 提供了大多數必要資訊用于發現問題。在這個例子中,我們看到指令指針(EIP)正執行于函數 faulty_write 中,是以我們就知道該從哪兒開始檢查。字串 3/576 告訴我們處理器正處于函數的第3個位元組上,而函數整體長度為 576 個位元組。注意這些數值都是十進制的,而非十六進制。

然而,當錯誤發生在可 裝載子產品中時,為了擷取錯誤相關的有用資訊,開發者還必須注意一些情況。klogd 在開始運作時裝入所有可用符号,并随後使用這些符号。如果在 klogd 已經對自身初始化之後(一般在系統啟動時),裝載某個子產品,那 klogd 将不會有這個子產品的符号資訊。強制 klogd取得這些資訊的辦法是,發送一個 SIGUSR1 信号給 klogd 程序,這種操作在時間順序上,必須是在子產品已經裝入(或重新裝載)之後,而在進行任何可能引起 oops 的處理之前。

還可以在運作 klogd 時加上 -p 選項,這會使它在任何發現 oops 消息的時刻重新讀入符号資訊。不過,klogd 的man 手冊不推薦這個方法,因為這使 klogd 在出問題之後再向核心查詢資訊。而發生錯誤之後,所獲得的資訊可能是完全錯誤的了。

為 了使 klogd 正确地工作,必須給它提供符号表檔案 System.map 的一個目前複本。通常這個檔案在 /boot 中;如果從一個非标準的位置編譯并安裝了一個核心,就需要把 System.map 拷貝到 /boot,或告知 klogd 到什麼位置檢視。如果符号表與目前核心不比對,klogd 就會拒絕解析符号。假如一個符号被解析在系統日志中,那麼就有理由确信它已被正确解析了。

使用 ksymoops

有 些時候,klogd 對于跟蹤目的而言仍顯不足。開發者經常既需要取得十六進制位址,又要獲得對應的符号,而且偏移量也常需要以十六進制的形式列印出來。除了位址解碼之外,往 往還需要更多的資訊。對 klogd 來說,在出錯期間被殺掉,也是常用的事情。在這些情況下,可以調用一個更為強大的 oops 分析器,ksymoops 就是這樣的一個工具。

在 2.3 開發系列之前,ksymoops 是随核心源碼一起釋出的,位于 scripts 目錄之下。它現在則在自己的FTP 站點上,對它的維護是與核心相獨立的。即使讀者所用的仍是較早期的核心,或許還可以從 ftp://ftp.ocs.com.au/pub/ksymoops 站點上擷取這個工具的更新版本。

為了取得最佳的工作狀态,除錯誤消息之外,ksymoops 還需要很多資訊;可以使用指令行選項告訴它在什麼地方能找到這些各個方面的内容。ksymoops 需要下列内容項:

System.map 檔案這個映射檔案必須與 oops 發生時正在運作的核心相一緻。預設為 /usr/src/linux/System.map。

子產品清單ksymoops 需要知道 oops 發生時都裝入了哪些子產品,以便獲得它們的符号資訊。如果未提供這個清單,ksymoops 會檢視 /proc/modules。

在 oops 發生時已定義好的核心符号表預設從 /proc/ksyms 中取得該符号表。

當 前正運作的核心映像的複本注意,ksymoops 需要的是一個直接的核心映像,而不是象 vmlinuz、zImage 或 bzImage 這樣被大多數系統所使用的壓縮版本。預設是不使用核心映像,因為大多數人都不會儲存這樣的一個核心。如果手邊就有這樣一個符合要求的核心的話,就應該采用 -v 選項告知 ksymoops 它的位置。

已裝載的任何核心子產品的目标檔案位置ksymoops 将在标準目錄路徑尋找這些子產品,不過在開發中,幾乎總要采用 -o 選項告知 ksymoops 這些子產品的存放位置。

雖 然 ksymoops 會通路 /proc 中的檔案來取得它所需的資訊,但這樣獲得的結果是不可靠的。在 oops 發生和 ksymoops 運作的時間間隙中,系統幾乎一定會重新啟動,這樣取自 /proc 的資訊就可能與故障發生時的實際狀态不符合。隻要有可能,最好在引起 oops 發生之前,儲存 /proc/modules 和 /proc/ksyms 的複本。

我們強烈建議驅動程式開發人員閱讀 ksymoops 的手冊頁,這是一個很好的資料文檔。

這 個工具指令行中的最後一個參數是 oops 消息的位置;如果缺少這個參數,ksymoops 會按Unix 的慣例去讀取标準輸入裝置。運氣好的話,消息可以從系統日志中重新恢複;在發生很嚴重的崩潰情況時,我們可能不得不将這些消息從螢幕上抄下來,然後再敲進 去(除非用的是序列槽控制台,這對核心開發人員來說,是非常棒的工具)。

注意,當 oops 消息已經被 klogd 處理過時,ksymoops 将會陷于混亂。如果 klogd 已經運作,而且 oops 發生後系統仍在運作,那麼經常可以通過調用 dmesg 指令來獲得一個幹淨的 oops 消息。

如果沒有明确地提供全部的上述資訊,ksymoops 會發出警告。對于載入子產品未作符号定義這類的情況,它同樣會發出警告。一個不作任何警告的 ksymoops 是很少見的。

ksymoops 的輸出類似如下:

>>EIP; c48370c3 <[faulty]faulty_write+3/20>   <=====

Trace; c01356e6 <sys_write+d6/100>

Trace; c010b860 <system_call+34/38>

Code;  c48370c3 <[faulty]faulty_write+3/20>

00000000 <_EIP>:

Code;  c48370c3 <[faulty]faulty_write+3/20>   <=====

 0:   c7 05 00 00 00    movl   $0x0,0x0   <=====

Code;  c48370c8 <[faulty]faulty_write+8/20>

 5:   00 00 00 00 00

Code;  c48370cd <[faulty]faulty_write+d/20>

 a:   31 c0             xorl   %eax,%eax

Code;  c48370cf <[faulty]faulty_write+f/20>

 c:   89 ec             movl   %ebp,%esp

Code;  c48370d1 <[faulty]faulty_write+11/20>

 e:   5d                popl   %ebp

Code;  c48370d2 <[faulty]faulty_write+12/20>

 f:   c3                ret    

Code;  c48370d3 <[faulty]faulty_write+13/20>

10:   8d b6 00 00 00    leal   0x0(%esi),%esi

Code;  c48370d8 <[faulty]faulty_write+18/20>

15:   00

正 如上面所看到的,ksymoops 提供的 EIP 和核心堆棧資訊與 klogd 所做的很相似,不過要更為準确,而且是十六進制形式的。可以注意到,faulty_write 函數的長度被正确地報告為 0x20個位元組。這是因為 ksymoops 讀取了子產品的目标檔案,并從中獲得了全部的有用資訊。

而且在這個例子中,還可以得到錯誤發生處代碼的彙編語言形式的轉儲輸出。這些資訊常被用于确切地判斷發生了些什麼事情;這裡很明顯,錯誤在于一個向 0 位址寫入資料 0 的指令。

ksymoops 的一個有趣特點是,它可以移植到幾乎所有 Linux 可以運作的平台上,而且還利用了 bfd (二進制格式描述)庫同時支援多種計算機結構。走出 PC 的世界,我們可以看到 SPARC64 平台上顯示的 oops 消息是何等的相似(為了便于排版有幾行被打斷了):

Unable to handle kernel NULL pointer dereference

tsk->mm->context = 0000000000000734

tsk->mm->pgd = fffff80003499000

            // ____ //

            "@'/ .. /`@"

           /_| /_ _/ |_/

              /_ _ _/

ls(16740): Oops

TSTATE: 0000004400009601 TPC: 0000000001000128 TNPC: 0000000000457fbc /

Y: 00800000

g0: 000000007002ea88 g1: 0000000000000004 g2: 0000000070029fb0 /

g3: 0000000000000018

g4: fffff80000000000 g5: 0000000000000001 g6: fffff8000119c000 /

g7: 0000000000000001

o0: 0000000000000000 o1: 000000007001a000 o2: 0000000000000178 /

o3: fffff8001224f168

o4: 0000000001000120 o5: 0000000000000000 sp: fffff8000119f621 /

ret_pc: 0000000000457fb4

l0: fffff800122376c0 l1: ffffffffffffffea l2: 000000000002c400 /

l3: 000000000002c400

l4: 0000000000000000 l5: 0000000000000000 l6: 0000000000019c00 /

l7: 0000000070028cbc

i0: fffff8001224f140 i1: 000000007001a000 i2: 0000000000000178 /

i3: 000000000002c400

i4: 000000000002c400 i5: 000000000002c000 i6: fffff8000119f6e1 /

i7: 0000000000410114

Caller[0000000000410114]

Caller[000000007007cba4]

Instruction DUMP: 01000000 90102000 81c3e008 <c0202000> /

30680005 01000000 01000000 01000000 01000000

請注意,指令轉儲并不是從引起錯誤的那個指令開始,而是之前的三條指令:這是因為 RISC 平台以并行的方式執行多條指令,這樣可能産生延期的異常,是以必須能回溯最後的幾條指令。

下面是當從 TSTATE 行開始輸入資料時,ksymoops 所列印出的資訊:

>>TPC; 0000000001000128 <[faulty].text.start+88/a0>   <=====

>>O7;  0000000000457fb4 <sys_write+114/160>

>>I7;  0000000000410114 <linux_sparc_syscall+34/40>

Trace; 0000000000410114 <linux_sparc_syscall+34/40>

Trace; 000000007007cba4 <END_OF_CODE+6f07c40d/????>

Code;  000000000100011c <[faulty].text.start+7c/a0>

0000000000000000 <_TPC>:

Code;  000000000100011c <[faulty].text.start+7c/a0>

 0:   01 00 00 00       nop

Code;  0000000001000120 <[faulty].text.start+80/a0>

 4:   90 10 20 00       clr  %o0     ! 0 <_TPC>

Code;  0000000001000124 <[faulty].text.start+84/a0>

 8:   81 c3 e0 08       retl

Code;  0000000001000128 <[faulty].text.start+88/a0>   <=====

 c:   c0 20 20 00       clr  [ %g0 ]   <=====

Code;  000000000100012c <[faulty].text.start+8c/a0>

10:   30 68 00 05       b,a   %xcc, 24 <_TPC+0x24> /

                      0000000001000140 <[faulty]faulty_write+0/20>

Code;  0000000001000130 <[faulty].text.start+90/a0>

14:   01 00 00 00       nop

Code;  0000000001000134 <[faulty].text.start+94/a0>

18:   01 00 00 00       nop

Code;  0000000001000138 <[faulty].text.start+98/a0>

1c:   01 00 00 00       nop

Code;  000000000100013c <[faulty].text.start+9c/a0>

20:   01 00 00 00       nop

要列印出上面顯示的反彙編代碼,我們就必須告知 ksymoops 目标檔案的格式和結構(之是以需要這些資訊,是因為 SPARC64 使用者空間的本地結構是32位的)。本例中,使用選項 -t elf64-sparc -a sparc:v9 可進行這樣的設定。

讀 者可能會抱怨對調用的跟蹤并沒帶回什麼值得注意的資訊;然而,SPARC 處理器并不會把所有的調用跟蹤記錄儲存到堆棧中:07 和 I7 寄存器儲存了最後調用的兩個函數的指令指針,這就是它們出現在調用跟蹤記錄邊上的原因。在這個例子中,我們可以看到,故障指令位于一個由 sys_write 調用的函數中。

要注意的是,無論平台/結構是怎樣的 一種配合情況,用來顯示反彙編代碼的格式與 objdump 程式所使用的格式是一樣的。objdump 是個很強大的工具;如果想檢視發生故障的完整函數,可以調用指令: objdump -d faulty.o(再次重申,對于 SPARC64 平台,需要使用特殊選項:--target elf64-sparc-architecture sparc:v9)。

關于 objdump 和它的指令行選項的更多資訊,可以參閱這個指令的手冊頁幫助。

學 習對 oops 消息進行解碼,需要一定的實踐經驗,并且了解所使用的目标處理器,以及彙編語言的表達習慣等。這樣的準備是值得的,因為花費在學習上的時間很快會得到回 報。即使之前讀者已經具備了非 Unix 作業系統中PC 彙編語言的專門知識,仍有必要花些時間對此進行學習,因為Unix 的文法與 Intel 的文法并不一樣。(在 as 指令 infor 頁的“i386-specific”一章中,對這種差異進行了很好的描述。)

4.4.2  系統挂起

盡 管核心代碼中的大多數錯誤僅會導緻一個oops 消息,但有時它們則會将系統完全挂起。如果系統挂起了,任何消息都無法列印。例如,如果代碼進入一個死循環,核心就會停止進行排程,系統不會再響應任何動 作,包括 Ctrl-Alt-Del 組合鍵。處理系統挂起有兩個選擇――要麼是防範于未然;要麼就是亡羊補牢,在發生挂起後調試代碼。

通 過在一些關鍵點上插入 schedule 調用可以防止死循環。schedule 函數(正如讀者猜到的)會調用排程器,并是以允許其他程序“偷取”當然程序的CPU時間。如果該程序因驅動程式的錯誤而在核心空間陷入死循環,則可以在跟 蹤到這種情況之後,借助 schedule 調用殺掉這個程序。

當然,應 該意識到任何對 schedule 的調用都可能給驅動程式帶來代碼重入的問題,因為 schedule 允許其他程序開始運作。假設驅動程式進行了合适的鎖定,這種重入通常還并不緻于帶來問題。不過,一定不要在驅動程式持有spinlock 的任何時候調用 schedule。

如果驅動程式确實會挂起系統,而又不知該在什麼位置插入 schedule 調用時,最好的方法是加入一些列印資訊,并把它們寫入控制台(通過修改 console_loglevel 的數值)。

有 時系統看起來象挂起了,但其實并沒有。例如,如果鍵盤因某種奇怪的原因被鎖住了就會發生這種情況。運作專為探明此種情況而設計的程式,通過檢視它的輸出情 況,可以發現這種假挂起。顯示器上的時鐘或系統負荷表就是很好的狀态螢幕;隻要它保持更新,就說明 scheduler 正在工作。如果沒有使用圖形顯示,則可以運作一個程式讓鍵盤LED閃爍,或不時地開關軟驅馬達,或不斷觸動揚聲器(通常蜂鳴聲是令人煩惱的,應盡量避免; 可改為尋求 ioctl 指令 KDMKTONE ),來檢查 scheduler 是否工作正常。O’Reilly FTP站點上可以找到一個例子(misc-progs/heartbeat.c),它會使鍵盤LED不斷閃爍。

如 果鍵盤不接收輸入,最佳的處理方法是從網絡登入到系統中,殺掉任何違例的程序,或是重新設定鍵盤(用 kdb_mode -a)。然而,如果沒有可用的網絡用來幫助恢複的話,即使發現了系統挂起是由鍵盤死鎖造成的也沒有用了。如果是這樣的情況,就應該配置一種可替代的輸入設 備,以便至少可以正常地重新開機系統。比起去按所謂的“大紅鈕”,在你的計算機上,通過替代的輸入裝置來關機或重新開機系統要更為容易些,而且它可以免去fsck 對磁盤的長時間掃描。

例如,這種替代輸入裝置可以是滑鼠。1.10或更新 版本的 gpm 滑鼠伺服器可以通過指令行選項支援類似的功能,不過僅限于文本模式。如果沒有網絡連接配接,并且以圖形方式運作,則建議采用某些自定義的解決方案,比如,設定 一個與序列槽線 DCD 針腳相連的開關,并編寫一個查詢 DCD 信号狀态變化的腳本,用于從外界幹預鍵盤已被死鎖的系統。

對 于上述情形,一個不可缺少的工具是“magic SysRq key”,2.2 和後期版本核心中,在其它體系結構上也可利用得到它。SysRq 魔法鍵是通過PC鍵盤上的 ALT 和 SysRq 組合鍵來激活的,在 SPARC 鍵盤上則是 ALT 和 Stop 組合鍵。連同這兩個鍵一起按下的第三個鍵,會執行許多有用動作中的其中一種,這些動作如下:

r

在無法運作 kbd_mode 的情況中,關閉鍵盤的 raw 模式。

k

激活“留意安全鍵”(SAK)功能。SAK 将殺掉目前控制台上運作的所有程序,留下一個幹淨的終端。

s

對所有磁盤進行緊急同步。

u

嘗試以隻讀模式重新挂裝所有磁盤。這個操作通常緊接着 s 動作之後被調用,它可以在系統處于嚴重故障狀态時節省很多檢查檔案系統的時間。

b

立即重新開機系統。注意先要同步并重新挂裝磁盤。

p

列印目前的寄存器資訊。

t

列印目前的任務清單。

m

列印記憶體資訊。

還 有其它的一些 SysRq 功能;要獲得完整清單,可參閱核心源碼 Documentation 目錄下的sysrq.txt 檔案。注意,SysRq 功能必須明确地在核心配置中被開啟,出于安全原因,大多數發行系統并未開啟它。不過,對于一個用于驅動程式開發的系統來說,為開啟 SysRq 功能而帶來的重新編譯新核心的麻煩是值得的。SysRq 必須在運作時通過下面的指令啟動:

echo 1 > /proc/sys/kernel/sysrq

在 複現系統的挂起故障時,另一個要采取的預防措施是,把所有的磁盤都以隻讀的方式挂裝在系統上(或幹脆就卸裝它們)。如果磁盤是隻讀的或者并未挂裝,就不會 發生破壞檔案系統或緻使檔案系統處于不一緻狀态的危險。另一個可行方法是,使用通過 NFS (網絡檔案系統)将其所有檔案系統挂裝入系統的計算機。這個方法要求核心具有“NFS-Root”的能力,而且在引導時還需傳入一些特定參數。如果采用這 種方法,即使我們不借助于 SysRq,也能避免任何檔案系統的崩潰,因為NFS 伺服器管理檔案系統的一緻性,而它并不受驅動程式的影響。

4.5  調試器和相關工具

最後一種調試子產品的方法就是使用調試器來一步步地跟蹤代碼,檢視變量和計算機寄存器的值。這種方法非常耗時,應該盡量避免。不過,某些情況下通過調試器對代碼進行細粒度的分析是很有價值的。

在核心中使用互動式調試器是一個很複雜的問題。出于對系統所有程序的整體利益考慮,核心在它自己的位址空間中運作。其結果是,許多使用者空間下的調試器所提供的常用功能很難用于核心之中,比如斷點和單步調試等。本節着眼于調試核心的幾種方法;它們中的每一種都各有利弊。

4.5.1  使用 gdb

gdb在探究系統内部行為時非常有用。在我們這個層次上,熟練使用調試器,需要掌握 gdb 指令、了解目标平台的彙編代碼,還要具備對源代碼和優化後的彙編碼進行比對的能力。

啟動調試器時必須把核心看作是一個應用程式。除了指定未壓縮的核心映像檔案名外,還應該在指令行中提供“core 檔案”的名稱。對于正運作的核心,所謂 core 檔案就是這個核心在記憶體中的核心映像,/proc/kcore。典型的 gdb 調用如下所示:

gdb /usr/src/linux/vmlinux /proc/kcore

第一個參數是未經壓縮的核心可執行檔案的名字,而不是 zImage 或 bzImage 以及其他任何壓縮過的核心。

gdb 指令行的第二個參數是是 core 檔案的名字。與其它 /proc中的檔案類似,/proc/kcore也是在被讀取時産生的。當在 /proc檔案系統中執行 read 系統調用時,它會映射到一個用于資料生成而不是資料讀取的函數上;我們已在“使用 /proc檔案系統”一節中介紹了這個特性。kcore 用來按照 core 檔案的格式表示核心“可執行檔案”;由于它要表示對應于所有實體記憶體的整個核心位址空間,是以是一個非常巨大的檔案。在 gdb 的使用中,可以通過标準gdb指令檢視核心變量。例如,p jiffies可以列印從系統啟動到目前時刻的時鐘滴答數。

從gdb 列印資料時,核心仍在運作,不同資料項的值會在不同時刻有所變化;然而,gdb為了優化對 core 檔案的通路,會将已經讀到的資料緩存起來。如果再次檢視jiffies變量,仍會得到和上次一樣的值。對通常的 core 檔案來說,對變量值進行緩存是正确的,這樣可避免額外的磁盤通路。但對“動态的”core 檔案來說就不友善了。解決方法是在需要重新整理gdb 緩沖區的時候,執行指令core-file /proc/kcore;調試器将使用新的 core 檔案并丢棄所有的舊資訊。不過,讀新資料時并不總是需要執行core-file 指令;gdb 以幾KB大小的小資料塊形式讀取 core 檔案,緩存的僅是已經引用的若幹小塊。

對核心進行調試時,gdb 通常能提供的許多功能都不可用。例如,gdb 不能修改核心資料;因為在處理其記憶體映像之前,gdb 期望把待調試程式運作在自己的控制之下。同樣,也不能設定斷點或觀察點,或者單步跟蹤核心函數。

如果用調試選項(-g)編譯了核心,産生的 vmlinux 會比沒有使用 -g選項的更适合于gdb。不過要注意,用 -g選項編譯核心需要大量的磁盤空間(每個目标檔案和核心自身都會比通常的大三倍甚至更多)。

在 非PC類計算機上,情況則不盡相同。在 Alpha 上,make boot會在生成可啟動映像前将調試資訊去掉,是以最終會獲得 vmlinux 和 vmlinux.gz 兩個檔案。gdb 可以使用前者,後者用來啟動。在SPARC上,預設情況則是不把核心(至少是2.0核心)調試資訊去掉。

當 用 -g選項編譯核心并且和 /proc/kcore一起使用 vmlinux 運作調試器時,gdb 可以傳回很多核心内部資訊。例如,可以使用下面的指令來轉儲結構資料,如p *module_list、p *module_list->next 和 p *chrdevs[4]->fops 等。為了在使用 p 指令時取得最好效果,有必要保留一份核心映射表和随手可及的源碼。

利用 gdb 可在目前核心上執行的另一個有用任務是,通過disassemble指令(可縮寫為 disass )或是“檢查指令”(x/i)指令對函數進行反彙編。disassemble 指令的參數可以是函數名或是記憶體範圍;而 x/i 則使用一個記憶體位址做為參數,也可以是符号名稱的形式。例如,可以用 x/20i 反彙編 20 條指令。注意,不能反彙編一個子產品的函數,因為調試器作用的是 vmlinux,它并不知道子產品的情況。如果試圖通過位址反彙編子產品代碼,gdb 很有可能會傳回“Cannot access memory at xxxx(不能通路 xxxx 處的記憶體)”這樣的資訊。基于同樣的原因,也不能檢視屬于子產品的資料項。如果已知道變量的位址,可以從 /dev/mem 中讀出它們的值,但要弄明白從系統記憶體中分解出的原始資料的含義,難度是相當大的。

如 果需要反彙編子產品函數,最好對子產品的目标檔案用 objdump 工具進行處理。很不幸,該工具隻能對磁盤上的檔案複本進行處理,而不能對運作中的子產品進行處理;是以,由objdump給出的位址都是未經重定位的位址, 與子產品的運作環境無關。對未經連結的目标檔案進行反彙編的另一個不利因素在于,其中的函數調用仍是未作解析的,是以就無法輕松地區分是對 printk 的調用呢,還是對 kmalloc 的調用。

正如上面看到的,當目的在于檢視核心的運作情況時,gdb是一個有用的工具,但對于裝置驅動程式的調試,它還缺少一些至關重要的功能。

4.5.2  kdb 核心調試器

很 多讀者可能會奇怪這一點,即為什麼不把一些更進階的調試功能直接編譯進核心呢。答案很簡單,因為 Linus 不信任互動式的調試器。他擔心這些調試器會導緻一些不良的修改,也就是說,修補的僅是一些表面現象,而沒有發現問題的真正原因所在。是以,沒有在核心中内 建調試器。

然而,其他的核心開發人員偶爾也會用到一些互動式的調試工具。 kdb 就是其中一種内建的核心調試器,它在 oss.sgi.com 上以非正式的更新檔形式提供。要使用 kdb,必須首先獲得這個更新檔(取得的版本一定要和核心版本相比對),然後對目前核心源碼進行 patch 操作,再重新編譯并安裝這個核心。注意,kdb 僅可用于 IA-32(x86) 系統(雖然用于 IA-64 的一個版本在主流核心源碼中短暫地出現過,但很快就被删去了)。

一旦運作的是支援 kdb 的核心,有幾個方法可以進入 kdb 的調試狀态。在控制台上按下 Pause(或 Break)鍵将啟動調試。當核心發生 oops,或到達某個斷點時,也會啟動 kdb。無論是哪一種情況,都看到下面這樣的消息:

Entering kdb (0xc1278000) on processor 1 due to Keyboard Entry [1]kdb>  

注意,當 kdb 運作時,核心所做的每一件事情都會停下來。當激活 kdb 調試時,系統不應運作其他的任何東西;尤其是,不要開啟網絡――當然,除非是在調試網絡驅動程式。一般來說,如果要使用 kdb 的話,最好在啟動時進入單使用者模式。

作為一個例子,考慮下面這個快速的 scull 調試過程。假定驅動程式已被載入,可以象下面這樣訓示 kdb 在 scull_read 函數中設定一個斷點:

[1]kdb> bp scull_read

Instruction(i) BP #0 at 0xc8833514 (scull_read)     is enabled on cpu 1

[1]kdb> go

bp 指令訓示 kdb 在核心下一次進入 scull_read 時停止運作。随後我們輸入 go 繼續執行。在把一些東西放入 scull 的某個裝置之後,我們可以在另一個終端的 shell 中運作 cat 指令嘗試讀取這個裝置,這樣一來就會産生如下的狀态:

Entering kdb (0xc3108000) on processor 0 due to Breakpoint @ 0xc8833515

Instruction(i) breakpoint #0 at 0xc8833514

scull_read+0x1:   movl   %esp,%ebp

[0]kdb>

我們現在正處于 scull_read 的開頭位置。為了查明是怎樣到達這個位置的,我們可以看看堆棧跟蹤記錄:

[0]kdb> bt

  EBP       EIP         Function(args)

0xc3109c5c 0xc8833515  scull_read+0x1

0xc3109fbc 0xfc458b10  scull_read+0x33c255fc( 0x3, 0x803ad78, 0x1000,

0x1000, 0x804ad78)

0xbffffc88 0xc010bec0  system_call

[0]kdb>

kdb 試圖列印出調用跟蹤所記錄的每個函數的參數清單。然而,它往往會被編譯器所使用的優化技巧弄糊塗。是以在這個例子中,雖然 scull_read 實際隻有四個參數,kdb 卻列印出了五個。

下面我們來看看如何查詢資料。mds 指令是用來對資料進行處理的;我們可以用下面的指令查詢 scull_devices 指針的值:

[0]kdb> mds scull_devices 1

c8836104: c4c125c0 ....

在這裡,我們請求檢視的是從 scull_devices 指針位置開始的一個字大小(4個位元組)的資料;應答告訴我們裝置資料數組的起始位址位于 c4c125c0。要檢視裝置結構自身的資料值,我們需要用到這個位址:

[0]kdb> mds c4c125c0

c4c125c0: c3785000  ....

c4c125c4: 00000000  ....

c4c125c8: 00000fa0  ....

c4c125cc: 000003e8  ....

c4c125d0: 0000009a  ....

c4c125d4: 00000000  ....

c4c125d8: 00000000  ....

c4c125dc: 00000001  ....

上 面的8行分别對應于 Scull_Dev 結構中的8個成員。是以,通過顯示的這些資料,我們可以知道,第一個裝置的記憶體是從 0xc3785000 開始配置設定的,連結清單中沒有下一個資料項,量子大小為 4000(十六進制形式為 fa0)位元組,量子集大小為 1000(十六進制形式為 3e8),這個裝置中有 154 個位元組(十六進制形式為 9a)的資料,等等。

kdb 還可以修改資料。假設我們要從裝置中削減一些資料:

[0]kdb> mm c4c125d0 0x50

0xc4c125d0 = 0x50

接下來對裝置的 cat 操作所傳回的資料就會少于上次。

kdb 還有許多其他的功能,包括單步調試(根據指令,而不是C源代碼行),在資料通路中設定斷點,反彙編代碼,跟蹤連結清單,通路寄存器資料等等。加上 kdb 更新檔之後,在核心源碼樹的 Documentation/kdb 目錄可以找到完整的手冊頁。

4.5.3  內建的核心調試器更新檔

有 很多核心開發人員為一個名為“內建的核心調試器”的非正式更新檔作出過貢獻,我們可将其簡稱為 IKD(integrated kernel debugger)。IKD 提供了很多值得關注的核心調試工具。x86 是這個更新檔的主要平台,不過它也可以用于其它的結構體系之上。IKD 更新檔可以從 ftp://ftp.kernel.org/pub/linux/kernel/people/andrea/ikd 下載下傳。它是一個必須應用于核心源碼的patch 更新檔;因為這個 patch 是與版本相關的,是以要確定下載下傳的更新檔與正使用的核心版本相一緻。

IKD 更新檔的功能之一是核心堆棧調試。如果開啟這個功能,核心就會在每個函數調用時檢查核心堆棧的空閑空間的大小,如果過小的話就會強制産生一個 oops。如果核心中的某些事情引起堆棧崩潰,這個工具就能用來幫助查找問題。這其實也就是一種“堆棧計量表”的功能,可以在任何特定的時刻檢視堆棧的填 充程度。

IKD 更新檔還包含了一些用于發現核心死鎖的工具。如果某個核心過程持續時間過久而沒有得到排程的話,“軟體死鎖”探測器就會強制産生一個 oops。這是簡單地通過對函數調用進行計數來實作的,如果計數值超過了一個預定義的門檻值,探測器就會動作,并中止一些工作。IKD 的另一個功能是可以連續地把程式計數器列印到虛拟控制台上,這可以作為跟蹤死鎖的最後手段。“信号量死鎖”探測器則是在某個程序的 down 調用持續時間過久時強制産生 oops。

IKD 中的其它調試功能包括核心的跟蹤功能,它可以記錄核心代碼的執行路徑。還有一些記憶體調試工具,包括一個記憶體洩漏探測器和一些稱為“poisoner”的工具,它們在跟蹤記憶體崩潰問題時非常有用。

最後,IKD 也包含前一節讨論過的 kdb 調試器。不過,IKD 更新檔中的 kdb 版本有些老。如果需要 kdb 的話,我們推薦直接從 oss.sgi.com 擷取目前的版本。

4.5.4  kgdb 更新檔

kgdb 是一個在Linux 核心上提供完整的 gdb 調試器功能的更新檔,不過僅限于 x86 系統。它通過序列槽連線以鈎子的形式挂入目标調試系統進行工作,而在遠端運作 gdb。使用 kgdb 時需要兩個系統――一個用于運作調試器,另一個用于運作待調試的核心。和 kdb 一樣,kgdb 目前可從 oss.sgi.com 獲得。

設 置 kgdb包括安裝核心更新檔并引導打過更新檔之後的核心兩個步驟。兩個系統之間需要通過序列槽電纜(或空數據機電纜)進行連接配接,在 gdb 這一側,需要安裝一些支援檔案。kgdb 更新檔把詳細的用法說明放在了檔案 Documentation/i386/gdb-serial.txt 中;我們在這裡就不再贅述。建議讀者閱讀關于“調試子產品”的說明:接近末尾的地方,有一些出于這個目的而編寫的很好的 gdb 宏。

4.5.5  核心崩潰轉儲分析器

崩 潰轉儲分析器使系統能把發生 oops 時的系統狀态記錄下來,以便在随後空閑的時候檢視這些資訊。如果是對于一個異地使用者的驅動程式進行支援,這些工具就會特别有用。使用者可能不太願意把 oops 複制下來,是以安裝崩潰轉儲系統可以使技術支援人員不必依賴于使用者的工作,也能獲得用于跟蹤使用者問題的必要資訊。也正是出于這樣的原因,可供利用的崩潰轉 儲分析器都是由那些對使用者系統進行商業支援的公司開發的,這也就不足為奇了。

目前有兩個崩潰轉儲分析器的更新檔可以用于 Linux。在編寫本節的時候,這兩個工具都比較新,而且都處在不斷的變化之中。與其提供可能已經過時的詳細資訊,我們倒不如隻是給出一個概觀,并指點讀者在哪裡可以找到更多的資訊。

第 一個分析器是 LKCD(Linux Kernel Crash Dumps,“Linux核心崩潰轉儲”)。這個工具仍可以從 oss.sgi.com 上獲得。當核心發生 oops 時,LKCD 會把目前系統狀态(主要指記憶體)寫入事先指定好的轉儲裝置中。這個轉儲裝置必須是一個系統交換區。下次重新開機中(在存儲交換功能開啟之前)系統會運作一個稱 為 LCRASH 的工具,來生成崩潰的概要記錄,并可選擇地把轉儲的複本儲存在一個普通檔案中。LCRASH 可以互動方式地運作,提供了很多調試器風格的指令,用以查詢系統狀态。

LKCD 目前隻支援 Intel 32位體系結構,并隻能用在 SCSI 磁盤的交換分區上。

另一個崩潰轉儲設施可以從 www.missioncriticallinux.com 獲得。這個崩潰轉儲子系統直接在目錄 /var/dumps 中建立崩潰轉儲檔案,而且并不使用交換區。這樣就使某些事情變得更為容易,但也意味着在知道問題已經出現在哪裡的時候,檔案系統已被系統修改。生成的崩潰 轉儲的格式是标準的 core 檔案格式,是以可以利用 gdb 這類工具進行事後的分析。這個工具包也提供了另外的分析器,可以從崩潰轉儲檔案中解析出比 gdb 更豐富的資訊。

4.5.6  使用者模式的 Linux 虛拟機

用 戶模式 Linux 是一個很有意思的概念。它作為一個獨立的可移植的 Linux 核心而建構,包含在子目錄 arch/um 中。然而,它并不是運作在某種新的硬體上,而是運作在基于 Linux 系統調用接口所實作的虛拟機之上。是以,使用者模式 Linux 可以使 Linux 核心成為一個運作在 Linux 系統之上單獨的、使用者模式的程序。

把 一個核心的複本當作使用者模式下的程序來運作可以帶來很多好處。因為它運作在一個受限制的虛拟處理器之上,是以有錯誤的核心不會破壞“真正的”系統。對軟/ 硬體的不同配置可以在相同的架構中輕易地進行嘗試。并且,對于核心開發人員來說最值得注目的特點在于,可以很容易地利用 gdb 或其它調試器對使用者模式 Linux 進行處理。歸根結底,它隻是一個程序。很明顯,使用者模式 Linux 有潛力加快核心的開發過程。

迄 今為止,使用者模式 Linux 虛拟機還未在主流核心中釋出;要下載下傳它,必須通路它的 web 站點(http://user-mode-linux.sourceforge.net)。需要提醒的是,它僅可以內建到 2.4.0 之後的早期 2.4 核心版本中;當然等到本書出版的時候,版本支援方面可能會做得更好。

目 前,使用者模式 Linux 虛拟機也存在一些重大的限制,不過大部分可能很快就會得到解決。虛拟處理器目前隻能工作于單處理器模式;雖然虛拟機可以毫無問題地運作在 SMP 系統上,但它仍是把主機模拟成單 CPU 模式。不過,對于驅動編寫者來說,最大的麻煩在于,使用者模式核心不能通路主機系統上的硬體裝置。是以,盡管使用者模式 Linux虛拟機對于本書中的大多數樣例驅動程式的調試非常有用,卻無法用于調試那些處理實際硬體的驅動程式。最後一點,使用者模式 Linux虛拟機僅能運作在 IA-32 體系結構之上。

因為對所有這些問題的修補工作正在進行之中,是以在不遠的将來,對于 Linux 裝置驅動程式的開發人員,使用者模式 Linux虛拟機可能會成為一個不可或缺的工具。

4.5.7  Linux 跟蹤工具包

Linux 跟蹤工具包(LTT)是一個核心更新檔,包含了一組可以用于核心事件跟蹤的相關工具集。跟蹤内容包括時間資訊,而且還能合理地建立在一段指定時間内所發生事件的完整圖形化描述。是以,LTT不僅能用于調試,還能用來捕捉性能方面的問題。

在 Web 站點 www.opersys.com/LTT 上,可以找到 LTT 以及大量的資料。

4.5.8  Dynamic Probes

Dynamic Probes (或 DProbes )是 IBM 為基于 IA-32 結構的Linux 釋出的一種調試工具(遵循 GPL 協定)。它可以在系統的幾乎任何一個地方放置一個“探針”,既可以是使用者空間也可以是核心空間。這個探針由一些當控制到達指定地點即開始執行的代碼(用一 種特别設計的,面向堆棧的語言編寫)組成。這種代碼能把資訊傳送回使用者空間,修改寄存器,或者完成許多其它的工作。DProbes 很有用的特點是,一旦核心編譯進了這個功能,探針就可以插到一個運作系統的任一個位置,而無需重建核心或重新啟動。DProbes 也可以協同 LTT 工具在任意位置插入新的跟蹤事件。

DProbes 工具可以從 IBM 的開放源碼站點,即 http://oss.software.ibm.com 上下載下傳.

繼續閱讀