0. 整体调用过程
在通常情况下,调用系统调用和调用一个普通的自定义函数在代码上并没有什么区别,
但调用后发生的事情有很大不同。
调用自定义函数是通过 call 指令直接跳转到该函数的地址,继续运行。
而系统调用调用的过程如下:
-
- 应用程序 调用接口函数 (API)
-
- API 将系统调用号放入EAX 中, 然后通过中断调用 使系统进入内核态;
-
- 内核中的中断处理函数 根据系统调用号, 调用对应的内核函数(系统调用);
-
- 系统函数 完成相应功能, 将返回值存入 EAX, 返回到中断处理函数;
-
- 中断处理函数 返回到 API 中;
-
- API 将 EAX 返回给 应用程序。
1. APP 调用API, 应用程序调用接口函数
应用程序调用 接口函数,
接口函数,叫 API(Application Programming Interface)。
API 并不能完成系统调用的真正功能,它要做的是去调用真正的系统调用,过程是:
- 将系统调用的编号存入
EAX
寄存器中; - 把函数参数存入其他 通用寄存器中;
- 触发
0x80
号中断, (int 0x80);
linux-0.11 的 lib 目录下有一些已经实现的 API。
Linus 编写它们的原因是在内核加载完毕后,会切换到用户模式下,做一些初始化工作,然后启动 shell。
而用户模式下的很多工作需要依赖一些系统调用才能完成,因此在内核中实现了这些系统调用的 API。
后面的目录如果没有特殊说明,都是指在 ~/oslab/linux-0.11 中。
比如下面的 lib/close.c,是指 ~/oslab/linux-0.11/lib/close.c。
1.1 lib/close.c
接口函数
我们不妨看看 lib/close.c,研究一下 close() 的 API:
#define __LIBRARY__
#include <unistd.h>
_syscall1(int, close, int,fd)
注意这里的 _syscall1
便是一个接口函数
1.2 接口函数的展开
其中, _syscall1
是一个宏, 在 include/unistd.h
中定义
#define _syscall1(type, name, atype, a) \
type name(atype a)\
{\
long __res; \
__asm__ volatile ("int $0x80"\
: "=a" (__res) \
: "0" (__NR_##name), "b" ((long)(a)) ); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
将 _syscal1(int, close, int, fd)
进行宏展开, 可以得到:
int close(int fd)
{
long __res;
__asm__ volatile("int %0x80"
: "=a" (__res)
: "0" (__NR_close), "b" ((long) (fd)) );
if (__res >= 0)
return (int) __res;
errno = -__res;
return -1;
}
- 它先将宏
__NR_close
存入 EAX, - 将参数
fd
存入 EBX, - 然后进行 0x80 中断调用。
- 调用返回后,从 EAX 取出返回值,存入 __res,
- 再通过对
__res
的判断决定传给 API 的调用者什么样的返回值。
另外, 对于宏展开 ,涉及了 GCC 内嵌汇编的知识,
1.3 系统调用功能号
其中 __NR_close 就是系统调用的编号,在 include/unistd.h
中定义:
#define __NR_close 6
/*
所以添加系统调用时需要修改include/unistd.h文件,
使其包含__NR_whoami和__NR_iam。
*/
#define __NR_sgetmask 68
#define __NR_ssetmask 69
#define __NR_setreuid 70
#define __NR_setregid 71
#define __NR_iam 72
#define __NR_whoami 73
在 0.11 环境下编译 C 程序,包含的头文件都在 /usr/include 目录下
注意, 这里是 启动bochs
硬件模拟器后, 查看的;
。
1.4 系统调用的头文件
该目录下的 unistd.h 是标准头文件(它和 0.11 源码树中的 unistd.h 并不是同一个文件,虽然内容可能相同),没有 __NR_whoami 和 __NR_iam 两个宏,需要手工加上它们,也可以直接从修改过的 0.11 源码树中拷贝新的 unistd.h 过来。
/*
而在应用程序中,要有:
*/
/* 有它,_syscall1 等才有效。详见unistd.h */
#define __LIBRARY__
/* 有它,编译器才能获知自定义的系统调用的编号 */
#include "unistd.h"
/* iam()在用户空间的接口函数 */
_syscall1(int, iam, const char*, name);
/* whoami()在用户空间的接口函数 */
_syscall2(int, whoami,char*,name,unsigned int,size);
2. 中断进入内核, int 0x80
的处理过程
int 0x80 触发后,接下来就是内核的中断处理了。先了解一下 0.11 处理 0x80 号中断的过程。
2.1 内核初始化
在内核初始化时,主函数 在 init/main.c 中调用了 sched_init()
初始化函数;
(Linux 实验环境下是 main(),Windows 下因编译器兼容性问题被换名为 start())
void main(void)
{
// ……
time_init();
sched_init();
buffer_init(buffer_memory_end);
// ……
}
sched_init()
在 kernel/sched.c
中定义为:
void sched_init(void)
{
// ……
set_system_gate(0x80,&system_call);
}
set_system_gate 是个宏,在 include/asm/system.h 中定义为:
#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr)
_set_gate 的定义是:
#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
"movw %0,%%dx\n\t" \
"movl %%eax,%1\n\t" \
"movl %%edx,%2" \
: \
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
"o" (*((char *) (gate_addr))), \
"o" (*(4+(char *) (gate_addr))), \
"d" ((char *) (addr)),"a" (0x00080000))
然看起来挺麻烦,但实际上很简单,就是填写 IDT(中断描述符表),将 system_call 函数地址写到 0x80 对应的中断描述符中,也就是在中断 0x80 发生后,自动调用函数 system_call。具体细节请参考《注释》的第 4 章。
2.2 system_call
函数
接下来看 system_call。该函数纯汇编打造,定义在 kernel/system_call.s 中:
!……
! # 这是系统调用总数。如果增删了系统调用,必须做相应修改
nr_system_calls = 72
!……
.globl system_call
.align 2
system_call:
! # 检查系统调用编号是否在合法范围内
cmpl \$nr_system_calls-1,%eax
ja bad_sys_call
push %ds
push %es
push %fs
pushl %edx
pushl %ecx
! # push %ebx,%ecx,%edx,是传递给系统调用的参数
pushl %ebx
! # 让ds, es指向GDT,内核地址空间
movl $0x10,%edx
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx
! # 让fs指向LDT,用户地址空间
mov %dx,%fs
call sys_call_table(,%eax,4)
pushl %eax
movl current,%eax
cmpl $0,state(%eax)
jne reschedule
cmpl $0,counter(%eax)
je reschedule
system_call 用 .globl 修饰为其他函数可见。
Windows 实验环境下会看到它有一个下划线前缀,这是不同版本编译器的特质决定的,没有实质区别。
2.3 sys_call_table()
call sys_call_table(,%eax,4) 之前是一些压栈保护,修改段选择子为内核段,
call sys_call_table(,%eax,4) 之后是看看是否需要重新调度,这些都与本实验没有直接关系,
此处只关心 call sys_call_table(,%eax,4) 这一句。
根据汇编寻址方法它实际上是:
call sys_call_table + 4 * %eax,其中 eax 中放的是系统调用号,即 __NR_xxxxxx。
显然,sys_call_table 一定是一个函数指针数组的起始地址,它定义在 include/linux/sys.h 中:
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,…
增加实验要求的系统调用,需要在这个函数表中增加两个函数引用 ——sys_iam 和 sys_whoami。当然该函数在 sys_call_table 数组中的位置必须和 __NR_xxxxxx 的值对应上。
同时还要仿照此文件中前面各个系统调用的写法,加上:
extern int sys_whoami();
extern int sys_iam();
不然,编译会出错的。
3.用户态与核心态之间传递数据
针参数传递的是应用程序所在地址空间的逻辑地址,在内核中如果直接访问这个地址,访问到的是内核空间中的数据,不会是用户空间的。所以这里还需要一点儿特殊工作,才能在内核中从用户空间得到数据。
3.1 open
接口函数的 宏展开
要实现的两个系统调用参数中都有字符串指针,非常像 open(char *filename, ……),所以我们看一下 open() 系统调用是如何处理的。
int open(const char * filename, int flag, ...)
{
// ……
__asm__("int $0x80"
:"=a" (res)
:"0" (__NR_open),"b" (filename),"c" (flag),
"d" (va_arg(arg,int)));
// ……
}
可以看出,系统调用是用 eax、ebx、ecx、edx 寄存器来传递参数的。
其中 eax 传递了系统调用号,而 ebx、ecx、edx 是用来传递函数的参数的
ebx 对应第一个参数,ecx 对应第二个参数,依此类推。
如 open 所传递的文件名指针是由 ebx 传递的,也即进入内核后,通过 ebx 取出文件名字符串。open 的 ebx 指向的数据在用户空间,而当前执行的是内核空间的代码,如何在用户态和核心态之间传递数据?
接下来我们继续看看 open 的处理:
system_call: //所有的系统调用都从system_call开始
! ……
pushl %edx
pushl %ecx
pushl %ebx # push %ebx,%ecx,%edx,这是传递给系统调用的参数
movl $0x10,%edx # 让ds,es指向GDT,指向核心地址空间
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx # 让fs指向的是LDT,指向用户地址空间
mov %dx,%fs
call sys_call_table(,%eax,4) # 即call sys_open
由上面的代码可以看出,获取用户地址空间(用户数据段)中的数据依靠的就是段寄存器 fs,下面该转到 sys_open 执行了,在 fs/open.c 文件中:
3.2 open
系统函数
int sys_open(const char * filename,int flag,int mode) //filename这些参数从哪里来?
/*是否记得上面的pushl %edx, pushl %ecx, pushl %ebx?
实际上一个C语言函数调用另一个C语言函数时,编译时就是将要
传递的参数压入栈中(第一个参数最后压,…),然后call …,
所以汇编程序调用C函数时,需要自己编写这些参数压栈的代码…*/
{
……
if ((i=open_namei(filename,flag,mode,&inode))<0) {
……
}
……
}
它将参数传给了 open_namei()。
3.3 传递数据的核心函数 get_fs_byte()
再沿着 open_namei() 继续查找,文件名先后又被传给dir_namei()、get_dir()。
在 get_dir() 中可以看到:
static struct m_inode * get_dir(const char * pathname)
{
……
if ((c=get_fs_byte(pathname))=='/') {
……
}
……
}
处理方法就很显然了:用 get_fs_byte() 获得一个字节的用户空间中的数据。
所以,在实现 iam() 时,调用 get_fs_byte() 即可。
但如何实现 whoami() 呢?即如何实现从核心态拷贝数据到用心态内存空间中呢?
猜一猜,是否有 put_fs_byte()?有!看一看 include/asm/segment.h :
extern inline unsigned char get_fs_byte(const char * addr)
{
unsigned register char _v;
__asm__ ("movb %%fs:%1,%0":"=r" (_v):"m" (*addr));
return _v;
}
extern inline void put_fs_byte(char val,char *addr)
{
__asm__ ("movb %0,%%fs:%1"::"r" (val),"m" (*addr));
}
他俩以及所有 put_fs_xxx() 和 get_fs_xxx() 都是用户空间和内核空间之间的桥梁,在后面的实验中还要经常用到。