基本的x86体系下系统调用相关的指令可以看这篇 文章。
x86下,最早是使用软中断指令int 0×80来做的,不过现在内核是使用syscall和sysenter指令,只有64位下才会使用syscall,而大部分情况都是使用sysenter,这里我们主要介绍sysenter指令,不过具体实现3者现在都差不多,这是因为内核使用了VDSO来兼容所有的指令,接下来我们就要来详细的分析内核是如何实现vdso层,以及glibc库(也就是用户空间)是如何来调用vdso层的接口,从而进入内核。
首先来看glibc的代码,下面这段代码就是syscall的实现,位置是在sysdeps/unix/sysv/linux/i386/syscall.S这个文件里面。这段汇编很简单,就是保存寄存器,然后讲参数,系统调用号入站,最后调用ENTER_KERNEL进入内核。所以这里最关键的就是ENTER_KERNEL这个宏。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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. \*/ PSEUDO_END (syscall)
接下来我们就来看ENTER_KERNEL这个宏的实现,这个宏主要就是用来进入内核,通过vdso调用内核对应的系统调用接口,从而达到执行系统调用的目的。
通过下面的代码我们可以看到通过宏I386_USE_SYSENTER来决定是否使用快速系统调用,这里这个宏就不详细分析了,只需要知道他主要是通过makefile中的参数进行控制的就可以了。
如果I386_USE_SYSENTER没有定义,则说明不使用快速系统调用,此时使用老的方法,也就是使用软中断指令int $0×80来进入内核,而如果使用快速系统调用则通过SHARED宏来决定使用那种方式来得到vdso的页地址(也就是内核实现的系统调用的页,这个后面会详细介绍).这里接下来会详细分析SHARED打开的情况,也就是最常用的情况。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #ifdef I386_USE_SYSENTER \# ifdef SHARED \# define ENTER_KERNEL call *%gs:SYSINFO_OFFSET \# else \# define ENTER_KERNEL call *_dl_sysinfo \# endif #else \# define ENTER_KERNEL int $0x80 #endif
因此这里最关键就是call *%gs:SYSINFO_OFFSET这段汇编了,首先我们知道寄存器%gs里面保存的是TLS(Thread Local Storage),然后SYSINFO_OFFSET是在nptl/sysdeps/i386/tcb-offsets.sym里面定义:
1 2 SYSINFO_OFFSET offsetof (tcbhead_t, sysinfo)
下面就是 tcbhead_t的结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 typedef struct { void \*tcb; /\* Pointer to the TCB. Not necessarily the thread descriptor used by libpthread. */ dtv_t *dtv; void \*self; /\* Pointer to the thread descriptor. */ int multiple_threads; //SYSINFO_OFFSET也就是他的偏移。 uintptr_t sysinfo; uintptr_t stack_guard; uintptr_t pointer_guard; int gscope_flag; #ifndef __ASSUME_PRIVATE_FUTEX int private_futex; #else int __unused1; #endif /\* Reservation of some values for the TM ABI. \*/ void *__private_tm[5]; } tcbhead_t;
通过上面的计算我们能够得到SYSINFO_OFFSET的值就是0×10,这里也就是调用tcbhead_t的sysinfo的值,而tcbhead_t.sysinfo这个值是在那里赋值的呢,看下面的代码,nptl/sysdeps/i386/tls.h:
这里TLS_INIT_TP是用来初始化一个thread pointer,而其中就将tcb的头进行了初始化,而头的sysinfo域是通过INIT_SYSINFO进行初始化的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 \# define TLS_INIT_TP(thrdescr, secondcall) \ ({ void *_thrdescr = (thrdescr); \ tcbhead_t *_head = _thrdescr; \ union user_desc_init _segdescr; \ int _result; \ \ _head->tcb = _thrdescr; \ /\* For now the thread descriptor is at the same address. \*/ \ _head->self = _thrdescr; \ /\* New syscall handling support. \*/ \ ……………………………………………………………………………….. #if defined NEED_DL_SYSINFO \# define INIT_SYSINFO \ //可以看到它的值就是dl_sysinfo的地址 _head->sysinfo = GLRO(dl_sysinfo) #else \# define INIT_SYSINFO #endif
接下来就是dl_sysinfo的值了,它是在函数_dl_sysdep_start (elf/dl-sysdep.c)中被赋值的,而_dl_sysdep_start这个函数是干吗的呢,glibc的注释写的很清楚:
1 2 3 4 5 6 7 8 /* Call the OS-dependent function to set up life so we can do things like file access. It will call \`dl_main’ (below) to do all the real work of the dynamic linker, and then unwind our frame and run the user entry point on the same stack we entered on. */
我的理解就是得到一些依赖os的函数的地址(动态库),然后放到对应的段,以便与后面存取。
下面就是对应的代码片段。这里可以看到它是通过判断函数的类型来进行不同的操作,这里我们节选我们感兴趣的sysinfo部分,这里可以看到sysinfo的类型就是AT_SYSINFO。这里一般来说取的就是ELF auxiliary vectors的值,也就是说内核会把相关的信息放到ELF auxiliary vectors中。而什么是ELF auxiliary vectors,这里介绍的比较详细:
http://articles.manugarg.com/aboutelfauxiliaryvectors.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #define AT_SYSINFO 32 #ifdef NEED_DL_SYSINFO case AT_SYSINFO: new_sysinfo = av->a_un.a_val; break; #endif ………………………………………….. #if defined NEED_DL_SYSINFO /\* Only set the sysinfo value if we also have the vsyscall DSO. \*/ if (GLRO(dl_sysinfo_dso) != 0 && new_sysinfo) GLRO(dl_sysinfo) = new_sysinfo; #endif
接下来就该到内核了,也就是说AT_SYSINFO类型对应的到底是那里。
在看内核代码之前,我们先来了解下vdso的结构,首先我们随便ldd一个可执行文件,下面是我的机器上的情况:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 ldd nginx linux-gate.so.1 => (0xb77d9000) libcrypt.so.1 => /lib/libcrypt.so.1 (0xb778a000) libpcre.so.0 => /lib/libpcre.so.0 (0xb7753000) libcrypto.so.1.0.0 => /usr/lib/libcrypto.so.1.0.0 (0xb75d9000) libz.so.1 => /usr/lib/libz.so.1 (0xb75c4000) libperl.so => /usr/lib/perl5/core_perl/CORE/libperl.so (0xb746c000) libnsl.so.1 => /lib/libnsl.so.1 (0xb7455000) libdl.so.2 => /lib/libdl.so.2 (0xb7451000) libm.so.6 => /lib/libm.so.6 (0xb742c000) libutil.so.1 => /lib/libutil.so.1 (0xb7428000) libpthread.so.0 => /lib/libpthread.so.0 (0xb740e000) libc.so.6 => /lib/libc.so.6 (0xb72c2000) /lib/ld-linux.so.2 (0xb77da000)
这里我们看到有一个linux-gate.so.1的动态库,这个库其实是不存在的,而它其实就是一块内存,其中包括了vdso生成的系统调用的代码,也就是说内核mmap这块内存(其实这快内存也就是完全遵循elf格式)到用户空间,然后ldd将它作为动态库来处理,此时用户空间就很容易来执行这块内存的代码。
有关vdso的部分这篇也是介绍的不错,可以看看。
在初始化的时候,内核会判断系统之不支持快速系统调用,如果支持的话则将快速系统调用相关的代码拷贝到将要mmap的内存,否则就拷贝软中断指令。来看代码,是在arch/x86/vdso/vdso32-setup.c的sysenter_setup函数。
这个函数就是判断支持那些指令,然后做不同的处理,可以看到最优先处理的就是syscall,然后是sysenter,最后是int80,这里我们主要来看sysenter,这里可以看到是将vdso32_sysenter_start的地址付给vsyscall ,然后将vsyscall的内容拷贝到对应的页。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 int __init sysenter_setup(void) { void \*syscall_page = (void \*)get_zeroed_page(GFP_ATOMIC); const void *vsyscall; size_t vsyscall_len; //得到对应的页 vdso32_pages[0] = virt_to_page(syscall_page); #ifdef CONFIG_X86_32 gate_vma_init(); #endif //开始决定使用那种方式 if (vdso32_syscall()) { vsyscall = &vdso32_syscall_start; vsyscall_len = &vdso32_syscall_end – &vdso32_syscall_start; } else if (vdso32_sysenter()){ vsyscall = &vdso32_sysenter_start; vsyscall_len = &vdso32_sysenter_end – &vdso32_sysenter_start; } else { vsyscall = &vdso32_int80_start; vsyscall_len = &vdso32_int80_end – &vdso32_int80_start; } //拷贝到对应的页 memcpy(syscall_page, vsyscall, vsyscall_len); //重定向。 relocate_vdso(syscall_page); return 0; }
接下来就是来看vdso32_sysenter_start到底是什么东西,它的定义是在arch/x86/vdso/vdso32.S中的。可以看到这里vdso32_sysenter_start代表的内容也就是vdso32-sysenter.so,也就是说上面代码就是拷贝vdso32-sysenter.so到对应的页。
1 2 3 4 vdso32_sysenter_start: .incbin "arch/x86/vdso/vdso32-sysenter.so"
然后就是在fs/binfmt_elf.c文件的load_elf_binary函数中加载对应的vdso32-sysenter.so文件到内存,然后调用arch_setup_additional_pages将vsdo映射到用户空间,因此我们来看arch_setup_additional_pages这个函数,这个函数很简单就是映射上面copy的页的内容到用户空间。
这里有个需要注意的就是VDSO_HIGH_BASE这个值,其实我们上面拷贝完so之后会有一个重定向(relocate_vdso),这个重定向会将vdso的地址重定向到这里。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 int arch_setup_additional_pages(struct linux_binprm *bprm, int uses_interp) { struct mm_struct *mm = current->mm; unsigned long addr; int ret = 0; bool compat; if (vdso_enabled == VDSO_DISABLED) return 0; down_write(&mm->mmap_sem); /* Test compat mode once here, in case someone changes it via sysctl */ compat = (vdso_enabled == VDSO_COMPAT); map_compat_vdso(compat); if (compat) addr = VDSO_HIGH_BASE; else { addr = get_unmapped_area(NULL, 0, PAGE_SIZE, 0, 0); if (IS_ERR_VALUE(addr)) { ret = addr; goto up_fail; } } //设置vdso的地址为addr也就是我们前面设置的VDSO_HIGH_BASE current->mm->context.vdso = (void *)addr; if (compat_uses_vma || !compat) { /* * MAYWRITE to allow gdb to COW and set breakpoints * * Make sure the vDSO gets into every core dump. * Dumping its contents makes post-mortem fully * interpretable later without matching up the same * kernel and hardware config to see what PC values * meant. */ ret = install_special_mapping(mm, addr, PAGE_SIZE, VM_READ|VM_EXEC| VM_MAYREAD|VM_MAYWRITE|VM_MAYEXEC| VM_ALWAYSDUMP, vdso32_pages); if (ret) goto up_fail; } current_thread_info()->sysenter_return = VDSO32_SYMBOL(addr, SYSENTER_RETURN); up_fail: if (ret) current->mm->context.vdso = NULL; up_write(&mm->mmap_sem); return ret; }
而最关键的部分就是系统调用的实现部分是在arch/x86/vdso/vdso32/sysenter.S中的,也就是__kernel_vsyscall,linux会编译(可以看vdso下面的Makefile)它为一个so,然后供上面使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 .globl __kernel_vsyscall .type __kernel_vsyscall,@function ALIGN __kernel_vsyscall: .LSTART_vsyscall: push %ecx .Lpush_ecx: push %edx .Lpush_edx: push %ebp .Lenter_kernel: movl %esp,%ebp sysenter
然后是arch/x86/vdso/vdso32/vdso32.ld.S中的也就是定义上面的__kernel_vsyscall为VDSO32_vsyscall这个名字,这里其实就是个别名了,到后面这个别名会用到,也就是在动态库中使用的就是VDSO32_vsyscall表示调用系统调用。
1 2 3 4 5 6 7 8 VDSO32_PRELINK = VDSO_PRELINK; VDSO32_vsyscall = __kernel_vsyscall; VDSO32_sigreturn = __kernel_sigreturn; VDSO32_rt_sigreturn = __kernel_rt_sigreturn;
然后我们就来看内核和glibc库如何关联起来,这里关键也就是类型AT_SYSINFO对应的内容是什么,因此我们搜索内核代码,发现了下面这部分,这个宏也就是设置类型为AT_SYSINFO的内容以便与用户空间存取。
这里的原理是这样的,内核在装载镜像的时候会将这快(系统调用相关的)拷贝到用户空间,然后将对应的地址拷贝到ELF auxiliary vectors以供用户空间使用。
内核会将所需要的信息比如sysinfo地址放到ELF auxiliary vectors(一般来说都是键值对),然后用户空间就可以很简单的取到所需要的函数的地址,而这里NEW_AUX_ENT就是将类型地址的键值对放到ELF auxiliary vectors。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #define ARCH_DLINFO_IA32(vdso_enabled) \ do { \ if (vdso_enabled) { \ NEW_AUX_ENT(AT_SYSINFO, VDSO_ENTRY); \ NEW_AUX_ENT(AT_SYSINFO_EHDR, VDSO_CURRENT_BASE); \ } \ } while (0) #ifdef CONFIG_X86_32 //x86_32调用ARCH_DLINFO_IA32。 #define ARCH_DLINFO ARCH_DLINFO_IA32(vdso_enabled)
然后来看NEW_AUX_ENT是干吗的,这个宏主要是将对应的信息按照elf的格式进行设置。而它的定义的地方和ARCH_DLINFO调用的地方一致,那就是create_elf_fdpic_tables中。
可以看到NEW_AUX_ENT很简单,就是拷贝对应的值到用户空间的ELF auxiliary vectors。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 static int create_elf_fdpic_tables(struct linux_binprm *bprm, struct mm_struct *mm, struct elf_fdpic_params *exec_params, struct elf_fdpic_params *interp_params) { #define NEW_AUX_ENT(id, val) \ do { \ struct { unsigned long _id, _val; } __user *ent; \ \ ent = (void __user *) csp; \ //拷贝对应的id和value到用户空间. __put_user((id), &ent[nr]._id); \ __put_user((val), &ent[nr]._val); \ nr++; \ } while (0) ……………………………………. NEW_AUX_ENT(AT_EGID, (elf_addr_t) cred->egid); NEW_AUX_ENT(AT_SECURE, security_bprm_secureexec(bprm)); NEW_AUX_ENT(AT_EXECFN, bprm->exec); #ifdef ARCH_DLINFO nr = 0; csp -= AT_VECTOR_SIZE_ARCH \* 2 \* sizeof(unsigned long); /* ARCH_DLINFO must come last so platform specific code can enforce * special alignment requirements on the AUXV if necessary (eg. PPC). */ //调用ARCH_DLINFO完成sysinfo的拷贝 ARCH_DLINFO; #endif ……………………………………………………………
最后我们就来看拷贝的是什么东西。可以看到上面的参数是AT_SYSINFO, VDSO_ENTRY第一个是id,第二个是VDSO_ENTRY,第一个我们知道就是glibc中的type,而第二个呢,来看内核的代码,其实很简单VDSO_ENTRY就是表示VDSO32_vsyscall这个符号的地址,而这个符号我们知道就是__kernel_vsyscall,也就是系统调用的实现函数。这下完全清楚了,那就是上面的glibc的ENTER_KERNEL最终调用的就是内核的__kernel_vsyscall。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 #define VDSO_ENTRY \ ((unsigned long)VDSO32_SYMBOL(VDSO_CURRENT_BASE, vsyscall)) #define VDSO32_SYMBOL(base, name) \ ({ \ extern const char VDSO32_##name[]; \ (void *)(VDSO32_##name – VDSO32_PRELINK + (unsigned long)(base)); \ })
总结一下,大体的过程是这样子的,内核在运行的时候会动态加载一个so到物理页,然后会将这个物理页映射到用户空间,并且会将里面的函数根据类型设置到ELF Auxiliary Vectors,然后glibc调用的时候就可以通过ELF Auxiliary Vectors来取得对应系统调用函数。