Stack Switching in Linux

基于 Linux 5.7-rc5 源码简单介绍了 Linux 中栈的切换。

Linux 中常见的栈

在 Linux 中,常见的栈分为 3 类:

  • 用户栈:线程在用户态使用的栈,位于用户态地址空间顶部,栈底由 Linux 宏 STACK_TOP 宏定义,但 Linux 在加载进程时,会对栈进行随机化,因此栈底的值往往不等于 STACK_TOP,用户栈是一类 per-task 的栈。用户栈在加载可执行程序时进行初始化,以 elf 文件加载为例,在函数 load_elf_binary 中函数 randomize_stack_top 会将宏 STACK_TOP 进行随机化后作为用户栈的栈底地址,然后函数 setup_arg_pages 会在栈里填充进程的参数等内容。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    /* fs/binfmt_elf.c */
    static int load_elf_binary(struct linux_binprm *bprm)
    {
    ...
    retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
    executable_stack);
    ...
    }

    List.1 用户栈的初始化

    用户栈的大小可以通过 ulimit -s 获取,一般默认为 8MB。

    1
    2
    $ ulimit -s			// ulimit是shell built-in command
    8192 // 单位为kbytes
  • 内核栈:线程在内核态使用的栈,在进行 fork 时,新线程的栈会由内核动态分配,大小为由宏 THREAD_SIZE 定义,在 x86_64 下为 4 个页面(不开启 KASAN 的情况下),即 16KB,内核栈是一类 per-task 的栈。内核栈在 fork 时进行初始化,在函数 dup_task_struct 中进行栈的分配和初始化。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    /* kernel/fork.c */
    static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
    {
    ...
    stack = alloc_thread_stack_node(tsk, node); /* 分配栈 */
    ...
    tsk->stack = stack; /* 初始化task_struct中的栈指针 */
    ...
    }
List.2 内核栈的初始化
  • 中断栈:内核在处理硬中断时使用的栈,大小为 IRQ_STACK_SIZE,在 x86_64 下为 4 个页面(不开启 KASAN 的情况下),即 16KB,中断栈也是一类 per-cpu 的栈。中断栈的初始化以及与具体架构有关。

    x86_64 中断栈的是静态分配的栈,在 CPU 启动过程中初始化相应的指针,初始化完成后就不再改变。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /* arch/x86/kernel.c */
    DEFINE_PER_CPU_PAGE_ALIGNED(struct irq_stack, irq_stack_backing_store) __visible; /* 静态分配中断栈 */
    DECLARE_INIT_PER_CPU(irq_stack_backing_store);
    ...
    int irq_init_percpu_irqstack(unsigned int cpu)
    {
    if (per_cpu(hardirq_stack_ptr, cpu))
    return 0;
    return map_irq_stack(cpu); /* 映射中断栈 */
    }
    List.3 x86_64 中断栈的初始化

    ARM64 中断栈的初始化由函数 init_irq_stacks 完成,对于没有使用 CONFIG_VMAP_STACK 的情况,栈是静态分配的,当使用了 CONFIG_VMAP_STACK 时,栈会进行动态分配。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    /* arch/arm64/kernel/irq.c */
    #ifdef CONFIG_VMAP_STACK
    static void init_irq_stacks(void)
    {
    int cpu;
    unsigned long *p;

    for_each_possible_cpu(cpu) {
    p = arch_alloc_vmap_stack(IRQ_STACK_SIZE, cpu_to_node(cpu)); /* 动态分配中断栈 */
    per_cpu(irq_stack_ptr, cpu) = p;
    }
    }
    #else
    /* irq stack only needs to be 16 byte aligned - not IRQ_STACK_SIZE aligned. */
    DEFINE_PER_CPU_ALIGNED(unsigned long [IRQ_STACK_SIZE/sizeof(long)], irq_stack); /* 静态分配中断栈 */

    static void init_irq_stacks(void)
    {
    int cpu;

    for_each_possible_cpu(cpu)
    per_cpu(irq_stack_ptr, cpu) = per_cpu(irq_stack, cpu);
    }
    #endif
List.4 ARM64 中断栈的初始化

x86_64 的栈切换

x86_64 总是使用 RSP 寄存器作为栈指针,因此,当要进行栈切换时,总是需要去更新 RSP 寄存器的值。

除了前文介绍的 3 种常用的栈以外,x86_64 Linux 还引入了 trampoline stack。Trampoline stack 是 x86_64 Linux 在从用户态切换到内核态时临时使用的栈,大小为 512 字节,是一类 per-cpu 的栈。Trampoline stack 的指针保存在 Task State Segment (TSS) 中,trampoline stack 的切换是由硬件自动完成的。

此外,对于 x86_64,Intel 还提供了 Interrupt Stack Table (IST) 硬件特性 [1]。IST 提供了 7 个新的栈,这些栈的切换方式与 trampoline stack 类似,是由硬件自动完成的。当发生中断 / 异常时,硬件通过 interrupt-descriptor table (IDT) 中的 descriptor 的一个域(即下图红框内的 3-bit)确定需要使用哪个栈,如果全都不使用,则会硬件使用默认的行为。x86_64 Linux 目前定义了 4 种 IST 栈,包括用于处理 Double fault 的 ESTACK_DF,用于处理 Non-Maskable Interrupt 的 ESTACK_NMI,用于处理 Hardware Debug Interrupt 的 ESTACK_DB 以及用于处理 Machine Check Exception 的 ESTACK_MCE[2]

