第二讲 实践与实验介绍

第四节 实践:裸机程序 -- LibOS

向勇 陈渝 李国良 任炬

2024年春季


提纲

1. 实验目标和思路

  1. 实验要求
  2. 实践步骤
  3. 代码结构
  4. 内存布局
  5. 基于 GDB 验证启动流程
  6. 函数调用
  7. LibOS初始化
  8. SBI调用

LibOS的实验目标

裸机程序(Bare Metal Program ):与操作系统无关的OS类型的程序

  • 建立应用程序的执行环境
    • 让应用与硬件隔离
    • 简化应用访问硬件的难度和复杂性
  • 执行环境(Execution Environment):负责给在其上执行的软件提供相应的功能与资源的多层次软硬件系统

bg right:35% 100%


LibOS历史背景

1949-1951 年,英国 J. Lyons and Co. 公司(连锁餐厅和食品制造集团公司)开创性地引入并使用剑桥大学的 EDSAC 计算机,联合设计实现了 LEO I ‘Lyons Electronic Office’ 软硬件系统

bg right:50% 70%


LibOS历史背景 -- 子程序

  • 参与 EDSAC 项目的 David Wheeler 发明了子程序的概念 – Wheeler Jump
  • 基于便捷有效的子程序概念和子程序调用机制,软件开发人员在EDSAC和后续的LEO计算机上开发了大量的系统子程序库,形成了最早的操作系统原型。

bg right:50% 70%


LibOS总体思路

  • 编译:通过设置编译器支持编译裸机程序
  • 构造:建立裸机程序的栈和SBI(Supervisor Binary Interface)服务请求接口
  • 运行:OS的起始地址和执行环境初始化

bg right:50% 100%


提纲

  1. 实验目标和思路

2. 实验要求

  1. 实践步骤
  2. 代码结构
  3. 内存布局
  4. 基于 GDB 验证启动流程
  5. 函数调用
  6. LibOS初始化
  7. SBI调用

理解LibOS的执行过程

  • 会编写/编译/运行裸机程序
  • 懂基于裸机程序的函数调用
  • 能看懂汇编代码伪代码
  • 能看懂内嵌汇编代码
  • 初步理解SBI调用 bg right:50% 90%

掌握基本概念

  • 会写三叶虫操作系统了!
    • ABI是啥? Application Binary Interface
    • SBI是啥? Supervisor Binary Interface bg right:50% 90%

注:三叶虫Trilobita是寒武纪最有代表性的远古动物


分析执行细节

  • 在机器级层面理解函数
    • 寄存器(registers)
    • 函数调用/返回(call/return)
    • 函数进入/离开(enter/exit)
    • 函数序言/收尾(prologue/epilogue)

bg right:50% 90%


OS不总是软件的最底层

  • 天外有天
  • Why?

bg right:50% 90%


提纲

  1. 实验目标和思路
  2. 实验要求

3. 实践步骤

  1. 代码结构
  2. 内存布局
  3. 基于 GDB 验证启动流程
  4. 函数调用
  5. LibOS初始化
  6. SBI调用

实践步骤

  • 建好开发与实验环境
  • 移出标准库依赖
  • 支持函数调用
  • 基于SBI服务完成输出与关机

理解运行程序的内存空间和栈

bg right:50% 100%


操作步骤

git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
cd rCore-Tutorial-v3
git checkout ch1

cd os
make run

执行结果

[RustSBI output]
Hello, world!
.text [0x80200000, 0x80202000)
.rodata [0x80202000, 0x80203000)
.data [0x80203000, 0x80203000)
boot_stack [0x80203000, 0x80213000)
.bss [0x80213000, 0x80213000)
Panicked at src/main.rs:46 Shutdown machine!

除了显示 Hello, world! 之外还有一些额外的信息,最后关机。


提纲

  1. 实验目标和思路
  2. 实验要求
  3. 实践步骤

4. 代码结构

  1. 内存布局
  2. 基于 GDB 验证启动流程
  3. 函数调用
  4. LibOS初始化
  5. SBI调用

bg right:55% 100%


LibOS代码结构

./os/src
Rust        4 Files   119 Lines
Assembly    1 Files    11 Lines

├── bootloader(内核依赖的运行在 M 特权级的 SBI 实现,本项目中我们使用 RustSBI)
│   ├── rustsbi-k210.bin(可运行在 k210 真实硬件平台上的预编译二进制版本)
│   └── rustsbi-qemu.bin(可运行在 qemu 虚拟机上的预编译二进制版本)

LibOS代码结构

├── os(我们的内核实现放在 os 目录下)
│   ├── Cargo.toml(内核实现的一些配置文件)
│   ├── Makefile
│   └── src(所有内核的源代码放在 os/src 目录下)
│       ├── console.rs(将打印字符的 SBI 接口进一步封装实现更加强大的格式化输出)
│       ├── entry.asm(设置内核执行环境的的一段汇编代码)
│       ├── lang_items.rs(需要我们提供给 Rust 编译器的一些语义项,目前包含内核 panic 时的处理逻辑)
│       ├── linker-qemu.ld(控制内核内存布局的链接脚本以使内核运行在 qemu 虚拟机上)
│       ├── main.rs(内核主函数)
│       └── sbi.rs(调用底层 SBI 实现提供的 SBI 接口)

提纲

  1. 实验目标和思路
  2. 实验要求
  3. 实践步骤
  4. 代码结构

5. 内存布局

  1. 基于 GDB 验证启动流程
  2. 函数调用
  3. LibOS初始化
  4. SBI调用

App/OS内存布局

bg w:1000


bss段

  • bss段(bss segment)通常是指用来存放程序中未初始化的全局变量的一块内存区域
  • bss是英文Block Started by Symbol的简称
  • bss段属于静态内存分配 bg right:55% 130%

data段

  • 数据段(data segment)通常是指用来存放程序中已初始化的全局变量的一块内存区域
  • 数据段属于静态内存分配 bg right:50% 130%

text段

  • 代码段(code segment/text segment)是指存放执行代码的内存区域
  • 这部分区域的大小确定,通常属于只读
  • 在代码段中,也有可能包含一些只读的常数变量 bg right:50% 130%

堆(heap)

  • 堆是用于动态分配的内存段,可动态扩张或缩减
  • 程序调用malloc等函数新分配的内存被动态添加到堆上
  • 调用free等函数释放的内存从堆中被剔除 bg right:50% 130%

栈(stack)

  • 栈又称堆栈,是用户存放程序临时创建的局部变量
  • 函数被调用时,其参数和函数的返回值也会放到栈中
  • 由于栈的先进后出特点,所以栈特别方便用来保存/恢复当前执行状态

bg right:50% 130%


栈(stack)

可以把堆栈看成一个寄存和交换临时数据的内存区

OS编程与应用编程的一个显著区别是,OS编程需要理解栈上的物理内存结构机器级内容(相关寄存器和指令)

bg right:50% 130%


链接时的内存布局定制

# os/src/linker-qemu.ld
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80200000;

