使用Rust编写操作系统 - 1.4 - 测试

本篇文章将探讨在no_std环境中,可执行文件的单元和集成测试。我们将利用Rust对自定义测试框架的支持来在内核中执行测试函数。为了输出QEMU的结果,我们将使用QEMU和bootimage工具的其他功能。

这个博客是在GitHub上公开开发的。如果你有任何问题或疑问,请在那里开一个issue。你也可以在底部留言。这篇文章的完整源代码可以在post-04分支中找到。

需要了解的内容

本文取代了(现已废弃的)单元测试集成测试这两篇文章。本文将假设你在2019-04-27之后阅读量了A Minimal Rust Kernel一文。目的主要是为了添加.cargo/config.toml文件,设置了默认的编译目标,并设置了cargo runrunner参数

Rust中的测试

Rust有一个内置的测试框架,它能够在不设置任何东西的情况下执行单元测试。只要创建一个通过断言检查一些结果的函数,并在函数前添加#[test]属性,cargo test就会自动发现并执行该crate中的所有测试函数。

不幸的是,对于像我们的内核这样no_std程序来说,就比较复杂了。问题在于Rust的测试框架隐式地使用了内置的test库,而测试库依赖于标准库。这意味着我们不能为#[no_std]的内核使用默认的测试框架。

当我们尝试在项目中运行cargo test时,可以看到以下报错:

1
2
3
> cargo test
Compiling blog_os v0.1.0 (/…/blog_os)
error[E0463]: can't find crate for `test`

由于testcrate依赖于标准库,所以它不能用于我们的裸机目标。虽然将testcrate移植到#[no_std]环境中是可行的,但这是非常不稳定的,需要一些黑科技,比如重新定义panic宏。

自定义测试框架

幸运的是,Rust支持通过”unstable”的custom_test_frameworks特性替换默认的测试框架。这个特性下,测试不需要外部库,因此也可以在#[no_std]环境中工作。它的工作原理是收集所有带有#[test_case]属性的函数,然后以测试列表为参数调用用户指定的runner函数。因此,它给了实现者对测试过程尽可能大的控制权。

与默认的测试框架相比,缺少许多默认测试框架具有的高级功能,比如should_panic测试就是不可用的。因此,如果需要的话,要由实现者自己实现这样的功能。这对我们来说是很理想的,因为我们有一个非常特殊的执行环境,在这种环境下,这种高级特性的默认实现可能本来就无法正常工作。例如,#[should_panic]属性依赖于栈展开来捕获panic,但我们恰好禁用了栈展开。

要为我们的内核实现一个自定义测试框架,我们在main.rs中添加以下内容:

in src/main.rs
1
2
3
4
5
6
7
8
9
10
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]

#[cfg(test)]
fn test_runner(tests: &[&dyn Fn()]) {
println!("Running {} tests", tests.len());
for test in tests {
test();
}
}

我们的runner只是打印一个简短的调试信息,然后调用列表中的每个测试函数。参数类型&[&dyn Fn()],是由Fn()trait这一trait对象的引用组成的slice。它基本上是一个可以像函数一样调用的类型的引用,所组成的列表。由于这些函数对于非测试用途的cargo run是无用的,所以我们使用#[cfg(test)]属性只在测试中运行它。

现在运行cargo test时,我们看到运行成功了(如果没有成功,请看下面的注意)。然而,我们仍然看到的是”Hello World”,而不是test_runner的消息。原因是_start函数仍然被用作入口点。自定义测试框架功能会生成一个调用test_runnermain函数,但这个函数被忽略了,因为我们使用了#[no_main]属性,并提供了我们自己的入口点。

注意

unstable.build-std配置项仅在2020-07-15之后的Rust nightly中提供

目前在cargo中存在一个bug,在某些情况下会导致cargo test中出现”duplicate lang item”的错误。这是由于在Cargo.toml中将某个配置文件设置为了panic = "abort"。尝试删除它,然后cargo test应该就可以正常工作了。更多信息请参见这个cargo issue

为了解决这个问题,我们首先需要通过reexport_test_harness_main属性将生成的函数名称改为与main不同的名称。然后我们就可以从我们的_start函数中调用重命名的函数了。

in src/main.rs
1
2
3
4
5
6
7
8
9
10
11
#![reexport_test_harness_main = "test_main"]

#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");

#[cfg(test)]
test_main();

loop {}
}

我们将测试框架入口函数的名称设置为test_main,并在_start入口点调用它。我们使用条件编译,只在测试环境中添加对test_main的调用,因为该函数不会在正常cargo run时生成。

当我们现在执行cargo test时,就能在屏幕上看到test_runner打印的”Running 0 tests”。现在就可以创建第一个测试函数了:

in src/main.rs
1
2
3
4
5
6
#[test_case]
fn trivial_assertion() {
print!("trivial assertion... ");
assert_eq!(1, 1);
println!("[ok]");
}

现在运行cargo test时,便可以看到以下输出:

QEMU中打印"Hello World!", "Running 1 tests", "trivial assertion... [ok]"

传递给test_runner函数的test slice现在包含了对trivial_assertion函数的引用。由trivial assertion... [ok]在屏幕上的输出,我们看到测试被调用,并且测试通过了。

执行完测试后,test_runner结束并回到到test_main函数,而这个函数又返回到_start入口点函数。在_start结束前,程序进入了一个死循环,因为入口点函数不允许返回。这是个问题,因为我们希望cargo test在运行完所有测试后能够自动退出。

退出QEMU

现在,_start函数最后是一个死循环,因此,需要在每次执行cargo test时手动关闭QEMU。这很麻烦,因为我们希望在没有用户交互的脚本中运行cargo test。最直观的解决方案是实现一种适当的方式来关闭操作系统。但是实现这个过程比较复杂,因为它需要实现对APMACPI电源管理标准的支持。

幸运的是,还有一条捷径。QEMU支持一个特殊的isa-debug-exit设备, 它提供了一个从访客系统中退出QEMU的简单方法。要启用该设备,则需要给QEMU传递一个-device参数。这可以通过在Cargo.toml中添加一个package.metadata.bootimage.test-args配置键来实现:

in Cargo.toml
1
2
[package.metadata.bootimage]
test-args = ["-device", "isa-debug-exit,iobase=0xf4,iosize=0x04"]

bootimage runner将为所有可执行测试附加test-args参数到默认QEMU命令中。而对于正常的cargo run,这些参数会被忽略。

连同设备名(isa-debug-exit),我们传递了iobaseiosize两个参数,这两个参数指定了设备可以从内核到达的I/O端口。

I/O端口

对于x86架构,CPU与外设硬件之间的通信有两种不同的方法,即内存映射I/O端口映射I/O。我们已经使用内存映射I/O通过内存地址0xb8000来访问VGA文本缓冲区。这个地址不是映射到主存,而是映射到VGA设备上的一些内存。

与内存映射I/O不同,端口映射的I/O使用单独的I/O总线进行通信。每个连接的外设都有一个或多个端口号。为了与这样的I/O端口进行通信,有一种特殊的CPU指令,叫做inout,这些指令需要指定一个端口号和一个字节数据作为参数(这些指令也有一些变体,允许发送一个u16u32)。

isa-debug-exit设备使用端口映射的I/O。iobase参数指定设备的端口地址(0xf4是x86的IO总线上的一个一般不使用的端口),iosize指定端口大小(0x04表示4个字节)。

使用退出设备

isa-debug-exit设备的功能非常简单。当一个值被写入iobase所指定的I/O端口时,它会使QEMU以退出状态(value << 1) | 1退出。因此,当我们向该端口写入0时,QEMU将以退出状态(0 << 1) | 1 = 1退出,而当我们向该端口写入1时,将以退出状态(1 << 1) | 1 = 3退出。

我们不需要手动调用inout汇编指令,直接使用x86_64 crate提供的抽象。在Cargo.tomldependencies部分添加对该crate的依赖即可。

in Cargo.toml
1
2
[dependencies]
x86_64 = "0.13.2"

现在我们可以使用crate提供的Port类型来创建exit_qemu函数了:

in src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[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);
}
}

该函数在0xf4上创建了一个新Port对象,作为isa-debug-exit设备的iobase。然后将参数退出代码写入该端口。我们使用u32是因为我们指定了isa-debug-exit设备的iosize4字节。这两种操作都是非安全的,因为向I/O端口写入数据可能会导致不可预知的结果。

为了指定退出状态,我们创建了一个QemuExitCode枚举类型。想要达到的效果是,如果所有的测试都成功了就用成功退出代码退出,否则就用失败退出代码退出。该枚举被标记为#[repr(u32)],这会强制编译器用一个u32整型来表示该枚举变量。我们用退出码0x10表示成功,0x11表示失败。实际的退出代码并不太重要,只要不与QEMU的默认退出代码冲突即可。例如,使用退出码0表示成功并不是一个好主意,因为它在转换后变成了(0 << 1) | 1 = 1,这就是QEMU运行失败时的默认退出码,这将导致我们无法区分QEMU错误和测试运行成功。

现在就可以更新我们的test_runner了,在所有测试运行后会退出QEMU:

in src/main.rs
1
2
3
4
5
6
7
8
fn test_runner(tests: &[&dyn Fn()]) {
println!("Running {} tests", tests.len());
for test in tests {
test();
}
/// new
exit_qemu(QemuExitCode::Success);
}

我们现在运行cargo test时,看到QEMU在执行完测试后立即关闭。问题是,虽然我们传入了Success退出码,但cargo test却将测试解释为失败:

1
2
3
4
5
6
7
8
9
10
> cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running target/x86_64-blog_os/debug/deps/blog_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-blog_os/debug/
deps/bootimage-blog_os-5804fc7d2dd4c9be.bin -device isa-debug-exit,iobase=0xf4,
iosize=0x04`
error: test failed, to rerun pass '--bin blog_os'

问题在于,cargo test将除0以外的所有错误代码视为失败。

成功退出码

为了解决这个问题,bootimage提供了一个test-success-exit-code配置键,将指定的退出代码映射到退出代码0

in Cargo.toml
1
2
3
4

[package.metadata.bootimage]
test-args = […]
test-success-exit-code = 33 # (0x10 << 1) | 1

有了这个配置,bootimage就会把我们的成功退出码映射到退出码0上,如此,cargo test就会正确识别成功测试,也就不会再把成功测试算作失败了。

我们的测试函数现在会自动关闭QEMU,并能够将正确的测试结果上报。可以看到QEMU窗口瞬间闪过,以至于我们没有足够的时间阅读测试结果。如果能把测试结果打印到控制台就更好了,这样我们在QEMU退出后仍然能看到测试结果。

打印到控制台

为了在控制台上看到测试输出,我们需要以某种方式将数据从内核发送到主机系统。有很多方法可以实现,例如通过TCP网络接口发送数据。然而,设置网络协议栈是一项相当复杂的任务,所以我们将选择一个更简单的解决方案。

串口通信

一个简单的发送数据的方法是使用串口,这是一个古老的接口标准,在现代计算机中已经找不到了。串口协议很容易编程控制,QEMU可以将通过串口发送的字节重定向到主机的标准输出或文件中。

实现串行接口的芯片叫做UART。x86上的UART型号很多,但幸运的是它们之间的区别仅在于一些我们用不到的高级功能。目前常见的UART都能兼容到16550 UART,所以我们的测试框架就选用这个型号。

我们将使用uart_16550 crate来初始化UART并通过串口发送数据。为了将它作为一个依赖项添加,我们更新了Cargo.tomlmain.rs

in Cargo.toml
1
2
[dependencies]
uart_16550 = "0.2.0"

uart_16550crate中包含了用以表示UART寄存器的SerialPort结构体,但我们仍然需要自己构造一个实例。为此,我们创建一个新的serial模块,其内容如下:

in src/main.rs
1
mod serial;
in src/serial.rs
1
2
3
4
5
6
7
8
9
10
11
use uart_16550::SerialPort;
use spin::Mutex;
use lazy_static::lazy_static;

lazy_static! {
pub static ref SERIAL1: Mutex<SerialPort> = {
let mut serial_port = unsafe { SerialPort::new(0x3F8) };
serial_port.init();
Mutex::new(serial_port)
};
}

类似VGA文本缓冲区中,使用lazy_staticspinlock来创建静态Writer实例的操作。我们这次通过使用lazy_static,确保init方法在第一次使用时有且仅有一次调用。

isa-debug-exit设备一样,UART也是使用端口I/O进行编程的。由于UART比较复杂,它使用多个I/O端口来编程不同的设备寄存器。非安全函数SerialPort::new需要UART的第一个I/O端口的地址作为参数,从这个地址就可以计算出所有需要的端口地址。我们传递的是端口地址0x3F8,这是第一个串行接口的标准端口号。

为了使串口便于使用,我们添加了serial_print!serial_println!宏:

in src/serial.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#[doc(hidden)]
pub fn _print(args: ::core::fmt::Arguments) {
use core::fmt::Write;
SERIAL1.lock().write_fmt(args).expect("Printing to serial failed");
}

/// Prints to the host through the serial interface.
#[macro_export]
macro_rules! serial_print {
($($arg:tt)*) => {
$crate::serial::_print(format_args!($($arg)*));
};
}

/// Prints to the host through the serial interface, appending a newline.
#[macro_export]
macro_rules! serial_println {
() => ($crate::serial_print!("\n"));
($fmt:expr) => ($crate::serial_print!(concat!($fmt, "\n")));
($fmt:expr, $($arg:tt)*) => ($crate::serial_print!(
concat!($fmt, "\n"), $($arg)*));
}

这个实现与我们的printprintln宏的实现非常相似。由于SerialPort类型已经实现了fmt::Writetrait,因此不需要提供自己的实现。

这次不用再将测试结果打印到VGA文本缓冲区了,现在直接将结果打印到串行接口:

in src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
#[cfg(test)]
fn test_runner(tests: &[&dyn Fn()]) {
serial_println!("Running {} tests", tests.len());
[…]
}

#[test_case]
fn trivial_assertion() {
serial_print!("trivial assertion... ");
assert_eq!(1, 1);
serial_println!("[ok]");
}

注意到serial_println宏直接存在于根命名空间下,这是因为我们使用了#[macro_export]属性。因此,如果通过使用crate::serial::serial_println将无法导入该宏。

QEMU参数

要查看QEMU的串行输出,我们需要使用-serial参数来重定向输出到stdout:

in Cargo.toml
1
2
3
4
[package.metadata.bootimage]
test-args = [
"-device", "isa-debug-exit,iobase=0xf4,iosize=0x04", "-serial", "stdio"
]

现在运行cargo test时,直接可以在控制台中看到测试输出:

1
2
3
4
5
6
7
8
9
10
> cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running target/x86_64-blog_os/debug/deps/blog_os-7b7c37b4ad62551a
Building bootloader
Finished release [optimized + debuginfo] target(s) in 0.02s
Running: `qemu-system-x86_64 -drive format=raw,file=/…/target/x86_64-blog_os/debug/
deps/bootimage-blog_os-7b7c37b4ad62551a.bin -device
isa-debug-exit,iobase=0xf4,iosize=0x04 -serial stdio`
Running 1 tests
trivial assertion... [ok]

然而,当测试失败时,我们仍然会在QEMU中看到输出,因为我们的panic handler仍然使用println。为了模拟这种情况,我们可以将测试trivial_assertion中的断言改为assert_eq!(0, 1)

panic时仍然在QEMU中打印

可以看到,panic信息仍然被打印到VGA缓冲区,而其余的测试输出则被打印到串口。panic信息非常有用,所以我们希望也能在控制台中看到这些信息。

Panic时打印错误信息

为了在panic中带着错误信息退出QEMU,我们可以使用条件编译,在测试模式下使用另一个panic处理程序。

in src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 现有的panic_handler
#[cfg(not(test))] // 新增条件编译属性
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
loop {}
}

// 测试模式下的panic_handler
#[cfg(test)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
serial_println!("[failed]\n");
serial_println!("Error: {}\n", info);
exit_qemu(QemuExitCode::Failed);
loop {}
}

在测试panic处理函数中,我们使用serial_println代替println,然后用失败退出码退出QEMU。请注意,在exit_qemu调用之后,我们仍然需要写一个死循环,因为编译器并不知道在测试模式下isa-debug-exit设备会导致程序退出。

现在,QEMU也会因为测试失败而退出,并在控制台上打印一个有用的错误信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running target/x86_64-blog_os/debug/deps/blog_os-7b7c37b4ad62551a
Building bootloader
Finished release [optimized + debuginfo] target(s) in 0.02s
Running: `qemu-system-x86_64 -drive format=raw,file=/…/target/x86_64-blog_os/debug/
deps/bootimage-blog_os-7b7c37b4ad62551a.bin -device
isa-debug-exit,iobase=0xf4,iosize=0x04 -serial stdio`
Running 1 tests
trivial assertion... [failed]

Error: panicked at 'assertion failed: `(left == right)`
left: `0`,
right: `1`', src/main.rs:65:5

现在能够在控制台上看到了所有的测试输出了,我们也不再需要瞬间闪过的QEMU窗口。接下来,我们要完全隐藏它。

隐藏QEMU

通过使用isa-debug-exit设备和串口完成完整的测试结果的上报,现在我们不再需要QEMU窗口了。可以通过给QEMU传递-display none参数来隐藏它:

in Cargo.toml
1
2
3
4
5
[package.metadata.bootimage]
test-args = [
"-device", "isa-debug-exit,iobase=0xf4,iosize=0x04", "-serial", "stdio",
"-display", "none"
]

现在的QEMU完全在后台运行,不再打开任何窗口。这不仅减少了弹出窗口的干扰,而且还允许我们的测试框架在没有图形用户界面的环境中运行,如CI服务或SSH连接。

超时

由于cargo test会等到测试运行结束才退出,所以一个永不返回的测试会永远的将测试阻塞。这很不幸,但在实践中并不是一个大问题,因为通常很容易避免在测试中写死循环。然而,在我们的案例中,死循环会在各种情况下发生:

  • bootloader无法加载我们的内核,这会导致系统无休止地重启。
  • BIOS/UEFI固件无法加载bootloader,同样也会导致无休止地重启。
  • CPU在我们一些函数的最后进入了loop {}语句,例如因为QEMU退出设备不能正常工作。
  • 硬件导致系统复位,比如CPU异常没有被捕捉到(在以后的文章中会有详细解释)。

由于在很多情况下会出现死循环,bootimage工具默认为每个测试可执行文件设置了5分钟的超时。如果测试没有在这个时间内完成,它就会被标记为失败,并将 “Timed Out”错误打印到控制台。这个功能可以确保陷入死循环的测试不会永远的阻塞cargo test

可以通过在trivial_assertion测试中加入一个loop {}语句自己尝试一下。当运行cargo test时,你会看到测试在5分钟后被标记为超时。超时时间可以通过Cargo.toml中的test-timeout键来进行配置:

in Cargo.toml
1
2
[package.metadata.bootimage]
test-timeout = 300 # (in seconds)

如果你不想等待5分钟trivial_assertion测试超时,可以暂时地降低这个值。

自动插入打印

trivial_assertion测试目前需要使用serial_print!serial_println!来打印自己的状态信息:

1
2
3
4
5
6
#[test_case]
fn trivial_assertion() {
serial_print!("trivial assertion... ");
assert_eq!(1, 1);
serial_println!("[ok]");
}

为每一个测试手动添加这些打印语句是很麻烦的,升级一下test_runner函数就可以做到自动打印这些消息。要实现这一点,首先需要创建一个新的Testabletrait:

in src/main.rs
1
2
3
pub trait Testable {
fn run(&self) -> ();
}

这个技巧是为所有具有Fn()traitT类型实现这个测试用trait:

in src/main.rs
1
2
3
4
5
6
7
8
9
10
impl<T> Testable for T
where
T: Fn(),
{
fn run(&self) {
serial_print!("{}...\t", core::any::type_name::<T>());
self();
serial_println!("[ok]");
}
}

在实现run函数时,首先使用any::type_name函数打印函数名。这个函数在编译器中直接实现,它返回每个类型的字符串描述。对于函数来说,类型就是它们的名字,而这种情况这正是我们希望的。\t字符是制表符tab,为了与[ok]信息进行一定程度的对齐。

打印函数名后,我们使用self()调用测试函数本身。这只是因为我们要求self实现了Fn()trait。在测试函数返回后,我们打印[ok]来表示该函数没有panic。

最后一步是为test_runner升级新特性Testabletrait:

in src/main.rs
1
2
3
4
5
6
7
8
#[cfg(test)]
pub fn test_runner(tests: &[&dyn Testable]) {
serial_println!("Running {} tests", tests.len());
for test in tests {
test.run(); // new
}
exit_qemu(QemuExitCode::Success);
}

仅有的两个变化是测试参数的类型从&[&dyn Fn()]变成了&[&dyn Testable],以及我们现在调用的是test.run()而不是test()

现在可以从trivial_assertion测试中删除打印语句,因为它们现在是自动打印的:

in src/main.rs
1
2
3
4
#[test_case]
fn trivial_assertion() {
assert_eq!(1, 1);
}

现在cargo test的输出是这样的:

1
2
Running 1 tests
blog_os::trivial_assertion... [ok]

现在函数名包含了函数的完整路径,这在不同模块中的测试函数有相同名称时很有用。其余的输出看起来和原来一样,只不过我们不再需要在测试中手动添加打印语句了。

VGA缓冲区测试

有了一个好用的测试框架,现在我们可以为VGA缓冲区的实现创建一些测试。首先,我们创建一个非常简单的测试来验证println是否能够正常工作:

in src/vga_buffer.rs
1
2
3
4
#[test_case]
fn test_println_simple() {
println!("test_println_simple output");
}

该测试只是打印一些字符到VGA缓冲区。如果测试没有panic,这意味着println调用也没有panic。

为了确保即使打印了很多行,并且行数多到从屏幕上方移除旧行,也不会发生panic,我们可以创建另一个测试:

in src/vga_buffer.rs
1
2
3
4
5
6
#[test_case]
fn test_println_many() {
for _ in 0..200 {
println!("test_println_many output");
}
}

我们还可以创建一个测试函数来验证打印的行是否真的出现在屏幕上:

in src/vga_buffer.rs
1
2
3
4
5
6
7
8
9
#[test_case]
fn test_println_output() {
let s = "Some test string that fits on a single line";
println!("{}", s);
for (i, c) in s.chars().enumerate() {
let screen_char = WRITER.lock().buffer.chars[BUFFER_HEIGHT - 2][i].read();
assert_eq!(char::from(screen_char.ascii_character), c);
}
}

该函数指定了一个测试字符串,先使用println打印,然后遍历屏幕字符,也就是静态变量WRITER中代表VGA文本缓冲区的二维数组。由于println打印到最后一行屏幕后立即附加一个换行符,所以字符串应该出现在第BUFFER_HEIGHT - 2行。

利用enumerate函数,使用变量i记录迭代次数,然后用它来从VGA缓冲区二维数组中获取与c对应的字符,通过将屏幕字符的ascii_character与c进行比较,我们确保字符串的每个字符都真正出现在VGA文本缓冲区中。

我们还可以创建更多的测试函数,例如,测试打印很长的行时字符是否被正确封装的函数;或者测试是否能够正确处理换行符、非打印字符、非unicode字符的函数。

然而,在这篇文章的其余部分,我们将解释如何创建集成测试来测试不同组件之间的交互。

集成测试

Rust中集成测试的惯例是把它们放到项目根目录下的tests目录中(即与src目录平级)。默认测试框架和自定义测试框架都会自动识别并执行该目录下的所有测试。

所有的集成测试都是与与main.rs完全分开的单独的可执行文件。这意味着我们需要为每个测试定义一个入口点函数。让我们创建一个名为basic_boot的集成测试示例,仔细看看它是如何工作的:

in tests/basic_boot.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]
#![reexport_test_harness_main = "test_main"]

use core::panic::PanicInfo;

#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
test_main();

loop {}
}

fn test_runner(tests: &[&dyn Fn()]) {
unimplemented!();
}

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
loop {}
}

由于集成测试是独立的可执行文件,我们需要再次提供所有的crate属性(如no_stdno_maintest_runner等)。我们还需要创建一个新的入口点函数_start,它调用测试入口点函数test_main。我们不需要任何cfg(test)属性,因为集成测试的可执行文件永远不会以非测试模式构建。

我们使用总是panic的unimplemented宏作为test_runner函数的占位符,而且暂时在panic_handler中只做loop。理想情况下,我们希望能像在main.rs中一样使用serial_println宏和exit_qemu函数实现这些函数。问题是,我们无法访问这些函数,因为测试是完全独立于main.rs可执行文件构建的。

如果你在这个阶段运行cargo test,你会得到一个无休止的循环,因为panic_handler会无休止地循环。你需要使用Ctrl+c快捷键来退出QEMU。

创建单独的库

为了使集成测试能够使用所需的功能,我们需要从main.rs中分离出一个库,以供其他crate和集成测试可执行文件使用。为此,我们创建一个新的src/lib.rs文件:

src/lib.rs
1
#![no_std]

main.rs一样,lib.rs也是一个特殊的文件,会被cargo自动识别。这个库是一个独立的编译单元,所以我们需要再次指定#![no_std]属性。

为了使我们的库能与cargo test配合使用,我们还需要将main.rs中的测试函数和属性移到lib.rs中:

in src/lib.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

#![cfg_attr(test, no_main)]
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]
#![reexport_test_harness_main = "test_main"]

use core::panic::PanicInfo;

pub trait Testable {
fn run(&self) -> ();
}

impl<T> Testable for T
where
T: Fn(),
{
fn run(&self) {
serial_print!("{}...\t", core::any::type_name::<T>());
self();
serial_println!("[ok]");
}
}

pub fn test_runner(tests: &[&dyn Testable]) {
serial_println!("Running {} tests", tests.len());
for test in tests {
test.run();
}
exit_qemu(QemuExitCode::Success);
}

pub fn test_panic_handler(info: &PanicInfo) -> ! {
serial_println!("[failed]\n");
serial_println!("Error: {}\n", info);
exit_qemu(QemuExitCode::Failed);
loop {}
}

/// Entry point for `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
test_main();
loop {}
}

#[cfg(test)]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
test_panic_handler(info)
}

为了使可执行文件和集成测试能够调用test_runner,我们不对它应用cfg(test)属性,并将其公开。我们还将panic_handler的实现提取到一个公共的test_panic_handler函数中,这样它也可以用于可执行文件。

由于我们的lib.rs是独立于main.rs进行测试的,所以当库以测试模式编译时,我们需要添加一个_start入口点和一个panic_handler。通过使用cfg_attr crate属性,我们在这种情况下有条件地启用no_main属性。

继续将QemuExitCode枚举和exit_qemu函数移出,并将它们公开:

in src/lib.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[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);
}
}

现在,可执行文件和集成测试可以从库中导入这些函数,而不需要定义自己的实现。为了让printlnserial_println也能使用,我们把模块的声明也移到了这里:

in src/lib.rs
1
2
pub mod serial;
pub mod vga_buffer;

我们将这些模块公开,以使它们可以在库外使用。这也是使printlnserial_println宏可用的必要条件,因为它们使用了模块的_print函数。

现在我们可以使用新写好的库更新main.rs:

in src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runner(blog_os::test_runner)]
#![reexport_test_harness_main = "test_main"]

use core::panic::PanicInfo;
use blog_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) -> ! {
blog_os::test_panic_handler(info)
}

