Dunkleosteus OS

个人理解:本实验是通过特权级的隔离分成了整体三大部分,bootloader、OS、App。下层对上层提供支持,分别为SBI和ABI的支持。可以理解为层层封装。


在这里插入图片描述

😃 本系统在上个系统的基础上将应用程序与系统实现了隔离。将多个程序打包到一起输入计算机,当一个程序运行结束后,计算机会自动执行下一个程序。

【总体思路】

  • 构造包含OS和多个APP的单一执行镜像
  • 通过批处理支持多个APP的自动加载和运行
  • 利用硬件特权级机制实现对操作系统自身的保护
  • 支持跨特权级的syscall
  • 实现特权级的穿越

【代码架构】

── os2
│   ├── Cargo.toml
│   ├── Makefile (修改:构建内核之前先构建应用)
│   ├── build.rs (新增:生成 link_app.S 将应用作为一个数据段链接到内核)
│   └── src
│       ├── batch.rs(新增:实现了一个简单的批处理系统)
│       ├── console.rs
│       ├── entry.asm
│       ├── lang_items.rs
│       ├── link_app.S(构建产物,由 os/build.rs 输出)
│       ├── linker.ld
│       ├── logging.rs
│       ├── main.rs(修改:主函数中需要初始化 Trap 处理并加载和执行应用)
│       ├── sbi.rs
│       ├── sync(新增:包装了RefCell,暂时不用关心)
│       │   ├── mod.rs
│       │   └── up.rs
│       ├── syscall(新增:系统调用子模块 syscall)
│       │   ├── fs.rs(包含文件 I/O 相关的 syscall)
│       │   ├── mod.rs(提供 syscall 方法根据 syscall ID 进行分发处理)
│       │   └── process.rs(包含任务处理相关的 syscall)
│       └── trap(新增:Trap 相关子模块 trap)
│           ├── context.rs(包含 Trap 上下文 TrapContext)
│           ├── mod.rs(包含 Trap 处理入口 trap_handler)
│           └── trap.S(包含 Trap 上下文保存与恢复的汇编代码)
└── user(新增:应用测例保存在 user 目录下)
   ├── Cargo.toml
   ├── Makefile
   └── src
      ├── bin(基于用户库 user_lib 开发的应用,每个应用放在一个源文件中)
      │   ├── ...
      ├── console.rs
      ├── lang_items.rs
      ├── lib.rs(用户库 user_lib)
      ├── linker.ld(应用的链接脚本)
      └── syscall.rs(包含 syscall 方法生成实际用于系统调用的汇编指令,
                     各个具体的 syscall 都是通过 syscall 来实现的)

【实现应用程序】

😃 新增加三个最终放进OS中执行的APP

在根目录下新建 user/src/bin 目录,该目录主要存放的是一个个 APP 。存进去三个APP,分别为:

  • hello_world.rs:打印输出字符串

    #![no_std]
    #![no_main]
    
    #[macro_use]
    extern crate user_lib;
    
    /// 正确输出:
    /// Hello world from user mode program!
    
    #[no_mangle]
    fn main() -> i32 {
        println!("Hello, world from user mode program!");
        0
    }
    
  • bad_address.rs:访问一个非法的物理地址,测试批处理系统是否会被该错误影响

    #![no_std]
    #![no_main]
    
    extern crate user_lib;
    
    /// 由于 rustsbi 的问题,该程序无法正确退出
    /// > rustsbi 0.2.0-alpha.1 已经修复,可以正常退出
    
    #[no_mangle]
    pub fn main() -> isize {
        unsafe {
            #[allow(clippy::zero_ptr)]
            (0x0 as *mut u8).write_volatile(0);
        }
        panic!("FAIL: T.T\n");
    }
    
  • power_3.rs:不断在计算操作和打印字符串操作之间切换

    #![no_std]
    #![no_main]
    
    #[macro_use]
    extern crate user_lib;
    
    const LEN: usize = 100;
    
    #[no_mangle]
    fn main() -> i32 {
        let p = 3u64;
        let m = 998244353u64;
        let iter: usize = 200000;
        let mut s = [0u64; LEN];
        let mut cur = 0usize;
        s[cur] = 1;
        for i in 1..=iter {
            let next = if cur + 1 == LEN { 0 } else { cur + 1 };
            s[next] = s[cur] * p % m;
            cur = next;
            if i % 10000 == 0 {
                println!("power_3 [{}/{}]", i, iter);
            }
        }
        println!("{}^{} = {}(MOD {})", p, iter, s[cur], m);
        println!("Test power_3 OK!");
        0
    }
    
😃 为三个 APP 增加内部所使用的函数库,类似于其他语言的标准库。

