哈工大操作系统实验3 系统调用(System Call)
该篇文章是哈工大操作系统实验3的完成笔记,其中包含了详细的步骤和相关代码,并有截图说明。实验内容我都成功通过了,但是因为内容较多,记录中难免会有疏忽,如有发现错误,欢迎大家留言和我联系。
实验内容
该实验的内容其实就是编写两个系统调用:
- 第一个系统调用来记录我是谁,接收一个name参数,关键词 iam。
- 第二个系统调用来显示第一个系统调用记录的name。关键词 whoami。
最后运行的结果如下图:
这两个系统调用功能非常简单,实验的目的主要是要了解系统调用的原理。
系统调用原理
用户程序调用系统内核库函数称为系统调用。关于系统调用原理,实验内容里解释的很详细,我这里针对其中的基本过程补充了一些说明和相关的文件。
基本过程
系统调用的基本过程:
- 应用程序调用库函数(API);
- API 将系统调用号存入 EAX,然后通过中断调用使系统进入内核态;
- 内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
- 系统调用完成相应功能,将返回值存入 EAX,返回到中断处理函数;
- 中断处理函数返回到 API 中;
- API 将 EAX 返回给应用程序。
这个基本过程咋一看难以理解,我顺着源码查看,画了一流程图方便理解。
一些说明:
- 定义80中断处理函数:这个是在系统运行起来就处理好了,详细流程可以参考:main.c -> kernel/sched.c中的sched_init函数 -> include/asm/system.h中的set_system_gate函数,将0x80号中断的处理例程设置为 kernel/system_call.s 的 system_call例程。
- include/unistd.h:该文件中定义了内核系统调用号和系统调用嵌入式汇编宏函数,参考《Linux内核完全注释》第11.14章节。
- kernel/system_call.s:该文件实现系统调用(system_call)中断 int 0x80 的入口处理过程,参考《Linux内核完全注释》第5.6章节。
- include/linux/sys.h:该文件列出了内核中所有系统调用函数的原型,以及系统调用函数指针表,参考《Linux内核完全注释》第11.30章节。
- kernel目录:该目录放置内核进程调度、信号处理、系统调用等程序,参考《Linux内核完全注释》第2.8章节和第5章。最终要编写的两个系统调用实现就是放在这个目录。
开发过程
知道基本过程后,那么实验的过程也就容易理解了。大体的过程分为两步:
- 内核实现:需要增加iam、whoami两个函数,并修改相关的地方;
- 用户程序测试:内核编写好后,编写用户程序 iam.c 和 whoami.c 进行测试。
当然在实验前我们要准备好相关源码,实验后进行测试评分、编写实验报告等。
实验前准备
恢复Linux0.11源码,这步非常简单,参照实验内容做即可。
# 删除原来的文件
$ cd ~/oslab
$ sudo rm -rf ./*
# 重新拷贝
$ cp -r /home/teacher/oslab/* ./
内核实现
既然用户程序要调用内核库函数,那么内核就需要先定义并实现这些库函数,涉及的相关地方也需要调整。
- include/unistd.h 增加两个系统调用编号72和73,从71接着开始。
// include/unistd.h 文件
#define __NR_setreuid 70
#define __NR_setregid 71
#define __NR_iam 72 // iam 系统调用号
#define __NR_whoami 73 // whoami 系统调用号
include/unistd.h 这个文件内核程序有用,用户程序也有用,只是本案中,因为内核程序并没有使用到这两个系统调用,所以这里不更改也行。
关于内核程序也有使用系统调用的理解,可以参考实验的说明6.1章节。内容说明如下:linux-0.11 的 lib 目录下有一些已经实现的 API。Linus 编写它们的原因是在内核加载完毕后,会切换到用户模式下,做一些初始化工作,然后启动 shell。而用户模式下的很多工作需要依赖一些系统调用才能完成,因此在内核中实现了这些系统调用的 API。
具体的文件可以参考 init/main.c ,也引用了 include/unistd.h 这个文件。
- 修改系统调用总数。
在 kernel/system_call.s 中,_system_call例程是0x80的入口处理过程,在_system_call中,会先检测调用号是否超出系统调用总数(nr_system_calls),所以需要修改这个系统调用总数。
# kernel/system_call.s 文件中
# offsets within sigaction
sa_handler = 0
sa_mask = 4
sa_flags = 8
sa_restorer = 12
nr_system_calls = 74 # 系统调用总数,原先为72,增加2个就是74。
_system_call例程中后面会通过 _sys_call_table 表格定位到具体的内核库函数进行后续的处理,接下来就要修改这个表。
# kernel/system_call.s 文件中
_system_call:
......
call _sys_call_table(,%eax,4) # 这句操作数的含义是:调用地址 = _sys_call_table + %eax * 4。对应的 sys_call_table 在 include/linux/sys.h 中,其中定义了一个包括 72 个系统调用 C 处理函数的地址数组表。
......
- 修改系统调用表(sys_call_table)。
sys_call_table 是一个函数指针数组的起始地址,它定义在 include/linux/sys.h 中。增加实验要求的系统调用,需要在这个函数表中增加两个函数引用: sys_iam 和 sys_whoami。当然该函数在 sys_call_table 数组中的位置必须和 __NR_xxxxxx 的值对应上。
同时还要仿照此文件中前面各个系统调用的写法,加上 extern int sys_iam(); 以及 extern int sys_whoami();
- 实现内核函数。
sys_call_table只是定义了内核库函数的索引表格,现在就要实现具体的库函数。
在 linux 0.11的设计中,库函数放在 kernel目录。在 kernel 目录增加 who.c 文件,编写 sys_iam 和 sys_whoami 两个接口。
#define __LIBRARY__ // 定义一个符号常量,见下行说明。
#include <unistd.h> // Linux 标准头文件。定义了各种符号常数和类型,并申明了各种函数。如果定义了__LIBRARY__,则还含系统调用号和内嵌汇编 syscall0()等。
#include <errno.h> // 错误号头文件。包含系统中各种出错号。(Linus 从 minix 中引进的)。包含了 EINVAL 错误码的定义。
#include <string.h> // 字符串头文件。主要定义了一些有关字符串操作的嵌入函数。其中包含了 strlen、strcpy 函数。
#include <asm/segment.h> // 段操作头文件。定义了有关段寄存器操作的嵌入式汇编函数。其中包含了 get_fs_byte 和 put_fs_byte 函数
char buffer[24]; // 声明一个缓冲区,用来保存用户程序输入的name,23个字符 +'\0' = 24
/*
* 将name的内容拷贝到buffer,name的长度不超过23个字符。
* 返回:拷贝的字符数。如果name的字符个数超过了23,则返回-1,并置errno为EINVAL。
*/
int sys_iam(const char *name)
{
/*
一开始我想直接用strlen计算name的长度,用strcpy复制name到buffer中。
但是不能这样操作,因为name的位置在用户程序段,用户程序段需要用fs进行操作。
strlen和strcpy都是默认用内核数据段ds和es进行操作。
*/
// int len = strlen(name);
// strcpy(buffer, name);
/*
直接打印name也是不行的,因为printk只能打印内核数据段内的数据,而name的位置是在用户程序段。
*/
// printk("name=%s\n", name);
/* 下面将name复制到tmp中 */
int i; // i用作字符串索引
char tmp[25]; // 临时存储输入字符串,操作失败时不影响buffer,比buffer的长度多1,即可校验长度是否超出。
for (i = 0; i < 25; i++)
{
tmp[i] = get_fs_byte(name + i); // 从用户程序内存取得数据,fs指向用户程序段,name是在用户程序段中的起始偏移位置
if (tmp[i] == '\0') // 字符串结束
break;
}
printk("tmp=%s\n", tmp); // 调试打印tmp内容
/* 判断输入的长度是否超过23 */
int len = strlen(tmp); // tmp位于内核中,所以可以直接用strlen计算长度。
if (len > 23) // 字符长度大于23个
{
printk("The length of name is greater than 23!\n");
return -EINVAL; // 置errno为EINVAL,返回“-1”。具体实现见include/unistd.h中的_syscalln宏。
}
/* 将tmp复制到buffer中 */
strcpy(buffer, tmp);
printk("buffer=%s\n", buffer); // 调试打印buffer内容。不知道为什么这里打印不出来,但实际上buffer是有内容的。
/* 返回长度 */
return len;
}
/*
* 将buffer拷贝到name指向的用户地址空间中,确保不会对name越界访存(name的大小由size说明)
* 返回:拷贝的字符数。如果size小于需要的空间,则返回“-1”,并置errno为EINVAL。
*/
int sys_whoami(char *name, unsigned int size)
{
/* 先校验buffer长度是否超过size,size为name的长度 */
int len = strlen(buffer);
if (len > size)
{
return -EINVAL; // 置errno为EINVAL,返回“-1”。具体实现见include/unistd.h中的_syscalln宏。
}
/* 把 buffer 输出至 name */
int i;
for (i = 0; i < size; i++)
{
put_fs_byte(buffer[i], name + i); // 将buffer逐个字节写入到用户程序段内name起始的位置。fs指向用户程序段,name是在用户程序段中的起始偏移位置
if (buffer[i] == '\0') // 字符串结束
break;
}
return len;
}
- 修改kernal/Makefile文件。
上面增加了who.c的文件,在Makefile文件中就要增加这个文件的编译规则,这样在make all的时候就会自动编译链接这个文件了。修改参考下图:
- 重新编译内核并运行。
现在重新编译内核,进入 linux-0.11 目录:
# 当前的工作路径为 /home/shiyanlou/oslab/linux-0.11/
# 编译内核
$ make all
# 执行 oslab 目录中的 run 脚本
$ ../run
如果出现错误啥的,那么说明我们的修改生效了,根据错误提示进行排查即可。如果一切正常,那么就OK了。
用户程序测试
内核好了,那么就要编写用户程序测试一下新编写的系统调用。
因为linux0.11启动后,进入shell界面,只有vi编辑器,加上早年的编辑器功能比较少,如果在shell环境下进行编写非常麻烦。推荐使用挂载Linux0.11文件系统的方法,在宿主机(Ubuntu)上编辑文件就方便多了。
如果感觉Ubuntu编写还不太方便,蓝桥的Ubuntu系统可以上传本地编写好的代码文件,上传统一放在 /home/shiyanlou/Code 这个文件里。
挂载Linux0.11文件系统的方式:可以参考实验1的文件交换章节,我把相关命令列在这里,方便使用。
# 当前的工作路径为 /home/shiyanlou/oslab/
# 启动挂载脚本
$ sudo ./mount-hdc
# 之后即可在 hdc 目录查看到Linux0.11文件
# 卸载
$ sudo umount hdc
- /usr/include/unistd.h 增加两个系统调用编号72和73,从71接着开始。
// include/unistd.h 文件
#define __NR_setreuid 70
#define __NR_setregid 71
#define __NR_iam 72 // iam 系统调用号
#define __NR_whoami 73 // whoami 系统调用号
用户测试程序会引用这个文件,发起iam和whoami系统调用。
- 编写iam.c 和 whoami.c 两个用户程序进行测试。
- iam.c 用来测试 iam 系统调用。
- whoami.c 用来测试 whoami 系统调用。
iam.c源码:
/* 用户程序测试iam系统调用 */
/* Linux0.11的用户端C语言程序,不能用 "//" 进行注释,会报如下错误:parse error before "/" */
#define __LIBRARY__ /* 定义一个符号常量,见下行说明。unistd.h文件中会用到。 */
#include <stdio.h> /* 使用其中的 printf 打印字符串。 */
#include <unistd.h> /* Linux 标准头文件。定义了各种符号常数和类型,并申明了各种函数。如果定义了__LIBRARY__,则还含系统调用号和内嵌汇编 syscall0()等。 */
/* _syscall1宏展开后是一个调用系统函数的函数,接收1个参数。参考 include/unistd.h 文件 */
_syscall1(int, iam, const char *, name);
int main(int argc, char **argv) /* argc表示参数个数。argv参数数组。如执行:./iam cjb,那么 argc=2, argv[0]=./iam、argv[1]=cjb */
{
int result = 0;
/* 判断参数个数,如果小于1,表示没有输入名称,则提示错误 */
if (argc < 1)
{
printf("Not enough arguments!\n");
return -1;
}
/* 通过系统调用告知内核我是谁,即第2个参数的内容。 */
result = iam(argv[1]); /* result表示argv[1]的长度 */
printf("The result is %d\n", result);
return 0;
}
whoami.c文件:
#define __LIBRARY__ /* 定义一个符号常量,见下行说明。unistd.h文件中会用到。 */
#include <unistd.h> /* Linux 标准头文件。定义了各种符号常数和类型,并申明了各种函数。如果定义了__LIBRARY__,则还含系统调用号和内嵌汇编 syscall0()等。 */
#include <stdio.h> /* 使用其中的 printf 打印字符串。 */
/* _syscall2是宏,宏展开后是一个调用系统函数的函数,接收2个参数。参考 include/unistd.h 文件 */
_syscall2(int, whoami, char *, name, unsigned int, size);
int main()
{
int len = 0;
char s[30]; /* 声明一个字符串,接收whoami返回的字符内容 */
len = whoami(s, 30); /* 调用了_syscall2定义的whoami函数 */
printf("Name is %s, the length of name is %d\n", s, len);
return 0;
}
- 编译文件
# /usr/root 目录
$ gcc -o iam iam.c -Wall
$ gcc -o whoami whoami.c -Wall
一些说明:
- -o:表示输出可执行文件,后面接可执行文件名;
- -Wall:表示输出尽可能多的警告信息。
- 运行
# /usr/root 目录
$ ./iam QingYun
$ ./whoami
运行结果:
如果能成功看到上图输出的内容,那么系统调用就成功了。恭喜,终于又在操作系统内核上更进了一步。
运行时碰到的一个错误,记录在这里。错误信息如下:
general protection:0000
EIP :0008:00008821
EFLAGS:00010206
ESP:0004:00004000
fs: 0010
base:10000000,limit:04000000
Pid:12,process nr:4
f2 ae f7 d1 49 85 d2 78 06 39
Segmentation fault
排查情况:因为在内核库函数sys_iam里打印收到的name参数,而name参数是在用户程序段里、而对应的段指向在内核数据段,所以就报这个段错误了。
int sys_iam(const char *name)
{
...
/*
直接打印name也是不行的,因为printk只能打印内核数据段内的数据,而name的位置是在用户程序段。
*/
// printk("name=%s\n", name);
...
}
测试评分
测试脚本实验已经提供了,它的功能是测试 iam.c 和 whoami.c。将 testlab2.sh(在 /home/teacher 目录下) 拷贝到和 iam.c 和 whoiam.c 的同一个目录,并增加可执行权限,这步在宿主机(Ubuntu)上完成。
# 当前的工作路径为 /home/shiyanlou/oslab/
# 挂载文件系统
$ sudo ./mount-hdc
# 复制 testlab2.sh 到和 iam.c 和 whoiam.c 的同一个目录
$ cp /home/teacher/testlab2.sh hdc/usr/root/
# 增加可执行权限
$ chmod +x hdc/usr/root/testlab2.sh
编译运行linux0.11,执行 testlab2.sh,发现居然是0分:
后来去查看 testlab2.sh 源码,才发现原来是 testlab2.sh 是去运行 iam 和 whoami 程序,然后判断 whoami 输出进行得分,前面写的 whoami.c 输出的内容不匹配,那么修改一下就可以了。
whoami.c 只要输出一个名字即可:
#define __LIBRARY__ /* 定义一个符号常量,见下行说明。unistd.h文件中会用到。 */
#include <unistd.h> /* Linux 标准头文件。定义了各种符号常数和类型,并申明了各种函数。如果定义了__LIBRARY__,则还含系统调用号和内嵌汇编 syscall0()等。 */
#include <stdio.h> /* 使用其中的 printf 打印字符串。 */
/* _syscall2是宏,宏展开后是一个调用系统函数的函数,接收2个参数。参考 include/unistd.h 文件 */
_syscall2(int, whoami, char *, name, unsigned int, size);
int main()
{
int len = 0;
char s[30]; /* 声明一个字符串,接收whoami返回的字符内容 */
len = whoami(s, 30); /* 调用了_syscall2定义的whoami函数 */
printf("%s\n", s); /* 就是这行输出只要输出一个名字就可以了 */
return 0;
}
再次运行成功通过:
实验报告
- 从 Linux 0.11 现在的机制看,它的系统调用最多能传递几个参数?你能想出办法来扩大这个限制吗?
最大传递的参数:在Linux 0.11中,系统调用的参数主要通过寄存器来传递。从include/unistd.h可以看出,系统调用宏定义(如_syscall0、_syscall1、_syscall2、_syscall3)表明,系统调用可以无参数,或者有1个、2个、3个参数。这些参数分别通过寄存器ebx、ecx、edx来传递。因此,在Linux 0.11的当前机制下,如果不考虑系统调用编号和其他技术手段,系统调用最多能直接传递3个参数。
改进的方法:使用数据结构的方式。将需要传递的多个参数保存在一段自定义的数据结构中。将该用户态地址空间的首地址作为参数传递给系统调用。在系统调用内部,通过寄存器间接寻址的方式访问该数据结构,从而获取所有参数。
- 用文字简要描述向 Linux 0.11 添加一个系统调用 foo() 的步骤。
主要步骤如下:
- 在 include/unistd.h 定义系统调用号: #define __NR_foo 72;
- kernel/system_call.s 中最大系统调用数 + 1;
- 修改系统调用表(sys_call_table),增加系统调用 foo 的地址索引;
- 实现foo函数;
- 编译内核并运行测试。
系统调用编写完后,还需要编写一个用户程序进行测试一下。
参考资料
- 实验课本身的内容:这个翻来覆去多看几遍,结合《Linux内核完全注释》很多就理解了。
- 赵炯博士的《Linux内核完全注释》:代码的完全注释,通过阅读代码了解了很多细节问题。
完。