实现记事本
为了实现上节中交互式终端的目标,先不管运行程序,我们首先要能够通过键盘向终端程序中输入。也就是说,我们要实现一个用户程序,它能够接受键盘的输入,并将键盘输入的字符显示在屏幕上。这不能叫一个终端,姑且叫它记事本吧。
这个用户程序需要的功能是:接受键盘输入(可以被称为“标准输入”)的一个字符。
为此我们需约定这样一个系统调用:
- 文件读入,系统调用
我们先在用户程序模板中声明该系统调用:
// usr/rust/src/syscall.rs
enum SyscallId {
Read = 63,
}
pub fn sys_read(fd: usize, base: *const u8, len: usize) -> i64 {
sys_call(SyscallId::Read, fd, base as usize, len, 0)
}
这里的系统调用接口设计上是一个记事本所需功能更强的文件读入:传入的参数中,fd
表示文件描述符,base
表示要将读入的内容保存到的虚拟地址,len
表示最多读入多少字节。其返回值是成功读入的字节数。
方便起见,我们还是将这个系统调用封装一下来实现我们所需的功能。
// usr/rust/src/io.rs
use crate::syscall::sys_read;
// 每个进程默认打开三个文件
// 标准输入 stdin fd = 0
// 标准输出 stdout fd = 1
// 标准错误输出 stderr fd = 2
pub const STDIN: usize = 0;
// 调用 sys_read 从标准输入读入一个字符
pub fn getc() -> u8 {
let mut c = 0u8;
assert_eq!(sys_read(STDIN, &mut c, 1), 1);
c
}
接下来我们可以利用 getc
着手实现我们的记事本了!
// usr/rust/src/bin/notebook.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user;
use user::io::getc;
use user::io::putchar;
const LF: u8 = 0x0au8;
const CR: u8 = 0x0du8;
const BS: u8 = 0x08u8;
const DL: u8 = 0x7fu8;
#[no_mangle]
pub fn main() {
println!("Welcome to notebook!");
let mut line_count = 0;
loop {
let c = getc();
match c {
LF | CR => {
line_count = 0;
print!("{}", LF as char);
print!("{}", CR as char);
}
DL => if line_count > 0 {
// 支持退格键
// 写法来源:https://github.com/mit-pdos/xv6-riscv-fall19/blob/xv6-riscv-fall19/kernel/console.c#L41
putchar(BS as char);
putchar(' ');
putchar(BS as char);
line_count -= 1;
}
_ => {
line_count += 1;
print!("{}", c as char);
}
}
}
}
很简单,就是将接受到的字符打印到屏幕上。
看一下 getc
的实现,我们满怀信心 sys_read
的返回值是 ,也就是确保一定能够读到字符。不过真的是这样吗?
缓冲区
实际上,我们用一个缓冲区来表示标准输入。你可以将其看作一个字符队列。
- 键盘是生产者:每当你按下键盘,所对应的字符会加入队尾;
sys_read
是消费者:每当调用sys_read
函数,会将队头的字符取出,并返回。
在 sys_read
的时候,如果队列不是空的,那么一切都好;如果队列是空的,由于它要保证能够读到字符,因此它只能够等到什么时候队列中加入了新的元素再返回。
而这里的“等”,又有两种等法:
最简单的等法是:在原地 while (q.empty()) {}
。也就是知道队列非空才跳出循环,取出队头的字符并返回。
另一种方法是:当 sys_read
发现队列是空的时候,自动放弃 CPU 资源进入睡眠(或称阻塞)状态,也就是从调度单元中移除当前所在线程,不再参与调度。而等到某时刻按下键盘的时候,发现有个线程在等着这个队列非空,于是赶快将它唤醒,重新加入调度单元,等待 CPU 资源分配过来继续执行。
后者相比前者的好处在于:前者占用了 CPU 资源却不干活,只是在原地等着;而后者虽然也没法干活,却很有自知之明的把 CPU 资源让给其他线程使用,这样就提高了 CPU 的利用率。
我们就使用后者来实现 sys_read
。
条件变量
这种线程将 CPU 资源放弃,并等到某个条件满足才准备继续运行的机制,可以使用条件变量 (Condition Variable) 来描述。而它的实现,需要依赖几个新的线程调度机制。
// src/process/mod.rs
// 当前线程自动放弃 CPU 资源并进入阻塞状态
// 线程状态: Running(Tid) -> Sleeping
pub fn yield_now() {
CPU.yield_now();
}
// 某些条件满足,线程等待 CPU 资源从而继续执行
// 线程状态: Sleeping -> Ready
pub fn wake_up(tid: Tid) {
CPU.wake_up(tid);
}
// 获取当前线程的 Tid
pub fn current_tid() -> usize {
CPU.current_tid()
}
// src/process/processor.rs
impl Processor {
...
pub fn yield_now(&self) {
let inner = self.inner();
if !inner.current.is_none() {
unsafe {
// 由于要进入 idle 线程,必须关闭异步中断
// 手动保存之前的 sstatus
let flags = disable_and_store();
let tid = inner.current.as_mut().unwrap().0;
let thread_info = inner.pool.threads[tid].as_mut().expect("thread not existed when yielding");
// 修改线程状态
thread_info.status = Status::Sleeping;
// 切换到 idle 线程
inner.current
.as_mut()
.unwrap()
.1
.switch_to(&mut *inner.idle);
// 从 idle 线程切换回来
// 恢复 sstatus
restore(flags);
}
}
}
pub fn wake_up(&self, tid: Tid) {
let inner = self.inner();
inner.pool.wakeup(tid);
}
pub fn current_tid(&self) -> usize {
self.inner().current.as_mut().unwrap().0 as usize
}
}
// src/process/thread_pool.rs
// 改成 public
pub struct ThreadInfo {
// 改成 public
pub status: Status,
pub thread: Option<Box<Thread>>,
}
pub struct ThreadPool {
// 改成 public
pub threads: Vec<Option<ThreadInfo>>,
scheduler: Box<dyn Scheduler>,
}
impl ThreadPool {
...
pub fn wakeup(&mut self, tid: Tid) {
let proc = self.threads[tid].as_mut().expect("thread not exist when waking up");
proc.status = Status::Ready;
self.scheduler.push(tid);
}
}
下面我们用这几种线程调度机制来实现条件变量。
// src/lib.rs
mod sync;
// src/sync/mod.rs
pub mod condvar;
// src/sync/condvar.rs
use spin::Mutex;
use alloc::collections::VecDeque;
use crate::process::{ Tid, current_tid, yield_now, wake_up };
#[derive(Default)]
pub struct Condvar {
// 加了互斥锁的 Tid 队列
// 存放等待此条件变量的众多线程
wait_queue: Mutex<VecDeque<Tid>>,
}
impl Condvar {
pub fn new() -> Self {
Condvar::default()
}
// 当前线程等待某种条件满足才能继续执行
pub fn wait(&self) {
// 将当前 Tid 加入此条件变量的等待队列
self.wait_queue
.lock()
.push_back(current_tid());
// 当前线程放弃 CPU 资源
yield_now();
}
// 条件满足
pub fn notify(&self) {
// 弹出等待队列中的一个线程
let tid = self.wait_queue.lock().pop_front();
if let Some(tid) = tid {
// 唤醒该线程
wake_up(tid);
}
}
}
讲清楚了机制,下面我们看一下具体实现。
缓冲区实现
// src/fs/mod.rs
pub mod stdio;
// src/fs/stdio.rs
use alloc::{ collections::VecDeque, sync::Arc };
use spin::Mutex;
use crate::process;
use crate::sync::condvar::*;
use lazy_static::*;
pub struct Stdin {
// 字符队列
buf: Mutex<VecDeque<char>>,
// 条件变量
pushed: Condvar,
}
impl Stdin {
pub fn new() -> Self {
Stdin {
buf: Mutex::new(VecDeque::new()),
pushed: Condvar::new(),
}
}
// 生产者:输入字符
pub fn push(&self, ch: char) {
// 将字符加入字符队列
self.buf
.lock()
.push_back(ch);
// 如果此时有线程正在等待队列非空才能继续下去
// 将其唤醒
self.pushed.notify();
}
// 消费者:取出字符
// 运行在请求字符输入的线程上
pub fn pop(&self) -> char {
loop {
// 将代码放在 loop 里面防止再复制一遍
// 尝试获取队首字符
let ret = self.buf.lock().pop_front();
match ret {
Some(ch) => {
// 获取到了直接返回
return ch;
},
None => {
// 否则队列为空,通过 getc -> sys_read 获取字符的当前线程放弃 CPU 资源
// 进入阻塞状态等待唤醒
self.pushed.wait();
// 被唤醒后回到循环开头,此时可直接返回
}
}
}
}
}
lazy_static! {
pub static ref STDIN: Arc<Stdin> = Arc::new(Stdin::new());
}
生产者:键盘中断
首先我们要能接受到外部中断,而 OpenSBI
默认将外部中断和串口开关都关上了,因此我们需要手动将他们打开:
// src/interrupt.rs
use crate::memory::access_pa_via_va;
use riscv::register::sie;
pub fn init() {
...
// enable external interrupt
sie::set_sext();
// closed by OpenSBI, so we open them manually
// see https://github.com/rcore-os/rCore/blob/54fddfbe1d402ac1fafd9d58a0bd4f6a8dd99ece/kernel/src/arch/riscv32/board/virt/mod.rs#L4
init_external_interrupt();
enable_serial_interrupt();
}
pub unsafe fn init_external_interrupt() {
let HART0_S_MODE_INTERRUPT_ENABLES: *mut u32 = access_pa_via_va(0x0c00_2080) as *mut u32;
const SERIAL: u32 = 0xa;
HART0_S_MODE_INTERRUPT_ENABLES.write_volatile(1 << SERIAL);
}
pub unsafe fn enable_serial_interrupt() {
let UART16550: *mut u8 = access_pa_via_va(0x10000000) as *mut u8;
UART16550.add(4).write_volatile(0x0B);
UART16550.add(1).write_volatile(0x01);
}
这里的内存尚未被映射,我们在内存模块初始化时完成映射:
// src/memory/mod.rs
pub fn kernel_remap() {
let mut memory_set = MemorySet::new();
extern "C" {
fn bootstack();
fn bootstacktop();
}
memory_set.push(
bootstack as usize,
bootstacktop as usize,
MemoryAttr::new(),
Linear::new(PHYSICAL_MEMORY_OFFSET),
None,
);
memory_set.push(
access_pa_via_va(0x0c00_2000),
access_pa_via_va(0x0c00_3000),
MemoryAttr::new(),
Linear::new(PHYSICAL_MEMORY_OFFSET),
None
);
memory_set.push(
access_pa_via_va(0x1000_0000),
access_pa_via_va(0x1000_1000),
MemoryAttr::new(),
Linear::new(PHYSICAL_MEMORY_OFFSET),
None
);
unsafe {
memory_set.activate();
}
}
也因此,内存模块要比中断模块先初始化。
// src/init.rs
#[no_mangle]
pub extern "C" fn rust_main() -> ! {
extern "C" {
fn end();
}
crate::memory::init(
((end as usize - KERNEL_BEGIN_VADDR + KERNEL_BEGIN_PADDR) >> 12) + 1,
PHYSICAL_MEMORY_END >> 12
);
crate::interrupt::init();
crate::fs::init();
crate::process::init();
crate::timer::init();
crate::process::run();
loop {}
}
随后,我们对外部中断进行处理:
// src/interrupt.rs
#[no_mangle]
pub fn rust_trap(tf: &mut TrapFrame) {
...
Trap::Interrupt(Interrupt::SupervisorExternal) => external(),
...
}
fn external() {
// 键盘属于一种串口设备,而实际上有很多种外设
// 这里我们只考虑串口
let _ = try_serial();
}
fn try_serial() -> bool {
// 通过 OpenSBI 获取串口输入
match super::io::getchar_option() {
Some(ch) => {
// 将获取到的字符输入标准输入
if (ch == '\r') {
crate::fs::stdio::STDIN.push('\n');
}
else {
crate::fs::stdio::STDIN.push(ch);
}
true
},
None => false
}
}
// src/io.rs
pub fn getchar() -> char {
let c = sbi::console_getchar() as u8;
match c {
255 => '\0',
c => c as char
}
}
// 调用 OpenSBI 接口
pub fn getchar_option() -> Option<char> {
let c = sbi::console_getchar() as isize;
match c {
-1 => None,
c => Some(c as u8 as char)
}
}
消费者:sys_read 实现
这就很简单了。
// src/syscall.rs
pub const SYS_READ: usize = 63;
pub fn syscall(id: usize, args: [usize; 3], tf: &mut TrapFrame) -> isize {
match id {
SYS_READ => {
sys_read(args[0], args[1] as *mut u8, args[2])
}
...
}
}
// 这里 fd, len 都没有用到
fn sys_read(fd: usize, base: *mut u8, len: usize) -> isize {
unsafe {
*base = crate::fs::stdio::STDIN.pop() as u8;
}
return 1;
}
这里我们要写入用户态内存,但是 CPU 默认并不允许在内核态访问用户态内存,因此我们要在内存初始化的时候将开关打开:
// src/memory/mod.rs
use riscv::register::sstatus;
pub fn init(l: usize, r: usize) {
unsafe {
sstatus::set_sum();
}
// 以下不变
FRAME_ALLOCATOR.lock().init(l, r);
init_heap();
kernel_remap();
println!("++++ setup memory! ++++");
}
现在我们可以将要运行的程序从 rust/hello_world
改成 rust/notebook
了!
将多余的线程换入换出提示信息删掉,运行一下,我们已经实现了字符的输入及显示了!可以享受输入带来的乐趣了!(大雾
如果记事本不能正常工作,可以在这里找到已有的代码。