2010年8月22日 星期日

淺談 GCC 中的 trampoline (1)

最近正好看到前輩 Jserv 的文章 GCC 的 nested function 與 trampoline 以及學長Thinker的 補充說明 後,由於這個領域也算是小弟的守備範圍,又巧逢本格乾旱已久,也好久沒有文章了,就當成是久旱逢甘霖,搭一下順風車淺談一下這個議題。這篇文章會以 GCC 的觀點來切入 trampoline ,並且提及在不同平台中支援 trampoline 的注意事項。不巧的是GCC 4.5 系列 trampoline 的 API 正好有作一些更改,把一些原本是 Macro 的 API 改成使用 Target Hook 的方式,雖然跟功能性沒有什麼太大的關係,由於小弟還沒跟上 GCC 4.5 系列,為了重現臨場感,所以本篇文章如果有使用到 GCC 的原始碼的話,是使用GCC-4.4 系列的原始碼為範例的,請讀者見諒。

Trampoline 字面的意思是體操用的彈翻床,所以就有跳(Jump)的意思存在,其實trampoline這個字在計算機科學領域是有多重意義存在的,不過大多跟跳有關就是了,trampoline詳細的定義和意義可以參考英文維基 Trampoline (computers) 條目中的內容,這邊不再贅述。而 GCC中 Trampoline 主要是用來支援一個以前在程式語言界蠻流行的功能 Nested function,而 Nested function 最主要的目的是被設計來實現 information hiding 的,information hiding 在那個年代被認為是提高軟體工程師生產力的秘密武器,而且也是廣為流行的,就把它想成現在的嵌入式程式阿宅都把灌"吸"當王道這樣就好了。或許讀者就會問說:「好好的 C 語言標準不支援,支援這種奇怪的東西幹麻?」當然除了它可以實現一些很奇特的功能像 Functional  Language 的closure 之類的東西,最重要的原因就是歷史因素,這個歷史因素也很簡單,那就是 Glibc 有用到這個功能,所以 GCC 就支援這個功能了。

如同上一段所說的,傳統上來說 Nested Function 是被設計來實現 information hiding 的,所以變數的Scope (變數的有效範圍) 是 Nested Function 中很重要的議題,就語言學來說,其實變數的Scoping 有分成 Dynamic Scoping 和 Static Scoping,用白話來說,假設每個函式有分成外圈和內圈好了,所謂的 A 比 B 還外圈就是代表 B 被允許存取 A 的資料,而 Static Scoping 的語言代表 A 跟 B 的關係在 Static Time (也就是所謂的 Compiling Time) 決定的,而 Dynamic Scoping 的語言代表 A 跟 B 的關係是在 Dynamic Time (也就是所謂的 Runtime) 決定的。舉個簡單的例子來說明,

procudure A
       procudure B
            XXXX

end

procudure C

    A()
end
   

就 Static Scoping 的語言來說 B 是 A 的內圈,可是就 Dynamic Scoping 的語言來說 A 是 C 的內圈。以實作上來說,內圈函式為了存取外圈函式的變數,內圈函式的 Stack Frame 中就必須要建一條 Link 到外圈函式的 Stack Frame 去,而連結到 Static Scope 外圈函式的 Link 稱為 Static Chain 而連結到 Dynamic  Scope 外圈函式的 Link 稱為 Dynamic  Chain 。

就學理來說,Static Typing 的語言通常會使用 Static Scope ,所以我們偉大的灌"C"哥非常靜態,所以可以想見在C語言上實作 Nested Function 也應該是屬於 Static Scoping 的 ,既然是這樣,所以我們也可以很快的想到 "這樣的系統" 是必須要有 Static Chain 的。所以 GCC 中的 trampoline 需要負責做兩件事情,就是 1.幫將被呼叫的函式產生  Static Chain,然後 2.跳轉到 被呼叫的函式去。GCC 為了減少執行時期的開銷,Static Chain 是存放在一個稱為 Static Chain Register 的暫存器內,想當然爾這個暫存器最好是 Caller-Save Register,而且每個平台會使用不同的Register。以x86 32位元的平台來說,Static Chain Register 是 ECX ,然而 x86-64 ECX已經被當成參數來使用,所以使用R10。最後附帶一提的是,由於 trampoline 的機制必須要在執行在 Stack 上的程式碼,大部分的 RISC Machine ( 因為 instruction 跟 data caches分離) 在trampoline 複製到 Stack 中的時候因為不知道他是指令,所以會產生 Memory 的值跟 Instruction Cache 中的值不一致的狀況,所以在使用這種機制時就必須要 clear instruction cache ,以避免執行到 instruction cache 中的舊指令。

2010年6月7日 星期一

使用 Chroot 來混用 Ubuntu 10.04 TLS 和 Ubuntu 8.04 LTS

Ubuntu 是個非常流行而且算是主流中的主流(?)的一種Linux發行版,也就因為是主流中的主流,所以其實 Ubuntu 中的套件裡面軟體都非常新(不過Ubuntu當一個版本Release後,通常就不會升級裡面軟體的版本主要版本了,通常只進行bugfix 和 patch number的更新),這對很喜歡用新版的Linux使用者來說,算是一種福音。(當然,如果很喜歡用最新版而且非常想更新主要版本的使用者,通常會選擇Gentoo+使用~符號來嘗鮮)。版本新是很好,可是對軟體開發者來說,卻有個非常重大的缺點,那就是開發出來的二進制的散佈問題。

或許有的人會問:「那就用 static link 就好了阿,這樣不是一切都解決了?」姑且不提有些函式庫就是沒辦法 static link,還是某些發行版為了 license issue ,所以只提供了share 版本的函式庫 ,其實static link 並不能完全解決二進制散佈問題。聽到筆者這樣說,或許這樣大家就會問了:「static link 不是把所有的東西拉進來了嗎?而且理論上是 Self-contain 的,為什麼還是會遇到問題?」簡單的說,在編譯的時候即使使用了 static link,很清楚的地方是,其實 kernel 並沒有被拉到執行檔內,這樣的話static link的二進制檔嚴格來說是跟 kernel 有些相依性的。

什麼是跟 kernel 有些相依性,程式開發人員都知道,跟 Linux 打交道的方法就是透過 system call,linux 中的 libc (glibc, eglibc, uclibc and bionic) 就是提供了 C 語言跟 linux kernel 系統呼叫的一個橋樑,為了效能上的考量,新版的 libc 通常會使用某個版本以上的 linux kernel 才提供的 syscall,這樣會造成即使是static link的二進制,也需要在某個版本以上的Linux Kernel才能執行。以Ubuntu來說,8.04 編譯出來的檔案至少要使用2.6.8以上的Linux Kernel才能執行,而10.04卻需要2.6.18以上的Linux Kernel才能執行,這個差距足以讓那些 Red Hat Enterprise Linux AS release 4 或是 Cent OS 4.6 的工作站無法執行在Ubuntu 10.04 開發出來的二進制。

如果想要升級到 Ubuntu 10.04 LTS 卻害怕相容性問題,想要使用新版本的軟體來開發程式卻想使用 Ubuntu 8.04 LTS 來編譯程式的話,或許有人會想用使用Dual Boot,可是其實不需要那麼麻煩, chroot 就是你的救星! 參考 Gentoo的作法 ,先將Ubuntu 8.04 安裝在某個分割區,然後使用Ubuntu 10.04 開機後,掛載 8.04 的分割區後,執行以下的程式碼 (假設 8.04 的分割區在被掛載在 /mnt ),然後再執行 chroot /mnt /bin/bash ,就能輕鬆享用 8.04 的環境。

# mount -o bind /dev /mnt/dev
# mount -o bind /dev/pts /mnt/dev/pts
# mount -o bind /dev/shm /mnt/dev/shm
# mount -o bind /proc /mnt/proc
# mount -o bind /proc/bus/usb /mnt/proc/bus/usb
# mount -o bind /sys /mnt/sys

2010年1月9日 星期六

淺談C++例外處理 (後篇)

這一篇是這個系列的完結篇了,重點放在 GCC C++ Compiler 的例外處理機制 - Dwarf2 Exception Handling。看到 "Dwarf" 這個英文單字,如果是執行檔格式有研究的人,大概會想到的東西除了龍與地下城和魔獸爭霸的矮人以外就是debug相關的東西了。沒錯,這邊Dwarf2的意思就跟大家想的那件是一樣,他是一種 "ELF" 格式中的 debugging data format,全名是 "Debug With Attributed Record Format"。會取這個名字的梗是由於"ELF"是精靈,而"Dwarf"雖然我們叫他為矮人,而他們在北歐神話中其實是一種精靈(黑暗精靈)。

例外處理的概念並不困難,只是實作卻是比想像中複雜很多,撇開C++標準規定的"拋出異常時必需要解構所有的自動變數"這個複雜的描述不談,其實對程式語言來說,想要正確的回復到那個時候的狀態,只要能取回例外處理區塊 (在 C++中為catch block) 範圍看的到的區域變數值就差不多了。然而,取回區域變數值說起來很容易,事實上卻不容易,以 C++ 這種語言來說,函式的呼叫或是程式區塊是用堆疊的觀念去處理的,更何況編譯器為了最佳化,是有可能把變數放到暫存器內的。所以可想而知編譯器會在函式的開頭和結尾都插入一些代碼來負責這些平衡堆疊這件事。用簡單的話來說,就是編譯器函式的開頭插入把堆疊指標($esp)調整好,並把有用到且該保存的暫存器壓入堆疊內,並且在離開的時候插入了恢復堆疊指標和暫存器的代碼。

我們看以下的程式碼,如果執行時期 a 呼叫了 b ,且 b 呼叫了 c 的話。

   a() call b() call c()

如果 c 函式是經由例外處理這條路徑回到了 a ( 也就是 c 拋出例外,而且 b 沒有例外處理區域,而 a 是有例外處理區域的話 ),可以而知的是 c  跟 b 結尾的程式(用編譯器的行話來說叫epilogue) 並沒有機會被呼叫到,也就是說,如果發生這樣的狀況,我們如果想要進行堆疊的回復動作 (Stack Unwinding) 就必須要經過特殊處理了。在上一篇中,我們已經談過了 GCC 的 C++ 編譯器如何使用 C 函式庫中,專門用來處理非本地跳躍的函式 Setjmp/Longjmp 來處理這個問題,而這一篇,我們將會討論GCC 的 C++ 編譯器使用的第二種處理機制,也就是利用 Dawrf2 中的 debug informantion 來處理這個問題。

那如何利用 debug informantion 來進行 Stack Unwinding 呢?簡單來說,Dawrf2 的除錯資訊當中裡面有一個欄位是專門用來提供frame相關的資訊稱為CFI (Call Frame Infomation),編譯器當然也準備好每個函式Frame相關的操作如 push register 和 stack / frame pointer 的調整並編碼成debug informantion 存到執行檔當中。 所以當例外發生的時候,C++ Runtime Library 就能解析這個除錯資訊來了解究竟在函式開頭的時候編譯器做了些什麼事,並且把必要回復的資訊恢復回來。由於GCC 的 C++ 編譯器是使用 langauge-specific data area (LSDA) 這個欄位來儲存這部份的資料並且使用了LEB128編碼來進行壓縮。附帶一提的是,由於這種編碼方式的資料存取的時候是 Unaligned,縱使是硬體有支援 Unaligned access 還是會降低存取的效率。而且由於大部分的函式都是aligned,可是這種狀況會讓函式變成Unaligned ,如果dynmaic loader在設計的時候沒有考慮到這一點,在沒有硬體沒有支援Unaligned access 的處理器上,可能就會引發異常了。 

接下來切入實作部份,上一篇中已經將 GCC C++ Compiler 例外處理的應用程式介面(API) 和 C++程式碼中的 try, throw, catch 區塊和表示式對應的程式碼說明了一次。由於對 GCC C++ Compiler 例外處理的機制來說,使用Dwarf2 Exception Handling 來進行例外處理是不會影響大流程的,只有 Unwinding 的部分是會受到使用Setjmp/Longjmp 或Dwarf2 Exception Handling 影響。基本上來說,兩種例外處理機制並不是相容的,為了區別使用兩種例外處理機制的二進位並且讓他們可以在共用函式庫內共存,兩種機制使用的例外處理API是稍有不同的 ─ 在_Unwind開頭的API中,他們使用了名字相似但不同的函式,這個處理方式也能讓autotool之流的工具可以方便的辨認他們的編譯器是使用哪種例外處理的機制。以下為兩種機制使用的例外處理API的名稱。

Dwarf2 (Call Frame)

Setjmp/Longjmp

_Unwind_RaiseException

_Unwind_SjLj_RaiseException

_Unwind_ForcedUnwind

_Unwind_SjLj_ForcedUnwind

_Unwind_Resume  

_Unwind_SjLj_Resume

_Unwind_Resume_or_Rethrow

_Unwind_SjLj_Resume_or_Rethrow

例外處理的三個關鍵字中,以 throw 的處理最複雜,也就是在throw 轉換控制權到catch區塊的時候需要進行Stack Unwind並且進行非本地的跳轉,上一篇中也提到了主要處理throw這個關鍵字語意的例外處理函式就是 _Unwind_RaiseException。其實_Unwind_RaiseException的處理方式也跟SjLj的版本差不多,有比較大差異的地方大概就是尋找handler 的方式 和 跳轉至例外處理函式的方式。基本上尋找handler是利用PC值去Unwind Table查表來尋找例外處理函式,而比較複雜的是跳轉的部分,他用了一個GCC builtin函式 __builtin_eh_return

do                                                                     
  {                                                                    
    long offset = uw_install_context_1 ((CURRENT), (TARGET));          
    void *handler = __builtin_frob_return_addr ((TARGET)->ra); 
    __builtin_eh_return (offset, handler); 
  }                                                                   
while (0)

以上的程式是_Unwind_RaiseException用來返回例外處理函式的程式碼,uw_install_context_1 是用來恢復handler的暫存器資訊並且計算 Stack Pointer 需要調整的值,第二個函式是用來取得 handler 的位址。第三部份是 __builtin_eh_return ,由於它是一個GCC平台相關的內建函式,所以每個平台的實作稍有不同。大部分平台採取的實作方式是利用一個被大部分的RTOS用來進行 Context Switch 時候技法 ─ 在Stack中更新 pushed return address ,也就是說函式返回的時候就可以自動進行跳轉,然後返回前必須修正Stack Pointer的值,這樣控制權轉移到正確的地方的時候 (某個catch block),這時堆疊中的值也是會正確的了。