Skip to content

Commit ba08d69

Browse files
update os posts
Signed-off-by: Henry Gressmann <[email protected]>
1 parent 9a4d1b7 commit ba08d69

File tree

3 files changed

+56
-51
lines changed

3 files changed

+56
-51
lines changed

content/rust-os/1-hello-riscv/index.md

+34-35
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title = "Operating Systems in Rust #1: Hello RISC-V"
33
description = "In this first post, we'll set up our environment and write a simple program that prints 'Hello World' to the screen."
44
date = 2023-05-07
5-
updated = 2023-05-12
5+
updated = 2024-03-01
66
aliases = ["osdev-1"]
77
transparent = true
88

@@ -22,15 +22,15 @@ This is a series of posts about my journey creating a kernel in rust. You can fi
2222
I've been interested in operating systems for a while now and, with many of the recent advancements in rust's role in the OS ecosystem, I thought it would be fun to try and write a kernel in rust.
2323
I've found that many blogs and guides on writing kernels and operating systems are either pretty outdated or not very accessible, so this will be a different (and hopefully more fun) approach, fully utilizing the Rust ecosystem to get up and running quickly and minimizing the use of unsafe and assembly code.
2424

25-
This series requires pre-requisite knowledge of Rust or a similar programming language. All commands throughout the series will also be expecting a Linux terminal and might need to be adjusted slightly for macOS or Windows. I'll be using Arch Linux, but any distro should work fine. I'm trying to keep everything approachable, so if you have any questions or suggestions, feel free to reach out to me at [[email protected]](mailto:[email protected])
25+
This series requires pre-requisite knowledge of Rust or a similar programming language. All commands throughout the series will also be expecting a Linux terminal and might need to be adjusted slightly for macOS or Windows. I'll be using Arch Linux, but any distro should work fine.
2626

27-
To follow along, I've also created a [GitHub Repo](https://github.com/explodingcamera/pogos) with a branch for each part of the series. You can find the code for this part [here](https://github.com/explodingcamera/pogos/tree/part-1).
27+
<!-- To follow along, I've also created a [GitHub Repo](https://github.com/explodingcamera/pogos) with a branch for each part of the series. You can find the code for this part [here](https://github.com/explodingcamera/pogos/tree/part-1). -->
2828

2929
<!-- {{toc}} -->
3030

3131
# CPU Architectures and RISC-V
3232

33-
X86 is currently the dominant CPU architecture and has recently lost a bit of market share to emerging ARM CPUs like the [Apple M Series](https://en.wikipedia.org/wiki/Apple_M2) or [AWS Graviton](https://en.wikipedia.org/wiki/AWS_Graviton). For this series, however, we'll be targeting RISC-V. RISC-V is a CPU Architecture released in 2015 under royalty-free open-source licenses. By focusing on small and modular instruction extensions and being standardized so recently, it avoids the sizeable historical baggage and weird design decisions plaguing x86 (check out [this](https://mjg59.dreamwidth.org/66109.html) article to see the horrendous boot process in action). RISC-V was also designed to be extensible, allowing for custom instruction extensions to be added to the base ISA. This enables us to use the same kernel on a wide range of CPUs, from small embedded devices to high-performance servers.
33+
X86 is currently the dominant CPU architecture and has recently lost a bit of market share to emerging ARM CPUs like the [Apple M Series](https://en.wikipedia.org/wiki/Apple_M2) or [AWS Graviton](https://en.wikipedia.org/wiki/AWS_Graviton). For this series, however, we'll be targeting RISC-V. RISC-V is a CPU Architecture released in 2015 under royalty-free open-source licenses. By focusing on small and modular instruction extensions and being so new, it avoids the sizeable historical baggage and weird design decisions plaguing x86 (check out [this](https://mjg59.dreamwidth.org/66109.html) article to see the horrendous boot process in action). RISC-V was also designed to be extensible, allowing for custom instruction extensions to be added to the base ISA. This enables us to use the same kernel on a wide range of CPUs, from small embedded devices to high-performance servers.
3434

3535
I'll be using [QEMU](https://www.qemu.org/) to run our kernel. QEMU is a virtual machine that can emulate a wide range of CPUs and devices, including RISC-V. At the end of this series, we'll also run it on an actual board (my [MangoPi MQ-Pro](https://mangopi.org/mangopi_mqpro) recently arrived, and I'm excited to try it).
3636

@@ -44,7 +44,7 @@ Because the standard library depends on an operating system to provide memory al
4444

4545
{% end %}
4646

47-
To do this, we'll use the `core` library, a subset of the standard library that doesn't depend on an operating system. With it, we won't have access to things like `println!` or `Vec`, but we can still use types like `Option` and `Result` and many other useful APIs.
47+
To do this, we'll use the `core` library, a subset of the standard library that doesn't depend on an underlying operating system. With it, we won't have access to things like `println!` or `Vec`, but we can still use types like `Option` and `Result` and many other useful APIs.
4848
In the next post, we'll also use the `alloc` create to enable us to use language features that require heap allocations, such as `Vec` and `Box`.
4949

5050
Before we start, we'll need to install a couple of things:
@@ -65,10 +65,6 @@ rustup component add rust-src --toolchain nightly
6565
# add the RISC-V target
6666
# - GC stands for the generic (IMAFD extensions) and compressed extensions
6767
# These are the most common extensions which are required for most applications
68-
# - ELF is the executable format we'll be using
69-
# This is the format Linux and most other UNIX-like operating systems use for executables.
70-
# Other common formats are PE (windows) and Mach-O (macOS).
71-
# (On x86, we'd have to endure the pain of dealing with PE binaries)
7268
rustup target add riscv64gc-unknown-none-elf --toolchain nightly
7369

7470
# create a new cargo project (this will be our kernel)
@@ -108,26 +104,26 @@ rustflags = [
108104
build-std = ["core", "alloc"]
109105
```
110106

107+
Even though you can use `no_std` and `alloc` without building the standard library yourself just fine for building libraries, it's required for building executables (at least for now).
108+
111109
# Booting on RISC-V
112110

113111
## RISC-V Privilege Levels
114112

115113
To better understand how we'll be booting our kernel, we'll first have to understand how RISC-V's privilege levels work. RISC-V has three privilege levels, sometimes called _rings_ or _modes_.
116114

117-
Firmware runs in Machine mode, the highest [privilege level](http://docs.keystone-enclave.org/en/latest/Getting-Started/How-Keystone-Works/RISC-V-Background.html#RISC-V-privilieged-isa) on RISC-V. The kernel will run in Supervisor-mode, and user programs will run in User-mode, the lowest privilege level.
115+
Firmware runs in Machine mode, the highest [privilege level](http://docs.keystone-enclave.org/en/latest/Getting-Started/How-Keystone-Works/RISC-V-Background.html#RISC-V-privilieged-isa).
116+
This is where the bootloader and the Supervisor Execution Environment (SEE) run. This SEE is a piece of software that provides a small abstraction layer between the kernel and the hardware, and loads the kernel into memory and jumps to it. Our kernel will run in Supervisor-mode, which is the second highest privilege level. Finally, applications will run in User-mode, the lowest privilege level.
118117

119118
{{ figure(caption = "The three privilege levels of RISC-V", position="center", src="./assets/rings.svg") }}
120119

121120
## SBI and OpenSBI
122121

123-
Compared to other CPU Architectures, RISC-V's boot process is straightforward.
122+
Compared to other CPU Architectures, RISC-V's boot process is relatively straightforward (If you don't try to force UEFI onto it).
124123
We're using OpenSBI as our Supervisor Execution Environment (SEE), our _M-mode RUNTIME firmware_.
125124

126-
{% quote (class="info")%}
127-
SBI (Supervisor Binary Interface) is a standard interface for interacting with the SEE, and OpenSBI is an implementation of this standard.
128-
{% end %}
129-
130-
The version shipping with QEMU uses a Jump Address ([_FW_JUMP_](https://github.com/riscv-software-src/opensbi/blob/master/docs/firmware/fw_jump.md)), in this case, `0x80200000`, which is where we'll be putting our kernel. QEMU will load our kernel into memory and jump to `0x80000000`, from where OpenSBI will then jump to `0x80200000`, where our kernel is located.
125+
The version shipping with QEMU uses a Jump Address ([_FW_JUMP_](https://github.com/riscv-software-src/opensbi/blob/master/docs/firmware/fw_jump.md)), in this case, `0x80200000`.
126+
This is a location in memory where we'll be putting our kernel, using the `-kernel` flag we set in our `.cargo/config.toml` file earlier. From there, OpenSBI will run some initialization code and jump to our kernel.
131127

132128
{{ figure(caption = "Traditional Boot Flow", position="center", src="./assets/boot1.svg") }}
133129

@@ -143,7 +139,9 @@ To interact with SBI, we will use the `ecall` instruction, a trap instruction th
143139

144140
To make a binary that can be loaded as a kernel, we'll use the [riscv-rt](https://crates.io/crates/riscv-rt) crate, which provides a runtime for RISC-V. It also provides us with a trap handler which will be very useful for handling interrupts and exceptions and a linker script which we'll be using to set up the memory layout of our kernel.
145141

146-
Later on, we'll be writing our own runtime, but for now, we'll use this to get up and running quickly. We can add it to our project by adding the following to our `Cargo.toml` file:
142+
`riscv-rt` is primarily designed to be used with the microcontrollers, so in a later post we'll be replacing it with our own linker script and runtime.
143+
144+
To add it to our project, we have to update our `Cargo.toml` file:
147145

148146
{{ file(name = "cargo.toml")}}
149147

@@ -155,18 +153,19 @@ name = "kernel"
155153
version = "0.1.0"
156154

157155
[dependencies]
158-
# enable the s-mode feature to use the supervisor mode runtime (opensbi will run in machine mode and load our kernel into supervisor mode)
156+
# enable the s-mode feature to use the supervisor mode runtime
157+
# (opensbi will run in machine mode and load our kernel into supervisor mode)
159158
riscv-rt = {version = "0.11", features = ["s-mode"]}
160159
sbi = "0.2" # provides a wrapper around the SBI functions to make them easier to use
161160
```
162161

163-
Now we need to configure our linker. A linker is a program that takes a bunch of object files and combines them into a single binary, and - in our case - we need to tell it where to put the different sections of our binary so SBI can find them. `riscv-rt` already ships with a linker script. We will only need to tell it about the memory layout of our kernel.
162+
Now that we have `riscv-rt` and `sbi` in our project, we can start writing our kernel. We'll start by configuring our linker to tell it where to put the different sections of our binary, such as the text, data, and bss sections. `riscv-rt` already ships with a linker script, so we will only need to tell it some basic information about the memory layout the device we want to run it on.
164163

165-
Since we're using the Executable and Linking Format (ELF) for our kernel, we'll be distinguishing different regions of memory TEXT, DATA, and BSS.
164+
The executable format we'll be using is ELF, which is the format used by Linux and most other UNIX-like operating systems. Other common formats are PE (windows) and Mach-O (macOS). On x86, we'd have to endure the pain of dealing with PE binaries.
166165

167166
{{ figure(caption = "ELF Memory Layout", position="center", src="./assets/elf.svg") }}
168167

169-
To make sure that our kernel is loaded at the correct address, we need to add the following to our `memory.x` file:
168+
To start, we'll put the entire kernel in RAM, and give it size of 16MB. We'll do this by creating a new file called `memory.x` in the root of our project:
170169

171170
{{ file(name = "memory.x")}}
172171

@@ -175,7 +174,6 @@ To make sure that our kernel is loaded at the correct address, we need to add th
175174
MEMORY
176175
{
177176
RAM : ORIGIN = 0x80200000, LENGTH = 16M
178-
/* 16MB ought to be enough for anyone */
179177
}
180178
181179
REGION_ALIAS("REGION_TEXT", RAM);
@@ -186,7 +184,7 @@ REGION_ALIAS("REGION_HEAP", RAM);
186184
REGION_ALIAS("REGION_STACK", RAM);
187185
```
188186

189-
`link.x` will be provided by `riscv-rt`, and we don't need to change it for now.
187+
The other linker script we specified in our `.cargo/config.toml` file, `link.x`, will be provided by `riscv-rt`.
190188
To make sure that the linker can find our script, we need to add the following to our `build.rs` file:
191189

192190
{{ file(name = "build.rs")}}
@@ -200,7 +198,6 @@ use std::path::PathBuf;
200198
fn main() {
201199
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
202200

203-
// Put the linker script somewhere the linker can find it.
204201
fs::write(out_dir.join("memory.x"), include_bytes!("memory.x")).unwrap();
205202
println!("cargo:rustc-link-search={}", out_dir.display());
206203
println!("cargo:rerun-if-changed=memory.x");
@@ -210,31 +207,35 @@ fn main() {
210207

211208
# Hello world
212209

213-
Now that we have the linker configured, we can start writing our kernel. We'll start by writing a simple hello world program that prints "Hello world!" to the console and then shuts down the machine.
210+
Now that we have the linker configured, we can start writing some actual code. We'll start by writing a simple hello world program that prints "Hello world!" to the console and then shuts down the machine.
214211

215212
## Printing
216213

217-
Since we're in a `no_std` environment, we can't use the standard library and must implement a print function ourselves. We'll be using the `sbi` crate to interact with our Supervisor Execution Environment (SEE) OpenSBI, which provides a `console_putchar` function that we can use to print a single character to the console. We can use this to implement a simple print function that prints a string to the console. This print function iterates over the characters in the string and prints them to the QEMU debug console.
214+
Since we're in a `no_std` environment, we can't use the standard library and must implement a print function ourselves. We'll be using the `sbi` crate to interact with our Supervisor Execution Environment (SEE) OpenSBI, which provides a `console_putchar` function that we can use to print a single character to the console. Using this crate, we can create a simple print function that prints a string to the console. This print function iterates over the characters in the string and prints them to the QEMU debug console.
215+
216+
{% quote (class="info")%}
217+
`console_putchar` is now part of the SBI Debug Extension, which is not yet available in all SBI implementations. For now, we'll use the legacy console putchar function, which is available in all SBI implementations.
218+
{% end %}
218219

219220
{{ file(name = "src/utils.rs")}}
220221

221222
```rust
222223

223224
pub fn print(t: &str) {
224225
t.chars().for_each(
225-
// FUTURE: replace with the new SBI debug extension once it's available in all SBI implementations
226226
|c| sbi::legacy::console_putchar(c.try_into().unwrap_or(b'?')),
227227
);
228228
}
229229
```
230230

231-
To get super fancy with our print function, we can also implement a macro that allows us to print to the console using the same syntax as the standard library's `println!` macro. All we need to do for this is implement the `core::fmt::Write` for our print function.
231+
To get super fancy with our print function, we can also implement a macro that allows us to print to the console using the same syntax as the standard library's `println!` macro. All we need to do for this is implement the `core::fmt::Write` trait for our print function.
232232

233233
{{ file(name = "src/utils.rs")}}
234234

235235
```rust
236236

237237
struct Writer {}
238+
238239
pub fn print_args(t: core::fmt::Arguments) {
239240
use core::fmt::Write;
240241
let mut writer = Writer {};
@@ -284,7 +285,7 @@ pub fn shutdown() -> ! {
284285

285286
## Panic handler
286287

287-
Notice the `.unwrap()` call in the `print_args` function? Since we're using `no_std`, we can't use the standard library's panic handler. Instead, we'll write our panic handler to print the panic message to the console and then halt the CPU. We can do this by implementing the `panic_handler` lang item. This is a special function that is called when a panic occurs. We can implement it like this:
288+
Notice the `.unwrap()` call in the `print_args` function? Since we're using `no_std`, we can't use the standard library's panic handler. Instead, we'll write our panic handler to print the panic message to the console and then halt the CPU. To do this, wee need to mark a function with the special `panic_handler` lang item:
288289

289290
{{ file(name = "src/panic_handler.rs")}}
290291

@@ -296,7 +297,7 @@ use sbi::system_reset::{ResetReason, ResetType};
296297

297298
#[panic_handler]
298299
fn panic(info: &PanicInfo) -> ! {
299-
println!("{info}");
300+
println!("A panic occurred: {info}");
300301

301302
let _ = sbi::system_reset::system_reset(
302303
ResetType::Shutdown,
@@ -315,16 +316,12 @@ fn panic(info: &PanicInfo) -> ! {
315316

316317
## Entry point
317318

318-
Now we can finally write our hello world program. We'll be using the `entry` macro from `riscv-rt` to mark our main function as the entry point of our program. `riscv-rt` will load some assembly to set up a basic c-runtime environment and then call our main function. The main function will be passed the hart id of the hart that is executing it, which is passed to us by OpenSBI through the `a0` register.
319+
Now we can finally write our hello world program. Using the `entry` macro from `riscv-rt`, we can mark our main function as the entry point of our program. `riscv-rt` will load some assembly to set up a basic c-runtime environment and then call our main function with the hart id of the hart that is executing it (passed to us by OpenSBI through the `a0` register).
319320

320321
{% quote (class="info")%}
321-
322322
Hart is the RISC-V term for a CPU core. A RISC-V system can have multiple harts, each with its own register state and program counter.
323-
324323
{% end %}
325324

326-
Something you might not have seen before is the `!` return type. This is a special type that means that the function never returns. Since we're writing a kernel, there's nowhere for our program to return to, so we'll need to either loop forever or shut down the machine.
327-
328325
{{ file(name = "src/main.rs")}}
329326

330327
```rust
@@ -349,6 +346,8 @@ fn main(a0: usize) -> ! {
349346
}
350347
```
351348

349+
Something you might not have seen before is the `!` return type. This is a special type that means that the function never returns. Since we're writing a kernel, there's nowhere for our program to return to, so we'll need to either loop forever or shut down the machine.
350+
352351
# Review
353352

354353
Once we run `cargo run`, we should see "Hello world!" printed on the console:

0 commit comments

Comments
 (0)