-
-
schedule()
-
发表于: 4天前 950
-
以前认真读过linux-2.4.0内核的schedule()代码,几年过去,有些迷糊了。
它主要包含三方面实现:
1. 调度策略:SCHED_FIFO、SCHED_RR、SCHED_OTHER;
2. 切换进程空间:switch_mm(),修改cr3寄存器,指向目标进程目录表;
3. 切换进程执行:switch_to()。
1和2都好理解,本文专注switch_to(),它最梦幻。
#define switch_to(prev,next,last) do { \
asm volatile("pushl %%esi\n\t" \
"pushl %%edi\n\t" \
"pushl %%ebp\n\t" \
"movl %%esp,%0\n\t" /* save ESP */ \
"movl %3,%%esp\n\t" /* restore ESP */ \
"movl $1f,%1\n\t" /* save EIP */ \
"pushl %4\n\t" /* restore EIP */ \
"jmp __switch_to\n" \
"1:\t" \
"popl %%ebp\n\t" \
"popl %%edi\n\t" \
"popl %%esi\n\t" \
:"=m" (prev->thread.esp),"=m" (prev->thread.eip), \
"=b" (last) \
:"m" (next->thread.esp),"m" (next->thread.eip), \
"a" (prev), "d" (next), \
"b" (prev)); \
} while (0)假设一段时间之前,进程A执行schedule()让出了CPU,并调度了进程B执行,接着进程B又调度进程C执行,...,当前执行schedule()的是进程P,它根据所有进程的调度策略和相应数据,比较权重,选择了进程A为调度目标。
那么switch_to()的执行过程会是这样:

需要注意的是,"movl %3, %%esp"之前的指令,只会影响进程P的内核栈和task_struct内容,对应左下方的演示,而右下方跟着动,只是为了说明,进程A当初切换到进程B时,也对自己的内核栈和task_struct,做过相应操作。
1. "movl $1f, %1"指令,"%1"对应输出部"=m" (prev->thread.eip),这里的prev,是从哪个进程(P/A)的栈里取的?
定格在这条指令刚执行之前,栈内容是这样:

此时,ESP已经切换到进程A的栈区域了,EBP仍然指向进程P的schedule()执行栈帧。
这样看来,反汇编一下内核文件(vmlinux),看看schedule()函数中的这条指令,是根据EBP还是ESP获取prev的,就能证实了。不过都不是,因为输入部中的"=m" (prev->thread.eip),会让gcc在指令部之前,添加额外的指令,将prev放入某个寄存器,并且访问prev->thread.eip的指令,都被编译成从该寄存器获取事先保存的prev值,而不是到了执行这条指令时,才根据EBP或ESP从栈里读取。
所以,进程P执行"movl $1f, %1"指令,实际上是将1:标号处的代码地址,保存到进程P自己的thread.eip。相应的,进程A当初执行switch_to()时,也是如此。
观察以下test.c程序的编译结果,可以验证上述内容(为了看得更清晰,修剪了switch_to()的部分代码):
#include <stdio.h>
struct task_struct {
struct {
unsigned long esp;
unsigned long eip;
} thread;
};
void __switch_to(struct task_struct *prev, struct task_struct *next)
{
(void)prev;
(void)next;
}
#define switch_to(prev,next,last) do { \
asm volatile("pushl %%ebp\n\t" \
"movl $1f,%0\n\t" \
"pushl %2\n\t" \
"jmp __switch_to\n" \
"1:\t" \
"popl %%ebp\n\t" \
:"=m" (prev->thread.eip), "=b" (last) \
:"m" (next->thread.eip), "a" (prev), "d" (next), "b" (prev)); \
} while (0)
int main()
{
struct task_struct t1 = { {0, 0} };
struct task_struct t2 = { {0, 0} };
struct task_struct *prev = &t1;
struct task_struct *next = &t2;
switch_to(prev, next, prev);
printf("%p\n", prev);
return 0;
}由于我用的是x86_64环境,所以加-m32编译:
gcc -m32 -S -O0 -o test.s test.c
查看test.s文件内容,分析编译结果:

