跳转到内容

第一章

Terminal window
# 文件格式
$ file target/riscv64gc-unknown-none-elf/debug/os
target/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 等等


这一节的目标是解释 内核在真实硬件或模拟器上是如何启动,并执行第一条指令的。我们从硬件和程序如何到达那条指令的流程开始讲。

任何计算机都由三部分组成:

  • CPU(处理器):按顺序读取、解释并执行指令。
  • 物理内存(RAM):存放指令和数据。
  • 外设(I/O 设备):如键盘、显示器等。

CPU 访问内存的方式类似于读取一个大数组里的元素。对于多数架构(例如 RISC-V),数据访问要满足对齐规则,否则会出错。


在这本教程中,我们使用 QEMU-system-riscv64 模拟一个 RISC-V 机器

当 QEMU 启动时,它会:

  1. 分配物理内存空间。
  2. 把 bootloader(二进制文件)加载到内存起始位置
  3. 把我们编译好的 内核镜像(os.bin)加载到指定的地址

CPU 启动后:

  • PC(程序计数器)最初会指向一个固定地址(如 0x1000)。
  • 这里其实是 QEMU 内部的一段引导代码,会跳转到 bootloader 开始地址。
  • bootloader 初始化硬件之后,再跳转到 我们内核第一条真正要执行的指令

为了让内核能真正运行:

  • 内核必须被 加载到正确的位置
  • 内核编译产出的文件不能直接作为镜像用,需要调整布局,确保第一条指令位置正确。
  • 在模拟器里,只有当 PC 跳到这个位置时,我们的内核代码才开始执行。

一个程序在内存中不是一堆乱七八糟的字节,它分成几个部分:

  • 代码部分(.text):CPU 要执行的指令。
  • 已初始化数据(.data 和 .rodata)。
  • 未初始化数据(.bss):运行时由系统清 0。
  • 堆(heap)和栈(stack):运行时使用的内存。

当我们编译内核时:

  1. 编译器把 Rust 代码变成汇编。
  2. 汇编器生成机器代码。
  3. 链接器将各部分组合成一个最终文件。
  4. 最后的文件要经过调整才能当成真正内核镜像。

这一节的目标是解释 为什么内核需要支持函数调用,以及如何让函数调用在 RISC‑V 上正确运行。我们已经成功执行了内核的第一个指令(用汇编写的),接下来希望把控制权交给用 Rust 语言编写的内核代码。为此必须做一些准备工作,包括设置 ,让函数调用和返回在内核中能够正常工作。


当程序调用一个函数时,CPU 必须:

  1. 跳转到被调用函数的位置执行它的代码;
  2. 等函数执行完毕后 返回到调用点的下一条指令继续执行

前者跳转地址是固定的,但是 返回的地址只有在运行时才知道。对函数调用来说,需要一种机制来保存返回地址,使 CPU 知道从哪里回来继续执行。


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)


如果只有简单调用,那么用 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 等寄存器不是函数调用上下文的一部分。


函数调用使用栈来保存寄存器。栈是一个从高地址向低地址增长的数据区:

  • 当函数调用时,分配一块区域来保存当前状态,这叫 栈帧
  • 函数返回时,再把这块区域释放。

栈帧里通常保存寄存器、局部变量等内容。编译器会自动在函数开头生成 Prologue(开场保存寄存器) 和在函数返回前生成 Epilogue(恢复寄存器)


entry.asm 里,我们设置了启动栈:

.section .text.entry
.globl _start
_start:
la sp, boot_stack_top
call rust_main
.section .bss.stack
.globl boot_stack_lower_bound
boot_stack_lower_bound:
.space 4096 * 16
.globl boot_stack_top
boot_stack_top: