Linux系统调用过程分析

參考:

《Linux内核设计与实现》

0 摘要

linux的系统调用过程:
层次例如以下:
用户程序------>C库(即API):INT 0x80 ----->system_call------->系统调用服务例程-------->内核程序
先说明一下,我们常说的用户API事实上就是系统提供的C库。
系统调用是通过软中断指令 INT 0x80 实现的,而这条INT 0x80指令就被封装在C库的函数中。


(软中断和我们常说的硬中断不同之处在于,软中断是由指令触发的,而不是由硬件外设引起的。)
INT 0x80 这条指令的运行会让系统跳转到一个预设的内核空间地址,它指向系统调用处理程序。即system_call函数。
(注意:。。!系统调用处理程序system_call 并非系统调用服务例程,系统调用服务例程是对一个详细的系统调用的内核实现函数。而系统调用处理程序是在运行系统调用服务例程之前的一个引导过程。是针对INT 0x80这条指令,面向全部的系统调用的。

简单来讲,运行不论什么系统调用。都是先通过调用C库中的函数,这个函数里面就会有软中断 INT 0x80 语句。然后转到运行系统调用处理程序 system_call ,
system_call 再依据详细的系统调用号转到运行详细的系统调用服务例程。)
system_call函数是怎么找到详细的系统调用服务例程的呢?通过系统调用号查找系统调用表sys_call_table!软中断指令INT 0x80运行时,系统调用号会被放入 eax 寄存器中,system_call函数能够读取eax寄存器获取,然后将其乘以4,生成偏移地址,然后以sys_call_table为基址。基址加上偏移地址,就能够得到详细的系统调用服务例程的地址了!


然后就到了系统调用服务例程了。

须要说明的是。系统调用服务例程仅仅会从堆栈里获取參数,所以在system_call运行前。会先将參数存放在寄存器中。system_call运行时会首先将这些寄存器压入堆栈。

system_call退出后。用户能够从寄存器中获得(被改动过的)參数。
 
另外:系统调用通过软中断INT 0x80陷入内核。跳转到系统调用处理程序system_call函数,然后运行对应的服务例程。可是因为是代表用户进程,所以这个运行过程并不属于中断上下文,而是进程上下文。因此。系统调用运行过程中,能够訪问用户进程的很多信息,能够被其它进程抢占,能够休眠。
当系统调用完毕后,把控制权交回到发起调用的用户进程前。内核会有一次调度。

假设发现有优先级更高的进程或当前进程的时间片用完,那么会选择优先级更高的进程或又一次选择进程运行。

1       系统调用意义
linux内核中设置了一组用于实现系统功能的子程序,称为系统调用。

系统调用和普通库函数调用很相似,仅仅是系统调用由操作系统核心提供,执行于核心态。而普通的函数调用由函数库或用户自己提供。执行于用户态。
 
一般的,进程是不能訪问内核的。它不能訪问内核所占内存空间也不能调用内核函数。CPU硬件决定了这些(这就是为什么它被称作"保护模式")。为了和用户空间上执行的进程进行交互,内核提供了一组接口。透过该接口,应用程序能够訪问硬件设备和其它操作系统资源。这组接口在应用程序和内核之间扮演了使者的角色,应用程序发送各种请求。而内核负责满足这些请求(或者让应用程序临时搁置)。

实际上提供这组接口主要是为了保证系统稳定可靠。避免应用程序肆意妄行,惹出大麻烦。


 
系统调用在用户空间进程和硬件设备之间加入了一个中间层。该层主要作用有三个:
(1) 它为用户空间提供了一种统一的硬件的抽象接口。

比方当须要读些文件的时候,应用程序就能够不去管磁盘类型和介质,甚至不用去管文件所在的文件系统究竟是哪种类型。
(2)系统调用保证了系统的稳定和安全。作为硬件设备和应用程序之间的中间人,内核能够基于权限和其它一些规则对须要进行的訪问进行裁决。

举例来说,这样能够避免应用程序不对地使用硬件设备,窃取其它进程的资源,或做出其它什么危害系统的事情。
(3) 每一个进程都执行在虚拟系统中,而在用户空间和系统的其余部分提供这样一层公共接口。也是出于这样的考虑。假设应用程序能够任意訪问硬件而内核又对此一无所知的话,差点儿就没法实现多任务和虚拟内存,当然也不可能实现良好的稳定性和安全性。

在Linux中。系统调用是用户空间訪问内核的惟一手段。除异常和中断外,它们是内核惟一的合法入口。
 
2       API/POSIX/C库的关系
普通情况下,应用程序通过应用编程接口(API)而不是直接通过系统调用来编程。

这点非常重要,由于应用程序使用的这样的编程接口实际上并不须要和内核提供的系统调用一一相应。一个API定义了一组应用程序使用的编程接口。它们能够实现成一个系统调用,也能够通过调用多个系统调用来实现,而全然不使用不论什么系统调用也不存在问题。实际上,API能够在各种不同的操作系统上实现,给应用程序提供全然同样的接口,而它们本身在这些系统上的实现却可能迥异。
 
在Unix世界中。最流行的应用编程接口是基于POSIX标准的,其目标是提供一套大体上基于Unix的可移植操作系统标准。POSIX是说明API和系统调用之间关系的一个极好样例。在大多数Unix系统上。依据POSIX而定义的API函数和系统调用之间有着直接关系。


 
Linux的系统调用像大多数Unix系统一样,作为C库的一部分提供例如以下图所看到的。

C库实现了 Unix系统的主要API。包含标准C库函数和系统调用。全部的C程序都能够使用C库,而因为C语言本身的特点,其它语言也能够非常方便地把它们封装起来使用。

 
从程序猿的角度看,系统调用无关紧要。他们仅仅须要跟API打交道就能够了。相反。内核仅仅跟系统调用打交道;库函数及应用程序是怎么使用系统调用不是内核所关心的。


 
关于Unix的界面设计有一句通用的格言“提供机制而不是策略”。

换句话说,Unix的系统调用抽象出了用于完毕某种确定目的的函数。至干这些函数怎么用全然不须要内核去关心。差别对待机制(mechanism)和策略(policy)是Unix设计中的一大亮点。大部分的编程问题都能够被分割成两个部分:“须要提供什么功能”(机制)和“如何实现这些功能”(策略)。

 
3       系统调用的实现
3.1    系统调用处理程序
您也许疑惑: “当我输入 cat /proc/cpuinfo 时。cpuinfo() 函数是怎样被调用的?”内核完毕引导后,控制流就从相对直观的“接下来调用哪个函数?”改变为取决于系统调用、异常和中断。
 
用户空间的程序无法直接运行内核代码。它们不能直接调用内核空间中的函数,由于内核驻留在受保护的地址空间上。

假设进程能够直接在内核的地址空间上读写的话,系统安全就会失去控制。

所以,应用程序应该以某种方式通知系统,告诉内核自己须要运行一个系统调用,希望系统切换到内核态,这样内核就能够代表应用程序来运行该系统调用了。
 
通知内核的机制是靠软件中断实现的。首先,用户程序为系统调用设置參数。当中一个參数是系统调用编号。參数设置完毕后,程序运行“系统调用”指令。x86系统上的软中断由int产生。

这个指令会导致一个异常:产生一个事件,这个事件会致使处理器切换到内核态并跳转到一个新的地址,并開始运行那里的异常处理程序。此时的异常处理程序实际上就是系统调用处理程序。

它与硬件体系结构紧密相关。
 
新地址的指令会保存程序的状态。计算出应该调用哪个系统调用,调用内核中实现那个系统调用的函数,恢复用户程序状态,然后将控制权返还给用户程序。

系统调用是设备驱动程序中定义的函数终于被调用的一种方式。 
3.2    系统调用号
在Linux中。每一个系统调用被赋予一个系统调用号。

这样,通过这个独一无二的号就能够关联系统调用。当用户空间的进程运行一个系统调用的时候。这个系统调用号就被用来指明究竟是要运行哪个系统调用。进程不会提及系统调用的名称。
 
系统调用号相当关键。一旦分配就不能再有不论什么变更,否则编译好的应用程序就会崩溃。Linux有一个“未实现”系统调用sys_ni_syscall(),它除了返回一ENOSYS外不做不论什么其它工作,这个错误号就是专门针对无效的系统调用而设的。
 
由于全部的系统调用陷入内核的方式都一样,所以不过陷入内核空间是不够的。因此必须把系统调用号一并传给内核。在x86上,系统调用号是通过eax寄存器传递给内核的。在陷人内核之前,用户空间就把相应系统调用所相应的号放入eax中了。

这样系统调用处理程序一旦执行,就能够从eax中得到数据。其它体系结构上的实现也都类似。
 
内核记录了系统调用表中的全部已注冊过的系统调用的列表。存储在sys_call_table中。它与体系结构有关,一般在entry.s中定义。

