Linux C/C++ 原始套接字:打造链路层ping实现

在C/C++中,我们可以使用socket函数来创建套接字。我们需要指定地址族为AF_PACKET,协议为htons(ETH_P_ALL)来捕获所有传入和传出的数据包。

可以使用sendto和recvfrom函数来发送和接收数据包。我们需要构建一个合法的链路层数据包,在数据包的头部添加目标MAC地址和源MAC地址,并指定以太网类型为htons(ETH_P_IP)。

然后,可以在发送前设置IP头部和ICMP头部。为了构建一个有效的ICMP Echo Request消息,需要正确设置ICMP类型、代码、校验和和标识符字段。

最后,通过接收数据包并解析来获取目标设备的响应。需要注意处理ICMP Echo Reply消息,并检查序列号和校验和的正确性。

原始套接字的概念

原始套接字(Raw Socket)是一种在网络编程中使用的特殊套接字类型,它允许应用程序直接访问和操作网络协议栈中的网络层和传输层协议。与其他套接字类型(如TCP套接字和UDP套接字)相比,原始套接字提供更底层的网络访问能力。

原始套接字允许应用程序发送和接收自定义的网络数据包,绕过操作系统的网络协议栈的上层处理。应用程序可以直接构建和解析网络协议的头部,并将数据发送到特定的目标主机或接口。这种直接的访问能力使得原始套接字成为一种强大的工具,能够实现各种高级网络功能和协议,如网络监测、网络仿真、路由协议实现等。

使用原始套接字时,应用程序需要具有足够的权限来使用它,通常需要以超级用户(如root用户)的身份运行。这是因为原始套接字具有更高的网络访问权限,可以直接操作底层网络接口和协议,不受操作系统的保护和限制。

原始套接字的使用需要对网络协议和协议栈的内部工作原理有一定的了解,以正确构建和解析网络数据包的格式。它通常用于开发高级网络应用程序、网络调试工具、网络安全评估工具等场景中,对网络进行更底层的控制和操作。而在普通的网络编程中,一般使用更高级的套接字类型(如TCP套接字和UDP套接字)来实现应用层协议的交互。

链路层ping实现原理

链路层ping工具使用原始套接字实现,涉及以下原理:

  1. 原始套接字:原始套接字是一种可以直接访问网络协议栈的套接字类型。它允许应用程序直接发送和接收网络数据包,绕过传输层和应用层协议的处理。

  2. ICMP协议:链路层ping工具使用的是ICMP协议(Internet Control Message Protocol)。ICMP协议是在网络层之上使用的一种控制协议,用于在IP网络中传递错误消息和有关网络状态的信息。

  3. ICMP Echo请求和响应:链路层ping工具通常发送ICMP Echo请求(也称为ping请求)到目标主机,并期望收到相应的ICMP Echo响应。当目标主机收到Echo请求时,它将生成一个对应的Echo响应并将其发送回源主机。

  4. 构造和解析ICMP数据包:链路层ping工具需要构造符合ICMP协议规范的数据包,并能够解析收到的ICMP响应数据包。ICMP数据包包含ICMP报文头部和负载数据,其中ICMP Echo请求和响应的报文类型为8和0。

  5. IP数据包发送和接收:链路层ping工具使用原始套接字发送和接收IP数据包。在构造ICMP数据包时,会将ICMP报文作为负载放入IP数据报中,并设置目标IP地址、源IP地址、协议类型等IP头部字段。

  6. 校验和计算:在构造ICMP数据包时,需要计算校验和字段。校验和计算是为了确保数据在传输过程中的完整性,接收端可以通过校验和验证数据包的正确性。

  7. Root权限:由于原始套接字需要对网络接口进行底层访问,因此使用原始套接字通常需要root权限。

链路层ping的需求

链路层ping工具的需求可以归纳为以下几个方面:

  1. 网络连通性测试:链路层ping工具可以用于测试两个网络节点之间的连通性。它能够发送ICMP Echo请求到目标节点,并获取对应的ICMP Echo响应,从而确定节点之间是否能够正常通信。

  2. 网络性能测试:链路层ping工具可以测量网络链路的性能指标,如往返延迟(RTT,Round-Trip Time)和丢包率。通过发送ICMP Echo请求,并测量请求到响应的时间差,可以估计网络链路的延迟。同时,通过记录响应丢失的数量,可以计算丢包率。

  3. 网络故障排除:当网络出现故障时,链路层ping工具可以用于排查故障原因。通过在网络各个节点之间进行ping测试,可以确定具体哪个节点之间出现问题,从而有针对性地进行故障排除。

  4. 确定网络拓扑:通过链路层ping工具,在网络中的不同节点之间进行ping测试,可以帮助确定网络的拓扑结构,包括节点之间的连接方式和路径。

  5. 定位网络性能瓶颈:链路层ping工具可以用于定位网络中的性能瓶颈。通过在不同节点之间进行ping测试,并分析各个测试点的延迟和丢包率,可以确定网络的性能瓶颈所在,从而采取相应的优化措施。

  6. 监控网络稳定性:链路层ping工具可以用于监测网络的稳定性。通过定期进行ping测试,并记录网络的性能指标,可以获得网络的运行情况,并及时发现潜在的问题。

  7. 自动化网络管理:链路层ping工具可以通过脚本或自动化程序进行批量测试和监控,从而实现对大规模网络的管理和优化。

Linux C/C++ 原始套接字:打造链路层ping工具

使用原始套接字(AF_PACKET、SOCK_raw)实现的链接层ping实用程序.

struct ping_config {
	unsigned long count;
	unsigned long size;
	unsigned long interval;	/* in milliseconds */
	int listen;
	const char *ifname;
	int ifindex;
	unsigned char ifaddr[ETH_ALEN];
	struct list_head hosts;
	unsigned char replyto[ETH_ALEN];
};

struct ping_host {
	unsigned char host[ETH_ALEN];
	struct list_head node;
};


enum llcmp_types {
	LLCMP_ECHO_REQUEST = 128,
	LLCMP_ECHO_REPLY = 129,
};

/* 类似于ICMPv6回显请求/回复的结构 */
struct ping_header {
	struct ethhdr ethhdr;
	uint8_t reserved1;	
	uint8_t reserved2;	
	__be16 payload_len;	
	uint8_t type;
	uint8_t reserved3;	
	uint16_t reserved4;	
	__be16 identifier;
	__be16 seqno;
	uint8_t replyto[6];
} __attribute__((packed));

struct ping_config ping_config;

...


static void sigint_handler(int signo) {
	term = 1;
}

static int init_socket(void)
{
	struct ifreq ifreq;
	const char *ifname =  ping_config.ifname;
	int ret;
	struct epoll_event event;

	/* create socket */
	sd = socket(AF_PACKET, SOCK_RAW, htons(LLCMP_ETHER_TYPE));
	if (sd < 0) {
		fprintf(stderr,
			"Error: Can't open a raw socket for ether type 0x%04x\n",
			LLCMP_ETHER_TYPE);
		return -EPERM;
	}

	/* bind socket to specific interface */
	ret = setsockopt(sd, SOL_SOCKET, SO_BINDTODEVICE, ifname,
			 strlen(ifname));
	if (ret < 0) {
		fprintf(stderr, "Error: Can't bind to device %s\n", ifname);
		goto err;
	}

	/* get MAC address and index of interface */
	memset(&ifreq, 0, sizeof(ifreq));
	strcpy(ifreq.ifr_name, ifname);

	ret = ioctl(sd, SIOCGIFHWADDR, &ifreq);
	if (ret < 0) {
		fprintf(stderr, "Error: Can't get mac address of interface\n");
		goto err;
	}
	eth_copy(ping_config.ifaddr,
		 (unsigned char *)ifreq.ifr_hwaddr.sa_data);

	ret = ioctl(sd, SIOCGIFINDEX, &ifreq);
	if (ret < 0) {
		fprintf(stderr, "Error: Can't get interface index\n");
		goto err;
	}
	ping_config.ifindex = ifreq.ifr_ifindex;

	/* add socket to epoll */
	memset(&event, 0, sizeof(event));
	event.events = EPOLLIN;
	event.data.fd = sd;

	if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sd, &event)) {
		fprintf(stderr, "Error: Can't add socket to epoll.\n");
		ret = -EPERM;
		goto err;
	}

	return 0;

err:
	close(sd);
	return ret;
}

static int config_add_host(const char *host) {
	struct ping_host *ping_host;

	ping_host = malloc(sizeof(*ping_host));
	if (!ping_host) {
		fprintf(stderr,
			"Error: Can't allocate host: out-of-memory\n");
		return -ENOMEM;
	}

	if (eth_str2bin(host, ping_host->host) < 0) {
		free(ping_host);
		fprintf(stderr, "Error: invalid MAC address: %s\n", host);
		return -EINVAL;
	}

	if (eth_is_zero(ping_host->host)) {
		free(ping_host);
		fprintf(stderr,
			"Error: zero MAC address not allowed\n");
		return -EINVAL;
	}

	list_add_tail(&ping_host->node, &ping_config.hosts);

	return 0;
}

static void config_free_hosts(void) {
	struct ping_host *host, *tmp;

	list_for_each_entry_safe(host, tmp, &ping_config.hosts, node) {
		list_del(&host->node);
		free(host);
	}
}

static int init_args(int argc, char *argv[])
{
...
	struct option long_opts[] =
	{
		{"help",	no_argument,		0, 'h'},
		{"listen",	no_argument,		0, 'l'},
		{"interface",	required_argument,	0, 'I'},
		{"count",	required_argument,	0, 'c'},
		{"size",	required_argument,	0, 's'},
		{"interval",	required_argument,	0, 'i'},
		{"replyto",	required_argument,	0, 'r'},
		{0, 0, 0, 0}
        };

	while(1) {
		opt = getopt_long(argc, argv, "hlc:s:i:I:r:", long_opts, &opt_idx);
		if (opt == -1)
			break;

		errno = 0;

		switch(opt) {
		case 'h':
			usage(argv[0]);
			exit(2);
			break;
		case 'l':
			ping_config.listen = 1;
			break;
		case 'c':
			ping_config.count = strtoul(optarg, &endptr, 10);
			if (errno != 0 || endptr == optarg ||
			    ping_config.count == 0)
				goto parse_err;
			break;
		case 's':
			ping_config.size = strtoul(optarg, &endptr, 10);
			if (errno != 0 || endptr == optarg)
				goto parse_err;
			break;
		case 'i':
			if (sscanf(optarg, "%f", &interval) == EOF ||
			    interval <= 0 || interval > UINT16_MAX)
				goto parse_err;

			ping_config.interval = (unsigned long)(1000 * interval);
			if (ping_config.interval == 0)
				goto parse_err;
			break;
		case 'I':
			ping_config.ifname = optarg;
			break;
		case 'r':
			if (eth_str2bin(optarg, ping_config.replyto) < 0) {
				fprintf(stderr, "Error: invalid MAC address: %s\n\n",
					argv[optind]);
				exit(2);
			}
			if (eth_is_zero(ping_config.replyto)) {
				fprintf(stderr,
					"Error: zero MAC address not allowed\n\n");
				exit(2);
			}
			break;
		default:
			fprintf(stderr, "\n");
			usage(argv[0]);
			exit(2);
		}
	}

...
}

static int init_request_buffer(void)
{
...

	request_buffer = malloc(len);
	if (!request_buffer)
		return -ENOMEM;

	memset(request_buffer, 0, len);
	eth_copy(request_buffer->ethhdr.h_source, ping_config.ifaddr);
	request_buffer->ethhdr.h_proto = htons(LLCMP_ETHER_TYPE);

	request_buffer->payload_len = htons(ping_config.size);
	request_buffer->type = LLCMP_ECHO_REQUEST;
	request_buffer->identifier = identifier;

	if (eth_is_zero(ping_config.replyto))
		eth_copy(request_buffer->replyto, ping_config.ifaddr);
	else
		eth_copy(request_buffer->replyto, ping_config.replyto);

	return 0;
}

