在系列博客中前言部分就提到过,芯片开发板上器件众多,但驱动器件"去办事",背后是有一套模型的,本质的操作就是给器件读写操作,至于原因大家可以往前翻看。
1 设备类型
Linux中,设备被分为以下三类:
块设备、字符设备、网络设备。
块设备 :通常缩写是blkdev,它是可寻址的,寻址以块为单位,块大小随设备不同而不同,支持重定位(seeking)操作,也就是对数据随机访问。常见块设备有:磁盘,光碟,Flash等存储设备;块设备通过"块设备节点"这样的特殊文件来访问,并通常挂载为文件系统。
字符设备 :通常缩写为cdev,它是不可寻址的,仅提供数据的流式访问,即一个个字符,或一个个字节。常见字符设备有键盘,鼠标,打印机等;字符设备通过"字符设备节点"这样的特殊文件来访问,与块设备不同,应用程序直接访问设备节点与字符设备交互。
网络设备 :通常以以太网(ethernet devices)来称呼,它提供了对网络(如Internet)的访问,这是通过一个物理适配器和一种特定协议(如IP协议)进行的;网络设备打破了Unix的"一切皆文件"的设计原则,其不是通过设备节点来访问,而是通过套接字API这样的特殊接口来访问。
Linux还有一些其他设备类型,但都是针对单个任务而非通用的,一个特例是"杂项设备"(miscellaneous device),通常简写为miscdev,它实际是个简化的字符设备。
还有一些设备驱动不表示物理设备而是虚拟的,仅供访问内核功能,这种称为伪设备(pseudo device),最常见的如内核随机发生器,空设备,零设备,满设备,内存设备等。
设备模型
Linux 2.6内核引入统一设备模型(device model),以提供一个独立的机制专门来表示设备,并描述其在系统中的拓扑结构,形成一个设备树,内核可以沿设备树的叶子方向依次遍历,以保证系统以正确顺序关闭设备电源。
1.1 kobject
设备模型的核心部分就是kobject,描述符如下:
c
struct kobject {
const char *name;
struct list_head entry;
struct kobject *parent;
struct kset *kset;
struct kobj_type *ktype;
struct sysfs_dirent *sd;
struct kref kref;
unsigned int state_initialized:1;
unsigned int state_in_sysfd:1;
unsigned int state_add_uevent_sent:1;
unsigned int state_remove_uevent_sent:1;
unsigned int uevent_suppress:1;
};
kobject通常是嵌入其他结构中,单独意义不大,如嵌入到字符设备:
c
struct cdev {
struct kobject kobj;
struct module *owner;
const struct file_operations *ops;
struct list_head list;
dev_t dev;
unsigned int count;
};
当kobject被嵌入到其他结构时,该结构便有了kobject提供的标准功能。更重要的一点是,嵌入kobject的结构体可以成为设备对象层次架构中的一部分,比如cdev结构体可以通过其父指针cdev->kobj.parent和链表cdev->kobj.entry插入到对象层次结构中。
1.2 ktype
kobject对象被关联到一种特殊的类型,即ktype,ktype由kobj_type结构体表示:
c
struct kobj_type {
void (*release)(struct kobject *);
const struct sysfs_ops *sysfs_ops;
struct attribute **default_attrs;
};
ktype的存在是为了描述一族kobject所具有的的普遍特性,如此一来,不再需要对每个kobject都分别定义自己的特性,而是将这些普遍特性在ktype结构中一次定义。
release指针指向kobject引用计数减至零时要被调用的析构函数,负责释放kobject所用的内存和其他相关清理工作。
sysfs_ops描述了sysfs文件读写时的特性。
default_attrs指向一个attribute结构体数组,里面定义了该kobject相关的默认属性。
1.3 kset
是kobject对象的集合体,把它看成一个容器,可将所有相关的kobject对象,如全部块设备,置于同一位置。kset描述符如下:
c
struct kset {
struct list_head list;
spinlock_t list_lock;
struct kobject kobj;
struct kset_uevent_ops *uevent_ops;
};
list连接该集合kset中所有的kobject对象,list_lock是保护这个链表中元素的自旋锁,kobj指向kobject对象代表了该集合的基类。uevent_ops指向一个结构体,用于处理kobject对象的热插拔操作。uevent就是用户事件(user event)的缩写,提供了用户空间热插拔信息进行通信的机制。
1.4 kobject,ktype和kset的相互关系
kobject引入诸如引用计数,父子关系等基本对象道具,并且以统一方式提供这些功能,不过它自身意义不大,需要嵌入其他数据结构中;ktype定义了一些kobject相关的默认属性:析构行为,sysf行为等;kobjec又归入kset中,将每个kobject链表起来,具有相同ktype的kobject可以被分组到不同的kset,在Linux中,只有少数的ktype,但有多个kset。
kref
引用计数kref是kobject的一个重要功能。初始化后,kobject的引用计数置为1.只要引用计数不为0,该对象就会继续保留在内存中。任何包含对象引用的代码首先要增加对该对象的引用计数,当代码结束后减少它的引用计数。
kref描述如下:
c
struct kref {
atomic_t refcount;
};
其中唯一的字段是用来存放原子变量。在使用kref前,必须通过kref_init()来初始化:
c
void kref_init(struct kref *kref)
{
atomic_set(&kref->refcount, 1);
}
(1)增加引用计数:
struct kobject* kobject_get(struct kobject *kobj);
---> 进一步调用kref_get()
(2)减少引用计数:
void kobject_put(struct kobject *kobj);
---> 进一步调用kref_put()
(3)sysfs
sysfs文件系统是一个处于内存中的虚拟文件系统,它提供kobject对象层次结构的视图,帮助用户能以一个简单的文件系统方式来观察各种设备的拓扑结构。
(4)添加一个kobject:
仅仅初始化kobject不能将其自动导入到sysfs中,若把kobject导入sysfs中,需调用:
int kobject_add(struct kobject* kobj, struct kobject *parent, const char *fmt, ...);
如果parent指针被设置,则该kobject在sysfs中被映射到父目录下的子目录;如果parent指针为空,则kobject被映射为kset->kobj的子目录;如果parent和kset都没被设置,则认为kobject没有父对象,会被映射为sysfs下的根目录。
辅助函数kobject_create_and_add把kobject_create()和kobject_add()所做的工作放在一个函数中:
struct kobject* kobject_create_and_add(const char *name, struct kobject *parent);
(5)删除一个kobject:
从sysfs中删除一个kobject对应的文件目录,需调用:void kobject_del(struct kobject *kobj);
2 模块
模块(module)化机制是Linux一大创新,有以下优点:
1.在系统运行动态加载模块,扩充内核功能,不需要时可以卸载。
2.修改内核功能,不必重新全部编译整改内核,只需编译相应模块即可。
3.模块目标代码一旦被加载重定位到内核,其作用域和静态链接的代码完全等价。
module_init()是一个宏调用,里面的唯一参数是模块初始化参数,该初始化参数必须符合下面形式:
int my_init(void); ---> 一般初始化函数用于初始化硬件,分配数据结构等。如果被静态编译到内核映像,将会被内核启动时运行。
module_exit()是一个宏调用,里面是模块的出口函数,其退出函数必须符合以下形式:
void my_exit(); ---> 可能会负责清理资源,以保证硬件处于一致状态。
模块代码有两种运行方式:
1.静态编译链接进内核,在系统启动过程中进行初始化;
2.编译成动态可加载的module,并通过insmod来动态加载,接着初始化。
有些模块是必须要编译进内核的,和内核一起运行,从不卸载,如vfs,platform_bus等。
静态链接和初始化:
Make menuconfig时选择 将模块编译进内核 即为静态编译,或直接在Makefile文件中指定为
obj-y += hello.o
编译后会被链接到section(.initcall6.init),并在内核启动时,被kernel_init()调用。
动态链接和初始化:
Make menuconfig时选择 将模块编译成模块 即为动态编译,或直接在Makefile文件中指定为
obj-m += hello.o
有个类型为module的全局变量__this_module,其成员init就是init_module,也就是hello_init,并被链接到section(".gnu.linkonce.this_module").
动态加载需要用到insmod这个用户层命令,代码会移到内核指定位置中,init函数也会被调用
3 块设备
块设备的管理是个艺术活,有必要单独讲下,其背后包含如何处理延时写的思想;在嵌入式中实时处理是很关键的,而块设备一般访问速率比较慢,单次读写数据量很可能也比较大,所以如何处理块设备的读写是个很重要的事情。
3.1 块I/O层
系统中能随机(不需要按顺序)访问固定大小的数据片(chunks)的硬件设别称为块设备,这些固定大小的数据片就称为块,常见的块设备有磁盘,闪存等。
块设备中最小的可寻址单元是扇区,扇区大小一般是2的整数倍,最常见的是512字节。扇区的大小是设备的物理属性,而因为各种软件用途不同,它们会有自己的最小逻辑可寻址单元------块。块是文件系统的一种抽象------只能基于块访问文件系统。块不能比扇区还小,只能数倍于扇区大小,且不能超过一个页的大小。通常块大小是512B,1KB,4KB等。
3.2 缓冲区和缓冲区头
当一个块调入内存时,它要存储在一个缓冲区中。每个缓冲区与一个块对应,它相当于是磁盘块在内存中的映射。缓冲区描述符如下:
c
struct buffer_head {
unsigned long b_state; /* 缓冲区状态标志 */
struct buffer_head *b_this_page; /* 页面中的缓冲区 */
struct page *b_page; /* 存储缓冲区的页面 */
sector_t b_blocknr; /* 起始块号 */
size_t b_size; /* 映像中大小 */
char *b_data; /* 页面内的数据指针 */
struct block_device *b_bdev; /* 相关联的块设备 */
bh_end_io_t *b_end_io; /* I/O完成方法 */
void *b_private; /* io完成方法 */
struct list_head b_assoc_buffers; /* 相关的映射链表 */
struct address_space *b_assoc_map; /* 相关的地址空间 */
atomic_t b_count; /* 缓冲区使用计数 */
};
目前内核中块I/O操作的基本容器由bio结构体表示,
c
struct bio {
sector_t bi_sector; /* 磁盘上相关的扇区 */
struct bio *bi_next; /* 请求链表 */
struct block_device *bi_bdev; /* 相关的块设备 */
unsigned long bi_flags; /* 状态和命令标志 */
unsigned long bi_rw; /* 读还是写 */
unsigned short bi_vcnt; /* bio_vecs偏移个数 */
unsigned short bi_idx; /* bio_io_vect的当前索引 */
unsigned short bi_phys_segments; /* 结合后的片段数目 */
unsigned int bi_size; /* I/O计数 */
unsigned int bi_seg_front_size; /* 第一个可合并的段大小 */
unsigned int bi_seg_back_size; /* 最后一个可合并的段大小 */
unsigned int bi_max_vecs; /* bio_vecs数目上限 */
unsigned int bi_comp_cpu; /* 结束CPU */
atomic_t bi_cnt; /* 使用计数 */
struct bio_vec *bi_io_vec; /* bio_vecs链表 */
bio_end_io_t *bi_end_io; /* I/O完成方法 */
void *bi_private; /* 拥有者的私有方法 */
bio_destructor_t *bi_destructor; /* 撤销方法 */
struct bio_vec bi_inline_vecs[0]; /* 内嵌bio向量 */
};
使用bio结构体的目的主要是代表正在现场执行的I/O操作,该结构体的主要域都是用来管理相关信息的。
3.3 I/O调度程序
块设备将挂起的块I/O请求保存在请求队列中,该队列由request_queue结构体表示。如果简单地以内核产生请求次序直接请求块设备的话,性能会很差,因为磁盘寻址是整个计算机中最慢的操作之一,每一次寻址(定位磁盘头到指定块位置)需要花费不少时间。
为了优化寻址操作,内核会先执行合并与排序的预操作。
Linus电梯
Linus电梯能执行合并与排序的预处理,当有新的请求加入队列时,它会检查其他每个挂起的请求是否可以合并。
最终期限(deadline)I/O调度程序
为了解决Linus电梯所带来的的饥饿问题。因为为了减少磁盘寻址时间,对某个磁盘区域上操作比较频繁,这会导致其他位置的操作得不到运行机会。在最后期限I/O调度程序中,每个请求都有一个超时时间,默认读请求超时时间是500ms,写请求超时时间是5s。
其他还有预测I/O调度,完全公正的排队I/O调度等。
3.4 页高速缓存和页回写
页高速缓存(cache)是Linux内核实现磁盘缓存,主要用来减少对磁盘的I/O操作。具体来讲,是把对磁盘的数据写到物理内存中,把对磁盘的访问变成对物理内存的访问。
缓存手段
页高速缓存是由内存中的物理页组成的,其内容对应磁盘上的物理块,页高速缓存大小能动态调整。当内核开始读操作,它首先会检查需要的数据是否在页高速缓存中,如果在,就放弃访问磁盘,直接从内存中读取数据,这个行为称作缓存命中。如果缓存中没有相应数据,即未命中,则内核必须调度块I/O操作从磁盘中读取数据。
写缓存
写缓存一般有如下三种策略:
1.不缓存(nowrite),即绕过缓存,直接把数据写进磁盘,并且回头使缓存中的数据失效,后续有读操作的话,必须从磁盘中读取出来;该策略使用很少,因为不但不去写缓存,还额外费力使缓存数据失效。
2.写透缓存(write-through),写操作更新缓存,同时也更新磁盘文件,写操作会立即穿透缓存到磁盘中;好处是能保证缓存和磁盘数据同步,且不用使缓存失效;
3.回写缓存(write-back),也是Linux采用的,写操作直接写到缓存中,但不会立即更新磁盘,而是将页高速缓存被写入的页面标记为"脏",并加入到脏页表中,然后由专门进程(回写进程)周期性地将脏页写回磁盘中,写完后清理"脏"标记。这里的"脏"更好描述是"未同步"。
缓存回收
1.最近最少使用LRU
LRU需要跟踪每个页面的访问踪迹,以便能回收最老时间的页面,该策略的良好效果来源于缓存的数据越久未被访问,则越不大可能近期再被访问。
2.双链策略
Linux的双链策略,维护的不再是一个LRU链表,而是两个链表:活跃链表和非活跃链表。活跃链表上的页面被认为不会被换出,非活跃链表上的页面可以被换出。双链策略中:
每个页面有2个标记位:
PG_active - 标记页面是否活跃; PG_referenced - 表示页面是否被进程访问到。
页面移动流程如下:
- a.当页面首次被访问时,PG_active置1,加入到活动链表;
- b.当页面再次被访问时,PG_referenced置1,如果此时页面在非活动链表中,则将其移到活跃链表,并将PG_active置1,PG_referenced置0;
- c.系统中的daemon会定时扫描活跃链表,定时将PG_referenced位置0;
- d.系统中daemon定时检查页面PG_referenced,如果PG_referenced=0,则此页面的PG_active置0,同时移动到非活跃链表。