细节决定成败

作者:Georg V. Zezschwitz

从前两篇文章的干净代码环境出发,我们现在转向所有令人讨厌的中断内容。令人惊讶的是,Linux 为我们隐藏了大部分内容,因此我们不需要编写任何汇编代码...

读取和写入

现在,我们的神奇 skel-机器驱动程序可以加载甚至卸载(与 DOS 不同,它无痛),但它既没有读取也没有写入任何字符。因此,我们将开始充实前一篇文章(在fops and filp下)中介绍的 skel_read()skel_write() 函数。这两个函数都接受四个参数

Static int skel_read (struct inode *inode,
                      struct file *file,
                      char *buf, int count)
Static int skel_write (struct inode *inode,
                       struct file *file,
                       const char *buf,
                       int count)

inode 结构为函数提供在 skel_open() 调用期间已经使用的信息。例如,我们从 inode->i_rdev 确定用户想要打开哪个板卡,并将此数据以及板卡的基本地址和中断传输到文件描述符的 private_data 条目。我们现在可能会忽略此信息,但如果我们不使用此技巧,inode 是我们唯一的机会来确定我们正在与哪个板卡对话。

file 结构包含更有价值的数据。您可以在 <linux/fs.h> 的定义中探索所有元素。如果您使用 private_data 条目,您可以在这里找到它,并且您还应该使用 f_flags 条目——例如,向您揭示用户是否想要阻塞或非阻塞模式。(我们将在稍后更详细地解释这个主题。)

buf 参数告诉我们读取的字节放在哪里(或写入的字节在哪里),count 指定有多少字节。但是您必须记住,每个进程都有自己的私有地址空间。在内核代码中,有一个所有进程共有的地址空间。当系统调用代表特定进程执行时,它们在内核地址空间中运行,但仍然能够访问用户空间。从历史上看,这是通过使用 fs 寄存器的汇编代码完成的;当前的 Linux 内核将特定代码隐藏在名为 get_user_byte()(用于从用户地址空间读取一个字节)、put_user_byte()(用于写入一个字节)等函数中。它们以前被称为 get_fs_byte,只有 memcpy_tofs()memcpy_fromfs() 甚至在 DEC Alpha 上也揭示了这些旧时代。如果您想探索,请查看 <asm/segment.h>

让我们想象一下理想的硬件,它总是渴望接收数据,快速读取和写入,并通过我们接口的基本地址处的简单 8 位数据端口进行访问。虽然这个例子是不现实的,但如果您不耐烦,您可以尝试以下代码

Static int skel_read (struct inode *inode,
                      struct file *file,
                      char *buf, int count) {
    int n = count;
    char *port = PORT0 ((struct Skel_Hw*)
                        (file->private_data));
    while (n--) {
        Wait till device is ready
        put_user_byte (inb_p (port), buf);
        buf++;
    }
    return count;
}

请注意 inb_p() 函数调用,它是来自硬件的实际 I/O 读取。您可能会决定使用其快速等效项 inb(),它省略了一些慢速硬件可能需要的最小延迟,但我更喜欢安全的方式。

等效的 skel_write() 函数几乎相同。只需将 put_user_byte() 行替换为以下内容

        outb_p (get_user_byte (buf), port);

但是,这些行有很多缺点。使用它们会导致 Linux 无限循环,同时等待永远不会准备就绪的设备吗?我们的驱动程序应该将等待循环中的时间分配给其他进程,充分利用我们昂贵的 CPU 中的所有资源,并且它应该有一个输入和输出缓冲区,用于在我们不在 skel_read() 和相应的 skel_write() 调用中时到达的字节。它还应包含超时测试以防出错,并且应支持阻塞和非阻塞模式。

阻塞和非阻塞模式

想象一个一次读取 256 字节的进程。不幸的是,当调用 skel_read() 时,我们的输入缓冲区是空的。那么它应该怎么做——返回并说还没有数据,还是等到至少到达一些字节?

答案是两者都是阻塞模式意味着用户希望驱动程序等待直到读取一些字节。非阻塞模式意味着尽快返回——只需读取所有可用的字节。类似的规则适用于写入:阻塞模式意味着“在您可以接受一些数据之前不要返回”,而非阻塞模式意味着:“即使没有接受任何内容也返回。” read()write() 调用通常返回成功读取或写入的数据字节数。但是,如果设备是非阻塞的并且无法传输任何字节,则通常返回 -EAGAIN(意思是:“再来一次,山姆”)。偶尔,旧代码可能会返回 -EWOULDBLOCK,它在 Linux 下与 -EAGAIN 相同。

也许现在您像我第一次听到这两种模式一样开心地笑了。如果这些概念对您来说是新的,您可能会发现以下提示很有帮助。每个设备默认以阻塞模式打开,但您可以通过在 open() 调用中设置 O_NONBLOCK 标志来选择非阻塞模式。您甚至可以使用 fcntl() 调用稍后更改文件的行为。 fcntl() 调用很简单,手册页对于任何程序员来说都足够了。

睡美人

很久以前,一位美丽的公主被女巫送入漫长而沉睡的睡眠中,持续了一百年。世界几乎忘记了她和她的城堡,玫瑰缠绕着城堡,直到有一天,一位英俊的王子来了,吻了她,唤醒了她——以及你在童话故事中听到的所有其他美好的事情都发生了。

我们的驱动程序应该像公主在等待数据时所做的那样:睡觉,让世界继续运转。 Linux 提供了一种机制来实现这一点,称为 interruptible_sleep_on()。每个到达此调用的进程都将进入睡眠状态,并将其时间片贡献给世界的其余部分。它将一直在此函数中停留,直到另一个进程调用 wake_up_interruptible(),而这个“王子”通常采用成功接收或发送数据的中断处理程序的形式,或者如果发生超时情况,则采用 Linux 本身的形式。

安装中断处理程序

本系列的前一篇文章展示了一个最小的中断处理程序,它被称为 skel_trial_fn(),但没有解释其工作原理。在这里,我们介绍一个“完整”的中断处理程序,它将处理到实际硬件设备的输入和来自实际硬件设备的输出。图 1 显示了其概念的简单版本:当驱动程序等待设备准备就绪(阻塞)时,它通过调用 interruptible_sleep_on() 进入睡眠状态。有效的中断结束了这种睡眠,重新启动 skel_write()

图 1 不包括我们在使用内部输出缓冲区时需要的双重嵌套循环结构。原因是如果我们在 skel_write() 函数中只能执行写入,则不需要内部输出缓冲区。但是我们的驱动程序应该在不在 skel_read() 中时也捕获数据,并且即使不在 skel_write() 中也应该在后台写入数据。因此,我们将更改 skel_write() 中的硬件写入以写入输出缓冲区,并让中断处理程序执行到硬件的实际写入。中断和 skel_write() 现在将通过“睡美人”机制和输出缓冲区连接起来。

中断处理程序在设备的 open()close() 调用期间安装和卸载,如前一篇文章中所建议的那样。此任务由以下内核调用处理

#include <linux/sched.h>
int request_irq(unsigned int irq,
                void (*handler)
                     (int, struct pt_regs *),
                unsigned long flags,
                const char *device);
void free_irq(unsigned int irq);

handler 参数是我们希望安装的实际中断处理程序。 flags 参数的作用是设置处理程序的几个功能,最重要的是它作为快速处理程序的行为(在 flags 中设置 SA_INTERRUPT)或作为慢速处理程序的行为(设置 SA_INTERRUPT)。快速处理程序在禁用所有中断的情况下运行,而慢速处理程序在启用除自身之外的所有中断的情况下执行。

最后,device 参数用于在查看 /proc/interrupts 时识别处理程序。

request_irq() 安装的处理程序函数仅传递中断号和处理器寄存器的(通常无用的)内容。

因此,我们将首先确定调用中断属于哪个板卡。如果我们找不到任何板卡,就会发生称为中断的情况,我们应该忽略它。通常,中断用于告知设备是否准备好进行读取写入,因此我们必须通过一项或多项硬件测试来找出设备希望我们做什么。

当然,我们应该快速离开我们的中断处理程序。奇怪的是,即使在快速中断处理程序中也允许使用 printk()(以及 PDEBUG 行)。这是 Linux 实现的一个非常有用的功能。如果您查看 kernel/printk.c,您会发现它的实现是基于等待队列的,因为将消息实际传递到日志文件是由外部进程(通常是 klogd)处理的。

如图 图 2 所示,Linux 可以在 interruptible_sleep_on() 中处理超时。例如,如果您正在使用一个设备向其发送应答,并且期望它在有限的时间内回复,则在返回给用户进程的返回值中导致超时以发出 I/O 错误 (-EIO) 可能是一个不错的选择。

当然,用户进程也可以使用 alarm 机制来处理这个问题。但在驱动程序本身中处理这个问题肯定更容易。超时标准由 SKEL_TIMEOUT 指定,以 jiffies 为单位计数。 Jiffies 是 Linux 系统的稳定心跳,一个稳定的计时器每隔几毫秒递增一次。频率或每秒 jiffies 数在 <asm/param.h> (包含在 <linux/sched.h> 中) 中由 HZ 定义,并且在不同的架构上有所不同(100 Hz Intel,1 kHz Alpha)。您只需设置

#define SKEL_TIMEOUT timeout_seconds * HZ
/* ... */
current->timeout = jiffies + SKEL_TIMEOUT

如果 interruptible_sleep_on 超时,则在返回后将清除 current->timeout

请注意,中断可能会发生在 skel_read()skel_write() 中。可能在中断中更改的变量应声明为 volatile。它们还需要受到保护以避免竞争条件。保护关键区域的经典代码序列如下

unsigned long flags;
save_flags (flags);
cli ();
critical region
restore_flags (flags);

最后,用于“完整”错误处理程序的代码

#define SKEL_IBUFSIZ 512
#define SKEL_OBUFSIZ 512
/* for 5 seconds timeout */
#define SKEL_TIMEOUT (5*HZ)
/* This should be inserted in the Skel_Hw-structure */
typedef struct Skel_Hw {
    /* write position in input-buffer */
    volatile int ibuf_wpos;
    /* read position in input-buffer */
    int ibuf_rpos;
    /* the input-buffer itself */
    char *ibuf;
    /* write position in output-buffer */
    int obuf_wpos;
    /* read position in output-buffer */
    volatile int buf_rpos;
    char *obuf;
    struct wait_queue *skel_wait_iq;
    struct wait_queue *skel_wait_oq;
    [...]
}
#define SKEL_IBUF_EMPTY(b) \
 ((b)->ibuf_rpos==(b)->ibuf_wpos)
#define SKEL_OBUF_EMPTY(b) \
 ((b)->obuf_rpos==(b)->obuf_wpos)
#define SKEL_IBUF_FULL(b) \
 (((b)->ibuf_wpos+1)%SKEL_IBUFSIZ==(b)->ibuf_rpos)
#define SKEL_OBUF_FULL(b) \
 (((b)->obuf_wpos+1)%SKEL_OBUFSIZ==(b)->obuf_rpos)
Static int skel_open (struct inode *inode,
                      struct file *filp) {
    /* .... */
    /* First we allocate the buffers */
    board->ibuf = (char*) kmalloc (SKEL_IBUFSIZ,
                                   GFP_KERNEL);
    if (board->ibuf == NULL)
        return -ENOMEM;
    board->obuf = (char*) kmalloc (SKEL_OBUFSIZ,
                                   GFP_KERNEL);
    if (board->obuf == NULL) {
        kfree_s (board->ibuf, SKEL_IBUFSIZ);
        return -ENOMEM;
    }
    /* Now we clear them */
    ibuf_wpos = ibuf_rpos = 0;
    obuf_wpos = obuf_rpos = 0;
    board->irq = board->hwirq;
    if ((err=request_irq(board->irq>
                         skel_interrupt,
                         SA_INTERRUPT, "skel")))
        return err;
}
Static void skel_interrupt(int irq,
                    struct pt_regs *unused) {
    int i;
    Skel_Hw *board;
    for (i=0, board=skel_hw; i<skel_boards;
         board++, i++)
         /* spurious */
        if (board->irq==irq) break;
    if (i==skel_boards) return;
    if (board_is_ready_for_input)
        skel_hw_write (board);
    if (board_is_ready_for_output)
        skel_hw_read (board);
}
Static inline void skel_hw_write (Skel_Hw *board){
    int rpos;
    char c;
    while (! SKEL_OBUF_EMPTY (board) &&
        board_ready_for_writing) {
        c = board->obuf [board->obuf_rpos++];
        write_byte_c_to_device
        board->obuf_rpos %= SKEL_OBUF_SIZ;
    }
    /* Sleeping Beauty */
    wake_up_interruptible (board->skel_wait_oq);
}
Static inline void skel_hw_read (Skel_Hw *board) {
    char c;
    /* If space left in the input buffer & device ready: */
    while (! SKEL_IBUF_FULL (board) &&
        board_ready_for_reading) {
        read_byte_c_from_device
        board->ibuf [board->ibuf_wpos++] = c;
        board->ibuf_wpos %= SKEL_IBUFSIZ;
    }
    wake_up_interruptible (board->skel_wait_iq);
}
Static int skel_write (struct inode *inode,
                       struct file *file,
                       char *buf, int count) {
    int n;
    int written=0;
    Skel_Hw board =
        (Skel_Hw*) (file->private_data);
    for (;;) {
        while (written<count &&
               ! SKEL_OBUF_FULL (board)) {
            board->obuf [board->obuf_wpos] =
                get_user_byte (buf);
            buf++; board->obuf_wpos++;
            written++;
            board->obuf_wpos %= SKEL_OBUFSIZ;
        }
        if (written) return written;
        if (file->f_flags & O_NONBLOCK)
            return -EAGAIN;
        current->timeout = jiffies + SKEL_TIMEOUT;
        interruptible_sleep_on (
            &(board->skel_wait_oq));
        /* Why did we return? */
        if (current->signal & ~current->blocked)
        /* If the signal is not not being
           blocked */
            return -ERESTARTSYS;
        if (!current->timeout)
            /* no write till timout: i/o-error */
            return -EIO;
    }
}
Static int skel_read (struct inode *inode,
                      struct file *file,
                      char *buf, int count) {
    Skel_Hw board =
        (Skel_Hw*) (file->private_data);
    int bytes_read = 0;
    if (!count) return 0;
    if (SKEL_IBUF_EMPTY (board)) {
        if (file->f_flags & O_NONBLOCK)
            /* Non-blocking */
            return -EAGAIN;
        current->time_out = jiffies+SKEL_TIMEOUT;
        for (;;) {
            skel_tell_hw_we_ask_for_data
            interruptible_sleep_on (
                &(board->skel_wait_iq));
            if (current->signal
                & ~current->blocked)
                return -ERESTARTSYS;
            if (! SKEL_IBUF_EMPTY (board))
                break;
            if (!current->timeout)
                /* Got timeout: return -EIO */
                return -EIO;
        }
    }
    /* if some bytes are here, return them */
    while (! SKEL_IBUF_EMPTY (board)) {
        put_user_byte (board->ibuf
                          [board->ibuf_rpos],
                       buf);
        buf++; board->ibuf_rpos++;
        bytes_read++;
        board->ibuf_rpos %= SKEL_IBUFSIZ;
        if (--count == 0) break;
    }
    if (count) /* still looking for some bytes */
        skel_tell_hw_we_ask_for_data
    return bytes_read;
}
处理 select()

要显示的最后一个重要的 I/O 函数是 select(),在我们看来,它是 Unix 最有趣的部分之一。

select() 调用用于等待设备准备就绪,并且是对于新手 C 程序员来说最可怕的函数之一。虽然此处未显示其在应用程序中的使用,但显示了系统调用的驱动程序特定部分,其最令人印象深刻的功能是其紧凑性。

这是完整代码

Static int skel_select(struct inode *inode,
                       struct file *file,
                       int sel_type,
                       select_table *wait) {
    Skel_Clientdata *data=filp->private_data;
    Skel_Board *board=data->board;
    if (sel_type==SEL_IN) {
        if (! SKEL_IBUF_EMPTY (board))
            /* readable */
            return 1;
        skel_tell_hw_we_ask_for_data;
        select_wait(&(hwp->skel_wait_iq), wait);
        /* not readable */
        return 0;
    }
    if (sel_type==SEL_OUT) {
        if (! SKEL_OBUF_FULL (board))
            return 1;  /* writable */
        /* hw knows */
        select_wait (&(hwp->skel_wait_oq), wait);
        return 0;
    }
    /* exception condition: cannot happen */
    return 0;
}

如您所见,内核负责管理等待队列的麻烦,您只需检查是否准备就绪。

当我们第一次为驱动程序编写 select() 调用时,我们不理解 wait_queue 实现,您也不需要理解。您只需要知道代码有效。 wait_queue 具有挑战性的,并且通常当您编写驱动程序时,您没有时间接受挑战。

实际上,select 在其与读取和写入的关系中更容易理解:如果 select() 说文件是可读的,则下一个读取不得阻塞(独立于 O_NONBLOCK),这意味着您必须告诉硬件返回数据。中断将收集数据,并唤醒队列。如果用户正在选择写入,情况类似:驱动程序必须告知 write() 是否会阻塞。如果缓冲区已满,它将阻塞,但您不需要告诉硬件它,因为 write() 已经告诉它了(当它填充缓冲区时)。如果缓冲区未满,则写入不会阻塞,因此您返回 1。

这种考虑选择写入的方式可能看起来很奇怪,因为有时您需要同步写入,并且您可能期望设备在已接受挂起的输入时可写入。不幸的是,这种做事方式会破坏阻塞/非阻塞机制,因此提供了一个额外的调用:如果您需要同步写入,驱动程序必须提供(在其 fops 中)fsync() 调用。应用程序通过 fsync() 系统调用调用 fops->fsync,如果驱动程序不支持它,则返回 -EINVAL

ioctl()--传递控制信息

想象一下,您想更改您构建的串行多端口卡的波特率。或者告诉您的帧捕获器更改图像的分辨率。或者其他任何东西... 您可以将这些指令包装成一系列转义序列,例如,ANSI 仿真中的屏幕定位。但是,正常的做法是进行 ioctl() 调用。

ioctl() 调用在 <sys/ioctl.h> 中定义,形式为

ioctl (int file_handle, int command, ...)

其中 ... 被认为是 char * 类型的参数(根据 ioctl 手册页)。奇怪的是,内核在 fs/ioctl.c 中以以下形式接收这些参数

int sys_ioctl (unsigned int fd, unsigned int cmd,
               unsigned long arg);

更令人困惑的是,<linux/ioctl.h> 给出了关于如何构建第二个参数中的命令的详细规则,但是所有驱动程序中还没有人真正遵循这些想法。

无论如何,与其清理 Linux 源代码树,不如关注 ioctl() 调用的总体思想。作为用户,您在前两个参数中传递文件句柄和命令,并将指向驱动程序应读取和/或写入的数据结构的指针作为第三个参数传递。

内核本身解释了一些命令——例如,FIONBIO,它更改文件的阻塞/非阻塞标志。其余的传递给我们自己的、驱动程序特定的 ioctl() 调用,并以以下形式到达

int skel_ioctl (struct inode *inode,
                struct file *file,
                unsigned int cmd,
                unsigned long arg)

在我们展示 skel_ioctl() 实现的小例子之前,您定义的命令应遵守以下规则

  1. 从 /usr/src/linux/MAGIC 中选取一个空闲的 MAGIC 数字,并将此数字作为 16 位命令字的高八位。

  2. 在低八位中枚举命令。

为什么这样?想象一下,“傻比利”启动了他最喜欢的终端程序 minicom 来连接到他的邮箱。“傻比利”不小心将 minicom 使用的串行线路从 /dev/ttyS0 更改为 /dev/skel0(他非常傻)。 minicom 接下来要做的是使用 TCGETA 作为命令通过 ioctl() 初始化“串行线路”。不幸的是,您的设备驱动程序隐藏在 /dev/skel0 后面,使用该号码来控制实验室长期实验的电压...

如果 ioctl() 命令中的高八位因驱动程序而异,则对不适当设备的每个 ioctl() 都将导致 -EINVAL 返回,从而保护我们免受极其意外的结果。

现在,为了完成本节,我们将实现一个 ioctl() 调用,用于读取或更改驱动程序中的超时延迟。如果您想使用它,您必须引入一个新变量

unsigned long skel_timeout = SKEL_TIMEOUT;

紧跟在 SKEL_TIMEOUT 的定义之后,并将以后每次出现的 SKEL_TIMEOUT 替换为 skel_timeout

我们选择 MAGIC '4' (ASCII 字符 4)并定义两个命令

# define SKEL_GET_TIMEOUT 0x3401
# define SKEL_SET_TIMEOUT 0x3402

在我们的用户进程中,这些行将使超时值加倍

/* ... */
unsigned long timeout;
if (ioctl (skel_hd, SKEL_GET_TIMEOUT,
           &timeout) < 0) {
    /* an error occurred (Silly billy?) */
    /* ... */
}
timeout *= 2;
if (ioctl (skel_hd, SKEL_SET_TIMEOUT,
           &timeout) < 0) {
    /* another error */
    /* ... */
}

在我们的驱动程序中,这些行将完成工作

int skel_ioctl (struct inode *inode,
                struct file *file,
                unsigned int cmd,
                unsigned long arg) {
    switch (cmd) {
    case SKEL_GET_TIMEOUT:
        put_user_long(skel_timeout, (long*) arg);
        return 0;
    case SKEL_SET_TIMEOUT:
        skel_timeout = get_user_long((long*) arg);
        return 0;
    default:
        return -EINVAL; /* for Silly Billy */
    }
}

Georg V. Zezschwitz 是一位 27 岁的 Linuxer,对计算机科学的实践方面有浓厚的兴趣,并且有避免睡眠的倾向。

XXXXXXXXXXXXXXXX (XXXXXXXXXXXXXX) 像 Georg 一样,也 27 岁,对计算机科学的实践方面有相同的兴趣,并且有相同的避免睡眠的倾向。

加载 Disqus 评论