static int init_ping(int argc, char *argv[])
{
	int ret;

	INIT_LIST_HEAD(&ping_config.hosts);

	ret = init_args(argc, argv);
	if (ret < 0)
		return -EINVAL;

	epoll_fd = epoll_create1(0);
	if (epoll_fd < 0) {
		fprintf(stderr, "Can't create epoll file descriptor.\n");
		return -EPERM;
	}

	ret = init_socket();
	if (ret < 0)
		goto err1;

	if (signal(SIGINT, sigint_handler) == SIG_ERR) {
		fprintf(stderr, "Can't establish SIGINT handler.\n");
		ret = -EPERM;
		goto err2;
	}

	if (!ping_config.listen) {
		ret = init_request_buffer();
		if (ret < 0) {
			fprintf(stderr,
				"Can't allocate request buffer: %s\n",
				(ret == -EBUSY) ?
					"no entropy" : "out-of-memory");
			goto err2;
		}
	}

	return 0;

err2:
	close(sd);
err1:
	close(epoll_fd);

	return ret;
}

static int send_packet(struct ping_header *plhdr)
{
...

	ret = sendto(sd, plhdr, sizeof(*plhdr) + ntohs(plhdr->payload_len), 0,
		     (struct sockaddr*)&addr, sizeof(addr));
	if (ret < 0) {
		fprintf(stderr, "Error: Could not send packet: %s\n", strerror(errno));
		return ret;
	}

	return 0;
}

static int send_echo_request(void)
{
...

	list_for_each_entry(host, &ping_config.hosts, node) {
		eth_copy(request_buffer->ethhdr.h_dest, host->host);

		ret = send_packet(request_buffer);
		if (ret < 0)
			return ret;
	}

	rtcidx = (rtcidx + 1) % REQUEST_TIME_CACHE_SIZE;
	clock_gettime(CLOCK_MONOTONIC, &request_time_cache[rtcidx]);

	return 0;
}

static void print_echo_request(struct ping_header *plhdr)
{
...

	eth_bin2str(plhdr->ethhdr.h_source, src);
	eth_bin2str(plhdr->ethhdr.h_dest, dst);
	eth_bin2str(plhdr->replyto, replyto);

	if (plhdr->type == LLCMP_ECHO_REQUEST)
		snprintf(type, sizeof(type), "echo request");
	else if (plhdr->type == LLCMP_ECHO_REPLY)
		snprintf(type, sizeof(type), "echo reply");
	else
		snprintf(type, sizeof(type), "unknown");

	printf("%s > %s, LLCMP, %s, reply-to %s, id 0x%04x, seq %u, length %u(%u)\n",
		src, dst, type, replyto, ntohs(plhdr->identifier),
		ntohs(plhdr->seqno), paylen, pktlen);
}

static int recv_echo_request(struct ping_header *plhdr)
{
	print_echo_request(plhdr);

	plhdr->type = LLCMP_ECHO_REPLY;
	eth_copy(plhdr->ethhdr.h_dest, plhdr->replyto);
	eth_copy(plhdr->ethhdr.h_source, ping_config.ifaddr);
	eth_copy(plhdr->replyto, ping_config.ifaddr);

	return send_packet(plhdr);
}

static int check_seqno_range(const uint16_t recv_seqno)
{
...

	if (send_seqno > REQUEST_TIME_CACHE_SIZE)
		last_seqno = send_seqno - REQUEST_TIME_CACHE_SIZE + 1;

	/* 不支持seqno环绕 */
	if (send_seqno > recv_seqno ||
	    recv_seqno < last_seqno)
		return -ERANGE;

	return 0;
}

static int echo_reply_timediffus_get(unsigned long *timediff,
				     const uint16_t seqno)
{
...

	idx = (seqno - 1) % REQUEST_TIME_CACHE_SIZE;
	clock_gettime(CLOCK_MONOTONIC, &now);
	diff = timespec_diffus(request_time_cache[idx], now);
	diff = diff < 0 ? 0 : diff;

	if (diff > ULONG_MAX)
		return -ERANGE;

	*timediff = (unsigned long)diff;
	return 0;
}

static int recv_echo_reply(struct ping_header *plhdr)
{
...

	eth_bin2str(plhdr->ethhdr.h_source, src);
	eth_bin2str(plhdr->replyto, replyto);

	if (echo_reply_timediffus_get(&timediff_us, ntohs(plhdr->seqno)) < 0)
		printf("%u(%u) bytes from %s (via %s): llcmp_seq=%u\n",
		       paylen, pktlen, replyto, src, ntohs(plhdr->seqno));
	else
		printf("%u(%u) bytes from %s (via %s): llcmp_seq=%u time=%lu.%03lu ms\n",
		       paylen, pktlen, replyto, src, ntohs(plhdr->seqno),
		       timediff_us / 1000, timediff_us % 1000);

	return 0;
}

static int recv_check_header(struct ping_header *plhdr, int len)
{
...

	if (ntohs(plhdr->payload_len) > len - sizeof(*plhdr)) {
		fprintf(stderr,
			"Warning: received malformed packet: payload_len too large (%i > %zu bytes)\n",
			ntohs(plhdr->payload_len), (size_t)len - sizeof(*plhdr));
		return -EINVAL;
	}

	if (ntohs(plhdr->ethhdr.h_proto) != LLCMP_ETHER_TYPE) {
		fprintf(stderr,
			"Warning: received malformed packet: invalid ether type (0x%04x, expected 0x%04x)\n",
			ntohs(plhdr->ethhdr.h_proto), LLCMP_ETHER_TYPE);
		return -EINVAL;
	}

	/* 源代码检查:忽略自己的帧 */
	if (eth_is_own(plhdr->ethhdr.h_source))
		return -EADDRNOTAVAIL;

	/* 目的地检查:只接受多播和自己的地址 */
	if (!eth_is_own(plhdr->ethhdr.h_dest) &&
	    !eth_is_multicast(plhdr->ethhdr.h_dest))
		return -EADDRNOTAVAIL;

	return 0;
}

static int recv_packet(int sd)
{
...

	/* 忽略不适合我们的东西 */
	if (ret == -EADDRNOTAVAIL)
		return 0;
	/* 畸形数据包  */
	else if (ret < 0)
		return ret;

	switch (plhdr->type) {
	case LLCMP_ECHO_REQUEST:
		/* 仅适用于监听 */
		if (!ping_config.listen)
			return 0;

		ret = recv_echo_request(plhdr);
		break;
	case LLCMP_ECHO_REPLY:
		/* 仅适用于请求者 */
		if (ping_config.listen)
			return 0;

		/* 忽略不是来自我们会话的回复 */
		if (plhdr->identifier != request_buffer->identifier)
			return 0;

		ret = recv_echo_reply(plhdr);
		break;
	default:
		fprintf(stderr,
			"Warning: unknown ping type: %u\n", plhdr->type);
		return -EINVAL;
	}

	return ret;
}

static int get_next_timeout()
{
...

	add.tv_sec = ping_config.interval / 1000;
	add.tv_nsec = (ping_config.interval % 1000) * (1000*1000);
	next = timespec_sum(request_time_cache[rtcidx], add);

	diff = timespec_diffus(now, next) / 1000;
	if (diff < 0)
		return 0;

	return (diff > INT_MAX) ? INT_MAX : (int)diff;
}

int main(int argc, char *argv[])
{
...

	while(!term) {
		if (!ping_config.listen) {
			if (!ev_count) {
				if (count &&
				    count <= ntohs(request_buffer->seqno))
					break;

				send_echo_request();
			}

			timeout = get_next_timeout();
		}

		ev_count = epoll_wait(epoll_fd, events, MAX_EVENTS,
				      timeout);

		for(int i = 0; i < ev_count; i++)
			recv_packet(events[i].data.fd);
	}

...

	return 0;
}

...

If you need the complete source code, please add the WeChat number (c17865354792)

dping利用其自己的非正式以太类型(0x4304),该类型允许在低级别上测试链路,而不依赖于像IPv4/IPv6这样的网络协议。在发送回显请求或回显回复之前,不需要ARP或ICMPv6邻居发现。

要使用dping,请在侦听模式下启动一个dping实例(–listen)。然后,您可以通过在指定了目标主机的MAC地址的不同主机上启动另一个dping实例来ping它。

  • 示例:

    dping可以用于单独测试链路的单播和多播能力。

  • 运行效果:

单播测试:
Listener on ens33/MAC address :

Sender on ens37/MAC address :

广播测试:

回复功能可用于强制侦听器回复广播地址。这样,就可以在不依赖单播的情况下单独测试接口的广播功能。

Listener on ens33/MAC address :

Sender on ens37/MAC address :

在这里插入图片描述
数据包格式

  • 链路层控制消息协议

数据包格式(主要)类似于ICMPv6,即IPv6的互联网控制消息协议。目前,LLCMP回声请求和LLCMP回声回复在dping中实现:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                     Ethernet Destination ...                  |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  ... Ethernet Destination     |       Ethernet Source ...     |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                 ... Ethernet Source                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|      LLCMP Ethernet Type      |  Reserved1    |  Reserved2    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|      LLCMP Payload Length     |  LLCMP Type   |  Reserved3    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|      LLCMP Identifier         |  LLCMP Sequence Number        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                     LLCMP Reply-To ...                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  ... LLCMP Reply-To           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

以太网目标:

接收器的6字节MAC地址。除00:00:00:00:00:00外,其他时间都允许。

以太网源:

6字节的发送方MAC地址。只允许单播MAC地址(第一个八位字节的最低有效位设置为0,而不是00:00:00:00:00)。

LLCMP以太网类型:

0x4304(网络字节顺序/big-endian),非官方以太网类型。

预留1:

保留,稍后可能有LLCMP跃点限制

预留2:

为稍后的服务质量保留,可能是LLCMP流量类

LLCMP有效载荷长度:

LLCMP标头之外的字节数。通常为“帧大小-14字节以太网头-16字节LLCMP头”。(待办事项:可能将LLCMP标头大小定义为6字节,因此排除回声请求/回复特定字段“标识符”、“序列号”和“回复”?)

LLCMP类型:

当前实施/定义:

LLCMP回声请求,128

LLCMP回声回复,129

预留3:

保留,以后可能是特定LLCMP类型的代码/子类型或标志

LLCMP标识符:

随机,但对于特定LLCMP回显请求/回复会话固定为2个字节

LLCMP序列号:

一个2字节的序列号(网络字节顺序/big-endian),用于特定的LLCMP回显请求/回复交换。从1开始,每次回显请求增加1。

LLCMP回复:

6字节MAC地址。在LLCMP回声请求中,指定接收器应在其LLCMP回声回复中使用的以太网目的地。在LLCMP中,回声回复等于LLCMP回声回复的以太网源。也可用于检测第2层源NAT/代理。

总结

原始套接字是一种特殊的套接字类型,允许应用程序直接访问和操作网络协议栈中的网络层和传输层协议。使用原始套接字可以实现更底层的网络访问和控制,例如构建和解析自定义的网络数据包。

在实现链路层ping工具时,原始套接字可以用于发送和接收ICMP Echo请求和响应,从而测试网络的连通性、性能和稳定性。

通过原始套接字,我们可以 bypass 操作系统的一些网络协议栈处理,直接操作链路层数据,以及分析 network path 过程中的网络延迟和网络损耗,进而诊断、分析和解决网络问题。使用原始套接字还需要注意权限管理和安全性,并且需要对网络协议和协议栈有一定的了解。

Welcome to follow WeChat official account【程序猿编码

参考:RFC 792、RFC 791、RFC 4861、RFC 4443

  • 24
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值