0x01 介绍

在x86体系下,系统调用和中断异常都有着成熟完善的处理方式,且这种方式很久也没有过大的变动了。

不过就在最近,Intel提出了一种新的技术方案,完全革新了现有的系统调用和中断异常的处理机制。

该方案的名称是 fred,全称为 Flexible Return and Event Delivery

该方案有很多优点,比如说,它性能更好更安全,而且它还把很多操作直接集成到了cpu里。

但我觉得它最好的地方,是它直接统一了系统调用和中断异常的处理方式,这使得内核里的代码变得简洁干净。

目前,fred的相关代码,已经被合并到了linux内核中,下面我们就来看一下,使用一般方式与使用fred方式,在处理系统调用和中断异常上有什么不同。

0x02 系统调用

我们先来看下,使用一般方式与使用fred方式,在处理系统调用上有什么区别。

为了便于理解,我画了下面这张图:

在上图中,左半部分就是使用一般方式来处理系统调用的过程,右半部分就是使用fred方式来处理系统调用的过程。

从上图我们可以看到,系统调用的处理过程,在用户态是没有变化的。

都是先将要执行的系统调用编号,设置到rax寄存器里,然后再将系统调用所需的参数,设置到rdi/rsi等相关寄存器里,最后再使用syscall汇编指令,告诉cpu进入到内核态,开始执行系统调用。

注意,在各种编程语言中,我们并不需要使用上述汇编方式来执行系统调用,编程语言其实都把各种系统调用为我们封装好了,我们只需要调用对应的api就好。

但不管我们使用的是什么编程语言,使用的是什么api,其底层都是使用上述汇编方式来完成系统调用的。

我们接着上图讲。

当系统调用进入到内核态后,使用一般方式和使用fred方式,它们的处理流程就开始发生了变化。

我们先来看下使用一般方式处理系统调用的过程。

当cpu执行syscall汇编指令时,cpu直接进入到内核态,然后从MSR_LSTAR寄存器里,获取用于处理系统调用的入口函数,也就是上图中的entry_SYSCALL_64,然后跳到这个函数开始执行。

在entry_SYSCALL_64函数里,它先使用swapgs汇编指令,设置在内核态下,gs段寄存器的值。

gs段寄存器在内核里是用于获取percup变量的,percpu变量顾名思义,就是对于一个变量来说,每个cpu都有自己的一份内存,这和编程语言中的thread local变量很类似。

在设置完gs段寄存器的值之后,entry_SYSCALL_64函数就把当前使用的栈,切换为进程对应的内核栈,其实也就是修改rsp寄存器的值。

接着,再把原ss/rsp/rflags/cs/rip寄存器里的值,以及系统调用编号,压入到新的内核栈中。

这些操作都是为了保存用户进程的执行状态,有了这些值,我们就可以在后续执行完系统调用,并退出到用户态后,继续执行用户进程syscall汇编指令之后的代码了。

之后,我们又使用PUSH_AND_CLEAR_REGS宏,将所有通用寄存器的值压入到栈中。

这样做的目的,一是释放这些寄存器,使后续代码可以随意使用它们,二是因为这些寄存器的值被保存到了栈中,这样如果后续代码想要访问这些寄存器在此时的值,就可以直接从栈中获取。

那为什么我们需要访问这些寄存器在此时的值呢?

因为最开始我们就说过,系统调用的参数,是被放到这些寄存器里的,所以在后续的处理逻辑里,是需要这些值的。

为了便于后续代码访问栈内存里存放的各寄存器的值,内核定义了一个数据结构 struct pt_regs:

我们可以看到,这个结构体里的各个字段,就和此时我们压入到栈中的各寄存器值是完全对应的。

也就是说,此时,栈寄存器rsp里存放的栈顶地址,就是一个指向struct pt_regs结构体的指针。

在执行完这些压栈操作之后,entry_SYSCALL_64函数里又调用了do_syscall_64函数。

注意,调用这个函数时传入了两个参数,第一个就是struct pt_regs结构体指针,其实也就是rsp寄存器的值,另外一个就是系统调用的编号,它是rax寄存器的值。

这样,do_syscall_64函数相当于就知道了要执行什么系统调用,以及持有了这些系统调用所需的参数。

接着,do_syscall_64函数就根据这些信息,开始执行对应系统调用的真正逻辑了。

在执行完do_syscall_64函数的内部逻辑后,entry_SYSCALL_64函数又使用POP_REGS宏,将栈里保存的通用寄存器的值,弹出到对应的寄存器里。

这里要注意,如果我们在处理系统调用时,改动过栈里的这些通用寄存器的值,那此时POP_REGS宏的作用,就是把这些改动,同步到对应的寄存器里。

改动栈里寄存器值的方式,就是通过传入的struct pt_regs结构体指针,因为该指针指向的struct pt_regs结构体所占内存,就是这块栈内存。

系统调用的结果状态,是通过rax寄存器返回的,do_syscall_64函数在处理完对应的系统调用后,就把结果状态赋值到了regs->rax字段里,而这个字段里的值,就是在此时被赋值到rax寄存器里的。

接着,entry_SYSCALL_64函数根据栈里存放的剩余信息,将栈切换回用户进程使用的栈,然后又使用swapgs汇编指令,将gs段寄存器的值,切换回用户进程使用的值。

