第一章
# 文件格式$ file target/riscv64gc-unknown-none-elf/debug/ostarget/riscv64gc-unknown-none-elf/debug/os: ELF 64-bit LSB executable, UCB RISC-V, ......
# 文件头信息$ rust-readobj -h target/riscv64gc-unknown-none-elf/debug/os File: target/riscv64gc-unknown-none-elf/debug/os Format: elf64-littleriscv Arch: riscv64 AddressSize: 64bit ...... Type: Executable (0x2) Machine: EM_RISCV (0xF3) Version: 1 Entry: 0x0 ...... }
# 反汇编导出汇编程序$ rust-objdump -S target/riscv64gc-unknown-none-elf/debug/os target/riscv64gc-unknown-none-elf/debug/os: file format elf64-littleriscv工作空间: taplo.toml 指定 toml 格式化配置;rustfmt 指定 rust 格式化配置;rust-toolchain.toml 指定项目需要具有的工具链;xtask 编写构建流程;.cargo/config.toml 配置 alias 等。如果多个 member 具有不同的 target,就不能在工作空间的 .cargo/config.toml 里面配置 target 而是为每个 member 单独添加配置,但注意.cargo 只有当前工作目录能读取到,从 workspace 工作目录执行 cargo 命令是读不到 member 的.cargo/config.toml 的,于是需要在 xtask 命令行应用里面编写需要构建的 member 及其构建 flags,env 等等
操作系统指令
Section titled “操作系统指令”这一节的目标是解释 内核在真实硬件或模拟器上是如何启动,并执行第一条指令的。我们从硬件和程序如何到达那条指令的流程开始讲。
计算机硬件基础
Section titled “计算机硬件基础”任何计算机都由三部分组成:
- CPU(处理器):按顺序读取、解释并执行指令。
- 物理内存(RAM):存放指令和数据。
- 外设(I/O 设备):如键盘、显示器等。
CPU 访问内存的方式类似于读取一个大数组里的元素。对于多数架构(例如 RISC-V),数据访问要满足对齐规则,否则会出错。
QEMU 模拟器是怎样启动的
Section titled “QEMU 模拟器是怎样启动的”在这本教程中,我们使用 QEMU-system-riscv64 模拟一个 RISC-V 机器。
当 QEMU 启动时,它会:
- 分配物理内存空间。
- 把 bootloader(二进制文件)加载到内存起始位置。
- 把我们编译好的 内核镜像(os.bin)加载到指定的地址。
CPU 启动后:
- PC(程序计数器)最初会指向一个固定地址(如
0x1000)。 - 这里其实是 QEMU 内部的一段引导代码,会跳转到 bootloader 开始地址。
- bootloader 初始化硬件之后,再跳转到 我们内核第一条真正要执行的指令。
为什么内核第一条指令很重要
Section titled “为什么内核第一条指令很重要”为了让内核能真正运行:
- 内核必须被 加载到正确的位置。
- 内核编译产出的文件不能直接作为镜像用,需要调整布局,确保第一条指令位置正确。
- 在模拟器里,只有当 PC 跳到这个位置时,我们的内核代码才开始执行。
程序在内存里的结构
Section titled “程序在内存里的结构”一个程序在内存中不是一堆乱七八糟的字节,它分成几个部分:
- 代码部分(.text):CPU 要执行的指令。
- 已初始化数据(.data 和 .rodata)。
- 未初始化数据(.bss):运行时由系统清 0。
- 堆(heap)和栈(stack):运行时使用的内存。
当我们编译内核时:
- 编译器把 Rust 代码变成汇编。
- 汇编器生成机器代码。
- 链接器将各部分组合成一个最终文件。
- 最后的文件要经过调整才能当成真正内核镜像。
这一节的目标是解释 为什么内核需要支持函数调用,以及如何让函数调用在 RISC‑V 上正确运行。我们已经成功执行了内核的第一个指令(用汇编写的),接下来希望把控制权交给用 Rust 语言编写的内核代码。为此必须做一些准备工作,包括设置 栈,让函数调用和返回在内核中能够正常工作。
函数调用的基本问题
Section titled “函数调用的基本问题”当程序调用一个函数时,CPU 必须:
- 跳转到被调用函数的位置执行它的代码;
- 等函数执行完毕后 返回到调用点的下一条指令继续执行。
前者跳转地址是固定的,但是 返回的地址只有在运行时才知道。对函数调用来说,需要一种机制来保存返回地址,使 CPU 知道从哪里回来继续执行。
RISC‑V 如何支持函数调用
Section titled “RISC‑V 如何支持函数调用”RISC‑V 指令集提供了两个支持函数调用的跳转指令:
| 指令 | 功能 |
|---|---|
jal rd, imm | 将 PC+4 保存到寄存器 rd,然后跳转到 PC + imm |
jalr rd, imm(rs) | 将 PC+4 保存到 rd,然后跳转到 rs + imm |
这两条指令的特点是 跳转前自动把返回地址存到 rd 寄存器中。在 RISC‑V 中,我们约定将返回地址放在寄存器 ra(x1) 里,这样函数返回时只要跳回 ra 就可以继续执行之前的代码。汇编中我们常用伪指令 ret 来实现这一跳转,它会被翻译成 jalr x0, 0(x1)。
嵌套调用和寄存器保存的问题
Section titled “嵌套调用和寄存器保存的问题”如果只有简单调用,那么用 ra 保存返回地址就够用;但是当函数有 多层嵌套 调用时,ra 会被覆盖,这时返回地址就丢失了。为了解决这个问题,我们必须把要保存的寄存器 保存到内存上(也就是栈)。
使用栈可以让每次调用前保存当前寄存器状态,返回后再恢复。这个保存与恢复过程分两部分:
- 调用者保存(Caller‑Saved)寄存器:调用函数前需要保存,调用返回后再恢复;
- 被调用者保存(Callee‑Saved)寄存器:子函数一开始保存,返回前再恢复;
这样不论函数调用多复杂,寄存器值都不会意外丢失。
函数调用规范(Calling Convention)
Section titled “函数调用规范(Calling Convention)”所谓函数调用规范,就是规则定义了:
- 参数和返回值从哪些寄存器传递;
- 哪些寄存器需要调用者保存,哪些需要被调用者保存;
- 如何使用栈等。
在 RISC‑V 上,常用的调用规范里:
| 寄存器 | 谁保存 | 用途 |
|---|---|---|
a0 ~ a7 | 调用者保存 | 传递函数参数,返回值用 a0/a1 |
t0 ~ t6 | 调用者保存 | 临时寄存器,可随意使用 |
s0 ~ s11 | 被调用者保存 | 程序想保留的寄存器 |
ra(x1) | 被调用者保存 | 保存返回地址 |
sp(x2) | 被调用者保存 | 栈指针 |
fp(s0) | 被调用者保存 | 栈帧指针 |
gp、tp、zero 等寄存器不是函数调用上下文的一部分。
栈和栈帧(Stack & Stack Frame)
Section titled “栈和栈帧(Stack & Stack Frame)”函数调用使用栈来保存寄存器。栈是一个从高地址向低地址增长的数据区:
- 当函数调用时,分配一块区域来保存当前状态,这叫 栈帧;
- 函数返回时,再把这块区域释放。
栈帧里通常保存寄存器、局部变量等内容。编译器会自动在函数开头生成 Prologue(开场保存寄存器) 和在函数返回前生成 Epilogue(恢复寄存器)。
内核启动栈的设置
Section titled “内核启动栈的设置”在 entry.asm 里,我们设置了启动栈:
.section .text.entry.globl _start_start: la sp, boot_stack_top call rust_main
.section .bss.stack.globl boot_stack_lower_boundboot_stack_lower_bound: .space 4096 * 16.globl boot_stack_topboot_stack_top: