Skip to content

【Zig 日报】用 Zig 从零开始编写一个操作系统内核 #251

@jiacai2050

Description

@jiacai2050

原文:https://popovicu.com/posts/writing-an-operating-system-kernel-from-scratch/

我最近在 RISC-V 上实现了一个概念验证时间共享操作系统内核。在本文中,我将分享这个原型的工作原理细节。目标受众是指任何希望了解低级别系统软件、驱动程序、系统调用等的人,我希望这对系统软件和计算机架构的学生来说将特别有用。

这是我为本科操作系统完成的一门练习的回顾,从功能上讲,它应该类似于一个典型的操作系统项目。然而,这一实验侧重于现代工具以及RISC-V的现代架构。RISC-V 是一项出色的技术,比其他CPU架构更易于理解,同时仍然是许多新系统的热门选择,而不仅仅是一种教育架构。

最后,在这里做不同的事情时,我在Zig中实施了这个练习,而不是传统的C。除了进行一个有趣的实验外,我相信Zig还能让这个实验更容易在机器上重现,因为它非常容易设置,且无需安装(否则在与RISC-V交叉编译时可能会有些混乱)。

GitHub 仓库

本次实验的最终代码已在 popovicu/zig-time-sharing-kernel 开源上。 GitHub 应该是事实的源头,可能与下面的代码略有不一致。

推荐阅读

计算机工程的基本原理,特别是计算机架构,是假设。具体来说,掌握注册表、CPU如何处理内存以及中断都是必需的。

在深入探讨本实验之前,建议同时审阅以下背景文献:

  1. RISC-V 上的裸金属编程
  2. 使用SBI进行RISC-V启动
  3. 使用定时器实例进行RISC-V中断
  4. OptionalMaking a micro Linux distro可选——制作一个微型Linux发行版——主要用于内核/用户空间分割的简要理念

优衣架

unikernel我们将开发一种类型的单体。简单来说,这个设置直接将应用程序代码与它所依赖的OS内核关联起来。基本上,所有内容都捆绑在单个二进制可执行文件中,用户代码会与内核一起加载到内存中。

这绕过了在运行时单独加载用户代码的需要,而该代码本身就是一个复杂的字段(涉及链接器、加载器等)。

SBI 层

RISC-V 支持分层权限模型。系统可启动到完全裸机模式(M),然后支持其他几种不太显眼的模式。请查看背景文本以获取更多详细信息;以下是简要摘要:

  1. M模式几乎可以做任何事情,完全是裸露的。
  2. 中间是S模式,即主管,通常托管操作系统内核。
  3. 底部是U模式用户,其中应用代码正在运行。

较低的权限级别可以将请求发送到更高的权限级别。

我们假设我们软件栈底部有一个SBI层,特别是OpenSBI。this text请将本文用于必要背景,因为我们将使用SBI层来管理控制台打印并控制计时器硬件。虽然手动实现是可能的,但我想通过展示使用OpenSBI更便携的方法,为本文增加更多价值。

内核的目标

我们希望支持一些简单易用的关键功能:

  1. 在执行前静态定义线程;即不支持动态线程创建。此外,为了简单起见,线程被作为永无止境的函数来实现。
  2. 线程以用户模式运行,能够向以S模式运行的内核发送系统调用。
  3. 时间被分割并分配给不同的线程。系统定时器将设置为每帧几毫秒勾选一次,此时可能会将一条线程切换出来。

最后,开发目标是单核机器。

虚拟化以及究竟是什么

在实施线程之前,我们应该先确定它们究竟是什么。分时环境中的线程概念使得多个工作负载能够运行单个核心(如上所述,我们专注于单核设备),而每个线程的编程模型基本上与机器上唯一的软件相同。这是一个大致的定义,我们将加以改进。

要理解时间分配,让我们简要考虑一下它的对比:合作调度/线程。在协作排程/线程中,线程会自愿为另一个工作负载提供CPU时间。最终,人们期望另一个线程能将控制权重新回到第一个。

function thread():
  operation_1();
  operation_2();
  YIELD();
  operation_3();
  YIELD();
  ...

