7.9 用户接口

目录

一 序言

二 应用层实现

三 内核层实现


序言

   这一部分,我们简单介绍一下用户接口。从本质上讲,操作系统就是对计算机硬件资源进行一个封装,从而方便用户来使用。前面几部分,我们重点介绍了操作系统自身的一些功能,这些功能的实现,方便了用户对硬件资源的使用。无论是中断管理、进程管理、内存管理,还是文件系统、网络、显示等等,都是这样的目的。但是,到目前为止,这些功能都只是存在于操作系统自身内部的模块,并没有为用户所用。如果不能为用户所用,有再好的功能,也是白搭。而如果要为用户所用,在计算机世界里,最简便的方式,就是提供接口,即将所有的服务,以接口的方式提供出来,供用户程序使用。通常,将操作系统层面提供的接口,称为系统调用。

应用层实现

   系统调用就像操作系统的包装层。应用程序编写者,不需要关心系统服务如何实现,只需要了解接口如何使用即可。就以Linux操作系统为例,当需要创建一个新的进程时,可以在当前进程基础上,调用fork接口即可。同样的,当需要打开一个文件时,只需要调用open接口即可。内存的使用,网络的使用,都是如此。这里,我们并不打算详细的介绍操作系统提供了哪些系统调用接口。对此感兴趣的读者,可以查阅专门的书籍。在这部分,我们简要介绍一下系统调用本身的实现,及相关的一些问题。如果不深挖其中的原理和依赖条件,读者是很难想象到,即使是这样一个简单的功能,其实现时也要考虑很多复杂的因素。相应的,了解了其实现需要考虑的复杂因素,可以加深我们对操作系统和应用程序之间关系的理解,可谓一举两得。

   上面我们举例提的接口,并不是系统调用真正的接口。实际中,应用程序很少直接调用系统调用接口,取而代之,很多时候是通过相关的库函数来间件调用到系统调用的。库函数会对系统调用接口进行一些封装,甚至是功能上的二次实现。比如,对于分配内存,我们常用的malloc,就是C库提供的,在Linux下,其底层是brk系统调用。封装库通过对系统调用的二次封装,提供给用户更易用的接口。同样以malloc为例,在用户程序调用时,该接口并不是每次都调用brk来向操作系统申请内存,也不是每次free时,向操作系统释放内存。相反,其自身就管理了从操作系统获取的内存,在二次处理后,提供给用户。所以,应用程序malloc时,库可能直接就将自己二次管理的内存提供给用户,而用户释放内存时,内存只是在库这一层做了free的标记,不一定真的交给操作系统,这样用户再次申请内存时,就可以从库直接获取到内存,而不需要频繁的跟操作系统交互。这样做,其中的一个重要原因在于,系统调用的开销比较大。后面会涉及到这一点。即便库通过brk调用,跟操作系统要内存,操作系统也并不是真的分配物理内存给应用,而只是分配虚拟内存,等到用户真的向内存空间(地址)写数据时,操作系统才会根据缺页异常,来真正的为应用分配物理内存。这个特性,我把它总结为操作系统的承诺制。也就是说,对于内存分配请求,操作系统总是做出承诺,而并不实际分配。不单对应用是这样,即使是内核内部,很多的内存申请也是这样的。这种做法的主要目的是为了提高效率。

   就malloc接口而言,我们还漏了两点。一是,在分配小内存时,用的是brk系统调用,但是,如果分配的内存大小大于128K(具体值可能会跟相关库版本和实现有关)时,库会选择使用mmap,进行内存映射方式,来获取物理内存;二是,malloc只是一个应用层比较常用的接口,不同的C库,对其实现并不一样,而且根据是否多线程,应用环境,还有不同的优化。甚至,你可以根据自己的内存分配需求,自己来实现相关接口,从而对泄露监测等,做出自己的优化方案。可见,一个普普通通的接口,内部就包含了这么多的内容。很多写了十年多程序的开发者,也不见得曾停下来,思考过它的细节。我们看到的很简单的调用接口,只是冰山一角。

   我们使用库接口,而不是直接使用系统调用,就是因为库对系统调用良好的封装。但是,这并不绝对。有些接口,并不需要进行复杂的二次封装,其本身的语义简单清晰,这种情况下,我们可以直接调用该系统调用。另外一种情况,就是针对特定的需求和场景,我们可能也不想要库的复杂封装。比如,对于上面所述内存管理,如果应用对所需内存有自己的管理要求,那么也可以绕过库,通过直接的系统调用来完成。

   使用库,而非直接的系统调用,还有一个隐含的好处就是跨平台。就拿我们最常见的C库来说,Windows下内存分配的系统调用跟Linux下是不一样的,但是,如果我们使用C库,那么这种差异就自然的屏蔽了,程序的可移植性会更好。总之,使用那些流行的库,好处是自然的附带了封装和跨平台特性,通用性强;缺点也是通用性强,在特殊需求场景下,比如占用空间、性能或安全性上,可能不满足要求,此时就需要定制性的开发。

   最后,回到系统调用本身。我们常听说内核空间和应用空间,二者是隔离的。既然这样,系统调用是如何实现的?显然,不能是直接的函数调用,否则,跨越空间就无从谈起。要理解这个过程,我们还是需要向下挖掘,尽量靠近汇编来辅助分析。

   首先,我们简单再看看函数调用。这在汇编层面,就是一个跳转指令。跳转到目标地址后,通过栈完成参数传递。函数功能完成后,通过返回指令,完成出栈动作,并回到跳转指令的下一条指令继续执行。在虚拟地址空间中,我们的函数地址都是从0开始分配的,完成链接后,加载器再对它们进行重定位,完成最终地址的分配。编译器、连接器和加载器是平台相关的,故此,它们不会把你的程序定位到内核空间。自然,通过跳转,你是跑不到内核空间的。那我们是否可以通过指针,强制到某个地址执行呢?这会绕过编译器、链接器和加载器。这也是办不到的。地址最终对应的是内存空间,直接访问内核地址范围,在虚拟地址到物理地址转换时,页表中是不存在的。这跟某些野指针类似,内核会报告段错误,进程会被kill掉。

   其次,系统调用的支持,是在编译工具链中。对于Linux平台,无论是X86还是ARM,我们可以用同一个系统调用接口(不是库封装的接口,而是操作系统本身提供的原始接口),编译后,编译器会将其替换为具体的平台汇编指令。原始方法调用系统提供的接口,是有一定的格式要求的。主要包括两部分,一部分是涉及系统调用的调用号,另外一部分是参数。至于为啥用调用号,而不是名字,后面会介绍。下面我们看个具体的例子。以Linux平台里的epoll为例,可以有如下两种写法:

      count = epoll_wait(mPollerFd_,  events,  kMaxConns,  1000);

      count = syscall(__NR_epoll_wait,  mPollerFd_,  events,  kMaxConns,  1000);

   上面两种写法都是可以的。当我们在编写应用程序代码时,并不会看到内核代码,所以,也不会链接到内核代码。既然这样,那么程序又是怎么编译通过的呢?这就是前面说的,需要编译工具链的支持。一方面是头文件。通过头文件,应用层和系统层对接口的理解就是一致的。另一方面是工具链。工具链需要帮助我们完成接口的“实现”。这个实现,并非真正功能的实现,而是中继的实现。也就是完成应用层和内核之间的衔接。这样一来,应用层编译时,看到的是已经实现的接口。但是,执行时,该实现会将最终的需求,转给内核,内核完成后,再将结果返回给中继,中继再返回给应用。这就像在应用和内核之间加了一个门。只要这个看门人对上(应用层)和对下(内核)的对接定好了,那么上下层就可以自己变化了。以后,大家每次过门时,重新包装好自己的信息,看门人就能将它正确的传递给彼此。

   我们可以将上下层的约定,看成一个契约。大家约定好,以后不再变化。这种约定只是说接口形式不变化,但是具体实现,不做限制。就拿上面两个接口来讲,对上层来讲,最终效果是等价的,只不过第二种显示的指定了调用号。下面通过汇编,我们可以更清楚的看到这个过程。

   编译上面两种接口形式,查看对应的汇编。ARM32平台。下同。

   可以看到,最终链接到C库中。查看C库中的实现。

   Epoll是通过svc异常指令实现的,调用号是通过r7寄存器传递的,值是252。这跟代码里的定义是一致的。

   好了,到此,我们基本理清了应用层的流程。

   最后补充一点,如果你对系统比较熟悉,也可以绕开编译器,直接在代码中嵌入汇编,实现最直接的系统调用。只不过,我们一般不这样做。下面,我们就看看内核层的实现。

内核层实现

   仍然以Linux操作系统为例。在上一节最后部分的汇编,我们看到,该系统调用是通过svc异常指令触发的,并通过r7寄存器传递了调用号。这样,操作系统会到异常向量表中找到调用号所对应的处理程序,然后进行系统调用的处理。对于x86架构的CPU,系统调用是通过int指令触发的。可见,无论是ARM还是x86,系统调用都不是直接通过函数调用来实现,而是都依赖CPU的异常指令。后面为了方便说明,将它们统一称作软中断。也就是说,Linux的系统调用是基于软中断实现的。

   我们先看看,什么是软中断。从名字上理解,软中断是中断。既然是中断,就会触发CPU执行一系列的动作,包括寄存器的压栈、中断程序的执行、寄存器的出栈、原有执行流程的恢复等等。这是中断的标准执行流程。但是,这里有一个软字,与之相反,对应的就应该有一个硬中断。其实,这样一对比,就比较好理解了。软中断,言下之意就是一个由软件触发的中断;而硬中断,则意味着是一个由硬件触发的中断。硬中断的特点是硬件根据设定条件,自动触发。比如时钟定时器定时触发,网卡收到数据包触发,串口收到数据触发等等。而软中断,则是CPU为了方便系统设计而专门增加的功能。怎么说呢,可能有些操作需要像中断一样的流程和环境,需要CPU将当前寄存器压栈来保护,然后跳到另一个地方执行工作任务,完了后再弹出来,以恢复原有的流程。但是执行这个操作,可能没有相应的硬件时机来对应,这样就可以设计一个软件中断,当软件设计者需要这个中断时,就通过软件方式来触发,而不用担心没有相应的硬件触发源,导致功能无法执行。

   再联系到系统调用。当应用程序调用系统调用接口时,就触发一个软中断,这个中断的中断服务程序,就是系统调用接口的具体功能实现。这里,大家可能还是有疑问,为什么系统调用要用软中断方式?(显然,直接调用是不行的,前面已经说明了)而且软中断,再怎么说也是一个中断,实现复杂不说,相对于函数调用,CPU还得额外执行一套标准流程,增加了处理的耗时,真可谓吃力不讨好。要解释其中的缘由,就不得不从Linux内核的架构说起。

   系统调用提供的功能,本质上说,就是操作系统提供的接口服务。操作系统更偏向成为整个系统硬件资源的管理者角色,而应用程序则偏向成为资源的使用者。显然,管理者拥有的权限,要大于使用者。在前面我们也提到了,操作系统是处于内核级的(Intel的CPU,将权限分为了0、1、2、3四个级别,Linux只使用了其中的0和3两个级别)。这一级可以修改一些重要寄存器的内容。因为操作系统先运行,所以,其可以将自己设置为高权限状态,并限制后续应用程序对权限的修改。这样,操作系统就天然的占有了先机。而应用程序,则被操作系统限制为用户级别,不具有修改CPU某些部分的权限。为了系统的稳定和安全,用户层代码不应该也不能直接使用内核层代码和数据。这一点,在虚拟内存机制构建起来后,就已经决定了。整个系统的层次关系如下图:(图片来自网络)

   通过图,可以清楚的看到,内核对用户程序来讲,就是一套调用接口而已。前面我们讲了,应用层无法直接使用内核代码和数据,那么,内核是否可以访问应用代码和数据呢?这倒是可以的。最常见的例子,就是copy_from_user和copy_to_user。这个接口在驱动中很常见。内核可以从应用层拷贝数据过来,也可以将数据拷贝到用户空间,但是反过来不行。(当然,内核对用户空间的访问也不是绝对的。有一些硬件,就有机制来限制内核对用户空间的随意访问。)

   再换个角度看。之前在进程和内存部分,已经介绍了,操作系统的代码和数据区在内存的1G空间里,并且是进程共享的。也就是说,进程的4G空间里,有1G是映射到内核的。如下图所示:

   那么,从内存的动态运行角度来看,进程要使用操作系统的代码和数据,需要将指针跳转到OS区。如果这种跳转被允许,那操作系统就没有安全性可言了。而通过系统调用,主动权就在操作系统手里了。可以将操作系统提供的服务类比为柜台窗口来理解这一点。应用程序通过直接的函数调用形式来获取服务,就好比是无人柜台,全靠用户自觉;而系统调用,就好比是有柜台服务员,需要什么,告诉服务员,让服务员替自己拿过来。

   这个服务员可以说就是软中断。通过软中断可以避免用户代码直接访问内核代码,同时又能将内核的服务提供出来,一举两得。通过软中断,CPU指令指针可以从用户空间跳转到内核空间(为什么软中断就可以跳转到内核空间,这是硬件设计决定的。发生中断后,CPU通过硬件,陷入内核层级,并执行固定的中断处理程序。又因为中断服务程序是操作系统实现的服务代码,CPU执行中断服务程序,就是执行内核代码了),同时又保证了用户代码同内核代码的隔离。基于这种运行时的限制,达到了逻辑上的分层效果。

   对于Linux而言,软中断发生后,为了能够正确的回到中断前的应用,需要做一些额外的工作,这包括:将当前CPU的现场,保存到内核栈中;执行中断服务程序,进入系统调用表;根据r7寄存器的调用号,执行正确的系统调用函数;完成后,将内核栈中保存的CPU现场恢复,清空内核栈,返回到用户空间。

   对于软中断具体怎么实现的用户接口,这里就不再介绍了,网络上有很多资料,感兴趣的读者可以查阅具体流程。关于系统调用的内容,就介绍到这里。

  【后记。补充一点:系统调用不一定非要用软中断或者异常来实现。现在也有一些方案,为了效率,将系统调用实现为类似直接函数调用的形式。对此有兴趣的读者,可以查阅相关资料,了解详细信息。所谓创新,就是不拘泥与现有的答案。操作系统层面还有许多其他微创新,在不断的突破人们的传统概念和认识。比如,仍然以系统调用为例,为了减少频繁的内核和应用的切换,以及复杂的层次封装,一些创新中,并不将驱动层的数据传递到应用层,而是直接在内核层完成处理,而有些创新中,采取了相反的策略,特定驱动数据并不经过内核层的通用过滤处理,而是直接到应用层进行特殊处理。所以,创新无极限。

   再看几年前的这篇稿子,想到了这些,补充记录一下。】

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

龙赤子

你的小小鼓励助我翻山越岭

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

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

打赏作者

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

抵扣说明:

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

余额充值