rCore_Lab7

本章任务

本章要完成的操作系统的核心目标是: 让不同应用通过进程间通信的方式组合在一起运行 。

  • 支持标准输入/输出文件

    我们将支持三种文件:标准输入输出、管道以及在存储设备上的常规文件和目录文件

  • 支持管道文件

  • 支持对应用程序的命令行参数的解析和传递

  • 实现标准 I/O 重定向功能

    image-20240122144051084

  • 支持信号

    image-20240122144346431

迅猛龙

迅猛龙是一种中型恐龙,生活于8300 至7000万年前的晚白垩纪,它们是活跃的团队合作型捕食动物,可以组队捕食行动迅速的猎物。

image-20240122143038429

过上图,大致可以看出迅猛龙操作系统 – IPCOS增加了两种通信机制,一种是交换数据的管道(Pipe)机制,另外一种是发送异步通知事件的信号(signal)机制,应用程序通过新增的管道和信号相关的系统调用可以完成进程间通信。

这里把管道看成是一种特殊的内存文件,并在进程的打开文件表 fd_talbe 中被管理,而且进程通过文件读写系统调用就可以很方便地基于管道实现进程间的数据交换。

而信号是进程管理的一种资源,发送信号的进程可以通过系统调用给接收信号的目标进程控制块中的 signal 结构更新所发信号信息,操作系统再通过扩展 trap_handler 中从内核态返回到用户态的处理流程, 改变了接收信号的目标进程的执行上下文,从而让接收信号的目标进程可以优先执行处理信号事件的预设函数 signal_handler ,在处理完信号后,再继续执行之前暂停的工作。

标准输入/输出

当一个进程被创建的时候,内核会默认为其打开三个缺省就存在的文件:

  • 文件描述符为 0 的标准输入

  • 文件描述符为 1 的标准输出

  • 文件描述符为 2 的标准错误输出

在我们的实现中并不区分标准输出和标准错误输出,而是会将文件描述符 1 和 2 均对应到标准输出>。实际上,在本章中,标准输出文件就是串口输出,标准输入文件就是串口输入。

这里隐含着有关文件描述符的一条重要的规则:即进程打开一个文件的时候,内核总是会将文件分配到该进程文件描述符表中 最小的 空闲位置。比如,当一个进程被创建以后立即打开一个文件,则内核总是会返回文件描述符 3 (0~2号文件描述符已被缺省打开了)。当我们关闭一个打开的文件之后,它对应的文件描述符将会变得空闲并在后面可以被分配出去。

标准输入/输出文件其实是把设备当成文件,标准输入文件就是串口的输入或键盘,而标准输出文件就是串口的输出或显示器。

在 fork 的时候,子进程需要完全继承父进程的文件描述符表来和父进程共享所有文件

管道

image-20240122152539899

管道是一种进程间通信机制,由操作系统提供,并可通过直接编程或在shell程序的帮助下轻松地把不同进程(目前是父子进程之间或子子进程之间)的输入和输出对接起来。我们也可以将管道看成一个有一定缓冲区大小的字节队列,它分为读和写两端,需要通过不同的文件描述符来访问。读端只能用来从管道中读取,而写端只能用来将数据写入管道。由于管道是一个队列,读取数据的时候会从队头读取并弹出数据,而写入数据的时候则会把数据写入到队列的队尾。由于管道的缓冲区大小是有限的,一旦整个缓冲区都被填满就不能再继续写入,就需要等到读端读取并从队列中弹出一些数据之后才能继续写入。当缓冲区为空的时候,读端自然也不能继续从里面读取数据,需要等到写端写入了一些数据之后才能继续读取。

一般在shell程序中, “|” 是管道符号,即两个命令之间的一道竖杠。

/// 功能:为当前进程打开一个管道。
/// 参数:pipe 表示应用地址空间中的一个长度为 2 的 usize 数组的起始地址,内核需要按顺序将管道读端
/// 和写端的文件描述符写入到数组中。
/// 返回值:如果出现了错误则返回 -1,否则返回 0 。可能的错误原因是:传入的地址不合法。
/// syscall ID:59
pub fn sys_pipe(pipe: *mut usize) -> isize;

在父进程中,我们通过 pipe 打开一个管道文件数组,其中 pipe_fd[0] 保存了管道读端的文件描述符,而 pipe_fd[1] 保存了管道写端的文件描述符。

标准输入/输出重定向

内核在执行 sys_exec 系统调用创建基于新应用的进程时,会直接把文件描述符表位置 0 放置标准输入文件,位置 1 放置标准输出文件,位置 2 放置标准错误输出文件。

标准输入/输出文件其实是把设备当成文件,标准输入文件就是串口的输入或键盘,而标准输出文件就是串口的输出或显示器。

因此,在应用执行之前,我们就要对应用进程的文件描述符表进行某种替换。以输出为例,我们需要提前打开文件并用这个文件来替换掉应用文件描述符表位置 1 处的标准输出文件,这就完成了所谓的重定向。在重定向之后,应用认为自己输出到 fd=1 的标准输出文件,但实际上是输出到我们指定的文件中。我们能够做到这一点还是得益于文件的抽象,因为在进程看来无论是标准输出还是常规文件都是一种文件,可以通过同样的接口来读写。

引入 sys_dup

// user/src/syscall.rs

/// 功能:将进程中一个已经打开的文件复制一份并分配到一个新的文件描述符中。
/// 参数:fd 表示进程中一个已经打开的文件的文件描述符。
/// 返回值:如果出现了错误则返回 -1,否则能够访问已打开文件的新文件描述符。
/// 可能的错误原因是:传入的 fd 并不对应一个合法的已打开文件。
/// syscall ID:24
pub fn sys_dup(fd: usize) -> isize;
pub fn sys_dup(fd: usize) -> isize {
    let task = current_task().unwrap();
    let mut inner = task.acquire_inner_lock();
    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
}

事件通知

在进程间还存在“事件通知”的需求:操作系统或某进程希望能单方面通知另外一个正在忙其它事情的进程产生了某个事件,并让这个进程能迅速响应。如果采用之前同步的 IPC 机制,难以高效地应对这样的需求。

比如,用户想中断当前正在运行的一个程序,于是他敲击 Ctrl-C 的组合键,正在运行的程序会迅速退出它正在做的任何事情,截止程序的执行。

image-20240122200422338

我们需要有一种类似于硬件中断的软件级异步通知机制,使得进程在接收到特定事件的时候能够暂停当前的工作并及时响应事件,并在响应事件之后可以恢复当前工作继续执行。如果进程没有接收到任何事件,它可以执行自己的任务。

信号机制

信号(Signals)是类 UNIX 操作系统中实现进程间通信的一种异步通知机制,用来提醒某进程一个特定事件已经发生,需要及时处理。当一个信号发送给一个进程时,操作系统会中断接收到信号的进程的正常执行流程并对信号进行处理。如果该进程定义了信号的处理函数,那么这个处理函数会被调用,否则就执行默认的处理行为,比如让该进程退出。在处理完信号之后,如果进程还没有退出,则会恢复并继续进程的正常执行。

信号的接收方是一个进程,接收到信号有多种处理方式,最常见的三种如下:

  • 忽略:就像信号没有发生过一样。

  • 捕获:进程会调用相应的处理函数进行处理。

  • 终止:终止进程。

如果应用没有手动设置接收到某种信号之后如何处理,则操作系统内核会以默认方式处理该信号,一般是终止收到信号的进程或者忽略此信号。每种信号都有自己的默认处理方式。

信号处理流程

image-20240122201241997

信号有两种来源:最开始的时候进程在正常执行,此时可能内核或者其他进程给它发送了一个信号,这些就属于异步信号,是信号的第一种来源;信号的第二种来源则是由进程自身的执行触发,在处理 Trap 的时候内核会将相应的信号直接附加到进程控制块中,这种属于同步信号。

