这张图画了很久,主要是让大家从全局的角度看一下系统调用在Linux内核中的实现。因为图片比较大,微信公众号上的压缩比较厉害,所以很多细节都看不清楚。我上传了一个单独的副本到github。如果想要原图,可以点击下方链接阅读原文,直接访问github。或者你也可以加我微信yt0x01,我会单独发给你。在说具体细节之前,我们先按照上图来看一下系统调用的整体实现。系统调用的实现基础其实是两条汇编指令,分别是syscall和sysret。syscall将执行逻辑从用户态切换到内核态。cpu进入内核态后,会从MSR_LSTAR寄存器中获取处理系统调用的内核代码起始地址,也就是上面的entry_SYSCALL_64。在执行entry_SYSCALL_64函数时,内核代码会先按照约定从rax寄存器中获取要执行的系统调用编号,然后根据编号从sys_call_table数组中找到对应的系统调用函数。接下来,从rdi、rsi、rdx、r10、r8、r9寄存器中获取系统调用函数需要的参数,然后调用函数,将这些参数传入。系统调用函数执行完毕后,会将执行结果放入rax寄存器中。最后执行sysret汇编指令,从内核态切换回用户态,用户程序继续执行。如果用户程序需要系统调用的返回结果,就会从rax中获取。整体流程是这样的。相对来说,还是比较简单的。主要是先了解syscall和sysret这两条汇编指令。理解了这两条汇编指令之后,再看内核源码就会容易很多。syscall和sysret指令的详细介绍请参考Intel?64andIA-32ArchitecturesSoftwareDeveloper'sManual。有了以上对系统调用的整理和了解,我们再来看看它的具体实现细节。以write系统调用为例,对应的内核源码为:在内核中,所有的系统调用函数都是由SYSCALL_DEFINE等宏定义的。例如,上面的写函数使用SYSCALL_DEFINE3。展开宏后,我们可以得到如下函数定义:由上可见,SYSCALL_DEFINE3宏展开为三个函数,其中只有__x64_sys_write是外部可访问的,另外两个是静态修改的,不能外部访问,所以上面提到的sys_call_table数组中注册的函数应该就是这个函数。那么这个函数是如何注册到这个数组中的呢?先不说答案,我们先来看sys_call_table数组的定义:从上面可以看出,数组每个元素的默认值为__x64_sys_ni_syscall:这个函数也很简单,就是error直接返回code-ENOSYS,说明系统调用不合法。定义sys_call_table数组的地方好像只设置了默认值,并没有设置真正的系统调用函数。我们再看看别的地方有没有代码会在sys_call_table数组中注册真正的系统调用函数。很不幸的是,不行。这就奇怪了,系统调用函数是在哪里注册的呢?我们回过头来看看sys_call_table数组的定义。设置默认值后,还包括了一个名为asm/syscalls_64.h的头文件,这个位置的include头文件比较奇怪,我们来看看里面都有什么。但是,此文件不存在。那么我们只能初步怀疑这个头文件是在编译时生成的。带着这个疑惑,我们搜索了相关内容,发现了一些端倪:这个文件确实是编译时生成的,上面的makefile脚本和syscall_64.tbl模板文件中使用了syscalltbl.sh来生成这个syscalls_64.h头文件。我们看一下syscall_64.tbl模板文件的内容:这里确实定义了write系统调用,它的编号被标记为1。我们再看一下生成的syscalls_64.h头文件:里面有很多像macro这样的东西其中定义的调用。__SYSCALL_COMMON,这不是sys_call_table数组定义中定义的宏吗?去上面看看__SYSCALL_COMMON的宏定义。它的作用是将sym表示的函数赋值给sys_call_table数组的nr下标。所以对于__SYSCALL_COMMON(1,sys_write)来说,就是将__x64_sys_write函数注册到sys_call_table数组下标为1的槽中。而这个__x64_sys_write函数正是我们上面猜测的,SYSCALL_DEFINE3定义的write系统调用,扩展后的外部可访问函数。这样一来,恍然大悟,真正的系统调用函数的注册是先定义__SYSCALL_COMMON宏,然后include根据syscall_64.tbl模板生成的syscalls_64.h头文件,非常巧妙。系统调用函数注册到sys_call_table数组的过程到这里就很清楚了。接下来我们继续看这个数组的用处:do_syscall_64是用到的,首先通过nr在sys_call_table数组中找到对应的系统调用函数,然后调用该函数,将regs传入。这个过程和我们上面预估的一样,传入的regs参数的类型也和我们上面注册的系统调用函数需要的类型一样。也就是说,regs参数的字段包含了各个系统调用函数所需要的参数。SYSCALL_DEFINE等宏展开的一系列函数会从这些字段中提取出真正的参数,然后对它们进行类型转换,最后将这些参数传递给最终的系统调用函数。对于上述write系统调用宏展开后的那些函数,__x64_sys_write会先从regs中提取出di、si、dx字段作为真实参数,然后__se_sys_write将这些参数转换成正确的类型,最后调用__do_sys_write函数,转换后的参数传递给它。系统调用函数执行完毕后,会将结果赋值给regs的ax字段。从上面可以看出,系统调用函数的参数和返回值的传递是通过regs来完成的。但是文章开头不是说系统调用的参数和返回值的传递是通过寄存器完成的吗?这里structpt_regs的字段是怎么通过的呢?别着急,先看看structpt_regs的定义:你有没有注意到这里的字段名都是寄存器名。那是不是意味着在执行系统调用的代码中,有逻辑将寄存器中的值放入结构体的相应域中,当系统调用结束时,将这些域中的值赋值给对应的寄存器在哪里?离真相越来越近了。我们继续看使用do_syscall_64的地方:上图中的entry_SYSCALL_64方法是系统调用过程中最重要的方法。为了方便理解,我对这个方法做了很多修改,也加了很多注释。这里需要注意的是第100行到第121行的逻辑,将每个寄存器的值压入栈,构建structpt_regs对象。这可以构建一个structpt_regs对象吗?是的。我们回过头来看看structpt_regs的定义,看看它的字段名和顺序是不是和这里的push顺序刚好相反。我们再想一想,当我们要构建一个structpt_regs对象的时候,我们需要在内存中为它分配一个空间,然后用一个地址指向这个空间,这个地址就是structpt_regs对象的指针,这里我们需要要注意的是,这个指针存放的地址是这个内存空间的最小地址。看上面的入栈流程,每次入栈操作都可以认为是分配内存空间,赋值。当r15最终被压入栈时,整个内存空间被分配,数据被初始化。此时rsp指向的栈顶地址就是这块内存空间的最小地址,因为在压栈的过程中,栈顶的地址总是在变小。综上所述,压栈后,rsp中的地址是一个structpt_regs对象的地址,也就是该对象的指针。构建完structpt_regs对象后,第123行将rax中存储的系统调用号赋值给rdx,第124行将rsp中存储的structpt_regs对象的地址即对象指针赋值给rsi,然后执行call指令调用do_syscall_64方法。在调用do_syscall_64方法之前,rdi和rsi的赋值是遵守c调用约定的,因为调用约定中约定在调用c方法时,第一个参数要放在rdi中,第二个参数要放在rdi中放在rsi中。我们去上面看看do_syscall_64方法的定义,参数类型和顺序是不是和我们这里说的一模一样。调用do_syscall_64方法后,系统调用的整个流程就差不多结束了。上图中129到133行是在做一些寄存器回收的工作,比如从栈中弹出相应的值到rax、rip、rsp等。这里需要注意的是,上面do_syscall_64方法中设置了栈中rax的值,存放的是系统调用的最终结果。另外,在栈中弹出的rip和rsp的值分别是用户态程序后续指令的地址和它的栈地址。最后执行sysret,从内核态切换到用户态,继续执行syscall后面的逻辑。至此,完整的系统调用处理流程差不多就走完了,但是这里还有一小步,就是syscall指令在进入内核态后如何找到entry_SYSCALL_64方法:它实际上是注册在MSR_LSTAR寄存器中。syscall指令进入内核态后,会直接从这个寄存器中取出系统调用处理函数的地址,开始执行。这些就是系统调用内核态的逻辑处理。下面我们用一个例子来演示用户态部分:编译和执行:我们使用syscall来执行write系统调用,写入的字符串为Hi\n。syscall执行后,我们直接使用ret命令将write的返回结果转换为程序的退出码返回。所以在上图中,输出的是Hi,程序的退出码是3。如果不理解上面的汇编,可以这样理解:这里,我们使用glibc中的write方法来执行系统调用。这个方法其实就是对syscall指令的一层封装。上面还是用上面的汇编代码。这个例子到此结束。你不觉得你过得不开心吗?我们分析了那么多代码,最后搞到这么一个小例子,不行,我们还得干点别的。我们自己写一个系统调用怎么样?去做就对了。我们先在write系统调用下定义一个自己的系统调用:方法很简单,就是参数加10,然后返回。然后在syscall_64.tbl中注册这个系统调用,编号为442:编译内核,等待执行。我们修改一下上面写的hi程序,编译一下:然后在虚拟机中启动刚刚编译好的linux内核,执行上面的程序:看结果,正好是20。搞定,收工。本文转载自微信公众号“猫食猫客”,可通过以下二维码关注。转载本文请联系猫猫猫客公众号。
