提示:欢迎查看本文所属专栏:xv6操作系统实验,在这里你可以通过对mit的xv6操作系统源码进行修改,真正的接触操作系统的一些底层原理是如何实现的。


⭐前言

  本章实验接着第三章实验继续进行下去,关于第三章的实验内容回顾,可以参考前面的链接。
  在xv6操作系统源码中,提供了自旋锁用于内核程序代码的并发同步,但是,对于我们自己编写的用户程序代码,并没有提供进程同步机制,这次实验我们来动手来实现一个简单的xv6操作系统的信号量机制,从而达到用户程序代码的进程同步,当然,本次实验也是建立在xv6源码的自旋锁实现上,在本次实验实现信号量机制的过程中,还可以很好的了解xv6操作系统自旋锁是怎么实现的


🔧一、创建内核全局变量并访问

  在学习进程同步算法的时候,比如银行家算法,哲学家就餐问题,读者写者问题等,都是基于信号量机制来实现的,这些信号量,都是以全局变量的形式给各个进程之间发送进程同步信号的,所以,实现信号量机制的前提是,我们需要提供一个内核全局变量来给各个用户进程访问,创建内核全局变量,并为用户进程提供系统调用访问内核全局变量的具体实现如下所示。

1.1 创建内核全局变量

  在xv6操作系统源代码spinlock.h文件末尾插入一行int sh_var_for_sem_demo; ,由于spinlock.h属于内核空间代码,所以我们定义的该变量可以被所有进程访问,同时,为了让其他代码能访问该变量,还需要在defs.h文件中添加int sh_var_for_sem_demo; ,如下两幅图所示。
在这里插入图片描述
在这里插入图片描述

1.2 访问内核全局变量

  在第本专栏第二章中,我们介绍了如何在xv6操作系统下如何添加内核全局变量,并且实现了给用户进程访问内核全局变量的系统调用,这里也是一样的,具体步骤如下:

  • syscall.h末尾插入两行#define SYS_sh_var_read 22; #define SYS_sh_var_write 23; ,为读写操作的系统调用进行编号。
    在这里插入图片描述

  • user.h中声明int sh_var_read(void); int sh_var_write(int); 两个用户态函数原型,并在usys.S末尾插入两行SYSCALL(sh_var_read); SYSCALL(sh_var_write); 以实现上述两个函数。
    在这里插入图片描述
    在这里插入图片描述

  • 修改系统调用跳转表,即syscall.c中的syscalls[]数组——添加两个元素[SYS_sh_var_read] sys_sh_var_read, [SYS_sh_var_write] sys_sh_var_write, ,如下图所示。
    在这里插入图片描述

  • syscall.csyscalls[]数组前面声明上述两个函数是外部函数extern int sys_sh_var_read(void); extern int sys_sh_var_write(void); ,如下图所示。
    在这里插入图片描述

  • 最后,在sysproc.c中实现int sh_var_read(void)int sh_var_write(int)
    在这里插入图片描述

1.3 编写程序无互斥并发访问内核全局变量

  定义了共享变量int sh_var_for_sem_demo; 以及访问的系统调用int sh_var_read(void)int sh_var_write(int)之后,可以在应用程序中尝试无互斥并发访问共享变量,我们编写C程序sh_rw_nolock.c来进行验证。
在这里插入图片描述

sh_rw_nolock.c源代码如下:

#include "types.h"
#include "stat.h"
#include "user.h"

int
main(int argc, char *argv[])
{
		sh_var_write(0);			//init sh_var_for_sem_demo
        int pid = fork();			//创建子进程
        int i,n;
        for(i=0;i<100000;i++)
        {
                n=sh_var_read();	//读取内核全局变量
                sh_var_write(n+1);	//对内核全局变量进行+1操作
        }
        printf(1,"sum = %d\n",sh_var_read());	//输出全局变量的值
        if(pid>0){
                wait();
        }
        exit();
}

  在xv6操作系统源码目录下执行make qemu命令,编译C程序并且在QEMU环境中输入sh_rw_nolock运行该程序,结果如下:
