《Linux内核分析》第四课笔记

来源:互联网 发布:ubuntu更新软件源 编辑:程序博客网 时间:2024/06/03 22:45

在孟宁老师的网易云课堂《Linux内核分析》第四课上,简单讲述了系统调用的基础知识。本文对课程中没有详述的几个问题进行展开描述。

为什么需要系统调用

墙与门

作为一个现代操作系统,保证系统安全稳定是最基本的需求。一个进程的行为不应该影响另一个进程,更不能影响整个系统。这就出现了用户空间与内核空间的划分,它在不同的进程间、进程与内核间筑建了一道道保护墙。关闭了进程发出危险动作的所有可能。
而内核是经过仔细设计、被认为是更稳定可靠的。由内核来实施高危操作相对更安全。

然而用户空间仍然有访问硬件、与其他进程通信、使用系统资源等需求,为此系统在保护墙上给用户空间进入内核空间留下一道“门”,并且还要给门上锁,钥匙由内核维护。这样所有高危操作都能被内核牢牢把控住了。

墙与门

相比之下,一些嵌入式操作系统没有内核态与进程态的区别,一个进程可以轻易修改一些关键硬件寄存器等,从而导致整个系统崩溃。

如何筑造墙和门

墙和门的建造离不开硬件支持。
x86提供了运行级别和MMU等硬件机制。用户态运行在低级别上,内核态运行在高级别上;用户态每个进程的虚拟地址空间各自独立,与内核态相互隔离。这就形成了牢不可破的墙。
同时,由低级别的用户态进入高级别的内核态需要特殊的硬件操作,在x86上表现为各种“门”。穿越各种门的“钥匙”(中断配置,或简单理解为中断向量表)是启动时由内核配置好的,且这些钥匙一直由内核保管。进入用户空间后要想穿门而过,只能向内核申请。向内核申请穿门而过的方法就是系统调用,它是用户空间主动进入内核空间的唯一途径。

可见系统调用是与硬件平台紧密相关的。不同平台上系统调用表不完全相同,系统调用的实现不同。但基本原理大同小异。下面的讨论主要集中在32位的x86平台上。

系统调用的实现

既然系统调用与硬件平台紧密相关,那就有必要了解一些必要的硬件知识。在前文《第五讲 中断、异常和信号》中描述了x86硬件的中断机制和Linux的中断和异常处理。这里不再赘述。
x86的系统调用是由128号中断实现的。因此系统调用具有所有与其他中断和异常处理相同的特点,执行与其他异常处理程序相似的操作。
在初始化过程中,将128号中断对应系统调用:

============ arch/x86/kernel/traps.c 874 874 ===============set_system_trap_gate(SYSCALL_VECTOR, &system_call);

用户调用int $0x80指令,会导致CPU硬件完成一系列动作,如将当前EFLAGS寄存器的内容以及返回地址压入用户态栈,穿越一道陷阱门,提升运行级别,将代码段选择符指向内核代码段,eip指向system_call()函数地址等,最终进入内核空间。

============= arch/x86/kernel/entry_32.S 496 508 ==============ENTRY(system_call)    RING0_INT_FRAME         # can't unwind into user space anyway    pushl_cfi %eax          # save orig_eax    SAVE_ALL    GET_THREAD_INFO(%ebp)                    # system call tracing in operation / emulation    testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%ebp)    jnz syscall_trace_entry    cmpl $(nr_syscalls), %eax    jae syscall_badsyssyscall_call:    call *sys_call_table(,%eax,4)    movl %eax,PT_EAX(%esp)      # store the return value
  1. 首先把系统调用号和一些CPU寄存器保存到相应栈中
  2. 然后保存当前进程的thread_info数据结构地址, 这是通过把ebp低位掩码为零实现的。(回忆《Linux内核分析》第二课笔记 中内核栈的结构图)
  3. 检查该进程是否正被调试,如果是则跳转到一个特定入口
  4. 如果是正常调用,检查系统调用号是否超过范围
  5. 如果系统调用号非法,用syscall_badsys处理
  6. 调用对应的系统调用服务例程
  7. 返回值在eax中保存

Linux系统在不同平台上提供了不同数量的系统调用。在32位x86上,系统调用在一个sys_call_table数组中定义,目前共有341项,其中有些表项尚未实现,它们会调用sys_ni_syscall()函数,返回出错码-ENOSYS。

当系统调用服务例程结束时,把返回值通过exa寄存器传递给用户进程,然后需要检查是否需要进程调度等,如果不需要则调用iret语句返回用户态进程。

系统调用中的传参

普通中断是不能传参的,因为它没有用户空间上下文,因此不会与用户空间通信。
但我们需要通过一个参数区分不同的系统调用号,它通过eax寄存器传递。用户空间进程在进入系统调用前夕,把中断调用号保存在eax中,在system_call()函数中取出该值再跳转到对应的系统调用服务例程中。此外有些系统调用需要与用户空间通信,这就产生了参数传递与检查的需求。
普通中断也没有返回值,但系统调用需要返回表示调用成功与否的标志,这也是通过eax寄存器实现的。

传参方式

普通C语言函数调用是通过寄存器和栈来传参的,但系统调用横跨用户态和内核态,所以既不能使用用户态栈也不能使用内核态栈,这就要求系统调用只能通过寄存器传参。事实上,系统调用的参数先被写入CPU寄存器中,然后再通过SAVE_ALL宏压入内核态堆栈中。

使用寄存器传递参数有一些限制,包括参数长度不能超过32位,参数个数不能超过6个。长于32位的参数和多余参数要通过指针传递。

验证参数

在孟宁课程第三部分中,亲自实现了一个time系统调用。可见系统调用完全可以由普通程序员自行发起,而不借助于库。这也导致用户空间传递来的参数是不安全的,内核要对其仔细检查。例如检查文件句柄是否合法,是否有操作权限,检查地址范围是否合法等等。

早期Linux内核检查地址是否属于进程的地址空间(关于进程地址空间的知识见《第三讲 进程地址空间 》),但这非常费时,同时绝大多数程序都并没有错误。

从Linux2.2开始,内核只检查这个线性地址是否小于PAGE_OFFSET,而将真正的检查尽可能向后推迟,推迟到分页单元将线性地址转换为物理地址时。错误的地址将由缺页异常所处理。为了让缺页异常能正确识别这种情况,又引出一个复杂的动态地址检查机制。这里不再展开。

现在的内核使用access_ok()宏实现对用户空间的地址检查,它将地址范围与当前进程的thread_info结构体中addr_limit.seg字段标识的值做比较,从而确定地址是否合法。后者通常来讲就是PAGE_OFFSET。浏览内核代码会发现,它还通过likely宏来加速判断。

访问进程地址空间

系统调用需要频繁读写进程地址空间。内核使用get_user()put_user()copy_from_user()copy_to_user()strncpy_from_user()strlen_user()clear_user()的宏。此外内核还提供了不包含有效性检查的一组宏来实现加速,它们是在上述宏前加两个下划线前缀,如__get_user()等。这里我们不再展开描述。

系统调用的其他方法

除了int $0x80外,Pentium II以后的处理器还提供了一对sysenter/sysexit汇编语言指令。为了既支持老版芯片又支持新版芯片,既支持老版库又支持新版库,内核做了大量的兼容性工作。深入了解这些工作是一个既困难又有趣的事情,我们在这里也不便展开。

总结

深入理解系统调用,需要知其然且知其所以然。
本文只涉及了系统调用的一般性知识。特定的某些系统调用散落在内核各个子系统中。把它们串起来可以管中窥豹见识到内核各个模块是如何紧密联系到一起成为一个有机体的。这些重要的系统调用将在今后逐一分析。

0 0
原创粉丝点击