这个表中为每个有效的系统调用指定了惟一的系统调用号。sys_call_table是一张由指向实现各种系统调用的内核函数的函数指针组成的表:
ENTRY(sys_call_table)
.long SYMBOL_NAME(sys_ni_syscall) /* 0 - old "setup()" system call*/
.long SYMBOL_NAME(sys_exit)
.long SYMBOL_NAME(sys_fork)
.long SYMBOL_NAME(sys_read)
.long SYMBOL_NAME(sys_write)
.long SYMBOL_NAME(sys_open) /* 5 */
.long SYMBOL_NAME(sys_close)
.long SYMBOL_NAME(sys_waitpid)
。。

。。。
.long SYMBOL_NAME(sys_capget)
.long SYMBOL_NAME(sys_capset)      /* 185 */
.long SYMBOL_NAME(sys_sigaltstack)
.long SYMBOL_NAME(sys_sendfile)
.long SYMBOL_NAME(sys_ni_syscall) /* streams1 */
.long SYMBOL_NAME(sys_ni_syscall) /* streams2 */
.long SYMBOL_NAME(sys_vfork)      /* 190 */
 
system_call()函数通过将给定的系统调用号与NR_syscalls做比較来检查其有效性。

假设它大于或者等于NR syscalls,该函数就返回一ENOSYS。

否则。就运行对应的系统调用。


      call *sys_ call-table(,%eax, 4)
因为系统调用表中的表项是以32位(4字节)类型存放的,所以内核须要将给定的系统调用号乘以4,然后用所得的结果在该表中查询其位置
 
3.3    參数传递
除了系统调用号以外。大部分系统调用都还须要一些外部的參数输人。所以。在发生异常的时候。应该把这些參数从用户空间传给内核。最简单的办法就是像传递系统调用号一样把这些參数也存放在寄存器里。

在x86系统上,ebx, ecx, edx, esi和edi依照顺序存放前五个參数。须要六个或六个以上參数的情况不多见,此时,应该用一个单独的寄存器存放指向全部这些參数在用户空间地址的指针。


 
给用户空间的返回值也通过寄存器传递。在x86系统上。它存放在eax寄存器中。接下来很多关于系统调用处理程序的描写叙述都是针对x86版本号的。

但不用操心,全部体系结构的实现都非常类似。
 
3.4    參数验证
系统调用必须细致检查它们全部的參数是否合法有效。

举例来说。与文件I/O相关的系统调用必须检查文件描写叙述符是否有效。

与进程相关的函数必须检查提供的PID是否有效。必须检查每一个參数。保证它们不但合法有效,并且正确。


 
最重要的一种检查就是检查用户提供的指针是否有效。试想,假设一个进程能够给内核传递指针而又无须被检查,那么它就能够给出一个它根本就没有訪问权限的指针,哄骗内核去为它拷贝本不同意它訪问的数据,如原本属于其它进程的数据。

在接收一个用户空间的指针之前,内核必须保证:
²      指针指向的内存区域属于用户空间。进程决不能哄骗内核去读内核空间的数据。
²      指针指向的内存区域在进程的地址空间里。进程决不能哄骗内核去读其它进程的数据。
²      假设是读,该内存应被标记为可读。假设是写。该内存应被标记为可写。进程决不能绕过内存訪问限制。


 
内核提供了两个方法来完毕必须的检查和内核空间与用户空间之间数据的来回拷贝。注意。内核不管何时都不能轻率地接受来自用户空间的指针!这两个方法中必须有一个被调用。

为了向用户空间写入数据,内核提供了copy_to_user(),它须要三个參数。第一个參数是进程空间中的目的内存地址。

第二个是内核空间内的源地址。

最后一个參数是须要拷贝的数据长度(字节数)。
 
为了从用户空间读取数据,内核提供了copy_from_ user(),它和copy-to-User()相似。该函数把第二个參数指定的位置上的数据复制到第一个參数指定的位置上,拷贝的数据长度由第三个參数决定。


 
假设运行失败,这两个函数返回的都是没能完毕拷贝的数据的字节数。

假设成功,返回0。当出现上述错误时,系统调用返回标准-EFAULT。


 
注意copy_to_user()和copy_from_user()都有可能引起堵塞。当包括用户数据的页被换出到硬盘上而不是在物理内存上的时候,这样的情况就会发生。此时。进程就会休眠,直到缺页处理程序将该页从硬盘又一次换回物理内存。
 
3.5    系统调用的返回值
系统调用(在Linux中常称作syscalls)通常通过函数进行调用。

它们通常都须要定义一个或几个參数(输入)并且可能产生一些副作用。比如写某个文件或向给定的指针拷贝数据等等。

为防止和正常的返回值混淆,系统调用并不直接返回错误码,而是将错误码放入一个名为errno的全局变量中。

通经常使用一个负的返回值来表明错误。返回一个0值通常表明成功。假设一个系统调用失败,你能够读出errno的值来确定问题所在。通过调用perror()库函数,能够把该变量翻译成用户能够理解的错误字符串。
 
errno不同数值所代表的错误消息定义在errno.h中,你也能够通过命令"man 3 errno"来察看它们。

须要注意的是,errno的值仅仅在函数错误发生时设置,假设函数不错误发生,errno的值就无定义,并不会被置为0。另外,在处理errno前最好先把它的值存入还有一个变量。由于在错误处理过程中。即使像printf()这种函数出错时也会改变errno的值。
 
当然,系统调用终于具有一种明白的操作。

举例来说,如getpid()系统调用。依据定义它会返回当前进程的PID。内核中它的实现很easy:
asmlinkage long sys_ getpid(void)
{
    return current-> tgid;
}
 
上述的系统调用虽然很easy,但我们还是能够从中发现两个特别之处。首先,注意函数声明中的asmlinkage限定词,这是一个小戏法。用于通知编译器仅从栈中提取该函数的參数。全部的系统调用都须要这个限定词。其次,注意系统调用get_pid()在内核中被定义成sys_ getpid。

这是Linux中全部系统调用都应该遵守的命名规则
 
4       加入新系统调用
给Linux加入一个新的系统调用是件相对easy的工作。如何设计和实现一个系统调用是难题所在,而把它加到内核里却无须太多周折。让我们关注一下实现一个新的Linux系统调用所需的步骤。


 
实现一个新的系统调用的第一步是决定它的用途。

它要做些什么?每一个系统调用都应该有一个明白的用途。

在Linux中不提倡採用多用途的系统调用(一个系统调用通过传递不同的參数值来选择完毕不同的工作)。ioctl()就应该被视为一个反例。


 
新系统调用的參数、返回值和错误码又该是什么呢?系统调用的接口应该力求简洁。參数尽可能少。

设计接口的时候要尽量为将来多做考虑。

你是不是对函数做了不必要的限制?系统调用设计得越通用越好。

不要如果这个系统调用如今怎么用将来也一定就是这么用。

系统调用的目的可能不变,但它的使用方法却可能改变。

这个系统调用可移植吗?

别对机器的字节长度和字节序做如果。

当你写一个系统调用的时候,要时刻注意可移植性和健壮性。不但要考虑当前,还要为将来做打算。
 
当编写完一个系统调用后。把它注冊成一个正式的系统调用是件琐碎的工作:
在系统调用表的最后增加一个表项。

每种支持该系统调用的硬件体系都必须做这种工作。从0開始算起,系统调用在该表中的位置就是它的系统调用号。
对于所支持的各种体系结构,系统调用号都必须定义于<asm/unistd.h>中。
系统调用必须被编译进内核映象(不能被编译成模块)。这仅仅要把它放进kernel/下的一个相关文件里就能够。


 
让我们通过一个虚构的系统调用f00()来细致观察一下这些步骤。

首先,我们要把sys_foo增加到系统调用表中去。对于大多数体系结构来说,该表位干entry.s文件里。形式例如以下:
ENTRY(sys_ call_ table)
      ·long sys_ restart_ syscall/*0*/
      .long sys_ exit
      ·long sys_ fork
      ·long sys_ read
      .long sys_write
我们把新的系统调用加到这个表的末尾:
     .long sys_foo
尽管没有明白地指定编号,但我们增加的这个系统调用被依照次序分配给了283这个系统调用号。对于每种须要支持的体系结构,我们都必须将自己的系统调用加人到其系统调用表中去。每种体系结构不须要相应同样的系统调用号。
 
接下来,我们把系统调用号增加到<asm/unistd.h>中,它的格式例如以下:
/*本文件包括系统调用号*/
#define_ NR_ restart_ syscall
#define NR exit
#define NR fork
#define NR read
#define NR write
#define NR- mq getsetattr 282
然后,我们在该列表中增加以下这行:
#define_ NR_ foo 283
 
最后,我们来实现f00()系统调用。不管何种配置,该系统调用都必须编译到核心的内核映象中去,所以我们把它放进kernel/sys.c文件里。你也能够将其放到与其功能联系最紧密的代码中去
 
