diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6444fc47..3a9063f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,10 @@ jobs: if: matrix.arch == 'x86_64' - run: cargo xtask ci qemu ${{ matrix.flags }} --target ${{ matrix.arch }}-multiboot --microvm --release if: matrix.arch == 'x86_64' + - run: cargo xtask ci qemu ${{ matrix.flags }} --target ${{ matrix.arch }}-uefi --esp + if: matrix.arch == 'x86_64' + - run: cargo xtask ci qemu ${{ matrix.flags }} --target ${{ matrix.arch }}-uefi --esp --release + if: matrix.arch == 'x86_64' - run: cargo xtask ci qemu ${{ matrix.flags }} --target ${{ matrix.arch }}-uefi if: matrix.arch == 'x86_64' - run: cargo xtask ci qemu ${{ matrix.flags }} --target ${{ matrix.arch }}-uefi --release diff --git a/README.md b/README.md index 1381e7f5..0106b153 100644 --- a/README.md +++ b/README.md @@ -43,18 +43,7 @@ qemu-system-x86_64 \ #### UEFI Boot -For booting from UEFI, we have to set up an EFI system partition (ESP). -OVMF will automatically load and execute the loader if placed at `\EFI\BOOT\BOOTX64.EFI` in the ESP. -The Hermit application has to be next to the loader with the filename `hermit-app`. -You can set the ESP up with the following commands: - -```bash -$ mkdir -p esp/EFI/BOOT -$ cp esp/EFI/BOOT/BOOTX64.EFI -$ cp esp/EFI/BOOT/hermit-app -``` - -Then, you can boot Hermit like this: +When using UEFI, you can boot Hermit in a similar way: ```bash qemu-system-x86_64 \ @@ -66,9 +55,14 @@ qemu-system-x86_64 \ -display none -serial stdio \ -drive if=pflash,format=raw,readonly=on,file= \ -drive if=pflash,format=raw,readonly=on,file= \ - -drive format=raw,file=fat:rw:esp + -kernel \ + -initrd ``` +This is possible thanks to a build-time transformation of the loader, applied when building using `xtask`. +If this transformation breaks, or if you are not building with `xtask`, you can instead make use of an EFI system partition. +Refer to a [previous version](https://github.com/hermit-os/loader/blob/4ba86e4048ce770b8584727f8fc0f1f8f1b7f510/README.md#uefi-boot) of this file for precise instructions. + #### No KVM If you want to emulate x86-64 instead of using KVM, omit `-enable-kvm` and set the CPU explicitly to a model of your choice, for example `-cpu Skylake-Client`. diff --git a/src/os/uefi/allocator.rs b/src/os/uefi/allocator.rs index caaf21c5..d6331bd7 100644 --- a/src/os/uefi/allocator.rs +++ b/src/os/uefi/allocator.rs @@ -49,7 +49,7 @@ static ALLOCATOR: LockedAllocator = LockedAllocator::uefi(); pub fn exit_boot_services() { assert!(matches!(*ALLOCATOR.0.lock(), GlobalAllocator::Uefi)); - let mem = vec![MaybeUninit::uninit(); 4096].leak(); + let mem = vec![MaybeUninit::uninit(); 0x2000].leak(); let bump = BumpAllocator::from(mem); diff --git a/src/os/uefi/mod.rs b/src/os/uefi/mod.rs index 63b59263..13f9114c 100644 --- a/src/os/uefi/mod.rs +++ b/src/os/uefi/mod.rs @@ -14,9 +14,11 @@ use hermit_entry::boot_info::{ }; use hermit_entry::elf::{KernelObject, LoadedKernel}; use log::{error, info}; -use uefi::boot::{AllocateType, MemoryType, PAGE_SIZE}; +use uefi::CString16; +use uefi::boot::{AllocateType, MemoryType, PAGE_SIZE, open_protocol_exclusive}; use uefi::fs::{self, FileSystem, Path}; use uefi::prelude::*; +use uefi::proto::loaded_image::LoadedImage; use uefi::table::cfg::ConfigTableEntry; pub use self::console::CONSOLE; @@ -29,9 +31,18 @@ fn main() -> Status { uefi::helpers::init().unwrap(); crate::log::init(); - let mut esp = Esp::new().unwrap(); - - let kernel_image = esp.read_app(); + let kernel_args = KernelArguments::new().unwrap(); + let mut esp = BootPartition::new().unwrap(); + + let kernel_image = if let Some(path) = kernel_args + .as_ref() + .and_then(|arg| arg.initrd_path.as_ref()) + { + esp.read_app_at(path.as_ref()) + .expect("Could not open kernel image provided in initrd") + } else { + esp.read_app() + }; let kernel = KernelObject::parse(&kernel_image).unwrap(); let kernel_memory = alloc_page_slice(kernel.mem_size()).unwrap(); @@ -48,7 +59,9 @@ fn main() -> Status { .rsdp(u64::try_from(rsdp.expose_provenance()).unwrap()) .unwrap(); - if let Some(bootargs) = esp.read_bootargs() { + if let Some(kernel_args) = kernel_args { + fdt = fdt.bootargs(kernel_args.hermit_args).unwrap(); + } else if let Some(bootargs) = esp.read_bootargs() { fdt = fdt.bootargs(bootargs).unwrap(); } @@ -122,11 +135,11 @@ fn rsdp() -> *const c_void { }) } -pub struct Esp { +pub struct BootPartition { fs: FileSystem, } -impl Esp { +impl BootPartition { pub fn new() -> uefi::Result { let image_handle = boot::image_handle(); let fs = boot::get_image_file_system(image_handle)?; @@ -186,3 +199,42 @@ impl Esp { inner(&mut self.fs, path.as_ref()) } } + +/// Reads arguments passed when using Kernel Direct Boot (`-kernel -initrd` arguments with UEFI +/// support) +#[derive(Debug)] +struct KernelArguments { + /// Arguments that should be forwarded to Hermit + hermit_args: String, + + /// Image path, overriding default option + initrd_path: Option, +} + +impl KernelArguments { + pub fn new() -> uefi::Result> { + let image_handle = boot::image_handle(); + let loaded_image = open_protocol_exclusive::(image_handle)?; + let Some(raw_options) = loaded_image.load_options_as_cstr16().ok() else { + return Ok(None); + }; + + let raw_options: String = raw_options.into(); + let args = if let Some(rest) = raw_options.strip_prefix("initrd=") { + let (initrd, rest) = rest.split_once(' ').unwrap(); + Self { + hermit_args: rest.into(), + initrd_path: Some(initrd.try_into().unwrap()), + } + } else { + Self { + hermit_args: raw_options, + initrd_path: None, + } + }; + + info!("Read QEMU kernel arguments: {args:?}"); + + Ok(Some(args)) + } +} diff --git a/xtask/src/artifact.rs b/xtask/src/artifact.rs index 20438118..638d100f 100644 --- a/xtask/src/artifact.rs +++ b/xtask/src/artifact.rs @@ -76,6 +76,16 @@ impl Artifact { fn release_args(&self) -> &'static [&'static str] { if self.release { &["--release"] } else { &[] } } + + /// Applies any required post-build transformation on this artifact + pub fn post_build(&self) { + if self.target != Target::X86_64Uefi { + return; + } + + // EFI files need to be transformed slightly to allow booting with `-kernel` in QEMU + super::pe::PEFile::load_from_path(self.build_object().as_ref()).rewrite(); + } } pub trait CmdExt { diff --git a/xtask/src/build.rs b/xtask/src/build.rs index dfe3b0ac..fe7272c9 100644 --- a/xtask/src/build.rs +++ b/xtask/src/build.rs @@ -30,6 +30,8 @@ impl Build { .cargo_build_args(&self.artifact) .run()?; + self.artifact.post_build(); + let build_object = self.artifact.build_object(); let dist_object = self.artifact.dist_object(); eprintln!( diff --git a/xtask/src/ci/qemu.rs b/xtask/src/ci/qemu.rs index d989d280..9c5e0d2a 100644 --- a/xtask/src/ci/qemu.rs +++ b/xtask/src/ci/qemu.rs @@ -28,6 +28,11 @@ pub struct Qemu { #[arg(long)] u_boot: bool, + /// Run with an EFI System Partition. + /// Only used for x86_64-uefi + #[arg(long)] + esp: bool, + #[command(flatten)] build: Build, @@ -82,7 +87,7 @@ impl Qemu { let sh = crate::sh()?; match self.build.target() { - Target::X86_64Uefi => { + Target::X86_64Uefi if self.esp => { sh.remove_path("target/esp")?; // Spec: https://uefi.org/specs/UEFI/2.11/03_Boot_Manager.html#removable-media-boot-behavior @@ -190,7 +195,7 @@ impl Qemu { .unwrap(), ); } - Target::X86_64Uefi => { + Target::X86_64Uefi if self.esp => { use ovmf_prebuilt::{Arch, FileType, Prebuilt, Source}; let prebuilt = Prebuilt::fetch(Source::LATEST, "target/ovmf") @@ -211,6 +216,41 @@ impl Qemu { cpu_args.push("-drive".to_string()); cpu_args.push("format=raw,file=fat:rw:target/esp".to_string()); } + Target::X86_64Uefi => { + use ovmf_prebuilt::{Arch, FileType, Prebuilt, Source}; + + let prebuilt = Prebuilt::fetch(Source::LATEST, "target/ovmf") + .expect("failed to update prebuilt"); + let code = prebuilt.get_file(Arch::X64, FileType::Code); + let vars = prebuilt.get_file(Arch::X64, FileType::Vars); + + cpu_args.push("-drive".to_string()); + cpu_args.push(format!( + "if=pflash,format=raw,readonly=on,file={}", + code.display() + )); + cpu_args.push("-drive".to_string()); + cpu_args.push(format!( + "if=pflash,format=raw,readonly=on,file={}", + vars.display() + )); + cpu_args.push("-kernel".to_string()); + cpu_args.push( + self.build + .dist_object() + .into_os_string() + .into_string() + .unwrap(), + ); + cpu_args.push("-initrd".to_string()); + cpu_args.push( + self.build + .ci_image(self.image.as_deref().unwrap()) + .into_os_string() + .into_string() + .unwrap(), + ); + } _ => unreachable!(), } cpu_args diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ae74c2bc..e818a983 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -6,6 +6,7 @@ mod build; mod ci; mod clippy; mod object; +mod pe; mod target; use std::env; diff --git a/xtask/src/pe.rs b/xtask/src/pe.rs new file mode 100644 index 00000000..b940c475 --- /dev/null +++ b/xtask/src/pe.rs @@ -0,0 +1,177 @@ +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; + +/// A helper struct to parse PE files and give them the Linux Boot Protocol attributes +/// that QEMU expects +pub(crate) struct PEFile { + original_data: Vec, + output_handle: File, +} + +/// Position of the new PE header. 0x40 is the end of the DOS header, it cannot be earlier. +const PE_HEADER_POS: usize = 0x40; + +/// The size of the Optional Section of the header +const LINUX_MAGIC_POS: usize = 0x202; +const LINUX_MAX_INITRD_SIZE_POS: usize = 0x22C; + +const SYMBOL_TABLE_SIZE: usize = 40; + +const MAX_OPT_HEADER_SIZE: usize = 0xa0; + +// Helpful reference: https://upload.wikimedia.org/wikipedia/commons/1/1b/Portable_Executable_32_bit_Structure_in_SVG_fixed.svg +// (but it's for PE32, and we have a PE32+, so cross-reference with https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#optional-header-image-only) + +impl PEFile { + /// Write the firt 0x40 bytes of the PE file, corresponding to the DOS section + fn write_dos_header(&mut self) { + let mut dos_header = vec![0x00u8; 0x40]; + dos_header[0] = 0x4D; + dos_header[1] = 0x5A; + dos_header.as_mut_slice()[0x38..0x3C].copy_from_slice( + &[0xcd, 0x23, 0x82, 0x81], // base DOS code, does nothing + ); + dos_header.as_mut_slice()[0x3C..0x40] + .copy_from_slice(&u32::try_from(PE_HEADER_POS).unwrap().to_le_bytes()[0..4]); + + self.output_handle + .write_all(dos_header.as_slice()) + .expect("failed to write DOS header"); + } + + /// Reads the original PE header position + fn pe_header_pos(&self) -> usize { + let pe_header_pos: [u8; 4] = (&self.original_data[0x3C..0x40]).try_into().unwrap(); + let pe_header_pos = u32::from_le_bytes(pe_header_pos); + + usize::try_from(pe_header_pos).unwrap() + } + + /// Write the PE header of this file. Comes just after the DOS header. + fn write_pe_header(&mut self) { + const PE_MANDATORY_HEADER_SIZE: usize = 0x18; + let pe_header_pos = self.pe_header_pos(); + let old_header = &self.original_data.as_slice()[pe_header_pos..]; + + // We make sure that the old header is not too big for us to put the linux symbols where we + // want them + let num_symbol_tables: [u8; 2] = old_header[0x06..0x08].try_into().unwrap(); + let num_symbol_tables = usize::from(u16::from_le_bytes(num_symbol_tables)); + let opt_section_size: [u8; 2] = old_header[0x14..0x16].try_into().unwrap(); + let opt_section_size = usize::from(u16::from_le_bytes(opt_section_size)); + + let total_size_of_header: [u8; 4] = old_header[0x54..0x58].try_into().unwrap(); + let total_size_of_header = + usize::try_from(u32::from_le_bytes(total_size_of_header)).unwrap(); + assert!( + total_size_of_header >= LINUX_MAGIC_POS, + "total header size is too small" + ); + + // We copy the old header, up to the max header size (start of RVA directory) + let mut new_header = Vec::new(); + new_header.extend_from_slice(&old_header[..PE_MANDATORY_HEADER_SIZE + MAX_OPT_HEADER_SIZE]); + + if opt_section_size >= MAX_OPT_HEADER_SIZE { + // We need to remove extraneous Header Data Directory entries. + // Only the first six are required to boot correctly. + // Each entry occupies 8 bytes. + + // Adapt the number of data directory entries + // Offset: 108 in the optional header (https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#optional-header-windows-specific-fields-image-only) + new_header.as_mut_slice() + [PE_MANDATORY_HEADER_SIZE + 108..PE_MANDATORY_HEADER_SIZE + 112] + .copy_from_slice(6u32.to_le_bytes().as_slice()); + + // Set the header size to our value + (new_header.as_mut_slice()[0x14..0x16]).copy_from_slice( + u16::try_from(MAX_OPT_HEADER_SIZE) + .unwrap() + .to_le_bytes() + .as_slice(), + ); + } else if opt_section_size < MAX_OPT_HEADER_SIZE { + panic!("invalid PE header: header is too small"); + } + + let total_header_size = PE_MANDATORY_HEADER_SIZE + + MAX_OPT_HEADER_SIZE + + (num_symbol_tables * SYMBOL_TABLE_SIZE); + assert!( + PE_HEADER_POS + total_header_size <= LINUX_MAGIC_POS, + "too many symbol tables! ({num_symbol_tables})" + ); + + // Copy the symbol tables + let symbol_tables_start = PE_MANDATORY_HEADER_SIZE + opt_section_size; + let symbol_tables_end = symbol_tables_start + (num_symbol_tables * SYMBOL_TABLE_SIZE); + new_header.extend_from_slice(&old_header[symbol_tables_start..symbol_tables_end]); + + // Pad with zeros + while new_header.len() + PE_HEADER_POS < LINUX_MAGIC_POS { + new_header.push(0); + } + + assert_eq!(new_header.len(), LINUX_MAGIC_POS - PE_HEADER_POS); + + // Linux Boot section + new_header.extend_from_slice(b"HdrS"); // Magic string for the header + new_header.extend_from_slice(0x20fu32.to_le_bytes().as_ref()); // Version number + + // Pad with zeros + while new_header.len() + PE_HEADER_POS < LINUX_MAX_INITRD_SIZE_POS { + new_header.push(0); + } + + // Max initrd size: u32::MAX + new_header.extend_from_slice(&[0xff; 4]); + + self.output_handle + .write_all(&new_header) + .expect("failed to write PE header"); + } + + fn write_pe_body(&mut self) { + // The PE file contains basically nothing before its first object, so we can just copy everything as-is + let position = self + .output_handle + .seek(SeekFrom::End(0)) + .expect("failed to obtain current position in object file"); + let position = usize::try_from(position).unwrap(); + + self.output_handle + .write_all(&self.original_data.as_slice()[position..]) + .expect("failed to write PE body") + } + + pub fn load_from_path>(path: T) -> Self { + let contents = { + let mut file = OpenOptions::new() + .read(true) + .open(path.as_ref()) + .expect("missing built object file"); + let mut contents = Vec::new(); + file.read_to_end(&mut contents) + .expect("failed to read object file"); + + contents + }; + + let write_handle = OpenOptions::new() + .truncate(true) + .write(true) + .open(path.as_ref()); + + Self { + output_handle: write_handle.unwrap(), + original_data: contents, + } + } + + pub fn rewrite(mut self) { + self.write_dos_header(); + self.write_pe_header(); + self.write_pe_body(); + } +}