news 2026/10/20 2:05:47

多路转接原理与应用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多路转接原理与应用

目录

一.前言:

1.为什么需要多路转接:

a.传统阻塞 I/O 的并发困境:

b.多路转接解决了什么:

2.事件探视机制的工作模型:

3.注意事项:

二.Linux 核心多路复用 API 使用:

1.Linux 下基于 select 的 TCP 服务:

a.底层结构刨析:

b.核心接口解析:

c.完整代码示例:

d.技术复盘:

2.基于 poll 的 TCP 服务

a.底层结构刨析:

b.核心接口剖析:

c.完整代码示例

d.技术复盘:

3.基于epoll实现tcp服务

a.底层原理:

b.核心接口剖析:

c.完整代码示例:

d.技术复盘:

三.总结:

1.时代意义:

2.工业界现状抉择:

一.前言:

1.为什么需要多路转接:

a.传统阻塞 I/O 的并发困境:

  • 阻塞 I/O会导致线程“死等”数据,高并发时必须开辟海量线程,极易耗尽内存并引发灾难性的上下文切换开销;
  • 非阻塞 I/O依靠死循环不断轮询探查数据状态,会白白榨干 CPU 算力;
  • 信号驱动 I/O机制复杂且信号触发过于频繁,尤其是在基于字节流的 TCP 协议下;
  • 异步 I/O(AIO)看着是解决了上述大部分问题,但开发难度高与跨平台优势低。

b.多路转接解决了什么:

  • 主要解决了原先需要依赖多进程或多线程处理的多 fd 的 I/O情况,变为仅使用一个线程就可以完成,即“用最少的系统资源,去支撑最大量级的并发连接”。
  • 也就是说,多路转接不存在 CPU 空转,不会有多线程开销,线程多次触发等问题,完美兼顾了高并发处理能力与极低的系统资源消耗。

2.事件探视机制的工作模型:

  • 主要分为:fd 注册,事件探视,进程唤醒以及读写四大部分。
  • 在这个模型中,进程不会主动去遍历每个 fd,而是将 fd 交给操作系统内核,此时进程只需要查看注册好的 fd 中是否有事件准备就绪(期间阻塞休眠),一旦有事件发生可以立刻找到对应的 fd,进行数据操作。

3.注意事项:

本文的高度围绕代码,帮助读者学习与使用select、poll、epoll等。

二.Linux 核心多路复用 API 使用:

1.Linux 下基于 select 的 TCP 服务:

a.底层结构刨析:

  • 结构承载:select采用固定大小的位图来承载文件描述符。位图的索引直接对应文件描述符的数值,将第n个 bit 位置 1,即代表指示内核监控第n号文件描述符。
  • 入核与挂载:调用select时,系统会将整个位图从用户空间全量拷贝到内核空间。内核对位图进行第一次线性遍历,将当前文件描述符挂载到等待队列上。若此时没有检测到任何事件就绪,进程会主动让出 CPU,进入休眠阻塞状态。
  • 唤醒与出核:当网卡接收到数据触发硬件中断后,会唤醒该进程。进程醒来后,内核必须进行第二次线性遍历,将位图中未就绪的 bit 清零。最后,将修改后的位图从内核空间覆盖拷贝回用户空间,程序据此判断事件归属。

b.核心接口解析:

fd_set master_set; // 保存所有需要监控的 fd 的结构体 FD_ZERO(&master_set); // 初始化位图 FD_SET(fd, &master_set); //把 _fd 加入位图集合 struct timeval tv; //设置阻塞超时时间 tv.tv_sec = 3; // 秒 tv.tv_usec = 0; //微妙 /* 参数:最大fd序号+1,需要监控读事件的fd,需要监控写事件的fd,需要监控异常事件的fd,超时设置 返回值:小于零出现错误,等于零超时,大于零则是事件就绪的链接个数 */ select(max_fd + 1, nullptr, nullptr, nullptr, &tv); /* 参数:当前遍历到的fd,判断fd在哪个队列就绪 返回值:存在返回1,不存在返回0 */ FD_ISSET(_fd, &read_fds)

c.完整代码示例:

#include <iostream> #include <vector> #include <cstring> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #include <sys/select.h> int main() { //创建 TCP 监听 socket int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; } //允许地址复用 int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &amp;opt, sizeof(opt)); //绑定 IP 与 端口 sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(8080); if (bind(listen_fd, (sockaddr*)&amp;addr, sizeof(addr)) &lt; 0) { perror("bind"); return 1; } //开始监听 if (listen(listen_fd, 5) &lt; 0) { perror("listen"); return 1; } std::cout &lt;&lt; "Server started on port 8080...\n"; //设置select fd_set master_set; // 保存所有需要监控的 fd FD_ZERO(&amp;master_set); // 清空位图 FD_SET(listen_fd, &amp;master_set); // 把 listen_fd 加入集合 int max_fd = listen_fd; // 当前监控的最大 fd 数值 std::vector&lt;int&gt; client_fds; // 保存所有已连接的客户端 fd // 5. 事件循环 while (true) { // select 会修改传入的集合,因此必须每次用临时变量备份 fd_set read_fds = master_set; // 阻塞等待,直到有 socket 变为可读 struct timeval tv; tv.tv_sec = 3; // 秒 tv.tv_usec = 0; int ret = select(max_fd + 1, &amp;read_fds, nullptr, nullptr, &amp;tv); if (ret &lt; 0) { perror("select"); break; } else if(ret == 0) { //超时 std::cout &lt;&lt; "执行自己的任务中..." &lt;&lt; std::endl; sleep(1); } else { //返回值大于零,此时有 socket 变读事件 // 监听 socket 可读 -&gt; 代表有新客户端连接 if (FD_ISSET(listen_fd, &amp;read_fds)) { sockaddr_in client_addr{}; socklen_t len = sizeof(client_addr); int new_fd = accept(listen_fd, (sockaddr*)&amp;client_addr, &amp;len); if (new_fd &gt;= 0) { std::cout &lt;&lt; "新客户端接入, fd = " &lt;&lt; new_fd &lt;&lt; "\n"; FD_SET(new_fd, &amp;master_set); // 加进监控集合 client_fds.push_back(new_fd); // 存起来供后续遍历 if (new_fd &gt; max_fd) max_fd = new_fd; } } //普通客户端 socket for (auto it = client_fds.begin(); it != client_fds.end(); ) { int fd = *it; if (FD_ISSET(fd, &amp;read_fds)) { char buf[1024]; ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0); if (n &gt; 0) { // 收到数据,打印并原样回显 (Echo) buf[n] = '\0'; std::cout &lt;&lt; "收到客户端[" &lt;&lt; fd &lt;&lt; "]: " &lt;&lt; buf; send(fd, buf, n, 0); ++it; } else { // n == 0 表示对端正常关闭连接,n &lt; 0 表示读取出错 std::cout &lt;&lt; "客户端[" &lt;&lt; fd &lt;&lt; "] 断开连接\n"; close(fd); FD_CLR(fd, &amp;master_set); // 从位图中移除 it = client_fds.erase(it); // 从容器中删除 // 如果关掉的是最大的 fd,重新找一次最大值 if (fd == max_fd) { max_fd = listen_fd; for (int c : client_fds) { if (c &gt; max_fd) max_fd = c; } } } } else { ++it; } // } // } // } close(listen_fd); return 0; }

d.技术复盘:

  • 由于底层采用定长的unsigned long数组实现位图,导致承载文件描述符数量存在上限。
  • 由于每次调用都会破坏传入参数,把没有事件就绪的 bit 直接清零,需要重新对整个位图进行赋值和恢复,导致用户态维护成本高。
  • 由于每次调用的双向全量拷贝,导致内存开销巨大。
  • 无论内核维护文件描述符,还是用户判断哪个文件描述符事件就绪,都需要线性遍历文件描述符,导致海量并发下 CPU 性能急剧衰退。
  • 跨平台兼容性极佳,几乎所有操作系统均原生支持。
  • 超时时间可以设置到微秒级别,精度较高。
  • 支持单独设置异常监控,在BMC等领域效果出众。

2.基于 poll 的 TCP 服务

a.底层结构刨析:

  • 用户层注册:采用pollfd结构数组记录文件描述符以及对应监听事件。
  • 入核与挂载:通过系统调用poll进入内核以后,拷贝pollfd,创建poll_list跟踪监控文件描述符,为其注册唤醒回调,并首次遍历判断事件状态。
  • 唤醒与出核:线程被唤醒后,遍历监控 fd,发现存在事件准备就绪后,将更新好的pollfd拷贝回用户空间,遍历其并进行业务操作。

b.核心接口剖析:

//poll 文件描述符维护结构 struct pollfd fds[MAX_CLIENTS]; //初始化与添加fd for (int i = 0; i < MAX_CLIENTS; ++i) { fds[i].fd = -1; fds[i].events = 0; } fds[0].fd = listen_fd; fds[0].events = POLLIN; //参数:文件描述符数组,监控下标0到nfds之间的数据,超时设置0不阻塞,-1永远等待,大于0则是等待事件,单位ms int ret = poll(fds, nfds, -1);

c.完整代码示例

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <poll.h> #include <errno.h> #define MAX_CLIENTS 1024 // 最多同时连接的客户端数量 #define BUFFER_SIZE 4096 // 读写缓冲区大小 int main() { int listen_fd, new_fd; // 监听套接字和新连接套接字 struct sockaddr_in server_addr, client_addr; socklen_t addr_len = sizeof(client_addr); char buffer[BUFFER_SIZE]; ssize_t nread; /* 创建监听套接字 */ listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd == -1) { perror("socket"); exit(EXIT_FAILURE); } /* 设置地址重用,避免 "Address already in use" */ int optval = 1; if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &amp;optval, sizeof(optval)) == -1) { perror("setsockopt"); close(listen_fd); exit(EXIT_FAILURE); } /*绑定地址 */ memset(&amp;server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网络接口 server_addr.sin_port = htons(8888); if (bind(listen_fd, (struct sockaddr*)&amp;server_addr, sizeof(server_addr)) == -1) { perror("bind"); close(listen_fd); exit(EXIT_FAILURE); } /* 开始监听 */ if (listen(listen_fd, 5) == -1) { // 5 是未完成连接队列的大小 perror("listen"); close(listen_fd); exit(EXIT_FAILURE); } printf("Echo server listening on port %d\n", 8888); /* 准备 poll 所需的数据结构 */ struct pollfd fds[MAX_CLIENTS]; // poll 监控的文件描述符数组 int nfds = 1; // 当前有效的 fd 数量(初始只有监听套接字) // 初始化所有槽位,fd 设为 -1 表示空闲 for (int i = 0; i &lt; MAX_CLIENTS; ++i) { fds[i].fd = -1; fds[i].events = 0; } // 将监听套接字放入第一个位置 fds[0].fd = listen_fd; fds[0].events = POLLIN; // 关心可读事件(有新连接到来) /* 主循环:不断调用 poll 处理事件 */ while (1) { int ret = poll(fds, nfds, -1); // -1 表示无限等待 if (ret == -1) { perror("poll"); break; } /* 检查监听套接字是否有新连接 */ if (fds[0].revents &amp; POLLIN) { new_fd = accept(listen_fd, (struct sockaddr*)&amp;client_addr, &amp;addr_len); if (new_fd == -1) { perror("accept"); continue; } printf("New connection from %s:%d (fd=%d)\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), new_fd); // 找一个空闲槽位存放新客户端的 fd int slot = -1; for (int i = 1; i &lt; MAX_CLIENTS; ++i) { // 从下标 1 开始找 if (fds[i].fd == -1) { slot = i; break; } } if (slot == -1) { fprintf(stderr, "Too many clients, rejecting new connection.\n"); close(new_fd); } else { fds[slot].fd = new_fd; fds[slot].events = POLLIN; // 只关心可读事件 if (slot &gt;= nfds) { nfds = slot + 1; // 更新有效 fd 上限 } } } /* 遍历其余 fd,处理客户端数据 */ for (int i = 1; i &lt; nfds; ++i) { if (fds[i].fd == -1) continue; // 跳过空槽位 if (fds[i].revents &amp; POLLIN) { // 有数据可读 nread = read(fds[i].fd, buffer, sizeof(buffer) - 1); if (nread &gt; 0) { buffer[nread] = '\0'; // 方便调试打印 printf("Received %zd bytes from fd %d: %s", nread, fds[i].fd, buffer); // Echo 回显:把收到的数据原样写回 write(fds[i].fd, buffer, nread); } else if (nread == 0) { // 客户端关闭连接 printf("Client fd %d disconnected.\n", fds[i].fd); close(fds[i].fd); fds[i].fd = -1; // 标记槽位为空 // 可选:调整 nfds 以缩小 poll 扫描范围(非必须) // 如果当前 i 正好是 nfds-1,则向前收缩 nfds while (nfds &gt; 1 &amp;&amp; fds[nfds-1].fd == -1) { --nfds; } } else { // 读取错误 perror("read"); close(fds[i].fd); fds[i].fd = -1; while (nfds &gt; 1 &amp;&amp; fds[nfds-1].fd == -1) { --nfds; } } } // 如果发生错误或挂断,也关闭连接 if (fds[i].revents &amp; (POLLERR | POLLHUP | POLLNVAL)) { printf("Error/Hangup on fd %d, closing.\n", fds[i].fd); close(fds[i].fd); fds[i].fd = -1; while (nfds &gt; 1 &amp;&amp; fds[nfds-1].fd == -1) { --nfds; } } } } /* 清理:关闭所有客户端连接和监听套接字 */ for (int i = 0; i &lt; nfds; ++i) { if (fds[i].fd != -1) { close(fds[i].fd); } } close(listen_fd); return 0; }

