您的位置:首页 > 运维架构 > Linux

Linux设备驱动核心理论(三)

2015-08-13 15:40 453 查看
10.中断与时钟

10.1 中断与定时器

所谓中断是指CPU在执行程序的过程中,出现了某些突发事件急待处理,CPU必须暂停执行当前程序,转去处理突发事件,处理完毕后CPU又返回原程序被中断的位置并继续执行。

根据中断的来源,中断可分为内部中断和外部中断,内部中断的中断来源来自CPU内部(软件中断、溢出、除法错误等,例如,操作系统从用户态切换到内核态需借助CPU内部的软件中断),外部中断的中断来源来自CPU外部,由外设提出请求。

根据中断是否可以屏蔽分为可屏蔽中断与不屏蔽中断(NMI),可屏蔽中断可以通过屏蔽字被屏蔽,屏蔽后,该中断不再得到响应,而不屏蔽中断不能被屏蔽。

根据中断入口跳转方法的不同,分为向量中断和非向量中断。采用向量中断的CPU通常为不同的中断分配不同的中断号,当检测到某中断号的中断到来后,就自动跳转到与该中断号对应的地址执行,不同中断号的中断有不同的入口地址。非向量中断的多个中断共享一个入口地址,进入该入口地址后再通过软件判断中断标志来识别具体是哪个中断。也就是说,向量中断由硬件提供中断服务程序入口地址,非向量中断由软件提供提供中断服务程序入口地址。

嵌入式系统以及X86' PC中大多数包含可编程中断控制器(PIC)、许多MCU内部就集成了PIC。如在80386中PIC是两片i8259Ax芯片的级联。通过读写PIC的寄存器,程序员可以屏蔽/使能某中断及获得中断状态,前者一般通过中断MASK寄存器完成,后者一般通过中断PEND寄存器完成。

定时器在硬件上也依赖中断来实现,它接受一个时钟输入,当时钟脉冲到来时,将目前计数值增1并与预先设置的计数值(计数目标)比较,若相等,证明计数周期满,产生定时器中断并复位目前计数值。下图为典型的嵌入式处理内可编程间隔定时器(PIT)的工作原理。



10.2 Linux中断处理程序架构

设备的中断会打断内核中进程的正常调度和运行,系统对更高吞吐率的追求势必要求中断服务程序尽可能的短小精悍。但是,这个良好的愿望往往与现实并不吻合。在大多数真实的系统中,当中断到来时,要完成的工作往往并不会是短小的,它可能要进行较大量的耗时处理。下图描述了Linux内核的中断处理机制。



为了在中断执行时间尽可能短和中断处理需完成大量工作之间找到一个平衡点,Linux将中断处理程序分解为两个半部:顶半部(top half)和底半部(bottom half)。

顶半部完成尽可能少的比较紧急的功能,它往往只是简单地读取寄存器中的中断状态并清除中断标志后就进行“等级中断”的工作。“登记中断”意味着将底半部处理程序挂到该设备的底半部执行队列中去,这样,顶半部执行的速度就会很快,可以服务更多的中断请求。

现在,中断处理工作的重心就落在了底半部的头上,它来完成中断事件的绝大多数任务。底半部几乎做了中断处理程序所有的事情,而且可以被新的中断打断,这也是底半部和顶半部的最大不同,因为顶半部往往被设计成不可中断。底半部则相对来说并不是非常紧急的,而且相对比较耗时,不在硬件中断服务程序中执行。

尽管顶半部、底半部的结合能够改善系统的响应能力,但是,僵化地认为Linux设备驱动中的中断处理一定要分为两个半部则是不对的,如果中断要处理的工作本身很少,则完全可以直接在顶半部全部完成。

其他操作系统中对中断的处理也采用了类似于Linux的方法,真正的硬件中断服务程序都应该尽可能短。因此,许多操作系统就提供了中断上下文和非中断上下文相结合的机制,将中断的耗时工作保留到非中断上下文去执行。例如,在VxWorks中,网络设备包含接收终端到来后,中断服务程序会通过netJobAdd()函数将耗时的包接收和上传工作交给tNetTask任务去执行。

在Linux中,查看/proc/interrupts文件可以获得系统中中断的统计信息,在单处理器的系统中,第1列是中断号,第2列是向CPU0产生该中断的次数,之后的是对于中断的描述。

10.3 Linux中断编程

10.3.1 申请和释放中断

在Linux设备驱动中,使用中断的设备需要申请和释放对应的中断,分别使用内核提供的request_irq()和free_irq()函数。

1.申请IRQ

int request_irq(unsigned int irq, irq_handler_t handler, unsigned long irqflags, const char *devname, void *dev_id)

irq是要申请的硬件中断号。

handler是向系统登记的中断处理函数(顶半部),是一个回调函数,中断发生时,系统调用这个函数,dev_id参数将被传递给它。

irqflags是中断处理的属性,可以指定中断的触发方式以及处理方式。在触发方式方面,可以是IRQF_TRIGGER_RISING、IRQF_TRIGGER_FALLING、IRQF_TRIGGER_HIGH、IRQF_TRIGGER_LOW等,在处理方式方面,若设置了IRQF_DIABLED,表明中断处理程序是快速处理程序,快速处理程序被调用时屏蔽所有中断,慢速处理程序则不会屏蔽其他设备的驱动;若设置了IRQF_SHARED,则表示多个设备共享中断,dev_id在中断共享时会用到,一般设置为这个设备的设备结构体或者NULL。

request_irq()返回0表示成功,返回-EINVAL表示中断号无效或处理函数指针为NULL,返回-EBUSY表示中断已经被占用且不能共享。

顶半部handler的类型irq_handler_t定义为:

typedef irqreturn_t (*irq_handler_t)(int, void*)

typedef int irqreturn_t;

2.释放IRQ

与request_irq()对应的函数为free_irq(),free_irq()的原型为:void free_irq(unsigned int irq, void *dev_id);

free_irq()中参数的定义与request_irq()相同。

10.3.2 使能和屏蔽中断

下列3个函数用于屏蔽一个中断源

void disable_irq(int irq);

void disable_irq_nosync(int irq);

void enable_irq(int irq);

disable_irq_nosync与disable_irq的区别在于前者立即返回,而后者等待目前的中断处理完成。由于disable_irq会等待指定的中断被处理完,因此如果n号中断的顶半部用disable_irq(n),会引起系统的死锁,这种情况下,只能调用disable_irq_nosync(n)。

下列两个函数(或宏,具体实现依赖于CPU体系结构)将屏蔽本CPU内的所有中断:

#define local_irq_save(flags)

void local_irq_disable(void);

前者会将目前的中断状态保留在flags中(注意flags为unsigned long类型,被直接传递,而不是通过指针),后者直接禁止中断而不保存状态。

与上述两个禁止中断对应的恢复中断的函数(或宏)是:

#define local_irq_restore(flags)

void local_irq_enable(void);

以上各local_开头的方法的作用范围是本CPU内。

10.3.3 底半部机制

Linux实现底半部的机制主要有tasklet、工作队列和软中断。

1.tasklet

tasklet的使用较简单,我们只需要定义tasklet及其处理函数并将两者关联,例如:

void my_tasklet_func(unsigned long); /*定义一个处理函数*/

DECLARE_TASKLET(my_tasklet, my_tasklet_func, data); /*定义一个tasklet结构my_tasklet,与my_tasklet_func(data)函数相关联*/

代码DECLARE_TASKLET(my_tasklet, my_tasklet_func, data)实现了定义名称为my_tasklet的tasklet并将其与my_tasklet_func()这个函数绑定,而传入这个函数的参数为data。

在需要调度tasklet的时候引用一个tasklet_schedule()函数就能使系统在适当的时候进行调度运行:tasklet_schedule(&my_tasklet);

2.工作队列

工作队列的使用方法和tasklet非常相似,下面的代码用于定义一个工作队列和一个底半部执行函数:

struct work_struct my_wq; /*定义一个工作队列*/

void my_wq_func(unsigned long); /*定义一个处理函数*/

INIT_WORK(&my_wq, (void(*)(void*)) my_wq_func, NULL); /*初始化工作队列并将其与处理函数绑定*/

schedule_work(&my_wq); /*调度工作队列执行*/

3.软中断

软中断(softirq)也是传统的底半部处理机制,它的执行时机通常是顶半部返回的时候,tasklet是基于软中断实现的,因此也运行于软中断上下文。

在Linux内核中,用sofrirq_action结构体表征一个软中断,这个结构体中包含软中断处理函数指针和传递给该函数的参数。使用open_softirq()函数可以注册软中断对应的处理函数,而raise_softirq()函数可以触发一个软中断。

软中断和tasklet运行于软中断上下文,仍然属于原子上下文的一种,而工作队则运行于进程上下文。因此,软中断和tasklet处理函数不能睡眠,而工作队列处理函数中允许睡眠。

local_bh_diable()和local_bh_enable()是内核中用于禁止和使能软中断和tasklet底半部机制的函数。

内核中采用softirq的地方包括HI_SOFTIRQ、TIMER_SOFTIRQ、NET_TX_SOFTIRQ、NET_RX_SOFTIRQ、SCSI_SOFTIRQ、TASKLET_SOFTIRQ等,一般来说,驱动的编写者不会也不宜直接使用softirq。

硬中断。、软中断和信号的区别:硬中断是外部设备对CPU的中断,软中断是中断底半部的一种处理机制,而信号则是由内核(或其他进程)对某个进程的中断。在论及系统调用的场合,人们也常说通过软中断(例如ARM为swi)陷入内核,此时软中断的概念是指由软件指令引发的中断,和我们这个地方说的softirq是两个完全不同的概念。

10.3.4 实例:S3C6410实时钟中断

10.4 中断共享

多个设备共享一根硬件中断线的情况咋实际的硬件系统中广泛存在,Linux 2.6支持这种中断共享。下面是中断共享的使用方法。

(1)共享中断的多个设备在申请中断时,都应该使用TRQF_SHARED标志,而且一个设备以IRQF_SHARED申请某中断成功的前提是该中断未被申请,或该中断虽然被申请了,但是之前申请该中断的所有设备也都是以IRQF_SHARED标志申请该中断。

(2)尽管内核模块可访问的全局地址都可以作为request_irq(..., void *dev_id)的最后一个参数dev_id,但是设备结构体指针显然是可传入的最佳参数。

(3)在中断到来时,会遍历执行共享此中断的所有中断处理程序,直到某一个函数返回IRQ_HANDLED。在中断处理程序顶半部中,应迅速地根据硬件寄存器中的信息比照传入的dev_id参数判断是否是设备的中断,若不是,应迅速返回IRQ_NONE。



10.5 内核定时器

10.5.1 内核定时器编程

软件意义上的定时器最终依赖硬件定时器来实现,内核在时钟中断发生后执行检测个定时器是否到期,到期后的定时器处理函数将作为软中断在底半部执行,实质上,时钟中断处理程序会唤起TIMER_SOFTIRQ软中断,运行当前处理器上到期的所有定时器。

在Linux设备驱动编程中,可以利用Linux内核中提供的一组函数和数据结构来完成定时出发工作或者完成某周期性的事务。这组函数和数据结构使得驱动工程师多数情况下不用关心具体的软件定时器究竟对应着怎样的内核和硬件行为。

Linux内核所提供的用于操作定时器的数据结构和函数如下:

1.timer_list

struct timer_list{

struct list_head entry; /*定时器列表*/

unsigned long expires; /*定时器到期时间*/

void (*function)(unsigned long); /*定时器处理函数*/

unsigned long data; /*作为参数被传入定时器处理函数*/

struct timer_base_s ×base;

...

};

在Linux内核中,timer_list结构体的一个实例对应一个定时器。定义方式:struct timer_list my_timer;

2.初始化定时器

void init_timer(struct timer_list * timer);

上述init_timer()函数初始化timer_list的entry的next为NULL,并给base指针赋值。

TIMER_INITIALIZER(_function, _expires, _data)宏用于赋值定时器结构体的function、expires、data和base成员,这个宏的定义为:

#define TIMER_INITALIZER(_function, _expires, _data){ \

.entry={.prev=TIMER_ENTRY_STATIC}, \

.function=(_function), \

.expires=(_expires), \

.data=(_data), \

.base=&boot_tvec_bases, \

}

DEFINE_TIMER(name, _function, _expires, _data)宏是定义并初始化定时器成员的“快捷方式”,这个宏定义为:

#define DEFINE_TIMER(_name, _function, _expires, _data) \

struct timer_list_name=TIMER_INITIALIZER(_function, _expires, _data)

此外,setup_timer()也可用于初始化定时器并复制其成员,其源代码为:

static inline void setup_timer(struct timer_list *timer, void (*function)(unsigned long), unsigned long data)

{

timer->function=function;

timer->data=data;

init_timer(timer);

}

3.增加定时器

void add_timer(struct timer_list *timer);

上述函数用于注册内核定时器,将定时器加入到内核动态定时器链表中。

4.删除定时器

int del_timer(struct timer_list *timer);

del_timere_sync()是del_timer()的同步版,在删除一个定时器时需等待其被处理完,因此该函数的调用不能发生在中断上下文。

5.修改定时器的expire

int mod_timer(struct timer_list *timer, unsigned long expires);

上述函数用于修改定时器的到期时间,在新的被传入的expires到来后才会执行定时器函数。

在定时器处理函数中,在做完相应的工作后,往往会延后expires并将定时器再次添加到内核定时器链表,以便定时器能再次被触发。

10.5.2 内核中延迟的工作delayed_work

注意,对于这种周期性的任务,Linux内核还提供了一套封装好的快捷机制,其本质利用工作队列和定时器实现,这套快捷机制就就是delayed_work。delayed_work结构体的定义如下:

struct delayed_work{

struct work_struct work;

struct timer_list timer;

};

struct work_struct{

atomic_long_t data;

#define WORK_STRUCT_PENDING 0

#define WORK_STRUCT_FLAG_MASK(3UL)

#define WORK_STRUCT_WQ_DATA_MASK(~WORK_STRUCT_FLAG_MASK)

struct list_head entry;

work_func_t func;

#ifdef CONFLG_LOCKDEP

struct lockdep_map lockdep_map;

#endif

};

我们通过如下调度一个delayed_work在指定的延时后执行:int schedule_delayed_work(struct delayed_work *work, unsigned long delay);

当指定的delay到来时delayed_work结构体中work成员的work_func_t类型成员func()会被执行。work_func_t类型定义为:

typedef void (*work_func_t)(struct work_struct *work);

其中delay参数的单位是jiffies,因此一种常见的方法如下:

schedule_delayed_work(&work, msecs_to_jiffies(poll_interval));

其中的msecs_to_jiffies()用于将毫秒转化为jiffies。

如果要周期性的执行任务,通常会在delayed_work的工作函数中再次调用schedule_delayed_work(),周而复始。

如下函数用来取消delayed_work:

int cancel_delayed_work(struct delayed_work *work);

int cancel_delayed_work_sync(struct delayed_work *work);

10.5.3实例:秒字符设备

10.6 内核延时

10.6.1 短延迟

Linux内核中提供了如下3个函数分别进行纳秒、微妙和毫秒延迟:

void ndelay(unsigned long nsecs);

void udelay(unsigned long usecs);

void mdelay(unsigned long msecs);

上述延迟的实现原理本质上是忙等待,它根据CPU频率进行一定次数的循环。有时候,人们在软件中进行这样的延迟:

void delay(unsigned int time)

{

while(time--);

}

ndelay()、udelay()和mdelay()函数的实现方式机理与此类似。内核在启动时,会运行一个延迟测试程序(delay loop calibration),计算lpj(loops per jiffy)。

毫秒时延(以及更大的秒时延)已经比较大,在内核中,最好不要直接使用mdelay()函数,这将无谓地耗费CPU资源,对于毫秒级以上时延,内核提供了下述函数:

void msleep(unsigned int milisecs);

unsigned long msleep_interruptible(unsigned int millisecs);

void ssleep(unsigned int seconds);

上述函数将使得调用它的进程睡眠参数指定的时间,msleep()、ssleep()不能被打断,而msleeo_interruptible()则可以被打断。

受系统Hz以及进程调度的影响,msleep()类似函数的精度是有限的。

10.6.2 长延迟

内核中进行延迟的一个很直观的方法是比较当前的jiffies和目标jiffies(设置为当前jiffies加上时间间隔的jiffies),直到未来的jiffies达到目标jiffies。

与time_befor()对应的还有一个timer_after(),他们在内核中定义为(实际上只是将传入的未来时间jiffies和被调用时的jiffies进行一个简单的比较):

#define time_after(a, b) (typecheck(unsigned long, a) && typecheck(unsigned long, b) && ((long)(b) - (long)(a) < 0))

#define time_befor(a,b) time_after(b.a)

为了防止time_before()和time_after()的比较过程中编译器对jiffies的优化,内核将其定义为volatile变量,这将保证它每次都被重新读取。

10.6.3 睡着延迟

睡着延迟无疑是比较忙等待更好的方式,睡着延迟在等待的时间到来之间进程处于睡眠状态,CPU资源被其他进程使用。schedule_timeout()可以使当前任务睡眠指定的jiffies之后重新被调度执行,msleep()和msleep_interruptible()本质上都是依靠包含了schedule_timeout()的schedule_timeout_uninterruptible()和schedule_timeout_interruptible()实现的。

实际上,schedule_timeout()的实现原理是向系统添加一个定时器,在定时器处理函数中唤醒参数对应的进程。

下面两个函数可以将当前进程添加到等待队列中,从而在等待队列上睡眠。当超时发生时,进程将被唤醒(后者可以在超时前被打断):

sleep_on_timeout(wait_queue_head_t *q, unsigned long timeout);

interruptible_sleep_on_timeout(wait_queue_head_t *q, unsigned long timeout);

11. 内存与I/O访问

11.1 CPU与内存和I/O

11.1.1内存空间与I/O空间

在X86处理器中存在着I/O空间的概念,I/O空间是相对于内存空间而言的,它通过特定的指令in、out来访问。端口号标识了外设的寄存器地址。Intel语法的in、out指令格式如下:

IN 累加器, {端口号|DX]

OUT {端口号|DX},累加器

目前,大多数嵌入式微控制器如ARM、PowerPC等中并不提供I/O空间,而仅存在内存空间。内存空间可以直接通过地址、指针来访问,程序和程序运行中使用的变量和其他数据都存在于内存空间中。

内存地址可以直接由C语言操作,例如在I86处理器中执行如下代码:

unsigned char *p=(unsigned char *)0xF000FF00;

*p=11;

以上程序的意义为在绝对地址0xF0000+0xFF00(186处理器使用16位段地址和16位偏移地址)写入11。

而在ARM、PowerPC等未采用段地址的处理器中,p指向的内存空间就是0xF0000FFF0,而*p=11就是在该地址写入11。

再如,186处理器启动后会在绝对地址0xFFFF0(对应C语言指针是0xF000FFF0,0xF000为段地址,0xFFF0为段内偏移)执行,请看下面的代码:

typedef void (*lpFunction)(); /*定义一个无参数,无返回值类型的函数指针类型*/

lpFunction lpReset=(lpFunction)0xF000FFF0; /*定义一个函数指针,指向CPU启动后所执行第一条指令的位置*/

lpRest(); /*调用函数*/

在以上程序中,没有定义任何一个函数实体,但是程序中却执行了这样的函数调用:lpReset(),它实际上起到了“软重启”的作用,跳转到CPU启动后第一条要执行的指令的位置。因此,可以通过函数指针调用一个没有函数体的“函数”,本质上只是换一个地址开始执行。

即便是在X86处理器中,虽然提供了I/O空间,如果由我们自己设计电路板,外设仍然可以只挂接在内存空间。此时,CPU可以像访问一个内存单元那样访问外设I/O端口,而不需要设立专门的I/O指令。因此,内存空间是必须的,而I/O空间是可选的。下面给出了内存空间和I/O空间的对比:



11.1.2内存管理单元MMU

高性能处理器一般会提供一个内存管理单元(MMU),该单元辅助操作系统进行内存管理,提供虚拟地址和物理地址的映射、内存访问权限保护和Cache缓存控制等硬件支持。操作系统内核借助MMU,可以让用户感觉到好像程序可以使用非常强大的内存空间,从而使得编程人员在写程序时不用考虑计算机中的物理内存的实际容量。

为了理解基本的MMU操作原理,需先明晰几个概念。

(1)TLB:Translation Lookaside Buffer,即转换旁路缓存,TLB是MMU的核型部件,它缓存少量的虚拟地址与物理地址的转换关系,是转换表的Cache,因此也经常被称为“快表”。

(2)TTW:Translation Table walk,即转换表漫游,当TLB中没有缓冲对应的地址转换关系时,需要通过对内存中转换表(大多数处理器的转换表为多级页表)的访问来获得虚拟地址和物理地址的对应关系。TTW成功后。结果应写入TLB。



下面是一个典型的ARM处理器访问内存的过程,其他处理器也执行类似过程。当ARM要访问存储器时,MMU先查找TLB中的虚拟地址表。如果ARM的结构支持分开的数据TLB(DTLB)和指令TLB(ITLB),则除取指令使用ITLB外,其他的都是用DTLB。ARM处理器的MMU如下图所示:



若TLB中没有虚拟地址的入口,则转换表遍历硬件从存放于主存储器中的转换表中获取地址转换信息和访问权限(即执行TTW),同时将这些信息放入TLB。他或者被放在一个没有使用的入口或者替换一个已经存在的入口。之后,在TLB条目中控制信息的控制下,当访问权限允许时,对真实物理地址的访问将在Cache或者在内存中发生。如下图所示:



ARM中的TLB条目中的控制信息用于控制对对应地址的访问权限以及Cache的操作。

C(高速缓存)和B(缓冲)位被用来控制对应地址的高速缓存和写缓冲,并决定是否高速缓存。

访问权限和域位用来控制读写访问是否被允许。如果不允许,则MMU将向ARM处理器发送一个存储器异常,否则访问将被允许进行。

上述描述的MMU机制针对的虽然是ARM处理器,但PowerPC、MIPS等其他处理器也均有类似的操作。

MMU具有虚拟地址和物理地址转换、内存访问权限保护等功能,这将使得Linux操作系统能单独为系统的每个用户进程分配独立的内存空间并保证用户空间不能访问内核空间的地址,为操作系统的虚拟内存管理模块提供硬件基础。

Linux内核使用了三级页表PGD、PMD和PTE,对于许多体系结构而言,PMD这一级实际上只有一个入口。

类型为struct mm_struct的参数mm用于描述Linux进程所占有的内存资源。pgd_offset、pmd_offset分别用于得到一级页表和二级页表的入口,最后通过pte_offset_map得到目标页表项。但是MMU并非所所有处理器都是必须的。

11.2 Linux内存管理

对于包含MMU的处理器而言,Linux系统提供了复杂的存储管理系统,使得进程所能访问的内存达到4GB。

在Linux系统中,进程的4GB内存空间被分为两个部分--用户空间与内核空间。用户空间地址一般分布为0~3GB(即PAGE OFFSET,在0x86中他等于0xC0000000),这样,剩下的3~4GB为内核空间,如下图所示。用户进程通常情况下只能访问用户空间的虚拟地址,不能访问内核空间的虚拟地址。用户进程只有通过系统调用(代表用户进程在内核态执行)等方式才可以访问到内核空间。



每个进程的用户空间都是完全独立的、互不相干的,用户进程各自有不同的页表。而内核空间是由内核负责映射,它并不会跟着进程该表,是固定的。内核空间地址有自己对应的页表,内核的虚拟空间独立于其他程序。

Linux中1GB的内核空间又被划分为物理内存映射区、虚拟内存分配区、高端页面映射区、专用页面映射区和系统保留映射区这几个区域,如下图所示:



一般情况下,物理内存映射区最大长度为896MB,系统的物理内存被顺序映射在内核空间的这个区域中,当系统物理内存大于896MB时,超过物理内存映射区的那部分成为高端内存(而未超过物理内存映射区的内存通常被称为常规内存),内核在存取高端内存时必须将它们映射到高端页面映射区。

Linux保留内核空间最顶部FIXADDR TOP~4GB的区域作为保留区。

紧接着最顶端的保留区以下的一段区域为专用页面映射区(FIXADDR_START~FIXADDR TOP),它的尺寸和每一页的用途由fixed_address枚举结构在编译时预定义,用__fix_to_virt(index)可获取专用区内预定义页面的逻辑地址。其开始地址和结束地址宏定义如下:

#define FIXADDR_START (FIXADDR_TOP - __FIXADDR_SIZE)

#define FIXADDR_TOP ((unsigned long)__FIXADDR_TOP)

#define __FIXADDR_TOP 0xfffff000

接下来,如果系统配置了高端内存,则位于专用页面映射区之下的就是一段高端内存映射区,其起始地址为PKMAP_BASE,定义如下:

#define PKMAP_BASE((FIXADDR_BOOT_START-PAGE_SIZE*(LAST_PKMAP+1)&PDM_MASK)

其中所涉及的宏定义如下:

#define FIXADDR_BOOT_START(FIXADDR_TOP-__FIXADDR_BOOT_SIZE)

#define LAST_PKMAP PTRS_PER_PTE

#define PTRS_PER_PTE 512

#define PMD_MASK (~(PMD_SIZE-1))

#define PMD_SIZE (1UL << PMD_SHIFT)

#define PMD_SHIFT 21

在物理区和高端映射区之间为虚拟内存分配区(VMALLOC_START~VMALLOC_END),用于vmalloc()函数,它的前部与物理内存映射区有一个隔离带,后部与高端映射区也有一个隔离带,vmalloc区域定义如下:

#define VMALLOC_OFFSET(8*1024*1024)

#define VMALLOC_START(((unsigned long) high_memory+vmalloc_earlyreserve + 2*VMALLOC_OFFSET-1) & ~(VMALLOC_OFFSET -1))

#ifdef CONFIG_HIGHMEM /*支持高端内存*/

#define VMALLOC_END(PKMAP_BASE-2*PAGE_SIZE)

#else /*不支持高端内存*/

#define VMALLOC_END (FIXADDR_START-2*PAGE_SIZE)

#endif

当系统物理内存超过4GB时,必须使用CPU的扩展分页(PAE)模式所提供的64位页目录项才能存取到4GB以上的物理内存,这需要CPU的支持。加入了PAE功能的Intel Pentium Pro及其后的CPU允许内存最大可配置到64GB,具备36位物理地址空间寻址能力。

由此可见,在3~4GB之间的内核空间中,从低地址到高地址依次为:物理内存映射区--隔离带--vmalloc虚拟内存分配器--隔离带--高端内存映射区--专用页面映射区--保留区。

11.3内存存取

11.3.1 用户空间内存动态申请

在用户空间动态申请内存的函数为malloc(),这个函数在各种操作系统上的使用时一致的,malloc()申请的内存的释放函数为free()。

malloc()的内存一定要被free(),否则会造成内存泄漏。理想情况下,malloc()和free()应成对出现,即谁申请,就由谁释放。

完全让malloc()和free()成对出现有时候很难做到,即便如此,也应尽力将malloc()申请内存的释放限制在本模块范围之内。

对于Linux内核而言,C库的malloc()函数通常通过brk()和mmap()两个系统调用来实现。

11.3.2 内核空间内存动态申请

在Linux内核空间申请内存设计的函数主要包括kmalloc()、__get_free_pages()和vmalloc等。kmalloc()和__get_free_pages()(及其类似函数)申请的内存位于物理内存映射区域,而且在物理上也是连续的,它们与真实的物理地址只有一个固定的偏移,因此存在较简单的转换关系。而vmalloc()在虚拟内存空间给出一块连续的内存区,实质上,这片连续的虚拟内存在物理内存中并不一定连续,而vmalloc()申请的虚拟内存和物理内存之间也没有简单的换算关系。

1.kmalloc()

void *kamlloc(size_t size, int flags);

给kmalloc()的第一个参数是要分配的块的大小,第二个参数为分配标志,用于控制kmallockmalloc()的行为。

最常用的分配标志是GFP_KERNEL,其含义是在内核空间的进程中申请内存。kmalloc()的底层依赖__get_free_pages()实现,分配标志的前缀GFP正好是这个底层函数的缩写。使用GFP_KERNEL标志申请内存时,若暂时不能满足,则进程会睡眠等待页,即会引起阻塞,因此不能在中断上下文或持有自旋锁的时候使用GPF_KENNEL申请内存。

在中断处理函数、tasklet和内核定时器等非进程上下文中不能阻塞,此时驱动应当使用GFP_ATOMIC标志来申请内存。当使用GFP_ATOMIC标志申请内存时,若不存在空闲页,则不等待,直接返回。

其他的相对不常用的申请标志还包括GFP_USER(用来为用户空间页分配内存,可能阻塞)、GFP_HIGHUSER(类似GFP_USER,但是从高端内存分配)、GFP_MOIO(不允许任何I/O初始化)、GFP_NOFS(不允许进行任何文件系统调用)、__GFP_DMA(要求分配在能够DMA的内存区)、__GFP_HIGHMEM(指示分配的内存可以位于高端内存)__GFP_COLD(请求一个较长时间不访问的页)、__GFP_NOWWRN(当一个分配无法满足时,阻止内核发出警告)、__GFP_HIGH(高优先级请求,允许获得被内核保留给紧急状况使用的最后的内存页)、__GFP_REPEAT(分配失败则尽力重复尝试)、__GFP_NOFAIL(标志只须申请成功,不推荐)和__GFP_NORETRY(若申请不到,则立即放弃)。

使用kmalloc()申请的内存应使用kfree()释放,这个函数的用法和用户空间的free()类似。

2.__get_free_pages()

__get_free_pages()系列函数/宏是Linux内核本质上最底层的用于获取空闲内存的方法,因为底层的伙伴算法以page的2的n次幂为单位管理空闲内存,所以最底层的内存申请总是以页为单位的。

__get_free_pages()系列函数/宏包括get_zeroed_page()、__get_free_page()和__get_free_pages()。

get_zeroed_page(unsigned int flags); 该函数返回一个指向新页的指针并且将该页清零。

__get_free_page(unsigned int falgs); 该宏返回一个指向新页的指针但是该页不清零,它实际上为:#define __get_free_page(gfp_mask) __get_free_pages((gfp_mask), 0)就是调用下面的__get_free_pages()申请1页。

__get_free_pages(unsigned int flags, unsigned int order); 该函数可分配多个也并返回内存的首地址,分配的页数为2的order幂次,分配的页也不清零。order允许的最大值是10(即1024页)或者11(即2048页),依赖于具体的硬件平台。

__get_free_pages()和get_zeroed_page()的实现中调用了alloc_pages()函数,alloc_pages()既可以在内核空间分配,也可以在用户空间分配,其原型为:struct page * alloc_pages(int gfp_mask, unsigned long order);参数含义与__get_free_pages()类似,但它返回分配的第一页的描述符而非首地址。

使用__get_free_pages()系列函数/宏申请的内存应使用下列函数释放:

void free_page(unsigned long addr);

void free_page(unsigned long addr, unsigned long order);

如果申请和释放的order不一样,则会引起内存的混乱。

__get_free_pages等函数在使用时,其申请标志的值为kmalloc()完全一样,各标志的含义也与kmalloc()完全一致,最常见的是GFP_KERNEL和GFP_ATOMIC。

3.vmalloc()

vmalloc()一般用于在为只存在于软件中(没有对应的硬件意义)的较大的顺序缓冲区分配内存,vamlloc()远大于__get_free_pages()的开销,为了完成vmalloc()。因此,只是调用vmalloc()来分配少量的内存(如1页)是不妥的。

vmalloc()申请的内存应使用vfree()释放,vmalloc()和vfree()的函数原型如下:

void *vmalloc(unsigned long size);

void *vfree(void *addr);

vmalloc()不能用在原子上下文中,因为它的内部实现使用了标志为GFP_KERNEL的kmalloc()。

使用vmalloc()不能用在原子上下文中,因为它的内部实现使用了标志为GFP_KERNEL的kmalloc()。

使用vmalloc函数的一个例子函数是create_module()系统调用,它利用vmalloc()函数来获取被创建模块需要的内存空间。

4.slab与内存池

一方面,完全使用页为单元申请和释放内存容易导致浪费(如果要申请少量字节也需要1页);另一方面,在操作系统的运作过程中,经常会涉及大量对象的重复生成、使用和释放内存问题。在Linux系统中所用到的对象,比较典型的例子是inode、task_struct等。如果我们能够用合适的方法使得在对象前后两次被使用时分配在一块内存或同一类内存空间且保留了基本的数据结构,就可以大大提高效率。slab算法就是针对上述特点设计的。实际上kmalloc()即是使用slab机制实现的。

(1)创建slab缓存

struct kmem_cache *kmem_cache_create(const char *name, size_t size, size_t align, unsigned long flags, void (*ctor)(void*, struct kmem_cache*, unsigned long), void(*dtor)(void*, struct kmem_cache*, unsigned
long));

