From 2bc74ce8f735080a6d4391351f0a44d4b7c85733 Mon Sep 17 00:00:00 2001 From: Philipp Oppermann Date: Sun, 30 Apr 2023 17:09:37 +0200 Subject: [PATCH] Write sections about creating bootable disk image --- .../edition-3/posts/02-booting/index.md | 377 +++++++++++++++++- 1 file changed, 361 insertions(+), 16 deletions(-) diff --git a/blog/content/edition-3/posts/02-booting/index.md b/blog/content/edition-3/posts/02-booting/index.md index cbc996c5..03b9730b 100644 --- a/blog/content/edition-3/posts/02-booting/index.md +++ b/blog/content/edition-3/posts/02-booting/index.md @@ -275,7 +275,7 @@ We will take a closer look at the `entry_point` macro and the different configur bootloader_api::entry_point!(kernel_main); // ↓ this replaces the `_start` function ↓ -fn kernel_main(bootinfo: &'static mut bootloader_api::BootInfo) -> ! { +fn kernel_main(_bootinfo: &'static mut bootloader_api::BootInfo) -> ! { loop {} } ``` @@ -284,7 +284,7 @@ There are a few notable things: - The `kernel_main` function is just a normal Rust function with an arbitrary name. No `#[no_mangle]` attribute is needed anymore since the `entry_point` macro handles this internally. - Like before, our entry point function is [diverging], i.e. it must never return. We ensure this by looping endlessly. -- There is a new [`BootInfo`] argument, which the bootloader fills with various system information. We will use this argument later. +- There is a new [`BootInfo`] argument, which the bootloader fills with various system information. We will use this argument later. For now, we prefix it with an underscore to avoid an "unused variable" warning. - The `entry_point` macro verifies that the `kernel_main` function has the correct arguments and return type, otherwise a compile error will occur. This is important because undefined behavior might occur when the function signature does not match the bootloader's expectations. [diverging]: https://doc.rust-lang.org/rust-by-example/fn/diverging.html @@ -326,46 +326,391 @@ This means that we can now look into how to create a bootable disk image from ou Now that our kernel is compatible with the `bootloader` crate, we can turn it into a bootable disk image. To do that, we need to create a disk image file with an [MBR] or [GPT] partition table and create a new [FAT][FAT file system] boot partition there. -Then we can copy our compiled kernel and the compiled bootloader implementation there. +Then we copy our compiled kernel and the compiled bootloader to this boot partition. -While we could perform these steps manually using platform-specific tools (e.g. [`mkfs`] on Linux), this would not be cumbersome and fragile. -Fortunately, the `bootloader` crate provides a [`DiskImageBuilder`] to construct both BIOS and UEFI disk images in a simple way. -It works on Windows, macOS, and Linux without any additional dependencies. -We just need to pass path to our kernel executable and then call `create_bios_image` and/or `create_uefi_image` with our desired target path. +While we could perform these steps manually using platform-specific tools (e.g. [`mkfs`] on Linux), this would be cumbersome to use and difficult to set up. +Fortunately, the `bootloader` crate provides a cross-platform [`DiskImageBuilder`] type to construct BIOS and UEFI disk images. +We just need to pass path to our kernel executable and then call [`create_bios_image`] and/or [`create_uefi_image`] with our desired target path. [`mkfs`]: https://www.man7.org/linux/man-pages/man8/mkfs.fat.8.html [`DiskImageBuilder`]: https://docs.rs/bootloader/0.11.3/bootloader/struct.DiskImageBuilder.html +[`create_bios_image`]: https://docs.rs/bootloader/0.11.3/bootloader/struct.DiskImageBuilder.html#method.create_bios_image +[`create_uefi_image`]: https://docs.rs/bootloader/0.11.3/bootloader/struct.DiskImageBuilder.html#method.create_uefi_image By using the `DiskImageBuilder` together with some advanced features of `cargo`, we can combine the kernel build and disk image creation steps. -This way, we also don't need to pass the `--target x86_64-unknown-none` argument anymore. +Another advantage of this approach is that we don't need to pass the `--target x86_64-unknown-none` argument anymore. In the next sections, we will implement following steps to achieve this: - Create a [`cargo` workspace] with an empty root package. - Add an [_artifact dependency_] to include the compiled kernel binary in the root package. -- Create a [build script] for the root package that invokes the `bootloader::DiskImageBuilder`. +- Invoke the `bootloader::DiskImageBuilder` in our new root package. [`cargo` workspace]: https://doc.rust-lang.org/cargo/reference/workspaces.html [_artifact dependency_]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#artifact-dependencies -[build script]: https://doc.rust-lang.org/cargo/reference/build-scripts.html Don't worry if that sounds a bit complex! We will explain each of these steps in detail. #### Creating a Workspace -TODO +Cargo provides a feature named [_workspaces_] to manage projects that consistent of multiple crates. +The idea is that the crates share a single `Cargo.lock` file (to pin dependencies) and a common `target` folder. +The different crates can depend on each other by specifying [`path` dependencies]. + +[_workspaces_]: https://doc.rust-lang.org/cargo/reference/workspaces.html +[`path` dependencies]: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#specifying-path-dependencies + +Creating a cargo workspace is easy. We first create a new subfolder named `kernel` and move our existing `Cargo.toml` file and `src` folder there. +We keep the `Cargo.lock` file and the `target` folder in the outer level, `cargo` will update them automatically. +The folder structure should look like this now: + +```bash ,hl_lines=3-6 +. +├── Cargo.lock +├── kernel +│   ├── Cargo.toml +│   └── src +│   └── main.rs +└── target +``` + +Next, we create a new `blog_os` crate at the root using `cargo init`: + +```bash +❯ cargo init --name blog_os +``` + +You can of course choose any name you like for the crate. +The command creates a new `src/main.rs` at the root with a main function printing "Hello, world!". +It also creates a new `Cargo.toml` file at the root. +The directory structure now looks like this: + +```bash,hl_lines=3 8-9 +. +├── Cargo.lock +├── Cargo.toml +├── kernel +│   ├── Cargo.toml +│   └── src +│   └── main.rs +├── src +│   └── main.rs +└── target +``` + +The final step is to add the workspace configuration to the `Cargo.toml` at the root: + +```toml ,hl_lines=8-9 +# in top-level Cargo.toml + +[package] +name = "blog_os" +version = "0.1.0" +edition = "2021" + +[workspace] +members = ["kernel"] + +[dependencies] +``` + +That's it! +Now our `blog_os` and `kernel` crates live in the same workspace. +To ensure that everything works as intended, we can run `cargo tree` to list all the packages in the workspace: + +```bash +❯ cargo tree --workspace +blog_os v0.1.0 (/.../os) + +kernel v0.1.0 (/.../os/kernel) +└── bootloader_api v0.11.3 +``` + +We see that both the `blog_os` and the `kernel` crates are listed, which means that `cargo` recognizes that they're both part of the same workspace. + +
+ +If you're getting a _"profiles for the non root package will be ignored"_ warning here, you probably still have a manual `panic = "abort"` override specified in your `kernel/Cargo.toml`. +This override is no longer needed since we compile our kernel for the `x86_64-unknown-none` target, which uses `panic = "abort"` by default. +So to fix this warning, just remove the `profile.dev` and `profile.release` tables from your `kernel/Cargo.toml` file. + +
+ +We now have a simple cargo workspace and a new `blog_os` crate at the root. +But what do we need that new crate for? #### Adding an Artifact Dependency -TODO +The reason that we added the new `blog_os` crate is that we want to do something with our _compiled_ kernel. +`Cargo` provides an useful feature for this, called [_artifact dependencies_]. +The basic idea is that crates can depend on compiled artifacts (e.g. executables) of other crates. +This is especially useful for artifacts that need to be compiled for a specific target, such as our OS kernel. -#### Creating a Build Script +[_artifact dependencies_]: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#artifact-dependencies -TODO +Unfortunately, artifact dependencies are still an unstable feature and not available on stable Rust/Cargo releases yet. +This means that we need to use a [nightly Rust] release for now. -> For building the bootloader, you need to have the `llvm-tools-preview` rustup component installed. -> You can do so by executing `rustup component add llvm-tools-preview`. +##### Nightly Rust +As the name implies, nightly releases are created every night from the latest `master` commit of the [`rust-lang/rust`] project. +While there is some risk of breakage on the nightly channel, it only occurs very seldomly thanks to extensive checks on the [Rust CI]. +Most of the time, breakage only affects unstable features, which require an explicit opt-in. +So by limiting the number of used unstable features as much as possible, we can get a quite stable experience on the nightly channel. +In case something _does_ go wrong, [`rustup`] makes it easy to switch back to an earlier nightly until the issue is resolved. + +[nightly Rust]: https://doc.rust-lang.org/book/appendix-07-nightly-rust.html +[`rust-lang/rust`]: https://github.com/rust-lang/rust +[Rust CI]: https://forge.rust-lang.org/infra/docs/rustc-ci.html + +
+What is rustup? + +The [`rustup`] tool is the [officially recommended] way of installing Rust. +It supports having multiple versions of Rust installed simultaneously and makes upgrading Rust easy. +It also provides access to optional tools and components such as [`rustfmt`] or [`rust-analyzer`]. +This guide requires `rustup`, so please install it if you haven't already. + +[`rustup`]: https://rustup.rs/ +[officially recommended]: https://www.rust-lang.org/learn/get-started +[`rustfmt`]: https://github.com/rust-lang/rustfmt/ +[`rust-analyzer`]: https://github.com/rust-lang/rust-analyzer + +
+ +##### Using Nightly Rust + +To use nightly Rust for our project, we create a new [`rust-toolchain.toml`] file in the root directory of our project: + +[`rust-toolchain.toml`]: https://rust-lang.github.io/rustup/overrides.html#the-toolchain-file + +```toml ,hl_lines=1-3 +[toolchain] +channel = "nightly" +targets = ["x86_64-unknown-none"] +``` + +The `channel` field specifies which [toolchain`] to use. +In our case, we want to use the latest nightly compiler. +We could also specify a specific nightly here, e.g. `nightly-2023-04-30`, which can be useful when there is some breakage in the newest nightly. +In the `targets` list, we can specify additional targets that we want to compile to. +In our case, we specify the `x86_64-unknown-none` target that we use for our kernel. + +[`toolchain`]: https://rust-lang.github.io/rustup/concepts/toolchains.html + +Rustup automatically reads the `rust-toolchain.toml` file and sets up the requested Rust version when running a `cargo` or `rustc` command in this folder, or a subfolder. +We can try this by running `cargo --version`: + +```bash +❯ cargo --version +info: syncing channel updates for 'nightly-x86_64-unknown-linux-gnu' +info: latest update on 2023-04-30, rust version 1.71.0-nightly (87b1f891e 2023-04-29) +info: downloading component 'cargo' +info: downloading component 'clippy' +info: downloading component 'rust-docs' +info: downloading component 'rust-std' +info: downloading component 'rust-std' for 'x86_64-unknown-none' +info: downloading component 'rustc' +info: downloading component 'rustfmt' +info: installing component 'cargo' +info: installing component 'clippy' +info: installing component 'rust-docs' +info: installing component 'rust-std' +info: installing component 'rust-std' for 'x86_64-unknown-none' +info: installing component 'rustc' +info: installing component 'rustfmt' +cargo 1.71.0-nightly (9e586fbd8 2023-04-25) +``` + +We see that `rustup` automatically downloads and install the nightly version of all Rust components. +This is of course only done once, if the requested toolchain is not installed yet. +To list all installed toolchains, use `rustup toolchain list`. +Updating toolchains is possible through `rustup update`. + +##### Enabling Artifact Dependencies + +Now that we've installed a nightly version of Rust, we can opt-in to the unstable [_artifact dependency_] feature. +To do this, we create a new folder named `.cargo` in the root of our project. +Inside that folder, we create a new [`cargo` configuration file] named `config.toml`: + +[`cargo` configuration file]: https://doc.rust-lang.org/cargo/reference/config.html + +```toml ,hl_lines=3-4 +# .cargo/config.toml + +[unstable] +bindeps = true +``` + +##### Creating an Artifact Dependency + +After switching to nightly Rust and enabling the unstable `bindeps` feature, we can finally add an artifact dependency on our compiled kernel. +For this, we update the `dependency` table of our `blog_os` crate: + +```toml ,hl_lines=9-10 +[package] +name = "blog_os" +version = "0.1.0" +edition = "2021" + +[workspace] +members = ["kernel"] + +[dependencies] +kernel = { path = "kernel", artifact = "bin", target = "x86_64-unknown-none" } +``` + +We specify that the `kernel` crate lives in the `kernel` subdirectory through the `path` key. +The `artifact = "bin"` key specifies that we're interested in the compiled kernel binary (this makes the dependency an artifact dependency). +Finally, we use the `target` key to specify that our kernel binary should be compiled for the `x86_64-unknown-none` target. + +Now `cargo` will automatically build our kernel before building our `blog_os` crate. +We can see this when building the `blog_os` crate using `cargo build`: + +``` +❯ cargo build + Compiling bootloader_api v0.11.3 + Compiling kernel v0.1.0 (/.../os/kernel) + Compiling blog_os v0.1.0 (/.../os) + Finished dev [unoptimized + debuginfo] target(s) in 0.51s +``` + +The `blog_os` crate should be built for our host system, so we don't specify a `--target` argument. +Cargo uses the same profile for compiling the `blog_os` and `kernel` crates, so `cargo build --release` will also build the `kernel` binary with optimizations enabled. + +Now that we have set up an artifact dependency on our kernel, we can finally create the bootable disk image. + +#### Using the `DiskImageBuilder` + +The last step to create the bootable disk image is to invoke the [`DiskImageBuilder`] of the `bootloader` crate. +For that, we first add a dependency on the `bootloader` crate to our `blog_os` crate: + +```toml ,hl_lines=5 +# in root Cargo.toml + +[dependencies] +kernel = { path = "kernel", artifact = "bin", target = "x86_64-unknown-none" } +bootloader = "0.11.3" +``` + +The crate requires the `llvm-tools` component of `rustup`, which is not installed by default. +To install it, we update our `rust-toolchain.toml` file: + +```toml ,hl_lines=6 +# rust-toolchain.toml + +[toolchain] +channel = "nightly" +targets = ["x86_64-unknown-none"] +components = ["llvm-tools-preview"] +``` + +If we run `cargo build` now, the bootloader should be built as a dependency. +The initial build will take a long time, but it should finish without errors. +Please open an issue in the [`rust-osdev/bootloader`] repository if you encounter any issues. + +[`rust-osdev/bootloader`]: https://github.com/rust-osdev/bootloader + +Now we can use the [`DiskImageBuilder`] in the `main` function of our `blog_os` crate: + +```rust, hl_lines=3-4 7-9 +// src/main.rs + +use bootloader::DiskImageBuilder; +use std::path::PathBuf; + +fn main() { + // set by cargo for the kernel artifact dependency + let kernel_path = PathBuf::from(env!("CARGO_BIN_FILE_KERNEL")); + let disk_builder = DiskImageBuilder::new(kernel_path); +} +``` + +Cargo communicates the path of artifact dependencies through environment variables. +For our `kernel` dependency, the environment variable name is `CARGO_BIN_FILE_KERNEL`. +The environment variable is set at build time, so we can use the [`env!`] macro to read it. +We then wrap convert it to a [`PathBuf`] because that's the type that [`DiskImageBuilder::new`] expects. + +[`env!`]: https://doc.rust-lang.org/std/macro.env.html +[`PathBuf`]: https://doc.rust-lang.org/std/path/struct.PathBuf.html +[`DiskImageBuilder::new`]: https://docs.rs/bootloader/0.11.3/bootloader/struct.DiskImageBuilder.html#method.new + +Now we need to decide where we want to place the disk images. +This is entirely up to. +In the following, we will place the images next to `blog_os` executable, which will be under `target/debug` (for development builds) or `target/release` (for optimized builds): + +```rust ,hl_lines=2 4 9-10 12 +use bootloader::DiskImageBuilder; +use std::{env, error::Error, path::PathBuf}; + +fn main() -> Result<(), Box> { + // set by cargo for the kernel artifact dependency + let kernel_path = PathBuf::from(env!("CARGO_BIN_FILE_KERNEL")); + let disk_builder = DiskImageBuilder::new(kernel_path); + + // place the disk image files under target/debug or target/release + let target_dir = env::current_exe()?; + + Ok(()) +} +``` + +We use the [`std::env::current_exe`] function to get the path to the `blog_os` executable. +This function can (rarely) fail, so we add some basic error handling to our `main` function. +For that, we change the return value of the function to a [`Result`] with a dynamic error type (a [_trait object_] of the [`Error`] trait). +This allows us to use the [`?` operator] to exit with an error code on error. + +[`std::env::current_exe`]: https://doc.rust-lang.org/std/env/fn.current_exe.html +[`Result`]: https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html#recoverable-errors-with-result +[`?` operator]: https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html#a-shortcut-for-propagating-errors-the--operator +[_trait object_]: https://doc.rust-lang.org/book/ch17-02-trait-objects.html#using-trait-objects-that-allow-for-values-of-different-types +[`Error`]: https://doc.rust-lang.org/std/error/trait.Error.html + +The final step is to actually create the UEFI and BIOS disk images: + +```rust ,hl_lines=12-14 16-18 +use bootloader::DiskImageBuilder; +use std::{env, error::Error, path::PathBuf}; + +fn main() -> Result<(), Box> { + // set by cargo for the kernel artifact dependency + let kernel_path = PathBuf::from(env!("CARGO_BIN_FILE_KERNEL")); + let disk_builder = DiskImageBuilder::new(kernel_path); + + // place the disk image files under target/debug or target/release + let target_dir = env::current_exe()?; + + let uefi_path = target_dir.with_file_name("blog_os-uefi.img"); + disk_builder.create_uefi_image(&uefi_path)?; + println!("Created UEFI disk image at {}", uefi_path.display()); + + let bios_path = target_dir.with_file_name("blog_os-bios.img"); + disk_builder.create_bios_image(&bios_path)?; + println!("Created BIOS disk image at {}", bios_path.display()); + + Ok(()) +} +``` + +We use the [`PathBuf::with_file_name`] method to create the target paths for the disk images. +To create the images, we call the `create_uefi_image` and `create_bios_image` methods of [`DiskImageBuilder`]. +Finally, we print the full paths to the created files. + +[`PathBuf::with_file_name`]: https://doc.rust-lang.org/std/path/struct.PathBuf.html#method.with_file_name + +Now we can use a simple `cargo run` to cross-compile our kernel, build the bootloader, and combine them to create a bootable disk image: + +``` +❯ cargo run + Compiling kernel v0.1.0 (/.../os/kernel) + Compiling blog_os v0.1.0 (/.../os) + Finished dev [unoptimized + debuginfo] target(s) in 0.52s + Running `target/debug/blog_os` +Created UEFI disk image at /.../os/target/debug/blog_os-uefi.img +Created BIOS disk image at /.../os/target/debug/blog_os-bios.img +``` + +Cargo will automatically detect when our kernel code is modified and recompile the dependent `blog_os` crate. #### Making `rust-analyzer` happy