这个库可以像普通的外部crate一样使用。调用它就像调用本项目的crate一样,在这里就是blog_os。上面的代码在test_runner属性中使用了blog_os::test_runner函数,在cfg(test)panic_handler中使用了blog_os::test_panic_handler函数。同时,它还导入了println宏,使其可以用于_startpanic函数。

这时,cargo runcargo test应该又可以工作了。当然,cargo test仍然会无休止地循环(你可以用Ctrl+c退出)。让我们通过在集成测试中使用所需的库函数来解决这个问题。

完成集成测试

如同src/main.rs一样,我们的tests/basic_boot.rs可执行文件也可以从新库中导入类型。这让我们可以导入缺少的组件来完成集成测试:

in tests/basic_boot.rs
1
2
3
4
5
6
#![test_runner(blog_os::test_runner)]

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
blog_os::test_panic_handler(info)
}

我们不需要重新实现一个测试runner,而是使用我们库中的test_runner函数。对于panic处理器,我们则调用blog_os::test_panic_handler函数,就像我们在main.rs中做的那样。

现在cargo test又正常退出了。当你运行它的时候,你会发现,它分别为lib.rsmain.rsbasic_boot.rs构建和运行测试。对于main.rsbasic_boot集成测试,它报告”Running 0 tests”,因为这些文件没有任何用#[test_case]注释的函数。

现在我们可以在basic_boot.rs中添加测试。例如,我们可以测试println是否能够正常工作,就像我们在VGA缓冲区测试中做的那样:

in tests/basic_boot.rs
1
2
3
4
5
6
use blog_os::println;

#[test_case]
fn test_println() {
println!("test_println output");
}

当我们现在运行cargo test时,我们看到它找到并执行了测试函数。

这个测试现在看起来可能有点无用,因为它几乎和VGA缓冲区的那个测试一模一样。但是,将来我们的main.rslib.rs_start函数可能会扩充新内容,并在运行test_main函数之前调用各种初始化例程,这样两个测试就会在截然不同的环境中执行。

通过在basic_boot环境下测试println,而不调用_start中的任何初始化例程,我们可以确保println在启动后能正常工作。这一点很重要,因为我们需要依靠它来打印panic信息。

更多测试

集成测试的强大之处在于,它们被视为完全独立的程序执行。这使得它们可以完全控制环境,从而可以测试代码是否与CPU或硬件设备正确交互。

我们的basic_boot测试是一个非常简单的集成测试的例子。在未来,我们的内核将增加更多特性,并以各种方式与硬件交互。通过添加集成测试,我们可以确保这些交互正常工作(且能够持续正常工作)符合预期。对于未来可能的测试,我们有一些想法:

  • CPU异常:当代码执行了无效的操作(比如除零运算),CPU会抛出一个异常。内核可以为这种异常注册处理函数。集成测试可以验证当CPU异常发生时,是否调用了正确的异常处理函数;或是在可解决的异常后发生后,是否能继续执行正确操作。
  • 页表:页表定义了哪些内存区域是有效的和可访问的。通过修改页表,可以分配新的内存区域,例如在启动程序时。集成测试可以在_start函数中对页表进行一些修改,然后在#[test_case]函数中验证修改是否有预期效果。
  • 用户空间程序:用户空间程序是指对系统资源访问受限的程序。例如,它们不能访问内核数据结构或其他程序的内存。集成测试可以启动用户空间程序并执行被禁止的操作,而后验证内核是否阻止了这些应该被禁止的操作。

你可以想象,还有很多测试是可能的。通过添加这样的测试,我们可以确保当我们在内核中添加新功能或者重构代码时,不会意外地破坏它们。当我们的内核变得更大、更复杂时,这一点尤其重要。

测试应该panic的行为

标准库的测试框架支持名为#[should_panic]的属性,以允许构建应该失败的测试。这很有用,例如当传递一个无效参数时,可以验证函数是否会出现错误。不幸的是,#[no_std]的crate并不支持这个属性,因为它需要标准库的支持。

虽然我们不能在内核中使用#[should_panic]属性,但是我们可以通过创建一个集成测试来获得类似的行为,这个测试可以从panic处理程序中获得成功的错误代码。让我们开始创建这样一个名为should_panic的测试:

in tests/should_panic.rs
1
2
3
4
5
6
7
8
9
10
11
12
#![no_std]
#![no_main]

use core::panic::PanicInfo;
use blog_os::{QemuExitCode, exit_qemu, serial_println};

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
serial_println!("[ok]");
exit_qemu(QemuExitCode::Success);
loop {}
}

这个测试仍然不完整,因为它还没有定义_start函数或任何自定义测试runner属性。让我们来补充缺失的部分:

in tests/should_panic.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#![feature(custom_test_frameworks)]
#![test_runner(test_runner)]
#![reexport_test_harness_main = "test_main"]

#[no_mangle]
pub extern "C" fn _start() -> ! {
test_main();

loop {}
}

pub fn test_runner(tests: &[&dyn Fn()]) {
serial_println!("Running {} tests", tests.len());
for test in tests {
test();
serial_println!("[test did not panic]");
exit_qemu(QemuExitCode::Failed);
}
exit_qemu(QemuExitCode::Success);
}

测试没有复用lib.rs中的test_runner,而是定义了自己的test_runner函数,当测试没有panic并返回时(我们希望测试会产生panic),就以失败码退出。如果没有定义测试函数,runner就会以成功码退出。由于runner总是在运行一个测试后退出,所以定义多个#[test_case]函数是没有意义的。

现在我们可以创建一个应该会失败的测试:

in tests/should_panic.rs
1
2
3
4
5
6
7
use blog_os::serial_print;

#[test_case]
fn should_fail() {
serial_print!("should_panic::should_fail...\t");
assert_eq!(0, 1);
}

测试使用assert_eq来断言01相等,这当然会失败,于是测试就可以如愿以偿的panic了。注意,我们需要在这里使用serial_print!手动打印函数名,因为我们没有使用Testabletrait。

当我们通过cargo test --test should_panic运行测试时,我们看到测试是成功的,因为测试如期panic了。当我们注释掉断言一行并再次运行测试时,我们看到它确实失败了,出现了”test did not panic”的消息。

这种方法的一个重要缺点是,它只对单个测试函数有效。对于多个#[test_case]函数,只有第一个函数被执行,因为在调用了panic处理器之后便无法继续执行了。目前我还不知道有什么好的方法来解决这个问题,如果你有什么想法,请告诉我!

无环境测试

对于只有一个测试函数的集成测试(如should_panic测试),其实并不需要测试runner。在这样的情况下,我们可以完全禁用测试runner,并直接在_start函数中运行我们的测试。

其中的关键是在Cargo.toml中禁用测试的harness标志,它定义了集成测试是否使用测试runner。当它被设置为false时,默认的测试runner和自定义测试runner功能都会被禁用,这样测试就会被当作一个普通的可执行文件来处理。

让我们为should_panic测试禁用harness标识:

in Cargo.toml
1
2
3
[[test]]
name = "should_panic"
harness = false

现在,我们通过删除测试runner相关代码,大幅简化了should_panic测试,看起来是这样的:

in tests/should_panic.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#![no_std]
#![no_main]

use core::panic::PanicInfo;
use blog_os::{exit_qemu, serial_print, serial_println, QemuExitCode};

#[no_mangle]
pub extern "C" fn _start() -> ! {
should_fail();
serial_println!("[test did not panic]");
exit_qemu(QemuExitCode::Failed);
loop{}
}

fn should_fail() {
serial_print!("should_panic::should_fail...\t");
assert_eq!(0, 1);
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
serial_println!("[ok]");
exit_qemu(QemuExitCode::Success);
loop {}
}

现在我们直接从_start函数中调用should_fail函数,如果函数能够返回,则以失败码退出。当我们现在运行cargo test --test should_panic时,我们看到测试的行为和之前完全一样。

除了创建should_panic测试外,禁用harness属性对于复杂的集成测试也很有用,例如当各测试函数均有副作用,需要按照指定的顺序运行时。

小结

测试是一种非常有用的技术,可以确保某些组件具有所期望的行为。即使测试不能表明没有bug,但也仍是找到bug的有用工具,尤其是避免回溯。

这篇文章解释了如何为Rust内核建立一个测试框架。我们使用Rust的自定义测试框架功能在裸机环境中实现对简单的#[test_case]属性的支持。 通过使用QEMU的isa-debug-exit设备,测试runner可以在运行测试后退出QEMU并报告测试结果。为了将错误消息打印到控制台而不是VGA缓冲区,我们为串行端口创建了一个基础驱动程序。

在为println宏创建了一些测试之后,我们在后半部分探讨了集成测试。我们了解到集成测试位于tests目录中,并被视为完全独立的可执行文件。为了使他们能够访问exit_qemu函数和serial_println宏,我们将大部分代码移入了一个库,该库可以被所有可执行文件和集成测试导入。由于集成测试在各自独立的环境中运行,因此可以测试与硬件的交互或创建应引起panic的测试。

现在,我们有了一个在QEMU内真实环境中运行的测试框架。通过在以后的文章中创建更多测试,我们可以使内核变得更复杂时依旧保持可维护性。

下期预告

在下一篇文章中,我们将探讨CPU异常。当发生非法事件时,CPU将抛出这些异常,例如除零或访问未映射的内存页面(即所谓的“页面错误”)。能够捕获和检查这些异常对于调试将来的错误非常重要。异常处理也非常类似于硬件中断的处理,这是提供键盘支持所必需的。

支持本项目

创建和维护这个博客和相关库是一项繁重的工作,但我真的很喜欢。通过支持我,您可以让我在新内容、新功能和持续维护上投入更多时间。

支持我的最好方式是在GitHub上赞助我,因为他们不收取任何中间费用。如果你喜欢其他平台,我也有PatreonDonorbox账户。后者是最灵活的,因为它支持多种货币和一次性捐款。

感谢您的支持!

评论

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×