哈工大操作系统课程实验记录

哈工大操作系统课程实验记录

0-课程准备

课程视频地址:
https://www.bilibili.com/video/BV1d4411v7u7

实验楼地址:
https://www.shiyanlou.com/courses/115

缓存下来的离线课程(更新中):
链接: https://pan.baidu.com/s/1OoVD3MLO2hBCRX3sMMXunA
密码: 6rlq

摘录下来的实验指导(更新中):
链接: https://pan.baidu.com/s/139wBQV4L73lzHlrZmUmdhQ
密码: kbaq

我的实验环境:Ubuntu18.04

关于如何在本机上搭建实验环境:https://github.com/DeathKing/hit-oslab

关于实验参考代码和实验报告:https://github.com/haohuaijin/hit-linux-0.11-lab
如果访问不到的话,在分享的百度网盘链接里有下载好的zip包,对着README用就好了

下面是我的实验记录,主要是一些避坑指南,如果哪里有写的不对的地方请指正,感激不尽(正文开始):

1-熟悉实验环境

整个实验环境搭建过程在hit-oslab-master.zip文件中;

在linux0.11进行make后生成的Image文件就是内核镜像文件;

run脚本运行Bochs启动linux0.11,记得在你的终端中输入c才能启动linux0.11

运行后 bochs 会自动在它的虚拟软驱 A 和虚拟硬盘上各挂载一个镜像文件,软驱上挂载的是linux0.11内核linux-0.11/Image(Bochs配置的从软驱启动),硬盘上挂载的是linux0.11的文件系统hdc-0.11.img

hdc-0.11.img 文件的格式是 Minix 文件系统的镜像,是linux0.11的虚拟文件系统,你在Ubuntu上可以挂载这个文件系统与linux0.11进行文件互传;

#挂载
sudo ./mount-hdc
#卸载
sudo umount hdc

注意 1:不要在 0.11 内核运行的时候 mount 镜像文件,否则可能会损坏文件系统。同理,也不要在已经 mount 的时候运行 0.11 内核。

注意 2:在关闭 Bochs 之前,需要先在 0.11 的命令行运行 “sync”,确保所有缓存数据都存盘后,再关闭 Bochs。

2-操作系统的引导

这里还没关os内核什么事,主要是在引导程序上做文章

首先在bootsect中显示个性化字符串,这个比较简单,照着给的示例敲了一遍:

SETUPLEN=2
SETUPSEG=0x07e0

entry _start
_start:
	!寄存器置参数,bios系统调用获取光标位置
    mov ah,#0x03
    xor bh,bh
    int 0x10
    !字符串信息参数
    mov cx,#18
    mov bx,#0x0007
    mov bp,#msg1
    !显示
    mov ax,#0x07c0
    mov es,ax
    mov ax,#0x1301
    int 0x10
    
load_setup:
    mov dx,#0x0000
    mov cx,#0x0002
    mov bx,#0x0200
    !读磁盘的0磁道2扇区,是setup程序
    mov ax,#0x0200+SETUPLEN
    int 0x13
    
    jnc ok_load_setup
    mov dx,#0x0000
    mov ax,#0x0000
    int 0x13
    jmp load_setup
    
    !暂时阻塞在这
ok_load_setup:
    jmpi    0,SETUPSEG
    
    !数据
msg1:
    .byte   13,10
    .ascii  "Sunix by syc"
    .byte   13,10,13,10
    
    !引导扇区标志
.org 510
boot_flag:
    .word   0xAA55

编译和运行:

$ as86 -0 -a -o bootsect.o bootsect.s
$ ld86 -0 -s -o bootsect bootsect.o

如果出现读入错误,用vim重新打开文件,再保存,关闭,这是vscode编码导致的问题。

用dd工具生成镜像文件(跳过了minix文件格式的32B的文件头):

$ dd bs=1 if=bootsect of=Image skip=32

让bootsect从磁盘的0磁道2扇区读入setup(setup.s暂时照抄bootsect.s,把显示的字符串改一改就行):

load_setup:
! 设置驱动器和磁头(drive 0, head 0): 软盘 0 磁头
    mov dx,#0x0000
! 设置扇区号和磁道(sector 2, track 0): 0 磁头、0 磁道、2 扇区
    mov cx,#0x0002
! 设置读入的内存地址:BOOTSEG+address = 512,偏移512字节
    mov bx,#0x0200
! 设置读入的扇区个数(service 2, nr of sectors),
! SETUPLEN是读入的扇区个数,Linux 0.11 设置的是 4,
! 我们不需要那么多,我们设置为 2(因此还需要添加变量 SETUPLEN=2)
    mov ax,#0x0200+SETUPLEN
! 应用 0x13 号 BIOS 中断读入 2 个 setup.s扇区
    int 0x13
! 读入成功,跳转到 ok_load_setup: ok - continue
    jnc ok_load_setup
! 软驱、软盘有问题才会执行到这里。我们的镜像文件比它们可靠多了
    mov dx,#0x0000
! 否则复位软驱 reset the diskette
    mov ax,#0x0000
    int 0x13
! 重新循环,再次尝试读取
    jmp load_setup
ok_load_setup:
! 接下来要干什么?当然是跳到 setup 执行。
! 要注意:我们没有将 bootsect 移到 0x9000,因此跳转后的段地址应该是 0x7ce0
! 即我们要设置 SETUPSEG=0x07e0

现在需要将bootsect.s和setup.s联编,用linus给的tools/build.c工具进行make,用的时候把后面一部分内容注掉,参见实验指导;

获取硬件参数:

把光标位置,内存大小,磁盘容量等硬件参数写到0x9000处,后面配合显示程序进行显示。此处主要用一些int中断来获取硬件参数。

mov    ax,#INITSEG
! 设置 ds = 0x9000
mov    ds,ax
mov    ah,#0x03
! 读入光标位置
xor    bh,bh
! 调用 0x10 中断
int    0x10
! 将光标位置写入 0x90000.
mov    [0],dx

! 读入内存大小位置
mov    ah,#0x88
int    0x15
mov    [2],ax

! 从 0x41 处拷贝 16 个字节(磁盘参数表)
mov    ax,#0x0000
mov    ds,ax
lds    si,[4*0x41]
mov    ax,#INITSEG
mov    es,ax
mov    di,#0x0004
mov    cx,#0x10
! 重复16次
rep
movsb

最后将得到的硬件参数信息进行打印显示,自己要写一些显示程序,挺恶心的,直接复制粘贴源码了,不赘述。

所以,总结来说,这一节的工作就是:

  1. bootsect进行信息输出;
  2. 将setup从0磁道2扇区读入;
  3. setup获取硬件参数;
  4. 将得到的硬件参数显示出来;

说起来也不复杂,但是汇编嘛,你懂得,粒度太细,一点点写不对就完蛋,还不能调试,也挺难的,希望后面C代码能舒服一些。

3-系统调用

这一节主要是通过实现自定义系统调用深刻理解systemcall是怎么实现的,至少能理解个大概。
此次实验的基本内容是:在 Linux 0.11 上添加两个系统调用,并编写两个简单的应用程序测试它们。

(1)iam()

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

int iam(const char * name);

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

(2)whoami()

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

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

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

(4)测试:

运行添加过新系统调用的 Linux 0.11,在其环境下编写两个测试程序 iam.c 和 whoami.c。最终的运行结果是:

$ ./iam lizhijun

$ ./whoami

lizhijun

可见,我们的主要工作就是在内核区和用户区借助指针和数组空间相互传递字符串。另外,上面的函数prototype只是一个示意,并没有真正实现它。
先看系统调用过程:
操作系统实现系统调用的基本过程(在 MOOC 课程中已经给出了详细的讲解)是:

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

关键点:系统调用号,中断,内核函数(地址:函数指针),中断返回
在unistd.h中定义了几个宏函数:_syscall0, _syscall1, _syscall2…他们实现是所有系统调用的关键,以close为例:
close的原型定义在lib/close.c中:

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

其中 _syscall1 是一个宏,在 include/unistd.h 中定义(里面还定义了0个,2个,3个参数的宏函数)

#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; \
}

_syscall1(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;
}

可见,close就是只有一个参数的系统调用_syscall1(type,name,atype,name)的宏展开,对照上面的宏展开结果的内容,close的过程就是将__NR_close参数压入eax,这是close的系统调用号的宏,unistd.h中定义了所有的系统调用号的宏;fd是文件描述符,是sys_close()的要用参数,压入ebx寄存器;然后int0x80中断进入内核;最终的返回结果存入eax寄存器,然后再从eax取到_res,视其情况返回并置全局变量errno。

ok,现在我们要着手改动内核了哟,兴不兴奋?
参考close的实现,对于我们要添加的系统调用,也需要在unistd.h中添加对应的宏,宏值顺延,别瞎搞:
在这里插入图片描述
注意上面的改动实在linux0.11源码中改的,这是编译内核用的代码。但是启动linux0.11后,你需要将bochs虚拟机里面的unistd.h也做相同改动,因为在bochs虚拟机里编程用的是他自己的unistd.h,就像你再你本机上编程要#include <unistd.h>一样。

调用中断后,我们要着手编写中断处理程序(内核代码),那么首先我们要清楚怎么跳到中断处理函数:
系统初始化后,对于80号中断:

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
system_call的源码是汇编程序,我们需要把nr_system_calls的值修改为74,因为我们增加了两个系统调用:
在这里插入图片描述
其余的代码忽略,剩下里面最核心的一句就是:

    call sys_call_table(,%eax,4)

根据汇编寻址方法它实际上是:call sys_call_table + 4 * %eax,其中 eax 中放的是系统调用号,即 __NR_xxxxxx,因为函数指针大小为32bit,所以将他乘以4

显然,sys_call_table 一定是一个函数指针数组的起始地址,它定义在 include/linux/sys.h 中:

fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,...

所以我们在这个函数指针数组后面加上两个函数指针,同时在前面添加上函数声明:
在这里插入图片描述
凡是以sys打头的都是内核函数,然后我们去kernel/who.c中实现这两个函数就好了,如果你会写的话现在就可以着手编写这两个函数了。

为了编译,我们还需要修改kernel/makefile
在这里插入图片描述
在目标中添加who.o,在依赖中添加who的依赖规则。
调试程序的话可以用printk函数,就是运行在kernel空间的printf函数。

下面贴出sys_iam和sys_whoami的实现源码,内含剧透,需要请回避:

#include <asm/segment.h>
#include <errno.h>
#include <string.h>
char myname[24];
int sys_iam(const char *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(myname, str);
    return i-1;
}

int sys_whoami(char *name, unsigned int size){
    int length = strlen(myname);
    if (size < length){
        return -(EINVAL);
    }
    int i = 0;
    for (i = 0; i < length; i++)
    {
        // copy from kernel mode to user mode
        put_fs_byte(myname[i], name + i);
    }
    return length;
}

上面的代码是写在kernel/who.c中的内核代码,里面的get_fs_byte和put_fs_byte是linus给的用于内核区和用户区数据拷贝的函数。
编译,启动bochs虚拟机,在虚拟机中编写如下测试程序:

#define __LIBRARY__
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
//iam()在用户空间的接口函数
// 这是定义在unistd.h里面的一个宏,展开后是一个包含int 0x80中断的代码。
_syscall1(int, iam, const char*, name);
int main(int argc, char* argv[]) {
    iam(argv[1]);
    return 0;
}

在虚拟机中编译,运行就行了,注意宏定义写在第一行
另外还有一个whoami的测试程序,和其他测试程序和脚本,不赘述。
最后就能实现实验要求的效果了。

回顾一下我们对内核做了什么改动:
1-我们在kernel/who.c中实现了两个内核函数,他们能将用户区和内核区的指定字符串相互传递。
2-而这两个内核函数暴露给用户态的接口是一(两)个宏函数_syscall1(int, iam, const char*, name);,借助它,我们得以传递参数,引发中断,进入内核。
3-进入内核后,中断服务子程序的入口地址借助我们定义的系统调用号(由eax寄存器传递)在函数指针的数组fn_ptr sys_call_table[]中找出,然后就去执行内核函数代码了。
4-从内核返回后,由eax传递返回值给_res,然后中断返回。

这大概就是实验3的全部内容了,这其中还涉及一些理论支撑:比如内存的保护方法(CPL和DPL的关系)。

4-进程运行轨迹的跟踪与统计

怎么说呢,这一节没有上一节来的刺激,本节的核心任务是在linux0.11涉及进程调度的内核程序中找到所有发生进程状态切换的代码点,并在这些点插入函数fprintk()(内核的fprintf()函数),来输出进程状态变化的情况到 log 文件中,最后用给定的py程序分析进程的运行轨迹,进而能得到进程的周转时间,等待时间,CPU时间,IO时间等信息。

《注释》那本书我没读多少,所以对内核代码中进程切换点的把握还不是很好,不过跟着答案做一遍还是有一些收获的。
在这里插入图片描述
首先需要编写一个多进程并发的程序,会用fork()就没啥问题:

基于模板 process.c 编写多进程的样本程序,实现如下功能:所有子进程都并行运行,每个子进程的实际运行时间一般不超过 30 秒;父进程向标准输出打印所有子进程的 id,并在所有子进程都退出后才退出;

主要的源码如下:

void cpuio_bound(int last, int cpu_time, int io_time);
#define CHILD_PROCESS_NUM 4

int main(void) {
	pid_t pid;
	int i = 0;

	while (i < CHILD_PROCESS_NUM)	{
		if ((pid = fork()) < 0)		{
			fprintf(stderr, "Error in fork()\n");
			return -1;
		}else if (pid == 0)  {
			/* 子进程执行指定时间后退出 */
			cpuio_bound(CHILD_RUN_TIME, 1, 1);
			exit(0);
		}	else	{
			fprintf(stdout, "Process %lu created.\n", (long)(pid));
			++i;
		}
	}

	/* 父进程中一直要等待所有的子进程退出 */
	while ((pid = wait(NULL)) != -1)	{
		fprintf(stdout, "Process %lu terminated.\n", (long)(pid));
	}	
	return 0;
}

其中cpuio_bound函数是已经实现的一个可以调节CPU时间和IO时间比例的函数。
在main中循环创建4个子进程,执行指定时间后退出,父进程打印提示信息,并阻塞等待所有子进程都被wait回收后退出。代码参考:https://github.com/iLoveTangY/hit-oslab

把上面的那个程序在linux0.11下编译,执行,应该能正常执行,当然在本机上应该是能跑通的。

接下来我们需要记录linux0.11的进程切换情况:

  • Linux0.11 上实现进程运行轨迹的跟踪。基本任务是在内核中维护一个日志文件 /var/process.log,把从操作系统启动到系统关机过程中所有进程的运行轨迹都记录在这一 log 文件中。
  • 在修改过的 0.11 上运行样本程序,通过分析 log 文件,统计该程序建立的所有进程的等待时间、完成时间(周转时间)和运行时间,然后计算平均等待时间,平均完成时间和吞吐量。可以自己编写统计程序,也可以使用python 脚本程序—— stat_log.py(在 /home/teacher/ 目录下) ——进行统计。

/var/process.log` 文件的格式必须为:

pid    X    time

其中:

  • pid 是进程的 ID;
  • X 可以是 N、J、R、W 和 E 中的任意一个,分别表示进程新建(N)、进入就绪态(J)、进入运行态®、进入阻塞态(W) 和退出(E);
  • time 表示 X 发生的时间。这个时间不是物理时间,而是系统的滴答时间(tick)

linux0.11机器上的8253定时器每隔10ms产生一次中断,产生一次系统滴答,在kernel/schhed.c中定义:long volatile jiffies=0;,所以上面的第三列time就是全局变量jiffies的值。

首先我们让系统刚一开始就打开var/process.log文件,并将其dup到文件描述符3,这样我们每次向文件描述符3中fprintk格式化字符串就可以了:

操作系统启动后先要打开 /var/process.log,然后在每个进程发生状态切换的时候向 log 文件内写入一条记录,其过程和用户态的应用程序没什么两样。然而,因为内核状态的存在,使过程中的很多细节变得完全不一样。

为了能尽早开始记录,应当在内核启动时就打开 log 文件。内核的入口是 init/main.c 中的 main()(Windows 环境下是 start()),其中一段代码是:

//……
move_to_user_mode();
if (!fork()) {        /* we count on this going ok */
    init();
}
//……

这段代码在进程 0 中运行,先切换到用户模式,然后全系统第一次调用 fork() 建立进程 1。进程 1 调用 init()

在 init()中:

// ……
//加载文件系统
setup((void *) &drive_info);

// 打开/dev/tty0,建立文件描述符0和/dev/tty0的关联
(void) open("/dev/tty0",O_RDWR,0);

// 让文件描述符1也和/dev/tty0关联
(void) dup(0);

// 让文件描述符2也和/dev/tty0关联
(void) dup(0);

// ……

这段代码建立了文件描述符 0、1 和 2,它们分别就是 stdin、stdout 和 stderr。这三者的值是系统标准,不可改变。

可以把 log 文件的描述符关联到 3。文件系统初始化,描述符 0、1 和 2 关联之后,才能打开 log 文件,开始记录进程的运行轨迹。

为了能尽早访问 log 文件,我们要让上述工作在进程 0 中就完成。所以把这一段代码从 init() 移动到 main() 中,放在 move_to_user_mode() 之后(不能再靠前了),同时加上打开 log 文件的代码。

修改后的 main() 如下:

在这里插入图片描述

这样,文件描述符 0、1、2 和 3 就在进程 0 中建立了。根据 fork() 的原理,进程 1 会继承这些文件描述符,所以 init() 中就不必再 open() 它们。此后所有新建的进程都是进程 1 的子孙,也会继承它们。但实际上,init() 的后续代码和 /bin/sh 都会重新初始化它们。所以只有进程 0 和进程 1 的文件描述符肯定关联着 log 文件,这一点在接下来的写 log 中很重要。

为了能在内核态使用fprintf,实验指导提供了实现好了fprintk函数,具体参见实验指导。

然后就是实验的核心内容了(好多地方理解的还很模糊,有时间研读源码吧):
必须找到所有发生进程状态切换的代码点,并在这些点添加fprintk函数,来输出进程状态变化的情况到 log 文件中。
此处要面对的情况比较复杂,需要对 kernel 下的 fork.csched.c 有通盘的了解,而 exit.c 也会涉及到。

首先是在fork.ccopy_process函数中:
在这里插入图片描述
这里进程被创建好后,发生了进程状态切换:新建态和就绪态。

接着是在sched.c中,见名知义,这是内核中有关任务调度函数的程序,所以这里面进程状态切换一定少不了:
(1)切换到就绪态:
在这里插入图片描述
(2)需要判别是否真的发生了任务的切换:
在这里插入图片描述
否则可能还在运行原来的进程;

(3)当进程主动sleep_on阻塞自己时,当前进程状态为W,然后选择一个就绪进程:
在这里插入图片描述在这里插入图片描述
(4)当进程被唤醒时,回到就绪态:
在这里插入图片描述
最后exit.c中就是当进程退出时状态切换:
(1)当进程退出时,当然是E:
在这里插入图片描述
(2)以及最后,这个我熟,父进程调用waitpid后自己阻塞,进入W态:
在这里插入图片描述

上面就是需要在内核中插入的代码,涉及到文件有 init/main.ckernel/fork.ckernel/sched.c。然后重新make内核,在linux0.11中编译执行process,然后把生成的process.log拷贝到本机,用py程序分析即可,后面的修改时间片没有做,具体可以参考实验指导。

  • 9
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值