你好,我是LMOS。
在上一课中,我们实现了建立设备的接口,这相当于制定了部门的相关法规,只要遵守这些法规就能建立一个部门。当然,建立了一个部门,是为了干活的,吃空饷可不行。
其实一个部门的职责不难确定,它应该能对上级下发的任务作出响应,并完成相关工作,而这对应到设备,就是如何处理内核的I/O包,这节课我们就来解决这个问题。
首先,我们需要搞清楚什么是I/O包,然后实现内核向设备发送I/O包的工作。最后,我还会带你一起来完成一个驱动实例,用于处理I/O包,这样你就能真正理解这里的来龙去脉了。
好,让我们开始今天的学习吧!代码你可以从这里下载。
就像你要给部门下达任务时,需要准备材料报表之类的东西。同样,内核要求设备做什么事情,完成什么功能,必须要告诉设备的驱动程序。
内核要求设备完成任务,无非是调用设备的驱动程序函数,把完成任务的细节用参数的形式传递给设备的驱动程序。
由于参数很多,而且各种操作所需的参数又不相同,所以我们就想到了更高效的管理方法,也就是把各种操作所需的各种参数封装在一个数据结构中,称为I/O包,这样就可以统一驱动程序功能函数的形式了。
思路理清以后,现在我们来设计这个数据结构,如下所示。
typedef struct s_OBJNODE
{
spinlock_t on_lock; //自旋锁
list_h_t on_list; //链表
sem_t on_complesem; //完成信号量
uint_t on_flgs; //标志
uint_t on_stus; //状态
sint_t on_opercode; //操作码
uint_t on_objtype; //对象类型
void* on_objadr; //对象地址
uint_t on_acsflgs; //访问设备、文件标志
uint_t on_acsstus; //访问设备、文件状态
uint_t on_currops; //对应于读写数据的当前位置
uint_t on_len; //对应于读写数据的长度
uint_t on_ioctrd; //IO控制码
buf_t on_buf; //对应于读写数据的缓冲区
uint_t on_bufcurops; //对应于读写数据的缓冲区的当前位置
size_t on_bufsz; //对应于读写数据的缓冲区的大小
uint_t on_count; //对应于对象节点的计数
void* on_safedsc; //对应于对象节点的安全描述符
void* on_fname; //对应于访问数据文件的名称
void* on_finode; //对应于访问数据文件的结点
void* on_extp; //用于扩展
}objnode_t;
现在你可能还无法从objnode_t这个名字看出它跟I/O包的关系。但你从刚才的代码里可以看出,objnode_t的数据结构中包括了各个驱动程序功能函数的所有参数。
等我们后面讲到API接口时,你会发现,objnode_t结构不单是完成了I/O包传递参数的功能,它在整个I/O生命周期中,都起着重要的作用。这里为了好理解,我们就暂且把objnode_t结构当作I/O包来看。
刚才,我们已经定义了I/O包也就是objnode_t结构,但若是要使用它,就必须先把它建立好。
根据以往的经验,你应该已经猜到了,这里创建I/O包就是在内存中建立objnode_t结构的实例变量并初始化它。由于这是一个全新的模块,所以我们要先在cosmos/kernel/目录下建立一个新的krlobjnode.c文件,在这个文件中写代码,如下所示。
//建立objnode_t结构
objnode_t *krlnew_objnode()
{
objnode_t *ondp = (objnode_t *)krlnew((size_t)sizeof(objnode_t));//分配objnode_t结构的内存空间
if (ondp == NULL)
{
return NULL;
}
objnode_t_init(ondp);//初始化objnode_t结构
return ondp;
}
//删除objnode_t结构
bool_t krldel_objnode(objnode_t *onodep)
{
if (krldelete((adr_t)onodep, (size_t)sizeof(objnode_t)) == FALSE)//删除objnode_t结构的内存空间
{
hal_sysdie("krldel_objnode err");
return FALSE;
}
return TRUE;
}
上述代码非常简单,主要完成了建立、删除objnode_t结构这两件事,其实说白了就是分配和释放objnode_t结构的内存空间。
这里再一次体现了内存管理组件在操作系统内核之中的重要性,objnode_t_init函数会初始化objnode_t结构中的字段,因为其中有自旋锁、链表、信号量,而这些结构并不能简单地初始为0,否则可以直接使用memset之类的函数把那个内存空间清零就行了。
现在我们假定在上层接口函数中,已经建立了一个I/O包(即objnode_t结构),并且把操作码、操作对象和相关的参数信息填写到了objnode_t结构之中。那么下一步,就需要把这个I/O发送给具体设备的驱动程序,以便驱动程序完成具体工作。
我们需要定义实现一个函数,专门用于完成这个功能,它标志着一个设备驱动程序开始运行,经它之后内核就实际的控制权交给驱动程序,由驱动程序代表内核操控设备。
下面,我们就来写好这个函数,不过这个函数属于驱动模型函数,所以要在krldevice.c文件中实现这个函数。代码如下所示。
//发送设备IO
drvstus_t krldev_io(objnode_t *nodep)
{
//获取设备对象
device_t *devp = (device_t *)(nodep->on_objadr);
if ((nodep->on_objtype != OBJN_TY_DEV && nodep->on_objtype != OBJN_TY_FIL) || nodep->on_objadr == NULL)
{//检查操作对象类型是不是文件或者设备,对象地址是不是为空
return DFCERRSTUS;
}
if (nodep->on_opercode < 0 || nodep->on_opercode >= IOIF_CODE_MAX)
{//检查IO操作码是不是合乎要求
return DFCERRSTUS;
}
return krldev_call_driver(devp, nodep->on_opercode, 0, 0, NULL, nodep);//调用设备驱动
}
//调用设备驱动
drvstus_t krldev_call_driver(device_t *devp, uint_t iocode, uint_t val1, uint_t val2, void *p1, void *p2)
{
driver_t *drvp = NULL;
if (devp == NULL || iocode >= IOIF_CODE_MAX)
{//检查设备和IO操作码
return DFCERRSTUS;
}
drvp = devp->dev_drv;
if (drvp == NULL)//检查设备是否有驱动程序
{
return DFCERRSTUS;
}
//用IO操作码为索引调用驱动程序功能分派函数数组中的函数
return drvp->drv_dipfun[iocode](devp, p2);
}
krldev_io函数,只接受一个参数,也就是objnode_t结构的指针。它会首先检查objnode_t结构中的IO操作码是不是合乎要求的,还要检查被操作的对象即设备是不是为空,然后调用krldev_call_driver函数。
这个krldev_call_driver函数会再次确认传递进来的设备和IO操作码,然后重点检查设备有没有驱动程序。这一切检查通过之后,我们就用IO操作码为索引调用驱动程序功能分派函数数组中的函数,并把设备和objnode_t结构传递进去。有没有觉得眼熟?没错,这正是我们前面课程中对驱动程序的设计。
好了,现在一个设备的驱动程序就能正式开始工作,开始响应处理内核发来的I/O包了。可是我们还没有驱动呢,所以下面我们就去实现一个驱动程序。
现在我们一起来实现一个真实而且简单的设备驱动程序,就是systick设备驱动,它是我们Cosmos系统的心跳,systick设备的主要功能和作用是每隔 1ms产生一个中断,相当于一个定时器,每次时间到达就产生一个中断向系统报告又过了1ms,相当于千分之一秒,即每秒钟内产生1000次中断。
对于现代CPU的速度来说,这个中断频率不算太快。x86平台上有没有这样的定时器呢?当然有,其中8254就是一个古老且常用的定时器,对它进行编程设定,它就可以周期的产生定时器中断。
这里我们就以8254定时器为基础,实现Cosmos系统的systick设备。我们先从systick设备驱动程序的整体框架入手,然后建立systick设备,最后一步一步实现systick设备驱动程序。
在前面的课程中,我们已经了解了在Cosmos系统下,一个设备驱动程序的基本框架,但是我们没有深入具体化。
所以,这里我会带你从全局好好了解一个真实的设备,它的驱动程序应该至少有哪些函数。由于这是个驱动程序,我们需要在cosmos/drivers/目录下建立一个drvtick.c文件,在drvtick.c文件中写入以下代码,如下所示。
//驱动程序入口和退出函数
drvstus_t systick_entry(driver_t *drvp, uint_t val, void *p)
{
return DFCERRSTUS;
}
drvstus_t systick_exit(driver_t *drvp, uint_t val, void *p)
{
return DFCERRSTUS;
}
//设备中断处理函数
drvstus_t systick_handle(uint_t ift_nr, void *devp, void *sframe)
{
return DFCEERSTUS;
}
//打开、关闭设备函数
drvstus_t systick_open(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
drvstus_t systick_close(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
//读、写设备数据函数
drvstus_t systick_read(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
drvstus_t systick_write(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
//调整读写设备数据位置函数
drvstus_t systick_lseek(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
//控制设备函数
drvstus_t systick_ioctrl(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
//开启、停止设备函数
drvstus_t systick_dev_start(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
drvstus_t systick_dev_stop(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
//设置设备电源函数
drvstus_t systick_set_powerstus(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
//枚举设备函数
drvstus_t systick_enum_dev(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
//刷新设备缓存函数
drvstus_t systick_flush(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
//设备关机函数
drvstus_t systick_shutdown(device_t *devp, void *iopack)
{
return DFCERRSTUS;
}
以上就是一个驱动程序必不可少的函数,在各个函数可以返回一个错误状态,而不做任何实际工作,但是必须要有这个函数。这样在内核发来任何设备功能请求时,驱动程序才能给予适当的响应。这样,一个驱动程序的整体框架就确定了。
写好了驱动程序的整体框架,我们这个驱动就完成了一半。下面我们来一步一步来实现它。
我们先来写好systick设备驱动程序的入口函数。那这个函数用来做什么呢?其实我们在上一节课就详细讨论过,无非是建立设备,向内核注册设备,安装中断回调函数等操作,所以这里不再赘述。
我们直接写出这个函数,如下所示。
drvstus_t systick_entry(driver_t* drvp,uint_t val,void* p)
{
if(drvp==NULL) //drvp是内核传递进来的参数,不能为NULL
{
return DFCERRSTUS;
}
device_t* devp=new_device_dsc();//建立设备描述符结构的变量实例
if(devp==NULL)//不能失败
{
return DFCERRSTUS;
}
systick_set_driver(drvp);
systick_set_device(devp,drvp);//驱动程序的功能函数设置到driver_t结构中的drv_dipfun数组中
if(krldev_add_driver(devp,drvp)==DFCERRSTUS)//将设备挂载到驱动中
{
if(del_device_dsc(devp)==DFCERRSTUS)//注意释放资源。
{
return DFCERRSTUS;
}
return DFCERRSTUS;
}
if(krlnew_device(devp)==DFCERRSTUS)//向内核注册设备
{
if(del_device_dsc(devp)==DFCERRSTUS)//注意释放资源
{
return DFCERRSTUS;
}
return DFCERRSTUS;
}
//安装中断回调函数systick_handle
if(krlnew_devhandle(devp,systick_handle,20)==DFCERRSTUS)
{
return DFCERRSTUS; //注意释放资源。
}
init_8254();//初始化物理设备
if(krlenable_intline(0x20)==DFCERRSTUS)
{
return DFCERRSTUS;
}
return DFCOKSTUS;
}
你可能非常熟悉这部分代码,没错,这正是上节课中,我们的那个驱动程序入口函数的实例。
不过在上节课里,我们主要是要展示一个驱动程序入口函数的流程。这里却是要投入工作的真实设备驱动。
最后的krlenable_intline函数,它的主要功能是开启一个中断源上的中断。而init_8254函数则是为了初始化8254,它就是一个古老且常用的定时器。这两个函数非常简单,我已经帮写好了。
但是这样还不够,有了驱动程序入口函数,驱动程序并不会自动运行。根据前面我们的设计,需要把这个驱动程序入口函数放入驱动表中。
下面我们就把这个systick_entry函数,放到驱动表里,代码如下所示。
//cosmos/kernel/krlglobal.c
KRL_DEFGLOB_VARIABLE(drventyexit_t,osdrvetytabl)[]={systick_entry,NULL};
有了刚才这步操作之后,Cosmos在启动的时候,就会执行初始驱动初始化init_krldriver函数,接着这个函数就会启动运行systick设备驱动程序入口函数。我们的systick_entry函数一旦执行,就会建立systick设备,不断的产生时钟中断。
在驱动程序入口函数中,除了那些标准的流程之外,我们还要对设备和驱动进行适当的配置,就是设置一些标志、状态、名称、驱动功能派发函数等等。有了这些信息,设备才能加入到驱动程序中,然后注册到内核,这样才能被内核所识别。
好,让我们先来实现设置驱动程序的函数,它主要设置设备驱动程序的名称、功能派发函数,代码如下。
void systick_set_driver(driver_t *drvp)
{
//设置驱动程序功能派发函数
drvp->drv_dipfun[IOIF_CODE_OPEN] = systick_open;
drvp->drv_dipfun[IOIF_CODE_CLOSE] = systick_close;
drvp->drv_dipfun[IOIF_CODE_READ] = systick_read;
drvp->drv_dipfun[IOIF_CODE_WRITE] = systick_write;
drvp->drv_dipfun[IOIF_CODE_LSEEK] = systick_lseek;
drvp->drv_dipfun[IOIF_CODE_IOCTRL] = systick_ioctrl;
drvp->drv_dipfun[IOIF_CODE_DEV_START] = systick_dev_start;
drvp->drv_dipfun[IOIF_CODE_DEV_STOP] = systick_dev_stop;
drvp->drv_dipfun[IOIF_CODE_SET_POWERSTUS] = systick_set_powerstus;
drvp->drv_dipfun[IOIF_CODE_ENUM_DEV] = systick_enum_dev;
drvp->drv_dipfun[IOIF_CODE_FLUSH] = systick_flush;
drvp->drv_dipfun[IOIF_CODE_SHUTDOWN] = systick_shutdown;
drvp->drv_name = "systick0drv";//设置驱动程序名称
return;
}
上述代码的功能并不复杂,我一说你就能领会。systick_set_driver函数,无非就是将12个驱动功能函数的地址,分别设置到driver_t结构的drv_dipfun数组中。其中,驱动功能函数在该数组中的元素位置,正好与IO操作码一一对应,当内核用IO操作码调用驱动时,就是调用了这个数据中的函数。最后,我们将驱动程序的名称设置为systick0drv。
新建的设备也需要配置相关的信息才能工作,比如需要指定设备,设备状态与标志,设备类型、设备名称这些信息。尤其要注意的是,设备类型非常重要,内核正是通过类型来区分各种设备的,下面我们写个函数,完成这些功能,代码如下所示。
void systick_set_device(device_t *devp, driver_t *drvp)
{
devp->dev_flgs = DEVFLG_SHARE;//设备可共享访问
devp->dev_stus = DEVSTS_NORML;//设备正常状态
devp->dev_id.dev_mtype = SYSTICK_DEVICE;//设备主类型
devp->dev_id.dev_stype = 0;//设备子类型
devp->dev_id.dev_nr = 0; //设备号
devp->dev_name = "systick0";//设置设备名称
return;
}
上述代码中,systick_set_device函数需要两个参数,但是第二个参数暂时没起作用,而第一个参数其实是一个device_t结构的指针,在systick_entry函数中调用new_device_dsc函数的时候,就会返回这个指针。后面我们会把设备加载到内核中,那时这个指针指向的设备才会被注册。
其实对于systick这样设备,主要功能是定时中断,还不能支持读、写、控制、刷新、电源相关的功能,就算内核对systick设备发起了这样的I/O包,systick设备驱动程序相关的功能函数也只能返回一个错误码,表示不支持这样的功能请求。
但是,打开与关闭设备这样的功能还是应该要实现。下面我们就来实现这两个功能请求函数,代码如下所示。
//打开设备
drvstus_t systick_open(device_t *devp, void *iopack)
{
krldev_inc_devcount(devp);//增加设备计数
return DFCOKSTUS;//返回成功完成的状态
}
//关闭设备
drvstus_t systick_close(device_t *devp, void *iopack)
{
krldev_dec_devcount(devp);//减少设备计数
return DFCOKSTUS;//返回成功完成的状态
}
这样,打开与关闭设备的功能就实现了,只是简单地增加与减少设备的引用计数,然后返回成功完成的状态就行了。而增加与减少设备的引用计数,是为了统计有多少个进程打开了这个设备,当设备引用计数为0时,就说明没有进程使用该设备。
对于systick设备来说,重要的并不是打开、关闭,读写等操作,而是systick设备产生的中断,以及在中断回调函数中执行的操作,即周期性的执行系统中的某些动作,比如更新系统时间,比如控制一个进程占用CPU的运行时间等,这些操作都需要在systick设备中断回调函数中执行。
按照前面的设计,systick设备每秒钟产生1000次中断,那么1秒钟就会调用1000次这个中断回调函数,这里我们只要写出这个函数就行了,因为安装中断回调函数的思路,我们在前面的课程中已经说过了(可以回顾上节课),现在我们直接实现这个中断函数,代码可以像后面这样写。
drvstus_t systick_handle(uint_t ift_nr, void *devp, void *sframe)
{
kprint("systick_handle run devname:%s intptnr:%d\n", ((device_t *)devp)->dev_name, ift_nr);
return DFCOKSTUS;
}
这个中断回调函数,暂时什么也没干,就输出一条信息,让我们知道它运行了,为了直观观察它运行了,我们要对内核层初始化函数修改一下,禁止进程运行,以免进程输出的信息打扰我们观察结果,修改的代码如下所示。
void init_krl()
{
init_krlmm();
init_krldevice();//初始化设备
init_krldriver();//初始化驱动程序
init_krlsched();
//init_krlcpuidle();禁止进程运行
STI();//打开CPU响应中断的能力
die(0);//进入死循环
return;
}
下面,我们打开终端切到Cosmos目录下,执行make vboxtest指令,如果不出意外,我们将会中看到如下界面。
上图中的信息,会不断地滚动出现,信息中包含设备名称和中断号,这标志着我们中断回调函数的运行正确无误。
当然,如果我们费了这么功夫搞了中断回调函数,就只是为了输出信息,那也太不划算了,我们当然有更重要的事情要做,你还记得之前讲过的进程知识吗?这里我再帮你理一理思路。
我们在每个进程中都要主动调用进程调度器函数,否则进程就会永远霸占CPU,永远运行下去。这是因为,我们没有定时器可以周期性地检查进程运行了多长时间,如果进程的运行时间超过了,就应该强制调度,让别的进程开始运行。
更新进程运行时间的代码,我已经帮你写好了,你只需要在这个中断回调函数中调用就好了,代码如下所示。
drvstus_t systick_handle(uint_t ift_nr, void *devp, void *sframe)
{
krlthd_inc_tick(krlsched_retn_currthread());//更新当前进程的tick
return DFCOKSTUS;
}
这里的krlthd_inc_tick函数需要一个进程指针的参数,而krlsched_retn_currthread函数是返回当前正在运行进程的指针。在krlthd_inc_tick函数中对进程的tick值加1,如果大于20(也就是20 毫秒)就重新置0,并进行调度。
下面,我们把内核层初始化函数恢复到原样,重新打开终端切到cosmos目录下,执行make vboxtest指令,我们就将会看到如下界面。
我们可以看到,进程A、进程B,还有调度器交替输出的信息。这已经证明我们更新进程运行时间,检查其时间是否用完并进行调度的代码逻辑,都是完全正确的,恭喜你走到了这一步!
至此,我们的systick驱动程序就实现了,它非常简单,但却包含了一个驱动程序完整实现。同时,这个过程也一步步验证了我们对驱动模型的设计是正确的。
又到课程的结尾,到此为止,我们了解了实现一个驱动程序完整过程,虽然我们只是驱动了一个定时器设备,使之周期性的产生定时中断。在定时器设备的中断回调函数中,我们调用了更新进程时间的函数,达到了这样的目的:在进程运行超时的情况下,内核有能力夺回CPU,调度别的进程运行。
现在我来为你梳理一下重点。
1.为了搞清楚设备如何处理I/O包,我们了解了什么是I/O包,写好了处理建立、删除I/O包的代码。
2.要使设备完成相应的功能,内核就必须向设备驱动发送相应的I/O包,在I/O包提供相应IO操作码和适当的参数。所以,我们动手实现了向设备发送I/O包并调用设备驱动程序的机制。
3.一切准备就绪之后,我们建立了systick驱动程序实例,这是一个完整的驱动程序,它支持打开关闭和周期性产生中断的功能请求。通过这个实例,让我们了解了一个真实设备驱动的实现以及它处理内核I/O包的过程。
你可能对这样简单的驱动程序不够满意,也不能肯定我们的驱动模型是不是能适应大多数场景,请不要着急,在后面讲到文件系统时,我们会实现一个更为复杂的驱动程序。
请你想一想,为什么没有systick设备这样周期性的产生中断,进程就有可能霸占CPU呢?
欢迎你在留言区跟我交流互动,也欢迎你把这节课分享给身边的同事、朋友,一起实践驱动程序的实例。
好,我是LMOS,我们下节课见!