Linux驱动开发--阻塞、非阻塞I/O

2. 阻塞、非阻塞I/O

IO 指的是 Input/Output,也就是输入/输出,是应用程序对驱动设备的输入/输出操作。当应用程序对设备驱动进行操作的时候,如果不能获取到设备资源,那么阻塞式 IO 就会将应用程序对应的线程挂起,直到设备资源可以获取为止。对于非阻塞 IO,应用程序对应的线程不会挂起,它要么一直轮询等待,直到设备资源可以使用,要么就直接放弃。

在阻塞访问时,不能获取资源的进程将进入休眠,它将CPU资源"礼让"给其他进程。因为阻塞的进程会进入休眠状态,所以必须确保有一个地方能够唤醒休眠的进程 ,否则,进程就真的"寿终正寝"了。唤醒进程的地方最大可能发生在中断里面,因为在硬件资源获得的同时往往伴随着一个中断。而非阻塞的进程则不断尝试,直到可以进行I/O.

对于设备驱动文件的默认读取方式就是阻塞式的

c 复制代码
// 代码清单 8.1 阻塞地读串口一个字符
char buf;
fd = open("/dev/ttyS1", O_RDWR);
...
res = read(fd, &buf, 1);  /* 当串口上有输入时才返回 */
if(res == 1)
    printf("%c\n", buf);



// 代码清单 8.2 非阻塞地读串口一个字符
char buf;
fd = open("/dev/ttyS1", O_RDWR | O_NONBLOCK);
...
while(read(fd, &buf, 1) != 1)
    continue;  /* 串口上无输入也返回,因此要循环尝试读取串口 */
printf("%c\n", buf);

除了在打开文件时可以指定阻塞还是非阻塞方式以外,在文件打开后,也可以通过ioctl()fcntl()改变读写的方式,如从阻塞变更为非阻塞或者从非阻塞变更为阻塞。例如,调用 fcntl(fd.F_SETFL,O_NONBLOCK)可以设置 fd 对应的 I/0 为非阻塞。

2.1 阻塞--等待队列(休眠、唤醒)

在 Linux 驱动程序中,可以使用等待队列(Wait Queue)来实现阻塞进程的唤醒。等待队列很早就作为一个基本的功能单位 出现在 Linux 内核里了,它以队列为基础数据结构,与进程调度机制紧密结合,可以用来同步对系统资源的访问,第 7 章中所讲述的信号量在内核中也依赖等待队列来实现。

Linux 内核提供了如下关于等待队列的操作:

①定义并初始化"等待队列头部"

等待队列头使用结构体wait_queue_head_t 表示, wait_queue_head_t 结构体定义在文件 include/linux/wait.h 中,结构体内容如下所示:

c 复制代码
struct __wait_queue_head {
	spinlock_t			lock;
	struct list_head	task_list;
};
typedef struct __wait_queue_head wait_queue_head_t;

定义好等待队列头以后需要初始化, 使用 init_waitqueue_head 函数初始化等待队列头,函数原型如下:

c 复制代码
extern void __init_waitqueue_head(wait_queue_head_t *q, const char *name, struct lock_class_key *);

#define init_waitqueue_head(q)					\
	do {										\
		static struct lock_class_key __key;		\
												\
		__init_waitqueue_head((q), #q, &__key);	\
	} while (0)
//参数 q 就是要初始化的等待队列头。

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

c 复制代码
#define DECLARE_WAIT_QUEUE_HEAD(name) \
	wait_queue_head_t name = __WAIT_QUEUE_HEAD_INITIALIZER(name)

#define __WAIT_QUEUE_HEAD_INITIALIZER(name) {				\
	.lock		= __SPIN_LOCK_UNLOCKED(name.lock),			\
	.task_list	= { &(name).task_list, &(name).task_list } }

②定义等待队列元素

等待队列头就是一个等待队列的头部,每个访问设备的进程都是一个队列项 ,当设备不可用的时候就要将这些进程对应的等待队列项添加到等待队列里面。结构体 wait_queue_t 表示等待队列项,结构体内容如下:

c 复制代码
typedef struct __wait_queue wait_queue_t;
struct __wait_queue {
	unsigned int		flags;
	void			*private;
	wait_queue_func_t	func;
	struct list_head	task_list;
};

使用宏 DECLARE_WAITQUEUE(name, tsk) 定义并初始化一个等待队列项,宏的内容如下: name就是等待队列项的名字, tsk 表示这个等待队列项属于哪个任务(进程),一般设置为current , 在 Linux 内 核 中 current 相 当 于 一 个 全 局 变 量 , 表 示 当 前 进 程 。 因 此 宏DECLARE_WAITQUEUE 就是给当前正在运行的进程创建并初始化了一个等待队列项。

c 复制代码
#define DECLARE_WAITQUEUE(name, tsk)					\
	wait_queue_t name = __WAITQUEUE_INITIALIZER(name, tsk)


#define __WAITQUEUE_INITIALIZER(name, tsk) {		\
	.private	= tsk,								\
	.func		= default_wake_function,			\
	.task_list	= { NULL, NULL } }

③添加/移除等待队列

当设备不可访问的时候就需要将进程对应的等待队列项添加到前面创建的等待队列头中,只有添加到等待队列头中以后进程才能进入休眠态。当设备可以访问以后再将进程对应的等待队列项从等待队列头中移除即可 。

c 复制代码
void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);
void remove_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);

add_wait_queue() 用于将等待队列元素 wait 添加到等待队列头部 q 指向的双向链表中,而 remove_wait_queue() 用于将等待队列元素 wait 从由 q 头部指向的链表中移除。下面简单看一下添加过程:

c 复制代码
static inline void __add_wait_queue(wait_queue_head_t *head, wait_queue_t *new)
{
	list_add(&new->task_list, &head->task_list);
}

static inline void list_add(struct list_head *new, struct list_head *head)
{
	__list_add(new, head, head->next);
}

static inline void __list_add(struct list_head *new,
			      struct list_head *prev,
			      struct list_head *next)
{
    //头插法
	next->prev = new;
	new->next = next;
	new->prev = prev;
	WRITE_ONCE(prev->next, new);
}

④唤醒队列

上述操作会唤醒以 queue 作为等待队列头部的队列中所有的进程。(但是现在应该改成上表中的状态了!!!)

  • wake_up() 应该与 wait_event() 或 wait_event_timeout() 成对使用;

  • wake_up_interruptible() 则应与 wait_event_interruptible() 或 wait_event_interruptible_timeout() 成对使用。

  • wake_up() 可唤醒处于 TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE 的进程,而 wake_up_interruptible() 只能唤醒处于 TASK_INTERRUPTIBLE 的进程。

⑤等待事件

除了主动唤醒以外,也可以设置等待队列等待某个事件,当这个事件满足以后就自动唤醒等待队列中的进程,和等待事件有关的 API 函数如下 所示:

等待第 1 个参数 queue 作为等待队列头部的队列被唤醒,而且第 2 个参数 condition 必须满足,否则继续阻塞。wait_event() 和 wait_event_interruptible() 的区别在于后者可以被信号打断,而前者不能。加上 _timeout 后的宏意味着阻塞等待的超时时间,以 jiffies 为单位,在第 3 个参数的 timeout 到达时,不论 condition 是否满足,均返回。

Linux 内核使用全局变量 jiffies 来记录系统从启动以来的系统节拍数,系统启动的时候会将 jiffies 初始化为 0;在编译 Linux 内核的时候可以通过图形化界面设置系统节拍率 。比如 1000Hz, 100Hz 等等 。

⑦在等待队列上睡眠

c 复制代码
sleep_on(wait_queue_head_t *q);
interruptible_sleep_on(wait_queue_head_t *q);

sleep_on() 函数的作用就是将目前进程的状态置成 TASK_UNINTERRUPTIBLE,并定义一个等待队列元素,之后把它挂到等待队列头部 q 指向的双向链表,直到资源可获得,q 队列指向链接的进程被唤醒。

interruptible_sleep_on() 与 sleep_on() 函数类似,其作用是将目前进程的状态置成 TASK_INTERRUPTIBLE,并定义一个等待队列元素,之后把它附属到 q 指向的队列,直到资源可获得(q 指引的等待队列被唤醒)或者进程收到信号。

sleep_on() 函数应该与 wake_up() 成对使用,interruptible_sleep_on() 应该与 wake_up_interruptible() 成对使用。

再设备驱动中使用等待队列--模板:

c 复制代码
static size_t xxx_write(struct file *file, const char *buffer, size_t count, loff_t *ppos)
{
    ...
    DECLARE_WAITQUEUE(wait, current);
     /* 添加元素到等待队列 */
    add_wait_queue(&xxx_wait, &wait); 

    /* 等待设备缓冲区可写 */
    do {
        avail = device_writable(...);
        if (avail < 0) {
            if (file->f_flags & O_NONBLOCK) { /* 非阻塞 */
                ret = -EAGAIN;
                goto out;
            }
            __set_current_state(TASK_INTERRUPTIBLE); /* 改变进程状态 */
            schedule(); /* 调度其他进程执行 */
            if (signal_pending(current)) { /* 如果是因为信号唤醒 */
                ret = -ERESTARTSYS;
                goto out;
            }
        }
    } while (avail < 0);

    /* 写设备缓冲区 */
    device_write(...)

    out:
    remove_wait_queue(&xxx_wait, &wait); /* 将元素移出 xxx_wait 指引的队列 */
    set_current_state(TASK_RUNNING); /* 设置进程状态为 TASK_RUNNING */
    return ret;
}

1)如果是非阻塞访问(O_NONBLOCK 被设置),设备忙时,直接返回"-EAGAIN"。

2)对于阻塞访问,会调用 __set_current_state(TASK_INTERRUPTIBLE) 进行进程状态切

换并显示通过"schedule()"调度其他进程执行。

3)醒来的时候要注意,由于调度出去的时候,进程状态是 TASK_INTERRUPTIBLE,

即浅度睡眠,所以唤醒它的有可能是信号,因此,我们首先通过 signal_pending(current) 了解

是不是信号唤醒的,如果是,立即返回"-ERESTARTSYS"。

DECLARE_WAITQUEUEadd_wait_queue 这两个动作加起来完成的效果如图 8.2 所示。在 wait_queue_head_t 指向的链表上,新定义的 wait_queue 元素被插入,而这个新插入的元素绑定了一个 task_struct(当前做 xxx_write 的 current,这也是DECLARE_WAITQUEUE 使用 "current" 作为参数的原因)。

内核定义了 task_struct 结构体表示一个进程.

2.2 实验示例

2.3 非阻塞--轮询(POLL、Select机制)

如果用户应用程序以非阻塞的方式访问设备,设备驱动程序就要提供非阻塞的处理方式,也就是轮询。 pollepollselect 可以用于处理轮询,应用程序通过 selectepollpoll 函数来查询设备是否可以操作,如果可以操作的话就从设备读取或者向设备写入数据 。当应用程序调用 selectepollpoll 函数的时候设备驱动程序中的 poll 函数就会执行,因此需要在设备驱动程序中编写 poll 函数。

2.3.1 select 函数
c 复制代码
int select(int numfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,struct timeval *timeout);

其中 readfds、writefds、exceptfds 分别是被 select() 监视的读、写和异常处理的文件描述符集合numfds 的值是需要检查的号码最高的 fd 加 1。readfds 文件集中的任何一个文件变得可读,select() 返回;同理,writefds 文件集中的任何一个文件变得可写,select 也返回。

如图 8.3 所示,第一次对 n 个文件进行 select() 的时候,若任何一个文件满足要求,select() 就直接返回;第 2 次再进行 select() 的时候,没有文件满足读写要求,select() 的进程阻塞且睡眠。由于调用 select() 的时候,每个驱动的 poll() 接口都会被调用到,实际上执行select() 的进程被挂到了每个驱动的等待队列上,可以被任何一个驱动唤醒。如果 FD 变得可读写,select() 返回。

imeout:超时时间,当我们调用 select 函数等待某些文件描述符可以设置超时时间,超时时间使用结构体 timeval 表示,结构体定义如下所示:

c 复制代码
struct timeval {
    long tv_sec; /* 秒 */
    long tv_usec; /* 微妙 */
}

当 timeout 为 NULL 的时候就表示无限期的等待。 此时就是编程阻塞IO了!

返回值: 0,表示的话就表示超时发生,但是没有任何文件描述符可以进行操作; -1,发生错误;其他值,可以进行操作的文件描述符个数。

比如我们现在要从一个设备文件中读取数据,那么就可以定义一个 fd_set 变量,这个变量要传递给参数 readfds。当我们定义好一个 fd_set 变量以后可以使用如下所示几个宏进行操作,下列操作用来设置、清除、判断文件描述符集合:

操作 函数原型 描述
清除文件描述符集合 FD_ZERO(fd_set *set) 清除一个文件描述符集合
添加文件描述符 FD_SET(int fd, fd_set *set) 将一个文件描述符加入文件描述符集合中
清除文件描述符 FD_CLR(int fd, fd_set *set) 将一个文件描述符从文件描述符集合中清除
判断文件描述符 FD_ISSET(int fd, fd_set *set) 判断文件描述符是否被置位

使用 select 函数对某个设备驱动文件进行读非阻塞访问的操作示例如下所示:

c 复制代码
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)) { /* 判断是否为 fd 文件描述符 */
            /* 使用 read 函数读取数据 */
            .
            .
            /* 使用 read 函数读取数据 */
        }
        break;
    }
}

引出下面POLL,在单个线程中, select 函数能够监视的文件描述符数量有最大的限制,一般为 1024,可以修改内核将监视的文件描述符数量改大,但是这样会降低效率!

c 复制代码
#undef __FD_SETSIZE
#define __FD_SETSIZE	1024

typedef struct {
	unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;
2.3.2 POLL函数

在单个线程中, select 函数能够监视的文件描述符数量有最大的限制,一般为 1024,可以修改内核将监视的文件描述符数量改大,但是这样会降低效率!这个时候就可以使用 poll 函数, poll 函数本质上和 select 没有太大的差别,但是 poll 函数没有最大文件描述符限制, Linux 应用程序中 poll 函数原型如下所示:

poll() 的功能和实现原理与 select() 相似,其函数原型为:

c 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds: 要监视的文件描述符集合以及要监视的事件,为一个数组 ,数组元素都是结构体 pollfd类型的pollfd 结构体如下所示:
c 复制代码
struct pollfd {
	int fd;			/* 文件描述符 */
	short events;	/* 请求的事件 */
	short revents;	/* 返回的事件 */
};
  • fd 是要监视的文件描述符,如果 fd 无效的话那么 events 监视事件也就无效,并且 revents返回 0。 events 是要监视的事件,可监视的事件类型如下所示:
事件类型 描述
POLLIN 有数据可以读取。
POLLPRI 有紧急的数据需要读取。
POLLOUT 可以写数据。
POLLERR 指定的文件描述符发生错误。
POLLHUP 指定的文件描述符挂起。
POLLNVAL 无效的请求。
POLLRDNORM 等同于 POLLIN。
  • revents 是返回参数,也就是返回的事件, 由 Linux 内核设置具体的返回事件。

  • nfds: poll 函数要监视的文件描述符数量。

  • timeout: 超时时间,单位为 ms。

  • 返回值:返回 revents 域中不为 0 的 pollfd 结构体个数,也就是发生事件或错误的文件描述符数量; 0,超时; -1,发生错误,并且设置 errno 为错误类型。

使用 poll 函数对某个设备驱动文件进行读非阻塞访问的操作示例如下所示:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <poll.h>

void main(void)
{
    int ret;
    int fd1, fd2;
    struct pollfd fds[2]; // 用于存储两个设备文件的轮询信息

    // 打开两个设备文件,设置为非阻塞模式
    fd1 = open("device1", O_RDWR | O_NONBLOCK);
    if (fd1 < 0) {
        perror("open device1 failed");
        exit(EXIT_FAILURE);
    }

    fd2 = open("device2", O_RDWR | O_NONBLOCK);
    if (fd2 < 0) {
        perror("open device2 failed");
        close(fd1); // 关闭已打开的文件描述符
        exit(EXIT_FAILURE);
    }

    // 构造轮询结构体
    fds[0].fd = fd1;
    fds[0].events = POLLIN; // 监视设备1是否可读
    fds[1].fd = fd2;
    fds[1].events = POLLIN; // 监视设备2是否可读

    // 轮询两个设备文件,超时时间设置为1000ms
    ret = poll(fds, 2, 1000);
    if (ret > 0) {
        // 检查设备1是否可读
        if (fds[0].revents & POLLIN) {
            printf("Device1 is readable\n");
            // 在这里可以对设备1进行读操作
            // 示例代码省略读操作部分
        }

        // 检查设备2是否可读
        if (fds[1].revents & POLLIN) {
            printf("Device2 is readable\n");
            // 在这里可以对设备2进行读操作
            // 示例代码省略读操作部分
        }
    } else if (ret == 0) {
        printf("Poll timeout\n");
    } else {
        perror("poll failed");
    }

    // 关闭文件描述符
    close(fd1);
    close(fd2);
}
2.3.3 epoll函数

当多路复用的文件数量庞大、I/O 流量频繁的时候,一般不太适合使用 select() 和 poll(),此种情况下,select() 和 poll() 的性能表现较差,我们宜使用 epollepoll 的最大好处是会随着 fd 的数目增长而降低效率,select() 则会随着 fd 的数量增大性能下降明显。

epoll 相关的用户空间编程接口包括:

c 复制代码
int epoll_create(int size);

epoll 就是为处理大并发而准备的,一般常常在网络编程中使用 epoll 函数。

2.3.4 Linux 驱动下的 poll 操作函数

当应用程序调用 select 或 poll 函数来对驱动程序进行非阻塞访问的时候,驱动程序file_operations 操作集中的 poll 函数就会执行。所以驱动程序的编写者需要提供对应的 poll 函数, poll 函数原型如下所示:

c 复制代码
unsigned int (*poll) (struct file *filp, struct poll_table_struct *wait)  
  • filp: 要打开的设备文件(文件描述符)。
  • wait: 结构体 poll_table_struct 类型指针,轮询表指针 , 由应用程序传递进来的。一般将此参数传递给poll_wait 函数。
  • 返回值:返回设备资源的可获取状态,即 POLLIN、POLLOUT、POLLPRI、POLLERR、POLLNVAL 等宏的位"或"结果。每个宏的含义都表明设备的一种状态,如POLLIN(定义为 0x0001)意味着设备可以无阻塞地读,POLLOUT(定义为 0x0004)意味着设
    备可以无阻塞地写。可以返回的资源状态如下:
事件类型 描述
POLLIN 有数据可以读取。
POLLPRI 有紧急的数据需要读取。
POLLOUT 可以写数据。
POLLERR 指定的文件描述符发生错误。
POLLHUP 指定的文件描述符挂起。
POLLNVAL 无效的请求。
POLLRDNORM 等同于 POLLIN,普通数据可读

这个函数应该进行两项工作:

1)对可能引起设备文件状态变化的等待队列调用 poll_wait() 函数,将对应的等待队列头部添加到 poll_table 中。

2)返回表示是否能对设备进行无阻塞读、写访问的掩码。

这里一定注意我们的视角从前面的应用端APP,转换到了一个具体的设备驱动。


用于向 poll_table 注册等待队列的关键 poll_wait() 函数的原型如下:

c 复制代码
void poll_wait(struct file *filp, wait_queue_head_t *queue, poll_table * wait);

poll_wait() 函数的名称非常容易让人产生误会,以为它和 wait_event() 等一样,会阻塞地等待某事件的发生,其实这个函数并不会引起阻塞。poll_wait 函数不会引起阻塞poll_wait() 函数所做的工作是把当前进程--应用程序添加到 wait 参数指定的等待列表(poll_table)中,实际作用是让唤醒参数 queue 对应的等待队列可以唤醒因 select() 而睡眠的进程。

c 复制代码
struct poll_table_struct;
/* 
 * structures and helpers for f_op->poll implementations
 */
typedef void (*poll_queue_proc)(struct file *, wait_queue_head_t *, struct poll_table_struct *);

/*
 * Do not touch the structure directly, use the access functions
 * poll_does_not_wait() and poll_requested_events() instead.
 */
typedef struct poll_table_struct {
	poll_queue_proc _qproc;
	unsigned long _key;
} poll_table;
//poll_table 结构用于管理 poll 系统调用的等待队列。

这里详细看一下这里的逻辑:

poll_table 是一个用于管理多个设备等待队列的结构。它的主要作用是将多个设备的等待队列头(wait_queue_head_t)集中管理起来,以便在调用 poll 函数时能够高效地处理多个设备的等待队列。

关于 poll_table 的管理机制

  • 当应用程序调用 pollselect 系统调用时,内核会调用设备驱动程序中定义的 poll 函数。unsigned int (*poll)(struct file *filp, struct poll_table_struct *wait);其中,wait 是一个指向 poll_table_struct 的指针,它是一个内核管理的结构,用于保存当前设备的等待队列头。
  • poll 函数中,驱动程序需要使用 poll_wait 宏来将当前设备的等待队列头(wait_queue_head_t)添加到 poll_table 中。
  • poll_table 是动态管理的。当一个设备的等待队列头被添加到 poll_table 后,内核会负责维护这些等待队列头。如果设备有数据可读或可写,内核会唤醒对应的等待队列中的任务。

poll_table 是由内核管理的,它用于集中管理多个设备的等待队列头。

  • 每个设备驱动程序通常会定义自己的等待队列头(wait_queue_head_t)。例如,在一个 LED 驱动中,可能会定义一个等待队列头,用于等待 LED 状态变化的通知。而在一个按键驱动中,也会定义一个等待队列头,用于等待按键事件。
  • 当应用程序调用 poll 函数时,内核会创建一个 poll_table,并将所有相关设备的等待队列头添加到这个 poll_table 中。这样,内核可以统一管理这些等待队列头,而不需要每个驱动程序单独管理。

通过以上分析,可得出设备驱动中poll函数的典型模板:

c 复制代码
static unsigned int xxx_poll(struct file *filp, poll_table *wait)
{
    unsigned int mask = 0;
    struct xxx_dev *dev = filp->private_data; 	/* 获得设备结构体指针 */
    ...
    poll_wait(filp, &dev->r_wait, wait);    	/* 加入读等待队列 */
    poll_wait(filp, &dev->w_wait, wait);    	/* 加入写等待队列 */
	
    if (data_available)
        mask |= POLLIN | POLLRDNORM;        	/* 可读 */
        /* 标示数据可获得(对用户可读)*/

    if (...)
        mask |= POLLOUT | POLLWRNORM;       	/* 可写 */
        /* 标示数据可写入 */

    ...
    return mask;
}

2.4 实验示例

相关推荐
FoldWinCard6 小时前
D5 Linux 网络及端口命令
linux·运维·服务器
零涂毕业设计8 小时前
毕业设计模块开发-OLED显示屏(IIC协议0.96寸)STM32 ESP32 FPGA Linux驱动免费分享
linux·stm32·单片机·嵌入式·esp32·fpga·oled
tedcloud1238 小时前
OmniRoute怎么部署?开源AI模型路由平台Linux部署教程
linux·服务器·人工智能·开源·音视频
霸道流氓气质8 小时前
KMS 密钥管理服务(Key Management Service)原理与实践
linux·服务器·数据库
SakitamaX8 小时前
LVS(Linux Virtual Server)概念详解
linux·运维·lvs
小张同学a.8 小时前
Linux系统管理
linux·运维
阿龙的工作记录9 小时前
深入理解Linux设备驱动模型
linux·嵌入式硬件·工作记录
代码村新手9 小时前
Linux的基本指令
linux·运维·服务器
hkj880810 小时前
Ubuntu 切换java版本
java·linux·ubuntu
宵时待雨11 小时前
优选算法专题9:哈希表
数据结构·算法·散列表