在这里插入图片描述
  我们编写该程序的本意是,父进程和子进程都对内核全局变量循环加10000,最后的预期结果应该是20000,但是,在无互斥并发访问的程序下,父进程和子进程的for循环中,对内核全局变量的读写是交替执行的(也就是父进程和子进程对内核全局变量的读写操作不是原子性的),这就会导致一个问题,当父进程执行sh_var_write()给内核全局变量赋新值时,子进程在这之前已经执行了sh_var_read()读取到了旧的数据,然后在旧的数据基础上进行加1写入内核全局变量,所以最后结果不是我们预期的20000,但是,在本文后续实验中,我们通过信号量机制来实现读写操作的原子性(用户进程对内核全局变量的整个读写操作是互斥的),稍微修改程序,来达到我们的预期值20000。

🔧二、定义信号量数据结构

  为了实现信号量,除了创建、撤销、P、V操作外,还需要添加新的数据结构、初始化函数、调整wakeup唤醒操作等

  为了管理信号量,本实验声明struct sem结构体,其中resource_count成员用于记录信号量中资源的数量lock内核自旋锁是为了让信号量的操作保持原子性procs[]proc_q_len分别是等待信号量的阻塞睡眠进程号数组阻塞睡眠的进程数量allocated用于表示该信号量是否已经分配使用

  整个系统内部声明一个信号量数组sems[128],也就是说用户进程申请的信号量总数不超过128个,把这些代码放到spinlock.h中,相应的数据结构和数据定义如下图所示。
在这里插入图片描述
  至此,信号量数据结构和实现信号量所需要的内核全局变量都已定义完成。

🔧三、完成信号量操作的系统调用

  为了实现信号量,我们需要增加四个系统调用,分别是sem_create ()创建信号量,其参数是信号量的初值(例如互斥量则用1做初值),返回值是信号量的编号,即内核变量sems[]数组的下标。sem_p()则是对指定编号的信号量进行P操作(减一操作、down操作),反之sem_v()则是对指定编号的信号量进行V操作(增一操作、up操作),这两个操作和操作系统理论课堂上讨论的行为一致,都会涉及到进程的睡眠或唤醒操作sem_free()是撤销一个信号量。

  • int sem_create (int n_sem):参数n_sem是初值,返回的是信号量的编号,0为出错。
  • int sem_p(int sem_id):减一操作,减为0时阻塞睡眠,记录到sem.procs[]中,返回值0表示正常,返回值1则出错。
  • int sem_v(int sem_id):增一操作,增加到0时唤醒队列中的进程,清除sems[id].procs[]对应的进程号,返回值为0表示成功,1表示出错。
  • int sem_ free (int sem_id):释放指定id的信号量。返回值为0表示成功,-1表示出错。

3.1 信号量的核心代码

  根据上述所描述信号量相关的四个系统调用,我们将信号量的核心代码实现放在xv6操作系统源码spinlock.c文件中。

3.1.1 seminit()

  xv6操作系统启动时,要调用seminit()对信号量进行初始化。seminit()完成的工作很简单,就是完成信号量数组的自旋锁的初始化。我们把该函数的代码插入到spinlock.c中,如下图所示。
在这里插入图片描述
sys_sem_init()核心实现源代码如下:

void seminit () {
	int i;
	for(i=0;i<SEM_MAX_NUM;i++){
		initlock(&(sems[i].lock), "semaphore");
		sems[i].allocated=0;
	}
	return;
}

  然后在main.cmain()中插入一行seminit(); ,为了让main.c能调用seminit(),还需要在defs.h中插入seminit()函数原型,如下图所示。
在这里插入图片描述
在这里插入图片描述

3.1.2 修改xv6操作系统源码wakeup操作

  由于xv6操作系统源码自带的wakup操作会将所有等待相同事件的进程唤醒,因此也可以重写一个新的wakeup操作函数wakup1p(),仅唤醒等待指定信号量的一个进程,从而避免“群惊”效应,我们打开xv6操作系统源码proc.c文件,另外,还需要在defs.h中声明该函数原型void wakeup1p(void*); ,如下图所示。
在这里插入图片描述
在这里插入图片描述

wakeup1p()核心实现源代码如下:

void wakeup1p(void *chan) {
    acquire(&ptable.lock);
    struct proc *p;
    for(p = ptable.proc; p < &ptable.proc[NPROC]; p++) {
        if(p->state == SLEEPING && p->chan == chan) {	//将一个指定进程从阻塞睡眠状态唤醒
            p->state = RUNNABLE;
            break;		//将一个进程设置为运行状态后,退出该循环
        }
    }
    release(&ptable.lock);
}

3.1.3 sys_sem_create()

  该函数扫描信号量数组sems[],查看里面allocated标志,发现未用的则将其allocated置1(标记为该信号量已分配状态),即可返回其编号。如果扫描一次后未发现,则返回错误代码-1。注意每次操作时需要对sems[i]进行加锁操作(保证创建信号量是一个原子操作),检查完成后进行解锁,如下图所示。
在这里插入图片描述
sys_sem_create()核心实现源代码如下:

int sys_sem_create(void) {
	int n_sem, i;
	if(argint(0, &n_sem) < 0 )
	return -1;
	for(i = 0; i < SEM_MAX_NUM; i++) {
		acquire(&sems[i].lock);
		if(sems[i].allocated == 0) {
			sems[i].allocated = 1;
			sems[i].resource_count = n_sem;
			cprintf("create %d sem\n",i);
			release(&sems[i].lock);
			return i;
		}
		release(&sems[i].lock);
	}
	return -1;
}

3.1.4 sys_sem_p()

  该函数将指定id作为下标,访问信号量数组sems[] ,获得当前信号量sems[id],然后用acquire()sems[id].lock加锁,加锁成功后对sems[id].resource_count--将资源分配给进程),如果发现sems[id].resource_count < 0,则将进程设置阻塞睡眠(因为进程需要该资源才能继续运行下去,但是对resource_count进行减1操作之后,该变量小于0,说明该资源都被分配给其他进程了,导致进程不能继续运行,这个时候,进程应该让出cpu使用权,进入阻塞睡眠状态),接着用realease()sems[id].lock解锁。否则,当对sems[id].resource_count--将资源分配给进程)后,发现sems[id].resource_count >= 0,说明资源数量足够,不需要将进程设置阻塞睡眠状态,如下图所示。
在这里插入图片描述sys_sem_p()核心实现源代码如下:

int sys_sem_p()
{       
	int id;
    if(argint(0, &id) < 0)
      return -1;
    acquire(&sems[id].lock);
    sems[id]. resource_count--;       //allocate resource
    if(sems[id].resource_count<0)     //首次进入、或被唤醒时,资源不足
      sleep(&sems[id],&sems[id].lock);//睡眠(会释放sems[id].lock才阻塞)
    release(&sems[id].lock);          //解锁(唤醒到此处时,重新持有sems[id].lock)
    return 0;                         //此时获得信号量资源
}

3.1.5 sys_sem_v()

  该函数将指定id作为下标,访问信号量数组sems[],获得当前信号量sems[id],然后用acquire()sems[id].lock加锁,加锁成功后对sems[id].resource_count++,如果发现对sems[id].resource_count进行了加1操作后sem[id].resource_count <= 0,说明存在阻塞睡眠进程(为什么是小于等于0呢?从上一个p操作可以看出来,就算资源数量小于等于0时,阻塞睡眠的进程还是会对sems[id].resource_count变量进行减1操作,这就说明了,只要v操作对sems[id].resource_count变量进行加1之前小于0,就存在阻塞睡眠进程,自然对小于0的sems[id].resource_count变量进行加1之后,就变成了小于等于0),这就需要我们通过自定义的wakeup1()函数唤醒阻塞睡眠进程,接着用release()sems[id].lock解锁。否则,当sems[id].resource_count进行了加1操作后sem[id].resource_count > 0,说明不存在阻塞睡眠进程,不需要执行唤醒阻塞睡眠进程操作,如下图。
在这里插入图片描述
sys_sem_v()核心实现源代码如下:

int sys_sem_v()
{
    int id;
    if(argint(0,&id)<0)
      return -1;
    acquire(&sems[id].lock);
    sems[id].resource_count++;         //release resource
    if(sems[id].resource_count<=0)     //有阻塞等待该资源的进程
      wakeup1p(&sems[id]);             //唤醒等待该资源的1个进程
    release(&sems[id].lock);           //释放锁
    return 0;
}

3.1.6 sys_sem_free()

  该函数将指定id作为下标,访问信号量数组sems[],获得当前信号量sems[id],然后用acquire()函数将sems[id].lock加锁,判定该信号量上没有阻塞睡眠的进程,则将sems[id].allocated标志设置0,即未使用状态,从而释放信号量,最后用release()函数将sems[id].lock解锁,如下图所示。
在这里插入图片描述
sys_sem_free()核心实现源代码如下:

int sys_sem_free(){
    int id;
    if(argint(0,&id)<0)			//非法的信号量编号
      return -1;
    acquire(&sems[id].lock);	//获得锁
    if(sems[id].allocated == 1 && sems[id].resource_count > 0){	//只有资源数量大于0时,才没有使用该资源的进程
        sems[id].allocated = 0;	//标志设置为0,表示该资源没有被分配
        cprintf("free %d sem\n", id);
    }
    release(&sems[id].lock);	//释放锁
    return 0;
}

3.2 系统调用辅助代码

  在3.2节实现了四个系统调用和两个非系统调用的核心代码之后,我们还需为编写的四个系统调用设计系统调用号、用户入口函数和系统调用跳转表等,系统调用的具体实现可以参考本专栏的第二章,步骤如下:

  • syscall.h末尾插入四行#define SYS_sem_create 24#define SYS_sem_free 25#define SYS_sem_p 26#define SYS_sem_v 27,为新添的四个系统调用进行编号,如下图所示。
    在这里插入图片描述

  • user.h中声明 int sem_create (int);int sem_free (int);int sem_p (int);int sem_v (int);四个用户态函数原型,在usys.S末尾插入四行SYSCALL(sem_create)SYSCALL(sem_free)SYSCALL(sem_p)SYSCALL(sem_v)以实现上述四个函数,如下图所示。
    在这里插入图片描述
    在这里插入图片描述

  • syscall.c中修改系统调用跳转表,即syscalls[]数组,添加四个元素[SYS_sem_create] sys_sem_create,[SYS_sem_free] sys_sem_free,[SYS_sem_p] sys_sem_p,[SYS_sem_v] sys_sem_v,
    在这里插入图片描述

  • syscall.csyscalls[]数组前面声明上述四个函数是外部函数extern int sys_sem_create (void);extern int sys_sem_free (void);extern int sys_sem_p (void);extern int sys_ sem_v(void);
    在这里插入图片描述
      至此,系统调用辅助代码编写完成,我们编写信号量相关的四个系统调用就可以在我们编写的用户程序中调用了

🔧四、编写信号量测试代码

  在第一节后续的测试代码中,我们通过无互斥并发访问内核全局变量,发现父进程和子进程对内核全局变量的赋值不是我们所预期的,这里,我们通过已经实现的信号量机制,来实现内核全局变量读写操作的原子性(用户进程互斥访问),编写sh_rw_lock.c程序,如下图所示。
在这里插入图片描述
sh_rw_lock.c程序源代码如下:

#include "types.h"
#include "stat.h"
#include "user.h"

int main(){
        sh_var_write(0);		//给内核全局变量赋初值
        int id=sem_create(1);	//创建信号量,参数为1表示该资源数量为1,同一时刻只能有一个进程互斥访问
        int pid = fork();		//创建子进程
        int i,n;
        for(i=0;i<100000;i++){	
                sem_p(id);		//执行p操作,申请资源
                n=sh_var_read();
                sh_var_write(n+1);
                sem_v(id);		//执行v操作,释放资源
        }
        if(pid >0){
                wait();
                sem_free(id);	//释放信号量
        }
        printf(1,"sum=%d\n",sh_var_read());
        exit();
}

  修改Makefile文件,在xv6源码目录下输入make qemu,编译我们编写的C程序,在QEMU环境中运行该程序,结果如下图所示。
在这里插入图片描述
  通过程序运行结果可以发现,我们的信号量机制实验成功完成了,并且成功实现了用户进程之间对内核全局变量的互斥访问,最后,内核全局变量的值为20000,符合我们的预期,至此,本章实验完成。


🎉五、总结

  本章实验通过对内核全局变量的无互斥并发访问,所面临读取脏数据问题的角度出发,来实现xv6操作系统的信号量机制,并且成功实现了对内核全局变量的互斥访问,在我们的信号量机制验证代码中,我们创建信号量的时候,初始参数设置为1,表明该信号量是对操作系统临界资源的互斥访问限制
  本章实验的篇幅很长,接近1.2w字,充分结合了操作系统信号量和进程同步的理论知识,并且进行了实现,作者也是完成了该实验之后,极大的巩固了我对操作系统信号量机制的认识。

注:本专栏所有内容都是参考深圳大学罗秋明老师著的《操作系统原型-xv6分析与实验》,并且提取了较为简单和重要的部分,想要进一步深入xv6操作系统,可以学习本书更多内容。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