【操作系统实验】设备驱动(Linux环境下)

【实验目的】
实验目的:熟悉Linux下驱动程序设计
     编译内核
实验要求:在Linux系统下,编译内核,并在该内核下完成实验;
     自主设计驱动程序,完成驱动程序的安装

【实验内容】
1.编译内核,构造内核源码树
2.ubantu14.04 32位下编写hello world程序以及加载驱动
3.ubantu14.04第二个memory驱动程序
4.ubantu14.04第三个使用文件私有数据的globalmem的设备驱动
5.Linux设备驱动中的阻塞与非阻塞I/O

【实验环境】(含主要设计设备、器材、软件等)
Pc电脑一台
在这里插入图片描述

【实验步骤、过程】(含原理图、流程图、关键代码,或实验过程中的记录、数据等)

1.编译内核,构造内核源码树(itc-centos)

(1)编译指令
①指导书上的指令

uname -r  //查看内核版本
ls /usr/src  //输出:linux-headers-3.13.0-32,linux-headers-3.13.0-32-generic
apt-cache search linux-source  //查看一下可一下载的源码包
sudo apt-get install linux-source-3.13.0  //下载完成后,在/usr/sr下会有压缩包,然后解压
make oldconfig  //开始配置内核 选择最快的原版的配置方式,menuconfig , xconfig也行
make bzImage  //执行结束后,可以看到在当前目录下生成了一个新的文件: vmlinux
make modules和make modules_install

②自己查阅的资料
  以一个不是root用户的户口,创建一个以~/rpmbuild为基础的目录树:

[user@host] $ mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SPRMS}
[user@host] $ echo ‘%_topdir % (echo $HOME) / rpmbuild’ > ~/.rpmmacros 

  以一个不是root用户的普通户口,执行以下指令来安装源代码组件:

[user@host] $ rpm -i http://vault.centos.org/7.7.1908/updates/Source/SPackages
/kernel-3.10.0-1062.9.1.e17.src.rpm 2>&1 | grep -v exist

解压及预备源代码文件:

[user@host] $ cd ~/rpmbuild/SPECS
[user@host SPECS] $ rpmbuild -bp --target = $(uname -m) kernel.spec

  其中,$(uname -m)这条命令,可以将目标结构设置为你现有的内核的结构,一般来说这是可行的,因为多数人需要以i686或x86_64为目标。

(2)编译过程截图
【操作系统实验】设备驱动(Linux环境下)_第1张图片
【操作系统实验】设备驱动(Linux环境下)_第2张图片

            图1 编译内核过程

(3)编译过程注意事项
  如果这一步中没有建立内核源码树,按下面步骤进行,虽然能够生成hello.ko,但执行sudo insmod hello.ko后,执行lsmod会没反应,导致系统报告问题,会导致下次开机或重启时有问题,若启动不了,可以进入recovery模式,执行fsck,开机时做嵌入式linux开发一般在PC机上编译好了,下到板子上去运行,板子上的linux内核和PC机上的linux版本很多时候都是不一样的,比如pc机上的是linux2.6,板子上的是linux3.1,这个时候就要下linux3.1的内核,用它编译的驱动模块在板子上才能加载上,不然会出错。
  在执行最后一条指令make modules和make modules_install时,执行结束之后,会在/lib/modules下生成新的目录/lib/modules/3.13.0-32-generic/,但若由于主机本身内核版本就为3.13.0-32-generic,所以/lib/modules/3.13.0-32-generic/本身就存在,此时这两条指令make modules和make modules_install就不需要执行了。

2.ubantu14.04 32位下编写hello world程序以及加载驱动

(1)实验步骤
①编写hello. c程序并写Makefile文件;
②之后通过make指令,生成hello.ko等其他文件;
③执行sudo insmod hello.ko,在lsmod即可验证模块是否已装载,最后rmmod移出模块;
④通过cat /var/log/syslog | grep world来观察其输出。

(2)实验过程截图
【操作系统实验】设备驱动(Linux环境下)_第3张图片

          图2 hello.c代码
【操作系统实验】设备驱动(Linux环境下)_第4张图片

          图3 对于Makefile内容
在这里插入图片描述

          图4 执行make指令
在这里插入图片描述

          图5 执行lsmod指令
