Bit Fields in C++

Andreas Hohmann February 25, 2025 #c++ #bitfield #templates

Whether we are dealing with network protocols or device drivers, system-level programming often involves bit fields. As a system programming language, C offers a concise syntax to specify bit fields in C strctures, for example

struct CStylePacket {
  u16 a : 3;
  u16 b : 9;
  u16 c : 2;
};

Here and in the following examples we are using the short type names popularized by Rust:

using u32 = std::uint32_t;
using u16 = std::uint16_t;
using u8 = std::uint8_t;

How are these bit fields mapped to memory? This unfortunately depends on the compiler and processor architecture, and there is no standard way to define the layout precisely.

Most compilers choose the layout depending on the endianness of the target processor architecture. For little-endian architectures (such as Intel's x86), the bit fields are assigned from least to most significant bit ("right to left"), and for big-endian architectures such as ARM, the fields are arranged in the opposite direction from most to least significant bit ("left to right").

On my Intel desktop, I can demonstrate the little-endian layout with the following unit test (using gtest):

TEST(BitFieldTest, SetsBitFields) {
  CStylePacket p{.a = 5, .b = 300, .c = 2};

  EXPECT_EQ(sizeof(p), 2);

  std::span<u8> data(reinterpret_cast<u8 *>(&p), sizeof(p));

  EXPECT_EQ(data.size(), 2);

  EXPECT_EQ(p.a, 0b101);
  EXPECT_EQ(p.b, 0b100101100);
  EXPECT_EQ(p.c, 0b10);

  EXPECT_EQ(data[0], 0b01100101);
  EXPECT_EQ(data[1], 0b00101001);

  EXPECT_THAT(data, ElementsAre(0b01100101, 0b00101001));
}

Specifications, in particular in networking, typically define the bit layout in big-endian order (also known as network order). Here is the layout of an IPv4 header (omitting the options):

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version|  IHL  |Type of Service|          Total Length         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|         Identification        |Flags|      Fragment Offset    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Time to Live |    Protocol   |         Header Checksum       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       Source Address                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Destination Address                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

If we want to map a C structure to such a network packet, we have to define the bit fields twice using a C macro that checks the endianness. The Linux IP header ip.h contains the following iphdr structure:

struct iphdr {
#if defined(__LITTLE_ENDIAN_BITFIELD)
	__u8	ihl:4,
		version:4;
#elif defined (__BIG_ENDIAN_BITFIELD)
	__u8	version:4,
  		ihl:4;
#else
#error	"Please fix <asm/byteorder.h>"
#endif
	__u8	tos;
	__be16	tot_len;
	__be16	id;
	__be16	frag_off;
	__u8	ttl;
	__u8	protocol;
	__sum16	check;
	__struct_group(/* no tag */, addrs, /* no attrs */,
		__be32	saddr;
		__be32	daddr;
	);
	/*The options start here. */
};

In addition to the bitfield order, we also have to consider the endianness when reading multi-byte integers. The Posix standard defines the hton (host to network) and ntoh (network to host) functions (e.g., htons) for this purpose.

Another problem is the alignment of the mapped structure. If the byte array containing the data (for example, a packet) is not properly aligned, we have to copy the data to a new structure.

This is all managable but definitely lowers the signal-noise ratio of low-level code and invites subtle errors. Can we do better with C++? We would like to take some raw data (given as a byte array) and view it according to a precise layout with bit fields. It should be possible to get and set these fields (directly in the raw data) using normal field accessor syntax and without having to worry about endianness or alignment.

The first step towards this end is a way to read and write sequences of bits as integers. As we are mainly interested in networking, we stick to network (big-endian) order. The following function reads a sequence of bits from a byte array.

// Returns the value of the `data` bits from `bit_first` to `bit_end` (inclusive).
//
// The bits are interpreted in network order. Bit 0 is the most significant
// bit of the first byte (data[0]), bit 8 is the most significant bit of the
// second byte (data[1]), and so forth.
template <std::unsigned_integral V = u32>
constexpr V get_bitfield(u8 const *const data, u32 bit_first, u32 bit_last) {
  // Index of the byte containing the first bit.
  u32 i_first = bit_first / 8;

  // Bits of first byte containing the bit range.
  u8 mask_first = 0xff >> (bit_first % 8);

  // Index of the byte containing the last bit.
  u32 i_last = bit_last / 8;

  // Number of bits following the bit range in the last byte.
  u8 s_last = 7 - bit_last % 8;

  V x = data[i_first] & mask_first;
  for (int i = i_first + 1; i <= i_last; ++i) {
    x = (x << 8) | data[i];
  }
  return x >> s_last;
}

I use a closed range from bit_first to bit_last because that's how bit fields are typically specified (as in the IP header example above). After determining the indices of the first and last byte containing bits of the range, the only tricky part is the masking of the bits in the first and last byte. The latter is done by shifting the result in the last line.

Note that I kept the clearer arithmetic division and modulo operation in the hope that the compiler will replace them with the more efficient shift operations.

Writing to a bit field is only slightly more complicated. The main difference is that we start with the last byte. The set_masked helper function is used for setting the bits in the first and last byte (which is the same if the bitfield is contained in a single byte).

// Copies the `mask` bits of the `value` to the `data`.
template <std::unsigned_integral V = u32>
constexpr void set_masked(u8& data, u8 mask, V value) {
  data = (data & ~mask) | (value & mask);
}

// Sets `data` bits from `bit_first` to `bit_last` (inclusive) to `value`.
//
// The bits are interpreted in network order. Bit 0 is the most significant
// bit of the first byte (data[0]), bit 8 is the most significant bit of the
// second byte (data[1]), and so forth.
template <std::unsigned_integral V = u32>
constexpr void set_bitfield(u8* const data, u32 bit_first, u32 bit_last, V value) {
  // index of the byte containing the first bit
  u32 i_first = bit_first / 8;

  // bits of the first byte containing the bit range
  u8 mask_first = 0xff >> (bit_first % 8);

  // index of the byte containing the last bit
  u32 i_last = bit_last / 8;

  // number of bits following the bit range in the last byte
  u8 s_last = 7 - bit_last % 8;

  // bits of the last bytes containing the bit range
  u8 mask_last = (0xff << s_last) & 0xff;

  // shift value so that it aligns with the data bits
  value <<= s_last;

  // single byte case
  if (i_first == i_last) {
    set_masked(data[i_first], mask_first & mask_last, value);
    return;
  }

  // set last byte
  set_masked(data[i_last], mask_last, value);
  value >>= 8;

  // set intermediate bytes
  for (int i = i_last - 1; i > i_first; --i) {
    data[i] = value & 0xff;
    value >>= 8;
  }

  // set first byte
  set_masked(data[i_first], mask_first, value);
}

If we want to define a structure using bitfields, the bit range will be known at compile time. The parameters bit_first and bit_last can become template parameters, and we can sprinkle the code with some constexpr modifiers to show which operations are performed at compile time.

template <u32 bit_first, u32 bit_last, std::unsigned_integral V = u32>
  requires(bit_first <= bit_last)
V get_bitfield_tmpl(u8 const *const data) {
  // index of the byte containing the first bit
  constexpr u32 i_first = bit_first / 8;

  // bits of first byte containing the bit range
  constexpr u8 mask_first = 0xff >> (bit_first % 8);

  // index of the byte containing the last bit
  constexpr u32 i_last = bit_last / 8;

  // number of bits following the bit range in the last byte
  constexpr u8 s_last = 7 - bit_last % 8;

  V x = data[i_first] & mask_first;
  for (int i = i_first + 1; i <= i_last; ++i) {
    x = (x << 8) | data[i];
  }
  return x >> s_last;
}

Besides the constexpr expressions, the compiler will also unroll the for-loop or drop it completely. In the setter function, the only if-statement can also be evaluated at compile time.

template <u32 bit_first, u32 bit_last, std::unsigned_integral V = u32>
  requires(bit_first <= bit_last)
void set_bitfield_tmpl(u8 *const data, V value) {
  // index of the byte containing the first bit
  constexpr u32 i_first = bit_first / 8;

  // bits of the first byte containing the bit range
  constexpr u8 mask_first = 0xff >> (bit_first % 8);

  // index of the byte containing the last bit
  constexpr u32 i_last = bit_last / 8;

  // number of bits following the bit range in the last byte
  constexpr u8 s_last = 7 - bit_last % 8;

  // bits of the last bytes containing the bit range
  constexpr u8 mask_last = (0xff << s_last) & 0xff;

  // shift value so that it aligns with the data bits
  value <<= s_last;

  // single byte case
  if constexpr (i_first == i_last) {
    set_masked(data[i_first], mask_first & mask_last, value);
    return;
  }

  // set last byte
  set_masked(data[i_last], mask_last, value);
  value >>= 8;

  // set intermediate bytes
  for (int i = i_last - 1; i > i_first; --i) {
    data[i] = value & 0xff;
    value >>= 8;
  }

  // set first byte
  set_masked(data[i_first], mask_first, value);
}

These two functions take care of accessing the bits in network order, but don't give us the easy field access yet. C++ usually provides ways to define custom objects that look like the built-in objects. For fields, we can take advantage of the ability to override conversion and assignment operators. A first solution for a "field object" could look like this:

template <u32 bit_first, u32 bit_last, std::unsigned_integral V = u32>
  requires(bit_first <= bit_last)
class PtrBitField {
public:
  explicit PtrBitField(u8 *const data) : data_(data) {}

  operator V() const { return get_bitfield_tmpl<bit_first, bit_last, V>(data_); }

  PtrBitField &operator=(V value) {
    set_bitfield_tmpl<bit_first, bit_last, V>(data_, value);
    return *this;
  }

private:
  u8 *const data_;
};

Using this class, we can define a packet view that lets us act on the bit fields as if they were normal fields.

class Packet {
public:
  explicit Packet(u8 *const data) : a(data), b(data) {}

  PtrBitField<0, 2, u16> a;
  PtrBitField<3, 8, u16> b;
};

TEST(PacketTest, GetsBitFields) {
  u8 data[]{0xc7, 0x96};
  Packet p(data);

  EXPECT_EQ(sizeof(p), 2 * sizeof(u8 *));

  EXPECT_EQ(data[0], 0xc7);

  EXPECT_EQ(p.a, 6);
  EXPECT_EQ(p.b, 15);

  p.a = 5;
  EXPECT_EQ(p.a, 5);
  EXPECT_EQ(p.b, 15);
  p.b = 17;
  EXPECT_EQ(p.a, 5);
  EXPECT_EQ(p.b, 17);

  EXPECT_EQ(data[0], 0xa8);
  EXPECT_EQ(data[1], 0x96);
}

There is one problem, however. Each field object has its own copy of the data pointer so that the size of the Packet class is proportional to the number of fields. To work around this, we can employ a trick and turn the packet structure into a union (see, for example, BitFieldMember).

The following ThinBitField assumes that it uses the same memory as a type T that has data() methods returning a pointer to the (const and non-const) byte array, that is, a type satisfying the concept

template <typename T>
concept DataHolder = requires(T &a, T const &b) {
  { a.data() } -> std::convertible_to<u8 *>;
  { b.data() } -> std::convertible_to<u8 const *>;
  { T::data_size } -> std::convertible_to<u32>;
};

We also include a data_size constant so that we can check the start and end of the bit fields at compile time. It's up to the type T to make sure (possibly at runtime) that the data has at least as many bytes.

Unfortunately, we cannot use the concept in the follwoing ThinBitField template, because the type is not fully defined when we use the fields.

template <typename /* DataHolder */ T, u32 bit_first, u32 bit_last,
          std::unsigned_integral V = u32>
  requires(bit_first <= bit_last && bit_last < T::data_size * 8)
class ThinBitField {
public:
  operator V() const {
    return get_bitfield_tmpl<bit_first, bit_last, V>(data());
  }

  ThinBitField &operator=(V value) {
    set_bitfield_tmpl<bit_first, bit_last, V>(data(), value);
    return *this;
  }

private:
  u8 *data() { return reinterpret_cast<T *>(this)->data(); }
  u8 const *data() const { return reinterpret_cast<T const *>(this)->data(); }
};

The packet view is now defined as a union so that all fields overlap the memory of the packet object:

union ThinPacket {
public:
  constexpr static u32 data_size = 2;

  explicit ThinPacket(std::span<u8> data) : data_(data) {
    assert(data.size() >= data_size);
  }

  // `data()` getters have to be public to be visible from `ThinBitField`. One
  // cannot declare a partially specialized class template as a friend.

  u8 const *data() const { return data_.data(); }
  u8 *data() { return data_.data(); }

  ThinBitField<ThinPacket, 0, 2, u16> a;
  ThinBitField<ThinPacket, 3, 8, u16> b;

private:
  std::span<u8> data_;
};

The usage looks the same:

TEST(ThinPacket, AccessesBitFields) {
  u8 data[]{0xc7, 0x96};
  ThinPacket p(data);

  EXPECT_EQ(sizeof(p), sizeof(u8 *));

  EXPECT_EQ(p.a, 6);
  EXPECT_EQ(p.b, 15);

  p.a = 4;
  p.b = 23;

  EXPECT_EQ(p.a, 4);
  EXPECT_EQ(p.b, 23);
}

As a larger example, let's define the IP packet without the preprocessor directives and without the need to call the hton/ntoh functions:

union ThinIpPacket {
 public:
  constexpr static u32 data_size = 20;

  explicit ThinIpPacket(u8* const data) : data_(data) {}

  u8 const* data() const { return data_; }
  u8* data() { return data_; }

  template <int bit_start, int bit_end>
  using BitField = ThinBitField<ThinIpPacket, bit_start, bit_end>;

  // IP version
  BitField<0, 3> version;
  // internet header length in 4-byte words (4 bits, length up to 15*4 = 60
  // bytes)
  BitField<4, 7> ihl;
  // differentiated services code point
  BitField<8, 13> dscp;
  // explicit congestion notification
  BitField<14, 15> ecn;
  // total length
  BitField<16, 31> len;
  // identification
  BitField<32, 47> id;
  // flags
  BitField<48, 50> flags;
  // fragment offset, given in units of 8 bytes (all but the last fragment have
  // a length that's divisible by 8), 13 bits, max fragment offset therefore
  // 65528
  BitField<51, 63> fo;
  // time to live (hop count)
  BitField<64, 71> ttl;
  // protocol
  BitField<72, 79> protocol;
  // header checksum
  BitField<80, 95> chk;
  // source address
  BitField<96, 127> src;
  // destination address
  BitField<128, 159> dst;

 private:
  u8* const data_;
};

static_assert(DataHolder<ThinIpPacket>);

TEST(ThinIpPacketTest, AccessesBitFields) {
  // clang-format off
  //
  // see https://pressbooks.howardcc.edu/cmsy164/chapter/packet-analysis-ip-headers-tools-and-notes/
  u8 data[]{
      /* 00 */ 0x45, 0x00, 0x01, 0x23, 0x50, 0x6a, 0x00, 0x00,
      /* 08 */ 0x40, 0x11, 0x8d, 0x0f, 0xc0, 0xa8, 0x0d, 0x01,
      /* 10 */ 0xc0, 0xa8, 0x0d, 0xff, 0xd6, 0x83, 0xd6, 0x83,
      /* 18 */ 0x01, 0x0f, 0x63, 0x13, 0x00, 0x73, 0x79, 0x73,
      /* 20 */ 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x73, 0x2d, 0x4d,
      /* 28 */ 0x61, 0x63, 0x2d, 0x50, 0x72, 0x6f, 0x2e, 0x6c,
      /* 30 */ 0x6f, 0xf3, 0x61, 0x6c, 0x00, 0x31, 0xef, 0x6d,
      /* 38 */ 0xff, 0x7f, 0x00, 0x00, 0x10, 0xf2, 0x87, 0xa7,
      /* 40 */ 0x91, 0x7f, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
      /* 40 */ 0x00, 0x00, 0x00, 0x00, 0x10, 0x37, 0xc3, 0x01,
      /* 50 */ 0x00, 0x70,
  };
  // clang-format on
  ThinIpPacket p(data);

  EXPECT_EQ(p.version, 4);
  EXPECT_EQ(p.ihl, 5);
  EXPECT_EQ(p.dscp, 0);
  EXPECT_EQ(p.len, 0x0123);
  EXPECT_EQ(p.ttl, 64);
  EXPECT_EQ(p.protocol, 17); // UDP
  EXPECT_EQ(p.chk, 0x8d0f);
  EXPECT_EQ(p.src, 0xc0a80d01);
  EXPECT_EQ(p.dst, 0xc0a80dff);
}

I'm not claiming that this is the best way to deal with bit-level protocols, but the relatively straightforward code shows that it's possible to access bit fields in C++ in a way that looks like C bit fields but avoids their endianness and alignment issues.