解決Linux內核問題實用技巧之 – Crash工具結合/dev/mem任意修改記憶體
- 2019 年 10 月 30 日
- 筆記
Linux內核程式設計師幾乎每天都在和各種問題互相對峙:
- 內核崩潰了,需要排查原因。
- 系統參數不合適,需要更改,卻沒有介面。
- 改一個變數或一條if語句,就要重新編譯內核。
- 想稍微調整下邏輯卻沒有源碼,沒法編譯。
- …
解決每一類問題都需要消耗大量的時間,特別是重新編譯內核這種事情。於是,每一個Linux內核程式設計師或多或少都會掌握一些Hack技巧,以節省時間提高工作效率。
然而,自己Hack內核非常容易出錯,稍不留意就會傷及無辜(panic,踩記憶體…),然後你會陷入沒完沒了的細節,比如查找頁表就夠折騰。
俗話說工欲善其事,必先利其器,臨淵羨魚,不如退而結網。
但是如果你使用現成的工具,就會發現有時候工具很難擴展。自己需要的邊緣小眾功能往往並不提供,你依然需要自己動手但卻又無從下手。
怎麼辦?
為何不把二者結合呢?
本文將通過幾個簡單的小例子,描述如何綜合systemtap,crash & gdb,/dev/mem,內核模組等技術排查以及解決現實中的Linux問題。
關於前置知識
本文不想花太多筆墨在前置知識上,本文默認讀者已經了解systemtap,crash & gdb等工具的基本用法。作為Linux內核開發者,這些工具的熟練使用是必須的。
如果需要這些知識,自行百度或者Google(有條件的話),會得到更好的答案。其中每一個細節都可以單獨寫一篇文章甚至一本書。
但還是要說一點關於 /dev/mem 的話題。
/dev/mem 幾乎總是被宣稱為作為整個物理記憶體映像可以被mmap到進程地址空間,很多人確實將/dev/mem設備文件mmap到了自己的程式,然而卻幾乎一無所得。這不是程式設計師的錯,畢竟作為一個平坦的記憶體地址空間,/dev/mem的內容看起來沒有任何結構,一般DIY的程式根本就無力解析它。
/dev/mem 是個寶藏,它暴露了整個記憶體,但是只有你擁有強大的分析能力時,它才是寶藏,否則它只是一塊平坦的空間,充滿著0或1。所有的內核實時數據均在 /dev/mem 中,找到它們才有意義,但找到它們並不容易。
crash & gdb工具會把這件事情做得很好。本文後面將側重於crash工具,gdb與此類似。
crash不光可以用來分析調試已經死掉的Linux屍體的vmcore記憶體映像,還可以用來分析調試活著的Linux Live記憶體映像,比如/dev/mem和/proc/kcore。同樣都是記憶體映像,調試活著的記憶體映像顯得更加有趣些。本文的例子將無一例外地描述這個方面的操作步驟和細節。
現在讓我們開始。
使/dev/mem可寫
小貼士:這個步驟非常重要!建議始終作為hack /dev/mem的第一步!
這個例子是第一步,也是繼續下面所有例子的前提。
首先,我們執行crash命令,調試/dev/mem記憶體映像:
[root@localhost ~]# crash /usr/lib/debug/usr/lib/modules/3.10.0-15.327.x86_64/vmlinux /dev/mem
大多數情況下,當我們嘗試使用crash工具的wr命令寫一個變數或者記憶體地址的時候,會收穫一個報錯提示:
crash> wr jiffies 123456wr: cannot write to /proc/kcore
這是因為我們運行的Linux內核大多數情況下都開啟了以下的編譯選項:
CONFIG_STRICT_DEVMEM=y
這意味著,當我們嘗試寫 /dev/mem 的時候,會受到內核函數 devmem_is_allowed 的約束。所以,為了我們使用crash wr命令修改記憶體成為可能,我們必須要繞開這一約束,即:
- 讓 devmem_is_allowed 函數恆返回1。
這一點通過systemtap很容易做到:
[root@localhost mod]# stap -g -e 'probe kernel.function("devmem_is_allowed").return { $return = 1 }'
在上述stap命令保持的情況下,退出crash並再次運行,此時我們便將可以完全讀寫 /dev/mem 了,如果說依然發生記憶體不可寫的情況,那便是受到了頁表項的約束,這個我們後面會談。
我們並不想讓那個stap命令一直運行在那裡,我們不希望通過crash寫記憶體這個操作依賴一個不能退出的stap命令,所以第一步,我們將直接修改 devmemisallowed 函數本身!
我們先反彙編它:
crash> dis -s devmem_is_allowedFILE: arch/x86/mm/init.cLINE: 583 578 * contains bios code and data regions used by X and dosemu and similar apps. 579 * Access has to be given to non-kernel-ram areas as well, these contain the PCI 580 * mmio resources as well as potential bios/acpi data regions. 581 */ 582 int devmem_is_allowed(unsigned long pagenr)* 583 { 584 if (pagenr < 256) 585 return 1; 586 if (iomem_is_exclusive(pagenr << PAGE_SHIFT)) 587 return 0; 588 if (!page_is_ram(pagenr)) 589 return 1; 590 return 0; 591 } crash>
非常簡單的邏輯,我想我們可以很快完成該函數的二進位修改。
讓我們看一下它的彙編碼,並且注意到下圖紅色框里的細節:

我們只要將 ja xxx 處的指令改成nop序列即可繞開這個跳轉,即修改 0xffffffff8105e649 地址處的2個位元組的值:
crash> wr -16 0xffffffff8105e649 0x9090
當然,比這個更直接的方法是直接重寫這個函數,僅僅執行兩個指令, mov $0x1,%eax 和 retq 。但是很遺憾,使用crash命令完成這個修改難度極大,我們仔細縷一下:

無論先替換nop還是先替換ret,均會破壞棧幀,造成返回地址錯誤從而panic:

除非同時原子替換二者(這在crash工具中幾乎不可能)。更安全的替換方案是在crash外部去替換,比如寫一個內核模組。先將crash查詢到的地址記錄下來:

隨後編寫模組,修改兩個地址的值:
#include <linux/module.h> static int __init hook_init(void){ char *to_nop = 0xffffffff8105e635; char *to_ret = 0xffffffff8105e642; to_nop[0] = 0x90;// 替換push為nop to_ret[0] = 0xc3;// 替換mov為ret // 不是真的載入。 return -1;} static void __exit hook_exit(void){} module_init(hook_init);module_exit(hook_exit);MODULE_LICENSE("GPL");
模組載入命令執行後,我們再次crash反彙編devmemisallowed,看看效果:

程式碼還是很簡潔的,最終也成功了,但是挺迂迴的,沒有第一種方法修改ja指令更簡單。
OK,接著我實際選擇的 「修改ja指令為兩個nop」 繼續講。現在讓我們殺掉stap命令,並且重新打開crash,再次看 devmemisallowed 函數的反彙編:

很明顯,條件跳轉已經被改成了nop序列,至此,我們已經解除了對stap的依賴。沒有stap的情況下,我們可以試試看修改一些無關緊要的東西:
crash> rd panic_on_oopsffffffff81977890: 0000000000000001 ........crash> wr panic_on_oops 0crash> rd panic_on_oopsffffffff81977890: 0000000000000000 ........crash>
現在,我們可以使用crash來修改記憶體了。當然,如果你照著上面的步驟一步一步挨著做也沒有成功,比如在寫記憶體時收穫了下面的錯誤:
crash> wr -16 0xffffffffa8c6fad4 0x9090wr: write error: kernel virtual address: ffffffffa8c6fad4 type: "write memory"crash>
不要著急,後面我們會專門分析這種情況下怎麼應對。
現在,讓我們繼續更多的例子。
修改TCP初始擁塞窗口
很多搞TCP調優的同行曾經吐槽, 「為什麼不能修改系統的TCP初始擁塞窗口啊?!」
目前Linux的TCP實現中初始擁塞窗口時10個mss,該值是Google內網經驗和全球經驗折中的結果。 該值不能修改的原因之一我覺得是為了保證TCP的公平性。如果這個值能被隨意配置修改,那豈不是把該值配置越來對自己越有利嗎?這會破壞公平性。用戶態的Quic就有這樣的問題。
修改TCP初始擁塞窗口的方法很多,比如將該值導出成sysctl配置並重新編譯內核,比如用iproute2配置攜帶 init_cwnd 參數的的路有項(同樣有最大值限制)等等,但是都不直接也並不簡單,我們要做的只是將下面的宏改成別的值即可:
/* TCP initial congestion window as per draft-hkchu-tcpm-initcwnd-01 */#define TCP_INIT_CWND 10
然而宏並非變數,宏是在編譯期就被替換的。
為了確認這宏定義值,我們編寫一個簡單的packetdrill腳本:
// test.pkt// Establish a connection and send 20 MSS.0.000 socket(..., SOCK_STREAM, IPPROTO_TCP) = 30.000 bind(3, ..., ...) = 00.000 listen(3, 1) = 0 0.000 < S 0:0(0) win 32792 <mss 1000,sackOK,nop,nop,nop,wscale 7>0.0 > S. 0:0(0) ack 1 <...>0.0 < . 1:1(0) ack 1 win 10240.0 accept(3, ..., ...) = 4 0.0 %{ print tcpi_snd_cwnd }%0.0 write(4, ..., 20000) = 20000 5.0 < . 1:1(0) ack 1 win 257 <sack 1001:3001,nop,nop>
運行它:
[root@localhost sack]# pdrill ./test.pkt --tolerance_usecs=1000010
OK,很明顯是10個mss。現在讓我們用crash來修改它。
TCP連接初始化擁塞窗口的函數是 tcpinitcwnd,我們用crash的dis命令看看它是怎麼實現的:

改法很簡單,我們看 0xffffffff815790e7 處的內容:
crash> rd 0xffffffff815790e7ffffffff815790e7: e083480000000aba .....H..crash>
將 0000000a 改成 00000005,這意味著初始擁塞窗口變成了5個mss:
crash> wr 0xffffffff815790e7 0xe0834800000005bacrash>
我們用packetdrill腳本確認這個修改:
[root@localhost sack]# pdrill ./test.pkt --tolerance_usecs=100005
通過利用crash工具,修改TCP初始擁塞窗口非常簡單。
修改TCP Time wait時間
很多人遭遇過TCP Time wait過多的問題,一個主動斷開的連接要維持60秒的Time wait狀態(Linux系統),這在現代高速網路環境下已經不再必要。我們想把這個值調小,但遺憾的是,這個值在Linux內核中同樣是是以宏定義存在的,無法調整。
和修改TCP初始擁塞窗口方法一致,不同的是 tcptimewait 函數的複雜度要遠高於 tcpinitcwnd 函數,不過大同小異,TCPTIMEWAITLEN 和 TCPINITCWND 在同一個地方被定義:
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT * state, about 60 seconds */
在HZ被定義為1000的情況下,只需要注意即時數60*1000,即0xea60即可:

注意到該值,我們需要將其改為我們需要的更小的值。
對於複雜的函數,在我們使用crash dis命令時,可以配合-l -s等參數將源程式碼和彙編指令做對應關係,更有效率地定位到我們需要修改的地方。
修改頁表項之後…
每一個運行的Linux內核都可能來自不同的編譯選項,從而導致了crash命令運行時的不同表現行為。
比如,即便是用stap hook住了devmemisallowed的返回值,讓它恆為1,也依然無法修改記憶體:
crash> wr -16 0xffffffffa8c6fad4 0x9090wr: write error: kernel virtual address: ffffffffa8c6fad4 type: "write memory"
這是為什麼呢?
在現代作業系統中,地址空間的記憶體操作全部針對虛擬地址進行,而決定該虛擬記憶體對應的物理記憶體是否可寫是由頁表項決定的。
所以,首先我們要確認頁表項的許可權,這在crash中用vtop即可:
crash>crash> vtop 0xffffffffa8c6fad4wr: current context no longer exists -- restoring "crash" context: PID: 3262COMMAND: "crash" TASK: ffff9e6bfdbacf10 [THREAD_INFO: ffff9e6bf67e4000] CPU: 2 STATE: TASK_RUNNING (ACTIVE) VIRTUAL PHYSICALffffffffa8c6fad4 3b86fad4 PML4 DIRECTORY: ffffffffa980e000PAGE DIRECTORY: 3c412067 PUD: 3c412ff0 => 3c413063 PMD: 3c413a30 => 3b8000e1 PAGE: 3b800000 (2MB) PTE PHYSICAL FLAGS3b8000e1 3b800000 (PRESENT|ACCESSED|DIRTY|PSE) # 無可寫標誌!! PAGE PHYSICAL MAPPING INDEX CNT FLAGSffffe06f80ee1bc0 3b86f000 0 0 1 1fffff00000400 reservedcrash>
注意到, 3b8000e1 表明該頁表項指向的頁面是不可寫的!
我們需要修改頁表項,而這個很容易,注意以下的關係:

我們只需要寫物理記憶體 0x3c413a30 即可:
crash> rd -p 3c413a30 3c413a30: 000000003b8000e1 ...;....crash> wr -p 3c413a30 000000003b8000e3crash>
此時,再次確認之前寫失敗的vtop結果:
PTE PHYSICAL FLAGS3b8000e3 3b800000 (PRESENT|RW|ACCESSED|DIRTY|PSE) # 有了可寫標誌
OK,有了可寫標誌,現在寫入它:
wr: write error: kernel virtual address: ffffffffa8c6fad4 type: "write memory"
很不幸,又失敗了。這是為什麼呢?
寫一個單獨的內核模組,刷新一下TLB。刷TLB很容易,就是重新載入RC4暫存器並重新使能分頁機制即可,此時系統會將所有的TLB失效。
有個未導出的 flushtlball 現成的函數卻不可以直接調用,我們需要在 /proc/kallsyms 里找到它的地址來調用。程式碼如下:
#include <linux/module.h> void (*pf)(void);static int __init flushtlb_init(void){ // 從/proc/kallsyms獲取flush_tlb_all的地址並調用. pf = 0xffffffffa8c7a040; pf(); // 不要真正載入 return -1;} static void __exit flushtlb_exit(void){} module_init(flushtlb_init);module_exit(flushtlb_exit);MODULE_LICENSE("GPL");
我們發現,只要刷一遍TLB,crash中讀取的頁表項PTE就會重新還原為不可寫。
出現該問題的內核編譯時有兩個選項CONFIGPHYSICALSTART和CONFIGPHYSICALSTART,crash的manual中有關於此的描述:
–reloc size When analyzing live x86 kernels that were configured with a CONFIGPHYSICALSTART value that is larger than its CONFIGPHYSICALALIGN value, then it will be necessary to enter a relocation size equal to the difference between the two values.
我們確認下當前寫失敗的內核的config文件配置:
CONFIG_PHYSICAL_START=0x1000000CONFIG_PHYSICAL_ALIGN=0x200000
與此同時,該內核的flushtlball並非簡單操作RC4暫存器這麼簡單。
我們不再指望使用crash直接修改記憶體,退一步,讓crash作為我們的資訊查詢工具起作用,剩餘的記憶體寫操作讓我們自己寫內核模組來做。
依然以修改TCP初始擁塞窗口為例,我們要改兩個地方:
- 改頁表項,讓 tcp_init_cwnd 函數指令可寫。
- 改tcp_init_cwnd函數硬編碼的指令操作數。
分兩步走,先找到頁表項並通過ptov命令得到它的虛擬地址(所有OS級別的寫記憶體都基於虛擬地址進行):

再找需要修改的指令地址和值:

我們依據這些值編寫內核模組:
#include <linux/module.h> void (*pf)(void);static int __init modcwnd_init(void){ char *ppte = 0xffff9e6bfc413a48; // 需要修改的PTE地址 long *pvalue = 0xffffffffa924eb67; // 需要修改的窗口值地址 pf = 0xffffffffa8c7a040; ppte[0] = 0xe3; pf(); // 我們將其改成一個奇怪的值:6 pvalue[0] = 0xe0834800000006ba; return -1;} static void __exit modcwnd_exit(void){} module_init(modcwnd_init);module_exit(modcwnd_exit);MODULE_LICENSE("GPL");
嘗試載入模組之後,執行我們上面的packetdrill腳本,這次讓我們用抓包來確認:

正好6個數據段。
這種情況下,crash工具成了輔助,而真正起作用的是我們自己編寫的內核模組,而這些背後,需要我們對作業系統整體的記憶體管理機制擁有清晰的認知。編寫這種內核模組也是Linux內核程式設計師必備的技能。
修改用戶進程記憶體
來點輕鬆的,我們來用crash修改一個用戶態程式的記憶體,先看程式程式碼:
#include <stdio.h>#include <stdlib.h> int main(){ unsigned long a = 0x1122334455667788; printf("%lx %pn", a, &a); getchar(); printf("%lxn", a); getchar();}
和《Linux內核如何私闖進程地址空間並修改進程記憶體》這篇文章里做的事情差不多,不同的是,本文我們用一種更加優雅的方式來進行記憶體篡改。
首先,運行它:
[root@localhost mod]# ./a.out1122334455667788 0x7fff45d5d4d8
然後在crash中找到它:
crash> ps |grep a.out 7166 1582 0 ffff9e6bf66f8000 IN 0.0 4324 516 a.outcrash> set 7166 PID: 7166COMMAND: "a.out" TASK: ffff9e6bf66f8000 [THREAD_INFO: ffff9e6bcb0ec000] CPU: 0 STATE: TASK_INTERRUPTIBLE
我們的目標是修改變數a的值,因此我們要定位該進程的用戶態堆棧。
注意,此時getchar已經在內核空間等待了,所以bt命令只是內核棧,用戶占還需要我們自己來找,我們從進程的task_struct結構體里尋找用戶堆棧的蛛絲馬跡:
crash> task_struct ffff9e6bf66f8000struct task_struct { state = 1, stack = 0xffff9e6bcb0ec000, usage = { counter = 2 }, flags = 4202496,... sp0 = 18446636784538288128, sp = 18446636784538287176, usersp = 140734365029480,...
我們就從 usersp = 140734365029480 開始找吧:

現在修改它:
crash> wr -u -64 0x7fff45d5d4d8 0xaabbccdd99887700
然後在a.out運行的終端敲回車:
[root@localhost mod]# ./a.out1122334455667788 0x7fff45d5d4d8 aabbccdd99887700
可以看到,變數a的值發生了改變。
拯救D進程
我們經常在系統中發現D狀態的進程,大多數情況下我們對其無能為力。
D進程處在 等待資源不能滿足卻又不能離開 的兩難境地。然而這並不意味著D進程不可拯救,我們只需要解除它對資源的依賴,然後讓它退出即可,即調用 do_exit。
以下三篇文章描述了一種拯救D進程的方法:
Linux如何終止D狀態的進程 :https://blog.csdn.net/dog250/article/details/53043445
Linux x86內核終止D狀態的進程 :https://blog.csdn.net/dog250/article/details/53071973
Linux x86_64內核終止D狀態的進程 :https://blog.csdn.net/dog250/article/details/53072028
下面將介紹另一種方法。為了舉個例子,首先我們要先自己造一個D進程。我們先寫一個內核模組
// main4.c#include <linux/module.h>#include <linux/sched.h>#include <linux/wait.h> int condition = 1234;module_param(condition, uint, 0644); wait_queue_head_t waitq; static int __init Ds1_init(void){ long magic = 0x22334455667788; condition = 0x1234; printk("condition:%lu magic:%lu %pn", condition, magic, &waitq); init_waitqueue_head (&waitq); // 此處沒有任何人會將condition設置為123,因此insmod會一直等待,進而D住 wait_event(waitq, condition == 123); return 0;} static void __exit Ds1_exit(void){} module_init(Ds1_init);module_exit(Ds1_exit);MODULE_LICENSE("GPL");
然後我們試著載入這個模組。很不幸,卡住了,即便是 kill -9 也無法殺掉它。很顯然,它D住了:

現在,我們如何將其從D狀態激活呢?下面我們用crash工具試試看。我們的目標有3步:
- 找到insmod進程。
- 修改內核模組里的condition變數的值為希望的123。
- 喚醒insmod睡眠在的wait隊列。
我們一步一步來。先找到insmod進程:
crash> ps |grep insmod 9074 1408 0 ffff88003a3e3de0 UN 0.1 13252 800 insmodcrash> set 9074 PID: 9074COMMAND: "insmod" TASK: ffff88003a3e3de0 [THREAD_INFO: ffff880022a2c000] CPU: 0 STATE: TASK_UNINTERRUPTIBLE # D狀態crash>
接下來我們要找到condition變數的位置,這個需要些技巧,千人千法。我這裡只介紹我採用的方法。先列印出最後的stack:
crash> btPID: 9074 TASK: ffff88003a3e3de0 CPU: 0 COMMAND: "insmod" #0 [ffff880022a2fca8] __schedule at ffffffff81639b5d #1 [ffff880022a2fd10] schedule at ffffffff8163a199 #2 [ffff880022a2fd20] init_module at ffffffffa00370bb [main4] #3 [ffff880022a2fd60] do_one_initcall at ffffffff810020e8 #4 [ffff880022a2fd90] load_module at ffffffff810e9f3e #5 [ffff880022a2fee8] sys_finit_module at ffffffff810ea8f6 #6 [ffff880022a2ff80] system_call_fastpath at ffffffff81645189 RIP: 00007f313239a1c9 RSP: 00007ffdbeea5578 RFLAGS: 00010216... # 不care暫存器,因為這種case用不到 R13: 00000000007671c0 R14: 0000000000000000 R15: 00000000007671f0 ORIG_RAX: 0000000000000139 CS: 0033 SS: 002bcrash>
我們希望可以在main4模組的函數 內部 找到為condition變數賦值的語句或者找到 「condition == 123」 。我們注意到以下的行:
#2 [ffff880022a2fd20] init_module at ffffffffa00370bb [main4]
我們就在地址 0xffffffffa00370bb 前面某個地方找找看。之所以在前面找是因為condition變數的操作語句在執行流調用下一個函數之前,所以還在上一個棧幀上:

當然,更直接的,還可以直接反彙編Ds1_init函數,很容易從內核棧上獲取它的位置:

現在很明確,方法有兩個:
- 修改cmpl語句,將123,即0x7b改成0x1234。
- 修改condition變數位置的值,改成0x7b,即123。
我選擇方法2(寧改數據不改指令,萬一碰到指令不可寫又要改頁表項):
crash> rd -32 0xffffffffa0146000ffffffffa0146000: 00001234 4...crash> wr -32 0xffffffffa0146000 0x0000007bcrash>crash> waitq 0xffffffffa0370260PID: 9074 TASK: ffff88003a3e3de0 CPU: 1 COMMAND: "insmod"crash>
現在condition的值已經是123了,接下來最後一步,喚醒insmod的睡眠隊列wait。再看上面的反彙編:

注意到地址 0xffffffffa0146260 ,作為函數 preparetowait 的參數,它就是等待隊列waitq。我們確認一下:
crash> wait_queue_head_ttypedef struct __wait_queue_head { spinlock_t lock; struct list_head task_list;} wait_queue_head_t;SIZE: 24crash> wait_queue_head_t 0xffffffffa0146260struct wait_queue_head_t { lock = { { rlock = { raw_lock = { { head_tail = 131074, tickets = { head = 2, tail = 2 } } } } } }, task_list = { next = 0xffff880022a2fd40, prev = 0xffff880022a2fd40 }}
0xffff880022a2fd40 作為兩枚listhead指針,鏈入的正是waitqueuet,我們可以再次確認:

其中確認waitqueuet對象時將list減去3*8這個偏移是可以用crash工具的 *struct waitqueuet.tasklist -o* 計算出來的。
現在,是時候寫一個內核模組來喚醒D進程了:
#include <linux/module.h>#include <linux/sched.h> static int __init wake_init(void){ wait_queue_head_t *wait = 0xffff880022a2fd28; wake_up(wait); // 並不載入,wakeup後即退出。 return -1;} static void __exit wake_exit(void){} module_init(wake_init);module_exit(wake_exit);MODULE_LICENSE("GPL");
編譯載入,載入,效果如下:
[root@localhost mod]# insmod ./wake.koinsmod: ERROR: could not insert module ./wake.ko: Operation not permitted[root@localhost mod]# ps -elf|grep [i]nsmod[root@localhost mod]#
這就完成了我們拯救D進程的演示,但是注意,拯救D進程沒有通用的方法,即便是成功將其從D狀態救出,也依然要確認資源依賴,解除資源依賴後儘快調用 do_exit 。
結語
以上只是拋磚引玉般結合 crash,stap以及內核模組分析了幾個簡單的實例,如果繼續下去,還會有非常多類似的例子以及更為複雜更為有趣的案例供我們去分析或者把玩,這將給我們帶來無窮無盡的快樂。
但是限於篇幅,本文只能在此點到為止。本文的宗旨在於,通過這些簡單有趣的實例,讓我們理解工具的使用方法以及使用這些工具的重要性。
與此同時,在我們日常分析解決Linux內核問題時,如何使用工具並不是核心,工具始終只是一個讓你的工作效率更高的錦上之花,真正乾貨的背後永遠都是對作業系統理論以及對Linux內核本身的理解和掌握,否則,工具掌握得再熟練也只能是個熟練工。
浙江溫州皮鞋濕,下雨進水不會胖。
(完)