一直想找一个小而完整的操作系统来理解计算机系统如何从开机到运行应用,但又不想一上来就啃 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-V
1.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 名称 | 用途 |
|---|---|---|
| x0 | zero | 恒零寄存器 |
| x1 | ra | 返回地址 |
| x2 | sp | 栈指针 |
| x3 | gp | 全局指针 |
| x8 | s0/fp | 栈帧指针 |
| x10~x17 | a0~a7 | 函数参数 |
| x5~x7, x28~x31 | t0~t6 | 临时寄存器 |
| x8~x9, x18~x27 | s0~s11 | 保存寄存器 |
RISC-5 虽然也有 32 个寄存器,但没有这些严格的调用约定。Oberon 编译器在生成 RISC-5 汇编时,往往按照 RISC-5 自身设计来分配寄存器。因此,在移植编译器后端时,不仅要修改指令编码,还要重新做寄存器分配和过程调用约定。
3.2 异常与中断机制
这是移植中最容易出问题的部分。
RISC-5 的异常处理相对简单,处理器遇到异常时会跳转到固定地址,系统软件从那里开始处理。每个中断源往往有独立的处理入口,便于快速区分事件类型。
RISC-V 则把中断和异常统一交给控制状态寄存器处理,核心是这几个 CSR:
- mtvec:异常/中断向量基地址,低两位表示向量模式。
- 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 的常见地址:
| 设备 | 起始地址 |
|---|---|
| UART0 | 0x10000000 |
| CLINT | 0x02000000 |
| PLIC | 0x0C000000 |
| 内存 | 0x80000000 |
Oberon 系统驱动程序中原本写死的寄存器地址,在移植时要抽成配置宏或外设抽象接口。不要试图让 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_handler:
void 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 相关课程设计,这个项目可以成为一份很有分量的综合实践。它不只是验证一个处理器设计是否能用,而是让你亲手搭建了一条从编译器到操作系统的完整工具链。顺着这条路线走下去,很多零散的知识会在同一个系统里串成一条线,这种连接感,是刷再多题也无法替代的。