操作系统原理实验(三)操作系统的基石:中断与异常(内核测试,异常,双重故障)

内核测试

在rust中测试

Rust 具有内置测试框架,能够运行单元测试。只需创建一个函数,将属性添加到函数标头。然后将自动查找并执行您的箱子的所有测试功能。#[test]cargo test
问题是 Rust 的测试框架隐式使用内置测试库,它依赖于标准库。这意味着我们不能使用内核的默认测试框架。no_std#[no_std]
cargo xtest
在这里插入图片描述
自定义测试框架
rust支持自定义测试框架,并且不需要额外的库,在#[no_std]环境中它也可以工作。
它的工作原理是收集所有标注了#[test_case]属性的函数,然后将这个测试函数的列表作为参数传递给用户指定的runner函数。

要为我们的内核实现自定义测试框架,我们需要将如下代码添加到我们的main.rs中去:

// in src/main.rs

#![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会打印一个简短的debug信息,然后将调用列表中的每个测试函数。由于这个函数在不进行测试的时候不会派上用场,这里我们使用#[cfg(test)]属性,保证它只在测试中生成。
现在当我们执行cargo xtest ,可以发现运行成功。然而,我们看到的仍然是字符串"Hello World",而不是test_runner传递来的信息;这是由于我们的入口点仍然是 _start 函数——自定义测试框架确实会生成一个main函数来调用test_runner,但由于我们使用#[no_main]并提供自己的入口点,这个main函数将被编译器忽略。
为了修复这个问题,我们需要通过reexport_test_harness_main属性,将生成的函数的名称更改为与main不同。使用下面的代码,我们可在_start函数里调用已经重命名的函数:

// 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 {}
}

注意将这个放在最上面
在这里插入图片描述

现在执行 时,我们在屏幕上看到"运行 0 次测试"消息。
在这里插入图片描述

现在,我们已准备好创建我们的第一个测试函数:test_runner

// in src/main.rs

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

现在运行时,我们看到以下输出:cargo xtest
在这里插入图片描述
执行测试后,我们返回到函数,该函数又返回到我们的入口点函数。在 末尾,我们输入一个无限循环,因为入口点函数不允许返回。这是一个问题,因为我们希望在运行所有测试后退出。

退出 QEMU

现在我们在_start函数结束后进入了一个死循环,所以每次执行完cargo xtest后我们都需要手动去关闭QEMU;
我们的办法是:QEMU支持一种名为 isa-debug-exit的特殊设备,它提供了一种从客户系统里退出QEMU的简单方式。为了使用这个设备,我们可以通过将配置关键字package.metadata.bootimage.test-args添加到我们的Cargo.toml中来达到目的:

# in Cargo.toml

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

在传递设备名 (isa-debug-exit)的同时,我们还传递了两个参数:iobase和iosize。这两个参数指定了一个I/O端口,我们的内核将通过它来访问设备。
I/O 端口
CPU 和外围硬件之间有两种不同的通信方法,内存映射 I/O 和端口映射 I/O。我们已经使用内存映射的 I/O 通过内存地址访问 VGA文本缓冲区。
isa-debug-exit设备使用的就是端口映射I/O。其中,iobase 参数指定了设备对应的端口地址(在x86中,0xf4通常是一个未被使用的端口),而iosize则指定了端口的大小(0x04代表4字节)。
使用退出设备
isa-debug-exit设备的功能非常简单。当一个 value被写入iobase指定的端口时,它将导致QEMU以退出状态 (value << 1) | 1退出。
这里我们使用x86_64 crate提供的抽象,而不是手动调用in或out指令。为了添加对该crate的依赖,我们可以将其添加到项目配置Cargo.toml的dependencies小节中去:

# in Cargo.toml

[dependencies]
x86_64 = "0.12.1"

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

// 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);
    }
}

该函数在0xf4处定义了一个新端口,该端口同时也是 isa-debug-exit 设备的 iobase ;这之后,它会向端口写入传递的退出代码。
为了指定退出状态,我们创建了一个 QemuExitCode枚举。我们思路大体上是:如果所有的测试均成功,就以成功退出码退出;否则就以失败退出码退出。这个枚举类型被标记为 #[repr(u32)],代表每个变量都是一个u32的整数类型。我们使用退出代码0x10代表成功,0x11代表失败。

现在我们来更新test_runner的代码,让程序在运行所有测试完毕后退出QEMU:

fn test_runner(tests: &[&dyn Fn()]) {
    println!("Running {} tests", tests.len());
    for test in tests {
        test();
    }
    /// new
    exit_qemu(QemuExitCode::Success);
}

当我们现在运行cargo xtest时,QEMU会在测试运行后立刻退出。然而,即使我们传递了表示成功(Success)的退出代码, cargo xtest依然会将所有的测试都视为失败:
在这里插入图片描述
这里的问题在于,cargo test会将所有非0的错误码都视为测试失败。
成功退出代码
为了解决这个问题,bootimage包提供了一个 test-success-exit-code配置项,可以将指定的退出代码映射到表示成功的退出代码0:

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

打印到控制台

要在控制台上查看测试输出,我们需要以某种方式将数据从内核发送到宿主系统。
串口
QEMU可将通过串口发送的数据重定向到宿主机的标准输出或是外部文件中。
用来实现串行接口的芯片被称为UARTs。目前通用的UARTs都会兼容16550 UART,所以我们在我们测试框架里采用该模型。
我们将我们的Cargo.toml和main.rs修改为如下:

# in Cargo.toml

[dependencies]
uart_16550 = "0.2.0"

我们使用以下内容来创建一个新的串口模块serial:

// in src/main.rs

mod serial;
// in src/serial.rs

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)
    };
}

与设备一样,UART 使用端口 I/O 进行编程。由于 UART 更为复杂,因此它使用多个 I/O 端口对不同的设备寄存器进行编程。不安全函数希望 UART 的第一个 I/O 端口的地址作为参数,从该端口中可以计算所有所需端口的地址。我们传递的端口地址,是第一个串行接口的标准端口号。isa-debug-exitSerialPort::new0x3F8

为使串行端口易于使用,我们添加和宏:serial_print!serial_println!

#[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)*));
}

现在,我们可以打印到串行接口,而不是在我们的测试代码中的VGA文本缓冲区:

// in src/main.rs

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

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

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

# in Cargo.toml

[package.metadata.bootimage]
test-args = [
    "-device", "isa-debug-exit,iobase=0xf4,iosize=0x04", "-serial", "stdio"
]

现在运行时,我们直接在控制台中看到测试输出:cargo xtest
在这里插入图片描述
然而,当测试失败时,我们仍然会在QEMU内看到输出结果——因为我们的panic handler还是依赖于println。为了模拟这个过程,我们将我们的trivial_assertion test中的断言(assertion)修改为assert_eq!(0, 1):
在这里插入图片描述
在这里插入图片描述
我们看到,恐慌消息仍打印到 VGA 缓冲区,而其他测试输出打印到串行端口。恐慌消息非常有用,因此在控制台中看到它也很有用。
为panic打印错误信息
若要在恐慌时出现错误消息退出 QEMU,我们可以在测试模式下使用条件编译使用不同的紧急事件处理程序:

// our existing panic handler
#[cfg(not(test))] // new attribute
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    println!("{}", info);
    loop {}
}

// our panic handler in test mode
#[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调用后,我们仍然需要一个无限循环的loop——因为编译器并不知道 isa-debug-exit设备会导致程序退出。

现在,即使在测试失败的情况下QEMU仍然会运行,并会将一些有用的错误信息打印到控制台:
在这里插入图片描述
由于我们现在在控制台上看到所有测试输出,因此我们不再需要短时间弹出的 QEMU 窗口。因此,我们可以完全隐藏它。
隐藏 Qemu
由于我们使用设备和串行端口报告完整的测试结果,因此不再需要 QEMU 窗口。我们可以通过将参数传递到 Qemu 来隐藏它:isa-debug-exit-display none

