CPU虚拟化系列文章之虚拟机切入和退出
王柏生、謝廣軍
讀完需要
8
分鐘速讀僅需 3 分鐘
本文摘自于王柏生、謝廣軍撰寫的《深度探索 Linux 系統虛擬化:原理與實現》一書,重點討論了虛擬機 CPU 如何在 Host 模式和 Guest 模式之間切換,以及在 Host 模式和 Guest 模式切換時,KVM 及物理 CPU 是如何保存虛擬 CPU 的上下文的。
1
? ?
GCC 內聯匯編
KVM模塊中切入Guest模式的代碼使用GCC的內聯匯編編寫,為了理解這段代碼,我們需要簡要地介紹一下這段內聯匯編涉及的語法,其基本語法模板如下:
asm volatile ( assembler template : output operands /* optional */: input operands /* optional */: list of clobbered registers /* optional */);(1)關鍵字asm和volatile
asm為GCC關鍵字,表示接下來要嵌入匯編代碼,如果asm與程序中其他命名沖突,可以使用__asm__。
volatile為可選關鍵字,表示不需要GCC對下面的匯編代碼做任何優化,類似的,GCC也支持__volatile__。
(2)匯編指令(assembler template)
這部分即要嵌入的匯編指令,由于是在C語言中內聯匯編代碼,因此須用雙引號將命令括起來。如果內嵌多行匯編指令,則每條指令占用1行,每行指令使用雙引號括起來,以后綴\n\t結尾,其中\n為newline的縮寫,\t為tab的縮寫。由于GCC將每條指令以字符串的形式傳遞給匯編器AS,所以我們使用\n\t分隔符來分隔每一條指令,示例代碼如下:
__asm__ ("movl %eax, %ebx \n\t""movl $56, %esi \n\t""movl %ecx, $label(%edx,%ebx,$4) \n\t""movb?%ah,?(%ebx)?\n\t");當使用擴展模式,即包含output、input和clobber list部分時,匯編指令中需要使用兩個“%”來引用寄存器,比如%%rax;使用一個“%”來引用輸入、輸出操作數,比如%1,以便幫助GCC區分寄存器和由C語言提供的操作數。
(3)輸出操作數(output operands)
內聯匯編有零個或多個輸出操作數,用來指示內聯匯編指令修改了C代碼中的變量。如果有多個輸出參數,則需要對每個輸出參數進行分隔。每個輸出操作數的格式為:
[[asmSymbolicName]] constraint (cvariablename)我們可以為輸出操作數指定一個名字asmSymbolicName,匯編指令中可以使用這個名字引用輸出操作數。
除了使用名字引用操作數外,還可以使用序號引用操作數。比如輸出操作數有兩個,那么可以用%0引用第1個輸出操作數,%1引用第2個操作數,以此類推。
輸出操作數的約束部分必須以“=”或者“+”作為前綴,“=”表示只寫,“+”表示讀寫。在前綴之后,就可以是各種約束了,比如“=a”表示先將結果輸出至rax/eax寄存器,然后再由rax/eax寄存器更新相應的輸出變量。
cvariablename為代碼中的C變量名字,需要使用括號括起來。
(4)輸入操作數(input operands)
內聯匯編可以有零個或多個輸入操作數,輸入操作數來自C代碼中的變量或者表達式,作為匯編指令的輸入,每個輸入操作數的格式如下:
[[asmSymbolicName]] constraint (cexpression)同輸出操作數相同,也可以為每個輸入操作數指定名字asmSymbolicName,匯編指令中可以使用這個名字引用輸入操作數。
除了使用名字引用輸入操作數外,還可以使用序號引用輸入操作數。輸入操作數的序號以最后一個輸出操作數的序號加1開始,比如輸出操作數有兩個,輸入操作數有3個,那么需要使用%2引用第1個輸入操作數,%3引用第2個輸入操作數,以此類推。
除了不必以“=”或者“+”前綴開頭外,輸入操作數的前綴與輸出操作數基本相同。除了寄存器約束外,在后面的代碼中我們還會看到“i”這個約束,表示這個輸入操作數是個立即數(immediate integer)。
?cexpression為代碼中的C變量或者表達式,需要使用括號括起來。
(5)clobber list
某些匯編指令執行后會有一些副作用,可能會隱性地影響某些寄存器或者內存的值,如果被影響的寄存器或者內存并沒有在輸入、輸出操作數中列出來,那么需要將這些寄存器或者內存列入clobber list。通過這種方式,內聯匯編告知GCC,需要GCC“照顧”好這些被影響的寄存器或者內存,比如必要時需要在執行內聯匯編指令前保存好寄存器,而在執行內聯匯編指令后恢復寄存器的值。
接下來我們來看一個具體的例子。這個例子是一個加法運算,一個加數是val,值為100,另外一個加數是一個立即數400,計算結果保存到變量sum中:
?int?val?=?100,?sum?=?0;asm?("movl?%1,?%%rax;?\n\t""movl?%c[addend],?%%rbx;?\n\t""addl?%%rbx,?%%rax;?\n\t"“movl?%%rax,?%0;?\n\t”:?“=”(sum):?(c)(val),?[addend]”i”(400):?“rbx”);我們先來看第3行的匯編指令。因為存在寄存器引用和通過序號引用的操作數,所以使用兩個“%”引用寄存器。%1引用的是輸入操作數val,其中c表示使用rcx寄存器保存val,也就是說在執行這條匯編指令前,首先將val的值賦值到rcx寄存器中,然后匯編指令再將rcx寄存器的值賦值到rax寄存器中。
第4行的匯編指令引用的addend是第2個輸入操作數的符號名字,因為這是一個立即數,所以這個變量前面使用了c修飾符。這是GCC的一個語法,表示后面是個立即數。
第5條指令求rbx寄存器和rax寄存器的和,并將結果保存到rax寄存器中。
第6條指令中的%0引用的是輸出操作數sum,這是C代碼中的變量,因為sum是只寫的輸出操作數,所以使用約束“=”。所以第6行的匯編指令是將計算的結果存儲到變量sum中。
從這段代碼中我們看到,在匯編代碼中使用了rbx寄存器,而rbx寄存器沒有出現在輸出、輸入操作數中,所以內聯匯編需要把rbx寄存器列入clobber list中,見第10行代碼,告訴GCC匯編指令污染了rbx寄存器,如果有必要,則需要在執行內聯匯編指令前自行保存rbx寄存器,執行內聯匯編指令后再自行恢復rbx寄存器。
2
? ?
虛擬機切入和退出及相關的上下文保存
了解了內聯匯編的語法后,接下來我們開始探討虛擬機切入和退出部分的內聯匯編指令:
?static?void?vmx_vcpu_run(struct?kvm_vcpu?*vcpu){struct?vcpu_vmx?*vmx?=?to_vmx(vcpu);…asm(/*?Store?host?registers?*/"push?%%"R"dx;?push?%%"R"bp;""push?%%"R"cx?\n\t""cmp?%%"R"sp,?%c[host_rsp](%0)?\n\t""je?1f?\n\t""mov?%%"R"sp,?%c[host_rsp](%0)?\n\t"__ex(ASM_VMX_VMWRITE_RSP_RDX)?"\n\t""1:?\n\t"/*?Reload?cr2?if?changed?*/"mov?%c[cr2](%0),?%%"R"ax?\n\t""mov?%%cr2,?%%"R"dx?\n\t""cmp?%%"R"ax,?%%"R"dx?\n\t""je?2f?\n\t""mov?%%"R"ax,?%%cr2?\n\t""2:?\n\t"/*?Check?if?vmlaunch?of?vmresume?is?needed?*/"cmpl?$0,?%c[launched](%0)?\n\t"/*?Load?guest?registers.??Don't?clobber?flags.?*/"mov?%c[rax](%0),?%%"R"ax?\n\t""mov?%c[rbx](%0),?%%"R"bx?\n\t"…"mov?%c[rcx](%0),?%%"R"cx?\n\t"?/*?kills?%0?(ecx)?*//*?Enter?guest?mode?*/"jne?.Llaunched?\n\t"__ex(ASM_VMX_VMLAUNCH)?"\n\t""jmp?.Lkvm_vmx_return?\n\t"".Llaunched:?"?__ex(ASM_VMX_VMRESUME)?"\n\t"".Lkvm_vmx_return: "/* Save guest registers, load host registers, keep …*/"xchg %0, (%%"R"sp) \n\t""mov?%%"R"ax,?%c[rax](%0)?\n\t""mov?%%"R"bx,?%c[rbx](%0)?\n\t""pop"Q"?%c[rcx](%0)?\n\t""mov?%%"R"dx,?%c[rdx](%0)?\n\t"…"mov?%%cr2,?%%"R"ax???\n\t""mov?%%"R"ax,?%c[cr2](%0)?\n\t""pop??%%"R"bp;?pop??%%"R"dx?\n\t""setbe?%c[fail](%0)?\n\t":?:?"c"(vmx),?"d"((unsigned?long)HOST_RSP),[launched]"i"(offsetof(struct?vcpu_vmx,?launched)),[fail]"i"(offsetof(struct?vcpu_vmx,?fail)),[host_rsp]"i"(offsetof(struct?vcpu_vmx,?host_rsp)),[rax]"i"(offsetof(struct?vcpu_vmx,?vcpu.arch.regs[VCPU_REGS_RAX])),[rbx]"i"(offsetof(struct?vcpu_vmx,?vcpu.arch.regs[VCPU_REGS_RBX])),…[cr2]"i"(offsetof(struct?vcpu_vmx,?vcpu.arch.cr2)):?"cc",?"memory",?R"ax",?R"bx",?R"di",?R"si"#ifdef?CONFIG_X86_64,?"r8",?"r9",?"r10",?"r11",?"r12",?"r13",?"r14",?"r15"#endif);…}CPU從Host模式切換到Guest模式時,并不會自動保存部分寄存器,典型的比如通用寄存器。因此,第7行代碼KVM將宿主機的通用寄存器保存到棧中。當發生VM退出時,KVM從棧中將這些保存的宿主機的通用寄存器恢復到CPU的物理寄存器中。這里,宏R在64位下值為r,32位下為e,所以通過定義這個宏,從編碼層面更簡潔地支持64位和32位。但是讀者可能有疑問,為什么這里只保存這兩個寄存器?事實上,KVM最初的實現是將所有的通用寄存器都壓入棧中了。后來使用了GCC內聯匯編的clobber list特性,將所有可能會被內聯匯編代碼影響的寄存器都寫入clobber list中,GCC自己負責保存和恢復操作這些寄存器的內容。代碼第57~61行就是clobber list。這里面有兩個特殊的寄存器:rdx/edx和rbp/ebp,其中rdx/edx寄存器是GCC保留的regparm特性,不能放在clobber list中,另外一個rbp/ebp寄存器也不生效,所以KVM手動保存了這兩個寄存器。
此外,KVM在第8行代碼保存了rcx/ecx寄存器,這里的rcx/ecx寄存器有著特殊的使命。當從Guest退出到Host時,CPU不會自動保存Guest的一些寄存器,典型的如通用寄存器,KVM手動將其保存到了結構體vcpu_vmx中的子結構體中。因此,在Guest退出的那一刻,首先必須要獲取結構體vcpu_vmx的實例,也就是第3行代碼中的變量vmx,將CPU寄存器中的狀態保存到這個vmx中,也就是說,在保存完Guest的狀態后,才能進行其他操作,避免破壞Guest的狀態。于是,每次從Host切入Guest前的最后一刻,KVM將vmx的地址壓入棧頂,然后在Guest退出時從棧頂第一時間取出vmx。那么如何將vmx壓入棧頂呢?參見第47行代碼,這里使用了GCC內聯匯編的input約束,即在執行匯編代碼前,告訴編譯器將變量vmx加載到rcx/ecx寄存器,那么在執行第8行代碼,即將rcx/ecx寄存器的內容壓入棧時,實際上是將變量vmx壓入棧頂了。
在Guest退出時,CPU會自動將VMCS中Host的rsp/esp寄存器恢復到物理CPU的rsp/esp寄存器中,所以此時可以訪問VCPU線程在Host態下的棧。在Guest退出后的第1行代碼,即第36行代碼,調用xchg指令將棧頂的值和序號%0指代的變量進行交換,根據第47行代碼可見,%0指代變量vmx,對應的寄存器是rcx/ecx,也就是說,這行代碼將切入Guest之前保存到棧頂的變量vmx的地址恢復到了rcx/ecx寄存器中,%0引用的也是這個地址,那么就可以使用%0引用這個地址保存Guest的寄存器了。
讀者可能會問,Guest沒有使用變量vmx,也沒有破壞它,那么Host是否可以直接使用這個變量呢?事實上,從底層來看,對于存放在棧中的變量vmx,GCC通常使用棧幀基址指針rbp/ebp或寄存器引用。但是,在Guest退出的第一時間,除了專用寄存器,這些通用寄存器中保存的都是Guest的狀態,所以自然也無法通過rbp/ebp加偏移的方式來引用vmx。因為退出Guest時CPU自動恢復Host的棧頂指針,所以KVM巧妙地利用了這一點,借助棧頂保存vmx。然后,通過交換棧頂的變量和rcx/ecx寄存器,實現了在rcx/ecx寄存器中引用vmx的同時,又將Guest的rcx/ecx寄存器的狀態保存到了棧中。
獲取到了保存Guest狀態的地址,接下來保存Guest的狀態,見代碼第37~43行。
退出Guest后的第1行代碼(即第36行)將Guest的rcx/ecx寄存器的值保存到了棧中,所以第39行代碼從棧頂彈出Guest的rcx/ecx的值到保存Guest狀態的內存中rcx/ecx相應的位置。
并不是每次Guest退出到切入,Host的棧都會發生變化,因此Host的rsp/esp也無須每次都更新。只有rsp/esp變化了,才需要更新VMCS中Host的rsp/esp字段,以減少不必要的寫VMCS操作。所以KVM在VCPU中記錄了host_rsp的值,用來比較rsp/esp是否發生了變化,見代碼第9~13行。
將Host的rsp/esp寫入VMCS中的指令是:
ASM_VMX_VMWRITE_RSP_RDX寫VMCS的指令有兩個參數,一個指明寫VMCS中哪個字段,另外一個是寫入的值。rsp/esp很好理解,指明寫入的值在rsp/esp寄存器里。那么rdx是什么呢?見第47行代碼對寄存器rdx/edx的約束:
"d"((unsigned long)HOST_RSP)結合宏HOST_RSP的定義:
/* VMCS Encodings */ enum vmcs_field {…HOST_RSP = 0x00006c14,… };可見,ASM_VMX_VMWRITE_RSP_RDX就是將rsp/esp的值寫入VMCS中Host的rsp字段。
VMX沒有定義CPU自動保存cr2寄存器,但是事實上,Host可能更改cr2的值,以下面這段代碼為例:
commit 1c696d0e1b7c10e1e8b34cb6c797329e3c33f262 KVM: VMX: Simplify saving guest rcx in vmx_vcpu_run linux.git/arch/x86/kvm/x86.cvoid kvm_inject_page_fault(struct kvm_vcpu *vcpu, …) {++vcpu->stat.pf_guest;vcpu->arch.cr2 = fault->address;kvm_queue_exception_e(vcpu, PF_VECTOR, fault->error_code); }所以,在切入Guest前,KVM檢測物理CPU的cr2寄存器與VCPU中保存的Guest的cr2寄存器是否相同,如果不同,則需要使用Guest的cr2寄存器更新物理CPU的cr2寄存器,見第14~20行代碼。但是絕大數情況下,從Guest退出到下一次切入Guest,cr2寄存器的值不會發生變化,另一方面,加載cr2寄存器的開銷很大,所以只有在cr2寄存器發生變化時才需要重新加載cr2寄存器。
有些Guest的退出是由頁面異常引起的,比如通過MMIO方式訪問外設的I/O,而頁面異常的地址會記錄在cr2寄存器中,因此在Guest退出時,KVM需要保存Guest的cr2,見代碼第42~43行。由于指令格式的限制,mov指令不支持控制寄存器到內存地址的復制,因此需要通過rax/eax寄存器中轉一下。
在切入Guest前,除了加載cr2寄存器外,還需要加載那些物理CPU不會自動加載的通用寄存器,見代碼第24~27行。
考慮到xchg是個原子操作,會鎖住地址總線,因此為了提高效率,后來KVM摒棄了這條指令,設計了一種新的方案。KVM在VCPU的棧中為Guest的rcx/ecx寄存器分配了一個位置。這樣,當Guest退出時,在使用rcx/ecx寄存器引用變量vmx前,可以將Guest的rcx/ecx寄存器臨時保存到VCPU的棧中為其預留的位置:
commit 40712faeb84dacfcb3925a88231daa08b3624d34 KVM: VMX: Avoid atomic operation in vmx_vcpu_run linux.git/arch/x86/kvm/vmx.cstatic?void?vmx_vcpu_run(struct?kvm_vcpu?*vcpu){…asm(/* Store host registers */"push %%"R"dx; push %%"R"bp;""push %%"R"cx \n\t" /* placeholder for guest rcx */"push %%"R"cx \n\t"…".Lkvm_vmx_return: "/* Save guest registers, load host registers, …*/"mov %0, %c[wordsize](%%"R"sp) \n\t""pop %0 \n\t""mov %%"R"ax, %c[rax](%0) \n\t""mov %%"R"bx, %c[rbx](%0) \n\t""pop"Q" %c[rcx](%0) \n\t"…[wordsize]"i"(sizeof(ulong))…}第7行代碼就是KVM為Guest的rcx/ecx寄存器在棧上預留的空間,第8行代碼是將變量vmx壓入棧中。
在Guest退出的那一刻,CPU的rcx/ecx寄存器中存儲的是Guest的狀態,所以使用rcx/ecx寄存器前,需要將Guest的狀態保存起來。保存的位置就是進入Guest前,KVM為其在棧上預留的位置,即棧頂的下一個位置,見第12行代碼,即棧頂加上一個字(word)的偏移。
保存好Guest的值后,rcx/ecx寄存器就可以使用了,第13行代碼將棧頂的值即vmx彈出到rcx/ecx寄存器中。彈出棧頂的vmx后,下面就是Guest的rcx/ecx寄存器了,所以第16行代碼將Guest的rcx/ecx寄存器保存到結構體VCPU中的相關寄存器數組中。
作者簡介:
王柏生
資深技術專家,先后就職于中科院軟件所、紅旗Linux和百度,現任百度主任架構師。在操作系統、虛擬化技術、分布式系統、云計算、自動駕駛等相關領域耕耘多年,有著豐富的實踐經驗。
著有暢銷書《深度探索Linux操作系統》(2013年出版)。
謝廣軍
計算機專業博士,畢業于南開大學計算機系。
資深技術專家,有多年的IT行業工作經驗。現擔任百度智能云副總經理,負責云計算相關產品的研發。多年來一直從事操作系統、虛擬化技術、分布式系統、大數據、云計算等相關領域的研發工作,實踐經驗豐富。
?
*本文經出版社授權發布,更多關于虛擬化技術的內容推薦閱讀《深度探索Linux系統虛擬化:原理與實現》。
- EOF -
想要加入中生代架構群的小伙伴,請添加群合伙人大白的微信
申請備注(姓名+公司+技術方向)才能通過哦!
精彩文章推薦
RocketMQ 專家丁威:Kafka 和 RocketMQ 從性能角度對比
解密滴滴黑科技:超低功耗桔視ADAS落地實踐
DDD專家張逸:《解構領域驅動設計》前言
張凱江:架構能力-“構建”世界的能力
申通快遞在雙11的云原生應用實踐
輕輕一掃,立刻扣款,付款碼背后的原理你不想知道嗎?
? ?END ? ?? #架構師必備#點分享點點贊點在看
總結
以上是生活随笔為你收集整理的CPU虚拟化系列文章之虚拟机切入和退出的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 2022国内低代码平台厂商排行榜—经典收
- 下一篇: Miniconda3及pip换源(con