当前位置: 首页 > article >正文

网络-内核是如何与用户进程交互

1、socket的直接创建

在这里插入图片描述

net/socket.c

SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
	...
	retval = sock_create(family, type, protocol, &sock);
	...
}

int sock_create(int family, int type, int protocol, struct socket **res)
{
	return __sock_create(current->nsproxy->net_ns, family, type, protocol, res, 0);
}

int __sock_create(struct net *net, int family, int type, int protocol,
			 struct socket **res, int kern)
{
	int err;
	struct socket *sock;
	const struct net_proto_family *pf;
	...
	//分配socket对象
	sock = sock_alloc();
	...
	//获取对应协议簇的操作表
	pf = rcu_dereference(net_families[family]);
	...
	//调用协议簇的创建函数,对于AF_INET对应的是inet_create,这个函数在inet_init函数中被初始化
	err = pf->create(net, sock, protocol, kern);
	...
}
EXPORT_SYMBOL(__sock_create);

socket在内核中是怎么创建的?
sock_create->__sock_create->inet_create
在__sock_create里首先调用sock_alloc来分配一个struct sock内核对象,接着获取协议簇的操作函数表,调用其create方法。对于AF_INET协议簇来说,执行到的是inet_create函数。

static int inet_create(struct net *net, struct socket *sock, int protocol,
                       int kern)
{
	struct sock *sk;            // 指向 sock 结构体,表示套接字
	struct inet_protosw *answer; // 指向 inet_protosw 结构体,表示协议开关
	struct inet_sock *inet;     // 指向 inet_sock 结构体,表示 IPv4 套接字
	struct proto *answer_prot;  // 指向 proto 结构体,表示协议操作
	unsigned char answer_flags;  // 协议开关标志
	int try_loading_module = 0;  // 尝试加载模块的次数
	int err;                    // 用于存储函数返回值

	/*
	 * 检查协议号是否在有效范围内。
	 * 如果不在,返回 -EINVAL 错误。
	 */
	if (protocol < 0 || protocol >= IPPROTO_MAX)
		return -EINVAL;

	/*
	 * 初始化套接字状态为未连接。
	 */
	sock->state = SS_UNCONNECTED;

	/*
	 * 查找请求的类型/协议对。
	 * 首先尝试在 RCU 读锁保护下查找协议。
	 */
lookup_protocol:
	err = -ESOCKTNOSUPPORT; // 设置错误为“不支持的套接字类型”
	rcu_read_lock();        // 锁定 RCU 读锁

	/*
	 * 遍历协议开关列表,查找匹配的协议。
	 * 如果找到匹配的协议,跳出循环。
	 */
	list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {
		if (protocol == answer->protocol) {
			if (protocol != IPPROTO_IP)
				break;
		} else {
			if (IPPROTO_IP == protocol) {
				protocol = answer->protocol;
				break;
			}
			if (IPPROTO_IP == answer->protocol)
				break;
		}
		err = -EPROTONOSUPPORT; // 设置错误为“不支持的协议”
	}

	/*
	 * 如果没有找到匹配的协议,尝试加载相应的模块。
	 * 如果模块加载后,再次尝试查找协议。
	 */
	if (unlikely(err)) {
		if (try_loading_module < 2) {
			rcu_read_unlock(); // 解锁 RCU 读锁
			if (++try_loading_module == 1)
				request_module("net-pf-%d-proto-%d-type-%d",
					       PF_INET, protocol, sock->type);
			else
				request_module("net-pf-%d-proto-%d",
					       PF_INET, protocol);
			goto lookup_protocol; // 重新查找协议
		} else {
			goto out_rcu_unlock; // 如果模块加载失败,跳转到错误处理
		}
	}

	/*
	 * 检查是否允许非内核进程创建原始套接字。
	 * 如果不允许,返回 -EPERM 错误。
	 */
	err = -EPERM;
	if (sock->type == SOCK_RAW && !kern &&
	    !ns_capable(net->user_ns, CAP_NET_RAW))
		goto out_rcu_unlock;

	/*
	 * 设置套接字的操作函数和协议。
	 */
	sock->ops = answer->ops;
	//获得tcp_port
	answer_prot = answer->prot;
	answer_flags = answer->flags;
	rcu_read_unlock(); // 解锁 RCU 读锁

	/*
	 * 分配套接字内存。
	 * 如果分配失败,返回 -ENOBUFS 错误。
	 */
	err = -ENOBUFS;
	sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);
	if (!sk)
		goto out;

	/*
	 * 初始化套接字。
	 */
	err = 0;
	if (INET_PROTOSW_REUSE & answer_flags)
		sk->sk_reuse = SK_CAN_REUSE; // 设置套接字重用标志

	inet = inet_sk(sk); // 获取 IPv4 套接字结构体
	inet->is_icsk = (INET_PROTOSW_ICSK & answer_flags) != 0; // 设置是否为控制套接字

	// 初始化其他 IPv4 特定字段
	inet->nodefrag = 0;
	if (SOCK_RAW == sock->type) {
		inet->inet_num = protocol;
		if (IPPROTO_RAW == protocol)
			inet->hdrincl = 1;
	}
	if (net->ipv4.sysctl_ip_no_pmtu_disc)
		inet->pmtudisc = IP_PMTUDISC_DONT;
	else
		inet->pmtudisc = IP_PMTUDISC_WANT;
	inet->inet_id = 0;

	// 关联套接字和协议
	sock_init_data(sock, sk);

	sk->sk_destruct	   = inet_sock_destruct;
	sk->sk_protocol	   = protocol;
	sk->sk_backlog_rcv = sk->sk_prot->backlog_rcv;

	inet->uc_ttl	= -1;
	inet->mc_loop	= 1;
	inet->mc_ttl	= 1;
	inet->mc_all	= 1;
	inet->mc_index	= 0;
	inet->mc_list	= NULL;
	inet->rcv_tos	= 0;

	sk_refcnt_debug_inc(sk); // 增加套接字引用计数

	// 如果设置了特定协议号,添加到协议哈希链
	if (inet->inet_num) {
		err = sk->sk_prot->hash(sk);
		if (err) {
			sk_common_release(sk);
			goto out;
		}
	}

	// 如果协议需要,调用协议的初始化函数
	if (sk->sk_prot->init) {
		err = sk->sk_prot->init(sk);
		if (err) {
			sk_common_release(sk);
			goto out;
		}
	}

	// 对于非内核进程,运行 BPF cgroup 套接字挂钩程序
	if (!kern) {
		err = BPF_CGROUP_RUN_PROG_INET_SOCK(sk);
		if (err) {
			sk_common_release(sk);
			goto out;
		}
	}
out:
	return err; // 返回结果
out_rcu_unlock:
	rcu_read_unlock(); // 解锁 RCU 读锁
	goto out; // 跳转到函数出口
}

在inet_create中根据类型SOCK_STREAM查找到对于TCP定义的操作方法实现集合inet_stream_ops和tcp_port,并把它们分别设置到socket->ops和sock->sk_port上。
在这里插入图片描述

void sock_init_data(struct socket *sock, struct sock *sk)
{
	/* 初始化套接字通用字段 */
	sk_init_common(sk);
	...
	/* 设置套接字的接收和发送缓冲区大小 */
	sk->sk_rcvbuf = sysctl_rmem_default; // 接收缓冲区默认大小
	sk->sk_sndbuf = sysctl_wmem_default; // 发送缓冲区默认大小
	...
	/* 设置套接字状态为 TCP_CLOSE(关闭状态) */
	sk->sk_state = TCP_CLOSE;

	/* 将套接字与 socket 结构体关联 */
	sk_set_socket(sk, sock);
	/* 设置套接字的回调函数 */
	//当套接字的状态发生变化时,这个回调函数被调用。例如,套接字从监听状态变为已连接状态,或者从已连接状态变为关闭状态。
	sk->sk_state_change = sock_def_wakeup;
	//当套接字接收队列中有数据可读时,这个回调函数被调用。它通知套接字数据已经准备好,可以被用户空间读取。
	sk->sk_data_ready = sock_def_readable;
	//当套接字的发送队列有足够的空间来接受新的数据时,这个回调函数被调用。它通知套接字发送缓冲区不再满,可以发送更多数据。
	sk->sk_write_space = sock_def_write_space;
	//当套接字遇到错误时,这个回调函数被调用。它负责向用户空间报告错误,例如连接重置、数据传输错误等。
	sk->sk_error_report = sock_def_error_report;
	//当套接字被销毁时,这个回调函数被调用。它负责执行清理操作,释放套接字占用的资源。
	sk->sk_destruct = sock_def_destruct;

	/* 设置套接字的接收和发送超时时间 */
	sk->sk_rcvtimeo = MAX_SCHEDULE_TIMEOUT;
	sk->sk_sndtimeo = MAX_SCHEDULE_TIMEOUT;
	...
	/* 设置套接字的时间戳 */
	sk->sk_stamp = SK_DEFAULT_STAMP;

}
EXPORT_SYMBOL(sock_init_data);

2、内核IO和用户进程协作之阻塞方式

同步阻塞IO总体流程如下:
在这里插入图片描述

2.1 等待接收消息

recv函数通过strace命令跟踪,可以看到recv会执行recvform调用。
在这里插入图片描述
下面从源代码看看recvfrom是怎么把自己阻塞掉的。

SYSCALL_DEFINE6(recvfrom, int, fd, void __user *, ubuf, size_t, size,
		unsigned int, flags, struct sockaddr __user *, addr,
		int __user *, addr_len)
{
	...
	err = import_single_range(READ, ubuf, size, &iov, &msg.msg_iter);
	if (unlikely(err))
		return err;
	
	// 根据用户传入的fd找到socket对象
	sock = sockfd_lookup_light(fd, &err, &fput_needed);
	...
	err = sock_recvmsg(sock, &msg, flags);
	...
}

接下来调用顺序为:
sock_recvmsg==>sock_recvmsg_nosec==>inet_recvmsg==>tcp_recvmsg

int sock_recvmsg(struct socket *sock, struct msghdr *msg, int flags)
{
	int err = security_socket_recvmsg(sock, msg, msg_data_left(msg), flags);

	return err ?: sock_recvmsg_nosec(sock, msg, flags);
}

static inline int sock_recvmsg_nosec(struct socket *sock, struct msghdr *msg,
				     int flags)
{
	return sock->ops->recvmsg(sock, msg, msg_data_left(msg), flags);
}

int inet_recvmsg(struct socket *sock, struct msghdr *msg, size_t size,
		 int flags)
{
	struct sock *sk = sock->sk;
	int addr_len = 0;
	int err;

	sock_rps_record_flow(sk);

	err = sk->sk_prot->recvmsg(sk, msg, size, flags & MSG_DONTWAIT,
				   flags & ~MSG_DONTWAIT, &addr_len);
	if (err >= 0)
		msg->msg_namelen = addr_len;
	return err;
}

int tcp_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int nonblock,
		int flags, int *addr_len)
{
	...
	do {
		...
		//遍历接收队列接收数据
		skb_queue_walk(&sk->sk_receive_queue, skb) {
			...
		//数据接收完成则返回
		if (copied >= target && !sk->sk_backlog.tail)
			break;

		if (copied >= target) {
			/* Do not sleep, just process backlog. */
			release_sock(sk);
			lock_sock(sk);
		} else {
			//没有收到足够的数据,启用sk_wait_data阻塞当前进程,等待数据到来的通知
			sk_wait_data(sk, &timeo, last);
		}
		...
	} while (len > 0);

}
EXPORT_SYMBOL(tcp_recvmsg);

这里可以看到,skb_queue_Walk在访问sock对象下的接收队列,如果没有收到数据或者收到的不够多,那么调用sk_wait_data将当前进程阻塞。
在这里插入图片描述

int sk_wait_data(struct sock *sk, long *timeo, const struct sk_buff *skb)
{
	//当前进程关联到所定义的等待队列项上,并设置唤醒回调函数
	DEFINE_WAIT_FUNC(wait, woken_wake_function);
	int rc;
	//添加等待队列项到sock的等待队列中
	add_wait_queue(sk_sleep(sk), &wait);
	sk_set_bit(SOCKWQ_ASYNC_WAITDATA, sk);
	//通过调用wait_woken让出CPU,然后进入睡眠
	rc = sk_wait_event(sk, timeo, skb_peek_tail(&sk->sk_receive_queue) != skb, &wait);
	sk_clear_bit(SOCKWQ_ASYNC_WAITDATA, sk);
	remove_wait_queue(sk_sleep(sk), &wait);
	return rc;
}

#define sk_wait_event(__sk, __timeo, __condition, __wait)		\
	({	int __rc;						\
		release_sock(__sk);					\
		__rc = __condition;					\
		if (!__rc) {						\
			*(__timeo) = wait_woken(__wait,			\
						TASK_INTERRUPTIBLE,	\
						*(__timeo));		\
		}							\
		sched_annotate_sleep();					\
		lock_sock(__sk);					\
		__rc = __condition;					\
		__rc;							\
	})

在这里插入图片描述
当内核收完数据产生就绪事件的时候,会通过回调查找socket等待队列项,进而可以找到回调函数和在等待该socket就绪事件的进程。

2.2 软中断模块

网络包从网卡后怎么接收再交给软中断处理的,这篇文章有描述:添加链接描述
下面从tcp_v4_rcv的源码开始看,总体接收流程如下:
在这里插入图片描述
软中断里收到数据之后,发现是TCP包就会执行tcp_v4_rcv函数。如果是ESTABLISH状态下的数据包,则最终会把数据解析出来放到对应socket的接收队列中,然后调用sk_data_ready来唤醒用户进程。

int tcp_v4_rcv(struct sk_buff *skb)
{
	...
	th = (const struct tcphdr *)skb->data;	//获取tcp header
	iph = ip_hdr(skb);	//获取ip header
	...
lookup:
	//根据数据包header中的IP、端口信息查找到对应的socket
	sk = __inet_lookup_skb(&tcp_hashinfo, skb, __tcp_hdrlen(th), th->source,
			       th->dest, &refcounted);	
	if (!sk)
		goto no_tcp_socket;

process:
	if (sk->sk_state == TCP_TIME_WAIT)
		goto do_time_wait;

	if (sk->sk_state == TCP_NEW_SYN_RECV) {
		struct request_sock *req = inet_reqsk(sk);
		struct sock *nsk;

		sk = req->rsk_listener;
		if (unlikely(tcp_v4_inbound_md5_hash(sk, skb))) {
			sk_drops_add(sk, skb);
			reqsk_put(req);
			goto discard_it;
		}
		if (unlikely(sk->sk_state != TCP_LISTEN)) {
			inet_csk_reqsk_queue_drop_and_put(sk, req);
			goto lookup;
		}
		/* We own a reference on the listener, increase it again
		 * as we might lose it too soon.
		 */
		sock_hold(sk);
		refcounted = true;
		nsk = tcp_check_req(sk, skb, req, false);
		if (!nsk) {
			reqsk_put(req);
			goto discard_and_relse;
		}
		if (nsk == sk) {
			reqsk_put(req);
		} else if (tcp_child_process(sk, nsk, skb)) {
			tcp_v4_send_reset(nsk, skb);
			goto discard_and_relse;
		} else {
			sock_put(sk);
			return 0;
		}
	}
	if (unlikely(iph->ttl < inet_sk(sk)->min_ttl)) {
		__NET_INC_STATS(net, LINUX_MIB_TCPMINTTLDROP);
		goto discard_and_relse;
	}

	if (!xfrm4_policy_check(sk, XFRM_POLICY_IN, skb))
		goto discard_and_relse;

	if (tcp_v4_inbound_md5_hash(sk, skb))
		goto discard_and_relse;

	nf_reset(skb);

	if (tcp_filter(sk, skb))
		goto discard_and_relse;
	th = (const struct tcphdr *)skb->data;
	iph = ip_hdr(skb);

	skb->dev = NULL;

	if (sk->sk_state == TCP_LISTEN) {
		ret = tcp_v4_do_rcv(sk, skb);
		goto put_and_return;
	}
	//socket未被用户锁定
	if (!sock_owned_by_user(sk)) {
		if (!tcp_prequeue(sk, skb))
			ret = tcp_v4_do_rcv(sk, skb);
	} else if (tcp_add_backlog(sk, skb)) {
		goto discard_and_relse;
	}
}

在tcp_v4_do_rcv中,首先找到对应skb对应的socket,然后进入tcp_v4_do_rcv。

int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
	struct sock *rsk;

	if (sk->sk_state == TCP_ESTABLISHED) { /* Fast path */
		struct dst_entry *dst = sk->sk_rx_dst;

		sock_rps_save_rxhash(sk, skb);
		sk_mark_napi_id(sk, skb);
		if (dst) {
			if (inet_sk(sk)->rx_dst_ifindex != skb->skb_iif ||
			    !dst->ops->check(dst, 0)) {
				dst_release(dst);
				sk->sk_rx_dst = NULL;
			}
		}
		//执行连接状态下的数据处理
		tcp_rcv_established(sk, skb, tcp_hdr(skb), skb->len);
		return 0;
	}
	//其他非ESTABLISH状态的数据包处理
	...
}

在tcp_v4_do_rcv中会调用tcp_rcv_established,在tcp_rcv_established中会调用tcp_queue_rcv把数据添加到接收队列末尾,然后调用sock的sk_data_ready函数指针,这个指针在sock初始化的时候已经被设置成了sock_def_readable,所以会调用到sock_def_readable。

void tcp_rcv_established(struct sock *sk, struct sk_buff *skb,
			 const struct tcphdr *th, unsigned int len)
{
	struct tcp_sock *tp = tcp_sk(sk); // 获取 TCP 套接字的特定结构体
	skb_mstamp_get(&tp->tcp_mstamp); // 获取当前时间戳
	if (unlikely(!sk->sk_rx_dst)) // 检查接收目的地是否已设置
		inet_csk(sk)->icsk_af_ops->sk_rx_dst_set(sk, skb); // 设置接收目的地

	/*
	 * 头部预测。
	 * 代码大致遵循 Van Jacobson 的 "30 instruction TCP receive"。
	 */
	tp->rx_opt.saw_tstamp = 0; // 初始化接收选项

	/* 检查 TCP 头部的有效性 */
	if ((tcp_flag_word(th) & TCP_HP_BITS) == tp->pred_flags &&
	    TCP_SKB_CB(skb)->seq == tp->rcv_nxt &&
	    !after(TCP_SKB_CB(skb)->ack_seq, tp->snd_nxt)) {
		int tcp_header_len = tp->tcp_header_len;
		if (len <= tcp_header_len) {
			...
			if (!eaten) {
				...
				/* 大量数据传输:接收方 */
				eaten = tcp_queue_rcv(sk, skb, tcp_header_len, &fragstolen); // 将数据包排入接收队列
			}

			tcp_event_data_recv(sk, skb); // 触发数据接收事件

			if (TCP_SKB_CB(skb)->ack_seq != tp->snd_una) {
				/* 处理 ACK */
				tcp_ack(sk, skb, FLAG_DATA);
				tcp_data_snd_check(sk); // 检查发送数据
				if (!inet_csk_ack_scheduled(sk))
					goto no_ack;
			}

			__tcp_ack_snd_check(sk, 0); // 检查 ACK 发送
no_ack:
			if (eaten)
				kfree_skb_partial(skb, fragstolen); // 释放部分数据包
			sk->sk_data_ready(sk); // 通知套接字有数据可读
			return;
		}
	}
	...
}
EXPORT_SYMBOL(tcp_rcv_established);

在这里插入图片描述

static int __must_check tcp_queue_rcv(struct sock *sk, struct sk_buff *skb, int hdrlen,
		  bool *fragstolen)
{
	int eaten;
	struct sk_buff *tail = skb_peek_tail(&sk->sk_receive_queue);

	__skb_pull(skb, hdrlen);
	eaten = (tail &&
		 tcp_try_coalesce(sk, tail, skb, fragstolen)) ? 1 : 0;
	tcp_rcv_nxt_update(tcp_sk(sk), TCP_SKB_CB(skb)->end_seq);
	//将接收到的数据添加到尾部
	if (!eaten) {
		__skb_queue_tail(&sk->sk_receive_queue, skb);
		skb_set_owner_r(skb, sk);
	}
	return eaten;
}
static void sock_def_readable(struct sock *sk)
{
	struct socket_wq *wq;

	rcu_read_lock();
	wq = rcu_dereference(sk->sk_wq);
	if (skwq_has_sleeper(wq))
		//唤醒等待队列的进程
		wake_up_interruptible_sync_poll(&wq->wait, POLLIN | POLLPRI |
						POLLRDNORM | POLLRDBAND);
	sk_wake_async(sk, SOCK_WAKE_WAITD, POLL_IN);
	rcu_read_unlock();
}

#define wake_up_interruptible_sync_poll(x, m)				\
	__wake_up_sync_key((x), TASK_INTERRUPTIBLE, 1, (void *) (m))

//nr_exclusive被宏定义成了1,是为了防止惊群
void __wake_up_sync_key(wait_queue_head_t *q, unsigned int mode,
			int nr_exclusive, void *key)
{
	unsigned long flags;
	int wake_flags = 1; /* XXX WF_SYNC */

	if (unlikely(!q))
		return;

	if (unlikely(nr_exclusive != 1))
		wake_flags = 0;

	spin_lock_irqsave(&q->lock, flags);
	__wake_up_common(q, mode, nr_exclusive, wake_flags, key);
	spin_unlock_irqrestore(&q->lock, flags);
}

static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
			int nr_exclusive, int wake_flags, void *key)
{
	wait_queue_t *curr, *next;

	list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
		unsigned flags = curr->flags;
		//调用进程加入等待队列时注册的回调函数
		if (curr->func(curr, mode, wake_flags, key) &&
				(flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
			break;
	}
}

在这里插入图片描述
__wake_up_common实现唤醒,该函数调用的传入参数nr_exclusive写死了为1,这里是指即使有多个进程都阻塞在同一个socket上,也只唤醒一个进程。其作用是为了避免“惊群”。在recv注册等待队列项的时候,内核把curr->func设置成了woken_wake_function;在woken_wake_function中调用了woken_wake_function,最终调用到了try_to_wake_up,调用try_to_wake_up传入的参数curr->private就是当时因为等待而被阻塞的进程任务。当这个函数执行完的时候,在socket上等待而被阻塞的进程就被推入可运行队列了。

int woken_wake_function(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
	wait->flags |= WQ_FLAG_WOKEN;

	return default_wake_function(wait, mode, sync, key);
}

int default_wake_function(wait_queue_t *curr, unsigned mode, int wake_flags,
			  void *key)
{
	return try_to_wake_up(curr->private, mode, wake_flags);
}

同步阻塞整体流程图如下:
在这里插入图片描述

文章内容参考:《深入理解Linux网络》
Linux版本用的是4.12,书中用的是3.10


http://www.kler.cn/a/311798.html

相关文章:

  • 算法训练(leetcode)二刷第二十三天 | 455. 分发饼干、*376. 摆动序列、53. 最大子数组和
  • Spring框架之适配器模式 (Adapter Pattern)
  • 大模型就业收入高吗?大模型入门到精通,收藏这篇就够了
  • 如何管理好自己的LabVIEW项目
  • 自监督学习:机器学习的未来新方向
  • 【Java开发】Vue的那些小事(一)
  • MySQL从入门到精通
  • MyBatis 数据处理:主键获取、批量删除与动态表名
  • Linux 磁盘清理重新格式化挂载脚本及问题解决
  • flink doris批量sink
  • 我可真厉害,3分钟让你成为AI高手:提示词(prompt)制作及调优(免费教你,别再被割了)
  • 企业EMS -能源管理系统-能源管理系统源码-能源在线监测平台
  • Linux进阶系列(四)——awk、sed、端口管理、crontab
  • 好菜每回味不同——建造者模式
  • GEE教程:对降水数据进行重投影(将10000m分辨率提高到30m)
  • ESP32配网接入Wifi
  • Spring Boot从0到1 -day02
  • 【踩坑】装了显卡,如何让显示器从主板和显卡HDMI都输出
  • QTAndroid编译环境配置
  • Linux基础命令——文件系统的日常管理
  • TaskRes: Task Residual for Tuning Vision-Language Models
  • vue项目中——如何用echarts实现动态水球图
  • 828华为云征文 | 华为云X实例监控与告警管理详解
  • 【Linux入门】基本指令(一)
  • 服务器上PFC配置丢失问题排查与解决方案
  • Python | Leetcode Python题解之第412题Fizz Buzz