
C++ のビットフィールドを Rust で再現してみた
はじめに
以前、ゲーム開発をしていた頃の話ですが、ネットワークで送るパケットサイズを抑えるため、C++ のビットフィールドへ複数の値を詰めたことがありました。同じことが Rust でもできるのか気になったので、やってみることにしました。
#[repr(C, packed)] を付ければ構造体をビット単位で詰め、そのままネットワークへ送れるように思えます。しかし今回は、repr(packed) を使わず、シフトとマスクを使って明示的に変換する方法で 2 バイトに詰めることにしました。
Rust とは
Rust は、メモリ安全性と実行性能の両立を目指すシステムプログラミング言語です。ガベージコレクターを使わず、所有権と借用の仕組みによってメモリを管理します。コンパイラーが、解放後のメモリ参照やデータ競合につながる多くの操作をコンパイル時に検出します。
Rust はメモリレイアウトやアラインメント、ポインターなどを扱う低水準な機能も備えています。安全性を確認できない操作は unsafe として明示する設計です。ただ今回扱うビットパッキングでは unsafe を使わず、整数のビット演算とバイト列への変換だけで通信形式を作っていきます。
検証環境
- macOS 26.6.2、arm64
- rustc 1.98.1、host
aarch64-apple-darwin - Apple clang 21.0.0、target
arm64-apple-darwin25.6.0 - Rust の外部クレート: なし
対象読者
- C/C++ でビットフィールドや構造体のパッキングを使った経験がある方
- Rust でバイナリプロトコルを実装したい方
- ゲーム、組み込み、IoT などで通信量を抑えたい方
参考資料
- Type layout - The Rust Reference
- Behavior considered undefined - The Rust Reference
- u16 - Rust
- std::ptr::read_unaligned - Rust
- Bit-fields - C++ working draft
検証
C++ ビットフィールドの再現
今回は、次の 4 フィールドを 16 ビットへ収めます。パケットのバイト順はビッグエンディアンとします。
| ビット | フィールド | ビット数 | 値域 | 代表値 |
|---|---|---|---|---|
| 15..13 | version |
3 | 0 から 7 | 5 |
| 12 | compressed |
1 | 0 または 1 | 1 |
| 11..8 | message_type |
4 | 0 から 15 | 10 |
| 7..0 | payload_len |
8 | 0 から 255 | 127 |
代表値を仕様どおりに並べると、次のビット列になります。
101 1 1010 01111111
16 進数では 0xba7f で、ネットワークへ送るバイト列は ba 7f です。
まず、過去に使っていた方法を再現してみました。
#include <array>
#include <bit>
#include <cstddef>
#include <cstdint>
#include <iomanip>
#include <iostream>
struct Header {
std::uint16_t message_type : 4;
std::uint16_t compressed : 1;
std::uint16_t version : 3;
std::uint16_t payload_len : 8;
};
int main() {
Header header{.message_type = 10,
.compressed = 1,
.version = 5,
.payload_len = 0x7f};
const auto bytes = std::bit_cast<std::array<std::byte, sizeof(Header)>>(header);
std::cout << "size=" << sizeof(Header) << " alignment=" << alignof(Header)
<< '\n';
std::cout << "fields=" << header.version << ',' << header.compressed << ','
<< header.message_type << ',' << header.payload_len << '\n';
std::cout << "object bytes=";
for (const auto byte : bytes) {
std::cout << ' ' << std::hex << std::setw(2) << std::setfill('0')
<< std::to_integer<unsigned int>(byte);
}
std::cout << '\n';
}
コンパイルして実行するとこうなりました。
c++ -std=c++20 -Wall -Wextra -pedantic bitfield.cpp -o cpp-bitfield
./cpp-bitfield
size=2 alignment=2
fields=5,1,10,127
object bytes= ba 7f
構造体は期待どおり 2 バイトになり、オブジェクト表現も今回定義した通信形式と同じ ba 7f になりました。
Rust の構造体レイアウト
続いて、Rust で同じ 4 個の論理値を持つ構造体を作りました。各フィールドをu8またはboolとして宣言し、既定のrepr(Rust)、repr(C)、repr(C, packed)を比較します。
use std::mem::{align_of, size_of};
struct RustHeader {
version: u8,
compressed: bool,
message_type: u8,
payload_len: u8,
}
#[repr(C)]
struct CHeader {
version: u8,
compressed: bool,
message_type: u8,
payload_len: u8,
}
#[repr(C, packed)]
struct PackedHeader {
version: u8,
compressed: bool,
message_type: u8,
payload_len: u8,
}
fn print_layout<T>(name: &str) {
println!(
"{name}: size={} bytes, alignment={} byte(s)",
size_of::<T>(),
align_of::<T>()
);
}
fn main() {
print_layout::<RustHeader>("repr(Rust)");
print_layout::<CHeader>("repr(C)");
print_layout::<PackedHeader>("repr(C, packed)");
}
実行結果は次のとおりです。
repr(Rust): size=4 bytes, alignment=1 byte(s)
repr(C): size=4 bytes, alignment=1 byte(s)
repr(C, packed): size=4 bytes, alignment=1 byte(s)
repr(C, packed) を付けても 2 バイトにはなりませんでした。 それぞれのフィールドが 1 バイトなので、フィールド間のパディングをなくしても合計は 4 バイトです。
repr(packed) は、フィールド自体をビット単位へ圧縮するわけではないためこのような結果になっています。repr(packed) は型のアラインメントを下げてフィールド間のパディングを減らすためのものです。また、repr(packed) を単独で指定してもフィールド順は保証されません。
パックされたフィールドへの参照
repr(packed) には、サイズとは別に未整列アクセスへの注意も必要です。未整列アクセスの問題が表れるように、u8 の後ろへ u16 を置いた最小例を作ります。
#[repr(C, packed)]
struct PackedHeader {
tag: u8,
payload_len: u16,
}
fn main() {
let header = PackedHeader {
tag: 1,
payload_len: 256,
};
let payload_len = &header.payload_len;
println!("{payload_len}");
}
このコードはコンパイルできません。
error[E0793]: reference to field of packed struct is unaligned
--> packed-field-reference.rs:12:23
|
12 | let payload_len = &header.payload_len;
| ^^^^^^^^^^^^^^^^^^^
u16 は通常 2 バイト境界への整列を必要とします。しかし、パックされた構造体では、その境界に置かれているとは限りません。未整列の場所へ通常の参照を作ると未定義動作になるため、Rust はコンパイル時に拒否します。
実装
プログラム内では、各値を読みやすい Header として保持します。通信時だけ、この構造体と [u8; 2] の間を変換します。
#[derive(Debug, PartialEq)]
pub struct Header {
pub version: u8,
pub compressed: bool,
pub message_type: u8,
pub payload_len: u8,
}
#[derive(Debug, PartialEq)]
pub enum EncodeError {
VersionOutOfRange(u8),
MessageTypeOutOfRange(u8),
}
impl Header {
pub fn encode(&self) -> Result<[u8; 2], EncodeError> {
if self.version > 0b111 {
return Err(EncodeError::VersionOutOfRange(self.version));
}
if self.message_type > 0b1111 {
return Err(EncodeError::MessageTypeOutOfRange(self.message_type));
}
let packed = (u16::from(self.version) << 13)
| (u16::from(self.compressed) << 12)
| (u16::from(self.message_type) << 8)
| u16::from(self.payload_len);
Ok(packed.to_be_bytes())
}
pub fn decode(bytes: [u8; 2]) -> Self {
let packed = u16::from_be_bytes(bytes);
Self {
version: ((packed >> 13) & 0b111) as u8,
compressed: ((packed >> 12) & 0b1) != 0,
message_type: ((packed >> 8) & 0b1111) as u8,
payload_len: (packed & 0xff) as u8,
}
}
}
encode では、各値を仕様上の位置まで左シフトし、OR で 1 個の u16 へまとめています。最後に to_be_bytes を呼び、バイト順をビッグエンディアンへ固定します。
version と message_type は、Rust では u8 なので、それぞれ 3 ビットと 4 ビットを超える値も保持できます。超過分を黙って切り捨てると呼び出し側の誤りを発見できないため、エンコード前に値域を検証しています。
代表値を変換すると、仕様どおりの結果になりました。
Header: size=4 bytes, alignment=1 byte(s)
wire length: 2 bytes
wire bytes: [ba, 7f]
wire bits: 1011101001111111
decoded: Header { version: 5, compressed: true, message_type: 10, payload_len: 127 }
固定バイト列と全組み合わせのテスト
エンコードした値を同じ実装でデコードし、元へ戻ることを確認するだけでは、両方に同じビット位置の誤りがあった場合にテストを通過してしまいます。
そこで、最初に仕様から求めた [0xba, 0x7f] をゴールデン値として固定しました。
#[test]
fn encodes_known_header_to_expected_bytes() {
let header = Header {
version: 5,
compressed: true,
message_type: 10,
payload_len: 0x7f,
};
assert_eq!(header.encode(), Ok([0xba, 0x7f]));
}
ゴールデン値に加えて、最小値・最大値・仕様外入力の拒否を確認しました。今回の仕様で取り得る値は、8 × 2 × 16 × 256 = 65,536 通りです。すべての組み合わせについて、エンコードしてからデコードすると元へ戻ることもテストしました。
#[test]
fn round_trips_all_valid_headers() {
for version in 0..=0b111 {
for compressed in [false, true] {
for message_type in 0..=0b1111 {
for payload_len in 0..=0xff {
let header = Header {
version,
compressed,
message_type,
payload_len,
};
assert_eq!(Header::decode(header.encode().unwrap()), header);
}
}
}
}
}
手元の環境では全テストが成功しました。
running 6 tests
test result: ok. 6 passed; 0 failed; 0 ignored
考察
repr ではなぜ 2 バイトにならなかったのか
Rust の repr は、構造体をメモリ上へどのように配置するかを指定する機能です。repr(packed) が変えるのは各フィールドのアラインメントと、その結果として生じるフィールド間のパディングであり、u8 や bool 自体の大きさは変えません。ビット幅まで変えると同じ型の値域や扱い方まで変わってしまうため、repr では型を保ったままメモリ上の配置だけを変える設計にしているのではないかと考えられます。
C++ でできたことは Rust でもできたのか
複数の値を仕様どおりの 2 バイトへ詰めるという目的は、Rust でも達成できたと言えます。ただし、Rust には今回利用できる C++ と同等の組み込みビットフィールドがないため、シフトとマスクによって通信形式を明示的に組み立てています。同じ仕組みを用意するよりも、処理系のメモリレイアウトから通信形式を切り離す方が、Rust の移植性や安全性を重視する設計には合っているのかもしれません。
まとめ
C++ のビットフィールドで作っていた 2 バイトのパケットヘッダーは、Rust でも作成できました。repr(packed) はビットフィールドとは異なり、今回の構造体を 2 バイトにはできませんでした。Rust ではシフトとマスクを使って通信形式を明示することで、同じ 2 バイトを生成できました。C++ から Rust へパケット処理を移す際の参考になれば幸いです。