Figure.1 x86_64 IDT discripter 格式

同时,如果开启了 Shadow Stack,每个 IST 中的栈都会有一个对应的 Shadow Stack。

Figure.2 Shadow Stack for IST

为了简化,不讨论 Shadow Stack 以及 IST 中的栈,故后续讨论的中断和异常不包括 Double fault,Non-Maskable Interrupt (NMI),Hardware Debug Interrupt 以及 Machine Check Exception

x86_64 在硬件上有四个特权级,ring0,ring1,ring2,ring3,但有时为了在特殊情况下进一步区分特权级,还有 ring -1 (用于虚拟化)以及 ring -2(System Management Mode[3],为 BIOS 和 UEFI 而设计)的说法。一般情况下,用户态使用 ring3,内核态使用 ring0,并且为了简化,不讨论使用虚拟化的情况。

Trampoline stack 的初始化和内核栈指针

Trampoline stack 在 CPU 初始化过程中进行初始化,将 trampoline stack 的地址加载到 TSS 中,即 tss.sp0 中。如果没有开启 XEN 的半虚拟化,则 tss.sp0 在加载后就不再变化。

1
2
3
4
5
6
7
8
9
10
11
/* arch/x86/kernel/cpu/common.c */
void cpu_init(void)
{
...
/*
* sp0 points to the entry trampoline stack regardless of what task
* is running.
*/
load_sp0((unsigned long)(cpu_entry_stack(cpu) + 1)); /* 将trampoline stack指针加载到tss.sp0中 */
...
}
List.5 trampoline stack 指针的加载

Trampoline stack 是静态分配的,且大小为 512 字节。

1
2
3
4
5
6
7
8
9
10
11
/* arch/x86/mm/cpu_entry_area.c */
static DEFINE_PER_CPU_PAGE_ALIGNED(struct entry_stack_page, entry_stack_storage); /* 静态分配trampoline stack */

/* arch/x86/include/asm/processor.h */
struct entry_stack {
unsigned long words[64]; /* 大小为64个unsigned long,在x86_64 Linux下即512字节 */
};

struct entry_stack_page {
struct entry_stack stack;
} __aligned(PAGE_SIZE);
List.6 trampoline stack 的初始化

虽然 Linux 在初始化内核栈的时候在 task_struct 中的保存了内核栈的指针,但 x86_64 Linux 在发生异常或中断时并不是直接从 task_struct 中读取内核栈指针,而是从 cpu_current_top_of_stack(实际上是 tss.sp1 的别名)这个 per-cpu 变量中获取。因此,在每次进行进程切换时,内核会在__switch_to 函数中更新 cpu_current_top_of_stack 变量。

1
2
3
4
5
6
7
8
/* arch/x86/kernel/process_64.c */
__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
...
this_cpu_write(cpu_current_top_of_stack, task_top_of_stack(next_p));
...
}
List.7 x86_64 内核栈指针的更新

syscall 的栈切换

syscall 指令是 x86_64 为了加快系统调用速度而提供的一条指令。当硬件执行 syscall 指令时,硬件将切换到 ring0,将 syscall 的下一条指令的地址加载到 RCX 寄存器中,并加载 IA32_LSTAR MSR 的值作为切换到 ring0 后的入口地址。同时,硬件会将 RFLAGS 的内容保存到 R11 寄存器里,并根据 IA32_FMASK MSR 的设置更新 RFLAGS 寄存器。此外,硬件会从 IA32_STAR MSR 中获取 SSCS 段寄存器的值,但硬件会将固定值加载到 descriptor 的 cache 中,而不是加载 GDTLDT 中对应的 descriptor[4]

sysret 是与 syscall 成对使用的指令,用于从系统调用返回,除了 SSCS 段寄存器的值是从 IA32_STAR MSRsyscall 指令不同的域获得的以外,其他处理与 syscall 指令相反。这里需要记住的是,无论是 syscall 还是 sysret 指令,都没有改变栈指针寄存器(即 RSP 的值),也不会向内存中写入任何内容 [4]

syscall 指令避免了需要通过中断的方式进行系统调用。x86_64 Linux 系统调用处理函数入口为 entry_SYSCALL_64,在函数入口处,内核会将用户栈的地址临时保存在 tss.sp2 中(第 4 行),然后从 cpu_current_top_of_stack 中获取当前线程的内核栈地址(第 6 行),并切换到内核栈,然后再从 tss.sp2 中取出用户栈的指针保存到内核栈上(第 10 行)。因此在 syscall 的处理入口处,x86_64Linux 并没有使用 trampoline stack。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* arch/x86/entry/entry_64.S */
SYM_CODE_START(entry_SYSCALL_64)
...
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* movq数据流向为左边流向右边 */
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp /* 切换到内核栈 */

/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */
...
call do_syscall_64 /* returns with IRQs disabled */ /* 处理系统调用 */
...
/* syscall 处理完毕,准备返回 */
syscall_return_via_sysret:
...
movq %rsp, %rdi
movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp /* 切换到trampoline stack */
...
SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi /* 用于兼容KPTI */