【操作系统实验】设备驱动(Linux环境下)_第5张图片

          图6 运行结果

(3)实验过程注意事项

$(MAKE) -C $(KERNELDIR) M=$(PWD) modules

  这句是Makefile的规则:这里的$(MAKE)就相当于make,-C 选项的作用是指将当前工作目录转移到你所指定的位置。“M=”选项的作用是,当用户需要以某个内核为基础编译一个外部模块的话,需要在make modules 命令中加入“M=dir”,程序会自动到你所指定的dir目录中查找模块源码,将其编译,生成ko文件。
  一定要实现查看电脑Linux的内核版本,机房不同电脑的内核版本可能不一样,要事先查好之后并且在Makefile文件中版本号要改。
  用printk,内核会根据日志级别,可能把消息打印到当前控制台上,这个控制台通常是一个字符模式的终端、一个串口打印机或是一个并口打印机。这些消息正常输出的前提是──日志输出级别小于console_loglevel(在内核中数字越小优先级越高)。

3.ubantu14.04第二个memory驱动程序

(1)实验步骤
①编写c程序并写Makefile文件;
②根据设备号,从内核空间将数据取出,写入相应的设备空间内;
③通过程序创建主从设备,将数据写入;
④再通过模块功能读出,观察其输出。

(2)实验过程截图
①到包含Makefile和mydm1.c的目录下执行make,生成mydm1.ko;
②执行sudo insmod mydm1.ko;
③验证:lsmod | grep mydm1
④需要创建一个文件(该设备文件用于和设备驱动操作)
mknod /dev/fgj c 224 0 c代表字符设备 224为主设备号,0为从设备号
⑤ gcc test.c

主要代码如下:
1.mydm1.c:

int devMajor = 224; // 主设备号用于内核把文件和它的驱动链接在一起

static unsigned char simple_inc = 0;
static unsigned char demoBuffer[256]; // 用于存储该驱动的数据

int simple_open( struct inode *inode, struct file *filp )
{
    printk(" : open Success!\n");
    if(simple_inc>0)
    {
        return -1;
    }
    
    simple_inc = simple_inc + 1;
    return 0;
}

int simple_release(struct inode *inode, struct file *filp)
{
    printk(": release Success%d!\n",devMajor);
    simple_inc = simple_inc - 1;
    return 0;
}

ssize_t simple_read(struct file *filp,char __user *buf,size_t count,loff_t *f_pos)
{
    printk(": read Success%d!\n", devMajor);
    /*把数据复制到应用程序空间
    从内核空间demoBuffer中拷贝数据到用户空间buf中*/
    if( copy_to_user(buf,demoBuffer,count) ) 
    {
        count=-EFAULT;
    }
    return count;
}

ssize_t simple_write(struct file *filp, const char __user *buf,size_t count,loff_t *f_pos)
{
    printk(": write Success%d!\n",devMajor);
    /*把数据复制到内核空间*/
    if(copy_from_user(demoBuffer + *f_pos,buf,count))
    {
        count=-EFAULT;
    }
    return count;
}

struct file_operations simple_fops={
.owner=THIS_MODULE,
.read=simple_read,
.write=simple_write,
.open=simple_open,
.release=simple_release,
};

/*******************************************************
MODULEROUTINE
*******************************************************/
static void __exit simple_cleanup_module(void)
{
    unregister_chrdev(devMajor,"mydm1");
    printk(" : simple_cleanup_module!\n");
    //return 0;
}

static int __init simple_init_module(void)
{
    int ret;
    //根据设备号与设备名注册字符设备,此处设备名不一定要与模块名相同,
    /*register_chrdev函数用于在内核空间,把驱动
    和/dev下设备文件链接在一起*/
    ret=register_chrdev( devMajor,"mydm1",&simple_fops);
    if(ret<0)
    {
        printk(" : Unabletoregistercharacterdevice%d!\n",devMajor);
        return ret;
    }
    else
    {
        printk(" : Success%d!\n",devMajor );
    }
    return 0;
}

module_init(simple_init_module);
module_exit(simple_cleanup_module);

2.Makefile:

obj-m := mydm1.o
#KERNELDIR := /lib/modules/3.16.61-32-generic/build
KERNELDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
modules:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) modules
modules_install:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) modules_install
clean:
    rm *.o *.ko *.mod.c *.order *.symvers

3.test.c:

#include 
#include 
#include 
#include 
#include 

void main(void)
{
    int fd;
    int i;
    char data[256];
    int retval;
    fd = open("/dev/fgj",O_RDWR);
    if(fd==-1)
    {
        perror("erroropen\n");
        exit(-1);
    }
    printf("open/dev/fgjsuccessfully\n");
    //写数据
    retval = write(fd,"fgj",3);
    if(retval==-1)
    {
        perror("writeerror\n");
        exit(-1);
    }
    printf("write successfully\n");
    //读数据
    retval=read(fd,data,3);
    if(retval==-1)
    {
        perror("readerror\n");
        exit(-1);
    }
    data[retval]=0;
    printf("readsuccessfully:%s\n",data);
    //关闭设备
    close(fd);
}

运行结果如下:
【操作系统实验】设备驱动(Linux环境下)_第6张图片

              图7 执行make命令
【操作系统实验】设备驱动(Linux环境下)_第7张图片

              图8 执行结果

(3)实验过程注意事项
①保证/dev/fgj有读写权限 chmod 666 /dev/fgj;
②printk打印的消息若灭有在控制台上显示,可以同构dmesg命令或查看系统的日志文件cat /var/log/syslog命令看到;
③驱动编译:gcc必须用系统自带的版本,gcc -v查看当前版本, 否则insmod后,lsmod会没反应,系统直接卡死,注销也不行,只能重启;

4.ubantu14.04第三个使用文件私有数据的globalmem的设备驱动

(1)实验步骤
①编写c程序并写Makefile文件;
②执行:make ,然后sudo insmod globalmem.ko;
③验证:lsmod 可以看到该模块,mknod /dev/globalmem c 354 0, echo ‘good nihao’ > /dev/globalmem, cat /dev/globalmem可以看到输出good nihao;

(2)实验过程截图
代码如下:

#define GLOBALMEM_SIZE 0X1000 /*全局内存最大4KB*/
#define MEM_CLEAR 0x1 /*清零全局内存*/
#define GLOBALMEM_MAJOR 354

static int globalmem_major = GLOBALMEM_MAJOR;/*预设的globalmem的主设备号*/

/*globalmem的设备结构体:包含了对应于globalmem字符设备的cdev 和 使用内存mem[GLOBALMEM_SIZE]*/
struct globalmem_dev
{
    struct cdev cdev;  //cdev结构体
    unsigned char mem[GLOBALMEM_SIZE];  //全局内存
};

struct globalmem_dev *globalmem_devp;  //设备结构体指针

/*文件打开函数*/
int globalmem_open(struct inode *inode,struct file *filp)
{
    filp->private_data = globalmem_devp; //将设备结构体指针赋值给文件私有数据指针
    return 0;
}

/*文件释放函数*/
int globalmem_release(struct inode *inode,struct file *filp)
{
    return 0;
}

/*设备控制函数:ioctl()函数接受的MEM_CLEAR命令,这个命令将全局内存的有效数据长度清零,对于设备不支持的命令,ioctl()函数应该返回-EINVAL*/
static long globalmem_ioctl( /*struct inode *inodep,*/struct file *filp,unsigned int cmd,unsigned long arg)
{
     struct globalmem_dev *dev = filp->private_data; //获得设备结构体指针
     switch(cmd)
     {
      case  MEM_CLEAR:
        memset(dev->mem,0,GLOBALMEM_SIZE);
        printk(KERN_INFO"globalmem is set to zero\n");
        break;
      default:
        return -EINVAL;
    }
    return 0;
}

/*读函数:读写函数主要是让设备结构体的mem[]数组与用户空间交互数据,并随着访问字节数变更返回用户的文件读写偏移位置*/
static ssize_t globalmem_read(struct file *filp,char __user *buf,size_t size,loff_t *ppos )
{
     unsigned long p = *ppos;
     unsigned int count = size;
     int ret = 0;
     struct globalmem_dev *dev = filp->private_data; //获得设备结构体指针
     
     if(p >= GLOBALMEM_SIZE)  //分析和获取有效的写长度
     {
        return count ? -ENXIO:0;
     }
     if(count > GLOBALMEM_SIZE - p)
     {
        count = GLOBALMEM_SIZE - p;
     }
     
     if(copy_to_user(buf,(void *)(dev->mem+p),count))  //内核空间->用户空间
     {
        ret = -EFAULT;
     }
     else
     {
          *ppos += count;
          ret = count;
          printk(KERN_INFO"read %d bytes(s) from %ld\n",count,p);
     }
     return ret;
}

