进程间通信
branch ch7
sys_pipe
pub fn sys_pipe(pipe: *mut usize) -> isize;
功能:为当前进程打开一个管道。
参数:pipe 表示应用地址空间中的一个长度为 2 的 usize 数组的起始地址,内核需要按顺序将管道读端和写端的文件描述符写入到数组中。
返回值:如果出现了错误则返回 -1,否则返回 0 。可能的错误原因是:传入的地址不合法。
syscall ID:59
用户库会将其包装为 pipe 函数:
pub fn pipe(pipe_fd: &mut [usize]) -> isize { sys_pipe(pipe_fd) }
当一个管道的所有读端文件/写端文件都被关闭之后,管道占用的资源才会被回收。 pub fn sys_close(fd: usize) -> isize;
只需将进程控制块中的文件描述符表对应的一项改为 None 代表它已经空闲即可
同时这也会导致内层的引用计数类型 Arc 被销毁
会减少一个文件的引用计数,当引用计数减少到 0 之后,文件所占用的资源就会被自动回收。
会在用户库中被包装为 close 函数。 close=_=
管道使用ch7b_pipetest
父进程中,我们通过 pipe 打开一个管道文件数组,
其中 pipe_fd[0] 保存了管道读端的文件描述符,而 pipe_fd[1] 保存了管道写端的文件描述符。
在 fork 之后,子进程会完全继承父进程的文件描述符表,
于是子进程也可以通过同样的文件描述符来访问同一个管道的读端和写端。
管道是单向的,
这个测例中我们希望管道中的数据从父进程流向子进程
即父进程仅通过管道的写端写入数据,而子进程仅通过管道的读端读取数据。
如果想在父子进程之间实现双向通信,我们就必须创建两个管道。
ch7b_pipe_large_test
基于文件的管道
将管道的一端(读端或写端)抽象为 Pipe 类型
pub struct Pipe {
readable: bool, readable 和 writable 分别指出该管道端可否支持读取/写入
writable: bool,
buffer: Arc>, 过 buffer 字段还可以找到该管道端所在的管道自身
}
后续我们将为它实现 File Trait ,之后它便可以通过文件描述符来访问
管道自身,带有一定大小缓冲区的字节队列,我们抽象为 PipeRingBuffer 类型:
pub struct PipeRingBuffer {
arr: [u8; RING_BUFFER_SIZE],
arr/head/tail 三个字段用来维护一个循环队列,
其中 arr 为存放数据的数组,
head: usize, head 为循环队列队头的下标,
tail: usize, tail 为循环队列队尾的下标。
status: RingBufferStatus, RingBufferStatus 记录了缓冲区目前的状态:
FULL 表示缓冲区已满不能再继续写入;
EMPTY 表示缓冲区为空无法从里面读取;
而 NORMAL 则表示除了 FULL 和 EMPTY 之外的其他状态。
write_end: Option>,
保存了它的写端的一个弱引用计数,
这是由于在某些情况下需要确认该管道所有的写端是否都已经被关闭了,
通过这个字段很容易确认这一点
}
从内存管理的角度,每个读端或写端中都保存着所属管道自身的强引用计数,
我们确保这些引用计数只会出现在管道端口 Pipe 结构体中
于是,一旦一个管道所有的读端和写端均被关闭,便会导致它们所属管道的引用计数变为 0
循环队列缓冲区所占用的资源被自动回收
虽然 PipeRingBuffer 中保存了一个指向写端的引用计数,但是它是一个弱引用,也就不会出现循环引用的情况导致内存泄露
管道创建
通过 PipeRingBuffer::new 可以创建一个新的管道:
Pipe 的 read/write_end_with_buffer 方法可以分别从一个已有的管道创建它的读端和写端:
impl Pipe {
pub fn read_end_with_buffer(buffer: Arc>) -> Self {
Self {
readable: true,
writable: false,
buffer,
}
}
pub fn write_end_with_buffer(buffer: Arc>) -> Self {
Self {
readable: false,
writable: true,
buffer,
}
}
读端和写端的访问权限进行了相应设置
不允许向读端写入,也不允许从写端读取
}
pub fn make_pipe() -> (Arc, Arc) {
创建一个管道并返回它的读端和写端
调用 PipeRingBuffer::set_write_end 在管道中保留它的写端的弱引用计数。
系统调用 sys_pipe :
pub fn alloc_fd(&mut self) -> usize {
可以在进程控制块中分配一个最小的空闲文件描述符来访问一个新打开的文件。
它先从小到大遍历所有曾经被分配过的文件描述符尝试找到一个空闲的,
如果没有的话就需要拓展文件描述符表的长度并新分配一个。
pub fn sys_pipe(pipe: *mut usize) -> isize {
调用 make_pipe 创建一个管道并获取其读端和写端
我们分别为读端和写端分配文件描述符并将它们放置在文件描述符表中的相应位置中。
我们则是将读端和写端的文件描述符写回到应用地址空间。
管道读写
impl PipeRingBuffer {
pub fn read_byte(&mut self) -> u8 {
可以从管道中读取一个字节
在调用它之前需要确保管道缓冲区中不是空的
更新循环队列队头的位置
比较队头和队尾是否相同
如果相同的话则说明管道的状态变为空 EMPTY
仅仅通过比较队头和队尾是否相同不能确定循环队列是否为空
它既有可能表示队列为空,也有可能表示队列已满
因此我们需要在 read_byte 的同时进行状态更新
pub fn available_read(&self) -> usize {
计算管道中还有多少个字符可以读取
首先需要需要判断队列是否为空
队头和队尾相等可能表示队列为空或队列已满
如果队列为空的话直接返回 0,否则根据队头和队尾的相对位置进行计算
pub fn all_write_ends_closed(&self) -> bool {
判断管道的所有写端是否都被关闭了
通过尝试将管道中保存的写端的弱引用计数升级为强引用计数来实现的
升级失败的话,说明管道写端的强引用计数为 0 ,也就意味着管道所有写端都被关闭了
从而管道中的数据不会再得到补充
待管道中仅剩的数据被读取完毕之后,管道就可以被销毁了
fn read(&self, buf: UserBuffer) -> usize {
buf_iter 将传入的应用缓冲区 buf 转化为一个能够逐字节对于缓冲区进行访问的迭代器
每次调用 buf_iter.next() 即可按顺序取出用于访问缓冲区中一个字节的裸指针
read_size 用来维护实际有多少字节从管道读入应用的缓冲区
File::read 的语义是要从文件中最多读取应用缓冲区大小那么多字符。
这可能超出了循环队列的大小,或者由于尚未有进程从管道的写端写入足够的字符,
因此我们需要将整个读取的过程放在一个循环中,
当循环队列中不存在足够字符的时候暂时进行任务切换,
等待循环队列中的字符得到补充之后再继续读取。
用 loop_read 来保存循环这一轮次中可以从管道循环队列中读取多少字符。
如果管道为空则会检查管道的所有写端是否都已经被关闭,
如果是的话,说明我们已经没有任何字符可以读取了,这时可以直接返回;
否则我们需要等管道的字符得到填充之后再继续读取,
因此我们调用 suspend_current_and_run_next 切换到其他任务,等到切换回来之后回到循环开头再看一下管道中是否有字符了。
在调用之前我们需要手动释放管道自身的锁,因为切换任务时候的 __switch 并不是一个正常的函数调用。
如果 loop_read 不为 0
在这一轮次中管道中就有 loop_read 个字节可以读取
可以迭代应用缓冲区中的每个字节指针并调用 PipeRingBuffer::read_byte 方法来从管道中进行读取
如果这 loop_read 个字节均被读取之后还没有填满应用缓冲区就需要进入循环的下一个轮次
否则就可以直接返回了。
Pipe 的 write 方法与read类似
命令行参数
pub fn exec(path: &str, args: &[*const u8]) -> isize { sys_exec(path, args) }
pub fn sys_exec(path: &str, args: &[*const u8]) -> isize;
syscall(SYSCALL_EXEC, [path.as_ptr() as usize, args.as_ptr() as usize, 0])
参数多出了一个 args 数组
数组中的每个元素都是命令行参数字符串的起始地址
实际传递给内核的实际上是这个数组的起始地址
shell程序的命令行参数分割
user/src/bin/ch6b_user_shell.rs
shell程序 user_shell 中,一旦接收到一个回车,我们就会将当前行的内容 line 作为一个名字并试图去执行同名的应用
现在 line 还可能包含一些命令行参数,
只有最开头的一个才是要执行的应用名
第一件事情就是将 line 用空格分割
经过分割, args 中的 &str 都是 line 中的一段子区间,它们的结尾并没有包含 \0 ,
因为 line 是我们输入得到的,中间本来就没有 \0 。
由于在向内核传入字符串的时候,我们只能传入字符串的起始地址,因此我们必须保证其结尾为 \0 。
从而我们用 args_copy 将 args 中的字符串拷贝一份到堆上并在末尾手动加入 \0 。
这样就可以安心的将 args_copy 中的字符串传入内核了。
我们用 args_addr 来收集这些字符串的起始地址:
let mut args_addr: Vec<*const u8> = args_copy
.iter()
.map(|arg| arg.as_ptr())
.collect();
args_addr.push(0 as *const u8);
向量 args_addr 中的每个元素都代表一个命令行参数字符串的起始地址。
为了让内核能够获取到命令行参数的个数,我们在 args_addr 的末尾放入一个 0 ,
这样内核看到它时就能知道命令行参数已经获取完毕了。
在 fork 出来的子进程中,我们调用 exec 传入命令行参数。
sys_exec 将命令行参数压入用户栈
首先需要将应用传进来的命令行参数取出来
每次我们都可以从一个起始地址通过 translated_str 拿到一个字符串
直到 args 为 0 就说明没有更多命令行参数了。
调用 TaskControlBlock::exec 的时候
pub fn exec(&self, elf_data: &[u8], args: Vec) {
需要将获取到的 args_vec 传入进去并将里面的字符串压入到用户栈app stack上。
具体的格式
首先需要在用户栈上分配一个字符串指针数组,也就是蓝色区域。
数组中的每个元素都指向一个用户栈更低处的命令行参数字符串的起始地址。
最开始我们只是分配空间,具体的值要等到字符串被放到用户栈上之后才能确定更新
逐个将传入的 args 中的字符串压入到用户栈中,对应于图中的橙色区域。
为了实现方便,我们在用户栈上预留空间之后逐字节进行复制。
注意 args 中的字符串是通过 translated_str 从应用地址空间取出的,它的末尾不包含 \0 。
了应用能知道每个字符串的长度,我们需要手动在末尾加入 \0
将 user_sp 以 8 字节对齐
在 Qemu 平台上其实可以忽略这一步
还需要对应修改 TrapContext
我们的 user_sp 相比之前已经发生了变化,
它上面已经压入了命令行参数。
同时,我们还需要修改 TrapContext中的 a0/a1 寄存器
让 a0 表示命令行参数的个数
而 a1 则表示图中 argv_base 即蓝色区域的起始地址
这两个参数在第一次进入对应应用的用户态的时候
会被接收并用于还原命令行参数
用户库从app stack上还原命令行参数
pub extern "C" fn _start(argc: usize, argv: usize) -> ! {
应用第一次进入用户态的时候
放在 TrapContext 的a0/a1 两个寄存器中的内容可以被用户库中的入口函数以参数的形式接收
在入口 _start 中我们就接收到了命令行参数个数 argc 和字符串数组的起始地址 argv
但是这个起始地址不太好用
望能够将其转化为编写应用的时候看到的 &[&str] 的形式
分别取出 argc 个字符串的起始地址(基于字符串数组的 base 地址 argv ),
从它向后找到第一个 \0 就可以得到一个完整的 &str 格式的命令行参数字符串并加入到向量 v 中。
最后通过 v.as_slice 就得到了我们在 main 主函数中看到的 &[&str] 。
标准输入输出重定向
增强 shell 程序使用文件系统时的灵活性
新增标准输入输出重定向功能
对于应用来说是透明
应用中除非明确指出了数据要从指定的文件输入或者输出到指定的文件
数据默认都是输入自进程文件描述表位置 0 处的标准输入stdin
并输出到进程文件描述符表位置 1 处的标准输出stdout
pub fn sys_dup(fd: usize) -> isize;
功能:将进程中一个已经打开的文件复制一份并分配到一个新的文件描述符中。
参数:fd 表示进程中一个已经打开的文件的文件描述符。
返回值:如果出现了错误则返回 -1,否则能够访问已打开文件的新文件描述符。
可能的错误原因是:传入的 fd 并不对应一个合法的已打开文件。
syscall ID:24
首先检查传入 fd 的合法性。
在文件描述符表中分配一个新的文件描述符,
并保存 fd 指向的已打开文件的一份拷贝即可
在shell程序 user_shell 分割命令行参数的时候
要检查是否存在通过 < 或 > 进行输入输出重定向的情况
如果存在的话则需要将它们从命令行参数中移除
并记录匹配到的输入文件名或输出文件名到字符串 input 或 output 中
为了实现方便,我们这里假设输入shell程序的命令一定合法
即 < 或 > 最多只会出现一次
后面总是会有一个参数作为重定向到的文件
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);
}
打开文件和替换的过程则发生在 fork 之后的子进程分支中:
// user/src/bin/user_shell.rs
输入重定向
尝试打开输入文件 input 到 input_fd 中
首先通过 close 关闭标准输入所在的文件描述符 0
之后通过 dup 来分配一个新的文件描述符来访问 input_fd 对应的输入文件。
这里用到了文件描述符分配的重要性质:
即必定分配可用描述符中编号最小的一个。
由于我们刚刚关闭了描述符 0 ,
那么在 dup 的时候一定会将它分配出去,
于是现在应用进程的文件描述符 0 就对应到输入文件了。
最后,因为应用进程的后续执行不会用到输入文件原来的描述符 input_fd ,所以就将其关掉。
输出重定向
原理和输入重定向几乎完全一致
只是通过 open 打开文件的标志不太相同

Generated 2025-05-05 04:17:35 +0000
25bcfca-dirty 2025-05-04 14:47:56 +0000