user/src 目录下增加外部标准库:

  • console.rs:内部实现了println宏等函数,与Trilobita OS一样。

  • lang_items.rs:内部实现了panic_handler的实现,与Trilobita OS一样。

  • syscall.rs:内部通过 ecall 内嵌汇编的形式实现了系统调用的封装。 由于应用程序运行在用户态(即 U 模式), ecall 指令会触发名为 Environment call from U-mode 的异常, 并 Trap 进入 S 模式执行批处理系统针对这个异常特别提供的服务程序。 这个接口被称为 ABI 或者系统调用。这个ecall是从U态到S态的调用

    use crate::TaskInfo;
    
    use super::{Stat, TimeVal};
    
    pub const SYSCALL_WRITE: usize = 64;
    pub const SYSCALL_EXIT: usize = 93;
    
    pub fn syscall(id: usize, args: [usize; 3]) -> isize {
        let mut ret: isize;
        unsafe {
            core::arch::asm!(
                "ecall",
                inlateout("x10") args[0] => ret,
                in("x11") args[1],
                in("x12") args[2],
                in("x17") id
            );
        }
        ret
    }
    
    pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
        syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
    }
    
    pub fn sys_exit(exit_code: i32) -> ! {
        syscall(SYSCALL_EXIT, [exit_code as usize, 0, 0]);
        panic!("sys_exit never returns!");
    }
    
    
  • lib.rs

    #![no_std]
    #![feature(linkage)]
    #![feature(panic_info_message)]
    
    #[macro_use]
    pub mod console;
    mod lang_items;
    mod syscall;
    
    extern crate core;
    
    #[macro_use]
    pub use syscall::*;
    
    fn clear_bss() {
        extern "C" {
            fn start_bss();
            fn end_bss();
        }
        unsafe {
            core::slice::from_raw_parts_mut(
                start_bss as usize as *mut u8,
                end_bss as usize - start_bss as usize,
            )
            .fill(0);
        }
    }
    
    #[no_mangle]
    #[link_section = ".text.entry"]
    pub extern "C" fn _start() -> ! {
        clear_bss();
        exit(main());
    }
    
    #[linkage = "weak"]
    #[no_mangle]
    fn main() -> i32 {
        panic!("Cannot find main!");
    }
    
    pub fn write(fd: usize, buf: &[u8]) -> isize {
        sys_write(fd, buf)
    }
    
    pub fn exit(exit_code: i32) -> ! {
        sys_exit(exit_code);
    }
    
    • 前面几行调用了外部的库,使得内部的APP可以使用外部的库。
    • 第 15 - 27 行:实现了clear_bss()函数,清除bss段。
    • 第 30 行:link_section 宏将 _start 函数编译后的汇编代码放在名为 .text.entry 的代码段中, 方便用户库链接脚本将它作为用户程序的入口。
    • 第 33 行:调用 main 函数得到一个类型为 i32 的返回值, 最后,调用用户库提供的 exit 接口退出,并将返回值告知批处理系统。
    • 第 36 行:启用弱链接,这样在最后链接的时候,虽然 lib.rsbin 目录下的某个应用程序中都有 main 符号, 但由于 lib.rs 中的 main 符号是弱链接, 链接器会使用 bin 目录下的函数作为 main 。 如果在 bin 目录下找不到任何 main ,那么编译也能通过,但会在运行时报错。
    • 第 42 - 44 行:封装sys_write调用。
    • 第 46 - 48 行:封装exit调用。
😃 增加内存布局的设置,使得按照我们的需求在指定位置运行三个APP

user/src 目录下增加 linker.ld 链接脚本

OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80400000;

SECTIONS
{
    . = BASE_ADDRESS;
    .text : {
        *(.text.entry)
        *(.text .text.*)
    }
    . = ALIGN(4K);
    .rodata : {
        *(.rodata .rodata.*)
        *(.srodata .srodata.*)
    }
    . = ALIGN(4K);
    .data : {
        *(.data .data.*)
        *(.sdata .sdata.*)
    }
    .bss : {
        start_bss = .;
        *(.bss .bss.*)
        *(.sbss .sbss.*)
        end_bss = .;
    }
    /DISCARD/ : {
        *(.eh_frame)
        *(.debug*)
    }
}
  • 第 1 行:我们设置了目标平台为 riscv
  • 第 2 行:我们设置了整个程序的入口点为 _start
  • 第 3 行:定义了一个常量 BASE_ADDRESS0x80400000,将程序的起始物理地址调整为 0x80400000 ,三个应用程序都会被加载到这个物理地址上运行;
  • 第 8 行:将 _start 所在的 .text.entry 放在整个程序的开头 0x80400000; 批处理系统在加载应用后,跳转到 0x80400000,就进入了用户库的 _start 函数;
😃 增加Python文件脚本对整体文件进行编译
import os

base_address = 0x80400000
step = 0x20000
linker = "src/linker.ld"

app_id = 0
apps = os.listdir("build/app")
apps.sort()
chapter = os.getenv("CHAPTER")

for app in apps:
    app = app[: app.find(".")]
    os.system(
        "cargo rustc --bin %s --release -- -Clink-args=-Ttext=%x"
        % (app, base_address + step * app_id)
    )
    print(
        "[build.py] application %s start with address %s"
        % (app, hex(base_address + step * app_id))
    )
    if chapter == '3':
        app_id = app_id + 1
  • 第 8 行:os.listdir() 方法用于返回指定的文件夹包含的文件或文件夹的名字的列表。
  • 第 9 行:对文件的列表进行排序
  • 第 10 行:返回环境变量键的值,在这里就是返回 CHAPTER 这个 KeyValue
  • 第 13 行:取得app这个文件的名字
  • 第 14 行:system函数可以将字符串转化成命令在服务器上运行;其原理是每一条system函数执行时,其会创建一个子进程在系统上执行命令行,子进程的执行结果无法影响主进程。
  • 第 15 - 16 行:执行 cargo rustc --bin %s --release -- -Clink-args=-Ttext=%x 系统命令,可以举个例子,假设 hello_world 为内部运行的第一个函数,则运行 cargo rustc --bin hello_world --release – -Clink-args=-Ttext=0x80420000
    • cargo rustc [OPTIONS] [–] [args]…
    • cargo rustc --bin:针对这个目录生成二进制文件
    • cargo rustc --release:生成的target文件夹下不再有debug目录,替代的是release目录
    • -Clink-args=-Ttext=0x80420000:使用我们的链接脚本,-Ttext链接时将初始地址重定向为0x80420000。

这个python应用主要是将build/app下的文件生成bin文件

😃 编写Makefile文件进行编译整个文件
TARGET := riscv64gc-unknown-none-elf
MODE := release
APP_DIR := src/bin
TARGET_DIR := target/$(TARGET)/$(MODE)
BUILD_DIR := build
OBJDUMP := rust-objdump --arch-name=riscv64
OBJCOPY := rust-objcopy --binary-architecture=riscv64
PY := python3

BASE ?= 0
CHAPTER ?= 0
TEST ?= $(CHAPTER)

