在嵌入式 Linux 驱动开发中,I/O(输入/输出)操作是不可避免的一部分。无论是从设备读取数据,还是向设备写入数据,I/O 操作都是应用程序与硬件设备之间的桥梁。然而,I/O 操作的处理方式却可以大不相同,尤其是在阻塞与非阻塞 I/O 模型之间。本文将深入探讨阻塞与非阻塞 I/O 的概念、实现方式以及它们在 Linux 驱动开发中的应用。

1. 阻塞与非阻塞 I/O 简介

1.1 什么是 I/O?

在嵌入式系统中,I/O 操作通常指的是应用程序与硬件设备之间的数据交换。这里的 I/O 并不是指 GPIO(通用输入输出引脚),而是指应用程序对设备驱动的输入输出操作。例如,从传感器读取数据、向显示器写入数据等都属于 I/O 操作。

1.2 阻塞 I/O

阻塞 I/O 是指当应用程序请求 I/O 操作时,如果设备资源不可用(例如设备忙或数据未准备好),应用程序的线程会被挂起,进入休眠状态,直到设备资源可用为止。这种方式的好处是,当设备不可用时,应用程序不会占用 CPU 资源,而是将 CPU 资源让给其他任务。

1.2.1 阻塞 I/O 的工作流程

  1. 应用程序调用 read() 函数从设备读取数据。
  2. 如果设备不可用或数据未准备好,应用程序线程进入休眠状态。
  3. 当设备可用时,操作系统唤醒应用程序线程,并从设备中读取数据。
  4. 数据读取成功后,应用程序继续执行。

1.2.2 阻塞 I/O 的代码示例

int fd;
int data = 0;

fd = open("/dev/xxx_dev", O_RDWR); /* 阻塞方式打开设备 */
ret = read(fd, &data, sizeof(data)); /* 读取数据 */

在这个示例中,open() 函数以阻塞方式打开设备文件,read() 函数会阻塞应用程序线程,直到设备数据可用。

1.3 非阻塞 I/O

非阻塞 I/O 是指当应用程序请求 I/O 操作时,如果设备资源不可用,应用程序线程不会被挂起,而是立即返回一个错误码(通常是 EAGAINEWOULDBLOCK),表示设备暂时不可用。应用程序可以选择轮询设备状态,或者直接放弃操作。

1.3.1 非阻塞 I/O 的工作流程

  1. 应用程序调用 read() 函数从设备读取数据。
  2. 如果设备不可用或数据未准备好,read() 函数立即返回一个错误码。
  3. 应用程序可以选择继续轮询设备状态,或者直接放弃操作。

1.3.2 非阻塞 I/O 的代码示例

int fd;
int data = 0;

fd = open("/dev/xxx_dev", O_RDWR | O_NONBLOCK); /* 非阻塞方式打开设备 */
ret = read(fd, &data, sizeof(data)); /* 读取数据 */

在这个示例中,open() 函数以非阻塞方式打开设备文件,read() 函数不会阻塞应用程序线程,而是立即返回。

2. 阻塞 I/O 的实现:等待队列

在 Linux 内核中,阻塞 I/O 的实现依赖于等待队列(wait queue)。等待队列是一种用于管理休眠进程的机制,当设备不可用时,进程会被添加到等待队列中,进入休眠状态;当设备可用时,进程会被唤醒。

2.1 等待队列头

等待队列头是等待队列的核心数据结构,用于管理等待队列中的所有进程。等待队列头的类型为 wait_queue_head_t,定义在 include/linux/wait.h 中:

struct __wait_queue_head {
    spinlock_t lock;
    struct list_head task_list;
};
typedef struct __wait_queue_head wait_queue_head_t;

2.1.1 初始化等待队列头

等待队列头在使用前需要初始化,可以使用 init_waitqueue_head() 函数进行初始化:

void init_waitqueue_head(wait_queue_head_t *q);

也可以使用宏 DECLARE_WAIT_QUEUE_HEAD 一次性完成等待队列头的定义和初始化:

DECLARE_WAIT_QUEUE_HEAD(my_queue);

2.2 等待队列项

每个等待的进程都是一个等待队列项,类型为 wait_queue_t,定义如下:

struct __wait_queue {
    unsigned int flags;
    void *private;
    wait_queue_func_t func;
    struct list_head task_list;
};
typedef struct __wait_queue wait_queue_t;

2.2.1 定义并初始化等待队列项

可以使用宏 DECLARE_WAITQUEUE 定义并初始化一个等待队列项:

DECLARE_WAITQUEUE(name, tsk);

其中,name 是等待队列项的名字,tsk 是等待队列项所属的任务(进程),通常设置为 current,表示当前进程。

2.3 添加/移除等待队列项

当设备不可用时,需要将进程对应的等待队列项添加到等待队列头中;当设备可用时,需要将等待队列项从等待队列头中移除。

2.3.1 添加等待队列项

void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);

2.3.2 移除等待队列项

void remove_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);

2.4 等待唤醒

当设备可用时,需要唤醒等待队列中的进程。可以使用以下两个函数:

void wake_up(wait_queue_head_t *q);
void wake_up_interruptible(wait_queue_head_t *q);

wake_up() 函数可以唤醒所有等待队列中的进程,而 wake_up_interruptible() 函数只能唤醒处于可中断状态的进程。

2.5 等待事件

除了主动唤醒进程外,还可以使用等待事件机制。等待事件机制允许进程在某个条件满足时自动唤醒。常用的等待事件函数如下:

  • wait_event(wq, condition):等待条件满足,进程进入不可中断状态。
  • wait_event_timeout(wq, condition, timeout):等待条件满足或超时。
  • wait_event_interruptible(wq, condition):等待条件满足,进程进入可中断状态。
  • wait_event_interruptible_timeout(wq, condition, timeout):等待条件满足或超时,进程进入可中断状态。

3. 非阻塞 I/O 的实现:轮询

非阻塞 I/O 的实现依赖于轮询机制。应用程序可以通过 selectpollepoll 函数来查询设备状态,判断设备是否可以进行 I/O 操作。

3.1 select 函数

select 函数用于监视多个文件描述符的状态变化,原型如下:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

3.1.1 select 函数的使用示例

void main(void) {
    int ret, fd;
    fd_set readfds;
    struct timeval timeout;

    fd = open("dev_xxx", O_RDWR | O_NONBLOCK); /* 非阻塞方式打开设备 */

    FD_ZERO(&readfds); /* 清空 readfds */
    FD_SET(fd, &readfds); /* 将 fd 添加到 readfds */

    timeout.tv_sec = 0;
    timeout.tv_usec = 500000; /* 设置超时时间为 500ms */

    ret = select(fd + 1, &readfds, NULL, NULL, &timeout);
    switch (ret) {
        case 0: /* 超时 */
            printf("timeout!\r\n");
            break;
        case -1: /* 错误 */
            printf("error!\r\n");
            break;
        default: /* 可以读取数据 */
            if (FD_ISSET(fd, &readfds)) {
                /* 使用 read 函数读取数据 */
            }
            break;
    }
}

3.2 poll 函数

poll 函数与 select 函数类似,但没有文件描述符数量的限制。poll 函数的原型如下:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

3.2.1 poll 函数的使用示例

void main(void) {
    int ret, fd;
    struct pollfd fds;

    fd = open(filename, O_RDWR | O_NONBLOCK); /* 非阻塞方式打开设备 */

    fds.fd = fd;
    fds.events = POLLIN; /* 监视数据是否可以读取 */

    ret = poll(&fds, 1, 500); /* 轮询设备状态,超时时间为 500ms */
    if (ret > 0) { /* 数据有效 */
        /* 读取数据 */
    } else if (ret == 0) { /* 超时 */
        /* 处理超时 */
    } else if (ret < 0) { /* 错误 */
        /* 处理错误 */
    }
}

3.3 epoll 函数

epoll 函数是 Linux 内核为处理大规模并发 I/O 操作而设计的,特别适用于网络编程。epoll 函数的使用分为三个步骤:

  1. 创建 epoll 句柄:epoll_create()
  2. 添加/修改/删除监视的文件描述符:epoll_ctl()
  3. 等待事件发生:epoll_wait()

3.3.1 epoll 函数的使用示例

void main(void) {
    int epfd, fd, ret;
    struct epoll_event ev, events[10];

    epfd = epoll_create(1); /* 创建 epoll 句柄 */
    fd = open(filename, O_RDWR | O_NONBLOCK); /* 非阻塞方式打开设备 */

    ev.events = EPOLLIN; /* 监视数据是否可以读取 */
    ev.data.fd = fd;

    epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); /* 添加监视的文件描述符 */

    ret = epoll_wait(epfd, events, 10, 500); /* 等待事件发生,超时时间为 500ms */
    if (ret > 0) { /* 有事件发生 */
        /* 处理事件 */
    } else if (ret == 0) { /* 超时 */
        /* 处理超时 */
    } else if (ret < 0) { /* 错误 */
        /* 处理错误 */
    }
}

4. 总结

阻塞与非阻塞 I/O 是 Linux 驱动开发中两种重要的 I/O 模型。阻塞 I/O 适用于设备操作时间较长的场景,能够有效节省 CPU 资源;而非阻塞 I/O 则适用于需要快速响应的场景,能够避免应用程序线程被长时间挂起。在实际开发中,开发者可以根据具体需求选择合适的 I/O 模型,并结合等待队列、轮询等机制实现高效的 I/O 操作。

通过本文的详细讲解,相信读者已经对阻塞与非阻塞 I/O 有了深入的理解。在实际的嵌入式 Linux 驱动开发中,合理运用这些知识,能够帮助开发者编写出更加高效、稳定的驱动程序。