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的中断处理程序。,每当有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 ,负责恢复先前压栈的寄存器,返回用户态。系统调用执行完毕。
最后这一部分是看了好些英文博客,博主感觉还不是很清晰,后面再慢慢梳理梳理~
2 汇编实现
这里是复制的大神的代码,感觉比较好,所以直接贴过来了,也供大家学习下。(里面有些汇编我暂时还不懂,肯定写不了,但不耽误看)
#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);
}
在当前主流的系统调用库(glibc) 中,软中断int 0x80
实现系统调用 只有在硬件不支持快速系统调用(sysenter / syscall)的时候才会调用,而我们使用的的硬件目前都支持快速系统调用,所以为了能够看看 int 0x80 的效果,所以也就找了这段代码学习。
这段代码首先通过 int 0x80 调用系统调用 open 得到 fd (由 eax 返回),再作为 read 的参数传入,从而读出了文件中的内容。
执行下看看
[hezz coding_test 15:37]$touch /tmp/test
[hezz coding_test 15:37]$vi /tmp/test #写入hello world!
[hezz coding_test 15:37]$gcc syscall_asm.c
[hezz coding_test 15:37]$./a.out
13
hello world!
3 总结
传统系统调用(int 0x80) 通过中断/异常实现,在执行 int 指令时,发生 trap,陷入内核,切换至内核态;然后硬件根据寄存器中参数在中断描述符表中的对应表项表项,返回文件描述符(fd)。
最后硬件将 ss / esp / eflags / cs / eip / error code 依次压到内核栈。返回时,iret 将先前压栈的 ss / esp / eflags / cs / eip 弹出,恢复用户态调用时的寄存器上下文。
另外关于驱动设备文件的file_operation
对应函数是可以自己实现的,当我们打开驱动设备文件后,就建立了文件中struct file
和struct file_operation
对应的关系。我们以write系统调用为例看一下:
文件:fs/read.write.c
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;
if (f.file) {
loff_t pos = file_pos_read(f.file);
ret = vfs_write(f.file, buf, count, &pos);
if (ret >= 0)
file_pos_write(f.file, pos);
fdput_pos(f);
}
return ret;
}
关键代码在vfs_write
.
ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{
ssize_t ret;
if (!(file->f_mode & FMODE_WRITE))
return -EBADF;
if (!(file->f_mode & FMODE_CAN_WRITE))
return -EINVAL;
if (unlikely(!access_ok(VERIFY_READ, buf, count)))
return -EFAULT;
ret = rw_verify_area(WRITE, file, pos, count);
if (!ret) {
if (count > MAX_RW_COUNT)
count = MAX_RW_COUNT;
file_start_write(file);
ret = __vfs_write(file, buf, count, pos);
if (ret > 0) {
fsnotify_modify(file);
add_wchar(current, ret);
}
inc_syscw(current);
file_end_write(file);
}
return ret;
}
继续跟入__vfs_write
,
ssize_t __vfs_write(struct file *file, const char __user *p, size_t count,
loff_t *pos)
{
if (file->f_op->write)
return file->f_op->write(file, p, count, pos);
else if (file->f_op->write_iter)
return new_sync_write(file, p, count, pos);
else
return -EINVAL;
}
if (file->f_op->write)
return file->f_op->write(file, p, count, pos);
关键就是这一句,如果我们打开的驱动设备文件,有没有自己实现的write函数,如果有则调用该驱动自定义的write函数。
参考
The Definitive Guide to Linux System Calls
System call
SYSCALL_DEFINE3 宏定义