从read开始分析系统调用的上下文切换

语言: CN / TW / HK

本文从系统调用read开始分析系统调用的上下文切换过程。

确定路径

首先,选择read系统调用的具体实现函数vfs_read,打印出其函数调用关系(这里可选的工具有很多,ftrace,bcc的stackcount等)。

(运行时打印函数调用关系的底层原理基本都是使用栈回溯原理,我们先明确一点是一个进程通常在执行用户态代码和系统调用的代码时使用的不是一个栈)这里追踪到进程切换到内核栈执行的第一个可在符号表中找到的函数点是entry_SYSCALL_64_after_hwframe。

SYM_CODE_START(entry_SYSCALL_64)
UNWIND_HINT_EMPTY


swapgs
/* tss.sp2 is scratch space. */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp


SYM_INNER_LABEL(entry_SYSCALL_64_safe_stack, SYM_L_GLOBAL)


/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
SYM_INNER_LABEL(entry_SYSCALL_64_after_hwframe, SYM_L_GLOBAL)
pushq %rax /* pt_regs->orig_ax */


PUSH_AND_CLEAR_REGS rax=$-ENOSYS


/* IRQs are off. */
movq %rax, %rdi
movq %rsp, %rsi
call do_syscall_64 /* returns with IRQs disabled */

查阅内核代码,发现e ntry_ S YSCALL_64_after_hwframe 是从entry _ SY SCALL _64开始执行的 代码中的标号。 因为 entry _S YSCALL_64 entry _ SYSCALL_64 _after_hwf rame的过程中并没有类似于call这样的调用指令,这么说 系统 调用的入口是从netry_SYSCALL_64开始的。

继续用qemu打个断点看看,毕竟可以看到entry_SYSCALL_64_safe_stack和entry_SYSCALL_64_after_hwframe都是SYM_L_GLOBAL,是全局的符号。并结合内核代码搜索路径发现,这两个全局符号只是在其他路径中用作地址判断,并无跳转式调用。

此时可以确定系统调用的代码的确是从entry_SYSCALL_64开始执行的。

接下来分析代码。

swapgs指令

swapgs

首先执行的是swapgs指令,该指令的描述是:

When FS and GS segment overrides are used in 64-bit mode, their respective base addresses are used in the linear address calculation: (FS or GS).base + index + displacement. FS.base and GS.base are then expanded to the full linear-address size supported by the implementation. The resulting effective address calculation can wrap across

positive and negative addresses; the resulting linear address must be canonical.

1. The SWAPGS instruction is available only in 64-bit mode. It swaps the contents of two specific MSRs (IA32_GS_BASE and IA32_KERNEL_GS_BASE).

2. The IA32_GS_BASE MSR shadows the base address portion of the GS descriptor register; the IA32_KERNEL_GS_BASE MSR holds the base address of the GS segment used by the kernel (typically it houses kernel structures).

3. SWAPGS is intended for use with fast system calls when in 64-bit mode to allow immediate access to kernel structures on transition to kernel mode.

通常来说,在 x86- 6 4 分段单元在绝大多数情况下都被绕过了,但少数情况下不会绕过,这里就包括fs和gs段寄存器,但现在获得它们的基地址的方式是通过IA32_FS_BASE和IA32_GS_BASE这两个MSR寄存器。

简单来说这里swapgs的功能,swapgs交换IA32_GS_BASE和IA32_KERNEL_GS MSR的值,因为IA32_GS_KERNEL_GS MSR可以被内核用于保存数据。也就是说,执行完swapgs指令后就可以通过gs寄存器获取一些内核的数据了。

qemu调试在swapgs后打印gs相关寄存器的值。

明显可以看到swapgs让 I A32_GS_ KERNEL_GS MSR和 I A32_GS_ KERNEL_GS MSR的值进行了交换。

save sp

movq  %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)

可以看到一个tss字段。

tss(task status segment),从名字中的segment,不难将其理解为一个段。intel是希望为每一个进程准备一个独立的tss段,进程切换时依靠tss段进行寄存器的保存与恢复。当然X86下的特权级有ring0~ring3,进程在特权级切换时也需要保存与恢复寄存器。

当然,Linux现在并没有安全按照这种思想去做,从代码可以看出,所谓的tss并没有设计成per-task的,而是per-cpu的。

#ifdef CONFIG_X86_64
#define __percpu_seg gs
#else
#define __percpu_seg fs
#endif


#ifdef __ASSEMBLY__


#ifdef CONFIG_SMP
#define PER_CPU_VAR(var) %__percpu_seg:var
#else /* ! SMP */
#define PER_CPU_VAR(var) var
#endif /* SMP */

可以从这里看到,原来gs寄存器指向__percpu段基地址。

我们是将sp寄存器的值(也就是用户态栈顶指针的值)保存在当前cpu变量的cpu_tss_rw的sp2的成员中。

OFFSET(TSS_sp2, tss_struct, x86_tss.sp2);

为什么是sp2?注释中给出的解释是:因为Linux没有使用ring2,因此sp2被用来在此暂存用户的栈顶指针的值。

  /*
* Since Linux does not use ring 2, the 'sp2' slot is unused by
* hardware. entry_SYSCALL_64 uses it as scratch space to stash
* the user RSP value.
*/
u64 sp2;

change CR3

SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp

看一下SWITCH_TO_KERNEL_CR3的代码:

.macro SWITCH_TO_KERNEL_CR3 scratch_reg:req
ALTERNATIVE "jmp .Lend_\@", "", X86_FEATURE_PTI
mov %cr3, \scratch_reg
ADJUST_KERNEL_CR3 \scratch_reg
mov \scratch_reg, %cr3
.Lend_\@:
.endm

这里的逻辑大概就是将cr3的值取出放入scratch_reg这个寄存器中,然后再通过ADJUST_KERNEL_CR3对寄存器的值进行一个修正,再写回到cr3寄存器中。这里很有意思,直接拿已经“没用”了的rsp寄存器来充当这个暂存的寄存器。

看一下ADJUST_KERNEL_CR3的代码:

.macro ADJUST_KERNEL_CR3 reg:req
ALTERNATIVE "", "SET_NOFLUSH_BIT \reg", X86_FEATURE_PCID
/* Clear PCID and "PAGE_TABLE_ISOLATION bit", point CR3 at kernel pagetables: */
andq $(~PTI_USER_PGTABLE_AND_PCID_MASK), \reg
.endm

其中注释的意思是清除PCID还有PAGE_TABLE_ISOLATION位,让cr3指向内核页表。

#ifdef CONFIG_PAGE_TABLE_ISOLATION
# define X86_CR3_PTI_PCID_USER_BIT 11
#endif


/*
* PAGE_TABLE_ISOLATION PGDs are 8k. Flip bit 12 to switch between the two
* halves:
*/
#define PTI_USER_PGTABLE_BIT PAGE_SHIFT
#define PTI_USER_PGTABLE_MASK (1 << PTI_USER_PGTABLE_BIT)
#define PTI_USER_PCID_BIT X86_CR3_PTI_PCID_USER_BIT
#define PTI_USER_PCID_MASK (1 << PTI_USER_PCID_BIT)
#define PTI_USER_PGTABLE_AND_PCID_MASK (PTI_USER_PCID_MASK | PTI_USER_PGTABLE_MASK)

这里可以这么理解,在PAGE_TABLE_ISOLATION的情况下PGD共占8k,且8k对齐,也就是2页。第一页就是内核态PGD的内容,第二页就是用户态PGD的内容。因为占8k且8k对齐,因此清除掉第13位的值就完成了PGD从用户态到内核态的切换。(至于PCID的清除,这里也是只清除了第12位的值,我的理解是用户进程使用PCID进行区分,只用到低11位,而低12一直为1;第12位为0则代表该地址是内核态页表)。

此时,CR3寄存器切换完成。

change sp

movq  PER_CPU_VAR(cpu_current_top_of_stack), %rsp

此时,依旧是依靠per_cpu变量读取当前进程的内核态栈顶指针存入sp寄存器,本质也是依靠gs寄存器了。

此时也是依靠gs寄存器,我们完成了sp寄存器的修改,也就实现了从用户栈到内核栈的切换。

可以看到,栈顶指针的确在此时发生了变化。

每个进程都有自 己单独的 内核栈 ,也就是说 进程切换的时候 需要 更改这个per_cpu变量 ,为此我们查询了__switch_to函数发现了这行代码:

this_cpu_write(cpu_current_top_of_stack, task_top_of_stack(next_p));

stack push

  pushq  $__USER_DS        /* pt_regs->ss */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
SYM_INNER_LABEL(entry_SYSCALL_64_after_hwframe, SYM_L_GLOBAL)
pushq %rax /* pt_regs->orig_ax */

PUSH_AND_CLEAR_REGS rax=$-ENOSYS

可以看到这段代码就是不断压栈。

不难看出,前面几次的pushq每次都是放入了8字节,而最后的PUASH_AND_CLEAR_REGS则是直接放入了fd0-f58=78字节。

.macro PUSH_AND_CLEAR_REGS rdx=%rdx rax=%rax save_ret=0
PUSH_REGS rdx=\rdx, rax=\rax, save_ret=\save_ret
CLEAR_REGS
.endm

从PUASH_AND_CLEAR_REGS的代码 也是不难 看到PUSH_REGS, 明显这里是一次push了许多寄存器的内容。

.macro PUSH_REGS rdx=%rdx rax=%rax save_ret=0
.if \save_ret
pushq %rsi /* pt_regs->si */
movq 8(%rsp), %rsi /* temporarily store the return address in %rsi */
movq %rdi, 8(%rsp) /* pt_regs->di (overwriting original return address) */
.else
pushq %rdi /* pt_regs->di */
pushq %rsi /* pt_regs->si */
.endif
pushq \rdx /* pt_regs->dx */
pushq %rcx /* pt_regs->cx */
pushq \rax /* pt_regs->ax */
pushq %r8 /* pt_regs->r8 */
pushq %r9 /* pt_regs->r9 */
pushq %r10 /* pt_regs->r10 */
pushq %r11 /* pt_regs->r11 */
pushq %rbx /* pt_regs->rbx */
pushq %rbp /* pt_regs->rbp */
pushq %r12 /* pt_regs->r12 */
pushq %r13 /* pt_regs->r13 */
pushq %r14 /* pt_regs->r14 */
pushq %r15 /* pt_regs->r15 */
UNWIND_HINT_REGS


.if \save_ret
pushq %rsi /* return address on top of stack */
.endif
.endm

这里可能还会有点疑惑,为什么ss段和cs段保存时是直接用的__USER_DS和__USER_CS呢?

我们先看一下它们的定义:

#define __KERNEL_CS      (GDT_ENTRY_KERNEL_CS*8) // 16
#define __KERNEL_DS (GDT_ENTRY_KERNEL_DS*8) // 24


#define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS*8 + 3) // 43
#define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS*8 + 3) // 51

我们在断点entry_SYSCALL_64处打印寄存器信息,可以看到:

此时cs段和ss段已经转变为__KERNEL_CS和__KERNEL_DS了,也就是说这两个段寄存器的变化是在entry_SYSCALL_64之前就已经变了。所以只能用固定值进行填充。而这也有一个问题,是不是所有进程的用户态和内核态的cs和ss的值就是在这个几个值中来回变化呢?

为了验证,动手一个hello,然后gdb看一下它的cs寄存器和ss寄存器的值。

猜想符合预期。再来分析下为什么是这几个固定的值呢?

由于现在通常不用段来作地址转换(除了前文中提到的fs,gs等比较特殊的情况),但依然要用于判断特权级,也就是决定内核的代码和数据让不让你访问。

我们分析定义可以,*8其实可以视为是左移3位,再+3等同于低3位为011。其中第3位为0表示是GDT数据,再高的位就是GDT的index,而最后2位就是特权级RPL了。

也就是说,当系统调用或者中断发生时,我们的CS段和SS段的RPL可以通过门的检测,检测通过后cs寄存器和ss寄存器的内容变为__KERNEL_CS和__KERNEL_DS。

给系统调用的参数

movq  %rax, %rdi
movq %rsp, %rsi
call do_syscall_64

调用函数的时候,第1个参数在rdi寄存器,第2个参数在rsi寄存器。

#ifdef CONFIG_X86_64
__visible noinstr void do_syscall_64(unsigned long nr, struct pt_regs *regs)

也就是说,此时分别将原本rax保存的系统调用号和保存的用户态上下文信息传递给了do_syscall_64了,至此就进入了系统调用的处理过程了。

ebpf脚本

选择的挂载点分别是glibc的read函数和内核中read系统调用的入口函数__x64_sys_read。

from __future__ import print_function
from bcc import BPF
from bcc.utils import printb
from time import sleep


b = BPF(text = """
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#include <linux/mm_types.h>


struct mini_regs {
unsigned long ip;
unsigned long cs;
unsigned long flags;
unsigned long sp;
unsigned long ss;
};


struct user_vmas {
unsigned long start_code;
unsigned long end_code;
unsigned long start_data;
unsigned long end_data;
unsigned long start_brk;
unsigned long brk;
unsigned long start_stack;
};


struct stack_regs_info {
struct mini_regs user_regs;
struct mini_regs user_save_regs;
struct mini_regs kern_regs;
struct user_vmas user_vmas;
int finished;
};


BPF_HASH(user_to_kern, u32, struct stack_regs_info);


static void restore_regs(struct mini_regs *mr, struct pt_regs *regs)
{
bpf_probe_read_kernel(&(mr->ip), sizeof(unsigned long), &(regs->ip));
bpf_probe_read_kernel(&(mr->cs), sizeof(unsigned long), &(regs->cs));
bpf_probe_read_kernel(&(mr->flags), sizeof(unsigned long), &(regs->flags));
bpf_probe_read_kernel(&(mr->sp), sizeof(unsigned long), &(regs->sp));
bpf_probe_read_kernel(&(mr->ss), sizeof(unsigned long), &(regs->ss));
}


static void restore_vmas(struct user_vmas *uv, struct mm_struct *mm)
{
bpf_probe_read_kernel(&(uv->start_code), sizeof(unsigned long), &(mm->start_code));
bpf_probe_read_kernel(&(uv->end_code), sizeof(unsigned long), &(mm->end_code));
bpf_probe_read_kernel(&(uv->start_data), sizeof(unsigned long), &(mm->start_data));
bpf_probe_read_kernel(&(uv->end_data), sizeof(unsigned long), &(mm->end_data));
bpf_probe_read_kernel(&(uv->start_brk), sizeof(unsigned long), &(mm->start_brk));
bpf_probe_read_kernel(&(uv->brk), sizeof(unsigned long), &(mm->brk));
bpf_probe_read_kernel(&(uv->start_stack), sizeof(unsigned long), &(mm->start_stack));
}


int get_user_regs(struct pt_regs *ctx) {
u32 pid = bpf_get_current_pid_tgid();
struct stack_regs_info *psri, sri = {};


psri = user_to_kern.lookup(&pid);
if (!psri) {
restore_regs(&(sri.user_regs), ctx);
sri.finished = 0;


user_to_kern.update(&pid, &sri);
}


return 0;
}


int get_kern_regs(struct pt_regs *ctx) {
u32 pid = bpf_get_current_pid_tgid();
struct pt_regs *regs = (struct pt_regs *)PT_REGS_PARM1(ctx);
struct task_struct *curr = (struct task_struct *)bpf_get_current_task();


struct stack_regs_info *psri;


psri = user_to_kern.lookup(&pid);
if (psri && !psri->finished) {
restore_regs(&(psri->user_save_regs), regs);
restore_regs(&(psri->kern_regs), ctx);
restore_vmas(&(psri->user_vmas), curr->mm);
psri->finished = 1;


}


return 0;
}


""")


b.attach_uprobe(name="c", sym="read", fn_name="get_user_regs")
b.attach_kprobe(event="__x64_sys_read", fn_name="get_kern_regs")


while 1:
sleep(1)


user_to_kern = b.get_table("user_to_kern")


for k, v in user_to_kern.items():
uregs = v.user_regs
sregs = v.user_save_regs
kregs = v.kern_regs
uvmas = v.user_vmas


print("###\npid: ", k.value)
print("ip cs flags sp ss")
print("user_runn: ", uregs.ip, uregs.cs, uregs.flags, uregs.sp, uregs.ss)
print("user_save: ", sregs.ip, sregs.cs, sregs.flags, sregs.sp, sregs.ss)
print("kern_runn: ", kregs.ip, kregs.cs, kregs.flags, kregs.sp, kregs.ss)
print("start_code end_code start_data end_data start_brk brk start_stack")
print("user_vmas: ", uvmas.start_code, uvmas.end_code, uvmas.start_data, uvmas.end_data, uvmas.start_brk, uvmas.brk, uvmas.start_stack)
user_to_kern.clear()

运行结果:

结果分析:

  • user_runn与user_save分别是glibc函数触发时的用户态环境以及进入内核态后内核堆栈中保存的用户态环境(用作现场恢复);kern_runn是进入内核态时的内核态环境;user_vmas是task_struct中mm中的各个关键地址;

  • ip寄存器(user_runn略大于user_save),说明了一个顺序执行的关系,内核保存用户环境发生在glibc调用逻辑之后;而在kern_run中的ip明显远大于user相关的,因为这里已经是内核地址空间了;

  • user_save与user_runn的sp寄存器相比,没有变化(pid:3312/2555),或者略有减少(pid:2753),说明用户态栈在glibc到进入内核态可能会压栈一些内容。(栈的起始地址是高地址);而用户态的sp寄存器与user_vmas的stack(用户态stack的起始地址)相比也是比较接近,但是皆小于,说明了栈的使用关系。而user相关的stack也好,sp也好,明显区别于kern的sp,kern也是换了一个新的堆栈关系;

  • cs和ss的内容符合上面的分析;

  • 问题:为什么user的ip的值不在vmas的start_code与end_code之间。在brk(堆)和stack之间,有一个mmap的区域,mmap可以映射文件到进程的地址空间,映射的区域可以有读、写、执行权限,可以用于执行代码;

总结

  1. swapgs指令用于让gs寄存器可用,gs寄存器用于寻址per_cpu变量,从而服务于栈切换;

  2. cr3寄存器的切换表现了内核PGD和用户PGD的关系,8k连续且8k对齐;

  3. 内核栈中首先需要保存用户态的寄存器情况(也就要上下文),用于最后恢复;

  4. cs寄存器和ss寄存器的修改应该在entry_SYSCALL_64之前,应该是在门检测时进行修改了;且进程的cs和ss都是那几个固定的值。