popq %rdi
popq %rsp
USERGS_SYSRET64
SYM_CODE_END(entry_SYSCALL_64)
List.8 x86_64 syscall 处理流程

当 syscall 处理完毕后(即函数 do_syscall_64 执行完毕后),x86_64Linux 会首先读取 tss.sp0 中存放的 trampoline stack 地址,并切换到 trampoline stack(第 18 行)。最后加载用户态栈指针(第 23 行)后回到用户态。因此,在 syscall 处理过程中,x86_64 Linux 进行了 3 次栈切换:用户栈 -> 内核栈 -> trampoline stack -> 用户栈

中断的栈切换

x86_64 发生中断时,硬件会进行一些列操作,并将一些寄存器的内容保存在栈上。

当中断发生在用户态时,硬件会从 TSS 中取出 ring0 的栈指针(即 tss.sp0)。而 tss.sp0 在初始化时,加载了 trampoline stack 的指针。因此,在发生中断时,硬件会自动切换到 trampoline stack,而 SS 段寄存器的值将变为 0。同时,由于硬件自动切换了栈,为了防止中断发生前的栈指针丢失,硬件会把发生中断前的段寄存器 SS、栈指针 RSP、标志寄存器 RFLAGS、段寄存器 CS 以及指令指针寄存器 EIP 依次压入 trampoline stack。如果该中断需要保存 error code,硬件还会在最后将 error code 压栈 [5]

Figure.3 中断发生在用户态时栈的布局

如果中断发生在内核态,则硬件不会切换栈,但在 x86_64 下,硬件同样会保存段寄存器 SS 的值和栈指针 RSP 的值,而不像 32 位模式下的 x86 只会保存标志寄存器 RFLAGS、段寄存器 CS 和指令指针寄存器 RIP,以及 error code(如果存在)[5]

Figure.4 中断发生在内核态时栈的布局

x86_64 Linux 在发生中断后会跳转到函数 common_interrupt 中,common_interrupt 主要包含三个部分:①中断处理的准备工作,包括保存中断上下文,进行栈切换等,由 interrupt_entry 函数完成。②中断处理,由函数 do_IRQ 完成。③中断退出,从 Label ret_from_intr 处开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* common_interrupt is a hotpath. Align it */
.p2align CONFIG_X86_L1_CACHE_SHIFT
SYM_CODE_START_LOCAL(common_interrupt)
addq $-0x80, (%rsp) /* Adjust vector to [-256, -1] range */
call interrupt_entry /* 为中断处理进行准备工作 */
UNWIND_HINT_REGS indirect=1
call do_IRQ /* rdi points to pt_regs */ /* 处理中断 */
/* 0(%rsp): old RSP */
ret_from_intr: /* 中断处理完毕 */
DISABLE_INTERRUPTS(CLBR_ANY)
TRACE_IRQS_OFF

LEAVE_IRQ_STACK /* 离开中断栈 */

...
List.9 x86_64 中断处理函数

x86_64 在用户态发生中断时,实际上在处理中断前做了 3 次栈切换,第一次栈切换由硬件进行,将当前的 RSP 切换为 TSS 中的 sp0 的值,即切换到 trampoline stack,同时硬件会把部分寄存器(SS,RSP,RFLAGS,CS 以及 RIP) 的值保存在 trampoline stack 上。第二次切换发生在函数 interrupt_entry 中,interrupt_entryx86_64 Linux 在执行中断处理函数之前需要执行的函数,为中断处理做一些准备工作。在 interrupt_entry 中,内核会把硬件保存在 trampoline stack 中的内容压入内核栈中,同时把其他未保存的通用寄存器 (GPR) 保存在内核栈中,构成中断上下文 (pt_regs) 结构体。因此,中断上下文实际上是保存在内核栈里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/* arch/x86/entry/entry_64.S */
/*
* Interrupt entry helper function.
*
* Entry runs with interrupts off. Stack layout at entry:
* +----------------------------------------------------+
* | regs->ss |
* | regs->rsp |
* | regs->eflags |
* | regs->cs |
* | regs->ip |
* +----------------------------------------------------+
* | regs->orig_ax = ~(interrupt number) |
* +----------------------------------------------------+
* | return address |
* +----------------------------------------------------+
*/
SYM_CODE_START(interrupt_entry)
...
testb $3, CS-ORIG_RAX+8(%rsp) /* 判断中断发生前是否位于内核态 */
jz 1f
...
/* 中断发生前位于用户态 */
SWITCH_TO_KERNEL_CR3 scratch_reg=%rdi
movq %rsp, %rdi
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp /* 切换到内核栈 */
...

pushq 7*8(%rdi) /* regs->ss */
pushq 6*8(%rdi) /* regs->rsp */
pushq 5*8(%rdi) /* regs->eflags */
pushq 4*8(%rdi) /* regs->cs */
pushq 3*8(%rdi) /* regs->ip */
UNWIND_HINT_IRET_REGS
pushq 2*8(%rdi) /* regs->orig_ax */
pushq 8(%rdi) /* return address */

movq (%rdi), %rdi
jmp 2f
1:
FENCE_SWAPGS_KERNEL_ENTRY /* 中断发生前位于内核态 */
2:
PUSH_AND_CLEAR_REGS save_ret=1 /* 保存其余的寄存器,构造pt_regs */
ENCODE_FRAME_POINTER 8

