04 - Testing
This post explores testing in no_std
executables. We will use Rust's support for custom test frameworks to execute test functions inside our kernel.
Testing in Rust
Rust has a built-in test framework that is capable of running unit tests without the need to set anything up. Just create a function that checks some results through assertions and add the #[test]
attribute to the function header. Then cargo test
will automatically find and execute all test functions of your crate.
Unfortunately, it's a bit more complicated for no_std
applications such as our kernel. The problem is that Rust's test framework implicitly uses the built-in test
library, which depends on the standard library. This means that we can't use the default test framework for our #[no_std]
kernel.
We can see this when we try to run cargo test
in our project:
> cargo test
Compiling siso_os v0.1.0 (/…/siso_os)
error[E0463]: can't find crate for `test`
Since the test
crate depends on the standard library, it is not available for our bare metal target. While porting the test
crate to a #[no_std]
context is possible, it is highly unstable and requires some hacks, such as redefining the panic
macro.
Custom Test Frameworks
Fortunately, Rust supports replacing the default test framework through the unstable custom_test_frameworks
feature. This feature requires no external libraries and thus also works in #[no_std]
environments. It works by collecting all functions annotated with a #[test_case]
attribute and then invoking a user-specified runner function with the list of tests as an argument. Thus, it gives the implementation maximal control over the test process.
The disadvantage compared to the default test framework is that many advanced features, such as should_panic
tests, are not available. Instead, it is up to the implementation to provide such features itself if needed. This is ideal for us since we have a very special execution environment where the default implementations of such advanced features probably wouldn't work anyway. For example, the #[should_panic]
attribute relies on stack unwinding to catch the panics, which we disabled for our kernel.
To implement a custom test framework for our kernel, we add the following to our main.rs
:
// in src/main.rs
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]
#[cfg(test)]
pub fn test_runner(tests: &[&dyn Fn()]) {
println!("Running {} tests", tests.len());
for test in tests {
test();
}
}
Our runner just prints a short debug message and then calls each test function in the list. The argument type &[&dyn Fn()]
is a slice of trait object references of the Fn() trait. It is basically a list of references to types that can be called like a function. Since the function is useless for non-test runs, we use the #[cfg(test)]
attribute to include it only for tests.
When we run cargo test
now, we see that it now succeeds (if it doesn't, see the note below). However, we still see our "Hello World" instead of the message from our test_runner
. The reason is that our _start
function is still used as entry point. The custom test frameworks feature generates a main
function that calls test_runner
, but this function is ignored because we use the #[no_main]
attribute and provide our own entry point.
Note: There is currently a bug in cargo that leads to "duplicate lang item" errors on cargo test
in some cases. It occurs when you have set panic = "abort"
for a profile in your Cargo.toml
. Try removing it, then cargo test
should work. Alternatively, if that doesn't work, then add panic-abort-tests = true
to the [unstable]
section of your .cargo/config.toml
file. See the cargo issue for more information on this.
To fix this, we first need to change the name of the generated function to something different than main
through the reexport_test_harness_main
attribute. Then we can call the renamed function from our _start
function:
// in src/main.rs
#![reexport_test_harness_main = "test_main"]
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
#[cfg(test)]
test_main();
loop {}
}
We set the name of the test framework entry function to test_main
and call it from our _start
entry point. We use [conditional compilation] to add the call to test_main
only in test contexts because the function is not generated on a normal run.
When we now execute cargo test
, we see the "Running 0 tests" message from our test_runner
on the screen. We are now ready to create our first test function:
// in src/main.rs
#[test_case]
fn trivial_assertion() {
print!("trivial assertion... ");
assert_eq!(1, 1);
println!("[ok]");
}
When we run cargo test
now, we see the following output:
The tests
slice passed to our test_runner
function now contains a reference to the trivial_assertion
function. From the trivial assertion... [ok]
output on the screen, we see that the test was called and that it succeeded.
After executing the tests, our test_runner
returns to the test_main
function, which in turn returns to our _start
entry point function. At the end of _start
, we enter an endless loop because the entry point function is not allowed to return. This is a problem, because we want cargo test
to exit after running all tests.
Exiting QEMU
Right now, we have an endless loop at the end of our _start
function and need to close QEMU manually on each execution of cargo test
. This is unfortunate because we also want to run cargo test
in scripts without user interaction. The clean solution to this would be to implement a proper way to shutdown our OS. Unfortunately, this is relatively complex because it requires implementing support for either the APM or ACPI power management standard.
Luckily, there is an escape hatch: QEMU supports a special isa-debug-exit
device, which provides an easy way to exit QEMU from the guest system. To enable it, we need to pass a -device
argument to QEMU. We can do so by adding a package.metadata.bootimage.test-args
configuration key in our Cargo.toml
:
# in Cargo.toml
[package.metadata.bootimage]
test-args = ["-device", "isa-debug-exit,iobase=0xf4,iosize=0x04"]
The bootimage runner
appends the test-args
to the default QEMU command for all test executables. For a normal cargo run
, the arguments are ignored.
Together with the device name (isa-debug-exit
), we pass the two parameters iobase
and iosize
that specify the I/O port through which the device can be reached from our kernel.
I/O Ports
There are two different approaches for communicating between the CPU and peripheral hardware on x86, memory-mapped I/O and port-mapped I/O. We already used memory-mapped I/O for accessing the VGA text buffer through the memory address 0xb8000
. This address is not mapped to RAM but to some memory on the VGA device.
In contrast, port-mapped I/O uses a separate I/O bus for communication. Each connected peripheral has one or more port numbers. To communicate with such an I/O port, there are special CPU instructions called in
and out
, which take a port number and a data byte (there are also variations of these commands that allow sending a u16
or u32
).
The isa-debug-exit
device uses port-mapped I/O. The iobase
parameter specifies on which port address the device should live (0xf4
is a generally unused port on the x86's IO bus) and the iosize
specifies the port size (0x04
means four bytes).
Using the Exit Device
The functionality of the isa-debug-exit
device is very simple. When a value
is written to the I/O port specified by iobase
, it causes QEMU to exit with exit status (value << 1) | 1
. So when we write 0
to the port, QEMU will exit with exit status (0 << 1) | 1 = 1
, and when we write 1
to the port, it will exit with exit status (1 << 1) | 1 = 3
.
Instead of manually invoking the in
and out
assembly instructions, we use the abstractions provided by the x86_64
crate. To add a dependency on that crate, we add it to the dependencies
section in our Cargo.toml
:
# in Cargo.toml
[dependencies]
x86_64 = "0.14.2"
Now we can use the Port
type provided by the crate to create an exit_qemu
function:
// in src/main.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum QemuExitCode {
Success = 0x10,
Failed = 0x11,
}
pub fn exit_qemu(exit_code: QemuExitCode) {
use x86_64::instructions::port::Port;
unsafe {
let mut port = Port::new(0xf4);
port.write(exit_code as u32);
}
}
The function creates a new Port
at 0xf4
, which is the iobase
of the isa-debug-exit
device. Then it writes the passed exit code to the port. We use u32
because we specified the iosize
of the isa-debug-exit
device as 4 bytes. Both operations are unsafe because writing to an I/O port can generally result in arbitrary behavior.
To specify the exit status, we create a QemuExitCode
enum. The idea is to exit with the success exit code if all tests succeeded and with the failure exit code otherwise. The enum is marked as #[repr(u32)]
to represent each variant by a u32
integer. We use the exit code 0x10
for success and 0x11
for failure. The actual exit codes don't matter much, as long as they don't clash with the default exit codes of QEMU. For example, using exit code 0
for success is not a good idea because it becomes (0 << 1) | 1 = 1
after the transformation, which is the default exit code when QEMU fails to run. So we could not differentiate a QEMU error from a successful test run.
We can now update our test_runner
to exit QEMU after all tests have run:
// in src/main.rs
fn test_runner(tests: &[&dyn Fn()]) {
println!("Running {} tests", tests.len());
for test in tests {
test();
}
/// new
exit_qemu(QemuExitCode::Success);
}
When we run cargo test
now, we see that QEMU immediately closes after executing the tests. The problem is that cargo test
interprets the test as failed even though we passed our Success
exit code:
> cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running target/x86_64-siso_os/debug/deps/siso_os-5804fc7d2dd4c9be
Building bootloader
Compiling bootloader v0.5.3 (/home/philipp/Documents/bootloader)
Finished release [optimized + debuginfo] target(s) in 1.07s
Running: `qemu-system-x86_64 -drive format=raw,file=/…/target/x86_64-siso_os/debug/
deps/bootimage-siso_os-5804fc7d2dd4c9be.bin -device isa-debug-exit,iobase=0xf4,
iosize=0x04`
error: test failed, to rerun pass '--bin siso_os'
The problem is that cargo test
considers all error codes other than 0
as failure.
Success Exit Code
To work around this, bootimage
provides a test-success-exit-code
configuration key that maps a specified exit code to the exit code 0
:
# in Cargo.toml
[package.metadata.bootimage]
test-args = […]
test-success-exit-code = 33 # (0x10 << 1) | 1
With this configuration, bootimage
maps our success exit code to exit code 0, so that cargo test
correctly recognizes the success case and does not count the test as failed.
Our test runner now automatically closes QEMU and correctly reports the test results. We still see the QEMU window open for a very short time, but it does not suffice to read the results. It would be nice if we could print the test results to the console instead, so we can still see them after QEMU exits.
Create a Library
In order to better organise the code for future development, we need to split off a library from our main.rs
, which can be included by other crates. To do this, we create a new src/lib.rs
file:
// src/lib.rs
#![no_std]
Like the main.rs
, the lib.rs
is a special file that is automatically recognized by cargo. The library is a separate compilation unit, so we need to specify the #![no_std]
attribute again.
To make our library work with cargo test
, we need to also move the test function and attributes from main.rs
to lib.rs
:
// in src/lib.rs
#![cfg_attr(test, no_main)]
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]
#![reexport_test_harness_main = "test_main"]
// No cfg_test gate here, so that the test_runner function
// is available in main.rs
pub fn test_runner(tests: &[&dyn Fn()]) {
println!("Running {} tests", tests.len());
for test in tests {
test();
}
}
/// Entry point for `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
test_main();
loop {}
}
pub fn test_panic_handler(info: &PanicInfo) -> ! {
println!("[failed]\n");
println!("Error: {}\n", info);
exit_qemu(QemuExitCode::Failed);
loop {}
}
#[cfg(test)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
test_panic_handler(info)
}
Since our lib.rs
is tested independently of our main.rs
, we need to add a _start
entry point and a panic handler when the library is compiled in test mode.
We also move over the QemuExitCode
enum and the exit_qemu
function and make them public:
// in src/lib.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum QemuExitCode {
Success = 0x10,
Failed = 0x11,
}
pub fn exit_qemu(exit_code: QemuExitCode) {
use x86_64::instructions::port::Port;
unsafe {
let mut port = Port::new(0xf4);
port.write(exit_code as u32);
}
}
To also make println
available, we move the module declarations too:
// in src/lib.rs
pub mod vga_buffer;
We make the modules public to make them usable outside of our library. This is also required for making our println
macro usable since it uses the _print
functions of the modules.
Now we can update our main.rs
to use the library:
// in src/main.rs
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runner(siso_os::test_runner)]
#![reexport_test_harness_main = "test_main"]
use core::panic::PanicInfo;
use siso_os::println;
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
#[cfg(test)]
test_main();
loop {}
}
/// This function is called on panic.
#[cfg(not(test))]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
loop {}
}
#[cfg(test)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
siso_os::test_panic_handler(info)
}
Insert printing automatically
Our trivial_assertion
test currently needs to print its own status information using print!/println
:
fn trivial_assertion() {
print!("trivial assertion... ");
assert_eq!(1, 1);
println!("[ok]");
}
Manually adding these print statements for every test we write is cumbersome, so let’s update our test_runner
to print these messages automatically. To do that, we need to create a new Testable
trait:
// in src/lib.rs
pub trait Testable {
fn run(&self) -> ();
}
The trick now is to implement this trait for all types T
that implement the Fn() trait:
// in src/lib.rs
impl<T> Testable for T
where
T: Fn(),
{
fn run(&self) {
print!("{}...\t", core::any::type_name::<T>());
self();
println!("[ok]");
}
}
We implement the run
function by first printing the function name using the any::type_name function. This function is implemented directly in the compiler and returns a string description of every type. For functions, the type is their name, so this is exactly what we want in this case. The \t
character is the tab character, which adds some alignment to the [ok]
messages.
After printing the function name, we invoke the test function through self()
. This only works because we require that self implements the Fn() trait
. After the test function returns, we print [ok]
to indicate that the function did not panic.
The last step is to update our test_runner
to use the new Testable
trait:
#[cfg(test)]
pub fn test_runner(tests: &[&dyn Testable]) { // new
println!("Running {} tests", tests.len());
for test in tests {
test.run(); // new
}
exit_qemu(QemuExitCode::Success);
}
The only two changes are the type of the tests argument from &[&dyn Fn()]
to &[&dyn Testable]
and the fact that we now call test.run()
instead of test()
.
We can now remove the print statements from our trivial_assertion test since they’re now printed automatically:
#[test_case]
fn trivial_assertion() {
assert_eq!(1, 1);
}
The function name now includes the full path to the function, which is useful when test functions in different modules have the same name. Otherwise, the output looks the same as before, but we no longer need to add print statements to our tests manually.
The library is usable like a normal external crate. It is called siso_os
, like our crate. The above code uses the siso_os::test_runner
function in the test_runner
attribute and the siso_os::test_panic_handler
function in our cfg(test)
panic handler. It also imports the println
macro to make it available to our _start
and panic
functions.
At this point, cargo run
and cargo test
should work again. Of course, cargo test
still loops endlessly (you can exit with ctrl+c
). This will be fixed in a future post with more testing capabilities.
Acknowledgment
This lab is based on the Writing an OS in Rust series from Philipp Oppermann.