/*写函数*/
static ssize_t globalmem_write(struct file *filp,const char __user *buf,size_t size,loff_t *ppos)
{
     unsigned long p = *ppos;
     unsigned int count = size;
     int ret = 0;
     
     struct globalmem_dev *dev = filp->private_data;
     
     if(p >= GLOBALMEM_SIZE)  //分析和获取有效的写长度
     {
        return count? -ENXIO:0;
     }
     
     if(count > GLOBALMEM_SIZE - p)
     {
        count = GLOBALMEM_SIZE - p;
     }
     
     if(copy_from_user(dev->mem + p,buf,count)) // 用户空间->内核空间
     {
        ret = -EFAULT;
     }
     else
     {
          *ppos =+ count;
          ret = count;
          printk(KERN_INFO"written %d bytes(s) from %ld\n",count,p);
     }
     return ret;
}

/*seek文件定位函数:seek()函数对文件定位的起始地址可以是文件开头(SEEK_SET,0)、当前位置(SEEK_CUR,1)、文件尾(SEEK_END,2)*/
static loff_t globalmem_llseek(struct file *filp,loff_t offset,int orig)
{
 loff_t ret = 0;
 switch(orig)
 {
  case 0:   //相对文件开始位置偏移
   if(offset <0 )
   {
    ret = -EINVAL;
    break;
   }
   
   if((unsigned int )offset > GLOBALMEM_SIZE)
   {
    ret = - EINVAL;
    break;
   }
   filp->f_pos = (unsigned int)offset;
   ret = filp->f_pos;
   break;
   
  case 1:   //相对文件当前位置偏移
   if((filp->f_pos + offset) > GLOBALMEM_SIZE)
   {
    ret = -EINVAL;
    break;
   }
   if((filp->f_pos + offset)<0)
   {
    ret = -EINVAL;
    break;
   }
   filp->f_pos +=offset;
   ret = filp->f_pos;
   break;
  default:
   ret = -EINVAL;
   break; 
 }
 return ret;
}

/*文件操作结构体*/
static const struct file_operations globalmem_fops=
{
 .owner = THIS_MODULE,
 .llseek = globalmem_llseek,
 .read = globalmem_read,
 .write = globalmem_write,
// .ioctl = globalmem_ioctl,
 .unlocked_ioctl = globalmem_ioctl,
 .open = globalmem_open,
 .release = globalmem_release,
};

/*初始化并注册cdev*/
static void globalmem_setup_cdev(struct globalmem_dev *dev,int index)
{
 int err,devno = MKDEV(globalmem_major,index);
 cdev_init(&dev->cdev,&globalmem_fops);
 dev->cdev.owner = THIS_MODULE;
 dev->cdev.ops = &globalmem_fops;
 err = cdev_add(&dev->cdev,devno,1);
 if(err)
 {
  printk(KERN_NOTICE"Error %d adding LED%d",err,index);
 } 
}

/*设备驱动模块加载函数*/
static int __init globalmem_init(void)
{
 int result;
 dev_t devno = MKDEV(globalmem_major,0);
 
 if(globalmem_major) //申请设备号
 {
  result = register_chrdev_region(devno,1,"globalmem");
 }
 else  //动态申请设备号
 {
  result = alloc_chrdev_region(&devno,0,1,"globalmem");
  globalmem_major = MAJOR(devno);
 }
 
 if(result < 0)
 {
  return result;
 }
 
 globalmem_devp = kmalloc(sizeof(struct globalmem_dev),GFP_KERNEL); //申请设备结构体的内存
 if(!globalmem_devp)
 {
  result = -ENOMEM;
  goto fail_malloc;
 }
 
 memset(globalmem_devp,0,sizeof(struct globalmem_dev));
 globalmem_setup_cdev(globalmem_devp,0);
 return 0;
 
 fail_malloc:unregister_chrdev_region(devno,1);
 return result;
}

/*模块卸载函数*/
static void __exit globalmem_exit(void)
{
 cdev_del(&globalmem_devp->cdev); //注销cdev
 kfree(globalmem_devp);          //释放设备结构体内存
 unregister_chrdev_region(MKDEV(globalmem_major,0),1); //释放设备号
}

MODULE_AUTHOR("Song Baohua");
MODULE_LICENSE("Dual BSD/GPL");

module_param(globalmem_major,int,S_IRUGO);

module_init(globalmem_init);
module_exit(globalmem_exit);

2.Makefile:

KERNELDIR := /lib/modules/$(shell uname -r)/build 
obj-m := globalmem.o
modules:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) modules
modules_install:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) modules_install
  
clean:
    make -C $(KERNELDIR) M=`pwd` modules clean  
    rm -rf modules.order

【操作系统实验】设备驱动(Linux环境下)_第8张图片

          图9 执行make指令
【操作系统实验】设备驱动(Linux环境下)_第9张图片

          图10 执行lsmod指令
在这里插入图片描述

          图11 验证结果

5.Linux设备驱动中的阻塞与非阻塞I/O

(1)实验内容
  阻塞和非阻塞I/O是设备访问的两种不同模式,驱动程序可以灵活的支持用户空间对设备的这两种访问方式。本例子讲述了这两者的区别并实现I/O的等待队列机制,并进行了用户空间的验证。

基本概念:
1> 阻塞操作是指在执行设备操作时,若不能获得资源,则挂起进程直到满足操作条件后再进行操作。被挂起的进 程进入休眠,被从调度器移走,直到条件满足。
2> 非阻塞操作在不能进行设备操作时,并不挂起,它或者放弃,或者不停地查询,直到可以进行操作。非阻塞应用程序通常使用select系统调用查询是否可以对设备进行无阻塞的访问最终会引发设备驱动中poll函数执行。

(2)实验过程截图
主要代码如下;

/*globalfifo设备结构体*/

struct globalfifo_dev
{
    struct cdev cdev;                   //cdev结构体
    unsigned int current_len;           //fifo有效数据长度
    unsigned char mem[GLOBALFIFO_SIZE];
    struct semaphore sem;            //并发控制用的信号量
    wait_queue_head_t r_wait;           //阻塞读用的等待队列 内核双向循环链表 都可以为头
    wait_queue_head_t w_wait;           //阻塞写用的等待队列头
}; 
/*globalfifo读函数*/
static ssize_t globalfifo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos)
{
    int ret;
    struct globalfifo_dev *dev = filp->private_data;
    DECLARE_WAITQUEUE(wait, current);

    down(&dev->sem);                     /*获得信号量*/
    add_wait_queue(&dev->r_wait, &wait); /*加入读等待队列头 到内核*/
    /*等待FIFO非空*/
    if(dev->current_len == 0)
    {
        if(filp->f_flags & O_NONBLOCK)
        {   /*如果进程为 非阻塞打开 设备文件*/
            ret = -EAGAIN;
            goto out;
        }
        __set_current_state(TASK_INTERRUPTIBLE); /*改变进程状态为睡眠*/
        up(&dev->sem);                           /*释放信号量*/
       schedule();                              /*调度其他进程执行*/
        if(signal_pending(current))
        {
            /*如果是因为信号唤醒*/
            ret = -ERESTARTSYS;
            goto out2;
        }
        down(&dev->sem);
    }
通过Up和down获取信号量以及释放信号量。
/*ioctl 设备控制函数*/
static long globalfifo_ioctl(/*struct inode *inodep,*/struct file *filp, unsigned int cmd, unsigned long arg)
{
    struct globalfifo_dev *dev = filp->private_data;/*获得设备结构体指针*/

    switch(cmd)
    {
        case FIFO_CLEAR:
            down(&dev->sem);    /*获得信号量*/
            dev->current_len = 0;
            memset(dev->mem,0,GLOBALFIFO_SIZE);
            up(&dev->sem);      /*释放信号量*/
            printk(KERN_INFO"globalfifo is set to zero");
            break;
        default:
            return -EINVAL;
    }
    return 0;
}
加入轮询方式,判断FIFO空和满。
/*在驱动中的增加轮询操作*/
static unsigned int globalfifo_poll(struct file *filp, poll_table *wait)
{
    unsigned int mask = 0;
    struct globalfifo_dev *dev = filp->private_data;/*获得设备结构体指针*/
    down(&dev->sem);
    poll_wait(filp, &dev->r_wait, wait);
    poll_wait(filp, &dev->w_wait, wait);
    /*fifo非空*/
    if(dev->current_len != 0)
    {
        mask |= POLLIN | POLLRDNORM; /*标示数据可以获得*/
    }
    /*fifo 非满*/
    if(dev->current_len != GLOBALFIFO_SIZE)
    {
        mask |= POLLOUT | POLLWRNORM ; /*标示数据可以写入*/
    }
    up(&dev->sem);
    return mask; /*返回驱动是否可读 或可写的 状态*/
}

【操作系统实验】设备驱动(Linux环境下)_第10张图片

            图12 运行结果1
【操作系统实验】设备驱动(Linux环境下)_第11张图片

            图13 运行结果2

【实验结果或总结】(对实验结果进行相应分析,或总结实验的心得体会,并提出实验的改进意见)

1.在编写 hello world 程序以及加载驱动的时候,module_init(hello_init); module_exit(hello_exit); 这两个是函数的入口地址和出口地址。同时Makefile文件记得更改KERNELDIR := /lib/modules/3.16.61-32-generic/build的版本号,包括32也需要改成自己电脑相匹配的。

2.$(MAKE) -C ( K E R N E L D I R ) M = (KERNELDIR) M= (KERNELDIR)M=(PWD) modules 这句是 Makefile 的规则:这里的$(MAKE)就相当于 make,-C 选项的作用是指将当前,工作目录转移到你所指定的位置。“M=”选项的作用是,当用户需要以某个内核为基础。

3.通过查阅相关资料,我明白了Makefile文件的作用,它相当于是覆写了make指令,让make指令的指向目标变为当前目录下的文件了。而sudo的作用在于,普通用户在安装时,是没有权限的,因此要通过sudo来获取超级用户权限。

4.在第二个memory驱动程序中 ret=register_chrdev( devMajor,“mydm1”,&simple_fops); register_chrdev 函数用于在内核空间,把驱动 和/dev 下设备文件链接在一起,mydm1.c :read()函数,将内核空间中的数据复制到用户空间中,从而读取出数据。在验证过程中,首先是在内核的设备区创建了对应程序的设备区,再通过测试程序往里面写入数据,最后再在用户空间中读出来。Write()函数则相反。

5.Makefile 里的KERNELDIR := /lib/modules/$(shell uname -r)/build 则是用脚本的方法获取 uname -r 即可不用对代码进行修改,动态获取内核的版本信息。

6.使用文件私有数据的globalmem的设备驱动 :用到了内核区的字符设备,将输入的字符存储到全局内存区域,再从用户空间中访问这个区域,获取到其中的数据。struct globalmem_dev { struct cdev cdev; //cdev 结构体 unsigned char mem[GLOBALMEM_SIZE]; //全局内存 }; globalmem 的设备结构体:包含了对应于 globalmem 字符设备的 cdev 和 使用内存 mem[GLOBALMEM_SIZE],其他与上一个实验类似。增加了一个ioctl函数:ioctl()函数接受的 MEM_CLEAR 命令,这个命令将全局内存的有效数据长度 清零,对于设备不支持的命令,ioctl()函数应该返回-EINVAL。,mknod /dev/globalmem c 354 0, echo ‘good nihao’ > /dev/globalmem, cat /dev/globalmem 即可验证输出 ,cat是读出,echo是写入。

  最后感谢倪福川老师以及各位同学给我的帮助,通过一学期对于操作系统理论和实验的学习,让我对操作系统加深了理解,在自己原有知识的基础上得到了更深层次的感悟,我也会把在操作系统课程之中学到的知识应用到今后的学习生活中去,思考问题也要更细致,要学会从底层去思考问题的起源,并学会从底层去解决问题。最后再一次感谢倪福川老师这一学期以来的谆谆教诲,我定当铭记于心,认真完成后续课程的学习。

你可能感兴趣的:(#,操作系统,操作系统,设备驱动)