kmem_cache_create()用于创建一个slab缓存,它是一个可以驻留任意数目全部同样大小的后备缓存。参数size是要分配的每个数据结构的大小,参数flags是控制如何进行分配的位掩码,包括SLAB_NO_REAP(即使内存紧缺也不自动收缩这块缓存)、SLAB_HWCACHE_ALIGN(每个数据对对象被对齐到一个缓存行)、SLAB_CACHE_DMA(要求数据对象在DMA内存区分配)等。

(2)分配slab缓存

void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags);

上述函数在kmem_cache_create()创建的slab后备缓存中分配一块并返回首地址指针。

(3)释放slab缓存

void kmem_cache_free(struct kmem_cache *cachep, void *objp);

上述函数释放由kmem_cache_alloc()分配的缓存。

(4)收回slab缓存

int kmem_cache_destory(struct kmem_cache *cachep);

在系统中通过/proc/slabinfo节点可以获得当前slab的分配和使用情况。

注意,slab不是要替代__get_free_pages(),其在最底层仍然依赖于__get_free_pages(),slab在底层每次申请1页或多页,之后再分隔这些页为更小的单元进行管理,从而节省了内存,也提高了slab缓冲对象的访问效率。

除了slab以外,在Linux内核中还包含对内存池的支持,内存池技术也是一种非常经典的用于分配大量小对象的后备缓存技术。

Linux内核中,与内存池相关的操作包括如下几种。

(1)创建内存池

mempool_t *mempool_create(int min_nr, mempool_alloc_t *alloc_fn, mempool_free_t *free_fn, void *pool_data);

mempool_create()函数用于创建一个内存池,min_nr参数是需要预分配对象的数目,alloc_fn和free_fn是指向内存池机制提供的标准对象分配和回收函数的指针,其原型分别为:typedef void *(mempool_alloc_t)(int gfp_mask, void *pool_data);和typedef void (mempool_free_t)(void *element,
void *pool_data);

pool_data是分配和回收函数用到的指针,gfp_mask是分配标识。只有当__GFP_WAIT标记被指定时,分配函数才会休眠。

(2)分配和回收对象

在内存池中分配和回收对象需由以下函数来完成:

void *mempool_alloc(mempool_t *pool, int gfp_mask);

void mempool_free(void *element, mempool_t *pool);

mempool_alloc()用来分配对象,如果内存池分配器无法提供内存,那么就可以用预分配的池。

(3)回收内存池

void mempool_destroy(mempool_t *pool);

mempool_create()函数创建的内存池需由mempool_destroy()来回收。

11.3.3虚拟地址与物理地址关系

对于内核物理内存映射区的虚拟内存,使用virt_to_phys()可以实现内核虚拟地址转化为物理地址,virt_to_phys()的实现是体系结构相关的,对于ARM而言,virt_to_phys()的定义如下代码所示:

static inline unsigned long virt_to_phys(void *x)

{

return __virt_to_phys((unsigned long)(x));

}

#define __virt_to_phys(x) ((x)-PAGE_OFFSET+PHYS_OFFSET)

上面转换过程的PAGE_OFFSET通常为3GB,而PHYS_OFFSET则定义为系统DRAM内存的基地址。

与之对应的函数为phys_to_virt(),它将物理地址转化为内核虚拟地址,phys_to_virt()的定义如下代码所示:

static inline void *phys_to_virt(unsigned long x)

{

return (void*)(__phys_to_virt((unsigned long)(x)));

}

#define __phys_to_virt(x) ((x)-PHYS_OFFSET+PAGE_OFFSET)

注意,上述virt_to_phys()和phys_to_virt()方法仅适用于896MB以下的低端内存,高端内存的虚拟地址与物理内存之间不存在如此简单的换算关系。

11.4 设备I/O端口和I/O内存的访问

设备通常会提供一组寄存器来用于控制设备、读写设备和获取设备状态,即控制寄存器、数据寄存器和状态寄存器。这些寄存器可能位于I/O空间,也可能位于内存空间。当位于I/O空间时,通常被称为I/O端口,位于内存空间时,对应的内存空间被称为I/O内存。

11.4.1 Linux I/O端口和I/O内存访问接口

1.I/O端口

在Linux设备驱动中,应使用Linux内核提供的函数来访问定位于I/O空间的端口,这些函数包括如下几种。

(1)读写字节端口(8位宽)

unsigned inb(unsigned port);

void outb(unsigned char byte, unsigned port);

(2)读写字端口(16位宽)

unsigned inw(unsigned port);

void outw(unsigned short word, unsigned port);

(3)读写长字端口(32位)

unsigned inl(unsigned port);

void outl(unsigned longword, unsigned port);

(4)读写一串字节

void insb(unsigned port, void *addr, unsigned long count);

void outsb(unsigned port, void *addr, unsigned long count);

(5)insb()从端口port开始读count个字节端口,并将读取结果写入addr指向的内存;outsb将addr指向的内存的count个字节连续地写入port开始的端口。

(6)读写一串字

void insw(unsigned port, void *addr, unsigned long count);

void outsw(unsigned port, void *addr, unsigned long count);

(7)读写一串长字

void insl(unsigned port, void *addr, unsigned long count);

void outsl(unsigned port, void *addr, unsigned long count);

上述各函数中I/O端口号port的类型高度依赖于具体的硬件平台,因此,只是写出了unsigned。

2.I/O内存

在内核中访问I/O内存之前,需首先使用ioremap()函数将设备所处的物理地址映射到虚拟地址,ioremap()的原型如下:

void *ioremap(unsigned long offset, unsigned long size);

ioremap()与vmalloc()类似,也需要建立新的页表,但是它并不进行vmalloc()中所执行的内存分配行为。ioremap()返回一个特殊的虚拟地址,该地址可用来存取特定的物理地址范围。通过ioremap()获得的虚拟地址应该被iounmap()函数方式,其原型如下:

void iounmap(void * addr);

在设备的物理地址被映射到虚拟地址之后,尽管可以直接通过指针访问这些地址,但是可以使用Linux内核的如下一组函数来完成设备内存映射的虚拟地址的读写,这些函数如下所示。

(1)读I/O内存

unsigned int ioread8(void *addr);

unsigned int ioread16(void *addr);

unsigned int ioread32(void *addr);

与上述函数对应的较早版本的函数为(这些函数在Linux2.6中仍然被支持):

unsigned readb(address);

unsigned readw(address);

unsigned readl(address);

(2)写I/O内存

void iowrite8(u8 value, void* addr);

void iowrite16(u16 value, void *addr);

void iowrite32(u32 value, void *addr);

与上述函数对应的较早版本的函数为(这些函数在Linux 2.6中仍然被支持):

void writeb(unsigned value, address);

void writew(unsigned value, address);

void writel(unsigned value, address);

(3)读一串I/O内存

void ioread8_rep(void *addr, void *buf, unsigned long count);

void ioread16_rep(void *addr, void *buf, unsigned long count);

void ioread32_rep(void *addr, void *buf, unsigned long count);

(4)写一串I/O内存

void iowrite8_rep(void *addr, const void *buf, unsigned long count);

void iowrite16_rep(void *addr, const void *buf, unsigned long count);

void iowrite32_rep(void *addr, const void *buf, unsigned long count);

(5)复制I/O内存

void memcpy_fromio(void * dest, void *source, unsigned int count);

void memcpy_toio(void *dest, void *soutce, unsigned int count);

(6)设置I/O内存

void memset_io(void *addr, u8 value, unsigned int count);

3.把I/O端口映射到内存空间

void *ioport_map(unsigned long port, unsigned int count);

通过这个函数,可以把port开始的count个连续的I/O端口映射为一段“内存空间”,然后就可以在其返回的地址上像访问I/O内存一样访问这些I/O端口。当不再需要这种映射时,需要调用下面的函数来撤销。

void ioport_unmap(void *addr);

实际上,分析ioport_map()的源码可发现,映射到内存空间行为实际上是给开发人员制造一个“假象”,并没有映射到内核虚拟地址,仅仅是为了让工程师可使用同一的I/O内存访问接口访问I/O端口。

11.4.2 申请与释放设备I/O端口和I/O内存

1.I/O端口申请

Linux内核提供了一组函数用于申请和释放I/O端口

struct resoutce *request_region(unsigned long first, unsigned long n, const char *name);

该函数向内核申请n个端口,这些端口从first开始,name参数为设备的名称,如果分配成功返回值是非NULL,如果返回NULL,则意味着申请失败。

当用reuqest_region()申请的I/O端口使用完成后,应当使用release_region()函数将他们归还给系统,这个函数的原型如下:

void release_region(unsigned long start, unsigned long n);

2.I/O内存申请

同样,Linux内核提供了一组函数用于申请和释放I/O内存的范围。

struct resource *request_mem_region(unsigned long start, unsigned long len, char *name);

该函数向内核申请n个内存地址,这些地址从first开始,name参数为设备的名称。如果分配成功返回值是非NULL,如果返回NULL,则意味着申请I/O失败。

当用request_mem_region()申请的I/O内存使用完成后,应当使用release_mem_region()函数将它们归还给系统,这个函数的原型如下:

request_mem_region(unsigned long start, unsigned long len);

上述request_region()和release_mem_region()都不是必须的,但建议使用,其任务是检查申请的资源是否可用,如果可用则申请成功,并标志为已经使用,其他驱动想再次申请该资源时就会失败。

有许多设备驱动程序在没有申请I/O端口和I/O内存之前就直接访问了,这不够安全。

11.4.3 设备I/O端口和I/O内存访问流程

I/O端口访问的一种途径是直接使用I/O端口操作函数:在设备打开或驱动模块被加载时申请I/O端口区域,之后使用inb()、outb()等进行端口访问,最后,在设备关闭或驱动被卸载时释放I/O端口范围。流程图如下图所示:



I/O端口访问的另一种途径是将I/O端口映射为内存进行访问:在设备打开或驱动模块被加载时,申请I/O端口区域并使用ioport_map()映射到内存,之后使用I/O内存的函数进行端口访问,最后,在设备关闭或驱动被卸载时释放I/O端口并释放映射。流程图如下图所示:



I/O内存的访问步骤如下图所示,首先是调用request_mem_region()申请资源,接着将寄存器地址通过ioremap()映射到内核空间虚拟地址,之后就可以通过Linux设备访问编程接口访问这些设备的寄存器了。访问完成后,应对ioremap()申请的虚拟地址进行释放,并释放release_mem_region()申请的I/O内存资源。



11.4.4 将设备地址映射到用户空间

1.内存映射与VMA

一般情况下,用户空间是不可能也不应该直接访问设备的,但是,设备驱动程序中可实现mmap()函数,这个函数可使得用户空间直接访问设备的物理地址。实际上,mmap()实现了这样的一个映射过程:它将用户空间的一段内存与设备内存管理,当用户访问用户空间的这段地址范围时,实际上会转化为对设备的访问。

这种能力对于显示适配器一类的设备非常有意义,如果用户空间可直接通过内存映射访问显存的话,屏幕帧的各点的像素将不再需要一个从用户空间到内核空间的复制的过程。

mmap()必须以PAGE_SIZE为单位进行映射,实际上,内存只能以页为单位进行映射,若要映射非PAGE_SIZE整数倍的地址范围,要先进行页对齐,强行以PAGE_SIZE的倍数大小进行映射。

从file_operations文件操作结构体可以看出,驱动中mmap()函数的原型如下:

int(*mmap)(struct file *, struct vm_area_struct *);

驱动中的mmap()函数将在用户进行mmap()系统调用时最终被调用,mmap()系统调用的原型与file_operations中mmap()的原型区别很大,如下所示:

caddr_t mmap(caddr_t addr, size_t len, int port, int flags, int fd, off_t offset);

参数fd为文件描述符,一般由open()返回,fd也可以指定为-1,此时需指定flags参数中MAP_ANON,表明进行的是匿名映射。

len是映射到调用用户空间的字节数,它从被映射文件开头offset个字节开始算起,offset参数一般设为0,表示从文件开始映射。

port参数指定访问权限,可取如下几个值得“或”:PROT_READ(可读)、PROT_WRITE(可写)、PROT_EXEC()可执行和PROT_NONE(不可访问)。

参数addr指定文件应被映射到用户空间的起始地址,一般被指定为NULL,这样,选择起始地址的任务将由内核完成,而函数的返回值就是映射到用户空间的地址。其类型caddr_t实际上就是void*。

当用户调用mmap()的时候,内核会进行如下处理:

(1)在进程的虚拟空间查找一块VMA。

(2)将这块VMA进行映射。

(3)如果设备驱动程序或者文件系统的file_operations定义了mmap()操作,则调用它。

(4)将这个VMA插入进程的VMA链表中。

由mmap()系统调用映射的内存可由munmap()解除映射,这个函数的原型如下:

int munmap(caddr_t addr, size_t len);

驱动程序中mmap()的实现机制是建立页表,并填充VMA结构体中vm_operations_struct指针。VMA即vm_area_struct,用于描述一个虚拟内存区域。

VMA结构体描述的虚拟地址介于vm_start和vm_end之间,而其vm_ops成员指向这个VMA的操作集。针对VMA的操作都被包含在vm_operations_struct结构体中。

在内核生成一个VMA后,它会调用该VMA的open()函数,例如fork一个继承父继承资源的子进程时。但是,当用户进行mmap()系统调用后,尽管VMA在设备驱动文件操作结构体的mmap()被调用前就已产生,内核却不会调用VMA的open()函数,通常需要在驱动的mmap()函数中显式调用vma->vm_ops->open()。

当调用的remap_pfn_range()创建页表,以VMA结构体的成员(VMA的数据成员是内核根据用户的请求自己填充的)作为remap_pfn_range()的参数,映射的虚拟地址范围是vma->vm_start至vma->vm_end。

remap_pfn_range()函数的原型如下:

int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr, unsigned long pfn, unsigned long size, pgprot_t prot);

其中的addr参数表示内存映射开始处的虚拟地址。remap_pfn_range()函数为addr~addr+size之间的虚拟地址构造页表。

pfn是虚拟地址应该映射到物理地址的页帧号,实际上就是物理地址右移PAGE_SHIFT位。若PAGE_SIZE为4KB,则PAGE_SHIFT为12,因为PAGE_SIZE等于1<<PAGE_SHIFT。

prot是新页所要求的保护属性。

在驱动程序中,我们能使用remap_pfn_range()映射内存中的保留页(如X86系统中的640KB~1MB区域)和设备I/O内存,另外,kmalloc()申请的内存若要被映射到用户空间可以通过mem_map_reserve()设置为保留后进行。

调用remap_page_range(start, page, PAGE_SIZE, PAGE_SHARED);的第4个参数PAGE_SHARED实际上是_PAGE_PRESENT|_PAGE_USER|_PAGE_RW,表明可读写并映射到用户空间。

通常,I/O内存被映射时需要时nocache的,这时候,我们应该对vma->vm_page_prot设置nocache标志之后再映射。

pgprot_noncached()是一个宏,它高度依赖于CPU体系结构,ARM的pgprot_noncached()定义如下:

#define pgprot_noncached(prot) __pgprot(pgprot_val(prot)&~(L_PTE_CACHEABLE | L_PTE_BUFFERABLE))

另一个比pgprot_noncached()稍微少一些限制的宏是pgprot_writecombine(),它的定义如下:

#define pgprot_writecombine(prot) _pgprot(pgprot_val(prot) & ~L_PTE_CACHEABLE)

pgprot_noncached()实际禁止了相关页的Cache和写缓冲(write buffer),pgprot_writecombine()则没有禁止写缓冲。ARM的写缓冲是一个非常小的FIFO存储器,位于处理器与主存之间,其目的在于将处理器核和Cache从较慢的主存写操作中解脱出来。写缓冲区与Cache在存储层次上处于同一层次,但是它只作用于写主存。

2.nopage()函数

除了remap_pfn_range()以外,在驱动程序中实现VMA的nopage()函数通常可以为设备提供更加灵活的内存映射途径。当访问的页不在内存,即发生缺页异常时,nopage()会被内核自动调用。这是因为,当发生缺页异常时,系统会经过如下处理过程:

(1)找到缺页的虚拟地址所在的VMA。

(2)如果必要,分配中间页目录表和页表。

(3)如果页表对应的物理页面不存在,则调用这个VMA的nopage()方法,它返回物理页面的页描述符。

(4)将物理页面的地址填充到页表中。

实现nopage()后,用户空间可以通过mremap()系统调用重新绑定映射区域所绑定的地址。

nopage()与remap_pfn_range()的一个较大区别在于remap_pfn_range()一般用于设备内存映射,而nopage()还可用于RAM映射,其调用发生在缺页异常时。

大多是设备驱动都不需要提供设备内存到用户空间的映射能力,因为,对于串口等面向流的设备而言,实现这种映射毫无意义。而对于显示、视频等设备,建立映射可减少用户空间和内核空间之间的内存拷贝。

11.5 I/O内存静态映射

在将Linux一直到目标电路板的过程中,通常会建立外设I/O内存物理地址到虚拟地址的静态映射,这个映射通过在电路板对应的map_desc结构体数组中添加新的成员来完成,map_desc结构体的定义如下:

struct map_desc{

unsigned long virtual; /*虚拟地址*/

unsigned long pfn; /*__phys_to_pfn(phy_addr)*/

unsigned long length; /*大小*/

unsigned int type; /*类型*/

};

在设备驱动中访问经过map_desc数组映射后的I/O内存时,直接在map_desc中该段的虚拟地址上加上相应的偏移即可,不再需要使用ioremap()。

11.6 DMA

DMA是一种无序CPU的参与就可以让外设与系统内存之间进行双向数据传输的硬件机制。使用DMA可以使系统CPU从实际的I/O数据传输过程中摆脱出来,从而大大提高系统的吞吐率。DMA通常与硬件体系结构特别是外设的总线技术密切相关。

DMA方式的数据传输由DMA控制器(DMAC)控制,在传输期间,CPU可以并发地执行其他任务。当DMA结束后,DMAC通过中断通知CPU数据传输已经结束,然而由CPU执行相应的中断服务程序进行后处理。

11.6.1 DMA与Cache一致性

Cache和DMA本身似乎是两个毫不相关的事物。Cache被用作CPU针对内存的缓存,利用程序的空间局部性和时间局部性原理,达到较高的命中率从而避免CPU每次都一定要与相对慢速的内存交互数据来提高数据的访问数率。DMA可以用作内存与外设之间传输数据的方式,这种传输方式之下,数据并不需要经过CPU中转。

假设DMA针对内存的目的地址与Cache缓存的对象没有重叠区域,DMA和Cache之间将相安无事。但是,如果DMA的目的地址与Cache所缓存的内存地址访问有重叠,经过DMA操作,Cache缓存对应的内存的数据已经被修改,而CPU本身并不知道,它仍然认为Cache中的数据就是内存中的数据,以后访问Cache映射的内存时,它仍然使用陈旧的Cache数据,这样就发生Cache与内存之间数据“不一致性”的错误。



所谓Cache数据与内存数据的不一致性,是指在采用Cache的系统中,同样一个数据可能既存在于Cache中,也存在于主存中,Cache与主存中的数据一样则具有一致性,数据若不一样则具有不一致性。

需要特别注意的是,Cache与内存的一致性问题经常被初学者遗忘。在发生Cache与内存不一致性错误后,驱动将无法正常运行。如果没有相关的背景知识,工程师几乎无法定位错误的原因,因为看起来所有的程序都是完全正确的。

解决由于DMA导致的Cache一致性问题的最简单方法是直接禁止DMA目标地址范围内内存的Cache功能。当然,这将牺牲性能,但是却更可靠。

Cache的不一致问题并非只是发生在DMA的情况下,实际上,还存在于Cache使能和关闭的时刻。例如,对于带MMU功能的ARM处理器,在开启MMU之前,需要先置Cache无效,TLB也是如此。

11.6.2 Linux下的DMA编程

首先DMA本身不属于一种等同于字符设备、块设备和网络设备的外设,它只是外设与内存交互数据的一种方式。

内存中用于与外设交互数据的一块区域被称做DMA缓存区,在设备不支持scatter/gather(分散/聚集,简称SG)操作的情况下,DMA缓冲区必须是物理上连续的。

1.DMA ZONE

对于X86系统的ISA设备而言,其DMA操作只能在16MB以下的内存中进行,因此,在使用,kmalloc()和__get_free_pages()及其类似函数申请DMA缓冲区时应使用GFP_DMA标志,这样能保证获得的内存位于DMA_ZONE,是具备DMA能力的。

内核中定义了__get_free_pages()针对DMA的“快捷方式”__get_dma_pages(),它在申请标志中添加了GFP_DMA,如下所示:

#define __get_dma_pages(gfp_mask, order) __get_free_pages((gfp_mask) | GFP_DMA, (order))

如果不想使用log2size即order为参数申请DMA内存。则可以使用另一个函数dma_mem_alloc()。

对于大多数现代嵌入式处理器而言,DMA操作可以在整个常规内存区域进行,因此DMA ZONE就直接覆盖了常规内存。

2.虚拟地址,物理地址和总线地址

基于DMA的硬件使用总线地址而非物理地址,总线地址是从设备角度上看到的内存地址,物理地址则是从CPU MMU控制器外围角度上看到的内存地址(从CPU核角度看到的是虚拟地址)。虽然在PC上,对于ISA和PCI而言,总线地址即物理地址,但并非每个平台都是如此。因为有时候接口总线通过桥接电路被连接,桥接电路会将I/O地址映射为不同的物理地址。例如,在PReP(PowerPC Reference Platform)系统中,物理地址0在设备端看起来是0x80000000,而0通常又被映射为虚拟地址0xC0000000,所以同一地址就具备了三重身份:物理地址0、总线地址0x80000000及虚拟地址0xC0000000。还有一些系统提供了页面映射机制,它能将任意的页面映射为连续的外设总线地址。内核提供了如下函数用于进行简单的虚拟地址/总线地址转换:

unsigned long virt_to_bus(volatile void *address);

void *bus_to_virt(unsigned long address);

在使用IOMMU或反弹缓冲区的情况下,上述函数一般不会正常工作,而且,这两个函数并不建议使用。如下图所示,IOMMU的工作原理与CPU内的MMU非常类似,不过它针对的是外设总线地址和内存地址之间的转化。由于IOMMU可以使得外设DMA引擎看到“虚拟地址”,因此在使用IOMMU的情况下,在修改映射寄存器后,可以使得SG中分段的缓冲区地址对外设变得连续。



3.DMA地址掩码

设备并不一定能在所有的内存地址上执行DMA操作,在这种情况下应该通过下列函数执行DMA地址掩码:

int dma_set_mask(struct device *dev, u64 mask);

例如,对于只能在24位地址上执行DMA操作的设备而言,就应该调用dma_set_mask(dev, 0xffffff);

4.一致性DMA缓冲区

DMA映射包括两个方面的工作:分配一片DMA缓冲区;为这片缓冲区产生设备可访问的地址。同时,DMA映射也必须考虑Cache一致性问题。内核提供了以下函数用于分配一个DMA一致性的内存区域:

void *dma_alloc_coherent(struct device *dev, size_t size, dma_addr_t *handle, gfp_t gfp);

上述函数的返回值为申请到的DMA缓冲区的虚拟地址,此外,该函数还通过参数handle返回DMA缓冲区的总线地址。handle的类型为dma_addr_t,代表的是总线地址。

dma_alloc_coherent()申请一片DMA缓冲区,进行地址映射并保证该缓冲区的Cache一致性。与dma_alloc_coherent()对应的释放函数为:

void dma_free_coherent(struct device *dev, size_t size, void *cpu_addr, dma_addr_t handle);

以下函数用于分配一个写合并(writecombining)的DMA缓冲区:

void *dma_alloc_writecombine(struct device *dev, size_t size, dma_addr_t *handle, gfp_t gfp);

与dma_alloc_writecombine()对应的释放函数dma_free_writecombine()实际上就是dma_free_coherent(),因为它定义为:

#define dma_free_writecombine(dev, size, cpu_addr, handle) dma_free_coherent(dev, size, cpu_addr, handle)

此外,Linux内核还提供了PCI设备申请DMA缓冲区的函数pci_alloc_consistent(),其原型为:

void *pci_alloc_consistent(struct pci_dev *pdev, size_t size, dma_addr_t *dma_addrp);

对应的释放函数为pci_free_consistent(),其原型为:

void pci_free_consistent(struct pci_dev *pdev, size_t size, void *cpu_addr, dma_addr_t dma_addr);

5.流式DMA缓冲区

并非所有的DMA缓冲区都是驱动申请的,如果是驱动申请的,用一致性DMA缓冲区自然最方便,直接考虑了Cache一致性问题,但是,许多情况下,缓冲区来自内核的较上层(如网卡驱动中的网络报文、块设备驱动中要写入设备的数据等),上层很可能用的是普通的kmalloc()、__get_free_pages()等方法申请,这时候就要使用流式DMA映射。流式DMA缓冲区使用的一般步骤如下:

(1)进行流式DMA映射。

(2)执行DMA操作。

(3)进行流式DMA去映射。

流式DMA映射操作在本质上多数就是进行Cache的invalidata或flush操作,以解决Cache一致性问题。

相对于一致性DMA映射而言,流式DMA映射的接口较为复杂。对于单个已经分配的缓冲区而言,使用dma_map_single()可实现流式DMA映射,该函数的原型为:

dma_addr_t dma_map_single(struct device *dev, void *buffer, size_t size, enum dma_data_direction direction);

如果映射成功,返回的是总线地址,否则,返回NULL。第4个参数为DMA的方向,可能的值包括DMA_TO_DEVICE、DMA_FROM_DEVICE、DMA_BIDIRECTIONAL和DMA_NONE。

dma_map_single()反函数为dma_unmap_single(),原型是:

void dma_unmap_single(struct device *dev, dma_addr_t dma_addr, size_t size, enum dma_data_direction direction);

通常情况下,设备驱动不应该访问unmap的流式DMA缓冲区,如果一定要这么做,可先使用如下函数获得DMA缓冲区的拥有权:

void dma_sync_single_for_cpu(struct device *dev, dma_handle_t bus_addr, size_t size, enum dma_data_direction direction);

在驱动访问完DMA缓冲区后,应该将其所有权返还给设备,通过如下函数完成:

void dma_sync_single_for_device(struct device *dev, dma_handle_t bus_addr, size_t size, enum dma_data_direction direction);

如果设备要求较大的DMA缓冲区,在其支持SG模式的情况下,申请不连续的多个相对较小的DMA缓冲区通常是防止申请太大的连续物理空间的方法。在Linux内核中,使用如下函数映射SG:

int dma_map_sg(struct device *dev, struct scatterlist *sg, int nents, enum dma_data_direction direction);

nents是散列表(scatterlist)入口的数量,该函数的返回值是DMA缓冲区的数量,可能小于nents。对于scatterlist中的每个项目,dma_map_sg()为设备产生恰当的总线地址,它会合并物理上临近的内存区域。

scatterlist结构体的定义如下,它包含了scatterlist对应的page结构体指针、缓冲区在page中的偏移(offset)、缓冲区长度(length)以及总线地址(dma_address).

struct scatterlist{

struct page *page;

unsigned int offset;

dma_addr_t dma_address;

unsigned int length;

}

执行dma_map_sg()后,通过sg_dma_address()可返回scatterlist对应缓冲区的总线地址,sg_dma_len()可返回scatterlist对应缓冲区的长度,这两个函数的原型为:

dma_addr_t sg_dma_address(struct scatterlist *sg);

unsigned int sg_dma_len(struct scatterlist *sg);

在DMA传输结束后,可通过dma_map_sg()的反函数dma_unmap_sg()去除DMA映射:

void dma_unmap_sg(struct device *dev, struct scatterlist *list, int nents, enum dma_data_direction direction);

SG映射属于流式DMA映射,与单一缓冲区情况下的流式DMA映射类似,如果设备驱动一定要访问映射情况下的SG缓冲区,应该先调用如下函数:

void dma_sync_sg_for_cpu(struct device *dev, struct scatterlist *sg, int nents, enum dma_data_direction direction);

访问完成后,通过下列函数将所有权返回给设备:

void dma_sync_sg_for_device(struct device *dev, struct scatterlist *sg, int nents, enum dma_data_direction direction);

Linux系统中可以有一个相对简单的方法预先分配缓冲区,那就是同步"mem="参数预留内存。例如,对于内存为64MB的系统,通过传递mem=62MB命令行参数可以使得顶部2MB内存被预留出来作为I/O内存使用,这2MB内存可以被静态映射,也可以被执行ioremap()。

6.申请和释放DMA通道

和中断一样,在使用DMA之前,设备驱动程序需首先向系统申请DMA通道,申请DMA通道的函数如下:

int request_dma(unsigned int dmanr, const char *device_id);

同样的,设备结构体指针可作为传入device_id的最佳参数。

使用完DMA通道后,应该利用如下函数释放该通道:

void free_dma(unsigned int dmaer);

现在可以总结出在Linux设备驱动中DMA相关代码的流程如下图所示:



12.工程中的Linux设备驱动

12.1 platform设备驱动

12.1.1 platform总线、设备与驱动

在Linux 2.6的设备驱动模型中,关心总线、设备和驱动这3个实体,总线将设备和驱动绑定。在系统每注册一个设备的时候,会寻找与之匹配的驱动;相反的,在系统每注册一个驱动的时候,会寻找与之匹配的设备,而匹配由总线完成。

一个现实的 Linux设备和驱动通常都需要挂载在一种总线上,对于本身依附于PCI、USB、I2C、SPI等的设备而言,这自然不是问题,但是在嵌入式系统里面,SoC系统中集成的独立的外设控制器、挂在在SoC内存空间的外设等确不依赖于此类总线。基于这一背景,LInux发明了一种虚拟的总线,成为platform总线,相应的设备称为platform_device,而驱动成为platform_driver。

注意,所谓的platform_device并不是与字符设备、块设备和网络设备并列的概念,而是Linux系统提供的一种附加手段。

platform_driver这个结构体中包含probe()、remove()、shutdown()、suspend()、resume()函数,通常也需要由驱动实现。

系统中为platform总线定义了一个bus_type的实例platform_bus_type。

重点关注其match()成员函数,正是此成员函数确定了platform_device和platform_driver之间如何匹配。匹配platform_device和platform_driver主要看两者的name字段是否相同。

对platform_device的定义通常在BSP的板文件中实现,在板文件中,将platform_device归纳为一个数组,最终通过platform_add_device()函数同一注册。platform_add_devices()函数可以将平台设备添加到系统中,这个函数的原型为:

int platform_add_devices(struct platform_device **devs, int num);

该函数的第一个参数为平台设备数组的指针,第二个参数为平台设备的数量,它内部调用了platform_device_register()函数用于注册单个的平台设备。

12.1.2 将globalfifo作为platform设备

现在我们将前面章节的globalfifo驱动挂接到platform总线上,要完成两个工作。

(1)将globalfifo移植为platform驱动。

(2)在板文件中添加globalfifo这个platform设备。

为了完成将globalfifo移植到platform驱动的工作,需要在原始的globalfifo字符设备驱动中套一层platform_driver的外壳,注意进行这一工作后,并没有改变globalfifo是字符设备的本质,只是将其挂在到了platform总线。

12.1.3 platform设备资源和数据

资源本身由resource结构体描述,其定义如下:

struct resoutce{

resource_size_t start;

resource_size_t end;

const char *name;

unsigned long flags;

struct resource *parent, *sibling, *child;

};

我们通常关心start、end和flags这3个字段,分别标明资源的开始值、结束值和类型,flags可以为IORESOURCE_IO、IORESOURCE_MEM、IORESOURCE_IRQ、IORESOURCE_DMA等。start、end的含义会随着flags而变更,如当flags为IORESOURCE_MEM时,start、end分别表示该platform_device占据的内存的开始地址和结束地址:当flags为IORESOURCE_IRQ时,start、end分别表示该platform_device使用的中断号的开始值和结束值,如果只使用1个中断号,开始和结束值相同。对于同类型的资源而言,可以有多分,例如说某设备占据了2个内存区域,则可以定义2个IORESOURCE_MEM资源。

对resource的定义也通常在BSP的板文件中进行,而在具体的设备驱动中透过platform_get_resource()这样的API来获取,此API的原型为:

struct resource *platform_get_resource(struct platform_device*, unsigned int, unsigned int);

对于IRQ而言,platform_get_resource()还有一个进行了封装的变体platform_get_irq(),其原型为:

int platform_get_irq(struct platform_device *dev, unsigned int num);

它实际上调用了“platform_get_resource(dev, IORESOURCE_IRQ, num);”。

