A common thing for those of us in this world to do is write an toy OS. And while I’ve never quite done that it may still be in the cards someday. But I am hyper-interested (see what I did there) in virtualization. And in my quest to continue on the path of learning Rust I figured it would be fun to write one in Rust. That way I learn a lot about low level virtualization details which includes the CPU support necessary for it as well as hopefully a couple of things about virtualizing various operating systems, microkernels, etc.
So, where do we start? Well there are a number of resources for this goal and I’ll include anything I use at the bottom. A lot of this will be a restatement or reframing of code and information I find in the resources. When code is involved I will try and specifically give credit. Hopefully I’ll also have something helpful to add!
Our goal will be to write a type 2 hypervisor. That is to say one that loads like any other application and so requires an operating system to operate. It will not be a bare-metal hypervisor also called type 1.
So all of my work currently will be on Intel processors that’s what we’re targeting. It would be nice to one day adapt this to an AMD as a seperate exercise.
The first thing we want to do is see if our CPU is what we expect and has the features we expect. So start a Rust project however you want, I love IntelliJ with the Rust plugin but a simple text file for main.rs will work too after you use Cargo to start a new project cargo new new_project.
We’re going to use code we find at resource #1 from memN0ps. I would also like to take a moment to mention how much I like Sourcegraph. It’s great to find examples of invocations, usage, etc. If you don’t understand where or how a code snippet fits into a larger source file, package, etc. then you can find other usages of the fuction, struct, etc and figure it all out.
Just like in memN0ps’ example we’re going to use a custom HyperVisorError that we create to handle errors, which will be made using thiserror-no-std crate. But we’re going to start with a smaller enum and add to it as we go. For right now we’ll panic if we don’t have the CPU and features we expect.
Your Cargo.toml should look something like this.
[package]
name = "hackvisor"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
x86 = { version = "0.52.0", default-features = false }
thiserror-no-std = "2.0.2"
Put this in main.rs. I’m a sucker for “Hello World” so we’ll leave that there so we know our program ran.
use x86::cpuid::*;
use thiserror_no_std::Error;
#[derive(Error, Debug)]
pub enum HypervisorError {
#[error("Intel CPU not found")]
CPUUnsupported,
}
fn main() {
println!("Hello, world!");
let res = has_intel_cpu();
match res {
Ok(_) => {}
Err(err) => {
panic!("{}", err);
}
}
}
/// Check to see if CPU is Intel (“GenuineIntel”).
pub fn has_intel_cpu() -> Result<(), HypervisorError> {
let cpuid = CpuId::new();
if let Some(vi) = cpuid.get_vendor_info() {
if vi.as_str() == "GenuineIntel" {
// return Ok(());
return Err(HypervisorError::CPUUnsupported);
}
}
Err(HypervisorError::CPUUnsupported)
}
In this code it should panic if you have an Intel CPU. This is just a test. Let’s change the code so it doesn’t panic. Replace the has_intel_cpu with the code below or make the necessary modification.
/// Check to see if CPU is Intel (“GenuineIntel”).
pub fn has_intel_cpu() -> Result<(), HypervisorError> {
let cpuid = CpuId::new();
if let Some(vi) = cpuid.get_vendor_info() {
if vi.as_str() == "GenuineIntel" {
return Ok(())
}
}
Err(HypervisorError::CPUUnsupported)
}
Let’s add a check for VMX (again coming from memN0ps).
/// Check processor supports for Virtual Machine Extension (VMX) technology - CPUID.1:ECX.VMX[bit 5] = 1 (Intel Manual: 24.6 Discovering Support for VMX)
pub fn has_vmx_support() -> Result<(), HypervisorError> {
let cpuid = CpuId::new();
if let Some(fi) = cpuid.get_feature_info() {
if fi.has_vmx() {
return Ok(());
}
}
Err(HypervisorError::VMXUnsupported)
}
Let’s modify our main function now so that we panic if we have an Intel CPU but not VMX.
fn main() {
println!("Hello, world!");
let res = has_intel_cpu();
match res {
Ok(()) => {
// OK, we have an Intel CPU. Does it have VMX?
println!("We have an Intel CPU");
let res = has_vmx_support();
match res {
Ok(()) => {
println!("We have a CPU that supports VMX too.");
}
Err(err) => {
panic!("{}", err);
}
}
}
Err(err) => {
panic!("{}", err);
}
}
}
Let’s add the value to our enum for that error too.
#[derive(Error, Debug)]
pub enum HypervisorError {
#[error("Intel CPU not found")]
CPUUnsupported,
#[error("VMX is not supported")]
VMXUnsupported,
}
What’s next? Well we need to enable VMX operation. How do we know that? Well simply because memN0ps tells us to! OK, but seriously we can reference Volume 3 of the Software Developer’s Manual for Intel® 64 and IA-32 Architectures. It’s in Chapter 24 - Introduction to Virtual Machine Extensions. Following that is Chapter 25 - Virtual Machine Control Structures and all the way through Chapter 31 are topics about Virtual Machine Extensions.
Before we can enable VMX we need to set CR4.VMXE[bit 13] = 1. But before we can even that that there are a number of things we have to setup first. memN0ps’ tutorial lays it out in a way that’s more confusing to me so we’ll do it slightly differently. We’re going to start from main where we would launch the virtual machine and work to resolve what’s needed from there.
So we’re going to start with two structs: a hypervisor builder and a hypervisor. The hypervisor builder will clearly give us an instance of the hypervisor or an error.
#[derive(Default)]
pub struct HypervisorBuilder {
}
pub struct Hypervisor {
}
I’ll just be appending all of this code to the bottom of main.rs. Certainly not an ideal practice but we’ll refactor it later with the mantra of Make It Work, Make It Right and then Make It Fast.
Let’s add a function to the builder implementation so that we can return a Hypervisor instance.
impl HypervisorBuilder {
pub fn build(self) -> Result<Hypervisor, HypervisorError> {
println!("Building Hypervisor");
Ok(Hypervisor{})
}
Let’s add a method to init our Hypervisor and do nothing right now except print a statement.
impl Hypervisor {
pub fn init(self) {
println!("Hypervisor initialized.");
}
}
Now we’ll modify our match arm where we’ve determined we have a CPU with the right features to include creating the HypervisorBuilder and building a Hypervisor and then calliing init. Inside that match arm modify the code to be the following.
println!("We have a CPU that supports VMX too.");
let hb = HypervisorBuilder::default();
let hypv = hb.build().expect("Failed to build Hypervisor");
hypv.init();
Once you build that and run it your terminal output should be this.
Hello, world!
We have an Intel CPU
We have a CPU that supports VMX too.
Hypervisor initialized.
At this point the post is pretty long. We’ve not done anything really to build an actual Hypervisor but I wanted this to be a more gentle approach so that if you are also new to Rust AND building a Hypervisor then it’ a more gradual build up. Hope it was helpful!
1 - Hypervisor Development in Rust - - Part 1