Linux内核模块模型面向对象分析
C到C++,它们的关系演变过程是怎样的。从Linux的内核代码里面你可以了解到更深的编程层次的面向对象,而不是简单的封装、继承、多态。首先这个题目有点大,而且过于深,而我能了解到的也只是冰山一角,不过我觉得能去做这样的一种有意义的工作,对于提升自己来说,也是很有帮助。
主要分以下几部分:
-
引言。
-
C语言中的封装、继承与多态。
-
从Linux体系设备驱动模型(defs)来看面向对象。
-
类比插件系统来看内核模块模型中层叠技术的可扩展性。
-
虚拟函数表与Linux内核符号导出表的面向对象特性优劣分析。
-
总结
-
附录
其中,每一部分都会穿插部分语言代码,让整个描述过程更加详实、不空洞。
1 引言:
Linux操作系统架构在合适的硬件之上,诸如 cpu、外存、内存、PCI等设备之上。在Linux这个国家里,任何的硬件设备都作为一个资源,而操作系统就像一个国家机器对他的资源进行管理,国家的公民是运行与操作系统内部的进程,当然这个国家也有公务员(内核线程:注,在内核中没有进程只有线程。关于什么是线程什么是进程,很多书上其实都描述的不在要点。详见附录问题1)。内核线程把持着所有的硬件资源,进程使用需要发起80软中断(INT 0x80)请求内核响应。资源主要分为:内存/虚存、文件(设备)两大类。
2 C语言中的封装、继承与多态:
封装定义是在程序上,隐藏对象的属性和实现细节,仅对外公开接口,控制在程序中属性的读和修改的访问级别;将抽象得到的数据和行为(或功能)相结合,形成一个有机的整体,也就是将数据与操作数据的源代码进行有机的结合,形成“类”,其中数据和函数都是类的成员。
假设我们有类A, 其内含有a、b、c三个成员变量,并有操作方法 op_a(a)、op_b(b)、op_c(c)、op_ab(a, b)等。在面向对象语言中及其容易实现,在C语言中面临的最大问题是操作方法及其参数传递。
通过函数表可以弥补语言机制上的缺陷:
struct Ops {
void (*op_a) (struct A *, loff_t, int);
void (*op_b) (struct A *, char __user *, size_t, loff_t *);
void (*op_ab) (struct A *, const char __user *, size_t, loff_t *);
…
};
首先这是一个只含有方法的结构体,我们称之为函数表,传递的结构体参数A为c++中默认编译器为类传递的this对象,这样,我们将结构体ops内嵌到结构体A中,如下:
Struct A{
Int a;
Int b;
Int c;
Struct Ops ops;
}
将方法封装到了结构体中。在这里结构体A相当于一个父类,而ops是作为父类公开给子类的接口。抽象出了接口,多态就很容易实现了。从这里模拟封装的过程,可以很容易发现接口的实质是父类提前为子类预留的占位符,是一种“高瞻远瞩”的行为。
最早C++里面的封装是从C中的头文件规则衍生出来的。其关键在于对于一个公共数据操作方法的归类。如果一个公共变量,被很多个C 函数修改,如下图:
后期调试及维护时,带来极大的不便,因为根本不知道系统中到底有多少函数操作的这块数据,这块数据的状态什么时候,被哪个函数改变了状态。所以就演变成了下面的方式:
将操作此数据的函数放到同一个文件中去,任何其它改变或访问此数据的函数都需要通过这个文件中的函数来访问。
所以面向对象中的类,其实是C工程中总结出来的一系列管理方法在语言层面上的实现,并没有太多的新东西。
多态:是允许将父对象设置成为和一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。
由多态的定义,最关键的地方在于运行时切换即在编译期不能确定需要执行的代码。这里我们声明两个子操作表 ops1 、ops2,当需要使用ops2时,将 A->ops = ops2,即将子类实例化到父类,下面类A的具体操作就会采用ops2操作表中的方法。如图:
代码示例:
Struct A a;
a-> ops = ops1;
a->ops->op_a(a);
//切换
a->ops = ops2;
a->ops->op_a(a);
继承:特殊类(或子类、派生类)的对象拥有其一般类(或称父类、基类)的全部属性与服务,称作特殊类对一般类的继承。
继承的本质是保留父类的一些成员和函数,实质上是保留父类的存储区及跳转函数表,继承在C语言中也是采用内嵌结构体的方式来实现的。这里举内核中的链表结构体来说明,
我们通常所使用链表结构如下:
Struct A_LIST{
Int a;
Int b;
Struct A_LIST* next;
}
在这里我们发现,每一个特定类型的链表机构都需要为其增加一个next或者prev字段来保持链表的结构。还需要为每一个特定的链表类实现脱链、入链操作等。我们需要抽象出一个“基类”来实现链表的功能,其他链表类只需要简单的继承这个链表类就可以了。
Linux 内核的链表结构:
struct hst_head{
struct list_head *next, prey;
);
在这个结构体中,链表及作为一个单独的结构体,不再依附与任何对象。
同上面的例子,我们只需要声明
Struct A_LIST{
Int a;
Int b;
Struct hst_head T* list;
}
这样A_LIST即可作为一个链表对象,其链表操作均由其成员list代为实现,起到了继承基类的可复用方法的作用。实际过程如下图:
其作为一个连接件,只对本身结构体负责,而不需要关注真正归属的结构体。正如继承的特性,父类的方法无法操作也不需要操作子类的成员。(关于从连接件获取宿主结构体方法见附录1.问题2)
至此,C在面向对象的三大特性的实现过程就结束了。
3 从Linux体系设备驱动模型(defs)来看面向对象:
Sysfs:用于将系统中的设备组织成层次结构,并向用户模式程序提供详细的内核数据结构。
Sysfs是一个特殊的文件系统,没有实际存放文件的介质,sysfs信息来源于kobject层次结构,读取sys就是动态的从kobject结构链中提取信息。
两个基础的数据结构kobject和kset
Kobject <linux/kobject.h>
struct kobject
{
const char * k_name; //指向kobject名称的起始位置
char name[KOBJ_NAME_LEN];//不足则重新分配(20)
struct kref kref; /*引用计数*/
struct list_head entry; /*在所挂到链表的连接体*/
struct kobject * parent; //指向kobject的父对象
struct kset * kset; /*指向所属的kset*/
struct kobj_type * ktype; //指向其对象类型描述符的指针
struct dentry * dentry;
//sysfs文件系统中与该对象对应的文件节点目录项
};
引用计数的增加和减小;(操作kref)
struct kobject *kobject_get(struct kobject *kobj);
void kobject_put(struct kobject *kobj);
如果引用计数降为0,调用kobject release() 释放此结构
相关初始化工作
void kobject_init(struct kobject * kobj); kobject初始化函数。
Int kobject_set_name(struct kobject *kobj, const char *format, ...);kobject 名称
int kobject_add(struct kobject * kobj); 加入linux设备层次
void kobject_del(struct kobject * kobj); 从层次中删除此对象
-
挂接该kobject对象到kset的list链
-
增加父目录各级kobject的引用计数
-
在其parent指向的目录下创建文件节点
注册与注销:
int kobject_register(struct kobject * kobj);
-
kobject init()
-
kobject_add()
void kobject_unregister(struct kobject * kobj);
-
kobject_del()
-
kobject_put() 如果引用计数降为0,调用kobject release() 释放此结构
kobj_type的目标就是为不同类型的kobject提供不同的属性以及销毁方法。
Kobj type struct kobj_type {
void (*release)(struct kobject *); //kobject销毁时调用的函数kobject_put()调用
struct sysfs_ops * sysfs_ops; //对属性的操作表
struct attribute ** default_attrs; //属性列表
};
操作表:
struct sysfs_ops {
ssize_t (*show)(struct kobject *, struct attribute *,char *);
ssize_t (*store)(struct kobject *,struct attribute *,const char *, size_t);
};
struct kset {
struct subsystem * subsys; 所在的subsystem的指针
struct kobj type * ktype; 指向该kset对象类型描述符的指针
struct list head list; 用于连接该kset中所有kobject的链表头
struct kobject kobj; 嵌入的kobject
struct kset hotplug ops * hotplug ops; 指向热插拔操作表的指针
};
List为kobject的双向链表头,ktype为对象类型供所有kobject共享。Kobj为所有kobject指向的parent域。Kset的引用计数依赖其成员kobj的引用计数
根据以上数据结构总结如下:
一共存在三条链:
1、子kobj双向链表(有list做链头)
2、 父子链(kobj)
3、 归属链 (kset)
在面向对象中的正反向引用(1对1、1对多)在C中的实现过程如上:
当处理一对多的关系时,“一”需要维护一个“多”的链表,而“多”需要维护一个“一”的指针。
4 类比插件系统来看内核模块模型中层叠技术的可扩展性。
这里我们分析一个大名鼎鼎的插件架构OSGI,包括eclipse在内的众多插件系统均是依照OSGI规范来架构自己的插件系统的。
从面向对象的角度来说,构建插件系统如同堆积木,又像企业架构总线ESB,其上的插件可以做到热插拔,而不影响整个系统的稳定。架构图如下:
插件配置文件举例:
下面的是插件系统SharpDevelop中,关于Html帮助文档的一段插件配置信息:
<AddInname = "Help 2.0 Environment for SharpDevelop"
author = "Mathias Simmack"
description = "integrates Microsoft's Help 2.0 Environment"
addInManagerHidden = "preinstalled">
//对应插件的描述信息
<Manifest>
//核心信息
<Identityname = "ICSharpCode.HtmlHelp2"/>
//标识自己的ID,供整个系统中访问(防止重名,采用了命名空间的命名方式)
<Dependencyaddin="SharpDevelop"/>
//依赖插件
</Manifest>
<Runtime>
//运行期DLL导入要求(加载此插件时需要动态导入的DLL链接库)
<Importassembly="HtmlHelp2.dll"/>
</Runtime>
//插件树构造结点
<Pathname = "/SharpDevelop/Views/Browser/SchemeExtensions">
<BrowserSchemeExtensionid = "ms-help"class = "HtmlHelp2.BrowserScheme"/>
</Path>
//插件树构造结点
<Pathname = "/SharpDevelop/Services/HelpProvider">
<Classid = "HtmlHelp2"class = "HtmlHelp2.MSHelpProvider"/>
</Path>
//插件树构造面板结点
<Pathname = "/SharpDevelop/Workbench/Pads">
<Padid = "TocPad"
category = "Help2"
title = "${res:AddIns.HtmlHelp2.Contents}"
icon = "HtmlHelp2.16x16.Toc"
class = "HtmlHelp2.HtmlHelp2TocPad"/>
<Padid = "IndexPad"
category = "Help2"
title = "${res:AddIns.HtmlHelp2.Index}"
icon = "HtmlHelp2.16x16.Index"
class = "HtmlHelp2.HtmlHelp2IndexPad"/>
。。。。
//面板区
</Path>
<Pathname = "/SharpDevelop/Dialogs/OptionsDialog/ToolsOptions">
<DialogPanelid = "HtmlHelp2Options"
label = "${res:AddIns.HtmlHelp2.Environment}"
class = "HtmlHelp2.Environment.HtmlHelp2OptionsPanel"/>
//对话框
</Path>
<Pathname = "/SharpDevelop/Workbench/MainMenu/Help">
<MenuItemid = "HtmlHelp2Separator2"
type = "Separator"
insertafter = "Help"/>
<MenuItemid = "TocPadCommand"
insertafter = "HtmlHelp2Separator2"
label = "${res:AddIns.HtmlHelp2.Contents}"
icon = "HtmlHelp2.16x16.Toc"
shortcut = "Control|Alt|F1"
class = "HtmlHelp2.ShowTocMenuCommand"/>
。。。
//菜单区
</Path>
<Pathname = "/SharpDevelop/ViewContent/Browser/Toolbar">
<Conditionname = "BrowserLocation"urlRegex = "^ms-help:\/\/"action="Exclude">
<ToolbarItemid = "SyncHelpTopic"
icon = "Icons.16x16.ArrowLeftRight"
tooltip = "${res:AddIns.HtmlHelp2.SyncTOC}"
class = "HtmlHelp2.SyncTocCommand"
insertafter = "NewWindow"/>
<ToolbarItemid = "PreviousHelpTopic"
icon = "Icons.16x16.ArrowUp"
tooltip = "${res:AddIns.HtmlHelp2.PreviousTopic}"
class = "HtmlHelp2.PreviousTopicCommand"
insertafter = "SyncHelpTopic"/>
。。。
//工具栏区
</Condition>
</Path>
</AddIn>
从系统启动,到插件正确加载的流程分析:
//初始化启动配置:
StartupSettings startup = newStartupSettings();
//设置AddIns插件目录
startup.AddAddInsFromDirectory(Path.Combine(startup.ApplicationRootPath, "AddIns"));
//Workbench Initialization and startup 工作区的初始化及启动
helper.RunWorkbench(settings);
//加载插件树
addInFiles.AddRange(FileUtility.SearchDirectory(addInDir, "*.addin"));
//初始化插件树结点类型:
static AddInTree()
{
doozers.Add("Class", newClassDoozer());
doozers.Add("FileFilter", newFileFilterDoozer());
doozers.Add("String", newStringDoozer());
doozers.Add("Icon", newIconDoozer());
doozers.Add("MenuItem", newMenuItemDoozer());
doozers.Add("ToolbarItem", newToolbarItemDoozer());
doozers.Add("Include", newIncludeDoozer());
conditionEvaluators.Add("Compare", newCompareConditionEvaluator());
conditionEvaluators.Add("Ownerstate", newOwnerStateConditionEvaluator());
ApplicationStateInfoService.RegisterStateGetter("Installed 3rd party AddIns", GetInstalledThirdPartyAddInsListAsString);
}
核心加载XML插件树代码:
using (XmlTextReader reader = newXmlTextReader(configurationFileName))
{
while (reader.Read())
{
if (reader.NodeType == XmlNodeType.Element)
{
if (reader.Name == "AddIn") //插件对应的.addins所处的位置
{
string fileName = reader.GetAttribute("file");
if (fileName != null && fileName.Length > 0)
{
addInFiles.Add(fileName);
}
}
elseif (reader.Name == "Disable")
{
string addIn = reader.GetAttribute("addin");
if (addIn != null && addIn.Length > 0)
{
disabledAddIns.Add(addIn);
}
}
}
}
}
在Linux的模块层叠机制中,也是采用这种方式来进行模块开发。但是不同的是,在Linux没有相关的依赖配置,而是由系统在本地符号表中寻找依赖项,如果未能寻找到则在全局符号表中寻找,其基本思想就是采用可热插拔的模块机制来维护系统的灵活性。
5虚拟函数表与Linux内核符号导出表的面向对象特性优劣分析。
这一部分着重讨论接口与实现的分离技术,在高级面向对象语言中,诸如:java、c#等语言可以通过反射来逆向根据字符串发现函数的二进制入口。这是非常方便,但是也是非常低效的,而在C、C++ 语言中是没有相对应的技术的,但是有静态链接库、动态链接库、符号导出表、虚拟函数表等几种类似方案。
静态链接库:
即将接口及其实现编译成静态Lib文件,一般库的使用者会将.lib库链接到自己的工程中,通过接口声明的头文件使用库中的函数,这是比较常见的做法。其缺点也是显而易见的,在各个程序内部各自维护相同的一段代码,极大的浪费内存去存储相同的库文件代码。第二:一旦lib库有缺陷需要重新发布新版本的时候,所有基于此库的应用程序都不能使用新的库文件,必须重新编译链接后才能运行。
动态链接:
上图即为动态链接库的运行模式,引入库非常小,而真实的链接库在系统内存中只保持一份,即所有应用程序都调用同一个库中的内容,当重新发布二进制组件时可以不影响任何使用者的正常运行,但是仍有部分问题没有解决。
如果这时候,头文件(接口文件)中的结构体需要根据其实现的算法进行适当的调整,那么对于已经链接的应用程序是致命的,数据结构一旦改变,而应用程序并不知晓,那么必然会导致缓冲区溢出或者泄漏。
这个时候我们采用一种障眼法来弥补上面的问题:
举例如下:
mystring.h (头文件)
定义:
Typedef void mystring;
Void createMystring(mystring ** string);
Void op1_Mystring(mystring * string);
Void op2_Mystring(mystring * string);
Mystring.c (实现文件)
定义:
//真实的结构体
Typedef struct CString{
Int a;
Int b;
}
Void createMystring(mystring ** string){}
Void op1_Mystring(){}
Void op2_Mystring(){}
用这种方法就可以不向应用程序暴露任何内部核心结构体的任何信息,从而达到分离的效果。
在应用程序层,的调用方式如下:
Void* t;
createMystring(&t);
op1_Mystring(t);
op2_Mystring(t);
这样只要接口不变,任何库的改动都不会影响到应用程序的正常运行。
到这里,仍然使用的是Windows下的动态链接库的方法,通过编译器来生成引入库文件来实现二进制程序与符号名的分离和映射。没有任何跨平台性,因为使用的不是纯C语法。
虚拟函数表:(跨平台的链接技术)
将上面的例子修改如下:
Mystrig.h实现如下:
Struct mystring
{
Struct mystringVtbl* vtbl;
}
//虚拟函数表
Struct mystringVtbl
{
Void op1_Mystring(mystring * string);
Void op2_Mystring(mystring * string);
}
定义虚拟操作宏:
#define MYSTRING_op1(p) ((mystring*)p - > vtbl)-> op1_Mystring(p);
#define MYSTRING_op2(p) ((mystring*)p - > vtbl)-> op2_Mystring(p);
//衔接的关键
Void createMystring(mystring ** string);
Mystring.c 实现如下:
//内部真实的核心结构体
Struct CString{
//虚拟函数表指针
Struct mystringVtbl * vtbl;
Int a ;
Int b;
}
具体实现函数:
初始化函数表:(真实函数地址)
Struct mystringVtbl vtbl = {
op1_Mystring,
op2_Mystring
}
Void createMystring(mystring ** string){
CString* pMe = malloc(sizeof(CString));
pMe->vbtl = & vtbl;
//初始化结构体成员
…
*string = (mystring* )pMe;
//关键之处:强制类型同结构转换。
}
Void op1_Mystring(mystring * string){
//反向转换
CString* pMe = (CString*)string;
}
Void op2_Mystring(mystring * string){
//同op1
}
对象之间的关系如下图:
这里的整个过程最重要的一步是实例化函数表及返回实例操作是由Create函数来引导,故牺牲了一个函数的代价换来其他所有函数与其二进制实现的分离。而这唯一的一根线可以使用参数的方式在程序启动之初传递给main。
应用层的调用逻辑如下:
Mystring * t;
createMystring(&t);
MYSTRING_op1(t);
MYSTRING_op2(t)
即在编译期是不需要实际生成t对象的,只是使用了一个空的虚拟函数表来完成这个工作,后面是调用宏来实现其功能。
整个系统里面可能存在多个这样的接口,那么不可能为每个接口暴露一个Create函数为其实例化函数表,这里使用同样的原理在虚拟函数表之上再封装一层shell。
Shell.h
定义如下:
Struct shell{
Struct shellVtbl* vtbl;
};
Struct shellVtbl{
Void (*CreateInstance)(shell* p, int ID, void** ppObj, int data);
};
这里ID为待创建接口的ID;
Shell.c
Struct Cshell{
Struct shellVtbl* vtbl;
};
ShellVtbl gvtShell = {CreateInstance};
Void CreateObject(void** ppObj, int data) {
Cshell* pMe = malloc(sizeof(Cshell));
pMe->vtbl = &gvtShell;
*ppObj = (void*)pMe;
}
Void CreateInstance(shell* pShell, int ID, void** ppObj, int data)
{
CShell *pMe = (CShell*)pShell;
Switch(ID)
{
Case 1: Create1();break;
Case 2: Create2();break;
………
}
}
整个调用流程如下:
内核符号导出表:
内核符号表可以分为“私有”和“公共”。平常所说的内核符号表指的是“公共内核符号表”。
动存在于内核空间,它的每一个函数每一个变量都会有对应的符号,这部分符号也可以称作内核符号,它们不导出的话就只能为自身所用,导出后就可以成为公用,对于导出的那部分的内核符号就是我们常说的内核符号表。insmod的时候并不是所有的函数都得到内核符号表去寻找对应的符号,因为每一个驱动在自己分配的空间里也会存在一份符号表,里面有关于这个驱动里使用到的变量以及函数的一些符号,驱动首先会在这里面找,如果发现某个符号没有了,那么就会去公共内核符号表中搜索,搜素到了,则该模块加载成功,搜索不到则该模块加载失败。
内核所采用的符号表类似于C中的全局变量,为了避免冲突,只有需要导出的符号才会导出,否则一律采用static为变量描述为本地变量。
从本身来讲,这种方法导致的结果必然是符号表混乱冲突,模块的开发并不是由内核人员来维护,命名规则,访问控制等都不好控制。一旦某个模块出现访问异常,必然导致整个系统的崩溃。
这里我个人感觉应该添加一层shell来控制内核的导出表,其它普通模块自己提供shell来为他人提供服务。这层shell需要严格规定数据的访问和修改方式,以维护系统内核及模块的稳定性。
修改后的访问图如下:
6 总结
刚开始学习,总觉得语言会是学习过程中最大的障碍,喜欢拿起语法书慢慢读,其实不是的。任何语言都是相通的,从C到C++再到java、C#, 从指针到引用,从头文件与实现文件的分离到接口和实现的分离,其实用法、思想一直没有变过,只是将一些常用的规则、规范融入进了高级语言的语法层次上,关键还是要掌握其设计的精髓。时间较为仓促,就先写下这么多吧。
7 附录
附录1
问题1:进程与线程,32位PC机上可以寻址4GB的虚拟内存空间,在这4GB的内存空间中,各个进程本身占用较低位的3GB虚拟内存并与系统中所有进程及内核线程共享高地址的1GB虚拟内存。要区分是进程和线程的关键在此进/线程是否具有独立的3GB用户空间,如果有则是进程,否则为线程。
问题2:
关于链表结构的宿主指针获取方法,
获取结构类型TYPE里的 成员MEMBER 在结构体内的偏移
#define offsetof(TYPE, MEMBER) ((size_t)&((TYPE *)0)->MEMBER)
通过指向成员member的指针ptr获取该成员结构体type的指针
#define container_of(ptr, type, member) ({
const type(((type*) 0)->member)*__mptr = (ptr);
(type*)((char*)__mptr – offsetof(type, member))
})