# in Cargo.toml

[package.metadata.bootimage]
test-args = [
    "-device", "isa-debug-exit,iobase=0xf4,iosize=0x04", "-serial", "stdio",
    "-display", "none"
]

现在 QEMU 完全在后台运行,不再打开任何窗口。
超时
由于 cargo xtest 会等待test runner退出,如果一个测试永远不返回,它将一直阻塞test runner。
阻塞的情况:
• 引导程序bootloader加载内核失败,导致系统不停重启;
• BIOS/UEFI固件加载bootloader失败,同样会导致无限重启;
• CPU在某些函数结束时进入一个loop {}语句。
• 硬件触发了系统重置,例如未捕获CPU异常时。

由于无限循环可能会在各种情况中发生,因此,bootimage工具默认为每个可执行测试设置了一个长度为5分钟的超时时间限——如果测试未在此时间限内完成,则将其标记为失败,并向控制台输出"Timed Out"即超时错误。这个功能能确保那些卡在无限循环里的测试不会一直阻塞cargo xtest。

超时持续的时间限可以通过Cargo.toml中的test-timeout来进行配置:

# in Cargo.toml

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

你也可以更改超时时间

测试 VGA 缓冲区

现在,我们已经有一个工作测试框架,我们可以为我们的VGA缓冲区实现创建一些测试。首先,我们创建一个非常简单的测试来验证是否有效而不惊慌:

// in src/vga_buffer.rs

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

测试只是打印到 VGA 缓冲区的内容。如果它在不惊慌的情况下完成,则意味着调用也不会惊慌。

为了确保即使打印了多行且将行移离屏幕,也未发生恐慌,我们可以创建另一个测试:

// in src/vga_buffer.rs

#[test_case]
fn test_println_many() {
    for _ in 0..200 {
        println!("test_println_many output");
    }
}

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

// in src/vga_buffer.rs

#[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);
    }
}

在这里插入图片描述

集成测试

在Rust中,集成测试的约定是将其放到项目根目录中的tests目录下(即src的同级目录)。无论是默认测试框架还是自定义测试框架都将自动获取并执行该目录下所有的测试。
所有的集成测试都是它们自己的可执行文件,并且与我们的main.rs完全独立——这也就意味着每个测试都需要定义它们自己的函数入口点。让我们创建一个名为basic_boot的例子,

// in tests/basic_boot.rs

#![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_std, no_main, test_runner, 等等)。我们还需要创建一个新的入口点函数_start,用于调用测试入口函数test_main。
这里我们使用unimplemented宏,充当test_runner暂未实现的占位符;

如果现阶段你运行cargo xtest,你将进入一个无限循环,因为目前panic处理函数暂时只包含一个无限循环;你需要使用快捷键Ctrl+c,才能退出。
在这里插入图片描述
在这里插入图片描述
创建库
为了让这些函数能在我们的集成测试中使用,我们需要从我们的main.rs中分割出一个库,这个库应当可以被其他的crate和集成测试可执行文件使用。
为了达成这个目的,我们创建一个新文件src/lib.rs:

// src/lib.rs

#![no_std]

为了让我们的库可以和cargo xtest一起协同工作,我们还需要添加以下测试函数和属性:

// in src/lib.rs

#![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) 属性(attribute),且应将其设置为公有函数。同时,我们还将panic的处理程序分解为public函数test_panic_handler,这样一来它也可以用于可执行文件了。

我们还将QemuExitCode枚举和exit_qemu函数从main.rs移过来,并设置其为公有函数:

// 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);
    }
}

现在,可执行文件和集成测试都可以从库中导入这些函数,而不需要实现自己的定义。

为了使println 和 serial_println可用,我们将以下的模块声明代码也移动到lib.rs中:

// in src/lib.rs

pub mod serial;
pub mod vga_buffer;

我们将模块公开,使其从库外部使用。这也是使我们的和宏可用,因为他们使用模块的功能。

现在,可以修改main.rs代码来使用我们的库:

// src/main.rs

#![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 lyh_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) -> ! {
    lyh_os::test_panic_handler(info)
}

完成集成测试
就像我们的src/main.rs,我们的tests/basic_boot.rs可执行文件同样可以从我们的新库中导入类型。这也就意味着我们可以导入缺失的组件来完成我们的测试。

// in tests/basic_boot.rs

#![test_runner(blog_os::test_runner)]

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

这里我们使用我们的库中的test_runner函数,而不是重新实现一个test runner。至于panic处理,调用lyh_os::test_panic_handler函数即可,就像我们之前在我们的main.rs里面做的一样。

现在,cargo xtest又能够正常退出了。当你运行该命令时,你会发现它为我们的lib.rs, main.rs, 和 basic_boot.rs分别构建并运行了测试。对于main.rs 和 basic_boot的集成测试,它会报告"Running 0 tests"(正在运行0个测试),因为这些文中暂时没有任何用 #[test_case]标注的函数。

现在,我们可以向 中添加测试。

// in tests/basic_boot.rs

use lyh_os::println;

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

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

注意将原来的panic注释掉
在这里插入图片描述
应当panic的测试
标准库的测试框架支持一个#[should_panic]属性,该属性允许构造应失败的测试。但是#[no_std]

接下来让我们一起创建一个文件名为should_panic的测试吧:

// in tests/should_panic.rs

#![no_std]
#![no_main]

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

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

此测试仍然不完整,因为它尚未定义函数或任何自定义测试运行程序属性。让我们添加缺失的部分:_start

// in tests/should_panic.rs

#![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);
}

这个测试定义了自己的test_runner函数,而不是复用lib.rs中的test_runner,该函数会在测试没有panic而是正常退出时,返回一个错误退出代码——因为这里我们希望测试会panic。

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

// in tests/should_panic.rs

use lyh_os::serial_print;

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

该测试用 assert_eq来断言(assert)0和1是否相等。毫无疑问,这当然会失败(0当然不等于1),所以我们的测试就会像我们想要的那样panic。

当我们通过cargo xtest --test should_panic运行该测试时,我们会发现成功了因为该测试如我们预期的那样panic了。
在这里插入图片描述
当我们将断言部分(即assert_eq!(0, 1);)注释掉后,我们就会发现测试失败,返回了信息"test did not panic"。
在这里插入图片描述
在这里插入图片描述
无线束测试
对于那些只有单个测试函数的集成测试而言(例如我们的should_panic测试),其实并不需要test runner。对于这种情况,我们可以完全禁用test runner,直接在_start函数中直接运行我们的测试。

这里的关键就是在Cargo.toml中为测试禁用 harness flag,这个标志(flag)定义了是否将test runner用于集成测试中:如果该标志位被设置为false,那么默认的test runner和自定义的test runner功能都将被禁用,这样一来该测试就可以像一个普通的可执行程序一样运行了。

现在让我们为我们的should_panic测试禁用harness flag:

# in Cargo.toml

[[test]]
name = "should_panic"
harness = false

现在,我们通过删除测试运行程序相关的代码来大大简化测试。结果如下所示:should_panic

// in tests/should_panic.rs

#![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 xtest --test should_panic时,我们可以发现测试的行为和之前完全一样。
在这里插入图片描述

CPU异常

实现

我们将首先在 中创建新的中断模块,该模块首先创建一个函数,该函数将创建一个新的:src/interrupts.rs init_idtInterrupt DescriptorTable

// in src/lib.rs

pub mod interrupts;

// in src/interrupts.rs

use x86_64::structures::idt::InterruptDescriptorTable;

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
}

它的唯一目的是在断点指令时暂停程序。

我们只希望在执行断点指令时打印一条消息,然后继续该程序。所以让我们创建一个简单的breakpoint_handler函数并将其添加到IDT:

