0背景
之前的博客有对系统调用进行了概述,有兴趣的小伙伴可以翻看下:Linux系统调用之一——概述导论,这里是主要来介绍下系统调用的机制。
无论是GUI(用户图形接口)、应用程序,还是命令行接口最终都需要使用系统调用来实现。
当我们要打开文件(open)然后进行写入(write)或者分配内存(malloc)时,此时将会切换到内核态,虽然我们并察觉不到;之后内核对调用进行检查,如果通过,则按照指令执行相应的操作,分配相应的资源。
这种机制被称为系统调用,用户态进程发起调用,切换到内核态,内核态完成,返回用户态继续执行,这也是用户态唯一主动切换到内核态的合法手段(exception 和 interrupt 是被动切换)。
先通过图示来大致看下,以fork()为例看下系统调用流程:
在Linux发展历史上,x86 的系统调用实现经历了 int / iret 到 sysenter / sysexit 再到 syscall / sysret 的演变。通过软中断0x80的方式来实现系统调用实在太慢了,目前只要硬件支持,大部分使用的都是sysenter / sysexit或者syscall / sysret。但无论从学还是理解上,还是要先从软中断开始,所以本文也将主要通过分析软中断0x80来介绍系统调用机制。
1 0x80中断
1.1 中断处理函数entry_INT80_32
**Linux内核为中断编号128(0x80)注册一个名为entry_INT80_32的中断处理程序。**让我们看一下实际执行此操作的代码。
PS:本文分析基于Linux kernel 4.9.76 ,glibc 2.25.90
文件:arch/x86/kernel/traps.c
void __init trap_init(void)
{
/* ..... other code ... */
/*CONFIG_IA32_EMULATION //该宏为允许在64位内核中运行32位代码.
我们通过宏CONFIG_X86_32包含内容分析
#ifdef CONFIG_IA32_EMULATION
set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_compat);
set_bit(IA32_SYSCALL_VECTOR, used_vectors);
#endif
*/
#ifdef CONFIG_X86_32
set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);
set_bit(IA32_SYSCALL_VECTOR, used_vectors);
#endif
/* ..... other code ... */
}
其中 IA32_SYSCALL_VECTOR
在头文件arch/x86/include/asm/irq_vectors.h
被定义为0x80
,set_system_intr_gate
用于在中断描述符表(IDT)上设置系统调用门。
也就是Linux内核保留了一个0x80软件中断,用户程序可以通过该中断触发内核,然后硬件根据中断向量号在 IDT 中找到对应的表项,即中断描述符,问题是内核如何知道它应该执行多少系统调用呢?实际上程序编译时会预先将系统调用号放入eax寄存器中。系统调用相关参数将放在其余的通用寄存器中。
所以当0x80中断来时,从entry_INT80_32
开始执行,其定义在 arch/x86/entry/entry_32.S
ENTRY(entry_INT80_32)
ASM_CLAC
pushl %eax /* pt_regs->orig_ax */
SAVE_ALL pt_regs_ax=$-ENOSYS /* save rest */
/*
* 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:
pushl %eax
将存在eax中的系统调用号压栈,然后SAVE_ALL
将其他寄存器的值压栈,这些寄存器放着调用函数的参数,参看以下代码:
.macro SAVE_ALL pt_regs_ax=%eax
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
.endm
压栈完毕,TRACE_IRQS_OFF
关闭中断,movl %esp, %eax
将当前栈指针保存到eax,然后 call do_int80_syscall_32
调用do_int80_syscall_32,以上是中断处理函数entry_INT80_32的定义。
1.2 中断处理函数do_syscall_32_irqs_on
继续分析,接下来就调用到了do_int80_syscall_32该函数定义在arch/x86/entry/common.c
/* Handles int $0x80 */
__visible void do_int80_syscall_32(struct pt_regs *regs)
{
enter_from_user_mode();/* Called on entry from user mode with IRQs off*/
local_irq_enable();/* unconditionally enable interrupts */
do_syscall_32_irqs_on(regs);
}
前面两个我把英文注释加上了,不再细看,主要关注函数do_syscall_32_irqs_on
,同样定义在arch/x86/entry/common.c
。
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
current->thread.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)) {
/*
* 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);
}
syscall_return_slowpath(regs);
}
该函数的参数regs,其实指的就是先前在 entry_INT80_32 依次被压入栈的寄存器值。定义在arch/x86/include/asm/ptrace.h
中,__i386__中的定义如下:
#ifdef __i386__
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 long ds;
unsigned long es;
unsigned long fs;
unsigned long gs;
unsigned long orig_ax;
unsigned long ip;
unsigned long cs;
unsigned long flags;
unsigned long sp;
unsigned long ss;
};
#else /* __i386__ */
先取出系统调用号,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);
根据寄寄存器中存储的函数参数从系统调用表(ia32_sys_call_table) 中取出对应的处理函数,然后syscall_return_slowpath(regs)
再调用该函数。
1.3系统调用表ia32_sys_call_table
其实还还有很关键的一部分就是系统调用表ia32_sys_call_table
,上一步的函数处理中就是根据寄存器参数,从系统调用表中取出对应处理函数的。
IDT定义在arch/x86/entry/syscall_32.c
/* System call table for i386. */
#include <linux/linkage.h>
#include <linux/sys.h>
#include <linux/cache.h>
#include <asm/asm-offsets.h>
#include <asm/syscall.h>
#define __SYSCALL_I386(nr, sym, qual) extern asmlinkage long sym(unsigned long, unsigned long, unsigned long, unsigned long, unsigned long, unsigned long) ;
#include <asm/syscalls_32.h>
#undef __SYSCALL_I386
#define __SYSCALL_I386(nr, sym, qual) [nr] = sym,
extern asmlinkage long sys_ni_syscall(unsigned long, unsigned long, unsigned long, unsigned long, unsigned long, unsigned long);
__visible const sys_call_ptr_t ia32_sys_call_table[__NR_syscall_compat_max+1] = {
/*
* Smells like a compiler bug -- it doesn't work
* when the & below is removed.
*/
[0 ... __NR_syscall_compat_max] = &sys_ni_syscall,
#include <asm/syscalls_32.h>
};
以上是syscall_32.c的全部代码,并未发现IDT定义,那应该在头文件中,实际上在未编译的内核中根本找不到syscalls_32.h
这个头文件,事实上我们需要编译kernel后才会出现。
而实际上syscalls_32.h
又依赖于syscall_32.tbl
文件:syscall_32.tbl
#
# 32-bit system call numbers and entry vectors
#
# The format is:
# <number> <abi> <name> <entry point> <compat entry point>
#
# The abi is always "i386" for this file.
#
0 i386 restart_syscall sys_restart_syscall
1 i386 exit sys_exit
2 i386 fork sys_fork sys_fork
3 i386 read sys_read
4 i386 write sys_write
5 i386 open sys_open compat_sys_open
...
...
381 i386 pkey_alloc sys_pkey_alloc
382 i386 pkey_free sys_pkey_free
共383个系统调用函数,而编译后的rch/x86/include/generated/asm/syscalls_32.h
__SYSCALL_I386(0, sys_restart_syscall, )
__SYSCALL_I386(1, sys_exit, )
#ifdef CONFIG_X86_32
__SYSCALL_I386(2, sys_fork, )
#else
__SYSCALL_I386(2, sys_fork, )
#endif
__SYSCALL_I386(3, sys_read, )
__SYSCALL_I386(4, sys_write, )
#ifdef CONFIG_X86_32
__SYSCALL_I386(5, sys_open, )
#else
__SYSCALL_I386(5, compat_sys_open, )
...
说明 syscalls_32.h 是在编译过程中动态生成的,感兴趣的小伙伴可以去查看脚本arch/x86/entry/syscalls/syscalltbl.sh
。arch/x86/syscalls/syscall_32.tbl
这样我们的系统调用表如下:
__visible const sys_call_ptr_t ia32_sys_call_table[__NR_syscall_compat_max+1] = {
[0] = sys_restart_syscall,
[1] = sys_exit,
[2] = sys_fork,
[3] = sys_read,
[4] = sys_write,
[5] = sys_open,
...
};
1.4以系统调用sys_open为例分析
这里通过软中断和AT&T汇编写一个简单的系统调用,并不规范,至少要退出。(可以直接编译)
int main(int argc, char *argv[])
{
asm ("movl $0x05, %eax\n" /* 设置系统调用号 */
"movl $1, %ebx\n" /* 设置系统调用参数 */
"movl $2, %ecx\n" /* 设置系统调用参数 */
"int $0x80" /* 进入系统调用中断 */
);
}
movl $0x05, %eax
可见调用号为0x05,所以这里调用了sys_open,其定义在fs/open.c
,而这里我们传入3个参数
SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
if (force_o_largefile())
flags |= O_LARGEFILE;
return do_sys_open(AT_FDCWD, filename, flags, mode);
}
关于SYSCALL_DEFINE3
和相关宏定义如下:
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINEx(x, sname, ...) \
SYSCALL_METADATA(sname, x, __VA_ARGS__) \
__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)
#define __SYSCALL_DEFINEx(x, name, ...) \
asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__)) \
__attribute__((alias(__stringify(SyS##name)))); \
\
static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__)); \
\
asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__)); \
\
asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__)) \
{ \
long ret = SYSC##name(__MAP(x,__SC_CAST,__VA_ARGS__)); \
__MAP(x,__SC_TEST,__VA_ARGS__); \
__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__)); \
return ret; \
} \
\
static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__))
其中SYSCALL_METADATA
保存了调用的基本信息,供调试程序跟踪使用(kernel 需开启 CONFIG_FTRACE_SYSCALLS
)。
而 __SYSCALL_DEFINEx
用于拼接函数,函数名被拼接为 sys##_##open
,参数也通过 __SC_DECL
拼接,最终得到展开后的定义:
asmlinkage long sys_open(const char __user * filename, int flags, umode_t mode)
{
if (force_o_largefile())
flags |= O_LARGEFILE;
return do_sys_open(AT_FDCWD, filename, flags, mode);
}
这里也就是说,将do_sys_open(AT_FDCWD, filename, flags, mode)封装为3参数的sys_open,同理,其他数量参数也是如是封装。
long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
{
struct open_flags op;
int fd = build_open_flags(flags, mode, &op);
struct filename *tmp;
if (fd)
return fd;
tmp = getname(filename);
if (IS_ERR(tmp))
return PTR_ERR(tmp);
fd = get_unused_fd_flags(flags);
if (fd >= 0) {
struct file *f = do_filp_open(dfd, tmp, &op);
if (IS_ERR(f)) {
put_unused_fd(fd);
fd = PTR_ERR(f);
} else {
fsnotify_open(f);
fd_install(fd, f);
}
}
putname(tmp);
return fd;
}
getname 将处于用户态的文件名拷到内核态,然后通过 get_unused_fd_flags 获取一个没用过的文件描述符,然后 do_filp_open 创建 struct file , fd_install 将 fd 和 struct file 绑定(task_struct->files->fdt[fd] = file),然后返回 fd。
fd一直返回到 do_syscall_32_irqs_on ,被设置到 regs->ax (eax) 中。接着返回 entry_INT80_32 继续执行,最后执行 INTERRUPT_RETURN 。INTERRUPT_RETURN 在 arch/x86/include/asm/irqflags.h 中定义为 iret ,负责恢复先前压栈的寄存器,返回用户态。系统调用执行完毕。
汇编
在目前主流的系统调用库(glibc) 中,int 0x80 只有在硬件不支持快速系统调用(sysenter / syscall)的时候才会调用,但目前的硬件都支持快速系统调用,所以为了能够看看 int 0x80 的效果,我们手撸汇编:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(){
char * filename = "/tmp/test";
char * buffer = malloc(80);
memset(buffer, 0, 80);
int count;
__asm__ __volatile__("movl $0x5, %%eax\n\t"
"movl %1, %%ebx\n\t"
"movl $0, %%ecx\n\t"
"movl $0664, %%edx\n\t"
"int $0x80\n\t"
"movl %%eax, %%ebx\n\t"
"movl $0x3, %%eax\n\t"
"movl %2, %%ecx\n\t"
"movl $80, %%edx\n\t"
"int $0x80\n\t"
"movl %%eax, %0\n\t"
:"=m"(count)
:"g"(filename), "g"(buffer)
:"%eax", "%ebx", "%ecx", "%edx");
printf("%d\n", count);
printf("%s\n", buffer);
free(buffer);
}
这段代码首先通过 int 0x80 调用系统调用 open 得到 fd (由 eax 返回),再作为 read 的参数传入,从而读出了文件中的内容。但比较奇怪的是如果 buffer 存储在栈中 (buffer[80]),则调用 read 失败。只有将 buffer 作为全局变量或存储在堆中,才能调用成功。希望有知道的大大指点一下。
执行下看看
[hezz coding_test 15:37]$touch /tmp/test
[hezz coding_test 15:37]$vi /tmp/test
[hezz coding_test 15:37]$gcc syscall_asm.c
[hezz coding_test 15:37]$./a.out
4
111
总结
传统系统调用(int 0x80) 通过中断/异常实现,在执行 int 指令时,发生 trap。硬件找到在中断描述符表中的表项,在自动切换到内核栈 (tss.ss0 : tss.esp0) 后根据中断描述符的 segment selector 在 GDT / LDT 中找到对应的段描述符,从段描述符拿到段的基址,加载到 cs ,将 offset 加载到 eip。最后硬件将 ss / sp / eflags / cs / ip / error code 依次压到内核栈。返回时,iret 将先前压栈的 ss / sp / eflags / cs / ip 弹出,恢复用户态调用时的寄存器上下文。