实时 Linux 简介
如果您想通过 PC 控制摄像头、机器人或科学仪器,那么很自然会想到使用 Linux,这样您就可以利用其开发环境、X Window 系统以及所有网络支持。然而,Linux 无法可靠地运行这些硬实时应用程序。一个简单的实验可以说明这个问题。拿一个扬声器,将其连接到并行端口的一个引脚;然后运行一个程序来切换引脚。如果您的程序是唯一运行的程序,扬声器会发出一种不错、相对稳定的音调——不是完全稳定,但还不错。当 Linux 每隔几秒更新文件系统时,您可能会注意到音调的细微变化。如果您将鼠标移动到几个窗口上,音调就会变得不规则。如果您启动 Netscape,您会听到间歇的静音,因为您的程序正在等待更高优先级的进程运行。
问题在于,与大多数通用操作系统一样,Linux 的设计目标是优化平均性能,并力求为每个进程提供公平的计算时间份额。这对于通用计算来说非常棒,但对于实时编程来说,精确的计时和可预测的性能比平均性能更重要。例如,如果摄像头每毫秒填充一个缓冲区,则读取该缓冲区的进程中的瞬时延迟可能会导致数据丢失。如果光刻机中的步进电机必须以精确的时间间隔打开和关闭,以最大限度地减少振动并在正确的时间将晶圆移动到位,则瞬时延迟可能会导致无法恢复的故障。想想如果导致化学实验紧急关闭的任务必须等待 Netscape 重绘窗口才能运行,会发生什么情况。
事实证明,重新设计 Linux 以提供有保证的性能将需要大量的工作。而承担这样的工作将有悖于我们最初的目的。我们将拥有的不是现成的通用操作系统,而是一个定制的专用操作系统,它不会搭上 Linux 主要开发工作的顺风车。因此,我们在 Linux 底层偷偷塞入了一个小型、简单的实时操作系统。Linux 变成了一个只有在没有实时任务运行时才会运行的任务,并且每当实时任务需要处理器时,我们都会抢占 Linux。Linux 本身需要的更改是最小的。Linux 在执行运行进程、捕获中断和控制设备的业务时,基本上没有意识到实时操作系统的存在。实时任务可以以相当高的精度运行。在我们的测试 P120 系统中,我们可以将任务调度到约 20 微秒的精度范围内运行。
实时 Linux 是一个研究项目,有两个目标。首先,我们想要一个实用的、非专有的工具,我们可以用它来控制科学仪器和机器人。我们的另一个目标是将 RT-Linux 用于实时和非实时操作系统设计的研究。我们希望能够了解如何使操作系统高效可靠。例如,即使是非实时操作系统也应该能够确定它是否可以保证其 I/O 设备所需的定时。我们还对哪些类型的调度规则实际上最终对实时应用程序最有用感兴趣。遵循这一双重目的,本文将讨论如何使用 RT-Linux 以及它的工作原理。
让我们考虑一个例子。假设我们要编写一个应用程序,该应用程序实时轮询设备以获取数据并将数据存储在一个文件中。RT-Linux 背后的主要设计理念如下
实时程序应分为小的、简单的部分(具有硬实时约束)和较大的部分(执行更复杂的处理)。
遵循这一原则,我们将我们的应用程序分为两部分。硬实时部分将作为实时任务执行,并将数据从设备复制到一个名为实时 FIFO的特殊 I/O 接口中。程序的主要部分将作为普通的 Linux 进程执行。这部分将从实时 FIFO 的另一端读取数据,并将数据显示和存储在一个文件中。
实时组件将作为内核模块编写。Linux 允许我们编译和加载内核模块,而无需重启系统。模块的代码总是以 MODULE 的定义和 module.h 文件的包含开始。之后,我们包含实时头文件 rt_sched.h 和 rt_fifo.h,并声明一个 RT_TASK 结构。
#define MODULE #include <linux/module.h< /* always needed for real-time tasks */ #include <linux/rt_sched.h> #include <linux/rt_fifo.h> RT_TASK mytask;
实时任务结构将包含指向此任务的代码、数据和调度信息的指针。任务结构在第一个包含文件中定义。目前,RT-Linux 只有一个相当简单的调度器。将来,调度器也将是可加载的模块。目前,实时任务与 Linux 进程通信的唯一方式是通过称为实时 FIFO 的特殊队列。实时 FIFO 的设计目的是使实时任务在读取或写入数据时永远不会被阻塞。图 1 说明了实时 FIFO。

图 1. 实时 FIFO
示例程序将简单地循环,从设备读取数据,将数据写入 RT-FIFO,并等待一段固定的时间。
/* this is the main program */ void mainloop(int fifodesc) { int data; /* in this loop we obtain data from */ /* the device and put it into the */ /* fifo number 1 */ while (1) { data = get_data(); rt_fifo_put(fifodesc, (char *) &data, sizeof(data)); /* give up the CPU till the */ /* next period */ rt_task_wait(); } }
所有模块都必须包含一个初始化例程。示例实时任务的初始化例程将
记录当前时间,
初始化实时任务结构,
并将任务放在周期性调度中。
rt_task_init 例程初始化任务结构,并安排将参数传递给任务。在本例中,参数是实时 FIFO 的固定描述符。rt_make_periodic 例程将新任务放在周期性调度队列中。周期性调度意味着任务被调度为在时间单位的特定间隔运行。另一种选择是使任务仅在中断导致其变为活动状态时才运行。
This function is needed in any module. It */ /* will be invoked when the module is loaded. */ int init_module(void) { #define RTfifoDESC 1 /* get the current time */ RTIME now = rt_get_time(); /* `rt_task_init' associates a function */ /* with the RT_TASK structure and sets */ /* several execution parameters: */ /* priority level = 4, */ /* stack size = 3000 bytes, */ /* pass 1 to `mainloop' as an argument */ rt_task_init(&mytask, mainloop, RTfifoDESC, 3000, 4); /* Mark `mytask' as periodic. */ /* It could be interrupt-driven as well */ /* mytask will have period of 25000 */ /* time units. The first run is in */ /* 1000 time units from now */ rt_task_make_periodic(&mytask, now + 1000, 25000); return 0; }`
Linux 还要求每个模块都有一个清理例程。对于实时任务,我们要确保死任务不再被调度。
/* cleanup routine. It is invoked when the */ /* module is unloaded. */ void cleanup_module(void) { /* kill the realxi--time task */ rt_task_delete(&mytask); }
这就是模块的结尾。我们还需要一个作为普通 Linux 进程运行的程序。在本例中,该进程将只从 FIFO 读取数据并将数据写入(复制)到 stdout。
#include <rt_fifo.h> #include <stdio.h> #define RTfifoDESC 1 #define BUFSIZE 10 int buf[BUFSIZE]; int main() { int i; int n; /* create fifo number 1 with size of */ /* 1000 bytes */ rt_fifo_create(1, 1000); for (n=0; n < 100; n++) { /* read the data from the fifo */ /* and print it */ rt_fifo_read(1, (char *) buf, BUFSIZE * sizeof(int)); for (i = 0; i < BUFSIZE; i++) { printf("%d ", buf[i]); } printf("\n"); } /* destroy fifo number 1 */ rt_fifo_destroy(1); return 0; }
主程序还可以将数据在屏幕上显示、通过网络发送等。所有这些活动都被认为是是非实时的。FIFO 大小必须足够大,以避免溢出。可以检测到溢出,并且可以使用另一个 FIFO 来通知主程序有关溢出的信息。
虽然 Linux 具有在给定时间间隔内挂起进程的系统调用,但它不能保证进程会在该时间间隔过去后立即恢复。根据系统负载,进程可能会在超过一秒后才被调度。此外,用户进程可能会在不可预测的时刻被抢占,并被迫等待其 CPU 时间份额。为关键任务分配最高优先级没有帮助,部分原因是 Linux 的“公平”时间共享调度算法。该算法力求确保每个用户程序都能获得公平的计算机时间份额。当然,如果我们有一个实时任务,我们希望它在需要时获得 CPU,无论这有多么不公平。Linux 虚拟内存也增加了不可预测性。属于任何用户进程的页面都可以在任何时候被交换到磁盘。将请求的页面带回 RAM 在 Linux 中需要不可预测的时间量。
其中一些问题很容易或相对容易地解决。可以创建一类新的特殊 Linux 进程,这些进程更具实时性。我们可以更改调度算法,以便实时进程以轮询或周期性方式调度。我们可以将实时进程锁定在内存中,这样它的页面将永远不会被交换出去。事实上,这两种想法都是 POSIX.1b-1993 规范的一部分,该规范定义了“实时”进程的标准。POSIX.1b-1993 正在被纳入 Linux。在较新版本的 Linux 中,已经提供了用于将用户页面锁定在内存中的系统调用、使调度器策略基于优先级,甚至提供了用于更可预测地处理信号的系统调用。
POSIX.1b-1993 并不能解决我们所有的问题。它并非旨在解决我们在本文开头讨论的那类问题。该标准的目标是所谓的软实时程序。在窗口中显示视频的程序是软实时任务的完美示例。我们希望此任务运行得快速且相当频繁,以便获得良好的显示质量,但是几毫秒的延迟不会产生太大的影响。对于硬实时问题,POSIX 标准有几个缺点
Linux 进程是重量级进程,与进程切换相关的开销很大。虽然 Linux 在切换进程方面相对较快,但在快速机器上可能需要几百微秒。这将使得将任务调度为每 200 微秒轮询一次传感器成为不可能。
Linux 遵循标准的 Unix 技术,使内核进程变为非抢占式的。也就是说,当进程正在进行系统调用(并在内核模式下运行)时,无论另一个任务的优先级有多高,都不能强制它将处理器让给另一个任务。对于编写操作系统的人来说,这很棒,因为它使许多非常复杂的同步问题消失了。对于想要运行实时程序的人来说,这不太好,因为重要的进程在内核代表即使是最不重要的进程工作时也无法被调度。在内核模式下,它不能被重新调度。例如,如果 Netscape 调用 fork,则 fork 将在任何其他进程可以运行之前完成。
Linux 在内核代码的临界区中禁用中断。这种中断禁用意味着实时中断可能会被延迟,直到当前进程(无论其优先级有多低)完成其临界区。考虑以下代码片段
line1: temp = qhead; line2: qhead = temp->next;
假设在内核到达第 1 行之前,qhead 包含一个数据结构的地址,该数据结构是队列上唯一的数据结构,并且 qhead->next 包含 0。现在假设内核例程完成第 1 行并计算值 temp->next(为 0),然后被中断暂停,该中断导致将新元素添加到队列中。当中断例程完成时,qhead->next 将不再等于 0,但是当内核例程继续时,它会将 0 值分配给 qhead,从而丢失新元素。为了防止这些类型的错误,Linux 内核广泛使用 cli 命令在这些临界区清除(禁用)中断。此示例中的内核例程将在开始更改队列之前禁用中断,并且仅在操作完成时才重新启用中断;因此,中断有时会被延迟。很难计算出临界区可能导致的最坏情况延迟。您必须仔细检查每个驱动程序(以及操作系统的其余部分)的代码,才能做出一个好的估计。我们测得的延迟长达 1/2 毫秒。想想这样的延迟对我们的摄像头例程意味着什么。
要将 Linux 内核更改为具有低中断处理延迟的可抢占式实时内核,将需要对 Linux 内核代码进行大量重写——几乎是编写一个新的内核。实时 Linux 使用了一种更简单、更高效的解决方案。
基本思想是使 Linux 在实时内核的控制下运行(参见图 2)。当有实时工作要完成时,RT 操作系统会运行其任务之一。当没有实时工作要完成时,实时内核会调度 Linux 运行。因此,Linux 是 RT 内核的最低优先级任务。

图 2
Linux 禁用中断的问题通过在实时内核中模拟 Linux 中断相关例程来解决。例如,每当 Linux 内核调用本应禁用中断的 cli() - 例程时,都会重置一个软件中断标志。所有中断都由 RT 内核捕获,并根据此标志的状态和中断掩码传递给 Linux 内核。因此,中断始终可用于 RT 内核,同时仍然允许 Linux“禁用”它们。在上面的示例中,Linux 内核例程将调用 cli() 来清除软件中断标志。如果发生中断,实时执行程序将捕获它并决定该怎么做。如果中断导致运行实时任务,则执行程序将保存 Linux 的状态并立即启动实时任务。如果中断只需要传递给 Linux,实时执行程序将设置一个标志,指示存在挂起的中断,然后恢复 Linux 执行,而不运行 Linux 中断处理程序。当 Linux 重新启用中断时,实时执行程序将处理所有挂起的中断,并导致执行相应的 Linux 处理程序。
实时内核本身是非抢占式的,但由于其例程非常小且速度快,因此不会造成很大的延迟。在 Pentium 120 上的测试表明,最大调度延迟小于 20 毫秒。
实时任务在内核特权级别运行,以便直接访问计算机硬件。它们具有用于代码和数据的固定内存分配——否则,当任务请求新内存或代码页中的页面时,我们将不得不允许不可预测的延迟。实时任务不能使用 Linux 系统调用或直接调用 Linux 内核中的例程或访问普通数据结构,因为这会引入不一致的可能性。在我们上面的示例中,更改队列的内核例程将调用 cli,但这不会阻止实时任务启动。因此,我们不能允许实时任务直接访问队列。但是,我们需要一种实时任务与内核以及用户任务交换数据的方式。例如,在数据收集应用程序中,我们可能需要通过网络发送 RT 任务收集的数据,或者将其本地写入文件,同时在屏幕上显示它。
实时 FIFO 用于在实时进程和普通 Linux 进程之间传递信息。与实时任务一样,RT-FIFO 永远不会被分页出去。这消除了由于分页导致的不可预测延迟的问题。并且实时 FIFO 的设计目的是永远不会阻塞实时任务。
最后,出现了一个问题——实时内核如何跟踪实时时间。在为实时系统实现调度器时,时钟中断速率和任务释放抖动之间通常存在权衡。通常,睡眠任务在周期性时钟中断处理程序的执行期间恢复。相对较低的时钟中断速率不会带来太多开销,但同时会导致任务过早或过晚恢复。在实时 Linux 中,通过使用高粒度、单次定时器以及标准周期性时钟中断,可以消除此问题。任务在定时器中断处理程序中精确地在需要时恢复。
当前版本的 RT-Linux 可通过匿名 ftp 从 luz.cs.nmt.edu 获取。有关 RT-Linux 的信息可以在网站 luz.cs.nmt.edu/~rtlinux 上找到。该系统正在积极开发中,因此尚未达到生产级别的稳定性,但它非常可靠。我们也在开发一些应用程序,这些应用程序也将在网站上提供。我们要求使用该系统的人员也将其应用程序放在网站上。
Michael Barabanov
Victor Yodaiken 是新墨西哥理工大学的计算机科学教授。他的研究领域是操作系统、实时系统和自动机理论。