手把手教你用Go实现Ping操作

​ 这次我们来看一下什么是 Ping 操作,以及它有什么用处,并且我们来动手实现一个简易版的 Ping 工具。

Ping 是什么?

ping 是一个计算机网络工具,通常用于测试网络连接的可达性和测量往返时间。在大多数操作系统中,ping 命令是一个内置的命令行工具,可以通过命令行终端使用。例如,在 Windows 操作系统中,你可以在命令提示符中运行 ping 命令,而在类 Unix 操作系统(如 Linux 和 macOS)中,你可以在终端中使用 ping 命令。通常,命令的语法是 ping 目标主机或 IP,然后命令将输出与目标主机的通信状态和 RTT 相关的信息。

Ping 有什么用处?

Ping 工具主要有以下几个主要用途:

  1. 测试主机的可达性ping 命令用于检查另一个主机是否可以在网络上访问。它向目标主机发送一个小的数据包(通常是 ICMP Echo Request),如果目标主机正常工作,它将响应一个回复数据包(通常是 ICMP Echo Reply)。如果没有响应,那么目标主机可能无法访问或处于离线状态。
  2. 测量往返时间(RTT)ping 命令通常会显示每次请求和响应之间的时间差,这被称为往返时间(RTT)。这个值表示了数据从发送端到接收端的往返延迟,通常以毫秒为单位。测量 RTT 对于评估网络性能和延迟非常有用。
  3. 网络故障排除ping 是网络故障排除的有用工具之一。通过检查 ping 的输出,网络管理员可以确定网络连接是否正常,以及延迟是否在可接受范围内。如果 ping 失败,管理员可以进一步调查网络故障的原因。
  4. 监测网络稳定性ping 命令还可以用于监测网络的稳定性。通过连续地向目标主机发送 ping 请求,可以了解网络连接的质量和稳定性。如果出现不稳定性,管理员可以及时采取措施。

动手实现一个 Ping 工具

​ 首先,我们要了解一下 Ping 操作的工作原理:向网络上的另一个主机系统发送 ICMP 报文,如果指定系统得到了报文,它将把回复报文传回给发送者。

​ 先来看看 ICMP 报文长什么样:

ICMP报文

​ ICMP 报文由 ICMP 报文头 和 数据包组成,其报文头包含 Type、Code、Checksum、ID、SequenceNum 字段。因此,我们需要先在本地主机上定义 ICMP 请求报文结构体:

1
2
3
4
5
6
7
type ICMP struct {
Type uint8 // 类型
Code uint8 // 代码
CheckSum uint16 // 校验和
ID uint16 // ID
SequenceNum uint16 // 序号
}

​ 上面只是 ICMP 的报文头,我们在后面还需要为这个报文构建请求数据。需要注意的是,定义的顺序不能乱,因为我们发送数据包是按字节发送的,所以获取对应的字段的时候,也是按照对应字段的位置去获取的,如果顺序乱了,获取到的数据就会出错。

​ 在构建数据之前,我们先设置好命令行参数,以获取对应参数和目标 IP,同时需要定义全局变量,将命令行参数绑定到对应的变量中,方便使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var (
helpFlag bool
timeout int64 // 耗时
size int // 大小
count int // 请求次数
)

func GetCommandArgs() {
flag.Int64Var(&timeout, "w", 1000, "请求超时时间")
flag.IntVar(&size, "l", 32, "发送字节数")
flag.IntVar(&count, "n", 4, "请求次数")
flag.BoolVar(&helpFlag, "h", false, "显示帮助信息")
flag.Parse()
}

​ 在 main 函数中,启用命令行参数设置:

1
2
3
func main() {
GetCommandArgs()
}

​ 在发送报文前,我们需要先建立连接,此时需要先获取目标 IP,这个由命令行参数中获取:

1
2
3
4
5
6
7
8
9
10
11
// 获取目标 IP
desIP := os.Args[len(os.Args)-1]
// 构建连接
conn, err := net.DialTimeout("ip:icmp", desIP, time.Duration(timeout)*time.Millisecond)
if err != nil {
log.Println(err.Error())
return
}
defer conn.Close()
// 远程地址
remoteaddr := conn.RemoteAddr()

​ 连接建立后,我们需要根据参数中的发送次数 count 去发送对应次数的报文,因此需要用 for 去做:

1
2
3
for i := 0; i < count; i ++ {
...
}

​ 通过百度百科可以查到,我们要使用的是 Ping 请求,即回显请求,其对应的 Type 和 Code 如下:

ICMPPing请求

​ 同样,我们在全局变量中添加对应的值:

1
2
3
4
var (
typ uint8 = 8
code uint8 = 0
)

​ 做好前面的准备工作,我们就可以开始构建我们的 ICMP 请求报文了。我们这里以发送的第几次作为 ID 和序列号:

1
2
3
4
5
6
7
icmp := &ICMP{
Type: typ,
Code: code,
CheckSum: uint16(0),
ID: uint16(i),
SequenceNum: uint16(i),
}

​ 由于 ICMP 是使用二进制进行传输的,所以我们需要将信息用二进制表示:

1
2
var buffer bytes.Buffer
binary.Write(&buffer, binary.BigEndian, icmp)

​ 然后根据发送数据的大小 size 构建数据并写在 ICMP 报文后面:

1
2
3
data := make([]byte, size)
buffer.Write(data)
data = buffer.Bytes()

​ 现在,就只差一个校验和字段了,计算 ICMP(Internet Control Message Protocol)报文的校验和字段遵循以下步骤:

  1. 将报文分为 16 位的字(两个字节)。
  2. 对所有字进行按位求和(二进制求和),包括数据部分和报文头。如果有剩余字节(奇数个字节),将其附加到最后一个字节。
  3. 将溢出的进位位(如果有)加回到结果中。
  4. 取结果的二进制反码(按位取反)

​ 代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func checkSum(data []byte) uint16 {
// 第一步:两两拼接并求和
length := len(data)
index := 0
var sum uint32
for length > 1 {
// 拼接且求和
sum += uint32(data[index])<<8 + uint32(data[index+1])
length -= 2
index += 2
}
// 奇数情况,还剩下一个,直接求和过去
if length == 1 {
sum += uint32(data[index])
}

// 第二部:高 16 位,低 16 位 相加,直至高 16 位为 0
hi := sum >> 16
for hi != 0 {
sum = hi + uint32(uint16(sum))
hi = sum >> 16
}
// 返回 sum 值 取反
return uint16(^sum)
}

​ 接着再将算出来的校验和放到报文头对应的位置中去,这里需要计算一下位置。假设我们有以下 ICMP 报文:

1
2
3
4
5
6
7
8
9
10
11
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Type | Code |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum (2 bytes) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Identifier (2 bytes) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number (2 bytes) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data (variable length) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

​ 校验和属于报文的第3、4个字节,即 data[2] 和 data[3]。

1
2
data[2] = byte(checkSum >> 8)
data[3] = byte(checkSum)

​ 最后再设置一下超时时间,就可以将数据 data 写入连接中了:

1
2
3
4
5
6
7
8
9
// 设置超时时间
conn.SetDeadline(time.Now().Add(time.Duration(timeout) * time.Millisecond))

// 将 data 写入连接中,
n, err := conn.Write(data)
if err != nil {
log.Println(err)
continue
}

​ 发送完成后,再构建缓冲接收响应包,

1
2
3
4
5
6
7
buf := make([]byte, 1024)
n, err = conn.Read(buf)
//fmt.Println(data)
if err != nil {
log.Println(err)
continue
}

​ 然后我们就可以从响应包中获取我们需要的数据,比如 IP 地址、TTL等:

icmp回复报文

​ 根据抓到的 ICMP 响应包,可以知道 IP 头共 20 个字节,源 IP 和 目标 IP 在我们接收的数据包的倒数 8 个字节里,所以我们可以推算出我们访问的 IP 地址,就可以构建我们的打印信息了:

1
fmt.Printf("来自 %d.%d.%d.%d 的回复:字节=%d 时间=%d TTL=%d\n", buf[12], buf[13], buf[14], buf[15], n-28, t, buf[8])

​ 至此,我们 Ping 工具的核心功能就实现了,还有一些统计信息,就不做具体的讲解了,感兴趣的可以从代码中看具体的实现。

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
package main

import (
"bytes"
"encoding/binary"
"flag"
"fmt"
"log"
"math"
"net"
"os"
"time"
)

// tcp 报文前20个是报文头,后面的才是 ICMP 的内容。
// ICMP:组建 ICMP 首部(8 字节) + 我们要传输的内容
// ICMP 首部:type、code、校验和、ID、序号,1 1 2 2 2
// 回显应答:type = 0,code = 0
// 回显请求:type = 8, code = 0

var (
helpFlag bool
timeout int64 // 耗时
size int // 大小
count int // 请求次数
typ uint8 = 8
code uint8 = 0
SendCnt int // 发送次数
RecCnt int // 接收次数
MaxTime int64 = math.MinInt64 // 最大耗时
MinTime int64 = math.MaxInt64 // 最短耗时
SumTime int64 // 总计耗时
)

// ICMP 序号不能乱
type ICMP struct {
Type uint8 // 类型
Code uint8 // 代码
CheckSum uint16 // 校验和
ID uint16 // ID
SequenceNum uint16 // 序号
}

func main() {
fmt.Println()
log.SetFlags(log.Llongfile)
GetCommandArgs()

// 打印帮助信息
if helpFlag {
displayHelp()
os.Exit(0)
}

// 获取目标 IP
desIP := os.Args[len(os.Args)-1]
//fmt.Println(desIP)
// 构建连接
conn, err := net.DialTimeout("ip:icmp", desIP, time.Duration(timeout)*time.Millisecond)
if err != nil {
log.Println(err.Error())
return
}
defer conn.Close()
// 远程地址
remoteaddr := conn.RemoteAddr()
fmt.Printf("正在 Ping %s [%s] 具有 %d 字节的数据:\n", desIP, remoteaddr, size)
for i := 0; i < count; i++ {
// 构建请求
icmp := &ICMP{
Type: typ,
Code: code,
CheckSum: uint16(0),
ID: uint16(i),
SequenceNum: uint16(i),
}

// 将请求转为二进制流
var buffer bytes.Buffer
binary.Write(&buffer, binary.BigEndian, icmp)
// 请求的数据
data := make([]byte, size)
// 将请求数据写到 icmp 报文头后
buffer.Write(data)
data = buffer.Bytes()
// ICMP 请求签名(校验和):相邻两位拼接到一起,拼接成两个字节的数
checkSum := checkSum(data)
// 签名赋值到 data 里
data[2] = byte(checkSum >> 8)
data[3] = byte(checkSum)
startTime := time.Now()

// 设置超时时间
conn.SetDeadline(time.Now().Add(time.Duration(timeout) * time.Millisecond))

// 将 data 写入连接中,
n, err := conn.Write(data)
if err != nil {
log.Println(err)
continue
}
// 发送数 ++
SendCnt++
// 接收响应
buf := make([]byte, 1024)
n, err = conn.Read(buf)
//fmt.Println(data)
if err != nil {
log.Println(err)
continue
}
// 接受数 ++
RecCnt++
//fmt.Println(n, err) // data:64,ip首部:20,icmp:8个 = 92 个
// 打印信息
t := time.Since(startTime).Milliseconds()
fmt.Printf("来自 %d.%d.%d.%d 的回复:字节=%d 时间=%d TTL=%d\n", buf[12], buf[13], buf[14], buf[15], n-28, t, buf[8])
MaxTime = Max(MaxTime, t)
MinTime = Min(MinTime, t)
SumTime += t
time.Sleep(time.Second)
}

fmt.Printf("\n%s 的 Ping 统计信息:\n", remoteaddr)
fmt.Printf(" 数据包: 已发送 = %d,已接收 = %d,丢失 = %d (%.f%% 丢失),\n", SendCnt, RecCnt, count*2-SendCnt-RecCnt, float64(count*2-SendCnt-RecCnt)/float64(count*2)*100)
fmt.Println("往返行程的估计时间(以毫秒为单位):")
fmt.Printf(" 最短 = %d,最长 = %d,平均 = %d\n", MinTime, MaxTime, SumTime/int64(count))
}

// 求校验和
func checkSum(data []byte) uint16 {
// 第一步:两两拼接并求和
length := len(data)
index := 0
var sum uint32
for length > 1 {
// 拼接且求和
sum += uint32(data[index])<<8 + uint32(data[index+1])
length -= 2
index += 2
}
// 奇数情况,还剩下一个,直接求和过去
if length == 1 {
sum += uint32(data[index])
}

// 第二部:高 16 位,低 16 位 相加,直至高 16 位为 0
hi := sum >> 16
for hi != 0 {
sum = hi + uint32(uint16(sum))
hi = sum >> 16
}
// 返回 sum 值 取反
return uint16(^sum)
}

// GetCommandArgs 命令行参数
func GetCommandArgs() {
flag.Int64Var(&timeout, "w", 1000, "请求超时时间")
flag.IntVar(&size, "l", 32, "发送字节数")
flag.IntVar(&count, "n", 4, "请求次数")
flag.BoolVar(&helpFlag, "h", false, "显示帮助信息")
flag.Parse()
}

func Max(a, b int64) int64 {
if a > b {
return a
}
return b
}

func Min(a, b int64) int64 {
if a < b {
return a
}
return b
}

func displayHelp() {
fmt.Println(`选项:
-n count 要发送的回显请求数。
-l size 发送缓冲区大小。
-w timeout 等待每次回复的超时时间(毫秒)。
-h 帮助选项`)
}

小结

​ 本文讲解了常用工具 Ping,并且从 ICMP 报文角度手把手教大家实现了一个简易版的 Ping 工具,在这个过程中大家可以收获到很多东西,希望大家能够自己动手实现一下,结果一定不会让你失望。


手把手教你用Go实现Ping操作
http://example.com/2023/10/31/Go/项目实战/Go实现Ping操作/
作者
Feng Tao
发布于
2023年10月31日
更新于
2023年11月13日
许可协议