Linux-浅谈系统调用

        我们开始研究操作系统中一个非常重要的概念——系统调用。大多数程序员在写程序时都很难离开系统调用,与系统调用打交道的方式是通过库函数的方式,库函数用来把系统调用给封装起来,要理解系统调用的概念还需要一些储备知识。

用户态与内核态

        计算机的硬件资源是有限的,为了减少有限资源的访问和使用冲突,CPU 和操作系统必须提供一些机制对用户程序进行权限划分。现代的 CPU 一般都有几种不同的指令执行权限级别,在高的执行权限级别下,代码可以执行特权指令,比如访问任意内存,这时 CPU 的执行级别对应的就是内核态,所有的指令包括特权指令都可以执行。相应的,在用户态,代码(低权限级别指令)能够掌控的范围会受到限制,比如只能访问特定范围的内存。

        为什么会出现这种情况呢?其实很容易理解,如果没有权限级别的划分,系统中不同程序员编写的应用程序都可以使用特权指令,由于不同的应用程序质量参差不齐,质量差的程序就很容易导致整个系统崩溃的。另外有些应用程序会非法访问其他进程甚至内核的资源,就会产生系统安全上的问题。

        系统调用是操作系统发展的过程中为了保证系统稳定性和安全性的一种重要机制。系统调用的出现让普通程序员写的用户态的代码很难导致整个系统的崩溃,而操作系统内核的代码是由更专业的程序员写的,有规范的测试,相对就会更稳定、更健壮。

X86 CPU  4 种指令执行权限

        以Intel X86 CPU为例,如下图所示,Intel X86 CPU 有 4 种不同的指令执行权限级别,分别是 0、1、2、3,数值越小,权限越高。按照 Intel 的设想,操作系统内核为 Ring0 级别,驱动程序运行在 Ring1 和 Ring2 级别,应用程序运行在 Ring3 级别,实际上主流的操作系统,如 Linux、Windows等,都没有采用图中的 4 级执行权限划分。Linux 操作系统在X86平台下只采用了其中的 0 和 3 两个级别,分别对应内核态和用户态。

ARM64 CPU异常级别

        ARM64架构的CPU中指令执行权限级别被称为异常的级别(Exception Level,EL),也是分为4级,普通的用户程序处于EL0级,级别最低,Supervisor处于EL1级,Hypervisor处于EL2级,Secure monitor处于EL3级。Linux 操作系统在ARM64环境中只用了其中的EL0级作为用户态,EL1级作为内核态。

32 位 Linux 进程地址空间

        在Linux系统中用户态和内核态很显著的区分方法就是指令指针寄存器的指向范围,在内核态下,指令指针寄存器的值可以是任意的地址,而用户态下指令指针寄存器的值只能访问受限的地址范围。

        在 32 位Linux 系统上每个进程有 4GB 的进程地址空间,如图4-4所示。内核态下的这 4GB 的地址空间全都可以访问;但是在用户态时,只能访问 0x00000000~0xbfffffff 的地址范围,0xc0000000 以上的部分只能在内核态下访问。

64 位 Linux 进程地址空间

        在Linux系统中用户态和内核态很显著的区分方法就是指令指针寄存器的指向范围,在内核态下,指令指针寄存器的值可以是任意的地址,而用户态下指令指针寄存器的值只能访问受限的地址范围。

        在64位Linux 系统上,一般使用48位的地址总线,每个进程有256TB 的进程地址空间,如图所示。内核态下的这256TB的地址范围全都可以访问;但是在用户态时,只能访问 0x000000000000~0x7ffffffff000 的地址范围,0x800000000000以上的部分只能在内核态下访问。

逻辑地址、线性地址和物理地址

        这里所说的进程地址空间是进程的线性地址而不是物理地址。在操作系统原理中将地址分为逻辑地址、线性地址和物理地址。

  • 逻辑地址一般是用段地址:段内偏移量来表示,起源 16 位X86 CPU 具有 20 位的地址总线可以访问的地址空间也就从 64K 扩展到 1M;
  • 线性地址就是进程的地址空间里从 0 开始线性递增的地址空间范围里的地址,是逻辑地址经过段地址转换之后的地址;
  • 物理地址就是实际物理内存的地址,线性地址经过内存管理单元(MMU)的内存页转换之后就是物理地址。

        逻辑地址和线性地址在 32 位和 64 位上目前都是虚拟地址,需要依次经过分段、分页映射最后才转换成物理地址。这个映射计算地址的过程一般由 CPU 内部的 MMU负责把虚拟地址转换为物理地址。

系统调用是一种特殊的中断

        系统调用最初是一种特殊的中断,一般中断(Interrupt)分外部中断和内部中断,内部中断又称为异常(Exception),异常又分为故障(fault)和陷阱(trap)。系统调用就是利用陷阱(trap)这种软件中断方式主动从用户态进入内核态的。

        值得注意的是,在ARM64架构中,各种中断的划分与X86架构有所不同,比如中断被称为异常(Exception),又进一步分为同步异常(Synchronous)和异步异常,异步异常又分为普通优先级的外部中断请求(IRQ)、高优先级的外部中断请求(FIQ)和系统错误(System Error)。这里需要解释一下ARM64体系中的同步异常和异步异常。在程序运行过程中发生的异常事件,属于同步异常,这种异常的处理相当于同步阻塞方式调用了异常处理程序;而在计算机运行过程中,出现某些意外情况需要干预时,CPU能自动停止正在运行的程序并转入处理新情况的程序,处理完毕后又返回原被暂停的程序继续运行,这些意外情况属于异步异常。

        显然ARM64中的Exception对应Interrupt,Synchronous对应trap,System Error对应fault,Synchronous和System Error都属于内部中断,IRQ和FIQ属于外部中断。

系统调用的触发指令

        一般来说,从用户态进入内核态是由中断触发的,可能是外部中断,在用户态进程执行时,硬件中断信号到来,进入内核态,就会执行这个中断对应的中断处理程序。也可能是用户态程序执行过程中,调用了一个系统调用,陷入了内核态,叫作陷阱(trap)或同步异常(Synchronous)。

        任何类型的中断处理都会有中断上下文的切换问题,当用户态切换到内核态时,就要把用户态程序执行的上下文环境保存起来,然后执行中断处理程序。

        在X86架构下int $0x80指令或syscall指令触发系统调用;在ARM64架构下svc指令触发系统调用。一般系统调用会在堆栈上保存一些寄存器的值,会保存中断发生时当前执行程序的栈顶地址、当时的状态字、当时的指令指针寄存器的值。同时会将当前进程内核态的栈顶地址、内核态的状态字放入 CPU 对应的寄存器,并且指令指针寄存器的值会指向中断处理程序的入口,对于系统调用来讲是指向系统调用处理的入口。

系统调用的基本工作原理

        系统调用是通过特定的软件中断向内核发出服务请求,比如X86架构下int $0x80或syscall指令的执行就会触发一个系统调用,ARM64架构svc指令的执行就会触发一个系统调用。系统调用返回指令,X86架构是中断返回指令iret或sysret,ARM64架构是异常返回指令eret。

系统调用的意义

        系统调用的意义是操作系统为用户态进程与硬件设备进行交互提供了一组接口。系统调用具有以下功能和特性:

  • 把用户从底层的硬件编程中解放出来。操作系统为我们管理硬件,用户态进程不用直接与硬件设备打交道。
  • 极大地提高系统的安全性。如果用户态进程直接与硬件设备打交道,会产生安全隐患,可能引起系统崩溃。
  • 使用户程序具有可移植性。用户程序与具体的硬件已经解耦合并用接口代替了,不会有紧密的关系,便于在不同系统间移植。

Linux的系统调用号

        Linux内核中大约定义了四五百个系统调用,这时内核如何知道用户态进程希望调用的是哪个系统调用呢?内核通过给每个系统调用一个编号来区分,即系统调用号,将API函数xyz()和系统调用内核函数sys_xyz()关联起来了。内核实现了很多不同的系统调用,用户态进程必须指明需要执行哪个系统调用,这需要使用寄存器传递系统调用号。除了系统调用号外,系统调用也可能需要传递参数。 32位和64位X86都是使用EAX寄存器传递系统调用号,ARM64是使用X8寄存器传递系统调用号。

系统调用参数传递方式

        在32位x86体系结构下普通的函数调用是通过将参数压栈的方式传递的。系统调用从用户态切换到内核态,在用户态和内核态这两种执行模式下使用的是不同的堆栈,即进程的用户态堆栈和进程的内核态堆栈,传递参数方法无法通过参数压栈的方式,而是通过寄存器传递参数的方式。寄存器传递参数的个数是有限制的,而且每个参数的长度不能超过寄存器的长度,32位x86体系结构下寄存器的长度最大32位。除了EAX用于传递系统调用号外,参数按顺序赋值给EBX、ECX、EDX、ESI、EDI、EBP,参数的个数不能超过6个,即上述6个寄存器。如果超过6个就把某一个寄存器作为指针,指向内存,就可以通过内存来传递更多的参数。以上就是32位x86体系结构下系统调用的参数传递方式。

        由于压栈的方式需要读写内存,函数调用速度较慢,64位x86体系结构下普通的函数调用和系统调用都是通过寄存器传递参数,RDI、RSI、RDX、RCX、R8、R9这6个寄存器用作函数/系统调用参数传递,依次对应第 1 参数到第 6 个参数。

        ARM64系统调用的参数传递是采用X0-X5这6个寄存器,系统调用号放在X8寄存器里传递。Linux系统调用最多有6个参数,ARM64函数调用参数可以使用X0-X7这8个寄存器。

X86 Linux系统调用概述

        在X86架构下系统调用或中断信号等中断事件发生之后即进入中断处理程序,中断发生时CPU第一时间就是保存当前CPU执行的关键上下文(栈顶指针寄存器、标志寄存器和指令指针寄存器),进入中断处理程序首先做的就是保存现场(比如老版本的内核中的SAVE_ALL),就是把其他寄存器的值也通过入栈操作,放到内核堆栈里去。当中断处理结束前的最后一件事是恢复现场(比如老版本的内核中的RESTORE_ALL)。恢复现场在 3.18.6 的 X86-32 位新内核中是由 restore_all 和 INTERRUPT_RETURN(iret)实现的,恢复现场就是负责把中断时保存现场时入栈的寄存器再依次出栈恢复到当前的 CPU 寄存器里。最后的 iret 与中断信号(包括 int 指令)发生时的 CPU 做的动作正好相反,之前是保存栈顶指针寄存器、标志寄存器和指令指针寄存器,这里就是恢复栈顶指针寄存器、标志寄存器和指令指针寄存器

        以32位X86为例仔细分析中断处理过程。系统调用通过 int $0x80 软件中断(陷阱)触发,硬件中断则是中断信号或故障触发,中断发生时将当前 CS:EIP 的值、当前的堆栈栈顶地址SS:ESP,以及 EFLAGS 标志寄存器的当前的值压栈到内核堆栈中同时把当前的中断信号、故障或者是系统调用对应的中断服务程序的入口加载到 CS:EIP 里把当前内核堆栈的栈顶信息也加载到寄存器 SS:ESP 里,这些都是由中断信号、故障或者是 int 指令触发CPU自动来完成的。完成后,当前 CPU 在执行下一条指令时就已经开始执行中断处理程序的入口了,这时对堆栈的操作已经是在内核堆栈上操作了,中断处理程序入口首先就是继续保存现场,然后完成中断服务,期间可能发生进程调度,如果没有发生进程调度或者已经调度回来,并且各项工作已经处理完毕,就开始准备中断返回,中断返回要恢复现场,最后 iret 返回到中断发生前的状态。

        以5.4版本的X86 Linux内核代码为例具体来看一下X86 Linux系统调用的处理过程。当用户态进程调用一个系统调用时,CPU切换到内核态并开始执行system_call(entry_INT80_32或entry_SYSCALL_64)汇编代码其中根据系统调用号调用对应的内核处理函数。具体来说,在X86 Linux中通过执行int $0x80或syscall指令来触发系统调用的执行,其中这条int $0x80汇编指令是产生中断向量为128的编程异常(trap),另外Intel处理器中还引入了sysenter指令(快速系统调用),因为sysenter指令由Intel系列CPU专用,AMD的CPU并不支持,因此不再详述。我们只关注int指令和syscall指令触发的系统调用,进入内核后,开始执行对应的中断服务程序entry_INT80_32或entry_SYSCALL_64。

ARM64 Linux系统调用概述

        在ARM64架构下Linux系统调用由同步异常svc指令触发。用户态(EL0级)程序调用库函数xyz()从而触发系统调用的时候,先把系统调用的参数依次放入X0-X5这6个寄存器(Linux系统调用最多有6个参数,ARM64函数调用参数可以使用X0-X7这8个寄存器),然后把系统调用号放在X8寄存器里,最后执行svc指令,CPU即进入内核态(EL1级)。顺便提一下svc指令一般会带一个立即数参数,一般是0x0,但并没有被Linux内核使用,而是把系统调用号放到了X8寄存器里。

        在ARM64架构CPU中,Linux系统调用(同步异常)和其他异常的处理过程大致相同。异常发生时,CPU首先把异常的原因(比如执行svc指令触发系统调用)放在ESR_EL1寄存器里把当前的处理器状态(PSTATE)放入SPSR_EL1寄存器里;把当前程序指针寄存器(PC)放入ELR_EL1寄存器里然后CPU通过异常向量表(vectors)基地址和异常的类型计算出异常处理程序的入口地址,即VBAR_EL1寄存器加上偏移量取得异常处理的入口地址,接着开始执行异常处理入口的第一行代码。这一过程是CPU自动完成的,不需要程序干预。

        进入异常处理入口之后,以svc指令对应的el0_sync为例,el0_sync处的内核汇编代码首先做的就是保存异常发生时程序的执行现场(保存现场),然后根据异常发生的原因(ESR_EL1寄存器)跳转到el0_svc,el0_svc处会根据系统调用号找到对应的系统调用内核处理函数接着执行系统调用内核处理函数sys_syz()。

        系统调用内核处理函数执行完成后,系统调用返回前需要恢复异常发生时程序的执行现场(恢复现场),其中就包括主动设置ELR_EL1和SPSR_EL1的值,原因是异常会发生嵌套,一旦发生异常嵌套ELR_EL1和SPSR_EL1的值就会随之发生改变,所以当系统调用返回时,需要恢复之前保存的ELR_EL1和SPSR_EL1的值最后内核调用异常返回指令eretCPU自动把ELR_EL1写回PC,把SPSR_EL1写回PSTATE,并返回到用户态程序里,可以继续运行了。


以上内容为中科大软件学院《Linux操作系统分析》课后总结,感谢孟宁老师的倾心教授,老师讲的太好啦(^_^)

参考资料:《庖丁解牛Linux内核分析》    孟宁  编著

  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

青衫客36

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值