哈工大操作系统实验(二)系统调用
本实验是 操作系统之基础 - 网易云课堂 的配套实验,推荐大家进行实验之前先学习相关课程:
L4 操作系统接口
L5 系统调用的实现
实验目的:
建立对系统调用接口的深入认识;
掌握系统调用的基本过程;
能完成系统调用的全面控制;
为后续实验做准备。
此次实验的基本内容是:在 Linux 0.11 上添加两个系统调用,并编写两个简单的应用程序测试它们。
(1)iam()
第一个系统调用是 iam(),其原型为:
int iam(const char * name);
完成的功能是将字符串参数 name 的内容拷贝到内核中保存下来。要求 name 的长度不能超过 23 个字符。返回值是拷贝的字符数。如果 name 的字符个数超过了 23,则返回 “-1”,并置 errno 为 EINVAL。
在 kernal/who.c 中实现此系统调用。
(2)whoami()
第二个系统调用是 whoami(),其原型为:
int whoami(char* name, unsigned int size);
它将内核中由 iam() 保存的名字拷贝到 name 指向的用户地址空间中,同时确保不会对 name 越界访存(name 的大小由 size 说明)。返回值是拷贝的字符数。如果 size 小于需要的空间,则返回“-1”,并置 errno 为 EINVAL。
也是在 kernal/who.c 中实现。
(3)测试程序
运行添加过新系统调用的 Linux 0.11,在其环境下编写两个测试程序 iam.c 和 whoami.c。最终的运行结果是:
$ ./iam lizhijun
$ ./whoami
lizhijun
实验提示:
操作系统实现系统调用的基本过程(在 MOOC 课程中已经给出了详细的讲解)是:
应用程序调用库函数(API);
API 将系统调用号存入 EAX,然后通过中断调用使系统进入内核态;
内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
系统调用完成相应功能,将返回值存入 EAX,返回到中断处理函数;
中断处理函数返回到 API 中;
API 将 EAX 返回给应用程序。
在外部看调用一个接口,只需传入表示文件描述符的整数,和进程ID作为参数给相应的系统调用;而接口内部的实现逻辑相对来说是复杂的,比如创建一个新的进程,拷贝当前进程。
在通常情况下,调用系统调用和调用一个普通的自定义函数在代码上并没有什么区别,但调用后发生的事情有很大不同。
调用自定义函数是通过 call 指令直接跳转到该函数的地址,继续运行。
而调用系统调用,是调用系统库中为该系统调用编写的一个接口函数,叫 API(Application Programming Interface)。API 并不能完成系统调用的真正功能,它要做的是去调用真正的系统调用,过程是:
把系统调用的编号存入 EAX;
把函数参数存入其它通用寄存器;
触发 0x80 号中断(int 0x80)。
linux-0.11 的 lib 目录下有一些已经实现的 API。Linus 编写它们的原因是在内核加载完毕后,会切换到用户模式下,做一些初始化工作,然后启动 shell。而用户模式下的很多工作需要依赖一些系统调用才能完成,因此在内核中实现了这些系统调用的 API。
我们不妨看看 lib/close.c,研究一下 close() 的 API:

其中 _syscall1 是一个宏,在 include/unistd.h 中定义。

将 _syscall1(int,close,int,fd) 进行宏展开,可以得到:
int close(int fd)
{
long __res;
__asm__ volatile ("int $0x80"//调用系统中断0x80
: "=a" (__res)//返回值eax(__res)
: "0" (__NR_close),"b" ((long)(fd)));//输入为系统中断调用号__NR_name
if (__res >= 0)//如果返回值>=0,则直接返回该值
return (int) __res;
errno = -__res;//否则置出错号,并返回-1
return -1;
}
上面是内嵌汇编,基本格式为:
asm("汇编语句模块"
:输出寄存器
:输入寄存器
:会被修改的寄存器);
即:
__asm__ volatile ("int $0x80"//汇编语句模块
: "=a" (__res)//"=a"表示输出寄存器为eax
: "0" (__NR_close),"b" ((long)(fd)));//"0"表示输入寄存器使用与上面相同的寄存器,即eax; "b"表示输入寄存器为ebx
再看一下前面系统的调用的过程:
应用程序调用库函数(API);
API 将系统调用号存入 EAX,然后通过中断调用使系统进入内核态;
内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
系统调用完成相应功能,将返回值存入 EAX,返回到中断处理函数;
中断处理函数返回到 API 中;
API 将 EAX 返回给应用程序。
这就是 API 的定义。它先将宏__NR_close 存入 EAX,将参数 fd 存入 EBX,然后进行 0x80 中断调用。调用返回后,从 EAX 取出返回值,存入 __res,再通过对 __res 的判断决定传给 API 的调用者什么样的返回值。
其中 __NR_close 就是系统调用的编号,在 include/unistd.h 中定义:
#define __NR_close 6
/*
所以添加系统调用时需要修改include/unistd.h文件,
使其包含__NR_whoami和__NR_iam。
*/
/*
而在应用程序中,要有:
*/
/* 有它,_syscall1 等才有效。详见unistd.h */
#define __LIBRARY__
/* 有它,编译器才能获知自定义的系统调用的编号 */
#include "unistd.h"
/* iam()在用户空间的接口函数 */
_syscall1(int, iam, const char*, name);
/* whoami()在用户空间的接口函数 */
_syscall2(int, whoami,char*,name,unsigned int,size);
在 0.11 环境下编译 C 程序,包含的头文件都在 /usr/include 目录下。
该目录下的 unistd.h 是标准头文件(它和 0.11 源码树中的 unistd.h 并不是同一个文件,虽然内容可能相同),没有 __NR_whoami 和 __NR_iam 两个宏,需要手工加上它们,也可以直接从修改过的 0.11 源码树中拷贝新的 unistd.h 过来。
注意:
unistd.h 不能直接在oslab直接直接修改,
而需要在虚拟机中修改,
在oslab中有一个mount-hdc脚本
运行sudo ./mount-hdc 可以把虚拟机硬盘挂载在oslab/hdc 目录下。
(这个也可以实现文件共享)
在hdc/usr/include 目录下修改unistd.h
修改后的unistd.h文件:

int 0x80 触发后,接下来就是内核的中断处理了。先了解一下 0.11 处理 0x80 号中断的过程。
在内核初始化时,主函数(在 init/main.c 中,Linux 实验环境下是 main(),Windows 下因编译器兼容性问题被换名为 start())调用了 sched_init() 初始化函数:
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 章。
接下来看 system_call。该函数纯汇编打造,定义在 kernel/system_call.s 中:
!……
! # 这是系统调用总数。如果增删了系统调用,必须做相应修改,
!本题中因为增加了sys_iam()和sys_whoami()就要将其加2
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 修饰为其他函数可见。
需要把nr_system_calls修改为74,表示中断函数的个数

Windows 实验环境下会看到它有一个下划线前缀,这是不同版本编译器的特质决定的,没有实质区别。
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();

不然,编译会出错的。
添加系统调用的最后一步,是在内核中实现函数 sys_iam() 和 sys_whoami()。
每个系统调用都有一个 sys_xxxxxx() 与之对应,它们都是我们学习和模仿的好对象。
比如在 fs/open.c 中的 sys_close(int fd):
int sys_close(unsigned int fd)
{
// ……
return (0);
}
它没有什么特别的,都是实实在在地做 close() 该做的事情。
所以只要自己创建一个文件:kernel/who.c,然后实现两个函数就万事大吉了。
注意:
指针参数传递的是应用程序所在地址空间的逻辑地址,在内核中如果直接访问这个地址,访问到的是内核空间中的数据,不会是用户空间的。所以这里还需要一点儿特殊工作,才能在内核中从用户空间得到数据。为此,我们需要的就是include/asm/segment.h中的get_fs_byte()和put_fs_byte()函数
get_fs_byte()函数定义:

put_fs_byte()函数定义:

who.c文件:
#include <string.h>
#include <errno.h>
#include <asm/segment.h>
char msg[24];
int sys_iam(const char * name)
{
char tep[26];
int i = 0;
for(; i < 26; i++)
{
tep[i] = get_fs_byte(name+i);//get_fs_byte用于在内核空间中获取用户空间的数据
if(tep[i] == '\0') break;
}
if (i > 23) return -(EINVAL);
strcpy(msg, tep);
return i;
}
int sys_whoami(char * name, unsigned int size)
{
int len = 0;
for (;msg[len] != '\0'; len++);
if (len > size)
{
return -(EINVAL);
}
int i = 0;
for(i = 0; i < size; i++)
{
put_fs_byte(msg[i], name+i);//put_fs_byte用于把数据由核心态复制到用户态内存空间
if(msg[i] == '\0') break;
}
return i;
}
修改Makefile
要想让我们添加的 kernel/who.c 可以和其它 Linux 代码编译链接到一起,必须要修改 Makefile 文件。
Makefile 里记录的是所有源程序文件的编译、链接规则,《注释》3.6 节有简略介绍。我们之所以简单地运行 make 就可以编译整个代码树,是因为 make 完全按照 Makefile 里的指示工作。
Makefile 在代码树中有很多,分别负责不同模块的编译工作。我们要修改的是 kernel/Makefile。需要修改两处。
(1)第一处
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
改为
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
即添加who.o
(2)第二处
### 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
改为
### Dependencies:
who.s who.o: who.c ../include/linux/kernel.h ../include/unistd.h
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
添加了 who.s who.o: who.c ../include/linux/kernel.h ../include/unistd.h。
Makefile 修改后,和往常一样 make all 就能自动把 who.c 加入到内核中了。
在linux-0.11目录下make all,who.c会被加入内核
新增iam.c 跟whoami.c文件以测试是否添加系统调用成功:
注意这两个文件是要在linux 0.11版本上编译的,所以我们应当先通过运行mount-hdc文件来把虚拟机的硬盘挂载在oslab/hdc 目录下,然后进入hdc/user/root目录中(这个目录就是虚拟机一开机的所在的目录)再创建iam.c和whoami.c。
内核源代码的unistd.h文件中定义了宏函数_syscalln(),其中n代表携带的参数个数,该宏函数展开时会通过int0x80进入内核并找到对应编号的系统调用。若我们要在用户程序中直接执行对应的系统调用,那么该系统调用宏的形式如下(具体参考linux-0.11内核完全注释的5.5章节或课程实验提示):
#define LIBRARY
#include <unistd.h>
_syscalln( …)
因此在iam.c和whoami.c文件代码中需要包含这三行代码,然后我们就可以在main函数中直接使用系统调用了。
main函数的两个参数是argc和argv[],其中argc的值是在命令行运行程序时给的参数的个数;argv是一个指针数组,argv[1]是在命令行执行程序时传递给它的第一个参数的地址, argv[2] 是在命令行执行程序时传递给它的第二个参数的地址…因此在iam.c文件中的main里直接使用系统调用函数iam,参数即为argv[1]。
iam.c:
#define __LIBRARY__
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
_syscall1(int,iam,const char*,name)
int main(int argc,char* argv[])
{
iam(argv[1]);
return 0;
}
whoami.c:
#define __LIBRARY__
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
_syscall2(int, whoami, char*, name, unsigned int, size);
int main(int argc, char ** argv)
{
char t[30];
whoami(t, 30);
printf("%s\n", t);
return 0;
}
切换到oslab目录,
./run
运行虚拟机
在Bochs窗口命令行下编译iam.c和whoami.c文件
gcc -o iam iam.c
gcc -o whoami whoami.c
运行测试:在虚拟机中通过iam调用将字符由用户态传入内核,然后通过whoami将传入内核的名字打印出来
./iam liudong
whoami
运行结果:


浙公网安备 33010602011771号