剖析中断和浏览 DMA

作者:Alessandro Rubini

尽管上个月的文章似乎涵盖了关于中断的所有内容,但事实并非如此。一个月后,您已准备好理解中断处理的终极技术。我们还将通过解释支持 DMA 的驱动程序的任务,开始探索迷人的内存管理世界。

当前 Linux 版本中的更改

在我们开始之前,您应该注意最近 Linux 版本中的两个更改。在 Linux 1.3.70 中,首次采取措施支持共享中断。其思想是多个设备和设备驱动程序共享同一中断线。这也是 PCI 规范的一部分,其中每个设备都有其自己的供应商和产品相关的设备 ID。当读取此 ID 时,当启用 PCI 启动时,Linux 会非常详细地说明已插入的 PCI 设备。因此,linux/sched.hrequest_irqfree_irq 的声明现在变为

extern int request_irq(unsigned int irq,
   void (*handler)(int, void *, struct pt_regs *),
   unsigned long flags,
   const char *device,
   void *dev_id);
extern void free_irq(unsigned int irq, void *dev_id);

因此,当使用 request_irq() 注册中断时,必须向 Linux 传递第四个参数:设备 ID。目前,大多数设备在请求和释放中断时将为 dev_id 传递 NULL — 这导致与以前相同的行为。如果您真的想要共享中断,除了传递 dev_id 之外,还必须在 flags 中设置 SA_SHIRQ。中断共享仅在同一中断线上的所有设备驱动程序都同意共享其中断时才有效。

第二个“更改”不是真正的更改,而是一种风格上的升级:get_user_byte()put_user_byte() 被认为是过时的,不应在新代码中使用。它们被更灵活的 get_userput_user 调用所取代。

Linus Torvalds 解释说,这些函数使用编译器“魔法”来查看传递给它们的指针,并读取或写入正确大小的项目。这意味着您不能使用 void *unsigned long 作为指针;您必须始终使用指向正确类型的指针。此外,如果您给它一个 char *,您将得到一个 char而不是一个 unsigned char,这与旧的 get_fs_byte() 函数不同。如果您需要 unsigned 值,请使用指向 unsigned 类型的指针。永远不要强制转换返回值以获得您想要的访问大小 — 如果您认为需要这样做,那么您正在做一些错误的事情。您的参数指针应始终是正确的类型。

本质上,您应该将 get_user() 视为简单的指针解引用(有点像普通 C 中的 *(xxx),只是它从用户空间获取指针数据)。实际上,在某些体系结构上,它正是这样。

在我们修复以前的疏忽时,值得注意的是,内核提供了一种自动检测中断线的功能。这与我们几个月前的文章中显示的内容略有不同。有兴趣的人可以查看 <linux/interrupt.h> 以获取相关文档。

现在让我们回到您通常安排的编程。

中断的拆分视图

您可能还记得上次,中断处理由单个驱动程序函数管理。我们的实现处理了低级(确认中断)和高级(例如唤醒任务)问题。这种做事方式可能适用于简单的驱动程序,但如果处理速度太慢,则很容易失败。

如果您查看代码,很明显,确认中断和检索或发送相关数据只是工作的一小部分。对于常见的设备,您每次中断只移动一个或几个字节,大部分时间都花在管理设备特定的队列、链以及您的实现中使用的任何其他奇怪的数据结构上。不要以我们的 skel_interrupt() 为例,因为它是有史以来最简化的中断处理程序;真正的设备可能具有大量状态信息和多种操作模式。如果您花费太多时间处理数据结构,则可能会错过下一个或多个中断,并导致挂起或数据丢失,因为当中断处理程序运行时,至少该中断会被阻止,而对于快速中断处理程序,所有中断都会被阻止。

为此问题设计的解决方案是将中断处理任务分为两部分

  • 首先,快速的“上半部分”处理硬件问题,并且必须在新中断发出之前终止。通常,除了在设备和某些内存缓冲区之间移动数据(如果您的设备驱动程序使用 DMA,甚至不需要这样做)以及确保硬件处于正常状态之外,这里几乎没有其他操作。

  • 然后,处理程序的较慢的“下半部分”在启用中断的情况下运行,并且可以花费任意长时间来完成所有事情。它在中断服务后尽快执行。

幸运的是,内核提供了一种特殊的方式来调度下半部分代码的执行,这不一定与特定进程相关;这意味着运行函数的请求和函数本身的执行都在任何进程的上下文之外完成。需要一种特殊的机制,因为其他内核函数都在进程的上下文中运行 — 一个有序的执行线程,通常与用户级程序的运行实例相关联 — 而中断处理是异步的,与特定进程无关。

下半部分:何时以及如何

从程序员的角度来看,下半部分与上半部分非常相似,因为它不能调用 schedule(),并且只能执行原子内存分配。这是可以理解的,因为该函数不是在进程的上下文中调用的;下半部分是异步的,就像上半部分,即正常的中断处理程序一样。主要区别在于启用了中断,并且没有正在进行的临界代码。那么,这些例程究竟何时执行?

如您所知,处理器主要代表进程执行,无论是在用户空间还是内核空间(在执行系统调用时)。值得注意的例外是为中断服务和调度另一个进程来代替当前进程:在这些操作期间,current 指针没有意义,即使它始终是指向 struct task_struct 的有效指针。此外,当进程进入/退出系统调用时,内核代码正在控制 CPU。这种情况经常发生,因为一段代码处理每个系统调用。

考虑到这一点,很明显,如果您希望尽快执行下半部分,则必须从调度程序或进入或离开系统调用时调用它,因为在中断服务期间执行它是禁忌。实际上,Linux 从 schedule()kernel/sched.c)和 ret_from_sys_call()(定义在特定于体系结构的汇编文件中,通常为 entry.S)调用 do_bottom_half(),它定义在 kernel/softirq.c 中。

下半部分不绑定到中断号,尽管内核保留了一个由 32 个此类函数组成的静态数组。目前(我使用的是 1.3.71),无法请求空闲的下半部分编号或 ID,因此我们将硬编码一个。这是肮脏的编码,但仅用于展示这个想法;稍后我们将删除这种 ID 窃取。

为了执行您的下半部分,您需要告知内核。这通过 mark_bh() 函数完成,该函数接受一个参数,即您的下半部分的 ID。

此列表显示了使用“肮脏” ID 伪分配的拆分中断处理程序的代码。

#include <linux/interrupt.h>
#define SKEL_BH 16 /* dirty planning */
/*
 * This is the top half, argument to request_irq()
 */
static void skel_interrupt(int irq,
                           struct pt_regs *regs)
{
  do_top_half_stuff()
  /* tell the kernel to run the bh later */
  mark_bh(SKEL_BH);
}
/*
 * This is the bottom half
 */
static void do_skel_bh(void)
{
  do_bottom_half_stuff()
}
/*
 * But the bh must be initialized ...
 */
int init_module(void)
{
  /* ... */
  init_bh(SKEL_BH, do_skel_bh);
  /* ... */
}
/*
 * ... and cleaned up
 */
void cleanup_module(void)
{
  /* ... */
  disable_bh(SKEL_BH)
  /* ... */
}

使用任务队列

实际上,真正不需要肮脏的下半部分 ID 分配,因为内核实现了一种更复杂的机制,您一定会喜欢它。

此机制称为“任务队列”,因为要调用的函数保存在队列中,由链表构成。这也意味着您可以注册多个与您的驱动程序关联的下半部分函数。此外,任务队列与中断处理没有直接关系,可以独立于中断管理使用。

任务队列是 struct tq_struct 的链,如 <linux/tqueue.h> 中声明的那样。

struct tq_struct {
    /* linked list of active bh's */
    struct tq_struct *next;
    /* must be initialized to zero */
    int sync;
    /* function to call */
    void (*routine)(void *);
    /* argument to function */
    void *data;
};
typedef struct tq_struct * task_queue;
void queue_task(struct tq_struct *bh_pointer,
                task_queue *bh_list);
void run_task_queue(task_queue *list);

您会注意到 routine 参数是一个获取指针参数的函数。这是一个有用的功能,正如您很快就会自己发现的那样,但请记住 data 指针完全由您负责:如果它指向 kmalloc()ed 内存,您必须记住自己释放它。

另一件要记住的事情是 next 字段用于保持队列一致,因此您必须小心永远不要查看它,并且永远不要将同一个 tq_struct 插入多个队列,也不要在一个队列中插入两次。

在标头中还有一些类似于 queue_task() 的其他函数,值得一看。在这里,我们坚持使用最通用的调用。

为了使用任务队列,您需要声明自己的队列或将任务添加到预定义的队列。我们将探索这两种方法。

此列表显示了如何在您自己的队列中使用中断处理程序运行多个下半部分。

#include <linux/interrupt.h>
#include <linux/tqueue.h#gt;
DECLARE_TASK_QUEUE(tq_skel);
#define SKEL_BH 16 /* dirty planning */
/*
 * Two different tasks
 */
static struct tq_struct task1;
static struct tq_struct task2;
/*
 * The bottom half only runs the queue
 */
static void do_skel_bh(void)
{
  run_task_queue(&tq_skel);
}
/*
 * The top half queues the different tasks based
 * on some conditions
 */
static void skel_interrupt(int irq,
                           struct pt_regs *regs)
{
  do_top_half_stuff()
  if (condition1()(I) {
    queue_task(&task1, &tq_skel);
    mark_bh(SKEL_BH);
  }
  if (condition2()(I) {
    queue_task(&task2, &tq_skel);
    mark_bh(SKEL_BH);
  }
}
/*
 * And init as usual
 */
int init_module(void)
{
  /* ... */
  task1.routine=proc1; task1.data=arg1;
  task2.routine=proc2; task2.data=arg2;
  init_bh(SKEL_BH, do_skel_bh);
  /* ... */
}
void cleanup_module(void)
{
  /* ... */
  disable_bh(SKEL_BH)
  /* ... */
}

使用任务队列是一种令人愉快的体验,有助于保持代码的整洁。例如,如果您正在运行前几期 Kernel Korner 专栏中描述的 skel 机器,您可以使用单个中断处理函数来服务多个硬件设备,该函数将硬件结构作为参数。此行为可以通过将 tq_struct 作为 Skel_Hw 结构的成员来实现。这里最大的优势是,如果多个设备在几乎同一时间请求关注,则所有设备都会排队并在以后一次性服务(启用中断)。当然,这仅在 Skel 硬件对何时确认中断以及中断条件的处理不太挑剔的情况下才有效。

运行预定义的队列

内核本身为了您的方便和娱乐定义了三个任务队列。自定义驱动程序通常应使用其中一个队列而不是声明自己的队列,并且任何驱动程序都可以使用预定义的队列而无需声明新的队列。存在特殊队列的唯一原因是更高的性能:ID 较小的队列首先执行。

三个预定义的队列是

struct tq_struct *tq_timer;
struct tq_struct *tq_scheduler;
struct tq_struct *tq_immediate;

第一个与内核计时器相关联运行,此处不作讨论 — 留给读者练习。下一个在每次发生调度时运行,最后一个在中断处理程序退出时“立即”运行,作为通用下半部分;这通常将是您在驱动程序中用来替换旧的下半部分机制的队列。

“立即”队列的使用方式与上面的 tq_skel 类似。您无需选择 ID 并声明它,尽管仍然必须使用 IMMEDIATE_BH 参数调用 mark_bh(),如下所示。相应地,tq_timer 队列使用 mark_bh(TIMER_BH),但 tq_scheduler 队列不需要标记即可运行,因此不调用 mark_bh()

此列表显示了使用“立即”队列的示例

#include <linux/interrupt.h>
#include <linux/tqueue.h>
/*
 * Two different tasks
 */
static struct tq_struct task1;
static struct tq_struct task2;
/*
 * The top half queues tasks, and no bottom
 * half is there
 */
static void skel_interrupt(int irq,
                           struct pt_regs *regs)
{
  do_top_half_stuff()
  if (condition1()(I) {
    queue_task(&task1,&tq_immediate);
    mark_bh(IMMEDIATE_BH);
    }
  if (condition2()(I) {
    queue_task(&task2,&tq_skel);
    mark_bh(IMMEDIATE_BH);
    }
}
/*
 * And init as usual, but nothing has to be
 * cleaned up
 */
int init_module(void)
{
  /* ... */
  task1.routine=proc1; task1.data=arg1;
  task2.routine=proc2; task2.data=arg2;
  /* ... */
}
示例:使用 tq_scheduler

任务队列非常有趣,但我们大多数人都缺少需要延迟处理的“真实”硬件。幸运的是,run_task_queue() 的实现足够智能,即使没有合适的硬件,您也可以玩得开心。

好消息是 run_task_queue() 在从队列中删除已排队的函数之后调用它们。因此,您可以从任务本身重新将任务插入队列。

这个愚蠢的任务每十秒钟只打印一条消息,直到世界末日。它只需要注册一次,然后它就会安排自己的存在。

struct tq_struct silly;
void silly_task(void *unused)
{
  static unsigned long last;
  if (jiffies/HZ/10 != last) {
    last=jiffies/HZ/10;
    printk(KERN_INFO "I'm there\n");
  }
queue_task(&silly, &tq_scheduler);
/* tq_scheduler doesn't need mark_bh() */
}

如果您正在考虑病毒,请稍等,并记住明智的管理员在事先阅读代码的情况下不会以 root 身份执行任何操作。

但是,让我们离开任务队列,开始研究内存问题。

PC 上的 DMA — 肮脏、依赖机器、糟糕

还记得 IBM PC 的旧时代吗?是的,那些日子,PC 配备 128 KB 的 RAM、8086 处理器、磁带接口和 360 KB 软盘。[您获得了整整 128KB?您真幸运!—ED] 那些日子,ISA 总线上的 DMA 被认为是快速的。DMA 的思想是在不让 CPU 执行移动单个字节的无聊工作的情况下,将数据块从设备传输到内存或反之亦然。相反,在初始化设备和主板的板载 DMA 控制器之后,设备会向 DMA 控制器发出信号,表明它有数据要传输。DMA 控制器将 RAM 置于从数据总线接收数据的状态,设备将数据放在数据总线上,完成此操作后,控制器会递增地址计数器并递减长度计数器,以便进一步的传输进入下一个位置。

在那些旧时代,这项技术速度很快,在 16 位 ISA 上允许高达每秒 800 千字节的传输速率。今天,我们使用 PCI 2.0 的传输速率为 132 MB/秒。但是,良好的旧 ISA DMA 仍然有其应用:想象一下声卡以 48 kHz 立体声播放 16 位编码的音乐。这将导致每秒 192 千字节。通过中断请求传输数据,例如每 20 微秒传输两个字,肯定会让 CPU 丢弃大量中断。以该速率轮询数据(非中断驱动的数据传输)肯定也会阻止 CPU 执行任何有用的操作。我们需要的是中速的连续数据流 — 非常适合 DMA。Linux 只需要启动和停止数据传输。其余的由硬件本身完成。

在本文中,我们将仅处理 ISA DMA — 大多数扩展卡仍然是 ISA,并且 ISA DMA 对于许多应用来说足够快。但是,ISA 总线上的 DMA 有严格的限制

  • 硬件和 DMA 控制器对虚拟内存一无所知 — 它们能看到的只是物理内存及其地址(此限制属于所有类型的 ISA DMA。)我们必须分配物理内存的连续块,而不是在此处使用一个页面,在彼处使用另一个页面,并在虚拟内存中将它们粘合在一起,并且我们不得交换出它们。

  • 基于 Intel 的系统有两个 DMA 控制器,一个具有较低的四个 DMA 通道,允许按字节传输,另一个具有较高的四个通道,允许(更快的)字传输。两者都只有一个 16 位地址计数器和一个 16 位长度计数器。因此,我们一次可能不会传输超过 65535 字节(或使用第二个控制器的字)。

  • 地址计数器仅表示地址的低 16 位(DMA 通道 0-3 上的 A0-A15,通道 4-7 上的 A1-A16)。地址空间的较高 8 位在寄存器中表示。它们在传输期间不会更改,这意味着传输只能在内存的 16 位(64 KB)段内进行。

  • 较高的8位?总共 24 位地址空间?是的,这很可悲,但这是事实。我们只能在 RAM 的较低的 16MB 内传输。如果您的数据最终需要到达另一个地址,则 CPU 必须再次“手动”复制它,使用 DMA 可访问的内存作为“反弹缓冲区”,这就是它的名称。

分配 DMA 缓冲区

好的,您知道这些限制 — 现在决定继续阅读还是不继续!

DMA 所需的第一件事是缓冲区。如果您使用以下方式分配缓冲区,则所有限制(内存的较低 16 MB、物理内存中的连续页面地址)都将得到满足

void  *dma_buf;
dma_buf = kmalloc(buffer_size,
                  GFP_BUFFER | GFP_DMA);

返回的地址永远不会适合页面的开头,尽管您希望如此。原因是 Linux 在已用和未用页面块上具有非常高级的内存管理,使用 kmalloc()。它维护小至 32 字节(DEC Alpha 上为 64 字节)的空闲段列表,另一个列表用于双倍大小的段,另一个列表用于四倍大小的段,等等。每次您释放 kmalloc()ed 内存时,Linux 都会尝试将您释放的段与空闲的相邻段连接起来。如果邻居也是空闲的,则它们将传递到双倍大小的列表中,并在其中再次检查它是否具有空闲邻居,以进入下一个顺序列表。

kmalloc 当前支持的大小(Linux 1.3.71)范围从 PAGE_SIZE > 7PAGE_SIZE << 5。2 的幂的每个步骤都是一个列表,因此一个列表中的两个连接元素形成下一个更高阶列表的一个元素。

您可能想知道为什么您没有获得简单的整页。那是因为在每个段的开头,都维护着一些列表信息。这称为(有些误导性的)page_descriptor,其大小当前在 16 到 32 字节之间(取决于您的机器类型)。因此,如果您向 Linux 请求 64KB 的 RAM,Linux 将不得不使用 128KB RAM 的空闲块,并为您提供 128 千字节到 32 字节。

这很难获得 — 软盘驱动程序有时会梦想实现这一点,当它尝试在运行时分配其 DMA 缓冲区并且找不到任何连续的 RAM 时。因此,始终以 2 的幂为单位进行思考,但随后减去一些字节(大约 0x20)。如果您想查看魔术数字,它们都在 mm/kmalloc.c 中。

中断的作用

大多数使用 DMA 的设备都会生成中断。例如,声卡会生成中断来告诉 CPU,“给我数据,我快用完了。”

我们为其构建驱动程序的机器非常迷人:它是一个实验室接口,具有自己的 CPU、自己的 RAM、模拟和数字输入和输出以及各种附加功能。它使用字符通道接收命令和发送答案,并使用 DMA 进行采样数据(或输出数据)的块传输。因此,调用的中断可能具有以下原因

  • 在字符通道上发送数据

  • 从字符通道读取数据

  • 启动与主机的 DMA 传输

  • 传输完成

  • 传输将要跨越 DMA 页寄存器的边界(必须递增 DMA 页寄存器)

您的中断处理程序必须找出中断的含义。通常,它会读取设备的状态寄存器,其中包含有关要执行的任务的更详细信息。

如您所见,我们已经远离了模块的干净加载和卸载的一般真理,并且正处于肮脏的专业化之中。我们决定我们的实验室驱动程序应执行以下任务

  • 在用户请求时分配可 DMA 的内存,并将此内存映射到用户空间(用户可以直接访问 DMA 缓冲区,并且可以“看到”它是如何填充的 — 这比先捕获数据然后再通过例如字符通道将其传输给用户更快,更灵活)。

  • 每当需要传输时,检查缓冲区地址是否有效(是否由我们的驱动程序分配)以及长度是否足够。

  • 并且,当然,不要忘记在用户关闭字符通道时释放内存,即使尚未显式释放。

此概念不同于软盘驱动程序,在软盘驱动程序中,您永远不会直接查看实际的 DMA 缓冲区。但这可能对您也有好处:您可能会决定将此方法用于帧抓取器。用户可能会分配多个缓冲区,为其中一个设置传输地址,并在第一个缓冲区被填充时查看另一个缓冲区。由于用户和系统唯一需要做的是切换采样地址,并且两个缓冲区都映射到用户地址空间,因此无需 CPU 传输图片的单个字节 — 它们只是到达用户想要它们的位置。我们将在下一期 Kernel Korner 中描述执行此操作的技巧。

在我们开始编写实际代码之前,让我们考虑一下完整传输要采取的步骤

  1. 发生中断,表示应开始传输。

  2. 中断处理程序启动传输。

  3. 中断处理程序返回,而 CPU 开始其正常活动,传输正在运行。

  4. 中断发生,表示传输已完成。

  5. 中断处理程序结束传输...

  6. ...并且可能会要求设备进行另一次传输,唤醒沉睡的美人等。

不要对我们不编写您的整个设备驱动程序感到失望 — 在不同的情况下,事情会有很大不同!这是步骤 2) 和 5) 的代码

static int skel_dma_start (unsigned long dma_addr,
                           int dma_chan,
                           unsigned long dma_len,
                           int want_to_read) {
    unsigned long flags;
    if (!dma_len || dma_len > 0xffff)
        /* Invalid length */
        return -EINVAL;
    if (dma_addr & 0xffff !=
       (dma_addr + dma_len) & 0xffff)
        /* We would cross a 64kb-segment */
        return -EINVAL;
    if (dma_addr + dma_len > MAX_DMA_ADDRESS)
        /* Only lower 16 MB */
        return -EINVAL;
    /* Don't need any irqs here... */
    save_flags (flags); cli ();
    /* Get a well defined state */
    disable_dma (dma_chan);
    clear_dma_ff (dma_chan);
    set_dma_mode (dma_chan,
                  want_to_read ?
                  /* we want to get data */
                  DMA_MODE_READ
                  /* we want to send data */
                 : DMA_MODE_WRITE);
    set_dma_addr (dma_chan, dma_addr);
    set_dma_count (dma_chan, dma_len);
    enable_dma (dma_chan);
    restore_flags (flags);
    return 0;
}
static void skel_dma_stop (int dma_chan) {
    disable_dma (dma_chan);
}

抱歉,我们无法在此处为您提供更详细的代码,因为您必须自己决定如何在驱动程序中包含 DMA。与往常一样,使事情正常工作的最佳方法是查看一些可行的实现作为起点。

深入和进一步

如果您想深入了解刚刚描述的主题,最好的老师仍然是源代码。拆分半中断处理程序和任务队列在整个主流内核中使用,而此处显示的 DMA 实现取自我们的 ceddrv-0.17,可通过 ftp 从 tsx-11.mit.edu 获得。

下一期将回到更具体的问题 — 我们意识到 DMA 和任务队列可能都显得相当深奥。我们将展示 mmap() 的工作原理以及驱动程序如何实现其语义。

Alessandro Rubini 是一位 27 岁的 Linux 用户,对计算机科学的实践方面很感兴趣,并且有拖延睡觉的倾向。这有助于他通过利用时区偏移来赶上截止日期。

Georg V. Zezschwitz 也是一位 27 岁的 Linux 用户,对计算机科学的实践方面有相同的兴趣,并且有拖延睡觉的倾向。

加载 Disqus 评论