面向对象地分析Linux内核设备驱动(1):——Linux内核驱动中面向对象的基本规则和实现方法

时间:2022-06-02 02:22:47

Linux内核驱动中面向对象的基本规则和实现方法


-         内核版本 Linux Kernel 2.6.34, 与 Robert.Love的《Linux Kernel Development》(第三版)所讲述的内核版本一样

-         源代码下载路径: https://www.kernel.org/pub/linux/kernel/v2.6/linux-2.6.34.tar.bz2

1. 引言——为什么要把面向对象的概念引入Linux内核


1). 管理大型软件的复杂度

- 众所周知,现代的软件项目变得越来越复杂,Linux内核更是世界上最大最复杂的软件工程。引用自《代码大全》中的观点,面向对象的设计思想是一种管理软件系统复杂性的手段。


- 人的精力有限,一个程序员在同一时间,只专注于软件系统的一小部分,才能最大的发挥工作效率。构建软件就像设计大型建筑,一个时间点上,我们聚焦在建筑结构蓝图设计时,就不要过分地将注意力分散的建筑内的电线布局,第N层的排水管道如何施工这些细节问题上,而是应该聚焦在整体设计上。


- 面向对象思想就是一种在代码编写之上的软件系统结构设计的思想。面向对象的语言用于描述系统框架结构是怎么样的,需要什么模块,模块之间的关系如何,如何遵守开闭准则,方便后期的维护和开发等一些设计意图层次上的问题。


- 面向对象的设计思想不太关心xxx函数实现如何初始化,要注册什么结构,要把xxx寄存器状态设置为0等流程细节的问题。这些应该是属于面向过程设计时的问题。


- 面向过程与面向对象的思想用途不同,没有好坏之分。面向对象思想更倾向于程序之上的顶层设计与程序系统结构设计,然后真正要实现一个函数细节的时候,还是需要面向过程地分析细节如何实现,需要初始化哪些变量,注册哪些结构,设置哪些寄存器等面向过程的问题。


- 面向对象的思想和语言无关,并不是C++或者JAVA 、Python等语言才有的。面向对象思想,是随着软件系统的复杂度越来越高,面对大规模软件系统设计的问题,而提出的一种管理大型软件系统设计的思想。只是在C语言出现时,计算机软硬件系统还在起步阶段,面向对象的思想尚未发展(可能Keep it simple and stupid 原则也是让C语言语法尽量保持简单的原因),因而C语言中缺乏面向对象相关的核心关键词语法的支持。而JAVA、Python等一些1990年代之后问世的语言,受到C++语言影响以及面向对象思想的逐渐流行,在语法层面就提供了面向对象的核心关键词支持,可以说在处理面向对象问题上具有先天优势。


- 虽然C语言不支持很多面向对象的核心关键词,但是随着Linux内核,Ffmpeg,Nginx等大规模以C语言编写的开源软件项目的发展与推广,C语言遇到的软件复杂度增加以及系统设计与系统长期维护的问题,与JAVA、C++编程遇到的复杂度问题是想通的。并且,面向对象思想也是由于开发者们在开发过程中遇到瓶颈才提出来的,这些问题,不管是用C语言编程还是JAVA编程,都会客观存在。因而用C语言模拟JAVA等面向对象的语言,采用面向对象的思想进行系统顶层设计是很有必要的。


- JAVA是一种类似C语言的命令式语言(用于区别Lisp等函数式编程语言),并且是设计良好的纯面向对象的语言。JAVA有不少关键词与C语言相同,并且JAVA标准制定委员会的很多成员也是C/C++标准制定委员会的。因而借鉴JAVA的面向对象思想,分析编写面向对象的C语言程序,是相当有帮助的。JAVA不同于C++, C++现在的定位是多种范式语言,细节太多,在面向对象特性的设计上也不够友好方便。所以我更倾向于借鉴JAVA的编程思想来分析编程面向对象的C语言代码。


- 文章中很多面向对象思想的出处,都来自JAVA。作为Linux和C语言嵌入式系统相关的开发者,可能大部分缺乏JAVA的编程经验。所以希望读者抽空学习一下JAVA,做一些小练习,拓宽编程的知识面,这样更能够领会面向对象设计的精髓,还能提出一些自己的新的看法。我认为,JAVA实际上是在C语言基础上的的一种改进,很多关键词与C相同,但是又弥补了C语言的不少缺陷,并且专注在面向对象的设计上。


