如何表示设备类型和设备驱动
计算机的结构
芯片并非独立存在,而是以总线为基础链接在一起的,相互配合,共同实现用户的要求的功能。
总线有层级关系,各种设备通过总线相连。
如何管理设备?
在前面的课程中,我们实现了管理内存和进程,其实进程从正面看它是管理应用程序的,
反过来看它也是管理 CPU 的,它能使 CPU 的使用率达到最高。
管理内存和管理 CPU 是操作系统最核心的部分,但是这还不够,因为计算机不止有
CPU,还有各种设备。
如果把计算机内部所有的设备和数据都描述成资源,操作系统内核无疑是这些资源的管理
者。既然设备也是一种资源,如何高效管理它们,以便提供给应用进程使用和操作,就是
操作系统内核的重要任务。
分权而治
一个国家之所以有那么多部门,就是要把管理工作分开,专权专职专责,对于操作系统也
是一样。
现代计算机早已不限于只处理计算任务,它还可以呈现图像、音频,和远程计算机通信,
储存大量数据,以及和用户交互。所以,计算机内部需要处理图像、音频、网络、储存、
交互的设备。这从上面的图中也可以看得出来。
操作系统内核要控制这些设备,就要包含每个设备的控制代码。如果操作系统内核被设计
为通用可移植的内核,那是相当可怕的。试想一下,这个世界上有如此多的设备,操作系
统内核代码得多庞大,越庞大就越危险,因为其中一行代码有问题,整个操作系统就崩溃
了。
可是仅仅只有这些问题吗?当然不是,我们还要考虑到后面这几点。
1. 操作系统内核开发人员,不可能罗列世界上所有的设备,并为其写一套控制代码
2. 为了商业目的,有很多设备厂商并不愿意公开设备的编程细节。就算内核开发人员想为
其写控制代码,实际也不可行。
3. 如果设备更新换代,就要重写设备的控制代码,然后重新编译操作系统内核,这样的话
操作很麻烦,操作系统内核开发人员和用户都可能受不了。
以上三点,足于证明这种方案根本不可取。
既然操作系统内核无法包含所有的设备控制代码,那就索性不包含,或者只包含最基本、
最通用的设备控制代码。这样操作系统内核就可以非常通用,非常精巧。
但是要控制设备就必须要有设备的相关控制代码才行,所以我们要把设备控制代码独立出
来,与操作系统内核分开、独立开发,设备控制代码可由设备厂商人员开发。
每个设备对应一个设备控制代码模块,操作系统内核要控制哪个设备,就加载相应的设备
代码模块,以后不使用这个设备了,就可以删除对应的设备控制代码模块。
这种方式,给操作系统内核带来了巨大的灵活性。设备厂商在发布新设备时,只要随之发
布一个与此相关的设备控制代码模块就行了。
设备分类
要想管理设备,先要对其分门别类,在开始分类之前,你不妨先思考一个问题:操作系统
内核所感知的设备,一定要与物理设备一一对应吗?
举个例子,储存设备,其实不管它是机械硬盘,还是 TF 卡,或者是一个设备控制代码模
块,它向操作系统内核表明它是储存设备,但它完全有可能分配一块内存空间来储存数
据,不必访问真正的储存设备。所以,操作系统内核所感知的设备,并不需要和物理设备
对应,这取决于设备控制代码自身的行为。
操作系统内核所定义的设备,可称为内核设备或者逻辑设备,其实这只是对物理计算平台
中几种类型设备的一种抽象。下面,我们在 cosmos/include/knlinc/krldevice_t.h 文件中
对设备进行分类定义,代码如下。
上面定义的这些类型的设备,都是 Cosmos 内核抽象出来的逻辑设备,例如
NETWORK_DEVICE 网络设备,不管它是有线网卡还是无线网卡,或者是设备控制代码虚
拟出来的虚拟网卡。Cosmos 内核都将认为它是一个网络设备,这就是设备的抽象,这样
有利于我们灵活、简便管理设备。
设备驱动
刚才我们解决了设备分类,下面我来研究如何实现分权而治,就是把操作每个设备的相关
代码独立出来,这种方式在业界有一个更专业的名字——设备驱动程序。同时在下面的内
容中,我们将不区分设备驱动程序和驱动程序。
这种“分权而治”的方式,给操作系统内核带了灵活性、可扩展性……可是也带来了新的问
题,有哪些问题呢?
首先是操作系统内核如何表示多个设备与驱动的存在?然后,还有如何组织多个设备和多
个驱动程序的问题,最后我们还得考虑应该让驱动程序提供一些什么支持。
设备
你能说说一个设备包含哪些信息吗?无非是设备类型,设备名称,设备状态,设备 id,设
备的驱动程序等。
我们把这些信息归纳成一个数据结构,在操作系统内核建立这个数据结构的实例变量,这
个设备数据结构的实例变量,一旦建立,就表示操作系统内核中存在一个逻辑设备了。
我们接下来就一起整理一下设备的信息,然后把它们变成一个数据结构,代码如下。
设备的信息比较多,大多是用于组织设备的。这里的设备 ID 结构十分重要,它表示设备的
类型、设备号,子设备号是为了解决多个相同设备的,还有一个指向设备驱动程序的指针,这是用于访问设备时调用设备驱动程序的,只要有人建立了一个设备结构的实例变量,内核就能感知到一个设备存在了。
至于是谁建立了设备结构的实例变量,这个问题我们接着探索。
驱动
操作系统内核和应用程序都不会主动建立设备,那么谁来建立设备呢?当然是控制设备的
代码,也就是我们常说的驱动程序。
那么驱动程序如何表示呢,换句话说,操作系统内核是如何感知到一个驱动程序的存在
呢?
根据前面的经验,我们还是要定义一个数据结构来表示一个驱动程序,数据结构中应该包
含驱动程序名,驱动程序 ID,驱动程序所管理的设备,最重要的是完成功能设备相关功能
的函数,下面我们来定义它,代码如下。
上述代码,你应该很容易看懂。Cosmos 内核每加载一个驱动程序模块,就会自动分配一
个驱动程序数据结构并且将其实例化。
而 Cosmos 内核在首次启动驱动程序时,就会调用这个驱动程序的入口点函数,在这个函
数中驱动程序会分配一个设备数据结构,并用相关的信息将其实例化,比如填写正确的设
备类型、设备 ID 号、设备名称等。
Cosmos 内核负责建立驱动数据结构,而驱动程序又建立了设备数据结构,这一来二去,
就形成了一个驱动程序与 Cosmos 内核“握手”的动作。
设备驱动的组织
有了设备、驱动,我们下面探索一下怎么合理的组织好它们。
组织它们要解决的问题,就是在哪里安放驱动。然后我们还要想好怎么找到它们,下面我
们用一个叫做设备表的数据结构,来组织这些驱动程序数据结构和设备数据结构。
这个结构我已经帮你定义好了,如下所示。
在这段代码的 devtable_t 结构中,devtlst_t 是每个设备类型一个,表示一类设备,但每
一类可能有多个设备,所以在 devtlst_t 结构中,有一个设备计数和设备链表。而你可能想到 Cosmos 中肯定要定义一个 devtable_t 结构的全局变量,代码如下。
上面的设备表的初始化代码已经写好了,如果你大脑中没有设备驱动组织图,可能脑子里
还是有点乱,所以我来帮你画一幅图,如下所示。
上图看似复杂,实则简单,我帮你理一下重点:首先 devtable_t 结构中能找到所有的设备
和驱动,然后从设备能找到对应的驱动,从驱动也能找到其管理的所有设备 ,最后就能实
现一个驱动管理多个设备。
驱动程序功能
我们还有一个问题需要解决,那就是驱动程序,究竟要为操作系统内核提供哪些最基本的
功能支持?
我们已经知道了,写驱动程序就是为了操控相应的设备,所以这得看大多数设备能完成什
么功能了。现代计算机的设备无非就是可以输入数据、处理数据、输出数据,然后完成一
些特殊的功能。
当然,现代计算机的设备很多,能耗是个严重的问题,所以操作系统内核应该能控制设备
能耗。下面我来帮你归纳一下用来驱动程序的几种主要函数,如下。
如上所述,我们可以把每一个操作定义成一个函数,让驱动程序实现这些函数。函数名你
可以随便写,但是函数的形式却不能改变,这是操作系统内核与驱动程序沟通的桥梁。当
然有很多设备本身并不支持这么多操作,例如时钟设备,驱动程序就不必实现相应的操
作。
那么这些函数如何和操作系统内核关联起来呢?还记得 driver_t 结构中那个函数指针数组
吗,如下所示。
重点回顾
现在,我们搞明白了一个典型计算机的结构,里面有很多设备,需要操作系统合理地管
理,而操作系统通过加载驱动程序来管理和使用设备,并为此提供了一系列的机制,这也
是我们这节课的重点。
1. 计算机结构,我们通过了解一个典型的计算机系统结构,明白了设备的多样性。然后我
们对设备做了抽象分类,采用分权而治的方式,让操作系统通过驱动程序来管理设备,同
时又能保证操作系统和驱动程序分离,达到操作系统和设备解耦的目的。
2. 把设备和设备驱动的信息归纳整理,抽象两个对应的数据结构,这两个数据结构在内存
中的实例变量就代码一个设备和对应的驱动。接着我们通过设备表结构组织了驱动和设备
的数据结构。
3. 驱动程序最主要的工作是要操控设备,但这些个操作设备的动作是操作系统调用的,所
以对驱动定义了必须要支持的 12 种标准方法,并对应到函数,这些函数的地址保存在驱动
程序的数据结构中。
设备I/O -- 如何在内核中注册设备?
设备的注册流程
在电脑上插入一个 USB 鼠标时,操作系统会作出怎样的反应呢?
整个过程可以分为5步:
- 操作系统会收到一个中断。
- USB 总线驱动的中断处理程序会执行。
- 调用操作系统内核相关的服务,查找 USB 鼠标对应的驱动程序。
- 操作系统加载驱动程序。
- 驱动程序开始执行,向操作系统内核注册一个鼠标设备。这就是一般操作系统加载驱动的粗略过程。对于安装在主板上的设备,操作系统会枚举设备信息,然后加载驱动程序,让驱动程序创建并注册相应的设备。当然,你还可以手动加载驱动程序。
- 为了简单起见,我们的 Cosmos 不会这样复杂,暂时也不支持设备热拨插功能。我们让 Cosmos 自动加载驱动,在驱动中向 Cosmos 注册相应的设备,这样就可以大大降低问题的复杂度
整个流程,可参考下图:
上图中,完整展示了 Cosmos 自动加载驱动的整个流程,Cosmos 在初始化驱动时会扫描整个驱动表,然后加载表中每个驱动,分别调用各个驱动的入口函数,最后在驱动中建立设备并向内核注册。
驱动程序表
为了简化问题,便于你理解,我们把驱动程序和内核链接到一起,省略了加载驱动程序的过程,因为加载程序不仅仅是把驱动程序放在内存中就可以了,还要进行程序链接相关的操作,这个操作极其复杂,我们先不在这里研究,感兴趣的话你可以自行拓展。
既然我们把内核和驱动程序链接在了一起,就需要有个机制让内核知道驱动程序的存在。这个机制就是驱动程序表,它可以这样设计。
//cosmos/kernel/krlglobal.c
KRL_DEFGLOB_VARIABLE(drventyexit_t,osdrvetytabl)[]={NULL};
drventyexit_t
类型,在上一讲了解过了,它就是一个函数指针类型,这里就是定义了一个函数指针数组,而这个函数指针数组中放的就是驱动程序的入口函数,而内核只需要扫描这个函数指针数组,就可以调用到每个驱动程序了。
有了这个函数指针数组,接着我们还需要写好这个驱动程序的初始化函数,代码如下。
void init_krldriver()
{
//遍历驱动程序表中的每个驱动程序入口函数
for (uint_t ei = 0; osdrvetytabl[ei] != NULL; ei++)
{ //运行一个驱动程序入口
if (krlrun_driverentry(osdrvetytabl[ei]) == DFCERRSTUS)
{
hal_sysdie("init driver err");
}
}
return;
}
void init_krl()
{
init_krlmm();
init_krldevice();
init_krldriver();
//……
return;
}
初始化驱动的代码就写好了。init_krldriver 函数主要的工作就是遍历驱动程序表中的每个驱动程序入口,并把它作为参数传给 krlrun_driverentry 函数。
有了 init_krldriver 函数,还要在 init_krl 函数中调用它,主要调用上述代码中的调用顺序,请注意,一定要先初始化设备表,然后才能初始化驱动程序,否则在驱动程序中建立的设备和驱动就无处安放了。
运行驱动程序
我们使用驱动程序表,虽然省略了加载驱动程序的步骤,但是驱动程序必须要运行,才能工作。接下来我们就详细看看运行驱动程序的全过程。
调用驱动程序入口函数
首先来解决怎么调用驱动程序入口函数。直接调用驱动程序入口函数是不行的,要先给它准备一个重要的参数,也就是驱动描述符指针。
为进一步理解,来写一个函数描述内核加载驱动的过程,后面代码中 drvp 就是一个驱动描述符指针。
drvstus_t krlrun_driverentry(drventyexit_t drventry)
{
driver_t *drvp = new_driver_dsc();//建立driver_t实例变量
if (drvp == NULL)
{
return DFCERRSTUS;
}
if (drventry(drvp, 0, NULL) == DFCERRSTUS)//运行驱动程序入口函数
{
return DFCERRSTUS;
}
if (krldriver_add_system(drvp) == DFCERRSTUS)//把驱动程序加入系统
{
return DFCERRSTUS;
}
return DFCOKSTUS;
}
先调用了 一个 new_driver_dsc 函数,用来建立一个 driver_t 结构实例变量.
然后就是调用传递进来的函数指针,并且把 drvp 作为参数传送进去。接着再进入驱动程序中运行,最后,当驱动程序入口函数返回的时候,就会把这个驱动程序加入到我们 Cosmos 系统中了。
一个驱动程序入口函数的例子
一个驱动程序要能够被操作系统调用,产生实际作用,那么这个驱动程序入口函数,就至少有一套标准流程要走,否则只需要返回一个 DFCOKSTUS 就行了,DFCOKSTUS 是个宏,表示成功的状态。
这个标准流程就是,首先要建立建立一个设备描述符,接着把驱动程序的功能函数设置到 driver_t 结构中的 drv_dipfun 数组中,并将设备挂载到驱动上,然后要向内核注册设备,最后驱动程序初始化自己的物理设备,安装中断回调函数。
看一个驱动程序的实际例子,代码如下。
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_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(20)==DFCERRSTUS)
{
return DFCERRSTUS;
}
return DFCOKSTUS;
}
上述代码是一个真实设备驱动程序入口函数的标准流程,这是一个例子,不能运行,是一个驱动程序框架,这个例子告诉我们,操作系统内核要为驱动程序开发者提供哪些功能接口函数,这在很多通用操作系统上叫作驱动模型。
设备与驱动的联系
上面的例子只是演示流程的,我们并没有写好供驱动程序开发者使用的接口函数,我们这就来写好这些接口函数。
要写的第一个接口就是将设备挂载到驱动上,让设备和驱动产生联系,确保驱动能找到设备,设备能找到驱动。代码如下所示。
drvstus_t krldev_add_driver(device_t *devp, driver_t *drvp)
{
list_h_t *lst;
device_t *fdevp;
//遍历这个驱动上所有设备
list_for_each(lst, &drvp->drv_alldevlist)
{
fdevp = list_entry(lst, device_t, dev_indrvlst);
//比较设备ID有相同的则返回错误
if (krlcmp_devid(&devp->dev_id, &fdevp->dev_id) == TRUE)
{
return DFCERRSTUS;
}
}
//将设备挂载到驱动上
list_add(&devp->dev_indrvlst, &drvp->drv_alldevlist);
devp->dev_drv = drvp;//让设备中dev_drv字段指向管理自己的驱动
return DFCOKSTUS;
}
由于我们的设计一个驱动程序可以管理多个设备,所以在上述代码中,要遍历驱动设备链表中的所有设备,看看有没有设备 ID 冲突。如果没有就把这个设备载入这个驱动中,并把设备中的相关字段指向这个管理自己的驱动,这样设备和驱动就联系起来了
向内核注册设备
一个设备要想被内核感知,最终供用户使用,就要先向内核注册,这个注册过程应该由内核来实现并提供接口,在这个注册设备的过程中,内核会通过设备的类型和 ID,把用来表示设备的 device_t 结构挂载到设备表中。下面我们来写好这部分代码,如下所示。
drvstus_t krlnew_device(device_t *devp)
{
device_t *findevp;
drvstus_t rets = DFCERRSTUS;
cpuflg_t cpufg;
list_h_t *lstp;
devtable_t *dtbp = &osdevtable;
uint_t devmty = devp->dev_id.dev_mtype;
if (devp->dev_drv == NULL)//没有驱动的设备不行
{
return DFCERRSTUS;
}
krlspinlock_cli(&dtbp->devt_lock, &cpufg);//加锁
//遍历设备类型链表上的所有设备
list_for_each(lstp, &dtbp->devt_devclsl[devmty].dtl_list)
{
findevp = list_entry(lstp, device_t, dev_intbllst);
//不能有设备ID相同的设备,如果有则出错
if (krlcmp_devid(&devp->dev_id, &findevp->dev_id) == TRUE)
{
rets = DFCERRSTUS;
goto return_step;
}
}
//先把设备加入设备表的全局设备链表
list_add(&devp->dev_intbllst, &dtbp->devt_devclsl[devmty].dtl_list);
//将设备加入对应设备类型的链表中
list_add(&devp->dev_list, &dtbp->devt_devlist);
dtbp->devt_devclsl[devmty].dtl_nr++;//设备计数加一
dtbp->devt_devnr++;//总的设备数加一
rets = DFCOKSTUS;
return_step:
krlspinunlock_sti(&dtbp->devt_lock, &cpufg);//解锁
return rets;
}
上述代码中,主要是检查在设备表中有没有设备 ID 冲突,如果没有的话就加入设备类型链表和全局设备链表中,最后对其计数器变量加一。完成了这些操作之后,我们在操作设备时,通过设备 ID 就可以找到对应的设备了。
安装中断回调函数
设备很多时候必须要和 CPU 进行通信,这是通过中断的形式进行的,例如,当硬盘的数据读取成功、当网卡又来了数据、或者定时器的时间已经过期,这时候这些设备就会发出中断信号,中断信号会被中断控制器接受,然后发送给 CPU 请求内核关注。
收到中断信号后,CPU 就会开始处理中断,转而调用中断处理框架函数,最后会调用设备驱动程序提供的中断回调函数,对该设备发出的中断进行具体处理。
既然中断回调函数是驱动程序提供的,我们内核就要提供相应的接口用于安装中断回调函数,使得驱动程序开发者专注于设备本身,不用分心去了解内核的中断框架。
来实现这个安装中断回调函数的接口函数,代码如下所示。
//中断回调函数类型
typedef drvstus_t (*intflthandle_t)(uint_t ift_nr,void* device,void* sframe);
//安装中断回调函数接口
drvstus_t krlnew_devhandle(device_t *devp, intflthandle_t handle, uint_t phyiline)
{
//调用内核层中断框架接口函数
intserdsc_t *sdp = krladd_irqhandle(devp, handle, phyiline);
if (sdp == NULL)
{
return DFCERRSTUS;
}
cpuflg_t cpufg;
krlspinlock_cli(&devp->dev_lock, &cpufg);
//将中断服务描述符结构挂入这个设备结构中
list_add(&sdp->s_indevlst, &devp->dev_intserlst);
devp->dev_intlnenr++;
krlspinunlock_sti(&devp->dev_lock, &cpufg);
return DFCOKSTUS;
}
上述代码中,krlnew_devhandle 函数有三个参数,分别是安装中断回调函数的设备,驱动程序提供的中断回调函数,还有一个是设备在中断控制器中断线的号码。
krlnew_devhandle 函数中一开始就会调用内核层的中断框架接口,你发现了么?这个接口还没写呢,所以我们马上就去写好它,但是我们不应该在 krldevice.c 文件中写,而是要在 cosmos/kernel/ 目录下建立一个 krlintupt.c 文件,在这个文件模块中写,代码如下所示。
typedef struct s_INTSERDSC{
list_h_t s_list; //在中断异常描述符中的链表
list_h_t s_indevlst; //在设备描述描述符中的链表
u32_t s_flg; //标志
intfltdsc_t* s_intfltp; //指向中断异常描述符
void* s_device; //指向设备描述符
uint_t s_indx; //中断回调函数运行计数
intflthandle_t s_handle; //中断处理的回调函数指针
}intserdsc_t;
intserdsc_t *krladd_irqhandle(void *device, intflthandle_t handle, uint_t phyiline)
{ //根据设备中断线返回对应中断异常描述符
intfltdsc_t *intp = hal_retn_intfltdsc(phyiline);
if (intp == NULL)
{
return NULL;
}
intserdsc_t *serdscp = (intserdsc_t *)krlnew(sizeof(intserdsc_t));//建立一个intserdsc_t结构体实例变量
if (serdscp == NULL)
{
return NULL;
}
//初始化intserdsc_t结构体实例变量,并把设备指针和回调函数放入其中
intserdsc_t_init(serdscp, 0, intp, device, handle);
//把intserdsc_t结构体实例变量挂载到中断异常描述符结构中
if (hal_add_ihandle(intp, serdscp) == FALSE)
{
if (krldelete((adr_t)serdscp, sizeof(intserdsc_t)) == FALSE)
{
hal_sysdie("krladd_irqhandle ERR");
}
return NULL;
}
return serdscp;
}
krladd_irqhandle 函数,它的主要工作是创建了一个 intserdsc_t 结构,用来保存设备和其驱动程序提供的中断回调函数。同时,我想提醒你,通过 intserdsc_t 结构也让中断处理框架和设备驱动联系起来了。
具体来说就是,中断处理框架既能找到对应的 intserdsc_t 结构,又能从 intserdsc_t 结构中得到中断回调函数和对应的设备描述符,从而调用中断回调函数,进行具体设备的中断处理。
驱动加入内核
当操作系统内核调用了驱动程序入口函数,驱动程序入口函数就会进行一系列操作,包括建立设备,安装中断回调函数等等,再之后就会返回到操作系统内核。
接下来,操作系统内核会根据返回状态,决定是否将该驱动程序加入到操作系统内核中。你可以这样理解,所谓将驱动程序加入到操作系统内核,无非就是将 driver_t 结构的实例变量挂载到设备表中。
写这个实现挂载功能的函数,如下所示。
drvstus_t krldriver_add_system(driver_t *drvp)
{
cpuflg_t cpufg;
devtable_t *dtbp = &osdevtable;//设备表
krlspinlock_cli(&dtbp->devt_lock, &cpufg);//加锁
list_add(&drvp->drv_list, &dtbp->devt_drvlist);//挂载
dtbp->devt_drvnr++;//增加驱动程序计数
krlspinunlock_sti(&dtbp->devt_lock, &cpufg);//解锁
return DFCOKSTUS;
}
由于驱动程序不需要分门别类,所以我们只把它挂载到设备表中一个全局驱动程序链表上就行了,最后简单地增加一下驱动程序计数变量,用来表明有多少个驱动程序。
小结
一个驱动程序开始是由内核加载运行,然后调用由内核提供的接口建立设备,最后向内核注册设备和驱动,完成驱动和内核的握手动作。现在我们来梳理一下这节课的重点。
首先我们一开始从全局出发,了解了设备的建立流程
。然后为了简化内核加载驱动程序的复杂性,我们设计了一个驱动程序表。
最后,按照驱动程序的开发流程,我们给驱动程序开发者提供了一系列接口,它们是建立注册设备、设备加入驱动、安装中断回调函数,驱动加入到系统等,这些共同构成了一个最简化的驱动模型。
设备如何处理内核I/O包?
什么是 I/O 包 就像你要给部门下达任务时,需要准备材料报表之类的东西。同样,内核要求设备做什么 事情,完成什么功能,必须要告诉设备的驱动程序。
内核要求设备完成任务,无非是调用设备的驱动程序函数,把完成任务的细节用参数的形 式传递给设备的驱动程序。 由于参数很多,而且各种操作所需的参数又不相同,所以我们就想到了更高效的管理方 法,也就是把各种操作所需的各种参数封装在一个数据结构中,称为 I/O 包,这样就可以 统一驱动程序功能函数的形式了。
思路理清以后,现在我们来设计这个数据结构,如下所示。
现在你可能还无法从 objnode_t 这个名字看出它跟 I/O 包的关系。但你从刚才的代码里可 以看出,objnode_t 的数据结构中包括了各个驱动程序功能函数的所有参数。
等我们后面讲到 API 接口时,你会发现,objnode_t 结构不单是完成了 I/O 包传递参数的 功能,它在整个 I/O 生命周期中,都起着重要的作用。
这里为了好理解,我们就暂且把 objnode_t 结构当作 I/O 包来看。 创建和删除 I/O 包 刚才,我们已经定义了 I/O 包也就是 objnode_t 结构,但若是要使用它,就必须先把它建 立好。 根据以往的经验,你应该已经猜到了,这里创建 I/O 包就是在内存中建立 objnode_t 结构 的实例变量并初始化它。由于这是一个全新的模块,所以我们要先在 cosmos/kernel/ 目 录下建立一个新的 krlobjnode.c 文件,在这个文件中写代码,如下所示。
上述代码非常简单,主要完成了建立、删除 objnode_t 结构这两件事,其实说白了就是分 配和释放 objnode_t 结构的内存空间。
这里再一次体现了内存管理组件在操作系统内核之中的重要性,objnode_t_init 函数会初 始化 objnode_t 结构中的字段,因为其中有自旋锁、链表、信号量,而这些结构并不能简 单地初始为 0,否则可以直接使用 memset 之类的函数把那个内存空间清零就行了。
向设备发送 I/O 包
现在我们假定在上层接口函数中,已经建立了一个 I/O 包(即 objnode_t 结构),并且把 操作码、操作对象和相关的参数信息填写到了 objnode_t 结构之中。那么下一步,就需要 把这个 I/O 发送给具体设备的驱动程序,以便驱动程序完成具体工作。
我们需要定义实现一个函数,专门用于完成这个功能,它标志着一个设备驱动程序开始运 行,经它之后内核就实际的控制权交给驱动程序,由驱动程序代表内核操控设备。
下面,我们就来写好这个函数,不过这个函数属于驱动模型函数,所以要在 krldevice.c 文 件中实现这个函数。代码如下所示。
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 设备驱 动程序。
systick 设备驱动程序的整体框架
在前面的课程中,我们已经了解了在 Cosmos 系统下,一个设备驱动程序的基本框架,但 是我们没有深入具体化。
所以,这里我会带你从全局好好了解一个真实的设备,它的驱动程序应该至少有哪些函 数。由于这是个驱动程序,我们需要在 cosmos/drivers/ 目录下建立一个 drvtick.c 文 件,在 drvtick.c 文件中写入以下代码,如下所示。
以上就是一个驱动程序必不可少的函数,在各个函数可以返回一个错误状态,而不做任何 实际工作,但是必须要有这个函数。这样在内核发来任何设备功能请求时,驱动程序才能 给予适当的响应。这样,一个驱动程序的整体框架就确定了。
写好了驱动程序的整体框架,我们这个驱动就完成了一半。下面我们来一步一步来实现 它。
systick 设备驱动程序的入口
我们先来写好 systick 设备驱动程序的入口函数。那这个函数用来做什么呢?其实我们在上 一节课就详细讨论过,无非是建立设备,向内核注册设备,安装中断回调函数等操作,所 以这里不再赘述。 我们直接写出这个函数,如下所示。
你可能非常熟悉这部分代码,没错,这正是上节课中,我们的那个驱动程序入口函数的实 例。
不过在上节课里,我们主要是要展示一个驱动程序入口函数的流程。这里却是要投入工作 的真实设备驱动。
最后的 krlenable_intline 函数,它的主要功能是开启一个中断源上的中断。而 init_8254 函数则是为了初始化 8254,它就是一个古老且常用的定时器。这两个函数非常简单,我已 经帮写好了。
但是这样还不够,有了驱动程序入口函数,驱动程序并不会自动运行。根据前面我们的设 计,需要把这个驱动程序入口函数放入驱动表中。 下面我们就把这个 systick_entry 函数,放到驱动表里,代码如下所示。
配置设备和驱动
在驱动程序入口函数中,除了那些标准的流程之外,我们还要对设备和驱动进行适当的配 置,就是设置一些标志、状态、名称、驱动功能派发函数等等。有了这些信息,设备才能 加入到驱动程序中,然后注册到内核,这样才能被内核所识别。 好,让我们先来实现设置驱动程序的函数,它主要设置设备驱动程序的名称、功能派发函 数,代码如下。
上述代码的功能并不复杂,我一说你就能领会。systick_set_driver 函数,无非就是将 12 个驱动功能函数的地址,分别设置到 driver_t 结构的 drv_dipfun 数组中。其中,驱动功 能函数在该数组中的元素位置,正好与 IO 操作码一一对应,当内核用 IO 操作码调用驱动 时,就是调用了这个数据中的函数。最后,我们将驱动程序的名称设置为 systick0drv。
新建的设备也需要配置相关的信息才能工作,比如需要指定设备,设备状态与标志,设备 类型、设备名称这些信息。尤其要注意的是,设备类型非常重要,内核正是通过类型来区 分各种设备的,下面我们写个函数,完成这些功能,代码如下所示。
上述代码中,systick_set_device 函数需要两个参数,但是第二个参数暂时没起作用,而第 一个参数其实是一个 device_t 结构的指针,在 systick_entry 函数中调用 new_device_dsc 函数的时候,就会返回这个指针。后面我们会把设备加载到内核中,那 时这个指针指向的设备才会被注册。
打开与关闭设备
其实对于 systick 这样设备,主要功能是定时中断,还不能支持读、写、控制、刷新、电源 相关的功能,就算内核对 systick 设备发起了这样的 I/O 包,systick 设备驱动程序相关的 功能函数也只能返回一个错误码,表示不支持这样的功能请求。
但是,打开与关闭设备这样的功能还是应该要实现。下面我们就来实现这两个功能请求函 数,代码如下所示。
这样,打开与关闭设备的功能就实现了,只是简单地增加与减少设备的引用计数,然后返 回成功完成的状态就行了。而增加与减少设备的引用计数,是为了统计有多少个进程打开 了这个设备,当设备引用计数为 0 时,就说明没有进程使用该设备。
systick 设备中断回调函数
对于 systick 设备来说,重要的并不是打开、关闭,读写等操作,而是 systick 设备产生的 中断,以及在中断回调函数中执行的操作,即周期性的执行系统中的某些动作,比如更新 系统时间,比如控制一个进程占用 CPU 的运行时间等,这些操作都需要在 systick 设备中 断回调函数中执行。
按照前面的设计,systick 设备每秒钟产生 1000 次中断,那么 1 秒钟就会调用 1000 次这 个中断回调函数,这里我们只要写出这个函数就行了,因为安装中断回调函数的思路,我 们在前面的课程中已经说过了(可以回顾上节课),现在我们直接实现这个中断函数,代 码可以像后面这样写。
下面,我们打开终端切到 Cosmos 目录下,执行 make vboxtest 指令,如果不出意外, 我们将会中看到如下界面。
上图中的信息,会不断地滚动出现,信息中包含设备名称和中断号,这标志着我们中断回 调函数的运行正确无误。
当然,如果我们费了这么功夫搞了中断回调函数,就只是为了输出信息,那也太不划算 了,我们当然有更重要的事情要做,你还记得之前讲过的进程知识吗?这里我再帮你理一 理思路。
我们在每个进程中都要主动调用进程调度器函数,否则进程就会永远霸占 CPU,永远运行 下去。这是因为,我们没有定时器可以周期性地检查进程运行了多长时间,如果进程的运 行时间超过了,就应该强制调度,让别的进程开始运行。
更新进程运行时间的代码,我已经帮你写好了,你只需要在这个中断回调函数中调用就好 了,代码如下所示。
这里的 krlthd_inc_tick 函数需要一个进程指针的参数,而 krlsched_retn_currthread 函数 是返回当前正在运行进程的指针。在 krlthd_inc_tick 函数中对进程的 tick 值加 1,如果大 于 20(也就是 20 毫秒)就重新置 0,并进行调度。
下面,我们把内核层初始化函数恢复到原样,重新打开终端切到 cosmos 目录下,执行 make vboxtest 指令,我们就将会看到如下界面。
我们可以看到,进程 A、进程 B,还有调度器交替输出的信息。这已经证明我们更新进程 运行时间,检查其时间是否用完并进行调度的代码逻辑,都是完全正确的,恭喜你走到了 这一步! 至此,我们的 systick 驱动程序就实现了,它非常简单,但却包含了一个驱动程序完整实 现。同时,这个过程也一步步验证了我们对驱动模型的设计是正确的。
瞧Linux:如何获取所有设备信息?
感受一下 Linux 下的设备信息
Linux 的设计哲学就是一切皆文件,各种设备在 Linux 系统下自然也是一个个文件。不过 这个文件并不对应磁盘上的数据文件,而是对应着存在内存当中的设备文件。实际上,我们对设备文件进行操作,就等同于操作具体的设备。 既然我们了解万事万物,都是从最直观的感受开始的,想 要理解 Linux 对设备的管理,自 然也是同样的道理。那么 Linux 设备文件在哪个目录下呢?其实现在我们在 /sys/bus 目录 下,就可以查看所有的设备了。 Linux 用 BUS(总线)组织设备和驱动,我们在 /sys/bus 目录下输入 tree 命令,就可以 看到所有总线下的所有设备了,如下图所示。
上图中,显示了部分 Linux 设备文件,有些设备文件是链接到其它目录下文件,这不是重 点,重点是你要在心中有这个目录层次结构,即总线目录下有设备目录,设备目录下是设 备文件。
数据结构
我们接着刚才的图往下说,我们能感觉到 Linux 的驱动模型至少有三个核心数据结构,分 别是总线、设备和驱动,但是要像上图那样有层次化地组织它们,只有总线、设备、驱动 这三个数据结构是不够的,还得有两个数据结构来组织它们,那就是 kobject 和 kset,下 面我们就去研究它们。
kobject 与 kset
kobject 和 kset 是构成 /sys 目录下的目录节点和文件节点的核心,也是层次化组织总 线、设备、驱动的核心数据结构,kobject、kset 数据结构都能表示一个目录或者文件节 点。下面我们先来研究一下 kobject 数据结构,代码如下所示。
每一个 kobject,都对应着 /sys 目录下(其实是 sysfs 文件系统挂载在 /sys 目录下) 的 一个目录或者文件,目录或者文件的名字就是 kobject 结构中的 name。 我们从 kobject 结构中可以看出,它挂载在 kset 下,并且指向了 kset,那 kset 是什么 呢?我们来分析分析,它是 kobject 结构的容器吗?
其实是也不是,因为 kset 结构中本身又包含一个 kobject 结构,所以它既是 kobject 的容 器,同时本身还是一个 kobject。kset 结构代码如下所示。
看到这里你应该知道了,kset 不仅仅自己是个 kobject,还能挂载多个 kobject,这说明 kset 是 kobject 的集合容器。在 Linux 内核中,至少有两个顶层 kset,代码如下所示。
我知道,你可能很难想象许多个 kset 和 kobject 在逻辑上形成的层次结构,所以我为你画 了一幅图,你可以结合这张示意图理解这个结构。
上图中展示了一个类似文件目录的结构,这正是 kset 与 kobject 设计的目标之一。kset 与 kobject 结构只是基础数据结构,但是仅仅只有它的话,也就只能实现这个层次结构, 其它的什么也不能干,根据我们以往的经验可以猜出,kset 与 kobject 结构肯定是嵌入到 更高级的数据结构之中使用,下面我们继续探索。
总线
kset、kobject 结构只是开胃菜,这个基础了解了,我们还要回到研究 Linux 设备与驱动 的正题上。我们之前说过了,Linux 用总线组织设备和驱动,由此可见总线是 Linux 设备 的基础,它可以表示 CPU 与设备的连接,那么总线的数据结构是什么样呢?我们一起来看 看。
Linux 把总线抽象成 bus_type 结构,代码如下所示。
可以看出,上面代码的 bus_type 结构中,包括总线名字、总线属性,还有操作该总线下 所有设备通用操作函数的指针,其各个函数的功能我在代码注释中已经写清楚了。
从这一点可以发现,总线不仅仅是组织设备和驱动的容器,还是同类设备的共有功能的抽 象层。下面我们来看看 subsys_private,它是总线的驱动核心的私有数据,其中有我们想 知道的秘密,代码如下所示。
看到这里,你应该明白 kset 的作用了,我们通过 bus_kset 可以找到所有的 kset,通过 kset 又能找到 subsys_private,再通过 subsys_private 就可以找到总线了,也可以找到 该总线上所有的设备与驱动。
设备
虽然 Linux 抽象出了总线结构,但是 Linux 还需要表示一个设备,下面我们来探索 Linux 是如何表示一个设备的。 其实,在 Linux 系统中设备也是一个数据结构,里面包含了一个设备的所有信息。代码如 下所示。
device 结构很大,这里删除了我们不需要关心的内容。另外,我们看到 device 结构中同 样包含了 kobject 结构,这使得设备可以加入 kset 和 kobject 组建的层次结构中。 device 结构中有总线和驱动指针,这能帮助设备找到自己的驱动程序和总线。
驱动
有了设备结构,还需要有设备对应的驱动,Linux 是如何表示一个驱动的呢?同样也是一个 数据结构,其中包含了驱动程序的相关信息。其实在 device 结构中我们就看到了,就是 device_driver 结构,代码如下。
在 device_driver 结构中,包含了驱动程序的名字、驱动程序所在模块、设备探查和电源 相关的回调函数的指针。在 driver_private 结构中同样包含了 kobject 结构,用于组织所 有的驱动,还指向了驱动本身,你发现没有,bus_type 中的 subsys_private 结构的机制 如出一辙。
文件操作函数
前面我们学习的都是 Linux 驱动程序的核心数据结构,我们很少用到,只是为了让你了解 最基础的原理。 其实,在 Linux 系统中提供了更为高级的封装,Linux 将设备分成几类分别是:字符设 备、块设备、网络设备以及杂项设备。具体情况你可以参考我后面梳理的图表
这些类型的设备的数据结构,都会直接或者间接包含基础的 device 结构,我们以杂项设备 为例子研究一下,Linux 用 miscdevice 结构表示一个杂项设备,代码如下。
miscdevice 结构就是一个杂项设备,它一般在驱动程序代码文件中静态定义。我们清楚地 看见有个 this_device 指针,它指向下层的、属于这个杂项设备的 device 结构。 但是这里重点是 file_operations 结构,设备一经注册,就会在 sys 相关的目录下建立设 备对应的文件结点,对这个文件结点打开、读写等操作,最终会调用到驱动程序对应的函 数,而对应的函数指针就保存在 file_operations 结构中,我们现在来看看这个结构。
file_operations 结构中的函数指针有 31 个,我删除了我们不熟悉的函数指针,我们了解 原理,不需要搞清楚所有函数指针的功能。 那么,Linux 如何调用到这个 file_operations 结构中的函数呢?我以打开操作为例给你讲 讲,Linux 的打开系统调用接口会调用 filp_open 函数,filp_open 函数的调用路径如下所 示。
看到这里,我们就知道了,file_operations 结构的地址存在一个文件的 inode 结构中。在 Linux 系统中,都是用 inode 结构表示一个文件,不管它是数据文件还是设备文件。 到这里,我们已经清楚了文件操作函数以及它的调用流程