Skip to content

Commit db47b27

Browse files
committed
Provide a high-level explanation on how to create bootloader
1 parent 9c1babd commit db47b27

File tree

1 file changed

+47
-27
lines changed
  • blog/content/edition-3/posts/02-booting/uefi

1 file changed

+47
-27
lines changed

blog/content/edition-3/posts/02-booting/uefi/index.md

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -748,52 +748,72 @@ Note that we also need to call `uefi::alloc::exit_boot_services()` before exitin
748748

749749
## Creating a Bootloader
750750

751+
Now that we know how to set up a framebuffer and query relevant system information, we only need a few more steps to turn our UEFI application into a bootloader. These steps includes loading a kernel executable into memory, setting up an execution environment, and passing control to the kernel's entry point function. In the following, we give some high-level instructions for each of these steps.
751752

752753
### Loading the Kernel
753754

754-
We already saw how to set up a framebuffer for screen output and query the physical memory map and the APIC base register address. This is already all the system information that a basic kernel needs from the bootloader.
755+
The first step is to load the kernel executable and setting it up properly. This involves loading the kernel from disk into memory, allocating a stack for it, and setting up a new page table hierarchy to properly map it to virtual memory.
755756

756-
The next step is to load the kernel executable. This involves loading the kernel from disk into memory, allocating a stack for it, and setting up a new page table hierarchy to properly map it to virtual memory.
757+
One approach for including our kernel could be to place it in the FAT partition created by our `disk_image` crate. Then we could use the _simple file system_ protocol of UEFI (see section 12.3 of the standard ([PDF][uefi-pdf])) to load it from disk into memory. The `uefi` crate supports this protocol through its [`SimpleFileSystem`] type.
757758

758-
#### Loading it from Disk
759-
760-
One approach for including our kernel could be to place it in the FAT partition created by our `disk_image` crate. Then we could use the TODO protocol of the `uefi` crate to load it from disk into memory.
759+
[`SimpleFileSystem`]: https://docs.rs/uefi/0.8.0/uefi/proto/media/fs/struct.SimpleFileSystem.html
761760

762761
To keep things simple, we will use a different appoach here. Instead of loading the kernel separately, we place its bytes as a `static` variable inside our bootloader executable. This way, the UEFI firmware directly loads it into memory when launching the bootloader. To implement this, we can use the [`include_bytes`] macro of Rust's `core` library:
763762

763+
[`include_bytes`]: https://doc.rust-lang.org/nightly/core/macro.include_bytes.html
764+
764765
```rust
765-
// TODO
766+
static KERNEL: &[u8] = include_bytes!("path/to/the/kernel/executable");
766767
```
767768

768-
#### Parsing the Kernel
769-
770-
Now that we have our kernel executable in memory, we need to parse it. In the following, we assume that the kernel uses the ELF executable format, which is popular in the Linux world. This is also the excutable format that the kernel created in this blog series uses.
771-
772-
The ELF format is structured like this:
773-
774-
TODO
775-
776-
The various headers are useful in different situations. For loading the executable into memory, the _program header_ is most relevant. It looks like this:
769+
After loading the kernel executable into memory (one way or another), we need to parse it. In the following, we assume that the kernel uses the [ELF] executable format, which is popular in the Linux world. This is also the excutable format that the kernel created in this blog series uses.
777770

778-
TODO
771+
The ELF format consists of several headers that describe the executable and define a number of sections. Typically, there is a section called `.text` that contains the actual executable code. Immutable values such as string constants are placed in a section named `.rodata` ("read-only data"). For mutable data (e.g. a `static` containing a `Mutex`), a section named `.data` is used. There is also a section named `.bss` that stores all data that is initialized with zero values (this allows to reduce the size of the binary).
779772

780-
TODO: mention readelf/objdump/etc for looking at program header
773+
The various ELF headers are useful in different situations. For loading the executable into memory, the _program header_ is most relevant. It basically groups all the sections of the executable into different groups by their access permissions. There are typically four groups:
781774

782-
There are already a number of ELF parsing crates in the Rust ecosystem, so we don't need to create our own. In the following, we will use the [`xmas_elf`] crate, but other crates might work equally well.
775+
- Read-only and executable: This contains the `.text` section and all other executable code.
776+
- Read-only: This contains the `.rodata` section and all other sections with immutable, non-executable data.
777+
- Read-write: This includes the `.data` section and `.bss` sections. The zeroes of the `.bss` section are not actually stored, only its size is listed. Thus, no memory is wasted for storing zeroes.
783778

784-
TODO: load program segements and print them
779+
There are various tools to analyze ELF files and read out most headers. The classical tools are `readelf` and `objdump`. There are also several Rust crates for parsing an ELF files, so we don't need to to implement it on our own. Some examples are [`goblin`], [`elf`], and [`xmas-elf`]. The `xmas-elf` crate works quite well in `no_std` environments, so that's the one I would recommend for a bootloader implementation.
785780

786-
TODO: .bss section -> mem_size might be larger than file_size
781+
[`goblin`]: https://docs.rs/goblin/0.3.4/goblin/
782+
[`elf`]: https://docs.rs/elf/0.0.10/elf/
783+
[`xmas-elf`]: https://docs.rs/xmas-elf/0.7.0/xmas_elf/
787784

788-
#### Page Table Mappings
785+
The parsing and mapping process then looks roughly like this:
789786

790-
TODO:
791-
792-
- create new page table
793-
- map each segment
794-
- special-case: mem_size > file_size
787+
```rust
788+
let elf_file = ElfFile::new(KERNEL)?;
789+
header::sanity_check(&elf_file)?;
790+
791+
for segment in elf_file_program_iter() {
792+
program::sanity_check(segment, &elf_file)?;
793+
if let Type::Load = segment.get_type()? {
794+
let phys_start = phys_offset(KERNEL) + segment.offset();
795+
let phys_end = phys_start + segment.file_size();
796+
797+
let virt_start = segment.virtual_addr();
798+
let virt_end = virt_start + segment.mem_size();
799+
800+
let permissions = permissions_from_flags(segment.flags());
801+
802+
for frame in frame(phys_start)..=frame(phys_end -1) {
803+
// TODO: create page table mapping for frame with permissions
804+
// at corresponding virtual address
805+
}
806+
807+
if virt_end > phys_end {
808+
// TODO: there is a `.bss` section in this segment -> map next
809+
// (virt_end - phys_end) bytes to free physical frame and initialize
810+
// them with zero
811+
}
812+
}
813+
}
814+
```
795815

796-
#### Create a Stack
816+
After creating a new page table mapping for the kernel this way, we need to allocate a runtime stack for it. For that, we choose a region of unused physical memory and map it to some virtual address. Ideally, we choose the virtual address range in a way that the page immediately before it is not mapped. Thus, we create a so-called _guard page_ that ensures that stack overflows lead to a CPU exception (a page fault) instead of corrupting other data.
797817

798818
### Switching to Kernel
799819

0 commit comments

Comments
 (0)