第2章 添加系统调用
实验目的
学习Linux内核的系统调用,理解、掌握Linux系统调用的实现框架、用户界面、参数传递、进入/返回过程。
实验内容
本实验分两步走。
第一步,在系统中添加一个不用传递参数的系统调用;执行这个系统调用,使用户的uid等于0。显然,这不是一个有实际意义的系统调用。我们的目的并不是实用不实用,而是通过最简单的例子,帮助熟悉对系统调用的添加过程,为下面我们添加更加复杂的系统调用打好基础。
第二步,用kernel module机制,实现系统调用gettimeofday的简化版,返回调用时刻的日期和时间。
实验指导
1.一个简单的例子
在我们开始学习系统调用这一章之前,让我们先来看一个简单的例子。就好像哪个经典的编程书上都会使用到的例子一样:
1: int main(){
2: printf(“Hello World!\n”);
3: }
我们也准备了一个例子给你:
1: #include
2: int main(){
3: int i = getuid();
4: printf(“Hello World! This is my uid: %d\n”, i);
5: }
这就是一个最简单的系统调用的例子。与上面那个传统的例子相比,在这个例子中多了2行,他们的作用分别是:
第一行:包括unistd.h这个头文件。所有用到系统调用的程序都需要包括它,因为系统调用中需要的参数(例如,本例中的“__NR_getuid”,以及_syscall0()函数)包括在unistd.h中;根据C语言的规定,include
第三行:进行getuid()系统调用,并将返回值赋给变量i。
好了,这就是最简单的一个使用了系统调用的程序,现在你可以在你的机器上试一试它。然后我们一起进入到系统调用的神秘世界中去。
2.系统调用基础知识
2.1 系统调用是什么
系统调用是内核提供的,功能十分强大的一系列函数。它们在内核中实现,然后通过一定的方式(例如,软中断)呈现给用户,是用户程序与内核交互的一个接口。可以这么说,没有了系统调用,我们就不可能编写出十分强大的用户程序,因为你失去了内核的支持。由此可见系统调用在一个系统中的重要性。
2.2 为什么需要系统调用
你可能会问,我们为什么需要系统调用?除了上面说到的原因外(为用户程序提供强大的系统支持),还有别的更重要的原因吗?当然有,这些原因就是安全和效率。
Linux 运行在两个模式(mode)下(实际上所有的类UNIX系统都是如此):用户态(user mode,或用户模式)和内核态(kernel mode,或内核模式)。关于这两个模式的具体情况,我们稍后介绍。总的说来,就是在内核态中可以运行一些特权指令,然后按照内核的特权方式进行内存的读写检查(例如在INTEL的CPU中,根据代码段寄存器cs和数据段寄存器ds),当然还有堆栈也切换到内核堆栈(例如在INTEL的CPU中,堆栈寄存器ss变为内核堆栈)。区分用户态与内核态的主要目的是出于安全的考虑,使得用户态运行的程序不能“擅自”访问某些敏感的内核变量,内核函数。用户态的程序只有通过中断门(gate)陷入(trap)到系统内核中去,才能执行一些具有特权的内核函数。
图2-1 模式切换
系统调用是用户程序与内核的接口。通过系统调用进程,可由用户态转入内核谈态,在内核态下完成相应的服务;之后,再返回到用户态。这种实现方式必然跨越我们刚才提及的两个模式:内核态与用户态。用户程序在用户态调用系统调用,通过门机制,系统进行模式切换(mode switch),进入内核态,执行相应的系统调用代码,返回(mode switch)用户态。我们可以画一个简图表示这个过程(如图2-1)。
至于效率的说法,这个涉及到操作系统的总体设计。我们都知道,如果没有操作系统,每个应用程序就将直接面对系统硬件。那么,如果想要运行你的程序,就得靠自己从面向底层硬件的代码编起。如果每个人都需要这么做,那是多么枯燥与乏味的一件事;而且,没有一定的计算机专业功底,真还做不来。幸好,操作系统替我们把这些事情都做了,它把硬件做了一个封装,给我们提供了一套统一的接口,这些接口就是系统调用。显然,它提高了我们写程序的效率。系统调用在这个模型中充当的角色就是一个接口,外面由用户程序(包括程序库)调用,内部连接内核的其它部分,共同实现用户的请求。
你可能会问:内核究竟是什么东西?在UNIX界,有一个关于操作系统的标准:POSIX(Portable Operating System Interface),其中有一节POSIX.1专门规定了系统调用的接口标准。当然,这里所说的操作系统指的是内核部分(kernel),这也是传统意义上的操作系统(区别于微软所提的操作系统概念,在微软看来,图形界面,IE浏览器都算是操作系统的一部分。作者同意这个观点,因为操作系统的目标之一,就是尽量地,持续不断地方便用户)。只要操作系统的实现遵循POSIX标准,那么程序在这些操作系统之间的移植就变得非常容易,有些甚至根本不用改动。Linux是遵循POSIX标准的操作系统。因此,很多Unix程序可以轻易地移植到Linux的世界中来。(顺便说一句,很多人都认为,这也是Linux之所以取得成功的重要原因。)
图2-2 内核的抽象数据结构
把内核这个概念抽象出来,可以得到一个简明的图像(如图2-2,内核的抽象数据结构)。
在我们进入下面复杂的代码分析之前,我们把一些基本的概念、要点回顾一下。每位读者的基础都不一样,如果你觉得没有必要,可以跳过这几个小节。当然,如果你能仔细跟着我们的思路,相信会对你帮助不少。
2.2.1运行模式(mode)、地址空间(space)、上下文(context)
运行模式:Intel 80386系列处理器定义了实模式和保护模式(注意,它们是硬件层面的概念,而操作系统的用户模式、内核模式,或者用户态、内核态,是系统软件层面的概念)。在实模式中,只能使用20位的寻址,相当于只能访问1M的内存。实模式中没有安全保护,并且只能使用实地址访问内存。机器刚开始启动起来的时候,就是处于这种模式下。而在保护模式中,可以使用i386系列处理器的很多高级特性,如段页机制,32位虚寻址等。这为Linux实现多任务,内存保护和基于页的管理机制提供了硬件基础。同时,保护模式下还提供四个特权级。Linux使用了其中的两个:特权级0和特权级3。特权级0就是我们平常所说的“内核态”,特权级3就是我们平常所说的“用户态”。之所以采用两个不同的特权级,主要是为了给操作系统提供保护。CPU处于特权级0时可以执行一些特权指令(比如开关中断),这些指令对于操作系统的实现是非常重要的。用户不能执行这些指令,也不能自行把特权级从3变为0,否则都会产生异常(异常的概念将在下面讲到)。所有这些切换必须通过精心设计的系统调用接口(当然硬件中断也可以)才能进入内核态。内核态与用户态分别使用自己的堆栈。因此,当发生系统调用,需要进行模式切换的时候,堆栈也要进行相应的切换。这一点我们很快将会看到。
地址空间:上面讲了i386对几个特权级的区分,区分的最终目的是为了对地址空间的保护。用户进程不应该能够访问所有的地址空间,只有通过系统调用这种受严格控制的接口,进程才能进入核心态并访问到受保护的那一部分地址空间的数据。同时,进程与进程之间的地址空间也不能够随便互访,它们之间应该是透明的。也就是说,一个进程应该感受不到其他进程的存在,在CPU的占用上是这样,在对内存的使用上也是如此。那么,怎么样实现这样一种隔离保护与对进程的“欺骗”呢?这就需要提供一种机制来实现同一进程在不同地址空间上的映射,以及不同进程之间地址空间的保护。在Linux中,由于有了i386硬件上的支持,通过虚拟存储管理机制(即虚存),很好实现了上面所讨论的要求。在Linux的虚存管理机制下,一般进程所使用的地址不直接对应物理的存储单元,而是都有自己的虚存空间,对虚拟地址的引用通过地址转换机制转换成为对物理地址的引用。同时,由于每个进程的地址空间通过地址转换机制映射到不同的物理存储页面上,进程能面对的总是虚拟的地址,这样就保证了进程所能访问的总是自己的虚拟地址,而不能访问或修改其它进程的地址空间,因为它根本看不到。进程因为这样的虚存管理机制而被“蒙骗”了。
每个进程的虚拟地址空间可以划分为两个部分:用户空间和内核空间。在用户态下只能访问用户空间;而在核心态下,既可以访问用户空间,又可以访问内核空间。内核空间在每个进程的虚拟地址空间中都是固定的(虚拟地址为3G~4G的地址空间),而且由于系统中只有一个内核实例在运行,因此所有进程的内核空间都映射到单一内核地址空间。内核中维护全局数据结构和每个进程的一些对象信息,后者包括的信息使得内核可以访问任何进程的地址空间。通过地址转换机制,进程可以直接访问进程本身的地址空间(通过内存管理单元MMU),而通过一些特殊的方法,也可以访问到其它进程的地址空间。
尽管所有进程都共享内核,但是内核空间是受保护的,进程在用户态无法访问。进程如果需要访问内核,则必须通过系统调用接口。进程调用一个系统调用时,通过执行一些特殊的指令(int 0x80指令)使系统陷入到内核,并将控制权交给内核,由内核替代进程完成操作。系统调用完成后,内核执行另一组特征指令(iret指令)将系统返回到用户态,控制权返回给进程。
上下文:上下文简单说来就是一个环境,相对于进程而言,就是进程执行时的环境。具体来说就是各个变量和数据,包括所有的寄存器变量,进程打开的文件,内存信息等。
一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。
l 用户级上下文:正文、数据、用户栈以及共享存储区;
l 寄存器上下文:通用寄存器、程序寄存器(IP)、处理机状态寄存器(EFLAGS)、栈指针(ESP);
l 系统级上下文:进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、 pgd、pmd、pte等)、内核栈等。
全部的上下文信息组成了一个进程的运行环境。从某个角度上来说,进程就是上下文集合的一个抽象概念。当发生进程调度,导致进程切换时,进程的运行环境也应及时切换,这就是上下文切换(context switch)。操作系统必须对上面提到的全部上下文信息进行切换,新调度的进程才能运行。而系统调用进行的是模式切换(mode switch)。模式切换与进程切换比较起来,要容易和节省时间得多,因为模式切换最主要的任务只是进行寄存器上下文的切换。关于这一点,将在模式切换的具体代码中得到印证。
2.2.2 段页机制,门,描述符
对于段页机制、门和描述符,本章用的不是太多,因此只对其中比较重要的部分进行简述。关于段页机制的详尽的描述,可以参考Intel提供的资料,在它的网站上有下载。
分段机制和分页机制是两种广泛使用的地址转换技术,虽然在Linux中更加偏向于使用页机制,但是分段是进入保护模式后i386硬件机制所规定的,无法避免。在i386中,由于分段和分页机制的存在,因此使用两级地址转换,如图2-3(两级地址转换机制)所示。
图2-3 两级地址转换机制
两级地址转换都在很大程度上依赖于硬件与操作系统的紧密合作。硬件除了使用自己的寄存器外,还使用驻留在内存的描述符表、页表等数据结构,来进行地址的转换。这些存储在内存的数据结构,只允许操作系统访问修改,普通的应用程序面对的只是逻辑地址一级,其它的地址转换全由操作系统和硬件接管,应用程序根本不能觉察这一个过程。
逻辑地址怎样转换为线性地址
在i386中,逻辑地址是这么构成的:
图2-4 逻辑地址
其中选择子(selector)就是我们经常说的CS、DS、ES、FS、SS、GS等。这些16位的选择子又分为三部分:索引部分、TI部分和RPL(当前特权级)部分。由当前特权级RPL(可以为0或3)决定当前进程访问相应段的权限;由TI部分为0或者为1来决定当前进程是选择GDT(Global Descriptor Table,全局描述符表)还是选择LDT(Local Descriptor Table,局部描述符表);再由索引部分决定深入GDT或者LDT的偏移。GDT(LDT)其实相当于一个数组,每个元素是一个8个字节的描述符,每个描述符都描述一个相应的段(系统代码段,系统数据段,用户代码段,用户数据段,堆栈段等)。由一个选择子唯一选择一个描述符(这就是“选择子”这个名称的由来了),然后这个描述符描述了一个特定的段(比如用户代码段)的段基地址,段的最大偏移,段的所有存取权限等(这是“描述符”这个名称的由来)。32位偏移(OFFSET)部分就是对这个具体段的偏移。这样,由这个逻辑地址就唯一地确定了一个32位的线性地址。整个过程如图2-5(逻辑地址到线性地址的转换)。
图2-5 逻辑地址到线性地址的转换
l GDTR是系统寄存器,存放着GDT的基地址(物理地址);同样有一个LDTR系统寄存器,存放着LDT的基地址(物理地址)
l TI=0选择GDT, TI=1选择LDT
l 每个段描述符8个字节
线性地址怎样转换为物理地址
线性地址转换为物理地址,是在分页机制中完成的。运行在i386机器上的Linux在分页机制中只采用了两级分页。分页机制与分段机制不同,段机制利用描述符,描述一个可大可小的存储块。分页机制管理的则是固定大小的内存页面(在i386中通常为4K)。同时,它把整个物理空间和线性空间都看成是由一个一个的页所组成(分别叫做页帧frame和页面page)。这样,通过分页机制,可以把线性空间的一个页面映射到物理空间的一个页帧。关于Linux下的分页机制,我们还会在第4章中详细讲述。
简单地说来,分页机制中,每一个线性地址被分成三个部分(这是相对于两级分页来说),见图2-6(线性地址到物理地址的转换)。前面两个部分(每个部分占10位)分别作为页目录和页表的索引,最后一个部分(占12位,寻址4K空间)作为一个页帧内的索引。
图2-6 线性地址到物理地址的转换
l CR3是系统寄存器,用以保存页目录的基地址
l 一个页表项和一个页目录项都是4个字节
2.2.3 中断(interrupt)、异常(exception)、陷入(trap)
实际上,本书没有必要严格地去区分什么是中断,什么是异常。由于本章要用到一点中断和异常的概念,所以这里稍微作一个介绍。
中断:是为了设备与CPU之间的通信。典型的有如服务请求,任务完成提醒等。比如我们熟知的时钟中断,硬盘读写服务请求中断。中断的发生与系统处在用户态还是在内核态无关,只决定于EFLAGS寄存器的一个标志位。我们熟悉的sti, cli两条指令就是用来设置这个标志位,然后决定是否允许中断。在单个CPU的系统中,这也是保护临界区的一种简便方法。中断是异步的,因为从逻辑上来说,中断的产生与当前正在执行的进程无关。事实上,中断是如此有用,Linux用它来统计时钟,进行硬盘读写等。
异常:异常是由当前正在执行的进程产生。异常包括很多方面,有出错(fault),有陷入(trap),也有可编程异常(programmable exception)。出错(fault)和陷入(trap)最重要的一点区别是他们发生时所保存的EIP值的不同。出错(fault)保存的EIP指向触发异常的那条指令;而陷入(trap)保存的EIP指向触发异常的那条指令的下一条指令。因此,当从异常返回时,出错(fault)会重新执行那条指令;而陷入(trap)就不会重新执行。这一点实际上也是相当重要的,比如我们熟悉的缺页异常(page fault),由于是fault,所以当缺页异常处理完成之后,还会去尝试重新执行那条触发异常的指令(那时多半情况是不再缺页)。陷入的最主要的应用是在调试中,被调试的进程遇到你设置的断点,会停下来等待你的处理,等到你让其重新执行了,它当然不会再去执行已经执行过的断点指令。
可编程中断:这类中断可由编程者用int指令来触发。在Linux中,使用了一个,也是唯一的一个可编程中断,就是int 0x80系统调用。硬件对可编程中断的处理与对trap的处理类似,即从这类异常返回时也是返回到触发异常的下一条指令。关于可编程中断,还有另外一种说法:软件中断(software interrupt),其实是一个意思。
2.3 相关数据结构、源代码分析,流程
跟系统调用相关的内核代码文件主要有:
l arch/i386/kernel/entry.S
l arch/i386/kernel/traps.c
l include/linux/unistd.h
还有一些代码零散地分布在内核代码目录下的其他文件中。
2.3.1 entry.S汇编文件
正如文件开头所说,在这个文件中包含了系统调用和异常的底层处理程序,信号量识别程序(这个调用在每次时钟中断和系统调用的时候都会发生),最关键的是文件中的汇编程序段ENTRY(system_call),它是所有系统调用响应程序的入口;以及汇编程序段ret_from_sys_call,这是所有系统调用和中断处理程序的返回点。我们接下来还会几次感受到它的存在。当然,还有一个系统调用表。所有这些,我们都会逐个地进行详细讨论。
好了,现在,如果你正坐在一台电脑旁边或者你手头正好有这个文件的代码,那么请打开这个文件,我们一步一步来。
2.3.1.1关于堆栈
arch/i386/kernel/entry.S
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 * 28(%esp) - %eip
35
36
37
38
39
40
这一段代码块只是正式代码开始之前的一段注释,之所以把它也拿出来,只是想告诉大家,它真的很重要。如果我们清楚系统堆栈结构,那么我们在理解很多问题的时候就会豁然开朗,比如进程的拷贝(fork时调用)、信号量、进程的追踪等,包括我们马上要看到的SAVE_ALL、RESTALL_ALL宏。
图2-7 堆栈切换
从34-38这几行中,按照压入堆栈的先后次序,依次保存了oldss、oldesp、eflags、cs、eip这五个寄存器。这是进程在执行int指令,陷入到内核的时候,由于是在不同的特权级别上进行切换,为了安全起见,系统会对堆栈进行切换。堆栈切换的时候,首先从当前任务的TSS(任务状态段)中获取高优先级的核心堆栈信息(SS和ESP),然后把低优先级堆栈信息(SS和ESP)保留到高优先级堆栈(即核心栈)中,也就是这里所看到的oldss和oldesp。然后再依次保存eflags、cs、eip。从用户堆栈切换到内核堆栈流程,如图2-7(堆栈切换)所示:
首先选择内核堆栈(每一个进程都有自己的内核堆栈,就是与进程自己的task_struct共用两个页面的地方)。然后在内核堆栈中,压入用户堆栈的ss、esp(也就是上面的oldss、oldesp),以便到时候返回到用户的堆栈。再然后依次压入EFLAGS,用户进程的cs、eip。需要说明的是,这几步都是由硬件完成,因此你不必害怕麻烦。
接下去压入堆栈的那些寄存器我们稍后再讲。
2.3.1.2 关于SAVE_ALL,RESTALL_ALL
arch/i386/kernel/entry.S
85
86
87
88
89
90
91
92
93
94
95
96
97
98
-------------------------------------------------------------------------------------------------------------------------------
100
101
102
103
104
105
106
107 popl %eax; \
108
109
110
111
这一部分程序结构很清晰,SAVE_ALL、RESTALL_ALL很多地方也是很对称的,理解起来困难不大,需要说明的只有三个地方:
① SAVE_ALL中的96-98行,往eds寄存器中放入$(__KERNEL_DS),意即使用内核数据段。你也许愿意看看$(__KERNEL_DS)变量是什么。自己动手吧,你一定可以找到。(提醒:include/asm-i386/segment.h)
② 如果你足够仔细,你会发现似乎有点不对:RESTALL_ALL前面一些指令很整齐地按照SAVE_ALL推进去的反向顺序弹出来,但是最后为什么还要给esp加上4(addl $4,%esp)?好样的,你足够仔细,这是一种可贵的品德。事实上,那是为了忽略掉系统调用进入的时候保存的那个orig_eas。下面我们讨论system_call的时候还会提到。
③ 然后iret返回。iret指令的执行是这样的:如果iret到相同的级别,那么从堆栈中弹出eip、cs和EFLAGS。如果是iret到不同的特权级别,那么从堆栈中弹出的是eip、cs、EFLAGS、esp和ss。
2.3.1.3 系统调用表(sys_call_table)
在这个文件中还有一个很重要的地方就是维护整个系统调用的一张表:系统调用表。系统调用表依次保存着所有系统调用的函数指针,以方便总的系统调用处理程序(system_call)进行索引调用。
arch/i386/kernel/entry.S
666 .section .rodata, “a”
667 #include “syscall_table.S”
668
669 syscall_table_size=(.-syscall_table)
arch/i386/kernel/syscall_table.S
1 ENTRY(sys_call_table)
2 .long SYMBOL_NAME(sys_ni_syscall)
3 .long SYMBOL_NAME(sys_exit)
4 .long SYMBOL_NAME(sys_fork)
5 .long SYMBOL_NAME(sys_read)
6 .long SYMBOL_NAME(sys_write)
7 .long SYMBOL_NAME(sys_open) /* 5 */
…
…
319 .long SYMBOL_NAME(sys_ppoll)
320 .long SYMBOL_NAME(sys_unshare) /* 310 */
两段汇编程序一起阅读、理解。汇编文件“syscall_table.S”定义了一个数组,数组名为sys_call_table。“.long”表示数组元素长度为4字节,而“SYMBOL_NAME(sys_open)”表示数组元素的值就是函数sys_open()的入口地址。
汇编文件“entry.S”的第667行include “syscall_table.S”将整个数组都装进来了。而第669行计算数组的长度syscall_table_size(两个地址之间相差的byte数)。其中,‘.’代表当前地址,sys_call_table代表数组首地址。
2.3.1.4 system_call和ret_from_sys_call
arch/i386/kernel/entry.S
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
这一部分代码取自第2.4.18版本。我们先讲解一下字面上的意思。至于要完全理解它的来由及用处,可能要等到我们把这一小节分析完毕。前面我们列出了ret_from_sys_call之前的系统堆栈状态,现在你可以对照那一页,然后再对照这一段代码来理解。
① 首先,系统把eax(里面存放着系统调用号)的值压入堆栈,注释也已经说了,就是把原始的eax值保存起来,因为使用SAVE_ALL宏保存起来的eax要被用来传递返回值。但是在保存了返回值到真正返回用户态还有一些事情要做,内核可能还会需要知道是哪个系统调用导致进程陷入了内核。所以,这里要保留一份eax的最初拷贝,以备急用。
② SAVE_ALL宏,这个我们前面刚刚讲到了,大家都还记得。
③ GET_CURRENT:
arch/i386/kernel/entry.S
131
132
133
其作用是取得当前进程的task_struct结构的指针返回到reg中,因为在Linux中核心栈的位置是task_struct之后的两个页面(8192bytes)处,所以此处把栈指针与上-8192,则得到的是task_struct结构指针。这一设计一直被津津乐道。在第2.6.15版本中,改为include/linux/asm-i386/thread_info.h中定义的GET_THREAD_INFO:
include/linux/asm-i386/thread_info.h
119 #define GET_THREAD_INFO(reg) \
120 movl $-THREAD_SIZE, reg; \
121 andl %esp, reg
④ 看看进程是不是被监视了,如果被trace了,则跳转到tracesys。
⑤ 检查eax中的参数,看是否合法。合法的eax值指的是范围从0 - NR_syscalls的一个数字,只有在这个范围内,system_call才能根据eax决定出调用哪一个具体的系统调用。系统通过eax传递进来的参数决定调用在系统调用表(sys_call_table)中的哪一个系统调用的过程可以用图2-8(系统调用索引)表示。
图2-8 系统调用索引
⑥ 202和203行,调用具体的内核系统调用代码,然后保存返回值到堆栈中。
⑦ 204行开始,ret_from_sys_call,程序会检测进程task_struct中的相应位,然后作出相应的跳转(need_resched置位,则重新调度;sigpending置位,则别的进程或者系统对该进程发了signal,马上跳转去处理signal)。ret_from_sys_call是一个很重要的过程,所有的系统调用,所有的中断都从这里返回。也就是说,所有的中断调用、中断处理最后都通过这个过程,然后回到一个不知道的地方。我之所以说“不知道的地方”,是因为系统的控制权可能仍然回到原先的进程,也可能发生任务切换,系统选择了另外一个它认为更加紧迫的进程然后把控制交给那个进程,这完全取决于系统,而不是发出系统调用请求的进程。
⑧ RESTALL_ALL:现在我们可以理解最后的“addl $4,%esp”了。
图2-9 寄存器变化
实际上,在system_call中,寄存器的使用是非常整齐的,我们整理一下,把所有牵涉到堆栈中寄存器的指令抽取出来,用图2-9(寄存器变化)表示。
最后我们用类C代码简化一下system_call过程:
system_call类C语言表示
void system_call(unsigned int eax)
{
task_struct * ebx;
save_context();
ebx = GET_CURRENT;
if (ebx -> tak_ptrace != 0x02)
goto tracesys;
if(eax > NR_syscalls)
goto badsys;
retval = (sys_call_table[eax * 4])();
if(ebx -> need_resched != 0)
goto reschedule;
if(ebx -> sigpending != 0)
goto signal_return;
restall_context();
}
2.3.2 traps.c( arch/i386/kernel/traps.c)文件
在这个文件中,给出了很多出错处理程序。当然最重要的还是trap_init函数。这个函数初始化中断描述符表(idt),往中断描述符表里面填入中断门,陷入门和调用门。我们可以看看源代码:
arch/i386/kernel/traps.c
1207 void __init
{
……
1221 set_trap_gate
1222 set_trap_gate
1223 set_intr_gate
1224 set_system_gate
……
1226
……
}
1226行中,SYSCALL_VECTOR的值就是0x80 (include/asm-i386/mach-default/irq_vectors.h, line 31),set_system_gate函数在中断描述符表中的第0x80项填入一个陷阱门描述符,这个描述符的作用就是使控制安全地转移到system_call这个函数中去。执行完system_call函数又能够安全地返回来。
小结
最后,我们对2.3.1和2.3.2小结一下,希望能得到一个比较完整的关于系统调用的初始化,系统调用的执行过程,系统调用的返回的概念:
① 系统调用初始化:在traps.c中,系统在初始化程序trap_init()中,通过调用set_system_gate(0x80, &system_call)函数,在中断描述符表(idt)里面填入系统调用的处理函数system_call,这就保证每次用户执行指令int 0x80的时候,系统能把控制转移到entry.S中的system_call函数中去。
② 系统调用执行:经过我们详细地分析system_call函数,我们可以了解到,当系统调用发生的时候,system_call函数会根据用户传递进来的系统调用号,在系统调用表中(sys_call_table)寻找到相应偏移地址的内核处理函数(也就是具体的内核系统调用处理代码,相应的,我们把system_call称为通用的系统调用处理代码),进行相应的处理。当然在这个过程之前,要保存环境(通过SAVE_ALL等指令);
③ 系统调用的返回:系统调用处理完毕后,通过ret_from_sys_call返回。返回之前,程序会检测一些变量,根据这些变量,跳转到相应的地方去处理。从系统调用返回,系统的控制权不一定会返还到原先调用系统调用的那个进程,这个我们前面已经讨论过了。真正回到用户空间之前,要恢复环境(通过RESTALL_ALL等指令)。
2.3.3 系统调用中普通参数的传递及unistd.h
前面讲的都是内核中的处理,并没有涉及用户地址空间,也没有涉及用户空间与内核地址空间的接口。我们知道,我们进行系统调用的时候可能是这样:getuid();又或者是这样:open(/tmp/foo, O_RDONLY, S_IREAD)。那么内核是怎样跟用户程序进行交互的呢?这包括控制权是怎样转移到内核的那个system_call处理函数去的,参数是如何传递的等等。在这里,标准C库充当了很重要的角色,它把用户希望传递的参数装载到CPU的寄存器中,然后触发0x80软中断。当从系统调用返回的时候(ret_from_sys_call),标准C库又接过控制权,处理返回值(每个系统调用都会有返回值)。因此,你可以把标准C库看成是用户程序与内核之间的一个小的桥梁。
我们先来看include/asm-i386/unistd.h这个头文件,这个头文件定义了所有的系统调用号,还定义了几个与系统调用相关的关键的宏。
include/asm-i386/unistd.h
9 #define __NR_exit
10 #define __NR_fork
11 #define __NR_read
12 #define __NR_write
13 #define __NR_open
14 #define __NR_close
……
241 #define __NR_llistxattr
242 #define __NR_flistxattr
243 #define __NR_removexattr
244 #define __NR_lremovexattr
245 #define __NR_fremovexattr
…………
311 #define __NR_unshare 310
312 #define __NR_syscalls 311
很清楚,文件一开始就定义了所有的系统调用号,你也可以清楚地了解,在2.6.15的内核中,总共有311个系统调用,它们整齐地排列。是的,你一定也能够想到了,到时候你添加系统调用的时候,你的那个系统调用号就是排放在最后,这个我们到时候会一步一步向你解释。每一个系统调用号都以“__NR_”开头,这可以说是一种习惯,或者说一种约定。但事实上,它还有更方便的地方,你也许也发现了,那就是除了这个“__NR_”头,所有的系统调用号就是你编写用户程序的那个名字。比如“__NR_getuid”,除去那个统一的“__NR_”头,就是getuid,你一定很熟悉这个名称。是的,标准库函数也很熟悉,它正是利用这样的共同性,通过宏替换,把一个一个你写的诸如getuid这样的名词转换成__NR_getuid,然后再转换成相应的数字号(比如__NR_getuid是24),通过eax寄存器传递给内核作为深入syscall_table的索引。
接下来,文件连续定义了7个宏,很多系统调用都是通过这些宏,进行展开,形成定义,这样用户程序才能进行系统调用。内核也才能知道用户具体的系统调用,然后进行具体的处理。使用这些宏把系统调用展开的工作基本上都是标准C库来做的。所以,我们刚才说,标准C库是联系用户程序和内核之间的一个桥梁。
我们挑选几个来讲解,其他的都可以类推。先来个简单的,不用传递参数的系统调用的宏:
include/asm-i386/unistd.h
341
342 type name
343 { \
344 long __res
345 __asm__ volatile ("int $0x80" \
346 : "=a" (__res
347 : "" (__NR_##name)); \
348 __syscall_return
349 }
这个宏用于展开那些不用参数的系统调用,比如getuid()、 fork()、pause()等。我们举一个实例,你就能很快地明白上面这段宏代码是怎么工作的了。比如你写的某段程序的某条语句:
pause();
那么,通过:
static inline _syscall0
这一行,因为_syscall0是一个带参数的宏(注意区分系统调用的参数和宏的参数),所以根据341-349行的宏定义转换成:
pause()
int pause(void)
{
long __res;
__asm__ volatile ("int $0x80"
: "=a" (__res)
: "" (__NR_pause));
__syscall_return(int,__res);
}
这只是简单的名字替换,也许你会说:太简单了,我想知道每一条语句的细节。嗯,那估计要困难一点,不过,我们应该可以克服。
344行:定义一个变量__res;
345行:__asm__这是gcc中嵌入汇编的写法,也就是所有的嵌入汇编语句放在__asm__()的括号内部。volatile这个修饰符是告诉gcc:这一段嵌入汇编语句不允许优化。gcc将严格按照你的汇编代码编译。“int $0x80”这条语句我们应该熟悉了,就是触发系统调用。
346行:你肯定注意到了,这里有一个冒号,事实上,347行还有一个。这是gcc内嵌汇编语言的语法。gcc关于内嵌汇编语言的规定如下:
基本格式:
__asm__ ( "汇编语句\n\t”
“汇编语句”
: “= 限制符” (变量), “= 限制符”(变量)
: “限制符” (变量), “限制符” (变量)
: 被改变了的寄存器,被改变的寄存器);
第一个冒号与第二个冒号之间的部分是声明输出变量用的(346行)。第二个冒号与第三个冒号之间(本例中没有第三个冒号)是声明输入变量用的(347行)。比如346行的:"=a" (__res
347行:根据我们刚才的说明,这一行用于说明输入变量是(__NR_##name),他使用eax寄存器。
为了更好地理解,我们把345~347行的嵌入汇编格式和由他们生成的汇编代码列在一起做一个对比:
345 - 347行的嵌入汇编格式
345 __asm__ volatile ("int $0x80" \
346 : "=a" (__res
347 : "" (__NR_##name)); \
生成的汇编代码
movl $__NR_##name, %eax /*先为输入参数分配寄存器*/
#APP
int $0x80 /*汇编代码*/
#NO_APP
movl %eax, __res /*最后处理输出参数*/
关于内嵌汇编的更多知识,可以参考gcc的手册(manual)。
348行:这是一个宏,只是对返回的值__res进行一定的处理,保证用户看到的返回值__res在正确的范围内(-1~-124)。
嗯,还真是有一点难度,不过,还是走过来了,不是吗?如果你没有完全理解,你也不必灰心,至少你已经懂得了这个宏定义的大致框架。还有你已经知道:就是这个宏,能把不带参数的系统调用展开,把用户系统调用的要求与内核具体系统调用的处理函数联系起来。这真是一个不错的进步。
为了巩固我们的理解,我们下面再讲一个复杂一些的:
include/asm-i386/unistd.h
371 #define _syscall3
372 type
373 { \
374 long __res
375 __asm__ volatile ("int $0x80" \
376 : "=a" (__res
377 : "" (__NR_##name),"b" ((long)(arg1
378 "d" ((long)(arg3
379 __syscall_return
380 }
这一个宏跟上面那个不同,只是因为由它展开的系统调用会有三个参数(arg1, arg2, arg3)。比如open(/tmp/foo, O_RDONLY, S_IREAD)。这样我们回到了前面我们曾经提到过的问题(我想你也许还记得):内核怎么样跟用户程序交互,怎样从用户程序得到这些系统调用参数?现在是给出回答的时候了。我们先把375~378行近似地转换成更易懂的汇编格式:
375 - 378行的嵌入汇编格式
375 __asm__ volatile ("int $0x80" \
376 : "=a" (__res
377 : "" (__NR_##name),"b" ((long)(arg1
378 "d" ((long)(arg3
生成的汇编代码
movl $__NR_##name, %eax //先为输入参数分配寄存器
movl arg1, %ebx
movl arg2, %ecx
movl arg3, %edx
#APP
int $0x80 //汇编代码
#NO_APP
movl %eax, __res //最后处理输出参数
由这个宏可以看出,内核是通过ebx,、ecx、edx来传递这三个参数的。我们在讲解system_call这个系统调用处理程序的时候,曾经说到了SAVE_ALL。在那里,SAVE_ALL宏把所有寄存器的值都压入堆栈,这一方面是为了保存环境;现在我们可以看到:更重要的是,把系统调用的参数也压入了堆栈。这样,system_call中调用的任何一个具体的系统调用处理程序(通常使用C编写),都能从堆栈中拿到他们想要的参数。这真的是很巧妙的一个设计。
那么,现在你一定会马上想到了:如果是四个参数呢?五个、六个参数呢?问得好。四个参数和五个参数,解决办法跟三个参数的情况差不多,只是要多两个寄存器而已:esi,、edi。这两个宏分别是:
l _syscall4
l _syscall5
在这里我们就不详细讲解了,因为你一定可以自己清楚地分析出来。那就动手吧,给出你自己的分析。
至于六个参数是怎样传递的,我们可以看看下面的代码:
include/asm-i386/unistd.h
405 #define _syscall6
406 type5,arg5,type6,arg6) \
407 type
408 { \
409 long __res
410 __asm__ volatile ("push %%ebp ; movl %%eax,%%ebp ; movl %1,%%eax ; int $0x80 ;
pop %%ebp" \
411 : "=a" (__res
412 : "i" (__NR_##name),"b" ((long)(arg1
413 "d" ((long)(arg3
414 "" ((long)(arg6))); \
415 __syscall_return
416 }
同样的,我们先把410~414行近似的转换成更易懂的汇编格式:
410 - 414行的嵌入汇编格式
410 __asm__ volatile ("push %%ebp ; movl %%eax,%%ebp ; movl %1,%%eax ; int $0x80 ;\
pop %%ebp" \
411 : "=a" (__res) \
412 : "i" (__NR_##name),"b" ((long)(arg1
413 "d" ((long)(arg3
414 "" ((long)(arg6))); \
生成的汇编代码
movl arg1, %ebx
movl arg2, %ecx
movl arg3, %edx
movl arg4, %esi
movl arg5, %edi
movl arg6, %eax
#APP
push %ebp
movl %eax, %ebp
movl $__NR_##name, %eax
int $0x80
pop %ebp
#NO_APP
movl %eax, $__res
gcc看上去似乎有点手忙脚乱,但实际上,它还是有条不紊。他把第六个参数(arg6)放到ebp寄存器里面,因为内嵌汇编的语法中,没有限定符使得arg6能分配到ebp寄存器。所以,它使用了eax作为桥梁,先把arg6放到eax中,然后编译的时候才把arg6移到ebp中,eax遵照老规矩,还是放上系统调用号(__NR_##name)。
至于6个以上的参数要怎么传递?现在的系统调用还没有六个以上的参数的。但是很显然你不可能再仿照上面传递6个参数的办法,因为已经没有通用寄存器了。或许你已经想到了定义一个结构体,然后把你的参数数据都放进那个结构体里面,通过把那个结构体的指针作为一个参数传入内核,从而达到让内核读取参数数据的目的。这真是一种不错的想法。事实上,Linux内核的设计者跟你想的一样:通过用户态程序传递指针给内核,然后再由内核通过这些指针访问用户地址空间的数据。关于这一部分,我们放到最后一部分:较高级主题中再讲。我们现在的任务是把整个系统调用的脉络打通。
我们继续我们的思路。上面讲到的都是参数怎样从用户程序传递到内核堆栈中。那么,到执行完SAVE_ALL并且再由call指令调用其内核处理函数时,内核堆栈的结构大致是这样:(图2-10调用具体函数前内核堆栈结构)
处于内核堆栈中的参数变量怎样具体地传递到每一个内核函数中呢?我们知道,典型的两种内核函数是这样:
l asmlinkage int sys_fork(struct pt_regs regs);
l asmlinkage int sys_open(const char * filename, int flags, int mode);
在sys_fork中,把整个堆栈中的内容视为一个struct pt_regs(include/asm-i386/ptrace.h文件,第26行)类型的参数,该参数的结构和堆栈的结构是一致的,所以可以使用堆栈中的全部信息。而在sys_open中参数filename、flags、mode正好对应于堆栈中的ebx、ecx、edx的位置,而这些寄存器正是用户在通过C库调用系统调用时给这些参数指定的寄存器。
__asm__ volatile ("int $0x80" \
: "=a" (__res
: "" (__NR_open),"b" (filename),"c" (flags), \
"d" (mode));
图2-10 调用具体函数前内核堆栈结构
事实上,你可以认为在标准C库中,用某种方法给所有的系统调用进行了“定义”,让用户程序与内核联系起来。如果你是一个做事情喜欢追根究底的人,那好,我们可以来粗略地看一下在glibc中这些事情到底是怎么做的。
我们还是拿一个最普通的例子来讲,看看getuid()是怎么通过glibc进行展开的。
glibc版本:glibc-2.2.5
sysdeps/unix/sysv/linux/i386/getuid.c
41 uid_t
42 __getuid (void)
43 {
44 #if __ASSUME_32BITUIDS > 0
45 return INLINE_SYSCALL (getuid32, 0);
46 #else
47 # ifdef __NR_getuid32
48 if (__libc_missing_32bit_uids <= 0)
49 {
50 int result;
51 int saved_errno = errno;
52
53 result = INLINE_SYSCALL (getuid32, 0);
54 if (result == 0 || errno != ENOSYS)
55 return result;
56
57 __set_errno (saved_errno);
58 __libc_missing_32bit_uids = 1;
59 }
60 # endif /* __NR_getuid32 */
61
62 return INLINE_SYSCALL (getuid, 0);
63 #endif
64 }
65
66 weak_alias (__getuid, getuid)
由这里可以看出,glibc中展开getuid的方法其实并不是我们所想象的那样使用
_syscall0( int, getuid);
而是使用自己的一套宏:
INLINE_SYSCALL (getuid, 0);
我们再追踪这个宏看看。
sysdeps/unix/sysv/linux/i386/sysdep.h
246 #define INLINE_SYSCALL(name, nr, args...) \
247 ({ \
248 unsigned int resultvar; \
249 asm volatile ( \
250 LOADARGS_##nr \
251 "movl %1, %%eax\n\t" \
252 "int $0x80\n\t" \
253 RESTOREARGS_##nr \
254 : "=a" (resultvar) \
255 : "i" (__NR_##name) ASMFMT_##nr(args) : "memory", "cc"); \
256 if (resultvar >= 0xfffff001) \
257 { \
258 __set_errno (-resultvar); \
259 resultvar = 0xffffffff; \
260 } \
261 (int) resultvar; })
具体的我们就不再深入追究下去了。从上面的分析我们已经知道的是:glibc并没有使用unistd.h中提供的宏,而是有自己的一套宏处理机制。而且,从LOADARGS_##NR、RESTOREARGS_##nr、ASMFMT_##nr(args)等几个宏来看,glibc这个系统调用宏处理更加灵活,它会根据具体系统调用的参数动态地调整需要的寄存器。
小结
我们在这一节分析了include/asm-i386/unistd.h 这个文件,我们知道了很多东西:
l 我们知道了系统调用号,知道了当我们自己添加一个系统调用的时候,我们也必须在这个文件里定义一个自己的系统调用号__NR_mysyscall;
l 我们学习了几个跟系统调用密切相关的宏:_syscall0~_syscall6。知道了怎样由这些宏转换成每一个具体的系统调用;
l 我们了解了gcc的内嵌汇编格式和语法,能读懂简单的内嵌汇编语句了;
l 我们还仔细研究了系统调用的参数传递,知道了Linux怎样巧妙地利用堆栈,利用寄存器进行多达6个参数的传递。
l 最后,我们对标准C库在系统调用中的作用(联系用户程序进行的系统调用与内核的具体系统调用处理)有了比较清楚的理解。
收获很大。
2.4 一个系统调用的详细实现
经过前面两节枯燥的讲解,原理部分的内容你应该都掌握得差不多了。所以,在这一节里,我们准备给你详细地讲解一个系统调用的实现:getuid()。在这个过程中,将2.3节讲到的东西作一个回顾与消化,达到深入理解的目的。为什么选择getuid()来讲解呢?因为它很简单,这样我们就可以把重点放在系统调用整个过程上,而不是放在某个具体的系统调用的实现上。通过这一节的讲解,你将能理顺整个系统调用的脉络。
我们还是拿本章最开始时给出的例子:
1: #include
2: int main(){
3: int i = getuid();
4: printf(“Hello World! This is my uid: %d\n”, i);
5: }
前面我们已经说过,所有要使用系统调用的程序,都要包含“unistd.h”这个头文件。编译这个文件的时候,编译器是怎么认识getuid()这个系统调用的呢?在上一节的末尾,我们已经对在glibc中对系统调用的处理有了一个大致的讨论。为了使大家的理解更加容易,在这里,我们作一个假定,那就是这个系统调用仍然是使用类似于unistd.h中定义的宏进行展开,这在原则上不会有错,而且更易于理解。
_syscall0( int, getuid);
还记得这个unistd.h里的宏是怎样展开的吗?对,展开成这个样子:
getuid
int getuid(void)
{
long __res;
__asm__ volatile ("int $0x80"
: "=a" (__res)
: "" (__NR_getuid));
__syscall_return(int,__res);
}
很显然,程序通过把系统调用号__NR_getuid(24)放入eax,然后通过执行这样一条指令
“int $0x80”
进行模式切换,进入内核。执行完“int $0x80”之后(也就是系统调用之后),如果控制又返回到这里,那么它接着执行后面一条语句,也即把返回值放入eax,返回。getuid完成。
我们继续深入下去,看看int 0x80指令之后系统到底做了什么事情。因为这是一条软中断指令,所以就要看系统规定的这条中断指令的处理程序是什么:
arch/i386/kernel/traps.c
set_system_gate
从这行程序我们可以看出,系统规定的系统调用的处理程序就是system_call。控制转移到内核之前,硬件会自动进行模式和堆栈的切换。好了,现在控制转移到了system_call:
arch/i386/kernel/entry.S
ENTRY(system_call)
pushl %eax # save orig_eax
SAVE_ALL
GET_CURRENT(%ebx)
testb $0x02,tsk_ptrace(%ebx) # PT_TRACESYS
jne tracesys
cmpl $(NR_syscalls),%eax
jae badsys
call *SYMBOL_NAME(sys_call_table)(,%eax,4)
movl %eax,EAX(%esp) # save the return value
ENTRY(ret_from_sys_call)
cli # need_resched and signals atomic test
cmpl $0,need_resched(%ebx)
jne reschedule
cmpl $0,sigpending(%ebx)
jne signal_return
restore_all:
RESTORE_ALL
由于前面我们已经详细讲解了这个函数,所以这里我们只列出它的功能步骤(同时我们假设没有其它意外的情况:没有被trace,没有设置重新调度位等):
① 保留一份系统调用号的最初拷贝;
② SAVE_ALL保存环境;
③ 得到该进程结构的指针,放在ebx里面;
④ 检查系统调用号,显然__NR_getuid( 24 )是合法的;
⑤ 根据这个系统调用号,索引sys_call_table,得到相应的内核处理程序:sys_getuid16;
⑥ 我们追踪sys_getuid16:
kernel/uid16.c
179 asmlinkage
180 {
181 return high2lowuid
182 }
可以看到,这个内核系统调用处理程序很简单,它只是返回当前进程的uid。当然,在2.6.15的内核中,由于进程的uid可以很大,而原先老版本的内核uid的类型只是“ unsigned short”,所以这里要对返回的uid做一些处理,使得它小于65535。反正这个我们可以不去细究它,我们只需要知道它返回了一个值。接下来
⑦ 保存返回值:从eax中移到堆栈中的eax的位置。
⑧ 好了,我们假设没有什么意外发生,于是ret_from_sys_call直接到RESTORE_ALL,从堆栈里面弹出保存的寄存器,堆栈切换,iret。
执行完iret之后,正如前面我们所分析的,进程回到用户态,返回值保存在eax中,于是得到返回值,打印:
Hello World! This is my uid:551
你的最简单的调用系统调用的程序到这里就结束了,系统调用的整个流程也理了一遍。
也许你还注意到了,在你添加的系统调用sys_getuid16
为了更形象、清晰,用一幅图来帮助你理解系统调用的整个流程,如图2-11。
图2-11 系统调用流程
很清晰了,不是吗?有了上面的认识,我们再进入下一阶段:系统调用的添加就容易多了。你是否跃跃欲试了呢?没问题,我们马上就开始!
2.5 简单系统调用的添加
在这一节中,我们将要实现一个简单的系统调用的添加。我们先给出题目:
题目 :在现有的系统中添加一个不用传递参数的系统调用。
功能要求:调用这个系统调用,使用户的uid等于0。
目的 :显然,这不是一个有实际意义的系统调用,我们的目的并不是有用,而是一种证明,一个对系统调用的添加过程的熟悉,为下面我们添加更加复杂的系统调用打好基础。
怎么样?觉得困难还是觉得太简单?我们首先承认,每个人接触Linux的时间长短不一样,因此基础也会有一点差别。那么对于觉得太简单的人呢,请你迅速地合上书本,然后跑到电脑前面,开始实现这个题目。来吧,不要眼高手低,做完之后,你就可以跳过这一节,直接进入下一节的学习了。对于觉得有点困难的人呢,不用着急,这一节就是专门为你准备的。我们会列出详细的实现步骤,你一定也没有问题的。
如果你前面对整个系统调用的过程有一个清晰的理解的话,我们就顺着系统调用的流程思路,给出一个添加新的系统调用的步骤:
2.5.1决定你的系统调用的名字
这个名字就是你编写用户程序想使用的名字,比如我们取一个简单的名字:mysyscall。一旦这个名字确定下来了,那么在系统调用中的几个相关名字也就确定下来了。
l 系统调用的编号名字:__NR_mysyscall;
l 内核中系统调用的实现程序的名字:sys_mysyscall;
现在在你的用户程序中出现了:
#include
int main()
{
mysyscall();
}
流程转到标准C库。
2.5.2利用标准C库进行包装吗
编译器怎么知道这个mysyscall是怎么来的呢?在前面我们分析的时候,我们知道那时标准C库给系统调用作了一层包装,给所有的系统调用做出了定义。但是显然,我们可能不愿意去改变标准C库,也没有必要去改变。那么我们在自己的程序中来做:
#include
_syscall0
int main()
{
mysyscall();
}
好,由于有了_syscall0这个宏,mysyscall将得到定义。但是现在系统会去找系统调用号,以放入eax。所以,接下来我们定义系统调用号。
2.5.3添加系统调用号
系统调用号在文件unistd.h里面定义。这个文件可能在你的系统上会有两个版本:一个是C库文件版本,出现的地方是在/usr/include/unistd.h和/usr/include/asm/unistd.h;另外还有一个版本是内核自己的unistd.h,出现的地方是在你解压出来的2.6.15内核代码的对应位置(比如/usr/src/linux/include/linux/unistd.h和/usr/include/asm-i386/unistd.h)。当然,也有可能这个C库文件只是一个到对应内核文件的连接。至于为什么会存在两个版本的头文件,这个问题将会在第2-6节“较高级主题”一节进行说明。现在,你要做的就是在文件unistd.h中添加我们的系统调用号:__NR_mysyscall,如下所示:
include/asm-i386/unistd.h
/usr/include/asm/unistd.h
231 #define __NR_mysyscall 223 /* mysyscall adds here */
添加系统调用号之后,系统才能根据这个号,作为索引,去找syscall_table中的相应表项。所以说,我们接下来的一步就是:
2.5.4在系统调用表中添加相应表项
我们前面讲过,系统调用处理程序(system_call)会根据eax中的索引到系统调用表(sys_call_table)中去寻找相应的表项。所以,我们必须在那里添加我们自己的一个值。
arch/i386/kernel/syscall_table.S
……
233 .long sys_mysyscall
234 .long sys_gettid
235 .long sys_readahead /* 225 */
……
到现在为止,系统已经能够正确地找到并且调用sys_mysyscall。剩下的就只有一件事情,那就是sys_mysyscall的实现。
2.5.5 sys_mysyscall的实现
我们把这一小段程序添加在kernel/sys.c里面。在这里,我们没有在kernel目录下另外添加自己的一个文件,这样做的目的是为了简单,而且不用修改Makefile,省去不必要的麻烦。
asmlinkage int sys_mysyscall(void)
{
current->uid = current->euid = current->suid = current->fsuid = 0;
return 0;
}
这个系统调用中,把标志进程身份的几个变量uid、euid、suid和fsuid都设为0。
到这里为止,我们所要做的添加一个新的系统调用的所有工作就完成了,是不是非常简单?的确如此。因为Linux内核结构的层次性还是非常清楚的,这就使得每一个开发者可以把精力放在怎么样实现具体的功能上,而不用在一些接口函数上伤脑筋。
现在所有的代码添加已经结束,那么要使得这个系统调用真正在内核中运行起来,我们就需要对内核进行重新编译。这个我们在前面就讨论到了,应该没有问题,因此我们在这里略过。
2.5.6 编写用户态程序
要测试我们新添加的系统调用,我们可以编写一个用户程序调用我们自己的系统调用。我们对自己的系统调用的功能已经很清楚了:使得自己的uid变成0。那么我们看看是不是如此:
用户态程序
#include
_syscall0
int main()
{
mysyscall(); /* 这个系统调用的作用是使得自己的uid为0 */
printf(“em…, this is my uid: %d. \n”, getuid());
}
2.6 较高级主题:添加一个更复杂的系统调用
在这一节里,我们准备讲解一些较高级主题:用户空间与内核空间数据的交换,内核编程应该注意的一些问题等,然后我们再实现一个比较复杂的系统调用。
2.6.1用户空间与内核空间的数据交换
前面我们就已经提到:如果用户程序要传给内核的数据很多,那么有一种办法就是通过传递指向用户态数据的结构指针,达到让内核访问用户空间数据的目的。同样地,我们上面看到的系统调用返回都只是通过eax传递一个返回值,这在很多情况下显然不能满足要求。因此,内核也可能通过指向用户空间的结构体指针,往用户空间写数据。所有这些,就是我们这一节将要讲的:用户空间与内核空间数据的交换。
跟访问用户空间数据相关的几个内核函数是:
l verify_area
l memcpy_fromfs
l memcpy_tofs
l copy_to_user
l copy_from_user
为什么内核访问用户空间的数据还要这么麻烦呢?内核不是运行在最高级别么?是的,你说得没错,内核是运行在特权级并且可以访问所有的数据变量,但是可以访问与能不能访问似乎稍微有点区别。因为用户程序传递给内核的是一个指针和一个范围,这个范围有可能(不管是用户程序有意还是无意)超出该进程的地址空间,内核如果盲目的为该进程服务,那么就有可能破坏其他进程的运行环境,从而造成不必要的损失。所以,基于这样的考虑,内核总是要对这个地址范围进行检验的,包括范围正确与否?能不能读?能不能写等。这些事情都是由函数verify_area()来做的。
verify_area()有三个参数。第一个参数type表示检测的类型:检测该进程是否有权限读(type为VERIFY_READ)、检测该进程是否有权限写(type为VERIFY_WRITE)。第二个参数addr是一个指针,指向要读或者要写的地址。第三个参数size,很明显,就是规定了需要检验的范围(以byte为单位)。verify_area()有返回值:返回0表示对相应内存的相应操作是被允许的;非0表示不被允许。典型的操作可能是这样;
典型操作
flag = verify_area(VERIFY_READ, buf, buf_len);
if(flag != 0){
…/* error handler: unable to read buf */
}
另外几个函数形式都比较相似。比如copy_to_user
l memcpy_fromfs
l memcpy_tofs
两个函数是为了兼容老的内核而保留的函数,他们的用法是配合verify_area(),用于从用户空间读数据到内核空间,或者把内核空间的数据写到用户空间去。比如还是刚刚举的例子:
典型操作
flag = verify_area(VERIFY_READ, user_buf, buf_len);
if(flag != 0){
…/* error handler: unable to read user_buf */
}
memcpy_fromfs
而另外两个内核函数:
l copy_to_user
l copy_from_user
在新的内核中使用得更多,因为他们不必跟verify_area() 配合就能使用。事实上copy_to_user
为了加深印象,我们可以拿出内核中的相应代码来看看:
kernel/time.c
101 asmlinkage
102 {
103 if (likely(tv != NULL)) {
104 struct timeval
105 do_gettimeofday
106 if (copy_to_user
107 return -EFAULT
108 }
109 if (unlikely(tz != NULL)) {
110 if (copy_to_user(tz, &sys_tz
111 return -EFAULT
112 }
113 return 0;
114 }
这段代码相信你已经比较熟悉了,是的,在讲 “Kernel Timer” 的时候已经讲过。我们这里主要印证一下我们刚才学到的东西。
104行:在内核空间定义一个与用户空间一样的结构体:ktv;
105行:往ktv里面填入具体的值;
106行:调用copy_to_user
2.6.2编写内核程序需要注意的一些问题
标准C库内核头文件与内核代码头文件
前面提到修改unistd.h的时候需要修改两个不同地方的unistd.h,为什么会这样呢?事实上,这是由于在你的系统中,存在两个版本的内核头文件:一个是标准C库内核头文件,主要是/usr/include/asm和/usr/include/linux两个目录;另一个是内核代码头文件,主要是/usr/src/linux/include/asm和/usr/src/include/linux两个目录。这两个头文件是不同的。标准C库内核头文件是用于编写用户态程序时使用,它与系统中的标准C库对应。所以,只要你不是升级你系统中的标准C库,就不需要修改标准C库内核头文件。而内核代码头文件只有在你编写内核程序的时候才会用到。这是两个完全不同的概念,比如可能在你的系统中使用的是2.6.*版本的内核,而系统内核头文件却还是2.2.*版本的。这并不影响你系统的正常使用。编译完你自己的新内核之后,你不需要把对应的asm和linux两个目录拷贝到/usr/include下面去,那样做反而是错误的。在有些早期的Linux发行版中,系统的/usr/include/asm和/usr/include/linux目录分别是到/usr/src/linux/include/asm和/usr/src/linux/include/linux目录的链接,Linus本人已经就这种情况进行了说明,并且指出那样做是没有任何道理的。在本章中,我们有时候是在编写内核程序,有时候是在编写用户程序,请读者区分这些情况。不要产生混淆。如果编写的代码运行在内核空间,那么用户态的标准C库就不能使用。也就是说,你不能使用printf,也没有fopen、malloc。不过,你也不必太着急,内核会有自己的一套函数调用提供给你。我们等会儿介绍。
防止内核被锁死和崩溃
不得不提醒你一下,当你编写的程序运行在内核中的时候,你是万能的,没有内存保护,没有权限限制,你可以做你想做的任何事情。当然,也可能导致你不想看到的任何事情:
l 没有内存保护。你的程序不小心就可能破坏了内核的内存映像;
l 只有6k左右的内核堆栈(i386系统结构中),而且还要和中断程序共用。因此也许你的程序使用了太多的内部变量,多到内核堆栈都放不下;
l 也许你禁止了中断,却又去调用了某些可能要sleep的函数等等。
不过,虽然如此,你也不必害怕得不敢动手。毕竟,没有冒险就没有进步,不是吗?
一些可能会用到的函数
① printk()(定义在include/linux/kernel.h中)
printk()函数是在编写内核代码时经常会使用到的一个函数。它被用来打印信息到console,或者到系统日志里。这对于我们开发和调试内核代码非常有用。你也许已经发现,它跟我们经常使用的标准C库函数printf()有些相像。的确,除了在第一个参数给出打印的权限级别外,printk()和printf()函数在其他参数的使用上基本上是一致的。比如:
printk(KERN_INFO “I’m in the kernel. my pid: %d.\n”, i);
更多的KERN_标志,你可以查看:
include/linux/kernel.h
33 #define KERN_EMERG
34
35 #define KERN_CRIT
36 #define KERN_ERR
37 #define KERN_WARNING
38 #define KERN_NOTICE
39 #define KERN_INFO
40 #define KERN_DEBUG
你可以使用上面的任何一个关键词。它们表示不同的信息级别,如果这个级别比console的级别高,那么信息就会被打印到终端上。否则,你可以到这个系统日志文件中去查看:/var/log/messages.
② copy_[to/from]_user() / get_user() / put_user()(定义在include/asm/uaccess.h中)
这个刚刚在“2.6.1 用户空间与内核空间的数据交换”讲了。这里不再重复。值得强调的一点是,这些函数都可能会sleep在某个地方。如果你在调用这些可能sleep的函数之前关了中断,那么,它们将永远不会醒来。
③ kmalloc()/kfree() (定义在include/linux/slab.h中)
这两个内核函数是用来在内核编程时分配和释放内存时使用。有点像在用户空间我们平常编程时经常使用到的malloc()/free()函数。不过kmalloc()函数还使用了一个标志位:
void * kmalloc
参数size表明要申请的内存大小(以byte为单位),flags参数表示申请内存的类型,这些类型可以是:
l GFP_KERNEL - 申请内存的进程可能被放入等待队列,也可能被交换到swap分区,但是仍然是使用最为普遍,也是分配到内存的最可靠的方式。
l GFP_USER - 用于为用户分配内存,也可能被放入等待队列,是优先级很低的一种请求方式。
l GFP_ATOMIC - 分配的时候不会被放入等待队列,如果没有分配到内存,则立即返回。多用在中断处理内部。
l GFP_DMA - 这个标志表明分配的内存用于DMA。对于不同的平台这有不同的含义,在i386平台上,意味着这些内存必须来自于物理内存的最初16M。
其它的使用方法上,这两个函数与用户态的malloc()/free()非常相近。
实验思考
这里,我们把系统调用的知识和“Kernel Module”一章的知识结合起来,用kernel module的方法来实现一个系统调用。这个系统调用是gettimeofday的简化版本。那么,你通过module方法添加一个系统调用的想法可行吗。例如,使用如下代码:
具体代码示例如下:
/* pedagogictime.c */
#include
#include
#include
/* 在这个头文件里面包含了所有的系统调用号 __NR_... */
#include
/* for struct time* */
#include
/* for copy_to_user() */
#include
/* for current macro */
#include
#define __NR_pedagogictime 238
MODULE_DESCRIPTION("My sys_pedagogictime()");
MODULE_AUTHOR("Your Name :), (C) 2002, GPLv2 or later");
/* 用来保存旧系统调用的地址 */
static int (*anything_saved)(void);
/* 这个是我们自己的系统调用函数sys_pedagogictime(). */
static int sys_pedagogictime(struct timeval *tv)
{
struct timeval ktv;
/* 这里我们需要增加模块使用计数。*/
MOD_INC_USE_COUNT;
do_gettimeofday(&ktv);
if (copy_to_user(tv, &ktv, sizeof(ktv))){
MOD_DEC_USE_COUNT;
return -EFAULT;
}
printk(KERN_ALERT"Pid %ld called sys_gettimeofday().\n",(long)current->pid);
MOD_DEC_USE_COUNT;
return 0;
}
/* 这里是初始化函数。__init标志表明这个函数使用后就可以丢弃了。*/
int __init init_addsyscall(void)
{
extern long sys_call_table[];
/* 保存原来系统调用表中此位置的系统调用 */
anything_saved = (int(*)(void))(sys_call_table[__NR_pedagogictime]);
/* 把我们自己的系统调用放入系统调用表,注意进行类型转换*/
sys_call_table[__NR_pedagogictime] = (unsigned long)sys_pedagogictime;
return 0;
}
/* 这里是退出函数。__exit标志表明如果我们不是以模块方式编译这段程序,则这个标志后的
* 函数可以丢弃。也就是说,模块被编译进内核,只要内核还在运行,就不会被卸载。
*/
void __exit exit_addsyscall(void)
{
extern long sys_call_table[];
/* 恢复原先的系统调用 */
sys_call_table[__NR_pedagogictime] = (unsigned long)anything_saved;
}
/* 这两个宏告诉系统我们真正的初始化和退出函数 */
module_init(init_addsyscall);
module_exit(exit_addsyscall);
然后,用命令:
gcc -Wall -O2 -DMODULE -D__KERNEL__ -DLINUX -c pedagogictime.c.
编译成.o文件,然后使用insmod pedagogictime.o把它动态地加载到正在运行的内核中。显然,这样的做法比起我们先前的那种要重新编译内核的办法更加灵活,更加方便。这也正是Linux kernel module program如此受欢迎的原因。
这样的做法正确吗?回答很简单,只要用测试程序验证一下,即有结论:
测试用的用户程序
/* for struct timeval */
#include
/* for _syscall1 */
#include
#define __NR_pedagogictime 238
_syscall1(int, pedagogictime, struct timeval *, thetime)
int main()
{
struct timeval tv;
pedagogictime(&tv);
printf("tv_sec : %ld\n", tv.tv_sec);
printf("tv_nsec: %ld\n", tv.tv_usec);
printf("em..., let me sleep for 2 second.:)\n");
sleep(2);
pedagogictime(&tv);
printf("tv_sec : %ld\n", tv.tv_sec);
printf("tv_nsec: %ld\n", tv.tv_usec);
}
假设这个程序是test.c,那么使用gcc -o test test.c得到test可执行文件,然后你可以执行这个test看看结果。
再次问一下,此法可行吗?你能想出更好的办法吗?
关于系统调用的所有主题,我们就讲到这里。该讲的都讲完了,你该做的都做了没有呢?一切都还没有结束,还有更多的任务等待着你我去完成。