在嵌入式 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 的工作流程
- 应用程序调用
read()
函数从设备读取数据。 - 如果设备不可用或数据未准备好,应用程序线程进入休眠状态。
- 当设备可用时,操作系统唤醒应用程序线程,并从设备中读取数据。
- 数据读取成功后,应用程序继续执行。
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 操作时,如果设备资源不可用,应用程序线程不会被挂起,而是立即返回一个错误码(通常是 EAGAIN
或 EWOULDBLOCK
),表示设备暂时不可用。应用程序可以选择轮询设备状态,或者直接放弃操作。
1.3.1 非阻塞 I/O 的工作流程
- 应用程序调用
read()
函数从设备读取数据。 - 如果设备不可用或数据未准备好,
read()
函数立即返回一个错误码。 - 应用程序可以选择继续轮询设备状态,或者直接放弃操作。
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 的实现依赖于轮询机制。应用程序可以通过 select
、poll
或 epoll
函数来查询设备状态,判断设备是否可以进行 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
函数的使用分为三个步骤:
- 创建
epoll
句柄:epoll_create()
- 添加/修改/删除监视的文件描述符:
epoll_ctl()
- 等待事件发生:
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 驱动开发中,合理运用这些知识,能够帮助开发者编写出更加高效、稳定的驱动程序。