Rust 重写 40 多年前的 Ping 命令

这篇文章会介绍如何使用 Rust 来显示 Linux 中的 ping 命令。先介绍 ping 命令的使用,然后介绍 ICMP 协议,最后用 Rust 来编写一个基础版本的 ping 命令。

ping 命令不为人知的历史

在工作中你一定问过或者听过这样的问题:怎么 ping 不通了?是的,我们经常使用 ping 命令来测试某个域名或 IP 的连通性,以及时延。

可是你知道 ping 命令的历史吗?它在 1983 年由 Mike Muuss 编写的,当时他在美国陆军弹道导弹研究实验室工作,他为了测试网络连接问题开发了这个被使用了 40 多年的命令。

多么令人羡慕,我多么希望自己的代码被运行 40 年。

那么为什么叫做 ping 呢?不知道,有两种说法:

  1. PING 是英文 Packet InterNet Groper (数据包互联网探测器)的缩写;
  2. 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

需要关注的是 ttltime 两个数值:

  • 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 :收到响应的时候,需要拿 idseq 进行匹配,确认多个响应时来自同一个请求;
  • 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::RAWProtocol::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 详解

seqnew 方法中我们初始化为 0,然后每发送一次递增。这主要是在接受到消息之后能够唯一匹配是哪一次请求发送出去包。而之前到 id 是一个 ping 进程的唯一标识。id+seq 就可以确定一个 ping 进程的一次请求。(你想想,如果你的系统中还有其他用户执行 ping 命令呢?在生产中完全是有可能的,是吧?)。

为什么这里不直接使用 + 运算符,而是使用 wrapping_add 方法呢?这是一个小技巧:因为在 ICMP 协议中 sequ16 类型的,最大是 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 通常是不带的。所以,这个方法中,使用首字节 verihl 进行粗略的判断:如果像是 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

另外,还存在如下不足:

  1. 跨平台支持不足,目前只保证能够在 macOS 上成功运行,Linux 没有测试过;
  2. 目前采用的是阻塞 I/O,不支持异步 I/O;
  3. 没有实现对 ping 的时延最大值、最小值、平均值之类的统计;
  4. 没有完善的异常错误处理机制;
  5. 没有单元测试;

对于这些缺失的能力,我有时间会再来补充,也欢迎提交 PR。

总结

总的来说,这个程序通过 200 多行代码已经完成了 ping 的核心功能,并且表现不错,来看一张截图:

通过这个程序,可以深入理解 ICMP 协议的实现,以及使用 Rust 进行 Socket 网络编程。

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!