lab5:深入理解进程切换

Linux中的进程切换由context_switch函数完成,该函数位于源代码目录的kernel/sched/core.c 中,代码如下:

context_switch函数

/*
 * context_switch - switch to the new MM and the new thread's register state.
 */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
	       struct task_struct *next, struct rq_flags *rf)
{
	prepare_task_switch(rq, prev, next);

	/*
	 * For paravirt, this is coupled with an exit in switch_to to
	 * combine the page table reload and the switch backend into
	 * one hypercall.
	 */
	arch_start_context_switch(prev);

	/*
	 * kernel -> kernel   lazy + transfer active
	 *   user -> kernel   lazy + mmgrab() active
	 *
	 * kernel ->   user   switch + mmdrop() active
	 *   user ->   user   switch
	 */
	if (!next->mm) {                                // to kernel
		enter_lazy_tlb(prev->active_mm, next);

		next->active_mm = prev->active_mm;
		if (prev->mm)                           // from user
			mmgrab(prev->active_mm);
		else
			prev->active_mm = NULL;
	} else {                                        // to user
		membarrier_switch_mm(rq, prev->active_mm, next->mm);
		/*
		 * sys_membarrier() requires an smp_mb() between setting
		 * rq->curr / membarrier_switch_mm() and returning to userspace.
		 *
		 * The below provides this either through switch_mm(), or in
		 * case 'prev->active_mm == next->mm' through
		 * finish_task_switch()'s mmdrop().
		 */
		switch_mm_irqs_off(prev->active_mm, next->mm, next);

		if (!prev->mm) {                        // from kernel
			/* will mmdrop() in finish_task_switch(). */
			rq->prev_mm = prev->active_mm;
			prev->active_mm = NULL;
		}
	}

	rq->clock_update_flags &= ~(RQCF_ACT_SKIP|RQCF_REQ_SKIP);

	prepare_lock_switch(rq, next, rf);

	/* Here we just switch the register state and the stack. */
	switch_to(prev, next, prev);
	barrier();

	return finish_task_switch(prev);
}

context_switch是一个在 Linux 操作系统中负责进程或线程切换的核心函数。它主要用于在运行队列(rq)上切换当前执行的进程。在此函数中,会保存当前进程的状态并加载新进程的状态。这个函数在内核中非常关键,因为它确保了多个任务能够在一个处理器上共享执行时间。

对于用户进程来说,其进程描述符(task_struct)的 mm 和 active_mm 相同,都是指向其进程地址空间。
对于内核线程而言,其 task_struct 的 mm 成员为 NULL(内核线程没有进程地址空间),然而内核线程执行的时候,总是需要一个进程地址空间,而 active_mm 就是指向它借用的那个进程地址空间。

函数的参数

  • struct rq *rq - 指向运行队列(rq)的指针,该结构存储了当前处理器上的进程信息。
  • struct task_struct *prev - 指向当前执行进程的PCB。
  • struct task_struct *next - 指向即将执行进程的PCB。
  • struct rq_flags *rf - 指向运行队列标志的指针,用于保存和恢复调度器锁的状态。

函数执行的主要步骤

(1)调用prepare_task_switch(rq, prev, next)函数,该函数在内核进程切换过程中负责进行各种准备工作。这些准备工作包括设置锁定、调用架构相关的钩子函数以及处理与任务切换相关的事件。

(2)调用arch_start_context_switch(prev)函数,开始执行特定架构的上下文切换。

