1.系统调用函数接口是如何转化为陷入命令
系统调用是通过一条陷入指令进入核心态,然后根据传给核心的系统调用号为索引在系统调用表中找到相映的处理函数入口地址。这里将详细介绍这一过程。
我们以x86为例说明:
由
于陷入指令是一条特殊指令,而且依赖与操作系统实现的平台,如在x86中,这条指令是int
0x80,这显然不是用户在编程时应该使用的语句,因为这将使得用户程序难于移植。所以在操作系统的上层需要实现一个对应的系统调用库,每个系统调用都在
该库中包含了一个入口点(如我们看到的fork, open,
close等等),这些函数对程序员是可见的,而这些库函数的工作是以对应系统调用号作为参数,执行陷入指令int
0x80,以陷入核心执行真正的系统调用处理函数。当一个进程调用一个特定的系统调用库的入口点,正如同它调用任何函数一样,对于库函数也要创建一个栈
帧。而当进程执行陷入指令时,它将处理机状态转换到核心态,并且在核心栈执行核心代码。
这里给出一个示例(linux/include/asm/unistd.h):
#define _syscallN(type, name, type1, arg1, type2, arg2, . . . ) \
type name(type1 arg1,type2 arg2) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2))); \
. . . . . .
__syscall_return(type,__res); \
}
在执行一个系统调用库中定义的系统调用入口函数时,实际执行的是类似如上的一段代码。这里牵涉到一些gcc的嵌入式汇编语言,不做详细的介绍,只简单说明其意义:
其
中__NR_##name是系统调用号,如name ==
ioctl,则为__NR_ioctl,它将被放在寄存器eax中作为参数传递给中断0x80的处理函数。而系统调用的其它参数arg1, arg2,
…则依次被放入ebx, ecx, . . .等通用寄存器中,并作为系统调用处理函数的参数,这些参数是怎样传入核心的将会在后面介绍。
下面将示例说明:
int func1()
{
int fd, retval;
fd = open(filename, ……);
……
ioctl(fd, cmd, arg);
. . .
}
func2()
{
int fd, retval;
fd = open(filename, ……);
……
__asm__ __volatile__(\
"int $0x80\n\t"\
:"=a"(retval)\
:"0"(__NR_ioctl),\
"b"(fd),\
"c"(cmd),\
"d"(arg));
}
这两个函数在Linux/x86上运行的结果应该是一样的。
若
干个库函数可以映射到同一个系统调用入口点。系统调用入口点对每个系统调用定义其真正的语法和语义,但库函数通常提供一个更方便的接口。如系统调用
exec有集中不同的调用方式:execl,
execle,等,它们实际上只是同一系统调用的不同接口而已。对于这些调用,它们的库函数对它们各自的参数加以处理,来实现各自的特点,但是最终都被映
射到同一个核心入口点。
2. 系统调用陷入内核后作的参数传递过程
当进程执行系统调用时,先调用系统调用库中定义某个函数,该函数通常被展开成前面提到的_syscallN的形式通过INT 0x80来陷入核心,其参数也将被通过寄存器传往核心。
在这一部分,我们将介绍INT 0x80的处理函数system_call。
思
考一下就会发现,在调用前和调用后执行态完全不相同:前者是在用户栈上执行用户态程序,后者在核心栈上执行核心态代码。那么,为了保证在核心内部执行完系
统调用后能够返回调用点继续执行用户代码,必须在进入核心态时保存时往核心中压入一个上下文层;在从核心返回时会弹出一个上下文层,这样用户进程就可以继
续运行。
那么,这些上下文信息是怎样被保存的,被保存的又是那些上下文信息呢?这里仍以x86为例说明。
在执行INT指令时,实际完成了以下几条操作:
(1) 由于INT指令发生了不同优先级之间的控制转移,所以首先从TSS(任务状态段)中获取高优先级的核心堆栈信息(SS和ESP);
(2) 把低优先级堆栈信息(SS和ESP)保留到高优先级堆栈(即核心栈)中;
(3) 把EFLAGS,外层CS,EIP推入高优先级堆栈(核心栈)中。
(4) 通过IDT加载CS,EIP(控制转移至中断处理函数)
然后就进入了中断0x80的处理函数system_call了,在该函数中首先使用了一个宏SAVE_ALL,该宏的定义如下所示:
#define SAVE_ALL \
cld; \
pushl %es; \
pushl %ds; \
pushl %eax; \
pushl %ebp; \
pushl %edi; \
pushl %esi; \
pushl %edx; \
pushl %ecx; \
pushl %ebx; \
movl $(__KERNEL_DS),%edx; \
movl %edx,%ds; \
movl %edx,%es;
该
宏的功能一方面是将寄存器上下文压入到核心栈中,对于系统调用,同时也是系统调用参数的传入过程,因为在不同特权级之间控制转换时,INT指令不同于
CALL指令,它不会将外层堆栈的参数自动拷贝到内层堆栈中。所以在调用系统调用时,必须先象前面的例子里提到的那样,把参数指定到各个寄存器中,然后在
陷入核心之后使用SAVE_ALL把这些保存在寄存器中的参数依次压入核心栈,这样核心才能使用用户传入的参数。
下面给出system_call的源代码:
ENTRY(system_call)
pushl %eax # save orig_eax
SAVE_ALL
GET_CURRENT(%ebx)
cmpl $(NR_syscalls),%eax
jae badsys
testb $0x20,flags(%ebx) # PF_TRACESYS
jne tracesys
call *SYMBOL_NAME(sys_call_table)(,%eax,4)
. . . . . .
在这里所做的所有工作是:
Ⅰ.保存EAX寄存器,因为在SAVE_ALL中保存的EAX寄存器会被调用的返回值所覆盖;
Ⅱ.调用SAVE_ALL保存寄存器上下文;
Ⅲ.判断当前调用是否是合法系统调用(EAX是系统调用号,它应该小于NR_syscalls);
Ⅳ.如果设置了PF_TRACESYS标志,则跳转到syscall_trace,在那里将会把当前程挂起并向其父进程发送SIGTRAP,这主要是为了设置调试断点而设计的;
Ⅴ.如果没有设置PF_TRACESYS标志,则跳转到该系统调用的处理函数入口。这里是以EAX(即前面提到的系统调用号)作为偏移,在系统调用表sys_call_table中查找处理函数入口地址,并跳转到该入口地址。
(补充说明:
1.GET_CURRENT宏
#define GET_CURRENT(reg) \
movl %esp, reg; \
andl $-81Array2, reg;
其
作用是取得当前进程的task_struct结构的指针返回到reg中,因为在Linux中核心栈的位置是task_struct之后的两个页面处
(81Array2bytes),所以此处把栈指针与-81Array2则得到的是task_struct结构指针,而task_struct中偏移为4
的位置是成员flags,在这里指令testb $0x20,flags(%ebx)检测的就是task_struct->flags。)
INT 0x80 SS
pt_regs
ESP
SALL_ALL 用户栈
CALL syscall_entry
核心栈
堆栈中的参数分析:
正
如前面提到的,SAVE_ALL是系统调用参数的传入过程,当执行完SAVE_ALL并且再由CALL指令调用其处理函数时,堆栈的结构应该如上图所示。
这时的堆栈结构看起来和执行一个普通带参数的函数调用是一样的,参数在堆栈中对应的顺序是(arg1, ebx),(arg2,
ecx),(arg3, edx). . . . .
.,这正是SAVE_ALL压栈的反顺序,这些参数正是用户在使用系统调用时试图传送给核心的参数。下面是在核心的调用处理函数中使用参数的两种典型方
法:
asmlinkage int sys_fork(struct pt_regs regs);
asmlinkage int sys_open(const char * filename, int flags, int mode);
在
sys_fork中,把整个堆栈中的内容视为一个struct
pt_regs类型的参数,该参数的结构和堆栈的结构是一致的,所以可以使用堆栈中的全部信息。而在sys_open中参数filename,
flags, mode正好对应与堆栈中的ebx, ecx, edx的位置,而这些寄存器正是用户在通过C库调用系统调用时给这些参数指定的寄存器。
__asm__ __volatile__(\
"int $0x80\n\t"\
:"=a"(retval)\
:"0"(__NR_open),\
"b"(filename),\
"c"(flags),\
"d"(mode));
核心如何使用用户空间的参数:
在
使用系统调用时,有些参数是指针,这些指针所指向的是用户空间DS寄存器的段选择子所描述段中的地址,而在2.2之前的版本中,核心态的DS段寄存器的中
的段选择子和用户态的段选择子描述的段地址不同(前者为0xC0000000,
后者为0x00000000),这样在使用这些参数时就不能读取到正确的位置。所以需要通过特殊的核心函数(如:memcpy_fromfs,
mencpy_tofs)来从用户空间数据段读取参数,在这些函数中,是使用FS寄存器来作为读取参数的段寄存器的,FS寄存器在系统调用进入核心态时被
设成了USER_DS(DS被设成了KERNEL_DS)。在2.2之后的版本用户态和核心态使用的DS中段选择子描述的段地址是一样的(都是
0x00000000),所以不需要再经过上面那样烦琐的过程而直接使用参数了。
内存映射分析:
Linux将4G的地址划分为用户空间和内核空间两部分。在Linux内核的低版本中(2。0。X),通常0-3G为用户空间,3G-4G为内核空间。这个分界点是可以可以改动
的。
正是这个分界点的存在,限制了Linux可用的最大内存为2G.而且要通过重编内核,调整这个分界点才能达到。实际上还可以有更好的方法来解决这个问题。由于内核空间与用户空间互不重合,所以可以用段机制提供的保护功能来保护内核级代码。
以下为2.0.X的部分代码:
/usr/src/linux/arch/i386/kernel/entry.S
A: .quad 0xc0c3Arraya000000ffff /* 0x10 kernel 1GB code at 0xC0000000 *
B: .quad 0xc0c3Array2000000ffff /* 0x18 kernel 1GB data at 0xC0000000 *
C: .quad 0x00cbfa000000ffff /* 0x23 user 3GB code at 0x00000000 *
D: .quad 0x00cbf2000000ffff /* 0x2b user 3GB data at 0x00000000 *
A,B为内核代码段及数据段的描述符。C,D为用户代码及数据段的描述符
从以上,我们可以清楚的看到A,B的特权级为0,而C,D的特权级为3。当内核
存取用户空间的内容时,他借助于fs寄存器,同过将FS寄存器的内容置为D
来达到访问用户空间的目的。
2.2.X版的 内核对此进行了改动。这样内核空间扩张到了4G,所以可以直接进行拷贝了
.quad 0x00cfArraya000000ffff /* 0x10 kernel 4GB code at 0x00000000 *
.quad 0x00cfArray2000000ffff /* 0x18 kernel 4GB data at 0x00000000 *
.quad 0x00cffa000000ffff /* 0x23 user 4GB code at 0x00000000 *
.quad 0x00cff2000000ffff /* 0x2b user 4GB data at 0x00000000 *
从表面上看内核的基地址变为了0,但实际上,内核通常仍在虚址3G以上。其中奥妙在与 不同的连接描述文件:
2.2.X: = 0xC0000000 + 0x100000;
2.0.X:= 0x100000;
2.0.X的起址为0x100000。这样一来,二者就相等了。
都是0xC0000000 + 0x100000
用户空间在2.2.X中从直观上变为0-4G,让人迷惑:其不是可以直接访问内核了?
其实不然, 同过使用页机制提供的保护,阻止了用户程序访问内核空间。
这样,存取用户空间实际上已不需要FS,GS的支持。但在内核中仍保留set_fs(X)等宏上你设的值用来验证随后的操作是否合适。是否超过设定的X。此处X不再是一个段描述符,而是一个具体的值。
此处就有一个陷阱:如果你将Set_fs的值设置为Kernel_DS,而没有将其该回去,当用户通过系统调用将一个Buffer的地址(应该在用户空间)设置为一个内核空间,而内核在访问该地址前认为默认当前的阀值仍为User_DS,事情就大大?
原文来自:
阅读(304) | 评论(0) | 转发(0) |