std::span/std::expectedで設計するEthernet IIフレーム解析

C++26のエルゴノミクス(std::spanstd::expected、コンセプト、contracts)を主役に、Ethernet IIフレームのヘッダ解析を実装しました。ネットワークから届く生のバイト列を、コピーせず・安全に構造化データへ変換するところまでを扱います。

Ethernet IIフレームの構造

Ethernet IIフレームの先頭14バイトは固定フォーマットです。

フィールド サイズ 内容
宛先MACアドレス 6バイト
送信元MACアドレス 6バイト
EtherType 2バイト(ビッグエンディアン) ペイロードのプロトコル種別(0x0800=IPv4、0x0806=ARPなど)

この14バイトに続けて、可変長のペイロードが続きます。まずはこの固定長ヘッダ部分をパースし、ペイロードは後続のARP解析に渡すだけの形にします。

型設計:MacAddress・EtherType・EthernetFrame

// l2-arp/include/l2arp/ethernet.hpp
using MacAddress = std::array<std::byte, 6>;
std::string to_string(const MacAddress& mac);

enum class EtherType : std::uint16_t {
    Ipv4 = 0x0800,
    Arp  = 0x0806,
    Ipv6 = 0x86DD,
};

enum class EthernetParseError {
    FrameTooShort,
};

struct EthernetFrame {
    MacAddress destination;
    MacAddress source;
    EtherType ether_type;
    std::span<const std::byte> payload;
};

std::expected<EthernetFrame, EthernetParseError> parse_ethernet_frame(std::span<const std::byte> frame);

MacAddressstd::array<std::byte, 6>をそのままエイリアスにしています。専用の構造体でラップしてto_string()をメンバにする案も考えましたが、std::arrayのままにすれば比較演算などが標準でそのまま使え、余計なラッパーが不要になります。文字列化だけフリー関数として外に出しました。

EtherTypeは、生のuint16_t値を保持する別フィールドを持たず、enum class自体に未知の値も含めて格納しています。C++のenum classは宣言していない値も保持できるため、frame.ether_type == EtherType::Arpのように既知の値との比較はそのまま書け、未知の値が来てもstd::to_underlying()で取り出せます。「生の値」と「解釈済みの値」を二重に持つ必要がありません。

std::spanでゼロコピーに扱う

EthernetFrame::payloadstd::span<const std::byte>です。raw socketから読んだバッファがstd::vectorだろうとstd::arrayだろうと、コピーせずにそのままこの型で受け渡せます。

実装側でもstd::spanの別の側面を使っています。

// l2-arp/src/ethernet.cpp
namespace {
    MacAddress read_mac(std::span<const std::byte, 6> bytes) {
        MacAddress mac;
        std::ranges::copy(bytes, mac.begin());
        return mac;
    }
}

read_macの引数はstd::span<const std::byte, 6>——サイズがテンプレート引数として型に埋め込まれた、いわゆる静的extentのspanです。呼び出し側は

read_mac(frame.subspan<0, 6>())

のようにsubspanにもテンプレート引数でオフセットとサイズを渡します。こうすると「渡ってくるのは必ず6バイトである」ことが型で保証され、read_macの内部でサイズチェックを一切書く必要がなくなります。実行時チェックの代わりに、コンパイル時にサイズの整合性を型で表現している形です。

parse_ethernet_frameの実装

// l2-arp/src/ethernet.cpp
std::expected<EthernetFrame, EthernetParseError> parse_ethernet_frame(std::span<const std::byte> frame) {
    if (frame.size() < HeaderSize) {
        return std::unexpected(EthernetParseError::FrameTooShort);
    }

    return EthernetFrame{
        .destination = read_mac(frame.subspan<0, 6>()),
        .source = read_mac(frame.subspan<6, 6>()),
        .ether_type = static_cast<EtherType>(static_cast<std::uint16_t>(
            (std::to_integer<unsigned>(frame[12]) << 8) | std::to_integer<unsigned>(frame[13]))),
        .payload = frame.subspan(HeaderSize),
    };
}

サイズ不足はstd::unexpected(EthernetParseError::FrameTooShort)として呼び出し元に返します。例外を投げるのではなく、戻り値の型に失敗を含めることで、呼び出し側に処理を強制する設計です。

なぜここでcontractsを使わなかったか

C++26では、関数の事前条件・事後条件をpre()/post()として言語レベルで書けるcontractsという機能が入ります(GCC 15では-fcontractsフラグで利用可能)。今回parse_ethernet_frameにはこの機能を使っていません。

理由は、pre/postが想定している失敗の性質と、ここで起きうる失敗の性質が違うからです。parse_ethernet_frameが受け取るのは、ネットワークから届いた検証前の生バイト列そのものです。サイズが14バイト未満であることは、呼び出し側のバグではなく、外部入力として普通に起こりうる状態です。こうした「起こりうる失敗」は型で表現して呼び出し元に処理させるべきもの——つまりstd::expectedの役割であり、pre()のような「ここに来る時点でこの条件は満たされているはず」という前提を置く場所ではありません。

contractsが向いているのは、むしろこの先の実装で出てくる、「既に検証済みのデータだけを受け取る内部ヘルパー関数」のような場所です。境界での検証にはstd::expected、境界の内側の前提条件にはcontracts、という役割分担を、最初の実装から意識して線引きしています。

doctestによるテスト

テストには、ヘッダオンリーで依存が軽いdoctestを採用しました。CMakeのFetchContentでビルド時に取得しています。

// l2-arp/tests/ethernet_parse_test.cpp
TEST_CASE("parse_ethernet_frame: valid ARP frame") {
    constexpr std::array<std::byte, 18> raw_frame = {
        std::byte{0xFF}, std::byte{0xFF}, std::byte{0xFF},
        std::byte{0xFF}, std::byte{0xFF}, std::byte{0xFF},
        std::byte{0x00}, std::byte{0x11}, std::byte{0x22},
        std::byte{0x33}, std::byte{0x44}, std::byte{0x55},
        std::byte{0x08}, std::byte{0x06}, std::byte{0xAA},
        std::byte{0xBB}, std::byte{0xCC}, std::byte{0xDD},
    };

    auto result = l2arp::parse_ethernet_frame(raw_frame);

    REQUIRE(result.has_value());
    CHECK(l2arp::to_string(result->destination) == "ff:ff:ff:ff:ff:ff");
    CHECK(l2arp::to_string(result->source) == "00:11:22:33:44:55");
    CHECK(result->ether_type == l2arp::EtherType::Arp);
    CHECK(result->payload.size() == 4);
}

TEST_CASE("parse_ethernet_frame: too short") {
    constexpr std::array<std::byte, 10> raw_frame{};

    auto result = l2arp::parse_ethernet_frame(raw_frame);

    REQUIRE_FALSE(result.has_value());
    CHECK(result.error() == l2arp::EthernetParseError::FrameTooShort);
}

テストデータはコード中に決め打ちで書いたバイト列(合成バイト列)で、実際のネットワークやroot権限を一切必要としません。ヘッダ解析ロジックそのものの正しさは、この段階で切り離して検証できます。

参考リンク

  • cppreference: std::span —— 静的extentと動的extentの違いを含めた仕様の一次情報源です
  • cppreference: std::expected —— std::unexpectedとの組み合わせ方を確認する際に参照します
  • IEEE 802.3 —— Ethernet IIフレームフォーマットの一次情報源です
  • doctest —— 今回採用したテストフレームワーク本体です