ifeq ($(TEST), 0) # No test, deprecated, previously used in v3
	APPS :=  $(filter-out $(wildcard $(APP_DIR)/ch*.rs), $(wildcard $(APP_DIR)/*.rs))
else ifeq ($(TEST), 1) # All test
	APPS :=  $(wildcard $(APP_DIR)/ch*.rs)
else
	TESTS := $(shell seq $(BASE) $(TEST))
	ifeq ($(BASE), 0) # Normal tests only
		APPS := $(foreach T, $(TESTS), $(wildcard $(APP_DIR)/ch$(T)_*.rs))
	else ifeq ($(BASE), 1) # Basic tests only
		APPS := $(foreach T, $(TESTS), $(wildcard $(APP_DIR)/ch$(T)b_*.rs))
	else # Basic and normal
		APPS := $(foreach T, $(TESTS), $(wildcard $(APP_DIR)/ch$(T)*.rs))
	endif
endif

ELFS := $(patsubst $(APP_DIR)/%.rs, $(TARGET_DIR)/%, $(APPS))

binary:
	@echo $(ELFS)
	@if [ ${CHAPTER} -gt 3 ]; then \
		cargo build --release ;\
	else \
		CHAPTER=$(CHAPTER) python3 build.py ;\
	fi
	@$(foreach elf, $(ELFS), \
		$(OBJCOPY) $(elf) --strip-all -O binary $(patsubst $(TARGET_DIR)/%, $(TARGET_DIR)/%.bin, $(elf)); \
		cp $(elf) $(patsubst $(TARGET_DIR)/%, $(TARGET_DIR)/%.elf, $(elf));)

disasm:
	@$(foreach elf, $(ELFS), \
		$(OBJDUMP) $(elf) -S > $(patsubst $(TARGET_DIR)/%, $(TARGET_DIR)/%.asm, $(elf));)
	@$(foreach t, $(ELFS), cp $(t).asm $(BUILD_DIR)/asm/;)

pre:
	@mkdir -p $(BUILD_DIR)/bin/
	@mkdir -p $(BUILD_DIR)/elf/
	@mkdir -p $(BUILD_DIR)/app/
	@mkdir -p $(BUILD_DIR)/asm/
	@$(foreach t, $(APPS), cp $(t) $(BUILD_DIR)/app/;)

build: clean pre binary
	@$(foreach t, $(ELFS), cp $(t).bin $(BUILD_DIR)/bin/;)
	@$(foreach t, $(ELFS), cp $(t).elf $(BUILD_DIR)/elf/;)

clean:
	@cargo clean
	@rm -rf $(BUILD_DIR)

all: build

.PHONY: elf binary build clean all
  • 第 1 - 8 行:用变量定义了变量。
  • 第 10 - 12 行:?=表示如果变量没有定义过则定义变量为右边值。
  • 第 14 - 27 行:根据TEST的值是0,1,其他来划分。
  • 第 15 行:
    • wildcard PATTERN:它被展开为已经存在的、使用空格分开的、匹配此模式的所有文件列表。这里是wildcard src/bin/ch*.rs
    • filter-out < pattern >,< text >:以< pattern >模式过滤< text >字符串中的单词,去除符合模式< pattern >的单词,返回不符合模式的字串。在这里就是去除所有带ch*的文件。
  • 第 17 行:匹配所有带 ch 的文件。
  • 第 19 行:shell脚本seq命令用于生成从一个数到另一个数之间的所有整数。
  • 第 20 行:判断BASE是否为0,是的话执行21行。
  • 第 21 行:$(foreach < var >,< list >,< text >)把参数< list >中的单词逐一取出放到参数< var >所指定的变量中, 然后再执行< text >所包含的表达式。每一次< text >会返回一个字符串,循环过程中,< text >的所返回的每个字符串会以空格分隔,最后当整个循环结束时,< text >所返回的每个字符串所组成的整个字符串(以空格分隔)将会是 foreach 函数的返回值。这里就是取出所有从0-TEST数值之间的chn_*.rs
  • 第 22 行:判断BASE是否为1,是的话执行23行。
  • 第 23 行:取出所有从1-TEST数值之间的chnb_*.rs
  • 第 25 行:取出所有从BASE-TEST数值之间的ch*.rs
  • 第 29 行:$(patsubst < pattern >,< replacement >,< text > ),查找中的单词(单词以“空格”、“Tab”或“回车”“换行”分隔)是否符合模式,如果匹配的话,则以替换。就是将原来/src/bin/*.rs的文件名称改为/target/riscv64gc-unknown-none-elf/release/ *.rs
  • 第 32 行:输出 ELFS 这个信息到终端。
  • 第 33 行:判断${CHAPTER}是否大于3,大于为0,小于为1。
  • 第 34 行:小于的话执行cargo build --release
  • 第 36 行:大于的话执行python3 build.py 的脚本。
  • 第 38 , 39 行:依次把ELF执行文件转成bianary文件,执行rust-objcopy --binary-architecture=riscv64 target/riscv64gc-unknown-none-elf/release/*.rs --strip-all -O binary target/riscv64gc-unknown-none-elf/release/%.bin
  • 第 40 行:将执行后的文件移动到需要的目录。
  • 第 42 - 45 行:执行反汇编指令。
  • 第 48 - 51 行:创建build这个目录和这个目录下的四个子文件。
  • 第 52 行:拷贝APPS的文件到build/app下。
  • 第 54 - 56 行:拷贝 /target/riscv64gc-unknown-none-elf/release/ *.rs 的文件到 build/binbuild/elf

这个makefile整体来讲就是先区分到底用的是哪些app,再将这些app按照流程进行编译成ELF可执行文件,再转换成二进制文件,相当于每个app最终都变成一个bin文件。最后拷贝到我们build下的文件中。与Trilobita OS的后续执行情况相同。

CHAPTER、TEST、BASE 三个参数是在进行build的时候传进去的参数进行编译,区分不同os的Apps

user 目录下 make build 得到 .bin 后缀的纯二进制镜像文件。 它们将被链接进内核,并由内核在合适的时机加载到内存。

【实现批处理操作系统】

我们要把应用程序的二进制镜像文件作为数据段链接到内核里, 内核需要知道应用程序的数量和它们的位置。

😃 将应用程序链接到内核

os/src/main.rs 中能够找到这样一行:

// 我们使用 `global_asm` 宏,将同目录下的汇编文件 `link_app.S` 嵌入到代码中。
core::arch::global_asm!(include_str!("link_app.S"));

这个文件是在 make run 构建操作系统时自动生成的,里面的内容大致如下:

# os/src/link_app.S

	.align 3
    .section .data
    .global _num_app
_num_app:
    .quad 3
    .quad app_0_start
    .quad app_1_start
    .quad app_2_start
    .quad app_2_end

    .section .data
    .global app_0_start
    .global app_0_end
app_0_start:
    .incbin "../user/build/bin/hello_world.bin"
app_0_end:

    .section .data
    .global app_1_start
    .global app_1_end
app_1_start:
    .incbin "../user/build/bin/bad_address.bin"
app_1_end:

    .section .data
    .global app_2_start
    .global app_2_end
app_2_start:
    .incbin "../user/build/bin/power.bin"
app_2_end:
  • 第 3 行:.align 3的意思是将当前的PC地址推进到8个字节对齐的位置处。如果当前位置PC为8000a002,则下一条指令的PC地址将被推进到8000a008。
  • 第 6 行:表示应用程序的数量。
  • 第 8 - 10 行:按照顺序放置每个应用程序的起始地址。
  • 第 11 行:放置最后一个应用程序的结束位置。
  • 第 13 - 18 行:app0,可以使用 incbin 来包含可执行文件、文字或其他任意数据。 文件的内容将按字节逐一添加到当前 ELF 节中,而不进行任何方式的解释。
  • 第 20 - 25 行:app1,与上面一样。
  • 第 27 - 32 行:app2,与上面一样。

这个汇编文件主要指明了user下之前生成的各个app.bin的位置数量,可以进行链接到内核中。

😃 找到并加载应用程序二进制码

OS 下新建 batch.rs

use crate::sync::UPSafeCell;
use crate::trap::TrapContext;
use lazy_static::*;

const USER_STACK_SIZE: usize = 4096;
const KERNEL_STACK_SIZE: usize = 4096 * 2;
const MAX_APP_NUM: usize = 16;
const APP_BASE_ADDRESS: usize = 0x80400000;
const APP_SIZE_LIMIT: usize = 0x20000;

#[repr(align(4096))]
struct KernelStack {
    data: [u8; KERNEL_STACK_SIZE],
}

#[repr(align(4096))]
struct UserStack {
    data: [u8; USER_STACK_SIZE],
}

static KERNEL_STACK: KernelStack = KernelStack {
    data: [0; KERNEL_STACK_SIZE],
};
static USER_STACK: UserStack = UserStack {
    data: [0; USER_STACK_SIZE],
};

impl KernelStack {
    fn get_sp(&self) -> usize {
        self.data.as_ptr() as usize + KERNEL_STACK_SIZE
    }
    pub fn push_context(&self, cx: TrapContext) -> &'static mut TrapContext {
        let cx_ptr = (self.get_sp() - core::mem::size_of::<TrapContext>()) as *mut TrapContext;
        unsafe {
            *cx_ptr = cx;
        }
        unsafe { cx_ptr.as_mut().unwrap() }
    }
}

impl UserStack {
    fn get_sp(&self) -> usize {
        self.data.as_ptr() as usize + USER_STACK_SIZE
    }
}

struct AppManager {
    num_app: usize,
    current_app: usize,
    app_start: [usize; MAX_APP_NUM + 1],
}

impl AppManager {
    pub fn print_app_info(&self) {
        info!("[kernel] num_app = {}", self.num_app);
        for i in 0..self.num_app {
            info!(
                "[kernel] app_{} [{:#x}, {:#x})",
                i,
                self.app_start[i],
                self.app_start[i + 1]
            );
        }
    }

    unsafe fn load_app(&self, app_id: usize) {
        if app_id >= self.num_app {
            panic!("All applications completed!");
        }
        info!("[kernel] Loading app_{}", app_id);
        // clear icache
        core::arch::asm!("fence.i");
        // clear app area
        core::slice::from_raw_parts_mut(APP_BASE_ADDRESS as *mut u8, APP_SIZE_LIMIT).fill(0);
        let app_src = core::slice::from_raw_parts(
            self.app_start[app_id] as *const u8,
            self.app_start[app_id + 1] - self.app_start[app_id],
        );
        let app_dst = core::slice::from_raw_parts_mut(APP_BASE_ADDRESS as *mut u8, app_src.len());
        app_dst.copy_from_slice(app_src);
    }

    pub fn get_current_app(&self) -> usize {
        self.current_app
    }

    pub fn move_to_next_app(&mut self) {
        self.current_app += 1;
    }
}

lazy_static! {
    static ref APP_MANAGER: UPSafeCell<AppManager> = unsafe {
        UPSafeCell::new({
            extern "C" {
                fn _num_app();
            }
            let num_app_ptr = _num_app as usize as *const usize;
            let num_app = num_app_ptr.read_volatile();
            let mut app_start: [usize; MAX_APP_NUM + 1] = [0; MAX_APP_NUM + 1];
            let app_start_raw: &[usize] =
                core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1);
            app_start[..=num_app].copy_from_slice(app_start_raw);
            AppManager {
                num_app,
                current_app: 0,
                app_start,
            }
        })
    };
}

pub fn init() {
    print_app_info();
}

pub fn print_app_info() {
    APP_MANAGER.exclusive_access().print_app_info();
}

pub fn run_next_app() -> ! {
    let mut app_manager = APP_MANAGER.exclusive_access();
    let current_app = app_manager.get_current_app();
    unsafe {
        app_manager.load_app(current_app);
    }
    app_manager.move_to_next_app();
    drop(app_manager);
    // before this we have to drop local variables related to resources manually
    // and release the resources
    extern "C" {
        fn __restore(cx_addr: usize);
    }
    unsafe {
        __restore(KERNEL_STACK.push_context(TrapContext::app_init_context(
            APP_BASE_ADDRESS,
            USER_STACK.get_sp(),
        )) as *const _ as usize);
    }
    panic!("Unreachable in batch::run_current_app!");
}
  • 第 12 - 14 行:创建一个内核栈结构体。
  • 第 17 - 19 行:创建一个用户栈结构体。
  • 第 21 - 23 行:实例化一个内核栈。以全局变量的形式实例化在批处理操作系统的 .bss 段中。
  • 第 24 - 26 行:实例化一个用户栈。以全局变量的形式实例化在批处理操作系统的 .bss 段中。
  • 第 29 - 31 行:我们为KernelStack类型实现了 get_sp 方法来获取栈顶地址。由于在 RISC-V 中栈是向下增长的, 我们只需返回包裹的数组的结尾地址。
  • 第 42 - 44 行:我们为UserStack类型实现了 get_sp 方法来获取栈顶地址。由于在 RISC-V 中栈是向下增长的, 我们只需返回包裹的数组的结尾地址。
  • 第 47 - 51 行:新建一个AppManager struct
  • 第 54 - 64 行:为AppManager实现了print_app_info这个方法,打印num_appapp_start数据。
  • 第 66 - 81 行:这个方法负责将参数 app_id 对应的应用程序的二进制镜像加载到物理内存以 0x80400000 起始的位置, 这个位置是批处理操作系统和应用程序之间约定的常数地址。 我们将从这里开始的一块内存清空,然后找到待加载应用二进制镜像的位置,并将它复制到正确的位置。
    • 第 67 - 69 行:判断是否加载到最后的APP。通过比较app_idnum_app参数。
    • 第 70 行:给出当前正在运行的app信息。
    • 第 72 行:汇编指令 fence.i ,它是用来清理 i-cache 的。缓存又分成 数据缓存 (d-cache) 和 指令缓存 (i-cache) 两部分,分别在 CPU 访存和取指的时候使用。 通常情况下, CPU 会认为程序的代码段不会发生变化,因此 i-cache 是一种只读缓存。 但在这里,我们会修改会被 CPU 取指的内存区域,使得 i-cache 中含有与内存不一致的内容, 必须使用 fence.i 指令手动清空 i-cache ,让里面所有的内容全部失效, 才能够保证程序执行正确性。(加入不清除i-cache当修改了CPU 取指的内存区域后,我们依旧使用上一条指令缓冲,但访问的内存却不是我们所期待的内存了:()
    • 第 74 行:获取0x80400000为起始的0x20000长度的切片进行清空操作。
    • 第 75 - 78 行:获取所加载的app的索引和长度组成切片。
    • 第 79 行:获取0x80400000为起始的0x20000长度的切片。
    • 第 80 行:将刚才所获得的APP长度的切片复制到以0x80400000为起始的0x20000长度上。
  • 第 83 - 85 行:为AppManager实现了get_current_app这个方法,返回current_app这个数据。
  • 第 87 - 89 行:为AppManager实现了move_to_next_app这个方法,current_app这个数据加一。
  • 第 92 - 93 行:lazy_static 提供的 lazy_static! 宏。lazy_static! 宏提供了全局变量的运行时初始化功能。一般情况下,全局变量必须在编译期设置初始值, 但是有些全局变量的初始化依赖于运行期间才能得到的数据。 如这里我们借助 lazy_static! 声明了一个 AppManager 结构的名为 APP_MANAGER 的全局实例, 只有在它第一次被使用到的时候才会进行实际的初始化工作。
  • 第 94 行:UPSafeCell 实现在 sync 模块中,所有的具有内部可变性特点的类型,都必须基于 UnsafeCell 来实现,否则必然出现各种问题。这个类型,是唯一合法的将 &T 类型转为 &mut T 类型的办法。
  • 第 95 行:extern “C” :该函数可以提供给其他库或者语言调用,并且采用c语言的调用约定。
  • 第 98 行:获取_num_app
  • 第 99 行:对 src 中的值执行易失性读取而不移动它。这使src 中的内存保持不变。获取总共有多少app.quad 3
  • 第 100 行:App的起始是[0; MAX_APP_NUM + 1]
  • 第 101 - 102行:pub unsafe fn from_raw_parts<'a, T>(data:*const T, len:usize) -> &'a [T]从一个指针和一个长度形成一个切片。这里是获取后续的app_n_start的指针和标记。
  • 第 103 行:依次将这个切片拷贝到app_start中。
  • 第 113 - 115 行:init函数调用 print_app_info 函数。
  • 第 117 - 119 行:print_app_info 函数第一次用到了全局变量 APP_MANAGER ,它在这时完成初始化。调用 exclusive_access 方法能获取其内部对象的可变引用。
  • 第 121 - 141 行:run_next_app 批处理操作系统的核心操作,即加载并运行下一个应用程序。 批处理操作系统完成初始化,或者应用程序运行结束/出错后会调用该函数。
    • __restore 所做的事情是在内核栈上压入一个 Trap 上下文,其 sepc 是应用程序入口地址 0x80400000 ,其 sp 寄存器指向用户栈,其 sstatusSPP 字段被设置为 Userpush_context 的返回值是内核栈压入 Trap 上下文之后的栈顶,它会被作为 __restore 的参数(这时我们可以理解为何 __restore 函数的起始部分会完成 sp←a0 ),这使得在 __restore 函数中 sp 仍然可以指向内核栈的栈顶。这之后,就和执行一次普通的 __restore 函数调用一样了。

以上的操作主要是通过管理link_app.S汇编文件所指示的APP,当需要执行app的时候通过细节操作(清除icache、内存清空)把app.bin拷贝到我们之前指定的0x80400000这片内存中。

【实现特权级的切换】

😃 为什么需要特权级切换

批处理操作系统为了建立好应用程序的执行环境,需要在执行应用程序前进行一些初始化工作, 并监控应用程序的执行,具体体现在:

  • 启动应用程序时,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序
  • 应用程序发起系统调用后,需要切换到批处理操作系统中进行处理
  • 应用程序执行出错时,批处理操作系统要杀死该应用并加载运行下一个应用
  • 应用程序执行结束时,批处理操作系统要加载运行下一个应用

这些处理都涉及到特权级切换,因此都需要硬件和操作系统协同提供的特权级切换机制。

😃 特权级切换相关的控制状态寄存器

当 CPU 在 U 特权级运行用户程序的时候触发 Trap, 并切换到 S 特权级的批处理操作系统进行处理。

CSR 名该 CSR 与 Trap 相关的功能
sstatusSPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息
sepc当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址
scause描述 Trap 的原因
stval给出 Trap 附加信息
stvec控制 Trap 处理代码的入口地址

特权级切换的具体过程一部分由硬件直接完成,另一部分则需要由操作系统来实现

😃 特权级切换的硬件控制机制

当 CPU 执行完一条指令并准备从用户特权级陷入( Trap )到 S 特权级的时候,硬件会自动完成如下这些事情

  • sstatusSPP 字段会被修改为 CPU 当前的特权级(U/S)。
  • sepc 会被修改为 Trap 处理完成后默认会执行的下一条指令的地址。
  • scause/stval 分别会被修改成这次 Trap 的原因以及相关的附加信息。
  • CPU 会跳转到 stvec 所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后从Trap 处理入口地址处开始执行。

stvec 相关细节

在 RV64 中, stvec 是一个 64 位的 CSR,在中断使能的情况下,保存了中断处理的入口地址。它有两个字段:

  • MODE 位于 [1:0],长度为 2 bits;
  • BASE 位于 [63:2],长度为 62 bits。

当 MODE 字段为 0 的时候, stvec 被设置为 Direct 模式,此时进入 S 模式的 Trap 无论原因如何,处理 Trap 的入口地址都是 BASE<<2 , CPU 会跳转到这个地方进行异常处理。本书中我们只会将 stvec 设置为 Direct 模式。而 stvec 还可以被设置为 Vectored 模式,

而当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级的特权指令 sret 来完成,这一条指令具体完成以下功能:

  • CPU 会将当前的特权级按照 sstatusSPP 字段设置为 U 或者 S ;
  • CPU 会跳转到 sepc 寄存器指向的那条指令,然后继续执行。
😃 用户栈与内核栈

在 Trap 触发的一瞬间, CPU 会切换到 S 特权级并跳转到 stvec 所指示的位置。 但是在正式进入 S 特权级的 Trap 处理之前,我们必须保存原控制流的寄存器状态,这一般通过栈来完成。 但我们需要用专门为操作系统准备的内核栈,而不是应用程序运行时用到的用户栈。

一切都是为了安全

😃 Trap 上下文

在 Trap 发生时需要保存的物理资源内容:

// os/src/trap/context.rs
use riscv::register::sstatus::{self, Sstatus, SPP};

#[repr(C)]
pub struct TrapContext {
    pub x: [usize; 32],
    pub sstatus: Sstatus,
    pub sepc: usize,
}

impl TrapContext {
    pub fn set_sp(&mut self, sp: usize) {
        self.x[2] = sp;
    }
    pub fn app_init_context(entry: usize, sp: usize) -> Self {
        let mut sstatus = sstatus::read();
        sstatus.set_spp(SPP::User);
        let mut cx = Self {
            x: [0; 32],
            sstatus,
            sepc: entry,
        };
        cx.set_sp(sp);
        cx
    }
}
  • 第 5 - 9 行:声明TrapContext保存上下文,包含所有的通用寄存器 x0~x31 ,还有 sstatussepc

    • 通用寄存器 x0~x31:两条控制流(应用程序控制流和内核控制流)运行在不同的特权级,所属的软件也可能由不同的编程语言编写,虽然在 Trap 控制流中只是会执行 Trap 处理相关的代码,但依然可能直接或间接调用很多模块,因此需保存寄存器。
    • scause/stval:它总是在 Trap 处理的第一时间就被使用或者是在其他地方保存下来了,因此它没有被修改并造成不良影响的风险。
    • sstatus/sepc :进入 Trap 的时候,硬件会立即覆盖掉 scause/stval/sstatus/sepc 的全部或是其中一部分。它们会在 Trap 处理的全程有意义(在 Trap 控制流最后 sret 的时候还用到了它们),而且确实会出现 Trap 嵌套的情况使得它们的值被覆盖掉。所以我们需要将它们也一起保存下来,并在 sret 之前恢复原样。
  • 第 15 - 25 行:为TrapContext实现了app_init_context 这个方法。

    • 第 17 行:将 sstatus 寄存器的 SPP 字段设置为 User

      sstatus 寄存器保存的是当前发生中断或者异常所在特权级,当中断或异常发生在U态的时候, sstatusSPP 位被置为0,然后sret指令后将返回U态;当 sstatusSPP 位被置为1时, sret 指令将返回S态,这时 SPP 位仍然被置为0。在sstatus中的SPP位记录的是陷阱来自用户模式还是超级用户模式,及控制sret返回到哪种模式。

    • 第 18 - 22 行:修改其中的 sepc 寄存器为应用程序入口点 entry 也就是0x80400000

    • 第 23 行:设置SP寄存器为我们设定的一个栈指针。

    • 第 24 行:返回cs。

😃 Trap 上下文的保存与恢复

Trap 处理的总体流程如下:首先通过 __alltraps 将 Trap 上下文保存在内核栈上,然后跳转到使用 Rust 编写的 trap_handler 函数完成 Trap 分发及处理。当 trap_handler 返回之后,使用 __restore 从保存在内核栈上的 Trap 上下文恢复寄存器。最后通过一条 sret 指令回到应用程序执行。

我们在 os/src/trap/trap.S实现 Trap 上下文保存/恢复的汇编代码,分别用外部符号 __alltraps__restore 标记为函数。

.altmacro
.macro SAVE_GP n
    sd x\n, \n*8(sp)
.endm
.macro LOAD_GP n
    ld x\n, \n*8(sp)
.endm
    .section .text
    .globl __alltraps
    .globl __restore
    .align 2
__alltraps:
    csrrw sp, sscratch, sp
    # now sp->kernel stack, sscratch->user stack
    # allocate a TrapContext on kernel stack
    addi sp, sp, -34*8
    # save general-purpose registers
    sd x1, 1*8(sp)
    # skip sp(x2), we will save it later
    sd x3, 3*8(sp)
    # skip tp(x4), application does not use it
    # save x5~x31
    .set n, 5
    .rept 27
        SAVE_GP %n
        .set n, n+1
    .endr
    # we can use t0/t1/t2 freely, because they were saved on kernel stack
    csrr t0, sstatus
    csrr t1, sepc
    sd t0, 32*8(sp)
    sd t1, 33*8(sp)
    # read user stack from sscratch and save it on the kernel stack
    csrr t2, sscratch
    sd t2, 2*8(sp)
    # set input argument of trap_handler(cx: &mut TrapContext)
    mv a0, sp
    call trap_handler

__restore:
    # case1: start running app by 
    # case2: back to U after handling trap
    mv sp, a0
    # now sp->kernel stack(after allocated), sscratch->user stack
    # restore sstatus/sepc
    ld t0, 32*8(sp)
    ld t1, 33*8(sp)
    ld t2, 2*8(sp)
    csrw sstatus, t0
    csrw sepc, t1
    csrw sscratch, t2
    # restore general-purpuse registers except sp/tp
    ld x1, 1*8(sp)
    ld x3, 3*8(sp)
    .set n, 5
    .rept 27
        LOAD_GP %n
        .set n, n+1
    .endr
    # release TrapContext on kernel stack
    addi sp, sp, 34*8
    # now sp->kernel stack, sscratch->user stack
    csrrw sp, sscratch, sp
    sret
  • 第 11 行:用.align__alltraps 的地址 4 字节对齐,这是 RISC-V 特权级规范的要求。

  • 第 13 行: csrrw 原型是 csrrw rd, csr, rs 可以将 CSR 当前的值读到通用寄存器 rd 中,然后将通用寄存器 rs 的值写入该 CSR 。因此这里起到的是交换 sscratchsp 的效果。在这一行之前 sp 指向用户栈, sscratch 指向内核栈(原因稍后说明),现在 sp 指向内核栈sscratch 指向用户栈

  • 第 16 行:我们准备在内核栈上保存 Trap 上下文,于是预先分配 34×8 字节的栈帧,这里改动的是 sp ,说明是在内核栈上。

  • 第 18 - 28 行:保存 Trap 上下文的通用寄存器 x0~x31不保存 x0tp(x4)(因为x0 被硬编码为 0 ,它自然不会有变化;还有 tp(x4) 寄存器,除非我们手动出于一些特殊用途使用它,否则一般也不会被用到)。我们在这里也不保存 sp(x2)(因为它在第 9 行后指向的是内核栈)。用户栈的栈指针保存在 sscratch 中,必须通过 csrr 指令读到通用寄存器中后才能使用,因此我们先考虑保存其它通用寄存器,腾出空间。

    我们要基于 sp 来找到每个寄存器应该被保存到的正确的位置。实际上,在栈帧分配之后,我们可用于保存 Trap 上下文的地址区间为 [sp,sp+8×34) , 按照 TrapContext 结构体的内存布局,基于内核栈的位置(sp所指地址)来从低地址到高地址分别按顺序放置 x0~x31这些通用寄存器,最后是 sstatussepc 。因此通用寄存器 xn 应该被保存在地址区间 [sp+8n,sp+8(n+1))

    • 第 18 行:通过sd指令保存x1
    • 第 20 行:通过sd指令保存x3
    • 第 24 - 26 行:为了简化代码,x5~x31 这 27 个通用寄存器我们通过类似循环的 .rept 每次使用 SAVE_GP 宏来保存,其实质是相同的。注意我们需要在 trap.S 开头加上 .altmacro 才能正常使用 .rept 命令。相当于反复执行sd指令保存x5-x32
  • 第 29 - 32 行:我们将 CSR sstatussepc 的值分别读到寄存器 t0t1 中然后保存到内核栈对应的位置上。指令 csrr rd, csr 的功能就是将 CSR 的值读到寄存器 rd 中。这里我们不用担心 t0t1 被覆盖, 因为它们刚刚已经被保存了。

  • 第 34 - 35 行:专门处理 sp 的问题。首先将 sscratch 的值读到寄存器 t2 并保存到内核栈上,注意: sscratch 的值是进入 Trap 之前的 sp 的值,指向用户栈。而现在的 sp 则指向内核栈。

  • 第 37 行:指令 a0←sp让寄存器 a0 指向内核栈的栈指针也就是我们刚刚保存的 Trap 上下文的地址, 这是由于我们接下来要调用 trap_handler 进行 Trap 处理,它的第一个参数 cx 由调用规范要从 a0 中获取。而 Trap 处理函数 trap_handler 需要 Trap 上下文的原因在于:它需要知道其中某些寄存器的值,比如在系统调用的时候应用程序传过来的 syscall ID 和 对应参数。我们不能直接使用这些寄存器现在的值,因为它们可能已经被修改了,因此要去内核栈上找已经被保存下来的值。

  • 第 38 行:执行trap_handlertrap_handler 返回之后会从调用 trap_handler 的下一条指令开始执行,也就是从栈上的 Trap 上下文恢复的 __restore

  • 第 43 行:通过调用返回值cx, sp 仍然指向内核栈的栈顶。

  • 第 46 - 59 行:负责从内核栈顶的 Trap 上下文恢复通用寄存器和 CSR 。注意我们要先恢复 CSR 再恢复通用寄存器,这样我们使用的三个临时寄存器才能被正确恢复。(你先恢复了通用寄存器,再恢复CSR那岂不是之前用的a0、t0、t1被覆盖了原始值现在保存的是CSR不是之前保存的通用寄存器的值了)

  • 第 61 行:在这一行之前,sp 指向保存了 Trap 上下文之后的内核栈栈顶, sscratch 指向用户栈栈顶。我们在第 61 行在内核栈上回收 Trap 上下文所占用的内存,回归进入 Trap 之前的内核栈栈顶。

  • 第 63 行:再次交换 sscratchsp,现在 sp 重新指向用户栈栈顶,sscratch 也依然保存进入 Trap 之前的状态并指向内核栈栈顶。

  • 第 64 行:在应用程序控制流状态被还原之后,我们使用 sret 指令回到 U 特权级继续运行应用程序控制流。

CSR 相关原子指令

RISC-V 中读写 CSR 的指令是一类能不会被打断地完成多个读写操作的指令。这种不会被打断地完成多个操作的指令被称为 原子指令 (Atomic Instruction)。这里的 原子 的含义是“不可分割的最小个体”,也就是说指令的多个操作要么都不完成,要么全部完成,而不会处于某种中间状态。

😃 Trap 分发与处理

// os/src/trap/mod.rs

mod context;

use crate::batch::run_next_app;
use crate::syscall::;
use riscv::register::{
    mtvec::TrapMode,
    scause::{self, Exception, Trap},
    stval, stvec,
};

core::arch::global_asm!(include_str!("trap.S"));

pub fn init() {
    extern "C" {
        fn __alltraps();
    }
    unsafe {
        stvec::write(__alltraps as usize, TrapMode::Direct);
    }
}

#[no_mangle]
pub fn trap_handler(cx: &mut TrapContext) -> &mut TrapContext {
    let scause = scause::read();
    let stval = stval::read();
    match scause.cause() {
        Trap::Exception(Exception::UserEnvCall) => {
            cx.sepc += 4;
            cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize;
        }
        Trap::Exception(Exception::StoreFault) | Trap::Exception(Exception::StorePageFault) => {
            error!("[kernel] PageFault in application, core dumped.");
            run_next_app();
        }
        Trap::Exception(Exception::IllegalInstruction) => {
            error!("[kernel] IllegalInstruction in application, core dumped.");
            run_next_app();
        }
        _ => {
            panic!(
                "Unsupported trap {:?}, stval = {:#x}!",
                scause.cause(),
                stval
            );
        }
    }
    cx
}

pub use context::TrapContext;

  • 第 11 行:通过 global_asm! 宏将 trap.S 这段汇编代码插入进来。从而可以使用__alltraps__restore这两个函数。

  • 第 13 - 20 行:修改 stvec 寄存器来指向正确的 Trap 处理入口点

    • 第 15 行:引入外部符号__alltraps
    • 第 18 行:将 stvec (trap处理代码的地址)设置为 Direct 模式指向__alltraps的地址。
  • 第 23 行:声明返回值为 &mut TrapContext 并在第 47 行实际将传入的 cx 原样返回,因此在 __restore 的时候 a0 寄存器在调用 trap_handler 前后并没有发生变化,仍然指向分配 Trap 上下文之后的内核栈栈顶,和此时 sp 的值相同,我们 sp←a0 并不会有问题;

  • 第 26 行:根据 scause 寄存器所保存的 Trap 的原因进行分发处理。这里我们无需手动操作这些 CSR ,而是使用 Rust 第三方库 riscv

  • 第 27 - 30 行:发现触发 Trap 的原因是来自 U 特权级的 Environment Call,也就是系统调用。这里我们首先修改保存在内核栈上的 Trap 上下文里面 sepc,让其增加 4。这是因为我们知道这是一个由 ecall 指令触发的系统调用,在进入 Trap 的时候,硬件会将 sepc 设置为这条 ecall 指令所在的地址(因为它是进入 Trap 之前最后一条执行的指令)。而在 Trap 返回之后,我们希望应用程序控制流从 ecall 的下一条指令 开始执行。因此我们只需修改 Trap 上下文里面的 sepc,让它增加 ecall 指令的码长,也即 4 字节。这样在 __restore 的时候 sepc 在恢复之后就会指向 ecall 的下一条指令,并在 sret 之后从那里开始执行。

    用来保存系统调用返回值的 a0 寄存器也会同样发生变化。我们从 Trap 上下文取出作为 syscall ID 的 a7 和系统调用的三个参数 a0~a2 传给 syscall 函数并获取返回值。 syscall 函数是在 syscall 子模块中实现的。 这段代码是处理正常系统调用的控制逻辑。

  • 第 31 - 38 行:分别处理应用程序出现访存错误和非法指令错误的情形。此时需要打印错误信息并调用 run_next_app 直接切换并运行下一个 应用程序。

  • 第 39 - 45 行:当遇到目前还不支持的 Trap 类型的时候,批处理操作系统整个 panic 报错退出。

  • 第 47 行:将传入的 cx 原样返回。

😃 syscall处理

对于系统调用而言, syscall 函数并不会实际处理系统调用,而只是根据 syscall ID 分发到具体的处理函数:

// os/src/syscall/mod.rs
const SYSCALL_WRITE: usize = 64;
const SYSCALL_EXIT: usize = 93;

mod fs;
mod process;

use fs::*;
use process::*;

pub fn syscall(syscall_id: usize, args: [usize; 3]) -> isize {
    match syscall_id {
        SYSCALL_WRITE => sys_write(args[0], args[1] as *const u8, args[2]),
        SYSCALL_EXIT => sys_exit(args[0] as i32),
        _ => panic!("Unsupported syscall_id: {}", syscall_id),
    }
}

这里依据传入进来的 syscall_id 分发到具体的处理函数。

// os/src/syscall/fs.rs
const FD_STDOUT: usize = 1;

pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
    match fd {
        FD_STDOUT => {
            let slice = unsafe { core::slice::from_raw_parts(buf, len) };
            let str = core::str::from_utf8(slice).unwrap();
            print!("{}", str);
            len as isize
        }
        _ => {
            panic!("Unsupported fd in sys_write!");
        }
    }
}

// os/src/syscall/process.rs
use crate::batch::run_next_app;

pub fn sys_exit(exit_code: i32) -> ! {
    info!("[kernel] Application exited with code {}", exit_code);
    run_next_app()
}
  • sys_write 我们将传入的位于应用程序内的缓冲区的开始地址和长度转化为一个字符串 &str ,然后使用批处理操作系统已经实现的 print! 宏打印出来。这里我们并没有检查传入参数的安全性,存在安全隐患。
  • sys_exit 打印退出的应用程序的返回值并同样调用 run_next_app 切换到下一个应用程序。

【主函数初始化】

#![no_std]
#![no_main]
#![feature(panic_info_message)]

#[macro_use]
extern crate log;

#[macro_use]
mod console;
mod batch;
mod lang_items;
mod logging;
mod sbi;
mod sync;
mod syscall;
mod trap;

core::arch::global_asm!(include_str!("entry.asm"));
core::arch::global_asm!(include_str!("link_app.S"));

fn clear_bss() {
    extern "C" {
        fn sbss();
        fn ebss();
    }
    unsafe {
        core::slice::from_raw_parts_mut(sbss as usize as *mut u8, ebss as usize - sbss as usize)
            .fill(0);
    }
}

#[no_mangle]
pub fn rust_main() -> ! {
    clear_bss();
    logging::init();
    println!("[kernel] Hello, world!");
    trap::init();
    batch::init();
    batch::run_next_app();
}
  • 第 34 行:清空bss段。
  • 第 35 行:日志初始化。
  • 第 37 行:特权级初始化,使得trap处理代码的地址指向__alltraps的地址。
  • 第 38 行:应用模块初始化。
  • 第 39 行:执行第一个应用模块。

😃 个人总结:

  1. Dunkleosteus OS 是在 Trilobita OS之上进化出来的一个与应用程序区分的最简单的OS。

  2. 从宏观层面分析,是将每个应用程序编译成bin文件之后再由OS按照顺序依次将一个个应用程序引导到内核中执行。

  3. 从微观层面分析,其中初始化应用程序上下文、执行系统调用、执行出错处理、加载下一个程序都需要OS的协助,也就是与Trilobita OS 应用与OS不区分的最大不同之处。

  4. 整体架构如图所示:

    在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值