- 本文也是希望起到抛砖引玉的作用,如有概念性问题,还请批评指正。

 

2). 良好的沟通需要一套通用的语言规则

- 将面向对象的概念引入基于Linux系统的C语言程序开发是很有必要的。虽然面向对象思想会带来很多新的概念术语(继承,多态等),很多做Linux驱动开发的工程师都是电子工程相关专业出身,这些概念可能刚接触会稍显晦涩,但是习惯性以面向对象思想分析大规模软件系统之后,能够帮你快速得掌握整个系统的结构以及原作者的设计意图,而不会陷入到一个个API的实现细节中,只见树木,不见森林。接手维护一个大型软件项目,首先要知道原作者的意图,即知道原作者为什么要这么编程,才能理清楚软件设计思路,进而修改扩展源代码。


- 面向对象的术语,是一种通用的规则,大家都掌握该规则,然后按照规则上的术语沟通,就能够用简短的语言表述出程序的意图。例如面向对象思想中的术语——继承基类,实现多态函数,如果用面向过程思想描述就是——定义一个XXX结构体,再定义XX和YY函数,用XX和YY函数填充XXX结构体中的A函数指针和B函数指针,再在初始化函数中调用C函数注册这个XXX结构体。不同思想所用术语的繁简程度,高下立判。过长的语言描述,容易带来更多的误解,信息丢失,理解不全等沟通障碍,这时,专用术语的优势就体现出来了。


- 面向对象思想往往和设计模式是分不开的。比如Linux内核中的通知链系统,用设计模式的术语来说,叫观察者模式,有学过该设计模式的读者,立马明白程序大概做了什么。但是如果不了解这一套语言沟通规则,就代码讲代码,可能又是一堆这样的过程描述性语言——定义一个xxx头,定义xx结构体,设置xx结构体的回调函数,回调函数输入参数是什么,返回什么,注册xx结构到xxx头……

 

3). Linux内核源码是训练面向对象思维的实战场

- 单纯地学习面向对象思想或者设计模式,如何不结合实际的代码来分析具体案例,过一段时间可能就会忘记书上讲了什么东西。


- 平时编写小规模程序时候,只需要一个人,不需要面向对象的思想就能完成,因而觉得面向对象这些东西都是书上的理论,实际上又用不着,但是一旦遇到Linux内核源码这种复杂级别的程序,就不知如何下手,容易一脸懵逼。


- Linux内核是世界上最大的软件工程项目之一,经过了20多年的发展和完善,内部子系统结构经过了不断重构(refracting)和反复迭代设计,其代码质量也是越来越高,这其中肯定从面向对象的编程思想中借鉴模仿了很多东西。当然阅读Linux内核源码时,由于历史原因,还是会有很多代码的命名,架构和设计风格不那么完美(例如videobuf的第一版)。所以阅读内核源码的时候,要学会其精华,同时也要抛弃一些不良的设计。


- JAVA Python等面向对象语言有大量的开源框架,让我们从实战中体验面向对象思想和设计模式。C语言的框架类库可能没有JAVA那么丰富,但是Linux内核作为C语言的代表作品,其中的设计思想是很值得用面向对象的语言分析一遍的,尤其是设备驱动相关的代码,有很多层抽象才变成了用户态都喜爱的文件。


- 其实抛开Linux内核中动态运行,维护系统运转的线程,其中大部分驱动代码都是静态存在于内存,等待被调用的。这样看来Linux内核中维持系统运转的进程就像JAVA虚拟机,而内核中等待被调用的代码,就像JDK的框架和类库,需要用户态去调用,也需要内核态驱动开发者利用框架进一步扩展。这样看来JDK与Linux内核设计思想也有就有了很大共通之处,用面向对象思想分析Linux内核设备驱动也就是一种高屋建瓴,了解各个子系统结构框架的通用思想。


- 网络上有一些C语言面向对象思想编程的文章,但是只是零零散散地整理了一些观点,不够系统化,案例也过于简单。所以我希望结合Linux内核这个大型的实际软件系统,更加系统化地在C语言编程中描述和应用面向对象思想。


- 综上所述,作为Linux系统C语言开发者,带着面向对象的思想,从不同的视角来学习Linux内核吧!

 

2.抽象与封装

 

-         熟悉结构化C语言编程的读者,对抽象与封装应该不陌生,在此简要带过,抽象和封装是面向对象编程思想的基础。

 

-         抽象即抽出事物最本质的特征来从某个方面描述这个事物。例如,牧羊犬和藏獒,它们抽象出来都是狗,都有会“汪汪汪”叫,会吃骨头等狗所拥有的行为特征。对于不需要分辨其到底是牧羊犬还是藏獒的用户,只需要知道一个物体是狗,那么肯定会联想到它会“汪汪汪”地叫这个特点。

 

-         在C语言数据结构中,我们所描述的ADT抽象数据类型,就是对各种数据对象模型的抽象,在此不多累述。


-         封装,在C语言编程中,大部分时候用一个函数调用(API)将一个复杂过程的细节屏蔽起来,用户不需要了解细节,只需要调用该函数就能实现相应的行为。例如吃饭函数,将盛饭,动筷子,夹菜,张嘴,咀嚼,下咽等细节屏蔽起来,我们只需要调用吃饭函数,默认就实现了一遍这样的流程。

 

-         面向对象思想中的封装使用更广泛,即一个对象类(C语言中用结构体代替),需要隐藏用户不需要也不应该知道的行为和属性。用户在访问对象时,不需要了解被封装的对象和属性,就能使用该对象类,同时对象类也应该通过权限设置,禁止用户过多地了解被封装的对象属性与行为。

 

-         总之,抽象与封装的思想都是为了让用户不需要了解对象过多的细节,就能直接通过API来使用对象,从而达到模块化编程,,程序员分工合作,各自负责维护自己负责模块对象细节的作用。

 

3. 继承与接口


1). 基类、子类与继承关系

- 在面向对象(OOP)程序设计中,当我们定义一个class的时候,可以从某个现有的class继承,新的class称为子类(Subclass),而被继承的class称为基类、父类或超类(Baseclass、Super class)。


- 典型的继承关系如图1所示,动物是一个基类,猫、狗和老鼠都是动物的子类,子类拥有父类的特征,我们称子类继承了父类的特征。比如猫、狗和老鼠都继承了动物都需要进食获取能量,能够发出叫声特征等。


- 继承描述的是一种IS-A的关系,例如C继承了B,那么我们称B是基类,C是子类,C IS B(例如:猫是动物),但是我们不能说B IS C(比如:动物是猫)。


- 继承关系和多态函数结合起来,就很容易达到管理系统对象复杂度的用途。例如,一个对象的实例,无论这个对象实例是猫、狗还是老鼠,我们只需要知道它是动物就OK了,我们可以把猫和狗之间当做它们的基类对象——动物类的实例来访问,调用动物发出叫声的函数,经过叫声函数在猫和狗中的多态函数实现,如果对象是猫,则会发出“喵喵”的叫声,如果对象是狗,则发出“汪汪”的叫声。

面向对象地分析Linux内核设备驱动(1):——Linux内核驱动中面向对象的基本规则和实现方法

Figure 1 典型的继承关系

2). Linux设备驱动中的继承关系实现

 

- 在Linux 内核C代码中,class类都用struct结构体来模拟

 

- Linux内核设备驱动中,字符设备模型是典型的基类,而video_device、 framebuffer、miscdevice都是字符设备,满足IS-A的关系,因而都继承了字符设备的特性(支持字符设备open, ioctl, read,write等典型的访问方法函数), 都是字符设备的子类。

 

-         字符设备基类与video_device、 framebuffer、miscdevice等继承体系关系的UML描述图如何2所示。由于C语言并没有严格的继承关系语法支持,加上多级继承的缘故,所以实现这种继承关系需要一些C语言技巧,细节上需要仔细钻研代码,推敲。但是细节上的障碍并不阻碍我们从面向对象这种较高的层次来阅读和管理Linux设备驱动的代码,理解这种继承关系。

 

-         用面向对象思想分析Linux内核,重点是理解代码模块之间的关系和设计思想、意图,至于如何处理继承关系的代码细节,实际上内核各个子系统框架已经通过精妙的C代码地处理过了,虽然不是严格的面向对象,但是思路上大致相同。

 

-        字符设备对象struct  cdev在include/linux/cdev.h中声明, Linux内核中相当多的驱动类型都抽象成字符设备cdev(就如同猫、狗抽象成动物),cdev通过和文件系统的inode节点关联,对用户态而言,抽象成字符设备类型的文件(这也是为什么用户态看来,所有设备都是抽象的问题)。

 

-        struct  file_operations是字符设备cdev最重要的组成部分,这个组件包含了cdev的核心函数方法(open/read/write/ioctl等),继承字符设备的子类都需要实现自己的struct  file_operations方法,以实现子类的多态函数

 

-         由于字符设备cdev类的继承体系以及其struct  file_operations中函数的多态实现,所以用户态程序可以通过访问字符设备cdev的方法来访问framebuffer、video_device等各种具体的设备驱动(类比于访问动物的叫声函数的方法,来调用具体的动物,如猫、狗的叫声函数)。

 

-         综合而言,继承关系最重要的优点就是,通过基类对象以及多态函数来访问具体子类对象实例

 

面向对象地分析Linux内核设备驱动(1):——Linux内核驱动中面向对象的基本规则和实现方法

Figure 2 Linux内核字符设备驱动之间的继承关系


- Linux内核中用C语言实现继承关系的方法和技巧有以下几种:

 

具体子类实现和基类的抽象差异性不大,继承体系只有一级继承,不需要做过多扩展时,在基类函数中加入空指针priv域即可。子类的私有特殊属性对象,可以放到空指针priv即可,子类相关的函数中,可以通过自定义的解析方法,强制转换,解析priv对象。

struct base_dev {
int attr;
int (*func1)();
void *priv;
}




*具体子类实现和基类的抽象差异性较大,继承体系只有一级继承,需要扩展基类时,可以将基类对象嵌入到子类中,在访问到具体的子类时,通过内核特有的container_of()类型的函数,获取子类对象。


例如图3所示video_device对象的声明:

在得到基类对象cdev之后,通过 container_of(vdev, struct video_device, cdev) 可以在vdev中获取structvideo_device对象的实例,进而访问struct v4l2_file_operations中的相关多态文件操作函数。


面向对象地分析Linux内核设备驱动(1):——Linux内核驱动中面向对象的基本规则和实现方法

Figure 3  video_device对象通过嵌入cdev从而继承cdev类

 

具体子类实现和基类的抽象差异性较大,继承体系只有多级(一般只有2), 需要一个抽象层来管理基类与子类的继承关系.

例如; framebuffer对象,具体的例如vga16设备的framebuffer,是用struct fb_info来描述的。

而在具体的vga16fb设备驱动子类中,在init加载时,都会调用register_framebuffer()函数将 vga16fb的struct fb_info描述对象注册到registered_fb[32]这个全局数组中(其实用链表更好,支持动态扩展,就可以超过32个fb对象的限制了),并且在会创建一个以FB_MAJOR为主设备号的次设备节点(创建节点,意味着有一个字符设备cdev对象实例化了)。

而在framebuffer子类对象的初始化函数fbmem_init()中,已经创建了一个framebuffer的字符设备cdev的子类对象,并设置了FB_MAJOR的主节点号。

这样在用户态通过 framebuffer的主设备节点字符设备cdev 的子类对象实例-- > cdev 注册的fb_fops 函数方法 -- > 数组registered_fb[32] 中的fb_info 子类对象实例 --> 调用fbops中的文件操作多态函数。

通过这个调用路径,从cdev的抽象对象访问到fb_info的子类具体对象,从而实现了多级继承体系以及多态函数的调用。

 

- 综上所述,Linux内核C语言编程,根据继承级数不同,实际对象和抽象对象差异不同,实现继承和多态的方法也存在多样化,但是宏观上的思路是一样的,用面向对象的语言来说,就是要继承基类,实现多态,让用户态程序能够以访问抽象字符设备文件的方法,访问具体驱动设备

 

3). 不严格的基类与抽象基类

