系统调用和库函数
系统调用是用户空间应用程序和内核提供的服务之间的函数接口。所有的操作系统都提供多种服务的入口点,应用程序通过这些入口点向内核请求服务,这些入口点就是系统调用。在glic标准库中会为每个系统调用设置一个具有同样名字的的函数,这些函数就是库函数。用户进程用标准C调用序列来调用这些标准库函数,然后标准库函数又用操作系统所要求的技术调用相应的内核服务。简单说就是标准库函数是系统调用的封装,主要是为了方便用户程序使用。以内存空间分配函数malloc为例,在glibc标准库的实现中是使用了一个叫ptmalloc2的内存管理系统,这个系统的底层是通过brk或者mmap系统调用来向内核申请内存。当用户调用malloc来申请内存时其实是从glibc的内存管理系统中申请,并不是直接从内核申请。用户free内存时也是如此,内存时归还给标准库的内存管理系统。这个例子中标准库可以看作是内核的一个缓冲区。
32 位系统调用过程
下面开始从glibc和Linux源码来分析系统调用的详细过程。 本文是基于2.21版本glibc,和5.0版本的Linux内核
我们以一个常用的系统调用 open,打开一个文件为例子,看看系统调用是怎么实现的。这一节我们仅仅会解析到从 glibc 如何调用到内核的 open,至于 open 怎么实现,并不是本文的重点。
在glibc标准库中open函数定义如下:
int open(const char *pathname, int flags, mode_t mode)
在 glibc 的源代码中,有个文件 syscalls.list,里面列着所有 glibc 的函数对应的系统调用,就像下面这个样子:
# File name Caller Syscall name Args Strong name Weak names
open - open Ci:siv __libc_open __open open
另外,glibc 还有一个脚本 make-syscall.sh,可以根据上面的配置文件,对于每一个封装好的系统调用,生成一个文件。这个文件里面定义了一些宏,例如 #define SYSCALL_NAME open。glibc 还有一个文件 syscall-template.S,使用上面这个宏,定义了这个系统调用的调用方式。
T_PSEUDO (SYSCALL_SYMBOL, SYSCALL_NAME, SYSCALL_NARGS)
ret
T_PSEUDO_END (SYSCALL_SYMBOL)
#define T_PSEUDO(SYMBOL, NAME, N) PSEUDO (SYMBOL, NAME, N)
这里的 PSEUDO 也是一个宏,它的定义如下:
#define PSEUDO(name, syscall_name, args) \
.text; \
ENTRY (name) \
DO_CALL (syscall_name, args); \
cmpl $-4095, %eax; \
jae SYSCALL_ERROR_LABEL
里面对于任何一个系统调用,会调用 DO_CALL。这也是一个宏,这个宏在32位环境定义如下。
源码路径:glibc-2.21\sysdeps\unix\x86_64\sysdep.h
/* Linux takes system call arguments in registers:
syscall number %eax call-clobbered
arg 1 %ebx call-saved
arg 2 %ecx call-clobbered
arg 3 %edx call-clobbered
arg 4 %esi call-saved
arg 5 %edi call-saved
arg 6 %ebp call-saved
......
*/
#define DO_CALL(syscall_name, args) \
PUSHARGS_##args \
DOARGS_##args \
movl $SYS_ify (syscall_name), %eax; \
ENTER_KERNEL \
POPARGS_##args
这个地方可以看到,先将请求参数放在寄存器里面,然后根据系统调用的名称,得到系统调用号,放在寄存器 eax 里面,然后执行 ENTER_KERNEL。库函数也是从这个地方开始和内核交互。
在 Linux 的源代码注释里面,我们看到 ENTER_KERNEL是一个值为0x80的中断。
# define ENTER_KERNEL int $0x80
int 就是 interrupt,“中断”的意思。int $0x80 就是触发一个软中断,通过它就可以从用户空间陷入(trap)内核空间。
在内核启动的时候,会在trap_init()这个函数中会对系统中断表进行初始化,其中会给IA32_SYSCALL_VECTOR (值为 0x80)注册一个中断处理程序。
linux-5.0\linux-5.0\arch\x86\include\asm\irq_vectors.h
#define IA32_SYSCALL_VECTOR 0x80
arch/x86/kernel/traps.c
#ifdef CONFIG_X86_32
set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);
set_bit(IA32_SYSCALL_VECTOR, used_vectors);
#endif
这是一个软中断的陷入门。于是在调用 int 0x80
后,硬件根据向量号在 IDT 中找到对应的表项,即中断描述符,然后硬件将切换到内核栈 。根据英特尔的操作手册我们知道CPU响应中断时硬件会自动将 ss / sp / eflags / cs / ip /这些寄存器和error code 依次压到内核栈。
图片来自英特尔官方操作手册,Intel® 64 and IA-32 Architectures Developer's Manual: Vol. 1 ,6.4 INTERRUPTS AND EXCEPTIONS
接下来继续看,在中断向量表找到中断处理程序后开始执行,0x80对应的是上面注册的entry_INT80_32。
linux-5.0\linux-5.0\arch\x86\entry\entry_32.S
ENTRY(entry_INT80_32)
ASM_CLAC
pushl %eax /* pt_regs->orig_ax 把eax寄存器压如内核栈*/
SAVE_ALL pt_regs_ax=$-ENOSYS switch_stacks=1 /* save rest 通过SAVE_ALL将一些寄存器压到内核栈*/
/*
* User mode is traced as though IRQs are on, and the interrupt gate
* turned them off.
*/
TRACE_IRQS_OFF
movl %esp, %eax
call do_int80_syscall_32 //保存完寄存器之后在这里进去处理系统调用
.Lsyscall_32_done:
STACKLEAK_ERASE
restore_all:
......
.Lirq_return: //处理完后返回
/*
* ARCH_HAS_MEMBARRIER_SYNC_CORE rely on IRET core serialization
* when returning from IPI handler and when returning from
* scheduler to user-space.
*/
INTERRUPT_RETURN
上面这段代码主要是通过 push 和 SAVE_ALL 将当前用户态的寄存器,保存在 pt_regs 结构里面。这里pt_regs定义在\linux-5.0\arch\x86\include\asm\ptrace.h文件,具体定义见末尾附录。pt_regs主要是用来保存用户上下文的。
SAVE_ALL这个宏展开后如下所示:
.macro SAVE_ALL pt_regs_ax=%eax switch_stacks=0
cld
PUSH_GS
pushl %fs
pushl %es
pushl %ds
pushl \pt_regs_ax
pushl %ebp
pushl %edi
pushl %esi
pushl %edx
pushl %ecx
pushl %ebx
movl $(__USER_DS), %edx
movl %edx, %ds
movl %edx, %es
movl $(__KERNEL_PERCPU), %edx
movl %edx, %fs
SET_KERNEL_GS %edx
/* Switch to kernel stack if necessary */
.if \switch_stacks > 0
SWITCH_TO_KERNEL_STACK
.endif
进入内核之前,把用户态的所有的寄存器保存到内核栈里边的pt_regs结构体中,然后通过do_int80_syscall_32 调用do_syscall_32_irqs_on。它的实现如下:
static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs)
{
struct thread_info *ti = current_thread_info();
unsigned int nr = (unsigned int)regs->orig_ax; //取出系统调用号
#ifdef CONFIG_IA32_EMULATION
ti->status |= TS_COMPAT;
#endif
if (READ_ONCE(ti->flags) & _TIF_WORK_SYSCALL_ENTRY) {
/*
* Subtlety here: if ptrace pokes something larger than
* 2^32-1 into orig_ax, this truncates it. This may or
* may not be necessary, but it matches the old asm
* behavior.
*/
nr = syscall_trace_enter(regs);
}
if (likely(nr < IA32_NR_syscalls)) {
nr = array_index_nospec(nr, IA32_NR_syscalls);
#ifdef CONFIG_IA32_EMULATION
regs->ax = ia32_sys_call_table[nr](regs);
#else
/*
* It's possible that a 32-bit syscall implementation
* takes a 64-bit parameter but nonetheless assumes that
* the high bits are zero. Make sure we zero-extend all
* of the args.
*/
regs->ax = ia32_sys_call_table[nr](
(unsigned int)regs->bx, (unsigned int)regs->cx,
(unsigned int)regs->dx, (unsigned int)regs->si,
(unsigned int)regs->di, (unsigned int)regs->bp);
//这一段就是根据系统调用号到系统调用表找出对应的系统调用处理程序,并把保存到 寄存器中的参数传进去,最后又用一个ax寄存器保存返回值
#endif /* CONFIG_IA32_EMULATION */
}
syscall_return_slowpath(regs);
}
在这里,我们看到,将系统调用号从 eax 里面取出来,然后根据系统调用号,在系统调用表中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。如果仔细比对,就能发现,这些参数所对应的寄存器,都是在SAVE_ALL的时候保存到内核栈的。根据宏定义,#define ia32_sys_call_table sys_call_table,系统调用就是放在这个表里面。当系统调用结束之后,在 entry_INT80_32 之后,紧接着调用的是 INTERRUPT_RETURN,我们能够找到它的定义,也就是 iret。iret 指令将原来用户态保存的现场恢复回来,包含代码段、指令指针寄存器等。这时候用户态进程恢复执行,系统调用执行完毕。
#define INTERRUPT_RETURN iret
整个过程归纳为下图:
附录:
struct pt_regs {
unsigned long bx;
unsigned long cx;
unsigned long dx;
unsigned long si;
unsigned long di;
unsigned long bp;
unsigned long ax;
unsigned short ds;
unsigned short __dsh;
unsigned short es;
unsigned short __esh;
unsigned short fs;
unsigned short __fsh;
/* On interrupt, gs and __gsh store the vector number. */
unsigned short gs;
unsigned short __gsh;
/* On interrupt, this is the error code. */
unsigned long orig_ax;
unsigned long ip;
unsigned short cs;
unsigned short __csh;
unsigned long flags;
unsigned long sp;
unsigned short ss;
unsigned short __ssh;
};