原作者:Ian Wienand
This work is licensed under the Creative Commons Attribution-ShareAlike License. To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/3.0/
参考
http://bottomupcs.com/
https://www.bottomupcs.com/system_calls.xhtml
总览
系统调用是描述用户态程序与内核如何与交互的机制,这背后的一般性原理如下描述。
系统调用编号
每个系统调用都有一个单独的系统调用编号,这个编号同时被用户态和内核态所知晓,比如,内核态和用户态同时知道系统调用编号10是 open(), 编号11是read()。(注:这里存疑,与unistd.h中不一致,望大佬告知)
ABI(application binary interface) 与 API (Application programming Interface)非常相似,但是API是提供给软件的,而ABI是提供给硬件的。ABI会定义 系统调用的编号应该被放进哪一个寄存器(比如在x86与特定的arm平台,这个寄存器是不同的),使得内核可以在被告知要执行这个系统调用时,可以找到这个系统调用。
注:博主也是第一次接触ABI的概念,根据百科的理解,API与ABI的区别在于,基于API编程的代码只要有对应的API,就可以编译通过。而基于特定ABI编译的目标码(可执行程序),可以在兼容该ABI的硬件系统上运行。
参数
很多情况下,系统调用在没有参数时是无法被执行的,比如open()调用需要告诉内核,哪一个文件需要被打开,同样的,ABI会定义为了能够被系统调用使用,参数需要被存储在哪个寄存器中。
实现机制(the trap)
为了实际执行系统调用,需要有某些手段能够让我们与我们希望执行系统调用的内核进行交互。所有的体系架构都会定义一个指令,通常叫中断(break)或者类似的名称,这个指令能够发送信号给硬件,告知硬件我们希望能够执行一个系统调用。
具体来说,这个指令会告诉硬件去修改当前的程序指令指针,指向内核的系统调用处理函数(在操作系统初始化的时候,他会告诉硬件系统调用处理函数所在的位置)。一旦用户空间调用了中断命令,它就会失去对程序的控制,并把这个控制权交付给内核。
剩下的操作是非常简单明了的,内核会去查看预先定义的寄存器,并找到系统调用的编号,然后根据编号去查找预定义的表中哪个函数需要被调用,以完成这个系统调用。对应的函数会被执行,执行完对应的操作后,将其返回值放置在另一个ABI 预先定义好的寄存器中,作为返回值寄存器。
最后内核会执行一个跳转指令,返回到用户态程序,然后用户态程序就可以在它跳到内核的位置重新开始执行。用户态程序通过返回值寄存器得到它想要的结果,然后就可以愉快地向下执行啦。
尽管这个过程的细节可能会非常吓人,但上述流程基本是所有系统调用的核心流程了。
libc
尽管你可以为每个系统调用手撸上述流程,但是系统库已经帮你完成了绝大部分工作,libc库是类unix系统上帮你封装系统调用的标准库,未来我们会了解到更多关于libc库相关的内容。
分析一个系统调用
尽管系统库常常帮我们完成了对系统调用的封装,我们还是有必要深入到更底层,去了解系统调用的工作方式。
我们会阐释可能是最简单的系统调用,getpid() 是如何工作的。这个调用不接受参数,并返回当前进程的id。
1 #include <stdio.h>
/* for syscall() */
#include <sys/syscall.h>
5 #include <unistd.h>
/* system call numbers */
#include <asm/unistd.h>
10 void function(void)
{
int pid;
pid = __syscall(__NR_getpid);
15 }
我们通过写一个简短的c程序,用来阐释系统调用背后的机制。首先要注意的是有一个由系统库提供的 syscall 参数,可以用来直接执行系统调用。这提供了一个简便的方式供程序员在不需要了解通过汇编语言在对应硬件上执行调用的范式的情况下,直接调用系统调用。这么一说,那我们还为啥要用 getpid() 呢? 首先,使用一个带有明确意义的函数符号调用自然会让程序由更好的可读性,而且,更重要的是getpid()调用可能在不同的系统上会有不同的工作方式。比如在linux上,getpid调用就会被缓存,如果它第二次被调用,那么系统库不会为了获取一个一模一样的信息而再去承担执行一次完整的系统调用的开销。
根据linux系统上的约定,系统调用编号会被定义在 asm/unistd.h 头文件中,是内核源文件的一部分。因为位于asm子目录中,这意味着不同的体系架构执行系统调用的方式可能是各不相同的。然后同样是由于约定,系统调用的编号会有一个包含了 “_NR” 的宏定义名称。因此你可以看到我们的程序执行了getpid系统调用,并把结果存在pid变量中。
我们会过一下不同的体系架构 实现上述代码的内部结构。这意味着我们要去查看真实代码,所以可能会变得复杂的有些吓人,但坚持下去,因为这正是你想要知道的系统的工作方式。
PowerPC
PowerPC是一个常用的RISC架构,用于早年的苹果电脑,以及像Xbox这样最新设备的核心。
Example 4.2. PowerPC system call example
1
/* On powerpc a system call basically clobbers the same registers like a
* function call, with the exception of LR (which is needed for the
* "sc; bnslr" sequence) and CR (where only CR0.SO is clobbered to signal
5 * an error return status).
*/
#define __syscall_nr(nr, type, name, args...) \
unsigned long __sc_ret, __sc_err; \
10 { \
register unsigned long __sc_0 __asm__ ("r0"); \
register unsigned long __sc_3 __asm__ ("r3"); \
register unsigned long __sc_4 __asm__ ("r4"); \
register unsigned long __sc_5 __asm__ ("r5"); \
15 register unsigned long __sc_6 __asm__ ("r6"); \
register unsigned long __sc_7 __asm__ ("r7"); \
\
__sc_loadargs_##nr(name, args); \
__asm__ __volatile__ \
20 ("sc \n\t" \
"mfcr %0 " \
: "=&r" (__sc_0), \
"=&r" (__sc_3), "=&r" (__sc_4), \
"=&r" (__sc_5), "=&r" (__sc_6), \
25 "=&r" (__sc_7) \
: __sc_asm_input_##nr \
: "cr0", "ctr", "memory", \
"r8", "r9", "r10","r11", "r12"); \
__sc_ret = __sc_3; \
30 __sc_err = __sc_0; \
} \
if (__sc_err & 0x10000000) \
{ \
errno = __sc_ret; \
35 __sc_ret = -1; \
} \
return (type) __sc_ret
#define __sc_loadargs_0(name, dummy...) \
40 __sc_0 = __NR_##name
#define __sc_loadargs_1(name, arg1) \
__sc_loadargs_0(name); \
__sc_3 = (unsigned long) (arg1)
#define __sc_loadargs_2(name, arg1, arg2) \
45 __sc_loadargs_1(name, arg1); \
__sc_4 = (unsigned long) (arg2)
#define __sc_loadargs_3(name, arg1, arg2, arg3) \
__sc_loadargs_2(name, arg1, arg2); \
__sc_5 = (unsigned long) (arg3)
50 #define __sc_loadargs_4(name, arg1, arg2, arg3, arg4) \
__sc_loadargs_3(name, arg1, arg2, arg3); \
__sc_6 = (unsigned long) (arg4)
#define __sc_loadargs_5(name, arg1, arg2, arg3, arg4, arg5) \
__sc_loadargs_4(name, arg1, arg2, arg3, arg4); \
55 __sc_7 = (unsigned long) (arg5)
#define __sc_asm_input_0 "0" (__sc_0)
#define __sc_asm_input_1 __sc_asm_input_0, "1" (__sc_3)
#define __sc_asm_input_2 __sc_asm_input_1, "2" (__sc_4)
60 #define __sc_asm_input_3 __sc_asm_input_2, "3" (__sc_5)
#define __sc_asm_input_4 __sc_asm_input_3, "4" (__sc_6)
#define __sc_asm_input_5 __sc_asm_input_4, "5" (__sc_7)
#define _syscall0(type,name) \
65 type name(void) \
{ \
__syscall_nr(0, type, name); \
}
70 #define _syscall1(type,name,type1,arg1) \
type name(type1 arg1) \
{ \
__syscall_nr(1, type, name, arg1); \
}
75
#define _syscall2(type,name,type1,arg1,type2,arg2) \
type name(type1 arg1, type2 arg2) \
{ \
__syscall_nr(2, type, name, arg1, arg2); \
80 }
#define _syscall3(type,name,type1,arg1,type2,arg2,type3,arg3) \
type name(type1 arg1, type2 arg2, type3 arg3) \
{ \
85 __syscall_nr(3, type, name, arg1, arg2, arg3); \
}
#define _syscall4(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4) \
type name(type1 arg1, type2 arg2, type3 arg3, type4 arg4) \
90 { \
__syscall_nr(4, type, name, arg1, arg2, arg3, arg4); \
}
#define _syscall5(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4,type5,arg5) \
95 type name(type1 arg1, type2 arg2, type3 arg3, type4 arg4, type5 arg5) \
{ \
__syscall_nr(5, type, name, arg1, arg2, arg3, arg4, arg5); \
}
这段代码是从内核的头文件 asm/unistd.h中截取下来的,它显示了系统调用在PowerPC上是如何被执行的,它看起来非常复杂,但是它是可以一步一步被拆解的。
首先,让我们跳到尾巴处,看到 “_syscallN” 宏 被定义的位置。你可以看到好多个宏,每个宏多接收一个参数。我们可以从最简单的版本开始着手,_syscall10 。 它只接收两个参数,分别是系统调用的返回类型和系统调用的名称。对于getpid调用来说,就是 _syscall0(int,getpid)
到目前位置还是挺简单的吧,现在开始我们要拆解 “__syscall_nr” 宏,这与我们之前的节点没有什么不同,我们接收参数的数量作为第一个参数,然后是返回值类型,系统调用名称,然后是实际的参数。
第一步是声明一些寄存器的名称,它所做的核心无非是说 __sc_0 对应于 寄存器r0,__sc_3 对应于寄存器 r3。编译器一般会编译时根据自己的需要使用寄存器,所以我们对这些寄存器施加限制是很重要的,这样才能避免编译器使用这些我们需要的寄存器。
然后我们会调用 sc_loadargs ,并使用了 ## 这个有趣的符号,这个符号仅仅意味着替换,_sc_loadargs##nr 意味着 ##nr这个位置用 nr这个变量替换,如果nr是 0 ,那么即 __sc_loadargs_0 ,在我们的例子中就是 __sc_loadargs_0(name, args) 。从下面__sc_loadargs_0的宏定义中我们可以看到,它把__sc_0 也就是 “r0” 寄存器0 设置为系统调用的编号,注意 这里 ##再次发挥了作用,将 name字符串和 __NR_拼接起来,构成__NR_getpid,然后这个名称即对应了一个特定的系统调用编号。(注:这个实际上是在内核中定义的头文件,然后由上层libc库进行封装构成实现)
所以,这些看起来很巧妙的代码实际上主要完成的就是把系统调用的编号放入到寄存器0中!!再过一遍代码,我们就会发现,其他宏不过是把系统调用的其他参数放入到r3到r7这些寄存器中。(你的系统调用最多只能拥有5个寄存器)
现在,我们已经准备好去解剖 __asm__代码段了吧。 这里的代码段称为内联汇编,因为这是混合在源代码中的汇编代码,要三言两语把这里语义整个剖析清楚是有点复杂的,但我们可以解释下关键部分。
暂时先忽略 volatile ,这是在告诉编译器,这段代码是不可预测的,故而让编译器不要尝试自作主张使用一些预测的优化处理策略。这里我们也同样采用从 结尾处先开始分析的方式,所有冒号之后的部分都是一种告知编译器,这段内联汇编是如何操作CPU寄存器的处理范式,寄存器需要知道这些,才能避免使用任意其中一个寄存器,否则可能会引起崩溃。
但更有意思的部分是 第一部分的两个汇编语句,那个做了所有事情的语句就是 sc调用。这就是你需要执行系统调用所要做的最核心的环节!
所以这个调用的执行的时候发生了什么呢? cpu会被打断,然后被告知移交控制权给特定的一个代码片段,这个代码片段就是在系统初始化时设置用于处理中断的。实际上存在很多的中断,系统调用只是其中一个。这段代码会读取寄存器R0,并找到对应的系统调用编号,然后查找内核中定义的表,找到对应的函数,并跳转到对应的位置执行系统调用,这个函数会接收寄存器R3-R7的参数。
So, what happens once the system call handler runs and completes?
未完待续.