// in src/interrupts.rs

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use crate::println;

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
    idt.breakpoint.set_handler_fn(breakpoint_handler);
}

extern "x86-interrupt" fn breakpoint_handler(
    stack_frame: &mut InterruptStackFrame)
{
    println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}

我们的处理程序只输出一条消息,并漂亮地打印中断堆栈帧。

当我们尝试编译它时,将发生以下错误:
在这里插入图片描述
出现此错误是因为调用约定仍然不稳定。要使用它,我们必须通过添加#![feature(abi_x86_interrupt)]在我们的顶端lib.rs.
在这里插入图片描述

加载 IDT

为了 CPU 使用我们新的中断描述符表,我们需要使用lidt指令加载它。的结构为此提供了一个加载方法函数。让我们尝试使用它:InterruptDescriptorTablex86_64

// in src/interrupts.rs

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
    idt.breakpoint.set_handler_fn(breakpoint_handler);
    idt.load();
}

当我们现在尝试编译它时,将发生以下错误:
在这里插入图片描述
我们的idt在堆栈上创建。然后,堆栈内存被重用用于其他函数,因此CPU将随机堆栈内存解释为IDT。幸运的是,InterruptDescriptorTable::load方法在其函数定义中对此生存期要求进行编码,以便RUST编译器能够在编译时防止此可能的错误。
作为另一种选择,我们可以尝试将idt存储为static:

static IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();

pub fn init_idt() {
    IDT.breakpoint.set_handler_fn(breakpoint_handler);
    IDT.load();
}

有一个问题:静态是不可变的,我们可以使用static mut

static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();

pub fn init_idt() {
    unsafe {
        IDT.breakpoint.set_handler_fn(breakpoint_handler);
        IDT.load();
    }
}

static mut非常容易发生数据竞争,所以我们需要一个unsafe块在每个通道上。

幸运的是lazy_static宏存在。
我们已经进口了lazy_static当我们为VGA文本缓冲区创建了一个抽象。这样我们就可以直接使用lazy_static!宏来创建静态IDT:

// in src/interrupts.rs

use lazy_static::lazy_static;

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt
    };
}

pub fn init_idt() {
    IDT.load();
}

运行它
使异常在内核中工作的最后一步是调用init_idt我们的功能main.rs。我们没有直接调用它,而是引入了一个通用的init在我们lib.rs:

// in src/lib.rs

pub fn init() {
    interrupts::init_idt();
}

有了这个函数,我们现在有了一个可以在不同的程序之间共享的初始化例程的中心位置。
现在,我们可以更新要调用的函数,然后触发断点异常:_start main.rsinit

// in src/main.rs

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

    blog_os::init(); // new

    // invoke a breakpoint exception
    x86_64::instructions::interrupts::int3(); // new

    // as before
    #[cfg(test)]
    test_main();

    println!("It did not crash!");
    loop {}
}

现在(使用 )在 QEMU 中运行它时,我们看到以下内容:cargo xrun
在这里插入图片描述
CPU 成功调用我们的断点处理程序,该处理程序打印消息,然后返回到打印消息的函数。
我们看到中断堆栈帧告诉我们异常发生时的指令和堆栈指针。此信息在调试意外异常时非常有用。
添加测试
让我们创建一个测试,以确保上面的工作继续。首先,我们更新_start函数也要调用init:

// in src/lib.rs

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

在这里插入图片描述
这个_start函数在运行时使用。因为测试lib.rs完全独立于main.rs。

现在,我们可以创建一个测试:test_breakpoint_exception

// in src/interrupts.rs

#[test_case]
fn test_breakpoint_exception() {
    // invoke a breakpoint exception
    x86_64::instructions::interrupts::int3();
}

测试调用int3函数来触发断点异常。通过检查之后是否继续执行,我们验证我们的断点处理程序是否正常工作。

通过运行cargo xtest(所有测试)或cargo xtest --lib(只测试lib.rs及其模块)。您应该在输出中看到以下内容:
在这里插入图片描述
在这里插入图片描述

双重故障

什么是双重错误?

简单地说,双故障是在CPU无法调用异常处理程序时发生的特殊异常。

提供双故障处理程序是非常重要的,因为如果不处理双重故障,则会导致致命的故障。三重故障会发生。无法捕捉到三重故障,而且大多数硬件都是通过系统重置来响应的。
触发双重故障
让我们通过触发一个异常来引发双重故障,因为我们没有定义处理程序函数:

// in src/main.rs

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

    lyh_os::init();

    // trigger a page fault
    unsafe {
        *(0xdeadbeef as *mut u64) = 42;
    };

    // as before
    #[cfg(test)]
    test_main();

    println!("It did not crash!");
    loop {}
}

我们用unsafe写入无效地址0xdeadbeef。虚拟地址未映射到页表中的物理地址,因此会发生页错误。我们还没有在我们的IDT发生双重故障。
当我们现在启动内核时,我们看到它进入了一个无休止的引导循环。启动循环的原因如下:
1、CPU 尝试写入 ,这会导致页面错误。0xdeadbeef
2、CPU 查看 IDT 中的相应条目,并看到未指定处理程序函数。因此,它无法调用页面错误处理程序,并发生双重故障。
3、CPU 查看双故障处理程序的 IDT 条目,但此条目也不指定处理程序函数。因此,发生三重故障。
4、三重故障是致命的。QEMU 对它的反应就像大多数真正的硬件一样, 并发布系统重置。

为了防止这种三重故障,我们需要提供页面故障的处理程序函数或双故障处理程序。

双重故障处理器

双故障是错误代码的正常异常,因此我们可以指定类似于断点处理程序的处理程序函数:

// in src/interrupts.rs

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt.double_fault.set_handler_fn(double_fault_handler); // new
        idt
    };
}

// new
extern "x86-interrupt" fn double_fault_handler(
    stack_frame: &mut InterruptStackFrame, _error_code: u64) -> !
{
    panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}

我们的处理程序打印一条简短的错误消息并转储异常堆栈帧。双故障处理程序的错误代码总是为零,因此没有理由打印它。断点处理程序的一个不同之处是,双故障处理程序是发散。原因是x86_64体系结构不允许从双故障异常返回。

现在启动内核时,我们应看到调用了双故障处理程序:
在这里插入图片描述
成功了!以下是这次发生的情况:

1、CPU 尝试写入 ,这会导致页面错误。0xdeadbeef
2、与之前一样,CPU 查看 IDT 中的相应条目,并看到没有定义处理程序函数。因此,发生双重故障。
3、CPU 跳转到 [现在存在] 双故障处理程序。

三重故障(和引导循环)不再发生,因为 CPU 现在可以调用双故障处理程序。

双重故障的原因

在处理前一个(第一个)异常处理程序期间发生第二个异常时,可能发生双重故障异常"。"可以"很重要:只有非常具体的异常组合才会导致双重故障。这些组合包括:
在这里插入图片描述
内核堆栈溢出
通过调用一个不断递归的函数,我们可以很容易地触发内核堆栈溢出:

// in src/main.rs

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

    blog_os::init();

    fn stack_overflow() {
        stack_overflow(); // for each recursion, the return address is pushed
    }

    // trigger a stack overflow
    stack_overflow();

    [] // test_main(), println(…), and loop {}
}

当我们在QEMU中尝试这段代码时,我们看到系统再次进入一个引导循环。
我们需要确保堆栈在发生双重故障异常时总是有效的。

切换堆栈

当出现异常时,x86_64体系结构能够切换到预定义的好堆栈。

切换机制被实现为中断堆栈表(Ist)IST是一个由7个指针组成的表,指向已知的好堆栈。

struct InterruptStackTable {
    stack_pointers: [Option<StackPointer>; 7],
}

对于每个异常处理程序,我们可以在相应的 IDT 条目中选择一个从IST 到字段的堆栈。
Ist 和 Tss
在x86_64,TSS 不再保存任何任务特定信息。相反,它包含两个堆栈表(IST 是其中之一)。
64 位 TSS 具有以下格式:在这里插入图片描述
当权限级别更改时,CPU 会使用权限堆栈表。
创建 TSS
让我们创建一个新的 TSS,它在其中断堆栈表中包含一个单独的双故障堆栈。

我们在新模块中创建 TSS:gdt

// in src/lib.rs

pub mod gdt;

// in src/gdt.rs

use x86_64::VirtAddr;
use x86_64::structures::tss::TaskStateSegment;
use lazy_static::lazy_static;

pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;

lazy_static! {
    static ref TSS: TaskStateSegment = {
        let mut tss = TaskStateSegment::new();
        tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
            const STACK_SIZE: usize = 4096 * 5;
            static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];

            let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
            let stack_end = stack_start + STACK_SIZE;
            stack_end
        };
        tss
    };
}

我们定义第 0 个 IST 条目是双故障堆栈(任何其他 IST 索引也有效)。然后,我们将双故障堆栈的顶部地址写入第 0 个条目。我们编写顶部地址是因为 x86 上的堆栈向下增长,即从高地址到低地址。
加载 TSS
我们需要向全局描述符表 (GDT) 添加新的段描述符,而不是直接加载表。然后,我们可以加载我们的 TSS 调用ltr指令与相应的 GDT 索引。
全局描述符表
它主要用于两件事:在内核空间和用户空间之间切换,以及加载 TSS 结构。
创建 GDT

// in src/gdt.rs

use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor};

lazy_static! {
    static ref GDT: GlobalDescriptorTable = {
        let mut gdt = GlobalDescriptorTable::new();
        gdt.add_entry(Descriptor::kernel_code_segment());
        gdt.add_entry(Descriptor::tss_segment(&TSS));
        gdt
    };
}

加载 GDT

// in src/gdt.rs

pub fn init() {
    GDT.load();
}

// in src/lib.rs

pub fn init() {
    gdt::init();
    interrupts::init_idt();
}

最后步骤
对于前两个步骤,我们需要访问code_selector和tss_selector我们的变量gdt::init功能。我们可以通过一个新的,使他们成为静态的一部分来实现这一点。Selectors结构:

// in src/gdt.rs

use x86_64::structures::gdt::SegmentSelector;

lazy_static! {
    static ref GDT: (GlobalDescriptorTable, Selectors) = {
        let mut gdt = GlobalDescriptorTable::new();
        let code_selector = gdt.add_entry(Descriptor::kernel_code_segment());
        let tss_selector = gdt.add_entry(Descriptor::tss_segment(&TSS));
        (gdt, Selectors { code_selector, tss_selector })
    };
}

struct Selectors {
    code_selector: SegmentSelector,
    tss_selector: SegmentSelector,
}

现在,我们可以使用选择器重新加载段寄存器并加载我们的 :csTSS

// in src/gdt.rs

pub fn init() {
    use x86_64::instructions::segmentation::set_cs;
    use x86_64::instructions::tables::load_tss;

    GDT.0.load();
    unsafe {
        set_cs(GDT.1.code_selector);
        load_tss(GDT.1.tss_selector);
    }
}

现在我们已经加载了一个有效的TSS和中断堆栈表,我们可以在IDT中为我们的双故障处理程序设置堆栈索引:

// in src/interrupts.rs

use crate::gdt;

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        unsafe {
            idt.double_fault.set_handler_fn(double_fault_handler)
                .set_stack_index(gdt::DOUBLE_FAULT_IST_INDEX); // new
        }

        idt
    };
}

这个set_stack_index方法是不安全的,因为调用方必须确保所使用的索引是有效的,并且不会用于另一个异常。
就这样!现在,每当出现双故障时,CPU都应该切换到双故障堆栈。因此,我们能够抓住全双故障,包括内核堆栈溢出:
在这里插入图片描述
从现在开始,我们再也不应该看到三重故障了!为了确保我们不会意外中断上述内容,我们为此添加一个测试。

堆栈溢出测试

为了测试我们的新模块并确保在堆栈溢出时正确调用双故障处理程序,我们可以添加一个集成测试。其想法是使测试函数中引发双重故障,并验证是否调用了双故障处理程序。gdt

让我们从最小骨架开始

// in tests/stack_overflow.rs

#![no_std]
#![no_main]

use core::panic::PanicInfo;

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

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

像我们的测试一样,测试将运行没有测试线束。原因是在双重故障后无法继续执行,因此多个测试没有意义。要禁用测试工具,我们将以下内容添加到 我们的 :panic_handlerCargo.toml

# in Cargo.toml

[[test]]
name = "stack_overflow"
harness = false

现在应该成功编译。当然,由于宏出现恐慌,测试失败。cargo test --test stack_overflow unimplemented
实施_start
函数的实现如下所示:_start

// in tests/stack_overflow.rs

use blog_os::serial_print;

#[no_mangle]
pub extern "C" fn _start() -> ! {
    serial_print!("stack_overflow::stack_overflow...\t");

    blog_os::gdt::init();
    init_test_idt();

    // trigger a stack overflow
    stack_overflow();

    panic!("Execution continued after stack overflow");
}

#[allow(unconditional_recursion)]
fn stack_overflow() {
    stack_overflow(); // for each recursion, the return address is pushed
    volatile::Volatile::new(0).read(); // prevent tail recursion optimizations
}

我们称之为gdt::init函数来初始化新的GDT。
这个stack_overflow函数几乎与我们main.rs。唯一的区别是我们做了额外的易挥发在函数末尾使用Volatile类型以防止编译器优化。
但是,在我们的示例中,我们希望堆栈溢出发生,因此我们在函数的末尾添加了一个虚拟易失性读语句,编译器不允许删除该语句。
测试 IDT
如上所述,测试需要自己的 IDT 和自定义的双故障处理程序。实现如下所示:

// in tests/stack_overflow.rs

use lazy_static::lazy_static;
use x86_64::structures::idt::InterruptDescriptorTable;

lazy_static! {
    static ref TEST_IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        unsafe {
            idt.double_fault
                .set_handler_fn(test_double_fault_handler)
                .set_stack_index(blog_os::gdt::DOUBLE_FAULT_IST_INDEX);
        }

        idt
    };
}

pub fn init_test_idt() {
    TEST_IDT.load();
}

实现与 中正常的 IDT 非常相似。与普通 IDT 一样,我们为双故障处理程序在 IST 中设置了堆栈索引,以便切换到单独的堆栈。函数通过 方法在 CPU 上加载 IDT。
双故障处理程序
唯一缺少的一块是我们的双故障处理程序。如下所示:

// in tests/stack_overflow.rs

use blog_os::{exit_qemu, QemuExitCode, serial_println};
use x86_64::structures::idt::InterruptStackFrame;

extern "x86-interrupt" fn test_double_fault_handler(
    _stack_frame: &mut InterruptStackFrame,
    _error_code: u64,
) -> ! {
    serial_println!("[ok]");
    exit_qemu(QemuExitCode::Success);
    loop {}
}

当调用双故障处理程序时,我们使用一个成功的退出代码退出QEMU,该代码将测试标记为已通过。由于集成测试是完全独立的可执行文件,所以我们需要设置#![feature(abi_x86_interrupt)]属性再次出现在测试文件的顶部。

现在我们可以通过测试cargo xtest --test stack_overflow(或cargo xtest来运行所有测试)。如所料,我们看到stack_overflow… [ok]控制台中的输出。尝试注释掉set_stack_index线:它应该会导致测试失败。
在这里插入图片描述
操作系统概念1~5次实验:https://download.csdn.net/download/weixin_43979304/15321050?spm=1001.2014.3001.5503

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SIR怀特

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值