共计 4420 个字符,预计需要花费 12 分钟才能阅读完成。
今天就跟大家聊聊有关 linux 系统调用是如何实现的,可能很多人都不太了解,为了让大家更加了解,丸趣 TV 小编给大家总结了以下内容,希望大家根据这篇文章可以有所收获。
这张图画了挺久的,主要是想让大家可以从全局角度,看下 linux 内核中系统调用的实现。
在讲具体的细节之前,我们先根据上图,从整体上看一下系统调用的实现。
系统调用的实现基础,其实就是两条汇编指令,分别是 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 reg; 64 and IA-32 Architectures Software Developer rsquo;s Manual。
有了上面对系统调用的整理理解,我们接下来看下其具体的实现细节。
以 write 系统调用为例,其对应的内核源码为:
在内核中,所有的系统调用函数都是通过 SYSCALL_DEFINE 等宏定义的,比如上面的 write 函数,使用的是 SYSCALL_DEFINE3。
将该宏展开后,我们可以得到如下的函数定义:
由上可见,SYSCALL_DEFINE3 宏展开后为三个函数,其中只有__x64_sys_write 是外部可访问的,其它两个都有被 static 修饰,不能被外部访问,所以注册到上文中提到的 sys_call_table 数组里的函数,应该就是这个函数。
那该函数是怎么注册到这个数组的呢?
我们先不说答案,先来看下 sys_call_table 数组的定义:
由上可见,该数组各元素的默认值都是 __x64_sys_ni_syscall:
该函数也非常简单,就是直接返回错误码 -ENOSYS,表示系统调用非法。
sys_call_table 数组定义的地方好像只设置了默认值,并没有设置真正的系统调用函数。
我们再看看其他地方,看是否有代码会注册真正的系统调用函数到 sys_call_table 数组里。
可惜,并没有。
这就奇怪了,那各系统调用函数到底是在哪里注册的呢?
我们再回头仔细看下 sys_call_table 数组的定义,它在设置完默认值之后,后面还 include 了一个名为 asm/syscalls_64.h 的头文件,这个位置 include 头文件还是比较奇怪的,我们看下它里面是什么内容。
但是,这个文件居然不存在。
那我们只能初步怀疑这个头文件是编译时生成的,带着这个疑问,我们去搜索相关内容,确实发现了一些线索:
这个文件确实是编译时生成的,上面的 makefile 中使用了 syscalltbl.sh 脚本和 syscall_64.tbl 模板文件来生成这个 syscalls_64.h 头文件。
我们来看下 syscall_64.tbl 模板文件的内容:
这里确实定义了 write 系统调用,且标明了它的编号是 1。
我们再来看下生成的 syscalls_64.h 头文件:
这里面定义了很多好像宏调用一样的东西。
__SYSCALL_COMMON,这个不就是 sys_call_table 数组定义那里 define 的那个宏嘛。
再去上面看下__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 来完成的。
但文章开始的时候不是说,系统调用的参数及返回值的传递,是通过寄存器来完成的吗,这里怎么是通过 struct pt_regs 的字段呢?
先别急,先来看下 struct pt_regs 的定义:
你有没有发现,这里面的字段名都是寄存器的名字。
那是不是说,在执行系统调用的代码里,有逻辑把各寄存器里的值放到了这个结构体的对应字段里,在结束系统调用时,这些字段里的值又被赋值到各个对应的寄存器里呢?
离真相越来越近。
我们继续看使用了 do_syscall_64 的地方:
上图中的 entry_SYSCALL_64 方法,就是系统调用流程中最重要的一个方法了,为了便于理解,我对该方法做了很多修改,并添加了很多注释。
这里需要注意的是 100 行到 121 行这段逻辑,它将各寄存器的值压入到栈中,以此来构建 struct pt_regs 对象。
这就能构建出一个 struct pt_regs 对象了?
是的。
我们回上面看下 struct pt_regs 的定义,看其字段名字及顺序是不是和这里的压栈顺序正好相反。
我们再想下,当我们要构建一个 struct pt_regs 对象时,我们要为其在内存中分配一块空间,然后用一个地址来指向这段空间,这个地址就是该 struct pt_regs 对象的指针,这里需要注意的是,这个指针里存放的地址,是这段内存空间的最小地址。
再看上面的压栈过程,每一次压栈操作我们都可以认为是在分配内存空间并赋值,当 r15 被最终压入到栈中后,整个内存空间分配完毕,且数据也初始化完毕,此时,rsp 指向的栈顶地址,就是这段内存空间的最小地址,因为压栈过程中,栈顶的地址是一直在变小的。
综上可知,在压栈完毕后,rsp 里的地址就是一个 struct pt_regs 对象的地址,即该对象的指针。
在构建完 struct pt_regs 对象后,123 行将 rax 中存放的系统调用编号赋值到了 rdx 里,124 行将 rsp 里存放的 struct pt_regs 对象的地址,即该对象的指针,赋值到了 rsi 中,接着后面执行了 call 指令,来调用 do_syscall_64 方法。
调用 do_syscall_64 方法之前,对 rdi 和 rsi 的赋值,是为了遵守 c calling convention,因为在该 calling convention 中约定,在调用 c 方法时,第一个参数要放到 rdi 里,第二个参数要放到 rsi 里。
我们再去上面看下 do_syscall_64 方法的定义,参数类型及顺序是不是和我们这里说的是完全一样的。
在调用完 do_syscall_64 方法后,系统调用的整个流程基本上就快结束了,上图中的 129 行到 133 行做的都是一些寄存器恢复的工作,比如从栈中弹出对应的值到 rax,rip,rsp 等等。
这里需要注意的是,栈中 rax 的值是在上面 do_syscall_64 方法里设置的,其存放的是系统调用的最终结果。
另外,在栈中弹出的 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。
看完上述内容,你们对 linux 系统调用是如何实现的有进一步的了解吗?如果还想了解更多知识或者相关内容,请关注丸趣 TV 行业资讯频道,感谢大家的支持。