自作OSにネットワーク機能を実装してpingに応答させる
はじめに
自作 OSにネットワーク機能を追加し、自作 OSから google.com に ping を送信する ところまでを実装しました。
そこに至るまでの実装をすべて一つの記事にまとめると長くなってしまうため、複数回に分けて解説します。本記事はその第一回です。
今回は、QEMU上で動作する自作 OSが、ホスト(WSL2)からの ping に応答できるまでの実装を解説していきます。
扱う範囲は OSI 参照モデルのレイヤー1〜3(物理層〜ネットワーク層)に相当します。
前提知識
本記事では以下の前提知識を持っていることを想定しています。
- OSI参照モデルの1〜3層の基礎知識
- IP アドレスとMAC アドレスの違いと役割
- pingコマンドの基本的な理解
- バイトオーダー(エンディアン)の概念
やったことの全体像
今回実装した内容は以下の通りです。
- ARP Request への応答(MAC アドレス解決)
- IPv4パケットの解析
- ICMP Echo Request(ping)への応答
自作 OS上で行っている処理の全体像を図にすると次のようになります。
本記事では以下の流れで解説していきます。
- virtio-net からパケットを受け取る仕組み
- イーサネットフレーム / ARP / IPv4 / ICMP 各ヘッダの解析
- 応答パケット(ARP Reply / ICMP Echo Reply)の組み立てと送信
virtio-netからパケット受信
virtio-netとは
virtio-netは、Virtioという準仮想化フレームワーク上で提供される仮想NICです。
Virtioそのものの仕組みについては、以前公開した以下の記事で詳しく解説しています。
本記事では、OS にネットワークパケットを届けてくれる仮想NICとして理解しておけば問題ありません。
ホスト側の設定
virtio-net を利用するために、ホスト側で TAPデバイスを作成し、IP アドレスを割り当てます。
# TAPインターフェースの作成
ip tuntap add dev tap0 mode tap
# IP アドレス設定
ip addr add 192.168.100.1/24 dev tap0
# デバイス有効化
ip link set tap0 up
# パケット転送を有効化
sysctl -w net.ipv4.ip_forward=1
この設定により、tap0 という名前のインターフェースに 192.168.100.1/24 の IP アドレスを割り当てた状態になります。
次に、QEMU の起動オプションで virtio-net デバイスを tap0 に接続します。
qemu-system-x86_64 \
-netdev tap,id=net0,ifname=tap0,script=no \
-device virtio-net-pci,netdev=net0,mac=52:54:00:12:34:56 \
これにより、ホストと自作 OSが TAP インターフェースを介して同じ仮想ネットワーク(192.168.100.0/24)上で通信できるようになります。
パケットを取り出してnetwork プロセスに送信
virtio-net デバイスは、受信パケットをカーネルが用意したバッファ(virtqueue)に直接書き込みます。
カーネル側では以下の処理を行っています。
- バッファへの書き込み - 受信キュー(receive queue)に登録したバッファへ、virtio-net がパケットを書き込む
- 到着検出 - デバイスからの通知で、パケットの到着を検出
- パケット回収 - キューからパケットを回収
- プロセスへの転送 - パケットをnetwork プロセスへ転送
この段階ではパケットの解析は行わず、受信したパケットを network プロセスに届ける という最小限の役割だけを担っています。
動作確認
ホスト側から以下のコマンドを実行すると、自作 OSがパケットを受信している様子を確認できます。
ping 192.168.100.200
この時点では、ホスト側で以下のような出力が表示されます。
PING 192.168.100.200 (192.168.100.200) 56(84) bytes of data.
From 192.168.100.1 icmp_seq=1 Destination Host Unreachable
From 192.168.100.1 icmp_seq=2 Destination Host Unreachable
...
ARP応答やICMP Echo Replyをまだ実装していないため、ホストは自作 OSのMAC アドレスを解決できず Destination Host Unreachable エラーが発生します。
イーサネットフレームの解析
network プロセスに届いたデータは、以下のような構造になっています。
ARP パケットの場合
ICMP パケットの場合
解析はOSI参照モデルの下位層から順に行います。(これをデカプセル化と呼びます)
まずは最下層のデータリンク層(レイヤー2)にあたる イーサネットフレーム の解析から始めます。
イーサネットフレームとは
イーサネットフレームは、イーサネットネットワーク上でデータを送受信するための基本単位です。
struct EthernetFrame {
uint8_t dst_mac[6]; ///< Destination MAC address
uint8_t src_mac[6]; ///< Source MAC address
uint16_t ethertype; ///< EtherType
uint8_t payload[]; ///< Frame payload
} __attribute__((packed));
| フィールド | サイズ | 役割 |
|---|---|---|
dst_mac |
6バイト | 宛先 MAC アドレス |
src_mac |
6バイト | 送信元 MAC アドレス |
ethertype |
2バイト | ペイロードのプロトコル種別 |
payload |
可変長 | 上位層のデータ(ARP や IPv4 パケットなど) |
ethertype の値によって、ペイロードに格納されているプロトコルを判別できます。
| EtherType | プロトコル |
|---|---|
0x0806 |
ARP |
0x0800 |
IPv4 |
0x86DD |
IPv6 |
イーサネットフレームの解析
network プロセスでは、まずイーサネットフレームのヘッダを解析し、ethertypeフィールドの値に応じて処理を振り分けます。
void handle_recv_packet(const Message& m)
{
const EthernetFrame* frame =
reinterpret_cast<const EthernetFrame*>(m.data.net.packet_data);
switch (static_cast<EthernetFrameType>(ntohs(frame->ethertype))) {
case EthernetFrameType::ARP:
process_arp(*reinterpret_cast<const ARPPacket*>(frame->payload));
break;
case EthernetFrameType::IPV4:
process_ipv4(*reinterpret_cast<const IPv4Header*>(frame->payload));
break;
case EthernetFrameType::IPV6:
LOG_ERROR("IPv6 packet received");
break;
default:
LOG_ERROR("Unknown Ethertype");
break;
}
}
ethertype が 0x0806 なら ARP、0x0800 なら IPv4 として、それぞれの解析処理に進みます。
ARPリクエストパケットに応答
ARPとは
ARP(Address Resolution Protocol)は、IP アドレスから対応するMAC アドレスを取得するためのプロトコルです。
イーサネットで通信を行うためには、宛先の MAC アドレスが必要です。
しかし、通常私たちが指定するのはIP アドレスであり、MAC アドレスは自動的に解決される必要があります。
この「IP アドレス → MAC アドレス」の変換を行うのがARPの役割です。
ARPの動作フロー
ホストが自作 OSにpingを送信する際も、まずは以下の流れでARPによるMAC アドレス解決が行われます。
-
ARP Request のブロードキャスト
ホストは、宛先IP(192.168.100.200)を含んだARP Requestをブロードキャスト送信します。イーサネットフレームの宛先MAC アドレスにはFF:FF:FF:FF:FF:FF(ブロードキャストアドレス)が指定され、ネットワーク内の全デバイスがこのパケットを受信します。 -
該当デバイスのみが応答
各デバイスはARP Request内のtarget_ipフィールドを確認し、「このIP アドレスは自分のものか?」を判定します。
一致したデバイス(自作 OS)のみがARP Replyを返し、それ以外のデバイスはパケットを破棄します。 -
MAC アドレスの学習
ホストは、受け取ったARP Replyから自作 OSのMAC アドレスを取得し、ARPテーブル(キャッシュ)に保存します。
以降の通信では、この情報を使って直接パケットを送信できるようになります。
この仕組みによってIP アドレスからMAC アドレスへの変換が行われ、イーサネットでの通信が可能になります。
ARPパケットの構造
ARPパケットの内部構造は次のようになっています。
struct ARPPacket {
uint16_t hw_type; ///< Hardware Type
uint16_t protocol_type; ///< Protocol Type
uint8_t hw_size; ///< Hardware Address Length
uint8_t protocol_size; ///< Protocol Address Length
uint16_t opcode; ///< Operation Code
uint8_t sender_mac[6]; ///< Sender MAC Address
uint32_t sender_ip; ///< Sender IP Address
uint8_t target_mac[6]; ///< Target MAC Address
uint32_t target_ip; ///< Target IP Address
} __attribute__((packed));
| フィールド | サイズ | 役割 |
|---|---|---|
hw_type |
2バイト | ハードウェアタイプ(Ethernet = 1) |
protocol_type |
2バイト | プロトコルタイプ(IPv4 = 0x0800) |
hw_size |
1バイト | ハードウェアアドレス長(MAC = 6) |
protocol_size |
1バイト | プロトコルアドレス長(IPv4 = 4) |
opcode |
2バイト | 操作コード(REQUEST = 1, REPLY = 2) |
sender_mac |
6バイト | 送信元 MAC アドレス |
sender_ip |
4バイト | 送信元 IP アドレス |
target_mac |
6バイト | 宛先 MAC アドレス |
target_ip |
4バイト | 宛先 IP アドレス |
ARP Request では target_mac は未知のため 00:00:00:00:00:00 が設定されており、ARP Reply でこのフィールドに正しい MAC アドレスが格納されます。
ARP Request の解析
イーサネットフレームから取り出したARPパケットの opcode フィールドを確認し、Request か Reply かを判別します。
void process_arp(const ARPPacket& arp_packet)
{
switch (static_cast<ARPOpcode>(ntohs(arp_packet.opcode))) {
case ARPOpcode::REQUEST:
handle_arp_request(arp_packet);
break;
case ARPOpcode::REPLY:
handle_arp_reply(arp_packet);
break;
default:
LOG_ERROR("Unknown ARP opcode");
break;
}
}
ping に応答するためには、ARP Requestを受信してARP Replyを返す必要があります。
ARP Reply の構築と送信
ARP Request を受信したら、以下の処理で ARP Reply を構築して返送します。
void handle_arp_request(const ARPPacket& arp_packet)
{
// 自分宛てのリクエストか確認
if (arp_packet.target_ip != htonl(hw::virtio::MY_IP)) {
return;
}
// 送信元のMAC アドレスをARPテーブルに登録(後続の通信で使用)
uint32_t sender_ip = ntohl(arp_packet.sender_ip);
arp_table.add(sender_ip, arp_packet.sender_mac);
void* buf;
ALLOC_OR_RETURN(buf, sizeof(ARPPacket), kernel::memory::ALLOC_ZEROED);
// ARP Replyの構築
ARPPacket& reply = *reinterpret_cast<ARPPacket*>(buf);
reply.hw_type = htons(1); // Ethernet
reply.protocol_type = htons(0x0800); // IPv4
reply.hw_size = MAC_ADDR_SIZE;
reply.protocol_size = 4;
reply.opcode = htons(static_cast<uint16_t>(ARPOpcode::REPLY));
// sender と target を入れ替える
reply.sender_ip = arp_packet.target_ip;
reply.target_ip = arp_packet.sender_ip;
memcpy(reply.sender_mac, hw::virtio::mac_addr, MAC_ADDR_SIZE);
memcpy(reply.target_mac, arp_packet.sender_mac, MAC_ADDR_SIZE);
// イーサネットフレームにのせて送信
transmit_ethernet_frame(arp_packet.sender_mac, EthernetFrameType::ARP, &reply,
sizeof(ARPPacket));
}
処理の流れは以下の通りです。
-
宛先IP アドレスの確認
target_ipが自分のIP アドレスと一致するかを確認し、一致しない場合は無視します。 -
ARP テーブルへの登録
送信元のIP アドレスとMAC アドレスのペアをARPテーブルに保存します。
これにより、後続の通信で相手のMAC アドレスを再度問い合わせる必要がなくなります。 -
ARP Reply の構築
Requestの送信元/宛先を入れ替え、自分のMAC アドレスをsender_macに設定します。 -
送信
構築したARP Replyを、イーサネットフレームに載せてホストに返送します。
この処理により、ホストは自作 OSのMAC アドレスを学習し、以降の ICMP Echo Request などを送信できるようになります。
IPv4パケットの解析
ARP による MAC アドレス解決が完了すると、ホストは IP パケットを送信できるようになります。
イーサネットフレームの ethertype が 0x0800 の場合、ペイロードには IPv4 パケットが格納されています。
IPv4ヘッダの構造
まず IPv4 ヘッダの構造を確認します。
struct IPv4Header {
uint8_t version_ihl; ///< Version and Internet Header Length
uint8_t dscp_ecn; ///< DSCP and ECN
uint16_t total_length; ///< Total Length (header + data)
uint16_t id; ///< Identification
uint16_t flags_fragment_offset; ///< Flags and Fragment Offset
uint8_t ttl; ///< Time to Live (routing hop limit)
uint8_t protocol; ///< Protocol
uint16_t header_checksum; ///< Header Checksum
uint32_t src_ip; ///< Source IP Address
uint32_t dst_ip; ///< Destination IP Address
} __attribute__((packed));
ping の応答に必要な主要フィールドは以下の通りです。
| フィールド | 役割 |
|---|---|
version_ihl |
上位4ビットがバージョン(IPv4 = 4)、下位4ビットがヘッダ長(IHL) |
protocol |
上位プロトコルの識別子(ICMP = 1, TCP = 6, UDP = 17) |
src_ip / dst_ip
|
送信元/宛先 IP アドレス |
プロトコルによる振り分け
IPv4ヘッダの protocol フィールドを確認し、上位プロトコルに応じた処理を行います。
void process_ipv4(const IPv4Header& ip_header)
{
size_t header_len = (ip_header.version_ihl & 0x0F) * 4;
size_t total_len = ntohs(ip_header.total_length);
size_t payload_len = total_len - header_len;
const uint8_t* payload =
reinterpret_cast<const uint8_t*>(&ip_header) + header_len;
switch (static_cast<IPv4Protocol>(ip_header.protocol)) {
case IPv4Protocol::ICMP:
process_icmp(*reinterpret_cast<const ICMPHeader*>(payload),
ntohl(ip_header.src_ip), payload_len);
break;
case IPv4Protocol::TCP:
LOG_ERROR("TCP packet received");
break;
case IPv4Protocol::UDP:
LOG_ERROR("UDP packet received");
break;
default:
LOG_ERROR("Unknown IPv4 protocol");
break;
}
}
protocol が 1(ICMP)の場合、IPv4 ペイロード部分を ICMP ヘッダとして解析します。
次の章では、この ICMP パケットを解析し、Echo Reply を返す処理を実装します。
ICMPリクエストパケットに応答
ping コマンドは ICMP(Internet Control Message Protocol)を使用して疎通確認を行います。
ARP による MAC アドレス解決が完了し、IPv4 パケットとして送信されてきた ICMP Echo Request に応答することで、ping が成功します。
ICMPとは
ICMPは、ネットワーク上でのエラーメッセージや診断情報を伝達するためのプロトコルです。
OSI参照モデルではネットワーク層(レイヤー3)に位置し、IP パケットのペイロードとして運ばれます。
代表的な ICMP メッセージには以下のようなものがあります。
- Echo Request / Echo Reply(Type 8 / 0) : ping で使用される疎通確認
- Destination Unreachable(Type 3) : 宛先に到達できない場合のエラー通知
- Time Exceeded(Type 11) : TTL が 0 になった場合のエラー通知(traceroute で使用)
本記事では、ping の応答に必要な Echo Request / Echo Reply のみを実装します。
ICMPヘッダの構造
ICMPヘッダは比較的シンプルな構造です。
struct ICMPHeader {
uint8_t type; ///< Type
uint8_t code; ///< Code
uint16_t checksum; ///< Checksum
} __attribute__((packed));
| フィールド | 役割 |
|---|---|
type |
ICMPメッセージの種類(Echo Request = 8, Echo Reply = 0) |
code |
メッセージのサブタイプ(Echo では常に 0) |
checksum |
ICMPヘッダとデータ部分全体の整合性を検証するためのチェックサム |
ICMP Echo Request / Reply の構造
ping で使用される Echo Request / Reply は、基本の ICMP ヘッダを拡張した構造になっています。
struct ICMPEchoHeader {
ICMPHeader icmp_header; ///< ICMP Header
uint16_t id; ///< Identifier
uint16_t sequence; ///< Sequence Number
uint8_t data[]; ///< Payload Data
} __attribute__((packed));
| フィールド | 役割 |
|---|---|
id |
識別子。送信元がリクエストとリプライを対応付けるために使用 |
sequence |
シーケンス番号。連続する ping リクエストを区別するために使用 |
data |
任意のペイロードデータ。リプライではリクエストと同じ内容をそのまま返す |
ICMPパケットの解析
IPv4ペイロードから取り出した ICMPパケットを解析し、 type フィールドを確認することで、 リクエスト(ECHO_REQUEST)かリプライ(ECHO_REPLY)かを判別します。
void process_icmp(const ICMPHeader& icmp_header, uint32_t src_ip, size_t icmp_len)
{
switch (static_cast<ICMPType>(icmp_header.type)) {
case ICMPType::ECHO_REQUEST:
handle_icmp_echo_request(
const_cast<ICMPEchoHeader*>(
reinterpret_cast<const ICMPEchoHeader*>(&icmp_header)),
src_ip, icmp_len);
break;
case ICMPType::ECHO_REPLY:
LOG_ERROR("ICMP Echo Reply received");
break;
default:
LOG_ERROR("Unknown ICMP type received");
break;
}
}
Echo Reply の構築と送信
ICMP Echo Request を受信したら、Echo Reply を構築して返送します。
void handle_icmp_echo_request(kernel::net::ICMPEchoHeader* echo_header,
uint32_t src_ip,
size_t icmp_len)
{
size_t payload_len = icmp_len - sizeof(ICMPEchoHeader);
// リプライ用バッファを確保し、リクエストの内容をコピー
uint8_t reply_buf[sizeof(ICMPEchoHeader) + payload_len];
ICMPEchoHeader* reply = reinterpret_cast<ICMPEchoHeader*>(reply_buf);
memcpy(reply, echo_header, icmp_len);
// タイプをECHO_REPLYに変更
reply->icmp_header.type = static_cast<uint8_t>(ICMPType::ECHO_REPLY);
reply->icmp_header.code = 0;
// チェックサムの再計算
reply->icmp_header.checksum = 0;
reply->icmp_header.checksum = calculate_checksum(reply, icmp_len);
// IPv4パケットとして送信
transmit_ipv4_packet(src_ip, IPv4Protocol::ICMP, reply, icmp_len);
}
処理の流れは以下の通りです。
-
リクエスト内容のコピー
id、sequence、dataをそのまま維持するため、受信したパケット全体をコピーします。 -
type の変更
typeをECHO_REQUEST(8)からECHO_REPLY(0)に変更します。 -
チェックサムの再計算
typeを変更したため、チェックサムを再計算する必要があります。
計算前にchecksumフィールドを 0 にクリアしておくことがポイントです。 -
送信
構築した Echo Reply を IPv4 パケットに載せて、リクエスト元の IP アドレスに返送します。
この処理により、ホストからの ping に対して正しく応答できるようになります。
パケットの送信とカプセル化
受信時はイーサネット → IPv4 → ICMP の順にヘッダを剥がしていきました(デカプセル化)。
送信時はこの逆で、ICMP → IPv4 → イーサネット の順にヘッダを付加していきます。これをカプセル化と呼びます。
以下の transmit_ipv4_packet 関数は、ICMP ペイロードに IPv4 ヘッダを付加し、さらにイーサネットフレームとして送信する処理を行います。
void transmit_ipv4_packet(uint32_t dst_ip,
IPv4Protocol protocol,
const void* payload,
size_t payload_len)
{
// IPv4 ヘッダの構築
IPv4Header ip_header;
size_t total_len = sizeof(IPv4Header) + payload_len;
ip_header.version_ihl = 0x45; // IPv4, ヘッダ長 20バイト(5 × 4)
ip_header.dscp_ecn = 0;
ip_header.total_length = htons(static_cast<uint16_t>(total_len));
ip_header.ttl = DEFAULT_TTL;
ip_header.protocol = static_cast<uint8_t>(protocol); // ICMP = 1
ip_header.src_ip = htonl(hw::virtio::MY_IP);
ip_header.dst_ip = htonl(dst_ip);
// チェックサムの計算(計算前に 0 クリア)
ip_header.header_checksum = 0;
ip_header.header_checksum = calculate_checksum(&ip_header, sizeof(IPv4Header));
// IPv4 ヘッダ + ペイロードを結合
uint8_t packet_buffer[sizeof(IPv4Header) + payload_len];
memcpy(packet_buffer, &ip_header, sizeof(IPv4Header));
memcpy(packet_buffer + sizeof(IPv4Header), payload, payload_len);
// 宛先 MAC アドレスを ARP テーブルから取得
uint8_t dst_mac[6] = { 0 };
if (!arp_table.resolve(dst_ip, dst_mac)) {
LOG_ERROR("Failed to resolve MAC address for IP: %08x", dst_ip);
return;
}
// イーサネットフレームとして送信
transmit_ethernet_frame(dst_mac, EthernetFrameType::IPV4, packet_buffer,
total_len);
}
処理の流れは以下の通りです。
-
IPv4 ヘッダの構築
バージョン、ヘッダ長、TTL、プロトコル種別、送信元/宛先 IP アドレスなどを設定します。
version_ihl = 0x45は、上位4ビットがバージョン(4 = IPv4)、下位4ビットがヘッダ長(5 × 4 = 20バイト)を表します。 -
チェックサムの計算
IPv4 ヘッダのチェックサムを計算します。ICMP のチェックサムとは別に、IPv4 ヘッダ専用のチェックサムが必要です。 -
パケットの結合
IPv4 ヘッダと ICMP ペイロードを連結して、1つの IPv4 パケットを作成します。 -
MAC アドレスの解決
宛先 IP アドレスに対応する MAC アドレスを ARP テーブルから取得します。
ここで、先ほど ARP Reply を受信した際に学習した MAC アドレスが活用されます。 -
イーサネットフレームとして送信
最後に、イーサネットヘッダを付加してtransmit_ethernet_frameで送信します。
この一連のカプセル化処理により、ICMP Echo Reply がホストに届けられ、ping が成功します。
pingの疎通確認
ここまでの実装で、ホストから自作 OS への ping が成功するようになりました。
ホスト側から以下のコマンドを実行します。
ping 192.168.100.200

PING 192.168.100.200 (192.168.100.200) 56(84) bytes of data.
64 bytes from 192.168.100.200: icmp_seq=1 ttl=32 time=52.8 ms
64 bytes from 192.168.100.200: icmp_seq=2 ttl=32 time=22.5 ms
64 bytes from 192.168.100.200: icmp_seq=3 ttl=32 time=22.6 ms
64 bytes from 192.168.100.200: icmp_seq=4 ttl=32 time=21.9 ms
64 bytes from 192.168.100.200: icmp_seq=5 ttl=32 time=21.4 ms
64 bytes from 192.168.100.200: icmp_seq=6 ttl=32 time=20.5 ms
64 bytes from 192.168.100.200: icmp_seq=7 ttl=32 time=20.3 ms
64 bytes from 192.168.100.200: icmp_seq=8 ttl=32 time=30.0 ms
64 bytes from 192.168.100.200: icmp_seq=9 ttl=32 time=29.1 ms
^C
--- 192.168.100.200 ping statistics ---
9 packets transmitted, 9 received, 0% packet loss, time 8160ms
rtt min/avg/max/mdev = 20.261/26.788/52.795/9.784 ms
応答が返ってきていることが確認できます。
これで、自作 OS が以下の一連の処理を正しく行えていることが確認できました。
- ARP Request を受信 → 自分の MAC アドレスを返す(ARP Reply)
- ICMP Echo Request を受信 → IPv4 ヘッダを解析し、ICMP Echo Reply を返す
- カプセル化して送信 → イーサネットフレームとしてホストに届ける
ハマったポイント
実装中にハマったポイントを紹介します。
バイトオーダー(エンディアン)の変換忘れ
ネットワークプロトコルでは、複数バイトの数値はビッグエンディアン(ネットワークバイトオーダー) で表現されます。
一方、x86/x64 CPU はリトルエンディアンを使用するため、変換を忘れると値が正しく解釈されません。
例えば、EtherType の値 0x0800(IPv4)は、メモリ上では 0x00 0x08 の順で格納されています。
これを変換せずに比較すると、0x0008 として解釈されてしまい、パケットの種別判定が失敗します。
// NG: 変換忘れ
if (frame->ethertype == 0x0800) { // 常に false になる
...
}
// OK: ntohs() で変換
if (ntohs(frame->ethertype) == 0x0800) {
...
}
本記事で使用している変換関数は以下の通りです。
| 関数 | 用途 |
|---|---|
ntohs() |
Network to Host Short(16ビット、受信時) |
ntohl() |
Network to Host Long(32ビット、受信時) |
htons() |
Host to Network Short(16ビット、送信時) |
htonl() |
Host to Network Long(32ビット、送信時) |
私は ARP パケットの opcode や IPv4 ヘッダの total_length で変換を忘れ、パケットが正しく処理されない問題に何度か遭遇しました。
チェックサム計算時のゼロクリア忘れ
IPv4 ヘッダや ICMP ヘッダのチェックサムを計算する際、計算前に checksum フィールドを 0 にクリアする必要があります。
// NG: ゼロクリア忘れ
ip_header.header_checksum = calculate_checksum(&ip_header, sizeof(IPv4Header));
// OK: 先にゼロクリア
ip_header.header_checksum = 0;
ip_header.header_checksum = calculate_checksum(&ip_header, sizeof(IPv4Header));
チェックサムはヘッダ全体を対象に計算されるため、checksum フィールド自身も計算に含まれます。
ゼロクリアせずに計算すると、不定値が含まれて正しいチェックサムが得られません。
私はこのミスに気づかず、Wireshark で「Header checksum: incorrect」と表示されて原因究明に時間がかかりました。
おわりに
本記事では、自作 OS にネットワーク機能を追加し、ホストからの ping に応答するまでの実装を解説しました。
実装した内容を振り返ると、以下の流れでパケットを処理しています。
- virtio-net からイーサネットフレームを受信
- イーサネットヘッダ を解析し、ARP / IPv4 を振り分け
- ARP Request に対して ARP Reply を返し、MAC アドレスを通知
- IPv4 ヘッダ を解析し、ICMP パケットを取り出す
- ICMP Echo Request に対して Echo Reply を返す
- カプセル化 して送信(ICMP → IPv4 → イーサネット)
ネットワークプロトコルは、本や資料で読むだけだとレイヤー間の関係やバイト列の扱いがイメージしづらい部分も多いですが、実際に手を動かして実装してみると理解が深まりました。
特に、デカプセル化とカプセル化の対称性や、ARP テーブルが後続の通信でどう活用されるかなど、実装を通じて初めて腑に落ちる部分が多々ありました。
参考資料
Discussion