内核抢占补丁在2.5系列中就已经被打上,接下来在2.6中也会打。这将显著地降低用户交互式应用程序、多媒体应用程序等类似应用程序的延迟。这一特性对实时系统和嵌入式系统来说特别有用。
2.5的内核抢占模块的工作由 Robert Love 完成。在先前的内核版本中(包括2.4内核),不允许抢占以内核模式运行的任务(包括通过系统调用进入内核模式的用户任务),直到它们自己主动释放 CPU。
在内核2.6中,内核是可抢占的。一个内核任务可以被抢占,为的是让重要的用户应用程序可以继续运行。这样做最主要的优势在于,可以极大地增强系统的用户交互性,用户将会觉得鼠标点击和击键的事件得到了更快速的响应。
当然,不是所有的内核代码段都可以被抢占。可以锁定内核代码的关键部分,不允许抢占。锁定可以确保每个 CPU 的数据结构和状态始终受到保护而不被抢占。
以下的代码片断显示了每个 CPU 的数据结构问题(在SMP系统中):
清单 1. 存在内核抢占问题的代码
int arr[NR_CPUS];
arr[smp_processor_id()] = i;
/* kernel preemption could happen here */
j = arr[smp_processor_id()] /* i and j are not equal as
smp_processor_id() may not be the same */
在这种情形下,如果在特定点发生了内核抢占,任务将会由于重新调度而被分配到其他处理器——smp_processor_id() 将返回一个不同的值。
这种情形应该通过锁定来进行保护。
FPU 模式是另外一种CPU应该被保护起来不被抢占的情形。当内核在执行浮点指令时,FPU 状态不被保存。如果这时发生了抢占,由于重新调度,FPU 状态就会与抢占前完全不同。所以 FPU 代码必须始终被锁定,以防止内核抢占。
锁定可以这样来实现,在关键部分禁止抢占,在之后再激活抢占。以下是在2.6内核中禁止和激活抢占的定义:
preempt_enable() -- 抢占计数器减1
preempt_disable() -- 抢占计数器加1
get_cpu() -- 先后调用 preempt_disable() 和 smp_processor_id()
put_cpu() -- 重新激活preemption()
使用这些定义,清单 1可以重写成这样:
清单 2. 使用防抢占锁的代码
int cpu, arr[NR_CPUS];
arr[get_cpu()] = i; /* disable preemption */
j = arr[smp_processor_id()];
/* do some critical stuff here */
put_cpu() /* re-enable preemption */
注意 preempt_disable()/enable()调用是可以嵌套的。也就是说,preempt_disable() 可以被调用 n 次,只有当第 n 次 preempt_enable() 被调用后,抢占才被重新激活。
当使用自旋锁时,抢占是被隐式地禁止的。例如,一个 spin_lock_irqsave() 调用会隐式地通过调用 preempt_disable() 来防止抢占;spin_unlock_irqrestroe() 通过调用 preempt_enable() 来重新激活抢占。
用户界面层
2.6内核重写了帧缓冲/控制台层。这将意味着需要更新各种用户空间帧缓冲工具,如 fbset 和 fbdesl。人机界面层还加入了对近乎所有可接入设备的支持,从触摸屏到盲人用的设备,到各种各样的鼠标。
线程操作可以提高速度;2.6内核现在可以处理任意数目的线程,PID最大可以到20亿(IA32上)。
另外一个变化是引入了 TLS(Thread Local Storage)系统调用,这个调用允许分配一个或多个 GDT(Global Descriptor Table)条目,作为线程注册表。每个 CPU 有一个 GDT,每个条目对应一个线程。这样就可以实现一个不受创建的线程数限制的1:1线程模型(因为每一个新的内核线程都是为一个用户线程而创建)。2.4内核中每个处理器最多只能支持8,192个线程。
系统调用 clone 被扩展,以优化线程的创建。如果 CLONE_PARENT_SETID 标志被设置,内核会把线程ID存储在一个给定的内存位置,如果当线程结束时 CLONE_CLEARID 标志被设置,内核就会把那个内存位置清空。这有助于用户级的内存管理去识别没有使用的内存块。同样,对线程注册表的信号安全加载的支持也已经融入到这个体系中。当 pthread_join 发生时由内核根据线程ID来完成 Futex(fast user space mutex)。(要了解futex的更多信息,请参阅参考资料).
POSIX信号处理在内核空间中完成。一个信号会传递给进程中一个可用的线程;销毁信号会终止整个进程。停止和继续信号也会影响整个进程,这样就可以实现对多线程进程的工作控制。
引入了退出系统调用的一个变种,叫做 exit_group(),这个系统调用终止整个进程和它的线程。此外,退出处理通过引入O(1)算法得到了改进,从而可以在两秒内终止一个具有成千上万个线程的进程(而在2.4内核中完成同样的事情需要15分钟)。
修改了 proc 文件系统,不再报告所有的线程而只是报告原始的线程。这样就避免了 /proc 报告速度的下降。内核保证原始的线程在所有其他线程终止之前不会终止。
虚拟内存的变化
从虚拟内存的角度来看,新内核融合了 Rik van Riel 的 r-map (反向映射,reverse mapping)技术,将显著改善虚拟内存 在一定程度负载下的性能。
为了理解反向映射技术,让我们来首先简单了解 Linux 虚拟内存系统的一些基本原理。
Linux 内核工作于虚拟内存模式:每一个虚拟页对应一个相应的系统内存的物理页。虚拟页和物理页之间的地址转换由硬件的页表来完成。对于一个特定的虚拟页,根据一条页表记录可以找到对应的物理页,或者是页无法找到的提示(说明存在一个页错误)。但是这种"虚拟到物理"的页映射不是总是一一对应的:多个虚拟页(被不同的进程共享的页)有可能指向同一个物理页。在这种情况下,每个共享进程的页记录将有指向对应物理页的映射。如果有类似这样的情况,当内核想要释放特定的物理页时,事情会变得复杂,因为它必须遍历所有的进程页表记录来查找指向这个物理页的引用;它只能在引用数达到0时才能释放这个物理页,因为它没有别的办法可以知道是不是还存在实际指向这个页的引用。这样当负载较高时会让虚拟内存变得非常慢。
反向地址映射补丁通过在结构页引入一个叫做 pte_chain 的数据结构(物理页结构)来解决这一问题。pte_chain 是一个指向页的 PTE 的简单链接列表,可以返回特定的被引用页的 PTE 列表。页释放一下子变得非常简单了。 不过,在这种模式中存在一个指针开销。系统中的每一个结构页都必须有一个额外的用于 pte_chain 的结构。在一个256M内存的系统中,有64K个物理页,这样就需要有 64KB * (sizeof(struct pte_chain)) 的内存被分配用于 pte_chain 的结构――一个很可观的数字。
有一些可以解决这个问题的技术,包括从结构页中删掉 wait_queue_head_t 域(用于对页的独占访问)。因为这个等待队列极少用到,所以在 rmap 补丁中实现了一个更小的队列,通过哈希队列来找到正确的等待队列。
尽管如此,rmap 的性能――尤其是处于高负载的高端系统――相对于2.4内核的虚拟内存系统还是有了显著的提高。
Linux 2.6的驱动程序移植
2.6内核给驱动程序开发人员带来了一系列非常有意义的变化。本节重点介绍将驱动程序从2.4内核移植到2.6内核的一些重要方面。
首先,相对于2.4来说,改进了内核编译系统,从而获得更快的编译速度。加入了改进的图形化工具:make xconfig(需要Qt库)和make gconfig(需要GTK库)。
内存管理的变化
在2.5的开发过程中,加入了内存池,以满足无间断地进行内存分配。其思想是预分配一个内存池,并保留到真正需要的时候。一个内存池由 mempool_create() 调用来创建(应该包含头文件 linux/mempool.h)。
mempool_t *mempool_create(int min_nr, mempool_alloc_t *alloc_fn,
mempool_free_t *free_fn, void *pool_data);
在这里 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 标记被指定时,分配函数才会休眠。
在池中分配和回收对象是由以下程序完成的:
void *mempool_alloc(mempool_t *pool, int gfp_mask);
void mempool_free(void *element, mempool_t *pool);
mempool_alloc() 用来分配对象;如果内存池分配器无法提供内存,那么就可以用预分配的池。
系统使用 mempool_destroy() 来回收内存池。
除了为内存分配引入了内存池之外,2.5内核还引入了三个用于常规内存分配的新的GFP标记,它们是:
__GFP_REPEAT -- 告诉页分配器尽力去分配内存。如果内存分配失败过多,应该减少这个标记的使用。
__GFP_NOFAIL -- 不能出现内存分配失败。这样,由于调用者被转入休眠状态,可能需要一段比较长的时间才能完成分配,调用者的需求才能得到满足。
__GFP_NORETRY -- 保证分配失败后不再重试,而向调用者报告失败状态。
除了内存分配的变化以外,remap_page_range()调用——用来映射页到用户空间——也经过了少量修改。相对于2.4来说,现在它多了一个参数。虚拟内存区域(VMA)指针要作为第一个参数,然后是四个常用的参数(start,end,size 和 protection 标记)。
工作队列接口
工作队列接口是在2.5的开发过程中引入的,用于取代任务队列接口(用于调度内核任务)。每个工作队列有一个专门的线程,所有来自运行队列的任务在进程的上下文中运行(这样它们可以休眠)。驱动程序可以创建并使用它们自己的工作队列,或者使用内核的一个工作队列。工作队列用以下方式创建:
struct workqueue_struct *create_workqueue(const char *name);
在这里 name 是工作队列的名字。
工作队列任务可以在编译时或者运行时创建。任务需要封装为一个叫做 work_struct 的结构体。在编译期初始化一个工作队列任务时要用到:
DECLARE_WORK(name, void (*function)(void *), void *data);
在这里 name 是 work_struct 的名字,function 是当任务被调度时调用的函数,data 是指向那个函数的指针。
在运行期初始化一个工作队列时要用到:
INIT_WORK(struct work_struct *work, void (*function)(void *), void *data);
用下面的函数调用来把一个作业(一个类型为work_struct 结构的工作队列作业/任务)加入到工作队列中:
int queue_work(struct workqueue_struct *queue, struct work_struct *work);
int queue_delayed_work(struct workqueue_struct *queue, struct work_struct
*work, unsigned long delay);
在queue_delay_work()中指定 delay,是为了保证至少在经过一段给定的最小延迟时间以后,工作队列中的任务才可以真正执行。
工作队列中的任务由相关的工作线程执行,可能是在一个无法预期的时间(取决于负载,中断等等),或者是在一段延迟以后。任何一个在工作队列中等待了无限长的时间也没有运行的任务可以用下面的方法取消:
int cancel_delayed_work(struct work_struct *work);
如果当一个取消操作的调用返回时,任务正在执行中,那么这个任务将继续执行下去,但不会再加入到队列中。清空工作队列中的所有任务使用:
void flush_workqueue(struct workqueue_struct *queue);
销毁工作队列使用:
void destroy_workqueue(struct workqueue_struct *queue);
不是所有的驱动程序都必须有自己的工作队列。驱动程序可以使用内核提供的缺省工作队列。由于这个工作队列由很多驱动程序共享,任务可能会需要比较长一段时间才能开始执行。为了解决这一问题,工作函数中的延迟应该保持最小或者干脆不要。
需要特别注意的是缺省队列对所有驱动程序来说都是可用的,但是只有经过GP许可的驱动程序可以用自定义的工作队列:
int schedule_work(struct work_struct *work); -- 向工作队列中添加一个任务
int schedule_delayed_work(struct work_struct *work, unsigned long delay); -- 向工作队列中添加一个任务并延迟执行
当模块被缷载时应该去调用一个 flash_scheduled_work() 函数,这个函数会使等待队列中所有的任务都被执行。
中断例程的变化
2.5的中断处理程序内部已经经历了许多变化,但是绝大部分对于普通的驱动程序开发者来说没有影响。不过,还是有一些重要的变化会影响到驱动程序开发者。
现在的中断处理函数的返回代码是一个 irqreturn_t 类型。这个由 Linus 引入的变化意味着中断处理程序告诉通用的 IRQ 层是否真的要中断。这样做是为了当中断请求不断到来时(原因是驱动程序偶然激活了一个中断位或者硬件坏掉了),捕获假中断(尤其是在共享的PCI线上),而任何驱动程序对此都是无能为力的。在2.6中,驱动程序如果要从一个设备上发出一个中断需要返回 IRQ_HANDLED,如果不是的话返回 IRQ_NONE。这样可以帮助内核的 IRQ 层清楚地识别出哪个驱动程序正在处理那个特定的中断。如果一个中断请求不断到来而且没有注册那个设备的处理程序(例如,所有的驱动程序都返回 IRQ_NONE),内核就会忽略来自那个设备的中断。缺省情况下,驱动程序 IRQ 例程应该返回 IRQ_HANDLED,当驱动程序正在处理那个中断时却返回了 IRQ_NONE,说明存在 bug。新的中断处理程序可能是类似于这样:
清单 4. 2.6的中断处理程序伪代码
irqreturn_t irq_handler(...) {
..
if (!(my_interrupt)
return IRQ_NONE; // not our interrupt
...
return IRQ_HANDLED; // return by default
}
注意,cli(),sti(),save_flags()和 restor_flags() 是不赞成使用的方法。取而代之的是 local_save_flags() 和 local_irq_disable(),用来禁止所有的本地中断(本处理器内的)。禁止所有处理器的中断是不可能的。
统一的设备模型
2.5开发过程中另一个最值得关注的变化是创建了一个统一的设备模型。这个设备模型通过维持大量的数据结构囊括了几乎所有的设备结构和系统。这样做的好处是,可以改进设备的电源管理和简化设备相关的任务管理,包括对以下信息的追踪:
系统中存在的设备,其所连接的总线
特定情形下设备的电源状态
系统清楚设备的驱动程序,并清楚哪些设备受其控制
系统的总线结构:哪个设备连接在哪个总线上,以及哪些总线互连(例如,USB和PCI总线的互连)
设备在系统中的类别描述(类别包括磁盘,分区等等)
在2.5内核中,与设备驱动程序相关的其他发展包括:
不再使用 malloc.h。所有包含 <linux/malloc.h>(用于内存分配)的代码现在要替换为 <linux/slab.h>。
用于 x86 体系结构的 HZ 值增加到1000。引入了一个叫做 jiffies_64 的瞬间计算器,以避免由于 HZ 值的变化而引起瞬间变量的迅速溢出。
引入了一个叫做 ndelay() 的新的延迟函数,允许纳秒级的等待。
引入了一个叫做 seqlock() 的新类型的锁,用于锁定小段的经常被访问的数据(不是指针)。
由于2.6内核可以抢占,应该在驱动程序中使用 preempt_disable() 和 preempt_enable(),从而保护代码段不被抢占(禁止 IRQ 同时也就隐式地禁止了抢占)。
在2.5中加入了异步 I/O。这意味着用户进程可以同时进行多个 I/O 操作,而不用等待它们完成。在字符驱动程序中引入了异步 API。
块层在2.5的开发过程中经历了大幅度的变化。这意味着原来用于2.4的块设备需要进行重新设计。
在2.5中引入了sys文件系统,它给出了系统的设备模型的用户空间描述。它挂载在 /sys 目录下。