零入门kubernetes网络实战-26->介绍IP-TCP-UDP-ARP-ICMP协议的封装过程以及用golang解析报文头的过程

本文深入介绍了以太网帧、ICMP、TCP、UDP和ARP协议的封装过程,并使用golang进行报文头解析。通过tcpdump抓包和Wireshark分析,验证了解析的准确性。文章还提供了TCP、UDP和ICMP的测试用例,帮助读者理解这些协议在网络传输中的工作原理。
摘要由CSDN通过智能技术生成

《零入门kubernetes网络实战》视频专栏地址

https://www.ixigua.com/7193641905282875942

本篇文章视频地址(稍后上传)


本篇文章是在上一篇文章的基础之上,重点介绍

  • 以太网帧、
  • ICMP、
  • UDP、
  • TCP、
  • ARP、
  • IP

协议的封装过程,

并用golang解析

  • 以太网帧、
  • ICMP、
  • UDP、
  • TCP、
  • ARP、
  • IP

协议的报文头结构,以及对应的数据内容

用到的测试代码是
上一篇文章中的测试用例1中的代码。

代码占用的篇幅量太大,不再这里重新展示了。

1、本篇文章的核心点

本篇文章的核心点:

  • 把ICMP、ARP、IP、TCP、UDP协议的封装过程进行展示
  • 使用golang对ICMP、ARP、IP、TCP、UDP协议的报文头进行解析
    • 通过tcpdump命令进行抓包,使用wireshark进行抓包分析
    • 跟golang解析的报文头进行对比,检测一下我们解析的报文头是否有问题
  • 不会介绍ARP、ICMP、IP、TCP、UDP协议的原理。

2、测试环境介绍

两台虚拟机

  • centos7.5 10.211.55.122
  • centos7.5 10.211.55.123

3、OSI七层模型介绍

在这里插入图片描述

4、网络信息传输模型图

客户端发送数据到服务器端的完整路线

在这里插入图片描述

5、帧

5.1、什么叫做帧

在数据链路传输的数据包叫做帧。

也就是说
数据链路层以帧为单位进行传输和处理数据。

在数据链路层里,将位组合成字节,并将字节组合成帧。

5.2、帧结构分类

至少存在

  • 以太网帧
  • PPP帧

6、以太网帧介绍

6.1、以太网帧结构 6.1.1、封装过程流程图

在这里插入图片描述

怎么看这个图呢?

  • 从上往下看
  • 从IP层到数据链路层,增加了哪些字段
  • 从数据链路层到物理层,增加哪些字段
    • 插入8个字节,目前不用关心。
    • 这8个字节,表明开始读取以太网帧了。也就是起始位置。

6.1.2、以太网帧结构

看上面图中第2行,从左到右看

  • 目的MAC地址,
    • 占用6个字节
    • 接收方设备的硬件地址
  • 源MAC地址
    • 占用6个字节
    • 发送方设备的硬件地址
  • 类型
    • 占用2个字节
    • 表示封装的数据的类型。如:
      • 0x0800代表IP协议帧
      • 0x0806代表ARP协议帧
      • 0x8864代表PPPoE
      • 0x86dd代表IPv6
      • 其他协议栈可以参考网址https://www.pianshen.com/article/5115861078/
      • 注意:
        • 没有TCP,UDP,ICMP。
        • 也就说,不能将TCP,UDP,ICMP数据直接封装到以太网帧里。
        • 必须先封装放到IP协议栈里。
  • 数据
    • 被封装的具体内容。
    • 占用46~1500字节
      • 如果不足46字节时,会进行填充到46字节
      • 最大占用1500字节。
      • 最大值也叫做最大传输单元(MTU)
    • 内容的类型是有要求的
      • 在上面"类型"里已经说明了
  • FCS
    • 帧校验序列

6.2、以太网帧报文头解析介绍

代码在前一篇文章中测试用例1的

etherheader.go文件中

先看一下main.go文件中的tapToUDP函数

在这里插入图片描述

读取原理,前面介绍tun设备时,已经介绍过了。

这里又简单的说明了一次。

然后,打开etherheader.go文件
在这里插入图片描述

7、IP报文介绍

7.1、IP报文封装过程

在这里插入图片描述

什么是封装呢?

简单的说,就是在上一层的基础上又添加一些东西而已。

添加的东西都有一定的规则。

上一层到下一层,添加了哪些属性。

7.2、IP报文详细结构如下:包括IP报文头结构+IP报文数据

从上面的图中,可以看出来
一个IP报文包括2部分构成

  • IP报文头
    • 固定部分占用20个字节
    • 可变长部分占用0~40个字节
  • 具体数据部分

一个IP报文,包括IP报文头+数据部分,最多可以占用65535个字节

在这里插入图片描述

首先注意一点:
IP报文的存储结构并不是如上图的样子,是个长方形。
IP报文是存储一个切片里。
只是IP报文的属性比较多,这里为了表达清楚,而如此绘画的。
前5行,每行占用4个字节。
按照图中的顺序存储的属性。
先版本号,然后头部长度,以此类推。

IP报文头结构的具体属性解释,可以参考下面的文章
https://wenku.baidu.com/view/f4559240f6335a8102d276a20029bd64783e62f1.html

7.3、使用golang来解析IP报文

7.3.1、对前篇文章测试用例1中的代码,进行更新

从上图中,可以看出来,
IP头部是由两部分构成的:
第一部分是固定部分,固定占用20个字节。
第二部分是可选部分,最少占用0个字节,最多占用40个字节。

因此,我们需要对测试用例1中的client包下的main.go文件以及ipv4header.go文件进行更新。

7.3.1.1、更新main.go文件中的tapToUDP函数

tapToUDP函数的最新内容如下:(直接copy即可)

func tapToUDP(udpConn *net.UDPConn, tapFile *os.File) {
	packet := make([]byte, 1024*64)
	size := 0
	var err error
	for {
		if size, err = tapFile.Read(packet); err != nil {
			return
		}

		te := MACType(packet[:size])
		printMACHeader(packet[:size])

		if strings.EqualFold(fmt.Sprintf("%x", te), "0800") {
			b := packet[14:size]
			printIPv4Header(b)
			hl := getIPv4HeaderLen(b)
			if b[9] == 1 {
				icmpPacket := b[hl:]
				printICMPHeader(icmpPacket)
			}

			if b[9] == 6 {
				tcpPacket := b[hl:]
				printTCPHeader(tcpPacket)
			}

			if b[9] == 17 {
				udpPacket := b[hl:]
				printUDPHeader(udpPacket)
			}
		}

		if strings.EqualFold(fmt.Sprintf("%x", te), "0806") {
			b := packet[14:size]
			printARPHeader(b)
		}

		rAddr, err := net.ResolveUDPAddr("udp", remoteAddr)
		if err != nil {
			log.Fatalln("failed to get udp socket:", err)
			return
		}
		if size, err = udpConn.WriteTo(packet[:size], rAddr); err != nil {
			fmt.Println(err.Error())
			return
		}
		fmt.Printf("tapToUDP--->Write Msg To UDP Conn OK! size:%d\n", size)
	}
}

在这里插入图片描述

7.3.1.2、更新ipv4header.go文件中,新增一个函数getIPv4HeaderLen

在ipv4header.go文件中,新增加一个函数

func getIPv4HeaderLen(packet []byte) int {
	header := packet[0]
	headerLen := header & 0x0f * 4
	hl, _ := strconv.Atoi(fmt.Sprintf("%d", headerLen))
	return hl
}

在这里插入图片描述

7.4、对ipv4header.go文件中的部分代码进行说明

在测试用例1中的ipv4header.go文件里

7.4.1、printVersionIPv4函数说明

该函数打印的是IP协议的版本号,
IP协议有两个版本号:

  • IPv4
  • IPv6

上面的图中,已经说明版本号占用的是第一个字节的前4个bit;

第一个字节,即packet[0]

而函数printVersionIPv4的参数类型是字节切片

只能按照字节获取

1个字节等于8个bit

那么,如何获取前4个bit的值呢?
直接将前4个字节向右移动4位即可,即packet[0]>>4

7.4.2、printHeaderLenIPv4函数说明

该函数是打印一个IP报文的头部长度
包括:固定长度+可选项长度

上面图中,已经说明,头部长度在第1个字节的后4个bit存储。
即:

  • 第1个字节用packet[0]来获取
  • 只有后4个bit有用,即packet[0]&0x0f
    • 0x0f,转换成二进制是0b00001111;
    • 0跟任何数相与的话,结果都是0
    • 即,将前4位置0
    • 即,前4位bit的数无意义了

packet[0]&0x0f*4?为什么最后乘以4呢?

头部长度占用4bit位,这里我们假设用每个bit位代表4个字节

那么,用二进制表示,4bit最小值是0b0000

4bit最大值是0b1111,即15

转换成字节数是15*4=60个字节

即,头部长度最多占用60个字节。

7.4.3、printAllLenIPv4函数说明

该函数是打印一个IP报文的长度;这里的长度包括IP头部长度+具体数据长度

uint16(packet[2])<<8|uint16(packet[3]) 什么意思呢?
首先,观察IP报文结构图中,显示IP报文总长度占用的第3个字节packet[2],以及第4个字节packet[3];

在golang中byte是uint8的别名,取值范围是0~255

假设当前IP报文的总长度是1000,已经超过byte的最大存储值255了, 一个字节已经存储不下了,

此时,可以使用两个字节进行存储。
将1000转换为二进制0000001111101000,可以

  • 将前8个bit,即00000011存储到packet[2]里,
  • 将后8个bit,即11101000存储到packet[3]里

这样的话,就可以存储起来了。

现在,过程反过来了;如何通过读取packet[2], packet[3]的值来获得1000?

我们需要将packet[2],packet[3]转换成二进制,并且将packet[2]放到packet[3]前面,因此,涉及到packet[2]的移位操作;

因为packet[2]的类型byte,一共才占用8位,因此,如果直接读取packet[2]的值,向左移动8的话,会导致数据丢失的;

因此,需要先将packet[2]的值,强制转换为uint16,再向左移动8位;

即,00000011->0000000000000011->0000001100000000

uint16类型不能uint8类型进行异或操作,位数不对。

因此,也必须将packet[3]强制转换为uint16

最终变为uint16(packet[2])<<8|uint16(packet[3])

在这里插入图片描述

7.4.4、printDstIPv4函数说明

获取IP的两种方式

在这里插入图片描述

8、TCP报文

8.1、TCP数据段封装过程

在这里插入图片描述

TCP报文头同样有两部分组成:

  • 固定部分,占20个字节
  • 可变长部分,占0~40个字节

8.2、TCP报文头的结构如下

在这里插入图片描述

每个属性的具体含义,可以参考下面的网址:

https://blog.csdn.net/marywang56/article/details/76151064

8.3、使用golang解析TCP报文头部结构

8.3.1、分析main.go文件中的tapToUDP函数

测试用例1的client包下的main.go文件里

在这里插入图片描述

8.3.2、分析tcpheader.go文件

主要分析一下

TCP报文头中的CWR、ACK、FIN这三个属性,如何获取进行分析;以及如何获取TCP报文中的数据部分进行简单分析一下。

其他属性的获取方式,跟以前很类似,不再过多介绍了。

8.3.2.1、printTCPFlagFIN函数说明

在这里插入图片描述

8.3.2.2、printTCPFlagCWR函数说明

CWR占用的是packet[13]字节中的第1个bit位,需要将剩余的属性全部置为0,

即,packet[13]&0x80

为什么还要向右移动7位呢?
在这里插入图片描述

8.3.2.3、printTCPFlagACK函数说明

在这里插入图片描述

在这里插入图片描述

8.3.2.4、printTCPData函数说明

在这里插入图片描述

8.4、测试

提供一个简单的tcp测试用例,抓包分析,

然后跟Wireshark软件进行对比分析

查看,我们golang解析的是否正确

8.4.1、启动helloworld级别的点对点VPN服务

将前一篇文章中的测试用例1启动即可。

前面文章中,已经说明了启动方式。

8.4.2、TCP测试用例

8.4.2.1、TCP服务器端代码
package main

import (
	"bufio"
	"fmt"
	"net"
	"os"
	"strings"
)

