Featured image of post 基于RISC-V架构的操作系统学习

基于RISC-V架构的操作系统学习

反复横跳

前言

这次还是课程实验。第二次吐槽,这么有意思的事情居然被框在平台的题目评测上,认真写的欲望一下子就降低了,不过选题依旧有价值,还是自己好好研究一下吧

以上是我曾经说出来的话。但当我经历明明实验完成了,评测程序一遍遍不通过,搞得我只好再来一次这种事,最终在配环境和运行这两个最简单的地方卡了整整两天,一共重来了6次。现在我只会说去你的应试教育

对,上面的话是我在两小时前说出来的,但现在已经结束了配环境,开始第一个正式实验了。确实很有意思啊,我又反悔,决定进行记录了

环境

我使用的是Ubuntu 24.04.4 LTS 64位 的虚拟机

  • CPU:4 核
  • 内存:8GB
  • 硬盘:50GB
  • 网络:NAT模式

img

在 Ubuntu 上编译一堆 C + 汇编 → 变成 RISC-V 的内核 → 用 QEMU 模拟一台 RISC-V 机器 → 把你的内核跑起来

安装依赖

1
2
3
4
5
sudo apt update
sudo apt install -y git build-essential cmake ninja-build \
    python3 python3-pip libglib2.0-dev libpixman-1-dev \
    gcc-riscv64-unknown-elf gdb-multiarch
sudo apt install mtools dosfstools

交叉编译工具链

1
sudo apt install gcc-riscv64-unknown-elf

测试:

1
riscv64-unknown-elf-gcc -v

安装 QEMU

1
sudo apt install qemu-system-misc

测试:

1
qemu-system-riscv64 --version

进入系统

操作系统运行在 QEMU 虚拟平台上,通过 make 构建生成内核与用户程序镜像。QEMU 模拟 RISC-V 硬件环境,执行 bootloader 后进入内核,最终加载用户程序执行。

系统结构分为 user、kernel 和虚拟硬件三层,其中 user 程序通过系统调用访问内核服务。

克隆好代码,进入代码文件夹,对源码进行编译

1
2
chmod +x run-qemu.sh
./run-qemu.sh

1. 安装 Docker(Ubuntu)

1
2
sudo apt update
sudo apt install -y docker.io

启动服务:

1
2
sudo systemctl start docker
sudo systemctl enable docker

验证:

1
docker --version

2. 加入权限(避免一直 sudo)

1
2
sudo usermod -aG docker $USER
newgrp docker

二、导入实验镜像(关键步骤)

你老师给的是:

1
kairos-lab-os-c-eh@v0.2.1.tar

假设你已经下载好:

1
unzip kairos-lab-os-c-eh@v0.2.1.zip

导入镜像:

1
docker load -i kairos-lab-os-c-eh@v0.2.1.tar

查看镜像:

1
docker images

三、启动实验容器(核心环境)

先建目录(用于代码映射):

1
2
mkdir -p ~/oslab
cd ~/oslab

启动容器:

1
2
3
docker run -it --privileged --net=host \
-v ~/oslab:/root/oslab \
kairos-lab-os-c-eh

进入后你会看到类似:

1
root@localhost:/#

四、检查工具链是否正常

在容器里执行:

1
2
3
riscv64-unknown-elf-gcc -v
qemu-system-riscv64 --version
eh --help

只要这三个正常,说明环境OK。


五、拉取头歌实验代码(你后面所有操作都在这里)

进入映射目录:

1
cd /root/oslab

复制你头歌给的链接:

1
https://git.educoder.net/xxxxx/xxxxx.git

克隆:

1
2
git clone https://git.educoder.net/xxxxx/xxxxx.git
cd xxxxx

系统调用与中断

一、先进入容器

1
2
docker run -it --privileged --net host \
-v ~/oslab:/root/oslab kairos-lab-os-c-eh

进入后:

1
cd /root/oslab/你的项目目录

二、体验 QEMU 用户态模拟

这个最简单。


1. 创建 hello.c

进入:

1
cd user/src

创建:

1
vim hello.c

写入实验给的代码。


2. 编译用户程序

回到项目根目录:

1
2
3
4


cd /root/oslab/项目目录
make user

image-20260513160410032


3. 用 QEMU 用户态运行

1
qemu-riscv64 build/user_prog/hello

这里调用的是:

QEMU 的“用户态模拟”。

本质是:

1
2
3
4
5
x86 Linux
QEMU翻译RISC-V指令
直接运行hello程序

你应该看到:

1
2
3
4
1) Open test.txt
2) Write Done
3) Open test.txt
4) Read content: Hello World!

4. 查看系统调用(重点)

1
qemu-riscv64 -strace build/user_prog/hello

你会看到:

1
2
3
4
openat(...)
write(...)
read(...)
close(...)

这个非常重要。

因为:

后面你自己的 OS 必须表现得和 Linux 类似

所以这个日志是“参考答案”。

![屏幕截图 2026-05-13 160634](d:\Users\Thaumazein\Pictures\Screenshots\屏幕截图 2026-05-13 160634.png)


三、运行你自己的操作系统


1. 清理构建(很重要)

实验文档已经强调了。

每次切换:

  • debug
  • CPU数量
  • 配置参数

之前都要:

1
make clean

2. 启动 OS

遇到问题脚本没执行权限。

直接:

1
chmod +x run-qemu.sh

然后再运行:

1
./run-qemu.sh debug=on

如果后面 run-gdb.sh 也报一样的问题,同样:

1
chmod +x run-gdb.sh

Linux 里从 Windows 复制、解压、git 拉下来的脚本,经常会丢失 executable 权限。

image-20260513160811391

1
2
chmod +x run-qemu.sh
./run-qemu.sh debug=on

这里调用的是:

1
qemu-system-riscv64

这是“系统级模拟”。

它不是运行单个程序,而是:

1
模拟整台RISC-V电脑

包括:

  • CPU
  • 内存
  • 磁盘
  • SBI
  • virtio设备

3. 在 OS shell 里运行 hello

进入 shell 后:

1
./hello

你会看到:

1
[DEBUG][SYSCALL...]

这些是你自己的 OS 输出的 syscall 日志。

image-20260513160925553


四、最关键的实验思想(一定理解)

你现在有两份日志:


Linux + QEMU 用户态

1
2
3
4
openat
write
read
close

你自己的 OS

1
2
syscall openat
syscall write

然后你对比:

1
2
3
Linux行为
vs
自己OS行为

如果不一致:

  • syscall 参数错了
  • 返回值错了
  • 文件系统错了
  • 内存错了

五、过滤 syscall 日志(实验重点)

编辑:

1
src/kernel/syscall.c

找到:

1
debug(...)

改成:

1
2
3
4
5
6
debug_if(strncmp(p->name, "hello", 5) == 0,
    "PID %d syscall %s = %ld from %#lx",
    p->pid,
    syscall_name(num),
    ret,
    epc);

作用:

1
只打印 hello 进程 syscall

否则整个系统日志会爆炸。

image-20260513161211690

image-20260513161344662

六、GDB 调试(后面实验会频繁用)


1. 启动调试版 QEMU

1
2
chmod +x run-gdb.sh
./run-gdb.sh

系统会停住等待 GDB。

image-20260513161518531

2. 新开终端进入容器

启动:

1
gdb-multiarch

连接:

1
2
target remote localhost:26000
file build/kernel

image-20260513162531984


提示找不到文件,原因是gdb没有进入容器而是在宿主机,进入容器的实验目录即可

image-20260513161956601

image-20260513162406949

3. 常用 GDB 命令

断点:

1
b syscall

继续:

1
c

单步:

1
n

查看寄存器:

1
info registers

查看调用栈:

1
bt

遇到问题:评测不通过,命令改成eh –lab-id 2 -t解决

调试

先别急着改代码,先按实验流程看“它到底报什么错”。

运行:

1
./run-qemu.sh

把最后的报错贴出来。

这个 lab 的思路不是乱找,而是:

  • 看错误
  • 顺着启动链找
  • 定位 init0 为什么没起来

实验提示已经把范围缩很小了:

1
src/kernel/proc.c::user0_init

说明问题大概率在:

  • init0 名字
  • exec 加载
  • 文件镜像
  • 第一个用户进程创建

这几个地方之一。

先把 QEMU 输出给我。

这个实验本质是在让你理解:

1
2
操作系统启动后,第一个用户进程是谁?
它是怎么被加载的?

你现在看到的报错:

1
2
3
4
😅 Emmmm, I think you're in the wrong place...
😅 Please fix: start 'init0' instead of me...

panic: init exiting

不是随机报错。

这是有人故意把系统“第一个启动的用户程序”改错了。

你现在一步一步看。

系统启动过程其实是:

1
2
3
4
5
6
QEMU
→ 启动 kernel
→ kernel 初始化内存/页表/文件系统
→ 创建第一个用户进程
→ exec(某个程序)
→ 进入 shell

这里最关键的是:

1
“某个程序”到底是谁?

实验就是在这里动了手脚。

你已经找到:

1
static void __user0_init(proc_t *p)

这里是:

1
2
3
4
5
char *argv[] = {USER0, NULL};

if (exec(USER0, argv, envp) != 0) {
    panic(USER0 " load");
}

重点来了。

这里并不是:

1
exec("init0")

而是:

1
exec(USER0)

说明:

1
真正决定启动谁的是 USER0

所以继续追。

你用了:

1
grep -R "define USER0" .

找到:

1
#define USER0 "masquerade"

这就是真正 bug。

正常应该是:

1
#define USER0 "init0"

但实验故意改成:

1
"masquerade"

于是启动过程变成:

1
2
3
4
5
6
kernel
→ exec("masquerade")
→ 文件系统里找 masquerade
→ 找不到 / 或运行错误
→ 打印提示
→ panic

为什么会打印:

1
Please fix: start 'init0' instead of me

因为这个“masquerade”程序本身就是故意放进去的假程序。

它被启动后:

  • 不进入 shell
  • 不正常工作
  • 只打印一句提示
  • 然后退出

而:

1
init0

才是真正系统初始化程序。

Linux 里其实也有类似概念:

1
PID 1 = init/systemd

如果它退出:

1
kernel panic

你这里看到:

1
panic: init exiting

就是同类逻辑。

因为:

1
第一个用户进程不能退出

否则系统没人管理了。

所以修复步骤:

打开:

1
vim include/param.h

找到:

1
#define USER0 "masquerade"

改成:

1
#define USER0 "init0"

保存。

然后重新编译:

1
2
make clean
./run-qemu.sh

这里必须 make clean

因为:

1
头文件变了

很多 .o 文件缓存可能不会完全重新生成。

之后系统会:

1
2
3
4
exec("init0")
→ 加载真正初始化程序
→ 创建 shell
→ 正常启动

你可以把这个实验理解成:

1
“操作系统启动链分析”

重点不是改那一行。

而是理解:

1
kernel 怎么进入用户态

以及:

1
第一个用户进程为什么重要

为了让你对这次 RISC-V 系统调用实验有最深层的掌握,我将整个过程拆解为“底层逻辑与操作流程”以及“多维排错指南”。


🛠️ 第一部分:系统调用实现全流程

实现一个系统调用的核心在于:建立用户态与内核态的通信桥梁。

1. 静态注册:建立编号映射

内核需要一个索引来识别用户到底想打哪个“电话”。

  • 文件路径entry/syscall.tbl
  • 操作:添加一行 666 echo sys_echo
  • 底层原理:构建脚本(如 syscall_gen.sh)会读取此表,生成一个函数指针数组。当 a7 寄存器为 666 时,内核会自动索引到 sys_echo 这个函数符号。

2. 内核实现:跨越特权级的数据处理

这是实验的核心代码,必须处理地址空间隔离问题。

  • 文件路径src/sys_echo.c
  • 核心逻辑

C

 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
#include "common.h"
#include "mm/vm.h"
#include "kernel/syscall.h"
#include "printf.h" // 必须包含内核版 printf 声明

uint64_t sys_echo() {
    uint64_t user_ptr; // 用户空间的虚拟地址(只是一个数值)
    int len;
    char buf[128];     // 内核空间的临时缓冲区

    // [Step A] 提取参数
    // 系统调用参数存在 trapframe->a0, a1 中。内核通过 argaddr 获取这些值。
    if (argaddr(0, &user_ptr) < 0 || argint(1, &len) < 0) {
        return -1; // 参数解析失败返回 -1
    }

    // [Step B] 安全防御 (Bound Check)
    // 永远不要信任用户传来的长度,防止溢出内核栈。
    if (len < 0 || len > 127) len = 127;

    // [Step C] 数据拷贝 (The Bridge)
    // 内核不能直接解引用 user_ptr,因为内核页表和用户页表不同。
    // copy_from_user 会查找用户页表,将数据安全地复制到内核 buf。
    if (copy_from_user(buf, user_ptr, len) < 0) {
        return -1;
    }
    buf[len] = '\0'; // 强制封口,确保字符串安全

    // [Step D] 执行内核任务
    printf("%s\n", buf);

    return 0; // 返回值会存入用户态的 a0 寄存器
}

3. 编译集成:配置 Makefile

  • 文件路径src/Makefile
  • 操作:添加 obj-y += sys_echo.o
  • 底层原理:Kbuild 系统会递归扫描该变量,将 sys_echo.c 编译并链接进 built-in.o,最终合并入内核镜像。

4. 用户态调用:触发陷阱

  • 文件路径user/src/test_echo.c
  • 操作

C

1
2
3
4
5
6
7
8
9
#include "syscall.h"
#include "string.h"

int main() {
    char *msg = "Hello Navi";
    // 汇编底层:li a7, 666; ecall
    syscall(666, msg, strlen(msg));
    return 0;
}

🔍 第二部分:全维度排错指南 (Debug Ledger)

根据你的实验记录,我们将报错分为四个等级:

1. 预编译级:头文件与标准库陷阱

  • 典型报错fatal error: stdio.h: No such file or directory
  • 错误逻辑:在内核里使用 #include <stdio.h>
  • 深度解析:内核是裸机程序 (Bare-metal),不运行在任何操作系统之上,所以没有标准库。
  • 对策:删除所有标准库头文件。内核打印只能用框架提供的 printf.h

2. 编译级:隐式声明错误

  • 典型报错error: implicit declaration of function 'printf' [-Werror=implicit-function-declaration]
  • 错误逻辑:没包含 printf.h 就直接用了 printf
  • 深度解析:由于开启了 -Werror,编译器要求所有函数必须先声明后使用。
  • 对策:包含正确的内核头文件。如果框架里的 common.h 没写好,就手动包含 #include "printf.h"

3. 链接级:符号丢失与 Makefile 权限

  • 典型报错undefined reference to 'sys_echo'E212: Can't open file for writing
  • 错误逻辑:Makefile 没改对,或者改了没保存。
  • 深度解析:链接器(ld)在生成最终镜像时,发现 syscall_table 里有一个叫 sys_echo 的坑位,但找不到对应的机器码实现。
  • 对策
    1. 检查 src/Makefile 里的文件名是否拼写正确(是 .o 不是 .c)。
    2. 解决权限问题:使用 chmod 666 src/Makefile 赋予写权限,或在保存时使用 :w!

4. 运行级:指针解引用与类型转换

  • 典型报错passing argument 2 of 'copy_from_user' makes integer from pointer without a cast
  • 错误逻辑:直接把 char * 传给 copy_from_user
  • 深度解析:在内核代码里,用户空间的地址只是一个符号,内核无法直接跳转到那个地址取值(硬件特权级限制)。copy_from_user 期望的是一个 uint64_t 类型的地址数值。
  • 对策:使用 uint64_t addr; argaddr(0, &addr); 拿到的 addr 才是正确的。

💡 总结建议

这次实验你遇到的最大阻碍是内核开发的隔离感。记住:内核里没有“魔法”,所有用户态看起来理所应当的 printf 或指针访问,在内核里都要通过显式包含头文件和软件页表翻译(copy_from_user)来实现。

现在,你可以放心运行 ./run-qemu.sh,在 Shell 里输入 test_echo 见证你的第一个系统调用了。

没有找到test——echo是因为写错文件夹

太棒了,看到 test_echo 成功出现在 ls 列表中且编译通过,说明你的整个工具链(交叉编译器、CMake、磁盘镜像打包)已经完全跑通了。

针对刚才“命令输入错误”导致 execvp failure 的这一关键环节,我已将其整理入最终版的排错与操作指南中。


🛠️ 第一部分:系统调用实现·标准操作流程

实现一个系统调用是内核开发中典型的“纵向贯穿”任务,涉及以下四个关键层级:

1. 静态注册层 (entry/syscall.tbl)

  • 动作:添加 666 echo sys_echo
  • 目的:分配系统调用号,并将号与内核函数符号关联。

2. 内核实现层 (src/sys_echo.c)

  • 动作:使用 argaddrargint 获取寄存器参数,用 copy_from_user 拷贝内存,最后用 printf 打印。
  • 注意:必须包含 #include "printf.h" 以满足内核环境下的函数声明需求。

3. 用户接口层 (user/include/syscall.h)

  • 动作:确保定义了对应的号(如果框架未自动同步)。
  • 目的:让用户态程序知道 666 代表的是哪个功能。

4. 测试构建层 (user/src/test_echo.c & Makefile)

  • 动作:在 user/Makefile(或 CMakeLists)中添加 test_echo 目标,并编写调用 syscall(666, ...) 的测试代码。

🔍 第二部分:深度排错指南 (Troubleshooting Ledger)

我们将你踩过的坑按发生阶段重新归类,这能帮你建立起一套职业的内核调试思维。

1. 编译链接级:环境与声明错误

  • 报错implicit declaration of function 'printf'undefined reference to 'printf'
  • 根因:内核不包含标准 C 库。
  • 对策:禁止包含 <stdio.h>;必须包含内核专属头文件(如 "printf.h")。

2. 构建级:镜像同步与权限错误

  • 报错ls 找不到文件,或 E212: Can't open file for writing
  • 根因:Makefile 没改对、未保存或没有权限。
  • 对策
    • 使用 chmodsudo 解决权限。
    • 核心准则:修改代码后,必须在根目录执行 make clean && make 以强制重新生成磁盘镜像 fs.img

3. 加载运行级:execvp failure (关键坑点)

  • 现象:尝试运行程序时,Shell 报错 execvp failure
  • 根因 A(文件类型错):输入了源码文件名(如 ./test_echo.c)。内核无法将文本文件识别为 ELF 格式。
  • 根因 B(路径不支持):有些简易 Shell 不支持相对路径 ./
  • 根因 C(格式损坏):由于未 make clean,导致旧的二进制残留或链接脚本失效。
  • 对策
    • 检查输入:只输入程序名 test_echo,不要带 .c
    • 验证格式:确保它是经由 riscv64-unknown-elf-gcc 编译出的 ELF 文件。

4. 内核逻辑级:指针解引用错误

  • 报错passing argument 2 ... makes integer from pointer 或内核 Panic。
  • 根因:试图在内核里直接使用用户态传来的指针。
  • 对策:参数必须用 uint64_t 接收地址值,并强制通过 copy_from_user 转换。

💡 终极复盘心得

“在内核的世界里,文件名和文件是两回事,地址和指针是两回事。”

这次实验最深刻的教训在于:内核是非常死板的。 你输入 test_echo.c 时,内核忠实地去读文件头的 4 个字节,发现不是 0x7f 0x45 0x4c 0x46 (ELF 的魔数),于是果断报出 execvp failure

现在,去掉后缀,再次输入 test_echo,迎接你的成功输出吧!

Like 0
本站已不稳定运行 小时 分钟
共发表文章 25 篇 ,总计 94.86 k 字
本站总访问量:
使用 Hugo 构建
主题 StackJimmy 设计