linux进程调度(一) - 进程切换和内核抢占

目标

分析Linux进程调度,第一部分。本部分主要分析进程的切换过程。

代码分析环境:

  • arm64架构
  • linux-5.10

分为以下几部分

  • 1 总体
    • 1.1 进程的栈: 用户栈和内核栈
    • 1.2 内核抢占(Kernel Preemption)
    • 1.3 进程切换的两种方式: 主动切换和被动切换
    • 1.4 进程切换的统一入口: 中断向量表
    • 1.5 进程切换中的上下文保存和恢复
  • 2 进程切换全流程
    • 2.1 情况A: 进程在用户态执行被中断打断
      • [流程2.1.1] el0_irq中断入口
      • [流程2.1.2 ] kernel_entry保存上下文
      • [流程2.1.3 ] 中断执行完成后调用ret_to_user
    • 2.2 情况B: 进程在内核态执行时被中断打断
      • [流程2.2.1] el1_irq中断入口
      • [流程2.2.2 ] kernel_entry保存上下文
      • [流程2.2.3 ] el1_interrupt_handler中断处理和执行内核抢占
      • [流程2.2.4 ] kernel_exit 1 恢复进程内核态上下文
    • 2.3 情况C: 进程在内核执行时无中断正常返回
      • [流程2.3.1] el0_sync中断入口
      • [流程2.3.2] kernel_entry 0 保存进程上下文
      • [流程2.3.2] 执行el0_sync_handler后调用ret_to_user

1 总体

1.1 进程的栈:用户栈和内核栈

栈是一个内存空间,在调度中主要用于保存调度实体的上下文。Linux是一个通用计算系统,采用了用户态空间和内核空间隔离,因而在进程调度中涉及到比较多的栈。 有如下几个栈的分类:
  • 进程栈
    • 进程用户栈(上栈)
    • 进程内核栈(下栈)
  • 系统栈。不归属于任何进程的栈,和进程的内核栈同属于内核空间。有称为中断栈(irq stack)。
    需要区分的是每一个进程都有上下两个栈。当进程在用户空间运行时,进程栈起作用;当在内核运行时,内核栈起作用。
    用户空间栈由sp_el0寄存器指针指向,进程的内核栈和系统栈均由sp_el1寄存器指向(由操作系统控制改写指针值)。

Linux有一系列数据结构记录指针状态。这些结构在进程切换中起到了关键作用,因此没有办法略过,空谈进程切换。
下图为

绘图5_1

图1 Linux的进程栈数据结构

每个进程的用户栈指针,在进入内核态时(无论是系统调用、异常、中断),其当前栈指针被保存至进程栈的内核栈的pt_regs(ptrace registers)空间,注意该值是用户态寻地址。pt_regs是一个数据结构,位于内核栈的最高位置。pt_regs的sp字段用于在进程转入内核执行时,保存用户态指针sp_el0。
进程的用户栈的最高地址(栈顶)和最低地址(栈底),都是在进程创建时确定的(大小RLIMIT_STACK,8MB),但是注意实际物理空间并不是立即分配的。仍然是遵循内核缺页异常的延迟分配策略。

进程的内核栈栈底位置由task_struct.stack指示。task_struct.sp用于内核在切换进程间保存原进程的内核栈指针sp_el1。
进程的内核栈的栈顶和栈底也是在进程创建时确定的(大小THREAD_SIZE, 16KB)。和进程的用户栈不一样,其物理空间是一次性分配的。

1.2 内核抢占(Kernel Preemption)

抢占的直接的理解是,进程执行被中断打断后,引起强制进程切换。需注意,进程执行一定是可以被中断打断的,但是打断后执不执行切换就不一定了。所以,抢占是指,由中断引起的强制切换过程。

内核抢占,是Linux进程调度的重要概念。它是指,进程在发起系统调用后的内核执行过程中,即内核进程上下文中,可以被切换。
但并不是所有操作系统都强调这个概念,嵌入式实时系统(RTOS)中,内核抢占并不是一个问题。因为进程内核态执行随时可以被抢占。

Linux的特殊之处在于其根本上是一个服务器系统,更注重多核调度利用率。因此多核调度中必然存在不可抢占的内核执行路径。另外,在另一种策略下,Linux有意设计延迟内核抢占。这种策略是在非抢占模式下,即CONFIG_PREEMPT_NONE、CONFIG_PREEMPT_VOLUNTARY配置下。

总的来说,如果系统设计目标倾向于实时性,该系统的内核抢占特征就越强,系统越倾向于整体吞路率系统的内核抢占特征就越弱。

注意,内核不抢占,不等于进程不被调度;即使内核完全不被抢占,进程也会被调度,只是调度不及时有延迟。

1.3 进程切换的两种方式:主动切换、被动切换。

Linux中进程切换有两种类型(实际上所有操作系统都是这两种类型)

  1. 主动切换: 进程因为某种需求准备暂停自身运行,因此主动告知调度器执行切换。
  2. 被动切换: 由调度器强行干预中止进程运行,执行切换。
    绘图5_2

图2 Linux进程切换分类

无论是主动还是被动切换,切换过程都是由内核完成。而因为进程一般运行在用户态,所以为了完成切换必须转入内核。
对于主动切换。都是通过系统调用进入内核。而后可根据在内核中调用切换触发代码分为两种情况:

  1. 调用主动挂起: wait_event
  2. 调度检查点: 包括条件重调度cond_resched,可能休眠might_sleep。

对于主动挂起。典型场景是IO等待,用户进程通过系统调用发起了一次IO操作,该操作当然进入内核,因为需要等待IO,调用等待函数使本进程挂起,执行重调度函数wait_event(该函数挂起进程,即将进程设置为TASK_INTERRUPTIBLE状态,然后调用schedule执行重调度),从而完成主动切换。

调度检查点,也是一种进程主动切换的方式。该方式用于非完全抢占式内核模式,具体是指PREEMPT_NONE和PREEMPT_VOLUNTARY模式。该方式的目的是为了加强非实时调度的实时性。(该抢占模式的流程在后文详细描述)。对于PREEMPT_NONE只使用cond_resched,对于PREEMPT_VOLUNTARY只使用might_sleep。

对于进程的被动切换,可分为两种:

  1. 在即将返回用户态之前执行完成切换。此时,进程的内核执行已经完毕。
  2. 中断执行完成之后完成切换。此时,进程的内核执行并没有做完。

上述两点其实就是内核抢占的区别,第一点不抢占,第二点抢占。但是都属于一种被动切换的方式。区别在于,第一点的方式有一定延迟性,因为它是不抢占的。而第二点,是即使的,因为它是及时的。

1.4 进程切换的统一入口: 中断向量表

该过程的入口即是中断向量表。中断向量表结构如下所示

@arch/arm64/kernel/entry.S

SYM_CODE_START(vectors)
	kernel_ventry	1, sync_invalid			// Synchronous EL1t
	kernel_ventry	1, irq_invalid			// IRQ EL1t
	kernel_ventry	1, fiq_invalid			// FIQ EL1t
	kernel_ventry	1, error_invalid		// Error EL1t

	kernel_ventry	1, sync				// Synchronous EL1h
	kernel_ventry	1, irq				// IRQ EL1h
	kernel_ventry	1, fiq_invalid			// FIQ EL1h
	kernel_ventry	1, error			// Error EL1h

	kernel_ventry	0, sync				// Synchronous 64-bit EL0
	kernel_ventry	0, irq				// IRQ 64-bit EL0
	kernel_ventry	0, fiq_invalid			// FIQ 64-bit EL0
	kernel_ventry	0, error			// Error 64-bit EL0

	kernel_ventry	0, sync_invalid, 32		// Synchronous 32-bit EL0
	kernel_ventry	0, irq_invalid, 32		// IRQ 32-bit EL0
	kernel_ventry	0, fiq_invalid, 32		// FIQ 32-bit EL0
	kernel_ventry	0, error_invalid, 32		// Error 32-bit EL0
SYM_CODE_END(vectors)

kernel_ventry宏函数是第一执行宏函数,该函数基本上是完成向二级宏函数el0_sync, el0_irq,el1_sync,el1_irq的直接跳转。在完成中断处理和进程切换后返回目标进程执行。上述四个入口抠的含义分别为

  1. el0_sync: 用户态进程做系统调用
  2. el0_irq: 用户态进程被中断打断;
  3. el1_sync: 内核态执行出错;
  4. el1_irq: 内核态执行中,通常为进程的内核执行路径(系统调用)执行中被中断打断。

进程调度的前置: 进程创建过程

1.5 进程切换中进程上下文的保存和恢复

如果进程执行不被打断,是不需要保存上下文。一旦可能被打断就需要保存上下文。因为Linux下进程被打断或可能被打断,都是进入内核态,所以也可以说是一进入内核态就需要保存上下文,并在返回用户态时恢复上下文。

无论是主动切换还是被动切换。Linux进程切换是由内核完成的,如上所述,是通过中断异常的响应流程完成的。因此在发起切换的第一时间,或说是中断异常响应的第一时间,需做执行上下文保存操作,并在即将返回目标进程执行的最后时间做上下文恢复操作。
(所谓“上下文”,实际就是一系列cpu寄存器的值,全部寄存器的值构成了一个完整的执行流的运行状态。)