const (
	ip   = "10.244.3.3"
	port = 9898
)

func main() {
	l, err := net.Listen("tcp", fmt.Sprintf("%s:%d", ip, port))
	if err != nil {
		fmt.Println("listen error:", err)
		os.Exit(1)
	}
	defer l.Close()
	fmt.Printf("listening on :%v", fmt.Sprintf("%s:%d", ip, port))
	for {
		conn, err := l.Accept()
		if err != nil {
			fmt.Println("accept error:", err)
			os.Exit(1)
		}
		fmt.Printf("message %s->%s\n", conn.RemoteAddr(), conn.LocalAddr())
		go handleRequest(conn)
	}
}

func handleRequest(conn net.Conn) {
	ip := conn.RemoteAddr().String()
	defer func() {
		fmt.Println("disconnect:" + ip)
		conn.Close()
	}()
	reader := bufio.NewReader(conn)
	writer := bufio.NewWriter(conn)

	for {
		b, _, err := reader.ReadLine()
		if err != nil {
			return
		}
		writer.Write([]byte(strings.ToUpper(string(b))))
		writer.Write([]byte("\n"))
		writer.Flush()
	}
}

TCP服务器端,只是将接收到的内容,转换为大写而已。

反馈给客户端。

8.4.2.2、TCP客户端代码
package main

import (
	"bufio"
	"fmt"
	"net"
	"os"
	"sync"
)

const (
	ip   = "10.244.3.3"
	port = 9898
)

func main() {
	conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", ip, port))
	if err != nil {
		fmt.Println("connect error", err)
		os.Exit(1)
	}
	defer conn.Close()
	var wg sync.WaitGroup
	wg.Add(2)
	go handleWrite(conn, &wg)
	go handleRead(conn, &wg)
	wg.Wait()
}

func handleWrite(conn net.Conn, wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 0; i < 1; i++ {

		writer := bufio.NewWriter(conn)
		writer.Write([]byte("hello456"))
		writer.Write([]byte("\n"))
		writer.Flush()
	}
	fmt.Println("write done")
}

func handleRead(conn net.Conn, wg *sync.WaitGroup) {
	defer wg.Done()
	reader := bufio.NewReader(conn)
	for i := 0; i < 1; i++ {
		line, _, err := reader.ReadLine()
		if err != nil {
			fmt.Println("read error", err)
			return
		}

		fmt.Printf("Read Msg From Tcp Server:%v\n", string(line))
	}
	fmt.Println("read done")
}

注意:

tcp客户端请求内容是
"hello456"
在使用golang进行解析时,查看一下是否是"hello456"
8.4.2.3、本地编译,上传到10.211.55.122,10.211.55.123节点上去

Makefile参考内容如下

build:
	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o tcpclient ./client/main.go
	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o tcpserver ./server/main.go

scp:
	scp tcpclient root@10.211.55.122:/root
	scp tcpserver root@10.211.55.123:/root

all:
	make build && make scp

在本地执行

make all

即可

8.4.2.4、登陆到10.211.55.122节点上, 进行抓包
tcpdump -nn -i tap99 -w tcp.pcap

在这里插入图片描述

8.4.2.5、登陆到10.211.55.123节点上,启动TCP服务器端服务
./tcpserver 

在这里插入图片描述

8.4.2.6、登陆到10.211.55.122节点上,启动TCP客户端服务
./tcpclient 

在这里插入图片描述

日志显示,已经将请求的内容,转换为了大写,说明确实是服务器端反馈的。

8.4.2.7、将抓包结果上传到wireshark里查看

在这里插入图片描述

8.4.2.8、在10.211.55.122节点上查看一下vpn客户端日志 并跟 Wireshark里解析的数据包进行对比

在这里插入图片描述

9、UDP报文介绍

9.1、UDP报文封装过程介绍

在这里插入图片描述

UDP报文的封装过程,跟TCP是一样的。

只是UDP的报文结构跟TCP不同。

9.2、UDP报文结构介绍

在这里插入图片描述

很明显,UDP的报文结构比TCP简单多了。

UDP报文头固定占用8个字节。

源端口

  • 源端口:
    • 主机的应用程序使用的端口号。
  • 目的端口:
    • 目的主机的应用程序使用的端口号。
  • 总长度:
    • 是指UDP头部和UDP数据的字节长度。
    • 因为UDP头 部长度为8字节,所以该字段的最小值为8。
      • 也就是说,数据部分可以为空
  • 校验和:
    • 检测UDP数据报在传输中是否有错,有错则丢弃。

9.3、使用golang解析UDP报文头部结构

9.3.1、main.go文件里

先看一下前一篇文章中的测试用例1中的客户端代码main.go文件里:

在这里插入图片描述

9.3.2、udpheader.go文件里

在这里插入图片描述

解析原理,跟以前的一样。不再过多解析了。

9.4、测试

提供一个简单的udp测试用例,抓包分析,

然后跟Wireshark软件进行对比分析

查看,我们golang解析的是否正确

9.4.1、启动helloworld级别的点对点VPN服务

将前一篇文章中的测试用例1启动即可。

前面文章中,已经说明了启动方式。

如果已经启动了,忽略即可。

9.4.2、UDP测试用例

其实,tcp测试用例,udp测试用例的逻辑不用心。

我们只是抓包分析。

9.4.2.1、UDP服务器端代码
package main

import (
	"fmt"
	"net"
	"time"
)

const ip = "10.244.3.3"

func main() {
	udpAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", ip, 8989))
	if err != nil {
		fmt.Println("Err resolve UDP address: ", err)
		return
	}

	serverConn, err := net.ListenUDP("udp", udpAddr)
	if err != nil {
		fmt.Println("ListenUDP error: ", err)
		return
	}

	var ticker = time.Tick(time.Second * 2) // 每隔2秒钟发送一个数据

	for {
		for _ = range ticker {
			var buff [512]byte
			n, rAddr, err := serverConn.ReadFromUDP(buff[0:])
			if err != nil {
				fmt.Println("Read error: ", err)
				break
			}
			fmt.Println("Read from client: ", string(buff[:n]))
			// 如果使用Write,本地测试时客户端接收不到信息
			serverConn.WriteToUDP([]byte("Hello client"), rAddr)
		}
	}
}

9.4.2.2、UDP客户端代码
package main

import (
	"fmt"
	"net"
)

const ip = "10.244.3.3"

func main() {
	udpAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", ip, 8989))
	if err != nil {
		fmt.Println("Err resolve UDP address: ", err)
		return
	}

	conn, err := net.DialUDP("udp", nil, udpAddr)
	if err != nil {
		fmt.Println("Dial UDP error: ", err)
		return
	}

	size, err := conn.Write([]byte("Hello UDP Server"))
	if err != nil {
		fmt.Printf("Send Msg Failed! error: %v\n", err.Error())
		return
	}
	fmt.Printf("Send Msg OK!size:%d\n", size)

	var buff [512]byte
	n, err := conn.Read(buff[0:])
	if err != nil {
		fmt.Println("ERR: ", err)
	}
	fmt.Printf("Read from server: %v\tlen(msg):%v\n", string(buff[:n]), n)

	//for {
	//	conn.Write([]byte("Hello server"))
	//	var buff [512]byte
	//	n, err := conn.Read(buff[0:])
	//	if err != nil {
	//		fmt.Println("ERR: ", err)
	//		break
	//	}
	//	fmt.Println("Read from server: ", string(buff[:n]))
	//}

}

9.4.2.3、本地编译,上传到10.211.55.122,10.211.55.123节点上去

Makefile参考内容,如下(一定要改成自己的)

build:
	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o udpclient ./client/main.go
	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o udpserver ./server/main.go

scp:
	scp udpclient root@10.211.55.122:/root
	scp udpserver root@10.211.55.123:/root

all:
	make build && make scp
9.4.2.4、登陆到10.211.55.122节点上, 进行抓包
tcpdump -nn -i tap99 -w udp.pcap

在这里插入图片描述

9.4.2.5、登陆到10.211.55.123节点上,启动UDP服务器端服务
./udpserver 

在这里插入图片描述

9.4.2.6、登陆到10.211.55.122节点上,启动UDP客户端服务
./udpclient 

在这里插入图片描述

9.4.2.7、将抓包结果上传到wireshark里查看

在这里插入图片描述

9.4.2.8、在10.211.55.122节点上查看一下vpn客户端日志 并跟 Wireshark里解析的数据包进行对比

在这里插入图片描述

10、ICMP报文

10.1、ICMP封包过程介绍

在这里插入图片描述

10.2、ICMP报文结构介绍

在这里插入图片描述

ICMP报文存在多个类型,不同的类型,ICMP头部属性并不相同。

这里画的是TYPE为8的情况下。

ICMP报文的类型,可以参考下面的文章

https://www.51cto.com/article/207507.html
http://t.zoukankan.com/linfeng-learning-p-12511225.html
https://blog.csdn.net/cixieku3433/article/details/100353399/

10.3、使用golang解析UDP报文头部结构

10.3.1、main.go文件里

在这里插入图片描述

10.3.2、icmpheader.go文件里

在这里插入图片描述

10.4、测试

本次测试的ICMP协议,因此,直接使用ping命令即可,

同样,使用Wireshark软件对ICMP报文进行分析

查看,我们使用golang解析的ICMP报文是否正确

10.4.1、启动helloworld级别的点对点VPN服务

将前一篇文章中的测试用例1启动即可。

前面文章中,已经说明了启动方式。

如果已经启动了,忽略即可。

10.4.2、登录到10.211.55.122客户端上

10.4.2.1、设置抓包命令
tcpdump -nn -i tap99 -w icmp.pcap

在这里插入图片描述

10.4.2.2、使用ping命令创建ICMP数据包

在10.211.55.122节点上ping 10.211.55.123节点上的tap99虚拟网络设备

ping -c 1 10.244.3.3 -I tap99

在这里插入图片描述

10.4.2.3、将抓包结果上传到wireshark里查看

在这里插入图片描述

10.4.2.4、在10.211.55.122节点上查看一下vpn客户端日志 并跟 Wireshark里解析的数据包进行对比

在这里插入图片描述

11、ARP报文

11.1、ARP报文封装过程

在这里插入图片描述

注意:

ARP报文并非是使用IP协议进行封装的。

而是,直接封装到以太网帧里。

11.2、ARP报文结构介绍

在这里插入图片描述

ARP报文跟其他协议报文还是有很大的区别的。

注意到了没,ARP报文是没有头部结构这一说的。

ARP报文的属性,以及占用的字节都已经固定好了。

11.3、使用golang解析arp报文结构

11.3.1、main.go文件里

在这里插入图片描述

11.3.2、arpheader.go文件里

不再介绍了,原理跟解析其他报文完全一样。

很清晰。

11.4、测试

本次测试的arp协议;

根据上面的ARP报文原理,我们已经知道为什么会产生了ARP报文了,

因此,我们可以继续使用ping来测试。

11.4.1、启动helloworld级别的点对点VPN服务

将前一篇文章中的测试用例1启动即可。

前面文章中,已经说明了启动方式。

如果已经启动了,忽略即可。

11.4.2、登录到10.211.55.122客户端上

11.4.2.1、设置抓包命令
tcpdump -nn -i tap99 -w icmp.pcap

在这里插入图片描述

11.4.2.2、使用ping命令创建ICMP数据包

在10.211.55.122节点上ping 10.211.55.123节点上的tap99虚拟网络设备

ping -c 1 10.244.3.3 -I tap99

在这里插入图片描述

11.4.2.3、将抓包结果上传到wireshark里查看

在这里插入图片描述

11.4.2.4、在10.211.55.122节点上查看一下vpn客户端日志 并跟 Wireshark里解析的数据包进行对比

在这里插入图片描述

12、总结

本篇文章主要是介绍了一下ICMP、TCP、UDP、ARP协议的封装过程,以及如何通过golang去解析报文结构。

TCP、UDP、ICMP协议的整体封装过程是一样的;都是封装在IP协议里;再将IP协议封装到以太网帧中。

而ARP协议是直接封装到以太网帧中的。

13、参考文档


<<零入门kubernetes网络实战>>技术专栏之文章目录


  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码二哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值