系统调用的实质就是函数调用,只是调用的函数是系统函数,处于内核态。
API与系统调用的区别:
为什么不直接调用内核函数执行?就可以省掉系统调用这个步骤?
用户空间程序不能直接执行内核代码,内核在受保护的地址空间上,不允许用户进程在内核地址空间上读写,极大地提高了系统的安全性,内核在请求接口时可以检查请求的正确性,其次,使得编程更加容易,相当于提供了一组接口。还有一点是有了隔离层使得程序更具有移植性。
系统调用其实是:应用程序以某种方式通知系统,告诉内核自己需要执行一个系统调用,靠软中断向内核发出请求,通过引发一个异常CPU切换到内核态去执行异常处理程序(即系统调用处理程序)(Linux对系统调用的调用早期是必须通过执行int $0x80汇编指令,产生向量为128的异常(异常是CPU内部出现的中断)),接着进程会传递系统调用号给内核识别所需系统调用,进入系统调用服务程序而后找到系统调用号所对应系统调用服务例程进行处理,其实服务例程才是实际上处理数据的程序。而系统调用处理程序,只是实现了从用户态到内核态转换后的一些必要处理而已,再由服务例程返回系统调用,最终CPU由内核态切回用户态,完成系统调用。
用户向内核传递一个系统调用号(EAX寄存器负责传递)
Linux有几百个系统调用,为每一个系统调用定义了一个唯一的编号,此编号称为系统调用号,定义在linux/arch/x86/include/asm/unistd_.h中
#define __NR_io_setup 0
34 __SC_COMP(__NR_io_setup, sys_io_setup, compat_sys_io_setup)
35 #define __NR_io_destroy 1
36 __SYSCALL(__NR_io_destroy, sys_io_destroy)
37 #define __NR_io_submit 2
38 __SC_COMP(__NR_io_submit, sys_io_submit, compat_sys_io_submit)
39 #define __NR_io_cancel 3
40 __SYSCALL(__NR_io_cancel, sys_io_cancel)
...
...
725 #define __NR_pwritev2 287
726 __SC_COMP(__NR_pwritev2, sys_pwritev2, compat_sys_pwritev2)
727 #define __NR_pkey_mprotect 288
728 __SYSCALL(__NR_pkey_mprotect, sys_pkey_mprotect)
729 #define __NR_pkey_alloc 289
730 __SYSCALL(__NR_pkey_alloc, sys_pkey_alloc)
731 #define __NR_pkey_free 290
732 __SYSCALL(__NR_pkey_free, sys_pkey_free)
733 #define __NR_statx 291
734 __SYSCALL(__NR_statx, sys_statx)
可以看到目前有291个系统调用。
系统调用处理程序通过此号从系统调用表中找到相应内核函数执行(系统调用服务例程)利用系统调用号作为下标,找到系统调用的封装例程。(bar()在内核态有对应的sys_bar(),则sys_bar()就是系统调用服务例程)
返回:通过syscall_exit_work()函数(由汇编语言编写)。所有的系统调用都返回一个整数,大部分封装例程返回一个整数,值依赖于相应的系统调用。(负数表示一个出错条件)
执行int $0x80汇编指令:内核初始化期间调用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_VACTOR值为0x80,通过这个值将段选择子、偏移量、类型、DPL装入门描述符的相关域。
system_call函数实现了系统调用处理程序,把系统调用号和这个异常处理程序可以用到的所有CPU寄存器保存到相应的栈里面,然后对系统调用号进行有效性检查,如果这个号大于或者等于NR_syscalls,系统调用处理程序终止。如果系统调用号无效,则跳转到syscall_badsys处执行,结果返回一个负的返回码。
若正确,则根据EAX传递的系统调用号调用对应的服务例程。
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
另一种进入系统调用的方法是执行sysenter汇编语言指令。(被称为快速系统调用,从用户态到内核态的快速切换方法)
如何根据系统调用号找到对应服务例程?
把EAX中的系统调用号乘以4,加上sys_call_table系统调用表的起始地址,从这个地址即可获得指向相应服务例程的指针,内存即找到了需要调用的服务例程。
在arch/x86/kernel/syscall_table_32.S中定义了系统调用表
ENTRY(sys_call_table)
.long sys_restart_syscall
system call, used for r
.long sys_exit
.long ptregs_fork
.long sys_read
.long sys_write
.long sys_open
当服务例程执行结束
之前看到系统调用只传递了EAX寄存器中的系统调用号,但是有些系统调用需要传递多个参数(如mmap),那么系统在用寄存器传递参数时遵循两个原则:
之前在写Linux系统编程时经常用到文件操作的系统调用,其中文件描述符就是给与内核参数验证,若参数不对则会返回负数请求系统调用失败。
知道了原理做实验思路就比较清晰,需要做的是一个增加系统调用日志收集系统。目前正在做,主要步骤是
添加系统调用号
增加系统调用表的表项
增加系统调用函数
修改Makefile以及函数声明
用编写的代码拦截所需系统调用,提供内核接口函数
重新编译内核
在用户态编写代码挂在内核的钩子函数上
运行用户代码打印系统调用日志