一、前言

最近對于虛擬化技術在操作系統研究以及在二進制逆向/漏洞分析上的能力很感興趣。看雪最近兩年的sdc也都有議題和虛擬化相關(只不過出于性能的考慮用的是kvm而不是tcg):

2021年的是"基于Qemu/kvm硬件加速下一代安全對抗平臺"

2022年的是"基于硬件虛擬化技術的新一代二進制分析利器"

跨平臺模擬執行unicorn框架和qiling框架都是基于qemu的tcg,本文的內容就是描述一下qemu tcg與unicorn的原理。

TCG的英文含義是Tiny Code Generator, Qemu可以在沒開啟硬件虛擬化支持的時候實現全系統的虛擬化,Qemu結合下面幾種技術共同實現虛擬化:

1.soft tlb / Softmmu/內存模擬

2.虛擬中斷控制器/中斷模擬

3.總線/設備模擬

4.TCG的CPU模擬

qemu進程代表著一個完整的虛擬機在運行,它沒有特殊的權限卻能正常的運行各種操作系統如windows/linux等,在沒有硬件虛擬化支持的時候靠的最主要的角色就是TCG,它滿足了Popek/Goldberg對于虛擬化的三大要求:

1.等價性

2.安全性

3.性能

https://en.wikipedia.org/wiki/Popek_and_Goldberg_virtualization_requirements

后來出現了硬件支持的虛擬化,kvm因此成為主流,在云平臺上運行的虛擬化都是有硬件支持的,但是TCG卻仍然是不可替代的,因為硬件虛擬化只能在源ISA和目標ISA都相同的情況下才能工作(比如在x86平臺虛擬化x86操作系統,或者在arm平臺下虛擬化arm操作系統),而如果源ISA和目標ISA不同的情況下如在x86平臺運行arm操作系統,只能靠TCG實現。

學習TCG的好處:

1.可以理解像libhoudini.so這樣的轉碼技術是如何實現的。

2.對理解應用層的虛擬機如java虛擬機中的jit技術很有幫助。

3.可以幫助理解cpu包括整個計算機體系結構是如何工作的。

4.可以幫助理解和定制二進制分析框架如unicorn/qiling,因為它們都是基于TCG。

5.某些vmp是基于unicorn來實現的,理解TCG可以基于此實現自己的vmp/加深對vmp的理解。

二、QEMU TCG

1. DBT

TCG本質上屬于DBT,即dynamic binary translation動態二進制轉換,相應的還有SBT,即static binary translation靜態二進制轉換。拿Android平臺舉例, SBT就相當于ART虛擬機中的AOT(ahead-of-time compilation),而DBT就相當于ART虛擬機中的JIT(Just-In-Time compilation)。

假如想在x86平臺運行arm程序,稱arm為source ISA, 而x86為target ISA, 在虛擬化的角度來說arm就是Guest, x86為Host。

最簡單的解決方案是實現一個解釋器,在一個循環中不斷的讀入arm程序指令,反匯編并用代碼去模擬指令的執行。但是解釋器的問題在于性能太低,后來就出來了DBT技術(QEMU也有解釋器模塊,具體搜索CONFIG_TCG_INTERPRETER),它也需要讀入arm程序指令并進行反匯編,不過接下來流程會進入即時編譯環節,將arm指令轉換成x86指令,最終執行的時候會直接跳轉到轉換過的x86指令執行,得到媲美于本地執行的性能。

DBT和JIT這兩個名詞經常可以互換使用,不過我的理解是JIT環境中的輸入是特意被設計過的可被模擬的指令格式(更多的是高層虛擬機如java虛擬機中的字節碼),而DBT的輸入則是不同平臺的ISA指令。

對于虛擬化來說,可以采用SBT將Guest代碼事先編譯好然后直接運行嗎? 對于模擬某些ISA如x86來說會遇到問題,因為x86的指令是不定長的,和反匯編器會遇到的問題一樣,有時候是無法準確區分出哪些是數據哪些是指令,當遇到一些運行時才知道目標的跳轉指令,SBT技術會遇到問題。這種問題被稱為Code-Discovery Problem。

而DBT則不會有此問題,以下稱source ISA中的pc指針為SPC, target ISA中的pc指針為TPC, 對于模擬一個arm系統來說,arm系統剛上電cpu會從物理地址0從開始執行,此時SPC=0, 假設此處的指令為"mov r0, #0", 而經過DBT轉換以后,轉換的代碼位于Qemu進程的虛擬地址0x7fbdd0000100處,此時的TPC=0x7fbdd0000100, 轉換后的指令為x86指令

"movl $0, (%rbp)",DBT技術中會實時記錄SPC與TPC的關系,遇到跳轉指令的時候可以得到跳轉指令的目標地址,因此不會有SBT中的問題。

2.QEMU IR

類似于LLVM,QEMU也定義有自己的IR:

https://www.qemu.org/docs/master/devel/tcg-ops.html

轉換過程如下:

 

引入IR的好處自然是當引入一種新的source ISA的時候,只需要完成source binary code到IR的轉換,IR到target binary code直接用現成的即可。

從上面QEMU IR的鏈接中可知,QEMU IR的指令主要分為函數調用指令、跳轉指令、算術指令、邏輯指令、條件移動指令、類型轉換指令、加載/存儲指令等構成,那么問題來了,僅靠固定的IR是無法模擬所有的ISA指令的,比如x86架構的cpuid指令并沒有與之對應的IR,遇到這種指令如何生成對應的IR?

這就涉及到執行上下文的概念,QEMU本身是Host上一個普通的進程,運行在QEMU上下文,而執行轉換后的目標代碼則運行在虛擬機上下文,當運行在虛擬機上下文的程序遇到一些條件時會退出至QEMU上下文處理,像在arm平臺執行cpuid指令就是這種情況,需要生成IR調用QEMU中的helper函數來模擬cpuid指令,模擬完了再回退到虛擬機上下文去執行。每個體系結構對應的helper函數在target/xxx/helper.h頭文件中定義。

include/tcg/tcg-op.h文件聲明了在實現一個生成IR的前端時可以調用的一些函數,這些函數以tcggen開頭。

3.Basic Block/Translation Block

TCG的二進制轉換是以塊為基本單元,即Basic Block,當Guest指令遇到下面幾種情況時會被分割成一個Basic Block:

1.遇到分支指令

2.遇到系統調用

3.達到頁邊界/最大長度限制

而TranslationBlock是QEMU中用來表示轉換過的Host指令的數據結構(以下簡稱TB),執行時的基本控制流程如下:

 

QEMU TCG Engine運行在QEMU上下文,當一個Basic Block被轉換成Tranlated Block以后,QEMU可以直接跳轉過去以虛擬化上下文去執行,這種跳轉是以函數調用的形式來實現的,因此還需要執行一些prologue"前言"代碼來保存函數調用時的信息,需要切換回TCG上下文時需要執行一些epilogue"序言"代碼來恢復函數調用前的信息。

拿x86_64平臺舉例,每次執行上下文切換需要執行大約20條指令(指令還會進行內存的讀寫),因此DBT的優化措施之一就是減少上下文切換,實現TB之間的直接鏈接,這種優化措施稱為Direct block chaining:

 

這種優化措施可以顯著的增加性能,但是這種優化方式還需要解決自修改代碼引發的問題,在收到硬件中斷時還需要快速的返回至QEMU上下文處理,等后面具體分析代碼的時候會描述。

不過chained tb有一個限制: 兩個chained tb對應的guest指令需要在同一個guest的page里。

將指令分割為Basic Block的一個主要原因是TB的緩存機制,當一個Basic Block被DBT轉換為TB以后,下次再執行到相同的Basic Block直接從緩存中獲取TB執行即可,無需再經過轉換:

