程序员应如何理解系统调用:上篇
在第一章回顾了一些重要主题:CPU、内存、程序、进程后,第二章将正式开始操作系统系列主题,本章主要来讨论操作系统是如何来对进程进行控制的,以下为本篇目录。
API与系统调用
系统调用的过程
系统调用类型
系统调用带来的好处
释放程序员生产力
提高系统稳定性
多任务以及虚拟内存
承接上文《系统调用是如何实现的》
操作系统主要有两项功能:
向用户程序提供一个友好的编程接口,即系统调用
管理计算机资源(包括CPU、内存、磁盘、网卡等外设,以及进程管理,线程管理,文件管理等)
通常操作系统如何管理计算资源对于程序员来说是不可见的,应用程序想要使用系统资源必须通过操作系统,从这个角度讲操作系统更像是server,我们的应用程序是client,client只需要向server发出request然后得到response,至于server如何处理请求并不要client关心。同样的道理,应用程序只需要进行系统调用,而操作系统通过系统调用来屏蔽了处理细节。
作为程序员应该意识到,我们的程序在运行时,CPU不仅仅在执行我们的程序,当涉及到文件、网络、进程控制、线程控制、I/O等时,单单依靠我们的代码是没有办法来完成这些操作的,这些只能依靠操作系统,对于程序员来说就是调用系统调用。当系统调用开始执行时,CPU从用户模式切换到内存模式并开始运行操作系统的代码,即操作系统开始运行来完成上述用户程序请求。
虽然系统调用在程序员眼里仅仅是一个普通的函数调用,但是深刻理解系统调用对于理解操作系统的运行方式来说是非常重要的。简而言之,要想成为编程高手,你需要理解系统调用。
API与系统调用
一般情况下,程序员不会直接使用系统调用进行编程,而是使用对系统调用进行了封装的API来编程,这个API在Unix/Linux下是libc来提供的,也就是我们熟悉的C标准库;在Windows下这个API叫做Win32 API,相信在Windows下编程的同学对此不会陌生。
实际上作为程序员我们使用的基本上是使用C标准库或Win32 API进行编程,而这些API又封装了系统调用(所谓封装,也就是这些API最终会调用到系统调用),这也是为什么很多程序员根本没有意识到自己的程序在进行系统调用的原因。
你可能会想,为什么我们要使用C标准库或者Win32 API(以下统称API)而不直接使用系统调用来进行编程呢?
一方面是因为这些系统调用的接口不是很易用;另一方面比较重要的是,API的使用对程序员屏蔽了系统调用,也就是说我们的程序并不直接依赖系统调用,这一点是极为重要,因此这些API可以选择使用某个系统调用或者使用多个系统调用或者不使用系统调用。这就给API的设计带了了极大的灵活性。同时只要API的接口是不变的,那么如果两个不同的操作系统提供了同样的API,我们的程序就可以不加修改的在另一种操作系统上运行了,这是非常棒的一种设计。比如,如果Windows上实现了C标准库,那么我们在Unix/Linux上基于C标准库的程序就可以不加修改的在Windows上运行。
通常来说一个系统调用会对应一个API,但是反过来不一定正确,也就是说一个API中不一定会调用系统调用,比如我们使用的memcpy,这里面就没有调用任何系统调用。而且多个API可能会调用同一个系统调用,比如在Linux下我们进行内存分配释放常用的几个函数malloc(),calloc(),free(),这些函数实际上都是调用的一个叫做brk()的系统调用来完成的。
由于Windows是闭源的商业操作系统,因此Win32 API有很好的兼容性,很古老的Windows程序放到现在的Win10上依然可以运行的好好的。
但是对于Unix来说情况就不一样了,由于历史原因,现存有很多基于Unix的操作系统,但是又包含了自己的实现。因此为了方便在这些系统是进行软件开发,提出了POSIX标准,POSIX标准主要是用来统一各个Unix平台上的API而不是这些平台上的系统调用,这些Unix系统可以有不同的系统调用,但是对外的API要提供一个大家都认可的统一的格式,POSIX就是来规定这些格式的。有了POSIX标准,基于该标准的编写的程序就可以运行在不同的Unix平台上了。顺便说一下,虽然我们经常把Unix和Linux放在一起阐述,但是Linux是和Unix完全不同的一个操作系统,仅仅是Linux在设计哲学上借鉴了Unix,Linux上已经提供了符合POSIX规范的API。
一般来说这些API(Unix/Linux下的C标准库或者Windows下的Win32 API)已经足够大部分程序员使用了,因此作为程序员很少会遇到需要直接使用系统调用的情况。
接下来我们就来看看一个系统调用的完整过程。
系统调用的过程
在这里我们依然以我们熟悉的HelloWorld程序为例来说明,同时我们假定运行的操作系统是Linux(其它系统下这个过程依然适用)。在Linux下printf其实是C标准库中的函数,当调用printf时最终会调用到一个叫做write()的系统调用。
include <stdio.h>
int main(){
printf("Hello World."); //调用系统调用write
return 0;
}
我们的HelloWorld程序在被加载到内存后,操作系统把CPU的程序计数器指向第一条HelloWorld程序指令所在的内存地址,这样我们的程序开始在用户模式下运行,由于printf仅仅是一个定义在C标准库中的普通的函数,因此当执行到该函数时CPU跳转到C标准库中去执行命令,此时CPU依然工作在用户模式下。由于C标准库中的printf函数最终会调用write系统调用,因此CPU最终会执行到trap命令,这时CPU开始由用户模式转变为内核模式,CPU跳转到提前定义好的内存地址开始执行操作系统的代码。就好比用户程序向操作系统喊了一句:“Hey,操作系统老兄,帮我执行一下你的write函数吧。”当操作系统在替用户完成任务后,依次返回到C标准库中的函数以及用户程序,最终我们的HelloWorld程序得以继续运行,如下图所示。
所有的系统调用都是按照这种过程完成的。
我们知道操作系统提供了很多功能,因此有很多系统调用,Windows中有上千个,Linux中较有几百个。不知道大家有没有注意到一点,那就是操作系统怎么知道要执行的是write系统调用呢?
原来这些系统调用都有一个唯一的编号,这样当CPU执行trap命令切换的内核模式后,操作系统就能通过系统调用编号知道需要执行什么样的函数啦。
因此你会看到,这里有个通过系统调用编号来查找具体处理函数的过程,这个过程在Linux下是由一个被称之为System Call Handler的函数来实现的,CPU在从用户模式切换到内核模式后,跳转的内存地址就是System Call Hander所在的位置。System Call Handler通过系统调用号找到具体的处理代码后开始调用这段C代码来处理用户程序的请求:如下图所示,从这里应该也能看出来,普通的函数在被调用CPU不会进行模式切换,普通的函数调用只在用户模式下就可以完成。
后续内容将在《程序员应如何理解系统调用:下篇》中继续。
操作系统系列
基础篇 | 1,什么是程序 |
3,程序员应如何理解内存:上篇 | |
4,程序员应如何理解内存:中篇 | |
5,程序员应如何理解内存:下篇 | |
6,程序员应如何理解CPU:上篇 | |
7,程序员应如何理解CPU:下篇 | |
系统调用篇 | 8,操作系统是如何看待进程的 |
9,系统调用是如何实现的 |
PS:微信公众号从去年开始限制了留言功能,如果你有任何问题欢迎直接在公众号留言。