一个关于信号处理函数的小trick
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
本作品 (李兆龙 博文, 由 李兆龙 创作),由 李兆龙 确认,转载请注明版权。
引言
最近闲来无事,开始回想大学两年半想玩而又没玩过的小玩意,一个有意思的念头几乎是瞬间浮现在脑海中,即用户态抢占式调度如何实现。这个问题其实就是前一阵某个比赛的一个题目,其实就是实现一个抢占式的用户态线程,这个玩意本身就属于一个比较Hack的东西,一般人闲的没事不会碰这个玩意。
但是从某个角度来讲现在我并不能严格的算是一个一般人,毕竟一般人谁没事一天躺尸睡觉还没有一丝的负罪感呢?而且我也确实是闲的没事。所以我们就来会会这个妖艳的小东西吧!
简单说说
用户态抢占式调度最麻烦的一点就是如何高效的实现中断以及调度到应该被调度的协程,后者其实是调度算法要做的事情,我们暂且不提。所以问题的关键在于中断处理程序中上下文如何切换,在LUTF的项目中我们采用信号处理函数实现中断,我们可以利用一个信号处理函数的实现机制来实现抢占式调度。
即[2]:
当执行信号处理函数的时候,内核会把该进程内核栈上的寄存器上下文sigcontext拷贝到用户态的堆栈中,再压入一个sigreturn系统调用作为返回地址,然后等信号处理函数完成后,sigreturn将会自动陷入内核,再将用户态的sigcontext拷贝回内核栈,以彻底完成信号处理,恢复进程的寄存器上下文。
可以看到信号处理的过程也类似于一个协程,无非调度比较简单而已。
当然学习的第一要素就是凡事多问问为什么,为什么内核要这么玩呢?来看看caltech的eperating system PPT吧,下图来源于CS124 Lec15:
写的很清楚,这样做的原因就是当从用户态返回到内核态的时候会清空内核栈,这样我们就没办法再返回到原来被中断的线程中了,这里其实已经有点类似于协程的调度了。
因为最后信号处理函数返回的时候需要借助于这些用户栈上的数据切换到原来被中断的线程上,所以需要进行一个系统调用来完成这件事,这个系统调用就是sigreturn
,这里的原理其实就是函数调用的原理,在函数结束后有一个首先借助ebp恢复栈帧,然后调用一个old eip
回到上一个函数的上下文中,这里相当于old eip
为sigreturn
的地址。
这里要提一下32位是ebp,esp,64位就是rbp,rsp了。
当然课程中也说到这种机制可以实现一个用户态线程库:
神奇72字节
在dog250大神的博客中提到我们可以通过信号处理函数的第一个局部变量偏移32字节找到rt_sigframe
,然后再偏移40字节找到sigcontext
,后面40字节的偏移很好算,看看结构体的定义就知道了。
我们来看看压上栈的结构体的定义:
#ifdef CONFIG_X86_64
struct rt_sigframe {
char __user *pretcode;
struct ucontext uc;
struct siginfo info;
/* fp state follows here */
};
...
/* 一路追溯,看看rt_sigframe展开后的样子 */
// include/uapi/asm-generic/ucontext.h
struct ucontext {
unsigned long uc_flags;
struct ucontext *uc_link;
stack_t uc_stack;
struct sigcontext uc_mcontext; // 这个就是我们要找的东西!
sigset_t uc_sigmask; /* mask last for extensibility */
};
typedef struct sigaltstack {
void __user *ss_sp;
int ss_flags;
size_t ss_size;
} stack_t;
我们可以看到如果得到了rt_sigframe
的首地址,偏移40
个字节就可以得到sigcontext
的首地址,其中存着所有的寄存器的信息。
那么32字节的偏移是怎么算的呢?
在大神的博客里描述的并不清楚,只是提到了32个字节中16字节的归属。目前还是不清楚dog250大神的32位偏移是怎么算的,我们来验证一下。
环境名称 | 值 |
---|---|
系统 | 5.9.6-arch1-1 |
GCC | 10.2.0 |
我们尝试打印下一个信号处理函数的堆栈信息,代码来自于[2]:
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
int i, j, k = 0;
unsigned char *stack_buffer;
unsigned char *p;
void sig_start(int signo)
{
unsigned long a = 0x1234567820000304;
p = (unsigned char*)&a;
printf("signo : %p; p : %p\n",&signo, &a);
stack_buffer = (unsigned char *)&a;
// 以下按照8字节为一组,打印堆栈的内容
printf("----begin stack----\n");
for (i = 0; i < 32; i++) {
for (j = 0; j < 8; j++) {
printf(" %.2x", stack_buffer[k]);
k++;
}
printf("\n");
}
printf("----end stack---- : \n",);
if (signo = SIGINT)
signal(SIGINT, NULL);
if (signo = SIGHUP)
signal(SIGHUP, NULL);
return;
}
int main()
{
printf("process id is %d %p %p\n",getpid(), main, sig_start);
signal(SIGINT, sig_start);
signal(SIGHUP, sig_start);
unsigned long esp = 0x1234567820000304;
printf("esp : %p\n", &esp);
printf("esp : %p\n", &esp);
for (;;);
}
我们用GDB调试一下:
我们可以看到esp的地址为0x7fffffffdbd0
而在a向上偏移16字节处存储的值是0x00007fffffffdbe0
,也就是原先的rbp,那么是否是原先的rip呢?从理论讲不是,因为注册了用户态信号处理函数的信号的调度不是把控制权交给用户态,而是内核直接执行这个handler。
所以这里的rip肯定不相同,我们只需要在GDB中触发信号之前info r
一下就可以验证。
明显可以看到rip不是堆栈上的值,所以基本可以确定是从内核调度这个handler的。
我们来看看栈上的地址空间,发现形参的地址其实低于局部变量a,也就是0x7fffffffd510
处的那个2。
我们假设八个字节为一帧,第一个帧确定为局部变量a,从栈空间上可以确定第三个帧为ebp,大神说第四个帧为sigreturn
,这个一会验证下,这个大神其实说的也有一点问题。那么第二个帧是什么呢?事情到了这个地步不跑汇编根本不可能猜出来,先给出答案,是一个栈上的安全安全机制,在汇编来看它的作用就是检测栈上的操作最后是否回到正确的位置,而不是存在于臆想中的old rip
。
很清楚的展示了第二个帧中的神秘变量是什么,首先push %rbp
,然后移动rsp,此时把rsp向下移动4个帧(我们前面定义过帧),把信号处理函数参数的值放到rbp下20个字节处。然后就是重头戏!把一个检查栈的变量放入rax,把rax的值放到rbp向下偏移8个字节处,这不就是我们前面说的第二个帧吗!真是得来全不费功夫啊哈哈哈!
然后清空rax,把局部变量的值放入到rax中,最后放入到rbp向下偏移16个字节处。
接下来我们验证下大神说的第四个帧中的sigreturn
地址。
上面这幅图就是显示信号处理函数PC上面的十条指令,我们可以很清楚的看到首先把刚刚那个栈检查的值放到rax,然后异或判断是否相等,不相等的话调用__stack_chk_fail@plt
,这个猜测是一个检查机制,相等的话顺序执行,也就是执行到sig_start+528
,然后执行leaveq
,它的作用就是movq %rbp, %rsp; popq %rbp
,现在rsp指向0x7fffffffd138
,然后执行retq,然后打印0x7fffffffd138
上值的内容,就是__restore_rt
,这个玩意想必最后调用__kernal_sigreturn
了[6]。
好了,现在我们大概知道这件事情的来龙去脉了。到了这里只想说一个词,通畅!
最后感谢小组18级内核组胡庆伟大大悉心指导,真是手把手的教我啊!没有他我可能还觉得那个第二个帧是old rip
呢。
最后说说
在[2]中其实已经可以看出一个简单抢占式调度如何做了,而且从技术上讲也是可行的,虽然他的代码在我的机器跑会有段错误,但是我们团队自己实现的用户态抢占式调度框架中基于这个72字节是运行正常的。
不过可以想到这样实现用户态线程的做法有以下问题:
- 不具备跨平台性,虽然可以预想到大多数的Linux发行版信号机制都是这样实现的,但是这个偏移的计算却没有什么文档告诉我们偏移多少是什么,不过至少OpenEuler上能跑。
- 效率低,每次用户态线程的切换都需要从内核态到用户态信号处理函数,然后调用sigreturn再回到内核,最后切换到一个新的线程运行,一次切换需要两次用户态到内核态的切换,这样效率上相比于内核线程毫无优势可言。
而且说实话,我也实在想不出来这个玩意到底有什么用,可能仅仅在创建上有一点优势,不需要每个线程维护一个task_struct
,并且调度逻辑可以自己实现,不需要遵循内核那一套,不过这些至少现在看起来是无关紧要的。
总结
这篇文章看起来有一点乱,但是读者如果可以搞清楚两个问题,即信号的实现机制和72字节偏移就可以说不亏了,第一个问题可以参考[4]lec15,第二个问题经过上面的验证我想没有太大问题了,唯一的疑点可能就是__restore_rt
最后是否调用了sig_return
。
参考: