深入理解Linux内核--系统调用

在应用程序和硬件间设置一个额外层优点:
1.使得编程更加容易
把用户从学习硬件设备的低级编程特性中解放出来
2.极大提高了系统的安全性
内核在试图满足某个请求前在接口级就可检查这种请求的正确性
3.接口使得程序更具有可移植性

Unix系统通过向内核发出系统调用实现了用户态进程和硬件设备间的大部分接口

POSIX API和系统调用

API:
一个函数定义,说明了如何获得一个给定的服务
系统调用:
通过软中断向内核态发出一个明确的请求

Unix系统给程序员提供了很多API的库函数。
libc的标准C库所定义的一些API引用了封装例程
通常下,每个系统调用应对应一个封装例程
而封装例程定义了应用程序使用的API

一个API没必要对应一个特定的系统调用
首先,
API可能直接提供用户态的服务
其次,
一个单独的API函数可能调几个系统调用

Posix标准针对API而不针对系统调用
判断一个系统是否与POSIX兼容要看它是否提供了一组合适的应用程序接口,
而不管对应的函数是如何实现的
事实上,
一些非Unix系统被认为是与POSIX兼容的
因为它们在用户态的库函数中提供了传统Unix能提供的所有服务

从编程者观点看,
API和系统调用间的差别没关系:
唯一相关的事情就是函数名,参数类型,返回代码的含义

从内核设计者观点看,
这种差别确实有关系,
因为系统调用属于内核,用户态的库函数不属于内核

大部分封装例程返回一个整数,
其值的含义依赖于相应的系统调用
返回值-1通常表示内核不能满足进程的要求。
在libc库中定义的errno变量包含特定的出错码。

系统调用处理程序及服务例程

当用户态的进程调一个系统调用时,
CPU切换到内核态并开始执行一个内核函数。
在80x86体系结构中,
可用两种不同的方式调Linux的系统调用
两种方式的最终结果都是跳转到所谓系统调用处理程序的汇编语言函数

因为内核实现了很多不同的系统调用
故进程必须传递一个名为系统调用号的参数来识别所需的系统调用
eax寄存器就用作此目的
当调用一个系统调用时通常还要传递另外的参数

所有的系统调用都返回一个整数值
这些返回值与封装例程返回值的约定不同
在内核中
正数或0表示系统调用成功结束
负数表示一个出错条件
后一种情况下,这个值就是存放在errno中必须返回给应用的负出错码
内核没设置或使用errno变量,
而封装例程从系统调用返回后设置这个变量
系统调用处理程序与其他异常处理程序结构类似
执行:
1.在内核态保存大多数寄存器的内容
2.调名为系统调用服务例程的相应C函数来处理系统调用
3.退出系统调用处理程序:
用保存在内核栈中的值加载寄存器
CPU从内核态切换回用户态
xyz系统调用对应的服务例程名通常是sys_xyz
1.在应用程序中系统调用的调用
xyz()
2.在Libc标准库中的封装例程
xyz()
{
	...
	SYSCALL
	...
}
3.系统调用处理程序--内核态
system_call:
	...
	sys_xyz()
	...
	SYSEXIT
4.系统调用服务例程--内核态
sys_xyz()
{
	...
}
为了把系统调用号与相应的服务例程关联起来
内核利用了一个系统调用分派表
这个表存放在sys_call_table数组
有NR_syscalls个表项
第n个表项包含系统调用号为n的服务例程的地址

NR_syscalls只是对可实现的系统调用最大个数的静态限制,
并不表示实际已实现的系统调用个数
实际上,
分派表中的任意一个表项也可包含sys_ni_syscall函数的地址
这个函数是"未实现"系统调用的服务例程
它仅仅返回出错码-ENOSYS

进入和退出系统调用

本地应用可通过两种不同方式调系统调用:
1.执行int $0x80
在Linux内核老版本,
这是从用户态切换到内核态的唯一方式
2.执行sysenter
在Intel Pentium 2中引入了这条指令
Linux 2,6内核支持此指令
同样,内核可通过两种方式从系统调用退出,从而使CPU切换回用户态
1.执行iret
2.执行sysexit
但支持进入内核的两种不同方式不像看起来那么简单
1.内核必须既支持只使用int $0x80
也支持sysenter
2.使用sysenter的标准库必须能处理仅支持int $0x80的旧内核
3.内核和标准库必须既能运行在不包含sysenter指令的旧处理器上
也能运行在包含它的新处理器上

通过int $0x80发出系统调用

向量128对应于内核入口点
在内核初始化期间调的函数trap_init用下面的方式建立对应于向量128的中断描述符表项:
set_system_gate(0x80, &system_call);
该调用把下列值存入这个门描述符的相应字段
Segment Selector
	内核代码段__KERNEL_CS的段选择符
Offset
	指向system_call系统调用处理程序的指针
Type
	15,表示这个异常是一个陷阱。相应的处理程序不禁止可屏蔽中断
DPL
	3。允许用户态进程调这个异常处理程序
当用户态进程发出int $0x80时,
CPU切换到内核态并开始从地址system_call处开始执行指令

system_call

首先把系统调用号和这个异常处理程序可用到的所有CPU寄存器保存到相应栈,
不包含由控制单元已自动保存的eflags,cs,eip,ss和esp

在第4章已经讨论的SAVE_ALL,也在ds和es中装入内核数据段的段选择符
system_call:
	pushl %eax
	SAVE_ALL
	movl $0xffffe000, %ebx
	andl %esp, %ebx
随后,
这个函数在ebx中存放当前进程的thread_info的地址
这是通过获得内核栈指针的值并把它取整到4KB或8KB的倍数而完成的

接下来,
system_call检查thread_info结构flag字段的TIF_SYSCALL_TRACE和TIF_SYSCALL_AUDIT之一是否被设置为1,
即检查是否有某一调试程序正在跟踪执行程序对系统调用的调用
如是,
则system_call两次调do_syscall_trace:
一次正好在这个系统调用服务例程执行前,
一次在其之后
这个函数停止current
并因此允许调试进程收集关于current的信息

然后, 
对用户进程传递来的系统调用号进行有效性检查
如这个号大于或等于系统调用分派表中的表项数
系统调用处理程序就终止
	cmpl $NR_syscalls, %eax
	jb nobadsys
	movl $(-ENOSYS), 24(%esp)
	jmp resume_userspace
nobadsys:
如系统调用号无效
该函数就把-ENOSYS值存放在栈中曾保存eax寄存器的单元中
(从当前栈顶开始偏移量为24的单元)
然后跳到resume_userspace
这样,
当进程恢复它在用户态的执行时,
会在eax中发现一个负的返回码

最后,调与eax中所包含的系统调用号对应的特定服务例程
call *sys_call_table(0, %eax, 4)
因为分派表中的每个表项占4个字节
故首先把系统调用号乘以4
再加上sys_call_table分派表的起始地址
然后从这个地址单元获取指向服务例程的指针
内核就找到了要调用的服务例程

从系统调用退出

当系统调用服务例程结束时,
system_call从eax获得它的返回值,
把这个返回值存放在曾保存用户态eax寄存器值的那个栈单元的位置上
movl %eax, 24(%esp)
故,用户态进程将在eax中找到系统调用的返回码
然后,
system_call关闭本地中断并检查当前进程的thread_info结构中的标志
cli
movl 8(%ebp), %ecx
testw $0xffff, %cx
je restore_all
flags字段在thread_info结构中的偏移量为8
所有标志都没设置,函数就跳到restore_all
restore_all恢复保存在内核栈中的寄存器的值,
并执行iret以重新开始执行用户态进程

只要任何一种标志被设置
则就要在返回用户态之前完成一些工作
如TIF_SYSCALL_TRACE被设置,
system_call就第二次调do_syscall_trace
然后跳到resume_userspace
否则,如TIF_SYSCALL_TRACE没被设置,
就跳到work_pending

在resume_userspace和work_pending处的代码检查重新调度请求,虚拟8086模式,挂起信号,单步执行
最终跳到restore_all处以恢复用户态进程的运行

通过sysenter发出系统调用

int由于要执行几个一致性检查和安全性检查
故速度慢
在Intel文档中被称为"快速系统调用"的sysenter指令,
提供了一种从用户态到内核态的快速切换方法

sysenter

使用三种特殊的寄存器,它们需装入以下信息
SYSENTER_CS_MSR
	内核代码段的段选择符
SYSENTER_EIP_MSR
	内核入口点的线性地址
SYSENTER_ESP_MSR
	内核堆栈指针
执行sysenter指令时,CPU控制单元:
1.把SYSENTER_CS_MSR内容拷贝到cs
2.把SYSENTER_EIP_MSR内容拷贝到eip
3.把SYSENTER_ESP_MSR内容拷贝到esp
4.把SYSENTER_CS_MSR加8值装入ss
故,CPU切换到内核态并开始执行内核入口点的第一条指令

在内核初始化期间,
一旦系统中的每个CPU执行enable_sep_cpu
三个特定于模型的寄存器就由该函数初始化
enable_sep_cpu执行:
1.把内核代码__KERNEL_CS的段选择符写入SYSENTER_CS_MSR
2.把下面要说明的函数sysenter_entry的线性地址写入SYSENTER_CS_EIP
3.计算本地TSS末端的线性地址,把这个值写入SYSENTER_CS_ESP
对SYSENTER_CS_ESP的设置有必要进行一些说明。
系统调用开始的时候,
内核堆栈是空的。
故,esp寄存器应指向4KB或8KB内存区域的末端
该内存区域包括内核堆栈和当前进程的描述符
因为用户态的封装例程不知这个内存区域的地址
所以它不能正确设置此寄存器

另一方面,
必须在切换到内核态前设置该寄存器的值
故,内核初始化这个寄存器以便为本地CPU的任务状态段编址
	
每次进程切换时,
内核把当前进程的内核栈指针保存到本地TSS的esp0
这样,系统调用处理程序读esp,
计算本地TSS的esp0
把正确的内核堆栈指针装入esp

vsyscall页

只要CPU和Linux都支持sysenter
标准库libc中的封装函数就可使用它

这个兼容性问题需要复杂的解决方案
本质上,
初始化阶段sysenter_setup建立一个称为vsyscall页的页框
其中包括一个小的EFL共享对象(一个小的EFL动态链接库)
当进程发出execve开始执行一个ELF程序时,
vsyscall页中的代码会自动被链接到进程的地址空间
vsyscall页中的代码使用最有用的指令发出系统调用

sysenter_setup为vsyscall页分配一个新页框
把它的物理地址与FIX_VSYSCALL固定映射的线性地址相关联
函数sysenter_setup把预先定义好的一个或两个EFL共享对象拷贝到该页
1.如CPU不支持sysenter
sysenter_setup建立一个包含下列代码的vsyscall页
__kernel_vsyscall:
	int $0x80
	ret
2.否则,如CPU的确支持sysenter,sysenter_setup建立一个包括下列代码的vsyscall页
__kernel_vsyscall:
	pushl %ecx
	pushl %edx
	pushl %ebp
	movl %esp, %ebp
	sysenter
当标准库中的封装例程必须调系统调用时,
调__kernel_vsyscall,不管它的实现代码是什么

最后一个兼容问题是由于老版本Linux内核不支持sysenter
此情况下
内核当然不建立vsyscall页
且函数__kernel_vsyscall不会被链接到用户态进程的地址空间
当新近的标准库识别这种状况后,
简单执行int $0x80调系统调用

进入系统调用

当用sysenter发出系统调用时,依次执行下述:
1.标准库的封装例程把系统调用号装入eax寄存器
调__kernel_vsyscall
2.__kernel_vsyscall把ebp,edx,ecx的内容保存到用户态堆栈
(系统调用处理程序将使用这些寄存器)
把用户栈指针拷贝到ebp
执行sysenter
3.CPU从用户态切换到内核态
内核开始执行sysenter_entry
(由SYSENTER_EIP_MSR指向)
4.sysenter_enter执行下述:
4.1.建立内核堆栈指针
movl -508(%esp), %esp
开始时,
esp寄存器指向本地TSS的第一个位置
本地TSS的大小为512字节。
故,sysenter把本地TSS中偏移量为4处的字段的内容(esp0)装入esp
esp0总数存放当前进程的内核堆栈指针
4.2.打开本地中断
sti
4.3.把用户数据段的段选择符,当前用户栈指针,eflags,用户代码段的段选择符及从系统调用退出时要执行的指令的地址保存到内核堆栈
pushl $(__USER_DS)
pushl %ebp
pushfl
pushl $(__USER_CS)
pushl $SYSENTER_RETURN
这些指令仿效int所执行的一些操作
4.4.把原来由封装例程传递的寄存器的值恢复到ebp
movl (%ebp), %ebp
上面这指令完成恢复工作,
因为__kernel_vsyscall把ebp原始值存入用户态堆栈
在随后把用户堆栈指针的当前值装入ebp
4.5.通过执行一系列指令调用系统调用处理程序,
这些指令与前面通过int $0x80指令发出系统调用
一节描述的在system_call处开始的指令是一样的

退出系统调用

系统调用服务例程结束时,
sysenter_entry本质上执行与system_call相同的操作
首先,
它从eax获得系统调用服务例程的返回码
将返回码存入内核栈中保存用户态eax寄存器值的位置
然后,
函数禁止本地中断,
检查current的thread_info结构中的标志

如有任何标志被设置
则在返回到用户态前需完成一些工作
为避免代码复制
函数跳到resume_userspace或work_pending处
最后,
汇编语言指令iret从内核堆栈中取五个参数
这样CPU切换安东用户态并开始执行SYSENTER_RETURN标记处代码

如sysenter_entry确定标志被清0
它就快速返回到用户态
movl 40(%esp), %edx
movl 52(%esp), %ecx
xorl %ebp, %ebp
sti
sysexit
把在上一节由sysenter_entry函数在第4c步保存的一对堆栈值加载到edx和ecx
edx获得SYSENTER_RETURN标记处地址
而ecx获得当前用户数据栈的指针

sysexit

sysexit是与sysenter配对的指令
它允许从内核态快速切换到用户态
执行此指令时,
CPU控制单元执行下述:
1.把SYSENTER_CS_MSR的值加16结果加载到cs
2.把edx寄存器的内容拷贝到eip
3.把SYSENTER_CS_MSR中的值加24得到的结果加载到ss
4.把ecx的内容拷贝到esp
因为SYSENTER_CS_MSR加载的是内核代码的选择符
cs加载的是用户代码的选择符
ss加载的用户数据段的选择符
结果,CPU从内核态切换到用户态
开始执行其地址存放在edx中的那条指令

SYSENTER_RETURN

存放在vsyscall页
当通过sysenter进入的系统调用被iret或sysexit终止时,
该页框中的代码被执行

该代码恢复保存在用户态堆栈中的ebp,edx,ecx寄存器的原始内容
并把控制权返回给标准库中的封装例程
SYSENTER_RETURN:
	popl %ebp
	popl %edx
	popl %ecx
	ret

参数传递

系统调用通常也许输入/输出参数
这些参数可能是实际的值
也可能是用户态进程地址空间的变量
甚至是指向用户态函数的指针的数据结构地址
	
因为system_call和sysenter_entry是Linux中所有系统调用的公共入口点
故每个系统调用至少有一个参数,
即通过eax寄存器传递来的系统调用号
如,如一个应用程序调fork,
则在执行int $0x80或sysenter之前就把eax置为2

因为这个寄存器的值由libc中的封装例程进行
故程序员通常不关系系统调用号

fork系统调用并不需其他的参数
不过,很多系统调用确实需由应用程序明确传递另外的参数
如mmap可能需多达6个额外参数

普通c函数参数传递通过把参数值写入活动的程序栈
系统调用是横跨用户和内核的特殊函数。
故,既不能用用户态栈也不能用内核态栈

发出系统调用前,
系统调用的参数被写入CPU寄存器
在调用系统调用服务例程前,
内核再把存放在CPU中的参数拷贝到内核态堆栈
因为,系统调用服务例程是普通的c函数

为何内核不直接把用户态的栈拷贝到内核态的栈?
1.同时操作两个栈比较复杂
2.寄存器的使用使系统调用处理程序的结构与其他异常处理程序的结构类似
然而,
为了用寄存器传递参数,需满足:
1.每个参数的长度不能超过寄存器的长度, 即32位
2.参数的个数不能超过6个(除eax中传递的系统调用号)
因为80x86处理器的寄存器数量有限

确实存在多于6个参数的系统调用
在此情况下,
用一个单独的寄存器指向进程地址空间中这些参数值所在的一个内存区
编程者不关系此工作区
调封装例程时,
参数被自动保存在栈。
封装例程将找到合适的方式把参数传递给内核

用于存放系统调用号和系统调用参数的寄存器是:
eax:系统调用号
ebx
ecx
edx
esi
edi
ebo
system_call和sysenter_entry使用SAVE_ALL把这些寄存器保存在内核态堆栈
故,
系统调用服务例程转到内核堆栈时,
会找到system_call或sysenter_entry的返回地址
接着是存放在ebx中的参数
存放在ecx中的参数等
系统调用服务例程容易通过用c语言结构来引用它的参数

例
int sys_write(unsigned int fd, const char * buf, unsigned int count)
int sys_fork(struct pt_regs regs)
服务例程的返回值必须写入eax寄存器中。
这是在执行“return n;”指令时由C编译程序自动完成的。

验证参数

访问进程地址空间

动态地址检查:修正代码

我们先说明一下在内核态引起缺页异常的四种情况。
这些情况必须由缺页异常处理程序来区分,因为不同情况采取的操作很不相同:
1.内核试图访问属于进程地址空间的页,
但是,或者是相应的页框不存在,
或者是内核试图去写一个只读页。
在这些情况下,处理程序必须分配和初始化一个新的页框
2.内核寻址到属于其地址空间的页,
但是相应的页表项还没有被初始化(参见第九章“处理非连续内存区访问”一节)。
在这种情况下,内核必须在当前进程页表中适当地建立一些表项。
3.某一内核函数包含编程错误,当这个函数运行时就引起异常;
或者,可能由于瞬时的硬件错误引起异常。
当这种情况发生时,处理程序必须执行一个内核漏洞
(参见第九章的“处理地址空间以外的错误地址”一节)。
4.本章所讨论的一种情况:系统调用服务例程试图读写一个内存区,
而该内存区的地址是通过系统调用参数传递来的,但却不属于进程的地址空间。

异常表

把访问进程地址空间的每条内核指令的地址放到一个叫异常表(exception table)的结构中并不用费太多功夫。
当在内核态发生缺页异常时,
do_page_fault()处理程序检查异常表:
如果表中包含产生异常的指令地址,
那么这个错误就是由非法的系统调用参数引起的,
否则,就是由某一更严重的bug引起的。

Linux定义了几个异常表。
主要的异常表在建立内核程序映像时由C编译器自动生成。
它存放在内核代码段的__ex_table节,
其起始与终止地址由C编译器产生的两个符号__start__ex_table和__stop__ex_table来标识。
每个动态装载的内核模块(参看附录二)都包含有自己的局部异常表。
这个表是在建立模块映像时由C编译器自动产生的,
当把模块插入到运行中的内核时把这个表装入内存。
每一个异常表的表项是一个exception_table_entry结构,它有两个字段:
insn
	访问进程地址空间的指令的线性地址。
fixup
	当存放在insn单元中的指令所触发的缺页异常发生时,
	fixup就是要调用的汇编语言代码的地址。
修正代码由几条汇编指令组成,用以解决由缺页异常所引起的问题。
在后面我们将会看到,修正通常由插入的一个指令序列组成,
这个指令序列强制服务例程返回一个出错码给用户态进程。
这些指令通常在访问进程地址空间的同一函数或宏中定义;
由C编译器把它们放置在内核代码段的一个叫作.fixup的独立部分。
search_exception_tables()函数用来在所有异常表中查找一个指定地址:
若这个地址在某一个表中,则返回指向相应exception_table_entry结构的指针;
否则,返回NULL。因此,缺页处理程序do_page_fault()执行下列语句:
if((fixup = search_exception_tables(regs->eip))){
	regs->eip = fixup->fixup;
	return 1;
}
regs->eip字段包含异常发生时保存到内核态栈eip寄存器中的值。
如果eip寄存器(指令指针)中的这个值在某个异常表中,
do_page_fault()就把所保存的值替换为search_exception_tables()的返回地址。
然后缺页处理程序终止,被中断的程序以修正代码的执行而恢复运行。

生成异常表和修正代码

GNU汇编程序(Assembler)伪指令.section允许程序员指定可执行文件的哪部分包含紧接着要执行的代码。
我们将在第二十章中看到,可执行文件包括一个代码段,
这个代码段可能又依次被划分为节。
因此,下面的汇编指令在异常表中加入一个表项;
“a”属性指定必须把这一节与内核映像的剩余部分一块加载到内存中。
.section __ex_table,"a"
.long faulty_instruction_address,fixup_code_address .previous
.previous伪指令强制汇编程序把紧接着的代码插入到遇到上一个.section伪指令时激活的节。

在这里插入图片描述
深入理解Linux内核--系统调用_第1张图片

每个异常表项由两个标号组成。
第一个是一个数字标号,其前缀b表示标号是“向后的”;
换句话说,标号出现在程序的前一行。
修正代码对这三个函数是公用的,且被标记为bad_get_user。
如果缺页异常是由标号1、2或3处的指令产生的,那么修正代码就执行。
在bad_get_user处的修正代码给发出系统调用的进程只简单地返回一个出错码-EFAULT。

内核封装例程

尽管系统调用主要由用户态进程使用,但也可以被内核线程调用,
内核线程不能使用库函数。
为了简化相应封装例程的声明,Linux定义了7个从_syscal10到_syscall6的一组宏。
	
每个宏名字中的数字0~6对应着系统调用所用的参数个数(系统调用号除外)。
也可以用这些宏来声明没有包含在libc标准库中的封装例程
(例如,因为Linux系统调用还未受到库的支持)。
然而,
不能用这些宏来为超过6个参数(系统调用号除外)的系统调用或产生非标准返回值的系统调用定义封装例程。
每个宏严格地需要2+2×n个参数,n是系统调用的参数个数。
前两个参数指明系统调用的返回值类型和名字;
每一对附加参数指明相应的系统调用参数的类型和名字。
因此,以fork()系统调用为例,其封装例程可以通过如下语句产生:
_syscall0(int,fork)
而write()系统调用的封装例程可以通过如下语句产生:
_syscall3(int,write,int,fd,const char *,buf,unsigned int,count)

你可能感兴趣的:(3-3.系统-Linux实现,linux,运维,服务器)