本文譯自Fabrice Bellard大神的文章《QEMU, a Fast and Portable Dynamic Translator》,如有翻譯不當之處,請斧正。
摘要
在本文中,我們將展示QEMU的內部機制。QEMU是一個快速的機器模擬器,它使用了獨創的可移植動態翻譯器。QEMU可以在若干種宿主機(x86,PowerPC,ARM以及Sparc)上模擬若干種CPU(x86,PowerPC,ARM以及Sparc)。它支持完全系統模擬和Linux用戶模式模擬。對於完全系統模擬,完整且不經修改的操作系統可以運行在虛擬機之上;而對於Linux用戶模式模擬,其意味着一個為某種目標CPU編譯的Linux進程可以運行在另一種CPU之上。
1 緒論
QEMU是一種機器模擬器:它能使未經修改的目標操作系統(比如Windows或者Linux)及其所有的應用程序運行在虛擬機之上。QEMU自身運行在多種宿主操作系統之上,比如Linux,Windows以及Mac OS X。此外,宿主機和目標機的CPU可以不同。
QEMU的主要用途為在某個操作系統之上運行另一個操作系統,比如在Linux上運行Windows,或者在Windows上運行Linux。由於虛擬機易於關閉並且其狀態可以被探測、保存與恢復,所以QEMU的另一個用途便是調試。除此之外,通過添加新的機器描述和模擬設備,可以模擬出一些特殊的嵌入式設備。
QEMU還集成了一個Linux特殊用戶模式模擬器。它使為某個目標CPU編譯的Linux進程可以運行在其他CPU之上。有了這樣一個模擬器,我們不必啟動一個完整的虛擬機就可以測試交叉編譯結果是否正確或者測試CPU模擬器是否功能正確。
QEMU由若干子系統組成:
- CPU模擬器(目前支持x86,PowerPC,ARM以及Sparc)
- 模擬設備(比如VGA顯示器,16450串口,PS/2鼠標和鍵盤,IDE硬盤,NE2000網卡,等等)
- 通用設備(比如塊設備,字符設備,網絡設備),這些設備用來連接模擬設備與宿主機中相應的設備
- 用於實例化模擬設備的機器描述(比如PC,PowerMac,Sun4m)
- 調試器
- 用戶接口
本文闡述了QEMU中所使用的動態翻譯器是如何實現的。動態翻譯器在運行時將目標CPU指令轉換為宿主機CPU指令。將翻譯后得到的二進制代碼保存到翻譯緩存中,使之能夠被重用。與解釋器相比,動態翻譯器的優勢在於,提取和解碼目標指令操作只需一次。
通常,由於需要重寫整個代碼生成器,動態翻譯器很難從某一個宿主機移植到另一個宿主機。這意味着其工作量相當於添加一個新的目標機器指令集到C編譯器中。然而,QEMU就簡便得多,它僅僅是把一系列由GCC離線生成的機器碼片段鏈接起來。
一個CPU模擬器還需要面臨其他更經典但是更困難的問題:
- 翻譯代碼緩存管理
- 寄存器分配
- 直接塊鏈接
- 內存管理
- 支持代碼自修改
- 支持異常處理
- 硬件中斷
- 用戶模式模擬
2 可移植動態翻譯器
2.1 Description
第一步,將每一條目標CPU指令拆分成更小更簡單的指令---微操作。每一個微操作由一小段C代碼實現。這一小段C代碼被GCC編譯成一個目標文件。我們選擇微操作的原因在於它們的數目比目標CPU中所有指令與操作的組合數要小得多(通常為幾百)。將目標CPU指令翻譯為微操作的工作完全通過手動編碼實現。為了追求可讀性和簡潔性,可以對源代碼進行優化,因為這個階段對速度的要求並沒有解釋器那么嚴格。
dyngen是一個編譯時工具,它將包含微操作的目標文件作為輸入,用以生成一個動態代碼生成器。動態代碼生成器在運行時被調用,生成一個完整的鏈接了若干微操作的宿主機函數。
這個工具的做法同[1]類似,但為了獲得更好地性能,在編譯時又做了更多工作。特別地,在QEMU中有一個關鍵思想,常量參數可以被傳遞給微操作。為了達到這個目的,針對每個常量參數都使用GCC對偽代碼進行重定位。這使得dyngen可以對偽代碼進行重定位,並在創建動態代碼的時候生成適當的C代碼來解決這些問題。重定位還支持對靜態數據和其他微操作中函數的引用。
2.2 Example
這里有一個例子,我們需要把下面的PowerPC指令轉換為x86代碼:
addl r1,r1,-16 # r1 = r1-16
PowerPC代碼翻譯器將會產生如下微操作:
movl_T0_r1 # T0 = r1
addl_T0_im -16 # T0 = T0 - 16
movl_r1_T0 # r1 = T0
在微操作的數目減到最小的同時,並沒有對生成代碼的質量產生負面影響。例如,我們只是生成從一些臨時寄存器之間的move操作,而不是32個PowerPC之間所有可能得move操作。通過使用GCC靜態寄存器變量擴展,這些T0,T1,T2臨時寄存器通常被指定為宿主機寄存器。
微操作movl _T0_ r1通常由如下代碼實現:
void op_movl_T0_r1(void)
{
T0 = env->regs[1];
}
env是一個包含了目標CPU狀態的結構體。32個PowerPC寄存器被保存在數組env->regs[32]中。
addl_T0_im 更有意思,因為它使用了一個常量參數。該常量參數的數值在運行時確定。
extern int __op_param1;
void op_addl_T0_im(void)
{
T0 = T0 + ((long)(&__op_param1));
}
dyngen生成的代碼生成器提取由opc_ptr指定的微操作流,輸出gen_code_ptr位置的宿主機代碼。微操作參數由opparameter_ptr指定:
[...]
for(;;) {
switch(*opc_ptr++) {
[...]
case INDEX_op_movl_T0_r1:
{
extern void op_movl_T0_r1();
memcpy(gen_code_ptr,
(char *)&op_movl_T0_r1+0,
3);
gen_code_ptr += 3;
break;
}
case INDEX_op_addl_T0_im:
{
long param1;
extern void op_addl_T0_im();
memcpy(gen_code_ptr,
(char *)&op_addl_T0_im+0,
6);
param1 = *opparam_ptr++;
*(uint32_t *)(gen_code_ptr + 2) =
param1;
gen_code_ptr += 6;
break;
}
[...]
}
}
[...]
}
對大多數微操作比如movl_T0_r1而言,GCC生成的宿主機代碼僅僅需要拷貝即可。當需要使用常量參數時,dyngen實際上是這樣處理的:GCC對__op_param1進行重定位,使用運行時參數對需要生成的代碼打補丁。
當代碼生成器在運行的時候,將會輸出如下宿主機代碼:
# movl_T0_r1
# ebx = env->regs[1]
mov 0x4(%ebp),%ebx
# addl_T0_im -16
# ebx = ebx - 16
add $0xfffffff0,%ebx
# movl_r1_T0
# env->regs[1] = ebx
mov %ebx,0x4(%ebp)
在x86機器上,T0將會被映射為ebx寄存器,CPU狀態上下文將會被映射到ebp寄存器。
2.3 Dyngen implementation
QEMU翻譯的關鍵是dyngen。在使用dyngen處理包含有微操作的目標文件時,需要完成如下任務:
- 目標文件(包含微操作)將被解析以獲取它的符號表,重定位入口點,及其代碼段。這個過程依賴於宿主機目標文件格式(dyngen支持ELF(Linux),PE-COFF(Windows),MACH-O(Mac OS X))。
- 微操作位於代碼段中,代碼段使用符號表。有一個特殊的宿主機方法用於獲取拷貝代碼的起始點與結束點。通常,函數prologue與epilogue會被忽略。
- 檢查每個微操作的重定位,獲取常量參數的個數。通過使用特殊符號__op_paramN來檢測常量參數重定位。
- C代碼中的內存拷貝函數用來拷貝微操作。對每個微操作的代碼進行重定位,以此對拷貝的代碼進行打補丁。如此便可使之被恰當地重定位。此外,重定位操作由宿主機定義。
- 對一些宿主機例如ARM而言,常量必須存放在生成代碼附近,因為需要通過相對位置訪問他們。宿主機通過一個特殊程序對生成代碼中的常量進行重定位。
當編譯微操作時,使用一系列GCC標志來操作函數prologue和epilogue代碼的生成,使之生成的形式易於解析。一個偽匯編宏強制GCC對每個微操作相關的函數編譯產生以唯一指令。如果若干輸出指令由單一微操作生成,代碼將鏈接失敗。
3 實現細節
3.1 Translated Blocks and Translation Cache
當QEMU第一次取得一段目標機器碼時,它將其翻譯為宿主機代碼。然后鏈接后續的跳轉代碼或指令。通過該指令,可以通過一種翻譯時無法推導出的方式修改靜態CPU狀態。我們把這種基本模塊叫做Translated Blocks(TBs)。
一個16MB的cache保存最近最常使用的TBs。簡單起見,當該緩存用滿時,它將被清空。
靜態CPU狀態就是在編譯過程中進入TB時已經知道的那部分CPU狀態。例如,編譯時,所有目標機器上的PC(程序計數器)是已知的。在x86機器上,靜態CPU狀態包括更多可以用來產生更優代碼的數據。例如,很重要的是,知曉CPU是處於保護模式、實模式、用戶模式還是核心模式,同時知道默認指令大小為16位還是32位。
3.2 Register allocation
QEMU使用固定的寄存器分配機制。這意味着每個目標CPU寄存器將被映射到宿主機固定的寄存器或內存地址。在大多數宿主機上,我們只是簡單地將目標機器寄存器映射到宿主機內存,並且只是保存一些臨時變量到宿主機寄存器中。臨時變量的分配情況在目標機描述文件中指定。這種方法的好處在於簡潔且可移植性好。
在QEMU未來的版本中,將會使用一個動態的臨時寄存器分配器,把目標寄存器直接保存在宿主機寄存器中,以避免一些不必要的move操作。
3.3 Condition code optimizations
要想獲得良好的性能,則必須有良好的CPU條件碼模擬(eflags register on x86)。QEMU使用惰性條件碼估值:它只保存某個指令中的源操作數(CC_SRC),目標操作數(CC_DST)和操作類型(CC_OP),而不是在每條x86指令執行之后計算條件碼。對於32位的加法計算,例如R=A+B,我們可以得到如下表達式:
CC_SRC=A
CC_DST=R
CC_OP=CC_OP_ADDL
由於可以通過存放在CC_OP中的常數知道有一個32位的加法,我們可以通過CC_SRC和CC_DST恢復A,B和R。然后,如果下一條指令需要,所有相關的條件碼,例如零結果(ZF),非負結果(SF),進位(CF)或者溢出(OF)都可以很容易獲得。
由於一個完整TB代碼會在一段時間內生成,條件碼估算可以在翻譯時得到進一步優化。在生成的代碼上,有一個看似保守的過程,用於檢測CC_OP,CC_SRC或者CC_DST是否未被
后續代碼使用。在TB結尾,我們認為這些變量已被使用了。然后,我們刪除那些后續代碼不再使用的變量。
3.4 Direct block chaning
在每個TB執行之后,QEMU通過一個hash表使用模擬PC以及靜態CPU狀態中的其他信息,來查找下一個TB。如果下一個TB還未翻譯,那么將啟動一個新的翻譯進程。否則,跳轉到下一個TB的操作到此為止。
為了在一些很普通場景(新的模擬PC值已知,比如條件跳轉指令)下進行加速,QEMU可以對TB打補丁程序,以致其能直接跳轉到下一個TB。
移植性最好的代碼使用非直接跳轉。在某些宿主機(例如x86或者PowerPC)上,將會直接附加一個子程序調用指令,以免block鏈產生開銷。
3.5 Memory management
對於系統模擬,QEMU使用mmap()這個系統調用來模擬目標機器MMU(內存管理單元)。只要模擬操作系統沒有使用宿主機操作系統占用的內存區域,這個模擬MMU都會正常工作。
為了能夠啟動任何操作系統,QEMU還支持一個軟件MMU。在這種模式下,每一次內存訪問都要進行MMU虛擬地址到物理地址的轉換。QEMU通過使用地址轉換緩存來加速轉換。
為了避免每次MMU映射改變時都清空地址轉換緩存,QEMU使用了一個物理索引轉換緩存。這意味着每個TB用它的物理地址進行索引。
當MMU映射改變時,因為跳轉目標的物理地址可能會改變,索引TB鏈被重置(例如,將不能從一個TB直接跳轉到另一個TB)。
3.6 Self-modifying code and translated code invalidation
在大多數CPU上,自修改代碼很容易處理。通過執行一條特殊的代碼緩存廢棄指令,可以發出信號指示出該代碼已被修改。這足以廢棄相應的翻譯代碼。
然而,在一些CPU例如x86上,當代碼被修改時,應用程序不能發出信號以廢棄指令緩存。所以,自修改代碼是一個特殊的挑戰。
當生成了一個TB的翻譯代碼時,如果相應的宿主機頁不是只讀的,那么它將會被設置為寫保護。如果有一個針對該頁的寫訪問產生,QEMU會廢棄該頁中所有的翻譯代碼,並使該頁重置為可寫。
通過維護一個包含給定頁中所有TB的鏈表,可以有效完成正翻譯代碼的廢棄任務。除此之外,還有其他鏈表用來取消直接block鏈。
當使用軟件MMU時,代碼廢棄將更加高效:如果某個代碼頁由於寫訪問而頻繁做廢棄代碼操作,將會創建一個展示該頁內部代碼的bitmap。每次往該頁的存儲操作都將檢查bitmap,以知曉該頁的代碼是否需要廢棄。這避免了該頁僅作數據修改時就進行代碼廢棄操作。
3.7 Exception support
當發生異常例如除零操作時,使用longjmp()跳轉到異常處理代碼。沒有使用軟件MMU時,宿主機信號處理器被用來捕獲無效內存訪問。
QEMU支持精確異常,這意味着在異常發生時可以獲取目標CPU的精確狀態。目標CPU的大多數狀態都會被翻譯代碼顯式存儲與修改。對於那些未被顯式存儲的目標CPU狀態S(例如當前程序計數器),它們將通過重翻譯TB獲取。在這個TB中發生了異常,並且狀態S在每條目標機器指令翻譯之前已被記錄。發生了異常的宿主機程序計數器被用於查找相關目標機器指令以及狀態S。
3.8 Hardware interrupts
為了運行速度更快,QEMU並不在每個TB中檢測硬件中斷是否處於未處理狀態。相反,用戶必須異步調用一個特殊的函數以獲知某個中斷未處理。該函數重置正在執行的TB鏈。這確保正在執行的TB鏈可以從CPU模擬器的主循環中立即返回。然后主循環會測試是否有某個中斷未處理,並對處理該中斷。
3.9 User mode emulation
為了能夠讓針對某種CPU編譯的Linux進程可以運行在另一種CPU上,QEMU還支持用戶模式模擬。
在CPU級別,用戶模式模擬僅僅是完全系統模擬的一個子集。因為QEMU假定用戶內存映射是由宿主機操作系統處理的,所以用戶模式模擬並沒有MMU模擬。QEMU包含了一個用於處理字節序問題和32/64bit轉換的通用Linux系統調用轉換器。因為QEMU支持異常處理,所以它顯然也模擬了目標機器的信號機制。每個目標機器的線程以宿主機線程的形式運行。
4 移植工作
為了將QEMU移植到新的CPU宿主機上,需要完成以下事情:
- 必須移植dyngen
- 為了優化性能,微操作所使用的臨時變量可以被映射到宿主機中某些特定的寄存器中。
- 為了維持指令緩存與內存的一致性,大多數宿主機CPU需要特殊指令的支持。
- 如果直接block鏈由分支指令實現,需要提供某些特殊的匯編宏。
QEMU整個移植工作的復雜度估計和動態連接器相當。
5 性能
為了測試因模擬而帶來的系統開銷,我們比較了BYTEmark benchmark在x86 Linux系統宿主機本地模式下的性能與x86目標機器用戶模式模擬下的性能。
經測試,就整型代碼(定點運算)而言,QEMU用戶模式比本地代碼模式慢四倍;就浮點代碼(浮點運算)而言,用戶模式則要慢十倍。可以理解的是,這個結果是由x86靜態CPU狀態缺少FPU棧指針而導致的。
對於完全系統模擬,QEMU比Bochs[4]幾乎快了30倍。
而用戶模式的QEMU則比valgrind –skin=none version 1.9.6[6]快了1.2倍。后者是一個手動編碼的x86 to x86的動態翻譯器,通常用於調試程序。--skin none選項確保Valgrind不會產生調試代碼。
6 結論及未來的工作
QEMU已經可以用於日常工作,特別是商業x86操作系統(如Windows)的模擬。使用PowerPC作為目標機器,在其上幾乎已經可以啟動Mac OS X。此外,在Sparc目標機器上,已經可以啟動Linux。目前還沒有那個動態翻譯器可以在如此多的宿主機上支持如此多的目標機器,這主要還是因為其他動態翻譯器的移植復雜度難以估量。而QEMU似乎在性能與復雜度之間做到了很好的平衡。
未來仍需要處理如下問題:
移植:QEMU已經很好地支持PowerPC和x86宿主機。其他宿主機(Sparc,Alpha,ARM,MIPS等)上的QEMU還需要進一步的工作。此外,QEM對用於編譯微操作C代碼的GCC為哪個版本有很大依賴。
完整系統模擬:ARM和MIPS目標機還未添加支持。(目前已經支持,我之前使用QEMU模擬過ARMv5處理器)
性能:軟件MMU的性能還有提升空間。一些緊要的微操作使用匯編語言手動編碼,而不是在當前的翻譯框架中做大量修改。同時,CPU主循環也可以用匯編語言手動編碼。
虛擬化:當宿主機和目標機器CPU相同時,可以在目標機上運行大多數代碼。最簡單的實現方法是,像通常一樣模擬目標機器內核代碼,但在目標機器上運行用戶代碼。
調試:可以添加緩存模擬和循環計數器,得到類似SIMICS[3]中的調試器。
7 Availability
目前可以通過http://bellard.org/qemu獲取可用的QEMU。
參考文獻
[1] Ian Piumarta, Fabio Riccardi, Optimizing direct threaded code by
selective inlining, Proceedings of the 1998 ACM SIGPLAN Conference
on Programming Language Design and Implementation
(PLDI).
[2] Mark Probst, Fast Machine-Adaptable Dynamic binary Translation,
Workshop on Binary Translation 2001.
[3] Peter S. Magnusson et al., SimICS/sun4m: A Virtual Workstation,
Usenix Annual Technical Conference, June 15-18, 1998.
[4] Kevin Lawton et al., the Bochs IA-32 Emulator Project,
http://bochs.sourceforge.net.
[5] The Free Software Foundation, the GNU Compiler Collection,
http://gcc.gnu.org.
[6] Julian Seward et al., Valgrind, an open-source memory debugger
for x86-GNU/Linux, http://valgrind.kde.org/.
[7] The BYTEmark benchmark program, BYTE Magazine, Linux version
available at
http://www.tux.org/˜mayer/linux/bmark.html.