需要明确的是,尽管年龄较大,但这并不是一种“过时”的技巧。事实上,它在许多现代编程语言及其运行时(通常从程序员那里抽象出来)中依然充满活力且运行良好。一个很好的例子是Go,它使用Goroutines在一个操作系统线程的顶部运行多个工作负载。尽管程序员不一定添加显式的收益操作,但编译器和运行时可以将它们注入到工作负载中。

现在,编程模型在时间共享环境中基本保持不变的含义应该更清楚。这条线自然会呈现如下:

function thread():
  operation_1();
  operation_2();
  operation_3();
  ...

根本没有明确的收益操作;相反,内核利用定时器和中断来无缝切换同一核心的线程。这正是我们在这个实验中将要实现的。

当多个工作负载运行在同一资源上,且每个工作负载都保留与唯一工作负载相同的编程模型时,我们可以说该资源已实现虚拟化。换句话说,如果我们在同一核心上运行5个线程,每个线程都“感觉”它有其核心,实际上运行在5个小核心上,而不是1个大核心。更正式地,每个线程都保留了对核心架构寄存器的自有视图(在RISC-V中)x0-x31以及一些企业社会责任,更多内容见下文)以及......一些内存!让我们更深入地探讨一下。

堆栈与内存虚拟化

首先,一个线程有其自身的堆栈,原因我们很快就会进行分析。其余内存与其他线程“共享”,但这需要进一步调查。

需要认识到,硬件虚拟化存在于一个光谱范围内,而非少数一些僵硬的选择。以下是一些虚拟化选项:

  1. 线程:虚拟化架构寄存器和堆栈,但其他内容不多;例如,不同的线程可以在内存中将数据共享到其他地方。
  2. 过程:比线程更重,内存被虚拟化,使得每个进程都“感觉”其具有专用的CPU核心,且自身内存无法被其他进程所触碰;此外,一个进程包含多个线程。
  3. 容器:虚拟化功能更高——每个容器都有其独立的文件系统,可能还有自己的网络接口;容器共享相同的内核和底层硬件。
  4. 虚拟机:将所有内容虚拟化。

两者之间还有更多色调,且每种选项可能具有不同的亚型。关键是,所有这些方法都能以不同的隔离方式运行不同的工作负载,_views_或者更直观地实现对机器及其环境的不同视图。

有趣的是,如果你仔细检查Linux内核源代码,_container_就找不到一个明确称为容器的构造。我们通常所说的容器并不是一种融入内核的机制,而是一组核心机制,它们共同用于形成针对工作负载环境的特定视图。例如chroot机制限制文件系统的可见性,而cgroups对工作负载施加限制;这些共同构成我们称之为容器的内容。

此外,我认为(尽管不要引用我的话),Linux中线程与进程之间的界限存在一些模糊之处。据我所知,_tasks_两者都是在内核任务基础上实现的,但在创建任务时,API 允许指定不同的限制。

归根结底,这完全意味着我们始终在定义一个工作负载,而对它所能看到和访问的内容却有不同的限制。何时以及为何实施不同的限制是另一天的话题。编写应用程序时会出现许多问题,其难度包括安全性的复杂性。

虚拟化一个线程

在本次实验中,我们将采用非常基础的、耗时的线程来实现最低限度的虚拟化。因此,目标如下:

  1. 线程的编程模型应基本保持不变。只要线程不与其他线程使用的内存内容交互,其编程模型就应保持一致,并辅以分时。
  2. 一个线程应具有其自身的架构寄存器保护视图,包括一些RISC-V CSR。
  3. 应指定一个线程属于其自身的堆栈。

应该显而易见,为什么一个线程需要自己查看寄存器。如果其他线程能够自由触摸线程的寄存器,该线程将无法完成任何有意义的工作。所有(我认为)RISC-V 指令至少适用于一个寄存器,因此保护线程的注册表视图至关重要。

此外,需要为线程分配一个私有堆栈,但稍显不太明显。答案是需要不同的堆栈来管理不同的执行上下文。即为调用函数,按约定,该栈用于分配函数-私有变量。此外,注册内容如下ra可以推到堆栈以保留函数中正确的返回地址(如果在其中调用另一个函数)。简而言之,根据RISC-V惯例,需要该堆栈来维持执行上下文的原因有多种。RISC-V 电话会议的详细信息将不在此描述。

中断上下文

了解中断代码如何运行以及应包含哪些内容至关重要,因为该机制将被大量利用,以实现线程之间的无缝时间共享。如需详细实时示例,this past text请查看此前的文字。

我将简要包含该文本中计时器中断程序的汇编:

s_mode_interrupt_handler:
        addi    sp,sp,-144
        sd      ra,136(sp)
        sd      t0,128(sp)
        sd      t1,120(sp)
        sd      t2,112(sp)
        sd      s0,104(sp)
        sd      a0,96(sp)
        sd      a1,88(sp)
        sd      a2,80(sp)
        sd      a3,72(sp)
        sd      a4,64(sp)
        sd      a5,56(sp)
        sd      a6,48(sp)
        sd      a7,40(sp)
        sd      t3,32(sp)
        sd      t4,24(sp)
        sd      t5,16(sp)
        sd      t6,8(sp)
        addi    s0,sp,144
        call    clear_timer_pending_bit
        call    set_timer_in_near_future
        li      a1,33
        lla     a0,.LC0
        call    debug_print
        nop
        ld      ra,136(sp)
        ld      t0,128(sp)
        ld      t1,120(sp)
        ld      t2,112(sp)
        ld      s0,104(sp)
        ld      a0,96(sp)
        ld      a1,88(sp)
        ld      a2,80(sp)
        ld      a3,72(sp)
        ld      a4,64(sp)
        ld      a5,56(sp)
        ld      a6,48(sp)
        ld      a7,40(sp)
        ld      t3,32(sp)
        ld      t4,24(sp)
        ld      t5,16(sp)
        ld      t6,8(sp)
        addi    sp,sp,144
        sret

此组件通过在 RISC-V 中编写标记为 S 级中断的 C 函数获得。使用此标签时,GCC _prologue__epilogue_编译器知道如何生成中断程序的序幕和尾声。序幕保存了堆叠上的建筑寄存器,后记还能恢复它们(此外,还会从S模式中具体返回)。所有这些都是通过正确标记C函数的调用约定而生成的。

这有点像函数调用,而实际上就是它的本质。中断可以(从某种非常简化的意义上)视为某种系统效应所调用的函数。因此,使用过的寄存器必须小心地保存在栈上,然后在程序退出时恢复;否则,像定时器中断这样的异步中断会随机损坏架构寄存器值,完全阻止任何实用软件运行!

实施(高级别)

我们将先通过描述高级想法,然后深入解析代码来探索实现。

利用中断堆栈约定

添加中断在某种程度上已经为您的应用代码引入了一种线程形式。在带有定时器中断的系统中,主应用程序代码会运行,有时会与定时器中断调用实例发生交错。当定时器发出信号时,核心会跳转到此中断程序,并在控制流返回“主线程”之前小心地恢复其架构状态。这里有两个同时运行的控制流:

  1. 主应用代码。
  2. 重复中断程序。

可以利用这种对时器中断的互接来实现额外的控制流,主要思路如下所述。

中断程序的核心夹在序幕和尾声之间。在通过从堆栈中恢复寄存器,在控制返回主应用线程之前,会先处理中断。

然而,why must we restore the registers from the same stack location我们为什么必须从同一个堆栈位置恢复寄存器?如果我们的中断逻辑将堆栈指针替换为其他部分内存,最终会找到一组不同的架构寄存值,从而进入一个完全不同的流程。换句话说,我们实现了语境转换,而这正是它在本实验中实现的方式。我们很快就会看到它的代码。

内核/用户空间分离

我们现在可以划定内核空间和用户空间。使用RISC-V,这自然意味着内核代码以主管(S)模式运行,用户空间代码以U模式运行。

机器将启动到机器(M)模式,并且由于我们希望利用SBI层,因此允许OpenSBI在此运行。然后,内核将在S模式中执行一些初始设置,然后启动用户空间线程的U模式执行。定期定时器中断将启用上下文切换,中断代码将在S模式中执行。最后,用户线程将能够对内核进行系统调用。

实现(代码)

请参考 GitHub 仓库以获取完整代码;我们仅会涵盖以下核心摘录。

组装启动

与往常一样,启动我们的S模式代码并在Zig中输入“主程序”需要一个简短的汇编片段。这是在startup.S

...
done_bss:

    # Jump to Zig main
    call main
...

其余的汇编启动主要包括清理BSS部分并设置初始内核代码的堆栈指针。

主内核文件和输入/输出驱动程序

我们现在来检查一下kernel.zig包含main函数。

首先,我们探索 OpenSBI 层以获取控制台功能。我们只会考虑使用近年来包含控制台功能的OpenSBI版本。否则,内核将停止并报告错误。

export fn main() void {
    const initial_print_status = sbi.debug_print(BOOT_MSG);

    if (initial_print_status.sbi_error != 0) {
        // SBI debug console not available, fall back to direct UART
        const error_msg = "ERROR: OpenSBI debug console not available! You need the latest OpenSBI.\n";
        const fallback_msg = "Falling back to direct UART at 0x10000000...\n";

        uart.uart_write_string(error_msg);
        uart.uart_write_string(fallback_msg);
        uart.uart_write_string("Stopping... We rely on OpenSBI, cannot continue.\n");

        while (true) {
            asm volatile ("wfi");
        }

        unreachable;
    }

main标记为export符合C ABI标准。

在这里,我们对几个I/O驱动程序进行了轻量级实现。如你所见,写作可以通过两种方式之一:要么我们通过SBI层进行。sbi.zig或者,如果失败,我们使用直接的MMIO(uart_mmio.zig)SBI 方法理论上应更具可移植性,因为它将输出管理细节与 M 级层(本质上取决于我们使用 MMIO 的操作),从而摆脱对精确内存空间地址的担忧。

让我们快速看看sbi.zig:

// Struct containing the return status of OpenSBI
pub const SbiRet = struct {
    sbi_error: isize,
    value: isize,
};

pub fn debug_print(message: []const u8) SbiRet {
    var err: isize = undefined;
    var val: isize = undefined;

    const msg_ptr = @intFromPtr(message.ptr);
    const msg_len = message.len;

    asm volatile (
        \\mv a0, %[len]
        \\mv a1, %[msg]
        \\li a2, 0
        \\li a6, 0x00
        \\li a7, 0x4442434E
        \\ecall
        \\mv %[err], a0
        \\mv %[val], a1
        : [err] "=r" (err),
          [val] "=r" (val),
        : [msg] "r" (msg_ptr),
          [len] "r" (msg_len),
        : .{ .x10 = true, .x11 = true, .x12 = true, .x16 = true, .x17 = true, .memory = true });

    return SbiRet{
        .sbi_error = err,
        .value = val,
    };
}

这非常简单;我们只是按照OpenSBI文档中所述的执行系统调用。请注意,当我首次编写此代码时,我并不完全熟悉Zig的错误处理能力,因此具有某种非惯性的错误处理能力。

然而,这可以被视为该内核中的第一个驱动程序,因为它直接管理向设备输出。

下一个是uart_mmio.zig:

// UART MMIO address (standard for QEMU virt machine)
pub const UART_BASE: usize = 0x10000000;
pub const UART_TX: *volatile u8 = @ptrFromInt(UART_BASE);

// Direct UART write function (fallback when SBI is not available)
pub fn uart_write_string(message: []const u8) void {
    for (message) |byte| {
        UART_TX.* = byte;
    }
}

这是简单且不言自明的。

返回kernel.zig以及main函数,我们创建3个用户线程,每个线程打印一条略有不同的消息(线程ID是不同的位)。此时,内核的设置已接近完成。

最后步骤包括设置和运行计时器中断。完成后,内核代码只有在计时器中断系统或用户空间代码请求系统调用时才会运行。

interrupts.setup_s_mode_interrupt(&s_mode_interrupt_handler);
_ = timer.set_timer_in_near_future();
timer.enable_s_mode_timer_interrupt();

我们可以立即请求上下文切换,但为了简单起见,我们会等到计时器启动并开始系统中的实际工作。

S模式处理器和上下文切换

虽然Zig编译器可以为我们的S模式处理程序生成足够的序幕和尾声,但我们将手动完成。原因是,我们还希望在生成的常规中无法捕捉到某些CSR。

这就是我们使用它的原因naked在Zig中调用约定。这迫使我们将整个函数写入组件,但一个快速逃逸到这一限制的阶段就是在需要Zig逻辑时调用Zig函数。

我不会在这里复制整个序幕和尾声,因为它们与之前使用RISC-V中断的C实验非常相似。相反,我会只关注不同的部分:

...
        // Save S-level CSRs (using x5 as a temporary register)
        \\csrr x5, sstatus
        \\sd x5, 240(sp)
        \\csrr x5, sepc
        \\sd x5, 248(sp)
        \\csrr x5, scause
        \\sd x5, 256(sp)
        \\csrr x5, stval
        \\sd x5, 264(sp)

        // Call handle_kernel
        \\mv a0, sp
        \\call handle_kernel
        \\mv sp, a0

        // Epilogue: Restore context
        // Restore S-level CSRs (using x5 as a temporary register)
        \\ld x5, 264(sp)
        \\csrw stval, x5
        \\ld x5, 256(sp)
        \\csrw scause, x5
        \\ld x5, 248(sp)
        \\csrw sepc, x5
        \\ld x5, 240(sp)
        \\csrw sstatus, x5
...

如你所见,除了核心建筑寄存器外,还在序幕和尾声中增加了几个寄存器。

接下来,在这个序幕/伴奏三明治中,我们援引handle_kernelZig 函数。此路线将传输到基于中断源是否为来自用户空间的同步系统调用或异步定时器中断的正确逻辑。原因是我们处于相同的S级中断程序,而不受中断源的影响,然后检查scause详情请见企业社会责任。

成功与该团队合作handle_kernel功能,我们需要注意装配级调用约定。该函数取一个整数参数,并返回一个整数参数。由于函数签名很小,其工作原理如下:

  1. 唯一的函数参数通过a0建筑寄存器。
  2. 同一注册机构在返回时还保存了该函数的结果。

这相当容易。让我们快速查看此函数的签名:

export fn handle_kernel(current_stack: usize) usize {
...

有点尴尬,但能完成任务。对此Zig逻辑的输入是调用Zig逻辑之前的堆栈顶部(这不可避免地会导致一些数据被添加到堆栈中)。_after_函数的输出位于完成 Zig 逻辑后堆栈顶部的位置。如果它与输入不同,那么我们正在执行上下文切换。如果情况相同,相同的工作负载线程在中断后将继续运行。

其余的逻辑非常简单。它会检查中断源(来自用户空间或定时器的系统调用),并相应地执行。

在计时器中断的情况下,会执行上下文切换。schedule函数scheduling.zig被调用,它可能会返回我们应该切换到的另一个堆栈:

const build_options = @import("build_options");
const sbi = @import("sbi");
const std = @import("std");
const thread = @import("thread");

pub fn schedule(current_stack: usize) usize {
    const maybe_current_thread = thread.getCurrentThread();

    if (maybe_current_thread) |current_thread| {
        current_thread.sp_save = current_stack;

        if (comptime build_options.enable_debug_logs) {
            _ = sbi.debug_print("[I] Enqueueing the current thread\n");
        }
        thread.enqueueReady(current_thread);
    } else {
        if (comptime build_options.enable_debug_logs) {
            _ = sbi.debug_print("[W] NO CURRENT THREAD AVAILABLE!\n");
        }
    }

    const maybe_new_thread = thread.dequeueReady();

    if (maybe_new_thread) |new_thread| {
        // TODO: software interrupt to yield to the user thread

        if (comptime build_options.enable_debug_logs) {
            _ = sbi.debug_print("Yielding to the new thread\n");
        }

        thread.setCurrentThread(new_thread);

        if (comptime build_options.enable_debug_logs) {
            var buffer: [256]u8 = undefined;
            const content = std.fmt.bufPrint(&buffer, "New thread ID: {d}, stack top: {x}\n", .{ new_thread.id, new_thread.sp_save }) catch {
                return 0; // Return bogus stack, should be more robust in reality
            };
            _ = sbi.debug_print(content);
        }

        return new_thread.sp_save;
    }

    _ = sbi.debug_print("NO NEW THREAD AVAILABLE!\n");

    while (true) {
        asm volatile ("wfi");
    }
    unreachable;
}

代码thread模块非常简单,用作基本队列的样板,用于管理代表线程的结构结构。我不会在这里复制它,因为它主要是人工智能生成的。但需要注意的是,这些堆栈在内存中是静态分配的,并且运行的线程的最大数量是硬编码的。

thread模块还包含设置新线程的逻辑。此时数据在线程运行之前就被推送到堆栈上。如果你想知道原因,那是因为从S级陷阱处理程序返回时,_something_我们需要在堆栈上安装一些东西来指示该去哪里。初始数据正是这样做的。我们可以根据需要在这里播种初始寄存值。事实上,在这个实验中,我们通过播种来演示将单个整数参数传递给线程函数a0在堆栈上注册值(按调用约定),线程函数可立即使用。

用户空间线程

如引言中所述,我们将将用户空间和内核空间代码捆绑成一个二进制信号,以避免动态加载、链接及其他复杂问题。因此,我们的用户空间代码包含常规功能:

/// Example: Create a simple idle thread
pub fn createPrintingThread(thread_number: usize) !*Thread {
    const thread = allocThread() orelse return error.NoFreeThreads;

    // Idle thread just spins
    const print_fn = struct {
        fn print(thread_arg: usize) noreturn {
            while (true) {
                var buffer: [256]u8 = undefined;
                const content = std.fmt.bufPrint(&buffer, "Printing from thread ID: {d}\n", .{thread_arg}) catch {
                    continue;
                };

                syscall.debug_print(content);

                // Simulate a delay
                var i: u32 = 0;
                while (i < 300000000) : (i += 1) {
                    asm volatile ("" ::: .{ .memory = true }); // Memory barrier to prevent optimization
                }
            }
            unreachable;
        }
    }.print;

    initThread(thread, @intFromPtr(&print_fn), thread_number);
    return thread;
}

Additionally, as mentioned above, we pre-seeded the stack such that when a0 is recovered from the stack upon the first interrupt return for a given thread, the function argument will be picked up. That’s how the print function accesses the thread_arg value and uses it in its logic.

To demonstrate the user/kernel boundary, we have syscall.debug_print(content);. This conceptually behaves more or less as printf from stdio.h in C. It performs prepares the arguments to the kernel and runs a system call with these arguments which should lead to some content getting printed on the output device. Here’s what the printing library looks like (from syscall.zig):

// User-level debug_print function
pub fn debug_print(message: []const u8) void {
    const msg_ptr = @intFromPtr(message.ptr);
    const msg_len = message.len;

    // Let's say syscall number 64
    // a7 = syscall number
    // a0 = message pointer
    // a1 = message length
    asm volatile (
        \\mv a0, %[msg]
        \\mv a1, %[len]
        \\li a7, 64
        \\ecall
        :
        : [msg] "r" (msg_ptr),
          [len] "r" (msg_len),
        : .{ .x10 = true, .x11 = true, .x17 = true, .memory = true });

    // Ignore return value for simplicity
}

System call 64 is served from the S-mode handler in kernel.zig. This is self-explanatory, and we won’t go into further details here.

Running the kernel

We will deploy the kernel on bare-metal, specifically on a virtual machine. In theory, this should also work on a real machine, provided an SBI layer is present when the kernel starts, and the linker script, I/O “drivers,” and other machine-specific constants are adapted.

To build, we simply run

zig build

To now run the kernel, we run:

qemu-system-riscv64 -machine virt -nographic -bios /tmp/opensbi/build/platform/generic/firmware/fw_dynamic.bin -kernel zig-out/bin/kernel

有关 OpenSBI 建设的详细信息,请参阅 OpenSBI 上的先前文本。强烈建议使用新构建的OpenSBI,因为如果没有,QEMU可能会使用过时的版本-bios旗帜被通过。

输出应首先包含大量的 OpenSBI 数据以及一些 OpenSBI 数据:

OpenSBI v1.7
   ____                    _____ ____ _____
  / __ \                  / ____|  _ \_   _|
 | |  | |_ __   ___ _ __ | (___ | |_) || |
 | |  | | '_ \ / _ \ '_ \ \___ \|  _ < | |
 | |__| | |_) |  __/ | | |____) | |_) || |_
  \____/| .__/ \___|_| |_|_____/|____/_____|
        | |
        |_|

Platform Name               : riscv-virtio,qemu
Platform Features           : medeleg
Platform HART Count         : 1
Platform IPI Device         : aclint-mswi
Platform Timer Device       : aclint-mtimer @ 10000000Hz
Platform Console Device     : uart8250
Platform HSM Device         : ---
Platform PMU Device         : ---
Platform Reboot Device      : syscon-reboot
Platform Shutdown Device    : syscon-poweroff
Platform Suspend Device     : ---
Platform CPPC Device        : ---
Firmware Base               : 0x80000000
Firmware Size               : 317 KB
Firmware RW Offset          : 0x40000
Firmware RW Size            : 61 KB
Firmware Heap Offset        : 0x46000
Firmware Heap Size          : 37 KB (total), 2 KB (reserved), 11 KB (used), 23 KB (free)
Firmware Scratch Size       : 4096 B (total), 400 B (used), 3696 B (free)
Runtime SBI Version         : 3.0
Standard SBI Extensions     : time,rfnc,ipi,base,hsm,srst,pmu,dbcn,fwft,legacy,dbtr,sse
Experimental SBI Extensions : none

Domain0 Name                : root
....

在 OpenSBI 发布之后,我们将看到内核输出:

Booting the kernel...
Printing from thread ID: 0
Printing from thread ID: 0
Printing from thread ID: 0
Printing from thread ID: 1
Printing from thread ID: 1
Printing from thread ID: 1
Printing from thread ID: 2
Printing from thread ID: 2
Printing from thread ID: 2
Printing from thread ID: 0
Printing from thread ID: 0
Printing from thread ID: 1
Printing from thread ID: 1
Printing from thread ID: 2
Printing from thread ID: 2
Printing from thread ID: 0
Printing from thread ID: 0
Printing from thread ID: 0
Printing from thread ID: 1
Printing from thread ID: 1
Printing from thread ID: 1
Printing from thread ID: 2
Printing from thread ID: 2
Printing from thread ID: 2

打印件将持续运行,直到QEMU终止。

如果要在极其冗长的模式下构建内核,请使用以下命令:

zig build -Ddebug-logs=true

使用相同的QEMU命令运行内核后,输出将显示如下:

Booting the kernel...
DEBUG mode on
Interrupt source: Timer, Current stack: 87cffe70
[W] NO CURRENT THREAD AVAILABLE!
Yielding to the new thread
New thread ID: 0, stack top: 80203030
Interrupt source: Ecall from User mode, Current stack: 80202ec0
Printing from thread ID: 0
Interrupt source: Ecall from User mode, Current stack: 80202ec0
Printing from thread ID: 0
Interrupt source: Ecall from User mode, Current stack: 80202ec0
Printing from thread ID: 0
Interrupt source: Timer, Current stack: 80202ec0
[I] Enqueueing the current thread
Yielding to the new thread
New thread ID: 1, stack top: 80205030
Interrupt source: Ecall from User mode, Current stack: 80204ec0
Printing from thread ID: 1
Interrupt source: Ecall from User mode, Current stack: 80204ec0
Printing from thread ID: 1
Interrupt source: Ecall from User mode, Current stack: 80204ec0
Printing from thread ID: 1
Interrupt source: Timer, Current stack: 80204ec0
[I] Enqueueing the current thread
Yielding to the new thread
New thread ID: 2, stack top: 80207030
Interrupt source: Ecall from User mode, Current stack: 80206ec0
Printing from thread ID: 2
Interrupt source: Ecall from User mode, Current stack: 80206ec0
Printing from thread ID: 2
Interrupt source: Ecall from User mode, Current stack: 80206ec0
Printing from thread ID: 2
Interrupt source: Timer, Current stack: 80206ec0
...

结论

存在许多具有教育意义的操作系统内核,但该实验结合了RISC-V、OpenSBI和Zig,与传统的C实现相比,提供了全新的视角。

生成的代码运行在QEMU虚拟机上,即使从源代码构建QEMU也能轻松设置。

为保持解释的简洁性,错误报告被保留得最少。如果修改代码并需要调试,尽管会提供足够的线索,尽管有些领域代码会简化(例如,SBI打印调用后会实现匿名结果)_ = ...)本示例中的大部分代码由克劳德生成的人工智能,以节省时间,并且应按预期运行。尽管代码的某些部分被简化,例如堆叠空间的过度分配,但这些部分并不会削弱实验的教育价值。

总体而言,这项实验是研究操作系统的起点,具备对计算机工程和计算机架构的基础性理解。它在实际应用中可能存在很多缺陷,但目前我们只是在进行黑客攻击!

我希望这是一次有用的探索。

请考虑关注 Twitter/XLinkedIn 以保持最新状态。

Metadata

Metadata

Assignees

No one assigned

    Labels

    日报daily report

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions