张凯捷——系统调用分析(1)
系统调用的概念
//
1
Wikipedia系统调用的解释
系统调用在Wikipedia中的解释为:
In computing, a system call is the programmatic way in which a computer program requests a service from the kernel of the operating system it is executed on. This may include hardware-related services (for example, accessing a hard disk drive), creation and execution of new processes, and communication with integral kernel services such as process scheduling. System calls provide an essential interface between a process and the operating system.
In most systems, system calls can only be made from userspace processes, while in some systems, OS/360 and successors for example, privileged system code also issues system calls.
主要意思是:
(1) 系统调用是程序以程序化的方式向其执行的操作系统请求服务。
(2) 请求的服务可能包括硬件相关服务(访问磁盘驱动器)、新进程创建和执行等。
(3) 系统调用在程序和操作系统之间提供一个基本接口。
大多数系统中,系统调用只由处于用户态的进程发出。
2
《Linux操作系统原理与应用》解释:
陈莉君老师的《Linux操作系统原理与应用(第二版)》对Linux系统调用解释为:
系统调用的实质就是函数调用,只是调用的函数是系统函数,处于内核态而已。用户在调用系统调用时会向内核传递一个系统调用号,然后系统调用处理程序通过此号从系统调用表中找到相应地内核函数执行(系统调用服务例程),最后返回。
3
总结
操作系统内核提供了许多服务,服务在物理表现上为内核空间的函数,系统调用即为在用户空间对这些内核提供服务的请求,即在用户空间程序“调用”内核空间的函数完成相应地服务。
//
系统调用实现分析
int / iret
0
1
早些时候,通过int 80来进行系统调用,调用一个系统调用示意图:
图2-1 int80系统调用示意图
下面基于linux-2.6.39内核进行分析:
1.1 初始化系统调用
内核在初始化期间调用trap_init()函数建立中断描述符表(IDT)中128个向量对应的表项。
在arch/x86/kernel/traps.c的trap_init()函数中可以看到:
#ifdef CONFIG_X86_32
set_system_trap_gate(SYSCALL_VECTOR, &system_call);
set_bit(SYSCALL_VECTOR, used_vectors);
#endif
SYSCALL_VECTOR在arch/x86/include/asm/irq_vectors.h可以看到值为0x80,即系统调用对应到0x80号中断。
set_system_trap_gate即用来在IDT上设置系统调用门,在arch/x86/include/asm/desc.h可以看到:
static inline void set_system_trap_gate(unsigned int n, void *addr)
{
BUG_ON((unsigned)n 0xFF);
_set_gate(n, GATE_TRAP, addr, 0x3, 0, __KERNEL_CS);
}
可以看到实际上执行的是__set_gate()函数,这个函数把相关值装入门描述符的相应域。
n:即为0x80这一中断号。
GATE_TRAP: 在arch/x86/include/asm/desc_defs.h中定义为0x0F,表示这一中断(异常)是陷阱。
addr:即为&system_call,系统调用处理程序入口。
0x3: 描述符特权级(DPL),表示允许用户态进程调用这一异常处理程序。
__KERNEL_CS: 由于系统调用处理程序处于内核当中,所以应选择__KERNEL_CS填充段寄存器。
1.2 系统调用处理(system_call())
执行int 80指令后,根据向量号在IDT中找到对应的表项,执行system_call()函数,在arch/x86/kernel/entry_32.S中可以看到system_call()函数:
ENTRY(system_call)
RING0_INT_FRAME
pushl_cfi %eax
SAVE_ALL
GET_THREAD_INFO(%ebp)
testl $_TIF_WORK_SYSCALL_ENTRY,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)
syscall_exit:
LOCKDEP_SYS_EXIT
DISABLE_INTERRUPTS(CLBR_ANY)
TRACE_IRQS_OFF
movl TI_flags(%ebp), %ecx
testl $_TIF_ALLWORK_MASK, %ecx # current-work
jne syscall_exit_work
restore_all:
TRACE_IRQS_IRET
restore_all_notrace:
movl PT_EFLAGS(%esp), %eax # mix EFLAGS, SS and CS
movb PT_OLDSS(%esp), %ah
movb PT_CS(%esp), %al
andl $(X86_EFLAGS_VM | (SEGMENT_TI_MASK 8) | SEGMENT_RPL_MASK), %eax
cmpl $((SEGMENT_LDT 8) | USER_RPL), %eax
CFI_REMEMBER_STATE
je ldt_ss
restore_nocheck:
RESTORE_REGS 4
irq_return:
INTERRUPT_RETURN
主要工作有:
(1)保存现场:
pushl_cfi %eax:先把系统调用号保存栈中。
SAVE_ALL:把异常处理程序可以用到的所有CPU寄存器保存到栈中。
GET_THREAD_INFO(%ebp):将当前进程PCB地址存放到ebp中,GET_THREAD_INFO()定义在arch/x86/include/asm/thread_info.h。
(2)跳转到相应服务程序:
cmpl $(nr_syscalls), %eax:先检查用户态进程传来的系统调用号是否有效,如果大于等于NR_syscalls,则跳转到syscall_badsys,终止系统调用程序,返回用户空间。
syscall_badsys:将-ENOSYS存放到eax寄存器所在栈中位置,再跳转到resume_userspace返回用户空间,返回后EAX中产生负的ENOSYS。
call *sys_call_table(,%eax,4):根据EAX中的系统调用号调用对应的服务程序。
(3)退出系统调用:
movl %eax, PT_EAX(%esp):保存返回值。
syscall_exit_work - work_pending - work_notifysig来处理信号。
可能执行call schedule来进行进程调度;或者跳转到resume_userspace,调用restall_all恢复现场,返回用户态。
1.3 系统调用表
在system_call()函数中的call *sys_call_table(,%eax,4) 语句中,根据eax寄存器中所存的系统调用号到sys_call_table系统调用表中找到对应的系统调用服务程序
由于是32位即每个sys_call_table是4个字节,如果是64位则程序语句为call *sys_call_table(, %eax, 8)
在linux-2.6.39内核源码中:
32位下系统调用表在arch/x86/kernel/syscall_table_32.S中定义,每个表项包含一个系统调用服务例程的地址:
ENTRY(sys_call_table)
.long sys_restart_syscall /* 0 - old system call, used for r estarting */
.long sys_exit
.long ptregs_fork
.long sys_read
.long sys_write
.long sys_open /* 5 */
...
64位系统的若要使用syscall指令来进行系统调用而不使用int 80,则用到的系统调用表在arch/x86/kernel/syscall_64.c中定义:
#define __SYSCALL(nr, sym) [nr] = sym,
const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
#include asm/unistd_64.h
};
可以看到系统调用表是include进去的,arch/x86/include/asm/unistd_64.h中放着:
#define __NR_read 0
__SYSCALL(__NR_read, sys_read)
#define __NR_write 1
__SYSCALL(__NR_write, sys_write)
#define __NR_open 2
__SYSCALL(__NR_open, sys_open)
...
所以在宏__SYSCALL的作用下,系统调用表为如下定义:
const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
[0] = sys_read,
[1] = sys_write,
[2] = sys_open,
...
};
vsyscalls 和vDSO
0
2
在Linux中调用系统调用的操作代价很大,因为处理器必须中断当前正在执行的任务并从用户态切换到内核态,执行完系统调用程序后又从内核态切换回用户态。
为了加快系统调用的速度,随后先后引入了两种机制——vsycalls和vDSO。
2.1 vsyscalls
vsyscalls的工作原理即为:Linux内核将第一个页面映射到用户空间,该页面包含一些变量和一些系统调用的实现,被映射到用户空间的系统调用即可以在用户空间执行,不需要进行上下文切换。
执行命令如下命令可以看到有关vsyscalls内存空间的信息:
$ sudo cat /proc/1/maps | grep vsyscall
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
vsyscall页面映射从内核启动开始start_kernel() - setup_arch() - map_vsyscall(),map_vsyscall()函数源码在arch/x86/entry/vsyscall/vsyscall_64.c中:
void __init map_vsyscall(void)
{
extern char __vsyscall_page;
unsigned long physaddr_vsyscall = __pa_symbol(&__vsyscall_page);
if (vsyscall_mode != NONE) {
__set_fixmap(VSYSCALL_PAGE, physaddr_vsyscall,
PAGE_KERNEL_VVAR);
set_vsyscall_pgtable_user_bits(swapper_pg_dir);
}
BUILD_BUG_ON((unsigned long)__fix_to_virt(VSYSCALL_PAGE) !=
(unsigned long)VSYSCALL_ADDR);
}
可以看到页面映射函数中首先使用__pa_symbol宏获取页面的物理地址。
__vsyscall_page在arch/x86/entry/vsysall/vsyscall_emu_64.S中定义,可以看出来__vsyscall_page包含三个系统调用:gettimeofday, time, getcpu:
__vsyscall_page:
mov $__NR_gettimeofday, %rax
syscall
ret
.balign 1024, 0xcc
mov $__NR_time, %rax
syscall
ret
.balign 1024, 0xcc
mov $__NR_getcpu, %rax
syscall
ret
获取页面的物理地址之后检查vsyscall_mode变量的值并使用__set_fixmap宏来设置页面的修复映射地址(Fix-Mapped Address),__set_fixmap在arch/x86/include/asm/fixmap.h中定义:
(1) 第一个参数是枚举类型fixed_addresses,这里传入参数实际值为(0xfffff000 - (-10UL 20)) 12:
#ifdef CONFIG_X86_VSYSCALL_EMULATION
VSYSCALL_PAGE = (FIXADDR_TOP - VSYSCALL_ADDR) PAGE_SHIFT,
#endif
(2) 第二个参数是必须映射的页面的物理地址,这里传入通过__pa_symbol宏定义获取到的物理地址
(3) 第三个参数是页面的flags,传入的是PAGE_KERNEL_VVAR,在arch/x86/include/asm/pgtable_types.h中定义,_PAGE_USER意味着可以通过用户模式的进程访问该页面:
#define default_pgprot(x) __pgprot((x) & __default_kernel_pte_mask)
#define __PAGE_KERNEL_VVAR (__PAGE_KERNEL_RO | _PAGE_USER)
#define PAGE_KERNEL_VVAR default_pgprot(__PAGE_KERNEL_VVAR | _PAGE_ENC)
设置完页面的修复地址后调用set_vsyscall_pgtable_user_bits()函数对覆盖VSYSCALL_ADDR的表设置_PAGE_USER;最后使用BUILD_BUG_ON宏来检查vsyscall页面的虚拟地址是否等于VSTSCALL_ADDR的值。
2.2 vDSO
虽然引入了vsyscall机制,但是vsyscall存在着问题:
(1)vsyscall的用户空间映射的地址是固定不变的,容易被黑客利用。
(2)vsyscall能支持的系统调用数有限,不易扩展。
vDSO是vsyscall的主要替代方案,是一个虚拟动态链接库,将内存页面以共享对象形式映射到每个进程,用户程序在启动的时候通过动态链接操作,把vDSO链接到自己的内存空间中。动态链接保证了vDSO每次所在的地址都不一样,并且可以支持数量较多的系统。
执行下列命令:
$ ldd /bin/uname
linux-vdso.so.1 = (0x00007ffcb75de000)
libc.so.6 = /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3c36e1d000)
/lib64/ld-linux-x86-64.so.2 (0x00007f3c371e7000)
可以看到uname util与三个库链接:
- linux-vdso.so.1:提供vDSO功能。
- lib.so.6:C标准库。
- ld-linux-x86-64.so.2:程序解释器(链接器)。
初始化vDSO发生在arch/x86/entry/vdso/vma.c的init_vdso()函数中:
static int __init init_vdso(void)
{
init_vdso_image(&vdso_image_64);
#ifdef CONFIG_X86_X32_ABI
init_vdso_image(&vdso_image_x32);
#endif
return 0;
}
使用init_dso_image()函数来初始化vdso_image结构体,vdso_image_64和vdso_image_x32在arch/x86/entry/vdso/vdso-image-64.c和arch/x86/entry/vdso/vdso-image-x32.c中进行定义,例如vdso_image_64
对vDOS系统调用的内存页面相关的结构体初始化后,使用从arch/x86/entry/vdso/vma.c中调用函数arch_setup_additional_pages()来检查并调用map_vdso_randomized() - map_vdso()函数来进行内存页面映射:
int arch_setup_additional_pages(struct linux_binprm *bprm, int uses_interp)
{
if (!vdso64_enabled)
return 0;
return map_vdso_randomized(&vdso_image_64);
}
上面说到的vsyscalls和vDSO都是从机制上对系统调用速度进行的优化,但是使用软中断来进行系统调用需要进行特权级的切换这一根本问题没有解决。
为了解决这一问题,Intel x86 CPU从Pentium II (Family6, Model 3, Stepping 3)之后,开始支持快速系统调用指令sysenter/sysexit,下篇将进行具体介绍。