Skip to content

Bitfields

wirespec provides first-class support for sub-byte fields through bits[N] and bit. Consecutive bit fields are automatically grouped into a single wire read, and the compiler generates shift+mask extraction code for each field. No manual bit manipulation is required.

bits[N]

bits[N] declares an N-bit unsigned integer field. N can range from 1 to 32. The field is stored in the generated C struct as the smallest unsigned integer type that fits:

NC type
1–8uint8_t
9–16uint16_t
17–32uint32_t
wire
packet IPv4Header {
    version: bits[4],    # 4-bit unsigned, stored as uint8_t
    ihl: bits[4],        # 4-bit unsigned, stored as uint8_t
    dscp: bits[6],       # 6-bit unsigned, stored as uint8_t
    ecn: bits[2],        # 2-bit unsigned, stored as uint8_t
    total_length: u16,
    # ...
}

bit

bit is an alias for bits[1]. It is the natural type for boolean protocol flags:

wire
packet TcpSegment {
    # ...
    cwr: bit,
    ece: bit,
    urg: bit,
    ack: bit,
    psh: bit,
    rst: bit,
    syn: bit,
    fin: bit,
    # ...
}

In the generated C struct, bit fields become uint8_t (value is 0 or 1 after masking).

BitGroup Auto-Grouping

When wirespec encounters a run of consecutive bits[N] or bit fields, it automatically groups them into a single wire read. The total width of the group determines how many bytes are read:

  • Total ≤ 8 bits → read 1 byte (uint8_t)
  • Total ≤ 16 bits → read 2 bytes (uint16_t)
  • Total ≤ 32 bits → read 4 bytes (uint32_t)

A non-bit field (any u8, u16, bytes[...], etc.) breaks the current group and starts a new one on the next bits[N] field encountered.

Example: two 4-bit nibbles

wire
packet IPv4FirstByte {
    version: bits[4],   # \
    ihl: bits[4],       # / — grouped: 1 byte read
    # rest of packet
}

Generated C (parse):

c
uint8_t _bitgroup_0;
if (ws_cursor_read_u8(cur, &_bitgroup_0) != WIRESPEC_OK) return WIRESPEC_ERR_SHORT_BUFFER;
out->version = (_bitgroup_0 >> 4) & 0x0F;
out->ihl     = (_bitgroup_0     ) & 0x0F;

Example: TCP flags (8 single-bit fields)

wire
module net.tcp
@endian big

packet TcpSegment {
    src_port: u16,
    dst_port: u16,
    seq_num: u32,
    ack_num: u32,
    data_offset: bits[4],   # \
    reserved:    bits[4],   # / — group 0: 1 byte
    cwr: bit,               # \
    ece: bit,               #  |
    urg: bit,               #  |
    ack: bit,               #  | — group 1: 1 byte
    psh: bit,               #  |
    rst: bit,               #  |
    syn: bit,               #  |
    fin: bit,               # /
    window: u16,
    checksum: u16,
    urgent_pointer: u16,
    require data_offset >= 5,
    options: bytes[length: data_offset * 4 - 20],
}

The data_offset / reserved run (8 bits total) becomes one byte read. The eight flag fields (8 bits total) become a second byte read. Generated C for the flags group:

c
uint8_t _bitgroup_1;
if (ws_cursor_read_u8(cur, &_bitgroup_1) != WIRESPEC_OK) return WIRESPEC_ERR_SHORT_BUFFER;
out->cwr = (_bitgroup_1 >> 7) & 0x01;
out->ece = (_bitgroup_1 >> 6) & 0x01;
out->urg = (_bitgroup_1 >> 5) & 0x01;
out->ack = (_bitgroup_1 >> 4) & 0x01;
out->psh = (_bitgroup_1 >> 3) & 0x01;
out->rst = (_bitgroup_1 >> 2) & 0x01;
out->syn = (_bitgroup_1 >> 1) & 0x01;
out->fin = (_bitgroup_1     ) & 0x01;

Example: mixed widths across 16 bits

wire
packet IPv4FlagsFragment {
    flags:           bits[3],    # \
    fragment_offset: bits[13],   # / — grouped: 2 bytes (uint16_t)
}

Generated C:

c
uint16_t _bitgroup_0;
if (ws_cursor_read_u16(cur, &_bitgroup_0) != WIRESPEC_OK) return WIRESPEC_ERR_SHORT_BUFFER;
out->flags           = (_bitgroup_0 >> 13) & 0x0007;
out->fragment_offset = (_bitgroup_0      ) & 0x1FFF;

Example: 32-bit group

wire
packet ThirtyTwoBits {
    x: bits[4],
    y: bits[12],
    z: bits[16],
}

Total is 32 bits, so the group uses a single uint32_t read:

c
uint32_t _bitgroup_0;
if (ws_cursor_read_u32(cur, &_bitgroup_0) != WIRESPEC_OK) return WIRESPEC_ERR_SHORT_BUFFER;
out->x = (_bitgroup_0 >> 28) & 0x0000000F;
out->y = (_bitgroup_0 >> 16) & 0x00000FFF;
out->z = (_bitgroup_0      ) & 0x0000FFFF;

Big-Endian Packing (default)

With @endian big (the default), fields are packed MSB-first. The first field in the group occupies the most significant bits of the group word.

For a one-byte group containing version: bits[4], ihl: bits[4]:

Bit:     7  6  5  4  3  2  1  0
         [  version  ][   ihl  ]

The first declared field (version) sits at the top (MSB) of the byte. Extraction uses a right-shift equal to the number of bits remaining to the right of that field.

Little-Endian Packing

With @endian little, fields are packed LSB-first. The first field in the group occupies the least significant bits of the group word.

wire
@endian little

packet LeBits {
    flag_a: bits[3],   # bits [2:0] of the wire byte
    flag_b: bits[5],   # bits [7:3] of the wire byte
}
Bit:     7  6  5  4  3  2  1  0
         [    flag_b    ][ fa ]

Generated C for LE packing:

c
uint8_t _bitgroup_0;
if (ws_cursor_read_u8(cur, &_bitgroup_0) != WIRESPEC_OK) return WIRESPEC_ERR_SHORT_BUFFER;
out->flag_a = (_bitgroup_0     ) & 0x07;   // bits [2:0]
out->flag_b = (_bitgroup_0 >> 3) & 0x1F;   // bits [7:3]

The shift direction reverses: the first-declared field has the smallest shift (sits at LSB), and subsequent fields shift upward.

Full IPv4 Header Example

The complete IPv4 header from examples/ip/ipv4.wspec uses three separate bit groups interleaved with regular fields:

wire
module ip.v4
@endian big

packet IPv4Header {
    version: bits[4],           # \
    ihl: bits[4],               # / — group 0: 1 byte
    dscp: bits[6],              # \
    ecn: bits[2],               # / — group 1: 1 byte
    total_length: u16,          # — regular field, not grouped
    identification: u16,        # — regular field
    flags: bits[3],             # \
    fragment_offset: bits[13],  # / — group 2: 2 bytes (uint16_t)
    ttl: u8,                    # — regular field
    protocol: u8,               # — regular field
    @checksum(internet)
    header_checksum: u16,       # — regular field
    src_addr: u32,              # — regular field
    dst_addr: u32,              # — regular field
}

Each regular u8/u16/u32 field between the bit-field runs breaks the current group. The three groups are independent reads.

Using Bit Fields in Expressions

Bit fields extracted from groups are ordinary integer values in expressions. You can use them in require, let, if, and bytes[length:]:

wire
packet TcpSegment {
    # ...
    data_offset: bits[4],
    # ...
    require data_offset >= 5,
    options: bytes[length: data_offset * 4 - 20],
}

The data_offset * 4 - 20 expression computes the options length from the data offset field, which is fully resolved before the options field is parsed.

Tips

  • Group boundaries are automatic. You never declare groups explicitly — just write the bits[N] fields consecutively and wirespec groups them.
  • Non-bit fields break groups. If you need two separate byte reads, place a regular field between them.
  • Groups must not exceed 32 bits. A run of bit fields totalling more than 32 bits is a compile error.
  • The bit alias improves readability. Prefer syn: bit over syn: bits[1] for boolean flags.