asmlinkage long sys-foo(void)
{
return THREAD SIZE
)
就是这样!严格说来,如今就能够在用户空间调用f00()系统调用了。
 
建立一个新的系统调用很easy。但却绝不提倡这么做。通常模块能够更好的取代新建一个系统调用。
 
5       訪问系统调用
5.1    系统调用上下文
内核在运行系统调用的时候处于进程上下文。

current指针指向当前任务,即引发系统调用的那个进程。


 
在进程上下文中,内核能够休眠而且能够被抢占。这两点都非常重要。首先。能够休眠说明系统调用能够使用内核提供的绝大部分功能。

休眠的能力会给内核编程带来极大便利。在进程上下文中能够被抢占,事实上表明,像用户空间内的进程一样。当前的进程相同能够被其它进程抢占。由于新的进程能够使用相同的系统调用。所以必须小心。保证该系统调用是可重人的。

当然。这也是在对称多处理中必须相同关心的问题。
 
当系统调用返回的时候,控制权仍然在system_call()中。它终于会负责切换到用户空间并让用户进程继续运行下去。


 
5.2    系统调用訪问演示样例
操作系统使用系统调用表将系统调用编号翻译为特定的系统调用。系统调用表包括有实现每一个系统调用的函数的地址。比如,read() 系统调用函数名为 sys_read。read() 系统调用编号是 3,所以 sys_read() 位于系统调用表的第四个条目中(由于系统调用起始编号为0)。从地址 sys_call_table + (3 * word_size) 读取数据,得到 sys_read() 的地址。
 
找到正确的系统调用地址后,它将控制权转交给那个系统调用。

我们来看定义 sys_read() 的位置,即 fs/read_write.c 文件。这个函数会找到关联到 fd 编号(传递给 read() 函数的)的文件结构体。那个结构体包括指向用来读取特定类型文件数据的函数的指针。进行一些检查后,它调用与文件相关的 read() 函数,来真正从文件里读取数据并返回。

与文件相关的函数是在其它地方定义的 —— 比方套接字代码、文件系统代码,或者设备驱动程序代码。这是特定内核子系统终于与内核其它部分协作的一个方面。
 
读取函数结束后,从 sys_read() 返回,它将控制权切换给 ret_from_sys。它会去检查那些在切换回用户空间之前须要完毕的任务。假设没有须要做的事情,那么就恢复用户进程的状态。并将控制权交还给用户程序。
5.3    从用户空间直接訪问系统调用
通常。系统调用靠C库支持。

用户程序通过包括标准头文件并和C库链接,就能够使用系统调用(或者调用库函数,再由库函数实际调用)。但假设你只写出系统调用,glibc库恐怕并不提供支持。值得庆幸的是,Linux本身提供了一组宏,用于直接对系统调用进行訪问。它会设置好寄存器并调用陷人指令。这些宏是_syscalln(),当中n的范围从0到6。代表须要传递给系统调用的參数个数,这是因为该宏必须了解究竟有多少參数依照什么次序压入寄存器。

举个样例,open()系统调用的定义是:
long open(const char *filename, int flags, int mode)
而不靠库支持,直接调用此系统调用的宏的形式为:
#define NR_ open 5
syscall3(long, open, const char*。filename, int, flags, int, mode)
这样,应用程序就能够直接使用open()
 
对于每一个宏来说,都有2+ n个參数。第一个參数相应着系统调用的返回值类型。第二个參数是系统调用的名称。再以后是依照系统调用參数的顺序排列的每一个參数的类型和名称。

_NR_ open在<asm/unistd.h>中定义,是系统调用号。该宏会被扩展成为内嵌汇编的C函数。由汇编语言运行前一节所讨论的步骤,将系统调用号和參数压入寄存器并触发软中断来陷入内核。

调用open()系统调用直接把上面的宏放置在应用程序中就能够了。
 
让我们写一个宏来使用前面编写的foo()系统调用,然后再写出測试代码炫耀一下我们所做的努力。
#define NR foo 283
_sysca110(long, foo)
int main()
{
long stack size;
stack_ size=foo();
printf("The kernel stack
size is 81d\n",stack_ size);
return;
}

6 实际使用的注意

(1)系统调用是须要提前编译固化到内核中的。并且须要官方分配一个系统调用号

(2)须要将系统调用注冊到支持的每一种体系结构中

(3)系统调用一般不能在脚本中直接訪问

(4)尽量避免新建系统调用,可用创建设备结点的方法取代。

posted @ 2017-05-21 17:02  yfceshi  阅读(6590)  评论(0编辑  收藏  举报