【本实验目标】
- 扩展文件抽象:Pipe,Stdout,Stdin
- 以文件形式进行进程间数据交换
- 以文件形式进行串口输入输出
- 信号机制实现进程间异步通知机制
- 本章要完成的操作系统的核心目标是: 让不同应用通过进程间通信的方式组合在一起运行 。
【总体思路】
【管道实现需要考虑的问题】
-
管道是内核中的一块内存:顺序写入/读出字节流
-
管道可抽象为文件:
- 进程中包含管道文件描述符,管道的
File Trait
的接口:read/write - 应用创建管道的系统调用:
sys_pipe
- 进程中包含管道文件描述符,管道的
-
pipe
是进程控制块的资源之一。
【信号实现需要考虑的问题】
signal
是内核通知应用的软件中断。- 设定**
signal
的整数编号值** - 建立应对某
signal
编号值的例程signal_handler
- 设定**
- 向某进程发出
signal
,打断进程的当前执行,转到signal_handler
执行。 signal
是进程控制块的资源之一
【一切皆是文件】
UNIX操作系统之前,大多数的操作系统提供了各种复杂且不规则的设计实现来处理各种I/O设备,如键盘、显示器、以磁盘为代表的存储介质、以串口为代表的通信设备等,使得应用程序开发繁琐且很难统一表示和处理I/O设备。
在 UNIX 操作系统中,”一切皆文件“ (Everything is a file) 是一种重要的设计思想,这种设计思想继承于 Multics 操作系统的 通用性 文件的设计理念。
在本章中,应用程序访问的 文件 (File) 就是一系列的字节组合。操作系统管理文件,但操作系统不关心文件内容,只关心如何对文件按字节流进行读写的机制,这就意味着任何程序可以读写任何文件(即字节流),对文件具体内容的解析是应用程序的任务,操作系统对此不做任何干涉。例如,一个Rust编译器可以读取一个C语言源程序并进行编译,操作系统并并不会阻止这样的事情发生。
- 键盘设备 是程序获得字符输入的一种设备,也可抽象为一种只读性质的文件,可以从这个文件中读出一系列的字节序列;
- 屏幕设备 是展示程序的字符输出结果的一种字符显示设备,可抽象为一种只写性质的文件,可以向这个文件中写入一系列的字节序列,在显示屏上可以直接呈现出来;
- 串口设备 是获得字符输入和展示程序的字符输出结果的一种字符通信设备,可抽象为一种可读写性质的文件,可以向这个文件中写入一系列的字节序列传给程序,也可把程序要显示的字符传输出去;还可以把这个串口设备拆分成两个文件,一个用于获取输入字符的只读文件和一个传出输出字符的只写文件。
【实现过程】
按照从下向上进行实现的步骤如下所示:
- 支持标准输入/输出文件
- 支持管道文件
- 支持对应用程序的命令行参数的解析和传递
- 实现标准 I/O 重定向功能
【代码架构】
├── bootloader
│ ├── rustsbi-k210.bin
│ └── rustsbi-qemu.bin
├── LICENSE
├── os
│ ├── build.rs
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── Makefile
│ └── src
│ ├── config.rs
│ ├── console.rs
│ ├── entry.asm
│ ├── fs(新增:文件系统子模块 fs)
│ │ ├── mod.rs(包含已经打开且可以被进程读写的文件的抽象 File Trait)
│ │ ├── pipe.rs(实现了 File Trait 的第一个分支——可用来进程间通信的管道)
│ │ └── stdio.rs(实现了 File Trait 的第二个分支——标准输入/输出)
│ ├── lang_items.rs
│ ├── link_app.S
│ ├── linker-k210.ld
│ ├── linker-qemu.ld
│ ├── loader.rs
│ ├── main.rs
│ ├── mm
│ │ ├── address.rs
│ │ ├── frame_allocator.rs
│ │ ├── heap_allocator.rs
│ │ ├── memory_set.rs
│ │ ├── mod.rs
│ │ └── page_table.rs
│ ├── sbi.rs
│ ├── syscall
│ │ ├── fs.rs(修改:调整 sys_read/write 的实现,新增 sys_dup/pipe)
│ │ ├── mod.rs(修改:调整 syscall 分发)
│ │ └── process.rs
│ ├── task
│ │ ├── context.rs
│ │ ├── manager.rs
│ │ ├── mod.rs
│ │ ├── pid.rs
│ │ ├── processor.rs
│ │ ├── switch.rs
│ │ ├── switch.S
│ │ └── task.rs(修改:在任务控制块中加入文件描述符表相关机制)
│ ├── timer.rs
│ └── trap
│ ├── context.rs
│ ├── mod.rs
│ └── trap.S
├── README.md
├── rust-toolchain
├── tools
│ ├── kflash.py
│ ├── LICENSE
│ ├── package.json
│ ├── README.rst
│ └── setup.py
└── user
├── Cargo.lock
├── Cargo.toml
├── Makefile
└── src
├── bin
│ ├── exit.rs
│ ├── fantastic_text.rs
│ ├── forktest2.rs
│ ├── forktest.rs
│ ├── forktest_simple.rs
│ ├── forktree.rs
│ ├── hello_world.rs
│ ├── initproc.rs
│ ├── matrix.rs
│ ├── pipe_large_test.rs(新增)
│ ├── pipetest.rs(新增)
│ ├── run_pipe_test.rs(新增)
│ ├── sleep.rs
│ ├── sleep_simple.rs
│ ├── stack_overflow.rs
│ ├── user_shell.rs
│ ├── usertests.rs
│ └── yield.rs
├── console.rs
├── lang_items.rs
├── lib.rs(新增两个系统调用:sys_dup/sys_pipe)
├── linker.ld
└── syscall.rs(新增两个系统调用:sys_dup/sys_pipe)
【基于文件的标准输入/输出 】
【标准输入/输出对 File trait
的实现 -> os/src/fs/stdio.rs】
- 我们把标准输出设备在文件描述符表中的文件描述符的值规定为
1
,用Stdout
表示。 - 我们把标准输入设备在文件描述符表中的文件描述符的值规定为
0
,用Stdin
表示 。
现在,我们可以重构操作系统,为标准输入和标准输出实现 File Trait
,使得进程可以按文件接口与I/O外设进行交互。
目的是将原本的标准IO输入输出进行封装为文件的形式。
原来的形式为
sys_write
和sys_read
等形式的系统调用,现在封装为文件形式后便可以通过read
,write
对文件进行操作实现IO
功能。
// os/src/fs/stdio.rs
use super::File;
use crate::mm::{UserBuffer};
use crate::sbi::console_getchar;
use crate::task::suspend_current_and_run_next;
/// The standard input
pub struct Stdin;
/// The standard output
pub struct Stdout;
impl File for Stdin {
fn readable(&self) -> bool { true }
fn writable(&self) -> bool { false }
fn read(&self, mut user_buf: UserBuffer) -> usize {
assert_eq!(user_buf.len(), 1);
// busy loop
let mut c: usize;
loop {
c = console_getchar();
if c == 0 {
suspend_current_and_run_next();
continue;
} else {
break;
}
}
let ch = c as u8;
unsafe { user_buf.buffers[0].as_mut_ptr().write_volatile(ch); }
1
}
fn write(&self, _user_buf: UserBuffer) -> usize {
panic!("Cannot write to stdin!");
}
}
impl File for Stdout {
fn readable(&self) -> bool { false }
fn writable(&self) -> bool { true }
fn read(&self, _user_buf: UserBuffer) -> usize{
panic!("Cannot read from stdout!");
}
fn write(&self, user_buf: UserBuffer) -> usize {
for buffer in user_buf.buffers.iter() {
print!("{}", core::str::from_utf8(*buffer).unwrap());
}
user_buf.len()
}
}
- 第 12 - 35 行:为
Stdin
实现了File trait
,包括readable
、writable
、read
、write
方法。- 第 13 行:实现了
readable
方法判断Stdin
文件是否可读,标准输入文件Stdin
是只读文件,所以直接返回true
。 - 第 14 行:实现了
writable
方法判断Stdin
文件是否可写,标准输入文件Stdin
是只读文件,所以直接返回false
。 - 第 15 - 31 行:实现了
read
方法允许进程通过read
从里面读入,目前每次仅支持读入一个字符。- 第 16 行:目前只支持一个字符一个字符读入。
- 第 20 行:调用
sbi
子模块提供的从键盘获取输入的接口console_getchar
。 - 第 21 - 23 行:如果返回 0 则说明还没有输入,我们调用
suspend_current_and_run_next
暂时切换到其他进程,等下次切换回来的时候再看看是否有输入了。 - 第 29 行:手动查页表将输入的字符正确的写入到应用地址空间。
- 第 32 - 34 行:实现了
write
方法,想向Stdin
中写入,由于Stdin
是只读文件,只允许进程通过read
从里面读入,所以当向里面写入的时候panic
。
- 第 13 行:实现了
- 第 37 - 49 行:为
Stdout
实现了File trait
,包括readable
、writable
、read
、write
方法。- 第 38 行:实现了
readable
方法判断Stdout
文件是否可读,标准输出文件Stdout
是只写文件,只允许进程通过write
写入到里面,所以直接返回false
。 - 第 39 行:实现了
writable
方法判断Stdout
文件是否可写,标准输出文件Stdout
是只写文件,只允许进程通过write
写入到里面,所以直接返回true
。 - 第 40 - 42 行:实现了
read
方法,想向Stdin
中读取,标准输出文件Stdout
是只写文件,只允许进程通过write
写入到里面,所以当向里面读的时候panic
。 - 第 43 - 48 行:实现了
write
方法,想向其中写入,也就是遍历每个切片,将其转化为字符串通过print!
宏来输出,返回整体长度。
- 第 38 行:实现了
【对标准输入/输出的管理】
应用程序如果要基于文件进行I/O访问,大致就会涉及如下几个操作:
- 打开(open):进程只有打开文件,操作系统才能返回一个可进行读写的文件描述符给进程,进程才能基于这个值来进行对应文件的读写;
- 关闭(close):进程基于文件描述符关闭文件后,就不能再对文件进行读写操作了,这样可以在一定程度上保证对文件的合法访问;
- 读(read):进程可以基于文件描述符来读文件内容到相应内存中;
- 写(write):进程可以基于文件描述符来把相应内存内容写到文件中;
当一个进程被创建的时候,内核会默认为其打开三个缺省就存在的文件:
- 文件描述符为 0 的标准输入
- 文件描述符为 1 的标准输出
- 文件描述符为 2 的标准错误输出
基于上一节File OS的代码实现,当进程打开一个文件的时候,内核总是会将文件分配到该进程文件描述符表中 最小的 空闲位置。
比如,当一个进程被创建以后立即打开一个文件,则内核总是会返回文件描述符 3 (0~2号文件描述符已被缺省打开了)。当我们关闭一个打开的文件之后,它对应的文件描述符将会变得空闲并在后面可以被分配出去。
【创建标准输入/输出文件】
当新建一个进程的时候,我们需要按照先前的说明为进程打开标准输入文件和标准输出文件:
// os/src/task/task.rs
impl TaskControlBlock {
/// Get the mutex to get the RefMut TaskControlBlockInner
pub fn inner_exclusive_access(&self) -> RefMut<'_, TaskControlBlockInner> {
self.inner.exclusive_access()
}
/// Create a new process
///
/// At present, it is only used for the creation of initproc
pub fn new(elf_data: &[u8]) -> Self {
// memory_set with elf program headers/trampoline/trap context/user stack
let (memory_set, user_sp, entry_point) = MemorySet::from_elf(elf_data);
let trap_cx_ppn = memory_set
.translate(VirtAddr::from(TRAP_CONTEXT).into())
.unwrap()
.ppn();
// alloc a pid and a kernel stack in kernel space
let pid_handle = pid_alloc();
let kernel_stack = KernelStack::new(&pid_handle);
let kernel_stack_top = kernel_stack.get_top();
// push a task context which goes to trap_return to the top of kernel stack
let task_control_block = Self {
pid: pid_handle,
kernel_stack,
inner: unsafe {
UPSafeCell::new(TaskControlBlockInner {
trap_cx_ppn,
base_size: user_sp,
task_cx: TaskContext::goto_trap_return(kernel_stack_top),
task_status: TaskStatus::Ready,
memory_set,
parent: None,
children: Vec::new(),
exit_code: 0,
fd_table: alloc::vec![
// 0 -> stdin
Some(Arc::new(Stdin)),
// 1 -> stdout
Some(Arc::new(Stdout)),
// 2 -> stderr
Some(Arc::new(Stdout)),
],
})
},
};
// prepare TrapContext in user space
let trap_cx = task_control_block.inner_exclusive_access().get_trap_cx();
*trap_cx = TrapContext::app_init_context(
entry_point,
user_sp,
KERNEL_SPACE.exclusive_access().token(),
kernel_stack_top,
trap_handler as usize,
);
task_control_block
}
/// Fork from parent to child
pub fn fork(self: &Arc<TaskControlBlock>) -> Arc<TaskControlBlock> {
// ---- access parent PCB exclusively
let mut parent_inner = self.inner_exclusive_access();
// copy user space(include trap context)
let memory_set = MemorySet::from_existed_user(&parent_inner.memory_set);
let trap_cx_ppn = memory_set
.translate(VirtAddr::from(TRAP_CONTEXT).into())
.unwrap()
.ppn();
// alloc a pid and a kernel stack in kernel space
let pid_handle = pid_alloc();
let kernel_stack = KernelStack::new(&pid_handle);
let kernel_stack_top = kernel_stack.get_top();
let mut new_fd_table: Vec<Option<Arc<dyn File + Send + Sync>>> = Vec::new();
// clone all fds from parent to child
for fd in parent_inner.fd_table.iter() {
if let Some(file) = fd {
new_fd_table.push(Some(file.clone()));
} else {
new_fd_table.push(None);
}
}
let task_control_block = Arc::new(TaskControlBlock {
pid: pid_handle,
kernel_stack,
inner: unsafe {
UPSafeCell::new(TaskControlBlockInner {
trap_cx_ppn,
base_size: parent_inner.base_size,
task_cx: TaskContext::goto_trap_return(kernel_stack_top),
task_status: TaskStatus::Ready,
memory_set,
parent: Some(Arc::downgrade(self)),
children: Vec::new(),
exit_code: 0,
fd_table: new_fd_table,
})
},
});
// add child
parent_inner.children.push(task_control_block.clone());
// modify kernel_sp in trap_cx
// **** access children PCB exclusively
let trap_cx = task_control_block.inner_exclusive_access().get_trap_cx();
trap_cx.kernel_sp = kernel_stack_top;
// return
task_control_block
// ---- release parent PCB automatically
// **** release children PCB automatically
}
}
-
第 11 - 57 行:为
TaskControlBlock
实现了new
方法创建一个进程。该进程创建具体代码解读参考Process OS
,其唯一的变化在 36 - 43 行,在文件描述表中增加了前三个标准输入输出文件。-
第 38 行:创建文件项,由于第一个创建按照道理其
fd
为0
。也就是把标准输入设备在文件描述符表中的文件描述符的值规定为0
。 -
第 40 - 42 行:创建文件项,由于第二个创建按照道理其
fd
为1
。也就是把标准输出设备在文件描述符表中的文件描述符的值规定为1、2
。在我们的实现中并不区分标准输出和标准错误输出,而是会将文件描述符 1 和 2 均对应到标准输出。
-
-
第 60 - 109 行:为
TaskControlBlock
实现了fork
方法创建一个子进程,该进程创建具体代码解读参考Process OS
,其唯一的变化在 73 - 81 行,在fork
的时候,子进程需要完全继承父进程的文件描述符表来和父进程共享所有文件。- 第 73 行:创建一个文件描述符表。
- 第 75 - 81 行:将父进程的文件描述符表中的向量一个个拷贝到新的文件描述符表中。
- 第 95 行:在创建新的子进程的时候将新创建的文件描述符表作为参数传入完成拷贝。
【继承标准输入/输出文件】
在 fork
的时候,子进程需要完全继承父进程的文件描述符表来和父进程共享所有文件。这样即使我们仅手动为初始进程 initproc
打开了标准输入输出,所有进程也都可以访问它们。
基于文件的标准输入/输出小结:以文件和文件描述符概念来重新定义标准输入/输出也就是为标准输入输出口实现了文件
trait
,这样便将进程对于标准输入输出的访问sys_read\sys_write
修改为基于文件抽象read\write
的接口实现。对 一切皆是文件 的设计思路进一步进行了完善。原本read\write只能对文件的操作通过重定义增加了对标准IO的操作了,换句话就是统一了对标准IO的操作。
将原来对
sys_read、sys_write
的操作进行了封装为read、write
的方法,但其内部与sys_read、sys_write
是一样的。通过文件描述符可以找到对应的标准输入和输出,通过read、write
的封装可以实现sys_read、sys_write
的功能。
【管道】
基于文件接口 File
来把不同进程的输入和输出连接起来,从而在不改变应用程序代码的情况下,让操作系统具有进程间信息交换和功能组合的能力。这需要我们实现一种父子进程间的单向进程间通信机制 — 管道,并为此实现两个新的系统调用 sys_pipe
和 sys_close
。
【应用层的管道使用方法 -> user/src/bin/pipetest.rs -> user/src/lib.rs -> user/src/syscall.rs】
首先来从简单的管道测例 pipetest
中介绍管道的使用方法:
// user/src/bin/pipetest.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
use user_lib::{close, fork, pipe, read, wait, write};
static STR: &str = "Hello, world!";
#[no_mangle]
pub fn main() -> i32 {
// create pipe
let mut pipe_fd = [0usize; 2];
pipe(&mut pipe_fd);
// read end
assert_eq!(pipe_fd[0], 3);
// write end
assert_eq!(pipe_fd[1], 4);
if fork() == 0 {
// child process, read from parent
// close write_end
close(pipe_fd[1]);
let mut buffer = [0u8; 32];
let len_read = read(pipe_fd[0], &mut buffer) as usize;
// close read_end
close(pipe_fd[0]);
assert_eq!(core::str::from_utf8(&buffer[..len_read]).unwrap(), STR);
println!("Read OK, child process exited!");
0
} else {
// parent process, write to child
// close read end
close(pipe_fd[0]);
assert_eq!(write(pipe_fd[1], STR.as_bytes()), STR.len() as isize);
// close write end
close(pipe_fd[1]);
let mut child_exit_code: i32 = 0;
wait(&mut child_exit_code);
assert_eq!(child_exit_code, 0);
println!("pipetest passed!");
0
}
}
- 第 15 行:创建保存管道文件描述符的数组,分别为
usize
类型。 - 第 16 行:创建管道。将管道读和写的文件描述符写到
pipe_fd
中。 - 第 18 - 20 行:按照文件描述符从小到大创建的特性,则除去标准输入和输出对应的文件描述符,
pipe_fd[0]
保存的读端文件描述符应该为3,pipe_fd[1]
保存的写端文件描述符应该为4。 - 第 21 行:创建子进程,在
fork
之后,子进程会完全继承父进程的文件描述符表,于是子进程也可以通过同样的文件描述符来访问同一个管道的读端和写端。- 第 24 行:管道是单向的,在这个测例中我们希望管道中的数据从父进程流向子进程,也即父进程仅通过管道的写端写入数据,而子进程仅通过管道的读端读取数据,所以关闭子进程的写端文件描述符。
- 第 25 行:创建缓存
buff
用来存放读取的数据。 - 第 26 行:通过
read
来从文件中读取数据,也就是从管道中读取数据。 - 第 28 行:关闭子进程的读端文件描述符。
- 第 29 行:判断读到的数据是否和传入管道的数据是一样的。
- 第 32 行:
else
后为父进程的后续处理。- 第 35 行:关闭父进程的读端文件描述符。因为父进程只负责往管道里发送数据。
- 第 36 行:判断往管道中写入的数据是否写入成功。如果写入成功则返回写入的字符长度,通过判断两个字符长度确定是否写入成功。
- 第 38 行:关闭写文件描述符。
- 第 40 行:等待子进程的退出。
- 第 41 行:判断子进程的退出码是否正确。
如果想在父子进程之间实现双向通信,我们就必须创建两个管道。有兴趣的同学可以参考测例
pipe_large_test
。
通过观察发现应用程序在创建管道的时候使用了pipe
函数,该函数的具体实现如下所示,其内部通过sys_pipe
系统调用实现创建管道:
// user/src/lib.rs
pub fn pipe(pipe_fd: &mut [usize]) -> isize {
sys_pipe(pipe_fd)
}
在user/src/syscall.rs
文件下新增一个系统调用来为当前进程打开一个代表管道的文件集,其内部通过syscall
系统调用来实现,ID
为59
。
/// user/src/syscall.rs
/// 功能:为当前进程打开一个管道。
/// 参数:pipe 表示应用地址空间中的一个长度为 2 的 usize 数组的起始地址,内核需要按顺序将管道读端
/// 和写端的文件描述符写入到数组中。
/// 返回值:如果出现了错误则返回 -1,否则返回 0 。可能的错误原因是:传入的地址不合法。
/// syscall ID:59
const SYSCALL_PIPE: usize = 59;
pub fn sys_pipe(pipe: &mut [usize]) -> isize {
syscall(SYSCALL_PIPE, [pipe.as_mut_ptr() as usize, 0, 0])
}
对应应用层而言是使用内核提供的管道的系统调用来实现的:pipe -> sys_pipe -> syscall
【基于文件的管道 -> os/src/fs/pipe.rs -> os/src/task/task.rs -> os/src/syscall/fs.rs】
os/src/fs/pipe.rs
文件主要描述了管道相关的结构体和创建管道的内部实现。
// os/src/fs/pipe.rs
use super::File;
use alloc::sync::{Arc, Weak};
use crate::sync::UPSafeCell;
use crate::mm::UserBuffer;
use crate::task::suspend_current_and_run_next;
/// One end of a pipe
pub struct Pipe {
readable: bool,
writable: bool,
buffer: Arc<UPSafeCell<PipeRingBuffer>>,
}
impl Pipe {
/// Create the read end of a pipe from a ring buffer
pub fn read_end_with_buffer(buffer: Arc<UPSafeCell<PipeRingBuffer>>) -> Self {
Self {
readable: true,
writable: false,
buffer,
}
}
/// Create the write end of a pipe with a ring buffer
pub fn write_end_with_buffer(buffer: Arc<UPSafeCell<PipeRingBuffer>>) -> Self {
Self {
readable: false,
writable: true,
buffer,
}
}
}
const RING_BUFFER_SIZE: usize = 32;
#[derive(Copy, Clone, PartialEq)]
enum RingBufferStatus {
FULL,
EMPTY,
NORMAL,
}
/// The underlying ring buffer of a pipe
pub struct PipeRingBuffer {
arr: [u8; RING_BUFFER_SIZE],
head: usize,
tail: usize,
status: RingBufferStatus,
write_end: Option<Weak<Pipe>>,
}
impl PipeRingBuffer {
pub fn new() -> Self {
Self {
arr: [0; RING_BUFFER_SIZE],
head: 0,
tail: 0,
status: RingBufferStatus::EMPTY,
write_end: None,
}
}
/// Set the write end bound to this buffer
pub fn set_write_end(&mut self, write_end: &Arc<Pipe>) {
self.write_end = Some(Arc::downgrade(write_end));
}
/// Write into the buffer
pub fn write_byte(&mut self, byte: u8) {
self.status = RingBufferStatus::NORMAL;
self.arr[self.tail] = byte;
self.tail = (self.tail + 1) % RING_BUFFER_SIZE;
if self.tail == self.head {
self.status = RingBufferStatus::FULL;
}
}
/// Read from the buffer
pub fn read_byte(&mut self) -> u8 {
self.status = RingBufferStatus::NORMAL;
let c = self.arr[self.head];
self.head = (self.head + 1) % RING_BUFFER_SIZE;
if self.head == self.tail {
self.status = RingBufferStatus::EMPTY;
}
c
}
/// Get the length of remaining data in the buffer
pub fn available_read(&self) -> usize {
if self.status == RingBufferStatus::EMPTY {
0
} else {
if self.tail > self.head {
self.tail - self.head
} else {
self.tail + RING_BUFFER_SIZE - self.head
}
}
}
/// Get the length of remaining space in the buffer
pub fn available_write(&self) -> usize {
if self.status == RingBufferStatus::FULL {
0
} else {
RING_BUFFER_SIZE - self.available_read()
}
}
/// Check if all write ends bounded to this buffer are closed
pub fn all_write_ends_closed(&self) -> bool {
self.write_end.as_ref().unwrap().upgrade().is_none()
}
}
/// Crate a pipe
/// return (read_end, write_end)
pub fn make_pipe() -> (Arc<Pipe>, Arc<Pipe>) {
let buffer = Arc::new(unsafe {
UPSafeCell::new(PipeRingBuffer::new())
});
let read_end = Arc::new(
Pipe::read_end_with_buffer(buffer.clone())
);
let write_end = Arc::new(
Pipe::write_end_with_buffer(buffer.clone())
);
buffer.exclusive_access().set_write_end(&write_end);
(read_end, write_end)
}
impl File for Pipe {
fn readable(&self) -> bool { self.readable }
fn writable(&self) -> bool { self.writable }
fn read(&self, buf: UserBuffer) -> usize {
assert_eq!(self.readable(), true);
let mut buf_iter = buf.into_iter();
let mut read_size = 0usize;
loop {
let mut ring_buffer = self.buffer.exclusive_access();
let loop_read = ring_buffer.available_read();
if loop_read == 0 {
if ring_buffer.all_write_ends_closed() {
return read_size;
}
drop(ring_buffer);
suspend_current_and_run_next();
continue;
}
// read at most loop_read bytes
for _ in 0..loop_read {
if let Some(byte_ref) = buf_iter.next() {
unsafe { *byte_ref = ring_buffer.read_byte(); }
read_size += 1;
} else {
return read_size;
}
}
}
}
fn write(&self, buf: UserBuffer) -> usize {
assert_eq!(self.writable(), true);
let mut buf_iter = buf.into_iter();
let mut write_size = 0usize;
loop {
let mut ring_buffer = self.buffer.exclusive_access();
let loop_write = ring_buffer.available_write();
if loop_write == 0 {
drop(ring_buffer);
suspend_current_and_run_next();
continue;
}
// write at most loop_write bytes
for _ in 0..loop_write {
if let Some(byte_ref) = buf_iter.next() {
ring_buffer.write_byte(unsafe { *byte_ref });
write_size += 1;
} else {
return write_size;
}
}
}
}
}
-
第 10 - 14 行:为管道定义了结构体。后续我们将为它实现
File
Trait ,之后它便可以通过文件描述符来访问。- 第 11 行:
readable
指出该管道端可否支持读取 - 第 12 行:
writable
指出该管道端可否支持写入 - 第 13 行:通过
buffer
字段可以找到该管道端所在的管道自身。
- 第 11 行:
-
第 16 - 33 行:为
Pipe
管道实现了两个方法,read/write_end_with_buffer
方法可以分别从一个已有的管道创建它的读端和写端。读端和写端的访问权限进行了相应设置:不允许向读端写入,也不允许从写端读取。 -
第 38 - 42 行:
RingBufferStatus
定义了缓冲区的的状态。FULL
表示缓冲区已满不能再继续写入;EMPTY
表示缓冲区为空无法从里面读取;而NORMAL
则表示除了FULL
和EMPTY
之外的其他状态。 -
第 45 - 51 行:定义了管道自身,也就是带有一定大小缓冲区的字节队列。
- 第 46 - 48 行:
PipeRingBuffer
的arr/head/tail
三个字段用来维护一个循环队列,其中arr
为存放数据的数组,head
为循环队列队头的下标,tail
为循环队列队尾的下标。 - 第 49 行:
status
的类型为RingBufferStatus
,记录了缓冲区目前的状态。 - 第 50 行:
write_end
字段还保存了它的写端的一个弱引用计数,这是由于在某些情况下需要确认该管道所有的写端是否都已经被关闭了,通过这个字段很容易确认这一点。
- 第 46 - 48 行:
-
第 53 - 110 行:为
PipeRingBuffer
实现了一些方法。-
第 54 - 62 行:实现了
PipeRingBuffer::new
可以创建一个新的管道。 -
第 64 - 66 行:实现了
PipeRingBuffer::set_write_end
方法,在管道中保留它的写端的弱引用计数。 -
第 68 - 75 行:实现了
PipeRingBuffer::write_byte
方法可以从管道中写入一个字节。 -
第 77 - 85 行:实现了
PipeRingBuffer::read_byte
方法可以从管道中读取一个字节。- 第 78 行:因为为读操作,所以状态肯定不是满的读一个少一个,所以先设置状态为正常态。
- 第 79 行:从
arr
中取读对应head
的字节。 - 第 80 行:将
head
的位置进行加一,因为是循环队列所以如果满了则指向开头。 - 第 81 - 83 行:若
head
指向了tail
则代表该循环队列为空了,更新status
状态位。
-
第 87 - 97 行:实现了
PipeRingBuffer::available_read
方法计算管道中还有多少个字符可以读取。- 第 88 - 89 行:判断管道是否为空,为空则返回0表明没有可读的。
- 第 91 - 92 行:由于是循环队列,当为正常时可读数为
tail - head
。 - 第 93 - 94 行:当
head
大于tail
时可读数为tail + RING_BUFFER_SIZE - head
。
-
第 99 - 105 行:实现了
PipeRingBuffer::available_write
方法计算管道中还有多少个字符可以写入。 -
第 107 - 109 行:实现了
PipeRingBuffer::all_write_ends_closed
方法,可以判断管道的所有写端是否都被关闭了。这是通过尝试将管道中保存的写端的弱引用计数升级为强引用计数来实现的。如果升级失败的话,说明管道写端的强引用计数为 0 ,也就意味着管道所有写端都被关闭了,从而管道中的数据不会再得到补充,待管道中仅剩的数据被读取完毕之后,管道就可以被销毁了。
-
-
第 114 - 126 行:实现了
make_pipe
方法可以创建一个管道并返回它的读端和写端。- 第 115 - 117 行:通过
RingBufferStatus:new
方法创建一个buff
存储管道自身。 - 第 118 - 120 行:通过
RingBufferStatus:read_end_with_buffer
方法创建一个读管道。 - 第 121 - 123 行:通过
RingBufferStatus:write_end_with_buffer
方法创建一个写管道。 - 第 124 行:保存写端的弱引用计数。
先创建一个存储管道的
buff
,再根据这个buff
创建对应的读管道和写管道进行返回。 - 第 115 - 117 行:通过
-
第 128 - 180 行:为
Pipe
实现了File trait
。包括readable
、writable
、read
、write
方法。-
第 129 行:实现了
readable
方法,判断Pipe
是否可读,返回readable
标志位。 -
第 130 行:实现了
writable
方法,判断Pipe
是否可写,返回writable
标志位。 -
第 131 - 156 行:实现了
read
方法,向Pipe
管道读出数据。-
第 132 行:首先判断当前
Pipe
是否可读。 -
第 133 行:
buf_iter
将传入的应用缓冲区buf
转化为一个能够逐字节对于缓冲区进行访问的迭代器,每次调用buf_iter.next()
即可按顺序取出用于访问缓冲区中一个字节的裸指针。 -
第 134 行:
read_size
用来维护实际有多少字节从管道读入应用的缓冲区。File::read
的语义是要从文件中最多读取应用缓冲区大小那么多字符。这可能超出了循环队列的大小,或者由于尚未有进程从管道的写端写入足够的字符,因此我们需要将整个读取的过程放在一个循环中,当循环队列中不存在足够字符的时候暂时进行任务切换,等待循环队列中的字符得到补充之后再继续读取。 -
第 135 - 155 行:为读取的大循环。
- 第 137 行:用
loop_read
来保存循环这一轮次中可以从管道循环队列中读取多少字符。 - 第 138 行:代表这次循环管道中并没有数据。
- 第 139 行:若管道为空则会检查管道的所有写端是否都已经被关闭
- 第 140 行:若都已关闭了说明我们已经没有任何字符可以读取了,这时可以直接返回。
- 第 143 行:我们调用
suspend_current_and_run_next
切换到其他任务,等管道的字符得到填充之后再继续读取。 - 第 147 行:如果
loop_read
不为 0 ,在这一轮次中管道中就有loop_read
个字节可以读取。 - 第 148 - 153 行:迭代应用缓冲区中的每个字节指针,并调用
PipeRingBuffer::read_byte
方法来从管道中进行读取。如果这loop_read
个字节均被读取之后还没有填满应用缓冲区,就需要进入循环的下一个轮次,否则就可以直接返回了。
所以管道的读取的字节是依据你传入的
buff
的大小的,直到把buff
填满才进行返回,否则就一直轮询等待。 - 第 137 行:用
-
-
第 157 - 179 行:实现了
write
方法,向Pipe
管道写入数据。其内部实现与read方法几乎一模一样,先判断是否可写,其次判断内部可写的数据大小,若可写大小为0则suspend_current_and_run_next
切换到其他任务等待管道内部数据被其他进程取走了再写。若可以写了则直接写,若这次写满了buff
依旧还有数据则切换到其他进程等待管道内部数据被其他进程取走了再写。和读一样,只有把buff
中的数据全部写到管道中了才可以返回,否则一直轮询。
-
os/src/task/task.rs
在TaskControlBlockInner
中新增了alloc_fd
方法的实现,TaskControlBlockInner::alloc_fd
可以在进程控制块中分配一个最小的空闲文件描述符来访问一个新打开的文件。
增加了进程中创建文件描述符的方法。
/// os/src/task/task.rs
/// Simple access to its internal fields
impl TaskControlBlockInner {
pub fn alloc_fd(&mut self) -> usize {
if let Some(fd) = (0..self.fd_table.len())
.find(|fd| self.fd_table[*fd].is_none()) {
fd
} else {
self.fd_table.push(None);
self.fd_table.len() - 1
}
}
}
- 第 5 - 7 行:从小到大遍历所有曾经被分配过的文件描述符尝试找到一个空闲的,找到的话返回这个文件描述符。
- 第 9 - 10 行:如果没有的话就需要拓展文件描述符表的长度并新分配一个。
os/src/syscall/fs.rs
文件新增加了sys_pipe
函数创建管道的完整实现,为系统层的实现。
// os/src/syscall/fs.rs
pub fn sys_pipe(pipe: *mut usize) -> isize {
let task = current_task().unwrap();
let token = current_user_token();
let mut inner = task.acquire_inner_lock();
let (pipe_read, pipe_write) = make_pipe();
let read_fd = inner.alloc_fd();
inner.fd_table[read_fd] = Some(pipe_read);
let write_fd = inner.alloc_fd();
inner.fd_table[write_fd] = Some(pipe_write);
*translated_refmut(token, pipe) = read_fd;
*translated_refmut(token, unsafe { pipe.add(1) }) = write_fd;
0
}
- 第 7 行:我们调用
make_pipe
创建一个管道并获取其读端和写端。 - 第 8 - 11 行:我们分别为读端和写端分配文件描述符,并将它们放置在文件描述符表中的相应位置中。
- 第 12 - 13 行:我们将读端和写端的文件描述符写回到应用地址空间。
文件管道的创建从内向外的说:
- 首先创建一个一定大小缓冲区的字节队列作为管道的存储
PipeRingBuffer
。- 其次依据这个存储
PipeRingBuffer
创建两个Pipe
管道,一个作为读一个作为写,其区分是依据readable
和writable
的参数。- 然后在进程上分别给这两个管道创建文件描述符并加入到文件描述符表中。
- 这个时候从文件描述符表中就有两个文件分别对应该刚分配的管道的读和写,可以向其中读写东西。
文件管道的读写主要就是往buff中按照顺序读写数据。
【命令行参数与标准 I/O 重定向】
在shell
程序和内核中支持命令行参数的解析和传递。而且我们可以把应用的命令行参数的扩展,管道以及标准 I/O
重定向功能综合在一起,来让两个甚至多个互不相干的应用也能合作。
【Linux命令行参数应用】
在使用 C/C++
语言开发 Linux
应用的时候,我们使用标准库提供的 argc/argv
来获取命令行参数,它们是直接被作为参数传给 main
函数的。
其中 argc
表示命令行参数的个数,而 argv
是一个长度为 argc
的字符串数组,数组中的每个字符串都是一个命令行参数。
#include <stdio.h>
int main(int argc, char* argv[]) {
printf("argc = %d\n", argc);
for (int i = 0; i < argc; i++) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
$ gcc a.c -oa -g -Wall
$ ./a aa bb 11 22 cc
argc = 6
argv[0] = ./a
argv[1] = aa
argv[2] = bb
argv[3] = 11
argv[4] = 22
argv[5] = cc
【实现命令行参数功能】
为了支持命令行参数, sys_exec
的系统调用接口需要发生变化,它的参数多出了一个 args
数组,数组中的每个元素都是命令行参数字符串的起始地址。实际传递给内核的实际上是这个数组的起始地址:
// user/src/syscall.rs
pub fn sys_exec(path: &str, args: &[*const u8]) -> isize {
syscall(
SYSCALL_EXEC,
[path.as_ptr() as usize, args.as_ptr() as usize, 0],
)
}
将其进行上层封为exec
函数:
// user/src/lib.rs
pub fn exec(path: &str, args: &[*const u8]) -> isize {
sys_exec(path, args)
}
【应用shell程序的命令行参数分割 -> user/src/bin/user_shell.rs】
之前在shell
程序 user_shell
中,一旦接收到一个回车,我们就会将当前行的内容 line
作为一个名字并试图去执行同名的应用。但是现在 line
还可能包含一些命令行参数,只有最开头的一个才是要执行的应用名。因此我们要做的第一件事情就是将 line
用空格进行分割:
// user/src/bin/user_shell.rs
#![no_std]
#![no_main]
extern crate alloc;
#[macro_use]
extern crate user_lib;
const LF: u8 = 0x0au8;
const CR: u8 = 0x0du8;
const DL: u8 = 0x7fu8;
const BS: u8 = 0x08u8;
use alloc::string::String;
use alloc::vec::Vec;
use user_lib::console::getchar;
use user_lib::{close, dup, exec, flush, fork, open, waitpid, OpenFlags};
#[no_mangle]
pub fn main() -> i32 {
println!("Rust user shell");
let mut line: String = String::new();
print!(">> ");
flush();
loop {
let c = getchar();
match c {
LF | CR => {
println!("");
if !line.is_empty() {
let args: Vec<_> = line.as_str().split(' ').collect();
let mut args_copy: Vec<String> = args
.iter()
.map(|&arg| {
let mut string = String::new();
string.push_str(arg);
string
})
.collect();
args_copy.iter_mut().for_each(|string| {
string.push('\0');
});
// redirect input
let mut input = String::new();
if let Some((idx, _)) = args_copy
.iter()
.enumerate()
.find(|(_, arg)| arg.as_str() == "<\0")
{
input = args_copy[idx + 1].clone();
args_copy.drain(idx..=idx + 1);
}
// redirect output
let mut output = String::new();
if let Some((idx, _)) = args_copy
.iter()
.enumerate()
.find(|(_, arg)| arg.as_str() == ">\0")
{
output = args_copy[idx + 1].clone();
args_copy.drain(idx..=idx + 1);
}
let mut args_addr: Vec<*const u8> =
args_copy.iter().map(|arg| arg.as_ptr()).collect();
args_addr.push(0 as *const u8);
let pid = fork();
if pid == 0 {
// input redirection
if !input.is_empty() {
let input_fd = open(input.as_str(), OpenFlags::RDONLY);
if input_fd == -1 {
println!("Error when opening file {}", input);
return -4;
}
let input_fd = input_fd as usize;
close(0);
assert_eq!(dup(input_fd), 0);
close(input_fd);
}
// output redirection
if !output.is_empty() {
let output_fd =
open(output.as_str(), OpenFlags::CREATE | OpenFlags::WRONLY);
if output_fd == -1 {
println!("Error when opening file {}", output);
return -4;
}
let output_fd = output_fd as usize;
close(1);
assert_eq!(dup(output_fd), 1);
close(output_fd);
}
// child process
if exec(args_copy[0].as_str(), args_addr.as_slice()) == -1 {
println!("Error when executing!");
return -4;
}
unreachable!();
} else {
let mut exit_code: i32 = 0;
let exit_pid = waitpid(pid as usize, &mut exit_code);
assert_eq!(pid, exit_pid);
println!("Shell: Process {} exited with code {}", pid, exit_code);
}
line.clear();
}
print!(">> ");
flush();
}
BS | DL => {
if !line.is_empty() {
print!("{}", BS as char);
print!(" ");
print!("{}", BS as char);
flush();
line.pop();
}
}
_ => {
print!("{}", c as char);
flush();
line.push(c as char);
}
}
}
}
-
第 22 行:打印
Rust user shell
提示符 -
第 23 行:声明
line
为String
类型。 -
第 24 行:打印
>>
提示符 -
第 25 行:清空
buff
-
第 26 - 130 行:为
shell
控制行的循环窗口。-
第 27 行:
getchar
通过sys_read
获取一个字符。 -
第 29 行:如果这个字符是回车换行(
0x0a
,0x0d
),也就是最后一个字符,则执行接下来的条件判断。-
第 31 行:如果目前的
line
也就是存储输入数据的buff
不为空,代表输入完全。 -
第 32 行:将输入的
line
用空格进行分割,args
中的&str
都是line
中的一段子区间,它们的结尾并没有包含\0
,因为line
是我们输入得到的,中间本来就没有\0
。 -
第 33 - 40 行:用
args_copy
将args
中的字符串拷贝一份到堆上。 -
第 42 - 44 行:由于在向内核传入字符串的时候,我们只能传入字符串的起始地址,因此我们必须保证其结尾为
\0
。我们在末尾手动加入\0
。这样就可以安心的将args_copy
中的字符串传入内核了。 -
第 47 - 55 行:检查是否有重定向输入,也就是检查是否有
<
这个参数。如果存在的话则需要将它们从命令行参数中移除,并记录匹配到的输入文件名到字符串input
中。 -
第 58 - 66 行:检查是否有重定向输出,也就是检查是否有
>
这个参数。和重定向输入一样需要将参数从命令行中取出。 -
第 68 - 70 行:用
args_addr
收集这些字符串的起始地址准备传给内核。向量args_addr
中的每个元素都代表一个命令行参数字符串的起始地址。由于我们要传递给内核的是这个向量的起始地址,为了让内核能够获取到命令行参数的个数,我们需要在args_addr
的末尾放入一个 0 ,这样内核看到它的时候就能知道命令行参数已经获取完毕了。 -
第 72 - 97 行:通过
fork
创建一个子进程,打开文件和替换的过程则发生在fork
之后的子进程分支中-
第 74 行:判断是否需要进行输入重定向。
-
第 75 行:尝试打开输入文件
input
到input_fd
中。 -
第 76 - 79 行:判断是否打开成功。
-
第 81 行:通过
close
关闭标准输入所在的文件描述符 0 。 -
第 82 行:通过
dup
来分配一个新的文件描述符来访问input_fd
对应的输入文件。这里用到了文件描述符分配的重要性质:即必定分配可用描述符中编号最小的一个。由于我们刚刚关闭了描述符 0 ,那么在
dup
的时候一定会将它分配出去,于是现在应用进程的文件描述符 0 就对应到输入文件了。 -
第 83 行:因为应用进程的后续执行不会用到输入文件原来的描述符
input_fd
,所以就将其关掉。 -
第 86 - 97 行:实现了输出重定向。
-
-
第 99 - 102 行:通过
exec
执行子程序,其执行参数需要进行改变传入的参数为args_copy
和args_addr
不再像原来一样直接将输入的一行作为应用名称传进去直接。
-
-
第 115 行:如果这个字符是
BS (Backspace)
退格键(ASCII=8)或者是**DEL (Delete)
删除键**(ASCII=127)- 第 116 行:如果当前
line
不为空 - 第 117 - 119 行:输入一个特殊的退格字节
BS
来实现将屏幕上当前行的最后一个字符用空格替换掉。 - 第 120 行:清空
buff
- 第 121 行:
user_shell
进程内维护的line
也需要弹出最后一个字符。
- 第 116 行:如果当前
-
第 124 行:如果用户输入了一个其他字符
- 第 125 - 127 行::会被视为用户的正常输入,我们直接将它打印在屏幕上并加入到
line
中。
- 第 125 - 127 行::会被视为用户的正常输入,我们直接将它打印在屏幕上并加入到
-
【sys_exec 将命令行参数压入用户栈 -> os/src/syscall/process.rs -> os/src/task/task.rs】
在 sys_exec
中,不再像之前一样直接根据传入的名字对应到应用名再寻找到对应的ELF文件直接替换地址空间执行程序,这里首先需要将应用传进来的命令行参数取出来:
/// os/src/syscall/process.rs
/// Syscall Exec which accepts the elf path
pub fn sys_exec(path: *const u8, mut args: *const usize) -> isize {
let token = current_user_token();
let path = translated_str(token, path);
let mut args_vec: Vec<String> = Vec::new();
loop {
let arg_str_ptr = *translated_ref(token, args);
if arg_str_ptr == 0 {
break;
}
args_vec.push(translated_str(token, arg_str_ptr as *const u8));
unsafe {
args = args.add(1);
}
}
if let Some(app_inode) = open_file(path.as_str(), OpenFlags::RDONLY) {
let all_data = app_inode.read_all();
let task = current_task().unwrap();
let argc = args_vec.len();
task.exec(all_data.as_slice(), args_vec);
argc as isize
} else {
-1
}
}
- 第 4 行:获取当前应用的
token
。 - 第 6 行:创建保存命令行参数的向量。
- 第 7 - 16 行:通过loop循环逐层获取保存的命令行参数。
- 第 8 行:这里的
args
指向命令行参数字符串起始地址数组中的一个位置,我们首先获取该起始地址。 - 第 9 - 11 行:
args
为 0 就说明没有更多命令行参数了,判断是否已经取到最后了。 - 第 12 行:从一个起始地址通过
translated_str
拿到一个字符串保存到args_vec
中。 - 第 14 行:将
args
指向下一个参数起始地址。
- 第 8 行:这里的
- 第 17 行:调用
open_file
函数,以只读的方式在内核中打开应用文件并获取它对应的OSInode
。 - 第 18 行:通过
OSInode::read_all
将该文件的数据全部读到一个向量all_data
中。 - 第 21 行:调用
TaskControlBlock::exec
的时候,我们需要将获取到的args_vec
传入进去并将里面的字符串压入到用户栈上,从向量all_data
中拿到应用中的ELF
数据也会一起被传入,相当于创建应用的时候也提供了参数。
exec
在原来的基础上增加了将命令行参数压入用户栈中。
// os/src/task/task.rs
impl TaskControlBlock {
pub fn exec(&self, elf_data: &[u8], args: Vec<String>) {
// memory_set with elf program headers/trampoline/trap context/user stack
let (memory_set, mut user_sp, entry_point) = MemorySet::from_elf(elf_data);
let trap_cx_ppn = memory_set
.translate(VirtAddr::from(TRAP_CONTEXT).into())
.unwrap()
.ppn();
// push arguments on user stack
user_sp -= (args.len() + 1) * core::mem::size_of::<usize>();
let argv_base = user_sp;
let mut argv: Vec<_> = (0..=args.len())
.map(|arg| {
translated_refmut(
memory_set.token(),
(argv_base + arg * core::mem::size_of::<usize>()) as *mut usize
)
})
.collect();
*argv[args.len()] = 0;
for i in 0..args.len() {
user_sp -= args[i].len() + 1;
*argv[i] = user_sp;
let mut p = user_sp;
for c in args[i].as_bytes() {
*translated_refmut(memory_set.token(), p as *mut u8) = *c;
p += 1;
}
*translated_refmut(memory_set.token(), p as *mut u8) = 0;
}
// make the user_sp aligned to 8B for k210 platform
user_sp -= user_sp % core::mem::size_of::<usize>();
// **** access inner exclusively
let mut inner = self.inner_exclusive_access();
// substitute memory_set
inner.memory_set = memory_set;
// update trap_cx ppn
inner.trap_cx_ppn = trap_cx_ppn;
// initialize trap_cx
let trap_cx = inner.get_trap_cx();
*trap_cx = TrapContext::app_init_context(
entry_point,
user_sp,
KERNEL_SPACE.exclusive_access().token(),
self.kernel_stack.get_top(),
trap_handler as usize,
);
trap_cx.x[10] = args.len();
trap_cx.x[11] = argv_base;
// **** release inner automatically
}
}
-
第 5 行:解析传入的
ELF
格式文件数据,生成对应的地址空间。 -
第 6 - 9 行:我们手动查页表找到位于应用地址空间中新创建的**
Trap
上下文被实际放在哪个物理页帧**上,用来做后续的初始化。 -
第 10 - 33 行:将命令行参数以某种格式压入用户栈。
-
第 11 行:首先为参数预留分配用户栈存储空间(橙色区域)。
-
第 12 行:记录参数的基地址。
-
第 13 - 20 行:分配字符串指针数组,指向每个字符串的开头。并将指针存到栈中(蓝色区域)。
-
第 21 行:在存储指针数组的后面加0当做结尾标志符(蓝色区域头)。
-
第 22 - 31 行:我们逐个将传入的
args
中的字符串压入到用户栈中,对应于图中的(橙色区域)。为了实现方便,我们在用户栈上预留空间之后逐字节进行复制。注意args
中的字符串是通过translated_str
从应用地址空间取出的,它的末尾不包含\0
。为了应用能知道每个字符串的长度,我们需要手动在末尾加入\0
。 -
第 33 行:将
user_sp
以 8 字节对齐,即图中的绿色区域。这是因为命令行参数的长度不一,很有可能压入之后user_sp
没有对齐到 8 字节,那么在K210
平台上在访问用户栈的时候就会触发访存不对齐的异常(绿色区域)。
-
-
第 35 行:获得该进程
inner
的使用权。 -
第 37 行:替换地址空间,这将导致原有的地址空间生命周期结束,里面包含的全部物理页帧都会被回收。
-
第 39 行:更新
trap
上下文的物理页。 -
第 41 - 50 行:修改新的地址空间中的
Trap
上下文,将解析得到的应用入口点、用户栈位置以及一些内核的信息进行初始化,这样才能正常实现Trap
机制。这里我们的user_sp
相比之前已经发生了变化,它上面已经压入了命令行参数。同时,我们还需要修改Trap
上下文中的a0/a1
寄存器,让a0
表示命令行参数的个数,而a1
则表示图中argv_base
即蓝色区域的起始地址。这两个参数在第一次进入对应应用的用户态的时候会被接收并用于还原命令行参数。
【用户库从用户栈上还原命令行参数 -> user/src/lib.rs】
在应用第一次进入用户态的时候,我们放在 Trap
上下文 a0/a1
两个寄存器中的内容可以被用户库中的入口函数以参数的形式接收:
// user/src/lib.rs
#[no_mangle]
#[link_section = ".text.entry"]
pub extern "C" fn _start(argc: usize, argv: usize) -> ! {
clear_bss();
unsafe {
HEAP.lock()
.init(HEAP_SPACE.as_ptr() as usize, USER_HEAP_SIZE);
}
let mut v: Vec<&'static str> = Vec::new();
for i in 0..argc {
let str_start =
unsafe { ((argv + i * core::mem::size_of::<usize>()) as *const usize).read_volatile() };
let len = (0usize..)
.find(|i| unsafe { ((str_start + *i) as *const u8).read_volatile() == 0 })
.unwrap();
v.push(
core::str::from_utf8(unsafe {
core::slice::from_raw_parts(str_start as *const u8, len)
})
.unwrap(),
);
}
exit(main(argc, v.as_slice()));
}
在入口 _start
中我们就接收到了命令行参数个数 argc
和字符串数组的起始地址 argv
。但是这个起始地址不太好用,我们希望能够将其转化为编写应用的时候看到的 &[&str]
的形式。转化的主体在第 10 - 23
行,就是分别取出 argc
个字符串的起始地址(基于字符串数组的 base
地址 argv
),从它向后找到第一个 \0
就可以得到一个完整的 &str
格式的命令行参数字符串并加入到向量 v
中。最后通过 v.as_slice
就得到了我们在 main
主函数中看到的 &[&str]
。
命令行参数的实现小结:
- 用户在
shell
中输入应用名称和该应用带的参数,shell
应用将输入的字符按照空格进行分割,分割后首先创建子进程,其次将其调用系统函数命令exec
让子进程执行该应用,其传入的参数分别为分割的第一个字符串也就是要执行的应用名称和其后的参数的地址。- 内核在实例该进程的时候依据传入的第一个参数找到要创建应用的数据,并将第二个参数所携带的用户输入的命令参数压入该进程的用户栈中进行实例进程,内核到用户的返回会携带
a0
和a1
两个参数,a0
表示命令行参数的个数,而a1
则表示指向各参数起始地址的指针地址。- 内核
exec
完成后返回应用态,若该应用第一次执行进入_start
函数入口。为其分配用户栈同时将传入的参数取出放到统一内存中。- 最后调用应用程序的
main
函数将参数传入进去也就完成了整个流程。命令行参数功能的主要目的是将从shell获得的应用附带的参数,传送到相应的进程作为main函数的参数。其中从 shell应用 -> 内核 -> 目标应用。
【标准输入输出重定向】
通过 >
我们可以将应用的输出重定向到文件中。我们也可以注意到在屏幕上暂时看不到 yield
的输出了。在应用 yield
退出之后,我们可以使用 cat
工具来查看文件 fileb
的内容,可以看到里面的确是 yield
的输出。同理,通过 <
则可以将一个应用的输入重定向到某个指定文件而不是从键盘输入。
重定向功能对于应用来说是透明的。数据默认都是输入自进程文件描述表位置 0 (即 fd=0
)处的标准输入,并输出到进程文件描述符表位置 1 (即 fd=1
)处的标准输出。
因此,在应用执行之前,我们就要对应用进程的文件描述符表进行某种替换。以输出为例,我们需要提前打开文件并用这个文件来替换掉应用文件描述符表位置 1 处的标准输出文件,这就完成了所谓的重定向。在重定向之后,应用认为自己输出到 fd=1
的标准输出文件,但实际上是输出到我们指定的文件中。我们能够做到这一点还是得益于文件的抽象,因为在进程看来无论是标准输出还是常规文件都是一种文件,可以通过同样的接口来读写。
为了实现重定向功能,我们需要引入一个新的系统调用 sys_dup
:
// user/src/syscall.rs
/// 功能:将进程中一个已经打开的文件复制一份并分配到一个新的文件描述符中。
/// 参数:fd 表示进程中一个已经打开的文件的文件描述符。
/// 返回值:如果出现了错误则返回 -1,否则能够访问已打开文件的新文件描述符。
/// 可能的错误原因是:传入的 fd 并不对应一个合法的已打开文件。
/// syscall ID:24
pub fn sys_dup(fd: usize) -> isize {
syscall(SYSCALL_DUP, [fd, 0, 0])
}
OS
中sys_dup
的实现如下所示,首先获取内部的可变引用,然后检查传入 fd
的合法性。然后在文件描述符表中分配一个新的文件描述符,并保存 fd
指向的已打开文件的一份拷贝即可。
// os/src/syscall/fs.rs
pub fn sys_dup(fd: usize) -> isize {
let task = current_task().unwrap();
let mut inner = task.inner_exclusive_access();
if fd >= inner.fd_table.len() {
return -1;
}
if inner.fd_table[fd].is_none() {
return -1;
}
let new_fd = inner.alloc_fd();
inner.fd_table[new_fd] = Some(Arc::clone(inner.fd_table[fd].as_ref().unwrap()));
new_fd as isize
}
在shell
程序 user_shell
中,在分割命令行参数的时候,我们要检查是否存在通过 <
或 >
进行输入输出重定向的情况,如果存在的话则需要将它们从命令行参数中移除,并记录匹配到的输入文件名或输出文件名到字符串 input
或 output
中。
标准输入输出重定向总结:由于每个进程默认输入输出为标准输入输出IO口,其放在了文件描述表的0和1上,而我们想进行重定向则直接将文件描述符0和1上原本的输入输出文件修改为我们所需要的文件即可。由于创建文件描述符的时候会默认从低向高创建,则直接关闭想要替换的标准输入输出口文件描述符,再拷贝重定向的文件创建文件描述符的时候会从小打大分配恰巧使用前面关闭的文件描述。
【信号】
信号(Signals)是类 UNIX 操作系统中实现进程间通信的一种异步通知机制,用来提醒某进程一个特定事件已经发生,需要及时处理。
【信号的应用层系统调用于应用实现】
sys_sigaction
: 设置信号处理例程sys_sigprocmask
: 设置要阻止的信号sys_kill
: 将某信号发送给某进程sys_sigreturn
: 清除堆栈帧,从信号处理例程返回
// user/src/syscall.rs
// 设置信号处理例程
// signum:指定信号
// action:新的信号处理配置
// old_action:老的的信号处理配置
fn sys_sigaction(
signum: i32,
action: *const SignalAction,
old_action: *mut SignalAction) -> isize
pub struct SignalAction {
// 信号处理例程的地址
pub handler: usize,
// 信号掩码
pub mask: SignalFlags
}
// 设置要阻止的信号
// mask:信号掩码
fn sys_sigprocmask(mask: u32) -> isize
// 清除堆栈帧,从信号处理例程返回
fn sys_sigreturn() -> isize
// 将某信号发送给某进程
// pid:进程pid
// signal:信号的整数码
fn sys_kill(pid: usize, signal: i32) -> isize
其外部包装如下所示:
// user/src/lib.rs
pub fn kill(pid: usize, signal: i32) -> isize {
sys_kill(pid, signal)
}
pub fn sigaction(
signum: i32,
action: *const SignalAction,
old_action: *mut SignalAction) -> isize {
sys_sigaction(signum, action, old_action)
}
pub fn sigprocmask(mask: u32) -> isize {
sys_sigprocmask(mask)
}
pub fn sigreturn() -> isize {
sys_sigreturn()
}
信号的应用层代码实现:
// user/src/bin/sig_simple.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
use user_lib::*;
fn func() {
println!("user_sig_test succsess");
sigreturn();
}
#[no_mangle]
pub fn main() -> i32 {
let mut new = SignalAction::default();
let old = SignalAction::default();
new.handler = func as usize;
println!("signal_simple: sigaction");
if sigaction(SIGUSR1, &new, &old) < 0 {
panic!("Sigaction failed!");
}
println!("signal_simple: kill");
if kill(getpid() as usize, SIGUSR1) < 0 {
println!("Kill failed!");
exit(1);
}
println!("signal_simple: Done");
0
}
- 第 18 - 19 行:建立了
new
和old
两个SignalAction
结构的变量。 - 第 20 行:设置
new.handler
为信号处理函数func
的地址。 - 第 23 行:调用
sigaction
函数,设置SIGUSR1
信号对应为new
变量,即该进程在收到SIGUSR1
信号后,会执行new
前面设置的func
函数来具体处理响应此信号。 - 第 27 行:通过
getpid
函数获得自己的pid
,并以自己的pid
和SIGUSR1
为参数,调用kill
函数,给自己发SIGUSR1
信号。
操作系统在收到
sys_kill
系统调用后,会保存该进程老的Trap
上下文,然后修改其Trap
上下文,使得从内核返回到该进程的func
函数执行,并在func
函数的末尾,进程通过调用sigreturn
函数,恢复到该进程之前被func
函数截断的地方,即sys_kill
系统调用后的指令处,继续执行,直到进程结束。
【信号的内核层设计与实现】
【核心数据结构】
在进程控制块中添加 signal
核心数据结构:
// os/src/task/task.rs
pub struct TaskControlBlockInner {
...
pub signals: SignalFlags, // 要响应的信号
pub signal_mask: SignalFlags, // 要屏蔽的信号
pub handling_sig: isize, // 正在处理的信号
pub signal_actions: SignalActions, // 信号处理例程表
pub killed: bool, // 任务是否已经被杀死了
pub frozen: bool, // 任务是否已经被暂停了
pub trap_ctx_backup: Option<TrapContext> //被打断的trap上下文
}
pub struct SignalAction {
pub handler: usize, // 信号处理函数的地址
pub mask: SignalFlags // 信号掩码
}
- 第 7 行:记录了当前正在处理的信号
ID
。 - 第 8 行:进程控制块中的
signal_actions
是每个信号对应的SignalAction
的数组,操作系统根据这个数组中的内容,可以知道该进程应该如何响应信号。 - 第 9 行:
killed
的作用是标志当前进程是否已经被杀死。因为进程收到杀死信号的时候并不会立刻结束,而是会在适当的时候退出。这个时候需要killed
作为标记,退出不必要的信号处理循环。 - 第 10 行:
frozen
的标志与SIGSTOP
和SIGCONT
两个信号有关。SIGSTOP
会暂停进程的执行,即将frozen
置为true
。此时当前进程会阻塞等待SIGCONT
(即解冻的信号)。当信号收到SIGCONT
的时候,frozen
置为false
,退出等待信号的循环,返回用户态继续执行。 - 第 11 行:保存了被打断的任务上下文。
- 第 14 - 17 行:
SignalAction
数据结构包含信号所对应的信号处理函数的地址和信号掩码。
【建立信号处理函数(signal_handler)】
// os/src/syscall/process.rs
fn sys_sigaction(
signum: i32
action: *const SignalAction,
old_action: *mut SignalAction) -> isize {
...
//1. 保存老的 signal_handler 地址到 old_action 中
let old_kernel_action = inner.signal_actions.table[signum as usize];
*translated_refmut(token, old_action) = old_kernel_action;
//2. 保存新的 signal_handler 地址到 TCB 的 signal_actions 中
let ref_action = translated_ref(token, action);
inner.signal_actions.table[signum as usize] = *ref_action;
...
}
保存该进程的 signal_actions
中对应信号的 sigaction
到 old_action
中,然后再把新的 ref_action
保存到该进程的 signal_actions
对应项中。其中会保存老的信号处理函数。
【发送信号】
先根据 pid
找到对应的进程控制块,然后把进程控制块中的 signals
中 signum
所对应的位设置 1
。
【在信号处理后恢复继续执行】
pub fn sys_sigretrun() -> isize {
if let Some(task) = current_task() {
let mut inner = task.inner_exclusive_access();
inner.handling_sig = -1;
// restore the trap context
let trap_ctx = inner.get_trap_cx();
*trap_ctx = inner.trap_ctx_backup.unwrap();
0
} else {
-1
}
}
sys_sigreturn
的主要工作是在信号处理函数完成信号响应后要执行的一个恢复操作,即把操作系统在响应信号前保存的 trap
上下文重新恢复回来,这样就可以从信号处理前的进程正常执行的位置继续执行了。
【响应信号】
执行APP --> __alltraps
--> trap_handler
--> handle_signals
--> check_pending_signals
--> call_kernel_signal_handler
--> call_user_signal_handler
--> // backup trap Context
// modify trap Context
trap_ctx.sepc = handler; //设置回到中断处理例程的入口
trap_ctx.x[10] = sig; //把信号值放到Reg[10]
--> trap_return //找到并跳转到位于跳板页的`__restore`汇编函数
--> __restore //恢复被修改过的trap Context,执行sret
执行APP的signal_handler函数
- 首先进行陷入式上下文的处理。
- 处理完成后检查是否有信号。
- 如果有根据信号的类型分别执行系统信号处理函数或用户信号处理函数。
- 如果是处理函数在应用层定义的,则将内核处理返回用户态的返回地址设为应用处理函数入口。直接跳转过去执行。
- 处理完后通过
sys_sigreturn
系统调用进入内核态表明信号处理完成。 - 将上下文进行修改为原来应用的下一条指令进行恢复继续执行原来的应用。
InterProcessCommunication OS小结:
该OS在原来的基础上进行了文件的抽象,实现了进程之间的通信机制。进程间的通信主要表现在两个进程之间如何将信息传递给对方,目前实现的进程间通信方式属于间接通信,所以两者之间的信息传递都需要通过内核进行传达。这就牵扯到内核数据的转发功能了。
- 管道的建立是在文件和TCB的基础上创建的。两者可以通过文件对一个内存进行操作,又因为文件描述符的进程封装问题导致只能一个读一个写。
- 信号的建立类似于中断的实现过程,也称为软中断。在正常执行流程中插入了一段信号处理函数,处理完后再返回回去继续执行。但是在检测信号的时候只能在trap进内核中检测。
- 命令行参数的实现主要是在接收传入的数据的时候同时对参数进行了保存,将参数进行不停的传递:shell应用->内核->新创建的进程。
- IO重定向的实现主要是用新创建的文件描述符替换了原本默认的标准输入输出口。
- 文件标准输入输出的实现是给每个进程在创建进程的时候将其文件描述符表的0和1、2对应到标准输入输出口。😃