OS内核的信号机制:所有的异步都可以是同步的

系统 其他OS
每个进程,在OS内核里都被一个task结构体表示,这个结构体的其中一个成员变量就是记录信号的:我们给他起名叫sigmap,Linux的不一定要叫这个名字,但肯定有这一项。

今天条友@xiamenuser给我提了一个关于操作系统的问题:怎么把定时器线程里的回调函数,(在定时器触发之后)挪到工作线程里运行?

这个需求要做的事,跟Linux内核的信号机制是一样的。

OS内核的信号机制,在1970年的Unix时代就有了,是一个上古话题。

在unix里,可以使用kill -9 pid命令杀掉进程(pid为进程号),在Linux里也可以。

1.OS内核的信号

有个专有的宏定义#define SIGKILL 9,然后信号9就成了一个特别牛的信号,大概除了0号idle进程和1号init进程之外,其他进程都可以杀死。

0号进程和1号进程是不能杀死的,否则系统就崩溃了!

int sys_kill(int sig, int pid)
{
if (sig < 0 || pid < 0)
return -EINVAL;
if (0 == pid || 1 == pid) {
if (SIGKILL == sig) return -1;
}
tasks[pid]->sigmap |= 1 << sig;
return 0;
}

OS内核里对应着kill命令的sys_kill()系统调用,大概是上面这样:

在进程的task结构体的sigmap成员变量上,设置1个标志位,进程就可以收到信号了。

每个进程,在OS内核里都被一个task结构体表示,这个结构体的其中一个成员变量就是记录信号的:我们给他起名叫sigmap,Linux的不一定要叫这个名字,但肯定有这一项。

这个信号在什么时候处理呢?

等到收信号的进程下一次被调度运行的时候。

当前运行的进程,肯定是发信号的进程,否则它没法主动发起kill()系统调用。

发信号的进程做的事,只是把信号设置到接收进程的信号图上,这时信号实际上已经发到了:但是接收进程并不会马上因为SIGKILL信号而被杀死。

SIGKILL信号的杀进程,实际上进程是自杀的!

当收到信号的进程再次被调度运行的时候,操作系统会让它先执行信号的处理函数,而SIGKILL的处理函数,就是exit()系统调用:进程退出。

这个过程可以是异步的,等到接收进程下一次被调度时再处理,至于什么时候轮到它:等吧。

也可以让它马上同步处理,只需要在sys_kill()函数的末尾加一行代码就行:

shedule_task( tasks[pid] );

直接选择接收进程是下一个要调度的进程,并且马上调度它运行:接下来它就完事了。

不需要等OS内核统计时间片,确定调度的优先级了,既然用户想让它挂掉,OS当然要马上让它挂掉。

毕竟Linux系统也惹不起用户啊,用户是可以重装windows的​

接下来,说说shedule_task()之后的细节。

2.信号是怎么处理的

每个信号都有一个处理函数,叫信号处理函数。

信号处理函数,是在用户态的代码里运行的。

所以,程序员可以自己给部分信号编写处理函数,用signal()系统调用注册到OS内核,就可以(在收到信号时)运行这个自己编写的函数了。

如果信号处理函数是在内核状态运行的,那显然用户编写的函数是没法运行的,因为用户函数的内存地址在用户空间(它在进程的代码段里)。

OS内核在信号处理时要做的是,把进程从内核返回后要运行的代码地址,改成信号处理函数的地址。

修改过程如下:

系统内核的信号处理过程

1)进程从内核返回时的状态,如上图。

内核栈上的寄存器排布顺序不一定是对的,这要查intel的手册,但是这些项肯定都有。

在进程使用iret指令(中断返回)从内核返回的那一刻,内核栈上的这些数据都要弹出到对应的寄存器。

然后,进程就会运行EIP指向的用户代码,同时用户态的栈顶就是ESP。

EIP和ESP指向的内容到底是什么,内核不需要管:这是由程序员写代码时确定的。

进程从内核返回之后的错误,错的是程序员,不是系统内核。

但要是返不回来,或者不能处理信号,错的就是系统内核了。

2)OS内核要做的是,修改内核栈上、保存的、用户态的、EIP和ESP(注意这3个定语):

A,让EIP指向信号处理函数,

B,让ESP指向信号处理函数的参数,

C,在信号处理函数的下方,放上“真正的”返回地址,

D,在信号处理函数运行完之后,丢掉(信号处理函数的)参数,弹出真正的返回地址:让程序恢复正常的状态,继续运行。

如上图中的绿字部分。

如果一次要处理多个信号的话,就顺着用户栈继续叠加就行。

siska内核demo里的信号处理代码,如下的3张图:

因为信号处理函数有参数,而参数要压在用户态的栈上,所以信号处理函数运行完之后还要清理它。

所以,与一般的C函数不同,信号处理函数是被调函数清理堆栈的:即它是pascal调用,而不是C调用!

C调用,都是主调函数清理堆栈的。

所以,信号处理函数的总入口是一段汇编代码,用来在C语言里完成这个pascal调用。

这么看来,pascal这种老语言,也不是想象的那么差​

这个信号处理方式,是我给出来的解决方案​

至于Linux是不是也这么做的,我就不知道了。

但是,这么做是可行的。

siska信号处理,pascal调用的汇编

上图95行的call *(%eax),就是调用信号处理的函数指针。

它前后的汇编代码,都是准备参数和清理堆栈。

3.回到开头的问题

怎么让定时器线程在触发之后,让回调函数在工作线程里运行?

回调函数一般有一个参数,表示回调上下文,但没有返回值。

因为定时器的添加和处理在2个线程里,回调函数的返回值没有意义。

如果回调函数的处理出错了,就在上下文里设置错误码作为提示。

所以,它的函数声明是这样的:void callback(void* ctx);

要让它正常运行,必须把回调上下文的指针添加到工作线程的用户栈上,同时让工作线程的内核栈上保存的EIP指向回调函数。

这个处理方式,与OS内核的信号处理方式是一样的。

信号处理函数的声明:void sighandler(int sig); 也是一个参数、无返回值。

在定时器触发之后,定时器线程可以发起一个系统调用,把这些信息给到内核,然后内核修改工作线程的数据,让定时器的回调处理“像个信号”一样就可以了​

这个系统调用如果Linux没有提供的话,就只能自己修改Linux内核代码,或者给Linus大牛提个需求了(他有可能看不过来你的邮件)。

PS:

工作线程和定时器线程在同一个进程里,所以它们的用户态内存的代码段、数据段、堆都是共享的,只是内核栈和用户栈不一样。

内核栈:在内核看来,每个线程也是一个可调度的进程,它必须有自己的内核栈和页表。

同一个进程的不同线程之间共享内存,靠的是页表的映射:把它们映射到同一个物理内存页上。

用户栈:不同的线程可以并发运行,它们的用户栈肯定是不同的,否则局部变量就互相覆盖了:这肯定是不可能的。

siska里信号处理的代码,如下:

siska信号处理,1

siska信号处理,2

责任编辑:武晓燕 来源: 今日头条
相关推荐

2023-04-03 09:44:18

2011-07-05 18:40:19

QT 信号 机制

2011-07-05 18:32:52

QT 信号 机制

2024-07-16 10:52:09

2009-07-21 09:31:00

Scala操作符

2021-12-10 00:01:53

Vsync信号机制

2017-05-15 11:39:36

2018-02-25 09:00:00

LinuxQ4OS开源

2011-10-11 16:57:13

windows8TechEd 2011

2022-03-31 08:15:59

递归代码非递归

2022-03-11 11:40:26

AI数据技术

2009-11-12 09:51:11

Visual C++项

2014-09-24 13:04:13

微信企业号

2017-09-14 09:40:32

PythonUbuntu信号机制

2022-03-07 10:27:03

Linux开源社区

2011-06-17 14:36:50

Linux

2013-04-08 12:41:35

JavaScriptJS

2023-03-20 18:34:02

营销大促质量保障稳定性

2013-07-04 09:46:06

X Phone

2020-08-14 12:17:48

算法机器学习
点赞
收藏

51CTO技术栈公众号