Linux系统调用详解(实现机制分析)

共 11345字,需浏览 23分钟

 ·

2020-11-02 08:46

作者:linux服务器开发

来源:SegmentFault 思否社区




系统调用概述


计算机系统的各种硬件资源是有限的,在现代多任务操作系统上同时运行的多个进程都需要访问这些资源,为了更好的管理这些资源进程是不允许直接操作的,所有对这些资源的访问都必须有操作系统控制。也就是说操作系统是使用这些资源的唯一入口,而这个入口就是操作系统提供的系统调用(System Call)。在linux中系统调用是用户空间访问内核的唯一手段,除异常和陷入外,他们是内核唯一的合法入口。


一般情况下应用程序通过应用编程接口API,而不是直接通过系统调用来编程。在Unix世界,最流行的API是基于POSIX标准的。


操作系统一般是通过中断从用户态切换到内核态。中断就是一个硬件或软件请求,要求CPU暂停当前的工作,去处理更重要的事情。比如,在x86机器上可以通过int指令进行软件中断,而在磁盘完成读写操作后会向CPU发起硬件中断。


中断有两个重要的属性,中断号和中断处理程序。中断号用来标识不同的中断,不同的中断具有不同的中断处理程序。在操作系统内核中维护着一个中断向量表(Interrupt Vector Table),这个数组存储了所有中断处理程序的地址,而中断号就是相应中断在中断向量表中的偏移量。


一般地,系统调用都是通过软件中断实现的,x86系统上的软件中断由int $0x80指令产生,而128号异常处理程序就是系统调用处理程序system_call(),它与硬件体系有关,在entry.S中用汇编写。接下来就来看一下Linux下系统调用具体的实现过程。




为什么需要系统调用


linux内核中设置了一组用于实现系统功能的子程序,称为系统调用。系统调用和普通库函数调用非常相似,只是系统调用由操作系统核心提供,运行于内核态,而普通的函数调用由函数库或用户自己提供,运行于用户态。


一般的,进程是不能访问内核的。它不能访问内核所占内存空间也不能调用内核函数。CPU硬件决定了这些(这就是为什么它被称作“保护模式”)。


为了和用户空间上运行的进程进行交互,内核提供了一组接口。透过该接口,应用程序可以访问硬件设备和其他操作系统资源。这组接口在应用程序和内核之间扮演了使者的角色,应用程序发送各种请求,而内核负责满足这些请求(或者让应用程序暂时搁置)。实际上提供这组接口主要是为了保证系统稳定可靠,避免应用程序肆意妄行,惹出大麻烦。


系统调用在用户空间进程和硬件设备之间添加了一个中间层。该层主要作用有三个:


1、它为用户空间提供了一种统一的硬件的抽象接口。比如当需要读些文件的时候,应用程序就可以不去管磁盘类型和介质,甚至不用去管文件所在的文件系统到底是哪种类型。

2、系统调用保证了系统的稳定和安全。作为硬件设备和应用程序之间的中间人,内核可以基于权限和其他一些规则对需要进行的访问进行裁决。举例来说,这样可以避免应用程序不正确地使用硬件设备,窃取其他进程的资源,或做出其他什么危害系统的事情。

3、每个进程都运行在虚拟系统中,而在用户空间和系统的其余部分提供这样一层公共接口,也是出于这种考虑。如果应用程序可以随意访问硬件而内核又对此一无所知的话,几乎就没法实现多任务和虚拟内存,当然也不可能实现良好的稳定性和安全性。在Linux中,系统调用是用户空间访问内核的惟一手段;除异常和中断外,它们是内核惟一的合法入口。




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设计中的一大亮点。大部分的编程问题都可以被切割成两个部分:“需要提供什么功能”(机制)和“怎样实现这些功能”(策略)。




区别


api是函数的定义,规定了这个函数的功能,跟内核无直接关系。而系统调用是通过中断向内核发请求,实现内核提供的某些服务。




联系


一个api可能会需要一个或多个系统调用来完成特定功能。通俗点说就是如果这个api需要跟内核打交道就需要系统调用,否则不需要。


程序员调用的是API(API函数),然后通过与系统调用共同完成函数的功能。


因此,API是一个提供给应用程序的接口,一组函数,是与程序员进行直接交互的。


系统调用则不与程序员进行交互的,它根据API函数,通过一个软中断机制向内核提交请求,以获取内核服务的接口。


并不是所有的API函数都一一对应一个系统调用,有时,一个API函数会需要几个系统调用来共同完成函数的功能,甚至还有一些API函数不需要调用相应的系统调用(因此它所完成的不是内核提供的服务)


需要C/C++ Linux高级服务器架构师学习资料后台加群812855908(包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等)





系统调用的实现原理


基本机制


前文已经提到了Linux下的系统调用是通过0x80实现的,但是我们知道操作系统会有多个系统调用(Linux下有319个系统调用),而对于同一个中断号是如何处理多个不同的系统调用的?最简单的方式是对于不同的系统调用采用不同的中断号,但是中断号明显是一种稀缺资源,Linux显然不会这么做;还有一个问题就是系统调用是需要提供参数,并且具有返回值的,这些参数又是怎么传递的?也就是说,对于系统调用我们要搞清楚两点:


  1. 系统调用的函数名称转换。
  2. 系统调用的参数传递。

首先看第一个问题。实际上,Linux中每个系统调用都有相应的系统调用号作为唯一的标识,内核维护一张系统调用表,sys_call_table,表中的元素是系统调用函数的起始地址,而系统调用号就是系统调用在调用表的偏移量。在x86上,系统调用号是通过eax寄存器传递给内核的。比如fork()的实现:

用户空间的程序无法直接执行内核代码。它们不能直接调用内核空间中的函数,因为内核驻留在受保护的地址空间上。如果进程可以直接在内核的地址空间上读写的话,系统安全就会失去控制。所以,应用程序应该以某种方式通知系统,告诉内核自己需要执行一个系统调用,希望系统切换到内核态,这样内核就可以代表应用程序来执行该系统调用了。

通知内核的机制是靠软件中断实现的。首先,用户程序为系统调用设置参数。其中一个参数是系统调用编号。参数设置完成后,程序执行“系统调用”指令。x86系统上的软中断由int产生。这个指令会导致一个异常:产生一个事件,这个事件会致使处理器切换到内核态并跳转到一个新的地址,并开始执行那里的异常处理程序。此时的异常处理程序实际上就是系统调用处理程序。它与硬件体系结构紧密相关。

新地址的指令会保存程序的状态,计算出应该调用哪个系统调用,调用内核中实现那个系统调用的函数,恢复用户程序状态,然后将控制权返还给用户程序。系统调用是设备驱动程序中定义的函数最终被调用的一种方式。

从系统分析的角度,linux的系统调用涉及4个方面的问题。

响应函数sys_xxx


响应函数名以“sys_”开头,后跟该系统调用的名字。

例如:

系统调用fork()的响应函数是sys_fork()(见Kernel/fork.c),
exit()的响应函数是sys_exit()(见kernel/fork.)。



系统调用表与系统调用号-=>数组与下标


文件include/asm/unisted.h为每个系统调用规定了唯一的编号。


在我们系统中/usr/include/asm/unistd_32.h,可以通过find / -name unistd_32.h -print查找)

而内核中的头文件路径不同的内核版本以及不同的发行版,文件的存储结构可能有所区别


假设用name表示系统调用的名称,那么系统调用号与系统调用响应函数的关系是:以系统调用号_NR_name作为下标,可找出系统调用表sys_call_table(见arch/i386/kernel/entry.S)中对应表项的内容,它正好是该系统调用的响应函数sys_name的入口地址。

系统调用表sys_call_table记录了各sys_name函数在表中的位置,共190项。有了这张表,就很容易根据特定系统调用


在表中的偏移量,找到对应的系统调用响应函数的入口地址。系统调用表共256项,余下的项是可供用户自己添加的系统调用空间。

在Linux中,每个系统调用被赋予一个系统调用号。这样,通过这个独一无二的号就可以关联系统调用。当用户空间的进程执行一个系统调用的时候,这个系统调用号就被用来指明到底是要执行哪个系统调用。进程不会提及系统调用的名称。

系统调用号相当关键,一旦分配就不能再有任何变更,否则编译好的应用程序就会崩溃。Linux有一个“未实现”系统调用sys_ni_syscall(),它除了返回一ENOSYS外不做任何其他工作,这个错误号就是专门针对无效的系统调用而设的。

因为所有的系统调用陷入内核的方式都一样,所以仅仅是陷入内核空间是不够的。因此必须把系统调用号一并传给内核。在x86上,系统调用号是通过eax寄存器传递给内核的。在陷人内核之前,用户空间就把相应系统调用所对应的号放入eax中了。这样系统调用处理程序一旦运行,就可以从eax中得到数据。其他体系结构上的实现也都类似。

内核记录了系统调用表中的所有已注册过的系统调用的列表,存储在sys_call_table中。它与体系结构有关,一般在entry.s中定义。这个表中为每一个有效的系统调用指定了惟一的系统调用号。sys_call_table是一张由指向实现各种系统调用的内核函数的函数指针组成的表:

system_call()函数通过将给定的系统调用号与NR_syscalls做比较来检查其有效性。如果它大于或者等于NR syscalls,该函数就返回一ENOSYS。否则,就执行相应的系统调用。


call *sys_ call-table(,%eax, 4)1

由于系统调用表中的表项是以32位(4字节)类型存放的,所以内核需要将给定的系统调用号乘以4,然后用所得的结果在该表中查询其位置



进程的系统调用命令转换为INT 0x80中断的过程


宏定义_syscallN()见include/asm/unisted.h)用于系统调用的格式转换和参数的传递。N取0~5之间的整数。

参数个数为N的系统调用由_syscallN()负责格式转换和参数传递。系统调用号放入EAX寄存器,启动INT 0x80后,规定返回值送EAX寄存器。



系统调用功能模块的初始化


对系统调用的初始化也就是对INT 0x80的初始化。

系统启动时,汇编子程序setup_idt(见arch/i386/kernel/head.S)准备了1张256项的idt表,由start_kernel()(见init/main.c),trap_init()(见arch/i386/kernel/traps.c)调用的C语言宏定义set_system_gate(0x80,&system_call)(见include/asm/system.h)设置0x80号软中断的服务程序为 system_call(见arch/i386/kernel/entry.S), system.call就是所有系统调用的总入口。



内核如何为各种系统调用服务


当进程需要进行系统调用时,必须以C语言函数的形式写一句系统调用命令。该命令如果已在某个头文件中由相应的_syscallN()展开,则用户程序必须包含该文件。当进程执行到用户程序的系统调用命令时,实际上执行了由宏命令_syscallN()展开的函数。系统调用的参数 由各通用寄存器传递,然后执行INT 0x80,以内核态进入入口地址system_call。



ret_from_sys_call


以ret_from_sys_call入口的汇编程序段在linux进程管理中起到了十分重要的作用。

所有系统调用结束前以及大部分中断服务返回前,都会跳转至此处入口地址。该段程序不仅仅为系统调用服务,它还处理中断嵌套、CPU调度、信号等事务。



内核如何为系统调用的参数传递参数




参数传递


除了系统调用号以外,大部分系统调用都还需要一些外部的参数输人。所以,在发生异常的时候,应该把这些参数从用户空间传给内核。最简单的办法就是像传递系统调用号一样把这些参数也存放在寄存器里。在x86系统上,ebx, ecx, edx, esi和edi按照顺序存放前五个参数。需要六个或六个以上参数的情况不多见,此时,应该用一个单独的寄存器存放指向所有这些参数在用户空间地址的指针。

给用户空间的返回值也通过寄存器传递。在x86系统上,它存放在eax寄存器中。接下来许多关于系统调用处理程序的描述都是针对x86版本的。但不用担心,所有体系结构的实现都很类似。



参数验证


系统调用必须仔细检查它们所有的参数是否合法有效。举例来说,与文件I/O相关的系统调用必须检查文件描述符是否有效。与进程相关的函数必须检查提供的PID是否有效。必须检查每个参数,保证它们不但合法有效,而且正确。

最重要的一种检查就是检查用户提供的指针是否有效。试想,如果一个进程可以给内核传递指针而又无须被检查,那么它就可以给出一个它根本就没有访问权限的指针,哄骗内核去为它拷贝本不允许它访问的数据,如原本属于其他进程的数据。在接收一个用户空间的指针之前,内核必须保证:

  • 指针指向的内存区域属于用户空间。进程决不能哄骗内核去读内核空间的数据。
  • 指针指向的内存区域在进程的地址空间里。进程决不能哄骗内核去读其他进程的数据。
  • 如果是读,该内存应被标记为可读。如果是写,该内存应被标记为可写。进程决不能绕过内存访问限制。

内核提供了两个方法来完成必须的检查和内核空间与用户空间之间数据的来回拷贝。注意,内核无论何时都不能轻率地接受来自用户空间的指针!这两个方法中必须有一个被调用。为了向用户空间写入数据,内核提供了copy_to_user(),它需要三个参数。第一个参数是进程空间中的目的内存地址。第二个是内核空间内的源地址。最后一个参数是需要拷贝的数据长度(字节数)。

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

如果执行失败,这两个函数返回的都是没能完成拷贝的数据的字节数。如果成功,返回0。当出现上述错误时,系统调用返回标准-EFAULT。

注意copy_to_user()和copy_from_user()都有可能引起阻塞。当包含用户数据的页被换出到硬盘上而不是在物理内存上的时候,这种情况就会发生。此时,进程就会休眠,直到缺页处理程序将该页从硬盘重新换回物理内存。



系统调用的返回值


系统调用(在Linux中常称作syscalls)通常通过函数进行调用。它们通常都需要定义一个或几个参数(输入)而且可能产生一些副作用,例如写某个文件或向给定的指针拷贝数据等等。为防止和正常的返回值混淆,系统调用并不直接返回错误码,而是将错误码放入一个名为errno的全局变量中。通常用一个负的返回值来表明错误。返回一个0值通常表明成功。如果一个系统调用失败,你可以读出errno的值来确定问题所在。通过调用perror()库函数,可以把该变量翻译成用户可以理解的错误字符串。

errno不同数值所代表的错误消息定义在errno.h中,你也可以通过命令”man 3 errno”来察看它们。需要注意的是,errno的值只在函数发生错误时设置,如果函数不发生错误,errno的值就无定义,并不会被置为0。另外,在处理errno前最好先把它的值存入另一个变量,因为在错误处理过程中,即使像printf()这样的函数出错时也会改变errno的值。

当然,系统调用最终具有一种明确的操作。举例来说,如getpid()系统调用,根据定义它会返回当前进程的PID。内核中它的实现非常简单:

asmlinkage long sys_ getpid(void) {
    return current-> tgid;
}

上述的系统调用尽管非常简单,但我们还是可以从中发现两个特别之处。首先,注意函数声明中的asmlinkage限定词,这是一个小戏法,用于通知编译器仅从栈中提取该函数的参数。所有的系统调用都需要这个限定词。其次,注意系统调用get_pid()在内核中被定义成sys_ getpid。这是Linux中所有系统调用都应该遵守的命名规则。



访问系统调用




系统调用上下文


内核在执行系统调用的时候处于进程上下文。current指针指向当前任务,即引发系统调用的那个进程。

在进程上下文中,内核可以休眠并且可以被抢占。这两点都很重要。首先,能够休眠说明系统调用可以使用内核提供的绝大部分功能。休眠的能力会给内核编程带来极大便利。在进程上下文中能够被抢占,其实表明,像用户空间内的进程一样,当前的进程同样可以被其他进程抢占。因为新的进程可以使用相同的系统调用,所以必须小心,保证该系统调用是可重人的。当然,这也是在对称多处理中必须同样关心的问题。

当系统调用返回的时候,控制权仍然在system_call()中,它最终会负责切换到用户空间并让用户进程继续执行下去。



系统调用访问示例


操作系统使用系统调用表将系统调用编号翻译为特定的系统调用。系统调用表包含有实现每个系统调用的函数的地址。例如,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。它会去检查那些在切换回用户空间之前需要完成的任务。如果没有需要做的事情,那么就恢复用户进程的状态,并将控制权交还给用户程序。



从用户空间直接访问系统调用


通常,系统调用靠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在中定义,是系统调用号。该宏会被扩展成为内嵌汇编的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;
}



添加系统调用




通过修改内核源代码添加系统调用




linux-2.6.*


通过以上分析linux系统调用的过程,将自己的系统调用加到内核中就是一件容易的事情。下面介绍一个实际的系统调用,并把它加到内核中去。要增加的系统调用是:inttestsyscall(),其功能是在控制终端屏幕上显示hello world,执行成功后返回0。



编写int testsyscall()系统调用–响应函数


编写一个系统调用意味着要给内核增加1个函数,将新函数放入文件kernel/sys.c中。新函数代码如下:

asmlingkage sys_testsyscall()

    print("hello worldn");    
    return 0;
 }



添加系统调用号


编写了新的系统调用过程后,下一项任务是使内核的其余部分知道这一程序的存在,然后重建包含新的系统调用的内核。为了把新的函数连接到已有的内核中去, 需要编辑2个文件:
1).inculde/asm/unistd.h在这个文件中加入

#define_NR_testsyscall 191



系统调用表中添加对应项


2).are/i386/kernel/entry.s这个文件用来对指针数组初始化,在这个文件中增加一行:

.long SYMBOL_NAME(_sys_tsetsycall)

将.rept NR_syscalls-190改为NR_SYSCALLS-191,然后重新编译和运行新内核。



使用新的系统调用


在保证的C语言库中没有新的系统调用的程序段,必须自己建立其代码如下

#inculde

_syscall0(int,testsyscall)

main()
{
    tsetsyscall();
}

在这里使用了_syscall0宏指令,宏指令本身在程序中将扩展成名为syscall()的函数,它在main()函数内部加以调用。

在testsyscall()函数中, 预处理程序产生所有必要的机器指令代码,包括用系统调用参数值加载相应的cpu寄存器, 然后执行int 0x80中断指令。



linux-3.*


在linux-3.8.4/kernel/sys.c 文件末尾添加新的系统调用函数如:

asmlinkage int sys_mycall(int number) {    
    printk("这是我添加的第一个系统调用");    
    return number; }

在arch/x86/syscall_32.tbl下找到unused 223号调用然后替换如:

223 i386 mycall sys_mycall

如果是64位系统,在arch/x86/syscalls/syscall_64.tbl下找到313号系统调用,然后在其下面加上314号自己的中断如:

`314 common mycall sys_mycall



利用内核模块添加系统调用


模块是内核的一部分,但是并没有被编译到内核里面去。它们被分别编译并连接成一组目标文件, 这些文件能被插入到正在运行的内核,或者从正在运行的内核中移走。内核模块至少必须有2个函数:init_module和cleanup_module。
第一个函数是在把模块插入内核时调用的;

第二个函数则在删除该模块时调用。由于内核模块是内核的一部分,所以能访问所有内核资源。根据对linux系统调用机制的分析,

如果要增加系统调用,可以编写自己的函数来实现,然后在sys_call_table表中增加一项,使该项中的指针指向自己编写的函数,

就可以实现系统调用。下面用该方法实现在控制终端上打印“hello world” 的系统调用testsyscall()。



编写系统调用内核模块


#inculde(linux/kernel.h)

#inculde(linux/module.h)

#inculde(linux/modversions.h)

#inculde(linux/sched.h)

 #inculde(asm/uaccess.h)

#define_NR_testsyscall 191

extern viod *sys_call+table[];

asmlinkage int testsyscall() { 
    printf("hello worldn");

    return 0;

}

int init_module() { 
    sys_call_table[_NR_tsetsyscall]=testsyscall;
    printf("system call testsyscall() loaded successn");

    return 0;
}

void cleanup_module() {

}



使用新的系统调用


#define_NR_testsyscall 191

_syscall0(int,testsyscall)

main()
{
    testsyscall();
}



内核Linux系统调用的列表


以下是Linux系统调用的一个列表,包含了大部分常用系统调用和由系统调用派生出的的函数。

进程控制




文件系统控制


文件读写操作


文件系统操作


系统控制


内存管理


网络管理


socket控制


用户管理


进程间通信


信号


消息


管道


信号量


共享内存




点击左下角阅读原文,到 SegmentFault 思否社区 和文章作者展开更多互动和交流。

- END -

浏览 29
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报