4.代碼環境


以x86_64平臺上運行一段arm程序做為研究對象,使用的QEMU源碼分支為stable-8.0。

需要準備三個文件startup.s, test.c,test.ld:

 

startup.s文件內容:

.global _Reset
_Reset:
 LDR sp, =stack_top
 BL c_entry
 B .

test.c文件內容:

volatile unsigned int * const UART0DR = (unsigned int *)0x101f1000;
void print_uart0(const char *s) {
    while(*s != '\0') { /* Loop until end of string */
        *UART0DR = (unsigned int)(*s); /* Transmit char */
        s++; /* Next char */
    }
}
void c_entry() {
    print_uart0("Hello world!");
}

test.ld文件內容:

ENTRY(_Reset)
SECTIONS
{
 . = 0x10000;
 .startup . : { startup.o(.text) }
 .text : { *(.text) }
 .data : { *(.data) }
 .bss : { *(.bss COMMON) }
 . = ALIGN(8);
 . = . + 0x1000; /* 4kB of stack memory */
 stack_top = .;
}

編譯:

arm-none-eabi-gcc -c -mcpu=arm926ej-s -g test.c -o test.o
arm-none-eabi-as -mcpu=arm926ej-s -g startup.s -o startup.o
arm-none-eabi-ld -T test.ld test.o startup.o -o test.elf
arm-none-eabi-objcopy -O binary test.elf test.bin

以上代碼的用途是往串口0x101f1000處寫入Hello World,代碼的鏈接地址為0x10000,它期望被加載到物理內存的地址也是0x10000,很多arm機器將內核加載至此。

啟動:

qemu-system-arm -M versatilepb -m 128 -kernel test.bin -nographic

會在屏幕上打印出Hello World!,此時退出QEMU的快捷鍵為Ctrl+A X。

qemu-system-arm程序是由QEMU源碼編譯出來的,-M versatilepb表示模擬的arm硬件為versatilepb(Arm Versatile boards),-m 128參數表示指定的機器內存為128M, -kernel參數為QEMU的Direct Linux Boot機制,由QEMU而不是磁盤上的Bootloade來將內核加載至內存。這種情況下啟動,arm會從物理地址0開始執行,事實上0地址處是qemu實現的一小段bootloader,只是用來將控制跳轉到0x10000內核處執行(test.bin),代碼在hw/arm/boot.c文件中:

/* A very small bootloader: call the board-setup code (if needed),
 * set r0-r2, then jump to the kernel.
 * If we're not calling boot setup code then we don't copy across
 * the first BOOTLOADER_NO_BOARD_SETUP_OFFSET insns in this array.
 */
static const ARMInsnFixup bootloader[] = {
    { 0xe28fe004 }, /* add     lr, pc, #4 */
    { 0xe51ff004 }, /* ldr     pc, [pc, #-4] */
    { 0, FIXUP_BOARD_SETUP },
#define BOOTLOADER_NO_BOARD_SETUP_OFFSET 3
    { 0xe3a00000 }, /* mov     r0, #0 */
    { 0xe59f1004 }, /* ldr     r1, [pc, #4] */
    { 0xe59f2004 }, /* ldr     r2, [pc, #4] */
    { 0xe59ff004 }, /* ldr     pc, [pc, #4] */
    { 0, FIXUP_BOARDID },
    { 0, FIXUP_ARGPTR_LO },
    { 0, FIXUP_ENTRYPOINT_LO },
    { 0, FIXUP_TERMINATOR }
};

5.打印出TCG轉換的文件

從qemu 7.1開始反匯編引擎已經替換為Capstone,因此需要安裝capstone:

sudo apt install libcapstone-dev

qemu提供了一些調試手段可以顯示出TCG轉換過程的內容:

qemu-system-arm -M versatilepb -m 128 -kernel test.bin -nographic -d in_asm -D in_asm.txt
qemu-system-arm -M versatilepb -m 128 -kernel test.bin -nographic -d op -D op.txt
qemu-system-arm -M versatilepb -m 128 -kernel test.bin -nographic -d out_asm -D out_asm.txt

in_asm.txt為arm反匯編程序的結果

op.txt為生成的IR指令的內容

out_asm為轉換后的Host指令的內容

分析TCG的時候,由于它擁有全系統虛擬化的能力,因此需要思考如下幾種情況是如何實現的:

1.普通算術邏輯運算指令如何更新Host體系結構相關寄存器

2.內存讀寫如何處理

3.分支指令(條件跳轉、非條件跳轉、返回指令)

4.目標機器沒有的指令、特權指令、敏感指令

5.非普通內存讀寫如設備寄存器訪問MMIO

6.指令執行出現了同步異常如何處理(如系統調用)

7.硬件中斷如何處理

6.TCG相關數據結構

qemu中一個tcg線程可以模擬多個vcpu,也可以多個tcg線程每個對應模擬一個vcpu,后者稱為Multi-Threaded TCG (MTTCG),是否為MTTCG由全局變量bool mttcg_enabled決定。對于此處的示例MTTCG是開啟狀態,不過簡單起見這里假設機器只有一個vcpu。

先來看一下TCG的一些重要數據結構:

1.TranslationBlock:

顧名思義,存放編譯后的TB相關信息,包括指向目標機器執行碼的指針

2.CPUArchState:

由于是模擬的cpu,因此存放著體系結構的cpu信息,比如對于arm平臺它定義在target/arm/cpu.h文件中,成員包括所有通用寄存器以及狀態碼等模擬cpu硬件所必需的信息。

3.TCGContext:

存放tcg中間存儲數據的結構體,包括轉換后的IR, tcg的核心就是圍繞此結構展開,前端IR以TCGOp列表的形式存放在TCGContext的ops對象中

4.TCGTemp:

對應于tcg IR中的變量,存放在TCGContext的temps數組中,變量有幾種不同的作用域類型。

tcg_temp_new_internal分配TEMP_EBB, TEMP_TB類型的TCGTemp變量

tcg_global_alloc分配TEMP_GLOBAL類型的TCGTemp變量

tcg_global_reg_new_internal分配TEMP_FIXED類型的TCGTemp變量

tcg_constant_internal分配TEMP_CONST類型的TCGTemp變量

TCPTemp每個類型的含義如下:

typedef enum TCGTempKind {
    /*
     * Temp is dead at the end of the extended basic block (EBB),
     * the single-entry multiple-exit region that falls through
     * conditional branches.
     */
    TEMP_EBB,
    /* Temp is live across the entire translation block, but dead at end. */
    TEMP_TB,
    /* Temp is live across the entire translation block, and between them. */
    TEMP_GLOBAL,
    /* Temp is in a fixed register. */
    TEMP_FIXED,
    /* Temp is a fixed constant. */
    TEMP_CONST,
} TCGTempKind;


那么編譯后的Host代碼存放在哪里?先看一下這幅圖:

在qemu啟動的早期會執行一個函數叫tcg_init_machine

在這個函數中會調用qemu_memfd_create()函數創建出一個匿名文件,該匿名文件的大小是根據當前Host機器的物理內存計算出來的,比如我的電腦是64G,最終計算出來的匿名文件大小為1G。

然后對匿名文件做兩次映射,一次映射為讀寫:(PROT_READ | PROT_WRITE),稱之為buf_rw

一次映射為寫執行(PROT_READ | PROT_EXEC),稱之為buf_rx,buf_rw和buf_rx之間的差值由全局變量tcg_splitwx_diff表示。

tcg在翻譯代碼的過程中會利用buf_rw寫這1G的空間,而執行的過程中則依賴于buf_rx在這1G的空間中執行代碼。由于buf_rw和buf_rx映射的是同一個文件且指定了MAP_SHARED參數,因此對buf_rw做出的修改會在buf_rx的空間可見。

tcg_init_machine函數還會調用tcg_target_qemu_prologue函數創建出對應于Host的prologue和epilogue,并且分別由全局變量tcg_qemu_tb_exectcg_code_gen_epilogue指向(如上圖)。

對于Host為x86_64來說,它的prologue如下:

//保存callee需要保存的寄存器
0x7fffac000000:  55                       pushq    %rbp
0x7fffac000001:  53                       pushq    %rbx
0x7fffac000002:  41 54                    pushq    %r12
0x7fffac000004:  41 55                    pushq    %r13
0x7fffac000006:  41 56                    pushq    %r14
0x7fffac000008:  41 57                    pushq    %r15
//第一個參數賦值給%rbp
0x7fffac00000a:  48 8b ef                 movq     %rdi, %rbp
//預留棧空間
0x7fffac00000d:  48 81 c4 78 fb ff ff     addq     $-0x488, %rsp
//跳轉到第二個參數地址處執行,第二個參數即為TranslationBlock.tc.ptr
0x7fffac000014:  ff e6                    jmpq     *%rsi

它的epilogue如下:

//恢復棧空間及callee需要保存的寄存器
0x7fffac000016:  33 c0                    xorl     %eax, %eax
0x7fffac000018:  48 81 c4 88 04 00 00     addq     $0x488, %rsp
0x7fffac00001f:  c5 f8 77                 vzeroupper 
0x7fffac000022:  41 5f                    popq     %r15
0x7fffac000024:  41 5e                    popq     %r14
0x7fffac000026:  41 5d                    popq     %r13
0x7fffac000028:  41 5c                    popq     %r12
0x7fffac00002a:  5b                       popq     %rbx
0x7fffac00002b:  5d                       popq     %rbp
0x7fffac00002c:  c3                       retq

假設現在正在翻譯第一個TB,TranslationBlock結構也是在1G的空間內分配,第一個TB緊接著epilogue,并且分配了TB以后TCGContext的code_gen_ptr將會指向TB的末端,該TB對應的Host機器碼地址存放在TranslationBlock.tc.ptr中,屬于buf_rx空間。

而buf_rw空間中TB對應的Host機器碼的開頭由TCGContext的code_buf指向,末端由TCGContext的code_ptr指向,兩者之差則為機器碼的長度。需要翻譯第二個TB時,第二個TranslationBlock結構則會在TCGContext.code_ptr的后面再分配,TCGContext的code_buf和code_ptr則再指向第二個TB對應的Host機器碼的開頭和末端,此時TCGContext的code_gen_ptr則再更新為第二個TB末端的位置。

如何執行編譯后的代碼?直接執行tcg_qemu_tb_exec()函數即可,該函數接受兩個參數,第一個參數為CPUArchState,第二個參數為TranslationBlock.tc.ptr,因此TB執行邏輯為:

1.prologue

2.TranslationBlock.tc.ptr

3.epilogue

如果做了Direct block chaining優化則不會再有epilogue,會跳轉到下一個TB執行。

7.tcg執行流程

tcg線程始于mttcg_cpu_thread_fn,執行流程為:

mttcg_cpu_thread_fn:
    do{
        if (cpu_can_run(cpu)) {
            ...
            tcg_cpus_exec(cpu)
                cpu_exec_start(cpu)
                cpu_exec(cpu)
                    cpu_exec_enter(cpu)
                    cpu_exec_setjmp(cpu, &sc)
                        sigsetjmp(cpu->jmp_env, 0) //設置同步異常退出點
                        cpu_exec_loop(cpu, sc)
                    cpu_exec_exit(cpu)
                cpu_exec_end(cpu)
            ...
        }
    } while (!cpu->unplug || cpu_can_run(cpu));

主要執行函數在cpu_exec_loop中,它的執行過程為:

cpu_exec_loop:
    while (!cpu_handle_exception(cpu, &ret)) { //處理同步異常
        while (!cpu_handle_interrupt(cpu, &last_tb)) { //處理異步中斷
            cpu_get_tb_cpu_state()
            tb = tb_lookup() //查找tb緩存
            if (tb == NULL) {
                tb = tb_gen_code() //進行dbt轉換
                    setjmp_gen_code()
                        gen_intermediate_code() //將Guest代碼轉換為IR
                        tcg_gen_code() //根據IR生成Host代碼
            }
            tb_add_jump() //Direct block chaining優化              
            cpu_loop_exec_tb() //執行Host目標代碼
        }
    }

tcg_gen_code在生成Host代碼之前還會基于當前的IR做一些優化。優化函數有tcg_optimize, reachable_code_pass,liveness_pass_0,liveness_pass_1,liveness_pass_2等。

8.普通算術邏輯運算指令如何更新Host體系結構相關寄存器

對于一條Guest指令來說,tcg的處理是將它翻譯為語義等價的多條IR(稱為微碼),比如

in_asm.txt文件中顯示出0地址處的arm指令為:

0x00000000:  e3a00000  mov      r0, #0

它編譯為微碼IR的結果為:

---- 00000000 00000000 00000000
 mov_i32 loc5,$0x0 //0x0賦值給loc5變量
 mov_i32 r0,loc5 //loc5再賦值給r0

loc5這種變量為tcg的TCGTemp,而r0則對應著arm的r0寄存器,因此tcg的IR其實并非和llvm中的IR那樣和平臺完全無關,它是和平臺相關的。

這種翻譯方式的優點是可以避免處理不同指令集的復雜性,但是缺點是以性能為代價(通常減慢 5-10 倍)。

再來看一下這條指令:

0x00000004:  e59f1004  ldr      r1, [pc, #4]

它對應的IR:

---- 00000004 00000000 00000e04
 add_i32 loc6,pc,$0x10   //loc6 = pc + 0x10
 mov_i32 loc9,loc6      //loc9 = loc6
 qemu_ld_i32 loc8,loc9,leul,2 //loc9處的內存加載至loc8變量,leul的含義為Little Endian unsigned long
 mov_i32 r1,loc8 //loc8賦值給r1寄存器

因此通過組合微碼以及結合qemu的helper函數,可以將Guest的所有指令都編譯為語義等價的IR,在微碼的基礎上進行一些優化以后再根據微碼一條一條的翻譯成Host指令。

DBT需要解決的一個問題是如何進行state mapping狀態綁定,拿0x00000000處的指令舉例,這條指令將r0寄存器的值賦給0,當執行完編譯過的Host指令以后,需要相應的在某個狀態中記錄下r0寄存器值為0,如果Host的寄存器數量很多,完全可以選一個x86_64寄存器作為arm中r0寄存器的對應物(寄存器綁定),否則就需要保存在內存中了。

對于tcg來說有個特殊的寄存器叫TCG_AREG0,它表示用哪個Host寄存器來指向Guest體系結構的CPUArchState,對于x86_64來說TCG_AREG0為%rbp(對于arm來說TCG_AREG0為r6寄存器),也就是說通過rbp寄存器可以找到arm的CPUArchState。qemu中有專門的TEMP_FIXED類型的TCGTemp用于表示TCG_AREG0:

ts = tcg_global_reg_new_internal(s, TCG_TYPE_PTR, TCG_AREG0, "env");
cpu_env = temp_tcgv_ptr(ts);

cpuenv在IR中被使用的話,在生成Host代碼階段對于x8664來說將會綁定到rbp寄存器。

事實上qemu中一共只有兩個TEMP_FIXED類型的TCGTemp,一個叫env,一個叫_frame。

來看一下CPUArchState的通用寄存器成員為

uint32_t regs[16];

因此arm指令

0x00000000:  e3a00000  mov      r0, #0

對應編譯過的x86_64代碼如下:

movl     $0, 0(%rbp)  //rbp指向CPUArchState,更新arm CPUArchState的regs[0]即r0寄存器

同樣的對于這條arm指令它最終改變了r2:

ldr      r2, [pc, #4]

對應編譯過的x86_64代碼如下:

movl     %r12d, 8(%rbp) //rbp指向CPUArchState,更新arm CPUArchState的regs[2]即r2寄存器

當然如果在一個TB內如果每遇到一個指令改變了寄存器都要寫入x86_64的rbp對應的內存地址是非常慢的,tcg有個優化措施是可以在TB結束之前只執行一次更新操作從而減少寫內存的操作。

因此通過TCG_AREG0寄存器,x86_64指令在執行的時候可以找到CPUArchState結構從而更新所有Guest體系結構的CPU狀態。

9. 內存讀寫如何處理

對于qemu來說讀寫內存涉及到內存模擬模塊,qemu還模擬了tlb,因此讀寫一塊arm的虛擬內存地址(Guest Virtual Address -> GVA)首先會查詢tlb,如果tlb不命中的話會走tlb慢路徑。tlb慢路徑要經由guest的mmu經頁表轉換為物理內存地址(Guest Physics Address -> GPA),再經過qemu內存管理模塊轉換為qemu進程的虛擬地址(Host Virtual Address -> HVA)。

那么讀寫GVA的arm指令編譯成X86_64指令就是讀寫對應的HVA即可。

tlb相應的數據結構在include/exec/cpu-defs.h文件中定義,其中結構體CPUTLB由ArchCPU中的CPUNegativeOffsetState neg所引用。

TLB命中時對應CPUTLBEntry對象的addend + GVA = HVA。

CPUTLBEntry對象的addr_read, addr_write, addr_code分別對應著讀寫執行指令的地址,地址的構成部分注釋中有描述:

/* bit TARGET_LONG_BITS to TARGET_PAGE_BITS : virtual address
       bit TARGET_PAGE_BITS-1..4  : Nonzero for accesses that should not
                                    go directly to ram.
       bit 3                      : indicates that the entry is invalid
       bit 2..0                   : zero
    */

以如下指令舉例:

0x00000004:  e59f1004  ldr      r1, [pc, #4]

它對應的IR為:

---- 00000004 00000000 00000e04
 add_i32 loc6,pc,$0x10     
 mov_i32 loc9,loc6 
 qemu_ld_i32 loc8,loc9,leul,2
 mov_i32 r1,loc8

上面最主要的是qemu_ld_i32這條IR,loc9的值為GVA,qemu_ld_i32則將loc9地址處的內存加載至loc8變量中并最終賦值給r1寄存器。

qemu_ld_i32這條IR它對應的x86_64代碼如下:

-- guest addr 0x00000004
0x7ff9c0000119:  41 8b fc                 movl     %r12d, %edi
0x7ff9c000011c:  c1 ef 05                 shrl     $5, %edi
0x7ff9c000011f:  23 bd 10 ff ff ff        andl     -0xf0(%rbp), %edi
0x7ff9c0000125:  48 03 bd 18 ff ff ff     addq     -0xe8(%rbp), %rdi
0x7ff9c000012c:  41 8d 74 24 03           leal     3(%r12), %esi
0x7ff9c0000131:  81 e6 00 fc ff ff        andl     $0xfffffc00, %esi
0x7ff9c0000137:  3b 37                    cmpl     0(%rdi), %esi
0x7ff9c0000139:  41 8b f4                 movl     %r12d, %esi
0x7ff9c000013c:  0f 85 9c 00 00 00        jne      0x7ff9c00001de
0x7ff9c0000142:  48 03 77 10              addq     0x10(%rdi), %rsi
0x7ff9c0000146:  44 8b 26                 movl     0(%rsi), %r12d

乍一看相當復雜的不知道在做什么,其實上面執行的邏輯是創建出調用qemu tlb的環境,先去tlb查詢是否有對應的HVA,如果沒有的話會生成一段tlb slow path的代碼并跳轉到tlb slow path去執行。生成這段x86_64的代碼位于tcg/i386/tcg-target.c.inc文件的tcg_out_qemu_ld函數。

一條條來解釋:

-- guest addr 0x00000004
//r12寄存器包含著要讀取的地址的低位部分addrlo(這里要讀取的地址為0x10),賦值給edi,edi為x86平臺函數調用的第一個參數寄存器
0x7ff9c0000119:  41 8b fc                 movl     %r12d, %edi
//地址 >> (TARGET_PAGE_BITS - CPU_TLB_ENTRY_BITS) = 5
0x7ff9c000011c:  c1 ef 05                 shrl     $5, %edi
//-0xf0為偏移量,rbp為CPUArchState,-0xf0分為兩部計算,首先獲取neg.tlb.f[IDX]在CPUArchState中的偏移,再獲取CPUTLBDescFast結構中mask成員的偏移, 因此-0xf0就為CPUTLBDescFast結構中mask成員的偏移,因此這條指令等于是執行了一個函數叫tlb_index(CPUArchState *env, uintptr_t mmu_idx,target_ulong addr)
0x7ff9c000011f:  23 bd 10 ff ff ff        andl     -0xf0(%rbp), %edi
//-0xe8為CPUTLBDescFast結構中的table成員的偏移,因此這條指令等于是執行了一個函數叫tlb_entry(CPUArchState *env, uintptr_t mmu_idx,target_ulong addr)
0x7ff9c0000125:  48 03 bd 18 ff ff ff     addq     -0xe8(%rbp), %rdi
//addrlo + (s_mask - a_mask)賦值給%esi, esi為x86平臺函數調用的第二個參數寄存器
0x7ff9c000012c:  41 8d 74 24 03           leal     3(%r12), %esi
//地址 & (TARGET_PAGE_MASK | a_mask)這樣提取出地址的除了頁偏移的其他部分
0x7ff9c0000131:  81 e6 00 fc ff ff        andl     $0xfffffc00, %esi
//0(%rdi)的值為對應CPUTLBEntry的addr_read成員變量的值,和要取的地址進行比較
0x7ff9c0000137:  3b 37                    cmpl     0(%rdi), %esi
//原始地址賦值給%esi
0x7ff9c0000139:  41 8b f4                 movl     %r12d, %esi
//如果CPUTLBEntry的addr_read成員變量的值和要取的地址不相等則表示tlb不命中,跳轉至tlb慢路徑地址0x7ff9c00001de處執行
0x7ff9c000013c:  0f 85 9c 00 00 00        jne      0x7ff9c00001de
//如果沒有進入tlb慢路徑表示tlb命中,0x10(%rdi)的值為CPUTLBEntry的addend成員變量的值,加上原始地址即為HVA
0x7ff9c0000142:  48 03 77 10              addq     0x10(%rdi), %rsi
//讀取HVA地址處的值并賦值給%r12d
0x7ff9c0000146:  44 8b 26                 movl     0(%rsi), %r12d

生成tlb slow path的代碼在tcg/tcg.c文件的tcg_gen_code函數中的:

/* Generate TB finalization at the end of block */
#ifdef TCG_TARGET_NEED_LDST_LABELS
    i = tcg_out_ldst_finalize(s); 
    if (i < 0) {
        return i;
    }

tlb slow path的代碼位于每個TB的尾端:

-- tb slow paths + alignment
//準備好第一個參數tcg_target_call_iarg_regs[0],它的值為CPUArchState env
0x7ff9c00001de:  48 8b fd                 movq     %rbp, %rdi
//準備好第三個參數tcg_target_call_iarg_regs[2],它的值為TCGMemOpIdx oi = 0x22
0x7ff9c00001e1:  ba 22 00 00 00           movl     $0x22, %edx
//準備好第四個參數tcg_target_call_iarg_regs[3],它的值為retaddr
0x7ff9c00001e6:  48 8d 0d 5c ff ff ff     leaq     -0xa4(%rip), %rcx
//調用函數helper_le_ldul_mmu
//helper_le_ldul_mmu(CPUArchState *env, target_ulong addr,TCGMemOpIdx oi, uintptr_t retaddr)
0x7ff9c00001ed:  ff 15 4d 00 00 00        callq    *0x4d(%rip)
//獲取返回值
0x7ff9c00001f3:  44 8b e0                 movl     %eax, %r12d
//跳轉回之前不命中的地方繼續執行
0x7ff9c00001f6:  e9 4e ff ff ff           jmp      0x7ff9c0000149

helper_le_ldul_mmu還會再檢測一次tlb是否命中,如果不命中將會調用體系結構相關函數做下一步的處理。

事實上會先訪問快速路徑,再訪問victim tlb, victim tlb機制見:

https://patchwork.ozlabs.org/project/qemu-devel/patch/1390930309-21210-1-git-send-email-trent.tong@gmail.com/

    
/* If the TLB entry is for a different page, reload and try again.  */
    if (!tlb_hit(tlb_addr, addr)) {
        if (!victim_tlb_hit(env, mmu_idx, index, tlb_off,
                            addr & TARGET_PAGE_MASK)) {
            tlb_fill(env_cpu(env), addr, size,
                     access_type, mmu_idx, retaddr);
            index = tlb_index(env, mmu_idx, addr);
            entry = tlb_entry(env, mmu_idx, addr);
        }
        tlb_addr = code_read ? entry->addr_code : entry->addr_read;
        tlb_addr &= ~TLB_INVALID_MASK;
    }

10.分支指令(條件跳轉、非條件跳轉、返回指令)

這條指令會間接的改變pc的值從而產生跳轉(從而終結當前TB)

0x0000000c:  e59ff004  ldr      pc, [pc, #4]

一個TB終結以后怎么執行有兩種可能:

1.直接執行下一個TB

2.回到qemu上下文繼續編譯執行

它的IR如下:

---- 0000000c 00000000 00000000
 mov_i32 tmp3,$0x18  //0x18處為pc應該更新到的值即pc + 4
 mov_i32 tmp7,tmp3    
 qemu_ld_i32 tmp6,tmp7,leul,10  //將(pc + 4)內存地址處的值取出存放于tmp6
 and_i32 pc,tmp6,$0xfffffffe  //這里的邏輯對應于target/arm/tcg/translate.c文件的gen_bx函數,注意SPC值發生了改變
 and_i32 tmp6,tmp6,$0x1        //同樣位于gen_bx函數
 st_i32 tmp6,env,$0x220        //賦值給env中的thumb成員
 //這條IR產生的原因是上面的gen_bx函數中的語句: s->base.is_jmp = DISAS_JUMP,從而退出translator_loop中的while循環,調用ops->tb_stop(db, cpu)從而調用gen_goto_ptr()產生此條IR
 call lookup_tb_ptr,$0x6,$1,tmp12,env 
 goto_ptr tmp12

再來具體看一下如下兩條IR:

call lookup_tb_ptr,$0x6,$1,tmp12,env
goto_ptr tmp12

那么call lookup_tb_ptr后面的參數是什么含義?具體可以參考tcg/tcg.c文件的tcg_dump_ops函數:

1.lookup_tb_ptr為TCGOp對象所對應的TCGHelperInfo對象的name字段。

2.$0x6為TCGOp對象所對應的TCGHelperInfo對象的flags字段。

3.$1為TCGOp對象的param2成員,即nb_oargs, 表示輸出參數的個數為1。

4.tmp12為op->args[]中輸出參數的字符串表示。

5.env為op->args[]中輸入參數的字符串表示。

因此lookup_tb_ptr這個helper函數輸入參數個數就是1,即為CPUArchState env, 輸出參數為tmp12,然后goto_ptr tmp12就跳轉至此地址處從而終結當前TB。

這段IR對應的x86_64 target代碼為:

0x7f2d53e7e1aa:  48 8b fd                 movq     %rbp, %rdi  //rbp為CPUArchState env賦值給第一個參數寄存器%rdi
//調用%eip + 0x65處的函數,即(helper_lookup_tb_ptr函數)
0x7f2d53e7e1ad:  ff 15 65 00 00 00        callq    *0x65(%rip) 
0x7f2d53e7e1b3:  ff e0                    jmpq     *%rax //跳轉至函數返回值處執行

lookup_tb_ptr函數的功能屬于tcg相關,因此它的實現位于accel/tcg/cpu-exec.c:

const void *HELPER(lookup_tb_ptr)(CPUArchState *env) //它的名字經擴展后為helper_lookup_tb_ptr

需要跳轉的目標地址在env結構的pc成員中,這個函數中會通過hash表查詢是否有目標TranslationBlock的緩存。如果有則跳轉至TranslationBlock.tc.ptr執行即可(即下一個),如果沒有則跳轉至tcg_code_gen_epilogue執行。

tcg_code_gen_epilogue指向了tcg的epilogue處,因此如果跳轉至tcg_code_gen_epilogue執行最終結果是tcg_qemu_tb_exec(env, tb_ptr)函數返回,從而回到了qemu tcg上下文處進行下一個TB的轉換執行。

綜上所述這種跳轉指令會生成helper函數lookup_tb_ptr,它要么成功找到下一個TB的地址并跳轉過去執行要么返回qemu tcg上下文執行。

再看一下另外一種跳轉指令:

0x00010004:  eb000017  bl       #0x10068

bl這條指令首先需要反匯編, 會進入到libqemu-arm-softmmu.fa.p/decode-a32.c.inc文件的disas_a32_extract_branch函數,a->imm為pc相對跳轉的偏移值。

然后需要轉換成IR,對應的函數為target/arm/tcg/translate.c文件的trans_BL函數。

轉換以后對應的IR為:

 add_i32 r14,pc,$0x8
 add_i32 pc,pc,$0x68
 goto_tb $0x0
 //0x7f666c000280即val的值為當前的TranslationBlock在buf_rx處的指針:
 //uintptr_t val = (uintptr_t)tcg_splitwx_to_rx((void *)tb) + idx;
 exit_tb $0x7f666c000280

這種跳轉和上面的跳轉不同的是goto_tb $0x0以及exit_tb。

在這個jmp_diff函數中還可以看到對arm來說pc真正的值為當前執行指令在arm模式下+8,在thumb模式下是+4:

static target_long jmp_diff(DisasContext *s, target_long diff)
{
    return diff + (s->thumb ? 4 : 8);
}

goto_tb $0x0對應的目標代碼如下:

//生成的代碼只是用于跳轉到下一條指令
0x7fff70000397:  e9 00 00 00 00           jmp      0x7fff7000039c

它由tcg/i386/tcg-target.c.inc文件中的tcg_out_goto_tb函數生成:

static void tcg_out_goto_tb(TCGContext *s, int which)
{
    /*
     * Jump displacement must be aligned for atomic patching;
     * see if we need to add extra nops before jump
     */
    int gap = QEMU_ALIGN_PTR_UP(s->code_ptr + 1, 4) - s->code_ptr;
    if (gap != 1) {
        tcg_out_nopn(s, gap - 1);
    }
    tcg_out8(s, OPC_JMP_long); /* jmp im */
    set_jmp_insn_offset(s, which);
    tcg_out32(s, 0);
    set_jmp_reset_offset(s, which);
}

這個函數除了生成jmp指令跳轉到下一條指令外還執行了如下兩個函數,作用如下:

set_jmp_insn_offset(s, which); //設置當前TB的jmp_insn_offset[0]為tcg_current_code_size(s)
set_jmp_reset_offset(s, which);//設置當前TB的jmp_reset_offset[0]為tcg_current_code_size(s)

exit_tb 0x7f666c000280對應的目標代碼為:

//-0x123(%rip)的值就是0x7f666c000280,賦值給%rax
0x7fff7000039c:  48 8d 05 dd fe ff ff     leaq     -0x123(%rip), %rax
//0x7fff70000018的值就是tb_ret_addr,即TB epilogue
0x7fff700003a3:  e9 70 fc ff ff           jmp      0x7fff70000018

因此總結起來goto_tb $0x0和exit_tb 0x7f666c000280的作用是:

1.設置當前TB的jmp_insn_offset[0]和jmp_reset_offset[0]。

2.將當前TB在buf_rx處的指針(0x7f666c000280)賦值給%rax。

3.跳轉至TB epilogue處即從tcg_qemu_tb_exec(env, tb_ptr)函數處返回。

從tcg_qemu_tb_exec(env, tb_ptr)函數處返回以后接著處理下一個TB,因此當前TB就變成了last_tb,返回到cpu_exec_loop函數中執行如下邏輯:

 if (last_tb) {
    tb_add_jump(last_tb, tb_exit, tb);
 }

最終tb_add_jump的邏輯為:

void tb_set_jmp_target(TranslationBlock *tb, int n, uintptr_t addr)
{
    /*
     * Get the rx view of the structure, from which we find the
     * executable code address, and tb_target_set_jmp_target can
     * produce a pc-relative displacement to jmp_target_addr[n].
     */
    const TranslationBlock *c_tb = tcg_splitwx_to_rx(tb);
    uintptr_t offset = tb->jmp_insn_offset[n];
    uintptr_t jmp_rx = (uintptr_t)tb->tc.ptr + offset;
    uintptr_t jmp_rw = jmp_rx - tcg_splitwx_diff;
    tb->jmp_target_addr[n] = addr;
    tb_target_set_jmp_target(c_tb, n, jmp_rx, jmp_rw);
}

用圖來表示是這樣的效果:

 

對內存的修改發生在buf_rw區域,而執行代碼則位于buf_rx區域,兩個區域是鏡像關系。

先看buf_rw區域,需要對Last TB CODE中的"e9 00 00 00 00"進行patch讓它指向Next TB CODE,這樣下次再執行Last TB CODE"e9 xx xx xx xx"處將會直接跳轉到Next TB CODE處執行,無需再退出至qemu上下文。這個就叫Direct block chaining優化

上面看到jmp_insn_offset[0]指向的是需要patch的指令在code區域的偏移,而jmp_reset_offset[0]則指向了需要patch指令的下一條指令,當需要斷開當前TB與下一條TB的Direct block chaining鏈接時,再執行patch,目標是jmp_reset_offset[0]即可恢復當前TB的跳轉。

Direct block chaining還需要解決的一個問題是自修改代碼,即當代碼會對代碼區域作修改時,這個代碼區域之前舊的翻譯指令不再有效,它和其他TB之間的鏈接也可能不再有效。

tcg針對自修改代碼也做了處理,結合著soft mmu的機制,當產生自修改代碼時會調用do_tb_phys_invalidate函數從而重置TB的一些狀態,其中就包括它所鏈接到其他TB的狀態。

11.目標機器沒有的指令、特權指令、敏感指令

前面提到過,tcg需要依賴于Guest的helper函數來模擬各種Guest的特殊指令,每個體系結構對應的helper函數在target/xxx/helper.h頭文件中聲明。

所有helpers的數組結構如下:

//所有helpers的數組
static const TCGHelperInfo all_helpers[] = {
#include "exec/helper-tcg.h"   //包含#include "helper.h"
};

比如對于arm的除法指令udiv,在x86_64平臺是沒有的,最終調用target/arm/helper.c文件udiv函數:

uint32_t HELPER(udiv)(CPUARMState *env, uint32_t num, uint32_t den)
{
    if (den == 0) {
        handle_possible_div0_trap(env, GETPC()); //引發除0異常
        return 0;
    }
    return num / den;
}

比如x86中的cpuid指令, arm平臺是沒有的,最終調用的函數為:

void helper_cpuid(CPUX86State *env)
{
    uint32_t eax, ebx, ecx, edx;
    cpu_svm_check_intercept_param(env, SVM_EXIT_CPUID, 0, GETPC());
    cpu_x86_cpuid(env, (uint32_t)env->regs[R_EAX], (uint32_t)env->regs[R_ECX],
                  &eax, &ebx, &ecx, &edx);
    env->regs[R_EAX] = eax;
    env->regs[R_EBX] = ebx;
    env->regs[R_ECX] = ecx;
    env->regs[R_EDX] = edx;
}

其他的特權指令敏感指令同理。

12.非普通內存讀寫如設備寄存器訪問MMIO

某些內存地址指向的并不是ram,而是設備的寄存器,這種內存地址叫MMIO,tcg必須正確處理這種內存地址訪問,當訪問MMIO時將與模擬設備進行通信。

這一塊涉及到qemu的內存模塊MemoryRegion和AddressSpace,是相當復雜的概念,限于篇幅不再描述,只需要知道tcg會生成訪問內存的helper函數如helper_le_stl_mmu,然后進入到qemu的內存模塊做下一步的處理。

13.指令執行出現了同步異常如何處理(如系統調用)

真實的cpu在執行的過程中會遇到異常和中斷,既然是模擬cpu,tcg也需要處理好異常和中斷,先以同步異常系統調用舉例,其實它是通過長跳轉來直接跳出tcg執行循環的,如下面的指令:

0x00010004:  ef000000  svc      #0

tcg解析到這條指令的時候會進入到trans_SVC函數.將DisasContextBase的is_jmp設置為DISAS_SWI表示當前tb的終結。

隨后退出到tb循環中執行arm_tr_tb_stop函數進入DISAS_SWI分支:

case DISAS_SWI:
    gen_exception(EXCP_SWI, syn_aa32_svc(dc->svc_imm, dc->thumb));

生成對應的IR為:

 add_i32 pc,pc,$0x8
 call exception_with_syndrome,$0x8,$0,env,$0x2,$0x46000000

最終在執行目標代碼時會調用的函數為:

//函數名稱為helper_exception_with_syndrome
//excp的值為: #define EXCP_SWI  2
//syndrome的值為0x46000000,由syn_aa32_svc()函數計算得出,可以認為是常量
//target_el值為1表示執行系統調用會切換exception level至1
void HELPER(exception_with_syndrome)(CPUARMState *env, uint32_t excp,
                                     uint32_t syndrome, uint32_t target_el)
{
    raise_exception(env, excp, syndrome, target_el);
}
void raise_exception(CPUARMState *env, uint32_t excp,
                     uint32_t syndrome, uint32_t target_el)
{
    CPUState *cs = env_cpu(env);
    if (target_el == 1 && (arm_hcr_el2_eff(env) & HCR_TGE)) {
        /*
         * Redirect NS EL1 exceptions to NS EL2. These are reported with
         * their original syndrome register value, with the exception of
         * SIMD/FP access traps, which are reported as uncategorized
         * (see DDI0478C.a D1.10.4)
         */
        target_el = 2;
        if (syn_get_ec(syndrome) == EC_ADVSIMDFPACCESSTRAP) {
            syndrome = syn_uncategorized();
        }
    }
    assert(!excp_is_internal(excp));
    cs->exception_index = excp;          //更新CPU狀態的異常下標
    env->exception.syndrome = syndrome; 
    env->exception.target_el = target_el; 
    cpu_loop_exit(cs);  //請求退出執行循環
}

cpu_loop_exit代碼如下:

void cpu_loop_exit(CPUState *cpu)
{
    /* Undo the setting in cpu_tb_exec.  */
    cpu->can_do_io = 1;
    /* Undo any setting in generated code.  */
    qemu_plugin_disable_mem_helpers(cpu);
    siglongjmp(cpu->jmp_env, 1);  //執行長跳轉退出至執行循環
}

最終會退出當前cpu的執行循環進入異常的處理過程:

cpu_exec_loop(CPUState *cpu, SyncClocks *sc)
{
    int ret;
    /* if an exception is pending, we execute it here */
    while (!cpu_handle_exception(cpu, &ret)) { //執行異常處理
        TranslationBlock *last_tb = NULL;
        int tb_exit = 0;
        while (!cpu_handle_interrupt(cpu, &last_tb)) {

cpu_handle_exception函數通過調用cc->tcg_ops->do_interrupt(cpu)進入最終的異常處理,從而調用到target/arm/helper.c文件中的函數:

void arm_cpu_do_interrupt(CPUState *cs) //邏輯是addr=8; addr += A32_BANKED_CURRENT_REG_GET(env, vbar);

也就是通過vbar寄存器(Vector Base Address Register)計算出異常處理函數的地址newpc, 并且通過take_aarch32_exception函數將pc置為異常處理函數地址并跳轉過去執行:

env->regs[15] = newpc;  //r15就是pc寄存器

因此可以看到對于系統調用來說始終沒有跳出tcg線程,因為系統調用為同步異常。

14.硬件中斷如何處理

硬件中斷屬于異步事件,對于真實的cpu來說,它會在執行每條指令以后檢查中斷引腳的信號判斷是否有外部中斷產生。對于tcg來說顯然粒度做不到這么細,因為這么做性能太低了,但又必須能夠及時響應外部中斷。

外部硬件中斷發生在IO線程,發生硬件中斷時會經由平臺的模擬中斷控制器一堆復雜的邏輯以后最終調用如下函數通知vcpu:

void mttcg_kick_vcpu_thread(CPUState *cpu)
{
    cpu_exit(cpu);
}
void cpu_exit(CPUState *cpu)
{
    qatomic_set(&cpu->exit_request, 1);
    /* Ensure cpu_exec will see the exit request after TCG has exited.  */
    smp_wmb();
    //Set to -1 to force TCG to stop executing linked TBs for this CPU and return to its top level loop (even in non-icount mode).
    qatomic_set(&cpu->icount_decr_ptr->u16.high, -1);
}

當把cpu->icount_decr_ptr->u16.high置為-1時就是告訴tcg線程中正在執行的tb盡快退出,回到qemu上下文進行外部中斷的處理。icount_decr_ptr還涉及到qemu的一個特性叫TCG Instruction Counting:

https://qemu.readthedocs.io/en/latest/devel/tcg-icount.html

為了讓TB執行的時候可以快速響應退出的指令,tcg在每個TB的開頭和結尾生成了如下代碼:

OP:
//開頭
 ld_i32 loc3,env,$0xfffffffffffffff0  //對應于cpu->icount_decr_ptr->u16.high
 brcond_i32 loc3,$0x0,lt,$L0 //如果cpu->icount_decr_ptr->u16.high < 0則跳轉至結尾處的$L0
...
//結尾
set_label $L0    
exit_tb $0x7f884c000043  //收到中斷通知,退出執行循環

因此tcg對硬件中斷的響應是以TB為粒度。

退出執行循環以后會進入:

while (!cpu_handle_interrupt(cpu, &last_tb)) {

處理中斷,下面的代碼就是和qemu硬件模擬相關的代碼,不再細述。

三、unicorn原理分析

unicorn是基于qemu tcg的,但qemu tcg還是太復雜了,它模擬的是一個完整的系統,unicorn只需要模擬執行一個可執行文件甚至一段代碼片段,因此unicorn中的tcg可以說是輕量級的tcg,這也是unicorn被稱為cpu模擬器的原因。

unicorn的特點:

1.只保留qemu tcg cpu模擬器的部分,移除掉其他如device,rom/bios等和系統模擬相關的代碼

2.盡量維持qemu cpu模擬器部分不變,這樣才容易和上游的qemu tcg代碼同步

3.重構tcg的代碼從而可以更好的實現線程安全性及同時運行多個unicorn實例

4.qemu tcg并非一個Instrumentation框架,而unicorn的目標是實現一個有多種語言綁定的Instrumentation框架,可以在多個級別跟蹤代碼的運行并執行設置好的回調函數。

因此unicorn的研究重點就在于研究它如何提供在指令級別、內存訪問級別的dynamic Instrumentation。

unicorn不僅實現了cpu模擬,還需要實現內存模擬,因此讓unicorn能運行起來還需要設置內存映射。

unicorn使用qemu的tcg做為cpu模擬的實現,使用tlb/softmmu/MemoryRegion做為內存模擬的實現。

設置內存映射以c語言的設置為例:

uc_mem_map(*uc, code_start, code_len, UC_PROT_ALL)

這段代碼設置了code_start到code_len之間的區域為虛擬cpu所使用的虛擬地址空間,由于unicorn中并沒有相應的操作系統代碼來設置頁表開啟mmu,因此unicorn中的mmu并沒有開啟:

//返回true表示沒有開啟mmu
if (regime_translation_disabled(env, mmu_idx)) {

在unicorn中虛擬地址(GVA)就等于物理地址(GPA)。

調用uc_mem_map函數設置內存映射,本質是創建出一個MemoryRegion對象,初始化這個對象并添加到system_memory這個全局的MemoryRegion樹層次結構中,這里涉及到qemu中的內存對象AddressSpace,MemoryRegion,FlatView和RAMBlock。這塊的機制相當復雜,描述它就得需要一兩篇博客的篇幅,這里只是簡單介紹一下概念:

1.AddressSpace是全局的內存視圖,它被每個體系結構的cpu結構引用, 它的成員MemoryRegion *root引用根MemoryRegion所表示的MemoryRegion樹。

2.多個MemoryRegion組成了一個樹結構,MemoryRegion它可以表示ram,rom,MMIO等多種類型的內存設備,可以認為MemoryRegion是Guest物理內存設備的表示。

3.MemoryRegion如果對應著ram內存設備,它的RAMBlock ram_block成員就表示Host一側分配出來的虛擬地址空間,所有的RAMBlock被存放在RAMList表示的列表中。

4.MemoryRegion是樹結構,它的平坦化線性模型由FlatView對象表示。

設置好內存映射,初始化相應的數據結構以后,unicorn就可以設置寄存器,設置好hook回調并且啟動unicorn引擎:

uc_hook_add(uc, &hook, UC_HOOK_CODE, my_callback,&count, 1, 0)
uc_reg_write(uc, UC_ARM_REG_R0, &r_r0)
uc_reg_write(uc, UC_ARM_REG_R2, &r_r2)
uc_emu_start(uc, code_start, code_start + sizeof(code) - 1, 0, 0)

1.UC_HOOK_CODE:

unicorn的Hook類型有很多種,一個一個來看,首先是UC_HOOK_CODE類型,它可以設置回調在每條指令執行前調用。

當調用了uc_hook_add函數以后,其實是創建出了struct hook對象并添加到了uc_struct這個全局對象的struct list hook[UC_HOOK_MAX]鏈表中去,unicorn其實是在IR層添加了相應的代碼來設置回調,比如對于

mov r0, #1

這么一條簡單的指令,如果設置了uc_hook_add(uc, &hook, UC_HOOK_CODE, my_callback,&count, 1, 0), 調試打印OPCode:

UNICORN_DEBUG=1 ./test_arm my_hook_test

打印出的IR是這樣的:

insn_idx=0 ---- 00001000 00000000 00000000
 1:  movi_i32 pc,$0x1000
 2:  movi_i32 tmp3,$0x4
 3:  movi_i64 tmp5,$0x55e38ad72840
 4:  movi_i64 tmp6,$0x1000
 5:  movi_i64 tmp7,$0x7fff1dd92190
 6:  call hookcode_4_55e38928e9a9,$0x0,$0,tmp5,tmp6,tmp3,tmp7
 7:  ld_i32 tmp3,env,$0xfffffffffffffff0
 8:  movi_i32 tmp4,$0x0
 9:  brcond_i32 tmp3,tmp4,lt,$L0
 10:  movi_i32 tmp3,$0x1
 11:  mov_i32 r0,tmp3

第1條到第6條是unicorn添加的用于hook的IR,產生這些IR的代碼位于反編譯Guest指令之前,對于arm來說就是disas_arm_insn函數中:

// Unicorn: trace this instruction on request
    if (HOOK_EXISTS_BOUNDED(s->uc, UC_HOOK_CODE, s->pc_curr)) {
        // Sync PC in advance
        gen_set_pc_im(s, s->pc_curr);
        gen_uc_tracecode(tcg_ctx, 4, UC_HOOK_CODE_IDX, s->uc, s->pc_curr);
        // the callback might want to stop emulation immediately
        check_exit_request(tcg_ctx);
    }

gen_uc_tracecode的邏輯就是創建出調用hookcode_4_55e38928e9a9這個helper函數的IR,這個helper函數的實現就是設置進來的回調函數,它是動態被創建出來并且添加到helper函數的hashtable中的。

代碼解讀如下:

//將當前正在執行指令的地址放置于pc寄存器
 1:  movi_i32 pc,$0x1000
//4的值為跟蹤的指令字節個數
 2:  movi_i32 tmp3,$0x4
//$0x55e38ad72840為uc_struct uc指令的值
 3:  movi_i64 tmp5,$0x55e38ad72840
//0x1000為當前pc執行的地址
 4:  movi_i64 tmp6,$0x1000
//$0x7fff1dd92190的值為設置回調時傳遞的user_data指針值
 5:  movi_i64 tmp7,$0x7fff1dd92190
//調用到回調函數 : void my_callback(uc_engine *uc, uint64_t address, uint32_t size, void *user_data)
 6:  call hookcode_4_55e38928e9a9,$0x0,$0,tmp5,tmp6,tmp3,tmp7

2.UC_HOOK_INSN:

UC_HOOK_INSN回調和UC_HOOK_CODE的回調原理差不多,只不過它可以用于跟蹤某些特定指定的執行。比如當追蹤x86的inb指令時,當執行到cpu_inb這個helper函數時就會調用回調:

uint8_t cpu_inb(struct uc_struct *uc, uint32_t addr)
{
    // uint8_t val;
    // address_space_read(&uc->address_space_io, addr, MEMTXATTRS_UNSPECIFIED,
    //                    &val, 1);
    //LOG_IOPORT("inb : %04"FMT_pioaddr" %02"PRIx8"", addr, val);
    // Unicorn: call registered IN callbacks
    struct hook *hook;
    HOOK_FOREACH_VAR_DECLARE;
    HOOK_FOREACH(uc, hook, UC_HOOK_INSN) {
        if (hook->to_delete)
            continue;
        if (hook->insn == UC_X86_INS_IN)
            return ((uc_cb_insn_in_t)hook->callback)(uc, addr, 1, hook->user_data);
    }
    return 0;
}

3.UC_HOOK_BLOCK

UC_HOOK_BLOCK用于跟蹤basic block的執行,因此最好的跟蹤點是一個basic block在處理之前:qemu/accel/tcg/translator.c的translator_loop函數for循環開始處:

if (HOOK_EXISTS_BOUNDED(uc, UC_HOOK_BLOCK, tb->pc)) {
        prev_op = tcg_last_op(tcg_ctx);
        block_hook = true;
        gen_uc_tracecode(tcg_ctx, 0xf8f8f8f8, UC_HOOK_BLOCK_IDX, uc, db->pc_first);
    }

用于生成IR的函數仍然是gen_uc_tracecode函數。

4.UC_HOOKMEM開頭的

以UC_HOOKMEM開頭跟蹤的是沒有映射的內存讀寫執行、正常的內存讀寫執行以及和權限相關的內存讀寫執行。

前面在分析qemu tcg的時候我們可以看到內存讀寫會先檢查tlb是否命中,如果不命中則會調用tlb helper函數走mmu并查找相應的HVA這條路,和內存加載相關的helper函數是load_helper(), 和內存存儲相關的helper函數是store_helper()。

那么在這兩個函數中添加代碼去調用unicorn回調函數不就可以達到跟蹤內存訪問的目的了嗎?

unicorn也正是這么做的,不過如果tlb命中了就不會調用tlb helper函數怎么辦?unicorn的解決方案是判斷如果有hook mem的回調函數,強制讓流程執行tlb slow path:

// Unicorn: fast path if hookmem is not enable
    if (!HOOK_EXISTS(s->uc, UC_HOOK_MEM_READ) && !HOOK_EXISTS(s->uc, UC_HOOK_MEM_WRITE))
        //沒有回調時走之前的邏輯
        tcg_out_opc(s, OPC_JCC_long + JCC_JNE, 0, 0, 0);
    else
        /* slow_path, so data access will go via load_helper() */
        tcg_out_opc(s, OPC_JMP_long, 0, 0, 0);

這樣一改所有內存讀寫都會走load_helper()store_helper()

unicorn為了快速判斷哪些內存訪問屬于"UNMAPPED"訪問,在uc_struct結構中的MemoryRegion **mapped_blocks成員中存放當前所有設置過內存映射的MemoryRegion區域,給定一個地址,調用如下函數可以快速得到對應的MemoryRegion:

MemoryRegion *memory_mapping(struct uc_struct *uc, uint64_t address)

如果獲取到的對象為null則表示該內存訪問屬于"UNMAPPED"訪問,從而調用相應的UC_HOOK_MEM_WRITE_UNMAPPED等回調。

5.UC_HOOK_INTR

由于unicorn沒有設備模擬的功能,因此UC_HOOK_INTR無法監聽硬件中斷,只能監聽同步異常如系統調用,前面在分析tcg功能的時候提到cpu_handle_exception函數是tcg處理同步異常的地方,unicorn也是在這里處理UC_HOOK_INTR回調的:

HOOK_FOREACH(uc, hook, UC_HOOK_INTR) {
            if (hook->to_delete) {
                continue;
            }
          //cpu->exception_index即是中斷號
            ((uc_cb_hookintr_t)hook->callback)(uc, cpu->exception_index, hook->user_data);
            catched = true;
        }

6.UC_HOOK_INSN_INVALID

對應著非法指令異常,對于arm來說,qemu/target/arm/translate.c文件用于反匯編arm指令,當遇到非法指令時會調用unallocated_encoding()函數引發非法指令異常,該異常號由EXCP_UDEF表示,然后arm_stop_interrput函數判斷是否為非法指令異常:

static bool arm_stop_interrupt(struct uc_struct *uc, int intno)
{
    switch (intno) {
    default:
        return false;
    case EXCP_UDEF:
    case EXCP_YIELD:
        return true;
    case EXCP_INVSTATE:
        uc->invalid_error = UC_ERR_EXCEPTION;
        return true;
    }
}

如果是非法指令異常則在cpu_handle_exception函數中調用非法指令異常的回調函數:

HOOK_FOREACH(uc, hook, UC_HOOK_INSN_INVALID) {
            if (hook->to_delete) {
                continue;
            }
            catched = ((uc_cb_hookinsn_invalid_t)hook->callback)(uc, hook->user_data);
            if (catched) {
                break;
            }
        }

四、總結

理解了qemu tcg的機制以后再去理解unicorn的功能就比較容易了,限于篇幅還有一些內容沒有描述清楚,歡迎大家一起交流、討論。