Rust-怎么使用rust在裸机上写代码

  1. TL;DR
    1. 下载所有依赖
    2. 创建工作目录
  2. 移除标准库
  3. 覆盖运行时环境
  4. 编译为目标平台代码
  5. 调整内存布局
  6. 编写入口函数
  7. 生成内核镜像
  8. 启动内核

TL;DR

在Ubuntu 22.04 LTS平台进行实验

下载所有依赖

  • 首先确保安装了rust, 可以按照官网用一行命令下载.

  • 下载所有依赖:

    sudo apt install -y pkg-config libglib2.0-dev libpixman-1-dev
    
  • 从源码开始编译并下载qemu:

    wget https://download.qemu.org/qemu-5.0.0.tar.xz
    
    tar xvJf qemu-5.0.0.tar.xz
    
    cd qemu-5.0.0
    
    ./configure --target-list=riscv32-softmmu,riscv64-softmmu
    
    make -j$(nproc)
    
    sudo make install
    

创建工作目录

在工作目录中创建一个文件rust-toolchain , 并且写入:

nightly-2020-06-27

使用cargo创建新的工程:

cargo new os

运行如下命令安装rust项目依赖:

cargo install cargo-binutils
rustup component add llvm-tools-preview
rustup target add riscv64gc-unknown-none-elf

移除标准库

rust中, 项目默认链接到rust的标准库std, 而std依赖于特定的OS, 如果在裸机上写代码需要移除标准库依赖.

main.rs中, 加入#![no_std]:

#![no_std]

fn main() {
    println!("Hello, world!");
}

然后运行cargo run, 会出现以下错误:

error: cannot find macro `println` in this scope
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`

其中, 第一个错误是因为没了标准库, 就没有了println这个宏, 直接把println删除就可以.

第二个错误是因为: rust中需要一个函数作为panic_handler, panic_handler在程序发生panic后执行, 也是标准库中的函数. 我们需要在main.rs中实现:

// main.rs

// core这个库不依赖于标准库和OS
use core::panic::PanicInfo

// ! 表示函数从不返回
#[panic_handler]
// PanicInfo包含了panic发生的一些信息, 例如文件名, 代码行数以及错误信息
fn panic(_info: &PanicInfo) -> ! {

}

之后, 第三个eh_personality是用来标记堆栈展开(Stack Unwinding)函数的语义项:

堆栈展开:

  • 当程序出现异常时, 一般需要记录堆栈信息, 这就需要沿着最里层的函数一步步向上收集信息.
  • 每到一层函数调用, 我都调用需要这个堆栈展开函数来处理信息, 收集完信息后, 调用panic函数打印有效信息给用户.
  • 这个函数在标准库中实现, 并且依赖于OS, 在这里直接在Cargo.toml进行配置, 要求出现错误直接调panic终止程序即可, 不要先进行Stack Unwinding然后再调panic终止.
...

# panic 时直接终止,因为我们没有实现堆栈展开的功能
[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

覆盖运行时环境

一个链接了标准库std的rust程序的运行过程是这样的:

  • 跳转到C语言运行时环境的入口crt0 (C Runtime Zero)的_start函数, 开始配置C语言运行的堆栈/寄存器等参数.
  • 然后跳转到rust运行时环境的入口start, 配置rust的运行时环境.
  • 然后跳转到rust的main函数运行.

因此, 需要覆盖C语言的运行时环境_start以及rust的运行时环境, main.rs中的内容是:

// 禁用标准库
#![no_std]

// 不使用main作为入口点
#![no_main]

// panic_handler

#[panic_handler]
fn panic(_info: &PanicInfo) {
  loop {}
}

// 覆盖crt0的_start函数
// no_mangle表示禁用rust编译器的名称调整(Name Mangling), 保证最终生成的函数一定叫_start
// pub extern "C"表示这个定义的函数可以在外面被C语言进行调用
#[no_mangle]
pub extern "C" fn _start() -> ! {
  loop {}
}

编译为目标平台代码

rust使用一个叫做Target triple的字符串来描述不同的平台, 可以使用rustc --version --verbose查看:

rustc 1.70.0 (90c541806 2023-05-31) (built from a source tarball)
binary: rustc
commit-hash: 90c541806f23a127002de5b4038be731ba1458ca
commit-date: 2023-05-31
host: aarch64-apple-darwin
release: 1.70.0
LLVM version: 16.0.2

其中的host就是这个Target triple, 它的格式是: arch-vendor-os-ABI.

rust编译生成的代码会自动适配这个平台, 我们的平台是riscv64gc-unknown-none-elf, 其中none表示没有OS.

os文件夹中新建一个.cargo文件夹, 创建一个config文件, 写入编译的目标平台:

# os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"

调整内存布局

对接的目标平台是qemu-virt, 其中DRAM的起始地址是0x80000000, 大小是128MB.

OS代码的起始地址是0x80200000, 因为程序首先要在M态运行OpenSBI, 然后才会切换到S态, 并且跳转到0x80200000并且运行OS代码.

一种典型的程序内存布局如下:

memlayout

  • .text段: 存放汇编代码
  • .rodata段: 只读数据段, 一般存储程序中的常量.
  • .data段: 存储初始化的全局变量.
  • .bss段: 存储被初始化为0的课读写数据, 与.data段不同, 由于知道了这个段中的数据会被初始化为0, 在可执行文件中只需要记录这个段的大小, 以及位置, 而不用记录数据, 因此这个段一般不占用内存.

链接脚本如下:


OUTPUT_ARCH(riscv)


ENTRY(_start)


BASE_ADDRESS = 0x80200000;

SECTIONS
{
  . = BASE_ADDRESS;


  kernel_start = .;


  text_start = .;

  .text : {
    *(.text.entry)
    *(.text .text.*)
  }

  rodata_start = .;

  .rodata : {
    *(.rodata .rodata.*)
  }

  data_start = .;

  .data : {
    *(.data .data.*)
  }

  .bss_start = .;

  .bss : {
    *(.sbss .bss .bss.*)
  }

  kernel_end = .;
}

然后在os/.cargo/config中添加配置, 使用自己的链接脚本:

# 使用我们的 linker script 来进行链接
[target.riscv64gc-unknown-none-elf]
rustflags = [
    "-C", "link-arg=-Tsrc/linker.ld",
]

编写入口函数

假设入口函数文件叫entry.S:

.section .text.entry

.globl _start

_start:
    # 将sp指向栈顶
    la sp, boot_stack_top
    call rust_main


.section .bss.stack

.globl boot_stack
boot_stack:
    # 16KB的启动栈(4096 x 4 Bytes)
    .space 4096 * 16
# 栈顶
.globl boot_stack_top
boot_stack_top:

然后在main.rs中添加如下代码:

#![no_std]
#![no_main]

// 用来支持内联汇编
#![feature(llvm_asm)]

// 用来内嵌整个汇编文件
#![feature(global_asm)]

// 汇编入口
global_asm!(include_str!("entry.S"));

use core::panic::PanicInfo;


#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

// 向屏幕输出一个字符
pub fn console_putchar(ch: u8) {
    let _ret: usize;
    let arg0: usize = ch as usize;
    let arg1: usize = 0;
    let arg2: usize = 0;
    let which: usize = 1;
    unsafe {
        llvm_asm!("ecall"
             : "={x10}" (_ret)
             : "{x10}" (arg0), "{x11}" (arg1), "{x12}" (arg2), "{x17}" (which)
             : "memory"
             : "volatile"
        );
    }
}

// rust入口函数
#[no_mangle]
pub extern "C" fn rust_main() -> ! {
    // 在屏幕上输出 "OK\n" ,随后进入死循环
    console_putchar(b'O');
    console_putchar(b'K');
    console_putchar(b'\n');

    loop {}
}

生成内核镜像

做好上述准备后, 可以通过cargo build生成二进制elf文件.

生成的文件在target/riscv64gc-unknown-none-elf/debug/os中.

然后去除elf文件中符号表等额外信息, 生成bin文件:

rust-objcopy os --strip-all -O binary kernel.bin

启动内核

生成内核镜像后, 使用如下命令启动内核:

qemu-system-riscv64 \
    -machine virt \
    -nographic \
    -bios default \
    -device loader,file=kernel.bin,addr=0x80200000