3. 开发环境搭建:QEMU模拟器安装、RISC-V交叉编译工具链配置、最小化内核项目结构、Makefile编写

好,咱们正式开始动手。这一章说白了就是「把家伙事儿备齐」。我见过太多人兴致勃勃想写内核,结果卡在环境搭建上,一卡就是两三天。其实没那么玄乎,咱们一步步来。

3.1 QEMU模拟器安装

QEMU 是什么?简单说,它能在你的电脑上模拟出一台 RISC-V 机器。你写的内核不用烧到真芯片上,直接在 QEMU 里跑就行。我个人习惯用 QEMU 做开发,因为调试起来太方便了——断点、单步、看寄存器,样样都行。

安装 QEMU 其实很简单。以 Ubuntu 为例:

sudo apt-get update
sudo apt-get install qemu-system-misc

如果你用的是其他发行版,比如 Arch:

sudo pacman -S qemu-system-riscv

装完之后验证一下:

qemu-system-riscv64 --version

看到版本号就说明装好了。嗯,这里要注意:qemu-system-misc 这个包在有些老版本 Ubuntu 里可能不包含 RISC-V 支持。我曾经踩过这个坑,装了半天发现模拟器不认识 -machine virt 参数。后来换成 qemu-system-riscv64 单独装才搞定。

避坑指南: 如果你在 macOS 上,建议用 Homebrew 安装:brew install qemu。Windows 用户可以用 WSL2,然后在 Linux 子系统里装。

3.2 RISC-V交叉编译工具链配置

你想想看,你的电脑是 x86 架构,但你要编译出能在 RISC-V 上跑的程序。这就需要一个「交叉编译工具链」。说白了,就是一套能在 x86 上生成 RISC-V 机器码的编译器、链接器、汇编器。

我推荐用 riscv64-unknown-elf- 这套工具链。为什么是 unknown-elf?因为它不依赖任何操作系统——咱们写的是内核,本身就是操作系统,不需要底层的 libc 支持。

安装方式有两种:

  1. 直接安装预编译包(推荐,省时间)
  2. 从源码编译(如果你有特殊需求)

咱们选第一种:

sudo apt-get install gcc-riscv64-unknown-elf

装完检查一下:

riscv64-unknown-elf-gcc --version

如果看到类似 riscv64-unknown-elf-gcc (GCC) 12.2.0 的输出,就说明搞定了。

小技巧: 如果你需要从源码编译,可以到 riscv-gnu-toolchain 仓库下载。但说实话,编译一次要半小时,我建议除非你有特殊优化需求,否则直接用预编译包。

3.3 最小化内核项目结构

好,工具链有了,模拟器有了。接下来咱们搭项目骨架。一个最小化的微内核,目录结构长什么样?我习惯这样组织:

mykernel/
├── arch/
│   └── riscv64/
│       ├── boot.S        # 启动汇编代码
│       ├── trap.c        # 中断/异常处理
│       └── memlayout.h   # 内存布局定义
├── kernel/
│   ├── main.c            # 内核入口
│   ├── syscall.c         # 系统调用实现
│   └── sched.c           # 调度器(后面会讲)
├── include/
│   ├── types.h           # 类型定义
│   └── syscall.h         # 系统调用号定义
├── Makefile
└── linker.ld             # 链接脚本

你可能会问:为什么这么分?

  • arch/ 放架构相关的代码。以后你想移植到 x86 或 ARM,只需要改这个目录。
  • kernel/ 放内核核心逻辑。这部分理论上与架构无关。
  • include/ 放头文件。我习惯把公共类型定义放在这里。

咱们先写一个最简单的 boot.S,让 QEMU 能跳转到我们的内核入口:

.section .text.init
.globl _start
_start:
    # 设置栈指针
    la sp, _stack_top
    
    # 跳转到 C 语言入口
    call kernel_main
    
    # 如果返回,就死循环
loop:
    wfi
    j loop

对应的 main.c:

void kernel_main(void) {
    // 暂时什么都不做
    while(1) {
        asm volatile("wfi");
    }
}

嗯,这里要注意:wfi 指令是 RISC-V 的「等待中断」指令。内核没事干的时候就该休眠,等中断来了再干活。这是微内核的基本哲学——不忙等。

3.4 Makefile编写

Makefile 是咱们和编译器之间的「翻译官」。我见过很多新手把 Makefile 写得巨复杂,动不动就上百行。其实对于微内核开发,一个 30 行的 Makefile 就够了。

咱们来看一个最小化的版本:

CROSS_COMPILE = riscv64-unknown-elf-
CC = $(CROSS_COMPILE)gcc
LD = $(CROSS_COMPILE)ld
OBJCOPY = $(CROSS_COMPILE)objcopy

CFLAGS = -march=rv64gc -mabi=lp64d -nostdlib -ffreestanding -Iinclude
LDFLAGS = -T linker.ld -nostdlib

SRCS = arch/riscv64/boot.S kernel/main.c
OBJS = $(SRCS:.S=.o)
OBJS := $(OBJS:.c=.o)

all: kernel.elf

%.o: %.S
	$(CC) $(CFLAGS) -c $< -o $@

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

kernel.elf: $(OBJS)
	$(LD) $(LDFLAGS) -o $@ $^

run: kernel.elf
	qemu-system-riscv64 -machine virt -bios none -kernel kernel.elf

clean:
	rm -f $(OBJS) kernel.elf

解释几个关键点:

  • -nostdlib:不链接标准库。咱们写内核,不需要 printf 那些东西。
  • -ffreestanding:告诉编译器这是独立环境,没有操作系统支持。
  • -march=rv64gc:指定 RISC-V 64位架构,带通用扩展。
  • -mabi=lp64d:LP64 数据模型,双精度浮点。
重要: 链接脚本 linker.ld 是必须的。它告诉链接器内核代码应该放在内存的哪个位置。对于 QEMU virt 机器,我们通常把内核放在 0x80000000 处。

对应的 linker.ld 长这样:

OUTPUT_ARCH(riscv)
ENTRY(_start)

SECTIONS
{
    . = 0x80000000;
    
    .text : {
        *(.text.init)
        *(.text)
    }
    
    .data : {
        *(.data)
    }
    
    .bss : {
        *(.bss)
    }
    
    _stack_top = . + 0x10000;
}

你可能会问:为什么是 0x80000000?因为 QEMU 的 virt 机器把 DRAM 映射到这个地址。这是硬件决定的,咱们改不了。

3.5 验证环境

所有文件写好后,在项目根目录执行:

make run

如果一切正常,你会看到 QEMU 窗口弹出来,然后... 黑屏。别慌,这是正常的!因为咱们的内核目前只会执行 wfi 指令,什么都不做。但至少证明:

  1. 交叉编译工具链能正常工作
  2. QEMU 能加载并运行我们的内核
  3. 项目结构是合理的
调试小技巧: 如果想看内核到底跑没跑,可以在 QEMU 启动时加 -d in_asm 参数,它会打印执行的每一条汇编指令。我调试启动阶段时经常用这招。

好,环境搭好了。下一章咱们开始写真正的系统调用——让内核和用户程序说上话。你想想看,一个连系统调用都没有的内核,跟一个死循环有什么区别?