- 在JAVA面向对象概念值,有抽象基类的概念, C++中有类似的虚基类的概念。抽象基类指基类对象的函数方法都是虚函数,并且抽象基类不能够直接实例化,必须被子类继承,然后实例化子类,由子类真正定义基类函数的实现。


- C语言的struct结构体中,不能直接定义函数,实现函数体。只能够通过声明函数指针的方式将函数指针嵌入到结构体中,然后在定义结构体或者实例化时,才真正给指针赋值实际的函数地址。类似struct cdev 极其重要的组件struct file_operations的声明如图4所示。


- 在面对对象编程中,基类与子类最重要的差别就是函数的多态实现上。比如基类动物的叫声函数,假如默认发出一种“哦哦”的声音,而子类猫的叫声函数通过多态覆盖基类的叫声函数,发出“喵喵”声,子类狗的叫声函数通过多态覆盖基类的叫声函数,发出“汪汪”声,从而实现了子类和基类的差异化。


- 那么问题来了,在C语言中,定义或者实例化一个结构体对象,例如 struct cdev  mycdev;mycdev->ops->ioctl = myioctl;  那么mycdev到底是structcdev这个基类的实例,还是继承了struct cdev对象的cdev的子类的实例(虽然定义了struct  cdev对象,但是cdev的函数方法被覆盖重写了,这里就是歧义产生点,在面向对象思想中,子类才会在继承基类之后覆盖重写基类的函数)。



面向对象地分析Linux内核设备驱动(1):——Linux内核驱动中面向对象的基本规则和实现方法Figure 4 struct cdev基类及其核心函数方法的声明

- 为了沟通交流上的统一,消除理解歧义,这里需要做一些妥协折中,放宽松面向对象思想的规则限制,制定几条Linux内核C语言面向对象编程的自定义规则,才可以在没有语法关键字支持的条件下,模拟OOP编程,将OOP灵活应用到内核设备驱动模型的分析。关于基类与继承的几条折中妥协的规则如下:


1. 在C语言面向对象编程中,因为结构体本身只能嵌入函数指针,所以不区分基类与抽象基类,我们用一个大概的基类概括这两种情况。


2. 定义一个结构体对象实例,例如struct cdev  mycdev; 如果mycdev中的structfile_operations *ops中的函数方法是用户自己实现的,那么我们就认为mydev对象是cdev的子类。如果mydev的mycdev中的struct file_operations *ops中的函数方法全部是采用Linux内核提供的默认的实现函数,那么我们就认为mycdev就是cdev类的一个实例,是一个基类对象的实例。实际上Linux内核为很多内核基类对象都提供了默认的实现函数(我们可以称为基类函数的实现),在对象的构造函数中,我们会给对应的函数指针赋值。在实例化一个字符设备为抽象的字符设备文件时,我们都会创建inode节点,而在这个过程中,调用的init_special_inode()函数如图5所示。可见cdev对象创建过程中都采用了默认的def_chr_fops实例化基类函数cdev的structfile_operations  *ops的函数方法。


面向对象地分析Linux内核设备驱动(1):——Linux内核驱动中面向对象的基本规则和实现方法

Figure 5 实例化字符设备cdev的过程中,使用Linux内核默认的基类函数方法def_chr_fops实例化cdev

3. 大部分情况下,定义cdev对象,实际上都是cdev的子类,因为cdev本身抽象层次太高,默认实现的函数方法也只提供了open的方法,open也只会最终调用实际字符设备驱动的open函数,并没有实现驱动的有效的功能。cdev就类似于动物这个基类,实际上还是抽象基类,世界上并没有一种真正叫做动物的对象实例,但是猫、狗才有真正的实例对象。从动物到猫和狗的对象,实际上还应该分出一些中间的子类,例如猫科动物,犬科动物,猫是猫科动物的子类,狗是犬科动物的子类。同理,framebuffer是继承cdev的子类,vga16fb才是真正的vga16图形显示器的驱动程序(要实例化成/dev/*下的节点)。


4. 从基类到子类的继承关系,最重要的思想是从抽象的基类对象到子类的具体对象的一个逐渐具体化的过程。在管理软件系统复杂度时,通过这种抽象到具体的过程,应用开发者只需了解一些抽象概念,调用抽象的API。而具体对象的细节维护则交给子类的维护者管理。所以继承关系,重要的是看清楚抽象到具体的思维方法,C语言实现这种继承关系的细节是次要的。

4). 单继承与接口

- 在真正的面向对象编程语言中,C++支持多重继承而JAVA不支持多重继承,多重继承会使得对象继承关系变得复杂化,同时会埋有钻石问题的隐患(如图6所示)。

 

面向对象地分析Linux内核设备驱动(1):——Linux内核驱动中面向对象的基本规则和实现方法

Figure 6 多重继承中存在的钻石问题,如果哺乳动物和食肉动物实现了相同的函数方法,狗在多重继承遇到不同基类相同函数的时候,容易引发混乱

 

- 在C语言面向对象编程的规则中,我建议模仿JAVA的单继承机制,另外用JAVA中接口实现机制(interface)代替可能在C++中的多继承机制

 

- 接口在面向对象编程中描述了一种LIKE-A的关系。例如机器狗,它不是动物,它在事物分类里面应该是机器而不是真正的狗,机器狗与牧羊犬,哈巴狗有着本质的区别,牧羊犬可以作为狗的一种子类,是一种继承关系,但是机器狗就不可以,生物学家也不认为机器狗是真正的狗。但是机器狗可以和狗一样发出“汪汪汪”的叫声,所以我们可以说机器狗与狗是LIKE-A而不是IS-A的关系,机器狗和机器才是IS-A的关系。这样可以说,机器狗是机器,但是它实现了狗的“汪汪汪”的叫声接口(当然它不能继承真正狗的DNA)。继承关系与接口实现关系的差别如图7所示。

面向对象地分析Linux内核设备驱动(1):——Linux内核驱动中面向对象的基本规则和实现方法

Figure 7 单继承体系中,继承关系与接口实现的区别

- Linux内核设备驱动中,也会遇到类似的多继承问题,例如三星framebuffer的驱动(s3c-fb.c),既是字符设备类型的驱动,又是虚拟平台总线(platform_driver)类型的驱动。所以需要制定面向对象的规则,管理类似多继承的问题。

 

- 关于Linux内核C语言编程中,需要为多继承与接口相关的几条规则,来适应上述出现的相关问题:

1. Linux内核C语言模拟JAVA的单继承机制,不支持多重继承,遇到同时具备cdev对象与platform_driver对象两种类型的驱动时,只继承其中一个对象,另一个则作为接口实现,以描述LIKE-A的概念。

 

2. 对于类似s3c-fb.c这类驱动,我们认为它是framebuffer 与 cdev的子类,实现struct  platform_driver这个虚拟总线实例化接口,因为s3c-fb驱动核心的功能特性是framebuffer的显示缓存功能逻辑,而struct  platform_driver这个接口的相关函数,只是在动态实例化framebuffer设备节点的时候调用一次,并非framebuffer本质的特征(就像机器狗,本质特性是机器的特性)。所以我们说s3c-fb IS-A  framebuffer, s3c-fb LIKE-A struct  platform_driver。

 

3. 由于驱动设备的复杂性,并不像自然界的事物容易看出继承关系。所以在研究内核设备驱动单继承关系的时候,不要拘泥于条条框框,要根据自己的研究目的来选择基类与继承关系。例如图6所示的,如果要研究狗的哺乳动物属性,在单继承条件下,我们可以认为狗继承了哺乳动物,实现了食肉动物的接口

 

4. 在研究类似s3c-fb.c这类驱动时,如果关注重点在s3c-fb的显示缓冲等framebuffer特性上,我们就认为s3c-fb继承了framebuffer,实现了struct  platform_driver的虚拟总线实例化接口。如果我们关注点在s3c-fb如何识别fb设备动态识别实例化,如果通过sys文件系统进行电影管理的特性,那么我们可以认为s3c-fb继承了platform_driver类,实现了framebuffer的接口(尽管这种case比较少见)。


5. 总之,在研究Linux内核设备驱动单继承与接口实现规则时,要主动权衡研究目的,根据需要选择继承的基类与实现的接口。但是字符设备驱动在大多数情况下,我们都认为XX驱动继承了字符设备cdev,实现了platform_driver的虚拟总线实例化接口。


5). 通过基类访问子类的方法

- 在面向对象编程中,通过继承关系,我们将子类对象赋值给基类对象的时候,可以通过基类对象,调用多态函数访问子类对象的实际函数。


-在Linux内核设备驱动中,我们在用户态open一个字符设备,然后调用字符设备的read/write/ioctl函数,最终也会调用到内核态设备驱动程序相应的read/write/ioctl函数的实现,从而模拟了通过基类与多态函数的特性来访问子类的目的。


-实际上,Linux内核会维护基类与子类cdev对象实例的链表,当用户态发起read/write/ioctl等字符设备系统调用函数时,read/write/ioctl等字符设备系统调用函数会通过/dev/*下的字符设备,设备节点号的方式(主节点号major,次节点号minor)从cdev链表的子类中,找到对应子类的cdev对象实例,然后判断是否为空并调用子类cdev->ops->read/write/ioctl等实际子类的多态函数实现,从而最终实现了通过访问基类的多态函数,最终访问到子类实际的多态函数,这个面向对象的特性。

 

4.多态与实例化


1). 多态

- 在面向对象程序设计中,属于继承关系的基类classanimal 与子类 class dog 都实现了相同的函数方法bark()时,我们说子类的bark()覆盖了基类的bark()函数。如果一个 class animal 基类被实例化为一个class dog对象,那么调用animal.bark()时,实际上会调用dog.bark()。这样我们称父类与子类相同的函数方法bark()为多态函数。


- 在C++中,多态是通过动态绑定实现的,程序在运行的时候,基类会通过虚函数表来查找子类的多态函数实现,当然这个过程都是系统运行库做的(run time),自己实现是相当复杂。


- 在Linux内核中,模拟多态的方法要简单一些,实际上是在基类的函数方法中,通过获取子类对象,再嵌套调用子类对象的同名函数来实现的。图8为framebuffer设备驱动的多态函数read实现。实际上,framebuffer的cdev的struct  file_operations对象的read函数会调用子类struct  fb_info对象中同名fb_read函数(如果子类未实现该函数,则不调用),从而模拟了继承关系中基类同名函数通过多态的方法调用子类同名函数的行为。

面向对象地分析Linux内核设备驱动(1):——Linux内核驱动中面向对象的基本规则和实现方法面向对象地分析Linux内核设备驱动(1):——Linux内核驱动中面向对象的基本规则和实现方法

Figure 8 framebuffer driver 中read函数的多态实现

2). 实例化

- 在C语言面向对象编程时,如第3节继承与接口中在第3段不严格的基类与抽象基类,提出的一个关于实例化与继承的问题,定义一个变量struct  cdev  mycdev; mycdev到底代表cdev的子类还是cdev的实例,在该章节中,已有规则说明在何种场景下mydev代表cdev的子类。


- 因而,在Linux内核中,关于对象实例化,还需要以下几条规则:

1. 定义一个类似 struct  cdev  mycdev;这样的结构体变量mycdev,虽然mydev会占用内存空间,但是mydev并并不算实例化内核设备驱动,只有mydev真正在/dev/*目录下创建了设备节点,才是一个内核驱动设备的实例。


2. 大部分情况下,内核设备是通过总线的接口(包括platform虚拟总线,也包括USB、SPI、I2C等真正的总线,只要是继承structbus_type基类对象的总线都可以)的probe()函数进行实例化的。例如三星的framebuffer驱动s3c-fb.c,就是通过实现struct platform_driver这个接口,在接口的probe()函数中,为识别到的framebuffer设备创建/dev/*下的节点,实现s3c-fb字符设备的实例化。


3. 在不实现总线接口的字符设备中,定义一个struct cdev  mycdev;之后,在模块加载module_init()的时候,也是可以调用构造函数(初始化函数),创建/dev/*下的设备节点,从而完成实例化的。这种情况下,mycdev可以代表一个设备的实例,这种情况下模拟了面向对象设计模型中的单例模式,这也是可行的。

5.聚合(组合)

-         聚合在面向对象中代表一种HAS-A的关系,比如struct cdev对象HAS-A  struct file_operations对象,我们就认为structfile_operations对象与struct cdev对象是聚合关系。


-         在面向对象中,聚合关系主要是为了区别于继承关系,例如V4L2子系统中,structvideo_device 有struct v4l2_file_operations对象,但是实际上structv4l2_file_operations中的函数都是struct file_operations中的同名函数,并且基类的struct file_operations中的同名函数最终会模拟多态函数的方式,调用到struct v4l2_file_operations中的函数。因而我们认为struct video_device对象中的struct v4l2_file_operations对象是继承自struct cdev对象,而不是聚合关系(HAS-A).但是struct video_device对象中的v4l2_std_id tvnorms对象,在struct  cdev基类对象中是不存在的,是structvideo_device对象特有的,struct video_device  HAS-A v4l2_std_idtvnorms, 因而我们认为v4l2_std_id tvnorms对象与struct video_device对象是聚合关系。



-         由于C语言没有严格的面向对象关键词标准来支持,所以聚合和继承的区别,还是需要人为分类维护。如果子类与基类有同名函数,并且子类同名函数被基类同名函数所调用,那么同名函数所在的*_operations对象都认为是从基类继承过来的,子类所拥有的与基类无关的对象,我们才认为是子类的聚合。

6. 模板与泛型


-         Linux内核中为了简化复杂对象的定义,提供了很多#define宏来模仿面向对象中的模板和泛型机制。

1). 模板

- 典型的模板宏代码如Linux内核信号量的模板include/linux/semaphore.h,为简化信号量的定义与初始化,提供了模板函数。

#define DECLARE_MUTEX(name)  \
structsemaphore name = __SEMAPHORE_INITIALIZER(name, 1)


 

2). 泛型

- 典型的泛型宏代码如Linux内核网络部分的socket地址泛型(include/linux/net.h ),定义一个socket地址,socket地址的具体数据类型在实际定义的时候才由type参数确定

#define DECLARE_SOCKADDR(type, dst,src)        \
typedst = ({ __sockaddr_check_size(sizeof(*dst)); (type) src; })



7. 开闭原则


-         开闭原则是可复用的面向对象代码设计的基石。


-         开闭原则是指,代码要对扩展开放,对修改关闭。


-         Linux内核设计中,通过继承关系,以及用户态与内核态隔离,限定使用一组API实现用户态与内核态通信的机制,使得内核代码对Linux内核态的设备驱动扩展与开发是开放的,而对Linux用户态应用程序的各种可能的修改关闭。


-         通过开闭原则,也实现了程序员的分工,Linux内核对内核维护的程序员扩展开放,对用户态应用开发程序员的修改关闭。整个内核设计思路是符合开闭原则的。


8. 设计模式


- 设计模式是在面向对象程序设计中总结出来的一些常用的代码复用规则。Linux内核设计中,大量地参考了经典的设计模式,这里仅仅举两个例子,读者在阅读各驱动源码时,需要自我总结一些设计模式。


1). 观察者模式(订阅者/发布者)

- 观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。这个主题对象在状态发生变化时,会通知所有观察者对象,使它们能够自动更新自己。


- Linux内核的通知链模型是典型的观察者模式,其介绍可以参阅本博客另一篇文章《Linux内核重点精要》中通知链模型的相关的介绍。


2).桥接模式(handle/body)

- 桥接模式的设计意图将抽象部分与它的实现部分分离,使它们都可以独立地变化。


- Linux内核中使用的最重要的桥接模式,在于万物皆文件的思想。即将用户态的抽象字符设备文件,与实际的字符设备驱动实现分离,从而使得文件描述符和内核设备驱动可以分别在用户态和内核态独立变化,只需要在open的时候将抽象文件与实际的设备驱动关联起来即可。


- 抽象字符设备文件与实际的内核设备驱动桥接模式的UML简化图如图9所示。

面向对象地分析Linux内核设备驱动(1):——Linux内核驱动中面向对象的基本规则和实现方法

Figure 9  Linux内核设备驱动模型中经典的桥接模式


9. 参考文献

-         《Linux内核设计与实现》—— Robert.Love

-         《设计模式》——伽马等(四人组)

-         《JAVA编程思想》——Bruce.Eckel

-         《代码大全》——SteveMcConnell

-         《C语言面向对象编程》 —— foruok的博客