Rust 重写 40 多年前的 Ping 命令
这篇文章会介绍如何使用 Rust 来显示 Linux 中的 ping
命令。先介绍 ping
命令的使用,然后介绍 ICMP 协议,最后用 Rust 来编写一个基础版本的 ping
命令。
ping 命令不为人知的历史
在工作中你一定问过或者听过这样的问题:怎么 ping
不通了?是的,我们经常使用 ping
命令来测试某个域名或 IP 的连通性,以及时延。
可是你知道 ping
命令的历史吗?它在 1983 年由 Mike Muuss 编写的,当时他在美国陆军弹道导弹研究实验室工作,他为了测试网络连接问题开发了这个被使用了 40 多年的命令。
多么令人羡慕,我多么希望自己的代码被运行 40 年。
那么为什么叫做 ping
呢?不知道,有两种说法:
- PING 是英文 Packet InterNet Groper (数据包互联网探测器)的缩写;
- PING 像是潜艇声纳发出来的声音,而 ping 是通过发送数据包来探测网络中的其他设备的,就像是声纳一样,所以使用 ping 来命名。
作者曾开玩笑,如果早知道这个 ping 那么流行,就应该为它申请一个专利。如果要看最初的源码,可以访问 kbaribeau/gist:4495181 。
但是作者本身并不像这个 ping 命令一样长寿,在 ping 命令诞生 17 年后,也就是 2000 年 12 月,死于一场交通事故。后来他的家人和约翰斯·霍普金斯大学设立“Michael J. Muuss Research Award”奖项,用以纪念这位计算机科学家。
Ping 命令的基础使用
ping 命令非常简单, -c
指定发送的数据包数量:
ping -c 5 juejin.cn
-i
:发送的时间间隔,单位为秒;-s
:指定数据包的大小;-W
:指定超市的时间;
另外需要知道,不通的操作系统实现也有所差别。例如 Windows 中,如果要持续请求的话需要加上 -t
选项,而 Linux/Mac 默认会一直请求。
例如 ping [juejin.cn](<http://juejin.cn>)
输出如下:
PING juejin.cn.queniusz.com (112.122.156.196): 56 data bytes
64 bytes from 112.122.156.196: icmp_seq=0 ttl=56 time=17.573 ms
64 bytes from 112.122.156.196: icmp_seq=1 ttl=56 time=14.775 ms
64 bytes from 112.122.156.196: icmp_seq=2 ttl=56 time=11.700 ms
64 bytes from 112.122.156.196: icmp_seq=3 ttl=56 time=12.313 ms
需要关注的是 ttl
和 time
两个数值:
time
表示请求往返的时间;ttl
表示数据包的最大跳数,每经过一个路由器 TTL 值就减 1, 默认是 64。
比如我 ping
本地的路由器,ttl
返回的就是 64:
$ ping 192.168.150.1
PING 192.168.150.1 (192.168.150.1): 56 data bytes
64 bytes from 192.168.150.1: icmp_seq=0 ttl=64 time=7.577 ms
ICMP 协议详解
如果 ping
不通,一定是网络不通吗?不一定,也有可能是目标在防火墙中禁止了 ICMP 协议的请求。
既然我们要实现 ping
命令,就必须要详细了解一下 ICMP(Internet Control Message Protocol)协议。在 TCP/IP 协议栈中是非常重要的,主要作用如下:
- 报告网络错误和异常;
- 提供网络诊断功能;
- 协助路由器和主机进行网络管理;
- 支持网络连通性测试;
ICMP 数据包的格式如下:
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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Type | Code | Checksum |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Rest of Header |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
其包含 8 个字节的固定 Header 和可变长度的 Data:
- Type(8位):指定 ICMP 消息的类型;
- Code(8位):提供特定消息类型的详细信息;
- Checksum(16位):用于错误检测的校验和;
- Rest of Header(32位):根据消息类型的不同而不同,是附加消息;
其中常见的消息类型如下表所示:
Type | 消息名称 | 描述 | 常见用途 |
---|---|---|---|
0 | Echo Reply | 回显应答 | ping 命令响应 |
3 | Destination Unreachable | 目标不可达 | 网络/主机/端口不可达错误报告 |
4 | Source Quench | 源端被关闭 | 流量控制(已废弃) |
5 | Redirect | 重定向 | 路由优化通知 |
8 | Echo Request | 回显请求 | ping 命令测试连通性 |
9 | Router Advertisement | 路由器通告 | 路由器发现协议 |
10 | Router Solicitation | 路由器请求 | 主机请求路由器信息 |
11 | Time Exceeded | 超时 | TTL 超时,traceroute 使用 |
12 | Parameter Problem | 参数问题 | IP 头部错误报告 |
13 | Timestamp Request | 时间戳请求 | 时间同步 |
14 | Timestamp Reply | 时间戳应答 | 时间同步响应 |
比如当我们使用 ping
命令的时候,请求阶段会发送 Type 8, 如果成功响应则回复 Type 0, 失败则回复 Type 3。而 Type 3 包含下表所示的这些 Code:
Code | 描述 | 含义 | 应用场景 |
---|---|---|---|
0 | Network Unreachable | 网络不可达 | 路由器无法找到目标网络 |
1 | Host Unreachable | 主机不可达 | 网络可达但主机不响应 |
2 | Protocol Unreachable | 协议不可达 | 目标主机不支持指定协议 |
3 | Port Unreachable | 端口不可达 | 目标端口没有应用程序监听 |
4 | Fragmentation Needed and DF Set | 需要分片但设置了 DF 标志 | MTU 发现机制 |
5 | Source Route Failed | 源路由失败 | 严格源路由无法完成 |
6 | Destination Network Unknown | 目标网络未知 | 路由表中无目标网络信息 |
7 | Destination Host Unknown | 目标主机未知 | 无法解析目标主机 |
8 | Source Host Isolated | 源主机被隔离 | 源主机网络配置问题 |
9 | Network Administratively Prohibited | 网络被管理禁止 | 防火墙或 ACL 阻止 |
10 | Host Administratively Prohibited | 主机被管理禁止 | 目标主机拒绝连接 |
11 | Network Unreachable for TOS | 指定 TOS 的网络不可达 | 服务类型路由问题 |
12 | Host Unreachable for TOS | 指定 TOS 的主机不可达 | 服务类型主机问题 |
13 | Communication Administratively Prohibited | 通信被管理禁止 | 策略阻止通信 |
ICMP 协议本身并不复杂。对于我们实现 ping
命令来说,了解上面这些就够了。
Ping 命令实现
接下来,我们就来实现 ping
命令,1983 年的 ping
是用 C 写的,有 1000 多行代码。用 Rust 重写,代码量会少很多。我们会实现如下功能:
- 支持 IPv4 地址和域名的解析;
- 可配置发送包数量、间隔、超时时间;
- 实时显示 ping 的结果;
- 统计信息(丢包率、最小/平均/最大延迟);
ctrl+c
优雅退出;
程序最终的流程如下图所示:
1. 添加依赖
首先我们在 Cargo.toml
加入一些 crate:
[package]
name = "rs-ping"
version = "1.0.0"
edition = "2024"
[dependencies]
clap = { version = "4.5.43", features = ["derive"] }
socket2 = { version = "0.6.0", features = ["all"] }
rand = "0.9.2"
dns-lookup = "2.1.0"
libc = "0.2.174"
version 和 features 尽量和我一样,比如说 socket2,低版本和我当前的版本之间有差异,默认是没有启用 Type::RAW
的。
2. 实现命令行
命令行我们使用强大的 clap
就可以了,我们重造 ping
的轮子,而不是 clap
。首先定义命令行的参数:
use clap::Parser;
#[derive(Parser)]
#[command(name = "ping")]
#[command(about = "A ping implementation in Rust")]
struct Args {
target: String,
#[arg(short = 'c', long="count", default_value = "4")]
count: u32,
#[arg(short = 'i', long = "interval", default_value = "1")]
interval: f64,
#[arg(short = 'W', long = "timeout", default_value = "3")]
timeout: f64,
#[arg(short = 's', long = "size", default_value = "56")]
size: usize,
}
fn main() {
let _ = Args::parse();
}
这些参数应该不需要多做解释,上面讲解 ping
命令的时候都提到过了。
3. 解析目标地址
通过命令行传入的 target
可能是 IP 也可能是域名。所以第一步我们需要解析目标地址,如果是域名,则需要解析为 IP。所以我们需要实现一个名为 resolve_hostname
的方法:
fn resolve_hostname(hostname: &str) -> IpAddr {
if let Ok(ip) = hostname.parse::<Ipv4Addr>() {
return IpAddr::V4(ip)
}
let ips = dns_lookup::lookup_host(hostname).unwrap();
for ip in ips {
if let IpAddr::V4(ip) = ip {
return IpAddr::V4(ip);
}
}
unreachable!()
}
在这个方法中,我们通过 dns_lookup
这个 crate
来完成 dns 解析,为了减少代码量,这边暂时不管 IPv6 。然后在 main
函数调用这个方法:
fn main() {
let args = Args::parse();
let target_ip = resolve_hostname(args.target.as_str());
println!("{}", target_ip);
}
然后测试一下:
$ cargo run -- juejin.cn
116.196.143.218
4. 创建 Pinger 数据结构
接下来就进入核心部分的实现了,创建一个数据结构名为 Pinger
:
pub struct Pinger {
socket: Socket,
target: Ipv4Addr,
id: u16,
payload: Vec<u8>
}
这个结构体中包含如下这些成员:
socket
:在创建Pinger
的时候就初始化socket
,这样可以优化性能,避免内核重复去创建 Socket,保持状态;target
:就是目标 IP;id
:收到响应的时候,需要拿id
和seq
进行匹配,确认多个响应时来自同一个请求;payload
:前面 8 个字节时u64
的 Unix 时间戳,剩余按序填充。这是 BSD-ish 的做法,不过 BSD 中的 ping 的时间戳可能是毫秒;
然后创建 Pinger
的两个方法:
impl Pinger {
pub fn new(target: Ipv4Addr, payload_size: usize) -> Self
pub fn ping()
}
5. 实现 New 方法
最先实现 new
方法,这个方法的作用是初始化 socket
、生成随机的 id
以及填充 payload
:
pub fn new(target: Ipv4Addr, payload_size: usize, timeout: Duration) -> std::io::Result<Self> {
// RAW ICMPv4 socket(macOS/BSD 上可用)
let socket = Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::ICMPV4))?;
socket.set_nonblocking(false)?;
socket.set_read_timeout(Some(timeout))?;
socket.set_write_timeout(Some(timeout))?;
// 随机 id(Echo 的 Identifier)
let id = rand::random::<u16>();
// 准备 payload:前 8 字节放时间戳(秒),剩余填充
let mut payload = Vec::with_capacity(payload_size);
let ts = std::time::SystemTime
::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
payload.extend_from_slice(&ts.to_be_bytes());
for i in 8..payload_size {
payload.push((i % 256) as u8);
}
if payload.len() < payload_size {
payload.resize(payload_size, 0);
}
Ok(Self { socket, target, id, payload, seq: 0 }
}
这里需要着重说明的是,我们创建 Socket 使用,指定 Type::RAW
,它指的是自己构造 IP/输出层的头,因为 ICMP 工作在网络层,而不是传输层,既不属于 TCP 也不是 UDP。 通过 Type::RAW
和 Protocol::ICMPV4
的组合,可以直接构造并发送 ICMP Echo Request,接收 Echo Reply。常见 Type 如下表所示:
Type | 对应常量 | 是否面向连接 | 是否可靠/保序 | 是否保留消息边界 | 典型用途 |
---|---|---|---|---|---|
Type::STREAM |
SOCK_STREAM |
是 | 可靠、保序 | 否,字节流 | TCP |
Type::DGRAM |
SOCK_DGRAM |
否 | 不可靠、可能乱序 | 是,数据报 | UDP |
Type::RAW |
SOCK_RAW |
否 | 依协议而定 | 是,数据报 | ICMP或自己构造 IP/传输层的头 |
Type::SEQPACKET |
SOCK_SEQPACKET |
是 | 可靠、保序 | 是,定长/变长分节 | SCTP、AF_UNIX seqpacket |
使用
Type::RAW
,这是一个危险的操作,所以当你调试程序的时候需要 Root 权限,加上sudo
。但是你是否奇怪,我们在 Linux 或者 macOS 执行ping
为什么不需要加上sudo
呢?后文分解。
6. 实现 ping
接下来就是实现 ping_once
这个核心方法了!想想是不是还有点小激动呢?其实也没有几行代码,从原理上来将,使用我们在 new
方法中构造的 socket
发送并接收消息就行了。
先来看代码,主要是构造 ICMP Echo Request,然后计算校验和,接着发送数据包,最后接受数据:
pub fn ping_once(&mut self) -> std::io::Result<(f64, Option<u8>, usize)> {
self.seq = self.seq.wrapping_add(1);
// 构造 ICMP Echo Request:type(8) code(0) csum(2) id(2) seq(2) + payload
let mut packet = Vec::with_capacity(8 + self.payload.len());
packet.push(8); // type: 8 (echo request)
packet.push(0); // code: 0
packet.extend_from_slice(&[0, 0]); // checksum 占位
packet.extend_from_slice(&self.id.to_be_bytes());
packet.extend_from_slice(&self.seq.to_be_bytes());
packet.extend_from_slice(&self.payload);
// 计算校验和并回填
let csum = icmp_checksum(&packet);
packet[2] = (csum >> 8) as u8;
packet[3] = (csum & 0xff) as u8;
// 构造目标地址并发送
let t0 = Instant::now();
let addr = SockAddr::from(SocketAddrV4::new(self.target, 0));
let _sent = self.socket.send_to(&packet, &addr)?;
let mut buf = [MaybeUninit::<u8>::uninit(); 2048];
// 接收
loop {
let (n, _from) = self.socket.recv_from(&mut buf)?;
let buf: &[u8] = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, n) };
// 解析:macOS/BSD 上通常不含 IP 头;Linux 上含 IP 头
let (icmp_off, ttl_opt) = guess_icmp_offset_and_ttl(&buf[..n]);
if n < icmp_off + 8 {
continue;
}
let icmp = &buf[icmp_off..];
// type 是 Rust 关键字,所以前面加上 `r#`
let r#type = icmp[0];
let code = icmp[1];
let _recv_csum = u16::from_be_bytes([icmp[2], icmp[3]]);
let recv_id = u16::from_be_bytes([icmp[4], icmp[5]]);
let recv_seq = u16::from_be_bytes([icmp[6], icmp[7]]);
// 只接受 Echo Reply,且 id/seq 匹配
if r#type == 0 && code == 0 && recv_id == self.id && recv_seq == self.seq {
let rtt = t0.elapsed().as_secs_f64() * 1000.0;
return Ok((rtt, ttl_opt, n.saturating_sub(icmp_off)));
}
// 否则继续收,直到匹配或超时(由 SO_RCVTIMEO 触发上层 Err)
}
}
7. id 和 seq 详解
seq
在 new
方法中我们初始化为 0,然后每发送一次递增。这主要是在接受到消息之后能够唯一匹配是哪一次请求发送出去包。而之前到 id
是一个 ping
进程的唯一标识。id+seq
就可以确定一个 ping
进程的一次请求。(你想想,如果你的系统中还有其他用户执行 ping
命令呢?在生产中完全是有可能的,是吧?)。
为什么这里不直接使用 +
运算符,而是使用 wrapping_add
方法呢?这是一个小技巧:因为在 ICMP 协议中 seq
是 u16
类型的,最大是 65535。使用这个方法,如果发生溢出,会自然会到 0。这个技巧在系统编程中是非常重要的。
而在 Linux 或者 macOS 中,可能是通过
pid & 0xffff
这样的算法产生一个固定的 id 的。
构造 ICMP Echo Request
发送一个 ICMP 的数据包, type
是一个字节吧,值时 8, 而 code
也是一个字节,值是 0,csum
和 id 、 seq
都是 2 个字节。其中 id
是固定的值,而 seq
每次请求递增。所以一个数据包的大小就是 8 个字节加上 payload
的长度。
let mut packet = Vec::with_capacity(8 + self.payload.len());
8. checksum 详解
我们已经构造好了 packet
,然后需要将 checksum(校验和)填充进去。代码如下:
let csum = icmp_checksum(&packet);
packet[2] = (csum >> 8) as u8;
packet[3] = (csum & 0xff) as u8;
icmp_checksum
这个方法如下:
fn icmp_checksum(data: &[u8]) -> u16 {
// checksum 只需要 2 个字节,而这里却使用 u32(4字节),
// 这是因为还需要包含累加过程中的进位
let mut sum: u32 = 0;
// 按照两个字节拆分为若干的 u16
let mut chunks = data.chunks_exact(2);
// 对每组 chunk 求和
for c in &mut chunks {
let w = u16::from_be_bytes([c[0], c[1]]) as u32;
sum = sum.wrapping_add(w);
}
// 对奇数求和
if let Some(&rem) = chunks.remainder().first() {
let w = u16::from_be_bytes([rem, 0]) as u32;
sum = sum.wrapping_add(w);
}
while (sum >> 16) != 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
!(sum as u16)
}
结合代码和上面的示意图,我们来详细说一下 ICMPv4 校验和的规则如下:
把整个 ICMP 报文(头 + 数据)报文,按照 16位(2字节)切分位若干个 u16
。这在代码中对应的是 let mut chunks = data.chunks_exact(2)
;
每组 chunk
需要进行求和,使用大端序。但是 CPU 本地的字节序不一定是大端序,也可能是小端序。如果是x86/x86-64
是小端序,而 ARM
既支持大端序也支持小端序,而一些服务器架构则常用小端序……因此,通过 u16::from_be_bytes
方法统一为 u32
(采用大端序),在一些小端序的机器上,u16::from_be_bytes
会对字节做一次交换,从而按大端序解释两个字节。最后累加;
然后是对奇数求和,比如一共有 5 个字节,那么 chunks.remainder
获取的就是最后一个字节。按照 RFC 1071(Checksum standard) 的规定,需要将最后一个字节放在高 8 位,低 8 位补零;
最后的 while
以及 !(sum as u16)
实现的是 ICMP Checksum 算法中的进位回卷(carry around)以及按位取反。
我们将每两个字节(u16
)累加,可能会导致超过 u16
所能表示的范围( 2 的 16 次方,也就是 65536,用十六进制就是 0xffff
), 导致溢出,这也是 sum
采用 u32
而不是 u16
的原因。
sum >> 16
就是取出 sum
的高 16 位(也就是进位部分),如果它等于 0 的话,说明没有溢出,不做处理。否则,通过 sum & 0xffff
获取 sum
的低 16 位,然后将高位进位加到低位(sum & 0xffff)+ (sum >> 16)
。
之所为这里使用 while
是因为防止进位后仍然产生新的进位。但是理论上是不会的,这么做是 RFC 推荐的做法,循环是用来保证没有进位为止。
最后取反。计算出了 checksum,然后将其写入到数据包中,两个字节分别是 packet[2]
和 packet[3]
,至于 csum >> 8
是取出第 1 个字节,csum & 0xff
是取出第 2 个字节:
let csum = icmp_checksum(&packet);
packet[2] = (csum >> 8) as u8;
packet[3] = (csum & 0xff) as u8;
9. 发送请求
接着,我们就到了发送请求这一步了:
let addr = SockAddr::from(SocketAddrV4::new(self.target, 0));
let t0 = Instant::now();
let _sent = self.socket.send_to(&packet, &addr)?;
第一句构造目标地址,这里的 0
表示端口号,没有任何意义。因为 ICMP 是工作在 IP 层,所以不使用 TCP/UDP 的端口。
第二句是在 send_to
之前,记录一个 t0
的时间点,用来之后计算延迟。
然后通过 send_to
方法将这个 ICMP Echo Request 数据包发送到目标地址。而返回 _sent
表示实际饭送的字节数(大多数情况下是和我们的数据包的大小,也就是 packet.len()
是一致的。
但这里还是要说得更深入一些,在操作系统底层,RAW 类型的 Socket 会直接跳过 TCP/UDP
层,构造 IP 包,将我们之前构造的 packet
包放在 IP 数据部分(Payload)。自动加上 IP
头,标记协议字段为 1
(ICMP协议)。交给内核的 IP 层,进行路由后封装成二进制帧,然后转发给无力网卡,最后通过链路层发送出去。
10. 接收响应
发送了请求之后,就可以接收响应了。首先,我们需要创建接收数据的缓冲区,在 socket2 的 0.6 版本之后,使用 MaybeUninit<u8>
这个结构,下面的代码是不兼容低版本的:
let mut buf = [MaybeUninit::<u8>::uninit(); 2048];
let (n, _from) = self.socket.recv_from(&mut buf)?;
创建 buf
之后,阻塞调用 self.socket.recv_from
方法,来获取响应。但是不知道你是否存在疑问,为什么还要用 loop
去反复接收请求呢?难道一个请求会有多个响应吗?
是这样的,正常情况下是只会有一个响应。但是在复杂网络场景中,就可能会出现多个响应。比如说有多个并发的 ping、或者请求的是一个广播地址、或者网络中某个网络设备存在问题响应了多次等等。所以,这里要通过循环来接收数据包,并抛弃无效的数据包。
再来看下面的代码:
let buf: &[u8] = unsafe {
std::slice::from_raw_parts(buf.as_ptr() as *const u8, n)
};
因为 MaybeUninit
这个结构,它是一个 2048 字节的 buf。看名字就可以知道,它可能并没有完成初始化,如果我们直接去读当中的值,就会产生为定义行为(UB),这是一个 Unsafe 的操作,所以需要将其放在 unsafe {}
块中。
但是通过之前我们调用 self.socket.recv_from
方法,前面的 n
个字节已经由内核写入了数据。所以虽然放在 unsafe {}
块中,但这代码实际上是安全的。
11. 解析数据包
接收到数据包之后,我们需要对数据包进行解析。我们继续看下面解析的代码:
let (icmp_off, ttl_opt) = guess_icmp_offset_and_ttl(&buf[..n]);
if n < icmp_off + 8 {
continue;
}
不通的平台的网络协议栈的实现是不通的。这里需要调用 guess_icmp_offset_and_ttl
方法跨平台的兼容处理:
fn guess_icmp_offset_and_ttl(pkt: &[u8]) -> (usize, Option<u8>) {
if pkt.len() >= 20 {
let ver = pkt[0] >> 4;
let ihl = (pkt[0] & 0x0f) as usize;
if ver == 4 && ihl >= 5 {
let ip_header_len = ihl * 4;
// IP 协议为 ICMP(1) 的概率高;无法严格校验就尽量容错
let ttl = pkt.get(8).copied(); // TTL 在 IPv4 头第 9 字节
if pkt.len() >= ip_header_len + 8 {
return (ip_header_len, ttl);
}
}
}
// 退化:直接认为没有 IP 头(BSD/macOS 的常见情形)
(0, None)
}
在 Linux 中,RAW + ICMP 通常会携带 IPv4
的头,而 BSD 或者 macOS 通常是不带的。所以,这个方法中,使用首字节 ver
和 ihl
进行粗略的判断:如果像是 IPv4
,则 ip_header_len = ihl * 4
, 返回 ip_header_len
以及 ttl
。否则,就认为不包含 IP Header。
如果 n < icmp_off + 8
则丢弃这个数据包,这是因为 ICMP 的头至少需要 8 个字节(前面已经分析过了,包括 Type、Code、Checksum、Id 以及 Seq。
最后,解析数据包,这代码还是比较好理解的,就是按照字节序来读取:
let icmp = &buf[icmp_off..];
let r#type = icmp[0];
let code = icmp[1];
let _recv_csum = u16::from_be_bytes([icmp[2], icmp[3]]);
let recv_id = u16::from_be_bytes([icmp[4], icmp[5]]);
let recv_seq = u16::from_be_bytes([icmp[6], icmp[7]]);
解析数据包之后,需要和 id
以及 seq
进行匹配,如果不一致,则判断为无效的数据包,等待接收下一个数据包。如果匹配,则直接返回接收到的数据包:
if r#type == 0 && code == 0 && recv_id == self.id && recv_seq == self.seq {
let rtt = t0.elapsed().as_secs_f64() * 1000.0;
return Ok((rtt, ttl_opt, n.saturating_sub(icmp_off)));
}
到这里,我们就已经完成了 Ping 的最基础的实现了。
这个程序还存在哪些不足
虽然,我们已经完成了 ping 的核心代码,但是受限于文章的篇幅以及写作的目的,到这里要画一下一个休止符了。
首先是目的已经达到了,这篇文章主要目的是用 Rust 来重写 ping 的核心功能,通过重复制轮子来理解 Rust 网络编程、理解 Socket 编程、ICMP 协议。我想我已经非常详细的讲解并实现了这个过程中的每一个知识点。
完整可运行的代码可以看 GitHub。
另外,还存在如下不足:
- 跨平台支持不足,目前只保证能够在 macOS 上成功运行,Linux 没有测试过;
- 目前采用的是阻塞 I/O,不支持异步 I/O;
- 没有实现对 ping 的时延最大值、最小值、平均值之类的统计;
- 没有完善的异常错误处理机制;
- 没有单元测试;
对于这些缺失的能力,我有时间会再来补充,也欢迎提交 PR。
总结
总的来说,这个程序通过 200 多行代码已经完成了 ping 的核心功能,并且表现不错,来看一张截图:
通过这个程序,可以深入理解 ICMP 协议的实现,以及使用 Rust 进行 Socket 网络编程。
本作品采用《CC 协议》,转载必须注明作者和本文链接
推荐文章: