本文以x86_64平台为例,分析linux下的系统调用是如何被执行的。
假设目标系统调用是,其对应的内核源码为:
// fs/read_write.c
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
return ksys_write(fd, buf, count);
}
这里主要看下SYSCALL_DEFINE3这个宏定义:
// include/linux/syscalls.h
#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_DEFINEx(x, sname, __VA_ARGS__)
【文章福利】小编推荐自己的Linux内核技术交流群:【 865977150】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!前100名进群领取,额外赠送一份价值 699的内核资料包(含视频教程、电子书、实战项目及代码)
资料直通车:最新Linux内核源码资料文档+视频资料
学习直通车:Linux内核源码/内存调优/文件系统/进程管理/设备驱动/网络协议栈
该宏又引用了__SYSCALL_DEFINEx,继续看下:
// arch/x86/include/asm/syscall_wrapper.h
#define __SYSCALL_DEFINEx(x, name, ...) \
asmlinkage long __x64_sys##name(const struct pt_regs *regs); \
... \
static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)); \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__));\
asmlinkage long __x64_sys##name(const struct pt_regs *regs) \
{ \
return __se_sys##name(SC_X86_64_REGS_TO_ARGS(x,__VA_ARGS__));\
} \
... \
static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)) \
{ \
long ret = __do_sys##name(__MAP(x,__SC_CAST,__VA_ARGS__));\
... \
return ret; \
} \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))
该宏的参数中,x为3,name为_write,...代表的__VA_ARGS__为unsigned int, fd, const char __user *, buf, size_t, count。
接着,在宏的定义中,先声明了三个函数,分别为__x64_sys_write、_se_sys_write、__do_sys_write,紧接着,定义了__x64_sys_write和_se_sys_write的实现,__x64_sys_write内调用_se_sys_write,_se_sys_write内调用__do_sys_write。
__do_sys_write只是一个方法头,它和最开始的write系统调用的方法体构成完整的方法。
由上可以看到,三个方法中,只有__x64_sys_write方法没有static,即只有它是外部可调用的,所以我们看下哪里引用了__x64_sys_write。
// arch/x86/entry/syscalls/syscall_64.tbl
#
# 64-bit system call numbers and entry vectors
#
# The format is:
#
#
# The __x64_sys_*() stubs are created on-the-fly for sys_*() system calls
#
# The abi is "common", "64" or "x32" for this file.
#
0 common read __x64_sys_read
1 common write __x64_sys_write
...
我们会在一个非c文件中,找到了对__x64_sys_write方法的引用,但这个文件又是怎么被使用的呢?
根据arch/x86/entry/syscalls/Makefile我们可以知道,是有对应的shell脚本,根据上面的文件来生成c版的头文件,比如下面两个。
kernel内部使用的:
// arch/x86/include/generated/asm/syscalls_64.h
#ifdef CONFIG_X86
__SYSCALL_64(0, __x64_sys_read, )
#else /* CONFIG_UML */
__SYSCALL_64(0, sys_read, )
#endif
#ifdef CONFIG_X86
__SYSCALL_64(1, __x64_sys_write, )
#else /* CONFIG_UML */
__SYSCALL_64(1, sys_write, )
#endif
...
给用户使用的:
// arch/x86/include/generated/uapi/asm/unistd_64.h
#define __NR_read 0
#define __NR_write 1
...
那生成的这两个头文件又是给谁使用的呢?看下下面这个文件:
// arch/x86/entry/syscall_64.c
#define __SYSCALL_64(nr, sym, qual) [nr] = sym,
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
};
该文件中定义了一个const的数组变量sys_call_table,数组下标为系统调用的编号,值为该编号对应的系统调用方法。
最开始整个数组都初始化为sys_ni_syscall,该方法内会返回错误码ENOSYS,表示对应的方法未实现。
接着用#include
该include的文件就是上面生成的arch/x86/include/generated/asm/syscalls_64.h,syscalls_64.h文件里调用__SYSCALL_64,为对应的系统下标赋值。
最后,sys_call_table[1] = __x64_sys_write。
到这里,我们基本可以猜测,肯定有个地方是根据系统调用的编号,到数组sys_call_table中找到对应方法,然后调用。
让我们来看下这段代码在哪里
// arch/x86/entry/common.c
__visible void do_syscall_64(unsigned long nr, struct pt_regs *regs)
{
...
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
regs->ax = sys_call_table[nr](regs);
}
...
}
上面的方法就是我们要找的方法。
我们再看下这个方法是在哪里被调用的。
// arch/x86/entry/entry_64.S
ENTRY(entry_SYSCALL_64)
...
call do_syscall_64 /* returns with IRQs disabled */
...
上面的就是对应的汇编代码了,这里为了简单,省略掉了该汇编方法的其他部分。
那这段汇编代码又是在哪里调用的呢?
// arch/x86/kernel/cpu/common.c
void syscall_init(void)
{
...
wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
...
}
在上面的方法中,我们可以看到,汇编代码entry_SYSCALL_64被写到了MSR_LSTAR表示的寄存器中。
该寄存器的作用就是,当我们执行syscall机器指令时,MSR_LSTAR寄存器中存放的对应方法就会被执行,即在user space,我们只要执行syscall机器指令,给它对应的系统调用编号和参数,kernel space里对应的系统调用就会被执行了。
有兴趣的可以分析并执行下下面的汇编代码,好好体会下整个系统调用的流程。
# ----------------------------------------------------------------------------------------
# Writes "Hello, World" to the console using only system calls. Runs on 64-bit Linux only.
# To assemble and run:
#
# gcc -c hello.s && ld hello.o && ./a.out
#
# or
#
# gcc -nostdlib hello.s && ./a.out
# ----------------------------------------------------------------------------------------
.global _start
.text
_start:
# write(1, message, 13)
mov $1, %rax # system call 1 is write
mov $1, %rdi # file handle 1 is stdout
mov $message, %rsi # address of string to output
mov $13, %rdx # number of bytes
syscall # invoke operating system to do the write
# exit(0)
mov $60, %rax # system call 60 is exit
xor %rdi, %rdi # we want return code 0
syscall # invoke operating system to exit
message:
.ascii "Hello, world\n"
到这里,系统调用对应的kernel space部分就已经分析完毕了,下篇文章我们结合对应的c源码,看下user space的部分是如何实现的。
简而言之就是通过一定的约定来实现指定系统调用编号和传递参数及返回值。
比如x86_64平台,在执行syscall机器码之前,系统调用的编号要先放到rax寄存器,参数要分别放到rdi、rsi、rdx、r10、r8、r9寄存器中,这样kernel中的代码就会从这些地方取值,然后继续执行逻辑,当kernel部分的逻辑完成之后,结果会再放到rax寄存器中,这样user space的部分就可以从rax寄存器中拿到返回值。
下面我们再来看下上篇文章最后的例子:
# ----------------------------------------------------------------------------------------
# Writes "Hello, World" to the console using only system calls. Runs on 64-bit Linux only.
# To assemble and run:
#
# gcc -c hello.s && ld hello.o && ./a.out
#
# or
#
# gcc -nostdlib hello.s && ./a.out
# ----------------------------------------------------------------------------------------
.global _start
.text
_start:
# write(1, message, 13)
mov $1, %rax # system call 1 is write
mov $1, %rdi # file handle 1 is stdout
mov $message, %rsi # address of string to output
mov $13, %rdx # number of bytes
syscall # invoke operating system to do the write
# exit(0)
mov $60, %rax # system call 60 is exit
xor %rdi, %rdi # we want return code 0
syscall # invoke operating system to exit
message:
.ascii "Hello, world\n"
现在就非常明白了吧,比如第一个write系统调用,因为其编号为1,所以先将1放到rax里,之后将标准输出文件描述符到到rdi里,再之后将message地址放到rsi里,再之后将message的长度13放到rdx里,最后调用syscall机器码,这样就会转到对应kernel space部分的代码。
从汇编角度我们已经讲明白了,那在c语言中我们又是如何调用呢?总不能在c中嵌入汇编代码吧?
其实本质上就是在c中嵌入汇编代码,只是不是我们来做,而是glibc来帮我做。
再来看个例子:
#include
int main(int argc, char *argv[]) {
write(STDOUT_FILENO, "Hello, World\n", 13);
return 60;
}
这个例子就是上面汇编代码对应的c实现,编译执行之后也是会输出同样的内容。
注意,这里的write并不是kernel内部的系统调用write,而是glibc中的一个wrapper,这个wrapper里面再帮我们调用真正的系统调用write。
我们再来看下对应的glibc的代码:
// sysdeps/unix/sysv/linux/write.c
/* Write NBYTES of BUF to FD. Return the number written, or -1. */
ssize_t
__libc_write (int fd, const void *buf, size_t nbytes)
{
return SYSCALL_CANCEL (write, fd, buf, nbytes);
}
...
weak_alias (__libc_write, write)
...
这里需要注意的是,write方法其实是__lib_write的一个weak alias,当我们调用write时,其实相当于我们在调用__lib_write。
继续看下SYSCALL_CANCEL宏:
// sysdeps/unix/sysdep.h
#define SYSCALL_CANCEL(...) \
({ \
long int sc_ret; \
if (SINGLE_THREAD_P) \
sc_ret = INLINE_SYSCALL_CALL (__VA_ARGS__); \
else \
{
... \
} \
sc_ret; \
})
这个宏里面又调用了INLINE_SYSCALL_CALL,INLINE_SYSCALL_CALL里又调用了很多其他的宏,这里就不一一展开了,有兴趣的朋友可以留言,我们再一起交流。
最终,会调用下面的宏。
// sysdeps/unix/sysv/linux/x86_64/sysdep.h
#define internal_syscall3(number, err, arg1, arg2, arg3) \
({ \
unsigned long int resultvar; \
TYPEFY (arg3, __arg3) = ARGIFY (arg3); \
TYPEFY (arg2, __arg2) = ARGIFY (arg2); \
TYPEFY (arg1, __arg1) = ARGIFY (arg1); \
register TYPEFY (arg3, _a3) asm ("rdx") = __arg3; \
register TYPEFY (arg2, _a2) asm ("rsi") = __arg2; \
register TYPEFY (arg1, _a1) asm ("rdi") = __arg1; \
asm volatile ( \
"syscall\n\t" \
: "=a" (resultvar) \
: "0" (number), "r" (_a1), "r" (_a2), "r" (_a3) \
: "memory", REGISTERS_CLOBBERED_BY_SYSCALL); \
(long int) resultvar; \
})
是不是很熟悉,这就是我们上面手写的汇编代码啊。
到此,整个流程就全部通了。
我们在写c时(其他语言也一样),调用的其实是glibc里的wrapper,glibc里的wrapper再帮我们调用对应的系统调用,之后再将结果从rax中取出,返回给我们,这样我们使用起来就非常方便了