3.3 实时任务
实时任务是一个用户定义的程序,它按照在内核控制下的特定的调度方式来执行。
最开始的设计是给每一个实时任务有自己的地址空间来提供内存保护。这通过80x86处理器内置的分页机制[10]。在每次上下文切换中,页目录是基于寄存器的变化来指向新任务的页目录。
任务间的切换非常频繁,如果在TLB没有命中时,使得系统在上下文切换的开销很大,系统性能会降低。别的系统开销还有是系统的调用,在保护模式下也是个费时的操作。
一种提高性能的方法是所有的实时任务运行在一个地址空间。通过使用内核地址空间,除去了保护模式变换的系统开销。Linux一个很有用的特性是:可装载内核模块。内核模块可以动态连接到内核地址空间,和链接为内核代码。每个模块定义了两个例程:init_module()和cleanup_module()。init_module()在模块装载到内核是调用,cleanup_module()在删除模块时调用。这就提供了一个简单的方法在Linux中操作驱动程序和文件系统。
可链接模块用以在当前的RTLinux中动态创建实时任务。这种实现方法也更脆弱:一个实时任务的错误可能引起整个系统的崩溃。C语言的使用加重了这个问题。数组、指针等的应用,很容易引起与内存相关的程序错误。另一方面,由于实时任务一般控制昂贵的外围设备,理所当然要使用与系统内核编程时相同的警告级别。
实时任务运行在内核地址空间有几个好处。除了上面提到的TLB命中问题和保护模式切换的问题外,这种方法使我们通过名字引用函数和对象,胜于通过描述符来引用。比如,实时任务表现为一个C的结构体。每个任务可以赋予一个C标识符,别的任务也可以通过这个标识符引用任务。动态链接执行过程中,模块装载解决了符号寻址问题,所以访问是非常高效的。
所有的任务在系统的地址空间,任务的切换也更简单。一个上下文切换是保存所有整数寄存器到栈中,改变栈的指针指向新的任务。同样也支持有浮点运算的任务。
为实时任务进行实时编程的接口将在第四章介绍。
3.3.1 实时线程数据结构
struct rtl_thread_struct
struct rtl_thread_struct {
int *stack;
int fpu_initialized;
RTL_FPU_CONTEXT fpu_regs;
int uses_fp;
int *kmalloc_stack_bottom;
struct rtl_sched_param sched_param; /* 线程调度参数 */
struct rtl_thread_struct *next; /* 链表中下一个线程 */
int cpu; /* 线程的CPU号 */
hrtime_t resume_time; /* 恢复时间 */
hrtime_t period; /* 任务周期 */
hrtime_t timeval;
struct module *creator; /* 线程创建者 */
void (*abort)(void *);
void *abortdata;
int threadflags;
rtl_sigset_t pending;
rtl_sigset_t blocked;
void *user[4];
int errno_val;
struct rtl_cleanup_struct *cleanup;
int magic;
struct rtl_posix_thread_struct posix_data;
void *tsd [RTL_PTHREAD_KEYS_MAX];
};
程序3.2 rtl_thread_struct结构
3.3.2 创建线程和线程调度
一个实时程序使用一个或几个线程来执行。线程是轻量级进程,它们共享公共的地址空间。在RTLinux中,所有的线程共享Linux内核地址空间。
线程操作相关的函数将在第四章介绍。
3.4 实时调度
实时调度器的只要任务是满足所有实时任务的时间要求。有很多方法表示时间的约束和很多的调度策略[4]。不存在一个适合所有任务的调度策略。
在大多数实时系统中,调度器是由大的、复杂的代码块组成,它也不可能扩展到适用任何情况。用户只是通过调节参数来改变调度器的行为,往往这是不够的。一般调度器代码也比较慢。
在RTLinux中,允许用户编写自己的调度器代码。可以把它实现为一个可装载的内核模块。这就使得可以实验不同的调度策略和算法,以找到一个最适合自己应用的调度方式。
3.4.1 实现的调度器
迄今为止实现了两种调度器。一个是基于优先级的占先式调度器。调度策略如下所述。每个任务赋予一个唯一的优先级。假如有几个任务处于就绪状态,优先级最高的那个将运行。只要一个优先级更高的任务就绪,它就可以中断当前较低优先级任务的执行。每个任务假定它可以自由的放弃CPU。
这个调度器直接支持周期任务。每个任务的周期和开始时间是可以给定的。一个中断驱动的(非周期的)任务通过定义中断处理程序,然后通过中断处理程序来唤醒相应的任务。
根据每个任务的周期和它们的终止时间,很自然的我们可以根据速率单调调度算法(rate monotonic scheduling algorithm, RMS)[5]决定每个任务的优先级。根据这个算法,周期短的任务有高的优先级。对于有 个任务的实时调度来说,满足下面公式的实时任务将能够成功的调度,每个任务都不会超过它们的最终期限(deadline):
这里, 为任务 在每个周期的最长执行时间, 为任务 的周期。非周期任务将处理为周期任务,同样赋予一个优先级[5]。
调度器把Linux当作一个有最低优先级的任务。Linux只在没有实时任务运行时运行。为此,从Linux切换到实时任务时,软中断状态将被记录,而且禁止软中断。当切换回来时,软中断状态将恢复。
另外一个调度器是根据最早期限优先算法(Earliest Deadline First, EDF)实现的。在这个算法中没有静态的优先级。而是最靠近最终期限(deadline)的任务总是最先执行。
3.4.2 设计用户自己的调度器
RTLinux作为开放的系统,具有以下的优势方便用户设计自己的调度器,以实现自己特有的调度方式:
l 一个分时复用的基于优先级调度的内核;
l 有精确可靠的时间片划分;
l 精确的时钟控制原语;
l 快且可预测的中断响应和进程切换时间;
RTLinux的调度器在文件rtl_sched.c和rtl_schedule.h中定义,实时线程可以在创建时用函数pthread_attr_setschedparam设置或在运行中pthread_setchedparam改变其优先级。scheduler将系统的优先级设为-1,而所有实时线程的优先级大于0,从而保证实时线程优先执行。用户需要改动的是rtl_schedule(调度过程)函数;两个重要的数据结构:schedule_t和rtl_thread_struct;和任务队列task_queue *list,task_queue *destlist。
通过对以上的数据结构和调度过程的修改,用户可以自己实现特定的调度算法。
3.5 计时
精确的计时是正确的调度器操作是必须的。调度器常常要求在一个特定的时刻进行任务切换。计时的错误将引起背离计划的调度,导致任务释放抖动(task release jitter)[24]。在大多数的应用中任务释放抖动是不好的。要尽量减少它的影响。
低的时间精度一个原因是在操作系统中,使用周期的时钟中断。系统设计者必须在时钟中断处理函数开销与计时精度之间做一个折中[24]。有时候使用周期时钟并不能得到要求的计时器定时精度。
在Linux中也是一样。在IBM兼容的PC上,硬件定时器的时钟中断速率设定为大约100Hz左右。因此,任务可以达到10毫秒的精度。一些商业的操作系统,比如VxWorks、REAL/IX也是使用周期的时钟中断,尽管它们允许用户改变中断频率。
在RTLinux中,消除这个折中是在需要的时候才通过使用一个可编程间隔定时器来中断CPU。特别地,使Intel 8354定时器芯片工作在interrupt-on-terminal-count模式。使用这种模式,可以使中断调度得到1微秒左右的精度。这种方法的定时器精度高而系统开销是最小。
为Linux模拟了一个周期的中断。使用软中断是非常简单的:为了模拟一个中断要求,一个未处理的中断屏蔽位被设定。在下一个软中断返回时,或者软sti执行时,处理函数将被调用。
3.5.1 时间相关函数
时间相关的函数将在第四章介绍。
3.6 进程间通信
由于Linux内核任何时候都可能被实时任务占先,Linux线程不能安全地被实时任务调用。不管怎样,必须要有一些进程间通信(IPC)的机制。RTLinux提供了三种通信方法。
3.6.1 FIFO设备
RTLinux用FIFO管道来在Linux进程或者Linux内核与实时进程间传递数据,这种管道称为实时管道(real-time FIFOs),以区别UNIX IPC机制中的管道。
RT-FIFO管道是在内核地址空间的。通过一个整数来引用。RT-FIFOs的数目在编译系统时给定,可以重新编译系统改变大小。
操作RT-FIFOs的函数包括创建、删除、读FIFO和写FIFO。读写操作是原子操作,不能中断。不可中断是为了避免优先级倒置问题。
在Linux进程中,把RT-FIFOs当作普通的字符设备,而不是一个系统调用。字符设备给用户一个与实时任务通信的全功能的应用程序接口(API)。这个接口对Linux进程来说是标准的设备接口,包括:open, close, read和write。
struct file_operations rtf_fops
static struct file_operations rtf_fops =
{
rtf_llseek,
rtf_read,
rtf_write,
NULL,
rtf_poll,
NULL,
NULL,
rtf_open,
NULL,
rtf_release,
NULL,
NULL,
NULL,
NULL,
NULL
};
下面是原始的存取例程:
int rtf_create(unsigned int minor, int size)
int rtf_destroy(unsigned int minor)
int rtf_put(unsigned int minor, void *buf, int count)
int rtf_get(unsigned int minor, void *buf, int count)
对于每个FIFO管道可以通过
int rtf_create_handler(unsigned int minor,
int (*handler) (unsigned int fifo))
安装自己的处理程序,当数据从FIFO中读出或写出时运行。
3.6.2 共享内存
在RTLinux启动的时候,通过指定内核一个mem参数决定内核可以使用的内存大小,空出来的内存空间用于实时任务和Linux进程进行通信的共享内存。在RTLinux任务中通过/dev/mem设备在这段内存中寻址,Linux进程也通过读取这段内存的数据获得实时任务提供的信息,这样完成实时任务和Linux进程之间的通信。
3.6.3 mbuff驱动程序
它是由Tomasz Motylewski提供的一个使用共享内存的驱动程序,用来实现核心内存空间和用户之间的共享。通过使用mbuff提供的mbuff_alloc()的函数给申请的内存取一个名字,mbuff驱动程序使用一个链表通过这个名字来管理这些申请的内存。通过这个驱动程序也可以在包括RTLinux的Linux内核内存空间和用户内存空间之间共享内存。
#include <mbuff.h>
void * mbuff_alloc(const char *name, int size);
void mbuff_free(const char *name, void * mbuf);
第一次调用mbuff_alloc时,给定一个名字,一个给定大小的共享内存块将分配。这个内存块的引用数设为1。调用成功返回新内存块的指针。失败时返回NULL。如果给定的名字已经存在,将返回存在的内存块的指针,以操作这块共享内存块,该内存块的引用数将加1。