设备除了可以在BSP中定义资源以外,还可以附加一些数据信息,因为对设备的硬件描述除了中断、内存、DMA通道以外,可能还会有一些配置信息,而这些配置信息也依赖于板,不适宜直接放置在设备驱动本身,因此,platform也提供了platform_data的支持。platform_data的形式是自定义的。

设备驱动中引入platform的概念至少有如下两大好处。

(1)使得设备被挂接在一个总线上,因此,符合Linux 2.6的设备模型。其结果是,配套的sysfs结点、设备电源管理都成为可能。

(2)隔离BSP和驱动。在BSP中定义platform设备和设备使用的资源,设备的具体配置信息,而在驱动中,只须要通过通用API去获取资源和数据,做到了板相关代码和驱动代码的分离,使得驱动具有更好的可扩展性和跨平台性。

12.2 设备驱动的分层思想

12.2.1 设备驱动核心层和例化

在面向对象的程序设计中,可以为某一类相似的事务定义一个基类,而具体的事务可以继承这个基类中的函数。如果对于继承的这个事物而言,其某成员函数的实现与基类一致,那它就可以直接继承基类的函数,相反,它可以重载之,这中面向对象的设计思想极大地提高了代码的可重用能力,是对实现世界事物间关系的一种良好呈现。

Linux内核完全由C语言和汇编语言写成,但是却频繁用到了面向对象的设计思想。在设备驱动方面,往往为同类的设备设计了一个框架,而框架中的核心层则实现了该设备通用的一些功能。同样的,如果具体的设备不想使用核心层的函数,它可以重载之。

core_funca的实现中,会检查底层设备是否重载了funca(),如果重载了,就调用底层的代码,否则,直接使用通用层的。这样做的好处是,核心层的代码可以处理绝大多数该类设备的funca()对应的工程,只有少数特殊设备需要重新实现funca()。

分层设计明显带来的好处是,对于通用代码,具体的底层驱动不需要再实现,而仅仅只关心其底层的操作。

下图明确反映了设备驱动的核心层与具体设备驱动的关系,实际上,这种分层可能只有两层,也可能是多层的。



这样的分层化设计在Linux的input、RTC、MTD、I2C、SPI、TTY、USB等诸多设备驱动类型中屡见不鲜。

12.2.2 输入设备驱动

输入设备(如按键、键盘、触摸屏、鼠标等)是典型的字符设备,其一般的工作机理是底层在按键、触摸等动作发送时产生一个中断(或驱动通过timer定时查询),然后CPU通过SPI、I2C或外部存储器总线读取键值、坐标等数据,放入一个缓冲区,字符设备驱动管理该缓冲区,而驱动的read()接口让用户可以读取键值、坐标等数据。

显然,在这些工作中,只是中断、读键值/坐标值是设备相关的,而输入事件的缓冲区管理以及字符设备驱动的file_operations接口则对输入设备是通用的。基于此,内核设计了输入子系统,由核心层处理公共的工作。Linux内核输入了系统的架构如下图:



输入核心提供了底层输入设备驱动程序所需的API,如分配/释放一个输入设备:

struct input_dev *input_allocate(void);

void input_free_device(struct input_dev *dev);

input_allocate_device()返回的是1个input_dev结构体,此结构体用于表征1个输入设备。

注册/注销输入设备用的接口如下:

int __must_check_input_register_device(struct input_dev *);

void input_unregister_device(struct input_dev *);

报告输入事件用的接口如下:

void input_event(struct input_dev *dev, unsigned int type, unsigned int code, int value); /*报告指定type、code的输入事件*/

void input_report_key(struct input_dev *dev, unsigned int code, int value); /*报告键值*/

void input_report_rel(struct input_dev *dev, unsigned int code, int value); /*报告相对坐标*/

void input_report_abs(struct input_dev *dev, unsigned int code, int value); /*报告绝对坐标*/

void input_sync(struct input_dev *dev); /*报告同步事件*/

而所有的输入事件,内核都用统一的数据结构来描述,这个数据结构是input_event。

drivers/input/keyboard/gpio_keys.c基于input架构,名为“gpio-keys”。它将硬件相关的信息(如使用的GPIO号,按下和抬起时的电平等)屏蔽在板文件platform_device的platform_data中,因此该驱动可应用于各个处理器,具有良好的跨平台性。

在注册输入设备后,底层输入设备驱动的核心工作只剩下在按键、触摸等人为动作发生的时候,报告事件。

12.2.3 RTC设备驱动

RTC(实时钟)借助电池供电,在系统掉电的情况下时间依然可以正常走动,它通常还具有产生周期性中断以及产生闹钟(alarm)中断的能力,是一种典型的字符设备。作为一种字符设备驱动,RTC需要有file_operations中接口函数的实现,如open()、release()、read()、poll()、ioctl()等,而典型的IOCTL包括RTC_SET_TIME、RTC_ALM_READ、RTC_ALM_SET、RTC_IRQP_SET、RTC_IRQP_READ等,这些对于所有的RTC是通用的,只有底层的具体实现是设备相关的。

因此,drivers/rtc/rtc-dev.c实现了RTC驱动通用的字符设备驱动层,它实现了file_opearations的成员函数以及一些关于RTC的通用的控制代码,并向底层导出rtc_device_unregister()用于注册和注销RTC:导出rtc_class_ops结构体用于描述底层的RTC硬件操作。这一RTC通用层实现的结果是,底层RTC驱动不再需要关心RTC作为字符设备驱动的具体实现,也无需关心一些通用的RTC控制逻辑,下图说明了这种关系。



12.3 主机驱动与外设驱动分离思想

12.3.1 主机、外设驱动分离的意义

在Linux设备驱动框架的设计中,除了有分层设计实现以外,还有分隔的思想。举一个简单的例子,假设我们要通过SPI总线访问某外设,在这个访问过程中,要通过操作CPO XXX上的SPI控制器的寄存器来达到访问SPI外设YYY的目的,最简单的方法是:

return_type xxx_write_spi_yyy(...)

{

xxx_write_spi_host_ctrl_reg(ctrl);

xxx_write_spi_host_data_reg(buf);

while(!(xxx_spi_host_status_reg() & SPI_DATA_TRANSFER_DONE));

...

}

如果按照这种方式来设计驱动,结果是对于任何一个SPI外设来讲,它的驱动代码都是CPU相关的。也就是说,当然用在CPU_XXX上的时候,它访问XXX的SPI主机控制寄存器,当用在XXX1的时候,它访问XXX1的SPI主机控制寄存器。

这显然是不能接受的,因为这意味着外设YYY用在不同的CPU XXX和XXX1上的时候需要不同的驱动,那么,我们可以用下图所示的思想对主机控制器驱动和外设驱动进行分离。这样的结果是,外设a、b、c的驱动与主机控制器A、B、C的驱动不相关,主机控制器驱动不关心外设,而外设驱动也不关心主机,外设只是访问核心层的通用API进行数据传输,主机和外设之间可以进行任意的组合。



如果我们不进行如上图所示的主机和外设分离,外设a、b、c和主机A、B、C进行组合的时候,需要9个不同的驱动。设想一共有m个主机控制器,n个外设,分离的结果是需要m+n个驱动,不分离则需要m*n个驱动。

Linux SPI、I2C、USB、ASoC(ALSA SoC)等子系统都典型地利用了这种分离的设计思想。

12.3.2 Linux SPI主机和设备驱动

SPI(同步外设接口)是由摩托罗拉公司开发的全双工同步串行总线,其接口由MISO(串行数据输入)、MOSI(串行数据输出)、SCK(串行移位时钟)、SS(从使能信号)4种信号构成,SS决定了唯一的与主设备通信的从设备,主设备通过产生移位时钟来发起通信。通信时,数据由MOSI输出,MISO输入,数据在时钟的上升或下降沿由MOSI输出,在紧接着的下降或上升沿由MISO读入,这样经过8/16次时钟的改变,完成8/16位数据的传输。

SPI模块为了和外设进行数据交换,。根据外设工作要求,其输出串行同步时钟性(CPOL)和相位(CPHA)可以进行配置。如果CPOL=0,串行同步时钟的空闲状态为低电平;如果CPOL=1,串行同步时钟的空闲状态为高电平。如果CPHA=0,在串行同步时钟的第一个跳变沿(上升或下降)数据被采样;如果CPHA=1,在串行同步时钟的第二个跳变沿(上升或下降)数据被采样。SPI接口时序如下图所示:



在Linux中,用spi_master结构体来描述一个SPI主机控制器驱动,其主要成员是主机控制器的序号(系统中可能存在多个SPI主机控制器)、片选数量、SPI模式和时钟设置用到的函数、数据传输用到的函数等。

struct spi_master{

struct device dev,

s16 bus_num,

u16 num_chipselect,

int (*setup)(struct spi_device *spi); /*设置模式和时钟*/

int (*transfer)(struct spi_device *spi, struct spi_message *mesg); /*双向数据传输*/

void (*cleanup)(struct spi_device *spi);

}

分配、注册和注销SPI主机的API由SPI核心提供:

struct spi_master * spi_alloc_master(struct device *host, unsigned size);

int spi_register_master(struct spi_master *master);

void spi_unregister_master(struct api_master *master);

在Linux中,用spi_driver结构体来描述一个SPI外设驱动,可以认为是spi_master的client驱动。

struct spi_driver{

int (*probe)(struct spi_device *spi);

int (*remove)(struct spi_device *spi);

int (*shutdown)(struct spi_device *spi);

int (*suspend)(struct spi_device *spi, pm_message_t mesg);

int (*resume)(struct spi_device *spi);

struct device_driver;

};

可以看出,spi_driver结构体和platform_driver结构体有极大的相似性,都有probe()、removed()、suspend()、resume()这样的接口。是的,这几乎是一切client驱动的习惯模板。

在SPI外设驱动中,当透过SPI总线进行数据传输的时候,使用了一套与CPU无关的统一的接口,这套接口的第一个关键数据结构就是spi_transfer,它用于描述SPI传输。

struct spi_transfer{

const void *tx_buf;

void *rx_buf;

unsigned len;

dma_addr_t tx_dma;

dma_addr_t rx_dma;

unsigned cs_change:1;

u8 bits_per_word;

u16 delay_usecs;

u32 speed_hz;

struct list_head transfer_list;

};

而一次完整的SPI传输流程可能不只包含一次spi_transfer,它可能包含一个或多个spi_transfer,这些spi_transfer最终通过spi_message组织在一起。

struct spi_message{

struct list_head transfers;

struct spi_device *spi;

unsigned is_dma_mapped:1;

void (*complete)(void *context); /*完成被一个callback报告*/

void *context;

unsigned actual_length;

int status;

struct list_head queue;

void *state;

};

通过spi_message_init()可以初始化spi_message,而将spi_transfer添加到spi_message队列的方法则是:

void spi_message_add_tail(struct spi_transfer *t, struct spi_message *m);

发起一次spi_message的传输有同步和异步两种方式,使用同步API时,会阻塞等待这个消息被处理完。同步操作时使用的API是:

int spi_sync(struct spi_device *spi, struct spi_message *message);

使用异步API时,不会阻塞等待这个消息被处理完,但是可以在spi_message的complete字段挂接一个回调函数,当消息被处理完成后,该函数会被调用。异步操作时使用的是API是:

int spi_async(struct spi_device *spi, struct spi_message *message);

spi_transfer、spi_message是SPI核心层的两个通用API,在SPI外设驱动中可以直接调用它们进行写和读操作。

SPI外设驱动遍布于内核的drivers、sound的各个子目录之下,SPI只是一种总线,spi_driver的作用只是将SPI外设挂接在该总线上,因此在spi_driver的probe()成员函数中,将注册SPI外设本身所属设备驱动的类型。

和platform_driver对应着一个platform_device一样,spi_driver也对应着一个spi_device;platform_device需要在BSP的板文件中添加板信息数据,而spi_driver的板信息用spi_board_info结构体描述,该结构体记录SPI外设使用的主机控制器序号、片选序号、数据比特率、SPI传输模式(即CPOL、CPHA)等。

在Linux启动过程中,在机器的init_machine()函数中,会通过如下语句注册这些spi_board_info:

spi_register_board_info(nokia770_spi_board_info, ARRAY_SIZE(nokia770_spi_board_info));

这一点和启动时通过platform_add_devices()添加platform_device非常相似。

12.4 设备驱动中的电源管理

一个真实生活中的设备驱动除了要处理设备的基本功能以外,还需要处理电源管理,主要提供挂起和恢复用的suspend()、resume()两个函数,对于platform_driver而言,该结构体已经包含了这两个成员函数,包含suspend()、resume()入口的platform_driver。

通常而言,在suspend()函数里面会停止设备,并关闭给他提供的时钟,所以在suspend()函数里面经常看见这样的语句:

clk_disable(xxx->clk);

而在resume()函数中,进行相反的操作:

clk_enable(xxx->clk);

clk_disable()、clk_enable()的具体实现直接依赖于SoC的类型,实际上,在BSP内为SoC内的各个PLL、分频器和时钟gata建立了一颗树,并提供了一组操作时钟的通用API。因此,在具体的设备驱动中,最好不要直接去修改寄存器来操作时钟,而应该用如下API:

struct clk *clk_get(struct device *dev, const char *id); /*获得、释放时钟*/

void clk_put(struct clk *clk);

int clk_enable(struct clk *clk); /*使能、禁止时钟*/

void clk_disable(struct clk *clk);

unsigned long clk_get_rate(struct clk *clk); /*获得、试探和设置频率*/

long clk_round_rate(struct clk *clk, unsigned long rate);

int clk_set_rate(struct clk *clk, unsigned long rate);

int clk_set_parent(struct clk *clk, struct clk *parent); /*设置、获得父时钟*/

struct clk *clk_get_parent(struct clk *clk);

从platform_driver结构体的定义可知,它除了包含suspend()和resume()入口以外,还包含如下两个入口:

int (*suspend_late)(struct platform_device *, pm_message_t state);

int (*resume_early)(struct platform_device *);

suspend_late()与suspend()的区别在于,suspend_late()工作于中断都被禁止的情况下,而且仅有一个CPU是活跃的。相似的,reusme_early()也工作与中断都被禁止的情况下,绝大多数情况下,设备驱动不提供suspend_late()和resume_early()入口。

12.5 misc设备驱动

Linux包含了许多的设备驱动类型,而不管分类有多细,总会有漏网的,这就是我们经常说道的“其他的”、“等等”。在Linux里面,把无法归类的五花八门的设备定位混杂设备(用miscdevice结构体描述)。Linux内核所提供的miscdevice有很强的包容性,如NVRAM、看门狗、DS1286等实时钟、字符LCD、ARM768随机数发生器等,体现了大杂烩的本意。

miscdevice共享一个主设备号MISC_MAJOR(即10),但次设备号不同。所有的miscdevice设备形成了一个链表,对设备访问时内核根据次设备号查找对应的miscdevice设备,然后调用其file_operations结构体中注册的文件操作接口进行操作。

在内核中,用struct miscdevice结构体表征miscdevice设备,这个结构体的定义代码如下:

struct miscdevice{

int minor;

const char *name;

const struct file_operations *fops;

struct list_head_list;

struct device *parent;

struct device *this_device;

};

miscdevice在本质上仍然属于字符设备,只是被增加了一层封装而已,因此其驱动的主题工作还是file_operations的成员函数。

对于misddevice的注册和注销分别通过如下两个API完成:

int misc_register(struct miscdevice * misc);

int misc_deregister(struct miscdevice * misc);

12.6 基于sysfs的设备驱动

一些设备驱动以sysfs节点的形式存在,其基本没有对应的/dev节点:一些设备驱动虽然有对应的/dev节点,也依赖于sysfs结点进行一些工作。

Linux专门提供了一种类型的设备驱动,以结构体sysdev_driver进行描述,该结构体的定义如下代码:

struct sysdev_driver{

struct list_head entry;

int (*add)(struct sys_device *);

int (*remove)(struct sys_device *);

int (*shutdown)(struct sys_device *);

int (*suspend)(struct sys_device *, pm_message_t state);

int (*resume)(struct sys_device *);

};

注册和注销此类驱动的API为:

int sysdev_driver_register(struct sysdev_class*, struct sysdev_driver *);

void sysdev_driver_unregister(struct sysdev_class*, struct sysdev_driver *);

而此类驱动中通常会通过两个API来创建和移除sysfs的结点:

int sysdev_create_file(struct sys_dev*, struct sysdev_attribute *);

void sysdev_remove_file(struct sys_device *, struct sysdev_attribute*);

而sysdev_create_file()最终调用的是sysfs_create_file(),sysfs_create_file()的第一个参数为kobject的指针,第二个参数是一个attribute结构体,每个attribute对应着sysfs中的一个文件,而读写一个attribute对应的文件通常需要show()和store()这两个函数,形如:

static ssize_t xxx_show(struct kobject *, struct attribute * attr, char * buffer);

static ssze_t xxx_store(struct kobject *kobj, struct attribute * attr, const char * buffer, size_t count);

典型的,如CPU频率驱动cpufreq(位于drivers/cpufreq)就是一个sysdev_driver形式的驱动,他的主要工作就是提供一些sysfs的结点,包括cpuinfo_cur_freq、cpuinfo_max_freq、cpuinfo_min_freq、scaling_available_frequencies、scaling_available_govermors、scaling_cur_freq、scaling_driver、scaling_governor、scaling_max_freq、scaling_min_freq等。用户空间可以手动cat、echo来操作这些节点或者使用cpufrequtils工作访问这些节点以与内核通信。

还有一类设备虽然不以sysdev_driver的形式存在,但是其本质上只是包含sysfs结点。

12.7 Linux设备驱动的固件加载

一个外设的运行可能依赖于固件,如一些CSR公司的WIFI模块,在启动前需要加载固件。传统的设备驱动将固件的二进制码作为一个数组直接编译进目标代码。而在Linux 2.6中,有一套成熟的固件加载流程。

首先,申请固件的驱动程序发起如下请求:

int request_firmware(const struct firmware **fw, const char *name, struct device *device);

第一个参数用于保护申请到的固件,第二个参数是固件名,第三个参数是申请固件的设备结构体。在发起调用后,内核的udevd会配合将固件通过对应的sysfs结点写入内核(在设置好udev规则的情况下)。之后内核将收到的firmware写入外设,最后通过如下API释放请求:

void release_firmware(const struct firmare * fw);

12.8 Android设备驱动

Android的设备驱动与Linux一样,因为Android本身基于Linux内核,但是Andorid对内核引入了如下主要补丁。

1.binder IPC系统

binder机制是Android提供的一种进程间通信方法,使一个进程可以以类似远程过程调用的形式调用另一个进程所提供的功能。他就是一种典型的以miscdevice形式实现的字符设备,而且提供了一些/proc结点。本质上,bingder用户空间的程序绝大多数情况下在底层是调用了binder驱动的ioctl()函数。

Android中的binder通信基于Service/Client模型,所有需要IBinder通信的进程都必须创建一个IBinder接口,Android虚拟机启动之前系统会先启动Service Manager进程,Service Manager打开binder驱动,并通知binder驱动程序这个进程将作为System Service Manager,然后该进程将进入一个循环,等待处理来自其他进程的数据。

而在用户程序方面,Service端创建一个System Service后,通过defaultServiceManager()可以得到远程ServiceManager的接口,通过这个接口我们可以调用addService()函数将新的SystemService添加到Service Manager进程中。对于Client端而言,则可以通过getService()获取到需要连接的目的Service的IBinder对象。对用户而言,获得这个对象后就可以通过binder驱动访问Service对象中的方法。

Client与Service在不同的进程中,通过这种方式实现了类似线程间的迁移的通信方式,对用户而言当调用Service返回的IBinder接口后,访问Service中的方法就如同调用自己的函数。两个进程间通信就好像是一个进程进入另一个进程执行代码然后带着执行的结果返回。

2.ashemem内存共享机制

asheme是Android新增的一种内存分配/共享机制。它也是一种典型的以miscdevice形式实现的字符设备。

在dev目录下对应的设备是/dev/ashmem,相比于传统的内存分配机制,如malloc、匿名/命名mmap,其好处是提供了辅助内核内存回收算法的pin/unpin机制。

ashmem的典型用法是先打开设备文件,然后做mmap映射。

(1)通过调用ashmem_create_region()函数打开和设置ashmem,这个函数的实质工作为:

fd=open("/dev/ashmem".O_RDWR);

ioctl(fd, ASHMEM_SET_NAME, region_name);

ioctl(fd, ASHMEM_SET_SIZE, region_size);

(2)应用程序一般会调用mmap来把ashmem分配的空间映射到进程空间:

mapAddr=mmap(NULL, pHdr->mapLength, PROT_READ|PORT_WRITE, MAP_PRIVATE, fd, 0);

应用程序还可以通过ioctl()来pin(ASHMEM_PIN)和unpin(ASHMEM_UNPIN)某一段映射的空间,以提示内核的page_cache算法可以把那些页面回收,这是一般mmap做不到的。通过ASHMEM_GET_PIN_STATUS这个IOCTL可以查询pin的状态。

3.Android电源管理

Android电源管理针对标准Linux内核的电源管理进行了一些优化。

4.Android Low Memory Killer

Linux内核本身提供了OOM(Out Of Memory)机制,它可以在系统内存不够的情况下主动杀死进程腾出内存。不过Android的Low Memory Killer相对于Linux标准OOM机制更加灵活,它可以根据需要杀死进程来释放内存。

5.Android RAM console和log设备

为了辅助调试,Android增加了用于将内核打印消息保存起来的RAM console和用户应用程序可以写入、读取log信息的logger设备驱动。这两个驱动分别放置在drivers/android/ram_console.c和drivers/android/logger.c文件。

RAM console通过register_console()被注册。而logger又是一个典型的miscdevice。

6.Andorid alarm、timed_gpio等。

就Android系统本身而言,在其上编写设备驱动没什么神秘的,基本完全按照Linux内核本身的框架进行,而Android自身引入的这些补丁,曾经有部分进入过Linux mainline的drivers/staging目录,尔后由于缺少维护的原因,被Greg KH移除。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: