在qemu中模擬裝置
【介紹】
本文作為這個文件的補充:in nek:X86上的ARM Linux除錯環境 。
在那個文件中,我們已經可以在x86機器上模擬一個ARM Linux的執行環境。本文我們簡單介紹一下怎麼在qemu中模擬一個裝置給Guest Linux。這對於很多SoC軟體使能工程師來說很重要。因為對他們來說,單板都是受限的,而且軟體開發要和SoC同步進行,軟體開發初期不一定有SoC。再說,SoC模擬階段Bug一堆,硬體Simulator太慢,使用Emulator就成為最好的選擇了。
相比硬體Simulator(包括FPGA),Eumlator最大的好處是快(很多時候,比真實單板都快得多,因為可以精簡配置,一臺10個SAS介面的伺服器,我可以只留下一個埠來做除錯),自由度高(想模擬什麼情形就模擬什麼情形),缺點是非cylcle精確。但對軟體工程師來說這無所謂,因為本來我們就是要調軟體功能,不是調硬體實現。
qemu的裝置模擬原理很簡單,可以很快上手,是值得SoC軟體工程師作為一個必備技能來學習的。
首先我們理解一下Qemu的工作原理。很多系統工程師對Qemu有距離感,其實只是不熟悉,說起來,我覺得Qemu比Linux Kernel還是容易很多的。作為最基礎的原理,我原來寫過一個演示性的例子:in nek:一個非常簡單的CPU模擬器 ,那個只寫了幾個小時,當然並不實用,但用來說明模擬器是什麼已經足夠了。這一定程度上說明,模擬器在原理上並不複雜。
Qemu要解決具體問題,相對當然複雜得多,但得益於良好的封裝性,我們要在Qemu裡面加模擬裝置,需要知道的原理並不多。它的程式碼模型大概就是這樣的(我用Python當做偽碼來表述這個邏輯):
def run_a_guest(): vm = create_vm() vm.create_cpu_object() vm.create_device_object() for_each_cpu(cpu): create_thread(cpu_thread, cpu) def cpu_thread(cpu): while true: try: cpu.run(vm) except EIO eio: find_device(eio.io_address).handle_io();
對很多人來說,那個cpu.run()是最難理解的,在Qemu中有各種各樣的實現方式,比如基於qemu.ko的,基於TCG(翻譯執行),或者基於KVM的。但對於做裝置的人來說,這些統統不用管,你就認為它是個系統呼叫好了。如果是KVM,這個地方其實就是個ioctl(KVM_RUN),能執行到哪裡就執行到哪裡,如果執行不了了(比如IO空間被人訪問了),就從系統呼叫中返回,註冊了這片IO空間的裝置出來響應要求,進行一些處理。處理完了,就回去接著ioctl(KVM_RUN)就好了。
而這個裝置處理的整個過程,其實就是qemu這個程序在執行,這和一個普通的作業系統的程式設計環境沒有任何不同,完全就是響應IO空間的讀寫操作而已。這樣一想,是不是其實很簡單?
【增加裝置驅動】
首先,我們要能夠重新編譯qemu,這隨便上網一搜就是一大把,我在Ubuntu@x86_64上模擬ARM aarch64,編譯命令如下(我驗證的時候最新的stable版本是2.9):
git clone git://git.qemu.org/qemu.git apt-get build-deps qemu #安裝開發庫 cd qemu ./configure #如果喜歡,可以自己挑選具體要什麼特性 make
先確認你可以編譯過,這樣我們加東西的基礎就有了。
然後我們要加一個裝置驅動,qemu/hw/目錄裡面全部都是,每個就是一個裝置驅動,你找一種驅動來拷貝就好。
如果你要模擬PCIE裝置,推薦模仿edu.c,這個模組有文件(qemu/docs/devel),解釋比較充分,但你要圖簡單(模擬PCIE裝置你至少要模擬配置空間吧),你可以像我一樣,直接模擬platform_device,我選的是pl011,你自己可以先執行一個虛擬機器,然後到/sys/devices/platform裡面挑,看哪個順眼學哪個就好了。
要把這個檔案加入到編譯系統中,只需要在它所在目錄的Makefile.objs加一個xxx.o就可以了,方式和Linux Kernel基本上是一樣的。
一個裝置驅動類似一個Linux核心的LKM,通過type_init(type_init_function)定義,其語義空間如下:
Type/Class:一種裝置型別(相當於Java中的Class)
Instance:一個裝置例項(相當於Java中的Object)
通常你在Instance的初始化函式中申請一些MemoryRegion,註冊你的IO空間被訪問的回撥函式,問題就基本解決了)
注:更多資訊,參考後面的QOM一節。
【建立裝置】
增加裝置驅動僅僅是表明這個裝置可以被建立了,還沒有建立。裝置由“機器”來定義,就是你用-machine xxxx指定的那個東西。這也是一個驅動,比如我們在ARM平臺上常用virt這中平臺,用的機器定義就是qemu/hw/arm/virt.c。
這個也基本上不用學,你仿照其他裝置那樣建立一個裝置就可以了。一般包括兩個動作:
- 在某個匯流排下面建立裝置,比如在系統總線上建立裝置,我們可以:sysbus_create_simple(驅動的名字,IO地址,IRQ編號)。
- 建立dts或者acpi入口,這個都有標準函式,比如qemu_fdt_add_subnode()。
做完這個動作,用這個虛擬機器執行你的Linux,對應的裝置就能被發現到。
這是靜態的,動態的可以通過在命令列用-device來分配,這個讀者自己去摸吧,基本原理基本是一樣的。
【trace】
一般除錯這種驅動我們都不直接列印(因為虛擬機器還需要佔用控制檯呢),所以我們都有trace,trace可以通過qemu命令列-trace或者活在在qemu的控制檯中使能,怎麼用可以自己看手冊,我們這裡主要講程式設計介面。
trace的程式設計介面和Linux核心ftrace event很接近,但比Linux核心的介面容易很多。你不需要定義Linux ftrace那一大堆標頭檔案,qemu都寫成指令碼了,你只需要在目錄下面放一個trace-events檔案,裡面描述你的函式原形,然後在你的主程式中直接調就可以了。
這裡唯一要注意的是,qemu的Makefile做得比較蠢,如果你建立了新的目錄,需要在根目錄的Makefile.objs中更改trace-events-subdirs變數,把你的目錄包含進去,子目錄也必須手工加。
但僅僅trace需要這樣,你不用trace就不需要,簡單修改Makefile.objs就可以了。
【MemoryRegion】
好了,前面都是比較簡單的東西,最後我們重點理解一下qemu的MemoryRegion的概念。我們剛才說了,硬體模擬無外乎兩個東西,一個是中斷,一個是IO訪問。
中斷很簡單,知道中斷號,用qemu_set_irq()往裡種就可以了。記憶體區會麻煩很多,所以我們需要多介紹一些概念:
MemoryRegion:這表示一組面向Guest的,具有相同屬性的記憶體區。後面簡稱MR。
MemoryRegionCache:這表示一片為了滿足Guest需要的一片臨時的“真記憶體”。換句話說,MemoryRegion是描述一片記憶體區,MemoryRegionCache是真的要用的記憶體。後面簡稱MRC。
AddressSpace:這表示一個地址空間,一個地址空間可以包含多個不同屬性的MR。後面簡稱AS。
FlatView:這表示看到的地址空間。這就比較繞了。這麼說:AS是立體的,裡面的MR是相互獨立的,他們可以交疊。但當你去訪問的時候,某個時刻,某個實體地址總是對應著某個MR中的地址,FlatView用來表示層疊的結果。
MR可以有很多型別,其中前面提到的都是IO型別的,這種算是最簡單的。它的實際地址在建立裝置的時候給定,而在裝置驅動只要在instance的初始化函式中,從傳入的系統匯流排物件中就可以拿到了。一般方法是:
memory_region_init_io(&iomr, owner, ops, priv, name, size);
sysbus_init_mmio(sys_bus_device, &iomr);
這樣你就有了一個mr物件,Guest的訪問由ops的讀寫函式來響應。
但除了GPIO這種簡單裝置,幾乎沒有什麼裝置只有IO空間的,我們還需要做DMA。如果不使用IOMMU,這也很簡單,請求總是通過IO空間進來的,進來以後呼叫dma_memory_rw(&address_space_memory, pa, buf, size, directory)就可以了。那個address_space_memory是個全域性變數,就是整個虛擬機器的AS。反正整個物理空間你都有了,給你實體地址你想訪問啥不行啊。
但如果你需要IOMMU就不同了,你只拿到VA,VA到PA的對映你沒有,這需要另一種MR。所謂IOMMUMR。建立方法和io類似:
memory_region_init_iommu(&iommumr, instance_size, mrtypename, owner, name, size);
iommumr是我們要建立的MR記憶體,instance_size是它的大小,size是這個這個翻譯器的輸入地址的範圍(iova的範圍),其他域可以直接理解。唯一比較麻煩的是這個mrtypename。這個東西需要再建立一個父類是TYPE_IOMMU_MEMORY_REGION的新裝置型別,例如這樣:
static const TypeInfo rc4030_iommu_memory_region_info = { .parent = TYPE_IOMMU_MEMORY_REGION, .name = TYPE_RC4030_IOMMU_MEMORY_REGION, .class_init = rc4030_iommu_memory_region_class_init, };
然後在class_init中給這個域建立一組用於翻譯的函式就可以了。其中最核心的顯然是其中的translate函數了。我們簡單看看它的API定義:
IOMMUTLBEntry translate(IOMMUMemoryRegion *iommu, hwaddr addr, IOMMUAccessFlags flag, int iommu_idx);
iommu是操作上下文,addr是實體地址,flag是訪問屬性,iommu_idx基本上可以認為就0,它是基於不同訪問屬性使用多個index而已,可以根據需要決定怎麼用。返回值就是翻譯結果了。至於怎麼通過IO和實體地址把翻譯表傳給你的模擬驅動,前面的知識也夠了。這樣,我們基本上就有了模擬一個硬體的全部基礎知識了。
剩下的問題可能是花幾個小時試一試了。
【QOM】
這一章其實不太需要,但前面討論MR的時候,很多人肯定會注意到裡面的面向物件要素,我們這裡簡單總結一下Qemu Object Model。
Qemu是用C寫的,不支援面向物件特性,但偏偏裝置極為適合使用面向物件管理。所以Qemu寫了一套用C模擬的面向物件介面。這套介面的基本概念空間可以看看這裡:Features/QOM - QEMU。基本上可以這樣理解:
- 這是一個單繼承系統,每個物件只能有一個父類,在類定義的時候直接通過parent域指定(這是一個字串索引),所謂繼承,本質是把父類的資料結構直接拷貝到子類,這樣,子類總可以使用父類的屬性,如此而已。
- 所有Class都是device,bus是device的interface
- Class可以設定屬性,屬性意味這一對get/set回撥(qemu -device driver-name,help可以直接查詢device的屬性)