The Adventures of OS
使用Rust的RISC-V操作系统
在Patreon上支持我! 操作系统博客 RSS订阅 Github EECS网站
这是用Rust编写RISC-V操作系统系列教程中的第11章。
用户空间进程
2020年6月1日:仅限PATREON
2020年6月8日:公开
资源和参考资料
ELF标准可以在这里找到: ELF File Format (PDF).
简介
这就是我们一直在等待的时刻。十个章节的设置使我们来到了这一时刻--最终能够从磁盘上加载一个进程并运行它。可执行文件的文件格式被称为ELF(可执行和可链接格式)。我将对它进行一些详细介绍,但你可以通过这一种文件类型探索很多途径。
ELF文件格式
可执行和可链接格式(ELF)是一种广泛使用的文件格式。 如果你使用过Linux,你无疑见过它或它的效果。 这种文件格式包含一个ELF头,后面是程序头。 每一次,我们都在告诉操作系统链接器将可执行部分映射到了哪里。如果你不记得了,我们有一个用于CPU指令的.text部分,用于全局常量的.rodata,用于全局初始化变量的.data,以及用于全局未初始化变量的.bss部分。在ELF格式中,编译器会决定把这些放在哪里。 另外,由于我们使用的是虚拟内存地址,ELF头指定了入口点,这就是我们在第一次调度进程时要放在程序计数器中的内容。
[joke]我的笔迹从4岁起就没有改进过[/joke]
让我们看看一些能帮助我们的Rust结构。这些都在elf.rs中。
#![allow(unused)] fn main() { #[repr(C)] pub struct Header { pub magic: u32, pub bitsize: u8, pub endian: u8, pub ident_abi_version: u8, pub target_platform: u8, pub abi_version: u8, pub padding: [u8; 7], pub obj_type: u16, pub machine: u16, // 0xf3 for RISC-V pub version: u32, pub entry_addr: usize, pub phoff: usize, pub shoff: usize, pub flags: u32, pub ehsize: u16, pub phentsize: u16, pub phnum: u16, pub shentsize: u16, pub shnum: u16, pub shstrndx: u16, } }
所有ELF文件都以这个ELF头结构开始。最上面是0x7f,后面是大写的ELF,即0x7f 0x45 0x4c和0x46,你可以在下面看到。我对ls(列表)命令进行了简单的十六进制转储。你可以看到神奇的东西就在那里。
其余的字段将告诉我们这个ELF文件是为哪个架构制作的。RISC-V已经保留了0xf3作为其机器类型。所以,当我们从磁盘上加载ELF文件时,我们必须确保它是为正确的体系结构制作的。你也会注意到entry_addr
也在上面。这是一个虚拟内存地址,是_start
的开始。我们的_start只是简单的调用main,然后当main返回时,它调用exit系统调用。这就是大多数程序的实际工作方式,但它们的工作要严格得多,包括获取命令行参数等等。现在,我们还没有这些。
我们需要知道的字段是phoff
字段,它指定了程序头的偏移。程序头是一个或多个程序部分的表格。我拍了一张ls(再次)和程序头的快照。你可以用readelf -l /bin/ls
做同样的事情。下面的代码显示了我是如何用Rust读取ELF头的。
#![allow(unused)] fn main() { let elf_hdr; unsafe { elf_hdr = (buffer.get() as *const elf::Header).as_ref().unwrap(); } if elf_hdr.magic != elf::MAGIC { println!("ELF magic didn't match."); return; } if elf_hdr.machine != elf::MACHINE_RISCV { println!("ELF loaded is not RISC-V."); return; } if elf_hdr.obj_type != elf::TYPE_EXEC { println!("ELF is not an executable."); return; } }
你可以看到,现在我们到了程序头,我对/bin/ls进行了快照。
程序头在Rust中具有以下结构。
#![allow(unused)] fn main() { #[repr(C)] pub struct ProgramHeader { pub seg_type: u32, pub flags: u32, pub off: usize, pub vaddr: usize, pub paddr: usize, pub filesz: usize, pub memsz: usize, pub align: usize, } }
/bin/ls使用共享库,但我们还没那么厉害。所以,我们关心的唯一程序头是由LOAD显示的那些。这些是我们需要为我们的静态二进制文件加载到内存中的部分。 在ProgramHeader结构中,我们需要seg_type为LOAD。标志位告诉我们如何保护虚拟内存。有三个标志EXECUTE(1),WRITE(2)和READ(4)。我们还需要off(偏移量),它告诉我们在ELF文件中要加载到程序内存的部分包含在哪里。最后,vaddr是我们需要指向MMU的地方,即我们将这部分加载到内存中的地方。你可以在test.rs的test_elf()函数中看到我是怎么做的。
#![allow(unused)] fn main() { for i in 0..elf_hdr.phnum as usize { let ph = ph_tab.add(i).as_ref().unwrap(); if ph.seg_type != elf::PH_SEG_TYPE_LOAD { continue; } if ph.memsz == 0 { continue; } memcpy(program_mem.add(ph.off), buffer.get().add(ph.off), ph.memsz); let mut bits = EntryBits::User.val(); if ph.flags & elf::PROG_EXECUTE != 0 { bits |= EntryBits::Execute.val(); } if ph.flags & elf::PROG_READ != 0 { bits |= EntryBits::Read.val(); } if ph.flags & elf::PROG_WRITE != 0 { bits |= EntryBits::Write.val(); } let pages = (ph.memsz + PAGE_SIZE) / PAGE_SIZE; for i in 0..pages { let vaddr = ph.vaddr + i * PAGE_SIZE; let paddr = program_mem as usize + ph.off + i * PAGE_SIZE; map(table, vaddr, paddr, bits, 0); } } }
我在上面的代码中所做的就是枚举所有的程序头文件。 ELF头文件通过phnum字段告诉我们有多少个头文件。然后我们检查段的类型,看它是否是LOAD。如果不是,我们就跳过它。 然后,我们检查该段是否真的包含任何东西。如果没有,那么加载它就没有用了。最后,我们把从文件系统中读到的东西(缓冲区)复制到进程的内存(program_mem)中。由于这些是虚拟内存地址,代码的其余部分决定了我们应该如何映射这些页面。
运行进程
我们需要映射一些东西,包括堆栈和程序。另外,别忘了将程序计数器设置为entry_addr!
#![allow(unused)] fn main() { (*my_proc.frame).pc = elf_hdr.entry_addr; (*my_proc.frame).regs[2] = STACK_ADDR as usize + STACK_PAGES * PAGE_SIZE; (*my_proc.frame).mode = CpuMode::User as usize; (*my_proc.frame).pid = my_proc.pid as usize; (*my_proc.frame).satp = build_satp(SatpMode::Sv39, my_proc.pid as usize, my_proc.root as usize); }
在这里,regs[2]是堆栈指针(SP),它必须是有效的并被映射,否则进程将立即出现页面故障。现在一切都准备好了,我们的最后一点执行工作是将其添加到进程列表中。当调度器开始工作时,它将运行我们新造的进程。
#![allow(unused)] fn main() { if let Some(mut pl) = unsafe { PROCESS_LIST.take() } { println!( "Added user process to the scheduler...get ready \ for take-off!" ); pl.push_back(my_proc); unsafe { PROCESS_LIST.replace(pl); } } else { println!("Unable to spawn process."); } }
编写用户空间程序
我们还没有一个C语言库。然而,我正在使操作系统访问newlib,它是一个主要用于嵌入式系统的小型C语言库。目前,我做了一个叫做startlib
的小库,它将使我们开始第一步,我把printf复制到了里面。
#![allow(unused)] fn main() { .section .text.init .global _start _start: call main li a0, 93 j make_syscall }
_start是一个特殊的标签,编译器将用它作为入口地址。回顾一下,当我们建立一个新的进程时,我们在程序计数器中设置了这个地址。在main返回后,我们安排了一个编号为93的系统调用,也就是 "exit "的系统调用。这个系统调用所做的就是取消进程的调度,释放其所有的资源。
还有其他一些实用程序,包括我们的小库中的printf,但是还是让我们做一个简单的程序,看看我们是否能让它工作。为了更加稳健,我将扩展我们所有的可用部分,看看它们是否能正常加载。
#include <printf.h>
const int SIZE = 1000;
int myarray[SIZE];
int another_array[5] = {1, 2, 3, 4, 5};
int main()
{
printf("I'm a C++ program, and I'm running in user space. How about a big, Hello World\n");
printf("My array is at 0x%p\n", myarray);
printf("I'm going to start crunching some numbers, so gimme a minute.\n");
for (int i = 0;i < SIZE;i++) {
myarray[i] = another_array[i % 5];
}
for (int i = 0;i < 100000000;i++) {
myarray[i % SIZE] += 1;
}
printf("Ok, I'm done crunching. Wanna see myarray[0]? It's %d\n", myarray[0]);
return 0;
}
这个程序其实并没有做什么有用的事情,但是它可以看到系统调用是否工作以及上下文切换。在QEMU上,这个程序在我家里的机器上运行大约需要5到8秒。
然后我们用我们的C++工具链(如果你有的话)来编译这个。riscv64-unknown-elf-g++ -Wall -O0 -ffreestanding -nostartfiles -nostdlib -static -march=rv64g -mabi=lp64d -I. /startlib -L. /startlib -o helloworld.elf helloworld.cpp -lstart
。
如果你没有工具链,你可以在这里下载我的程序:helloworld.elf. 这要求你的系统调用与我的相同,因为它是按系统调用号进行的。
上传程序
我们可以使用Linux来上传我们的elf文件。
请注意节点号(26)和文件大小(14776)。你的可能不一样,所以你可能要设置它。修改test.rs,把你的inode和文件大小放在顶部。
#![allow(unused)] fn main() { let files_inode = 26u32; // Change to yours! let files_size = 14776; // Change to yours! let bytes_to_read = 1024 * 50; let mut buffer = BlockBuffer::new(bytes_to_read); let bytes_read = syscall_fs_read( 8, files_inode, buffer.get_mut(), bytes_to_read as u32, 0, ); if bytes_read != files_size { println!( "Unable to load program at inode {}, which should \ be {} bytes, got {}", files_inode, files_size, bytes_read ); return; } }
这将使用我们的文件系统读取调用,将给定的inode读入内存。然后,我们仔细检查大小是否与stat所说的完全一致。然后我们开始ELF加载过程,正如我之前讨论的那样。
从这里开始,当你cargo运行
你的操作系统时,你应该看到以下内容。
让我们再看看是什么在做这件事:
#include <printf.h>
const int SIZE = 1000;
int myarray[SIZE];
int another_array[5] = {1, 2, 3, 4, 5};
int main()
{
printf("I'm a C++ program, and I'm running in user space. How about a big, Hello World\n");
printf("My array is at 0x%p\n", myarray);
printf("I'm going to start crunching some numbers, so gimme a minute.\n");
for (int i = 0;i < SIZE;i++) {
myarray[i] = another_array[i % 5];
}
for (int i = 0;i < 100000000;i++) {
myarray[i % SIZE] += 1;
}
printf("Ok, I'm done crunching. Wanna see myarray[0]? It's %d\n", myarray[0]);
return 0;
}
好吧,这难道不是一件好事吗?这就是我看到的打印到屏幕上的东西! 请注意,myarray[0]得到了100001。在开始时,我们把数值1放入myarray[0],然后每1000个100000000,我们再把一个数值放入myarray[0],总共是100001。所以,是的,我们的部分似乎是在正常运作
验证
让我们检查一下我们的helloworld.elf文件,看看我们在Rust中做了什么。让我们首先检查ELF头。你会看到我们的入口点0x101e4就是我们输入PC的内容(你可以println!this out来验证)。
接下来是程序头文件。这导致我在第一次检查程序头时写出了几个bug。注意到.text、.rodata和.eh_frame被放在第一个头文件中,有读取和执行的权限。还注意到0x303c和0x3040(PH0的结束和PH1的开始)重叠了物理页(但不是虚拟页)。我不喜欢这样。.rodata不应该是可执行的,但它是可执行的,因为它和.text部分在同一个物理和虚拟页面。
看看这个。我们的ELF头显示的正是我们在操作系统中验证的内容,程序头也是差不多的。在Linux中,唯一真正重要的文件类型是ELF。其他一切都由它需要的任何帮助程序来决定。
结论
欢迎来到这个操作系统教程的结尾。为了完善你的操作系统,你将需要增加read、write、readdir等的系统调用。我把libgloss的列表放在syscall.rs中(在底部),这样你就知道libgloss包含哪些系统调用。最终的测试是将gcc编译到你的文件系统中并从那里执行它。另外,我们需要有一个遍历函数来按名字遍历目录结构。目前,我们是通过节点号来定位的,这不是很好。然而,你可以看到一个操作系统的所有有用的部分。
本教程到此结束,但我将在我们的操作系统中加入一些东西。我计划加入图形和网络,但这扩大了本教程的范围,所以我将把它们放在自己的博客文章中。
恭喜你,你现在有一个可以运行进程的操作系统了 像往常一样,你可以在我的GitHub仓库里找到这个操作系统和它的所有更新:https://github.com/sgmarz/osblog.