(3)检查即将执行的进程(next)是否具有内存管理(mm)结构。这将决定切换到内核模式还是用户模式。

        ●  如果 next->mm 为 NULL,表示切换到内核模式。执行以下操作:

  1. 调用 enter_lazy_tlb(prev->active_mm, next) 函数,进入懒惰的 TLB模式,以延迟更新内核任务的 TLB。
  2. 将 next->active_mm 设置为 prev->active_mm,即将当前进程的内存管理结构转移到新的内核进程。
  3. 如果 prev->mm 不为 NULL,表示当前进程是用户态进程,调用 mmgrab(prev->active_mm) 函数,增加prev->active_mm的引用计数。通俗讲就是,需要将这个被借用的 mm_struct 的引用计数增加一,如果该 mm_struct 对应的用户进程已经退出,则系统需要等到其引用计数为 0(即不再有内核线程借用它)才能将其销毁。
  4. 否则,表示当前进程是内核进程,就将 prev->active_mm 设置为 NULL,即把当前进程的 active_mm 成员清空,结束其对于该 mm_struct 的借用(这只是把借用它的内核线程从一个转换到了另一个,故引用计数无需增加)。

        ●  如果 next->mm 不为 NULL,表示切换到用户模式。执行以下操作:

  1. 调用 membarrier_switch_mm(rq, prev->active_mm, next->mm) 函数,使用内存屏障,来保证上一个进程访问其内存空间与下一个进程访问其内存空间之间的先后顺序(某种程度上是一种进程同步机制),避免在访存进行过程中发生 mm_struct 的切换导致的访存错误。
  2. 调用 switch_mm_irqs_off(prev->active_mm, next->mm, next) 函数,真正切换内存管理结构,x86 中定义的 switch_mm_irqs_off 函数在 /arch/x86/mm/tlb.c 下。
  3. 如果 prev->mm 为 NULL,表示是从内核模式切换过来的,则需要设置 rq->prev_mm 用于后续清除引用计数,并执行 prev->active_mm = NULL 解除对 active_mm 的借用。

我当时很不能理解为什么会有这个语句rq->prev_mm = prev->active_mm;

注意,这里的情景是:从内核进程切换到用户进程。当当前进程是内核进程时,它没有与之关联的用户空间内存管理结构,所以我们需要确保在切换到用户进程之后,正确地处理内核进程的内存管理结构。

后来发现,这个语句的意思是将当前进程(即将被切换出)的内存管理结构(prev->active_mm)赋值给运行队列(rq)的prev_mm成员。这样做的目的是将当前进程的内存管理结构(prev->active_mm)暂存,以便稍后在finish_task_switch函数中对其进行适当的处理。在finish_task_switch函数中,将对rq->prev_mm调用mmdrop() 函数,以减少其引用计数。当引用计数减少到零时,将释放与prev->active_mm关联的资源。这确保了在进程切换期间,内存管理结构得到了正确的处理。

(4)更新rq->clock_update_flags,以确保在下一次更新时钟时不会跳过必要的更新。

(5)调用prepare_lock_switch(rq, next, rf)函数,准备在新进程上锁定运行队列。

(6)调用switch_to(prev, next, prev)函数,实际执行寄存器状态和栈的切换。这是在内核中完成进程切换的关键部分,因为它会保存当前进程(prev)的寄存器状态并加载即将执行的进程(next)的寄存器状态。这个函数是与特定处理器架构相关的,通常在内核的底层实现中定义。

(7)在完成寄存器和栈的切换之后,调用 barrier() 函数以确保编译器和处理器按照预期顺序执行指令。这是一种同步机制,用于确保内存操作的正确顺序。

(8)最后,调用 finish_task_switch(prev) 函数,完成进程切换。这个函数负责释放之前进程的资源、恢复调度器锁以及处理其他与进程切换相关的清理工作。

总之,context_switch函数是 Linux 内核中负责在多个进程之间切换的关键函数。它保存当前进程的状态,加载新进程的状态,并确保内存、寄存器和栈的正确切换。这个函数是内核调度器的核心组成部分,确保处理器能够在多个进程之间共享执行时间。

switch_to(prev, next, prev)

switch_to 是一个宏定义,用于在 Linux 内核中实现进程切换的关键部分。这个宏的主要目的是在两个进程之间切换 CPU 寄存器状态和栈。它会保存当前进程(prev)的寄存器状态,加载新进程(next)的寄存器状态,并将切换前的进程(prev)的状态返回给调用者。 

#define switch_to(prev, next, last)					\
do {									\
	prepare_switch_to(next);					\
									\
	((last) = __switch_to_asm((prev), (next)));			\
} while (0)

宏的参数

(1)prev - 指向当前执行进程的指针。
(2)next - 指向即将执行进程的指针。
(3)last - 用于存储切换前进程(prev)的状态。

宏的主要步骤

(1)调用 prepare_switch_to(next) 函数,为切换到新进程做准备。这个函数是与特定处理器架构相关的,通常在内核的底层实现中定义。它可以执行一些特定于处理器架构的任务,如清除或设置特定的寄存器等。

(2)调用 __switch_to_asm(prev, next) 函数,实际执行寄存器状态和栈的切换。这个函数是用汇编语言实现的,以确保对处理器寄存器的精确控制。在这个函数中,当前进程(prev)的寄存器状态将被保存,新进程(next)的寄存器状态将被加载到处理器中。

(3)将 __switch_to_asm 的返回值(即切换前进程(prev)的状态)赋给 last。这样,调用者可以知道哪个进程在切换之前是活动的。

switch_to 宏在 context_switch 函数中使用,以确保正确切换寄存器状态和栈。它是内核进程切换过程的核心组成部分,为多任务环境提供了基础支持。

__switch_to_asm

swtich_to 函数会进一步调用 __switch_to_asm,而 __switch_to_asm 的实现是和体系结构强相关的。下面是X86体系结构的代码:

ENTRY(__switch_to_asm)
  UNWIND_HINT_FUNC
  /*
   * Save callee-saved registers
   * This must match the order in inactive_task_frame
   */
  pushq  %rbp
  pushq  %rbx
  pushq  %r12
  pushq  %r13
  pushq  %r14
  pushq  %r15

  /* switch stack */
  movq  %rsp, TASK_threadsp(%rdi) // 保存旧进程的栈顶
  movq  TASK_threadsp(%rsi), %rsp // 恢复新进程的栈顶

  /* restore callee-saved registers */
  popq  %r15
  popq  %r14
  popq  %r13
  popq  %r12
  popq  %rbx
  popq  %rbp

  jmp  __switch_to
END(__switch_to_asm)

__switch_to_asm 函数负责保存当前进程(在寄存器 %rdi 中)的寄存器状态,加载新进程(在寄存器 %rsi 中)的寄存器状态,并切换进程的栈。

具体的执行步骤

1. 用 pushq 指令保存 callee-saved 寄存器(被调用者保存的寄存器),这些寄存器在函数调用中需要被保留。这里保存的寄存器顺序与 inactive_task_frame 结构中的顺序相匹配。依次保存的寄存器有 %rbp、%rbx、%r12、%r13、%r14 和 %r15。

2. 用 movq 指令将当前进程的栈顶(%rsp)保存到PCB中的 TASK_threadsp 偏移处。这样,在切换回该进程时,可以恢复其栈顶。

3. 用 movq 指令从新进程的PCB中恢复栈顶(TASK_threadsp 偏移处的值),并将其设置到 %rsp 寄存器中。这样就被切换到了新进程的栈。

4. 用 popq 指令恢复 callee-saved 寄存器。这些寄存器在之前的步骤中被保存到栈上,现在需要从栈中弹出并恢复到相应的寄存器中。依次恢复的寄存器有 %r15、%r14、%r13、%r12、%rbx 和 %rbp。

5. 使用 jmp 指令跳转到 __switch_to 函数。这个函数将完成剩余的进程切换过程,包括处理其他与进程切换相关的清理工作。

在_switch_to_asm()的中进行了从prev内核堆栈到next内核堆栈的切换,在最后不使用ret指令,而是通过jmp指令跳转到_switch_to()函数,在_switch_to()函数的结尾调用return返回,因为在_switch_to_asm()中进行了堆栈的切换,因此_switch_to()返回后,回到的是next进程的内核堆栈,而不是prev进程的内核堆栈。

引用老师PPT中的分析:注意__switch_to_asm是在C代码中调用的,也就是使用call指令,而这段汇编的结尾是jmp __switch_to,__switch_to函数是C代码最后有个return,也就是ret指令。 将__switch_to_asm和__switch_to结合起来,正好是call指令和ret指令的配对出现。 call指令压栈RIP寄存器到进程切换前的prev进程内核堆栈;而ret指令出栈存入RIP寄存器的是进程切换之后的next进程的内核堆栈栈顶数据。

注:我们可以看到上述过程中压栈和弹栈的顺序都是与下面这个数据结构相对应的。 

/*
 * This is the structure pointed to by thread.sp for an inactive task.  The
 * order of the fields must match the code in __switch_to_asm().
 */
struct inactive_task_frame {
#ifdef CONFIG_X86_64
	unsigned long r15;
	unsigned long r14;
	unsigned long r13;
	unsigned long r12;
#else
	unsigned long flags;
	unsigned long si;
	unsigned long di;
#endif
	unsigned long bx;

	/*
	 * These two fields must be together.  They form a stack frame header,
	 * needed by get_frame_pointer().
	 */
	unsigned long bp;
	unsigned long ret_addr;
};

struct fork_frame {
	struct inactive_task_frame frame;
	struct pt_regs regs;
};

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

青衫客36

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值