...
ENTER_IRQ_STACK old_rsp=%rdi save_ret=1 /* 切换到中断栈 */
/* We entered an interrupt context - irqs are off: */
TRACE_IRQS_OFF

ret /* 中断处理准备工作完成,函数返回,为特定中断执行相应的中断处理函数 */
SYM_CODE_END(interrupt_entry)
List.10 x86_64 中断处理入口

第三次切换是从内核栈切换到中断栈,中断栈的切换通过宏 ENTER_IRQ_STACK 实现。ENTER_IRQ_STACK 会将 per-cpu 变量 irq_count 的值加一,如果 irq_count 变为 0,则要切换到中断栈中(即未发生中断时,irq_count 的值为 - 1),如果 irq_count 大于 0,说明之前已经在使用中断栈了,无需在切换到中断栈。此外,ENTER_IRQ_STACK 还会将将原本的栈指针保存在中断栈上以便处理完中断后恢复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* arch/x86/entry/entry_64.S */
.macro ENTER_IRQ_STACK regs=1 old_rsp save_ret=0
DEBUG_ENTRY_ASSERT_IRQS_OFF

.if \save_ret
/*
* If save_ret is set, the original stack contains one additional
* entry -- the return address. Therefore, move the address one
* entry below %rsp to \old_rsp.
*/
leaq 8(%rsp), \old_rsp
.else
movq %rsp, \old_rsp
.endif
...
incl PER_CPU_VAR(irq_count)
jnz .Lirq_stack_push_old_rsp_\\@

movq \\old_rsp, PER_CPU_VAR(irq_stack_backing_store + IRQ_STACK_SIZE - 8)
movq PER_CPU_VAR(hardirq_stack_ptr), %rsp /* 切换到中断栈 */

...
pushq \old_rsp /* 将原本的栈指针保存在中断栈上 */
...
.endm
List.11 x86_64 中断栈的切换

中断处理完成后,Linux 会通过宏 LEAVE_IRQ_STACK 回到原本的栈上。LEAVE_IRQ_STACK 的动作比较简单,一个是将 RSP 恢复到调用 ENTER_IRQ_STACK 之前的值,另一个是将 irq_count 减一,以便 ENTER_IRQ_STACK 判断是否当前正在使用中断栈。

1
2
3
4
5
6
7
8
/* arch/x86/entry/entry_64.S */
.macro LEAVE_IRQ_STACK regs=1
DEBUG_ENTRY_ASSERT_IRQS_OFF
/* We need to be off the IRQ stack before decrementing irq_count. */
popq %rsp /* 回到原本的栈上 */
...
decl PER_CPU_VAR(irq_count) /* 中断计数-1 */
.endm
List.12 x86_64 中断栈的退出

由于中断发生前在用户态,因此最后还需要返回用户态,此时内核会再次从切换到 trampoline stack,当返回用户态时,硬件会自动从 trampoline stack 中读取用户态的栈顶指针,并设置 RSP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* arch/x86/entry/entry_64.S */
SYM_INNER_LABEL(swapgs_restore_regs_and_return_to_usermode, SYM_L_GLOBAL)
...
movq %rsp, %rdi
movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp /* 切换到trampoline stack */
UNWIND_HINT_EMPTY

/* Copy the IRET frame to the trampoline stack. */
pushq 6*8(%rdi) /* SS */
pushq 5*8(%rdi) /* RSP */
pushq 4*8(%rdi) /* EFLAGS */
pushq 3*8(%rdi) /* CS */
pushq 2*8(%rdi) /* RIP */

/* Push user RDI on the trampoline stack. */
pushq (%rdi)
...
INTERRUPT_RETURN /* 返回用户态 */
List.13 x86_64 中断退出

因此,如果中断发生在用户态,则将进行 6 次栈切换:用户栈 -> trampoline stack -> 内核栈 -> 中断栈 -> 内核栈 -> trampoline stack ->用户栈

如果中断发生在内核态,则硬件不会进行栈切换,即不会切换到 trampoline stack,因此 Linux 也无需再从 trampoline stack 切换到内核栈,故只进行 2 次栈切换(原本使用内核栈):内核栈 -> 中断栈 -> 内核栈

异常的栈切换

异常发生时,硬件的行为与中断发生时一致,不再重复介绍。

x86_64 Linux 的异常入口主要由 idtentry 宏定义,最终会跳转到 idtentry_part 宏中。idtentry_part 同样包括 3 个部分:①异常处理的准备工作,paranoid_entryerror_entry。②异常处理函数 \do_sym。③异常退出 paranoid_exiterror_exit。由于 paranoid_entryparanoid_exit 都与使用 IST 的中断 / 异常有关,因此这里只讨论 error_entryerror_exit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* arch/x86/entry/entry_64.S */
.macro idtentry_part do_sym, has_error_code:req, read_cr2:req, paranoid:req, shift_ist=-1, ist_offset=0
.if \paranoid
call paranoid_entry
/* returned flag: ebx=0: need swapgs on exit, ebx=1: don't need it */
.else
call error_entry /* 异常处理的准备工作 */
.endif
UNWIND_HINT_REGS
...
call \do_sym /* 调用异常处理函数 */
...
.if \paranoid
/* this procedure expect "no swapgs" flag in ebx */
jmp paranoid_exit
.else
jmp error_exit /* 异常退出 */
.endif
List.14 x86_64 异常处理函数

用户态引发的异常的处理主要位于 error_entry,异常发生前位于用户态时,硬件会自动切换从用户栈切换到 trampoline stack,然后再由内核从 trampoline stack 切换到对应的内核栈,sync_regs 函数将 pt_regs 拷贝到内核栈后的会返回内核栈的栈顶指针,然后内核再将该栈顶指针写入 RSP 完成 trampoline stack 到内核栈的切换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* arch/x86/entry/entry_64.S */
SYM_CODE_START_LOCAL(error_entry)
...
PUSH_AND_CLEAR_REGS save_ret=1 /* 在trampoline stack上保存pt_regs */
ENCODE_FRAME_POINTER 8
testb $3, CS+8(%rsp) /* 判断异常发生前是否位于内核态 */
jz .Lerror_kernelspace
...
/* 异常发生前位于用户态 */
.Lerror_entry_from_usermode_after_swapgs:
/* Put us onto the real thread stack. */
popq %r12 /* save return addr in %12 */
movq %rsp, %rdi /* arg0 = pt_regs pointer */
call sync_regs /* 将pt_regs保存到内核栈上,并将栈顶指针保存在rax寄存器中 */
movq %rax, %rsp /* switch stack */ /* 切换到内核栈 */
ENCODE_FRAME_POINTER
pushq %r12
ret
...
/* 异常发生前位于内核态 */
.Lerror_kernelspace:
...
List.15 x86_64 异常处理入口

用户态发生异常时,异常处理完毕的返回用户态时的栈的处理流程与中断一致,这里不再重复介绍。对于发生在用户态的异常,需要进行 4 次栈切换:用户栈 -> trampoline stack -> 内核栈 -> trampoline stack -> 用户栈。如果异常发生前位于内核态,则不进行栈切换(一直使用内核栈)。虽然 Linux 现在已经不支持中断的抢占,但异常仍然是可以被中断所抢占的,如果异常处理过程中发生了中断抢占的情况,还有可能会切换到中断栈上。

进程切换时的栈切换

在进程切换过程中,栈切换发生在__switch_to_asm 函数中,内核会把栈指针寄存器 RSP 的值放在当前进程 task_structthread 成员的 sp 成员中,同时在另一个进程的 task_struct 的同一位置取出新的栈指针,并写入到 RSP 寄存器中。

1
2
3
4
5
6
7
8
/* arch/x86/entry/entry_64.S */
SYM_FUNC_START(__switch_to_asm)
...
/* switch stack */
movq %rsp, TASK_threadsp(%rdi)
movq TASK_threadsp(%rsi), %rsp
...
SYM_FUNC_END(__switch_to_asm)
List.16 x86_64 线程切换时的栈切换

ARM64 的栈切换

相比于 x86_64ARM64 的栈切换机制要简单的多。ARM64 将特权级分为了 4 种:EL0, EL1, EL2, EL3,一般情况下,用户态为 EL0,内核态为 EL1EL2 用于管理虚拟机,运行 hypervisor,EL3 用于管理 TrustZone。ARM64 提供了两种栈,一种非特权状态下的栈 sp_el0,另一种特权状态下的栈 sp_elx(x 可以是 1,2,3,取决于硬件是否实现了 EL2EL3 特权级)。为了简化,这里只讨论硬件只实现了最低要求,即只实现了 EL1EL0[6]

在不同的特权级下,通用寄存器 sp(即栈寄存器) 会引用 sp_el0sp_el1(即 sp 相当于 sp_el0sp_el1 的别名),ARM64 允许 EL0 使用 sp_el0,而 EL1 使用 sp_el0sp_el1(由 PSTATE.SP 控制),分别记作 EL1tEL1h,由于 Linux 不使用 EL1t(可以从如下代码中看到,EL1t 的入口都是无效的)所以这里只讨论 EL1h[7]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/* 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
#ifdef CONFIG_COMPAT
kernel_ventry 0, sync_compat, 32 // Synchronous 32-bit EL0
kernel_ventry 0, irq_compat, 32 // IRQ 32-bit EL0
kernel_ventry 0, fiq_invalid_compat, 32 // FIQ 32-bit EL0
kernel_ventry 0, error_compat, 32 // Error 32-bit EL0
#else
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
#endif
SYM_CODE_END(vectors)
List.17 ARM64 异常向量表

对于 ARM64 而言,当从 EL0 发生中断或异常时,通用寄存器 sp 将自动引用 sp_el1 寄存器,即从用户栈到内核栈的切换是由硬件自动完成的,当从 EL1 退回到 EL0 时,从内核栈到用户栈的切换同样由硬件完成。

异常 / 中断的栈切换

ARM64 Linux 的异常 / 中断处理可以分为 3 个部分:①异常 / 中断处理的准备工作,由 kernel_entry 宏定义。②异常 / 中断的处理。③异常 / 中断 /syscall 的退出,由 kernel_exit 宏定义。因此,对于 ARM64 Linux,异常 / 中断的入口和出口相同。

对于用户态的异常 / 中断处理而言,仅发生 2 次栈切换,即 sp_el0(用户栈) -> sp_el1(内核栈) -> sp_el0(用户栈) 且都由硬件完成,对于内核态的异常 / 中断,则没有进行栈切换。

不同于 x86_64 由硬件自动将发生异常 / 中断时的栈顶指针保存在栈上,ARM64 在发生异常 / 中断时,Linux 会先将之前的栈顶指针保存在 x21 寄存器中,然后再存入内核栈中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* arch/arm64/kernel/entry.S */
.macro kernel_entry, el, regsize = 64
...
.if \el == 0 /* 异常/中断来自用户态 */
clear_gp_regs
mrs x21, sp_el0 /* 将用户栈指针保存在x21寄存器中 */
ldr_this_cpu tsk, __entry_task, x20
msr sp_el0, tsk /* 在内核态时,sp_el0将用于保存task_struct指针 */
...
.else /* 异常/中断来自内核态 */
add x21, sp, #S_FRAME_SIZE /* 将发生异常/中断前的内核栈顶指针值保存在x21寄存器中 */
...
.endif /* \el == 0 */
mrs x22, elr_el1
mrs x23, spsr_el1
stp lr, x21, [sp, #S_LR] /* 发生异常/中断之前的栈地址存到栈上 */
.endm
List.18 ARM64 中断 / 异常处理入口

当异常 / 中断处理完后,如果是来自用户态的异常 / 中断,则将栈顶指针从内核栈中取出放入 sp_el0 中,同时将内核栈顶指针恢复到发生异常 / 中断前的值(即内核栈变成空栈),如果是来自内核态的异常中断,则只需要恢复内核栈顶指针即可。

1
2
3
4
5
6
7
8
9
10
11
12
/* arch/arm64/kernel/entry.S */
.macro kernel_exit, el
...
.if \el == 0
ldr x23, [sp, #S_SP] // load return stack pointer
msr sp_el0, x23 /* 恢复原本的用户栈顶指针 */
...
.endif
...
add sp, sp, #S_FRAME_SIZE // restore sp /* 恢复内核栈顶指针 */
...
.endm
List.19 ARM64 中断 / 异常处理出口

ARM64 Linux 的中断处理主要由 irq_handler 宏定义,需要先切换到中断栈再进行中断的处理。

1
2
3
4
5
6
7
.macro	irq_handler
ldr_l x1, handle_arch_irq
mov x0, sp /* mov指令数据流从右边向左边 */
irq_stack_entry /* 切换到中断栈 */
blr x1 /* 处理中断 */
irq_stack_exit /* 从中断栈离开 */
.endm
List.20 ARM64 中断处理函数

中断栈的切换通过宏 irq_stack_entry 实现,Linux 会将内核栈顶指针保存在 x19 寄存器中,然后从根据当前栈的高位和内核栈的高位是否相等判断是否正在使用中断栈,如果相等,表示没有使用中断栈,因此需要切换到中断栈,不相等表示已经处于中断栈中,不需要再进行切换了。因此,不同于 x86_64 Linux 通过一个计数器判断是否在使用中断栈,ARM64 Linux 是通过当前栈的高位来判断是否在使用中断栈的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* arch/arm64/kernel/entry.S */
.macro irq_stack_entry
mov x19, sp // preserve the original sp

/*
* Compare sp with the base of the task stack.
* If the top ~(THREAD_SIZE - 1) bits match, we are on a task stack,
* and should switch to the irq stack.
*/
ldr x25, [tsk, TSK_STACK]
eor x25, x25, x19
and x25, x25, #~(THREAD_SIZE - 1) /* 计算当前栈的高位 */
cbnz x25, 9998f /* 当前栈和内核栈高位相等,则表示要切换到中断栈 */

ldr_this_cpu x25, irq_stack_ptr, x26 /* 加载中断栈指针 */
mov x26, #IRQ_STACK_SIZE
add x26, x25, x26

/* switch to the irq stack */
mov sp, x26
9998:
.endm
List.21 ARM64 中断栈的切换

中断栈的退出通过宏 irq_stack_exit 实现,即加载 x19 的内容,ARM64 Linux 在实现中断处理时,保证在调用 irq_stack_entry 到调用 irq_stack_exit 过程中 x19 的内容不会改变。

1
2
3
4
/* arch/arm64/kernel/entry.S */
.macro irq_stack_exit
mov sp, x19
.endm
List.22 ARM64 中断栈的退出

因此,对于来自用户的中断处理,需要进行 4 次栈切换:sp_el0(用户栈) -> sp_el1(内核栈) -> sp_el1(中断栈) -> sp_el1(内核栈) -> sp_el0(用户栈),对于来自内核的中断,仅进行最多 2 次栈切换:sp_el1(内核栈) -> sp_el1(中断栈) -> sp_el1(内核栈)

进程切换时的栈切换

进程切换时,当前进程的内核栈指针将被保存在 task_structthread 成员的 cpu_context 成员中,并从下个进程的 task_struct 的对应地址处加载内核栈指针,而 sp_el0 将被加载为下一个进程的 task_struct 指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* arch/arm64/kernel/entry.S */
SYM_FUNC_START(cpu_switch_to)
...
mov x9, sp
...
stp x29, x9, [x8], #16 /* 保存内核栈指针到task_struct中 */
...
ldp x29, x9, [x8], #16 /* 从task_struct中读取内核栈指针 */
ldr lr, [x8]
mov sp, x9 /* 加载新的内核栈指针 */
msr sp_el0, x1 /* 加载下一个进程的task_struct指针到sp_el0中 */
ptrauth_keys_install_kernel x1, 1, x8, x9, x10
ret
SYM_FUNC_END(cpu_switch_to)
List.23 ARM64 线程切换时的栈切换

RISCV64 的栈切换

RISCV64 有三种模式:Machine ModeSupervisor ModeUser Mode。一般情况下,Machine Mode 一般用于运行 Supervisor binary interface (SBI),Supervisor Mode 用于运行内核,User Mode 则用于运行用户程序。在 ARM64 中,EL2EL3 并不是必须的,但在 RISCV64 中,Machine Mode 则是一个必须要实现的模式,这是由于 RISCV64 发生中断时,总是会交给 Machine Mode 去判断,只有在 Machine Mode 下通过一定配置将中断委派给 Supervisor Mode 时,运行在 Supervisor Mode 下的内核才有机会处理中断。为了简化,后面假设 Machine Mode 已经将中断全部委派给内核处理,不再讨论 Machine Mode 相关的内容 [8]

异常 / 中断的栈切换

RISCV64ARM64 更加精简,既没有像 x86_64 那样有自动切换栈的机制,也没有 ARM64 那样存在多个栈寄存器的设计。RISCV64 只有一个栈寄存器 sp,一切栈切换都是通过软件方式实现的,因此更易于理解。RISCV64 并没有中断栈,只有用户栈与内核栈,初始化与 x86_64ARM64 一致。在 RISCV64 Linux 的中,中断 / 异常的入口是相同的,都以 handle_exception 为入口,由于只有用户栈和内核栈,因此在发生中断 / 异常时,相应的栈切换也非常简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* arch/riscv/kernel/entry.S */
ENTRY(handle_exception)
/*
* If coming from userspace, preserve the user thread pointer and load
* the kernel thread pointer. If we came from the kernel, the scratch
* register will contain 0, and we should continue on the current TP.
*/
csrrw tp, CSR_SCRATCH, tp
bnez tp, _save_context /* 根据CSR_SCRATCH是否为0判断中断/异常发生在内核还是用户态 */

_restore_kernel_tpsp:
csrr tp, CSR_SCRATCH
REG_S sp, TASK_TI_KERNEL_SP(tp) /* 发生在内核态则保存当前的栈指针,使来自内核和来自用户的中断/异常处理路径一致 */
_save_context:
REG_S sp, TASK_TI_USER_SP(tp) /* 保存发生异常前的栈指针到task_struct中 */
REG_L sp, TASK_TI_KERNEL_SP(tp) /* 加载内核栈指针,如果中断/异常发生在内核,则相当于加载刚刚存入内存的值 */
addi sp, sp, -(PT_SIZE_ON_STACK) /* 为中断上下文分配栈空间 */
...
REG_L s0, TASK_TI_USER_SP(tp) /* 加载发生中断/异常前的栈指针 */
...
REG_S s0, PT_SP(sp) /* 将发生中断/异常前的栈指针保存到栈上 */
...

END(handle_exception)
List.24 RISCV64 中断 / 异常入口

RISCV64 发生中断 / 异常时,首先根据系统寄存器 CSR_SCRATCH 的值判断中断 / 异常来自于内核还是用户,如果来自内核则恢复 CSR_SCRATCH 的值并将当前的栈指针保存到 task_struct 中,使得后续可以利用相同的方法去处理来自于内核和来自于用户的中断 / 异常。RISCV64 会将发生中断 / 异常前的栈指针保存在 TASK_TI_USER_SP(tp) 中(这里的 TASK_TI_USER_SP 并不一定保存的是用户栈的栈指针,也可能是内核栈的栈指针),在后续处理中,会将发生异常前的栈指针读取出来并保存到栈上,通过这种方式,使得无论中断来自于内核还是来自于用户,都可以从栈上读取发生异常前的栈指针地址。

在中断 / 异常处理结束后,RISCV64 将从栈上恢复中断 / 异常发生前的所有的寄存器的值,并从中断 / 异常返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* arch/riscv/kernel/entry.S */
ENTRY(handle_exception)
...
restore_all: /* 恢复中断前的所有寄存器 */
...
REG_L x30, PT_T5(sp)
REG_L x31, PT_T6(sp)

REG_L x2, PT_SP(sp) /* 从栈上读取发生异常/中断前的栈指针,并加载到sp寄存器里 */

#ifdef CONFIG_RISCV_M_MODE
mret
#else
sret /* 中断/异常处理结束,返回到发生中断/异常前的状态 */
#endif
...
END(handle_exception)
List.25 RISCV64 中断 / 异常出口

调度时的栈切换

RISCV64 在进程调度时的栈切换与 x86_64ARM64 类似,内核会把栈指针放在当前进程 task_structthread 成员的 sp 成员中,同时在另一个进程的 task_struct 的对应位置取出新的栈指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* arch/riscv/kernel/entry.S */
ENTRY(__switch_to)
/* Save context into prev->thread */
li a4, TASK_THREAD_RA
add a3, a0, a4
add a4, a1, a4
REG_S ra, TASK_THREAD_RA_RA(a3)
REG_S sp, TASK_THREAD_SP_RA(a3) /* 保存当前进程的内核栈指针 */
...
/* Restore context from next->thread */
REG_L ra, TASK_THREAD_RA_RA(a4)
REG_L sp, TASK_THREAD_SP_RA(a4) /* 读取下一个进程的内核栈指针 */
...
ret
ENDPROC(__switch_to)
List.26 RISCV64 线程切换时的栈切换

Discussion

为什么 x86_64 有 trampoline stack?

最初引入 x86 trampoline stack 的 patch 是 [9]。从 LKML 里可以看出来,之所以引入 trampoline stack 是为了实现 KASIER(现在叫 KPTI),KASIER 是什么以及引入 KASIER 的原因可以从 [10] 里面找到,这里不再展开。

KPTI 为每个进程分配了两个页表,第一个页表和没有引入 KPTI 之前并没有任何区别,称为原始页表,第二个页表被称为 “shadow” 页表,里面包含了用户地址空间的全部的映射以及需要支持内核运行的最小映射,“shadow” 页表里面不包含内核栈的映射。对于 x86_64 Linux, 如果开启了 KPTI,在分配顶级页表(即 pgd)的时候,内核会一次性分配 2 个页,一个作为原始页表,一个作为 “shadow” 页表,由于页表分配的时候两个页是 8KB 对齐的,因此,只要知道其中任何一个页表的地址,都可以通过翻转地址的第 12 位来获取另一个页表的地址。

开启 KPTI 后,当运行在内核态时,内核会使用原始页表,而运行在用户态时,内核会使用 “shadow” 页表。由于内核栈不再映射在用户态页表中,对于 x86_64,当发生中断或异常时,如果 tss.sp0 为内核页表,则硬件在保存 SSRSP 等寄存器时,会因为无法从”shadow” 页表中找到对应的映射而触发异常,导致内核崩溃。因此,需要引入一段在”shadow” 页表中有映射的内核内存作为一个临时的栈,才能使得硬件在保存现场时不会触发异常。x86_64 在使用 syscall 指令时,硬件并不会自动切换栈和保存现场,所以在 syscall 的处理流程中并没有切换到 trampoline stack 这一步。虽然 trampoline stack 是由 KPTI 引入的,但无论内核是否开启了 KPTI,都会使用 trampoline stack,在不开启 KPTI 的情况下,使用 trampoline stack 似乎是一种低效的做法。

不同于 x86_64 只使用 CR3 寄存器来存放页表基地址,ARM64 拥有两个页表基地址寄存器 TTBR0TTBR1,前者用于存放低一半的虚拟地址空间(即用户地址空间的映射)的页表基地址,后者用于存放高一半地址空间(即内核地址空间的映射)的页表基地址,其用户态和内核态的页表本身是分开的。在使用 KPTI 后,虽然在 EL0 运行时,KPTI 同样会将内核的映射隐藏,但由于 ARM64 在发生中断 / 异常时,硬件并不会向内存里写入内容,因此软件只需要在保存上下文时切换到原始页表后再将上下文写入内核栈即可,并不需要 trampoline stack。此外 ARM8.5 提供了禁止非特权访问由 TTBR0TTBR1 翻译的地址的硬件特性 FEAT_E0PD,或许可以取代 KPTI[11]

为什么要有中断栈?

Linux 很早就加入了中断栈,中断栈至少在 Linux 2.6 的时候就已经存在。中断是一个异步的事件,意味着中断可能在任何时刻发生。早期的 Linux 利用内核栈来同时处理中断,这导致在分配内核栈的内存时,必须考虑到最坏的情况,即内核栈的大小必须大于 $ 内核在没有发生中断的条件下所使用的最大栈空间 + 中断处理所需的最大栈空间 $,否则,内核就会在最坏情况下崩溃,影响内核的稳定性。而内核栈是每个进程都需要的资源,共用内核栈处理中断会导致浪费大量的内存,因而通过分配一个专用于处理中断栈的栈,可以有效降低内核栈所需要分配的内存空间大小。

共用内核栈处理中断只在极端情况下会导致栈溢出,而在用户态发生中断时,内核栈为空栈,由于内核栈和中断栈一样大,此时即便用内核栈处理中断也不会产生栈溢出。因此,x86_64 Linux 从 5.8 开始在发生中断时将不再无条件从内核栈切换到中断栈了,只有中断发生在内核的情况下才会切换到中断栈。

Reference

[1] kernel-stacks.rst - Documentation/x86/kernel-stacks.rst - Linux source code (v5.7-rc5) - Bootlin

[2] Intel® 64 and IA-32 Architectures Software Developer Manuals - Volume 3 - Charpter 6.14.5

[3] System Management Mode - Wikipedia

[4] Intel® 64 and IA-32 Architectures Software Developer Manuals - Volume 2 - Charpter 4.3

[5] Intel® 64 and IA-32 Architectures Software Developer Manuals - Volume 3 - Charpter 6.12.1

[6] Arm Architecture Reference Manual Armv8, for Armv8-A architecture profile - D1.1

[7] Arm Architecture Reference Manual Armv8, for Armv8-A architecture profile - D1.6.2

[8] RISC-V ISA Specification - Volume 2, Privileged Spec v. 20190608

[9] x86 entry-stack and Kaiser series

[10] KAISER: hiding the kernel from user space

[11] Arm Architecture Reference Manual Armv8, for Armv8-A architecture profile - A2.8.1