SECTIONS
{
    . = BASE_ADDRESS;
    skernel = .;

    stext = .;
    .text : {
      *(.text.entry)

bg right:50% 130%


链接时的内存布局定制

    .bss : {
        *(.bss.stack)
        sbss = .;
        *(.bss .bss.*)
        *(.sbss .sbss.*)

BSS:Block Started by Symbol SBSS:small bss,近数据,即使用短指针(near)寻址的数据

bg right:50% 130%


生成内核二进制镜像

bg w:1020


生成内核二进制镜像

rust-objcopy --strip-all \
target/riscv64gc-unknown-none-elf/release/os \
-O binary target/riscv64gc-unknown-none-elf/release/os.bin

提纲

  1. 实验目标和思路
  2. 实验要求
  3. 实践步骤
  4. 代码结构
  5. 内存布局

6. 基于 GDB 验证启动流程

  1. 函数调用
  2. LibOS初始化
  3. SBI调用

基于 GDB 验证启动流程

qemu-system-riscv64 \
    -machine virt \
    -nographic \
    -bios ../bootloader/rustsbi-qemu.bin \
    -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 \
    -s -S
riscv64-unknown-elf-gdb \
    -ex 'file target/riscv64gc-unknown-none-elf/release/os' \
    -ex 'set arch riscv:rv64' \
    -ex 'target remote localhost:1234'
[GDB output]
0x0000000000001000 in ?? ()

提纲

  1. 实验目标和思路
  2. 实验要求
  3. 实践步骤
  4. 代码结构
  5. 内存布局
  6. 基于 GDB 验证启动流程

7. 函数调用

  1. LibOS初始化
  2. SBI调用

编译原理对函数调用的支持

bg right:60% 90%


call/return伪指令

伪指令基本指令含义
retjalr x0, x1, 0函数返回
call offsetauipc x6, offset[31:12]; jalr x1, x6, offset[11:0]函数调用

auipc(add upper immediate to pc)被用来构建 PC 相对的地址,使用的是 U 型立即数。 auipc 以低 12 位补 0,高 20 位是 U 型立即数的方式形成 32 位偏移量,然后和 PC 相加,最后把结果保存在寄存器 x1。


函数调用跳转指令

w:1000

伪指令 ret 翻译为 jalr x0, 0(x1),含义为跳转到寄存器 ra(即x1)保存的地址。 快速入门RISC-V汇编的文档


call/return伪指令

伪指令基本指令含义
retjalr x0, x1, 0函数返回
call offsetauipc x6, offset[31:12]; jalr x1, x6, offset[11:0]函数调用

函数调用核心机制:

  • 在函数调用时,通过 call 伪指令保存返回地址并实现跳转;
  • 在函数返回时,通过 ret 伪指令回到跳转之前的下一条指令继续执行

函数调用约定

函数调用约定 (Calling Convention) 约定在某个指令集架构上,某种编程语言的函数调用如何实现。它包括了以下内容:

  • 函数的输入参数和返回值如何传递;
  • 函数调用上下文中调用者/被调用者保存寄存器的划分;
  • 其他的在函数调用流程中对于寄存器的使用方法。

RISC-V函数调用约定:调用参数和返回值传递

w:1200

  • RISC-V32:如果返回值64bit,则用a0~a1来放置
  • RISC-V64:如果返回值64bit,则用a0来放置

RISC-V函数调用约定:栈帧

w:1000


RISC-V函数调用约定:栈帧

栈帧(Stack Frames)

return address *
previous fp
saved registers
local variables
…
return address fp register
previous fp (pointed to *)
saved registers
local variables
… sp register

bg right:50% 180%


RISC-V函数调用约定:栈帧

  • 堆栈帧可能有不同的大小和内容,但总体结构是类似的
  • 每个堆栈帧始于这个函数的返回值前一个函数的fp值
  • sp 寄存器总是指向当前堆栈框架的底部
  • fp 寄存器总是指向当前堆栈框架的顶部

bg right:50% 180%


RISC-V函数调用约定:ret指令

  • 当 ret 指令执行,下面的伪代码实现调整堆栈指针和PC:
pc = return address
sp = sp + ENTRY_SIZE
fp = previous fp

bg right:50% 180%


RISC-V函数调用约定:函数结构

函数结构组成:prologue,body partepilogue

  • Prologue序言的目的是为了保存程序的执行状态(保存返回地址寄存器和堆栈寄存器FP)
  • Epilogue尾声的目的是在执行函数体之后恢复到之前的执行状态(跳转到之前存储的返回地址以及恢复之前保存FP寄存器)。

RISC-V函数调用约定:函数结构

函数结构组成:prologue,body partepilogue

.global sum_then_double
sum_then_double:
	addi sp, sp, -16		# prologue
	sd ra, 0(sp)			
	
	call sum_to                     # body part 
	li t0, 2
	mul a0, a0, t0
	
	ld ra, 0(sp)			# epilogue
	addi sp, sp, 16
	ret

RISC-V函数调用约定:函数结构

函数结构组成:prologue,body partepilogue

.global sum_then_double
sum_then_double:		
	
	call sum_to                     # body part 
	li t0, 2
	mul a0, a0, t0
	
	ret

Q:上述代码的执行与前一页的代码执行相比有何不同?


提纲

  1. 实验目标和思路
  2. 实验要求
  3. 实践步骤
  4. 代码结构
  5. 内存布局
  6. 基于 GDB 验证启动流程
  7. 函数调用

8. LibOS初始化

  1. SBI调用

分配并使用启动栈

分配并使用启动栈 快速入门RISC-V汇编的文档

# os/src/entry.asm
    .section .text.entry
    .globl _start
_start:
    la sp, boot_stack_top
    call rust_main

    .section .bss.stack
    .globl boot_stack
boot_stack:
    .space 4096 * 16
    .globl boot_stack_top
boot_stack_top:

分配并使用启动栈

# os/src/linker-qemu.ld
.bss : {
    *(.bss.stack)
    sbss = .;
    *(.bss .bss.*)
    *(.sbss .sbss.*)
}
ebss = .;

在链接脚本 linker.ld 中 .bss.stack 段最终会被汇集到 .bss 段中 .bss 段一般放置需要被初始化为零的数据


控制权转交:ASM --> Rust/C

将控制权转交给 Rust 代码,该入口点在 main.rs 中的rust_main函数

#![allow(unused)]
fn main() {
// os/src/main.rs  
pub fn rust_main() -> ! {
    loop {}
}
}
  • fn 关键字:函数; pub 关键字:对外可见,公共的
  • loop 关键字:循环

清空bss段

清空bss段(未初始化数据段)

pub fn rust_main() -> ! {
    clear_bss(); //调用清空bss的函数clear_bss()
}
fn clear_bss() {
    extern "C" {
        fn sbss(); //bss段的起始地址
        fn ebss(); //bss段的结束地址
    }
    //对[sbss..ebss]这段内存空间清零
    (sbss as usize..ebss as usize).for_each(|a| {
        unsafe { (a as *mut u8).write_volatile(0) }
    });
}

提纲

  1. 实验目标和思路
  2. 实验要求
  3. 实践步骤
  4. 代码结构
  5. 内存布局
  6. 基于 GDB 验证启动流程
  7. 函数调用
  8. LibOS初始化

9. SBI调用


SBI服务接口

在屏幕上打印 Hello world!

  • SBI服务接口
    • Supervisor Binary Interface
    • 更底层的软件给操作系统提供的服务
  • RustSBI
    • 实现基本的SBI服务
    • 遵循SBI调用约定 bg right:55% 90%

SBI服务编号

#![allow(unused)]
fn main() {
// os/src/sbi.rs
const SBI_SET_TIMER: usize = 0;
const SBI_CONSOLE_PUTCHAR: usize = 1;
const SBI_CONSOLE_GETCHAR: usize = 2;
const SBI_CLEAR_IPI: usize = 3;
const SBI_SEND_IPI: usize = 4;
const SBI_REMOTE_FENCE_I: usize = 5;
const SBI_REMOTE_SFENCE_VMA: usize = 6;
const SBI_REMOTE_SFENCE_VMA_ASID: usize = 7;
const SBI_SHUTDOWN: usize = 8;
}
  • usize 机器字大小的无符号整型

汇编级SBI调用

#![allow(unused)]
fn main() {
// os/src/sbi.rs
#[inline(always)] //总是把函数展开
fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
    let mut ret; //可修改的变量ret
    unsafe {
        asm!(//内嵌汇编
            "ecall", //切换到更高特权级的机器指令
            inlateout("x10") arg0 => ret, //SBI参数0&返回值
            in("x11") arg1,  //SBI参数1
            in("x12") arg2,  //SBI参数2
            in("x17") which, //SBI编号
        );
    }
    ret //返回ret值
}
}

SBI调用:输出字符

在屏幕上输出一个字符

#![allow(unused)]
fn main() {
// os/src/sbi.rs
pub fn console_putchar(c: usize) {
    sbi_call(SBI_CONSOLE_PUTCHAR, c, 0, 0);
}
}

实现格式化输出

  • 编写基于 console_putchar 的 println! 宏

SBI调用:关机

#![allow(unused)]
fn main() {
// os/src/sbi.rs
pub fn shutdown() -> ! {
    sbi_call(SBI_SHUTDOWN, 0, 0, 0);
    panic!("It should shutdown!");
}
}
  • panic!println!是一个宏(类似C的宏),!是宏的标志

优雅地处理错误panic

#![allow(unused)]
fn main() {
#[panic_handler]
fn panic(info: &PanicInfo) -> ! { //PnaicInfo是结构类型
    if let Some(location) = info.location() { //出错位置存在否?
        println!(
            "Panicked at {}:{} {}",
            location.file(), //出错的文件名
            location.line(), //出错的文件中的行数
            info.message().unwrap() //出错信息
        );
    } else {
        println!("Panicked: {}", info.message().unwrap());
    }
    shutdown() //关机
}
}

LibOS完整功能

优雅地处理错误panic

#![allow(unused)]
fn main() {
pub fn rust_main() -> ! {
    clear_bss();
    println!("Hello, world!");
    panic!("Shutdown machine!");
}
}

运行结果

[RustSBI output]
Hello, world!
Panicked at src/main.rs:26 Shutdown machine!

小结

  • 构造各种OS的实践中需要掌握的知识点(原理&实现)
  • 理解Compiler/OS/Machine的相互关联关系
  • 知道从机器启动到应用程序打印出字符串的过程
  • 能写Trilobita OS

【总结笔记】

本节深入探讨裸机程序LibOS的实践内容,它是一种与操作系统无关的程序,专门设计来建立一个应用程序的执行环境。这样的环境能有效隔离应用和硬件,简化应用程序访问硬件的复杂性。这一过程不仅涉及到编译器的配置,还包括栈和SBI(Supervisor Binary Interface)服务请求接口的建立,以及操作系统的启动地址和执行环境的初始化。

在探讨LibOS的实践过程中,理解内存布局是关键一环。内存布局包含了多个关键部分,每个部分在程序运行时扮演着特定的角色。

实验目标和思路

LibOS实验的核心目标是建立一个裸机程序,这意味着创建一个与操作系统无关但能在硬件上直接运行的程序。这样的程序可以直接与硬件交互,无需操作系统的干预,因此是理解硬件和软件交互的极佳案例。

LibOS的历史背景

LibOS的概念不是全新的。它可以追溯到1949-1951年间,当时英国的J. Lyons and Co.公司与剑桥大学合作,使用EDSAC计算机开发了LEO I,这是世界上第一台用于商业的计算机系统。在这个过程中,David Wheeler发明了“子程序”的概念,即所谓的Wheeler Jump,这是一种早期的软件复用方法,为操作系统的发展奠定了基础。

实验要求

为了完成LibOS实验,参与者需要具备以下能力:

  • 编写、编译和运行裸机程序;
  • 理解和实现基于裸机程序的函数调用;
  • 能够阅读并理解汇编代码及内嵌汇编代码;
  • 对SBI调用有初步的理解。

实践步骤

实践步骤包括准备开发环境、移除标准库依赖、实现函数调用支持,以及利用SBI服务完成基本的输入输出和关机操作。这个过程不仅要求理论知识,还需要实际的编程技能,特别是在Rust语言和汇编语言方面。

代码结构和内存布局

LibOS的代码结构包含Rust和汇编两部分,主要涵盖了内核主函数、SBI接口的封装、栈的配置和启动入口等。而内存布局则定义了.text、.rodata、.data、.bss段以及堆和栈的安排,这对于理解程序如何在硬件上运行至关重要。

GDB验证启动流程和函数调用

使用GDB(GNU Debugger)验证LibOS的启动流程是确认一切按预期运行的重要步骤。此外,深入理解函数调用的机器级实现—包括寄存器使用、调用约定和栈帧管理—是理解程序执行的关键。

LibOS初始化和SBI调用

LibOS的初始化过程包括分配启动栈、清除bss段,以及将控制权从汇编代码转移给Rust代码。SBI调用则提供了与硬件通信的基础服务,如在控制台上打印字符和系统关机。

内存布局

关键内存段

  1. bss段:用于存储未初始化的全局变量。由于这些变量在程序开始执行前未被明确初始化,它们在物理内存中的值为零。这一部分属于静态内存分配。
  2. data段:存放已初始化的全局变量。不同于bss段,data段中的变量在程序开始执行前已被初始化,因此在物理内存中有具体的值。
  3. text段:也称为代码段,用于存放程序的执行代码。这部分内存通常设为只读,以防止程序运行时修改其自身的指令。
  4. 堆(heap):用于动态内存分配。程序运行时,可以根据需要增加或减少堆空间的大小。
  5. 栈(stack):用于存储局部变量、函数参数和返回地址等。栈具有先进后出(LIFO)的特点,非常适合于管理函数调用过程中的数据。

内存布局定制

在链接阶段,通过特定的链接脚本可以定制程序的内存布局,比如指定各个内存段的起始地址和大小。这一步骤对于嵌入式系统和操作系统开发尤为重要,因为它直接关系到程序能否正确地与硬件交互。

基于GDB验证启动流程

为了确保LibOS按照预期执行,使用GDB进行调试是必不可少的步骤。通过GDB,开发者可以单步执行程序,检查寄存器和内存的状态,从而验证程序的启动流程是否正确。这一过程有助于快速定位和修复可能存在的问题。

函数调用和LibOS初始化

在LibOS的实现中,对函数调用的处理是基本且核心的部分。这涉及到对栈的操作、寄存器的保存和恢复,以及调用约定的遵循等。LibOS的初始化则包括设置启动栈、清空bss段和转交控制权给主函数等步骤。这些都是确保程序能够正常运行的基础环节。

SBI调用

LibOS通过SBI调用与硬件交互。SBI(Supervisor Binary Interface)提供了一套服务接口,使得操作系统可以请求底层的硬件服务,如输出字符到控制台、设置定时器和关机等。这些操作对于一个操作系统来说至关重要,因为它们构成了系统对硬件操作的基础。

总的来说,通过LibOS的开发和实践,参与者不仅能够深入理解操作系统的内部工作原理,还能学习到如何在裸机上编程,以及如何通过底层接口与硬件进行交互。这些知识和技能对于深入掌握计算机科学的基本原理和提高系统编程能力都是极为宝贵的。