核心開發人員一直在試圖尋找一種快捷高效的核心調試手段,用於核心開發之中。高效的調試技術有利於提高核心開發效率,縮短核心開發週期。 本文研究了一種新型的核心調試技術― Kprobes , Kprobes 是一個輕量級的核心調試工具,利用 Kprobes 技術可以在啟動並執行核心中動態插入探測點,在探測點處執行使用者預定義的操作。本文首先根據 Kprobes 在 Linux 核心中的源碼實現,針對 Linux CPU異常技術,single-step技術,Loadable Kernel Module技術以及RCU同步技術在 Kprobes 中的應用進行了研究。其次,針對 Kprobes 目前所支援的kprobe,jprobe,kretprobe等三種調試手段的實現進行了詳細的分析研究。
一、Kprobes調試技術
Kprobes調試技術概述
一直以來,核心開發人員一直在試圖尋找一種快捷高效的核心調試手段,用於核心開發之中。從 2.6版本的Linux開始,一種新的核心調試技術出現了,這就是Kprobes技術。
Kprobes最早是源於IBM的Dprobe項目發展起來的,Dprobe是一個IBM公司開發的核心調試工具。從2.6.9 Linux核心開始,Kprobes被加入核心源碼,並處於不斷完善之中,越來越多的功能被添加到 Kprobes 核心調試技術中來。Kprobes 目前已經能在 i386,x86_64, ppc64, ia64,sparc64等CPU平台上正常工作。
目前,大多發行版本都包含一個核心調試工具 SystemTap。SystemTap可以通過編寫指令碼調試核心,該工具正是依託 Kprobes 來實現的。Kprobes 是一個輕量級的核心調試工具,也就是說,Kprobes的運行基本不會影響到正常核心執行的流程。
利用Kprobes技術可以在啟動並執行核心中動態插入探測點,當核心運行到該探測點後可以執行使用者預定義的回呼函數。當執行完使用者函數後又會回到正常的核心執行流程,開始新一輪的調試工作。
Kprobes支援三種探測方式:第一種是最基本的探測方式稱為 kprobe,該探測方式支援在核心的任意位置放置探測點(除了與 Kprobes實 現相關的代碼)。第二種調試方式稱之為 jprobe,該探測方式主要用於調試函數傳入的參數。第三種調試方式稱之為 kretprobe,該調試方式是在函數返回時執行使用者回呼函數,利用該方式可以調試核心功能傳回值。以上討論的後兩種調試方式都是基於第一種 kprobe調試方式實現的。Kprobes還支援三種回呼函數類型:第一種叫做 pre_handler,該回呼函數用於在執行被探測指令前執行。第二種叫 post_handler,該回呼函數用於在執行完被探測指令後執行。第三種叫fault_handler,此函數用於在出現記憶體訪問錯誤時進行處理。
目前Kprobes核心調試技術已經被很多核心開發人員所採用,並使用在核心開發過程的各個階段。
Kprobes的開發還在不斷的進行之中,目前的SystemTap社區負責對該調試技術的維護以及新功能的開發,主要由IBM,Intel,Redhat等公司維護。
Kprobes配置說明
使用 Kprobes 進行核心調試前,先要對被調試核心進行相關配置。以 Linux 2.6.20.3 版本Linux核心為例:
首先,需要把 Kprobes 相關代碼編譯進核心,進入核心目錄運行 make menuconfig 命令,在Instrumentation Support項目中選擇Kprobes。
其次,選擇 Configure standard kernel features 中的 Include all symbols in kallsyms項,該項用於啟用kallsyms_lookup_name()函數,這個函數用於檢索核心功能的地址。
在最新版本的Kprobes實現中已經支援直接利用函數名來進行註冊。
第三,選擇Loadable module support中的Enable Loadable module support項,該項用於啟用核心的可插入模組功能。因為Kprobes的調試是通過模組插入實現的,調試者需要編寫調試模組並插入核心方式進行核心調試,因此必須選擇該選項。
Kprobes中的關鍵技術
Kprobes不只是純軟體的實現方案,該技術與具體硬體緊密相連,用到了一些硬體的特性。因此Kprobes的實現架構分為兩個部分實現:第一部分是Kprobes的 管理,這部分是與體繫結構無關的代碼。第二部分是與具體CPU體繫結構相關的實現代碼,比如準備逐步執行環境等。這些代碼與具體 CPU體繫結構緊密相連,因此在不同 CPU 上各有不同的實現方式。本文以下所有的討論都是基於 Intel IA32 CPU架構,2.6.20.3核心。下文討論用於支援Kprobes實現的四種關鍵技術。
Linux CPU異常處理技術以及在Kprobes中的應用
CPU異常是在CPU運行期間,由於外圍硬體發出中斷訊號或是執行CPU異常指令等情況所引發的。
CPU異常可分為硬體異常和軟體異常。
硬體異常也稱為硬體中斷,一般是有外圍硬體裝置發出中斷訊號引起。當外圍裝置發出一個中斷訊號,該訊號被發往中斷處理器仲裁,比較有名的是 8259中斷處理晶片,目前Intel的CPU一般採用APIC(Advanced Programmable Interrupt Controllers)來實現。中斷處理器仲裁後發往 CPU的中斷引腳,這時CPU會開始一次中斷處理流程。
軟體異常不是由外圍裝置發出的,而是由程式員寫入程式裡的一些 CPU 異常指令引起的,比如int,int3這類指令就會引起一次軟體異常。在早期的 CPU上Linux系統調用的實現就是利用 int指令。當CPU執行到這些異常指令時,同樣會開始一次異常處理流程。
作業系統CPU異常處理的實現是和特定CPU體繫結構緊密相關的。Intel IA32體系的CPU的每個中斷或是異常都有一個向量號,這些向量號從 0到255。Linux核心中,每一個中斷向量號都對應中斷描述符表(IDT)中的一項,因此中斷描述符表一共有 256項。IDT每一項包含8個位元組,這8個位元組的其中一部分是中斷處理函數的地址。表項的其他欄位的含義這裡不再介紹,可以從 Intel IA32程式員手冊中查到。Linux核心在初始化階段用set_trap_gate()宏初始中斷描述符表。
Linux核心中,異常處理是通過兩級跳轉實現的。比如,如果當CPU運行過程中接收到一次異常訊號,此時CPU會到根據中斷向量號到中斷描述符表中找到對應項,再跳轉到表項中所指的地址去執行。
這裡跳轉到的地址一般情況下對應源檔案 linux/arch/i386/kernel/entry.S 中的某個彙編函數入口。彙編函數經過一定的初始化工作後再跳轉到C函數中去執行。這裡的C函數才是真正的異常處理函數,當從C函數返回到原來的彙編函數 後,彙編函數還會做一部分後續工作。最後彙編函數執行 iret指令從中斷上下文中返回到被中斷的代碼中繼續執行。這是 Linux核心處理異常一般過程,因此稱Linux的異常處理過程是兩次跳轉實現的。
在Kprobes的實現中同樣也用到了CPU異常,當插入一個探測點的時候,Kprobes處理常式會把插入點處的指令儲存起來,然後用int3指令代替。當CPU運行到插入的int3指令時,Linux核心就進入了異常處理流程,之後再運行調試者預定義的回呼函數。利用 CPU異常來實現探測點的觸發並處理是Kprobes實現的關鍵。
single-step技術及在Kprobes中應用
調試器的單獨執行功能是非常有用的,程式員可以通過逐步執行代碼來確定程式執行的流程,隨時掌握變數變化情況,從而精確定位程式錯誤發生的位置。可以說逐步執行功能是一個調試器必須具備的功能。single-step技術就是為了調試器的逐步執行而設計的。
single-step技術的主要思想是,當程式執行到某條想要單獨執行 CPU指令時,在執行之前產生一次CPU異常,此時把異常返回時的CPU的EFLAGS寄存器的TF(調試位)位置為1,把IF(中斷屏蔽位)標誌位置為 0,然後把EIP指向逐步執行的指令。當單步指令執行完成後,CPU會自動產生一次調試異常(由於TF被置位)。此時,調試器一般都會把控制權又交回調試 器,回到互動模式。
在Kprobes實現中同樣也用到了single-step技術,但目的不是為了回到互動模式,而是把控制權交回Kprobes控制流程程。當Kprobes完成pre_handler()處理後,就會利用single-step技術執行被調試指令。此時,Kprobes會利用debug異常,執行post_handler()。這是single-step
技術在Kprobes的主要應用。
oadable kernel module技術及在Kprobes中的應用
loadable kernel module(LKM)技術是Linux核心的又一強大特性。
在LKM技術出現以前,添加核心代碼只能通過修改核心源碼,然後重新編譯核心再重啟後才會生效。
當LKM技術出現後,核心編程變的簡單許多,核心開發人員只需要把代碼寫成核心模組形式,再插入核心就可以作為核心的一部分運行。確切來講,LKM技術為內 核開發人員提供了在核心運行過程中動態插入和卸載核心模組的功能。LKM技術在不用重新編譯核心的情況下,可以擴充核心的功能。目前,大量的裝置驅動程式利 用LKM技術實現。驅動開發人員把驅動程式寫成模組的形式,並在核心啟動時自動載入入核心,當然這也可以通過手動載入方式實現。
LKM技術的出現帶來了兩個好處:第一,LKM技術使得驅動程式的開發和調試變得異常簡單,很大程度上提高了驅動開發的效率。第二,LKM技術的出現使得 核心鏡像不至於過於龐大,因為大部分的核心代碼可以寫成模組形式,使用時才載入。這樣不會使得核心在系統記憶體中佔用太多空間而影響系統效能。
在利用Kprobes進行核心調試時同樣用到了LKM技術。調試者需要把調試代碼寫成模組形式並插入核心。當調試模組被插入核心後就進入調試階段,而當調試模組從核心中卸載時,也就意味著調試過程的結束。可以說LKM技術是Kprobes技術進行核心調試基礎。
RCU技術及在Kprobes中的應用
Linux內 核有時會訪問到一些全域的資料結構,如果此時核心被搶佔並且資料被修改,又或者在SMP系統(即多處理器系統)上運行,可能會有多個 CPU同時訪問同一塊記憶體的情況,此時核心資料就可能產生不一致性。為了避免這種情況,核心使用了一些同步機制,比如利用訊號量同步,利用自旋鎖同步等方 法。在2.6版本的核心中又引入了一種新的核心同步機制RCU,這是Read Copy Update的縮寫。RCU的主要思想是分為兩個部分,第一部分是防止其他訪問者對被保護對象寫入,第二部分是真正展開寫入行為。讀者可以隨時訪問被 RCU保護的對象而不用獲得任何鎖。寫者先對對象的副本修改,在所有讀者都退出時再執行寫入行為,不同的寫者之間需要同步。RCU同步機制適用於存在大量 讀操作而很少寫操作的情況。因為這種情況下,讀操作不用獲得任何鎖就可以對共用對象進行讀操作,極大提高了效率。
在Kprobes對探測點資料結構的操作中也是大量存在讀操作而只有很少部分寫操作。為了提高效率,Kprobes的 實現中也引入了RCU機制。當發生探測點註冊或是登出時,或是在執行探測點的回呼函數時,探測點資料結構struct kprobe就會被RCU機制保護起來。根據RCU的原理,寫者的操作是被延遲的,而如果讀者發生了阻塞,那寫者的操作直到讀者被喚醒後才進行,這會大大 降低 RCU的效率。因此在執行Kprobes回呼函數時核心的搶佔以及CPU中斷都被禁用了。對於Kprobes來說,用RCU機制實現回呼函數訪問也極大的提高了SMP系統上多探測點探測的效率,因為可以並行的執行回呼函數。但在這樣的機制下,回呼函數必須被設計成可重新進入的函數。
Kprobes調試技術體繫結構分析
根據Kprobes的源碼實現,從邏輯上基本可以把 Kprobes分為3個部分:第一部分是註冊探測點部分,第二部分是調試處理部分,第三部分是登出探測點部分。第一部分主要功能是進行一些安全性檢查,並根據要求安裝探測點等工作。第二部分是 Kprobes實現的關鍵,該部分根據探測類型執行預定的操作。這裡的探測類型主要有三種:kprobe,jprobe和kretprobe。這部分還會完成被探測指令逐步執行等操作。第三部分是當調試者撤銷探測要求時,對探測點的登出操作,主要是恢複被探測指令等工作。
下面分別介紹kprobe,jprobe和kretprobe的實現機制。
3.1 kprobe的實現
3.1.1 相關資料結構與函數分析
1) struct kprobe結構
該資料結構是整個 Kprobes 體系的基礎,所有 Kprobes 的行為都是圍繞該結構展開。以下是
struct kprobe結構的主要成員:
2) struct notifier_block 結構
該結構用於註冊異常發生時調用的回呼函數。 int (*notifier_call)(struct notifier_block *, unsigned long, void *)成員是回呼函數指標,int priority成員用於設定調用優先順序。Kprobes中定義的優先順序為最高優先順序,確保註冊的回呼函數被首先調用到。
3) register_kprobe()函數
該函數用於完成struct kprobe結構的註冊,插入探測點等操作。
4) kprobe_handler()函數
該函數用於處理由kprobe引發的int3異常。
5) post_kprobe_handler ()函數
該函數用於處理由kprobe引發的debug異常。
kprobe處理流程分析
kprobe是Kprobes實現體系中最底層也是最基本的一種探測方式,jprobe和kretprobe探測方式都是通過kprobe來實現的。kprobe的主要處理流程如下圖所示:
1)kprobe的註冊過程
當調試者向核心插入一個 kprobe 模組時,首先會執行註冊探測點操作。該操作主要由register_kprobe()函數完成,在下文中都稱該函數為註冊器。註冊器的參數中包含一個 struct kprobe結構,該結構由調試者在調試模組中建立。
首先,註冊器會進行一些正確性檢查工作,判斷傳入的 struct kprobe結構中symbol_name和addr 是否同時存在,如果是則返回錯誤。之後還會判斷探測地址是否在核心程式碼片段中並且不在Kprobes實現相關的代碼中。這樣的檢查是很必要的,如果探測地址出現在Kprobes實現相關代碼中就會造成遞迴現象。如果被探測的地址已經被註冊過,則會在kprobe_table中以鏈表形式組織。
其次,註冊器會儲存被探測地址的指令碼到struct kprobe結構的ainsn.ainsn中去,以便以後進行single-step操作。對kprobe初始化完成後,註冊器會把傳入的struct kprobe結構指標插入雜湊表中。最後,註冊器把被探測的指令的第一個位元組替換成 int3指令。到這裡,kprobe的註冊工作就完成了。
2)kprobe int3異常處理過程
完成註冊後kprobe的準備工作就完成了,一旦核心執行到被探測的指令,也就是註冊時被替換成的int3指令時,就會引發一次軟體異常。CPU會根據中斷描述符表執行中斷處理函數,int3的中斷處理函數在/linux/arch/i386/kernel/entry.S中實現,KPROBE_ENTRY(int3)就是該中斷處理函數的入口。彙編中斷處理函數會調用 do_int3()函數,作為 int3 中斷處理的 C 語言處理函數。
do_int3()函數一開始就會去調用notify_die()函數,該函數的主要作用是調用核心代碼註冊的異常的 回 調 函 數 。 在 Kprobes 的初始化代碼(init_Kprobes()函數)中調用了register_die_notifier() 用於註冊異常回呼函數 。 Kprobes註冊的異常回呼函數為probe_exceptions_notify()。
此時執行權交到Kprobes之 中,kprobe_exception_notify()函數開始執行。該函數的參數中有一個參數val,該參數可以用於判斷當前回呼函數由有什麼異常產 生的。這裡異常由 int3指令產生,因此接收到的參數應該為“DIE_INT3”。此時,又會調用 kprobe_handler()函數,該函數是Kprobes處理int3異常的主要實現函數。該函數首先會把發生異常的地址記錄下來,因為該地址就是註冊探測點的地址。為了防止核心被搶佔,該函數禁止核心搶佔功能。在 i386 CPU上,進入int3中斷處理時已經關閉CPU中斷,目前Kprobes的實現中只有i386體繫上會關閉CPU中斷,在其他體繫上的實現都沒有這樣做。
接著,開始檢查此次 int3 異常是否是由前一次 Kprobes 處理流程引發的,如果是由前一次Kprobes處理流程引發,則有兩種可能性。第一是該次Kprobes處理由於前一次回呼函數執行了被探測代碼造成的,第二種可能性是由於 jprobe造成的,這部分將在 jprobe的實現一節中詳細討論。如果int3異常不是由前一次Kprobes處理流程引發的,根據先前記錄下來的探測點地址到雜湊表中找到登入的struct kprobe結構。如果該結構中包含了pre_handler函數指標,則執行該預定的函數。
執行完使用者定義的 pre_handler函數時,已經完成了一部分的調試工作。接下來,就開始準備single-step步驟,該步驟用 prepare_singlestep()函數完成。這個函數與體繫結構相關,下面是prepare_singlestep()函數在i386體系CPU 上的主要實現代碼:
程式1 prepare_singlestep()函數部分代碼
01 regs->eflags |= TF_MASK;
02 regs->eflags &= ~IF_MASK;
03 regs->eip = (unsigned long)p->ainsn.insn;
上面的代碼中設定了EFLAGS中的TF位並清空IF位,同時把異常返回的指令寄存器地址改為儲存起來的原探測指令處,當異常返回時這些設定就會生效。 single-step技術已經在上文中討論過,這裡不再贅述。執行完被探測的指令後,由於 CPU的標誌寄存器被置位,此時又會引發一次CPU異常,該異
常在Linux核心中被稱為DEBUG異常。
3)Kprobe DEBUG異常處理
Linux核心中對DEBUG異常的處理方式與處理int3異常很類似。DEGUG異常的中斷處理函數也是在/linux/arch/i386/kernel/entry.S 中實現,KPROBE_ENTRY(debug)就是該異常的中斷處理函數的入口。該函數會調用do_debug()函數進一步處理DEBUG異常,同樣 的notify_die()函數被調用。與int3異常不同的是此時傳入notify_die()函數的第一個參數是“DIE_DEBUG”。
最終,notify_die() 函數會調用Kprobes初始化時註冊的回呼函數kprobe_exceptions_notify() 。此時,控制權又一次交回Kprobes 。
kprobe_exceptions_notify() 判斷傳入的類型為DIE_DEBUG,這時會去調用post_kprobe_handler ()函數。post_kprobe_handler ()首先判斷使用者定義的post_handler回呼函數是否存在,如果存在則執行之。
之後,會調用 resume_execution()函數做一些會做恢複工作,該函數會把 EFLAGES寄存器的TF 為清空,並根據被探測指令類型的不同,做不同的處理。在 resume_execution()返回後,post_kprobe_handler ()函數就會啟用在 int3 異常處理中被禁止的核心搶佔功能。到這裡,Kprobes對DEBUG異常的處理基本完成了,又把控制權交回核心。
以上是kprobe執行的主要流程,可以看出kprobe利用了兩次CPU異常的方式執行了使用者定義的pre_handler 和 post_handler 回呼函數。並通過 single-step 技術執行了被探測指令。當一次kprobe 執行循環完成後,又開始等待新一輪執行循環的到來。只有當調試者卸載了調試模組後,kprobe的生命週期才算結束。
jprobe的實現
相關資料結構與函數分析
1) struct jprobe結構
該結構在註冊jprobe探測點時使用,它包含兩個成員:
struct kprobe kp;//這是jprobe一個內嵌的struct kprobe結構成員,因為jprobe是基於kprobe實現的。
kprobe_opcode_t *entry;//這是被探測函數的代理函數。
2) setjmp_pre_handler()函數
該函數作為jprobe內嵌kprobe的pre_handler,在探測點被觸發時首先會被調用到。
3) longjmp_break_handler()函數
該函數作為jprobe內嵌kprobe的break_handler,當再次進入int3異常時被調用。