d.技术复盘:

  • 可独立设置管理上限,但需要手动维护数组,系统底层采用线性扫描的方式,运行时间复杂度高(On)。
  • 支持多种事件,如对端关闭、挂起等,但不支持边缘触发。
  • 每次处理完事件后,不需要重新设置poll_fd,但仍然要线性遍历所有文件描述符。
  • POSIX标准内容之一,可移植性高。

3.基于epoll实现tcp服务

a.底层原理:

相比于poll与select,epoll主要在描述符管理方式和事件通知机制上进行了根本性优化(epoll并非poll的简单增强,而是一种全新的 I/O 多路复用模型)。

  • epoll 在内核中使用红黑树存储所有注册的文件描述符及其关注事件,使得增、删、改、查操作的时间复杂度均为O(log n)。
  • 每个注册的 fd 注册了一个回调函数。当 fd 上发生感兴趣的事件时(如数据到达),内核驱动会调用该回调函数,将对应的节点直接添加到内核维护的就绪链表中,该操作的时间复杂度为O(1)。
  • epoll_wait调用时,只需检查就绪链表是否为空。若非空,则将链表中的事件批量拷贝到用户空间的events数组,并清空链表(水平触发模式下保留就绪状态)。

b.核心接口剖析:

//创建epoll实例: //注:epoll_create1是Linux 2.6.27后代替epoll_create的新接口,不是写错了 // 参数:0 表示无特殊标志;EPOLL_CLOEXEC 表示执行 exec 时自动关闭该 fd,防止泄漏 epfd = epoll_create1(0); //设置监听属性 struct epoll_event { uint32_t events; /* 监听的事件 */ epoll_data_t data; /* 用户数据联合体 */ } __EPOLL_PACKED; //采用紧凑对齐方式编译 //控制epollfd接口 //参数:epoll文件描述符,操作类型,目标文件描述符,监听设置类型 epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); //参数:epoll文件描述符,用于接收就绪事件的 events 数组,队列最大值,等待事件(-1为阻塞等待) //返回值:event输出的有效数量 nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);

c.完整代码示例:

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <sys/epoll.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <errno.h> #define PORT 8888 // 监听端口 #define MAX_EVENTS 1024 // epoll_wait 一次最多返回的事件数 #define BUFFER_SIZE 4096 // 读写缓冲区大小 // 设置非阻塞模式 static int set_nonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); if (flags == -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd, epfd, nfds, i; struct sockaddr_in server_addr, client_addr; socklen_t addr_len = sizeof(client_addr); char buffer[BUFFER_SIZE]; /* 创建监听套接字 */ listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd == -1) { perror("socket"); exit(EXIT_FAILURE); } /* 设置地址重用 */ int optval = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)); /* 3. 绑定地址 */ memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(PORT); if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) { perror("bind"); close(listen_fd); exit(EXIT_FAILURE); } /* 开始监听 */ if (listen(listen_fd, 5) == -1) { perror("listen"); close(listen_fd); exit(EXIT_FAILURE); } /* 将监听套接字设为非阻塞(推荐,但不是必须) */ if (set_nonblocking(listen_fd) == -1) { perror("set_nonblocking"); close(listen_fd); exit(EXIT_FAILURE); } /* 创建 epoll 实例 */ epfd = epoll_create1(0); // 参数 0 等同于 epoll_create(1),但更现代 if (epfd == -1) { perror("epoll_create1"); close(listen_fd); exit(EXIT_FAILURE); } /* 将监听套接字加入 epoll,关注可读事件(新连接) */ struct epoll_event ev; ev.events = EPOLLIN; // 水平触发模式(默认) ev.data.fd = listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) { perror("epoll_ctl: listen_fd"); close(listen_fd); close(epfd); exit(EXIT_FAILURE); } printf("Epoll echo server started on port %d\n", PORT); /* 事件循环 */ struct epoll_event events[MAX_EVENTS]; // 用于接收就绪事件 while (1) { nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); // -1 表示无限等待 if (nfds == -1) { perror("epoll_wait"); break; } for (i = 0; i < nfds; ++i) { int fd = events[i].data.fd; /* 处理新连接 */ if (fd == listen_fd) { int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len); if (conn_fd == -1) { perror("accept"); continue; } printf("New connection from %s:%d (fd=%d)\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), conn_fd); // 将新连接设为非阻塞(重要:epoll 配合非阻塞更高效) if (set_nonblocking(conn_fd) == -1) { perror("set_nonblocking"); close(conn_fd); continue; } // 将新连接加入 epoll,关注可读事件 ev.events = EPOLLIN | EPOLLRDHUP; // EPOLLRDHUP 可检测对端关闭 ev.data.fd = conn_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev) == -1) { perror("epoll_ctl: add client"); close(conn_fd); } } /* 处理客户端数据或关闭 */ else { /* 检查是否发生错误或挂起 */ if (events[i].events & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) { printf("Client fd %d closed or error.\n", fd); close(fd); continue; } /* 可读事件 */ if (events[i].events & EPOLLIN) { ssize_t nread = read(fd, buffer, sizeof(buffer) - 1); if (nread > 0) { buffer[nread] = '\0'; printf("Received %zd bytes from fd %d: %s", nread, fd, buffer); // Echo 回显 write(fd, buffer, nread); } else if (nread == 0) { // 客户端关闭(read 返回 0) printf("Client fd %d disconnected.\n", fd); close(fd); } else { // 读错误 if (errno != EAGAIN) { // EAGAIN 是非阻塞的正常情况,忽略 perror("read"); close(fd); } } } } } } /* 清理 */ close(listen_fd); close(epfd); return 0; }

d.技术复盘:

  • epoll 的触发、添加速度极快,监控上限只受内存影响,但不支持普通文件描述符的读写。
  • 专用接口与 epoll_fd 管理,但维护与编写相对复杂。
  • 支持多种事件类型设置,但跨平台能力差。

三.总结:

1.时代意义:

select:传统阻塞 I/O 模型(每连接一线程)在并发量上升时,线程开销巨大,导致性能低下、资源浪费。select引入了多路复用思想,允许单线程同时监控多个文件描述符,初步解决了并发瓶颈。

poll:解决了 select 文件描述符数量存在明显上限,每次调用需要重新设置 fd 相关配置,返回后仍需要遍历所有文件描述符的问题。

epoll:为解决 select 与 poll 线性遍历所有监控的 fd,时间复杂度高,返回后仍需要全量扫描文件描述符等问题,做到了真正高并发高性能服务。

2.工业界现状抉择:

select: 通常用在连接数量少,不需要高并发性能,需要跨平台的场景。

poll:通常用在连接数量较多的并发,且限于Linux等系统下。

epoll:追求高并发高性能,且需要边缘触发或长连接的情况下。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 2:05:38

步进电机精度与细分控制:失步、共振、发热与选型调试全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 2:05:27

私域商城小程序平台哪个好?2026年7家综合横评

微信日活跃用户已经站上9亿量级&#xff0c;商家把客户从平台电商往自己小程序里沉淀&#xff0c;成了这两年最确定的经营动作。但"私域商城小程序平台哪个好"这个问题&#xff0c;后台每个月都能收到几十条。2026年10月&#xff0c;我把右以云、立亭云、杰建云、维双…

作者头像 李华
网站建设 2026/10/20 2:05:16

C# WPF图书管理系统源码:MVVM+EF Core+SQLite实战解析

1. 项目概述与整体设计思路1.1 这个系统到底解决了什么问题平时在.NET圈子里经常能看到图书管理系统这类题目&#xff0c;但多数示例代码只做了个壳子&#xff1a;能增删改查就算完事。真正拿去用的时候会发现&#xff0c;图书在架状态没人管&#xff0c;借书还书记录对不上&am…

作者头像 李华
网站建设 2026/10/20 2:05:08

金罡珠放进 ABAP 世界,守住业务数据需要哪些防线

一张销售订单正在被总部的业务人员修改,海外子公司的同事也打开了同一张订单。两边的界面都显示原来的交货日期,各自却准备写入不同的新日期。此时我们关心的不是程序能否执行 UPDATE,而是谁有权修改、并发修改会不会互相覆盖、错误数据能否在保存前被拦住。这种场景很适合借…

作者头像 李华
网站建设 2026/10/20 2:02:32

别再忍受千篇一律的开机画面:Ventoy主题定制完整指南

别再忍受千篇一律的开机画面&#xff1a;Ventoy主题定制完整指南 【免费下载链接】Ventoy A new bootable USB solution. 项目地址: https://gitcode.com/GitHub_Trending/ve/Ventoy 每次插上U盘开机&#xff0c;看到的都是同一张蓝底白字的GRUB菜单&#xff1f;其实Ven…

作者头像 李华