std::span/std::expectedで設計するEthernet IIフレーム解析
C++26のエルゴノミクス(std::span、std::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);MacAddressはstd::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::payloadはstd::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 —— 今回採用したテストフレームワーク本体です