其实,如果直接拿switch_to()的原始代码编译,会报错:

原因:输出和输入部提出的约束,显式和隐式占用的寄存器数量,超过了CPU可提供范围。
解决:使用-O2参数编译(gcc自己灵活安排,不用完全按照原始约束行事,但是保证效果一样),或者改进switch_to(),想办法让约束少占用些寄存器。
以下代码可以验证,随便删除一个约束("b" (last)除外),就可以用-O0编译了:
#define switch_to(prev,next,last) do { \
asm volatile("nop\n\t" \
:"=m" (prev->thread.esp),"=m" (prev->thread.eip), \
"=b" (last) \
:"m" (next->thread.esp),"m" (next->thread.eip), \
"a" (prev), "d" (next), "b" (prev)); \
} while (0)2. 传给__schedule_tail()函数的prev,是从哪个进程(P/A)的栈里取的?
asmlinkage void schedule(void)
{
...
switch_to(prev, next, prev);
__schedule_tail(prev);
...
}一方面,__schedule_tail(prev)不在内联汇编里面,会被老老实实编译成从栈上获取prev(见test.c给printf()传递prev参数,编译结果就是"pushl -52(%ebp)");
另一方面,switch_to()执行完毕时,EBP也一定切换到了进程A的内核栈:

根据以上两方面事实,传给__schedule_tail()函数的prev,就只能是从进程A的栈里取的了,代表进程A自己,但是这不符合__schedule_tail()的内部逻辑,仅通过"prev->has_cpu = 0"就可以推断,prev应该传"已经让出CPU的进程P"才对。
那真相就只有一个了:switch_to()修改了进程A内核栈中的prev。看代码也确实是这样:
输入部设置ebx=进程P的prev(test.s,65行),输出部设置了进程A的last=ebx(test.s,76~77行),而last是个宏参数,实际传的是prev(switch_to()调用语句),此时已经是进程A的prev。
3. __switch_to()参数怎么传的?
__switch_to()要求传入prev和next参数:
extern void FASTCALL(__switch_to(struct task_struct *prev, struct task_struct *next));
回头看一下switch_to()代码,它并不是"call __switch_to",而是"jmp __switch_to",并且前面执行了一条"pushl %4"指令,用于将进程A的thread.eip,压入栈中作为__switch_to()的返回地址,这样__switch_to()执行完毕,就可以返回到进程A的thread.eip代码,后续再根据进程A的内核栈、task_struct等信息,恢复它的进程上下文,从而最终达到进程切换的目的。但是,再上一条压栈指令是"pushl %%ebp",而ebp保存的显然不是next,这就说明,__switch_to()的参数,不是通过栈传递的。
答案在于FASTCALL:
#ifdef __i386__ #define FASTCALL(x) x __attribute__((regparm(3))) #else #define FASTCALL(x) x #endif
展开后发现regparm(3)修饰符,它表示前3个参数,通过EAX、EDX、ECX寄存器传递,不过__switch_to()只有2个参数,并且EAX、EDX都在switch_to()的输入部设置好了(test.s,63~64行)。
4. 既然所有进程执行switch_to()之后,都会让自己的thread.eip,指向1:标号处,为什么还要将"call __switch_to",拆分成"pushl %4"+"jmp __switch_to"两条指令?
这是考虑目标进程刚创建,首次被调度的情况,这样的进程,之前并没有调用过schedule(),也就没有执行过switch_to(),将自己的thread.eip设置为这里的1:标号处,而是设置为ret_from_fork(详见fork()函数,本文不再展开)。
[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!
赞赏
- schedule() 950
- Linux-2.4.0内核加载、启动过程 10499
- KernelSnitch(侧信道泄漏内核地址部分) 2752
- CVE-2014-0038内核漏洞exp分析 4458
- 零碎记录 943