与信号处理相关的系统调用则有三个:

  • sys_sigaction :设置信号处理例程

  • sys_procmask :设置进程的信号屏蔽掩码

  • sys_sigreturn :清除栈帧,从信号处理例程返回

sys_sigaction

// os/src/syscall/process.rs

/// 功能:为当前进程设置某种信号的处理函数,同时保存设置之前的处理函数。
/// 参数:signum 表示信号的编号,action 表示要设置成的处理函数的指针
/// old_action 表示用于保存设置之前的处理函数的指针(SignalAction 结构稍后介绍)。
/// 返回值:如果传入参数错误(比如传入的 action 或 old_action 为空指针或者)
/// 信号类型不存在返回 -1 ,否则返回 0 。
/// syscall ID: 134
pub fn sys_sigaction(
    signum: i32,
    action: *const SignalAction,
    old_action: *mut SignalAction,
) -> isize;

SignalAction结构体

// user/src/lib.rs

/// Action for a signal
#[repr(C, align(16))]
#[derive(Debug, Clone, Copy)]
pub struct SignalAction {
    pub handler: usize,						// 信号处理例程的入口地址
    pub mask: SignalFlags,					// mask 则表示执行该信号处理例程期间的信号掩码
}

这个信号掩码是用于在执行信号处理例程的期间屏蔽掉一些信号,每个 handler 都可以设置它在执行期间屏蔽掉哪些信号。

“屏蔽”的意思是指在执行该信号处理例程期间,即使 Trap 到内核态发现当前进程又接收到了一些信号,只要这些信号被屏蔽,内核就不会对这些信号进行处理而是直接回到用户态继续执行信号处理例程。

但这不意味着这些被屏蔽的信号就此被忽略,它们仍被记录在进程控制块中,当信号处理例程执行结束之后它们便不再被屏蔽,从而后续可能被处理

mask 作为一个掩码可以代表屏蔽掉一组信号,因此它的类型 SignalFlags 是一个信号集合:

bitflags! {
    pub struct SignalFlags: i32 {
        const SIGDEF = 1; // Default signal handling
        const SIGHUP = 1 << 1;
        const SIGINT = 1 << 2;
        const SIGQUIT = 1 << 3;
        const SIGILL = 1 << 4;
        const SIGTRAP = 1 << 5;
        ...
        const SIGSYS = 1 << 31;
    }
}

sigprocmask

sigaction 可以设置某个信号处理例程的信号掩码,而 sigprocmask 是设置这个进程的全局信号掩码

// user/src/lib.rs

/// 功能:设置当前进程的全局信号掩码。
/// 参数:mask 表示当前进程要设置成的全局信号掩码,代表一个信号集合,
/// 在集合中的信号始终被该进程屏蔽。
/// 返回值:如果传入参数错误返回 -1 ,否则返回之前的信号掩码 。
/// syscall ID: 135
pub fn sigprocmask(mask: u32) -> isize;

sigreturn

在进程向内核提供的信号处理例程末尾,函数的编写者需要手动插入一个 sigreturn 系统调用来通知内核信号处理过程结束,可以恢复进程先前的执行。它的接口如下:

// user/src/lib.rs

/// 功能:进程通知内核信号处理例程退出,可以恢复原先的进程执行。
/// 返回值:如果出错返回 -1,否则返回 0 。
/// syscall ID: 139
pub fn sigreturn() -> isize;

信号的产生

信号的产生有以下几种方式:

  • 进程通过 kill 系统调用给自己或者其他进程发送信号。

  • 内核检测到某些事件给某个进程发送信号,但这个事件与接收信号的进程的执行无关。典型的例子如: SIGCHLD 当子进程的状态改变后由内核发送给父进程。可以看出这可以用来实现更加灵活的进程管理,但我们的内核为了简单目前并没有实现 SIGCHLD 这类信号。

  • 前两种属于异步信号,最后一种则属于同步信号:即进程执行的时候触发了某些条件,于是在 Trap 到内核处理的时候,内核给该进程发送相应的信号。比较常见的例子是进程执行的时候出错,比如段错误 SIGSEGV 和非法指令异常 SIGILL 。

rust知识点

链接

链接分为:强链接(strong linkage)和弱链接(weak linkage)。

  • 强链接是默认行为。如果一个符号(比如函数或全局变量)在程序中强链接,那么链接器在遇到多个定义时会报错,因为通常每个符号只能有一个定义。
  • 弱链接则不同。如果一个符号被声明为弱链接,那么它可以有多个定义,而链接器会在这些定义之间做出选择。如果有多个弱链接符号的定义,链接器通常会选择其中一个(比如第一个遇到的),而忽略其他的。如果没有找到弱链接符号的定义,链接器也不会报错(这在某些情况下可以避免链接错误)。

#[linkage = "weak"]

在 Rust 和其他一些编程语言中,#[linkage = "weak"] 属性是一个特殊的链接指令,用于指定函数或全局变量的链接方式。理解这个属性需要一些背景知识关于编译器和链接器如何处理程序代码。

在 Rust 中,#[linkage = "weak"] 属性可以被用来指定一个函数或全局变量应该被视为弱链接。这通常用于高级场景,比如在进行跨平台开发或与特定的C库交互时。例如,你可能希望在一个平台上使用某个函数的默认实现,在另一个平台上使用不同的实现,而不改变其他代码。

弱链接的选择:

  1. 首次遇到原则:在许多情况下,链接器可能选择它首次遇到的弱链接符号的定义。这意味着在链接对象文件时的顺序可能会影响哪个定义被选用。
  2. 覆盖弱链接:如果链接器找到一个符号的强链接(strong linkage)定义,它通常会优先使用这个定义,而忽略所有弱链接的定义。强链接的定义被视为更具体、更优先。
  3. 默认定义:在某些情况下,弱链接可以用来提供默认的实现。如果链接器没有找到强链接的定义,它会退回到弱链接的定义。这在提供库的默认实现时很有用,允许用户提供替代实现。

map_or函数

函数定义

fn map_or<U, F>(self, default: U, f: F) -> U
where
    F: FnOnce(T) -> U,

举个例子

fn main() {
    let maybe_number: Option<i32> = Some(42);
    let result = maybe_number.map_or("none".to_string(), |num| num.to_string());
    println!("{}", result); 
    
}

内部定义

    #[inline]
    #[stable(feature = "rust1", since = "1.0.0")]
    #[rustc_const_unstable(feature = "const_option_ext", issue = "91930")]
    pub const fn map_or<U, F>(self, default: U, f: F) -> U
    where
        F: ~const FnOnce(T) -> U,
        F: ~const Destruct,
        U: ~const Destruct,
    {
        match self {
            Some(t) => f(t),
            None => default,
        }
    }

流程

为了实现标准输入/输出的重定向

1、作者为OSInode(普通文件)、Stdin/Stdout(标准输入输出流) 、Pipe(管道)实现了File trait

2、作者在alloc_fd分配fd的时候,设置了一个机制,即每次都分配最小的fd号

3、实现重定位的步骤(以重定向标准输入流为例)

  • 程序A执行open系统调用打开文件B,获得fd1
  • 程序A执行close(0)系统调用,关闭标准输入流的fd
  • 程序A执行dup(fd1)系统调用,在dup内部分配了一个文件fd2,且其值为0,占据了标准输入流在fd_table上的位置,同时程序A令fd_table[0] = fd_table[fd1] ,以此实现重定向
  • 程序A执行close(fd1)系统调用关闭文件B的文件索引结点,此时标准输入流的索引结点指向文件B
  • 程序A执行printf函数打印Hello World,相当于在文件B中写入Hello World

4、作者实现Pipe的步骤

  • 作者创建了一个Pipe结构体,维护了一个buff 缓冲区循环队列,并且维护了两个令牌(一个可以读buff数据,一个可以向buff写数据),且实现File trait
  • 作者实现了pipe系统调用,系统调用结果返回这两个令牌
  • 这两个令牌使用的Arc浅拷贝,因此一个令牌发生变化,另一个令牌也会变化
  • 拿到读入令牌的一方可以读数据,拿到写入令牌的一方可以写数据到buff

5、作者实现命令行参数的步骤

image-20240122180636787

  • 作者在shell用户程序中把输入的命令进行分割,选出命令参数,以vec的形式保存

  • 并在exec系统调用中传入命令参数

  • 内核在创建用户空间的时候把这些参数全部压入user_sp中

  • 作者新建了一个.text.entry段,并在link.ld文件中放在了text段前面,标志着最先执行这一段

    image-20240122180809753

    image-20240122180840213

同时这一段把usize类型的参数转化成了 &[&str]类型的参数,以方便使用

  • cat命令,就是通过open main函数的参数来read 并printf文件数据

  • pipe也是通过传递命令参数来在命令行完成两个可执行文件的数据传递的

6、作者实现signal机制的步骤

  • 作者把大部分的trap_hander使用了signal进行处理

  • 每次经过trap_hander的时候都要经过signal处理,这时候分两种情况

    • 转交给内核处理(如SIGKILL、SIGSTOP、SIGCONT、SIGDEF signal)

      如果是SIGSTOP,则暂停进程,一直loop,直到进程接受到SIGCONT,发生suspend_current_and_run_next进程切换,其他信号则一律exit_current_and_run_next

    • 转交给用户处理

      trapContext结构体保存本次trap_hander时候的上下文,之后修改当前sepc段(返回u模式的目标地址)为该signal的处理函数,且把signal类型传递给处理函数(通过修改trapContext的a0寄存器)

      • 用户通过schedule 回到u模式,并执行回调函数
      • 用户执行sys_sigreturn系统调用返回到内核,并替换回trapContext
      • 用户通过之前保存的trapContext调度回u模式回到原来位置继续执行程序
  • 作者中途设置了一些掩码,包括进程的全局mark与当前signal的局部mark,只有通过这些掩码与其他一些条件,sgnal才能得以执行

  • 作者说没有设置嵌套signal,但我觉得有的地方已经有嵌套signal的感觉了

image-20240122223243761

image-20240122223343827

这里判断局部mark的时候就有点嵌套signal的意思,如果通过了task_inner.handling_sig那就嵌套了

整个signal处理就是下面这张图的流程

image-20240122201241997

编程题

分别编写基于UNIX System V IPC的管道、共享内存、信号量和消息队列的Linux应用程序,实现进程间的数据交换。

一、pipe

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
    int pipefd[2];
    pid_t cpid;
    char buf;

    if (pipe(pipefd) == -1) {
        perror("pipe");
        return -1;
    }

    cpid = fork();
    if (cpid == -1) {
        perror("fork");
        return -1;
    }

    if (cpid == 0) {    // 子进程
        close(pipefd[1]); // 关闭写端
        while (read(pipefd[0], &buf, 1) > 0) {
            write(STDOUT_FILENO, &buf, 1);
        }
        write(STDOUT_FILENO, "\n", 1);
        close(pipefd[0]);
    } else {            // 父进程
        close(pipefd[0]); // 关闭读端
        write(pipefd[1], "Hello, world!\n", 14);
        close(pipefd[1]);
        wait(NULL);
    }

    return 0;
}

这里我发现了一个有趣的现象,就是fork之后,读写数据之前,我们要立马让父/子进程close一个文件句柄,否则的话,程序将永远运行,不能结束:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
    int pipefd[2];
    pid_t cpid;
    char buf;

    if (pipe(pipefd) == -1) {
        perror("pipe");
        return -1;
    }

    cpid = fork();
    if (cpid == -1) {
        perror("fork");
        return -1;
    }

    if (cpid == 0) {    // 子进程
       
        while (read(pipefd[0], &buf, 1) > 0) {
            write(STDOUT_FILENO, &buf, 1);
        }
        write(STDOUT_FILENO, "\n", 1);
        close(pipefd[1]); // 关闭写端
        close(pipefd[0]);
    } else {            // 父进程
       
        write(pipefd[1], "Hello, world!\n", 14);
        close(pipefd[1]);
        close(pipefd[0]); // 关闭读端
        wait(NULL);
    }

    return 0;
}

分析内核源码,我们可以搞明白:

image-20240123124441302

对于read函数,他必须等待所有的写fd都结束了才能退出,因此如果我们在读pipe前不销毁写fd,则会死循环下去(循环执行suspend_current_and_run_next)

二、共享内存

write.cpp

#include <stdio.h>
#include <stdlib.h>
#include <sys/shm.h>
#include <sys/stat.h>

int main() {
    int segment_id;
    char* shared_memory;
    const int size = 4096;

    // 创建共享内存段
    segment_id = shmget(IPC_PRIVATE, size, S_IRUSR | S_IWUSR);
    printf("%d",segment_id);
    // 附加到共享内存段
    shared_memory = (char*) shmat(segment_id, NULL, 0);
    
    // 写入数据到共享内存
    sprintf(shared_memory, "Hello, world!");

    // 脱离共享内存段
    shmdt(shared_memory);

    // 在这里,我们不立即删除共享内存段,以便其他进程可以使用它
    // 共享内存的键(segment_id)需要被传递给其他需要访问它的进程

    return 0;
}

read.cpp

#include <stdio.h>
#include <stdlib.h>
#include <sys/shm.h>
#include <sys/stat.h>

int main() {
    int segment_id;
    char* shared_memory;
    const int size = 4096;

    // 这里假设我们已经知道要访问的共享内存段的 segment_id
    segment_id = 1146930;

    // 附加到共享内存段
    shared_memory = (char*) shmat(segment_id, NULL, 0);

    // 读取共享内存中的数据
    printf("*%s*\n", shared_memory);

    // 脱离共享内存段
    shmdt(shared_memory);

    // 删除共享内存段(如果不再需要的话)
    shmctl(segment_id, IPC_RMID, NULL);

    return 0;
}

共享内存原理:

进程间共享内存是一种高效的进程间通信(IPC)机制。它的基本原理是允许两个或多个进程访问同一块内存区域,从而实现数据的共享和交换。这种机制与其他IPC方法(如管道、消息队列等)相比,因为直接内存访问而具有更高的效率。

工作流程

  1. 内存映射
    • 共享内存工作的关键是通过内存映射(Memory Mapping)的方式使得多个进程可以访问同一物理内存区域。内存映射是操作系统提供的一种机制,用于将物理内存地址映射到进程的虚拟地址空间。
  2. 创建共享内存段
    • 一个进程(通常是服务器进程)创建一个共享内存段。在 Linux 系统中,这可以通过 shmget 系统调用实现。这个调用返回一个标识符(通常是一个整数),用于后续的操作。
  3. 附加共享内存
    • 创建共享内存段后,需要将其“附加”到进程的地址空间。这是通过 shmat 系统调用实现的。附加后,进程可以通过自己的地址空间来访问共享内存。
  4. 使用共享内存
    • 一旦共享内存被附加,进程就可以像使用普通内存一样对其进行读写操作。这允许极快的数据交换,因为所有的通信都是在内存中直接进行的。
  5. 脱离和销毁共享内存
    • 使用完共享内存后,进程应该将其从自己的地址空间“脱离”。这通过 shmdt 系统调用实现。
    • 最后,当共享内存不再被任何进程使用时,可以通过 shmctl 调用销毁。

三、信号量 Semaphore

#include <stdio.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
    key_t key = 1234;
    int semid;
    struct sembuf sb = {0, -1, 0};  // 定义信号量操作

    // 创建信号量
    semid = semget(key, 1, 0666 | IPC_CREAT);

    // 初始化信号量
    semctl(semid, 0, SETVAL, 1);

    // 等待信号量
    semop(semid, &sb, 1);      // P()

    printf("Enter critical section\n");

    // 模拟临界区代码
    sleep(3);

    printf("Leave critical section\n");

    // 释放信号量
    sb.sem_op = 1;
    semop(semid, &sb, 1);       // V()

    // 删除信号量
    semctl(semid, 0, IPC_RMID);

    return 0;
}

四、消息队列(Message Queue)

send.cpp

#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdio.h>
#include <string.h>

struct message {
    long mtype;
    char mtext[100];
};

int main() {
    key_t key;
    int msgid;
    struct message msg;

    // 创建唯一的 key
    key = ftok("progfile", 65);

    // 创建消息队列
    msgid = msgget(key, 0666 | IPC_CREAT);
    msg.mtype = 1;

    // 写入消息
    strcpy(msg.mtext, "Hello, world!");

    // 发送消息
    msgsnd(msgid, &msg, sizeof(msg), 0);

    printf("Sent message: %s\n", msg.mtext);

    return 0;
}


recv.cpp

#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdio.h>
#include <string.h>

struct message {
    long mtype;
    char mtext[100];
};

int main() {
    key_t key;
    int msgid;
    struct message msg;

    // 创建唯一的 key
    key = ftok("progfile", 65);

    // 连接到消息队列
    msgid = msgget(key, 0666 | IPC_CREAT);

    // 接收消息
    msgrcv(msgid, &msg, sizeof(msg), 1, 0);

    printf("Received message: %s\n", msg.mtext);

    // 销毁消息队列
    msgctl(msgid, IPC_RMID, NULL);

    return 0;
}

分别编写基于UNIX的signal机制的Linux应用程序,实现进程间异步通知

send.cpp

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "使用方式: %s <PID>\n", argv[0]);
        return 1;
    }

    int pid = atoi(argv[1]);
    if (kill(pid, SIGUSR1) < 0) {
        perror("发送信号失败");
        return 1;
    }

    printf("信号已发送到进程 %d\n", pid);
    return 0;
}

recv.cpp

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

// 信号处理函数
void signal_handler(int sig) {
    printf("收到了信号 %d\n", sig);
    // 实现特定的逻辑
}

int main() {
    // 注册信号处理函数
    if (signal(SIGUSR1, signal_handler) == SIG_ERR) {
        printf("无法设置信号处理器\n");
        exit(1);
    }

    printf("等待信号,进程 ID: %d\n", getpid());

    // 进入无限循环,等待信号
    while (1) {
        pause();  // 暂停进程直到收到信号
    }

    return 0;
}

image-20240123131305415

问答题

1、直接通信和间接通信的本质区别是什么?分别举一个例子。

直接通信方式。发送进程直接把消息发送给接收进程,并将它挂在接收进程的消息缓冲队列上,接收进程从消息缓冲队列中取得消息

间接通信方式。发送进程把消息发送到某个中间实体,接收进程从中间实体取得消息。这种中间实体一般称为信箱。

本质区别是消息是否经过内核,如共享内存就是直接通信,消息队列则是间接通信。

2、试说明基于UNIX的signal机制,如果在本章内核中实现,请描述其大致设计思路和运行过程。

首先需要添加两个syscall,其一是注册signal handler,其二是发送signal。其次是添加对应的内核数据结构,对于每个进程需要维护两个表,其一是signal到handler地址的对应,其二是尚未处理的signal。当进程注册signal handler时,将所注册的处理函数的地址填入表一。当进程发送signal时,找到目标进程,将signal写入表二的队列之中。随后修改从内核态返回用户态的入口点的代码,检查是否有待处理的signal。若有,检查是否有对应的signal handler并跳转到该地址,如无则执行默认操作,如杀死进程。需要注意的是,此时需要记住原本的跳转地址,当进程从signal handler返回时将其还原。
  • 作者把大部分的trap_hander使用了signal进行处理

  • 每次经过trap_hander的时候都要经过signal处理,这时候分两种情况

    • 转交给内核处理(如SIGKILL、SIGSTOP、SIGCONT、SIGDEF signal)

      如果是SIGSTOP,则暂停进程,一直loop,直到进程接受到SIGCONT,发生suspend_current_and_run_next进程切换,其他信号则一律exit_current_and_run_next

    • 转交给用户处理

      trapContext结构体保存本次trap_hander时候的上下文,之后修改当前sepc段(返回u模式的目标地址)为该signal的处理函数,且把signal类型传递给处理函数(通过修改trapContext的a0寄存器)

      • 用户通过schedule 回到u模式,并执行回调函数
      • 用户执行sys_sigreturn系统调用返回到内核,并替换回trapContext
      • 用户通过之前保存的trapContext调度回u模式回到原来位置继续执行程序
  • 作者中途设置了一些掩码,包括进程的全局mark与当前signal的局部mark,只有通过这些掩码与其他一些条件,sgnal才能得以执行

3、比较在Linux中的无名管道(普通管道)与有名管道(FIFO)的异同。

  • 无名管道是最基本的管道类型,通常用于父子进程或者同一祖先的进程之间的通信。
  • 无名管道在文件系统中没有对应的文件名,它们仅存在于内存中。
  • 无名管道的典型用途是在 Unix shell 中的管道操作,比如 ls | grep "something"
  • 有名管道允许不相关的进程之间进行通信
  • 有名管道是双向的,但在任何时刻数据只能在一个方向上流动
  • 普通管道不存在文件系统上对应的文件,而是仅由读写两端两个fd表示,而FIFO则是由文件系统上的一个特殊文件表示,进程打开该文件后获得对应的fd。

lab7中实现的是无名管道

4、请描述Linux中的无名管道机制的特征和适用场景。

无名管道的典型用途是在 Unix shell 中的管道操作,比如 ls | grep "something"

无名管道用于创建在进程间传递的一个字节流,适合用于流式传递大量数据,但是进程需要自己处理消息间的分割。

5、请描述Linux中的消息队列机制的特征和适用场景。

消息队列用于在进程之间发送一个由type和data两部分组成的短消息,接收消息的进程可以通过type过滤自己感兴趣的消息,适用于大量进程之间传递短小而多种类的消息

6、请描述Linux中的共享内存机制的特征和适用场景。

共享内存用于创建一个多个进程可以同时访问的内存区域,故而消息的传递无需经过内核的处理适用在需要较高性能的场景,但是进程之间需要额外的同步机制处理读写的顺序与时机

7、请描述Linux的bash shell中执行与一个程序时,用户敲击 Ctrl+C 后,会产生什么信号(signal),导致什么情况出现。

SIGINT,程序终止

8、请描述Linux的bash shell中执行与一个程序时,用户敲击 Ctrl+Zombie 后,会产生什么信号(signal),导致什么情况出现。

会产生SIGTSTP,该进程将会暂停运行,将控制权重新转回shell。

9、请描述Linux的bash shell中执行 kill -9 2022 这个命令的含义是什么?导致什么情况出现。

向pid为2022的进程发送SIGKILL,该信号无法被捕获,该进程将会被强制杀死。

10、请指出一种跨计算机的主机间的进程间通信机制。

Sockets

RPC

实验题

非常暴力的完成了实验 xD

https://github.com/TL-SN/rCore/tree/lab7

qemu启动

直接make debug不太好调试a

qemu-system-riscv64 \
	-machine virt \
	-nographic \
	-bios '/home/tlsn/Desktop/OSSS/lab4/rCore-Tutorial-v3/bootloader/rustsbi-qemu.bin' \
	-device loader,file='/home/tlsn/Desktop/OSSS/lab7/rCore-Tutorial-v3/os/target/riscv64gc-unknown-none-elf/release/os',addr=0x80200000 \
	-drive file='/home/tlsn/Desktop/OSSS/lab7/rCore-Tutorial-v3/user/target/riscv64gc-unknown-none-elf/release/fs.img',if=none,format=raw,id=x0 \
	-device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 \
	-s -S
riscv64-unknown-elf-gdb \
    -ex '/home/tlsn/Desktop/OSSS/lab7/rCore-Tutorial-v3/os/target/riscv64gc-unknown-none-elf/release/os'  \
    -ex 'set arch riscv:rv64' \
    -ex 'target remote localhost:1234'
posted @ 2024-01-23 21:32  TLSN  阅读(13)  评论(0编辑  收藏  举报