进程上下文保存在kernel_entry宏函数的开头,有关于上下文保存的操作如下

	.macro	kernel_entry, el, regsize = 64
	.if	\regsize == 32
	mov	w0, w0				// zero upper 32 bits of x0
	.endif
	stp	x0, x1, [sp, #16 * 0]
	stp	x2, x3, [sp, #16 * 1]
	stp	x4, x5, [sp, #16 * 2]
	stp	x6, x7, [sp, #16 * 3]
	stp	x8, x9, [sp, #16 * 4]
	stp	x10, x11, [sp, #16 * 5]
	stp	x12, x13, [sp, #16 * 6]
	stp	x14, x15, [sp, #16 * 7]
	stp	x16, x17, [sp, #16 * 8]
	stp	x18, x19, [sp, #16 * 9]
	stp	x20, x21, [sp, #16 * 10]
	stp	x22, x23, [sp, #16 * 11]
	stp	x24, x25, [sp, #16 * 12]
	stp	x26, x27, [sp, #16 * 13]
	stp	x28, x29, [sp, #16 * 14]

上述sp栈指针为被切换进程的内核态栈指针。

有关上下文的恢复操作发生在kernel_exit宏函数的末尾。局部代码如下

	.macro	kernel_exit, el
	
	// ... ...
	
	msr	elr_el1, x21			// set up the return data
	msr	spsr_el1, x22
	ldp	x0, x1, [sp, #16 * 0]
	ldp	x2, x3, [sp, #16 * 1]
	ldp	x4, x5, [sp, #16 * 2]
	ldp	x6, x7, [sp, #16 * 3]
	ldp	x8, x9, [sp, #16 * 4]
	ldp	x10, x11, [sp, #16 * 5]
	ldp	x12, x13, [sp, #16 * 6]
	ldp	x14, x15, [sp, #16 * 7]
	ldp	x16, x17, [sp, #16 * 8]
	ldp	x18, x19, [sp, #16 * 9]
	ldp	x20, x21, [sp, #16 * 10]
	ldp	x22, x23, [sp, #16 * 11]
	ldp	x24, x25, [sp, #16 * 12]
	ldp	x26, x27, [sp, #16 * 13]
	ldp	x28, x29, [sp, #16 * 14]
	
	ldr	lr, [sp, #S_LR]
	add	sp, sp, #S_FRAME_SIZE		// restore sp
	eret
	sb
	.endm

上述sp指针为切换目标线程的内核态指针。最后恢复sp指针,通过eret返回目标进程执行。

2 进程切换全流程

该抢占模式的特征,就是“不抢占”。 以下以两个用户态进程P1和P2完整说明该流程。分3种情况: 1. 进程在用户态执行时被中断打断; 1. 进程在内核态执行时被中断打断; 1. 进程在内核态执行时正常返回(没有被任何中断打断)

2.1 情况A: 进程在用户态执行被中断打断

此时在中断入口走el0_irq;

[流程2.1.1] el0_irq中断入口

el0_irq @arch/arm64/kernel/entry.S

SYM_CODE_START_LOCAL_NOALIGN(el0_irq)
	kernel_entry 0
el0_irq_naked:
	el0_interrupt_handler handle_arch_irq
	b	ret_to_user
SYM_CODE_END(el0_irq)

[流程2.1.2 ] kernel_entry保存上下文

该操作具体过程参见1.5

[流程2.1.3 ] 中断执行完成后调用ret_to_user

中断实执行handle_arch_irq过程; 之后,调用ret_to_user。
ret_to_user表示在中断执行完成后,将要重新返回用户态的操作。

ret_to_user @arch/arm64/kernel/entry.S

SYM_CODE_START_LOCAL(ret_to_user)
	disable_daif
	gic_prio_kentry_setup tmp=x3
	ldr	x19, [tsk, #TSK_TI_FLAGS]
	and	x2, x19, #_TIF_WORK_MASK
	cbnz	x2, work_pending
finish_ret_to_user:
	user_enter_irqoff
	/* Ignore asynchronous tag check faults in the uaccess routines */
	clear_mte_async_tcf
	enable_step_tsk x19, x2
	kernel_exit 0

work_pending:
	mov	x0, sp				// 'regs'
	mov	x1, x19
	bl	do_notify_resume
	ldr	x19, [tsk, #TSK_TI_FLAGS]	// re-check for single-step
	b	finish_ret_to_user
SYM_CODE_END(ret_to_user)

该函数:首先检查当前进程描述符的thread_info的flags线程标志,是否有“工作”。工作的意思是_TIF_WORK_MASK,该宏定义

#define _TIF_WORK_MASK		(_TIF_NEED_RESCHED | _TIF_SIGPENDING | \
				 _TIF_NOTIFY_RESUME | _TIF_FOREIGN_FPSTATE | \
				 _TIF_UPROBE | _TIF_MTE_ASYNC_FAULT | \
				 _TIF_NOTIFY_SIGNAL)

注意该至少包括了_TIF_NEED_RESCHED重调度标志;该标志决定了要不要调度。
在判定“需要”调度后,进入work_pending流程。该函数中心是do_notify_resume

do_notify_resume有以下流程

asmlinkage void do_notify_resume(struct pt_regs *regs,
				 unsigned long thread_flags)
{
	do {
		if (thread_flags & _TIF_NEED_RESCHED) {
			/* Unmask Debug and SError for the next task */
			local_daif_restore(DAIF_PROCCTX_NOIRQ);

			schedule();
		} else {
			local_daif_restore(DAIF_PROCCTX);

			if (thread_flags & _TIF_UPROBE)
				uprobe_notify_resume(regs);

			if (thread_flags & _TIF_MTE_ASYNC_FAULT) {
				clear_thread_flag(TIF_MTE_ASYNC_FAULT);
				send_sig_fault(SIGSEGV, SEGV_MTEAERR,
					       (void __user *)NULL, current);
			}

			if (thread_flags & (_TIF_SIGPENDING | _TIF_NOTIFY_SIGNAL))
				do_signal(regs);

			if (thread_flags & _TIF_NOTIFY_RESUME) {
				tracehook_notify_resume(regs);
				rseq_handle_notify_resume(NULL, regs);
			}

			if (thread_flags & _TIF_FOREIGN_FPSTATE)
				fpsimd_restore_current_state();
		}

		local_daif_mask();
		thread_flags = READ_ONCE(current_thread_info()->flags);
	} while (thread_flags & _TIF_WORK_MASK);
}

该函数在重调度标志(_TIF_NEED_RESCHED)被设置时,调用schedule主调度函数

schedule主调度函数如下

schedule@kernel/sched/core.c


asmlinkage __visible void __sched schedule(void)
{
	struct task_struct *tsk = current;

	sched_submit_work(tsk);
	do {
		preempt_disable();
		__schedule(false);
		sched_preempt_enable_no_resched();
	} while (need_resched());
	sched_update_worker(tsk);
}

schedule向__schedule传入false参数,表示这不是一个抢占式重调度,非抢占调度。注意,非抢占调度的准确理解是,仍然是被动切换,但是不发生在进程内核态执行时。
该场景就是用户进程用户态执行时,上下文是用户态,直接被中断打断。

__schedule 的主要框架包括

  1. 通过pick_next_task,基于调度算法获得下一目标进程;
  2. 在目标进程不是当前进程时,执行context_switch进程切换操作。

context_switch调用包括

context_switch  [kernel/sched/core.c]
	switch_to [include/asm-generic/switch_to.h]
		__switch_to [arch/arm64/kernel/process.c]
			cpu_switch_to [arch/arm64/kernel/entry.S]

cpu_switch_to @arch/arm64/kernel/entry.S

SYM_FUNC_START(cpu_switch_to)
	mov	x10, #THREAD_CPU_CONTEXT
	add	x8, x0, x10
	mov	x9, sp
	stp	x19, x20, [x8], #16		// store callee-saved registers
	stp	x21, x22, [x8], #16
	stp	x23, x24, [x8], #16
	stp	x25, x26, [x8], #16
	stp	x27, x28, [x8], #16
	stp	x29, x9, [x8], #16
	str	lr, [x8]
	add	x8, x1, x10
	ldp	x19, x20, [x8], #16		// restore callee-saved registers
	ldp	x21, x22, [x8], #16
	ldp	x23, x24, [x8], #16
	ldp	x25, x26, [x8], #16
	ldp	x27, x28, [x8], #16
	ldp	x29, x9, [x8], #16
	ldr	lr, [x8]
	mov	sp, x9
	msr	sp_el0, x1
	ptrauth_keys_install_kernel x1, x8, x9, x10
	scs_save x0, x8
	scs_load x1, x8
	ret
SYM_FUNC_END(cpu_switch_to)

cpu_switch_to从汇编底层装载目标线程的内核栈指针和PC指针。其实际保存的寄存器包括:

  1. x19-x28
  2. x29 sp帧指针和sp 进程内核栈
  3. x30 lr原进程函数返回地址
    上述被称为被调用者保存寄存器(callee-saved)。该寄存器内容存储在进程描述符的thread.cpu_context结构中。该结构如下所示
struct cpu_context {
	unsigned long x19;
	unsigned long x20;
	unsigned long x21;
	unsigned long x22;
	unsigned long x23;
	unsigned long x24;
	unsigned long x25;
	unsigned long x26;
	unsigned long x27;
	unsigned long x28;
	unsigned long fp;
	unsigned long sp;
	unsigned long pc;
};

由此可见由内核执行的上下文切换,关键在于重新目标进程的pc执行指针、内核栈指针。在完成可能的切换后(有可能实际不切), 进入finish_ret_to_user,该例程最后调用kernel_exit 0完成对目标进程上下文的恢复。

2.2 情况B: 进程在内核态执行时被中断打断

本情况是进程已经进入了内核态执行(通过系统调用)。
被中断打断后进程切换有两个时机:

  1. 立即切换;
  2. 等原进程执行完内核路径,即将返回用户态时再做切换。
    这是和情况A-用户态执行被打断不同的。情况A是没有延迟的,中断执行完毕后就即将返回用户态,相当于做及时切换。本情况是仍然在内核执行路径中,可能是等待内核执行路径完成,再做切换,相当于是延迟切换。

[流程2.2.1] el1_irq中断入口

注意,在此之前,进程已经通过el0_sync入口进入内核态执行,即进程的用户态上下文,转入内核态上下文,仍是进程上下文。此时被中断打断。

@arch/arm64/kernel/entry.S ```assembly .align 6 SYM_CODE_START_LOCAL_NOALIGN(el1_irq) kernel_entry 1 el1_interrupt_handler handle_arch_irq kernel_exit 1 SYM_CODE_END(el1_irq) ```

[流程2.2.2 ] kernel_entry保存上下文

该操作具体过程参见1.5

[流程2.2.3 ] el1_interrupt_handler中断处理和执行内核抢占

该流程是内核抢占实现的具体实现。

@arch/arm64/kernel/entry.S

	.macro el1_interrupt_handler, handler:req
	enable_da_f

	mov	x0, sp
	bl	enter_el1_irq_or_nmi

	irq_handler	\handler

#ifdef CONFIG_PREEMPTION
	ldr	x24, [tsk, #TSK_TI_PREEMPT]	// get preempt count
	cbnz	x24, 1f				// preempt count != 0 || NMI return path
	bl	arm64_preempt_schedule_irq	// irq en/disable is done inside
1:
#endif

	mov	x0, sp
	bl	exit_el1_irq_or_nmi
	.endm

首先和el0_interrupt_handler的处理一样,先执行中断服务函数handle_arch_irq。但随后因为CONFIG_PREEMPTION的控制,进入抢占处理流程。该流程只对全抢占即CONFIG_PREEMPT及CONFIG_PREEMPT_RT生效。(只有在配置了CONFIG_PREEMPT及CONFIG_PREEMPT_RT下才会选中CONFIG_PREEMPTION)。

TSK_TI_PREEMPT定义如下

  DEFINE(TSK_TI_PREEMPT,	offsetof(struct task_struct, thread_info.preempt_count));

上述先检查当前进程线程信息的可抢占标志preempt_count是否为0:表示可抢占,如果不为0,则放弃执行抢占,跳转到enter_el1_irq_or_nmi返回。
下面看可抢占情况下的流程。

arm64_preempt_schedule_irq [arch/arm64/kernel/process.c]
	preempt_schedule_irq [kernel/sched/core.c]
		__schedule []
asmlinkage __visible void __sched preempt_schedule_irq(void)
{
	enum ctx_state prev_state;

	/* Catch callers which need to be fixed */
	BUG_ON(preempt_count() || !irqs_disabled());

	prev_state = exception_enter();

	do {
		preempt_disable();
		local_irq_enable();
		__schedule(true);
		local_irq_disable();
		sched_preempt_enable_no_resched();
	} while (need_resched());

	exception_exit(prev_state);
}

该函数路径中先关抢占,开中断。调用关键函数__schedule完成调度,传入参数true表示这是一个抢占参数。
该函数是一个循环操作,继续条件是调度 标志被设置。

如调度成功,内核执行流切换到一个新的进程。

[流程2.2.4 ] kernel_exit 1 恢复目标进程内核态执行

el1_interrupt_handler可能原进程被切走,但是最终一个目标进程还是会退出中断执行路径,恢复进程执行内核态上下文。
注意该过程实际上是完成中断退出,返回到el0_sync的新目标进程的内核执行路径中,并非直接返回到用户态。

2.3 情况C: 进程在内核执行时无中断正常返回

在某些情况下,进程执行系统调用没有被中断。此时进程切换时机和情况A是同样的,在即将返回用户态时的ret_to_user。

[流程2.3.1] el0_sync中断入口

el0_sync @arch/arm64/kernel/entry.S

SYM_CODE_START_LOCAL_NOALIGN(el0_sync)
	kernel_entry 0
	mov	x0, sp
	bl	el0_sync_handler
	b	ret_to_user
SYM_CODE_END(el0_sync)

[流程2.3.2] kernel_entry 0 保存进程上下文

此步同流程2.1.2

[流程2.3.2] 执行el0_sync_handler后调用ret_to_user

通过el0_sync_handler中会执行系统调用。后进入ret_to_user,此步和流程2.1.3中一致。在ret_to_user中完成可能的进程切换。
posted @ 2026-09-29 23:07  happysc  阅读(21)  评论(0)    收藏  举报