本文仅作为学习记录,非商业用途,侵删,如需转载需作者同意。
系统终于进入了用户态,公司进入正规可以开始接项目了。
这一节来解析Linux办事大厅如何实现的,因为每一个模块都涉及系统调用,层层下去了解可以内核各个模块的实现机制。
有时客户觉得直接去办事大厅不够方便。 Linux 还提供了glibc 这个中介,它更熟悉系统调用的细节,可以封装成更好的接口方便使用。
一、glibc对系统调用的封装
这一节解析到glibc 如何调用到内核的 open :
先在用户态进程里面调用open 函数:
为了方便大部分人会选择glibc 里面的open 函数,函数定义如下:
1 | int open(const char *pathname, int flags, mode_t mode) |
在glibc的源码中,有个文件syscalls.list 里面列着所有 glibc 的函数对应的系统调用,如下:
1 2 | # 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 使用上面这个宏,定义了这个系统调用的调用方式。
1 2 3 4 5 | 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 也是一个宏,它的定义如下:
1 2 3 4 5 6 | #define PSEUDO(name, syscall_name, args) \ .text; \ ENTRY (name) \ DO_CALL (syscall_name, args); \ cmpl $-4095, %eax; \ jae SYSCALL_ERROR_LABEL |
里面对于任何一个系统调用,会调用 DO_CALL,这也是一个宏,这个宏32位和64位的定义是不一样的。
1、32系统调用过程
32位的情况(i386目录下的sysdep.h文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | /* 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 是什么呢?
1 | # define ENTER_KERNEL int $0x80 |
int 就是 interrupt ,也就是中断的意思。 int $0x80 就是触发一个软中断,通过它就可以陷入 (trap) 内核。
在内核启动的时候,还记得有一个 trap_init() ,其中有这样的代码:
1 | set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32); |
这是软中断的陷入门,当接收到一个系统调用的时候,entry_INT80_32 就被调用了。
1 2 3 4 5 6 7 8 9 10 | ENTRY(entry_INT80_32) ASM_CLAC pushl %eax /* pt_regs->orig_ax */ SAVE_ALL pt_regs_ax=$-ENOSYS /* save rest */ movl %esp, %eax call do_syscall_32_irqs_on .Lsyscall_32_done: ...... .Lirq_return: INTERRUPT_RETURN |
通过push 和 SAVE_ALL 将当前用户态的寄存器,保存在 pt_regs 结构里面。
进入内核之前,保存所有的寄存器,然后调用do_syscall_32_irqs_on ,它的实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 | 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; ...... if (likely(nr < IA32_NR_syscalls)) {<!-- --> 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); } |
在这里,我们看到,将系统调用号从eax里面取出来,然后根据系统调用号,在系统调用表中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。如果仔细比对,就能发现,这些参数所对应的寄存器,和Linux 的注释是一样的。
根据宏定义,#define ia32_sys_call_table sys_call_table,系统调用就是放在这个表里面,至于这个表如何形成的,我们后面再讲。
当系统调用结束之后,在entry_INT80_32 之后,紧接着调用的是INTERRUPT_RETURN,我们能够找到它的定义,也就是 iret 。
1 | #define INTERRUPT_RETURN iret |
iret 指令将原来用户态保存的现场恢复回来,包含代码段,指令指针寄存器等。这时候用户态进程恢复执行。
这里我总结以下32位的系统调用是如何执行的。
2、64位系统调用过程
我们再来看64位的情况(x86_64下的 sysdep.h文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | /* The Linux/x86-64 kernel expects the system call parameters in registers according to the following table: syscall number rax arg 1 rdi arg 2 rsi arg 3 rdx arg 4 r10 arg 5 r8 arg 6 r9 ...... */ #define DO_CALL(syscall_name, args) \ lea SYS_ify (syscall_name), %rax; \ syscall |
和之前一样,还是将系统调用名称转换为系统调用号,放到寄存器rax。 这里是真正进行调用,不是用中断了,而是改用syscall 指令了。并且,通过注释我们也知道,传递参数的寄存器也变了。
syscall 指令还使用了一种特殊的寄存器,我们叫特殊模块寄存器(Model Specific Registers,简称MSR)。这种寄存器是CPU为了完成某些特殊控制功能为目的的寄存器,其中就有系统调用。
在系统初始化的时候,trap_init 除了初始化上面的中断模式,这里面还会调用 cpu_init -> syscall_init,这里面有这样的代码:
1 | wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64); |
rdmsr 和 wrmsr 是用来读写特殊模块寄存器的,MSR_LSTAR就是这样一个特殊的寄存器,当 syscall 指令调用的时候,会从这个寄存器里面拿出函数地址来调用,也就是调用entry_SYSCALL_64。
在 arch/x86/entry/entry_64.S 中定义了 entry_SYSCALL_64。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | ENTRY(entry_SYSCALL_64) /* Construct struct pt_regs on stack */ pushq $__USER_DS /* pt_regs->ss */ pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */ pushq %r11 /* pt_regs->flags */ pushq $__USER_CS /* pt_regs->cs */ pushq %rcx /* pt_regs->ip */ pushq %rax /* pt_regs->orig_ax */ pushq %rdi /* pt_regs->di */ pushq %rsi /* pt_regs->si */ pushq %rdx /* pt_regs->dx */ pushq %rcx /* pt_regs->cx */ pushq $-ENOSYS /* pt_regs->ax */ pushq %r8 /* pt_regs->r8 */ pushq %r9 /* pt_regs->r9 */ pushq %r10 /* pt_regs->r10 */ pushq %r11 /* pt_regs->r11 */ sub $(6*8), %rsp /* pt_regs->bp, bx, r12-15 not saved */ movq PER_CPU_VAR(current_task), %r11 testl $_TIF_WORK_SYSCALL_ENTRY|_TIF_ALLWORK_MASK, TASK_TI_flags(%r11) jnz entry_SYSCALL64_slow_path ...... entry_SYSCALL64_slow_path: /* IRQs are off. */ SAVE_EXTRA_REGS movq %rsp, %rdi call do_syscall_64 /* returns with IRQs disabled */ return_from_SYSCALL_64: RESTORE_EXTRA_REGS TRACE_IRQS_IRETQ movq RCX(%rsp), %rcx movq RIP(%rsp), %r11 movq R11(%rsp), %r11 ...... syscall_return_via_sysret: /* rcx and r11 are already restored (see code above) */ RESTORE_C_REGS_EXCEPT_RCX_R11 movq RSP(%rsp), %rsp USERGS_SYSRET64 |
这里先保存了很多寄存器到pt_regs 结构里面,例如用户态的代码段,数据段,保存参数的寄存器,然后调用 entry_SYSCALL64_slow_pat ->do_syscall_64。
1 2 3 4 5 6 7 8 9 10 11 12 | __visible void do_syscall_64(struct pt_regs *regs) {<!-- --> struct thread_info *ti = current_thread_info(); unsigned long nr = regs->orig_ax; ...... if (likely((nr & __SYSCALL_MASK) < NR_syscalls)) {<!-- --> regs->ax = sys_call_table[nr & __SYSCALL_MASK]( regs->di, regs->si, regs->dx, regs->r10, regs->r8, regs->r9); } syscall_return_slowpath(regs); } |
在do_syscall_64 里面,从rax里面拿出系统调用号,然后根据系统调用号,在系统调用表 sys_call_table 中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。如果仔细比对,你就能发现,这些参数所对应的寄存器和Linux的注释又是一样的。
所以,无论是32位,还是64位,都会到系统调用表 sys_call_table 这里来。
在研究系统调用表之前,我们看64位的系统调用返回的时候,执行的是 USERGS_SYSRET64。 定义如下:
1 2 3 | #define USERGS_SYSRET64 \ swapgs; \ sysretq; |
这里,返回用户态的指令变成了 sysretq。
我们这里总结下64位的系统调用如何执行的。
二、系统调用表
前面我们重点关注了系统调用的方式,都是最终到了系统调用表,但是到底调用内核的什么函数,还没有解读。
下面看下系统调用表 sys_call_table 是怎么形成的:
32位的系统调用表定义在 arch/x86/entry/syscalls/syscall_32.tbl 文件里,例如open 是这样定义的:
1 | 5 i386 open sys_open compat_sys_open |
64位的系统调用定义在另一个文件 arch/x86/entry/syscalls/syscall_64.tbl 里,例如 open 是这样定义的:
1 | 2 common open sys_open |
第一列的数字是系统调用号,32位和64位是不一样的
。
第三列是系统调用的名字;
第四列是系统调用在内核的实现函数,以sys_开头。
系统调用在内核中的实现函数要有一个声明,声明一般在 include/linux/syscalls.h 文件中,例如 sys_open 是这样的:
1 2 | asmlinkage long sys_open(const char __user *filename, int flags, umode_t mode); |
真正实现系统调用的地方,一般在
1 2 3 4 5 6 | 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 是一个宏系统调用,最多六个参数,根据参数的数目选择宏。具体是这样定义的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | #define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__) #define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__) #define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__) #define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__) #define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__) #define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__) #define SYSCALL_DEFINEx(x, sname, ...) \ SYSCALL_METADATA(sname, x, __VA_ARGS__) \ __SYSCALL_DEFINEx(x, sname, __VA_ARGS__) #define __PROTECT(...) asmlinkage_protect(__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_32.tbl 和 syscall_64.tbl 生成自己的unistd_32.sh 和 unistd_64.h。生成方式在
这里面会使用2个脚本,其中第一个脚本,arch/x86/entry/syscalls/syscallhdr.sh,会在文件中生产 #define __NR_open;第二个脚本arch/x86/entry/syscalls/syscalltbl.sh 会在文件中生成 __SYSCALL(__NR_open,sys_open)。 这样,unistd_32.h 和 unistd_64.h 是对应的系统调用号和系统调用实现函数之间的对应关系。
在文件 arch/x86/entry/syscall_32.c ,定义了这样一个表。 里面include 了这个头文件,从而所有的 sys_ 系统调用都在这个表里面了。
1 2 3 4 5 6 7 8 | __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> }; |
同理,在文件 arch/x86/entry/syscall_64.c 定义了这样一个表,里面include 了这个头文件,这样所有的 sys_ 的系统调用都在这个里面了。
1 2 3 4 5 6 7 8 9 | /* System call table for x86-64. */ asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {<!-- --> /* * Smells like a compiler bug -- it doesn't work * when the & below is removed. */ [0 ... __NR_syscall_max] = &sys_ni_syscall, #include <asm/syscalls_64.h> }; |
三、总结时刻
1、评论补充
一、宏是什么?给像我一样不懂C的人:
1,使用命令 #define 定义宏。该命令允许把一个名称指定成任何所需的文本,例如一个常量值或者一条语句。在定义了宏之后,无论宏名称出现在源代码的何处,预处理器都会把它用定义时指定的文本替换掉。
2,宏的名称一般使用全大写的形式。
3,宏可以定义参数,参数列表需要使用圆括号包裹,且必须紧跟名称,中间不能有空格。
4,使用#undef NAME取消宏的定义,从而可以重新定义或使用与宏重名的函数或变量。
5,出现在字符串中的宏名称不会被预编译器展开。
二、系统调用层:
1 用户在应用空间想要用内核环境的资源,怎么办捏?linux死规定了,就只能通过系统调用层
2 用户想要用什么资源就得通过调用对应的系统调用函数并加上参数
3 什么时候才真正实现了得到你想要的资源呢?那就是进入到内核空间(在中断处理函数里就可以),并调用了对应的系统调用函数(通过你在应用空间使用的函数(这些是名字固定了的) --> 里面有函数计算出对应的(映射的)真正系统调用号(就是真正系统调用函数地址在系统调用数组里的位置) --> 通过现在得到的系统调用号从系统调用数组中拿出这个真正的系统调用函数并执行,肯定加上一起传下来的参数了 --> 返回
三、
系统调用都会导致用户态切换内核态?而纯计算的不会?
作者回复: 系统调用都会,纯计算看算什么了,算加法不用进内核