哈工大操作系统实验系统调用

系统调用实验

实验内容

在 Linux 0.11 上添加两个系统调用,并编写两个简单的应用程序测试它们。

iam()

第一个系统调用是 iam(),其原型为:

1
int iam(const char * name);

完成的功能是将字符串参数 name 的内容拷贝到内核中保存下来。要求 name 的长度不能超过 23 个字符。返回值是拷贝的字符数。如果 name 的字符个数超过了 23,则返回 “-1”,并置 errno 为 EINVAL。

kernal/mywho.c 中实现此系统调用。

whoami()

第二个系统调用是 whoami(),其原型为:

1
int whoami(char* name, unsigned int size);

它将内核中由 iam() 保存的名字拷贝到 name 指向的用户地址空间中,同时确保不会对 name 越界访存(name 的大小由 size 说明)。返回值是拷贝的字符数。如果 size 小于需要的空间,则返回“-1”,并置 errno 为 EINVAL。

也是在 kernal/mywho.c 中实现。

模仿

操作系统实现系统调用的基本过程是:

  1. 应用程序调用库函数(API);
  2. API将系统调用号存入EAX,然后通过中断调用使系统进入内核态;
  3. 内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
  4. 系统调用完成相应功能,将返回值存入EAX,返回到中断处理函数;
  5. 中断处理函数返回到API中;
  6. API将EAX返回给应用程序。

而用户模式下的很多工作需要依赖一些系统调用才能完成,因此在内核中实现了这些系统调用的API。我们不妨看看lib/close.c,研究一下close()的API:

1
2
3
#define __LIBRARY__
#include <unistd.h>
_syscall1(int,close,int,fd)

其中_syscall1是一个宏,在include/unistd.h中定义。将_syscall1(int,close,int,fd)进行宏展开,可以得到:

1
2
3
4
5
6
7
8
9
10
11
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;
}

这就是API的定义。它先将宏__NR_close存入EAX,将参数fd存入EBX,然后进行0x80中断调用。调用返回后,从EAX取出返回值,存入__res,再通过对__res的判断决定传给API的调用者什么样的返回值。

其中__NR_close就是系统调用的编号,在include/unistd.h中定义:

1
#define __NR_close    6

从“int 0x80”进入内核函数

int 0x80触发后,接下来就是内核的中断处理了。先了解一下0.11处理0x80号中断的过程。

在内核初始化时,主函数(在init/main.c中,Linux实验环境下是main(),Windows下因编译器兼容性问题被换名为start())调用了sched_init()初始化函数:

1
2
3
4
5
6
7
8
void main(void)
{
……
time_init();
sched_init();
buffer_init(buffer_memory_end);
……
}

sched_init()在kernel/sched.c中定义为:

1
2
3
4
5
void sched_init(void)
{
……
set_system_gate(0x80,&system_call);
}

set_system_gate是个宏,在include/asm/system.h中定义为:

1
2
#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr)

_set_gate的定义是:

1
2
3
4
5
6
7
8
9
10
#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

增加实验要求的系统调用,需要在这个函数表中增加两个函数引用——sys_myiamsys_mywhoami。当然该函数在sys_call_table数组中的位置必须和__NR_xxxxxx的值对应上。同时还要仿照此文件中前面各个系统调用的写法,:

1
2
extern int sys_mywhoami();
extern int sys_myiam();

==详细解释见:==

每个系统调用都有一个sys_xxxxxx()与之对应,它们都是我们学习和模仿的好对象。比如在fs/open.c中的sys_close(int fd)

1
2
3
4
5
int sys_close(unsigned int fd)
{
……
return (0);
}

它没有什么特别的,都是实实在在地做close()该做的事情。所以只要自己创建一个文件:kernel/who.c,然后实现两个函数就万事大吉了。

在用户态和核心态之间传递数据

指针参数传递的是应用程序所在地址空间的逻辑地址,在内核中如果直接访问这个地址,访问到的是内核空间中的数据,不会是用户空间的。所以这里还需要一点儿特殊工作,才能在内核中从用户空间得到数据。

要实现的两个系统调用参数中都有字符串指针,非常像open(char *filename, ……),所以我们看一下open()系统调用是如何处理的。

1
2
3
4
5
6
7
8
9
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的处理:

1
2
3
4
5
6
7
8
9
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的处理:

1
2
3
4
5
6
7
8
9
10
11
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文件中:

1
2
3
4
5
6
7
8
9
10
11
12
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()。再沿着open_namei()继续查找,文件名先后又被传给dir_namei()get_dir()。在get_dir()中可以看到:

1
2
3
4
5
6
7
8
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:

1
2
3
4
5
6
7
8
9
10
11
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()都是用户空间和内核空间之间的桥梁,在后面的实验中还要经常用到。

实现

iam()

1
2
3
#define __LIBRARY__
#include <unistd.h>
_syscall1(int,myiam,const char*,name)

修改include/unistd.h中定义:

将system_calls的 nr_system_call改了,不然会越界报错,因为这里的nr_system_calls指的是总共有多少个系统调用。

1
#define __NR_myiam		74

sys.h:

1
2
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
...,sys_myiam,sys_mywhoami }
1
2
extern int sys_mywhoami();
extern int sys_myiam();

在mywho.c 实现sys_myiam();函数主体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <string.h>
#include <errno.h>
#include <asm/segment.h>
//要求 `name` 的长度不能超过 23 个字符。返回值是拷贝的字符数。如果 `name` 的字符个数超过了 `23`,则返回 “-1”,
//并置 errno 为 EINVAL。
//保存name
char global_name[24];
int sys_myiam(const char* name){
//name 长度检查:
char str[25];
int i = 0;
do{ // get char from user input
str[i] = get_fs_byte(name + i);
} while (i <= 25 && str[i++] != '\0');

if (i > 24){
return -(EINVAL);
}
strcpy(global_name, str);

return i-1;

}

要注意的是,这里代码是位于内核态的。我们定义的数据都是在内核态中定义的。无法直接获取用户态的内容,所以这里用了两个函数get_fs_byteput_fs_byte,第一个用来得到用户态的数据,第二个用来将数据存入用户态

whoami

操作同iam,函数体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//它将内核中由 `iam()` 保存的名字拷贝到 name 指向的用户地址空间中,
//同时确保不会对 `name` 越界访存(`name` 的大小由 `size` 说明)。返回值是拷贝的字符数。
//如果 `size` 小于需要的空间,则返回“-1”,并置 errno 为 EINVAL。
int sys_mywhoami(char* name, unsigned int size){
int length = strlen(global_name);
if(length>size){
return -(EINVAL);
}
int i=0;
do{
put_fs_byte(global_name[i], name + i);
} while (i < length);
return length;

}

测试

修改 Makefile

要想让我们添加的 kernel/who.c 可以和其它 Linux 代码编译链接到一起,必须要修改 Makefile 文件。

Makefile 里记录的是所有源程序文件的编译、链接规则,《注释》3.6 节有简略介绍。我们之所以简单地运行 make 就可以编译整个代码树,是因为 make 完全按照 Makefile 里的指示工作。

Makefile在代码树中有很多,分别负责不同模块的编译工作。我们要修改的是kernel/Makefile。需要修改两处。一处是:

1
2
3
OBJS  = sched.o system_call.o traps.o asm.o fork.o \
panic.o printk.o vsprintf.o sys.o exit.o \
signal.o mktime.o

改为:

1
2
3
OBJS  = sched.o system_call.o traps.o asm.o fork.o \
panic.o printk.o vsprintf.o sys.o exit.o \
signal.o mktime.o who.o mywho.o

另一处:

1
2
3
4
5
6
### Dependencies:
exit.s exit.o: exit.c ../include/errno.h ../include/signal.h \
../include/sys/types.h ../include/sys/wait.h ../include/linux/sched.h \
../include/linux/head.h ../include/linux/fs.h ../include/linux/mm.h \
../include/linux/kernel.h ../include/linux/tty.h ../include/termios.h \
../include/asm/segment.h

在末尾添加:

1
2
3
### Dependencies:
...
mywho.s mywho.o: mywho.c ../include/linux/kernel.h ../include/unistd.h

这一行规则表示了如何生成目标文件 mywho.smywho.o,它们依赖于源文件 mywho.c 以及两个头文件 ../include/linux/kernel.h../include/unistd.h

用printk()调试内核

oslab实验环境提供了基于C语言和汇编语言的两种调试手段。除此之外,适当地向屏幕输出一些程序运行状态的信息,也是一种很高效、便捷的调试方法,有时甚至是唯一的方法,被称为“printf法”。

要知道到,printf()是一个只能在用户模式下执行的函数,而系统调用是在内核模式中运行,所以printf()不可用,要用printk()。它和printf的接口和功能基本相同,只是代码上有一点点不同。printk()需要特别处理一下fs寄存器,它是专用于用户模式的段寄存器。看一看printk的代码(在kernel/printk.c中)就知道了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int printk(const char *fmt, ...)
{
……
__asm__("push %%fs\n\t"
"push %%ds\n\t"
"pop %%fs\n\t"
"pushl %0\n\t"
"pushl $buf\n\t"
"pushl $0\n\t"
"call tty_write\n\t"
"addl $8,%%esp\n\t"
"popl %0\n\t"
"pop %%fs"
::"r" (i):"ax","cx","dx");
……
}

显然,printk()首先push %fs保存这个指向用户段的寄存器,在最后pop %fs将其恢复,printk的核心仍然是调用tty_write()。查看printf()可以看到,它最终也要落实到这个函数上。

编写测试程序

激动地运行一下由你亲手修改过的“Linux 0.11 pro++”!然后编写一个简单的应用程序进行测试。比如在sys_iam()中向终端printk()一些信息,让应用程序调用iam(),从结果可以看出系统调用是否被真的调用到了。

可以直接在Linux 0.11环境下用vi编写(别忘了经常执行sync以确保内存缓冲区的数据写入磁盘),也可以在Ubuntu或Windows下编完后再传到Linux 0.11下。无论如何,最终都必须在Linux 0.11下编译。

myiam测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define __LIBRARY__
#include <unistd.h>
#include <stdio.h>
#include <errno.h>

//iam()在用户空间的接口函数
// 这是定义在unistd.h里面的一个宏,展开后是一个包含int 0x80中断的代码。
_syscall1(int, myiam, const char*, name);

int main(int argc, char* argv[]) {
myiam(argv[1]);
return 0;
}

mywhoami测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define __LIBRARY__
#include <unistd.h>
#include <stdio.h>
#include <errno.h>

// whoami()在用户空间的接口函数
// 这是定义在unistd.h里面的一个宏,展开后是一个包含int 0x80中断的代码。
_syscall2(int, mywhoami,char*,name,unsigned int,size);

int main(char *arg) {
char name[30];
mywhoami(name, 30);
printf("%s\n", name);
return 0;
}

在宿主linux下面挂载镜像文件hdc-linux0.11.img

在HIT目录下运行: sudo ./mount-hdc

拷贝目录后运行: sudo umount hdc

详见文章:参考文章

注意修改sh文件权限:

sudo chmod -R 755 xxx

这里如果使用虚拟机可以正常挂载,如果使用wsl2,由于内核问题无法挂载minix文件系统镜像,需要重新编译内核并更换内核。解决方案详见: wsl2更换内核

结果:

image-20231119230212969

报错

__NR_whoami undeclared

__NR_iam undeclared

在unistd.h中存在函数调用 in function iam: __NR_iam undeclared

in function whoami: __NR_whoami undeclared

原因 在写好的库中也就是在编译完运行之后的系统库中确实不存在这两个号

在0.11环境下编译C程序,包含的头文件都在/usr/include目录下。该目录下的unistd.h是标准头文件(它和0.11源码树中的unistd.h并不是同一个文件,虽然内容可能相同),没有__NR_whoami和__NR_iam两个宏,需要手工加上它们,也可以直接从修改过的0.11源码树中拷贝新的unistd.h过来。

所以要在镜像中修改这些文件