在内核中使用 easy-fs

块设备驱动层

drivers 子模块中的 block/mod.rs 中,我们可以找到内核访问的块设备实例 BLOCK_DEVICE

// os/src/drivers/block/mod.rs

type BlockDeviceImpl = virtio_blk::VirtIOBlock;

lazy_static! {
    pub static ref BLOCK_DEVICE: Arc<dyn BlockDevice> = Arc::new(BlockDeviceImpl::new());
}

在 qemu 上,我们使用 VirtIOBlock 访问 VirtIO 块设备,并将它全局实例化为 BLOCK_DEVICE ,使内核的其他模块可以访问。

在启动 Qemu 模拟器的时候,我们可以配置参数来添加一块 VirtIO 块设备:

 1# os/Makefile
 2
 3FS_IMG := ../user/target/$(TARGET)/$(MODE)/fs.img
 4
 5run: build
 6    @qemu-system-riscv64 \
 7        -machine virt \
 8        -nographic \
 9        -bios $(BOOTLOADER) \
10        -device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA) \
11        -drive file=$(FS_IMG),if=none,format=raw,id=x0 \
12        -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
  • 第 11 行,我们为虚拟机添加一块虚拟硬盘,内容为我们之前通过 easy-fs-fuse 工具打包的包含应用 ELF 的 easy-fs 镜像,并命名为 x0

  • 第 12 行,我们将硬盘 x0 作为一个 VirtIO 总线中的一个块设备接入到虚拟机系统中。 virtio-mmio-bus.0 表示 VirtIO 总线通过 MMIO 进行控制,且该块设备在总线中的编号为 0 。

内存映射 I/O (MMIO, Memory-Mapped I/O) 指通过特定的物理内存地址来访问外设的设备寄存器。查阅资料,可知 VirtIO 总线的 MMIO 物理地址区间为从 0x10001000 开头的 4KiB 。

config 子模块中我们硬编码 Qemu 上的 VirtIO 总线的 MMIO 地址区间(起始地址,长度)。在创建内核地址空间的时候需要建立页表映射:

// os/src/config.rs

pub const MMIO: &[(usize, usize)] = &[
    (0x10001000, 0x1000),
];

// os/src/mm/memory_set.rs

use crate::config::MMIO;

impl MemorySet {
    /// Without kernel stacks.
    pub fn new_kernel() -> Self {
        ...
        println!("mapping memory-mapped registers");
        for pair in MMIO {
            memory_set.push(MapArea::new(
                (*pair).0.into(),
                ((*pair).0 + (*pair).1).into(),
                MapType::Identical,
                MapPermission::R | MapPermission::W,
            ), None);
        }
        memory_set
    }
}

这里我们进行的是透明的恒等映射,让内核可以兼容于直接访问物理地址的设备驱动库。

由于设备驱动的开发过程比较琐碎,我们这里直接使用已有的 virtio-drivers crate,感兴趣的同学可以自行了解。

内核索引节点层

内核将 easy-fs 提供的 Inode 进一步封装为 OS 中的索引节点 OSInode

// os/src/fs/inode.rs

pub struct OSInode {
    readable: bool,
    writable: bool,
    inner: UPSafeCell<OSInodeInner>,
}

pub struct OSInodeInner {
    offset: usize,
    inode: Arc<Inode>,
}

OSInode 就表示进程中一个被打开的常规文件或目录。 readable/writable 分别表明该文件是否允许通过 sys_read/write 进行读写,读写过程中的偏移量 offsetInode 则加上互斥锁丢到 OSInodeInner 中。

文件描述符层

OSInode 也是要一种要放到进程文件描述符表中,通过 sys_read/write 进行读写的文件,我们需要为它实现 File Trait :

// os/src/fs/inode.rs

impl File for OSInode {
    fn readable(&self) -> bool { self.readable }
    fn writable(&self) -> bool { self.writable }
    fn read(&self, mut buf: UserBuffer) -> usize {
        let mut inner = self.inner.lock();
        let mut total_read_size = 0usize;
        for slice in buf.buffers.iter_mut() {
            let read_size = inner.inode.read_at(inner.offset, *slice);
            if read_size == 0 {
                break;
            }
            inner.offset += read_size;
            total_read_size += read_size;
        }
        total_read_size
    }
    fn write(&self, buf: UserBuffer) -> usize {
        let mut inner = self.inner.lock();
        let mut total_write_size = 0usize;
        for slice in buf.buffers.iter() {
            let write_size = inner.inode.write_at(inner.offset, *slice);
            assert_eq!(write_size, slice.len());
            inner.offset += write_size;
            total_write_size += write_size;
        }
        total_write_size
    }
}

read/write 的实现也比较简单,只需遍历 UserBuffer 中的每个缓冲区片段,调用 Inode 写好的 read/write_at 接口就好了。注意 read/write_at 的起始位置是在 OSInode 中维护的 offset ,这个 offset 也随着遍历的进行被持续更新。在 read/write 的全程需要获取 OSInode 的互斥锁,保证两个进程无法同时访问同个文件。

本章我们为 File Trait 新增了 readable/writable 两个抽象接口,从而在 sys_read/sys_write 的时候进行简单的访问权限检查。

文件系统相关内核机制实现

文件系统初始化

为了使用 easy-fs 提供的抽象,内核需要进行一些初始化操作。我们需要从块设备 BLOCK_DEVICE 上打开文件系统,并从文件系统中获取根目录的 inode 。

// os/src/fs/inode.rs

lazy_static! {
    pub static ref ROOT_INODE: Arc<Inode> = {
        let efs = EasyFileSystem::open(BLOCK_DEVICE.clone());
        Arc::new(EasyFileSystem::root_inode(&efs))
    };
}

这之后就可以使用根目录的 inode ROOT_INODE ,在内核中调用 easy-fs 的相关接口了。例如,在文件系统初始化完毕之后,调用 list_apps 函数来打印所有可用应用的文件名:

// os/src/fs/inode.rs

pub fn list_apps() {
    println!("/**** APPS ****");
    for app in ROOT_INODE.ls() {
        println!("{}", app);
    }
    println!("**************/")
}

通过 sys_open 打开文件

在内核中也定义一份打开文件的标志 OpenFlags

// os/src/fs/inode.rs

bitflags! {
    pub struct OpenFlags: u32 {
        const RDONLY = 0;
        const WRONLY = 1 << 0;
        const RDWR = 1 << 1;
        const CREATE = 1 << 9;
        const TRUNC = 1 << 10;
    }
}

impl OpenFlags {
    /// Do not check validity for simplicity
    /// Return (readable, writable)
    pub fn read_write(&self) -> (bool, bool) {
        if self.is_empty() {
            (true, false)
        } else if self.contains(Self::WRONLY) {
            (false, true)
        } else {
            (true, true)
        }
    }
}

它的 read_write 方法可以根据标志的情况返回要打开的文件是否允许读写。简单起见,这里假设标志自身一定合法。

接着,我们实现 open_file 内核函数,可根据文件名打开一个根目录下的文件:

// os/src/fs/inode.rs

pub fn open_file(name: &str, flags: OpenFlags) -> Option<Arc<OSInode>> {
    let (readable, writable) = flags.read_write();
    if flags.contains(OpenFlags::CREATE) {
        if let Some(inode) = ROOT_INODE.find(name) {
            // clear size
            inode.clear();
            Some(Arc::new(OSInode::new(
                readable,
                writable,
                inode,
            )))
        } else {
            // create file
            ROOT_INODE.create(name)
                .map(|inode| {
                    Arc::new(OSInode::new(
                        readable,
                        writable,
                        inode,
                    ))
                })
        }
    } else {
        ROOT_INODE.find(name)
            .map(|inode| {
                if flags.contains(OpenFlags::TRUNC) {
                    inode.clear();
                }
                Arc::new(OSInode::new(
                    readable,
                    writable,
                    inode
                ))
            })
    }
}

这里主要是实现了 OpenFlags 各标志位的语义。例如只有 flags 参数包含 CREATE 标志位才允许创建文件;而如果文件已经存在,则清空文件的内容。

在其基础上, sys_open 也就很容易实现了。

通过 sys_exec 加载并执行应用

有了文件系统支持后, sys_exec 所需的表示应用 ELF 格式数据改为从文件系统中获取:

 1// os/src/syscall/process.rs
 2
 3pub fn sys_exec(path: *const u8, mut args: *const usize) -> isize {
 4let token = current_user_token();
 5let path = translated_str(token, path);
 6let mut args_vec: Vec<String> = Vec::new();
 7loop {
 8    let arg_str_ptr = *translated_ref(token, args);
 9    if arg_str_ptr == 0 {
10        break;
11    }
12    args_vec.push(translated_str(token, arg_str_ptr as *const u8));
13    unsafe {
14        args = args.add(1);
15    }
16}
17if let Some(app_inode) = open_file(path.as_str(), OpenFlags::RDONLY) {
18    let all_data = app_inode.read_all();
19    let task = current_task().unwrap();
20    let argc = args_vec.len();
21    task.exec(all_data.as_slice(), args_vec);
22    argc as isize
23} else {
24    -1
25}

注意上面代码片段中的高亮部分。当执行获取应用的 ELF 数据的操作时,首先调用 open_file 函数,以只读的方式在内核中打开应用文件并获取它对应的 OSInode 。接下来可以通过 OSInode::read_all 将该文件的数据全部读到一个向量 all_data 中:

之后,就可以从向量 all_data 中拿到应用中的 ELF 数据,当解析完毕并创建完应用地址空间后该向量将会被回收。

同样的,我们在内核中创建初始进程 initproc 也需要替换为基于文件系统的实现:

// os/src/task/mod.rs

lazy_static! {
    pub static ref INITPROC: Arc<TaskControlBlock> = Arc::new({
        let inode = open_file("ch6b_initproc", OpenFlags::RDONLY).unwrap();
        let v = inode.read_all();
        TaskControlBlock::new(v.as_slice())
    });
}