从clone看Linux系统调用实现
一、clone调用
这里选择clone作为例子来描述这个问题,是因为它的确有比较明显的特征,这个特征就是它的实现比较复杂。
首先,用户态的clone并不和内核的sys_clone直接对应,而其它的大部分用户态API和内核的sys_XXX接口的参数是一一对应的,例如select、open等。在glibc中,每个体系结构都有自己的一个clone.S文件,它将会封装用户APP调用的clone函数,在这个文件中,不同的体系结构会有不同的特殊操作。
其次,clone关系到执行体(线程)的创建,而线程是操作系统中的一个重要的基本概念。
最后,对于i386体系结构的处理器,它的参数是一个结构struct pt_regs(注意,不是指针),所以这里也可以结合这个特征看一下386系统调用参数如何实现以及C语言如何处理结构类型参数。
二、用户态syscall函数
这是一个C库封装的函数,可以比较方便的以系统调用号来进入内核,执行内核的系统调用,Linux下man手册关于该函数的说明为
#define _GNU_SOURCE /* or _BSD_SOURCE or _SVID_SOURCE */
#include <unistd.h>
#include <sys/syscall.h> /* For SYS_xxx definitions */
int syscall(int number, ...);
可以看到,它是一个变参函数,并且不用指定系统调用参数的个数,只需要把系统调用的参数依次放在number参数之后就可以,所以用起来比较顺手(当然比较顺手的东西一般容易出错)。
例如可以通过
sycall(__NR_exit_group,12);
来退出任务,
sycall(__NR_mkdir,"/home/tsecer/Codetest",0777);
来创建一个文件夹。
既然sycall函数不知道用户传入了多少个参数,那么它就要根据“宁滥勿缺”的原则,要把所有的可能用到的参数全部准备好。对于386来说,这个“所有可能参数”包括除了ESP之外的七个通用寄存器(其中eax作为系统调用号)。
glibc-2.7\sysdeps\unix\sysv\linux\i386\syscall.S
.text
ENTRY (syscall)
PUSHARGS_6 /* Save register contents. */
_DOARGS_6(44) /* Load arguments. */
movl 20(%esp), %eax /* Load syscall number into %eax. */
ENTER_KERNEL /* Do the system call. */
POPARGS_6 /* Restore register contents. */
cmpl $-4095, %eax /* Check %eax for error. */
jae SYSCALL_ERROR_LABEL /* Jump to error handler if error. */
L(pseudo_end):
ret /* Return to caller. */
这里宏比较多,所以直接看汇编了
(gdb) disas syscall
Dump of assembler code for function syscall:
0x08050cb0 <syscall+0>: push %ebp
0x08050cb1 <syscall+1>: push %edi
0x08050cb2 <syscall+2>: push %esi
0x08050cb3 <syscall+3>: push %ebx 根据i386 ABI,这些寄存器需要由被调用函数(callee)保存和恢复。
0x08050cb4 <syscall+4>: mov 0x2c(%esp),%ebp
0x08050cb8 <syscall+8>: mov 0x28(%esp),%edi
0x08050cbc <syscall+12>: mov 0x24(%esp),%esi
0x08050cc0 <syscall+16>: mov 0x20(%esp),%edx
0x08050cc4 <syscall+20>: mov 0x1c(%esp),%ecx
0x08050cc8 <syscall+24>: mov 0x18(%esp),%ebx以上六个寄存器赋值操作将会传递用户态的所有可能参数,即使syscall(_NR_EXIT,12)只使用了一个参数,所有的这些寄存器对syscall来说都是不能偷懒的,因为该函数并没有要求传入参数个数,而且反过来说,如果指定了参数个数,这里必须进行一系列复杂的判断,这些判断对指令流水的伤害可能比直接赋值更大。这里虽然可能会有冗余,但是只要内核不用这些参数就可以。
0x08050ccc <syscall+28>: mov 0x14(%esp),%eax
0x08050cd0 <syscall+32>: call *0x80ca894
0x08050cd6 <syscall+38>: pop %ebx
0x08050cd7 <syscall+39>: pop %esi
0x08050cd8 <syscall+40>: pop %edi
0x08050cd9 <syscall+41>: pop %ebp
0x08050cda <syscall+42>: cmp $0xfffff001,%eax
0x08050cdf <syscall+47>: jae 0x8052790 <__syscall_error>
0x08050ce5 <syscall+53>: ret
End of assembler dump.
三、内核态参数接收和构造
从上面可以看到,Linux下系统调用参数的传递是通过寄存器传递的,这一点和386函数调用规则不同,通常的386函数调用都是通过堆栈传递参数,而内核的系统调用实现接口作为C语言它也不例外,它函数体中同样会试着从自己的堆栈中来获得自己的参数。正如之前看到的,用户态通过寄存器传入的参数需要由内核来为所有的内核接口准备堆栈参数。这里再次遇到用户态syscall的问题,那就是内核也不知道每个系统调用需要哪些参数,内核都是通过系统调用跳转表来跳转到系统调用号对应的函数指针位置处,所以它同样也只能把所有可能用到的参数都全部入栈。
看一下i386系统调用的内核实现linux-2.6.21\arch\i386\kernel\entry.S
#define SAVE_ALL \
cld; \
pushl %fs; \
pushl %es; \
pushl %ds; \
pushl %eax; \
pushl %ebp; \
pushl %edi; \
pushl %esi; \
pushl %edx; \
pushl %ecx; \
pushl %ebx; \可以看到,内核态的入栈顺序和用户态约定参数相同,依次压入堆栈,供内核API函数使用。
movl $(__USER_DS), %edx; \
movl %edx, %ds; \
movl %edx, %es; \
movl $(__KERNEL_PDA), %edx; \
movl %edx, %fs
………………
# system call handler stub
ENTRY(system_call)
RING0_INT_FRAME # can't unwind into user space anyway
pushl %eax # save orig_eax
CFI_ADJUST_CFA_OFFSET 4
SAVE_ALL
GET_THREAD_INFO(%ebp)
testl $TF_MASK,PT_EFLAGS(%esp)
jz no_singlestep
orl $_TIF_SINGLESTEP,TI_flags(%ebp)
no_singlestep:
# system call tracing in operation / emulation
/* Note, _TIF_SECCOMP is bit number 8, and so it needs testw and not testb */
testw $(_TIF_SYSCALL_EMU|_TIF_SYSCALL_TRACE|_TIF_SECCOMP|_TIF_SYSCALL_AUDIT),TI_flags(%ebp)
jnz syscall_trace_entry
cmpl $(nr_syscalls), %eax
jae syscall_badsys
syscall_call:
call *sys_call_table(,%eax,4) 函数指针跳转,执行系统号对应的系统调用实现。
movl %eax,PT_EAX(%esp) # store the return value
四、clone中给定函数return之后会怎样
很多事情,开始往往比收尾也简单的多。例如一个malloc的算法可以很简单,但是回收算法往往很难;调试器构造一个参数栈调用一个函数很简单,但是如何让其正确返回却很难;生态环境的破坏很简单,但是恢复往往很难。
对于线程的创建也同样如此,想象一下,clone系统调动可以很轻松的创建一个线程,然后将线程入口指向用户提供的函数地址,但是当线程结束之后,这个线程将会如何结束,因为这个函数里面只有return而没有exit。而这个正是用户态clone.S需要代劳的工作。
glibc-2.7\sysdeps\unix\sysv\linux\i386\clone.S
movl $SYS_ify(clone),%eax
……
int $0x80 这里执行clone系统调用,通过ix80中断进入内核。
……
test %eax,%eax
jl SYSCALL_ERROR_LABEL
jz L(thread_start)判断返回值,返回值为零为子线程。
L(pseudo_end):
ret
L(thread_start):
……
call *%ebx 子线程调用用户提供的函数入口,通过函数指针调用。事实上,用户clone中提供的函数入口根本就没有传入内核(大家可以看一下内核do_fork函数,其中并没有入口地址参数),所以这里在用户态调用线程入口函数。
#ifdef PIC
call L(here)
L(here):
popl %ebx
addl $_GLOBAL_OFFSET_TABLE_+[.-L(here)], %ebx
#endif
movl %eax, %ebx
movl $SYS_ify(exit), %eax “如何退出”的答案就在这里,当前面的函数返回之后,执行流将会到达这里,然后用户态再通过调用exit系统调用退出线程。
ENTER_KERNEL
五、C语言如何传递结构
可以看到,内核态的sys_clone声明为
asmlinkage int sys_clone(struct pt_regs regs)
,它的参数是一个结构体,那么C语言是如何传递结构体的呢?
[tsecer@Harry funcstruct]$ cat structvar.c
struct demo{
int intvar;
char charvar;
};
int foo(struct demo demarg)
{
demarg.intvar=0x1234;
}
int bar(struct demo * demptr)
{
foo(*demptr);
}
[tsecer@Harry funcstruct]$ gcc structvar.c -c
[tsecer@Harry funcstruct]$ objdump -dr structvar.o
structvar.o: file format elf32-i386
Disassembly of section .text:
00000000 <foo>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: c7 45 08 34 12 00 00 movl $0x1234,0x8(%ebp) 对于被调用函数来说,结构和其它基本类型一样,作为一个结构在堆栈中占据结构对应大小的空间,也就是参数的大小并不总是为一个int。
a: 5d pop %ebp
b: c3 ret
0000000c <bar>:
c: 55 push %ebp
d: 89 e5 mov %esp,%ebp
f: 83 ec 08 sub $0x8,%esp
12: 8b 45 08 mov 0x8(%ebp),%eax
15: 8b 50 04 mov 0x4(%eax),%edx
18: 8b 00 mov (%eax),%eax
1a: 89 04 24 mov %eax,(%esp)
1d: 89 54 24 04 mov %edx,0x4(%esp)这里比较有意思的是bar函数自己构造了一个临时变量传递给foo函数,所以传递结构时,被调用者同样不能通过修改入参来改变调用者的传入结构。
21: e8 fc ff ff ff call 22 <bar+0x16>
22: R_386_PC32 foo
26: c9 leave
27: c3 ret
[tsecer@Harry funcstruct]$
而对于386系统调用来说,SAVE_ALL中在堆栈中刚好布局形成了一个pt_regs
struct pt_regs {
long ebx;
long ecx;
long edx;
long esi;
long edi;
long ebp;
long eax;
int xds;
int xes;
int xfs;
/* int xgs; */
long orig_eax;
long eip;
int xcs;
long eflags;
long esp;
int xss;
};