二儀攸分和跨界溯源
這個世界是紛繁復雜的,同時又是符合一些簡單規律的。朱子有句名言:千頭萬緒,終歸一理。意思是無論多么混亂的東西,都始終是由一個簡單的道理支配著的。舉例來說,在古老的《易經》里便定義了陰陽兩個符號,并賦予了它們豐富而且深刻的內涵。在《易經》的系辭傳中,有一句著名的話:易有太極,是生兩儀。對這句話,有不同的理解。“天地起源說”是其中一種。根據這種理解,宇宙最初是混沌的一體,后來清者為天,濁者為地,這個形成天地的過程可以被簡稱為“二儀攸分”。
我不敢說這句話對描述天地形成有幾分準確,但憑著多年來對軟件的研究,我認為它對軟件世界是很適用的。最早的軟件就是一長串卡片,連在一起,卡片上的指令住在一個空間里,是平等的,沒有特權差異,沒有空間劃分。隨著計算機的發展,計算機系統里的角色開始細分,每個角色的特征和位置逐漸固定。于是有了中央化的內存和處理器,以及外部的輸入輸出設備。
中央的處理器和內存速度很快,外部的輸入輸出設備速度很慢。讓中央處理器直接與外設對話太影響效率了。于是便有了中斷的概念,中央處理器需要什么,下命令給外設,外設做好了,通過中斷通知中央處理器:“報告老大,您吩咐的任務完成了。”外部設備不止一個,中斷也有很多種,為了更好的處理中斷,便有了專門用于處理中斷的“控制程序”,這便是操作系統的前身。中斷處理程序的邏輯很敏感,如果出故障就會導致整個系統無法工作。既然它如此重要,那么就應該“優待”它,讓它享有特權,給它專門的“住所”,加強其住所的防衛,防止有人擅自闖入。于是便有了專門給中斷處理程序住的空間,也就是所謂的內核空間。有了高特權的內核空間后,也就有了低特權的用戶空間。于是本來混沌一體不分特權的軟件世界便分成兩個部分了。
在軟件世界的兩個空間中,“用戶空間是可見的,很多程序在上面生生不息,屬陽。內核空間不可見,但是承載著上面的應用,為應用提供服務,厚德載物,本身不發光,但是卻能反射應用的光輝,像月亮,屬陰。”(選自《軟件調試》卷2)今天,軟件世界的這種基本格局已經非常固定,無論是Windows,還是Linux都是如此。用戶空間和內核空間的關系與現實世界中公民與政府的公司非常類似。當公民需要政府服務時要到政府的窗口去辦理,軟件世界中的對應機制便叫“系統調用”,意思是要調用系統的服務。
[前半部分為科普,后半部分換擋,前方高寒,非geek止步]
受兩大空間劃分的影響,當我們調試軟件時,一般也分為調試用戶空間代碼的應用程序調試,和調試內核空間代碼的內核調試。相應的,調試器也可以分為應用程序調試器和內核調試器。
容易理解,當我們使用應用程序調試器調試應用程序時,是無法調試內核代碼的。反過來則未必。
或者說,使用內核調試器能調試用戶空間的代碼嗎?
理論上是可以的,因為內核空間的特權高,既然已經能訪問和控制內核空間,那么理論也就能訪問和控制受內核管理的用戶空間。
理論上可以,實踐中可以么?答案是未必,要看調試器的能力,也要看調試者的技術水平。
舉例來說,使用WinDBG來調試Windows內核時,也可以在用戶空間設置斷點,斷點命中后,可以讀寫變量,或者單步跟蹤。
不過,雖然WinDBG有這個能力,實際能做把WinDBG使用到這個程度的人其實并不多。在這方面,WinDBG還有一個非常牛的能力,那就是跨越陰陽二界地顯示系統調用的完整過程,比如:
在上面這幅完美的棧回溯中,下面部分是CPU在用戶空間的執行經過,從線程的起點開始,到AfxWinMain,經過消息循環,然后是OLE的處理文件拖拽邏輯,收到一個文件后,自己不認識,通過古老的DDE機制廣播消息尋求幫助,用于廣播消息的SendMessageW函數發起系統調用,進入內核空間,內核空間中的Win32K模塊處理這個服務請求,執行廣播消息的任務,把消息發個一個個頂層窗口,遇到一個“收到消息不回的壞蛋”,卡在那里了。
我第一次看到這樣的完美棧回溯時,我深深被微軟調試工具的技術水平所打動。
為什么呢?因為要跨越兩個空間顯示這個棧回溯有很多困難。第一個困難是每個普通線程都至少有兩個棧,用戶空間一個,內核空間一個,因此,要顯示上面這樣的完美棧回溯,必須要回溯兩個棧,因為起點在內核空間,因此,內核空間的棧比較好找。但用戶空間的棧位置就不那么好找。
第二個困難是今天的主流操作系統都是多任務的,有很多個用戶空間,要顯示上面的完美棧回溯,必須要找到正確的用戶空間,并且找到這個空間中的模塊列表,加載用戶空間的模塊。
自從開始開發NDB調試器,我就想讓它也具有“產生完美棧回溯”的能力。為了實現這個愿望,我花很多時間思考,加上很多時間寫代碼,當然還花了很多時間來調試代碼,讓它如預期的工作。
長話短說,解決第一個困難花了至少三十個小時的時間。最終的成功方案是要經歷這幾個步驟。先通過內核空間的棧回溯,找到CPU做系統調用時,飛到內核空間的起點,在x64下,它是使用匯編編寫的entry_SYSCALL_64。在內核源碼的syscall_init中,也可以看到這個證據。
entry_SYSCALL_64的源代碼位于:F:\bench\linux-5.0.7\arch\x86\entry\entry_64.S在這個非常值得細讀的匯編源文件中,有一段很長的注釋,這段注釋幫了我大忙。
更重要的是,在這個匯編函數中,它保存了一個所謂的硬件幀,通過不斷壓棧,在棧上形成形成了一個pt_regs結構體。
這個pt_regs結構體里面包含了關鍵的寄存器信息,特別是我需要的用戶空間棧指針rsp。
類比一下,這個結構體相當于Windows下的陷阱幀(KTRAP_FRAME),這又應了朱子的話:千頭萬緒,終歸一理。
在征戰這一關時,我的NDB發揮了很大作用,比如我在entry_SYSCALL_64入口設置斷點,成功命中,這讓我可以清楚觀察CPU從用戶空間飛到內核空間后剛剛著陸后的精確狀態,每個寄存器的取值。
- rax=00000000000000e4 rbx=0000000000000001rcx=00007ffdc513099a // RIP
- rdx=00007f9b9dec9e10 rsi=00007f9b9dec9e10rdi=0000000000000001
- rip=ffffffffa3e00010 rsp=00007f9b9dec9db8rbp=00007f9b9dec9dc0
- r8=0000000000000000 r9=00007f9b9dec9e10 r10=00007f9b9dec9db0
- r11=0000000000000286 r12=00007f9b9dec9e10r13=0000000000000000
- r14=00007f9b9dec9e10 r15=00007f9b9dec9e20
- iopl=0 nv up di ng nz na po nc
- cs=0010 ss=0018 ds=0000 es=0000 fs=0000 gs=0000 efl=00010086
- lk!entry_SYSCALL_64:
- ffffffff`a3e00010 0f01f8 invlpg eaxds:0010:00007ffd`c512d080=00126362
其中rcx寄存器的值便是用戶空間的程序指針,因為根據SYSCALL指令的定義,CPU會把rip保存到rcx,即上面注釋中說的:
64-bit SYSCALL saves rip to rcx使用NDB反匯編這個值-2(減2是為了看到已經執行過的syscall指令)便可以看到用戶空間發起系統調用的指令:
- u 007ffdc5130998
- 00007ffd`c5130998 0f05 syscall
- 00007ffd`c513099a 415c pop r12d
- 00007ffd`c513099c 5d pop rbp
- 00007ffd`c513099d c3 ret
- 00007ffd`c513099e a860 test al,0x60
- 00007ffd`c51309a0 7438 jz 00007ffdc51309da
- 00007ffd`c51309a2 4863c7 movsxd rax,edi
- 00007ffd`c51309a5 488d0dd4c6ffff lea rcx,[00007ffdc512d080]
順便說一下,使用匯編語言編寫的entry_SYSCALL_64函數準備pt_regs結構體的目的是為了給使用C語言編寫的do_syscall_64函數準備參數。后者的第二個參數便是指向pt_regs結構體的指針,即:
- __visible void do_syscall_64(unsigned long nr, struct pt_regs *regs)
下面是準備調用C語言函數前的寄存器上下文:
和棧數據:
對于這個棧數據,不常做底層調試的同行可能滿臉問號。對于我來說,幾乎每個字節都非常親切,像老朋友一樣,因為它們都個性鮮明,16位的段選擇子也住著64位的大房子(^-^)。
對于第二個困難,解決的難度更大,主要的步驟如下。
首先要通過per-cpu區找到當前任務指針,也就是current指針,參見我的上一篇文章。然后要通過current指針找到當前任務的地址空間描述,也就是mm_struct。然后找到mm_struct中的VMA鏈表,再遍歷這個鏈表,篩選出其中的一個個so模塊描述。
找到這些so模塊描述后,還需要把這些描述報告給調試引擎,讓調試引擎來加載用戶空間的模塊。
找到用戶空間的棧和加載了用戶空間的模塊后,再觀察棧回溯,便可以看到希望的曙光了。比如下面是11月21日時看到的場景:
已經看到libc和著名的ld模塊,這給我很大鼓勵。
仔細觀察上面這個棧回溯,容易看出libc中的函數名很不精確,用調試器分析,發現使用的libc符號文件缺少對棧回溯至關重要的幀信息。深究原因,讓人暈倒。與Windows下的符號文件不同,Linux(以Ubuntu為例)的gcc編譯時如果有-g選項,則會產生調試符號,與執行信息放在一個文件中。
包含符號信息的文件比較大,所以一般會通過所謂的strip過程產生一個消減符號的產品文件和一個用于調試的符號文件。比如使用下面這樣的objcopy命令便可以產生一個專門用作調試的符號文件:
- gedu@gedu-VirtualBox:~/labs/gemalloc$ readelf --debug-dump=frames gemalloc.dbg
- section '.eh_frame' has the NOBITS type - its contents are unreliable.
用下面兩條命令則可以產生一個適于發布到產品環境的不帶符號版本:
- gedu@gedu-VirtualBox:~/labs/gemalloc$ cp gemalloc gemalloc.prd
- gedu@gedu-VirtualBox:~/labs/gemalloc$ objcopy --strip-debug gemalloc.prd
觀察文件大小,是有明顯差異的:
調試時,我們一般使用符號文件。因為大多數符號信息是放在符號文件中的。注意,這里是說大多數,并不是全部。比如幀信息,因為發生異常時做棧展開也需要,所以幀信息是放在產品文件中的。既然幀信息對于調試和正常執行都需要,按說應該兩個文件都有一份,但事實上不是,至少上面截圖中使用的libc符號文件里不包含幀信息,節的頭仍在,但是標志位里有NOBITS標志,代表這個節的數據是不可靠的,解析時會失敗。
比如,使用readelf命令來顯示上面產生符號文件的幀信息,會得到如下提示:
- gedu@gedu-VirtualBox:~/labs/gemalloc$ readelf --debug-dump=frames gemalloc.dbg
- section '.eh_frame' has the NOBITS type - its contents are unreliable.
再附加個截圖吧:
以下是readelf程序的有關源代碼:
- if(section->sh_type == SHT_NOBITS)
- {
- /* There is no point in dumping the contents of a debugging section
- which has the NOBITS type - the bits in thefile will be random.
- This can happen when a file containing a.eh_frame section is
- stripped with the --only-keep-debug commandline option. */
- printf (_("section '%s' has the NOBITS type - its contents are unreliable.\n"),
- print_name);
- return 0;
怎么解決這個問題呢?就是要讓NDB為一個模塊加載兩個符號文件。細節從略。
過了這一個難關后,便可以看到更好一些的棧回溯了。libc的函數名變得很精確。
仔細觀察上面的結果,美中不足的是最后三行,有兩行重復clone函數,最后一行沒有給出函數名,也沒有顯示出代表root的返回地址為0特征。
他山之石可以攻玉。使用gdb來做對比較實驗,看到的是:
- (gdb) bt
- #0 thread_func (arg=0x9) at gemalloc.c:289
- #1 0x00007ffff68216ba in start_thread(arg=0x7fffc6ffd700)
- at pthread_create.c:333
- #2 0x00007ffff655741d in clone ()
- at../sysdeps/unix/sysv/linux/x86_64/clone.S:109
看來clone函數確實是線程的起點。gdb成功在起點處停車。可是ndb沒有。
給調試器上調試器,調試NDB。仔細跟蹤負責解析符號的ndw模塊。
跟蹤發現,ndw可以順利找到clone函數的幀信息,即:
- 000146e80000000000000014 00000000 CIE
- Version: 1
- Augmentation: "zR"
- Code alignment factor: 1
- Data alignment factor: -8
- Return address column: 16
- Augmentation data: 1b
- DW_CFA_def_cfa: r7 (rsp) ofs 8
- DW_CFA_offset: r16 (rip) at cfa-8
DW_CFA_undefined:r16 (rip)
上面的DW_XX是DWARF標準里定義的棧回溯指令,可以理解為腳本語言。NDW內部的解釋器順利執行了前兩條語句:
- DW_CFA_def_cfa: r7 (rsp) ofs 8
- DW_CFA_offset: r16 (rip) at cfa-8
但是在執行第三條語句時,懵圈了。
DW_CFA_undefined:r16 (rip)
我在跟蹤這條語句對應的解釋器代碼時,以為遇到了不認識的指令,推測是版本兼容導致的。
反復跟蹤和思索了很久,我終于領悟到了,這個undefined并不是我最初想的那樣。它的含義不是說它是一條未定義的指令,而是要把它的操作數,也就是后面的rip寄存器設置為“未定義”狀態。
某種程度來說,棧回溯就是在回滾寄存器的狀態,而這個
DW_CFA_undefined:r16 (rip)
就是要把rip寄存器的狀態回滾到“未定義狀態”。也就是讓它進入一個不知道為何值的狀態。
在NDW的老代碼中,對于這個情況,會把rip回滾到它的當前值(也就是不回滾),于是就出現了上面的clone函數的父函數還是clone函數的現象。
閱讀libc中clone函數的源代碼,可以找到這個DW_CFA_undefined指令的來源,是某位同行故意手工加入的。
代碼中的注釋很有意思:故意加了這個undefined來標記到了最外層的棧幀,非常明顯地!其實不加就挺好的。Anyway,也是用心良苦。
閉目思索,這里用的undefined也非常具有哲學意味。很多時候我們在尋找源頭,可是最終的源頭到底在哪里呢?我們找到的源頭也未必就是真的源頭。真的源頭常常是未知的,也就是undefined。比如2020年的新冠病毒,人類能找到真的源頭么?至少到今天,還是undefined。
如此想來,這種在線程源頭標記為undefined的方法還真是意義非凡。
找到根源之后,我直接把這種undefined的回滾處理成回滾到0,問題便解決了,夢寐以求的完美棧回溯出現在眼前。

本文轉載自微信公眾號「格友」,可以通過以下二維碼關注。轉載本文請聯系格友公眾號。