攻克 Linux 內核 Oops:手把手教你從崩潰到破案!
作爲一名長期深耕 Linux 內核開發的博主,在這條探索之路上,我遭遇過無數的挑戰,而 Linux 內核 Oops 問題,絕對是其中讓人最爲頭疼的難題之一。
還記得那是一個爲某項目開發定製 Linux 內核模塊的緊張時期,我滿心期待地將新編寫的驅動程序模塊加載到內核中,本以爲一切會順利進行,結果屏幕上突然跳出一大串密密麻麻的 Oops 錯誤信息,系統也陷入了不穩定的狀態。那一刻,我的心瞬間懸了起來,望着那看似雜亂無章的錯誤提示,內心充滿了焦慮與困惑,完全不知道問題究竟出在哪裏。
這種經歷並非個例,相信許多和我一樣在 Linux 內核開發領域摸爬滾打的朋友都有過類似的痛苦遭遇。Oops 錯誤就像隱藏在暗處的幽靈,一旦出現,就會讓我們精心構建的系統陷入混亂,耗費大量的時間和精力去排查和修復。它不僅考驗着我們的技術能力,更考驗着我們的耐心和毅力。
那麼,Oops 錯誤究竟是什麼呢?簡單來說,當 Linux 內核遇到無法正常處理的嚴重錯誤,如空指針引用、非法內存訪問、內核堆棧溢出等情況時 ,就會輸出一段包含豐富信息的錯誤報告,這段報告就是 Oops 信息。Oops 堪稱是內核開發者和系統調試人員的得力助手,它詳細地記錄下錯誤發生時內核的各種狀態信息,爲我們定位和解決問題提供了關鍵線索。接下來,就讓我們一起深入探尋 Linux 內核 Oops 調試方法,揭開它神祕的面紗,希望能幫助大家在今後遇到 Oops 問題時更加從容地應對。
一、Oops 是什麼?最後
1.1 定義闡述
在 Linux 內核的世界裏,Oops 是當內核檢測到嚴重錯誤,無法繼續正常執行當前操作時,輸出的一段詳細錯誤信息。它就像是內核在遇到無法處理的狀況時,向開發者發出的緊急求救信號。從本質上講,Oops 是內核的一種自我診斷機制,通過輸出關鍵的系統狀態和錯誤相關信息,爲調試提供關鍵線索。
與用戶空間的 Segmentation Fault(段錯誤)類似,Oops 同樣源於程序對內存的非法訪問或其他嚴重錯誤。比如在用戶空間中,當一個程序試圖訪問未分配給它的內存區域,或者訪問已釋放的內存時,就會觸發 Segmentation Fault 錯誤,導致程序崩潰。而在內核中,Oops 的出現意味着內核在執行過程中遇到了類似的嚴重問題,如空指針引用、非法內存訪問、內核堆棧溢出等 。這些問題一旦發生,會使內核的正常運行受到嚴重影響,甚至導致系統死機。因此,Oops 對於內核調試至關重要,它所包含的信息是我們深入瞭解內核錯誤原因、定位問題根源的關鍵。
1.2 引發原因
(1) 非法內存訪問
這是引發 Oops 最爲常見的原因之一。當內核代碼試圖訪問未被映射到物理內存的虛擬地址,或者訪問權限不足的內存區域時,就會觸發非法內存訪問錯誤。例如,在驅動程序開發中,如果對設備內存的映射和訪問操作不當,就很容易出現這種問題。假設我們正在編寫一個硬件驅動程序,需要與特定的硬件設備進行交互。在訪問設備的寄存器時,錯誤地計算了寄存器的地址,導致訪問了一個非法的內存地址,這時就極有可能引發 Oops 錯誤。
(2) 空指針引用
當內核代碼試圖解引用一個空指針時,空指針引用錯誤便會發生。這通常是由於代碼邏輯錯誤,在使用指針之前沒有對其進行有效的初始化或檢查。比如,在一個鏈表操作的內核模塊中,當遍歷鏈表時,如果沒有正確判斷鏈表節點指針是否爲空,就嘗試訪問節點的數據成員,一旦指針爲空,就會觸發 Oops。具體來說,假設有如下鏈表節點定義和遍歷代碼:
struct list_node {
int data;
struct list_node *next;
};
void traverse_list(struct list_node *head) {
struct list_node *current = head;
while (current != NULL) {
// 錯誤示範:沒有檢查current是否爲空就訪問其成員
printk(KERN_INFO "Data: %d\n", current->data);
current = current->next;
}
}
在上述代碼中,如果 head 指針爲空,或者在遍歷過程中 current 指針意外變爲空,就會發生空指針引用,進而導致 Oops。
(3) 內核模塊錯誤
內核模塊作爲可動態加載到內核中的代碼,若其中存在編程錯誤,也常常會引發 Oops。例如,模塊在初始化或卸載過程中,如果沒有正確處理資源的分配和釋放,就可能留下隱患。曾經在開發一個網絡設備驅動模塊時,在模塊初始化函數中申請了內存資源,但在卸載函數中卻忘記釋放這些內存,當多次加載和卸載該模塊後,系統的內存管理就出現了混亂,最終引發了 Oops 錯誤 。此外,模塊之間的兼容性問題也可能導致 Oops,比如不同模塊對同一內核數據結構的訪問和修改方式不一致,就容易引發衝突。
二、調試前的關鍵準備
在調試一個 bug 之前,我們所要做的準備工作有:
-
有一個被確認的 bug。
-
包含這個 bug 的內核版本號,需要分析出這個 bug 在哪一個版本被引入,這個對於解決問題有極大的幫助。可以採用二分查找法來逐步鎖定 bug 引入版本號。
-
對內核代碼理解越深刻越好,同時還需要一點點運氣。
-
該 bug 可以復現。如果能夠找到復現規律,那麼離找到問題的原因就不遠了。
-
最小化系統。把可能產生 bug 的因素逐一排除掉。
2.1 確認並定位 bug
在着手調試之前,首先要明確存在的問題,即確認並定位 bug。確定一個被確認的 bug 是調試的基礎,只有明確了問題所在,纔能有針對性地進行後續的調試工作。同時,獲取包含這個 bug 的內核版本號也至關重要,它能幫助我們快速定位問題出現的範圍。例如,在某個項目中,我發現系統在加載特定內核模塊時出現 Oops 錯誤,通過查看系統日誌,確定了問題出現的內核版本號爲 5.10.10。
若能進一步分析出這個 bug 在哪一個版本被引入,對於解決問題更是大有裨益。這裏可以採用二分查找法來逐步鎖定 bug 引入版本號。假設我們懷疑某個問題是在 2.6.11 到 2.6.20 這一系列內核版本中引入的,我們可以先從中間版本 2.6.15 開始檢查 。如果在 2.6.15 版本中沒有發現問題,那就說明錯誤是在 2.6.15 之後的版本引入的;接下來,我們可以在 2.6.15 和 2.6.20 的中間版本(如 2.6.17)繼續檢查。
反之,如果在 2.6.15 版本中出現了問題,那就說明錯誤是在 2.6.15 之前的版本引入的,我們就需要檢查 2.6.13 版本。通過不斷重複這樣的篩選過程,最終就能將問題鎖定在兩個相繼發行的版本之間,從而更容易對引發這個 bug 的代碼變更進行定位。
2.2 環境搭建
搭建一個完備的調試環境是進行 Linux 內核 Oops 調試的基礎,它爲我們提供了必要的工具和條件,使得調試工作能夠順利進行。在這個過程中,需要安裝和配置一系列的工具,這些工具相互協作,共同助力我們解決內核 Oops 問題。
GCC(GNU Compiler Collection)作爲一款強大的編譯器,是編譯內核和內核模塊必不可少的工具。以 Ubuntu 系統爲例,在終端中輸入命令 “sudo apt-get install build-essential”,即可輕鬆完成 GCC 的安裝。這行命令會自動下載並安裝 GCC 以及相關的編譯依賴庫,確保 GCC 能夠正常工作。安裝完成後,我們可以通過 “gcc -v” 命令來查看 GCC 的版本信息,驗證是否安裝成功。
GDB(GNU Debugger)則是調試的核心工具,它允許我們在內核運行時進行單步執行、設置斷點、查看變量值等操作,幫助我們深入瞭解內核的運行狀態,從而找到問題的根源。在 Ubuntu 系統上,同樣可以使用 “sudo apt-get install gdb” 命令進行安裝。安裝完成後,在調試時,我們可以使用 “gdb vmlinux” 命令來加載內核符號表,這裏的 “vmlinux” 是內核的可執行文件,加載符號表後,GDB 就能準確地定位到內核代碼中的具體位置,爲調試提供極大的便利。
make 工具在構建內核和內核模塊時發揮着重要作用,它能夠根據 Makefile 文件中的規則,自動編譯和鏈接源代碼,生成可執行文件或模塊。安裝 make 同樣很簡單,在 Ubuntu 系統中,執行 “sudo apt-get install make” 即可。安裝完成後,我們可以通過 “make -v” 命令查看 make 的版本,確認安裝無誤。
除了上述工具,還需要安裝一些與內核調試相關的依賴包,如 libncurses5-dev、bison、flex、libssl-dev、libelf-dev 等。這些依賴包提供了內核編譯和調試所需的各種庫和工具。在 Ubuntu 系統中,可以使用 “sudo apt-get install libncurses5-dev bison flex libssl-dev libelf-dev” 命令一次性安裝多個依賴包,確保調試環境的完整性。
2.3 內核配置優化
爲了更有效地進行內核調試,對內核配置進行優化是關鍵步驟。通過 make menuconfig 命令,我們可以進入內核配置界面,這是一個基於文本的交互式界面,類似於一個菜單樹,我們可以通過上下左右鍵進行選擇和操作。
在這個界面中,開啓 Magic SysRq key 選項尤爲重要。Magic SysRq key 是一個強大的系統請求鍵,它可以在系統出現問題時,通過組合鍵的方式向內核發送特定的命令,獲取系統的關鍵信息,如內存使用情況、任務列表等,爲調試提供重要線索。例如,當系統出現死機等異常情況時,我們可以按下 Alt + SysRq + m 組合鍵,內核會將內存信息輸出到控制檯,幫助我們分析內存使用是否存在問題。
Kernel debugging 選項的開啓也不可或缺,它會在內核中添加大量的調試信息,使得我們在調試時能夠獲取更詳細的內核運行狀態信息。比如,開啓該選項後,內核在出現 Oops 錯誤時,會輸出更多關於錯誤發生時的上下文信息,包括寄存器的值、函數調用棧等,這些信息對於準確分析錯誤原因至關重要。
此外,還有一些其他的調試相關選項也可以根據具體需求開啓,如 Debug slab memory allocations 用於調試內存分配問題,Spinlock and rw-lock debugging: basic checks 用於檢查自旋鎖和讀寫鎖的基本問題等。這些選項就像是調試過程中的得力助手,能夠幫助我們從不同角度發現和解決內核中的問題。
三、內核異常詳解
3.1BUG() —開發者觸發的邏輯錯誤
BUG 是指那些不符合內核的正常設計,但內核能夠檢測出來並且對系統運行不會產生影響的問題,比如在原子上下文中休眠,在內核中用 BUG 標識。
有過驅動調試經驗的人肯定都知道這個東西,這裏的 BUG 跟我們一般認爲的 “軟件缺陷” 可不是一回事,這裏說的 BUG() 其實是 linux kernel 中用於攔截內核程序超出預期的行爲,屬於軟件主動彙報異常的一種機制。這裏有個疑問,就是什麼時候會用到呢?一般來說有兩種用到的情況:
-
一是軟件開發過程中,若發現代碼邏輯出現致命 fault 後就可以調用 BUG() 讓 kernel 死掉 (類似於 assert),這樣方便於定位問題,從而修正代碼執行邏輯;
-
另外一種情況就是,由於某種特殊原因(通常是爲了 debug 而需抓 ramdump),我們需要系統進入 kernel panic 的情況下使用;
對於 arm64 來說 BUG() 定義如下:
arch/arm64/include/asm/bug.h
#ifndef _ARCH_ARM64_ASM_BUG_H
#define _ARCH_ARM64_ASM_BUG_H
#include <linux/stringify.h>
#include <asm/asm-bug.h>
#define __BUG_FLAGS(flags) \
asm volatile (__stringify(ASM_BUG_FLAGS(flags)));
#define BUG() do { \
__BUG_FLAGS(0); \
unreachable(); \
} while (0)
#define __WARN_FLAGS(flags) __BUG_FLAGS(BUGFLAG_WARNING|(flags))
#define HAVE_ARCH_BUG
#include <asm-generic/bug.h>
#endif /* ! _ARCH_ARM64_ASM_BUG_H */
注意最後的 define HAVE_ARCH_BUG ,對於 arm64 架構來說,會通過 include asm-generict/bug.h 對 BUG() 進行重定義。
include/asm-generic/bug.h
#ifndef HAVE_ARCH_BUG
#define BUG() do { \
printk("BUG: failure at %s:%d/%s()!\n", __FILE__, __LINE__, __func__); \
barrier_before_unreachable(); \
panic("BUG!"); \
} while (0)
#endif
#ifndef HAVE_ARCH_BUG_ON
#define BUG_ON(condition) do { if (unlikely(condition)) BUG(); } while (0)
#endif
也就是在 arm64 架構中 BUG() 和 BUG_ON() 都是執行的 panic()。而對於 arm 32 位架構來說,BUG() 會向 CPU 下發一條未定義指令而觸發 ARM 發起未定義指令異常,隨後進入 kernel 異常處理流程,通過調用 die() 經歷 Oops 和 panic。
3.2OOPS —錯誤報告框架
Oops 就意外着內核出了異常,此時會將產生異常時出錯原因,CPU 的狀態,出錯的指令地址、數據地址及其他寄存器,函數調用的順序甚至是棧裏面的內容都打印出來,然後根據異常的嚴重程度來決定下一步的操作:殺死導致異常的進程或者掛起系統。
例如,在編寫驅動或內核模塊時,常常會顯示或隱式地對指針進行非法取值或使用不正確的指針,導致內核發生一個 oops 錯誤。當處理器在內核空間中訪問一個分發的指針時,因爲虛擬地址到物理地址的映射關係還沒有建立,會觸發一個缺頁中斷,在缺頁中斷中該地址是非法的,內核無法正確地爲該地址建立映射關係,所以內核觸發一個 oops 錯誤。代碼如下:
arch/arm64/mm/fault.c
static void die_kernel_fault(const char *msg, unsigned long addr,
unsigned int esr, struct pt_regs *regs)
{
bust_spinlocks(1);
pr_alert("Unable to handle kernel %s at virtual address %016lx\n", msg,
addr);
mem_abort_decode(esr);
show_pte(addr);
die("Oops", regs, esr);
bust_spinlocks(0);
do_exit(SIGKILL);
}
通過 die() 會進行 oops 異常處理,詳細的 die() 函數流程看第 3 節。當出現 oops,並且如果有源碼,可以通過 arm 的 arch64-linux-gnu-objdump 工具看到出錯的函數的彙編情況,也可以通過 GDB 工具分析。如果出錯的地方爲內核函數,可以使用 vmlinux 文件。
如果沒有源碼,對於沒有編譯符號表的二進制文件,可以使用:
arch64-linux-gnu-objdump -d oops.ko
命令來轉儲 oops.ko 文件內核也提供了一個非常好用的腳本,可以快速定位問題,該腳本位於 Linux 源碼目錄下的 scripts/decodecode 中,會把出錯的 oops 日誌信息轉換成直觀有用的彙編代碼,並且告知具體出錯的彙編語句,這對於分析沒有源碼的 oops 錯誤非常有用。
3.3die() — 硬件異常處理函數
arch/arm64/kernel/traps.c
static DEFINE_RAW_SPINLOCK(die_lock);
/*
* This function is protected against re-entrancy.
*/
void die(const char *str, struct pt_regs *regs, int err)
{
int ret;
unsigned long flags;
raw_spin_lock_irqsave(&die_lock, flags);
oops_enter();
console_verbose();
bust_spinlocks(1);
ret = __die(str, err, regs);
if (regs && kexec_should_crash(current))
crash_kexec(regs);
bust_spinlocks(0);
add_taint(TAINT_DIE, LOCKDEP_NOW_UNRELIABLE);
oops_exit();
if (in_interrupt())
panic("Fatal exception in interrupt");
if (panic_on_oops)
panic("Fatal exception");
raw_spin_unlock_irqrestore(&die_lock, flags);
if (ret != NOTIFY_STOP)
do_exit(SIGSEGV);
}
oops_enter() ---> oops_exit() 爲 Oops 的處理流程,獲取 console 的 log 級別,並通過 __die() 通過對 Oops 感興趣的模塊進行 callback,打印模塊狀態不爲 MODULE_STATE_UNFORMED 的模塊信息,打印 PC、LR、SP、x0 等寄存器信息,打印調用棧信息,等等。
(1)__die()
arch/arm64/kernel/traps.c
static int __die(const char *str, int err, struct pt_regs *regs)
{
static int die_counter;
int ret;
pr_emerg("Internal error: %s: %x [#%d]" S_PREEMPT S_SMP "\n",
str, err, ++die_counter);
/* trap and error numbers are mostly meaningless on ARM */
ret = notify_die(DIE_OOPS, str, regs, err, 0, SIGSEGV);
if (ret == NOTIFY_STOP)
return ret;
print_modules();
show_regs(regs);
dump_kernel_instr(KERN_EMERG, regs);
return ret;
}
打印 EMERG 的 log,Internal error: oops.....;
-
notify_die() 會通知所有對 Oops 感興趣的模塊並進行 callback;
-
print_modules() 打印模塊狀態不爲 MODULE_STATE_UNFORMED 的模塊信息;
-
show_regs() 打印 PC、LR、SP 等寄存器的信息,同時打印調用堆棧信息;
-
dump_kernel_instr() 打印 pc 指針和前 4 條指令;
這裏不過多的剖析,感興趣的可以查看下源碼。這裏需要注意的是 notify_die() 會通知所有的 Oops 感興趣的模塊,模塊會通過函數 register_die_notifier() 將 callback 註冊到全局結構體變量 die_chain 中 (多個模塊註冊進來形成一個鏈表),然後在通過 notify_die() 函數去解析這個 die_chain,並分別調用 callback:
kernel/notifier.c
static ATOMIC_NOTIFIER_HEAD(die_chain);
int notrace notify_die(enum die_val val, const char *str,
struct pt_regs *regs, long err, int trap, int sig)
{
struct die_args args = {
.regs = regs,
.str = str,
.err = err,
.trapnr = trap,
.signr = sig,
};
RCU_LOCKDEP_WARN(!rcu_is_watching(),
"notify_die called but RCU thinks we're quiescent");
return atomic_notifier_call_chain(&die_chain, val, &args);
}
NOKPROBE_SYMBOL(notify_die);
int register_die_notifier(struct notifier_block *nb)
{
vmalloc_sync_mappings();
return atomic_notifier_chain_register(&die_chain, nb);
}
(2)oops 同時有可能 panic
從上面 die() 函數最後看到,oops_exit() 之後也有可能進入 panic():
arch/arm64/kernel/traps.c
void die(const char *str, struct pt_regs *regs, int err)
{
...
if (in_interrupt())
panic("Fatal exception in interrupt");
if (panic_on_oops)
panic("Fatal exception");
...
}
處於中斷或 panic_on_oops 打開時進入 panic。
中斷的可能性:
-
硬件 IRQ;
-
軟件 IRQ;
-
NMI;
panic_on_oops 的值受 CONFIG_PANIC_ON_OOPS_VALUE 影響。當然該值也可以通過節點 / proc/sys/kernel/panic_on_oops 進行動態修改。
3.4panic() —系統終止函數
panic 本意是 “恐慌” 的意思,這裏意旨 kernel 發生了致命錯誤導致無法繼續運行下去的情況。根據實際情況 Oops 最終也可能會導致 panic 的發生。
kernel/panic.c
/**
* panic - halt the system
* @fmt: The text string to print
*
* Display a message, then perform cleanups.
*
* This function never returns.
*/
void panic(const char *fmt, ...)
{
static char buf[1024];
va_list args;
long i, i_next = 0, len;
int state = 0;
int old_cpu, this_cpu;
bool _crash_kexec_post_notifiers = crash_kexec_post_notifiers;
//禁止本地中斷,避免出現死鎖,因爲無法防止中斷處理程序(在獲得panic鎖後運行)再次被調用panic
local_irq_disable();
//禁止任務搶佔
preempt_disable_notrace();
//通過this_cpu確認是否調用panic() 的cpu是否爲panic_cpu;
//即,只允許一個CPU執行該代碼,通過 panic_smp_self_stop() 保證當一個CPU執行panic時,
//其他CPU處於停止或等待狀態;
this_cpu = raw_smp_processor_id();
old_cpu = atomic_cmpxchg(&panic_cpu, PANIC_CPU_INVALID, this_cpu);
if (old_cpu != PANIC_CPU_INVALID && old_cpu != this_cpu)
panic_smp_self_stop();
//把console的打印級別放開
console_verbose();
bust_spinlocks(1);
va_start(args, fmt);
len = vscnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
if (len && buf[len - 1] == '\n')
buf[len - 1] = '\0';
//解析panic所攜帶的message,前綴爲Kernel panic - not syncing
pr_emerg("Kernel panic - not syncing: %s\n", buf);
#ifdef CONFIG_DEBUG_BUGVERBOSE
/*
* Avoid nested stack-dumping if a panic occurs during oops processing
*/
if (!test_taint(TAINT_DIE) && oops_in_progress <= 1)
dump_stack();
#endif
//如果kgdb使能,即CONFIG_KGDB爲y,在停掉所有其他CPU之前,跳轉kgdb斷點運行
kgdb_panic(buf);
if (!_crash_kexec_post_notifiers) {
printk_safe_flush_on_panic();
//會根據當前是否設置了轉儲內核(使能CONFIG_KEXEC_CORE)確定是否實際執行轉儲操作;
//如果執行轉儲則會通過 kexec 將系統切換到新的kdump 內核,並且不會再返回;
//如果不執行轉儲,則繼續後面流程;
__crash_kexec(NULL);
//停掉其他CPU,只留下當前CPU幹活
smp_send_stop();
} else {
/*
* If we want to do crash dump after notifier calls and
* kmsg_dump, we will need architecture dependent extra
* works in addition to stopping other CPUs.
*/
crash_smp_send_stop();
}
//通知所有對panic感興趣的模塊進行回調,添加一些kmsg信息到輸出
atomic_notifier_call_chain(&panic_notifier_list, 0, buf);
/* Call flush even twice. It tries harder with a single online CPU */
printk_safe_flush_on_panic();
//dump 內核log buffer中的log信息
kmsg_dump(KMSG_DUMP_PANIC);
/*
* If you doubt kdump always works fine in any situation,
* "crash_kexec_post_notifiers" offers you a chance to run
* panic_notifiers and dumping kmsg before kdump.
* Note: since some panic_notifiers can make crashed kernel
* more unstable, it can increase risks of the kdump failure too.
*
* Bypass the panic_cpu check and call __crash_kexec directly.
*/
if (_crash_kexec_post_notifiers)
__crash_kexec(NULL);
#ifdef CONFIG_VT
unblank_screen();
#endif
console_unblank();
//關掉所有debug鎖
debug_locks_off();
console_flush_on_panic(CONSOLE_FLUSH_PENDING);
panic_print_sys_info();
if (!panic_blink)
panic_blink = no_blink;
//如果sysctl配置了panic_timeout > 0則在panic_timeout後重啓系統
//首先,這裏會每隔100ms重啓 NMI watchdog
if (panic_timeout > 0) {
/*
* Delay timeout seconds before rebooting the machine.
* We can't use the "normal" timers since we just panicked.
*/
pr_emerg("Rebooting in %d seconds..\n", panic_timeout);
for (i = 0; i < panic_timeout * 1000; i += PANIC_TIMER_STEP) {
touch_nmi_watchdog();
if (i >= i_next) {
i += panic_blink(state ^= 1);
i_next = i + 3600 / PANIC_BLINK_SPD;
}
mdelay(PANIC_TIMER_STEP);
}
}
//其次,這裏確定reboot_mode,並重啓系統
if (panic_timeout != 0) {
/*
* This will not be a clean reboot, with everything
* shutting down. But if there is a chance of
* rebooting the system it will be rebooted.
*/
if (panic_reboot_mode != REBOOT_UNDEFINED)
reboot_mode = panic_reboot_mode;
emergency_restart();
}
#ifdef __sparc__
{
extern int stop_a_enabled;
/* Make sure the user can actually press Stop-A (L1-A) */
stop_a_enabled = 1;
pr_emerg("Press Stop-A (L1-A) from sun keyboard or send break\n"
"twice on console to return to the boot prom\n");
}
#endif
#if defined(CONFIG_S390)
disabled_wait();
#endif
pr_emerg("---[ end Kernel panic - not syncing: %s ]---\n", buf);
/* Do not scroll important messages printed above */
suppress_printk = 1;
local_irq_enable();
for (i = 0; ; i += PANIC_TIMER_STEP) {
touch_softlockup_watchdog();
if (i >= i_next) {
i += panic_blink(state ^= 1);
i_next = i + 3600 / PANIC_BLINK_SPD;
}
mdelay(PANIC_TIMER_STEP);
}
}
EXPORT_SYMBOL(panic);
詳細信息見代碼註釋。panic_timeout 是根據節點 /proc/sys/kernel/panic 值配置,用以指定在重啓系統之前需要 wait 的時長。
(1)panic_print_sys_info()
kernel/panic.c
#define PANIC_PRINT_TASK_INFO 0x00000001
#define PANIC_PRINT_MEM_INFO 0x00000002
#define PANIC_PRINT_TIMER_INFO 0x00000004
#define PANIC_PRINT_LOCK_INFO 0x00000008
#define PANIC_PRINT_FTRACE_INFO 0x00000010
#define PANIC_PRINT_ALL_PRINTK_MSG 0x00000020
static void panic_print_sys_info(void)
{
if (panic_print & PANIC_PRINT_ALL_PRINTK_MSG)
console_flush_on_panic(CONSOLE_REPLAY_ALL);
if (panic_print & PANIC_PRINT_TASK_INFO)
show_state();
if (panic_print & PANIC_PRINT_MEM_INFO)
show_mem(0, NULL);
if (panic_print & PANIC_PRINT_TIMER_INFO)
sysrq_timer_list_show();
if (panic_print & PANIC_PRINT_LOCK_INFO)
debug_show_all_locks();
if (panic_print & PANIC_PRINT_FTRACE_INFO)
ftrace_dump(DUMP_ALL);
}
panic_print 默認值爲 0,可以通過 /proc/sys/kernel/panic_print 節點配置,當 panic 發生的時候,用戶可以通過如下 bit 位配置打印系統信息:
-
bit 0:打印所有的進程信息;
-
bit 1:打印系統內存信息;
-
bit 2:打印定時器信息;
-
bit 3:打印當 CONFIG_LOCKEDP 打開時的鎖信息;
-
bit 4:打印所有 ftrace;
-
bit 5:打印串口所有信息;
四、內核調試配置選項
學習編寫驅動程序要構建安裝自己的內核(標準主線內核)。最重要的原因之一是:內核開發者已經建立了多項用於調試的功能。但是由於這些功能會造成額外的輸出,並導致能下降,因此發行版廠商通常會禁止發行版內核中的調試功能。
4.1 內核配置
爲了實現內核調試,在內核配置上增加了幾項:
Kernel hacking --->
啓用選項例如:
slab layer debugging(slab層調試選項)
4.2 調試原子操作
從內核 2.5 開發,爲了檢查各類由原子操作引發的問題,內核提供了極佳的工具。內核提供了一個原子操作計數器,它可以配置成,一旦在原子操作過程中,進城進入睡眠或者做了一些可能引起睡眠的操作,就打印警告信息並提供追蹤線索。所以,包括在使用鎖的時候調用 schedule (),正使用鎖的時候以阻塞方式請求分配內存等,各種潛在的 bug 都能夠被探測到。
下面這些選項可以最大限度地利用該特性:
CONFIG_PREEMPT = y
五、核心調試方法
當 Linux 內核出現 Oops 錯誤時,掌握有效的調試方法至關重要。接下來,我們將詳細介紹幾種核心調試方法,這些方法在定位和解決 Oops 問題時非常實用。
5.1 printk 函數運用
printk 堪稱 Linux 內核中的 “萬能調試助手”,它擁有強大的健壯性。無論在內核的中斷上下文還是進程上下文,printk 都能穩定地發揮作用。這意味着,當內核在處理緊急的中斷事件,或者在正常的進程執行流程中出現問題時,我們都可以藉助 printk 輸出關鍵的調試信息。它還可以在任何持有鎖時被調用,並且能夠在多處理器環境下同時被調用,無需額外的鎖機制來保證線程安全 。不過,在系統功能啓動的初期,終端還未完成初始化時,printk 存在一定的侷限性,此時它無法正常工作。
printk () 內核提供的格式化打印函數;健壯性是 printk 最容易被接受的一個特質,幾乎在任何地方,任何時候內核都可以調用它(中斷上下文、進程上下文、持有鎖時、多處理器處理時等)。
printk 支持 8 種不同的日誌級別,從高到低依次爲:
-
KERN_EMERG(0),表示系統不可用,是最爲緊急的情況,比如系統硬件出現嚴重故障,導致系統無法繼續運行;
-
KERN_ALERT(1),意味着必須立即採取行動,通常用於報告那些可能導致系統崩潰或嚴重影響系統運行的問題;
-
KERN_CRIT(2),代表嚴重情況,如硬盤故障、內存不足等;
-
KERN_ERR(3),表示錯誤情況,用於輸出一般性的錯誤信息,幫助開發者定位代碼中的錯誤;
-
KERN_WARNING(4),即警告情況,提示一些可能會引發問題的潛在風險,但系統仍可繼續運行;
-
KERN_NOTICE(5),表示正常但重要的情況,用於記錄一些需要關注的系統狀態變化;
-
KERN_INFO(6),提供一般信息,如系統啓動過程中的一些關鍵步驟、設備驅動的加載信息等;
-
KERN_DEBUG(7),用於調試信息,在開發和調試階段,通過輸出大量詳細的調試信息,幫助開發者深入瞭解內核的運行狀態 。這些日誌級別可以通過修改 /proc/sys/kernel/printk 文件來調整輸出級別。例如,當我們將該文件中的第一個數字設置爲 7 時,意味着只有日誌級別小於等於 7(即 KERN_DEBUG 及以上級別)的信息纔會被輸出,這樣可以在調試時獲取更詳細的信息。而在正式發佈的系統中,通常會將該值設置爲較低的數字,如 4,以減少不必要的日誌輸出,提高系統性能。
在系統啓動過程中,終端初始化之前,在某些地方是不能調用的。如果真的需要調試系統啓動過程最開始的地方,有以下方法可以使用:
-
使用串口調試,將調試信息輸出到其他終端設備。
-
使用 early_printk (),該函數在系統啓動初期就有打印能力。但它只支持部分硬件體系。
printk 和 printf 一個主要的區別就是前者可以指定一個 LOG 等級。內核根據這個等級來判斷是否在終端上打印消息。內核把比指定等級高的所有消息顯示在終端。
可以使用下面的方式指定一個 LOG 級別:printk(KERN_CRIT “Hello, world!\n”); 注意,第一個參數並不一個真正的參數,因爲其中沒有用於分隔級別(KERN_CRIT)和格式字符的逗號(,)。KERN_CRIT 本身只是一個普通的字符串(事實上,它表示的是字符串 "<2>";表 1 列出了完整的日誌級別清單)。
作爲預處理程序的一部分,C 會自動地使用一個名爲 字符串串聯 的功能將這兩個字符串組合在一起。組合的結果是將日誌級別和用戶指定的格式字符串包含在一個字符串中。
內核使用這個指定 LOG 級別與當前終端 LOG 等級 console_loglevel 來決定是不是向終端打印。下面是可使用的 LOG 等級:
#define KERN_EMERG "<0>" /* system is unusable */
#define KERN_ALERT "<1>" /* action must be taken immediately */
#define KERN_CRIT "<2>" /* critical conditions */
#define KERN_ERR "<3>" /* error conditions */
#define KERN_WARNING "<4>" /* warning conditions */
#define KERN_NOTICE "<5>" /* normal but significant condition */
#define KERN_INFO "<6>" /* informational */
#define KERN_DEBUG "<7>" /* debug-level messages */
#define KERN_DEFAULT "<d>" /* Use the default kernel loglevel */
注意,如果調用者未將日誌級別提供給 printk,那麼系統就會使用默認值 KERN_WARNING "<4>"(表示只有 KERN_WARNING 級別以上的日誌消息會被記錄)。由於默認值存在變化,所以在使用時最好指定 LOG 級別。有 LOG 級別的一個好處就是我們可以選擇性的輸出 LOG。
比如平時我們只需要打印 KERN_WARNING 級別以上的關鍵性 LOG,但是調試的時候,我們可以選擇打印 KERN_DEBUG 等以上的詳細 LOG。而這些都不需要我們修改代碼,只需要通過命令修改默認日誌輸出級別:
mtj@ubuntu :~$ cat /proc/sys/kernel/printk
4 4 1 7
mtj@ubuntu :~$ cat /proc/sys/kernel/printk_delay
0
mtj@ubuntu :~$ cat /proc/sys/kernel/printk_ratelimit
5
mtj@ubuntu :~$ cat /proc/sys/kernel/printk_ratelimit_burst
10
第一項定義了 printk API 當前使用的日誌級別。這些日誌級別表示了控制檯的日誌級別、默認消息日誌級別、最小控制檯日誌級別和默認控制檯日誌級別。printk_delay 值表示的是 printk 消息之間的延遲毫秒數(用於提高某些場景的可讀性)。
注意,這裏它的值爲 0,而它是不可以通過 /proc 設置的。printk_ratelimit 定義了消息之間允許的最小時間間隔(當前定義爲每 5 秒內的某個內核消息數)。消息數量是由 printk_ratelimit_burst 定義的(當前定義爲 10)。
如果您擁有一個非正式內核而又使用有帶寬限制的控制檯設備(如通過串口), 那麼這非常有用。注意,在內核中,速度限制是由調用者控制的,而不是在 printk 中實現的。
如果一個 printk 用戶要求進行速度限制,那麼該用戶就需要調用 printk_ratelimit 函數。
內核消息都被保存在一個 LOG_BUF_LEN 大小的環形隊列中。關於 LOG_BUF_LEN 定義:
#define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT)
※ 變量 CONFIG_LOG_BUF_SHIFT 在內核編譯時由配置文件定義,對於 i386 平臺,其值定義如下(在 linux26/arch/i386/defconfig 中):
CONFIG_LOG_BUF_SHIFT=18
記錄緩衝區操作:① 消息被讀出到用戶空間時,此消息就會從環形隊列中刪除。② 當消息緩衝區滿時,如果再有 printk () 調用時,新消息將覆蓋隊列中的老消息。③ 在讀寫環形隊列時,同步問題很容易得到解決。
※ 這個紀錄緩衝區之所以稱爲環形,是因爲它的讀寫都是按照環形隊列的方式進行操作的。
在標準的 Linux 系統上,用戶空間的守護進程 klogd 從紀錄緩衝區中獲取內核消息,再通過 syslogd 守護進程把這些消息保存在系統日誌文件中。klogd 進程既可以從 /proc/kmsg 文件中,也可以通過 syslog () 系統調用讀取這些消息。默認情況下,它選擇讀取 /proc 方式實現。klogd 守護進程在消息緩衝區有新的消息之前,一直處於阻塞狀態。
一旦有新的內核消息,klogd 被喚醒,讀出內核消息並進行處理。默認情況下,處理例程就是把內核消息傳給 syslogd 守護進程。syslogd 守護進程一般把接收到的消息寫入 /var/log/messages 文件中。不過,還是可以通過 /etc/syslog.conf 文件來進行配置,可以選擇其他的輸出文件。
dmesg 命令也可用於打印和控制內核環緩衝區。這個命令使用 klogctl 系統調用來讀取內核環緩衝區,並將它轉發到標準輸出(stdout)。這個命令也可以用來清除內核環緩衝區(使用 -c 選項),設置控制檯日誌級別(-n 選項),以及定義用於讀取內核日誌消息的緩衝區大小(-s 選項)。注意,如果沒有指定緩衝區大小,那麼 dmesg 會使用 klogctl 的 SYSLOG_ACTION_SIZE_BUFFER 操作確定緩衝區大小。
-
a) 雖然 printk 很健壯,但是看了源碼你就知道,這個函數的效率很低:做字符拷貝時一次只拷貝一個字節,且去調用 console 輸出可能還產生中斷。所以如果你的驅動在功能調試完成以後做性能測試或者發佈的時候千萬記得儘量減少 printk 輸出,做到僅在出錯時輸出少量信息。否則往 console 輸出無用信息影響性能。
-
b) printk 的臨時緩存 printk_buf 只有 1K,所有一次 printk 函數只能記錄 <1K 的信息到 log buffer,並且 printk 使用的 “ringbuffer”.
內核 printk 和日誌系統的總體結構:
動態調試:
動態調試是通過動態的開啓和禁止某些內核代碼來獲取額外的內核信息。首先內核選項 CONFIG_DYNAMIC_DEBUG 應該被設置。所有通過 pr_debug ()/dev_debug () 打印的信息都可以動態的顯示或不顯示。可以通過簡單的查詢語句來篩選需要顯示的信息。
-
源文件名
-
函數名
-
行號(包括指定範圍的行號)
-
模塊名
-
格式化字符串
將要打印信息的格式寫入 /dynamic_debug/control 中。
nullarbor:~ # echo 'file svcsock.c line 1603 +p' >
在調試過程中,合理地在關鍵代碼處插入 printk 輸出調試信息是非常有效的方法。比如,在一個網絡設備驅動程序中,當我們懷疑數據包的接收處理過程存在問題時,可以在接收函數的關鍵步驟處插入 printk 語句,輸出數據包的相關信息,如數據包的長度、源地址、目的地址等。假設我們有如下代碼:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/skbuff.h>
static int __init net_driver_init(void) {
// 初始化相關變量和設備
return 0;
}
static void __exit net_driver_exit(void) {
// 釋放資源
}
module_init(net_driver_init);
module_exit(net_driver_exit);
MODULE_LICENSE("GPL");
// 假設這是數據包接收函數
void net_rx_handler(struct sk_buff *skb) {
printk(KERN_INFO "Received packet, length: %u\n", skb->len);
// 進一步處理數據包
}
在上述代碼中,通過在 net_rx_handler 函數中插入 printk 語句,我們可以清晰地看到接收到的數據包的長度信息,這對於判斷數據包是否正常接收以及後續的處理邏輯是否正確提供了重要依據。
5.2 BUG 與 BUG_ON 宏
①BUG () 和 BUG_ON ()
一些內核調用可以用來方便標記 bug,提供斷言並輸出信息。最常用的兩個是 BUG () 和 BUG_ON ()。
定義在中:
#ifndef HAVE_ARCH_BUG
當調用這兩個宏的時候,它們會引發 OOPS,導致棧的回溯和錯誤消息的打印。※ 可以把這兩個調用當作斷言使用,如:BUG_ON (bad_thing);
②dump_stack()
有些時候,只需要在終端上打印一下棧的回溯信息來幫助你調試。這時可以使用 dump_stack ()。這個函數只在終端上打印寄存器上下文和函數的跟蹤線索。
if (!debug_check) {
printk(KERN_DEBUG “provide some information…/n”);
dump_stack();
}
(1) 功能作用
在 Linux 內核開發中,BUG 和 BUG_ON 宏就像是隱藏在代碼中的 “問題探測器”。當調用這兩個宏時,會立刻引發 Oops 錯誤。它們的主要作用是標記代碼中那些不應該出現的情況,一旦這些宏被觸發,就表明代碼中存在潛在的嚴重問題。比如,在一段代碼中,我們期望某個指針永遠不會爲空,那麼就可以使用 BUG_ON(ptr == NULL) 來進行斷言,如果在運行過程中 ptr 真的爲空,就會觸發 Oops,從而讓開發者能夠及時發現這個潛在的錯誤。
(2) 使用場景
在開發過程中,當我們懷疑代碼邏輯存在致命錯誤,或者某些條件在正常情況下絕對不應該成立時,就可以巧妙地使用 BUG 和 BUG_ON 宏。例如,在一個內存管理模塊中,假設我們有一個函數用於分配內存,並且在函數內部做了一些假設,如分配的內存大小必須大於 0。此時,我們可以在函數開頭使用 BUG_ON(size <= 0) 來檢查傳入的內存大小參數。
如果在實際運行中,由於某些原因導致 size 小於等於 0,就會觸發 Oops,這樣我們就能迅速定位到這個錯誤的源頭,避免在後續的代碼執行中出現更嚴重的問題。再比如,在一個多線程同步的場景中,我們使用信號量來控制對共享資源的訪問。假設某個線程在獲取信號量之前,不應該直接訪問共享資源,那麼可以在訪問共享資源的代碼處使用 BUG_ON(sem_count < 1) 來確保信號量的狀態是正確的,如果違反了這個假設,就會觸發 Oops,幫助我們發現潛在的同步問題。
5.3 dump_stack 函數
當內核出現 Oops 錯誤時,dump_stack 函數就如同一位 “線索偵探”,發揮着關鍵作用。它能夠打印出寄存器上下文和函數跟蹤線索,爲我們提供了深入瞭解內核運行狀態的關鍵信息。
寄存器上下文包含了內核在錯誤發生時各個寄存器的值,這些值反映了當時內核的執行環境,如程序計數器(PC)指示了當前正在執行的指令地址,棧指針(SP)指向了當前的棧頂位置等。通過分析這些寄存器的值,我們可以大致瞭解內核在出錯時的執行流程和狀態。
函數跟蹤線索則展示了函數的調用關係,它從當前出錯的函數開始,逐步回溯到調用它的上層函數,形成一條完整的函數調用鏈。例如,假設我們有一個內核模塊,其中包含多個函數之間的嵌套調用。當在某個函數中出現 Oops 錯誤時,調用 dump_stack 函數後,我們可能會得到如下的函數跟蹤線索:function_c -> function_b -> function_a,這清晰地表明瞭 function_c 是在 function_b 中被調用,而 function_b 又是在 function_a 中被調用的,從而幫助我們梳理出代碼的執行路徑,快速定位到問題可能出現的函數範圍 。通過這些線索,我們能夠更準確地分析錯誤發生的原因,爲解決 Oops 問題提供有力的支持。
5.4 GDB 調試工具
(1) 工作環境配置
使用 GDB 調試 Linux 內核 Oops 問題,首先需要進行一系列的環境配置。確保系統中已經安裝了 GDB,可以通過包管理器進行安裝,如在 Ubuntu 系統中,使用 “sudo apt - get install gdb” 命令即可完成安裝。準備好編譯好的內核源碼,這是進行調試的基礎,只有擁有完整的內核源碼,GDB 才能準確地定位到代碼中的具體位置。還需要準備帶有調試信息的內核鏡像,通常在編譯內核時,通過配置編譯選項,如添加 “-g” 選項,來生成包含調試信息的內核鏡像。例如,在編譯內核時,修改 Makefile 文件,在 CFLAGS 變量中添加 “-g”,然後重新編譯內核,這樣生成的內核鏡像就包含了豐富的調試信息,能夠被 GDB 識別和利用。
(2) 基本調試流程
下面結合一個實際的 Oops 案例來演示 GDB 的基本調試流程。假設我們的內核在運行某個驅動程序時出現了 Oops 錯誤,首先,使用 “gdb vmlinux” 命令啓動 GDB,並加載內核符號表,這裏的 “vmlinux” 是編譯生成的內核文件。接着,通過 “file vmlinux” 命令再次確認加載的內核文件。然後,使用 “target remote /dev/ttyS0” 命令連接到目標機的串口,這裏假設我們通過串口進行調試。連接成功後,使用 “load” 命令加載帶有調試信息的內核鏡像。接下來,就可以設置斷點來暫停內核的執行,以便進行調試。
比如,我們懷疑問題出在驅動程序的某個函數中,就可以使用 “b function_name” 命令在該函數處設置斷點,其中 “function_name” 是我們要設置斷點的函數名。設置好斷點後,使用 “c” 命令繼續執行內核,當執行到斷點處時,內核會暫停運行。此時,我們可以使用 “info registers” 命令查看當前寄存器的值,使用 “backtrace” 命令查看函數調用棧,還可以使用 “print variable_name” 命令查看變量的值,通過這些操作來分析內核的運行狀態,找出問題所在。
例如,在調試一個網絡驅動程序時,我們發現系統在接收數據包時出現 Oops 錯誤。通過上述步驟,我們在驅動程序的接收函數處設置斷點,當執行到斷點時,查看寄存器的值發現某個與數據包處理相關的寄存器值異常,進一步查看函數調用棧和相關變量的值,最終發現是由於在數據包校驗過程中,一個校驗和計算錯誤導致了 Oops,通過這樣的調試流程,我們成功定位並解決了問題。
5.5 objdump 工具
objdump 是一個功能強大的反彙編工具,在調試 Linux 內核 Oops 問題時,它能幫助我們深入分析內核模塊或相關二進制文件的彙編代碼。通過使用 “objdump -d” 命令,我們可以對內核模塊或二進制文件進行反彙編操作。例如,對於一個名爲 “module.ko” 的內核模塊,我們可以在終端中輸入 “objdump -d module.ko” 命令,此時,objdump 會將該模塊的二進制代碼轉換爲彙編代碼,並輸出到終端。
在分析出錯地址的彙編代碼時,我們首先需要從 Oops 信息中獲取出錯的地址。然後,在 objdump 輸出的彙編代碼中,找到與該地址對應的彙編指令。通過仔細分析這些彙編指令,我們可以瞭解內核在出錯時的具體操作,判斷是否存在指令錯誤、內存訪問異常等問題。比如,在一個 Oops 案例中,Oops 信息顯示出錯地址爲 “0x12345678”,我們使用 objdump 對相關的內核模塊進行反彙編後,在輸出的彙編代碼中找到該地址對應的指令是 “mov [eax], ebx”,通過進一步分析發現,此時 “eax” 寄存器的值是一個非法的內存地址,從而找到了導致 Oops 的原因是非法內存訪問。objdump 工具爲我們從底層彙編代碼的角度分析 Oops 問題提供了有力的支持,幫助我們更深入地理解內核錯誤的根源。
5.6 decodecode 腳本
在 Linux 源碼目錄下,有一個名爲 scripts/decodecode 的腳本,它就像是一把 “解碼鑰匙”,專門用於將 oops 日誌信息轉換爲直觀的彙編代碼。這個腳本的作用不可小覷,當我們面對複雜的 oops 日誌信息時,往往很難直接從中分析出問題的關鍵所在。而 decodecode 腳本能夠將這些晦澀難懂的 oops 日誌信息進行轉換,以彙編代碼的形式呈現出來,讓我們能夠更直觀地瞭解內核在出錯時的執行情況。
使用 decodecode 腳本的方法相對簡單,我們只需在終端中切換到 Linux 源碼目錄,然後執行 “./scripts/decodecode oops_log_file” 命令,其中 “oops_log_file” 是包含 oops 日誌信息的文件。腳本執行後,會輸出轉換後的彙編代碼,我們可以根據這些彙編代碼來分析出錯的原因。例如,在一個內核調試過程中,我們獲取到了一份 oops 日誌文件,通過執行 decodecode 腳本,將日誌信息轉換爲彙編代碼後,發現其中一段彙編代碼在進行內存操作時,使用了錯誤的寄存器索引,導致了內存訪問錯誤,從而引發了 Oops。通過 decodecode 腳本,我們能夠快速定位到問題的關鍵,提高了調試的效率和準確性 。
六、內存調試工具
6.1MEMWATCH
MEMWATCH 由 Johan Lindh 編寫,是一個開放源代碼 C 語言內存錯誤檢測工具,您可以自己下載它。只要在代碼中添加一個頭文件並在 gcc 語句中定義了 MEMWATCH 之後,您就可以跟蹤程序中的內存泄漏和錯誤了。MEMWATCH 支持 ANSIC,它提供結果日誌紀錄,能檢測雙重釋放(double-free)、錯誤釋放(erroneous free)、沒有釋放的內存(unfreedmemory)、溢出和下溢等等。
清單 1. 內存樣本(test1.c)
#include <stdlib.h>
#include <stdio.h>
#include "memwatch.h"
int main(void)
{
char *ptr1;
char *ptr2;
ptr1 = malloc(512);
ptr2 = malloc(512);
ptr2 = ptr1;
free(ptr2);
free(ptr1);
}
清單 1 中的代碼將分配兩個 512 字節的內存塊,然後指向第一個內存塊的指針被設定爲指向第二個內存塊。結果,第二個內存塊的地址丟失,從而產生了內存泄漏。現在我們編譯清單 1 的 memwatch.c。下面是一個 makefile 示例:test1
gcc -DMEMWATCH -DMW_STDIO test1.c memwatch
c -o test1
當您運行 test1 程序後,它會生成一個關於泄漏的內存的報告。清單 2 展示了示例 memwatch.log 輸出文件。
清單 2. test1 memwatch.log 文件
MEMWATCH 2.67 Copyright (C) 1992-1999 Johan Lindh
...
double-free: <4> test1.c(15), 0x80517b4 was freed from test1.c(14)
...
unfreed: <2> test1.c(11), 512 bytes at 0x80519e4
{FE FE FE FE FE FE FE FE FE FE FE FE ..............}
Memory usage statistics (global):
N)umber of allocations made: 2
L)argest memory usage : 1024
T)otal of all alloc() calls: 1024
U)nfreed bytes totals : 512
MEMWATCH 爲您顯示真正導致問題的行。如果您釋放一個已經釋放過的指針,它會告訴您。對於沒有釋放的內存也一樣。日誌結尾部分顯示統計信息,包括泄漏了多少內存,使用了多少內存,以及總共分配了多少內存。
6.2 YAMD
YAMD 軟件包由 Nate Eldredge 編寫,可以查找 C 和 C++ 中動態的、與內存分配有關的問題。在撰寫本文時,YAMD 的最新版本爲 0.32。請下載 yamd-0.32.tar.gz。執行 make 命令來構建程序;然後執行 make install 命令安裝程序並設置工具。一旦您下載了 YAMD 之後,請在 test1.c 上使用它。請刪除 #include memwatch.h 並對 makefile 進行如下小小的修改:使用 YAMD 的 test1
gcc -g test1.c -o test1
清單 3 展示了來自 test1 上的 YAMD 的輸出。
清單 3. 使用 YAMD 的 test1 輸出
YAMD version 0.32
Executable: /usr/src/test/yamd-0.32/test1
...
INFO: Normal allocation of this block
Address 0x40025e00, size 512
...
INFO: Normal allocation of this block
Address 0x40028e00, size 512
...
INFO: Normal deallocation of this block
Address 0x40025e00, size 512
...
ERROR: Multiple freeing At
free of pointer already freed
Address 0x40025e00, size 512
...
WARNING: Memory leak
Address 0x40028e00, size 512
WARNING: Total memory leaks:
1 unfreed allocations totaling 512 bytes
*** Finished at Tue ... 10:07:15 2002
Allocated a grand total of 1024 bytes 2 allocations
Average of 512 bytes per allocation
Max bytes allocated at one time: 1024
24 K alloced internally / 12 K mapped now / 8 K max
Virtual program size is 1416 K
End.
YAMD 顯示我們已經釋放了內存,而且存在內存泄漏。讓我們在清單 4 中另一個樣本程序上試試 YAMD。
清單 4. 內存代碼(test2.c)
#include <stdlib.h>
#include <stdio.h>
int main(void)
{
char *ptr1;
char *ptr2;
char *chptr;
int i = 1;
ptr1 = malloc(512);
ptr2 = malloc(512);
chptr = (char *)malloc(512);
for (i; i <= 512; i++) {
chptr[i] = 'S';
}
ptr2 = ptr1;
free(ptr2);
free(ptr1);
free(chptr);
}
您可以使用下面的命令來啓動 YAMD:
./run-yamd /usr/src/test/test2/test2
清單 5 顯示了在樣本程序 test2 上使用 YAMD 得到的輸出。YAMD 告訴我們在 for 循環中有 “越界(out-of-bounds)” 的情況。
清單 5. 使用 YAMD 的 test2 輸出
Running /usr/src/test/test2/test2
Temp output to /tmp/yamd-out.1243
*********
./run-yamd: line 101: 1248 Segmentation fault (core dumped)
YAMD version 0.32
Starting run: /usr/src/test/test2/test2
Executable: /usr/src/test/test2/test2
Virtual program size is 1380 K
...
INFO: Normal allocation of this block
Address 0x40025e00, size 512
...
INFO: Normal allocation of this block
Address 0x40028e00, size 512
...
INFO: Normal allocation of this block
Address 0x4002be00, size 512
ERROR: Crash
...
Tried to write address 0x4002c000
Seems to be part of this block:
Address 0x4002be00, size 512
...
Address in question is at offset 512 (out of bounds)
Will dump core after checking heap.
Done.
MEMWATCH 和 YAMD 都是很有用的調試工具,它們的使用方法有所不同。對於 MEMWATCH,您需要添加包含文件 memwatch.h 並打開兩個編譯時間標記。對於鏈接(link)語句,YAMD 只需要 -g 選項。
6.3 Electric Fence
多數 Linux 分發版包含一個 Electric Fence 包,不過您也可以選擇下載它。Electric Fence 是一個由 Bruce Perens 編寫的 malloc () 調試庫。它就在您分配內存後分配受保護的內存。如果存在 fencepost 錯誤(超過數組末尾運行),程序就會產生保護錯誤,並立即結束。通過結合 Electric Fence 和 gdb,您可以精確地跟蹤到哪一行試圖訪問受保護內存。ElectricFence 的另一個功能就是能夠檢測內存泄漏。
6.4 strace
strace 命令是一種強大的工具,它能夠顯示所有由用戶空間程序發出的系統調用。strace 顯示這些調用的參數並返回符號形式的值。strace 從內核接收信息,而且不需要以任何特殊的方式來構建內核。
將跟蹤信息發送到應用程序及內核開發者都很有用。在清單 6 中,分區的一種格式有錯誤,清單顯示了 strace 的開頭部分,內容是關於調出創建文件系統操作(mkfs )的。strace 確定哪個調用導致問題出現。清單 6. mkfs 上 strace 的開頭部分
execve("/sbin/mkfs.jfs", ["mkfs.jfs", "-f", "/dev/test1"], &
...
open("/dev/test1", O_RDWR|O_LARGEFILE) = 4
stat64("/dev/test1", {st_mode=&, st_rdev=makedev(63, 255), ...}) = 0
ioctl(4, 0x40041271, 0xbfffe128) = -1 EINVAL (Invalid argument)
write(2, "mkfs.jfs: warning - cannot setb" ..., 98mkfs.jfs: warning -
cannot set blocksize on block device /dev/test1: Invalid argument )
= 98
stat64("/dev/test1", {st_mode=&, st_rdev=makedev(63, 255), ...}) = 0
open("/dev/test1", O_RDONLY|O_LARGEFILE) = 5
ioctl(5, 0x80041272, 0xbfffe124) = -1 EINVAL (Invalid argument)
write(2, "mkfs.jfs: can\'t determine device"..., ..._exit(1)
= ?
清單 6 顯示 ioctl 調用導致用來格式化分區的 mkfs 程序失敗。ioctl BLKGETSIZE64 失敗。( BLKGET-SIZE64 在調用 ioctl 的源代碼中定義。) BLKGETSIZE64 ioctl 將被添加到 Linux 中所有的設備,而在這裏,邏輯卷管理器還不支持它。因此,如果 BLKGETSIZE64 ioctl 調用失敗,mkfs 代碼將改爲調用較早的 ioctl 調用;這使得 mkfs 適用於邏輯卷管理器。
七、Linux 內核 Oops 錯誤案例分析
7.1 案例引入
下面我們來看一個實際的 Linux 內核 Oops 錯誤案例,假設我們在開發一個自定義的內核模塊時,遇到了如下的 Oops 錯誤信息:
[ 10.234567] Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000
[ 10.234572] Mem abort info:
[ 10.234574] ESR = 0x96000045
[ 10.234577] EC = 0x25: DABT (current EL), IL = 32 bits
[ 10.234580] SET = 0, FnV = 0
[ 10.234582] EA = 0, S1PTW = 0
[ 10.234584] Data abort info:
[ 10.234586] ISV = 0, ISS = 0x00000045
[ 10.234588] CM = 0, WnR = 1
[ 10.234590] user pgtable: 4k pages, 39-bit VAs, pgdp=0000000108782000
[ 10.234594] [0000000000000000] pgd=0000000000000000, p4d=0000000000000000, pud=0000000000000000
[ 10.234603] Internal error: Oops: 96000045 [#1] PREEMPT SMP
[ 10.234608] Modules linked in: custom_module(O+)
[ 10.234616] CPU: 0 PID: 1234 Comm: some_process Tainted: G O 5.15.0 #1
[ 10.234621] Hardware name: Some_Hardware_Model (DT)
[ 10.234623] pstate: 60400009 (nZCv daif +PAN -UAO -TCO BTYPE=--)
[ 10.234628] pc : custom_function+0x28/0x1000 [custom_module]
[ 10.234638] lr : custom_function+0x24/0x1000 [custom_module]
[ 10.234644] sp : ffffffc01391bb20
[ 10.234647] x29: ffffffc01391bb20 x28: ffffff811e6db3b8
[ 10.234652] x27: 0000000000000003 x26: 0000000000000000
[ 10.234658] x25: 0000000000000019 x24: 0000000000000000
[ 10.234662] x23: 0000000000000000 x22: ffffffc011fa28c0
[ 10.234667] x21: ffffffc011fa4380 x20: ffffffc009035000
[ 10.234672] x19: ffffffc011fa2900 x18: 0000000000000000
[ 10.234677] x17: 0000000000000000 x16: 0000000000000000
[ 10.234682] x15: 180f0a0700000000 x14: 00656c75646f6d5f
[ 10.234688] x13: 0000000000000000 x12: 0000000000000018
[ 10.234692] x11: 0101010101010101 x10: ffffffff7f7f7f7f
[ 10.234697] x9 : ffffffc0100a07d0 x8 : 74696e6920656c75
[ 10.234702] x7 : 646f6d2073706f6f x6 : ffffffc012055ae9
[ 10.234707] x5 : ffffffc012055ae8 x4 : ffffff81feeb1b70
[ 10.234712] x3 : 0000000000000000 x2 : 0000000000000000
[ 10.234717] x1 : ffffff8119b2eac0 x0 : 0000000000000000
[ 10.234722] Call trace:
[ 10.234725] custom_function+0x28/0x1000 [custom_module]
[ 10.234732] another_function+0xb4/0x210
[ 10.234739] yet_another_function+0x68/0x210
[ 10.234747] some_kernel_function+0x1cb4/0x2258
[ 10.234752] __do_sys_some_syscall+0xe0/0x100
[ 10.234758] __arm64_sys_some_syscall+0x28/0x34
[ 10.234763] el0_svc_common.constprop.0+0x154/0x204
[ 10.234769] do_el0_svc+0x8c/0x98
[ 10.234774] el0_svc+0x20/0x30
[ 10.234780] el0_sync_handler+0xd8/0x184
[ 10.234785] el0_sync+0x1a0/0x1c0
[ 10.234790]
[ 10.234790] PC: 0xffffffc009034f28:....
[ 10.239344] Code: 910003fd 91000000 95fee5c3 d2800000 (b900001f)
[ 10.239349] ---[ end trace 0000000000000002 ]---
7.2 分析過程
(1) 信息提取
-
出錯地址:從 “Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000” 可以看出,這是一個空指針解引用錯誤,出錯的虛擬地址爲 0x0000000000000000。
-
寄存器值:通過 “pc : custom_function+0x28/0x1000 [custom_module]” 可知程序計數器(PC)指向 custom_function 函數內偏移 0x28 的位置;“lr : custom_function+0x24/0x1000 [custom_module]” 表明鏈接寄存器(LR)指向 custom_function 函數內偏移 0x24 的位置;還有其他衆多寄存器的值,如 “sp : ffffffc01391bb20” 表示棧指針(SP)的值 ,這些寄存器值反映了出錯時內核的運行狀態。
-
調用棧:從 “Call trace:” 後面的信息可以看到函數的調用關係,從 custom_function 開始,依次經過 another_function、yet_another_function 等函數,這些調用關係展示了程序執行到出錯點的路徑,對於分析錯誤原因非常關鍵。
(2) 工具運用
①首先,根據出錯地址和函數名,我們可以使用 GDB 進行調試。假設我們已經準備好編譯好的內核源碼和帶有調試信息的內核鏡像,啓動 GDB 並加載內核符號表:
gdb vmlinux
file vmlinux
②然後,通過 Oops 信息中 PC 指向的函數和偏移,在 GDB 中設置斷點:
b custom_function+0x28
③接着,使用 “info registers” 命令查看當前寄存器的值,與 Oops 信息中的寄存器值進行對比分析,進一步確認出錯時的狀態。
④利用 “backtrace” 命令查看函數調用棧,與 Oops 信息中的調用棧進行覈對,檢查是否存在異常的函數調用。我們還可以使用 objdump 工具對 custom_module 模塊進行反彙編分析。假設 custom_module 模塊的文件名爲 “custom_module.ko”,執行如下命令:
objdump -d custom_module.ko
④通過反彙編代碼,找到 PC 指向的偏移 0x28 處的彙編指令,分析該指令的操作,判斷是否存在指令錯誤或內存訪問異常等問題。例如,如果該指令是對某個指針進行解引用操作,而該指針爲空,就會導致空指針解引用錯誤,與 Oops 信息中的錯誤類型相符合。
7.3 解決辦法
經過上述分析,我們發現問題出在 custom_function 函數中對一個指針的使用上。假設該函數的代碼如下:
#include <linux/module.h>
#include <linux/kernel.h>
static void custom_function(void) {
int *ptr = NULL;
// 錯誤操作:沒有對ptr進行初始化就解引用
*ptr = 10;
}
static int __init custom_module_init(void) {
printk(KERN_INFO "Custom module initialized\n");
custom_function();
return 0;
}
static void __exit custom_module_exit(void) {
printk(KERN_INFO "Custom module exited\n");
}
module_init(custom_module_init);
module_exit(custom_module_exit);
MODULE_LICENSE("GPL");
從代碼中可以明顯看出,ptr 指針被初始化爲 NULL,然後在沒有進行任何初始化的情況下就被解引用,這正是導致空指針解引用錯誤的原因。
解決辦法很簡單,就是在使用指針之前對其進行正確的初始化。修改後的代碼如下:
#include <linux/module.h>
#include <linux/kernel.h>
static void custom_function(void) {
int value = 10;
int *ptr = &value;
*ptr = 10;
}
static int __init custom_module_init(void) {
printk(KERN_INFO "Custom module initialized\n");
custom_function();
return 0;
}
static void __exit custom_module_exit(void) {
printk(KERN_INFO "Custom module exited\n");
}
module_init(custom_module_init);
module_exit(custom_module_exit);
MODULE_LICENSE("GPL");
在修改後的代碼中,我們先定義了一個變量 value,然後將 ptr 指針指向 value,這樣就確保了 ptr 在被解引用時指向的是一個有效的內存地址,從而避免了空指針解引用錯誤。重新編譯內核模塊並加載到系統中,Oops 錯誤應該就不會再出現了。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/AW1yu1BUYKoBdo5KkRg4ew