
一直想找一个小而完整的操作系统来理解计算机系统如何从开机到运行应用但又不想一上来就啃 Linux。后来接触到 Project Oberon发现它比预想的更适合学习系统本身用 Oberon 语言写成整个系统只有一万多行代码干净、清晰几乎没有历史包袱。Project Oberon 原本运行在 Wirth 设计的 RISC-5 处理器上而 RISC-5 更像是一个教学和论文产物在通用开发环境里接触不多。随着 RISC-V 生态越来越成熟很多开发者都在做同一件事把 Project Oberon System 从 RISC-5 移植到 RISC-V 上运行。本文不会只给一个“能跑”的结果而是拆解移植工程背后的核心思路包括 RISC-5 与 RISC-V 的差异、启动代码、异常处理、设备驱动、编译器后端、模拟运行与排查方法。适合正在做 RISC-V 课程设计、对操作系统实现感兴趣、或想在真实指令集上跑完整系统的读者。1. 背景与核心概念Project Oberon、RISC-5 与 RISC-V1.1 Project Oberon 到底是什么Project Oberon 是由 Niklaus Wirth 和 Jürg Gutknecht 设计的操作系统诞生于上世纪 80 年代但它的源码至今仍被很多人拿来当作教学样例。整个系统由三部分组成Oberon 编程语言及其编译器。Oberon 操作系统内核包含任务调度、内存管理、文件系统、设备驱动。基于命令的文本用户界面。这套系统的特点是“你可以在一周内通读全部代码”。它不是简化版的玩具内核而是一个能自己编译自己、能跑图形界面、能读写磁盘、能联网的完整系统。这也是为什么很多操作系统课程和编译器课程都拿它举例。Project Oberon 最初的目标硬件平台是由 Peter Eberle 设计的 RISC 处理器也就是标题中的 RISC-5。系统、编译器、处理器三者是一套相对封闭的体系互相依赖度很高。这就带来一个很现实的问题如果想在更通用的硬件或模拟器上运行 Oberon必须把 RISC-5 相关的部分全部替换掉。1.2 RISC-5 与 RISC-V 的关系RISC-5 是 Wirth 等人为 Oberon 系统设计的一颗 32 位 RISC 处理器特点是指令长度固定为 32 位采用 load/store 架构寄存器数量为 32 个整体设计非常精简。它最常出现的场景是 FPGA 开发板或定制模拟器。由于 RISC-5 并非通用标准它的指令编码、异常机制、时序和地址映射都只服务 Oberon 系统本身。RISC-V 则是由 UC Berkeley 发起的开放指令集架构它不是一颗具体处理器而是一套规范。只要遵循规范任何人都可以设计自己的 RISC-V 处理器。RISC-V 的指令集以模块化方式组织基础指令集 RV32I 已经足够运行一个简单的操作系统。两者都属于精简指令集思想下的产物因此在设计哲学上有很多相似之处。但具体到指令编码、寄存器约定、CSR 控制寄存器、异常向量等细节RISC-5 和 RISC-V 差异非常大。从 RISC-5 移植到 RISC-V不是改几条汇编指令那么简单而是要把整个处理器相关的底层代码重写一遍。1.3 为什么这个移植工程值得关注RISC-V 现在是最受关注的指令集之一。很多高校的计算机组成原理课程、CPU 设计实验、嵌入式系统教材都在陆续从 MIPS 或 ARM 转向 RISC-V。但大部分学习者在 RISC-V 上只做过单周期 CPU 实验或者只写过简单汇编程序很少有机会在 RISC-V 上跑一个真正完整的操作系统。Project Oberon 在 RISC-V 上的移植正好填补这个空白。它足够小适合剖析又足够完整能让你看到操作系统的启动、中断、设备抽象、模块加载和编译器等关键环节如何在真实架构上落地。也正因为如此围绕“risc-v 指令集”“risc-v 指令架构寄存器”“risc-v 单周期 cpu 实验”这些关键词展开学习的人往往最终都会走到这类小型系统移植项目中。做单周期 CPU 实验时你可能只关心一条指令如何译码、如何执行做操作系统移植时你会不由自主地补上这些知识通用寄存器中哪些是调用者保存哪些是被调用者保存。中断入口如何保存上下文。异常返回地址从哪里取。设备寄存器映射到哪个地址空间。内存管理、特权级、中断开关如何配合。这类知识的珍贵之处在于它们不是在教程里读出来的而是在一次次启动失败、黑屏、异常死循环中真正理解和记住的。2. 移植前的准备工作2.1 确定目标运行环境移植工程的第一步是决定把 Oberon 系统跑在哪里。常见选择有三类QEMU 模拟器最方便不需要硬件适合调试内核早期启动流程。QEMU 的 riscv32 virt 机器提供了 UART、CLINT、PLIC可以满足基础设备验证。FPGA 开发板接近真实硬件适合进一步验证时序和外设行为但调试门槛更高一次编译下载周期较长。自研 RISC-V 模拟器适合学习用途如果你正在做 RISC-V 单周期 CPU 实验也可以把 Oberon 当作目标负载来验证自己的处理器。对于刚开始接触移植的开发者优先推荐 QEMU。它启动快日志可观测能更快暴露问题。2.2 获取源码与工具链Project Oberon 源码在 GitHub 上有官方仓库你可以通过以下命令获取git clone https://github.com/ProjectOberon/ProjectOberon.git cd ProjectOberon工具链方面需要准备 RISC-V 交叉编译工具链和 QEMU。以 Ubuntu 为例安装命令如下sudo apt update sudo apt install gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf qemu-system-misc这里使用的是 riscv64 交叉工具链但编译目标可以指定为 rv32imac后续需要根据实际选择调整。如果你在 macOS 或 Windows 上开发可以使用 Homebrew 或 WSL 环境命令略有差异。版本说明RISC-V 工具链和 QEMU 更新速度较快不同版本对 -M virt 机器的设备支持可能不同本文示例使用当前主流 QEMU 版本演示重点展示移植思路具体参数以你的环境为准。2.3 拆解 Oberon 系统的移植边界拿到源码后需要先缕清系统的模块关系。Project Oberon 大致可以划分为内核层处理系统启动、内存管理、任务调度、定时器、中断异常入口。设备层显示、键盘、鼠标、串口、磁盘、网络等设备的驱动程序。编译器层Oberon 语言编译器负责把系统源码编译成目标机器码。应用层编辑器、命令处理工具、文件浏览器等。移植到 RISC-V 时重点是处理内核层和设备层因为这两部分直接依赖 RISC-5 的寄存器、中断格式和内存地址映射。编译器层需要重写代码生成后端但不需要改动前端的语法分析、语义分析和中间表示。理解了边界接下来的工作就能沿着“启动代码 → 中断异常 → 设备驱动 → 编译器后端”这条主线推进。3. RISC-5 与 RISC-V 的关键差异3.1 运行模型与寄存器RISC-5 和 RISC-V 都是 32 个通用寄存器都采用 load/store 架构这是它们的相似之处。但 RISC-V 的寄存器有明确的 ABI 约定和功能分工例如x0 恒为零硬件强制写入丢弃。x1 是返回地址寄存器过程调用时使用 jal 写入。x2 是栈指针寄存器软件规范中约定。x10 到 x17 是参数寄存器同时也用于返回值。RISC-V 中常用的 RISC-V 指令架构寄存器别名如下寄存器ABI 名称用途x0zero恒零寄存器x1ra返回地址x2sp栈指针x3gp全局指针x8s0/fp栈帧指针x10~x17a0~a7函数参数x5~x7, x28~x31t0~t6临时寄存器x8~x9, x18~x27s0~s11保存寄存器RISC-5 虽然也有 32 个寄存器但没有这些严格的调用约定。Oberon 编译器在生成 RISC-5 汇编时往往按照 RISC-5 自身设计来分配寄存器。因此在移植编译器后端时不仅要修改指令编码还要重新做寄存器分配和过程调用约定。3.2 异常与中断机制这是移植中最容易出问题的部分。RISC-5 的异常处理相对简单处理器遇到异常时会跳转到固定地址系统软件从那里开始处理。每个中断源往往有独立的处理入口便于快速区分事件类型。RISC-V 则把中断和异常统一交给控制状态寄存器处理核心是这几个 CSRmtvec异常/中断向量基地址低两位表示向量模式。mepc异常发生时保存返回地址。mcause异常原因编号最高位区分中断与异常。mstatus全局中断使能位MPP 等特权级信息。mie中断使能掩码控制机器级定时器、外部中断等。典型的 RISC-V 异常入口配置如下la t0, trap_vector csrw mtvec, t0 # 清空 mstatus 中的 MIE 位 csrr t1, mstatus li t2, ~0x8 and t1, t1, t2 csrw mstatus, t1 # 使能机器级外部中断 li t3, 0x800 csrs mie, t3注意mstatus 的 MIE 位是第 3 位对应掩码 0x8。上面的代码先把全局中断关掉再使能外部中断源最终由 mstatus 的全局开关决定是否真正响应中断。Oberon 系统原生的中断处理逻辑是基于 RISC-5 的中断向量设计的因此移植时要把这一段底层代码全部替换为基于 mtvec/mepc/mcause 的版本同时保留 Oberon 上层的任务切换接口。3.3 启动流程与内存映射RISC-5 的启动流程通常是处理器复位后从固定地址取指令Oberon 系统通过一段启动代码完成内存检测、BSS 段清零、栈初始化然后进入主流程。RISC-V 的启动流程取决于具体实现。QEMU 的 riscv32 virt 机器通常将内存起始地址放在 0x80000000复位后从该地址执行。如果使用 -kernel 方式加载 ELF 文件QEMU 会解析 ELF 头并把代码段加载到对应地址。一个通用的 RISC-V 启动汇编模板如下.section .text.start .globl _start _start: # 设置栈指针 la sp, _stack_top # 清零 BSS 段 la t0, _bss_start la t1, _bss_end 1: bge t0, t1, 2f sw zero, 0(t0) addi t0, t0, 4 j 1b 2: # 跳转到 C/模块入口 la ra, boot_main j boot_main .section .bss .align 4 _bss_start: .space 4 _bss_end:这段代码完成三件事设置栈指针、清零 BSS、跳转到真正的启动函数。Oberon 的启动流程结构与之类似只是原来的 RISC-5 启动代码被替换为新的 RISC-V 版本。内存映射方面Oberon 原生版本需要显示设备、键盘控制器、磁盘控制器的地址都与 RISC-5 的硬件设计绑定。在 QEMU riscv32 virt 机器上常用设备地址与 RISC-5 完全不同因此需要重新配置抽象层。QEMU riscv32 virt 的常见地址设备起始地址UART00x10000000CLINT0x02000000PLIC0x0C000000内存0x80000000Oberon 系统驱动程序中原本写死的寄存器地址在移植时要抽成配置宏或外设抽象接口。不要试图让 Oberon 直接操作 RISC-5 地址而是让底层驱动适配 RISC-V 的 MMIO 空间。4. 移植工作的完整流程4.1 先搭建最小启动框架不要一开始就尝试把整个 Oberon 搬过来。最稳妥的做法是先建立一个最小的 RISC-V 启动框架能输出一个字符再逐步增加功能。创建最小启动工程的目录结构oberon-riscv/ ├── boot/ │ ├── start.S │ ├── link.ld │ └── boot.c ├── kernel/ │ ├── traps.c │ ├── traps.h │ └── ... └── Makefilelink.ld示例OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { RAM (rwx) : ORIGIN 0x80000000, LENGTH 64M } SECTIONS { . 0x80000000; .text : { *(.text.start) *(.text*) } RAM .data : { *(.data*) } RAM .bss : { __bss_start .; *(.bss*) __bss_end .; } RAM . ALIGN(16); __stack_top . 0x8000; }这个链接脚本把入口固定到 0x80000000并在 BSS 段之后预留栈空间。在boot.c中写一个最简单的串口输出函数#include stdint.h #define UART0_BASE 0x10000000 #define UART_THR (*(volatile uint32_t *)(UART0_BASE 0x00)) #define UART_LSR (*(volatile uint32_t *)(UART0_BASE 0x05)) static void uart_putc(char c) { // 等待发送 FIFO 为空 while ((UART_LSR 0x20) 0); UART_THR c; } void boot_main(void) { const char *msg Oberon RISC-V boot\r\n; while (*msg) { uart_putc(*msg); } while (1); }这一步的目标只有一个确认工具链、链接脚本、QEMU 加载方式都是对的。只有这一步跑通后续移植才有可靠的地基。4.2 适配 Oberon 内核启动逻辑Oberon 原系统的启动入口通常被称为Start或Kernel.Start它会完成内存布局检测与初始化。模块加载器的建立。设备驱动的初始化。任务调度器启动。在 RISC-V 移植版本中这部分逻辑可以用boot_main调用。关键是把原来的 RISC-5 汇编实现替换为 C 语言实现或者直接使用新的 RISC-V 启动汇编仅保留上层系统逻辑。启动顺序建议设置栈指针并清零 BSS。初始化 UART方便打印调试日志。初始化中断异常向量。初始化内存分配器。初始化模块加载器。加载核心模块并执行。每完成一步都要能通过日志或串口输出确认。不要等整个系统写完再统一调试。4.3 异常与中断处理适配Oberon 系统的任务切换、定时器、时钟等都依赖中断。在 RISC-V 中必须实现一个统一的异常入口。一个基础的 trap 入口示例.section .text.trap .align 2 .globl trap_vector trap_vector: # 保存上下文到栈 addi sp, sp, -128 sw x1, 0(sp) sw x2, 4(sp) sw x3, 8(sp) sw x4, 12(sp) sw x5, 16(sp) # 保存更多寄存器... # 读取异常原因并传给 C 处理函数 csrr a0, mcause csrr a1, mepc call trap_handler # 恢复上下文 csrw mepc, a0 # 恢复寄存器... addi sp, sp, 128 mret在 C 侧实现一个基础的trap_handlervoid trap_handler(uint32_t mcause, uint32_t mepc) { if (mcause 0x80000000) { uint32_t interrupt mcause 0x7FFFFFFF; // 处理外部中断、定时器中断等 handle_interrupt(interrupt); } else { // 异常例如非法指令、缺页、环境调用 handle_exception(mcause, mepc); } }注意mepc在异常返回时需要恢复。如果处理流程改写了mepc必须在mret前重新写回 CSR。很多早期移植器在断点调试时发现程序跑飞往往是因为上下文保存不完整或者mepc被覆盖。4.4 设备驱动抽象Oberon 的显示器和输入设备是系统体验的重要组成部分。原始版本依赖 RISC-5 平台上的显示控制器、键盘控制器、鼠标控制器这些设备地址在 RISC-V 上不存在。移植策略有两种方式方式一直接为 QEMU virt 平台写新驱动。比如基于 UART 做字符输入输出基于 virtio-gpu 做显示输出。这种方式工作量大但最贴近真实移植。方式二先将设备抽象层接口保留在底层用 UART 或简单帧缓冲实现临时驱动。系统先能启动和交互再逐步完善显示和网络。无论哪种方式都要把设备的寄存器地址、读写函数、中断号与上层逻辑隔离。Oberon 系统原本的Display模块、Input模块、File模块接口可以不动但内部实现要替换。4.5 编译器后端适配这是移植工程中最硬核的部分。Project Oberon 的编译器代码量虽然不大但它生成的是 RISC-5 汇编。要让编译器生成 RISC-V 汇编需要修改指令选择与编码。寄存器分配。函数调用约定的生成。栈帧布局。全局变量引用方式。在实际操作中可以先不修改编译器而是用交叉编译器编写一个“翻译层”把 RISC-5 汇编人工或脚本转换成 RISC-V 汇编。但这种方式只适用于简单程序无法支撑完整系统。更推荐的做法是针对性修改编译器后端让编译器直接输出 RISC-V 汇编。这需要你对 Oberon 语法、中间表示、目标代码生成都有足够理解。如果只是想在 RISC-V 上体验 Oberon 系统可以先保留 RISC-5 编译器用另一个简单的 C 语言引导程序加载 Oberon 核心镜像再逐步替换编译器。这种“引导式移植”在真实项目中很常见。4.6 编译与运行验证完成上面的步骤后就可以尝试编译并运行make qemu-system-riscv32 -M virt -nographic -kernel oberon.elf如果启动正常串口输出应该是 Oberon 的命令提示符。此时可以继续验证内存分配是否正常。文件系统是否能挂载。是否能加载模块。编译器是否能编译简单 Oberon 程序。如果在 QEMU 中运行顺利后续可以尝试在 FPGA 的 RISC-V 软核上运行。当然那时还需要根据开发板重新调整 UART、中断控制器和内存映射。5. 常见问题与排查思路5.1 编译阶段错误问题现象常见原因解决思路链接找不到 _start链接脚本 ENTRY 指定错误或 start.S 未编译进可执行文件检查 Makefile 是否包含 start.o编译报无法识别的指令汇编代码使用了 RISC-5 指令确认 .S 文件中的指令都属于 RISC-V 指令集链接地址溢出代码段超过链接脚本定义的内存范围调整 MEMORY 的 LENGTH 或检查 QEMU 内存配置5.2 启动阶段崩溃系统启动后没有任何输出或者打印几个字符后卡死通常出现在BSS 清零范围不正确。栈指针设置错误。串口驱动未等待发送 FIFO 就写入数据。代码段链接到错误地址。排查策略先用 QEMU 的-d in_asm查看 CPU 实际执行的指令确认 PC 是否跳到了预期地址。qemu-system-riscv32 -M virt -nographic -kernel oberon.elf -d in_asm -D qemu.log打开qemu.log后如果发现 PC 长时间停留在某个循环内可以用info registers查看寄存器状态定位问题代码。5.3 中断与异常异常系统启动后不响应定时器中断或者异常处理一进入就死循环。常见原因mtvec未正确设置。中断入口没有保存完整寄存器上下文。mret返回后mepc没有正确恢复。mie和mstatus的中断使能位没有同时打开。最常见的问题是保存上下文不完整。RISC-V 的中断入口必须保存所有可能被 C 函数修改的寄存器否则trap_handler执行完毕后现场就被破坏了。检查要领先不使能任何中断单独触发一次软件异常确认 trap 入口能进入并能正确返回。5.4 UART 输出乱码或无输出UART 输出乱码很多时候是波特率不匹配。QEMU 的 virt 机器对串口有一定配置先确认驱动用的是 MMIO 映射地址而不是 RISC-5 时代的地址。另外QEMU 串口寄存器位宽通常为 8 位如果代码以 32 位方式写入寄存器也可能导致数据错位。无输出则优先确认外设基地址是不是0x10000000。是否等待了发送 FIFO 空闲。链接脚本是否把代码放在了 QEMU 加载地址。排查顺序建议打印地址值 → 检查 makefile → 检查链接脚本 → 检查设备基地址 → 检查 QEMU 日志。5.5 编译器生成代码不合法修改 Oberon 编译器后端后生成的汇编可能包含非法指令或错误标签。这种情况不要直接调试编译产物而是写一个小的 Oberon 测试函数逐条对比编译器输出与预期 RISC-V 汇编。例如PROCEDURE Add(a, b: INTEGER): INTEGER; BEGIN RETURN a b END Add;对应的 RISC-V 汇编应该是Add: add a0, a0, a1 ret如果输出和预期不一致说明指令选择或寄存器分配仍有问题。通过这种方式逐步收敛。6. 最佳实践与工程建议6.1 分阶段移植保持每一步可运行移植大型系统最忌讳“一次改完再运行”。建议按这条主线推进最小启动 串口输出。内存初始化。中断异常框架。任务调度。设备驱动。文件系统。编译器。每一步都保持 main 分支可编译、可运行、可验证。一个阶段只有通过验证后才进入下一阶段。6.2 把平台相关代码集中隔离在源码中单独建立platform/riscv目录把启动代码、链接脚本、设备驱动、内存映射集中在里面。Oberon 原始系统代码尽量少改动这样做移植时可以快速定位问题也便于后续适配不同的 RISC-V 开发板。比较推荐的目录结构oberon/ ├── Kernel/ ├── FileSystem/ ├── Compiler/ ├── Display/ ├── Input/ └── platform/ └── riscv/ ├── start.S ├── link.ld ├── traps.c ├── uart.c ├── clint.c └── Makefile.inc6.3 重视日志与可观测性早期启动阶段是最难调试的因为此时文件系统、任务调度都还没有建立。一个可靠的经验是从第一步开始就通过 UART 打印日志。建议在框架中实现一个简单的kprintf支持十六进制输出。这样在内存初始化、模块加载、中断触发时都可以快速定位偏差。6.4 小步提交保持版本可回退移植工程往往需要做大量实验Git 仓库中应该保持小步提交。每次提交对应一个可运行的功能点而不是一次提交几千行改动。提交信息建议带上riscv标签例如riscv: add UART driver for qemu virt machine riscv: switch trap handler to mtvec/mcause这样后期排查问题时可以通过 commit 历史快速找到某个功能是什么时候引入的。6.5 写自动化验证脚本QEMU 的好处是可以脚本化运行。把启动测试写成脚本每次改动后自动运行能省下大量人工操作时间。#!/bin/bash # run-test.sh expect EOF spawn qemu-system-riscv32 -M virt -nographic -kernel oberon.elf expect Oberon RISC-V boot send TestCmd\r expect OK EOF这种脚本式回归测试在后续频繁修改编译器后端时特别有用。7. 总结与学习路线Project Oberon System 从 RISC-5 移植到 RISC-V看似是一个小众实验实际上覆盖了操作系统、编译原理、计算机组成、体系结构四个方向的核心知识点。做完这个工程你不仅理解了 Oberon 的内部结构还会对 RISC-V 指令集、RISC-V 指令架构寄存器、中断异常机制、设备抽象和编译器后端有远比书本更深刻的认识。如果你是从零开始建议先做一遍 RISC-V 单周期 CPU 实验理解指令从取指、译码到执行的全过程。这一步会为后续移植提供非常扎实的硬件直觉。接着学习 RISC-V 指令集重点看 RV32I 的基础指令和 CSR 寄存器。真正动手做移植时你会发现最关键的其实不是某条指令怎么用而是如何处理运行时的上下文、栈帧和中断嵌套。至于下一步可以从三条路线继续深入阅读 Project Oberon 源码把各个模块在 RISC-V 上的对应实现一一对应起来。把图形界面和输入设备完整移植到 RISC-V让 Oberon 在 QEMU 中具备完整的可视化操作体验。尝试在 FPGA 板卡上运行移植后的 Oberon进一步理解真实 RISC-V 硬件的时序、中断控制器和 MMIO 设计。如果你正在做 RISC-V 相关课程设计这个项目可以成为一份很有分量的综合实践。它不只是验证一个处理器设计是否能用而是让你亲手搭建了一条从编译器到操作系统的完整工具链。顺着这条路线走下去很多零散的知识会在同一个系统里串成一条线这种连接感是刷再多题也无法替代的。