最后,使用sysretq汇编指令,告诉cpu从内核态退回到用户态,继续执行用户进程代码。

而具体执行的代码位置,就是之前保存到内核栈里的rip寄存器里的值,其实也就是syscall汇编指令的后续代码。

至此,使用一般方式执行系统调用的过程就结束了。

其实,这整个过程还是比较简单的,主要就是把各寄存器的值压入到栈中,构成一个struct pt_regs结构体,然后调用do_syscall_64函数,并传入struct pt_regs结构体指针,以及系统调用编号,让其处理对应的系统调用。

处理完毕之后,就是各种弹栈操作,最后是执行sysretq退回到用户态,这也表示系统调用执行结束。

接下来我们看一下使用fred方式处理系统调用的过程。

使用fred方式处理系统调用的过程,和使用一般方式的处理过程大部分是类似的,这些类似的部分在上图中我也用 functionally equivalent 箭头标识了出来了,所以这里就不一一讲了,我们只讲一些重点。

在上图中,标红的函数,为内核中处理系统调用的入口函数,也就是内核里的真正代码。

我们可以看到,一般方式的内核入口函数是entry_SYSCALL_64,而fred方式的内核入口函数是asm_fred_entrypoint_user。

另外,还要注意入口函数的位置,一般方式是在进入到内核态后,cpu没有做其他事情,直接就开始执行entry_SYSCALL_64函数。

而fred方式则不然,在进入到内核态后,cpu是先做了很多准备工作,然后才开始执行asm_fred_entrypoint_user函数。

这些准备工作,在一般方式处理系统调用的过程中,都是由内核代码来完成的,而在fred方式下,cpu直接帮我们做好了这一切。

另外一点要注意的是,fred方式退出内核态使用的是ERETU汇编指令,而不是sysretq汇编指令。

ERETU汇编指令更加强大些,它不仅有sysretq汇编指令的功能,还会自动切换回用户进程所使用的栈,以及所使用的gs段寄存器的值。

也就是说,这些逻辑都是在cpu里帮我们实现好了,我们就不用在内核里再实现一遍了。

通过上述内容我们可以看到,fred方式和一般方式最主要的区别,就是把很多内核要做的事,直接集成到了cpu里,这样就使得内核的相关代码变得很简洁。

0x03 中断异常

我们再来看下,使用一般方式与使用fred方式,在处理中断和异常上有什么不同。

为了便于理解,我还是画了一张图:

同样,图的左半部分表示的是一般方式的处理过程,右半部分表示的是fred方式的处理过程。

这张图中,我画了在这两种方式下,时间中断以及除0异常的处理过程,其中红色的,还是表示内核用于处理对应中断和异常的入口函数。

同样的,在用户态,一般方式和fred方式的处理过程是没有什么区别的。

当进入到内核态后,两种方式开始有了一些差异。

一般方式在切换完内核栈,并压入相应的寄存器值到栈中后,是在IDT这个数组里查找该中断或异常的处理函数的。

并且对不同的中断或异常来说,这个处理函数是不同的。

如图中,时间中断的入口函数是asm_sysvec_apic_timer_interrupt,而除0异常的入口函数是asm_exc_divide_error。

fred方式还是和之前处理系统调用一样,在cpu里就直接做了切换gs段寄存器值、切换内核栈、将寄存器值压入栈中等操作,这样我们就不用在内核里再去实现这些逻辑。

此外,和一般处理方式不同,fred方式使用的还是统一的入口函数asm_fred_entrypoint_user,而且这个入口函数,和处理系统调用时使用的入口函数也是一样的。

至于对时间中断和除0异常的分流处理,是在后续fred_entry_from_user函数里完成的,并且如果我们回头看下系统调用的处理过程,会发现系统调用也是在这个函数里分流的。

这个是fred方式和一般处理方式又一重要的区别。

在处理完中断或异常后,要返回用户态时,一般方式和fred方式又有了区别。

一般方式先是做了很多准备工作,比如swapgs,然后再使用iretq汇编指令退出到用户态,而fred方式就只使用了ERETU这个汇编指令,因为这个汇编指令就内置完成了,所有退出到用户态前,要做的准备工作。

注意在结束系统调用时,fred方式使用的也是这个汇编指令。

至此,中断和异常的处理流程就结束了。

以上我们可以看到,一般方式和fred方式处理中断和异常的主要区别,还是fred方式把很多常规操作,都集成到了cpu里。

0x04 总结

上面我们讲了系统调用以及中断异常的处理流程,并且我们也讲了一般方式和fred方式在处理上的区别。

但是我们都是站在系统调用和中断异常各自的角度上来比较的,现在我们结合两者一起看下。

对于一般的处理方式来说,系统调用和中断异常的处理流程是完全不同的,且它们使用的也是不同的入口函数。

而对于fred方式来说,系统调用和中断异常的处理流程是完全一样的,并且它们使用的是统一的入口函数asm_fred_entrypoint_user。

我觉得这是fred方式最大的优势。

另外就是,fred方式把很多常规操作,比如切换gs段寄存器等,都集成到了cpu里,这就使得我们不用在内核代码里,再去实现这些逻辑了,内核代码就变得更加简单干净。

0x05 其他

如果有对linux及linux内核感兴趣的,可以扫描右侧二维码添加我的微信。

另外,我开设了一门 linux内核启动流程源码分析 课程,有对内核源码感兴趣的,欢迎报名。