攻克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 在哪一個版本被引入,這個對于解決問題有極大的幫助??梢圆捎枚植檎曳▉碇鸩芥i定 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 字節的內存塊,然后指向第一個內存塊的指針被設定為指向第二個內存塊。結果,第二個內存塊的地址丟失,從而產生了內存泄漏?,F在我們編譯清單 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 錯誤應該就不會再出現了。