JonasBirch-从零编写-Ping-笔记-全-

JonasBirch 从零编写 Ping 笔记(全)

001:用C语言从零编写PING程序

概述 📖

在本节课中,我们将要学习如何使用C语言从零开始编写一个PING程序。PING是一个用于测试网络连接的工具,它通过发送一个数据包到目标计算机并等待其回复,来验证两台计算机之间的网络是否通畅。我们将从理解ICMP协议开始,逐步构建我们自己的PING工具。

ICMP协议与PING原理 🔍

上一节我们介绍了PING程序的基本概念。本节中我们来看看PING程序背后的核心协议——ICMP。

PING程序使用ICMP协议。它发送一个类型为8的ICMP数据包,即“回显请求”。如果目标主机在线且网络通畅,它会回复一个类型为0的ICMP数据包,即“回显应答”。通过这个过程,我们可以测试网络连接。

一个ICMP数据包的基本结构如下:

  • 类型:8位字段,用于标识ICMP消息的类型。
  • 代码:8位字段,通常为0。
  • 校验和:16位字段,用于验证数据在传输过程中是否损坏。
  • 数据:可变长度的数据部分。

以下是ICMP数据包结构的代码表示:

struct icmp {
    uint8_t type;
    uint8_t code;
    uint16_t checksum;
    uint8_t data[];
} __attribute__((packed));

__attribute__((packed)) 确保编译器不对结构体进行内存对齐,使其大小与网络数据包格式完全一致。

创建ICMP数据包结构 🛠️

理解了ICMP数据包的结构后,我们需要在代码中定义它。我们将创建两个结构体:一个用于逻辑表示,另一个用于生成最终的原始字节流。

以下是核心数据结构的定义:

// ICMP数据包类型枚举
enum icmp_type {
    ICMP_TYPE_UNASSIGNED,
    ICMP_TYPE_ECHO,
    ICMP_TYPE_ECHO_REPLY
};

// 逻辑ICMP数据包结构
struct icmp_packet {
    enum icmp_type kind;
    uint16_t size;
    const uint8_t *data;
};

// 原始ICMP数据包结构(对应网络字节格式)
struct raw_icmp {
    uint8_t type;
    uint8_t code;
    uint16_t checksum;
    uint8_t data[];
} __attribute__((packed));

实现ICMP数据包构造函数 🧱

有了数据结构,我们需要一个函数来创建ICMP数据包对象。这个函数负责分配内存并初始化数据包。

以下是创建ICMP数据包的函数:

struct icmp_packet* make_icmp(enum icmp_type kind, const uint8_t *data, uint16_t size) {
    if (!data || size == 0) {
        return (struct icmp_packet*)0; // 返回空指针表示错误
    }
    struct icmp_packet *packet = malloc(sizeof(struct icmp_packet));
    assert(packet); // 确保内存分配成功
    zero(packet, sizeof(struct icmp_packet)); // 清零结构体
    packet->kind = kind;
    packet->size = size;
    packet->data = data;
    return packet;
}

计算ICMP校验和 🧮

校验和是网络数据包中用于验证数据完整性的关键部分。对于ICMP,我们需要计算整个数据包(包括头部和数据)的16位反码和。

以下是计算校验和的算法步骤:

  1. 将数据包内容视为一系列16位整数。
  2. 将这些整数相加(使用32位累加器以处理进位)。
  3. 将累加结果的高16位(进位)加到低16位上。
  4. 对最终的和取反码(按位取反),得到校验和。

以下是校验和计算函数的实现:

uint16_t checksum(const uint8_t *packet, uint16_t size) {
    uint32_t accumulator = 0;
    const uint16_t *p = (const uint16_t*)packet;
    // 将所有16位字相加
    for (uint16_t n = size; n > 1; n -= 2) {
        accumulator += *p++;
    }
    // 处理可能的奇数长度字节(填充0)
    if (n == 1) {
        accumulator += *(const uint8_t*)p;
    }
    // 将进位(高16位)加到结果(低16位)上
    accumulator = (accumulator >> 16) + (accumulator & 0xFFFF);
    accumulator += (accumulator >> 16);
    // 返回反码
    return (uint16_t)(~accumulator);
}

将逻辑包转换为原始字节流 ⚙️

最后一步是将我们创建的逻辑icmp_packet结构体转换为可以发送到网络上的原始字节序列。这个过程包括填充raw_icmp结构体、复制数据以及计算并填入校验和。

以下是转换函数的核心逻辑:

uint8_t* eval_icmp(const struct icmp_packet *packet) {
    if (!packet || !packet->data) return (uint8_t*)0;
    struct raw_icmp raw;
    // 根据包类型设置头部字段
    switch(packet->kind) {
        case ICMP_TYPE_ECHO:
            raw.type = 8; raw.code = 0; break;
        case ICMP_TYPE_ECHO_REPLY:
            raw.type = 0; raw.code = 0; break;
        default:
            return (uint8_t*)0;
    }
    raw.checksum = 0; // 先置零,计算后再填充
    // 分配内存并复制数据
    uint16_t total_size = sizeof(struct raw_icmp) + packet->size;
    uint8_t *raw_bytes = malloc(total_size);
    assert(raw_bytes);
    zero(raw_bytes, total_size);
    copy(raw_bytes, &raw, sizeof(struct raw_icmp));
    copy(raw_bytes + sizeof(struct raw_icmp), packet->data, packet->size);
    // 计算并设置校验和
    uint16_t cs = checksum(raw_bytes, total_size);
    ((struct raw_icmp*)raw_bytes)->checksum = cs;
    return raw_bytes;
}

测试我们的ICMP构建器 ✅

现在,让我们编写一个简单的测试程序来验证我们构建的ICMP数据包是否正确。

以下是测试代码:

int main() {
    // 1. 创建测试数据
    uint8_t *data = malloc(6); // "Hello" + 空字符
    assert(data);
    zero(data, 6);
    copy(data, "Hello", 5);
    // 2. 创建ICMP回显请求包
    struct icmp_packet *packet = make_icmp(ICMP_TYPE_ECHO, data, 5);
    show_icmp(packet); // 显示包信息
    // 3. 转换为原始字节
    uint8_t *raw = eval_icmp(packet);
    assert(raw);
    // 4. 以十六进制形式打印原始字节,便于分析
    print_hex(raw, sizeof(struct raw_icmp) + 5);
    // 5. 清理内存
    free(data);
    free(packet);
    free(raw);
    return 0;
}

运行此测试,如果输出显示类型为8(回显请求),并包含一个计算出的校验和以及数据“Hello”的十六进制表示,则说明我们的ICMP数据包构建器工作正常。

总结 🎯

本节课中我们一起学习了PING程序的基础——ICMP协议,并动手实现了ICMP数据包的构建。我们定义了数据结构,编写了构造函数、校验和算法以及将逻辑包转换为网络字节流的函数。虽然我们还没有实际发送和接收数据包,但已经为网络通信打下了坚实的基础。在下一节课中,我们将学习如何使用原始套接字来发送这个ICMP数据包并接收回复,从而完成我们的PING程序。

002:互联网协议

在本节课中,我们将继续PING项目的开发。上一节我们介绍了ICMP协议的基础,本节中我们将深入理解IP协议,并开始编写相关的代码。

网络协议分层概述

互联网协议被分为七层。最底层(第1层)最接近硬件,最高层(第7层)最接近应用程序。在本项目中,我们主要关注第3层和第4层的两个协议。

我们已经讨论过ICMP协议。今天,我们将重点介绍IP协议。

ICMP与IP协议结构

一个ICMP数据包的结构如下:它包含一个类型字段、一个代码字段、一个校验和字段以及一个数据字段。

如果我们发送一个PING数据包,类型字段将是8(回显请求)。当用户发送一个回显请求并收到回显应答时,PING过程就完成了。在这种情况下,代码字段为0。数据字段的内容可以由我们决定,通常包含一个用于区分不同数据包的ID号。校验和是一个通过特定数学公式计算得出的数字。

当收到回复时,类型字段变为0,这表示回显应答。

然而,我们不能只发送ICMP数据包。我们还需要包含寻址信息,这就是IP协议的作用。

IP数据包结构

一个IP数据包的结构如下。这里没有包含所有字段,只列出了最重要的部分。

首先,我们需要指定版本。你可能听说过IPv4(当前协议)和IPv6(互联网正在迁移的协议)。在本例中,我们处理的是IPv4,所以在这个字段中填入4。

TTL代表生存时间。通常我们一开始会设置一个较高的数字,如250或255。每当路由器转发这个数据包时,它会减少这个TTL字段的值。这样做的原因是,如果网络中存在环路,数据包不会无限循环。当TTL减到0时,数据包将被丢弃,从而停止环路。

源地址是发送方的IP地址。目的地址是接收方的IP地址。这些IP地址是常规整数,以所谓的网络字节序存储。这意味着一个点分十进制的IP地址A.B.C.D,在内存中会变成D.C.B.A的顺序。稍后我们会在代码中展示这一点。

校验和的计算方式与ICMP中的相同,使用相同的公式。

数据字段包含这个数据包的实际载荷。在我们的例子中,载荷就是整个ICMP数据包。我们将把整个ICMP数据包放入这个IP数据包的数据字段中,然后发送这个IP数据包。之后,我们会收到一个类似的数据包回复。

字节序处理

在继续编码之前,我们需要修复一个由观众指出的问题:当我们发送和接收数据时,数据是以大端字节序格式存储的。

大端字节序意味着最高有效位在左边,而我们的计算机通常使用小端字节序(最低有效位在右边)。因此,我们需要处理所有16位、32位、64位等数据,将它们进行字节序转换。

我们原始ICMP数据包中的大部分内容只是8位,但校验和需要处理。同样,当我们处理IP协议时,也需要处理更多字段。

以下是处理16位整数字节序转换的函数:

uint16_t ntoh16(uint16_t x) {
    uint8_t a, b;
    uint16_t y;
    b = x & 0x00FF;
    a = (x & 0xFF00) >> 8;
    y = (b << 8) | a;
    return y;
}

定义IP数据结构

现在,让我们开始为IP协议编写代码。首先定义我们需要的数据类型。

我们将定义一个结构体来存储我们感兴趣的信息。我们至少需要源IP地址和目的IP地址,可以将它们存储为32位整数。

我们还需要定义IP数据包的原始结构,包含所有必要的字段。以下是IP头结构的定义:

struct __attribute__((packed)) s_raw_ip {
    uint8_t version:4;
    uint8_t ihl:4;
    uint8_t dscp:6;
    uint8_t ecn:2;
    uint16_t length;
    uint16_t id;
    uint16_t flags:3;
    uint16_t fragment_offset:13;
    uint8_t ttl;
    uint8_t protocol;
    uint16_t checksum;
    uint32_t source;
    uint32_t destination;
    uint8_t options[0];
};

同时,我们定义一个更简洁的结构体来方便操作:

struct s_ip {
    enum {
        ICMP = 1,
        TCP = 6,
        UDP = 17
    } kind;
    uint32_t source;
    uint32_t destination;
    uint16_t id;
    void *payload;
};

创建IP数据包构造函数

接下来,我们创建一个构造函数来生成IP数据包。这个函数需要指定源地址、目的地址、ID和协议类型。

以下是make_ip函数的框架:

struct s_ip* make_ip(enum ip_kind kind, const char* source, const char* dest, uint16_t id) {
    // 分配内存
    // 转换字符串IP地址为网络字节序整数
    // 填充结构体字段
    // 返回结构体指针
}

在函数内部,我们需要将点分十进制的IP地址字符串转换为网络字节序的32位整数。我们可以使用标准C库函数,但为了教学,我们也可以自己实现一个简单的解析器。

如果ID为0,我们可以随机生成一个ID,或者使用一个递增的计数器。

实现数据包展示函数

为了方便调试,我们实现一个展示IP数据包内容的函数。

void show_ip(struct s_ip* pack, const char* identifier) {
    if (!pack) return;
    printf("IP Packet [%s]:\n", identifier);
    printf("  Kind: %d\n", pack->kind);
    printf("  ID: %u\n", pack->id);
    // 使用函数将整数IP转换回点分十进制字符串并打印
    printf("  Source: %s\n", int_to_dot(pack->source));
    printf("  Destination: %s\n", int_to_dot(pack->destination));
    if (pack->payload) {
        // 递归展示载荷(例如ICMP数据包)
        show_icmp(pack->payload, "Payload");
    }
}

我们还可以创建一个宏,根据传入的数据包类型自动调用正确的展示函数。

将IP结构体序列化为原始字节

最后,我们需要一个函数将我们的IP结构体转换为可以发送的原始字节序列(即序列化)。这个函数需要计算校验和,并处理字节序。

以下是eval_ip函数的框架:

uint8_t* eval_ip(struct s_ip* pack, size_t* out_size) {
    if (!pack) return NULL;
    // 计算总长度(IP头 + 载荷)
    // 分配内存缓冲区
    // 填充s_raw_ip结构体的各个字段,注意字节序
    // 如果存在载荷(如ICMP包),将其附加到IP头之后
    // 计算IP头的校验和并填入
    // 返回原始字节指针,并通过out_size返回长度
}

在填充字段时,版本设置为4,IHL(头部长度)在没有选项时是5(表示20字节)。总长度字段需要包含头部和载荷。TTL可以设置为一个标准值,如64。协议字段根据载荷类型设置(ICMP为1)。

校验和的计算需要将头部视为一个16位字的序列,求和后取反码。注意计算时校验和字段本身应设为0。

测试IP数据包构造

在编写完上述函数后,我们应该编写一个简单的测试程序来验证IP数据包的构造和序列化是否正确。

我们可以创建一个ICMP回显请求包,然后创建一个IP包,将ICMP包作为其载荷。最后,打印出原始字节以进行视觉检查,或使用网络分析工具验证。

总结

本节课中,我们一起学习了IP协议的基本结构及其在网络分层中的位置。我们定义了IP数据包的结构体,编写了构造函数、展示函数以及将结构体序列化为网络字节的函数。我们还处理了网络编程中至关重要的字节序问题。

下一节,我们将利用这些构建好的IP和ICMP数据包,开始使用原始套接字进行实际的网络发送和接收操作,最终完成我们的PING程序。

003:发送原始IP数据包

在本节课中,我们将学习如何创建一个 sendIP 函数,用于发送原始IP数据包。我们将从定义必要的变量和结构体开始,逐步构建发送逻辑,并最终成功发送一个包含ICMP回显请求的IP数据包。

概述

我们将创建一个函数来发送原始IP数据包。这涉及到构建IP和ICMP头部,计算数据包总长度,并使用 sendto 系统调用将数据包发送到网络。我们还会处理网络字节序等细节问题。

构建发送函数

首先,我们需要定义发送函数所需的基本变量和结构体。

我们将需要一个指向数据缓冲区的指针。首先,确认我们拥有所需的一切。如果没有原始套接字,我们需要立即退出。

我们需要一个 in_addr 结构体来处理地址。我们还需要一些 uint16_t 类型的变量来处理大小等信息。

我们还需要获取 sendto 函数的签名,因为我们可以使用多种不同的系统调用来发送IP数据包。sendmsg 要求我们填充一个庞大的结构体,而 send 可能更适用于常规的TCP连接。sendto 看起来很有希望,因为它允许我们提供文件描述符、包含要发送数据的缓冲区及其长度、标志以及目标地址信息。

实现数据包评估与发送

接下来,我们需要评估这个IP数据包。我们将创建一个 eval 函数来自动处理不同类型的数据包。

raw 变量应该等于 eval 函数的结果。我们提供数据包结构给 eval 函数,它就会准备好要发送的数据包。

然而,我们还需要计算数据包的长度。我们需要为此创建一个函数。数据包的总长度是IP头部结构体的大小加上ICMP结构体的大小,再加上负载的大小。

以下是计算大小的代码:

size = sizeof(struct iphdr) + sizeof(struct icmphdr) + payload_size;

现在,我们有了数据和大小。接下来,我们需要处理 sendto 的返回值。sendto 通常返回发送的字节数,如果出错则返回 -1。

我们需要检查返回值是否为 -1。如果发送失败,我们直接返回。

关于 sendto 的标志参数,MSG_DONTWAIT 表示非阻塞发送。在监听数据包时,我们至少需要使用这个标志,否则无法同时处理多个数据包的发送。但在当前测试阶段,顺序执行所有操作会更简单,所以我们暂时将标志设为 0。

测试发送功能

现在,我们可以使用我们的主函数进行测试。在打印出数据包信息后,我们尝试发送其中一个数据包。

我们需要创建一个变量来存储发送结果。我们调用 sendIP 函数,并提供我们构建好的原始数据包结构。

我们可能还无法看到回复,但这并不重要,重要的是我们提供了正确格式的真实IP地址。

编译并运行程序。我们遇到了一个错误:“Destination address required”。这意味着我们仍然需要在 sendto 调用中提供目标地址信息。

修正地址处理

我们需要修改 send 函数,稍微调整一下。我们需要构造一个 sockaddr 结构体。

首先清空该结构体。我们只需要提供实际的IP地址。在我们的IP结构体中,目标地址是一个已经是网络字节序的 in_addr_t。我们应该能够直接设置它。

我们需要将我们的IP地址赋值给 sockaddr_in 结构体的 sin_addr 字段。

修正后,再次运行程序。这次我们得到了 true 的响应,意味着发送调用成功了。

调试与修正数据包内容

虽然发送成功了,但使用抓包工具查看时,我们发现收到的数据包显示“IP 5 is invalid”。这是一个重要的线索。

查看原始IP数据包,前两个半字节(4位版本号和4位头部长度)的顺序错了。第一个字节是 0x45,其中 4 是IP版本,5 是头部长度(20字节除以4)。但它们目前是 0x54,顺序反了。

这是因为在处理小于一个字节的字段(如4位)时,系统按字节寻址,我们需要考虑字节内的位顺序。我们需要交换这两个4位字段的位置。

修正IP头部版本和长度的顺序后,重新编译并运行程序。

成功了!我们现在收到了一个从我们的IP地址到目标主机的ICMP回显请求数据包。标识符(ID)和序列号(Sequence)也显示出来了。

完善ICMP负载

目前,我们只在ICMP负载中发送了一些垃圾数据。根据标准,Ping的ICMP负载应该包含一个16位的标识符字段和一个16位的序列号字段。

我们应该使用这些字段作为负载的一部分。最好的方式是创建一个新的结构体,例如 ping_payload_t

这个结构体包含一个16位的 id 字段和一个16位的 seq 字段,后面可能还会跟着一些数据。

我们需要修改创建数据包的代码。不再使用简单的字符串作为负载,而是使用这个结构体。我们需要正确设置 idseq 字段,并确保它们是网络字节序。

修改后,我们的负载变成了8个字节(两个16位字段)。再次运行程序,没有错误,并且抓包工具显示我们发送的数据包包含了正确的标识符(5000)和序列号(1)。

总结

本节课中,我们一起学习了如何从零开始构建并发送一个原始的IP数据包。我们完成了以下步骤:

  1. 定义发送函数:规划了 sendIP 函数的基本框架和所需变量。
  2. 实现数据包构建:使用 eval 函数准备IP和ICMP头部,并正确计算数据包总长度。
  3. 调用系统接口:使用 sendto 系统调用发送数据,并处理了目标地址的传递问题。
  4. 调试字节序问题:发现并修正了IP头部中版本与长度字段的字节序错误。
  5. 完善协议细节:按照ICMP回显请求的规范,构建了包含标识符和序列号的负载。

现在,我们已经能够成功发送格式正确的ICMP回显请求数据包。在下一节课中,我们将在此基础上,实现接收并解析回复数据包,从而完成一个完整的Ping程序。

004:计算互联网校验和 🔢

在本节课中,我们将深入学习互联网校验和的计算方法。校验和是网络协议(如ICMP、IP、TCP、UDP)中用于验证数据完整性的关键机制。我们将通过一个具体的ICMP数据包示例,手把手演示如何从零开始计算校验和。

构建示例数据包 📦

上一节我们介绍了校验和的基本概念,本节中我们来看看如何具体计算。首先,我们需要一个示例数据包作为计算对象。这里我们构建一个简化的ICMP数据包结构,它包含以下几个字段:

  • 类型 (Type): 假设为 200 (十进制)
  • 代码 (Code): 假设为 122 (十进制)
  • 校验和 (Checksum): 初始设为 0
  • 标识符 (Identifier): 假设为 31337 (十进制)
  • 序列号 (Sequence Number): 假设为 40001 (十进制)
  • 数据 (Data): 假设为32位数据,分为两个16位数:1000020222 (十进制)

在计算校验和时,我们总是假设校验和字段为0,待计算完成后再填入正确值。

转换为十六进制 🔄

为了便于计算,我们首先将所有十进制数转换为十六进制数。以下是转换后的结果,每个字段都被视为一个16位的数:

  • 类型 (200) + 代码 (122): 0xCA7A
  • 校验和 (0): 0x0000
  • 标识符 (31337): 0x7A69
  • 序列号 (40001): 0x9C41
  • 数据第一部分 (10000): 0x2710
  • 数据第二部分 (20222): 0x4EFE

为了方便后续步骤,我们为这些16位数分配标签:

  • A = 0xCA7A
  • B = 0x0000
  • C = 0x7A69
  • D = 0x9C41
  • E = 0x2710
  • F = 0x4EFE

计算一补数和 (One’s Complement Sum) ➕

校验和的核心是计算“一补数和”。这听起来复杂,但操作很简单:我们将所有16位数相加,并对溢出部分进行特殊处理。

以下是计算步骤:

  1. 将所有数字相加:A + B + C + D + E + F
  2. 计算过程如下:
    0xCA7A
    
  • 0x0000

0xCA7A
  • 0x7A69

0x144E3 (此处产生溢出,我们暂时保留)
  • 0x9C41

0x1E124 (再次产生溢出)
  • 0x2710

0x20834
  • 0x4EFE

0x25732
```
最终得到结果 `0x25732`,这是一个17位的数(超过16位)。
  1. 处理溢出:将高位的溢出值(0x2)加回到结果的低16位(0x5732)上。
    0x5732
    
  • 0x0002

0x5734
```
这样,我们得到了一补数和:**`0x5734`**。

一补数和的计算公式可以概括为:将所有16位字段相加,然后将结果中任何超出16位的进位(溢出)加回到结果的低16位上,重复此过程直到没有进位为止。

取反得到最终校验和 🔁

得到一补数和后,我们需要对其进行“取反”操作,即计算其二进制的一补数(One’s Complement)。这意味着将每一位二进制数翻转:0变成11变成0

  1. 0x5734 转换为二进制:
    0101 0111 0011 0100
    
  2. 对每一位取反:
    1010 1000 1100 1011
    
  3. 将取反后的二进制数转换回十六进制:
    0xA8CB
    

因此,我们计算出的最终校验和为:0xA8CB。现在,我们可以将这个值填回数据包的校验和字段。

验证校验和 ✅

如何验证我们的计算是否正确呢?校验和的巧妙之处在于验证非常简单。如果我们用计算出的校验和(0xA8CB)替换掉数据包中的0,然后重新对所有16位字段(包括新的校验和)执行一次相同的“一补数和”计算,结果应该是一个特殊的值。

让我们验证一下:
将所有字段(A, 0xA8CB, C, D, E, F)相加并进行一补数和计算。

0xCA7A (A)
+ 0xA8CB (Checksum)
+ 0x7A69 (C)
+ 0x9C41 (D)
+ 0x2710 (E)
+ 0x4EFE (F)

按照前述步骤计算,最终结果将是 0xFFFF(即所有16位都是1)。这是因为校验和的设计使得数据包所有部分(包括校验和本身)的一补数和等于全1。

验证公式Sum of all 16-bit fields (including checksum) = 0xFFFF

如果计算结果为 0xFFFF,则证明数据包在传输过程中没有出错,校验和计算正确。

总结 📝

本节课中我们一起学习了互联网校验和的完整计算流程:

  1. 准备数据:构建数据包,并将校验和字段初始化为0。
  2. 转换格式:将所有字段转换为十六进制的16位数。
  3. 计算一补数和:将所有16位数相加,并将产生的任何进位加回结果的低16位。
  4. 取反:对一补数和进行二进制取反操作,得到最终的校验和值。
  5. 验证:将计算出的校验和代入数据包,重新计算所有字段的一补数和,结果应为 0xFFFF

这种校验和方法因其计算简单、验证方便,被广泛应用于IP、ICMP、TCP、UDP等多种网络协议中,是保证数据完整性的基石。

005:接收原始IP数据包 📦

在本节课中,我们将要学习如何接收并解析原始IP数据包,这是完成我们“从零实现PING”项目的最后一步。上一节我们成功修复了校验和错误,并能够发送正确的ICMP回显请求包。本节中,我们将实现接收函数,处理来自网络的回复,并最终完成一个功能完整的PING工具。


概述与问题回顾

我们之前已经实现了发送ICMP回显请求(Ping)的功能。但在测试时发现,虽然能发送数据包,却收不到预期的回复。经过排查,问题出在校验和计算函数的一个小错误上:我们在计算后错误地进行了字节序反转。

以下是修复后的校验和函数核心逻辑:

uint16_t checksum(uint16_t *addr, int len) {
    // ... 计算过程 ...
    // 注意:返回计算结果本身,不再进行反转
    return sum;
}

修复后,我们的Ping请求能够成功收到回复。接下来的任务就是编写代码来接收并解析这些回复包。


接收原始IP数据包

我们需要实现一个函数,从原始套接字中读取数据,验证其校验和,并将其解析为我们定义的结构化格式(如IP包、ICMP包)。

以下是接收函数的基本框架:

struct ip_packet* ip_receive(int socket_fd) {
    char buffer[1600]; // 缓冲区,足够容纳最大传输单元
    memset(buffer, 0, sizeof(buffer));

    // 使用 recvfrom 接收数据
    ssize_t bytes_received = recvfrom(socket_fd, buffer, sizeof(buffer) - 1, 0, NULL, NULL);
    if (bytes_received <= 0) {
        return NULL; // 接收失败或超时
    }
    // ... 后续解析和验证代码
}


解析IP包头

接收到原始数据后,我们首先将其强制转换为IP包头结构,并提取关键信息,如源地址、目标地址和协议类型。

以下是解析IP包头并验证校验和的关键步骤:

  1. 获取IP头指针struct ip_header *ip_hdr = (struct ip_header*)buffer;
  2. 计算校验和:调用 checksum 函数计算接收数据的校验和。
  3. 验证校验和:正确的IP包,其校验和计算结果应为 0xFFFF。验证公式为:
    calculated_checksum == 0xFFFF
    如果校验失败,则丢弃该数据包。
  4. 提取信息:从 ip_hdr 中读取源地址、目标地址、标识符(ID)和协议字段。


处理ICMP负载

如果IP包头中的协议字段表明这是一个ICMP包(协议号=1),我们需要进一步解析ICMP头部及其负载(即Ping回复数据)。

以下是处理ICMP包的核心步骤:

  1. 定位ICMP头:ICMP头紧接在IP头之后。计算指针偏移:
    struct icmp_header *icmp_hdr = (struct icmp_header*)(buffer + sizeof(struct ip_header));
    
  2. 验证ICMP校验和:同样计算ICMP部分的校验和并进行验证。
  3. 提取Ping回复数据:如果ICMP类型是“回显回复”(Type 0, Code 0),则其负载部分包含我们发送的Ping包数据(ID和序列号)。
  4. 构建结构化数据:使用提取的信息,填充我们自定义的 ip_packeticmp_packet 结构,以便于程序后续处理和显示。


整合到主程序并设置超时

我们将接收逻辑封装成一个独立的函数,并在主程序中循环发送Ping请求并等待回复。

为了让程序在目标主机不响应时不会无限期等待,我们需要为套接字设置接收超时。

以下是设置套接字超时的代码:

struct timeval tv;
tv.tv_sec = TIMEOUT_SECONDS;  // 超时秒数,例如2秒
tv.tv_usec = 0;
setsockopt(socket_fd, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv));

设置后,recvfrom 调用会在指定时间后返回,允许我们处理超时情况,并继续发送下一个Ping请求或结束程序。


最终测试与总结

完成所有代码后,我们进行了最终测试:

  • 测试1:Ping一个可达的主机(如 10.0.0.6)。程序成功发送请求并接收回复,显示“!”,并最终计算成功率。
  • 测试2:Ping一个不可达的地址(如 10.0.0.99)。程序在超时后显示“.”,表示请求超时,最终成功率为0%。

本节课中我们一起学习了如何接收和解析原始IP数据包,实现了Ping工具的完整双向通信。我们修复了关键的校验和错误,构建了数据包解析流程,并增加了超时处理机制,最终完成了一个从零开始、功能完备的PING程序。通过这个项目,我们深入理解了网络协议栈底层、原始套接字编程以及数据包的结构与验证。

006:用C语言实现以太网库

在本节课中,我们将开始实现以太网(Ethernet)协议。我们将深入到比IP层更低的网络层次。首先,我会介绍一个名为“Ping from scratch”的现有项目,它将作为我们实现的基础。该项目使用原始套接字(raw sockets)编写了一个ping工具,其中的代码非常适合用于我们的目的。

项目基础与设计思路

上一节我们提到了基础项目。本节中,我们来看看如何基于它构建我们的以太网库。

“Ping from scratch”项目中有一个接收IP数据包的函数,我将完全重写它。但其他部分代码很好,比如构造IP数据结构的函数。当我们有了包含IP地址等信息的结构后,可以调用 eval_ip 函数将其转换为能在网络上发送的格式。对于Ping协议涉及的ICMP,我也有类似的实现。

我希望为以太网实现类似的功能。我们将有一个构造函数,用于创建包含我们感兴趣信息的以太网数据结构。如果我们查看头文件,会发现每种协议都有两个数据结构:一个是只包含类型、源/目的IP地址等核心信息的“逻辑”结构;另一个是为了通过网络发送而需要的、包含更多字段的“原始”结构。eval 函数负责将前者转换为后者。

我的目标是为以太网实现同样的模式,并将其构建成一个简单的库,使我们能够通过直接读写网卡来发送和接收任何协议的数据包。这是总体的想法。

创建新项目

首先,我将复制整个“ping”项目文件夹。由于新项目将主要基于以太网(可能也涉及其他协议),我将其命名为“Ether”。

以下是创建新项目目录和重命名文件的步骤:

  • 复制整个 ping 文件夹到新的 ether 文件夹。
  • 清理不必要的文件。
  • ping.c 重命名为 ether.c
  • ping.h 重命名为 ether.h

接着,在代码编辑器中打开新项目,并修改Makefile以反映新的文件名。例如,将目标名称从 ping 改为 ether,并更新源文件和头文件的引用。

现在,开始清理 ether.c 文件。删除原有的 receive_ip 函数,因为我们将使用不同的接收方式。同时移除 main 函数和 send_ping 函数,只保留我们需要的框架。在头文件 ether.h 中也移除相应的函数声明。

设计以太网数据结构

清理完基础代码后,我们开始设计核心数据结构。

我们将定义一个结构体 struct ether 来表示以太网帧的逻辑视图。它需要包含以下信息:

  • 一个类型变量,用于指示网络层协议(例如是IP还是其他协议)。
  • 源MAC地址和目的MAC地址。
  • 载荷(payload)数据及其大小。
  • 帧类型(例如单播、广播等)。

我们使用 __attribute__((packed)) 确保结构体成员紧密排列,没有填充字节。定义如下:

struct __attribute__((packed)) ether {
    uint16_t type; // 协议类型,如IPv4
    mac_t dest;    // 目的MAC地址
    mac_t src;     // 源MAC地址
    uint8_t *payload; // 载荷数据指针
    size_t payload_size; // 载荷大小
    frame_type_t frame_type; // 帧类型(单播、广播等)
};
typedef struct ether ether_t;

接下来,我们需要定义 frame_type_t 枚举和 mac_t 类型。

frame_type_t 枚举定义了帧的发送方式:

enum __attribute__((packed)) frame_type {
    FRAME_TYPE_NONE,     // 未设置
    FRAME_TYPE_UNICAST,  // 单播(发送给特定目标)
    FRAME_TYPE_BROADCAST,// 广播(发送给整个本地网络)
    FRAME_TYPE_MULTICAST,// 组播(发送给一组主机)
    FRAME_TYPE_UNKNOWN   // 未知(行为类似广播)
};
typedef enum frame_type frame_type_t;

实现MAC地址处理

MAC地址是48位的硬件地址,通常表示为六组十六进制数,如 AA:BB:CC:DD:EE:FF。在代码中,我们需要一种方式来表示和操作它。

我们定义一个结构体来存储MAC地址。由于C语言没有48位整数类型,我们使用一个64位整数,但只使用其低48位。

struct __attribute__((packed)) mac {
    uint64_t addr:48; // 使用位域限定为48位
};
typedef struct mac mac_t;

为了方便调试和显示,我们实现一个 show_mac 函数,将 mac_t 结构体格式化为可读的字符串(采用类似Cisco的风格,如 AABB.CCDD.EEFF)。

函数实现思路是:从64位整数中分别提取出6个字节,然后用 snprintf 格式化成字符串。

更重要的是,我们需要能够从字符串或整数创建 mac_t。因此,我们实现两个函数:

  • read_mac_int: 从一个64位整数创建 mac_t
  • read_mac_str: 从一个格式化的字符串(如 "AABB.CCDD.EEFF")解析并创建 mac_t

为了提供更友好的接口,我们使用C11的 _Generic 关键字创建一个泛型宏 MAKE_MAC,它能根据参数类型自动选择调用哪个函数。

#define MAKE_MAC(x) _Generic((x), \
    uint64_t: read_mac_int,       \
    char*:    read_mac_str        \
)(x)

read_mac_str 函数的实现较为复杂,需要逐字节解析字符串,处理十六进制字符,并检查分隔符。我们使用一个宏来避免解析每个字节的重复代码。

在实现过程中,我们还需要一个辅助宏 HEX,用于将ASCII字符(‘0’-‘9’, ‘A’-‘F’, ‘a’-‘f’)转换为其对应的十六进制数值。

经过一系列编译、调试和错误修复(包括位域操作、类型转换、字符串解析逻辑等),我们最终成功实现了MAC地址的创建、解析和显示功能。

定义原始以太网帧结构

为了在网络上发送数据,我们需要定义原始的以太网帧结构,它对应着线缆上传输的比特序列。

根据维基百科的以太网帧格式,一个帧包括:

  • 前导码(Preamble,7字节)
  • 帧开始定界符(SFD,1字节)
  • 目的MAC地址(6字节)
  • 源MAC地址(6字节)
  • 可选的802.1Q标签(4字节,可选)
  • 以太网类型(EtherType,2字节,指示载荷协议)
  • 载荷(Payload,46-1500字节)
  • 帧校验序列(FCS,4字节)

然而,在实际编程中,通过AF_PACKET套接字或类似底层接口发送数据时,操作系统或网卡驱动通常会处理前导码、SFD和FCS。因此,我们实际需要构建的原始结构通常只包含目的地址、源地址、类型和载荷。

我们定义原始帧结构如下:

struct __attribute__((packed)) raw_ether {
    mac_t dest;      // 目的MAC地址
    mac_t src;       // 源MAC地址
    uint16_t type;   // 协议类型
    uint8_t payload[]; // 柔性数组,存放载荷
};
typedef struct raw_ether raw_ether_t;

注意,这是一个变长结构,payload 的实际长度在运行时确定。

实现构造与评估函数

现在,我们模仿IP数据结构的处理模式,为以太网实现两个核心函数:

  1. 构造函数 make_ether:根据给定的参数(目的MAC、源MAC、协议类型),创建一个逻辑上的 ether_t 结构体。

    ether_t* make_ether(mac_t dest, mac_t src, uint16_t type) {
        // 分配内存
        ether_t* e = malloc(sizeof(ether_t));
        // 初始化各字段
        e->dest = dest;
        e->src = src;
        e->type = type;
        e->payload = NULL;
        e->payload_size = 0;
        e->frame_type = FRAME_TYPE_UNICAST; // 默认单播
        return e;
    }
    
  2. 评估函数 eval_ether:将一个逻辑上的 ether_t 结构体,转换(序列化)为可以发送的原始字节流 raw_ether_t

    raw_ether_t* eval_ether(ether_t* e) {
        if (!e) return NULL;
        // 计算原始帧总大小:头部固定部分 + 载荷大小
        size_t total_size = sizeof(raw_ether_t) + e->payload_size;
        raw_ether_t* raw = malloc(total_size);
        // 填充头部字段
        raw->dest = e->dest;
        raw->src = e->src;
        raw->type = htons(e->type); // 注意网络字节序转换
        // 复制载荷数据
        if (e->payload && e->payload_size > 0) {
            memcpy(raw->payload, e->payload, e->payload_size);
        }
        return raw;
    }
    

    注意,type 字段需要使用 htons 函数转换为网络字节序(大端序)。

总结与下节预告

本节课中,我们一起学习了如何为以太网协议构建基础库。我们完成了以下工作:

  • 建立了项目基础:基于已有的“Ping from scratch”项目创建了新项目“Ether”。
  • 设计了核心数据结构:定义了表示逻辑视图的 ether_t 和表示原始帧的 raw_ether_t
  • 实现了MAC地址处理:创建了 mac_t 类型及相关函数,支持从字符串、整数创建MAC地址,并能格式化输出。
  • 实现了核心转换函数:编写了 make_ether 构造函数和 eval_ether 评估函数,为发送以太网帧做好了数据准备。

现在,我们已经有了一个坚实的数据处理基础。在下一节课中,我们将实现关键的 发送函数 ,学习如何通过底层网络接口将构建好的原始以太网帧数据真正发送到网络中。敬请期待!

007:以太网与局域网通信

在本节课中,我们将要学习以太网协议以及计算机如何在局域网内进行通信。我们将从比IP协议更底层的视角出发,理解数据如何在硬件层面进行传输,并掌握MAC地址、网络设备(如集线器和交换机)以及以太网帧的基本概念。

网络通信的层级模型

上一节我们介绍了网络通信的基本概念,本节中我们来看看网络通信的层级划分。IP协议位于OSI模型的第3层(网络层)。层级越高,越接近应用程序;层级越低,越接近硬件。以太网主要工作在第2层(数据链路层),有时也涉及第1层(物理层),但今天我们主要关注第2层。

一个贴切的比喻:传统邮件

为了理解网络数据包的封装,我们可以借助传统邮件的比喻。想象你要寄一封信。

  • 你首先将信息写在信纸(数据)上。
  • 然后将信纸放入信封。
  • 在信封正面写上收件人地址(目的地址)。
  • 在信封背面写上寄件人地址(源地址)。

如果只是单向通信,源地址并非必需。但如果收件人需要回信,那么源地址就至关重要。这个“信封-信件”的模型很好地类比了网络数据包的报头数据部分。

局域网与MAC地址

现在,让我们将这个比喻应用到计算机网络中。假设我们有一个小型局域网,包含三台个人电脑:PC1、PC2和PC3。每台设备都有一个唯一的标识符,称为MAC地址

一个真实的MAC地址看起来像这样:AA:BB:CC:DD:EE:FF。它由6个字段组成,通常用冒号分隔,每个字段是两个十六进制数字(0-9, A-F)。MAC地址被设计为全球唯一,通常在生产时就被烧录到网卡的硬件中。

MAC地址的前三个字段由全球性组织分配给制造商,后三个字段由制造商自行分配,确保在其组织内不重复即可。MAC地址用于在局域网内标识设备,而IP地址则用于在全球互联网上路由数据包。互联网正是由无数个这样的局域网通过路由器连接而成的。

网络连接设备:集线器与交换机

那么,如何将这些计算机连接起来呢?你可能会想到路由器,但路由器工作在第3层,而我们目前讨论的是第2层。路由器端口是局域网的边界,其另一侧连接着另一个网络。

在局域网内部,一种古老的连接设备是集线器。集线器的工作原理类似于电源插排:任何从一个端口进入的数据,都会被复制并发送到所有其他端口。这意味着网络中的所有设备都会收到该数据。

以下是使用集线器的两个主要缺点:

  1. 安全性差:由于所有数据帧都被广播,任何设备都可以轻易监听整个网络的通信。
  2. 效率低下:随着网络设备增多,不必要的广播流量会急剧增加,导致网络性能下降。

因此,现代网络中使用交换机取代了集线器。交换机是一种更智能的集线器。它通过检查数据帧的“信封”(报头)来学习网络中各个设备的MAC地址。当PC1需要发送数据给PC2时,交换机会直接将数据帧转发给PC2,而不会打扰PC3或路由器,从而大大提升了网络效率和安全性。

协议数据单元与以太网帧

当我们在网络上发送数据时,需要以一种所有设备都能理解的格式进行“打包”。这种格式称为协议数据单元。不同网络层(如第2层、第3层)的PDU格式不同,但通常都包含两个基本部分:报头数据。报头相当于信封,包含了寻址等信息;数据则相当于信纸,是实际要传递的内容。

现在,让我们具体看看第2层的以太网帧结构。一个简化的以太网帧包含以下字段:

字段 描述
目的地址 接收方设备的MAC地址。
源地址 发送方设备的MAC地址。
类型 标识数据字段内承载的上层协议类型(例如,0x0800代表IPv4数据包)。
数据 实际传输的有效载荷。

地址解析:广播与单播

在实际通信中,设备如何知道彼此的MAC地址呢?假设PC1 (AA:AA)想首次向PC2 (CC:CC)发送“Hello”消息。

PC1知道自己的MAC地址(AA:AA),但不知道PC2的地址。此时,PC1会构造一个特殊的以太网帧:

  • 目的地址:设置为全F(即FF:FF:FF:FF:FF:FF),这称为广播地址
  • 源地址AA:AA
  • 数据"Hello"

PC1将这个广播帧发送给交换机,交换机会将其转发给网络中的所有设备。PC2收到后,会从中得知PC1的MAC地址(AA:AA),并将其记录在自己的MAC地址表中。

现在,PC2想要回复“Hi there”。由于它已经知道了PC1的地址,因此可以构造一个单播帧:

  • 目的地址AA:AA
  • 源地址CC:CC
  • 数据"Hi there"

交换机收到这个目的地址明确的帧后,会查询其MAC地址表,并只将帧转发给PC1,而不会发送给其他设备。这是最高效的通信方式。

总结

本节课中我们一起学习了以太网和局域网通信的基础知识。我们了解了MAC地址作为硬件唯一标识符的作用,比较了集线器和交换机的工作原理,并剖析了以太网帧的结构。关键点在于,设备通过广播来发现未知的MAC地址,之后便使用单播进行定向高效通信。理解这些底层概念是进行网络编程和协议分析的重要基石。

如果你想更深入地学习这些内容,推荐阅读相关网络技术书籍,其中会详细讲解MAC地址、以太网协议,以及它们如何与IP协议协同工作。

008:数据包套接字 - 实现以太网库

概述

在本节课中,我们将继续构建名为“Esther”的以太网库项目。上一节我们设置了项目基础并添加了一些实用函数。本节我们将实现核心的“评估”函数,将数据结构转换为可发送的字节流,并最终通过数据包套接字发送以太网帧。

创建字节串数据结构

为了简化操作,我们首先创建一个新的数据结构来同时管理数据及其大小。

上一节我们介绍了数据结构的定义,本节中我们来看看如何创建一个更易用的容器。

以下是ByteString数据结构的定义:

typedef struct ByteString {
    uint16_t size;
    uint8_t data[];
} ByteString;

我们还需要一个构造函数来创建ByteString

ByteString* make_bytestring(const uint8_t* data, uint16_t size) {
    if (!data || !size) {
        return NULL;
    }
    ByteString* bs = malloc(sizeof(ByteString) + size);
    assert(bs);
    memset(bs, 0, sizeof(ByteString) + size);
    bs->size = size;
    memcpy(bs->data, data, size);
    return bs;
}

更新评估函数

接下来,我们需要更新现有的评估函数(如eval_icmp, eval_ip),让它们返回ByteString指针,而不是原始的uint8_t指针。这能让我们更方便地处理数据大小。

以下是更新eval_ip函数的示例:

ByteString* eval_ip(const IP* ip) {
    if (!ip) return NULL;
    // ... 原有的头部评估逻辑 ...
    // 假设 `p` 是指向已评估头部数据的指针,`length` 是总长度
    return make_bytestring(p, length);
}

实现字节串合并功能

为了将多个协议层的数据(如以太网头、IP头、载荷)合并成一个完整的数据包,我们需要一个合并函数。

以下是合并两个ByteString的函数:

ByteString* bs_merge(ByteString* a, ByteString* b) {
    if (!a && !b) return NULL;
    if (!a) return b;
    if (!b) return a;

    uint16_t new_size = a->size + b->size;
    ByteString* merged = make_bytestring(NULL, new_size);
    if (!merged) return NULL;

    uint8_t* p = merged->data;
    memcpy(p, a->data, a->size);
    p += a->size;
    memcpy(p, b->data, b->size);

    free(a);
    free(b);
    return merged;
}

实现以太网评估函数

现在,我们可以创建一个顶层的eval_ether函数。它会评估以太网头部,然后递归地评估其载荷(如IP包),最后将所有部分合并。

以下是eval_ether函数的框架:

ByteString* eval_ether(const Ether* ether) {
    if (!ether) return NULL;

    // 1. 评估以太网头部,得到 ByteString* bs_header
    ByteString* bs_header = ...;

    // 2. 如果存在载荷,则评估载荷(例如IP包)
    if (!ether->payload) {
        return bs_header;
    }
    ByteString* bs_payload = eval(ether->payload); // 使用宏或函数分派
    if (!bs_payload) {
        free(bs_header);
        return NULL;
    }

    // 3. 合并头部和载荷
    return bs_merge(bs_header, bs_payload);
}

配置数据包套接字

为了发送原始的以太网帧,我们需要使用AF_PACKET套接字,而不是之前使用的原始IP套接字。

以下是更新后的套接字设置函数:

int setup_socket(const char* interface_name, const uint8_t* source_mac) {
    int fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); // 捕获所有协议
    if (fd < 0) {
        perror("socket");
        return -1;
    }

    // 绑定到特定网络接口
    struct sockaddr_ll sll;
    memset(&sll, 0, sizeof(sll));
    sll.sll_family = AF_PACKET;
    sll.sll_protocol = htons(ETH_P_ALL);
    sll.sll_ifindex = if_nametoindex(interface_name); // 获取接口索引
    if (sll.sll_ifindex == 0) {
        perror("if_nametoindex");
        close(fd);
        return -1;
    }
    memcpy(sll.sll_addr, source_mac, 6);
    sll.sll_halen = 6;

    if (bind(fd, (struct sockaddr*)&sll, sizeof(sll)) < 0) {
        perror("bind");
        close(fd);
        return -1;
    }
    return fd;
}

创建发送帧的函数

有了评估函数和配置好的套接字,我们现在可以创建发送以太网帧的函数。

以下是send_frame函数:

bool send_frame(int fd, const Ether* ether) {
    if (fd < 0 || !ether) return false;

    ByteString* bs = eval_ether(ether);
    if (!bs) return false;

    ssize_t bytes_sent = send(fd, bs->data, bs->size, 0);
    free(bs);

    if (bytes_sent > 0) {
        return true;
    } else {
        perror("send");
        return false;
    }
}

整合测试:发送以太网帧

最后,我们创建一个主函数来整合所有部分,从命令行参数读取信息,构造数据包,并通过套接字发送。

以下是主函数的简化流程:

int main(int argc, char* argv[]) {
    // 1. 解析命令行参数(源/目标MAC, 源/目标IP, 消息,接口名)
    // 2. 创建 MAC 地址结构
    // 3. 创建 IP 包结构(类型设为 L4_RAW,载荷为消息字符串)
    // 4. 创建以太网包结构,将IP包作为其载荷
    // 5. 调用 setup_socket 创建并绑定套接字
    // 6. 调用 send_frame 发送数据包
    // 7. 清理资源
    return 0;
}

总结

本节课中我们一起学习了如何完成以太网库的核心功能。我们创建了ByteString数据结构来简化数据管理,实现了将多层网络协议数据结构评估并合并成字节流的功能,配置了AF_PACKET类型的数据包套接字以发送原始以太网帧,并最终整合所有模块,成功发送了一个自定义的以太网数据包。这为我们后续进行网络监控、数据包欺骗等高级操作奠定了坚实的基础。

posted @ 2026-03-29 09:18  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报