Skip to content

进程信号

这一节,我们来谈一谈我们的小内核进程之间的通信手段——信号。

我们会涉及到信号的实现、发送、接收处理、以及一个类似于sigaction的系统调用(自定义信号处理函数)。

当初写的时候有点迷糊,代码中好多相关结构体名字命名挺乱的。。。

信号可以大致分为以下两类:

  • 标准信号

传统的Unix信号,范围 1~31。以查看某一位是否为1/0即可知道有没有该信号。

不支持排队,也就是说,多个相同的信号会被合并为一个,这也是标准信号最大的特点。

常见的SIGINTSIGQUITSIGKILLSIGCHLD等都属于这一类

  • 实时信号

范围32~64,支持排队、优先级、附加数据、可靠传递(也就是不会丢失,不会被合并,一个一个处理)。

在我们的实现中,

标准信号采用 sigset_t(uint32_t) 实现,每一位代表一种信号;

实时信号我们为了简化,仅仅顺序先来先服务,采用队列实现。每个siginfo_t直接作为实时信号,其info字段表示信号种类,没有其他什么额外的东西;

(命名不规范的很多,这里只是冰山一角,哈哈)

1. 信号实现

这一部分比较简单(当然了,其实只是我们实现功能很又少又简陋而已。。。)

c
// include/core/signal.h

typedef uint32_t sigset_t;
// 信号处理函数类型
typedef void (*__sighandler_t)(int);

// 信号处理配置结构体
struct sigaction {
	__sighandler_t sa_handler; 
};

// siga 存放信号处理函数
struct signal_struct {
    spinlock_t lock;
	struct sigaction siga[_NSIG];
};

// 实时信号(加入 sigpending->sigpending.queue队列)
typedef struct {
	struct list_head list;
	uint8_t info;			// 信号信息
} siginfo_t;

// 待处理的信号
struct sigpending {
	struct fifo queue; 	// 实时信号
	sigset_t signal;	// 标准信号
	sigset_t blocked;	// 阻塞信号
};

// 信号管理(task_struct->signal)
struct signal {
	spinlock_t lock; // 用于处理信号标准合并导致的 sigpend 数量不对的问题
	uint32_t sigpend;
    struct sigpending sping;
	struct signal_struct *sig;
};

下面是对一些结构体的简答解释:

  • signal:管理信号的整个外围大结构,包括处理函数与存放信号(被task_struct直接使用)

  • sigpending:存放信号,包括标准和实时信号

  • siginfo_t:表示一个实时信号

  • signal_struct:存放信号处理函数

想必我们到了这里,已经大概知道了信号实现。(没错,我们仅仅实现了这微不足道的一点,哈哈)

2. 发送

这一部分也比较简单。伪代码如下(省去一堆错误检测处理):

c
// kernel/proc/signal.c
void send_sig(int sig, pid_t pid)
{
    // 检测信号合法性
    detect_sig_valid(sig);
    
    // 找到对应的进程
     p = find_proc(pid);
    
    // 检测信号是否被该程序堵塞
    is_blocked(p,sig);
    
    // 添加信号
    add_signal(p,sig);
}

3. 接收和处理

接收之后如果有的话就要处理,那么问题来了,进程检测信号有无的时机在哪里呢?

我们便也顺着古人的路子,采用程序主动查询方式。只不过可不是单独派一个玩意儿来监视。

具体来说:我们在程序中断即将返回的时候查看信号。为了不是遍历整个信号清单来查有没有待决信号,我们设置标志位来判断待决信号的数量。当我们发现有信号的时候,并移除信息,就完成了对信号的接收。

下面就要根据信号编号在siga中找到对应的处理函数了。这里就是需要我们注意的地方。对于系统提供的默认函数,直接执行貌似没有问题的。但是呢,对于用户自定义的函数,这里就有问题了。什么问题呢?

用户自定义的函数位于用户虚存空间(侧重于用户能访问的),而系统初始提供的默认处理函数位于内核空间。前面我们说到,我们是在中断即将返回的时候检查并处理信号,那此时的我们,是位于内核态的,而且还拖着一堆中断的备份信息呢。就算我们回到用户自定义的函数,这个函数返回的时候会返回到哪里呢(绝不可能一个return返回到内核空间,因为我们执行的时候位于用户态)?就算我们不管一切,直接执行用户自定义的函数,假如再中断?原中断的备份信息如何保存?如何回答到第一次中断回去的断点处呢?

因此,由于有用户自定义的信号处理函数这个拦路虎在,看来我们的执行并不会是一帆风顺。

下面我们给出解决办法。

4. sigaction

为了下文叙述方便,我们假设用户当前发生中断时所在的位置成为函数 A里,用户自定义的信号处理函数为B

现在的状况是在运行A的过程中发生中断,中断返回图中检测到有待决信号,对应的信号处理函数为B

这一部分和RISCV的中断返回约定有一点关联(可以参见函数约定)。

我们目前是在内核态,去执行B 之前怎么说都绕不开中断返回。正常如果没有信号的话,应该回到 A,那么如果我们什么都不管,直接修改 sepc ,然后恢复原来的中断保存的一堆寄存器,使得我们直接 sret 后到达函数 b,行吗? 很明显,这种做法是错误的。两个问题:

  1. 第一,如果函数 B 执行完后发生 ret,那么他应该返回 ra 寄存器中保存的地址。那么此时 ra 寄存器保存的地址是哪个呢?我们是在函数 A 中断,ra 的信息应该是在函数 A 环境中的 ra, 那么中断保存恢复后也应该是一样的,也就是此时的 raA 的上一级函数的地址。因此,当 B 中 发生 ret,会直接返回到 A 的上一级函数。这明显是不符合要求的。
  2. 第二,姑且算我们在Bret能够返回函数 A,函数 B 运行过程中会修改一堆寄存器,也会经历其他的一堆中断,最后返回A的时候寄存器说不定早已面目全非了。若不考虑函数 B,就普通的中断而言,函数 A 中中断保存和中断恢复的寄存器信息应该是完全一样的。现在的这个情况,明显也不满足条件。

我们先来解决第二个问题:

处理该问题的做法最简单的就是备份

在我们的内核中,中断发生时寄存器的保存的唯一位置位于 thread_info->trapframe。为了避免后面在执行 B 的时候被覆盖,我们将该thread_info->trapframe备份一份不就好了。不过备份到哪里呢?经过考虑,我们决定放在用户栈(主要是空间问题),然后当函数 B 返回函数 A 之前,从这个用户栈取出以前的信息,恢复后返回 A 即可。

不知觉之中发现,按照上述做法,好像上面两个问题都解决了,问题1的ra的问题也得到了解决。

下面我们为了实现方便,提出来一种简单的做法(虽然效率并不高):

我们额外增加了一个系统调用SYS_sigret,用于信号处理返回。该部分的代码如下:

c
// kernel/proc/signal.c
// 信号处理函数(部分)
{
	// 执行用户自定义函数准备
    
	struct thread_info *t = myproc();
	// 保存 tf 到用户栈
	memcpy((char *)t->tf->sp - sizeof(*t->tf), (void *)t->tf, sizeof(*t->tf));
	t->tf->sp -= sizeof(*t->tf);
	t->tf->a0 = (uint64_t)do_sig_handler;	// 自定义处理函数的地址,中断返回恢复寄存器的时候会被赋值给 a0
	t->tf->epc = (uint64_t)sigact_call;		// 中断返回后跳转到 sigact_call 执行
}



# /kernel/start.S
# 用于跳转到用户自定义信号处理函数,以及信号返回
sigact_call:
    jalr a0									// 见上面,跳转到自定义处理函数执行
    li  a7, SYS_sigret
    ecall									// 继续来个系统调用用于通知返回
        
// kernel/proc/signal.c
// 信号返回处理函数
int64_t do_sigret()
{
    struct thread_info *t = myproc();
    // 恢复最初的中断保存信息,这里中断返回便可以回到 `A`
    memcpy((void *)t->tf, (void *)t->tf->sp, sizeof(*t->tf));
    return 0;
}

有个示意图,描述了栈指针的移动:

image-20250621160909411

有个疑惑?为什么我们不可以直接在sigact_call中不使用SYS_sigret中断,而是直接弹栈恢复,此时的信息一样的A中断保存的信息,然后再跳转A不行吗?

其实我们最初设想就是这样的,但是呢如何跳转到A就是一个问题了。因为此时所有的寄存器都是“战备”紧绷状态,没有其他供我们自由使用的寄存器,也就是说,连找个寄存器存放返回到A的地址都找不到。。。

因此,我们选择了再一次程序主动系统调用来返回内核,然后内核中恢复信息,本次中断的保存的寄存器就不用管了,我们需要的是把原来保存在栈中的寄存器恢复,也就是赋值给thread_info->trapframe,然后与其他中断返回一样就回去了。从A视角看来,中断去中断来,也就是特别正常的。

如果我们故意直接调用SYS_sigret会怎样?

我们的建议是在进程中可以加一个标志位,来防止故意调用导致不确定的情况发生,只有在信号处理返回才允许。